返回 AiToEarn
douyin.service.ts
1 import type { AxiosError, AxiosInstance } from 'axios'
2 import type { DouyinPlatformResponseBody } from './douyin.exception'
3 import type {
4 DouyinApiRequestBody,
5 DouyinApiResponse,
6 DouyinClientTokenResponse,
7 DouyinOAuthEnvelope,
8 DouyinOAuthResponse,
9 DouyinOpenTicketResponse,
10 DouyinShareIdEnvelope,
11 DouyinSharePublishResult,
12 DouyinSharePublishResultEnvelope,
13 DouyinShareSchemaOptions,
14 DouyinUserInfo,
15 DouyinVideoCreateRequestBody,
16 DouyinVideoCreateResponse,
17 DouyinVideoUploadResponse,
18 } from './douyin.interface'
19 import { createHash, randomBytes } from 'node:crypto'
20 import { Injectable } from '@nestjs/common'
21 import { AccountType, AppException, ResponseCode } from '@yikart/common'
22 import axios from 'axios'
23 import { ServerRedisService } from '../../../../common/redis'
24 import { DouyinConfig } from './douyin.config'
25 import { DouyinPlatformException } from './douyin.exception'
26 import { DouyinOAuthGrantType } from './douyin.interface'
27
28 const TOKEN_EXPIRE_BUFFER_MS = 60 * 1000
29
30 interface DouyinClientTokenCache {
31 access_token: string
32 expires_in?: number
33 expiresAt?: number
34 }
35
36 interface DouyinOpenTicketCache {
37 ticket: string
38 clientToken: string
39 expires_in?: number
40 expiresAt?: number
41 }
42
43 @Injectable()
44 export class DouyinService {
45 private readonly apiBaseUrl = 'https://open.douyin.com'
46 private readonly authUrl = 'https://open.douyin.com/platform/oauth/connect/'
47 private readonly http: AxiosInstance
48 private clientTokenCache?: { accessToken: string, expiresAt: number }
49 private openTicketCache?: { clientToken: string, ticket: string, expiresAt: number }
50
51 constructor(
52 private readonly cfg: DouyinConfig,
53 private readonly redis: ServerRedisService,
54 ) {
55 this.http = this.createHttpClient()
56 }
57
58 private createHttpClient(): AxiosInstance {
59 const http = axios.create({ baseURL: this.apiBaseUrl })
60 http.interceptors.response.use(
61 (response) => {
62 if (DouyinPlatformException.hasPlatformError(response)) {
63 throw DouyinPlatformException.fromPlatformResponse(response)
64 }
65 return response
66 },
67 (error: AxiosError<DouyinPlatformResponseBody>) => {
68 throw DouyinPlatformException.fromAxiosError(error)
69 },
70 )
71 return http
72 }
73
74 private async apiRequest<T>(
75 method: 'GET' | 'POST',
76 path: string,
77 params: Record<string, string> = {},
78 body?: DouyinApiRequestBody,
79 accessToken?: string,
80 ): Promise<T> {
81 const headers = {
82 ...(accessToken ? { 'access-token': accessToken } : {}),
83 ...(body ? { 'Content-Type': 'application/json' } : {}),
84 }
85
86 const response = await this.http.request<DouyinApiResponse<T>>({
87 method,
88 url: path,
89 params,
90 data: body,
91 headers,
92 })
93
94 return response.data.data
95 }
96
97 private async oauthRequest<T>(
98 path: string,
99 data: Record<string, string>,
100 ): Promise<T> {
101 const response = await this.http.post<DouyinOAuthEnvelope<T>>(path, new URLSearchParams(data), {
102 headers: {
103 'Content-Type': 'application/x-www-form-urlencoded',
104 },
105 })
106 return response.data.data
107 }
108
109 generateAuthUrl(scopes: string[], state: string): string {
110 const params = new URLSearchParams({
111 client_key: this.cfg.clientId,
112 scope: scopes.join(','),
113 response_type: 'code',
114 redirect_uri: this.cfg.redirectUri,
115 state,
116 })
117
118 return `${this.authUrl}?${params.toString()}`
119 }
120
121 async exchangeCode(code: string): Promise<{
122 accessToken: string
123 refreshToken: string
124 expiresAt: Date
125 refreshExpiresAt: Date
126 openId: string
127 scope: string
128 }> {
129 const result = await this.oauthRequest<DouyinOAuthResponse>(
130 '/oauth/access_token/',
131 {
132 client_key: this.cfg.clientId,
133 client_secret: this.cfg.clientSecret,
134 code,
135 grant_type: DouyinOAuthGrantType.AuthorizationCode,
136 },
137 )
138
139 return {
140 accessToken: result.access_token,
141 refreshToken: result.refresh_token,
142 expiresAt: new Date(Date.now() + Number(result.expires_in) * 1000),
143 refreshExpiresAt: new Date(Date.now() + Number(result.refresh_expires_in) * 1000),
144 openId: result.open_id,
145 scope: result.scope,
146 }
147 }
148
149 async refreshAccessToken(refreshToken: string): Promise<{
150 accessToken: string
151 refreshToken: string
152 expiresAt: Date
153 refreshExpiresAt: Date
154 scope: string
155 }> {
156 const result = await this.oauthRequest<DouyinOAuthResponse>(
157 '/oauth/refresh_token/',
158 {
159 client_key: this.cfg.clientId,
160 client_secret: this.cfg.clientSecret,
161 grant_type: DouyinOAuthGrantType.RefreshToken,
162 refresh_token: refreshToken,
163 },
164 )
165
166 return {
167 accessToken: result.access_token,
168 refreshToken: result.refresh_token,
169 expiresAt: new Date(Date.now() + Number(result.expires_in) * 1000),
170 refreshExpiresAt: new Date(Date.now() + Number(result.refresh_expires_in) * 1000),
171 scope: result.scope,
172 }
173 }
174
175 async renewRefreshToken(refreshToken: string): Promise<{
176 refreshToken: string
177 refreshExpiresAt: Date
178 }> {
179 const result = await this.oauthRequest<{
180 refresh_token: string
181 expires_in: number | string
182 }>(
183 '/oauth/renew_refresh_token/',
184 {
185 client_key: this.cfg.clientId,
186 refresh_token: refreshToken,
187 },
188 )
189
190 return {
191 refreshToken: result.refresh_token,
192 refreshExpiresAt: new Date(Date.now() + Number(result.expires_in) * 1000),
193 }
194 }
195
196 async revokeAccessToken(accessToken: string, openId: string): Promise<void> {
197 await this.apiRequest<Record<string, never>>(
198 'POST',
199 '/oauth/revoke/',
200 {},
201 { open_id: openId },
202 accessToken,
203 )
204 }
205
206 async getUserInfo(accessToken: string, openId: string): Promise<{
207 openId: string
208 unionId?: string
209 nickname?: string
210 avatar?: string
211 city?: string
212 province?: string
213 country?: string
214 eAccountRole?: string
215 }> {
216 const result = await this.apiRequest<{ user: DouyinUserInfo }>(
217 'POST',
218 '/oauth/userinfo/',
219 {},
220 { open_id: openId },
221 accessToken,
222 )
223
224 const user = result.user
225 return {
226 openId: user.open_id,
227 unionId: user.union_id,
228 nickname: user.nickname,
229 avatar: user.avatar,
230 city: user.city,
231 province: user.province,
232 country: user.country,
233 eAccountRole: user.e_account_role,
234 }
235 }
236
237 async getShareid(): Promise<string> {
238 const response = await this.requestShareId<DouyinShareIdEnvelope>({
239 need_callback: true,
240 default_hashtag: 'hashtag',
241 })
242 const shareId = response.data.data?.share_id
243 if (!shareId) {
244 throw new AppException(ResponseCode.ChannelPlatformApiFailed, { platform: AccountType.Douyin, field: 'share_id', reasonCode: 'missing_platform_field' })
245 }
246
247 return shareId
248 }
249
250 async getSharePublishResult(shareId: string): Promise<DouyinSharePublishResult> {
251 const response = await this.requestShareId<DouyinSharePublishResultEnvelope>({
252 share_id: shareId,
253 })
254 const data = response.data.data ?? {}
255
256 return {
257 shareId: data.share_id ?? shareId,
258 itemId: data.item_id,
259 videoId: data.video_id,
260 shareUrl: data.share_url,
261 raw: data,
262 }
263 }
264
265 async generateShareSchema(options: DouyinShareSchemaOptions): Promise<string> {
266 const ticket = await this.getOpenTicket()
267 const nonceStr = this.generateNonceStr(32)
268 const timestamp = Math.floor(Date.now() / 1000).toString()
269 const signature = this.generateSignature(ticket, nonceStr, timestamp)
270
271 const url = new URL('snssdk1128://openplatform/share')
272 const query = url.searchParams
273 query.append('client_key', this.cfg.clientId)
274 if (options.shareId) {
275 query.append('state', options.shareId)
276 }
277 query.append('nonce_str', nonceStr)
278 query.append('timestamp', timestamp)
279 query.append('signature', signature)
280 query.append('share_type', 'h5')
281
282 if (options.title) {
283 query.append('title', options.title)
284 }
285 if (options.short_title) {
286 query.append('short_title', options.short_title)
287 }
288 if (options.video_path) {
289 query.append('video_path', options.video_path)
290 query.append('share_to_publish', '1')
291 }
292 if (options.image_list_path?.length) {
293 query.append('image_list_path', JSON.stringify(options.image_list_path))
294 }
295 if (options.title_hashtag_list?.length) {
296 query.append('title_hashtag_list', JSON.stringify(options.title_hashtag_list))
297 }
298 if (options.download_type) {
299 query.append('download_type', String(options.download_type))
300 }
301 if (options.private_status !== undefined) {
302 query.append('private_status', String(options.private_status))
303 }
304
305 return url.toString().replace(/\+/g, '%20')
306 }
307
308 private async requestShareId<T>(params: Record<string, string | boolean>) {
309 const clientToken = await this.getClientToken()
310 try {
311 return await this.postShareId<T>(params, clientToken)
312 }
313 catch (error) {
314 if (!this.isClientTokenStaleError(error)) {
315 throw error
316 }
317
318 await this.clearAppCredentialCache()
319 const refreshedClientToken = await this.getClientToken()
320 return this.postShareId<T>(params, refreshedClientToken)
321 }
322 }
323
324 private async postShareId<T>(params: Record<string, string | boolean>, clientToken: string) {
325 return this.http.post<T>(
326 '/share-id/',
327 undefined,
328 {
329 params,
330 headers: {
331 'Content-Type': 'application/json',
332 'access-token': clientToken,
333 },
334 },
335 )
336 }
337
338 private async getClientToken(): Promise<string> {
339 const now = Date.now()
340 if (this.clientTokenCache && this.clientTokenCache.expiresAt - now > TOKEN_EXPIRE_BUFFER_MS) {
341 return this.clientTokenCache.accessToken
342 }
343
344 const cached = await this.redis.getDouyinClientToken<DouyinClientTokenCache>()
345 if (cached?.access_token) {
346 if (cached.expiresAt === undefined || cached.expiresAt - now > TOKEN_EXPIRE_BUFFER_MS) {
347 if (cached.expiresAt !== undefined) {
348 this.clientTokenCache = {
349 accessToken: cached.access_token,
350 expiresAt: cached.expiresAt,
351 }
352 }
353 return cached.access_token
354 }
355 }
356
357 const response = await this.http.post<DouyinOAuthEnvelope<DouyinClientTokenResponse>>(
358 '/oauth/client_token/',
359 {
360 grant_type: DouyinOAuthGrantType.ClientCredential,
361 client_key: this.cfg.clientId,
362 client_secret: this.cfg.clientSecret,
363 },
364 {
365 headers: { 'Content-Type': 'application/json' },
366 },
367 )
368 const result = response.data.data
369 if (!result?.access_token) {
370 throw new AppException(ResponseCode.ChannelAccessTokenFailed, { platform: AccountType.Douyin, field: 'access_token', reasonCode: 'missing_platform_field' })
371 }
372 const expiresIn = Number(result.expires_in)
373 const expiresAt = now + expiresIn * 1000
374
375 this.clientTokenCache = {
376 accessToken: result.access_token,
377 expiresAt,
378 }
379 await this.redis.saveDouyinClientToken({
380 access_token: result.access_token,
381 expires_in: expiresIn,
382 expiresAt,
383 })
384 return result.access_token
385 }
386
387 private async getOpenTicket(): Promise<string> {
388 const clientToken = await this.getClientToken()
389 const now = Date.now()
390 if (
391 this.openTicketCache
392 && this.openTicketCache.clientToken === clientToken
393 && this.openTicketCache.expiresAt - now > TOKEN_EXPIRE_BUFFER_MS
394 ) {
395 return this.openTicketCache.ticket
396 }
397
398 const cached = await this.redis.getDouyinOpenTicket<DouyinOpenTicketCache>()
399 if (
400 cached?.ticket
401 && cached.clientToken === clientToken
402 && (cached.expiresAt === undefined || cached.expiresAt - now > TOKEN_EXPIRE_BUFFER_MS)
403 ) {
404 if (cached.expiresAt !== undefined) {
405 this.openTicketCache = {
406 clientToken,
407 ticket: cached.ticket,
408 expiresAt: cached.expiresAt,
409 }
410 }
411 return cached.ticket
412 }
413
414 const response = await this.http.get<DouyinApiResponse<DouyinOpenTicketResponse>>(
415 '/open/getticket/',
416 {
417 headers: {
418 'Content-Type': 'application/json',
419 'access-token': clientToken,
420 },
421 },
422 )
423 const result = response.data.data
424 if (!result?.ticket) {
425 throw new AppException(ResponseCode.ChannelPlatformApiFailed, { platform: AccountType.Douyin, field: 'ticket', reasonCode: 'missing_platform_field' })
426 }
427 const expiresIn = Number(result.expires_in)
428 const expiresAt = now + expiresIn * 1000
429
430 this.openTicketCache = {
431 clientToken,
432 ticket: result.ticket,
433 expiresAt,
434 }
435 await this.redis.saveDouyinOpenTicket({
436 ticket: result.ticket,
437 clientToken,
438 expires_in: expiresIn,
439 expiresAt,
440 })
441 return result.ticket
442 }
443
444 private async clearAppCredentialCache(): Promise<void> {
445 this.clientTokenCache = undefined
446 this.openTicketCache = undefined
447 await this.redis.deleteDouyinClientToken()
448 await this.redis.deleteDouyinOpenTicket()
449 }
450
451 private isClientTokenStaleError(error: unknown): boolean {
452 if (!(error instanceof DouyinPlatformException)) {
453 return false
454 }
455 return ['10008', '2190008', '28001003', '28001008'].includes(String(error.platformCause?.platformCode))
456 }
457
458 private generateNonceStr(length: number): string {
459 const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
460 const bytes = randomBytes(length)
461 let result = ''
462 for (let i = 0; i < length; i++) {
463 result += chars[bytes[i] % chars.length]
464 }
465 return result
466 }
467
468 private generateSignature(ticket: string, nonceStr: string, timestamp: string): string {
469 const signStr = `nonce_str=${nonceStr}&ticket=${ticket}&timestamp=${timestamp}`
470 return createHash('md5').update(signStr).digest('hex')
471 }
472
473 async uploadVideo(
474 accessToken: string,
475 openId: string,
476 videoBuffer: Buffer,
477 filename: string,
478 ): Promise<{ videoId: string }> {
479 const formData = new FormData()
480 formData.append('video', new Blob([new Uint8Array(videoBuffer)]), filename)
481
482 const response = await this.http.post<DouyinApiResponse<DouyinVideoUploadResponse>>(
483 '/api/douyin/v1/video/upload_video/',
484 formData,
485 {
486 params: { open_id: openId },
487 headers: { 'access-token': accessToken },
488 },
489 )
490 const videoId = response.data.data.video?.video_id
491 if (!videoId) {
492 throw new AppException(ResponseCode.ChannelPlatformMediaProcessingFailed, { platform: AccountType.Douyin, field: 'video_id', reasonCode: 'missing_platform_field' })
493 }
494
495 return { videoId }
496 }
497
498 async uploadImage(
499 accessToken: string,
500 openId: string,
501 imageBuffer: Buffer,
502 filename: string,
503 ): Promise<{ imageId: string }> {
504 const formData = new FormData()
505 formData.append('image', new Blob([new Uint8Array(imageBuffer)]), filename)
506
507 const response = await this.http.post<DouyinApiResponse<{ image?: { image_id?: string } }>>(
508 '/api/douyin/v1/video/upload_image/',
509 formData,
510 {
511 headers: { 'access-token': accessToken },
512 params: { open_id: openId },
513 },
514 )
515 const imageId = response.data.data.image?.image_id
516 if (!imageId) {
517 throw new AppException(ResponseCode.ChannelPlatformMediaProcessingFailed, { platform: AccountType.Douyin, field: 'image_id', reasonCode: 'missing_platform_field' })
518 }
519
520 return { imageId }
521 }
522
523 async createVideo(
524 accessToken: string,
525 openId: string,
526 params: {
527 videoId: string
528 title?: string
529 description?: string
530 customCoverImageId?: string
531 coverTsp?: number
532 downloadType?: number
533 privateStatus?: number
534 topics?: string[]
535 },
536 ): Promise<DouyinVideoCreateResponse> {
537 const textParts = [
538 params.title,
539 params.description,
540 ...(params.topics ?? []).map(topic => `#${topic.replace(/^#/, '')}`),
541 ].filter(Boolean)
542
543 const body: DouyinVideoCreateRequestBody = {
544 video_id: params.videoId,
545 text: textParts.join('\n').trim(),
546 custom_cover_image_url: params.customCoverImageId,
547 cover_tsp: params.coverTsp,
548 download_type: params.downloadType,
549 private_status: params.privateStatus,
550 }
551
552 return this.apiRequest<DouyinVideoCreateResponse>(
553 'POST',
554 '/api/douyin/v1/video/create_video/',
555 { open_id: openId },
556 body,
557 accessToken,
558 )
559 }
560 }
561
561 lines TYPESCRIPT