refactor(risk): 黑名单改为三态类型并按微信应用精确匹配

去掉 channel 空值通配,重建 pay_blacklist(ip/alipay_user/wechat_openid + wx_app_id),与平台支付应用作用域对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DaxPay Dev
2026-07-24 14:32:27 +08:00
parent 7b52cce087
commit 2ba93a34de
30 changed files with 260 additions and 104 deletions

View File

@@ -15,7 +15,6 @@ import org.springframework.stereotype.Repository;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
/// # 黑名单 Manager
@@ -31,25 +30,17 @@ public class PayBlacklistManager extends BaseManager<PayBlacklistMapper, PayBlac
return this.page(mpPage, wrapper);
}
/// 查重:同 type+value+channel+channelAppId空按空串
public boolean existsDuplicate(String type, String value, String channel, String channelAppId, Long excludeId) {
String ch = StrUtil.nullToEmpty(channel);
String app = StrUtil.nullToEmpty(channelAppId);
/// 查重:同 type+value+wxAppId空按空串
public boolean existsDuplicate(String type, String value, String wxAppId, Long excludeId) {
String app = StrUtil.nullToEmpty(wxAppId);
return lambdaQuery()
.eq(PayBlacklist::getType, type)
.eq(PayBlacklist::getValue, value)
.and(w -> {
if (StrUtil.isBlank(ch)) {
w.and(x -> x.isNull(PayBlacklist::getChannel).or().eq(PayBlacklist::getChannel, ""));
} else {
w.eq(PayBlacklist::getChannel, ch);
}
})
.and(w -> {
if (StrUtil.isBlank(app)) {
w.and(x -> x.isNull(PayBlacklist::getChannelAppId).or().eq(PayBlacklist::getChannelAppId, ""));
w.and(x -> x.isNull(PayBlacklist::getWxAppId).or().eq(PayBlacklist::getWxAppId, ""));
} else {
w.eq(PayBlacklist::getChannelAppId, app);
w.eq(PayBlacklist::getWxAppId, app);
}
})
.ne(excludeId != null, PayBlacklist::getId, excludeId)
@@ -57,47 +48,40 @@ public class PayBlacklistManager extends BaseManager<PayBlacklistMapper, PayBlac
}
/// 查找有效命中行enable 且未过期)
public Optional<PayBlacklist> findActiveHit(String type, String value, String channel, String channelAppId) {
///
/// - ip / alipay_user按 type+value忽略 wxAppId
/// - wechat_openid按 type+value+wxAppId 精确匹配wxAppId 为空则不命中)
public Optional<PayBlacklist> findActiveHit(String type, String value, String wxAppId) {
if (StrUtil.isBlank(type) || StrUtil.isBlank(value)) {
return Optional.empty();
}
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
List<PayBlacklist> list = lambdaQuery()
var query = lambdaQuery()
.eq(PayBlacklist::getType, type)
.eq(PayBlacklist::getValue, value)
.eq(PayBlacklist::getStatus, PayBlacklistStatusEnum.ENABLE.getCode())
.and(w -> w.isNull(PayBlacklist::getExpireTime).or().gt(PayBlacklist::getExpireTime, now))
.list();
if (list.isEmpty()) {
return Optional.empty();
.and(w -> w.isNull(PayBlacklist::getExpireTime).or().gt(PayBlacklist::getExpireTime, now));
if (PayBlacklistTypeEnum.WECHAT_OPENID.getCode().equals(type)) {
if (StrUtil.isBlank(wxAppId)) {
return Optional.empty();
}
query.eq(PayBlacklist::getWxAppId, wxAppId);
} else {
// ip / alipay_userwx_app_id 必须为空
query.and(w -> w.isNull(PayBlacklist::getWxAppId).or().eq(PayBlacklist::getWxAppId, ""));
}
// 优先精确 channel + channelAppId再宽匹配 channel 空
Optional<PayBlacklist> exact = list.stream()
.filter(e -> matchNullable(e.getChannel(), channel) && matchNullable(e.getChannelAppId(), channelAppId))
.findFirst();
if (exact.isPresent()) {
return exact;
}
// 名单 channel 为空视为对该 value 全局生效
return list.stream()
.filter(e -> StrUtil.isBlank(e.getChannel()) && StrUtil.isBlank(e.getChannelAppId()))
.findFirst();
return query.oneOpt();
}
/// 是否存在有效的 openId 类型黑名单enable 且未过期
public boolean hasActiveOpenIdBlacklist() {
/// 是否存在有效的用户标识类黑名单(支付宝 / 微信
public boolean hasActiveUserIdentityBlacklist() {
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
return lambdaQuery()
.eq(PayBlacklist::getType, PayBlacklistTypeEnum.OPEN_ID.getCode())
.in(PayBlacklist::getType,
PayBlacklistTypeEnum.ALIPAY_USER.getCode(),
PayBlacklistTypeEnum.WECHAT_OPENID.getCode())
.eq(PayBlacklist::getStatus, PayBlacklistStatusEnum.ENABLE.getCode())
.and(w -> w.isNull(PayBlacklist::getExpireTime).or().gt(PayBlacklist::getExpireTime, now))
.exists();
}
private static boolean matchNullable(String rule, String actual) {
if (StrUtil.isBlank(rule)) {
return StrUtil.isBlank(actual);
}
return rule.equals(actual);
}
}

View File

@@ -16,7 +16,10 @@ import java.time.OffsetDateTime;
/// # 支付黑名单配置
///
/// 平台级名单,维度为 IP / openId不含商户号事前拉黑。
/// 平台级名单,不含商户号事前拉黑。
/// - [PayBlacklistTypeEnum#IP]:全局 IP
/// - [PayBlacklistTypeEnum#ALIPAY_USER]:支付宝 userId不区分应用
/// - [PayBlacklistTypeEnum#WECHAT_OPENID]:微信 openId必须绑定 [wxAppId]
@EqualsAndHashCode(callSuper = true)
@Data
@FieldNameConstants
@@ -28,14 +31,11 @@ public class PayBlacklist extends MpBaseEntity implements ToResult<PayBlacklistR
/// @see PayBlacklistTypeEnum
private String type;
/// 名单值IP openId
/// 名单值IP / 支付宝 userId / 微信 openId
private String value;
/// 通道族openId 建议 wechat/alipayIP 可空
private String channel;
/// 通道应用 AppId可选防 openId 跨应用误杀)
private String channelAppId;
/// 微信平台支付应用 AppId仅 wechat_openid 使用
private String wxAppId;
/// 状态
/// @see PayBlacklistStatusEnum

View File

@@ -15,8 +15,10 @@ public enum PayBlacklistTypeEnum implements I18nSupport {
/// IP
IP("ip"),
/// 用户 openId / buyerId
OPEN_ID("open_id");
/// 支付宝用户userId通道内全局
ALIPAY_USER("alipay_user"),
/// 微信 OpenId绑定平台支付应用
WECHAT_OPENID("wechat_openid");
private final String code;
@@ -28,4 +30,9 @@ public enum PayBlacklistTypeEnum implements I18nSupport {
public static Optional<PayBlacklistTypeEnum> findByCode(String code) {
return Arrays.stream(values()).filter(e -> e.code.equals(code)).findFirst();
}
/// 是否为用户标识类名单(支付宝 / 微信)
public boolean isUserIdentity() {
return this == ALIPAY_USER || this == WECHAT_OPENID;
}
}

View File

@@ -22,7 +22,7 @@ public class PayBlacklistParam {
@NotNull(message = "{validation.field.id.notNull}", groups = ValidationGroup.edit.class)
private Long id;
/// 类型ip / open_id
/// 类型ip / alipay_user / wechat_openid
@Schema(description = "类型")
@NotBlank(message = "{validation.field.type.notBlank}", groups = ValidationGroup.add.class)
@Size(max = 32, message = "{validation.field.product.size}")
@@ -34,13 +34,10 @@ public class PayBlacklistParam {
@Size(max = 128, message = "{validation.field.openId.size}")
private String value;
@Schema(description = "通道族")
@Size(max = 32, message = "{validation.field.channel.size}")
private String channel;
@Schema(description = "通道应用AppId")
/// 微信平台支付应用 AppId仅 wechat_openid 必填)
@Schema(description = "微信平台支付应用 AppId")
@Size(max = 64, message = "{validation.field.channelAppId.size}")
private String channelAppId;
private String wxAppId;
/// 状态enable / disable
@Schema(description = "状态")

View File

@@ -23,6 +23,6 @@ public class PayBlacklistQuery {
@Schema(description = "状态")
private String status;
@Schema(description = "通道族")
private String channel;
@Schema(description = "微信平台支付应用 AppId")
private String wxAppId;
}

View File

@@ -22,11 +22,8 @@ public class PayBlacklistResult extends BaseResult {
@Schema(description = "名单值")
private String value;
@Schema(description = "通道族")
private String channel;
@Schema(description = "通道应用AppId")
private String channelAppId;
@Schema(description = "微信平台支付应用 AppId")
private String wxAppId;
@Schema(description = "状态")
private String status;

View File

@@ -2,6 +2,7 @@ package cn.daxpay.open.plugin.risk.service;
import cn.daxpay.open.platform.common.mybatisplus.util.MpUtil;
import cn.daxpay.open.platform.core.code.PayErrorCode;
import cn.daxpay.open.platform.core.enums.pay.channel.ChannelEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.platform.core.rest.param.PageParam;
@@ -48,8 +49,8 @@ public class PayBlacklistService {
if (StrUtil.isNotBlank(param.getStatus())) {
validateStatus(param.getStatus());
}
if (payBlacklistManager.existsDuplicate(param.getType(), param.getValue(),
param.getChannel(), param.getChannelAppId(), null)) {
normalizeScope(param);
if (payBlacklistManager.existsDuplicate(param.getType(), param.getValue(), param.getWxAppId(), null)) {
// 黑名单已存在
throw new BizInfoException(PayErrorCode.OPERATION_FAIL, "pay.error.risk.blacklistDuplicate");
}
@@ -67,14 +68,22 @@ public class PayBlacklistService {
PayBlacklist entity = getEntity(param.getId());
String originType = entity.getType();
String originValue = entity.getValue();
param.setType(originType);
normalizeScope(param);
PayBlacklistConvert.CONVERT.copy(param, entity);
entity.setType(originType);
entity.setValue(originValue);
// MapStruct IGNORE 不会清空 null
if (!PayBlacklistTypeEnum.WECHAT_OPENID.getCode().equals(originType)) {
entity.setWxAppId(null);
} else {
entity.setWxAppId(StrUtil.trimToNull(param.getWxAppId()));
}
if (StrUtil.isNotBlank(param.getStatus())) {
validateStatus(param.getStatus());
}
if (payBlacklistManager.existsDuplicate(entity.getType(), entity.getValue(),
entity.getChannel(), entity.getChannelAppId(), entity.getId())) {
entity.getWxAppId(), entity.getId())) {
throw new BizInfoException(PayErrorCode.OPERATION_FAIL, "pay.error.risk.blacklistDuplicate");
}
payBlacklistManager.updateById(entity);
@@ -88,37 +97,107 @@ public class PayBlacklistService {
}
/// 是否命中有效黑名单
public boolean isBlocked(String type, String value, String channel, String channelAppId) {
return findActive(type, value, channel, channelAppId).isPresent();
public boolean isBlocked(String type, String value, String wxAppId) {
return findActive(type, value, wxAppId).isPresent();
}
/// 是否存在有效的 openId 类型黑名单(供网关层智能触发 OAuth
/// 是否存在有效的用户标识类黑名单(供网关层智能触发 OAuth
public boolean hasActiveOpenIdBlacklist() {
return payBlacklistManager.hasActiveOpenIdBlacklist();
return payBlacklistManager.hasActiveUserIdentityBlacklist();
}
/// 查找有效名单行
public Optional<PayBlacklist> findActive(String type, String value, String channel, String channelAppId) {
return payBlacklistManager.findActiveHit(type, value, channel, channelAppId);
public Optional<PayBlacklist> findActive(String type, String value, String wxAppId) {
return payBlacklistManager.findActiveHit(type, value, wxAppId);
}
/// 供命中处理「加入黑名单」:无有效项则新建
/// 供命中处理「加入黑名单」
///
/// 按 hit 的 type/channel 映射;微信无 wxAppId 时抛错(避免非法行)。
@Transactional(rollbackFor = Exception.class)
public PayBlacklist ensureBlacklist(String type, String value, String channel, String reason) {
Optional<PayBlacklist> existing = payBlacklistManager.findActiveHit(type, value, channel, null);
public PayBlacklist ensureBlacklist(String hitType, String value, String channel,
String wxAppId, String reason) {
if (StrUtil.isBlank(value)) {
throw new BizInfoException(PayErrorCode.OPERATION_FAIL, "pay.error.risk.blacklistTypeInvalid");
}
ResolvedIdentity resolved = resolveIdentity(hitType, channel, wxAppId);
if (resolved == null) {
// 微信缺 AppId 或无法映射
if (ChannelEnum.WECHAT.getCode().equals(channel)
|| PayBlacklistTypeEnum.WECHAT_OPENID.getCode().equals(hitType)
|| ("open_id".equals(hitType) && ChannelEnum.WECHAT.getCode().equals(channel))) {
throw new BizInfoException(PayErrorCode.OPERATION_FAIL, "pay.error.risk.blacklistWechatAppRequired");
}
throw new BizInfoException(PayErrorCode.OPERATION_FAIL, "pay.error.risk.blacklistChannelInvalid");
}
Optional<PayBlacklist> existing = payBlacklistManager.findActiveHit(
resolved.type(), value, resolved.wxAppId());
if (existing.isPresent()) {
return existing.get();
}
PayBlacklist entity = new PayBlacklist()
.setType(type)
.setType(resolved.type())
.setValue(value)
.setChannel(channel)
.setWxAppId(resolved.wxAppId())
.setStatus(PayBlacklistStatusEnum.ENABLE.getCode())
.setReason(StrUtil.blankToDefault(reason, "risk hit auto add"));
payBlacklistManager.save(entity);
return entity;
}
/// 规范化作用域:微信必须 wxAppId其它类型强制清空
private void normalizeScope(PayBlacklistParam param) {
String type = param.getType();
if (PayBlacklistTypeEnum.WECHAT_OPENID.getCode().equals(type)) {
String wxAppId = StrUtil.trimToNull(param.getWxAppId());
if (StrUtil.isBlank(wxAppId)) {
throw new BizInfoException(PayErrorCode.OPERATION_FAIL, "pay.error.risk.blacklistWechatAppRequired");
}
param.setWxAppId(wxAppId);
return;
}
if (PayBlacklistTypeEnum.IP.getCode().equals(type)
|| PayBlacklistTypeEnum.ALIPAY_USER.getCode().equals(type)) {
param.setWxAppId(null);
return;
}
throw new BizInfoException(PayErrorCode.OPERATION_FAIL, "pay.error.risk.blacklistTypeInvalid");
}
/// 将命中快照映射为名单 type + wxAppId微信缺 AppId 则返回 null
private ResolvedIdentity resolveIdentity(String hitType, String channel, String wxAppId) {
if (PayBlacklistTypeEnum.IP.getCode().equals(hitType)) {
return new ResolvedIdentity(PayBlacklistTypeEnum.IP.getCode(), null);
}
if (PayBlacklistTypeEnum.ALIPAY_USER.getCode().equals(hitType)
|| ChannelEnum.ALIPAY.getCode().equals(channel)) {
return new ResolvedIdentity(PayBlacklistTypeEnum.ALIPAY_USER.getCode(), null);
}
if (PayBlacklistTypeEnum.WECHAT_OPENID.getCode().equals(hitType)
|| ChannelEnum.WECHAT.getCode().equals(channel)) {
String app = StrUtil.trimToNull(wxAppId);
if (StrUtil.isBlank(app)) {
log.warn("命中加黑跳过:微信名单缺少 wxAppId, hitType={}, channel={}", hitType, channel);
return null;
}
return new ResolvedIdentity(PayBlacklistTypeEnum.WECHAT_OPENID.getCode(), app);
}
// 兼容旧 hitType=open_id
if ("open_id".equals(hitType)) {
if (ChannelEnum.ALIPAY.getCode().equals(channel)) {
return new ResolvedIdentity(PayBlacklistTypeEnum.ALIPAY_USER.getCode(), null);
}
if (ChannelEnum.WECHAT.getCode().equals(channel)) {
String app = StrUtil.trimToNull(wxAppId);
if (StrUtil.isBlank(app)) {
return null;
}
return new ResolvedIdentity(PayBlacklistTypeEnum.WECHAT_OPENID.getCode(), app);
}
}
return null;
}
private PayBlacklist getEntity(Long id) {
return payBlacklistManager.findById(id)
// 黑名单不存在
@@ -136,4 +215,7 @@ public class PayBlacklistService {
throw new BizInfoException(PayErrorCode.OPERATION_FAIL, "pay.error.risk.blacklistStatusInvalid");
}
}
private record ResolvedIdentity(String type, String wxAppId) {
}
}

View File

@@ -62,10 +62,12 @@ public class PayRiskHitService {
"pay.error.risk.handleStatusInvalid"));
PayRiskHit entity = getEntity(param.getId());
if (status == PayRiskHitHandleStatusEnum.ADDED_BLACKLIST) {
// 微信名单需 wxAppId命中快照无该字段时无法自动写入
PayBlacklist bl = payBlacklistService.ensureBlacklist(
entity.getHitType(),
entity.getHitValue(),
entity.getChannel(),
null,
param.getHandleRemark());
entity.setBlacklistId(bl.getId());
}

View File

@@ -3,6 +3,7 @@ package cn.daxpay.open.plugin.risk.strategy;
import cn.daxpay.open.payment.strategy.risk.PayRiskCheckContext;
import cn.daxpay.open.payment.strategy.risk.PayRiskChecker;
import cn.daxpay.open.platform.core.code.PayErrorCode;
import cn.daxpay.open.platform.core.enums.pay.channel.ChannelEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.plugin.risk.entity.PayBlacklist;
import cn.daxpay.open.plugin.risk.enums.PayBlacklistTypeEnum;
@@ -31,7 +32,7 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
private final PayBlacklistService payBlacklistService;
private final PayRiskHitService payRiskHitService;
/// openId 黑名单存在性缓存(短 TTL 30s
/// 用户标识黑名单存在性缓存(短 TTL 30s
///
/// 仅供网关层判断是否触发强制 OAuth 取 openId, 非关键路径,
/// 30s 延迟可接受(黑名单 CRUD 不会立刻反映到 OAuth 触发判定)
@@ -49,12 +50,11 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
}
ctx.setPhase(PayRiskHitPhaseEnum.BEFORE_PAY.getCode());
// IP 名单(全局生效)
rejectIfBlocked(ctx, PayBlacklistTypeEnum.IP.getCode(), ctx.getClientIp(), null, null, true);
// openId 名单(按通道精细匹配)
boolean openIdBlocked = rejectIfBlocked(ctx, PayBlacklistTypeEnum.OPEN_ID.getCode(), ctx.getOpenId(),
ctx.getChannel(), ctx.getChannelAppId(), true);
if (!openIdBlocked && StrUtil.isBlank(ctx.getOpenId())) {
log.warn("支付前 openId 缺失, openId 黑名单降级为仅 IP 校验 + 事后补录: "
rejectIfBlocked(ctx, PayBlacklistTypeEnum.IP.getCode(), ctx.getClientIp(), null, true);
// 用户标识:按通道映射名单类型
boolean identityBlocked = checkUserIdentity(ctx, ctx.getOpenId(), true);
if (!identityBlocked && StrUtil.isBlank(ctx.getOpenId())) {
log.warn("支付前 openId 缺失, 用户标识黑名单降级为仅 IP 校验 + 事后补录: "
+ "tradeType={}, method={}, mchNo={}, clientIp={}",
ctx.getTradeType(), ctx.getMethod(), ctx.getMchNo(), ctx.getClientIp());
}
@@ -67,13 +67,11 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
}
ctx.setPhase(PayRiskHitPhaseEnum.AFTER_PAY.getCode());
// 事后只记命中,不抛错
rejectIfBlocked(ctx, PayBlacklistTypeEnum.IP.getCode(), ctx.getClientIp(), null, null, false);
rejectIfBlocked(ctx, PayBlacklistTypeEnum.OPEN_ID.getCode(), ctx.getOpenId(),
ctx.getChannel(), ctx.getChannelAppId(), false);
// buyerId 按 open_id 维度比对(主扫补洞)
rejectIfBlocked(ctx, PayBlacklistTypeEnum.IP.getCode(), ctx.getClientIp(), null, false);
checkUserIdentity(ctx, ctx.getOpenId(), false);
// buyerId 按用户标识维度比对(主扫补洞)
if (StrUtil.isNotBlank(ctx.getBuyerId()) && !StrUtil.equals(ctx.getBuyerId(), ctx.getOpenId())) {
rejectIfBlocked(ctx, PayBlacklistTypeEnum.OPEN_ID.getCode(), ctx.getBuyerId(),
ctx.getChannel(), ctx.getChannelAppId(), false);
checkUserIdentity(ctx, ctx.getBuyerId(), false);
}
}
@@ -88,13 +86,30 @@ public class DefaultPayRiskChecker implements PayRiskChecker {
return exists;
}
/// 按请求通道检查支付宝 / 微信用户标识名单
private boolean checkUserIdentity(PayRiskCheckContext ctx, String identity, boolean throwOnHit) {
if (StrUtil.isBlank(identity)) {
return false;
}
String channel = ctx.getChannel();
if (ChannelEnum.ALIPAY.getCode().equals(channel)) {
return rejectIfBlocked(ctx, PayBlacklistTypeEnum.ALIPAY_USER.getCode(), identity, null, throwOnHit);
}
if (ChannelEnum.WECHAT.getCode().equals(channel)) {
return rejectIfBlocked(ctx, PayBlacklistTypeEnum.WECHAT_OPENID.getCode(), identity,
ctx.getChannelAppId(), throwOnHit);
}
// 其它通道本期不查用户标识名单
return false;
}
/// 返回是否命中(用于外层判断是否需要打降级日志)
private boolean rejectIfBlocked(PayRiskCheckContext ctx, String type, String value,
String channel, String channelAppId, boolean throwOnHit) {
String wxAppId, boolean throwOnHit) {
if (StrUtil.isBlank(value)) {
return false;
}
Optional<PayBlacklist> hit = payBlacklistService.findActive(type, value, channel, channelAppId);
Optional<PayBlacklist> hit = payBlacklistService.findActive(type, value, wxAppId);
if (hit.isEmpty()) {
return false;
}