返回 AiToEarn
relay-media-resolver.service.ts
根目录 / project / aitoearn-backend / apps / aitoearn-ai / src / core / ai / relay-media / relay-media-resolver.service.ts
1 import type { AssetsConfig } from '@yikart/assets'
2 import { basename } from 'node:path'
3 import { Inject, Injectable, Logger, Optional } from '@nestjs/common'
4 import { ASSETS_CONFIG } from '@yikart/assets'
5 import { AssetType } from '@yikart/mongodb'
6 import axios, { AxiosInstance } from 'axios'
7 import { RelayConfig } from '../libs/relay/relay.config'
8
9 interface UploadSignResult {
10 id: string
11 url: string
12 uploadUrl: string
13 }
14
15 interface RelayCommonResponse<T> {
16 code?: number
17 message?: string
18 data: T
19 }
20
21 @Injectable()
22 export class RelayMediaResolverService {
23 private readonly logger = new Logger(RelayMediaResolverService.name)
24 private readonly httpClient: AxiosInstance | undefined
25 private readonly localUrlPrefixes: string[]
26
27 constructor(
28 @Optional() private readonly config?: RelayConfig,
29 @Optional() @Inject(ASSETS_CONFIG) private readonly assetsConfig?: AssetsConfig,
30 ) {
31 this.localUrlPrefixes = []
32 if (assetsConfig?.endpoint) {
33 this.localUrlPrefixes.push(assetsConfig.endpoint.replace(/\/+$/, ''))
34 }
35 if (assetsConfig?.cdnEndpoint) {
36 this.localUrlPrefixes.push(assetsConfig.cdnEndpoint.replace(/\/+$/, ''))
37 }
38
39 if (!config) {
40 return
41 }
42
43 this.httpClient = axios.create({
44 baseURL: config.url,
45 timeout: config.timeout,
46 headers: {
47 'x-api-key': config.apiKey,
48 },
49 })
50 }
51
52 async resolveText(text: string): Promise<string> {
53 if (!this.httpClient || !text || this.localUrlPrefixes.length === 0) {
54 return text
55 }
56
57 const localUrls = this.extractLocalUrls(text)
58 if (localUrls.length === 0) {
59 return text
60 }
61
62 const uploadedByDownloadUrl = new Map<string, string>()
63 let resolved = text
64 for (const localUrl of localUrls) {
65 const downloadUrl = this.toDownloadUrl(localUrl)
66 if (!uploadedByDownloadUrl.has(downloadUrl)) {
67 uploadedByDownloadUrl.set(downloadUrl, await this.uploadFileFromLocalUrl(downloadUrl))
68 }
69 const uploadedUrl = uploadedByDownloadUrl.get(downloadUrl)
70 if (uploadedUrl) {
71 resolved = resolved.split(localUrl).join(uploadedUrl)
72 }
73 }
74 return resolved
75 }
76
77 async resolveJson<T>(value: T): Promise<T> {
78 if (!this.httpClient || value == null) {
79 return value
80 }
81
82 if (typeof value === 'string') {
83 return await this.resolveText(value) as T
84 }
85
86 const serialized = JSON.stringify(value)
87 if (!serialized) {
88 return value
89 }
90
91 const resolved = await this.resolveText(serialized)
92 return resolved === serialized
93 ? value
94 : JSON.parse(resolved) as T
95 }
96
97 private extractLocalUrls(text: string): string[] {
98 if (this.localUrlPrefixes.length === 0) {
99 return []
100 }
101
102 const urlPattern = new RegExp(
103 `(${this.localUrlPrefixes.map(prefix => this.escapeRegExp(prefix)).join('|')})/[^"\\s]+`,
104 'g',
105 )
106 return [...new Set(text.match(urlPattern) || [])]
107 }
108
109 private toDownloadUrl(localUrl: string): string {
110 const cdnPrefix = this.assetsConfig?.cdnEndpoint?.replace(/\/+$/, '')
111 const endpointPrefix = this.assetsConfig?.endpoint?.replace(/\/+$/, '')
112 if (cdnPrefix && endpointPrefix && localUrl.startsWith(cdnPrefix)) {
113 const bucket = this.assetsConfig && 'bucketName' in this.assetsConfig
114 ? String(this.assetsConfig.bucketName || '')
115 : ''
116 const internalBase = `${endpointPrefix}${bucket ? `/${bucket}` : ''}`
117 return localUrl.replace(cdnPrefix, internalBase)
118 }
119 return localUrl
120 }
121
122 private async post<T>(url: string, data: unknown): Promise<T> {
123 const response = await this.httpClient!.post<RelayCommonResponse<T> | T>(url, data)
124 const body = response.data
125
126 if (this.isCommonResponse(body)) {
127 if (body.code != null && body.code !== 0) {
128 throw new Error(`Relay API error [${body.code}]: ${body.message}`)
129 }
130 return body.data
131 }
132
133 return body
134 }
135
136 private isCommonResponse<T>(body: RelayCommonResponse<T> | T): body is RelayCommonResponse<T> {
137 return typeof body === 'object' && body !== null && 'data' in body
138 }
139
140 private async uploadFileFromLocalUrl(localUrl: string): Promise<string> {
141 const filename = basename(new URL(localUrl).pathname)
142
143 const fileResponse = await axios.get(localUrl, { responseType: 'arraybuffer' })
144 const contentType = fileResponse.headers['content-type'] || 'application/octet-stream'
145 const size = (fileResponse.data as ArrayBuffer).byteLength
146
147 const signResult = await this.post<UploadSignResult>('/api/assets/uploadSign', {
148 filename,
149 type: AssetType.Temp,
150 size,
151 })
152
153 if (!signResult.uploadUrl) {
154 throw new Error(`Relay uploadSign returned no uploadUrl: ${JSON.stringify(signResult)}`)
155 }
156
157 await axios.put(signResult.uploadUrl, fileResponse.data, {
158 headers: { 'Content-Type': contentType },
159 timeout: this.config?.timeout,
160 })
161
162 await this.post(`/api/assets/${signResult.id}/confirm`, {})
163 this.logger.debug({ localUrl, relayUrl: signResult.url }, 'Uploaded local media to relay')
164 return signResult.url
165 }
166
167 private escapeRegExp(value: string): string {
168 return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
169 }
170 }
171
171 lines TYPESCRIPT