diff --git a/db/20261102_add_mch_checkout_relation.sql b/db/20261102_add_mch_checkout_relation.sql new file mode 100644 index 00000000..34c17da7 --- /dev/null +++ b/db/20261102_add_mch_checkout_relation.sql @@ -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 = '商户结账当前有效绑定'; diff --git a/db/20261102_add_mch_checkout_relation_menu.sql b/db/20261102_add_mch_checkout_relation_menu.sql new file mode 100644 index 00000000..1bf7a0f2 --- /dev/null +++ b/db/20261102_add_mch_checkout_relation_menu.sql @@ -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 ; diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/mch/MchCheckoutRelationConstants.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/mch/MchCheckoutRelationConstants.java new file mode 100644 index 00000000..7d832c13 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/mch/MchCheckoutRelationConstants.java @@ -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() { + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/mch/MchCheckoutRelationMerchantState.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/mch/MchCheckoutRelationMerchantState.java new file mode 100644 index 00000000..188cc91a --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/mch/MchCheckoutRelationMerchantState.java @@ -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; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchCheckoutRelationMerchantQueryDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchCheckoutRelationMerchantQueryDto.java new file mode 100644 index 00000000..c0671715 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchCheckoutRelationMerchantQueryDto.java @@ -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; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchCheckoutRelationQueryDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchCheckoutRelationQueryDto.java new file mode 100644 index 00000000..0980d4cf --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchCheckoutRelationQueryDto.java @@ -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; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchCheckoutRelationSaveDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchCheckoutRelationSaveDto.java new file mode 100644 index 00000000..181eb056 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchCheckoutRelationSaveDto.java @@ -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; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutRelationEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutRelationEntity.java new file mode 100644 index 00000000..9e3d96a3 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutRelationEntity.java @@ -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; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutRelationMerchantEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutRelationMerchantEntity.java new file mode 100644 index 00000000..c4fcbd03 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchCheckoutRelationMerchantEntity.java @@ -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; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutRelationDeptVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutRelationDeptVo.java new file mode 100644 index 00000000..792fff79 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutRelationDeptVo.java @@ -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 children = new ArrayList<>(); +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutRelationMerchantVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutRelationMerchantVo.java new file mode 100644 index 00000000..406ffd15 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutRelationMerchantVo.java @@ -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; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutRelationPreviewVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutRelationPreviewVo.java new file mode 100644 index 00000000..759acbf7 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutRelationPreviewVo.java @@ -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 errors; + + /** 新增、保留及移除商户的预览行,前端本地分页展示。 */ + private List merchants; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutRelationVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutRelationVo.java new file mode 100644 index 00000000..0064a547 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/mch/MchCheckoutRelationVo.java @@ -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 merchants; +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchCheckoutRelationController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchCheckoutRelationController.java new file mode 100644 index 00000000..722f8b7a --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchCheckoutRelationController.java @@ -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(@ParameterObject Page 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 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> 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> merchantPage(@ParameterObject Page 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> 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 preview(@Valid @RequestBody MchCheckoutRelationSaveDto dto) { + return R.ok(relationService.preview(dto)); + } + + /** 创建关系和明确商户名单;日志记录请求及最终成功或错误。 */ + @PostMapping + @Operation(summary = "新增商户结账关系") + @SysLog("新增商户结账关系") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutRelation_add')") + public R create(@Valid @RequestBody MchCheckoutRelationSaveDto dto) { + return R.ok(relationService.create(dto)); + } + + /** 按原始版本更新关系及增减绑定,提交时重新检查预览后的数据变化。 */ + @PutMapping + @Operation(summary = "修改商户结账关系") + @SysLog("修改商户结账关系") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutRelation_edit')") + public R update(@Valid @RequestBody MchCheckoutRelationSaveDto dto) { + return R.ok(relationService.update(dto)); + } + + /** 按版本删除主体并释放整组绑定,无整组权限时拒绝。 */ + @DeleteMapping("/{id}") + @Operation(summary = "删除商户结账关系") + @SysLog("删除商户结账关系") + @PreAuthorize("@pms.hasPermission('wallet_mchCheckoutRelation_del')") + public R delete(@PathVariable @Positive Long id, @RequestParam @Min(0) Integer version) { + relationService.delete(id, version); + return R.ok(true); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutRelationMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutRelationMapper.java new file mode 100644 index 00000000..2fbe5b40 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutRelationMapper.java @@ -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 { + + /** 按创建时间及ID倒序分页;仅返回整组商户均可访问的有效关系。 */ + Page pageVisible(Page 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); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutRelationMerchantMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutRelationMerchantMapper.java new file mode 100644 index 00000000..0cc1dee3 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutRelationMerchantMapper.java @@ -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 { + + /** 读取指定租户关系的完整绑定(含失效项),按商户ID排序,调用前须完成整组授权。 */ + List listMembers(@Param("relationId") Long relationId); + + /** 读取明确商户ID(新旧名单并集最多2000),包含软删除;lock=true按ID顺序锁定商户及归属用于终审。 */ + List listExact(@Param("ids") List ids, @Param("lock") boolean lock); + + /** 分页查询授权范围内未删除的候选商户,部门交集为空时返回空,根店分店独立返回。 */ + Page pageCandidates(Page page, @Param("query") MchCheckoutRelationMerchantQueryDto query, @Param("scope") MchCheckoutRelationScope scope, @Param("deptIds") List deptIds); + + /** 完整解析可加入候选,按ID顺序最多取1001项以检测超限,禁止以单页冒充全量。 */ + List resolveCandidates(@Param("query") MchCheckoutRelationMerchantQueryDto query, @Param("scope") MchCheckoutRelationScope scope, @Param("deptIds") List deptIds); + + /** 返回当前授权且未删除商户的去重部门ID,不加载其他商户字段,用于部门树裁剪。 */ + List listAccessibleDeptIds(@Param("scope") MchCheckoutRelationScope scope); + + /** 批量写入新增绑定;租户列由公共拦截器自动补全,唯一键冲突直接抛出。 */ + int insertMembers(@Param("members") List members); + + /** 删除同租户同关系指定商户绑定;只用于已校验的差异,返回实际删除数量。 */ + int deleteMembers(@Param("relationId") Long relationId, @Param("ids") List ids); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchCheckoutRelationService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchCheckoutRelationService.java new file mode 100644 index 00000000..40987ac9 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchCheckoutRelationService.java @@ -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 page(Page page, MchCheckoutRelationQueryDto query); + + /** 返回主体、版本和完整名单(含失效项);无权与不存在使用同一提示。 */ + MchCheckoutRelationVo detail(Long id); + + /** 按授权商户所属部门构建树并补齐祖先,仅使用当前租户部门。 */ + List departmentTree(); + + /** 分页返回授权商户候选和可选状态,总店分店分别返回。 */ + Page merchantPage(Page page, MchCheckoutRelationMerchantQueryDto query); + + /** 完整解析筛选条件内可加入商户;超过1000家时拒绝,不截断。 */ + List resolve(MchCheckoutRelationMerchantQueryDto query); + + /** 预览主体及商户差异,不占用商户,编辑必须通过完整旧名单授权。 */ + MchCheckoutRelationPreviewVo preview(MchCheckoutRelationSaveDto dto); + + /** 本地事务创建主体和全部绑定,任何冲突整体回滚,返回新主体。 */ + MchCheckoutRelationVo create(MchCheckoutRelationSaveDto dto); + + /** 锁定主体和涉及商户,按版本及差异更新;任何失败整体回滚。 */ + MchCheckoutRelationVo update(MchCheckoutRelationSaveDto dto); + + /** 检查原完整名单并按版本删除,同事务释放绑定。 */ + void delete(Long id, Integer version); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchCheckoutRelationServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchCheckoutRelationServiceImpl.java new file mode 100644 index 00000000..01e6c92f --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchCheckoutRelationServiceImpl.java @@ -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 page(Page 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 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 departmentTree() { + MchCheckoutRelationScope scope = access.requireScope(); + Map catalog = departmentCatalog(); + Set visible = new LinkedHashSet<>(); + for (Long deptId : merchantMapper.listAccessibleDeptIds(scope)) { + Set 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 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 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 merchantPage(Page page, MchCheckoutRelationMerchantQueryDto query) { + MchCheckoutRelationScope scope = access.requireScope(); + normalizeMerchantQuery(query, scope); + List deptIds = expandDepartments(query); + Page 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 resolve(MchCheckoutRelationMerchantQueryDto query) { + MchCheckoutRelationScope scope = access.requireScope(); + normalizeMerchantQuery(query, scope); + List 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 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 selected = index(merchantMapper.listExact(dto.getMerchantIds(), false)); + Set originalIds = original.stream().map(MchCheckoutRelationMerchantVo::getMerchantId).collect(Collectors.toSet()); + Set selectedIds = new LinkedHashSet<>(dto.getMerchantIds()); + List 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 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 original = readMembers(id); + List ids = original.stream().map(MchCheckoutRelationMerchantVo::getMerchantId).toList(); + Map 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 original = creating ? List.of() : readMembers(dto.getId()); + Set oldIds = original.stream().map(MchCheckoutRelationMerchantVo::getMerchantId).collect(Collectors.toCollection(LinkedHashSet::new)); + Set allIds = new TreeSet<>(oldIds); + allIds.addAll(dto.getMerchantIds()); + // 锁定读取看到当前商户状态,避免REPEATABLE_READ旧快照把刚删除的商户当作有效。 + Map 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 added = dto.getMerchantIds().stream().filter(id -> !oldIds.contains(id)).toList(); + List 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 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 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 readMembers(Long id) { + List rows = merchantMapper.listMembers(id); + if (rows.size() > MAX_MERCHANTS) { + throw new IllegalStateException("原绑定名单超过首期上限,请先检查异常数据"); + } + return rows; + } + + /** 按实际删除数量检查绑定差异,防止并发或异常数据导致部分释放。 */ + private void removeMembers(Long relationId, List 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 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 index(List rows) { + return rows.stream().collect(Collectors.toMap(MchCheckoutRelationMerchantVo::getMerchantId, Function.identity())); + } + + /** 批量补全当前租户部门名称,远程失败必须提示,不能把网络异常冒充空数据。 */ + private void fillDepartmentNames(List rows) { + List ids = rows.stream().map(MchCheckoutRelationMerchantVo::getDeptId).filter(Objects::nonNull).distinct().toList(); + Map 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 departmentCatalog() { + List departments = requireRemote(remoteDeptService.getInnerAllDept(new DeptAllListDto()), "读取部门层级失败,请重试"); + return departments.stream().filter(item -> item.getDeptId() != null) + .collect(Collectors.toMap(SysDept::getDeptId, Function.identity())); + } + + /** 扩展指定部门;null表示不限,空集合表示没有命中,禁止将空交集退化为全部。 */ + private List 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 catalog = departmentCatalog(); + Set selected = new LinkedHashSet<>(query.getDeptIds()); + if (!catalog.keySet().containsAll(selected)) { + throw new IllegalArgumentException("所选部门已发生变化,请刷新部门树"); + } + if (query.isIncludeChildren()) { + Map> children = new HashMap<>(); + catalog.values().forEach(dept -> children.computeIfAbsent(dept.getParentId(), key -> new ArrayList<>()).add(dept.getDeptId())); + Deque 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 nodes, Map 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 requireRemote(R response, String message) { + if (response == null || !response.isOk() || response.getData() == null) { + throw new IllegalStateException(message); + } + return response.getData(); + } + + /** 重新构建受限分页对象,丢弃客户端排序及特殊分页开关,拒绝非法数量和偏移溢出。 */ + private Page safePage(Page 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 added, List removed, String operator) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** 事务提交后记录稳定ID及变更,不输出商户支付、卡密码等配置。 */ + @Override + public void afterCommit() { + log.info("商户结账关系变更 relationId={} version={} operator={} added={} removed={}", + relationId, version, operator, added, removed); + } + }); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationAccess.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationAccess.java new file mode 100644 index 00000000..dbd40fe6 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationAccess.java @@ -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); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationScope.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationScope.java new file mode 100644 index 00000000..d1209524 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationScope.java @@ -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 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())); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutRelationMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutRelationMapper.xml new file mode 100644 index 00000000..2dda255b --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutRelationMapper.xml @@ -0,0 +1,86 @@ + + + + + + + (1 = 0 + + OR m.dept_id IN + #{deptId} + + OR m.create_by = #{scope.username} + ) + + + + r.del_flag = '0' + + 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(, 0)) + ) + + + + + 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 + + + + + + + + + + + + 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 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' + + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutRelationMerchantMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutRelationMerchantMapper.xml new file mode 100644 index 00000000..71a4c000 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchCheckoutRelationMerchantMapper.xml @@ -0,0 +1,97 @@ + + + + + + + + AND (1 = 0 + + OR m.dept_id IN + #{deptId} + + OR m.create_by = #{scope.username} + ) + + + + + 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 + + + + FROM mch_manage m + LEFT JOIN mch_checkout_relation_merchant b ON b.merchant_id = m.id + WHERE m.del_flag = '0' + + + + + AND m.dept_id IN + #{deptId} + + AND 1 = 0 + + + + AND m.name LIKE CONCAT('%', #{query.merchantName}, '%') + + + AND FIND_IN_SET(#{query.merchantType}, m.types) > 0 + + + + + + + + + + + + + + + + INSERT INTO mch_checkout_relation_merchant (id, relation_id, merchant_id, create_by, create_time) + VALUES + + (#{member.id}, #{member.relationId}, #{member.merchantId}, #{member.createBy}, #{member.createTime}) + + + + + DELETE FROM mch_checkout_relation_merchant + WHERE relation_id = #{relationId} AND merchant_id IN + #{id} + + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutRelationDatabaseTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutRelationDatabaseTest.java new file mode 100644 index 00000000..4c90f72a --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/mapper/mch/MchCheckoutRelationDatabaseTest.java @@ -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 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 + ")"; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchCheckoutRelationServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchCheckoutRelationServiceImplTest.java new file mode 100644 index 00000000..0dc61403 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchCheckoutRelationServiceImplTest.java @@ -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> 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 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; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationAccessContextTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationAccessContextTest.java new file mode 100644 index 00000000..499865df --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationAccessContextTest.java @@ -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()); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationScopeTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationScopeTest.java new file mode 100644 index 00000000..edf8546f --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/support/mch/MchCheckoutRelationScopeTest.java @@ -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; + } +}