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