feat(payment): 支付宝通道认证与认证调试工具

平台级支付宝配置生成授权中间页/换 userId,新增开发调试认证页与菜单。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DaxPay Dev
2026-07-09 17:12:08 +08:00
parent 428b0b9ce4
commit fa51d117d5
16 changed files with 458 additions and 23 deletions

View File

@@ -142,6 +142,7 @@ INSERT INTO "public"."iam_perm_menu" VALUES (4040106, 40401, 'channel:merchant',
INSERT INTO "public"."iam_perm_menu" VALUES (40502, 40105, 'payment:alipay:isv', 'admin', 'AlipayIsvAppManage', '支付宝服务商应用', 'Alipay ISV Application', 'menu.payment.config.alipayApp', NULL, 't', 'f', '/payment/channel/alipay/manage/app/AlipayIsvAppManage', '/payment/config/product/app-manage', NULL, 3, 'f', 't', 'f', 1, 1, 0, 'f', 'subpage', NULL, NULL, NULL, NULL, NULL, NULL, '2026-06-14 00:00:00+00', '2026-06-14 00:00:00+00');
INSERT INTO "public"."iam_perm_menu" VALUES (801, 8, 'develop:trade', 'admin', 'DevelopTrade', '支付调试', 'Payment Develop', 'menu.develop.trade', 'lucide:credit-card', 'f', 'f', '/payment/develop/trade/DevelopTrade', '/develop/trade', NULL, 1, 'f', 't', 'f', 1, 1, 0, 'f', 'menu', NULL, NULL, NULL, NULL, NULL, NULL, '2026-06-23 16:00:00+00', '2026-06-23 16:00:00+00');
INSERT INTO "public"."iam_perm_menu" VALUES (802, 8, 'develop:sign', 'admin', 'DevelopSign', '签名调试', 'Sign Develop', 'menu.develop.sign', 'lucide:file-signature', 'f', 'f', '/payment/develop/sign/DevelopSign', '/develop/sign', NULL, 2, 'f', 't', 'f', 1, 1, 0, 'f', 'menu', NULL, NULL, NULL, NULL, NULL, NULL, '2026-06-23 16:00:00+00', '2026-06-23 16:00:00+00');
INSERT INTO "public"."iam_perm_menu" VALUES (803, 8, 'develop:auth', 'admin', 'ChannelAuth', '认证调试', 'Auth Develop', 'menu.develop.auth', 'lucide:key-round', 'f', 'f', '/payment/develop/auth/ChannelAuth', '/develop/auth', NULL, 3, 'f', 't', 'f', 1, 1, 0, 'f', 'menu', NULL, NULL, NULL, NULL, NULL, NULL, '2026-07-09 16:00:00+00', '2026-07-09 16:00:00+00');
INSERT INTO "public"."iam_perm_menu" VALUES (4040117, 40401, 'merchant:store', 'admin', 'MchStoreInfoList', '门店管理', 'Store Management', 'menu.payment.merchant.store', NULL, 't', 'f', '/payment/merchant/store/MchStoreInfoList', '/payment/merchant/store', NULL, 15, 'f', 't', 'f', 1, 1, 0, 'f', 'subpage', NULL, NULL, NULL, NULL, NULL, NULL, '2026-06-24 16:00:00+00', '2026-06-24 16:00:00+00');
INSERT INTO "public"."iam_perm_menu" VALUES (4040103, 40401, 'merchant:mchInfo', 'admin', 'MchInfoManage', '商户资料', 'Merchant Profile', 'menu.payment.merchant.profile', NULL, 't', 'f', '/payment/merchant/manage/info/MchInfoManage', '/payment/merchant/manage/info', NULL, 1, 'f', 't', 'f', 1, 1, 0, 'f', 'subpage', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
INSERT INTO "public"."iam_perm_menu" VALUES (4040119, 40401, 'channel:wechat:app', 'admin', 'WechatIsvMchAppManage', '微信服务商子商户应用', 'WeChat ISV Sub-merchant App', 'menu.payment.merchant.channelMerchant.wechatIsvApp', NULL, 't', 'f', '/payment/channel/wechat/manage/mch/isv-app/WechatIsvMchAppManage', '/payment/merchant/channel-merchant/wechat-isv-mch-app-manage', NULL, 14, 'f', 't', 'f', 1, 1, 0, 'f', 'subpage', NULL, NULL, NULL, NULL, NULL, NULL, '2026-07-05 01:49:32.557374+00', '2026-07-05 01:49:32.557374+00');

View File

@@ -74,3 +74,17 @@ DELETE FROM iam_user_social WHERE source IN ('wechatMpPublic', 'douyinH5');
-- 三方平台管理中的抖音应用配置(H5 OAuth)已移除, 清理加密配置残留
-- 三方登录抖音扫码(iam_social_login_config source=douyin)不受影响
DELETE FROM system_platform_encrypt_config WHERE config_type = 'douyin_auth';
-- ===== 开发调试 - 认证调试菜单 =====
-- menuCode=develop:auth, 组件 views/payment/develop/auth/ChannelAuth
-- 权限: develop:auth:view (后端 @PermCode 自动注册)
DELETE FROM "public"."iam_perm_menu" WHERE id = 803;
INSERT INTO "public"."iam_perm_menu" VALUES (
803, 8, 'develop:auth', 'admin', 'ChannelAuth',
'认证调试', 'Auth Develop', 'menu.develop.auth',
'lucide:key-round', 'f', 'f',
'/payment/develop/auth/ChannelAuth', '/develop/auth', NULL,
3, 'f', 't', 'f', 1, 1, 0, 'f', 'menu',
NULL, NULL, NULL, NULL, NULL, NULL,
'2026-07-09 16:00:00+00', '2026-07-09 16:00:00+00'
);

View File

@@ -104,6 +104,12 @@
<artifactId>capability-wechat</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 支付宝开放平台能力(AlipayAuthStrategy 通道认证使用) -->
<dependency>
<groupId>cn.daxpay.open</groupId>
<artifactId>capability-alipay</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 翻译模块 -->
<dependency>
<groupId>cn.daxpay.open</groupId>

View File

@@ -0,0 +1,31 @@
package cn.daxpay.open.payment.core.assist;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 认证会话上下文
///
/// H5授权重定向场景下, 生成授权链接时将认证所需上下文序列化保存到Redis(以 authToken 为key),
/// 授权回调后凭 authToken 恢复, 供认证策略定位通道应用并完成 code 换 openId/userId。
/// 与 [ChannelAuthService] 的 queryCode 机制(付款码/道通场景)解耦, 独立 key 前缀管理。
@Data
@Accessors(chain = true)
public class AuthSession {
/// 支付产品编码
/// @see cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum
private String product;
/// 通道商户号
private String channelMchNo;
/// 支付能力编码(用于解析具体应用)
/// @see cn.daxpay.open.platform.core.enums.pay.channel.PayCapabilityEnum
private String capability;
/// 指定认证应用AppId(可选, 优先级高于配置自动解析)
private String opAppId;
/// 来源回跳路径(授权完成后前端回跳的目标路径)
private String returnPath;
}

View File

@@ -3,12 +3,23 @@ package cn.daxpay.open.payment.core.assist;
import cn.daxpay.open.payment.common.service.MerchantContextLoader;
import cn.daxpay.open.payment.core.strategy.PaymentStrategyFactory;
import cn.daxpay.open.payment.core.strategy.auth.AbsChannelAuthStrategy;
import cn.daxpay.open.platform.common.json.util.JacksonUtil;
import cn.daxpay.open.platform.core.enums.unipay.ChannelAuthStatusEnum;
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.alipay.auth.config.AlipayAuthConfig;
import cn.daxpay.open.platform.capability.alipay.auth.result.AlipayAuthResult;
import cn.daxpay.open.platform.capability.alipay.auth.service.AlipayAuthCapability;
import cn.daxpay.open.platform.common.json.util.JacksonUtil;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.code.DaxPayErrorCode;
import cn.daxpay.open.platform.core.enums.unipay.ChannelAuthStatusEnum;
import cn.daxpay.open.platform.core.enums.unipay.ChannelAuthTypeEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.system.service.config.PlatformAlipayAuthConfigService;
import cn.daxpay.open.platform.system.service.config.PlatformUrlConfigService;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -18,48 +29,176 @@ import org.springframework.stereotype.Service;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/// # 通道认证服务, 用户获取OpenId或UserId等新鲜
/// # 通道认证服务, 用户获取OpenId或UserId等标识
///
/// - **支付宝**: 平台级配置 + H5 中间页 JSAPI 取码(调试/支付共用), 不依赖商户上下文
/// - **其他通道**: 按支付产品路由认证策略; H5 授权重定向场景生成 authToken 保存会话
@Slf4j
@Service
@RequiredArgsConstructor
public class ChannelAuthService {
private final RedisTemplate<String, Object> redisTemplate;
/// 付款码/道通/调试场景的认证结果查询缓存前缀
public static final String CHANNEL_AUTH_KEY_PREFIX = "payment:channel-auth:";
/// H5授权重定向场景的认证会话上下文缓存前缀(与 queryCode 机制解耦)
public static final String AUTH_SESSION_KEY_PREFIX = "payment:auth-session:";
/// 支付宝 H5 中间页路径: /auth/alipay/{aliAppId}/{queryCode}
private static final String ALIPAY_AUTH_PATH = "/auth/alipay/{}/{}";
/// 会话码/查询码过期时间(分钟)
private static final long AUTH_TIMEOUT_MINUTES = 5;
private final MerchantContextLoader merchantContextLoader;
private final PlatformAlipayAuthConfigService platformAlipayAuthConfigService;
private final PlatformUrlConfigService platformUrlConfigService;
private final AlipayAuthCapability alipayAuthCapability;
/// 获取授权链接
///
/// 支付宝走平台级中间页; 其他通道生成 authToken 并委托产品策略。
public AuthUrlResult generateAuthUrl(GenerateAuthUrlParam param) {
// 支付宝: 平台级配置, 不依赖商户/应用
if (isAlipayAuth(param.getAuthType())) {
return generateAlipayAuthUrl();
}
merchantContextLoader.initMchByApp(param.getAppId());
var strategy = PaymentStrategyFactory.create(param.getChannel(), AbsChannelAuthStrategy.class);
AuthUrlResult authUrlResult = strategy.generateAuthUrl(param);
// 如果返回有查询Code值, 将结果写入Redis中
var strategy = PaymentStrategyFactory.createByProduct(param.getProduct(), AbsChannelAuthStrategy.class);
// 生成认证会话码并保存上下文, 授权回调后凭此恢复
String authToken = IdUtil.fastSimpleUUID();
AuthSession session = new AuthSession()
.setProduct(param.getProduct())
.setChannelMchNo(param.getChannelMchNo())
.setCapability(param.getCapability())
.setOpAppId(param.getOpAppId())
.setReturnPath(param.getReturnPath());
redisTemplate.opsForValue().set(AUTH_SESSION_KEY_PREFIX + authToken, session, AUTH_TIMEOUT_MINUTES, TimeUnit.MINUTES);
AuthUrlResult authUrlResult = strategy.generateAuthUrl(param, authToken);
// 返回查询 Code 则写入 Redis 等待结果
if (StrUtil.isNotBlank(authUrlResult.getQueryCode())) {
AuthResult authResult = new AuthResult().setStatus(ChannelAuthStatusEnum.WAITING.getCode());
redisTemplate.opsForValue().set(CHANNEL_AUTH_KEY_PREFIX + authUrlResult.getQueryCode(), authResult, 5, TimeUnit.MINUTES);
saveWaitingResult(authUrlResult.getQueryCode());
}
return authUrlResult;
}
/// 生成支付宝授权中间页链接(平台级, 无商户上下文)
///
/// 二维码指向 H5: `{paymentGatewayBaseUrl}/auth/alipay/{aliAppId}/{queryCode}`,
/// H5 内通过 JSAPI `ap.getAuthCode` 取码后回调 `auth`。
public AuthUrlResult generateAlipayAuthUrl() {
AlipayAuthConfig config = platformAlipayAuthConfigService.toCapabilityConfig();
if (!alipayAuthCapability.isConfigured(config)) {
// 支付宝: 平台级支付宝配置不完整, 请先在「三方平台管理」中配置
throw new BizInfoException(CommonErrorCode.SYSTEM_ERROR, "error.social.alipayNotConfigured");
}
String gatewayBase = platformUrlConfigService.getUrlConfig().getPaymentGatewayBaseUrl();
if (StrUtil.isBlank(gatewayBase)) {
// 支付网关前端地址未配置
throw new BizInfoException(DaxPayErrorCode.CONFIG_ERROR, "error.common.gatewayUrlNotConfigured");
}
String queryCode = RandomUtil.randomString(10);
String authPath = StrUtil.format(ALIPAY_AUTH_PATH, config.getAppId(), queryCode);
String authUrl = StrUtil.removeSuffix(gatewayBase, "/") + authPath;
saveWaitingResult(queryCode);
return new AuthUrlResult().setAuthUrl(authUrl).setQueryCode(queryCode);
}
/// 通过AuthCode获取认证结果
///
/// 支付宝走平台配置换 userId; 其他通道优先用 authToken 恢复会话再调产品策略。
public AuthResult auth(AuthCodeParam param) {
// 支付宝: 平台级换票, 不依赖商户/产品策略
if (isAlipayAuth(param.getAuthType())) {
AuthResult authResult = doAlipayAuth(param.getAuthCode());
authResult.setStatus(ChannelAuthStatusEnum.SUCCESS.getCode());
if (StrUtil.isNotBlank(param.getQueryCode())) {
redisTemplate.opsForValue().set(
CHANNEL_AUTH_KEY_PREFIX + param.getQueryCode(),
authResult,
AUTH_TIMEOUT_MINUTES,
TimeUnit.MINUTES);
}
return authResult;
}
merchantContextLoader.initMchByApp(param.getAppId());
var strategy = PaymentStrategyFactory.create(param.getChannel(), AbsChannelAuthStrategy.class);
AuthResult authResult = strategy.doAuth(param);
// 尝试从会话码恢复上下文
AuthSession session = loadSession(param.getAuthToken());
// product 优先从会话恢复, 其次取参数(小程序直连场景)
String product = (session != null && StrUtil.isNotBlank(session.getProduct()))
? session.getProduct() : param.getProduct();
var strategy = PaymentStrategyFactory.createByProduct(product, AbsChannelAuthStrategy.class);
AuthResult authResult = strategy.doAuth(param, session);
authResult.setStatus(ChannelAuthStatusEnum.SUCCESS.getCode());
// 会话恢复场景: 回填来源回跳路径, 供前端跳回业务页面
if (session != null) {
authResult.setReturnPath(session.getReturnPath());
}
// 兼容原 queryCode 机制(付款码/道通/调试场景)
if (StrUtil.isNotBlank(param.getQueryCode())) {
redisTemplate.opsForValue().set(CHANNEL_AUTH_KEY_PREFIX + param.getQueryCode(), authResult, 5, TimeUnit.MINUTES);
redisTemplate.opsForValue().set(
CHANNEL_AUTH_KEY_PREFIX + param.getQueryCode(),
authResult,
AUTH_TIMEOUT_MINUTES,
TimeUnit.MINUTES);
}
return authResult;
}
/// 通过查询码获取认证结果
/// 支付宝 authCode 换 userId/openId(平台级配置)
private AuthResult doAlipayAuth(String authCode) {
AlipayAuthConfig config = platformAlipayAuthConfigService.toCapabilityConfig();
if (!alipayAuthCapability.isConfigured(config)) {
throw new BizInfoException(CommonErrorCode.SYSTEM_ERROR, "error.social.alipayNotConfigured");
}
AlipayAuthResult alipayResult = alipayAuthCapability.getUserId(config, authCode);
// 统一映射: 支付链路按 openId 取值, 同时回填 userId
String userId = StrUtil.blankToDefault(alipayResult.getUserId(), alipayResult.getOpenId());
if (StrUtil.isBlank(userId)) {
// 支付宝: 获取用户标识失败
throw new BizInfoException(CommonErrorCode.SYSTEM_ERROR, "error.alipay.authFailed", "userId is blank");
}
return new AuthResult()
.setOpenId(userId)
.setUserId(userId)
.setAccessToken(alipayResult.getAccessToken());
}
/// 通过查询码获取认证结果(付款码/道通/调试场景)
public AuthResult queryAuthResult(String queryCode) {
// 从redis中获取读取后显式转换为目标类型
// 从 redis 中获取,读取后显式转换为目标类型
var authResult = redisTemplate.opsForValue().get(CHANNEL_AUTH_KEY_PREFIX + queryCode);
if (Objects.isNull(authResult)) {
return new AuthResult().setStatus(ChannelAuthStatusEnum.NOT_EXIST.getCode());
}
return JacksonUtil.convert(authResult, AuthResult.class);
}
/// 写入 WAITING 状态的查询结果
private void saveWaitingResult(String queryCode) {
AuthResult authResult = new AuthResult().setStatus(ChannelAuthStatusEnum.WAITING.getCode());
redisTemplate.opsForValue().set(
CHANNEL_AUTH_KEY_PREFIX + queryCode,
authResult,
AUTH_TIMEOUT_MINUTES,
TimeUnit.MINUTES);
}
/// 是否支付宝认证类型
private boolean isAlipayAuth(String authType) {
return Objects.equals(authType, ChannelAuthTypeEnum.ALIPAY.getCode());
}
/// 根据 authToken 加载会话上下文, 不存在或过期返回 null
private AuthSession loadSession(String authToken) {
if (StrUtil.isBlank(authToken)) {
return null;
}
var cached = redisTemplate.opsForValue().get(AUTH_SESSION_KEY_PREFIX + authToken);
if (Objects.isNull(cached)) {
return null;
}
return JacksonUtil.convert(cached, AuthSession.class);
}
}

View File

@@ -1,5 +1,6 @@
package cn.daxpay.open.payment.core.strategy.auth;
import cn.daxpay.open.payment.core.assist.AuthSession;
import cn.daxpay.open.payment.core.strategy.PaymentStrategy;
import cn.daxpay.open.payment.unipay.param.assist.AuthCodeParam;
import cn.daxpay.open.payment.unipay.param.assist.GenerateAuthUrlParam;
@@ -8,17 +9,20 @@ import cn.daxpay.open.payment.unipay.result.assist.AuthUrlResult;
/// # 通道抽象认证策略
///
/// 负责获取支付所需的用户标识(微信 openId / 支付宝 userId)。按支付产品([cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum])
/// 注册, 与支付策略粒度一致。策略复用支付的商户配置体系定位通道应用(appId/appSecret/authCallbackUrl)。
public abstract class AbsChannelAuthStrategy implements PaymentStrategy {
/// 获取授权链接
public abstract AuthUrlResult generateAuthUrl(GenerateAuthUrlParam param);
///
/// @param authToken 认证会话码, 由上层 [ChannelAuthService] 生成注入,
/// 策略负责将其拼入回调地址; 授权回跳时凭此恢复上下文。
public abstract AuthUrlResult generateAuthUrl(GenerateAuthUrlParam param, String authToken);
/// 通过AuthCode获取认证结果
public abstract AuthResult doAuth(AuthCodeParam param);
/// 获取微信OpenId认证是否使用道通认证方式
public boolean isWxChannelAuth() {
return false;
}
///
/// @param session 认证会话上下文(H5场景从 authToken 恢复; 小程序直连场景可为空, 此时从 param 取上下文)。
/// 策略需兼容 session 为 null 的情况(优先用 param 的 channelMchNo/capability/opAppId)。
public abstract AuthResult doAuth(AuthCodeParam param, AuthSession session);
}

View File

@@ -0,0 +1,99 @@
package cn.daxpay.open.payment.core.strategy.auth;
import cn.daxpay.open.platform.capability.alipay.auth.config.AlipayAuthConfig;
import cn.daxpay.open.platform.capability.alipay.auth.result.AlipayAuthResult;
import cn.daxpay.open.platform.capability.alipay.auth.service.AlipayAuthCapability;
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.PlatformAlipayAuthConfigService;
import cn.daxpay.open.platform.system.service.config.PlatformUrlConfigService;
import cn.daxpay.open.payment.core.assist.AuthSession;
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.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/// # 支付宝通道认证策略
///
/// 支付宝直连模式(ALIPAY)下获取用户标识(userId)。与微信策略不同, 支付宝认证不依赖商户级配置,
/// 统一使用**平台级**支付宝配置([PlatformAlipayAuthConfigService]), 调用 `alipay.system.oauth.token` 换 userId。
///
/// ## 适用场景
/// - 支付场景获取支付宝 userId(如 ALIPAY_JSAPI 需要)
/// - 三方登录的支付宝授权(iam 模块 AlipaySocialAuthRequest 复用同一份平台配置)
///
/// ## 回调机制
/// 与微信策略同构: 回调地址 = `{paymentGatewayBaseUrl}/auth/alipay/{authToken}`,
/// 授权回跳时凭 authToken 恢复会话上下文(由 [ChannelAuthService] 管理)。
///
@Slf4j
@Service
@RequiredArgsConstructor
public class AlipayAuthStrategy extends AbsChannelAuthStrategy {
/// 支付宝通道认证回调路径(支付网关前端路由)
private static final String AUTH_CALLBACK_PATH = "/auth/alipay/";
/// 授权范围: auth_base(静默授权, 仅取 userId, 不弹确认页, 支付场景体验更好)
private static final String SCOPE = "auth_base";
private final PlatformAlipayAuthConfigService platformAlipayAuthConfigService;
private final PlatformUrlConfigService platformUrlConfigService;
private final AlipayAuthCapability alipayAuthCapability;
@Override
public ProductEnum getProduct() {
return ProductEnum.ALIPAY;
}
/// 生成支付宝授权链接
///
/// 拼接回调地址(`{paymentGatewayBaseUrl}/auth/alipay/{authToken}`),
/// 用平台级配置调 [AlipayAuthCapability] 生成支付宝授权页 URL。
@Override
public AuthUrlResult generateAuthUrl(GenerateAuthUrlParam param, String authToken) {
AlipayAuthConfig capabilityConfig = platformAlipayAuthConfigService.toCapabilityConfig();
if (!alipayAuthCapability.isConfigured(capabilityConfig)) {
// 支付宝: 平台级支付宝配置不完整, 请先在「三方平台管理」中配置
throw new BizInfoException(CommonErrorCode.SYSTEM_ERROR, "error.social.alipayNotConfigured");
}
String redirectUri = this.buildRedirectUri(authToken);
// state 用 authToken, 支付宝回调原样回传, 但 ChannelAuthService 已通过路径里的 authToken 恢复上下文, 这里 state 仅做合规
// 平台级配置固定生产环境, 不再读取 sandbox
String authUrl = alipayAuthCapability.generateAuthUrl(capabilityConfig, redirectUri, SCOPE, authToken, false);
return new AuthUrlResult().setAuthUrl(authUrl);
}
/// 通过授权 authCode 换取支付宝 userId
///
/// 支付宝返回的 userId 统一映射到 [AuthResult] 的 openId 字段(支付链路按 openId 取值)。
@Override
public AuthResult doAuth(AuthCodeParam param, AuthSession session) {
AlipayAuthConfig capabilityConfig = platformAlipayAuthConfigService.toCapabilityConfig();
AlipayAuthResult authResult = alipayAuthCapability.getUserId(capabilityConfig, param.getAuthCode());
if (StrUtil.isBlank(authResult.getUserId())) {
// 支付宝: 获取用户标识失败
throw new BizInfoException(CommonErrorCode.SYSTEM_ERROR, "error.alipay.authFailed", "userId is blank");
}
// 支付宝 userId 映射到 openId(支付链路统一用 openId 字段)
return new AuthResult()
.setOpenId(authResult.getUserId())
.setUserId(authResult.getUserId());
}
/// 拼接认证回调地址: {paymentGatewayBaseUrl}/auth/alipay/{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_CALLBACK_PATH + authToken;
}
}

View File

@@ -4,6 +4,7 @@ import cn.daxpay.open.platform.core.enums.unipay.ChannelAuthTypeEnum;
import cn.daxpay.open.payment.unipay.param.MerchantPaymentCommonParam;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
@@ -16,8 +17,8 @@ import lombok.experimental.Accessors;
@Schema(title = "通道认证参数")
public class AuthCodeParam extends MerchantPaymentCommonParam {
/// 通道(支付宝平台级认证可不传)
@Schema(description = "通道")
@NotBlank(message = "{validation.field.channel.notBlank}")
private String channel;
/// 认证类型, 如果通道支持多种类型的情况下, 参数必传
@@ -44,5 +45,26 @@ public class AuthCodeParam extends MerchantPaymentCommonParam {
@Schema(description = "查询Code")
private String queryCode;
/// 认证会话码, H5授权重定向场景下由生成授权链接时下发, 回调后凭此恢复认证上下文
@Size(max = 64, message = "{validation.field.authToken.size}")
@Schema(description = "认证会话码")
private String authToken;
/// 支付产品编码, 小程序直连场景(无会话码)必传; H5会话码场景可不传(从会话恢复)
/// @see cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum
@Size(max = 32, message = "{validation.field.product.size}")
@Schema(description = "支付产品编码")
private String product;
/// 支付能力编码, 用于解析具体应用, 小程序场景需要; 会话码场景从会话恢复
/// @see cn.daxpay.open.platform.core.enums.pay.channel.PayCapabilityEnum
@Size(max = 32, message = "{validation.field.capability.size}")
@Schema(description = "支付能力编码")
private String capability;
/// 指定认证使用的应用AppId, 会话码恢复上下文时可不传, 优先级高于配置自动解析
@Size(max = 128, message = "{validation.field.opAppId.size}")
@Schema(description = "指定认证应用AppId")
private String opAppId;
}

View File

@@ -5,6 +5,7 @@ import cn.daxpay.open.platform.core.enums.pay.channel.ChannelEnum;
import cn.daxpay.open.payment.unipay.param.MerchantPaymentCommonParam;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
@@ -33,5 +34,28 @@ public class GenerateAuthUrlParam extends MerchantPaymentCommonParam {
/// 配置的域名优先级: 通道配置 》 平台配置
@Schema(description = "授权认证路径")
private String authPath;
/// 支付产品编码, 决定走哪个通道产品的认证策略
/// @see cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum
@NotBlank(message = "{validation.field.product.notBlank}")
@Size(max = 32, message = "{validation.field.product.size}")
@Schema(description = "支付产品编码")
private String product;
/// 指定认证使用的应用AppId, 优先级高于配置自动解析, 必须在系统中预先配置过
@Size(max = 128, message = "{validation.field.opAppId.size}")
@Schema(description = "指定认证应用AppId")
private String opAppId;
/// 来源回跳路径, 授权完成后前端回跳的目标路径, 会随会话码一起保存
@Size(max = 200, message = "{validation.field.returnPath.size}")
@Schema(description = "来源回跳路径")
private String returnPath;
/// 支付能力编码, 用于解析具体应用(公众号/小程序), 不同能力对应不同应用维度的openId
/// @see cn.daxpay.open.platform.core.enums.pay.channel.PayCapabilityEnum
@Size(max = 32, message = "{validation.field.capability.size}")
@Schema(description = "支付能力编码")
private String capability;
}

View File

@@ -27,5 +27,9 @@ public class AuthResult {
/// @see ChannelAuthStatusEnum
@Schema(description = "状态")
private String status;
/// 来源回跳路径(会话恢复时回填), 前端授权完成后据此跳回业务页面
@Schema(description = "来源回跳路径")
private String returnPath;
}

View File

@@ -0,0 +1,46 @@
package cn.daxpay.open.payment.web.develop.controller;
import cn.daxpay.open.payment.unipay.result.assist.AuthResult;
import cn.daxpay.open.payment.unipay.result.assist.AuthUrlResult;
import cn.daxpay.open.payment.web.develop.service.DevelopAuthService;
import cn.daxpay.open.platform.core.annotation.PermCode;
import cn.daxpay.open.platform.core.rest.Res;
import cn.daxpay.open.platform.core.rest.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/// 认证调试(管理)
///
/// 首期仅支付宝: 生成授权中间页链接并轮询认证结果。
@Validated
@PermCode(menuCode = "develop:auth")
@Tag(name = "认证调试服务")
@RestController
@RequestMapping("/admin/develop/auth")
@RequiredArgsConstructor
public class DevelopAuthController {
private final DevelopAuthService developAuthService;
@PermCode(code = "view", nameCn = "查看", nameEn = "View")
@Operation(summary = "生成支付宝授权链接")
@PostMapping("/generate-auth-url")
public Result<AuthUrlResult> generateAuthUrl() {
return Res.ok(developAuthService.generateAlipayAuthUrl());
}
@PermCode(code = "view", nameCn = "查看", nameEn = "View")
@Operation(summary = "通过查询码获取认证结果")
@GetMapping("/query-auth-result")
public Result<AuthResult> queryAuthResult(
@NotBlank(message = "{validation.field.queryCode.notBlank}") String queryCode) {
return Res.ok(developAuthService.queryAuthResult(queryCode));
}
}

View File

@@ -0,0 +1,29 @@
package cn.daxpay.open.payment.web.develop.service;
import cn.daxpay.open.payment.core.assist.ChannelAuthService;
import cn.daxpay.open.payment.unipay.result.assist.AuthResult;
import cn.daxpay.open.payment.unipay.result.assist.AuthUrlResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/// # 认证调试服务
///
/// 首期仅支持支付宝: 平台级配置生成 H5 中间页授权链接, 轮询 queryCode 取结果。
@Slf4j
@Service
@RequiredArgsConstructor
public class DevelopAuthService {
private final ChannelAuthService channelAuthService;
/// 生成支付宝授权链接(中间页 + queryCode)
public AuthUrlResult generateAlipayAuthUrl() {
return channelAuthService.generateAlipayAuthUrl();
}
/// 通过查询码获取认证结果
public AuthResult queryAuthResult(String queryCode) {
return channelAuthService.queryAuthResult(queryCode);
}
}

View File

@@ -30,5 +30,6 @@
"mchAppStatusNotFound": "Merchant app status not found: {0}",
"storeStatusNotFound": "Store status not found: {0}",
"backendBaseUrlNotConfigured": "Backend base URL (backendBaseUrl) is not configured, cannot generate callback URL",
"requestContextMissing": "Cannot obtain request context"
"requestContextMissing": "Cannot obtain request context",
"gatewayUrlNotConfigured": "Payment gateway base URL (paymentGatewayBaseUrl) is not configured, cannot generate auth callback URL"
}

View File

@@ -468,6 +468,13 @@
"queryCode": {
"notBlank": "Query code cannot be blank"
},
"authToken": {
"notBlank": "Auth token cannot be blank",
"size": "Auth token cannot exceed 64 characters"
},
"returnPath": {
"size": "Return path cannot exceed 200 characters"
},
"taskId": {
"notNull": "Message ID cannot be blank"
},

View File

@@ -30,5 +30,6 @@
"mchAppStatusNotFound": "未找到对应的商户应用状态类型: {0}",
"storeStatusNotFound": "未找到对应的门店状态类型: {0}",
"backendBaseUrlNotConfigured": "平台后端访问地址(backendBaseUrl)未配置, 无法生成回调地址",
"requestContextMissing": "无法获取请求上下文"
"requestContextMissing": "无法获取请求上下文",
"gatewayUrlNotConfigured": "支付网关前端地址(paymentGatewayBaseUrl)未配置, 无法生成认证回调地址"
}

View File

@@ -471,6 +471,13 @@
"queryCode": {
"notBlank": "查询码不可为空"
},
"authToken": {
"notBlank": "认证会话码不可为空",
"size": "认证会话码不可超过64位"
},
"returnPath": {
"size": "来源回跳路径不可超过200位"
},
"taskId": {
"notNull": "消息ID不可为空"
},