mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-12 15:35:38 +08:00
feat(payment): 收银台抖音认证支持 code/anonymous_code 分字段
CashierAuth 与 DouyinMaAuthService 按官方 jscode2session 分传 code 与 anonymous_code;小程序换 openId 归收银台运行时,Web 认证调试注释同步。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
package cn.daxpay.open.payment.admin.controller.develop;
|
||||
|
||||
import cn.daxpay.open.payment.auth.develop.DevelopAuthService;
|
||||
import cn.daxpay.open.payment.auth.develop.DevelopChannelAuthParam;
|
||||
import cn.daxpay.open.payment.auth.develop.DevelopAuthService;
|
||||
import cn.daxpay.open.payment.unipay.result.assist.AuthResult;
|
||||
import cn.daxpay.open.payment.unipay.result.assist.AuthUrlResult;
|
||||
import cn.daxpay.open.platform.core.annotation.PermCode;
|
||||
@@ -21,16 +21,13 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/// 认证调试(管理)
|
||||
///
|
||||
/// 调试入口:
|
||||
/// - 支付宝H5平台级配置(中间页授权链接)
|
||||
/// 调试入口(均为 OAuth 链接 + queryCode 轮询):
|
||||
/// - 支付宝H5平台级配置
|
||||
/// - 微信公众号配置(平台级 OAuth)
|
||||
/// - 抖音 H5 应用配置(平台级 silent_auth 静默授权)
|
||||
/// - 抖音 H5 应用配置(平台级 silent_auth)
|
||||
/// - 微信支付(直连/服务商, 需商户参数)
|
||||
/// - 抖音支付(直连/服务商, 需商户参数)
|
||||
/// - 支付宝小程序(暂未实现)
|
||||
/// - 微信小程序(商户端/运营端, 暂未实现)
|
||||
///
|
||||
/// 已实现项均通过查询码轮询认证结果。
|
||||
@Validated
|
||||
@PermCode(menuCode = PermCodes.Develop.Auth.MENU)
|
||||
@Tag(name = "认证调试服务")
|
||||
|
||||
@@ -27,8 +27,10 @@ import cn.daxpay.open.payment.auth.channel.MerchantChannelAuthService;
|
||||
/// - **微信支付(直连/服务商)**: 委托 [MerchantChannelAuthService] 按支付产品路由认证策略,
|
||||
/// 依赖商户上下文(channelMchNo/产品/能力)
|
||||
/// - **抖音支付(直连/服务商)**: 同微信支付, 显式选抖音网站应用 → [MerchantChannelAuthService]
|
||||
/// - **支付宝小程序**: 暂未实现
|
||||
/// - **微信小程序(商户端/运营端)**: 暂未实现
|
||||
///
|
||||
/// 小程序(微信/支付宝/抖音)获取 openId 已迁至收银台小程序运行时
|
||||
/// ([cn.daxpay.open.payment.trade.runtime.service.pay.gateway.CashierAuthService]),
|
||||
/// Web 认证调试不再提供小程序 code 直换入口。
|
||||
///
|
||||
/// 已实现项共用 queryCode 轮询机制(由 [AuthSessionStore#queryAuthResult] 统一查询)。
|
||||
@Slf4j
|
||||
|
||||
@@ -52,11 +52,10 @@ public class CashierAuthService {
|
||||
public AuthResult auth(CashierAuthParam param) {
|
||||
// channel 字符串 → 枚举(非法值由 findByCode 抛 ConfigNotExistException)
|
||||
ChannelAuthTypeEnum authType = ChannelAuthTypeEnum.findByCode(param.getChannel());
|
||||
String authCode = param.getAuthCode();
|
||||
return switch (authType) {
|
||||
case WECHAT -> authWechat(authType, authCode);
|
||||
case ALIPAY -> authAlipay(authType, authCode);
|
||||
case DOUYIN -> authDouyin(authType, authCode);
|
||||
case WECHAT -> authWechat(authType, param.getAuthCode());
|
||||
case ALIPAY -> authAlipay(authType, param.getAuthCode());
|
||||
case DOUYIN -> authDouyin(authType, param.getAuthCode(), param.getAnonymousCode());
|
||||
default -> throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
|
||||
"error.mobile_app.platformNotFound", authType.getCode());
|
||||
};
|
||||
@@ -64,6 +63,7 @@ public class CashierAuthService {
|
||||
|
||||
/// 微信小程序: jscode2session 换 openId
|
||||
private AuthResult authWechat(ChannelAuthTypeEnum authType, String authCode) {
|
||||
requireAuthCode(authCode);
|
||||
WxMiniAppConfig config = loadConfig(authType, WxMiniAppConfig.class);
|
||||
WechatAuthResult data = wechatMaAuthService.getOpenId(authCode, config.getAppId(), config.getAppSecret());
|
||||
if (StrUtil.isBlank(data.getOpenId())) {
|
||||
@@ -78,6 +78,7 @@ public class CashierAuthService {
|
||||
|
||||
/// 支付宝小程序: alipay.system.oauth.token 换 userId
|
||||
private AuthResult authAlipay(ChannelAuthTypeEnum authType, String authCode) {
|
||||
requireAuthCode(authCode);
|
||||
AlipayMiniAppConfig config = loadConfig(authType, AlipayMiniAppConfig.class);
|
||||
AlipayAuthConfig authConfig = toAlipayAuthConfig(config);
|
||||
AlipayAuthResult alipayResult = alipayAuthCapability.getUserId(authConfig, authCode);
|
||||
@@ -94,11 +95,16 @@ public class CashierAuthService {
|
||||
.setStatus(ChannelAuthStatusEnum.SUCCESS.getCode());
|
||||
}
|
||||
|
||||
/// 抖音小程序: jscode2session 换 openId
|
||||
private AuthResult authDouyin(ChannelAuthTypeEnum authType, String authCode) {
|
||||
/// 抖音小程序: jscode2session 换 openId(code / anonymous_code 分字段)
|
||||
private AuthResult authDouyin(ChannelAuthTypeEnum authType, String authCode, String anonymousCode) {
|
||||
if (StrUtil.isAllBlank(authCode, anonymousCode)) {
|
||||
// 授权码不可为空
|
||||
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
|
||||
"validation.field.oauthCode.notBlank");
|
||||
}
|
||||
DyMiniAppConfig config = loadConfig(authType, DyMiniAppConfig.class);
|
||||
DouyinAuthResult data = douyinMaAuthService.getOpenId(
|
||||
config.getAppId(), config.getAppSecret(), authCode);
|
||||
config.getAppId(), config.getAppSecret(), authCode, anonymousCode);
|
||||
if (StrUtil.isBlank(data.getOpenId())) {
|
||||
// 抖音: 获取用户标识失败
|
||||
throw new BizInfoException(CommonErrorCode.SYSTEM_ERROR, "error.douyin.authFailed", "openId is blank");
|
||||
@@ -108,6 +114,15 @@ public class CashierAuthService {
|
||||
.setStatus(ChannelAuthStatusEnum.SUCCESS.getCode());
|
||||
}
|
||||
|
||||
/// 微信/支付宝: authCode 必填
|
||||
private void requireAuthCode(String authCode) {
|
||||
if (StrUtil.isBlank(authCode)) {
|
||||
// 授权码不可为空
|
||||
throw new BizInfoException(CommonErrorCode.VALIDATE_PARAMETERS_ERROR,
|
||||
"validation.field.oauthCode.notBlank");
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载收银台小程序配置(统一入口, 内部完成 channel→platform 映射)
|
||||
private <T> T loadConfig(ChannelAuthTypeEnum authType, Class<T> configType) {
|
||||
return mobileAppService.findAppConfig(
|
||||
|
||||
@@ -9,6 +9,9 @@ import lombok.Data;
|
||||
///
|
||||
/// 收银台小程序(微信/支付宝/抖音)前端通过 uni.login / my.getAuthCode / tt.login
|
||||
/// 获取授权码后, 调用此参数换 openId/userId。无商户签名, 走 [IgnoreAuth] 端点。
|
||||
///
|
||||
/// 抖音: `authCode`(login code) 与 `anonymousCode` 对应官方分字段 `code` / `anonymous_code`,
|
||||
/// 至少传一个; 非匿名场景可同时传两者。微信/支付宝仅使用 `authCode`。
|
||||
@Data
|
||||
@Schema(title = "收银台认证参数")
|
||||
public class CashierAuthParam {
|
||||
@@ -19,9 +22,14 @@ public class CashierAuthParam {
|
||||
@Size(max = 32, message = "{validation.field.channel.size}")
|
||||
private String channel;
|
||||
|
||||
/// 授权码(uni.login / my.getAuthCode / tt.login 返回)
|
||||
/// 授权码(微信 uni.login / 支付宝 my.getAuthCode / 抖音 tt.login 的 code)
|
||||
/// 抖音匿名登录时可空, 此时须传 [anonymousCode]
|
||||
@Schema(description = "授权码")
|
||||
@NotBlank(message = "{validation.field.oauthCode.notBlank}")
|
||||
@Size(max = 128, message = "{validation.field.oauthCode.size}")
|
||||
private String authCode;
|
||||
|
||||
/// 抖音匿名登录凭证(tt.login 的 anonymous_code), 仅抖音使用; 与 authCode 至少传一个
|
||||
@Schema(description = "抖音匿名授权码")
|
||||
@Size(max = 128, message = "{validation.field.oauthCode.size}")
|
||||
private String anonymousCode;
|
||||
}
|
||||
|
||||
@@ -21,16 +21,14 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/// 认证调试(商户端)
|
||||
///
|
||||
/// 调试入口:
|
||||
/// - 支付宝H5平台级配置(中间页授权链接)
|
||||
/// 调试入口(均为 OAuth 链接 + queryCode 轮询):
|
||||
/// - 支付宝H5平台级配置
|
||||
/// - 微信公众号配置(平台级 OAuth)
|
||||
/// - 抖音 H5 应用配置(平台级 silent_auth 静默授权)
|
||||
/// - 抖音 H5 应用配置(平台级 silent_auth)
|
||||
/// - 微信支付(直连/服务商, 需商户参数)
|
||||
/// - 抖音支付(直连/服务商, 需商户参数)
|
||||
/// - 支付宝小程序(暂未实现)
|
||||
/// - 微信小程序(商户端/运营端, 暂未实现)
|
||||
///
|
||||
/// 已实现项均通过查询码轮询认证结果。
|
||||
/// 小程序获取 openId 已迁至收银台小程序运行时, Web 不再提供小程序调试入口。
|
||||
@Validated
|
||||
@PermCode(menuCode = PermCodes.Develop.Auth.MENU)
|
||||
@Tag(name = "认证调试服务(商户端)")
|
||||
|
||||
@@ -28,19 +28,33 @@ public class DouyinMaAuthService {
|
||||
|
||||
/// 通过授权码换取用户 openId
|
||||
///
|
||||
/// 调用抖音小程序 `jscode2session` 接口(POST JSON),
|
||||
/// `code` 可为 `tt.login` 返回的 `code`(用户授权) 或 `anonymous_code`(静默授权)。
|
||||
/// 优先取 `openid`, 其次 `anonymous_openid`。
|
||||
/// 调用抖音小程序 `jscode2session` 接口(POST JSON)。
|
||||
/// 官方要求 `code` 与 `anonymous_code` 为**分字段**, 至少传一个:
|
||||
/// - 非匿名登录: 传 `code`(必有 openid); 同时有 `anonymous_code` 时一并传用于数据同步
|
||||
/// - 匿名登录: 仅传 `anonymous_code`(返回 anonymous_openid)
|
||||
///
|
||||
/// @param appId 抖音小程序 AppId(即开放平台 clientKey)
|
||||
/// @param appSecret 抖音小程序 AppSecret(即开放平台 clientSecret)
|
||||
/// @param code tt.login 返回的 code 或 anonymous_code
|
||||
/// 响应优先取 `openid`, 其次 `anonymous_openid`。
|
||||
///
|
||||
/// @param appId 抖音小程序 AppId(即开放平台 clientKey)
|
||||
/// @param appSecret 抖音小程序 AppSecret(即开放平台 clientSecret)
|
||||
/// @param code tt.login 返回的登录凭证(可空)
|
||||
/// @param anonymousCode tt.login 返回的匿名登录凭证(可空)
|
||||
/// @return 授权结果(openId)
|
||||
public DouyinAuthResult getOpenId(String appId, String appSecret, String code) {
|
||||
public DouyinAuthResult getOpenId(String appId, String appSecret, String code, String anonymousCode) {
|
||||
if (StrUtil.isAllBlank(code, anonymousCode)) {
|
||||
// 抖音: 换取用户标识失败: {0}
|
||||
throw new OperationFailException("error.douyin.authFailed", "code and anonymous_code are blank");
|
||||
}
|
||||
JSONObject requestBody = new JSONObject();
|
||||
requestBody.set("appid", appId);
|
||||
requestBody.set("secret", appSecret);
|
||||
requestBody.set("code", code);
|
||||
// 官方分字段: 有值才写入, 禁止把 anonymous_code 塞进 code
|
||||
if (StrUtil.isNotBlank(code)) {
|
||||
requestBody.set("code", code);
|
||||
}
|
||||
if (StrUtil.isNotBlank(anonymousCode)) {
|
||||
requestBody.set("anonymous_code", anonymousCode);
|
||||
}
|
||||
|
||||
String body;
|
||||
try (HttpResponse response = HttpUtil.createPost(JSCODE2SESSION_URL)
|
||||
|
||||
@@ -3,9 +3,11 @@ package cn.daxpay.open.platform.system.mobile.config;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/// # 抖音小程序应用配置(落库 JSON 形状, 预留)
|
||||
/// # 抖音小程序应用配置(落库 JSON 形状)
|
||||
///
|
||||
/// 序列化后写入 [cn.daxpay.open.platform.system.entity.mobile.MobileApp#appConfig] 并加密存储。
|
||||
/// 供收银台小程序认证([cn.daxpay.open.payment.trade.runtime.service.pay.gateway.CashierAuthService])
|
||||
/// 调用 jscode2session 使用。
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class DyMiniAppConfig {
|
||||
|
||||
Reference in New Issue
Block a user