返回 AiToEarn
account.service.ts
1 import type { Account, AccountIdentity } from '@yikart/mongodb'
2 import { Injectable, Logger, Optional } from '@nestjs/common'
3 import { AccountType, AppException, ResponseCode, TableDto } from '@yikart/common'
4 import { AccountGroupRepository, AccountRepository, AccountStatus, ClientType, Transactional } from '@yikart/mongodb'
5 import { EventStream, EventStreamService, EventTopic } from '@yikart/redis'
6 import { AuthType, PlatformStatus } from '../platforms/platforms.interface'
7 import { PlatformIntegrationRegistry } from '../platforms/platforms.registry'
8 import { WeChatService } from '../platforms/wechat/wechat.service'
9 import { RelayClientService } from '../relay/relay-client.service'
10 import { ChannelAccountListQueryDto } from './account.dto'
11 import { CredentialService } from './credential.service'
12
13 interface AccountCreateInput {
14 refresh_token?: string
15 access_token?: string
16 type: AccountType
17 clientType?: Account['clientType']
18 loginCookie?: string
19 loginTime?: Date
20 uid?: string
21 account?: string
22 token?: string
23 avatar?: string
24 nickname?: string
25 fansCount?: number
26 followingCount?: number
27 readCount?: number
28 likeCount?: number
29 collectCount?: number
30 forwardCount?: number
31 commentCount?: number
32 lastStatsTime?: Date
33 workCount?: number
34 income?: number
35 groupId?: string
36 relayAccountRef?: string | null
37 }
38
39 interface AccountStatisticsUpdateData {
40 fansCount?: number
41 followingCount?: number
42 readCount?: number
43 likeCount?: number
44 collectCount?: number
45 forwardCount?: number
46 commentCount?: number
47 income?: number
48 workCount?: number
49 }
50
51 interface AccountFilter {
52 userId?: string
53 types?: AccountType[]
54 }
55
56 type NormalizedAccountCreateInput = AccountCreateInput & {
57 uid: string
58 nickname: string
59 }
60
61 @Injectable()
62 export class AccountService {
63 private readonly logger = new Logger(AccountService.name)
64
65 constructor(
66 private readonly accountRepository: AccountRepository,
67 private readonly accountGroupRepository: AccountGroupRepository,
68 private readonly platformRegistry: PlatformIntegrationRegistry,
69 private readonly eventStream: EventStreamService,
70 @Optional() private readonly credentialService?: CredentialService,
71 @Optional() private readonly wechatService?: WeChatService,
72 @Optional() private readonly relayClientService?: RelayClientService,
73 ) {}
74
75 async addAccount(userId: string, data: AccountCreateInput): Promise<Account> {
76 const account = await this.createPluginAccount(userId, data)
77 await this.emitAccountConnected(userId, account)
78 return account
79 }
80
81 @Transactional()
82 private async createPluginAccount(userId: string, data: AccountCreateInput): Promise<Account> {
83 const integration = this.platformRegistry.has(data.type)
84 ? this.platformRegistry.get(data.type)
85 : undefined
86 const status = integration?.status ?? PlatformStatus.Available
87 if (!integration || status !== PlatformStatus.Available || integration.metadata.authType !== AuthType.Plugin) {
88 throw new AppException(ResponseCode.ChannelAccountCreateNotSupported)
89 }
90
91 const normalizedData = await this.normalizeCreateInput(data)
92 const groupId = await this.resolveWritableGroupId(userId, data.groupId)
93 return this.saveWritableAccount(
94 {
95 type: normalizedData.type,
96 uid: normalizedData.uid,
97 account: normalizedData.account,
98 clientType: normalizedData.clientType,
99 },
100 {
101 ...normalizedData,
102 groupId,
103 userId,
104 status: AccountStatus.NORMAL,
105 },
106 userId,
107 )
108 }
109
110 async list(userId: string, query: ChannelAccountListQueryDto): Promise<{ total: number, list: Account[] }> {
111 if (query.ids?.length) {
112 const accounts = await this.accountRepository.listByUserIdAndIds(userId, query.ids)
113 return { total: accounts.length, list: await this.withRelayAccounts(accounts) }
114 }
115
116 if (query.spaceIds?.length) {
117 const accounts = await this.accountRepository.listBySpaceIds(userId, query.spaceIds)
118 return { total: accounts.length, list: await this.withRelayAccounts(accounts) }
119 }
120
121 if (query.types?.length || query.status !== undefined || query.groupId) {
122 const result = await this.accountRepository.listByFilterWithPagination(
123 { pageNo: 1, pageSize: 1000 },
124 {
125 userId,
126 status: query.status,
127 types: query.types,
128 groupIds: query.groupId ? [query.groupId] : undefined,
129 },
130 )
131 return {
132 total: result.total,
133 list: await this.withRelayAccounts(result.list as Account[]),
134 }
135 }
136
137 const accounts = await this.accountRepository.getUserAccounts(userId)
138 return { total: accounts.length, list: await this.withRelayAccounts(accounts) }
139 }
140
141 async getById(userId: string, accountId: string): Promise<Account> {
142 const account = await this.getOwnedAccount(userId, accountId)
143 return this.withRelayAccount(account)
144 }
145
146 @Transactional()
147 async delete(userId: string, accountId: string): Promise<boolean> {
148 await this.getOwnedAccount(userId, accountId)
149 const deleted = await this.accountRepository.deleteByIdAndUserId(accountId, userId)
150 if (deleted) {
151 await this.credentialService?.deleteCredential(accountId)
152 }
153 return deleted
154 }
155
156 @Transactional()
157 async deleteMany(userId: string, accountIds: string[]): Promise<void> {
158 const accounts = await this.accountRepository.listByUserIdAndIds(userId, accountIds)
159 const ownedAccountIds = accounts
160 .map(account => account.id || account._id)
161 .filter((accountId): accountId is string => Boolean(accountId))
162 if (!ownedAccountIds.length) {
163 return
164 }
165
166 const deleted = await this.accountRepository.deleteByUserIdAndIds(userId, ownedAccountIds)
167 if (deleted) {
168 await Promise.all(ownedAccountIds.map(accountId => this.credentialService?.deleteCredential(accountId)))
169 }
170 }
171
172 async getAuthStatus(userId: string, accountId: string): Promise<{ status: AccountStatus }> {
173 const account = await this.getById(userId, accountId)
174 return { status: account.status }
175 }
176
177 async getOwnedAccount(userId: string, accountId: string): Promise<Account> {
178 const account = await this.accountRepository.getByIdAndUserId(accountId, userId)
179 if (!account) {
180 throw new AppException(ResponseCode.AccountNotFound)
181 }
182 return account
183 }
184
185 @Transactional()
186 async updateAccountInfoById(userId: string, id: string, account: {
187 nickname?: string
188 avatar?: string
189 groupId?: string
190 }): Promise<Account> {
191 const oldInfo = await this.accountRepository.getByIdAndUserId(id, userId)
192 if (!oldInfo) {
193 throw new AppException(ResponseCode.AccountNotFound)
194 }
195
196 const update: Partial<Account> = {}
197 if (account.nickname !== undefined) {
198 update.nickname = account.nickname
199 }
200 if (account.avatar !== undefined) {
201 update.avatar = account.avatar
202 }
203 if (account.groupId !== undefined) {
204 update.groupId = await this.resolveWritableGroupId(userId, account.groupId)
205 }
206 if (Object.keys(update).length === 0) {
207 return oldInfo
208 }
209
210 const updated = await this.accountRepository.updateById(id, update)
211 if (!updated) {
212 throw new AppException(ResponseCode.AccountNotFound)
213 }
214 return updated
215 }
216
217 async getAccountById(id: string) {
218 return this.accountRepository.getAccountById(id)
219 }
220
221 async getUserAccounts(userId: string) {
222 return this.accountRepository.getUserAccounts(userId)
223 }
224
225 async getAccounts(filter: AccountFilter, pageInfo: TableDto) {
226 return this.accountRepository.getAccounts(filter, pageInfo)
227 }
228
229 async listByUserIdAndIds(userId: string, ids: string[]) {
230 return this.accountRepository.listByUserIdAndIds(userId, ids)
231 }
232
233 async getAccountListByIds(ids: string[]) {
234 return this.accountRepository.getAccountListByIds(ids)
235 }
236
237 async getAccountListByGroupId(groupId: string) {
238 return this.accountRepository.getAccountListByGroupId(groupId)
239 }
240
241 async getAccountListByUserIdAndGroupId(userId: string, groupId: string) {
242 return this.accountRepository.listByUserIdAndGroupId(userId, groupId)
243 }
244
245 async getUserAccountCount(userId: string) {
246 return this.accountRepository.getUserAccountCount(userId)
247 }
248
249 async getUserTotalFansCount(userId: string) {
250 return this.accountRepository.getByUserIdTotalFansCount(userId)
251 }
252
253 async getAccountsByIds(ids: string[]) {
254 return this.accountRepository.getAccountsByIds(ids)
255 }
256
257 async deleteUserAccount(id: string, userId: string): Promise<boolean> {
258 return this.accountRepository.deleteByIdAndUserId(id, userId)
259 }
260
261 async deleteUserAccounts(ids: string[], userId: string) {
262 return this.accountRepository.deleteByUserIdAndIds(userId, ids)
263 }
264
265 async updateAccountStatus(id: string, status: AccountStatus) {
266 return this.accountRepository.updateAccountStatus(id, status)
267 }
268
269 async updateAccountStatistics(id: string, data: AccountStatisticsUpdateData) {
270 const validData = Object.fromEntries(
271 Object.entries(data).filter(([key, value]) => {
272 if (value === undefined) {
273 return false
274 }
275 if (key === 'fansCount') {
276 return value > 0
277 }
278 return true
279 }),
280 ) as AccountStatisticsUpdateData
281
282 if (Object.keys(validData).length === 0) {
283 return false
284 }
285
286 return this.accountRepository.updateAccountStatistics(id, validData)
287 }
288
289 async createRelayAccount(userId: string, data: {
290 type: AccountType
291 uid: string
292 nickname: string
293 avatar?: string
294 relayAccountRef: string
295 groupId?: string
296 }): Promise<Account> {
297 const account = await this.createRelayAccountRecord(userId, data)
298 await this.emitAccountConnected(userId, account)
299 return account
300 }
301
302 @Transactional()
303 private async createRelayAccountRecord(userId: string, data: {
304 type: AccountType
305 uid: string
306 nickname: string
307 avatar?: string
308 relayAccountRef: string
309 groupId?: string
310 }): Promise<Account> {
311 const groupId = await this.resolveWritableGroupId(userId, data.groupId)
312 return this.saveWritableAccount(
313 { type: data.type, uid: data.uid },
314 {
315 userId,
316 type: data.type,
317 uid: data.uid,
318 nickname: data.relayAccountRef,
319 status: AccountStatus.NORMAL,
320 groupId,
321 relayAccountRef: data.relayAccountRef,
322 },
323 userId,
324 )
325 }
326
327 async getAccountByParam(param: { [key: string]: string }) {
328 return this.accountRepository.getAccountByParam(param)
329 }
330
331 async listByIds(ids: string[]) {
332 return this.accountRepository.listByIds(ids)
333 }
334
335 async listBySpaceIds(userId: string, spaceIds: string[]) {
336 return this.accountRepository.listBySpaceIds(userId, spaceIds)
337 }
338
339 async getAccountsByTypes(types: AccountType[], status?: AccountStatus) {
340 return this.accountRepository.getAccountsByTypes(types, status)
341 }
342
343 async sortRank(userId: string, groupId: string, list: { id: string, rank: number }[]) {
344 return this.accountRepository.updateManyRankByIds(userId, groupId, list)
345 }
346
347 private async resolveWritableGroupId(userId: string, groupId?: string): Promise<string> {
348 if (!groupId) {
349 const group = await this.accountGroupRepository.getDefaultGroup(userId)
350 return group.id
351 }
352
353 const groups = await this.accountGroupRepository.getAccountGorupListByIds([groupId], userId)
354 if (groups.length === 0) {
355 throw new AppException(ResponseCode.AccountGroupNotFound)
356 }
357 return groupId
358 }
359
360 private async resolveGroupId(userId: string): Promise<string> {
361 const group = await this.accountGroupRepository.getDefaultGroup(userId)
362 return group.id
363 }
364
365 private async withRelayAccount(account: Account): Promise<Account> {
366 const [resolved] = await this.withRelayAccounts([account])
367 return resolved ?? account
368 }
369
370 private async withRelayAccounts(accounts: Account[]): Promise<Account[]> {
371 const relayAccounts = accounts.filter(account => account.relayAccountRef)
372 if (relayAccounts.length === 0) {
373 return accounts
374 }
375 if (!this.relayClientService?.enabled) {
376 return accounts.map(account => account.relayAccountRef
377 ? this.toUnavailableRelayAccount(account)
378 : account)
379 }
380
381 try {
382 const result = await this.relayClientService.get<{ list?: Account[] }>(
383 '/v2/channels/accounts',
384 { ids: relayAccounts.map(account => account.relayAccountRef).filter(Boolean) },
385 )
386 const relayById = new Map((result.list ?? []).map(account => [account.id, account]))
387 return accounts.map(account => this.mergeRelayAccount(account, relayById))
388 }
389 catch (error) {
390 this.logger.error(error, 'Fetch relay accounts failed')
391 return accounts.map(account => account.relayAccountRef
392 ? this.toUnavailableRelayAccount(account)
393 : account)
394 }
395 }
396
397 private mergeRelayAccount(account: Account, relayById: Map<string, Account>): Account {
398 if (!account.relayAccountRef) {
399 return account
400 }
401
402 const relayAccount = relayById.get(account.relayAccountRef)
403 if (!relayAccount) {
404 return this.toUnavailableRelayAccount(account)
405 }
406
407 return {
408 ...relayAccount,
409 _id: account._id,
410 id: account.id,
411 userId: account.userId,
412 groupId: account.groupId,
413 relayAccountRef: account.relayAccountRef,
414 }
415 }
416
417 private toUnavailableRelayAccount(account: Account): Account {
418 const relayAccountRef = account.relayAccountRef ?? account.id
419 return {
420 ...account,
421 uid: account.uid || relayAccountRef,
422 nickname: relayAccountRef,
423 status: AccountStatus.ABNORMAL,
424 }
425 }
426
427 private async saveWritableAccount(
428 identity: AccountIdentity,
429 accountData: Partial<Account>,
430 userId: string,
431 ): Promise<Account> {
432 let account = await this.accountRepository.getByIdentity(identity)
433 let created = false
434 if (!account) {
435 account = await this.accountRepository.createByIdentity(identity, accountData)
436 created = true
437 }
438
439 if (!account) {
440 throw new AppException(ResponseCode.AccountCreateFailed)
441 }
442 if (!created && (account.userId === userId || !account.userId)) {
443 account = await this.accountRepository.updateByIdentity(identity, accountData) ?? account
444 }
445 if (account.userId !== userId) {
446 throw new AppException(ResponseCode.ChannelAccountAlreadyConnectedToAnotherUser)
447 }
448
449 return account
450 }
451
452 private async emitAccountConnected(userId: string, account: Account): Promise<void> {
453 await this.eventStream.emit(
454 EventStream.Channels,
455 EventTopic.ChannelsAccountConnected,
456 { userId, accountId: account.id, platform: account.type },
457 { source: 'account-service' },
458 )
459 }
460
461 private async normalizeCreateInput(data: AccountCreateInput): Promise<NormalizedAccountCreateInput> {
462 if (data.type === AccountType.WeChatChannels) {
463 return this.normalizeWeChatChannelsCreateInput(data)
464 }
465
466 if (!data.uid || !data.nickname) {
467 throw new AppException(ResponseCode.ChannelAccountCreateRequiredFieldMissing, {
468 fields: [
469 ...(!data.uid ? ['uid'] : []),
470 ...(!data.nickname ? ['nickname'] : []),
471 ],
472 })
473 }
474
475 const normalizedData: NormalizedAccountCreateInput = {
476 ...data,
477 uid: data.uid,
478 nickname: data.nickname,
479 }
480 if (normalizedData.type === AccountType.RedNote && normalizedData.clientType === undefined) {
481 normalizedData.clientType = ClientType.WEB
482 }
483 return normalizedData
484 }
485
486 private async normalizeWeChatChannelsCreateInput(data: AccountCreateInput): Promise<NormalizedAccountCreateInput> {
487 if (!data.loginCookie) {
488 throw new AppException(ResponseCode.ChannelAccountCreateRequiredFieldMissing, {
489 fields: ['loginCookie'],
490 })
491 }
492 if (!this.wechatService) {
493 throw new AppException(ResponseCode.ChannelAccountCreateNotSupported)
494 }
495
496 const authData = await this.wechatService.getChannelsAuthData(data.loginCookie)
497 if (!authData.uid) {
498 throw new AppException(ResponseCode.ChannelPlatformResponseInvalid)
499 }
500 if (data.uid && data.uid !== authData.uid) {
501 throw new AppException(ResponseCode.ChannelPlatformResponseInvalid)
502 }
503
504 const normalizedData = { ...data }
505 delete normalizedData.uid
506 delete normalizedData.account
507 delete (normalizedData as Record<string, unknown>)['channelId']
508
509 return {
510 ...normalizedData,
511 uid: authData.uid,
512 nickname: data.nickname ?? authData.nickname ?? authData.uid,
513 avatar: data.avatar ?? authData.avatar,
514 fansCount: data.fansCount ?? authData.fansCount,
515 followingCount: data.followingCount ?? authData.followingCount,
516 readCount: data.readCount ?? authData.readCount,
517 likeCount: data.likeCount ?? authData.likeCount,
518 collectCount: data.collectCount ?? authData.collectCount,
519 forwardCount: data.forwardCount ?? authData.forwardCount,
520 commentCount: data.commentCount ?? authData.commentCount,
521 workCount: data.workCount ?? authData.workCount,
522 lastStatsTime: data.lastStatsTime ?? new Date(),
523 }
524 }
525 }
526
526 lines TYPESCRIPT