返回 AiToEarn
credential.service.ts
根目录 / project / aitoearn-backend / apps / aitoearn-server / src / core / channels / accounts / credential.service.ts
1 import type { AccountType } from '@yikart/common'
2 import { randomUUID } from 'node:crypto'
3 import { Injectable } from '@nestjs/common'
4 import { AppException, ResponseCode } from '@yikart/common'
5 import { OAuth2CredentialRepository, Transactional } from '@yikart/mongodb'
6 import { ServerRedisService } from '../../../common/redis'
7 import { PlatformIntegrationRegistry } from '../platforms/platforms.registry'
8
9 interface CachedCredential {
10 accessToken: string
11 refreshToken?: string
12 expiresAt?: number
13 scope?: string
14 raw?: unknown
15 }
16
17 export interface RefreshedCredential {
18 accessToken: string
19 refreshToken?: string
20 expiresAt?: Date
21 scope?: string
22 }
23
24 export interface ExpiringCredentialCursor {
25 accessTokenExpiresAt: number
26 cursorId: unknown
27 }
28
29 export interface ExpiringCredential extends ExpiringCredentialCursor {
30 accountId: string
31 platform: AccountType
32 refreshTokenExpiresAt?: number
33 }
34
35 @Injectable()
36 export class CredentialService {
37 constructor(
38 private readonly credentialRepo: OAuth2CredentialRepository,
39 private readonly redis: ServerRedisService,
40 private readonly registry: PlatformIntegrationRegistry,
41 ) {}
42
43 async getCredential(accountId: string): Promise<CachedCredential | null> {
44 const cached = await this.redis.getChannelCredentialCache<CachedCredential>(accountId)
45 if (cached) {
46 return cached
47 }
48
49 const record = await this.credentialRepo.getByAccountId(accountId)
50 if (!record) {
51 return null
52 }
53
54 const credential: CachedCredential = {
55 accessToken: record.accessToken,
56 refreshToken: record.refreshToken || undefined,
57 expiresAt: record.accessTokenExpiresAt,
58 scope: record.scope || undefined,
59 raw: record.raw,
60 }
61
62 await this.redis.saveChannelCredentialCache(accountId, credential)
63 return credential
64 }
65
66 @Transactional()
67 async saveCredential(
68 accountId: string,
69 platform: AccountType,
70 credential: {
71 accessToken: string
72 refreshToken?: string
73 expiresAt?: Date
74 scope?: string
75 raw?: unknown
76 },
77 ): Promise<void> {
78 const accessTokenExpiresAt = credential.expiresAt
79 ? Math.floor(credential.expiresAt.getTime() / 1000)
80 : undefined
81 const credentialData = {
82 accessToken: credential.accessToken,
83 accessTokenExpiresAt,
84 ...(credential.refreshToken !== undefined && { refreshToken: credential.refreshToken }),
85 ...(credential.scope !== undefined && { scope: credential.scope }),
86 ...(credential.raw !== undefined && { raw: credential.raw }),
87 }
88
89 await this.credentialRepo.createOrUpdateByAccountId(accountId, platform, credentialData)
90
91 await this.redis.deleteChannelCredentialCache(accountId)
92 }
93
94 @Transactional()
95 async deleteCredential(accountId: string): Promise<void> {
96 await this.deleteCredentialRecord(accountId)
97 await this.redis.deleteChannelCredentialCache(accountId)
98 }
99
100 async deleteCredentialRecord(accountId: string): Promise<void> {
101 await this.credentialRepo.deleteByAccountId(accountId)
102 }
103
104 async invalidateCredential(accountId: string): Promise<void> {
105 await this.redis.deleteChannelCredentialCache(accountId)
106 }
107
108 async lockRefresh(accountId: string): Promise<string | null> {
109 const token = randomUUID()
110 const locked = await this.redis.acquireChannelCredentialRefreshLock(accountId, token)
111 return locked ? token : null
112 }
113
114 async unlockRefresh(accountId: string, token: string): Promise<void> {
115 await this.redis.releaseChannelCredentialRefreshLock(accountId, token)
116 }
117
118 async tryRefresh(account: { id: string, type: AccountType }): Promise<RefreshedCredential | null> {
119 const lockToken = await this.lockRefresh(account.id)
120 if (!lockToken) {
121 return null
122 }
123
124 try {
125 const credential = await this.getCredential(account.id)
126 if (!credential) {
127 throw new AppException(ResponseCode.ChannelCredentialNotFound, { accountId: account.id })
128 }
129
130 const result = await this.registry.getAuth(account.type).refresh({
131 accessToken: credential.accessToken,
132 refreshToken: credential.refreshToken,
133 })
134 if (!result.accessToken) {
135 throw new AppException(ResponseCode.ChannelAccessTokenFailed)
136 }
137 const refreshToken = result.refreshToken ?? credential.refreshToken
138
139 await this.saveCredential(account.id, account.type, {
140 accessToken: result.accessToken,
141 refreshToken,
142 expiresAt: result.expiresAt,
143 scope: result.scope,
144 raw: result.raw,
145 })
146
147 return {
148 accessToken: result.accessToken,
149 refreshToken,
150 expiresAt: result.expiresAt,
151 scope: result.scope ?? credential.scope,
152 }
153 }
154 finally {
155 await this.unlockRefresh(account.id, lockToken)
156 }
157 }
158
159 async listExpiringCredentials(
160 beforeTimestamp: number,
161 limit: number,
162 cursor?: ExpiringCredentialCursor,
163 ): Promise<ExpiringCredential[]> {
164 const records = await this.credentialRepo.listByAccessTokenExpiresAtAndNormalAccount(beforeTimestamp, limit, cursor)
165 return records.map(r => ({
166 cursorId: r.cursorId,
167 accountId: r.accountId,
168 platform: r.platform,
169 accessTokenExpiresAt: r.accessTokenExpiresAt as number,
170 refreshTokenExpiresAt: r.refreshTokenExpiresAt,
171 }))
172 }
173 }
174
174 lines TYPESCRIPT