返回 AiToEarn
work.service.ts
1 import type { AccountType } from '@yikart/common'
2 import type {
3 ChannelPaginationInput,
4 ChannelWorkDataSnapshotPayload,
5 WorkDetailInput,
6 WorkLinkInfoInput,
7 WorkListInput,
8 WorkOwnershipInput,
9 } from '../platforms/platforms.interface'
10 import { Injectable } from '@nestjs/common'
11 import { ChannelWorkDataSnapshotRepository } from '@yikart/channel-db'
12 import { AppException, ResponseCode } from '@yikart/common'
13 import { AccountRepository } from '@yikart/mongodb'
14 import { AuthService } from '../auth/auth.service'
15 import { normalizeChannelPagination } from '../platforms/platform-pagination.helper'
16 import { AuthType, ChannelPaginationMode } from '../platforms/platforms.interface'
17 import { PlatformIntegrationRegistry } from '../platforms/platforms.registry'
18 import { RelayAccountException } from '../relay/relay-account.exception'
19
20 @Injectable()
21 export class WorkService {
22 constructor(
23 private readonly registry: PlatformIntegrationRegistry,
24 private readonly authService: AuthService,
25 private readonly accountRepository: AccountRepository,
26 private readonly workSnapshotRepository: ChannelWorkDataSnapshotRepository,
27 ) {}
28
29 async getLinkInfo(
30 userId: string,
31 platform: AccountType,
32 link: string,
33 accountId?: string,
34 dataId?: string,
35 ) {
36 return this.resolveLinkInfo(userId, platform, link, accountId, dataId)
37 }
38
39 async parseLinkInfo(platform: AccountType, link: string) {
40 return this.resolveLinkInfo(undefined, platform, link)
41 }
42
43 async listWorks(
44 userId: string,
45 platform: AccountType,
46 accountId: string,
47 pagination: ChannelPaginationInput,
48 ) {
49 const provider = this.registry.getWork(platform)
50 if (!provider?.listWorks) {
51 throw new AppException(ResponseCode.ChannelPlatformOperationNotSupported, {
52 platform,
53 capability: 'work.listWorks',
54 })
55 }
56
57 const normalizedPagination = normalizeChannelPagination(
58 platform,
59 provider.listWorksPagination ?? { mode: ChannelPaginationMode.None },
60 pagination,
61 )
62 const credential = await this.getPlatformCredential(userId, accountId, platform)
63
64 const input: WorkListInput = {
65 accountId,
66 platform,
67 credential,
68 pagination: normalizedPagination,
69 }
70 const result = await this.callPlatformProvider(accountId, () => provider.listWorks!(input))
71 return {
72 platform,
73 items: result.items,
74 pagination: result.pagination,
75 }
76 }
77
78 private async resolveLinkInfo(
79 userId: string | undefined,
80 platform: AccountType,
81 link: string,
82 accountId?: string,
83 dataId?: string,
84 ) {
85 const integration = this.registry.get(platform)
86 const provider = integration.work
87 if (!provider?.getLinkInfo) {
88 return { platform, snapshots: [], message: 'Link info not supported' }
89 }
90
91 const needsAccountContext = provider.requiresCredentialForLinkInfo !== false
92 if (!accountId && needsAccountContext) {
93 return { platform, snapshots: [], message: 'Account required' }
94 }
95
96 const shouldLoadCredential = Boolean(
97 accountId
98 && integration.metadata.authType !== AuthType.Plugin,
99 )
100
101 const credential = shouldLoadCredential && accountId
102 ? await this.getPlatformCredential(userId ?? '', accountId, platform)
103 : undefined
104 if (accountId && !credential) {
105 await this.getPlatformAccount(userId ?? '', accountId, platform)
106 }
107
108 const input: WorkLinkInfoInput = {
109 accountId,
110 platform,
111 credential: credential || undefined,
112 link,
113 ...(dataId ? { dataId } : {}),
114 }
115
116 const result = await this.callPlatformProvider(accountId, () => provider.getLinkInfo!(input))
117 const savedSnapshots = await this.saveWorkSnapshots(
118 userId,
119 platform,
120 result.work?.id,
121 accountId,
122 result.snapshots,
123 result.rawResponse,
124 )
125 const latestSnapshot = savedSnapshots[savedSnapshots.length - 1]
126
127 return {
128 platform,
129 work: latestSnapshot?.work ?? result.work,
130 snapshots: savedSnapshots,
131 extra: latestSnapshot?.extra ?? result.extra,
132 snapshotId: latestSnapshot?.id,
133 fetchedAt: latestSnapshot?.fetchedAt,
134 }
135 }
136
137 async getDetail(
138 userId: string | undefined,
139 platform: AccountType,
140 platformWorkId: string,
141 accountId?: string,
142 ) {
143 const provider = this.registry.getWork(platform)
144 if (!provider?.getDetail) {
145 throw new AppException(ResponseCode.ChannelPlatformOperationNotSupported, {
146 platform,
147 capability: 'work.getDetail',
148 })
149 }
150
151 if (!accountId) {
152 throw new AppException(ResponseCode.AccountAuthRequired, { platform })
153 }
154
155 const credential = await this.getPlatformCredential(userId ?? '', accountId, platform)
156
157 const input: WorkDetailInput = {
158 accountId,
159 platformWorkId,
160 platform,
161 credential,
162 }
163
164 const result = await this.callPlatformProvider(accountId, () => provider.getDetail!(input))
165 const savedSnapshots = await this.saveWorkSnapshots(
166 userId,
167 platform,
168 platformWorkId,
169 accountId,
170 result.snapshots,
171 result.rawResponse,
172 )
173 const latestSnapshot = savedSnapshots[savedSnapshots.length - 1]
174
175 return {
176 platform,
177 work: latestSnapshot?.work ?? result.work,
178 snapshots: savedSnapshots,
179 extra: latestSnapshot?.extra ?? result.extra,
180 snapshotId: latestSnapshot?.id,
181 fetchedAt: latestSnapshot?.fetchedAt,
182 }
183 }
184
185 async verifyOwnership(
186 userId: string,
187 platform: AccountType,
188 platformWorkId: string,
189 candidateAccountId: string,
190 ) {
191 const provider = this.registry.getWork(platform)
192 if (!provider?.verifyOwnership) {
193 return { platform, owned: false, message: 'Ownership verification not supported' }
194 }
195
196 const credential = await this.getPlatformCredential(userId, candidateAccountId, platform)
197
198 const input: WorkOwnershipInput = {
199 accountId: candidateAccountId,
200 platformWorkId,
201 platform,
202 credential,
203 }
204
205 const owned = await this.callPlatformProvider(candidateAccountId, () => provider.verifyOwnership!(input))
206 return { platform, owned }
207 }
208
209 private async getPlatformCredential(userId: string, accountId: string, platform: AccountType) {
210 const account = await this.getPlatformAccount(userId, accountId, platform)
211 const credential = await this.authService.getValidCredential(accountId, userId)
212 return {
213 accessToken: credential.accessToken,
214 refreshToken: credential.refreshToken,
215 platformUid: account.uid,
216 account: account.account,
217 }
218 }
219
220 private async getPlatformAccount(userId: string, accountId: string, platform: AccountType) {
221 const account = await this.accountRepository.getByIdAndUserId(accountId, userId)
222 if (!account) {
223 throw new AppException(ResponseCode.AccountNotFound)
224 }
225 if (account.type !== platform) {
226 throw new AppException(ResponseCode.ChannelAuthPlatformMismatch)
227 }
228 if (account.relayAccountRef) {
229 throw new RelayAccountException(account.relayAccountRef, accountId)
230 }
231 return account
232 }
233
234 private async callPlatformProvider<T>(accountId: string | undefined, action: () => Promise<T>): Promise<T> {
235 try {
236 return await action()
237 }
238 catch (error) {
239 if (accountId) {
240 await this.authService.markAccountOfflineForCredentialFailure(accountId, error, 'platform_auth_failed')
241 }
242 throw error
243 }
244 }
245
246 private async saveWorkSnapshots(
247 userId: string | undefined,
248 platform: AccountType,
249 fallbackPlatformWorkId: string | undefined,
250 accountId: string | undefined,
251 snapshots: ChannelWorkDataSnapshotPayload[],
252 rawResponse?: unknown,
253 ) {
254 if (snapshots.length === 0) {
255 return []
256 }
257 if (!userId) {
258 return []
259 }
260 return this.workSnapshotRepository.createMany(
261 snapshots.map(snapshot => ({
262 userId,
263 platform,
264 accountId,
265 platformWorkId: snapshot.platformWorkId ?? fallbackPlatformWorkId ?? snapshot.work.id,
266 snapshotAt: snapshot.snapshotAt,
267 fetchedAt: snapshot.fetchedAt ?? new Date(),
268 periodStartAt: snapshot.periodStartAt,
269 periodEndAt: snapshot.periodEndAt,
270 work: snapshot.work,
271 metrics: snapshot.metrics,
272 extra: snapshot.extra,
273 rawResponse: snapshot.rawResponse ?? rawResponse,
274 })),
275 )
276 }
277 }
278
278 lines TYPESCRIPT