mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-09 22:45:58 +08:00
feat(payment): 支付宝通道认证与认证调试工具
平台级支付宝配置生成授权中间页/换 userId,新增开发调试认证页与菜单。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,5 +27,9 @@ public class AuthResult {
|
||||
/// @see ChannelAuthStatusEnum
|
||||
@Schema(description = "状态")
|
||||
private String status;
|
||||
|
||||
/// 来源回跳路径(会话恢复时回填), 前端授权完成后据此跳回业务页面
|
||||
@Schema(description = "来源回跳路径")
|
||||
private String returnPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -30,5 +30,6 @@
|
||||
"mchAppStatusNotFound": "未找到对应的商户应用状态类型: {0}",
|
||||
"storeStatusNotFound": "未找到对应的门店状态类型: {0}",
|
||||
"backendBaseUrlNotConfigured": "平台后端访问地址(backendBaseUrl)未配置, 无法生成回调地址",
|
||||
"requestContextMissing": "无法获取请求上下文"
|
||||
"requestContextMissing": "无法获取请求上下文",
|
||||
"gatewayUrlNotConfigured": "支付网关前端地址(paymentGatewayBaseUrl)未配置, 无法生成认证回调地址"
|
||||
}
|
||||
|
||||
@@ -471,6 +471,13 @@
|
||||
"queryCode": {
|
||||
"notBlank": "查询码不可为空"
|
||||
},
|
||||
"authToken": {
|
||||
"notBlank": "认证会话码不可为空",
|
||||
"size": "认证会话码不可超过64位"
|
||||
},
|
||||
"returnPath": {
|
||||
"size": "来源回跳路径不可超过200位"
|
||||
},
|
||||
"taskId": {
|
||||
"notNull": "消息ID不可为空"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user