Merge remote-tracking branch 'origin/master'

This commit is contained in:
zjf committed 2026-11-02 19:59:48 +08:00
commit 23858b4d82
35 files changed
+2450 -21

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
-- 商户结账关系:在钱包业务库执行;微服务通常为 pjcloudx_wallet,单体以实际业务库为准。
-- 仅新增两表,不改写商户和历史结账数据。MySQL 8;重复执行不会重复建表。
-- 主表逻辑删除,绑定表仅保存当前有效占用;历史财务依据应另存账单快照。
CREATE TABLE IF NOT EXISTS `mch_checkout_relation`
(
`id` BIGINT NOT NULL COMMENT '关系主键,应用分配雪花ID',
`intermediary_name` VARCHAR(100) NOT NULL COMMENT '中间方名称,保存前去除首尾空格',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '关系备注',
`version` INT NOT NULL DEFAULT 0 COMMENT '并发版本,每次修改递增',
`tenant_id` BIGINT NOT NULL COMMENT '所属租户,服务端确定',
`del_flag` CHAR(1) NOT NULL DEFAULT '0' COMMENT '0有效,1已删除',
`active_name` VARCHAR(100) GENERATED ALWAYS AS
(CASE WHEN `del_flag` = '0' THEN `intermediary_name` ELSE NULL END) STORED COMMENT '有效名称唯一键,删除后释放',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人账号',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间,服务端时区',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '最后修改人账号',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最后修改时间,服务端时区',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_mch_checkout_relation_name` (`tenant_id`, `active_name`),
KEY `idx_mch_checkout_relation_page` (`tenant_id`, `del_flag`, `create_time`, `id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '商户结账关系主体';
CREATE TABLE IF NOT EXISTS `mch_checkout_relation_merchant`
(
`id` BIGINT NOT NULL COMMENT '绑定主键,应用分配雪花ID',
`relation_id` BIGINT NOT NULL COMMENT '当前结账关系ID,同租户主表主键',
`merchant_id` BIGINT NOT NULL COMMENT '商户ID,引用同租户mch_manage.id',
`tenant_id` BIGINT NOT NULL COMMENT '所属租户,服务端确定',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '本次绑定操作人账号',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '本次绑定时间,服务端时区',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_mch_checkout_relation_merchant` (`tenant_id`, `merchant_id`),
KEY `idx_mch_checkout_relation_members` (`tenant_id`, `relation_id`, `merchant_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '商户结账当前有效绑定';
@@ -0,0 +1,82 @@
-- 在权限库执行;先在同一会话显式设置以下两个变量,再执行本脚本:
-- SET @checkout_relation_tenant_id = 目标租户ID;
-- SET @checkout_relation_parent_id = 已存在的商户业务父菜单ID;
-- 不自动授予角色;执行后按需分配查看、新增、编辑、删除权限。
-- 沿用当前代码中菜单0、按钮1的持久化协议(历史表注释与实际代码不一致)。
DELIMITER $$
DROP PROCEDURE IF EXISTS `migrate_20261102_mch_checkout_relation_menu`$$
CREATE PROCEDURE `migrate_20261102_mch_checkout_relation_menu`()
BEGIN
DECLARE v_page_id BIGINT;
DECLARE v_count INT;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
IF @checkout_relation_tenant_id IS NULL OR @checkout_relation_tenant_id <= 0
OR @checkout_relation_parent_id IS NULL OR @checkout_relation_parent_id <= 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '请先显式设置目标租户和父菜单ID';
END IF;
START TRANSACTION;
-- 锁定同一父菜单,串行化重复或并发安装,不依赖sys_menu额外唯一索引。
SELECT menu_id INTO v_page_id FROM sys_menu
WHERE menu_id = @checkout_relation_parent_id AND tenant_id = @checkout_relation_tenant_id
AND del_flag = '0' AND menu_type = '0' FOR UPDATE;
IF v_page_id IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '父菜单不存在、租户不匹配或不是可用菜单';
END IF;
SELECT COUNT(*), MAX(menu_id) INTO v_count, v_page_id FROM sys_menu
WHERE tenant_id = @checkout_relation_tenant_id AND del_flag = '0'
AND path = '/mch/checkout/setting/index';
IF v_count > 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已存在重复结账关系菜单,请先人工检查';
END IF;
IF v_count = 1 AND NOT EXISTS (
SELECT 1 FROM sys_menu WHERE menu_id = v_page_id AND parent_id = @checkout_relation_parent_id
AND menu_type = '0' AND (component IS NULL OR component = 'mch/checkout/setting/index')
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已存在菜单位置或组件不符,请先人工检查';
END IF;
IF v_count = 0 THEN
SET v_page_id = UUID_SHORT();
INSERT INTO sys_menu (menu_id, name, path, component, parent_id, icon, visible, sort_order,
keep_alive, menu_type, create_by, create_time, update_by, update_time, del_flag, tenant_id)
VALUES (v_page_id, '商户结账关系', '/mch/checkout/setting/index', 'mch/checkout/setting/index',
@checkout_relation_parent_id, 'ele-Connection', '1', 80, '1', '0',
'admin', NOW(), 'admin', NOW(), '0', @checkout_relation_tenant_id);
END IF;
IF EXISTS (
SELECT 1 FROM sys_menu WHERE tenant_id = @checkout_relation_tenant_id AND del_flag = '0'
AND permission IN ('wallet_mchCheckoutRelation_view', 'wallet_mchCheckoutRelation_add',
'wallet_mchCheckoutRelation_edit', 'wallet_mchCheckoutRelation_del')
AND (parent_id <> v_page_id OR menu_type <> '1')
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '结账关系权限已被其他菜单占用,请先人工检查';
END IF;
INSERT INTO sys_menu (menu_id, name, permission, parent_id, visible, sort_order, keep_alive,
menu_type, create_by, create_time, update_by, update_time, del_flag, tenant_id)
SELECT UUID_SHORT(), p.name, p.permission, v_page_id, '1', p.sort_order, '0',
'1', 'admin', NOW(), 'admin', NOW(), '0', @checkout_relation_tenant_id
FROM (
SELECT '查看结账关系' name, 'wallet_mchCheckoutRelation_view' permission, 1 sort_order
UNION ALL SELECT '新增结账关系', 'wallet_mchCheckoutRelation_add', 2
UNION ALL SELECT '编辑结账关系', 'wallet_mchCheckoutRelation_edit', 3
UNION ALL SELECT '删除结账关系', 'wallet_mchCheckoutRelation_del', 4
) p
WHERE NOT EXISTS (
SELECT 1 FROM sys_menu m WHERE m.tenant_id = @checkout_relation_tenant_id
AND m.parent_id = v_page_id AND m.permission = p.permission AND m.del_flag = '0'
);
COMMIT;
END$$
CALL `migrate_20261102_mch_checkout_relation_menu`()$$
DROP PROCEDURE `migrate_20261102_mch_checkout_relation_menu`$$
DELIMITER ;
@@ -0,0 +1,14 @@
package com.bsd.pjcloud.hard.api.constant.order;
/**
* 食堂销售统计中已核销预约的日期归属口径,仅用于查询协议,不持久化到订单表。
* 线下购买始终按支付时间统计,不受此枚举影响。
*/
public enum HardCanteenReservationDateBasisEnum {
/** 按实际核销时间统计;未传口径或显式传空时沿用此历史行为。 */
VERIFICATION_TIME,
/** 仅保留已核销订单,按预约用餐日期统计;预约日期为空时不计入。 */
APPT_USE_DATE
}
@@ -16,7 +16,7 @@ public enum HardSaleSourceEnum {
/** 订单类型 1 的线下购买,统计时间取订单支付时间。 */
OFFLINE_PURCHASE("OFFLINE_PURCHASE", "线下购买"),
/** 订单类型 2 且已完成核销的预约,统计时间取订单核销时间。 */
/** 订单类型 2 且已完成核销的预约;默认按核销时间统计,也可由查询口径指定预约用餐日期。 */
RESERVATION_VERIFIED("RESERVATION_VERIFIED", "预约核销");
/** 统计接口和前端标签页使用的来源编码。 */
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.api.dto.order;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsDeepDeptTreeInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenReservationDateBasisEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
import lombok.Data;
@@ -37,9 +38,12 @@ public class HardCarDto extends HardCarEntity implements BaseIsIncludeTreeInterf
/** 预计使用日期范围。 */
private LocalDateTime[] apptUseDateTimeList;
/** 菜品销售统计日期范围,线下按支付时间、预约按核销时间过滤;不落库。 */
/** 菜品销售统计日期闭区间,线下按支付时间、已核销预约按 reservationDateBasis 选择的日期过滤,不落库。 */
private LocalDateTime[] statisticsDateTimeList;
/** 已核销预约的统计日期归属;默认或空值按核销时间,APPT_USE_DATE 按预约用餐日期,不落库。 */
private HardCanteenReservationDateBasisEnum reservationDateBasis = HardCanteenReservationDateBasisEnum.VERIFICATION_TIME;
/** 菜品销售统计来源编码集合,取值来自 HardSaleSourceEnum;为空表示全部来源。 */
private List<String> saleSourceCodes;
@@ -5,6 +5,7 @@ import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.common.core.constant.enums.DateRangeTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenReservationDateBasisEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import lombok.Data;
@@ -54,10 +55,13 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
private LocalDateTime[] payTimeList;
/**
* 食堂销售统计日期范围,线下来源按支付时间、预约来源按核销时间过滤;不落库。
* 食堂销售统计日期闭区间;线下按支付时间,已核销预约按 reservationDateBasis 选择的日期过滤,不落库。
*/
private LocalDateTime[] statisticsDateTimeList;
/** 已核销预约的统计日期归属;默认或空值按核销时间,APPT_USE_DATE 按预约用餐日期,不落库。 */
private HardCanteenReservationDateBasisEnum reservationDateBasis = HardCanteenReservationDateBasisEnum.VERIFICATION_TIME;
/**
* 食堂销售统计来源编码集合,取值来自 HardSaleSourceEnum;为空表示同时查询两种来源。
*/
@@ -32,6 +32,7 @@ import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenStatisticsLevelEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenReservationDateBasisEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardSaleSourceEnum;
import com.bsd.pjcloud.hard.api.constant.total.DateRangeTypeEnum;
@@ -124,6 +125,9 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
*/
private static final String DEPARTMENT_SALES_EXPORT_TASK_NAME = "食堂菜品部门统计导出";
/** 预约用餐日口径的导出文件前缀及任务展示名称,避免与核销日口径的文件混淆。 */
private static final String DINING_DATE_EXPORT_NAME = "食堂用餐日期统计";
/**
* 异步导出提交时使用的占位总数,后台开始后会替换为实际日期批次数。
*/
@@ -773,15 +777,17 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
}
/**
* 提交部门统计异步分批导出任务。
* 提交部门统计异步分批导出任务;预约用餐日口径使用独立的展示名称和文件前缀。
*
* @param dto 页面当前的统计日期、来源、餐段、食堂和部门筛选条件
* @param dto 页面当前的统计日期、预约日期口径、来源、餐段、食堂和部门筛选条件
* @return 可供页面轮询的初始任务进度
*/
@Override
public AsyncTaskProgressVO submitDepartmentSalesExport(HardOrderDto dto) {
return submitSalesExport(dto, DEPARTMENT_SALES_EXPORT_REDIS_KEY, DEPARTMENT_SALES_EXPORT_TASK_NAME,
"食堂菜品部门统计", DEPARTMENT_SALES_EXPORT_FILE_TYPE);
boolean diningDateBasis = dto != null && dto.getReservationDateBasis() == HardCanteenReservationDateBasisEnum.APPT_USE_DATE;
return submitSalesExport(dto, DEPARTMENT_SALES_EXPORT_REDIS_KEY,
diningDateBasis ? DINING_DATE_EXPORT_NAME + "导出" : DEPARTMENT_SALES_EXPORT_TASK_NAME,
diningDateBasis ? DINING_DATE_EXPORT_NAME : "食堂菜品部门统计", DEPARTMENT_SALES_EXPORT_FILE_TYPE);
}
/**
@@ -119,7 +119,7 @@
</sql>
<!--
来源分支可以安全下推的公共订单筛选。日期条件在各分支分别使用支付时间或核销时间,外层仍保留
来源分支可以安全下推的公共订单筛选。日期条件在各分支使用支付时间及所选预约日期口径,外层仍保留
同一组条件作为契约保护;这里先缩小订单和购物车连接范围,避免大日期表在派生表中无条件聚合。
-->
<sql id="canteenSaleSourceOrderPushdownWhere">
@@ -176,8 +176,16 @@
</if>
</sql>
<!-- 日期列仅从固定枚举分支选择,投影、退款范围和商品范围共用同一依据,避免提前核销漏扣退款。 -->
<sql id="canteenSaleReservationStatisticsTime">
<choose>
<when test="dto.reservationDateBasis != null and dto.reservationDateBasis.name() == 'APPT_USE_DATE'">o.appt_use_date</when>
<otherwise>o.verification_time</otherwise>
</choose>
</sql>
<!--
预约退款只聚合当前核销统计范围内订单涉及的父购物车,避免每次统计都扫描并汇总全部历史售后。
预约退款只聚合当前统计日期口径范围内订单涉及的父购物车,避免每次统计都扫描并汇总全部历史售后。
商品名称条件不在此应用,退款最终仍按父购物车和商品主键精确关联到已筛选的原购物车行。
-->
<sql id="canteenSaleReservationRefundTotals">
@@ -196,7 +204,7 @@
and o.hour_type is not null and o.hour_type not in ('-1')
where refund_item.del_flag = '0'
<if test="dto.statisticsDateTimeList != null and dto.statisticsDateTimeList.length == 2">
and o.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
and <include refid="canteenSaleReservationStatisticsTime"/> between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
</if>
<include refid="canteenSaleSourceOrderPushdownWhere"/>
group by refund_item.parent_car_id, refund_item.product_id
@@ -204,7 +212,7 @@
</sql>
<!--
新版食堂销售统计按业务发生时间拆分来源:线下订单使用支付时间,预约订单只使用已核销订单的核销时间。
新版食堂销售统计按日期归属拆分来源:线下使用支付时间,已核销预约默认用核销时间,也可按预约用餐日期。
单来源查询只生成对应分支;预约分支先按已核销订单过滤再聚合购物车并扣减退款,避免全量购物车聚合。
-->
<sql id="canteenSaleStatsSource">
@@ -242,8 +250,8 @@
union all
</if>
<if test="dto.saleSourceCodes == null or dto.saleSourceCodes.size() == 0 or dto.saleSourceCodes.contains('RESERVATION_VERIFIED')">
select DATE_FORMAT(o.verification_time, '%Y-%m-%d') as stat_date,
o.verification_time as stat_time,
select DATE_FORMAT(<include refid="canteenSaleReservationStatisticsTime"/>, '%Y-%m-%d') as stat_date,
<include refid="canteenSaleReservationStatisticsTime"/> as stat_time,
o.id as order_id,
o.user_id,
o.merchant_id as mch_id,
@@ -271,18 +279,18 @@
and refund_car.product_id = a.product_id
where a.del_flag = '0'
<if test="dto.statisticsDateTimeList != null and dto.statisticsDateTimeList.length == 2">
and o.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
and <include refid="canteenSaleReservationStatisticsTime"/> between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
</if>
<include refid="canteenSaleSourceOrderPushdownWhere"/>
<include refid="canteenSaleSourceProductPushdownWhere"/>
group by o.id, o.user_id, o.merchant_id, o.user_dept_id, o.dept_id, o.merchant_dept_id,
o.hour_type, o.verification_time, a.product_id, refund_car.product_count, refund_car.total_price
o.hour_type, <include refid="canteenSaleReservationStatisticsTime"/>, a.product_id, refund_car.product_count, refund_car.total_price
having sum(ifnull(a.product_count, 0)) > ifnull(refund_car.product_count, 0)
</if>
</trim>
</sql>
<!-- 新版统计统一在来源子查询外应用筛选,保证支付时间和核销时间使用同一日期控件。 -->
<!-- 新版统计统一在来源子查询外应用筛选,保证支付时间和所选预约日期口径使用同一日期控件。 -->
<sql id="canteenSaleStatsWhere">
<if test="dto.statisticsDateTimeList != null and dto.statisticsDateTimeList.length == 2">
and sale_stats.stat_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
@@ -287,7 +287,7 @@
</foreach>
or a.refund_status is null)
</if>
<!-- 统计人员明细必须按业务发生时间筛选:线下按支付时间,预约仅按已核销时间。 -->
<!-- 统计人员明细继承汇总口径:线下按支付时间,已核销预约按所选的核销时间或预约用餐日期。 -->
<if test="dto.statisticsDateTimeList != null and dto.statisticsDateTimeList.length == 2">
and
<choose>
@@ -302,7 +302,7 @@
and (a.appt_id is not null or a.appt_rule_id is not null)
and a.order_status = '20'
and a.verification_time is not null
and a.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
and <include refid="canteenSaleReservationStatisticsTime"/> between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
)
</when>
<otherwise>
@@ -314,13 +314,21 @@
and (a.appt_id is not null or a.appt_rule_id is not null)
and a.order_status = '20'
and a.verification_time is not null
and a.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]})
and <include refid="canteenSaleReservationStatisticsTime"/> between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]})
)
</otherwise>
</choose>
</if>
</sql>
<!-- 明细查询使用订单别名 a;与购物车统计保持相同的固定日期口径分支,不拼接外部列名。 -->
<sql id="canteenSaleReservationStatisticsTime">
<choose>
<when test="dto.reservationDateBasis != null and dto.reservationDateBasis.name() == 'APPT_USE_DATE'">a.appt_use_date</when>
<otherwise>a.verification_time</otherwise>
</choose>
</sql>
<!--
App 订单可见范围:本人订单;关系图谱用户(与钱包相关人列表同口径)的全部类型订单;
食堂预约订单额外扩展付款人和有效被监护人。管理端查询不设置 appViewerUserId,本片段不生效。
@@ -0,0 +1,207 @@
package com.bsd.pjcloud.hard.mapper.order;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenReservationDateBasisEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardSaleSourceEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardCarDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 使用真实 Mapper XML 生成动态 SQL,验证预约日期口径贯穿投影、筛选、分组、退款和订单明细。
* 不连接数据库;数据结果和 MySQL 执行计划仍需在联调环境验证。
*/
class HardCanteenReservationDateBasisSqlTest {
/** 统计查询 Mapper 的完整命名空间。 */
private static final String CAR_MAPPER = "com.bsd.pjcloud.hard.mapper.order.HardCarMapper.";
/** 订单明细查询 Mapper 的完整命名空间。 */
private static final String ORDER_MAPPER = "com.bsd.pjcloud.hard.mapper.order.HardOrderMapper.";
/** 每个用例独立解析真实 Mapper,防止动态 SQL 状态或映射注册互相影响。 */
private Configuration configuration;
/**
* 从应用资源加载真实 Mapper XML,包含其所有公共 SQL 片段。
*
* @throws Exception 资源不可读取或 MyBatis 解析失败时抛出
*/
@BeforeEach
void loadMappers() throws Exception {
configuration = new Configuration();
for (String mapper : List.of("HardCarMapper", "HardOrderMapper")) {
String path = "mapper/order/" + mapper + ".xml";
try (InputStream stream = Resources.getResourceAsStream(path)) {
new XMLMapperBuilder(stream, configuration, path, configuration.getSqlFragments()).parse();
}
}
}
/** 验证提前或延后核销均不限制核销时间范围,汇总、部门餐段和导出的日期统一取预约日。 */
@Test
void appointmentDateIsUsedByEveryStatisticsQueryAndRefundSubquery() {
HardOrderDto dto = query(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
for (String statement : List.of("listByDeptStats", "listByHourTypeStats", "listSalesExportRows")) {
String sql = sql(CAR_MAPPER + statement, dto);
assertTrue(sql.contains("date_format(o.appt_use_date,'%y-%m-%d') as stat_date"), statement);
assertTrue(sql.contains("o.appt_use_date as stat_time"), statement);
assertEquals(2, occurrences(sql, "o.appt_use_date between ? and ?"), statement);
assertFalse(sql.contains("o.verification_time between"), statement);
assertTrue(sql.contains("o.verification_time is not null"), statement);
assertTrue(sql.contains("o.hour_type,o.appt_use_date,a.product_id"), statement);
assertTrue(sql.contains("o.pay_time between ? and ?"), statement);
assertFalse(sql.contains("coalesce(o.appt_use_date"), statement);
}
}
/** 验证未选择新口径的历史请求和显式空值继续按核销时间查询,线下仍使用支付时间。 */
@Test
void defaultAndNullBasisKeepVerificationTimeBehavior() {
HardOrderDto defaults = new HardOrderDto();
assertEquals(HardCanteenReservationDateBasisEnum.VERIFICATION_TIME, defaults.getReservationDateBasis());
assertEquals(HardCanteenReservationDateBasisEnum.VERIFICATION_TIME, new HardCarDto().getReservationDateBasis());
for (HardCanteenReservationDateBasisEnum basis : new HardCanteenReservationDateBasisEnum[]{
defaults.getReservationDateBasis(), null}) {
String sql = sql(CAR_MAPPER + "listByDeptStats", query(basis));
assertTrue(sql.contains("date_format(o.verification_time,'%y-%m-%d') as stat_date"));
assertEquals(2, occurrences(sql, "o.verification_time between ? and ?"));
assertFalse(sql.contains("o.appt_use_date between"));
assertTrue(sql.contains("o.pay_time between ? and ?"));
}
}
/** 验证仅预约来源时不会拼接线下分支,退款完成状态及净份数、净金额扣减保持原有语义。 */
@Test
void reservationSourceKeepsCompletedRefundDeductionAndVerificationRequirement() {
HardOrderDto dto = query(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
dto.setSaleSourceCodes(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()));
String sql = sql(CAR_MAPPER + "listByDeptStats", dto);
assertFalse(sql.contains("'offline_purchase' as sale_source"));
assertTrue(sql.contains("o.order_status = '20'"));
assertTrue(sql.contains("o.verification_time is not null"));
assertTrue(sql.contains("refund_order.status = '50'"));
assertTrue(sql.contains("greatest(sum(ifnull(a.product_count,0)) - ifnull(refund_car.product_count,0),0)"));
assertTrue(sql.contains("greatest(sum(ifnull(a.total_price,0)) - ifnull(refund_car.total_price,0),0)"));
assertTrue(sql.contains("having sum(ifnull(a.product_count,0)) > ifnull(refund_car.product_count,0)"));
}
/** 验证纯线下查询不受预约日期口径影响,不额外生成预约或退款分支。 */
@Test
void offlineSourceAlwaysUsesPaymentTime() {
HardOrderDto dto = query(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
dto.setSaleSourceCodes(List.of(HardSaleSourceEnum.OFFLINE_PURCHASE.getCode()));
String sql = sql(CAR_MAPPER + "listByDeptStats", dto);
assertTrue(sql.contains("o.pay_time as stat_time"));
assertTrue(sql.contains("o.pay_time between ? and ?"));
assertFalse(sql.contains("o.appt_use_date"));
assertFalse(sql.contains("hard_after_sale_car"));
}
/** 验证菜品明细使用 HardCarDto 的同一口径,同时保留食堂、部门、餐段和商品筛选。 */
@Test
void productDetailsUseAppointmentDateAndOriginalDimensions() {
HardCarDto dto = new HardCarDto();
dto.setReservationDateBasis(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
dto.setStatisticsDateTimeList(query(null).getStatisticsDateTimeList());
dto.setSaleSourceCodes(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()));
dto.setMerchantId(10L);
dto.setUserDeptIds(List.of(20L));
dto.setIsInclude(true);
dto.setHourType("1");
dto.setProductName("测试菜品");
String sql = sql(CAR_MAPPER + "proudctQueryStats", dto);
assertEquals(2, occurrences(sql, "o.appt_use_date between ? and ?"));
assertFalse(sql.contains("o.verification_time between"));
assertTrue(sql.contains("o.merchant_id = ?"));
assertTrue(sql.contains("o.user_dept_id in"));
assertTrue(sql.contains("o.hour_type = ?"));
assertTrue(sql.contains("sale_stats.product_name like"));
}
/** 验证全部来源与单预约来源的订单明细均按预约日期过滤,同时要求已完成核销。 */
@Test
void orderDetailsShareAppointmentDateForBothSourceSelections() {
HardOrderDto dto = query(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
for (List<String> sources : List.of(dto.getSaleSourceCodes(), List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()))) {
dto.setSaleSourceCodes(sources);
String sql = sql(ORDER_MAPPER + "pageJoin", dto);
assertTrue(sql.contains("a.appt_use_date between ? and ?"));
assertTrue(sql.contains("a.verification_time is not null"));
assertTrue(sql.contains("a.order_status = '20'"));
assertFalse(sql.contains("a.verification_time between"));
}
dto.setReservationDateBasis(null);
assertTrue(sql(ORDER_MAPPER + "pageJoin", dto).contains("a.verification_time between ? and ?"));
}
/** 验证部门树的包含和排除继续约束主查询与退款子查询,合计人数仍由数据库独立去重。 */
@Test
void departmentScopeAndDistinctTotalsRemainEffective() {
HardOrderDto dto = query(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
dto.setUserDeptIds(List.of(20L));
dto.setIsInclude(true);
String included = sql(CAR_MAPPER + "listByDeptStats", dto);
assertTrue(included.contains("o.user_dept_id in"));
assertTrue(included.contains("count(distinct sale_stats.user_id)"));
assertTrue(included.contains("count(distinct sale_stats.order_id)"));
dto.setIsInclude(false);
assertTrue(sql(CAR_MAPPER + "listByDeptStats", dto).contains("o.user_dept_id not in"));
}
/**
* 构造跨三天的查询,核销时间无独立限制,便于覆盖跨日核销的真实查询条件。
*
* @param basis 预约日期口径,允许空值以验证历史兼容
* @return 同时包含线下与预约来源的统计请求
*/
private HardOrderDto query(HardCanteenReservationDateBasisEnum basis) {
HardOrderDto dto = new HardOrderDto();
dto.setReservationDateBasis(basis);
dto.setStatisticsDateTimeList(new LocalDateTime[]{
LocalDateTime.of(2026, 11, 1, 0, 0), LocalDateTime.of(2026, 11, 3, 23, 59, 59)});
dto.setSaleSourceCodes(HardSaleSourceEnum.getCodes());
return dto;
}
/**
* 生成真实 MyBatis BoundSql 并仅规范化空白,避免依赖 XML 的缩进样式。
*
* @param statement 完整的 Mapper 方法标识
* @param dto 对应订单或菜品查询 DTO
* @return 小写且括号内部、逗号两侧没有多余空白的 SQL;保留运算符前后的分隔
*/
private String sql(String statement, Object dto) {
return configuration.getMappedStatement(statement).getBoundSql(Map.of("dto", dto)).getSql()
.replaceAll("\\s+", " ").replaceAll("\\(\\s+", "(").replaceAll("\\s+\\)", ")")
.replaceAll("\\s*,\\s*", ",").toLowerCase(Locale.ROOT);
}
/**
* 统计关键过滤条件出现次数,确保预约商品与退款子查询都采用同一日期范围。
*
* @param sql 已规范化的完整 SQL
* @param fragment 待检查的固定业务过滤片段
* @return 片段在 SQL 中出现的次数
*/
private int occurrences(String sql, String fragment) {
return (sql.length() - sql.replace(fragment, "").length()) / fragment.length();
}
}
@@ -15,9 +15,13 @@ import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResultAction;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenStatisticsLevelEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenReservationDateBasisEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardSaleSourceEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.HardCarDto;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
@@ -179,7 +183,7 @@ class HardCanteenTotalServiceImplTest {
departmentRow(HardCanteenStatisticsLevelEnum.HOUR_SOURCE, HardSaleSourceEnum.OFFLINE_PURCHASE, "1", 20L),
departmentRow(HardCanteenStatisticsLevelEnum.HOUR_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, "1", 20L)));
List<HardCanteenProductTotalVo> result = service.listByDept(dto, null, null);
List<HardCanteenProductTotalVo> result = service.listByDept(dto, mockDepartmentQueryUser(), null);
assertEquals(1, result.size());
assertEquals("当日合计", result.get(0).getStatisticsLevelName());
@@ -210,7 +214,7 @@ class HardCanteenTotalServiceImplTest {
departmentRow(HardCanteenStatisticsLevelEnum.HOUR_TOTAL, null, "1", 20L),
departmentRow(HardCanteenStatisticsLevelEnum.HOUR_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, "1", 20L)));
List<HardCanteenProductTotalVo> result = service.listByDept(dto, null, null);
List<HardCanteenProductTotalVo> result = service.listByDept(dto, mockDepartmentQueryUser(), null);
assertEquals(1, result.size());
assertTrue(result.get(0).getSourceRows().isEmpty());
@@ -350,6 +354,89 @@ class HardCanteenTotalServiceImplTest {
eq("食堂菜品部门统计导出"), eq(1), any(AsyncTaskResultAction.class));
}
/** 验证预约日期口径完整传入主表与部门餐段下钻查询,不在服务层退回核销日期。 */
@Test
void diningDateBasisReachesDepartmentAndMealQueries() {
HardOrderDto dto = statisticsDto();
dto.setReservationDateBasis(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
dto.setTotalIsFindChildren(true);
mockCanteenScope();
when(hardCarMapper.listByDeptStats(any(HardOrderDto.class), isNull())).thenReturn(List.of(
departmentRow(HardCanteenStatisticsLevelEnum.DATE_TOTAL, null, null, null)));
when(hardCarMapper.listByHourTypeStats(any(HardOrderDto.class))).thenReturn(List.of(
departmentRow(HardCanteenStatisticsLevelEnum.HOUR_TOTAL, null, "1", 20L)));
List<HardCanteenProductTotalVo> rows = service.listByDept(dto, mockDepartmentQueryUser(), null);
ArgumentCaptor<HardOrderDto> mainQuery = ArgumentCaptor.forClass(HardOrderDto.class);
ArgumentCaptor<HardOrderDto> childQuery = ArgumentCaptor.forClass(HardOrderDto.class);
verify(hardCarMapper).listByDeptStats(mainQuery.capture(), isNull());
verify(hardCarMapper).listByHourTypeStats(childQuery.capture());
assertEquals(HardCanteenReservationDateBasisEnum.APPT_USE_DATE, mainQuery.getValue().getReservationDateBasis());
assertEquals(HardCanteenReservationDateBasisEnum.APPT_USE_DATE, childQuery.getValue().getReservationDateBasis());
assertEquals(1, rows.get(0).getChildren().size());
assertEquals(rows.get(0).getCreateTime(), rows.get(0).getChildren().get(0).getCreateTime());
}
/** 验证菜品明细使用相同的预约日期口径及来源,仍走现有退款扣减统计查询。 */
@Test
void diningDateBasisReachesProductDetails() {
HardCarDto dto = new HardCarDto();
dto.setStatisticsDateTimeList(statisticsDto().getStatisticsDateTimeList());
dto.setReservationDateBasis(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
dto.setSaleSourceCodes(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()));
Page<HardCarVo> page = new Page<>(1, 10);
when(hardCarMapper.proudctQueryStats(any(Page.class), any(HardCarDto.class))).thenReturn(page);
service.proudctQuery(page, dto);
ArgumentCaptor<HardCarDto> query = ArgumentCaptor.forClass(HardCarDto.class);
verify(hardCarMapper).proudctQueryStats(eq(page), query.capture());
assertEquals(HardCanteenReservationDateBasisEnum.APPT_USE_DATE, query.getValue().getReservationDateBasis());
assertEquals(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()), query.getValue().getSaleSourceCodes());
verify(hardCarMapper, never()).proudctQuery(any(Page.class), any(HardCarDto.class));
}
/**
* 验证跨月导出冻结预约日期口径;提交后更改原始条件,不影响各自然日批次和文件命名。
*
* @throws Exception 执行捕获的后台任务失败时抛出
*/
@Test
void diningDateExportFreezesBasisAcrossMonthBoundary() throws Exception {
HardOrderDto dto = statisticsDto();
dto.setReservationDateBasis(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
dto.setStatisticsDateTimeList(new LocalDateTime[]{
LocalDateTime.of(2026, 10, 31, 0, 0), LocalDateTime.of(2026, 11, 2, 23, 59, 59)});
dto.setSaleSourceCodes(new ArrayList<>(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode())));
mockCanteenScope();
when(hardCarMapper.listSalesExportRows(any(HardOrderDto.class), isNull())).thenReturn(List.of());
when(remoteFileService.innerUpload(any(), eq("hard-export"), isNull(), eq("HARD_CANTEEN_DEPARTMENT_SALES_EXPORT")))
.thenReturn(R.ok(Map.of("url", "https://file.example/dining-date.xlsx")));
service.submitDepartmentSalesExport(dto);
dto.setReservationDateBasis(HardCanteenReservationDateBasisEnum.VERIFICATION_TIME);
dto.getSaleSourceCodes().clear();
dto.getStatisticsDateTimeList()[0] = LocalDateTime.of(2026, 11, 2, 0, 0);
ArgumentCaptor<AsyncTaskResultAction> action = ArgumentCaptor.forClass(AsyncTaskResultAction.class);
verify(asyncTaskExecutorService).submitWithResult(eq("hard:canteen-sales:export:department"),
eq("食堂用餐日期统计导出"), eq(1), action.capture());
AsyncTaskResult result = action.getValue().execute(mock(AsyncTaskProgressReporter.class));
ArgumentCaptor<HardOrderDto> batches = ArgumentCaptor.forClass(HardOrderDto.class);
verify(hardCarMapper, times(3)).listSalesExportRows(batches.capture(), isNull());
assertEquals(List.of(LocalDate.of(2026, 10, 31), LocalDate.of(2026, 11, 1), LocalDate.of(2026, 11, 2)),
batches.getAllValues().stream().map(batch -> batch.getStatisticsDateTimeList()[0].toLocalDate()).toList());
for (HardOrderDto batch : batches.getAllValues()) {
assertEquals(HardCanteenReservationDateBasisEnum.APPT_USE_DATE, batch.getReservationDateBasis());
assertEquals(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()), batch.getSaleSourceCodes());
assertEquals(batch.getStatisticsDateTimeList()[0].toLocalDate().atTime(23, 59, 59), batch.getStatisticsDateTimeList()[1]);
}
assertTrue(result.fileName().startsWith("食堂用餐日期统计_"));
assertEquals("https://file.example/dining-date.xlsx", result.fileUrl());
}
/**
* 构造覆盖 2026-08-24 完整自然日的来源统计请求。
*
@@ -395,6 +482,18 @@ class HardCanteenTotalServiceImplTest {
when(remoteDeptService.getDeptByDeptIds(List.of(20L))).thenReturn(R.ok(java.util.Map.of()));
}
/**
* 为部门统计提供真实调用必需的登录用户和自营食堂范围,避免测试使用空用户绕过权限前提。
*
* @return 可查询编号 10 食堂的模拟用户
*/
private PjcloudUser mockDepartmentQueryUser() {
PjcloudUser user = mock(PjcloudUser.class);
when(user.getId()).thenReturn(1L);
when(remoteMchService.getSelfMchIdList(eq(1L), any())).thenReturn(R.ok(List.of(10L)));
return user;
}
/**
* 构造部门统计指定展示层级的 Mapper 行。
*
@@ -0,0 +1,30 @@
package com.bsd.pjcloud.wallet.api.constant.mch;
/** 结账关系首期容量和状态协议;前后端容量必须一致。 */
public final class MchCheckoutRelationConstants {
/** 单组最多1000家商户;预览和完整解析禁止静默截断。 */
public static final int MAX_MERCHANTS = 1000;
/** 分页最大100条,避免候选表一次渲染全部商户。 */
public static final int MAX_PAGE_SIZE = 100;
/** 初始并发版本;后续每次编辑递增。 */
public static final int INITIAL_VERSION = 0;
/** 主体和商户表的有效删除标记。 */
public static final String ACTIVE = "0";
/** 本次新增的预览协议值。 */
public static final String ADDED = "ADDED";
/** 本次移除的预览协议值。 */
public static final String REMOVED = "REMOVED";
/** 本次保留的预览协议值。 */
public static final String RETAINED = "RETAINED";
/** 工具常量不允许实例化。 */
private MchCheckoutRelationConstants() {
}
}
@@ -0,0 +1,26 @@
package com.bsd.pjcloud.wallet.api.constant.mch;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/** 商户选择状态;枚举名称属于前后端协议,不写入商户主数据。 */
@Getter
@RequiredArgsConstructor
public enum MchCheckoutRelationMerchantState {
/** 当前无归属,可加入。 */
AVAILABLE("可加入"),
/** 属于正在编辑的关系,可以保留。 */
CURRENT("当前绑定"),
/** 已被其他关系占用,不返回其他关系的名称或ID。 */
BOUND("已绑定其他中间方,请先解除原绑定"),
/** 商户已逻辑删除,仅供有权限人员清理旧绑定。 */
DELETED("商户已删除,请移除该绑定"),
/** 商户记录物理缺失,仅全部数据范围人员可清理。 */
MISSING("商户不存在,请移除该绑定"),
/** 当前账号不可访问;响应不得包含商户名称及其他信息。 */
NO_PERMISSION("商户不可访问,请检查数据权限");
/** 状态的中文业务说明,供候选和预览展示。 */
private final String description;
}
@@ -0,0 +1,29 @@
package com.bsd.pjcloud.wallet.api.dto.mch;
import lombok.Data;
import jakarta.validation.constraints.*;
import java.util.List;
/** 部门级联商户筛选;同一模型用于分页与完整批量解析。 */
@Data
public class MchCheckoutRelationMerchantQueryDto {
/** 编辑中的关系ID,用于识别当前绑定,传入时必须具备整组权限。 */
@Positive
private Long relationId;
/** 所选部门ID,空值或空集合表示当前授权商户范围;最多1000个。 */
@Size(max = 1000)
private List<@NotNull @Positive Long> deptIds;
/** 是否包含选中部门下级,默认true;重叠部门展开后去重。 */
private boolean includeChildren = true;
/** 商户名称模糊搜索,空值表示不限。 */
@Size(max = 100)
private String merchantName;
/** 单个商户类型编码,对应merchant_type字典,空值表示全部。 */
@Size(max = 16)
private String merchantType;
}
@@ -0,0 +1,17 @@
package com.bsd.pjcloud.wallet.api.dto.mch;
import lombok.Data;
import jakarta.validation.constraints.Size;
/** 结账关系列表查询条件;范围和租户由服务端补充。 */
@Data
public class MchCheckoutRelationQueryDto {
/** 中间方名称模糊搜索,空值表示不限。 */
@Size(max = 100)
private String intermediaryName;
/** 绑定商户名称模糊搜索,空值表示不限。 */
@Size(max = 100)
private String merchantName;
}
@@ -0,0 +1,32 @@
package com.bsd.pjcloud.wallet.api.dto.mch;
import lombok.Data;
import jakarta.validation.constraints.*;
import java.util.List;
/** 管理端完整保存及预览请求;不接受租户和审计字段。 */
@Data
public class MchCheckoutRelationSaveDto {
/** 编辑关系主键;新增必须为空。 */
@Positive
private Long id;
/** 编辑时必填的原始版本,新增不填写;禁止负值。 */
@Min(0)
private Integer version;
/** 中间方名称,保存时去首尾空格,最多100字。 */
@NotBlank
@Size(max = 100)
private String intermediaryName;
/** 关系备注,最多500字,空值表示未填写。 */
@Size(max = 500)
private String remark;
/** 明确确认的商户ID集合,1至1000项,服务端再次去重和校验。 */
@NotEmpty
@Size(max = 1000)
private List<@NotNull @Positive Long> merchantIds;
}
@@ -0,0 +1,51 @@
package com.bsd.pjcloud.wallet.api.entity.mch;
import lombok.Data;
import com.baomidou.mybatisplus.annotation.*;
import com.bsd.pjcloud.common.core.util.TenantTable;
import java.time.LocalDateTime;
/** 商户结账关系主体;仅保存当前配置,历史结账应使用独立快照。 */
@Data
@TenantTable
@TableName("mch_checkout_relation")
public class MchCheckoutRelationEntity {
/** 关系主键,由平台雪花ID生成,作为后续结账引用的稳定标识。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 中间方名称,最多100字;保存前去首尾空格,同租户有效名称唯一。 */
private String intermediaryName;
/** 维护备注,最多500字,空值表示未填写。 */
private String remark;
/** 并发版本,新增为0;编辑和删除必须匹配客户端读到的版本。 */
@Version
private Integer version;
/** 租户主键,由平台多租户组件自动处理,不能为空,不接受客户端覆盖。 */
private Long tenantId;
/** 删除标记:0有效、1已删除;删除时同事务释放商户绑定。 */
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
/** 创建人账号,由公共字段填充器维护。 */
@TableField(fill = FieldFill.INSERT)
private String createBy;
/** 创建时间,使用服务端时区。 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/** 最后修改人账号,由公共字段填充器维护。 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
/** 最后修改时间,使用服务端时区。 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
@@ -0,0 +1,34 @@
package com.bsd.pjcloud.wallet.api.entity.mch;
import lombok.Data;
import com.baomidou.mybatisplus.annotation.*;
import com.bsd.pjcloud.common.core.util.TenantTable;
import java.time.LocalDateTime;
/** 商户当前有效结账归属;解绑实际删除本行,保留项不重建。 */
@Data
@TenantTable
@TableName("mch_checkout_relation_merchant")
public class MchCheckoutRelationMerchantEntity {
/** 本次绑定主键,由平台雪花ID生成。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 所属关系主键,必须与本行租户一致。 */
private Long relationId;
/** 商户主键,引用同租户mch_manage.id,总店与分店独立绑定。 */
private Long merchantId;
/** 所属租户,由平台多租户组件自动处理;租户与商户组合唯一。 */
private Long tenantId;
/** 本次绑定操作人账号,保留绑定不改写。 */
@TableField(fill = FieldFill.INSERT)
private String createBy;
/** 本次绑定时间,使用服务端时区。 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
}
@@ -0,0 +1,22 @@
package com.bsd.pjcloud.wallet.api.vo.mch;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/** 基于授权商户部门构建的树节点,祖先仅用于组织导航。 */
@Data
public class MchCheckoutRelationDeptVo {
/** 部门主键,前端保持字符串ID。 */
private Long id;
/** 父部门主键,0或缺失父节点作为根展示。 */
private Long parentId;
/** 部门名称,来自UPMS部门契约。 */
private String name;
/** 下级导航节点,默认空集合。 */
private List<MchCheckoutRelationDeptVo> children = new ArrayList<>();
}
@@ -0,0 +1,55 @@
package com.bsd.pjcloud.wallet.api.vo.mch;
import lombok.Data;
import com.fasterxml.jackson.annotation.JsonIgnore;
/** 商户选择和绑定预览的最小展示模型,不返回商户支付等配置。 */
@Data
public class MchCheckoutRelationMerchantVo {
/** 商户ID,即使商户物理缺失也保留绑定中的原始ID。 */
private Long merchantId;
/** 商户当前名称,物理缺失或无权访问时为空。 */
private String merchantName;
/** 当前所属部门ID,物理缺失或无权访问时为空。 */
private Long deptId;
/** 部门当前名称,无法匹配时显示未关联部门。 */
private String deptName;
/** 商户类型编码,逗号分隔,沿用merchant_type字典。 */
private String merchantTypes;
/** 父商户ID;平台根店标识沿用mch_manage.parent_id。 */
private Long parentId;
/** 候选状态:AVAILABLE、CURRENT、BOUND、DELETED、MISSING、NO_PERMISSION。 */
private String state;
/** 不可选或失效的文字说明,不泄露其他中间方名称。 */
private String message;
/** 是否可加入当前确认名单;已绑定其他关系或异常项为false。 */
private boolean selectable;
/** 预览变更类型:ADDED新增、REMOVED移除、RETAINED保留;候选查询为空。 */
private String change;
/** 内部存在性标识,来源商户表主键,不向客户端返回。 */
@JsonIgnore
private Long recordId;
/** 内部归属关系ID,用于冲突判断,不泄露其他关系标识。 */
@JsonIgnore
private Long boundRelationId;
/** 内部商户删除标记,供已删除绑定的清理校验。 */
@JsonIgnore
private String merchantDelFlag;
/** 内部商户创建人账号,仅用于本人数据权限。 */
@JsonIgnore
private String merchantCreateBy;
}
@@ -0,0 +1,27 @@
package com.bsd.pjcloud.wallet.api.vo.mch;
import lombok.Data;
import java.util.List;
/** 保存前预览;结果不占用商户,保存时必须重新检查。 */
@Data
public class MchCheckoutRelationPreviewVo {
/** 是否通过当前预览,false时展示异常且禁止保存。 */
private boolean valid;
/** 本次新增商户数量。 */
private int addedCount;
/** 本次移除商户数量。 */
private int removedCount;
/** 本次保留商户数量。 */
private int retainedCount;
/** 主体或名单校验错误,不包含无权访问的关系名称。 */
private List<String> errors;
/** 新增、保留及移除商户的预览行,前端本地分页展示。 */
private List<MchCheckoutRelationMerchantVo> merchants;
}
@@ -0,0 +1,37 @@
package com.bsd.pjcloud.wallet.api.vo.mch;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/** 结账关系主体及详情;分页不携带商户数组,详情返回完整名单。 */
@Data
public class MchCheckoutRelationVo {
/** 稳定的关系主键。 */
private Long id;
/** 中间方名称。 */
private String intermediaryName;
/** 维护备注,可为空。 */
private String remark;
/** 当前并发版本,编辑和删除原样提交。 */
private Integer version;
/** 当前绑定数量,包含待清理的失效项。 */
private Long merchantCount;
/** 失效绑定数量,包含软删除和物理缺失商户。 */
private Long invalidCount;
/** 关系创建时间,使用服务端时区。 */
private LocalDateTime createTime;
/** 关系最后修改时间,使用服务端时区。 */
private LocalDateTime updateTime;
/** 完整商户名单,仅详情接口填充,最多1000家。 */
private List<MchCheckoutRelationMerchantVo> merchants;
}
@@ -0,0 +1,113 @@
package com.bsd.pjcloud.wallet.controller.mch;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutRelationMerchantQueryDto;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutRelationQueryDto;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutRelationSaveDto;
import com.bsd.pjcloud.wallet.api.vo.mch.*;
import com.bsd.pjcloud.wallet.service.MchCheckoutRelationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 管理端商户结账关系接口,功能权限由注解检查,整组数据权限由Service统一终审。 */
@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/mch-checkout-relation")
@Tag(name = "商户结账关系管理", description = "mch-checkout-relation")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class MchCheckoutRelationController {
/** 关系维护服务,负责权限、预览及本地事务。 */
private final MchCheckoutRelationService relationService;
/** 分页读取当前账号整组可访问的关系,默认按创建时间及ID倒序。 */
@GetMapping("/page")
@Operation(summary = "分页查询商户结账关系")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutRelation_view')")
public R<Page<MchCheckoutRelationVo>> page(@ParameterObject Page<MchCheckoutRelationVo> page, @Valid @ParameterObject MchCheckoutRelationQueryDto query) {
return R.ok(relationService.page(page, query));
}
/** 读取主体、并发版本和完整绑定名单,含需清理的失效项。 */
@GetMapping("/{id}")
@Operation(summary = "查询商户结账关系详情")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutRelation_view', 'wallet_mchCheckoutRelation_edit')")
public R<MchCheckoutRelationVo> detail(@PathVariable @Positive Long id) {
return R.ok(relationService.detail(id));
}
/** 返回与商户数据权限一致的专用部门树,不使用当前用户直属部门树替代角色范围。 */
@GetMapping("/merchant-dept-tree")
@Operation(summary = "查询结账关系商户部门树")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutRelation_view', 'wallet_mchCheckoutRelation_add', 'wallet_mchCheckoutRelation_edit')")
public R<List<MchCheckoutRelationDeptVo>> departmentTree() {
return R.ok(relationService.departmentTree());
}
/** 查询明确部门范围内的候选,已绑定其他关系仍展示不可选提示但不泄露中间方信息。 */
@GetMapping("/merchant-page")
@Operation(summary = "分页查询结账候选商户")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutRelation_view', 'wallet_mchCheckoutRelation_add', 'wallet_mchCheckoutRelation_edit')")
public R<Page<MchCheckoutRelationMerchantVo>> merchantPage(@ParameterObject Page<MchCheckoutRelationMerchantVo> page, @Valid @ParameterObject MchCheckoutRelationMerchantQueryDto query) {
return R.ok(relationService.merchantPage(page, query));
}
/** 解析全部可加入商户,最多1000条,超量直接拒绝而非截取一页。 */
@PostMapping("/merchant-resolve")
@Operation(summary = "完整解析筛选范围内可加入商户")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutRelation_add', 'wallet_mchCheckoutRelation_edit')")
public R<List<MchCheckoutRelationMerchantVo>> resolve(@Valid @RequestBody MchCheckoutRelationMerchantQueryDto query) {
return R.ok(relationService.resolve(query));
}
/** 保存前预览变更及错误,预览不产生任何绑定占用。 */
@PostMapping("/preview")
@Operation(summary = "预览商户结账关系变更")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutRelation_add', 'wallet_mchCheckoutRelation_edit')")
public R<MchCheckoutRelationPreviewVo> preview(@Valid @RequestBody MchCheckoutRelationSaveDto dto) {
return R.ok(relationService.preview(dto));
}
/** 创建关系和明确商户名单;日志记录请求及最终成功或错误。 */
@PostMapping
@Operation(summary = "新增商户结账关系")
@SysLog("新增商户结账关系")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutRelation_add')")
public R<MchCheckoutRelationVo> create(@Valid @RequestBody MchCheckoutRelationSaveDto dto) {
return R.ok(relationService.create(dto));
}
/** 按原始版本更新关系及增减绑定,提交时重新检查预览后的数据变化。 */
@PutMapping
@Operation(summary = "修改商户结账关系")
@SysLog("修改商户结账关系")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutRelation_edit')")
public R<MchCheckoutRelationVo> update(@Valid @RequestBody MchCheckoutRelationSaveDto dto) {
return R.ok(relationService.update(dto));
}
/** 按版本删除主体并释放整组绑定,无整组权限时拒绝。 */
@DeleteMapping("/{id}")
@Operation(summary = "删除商户结账关系")
@SysLog("删除商户结账关系")
@PreAuthorize("@pms.hasPermission('wallet_mchCheckoutRelation_del')")
public R<Boolean> delete(@PathVariable @Positive Long id, @RequestParam @Min(0) Integer version) {
relationService.delete(id, version);
return R.ok(true);
}
}
@@ -0,0 +1,35 @@
package com.bsd.pjcloud.wallet.mapper.mch;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutRelationQueryDto;
import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutRelationEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutRelationVo;
import com.bsd.pjcloud.wallet.support.mch.MchCheckoutRelationScope;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
/** 结账关系主体访问;租户由公共拦截器自动隔离,列表在分页前检查整组数据权限。 */
@Mapper
public interface MchCheckoutRelationMapper extends PjcloudBaseMapper<MchCheckoutRelationEntity> {
/** 按创建时间及ID倒序分页;仅返回整组商户均可访问的有效关系。 */
Page<MchCheckoutRelationVo> pageVisible(Page<MchCheckoutRelationVo> page, @Param("query") MchCheckoutRelationQueryDto query, @Param("scope") MchCheckoutRelationScope scope);
/** 按租户和ID读取整组可见的有效主体;无权或不存在时均返回null。 */
MchCheckoutRelationVo findVisible(@Param("id") Long id, @Param("scope") MchCheckoutRelationScope scope);
/** 锁定同租户有效主体供编辑和删除串行执行;调用方随后必须校验完整旧名单权限。 */
MchCheckoutRelationEntity lockRelation(@Param("id") Long id);
/** 按数据库唯一键的同一字符比较规则检查名称,忽略编辑主体,不返回其他关系信息。 */
long countName(@Param("name") String name, @Param("excludeId") Long excludeId);
/** 以版本条件更新主体及审计字段,版本递增;返回1表示成功,0表示已变更或删除。 */
int updateVersioned(@Param("entity") MchCheckoutRelationEntity entity);
/** 按租户、ID及版本逻辑删除并递增版本;返回1成功,0表示并发变更。 */
int deleteVersioned(@Param("id") Long id, @Param("version") Integer version, @Param("operator") String operator, @Param("now") LocalDateTime now);
}
@@ -0,0 +1,38 @@
package com.bsd.pjcloud.wallet.mapper.mch;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutRelationMerchantQueryDto;
import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutRelationMerchantEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutRelationMerchantVo;
import com.bsd.pjcloud.wallet.support.mch.MchCheckoutRelationScope;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** 当前商户绑定访问;软删除和缺失商户保留在旧名单,写入受租户商户唯一键约束。 */
@Mapper
public interface MchCheckoutRelationMerchantMapper extends PjcloudBaseMapper<MchCheckoutRelationMerchantEntity> {
/** 读取指定租户关系的完整绑定(含失效项),按商户ID排序,调用前须完成整组授权。 */
List<MchCheckoutRelationMerchantVo> listMembers(@Param("relationId") Long relationId);
/** 读取明确商户ID(新旧名单并集最多2000),包含软删除;lock=true按ID顺序锁定商户及归属用于终审。 */
List<MchCheckoutRelationMerchantVo> listExact(@Param("ids") List<Long> ids, @Param("lock") boolean lock);
/** 分页查询授权范围内未删除的候选商户,部门交集为空时返回空,根店分店独立返回。 */
Page<MchCheckoutRelationMerchantVo> pageCandidates(Page<MchCheckoutRelationMerchantVo> page, @Param("query") MchCheckoutRelationMerchantQueryDto query, @Param("scope") MchCheckoutRelationScope scope, @Param("deptIds") List<Long> deptIds);
/** 完整解析可加入候选,按ID顺序最多取1001项以检测超限,禁止以单页冒充全量。 */
List<MchCheckoutRelationMerchantVo> resolveCandidates(@Param("query") MchCheckoutRelationMerchantQueryDto query, @Param("scope") MchCheckoutRelationScope scope, @Param("deptIds") List<Long> deptIds);
/** 返回当前授权且未删除商户的去重部门ID,不加载其他商户字段,用于部门树裁剪。 */
List<Long> listAccessibleDeptIds(@Param("scope") MchCheckoutRelationScope scope);
/** 批量写入新增绑定;租户列由公共拦截器自动补全,唯一键冲突直接抛出。 */
int insertMembers(@Param("members") List<MchCheckoutRelationMerchantEntity> members);
/** 删除同租户同关系指定商户绑定;只用于已校验的差异,返回实际删除数量。 */
int deleteMembers(@Param("relationId") Long relationId, @Param("ids") List<Long> ids);
}
@@ -0,0 +1,40 @@
package com.bsd.pjcloud.wallet.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutRelationMerchantQueryDto;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutRelationQueryDto;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutRelationSaveDto;
import com.bsd.pjcloud.wallet.api.vo.mch.*;
import java.util.List;
/** 结账关系管理契约;名单是明确商户集合,不随部门变更自动扩展。 */
public interface MchCheckoutRelationService {
/** 分页返回整组可见的有效关系,范围检查在数据库分页前执行。 */
Page<MchCheckoutRelationVo> page(Page<MchCheckoutRelationVo> page, MchCheckoutRelationQueryDto query);
/** 返回主体、版本和完整名单(含失效项);无权与不存在使用同一提示。 */
MchCheckoutRelationVo detail(Long id);
/** 按授权商户所属部门构建树并补齐祖先,仅使用当前租户部门。 */
List<MchCheckoutRelationDeptVo> departmentTree();
/** 分页返回授权商户候选和可选状态,总店分店分别返回。 */
Page<MchCheckoutRelationMerchantVo> merchantPage(Page<MchCheckoutRelationMerchantVo> page, MchCheckoutRelationMerchantQueryDto query);
/** 完整解析筛选条件内可加入商户;超过1000家时拒绝,不截断。 */
List<MchCheckoutRelationMerchantVo> resolve(MchCheckoutRelationMerchantQueryDto query);
/** 预览主体及商户差异,不占用商户,编辑必须通过完整旧名单授权。 */
MchCheckoutRelationPreviewVo preview(MchCheckoutRelationSaveDto dto);
/** 本地事务创建主体和全部绑定,任何冲突整体回滚,返回新主体。 */
MchCheckoutRelationVo create(MchCheckoutRelationSaveDto dto);
/** 锁定主体和涉及商户,按版本及差异更新;任何失败整体回滚。 */
MchCheckoutRelationVo update(MchCheckoutRelationSaveDto dto);
/** 检查原完整名单并按版本删除,同事务释放绑定。 */
void delete(Long id, Integer version);
}
@@ -0,0 +1,542 @@
package com.bsd.pjcloud.wallet.service.impl.mch;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.DeptAllListDto;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutRelationMerchantQueryDto;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutRelationQueryDto;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutRelationSaveDto;
import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutRelationEntity;
import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutRelationMerchantEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.*;
import com.bsd.pjcloud.wallet.api.constant.mch.MchCheckoutRelationMerchantState;
import com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutRelationMapper;
import com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutRelationMerchantMapper;
import com.bsd.pjcloud.wallet.service.MchCheckoutRelationService;
import com.bsd.pjcloud.wallet.support.mch.MchCheckoutRelationAccess;
import com.bsd.pjcloud.wallet.support.mch.MchCheckoutRelationScope;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.bsd.pjcloud.wallet.api.constant.mch.MchCheckoutRelationConstants.*;
/** 结账关系事务编排;读取使用一致快照,写入锁定当前行并以数据库唯一键兜底。 */
@Slf4j
@Service
@RequiredArgsConstructor
public class MchCheckoutRelationServiceImpl implements MchCheckoutRelationService {
/** 关系主体数据访问,含整组权限和版本条件。 */
private final MchCheckoutRelationMapper relationMapper;
/** 商户及当前绑定数据访问,所有写入受同一业务库事务保护。 */
private final MchCheckoutRelationMerchantMapper merchantMapper;
/** 当前用户角色范围计算器,不允许请求体覆盖权限。 */
private final MchCheckoutRelationAccess access;
/** UPMS部门契约,远程业务失败不能当作空部门或全部部门。 */
private final RemoteDeptService remoteDeptService;
/** 按授权整组条件分页,清除外部排序,只使用创建时间和主键的固定排序。 */
@Override
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public Page<MchCheckoutRelationVo> page(Page<MchCheckoutRelationVo> page, MchCheckoutRelationQueryDto query) {
MchCheckoutRelationScope scope = access.requireScope();
query.setIntermediaryName(trimToNull(query.getIntermediaryName()));
query.setMerchantName(trimToNull(query.getMerchantName()));
return relationMapper.pageVisible(safePage(page), query, scope);
}
/** 主体与名单在同一个一致快照读取,避免并发改组后泄露新加入的未授权商户。 */
@Override
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public MchCheckoutRelationVo detail(Long id) {
MchCheckoutRelationScope scope = access.requireScope();
MchCheckoutRelationVo result = requireVisible(id, scope);
List<MchCheckoutRelationMerchantVo> members = readMembers(id);
assertOriginalScope(members, scope);
members.forEach(item -> decorate(item, id));
fillDepartmentNames(members);
result.setMerchants(members);
return result;
}
/** 只保留可访问商户部门及其祖先;每条父链检测循环,异常时拒绝而非无限遍历。 */
@Override
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public List<MchCheckoutRelationDeptVo> departmentTree() {
MchCheckoutRelationScope scope = access.requireScope();
Map<Long, SysDept> catalog = departmentCatalog();
Set<Long> visible = new LinkedHashSet<>();
for (Long deptId : merchantMapper.listAccessibleDeptIds(scope)) {
Set<Long> chain = new HashSet<>();
Long cursor = deptId;
while (cursor != null && catalog.containsKey(cursor)) {
if (!chain.add(cursor)) {
throw new IllegalStateException("部门层级存在循环,请先检查部门数据");
}
visible.add(cursor);
cursor = catalog.get(cursor).getParentId();
}
}
Map<Long, MchCheckoutRelationDeptVo> nodes = new LinkedHashMap<>();
for (Long id : visible) {
SysDept dept = catalog.get(id);
MchCheckoutRelationDeptVo node = new MchCheckoutRelationDeptVo();
node.setId(id);
node.setParentId(dept.getParentId());
node.setName(dept.getName());
nodes.put(id, node);
}
List<MchCheckoutRelationDeptVo> roots = new ArrayList<>();
nodes.values().forEach(node -> {
MchCheckoutRelationDeptVo parent = nodes.get(node.getParentId());
if (parent == null) {
roots.add(node);
} else {
parent.getChildren().add(node);
}
});
sortDepartments(roots, catalog);
return roots;
}
/** 在当前授权范围内筛选商户,额外传入的编辑关系必须整组可见。 */
@Override
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public Page<MchCheckoutRelationMerchantVo> merchantPage(Page<MchCheckoutRelationMerchantVo> page, MchCheckoutRelationMerchantQueryDto query) {
MchCheckoutRelationScope scope = access.requireScope();
normalizeMerchantQuery(query, scope);
List<Long> deptIds = expandDepartments(query);
Page<MchCheckoutRelationMerchantVo> result = merchantMapper.pageCandidates(safePage(page), query, scope, deptIds);
result.getRecords().forEach(item -> decorate(item, query.getRelationId()));
fillDepartmentNames(result.getRecords());
return result;
}
/** 查询1001条探测超量;返回真实完整可加入集合,不受分页插件的1000条页上限截断。 */
@Override
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public List<MchCheckoutRelationMerchantVo> resolve(MchCheckoutRelationMerchantQueryDto query) {
MchCheckoutRelationScope scope = access.requireScope();
normalizeMerchantQuery(query, scope);
List<MchCheckoutRelationMerchantVo> rows = merchantMapper.resolveCandidates(query, scope, expandDepartments(query));
if (rows.size() > MAX_MERCHANTS) {
throw new IllegalArgumentException("筛选范围内可加入商户超过1000家,请缩小部门或搜索范围");
}
rows.forEach(item -> decorate(item, query.getRelationId()));
fillDepartmentNames(rows);
return rows;
}
/** 预览主体和明确名单,非法新增ID仅返回通用异常状态,不泄露无权商户信息。 */
@Override
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public MchCheckoutRelationPreviewVo preview(MchCheckoutRelationSaveDto request) {
MchCheckoutRelationSaveDto dto = normalizedSave(request);
MchCheckoutRelationScope scope = access.requireScope();
List<MchCheckoutRelationMerchantVo> original = new ArrayList<>();
if (dto.getId() != null) {
MchCheckoutRelationVo relation = requireVisible(dto.getId(), scope);
assertVersion(relation.getVersion(), dto.getVersion());
original = readMembers(dto.getId());
assertOriginalScope(original, scope);
} else if (dto.getVersion() != null) {
throw new IllegalArgumentException("新增关系不应携带版本号");
}
Map<Long, MchCheckoutRelationMerchantVo> selected = index(merchantMapper.listExact(dto.getMerchantIds(), false));
Set<Long> originalIds = original.stream().map(MchCheckoutRelationMerchantVo::getMerchantId).collect(Collectors.toSet());
Set<Long> selectedIds = new LinkedHashSet<>(dto.getMerchantIds());
List<MchCheckoutRelationMerchantVo> rows = new ArrayList<>();
int invalid = 0;
for (Long id : dto.getMerchantIds()) {
MchCheckoutRelationMerchantVo item = selected.getOrDefault(id, missing(id));
if (!scope.allows(item)) {
item = missing(id);
setState(item, MchCheckoutRelationMerchantState.NO_PERMISSION);
} else {
decorate(item, dto.getId());
}
if (!item.isSelectable()) {
invalid++;
}
item.setChange(originalIds.contains(id) ? RETAINED : ADDED);
rows.add(item);
}
for (MchCheckoutRelationMerchantVo item : original) {
if (!selectedIds.contains(item.getMerchantId())) {
decorate(item, dto.getId());
item.setChange(REMOVED);
rows.add(item);
}
}
List<String> errors = new ArrayList<>();
if (relationMapper.countName(dto.getIntermediaryName(), dto.getId()) > 0) {
errors.add("当前租户已存在同名中间方,请修改名称");
}
if (invalid > 0) {
errors.add("选中名单包含" + invalid + "家不可绑定商户,请处理后重新预览");
}
fillDepartmentNames(rows);
MchCheckoutRelationPreviewVo result = new MchCheckoutRelationPreviewVo();
result.setValid(errors.isEmpty());
result.setErrors(errors);
result.setMerchants(rows);
result.setAddedCount((int) rows.stream().filter(item -> ADDED.equals(item.getChange())).count());
result.setRemovedCount((int) rows.stream().filter(item -> REMOVED.equals(item.getChange())).count());
result.setRetainedCount((int) rows.stream().filter(item -> RETAINED.equals(item.getChange())).count());
return result;
}
/** 事务创建,名称和商户唯一键冲突均转换为业务提示并整体回滚。 */
@Override
@Transactional(rollbackFor = Exception.class, isolation = Isolation.READ_COMMITTED)
public MchCheckoutRelationVo create(MchCheckoutRelationSaveDto request) {
if (request.getId() != null || request.getVersion() != null) {
throw new IllegalArgumentException("新增关系不应携带ID或版本号");
}
return save(request, true);
}
/** 事务编辑,旧名单、移除项和目标名单均在行锁后重新校验。 */
@Override
@Transactional(rollbackFor = Exception.class, isolation = Isolation.READ_COMMITTED)
public MchCheckoutRelationVo update(MchCheckoutRelationSaveDto request) {
if (request.getId() == null || request.getVersion() == null) {
throw new IllegalArgumentException("编辑关系必须提供ID和版本号");
}
return save(request, false);
}
/** 锁主体后以排序后的商户行锁终审旧范围,逻辑删除和释放绑定一次提交。 */
@Override
@Transactional(rollbackFor = Exception.class, isolation = Isolation.READ_COMMITTED)
public void delete(Long id, Integer version) {
MchCheckoutRelationScope scope = access.requireScope();
MchCheckoutRelationEntity relation = requireLocked(id);
List<MchCheckoutRelationMerchantVo> original = readMembers(id);
List<Long> ids = original.stream().map(MchCheckoutRelationMerchantVo::getMerchantId).toList();
Map<Long, MchCheckoutRelationMerchantVo> locked = ids.isEmpty() ? Map.of() : index(merchantMapper.listExact(ids, true));
assertOriginalScope(ids.stream().map(key -> locked.getOrDefault(key, missing(key))).toList(), scope);
assertVersion(relation.getVersion(), version);
String operator = SecurityUtils.getUser().getUsername();
if (relationMapper.deleteVersioned(id, version, operator, LocalDateTime.now()) != 1) {
throw new IllegalStateException("关系已发生变化,请刷新后重试");
}
removeMembers(id, ids);
auditAfterCommit(id, version + 1, List.of(), ids, operator);
}
/** 保存公共事务体,必须由带事务的公开入口调用;所有远程范围计算在本地行锁之前完成。 */
private MchCheckoutRelationVo save(MchCheckoutRelationSaveDto request, boolean creating) {
MchCheckoutRelationSaveDto dto = normalizedSave(request);
MchCheckoutRelationScope scope = access.requireScope();
MchCheckoutRelationEntity relation = creating ? new MchCheckoutRelationEntity() : requireLocked(dto.getId());
List<MchCheckoutRelationMerchantVo> original = creating ? List.of() : readMembers(dto.getId());
Set<Long> oldIds = original.stream().map(MchCheckoutRelationMerchantVo::getMerchantId).collect(Collectors.toCollection(LinkedHashSet::new));
Set<Long> allIds = new TreeSet<>(oldIds);
allIds.addAll(dto.getMerchantIds());
// 锁定读取看到当前商户状态,避免REPEATABLE_READ旧快照把刚删除的商户当作有效。
Map<Long, MchCheckoutRelationMerchantVo> locked = index(merchantMapper.listExact(new ArrayList<>(allIds), true));
assertOriginalScope(oldIds.stream().map(id -> locked.getOrDefault(id, missing(id))).toList(), scope);
if (!creating) {
assertVersion(relation.getVersion(), dto.getVersion());
}
for (Long id : dto.getMerchantIds()) {
MchCheckoutRelationMerchantVo item = locked.getOrDefault(id, missing(id));
if (!scope.allows(item)) {
throw new IllegalStateException("选中商户包含不可访问的记录,请检查数据权限");
}
decorate(item, dto.getId());
if (!item.isSelectable()) {
throw new IllegalStateException("商户" + id + ":" + item.getMessage() + ",请重新预览");
}
}
String operator = SecurityUtils.getUser().getUsername();
LocalDateTime now = LocalDateTime.now();
relation.setIntermediaryName(dto.getIntermediaryName());
relation.setRemark(dto.getRemark());
relation.setUpdateBy(operator);
relation.setUpdateTime(now);
List<Long> added = dto.getMerchantIds().stream().filter(id -> !oldIds.contains(id)).toList();
List<Long> removed = oldIds.stream().filter(id -> !dto.getMerchantIds().contains(id)).toList();
try {
if (creating) {
relation.setVersion(INITIAL_VERSION);
relation.setDelFlag(ACTIVE);
if (relationMapper.insert(relation) != 1) {
throw new IllegalStateException("创建结账关系失败");
}
} else if (relationMapper.updateVersioned(relation) != 1) {
throw new IllegalStateException("关系已发生变化,请刷新后重试");
}
removeMembers(relation.getId(), removed);
if (!added.isEmpty()) {
List<MchCheckoutRelationMerchantEntity> inserts = new ArrayList<>();
for (Long id : added) {
MchCheckoutRelationMerchantEntity member = new MchCheckoutRelationMerchantEntity();
member.setId(IdWorker.getId());
member.setRelationId(relation.getId());
member.setMerchantId(id);
member.setCreateBy(operator);
member.setCreateTime(now);
inserts.add(member);
}
if (merchantMapper.insertMembers(inserts) != inserts.size()) {
throw new IllegalStateException("商户绑定写入不完整,请重试");
}
}
} catch (DuplicateKeyException exception) {
String cause = exception.getMostSpecificCause().getMessage();
String message = cause != null && cause.contains("uk_mch_checkout_relation_name")
? "当前租户已存在同名中间方,请修改名称"
: "商户归属已发生变化,请重新预览后保存";
throw new IllegalStateException(message, exception);
}
int version = creating ? INITIAL_VERSION : dto.getVersion() + 1;
auditAfterCommit(relation.getId(), version, added, removed, operator);
MchCheckoutRelationVo result = relationMapper.findVisible(relation.getId(), scope);
if (result == null) {
throw new IllegalStateException("保存后的关系无法读取,已取消本次保存");
}
return result;
}
/** 仅允许完整旧名单均可访问;判断使用原始商户字段,不能依赖前端隐藏按钮。 */
private void assertOriginalScope(List<MchCheckoutRelationMerchantVo> members, MchCheckoutRelationScope scope) {
if (members.stream().anyMatch(item -> !scope.allows(item))) {
throw new IllegalStateException("关系不存在或没有整组商户的访问权限");
}
}
/** 读取整组可见主体,统一隐藏不存在与无权记录的差别。 */
private MchCheckoutRelationVo requireVisible(Long id, MchCheckoutRelationScope scope) {
if (id == null || id <= 0) {
throw new IllegalArgumentException("关系ID无效");
}
MchCheckoutRelationVo relation = relationMapper.findVisible(id, scope);
if (relation == null) {
throw new IllegalStateException("关系不存在或没有整组商户的访问权限");
}
return relation;
}
/** 锁定主体仅用于事务写入,读取后仍须校验完整旧名单,禁止直接返回原始主体。 */
private MchCheckoutRelationEntity requireLocked(Long id) {
if (id == null || id <= 0) {
throw new IllegalArgumentException("关系ID无效");
}
MchCheckoutRelationEntity relation = relationMapper.lockRelation(id);
if (relation == null) {
throw new IllegalStateException("关系不存在或没有整组商户的访问权限");
}
return relation;
}
/** 读取完整原名单;异常超过上限时拒绝维护,不截断后覆盖。 */
private List<MchCheckoutRelationMerchantVo> readMembers(Long id) {
List<MchCheckoutRelationMerchantVo> rows = merchantMapper.listMembers(id);
if (rows.size() > MAX_MERCHANTS) {
throw new IllegalStateException("原绑定名单超过首期上限,请先检查异常数据");
}
return rows;
}
/** 按实际删除数量检查绑定差异,防止并发或异常数据导致部分释放。 */
private void removeMembers(Long relationId, List<Long> ids) {
if (!ids.isEmpty() && merchantMapper.deleteMembers(relationId, ids) != ids.size()) {
throw new IllegalStateException("原绑定已发生变化,请刷新后重试");
}
}
/** 复制并规范化保存输入,独立校验服务调用,不能只依赖Controller的Bean Validation。 */
private MchCheckoutRelationSaveDto normalizedSave(MchCheckoutRelationSaveDto input) {
String name = trimToNull(input.getIntermediaryName());
String remark = trimToNull(input.getRemark());
if (name == null || name.length() > 100 || (remark != null && remark.length() > 500)) {
throw new IllegalArgumentException("中间方名称须为1至100字,备注最多500字");
}
List<Long> ids = input.getMerchantIds();
if (ids == null || ids.isEmpty() || ids.size() > MAX_MERCHANTS
|| ids.stream().anyMatch(id -> id == null || id <= 0)) {
throw new IllegalArgumentException("请明确选择1至1000家有效商户");
}
if ((input.getId() != null && input.getId() <= 0) || (input.getVersion() != null && input.getVersion() < 0)) {
throw new IllegalArgumentException("关系ID或版本号无效");
}
MchCheckoutRelationSaveDto result = new MchCheckoutRelationSaveDto();
result.setId(input.getId());
result.setVersion(input.getVersion());
result.setIntermediaryName(name);
result.setRemark(remark);
result.setMerchantIds(ids.stream().distinct().sorted().toList());
return result;
}
/** 校验编辑版本,拒绝旧数据及版本溢出,写入SQL仍保留CAS条件。 */
private void assertVersion(Integer actual, Integer expected) {
if (actual == null || actual == Integer.MAX_VALUE || expected == null || !Objects.equals(actual, expected)) {
throw new IllegalStateException("关系已发生变化,请刷新后重新编辑");
}
}
/** 标记商户可选状态;顺序先检查存在性和删除状态,再检查归属。 */
private void decorate(MchCheckoutRelationMerchantVo item, Long relationId) {
MchCheckoutRelationMerchantState state;
if (item.getRecordId() == null) {
state = MchCheckoutRelationMerchantState.MISSING;
} else if (!ACTIVE.equals(item.getMerchantDelFlag())) {
state = MchCheckoutRelationMerchantState.DELETED;
} else if (item.getBoundRelationId() == null) {
state = MchCheckoutRelationMerchantState.AVAILABLE;
} else if (relationId != null && Objects.equals(item.getBoundRelationId(), relationId)) {
state = MchCheckoutRelationMerchantState.CURRENT;
} else {
state = MchCheckoutRelationMerchantState.BOUND;
}
setState(item, state);
}
/** 填充前后端状态协议,只有空闲和当前归属允许进入保存名单。 */
private void setState(MchCheckoutRelationMerchantVo item, MchCheckoutRelationMerchantState state) {
item.setState(state.name());
item.setMessage(state.getDescription());
item.setSelectable(state == MchCheckoutRelationMerchantState.AVAILABLE || state == MchCheckoutRelationMerchantState.CURRENT);
}
/** 构造缺失记录占位,只保留客户端已知ID,不补出任何越权信息。 */
private MchCheckoutRelationMerchantVo missing(Long id) {
MchCheckoutRelationMerchantVo item = new MchCheckoutRelationMerchantVo();
item.setMerchantId(id);
return item;
}
/** 商户结果按ID建立索引,后续差异以明确ID而非名称或部门关联。 */
private Map<Long, MchCheckoutRelationMerchantVo> index(List<MchCheckoutRelationMerchantVo> rows) {
return rows.stream().collect(Collectors.toMap(MchCheckoutRelationMerchantVo::getMerchantId, Function.identity()));
}
/** 批量补全当前租户部门名称,远程失败必须提示,不能把网络异常冒充空数据。 */
private void fillDepartmentNames(List<MchCheckoutRelationMerchantVo> rows) {
List<Long> ids = rows.stream().map(MchCheckoutRelationMerchantVo::getDeptId).filter(Objects::nonNull).distinct().toList();
Map<Long, SysDept> departments = ids.isEmpty() ? Map.of()
: requireRemote(remoteDeptService.getDeptByDeptIds(ids), "读取商户部门失败,请重试");
for (MchCheckoutRelationMerchantVo row : rows) {
SysDept dept = row.getDeptId() == null ? null : departments.get(row.getDeptId());
if (dept != null) {
row.setDeptName(dept.getName());
} else if (!MchCheckoutRelationMerchantState.NO_PERMISSION.name().equals(row.getState())) {
row.setDeptName("未关联部门");
}
}
}
/** 获取同租户部门目录(含历史删除项),用于级联展开和已删除归属的名称展示。 */
private Map<Long, SysDept> departmentCatalog() {
List<SysDept> departments = requireRemote(remoteDeptService.getInnerAllDept(new DeptAllListDto()), "读取部门层级失败,请重试");
return departments.stream().filter(item -> item.getDeptId() != null)
.collect(Collectors.toMap(SysDept::getDeptId, Function.identity()));
}
/** 扩展指定部门;null表示不限,空集合表示没有命中,禁止将空交集退化为全部。 */
private List<Long> expandDepartments(MchCheckoutRelationMerchantQueryDto query) {
if (query.getDeptIds() == null || query.getDeptIds().isEmpty()) {
return null;
}
if (query.getDeptIds().size() > MAX_MERCHANTS || query.getDeptIds().stream().anyMatch(id -> id == null || id <= 0)) {
throw new IllegalArgumentException("部门选择无效或数量超过1000个");
}
Map<Long, SysDept> catalog = departmentCatalog();
Set<Long> selected = new LinkedHashSet<>(query.getDeptIds());
if (!catalog.keySet().containsAll(selected)) {
throw new IllegalArgumentException("所选部门已发生变化,请刷新部门树");
}
if (query.isIncludeChildren()) {
Map<Long, List<Long>> children = new HashMap<>();
catalog.values().forEach(dept -> children.computeIfAbsent(dept.getParentId(), key -> new ArrayList<>()).add(dept.getDeptId()));
Deque<Long> pending = new ArrayDeque<>(selected);
while (!pending.isEmpty()) {
for (Long child : children.getOrDefault(pending.removeFirst(), List.of())) {
if (selected.add(child)) {
pending.addLast(child);
}
}
}
}
return new ArrayList<>(selected);
}
/** 规范化商户搜索,校验单个类型编码,编辑上下文必须整组可见。 */
private void normalizeMerchantQuery(MchCheckoutRelationMerchantQueryDto query, MchCheckoutRelationScope scope) {
query.setMerchantName(trimToNull(query.getMerchantName()));
query.setMerchantType(trimToNull(query.getMerchantType()));
if (query.getMerchantType() != null && MerchantTypeEnum.valueOfByType(query.getMerchantType()) == null) {
throw new IllegalArgumentException("商户类型无效");
}
if (query.getRelationId() != null) {
requireVisible(query.getRelationId(), scope);
}
}
/** 为部门树按平台排序值及ID稳定排序,不依赖数据库返回顺序。 */
private void sortDepartments(List<MchCheckoutRelationDeptVo> nodes, Map<Long, SysDept> catalog) {
nodes.sort(Comparator.comparing((MchCheckoutRelationDeptVo node) -> Optional.ofNullable(catalog.get(node.getId()).getSortOrder()).orElse(0))
.thenComparing(MchCheckoutRelationDeptVo::getId));
nodes.forEach(node -> sortDepartments(node.getChildren(), catalog));
}
/** 检查Feign业务状态与数据,失败时抛错,保持选择和范围判断失败关闭。 */
private <T> T requireRemote(R<T> response, String message) {
if (response == null || !response.isOk() || response.getData() == null) {
throw new IllegalStateException(message);
}
return response.getData();
}
/** 重新构建受限分页对象,丢弃客户端排序及特殊分页开关,拒绝非法数量和偏移溢出。 */
private <T> Page<T> safePage(Page<T> page) {
if (page.getCurrent() < 1 || page.getSize() < 1 || page.getSize() > MAX_PAGE_SIZE) {
throw new IllegalArgumentException("分页参数无效,每页最多100条");
}
try {
Math.multiplyExact(page.getCurrent() - 1, page.getSize());
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("分页页码过大");
}
return new Page<>(page.getCurrent(), page.getSize());
}
/** 文本去首尾空格,空字符串统一为空值,数据库名称比较仍由唯一键决定。 */
private String trimToNull(String value) {
return value == null || value.trim().isEmpty() ? null : value.trim();
}
/** 只在事务成功提交后记录差异,避免将回滚操作写成已生效;不替代未来账单快照。 */
private void auditAfterCommit(Long relationId, int version, List<Long> added, List<Long> removed, String operator) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
/** 事务提交后记录稳定ID及变更,不输出商户支付、卡密码等配置。 */
@Override
public void afterCommit() {
log.info("商户结账关系变更 relationId={} version={} operator={} added={} removed={}",
relationId, version, operator, added, removed);
}
});
}
}
@@ -0,0 +1,41 @@
package com.bsd.pjcloud.wallet.support.mch;
import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService;
import com.bsd.pjcloud.common.core.constant.enums.UserTypeEnum;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.PjcloudDefaultDataScopeHandle;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.Objects;
/** 复用平台角色数据范围,禁止租户缺失或TOC客户端退化为无范围查询。 */
@Component
@RequiredArgsConstructor
public class MchCheckoutRelationAccess {
/** 已有的数据权限Feign契约;平台计算器由调用方创建,并非独立Spring Bean。 */
private final RemoteDataScopeService dataScopeService;
/** 计算当前管理用户的商户数据范围;无可用授权时明确拒绝。 */
public MchCheckoutRelationScope requireScope() {
PjcloudUser user = SecurityUtils.getUser();
Long tenantId = TenantContextHolder.getTenantId();
if (user == null || tenantId == null || tenantId <= 0
|| UserTypeEnum.TOC.getStatus().equals(user.getUserType())) {
throw new IllegalStateException("缺少有效的管理端租户上下文");
}
DataScope dataScope = DataScope.of().only(true);
boolean all = Boolean.TRUE.equals(new PjcloudDefaultDataScopeHandle(dataScopeService).calcScope(dataScope));
String username = StringUtils.hasText(dataScope.getUsername()) ? dataScope.getUsername() : null;
var deptIds = dataScope.getDeptList().stream().filter(Objects::nonNull).distinct().toList();
if (!all && deptIds.isEmpty() && username == null) {
throw new IllegalStateException("当前账号没有可用的商户数据权限");
}
return new MchCheckoutRelationScope(all, deptIds, username);
}
}
@@ -0,0 +1,35 @@
package com.bsd.pjcloud.wallet.support.mch;
import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutRelationMerchantVo;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.List;
import java.util.Objects;
/** 服务端计算的单次请求权限快照,不允许从请求体绑定或供客户端指定。 */
@Getter
@RequiredArgsConstructor
public class MchCheckoutRelationScope {
/** 是否拥有当前租户全部商户数据范围,允许处理物理缺失项。 */
private final boolean all;
/** 角色数据权限允许的部门ID集合,允许为空但不表示全部。 */
private final List<Long> deptIds;
/** 本人权限的账号;为空表示没有本人维度的授权。 */
private final String username;
/** 按商户原始部门和创建人校验范围,软删除不改变归属,物理缺失仅全部范围可处理。 */
public boolean allows(MchCheckoutRelationMerchantVo merchant) {
if (all) {
return true;
}
if (merchant == null || merchant.getRecordId() == null) {
return false;
}
return (merchant.getDeptId() != null && deptIds.contains(merchant.getDeptId()))
|| (username != null && Objects.equals(username, merchant.getMerchantCreateBy()));
}
}
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutRelationMapper">
<!-- 范围属于成员商户,不是关系创建人;NULL条件也按无权限处理,避免SQL三值逻辑漏掉异常行。 -->
<sql id="memberScope">
(1 = 0
<if test="scope.deptIds != null and scope.deptIds.size() > 0">
OR m.dept_id IN
<foreach collection="scope.deptIds" item="deptId" open="(" separator="," close=")">#{deptId}</foreach>
</if>
<if test="scope.username != null">OR m.create_by = #{scope.username}</if>
)
</sql>
<sql id="visibleWhere">
r.del_flag = '0'
<if test="!scope.all">
AND NOT EXISTS (
SELECT 1 FROM mch_checkout_relation_merchant b
LEFT JOIN mch_manage m ON m.id = b.merchant_id
WHERE b.relation_id = r.id
AND (m.id IS NULL OR NOT COALESCE(<include refid="memberScope"/>, 0))
)
</if>
</sql>
<sql id="relationColumns">
r.id, r.intermediary_name, r.remark, r.version, r.create_time, r.update_time,
(SELECT COUNT(*) FROM mch_checkout_relation_merchant b
WHERE b.relation_id = r.id) AS merchant_count,
(SELECT COUNT(*) FROM mch_checkout_relation_merchant b
LEFT JOIN mch_manage m ON m.id = b.merchant_id
WHERE b.relation_id = r.id
AND (m.id IS NULL OR COALESCE(m.del_flag, '1') != '0')) AS invalid_count
</sql>
<select id="pageVisible" resultType="com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutRelationVo">
SELECT <include refid="relationColumns"/> FROM mch_checkout_relation r
WHERE <include refid="visibleWhere"/>
<if test="query.intermediaryName != null and query.intermediaryName != ''">
AND r.intermediary_name LIKE CONCAT('%', #{query.intermediaryName}, '%')
</if>
<if test="query.merchantName != null and query.merchantName != ''">
AND EXISTS (
SELECT 1 FROM mch_checkout_relation_merchant b
JOIN mch_manage m ON m.id = b.merchant_id
WHERE b.relation_id = r.id
AND m.name LIKE CONCAT('%', #{query.merchantName}, '%')
)
</if>
ORDER BY r.create_time DESC, r.id DESC
</select>
<select id="findVisible" resultType="com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutRelationVo">
SELECT <include refid="relationColumns"/> FROM mch_checkout_relation r
WHERE r.id = #{id} AND <include refid="visibleWhere"/>
</select>
<select id="lockRelation" resultType="com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutRelationEntity">
SELECT id, intermediary_name, remark, version, tenant_id, del_flag,
create_by, create_time, update_by, update_time
FROM mch_checkout_relation
WHERE id = #{id} AND del_flag = '0' FOR UPDATE
</select>
<select id="countName" resultType="long">
SELECT COUNT(*) FROM mch_checkout_relation
WHERE active_name = #{name}
<if test="excludeId != null">AND id != #{excludeId}</if>
</select>
<update id="updateVersioned">
UPDATE mch_checkout_relation
SET intermediary_name = #{entity.intermediaryName}, remark = #{entity.remark},
version = version + 1, update_by = #{entity.updateBy}, update_time = #{entity.updateTime}
WHERE id = #{entity.id}
AND version = #{entity.version} AND del_flag = '0'
</update>
<update id="deleteVersioned">
UPDATE mch_checkout_relation
SET del_flag = '1', version = version + 1, update_by = #{operator}, update_time = #{now}
WHERE id = #{id} AND version = #{version} AND del_flag = '0'
</update>
</mapper>
@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutRelationMerchantMapper">
<!-- 复用平台已计算的角色范围,参数只能由服务端MchCheckoutRelationAccess构造。 -->
<sql id="merchantScope">
<if test="!scope.all">
AND (1 = 0
<if test="scope.deptIds != null and scope.deptIds.size() > 0">
OR m.dept_id IN
<foreach collection="scope.deptIds" item="deptId" open="(" separator="," close=")">#{deptId}</foreach>
</if>
<if test="scope.username != null">OR m.create_by = #{scope.username}</if>
)
</if>
</sql>
<sql id="merchantColumns">
m.id AS record_id, m.name AS merchant_name, m.dept_id, m.types AS merchant_types,
m.parent_id, m.del_flag AS merchant_del_flag, m.create_by AS merchant_create_by,
b.relation_id AS bound_relation_id
</sql>
<sql id="candidateFrom">
FROM mch_manage m
LEFT JOIN mch_checkout_relation_merchant b ON b.merchant_id = m.id
WHERE m.del_flag = '0'
<include refid="merchantScope"/>
<if test="deptIds != null">
<choose>
<when test="deptIds.size() > 0">
AND m.dept_id IN
<foreach collection="deptIds" item="deptId" open="(" separator="," close=")">#{deptId}</foreach>
</when>
<otherwise>AND 1 = 0</otherwise>
</choose>
</if>
<if test="query.merchantName != null and query.merchantName != ''">
AND m.name LIKE CONCAT('%', #{query.merchantName}, '%')
</if>
<if test="query.merchantType != null and query.merchantType != ''">
AND FIND_IN_SET(#{query.merchantType}, m.types) > 0
</if>
</sql>
<select id="listMembers" resultType="com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutRelationMerchantVo">
SELECT b.merchant_id, <include refid="merchantColumns"/>
FROM mch_checkout_relation_merchant b
LEFT JOIN mch_manage m ON m.id = b.merchant_id
WHERE b.relation_id = #{relationId}
ORDER BY b.merchant_id
</select>
<select id="listExact" resultType="com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutRelationMerchantVo">
SELECT m.id AS merchant_id, <include refid="merchantColumns"/>
FROM mch_manage m
LEFT JOIN mch_checkout_relation_merchant b ON b.merchant_id = m.id
WHERE m.id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
ORDER BY m.id
<if test="lock">FOR UPDATE</if>
</select>
<select id="pageCandidates" resultType="com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutRelationMerchantVo">
SELECT m.id AS merchant_id, <include refid="merchantColumns"/>
<include refid="candidateFrom"/>
ORDER BY m.id DESC
</select>
<select id="resolveCandidates" resultType="com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutRelationMerchantVo">
SELECT m.id AS merchant_id, <include refid="merchantColumns"/>
<include refid="candidateFrom"/>
AND (b.id IS NULL <if test="query.relationId != null">OR b.relation_id = #{query.relationId}</if>)
ORDER BY m.id LIMIT 1001
</select>
<select id="listAccessibleDeptIds" resultType="java.lang.Long">
SELECT DISTINCT m.dept_id FROM mch_manage m
WHERE m.del_flag = '0' AND m.dept_id IS NOT NULL
<include refid="merchantScope"/>
</select>
<insert id="insertMembers">
<!-- 不在业务SQL拼接租户列;平台TenantLine拦截器为批量VALUES逐行自动补全。 -->
INSERT INTO mch_checkout_relation_merchant (id, relation_id, merchant_id, create_by, create_time)
VALUES
<foreach collection="members" item="member" separator=",">
(#{member.id}, #{member.relationId}, #{member.merchantId}, #{member.createBy}, #{member.createTime})
</foreach>
</insert>
<delete id="deleteMembers">
DELETE FROM mch_checkout_relation_merchant
WHERE relation_id = #{relationId} AND merchant_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</delete>
</mapper>
@@ -0,0 +1,171 @@
package com.bsd.pjcloud.wallet.mapper.mch;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.UUID;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* 实际MySQL约束和事务验证,仅显式设置CHECKOUT_RELATION_TEST_JDBC_URL时执行。
* 用户和密码分别取同名前缀的JDBC_USER、JDBC_PASSWORD;必须使用隔离测试库。
* 读取实际迁移并替换为本次UUID表名,清理仅删除本测试创建的表,不触碰正式业务表。
*/
@EnabledIfEnvironmentVariable(named = "CHECKOUT_RELATION_TEST_JDBC_URL", matches = "jdbc:mysql:.*")
class MchCheckoutRelationDatabaseTest {
/** 实际迁移的UUID主体表,避免和其他运行或业务表冲突。 */
private static String relationTable;
/** 实际迁移的UUID绑定表,本测试结束时清理。 */
private static String merchantTable;
/** 仅通过测试库名称校验后才允许创建及清理随机表。 */
private static boolean fixtureAllowed;
/** 读取真实迁移并创建隔离表,重复建表验证迁移可重跑。 */
@BeforeAll
static void createFixture() throws Exception {
String suffix = UUID.randomUUID().toString().replace("-", "");
relationTable = "checkout_relation_test_" + suffix;
merchantTable = "checkout_member_test_" + suffix;
Path cursor = Path.of(System.getProperty("user.dir")).toAbsolutePath();
while (cursor != null && !Files.exists(cursor.resolve("db/20261102_add_mch_checkout_relation.sql"))) {
cursor = cursor.getParent();
}
assertNotNull(cursor, "需要从源码工程运行,以读取实际迁移文件");
String migration = Files.readString(cursor.resolve("db/20261102_add_mch_checkout_relation.sql"))
.replaceAll("(?m)^--.*$", "")
.replace("`mch_checkout_relation_merchant`", "`" + merchantTable + "`")
.replace("`mch_checkout_relation`", "`" + relationTable + "`");
try (Connection connection = connection()) {
String catalog = connection.getCatalog();
assertNotNull(catalog, "URL必须明确指定隔离测试库");
assertTrue(catalog.endsWith("_checkout_relation_test"), "测试库名必须以_checkout_relation_test结尾");
fixtureAllowed = true;
for (int repeat = 0; repeat < 2; repeat++) {
for (String statement : migration.split(";")) {
if (!statement.isBlank()) execute(connection, statement);
}
}
}
}
/** 仅清理本次随机表名,即使测试失败也不删除任何既存业务表。 */
@AfterAll
static void dropFixture() throws Exception {
if (!fixtureAllowed || relationTable == null || merchantTable == null) return;
try (Connection connection = connection()) {
execute(connection, "DROP TABLE IF EXISTS `" + merchantTable + "`");
execute(connection, "DROP TABLE IF EXISTS `" + relationTable + "`");
}
}
/** 两个真实连接并发占用同商户,第二个等待首事务提交后必须被唯一键拒绝。 */
@Test
void concurrentMerchantClaimsShouldAllowOnlyOneCommit() throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
try (Connection first = connection()) {
first.setAutoCommit(false);
execute(first, insertMember(101, 1, 901, 1));
CountDownLatch attempted = new CountDownLatch(1);
Future<String> second = executor.submit(() -> {
try (Connection next = connection()) {
attempted.countDown();
try {
execute(next, insertMember(102, 2, 901, 1));
return "unexpected-success";
} catch (SQLException exception) {
return exception.getSQLState();
}
}
});
assertTrue(attempted.await(5, TimeUnit.SECONDS));
first.commit();
assertEquals("23000", second.get(10, TimeUnit.SECONDS));
assertEquals(1, scalar(first, "SELECT COUNT(*) FROM `" + merchantTable + "` WHERE tenant_id=1 AND merchant_id=901"));
} finally {
executor.shutdownNow();
}
}
/** 有效名称使用真实数据库字符比较;软删除释放名称,不同租户可使用同名。 */
@Test
void softDeleteShouldReleaseNameWithoutLosingTenantIsolation() throws Exception {
try (Connection connection = connection()) {
execute(connection, insertRelation(201, "Name", 1));
assertThrows(SQLException.class, () -> execute(connection, insertRelation(202, "name", 1)));
execute(connection, insertRelation(203, "Name", 2));
execute(connection, "UPDATE `" + relationTable + "` SET del_flag='1' WHERE id=201");
execute(connection, insertRelation(202, "name", 1));
assertEquals(3, scalar(connection, "SELECT COUNT(*) FROM `" + relationTable + "` WHERE id IN (201,202,203)"));
}
}
/** 主体插入后绑定冲突,显式回滚必须同时撤销主体且保留原绑定。 */
@Test
void bindingFailureShouldRollBackParentInsert() throws Exception {
try (Connection connection = connection()) {
execute(connection, insertMember(301, 30, 902, 1));
connection.setAutoCommit(false);
execute(connection, insertRelation(302, "回滚名称", 1));
assertThrows(SQLException.class, () -> execute(connection, insertMember(303, 302, 902, 1)));
connection.rollback();
assertEquals(0, scalar(connection, "SELECT COUNT(*) FROM `" + relationTable + "` WHERE id=302"));
assertEquals(1, scalar(connection, "SELECT COUNT(*) FROM `" + merchantTable + "` WHERE id=301"));
}
}
/** 实际数据库版本条件阻止第二次使用旧版本覆盖。 */
@Test
void staleVersionShouldAffectNoRows() throws Exception {
try (Connection connection = connection()) {
execute(connection, insertRelation(401, "版本测试", 1));
String update = "UPDATE `" + relationTable + "` SET version=version+1 WHERE id=401 AND tenant_id=1 AND version=0 AND del_flag='0'";
assertEquals(1, execute(connection, update));
assertEquals(0, execute(connection, update));
assertEquals(1, scalar(connection, "SELECT version FROM `" + relationTable + "` WHERE id=401"));
}
}
/** 打开显式配置的测试连接,不从应用配置读取任何实际环境凭证。 */
private static Connection connection() throws SQLException {
String username = System.getenv("CHECKOUT_RELATION_TEST_JDBC_USER");
String password = System.getenv("CHECKOUT_RELATION_TEST_JDBC_PASSWORD");
return DriverManager.getConnection(System.getenv("CHECKOUT_RELATION_TEST_JDBC_URL"), username == null ? "" : username, password == null ? "" : password);
}
/** 执行隔离表SQL并返回实际影响行数,SQL仅包含固定文本及本测试生成的值。 */
private static int execute(Connection connection, String sql) throws SQLException {
try (var statement = connection.createStatement()) {
return statement.executeUpdate(sql);
}
}
/** 读取一个整型验证结果。 */
private static long scalar(Connection connection, String sql) throws SQLException {
try (var statement = connection.createStatement(); var result = statement.executeQuery(sql)) {
assertTrue(result.next());
return result.getLong(1);
}
}
/** 构造隔离主体数据,名称仅来自本测试固定字符串。 */
private static String insertRelation(long id, String name, long tenantId) {
return "INSERT INTO `" + relationTable + "` (id, intermediary_name, tenant_id) VALUES (" + id + ",'" + name + "'," + tenantId + ")";
}
/** 构造隔离绑定,不依赖任何正式商户或关系数据。 */
private static String insertMember(long id, long relationId, long merchantId, long tenantId) {
return "INSERT INTO `" + merchantTable + "` (id, relation_id, merchant_id, tenant_id) VALUES (" + id + "," + relationId + "," + merchantId + "," + tenantId + ")";
}
}
@@ -0,0 +1,326 @@
package com.bsd.pjcloud.wallet.service.impl.mch;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutRelationMerchantQueryDto;
import com.bsd.pjcloud.wallet.api.dto.mch.MchCheckoutRelationSaveDto;
import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutRelationEntity;
import com.bsd.pjcloud.wallet.api.entity.mch.MchCheckoutRelationMerchantEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.*;
import com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutRelationMapper;
import com.bsd.pjcloud.wallet.mapper.mch.MchCheckoutRelationMerchantMapper;
import com.bsd.pjcloud.wallet.support.mch.MchCheckoutRelationAccess;
import com.bsd.pjcloud.wallet.support.mch.MchCheckoutRelationScope;
import org.junit.jupiter.api.AfterEach;
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.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.LongStream;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 关系业务边界测试;Mapper为Mock,不将这些结果当作真实数据库事务或并发验证。 */
@ExtendWith(MockitoExtension.class)
class MchCheckoutRelationServiceImplTest {
/** 主体访问Mock,用于控制可见性、版本及名称冲突。 */
@Mock
private MchCheckoutRelationMapper relationMapper;
/** 商户及绑定访问Mock,捕获真实差异写入集合。 */
@Mock
private MchCheckoutRelationMerchantMapper merchantMapper;
/** 当前账号范围Mock,默认仅部门20。 */
@Mock
private MchCheckoutRelationAccess access;
/** 部门远程契约Mock,检查业务错误不能降级为空结果。 */
@Mock
private RemoteDeptService remoteDeptService;
/** 批量新增绑定捕获器,断言不会重建保留项。 */
@Captor
private ArgumentCaptor<List<MchCheckoutRelationMerchantEntity>> insertedMembers;
/** 本轮测试不可由客户端指定的授权快照。 */
private MchCheckoutRelationScope scope;
/** 被测服务,通过构造器装配现有依赖。 */
private MchCheckoutRelationServiceImpl service;
/** 初始化局部授权和线程登录态;同步回调只用于检查提交后日志注册。 */
@BeforeEach
void setUp() {
scope = new MchCheckoutRelationScope(false, List.of(20L), null);
lenient().when(access.requireScope()).thenReturn(scope);
service = new MchCheckoutRelationServiceImpl(relationMapper, merchantMapper, access, remoteDeptService);
PjcloudUser user = mock(PjcloudUser.class);
lenient().when(user.getUsername()).thenReturn("finance");
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, null, List.of()));
TransactionSynchronizationManager.initSynchronization();
}
/** 清理线程安全上下文和同步回调,不影响其他测试。 */
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
TransactionSynchronizationManager.clearSynchronization();
}
/** 越权新增ID只能在预览得到脱敏占位,不能暴露名称、部门或内部归属。 */
@Test
void previewShouldMaskUnauthorizedMerchant() {
when(merchantMapper.listExact(eq(List.of(5L)), eq(false))).thenReturn(List.of(merchant(5L, 99L, null)));
MchCheckoutRelationPreviewVo result = service.preview(draft(5L));
assertFalse(result.isValid());
assertEquals("NO_PERMISSION", result.getMerchants().get(0).getState());
assertNull(result.getMerchants().get(0).getMerchantName());
assertNull(result.getMerchants().get(0).getDeptId());
assertNull(result.getMerchants().get(0).getBoundRelationId());
verifyNoInteractions(remoteDeptService);
}
/** 即使主体查询被错误放行,服务仍检查完整旧名单,不能通过移除越权项绕过授权。 */
@Test
void updateShouldRejectRemovalOfUnauthorizedOriginalMember() {
when(relationMapper.lockRelation(10L)).thenReturn(entity(0));
when(merchantMapper.listMembers(10L)).thenReturn(List.of(merchant(5L, 99L, 10L)));
when(merchantMapper.listExact(eq(List.of(2L, 5L)), eq(true)))
.thenReturn(List.of(merchant(2L, 20L, null), merchant(5L, 99L, 10L)));
assertThrows(IllegalStateException.class, () -> service.update(editing(2L)));
verify(relationMapper, never()).updateVersioned(any());
verify(merchantMapper, never()).deleteMembers(any(), anyList());
verify(merchantMapper, never()).insertMembers(anyList());
}
/** 部分权限不能清理物理缺失的旧商户,即使它没有可读取的部门。 */
@Test
void deleteShouldRejectPhysicallyMissingMemberForLimitedScope() {
MchCheckoutRelationMerchantVo missing = new MchCheckoutRelationMerchantVo();
missing.setMerchantId(5L);
when(relationMapper.lockRelation(10L)).thenReturn(entity(0));
when(merchantMapper.listMembers(10L)).thenReturn(List.of(missing));
when(merchantMapper.listExact(eq(List.of(5L)), eq(true))).thenReturn(List.of());
assertThrows(IllegalStateException.class, () -> service.delete(10L, 0));
verify(relationMapper, never()).deleteVersioned(any(), any(), any(), any());
}
/** 原软删除项被明确移除时允许预览通过;不能因失效项属于REMOVED而要求保留。 */
@Test
void previewShouldAllowExplicitRemovalOfDeletedMember() {
MchCheckoutRelationMerchantVo deleted = merchant(1L, 20L, 10L);
deleted.setMerchantDelFlag("1");
when(relationMapper.findVisible(10L, scope)).thenReturn(visible(0));
when(merchantMapper.listMembers(10L)).thenReturn(List.of(deleted));
when(merchantMapper.listExact(eq(List.of(2L)), eq(false))).thenReturn(List.of(merchant(2L, 20L, null)));
when(remoteDeptService.getDeptByDeptIds(List.of(20L))).thenReturn(R.ok(Map.of(20L, department(20L, 0L))));
MchCheckoutRelationPreviewVo result = service.preview(editing(2L));
assertTrue(result.isValid());
assertEquals(1, result.getAddedCount());
assertEquals(1, result.getRemovedCount());
assertEquals("DELETED", result.getMerchants().get(1).getState());
assertEquals("REMOVED", result.getMerchants().get(1).getChange());
}
/** 商户已被其他关系占用时预览不可保存,且不暴露其他中间方名称。 */
@Test
void previewShouldRejectAnotherRelationsMerchant() {
when(merchantMapper.listExact(eq(List.of(2L)), eq(false))).thenReturn(List.of(merchant(2L, 20L, 88L)));
when(remoteDeptService.getDeptByDeptIds(List.of(20L))).thenReturn(R.ok(Map.of(20L, department(20L, 0L))));
MchCheckoutRelationPreviewVo result = service.preview(draft(2L));
assertFalse(result.isValid());
assertEquals("BOUND", result.getMerchants().get(0).getState());
assertFalse(result.getMerchants().get(0).isSelectable());
}
/** 旧版本编辑在任何写入前失败,不能覆盖当前主体或名单。 */
@Test
void updateShouldRejectStaleVersionBeforeWriting() {
when(relationMapper.lockRelation(10L)).thenReturn(entity(1));
when(merchantMapper.listMembers(10L)).thenReturn(List.of(merchant(1L, 20L, 10L)));
when(merchantMapper.listExact(eq(List.of(1L)), eq(true))).thenReturn(List.of(merchant(1L, 20L, 10L)));
assertThrows(IllegalStateException.class, () -> service.update(editing(1L)));
verify(relationMapper, never()).updateVersioned(any());
verify(merchantMapper, never()).insertMembers(anyList());
}
/** 编辑只移除3、增加2,保留1的原绑定创建信息,并在提交后才记录日志。 */
@Test
void updateShouldWriteOnlyExplicitDifference() {
when(relationMapper.lockRelation(10L)).thenReturn(entity(0));
when(merchantMapper.listMembers(10L)).thenReturn(List.of(merchant(1L, 20L, 10L), merchant(3L, 20L, 10L)));
when(merchantMapper.listExact(eq(List.of(1L, 2L, 3L)), eq(true)))
.thenReturn(List.of(merchant(1L, 20L, 10L), merchant(2L, 20L, null), merchant(3L, 20L, 10L)));
when(relationMapper.updateVersioned(any())).thenReturn(1);
when(merchantMapper.deleteMembers(10L, List.of(3L))).thenReturn(1);
when(merchantMapper.insertMembers(anyList())).thenReturn(1);
when(relationMapper.findVisible(10L, scope)).thenReturn(visible(1));
MchCheckoutRelationVo result = service.update(editing(2L, 1L, 1L));
assertEquals(1, result.getVersion());
verify(merchantMapper).insertMembers(insertedMembers.capture());
assertEquals(List.of(2L), insertedMembers.getValue().stream().map(MchCheckoutRelationMerchantEntity::getMerchantId).toList());
assertEquals(10L, insertedMembers.getValue().get(0).getRelationId());
verify(merchantMapper).deleteMembers(10L, List.of(3L));
assertEquals(1, TransactionSynchronizationManager.getSynchronizations().size());
TransactionSynchronizationManager.getSynchronizations().get(0).afterCommit();
}
/** 名称冲突返回中文业务提示,异常路径不继续新增任何商户绑定。 */
@Test
void duplicateNameShouldStopBeforeBinding() {
when(merchantMapper.listExact(eq(List.of(2L)), eq(true))).thenReturn(List.of(merchant(2L, 20L, null)));
when(relationMapper.insert(any(MchCheckoutRelationEntity.class))).thenThrow(new DuplicateKeyException("uk_mch_checkout_relation_name"));
IllegalStateException error = assertThrows(IllegalStateException.class, () -> service.create(draft(2L)));
assertTrue(error.getMessage().contains("同名"));
verify(merchantMapper, never()).insertMembers(anyList());
assertTrue(TransactionSynchronizationManager.getSynchronizations().isEmpty());
}
/** 部门完整解析探测到1001家时拒绝,不截取前1000条返回。 */
@Test
void resolveShouldRejectOverflowWithoutTruncation() {
when(merchantMapper.resolveCandidates(any(), eq(scope), isNull()))
.thenReturn(Collections.nCopies(1001, merchant(2L, 20L, null)));
assertThrows(IllegalArgumentException.class, () -> service.resolve(new MchCheckoutRelationMerchantQueryDto()));
verifyNoInteractions(remoteDeptService);
}
/** 无效部门不得清空过滤条件后查询全部候选。 */
@Test
void removedDepartmentShouldNotBecomeUnfilteredQuery() {
MchCheckoutRelationMerchantQueryDto query = new MchCheckoutRelationMerchantQueryDto();
query.setDeptIds(List.of(999L));
when(remoteDeptService.getInnerAllDept(any())).thenReturn(R.ok(List.of(department(20L, 0L))));
assertThrows(IllegalArgumentException.class, () -> service.merchantPage(new Page<>(1, 20), query));
verify(merchantMapper, never()).pageCandidates(any(), any(), any(), any());
}
/** 部门接口业务失败不能被误认为空目录继续查询。 */
@Test
void departmentFailureShouldFailClosed() {
when(remoteDeptService.getInnerAllDept(any())).thenReturn(R.failed("不可用"));
assertThrows(IllegalStateException.class, () -> service.departmentTree());
verifyNoInteractions(merchantMapper);
}
/** 树只保留授权商户部门及祖先,不受当前用户直属部门树裁剪影响。 */
@Test
void departmentTreeShouldKeepOnlyRelevantAncestors() {
when(remoteDeptService.getInnerAllDept(any())).thenReturn(R.ok(List.of(department(1L, 0L), department(20L, 1L), department(99L, 1L))));
when(merchantMapper.listAccessibleDeptIds(scope)).thenReturn(List.of(20L));
List<MchCheckoutRelationDeptVo> result = service.departmentTree();
assertEquals(1, result.size());
assertEquals(1L, result.get(0).getId());
assertEquals(List.of(20L), result.get(0).getChildren().stream().map(MchCheckoutRelationDeptVo::getId).toList());
}
/** 超出首期名单上限在任何数据库访问前拒绝。 */
@Test
void saveShouldRejectMoreThanOneThousandIds() {
MchCheckoutRelationSaveDto dto = draft(1L);
dto.setMerchantIds(LongStream.rangeClosed(1, 1001).boxed().toList());
assertThrows(IllegalArgumentException.class, () -> service.create(dto));
verifyNoInteractions(relationMapper, merchantMapper);
}
/** 构造新增草稿,带空格名称用于覆盖服务端规范化。 */
private MchCheckoutRelationSaveDto draft(Long... ids) {
MchCheckoutRelationSaveDto dto = new MchCheckoutRelationSaveDto();
dto.setIntermediaryName(" 甲中间方 ");
dto.setRemark("备注");
dto.setMerchantIds(List.of(ids));
return dto;
}
/** 构造关系10的初始版本编辑草稿。 */
private MchCheckoutRelationSaveDto editing(Long... ids) {
MchCheckoutRelationSaveDto dto = draft(ids);
dto.setId(10L);
dto.setVersion(0);
return dto;
}
/** 构造原始商户查询行,含内部权限字段,不直接视为已授权响应。 */
private MchCheckoutRelationMerchantVo merchant(Long id, Long deptId, Long relationId) {
MchCheckoutRelationMerchantVo row = new MchCheckoutRelationMerchantVo();
row.setMerchantId(id);
row.setRecordId(id);
row.setMerchantName("商户" + id);
row.setDeptId(deptId);
row.setMerchantCreateBy("other");
row.setMerchantDelFlag("0");
row.setBoundRelationId(relationId);
return row;
}
/** 构造租户1关系10的锁定主体。 */
private MchCheckoutRelationEntity entity(int version) {
MchCheckoutRelationEntity relation = new MchCheckoutRelationEntity();
relation.setId(10L);
relation.setTenantId(1L);
relation.setVersion(version);
return relation;
}
/** 构造同一主体的可见响应。 */
private MchCheckoutRelationVo visible(int version) {
MchCheckoutRelationVo relation = new MchCheckoutRelationVo();
relation.setId(10L);
relation.setVersion(version);
return relation;
}
/** 构造同租户部门及其导航父级。 */
private SysDept department(Long id, Long parentId) {
SysDept dept = new SysDept();
dept.setDeptId(id);
dept.setParentId(parentId);
dept.setTenantId(1L);
dept.setName("部门" + id);
return dept;
}
}
@@ -0,0 +1,27 @@
package com.bsd.pjcloud.wallet.support.mch;
import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService;
import com.bsd.pjcloud.common.data.datascope.DataScopeHandle;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
/** 权限组件装配回归,复现只有现有Feign依赖、没有独立DataScopeHandle Bean的实际配置。 */
class MchCheckoutRelationAccessContextTest {
/** 仅提供项目已有远程权限Bean即可启动权限组件,无需为接口额外注册全局Bean。 */
@Test
void accessComponentShouldStartWithExistingFeignDependency() {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.registerBean(RemoteDataScopeService.class, () -> mock(RemoteDataScopeService.class));
context.register(MchCheckoutRelationAccess.class);
assertDoesNotThrow(context::refresh);
assertNotNull(context.getBean(MchCheckoutRelationAccess.class));
assertTrue(context.getBeansOfType(DataScopeHandle.class).isEmpty());
}
}
}
@@ -0,0 +1,51 @@
package com.bsd.pjcloud.wallet.support.mch;
import com.bsd.pjcloud.wallet.api.vo.mch.MchCheckoutRelationMerchantVo;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/** 商户部门与本人范围的组合边界,验证失效绑定清理不会绕过权限。 */
class MchCheckoutRelationScopeTest {
/** 软删除商户仍按保留部门确定能否清理。 */
@Test
void deletedMerchantShouldKeepItsDepartmentScope() {
MchCheckoutRelationMerchantVo row = merchant(20L, "other");
row.setMerchantDelFlag("1");
assertTrue(new MchCheckoutRelationScope(false, List.of(20L), null).allows(row));
assertFalse(new MchCheckoutRelationScope(false, List.of(30L), null).allows(row));
}
/** 本人角色和部门角色按或关系组合,使用商户创建人而非关系创建人。 */
@Test
void ownerScopeShouldAllowOwnMerchantOutsideDepartment() {
MchCheckoutRelationScope scope = new MchCheckoutRelationScope(false, List.of(30L), "self");
assertTrue(scope.allows(merchant(20L, "self")));
assertFalse(scope.allows(merchant(20L, "other")));
}
/** 物理缺失行没有部门可验证,只允许全部数据范围处理。 */
@Test
void missingMerchantShouldRequireFullScope() {
assertFalse(new MchCheckoutRelationScope(false, List.of(20L), "self").allows(new MchCheckoutRelationMerchantVo()));
assertTrue(new MchCheckoutRelationScope(true, List.of(), null).allows(new MchCheckoutRelationMerchantVo()));
}
/** 部门及创建人都为空时不能因空集合或SQL空值语义而被放行。 */
@Test
void absentPermissionFieldsShouldDenyWithoutThrowing() {
assertFalse(new MchCheckoutRelationScope(false, List.of(), null).allows(merchant(null, null)));
}
/** 构造保留原始权限字段的商户行。 */
private MchCheckoutRelationMerchantVo merchant(Long deptId, String creator) {
MchCheckoutRelationMerchantVo row = new MchCheckoutRelationMerchantVo();
row.setRecordId(1L);
row.setDeptId(deptId);
row.setMerchantCreateBy(creator);
return row;
}
}