返回 AiToEarn
auth.service.ts
1 import type { AccountIdentity } from '@yikart/mongodb'
2 import type {
3 AuthCallbackInput,
4 CredentialContext,
5 CredentialResult,
6 GenerateAuthUrlInput,
7 PlatformAccountCredentialSnapshot,
8 PlatformAccountProfile,
9 PlatformSelectableAccount,
10 } from '../platforms/platforms.interface'
11 import { Injectable, Logger, Optional } from '@nestjs/common'
12 import { AccountType, AppException, ChannelAuthSessionStatus, getLocale, ResponseCode } from '@yikart/common'
13 import { AccountGroupRepository, AccountRepository, AccountStatus, Transactional } from '@yikart/mongodb'
14 import { EventStream, EventStreamService, EventTopic } from '@yikart/redis'
15 import { nanoid } from 'nanoid'
16 import { UAParser } from 'ua-parser-js'
17 import { ServerRedisService } from '../../../common/redis'
18 import { CredentialService } from '../accounts/credential.service'
19 import { ChannelPlatformException, PlatformErrorCategory } from '../platforms/platforms.exception'
20 import { AuthCallbackResponseType, AuthType } from '../platforms/platforms.interface'
21 import { PlatformIntegrationRegistry } from '../platforms/platforms.registry'
22 import { RelayAccountException } from '../relay/relay-account.exception'
23 import { RelayAuthException } from '../relay/relay-auth.exception'
24 import { RelayClientService } from '../relay/relay-client.service'
25 import {
26 AuthCallbackResult,
27 AuthSession,
28 AuthSessionResult,
29 AuthViewFields,
30 ChannelAuthSessionFlow,
31 ConnectedSelectableAccount,
32 ConnectSelectableAccountsResult,
33 SelectableAccountView,
34 SelectedAccountIdentity,
35 StartAuthInput,
36 StartAuthSessionResult,
37 } from './auth.interface'
38
39 const AUTH_RANDOM_ID_LENGTH = 16
40
41 @Injectable()
42 export class AuthService {
43 private readonly logger = new Logger(AuthService.name)
44
45 constructor(
46 private readonly registry: PlatformIntegrationRegistry,
47 private readonly credentialService: CredentialService,
48 private readonly accountRepo: AccountRepository,
49 private readonly accountGroupRepo: AccountGroupRepository,
50 private readonly redis: ServerRedisService,
51 private readonly eventStream: EventStreamService,
52 @Optional() private readonly relayClientService?: RelayClientService,
53 ) {}
54
55 async startAuth(input: StartAuthInput): Promise<StartAuthSessionResult> {
56 const integration = this.getBackendAuthIntegration(input.platform)
57 const provider = integration.auth!
58 const sessionId = nanoid(AUTH_RANDOM_ID_LENGTH)
59 const expiresAt = new Date(Date.now() + 5 * 60 * 1000)
60 const { browser, device, os } = UAParser(input.userAgent ?? '')
61 const parsedDeviceType: GenerateAuthUrlInput['deviceType'] = device.type === 'mobile' || device.type === 'tablet'
62 ? device.type
63 : (!device.type && (browser.name || os.name) ? 'desktop' : 'unknown')
64 const deviceType = input.deviceType ?? parsedDeviceType
65 const groupId = await this.resolveGroupId(input.userId, input.groupId)
66
67 const authInput: GenerateAuthUrlInput = {
68 userId: input.userId,
69 state: sessionId,
70 deviceType,
71 }
72
73 const result = await provider.generateAuthUrl(authInput)
74
75 const session: AuthSession = {
76 flow: ChannelAuthSessionFlow.AccountAuth,
77 id: sessionId,
78 userId: input.userId,
79 platform: input.platform,
80 redirectUri: input.redirectUri,
81 callbackUrl: input.callbackUrl,
82 groupId,
83 authExtras: result.extras,
84 status: ChannelAuthSessionStatus.Pending,
85 expiresAt,
86 createdAt: new Date(),
87 }
88
89 await this.redis.saveChannelAuthSession(session.id, session)
90
91 return {
92 url: result.url,
93 sessionId,
94 expiresAt,
95 authInstructions: integration.metadata.authInstructions,
96 }
97 }
98
99 async completeCallback(
100 platform: AccountType,
101 callbackInput: Omit<AuthCallbackInput, 'session'>,
102 sessionId: string,
103 ): Promise<AuthCallbackResult> {
104 const session = await this.redis.getChannelAuthSession<AuthSession>(sessionId)
105 if (!this.isAccountAuthSessionRecord(session)) {
106 throw new AppException(ResponseCode.ChannelAuthSessionInvalid)
107 }
108 if (this.isSessionExpired(session)) {
109 throw new AppException(ResponseCode.ChannelAuthSessionInvalid)
110 }
111 if (session.platform !== platform) {
112 throw new AppException(ResponseCode.ChannelAuthPlatformMismatch)
113 }
114 if (session.status !== ChannelAuthSessionStatus.Pending) {
115 throw new AppException(ResponseCode.ChannelAuthSessionCompleted)
116 }
117
118 const provider = this.registry.getAuth(platform)
119 const credentialResult = await provider.exchangeCode({
120 ...callbackInput,
121 session,
122 })
123
124 const credentialContext = credentialResult.accessToken
125 ? this.toCredentialContext(credentialResult)
126 : undefined
127 const profile = credentialResult.profile
128 ?? (credentialContext
129 ? await provider.getProfile(credentialContext)
130 : undefined)
131 if (!profile) {
132 throw new AppException(ResponseCode.ChannelAuthPlatformUidMissing)
133 }
134 const selectableAccounts = credentialResult.selectableAccounts
135 ?? (!credentialResult.profile && credentialContext && provider.listSelectableAccounts
136 ? await provider.listSelectableAccounts(credentialContext)
137 : undefined)
138
139 if (selectableAccounts) {
140 const accounts = selectableAccounts
141 if (accounts.length === 1) {
142 const result = await this.connectSelectedAccountsForUser({
143 userId: session.userId,
144 platform: session.platform,
145 selectableAccounts: accounts,
146 selectedAccounts: [{
147 platformUid: accounts[0].platformUid,
148 account: accounts[0].account,
149 }],
150 groupId: session.groupId,
151 source: 'auth',
152 allowReassign: true,
153 })
154
155 session.status = ChannelAuthSessionStatus.Completed
156 session.accountId = result.accountIds[0]
157 session.accountIds = result.accountIds
158 session.accounts = result.accounts
159 await this.redis.saveChannelAuthSession(session.id, session)
160
161 return {
162 accountId: result.accountIds[0],
163 connectedAccounts: result.accounts,
164 callbackResponseType: credentialResult.callbackResponseType,
165 ...this.getAuthViewFields(session),
166 }
167 }
168
169 session.rootCredentialId = nanoid(AUTH_RANDOM_ID_LENGTH)
170 session.selectableAccounts = accounts
171 await this.redis.saveChannelAuthSession(session.id, session)
172
173 return {
174 requiresSelection: true,
175 accounts: this.toSelectableAccountViews(accounts),
176 callbackResponseType: credentialResult.callbackResponseType,
177 ...this.getAuthViewFields(session),
178 }
179 }
180
181 const connectedAccount = await this.connectAccountProfile({
182 userId: session.userId,
183 platform,
184 profile,
185 credential: credentialResult,
186 groupId: session.groupId,
187 source: 'auth',
188 allowReassign: true,
189 })
190 const accountId = connectedAccount.accountId
191
192 session.accountId = accountId
193 session.accountIds = [accountId]
194 session.accounts = [connectedAccount]
195 session.status = ChannelAuthSessionStatus.Completed
196 await this.redis.saveChannelAuthSession(session.id, session)
197
198 return {
199 accountId,
200 connectedAccounts: session.accounts,
201 callbackResponseType: credentialResult.callbackResponseType,
202 ...this.getAuthViewFields(session),
203 }
204 }
205
206 async connectSelectableAccounts(
207 sessionId: string,
208 selectedAccounts: SelectedAccountIdentity[],
209 ): Promise<ConnectSelectableAccountsResult> {
210 const session = await this.redis.getChannelAuthSession<AuthSession>(sessionId)
211 if (!this.isAccountAuthSessionRecord(session) || this.isSessionExpired(session) || session.status !== ChannelAuthSessionStatus.Pending) {
212 throw new AppException(ResponseCode.ChannelAuthSessionInvalid)
213 }
214 if (!session.selectableAccounts) {
215 throw new AppException(ResponseCode.ChannelAuthSelectableAccountsNotFound)
216 }
217
218 const result = await this.connectSelectedAccountsForUser({
219 userId: session.userId,
220 platform: session.platform,
221 selectableAccounts: session.selectableAccounts,
222 selectedAccounts,
223 groupId: session.groupId,
224 source: 'auth',
225 allowReassign: true,
226 })
227
228 session.status = ChannelAuthSessionStatus.Completed
229 session.accountId = result.accountIds[0]
230 session.accountIds = result.accountIds
231 session.accounts = result.accounts
232 delete session.rootCredentialId
233 delete session.selectableAccounts
234 await this.redis.saveChannelAuthSession(session.id, session)
235
236 return {
237 ...result,
238 ...this.getAuthViewFields(session),
239 }
240 }
241
242 async connectAccountProfile(input: {
243 userId: string
244 platform: AccountType
245 profile: PlatformAccountProfile
246 credential: CredentialResult
247 groupId?: string
248 source: string
249 allowReassign?: boolean
250 }): Promise<ConnectedSelectableAccount> {
251 const connectedAccount = await this.saveAccountProfile(input)
252 await this.emitAccountConnected(input.userId, connectedAccount.accountId, connectedAccount.platform, input.source)
253 return connectedAccount
254 }
255
256 @Transactional()
257 private async saveAccountProfile(input: {
258 userId: string
259 platform: AccountType
260 profile: PlatformAccountProfile
261 credential: CredentialResult
262 groupId?: string
263 source: string
264 allowReassign?: boolean
265 }): Promise<ConnectedSelectableAccount> {
266 const groupId = await this.resolveGroupId(input.userId, input.groupId)
267 const account = await this.createOrUpdateAccount({
268 userId: input.userId,
269 platform: input.platform,
270 platformUid: input.profile.platformUid,
271 account: input.profile.account,
272 displayName: input.profile.displayName,
273 avatarUrl: input.profile.avatarUrl,
274 fansCount: input.profile.fansCount,
275 followingCount: input.profile.followingCount,
276 groupId,
277 allowReassign: input.allowReassign ?? this.shouldReassignDouyinAccount(input.source, input.platform, input.credential),
278 })
279
280 if (!input.credential.accessToken) {
281 throw new AppException(ResponseCode.ChannelAccessTokenFailed)
282 }
283
284 await this.credentialService.saveCredential(account.id, input.platform, {
285 accessToken: input.credential.accessToken,
286 refreshToken: input.credential.refreshToken,
287 expiresAt: input.credential.expiresAt,
288 scope: input.credential.scope,
289 raw: input.credential.raw,
290 })
291
292 return {
293 accountId: account.id,
294 platform: input.platform,
295 platformUid: input.profile.platformUid,
296 account: input.profile.account,
297 displayName: input.profile.displayName,
298 avatarUrl: input.profile.avatarUrl,
299 }
300 }
301
302 async connectSelectedAccountsForUser(input: {
303 userId: string
304 platform: AccountType
305 selectableAccounts: PlatformSelectableAccount[]
306 selectedAccounts: SelectedAccountIdentity[]
307 groupId?: string
308 source: string
309 allowReassign?: boolean
310 }): Promise<{ accountIds: string[], accounts: ConnectedSelectableAccount[] }> {
311 const result = await this.saveSelectedAccountsForUser(input)
312 await Promise.all(result.accounts.map(account => this.emitAccountConnected(input.userId, account.accountId, account.platform, input.source)))
313 return result
314 }
315
316 @Transactional()
317 private async saveSelectedAccountsForUser(input: {
318 userId: string
319 platform: AccountType
320 selectableAccounts: PlatformSelectableAccount[]
321 selectedAccounts: SelectedAccountIdentity[]
322 groupId?: string
323 source: string
324 allowReassign?: boolean
325 }): Promise<{ accountIds: string[], accounts: ConnectedSelectableAccount[] }> {
326 if (input.selectedAccounts.length === 0) {
327 throw new AppException(ResponseCode.ChannelAuthSelectionRequired)
328 }
329
330 const keyOf = (account: SelectedAccountIdentity) => `${account.platformUid}\u0000${account.account ?? ''}`
331 const selectedAccounts = Array.from(
332 new Map(input.selectedAccounts.map(account => [keyOf(account), account])).values(),
333 )
334 const selectableAccounts = new Map(
335 input.selectableAccounts.map(account => [keyOf(account), account]),
336 )
337 const unknownAccount = selectedAccounts.find(account => !selectableAccounts.has(keyOf(account)))
338 if (unknownAccount) {
339 throw new AppException(ResponseCode.ChannelAuthSelectedAccountUnavailable)
340 }
341
342 const groupId = await this.resolveGroupId(input.userId, input.groupId)
343 const accountIds: string[] = []
344 const accounts: ConnectedSelectableAccount[] = []
345
346 for (const selectedAccount of selectedAccounts) {
347 const selectable = selectableAccounts.get(keyOf(selectedAccount))
348 if (!selectable) {
349 throw new AppException(ResponseCode.ChannelAuthSelectedAccountUnavailable)
350 }
351
352 const platform = selectable.platform ?? input.platform
353 const account = await this.createOrUpdateAccount({
354 userId: input.userId,
355 platform,
356 platformUid: selectable.platformUid,
357 account: selectable.account,
358 displayName: selectable.displayName,
359 avatarUrl: selectable.avatarUrl,
360 fansCount: selectable.fansCount,
361 followingCount: selectable.followingCount,
362 groupId,
363 allowReassign: input.allowReassign,
364 })
365
366 if (selectable.credential) {
367 await this.saveSelectableCredential(account.id, platform, selectable.credential)
368 }
369
370 accountIds.push(account.id)
371 accounts.push({
372 accountId: account.id,
373 platform,
374 platformUid: selectable.platformUid,
375 account: selectable.account,
376 displayName: selectable.displayName,
377 avatarUrl: selectable.avatarUrl,
378 })
379 }
380
381 if (accountIds.length === 0) {
382 throw new AppException(ResponseCode.ChannelAuthSelectionRequired)
383 }
384
385 return { accountIds, accounts }
386 }
387
388 async listAccountOwnerIds(input: {
389 platform: AccountType
390 profile: PlatformAccountProfile
391 selectableAccounts?: PlatformSelectableAccount[]
392 }): Promise<string[]> {
393 const owners = new Set<string>()
394 const accountIdentities = new Map<string, { platform: AccountType, platformUid: string, account?: string }>()
395 const addIdentity = (identity: { platform: AccountType, platformUid: string, account?: string }) => {
396 accountIdentities.set(`${identity.platform}\u0000${identity.platformUid}\u0000${identity.account ?? ''}`, identity)
397 }
398
399 addIdentity({
400 platform: input.platform,
401 platformUid: input.profile.platformUid,
402 account: input.profile.account,
403 })
404 for (const account of input.selectableAccounts ?? []) {
405 addIdentity({
406 platform: account.platform ?? input.platform,
407 platformUid: account.platformUid,
408 account: account.account,
409 })
410 }
411
412 for (const identity of accountIdentities.values()) {
413 const accountIdentity = identity.platform === AccountType.YouTube ? identity.account : undefined
414 const account = await this.accountRepo.getByIdentity({
415 type: identity.platform,
416 uid: identity.platformUid,
417 account: accountIdentity,
418 })
419 if (account?.userId) {
420 owners.add(account.userId)
421 }
422 }
423
424 return Array.from(owners)
425 }
426
427 async getAuthSessionResult(
428 userId: string,
429 platform: AccountType | undefined,
430 sessionId: string,
431 ): Promise<AuthSessionResult> {
432 if (this.relayClientService?.enabled) {
433 throw new RelayAuthException()
434 }
435 const session = await this.redis.getChannelAuthSession<AuthSession>(sessionId)
436 if (!session || session.userId !== userId) {
437 throw new AppException(ResponseCode.ChannelAuthSessionInvalid)
438 }
439 if (this.isSessionExpired(session)) {
440 throw new AppException(ResponseCode.ChannelAuthSessionInvalid)
441 }
442 if (platform && session.platform !== platform) {
443 throw new AppException(ResponseCode.ChannelAuthPlatformMismatch)
444 }
445 const expiresAt = this.getSessionExpiresAt(session)
446
447 return {
448 sessionId: session.id,
449 status: session.status,
450 requiresSelection: session.status === ChannelAuthSessionStatus.Pending
451 && Boolean(session.selectableAccounts?.length),
452 errorCode: session.errorCode,
453 ...(expiresAt ? { expiresAt } : {}),
454 accountId: session.accountId,
455 accountIds: session.accountIds,
456 accounts: session.accounts,
457 selectableAccounts: session.selectableAccounts
458 ? this.toSelectableAccountViews(session.selectableAccounts)
459 : undefined,
460 }
461 }
462
463 async markSessionFailed(sessionId: string, errorCode: number): Promise<void> {
464 const session = await this.redis.getChannelAuthSession<AuthSession>(sessionId)
465 if (!this.isAccountAuthSessionRecord(session) || this.isSessionExpired(session) || session.status !== ChannelAuthSessionStatus.Pending) {
466 return
467 }
468
469 session.status = ChannelAuthSessionStatus.Failed
470 session.errorCode = errorCode
471 delete session.rootCredentialId
472 delete session.selectableAccounts
473 await this.redis.saveChannelAuthSession(session.id, session)
474 }
475
476 async getAccountAuthStatus(userId: string, platform: AccountType, accountId: string) {
477 const account = await this.accountRepo.getByIdAndUserId(accountId, userId)
478 if (!account) {
479 throw new AppException(ResponseCode.AccountNotFound)
480 }
481 if (account.type !== platform) {
482 throw new AppException(ResponseCode.ChannelAuthPlatformMismatch)
483 }
484
485 return { status: account.status }
486 }
487
488 async revokeCredential(accountId: string, userId: string): Promise<void> {
489 const account = await this.getCredentialAccount(accountId, userId)
490 const credential = await this.credentialService.getCredential(accountId)
491
492 if (credential) {
493 const provider = this.registry.getAuth(account.type)
494 await provider.revoke?.({
495 accessToken: credential.accessToken,
496 refreshToken: credential.refreshToken,
497 platformUid: account.uid,
498 })
499 }
500
501 const offlineAccount = await this.markAccountOfflineInStore(accountId, { deleteCredential: true })
502 await this.credentialService.invalidateCredential(accountId)
503 await this.emitAccountOffline(offlineAccount, accountId, 'revoked')
504 }
505
506 async getValidCredential(
507 accountId: string,
508 userId?: string,
509 ): Promise<{ accessToken: string, refreshToken?: string, expiresAt?: Date, scope?: string }> {
510 const account = await this.getCredentialAccount(accountId, userId)
511 const credential = await this.credentialService.getCredential(accountId)
512 if (!credential) {
513 const error = new AppException(ResponseCode.ChannelCredentialNotFound, { accountId: account.id })
514 await this.markAccountOfflineForCredentialFailure(account.id, error, 'credential_not_found')
515 throw error
516 }
517
518 const expiresSoon = credential.expiresAt !== undefined
519 && credential.expiresAt <= Math.floor(Date.now() / 1000) + 60
520 const missingYouTubeExpiry = account.type === AccountType.YouTube
521 && credential.expiresAt === undefined
522 && !!credential.refreshToken
523
524 if (!expiresSoon && !missingYouTubeExpiry) {
525 return {
526 accessToken: credential.accessToken,
527 refreshToken: credential.refreshToken,
528 expiresAt: credential.expiresAt === undefined ? undefined : new Date(credential.expiresAt * 1000),
529 scope: credential.scope,
530 }
531 }
532
533 let refreshed: Awaited<ReturnType<CredentialService['tryRefresh']>>
534 try {
535 refreshed = await this.credentialService.tryRefresh(account)
536 }
537 catch (error) {
538 await this.markAccountOfflineForCredentialFailure(account.id, error, 'credential_refresh_failed')
539 throw error
540 }
541 if (refreshed) {
542 return {
543 accessToken: refreshed.accessToken,
544 refreshToken: refreshed.refreshToken,
545 expiresAt: refreshed.expiresAt,
546 scope: refreshed.scope,
547 }
548 }
549
550 const deadline = Date.now() + 5000
551 while (Date.now() < deadline) {
552 await new Promise(resolve => setTimeout(resolve, 250))
553 const refreshedCredential = await this.credentialService.getCredential(accountId)
554 const refreshedExpiresSoon = refreshedCredential?.expiresAt !== undefined
555 && refreshedCredential.expiresAt <= Math.floor(Date.now() / 1000) + 60
556 const refreshedMissingYouTubeExpiry = account.type === AccountType.YouTube
557 && refreshedCredential?.expiresAt === undefined
558 && !!refreshedCredential?.refreshToken
559 if (
560 refreshedCredential
561 && !refreshedExpiresSoon
562 && !refreshedMissingYouTubeExpiry
563 ) {
564 return {
565 accessToken: refreshedCredential.accessToken,
566 refreshToken: refreshedCredential.refreshToken,
567 expiresAt: refreshedCredential.expiresAt === undefined ? undefined : new Date(refreshedCredential.expiresAt * 1000),
568 scope: refreshedCredential.scope,
569 }
570 }
571 }
572
573 throw new AppException(ResponseCode.ChannelAccessTokenFailed)
574 }
575
576 async refreshCredential(
577 accountId: string,
578 userId?: string,
579 ): Promise<{ accessToken: string, refreshToken?: string, expiresAt?: Date, scope?: string }> {
580 const refreshed = await this.tryRefreshCredential(accountId, userId)
581 if (!refreshed) {
582 throw new AppException(ResponseCode.ChannelAccessTokenFailed)
583 }
584
585 return {
586 accessToken: refreshed.accessToken,
587 refreshToken: refreshed.refreshToken,
588 expiresAt: refreshed.expiresAt,
589 scope: refreshed.scope,
590 }
591 }
592
593 async tryRefreshCredential(
594 accountId: string,
595 userId?: string,
596 ): Promise<{ accessToken: string, refreshToken?: string, expiresAt?: Date, scope?: string } | null> {
597 const account = await this.getCredentialAccount(accountId, userId)
598 let refreshed: Awaited<ReturnType<CredentialService['tryRefresh']>>
599 try {
600 refreshed = await this.credentialService.tryRefresh(account)
601 }
602 catch (error) {
603 await this.markAccountOfflineForCredentialFailure(account.id, error, 'credential_refresh_failed')
604 throw error
605 }
606 if (!refreshed) {
607 return null
608 }
609
610 return {
611 accessToken: refreshed.accessToken,
612 refreshToken: refreshed.refreshToken,
613 expiresAt: refreshed.expiresAt,
614 scope: refreshed.scope,
615 }
616 }
617
618 private async getCredentialAccount(accountId: string, userId?: string) {
619 const account = userId
620 ? await this.accountRepo.getByIdAndUserId(accountId, userId)
621 : await this.accountRepo.getAccountById(accountId)
622 if (!account) {
623 throw new AppException(ResponseCode.AccountNotFound)
624 }
625 if (account.relayAccountRef) {
626 throw new RelayAccountException(account.relayAccountRef, accountId)
627 }
628 if (account.status === AccountStatus.ABNORMAL) {
629 throw new AppException(ResponseCode.ChannelAccountNotAuthorized)
630 }
631 return account
632 }
633
634 async markAccountOfflineForCredentialFailure(
635 accountId: string,
636 error: unknown,
637 reason = 'platform_auth_failed',
638 ): Promise<boolean> {
639 if (!this.isCredentialFailure(error)) {
640 return false
641 }
642 try {
643 await this.markAccountOffline(accountId, reason)
644 return true
645 }
646 catch (markError) {
647 this.logger.warn(error, `Credential failure for account ${accountId}`)
648 this.logger.warn(markError, `Failed to mark account ${accountId} offline after credential failure`)
649 return false
650 }
651 }
652
653 private isCredentialFailure(error: unknown): boolean {
654 if (error instanceof ChannelPlatformException) {
655 if (error.retryable) {
656 return false
657 }
658 return error.category === PlatformErrorCategory.Auth
659 }
660 if (error instanceof AppException) {
661 const credentialCodes: ResponseCode[] = [
662 ResponseCode.ChannelCredentialNotFound,
663 ResponseCode.ChannelRefreshTokenFailed,
664 ResponseCode.ChannelRefreshTokenExpired,
665 ResponseCode.ChannelRefreshTokenNotFound,
666 ResponseCode.ChannelAccessTokenFailed,
667 ResponseCode.ChannelPlatformTokenNotFound,
668 ]
669 return credentialCodes.includes(error.code)
670 }
671 return false
672 }
673
674 async markAccountOffline(accountId: string, reason: string): Promise<void> {
675 const account = await this.markAccountOfflineInStore(accountId)
676 await this.credentialService.invalidateCredential(accountId)
677 await this.emitAccountOffline(account, accountId, reason)
678 }
679
680 @Transactional()
681 private async markAccountOfflineInStore(accountId: string, options?: { deleteCredential?: boolean }) {
682 await this.accountRepo.updateById(accountId, { status: AccountStatus.ABNORMAL })
683 if (options?.deleteCredential) {
684 await this.credentialService.deleteCredentialRecord(accountId)
685 }
686 return this.accountRepo.getAccountById(accountId)
687 }
688
689 private async emitAccountOffline(
690 account: Awaited<ReturnType<AccountRepository['getAccountById']>>,
691 accountId: string,
692 reason: string,
693 ): Promise<void> {
694 if (account) {
695 await this.eventStream.emit(
696 EventStream.Channels,
697 EventTopic.ChannelsAccountOffline,
698 { accountId, platform: account.type, reason },
699 { source: 'auth' },
700 )
701 }
702 }
703
704 private async createOrUpdateAccount(input: {
705 userId: string
706 platform: AccountType
707 platformUid: string
708 account?: string
709 displayName: string
710 avatarUrl?: string
711 fansCount?: number
712 followingCount?: number
713 groupId: string
714 allowReassign?: boolean
715 }) {
716 const accountIdentity = input.platform === AccountType.YouTube ? input.account : undefined
717 const identity: AccountIdentity = {
718 type: input.platform,
719 uid: input.platformUid,
720 account: accountIdentity,
721 }
722 const accountData = {
723 userId: input.userId,
724 type: input.platform,
725 uid: input.platformUid,
726 account: input.account,
727 nickname: input.displayName,
728 avatar: input.avatarUrl,
729 status: AccountStatus.NORMAL,
730 groupId: input.groupId,
731 fansCount: input.fansCount,
732 followingCount: input.followingCount,
733 }
734 let account = await this.accountRepo.getByIdentity(identity)
735 let created = false
736 if (!account) {
737 account = await this.accountRepo.createByIdentity(identity, accountData)
738 created = true
739 }
740 if (!created && account && (account.userId === input.userId || !account.userId || input.allowReassign)) {
741 account = await this.accountRepo.updateByIdentity(identity, accountData) ?? account
742 }
743
744 if (!account) {
745 throw new AppException(ResponseCode.AccountCreateFailed)
746 }
747 if (account.userId !== input.userId) {
748 this.logger.warn(
749 { input, existingAccount: account },
750 'Channel account already connected to another user',
751 )
752 throw new AppException(ResponseCode.ChannelAccountAlreadyConnectedToAnotherUser)
753 }
754
755 return account
756 }
757
758 private async saveSelectableCredential(
759 accountId: string,
760 platform: AccountType,
761 credential: PlatformAccountCredentialSnapshot,
762 ): Promise<void> {
763 await this.credentialService.saveCredential(accountId, platform, {
764 accessToken: credential.accessToken,
765 refreshToken: credential.refreshToken,
766 expiresAt: credential.expiresAt,
767 scope: credential.scope,
768 })
769 }
770
771 private async emitAccountConnected(
772 userId: string,
773 accountId: string,
774 platform: AccountType,
775 source: string,
776 ): Promise<void> {
777 await this.eventStream.emit(
778 EventStream.Channels,
779 EventTopic.ChannelsAccountConnected,
780 { userId, accountId, platform },
781 { source },
782 )
783 }
784
785 private shouldReassignDouyinAccount(source: string, platform: AccountType, credential: CredentialResult) {
786 return source === 'auth'
787 && platform === AccountType.Douyin
788 && credential.callbackResponseType === AuthCallbackResponseType.Json
789 }
790
791 private toCredentialContext(credential: CredentialResult): CredentialContext {
792 if (!credential.accessToken) {
793 throw new AppException(ResponseCode.ChannelAccessTokenFailed)
794 }
795
796 return {
797 accessToken: credential.accessToken,
798 refreshToken: credential.refreshToken,
799 expiresAt: credential.expiresAt,
800 scope: credential.scope,
801 platformUid: credential.platformUid,
802 }
803 }
804
805 getPlatformAuthViewFields(platform: AccountType): Pick<AuthViewFields, 'platformDisplayName' | 'platformLogoUrl'> {
806 const integration = this.registry.get(platform)
807 const locale = getLocale()
808 return {
809 platformDisplayName: integration.metadata.displayName[locale],
810 platformLogoUrl: integration.metadata.logoUrl,
811 }
812 }
813
814 private isSessionExpired(session: AuthSession): boolean {
815 const expiresAt = this.getSessionExpiresAt(session)
816 return !!expiresAt && expiresAt.getTime() <= Date.now()
817 }
818
819 private isAccountAuthSessionRecord(session: unknown): session is AuthSession {
820 return Boolean(
821 session
822 && typeof session === 'object'
823 && !Array.isArray(session)
824 && (session as { flow?: unknown }).flow === ChannelAuthSessionFlow.AccountAuth,
825 )
826 }
827
828 private getSessionExpiresAt(session: AuthSession): Date | undefined {
829 if (!session.expiresAt) {
830 return undefined
831 }
832
833 return session.expiresAt instanceof Date
834 ? session.expiresAt
835 : new Date(session.expiresAt)
836 }
837
838 private toSelectableAccountViews(accounts: PlatformSelectableAccount[]): SelectableAccountView[] {
839 return accounts.map(account => ({
840 platform: account.platform,
841 platformUid: account.platformUid,
842 account: account.account,
843 displayName: account.displayName,
844 avatarUrl: account.avatarUrl,
845 parentPlatformUid: account.parentPlatformUid,
846 }))
847 }
848
849 private getAuthViewFields(session: AuthSession): AuthViewFields {
850 const integration = this.registry.get(session.platform)
851 const locale = getLocale()
852 const emptyAccountHint = integration.metadata.emptyAccountHint
853 return {
854 ...this.getPlatformAuthViewFields(session.platform),
855 callbackUrl: session.callbackUrl,
856 redirectUri: session.redirectUri,
857 emptyAccountHint: emptyAccountHint
858 ? {
859 title: emptyAccountHint.title[locale],
860 description: emptyAccountHint.description[locale],
861 action: emptyAccountHint.action
862 ? {
863 label: emptyAccountHint.action.label[locale],
864 url: emptyAccountHint.action.url,
865 }
866 : undefined,
867 }
868 : undefined,
869 }
870 }
871
872 private getBackendAuthIntegration(platform: AccountType) {
873 if (!this.registry.has(platform)) {
874 throw new AppException(ResponseCode.PlatformNotSupported, { platform })
875 }
876
877 if (this.relayClientService?.enabled) {
878 throw new RelayAuthException()
879 }
880
881 const integration = this.registry.get(platform)
882 if (integration.metadata.authType === AuthType.Plugin) {
883 throw new AppException(ResponseCode.PlatformNotSupported, { platform })
884 }
885 if (!integration.auth) {
886 throw new AppException(ResponseCode.PlatformNotSupported, { platform, capability: 'auth' })
887 }
888 return integration
889 }
890
891 async resolveGroupId(userId: string, groupId?: string): Promise<string> {
892 if (groupId) {
893 const group = await this.accountGroupRepo.getById(groupId)
894 if (!group || group.userId !== userId) {
895 throw new AppException(ResponseCode.AccountGroupNotFound)
896 }
897 return groupId
898 }
899
900 const defaultGroup = await this.accountGroupRepo.getDefaultGroup(userId)
901 return defaultGroup.id
902 }
903 }
904
904 lines TYPESCRIPT