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:
daxpay
2026-07-29 17:01:55 +08:00
parent 1b2a7c1064
commit eca7cd3bef
7 changed files with 69 additions and 33 deletions

View File

@@ -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 = "认证调试服务")

View File

@@ -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

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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 = "认证调试服务(商户端)")

View File

@@ -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)

View File

@@ -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 {