diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/client/WechatChannelClient.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/client/WechatChannelClient.java index 28e86a11d..faf473a47 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/client/WechatChannelClient.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/client/WechatChannelClient.java @@ -20,7 +20,8 @@ import org.springframework.web.service.annotation.PostExchange; /// # 微信通道客户端 /// -/// 声明式 HTTP 接口, 调用子应用 dax-pay-channel-one 的微信通道接口。 +/// 声明式 HTTP 接口, 调用子应用 dax-pay-channel-one 的微信**支付**通道接口。 +/// 公众号/小程序 OAuth 认证见 capability-wechat, 不经本客户端。 @HttpExchange(accept = MediaType.APPLICATION_JSON_VALUE, contentType = MediaType.APPLICATION_JSON_VALUE) public interface WechatChannelClient { diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/code/WechatAuthAppTypeEnum.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/code/WechatAuthAppTypeEnum.java new file mode 100644 index 000000000..9dd2688ba --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/code/WechatAuthAppTypeEnum.java @@ -0,0 +1,21 @@ +package cn.daxpay.open.channel.wechat.code; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/// # 微信服务商认证应用类型 +/// +/// 服务商模式下授权获取 openId 时, 控制使用服务商应用还是子商户应用。 +/// 配置挂在特约商户维度([cn.daxpay.open.channel.wechat.entity.isv.WechatIsvChannelMerchant]), 默认 SP_APP。 +@Getter +@RequiredArgsConstructor +public enum WechatAuthAppTypeEnum { + + /// 服务商应用(WechatIsvApp.sp_appid) + SP_APP("SP_APP"), + /// 子商户应用(WechatIsvMchApp.sub_appid) + SUB_APP("SUB_APP"); + + /// 编码 + private final String code; +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/direct/WechatDirectAppManager.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/direct/WechatDirectAppManager.java index 6c154ce38..7d6583876 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/direct/WechatDirectAppManager.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/direct/WechatDirectAppManager.java @@ -41,6 +41,13 @@ public class WechatDirectAppManager extends BaseManager findByChannelMchNoAndWxAppId(String channelMchNo, String wxAppId) { + return firstOpt(q -> q + .eq(WechatDirectApp::getChannelMchNo, channelMchNo) + .eq(WechatDirectApp::getWxAppId, wxAppId)); + } + /// 校验同一通道商户下wxAppId是否已存在(排除自身) public boolean existsByChannelMchNoAndWxAppId(String mchNo, String channelMchNo, String wxAppId, Long excludeId) { return lambdaQuery() diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvAppManager.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvAppManager.java index 1ba8e08ad..44cb34504 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvAppManager.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvAppManager.java @@ -39,6 +39,11 @@ public class WechatIsvAppManager extends BaseManager findByWxAppId(String wxAppId) { + return firstOpt(q -> q.eq(WechatIsvApp::getWxAppId, wxAppId)); + } + /// 校验微信应用AppId是否已存在(排除自身) public boolean existsByWxAppId(String wxAppId, Long excludeId) { return lambdaQuery() diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvMchAppManager.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvMchAppManager.java index c86be3ff5..8d111383d 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvMchAppManager.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/dao/isv/WechatIsvMchAppManager.java @@ -42,6 +42,13 @@ public class WechatIsvMchAppManager extends BaseManager findByChannelMchNoAndWxAppId(String channelMchNo, String wxAppId) { + return firstOpt(q -> q + .eq(WechatIsvMchApp::getChannelMchNo, channelMchNo) + .eq(WechatIsvMchApp::getWxAppId, wxAppId)); + } + /// 校验同一通道商户下wxAppId是否已存在(排除自身) public boolean existsByChannelMchNoAndWxAppId(String mchNo, String channelMchNo, String wxAppId, Long excludeId) { return lambdaQuery() diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/entity/isv/WechatIsvChannelMerchant.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/entity/isv/WechatIsvChannelMerchant.java index da7f30da5..1f9246130 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/entity/isv/WechatIsvChannelMerchant.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/entity/isv/WechatIsvChannelMerchant.java @@ -30,6 +30,11 @@ public class WechatIsvChannelMerchant extends MchBaseEntity implements ToResult< /// 微信特约商户号/二级商户号(V3 服务商支付 sub_mchid) private String subMchId; + /// 认证应用类型(服务商模式下授权获取openId时使用) + /// SP_APP=服务商应用(sp_appid), SUB_APP=子商户应用(sub_appid), 默认 SP_APP + /// @see cn.daxpay.open.channel.wechat.code.WechatAuthAppTypeEnum + private String authAppType; + /// 转换 @Override public WechatIsvChannelMerchantResult toResult() { diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/isv/WechatIsvChannelMerchantCreateParam.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/isv/WechatIsvChannelMerchantCreateParam.java index f4b31b998..209300554 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/isv/WechatIsvChannelMerchantCreateParam.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/param/isv/WechatIsvChannelMerchantCreateParam.java @@ -31,4 +31,9 @@ public class WechatIsvChannelMerchantCreateParam { @Schema(description = "微信特约商户号/二级商户号") @NotBlank(message = "{validation.field.subMchId.notBlank}") private String subMchId; + + /// 认证应用类型(SP_APP=服务商应用, SUB_APP=子商户应用), 不传默认 SP_APP + /// @see cn.daxpay.open.channel.wechat.code.WechatAuthAppTypeEnum + @Schema(description = "认证应用类型(默认SP_APP)") + private String authAppType; } diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/isv/WechatIsvChannelMerchantResult.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/isv/WechatIsvChannelMerchantResult.java index 2ddb28feb..e84f875a5 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/isv/WechatIsvChannelMerchantResult.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/result/isv/WechatIsvChannelMerchantResult.java @@ -24,4 +24,9 @@ public class WechatIsvChannelMerchantResult extends MchTradeBaseResult { @Schema(description = "微信特约商户号/二级商户号") private String subMchId; + + /// 认证应用类型(SP_APP=服务商应用, SUB_APP=子商户应用, 默认 SP_APP) + /// @see cn.daxpay.open.channel.wechat.code.WechatAuthAppTypeEnum + @Schema(description = "认证应用类型") + private String authAppType; } diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/isv/WechatIsvChannelMerchantService.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/isv/WechatIsvChannelMerchantService.java index 419be95aa..f62b8a52e 100644 --- a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/isv/WechatIsvChannelMerchantService.java +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/service/isv/WechatIsvChannelMerchantService.java @@ -1,5 +1,6 @@ package cn.daxpay.open.channel.wechat.service.isv; +import cn.daxpay.open.channel.wechat.code.WechatAuthAppTypeEnum; import cn.daxpay.open.channel.wechat.dao.isv.WechatIsvChannelMerchantManager; import cn.daxpay.open.channel.wechat.entity.isv.WechatIsvChannelMerchant; import cn.daxpay.open.channel.wechat.param.isv.WechatIsvChannelMerchantCreateParam; @@ -11,6 +12,7 @@ import cn.daxpay.open.platform.core.enums.channel.ChannelMerchantSourceEnum; import cn.daxpay.open.platform.core.exception.BizInfoException; import cn.daxpay.open.platform.core.exception.DataNotExistException; import cn.daxpay.open.platform.core.util.ChannelMchNoGenerateUtil; +import cn.hutool.core.util.StrUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -55,6 +57,10 @@ public class WechatIsvChannelMerchantService { entity.setChannelMchNo(channelMchNo); entity.setProduct(param.getProduct()); entity.setSubMchId(param.getSubMchId()); + // 认证应用类型: 不传默认 SP_APP(服务商应用), 认证策略据此路由 sp/sub 应用 + entity.setAuthAppType(StrUtil.isNotBlank(param.getAuthAppType()) + ? param.getAuthAppType() + : WechatAuthAppTypeEnum.SP_APP.getCode()); wechatIsvChannelMerchantManager.save(entity); } diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/strategy/direct/WechatDirectAuthStrategy.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/strategy/direct/WechatDirectAuthStrategy.java new file mode 100644 index 000000000..7d1a00c5b --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/strategy/direct/WechatDirectAuthStrategy.java @@ -0,0 +1,111 @@ +package cn.daxpay.open.channel.wechat.strategy.direct; + +import cn.daxpay.open.channel.wechat.dao.direct.WechatDirectAppManager; +import cn.daxpay.open.channel.wechat.entity.direct.WechatDirectApp; +import cn.daxpay.open.channel.wechat.entity.direct.WechatDirectAppAuthConfig; +import cn.daxpay.open.channel.wechat.service.direct.WechatDirectAppAuthConfigService; +import cn.daxpay.open.channel.wechat.service.direct.WechatDirectAppCapabilityService; +import cn.daxpay.open.payment.core.assist.AuthSession; +import cn.daxpay.open.payment.core.strategy.auth.AbsChannelAuthStrategy; +import cn.daxpay.open.payment.unipay.param.assist.AuthCodeParam; +import cn.daxpay.open.payment.unipay.param.assist.GenerateAuthUrlParam; +import cn.daxpay.open.payment.unipay.result.assist.AuthResult; +import cn.daxpay.open.payment.unipay.result.assist.AuthUrlResult; +import cn.daxpay.open.platform.capability.wechat.auth.result.WechatAuthResult; +import cn.daxpay.open.platform.capability.wechat.auth.result.WechatAuthUrlResult; +import cn.daxpay.open.platform.capability.wechat.auth.service.WechatMpAuthService; +import cn.daxpay.open.platform.core.code.CommonErrorCode; +import cn.daxpay.open.platform.core.code.DaxPayErrorCode; +import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; +import cn.daxpay.open.platform.core.exception.BizInfoException; +import cn.daxpay.open.platform.system.service.config.PlatformUrlConfigService; +import cn.hutool.core.util.StrUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/// # 微信直连认证策略 +/// +/// 微信直连模式(WECHAT_PAY)下获取用户标识(openId)。复用支付的商户配置体系定位直连应用 +/// (WechatDirectApp + WechatDirectAppAuthConfig), 调用 capability-wechat 完成 OAuth。 +/// +/// ## appId 解析优先级 +/// 1. opAppId 显式指定 → 校验该 appId 在系统预配过(按 channelMchNo + wxAppId 查), 命中即用 +/// 2. 否则按 capability 自动解析(显式能力配置 > appType 推导 > 首个兜底) +@Slf4j +@Service +@RequiredArgsConstructor +public class WechatDirectAuthStrategy extends AbsChannelAuthStrategy { + + private final WechatMpAuthService wechatMpAuthService; + private final WechatDirectAppCapabilityService wechatDirectAppCapabilityService; + private final WechatDirectAppAuthConfigService wechatDirectAppAuthConfigService; + private final WechatDirectAppManager wechatDirectAppManager; + private final PlatformUrlConfigService platformUrlConfigService; + + @Override + public ProductEnum getProduct() { + return ProductEnum.WECHAT_PAY; + } + + /// 生成公众号 OAuth 授权链接 + /// + /// 拼接回调地址({paymentGatewayBaseUrl}/auth/wechat/{authToken}), 委托 capability-wechat 生成微信 OAuth URL。 + @Override + public AuthUrlResult generateAuthUrl(GenerateAuthUrlParam param, String authToken) { + WechatDirectApp app = resolveApp(param.getChannelMchNo(), param.getCapability(), param.getOpAppId()); + WechatDirectAppAuthConfig authConfig = wechatDirectAppAuthConfigService.findByWechatDirectAppId(app.getId()); + String redirectUri = buildRedirectUri(authToken); + WechatAuthUrlResult result = wechatMpAuthService.generateAuthUrl( + redirectUri, app.getWxAppId(), authConfig.getAppSecret()); + return new AuthUrlResult().setAuthUrl(result.getAuthUrl()); + } + + /// 通过授权 code(公众号 OAuth 回调的 code)换取 openId + /// + /// 应用上下文优先从 session(H5 会话码场景)恢复, 否则取 param(小程序直连场景)。 + @Override + public AuthResult doAuth(AuthCodeParam param, AuthSession session) { + String channelMchNo = session != null && StrUtil.isNotBlank(session.getChannelMchNo()) + ? session.getChannelMchNo() : param.getChannelMchNo(); + String capability = session != null && StrUtil.isNotBlank(session.getCapability()) + ? session.getCapability() : param.getCapability(); + String opAppId = session != null && StrUtil.isNotBlank(session.getOpAppId()) + ? session.getOpAppId() : param.getOpAppId(); + WechatDirectApp app = resolveApp(channelMchNo, capability, opAppId); + WechatDirectAppAuthConfig authConfig = wechatDirectAppAuthConfigService.findByWechatDirectAppId(app.getId()); + WechatAuthResult data = wechatMpAuthService.getTokenAndOpenId( + param.getAuthCode(), app.getWxAppId(), authConfig.getAppSecret()); + if (StrUtil.isBlank(data.getOpenId())) { + // 微信: 获取openId失败 + throw new BizInfoException(CommonErrorCode.SYSTEM_ERROR, "error.channel.wechat.authFailed", "openId is blank"); + } + return new AuthResult() + .setOpenId(data.getOpenId()) + .setAccessToken(data.getAccessToken()); + } + + /// 解析认证应用: opAppId 显式指定(校验预配) > capability 自动解析 + private WechatDirectApp resolveApp(String channelMchNo, String capability, String opAppId) { + if (StrUtil.isNotBlank(opAppId)) { + // opAppId 必须在系统预配过(硬约束, 防任意 appId 滥用) + return wechatDirectAppManager.findByChannelMchNoAndWxAppId(channelMchNo, opAppId) + .orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, + "error.channel.wechat.opAppIdNotFound", opAppId)); + } + return wechatDirectAppCapabilityService.resolveApp(channelMchNo, capability) + // 微信: 直连商户应用不存在 + .orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, + "error.channel.wechat.mchAppNotFound")); + } + + /// 拼接认证回调地址: {paymentGatewayBaseUrl}/auth/wechat/{authToken} + private String buildRedirectUri(String authToken) { + String base = platformUrlConfigService.getUrlConfig().getPaymentGatewayBaseUrl(); + if (StrUtil.isBlank(base)) { + // 支付网关前端地址未配置 + throw new BizInfoException(DaxPayErrorCode.CONFIG_ERROR, "error.common.gatewayUrlNotConfigured"); + } + return StrUtil.removeSuffix(base, "/") + "/auth/wechat/" + authToken; + } +} diff --git a/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/strategy/isv/WechatIsvAuthStrategy.java b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/strategy/isv/WechatIsvAuthStrategy.java new file mode 100644 index 000000000..e989dffc0 --- /dev/null +++ b/daxpay-channel/daxpay-channel-wechat/src/main/java/cn/daxpay/open/channel/wechat/strategy/isv/WechatIsvAuthStrategy.java @@ -0,0 +1,166 @@ +package cn.daxpay.open.channel.wechat.strategy.isv; + +import cn.daxpay.open.channel.wechat.code.WechatAuthAppTypeEnum; +import cn.daxpay.open.channel.wechat.dao.isv.WechatIsvAppManager; +import cn.daxpay.open.channel.wechat.dao.isv.WechatIsvChannelMerchantManager; +import cn.daxpay.open.channel.wechat.dao.isv.WechatIsvMchAppManager; +import cn.daxpay.open.channel.wechat.entity.isv.WechatIsvApp; +import cn.daxpay.open.channel.wechat.entity.isv.WechatIsvAppAuthConfig; +import cn.daxpay.open.channel.wechat.entity.isv.WechatIsvChannelMerchant; +import cn.daxpay.open.channel.wechat.entity.isv.WechatIsvMchApp; +import cn.daxpay.open.channel.wechat.entity.isv.WechatIsvMchAppAuthConfig; +import cn.daxpay.open.channel.wechat.service.isv.WechatIsvAppAuthConfigService; +import cn.daxpay.open.channel.wechat.service.isv.WechatIsvAppCapabilityService; +import cn.daxpay.open.channel.wechat.service.isv.WechatIsvMchAppAuthConfigService; +import cn.daxpay.open.channel.wechat.service.isv.WechatIsvMchAppCapabilityService; +import cn.daxpay.open.payment.core.assist.AuthSession; +import cn.daxpay.open.payment.core.strategy.auth.AbsChannelAuthStrategy; +import cn.daxpay.open.payment.unipay.param.assist.AuthCodeParam; +import cn.daxpay.open.payment.unipay.param.assist.GenerateAuthUrlParam; +import cn.daxpay.open.payment.unipay.result.assist.AuthResult; +import cn.daxpay.open.payment.unipay.result.assist.AuthUrlResult; +import cn.daxpay.open.platform.capability.wechat.auth.result.WechatAuthResult; +import cn.daxpay.open.platform.capability.wechat.auth.result.WechatAuthUrlResult; +import cn.daxpay.open.platform.capability.wechat.auth.service.WechatMpAuthService; +import cn.daxpay.open.platform.core.code.CommonErrorCode; +import cn.daxpay.open.platform.core.code.DaxPayErrorCode; +import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum; +import cn.daxpay.open.platform.core.exception.BizInfoException; +import cn.daxpay.open.platform.system.service.config.PlatformUrlConfigService; +import cn.hutool.core.util.StrUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/// # 微信服务商认证策略 +/// +/// 微信服务商模式(WECHAT_ISV)下获取用户标识(openId)。按特约商户配置的认证应用类型 +/// ([cn.daxpay.open.channel.wechat.entity.isv.WechatIsvChannelMerchant.authAppType]) 路由: +/// +/// - **SP_APP(默认)**: 用服务商应用(WechatIsvApp.sp_appid) + WechatIsvAppAuthConfig 认证, 所得为 sp 维度 openId +/// - **SUB_APP**: 用子商户应用(WechatIsvMchApp.sub_appid) + WechatIsvMchAppAuthConfig 认证, 所得为 sub 维度 openId +/// +/// ## appId 解析优先级 +/// 1. opAppId 显式指定 → 按 authAppType 查对应表校验预配, 命中即用 +/// 2. 否则按 authAppType + capability 自动解析 +/// +/// 微信 OAuth 调用 capability-wechat, 与支付通道子应用解耦; 区别仅是主应用传入的 wxAppId/appSecret 来源不同。 +@Slf4j +@Service +@RequiredArgsConstructor +public class WechatIsvAuthStrategy extends AbsChannelAuthStrategy { + + private final WechatMpAuthService wechatMpAuthService; + private final WechatIsvAppCapabilityService wechatIsvAppCapabilityService; + private final WechatIsvAppAuthConfigService wechatIsvAppAuthConfigService; + private final WechatIsvAppManager wechatIsvAppManager; + private final WechatIsvMchAppCapabilityService wechatIsvMchAppCapabilityService; + private final WechatIsvMchAppAuthConfigService wechatIsvMchAppAuthConfigService; + private final WechatIsvMchAppManager wechatIsvMchAppManager; + private final WechatIsvChannelMerchantManager wechatIsvChannelMerchantManager; + private final PlatformUrlConfigService platformUrlConfigService; + + @Override + public ProductEnum getProduct() { + return ProductEnum.WECHAT_ISV; + } + + /// 生成公众号 OAuth 授权链接 + @Override + public AuthUrlResult generateAuthUrl(GenerateAuthUrlParam param, String authToken) { + ResolvedAuthApp app = resolveAuthApp(param.getChannelMchNo(), param.getCapability(), param.getOpAppId()); + String redirectUri = buildRedirectUri(authToken); + WechatAuthUrlResult result = wechatMpAuthService.generateAuthUrl( + redirectUri, app.wxAppId(), app.appSecret()); + return new AuthUrlResult().setAuthUrl(result.getAuthUrl()); + } + + /// 通过授权 code 换取 openId + @Override + public AuthResult doAuth(AuthCodeParam param, AuthSession session) { + String channelMchNo = session != null && StrUtil.isNotBlank(session.getChannelMchNo()) + ? session.getChannelMchNo() : param.getChannelMchNo(); + String capability = session != null && StrUtil.isNotBlank(session.getCapability()) + ? session.getCapability() : param.getCapability(); + String opAppId = session != null && StrUtil.isNotBlank(session.getOpAppId()) + ? session.getOpAppId() : param.getOpAppId(); + ResolvedAuthApp app = resolveAuthApp(channelMchNo, capability, opAppId); + WechatAuthResult data = wechatMpAuthService.getTokenAndOpenId( + param.getAuthCode(), app.wxAppId(), app.appSecret()); + if (StrUtil.isBlank(data.getOpenId())) { + // 微信: 获取openId失败 + throw new BizInfoException(CommonErrorCode.SYSTEM_ERROR, "error.channel.wechat.authFailed", "openId is blank"); + } + return new AuthResult() + .setOpenId(data.getOpenId()) + .setAccessToken(data.getAccessToken()); + } + + /// 解析认证应用(返回 wxAppId + appSecret) + /// + /// 优先级: opAppId(按authAppType校验对应表) > authAppType + capability 自动解析 + private ResolvedAuthApp resolveAuthApp(String channelMchNo, String capability, String opAppId) { + WechatAuthAppTypeEnum authAppType = loadAuthAppType(channelMchNo); + if (StrUtil.isNotBlank(opAppId)) { + return resolveByOpAppId(channelMchNo, opAppId, authAppType); + } + return resolveByConfig(channelMchNo, capability, authAppType); + } + + /// 读取特约商户的认证应用类型, 默认 SP_APP(服务商应用) + private WechatAuthAppTypeEnum loadAuthAppType(String channelMchNo) { + return wechatIsvChannelMerchantManager.findByChannelMchNo(channelMchNo) + .map(WechatIsvChannelMerchant::getAuthAppType) + .filter(StrUtil::isNotBlank) + .map(WechatAuthAppTypeEnum::valueOf) + .orElse(WechatAuthAppTypeEnum.SP_APP); + } + + /// 按配置(authAppType + capability)解析应用 + private ResolvedAuthApp resolveByConfig(String channelMchNo, String capability, WechatAuthAppTypeEnum authAppType) { + if (authAppType == WechatAuthAppTypeEnum.SP_APP) { + WechatIsvApp app = wechatIsvAppCapabilityService.resolveApp(capability) + // 微信: 服务商应用不存在 + .orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, + "error.channel.wechat.appNotFound")); + WechatIsvAppAuthConfig cfg = wechatIsvAppAuthConfigService.findByWechatIsvAppId(app.getId()); + return new ResolvedAuthApp(app.getWxAppId(), cfg.getAppSecret()); + } + // SUB_APP: 子商户应用(仅显式配置, 未配置返回empty) + WechatIsvMchApp app = wechatIsvMchAppCapabilityService.resolveApp(channelMchNo, capability) + // 微信: 服务商通道商户应用不存在 + .orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, + "error.channel.wechat.mchAppNotFound")); + WechatIsvMchAppAuthConfig cfg = wechatIsvMchAppAuthConfigService.findByWechatIsvMchAppId(app.getId()); + return new ResolvedAuthApp(app.getWxAppId(), cfg.getAppSecret()); + } + + /// 按 opAppId 解析应用(按 authAppType 查对应表校验预配) + private ResolvedAuthApp resolveByOpAppId(String channelMchNo, String opAppId, WechatAuthAppTypeEnum authAppType) { + if (authAppType == WechatAuthAppTypeEnum.SP_APP) { + WechatIsvApp app = wechatIsvAppManager.findByWxAppId(opAppId) + .orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, + "error.channel.wechat.opAppIdNotFound", opAppId)); + WechatIsvAppAuthConfig cfg = wechatIsvAppAuthConfigService.findByWechatIsvAppId(app.getId()); + return new ResolvedAuthApp(app.getWxAppId(), cfg.getAppSecret()); + } + WechatIsvMchApp app = wechatIsvMchAppManager.findByChannelMchNoAndWxAppId(channelMchNo, opAppId) + .orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, + "error.channel.wechat.opAppIdNotFound", opAppId)); + WechatIsvMchAppAuthConfig cfg = wechatIsvMchAppAuthConfigService.findByWechatIsvMchAppId(app.getId()); + return new ResolvedAuthApp(app.getWxAppId(), cfg.getAppSecret()); + } + + /// 拼接认证回调地址: {paymentGatewayBaseUrl}/auth/wechat/{authToken} + private String buildRedirectUri(String authToken) { + String base = platformUrlConfigService.getUrlConfig().getPaymentGatewayBaseUrl(); + if (StrUtil.isBlank(base)) { + // 支付网关前端地址未配置 + throw new BizInfoException(DaxPayErrorCode.CONFIG_ERROR, "error.common.gatewayUrlNotConfigured"); + } + return StrUtil.removeSuffix(base, "/") + "/auth/wechat/" + authToken; + } + + /// 解析后的认证应用(wxAppId + appSecret) + private record ResolvedAuthApp(String wxAppId, String appSecret) { } +}