返回 AiToEarn
tiktok.service.ts
根目录 / project / aitoearn-electron / server / src / modules / plat / tiktok / tiktok.service.ts
1 import { Injectable, BadRequestException, Logger, Inject, forwardRef } from '@nestjs/common';
2 import { HttpService } from '@nestjs/axios';
3 import { ConfigService } from '@nestjs/config';
4 import { Model } from 'mongoose';
5 import { InjectModel } from '@nestjs/mongoose';
6 import { firstValueFrom } from 'rxjs';
7 import * as FormData from 'form-data';
8
9 import { PubRecord, PubStatus, PubType } from 'src/db/schema/pubRecord.schema';
10 import { TikTokAuthService } from './tiktok.auth.service';
11 import { CreateVideoDto, TikTokCommentDto, GetVideosQueryDto, TikTokVideoFilterDto } from './dto/tiktok.dto';
12
13 @Injectable()
14 export class TikTokService {
15 private readonly logger = new Logger(TikTokService.name);
16 private readonly apiBaseUrl: string;
17 private readonly uploadApiBaseUrl: string;
18
19 constructor(
20 private readonly httpService: HttpService,
21 private readonly configService: ConfigService,
22 private readonly tikTokAuthService: TikTokAuthService,
23 @InjectModel(PubRecord.name) private readonly pubRecordModel: Model<PubRecord>,
24 ) {
25 const tiktokConfig = this.configService.get('tiktok');
26 if (!tiktokConfig) {
27 throw new Error('TikTok配置未找到,请检查环境变量和配置文件');
28 }
29 this.apiBaseUrl = tiktokConfig.apiBaseUrl;
30 this.uploadApiBaseUrl = tiktokConfig.uploadApiBaseUrl;
31 }
32
33 /**
34 * 获取用户视频列表
35 * @param accessToken 访问令牌
36 * @param userId 用户ID
37 * @param accountId TikTok账号ID
38 * @param limit 每页结果数
39 * @param cursor 分页游标
40 * @returns 用户视频列表
41 */
42 async getUserVideos(
43 accessToken: string,
44 userId: string,
45 accountId: string,
46 limit = 10,
47 cursor?: string
48 ): Promise<any> {
49 try {
50 // 确保limit是数字且在有效范围内
51 limit = isNaN(Number(limit)) ? 10 : Math.min(Math.max(Number(limit), 1), 50);
52
53 const params: any = {
54 fields: 'id,create_time,video_description,duration,height,width,share_count,comment_count,like_count,view_count,title,embed_link,embed_html,thumbnail_url',
55 max_count: limit
56 };
57
58 if (cursor) {
59 params.cursor = cursor;
60 }
61
62 const { data } = await firstValueFrom(
63 this.httpService.get(`${this.apiBaseUrl}/v2/video/list`, {
64 params,
65 headers: {
66 'Authorization': `Bearer ${accessToken}`
67 }
68 })
69 );
70
71 return data.data;
72 } catch (error) {
73 this.logger.error('获取TikTok视频列表失败:', error.response?.data || error.message);
74 throw new BadRequestException(`获取视频列表失败: ${error.response?.data?.error?.message || error.message}`);
75 }
76 }
77
78 /**
79 * 获取视频详情
80 * @param accessToken 访问令牌
81 * @param videoId 视频ID
82 * @returns 视频详情
83 */
84 async getVideoDetail(
85 accessToken: string,
86 videoId: string
87 ): Promise<any> {
88 try {
89 const { data } = await firstValueFrom(
90 this.httpService.get(`${this.apiBaseUrl}/v2/video/info/`, {
91 params: {
92 fields: 'id,create_time,video_description,duration,height,width,share_count,comment_count,like_count,view_count,title,embed_link,embed_html,thumbnail_url',
93 video_id: videoId
94 },
95 headers: {
96 'Authorization': `Bearer ${accessToken}`
97 }
98 })
99 );
100
101 return data.data;
102 } catch (error) {
103 this.logger.error('获取TikTok视频详情失败:', error.response?.data || error.message);
104 throw new BadRequestException(`获取视频详情失败: ${error.response?.data?.error?.message || error.message}`);
105 }
106 }
107
108 /**
109 * 方式1:初始化视频上传(旧版)
110 * @param accessToken 访问令牌
111 * @param videoSize 视频文件总大小(字节)
112 * @param chunkSize 分片大小(字节),默认为5MB
113 * @returns 初始化结果,包含上传所需的参数
114 */
115 async initVideoUpload(
116 accessToken: string,
117 videoSize?: number,
118 chunkSize: number = 5 * 1024 * 1024 // 默认5MB
119 ): Promise<any> {
120 try {
121 const requestBody: any = {};
122
123 // 如果提供了视频大小,添加分片上传的相关信息
124 if (videoSize) {
125 const totalChunkCount = Math.ceil(videoSize / chunkSize);
126 requestBody.source_info = {
127 source: "FILE_UPLOAD",
128 video_size: videoSize,
129 chunk_size: chunkSize,
130 total_chunk_count: totalChunkCount
131 };
132 }
133
134 const { data } = await firstValueFrom(
135 this.httpService.post(`${this.apiBaseUrl}/v2/post/publish/inbox/video/init/`, requestBody, {
136 headers: {
137 'Content-Type': 'application/json',
138 'Authorization': `Bearer ${accessToken}`
139 }
140 })
141 );
142
143 return data.data;
144 } catch (error) {
145 this.logger.error('初始化TikTok视频上传失败:', error.response?.data || error.message);
146 throw new BadRequestException(`初始化视频上传失败: ${error.response?.data?.error?.message || error.message}`);
147 }
148 }
149
150 /**
151 * 方式2:初始化视频发布(直接发布,新版)
152 * @param accessToken 访问令牌
153 * @param videoSize 视频文件总大小(字节)
154 * @param videoInfo 视频相关信息,包含标题、隐私级别等
155 * @param chunkSize 分片大小(字节),默认为10MB
156 * @returns 初始化结果,包含上传所需的参数
157 */
158 async initVideoPublish(
159 accessToken: string,
160 videoSize: number,
161 videoInfo: {
162 title?: string;
163 description?: string;
164 privacyStatus?: string;
165 disableComment?: boolean;
166 disableDuet?: boolean;
167 disableStitch?: boolean;
168 videoCoverTimestampMs?: number;
169 tags?: string[];
170 },
171 chunkSize: number = 10 * 1024 * 1024 // 默认10MB
172 ): Promise<any> {
173 try {
174 // 计算总分片数
175 const totalChunkCount = Math.ceil(videoSize / chunkSize);
176
177 // 处理hashtags,如果提供了
178 let title = videoInfo.title || videoInfo.description || '';
179 if (videoInfo.tags && videoInfo.tags.length > 0) {
180 const hashtagText = videoInfo.tags
181 .map(tag => `#${tag.replace(/^#/, '')}`)
182 .join(' ');
183 if (title) {
184 title = `${title} ${hashtagText}`;
185 } else {
186 title = hashtagText;
187 }
188 }
189
190 // 构建请求体
191 const requestBody: any = {
192 post_info: {
193 title: title,
194 privacy_level: videoInfo.privacyStatus || 'PUBLIC',
195 disable_duet: videoInfo.disableDuet || false,
196 disable_comment: videoInfo.disableComment || false,
197 disable_stitch: videoInfo.disableStitch || false
198 },
199 source_info: {
200 source: "FILE_UPLOAD",
201 video_size: videoSize,
202 chunk_size: chunkSize,
203 total_chunk_count: totalChunkCount
204 }
205 };
206
207 // 添加视频封面时间戳,如果提供了
208 if (videoInfo.videoCoverTimestampMs) {
209 requestBody.post_info.video_cover_timestamp_ms = videoInfo.videoCoverTimestampMs;
210 }
211
212 this.logger.debug('初始化视频发布请求:', JSON.stringify(requestBody));
213
214 const { data } = await firstValueFrom(
215 this.httpService.post(`${this.apiBaseUrl}/v2/post/publish/video/init/`, requestBody, {
216 headers: {
217 'Content-Type': 'application/json',
218 'Authorization': `Bearer ${accessToken}`
219 }
220 })
221 );
222
223 if (!data.data.publish_id || !data.data.upload_url) {
224 throw new BadRequestException('初始化视频发布失败,缺少publish_id或upload_url');
225 }
226
227 return data.data;
228 } catch (error) {
229 this.logger.error('初始化TikTok视频发布失败:', error.response?.data || error.message);
230 throw new BadRequestException(`初始化视频发布失败: ${error.response?.data?.error?.message || error.message}`);
231 }
232 }
233
234 /**
235 * 上传视频文件
236 * @param accessToken 访问令牌
237 * @param videoBuffer 视频文件缓冲区
238 * @param initData 初始化返回的数据
239 * @returns 上传结果
240 */
241 async uploadVideo(
242 accessToken: string,
243 videoBuffer: Buffer,
244 initData?: any
245 ): Promise<any> {
246 try {
247 // 如果没有提供初始化数据,先进行初始化
248 if (!initData) {
249 // 计算视频大小并进行初始化
250 const videoSize = videoBuffer.length;
251 initData = await this.initVideoUpload(accessToken, videoSize);
252 }
253
254 // 检查是否需要分片上传
255 if (initData.publish_id && initData.upload_url && videoBuffer.length > 10 * 1024 * 1024) {
256 // 如果有publish_id和upload_url,并且视频超过10MB,则使用分片上传
257 return await this.uploadVideoChunked(accessToken, videoBuffer, initData);
258 }
259
260 // 如果不需要分片,使用单次上传
261 // 创建Node.js版的FormData
262 const formData = new FormData();
263
264 // 直接将Buffer添加到FormData中
265 const filename = `video_${Date.now()}.mp4`;
266 formData.append('video', videoBuffer, {
267 filename,
268 contentType: 'video/mp4'
269 });
270
271 // 添加初始化返回的必要参数
272 if (initData.upload_params) {
273 Object.entries(initData.upload_params).forEach(([key, value]) => {
274 formData.append(key, value);
275 });
276 }
277
278 // 使用初始化返回的上传URL,如果没有则使用默认URL
279 const uploadUrl = initData.upload_url || `${this.apiBaseUrl}/v2/video/upload/`;
280
281 const { data } = await firstValueFrom(
282 this.httpService.post(uploadUrl, formData, {
283 headers: {
284 ...formData.getHeaders(),
285 'Authorization': `Bearer ${accessToken}`
286 }
287 })
288 );
289
290 return {
291 ...data.data,
292 init_data: initData, // 返回初始化数据,可能在发布时需要
293 };
294 } catch (error) {
295 this.logger.error('上传TikTok视频失败:', error.response?.data || error.message);
296 throw new BadRequestException(`上传视频失败: ${error.response?.data?.error?.message || error.message}`);
297 }
298 }
299
300 /**
301 * 方式1:分片上传视频(POST方式)
302 * @param accessToken 访问令牌
303 * @param videoBuffer 视频文件缓冲区
304 * @param initData 初始化返回的数据,包含 publish_id 和 upload_url
305 * @returns 上传结果
306 */
307 private async uploadVideoChunked(
308 accessToken: string,
309 videoBuffer: Buffer,
310 initData: any
311 ): Promise<any> {
312 try {
313 const { publish_id, upload_url } = initData;
314
315 if (!publish_id || !upload_url) {
316 throw new BadRequestException('初始化响应缺少 publish_id 或 upload_url');
317 }
318
319 // 设置分片大小(每个分片5MB)
320 const chunkSize = 5 * 1024 * 1024;
321 const totalSize = videoBuffer.length;
322 const totalChunkCount = Math.ceil(totalSize / chunkSize);
323
324 this.logger.debug(`视频总大小: ${totalSize} 字节, 分片数: ${totalChunkCount}`);
325
326 // 选择视频内容类型
327 const contentType = 'video/mp4';
328
329 // 存储每个分片的上传响应
330 const uploadResponses = [];
331
332 // 上传每个分片
333 for (let i = 0; i < totalChunkCount; i++) {
334 const start = i * chunkSize;
335 const end = Math.min(start + chunkSize, totalSize);
336 const chunkBuffer = videoBuffer.subarray(start, end);
337 const chunkLength = chunkBuffer.length;
338
339 this.logger.debug(`正在上传第${i + 1}/${totalChunkCount}个分片,范围: ${start}-${end-1}/${totalSize}, 大小: ${chunkLength} 字节`);
340
341 // 使用原生的请求头而不是FormData
342 const { data } = await firstValueFrom(
343 this.httpService.post(upload_url, chunkBuffer, {
344 headers: {
345 'Content-Type': contentType,
346 'Content-Length': chunkLength.toString(),
347 'Content-Range': `bytes ${start}-${end-1}/${totalSize}`,
348 'Authorization': `Bearer ${accessToken}`
349 }
350 })
351 );
352
353 uploadResponses.push(data);
354 }
355
356 // 使用最后一个分片的响应作为最终响应
357 const finalResponse = uploadResponses[totalChunkCount - 1];
358
359 return {
360 ...finalResponse.data,
361 publish_id,
362 video_id: finalResponse.data?.video_id || finalResponse.data?.id,
363 init_data: initData, // 返回初始化数据,可能在发布时需要
364 };
365 } catch (error) {
366 this.logger.error('分片上传TikTok视频失败:', error.response?.data || error.message);
367 throw new BadRequestException(`分片上传视频失败: ${error.response?.data?.error?.message || error.message}`);
368 }
369 }
370
371 /**
372 * 方式2:直接上传视频(PUT方式)
373 * @param accessToken 访问令牌
374 * @param videoBuffer 视频文件缓冲区
375 * @param initData 初始化返回的数据,包含 publish_id 和 upload_url
376 * @returns 上传结果
377 */
378 async directUploadVideo(
379 accessToken: string,
380 videoBuffer: Buffer,
381 initData: any
382 ): Promise<any> {
383 try {
384 const { publish_id, upload_url } = initData;
385
386 if (!publish_id || !upload_url) {
387 throw new BadRequestException('初始化响应缺少 publish_id 或 upload_url');
388 }
389
390 const totalSize = videoBuffer.length;
391 // 建议的分片大小,如果文件大于10MB则分片上传
392 const chunkSize = 10 * 1024 * 1024;
393 const needChunking = totalSize > chunkSize;
394
395 // 选择视频内容类型
396 const contentType = 'video/mp4';
397
398 this.logger.debug(`直接上传视频,总大小: ${totalSize} 字节, 是否需要分片: ${needChunking}`);
399
400 if (!needChunking) {
401 // 单次上传整个文件
402 const { data } = await firstValueFrom(
403 this.httpService.put(upload_url, videoBuffer, {
404 headers: {
405 'Content-Type': contentType,
406 'Content-Length': totalSize.toString(),
407 'Authorization': `Bearer ${accessToken}`
408 }
409 })
410 );
411
412 return {
413 publish_id,
414 ...data?.data,
415 };
416 } else {
417 // 分片上传
418 const totalChunkCount = Math.ceil(totalSize / chunkSize);
419 let lastResponse = null;
420
421 // 上传每个分片
422 for (let i = 0; i < totalChunkCount; i++) {
423 const start = i * chunkSize;
424 const end = Math.min(start + chunkSize, totalSize);
425 const chunkBuffer = videoBuffer.subarray(start, end);
426 const chunkLength = chunkBuffer.length;
427
428 this.logger.debug(`正在上传第${i + 1}/${totalChunkCount}个分片,范围: ${start}-${end-1}/${totalSize}, 大小: ${chunkLength} 字节`);
429
430 const { data } = await firstValueFrom(
431 this.httpService.put(upload_url, chunkBuffer, {
432 headers: {
433 'Content-Type': contentType,
434 'Content-Length': chunkLength.toString(),
435 'Content-Range': `bytes ${start}-${end-1}/${totalSize}`,
436 'Authorization': `Bearer ${accessToken}`
437 }
438 })
439 );
440
441 lastResponse = data;
442 }
443
444 return {
445 publish_id,
446 ...lastResponse?.data,
447 };
448 }
449 } catch (error) {
450 this.logger.error('直接上传TikTok视频失败:', error.response?.data || error.message);
451 throw new BadRequestException(`直接上传视频失败: ${error.response?.data?.error?.message || error.message}`);
452 }
453 }
454
455 /**
456 * 检查视频发布状态
457 * @param accessToken 访问令牌
458 * @param publishId 发布ID
459 * @returns 发布状态信息
460 */
461 async checkPublishStatus(
462 accessToken: string,
463 publishId: string
464 ): Promise<any> {
465 try {
466 const { data } = await firstValueFrom(
467 this.httpService.post(
468 `${this.apiBaseUrl}/v2/post/publish/status/fetch/`,
469 { publish_id: publishId },
470 {
471 headers: {
472 'Content-Type': 'application/json',
473 'Authorization': `Bearer ${accessToken}`
474 }
475 }
476 )
477 );
478
479 return data.data;
480 } catch (error) {
481 this.logger.error('检查TikTok视频发布状态失败:', error.response?.data || error.message);
482 throw new BadRequestException(`检查视频发布状态失败: ${error.response?.data?.error?.message || error.message}`);
483 }
484 }
485
486 /**
487 * 三步式完整上传并发布视频(新版API)
488 * @param accessToken 访问令牌
489 * @param userId 用户ID
490 * @param accountId TikTok账号ID
491 * @param videoBuffer 视频数据
492 * @param videoInfo 视频信息
493 * @param pollInterval 轮询状态的时间间隔(毫秒)。默认2秒。
494 * @param maxRetries 最大重试次数。默认30次,大约60秒。
495 * @returns 视频发布结果
496 */
497 async uploadAndPublishVideo(
498 accessToken: string,
499 userId: string,
500 accountId: string,
501 videoBuffer: Buffer,
502 videoInfo: {
503 title?: string;
504 description?: string;
505 privacyStatus?: string;
506 disableComment?: boolean;
507 disableDuet?: boolean;
508 disableStitch?: boolean;
509 videoCoverTimestampMs?: number;
510 tags?: string[];
511 },
512 pollInterval: number = 2000,
513 maxRetries: number = 30
514 ): Promise<any> {
515 try {
516 // 1. 第一步:初始化视频发布
517 this.logger.debug('第一步:初始化视频发布...');
518 const videoSize = videoBuffer.length;
519 const initResult = await this.initVideoPublish(accessToken, videoSize, videoInfo);
520
521 if (!initResult.publish_id || !initResult.upload_url) {
522 throw new BadRequestException('初始化失败,缺少必要的上传参数');
523 }
524
525 const { publish_id } = initResult;
526
527 // 2. 第二步:上传视频文件
528 this.logger.debug(`第二步:上传视频文件,publish_id: ${publish_id}...`);
529 await this.directUploadVideo(accessToken, videoBuffer, initResult);
530
531 // 3. 第三步:轮询视频发布状态 // 每分钟不超过30次
532 this.logger.debug(`第三步:轮询视频发布状态,publish_id: ${publish_id}...`);
533
534 // 存储发布记录
535 const maxRecord = await this.pubRecordModel.findOne().sort({ id: -1 });
536 const newId = maxRecord ? maxRecord.id + 1 : 1;
537
538 const pubRecord = await this.pubRecordModel.create({
539 id: newId,
540 userId,
541 accountId,
542 type: PubType.VIDEO,
543 status: PubStatus.UNPUBLISH,
544 title: videoInfo.title,
545 desc: videoInfo.description,
546 attachments: [publish_id],
547 publishTime: new Date(),
548 createTime: new Date(),
549 updateTime: new Date(),
550 });
551
552 let finalStatus = null;
553 let tries = 0;
554
555 // 轮询检查发布状态
556 while (tries < maxRetries) {
557 tries++;
558
559 const statusResult = await this.checkPublishStatus(accessToken, publish_id);
560 this.logger.debug(`状态检查 ${tries}/${maxRetries}: ${JSON.stringify(statusResult)}`);
561
562 // 判断视频是否发布成功
563 // 根据实际API返回的状态字段来判断(这里的status字段可能需要根据实际情况调整)
564 if (statusResult.status === 'SUCCESS' || statusResult.status === 'PUBLISHED') {
565 finalStatus = statusResult;
566 break;
567 } else if (statusResult.status === 'FAILED' || statusResult.status === 'ERROR') {
568 // 更新发布记录为失败状态
569 await this.pubRecordModel.findByIdAndUpdate(pubRecord._id, {
570 status: PubStatus.FAIL,
571 failReason: statusResult.error_message || '发布失败',
572 updateTime: new Date()
573 });
574
575 throw new BadRequestException(`视频发布失败: ${statusResult.error_message || '未知错误'}`);
576 }
577
578 // 等待指定时间后再次查询
579 await new Promise(resolve => setTimeout(resolve, pollInterval));
580 }
581
582 if (!finalStatus) {
583 // 超时仍未完成
584 await this.pubRecordModel.findByIdAndUpdate(pubRecord._id, {
585 status: PubStatus.FAIL,
586 failReason: '检查视频发布状态超时',
587 updateTime: new Date()
588 });
589
590 throw new BadRequestException('视频发布状态检查超时,请稍后在TikTok应用中查看发布状态');
591 }
592
593 // 更新发布记录为成功状态
594 const videoId = finalStatus.video_id || finalStatus.id || publish_id;
595 await this.pubRecordModel.findByIdAndUpdate(pubRecord._id, {
596 status: PubStatus.RELEASED,
597 resourceId: videoId,
598 updateTime: new Date()
599 });
600
601 return {
602 ...finalStatus,
603 resourceId: videoId,
604 publish_id,
605 };
606 } catch (error) {
607 this.logger.error('三步式视频上传发布失败:', error.response?.data || error.message);
608 throw new BadRequestException(`视频上传发布失败: ${error.response?.data?.error?.message || error.message}`);
609 }
610 }
611
612 /**
613 * 发布视频(原始方式)
614 * @param accessToken 访问令牌
615 * @param userId 用户ID
616 * @param accountId TikTok账号ID
617 * @param videoDto 视频发布参数
618 * @param uploadResult 上传结果,包含视频ID和初始化数据
619 * @returns 发布结果
620 */
621 async publishVideo(
622 accessToken: string,
623 userId: string,
624 accountId: string,
625 videoDto: CreateVideoDto,
626 uploadResult: any
627 ): Promise<any> {
628 try {
629 const videoId = uploadResult.video_id || (uploadResult.init_data?.video_id);
630 if (!videoId) {
631 throw new BadRequestException('无效的视频上传结果,缺少视频ID');
632 }
633
634 const params: any = {
635 video_id: videoId,
636 text: videoDto.description,
637 disable_comment: false,
638 disable_duet: false,
639 privacy_level: videoDto.private ? 'private' : 'public'
640 };
641
642 if (videoDto.hashtags && videoDto.hashtags.length > 0) {
643 // 添加话题标签
644 const hashtags = videoDto.hashtags.map(tag => `#${tag.replace(/^#/, '')}`).join(' ');
645 params.text = `${params.text} ${hashtags}`;
646 }
647
648 // 如果有初始化数据,添加必要的发布参数
649 if (uploadResult.init_data && uploadResult.init_data.publish_params) {
650 Object.assign(params, uploadResult.init_data.publish_params);
651 }
652
653 // 获取当前最大的 id
654 const maxRecord = await this.pubRecordModel.findOne().sort({ id: -1 });
655 const newId = maxRecord ? maxRecord.id + 1 : 1;
656
657 // 创建发布记录
658 const pubRecord = await this.pubRecordModel.create({
659 id: newId,
660 userId,
661 accountId,
662 type: PubType.VIDEO,
663 status: PubStatus.UNPUBLISH,
664 content: videoDto.description,
665 attachments: [videoId],
666 publishTime: new Date(),
667 createTime: new Date(),
668 updateTime: new Date(),
669 });
670
671 try {
672 // 获取发布URL,可能在初始化时已提供
673 const publishUrl = (uploadResult.init_data && uploadResult.init_data.publish_url) ||
674 `${this.apiBaseUrl}/v2/video/publish/`;
675
676 const { data } = await firstValueFrom(
677 this.httpService.post(publishUrl, params, {
678 headers: {
679 'Content-Type': 'application/json',
680 'Authorization': `Bearer ${accessToken}`
681 }
682 })
683 );
684
685 // 更新发布记录状态
686 await this.pubRecordModel.findByIdAndUpdate(pubRecord._id, {
687 status: PubStatus.RELEASED,
688 remoteId: data.data.share_id || videoId,
689 updateTime: new Date()
690 });
691
692 return data.data;
693 } catch (error) {
694 // 更新发布记录为失败状态
695 await this.pubRecordModel.findByIdAndUpdate(pubRecord._id, {
696 status: PubStatus.FAIL,
697 failReason: error.response?.data?.error?.message || error.message,
698 updateTime: new Date()
699 });
700
701 throw error;
702 }
703 } catch (error) {
704 this.logger.error('发布TikTok视频失败:', error.response?.data || error.message);
705 throw new BadRequestException(`发布视频失败: ${error.response?.data?.error?.message || error.message}`);
706 }
707 }
708
709 /**
710 * 删除视频
711 * @param accessToken 访问令牌
712 * @param videoId 视频ID
713 * @returns 删除结果
714 */
715 async deleteVideo(
716 accessToken: string,
717 videoId: string
718 ): Promise<any> {
719 try {
720 const { data } = await firstValueFrom(
721 this.httpService.post(`${this.apiBaseUrl}/v2/video/delete/`, {
722 video_id: videoId
723 }, {
724 headers: {
725 'Content-Type': 'application/json',
726 'Authorization': `Bearer ${accessToken}`
727 }
728 })
729 );
730
731 return { success: true };
732 } catch (error) {
733 this.logger.error('删除TikTok视频失败:', error.response?.data || error.message);
734 throw new BadRequestException(`删除视频失败: ${error.response?.data?.error?.message || error.message}`);
735 }
736 }
737
738 /**
739 * 获取视频评论列表
740 * @param accessToken 访问令牌
741 * @param videoId 视频ID
742 * @param limit 每页结果数
743 * @param cursor 分页游标
744 * @returns 评论列表
745 */
746 async getVideoComments(
747 accessToken: string,
748 videoId: string,
749 limit = 20,
750 cursor?: string
751 ): Promise<any> {
752 try {
753 // 确保limit是数字且在有效范围内
754 limit = isNaN(Number(limit)) ? 20 : Math.min(Math.max(Number(limit), 1), 50);
755
756 const params: any = {
757 fields: 'id,text,create_time,like_count,reply_comment_total,user',
758 video_id: videoId,
759 max_count: limit
760 };
761
762 if (cursor) {
763 params.cursor = cursor;
764 }
765
766 const { data } = await firstValueFrom(
767 this.httpService.get(`${this.apiBaseUrl}/v2/comment/list/`, {
768 params,
769 headers: {
770 'Authorization': `Bearer ${accessToken}`
771 }
772 })
773 );
774
775 return data.data;
776 } catch (error) {
777 this.logger.error('获取TikTok视频评论失败:', error.response?.data || error.message);
778 throw new BadRequestException(`获取评论失败: ${error.response?.data?.error?.message || error.message}`);
779 }
780 }
781
782 /**
783 * 发表评论
784 * @param accessToken 访问令牌
785 * @param commentDto 评论参数
786 * @returns 评论结果
787 */
788 async postComment(
789 accessToken: string,
790 commentDto: TikTokCommentDto
791 ): Promise<any> {
792 try {
793 const { data } = await firstValueFrom(
794 this.httpService.post(`${this.apiBaseUrl}/v2/comment/post/`, {
795 video_id: commentDto.videoId,
796 text: commentDto.text
797 }, {
798 headers: {
799 'Content-Type': 'application/json',
800 'Authorization': `Bearer ${accessToken}`
801 }
802 })
803 );
804
805 return data.data;
806 } catch (error) {
807 this.logger.error('发表TikTok评论失败:', error.response?.data || error.message);
808 throw new BadRequestException(`发表评论失败: ${error.response?.data?.error?.message || error.message}`);
809 }
810 }
811
812 /**
813 * 删除评论
814 * @param accessToken 访问令牌
815 * @param videoId 视频ID
816 * @param commentId 评论ID
817 * @returns 删除结果
818 */
819 async deleteComment(
820 accessToken: string,
821 videoId: string,
822 commentId: string
823 ): Promise<any> {
824 try {
825 const { data } = await firstValueFrom(
826 this.httpService.post(`${this.apiBaseUrl}/v2/comment/delete/`, {
827 video_id: videoId,
828 comment_id: commentId
829 }, {
830 headers: {
831 'Content-Type': 'application/json',
832 'Authorization': `Bearer ${accessToken}`
833 }
834 })
835 );
836
837 return { success: true };
838 } catch (error) {
839 this.logger.error('删除TikTok评论失败:', error.response?.data || error.message);
840 throw new BadRequestException(`删除评论失败: ${error.response?.data?.error?.message || error.message}`);
841 }
842 }
843
844 /**
845 * 点赞视频
846 * @param accessToken 访问令牌
847 * @param userId 用户ID
848 * @param accountId TikTok账号ID
849 * @param videoId 视频ID
850 * @returns 点赞结果
851 */
852 async likeVideo(
853 accessToken: string,
854 userId: string,
855 accountId: string,
856 videoId: string
857 ): Promise<any> {
858 try {
859 const { data } = await firstValueFrom(
860 this.httpService.post(`${this.apiBaseUrl}/v2/video/like/`, {
861 video_id: videoId
862 }, {
863 headers: {
864 'Content-Type': 'application/json',
865 'Authorization': `Bearer ${accessToken}`
866 }
867 })
868 );
869
870 return { success: true };
871 } catch (error) {
872 this.logger.error('TikTok视频点赞失败:', error.response?.data || error.message);
873 throw new BadRequestException(`视频点赞失败: ${error.response?.data?.error?.message || error.message}`);
874 }
875 }
876
877 /**
878 * 取消点赞视频
879 * @param accessToken 访问令牌
880 * @param userId 用户ID
881 * @param accountId TikTok账号ID
882 * @param videoId 视频ID
883 * @returns 取消点赞结果
884 */
885 async unlikeVideo(
886 accessToken: string,
887 userId: string,
888 accountId: string,
889 videoId: string
890 ): Promise<any> {
891 try {
892 const { data } = await firstValueFrom(
893 this.httpService.post(`${this.apiBaseUrl}/v2/video/unlike/`, {
894 video_id: videoId
895 }, {
896 headers: {
897 'Content-Type': 'application/json',
898 'Authorization': `Bearer ${accessToken}`
899 }
900 })
901 );
902
903 return { success: true };
904 } catch (error) {
905 this.logger.error('取消TikTok视频点赞失败:', error.response?.data || error.message);
906 throw new BadRequestException(`取消视频点赞失败: ${error.response?.data?.error?.message || error.message}`);
907 }
908 }
909
910 /**
911 * 搜索视频
912 * @param accessToken 访问令牌
913 * @param filterDto 搜索过滤参数
914 * @returns 搜索结果
915 */
916 async searchVideos(
917 accessToken: string,
918 filterDto: TikTokVideoFilterDto
919 ): Promise<any> {
920 try {
921 // 确保limit是数字且在有效范围内
922 const limit = isNaN(Number(filterDto.limit)) ? 10 : Math.min(Math.max(Number(filterDto.limit), 1), 50);
923
924 const params: any = {
925 fields: 'id,create_time,video_description,duration,height,width,share_count,comment_count,like_count,view_count,title,embed_link,thumbnail_url',
926 max_count: limit,
927 search_key: filterDto.keyword || ''
928 };
929
930 if (filterDto.cursor) {
931 params.cursor = filterDto.cursor;
932 }
933
934 const { data } = await firstValueFrom(
935 this.httpService.get(`${this.apiBaseUrl}/v2/video/search/`, {
936 params,
937 headers: {
938 'Authorization': `Bearer ${accessToken}`
939 }
940 })
941 );
942
943 // 如果设置了最低播放次数过滤
944 let videos = data.data.videos || [];
945 if (filterDto.minPlayCount && !isNaN(Number(filterDto.minPlayCount)) && Number(filterDto.minPlayCount) > 0) {
946 videos = videos.filter(video => {
947 return video.view_count >= Number(filterDto.minPlayCount);
948 });
949 }
950
951 return {
952 videos: videos,
953 cursor: data.data.cursor,
954 has_more: data.data.has_more
955 };
956 } catch (error) {
957 this.logger.error('搜索TikTok视频失败:', error.response?.data || error.message);
958 throw new BadRequestException(`搜索视频失败: ${error.response?.data?.error?.message || error.message}`);
959 }
960 }
961
962 /**
963 * 获取用户TikTok账号信息
964 * @param accessToken 访问令牌
965 * @param accountId TikTok账号ID
966 * @returns 账号信息
967 */
968 async getUserProfile(
969 accessToken: string,
970 accountId: string
971 ): Promise<any> {
972 try {
973 const { data } = await firstValueFrom(
974 this.httpService.get(`${this.apiBaseUrl}/v2/user/info/`, {
975 params: {
976 fields: 'open_id,union_id,avatar_url,bio_description,profile_deep_link,is_verified,follower_count,following_count,likes_count,video_count,nickname',
977 open_id: accountId
978 },
979 headers: {
980 'Authorization': `Bearer ${accessToken}`
981 }
982 })
983 );
984
985 return data.data.user;
986 } catch (error) {
987 this.logger.error('获取TikTok用户信息失败:', error.response?.data || error.message);
988 throw new BadRequestException(`获取用户信息失败: ${error.response?.data?.error?.message || error.message}`);
989 }
990 }
991 }
992
992 lines TYPESCRIPT