refactor(auth): 通道认证入口重命名并按产品路由网关授权

将 ChannelAuthFacade 升为 ChannelAuthService,商户产品策略拆为 ChannelProductAuthService;删除 Auth 参数遗留 channel 字段。网关/码牌授权改为经产品路由取 openId,抖音直连认证策略与 Jsapi 控制器下沉通道模块。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DaxPay Dev
2026-07-22 17:38:23 +08:00
parent ed5d4e501b
commit 54a52b680e
46 changed files with 948 additions and 345 deletions

View File

@@ -0,0 +1,140 @@
package cn.daxpay.open.channel.douyin.controller;
import cn.daxpay.open.channel.douyin.entity.direct.DouyinDirectApp;
import cn.daxpay.open.channel.douyin.entity.direct.DouyinDirectAppAuthConfig;
import cn.daxpay.open.channel.douyin.param.assist.DouyinJsapiConfigParam;
import cn.daxpay.open.channel.douyin.service.direct.DouyinDirectAppAuthConfigService;
import cn.daxpay.open.channel.douyin.service.direct.DouyinDirectAppCapabilityService;
import cn.daxpay.open.channel.douyin.strategy.direct.auth.DouyinDirectAuthStrategy;
import cn.daxpay.open.payment.common.context.MerchantContextLoader;
import cn.daxpay.open.payment.device.qrcode.dao.DeviceQrCodeManager;
import cn.daxpay.open.payment.device.qrcode.entity.DeviceQrCode;
import cn.daxpay.open.payment.merchant.enums.ClientEnvEnum;
import cn.daxpay.open.payment.merchant.enums.ClientRuntimeEnum;
import cn.daxpay.open.payment.merchant.enums.CodePayFormEnum;
import cn.daxpay.open.payment.merchant.service.gateway.ClientEnvPayResolveService;
import cn.daxpay.open.payment.merchant.service.gateway.CodePayResolveService;
import cn.daxpay.open.payment.route.service.runtime.PayRouteService;
import cn.daxpay.open.payment.trade.enums.GatewayOrderStatusEnum;
import cn.daxpay.open.payment.trade.order.entity.GatewayPayOrder;
import cn.daxpay.open.payment.trade.runtime.service.pay.gateway.GatewayPayAssistService;
import cn.daxpay.open.payment.unipay.param.trade.pay.NormalPayParam;
import cn.daxpay.open.platform.capability.douyin.auth.result.DouyinJsapiConfigResult;
import cn.daxpay.open.platform.capability.douyin.auth.service.DouyinOpenTokenService;
import cn.daxpay.open.platform.core.annotation.IgnoreAuth;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.platform.core.rest.Res;
import cn.daxpay.open.platform.core.rest.result.Result;
import cn.hutool.core.util.StrUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Objects;
/// # 抖音 H5 JSAPI 调起前置 - sdk.config 验签接口
///
/// 签名基于**通道商户网站应用**的 clientKey/appSecret 换取的 jsapi_ticket,
/// 与 H5 OAuth([DouyinDirectAuthStrategy]) 同源。
///
/// 上下文三选一: orderNo(网关单) / code(码牌) / channelMchNo(+可选 capability)。
@IgnoreAuth
@Validated
@Tag(name = "抖音 JSAPI 调起辅助")
@RestController
@RequestMapping("/unipay/assist/channel/douyin")
@RequiredArgsConstructor
public class DouyinJsapiController {
private final DouyinOpenTokenService douyinOpenTokenService;
private final DouyinDirectAppCapabilityService douyinDirectAppCapabilityService;
private final DouyinDirectAppAuthConfigService douyinDirectAppAuthConfigService;
private final GatewayPayAssistService gatewayPayAssistService;
private final DeviceQrCodeManager deviceQrCodeManager;
private final MerchantContextLoader merchantContextLoader;
private final ClientEnvPayResolveService clientEnvPayResolveService;
private final CodePayResolveService codePayResolveService;
private final PayRouteService payRouteService;
@Operation(summary = "获取抖音 JS-SDK sdk.config 验签包")
@GetMapping("/jsapi-config")
public Result<DouyinJsapiConfigResult> getJsapiConfig(@Valid DouyinJsapiConfigParam param) {
ResolvedChannel ctx = resolveChannelContext(
param.getOrderNo(), param.getCode(),
param.getChannelMchNo(), param.getCapability(), param.getChannelAppId());
DouyinDirectApp app = douyinDirectAppCapabilityService.resolveWebAppForH5Auth(
ctx.channelMchNo(), ctx.capability(), ctx.channelAppId());
DouyinDirectAppAuthConfig authConfig =
douyinDirectAppAuthConfigService.findByDouyinDirectAppIdForAuth(app.getId());
if (StrUtil.isBlank(authConfig.getAppSecret())) {
// 抖音: 直连应用授权密钥未配置
throw new BizInfoException(CommonErrorCode.SYSTEM_ERROR, "error.channel.douyin.appAuthSecretMissing");
}
DouyinJsapiConfigResult result = douyinOpenTokenService.buildJsapiConfig(
app.getDouyinAppId(), authConfig.getAppSecret(), param.getUrl());
return Res.ok(result);
}
private ResolvedChannel resolveChannelContext(String orderNo, String code, String channelMchNo,
String capability, String channelAppId) {
if (StrUtil.isNotBlank(channelMchNo)) {
return new ResolvedChannel(channelMchNo, capability, channelAppId);
}
if (StrUtil.isNotBlank(orderNo)) {
return resolveFromGatewayOrder(orderNo);
}
if (StrUtil.isNotBlank(code)) {
return resolveFromCodePay(code);
}
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.channel.douyin.jsapiContextRequired");
}
private ResolvedChannel resolveFromGatewayOrder(String orderNo) {
GatewayPayOrder order = gatewayPayAssistService.getOrderAndCheck(orderNo);
if (Objects.equals(order.getStatus(), GatewayOrderStatusEnum.PAYING.getCode())
&& StrUtil.isNotBlank(order.getChannelMchNo())) {
return new ResolvedChannel(order.getChannelMchNo(), order.getCapability(), order.getChannelAppId());
}
var resolved = clientEnvPayResolveService.resolveRequired(
order.getAppId(), ClientEnvEnum.DOUYIN, ClientRuntimeEnum.H5);
NormalPayParam routeParam = new NormalPayParam();
routeParam.setMchNo(order.getMchNo());
routeParam.setAppId(order.getAppId());
routeParam.setMethod(resolved.method());
routeParam.setChannelMchNo(resolved.channelMchNo());
routeParam.setCapability(resolved.capability());
payRouteService.resolve(routeParam);
return new ResolvedChannel(routeParam.getChannelMchNo(), routeParam.getCapability(), null);
}
private ResolvedChannel resolveFromCodePay(String code) {
DeviceQrCode entity = deviceQrCodeManager.findByCode(code)
.orElseThrow(() -> new DataNotExistException("error.device.qrcode.notFound"));
if (StrUtil.isBlank(entity.getMchNo())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "error.device.qrcode.notAssigned");
}
merchantContextLoader.initMch(entity.getMchNo());
var mchApp = merchantContextLoader.resolveApp(entity.getMchNo(), entity.getAppId());
CodePayFormEnum payForm = CodePayFormEnum.fromProgramType(entity.getProgramType());
var resolved = codePayResolveService.resolveRequired(mchApp.getAppId(), ClientEnvEnum.DOUYIN, payForm);
NormalPayParam routeParam = new NormalPayParam();
routeParam.setMchNo(entity.getMchNo());
routeParam.setAppId(mchApp.getAppId());
routeParam.setMethod(resolved.method());
routeParam.setChannelMchNo(resolved.channelMchNo());
routeParam.setCapability(resolved.capability());
payRouteService.resolve(routeParam);
return new ResolvedChannel(routeParam.getChannelMchNo(), routeParam.getCapability(), null);
}
private record ResolvedChannel(String channelMchNo, String capability, String channelAppId) {
}
}

View File

@@ -2,6 +2,7 @@ package cn.daxpay.open.channel.douyin.dao.direct;
import cn.daxpay.open.channel.douyin.entity.direct.DouyinDirectAppAuthConfig;
import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager;
import cn.daxpay.open.platform.core.annotation.IgnoreTenant;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@@ -20,6 +21,12 @@ public class DouyinDirectAppAuthConfigManager extends BaseManager<DouyinDirectAp
.oneOpt();
}
/// 根据应用ID查询授权认证配置运行态认证使用, 忽略租户隔离)
@IgnoreTenant
public Optional<DouyinDirectAppAuthConfig> findByDouyinDirectAppIdNotTenant(Long douyinDirectAppId) {
return findByDouyinDirectAppId(douyinDirectAppId);
}
/// 根据应用ID删除授权认证配置
public void deleteByDouyinDirectAppId(Long douyinDirectAppId) {
lambdaUpdate()

View File

@@ -2,6 +2,7 @@ package cn.daxpay.open.channel.douyin.dao.direct;
import cn.daxpay.open.channel.douyin.entity.direct.DouyinDirectApp;
import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager;
import cn.daxpay.open.platform.core.annotation.IgnoreTenant;
import org.springframework.stereotype.Repository;
import java.util.List;
@@ -11,6 +12,7 @@ import java.util.Optional;
///
/// - 配置态 CRUD: [#listByMchNoAndChannelMchNo]、[#existsByChannelMchNoAndDouyinAppId]
/// - 支付/回调(已装载 mchNo: 租户内 [#findFirstByChannelMchNo]、[#findFirstByChannelMchNoAndAppType]
/// - 认证引导(无上下文): 方法名带 NotTenant
///
@Repository
public class DouyinDirectAppManager extends BaseManager<DouyinDirectAppMapper, DouyinDirectApp> {
@@ -56,4 +58,24 @@ public class DouyinDirectAppManager extends BaseManager<DouyinDirectAppMapper, D
.oneOpt();
}
/// 按通道商户号与 douyinAppId 查询应用(支付/回调,租户内)
public Optional<DouyinDirectApp> findByChannelMchNoAndDouyinAppId(String channelMchNo, String douyinAppId) {
return lambdaQuery()
.eq(DouyinDirectApp::getChannelMchNo, channelMchNo)
.eq(DouyinDirectApp::getDouyinAppId, douyinAppId)
.oneOpt();
}
/// 按通道商户号与 douyinAppId 查询应用(认证引导,忽略租户)
@IgnoreTenant
public Optional<DouyinDirectApp> findByChannelMchNoAndDouyinAppIdNotTenant(String channelMchNo, String douyinAppId) {
return findByChannelMchNoAndDouyinAppId(channelMchNo, douyinAppId);
}
/// 按通道商户号与应用类型取首个应用(认证引导,忽略租户)
@IgnoreTenant
public Optional<DouyinDirectApp> findFirstByChannelMchNoAndAppTypeNotTenant(String channelMchNo, String appType) {
return findFirstByChannelMchNoAndAppType(channelMchNo, appType);
}
}

View File

@@ -0,0 +1,35 @@
package cn.daxpay.open.channel.douyin.param.assist;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 抖音 JSAPI sdk.config 查询参数
///
/// GET `/unipay/assist/channel/douyin/jsapi-config` 的 query 绑定对象。
/// 上下文三选一: orderNo(网关单) / code(码牌) / channelMchNo(+可选 capability/channelAppId)。
@Data
@Accessors(chain = true)
@Schema(title = "抖音 JSAPI sdk.config 查询参数")
public class DouyinJsapiConfigParam {
@NotBlank(message = "{validation.field.url.notBlank}")
@Schema(description = "当前页 URL(不含 hash)")
private String url;
@Schema(description = "网关订单号")
private String orderNo;
@Schema(description = "码牌编码")
private String code;
@Schema(description = "通道商户号")
private String channelMchNo;
@Schema(description = "能力编码")
private String capability;
@Schema(description = "通道应用ID")
private String channelAppId;
}

View File

@@ -5,6 +5,7 @@ import cn.daxpay.open.channel.douyin.dao.direct.DouyinDirectAppManager;
import cn.daxpay.open.channel.douyin.dao.direct.DouyinDirectAppAuthConfigManager;
import cn.daxpay.open.channel.douyin.entity.direct.DouyinDirectAppAuthConfig;
import cn.daxpay.open.channel.douyin.param.direct.DouyinDirectAppAuthConfigParam;
import cn.daxpay.open.platform.core.annotation.IgnoreTenant;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
@@ -43,6 +44,28 @@ public class DouyinDirectAppAuthConfigService {
return config;
}
/// 根据应用ID查询授权认证配置(运行态认证使用, 忽略租户隔离)
///
/// 与 [#findByDouyinDirectAppId] 的区别: 走 NotTenant 查询路径, 供认证策略(网关端无登录态)调用;
/// [#save] 等配置态仍调原方法(保留租户隔离)。
@IgnoreTenant
@Transactional(rollbackFor = Exception.class)
public DouyinDirectAppAuthConfig findByDouyinDirectAppIdForAuth(Long douyinDirectAppId) {
var existing = douyinDirectAppAuthConfigManager.findByDouyinDirectAppIdNotTenant(douyinDirectAppId);
if (existing.isPresent()) {
return existing.get();
}
var app = douyinDirectAppManager.findByIdNotTenant(douyinDirectAppId)
// 抖音: 直连商户应用不存在
.orElseThrow(() -> new DataNotExistException("error.channel.douyin.mchAppNotFound"));
var config = new DouyinDirectAppAuthConfig()
.setChannelMchNo(app.getChannelMchNo())
.setDouyinDirectAppId(douyinDirectAppId);
config.setMchNo(app.getMchNo());
douyinDirectAppAuthConfigManager.save(config);
return config;
}
/// 保存应用授权认证配置(更新)
@Transactional(rollbackFor = Exception.class)
public void save(DouyinDirectAppAuthConfigParam param) {

View File

@@ -136,6 +136,29 @@ public class DouyinDirectAppCapabilityService {
return resolveApp(channelMchNo, capability);
}
/// H5 silent_auth / JS-SDK 验签用网站应用解析(忽略租户)
///
/// 优先级: channelAppId 显式 > capability 命中且为 web_app > 通道商户首个 web_app。
/// 勿盲跟 DOUYIN_JSAPI→mini_program 推导。
@IgnoreTenant
public DouyinDirectApp resolveWebAppForH5Auth(String channelMchNo, String capability, String channelAppId) {
if (StrUtil.isNotBlank(channelAppId)) {
return douyinDirectAppManager.findByChannelMchNoAndDouyinAppIdNotTenant(channelMchNo, channelAppId)
.orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.channel.douyin.channelAppIdNotFound", channelAppId));
}
if (StrUtil.isNotBlank(capability)) {
var byCap = resolveAppNotTenant(channelMchNo, capability);
if (byCap.isPresent() && DouyinAppTypeCode.WEB_APP.equals(byCap.get().getAppType())) {
return byCap.get();
}
}
return douyinDirectAppManager.findFirstByChannelMchNoAndAppTypeNotTenant(
channelMchNo, DouyinAppTypeCode.WEB_APP)
.orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.channel.douyin.webAppNotFound"));
}
/// 查询抖音直连产品支持的支付能力候选列表(含国际化名称)
public List<DouyinCapabilityOption> listSupportedCapabilities() {
AbsProductStrategy strategy = PaymentStrategyFactory.createByProduct(

View File

@@ -0,0 +1,98 @@
package cn.daxpay.open.channel.douyin.strategy.direct.auth;
import cn.daxpay.open.channel.douyin.entity.direct.DouyinDirectApp;
import cn.daxpay.open.channel.douyin.entity.direct.DouyinDirectAppAuthConfig;
import cn.daxpay.open.channel.douyin.service.direct.DouyinDirectAppAuthConfigService;
import cn.daxpay.open.channel.douyin.service.direct.DouyinDirectAppCapabilityService;
import cn.daxpay.open.payment.auth.AuthSession;
import cn.daxpay.open.payment.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.douyin.auth.result.DouyinAuthResult;
import cn.daxpay.open.platform.capability.douyin.auth.service.DouyinH5AuthService;
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.infra.PlatformUrlConfigService;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/// # 抖音直连认证策略
///
/// 抖音直连模式(DOUYIN_PAY)下获取用户标识(openId)。复用支付的商户配置体系定位直连应用
/// (DouyinDirectApp + DouyinDirectAppAuthConfig), 调用 capability-douyin 完成 H5 silent_auth。
///
/// H5 silent_auth 强制解析网站应用, 见 [DouyinDirectAppCapabilityService#resolveWebAppForH5Auth]。
@Slf4j
@Service
@RequiredArgsConstructor
public class DouyinDirectAuthStrategy extends AbsChannelAuthStrategy {
private final DouyinH5AuthService douyinH5AuthService;
private final DouyinDirectAppCapabilityService douyinDirectAppCapabilityService;
private final DouyinDirectAppAuthConfigService douyinDirectAppAuthConfigService;
private final PlatformUrlConfigService platformUrlConfigService;
@Override
public ProductEnum getProduct() {
return ProductEnum.DOUYIN_PAY;
}
/// 生成抖音 H5 静默授权链接
@Override
public AuthUrlResult generateAuthUrl(GenerateAuthUrlParam param, String authToken) {
DouyinDirectApp app = douyinDirectAppCapabilityService.resolveWebAppForH5Auth(
param.getChannelMchNo(), param.getCapability(), param.getChannelAppId());
DouyinDirectAppAuthConfig authConfig = douyinDirectAppAuthConfigService.findByDouyinDirectAppIdForAuth(app.getId());
if (StrUtil.isBlank(authConfig.getAppSecret())) {
// 抖音: 直连应用授权密钥未配置
throw new BizInfoException(CommonErrorCode.SYSTEM_ERROR, "error.channel.douyin.appAuthSecretMissing");
}
String redirectUri = buildRedirectUri();
String authUrl = douyinH5AuthService.buildSilentAuthUrl(
app.getDouyinAppId(), redirectUri, authToken);
return new AuthUrlResult().setAuthUrl(authUrl);
}
/// 通过授权 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 channelAppId = session != null && StrUtil.isNotBlank(session.getChannelAppId())
? session.getChannelAppId() : param.getChannelAppId();
DouyinDirectApp app = douyinDirectAppCapabilityService.resolveWebAppForH5Auth(
channelMchNo, capability, channelAppId);
DouyinDirectAppAuthConfig authConfig = douyinDirectAppAuthConfigService.findByDouyinDirectAppIdForAuth(app.getId());
if (StrUtil.isBlank(authConfig.getAppSecret())) {
// 抖音: 直连应用授权密钥未配置
throw new BizInfoException(CommonErrorCode.SYSTEM_ERROR, "error.channel.douyin.appAuthSecretMissing");
}
DouyinAuthResult data = douyinH5AuthService.getOpenIdByCode(
app.getDouyinAppId(), authConfig.getAppSecret(), param.getAuthCode());
if (StrUtil.isBlank(data.getOpenId())) {
// 抖音: 获取openId失败
throw new BizInfoException(CommonErrorCode.SYSTEM_ERROR, "error.channel.douyin.authFailed", "openId is blank");
}
return new AuthResult()
.setOpenId(data.getOpenId())
.setAccessToken(data.getAccessToken());
}
/// 拼接认证回调地址: {paymentGatewayBaseUrl}/auth/douyin
private String buildRedirectUri() {
String base = platformUrlConfigService.getUrlConfig().getPaymentGatewayBaseUrl();
if (StrUtil.isBlank(base)) {
// 支付网关前端地址未配置
throw new BizInfoException(DaxPayErrorCode.CONFIG_ERROR, "error.common.gatewayUrlNotConfigured");
}
return StrUtil.removeSuffix(base, "/") + "/auth/douyin";
}
}

View File

@@ -66,7 +66,7 @@ public class DevelopAuthController {
@PostMapping("/generate-channel-auth-url")
public Result<AuthUrlResult> generateChannelAuthUrl(@RequestBody GenerateAuthUrlParam param) {
// 不加 @Valid: GenerateAuthUrlParam 继承 PaymentCommonParam.reqTime(@NotNull), 但认证不走签名/防重放, 无需 reqTime;
// channel/mchNo 由 ChannelAuthService 业务层兜底校验, 与 unipay ChannelAuthController 同类接口保持一致
// channel/mchNo 由 ChannelProductAuthService 业务层兜底校验, 与 unipay ChannelAuthController 同类接口保持一致
return Res.ok(developAuthService.generateChannelAuthUrl(param));
}

View File

@@ -1,7 +1,7 @@
package cn.daxpay.open.payment.admin.service.develop;
import cn.daxpay.open.payment.auth.AuthSessionStore;
import cn.daxpay.open.payment.auth.ChannelAuthService;
import cn.daxpay.open.payment.auth.ChannelProductAuthService;
import cn.daxpay.open.payment.auth.PlatformAuthService;
import cn.daxpay.open.payment.unipay.param.assist.GenerateAuthUrlParam;
import cn.daxpay.open.payment.unipay.result.assist.AuthResult;
@@ -16,7 +16,7 @@ import org.springframework.stereotype.Service;
/// - **支付宝(平台级)**: 委托 [PlatformAuthService] 生成 OAuth 授权链接, 轮询 queryCode 取结果
/// - **微信公众号配置(平台级)**: 委托 [PlatformAuthService], OAuth 重定向取 openId, 仅验证配置是否正确
/// - **抖音H5(平台级)**: 委托 [PlatformAuthService], silent_auth 静默授权取 openId, 仅验证配置是否正确
/// - **微信支付(直连/服务商)**: 委托 [ChannelAuthService] 按支付产品路由认证策略,
/// - **微信支付(直连/服务商)**: 委托 [ChannelProductAuthService] 按支付产品路由认证策略,
/// 依赖商户上下文(channelMchNo/产品/能力)
/// - **支付宝小程序**: 暂未实现
/// - **微信小程序(商户端/运营端)**: 暂未实现
@@ -28,7 +28,7 @@ import org.springframework.stereotype.Service;
public class DevelopAuthService {
private final PlatformAuthService platformAuthService;
private final ChannelAuthService channelAuthService;
private final ChannelProductAuthService channelProductAuthService;
private final AuthSessionStore authSessionStore;
/// 生成支付宝授权链接(平台级 OAuth + queryCode 轮询)
@@ -48,10 +48,10 @@ public class DevelopAuthService {
/// 生成微信支付(直连/服务商)授权链接
///
/// 委托 [ChannelAuthService#generateAuthUrl], 按支付产品路由对应认证策略:
/// 委托 [ChannelProductAuthService#generateAuthUrl], 按支付产品路由对应认证策略:
/// 直连(WECHAT_PAY) → WechatDirectAuthStrategy; 服务商(WECHAT_ISV) → WechatIsvAuthStrategy。
public AuthUrlResult generateChannelAuthUrl(GenerateAuthUrlParam param) {
return channelAuthService.generateAuthUrl(param);
return channelProductAuthService.generateAuthUrl(param);
}
/// 通过查询码获取认证结果

View File

@@ -7,7 +7,7 @@ import lombok.experimental.Accessors;
///
/// H5授权重定向场景下, 生成授权链接时将认证所需上下文序列化保存到Redis(以 authToken 为key),
/// 授权回调后凭 authToken 恢复, 供认证策略定位通道应用并完成 code 换 openId/userId。
/// 与 [ChannelAuthService] 的 queryCode 机制(付款码/道通场景)解耦, 独立 key 前缀管理。
/// 与 [ChannelProductAuthService] 的 queryCode 机制(付款码/道通场景)解耦, 独立 key 前缀管理。
@Data
@Accessors(chain = true)
public class AuthSession {

View File

@@ -15,8 +15,8 @@ import java.util.Objects;
/// # 认证会话与结果缓存
///
/// 统一管理通道认证/平台级认证共用的会话上下文(authToken)与轮询结果(queryCode)的 Redis 读写,
/// 与具体认证来源(通道商户策略 / 平台级配置)解耦, 供 [ChannelAuthService]、[PlatformAuthService]、
/// [ChannelAuthFacade] 及调试入口(DevelopAuthService)复用。
/// 与具体认证来源(通道商户策略 / 平台级配置)解耦, 供 [ChannelAuthService]、[ChannelProductAuthService]、
/// [PlatformAuthService] 及调试入口(DevelopAuthService)复用。
/// 授权成功后由 Facade 调用 [#deleteSession] 使 authToken 一次使用失效。
@Slf4j
@Service

View File

@@ -1,103 +0,0 @@
package cn.daxpay.open.payment.auth;
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.core.code.DaxPayErrorCode;
import cn.daxpay.open.platform.core.enums.unipay.ChannelAuthTypeEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Objects;
/// # 通道认证分发门面
///
/// 统一对外的「生成授权链接 / 授权码换用户标识」入口, 按会话来源与 authType 分流到
/// [PlatformAuthService](平台级配置) 或 [ChannelAuthService](商户级产品策略)。
/// Controller / 调试入口只做协议适配, 不在 Web 层写业务分支。
///
/// ## 分发优先级(auth)
/// 1. session.source = platform_alipay / platform_mp / platform_douyin → 平台服务对应方法
/// 2. authType=alipay 且无 session → 平台支付宝(小程序等直连兜底)
/// 3. 其余 → 通道策略
@Slf4j
@Service
@RequiredArgsConstructor
public class ChannelAuthFacade {
private final AuthSessionStore authSessionStore;
private final PlatformAuthService platformAuthService;
private final ChannelAuthService channelAuthService;
/// 生成授权链接: 支付宝走平台级 OAuth, 其余按支付产品走通道策略
public AuthUrlResult generateAuthUrl(GenerateAuthUrlParam param) {
if (isAlipayAuth(param.getAuthType())) {
// 透传 returnPath, 供码牌等业务页授权完成后回跳
return platformAuthService.generateAlipayAuthUrl(param.getReturnPath());
}
return channelAuthService.generateAuthUrl(param);
}
/// 通过 AuthCode 换取认证结果, 成功后销毁会话(一次使用)
public AuthResult auth(AuthCodeParam param) {
AuthSession session = authSessionStore.loadSession(param.getAuthToken());
// 会话已失效(且非支付宝直连兜底场景): 提示重新生成, 避免下游抛"不支持的能力: null"
// 支付宝平台级 OAuth 不依赖 session 字段, 由 doAuth 内的兜底分支处理, 保持原行为
if (session == null && !isAlipayAuth(param.getAuthType())) {
// 授权链接已失效, 请重新生成
throw new BizInfoException(DaxPayErrorCode.OPERATION_FAIL,
"pay.error.assist.authSessionExpired");
}
AuthResult result = doAuth(param, session);
// 平台级 auth 方法未回填 returnPath 时, 从会话补齐
if (session != null && StrUtil.isNotBlank(session.getReturnPath())
&& StrUtil.isBlank(result.getReturnPath())) {
result.setReturnPath(session.getReturnPath());
}
// 成功后失效 authToken, 避免 TTL 内重复消费会话上下文
authSessionStore.deleteSession(param.getAuthToken());
return result;
}
/// 按会话来源把授权码回调分到平台或通道处理
private AuthResult doAuth(AuthCodeParam param, AuthSession session) {
if (isPlatformAlipay(session)) {
return platformAuthService.authAlipay(param, session);
}
if (isPlatformMp(session)) {
return platformAuthService.authWechatMp(param, session);
}
if (isPlatformDouyin(session)) {
return platformAuthService.authDouyin(param, session);
}
// 无会话且 authType=alipay: 小程序等直连场景兜底
if (isAlipayAuth(param.getAuthType()) && session == null) {
return platformAuthService.authAlipay(param, null);
}
return channelAuthService.auth(param, session);
}
/// 是否支付宝认证类型(平台级支付宝走 OAuth)
private boolean isAlipayAuth(String authType) {
return Objects.equals(authType, ChannelAuthTypeEnum.ALIPAY.getCode());
}
/// 是否支付宝平台级配置来源
private boolean isPlatformAlipay(AuthSession session) {
return session != null && AuthSession.SOURCE_PLATFORM_ALIPAY.equals(session.getSource());
}
/// 是否微信系统公众号配置来源(平台级)
private boolean isPlatformMp(AuthSession session) {
return session != null && AuthSession.SOURCE_PLATFORM_MP.equals(session.getSource());
}
/// 是否抖音 H5 应用配置来源(平台级)
private boolean isPlatformDouyin(AuthSession session) {
return session != null && AuthSession.SOURCE_PLATFORM_DOUYIN.equals(session.getSource());
}
}

View File

@@ -1,115 +1,103 @@
package cn.daxpay.open.payment.auth;
import cn.daxpay.open.payment.merchant.dao.channel.ChannelMerchantManager;
import cn.daxpay.open.payment.merchant.entity.channel.ChannelMerchant;
import cn.daxpay.open.payment.common.context.MerchantContextLoader;
import cn.daxpay.open.payment.strategy.PaymentStrategyFactory;
import cn.daxpay.open.payment.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.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.enums.unipay.ChannelAuthStatusEnum;
import cn.daxpay.open.platform.core.code.DaxPayErrorCode;
import cn.daxpay.open.platform.core.enums.unipay.ChannelAuthTypeEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
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;
import org.springframework.stereotype.Service;
/// # 通道认证服务(商户级)
import java.util.Objects;
/// # 通道认证服务
///
/// 负责按支付产品路由认证策略(继承 [AbsChannelAuthStrategy]), 依赖商户上下文定位通道应用,
/// 获取支付所需的用户标识(微信 openId / 支付宝 userId)。H5 授权重定向场景生成 authToken 保存会话
/// 统一对外的「生成授权链接 / 授权码换用户标识」入口, 按会话来源与 authType 分流到
/// [PlatformAuthService](平台级配置) 或 [ChannelProductAuthService](商户级产品策略)
/// Controller / 调试入口只做协议适配, 不在 Web 层写业务分支。
///
/// **职责边界**: 本服务仅处理商户级通道认证; 平台级认证(平台支付宝配置 / 系统公众号配置)
/// 由 [PlatformAuthService] 承担, 会话与结果缓存由 [AuthSessionStore] 统一管理。
/// 平台级 vs 通道级 的来源分发由 [ChannelAuthFacade] 完成, 请勿在 Controller 再写分流。
/// ## 分发优先级(auth)
/// 1. session.source = platform_alipay / platform_mp / platform_douyin → 平台服务对应方法
/// 2. authType=alipay 且无 session → 平台支付宝(小程序等直连兜底)
/// 3. 其余 → 通道产品策略
@Slf4j
@Service
@RequiredArgsConstructor
public class ChannelAuthService {
private final AuthSessionStore authSessionStore;
private final MerchantContextLoader merchantContextLoader;
private final ChannelMerchantManager channelMerchantManager;
private final PlatformAuthService platformAuthService;
private final ChannelProductAuthService channelProductAuthService;
/// 获取通道授权链接
///
/// 生成 authToken 并委托产品策略([AbsChannelAuthStrategy])生成授权 URL, 会话随 authToken 保存,
/// 授权回调后凭此恢复上下文。同时生成 queryCode 供调试轮询(微信等 OAuth 重定向通道回调 URL
/// 不含 queryCode, 需随会话保存)。
/// 生成授权链接: 支付宝走平台级 OAuth, 其余按支付产品走通道策略
public AuthUrlResult generateAuthUrl(GenerateAuthUrlParam param) {
initMchContext(param.getAppId(), param.getMchNo());
// 支付产品: 显式传入优先, 否则从通道商户号反查(调试工具/直接指定通道商户场景)
String product = resolveProduct(param);
var strategy = PaymentStrategyFactory.createByProduct(product, AbsChannelAuthStrategy.class);
// 生成认证会话码并保存上下文, 授权回调后凭此恢复
String authToken = IdUtil.fastSimpleUUID();
// 生成 queryCode 供调试轮询(微信等 OAuth 重定向通道回调 URL 不含 queryCode, 需随会话保存)
String queryCode = RandomUtil.randomString(10);
AuthSession session = new AuthSession()
.setProduct(product)
.setChannelMchNo(param.getChannelMchNo())
.setCapability(param.getCapability())
.setChannelAppId(param.getChannelAppId())
.setReturnPath(param.getReturnPath())
.setQueryCode(queryCode);
authSessionStore.saveSession(authToken, session);
AuthUrlResult authUrlResult = strategy.generateAuthUrl(param, authToken);
// 回填 queryCode 并写入 WAITING 状态供前端轮询
authUrlResult.setQueryCode(queryCode);
authSessionStore.saveWaitingResult(queryCode);
return authUrlResult;
if (isAlipayAuth(param.getAuthType())) {
// 透传 returnPath, 供码牌等业务页授权完成后回跳
return platformAuthService.generateAlipayAuthUrl(param.getReturnPath());
}
return channelProductAuthService.generateAuthUrl(param);
}
/// 通过AuthCode获取通道认证结果
///
/// @param session 认证会话上下文(H5场景从 authToken 恢复; 小程序直连场景可为空, 此时从 param 取上下文)。
/// 由认证分发层在调用前通过 [AuthSessionStore#loadSession] 加载后注入。
public AuthResult auth(AuthCodeParam param, AuthSession session) {
initMchContext(param.getAppId(), param.getMchNo());
// 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());
/// 通过 AuthCode 换取认证结果, 成功后销毁会话(一次使用)
public AuthResult auth(AuthCodeParam param) {
AuthSession session = authSessionStore.loadSession(param.getAuthToken());
// 会话已失效(且非支付宝直连兜底场景): 提示重新生成, 避免下游抛"不支持的能力: null"
// 支付宝平台级 OAuth 不依赖 session 字段, 由 doAuth 内的兜底分支处理, 保持原行为
if (session == null && !isAlipayAuth(param.getAuthType())) {
// 授权链接已失效, 请重新生成
throw new BizInfoException(DaxPayErrorCode.OPERATION_FAIL,
"pay.error.assist.authSessionExpired");
}
// 写回轮询结果(微信等 OAuth 重定向通道从 session 恢复 queryCode)
authSessionStore.writeResultByQueryCode(param.getQueryCode(), session, authResult);
return authResult;
AuthResult result = doAuth(param, session);
// 平台级 auth 方法未回填 returnPath 时, 从会话补齐
if (session != null && StrUtil.isNotBlank(session.getReturnPath())
&& StrUtil.isBlank(result.getReturnPath())) {
result.setReturnPath(session.getReturnPath());
}
// 成功后失效 authToken, 避免 TTL 内重复消费会话上下文
authSessionStore.deleteSession(param.getAuthToken());
return result;
}
/// 商户上下文初始化: appId 优先(渠道配置/小程序直连), appId 为空则用 mchNo(调试/直接指定通道商户场景),
/// 两者均空时跳过(微信 OAuth 重定向回调仅含 authToken, 商户上下文由 session.channelMchNo 维度定位, 无需线程级 mchNo)。
private void initMchContext(String appId, String mchNo) {
if (StrUtil.isNotBlank(appId)) {
merchantContextLoader.initMchByApp(appId);
} else if (StrUtil.isNotBlank(mchNo)) {
merchantContextLoader.initMch(mchNo);
/// 按会话来源把授权码回调分到平台或通道产品处理
private AuthResult doAuth(AuthCodeParam param, AuthSession session) {
if (isPlatformAlipay(session)) {
return platformAuthService.authAlipay(param, session);
}
if (isPlatformMp(session)) {
return platformAuthService.authWechatMp(param, session);
}
if (isPlatformDouyin(session)) {
return platformAuthService.authDouyin(param, session);
}
// 无会话且 authType=alipay: 小程序等直连场景兜底
if (isAlipayAuth(param.getAuthType()) && session == null) {
return platformAuthService.authAlipay(param, null);
}
return channelProductAuthService.auth(param, session);
}
/// 解析支付产品: 显式传入优先, 否则从通道商户号(channelMchNo)反查所属产品
private String resolveProduct(GenerateAuthUrlParam param) {
if (StrUtil.isNotBlank(param.getProduct())) {
return param.getProduct();
}
// 缺失 product 时必须提供 channelMchNo 才能反查
if (StrUtil.isBlank(param.getChannelMchNo())) {
// 支付产品与通道商户号至少传其一
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.assist.productOrChannelMchRequired");
}
return channelMerchantManager.findByChannelMchNo(param.getChannelMchNo())
.map(ChannelMerchant::getProduct)
.orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.assist.channelMchNotFound", param.getChannelMchNo()));
/// 是否支付宝认证类型(平台级支付宝走 OAuth)
private boolean isAlipayAuth(String authType) {
return Objects.equals(authType, ChannelAuthTypeEnum.ALIPAY.getCode());
}
/// 是否支付宝平台级配置来源
private boolean isPlatformAlipay(AuthSession session) {
return session != null && AuthSession.SOURCE_PLATFORM_ALIPAY.equals(session.getSource());
}
/// 是否微信系统公众号配置来源(平台级)
private boolean isPlatformMp(AuthSession session) {
return session != null && AuthSession.SOURCE_PLATFORM_MP.equals(session.getSource());
}
/// 是否抖音 H5 应用配置来源(平台级)
private boolean isPlatformDouyin(AuthSession session) {
return session != null && AuthSession.SOURCE_PLATFORM_DOUYIN.equals(session.getSource());
}
}

View File

@@ -0,0 +1,115 @@
package cn.daxpay.open.payment.auth;
import cn.daxpay.open.payment.merchant.dao.channel.ChannelMerchantManager;
import cn.daxpay.open.payment.merchant.entity.channel.ChannelMerchant;
import cn.daxpay.open.payment.common.context.MerchantContextLoader;
import cn.daxpay.open.payment.strategy.PaymentStrategyFactory;
import cn.daxpay.open.payment.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.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.enums.unipay.ChannelAuthStatusEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
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;
import org.springframework.stereotype.Service;
/// # 通道产品认证服务(商户级)
///
/// 负责按支付产品路由认证策略(继承 [AbsChannelAuthStrategy]), 依赖商户上下文定位通道应用,
/// 获取支付所需的用户标识(微信 openId / 支付宝 userId)。H5 授权重定向场景生成 authToken 保存会话。
///
/// **职责边界**: 本服务仅处理商户级通道认证; 平台级认证(平台支付宝配置 / 系统公众号配置)
/// 由 [PlatformAuthService] 承担, 会话与结果缓存由 [AuthSessionStore] 统一管理。
/// 平台级 vs 通道级 的来源分发由 [ChannelAuthService] 完成, 请勿在 Controller 再写分流。
@Slf4j
@Service
@RequiredArgsConstructor
public class ChannelProductAuthService {
private final AuthSessionStore authSessionStore;
private final MerchantContextLoader merchantContextLoader;
private final ChannelMerchantManager channelMerchantManager;
/// 获取通道授权链接
///
/// 生成 authToken 并委托产品策略([AbsChannelAuthStrategy])生成授权 URL, 会话随 authToken 保存,
/// 授权回调后凭此恢复上下文。同时生成 queryCode 供调试轮询(微信等 OAuth 重定向通道回调 URL
/// 不含 queryCode, 需随会话保存)。
public AuthUrlResult generateAuthUrl(GenerateAuthUrlParam param) {
initMchContext(param.getAppId(), param.getMchNo());
// 支付产品: 显式传入优先, 否则从通道商户号反查(调试工具/直接指定通道商户场景)
String product = resolveProduct(param);
var strategy = PaymentStrategyFactory.createByProduct(product, AbsChannelAuthStrategy.class);
// 生成认证会话码并保存上下文, 授权回调后凭此恢复
String authToken = IdUtil.fastSimpleUUID();
// 生成 queryCode 供调试轮询(微信等 OAuth 重定向通道回调 URL 不含 queryCode, 需随会话保存)
String queryCode = RandomUtil.randomString(10);
AuthSession session = new AuthSession()
.setProduct(product)
.setChannelMchNo(param.getChannelMchNo())
.setCapability(param.getCapability())
.setChannelAppId(param.getChannelAppId())
.setReturnPath(param.getReturnPath())
.setQueryCode(queryCode);
authSessionStore.saveSession(authToken, session);
AuthUrlResult authUrlResult = strategy.generateAuthUrl(param, authToken);
// 回填 queryCode 并写入 WAITING 状态供前端轮询
authUrlResult.setQueryCode(queryCode);
authSessionStore.saveWaitingResult(queryCode);
return authUrlResult;
}
/// 通过AuthCode获取通道认证结果
///
/// @param session 认证会话上下文(H5场景从 authToken 恢复; 小程序直连场景可为空, 此时从 param 取上下文)。
/// 由认证分发层在调用前通过 [AuthSessionStore#loadSession] 加载后注入。
public AuthResult auth(AuthCodeParam param, AuthSession session) {
initMchContext(param.getAppId(), param.getMchNo());
// 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());
}
// 写回轮询结果(微信等 OAuth 重定向通道从 session 恢复 queryCode)
authSessionStore.writeResultByQueryCode(param.getQueryCode(), session, authResult);
return authResult;
}
/// 商户上下文初始化: appId 优先(渠道配置/小程序直连), appId 为空则用 mchNo(调试/直接指定通道商户场景),
/// 两者均空时跳过(微信 OAuth 重定向回调仅含 authToken, 商户上下文由 session.channelMchNo 维度定位, 无需线程级 mchNo)。
private void initMchContext(String appId, String mchNo) {
if (StrUtil.isNotBlank(appId)) {
merchantContextLoader.initMchByApp(appId);
} else if (StrUtil.isNotBlank(mchNo)) {
merchantContextLoader.initMch(mchNo);
}
}
/// 解析支付产品: 显式传入优先, 否则从通道商户号(channelMchNo)反查所属产品
private String resolveProduct(GenerateAuthUrlParam param) {
if (StrUtil.isNotBlank(param.getProduct())) {
return param.getProduct();
}
// 缺失 product 时必须提供 channelMchNo 才能反查
if (StrUtil.isBlank(param.getChannelMchNo())) {
// 支付产品与通道商户号至少传其一
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.assist.productOrChannelMchRequired");
}
return channelMerchantManager.findByChannelMchNo(param.getChannelMchNo())
.map(ChannelMerchant::getProduct)
.orElseThrow(() -> new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.assist.channelMchNotFound", param.getChannelMchNo()));
}
}

View File

@@ -15,6 +15,7 @@ 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.exception.BizInfoException;
import cn.daxpay.open.platform.system.entity.config.platform.auth.PlatformAlipayAuthConfig;
import cn.daxpay.open.platform.system.entity.config.platform.auth.PlatformDouyinH5AuthConfig;
import cn.daxpay.open.platform.system.entity.config.platform.auth.PlatformWechatMpAuthConfig;
import cn.daxpay.open.platform.system.service.config.auth.PlatformAlipayAuthConfigService;
@@ -33,12 +34,13 @@ import org.springframework.stereotype.Service;
/// 承载不依赖商户上下文、由平台级配置驱动的认证场景:
/// - **支付宝**: 平台级支付宝配置, OAuth 重定向, 调试/支付共用; URL 拼装见 [#buildAlipayAuthUrl],
/// 通道策略 [cn.daxpay.open.payment.strategy.auth.AlipayAuthStrategy] 亦委托本服务, 避免双轨实现
/// - **微信系统公众号配置**: 平台级微信公众号配置([PlatformWechatMpAuthConfig]), OAuth 重定向, 仅调试场景
/// - **抖音 H5 应用**: 平台级抖音 H5 配置([PlatformDouyinH5AuthConfig]), silent_auth 静默授权, 仅调试场景
/// - **微信系统公众号配置**: 平台级微信公众号配置([PlatformWechatMpAuthConfig]), OAuth 重定向, **仅调试场景**
/// (网关聚合/收银台/码牌支付走通道应用策略, 不再消费本配置)
/// - **抖音 H5 应用**: 平台级抖音 H5 配置([PlatformDouyinH5AuthConfig]), silent_auth 静默授权, **仅调试场景**
///
/// 与按支付产品路由策略的 [ChannelAuthService] 解耦: 本服务不读取商户级通道配置,
/// 与按支付产品路由策略的 [ChannelProductAuthService] 解耦: 本服务不读取商户级通道配置,
/// 只消费平台级配置并调用 capability(alipay/wechat/douyin) 用授权码换 token/openId; 会话与结果缓存委托 [AuthSessionStore]。
/// 对外分发入口见 [ChannelAuthFacade]。
/// 对外分发入口见 [ChannelAuthService]。
///
/// 三通道统一模式: 固定 redirect_uri + OAuth state 透传 authToken, 回调后从 state 恢复会话。
@Slf4j
@@ -105,7 +107,7 @@ public class PlatformAuthService {
/// 仅拼装支付宝 OAuth 授权 URL(不创建会话)
///
/// 供 [cn.daxpay.open.payment.strategy.auth.AlipayAuthStrategy] 复用: 通道侧已由
/// [ChannelAuthService] 创建 session/queryCode, 策略层只负责拼 URL, 避免与平台路径双份实现。
/// [ChannelProductAuthService] 创建 session/queryCode, 策略层只负责拼 URL, 避免与平台路径双份实现。
public String buildAlipayAuthUrl(String authToken) {
AlipayAuthConfig config = platformAlipayAuthConfigService.toCapabilityConfig();
if (!alipayAuthCapability.isConfigured(config)) {

View File

@@ -1,6 +1,7 @@
package cn.daxpay.open.payment.strategy.auth;
import cn.daxpay.open.payment.auth.AuthSession;
import cn.daxpay.open.payment.auth.ChannelProductAuthService;
import cn.daxpay.open.payment.strategy.PaymentStrategy;
import cn.daxpay.open.payment.unipay.param.assist.AuthCodeParam;
import cn.daxpay.open.payment.unipay.param.assist.GenerateAuthUrlParam;
@@ -15,7 +16,7 @@ public abstract class AbsChannelAuthStrategy implements PaymentStrategy {
/// 获取授权链接
///
/// @param authToken 认证会话码, 由上层 [ChannelAuthService] 生成注入,
/// @param authToken 认证会话码, 由上层 [ChannelProductAuthService] 生成注入,
/// 策略负责将其拼入回调地址; 授权回跳时凭此恢复上下文。
public abstract AuthUrlResult generateAuthUrl(GenerateAuthUrlParam param, String authToken);

View File

@@ -18,11 +18,11 @@ import org.springframework.stereotype.Service;
///
/// ## 适用场景
/// - 支付场景获取支付宝 userId(如 ALIPAY_JSAPI 需要)
/// - 经 [ChannelAuthService] 按 product=ALIPAY 路由时的 H5 OAuth / 小程序直连
/// - 经 [ChannelProductAuthService] 按 product=ALIPAY 路由时的 H5 OAuth / 小程序直连
///
/// ## 回调机制
/// 与微信/抖音策略同构: 回调地址固定为 `{paymentGatewayBaseUrl}/auth/alipay`,
/// 会话标识 authToken 通过 OAuth state 参数透传(会话由 [ChannelAuthService] 管理)。
/// 会话标识 authToken 通过 OAuth state 参数透传(会话由 [ChannelProductAuthService] 管理)。
@Slf4j
@Service
@RequiredArgsConstructor
@@ -35,7 +35,7 @@ public class AlipayAuthStrategy extends AbsChannelAuthStrategy {
return ProductEnum.ALIPAY;
}
/// 生成支付宝授权链接(委托平台服务拼 URL; session/queryCode 已由 ChannelAuthService 创建)
/// 生成支付宝授权链接(委托平台服务拼 URL; session/queryCode 已由 ChannelProductAuthService 创建)
@Override
public AuthUrlResult generateAuthUrl(GenerateAuthUrlParam param, String authToken) {
return new AuthUrlResult().setAuthUrl(platformAuthService.buildAlipayAuthUrl(authToken));

View File

@@ -1,5 +1,8 @@
package cn.daxpay.open.payment.trade.runtime.mq;
import cn.daxpay.open.payment.trade.runtime.consumer.GatewayTimeoutConsumer;
import cn.daxpay.open.payment.trade.runtime.consumer.NormalPayTimeoutConsumer;
/// # 支付业务 Artemis 消息地址常量
///
/// 支付核心业务使用的 Artemis address 常量,地址命名遵循 kebab-case 约定。

View File

@@ -1,46 +1,234 @@
package cn.daxpay.open.payment.trade.runtime.service.pay.gateway;
import cn.daxpay.open.payment.auth.PlatformAuthService;
import cn.daxpay.open.payment.auth.ChannelAuthService;
import cn.daxpay.open.payment.merchant.dao.gateway.GatewayCashierItemManager;
import cn.daxpay.open.payment.merchant.entity.gateway.GatewayCashierItem;
import cn.daxpay.open.payment.merchant.enums.CashierItemResolveModeEnum;
import cn.daxpay.open.payment.merchant.enums.ClientEnvEnum;
import cn.daxpay.open.payment.merchant.enums.ClientRuntimeEnum;
import cn.daxpay.open.payment.merchant.enums.GatewayCashierTypeEnum;
import cn.daxpay.open.payment.merchant.service.gateway.ClientEnvPayResolveService;
import cn.daxpay.open.payment.route.service.runtime.PayRouteService;
import cn.daxpay.open.payment.trade.enums.GatewayOrderStatusEnum;
import cn.daxpay.open.payment.trade.enums.GatewayPayTypeEnum;
import cn.daxpay.open.payment.trade.order.entity.GatewayPayOrder;
import cn.daxpay.open.payment.unipay.param.assist.GenerateAuthUrlParam;
import cn.daxpay.open.payment.unipay.param.gateway.GatewayAuthUrlParam;
import cn.daxpay.open.payment.unipay.param.trade.pay.NormalPayParam;
import cn.daxpay.open.payment.unipay.result.assist.AuthUrlResult;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.enums.unipay.ChannelAuthTypeEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Objects;
import java.util.Set;
/// # 网关 H5 授权服务
///
/// 公开端(无商户签名)根据网关订单生成 OAuth 链接, 用于收银台/聚合页取 openId。
/// 统一委托 [ChannelAuthService]:
/// - **支付宝**: 服务内走平台级 OAuth(本服务跳过通道路由)
/// - **微信/抖音**: 先解析支付路由再交 ChannelAuthService → 通道产品策略
///
/// 安全约束:
/// - 订单必须存在且可支付([GatewayPayAssistService#getOrderAndCheck])
/// - returnPath 仅允许站内业务相对路径, 防止开放重定向
///
/// 一期使用平台级认证配置(与调试工具同源); 通道级按支付项解析可二期扩展。
@Slf4j
@Service
@RequiredArgsConstructor
public class GatewayAuthService {
private static final Set<String> MINI_CLIENT_ENVS = Set.of(
ClientEnvEnum.WECHAT.getCode(),
ClientEnvEnum.ALIPAY.getCode(),
ClientEnvEnum.UNION_PAY.getCode(),
ClientEnvEnum.DOUYIN.getCode()
);
/// 网关 H5 授权支持的认证类型
private static final Set<ChannelAuthTypeEnum> SUPPORTED_AUTH_TYPES = Set.of(
ChannelAuthTypeEnum.ALIPAY,
ChannelAuthTypeEnum.WECHAT,
ChannelAuthTypeEnum.DOUYIN
);
private final GatewayPayAssistService gatewayPayAssistService;
private final PlatformAuthService platformAuthService;
private final ChannelAuthService channelAuthService;
private final ClientEnvPayResolveService clientEnvPayResolveService;
private final PayRouteService payRouteService;
private final GatewayCashierItemManager gatewayCashierItemManager;
/// 生成授权链接
public AuthUrlResult generateAuthUrl(GatewayAuthUrlParam param) {
// 校验订单可支付并装载商户上下文
GatewayPayOrder order = gatewayPayAssistService.getOrderAndCheck(param.getOrderNo());
String returnPath = sanitizeReturnPath(param.getReturnPath(), order.getOrderNo());
ChannelAuthTypeEnum authType = ChannelAuthTypeEnum.findByCode(param.getAuthType());
return switch (authType) {
case WECHAT -> platformAuthService.generateWechatMpAuthUrl(returnPath);
case ALIPAY -> platformAuthService.generateAlipayAuthUrl(returnPath);
case DOUYIN -> platformAuthService.generateDouyinAuthUrl(returnPath);
if (!SUPPORTED_AUTH_TYPES.contains(authType)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.clientEnvNotSupport");
}
GenerateAuthUrlParam authParam = new GenerateAuthUrlParam();
authParam.setMchNo(order.getMchNo());
authParam.setAppId(order.getAppId());
authParam.setReturnPath(returnPath);
authParam.setAuthType(authType.getCode());
// 支付宝由 ChannelAuthService 走平台 OAuth, 无需通道应用路由; 微信/抖音须同源 resolve
if (authType != ChannelAuthTypeEnum.ALIPAY) {
RouteSnapshot route = resolveRoute(order, param);
authParam.setProduct(route.product());
authParam.setChannelMchNo(route.channelMchNo());
authParam.setCapability(route.capability());
authParam.setChannelAppId(route.channelAppId());
}
return channelAuthService.generateAuthUrl(authParam);
}
/// 解析通道路由: 订单已锁定优先用快照; 否则按聚合/收银台与支付同源解析
private RouteSnapshot resolveRoute(GatewayPayOrder order, GatewayAuthUrlParam param) {
// 支付中已回填通道信息: 与锁定通道同源, 避免二次路由漂移
if (Objects.equals(order.getStatus(), GatewayOrderStatusEnum.PAYING.getCode())
&& StrUtil.isNotBlank(order.getChannelMchNo())
&& StrUtil.isNotBlank(order.getProduct())) {
return new RouteSnapshot(
order.getProduct(),
order.getChannelMchNo(),
order.getCapability(),
order.getChannelAppId());
}
String gatewayType = order.getGatewayType();
if (Objects.equals(gatewayType, GatewayPayTypeEnum.AGGREGATE.getCode())) {
return resolveAggregateRoute(order, param);
}
if (Objects.equals(gatewayType, GatewayPayTypeEnum.CASHIER.getCode())) {
return resolveCashierRoute(order, param);
}
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR, "pay.error.gateway.typeMismatch");
}
/// 聚合: ClientEnvPayResolve + PayRouteService(与 AggregatePayService 同源)
private RouteSnapshot resolveAggregateRoute(GatewayPayOrder order, GatewayAuthUrlParam param) {
if (StrUtil.isBlank(param.getClientEnv())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"validation.field.clientEnv.notBlank");
}
ClientEnvEnum clientEnv = ClientEnvEnum.findByCode(param.getClientEnv());
ClientRuntimeEnum runtime = ClientRuntimeEnum.ofOrDefault(param.getRuntime());
var resolved = clientEnvPayResolveService.resolveRequired(order.getAppId(), clientEnv, runtime);
NormalPayParam routeParam = new NormalPayParam();
routeParam.setMchNo(order.getMchNo());
routeParam.setAppId(order.getAppId());
routeParam.setMethod(resolved.method());
routeParam.setChannelMchNo(resolved.channelMchNo());
routeParam.setCapability(resolved.capability());
payRouteService.resolve(routeParam);
return new RouteSnapshot(
routeParam.getProduct(),
routeParam.getChannelMchNo(),
routeParam.getCapability(),
null);
}
/// 收银台: 按 itemId 解析 METHOD/DIRECT 后再路由(与 CashierPayService 同源)
private RouteSnapshot resolveCashierRoute(GatewayPayOrder order, GatewayAuthUrlParam param) {
if (param.getItemId() == null) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"validation.field.itemId.notBlank");
}
if (StrUtil.isBlank(param.getCashierType())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"validation.field.cashierType.notBlank");
}
GatewayCashierTypeEnum typeEnum = GatewayCashierTypeEnum.findByCode(param.getCashierType());
String bucketClientEnv = normalizeClientEnvForBucket(typeEnum, param.getClientEnv());
GatewayCashierItem item = loadAndCheckItem(param.getItemId(), order.getAppId(), typeEnum, bucketClientEnv);
String method = null;
String channelMchNo = null;
String capability = null;
CashierItemResolveModeEnum resolveMode = CashierItemResolveModeEnum.findByCode(item.getResolveMode());
switch (resolveMode) {
case METHOD -> {
if (StrUtil.isBlank(item.getMethod())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.cashierItemMethodRequired");
}
method = item.getMethod();
}
case DIRECT -> {
if (StrUtil.isBlank(item.getChannelMchNo()) || StrUtil.isBlank(item.getCapability())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.cashierItemChannelMchRequired");
}
channelMchNo = item.getChannelMchNo();
capability = item.getCapability();
method = item.getMethod();
}
default -> throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.clientEnvNotSupport");
};
}
NormalPayParam routeParam = new NormalPayParam();
routeParam.setMchNo(order.getMchNo());
routeParam.setAppId(order.getAppId());
routeParam.setMethod(method);
routeParam.setChannelMchNo(channelMchNo);
routeParam.setCapability(capability);
payRouteService.resolve(routeParam);
return new RouteSnapshot(
routeParam.getProduct(),
routeParam.getChannelMchNo(),
routeParam.getCapability(),
null);
}
private GatewayCashierItem loadAndCheckItem(Long itemId, String appId,
GatewayCashierTypeEnum typeEnum, String bucketClientEnv) {
GatewayCashierItem item = gatewayCashierItemManager.findById(itemId)
.orElseThrow(() -> new DataNotExistException("pay.error.gateway.cashierItemNotFound"));
if (!Objects.equals(item.getAppId(), appId)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.cashierItemNotFound");
}
if (!Objects.equals(item.getCashierType(), typeEnum.getCode())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.cashierItemNotFound");
}
if (!typeEnum.requiresClientEnv()) {
if (StrUtil.isNotBlank(item.getClientEnv())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.cashierItemNotFound");
}
} else if (!Objects.equals(item.getClientEnv(), bucketClientEnv)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.cashierItemNotFound");
}
return item;
}
private String normalizeClientEnvForBucket(GatewayCashierTypeEnum typeEnum, String clientEnv) {
if (!typeEnum.requiresClientEnv()) {
return null;
}
if (StrUtil.isBlank(clientEnv)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.clientEnvRequired");
}
ClientEnvEnum env = ClientEnvEnum.findByCode(clientEnv);
if (typeEnum == GatewayCashierTypeEnum.MINI && !MINI_CLIENT_ENVS.contains(env.getCode())) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.clientEnvNotSupport");
}
return env.getCode();
}
/// 校验并规范化 returnPath: 必须是以 / 开头的相对路径, 禁止协议/外链/反斜杠
@@ -50,7 +238,6 @@ public class GatewayAuthService {
"validation.field.returnPath.notBlank");
}
String path = returnPath.trim();
// 禁止绝对 URL / 协议相对 / 反斜杠逃逸
if (!path.startsWith("/")
|| path.startsWith("//")
|| path.contains("://")
@@ -59,7 +246,6 @@ public class GatewayAuthService {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.returnPathInvalid");
}
// 仅允许网关业务落地前缀, 且路径中应含本订单号(防跨单串跳)
boolean allowedPrefix = path.startsWith("/cashier/")
|| path.startsWith("/aggregate/")
|| path.startsWith("/h/");
@@ -67,11 +253,14 @@ public class GatewayAuthService {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"pay.error.gateway.returnPathInvalid");
}
// 去掉 hash, 保留 query
int hash = path.indexOf('#');
if (hash >= 0) {
path = path.substring(0, hash);
}
return path;
}
/// 路由快照(供组装 GenerateAuthUrlParam)
private record RouteSnapshot(String product, String channelMchNo, String capability, String channelAppId) {
}
}

View File

@@ -28,6 +28,7 @@ import cn.daxpay.open.platform.system.entity.config.platform.security.PlatformPa
import cn.daxpay.open.platform.system.service.config.security.PlatformSecurityConfigService;
import cn.daxpay.open.payment.trade.util.PayTradeInitUtil;
import cn.hutool.core.util.StrUtil;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Lazy;
@@ -41,6 +42,7 @@ import java.util.Objects;
/// 在 product/method 已解析后: 懒创建 Trade → 调通道策略 → 回写容器。
@Slf4j
@Service
@AllArgsConstructor
public class GatewayPayHandleService {
private final GatewayPayOrderManager gatewayPayOrderManager;
@@ -57,28 +59,6 @@ public class GatewayPayHandleService {
/// 自注入,保证 [GatewayPayHandleService#createTrade] / [GatewayPayHandleService#paySuccess] 走 Spring 事务代理
private final GatewayPayHandleService self;
public GatewayPayHandleService(GatewayPayOrderManager gatewayPayOrderManager,
PayTradeManager payTradeManager,
PayRouteService payRouteService,
MerchantContextLoader merchantContextLoader,
PayUniHandleService payUniHandleService,
GatewayPayAssistService gatewayPayAssistService,
LockExecutor lockExecutor,
ObjectProvider<PayRiskChecker> payRiskCheckerProvider,
PlatformSecurityConfigService platformSecurityConfigService,
@Lazy GatewayPayHandleService self) {
this.gatewayPayOrderManager = gatewayPayOrderManager;
this.payTradeManager = payTradeManager;
this.payRouteService = payRouteService;
this.merchantContextLoader = merchantContextLoader;
this.payUniHandleService = payUniHandleService;
this.gatewayPayAssistService = gatewayPayAssistService;
this.lockExecutor = lockExecutor;
this.payRiskCheckerProvider = payRiskCheckerProvider;
this.platformSecurityConfigService = platformSecurityConfigService;
this.self = self;
}
/// 发起网关支付
///
/// @param channelMchNo 通道商户号(DIRECT 模式传入跳过路由, 为空走路由解析)
@@ -156,10 +136,7 @@ public class GatewayPayHandleService {
String errMsg = (e instanceof PayFailureException)
? e.getMessage() : "支付出现异常: " + e.getMessage();
payUniHandleService.payFail(existing, errMsg);
if (e instanceof RuntimeException re) {
throw re;
}
throw new PayFailureException(errMsg);
throw (RuntimeException) e;
}
NormalPayResult payResult = self.paySuccess(current, existing, result);
// 风控事后补录: 用通道回写 buyerId 补充命中检查, 仅记录不阻断资金态

View File

@@ -17,10 +17,6 @@ import lombok.experimental.Accessors;
@Schema(title = "通道认证参数")
public class AuthCodeParam extends MerchantPaymentCommonParam {
/// 通道(支付宝平台级认证可不传)
@Schema(description = "通道")
private String channel;
/// 认证类型, 如果通道支持多种类型的情况下, 参数必传
/// @see ChannelAuthTypeEnum
@Schema(description = "认证类型")

View File

@@ -1,10 +1,8 @@
package cn.daxpay.open.payment.unipay.param.assist;
import cn.daxpay.open.platform.core.enums.unipay.ChannelAuthTypeEnum;
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;
@@ -18,19 +16,13 @@ import lombok.experimental.Accessors;
@Schema(title = "生成授权链接参数")
public class GenerateAuthUrlParam extends MerchantPaymentCommonParam {
/// 通道
/// @see ChannelEnum
@NotBlank(message = "{validation.field.channel.notBlank}")
@Schema(description = "通道")
private String channel;
/// 认证类型, 如果通道支持多种类型的情况下, 不传默认为微信场景
/// @see ChannelAuthTypeEnum
@Schema(description = "认证类型")
private String authType = ChannelAuthTypeEnum.WECHAT.getCode();
/// 支付产品编码, 决定走哪个通道产品的认证策略
/// 可选: 缺失时由 [cn.daxpay.open.payment.auth.ChannelAuthService] 从通道商户号(channelMchNo)反查
/// 可选: 缺失时由 [cn.daxpay.open.payment.auth.ChannelProductAuthService] 从通道商户号(channelMchNo)反查
/// @see cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum
@Size(max = 32, message = "{validation.field.product.size}")
@Schema(description = "支付产品编码")

View File

@@ -8,7 +8,7 @@ import lombok.Data;
/// # 网关 H5 生成授权链接参数(无商户签名)
///
/// 凭网关订单装载商户上下文后生成 OAuth 链接; 用于收银台/聚合等落地页取 openId。
/// 当前一期走平台级认证配置(微信公众号/支付宝/抖音 H5); 通道级(按支付项 DIRECT)可后续扩展
/// 微信/抖音按支付路由读通道商户应用; 支付宝仍走平台级配置
@Data
@Schema(title = "网关授权链接参数")
public class GatewayAuthUrlParam {
@@ -29,4 +29,26 @@ public class GatewayAuthUrlParam {
@NotBlank(message = "{validation.field.returnPath.notBlank}")
@Size(max = 200, message = "{validation.field.returnPath.size}")
private String returnPath;
/// 客户端环境(聚合/收银台 H5 解析支付方式用; 支付宝可空)
/// @see cn.daxpay.open.payment.merchant.enums.ClientEnvEnum
@Schema(description = "客户端环境 wechat/alipay/douyin/union_pay")
@Size(max = 32, message = "{validation.field.clientEnv.size}")
private String clientEnv;
/// 运行形态(聚合解析用; 空默认 h5)
/// @see cn.daxpay.open.payment.merchant.enums.ClientRuntimeEnum
@Schema(description = "运行形态 h5/mini")
@Size(max = 16, message = "{validation.field.runtime.size}")
private String runtime;
/// 收银台支付项 ID(收银台场景必填)
@Schema(description = "收银台支付项ID")
private Long itemId;
/// 收银台类型 h5/web/mini(收银台场景必填)
/// @see cn.daxpay.open.payment.merchant.enums.GatewayCashierTypeEnum
@Schema(description = "收银台类型")
@Size(max = 16, message = "{validation.field.cashierType.size}")
private String cashierType;
}

View File

@@ -1,6 +1,6 @@
package cn.daxpay.open.payment.unipay.client.service;
import cn.daxpay.open.payment.auth.ChannelAuthFacade;
import cn.daxpay.open.payment.auth.ChannelAuthService;
import cn.daxpay.open.payment.common.context.MerchantContextLoader;
import cn.daxpay.open.payment.common.util.PayMethodOpenIdSupport;
import cn.daxpay.open.payment.device.enums.QrCodeAmountTypeEnum;
@@ -30,7 +30,6 @@ import cn.daxpay.open.payment.unipay.result.trade.pay.NormalPayResult;
import cn.daxpay.open.platform.common.spring.util.WebServletUtil;
import cn.daxpay.open.platform.core.code.CommonCode;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.enums.pay.channel.ProductEnum;
import cn.daxpay.open.platform.core.enums.pay.trade.TradeSourceEnum;
import cn.daxpay.open.platform.core.enums.unipay.ChannelAuthTypeEnum;
import cn.daxpay.open.platform.core.exception.BizInfoException;
@@ -62,7 +61,7 @@ public class CodePayAssistService {
private final CodePayResolveService codePayResolveService;
private final NormalPayService normalPayService;
private final PayRouteService payRouteService;
private final ChannelAuthFacade channelAuthFacade;
private final ChannelAuthService channelAuthService;
private final NormalPayOrderManager normalPayOrderManager;
/// 风控检查器(可选 SPI用于判断是否存在 openId 黑名单, 决定是否触发强制 OAuth
private final ObjectProvider<PayRiskChecker> payRiskCheckerProvider;
@@ -186,11 +185,7 @@ public class CodePayAssistService {
authParam.setCapability(routeParam.getCapability());
authParam.setReturnPath(returnPath);
authParam.setAuthType(this.mapAuthType(clientEnv));
// channel: 由产品反推
if (StrUtil.isNotBlank(routeParam.getProduct())) {
authParam.setChannel(ProductEnum.findByCode(routeParam.getProduct()).getChannel());
}
return channelAuthFacade.generateAuthUrl(authParam);
return channelAuthService.generateAuthUrl(authParam);
}
/// 查询码牌订单状态(忽略租户; 仅 cashier_code 来源)
@@ -308,6 +303,7 @@ public class CodePayAssistService {
return switch (clientEnv) {
case ALIPAY -> ChannelAuthTypeEnum.ALIPAY.getCode();
case WECHAT -> ChannelAuthTypeEnum.WECHAT.getCode();
case DOUYIN -> ChannelAuthTypeEnum.DOUYIN.getCode();
default -> ChannelAuthTypeEnum.WECHAT.getCode();
};
}

View File

@@ -3,7 +3,7 @@ package cn.daxpay.open.payment.unipay.trade.controller;
import cn.daxpay.open.platform.core.annotation.IgnoreAuth;
import cn.daxpay.open.platform.core.rest.Res;
import cn.daxpay.open.platform.core.rest.result.Result;
import cn.daxpay.open.payment.auth.ChannelAuthFacade;
import cn.daxpay.open.payment.auth.ChannelAuthService;
import cn.daxpay.open.payment.unipay.param.assist.AuthCodeParam;
import cn.daxpay.open.payment.unipay.param.assist.GenerateAuthUrlParam;
import cn.daxpay.open.payment.common.result.DaxResult;
@@ -22,9 +22,9 @@ import org.springframework.web.bind.annotation.RestController;
/// # 通道认证服务
///
/// 统一支付网关对外的通道 OAuth 入口(取 openId/userId, **非登录**)。
/// 业务分发委托 [ChannelAuthFacade]:
/// 业务分发委托 [ChannelAuthService]:
/// - **生成授权链接**: authType=ALIPAY 走平台级支付宝 OAuth; 其余按支付产品走通道策略
/// - **授权码回调**: 按 session.source 走平台分支, 否则走通道策略
/// - **授权码回调**: 按 session.source 走平台分支, 否则走通道产品策略
@IgnoreAuth
@Tag(name = "通道认证服务")
@RestController
@@ -32,25 +32,25 @@ import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
public class ChannelAuthController {
private final ChannelAuthFacade channelAuthFacade;
private final ChannelAuthService channelAuthService;
@PaymentVerify
@Operation(summary = "获取授权链接")
@PostMapping("/generate-auth-url")
public DaxResult<AuthUrlResult> generateAuthUrl(@RequestBody GenerateAuthUrlParam param) {
return DaxRes.ok(channelAuthFacade.generateAuthUrl(param));
return DaxRes.ok(channelAuthService.generateAuthUrl(param));
}
@Operation(summary = "通过AuthCode获取认证结果")
@PostMapping("/auth")
public Result<AuthResult> auth(@RequestBody AuthCodeParam param) {
return Res.ok(channelAuthFacade.auth(param));
return Res.ok(channelAuthService.auth(param));
}
@Operation(summary = "通过AuthCode获取并设置认证结果")
@PostMapping("/auth-and-set")
public Result<Void> authAndSet(@RequestBody AuthCodeParam param) {
channelAuthFacade.auth(param);
channelAuthService.auth(param);
return Res.ok();
}
}

View File

@@ -1,63 +0,0 @@
package cn.daxpay.open.payment.unipay.trade.controller;
import cn.daxpay.open.platform.capability.douyin.auth.result.DouyinJsapiConfigResult;
import cn.daxpay.open.platform.capability.douyin.auth.service.DouyinOpenTokenService;
import cn.daxpay.open.platform.core.annotation.IgnoreAuth;
import cn.daxpay.open.platform.core.code.CommonErrorCode;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import cn.daxpay.open.platform.core.rest.Res;
import cn.daxpay.open.platform.core.rest.result.Result;
import cn.daxpay.open.platform.system.entity.config.platform.auth.PlatformDouyinH5AuthConfig;
import cn.daxpay.open.platform.system.service.config.auth.PlatformDouyinH5AuthConfigService;
import cn.hutool.core.util.StrUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/// # 抖音 H5 JSAPI 调起前置 - sdk.config 验签接口
///
/// 给前端 H5(抖音 APP webview) 调用: 前端拉起 `ttcjpay.dypay` 前必须先 `window.DouyinOpenJSBridge.config({...})`
/// 通过签名验证, 此接口返回该签名包(clientKey + timestamp + nonceStr + signature)。
///
/// 签名基于平台级 [PlatformDouyinH5AuthConfig](clientKey/clientSecret) 换取的 jsapi_ticket 计算,
/// 不依赖商户上下文, 故接口路径不挂 `@PaymentVerify`, 仅 `@IgnoreAuth` 跳过登录即可。
///
/// 算法: `MD5(jsapi_ticket={}&nonce_str={}&timestamp={}&url={})`, 字典序拼接。
///
/// 参考文档:
/// - JS 接入指南: https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/sdk/web-app/js/js-access
/// - 验证签名: https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/sdk/web-app/js/signature
@IgnoreAuth
@Tag(name = "抖音 JSAPI 调起辅助")
@RestController
@RequestMapping("/unipay/assist/channel/douyin")
@RequiredArgsConstructor
public class DouyinJsapiController {
private final PlatformDouyinH5AuthConfigService platformDouyinH5AuthConfigService;
private final DouyinOpenTokenService douyinOpenTokenService;
@Operation(summary = "获取抖音 JS-SDK sdk.config 验签包")
@Parameter(name = "url", description = "调用 JS 接口页面的完整 URL(不含 # 及后面部分)", required = true)
@GetMapping("/jsapi-config")
public Result<DouyinJsapiConfigResult> getJsapiConfig(@RequestParam("url") String url) {
if (StrUtil.isBlank(url)) {
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
"error.channel.douyin.jsapiConfigParamBlank");
}
PlatformDouyinH5AuthConfig config = platformDouyinH5AuthConfigService.getDouyinH5AuthConfig();
if (StrUtil.hasBlank(config.getClientKey(), config.getClientSecret())) {
// 抖音 H5 应用认证配置不完整, 请先在「平台配置」中配置 clientKey/clientSecret
throw new BizInfoException(CommonErrorCode.SYSTEM_ERROR,
"error.channel.douyin.h5AuthNotConfigured");
}
DouyinJsapiConfigResult result = douyinOpenTokenService.buildJsapiConfig(
config.getClientKey(), config.getClientSecret(), url);
return Res.ok(result);
}
}

View File

@@ -11,5 +11,10 @@
"tokenLockFailed": "Failed to acquire Douyin client_access_token refresh lock, please retry later",
"ticketLockFailed": "Failed to acquire Douyin jsapi_ticket refresh lock, please retry later",
"jsapiConfigParamBlank": "Douyin sdk.config signing parameter (clientKey/clientSecret/url) is blank",
"h5AuthNotConfigured": "Douyin H5 app auth config is incomplete, please configure clientKey/clientSecret in Platform Config first"
"h5AuthNotConfigured": "Douyin H5 app auth config is incomplete, please configure clientKey/clientSecret in Platform Config first",
"authFailed": "Failed to get user id (openId): {0}",
"channelAppIdNotFound": "Douyin AppId [{0}] is not configured in the system",
"webAppNotFound": "No Douyin web app under this channel merchant; cannot complete H5 auth/signing",
"appAuthSecretMissing": "Douyin direct app auth secret is missing; configure appSecret on the channel merchant app first",
"jsapiContextRequired": "Douyin JSAPI signing requires one of orderNo, code, or channelMchNo"
}

View File

@@ -420,6 +420,9 @@
"privateBucket": {
"notBlank": "Private bucket cannot be blank"
},
"url": {
"notBlank": "URL cannot be blank"
},
"urlType": {
"notBlank": "URL type cannot be blank"
},
@@ -803,6 +806,12 @@
},
"yopIsvNo": {
"notBlank": "YeePay ISV number cannot be blank"
},
"itemId": {
"notBlank": "Cashier item ID cannot be blank"
},
"runtime": {
"size": "Runtime code cannot exceed 16 characters"
}
}
}

View File

@@ -11,5 +11,10 @@
"tokenLockFailed": "Gagal mendapatkan kunci pembaruan client_access_token Douyin, coba lagi nanti",
"ticketLockFailed": "Gagal mendapatkan kunci pembaruan jsapi_ticket Douyin, coba lagi nanti",
"jsapiConfigParamBlank": "Parameter tanda tangan sdk.config Douyin (clientKey/clientSecret/url) kosong",
"h5AuthNotConfigured": "Konfigurasi autentikasi aplikasi H5 Douyin tidak lengkap, mohon konfigurasikan clientKey/clientSecret di Konfigurasi Platform terlebih dahulu"
"h5AuthNotConfigured": "Konfigurasi autentikasi aplikasi H5 Douyin tidak lengkap, mohon konfigurasikan clientKey/clientSecret di Konfigurasi Platform terlebih dahulu",
"authFailed": "Gagal mendapatkan id pengguna (openId): {0}",
"channelAppIdNotFound": "Douyin AppId [{0}] tidak dikonfigurasi di sistem",
"webAppNotFound": "Tidak ada aplikasi web Douyin pada merchant saluran; tidak dapat menyelesaikan auth/signing H5",
"appAuthSecretMissing": "Rahasia auth aplikasi langsung Douyin hilang; konfigurasikan appSecret pada aplikasi merchant saluran",
"jsapiContextRequired": "Penandatanganan JSAPI Douyin memerlukan salah satu dari orderNo, code, atau channelMchNo"
}

View File

@@ -420,6 +420,9 @@
"privateBucket": {
"notBlank": "Keranjang pribadi tidak boleh kosong"
},
"url": {
"notBlank": "URL tidak boleh kosong"
},
"urlType": {
"notBlank": "Jenis URL tidak boleh kosong"
},
@@ -803,6 +806,12 @@
},
"blockOverseasIp": {
"notNull": "Saklar blokir IP luar negeri tidak boleh kosong"
},
"itemId": {
"notBlank": "ID item kasir tidak boleh kosong"
},
"runtime": {
"size": "Kode runtime tidak boleh lebih dari 16 karakter"
}
}
}

View File

@@ -11,5 +11,10 @@
"tokenLockFailed": "Douyin client_access_token 更新ロックの取得に失敗しました、後でもう一度お試しください",
"ticketLockFailed": "Douyin jsapi_ticket 更新ロックの取得に失敗しました、後でもう一度お試しください",
"jsapiConfigParamBlank": "Douyin sdk.config 署名パラメータ(clientKey/clientSecret/url)が空です",
"h5AuthNotConfigured": "Douyin H5 アプリ認証設定が不完全です。先に「プラットフォーム設定」で clientKey/clientSecret を設定してください"
"h5AuthNotConfigured": "Douyin H5 アプリ認証設定が不完全です。先に「プラットフォーム設定」で clientKey/clientSecret を設定してください",
"authFailed": "ユーザー識別子(openId)の取得に失敗しました: {0}",
"channelAppIdNotFound": "指定の Douyin AppId [{0}] はシステムに未設定です",
"webAppNotFound": "チャネル販売者に Douyin Web アプリが未設定のため、H5 認証/署名できません",
"appAuthSecretMissing": "Douyin 直連アプリの認証シークレット未設定。チャネル販売者アプリで appSecret を設定してください",
"jsapiContextRequired": "Douyin JSAPI 署名には orderNo / code / channelMchNo のいずれかが必要です"
}

View File

@@ -420,6 +420,9 @@
"privateBucket": {
"notBlank": "プライベートバケットを空白にすることはできません"
},
"url": {
"notBlank": "URLを空白にすることはできません"
},
"urlType": {
"notBlank": "URL タイプを空白にすることはできません"
},
@@ -803,6 +806,12 @@
},
"yopIsvNo": {
"notBlank": "YeePay ISV番号は必須です"
},
"itemId": {
"notBlank": "キャッシャー支払項目IDは必須です"
},
"runtime": {
"size": "実行形態コードは16文字以内です"
}
}
}

View File

@@ -11,5 +11,10 @@
"tokenLockFailed": "Douyin client_access_token 갱신 잠금 획득에 실패했습니다, 나중에 다시 시도해 주세요",
"ticketLockFailed": "Douyin jsapi_ticket 갱신 잠금 획득에 실패했습니다, 나중에 다시 시도해 주세요",
"jsapiConfigParamBlank": "Douyin sdk.config 서명 매개변수(clientKey/clientSecret/url)가 비어 있습니다",
"h5AuthNotConfigured": "Douyin H5 앱 인증 구성이 불완전합니다. 먼저 '플랫폼 구성'에서 clientKey/clientSecret을 구성해 주세요"
"h5AuthNotConfigured": "Douyin H5 앱 인증 구성이 불완전합니다. 먼저 '플랫폼 구성'에서 clientKey/clientSecret을 구성해 주세요",
"authFailed": "사용자 식별자(openId)를 가져오지 못했습니다: {0}",
"channelAppIdNotFound": "지정한 Douyin AppId [{0}]가 시스템에 구성되지 않았습니다",
"webAppNotFound": "채널 판매자에 Douyin 웹 앱이 없어 H5 인증/서명을 완료할 수 없습니다",
"appAuthSecretMissing": "Douyin 직연동 앱 인증 시크릿이 없습니다. 채널 판매자 앱에서 appSecret을 구성하세요",
"jsapiContextRequired": "Douyin JSAPI 서명에는 orderNo, code 또는 channelMchNo 중 하나가 필요합니다"
}

View File

@@ -420,6 +420,9 @@
"privateBucket": {
"notBlank": "비공개 버킷은 비워둘 수 없습니다."
},
"url": {
"notBlank": "URL은 비워둘 수 없습니다"
},
"urlType": {
"notBlank": "URL 유형은 비워둘 수 없습니다."
},
@@ -803,6 +806,12 @@
},
"yopIsvNo": {
"notBlank": "YeePay ISV 번호는 필수입니다"
},
"itemId": {
"notBlank": "계산대 결제 항목 ID는 필수입니다"
},
"runtime": {
"size": "런타임 코드는 16자를 초과할 수 없습니다"
}
}
}

View File

@@ -11,5 +11,10 @@
"tokenLockFailed": "Gagal mendapatkan kunci pembaharuan client_access_token Douyin, sila cuba lagi kemudian",
"ticketLockFailed": "Gagal mendapatkan kunci pembaharuan jsapi_ticket Douyin, sila cuba lagi kemudian",
"jsapiConfigParamBlank": "Parameter tandatangan sdk.config Douyin (clientKey/clientSecret/url) kosong",
"h5AuthNotConfigured": "Konfigurasi pengesahan aplikasi H5 Douyin tidak lengkap, sila konfigurasikan clientKey/clientSecret dalam Konfigurasi Platform terlebih dahulu"
"h5AuthNotConfigured": "Konfigurasi pengesahan aplikasi H5 Douyin tidak lengkap, sila konfigurasikan clientKey/clientSecret dalam Konfigurasi Platform terlebih dahulu",
"authFailed": "Gagal mendapatkan id pengguna (openId): {0}",
"channelAppIdNotFound": "Douyin AppId [{0}] tidak dikonfigurasi dalam sistem",
"webAppNotFound": "Tiada apl web Douyin di bawah peniaga saluran; tidak dapat melengkapkan auth/tandatangan H5",
"appAuthSecretMissing": "Rahsia auth apl terus Douyin tiada; konfigurasikan appSecret pada apl peniaga saluran dahulu",
"jsapiContextRequired": "Tandatangan JSAPI Douyin memerlukan salah satu daripada orderNo, code atau channelMchNo"
}

View File

@@ -420,6 +420,9 @@
"privateBucket": {
"notBlank": "Baldi peribadi tidak boleh kosong"
},
"url": {
"notBlank": "URL tidak boleh kosong"
},
"urlType": {
"notBlank": "Jenis URL tidak boleh kosong"
},
@@ -803,6 +806,12 @@
},
"blockOverseasIp": {
"notNull": "Suis sekatan IP luar negara tidak boleh kosong"
},
"itemId": {
"notBlank": "ID item kasir tidak boleh kosong"
},
"runtime": {
"size": "Kod runtime tidak boleh melebihi 16 aksara"
}
}
}

View File

@@ -11,5 +11,10 @@
"tokenLockFailed": "ไม่สามารถรับล็อกการรีเฟรช client_access_token ของ Douyin ได้ โปรดลองอีกครั้งในภายหลัง",
"ticketLockFailed": "ไม่สามารถรับล็อกการรีเฟรช jsapi_ticket ของ Douyin ได้ โปรดลองอีกครั้งในภายหลัง",
"jsapiConfigParamBlank": "พารามิเตอร์ลายเซ็น sdk.config ของ Douyin (clientKey/clientSecret/url) ว่างเปล่า",
"h5AuthNotConfigured": "การกำหนดค่าการตรวจสอบสิทธิ์แอป H5 Douyin ไม่สมบูรณ์ โปรดกำหนดค่า clientKey/clientSecret ในการกำหนดค่าแพลตฟอร์มก่อน"
"h5AuthNotConfigured": "การกำหนดค่าการตรวจสอบสิทธิ์แอป H5 Douyin ไม่สมบูรณ์ โปรดกำหนดค่า clientKey/clientSecret ในการกำหนดค่าแพลตฟอร์มก่อน",
"authFailed": "ไม่สามารถรับรหัสผู้ใช้ (openId) ได้: {0}",
"channelAppIdNotFound": "Douyin AppId [{0}] ยังไม่ได้ตั้งค่าในระบบ",
"webAppNotFound": "ไม่มีแอปเว็บ Douyin ภายใต้ผู้ค้าช่องทาง ไม่สามารถยืนยันตัวตน/ลงนาม H5 ได้",
"appAuthSecretMissing": "ไม่มี appSecret การยืนยันตัวตนของแอป Douyin ตรง; ตั้งค่าในแอปผู้ค้าช่องทางก่อน",
"jsapiContextRequired": "การลงนาม JSAPI Douyin ต้องระบุ orderNo หรือ code หรือ channelMchNo"
}

View File

@@ -420,6 +420,9 @@
"privateBucket": {
"notBlank": "ที่เก็บข้อมูลส่วนตัวต้องไม่เว้นว่าง"
},
"url": {
"notBlank": "URL ไม่สามารถเว้นว่างได้"
},
"urlType": {
"notBlank": "ประเภท URL ไม่สามารถเว้นว่างได้"
},
@@ -803,6 +806,12 @@
},
"blockOverseasIp": {
"notNull": "สวิตช์บล็อก IP ต่างประเทศต้องไม่ว่าง"
},
"itemId": {
"notBlank": "ต้องระบุรหัสรายการแคชเชียร์"
},
"runtime": {
"size": "รหัส runtime ต้องไม่เกิน 16 ตัวอักษร"
}
}
}

View File

@@ -11,5 +11,10 @@
"tokenLockFailed": "Không thể lấy khóa làm mới client_access_token Douyin, vui lòng thử lại sau",
"ticketLockFailed": "Không thể lấy khóa làm mới jsapi_ticket Douyin, vui lòng thử lại sau",
"jsapiConfigParamBlank": "Tham số ký sdk.config Douyin (clientKey/clientSecret/url) bị trống",
"h5AuthNotConfigured": "Cấu hình xác thực ứng dụng H5 Douyin không đầy đủ, vui lòng cấu hình clientKey/clientSecret trong Cấu hình nền tảng trước"
"h5AuthNotConfigured": "Cấu hình xác thực ứng dụng H5 Douyin không đầy đủ, vui lòng cấu hình clientKey/clientSecret trong Cấu hình nền tảng trước",
"authFailed": "Không lấy được định danh người dùng (openId): {0}",
"channelAppIdNotFound": "Douyin AppId [{0}] chưa được cấu hình trong hệ thống",
"webAppNotFound": "Merchant kênh chưa cấu hình ứng dụng web Douyin; không thể hoàn tất xác thực/ký H5",
"appAuthSecretMissing": "Thiếu appSecret xác thực ứng dụng Douyin trực tiếp; hãy cấu hình trên ứng dụng merchant kênh",
"jsapiContextRequired": "Ký JSAPI Douyin cần một trong orderNo, code hoặc channelMchNo"
}

View File

@@ -420,6 +420,9 @@
"privateBucket": {
"notBlank": "Nhóm riêng tư không được để trống"
},
"url": {
"notBlank": "URL không được để trống"
},
"urlType": {
"notBlank": "Loại URL không được để trống"
},
@@ -803,6 +806,12 @@
},
"blockOverseasIp": {
"notNull": "Công tắc chặn IP nước ngoài không được để trống"
},
"itemId": {
"notBlank": "ID mục thanh toán quầy không được để trống"
},
"runtime": {
"size": "Mã runtime không được vượt quá 16 ký tự"
}
}
}

View File

@@ -11,5 +11,10 @@
"tokenLockFailed": "获取抖音 client_access_token 刷新锁失败, 请稍后重试",
"ticketLockFailed": "获取抖音 jsapi_ticket 刷新锁失败, 请稍后重试",
"jsapiConfigParamBlank": "抖音 sdk.config 验签参数(clientKey/clientSecret/url)存在空值",
"h5AuthNotConfigured": "抖音 H5 应用认证配置不完整, 请先在「平台配置」中配置 clientKey/clientSecret"
"h5AuthNotConfigured": "抖音 H5 应用认证配置不完整, 请先在「平台配置」中配置 clientKey/clientSecret",
"authFailed": "获取用户标识(openId)失败: {0}",
"channelAppIdNotFound": "指定的抖音应用AppId[{0}]未在系统中配置",
"webAppNotFound": "通道商户未配置抖音网站应用, 无法完成 H5 授权/验签",
"appAuthSecretMissing": "抖音直连应用授权密钥未配置, 请先在通道商户应用中配置 appSecret",
"jsapiContextRequired": "抖音 JSAPI 验签须传入 orderNo、code 或 channelMchNo 之一"
}

View File

@@ -420,6 +420,9 @@
"privateBucket": {
"notBlank": "私有存储桶不能为空"
},
"url": {
"notBlank": "URL不能为空"
},
"urlType": {
"notBlank": "端点类型不能为空"
},
@@ -803,6 +806,12 @@
},
"yopIsvNo": {
"notBlank": "易宝服务商号不能为空"
},
"itemId": {
"notBlank": "收银台支付项ID不可为空"
},
"runtime": {
"size": "运行形态编码不可超过16位"
}
}
}

View File

@@ -11,5 +11,10 @@
"tokenLockFailed": "取得抖音 client_access_token 更新鎖失敗, 請稍後重試",
"ticketLockFailed": "取得抖音 jsapi_ticket 更新鎖失敗, 請稍後重試",
"jsapiConfigParamBlank": "抖音 sdk.config 驗簽參數(clientKey/clientSecret/url)存在空值",
"h5AuthNotConfigured": "抖音 H5 應用認證配置不完整, 請先在「平台配置」中配置 clientKey/clientSecret"
"h5AuthNotConfigured": "抖音 H5 應用認證配置不完整, 請先在「平台配置」中配置 clientKey/clientSecret",
"authFailed": "取得使用者標識(openId)失敗: {0}",
"channelAppIdNotFound": "指定的抖音應用AppId[{0}]未在系統中配置",
"webAppNotFound": "通道商戶未配置抖音網站應用, 無法完成 H5 授權/驗簽",
"appAuthSecretMissing": "抖音直連應用授權密鑰未配置, 請先在通道商戶應用中配置 appSecret",
"jsapiContextRequired": "抖音 JSAPI 驗簽須傳入 orderNo、code 或 channelMchNo 之一"
}

View File

@@ -420,6 +420,9 @@
"privateBucket": {
"notBlank": "私有存儲桶不能為空"
},
"url": {
"notBlank": "URL不能為空"
},
"urlType": {
"notBlank": "端點類型不能為空"
},
@@ -803,6 +806,12 @@
},
"yopIsvNo": {
"notBlank": "易寶服務商號不能為空"
},
"itemId": {
"notBlank": "收銀台支付項ID不可為空"
},
"runtime": {
"size": "運行形態編碼不可超過16位"
}
}
}

View File

@@ -11,5 +11,10 @@
"tokenLockFailed": "取得抖音 client_access_token 更新鎖失敗, 請稍後重試",
"ticketLockFailed": "取得抖音 jsapi_ticket 更新鎖失敗, 請稍後重試",
"jsapiConfigParamBlank": "抖音 sdk.config 驗簽參數(clientKey/clientSecret/url)存在空值",
"h5AuthNotConfigured": "抖音 H5 應用認證配置不完整, 請先在「平台配置」中配置 clientKey/clientSecret"
"h5AuthNotConfigured": "抖音 H5 應用認證配置不完整, 請先在「平台配置」中配置 clientKey/clientSecret",
"authFailed": "取得使用者標識(openId)失敗: {0}",
"channelAppIdNotFound": "指定的抖音應用AppId[{0}]未在系統中配置",
"webAppNotFound": "通道商戶未配置抖音網站應用, 無法完成 H5 授權/驗簽",
"appAuthSecretMissing": "抖音直連應用授權密鑰未配置, 請先在通道商戶應用中配置 appSecret",
"jsapiContextRequired": "抖音 JSAPI 驗簽須傳入 orderNo、code 或 channelMchNo 之一"
}

View File

@@ -420,6 +420,9 @@
"privateBucket": {
"notBlank": "私有儲存桶不能為空"
},
"url": {
"notBlank": "URL不能為空"
},
"urlType": {
"notBlank": "端點型別不能為空"
},
@@ -803,6 +806,12 @@
},
"yopIsvNo": {
"notBlank": "易寶服務商號不能為空"
},
"itemId": {
"notBlank": "收銀台支付項ID不可為空"
},
"runtime": {
"size": "執行形態編碼不可超過16位"
}
}
}