diff --git a/im-admin/ruoyi-im/src/main/java/org/dromara/im/config/TikTokConfig.java b/im-admin/ruoyi-im/src/main/java/org/dromara/im/config/TikTokConfig.java new file mode 100644 index 0000000..ac4280d --- /dev/null +++ b/im-admin/ruoyi-im/src/main/java/org/dromara/im/config/TikTokConfig.java @@ -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; } +} diff --git a/im-admin/ruoyi-im/src/main/java/org/dromara/im/constant/TikTokConstant.java b/im-admin/ruoyi-im/src/main/java/org/dromara/im/constant/TikTokConstant.java new file mode 100644 index 0000000..36e1edd --- /dev/null +++ b/im-admin/ruoyi-im/src/main/java/org/dromara/im/constant/TikTokConstant.java @@ -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"; +} diff --git a/im-admin/ruoyi-im/src/main/java/org/dromara/im/controller/TikTokAuthController.java b/im-admin/ruoyi-im/src/main/java/org/dromara/im/controller/TikTokAuthController.java new file mode 100644 index 0000000..a9c9a97 --- /dev/null +++ b/im-admin/ruoyi-im/src/main/java/org/dromara/im/controller/TikTokAuthController.java @@ -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 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); + } +} diff --git a/im-admin/ruoyi-im/src/main/java/org/dromara/im/controller/TikTokOauth.java b/im-admin/ruoyi-im/src/main/java/org/dromara/im/controller/TikTokOauth.java deleted file mode 100644 index c4df85b..0000000 --- a/im-admin/ruoyi-im/src/main/java/org/dromara/im/controller/TikTokOauth.java +++ /dev/null @@ -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() { - - } -} diff --git a/im-admin/ruoyi-im/src/main/java/org/dromara/im/domain/TikTokTokenResponse.java b/im-admin/ruoyi-im/src/main/java/org/dromara/im/domain/TikTokTokenResponse.java new file mode 100644 index 0000000..0d08d3f --- /dev/null +++ b/im-admin/ruoyi-im/src/main/java/org/dromara/im/domain/TikTokTokenResponse.java @@ -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; } + } +} diff --git a/im-admin/ruoyi-im/src/main/java/org/dromara/im/service/ITikTokAuthService.java b/im-admin/ruoyi-im/src/main/java/org/dromara/im/service/ITikTokAuthService.java new file mode 100644 index 0000000..e6b3c3c --- /dev/null +++ b/im-admin/ruoyi-im/src/main/java/org/dromara/im/service/ITikTokAuthService.java @@ -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); +} diff --git a/im-admin/ruoyi-im/src/main/java/org/dromara/im/service/impl/TikTokAuthServiceImpl.java b/im-admin/ruoyi-im/src/main/java/org/dromara/im/service/impl/TikTokAuthServiceImpl.java new file mode 100644 index 0000000..4a574e6 --- /dev/null +++ b/im-admin/ruoyi-im/src/main/java/org/dromara/im/service/impl/TikTokAuthServiceImpl.java @@ -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 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; + } + } +}