mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-08 14:15:34 +08:00
refactor(auth): 通道认证入口重命名并按产品路由网关授权
将 ChannelAuthFacade 升为 ChannelAuthService,商户产品策略拆为 ChannelProductAuthService;删除 Auth 参数遗留 channel 字段。网关/码牌授权改为经产品路由取 openId,抖音直连认证策略与 Jsapi 控制器下沉通道模块。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// 通过查询码获取认证结果
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,8 +15,8 @@ import java.util.Objects;
|
||||
/// # 认证会话与结果缓存
|
||||
///
|
||||
/// 统一管理通道认证/平台级认证共用的会话上下文(authToken)与轮询结果(queryCode)的 Redis 读写,
|
||||
/// 与具体认证来源(通道商户策略 / 平台级配置)解耦, 供 [ChannelAuthService]、[PlatformAuthService]、
|
||||
/// [ChannelAuthFacade] 及调试入口(DevelopAuthService)复用。
|
||||
/// 与具体认证来源(通道商户策略 / 平台级配置)解耦, 供 [ChannelAuthService]、[ChannelProductAuthService]、
|
||||
/// [PlatformAuthService] 及调试入口(DevelopAuthService)复用。
|
||||
/// 授权成功后由 Facade 调用 [#deleteSession] 使 authToken 一次使用失效。
|
||||
@Slf4j
|
||||
@Service
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 约定。
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 补充命中检查, 仅记录不阻断资金态
|
||||
|
||||
@@ -17,10 +17,6 @@ import lombok.experimental.Accessors;
|
||||
@Schema(title = "通道认证参数")
|
||||
public class AuthCodeParam extends MerchantPaymentCommonParam {
|
||||
|
||||
/// 通道(支付宝平台级认证可不传)
|
||||
@Schema(description = "通道")
|
||||
private String channel;
|
||||
|
||||
/// 认证类型, 如果通道支持多种类型的情况下, 参数必传
|
||||
/// @see ChannelAuthTypeEnum
|
||||
@Schema(description = "认证类型")
|
||||
|
||||
@@ -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 = "支付产品编码")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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={}×tamp={}&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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 のいずれかが必要です"
|
||||
}
|
||||
|
||||
@@ -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文字以内です"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 중 하나가 필요합니다"
|
||||
}
|
||||
|
||||
@@ -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자를 초과할 수 없습니다"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 ตัวอักษร"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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ự"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 之一"
|
||||
}
|
||||
|
||||
@@ -420,6 +420,9 @@
|
||||
"privateBucket": {
|
||||
"notBlank": "私有存储桶不能为空"
|
||||
},
|
||||
"url": {
|
||||
"notBlank": "URL不能为空"
|
||||
},
|
||||
"urlType": {
|
||||
"notBlank": "端点类型不能为空"
|
||||
},
|
||||
@@ -803,6 +806,12 @@
|
||||
},
|
||||
"yopIsvNo": {
|
||||
"notBlank": "易宝服务商号不能为空"
|
||||
},
|
||||
"itemId": {
|
||||
"notBlank": "收银台支付项ID不可为空"
|
||||
},
|
||||
"runtime": {
|
||||
"size": "运行形态编码不可超过16位"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 之一"
|
||||
}
|
||||
|
||||
@@ -420,6 +420,9 @@
|
||||
"privateBucket": {
|
||||
"notBlank": "私有存儲桶不能為空"
|
||||
},
|
||||
"url": {
|
||||
"notBlank": "URL不能為空"
|
||||
},
|
||||
"urlType": {
|
||||
"notBlank": "端點類型不能為空"
|
||||
},
|
||||
@@ -803,6 +806,12 @@
|
||||
},
|
||||
"yopIsvNo": {
|
||||
"notBlank": "易寶服務商號不能為空"
|
||||
},
|
||||
"itemId": {
|
||||
"notBlank": "收銀台支付項ID不可為空"
|
||||
},
|
||||
"runtime": {
|
||||
"size": "運行形態編碼不可超過16位"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 之一"
|
||||
}
|
||||
|
||||
@@ -420,6 +420,9 @@
|
||||
"privateBucket": {
|
||||
"notBlank": "私有儲存桶不能為空"
|
||||
},
|
||||
"url": {
|
||||
"notBlank": "URL不能為空"
|
||||
},
|
||||
"urlType": {
|
||||
"notBlank": "端點型別不能為空"
|
||||
},
|
||||
@@ -803,6 +806,12 @@
|
||||
},
|
||||
"yopIsvNo": {
|
||||
"notBlank": "易寶服務商號不能為空"
|
||||
},
|
||||
"itemId": {
|
||||
"notBlank": "收銀台支付項ID不可為空"
|
||||
},
|
||||
"runtime": {
|
||||
"size": "執行形態編碼不可超過16位"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user