feat:结账关系维护
This commit is contained in:
1 parent
3b937f2e58
commit
78a3b9d123
26 files changed
+2079
No files matched your search
@@ -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 ;
|
||||
+30
@@ -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() {
|
||||
}
|
||||
}
|
||||
+26
@@ -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;
|
||||
}
|
||||
+29
@@ -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;
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
+22
@@ -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<>();
|
||||
}
|
||||
+55
@@ -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;
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
+113
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
@@ -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);
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
+542
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
@@ -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()));
|
||||
}
|
||||
}
|
||||
+86
@@ -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>
|
||||
+97
@@ -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>
|
||||
+171
@@ -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 + ")";
|
||||
}
|
||||
}
|
||||
+326
@@ -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;
|
||||
}
|
||||
}
|
||||
+27
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user