返回 AiToEarn
tiktok.auth.service.ts
根目录 / project / aitoearn-electron / server / src / modules / plat / tiktok / tiktok.auth.service.ts
1 import { Injectable, Inject, forwardRef, BadRequestException, HttpException, HttpStatus, Logger } from '@nestjs/common';
2 import { ConfigService } from '@nestjs/config';
3 import { HttpService } from '@nestjs/axios';
4 import { Model } from 'mongoose';
5 import { InjectModel } from '@nestjs/mongoose';
6 import { firstValueFrom } from 'rxjs';
7 import * as crypto from 'crypto';
8
9 import { RedisService } from 'src/lib/redis/redis.service';
10 import { IdService } from 'src/db/id.service';
11
12 import { AuthService } from 'src/auth/auth.service';
13 import { AccountService } from 'src/modules/account/account.service';
14 import { User } from 'src/db/schema/user.schema';
15 import { Account, AccountType, AccountStatus } from 'src/db/schema/account.schema';
16 import { AccountToken, TokenPlatform, TokenStatus } from 'src/db/schema/accountToken.schema';
17 import { TikTokOAuthTokenResponse, TikTokUser } from './dto/tiktok.dto';
18
19 // 工具函数
20 function getCurrentTimestamp(): number {
21 return Math.floor(Date.now() / 1000);
22 }
23
24 @Injectable()
25 export class TikTokAuthService {
26 private readonly logger = new Logger(TikTokAuthService.name);
27 private clientId: string;
28 private clientSecret: string;
29 private redirectUri: string;
30 private authUrl: string;
31 private tokenUrl: string;
32 private revokeUrl: string;
33 private refreshTokenUrl: string;
34 private apiBaseUrl: string;
35 private scopes: string;
36
37 private state: string;
38
39 constructor(
40 private readonly configService: ConfigService,
41 private readonly httpService: HttpService,
42 private readonly redisService: RedisService,
43
44 @Inject(forwardRef(() => AuthService))
45 private readonly authService: AuthService,
46
47 @Inject(forwardRef(() => AccountService))
48 private readonly accountService: AccountService,
49
50 @InjectModel(User.name) private readonly userModel: Model<User>,
51 @InjectModel(Account.name) private readonly accountModel: Model<Account>,
52 @InjectModel(AccountToken.name) private readonly accountTokenModel: Model<AccountToken>,
53 ) {
54 this.initTikTokSecrets();
55 }
56
57 /**
58 * 初始化TikTok API密钥和配置
59 */
60 private initTikTokSecrets() {
61 const tikTokConfig = this.configService.get('tiktok');
62 if (!tikTokConfig) {
63 throw new Error('TikTok配置未找到,请检查环境变量和配置文件');
64 }
65
66 this.clientId = tikTokConfig.clientId;
67 this.clientSecret = tikTokConfig.clientSecret;
68 this.redirectUri = tikTokConfig.redirectUri;
69 this.authUrl = tikTokConfig.authUrl;
70 this.tokenUrl = tikTokConfig.tokenUrl;
71 this.revokeUrl = tikTokConfig.revokeUrl;
72 this.refreshTokenUrl = tikTokConfig.refreshTokenUrl;
73 this.apiBaseUrl = tikTokConfig.apiBaseUrl;
74 this.scopes = tikTokConfig.scopes;
75
76 if (!this.clientId || !this.clientSecret) {
77 this.logger.error('TikTok客户端ID或密钥未设置');
78 }
79 }
80
81 /**
82 * 生成随机状态码用于OAuth流程
83 */
84 private generateState(): string {
85 return crypto.randomBytes(20).toString('hex');
86 }
87
88 /**
89 * 生成PKCE的code_verifier和code_challenge
90 * @returns 包含code_verifier和code_challenge的对象
91 */
92 private generatePKCE(): { codeVerifier: string; codeChallenge: string } {
93 // 生成随机码验证器
94 const codeVerifier = crypto.randomBytes(32).toString('base64url');
95
96 // 生成码挑战
97 const codeChallenge = crypto
98 .createHash('sha256')
99 .update(codeVerifier)
100 .digest('base64url');
101
102 return { codeVerifier, codeChallenge };
103 }
104
105 /**
106 * 获取TikTok授权URL
107 * @param mail 用户邮箱
108 * @returns 包含授权URL的对象
109 */
110 async getAuthorizationUrl(userId: string, mail: string): Promise<object> {
111 if (!userId) {
112 throw new BadRequestException('userId是必需的');
113 }
114
115 // 生成状态参数以防止CSRF攻击
116 const state = this.generateState();
117
118 // 生成PKCE的code_verifier和code_challenge
119 const { codeVerifier, codeChallenge } = this.generatePKCE();
120
121 const stateData = {
122 originalState: state, // 保留原始state值
123 userId: userId, // 用户ID
124 email: mail, // 邮箱
125 codeVerifier: codeVerifier // 保存code_verifier用于后续交换token
126 };
127
128 // 将状态与用户数据关联并存储在Redis中 (10分钟有效期)
129 await this.redisService.setKey(`tiktok:state:${state}`, JSON.stringify(stateData), 600);
130
131 // 构建TikTok授权URL
132 // const authUrl = new URL(this.authUrl);
133 // authUrl.searchParams.append('client_key', this.clientId);
134 // authUrl.searchParams.append('response_type', 'code');
135 // authUrl.searchParams.append('redirect_uri', `${this.redirectUri}/api/plat/tiktok/auth/callback`);
136 // authUrl.searchParams.append('scope', this.scopes);
137 // authUrl.searchParams.append('state', state);
138 // // 添加PKCE参数
139 // authUrl.searchParams.append('code_challenge', codeChallenge);
140 // authUrl.searchParams.append('code_challenge_method', 'S256');
141
142 // return {
143 // url: authUrl.toString()
144 // };
145
146 // 构建授权URL参数
147 const params = new URLSearchParams({
148 response_type: 'code',
149 client_key: this.clientId,
150 redirect_uri: `${this.redirectUri}/api/plat/tiktok/auth/callback`,
151 scope: this.scopes,
152 state: state,
153 code_challenge: codeChallenge,
154 // disable_auto_auth: '0',
155 code_challenge_method: 'S256', // 使用SHA-256算法
156 });
157
158 // 构建完整的授权URL
159 const authUrl = `${this.authUrl}?${params.toString()}`;
160 console.log('TikTok授权URL:', authUrl);
161
162 return { url: authUrl };
163
164 }
165
166 /**
167 * 处理TikTok授权回调
168 * @param code 授权码
169 * @param state 状态码
170 * @returns 处理结果
171 */
172 async handleAuthorizationCallback(code: string, state: string): Promise<object> {
173 // // 解析状态参数
174 // let parsedState;
175 // try {
176 // parsedState = JSON.parse(decodeURIComponent(state));
177 // } catch (error) {
178 // this.logger.error('无法解析状态参数:', error);
179 // throw new BadRequestException('无效的状态参数格式');
180 // }
181
182 // 从Redis获取保存的状态信息
183 // const originalState = parsedState.state;
184
185 const stateDataJson = await this.redisService.get(`tiktok:state:${state}`);
186 if (!stateDataJson) {
187 throw new BadRequestException('无效的状态参数或状态已过期');
188 }
189 // 解析状态数据
190 const stateData = JSON.parse(stateDataJson);
191 console.log('stateData:------', stateData);
192 const { userId, codeVerifier } = stateData;
193 if (!userId || !codeVerifier) {
194 throw new BadRequestException('状态数据不完整');
195 }
196
197 // 删除Redis中的状态信息
198 await this.redisService.del(`tiktok:state:${state}`);
199
200 try {
201 // 使用授权码交换令牌,并传入codeVerifier
202 const tokenResponse = await this.exchangeCodeForTokens(code, codeVerifier);
203 console.log("获取授权码成功!", tokenResponse);
204 // 获取用户信息
205 const userProfile = await this.getTikTokUserProfile(tokenResponse.access_token, tokenResponse.open_id);
206
207 // 更新或创建TikTok账户信息
208 await this.updateTikTokAccountInfo(
209 userId,
210 userProfile.open_id,
211 tokenResponse.access_token,
212 tokenResponse.refresh_token,
213 tokenResponse.expires_in
214 );
215
216 // 保存访问令牌到Redis以便后续使用
217 await this.redisService.setKey(
218 `tiktok:accessToken:${userProfile.open_id}`,
219 {
220 access_token: tokenResponse.access_token,
221 refresh_token: tokenResponse.refresh_token,
222 expires_in: tokenResponse.expires_in,
223 expiry_time: getCurrentTimestamp() + tokenResponse.expires_in
224 },
225 tokenResponse.expires_in - 300 // 令牌过期前5分钟
226 );
227
228 // 生成系统令牌
229 const userInfo = await this.userModel.findOne({ _id: userId });
230 const systemTokenInfo = {
231 phone: userInfo?.phone ?? '',
232 id: userId,
233 name: userInfo.name,
234 isManager: false,
235 googleId: userInfo?.googleAccount?.googleId ?? ''
236 };
237
238 const systemToken = await this.authService.generateToken(systemTokenInfo);
239
240 return { data: systemTokenInfo };
241 } catch (error) {
242 this.logger.error('处理TikTok授权回调失败:', error);
243 throw new HttpException(
244 '授权TikTok账户失败: ' + (error.response?.data?.error_description || error.message),
245 error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR
246 );
247 }
248 }
249
250 /**
251 * 交换授权码获取令牌
252 * @param code 授权码
253 * @returns TikTok OAuth令牌响应
254 */
255 private async exchangeCodeForTokens(code: string, codeVerifier: string): Promise<TikTokOAuthTokenResponse> {
256 try {
257 // 构建请求体
258 const params = new URLSearchParams({
259 client_key: this.clientId,
260 client_secret: this.clientSecret,
261 code: code,
262 grant_type: 'authorization_code',
263 redirect_uri: `${this.redirectUri}/api/plat/tiktok/auth/callback`,
264 // 添加PKCE code_verifier
265 // code_verifier: codeVerifier // Required for mobile and desktop app only.
266 });
267
268 // const base64Credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
269
270 const { data } = await firstValueFrom(
271 this.httpService.post(this.tokenUrl, params.toString(), {
272 headers: {
273 'Content-Type': 'application/x-www-form-urlencoded',
274 // 'Authorization': `Basic ${base64Credentials}`,
275 }
276 })
277 );
278
279 if (data.error) {
280 throw new BadRequestException(`交换令牌失败: ${data}`);
281 }
282
283 // return {
284 // access_token: data.access_token,
285 // refresh_token: data.refresh_token,
286 // expires_in: data.expires_in,
287 // token_type: data.token_type,
288 // scope: data.scope,
289 // open_id: data.open_id
290 // };
291 return data;
292 } catch (error) {
293 this.logger.error('交换TikTok授权码失败:', error);
294 throw new BadRequestException(`交换授权码失败: ${error.response?.data?.error_description || error.message}`);
295 }
296 }
297
298 /**
299 * 获取TikTok用户资料
300 * @param accessToken 访问令牌
301 * @param openId 用户开放ID
302 * @returns TikTok用户资料
303 */
304 async getTikTokUserProfile(accessToken: string, openId: string): Promise<TikTokUser> {
305 try {
306 const { data } = await firstValueFrom(
307 this.httpService.get(`${this.apiBaseUrl}/v2/user/info/`, {
308 params: {
309 fields: 'open_id,union_id,avatar_url,bio_description,profile_deep_link,is_verified,follower_count,following_count,likes_count,video_count,username, display_name',
310 // open_id: openId
311 },
312 headers: {
313 'Authorization': `Bearer ${accessToken}`
314 }
315 })
316 );
317
318 console.log(data)
319 // if (data.error) {
320 // throw new BadRequestException(`获取用户信息失败: ${data.error.message}`);
321 // }
322
323 return data.data.user;
324 } catch (error) {
325 this.logger.error('获取TikTok用户信息失败:', error);
326 throw new BadRequestException(`获取用户信息失败: ${error.response?.data?.error?.message || error.message || error.code}`);
327 }
328 }
329
330 /**
331 * 更新TikTok账户信息
332 * @param userId 用户ID
333 * @param tikTokId TikTok用户ID
334 * @param accessToken 访问令牌
335 * @param refreshToken 刷新令牌
336 * @param expires_in 令牌有效期(秒)
337 */
338 async updateTikTokAccountInfo(
339 userId: string,
340 tikTokId: string,
341 accessToken: string,
342 refreshToken: string,
343 expires_in: number
344 ): Promise<void> {
345 try {
346 // 获取TikTok用户信息
347 const tikTokUser = await this.getTikTokUserProfile(accessToken, tikTokId);
348
349 // 准备账号信息
350 const channelInfo = {
351 userId: userId,
352 type: AccountType.TIKTOK, // 需要在AccountType中添加TIKTOK类型
353 uid: tikTokId,
354 account: tikTokUser.username,
355 nickname: tikTokUser.display_name,
356 avatar: tikTokUser.avatar_url,
357 homePage: tikTokUser.profile_deep_link,
358 fansCount: tikTokUser.follower_count,
359 followCount: tikTokUser.following_count,
360 workCount: tikTokUser.video_count,
361 likeCount: tikTokUser.likes_count,
362 readCount: 0,
363 collectCount: 0,
364 forwardCount: 0,
365 commentCount: 0,
366 updateTime: new Date(),
367 status: AccountStatus.USABLE,
368 loginCookie: "1111", // TikTok不使用cookie认证
369 token: "111", // 存储访问令牌
370 };
371
372 // 使用AccountService创建或更新账户
373 const account = await this.accountService.addOrUpdateAccount(channelInfo);
374 this.logger.log("成功创建或更新TikTok账号:", account);
375
376 // 检查是否存在账号Token
377 let accountToken = await this.accountTokenModel.findOne({
378 accountId: tikTokId,
379 platform: TokenPlatform.TIKTOK // 需要在TokenPlatform中添加TIKTOK类型
380 });
381
382 if (accountToken) {
383 if (refreshToken && refreshToken.trim() !== '') {
384 accountToken.refreshToken = refreshToken;
385 }
386 accountToken.expiresAt = new Date((getCurrentTimestamp() + expires_in) * 1000);
387 accountToken.updateTime = new Date();
388 await accountToken.save();
389 this.logger.log("成功更新TikTok账号Token");
390 } else {
391 // 创建新Token
392 await this.accountTokenModel.create({
393 userId,
394 accountId: tikTokId,
395 platform: TokenPlatform.TIKTOK, // 需要在TokenPlatform中添加TIKTOK类型
396 refreshToken: refreshToken,
397 expiresAt: new Date((getCurrentTimestamp() + expires_in) * 1000),
398 status: TokenStatus.USABLE,
399 createTime: new Date(),
400 updateTime: new Date(),
401 });
402 this.logger.log("成功创建TikTok账号Token");
403 }
404 } catch (error) {
405 this.logger.error('更新TikTok账号信息失败:', error);
406 // 不抛出异常,避免影响授权流程
407 }
408 }
409
410 /**
411 * 刷新用户的TikTok访问令牌
412 * @param userId 用户ID
413 * @param accountId 账号ID
414 * @param refreshToken 刷新令牌
415 * @returns 新的访问令牌信息
416 */
417 async refreshAccessToken(userId: string, accountId: string, refreshToken: string): Promise<object> {
418 this.logger.log(`尝试刷新TikTok令牌: userId=${userId}, accountId=${accountId}`);
419 try {
420 const params = new URLSearchParams({
421 client_key: this.clientId,
422 client_secret: this.clientSecret,
423 grant_type: 'refresh_token',
424 refresh_token: refreshToken
425 });
426
427 const { data } = await firstValueFrom(
428 this.httpService.post(this.refreshTokenUrl, params.toString(), {
429 headers: {
430 'Content-Type': 'application/x-www-form-urlencoded'
431 }
432 })
433 );
434
435 if (data.error) {
436 throw new BadRequestException(`刷新令牌失败: ${data.error_description}`);
437 }
438
439 // 保存新令牌到Redis
440 await this.redisService.setKey(
441 `tiktok:accessToken:${accountId}`,
442 {
443 access_token: data.access_token,
444 refresh_token: data.refresh_token || refreshToken, // 有些OAuth提供商在刷新时不返回新的刷新令牌
445 expires_in: data.expires_in,
446 expiry_time: getCurrentTimestamp() + data.expires_in
447 },
448 data.expires_in - 300 // 令牌过期前5分钟
449 );
450
451 // 更新数据库中的刷新令牌
452 if (data.refresh_token) {
453 await this.accountTokenModel.updateOne(
454 { accountId: accountId, platform: TokenPlatform.TIKTOK },
455 {
456 refreshToken: data.refresh_token,
457 expiresAt: new Date((getCurrentTimestamp() + data.expires_in) * 1000),
458 updateTime: new Date()
459 }
460 );
461 }
462
463 this.logger.log('刷新TikTok访问令牌成功');
464 // 返回系统令牌用于前端重定向
465 const userInfo = await this.userModel.findOne({_id: userId});
466 const systemTokenInfo = {
467 phone: userInfo?.phone ?? '',
468 id: userId,
469 name: userInfo.name,
470 isManager: false,
471 googleId: userInfo?.googleAccount?.googleId ?? ''
472 };
473
474 const systemToken = await this.authService.generateToken(systemTokenInfo);
475 return { url: systemToken };
476 } catch (err) {
477 this.logger.error('刷新TikTok访问令牌失败:', err.response?.data || err.message);
478 throw new HttpException(
479 err.response?.data?.error_description || '刷新令牌失败',
480 err.response?.status || HttpStatus.BAD_REQUEST
481 );
482 }
483 }
484
485 /**
486 * 获取用户的TikTok访问令牌
487 * @param accountId 账号ID
488 * @returns 访问令牌
489 */
490 async getUserAccessToken(accountId: string): Promise<string> {
491 this.logger.log(`获取TikTok访问令牌: accountId=${accountId}`);
492
493 // 先检查Redis缓存
494 const cachedToken = await this.redisService.get(`tiktok:accessToken:${accountId}`);
495 if (cachedToken && cachedToken.access_token) {
496 this.logger.log("从Redis获取到有效令牌");
497 return cachedToken.access_token;
498 }
499
500 // 如果缓存中没有,尝试刷新
501 const accountTokenInfo = await this.accountTokenModel.findOne({
502 accountId: accountId,
503 platform: TokenPlatform.TIKTOK
504 });
505
506 if (!accountTokenInfo || !accountTokenInfo.refreshToken) {
507 throw new BadRequestException('无效的账号或刷新令牌丢失');
508 }
509
510 // 刷新并获取新令牌
511 const refreshResult = await this.refreshAccessToken(
512 accountTokenInfo.userId,
513 accountTokenInfo.accountId,
514 accountTokenInfo.refreshToken
515 );
516
517 // 刷新后再次从Redis获取
518 const newToken = await this.redisService.get(`tiktok:accessToken:${accountId}`);
519 if (!newToken || !newToken.access_token) {
520 throw new BadRequestException('刷新令牌后未能获取访问令牌');
521 }
522
523 return newToken.access_token;
524 }
525
526 /**
527 * 检查用户是否已授权TikTok
528 * @param accountId 账号ID
529 * @returns 是否已授权
530 */
531 async isAuthorized(accountId: string): Promise<boolean> {
532 try {
533 const accessToken = await this.getUserAccessToken(accountId);
534 return !!accessToken;
535 } catch (error) {
536 return false;
537 }
538 }
539
540 /**
541 * 撤销TikTok授权
542 * @param accountId 账号ID
543 * @returns 撤销结果
544 */
545 async revokeAuthorization(accountId: string): Promise<boolean> {
546 try {
547 const accessToken = await this.getUserAccessToken(accountId);
548 if (!accessToken) {
549 return true; // 已经没有授权了
550 }
551
552 // 撤销TikTok令牌
553 const params = new URLSearchParams({
554 access_token: accessToken,
555 client_key: this.clientId,
556 client_secret: this.clientSecret
557 });
558
559 await firstValueFrom(
560 this.httpService.post(`${this.revokeUrl}`, params.toString(), {
561 headers: {
562 'Content-Type': 'application/x-www-form-urlencoded'
563 }
564 })
565 );
566
567 // 删除Redis中的令牌
568 await this.redisService.del(`tiktok:accessToken:${accountId}`);
569
570 // 更新数据库记录状态
571 await this.accountTokenModel.updateOne(
572 { accountId: accountId, platform: TokenPlatform.TIKTOK },
573 { status: TokenStatus.DISABLE, updateTime: new Date() }
574 );
575
576 await this.accountModel.updateOne(
577 { uid: accountId, type: AccountType.TIKTOK },
578 { status: AccountStatus.DISABLE, updateTime: new Date() }
579 );
580
581 return true;
582 } catch (error) {
583 this.logger.error('撤销TikTok授权失败:', error);
584 return false;
585 }
586 }
587 }
588
588 lines TYPESCRIPT