Просмотр исходного кода

feat(统一支付): 租户守卫与商户配置

alphaH 1 неделя назад
Родитель
Сommit
6a2aa08d95

+ 111 - 0
java/src/main/java/com/payment/platform/module/payment/unified/controller/UnifiedMerchantController.java

@@ -0,0 +1,111 @@
+package com.payment.platform.module.payment.unified.controller;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.module.payment.unified.dto.query.UnifiedMerchantQueryDTO;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedMerchantSaveDTO;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedMerchantVO;
+import com.payment.platform.module.payment.unified.service.UnifiedMerchantService;
+import com.payment.platform.module.payment.unified.util.UnifiedTenantGuard;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 统一支付商户配置(后台 JWT 端点)。
+ *
+ * <p>★ 本类**两种门禁并存**,别照着其中一个去改另一个:
+ * <ul>
+ *   <li>{@link #list} 是**权限串** {@code module_payment:unified:merchant:list} ——
+ *       租户本来就该看得见自己那几行,而查询本身被 {@code TenantInnerInterceptor}
+ *       钉在自己的租户内;</li>
+ *   <li>{@link #save} / {@link #delete} 是**超管身份** {@code @perm.hasRole('SUPERUSER')} ——
+ *       商户号在上游是人工报备后录入,租户自助建号等于允许抢注别人已报备的号
+ *       ({@code mch_no} 全局唯一)。</li>
+ * </ul>
+ * 三个方法第一行都是 {@link UnifiedTenantGuard#require()}:非超管且没有归属租户的主体
+ * 会被拦截器跳过过滤,必须在这里拒绝。
+ *
+ * <p>权限串与 Task 17 的 {@code sys_menu.permission} 是跨任务契约,须逐字一致
+ * ({@code PermissionCheck.hasAny} 是全等匹配,差一个字符即 403)。
+ */
+@Tag(name = "统一支付-商户配置")
+@RestController
+@RequestMapping("/payment/unified/merchant")
+@RequiredArgsConstructor
+public class UnifiedMerchantController {
+
+    private final UnifiedMerchantService service;
+    private final UnifiedTenantGuard guard;
+
+    /** 租户也能看自己的配置(只读),所以这一条是**权限串**,不是超管专用。 */
+    @GetMapping
+    @Operation(summary = "商户配置分页列表")
+    @PreAuthorize("@perm.hasAny('module_payment:unified:merchant:list')")
+    public Result<PageResult<UnifiedMerchantVO>> list(
+            @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
+            @RequestParam(name = "page_size", defaultValue = "20") int pageSize,
+            @RequestParam(name = "tenant_id", required = false) Long tenantId,
+            @RequestParam(name = "mch_no", required = false) String mchNo,
+            @RequestParam(name = "merchant_name", required = false) String merchantName,
+            @RequestParam(name = "status", required = false) String status) {
+        guard.require();
+
+        UnifiedMerchantQueryDTO q = new UnifiedMerchantQueryDTO();
+        q.setPageNo(pageNo);
+        q.setPageSize(pageSize);
+        q.setTenantId(tenantId);
+        q.setMchNo(mchNo);
+        q.setMerchantName(merchantName);
+        q.setStatus(status);
+        return Result.ok(service.list(q));
+    }
+
+    /**
+     * 新增或修改商户配置。**只有超管**:{@code mch_no} 全局唯一,放开租户自助建号
+     * 等于允许抢注别人已报备的商户号(与 HAR/乐收赢同一条裁定)。
+     *
+     * <p>新增必须带 {@code tenant_id}(超管替哪个租户建号);修改忽略它(归属不可改)。
+     * 应答是 {@link UnifiedMerchantVO}:不含密钥,只给 {@code appSecretSet}。
+     */
+    @PostMapping
+    @Operation(summary = "新增或修改商户配置(仅超管)")
+    @PreAuthorize("@perm.hasRole('SUPERUSER')")
+    public Result<UnifiedMerchantVO> save(@Valid @RequestBody UnifiedMerchantSaveDTO dto) {
+        guard.require();
+        return Result.ok(UnifiedMerchantVO.of(service.save(dto)));
+    }
+
+    /**
+     * 删除商户配置。**只有超管**,理由同 {@link #save}(删掉等于让该租户无法下单)。
+     *
+     * <p>已有订单/退款时服务层返回 400「已有交易,不能删除,请停用」。
+     * id 以字符串接收再解析:非数字/超长返回 400「无效的 id」而不是框架默认的 500。
+     */
+    @DeleteMapping("/{id}")
+    @Operation(summary = "删除商户配置(仅超管)")
+    @PreAuthorize("@perm.hasRole('SUPERUSER')")
+    public Result<Void> delete(@PathVariable(name = "id") String id) {
+        guard.require();
+
+        long parsed;
+        try {
+            parsed = Long.parseLong(id);
+        } catch (NumberFormatException e) {
+            throw new BusinessException(400, "无效的 id");
+        }
+        service.delete(parsed);
+        return Result.ok();
+    }
+}

+ 21 - 0
java/src/main/java/com/payment/platform/module/payment/unified/dto/query/UnifiedMerchantQueryDTO.java

@@ -0,0 +1,21 @@
+package com.payment.platform.module.payment.unified.dto.query;
+
+import lombok.Data;
+
+/**
+ * 商户配置分页查询条件。
+ *
+ * <p>{@code tenantId} 是给超管筛选用的一栏:租户侧的查询本来就被
+ * {@code TenantInnerInterceptor} 钉在自己的 {@code tenant_id} 上,传了也越不了权。
+ */
+@Data
+public class UnifiedMerchantQueryDTO {
+
+    private int pageNo = 1;
+    private int pageSize = 20;
+
+    private Long tenantId;
+    private String mchNo;
+    private String merchantName;
+    private String status;
+}

+ 44 - 0
java/src/main/java/com/payment/platform/module/payment/unified/dto/req/UnifiedMerchantSaveDTO.java

@@ -0,0 +1,44 @@
+package com.payment.platform.module.payment.unified.dto.req;
+
+import lombok.Data;
+
+/**
+ * 新增/修改统一支付商户配置的请求体。
+ *
+ * <p>字段是否必填**按分支不同**,所以不挂 Bean Validation 注解:
+ * <ul>
+ *   <li>新增:{@code tenantId} / {@code mchNo} / {@code appId} / {@code appSecret} 必填;</li>
+ *   <li>修改:{@code tenantId} 一律忽略(归属不可改),{@code appSecret} 留空表示不修改。</li>
+ * </ul>
+ * 「留空 = 不修改」与「新增必填」是互斥规则,注解表达不了,校验由
+ * {@code UnifiedMerchantService#save} 按分支执行。
+ */
+@Data
+public class UnifiedMerchantSaveDTO {
+
+    /** 为空表示新增,非空表示修改 */
+    private Long id;
+
+    /** 归属租户。新增时必填(超管替哪个租户建号),修改时忽略 */
+    private Long tenantId;
+
+    /** 上游商户号,全局唯一;上游通知只带它,无认证上下文靠它定位租户 */
+    private String mchNo;
+
+    /** 上游应用 ID */
+    private String appId;
+
+    /** MD5 私钥:不返回前端、尽量少经手;修改时留空表示保留原值 */
+    private String appSecret;
+
+    /** 显示名 */
+    private String merchantName;
+
+    /** 渠道用户 ID 跳转的 ifCode;留空由数据库默认值兜底({@code fuioupay}) */
+    private String channelUserIfCode;
+
+    /** {@code 0}=启用、{@code 1}=停用;留空由数据库默认值兜底({@code 0}) */
+    private String status;
+
+    private String description;
+}

+ 57 - 0
java/src/main/java/com/payment/platform/module/payment/unified/dto/vo/UnifiedMerchantVO.java

@@ -0,0 +1,57 @@
+package com.payment.platform.module.payment.unified.dto.vo;
+
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
+import lombok.Data;
+
+import java.time.OffsetDateTime;
+
+/**
+ * 商户配置的后台 VO。
+ *
+ * <p>★ **没有** {@code appSecret} 字段,只有布尔 {@code appSecretSet}:
+ * 密钥只用于上游签名,页面只需要知道「配没配」。
+ */
+@Data
+public class UnifiedMerchantVO {
+
+    /** 雪花 ID(19 位)超 JS 安全整数,序列化为字符串防前端精度丢失 */
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long id;
+
+    /** 超管列表据此分辨各行属于谁;租户号是自增小整数,不需要转字符串 */
+    private Long tenantId;
+
+    private String mchNo;
+    private String appId;
+    private String merchantName;
+    private String channelUserIfCode;
+    private String status;
+
+    /** 是否已配置密钥(查询时现算,绝不回传密钥本身) */
+    private boolean appSecretSet;
+
+    private String description;
+    private OffsetDateTime createdTime;
+    private OffsetDateTime updatedTime;
+
+    public static UnifiedMerchantVO of(UnifiedMerchantEntity e) {
+        if (e == null) {
+            return null;
+        }
+        UnifiedMerchantVO vo = new UnifiedMerchantVO();
+        vo.setId(e.getId());
+        vo.setTenantId(e.getTenantId());
+        vo.setMchNo(e.getMchNo());
+        vo.setAppId(e.getAppId());
+        vo.setMerchantName(e.getMerchantName());
+        vo.setChannelUserIfCode(e.getChannelUserIfCode());
+        vo.setStatus(e.getStatus());
+        vo.setAppSecretSet(e.getAppSecret() != null && !e.getAppSecret().isBlank());
+        vo.setDescription(e.getDescription());
+        vo.setCreatedTime(e.getCreatedTime());
+        vo.setUpdatedTime(e.getUpdatedTime());
+        return vo;
+    }
+}

+ 300 - 0
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedMerchantService.java

@@ -0,0 +1,300 @@
+package com.payment.platform.module.payment.unified.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.module.payment.unified.dto.query.UnifiedMerchantQueryDTO;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedMerchantSaveDTO;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedMerchantVO;
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
+import com.payment.platform.module.payment.unified.mapper.UnifiedMerchantMapper;
+import com.payment.platform.module.payment.unified.mapper.UnifiedOrderMapper;
+import com.payment.platform.module.payment.unified.mapper.UnifiedRefundMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 租户 ↔ 上游「统一支付平台」商户配置。
+ *
+ * <p>{@code mch_no} 是上游全局唯一的商户号:上游通知只带它、且没有认证上下文,
+ * 所以它必须唯一定位到一行({@link #requireByMchNo})。
+ *
+ * <p>写入只由超管发起(门禁在 {@code UnifiedMerchantController#save/delete}),
+ * 但**归属({@code tenantId})由服务按分支处置**:新增必须显式给出(超管 bypass 了
+ * 租户拦截器,不显式给值会被自动填成超管自己的租户),修改一律忽略(归属不可改)。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class UnifiedMerchantService {
+
+    /** 行状态:启用。上游通知/轮询**不检查**它 —— 历史单还要继续收敛。 */
+    public static final String STATUS_ENABLED = "0";
+
+    private final UnifiedMerchantMapper merchantMapper;
+    private final UnifiedOrderMapper orderMapper;
+    private final UnifiedRefundMapper refundMapper;
+
+    /**
+     * 后台分页列表。
+     *
+     * <p>租户侧的行由 {@code TenantInnerInterceptor} 钉死在自己的 {@code tenant_id} 上;
+     * {@code tenantId} 条件只用于超管筛选(传了也只是多一道等值条件)。
+     */
+    public PageResult<UnifiedMerchantVO> list(UnifiedMerchantQueryDTO q) {
+        UnifiedMerchantQueryDTO query = q == null ? new UnifiedMerchantQueryDTO() : q;
+
+        LambdaQueryWrapper<UnifiedMerchantEntity> w = new LambdaQueryWrapper<>();
+        if (query.getTenantId() != null) {
+            w.eq(UnifiedMerchantEntity::getTenantId, query.getTenantId());
+        }
+        if (StrUtil.isNotBlank(query.getMchNo())) {
+            w.eq(UnifiedMerchantEntity::getMchNo, query.getMchNo());
+        }
+        if (StrUtil.isNotBlank(query.getMerchantName())) {
+            w.like(UnifiedMerchantEntity::getMerchantName, query.getMerchantName());
+        }
+        if (StrUtil.isNotBlank(query.getStatus())) {
+            w.eq(UnifiedMerchantEntity::getStatus, query.getStatus());
+        }
+        w.orderByDesc(UnifiedMerchantEntity::getCreatedTime);
+
+        Page<UnifiedMerchantEntity> page =
+                merchantMapper.selectPage(new Page<>(query.getPageNo(), query.getPageSize()), w);
+        List<UnifiedMerchantVO> rows = page.getRecords().stream().map(UnifiedMerchantVO::of).toList();
+        return PageResult.of(query.getPageNo(), query.getPageSize(), page.getTotal(), rows);
+    }
+
+    /**
+     * 新增或修改商户配置。
+     *
+     * <ul>
+     *   <li>新增:{@code tenantId} 必填;{@code mchNo} 全局唯一;{@code appSecret} 必填。</li>
+     *   <li>修改:{@code tenantId} 被忽略(归属不可改);{@code appSecret} 留空保留原值。</li>
+     * </ul>
+     *
+     * @throws BusinessException 400 入参缺失 / 编号重复;404 修改目标不存在
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public UnifiedMerchantEntity save(UnifiedMerchantSaveDTO dto) {
+        UnifiedMerchantEntity entity;
+
+        if (dto.getId() != null) {
+            entity = requireById(dto.getId());
+
+            // 归属不可改:已建订单带的是旧 tenant_id,改这里等于让同一笔业务横跨两个租户。
+            // 编号换了才需要重新查重;没变时按编号查会把这一行自己数进去。
+            if (StrUtil.isNotBlank(dto.getMchNo()) && !dto.getMchNo().equals(entity.getMchNo())) {
+                requireMchNoAvailable(dto.getMchNo());
+                entity.setMchNo(dto.getMchNo());
+            }
+            if (StrUtil.isNotBlank(dto.getAppId())) {
+                entity.setAppId(dto.getAppId());
+            }
+            // 留空 = 不修改。写成空串会让该商户之后所有上游请求签名失败。
+            if (StrUtil.isNotBlank(dto.getAppSecret())) {
+                entity.setAppSecret(dto.getAppSecret());
+            }
+            if (dto.getMerchantName() != null) {
+                entity.setMerchantName(dto.getMerchantName());
+            }
+            if (StrUtil.isNotBlank(dto.getChannelUserIfCode())) {
+                entity.setChannelUserIfCode(dto.getChannelUserIfCode());
+            }
+            if (StrUtil.isNotBlank(dto.getStatus())) {
+                entity.setStatus(dto.getStatus());
+            }
+            if (dto.getDescription() != null) {
+                entity.setDescription(dto.getDescription());
+            }
+        } else {
+            // 归属必须显式给出:超管 bypass 了租户拦截器,不显式给值会被自动填成
+            // 超管自己的租户,配置静默落到别人头上。
+            if (dto.getTenantId() == null) {
+                throw new BusinessException(400, "请选择租户");
+            }
+            if (StrUtil.isBlank(dto.getMchNo())) {
+                throw new BusinessException(400, "商户号不能为空");
+            }
+            if (StrUtil.isBlank(dto.getAppId())) {
+                throw new BusinessException(400, "appId 不能为空");
+            }
+            if (StrUtil.isBlank(dto.getAppSecret())) {
+                throw new BusinessException(400, "appSecret 不能为空");
+            }
+            requireMchNoAvailable(dto.getMchNo());
+
+            entity = new UnifiedMerchantEntity();
+            entity.setTenantId(dto.getTenantId());
+            entity.setMchNo(dto.getMchNo());
+            entity.setAppId(dto.getAppId());
+            entity.setAppSecret(dto.getAppSecret());
+            entity.setMerchantName(dto.getMerchantName());
+            entity.setChannelUserIfCode(dto.getChannelUserIfCode());
+            entity.setStatus(dto.getStatus());
+            entity.setDescription(dto.getDescription());
+        }
+
+        if (entity.getId() == null) {
+            // 主键由 MyBatis-Plus ASSIGN_ID(雪花)生成
+            merchantMapper.insert(entity);
+        } else {
+            merchantMapper.updateById(entity);
+        }
+        return entity;
+    }
+
+    /**
+     * 删除商户配置。
+     *
+     * <p>已产生订单或退款的商户**不能删**:历史单失去配置后,退款/对账都定位不到上游。
+     * 不再接新的交易应该用停用({@code status='1'})。
+     *
+     * @throws BusinessException 404 配置不存在;400 已有交易
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void delete(Long id) {
+        UnifiedMerchantEntity cfg = requireById(id);
+
+        Long orders = orderMapper.selectCount(new LambdaQueryWrapper<UnifiedOrderEntity>()
+                .eq(UnifiedOrderEntity::getMerchantId, id));
+        if (orders != null && orders > 0) {
+            throw new BusinessException(400, "已有交易,不能删除,请停用");
+        }
+
+        Long refunds = refundMapper.selectCount(new LambdaQueryWrapper<UnifiedRefundEntity>()
+                .eq(UnifiedRefundEntity::getMchNo, cfg.getMchNo()));
+        if (refunds != null && refunds > 0) {
+            throw new BusinessException(400, "已有交易,不能删除,请停用");
+        }
+
+        merchantMapper.deleteById(id);
+    }
+
+    /**
+     * 按主键取配置。
+     *
+     * <p>租户侧由 {@code TenantInnerInterceptor} 追加 {@code tenant_id} 条件,
+     * 超管跨租户直取。
+     *
+     * @throws BusinessException 400 id 为空;404 不存在(含不属于当前租户)
+     */
+    public UnifiedMerchantEntity requireById(Long id) {
+        if (id == null) {
+            throw new BusinessException(400, "商户 id 不能为空");
+        }
+        UnifiedMerchantEntity row = merchantMapper.selectById(id);
+        if (row == null) {
+            throw new BusinessException(404, "商户配置不存在");
+        }
+        return row;
+    }
+
+    /**
+     * 按**上游商户号**取配置 —— 无认证场景(上游通知/轮询)的唯一定位键。
+     *
+     * <p>{@code mch_no} 全局唯一({@code uk_unified_merchant_mch_no}),
+     * 无认证上下文里拦截器会跳过条件表的租户过滤,查询靠编号本身唯一定位。
+     *
+     * @throws BusinessException 400 编号为空;404 不存在
+     */
+    public UnifiedMerchantEntity requireByMchNo(String mchNo) {
+        if (StrUtil.isBlank(mchNo)) {
+            throw new BusinessException(400, "商户号不能为空");
+        }
+        UnifiedMerchantEntity row = merchantMapper.selectOne(new LambdaQueryWrapper<UnifiedMerchantEntity>()
+                .eq(UnifiedMerchantEntity::getMchNo, mchNo));
+        if (row == null) {
+            throw new BusinessException(404, "商户配置不存在: " + mchNo);
+        }
+        return row;
+    }
+
+    /**
+     * 为指定租户选出这次交易要用的配置。
+     *
+     * <p><b>三态</b>(未指定 {@code mchNo} 时):0 条 ⇒ 404;1 条 ⇒ 用它;
+     * 多条 ⇒ 400 要求指定 {@code mch_no}。多条时**绝不随便挑一条** ——
+     * 挑错了钱会进另一个商户号,全程没有报错,要到对账才发现。
+     *
+     * <p>指定了 {@code mchNo} 时租户条件写进 WHERE(不是查出来再在内存里比):
+     * 少这一条,租户 A 就能拿租户 B 的号下单。
+     *
+     * <p>返回前直接做停用校验:新建订单/退款/渠道用户跳转都必须要求 {@code status='0'}
+     * (spec §7.1)。上游通知与轮询**不走本方法**,所以历史单不受影响。
+     *
+     * @param tenantId **必须来自认证上下文**;为 {@code null} ⇒ 403 且先于查库
+     * @throws BusinessException 403 无归属租户;404 没配;400 多条未指定 / 商户已停用
+     */
+    public UnifiedMerchantEntity resolveForTenant(Long tenantId, String mchNo) {
+        if (tenantId == null) {
+            throw new BusinessException(403, "当前登录身份没有归属租户,无法选择商户配置,请联系管理员");
+        }
+
+        if (StrUtil.isNotBlank(mchNo)) {
+            UnifiedMerchantEntity row = merchantMapper.selectOne(new LambdaQueryWrapper<UnifiedMerchantEntity>()
+                    .eq(UnifiedMerchantEntity::getTenantId, tenantId)
+                    .eq(UnifiedMerchantEntity::getMchNo, mchNo));
+            if (row == null) {
+                // 「不存在」与「存在但不属于本租户」应答一致,不构成跨租户探针
+                throw new BusinessException(404, "商户号不存在或不属于当前租户: " + mchNo);
+            }
+            requireEnabled(row);
+            return row;
+        }
+
+        List<UnifiedMerchantEntity> rows = listByTenant(tenantId);
+        if (rows.isEmpty()) {
+            throw new BusinessException(404, "当前租户尚未配置商户号,请联系平台运营登记");
+        }
+        if (rows.size() > 1) {
+            throw new BusinessException(400, "当前租户配置了多个商户号,请在请求中指定 mch_no");
+        }
+        UnifiedMerchantEntity only = rows.get(0);
+        requireEnabled(only);
+        return only;
+    }
+
+    /** 某租户的全部配置(按主键升序,保证两条时读数稳定)。 */
+    public List<UnifiedMerchantEntity> listByTenant(Long tenantId) {
+        if (tenantId == null) {
+            return List.of();
+        }
+        return merchantMapper.selectList(new LambdaQueryWrapper<UnifiedMerchantEntity>()
+                .eq(UnifiedMerchantEntity::getTenantId, tenantId)
+                .orderByAsc(UnifiedMerchantEntity::getId));
+    }
+
+    /**
+     * 停用闸:新建订单、发起退款、生成渠道用户跳转 URL 都要求 {@code status='0'}。
+     *
+     * <p>★ 上游通知、轮询、回调重试**不调用**它 —— 历史单还要继续收敛。
+     *
+     * @throws BusinessException 404 配置为空;400 商户已停用
+     */
+    public void requireEnabled(UnifiedMerchantEntity cfg) {
+        if (cfg == null) {
+            throw new BusinessException(404, "商户配置不存在");
+        }
+        if (!STATUS_ENABLED.equals(cfg.getStatus())) {
+            throw new BusinessException(400, "商户已停用");
+        }
+    }
+
+    /** {@code mch_no} 全局唯一:跨租户也不能重复,否则上游分不清是哪一家的号。 */
+    private void requireMchNoAvailable(String mchNo) {
+        Long count = merchantMapper.selectCount(new LambdaQueryWrapper<UnifiedMerchantEntity>()
+                .eq(UnifiedMerchantEntity::getMchNo, mchNo));
+        if (count != null && count > 0) {
+            throw new BusinessException(400, "商户号已存在: " + mchNo);
+        }
+    }
+}

+ 58 - 0
java/src/main/java/com/payment/platform/module/payment/unified/util/UnifiedTenantGuard.java

@@ -0,0 +1,58 @@
+package com.payment.platform.module.payment.unified.util;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.core.security.LoginUser;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+
+/**
+ * 统一支付后台端点共用的租户守卫。
+ *
+ * <p>守的是两种会被**静默放行**的主体:
+ * <ul>
+ *   <li>取不到 {@code LoginUser} —— 默认拒绝,不默认放行;</li>
+ *   <li>非超管且 {@code tenantId == null} —— {@code TenantInnerInterceptor} 对条件表
+ *       ({@code pay_unified_*} 已登记)的分支是「已登录且租户为 null ⇒ 跳过过滤」
+ *       ({@code TenantInnerInterceptor:283-284}),放行等于让它跨租户读写。</li>
+ * </ul>
+ *
+ * <p>★ 判据与 {@code TenantInnerInterceptor} 用的是**同一个开关**
+ * ({@code LoginUser#getIsSuperuser})。换用别的判据(例如「tenantId 是不是 null」)
+ * 会出现「一边以为放行、另一边仍在过滤」或更坏的「一边以为在过滤、一边已经放行」——
+ * 两种组合界面上都看不出来。
+ */
+@Component
+public class UnifiedTenantGuard {
+
+    /**
+     * 取当前登录身份;不满足后台端点的最低要求即 403。
+     *
+     * @return 当前 {@link LoginUser}(超管允许没有归属租户)
+     * @throws BusinessException 403 取不到身份,或非超管却没有归属租户
+     */
+    public LoginUser require() {
+        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+        if (auth == null || !(auth.getPrincipal() instanceof LoginUser user)) {
+            throw new BusinessException(403, "取不到当前登录身份");
+        }
+        if (!Boolean.TRUE.equals(user.getIsSuperuser()) && user.getTenantId() == null) {
+            throw new BusinessException(403, "当前登录身份没有归属租户,请联系管理员");
+        }
+        return user;
+    }
+
+    /** 当前身份是否超管。取不到身份时抛 403(与 {@link #require()} 同一口径)。 */
+    public boolean isSuperuser() {
+        return Boolean.TRUE.equals(require().getIsSuperuser());
+    }
+
+    /**
+     * 当前身份的归属租户;超管没有归属租户时返回 {@code null}。
+     *
+     * <p>非超管且没有租户时抛 403 —— 这个组合不由调用方自己判,否则每处都要重复一遍。
+     */
+    public Long tenantIdOrNull() {
+        return require().getTenantId();
+    }
+}

+ 261 - 0
java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedMerchantControllerTest.java

@@ -0,0 +1,261 @@
+package com.payment.platform.module.payment.unified.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.unified.dto.query.UnifiedMerchantQueryDTO;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedMerchantSaveDTO;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedMerchantVO;
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
+import com.payment.platform.module.payment.unified.service.UnifiedMerchantService;
+import com.payment.platform.module.payment.unified.util.UnifiedTenantGuard;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 商户配置控制器(后台 JWT 端点)的三个面:
+ * <ol>
+ *   <li><b>闸在业务前</b>:三个方法第一行都调 {@code guard.require()},它拒绝时服务一次都不能被调
+ *       (R17:mock guard 令其抛 403 + {@code verifyNoInteractions});</li>
+ *   <li><b>契约</b>:类/方法路径与三个授权注解逐字钉死(前端与菜单 SQL 依赖它们);</li>
+ *   <li><b>id 解析</b>:字符串入参解析失败返回 400,不落到服务层。</li>
+ * </ol>
+ *
+ * <p>本类只测控制器自己负责的事。真实 guard 的行为在 {@code UnifiedTenantGuardTest},
+ * 服务行为在 {@code UnifiedMerchantServiceTest}。
+ */
+@ExtendWith(MockitoExtension.class)
+class UnifiedMerchantControllerTest {
+
+    @Mock private UnifiedMerchantService service;
+    @Mock private UnifiedTenantGuard guard;
+
+    private UnifiedMerchantController controller;
+
+    @BeforeEach
+    void setUp() {
+        controller = new UnifiedMerchantController(service, guard);
+    }
+
+    private static LoginUser user() {
+        LoginUser u = new LoginUser();
+        u.setUserId(1L);
+        u.setUsername("tester");
+        u.setStatus("0");
+        u.setTenantId(2L);
+        u.setIsSuperuser(true);
+        u.setAuthorities(List.of());
+        return u;
+    }
+
+    private static UnifiedMerchantSaveDTO dto() {
+        UnifiedMerchantSaveDTO dto = new UnifiedMerchantSaveDTO();
+        dto.setTenantId(7L);
+        dto.setMchNo("M001");
+        dto.setAppId("app-1");
+        dto.setAppSecret("SECRET-VALUE");
+        return dto;
+    }
+
+    // ─────────────────────── 一、闸先于一切(R17) ───────────────────────
+
+    @Test
+    void list_guardRejects_throws403_andNeverCallsService() {
+        when(guard.require()).thenThrow(new BusinessException(403, "当前登录身份没有归属租户"));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> controller.list(1, 20, null, null, null, null));
+
+        assertEquals(403, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(service);
+    }
+
+    @Test
+    void save_guardRejects_throws403_andNeverCallsService() {
+        when(guard.require()).thenThrow(new BusinessException(403, "当前登录身份没有归属租户"));
+
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.save(dto()));
+
+        assertEquals(403, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(service);
+    }
+
+    @Test
+    void delete_guardRejects_throws403_beforeEvenParsingTheId() {
+        when(guard.require()).thenThrow(new BusinessException(403, "当前登录身份没有归属租户"));
+
+        // 故意给一个解析不过的 id:若解析写在 guard 之前,这里会是 400 而不是 403。
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.delete("not-a-number"));
+
+        assertEquals(403, e.getCode(), "闸必须是第一行,实测消息: " + e.getMessage());
+        verifyNoInteractions(service);
+    }
+
+    // ─────────────────────────── 二、正对照 ───────────────────────────
+
+    @Test
+    void list_returnsServicePage_andPassesQueryFilters() {
+        when(guard.require()).thenReturn(user());
+        PageResult<UnifiedMerchantVO> page = PageResult.of(1, 20, 0, List.of());
+        when(service.list(any())).thenReturn(page);
+
+        Result<PageResult<UnifiedMerchantVO>> result =
+                controller.list(1, 20, 7L, "M001", "测试", "0");
+
+        assertSame(page, result.getData());
+
+        ArgumentCaptor<UnifiedMerchantQueryDTO> cap = ArgumentCaptor.forClass(UnifiedMerchantQueryDTO.class);
+        verify(service).list(cap.capture());
+        assertEquals(1, cap.getValue().getPageNo());
+        assertEquals(20, cap.getValue().getPageSize());
+        assertEquals(7L, cap.getValue().getTenantId());
+        assertEquals("M001", cap.getValue().getMchNo());
+        assertEquals("测试", cap.getValue().getMerchantName());
+        assertEquals("0", cap.getValue().getStatus());
+    }
+
+    /** 服务只回实体;VO 转换与「绝不回传 app_secret」是控制器这一层的责任。 */
+    @Test
+    void save_returnsVoWithAppSecretFlag_andNeverLeaksTheSecret() throws Exception {
+        when(guard.require()).thenReturn(user());
+        UnifiedMerchantEntity saved = new UnifiedMerchantEntity();
+        saved.setId(1234567890123456789L);
+        saved.setTenantId(7L);
+        saved.setMchNo("M001");
+        saved.setAppId("app-1");
+        saved.setAppSecret("SECRET-VALUE");
+        saved.setStatus("0");
+        when(service.save(any())).thenReturn(saved);
+
+        UnifiedMerchantSaveDTO dto = dto();
+        Result<UnifiedMerchantVO> result = controller.save(dto);
+
+        verify(service).save(same(dto));
+        assertNotNull(result.getData());
+        assertTrue(result.getData().isAppSecretSet());
+
+        String json = new ObjectMapper().writeValueAsString(result);
+        assertFalse(json.contains("SECRET-VALUE"), "应答不得含密钥明文: " + json);
+        assertTrue(json.contains("\"id\":\"1234567890123456789\""),
+                "雪花 id 必须序列化成字符串: " + json);
+    }
+
+    @Test
+    void delete_validId_parsesAndDelegates() {
+        when(guard.require()).thenReturn(user());
+
+        controller.delete("5");
+
+        verify(service).delete(5L);
+    }
+
+    /** 非数字 id ⇒ 400「无效的 id」,且服务一次都不被调。 */
+    @Test
+    void delete_nonNumericId_throws400_withoutCallingService() {
+        when(guard.require()).thenReturn(user());
+
+        BusinessException e = assertThrows(BusinessException.class, () -> controller.delete("abc"));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertEquals("无效的 id", e.getMessage());
+        verifyNoInteractions(service);
+    }
+
+    @Test
+    void delete_overflowId_throws400_withoutCallingService() {
+        when(guard.require()).thenReturn(user());
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> controller.delete("99999999999999999999"));
+
+        assertEquals(400, e.getCode(), "超出 Long 的入参同样是无效 id,实测消息: " + e.getMessage());
+        verifyNoInteractions(service);
+    }
+
+    // ─────────────────────────── 三、接口形状(契约) ───────────────────────────
+
+    @Test
+    void list_contract_permissionStringAndPath() throws Exception {
+        PreAuthorize pa = UnifiedMerchantController.class
+                .getMethod("list", int.class, int.class, Long.class, String.class, String.class, String.class)
+                .getAnnotation(PreAuthorize.class);
+
+        assertNotNull(pa, "列表端点必须有 @PreAuthorize");
+        assertEquals("@perm.hasAny('module_payment:unified:merchant:list')", pa.value(),
+                "权限串与 Task 17 的菜单 SQL 是跨任务契约,差一个字符即 403");
+
+        GetMapping get = UnifiedMerchantController.class
+                .getMethod("list", int.class, int.class, Long.class, String.class, String.class, String.class)
+                .getAnnotation(GetMapping.class);
+        assertNotNull(get, "列表必须是 GET");
+        assertEquals(0, get.value().length, "列表就挂在类路径上,没有子路径");
+    }
+
+    @Test
+    void save_contract_superuserOnlyAndPostOnClassPath() throws Exception {
+        PreAuthorize pa = UnifiedMerchantController.class
+                .getMethod("save", UnifiedMerchantSaveDTO.class)
+                .getAnnotation(PreAuthorize.class);
+
+        assertNotNull(pa, "新增/修改端点必须有 @PreAuthorize");
+        assertEquals("@perm.hasRole('SUPERUSER')", pa.value(),
+                "约束是**身份**不是角色授权:租户不能自助建号(mch_no 全局唯一,放开等于允许抢注)");
+
+        PostMapping post = UnifiedMerchantController.class
+                .getMethod("save", UnifiedMerchantSaveDTO.class)
+                .getAnnotation(PostMapping.class);
+        assertNotNull(post, "保存是 POST");
+        assertEquals(0, post.value().length, "保存挂在类路径上,没有子路径");
+    }
+
+    @Test
+    void delete_contract_superuserOnlyAndIdPathVariable() throws Exception {
+        PreAuthorize pa = UnifiedMerchantController.class
+                .getMethod("delete", String.class)
+                .getAnnotation(PreAuthorize.class);
+
+        assertNotNull(pa, "删除端点必须有 @PreAuthorize");
+        assertEquals("@perm.hasRole('SUPERUSER')", pa.value(),
+                "删除等于让该租户无法下单,必须只有超管能做");
+
+        DeleteMapping delete = UnifiedMerchantController.class
+                .getMethod("delete", String.class)
+                .getAnnotation(DeleteMapping.class);
+        assertNotNull(delete, "删除是 DELETE");
+        assertEquals(List.of("/{id}"), List.of(delete.value()));
+    }
+
+    @Test
+    void classPath_isTheFrontendContract() {
+        RequestMapping rm = UnifiedMerchantController.class.getAnnotation(RequestMapping.class);
+
+        assertNotNull(rm, "类级 @RequestMapping 不得为空 —— 真实路径拼不出来");
+        assertEquals("/payment/unified/merchant", rm.value()[0],
+                "前端 unified.ts 与菜单 SQL 依赖这一条路径");
+    }
+}

+ 520 - 0
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedMerchantServiceTest.java

@@ -0,0 +1,520 @@
+package com.payment.platform.module.payment.unified.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.module.payment.unified.dto.query.UnifiedMerchantQueryDTO;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedMerchantSaveDTO;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedMerchantVO;
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
+import com.payment.platform.module.payment.unified.mapper.UnifiedMerchantMapper;
+import com.payment.platform.module.payment.unified.mapper.UnifiedOrderMapper;
+import com.payment.platform.module.payment.unified.mapper.UnifiedRefundMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.lang.reflect.Field;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 商户配置服务。
+ *
+ * <p>本类盯的是几处「不会报错但会错」的地方:
+ * <ol>
+ *   <li>一个租户多条配置时由系统**随便挑一条** —— 钱会进另一个商户号,全程无报错;</li>
+ *   <li>修改时允许改归属 —— 已建订单带的是旧 {@code tenant_id},两边列表各看一半;</li>
+ *   <li>修改时把留空的 {@code app_secret} 写成空串 —— 该商户之后所有请求签名失败;</li>
+ *   <li>删除仍有交易记录的商户 —— 历史单失去配置,对账/退款都定位不到上游。</li>
+ * </ol>
+ */
+@ExtendWith(MockitoExtension.class)
+class UnifiedMerchantServiceTest {
+
+    @Mock private UnifiedMerchantMapper merchantMapper;
+    @Mock private UnifiedOrderMapper orderMapper;
+    @Mock private UnifiedRefundMapper refundMapper;
+
+    private UnifiedMerchantService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new UnifiedMerchantService(merchantMapper, orderMapper, refundMapper);
+        // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
+        // (同款引导见 HarMerchantServiceTest / LsyMerchantServiceTest。)
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), UnifiedMerchantEntity.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), UnifiedOrderEntity.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), UnifiedRefundEntity.class);
+    }
+
+    // ────────────────────────────── 工具 ──────────────────────────────
+
+    private static UnifiedMerchantEntity merchant(Long id, Long tenantId, String mchNo, String status) {
+        UnifiedMerchantEntity e = new UnifiedMerchantEntity();
+        e.setId(id);
+        e.setTenantId(tenantId);
+        e.setMchNo(mchNo);
+        e.setAppId("app-" + mchNo);
+        e.setAppSecret("OLD-SECRET");
+        e.setMerchantName("商户" + mchNo);
+        e.setChannelUserIfCode("fuioupay");
+        e.setStatus(status);
+        return e;
+    }
+
+    private static UnifiedMerchantSaveDTO createDto() {
+        UnifiedMerchantSaveDTO dto = new UnifiedMerchantSaveDTO();
+        dto.setTenantId(7L);
+        dto.setMchNo("M001");
+        dto.setAppId("app-1");
+        dto.setAppSecret("NEW-SECRET");
+        dto.setMerchantName("测试商户");
+        return dto;
+    }
+
+    // ─────────────────── 一、按租户选配置:0 / 1 / 多条 ───────────────────
+
+    @Test
+    void resolveForTenant_singleRow_returnsIt() {
+        UnifiedMerchantEntity only = merchant(11L, 2L, "M001", "0");
+        when(merchantMapper.selectList(any())).thenReturn(List.of(only));
+
+        assertSame(only, service.resolveForTenant(2L, null));
+    }
+
+    /** 404 而不是 403:调用方据此区分「还没报备」与「报备了但不是你的」。 */
+    @Test
+    void resolveForTenant_zeroRows_throws404() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of());
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.resolveForTenant(2L, null));
+
+        assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
+    }
+
+    /**
+     * 多条 ⇒ **400 要求指定**,绝不 LIMIT 1 随便挑一条。
+     *
+     * 挑错了不会报错,只会把钱收进另一个商户号 —— 到对账那天才看得见。
+     */
+    @Test
+    void resolveForTenant_multipleRows_throws400_mentionsMchNo() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of(
+                merchant(11L, 2L, "M001", "0"),
+                merchant(12L, 2L, "M002", "0")));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.resolveForTenant(2L, null));
+
+        assertEquals(400, e.getCode(), "多条是入参缺项,不是找不到,实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("指定 mch_no"),
+                "报错必须告诉调用方补哪个字段,实测消息: " + e.getMessage());
+        verify(merchantMapper, never()).selectOne(any());
+    }
+
+    /** 停用的商户不得继续接单:spec §7.1 要求新建订单/退款前强制 {@code status='0'}。 */
+    @Test
+    void resolveForTenant_disabledSingleRow_throws400_merchantDisabled() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of(merchant(11L, 2L, "M001", "1")));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.resolveForTenant(2L, null));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("商户已停用"), "实测消息: " + e.getMessage());
+    }
+
+    /** 指定商户号时必须带 tenant_id:少这个条件,租户 A 能拿租户 B 的号下单。 */
+    @Test
+    void resolveForTenant_withMchNo_scopedByTenantAndMchNo() {
+        UnifiedMerchantEntity row = merchant(11L, 2L, "M001", "0");
+        when(merchantMapper.selectOne(any())).thenReturn(row);
+
+        assertSame(row, service.resolveForTenant(2L, "M001"));
+
+        @SuppressWarnings({"unchecked", "rawtypes"})
+        ArgumentCaptor<LambdaQueryWrapper<UnifiedMerchantEntity>> cap =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(merchantMapper).selectOne(cap.capture());
+        String where = cap.getValue().getSqlSegment();
+        assertTrue(where.contains("tenant_id"), "实测 WHERE: " + where);
+        assertTrue(where.contains("mch_no"), "实测 WHERE: " + where);
+    }
+
+    /** 「不存在」与「存在但不是本租户的」应答一致(都 404),不构成跨租户探针。 */
+    @Test
+    void resolveForTenant_withMchNo_missing_throws404() {
+        when(merchantMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.resolveForTenant(2L, "M999"));
+
+        assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
+    }
+
+    /** 没有归属租户 ⇒ 403 且**先于查库**:这种主体会被拦截器跳过过滤。 */
+    @Test
+    void resolveForTenant_nullTenant_throws403_withoutQuerying() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.resolveForTenant(null, "M001"));
+
+        assertEquals(403, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(merchantMapper);
+    }
+
+    @Test
+    void listByTenant_nullTenant_returnsEmptyWithoutQuerying() {
+        assertTrue(service.listByTenant(null).isEmpty());
+        verify(merchantMapper, never()).selectList(any());
+    }
+
+    @Test
+    void requireEnabled_disabled_throws400_merchantDisabled() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.requireEnabled(merchant(11L, 2L, "M001", "1")));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("商户已停用"), "实测消息: " + e.getMessage());
+    }
+
+    @Test
+    void requireEnabled_enabled_passes() {
+        service.requireEnabled(merchant(11L, 2L, "M001", "0"));
+    }
+
+    // ───────────────────────── 二、按编号 / 主键寻址 ─────────────────────────
+
+    @Test
+    void requireByMchNo_found_returnsRow() {
+        UnifiedMerchantEntity row = merchant(11L, 2L, "M001", "0");
+        when(merchantMapper.selectOne(any())).thenReturn(row);
+
+        assertSame(row, service.requireByMchNo("M001"));
+    }
+
+    @Test
+    void requireByMchNo_missing_throws404() {
+        when(merchantMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.requireByMchNo("M999"));
+
+        assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
+    }
+
+    @Test
+    void requireByMchNo_blank_throws400_withoutQuerying() {
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.requireByMchNo("   "));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(merchantMapper);
+    }
+
+    @Test
+    void requireById_missing_throws404() {
+        when(merchantMapper.selectById(11L)).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.requireById(11L));
+
+        assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
+    }
+
+    @Test
+    void requireById_nullId_throws400_withoutQuerying() {
+        BusinessException e = assertThrows(BusinessException.class, () -> service.requireById(null));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        verifyNoInteractions(merchantMapper);
+    }
+
+    // ───────────────────────────── 三、新增 ─────────────────────────────
+
+    /**
+     * 缺 tenant_id 必须**拒绝**。
+     *
+     * 放过去的后果不是报错而是静默错位:AutoFillMetaObjectHandler 会拿超管自己的
+     * tenantId 填上,本想给租户 7 建的号落在超管名下。
+     */
+    @Test
+    void save_create_missingTenantId_throws400_withoutInsert() {
+        UnifiedMerchantSaveDTO dto = createDto();
+        dto.setTenantId(null);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.save(dto));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        verify(merchantMapper, never()).insert(any());
+    }
+
+    @Test
+    void save_create_duplicateMchNo_throws400_withoutInsert() {
+        when(merchantMapper.selectCount(any())).thenReturn(1L);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.save(createDto()));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("商户号已存在"), "实测消息: " + e.getMessage());
+        verify(merchantMapper, never()).insert(any());
+    }
+
+    /** 新增时 {@code app_secret} 必填(spec §7.1);空值放过去只会在上游签名阶段失败。 */
+    @Test
+    void save_create_blankAppSecret_throws400_withoutInsert() {
+        UnifiedMerchantSaveDTO dto = createDto();
+        dto.setAppSecret("   ");
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.save(dto));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("appSecret"), "实测消息: " + e.getMessage());
+        verify(merchantMapper, never()).insert(any());
+    }
+
+    @Test
+    void save_create_success_insertsWithChosenTenantAndSecret() {
+        when(merchantMapper.selectCount(any())).thenReturn(0L);
+
+        service.save(createDto());
+
+        ArgumentCaptor<UnifiedMerchantEntity> captor = ArgumentCaptor.forClass(UnifiedMerchantEntity.class);
+        verify(merchantMapper).insert(captor.capture());
+        UnifiedMerchantEntity inserted = captor.getValue();
+        assertEquals(7L, inserted.getTenantId(), "归属必须是超管选的那个租户");
+        assertEquals("M001", inserted.getMchNo());
+        assertEquals("NEW-SECRET", inserted.getAppSecret());
+        assertNull(inserted.getId(), "主键交给 MyBatis-Plus ASSIGN_ID(雪花)生成");
+    }
+
+    // ───────────────────────────── 四、修改 ─────────────────────────────
+
+    /** 改归属等于让同一笔业务横跨两个租户:请求里的 tenantId 必须被忽略。 */
+    @Test
+    void save_update_keepsOriginalTenant_ignoresRequestedOne() {
+        UnifiedMerchantEntity existing = merchant(5L, 1L, "M001", "0");
+        when(merchantMapper.selectById(5L)).thenReturn(existing);
+
+        UnifiedMerchantSaveDTO dto = createDto();
+        dto.setId(5L);
+        dto.setTenantId(9L);      // 试图改归属
+        dto.setMchNo("M001");     // 编号未变 ⇒ 不触发查重
+        service.save(dto);
+
+        ArgumentCaptor<UnifiedMerchantEntity> captor = ArgumentCaptor.forClass(UnifiedMerchantEntity.class);
+        verify(merchantMapper).updateById(captor.capture());
+        assertEquals(1L, captor.getValue().getTenantId(), "归属必须保持原值");
+        verify(merchantMapper, never()).insert(any());
+    }
+
+    /** 留空 = 不修改。写成空串会让该商户之后所有上游请求签名失败。 */
+    @Test
+    void save_update_blankAppSecret_keepsOriginal() {
+        UnifiedMerchantEntity existing = merchant(5L, 1L, "M001", "0");
+        existing.setAppSecret("OLD-SECRET");
+        when(merchantMapper.selectById(5L)).thenReturn(existing);
+
+        UnifiedMerchantSaveDTO dto = createDto();
+        dto.setId(5L);
+        dto.setMchNo("M001");
+        dto.setAppSecret("   ");
+        service.save(dto);
+
+        ArgumentCaptor<UnifiedMerchantEntity> captor = ArgumentCaptor.forClass(UnifiedMerchantEntity.class);
+        verify(merchantMapper).updateById(captor.capture());
+        assertEquals("OLD-SECRET", captor.getValue().getAppSecret(), "空白 appSecret 必须保留原值");
+    }
+
+    @Test
+    void save_update_nonBlankAppSecret_overwrites() {
+        UnifiedMerchantEntity existing = merchant(5L, 1L, "M001", "0");
+        existing.setAppSecret("OLD-SECRET");
+        when(merchantMapper.selectById(5L)).thenReturn(existing);
+
+        UnifiedMerchantSaveDTO dto = createDto();
+        dto.setId(5L);
+        dto.setMchNo("M001");
+        dto.setAppSecret("NEW-SECRET");
+        service.save(dto);
+
+        ArgumentCaptor<UnifiedMerchantEntity> captor = ArgumentCaptor.forClass(UnifiedMerchantEntity.class);
+        verify(merchantMapper).updateById(captor.capture());
+        assertEquals("NEW-SECRET", captor.getValue().getAppSecret());
+    }
+
+    @Test
+    void save_update_missingRow_throws404_withoutUpdate() {
+        when(merchantMapper.selectById(5L)).thenReturn(null);
+
+        UnifiedMerchantSaveDTO dto = createDto();
+        dto.setId(5L);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.save(dto));
+
+        assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
+        verify(merchantMapper, never()).updateById(any());
+        verify(merchantMapper, never()).insert(any());
+    }
+
+    // ───────────────────────────── 五、删除 ─────────────────────────────
+
+    /** 有订单 ⇒ 拒删:历史单失去配置,退款/对账都定位不到上游。 */
+    @Test
+    void delete_withOrders_throws400_andNeverDeletes() {
+        when(merchantMapper.selectById(5L)).thenReturn(merchant(5L, 2L, "M001", "0"));
+        when(orderMapper.selectCount(any())).thenReturn(1L);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.delete(5L));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("已有交易,不能删除,请停用"), "实测消息: " + e.getMessage());
+        verify(merchantMapper, never()).deleteById(any());
+        verifyNoInteractions(refundMapper);
+    }
+
+    /** 有退款也一样 —— 退款行只存 mch_no 快照,但仍是这笔商户的交易历史。 */
+    @Test
+    void delete_withRefunds_throws400_andNeverDeletes() {
+        when(merchantMapper.selectById(5L)).thenReturn(merchant(5L, 2L, "M001", "0"));
+        when(orderMapper.selectCount(any())).thenReturn(0L);
+        when(refundMapper.selectCount(any())).thenReturn(1L);
+
+        BusinessException e = assertThrows(BusinessException.class, () -> service.delete(5L));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("已有交易,不能删除,请停用"), "实测消息: " + e.getMessage());
+        verify(merchantMapper, never()).deleteById(any());
+    }
+
+    @Test
+    void delete_noTransactions_deletesById() {
+        when(merchantMapper.selectById(5L)).thenReturn(merchant(5L, 2L, "M001", "0"));
+        when(orderMapper.selectCount(any())).thenReturn(0L);
+        when(refundMapper.selectCount(any())).thenReturn(0L);
+
+        service.delete(5L);
+
+        verify(merchantMapper).deleteById(5L);
+    }
+
+    /** 交易按 {@code merchant_id} 判、退款按 {@code mch_no} 判 —— 两列写在各自的 wrapper 里。 */
+    @Test
+    void delete_transactionChecks_useMerchantIdAndMchNo() {
+        when(merchantMapper.selectById(5L)).thenReturn(merchant(5L, 2L, "M001", "0"));
+        when(orderMapper.selectCount(any())).thenReturn(0L);
+        when(refundMapper.selectCount(any())).thenReturn(0L);
+
+        service.delete(5L);
+
+        @SuppressWarnings({"unchecked", "rawtypes"})
+        ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> orderCap =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(orderMapper).selectCount(orderCap.capture());
+        assertTrue(orderCap.getValue().getSqlSegment().contains("merchant_id"),
+                "实测 WHERE: " + orderCap.getValue().getSqlSegment());
+
+        @SuppressWarnings({"unchecked", "rawtypes"})
+        ArgumentCaptor<LambdaQueryWrapper<UnifiedRefundEntity>> refundCap =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(refundMapper).selectCount(refundCap.capture());
+        assertTrue(refundCap.getValue().getSqlSegment().contains("mch_no"),
+                "实测 WHERE: " + refundCap.getValue().getSqlSegment());
+    }
+
+    // ─────────────────────────── 六、列表与 VO ───────────────────────────
+
+    @Test
+    void list_mapsEntitiesToVoPage() {
+        UnifiedMerchantEntity row = merchant(11L, 2L, "M001", "0");
+        com.baomidou.mybatisplus.extension.plugins.pagination.Page<UnifiedMerchantEntity> page =
+                new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(1, 20);
+        page.setRecords(List.of(row));
+        page.setTotal(1L);
+        doReturn(page).when(merchantMapper).selectPage(any(), any());
+
+        UnifiedMerchantQueryDTO q = new UnifiedMerchantQueryDTO();
+        q.setPageNo(1);
+        q.setPageSize(20);
+        PageResult<UnifiedMerchantVO> result = service.list(q);
+
+        assertEquals(1, result.getPageNo());
+        assertEquals(20, result.getPageSize());
+        assertEquals(1L, result.getTotal());
+        assertEquals(1, result.getList().size());
+        assertEquals("M001", result.getList().get(0).getMchNo());
+        assertTrue(result.getList().get(0).isAppSecretSet());
+    }
+
+    /**
+     * 硬约束:VO **没有** {@code appSecret} 字段,只给 {@code appSecretSet}。
+     *
+     * <p>反射断言字段不存在,再用真实序列化确认密钥明文不出现在报文里 ——
+     * 只断言字段名的话,有人加一个 {@code getAppSecret()} 方法照样泄漏。
+     */
+    @Test
+    void vo_neverExposesAppSecret_butReportsWhetherItIsSet() throws Exception {
+        UnifiedMerchantEntity entity = merchant(1234567890123456789L, 2L, "M001", "0");
+        entity.setAppSecret("S3CRET-VALUE");
+        entity.setDescription("备注");
+
+        UnifiedMerchantVO vo = UnifiedMerchantVO.of(entity);
+        assertTrue(vo.isAppSecretSet());
+        assertEquals(2L, vo.getTenantId());
+        assertEquals("M001", vo.getMchNo());
+
+        for (Field f : UnifiedMerchantVO.class.getDeclaredFields()) {
+            assertFalse("appSecret".equalsIgnoreCase(f.getName()),
+                    "VO 不得声明 appSecret 字段:读懂它的人就是攻击者");
+        }
+
+        String json = new ObjectMapper().writeValueAsString(vo);
+        assertFalse(json.contains("S3CRET-VALUE"), "序列化报文不得含密钥明文: " + json);
+        assertTrue(json.contains("\"appSecretSet\":true"), "实测 JSON: " + json);
+        assertTrue(json.contains("\"id\":\"1234567890123456789\""),
+                "19 位雪花 id 必须序列化成字符串,实测 JSON: " + json);
+    }
+
+    /** 正对照:只断言「没有 appSecret」的话,一个永远返回 null 的 of() 也会绿。 */
+    @Test
+    void vo_copiesTheDisplayFields() throws Exception {
+        UnifiedMerchantEntity entity = merchant(11L, 2L, "M001", "0");
+        entity.setDescription("备注");
+
+        UnifiedMerchantVO vo = UnifiedMerchantVO.of(entity);
+
+        assertEquals("app-M001", vo.getAppId());
+        assertEquals("商户M001", vo.getMerchantName());
+        assertEquals("fuioupay", vo.getChannelUserIfCode());
+        assertEquals("0", vo.getStatus());
+        assertEquals("备注", vo.getDescription());
+    }
+
+    /** 空实体不能抛 NPE —— 调用方只在有行时使用,但 of(null) 的语义要稳定。 */
+    @Test
+    void vo_ofNull_returnsNull() {
+        assertNull(UnifiedMerchantVO.of(null));
+    }
+}

+ 131 - 0
java/src/test/java/com/payment/platform/module/payment/unified/util/UnifiedTenantGuardTest.java

@@ -0,0 +1,131 @@
+package com.payment.platform.module.payment.unified.util;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.core.security.LoginUser;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 后台端点共用的租户守卫。
+ *
+ * <p>守的是**两种会被静默放行**的身份:
+ * <ul>
+ *   <li>取不到 {@code LoginUser} —— 默认拒绝,不能默认放行;</li>
+ *   <li>非超管且 {@code tenantId == null} —— {@code TenantInnerInterceptor} 对这种主体
+ *       会跳过租户过滤(没有租户可过滤),放行等于让它跨租户读写。</li>
+ * </ul>
+ *
+ * <p>★ {@code SecurityContextHolder} 是**线程本地且跨用例存活**的,必须每个用例后清空,
+ * 否则某条用例设置的主体泄漏给后面每一条「应当拒绝」的用例,让它们集体变绿。
+ */
+class UnifiedTenantGuardTest {
+
+    private final UnifiedTenantGuard guard = new UnifiedTenantGuard();
+
+    @AfterEach
+    void clearSecurityContext() {
+        SecurityContextHolder.clearContext();
+    }
+
+    private static LoginUser loginUser(Long tenantId, boolean superuser) {
+        LoginUser u = new LoginUser();
+        u.setUserId(1L);
+        u.setUsername("tester");
+        u.setStatus("0");
+        u.setTenantId(tenantId);
+        u.setIsSuperuser(superuser);
+        u.setAuthorities(List.of());
+        return u;
+    }
+
+    private static void login(Object principal) {
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(principal, null, List.of()));
+    }
+
+    @Test
+    void require_noAuthentication_throws403() {
+        SecurityContextHolder.clearContext();
+
+        BusinessException e = assertThrows(BusinessException.class, guard::require);
+
+        assertEquals(403, e.getCode(), "取不到身份必须默认拒绝,实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("取不到当前登录身份"), "实测消息: " + e.getMessage());
+    }
+
+    /** 匿名主体(principal 是字符串而不是 LoginUser)同样按「取不到身份」拒绝。 */
+    @Test
+    void require_principalIsNotLoginUser_throws403() {
+        login("anonymousUser");
+
+        BusinessException e = assertThrows(BusinessException.class, guard::require);
+
+        assertEquals(403, e.getCode(), "实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("取不到当前登录身份"), "实测消息: " + e.getMessage());
+    }
+
+    /**
+     * 非超管 + 没有归属租户 ⇒ 403。
+     *
+     * <p>这个组合不是假想:{@code sys_user.tenant_id} 可空,而 {@code is_superuser} 是另一个开关。
+     * 这种主体在条件表上会被跳过过滤,必须在这里拦死。
+     */
+    @Test
+    void require_nonSuperuserWithoutTenant_throws403_withNoTenantMessage() {
+        login(loginUser(null, false));
+
+        BusinessException e = assertThrows(BusinessException.class, guard::require);
+
+        assertEquals(403, e.getCode(), "实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("没有归属租户"), "实测消息: " + e.getMessage());
+    }
+
+    @Test
+    void require_tenantUser_returnsUser() {
+        LoginUser user = loginUser(2L, false);
+        login(user);
+
+        assertSame(user, guard.require(), "有归属租户的普通用户必须放行");
+    }
+
+    @Test
+    void require_superuserWithoutTenant_returnsUser() {
+        LoginUser user = loginUser(null, true);
+        login(user);
+
+        assertSame(user, guard.require(), "超管跨租户,没有自己的租户也必须放行");
+    }
+
+    // ─────────────────────────── 派生读数正对照 ───────────────────────────
+
+    @Test
+    void isSuperuser_readsTheCurrentPrincipal() {
+        login(loginUser(null, true));
+        assertTrue(guard.isSuperuser());
+
+        login(loginUser(2L, false));
+        assertEquals(false, guard.isSuperuser(), "普通租户不是超管");
+    }
+
+    @Test
+    void tenantIdOrNull_returnsOwnTenantForTenantUser() {
+        login(loginUser(2L, false));
+        assertEquals(2L, guard.tenantIdOrNull());
+    }
+
+    @Test
+    void tenantIdOrNull_returnsNullForSuperuser() {
+        login(loginUser(null, true));
+        assertNull(guard.tenantIdOrNull(), "超管没有归属租户 —— 这是 getTenantId() 的原样读数");
+    }
+}