7 changed files with 350 additions and 30 deletions
@ -0,0 +1,38 @@ |
|||||
|
package org.dromara.im.config; |
||||
|
|
||||
|
import org.springframework.boot.context.properties.ConfigurationProperties; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
@Component |
||||
|
@ConfigurationProperties(prefix = "tiktok") |
||||
|
public class TikTokConfig { |
||||
|
|
||||
|
// ========== 授权页面地址 ==========
|
||||
|
/** ROW(非美国地区) */ |
||||
|
public static final String AUTH_URL_ROW = |
||||
|
"https://seller.tiktokglobalshop.com/account/authorize"; |
||||
|
/** US(美国地区) */ |
||||
|
public static final String AUTH_URL_US = |
||||
|
"https://seller-us.tiktok.com/account/authorize"; |
||||
|
|
||||
|
// ========== API 地址 ==========
|
||||
|
public static final String TOKEN_URL = |
||||
|
"https://open-api.tiktokglobalshop.com/token/create"; |
||||
|
public static final String REFRESH_TOKEN_URL = |
||||
|
"https://open-api.tiktokglobalshop.com/token/refresh"; |
||||
|
|
||||
|
private String appKey; |
||||
|
private String appSecret; |
||||
|
private String redirectUri; |
||||
|
private String region; |
||||
|
|
||||
|
// getter / setter
|
||||
|
public String getAppKey() { return appKey; } |
||||
|
public void setAppKey(String v) { this.appKey = v; } |
||||
|
public String getAppSecret() { return appSecret; } |
||||
|
public void setAppSecret(String v) { this.appSecret = v; } |
||||
|
public String getRedirectUri(){ return redirectUri; } |
||||
|
public void setRedirectUri(String v){ this.redirectUri = v; } |
||||
|
public String getRegion() { return region; } |
||||
|
public void setRegion(String v) { this.region = v; } |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
package org.dromara.im.constant; |
||||
|
|
||||
|
public class TikTokConstant { |
||||
|
|
||||
|
/** 授权码有效期:30分钟(毫秒) */ |
||||
|
public static final long AUTH_CODE_EXPIRE_MS = 30 * 60 * 1000L; |
||||
|
|
||||
|
/** access_token 默认有效期:7天(秒) */ |
||||
|
public static final long ACCESS_TOKEN_EXPIRE_SEC = 7 * 24 * 60 * 60L; |
||||
|
|
||||
|
/** grant_type: 获取 token */ |
||||
|
public static final String GRANT_TYPE_AUTHORIZED = "authorized_code"; |
||||
|
|
||||
|
/** grant_type: 刷新 token */ |
||||
|
public static final String GRANT_TYPE_REFRESH = "refresh_token"; |
||||
|
} |
||||
@ -0,0 +1,95 @@ |
|||||
|
package org.dromara.im.controller; |
||||
|
|
||||
|
import cn.dev33.satoken.annotation.SaIgnore; |
||||
|
import org.dromara.im.domain.TikTokTokenResponse; |
||||
|
import org.dromara.im.service.ITikTokAuthService; |
||||
|
import org.slf4j.Logger; |
||||
|
import org.slf4j.LoggerFactory; |
||||
|
import org.springframework.web.bind.annotation.*; |
||||
|
|
||||
|
import java.security.SecureRandom; |
||||
|
import java.util.HexFormat; |
||||
|
import java.util.Map; |
||||
|
import java.util.concurrent.ConcurrentHashMap; |
||||
|
|
||||
|
@RestController |
||||
|
@RequestMapping("/tiktok") |
||||
|
public class TikTokAuthController { |
||||
|
|
||||
|
private static final Logger log = LoggerFactory.getLogger(TikTokAuthController.class); |
||||
|
|
||||
|
private final ITikTokAuthService authService; |
||||
|
private final ConcurrentHashMap<String, Long> stateStore = new ConcurrentHashMap<>(); |
||||
|
|
||||
|
public TikTokAuthController(ITikTokAuthService authService) { |
||||
|
this.authService = authService; |
||||
|
} |
||||
|
|
||||
|
@SaIgnore |
||||
|
@GetMapping("/authorize") |
||||
|
public String authorize() { |
||||
|
String state = generateState(); |
||||
|
stateStore.put(state, System.currentTimeMillis()); |
||||
|
String authUrl = authService.buildAuthUrl(state); |
||||
|
// 生产环境建议 302 跳转:
|
||||
|
// return "redirect:" + authUrl;
|
||||
|
return "请访问以下链接完成授权:\n" + authUrl; |
||||
|
} |
||||
|
|
||||
|
@SaIgnore |
||||
|
@GetMapping("/callback") |
||||
|
public String callback( |
||||
|
@RequestParam(value = "code", required = false) String code, |
||||
|
@RequestParam(value = "state", required = false) String state, |
||||
|
@RequestParam(value = "error", required = false) String error) { |
||||
|
|
||||
|
// 1. 用户拒绝
|
||||
|
if ("auth_denied".equals(error) || code == null || "null".equals(code)) { |
||||
|
return "用户拒绝了授权"; |
||||
|
} |
||||
|
|
||||
|
// 2. state 校验
|
||||
|
if (state == null || !stateStore.containsKey(state)) { |
||||
|
return "state 校验失败,可能存在 CSRF 攻击"; |
||||
|
} |
||||
|
stateStore.remove(state); |
||||
|
|
||||
|
// 3. 用 code 换 token
|
||||
|
log.info("收到授权回调, code={}, state={}", code, state); |
||||
|
TikTokTokenResponse tokenResp = authService.getAccessToken(code); |
||||
|
|
||||
|
if (tokenResp.getCode() != 0) { |
||||
|
return "获取 Token 失败:" + tokenResp.getMessage(); |
||||
|
} |
||||
|
|
||||
|
TikTokTokenResponse.TokenData data = tokenResp.getData(); |
||||
|
|
||||
|
// 4. ⚠️ 生产环境应将 data 存入数据库/Redis,而非直接返回
|
||||
|
return "授权成功!\n" |
||||
|
+ "卖家名称: " + data.getSellerName() + "\n" |
||||
|
+ "地区: " + data.getSellerBaseRegion() + "\n" |
||||
|
+ "open_id: " + data.getOpenId() + "\n" |
||||
|
+ "user_type: " + data.getUserType() + "\n" |
||||
|
+ "access_token: " + data.getAccessToken() + "\n" |
||||
|
+ "refresh_token: " + data.getRefreshToken() + "\n" |
||||
|
+ "access_token 过期(秒): " + data.getAccessTokenExpireIn() + "\n" |
||||
|
+ "refresh_token 过期(秒): " + data.getRefreshTokenExpireIn() + "\n" |
||||
|
+ "授权范围: " + String.join(", ", data.getGrantedScopes()); |
||||
|
} |
||||
|
|
||||
|
@SaIgnore |
||||
|
@GetMapping("/refresh") |
||||
|
public String refresh(@RequestParam String refreshToken) { |
||||
|
TikTokTokenResponse resp = authService.refreshToken(refreshToken); |
||||
|
if (resp.getCode() != 0) { |
||||
|
return "刷新失败:" + resp.getMessage(); |
||||
|
} |
||||
|
return "刷新成功!新 access_token:" + resp.getData().getAccessToken(); |
||||
|
} |
||||
|
|
||||
|
private String generateState() { |
||||
|
byte[] bytes = new byte[16]; |
||||
|
new SecureRandom().nextBytes(bytes); |
||||
|
return HexFormat.of().formatHex(bytes); |
||||
|
} |
||||
|
} |
||||
@ -1,30 +0,0 @@ |
|||||
package org.dromara.im.controller; |
|
||||
|
|
||||
import cn.dev33.satoken.annotation.SaIgnore; |
|
||||
import lombok.RequiredArgsConstructor; |
|
||||
import lombok.extern.slf4j.Slf4j; |
|
||||
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.Map; |
|
||||
|
|
||||
/** |
|
||||
* tiktok接口 |
|
||||
* |
|
||||
* @author Blue |
|
||||
* @date 2026-04-07 |
|
||||
*/ |
|
||||
@Slf4j |
|
||||
@Validated |
|
||||
@RequiredArgsConstructor |
|
||||
@RestController |
|
||||
@RequestMapping("/tiktok") |
|
||||
public class TikTokOauth { |
|
||||
@SaIgnore |
|
||||
@GetMapping("/callback") |
|
||||
public void TikTokCallbackVO() { |
|
||||
|
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,74 @@ |
|||||
|
package org.dromara.im.domain; |
||||
|
|
||||
|
import com.fasterxml.jackson.annotation.JsonProperty; |
||||
|
|
||||
|
public class TikTokTokenResponse { |
||||
|
|
||||
|
private int code; |
||||
|
private String message; |
||||
|
|
||||
|
@JsonProperty("request_id") |
||||
|
private String requestId; |
||||
|
|
||||
|
private TokenData data; |
||||
|
|
||||
|
// getter / setter(同你原来的,省略空间)
|
||||
|
|
||||
|
public int getCode() { return code; } |
||||
|
public void setCode(int code) { this.code = code; } |
||||
|
public String getMessage() { return message; } |
||||
|
public void setMessage(String message) { this.message = message; } |
||||
|
public String getRequestId() { return requestId; } |
||||
|
public void setRequestId(String requestId) { this.requestId = requestId; } |
||||
|
public TokenData getData() { return data; } |
||||
|
public void setData(TokenData data) { this.data = data; } |
||||
|
|
||||
|
public static class TokenData { |
||||
|
|
||||
|
@JsonProperty("access_token") |
||||
|
private String accessToken; |
||||
|
|
||||
|
@JsonProperty("access_token_expire_in") |
||||
|
private long accessTokenExpireIn; |
||||
|
|
||||
|
@JsonProperty("refresh_token") |
||||
|
private String refreshToken; |
||||
|
|
||||
|
@JsonProperty("refresh_token_expire_in") |
||||
|
private long refreshTokenExpireIn; |
||||
|
|
||||
|
@JsonProperty("open_id") |
||||
|
private String openId; |
||||
|
|
||||
|
@JsonProperty("seller_name") |
||||
|
private String sellerName; |
||||
|
|
||||
|
@JsonProperty("seller_base_region") |
||||
|
private String sellerBaseRegion; |
||||
|
|
||||
|
@JsonProperty("user_type") |
||||
|
private int userType; |
||||
|
|
||||
|
@JsonProperty("granted_scopes") |
||||
|
private String[] grantedScopes; |
||||
|
|
||||
|
public String getAccessToken() { return accessToken; } |
||||
|
public void setAccessToken(String v) { this.accessToken = v; } |
||||
|
public long getAccessTokenExpireIn() { return accessTokenExpireIn; } |
||||
|
public void setAccessTokenExpireIn(long v) { this.accessTokenExpireIn = v; } |
||||
|
public String getRefreshToken() { return refreshToken; } |
||||
|
public void setRefreshToken(String v) { this.refreshToken = v; } |
||||
|
public long getRefreshTokenExpireIn() { return refreshTokenExpireIn; } |
||||
|
public void setRefreshTokenExpireIn(long v) { this.refreshTokenExpireIn = v; } |
||||
|
public String getOpenId() { return openId; } |
||||
|
public void setOpenId(String v) { this.openId = v; } |
||||
|
public String getSellerName() { return sellerName; } |
||||
|
public void setSellerName(String v) { this.sellerName = v; } |
||||
|
public String getSellerBaseRegion() { return sellerBaseRegion; } |
||||
|
public void setSellerBaseRegion(String v) { this.sellerBaseRegion = v; } |
||||
|
public int getUserType() { return userType; } |
||||
|
public void setUserType(int v) { this.userType = v; } |
||||
|
public String[] getGrantedScopes() { return grantedScopes; } |
||||
|
public void setGrantedScopes(String[] v) { this.grantedScopes = v; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
package org.dromara.im.service; |
||||
|
|
||||
|
import org.dromara.im.domain.TikTokTokenResponse; |
||||
|
|
||||
|
public interface ITikTokAuthService { |
||||
|
|
||||
|
/** |
||||
|
* 生成授权链接 |
||||
|
*/ |
||||
|
String buildAuthUrl(String state); |
||||
|
|
||||
|
/** |
||||
|
* 用 auth_code 换取 access_token |
||||
|
*/ |
||||
|
TikTokTokenResponse getAccessToken(String authCode); |
||||
|
|
||||
|
/** |
||||
|
* 刷新 access_token |
||||
|
*/ |
||||
|
TikTokTokenResponse refreshToken(String refreshToken); |
||||
|
} |
||||
@ -0,0 +1,106 @@ |
|||||
|
package org.dromara.im.service.impl; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import org.dromara.im.config.TikTokConfig; |
||||
|
import org.dromara.im.constant.TikTokConstant; |
||||
|
import org.dromara.im.domain.TikTokTokenResponse; |
||||
|
import org.dromara.im.service.ITikTokAuthService; |
||||
|
import org.slf4j.Logger; |
||||
|
import org.slf4j.LoggerFactory; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
|
||||
|
import java.net.URI; |
||||
|
import java.net.http.HttpClient; |
||||
|
import java.net.http.HttpRequest; |
||||
|
import java.net.http.HttpResponse; |
||||
|
|
||||
|
@Service |
||||
|
public class TikTokAuthServiceImpl implements ITikTokAuthService { |
||||
|
|
||||
|
private static final Logger log = LoggerFactory.getLogger(TikTokAuthServiceImpl.class); |
||||
|
|
||||
|
private final TikTokConfig config; |
||||
|
private final ObjectMapper objectMapper = new ObjectMapper(); |
||||
|
private final HttpClient httpClient = HttpClient.newHttpClient(); |
||||
|
|
||||
|
public TikTokAuthServiceImpl(TikTokConfig config) { |
||||
|
this.config = config; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public String buildAuthUrl(String state) { |
||||
|
// ── 修复1: 使用正确的授权页面地址 + 补齐 redirect_uri ──
|
||||
|
String baseUrl = "US".equalsIgnoreCase(config.getRegion()) |
||||
|
? TikTokConfig.AUTH_URL_US |
||||
|
: TikTokConfig.AUTH_URL_ROW; |
||||
|
|
||||
|
return baseUrl |
||||
|
+ "?app_key=" + config.getAppKey() |
||||
|
+ "&redirect_uri=" + config.getRedirectUri() |
||||
|
+ "&state=" + state; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public TikTokTokenResponse getAccessToken(String authCode) { |
||||
|
// ── 修复2: POST + JSON body ──
|
||||
|
String jsonBody = """ |
||||
|
{ |
||||
|
"app_key": "%s", |
||||
|
"app_secret": "%s", |
||||
|
"auth_code": "%s", |
||||
|
"grant_type": "%s" |
||||
|
} |
||||
|
""".formatted( |
||||
|
config.getAppKey(), |
||||
|
config.getAppSecret(), |
||||
|
authCode, |
||||
|
TikTokConstant.GRANT_TYPE_AUTHORIZED); |
||||
|
|
||||
|
return doPostJson(TikTokConfig.TOKEN_URL, jsonBody); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public TikTokTokenResponse refreshToken(String refreshToken) { |
||||
|
String jsonBody = """ |
||||
|
{ |
||||
|
"app_key": "%s", |
||||
|
"app_secret": "%s", |
||||
|
"refresh_token": "%s", |
||||
|
"grant_type": "%s" |
||||
|
} |
||||
|
""".formatted( |
||||
|
config.getAppKey(), |
||||
|
config.getAppSecret(), |
||||
|
refreshToken, |
||||
|
TikTokConstant.GRANT_TYPE_REFRESH); |
||||
|
|
||||
|
return doPostJson(TikTokConfig.REFRESH_TOKEN_URL, jsonBody); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 统一 POST JSON 请求 |
||||
|
*/ |
||||
|
private TikTokTokenResponse doPostJson(String url, String jsonBody) { |
||||
|
try { |
||||
|
HttpRequest request = HttpRequest.newBuilder() |
||||
|
.uri(URI.create(url)) |
||||
|
.header("Content-Type", "application/json") |
||||
|
.POST(HttpRequest.BodyPublishers.ofString(jsonBody)) |
||||
|
.build(); |
||||
|
|
||||
|
HttpResponse<String> response = httpClient.send( |
||||
|
request, HttpResponse.BodyHandlers.ofString()); |
||||
|
|
||||
|
log.info("TikTok API 响应: {}", response.body()); |
||||
|
|
||||
|
return objectMapper.readValue(response.body(), TikTokTokenResponse.class); |
||||
|
|
||||
|
} catch (Exception e) { |
||||
|
log.error("TikTok API 请求失败", e); |
||||
|
TikTokTokenResponse errorResp = new TikTokTokenResponse(); |
||||
|
errorResp.setCode(-1); |
||||
|
errorResp.setMessage("请求失败: " + e.getMessage()); |
||||
|
return errorResp; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue