返回 AiToEarn
image.service.ts
根目录 / project / aitoearn-backend / apps / aitoearn-ai / src / core / ai / image / image.service.ts
1 import { BadRequestException, Injectable, Logger, NotFoundException, Optional } from '@nestjs/common'
2 import { QueueService } from '@yikart/aitoearn-queue'
3 import { AssetsService } from '@yikart/assets'
4 import { AppException, getErrorMessage, getExtByMimeType, ImageType, ResponseCode, UserType } from '@yikart/common'
5 import { AiLogChannel, AiLogImageResult, AiLogRepository, AiLogStatus, AiLogType, AssetType, ImageAiLogResponse, Transactional } from '@yikart/mongodb'
6 import parseDataUri from 'data-urls'
7 import OpenAI from 'openai'
8
9 import { runWithAiGenerationRetry } from '../ai-generation-retry.util'
10 import { GeminiService } from '../libs/gemini/gemini.service'
11 import { OpenaiService } from '../libs/openai'
12 import { ModelsConfigService } from '../models-config'
13 import { RelayMediaResolverService } from '../relay-media'
14 import {
15 GeminiImageGenerationDto,
16 ImageEditDto,
17 ImageEditModelsQueryDto,
18 ImageGenerationDto,
19 ImageGenerationModelsQueryDto,
20 UserGeminiImageGenerationDto,
21 UserImageEditDto,
22 UserImageGenerationDto,
23 } from './image.dto'
24
25 type Uploadable = File | Response
26
27 @Injectable()
28 export class ImageService {
29 private readonly logger = new Logger(ImageService.name)
30
31 constructor(
32 private readonly assetsService: AssetsService,
33 private readonly openaiService: OpenaiService,
34 private readonly geminiService: GeminiService,
35 private readonly aiLogRepo: AiLogRepository,
36 private readonly modelsConfigService: ModelsConfigService,
37 private readonly queueService: QueueService,
38 @Optional() private readonly relayMediaResolver?: RelayMediaResolverService,
39 ) { }
40
41 private resolveRuntimeImageModel(model: string, kind: 'generation' | 'edit'): string {
42 const modelConfig = kind === 'generation'
43 ? this.modelsConfigService.config.image.generation.find(item => item.name === model)
44 : this.modelsConfigService.config.image.edit.find(item => item.name === model)
45
46 return modelConfig?.runtimeModel ?? model
47 }
48
49 private getImageModelRetry(model: string, kind: 'generation' | 'edit'): number {
50 const modelConfig = kind === 'generation'
51 ? this.modelsConfigService.config.image.generation.find(item => item.name === model)
52 : this.modelsConfigService.config.image.edit.find(item => item.name === model)
53
54 return modelConfig?.retry ?? 0
55 }
56
57 /**
58 * 将 data uri 转换为 Uploadable
59 */
60 private getUploadableByDataUri(dataUri: string, filename = 'image'): Uploadable {
61 const file = parseDataUri(dataUri)
62 if (file == null) {
63 throw new BadRequestException('Invalid data URI')
64 }
65 const ext = getExtByMimeType(file.mimeType.essence as ImageType)
66
67 return new File([file.body as Uint8Array<ArrayBuffer>], `${filename}.${ext}`, { type: file.mimeType.essence })
68 }
69
70 /**
71 * 将 URL 转换为 Uploadable
72 */
73 private async getUploadableByUrl(url: string): Promise<Uploadable> {
74 return await fetch(url)
75 }
76
77 /**
78 * 将 URL 或 Data URI 转换为 Uploadable
79 */
80 private async getUploadableByUrlOrDataUri(urlOrDataUri: string, filename = 'image'): Promise<Uploadable> {
81 if (/^https?:\/\//.test(urlOrDataUri)) {
82 return await this.getUploadableByUrl(urlOrDataUri)
83 }
84 return this.getUploadableByDataUri(urlOrDataUri, filename)
85 }
86
87 private async resolveRelayText(text: string): Promise<string> {
88 if (!this.relayMediaResolver) {
89 return text
90 }
91 return await this.relayMediaResolver.resolveText(text)
92 }
93
94 /**
95 * 上传图片到S3并返回路径
96 */
97 private async uploadImageToS3(imageUrlOrResponse: string | Response, userId: string, subPath?: string): Promise<string> {
98 if (typeof imageUrlOrResponse === 'string') {
99 const result = await this.assetsService.uploadFromUrl(userId, {
100 url: imageUrlOrResponse,
101 type: AssetType.AiImage,
102 }, subPath)
103 return result.asset.path
104 }
105 else {
106 const contentType = imageUrlOrResponse.headers.get('content-type') || 'image/png'
107 const buffer = Buffer.from(await imageUrlOrResponse.arrayBuffer())
108 const result = await this.assetsService.uploadFromBuffer(userId, buffer, {
109 type: AssetType.AiImage,
110 mimeType: contentType,
111 }, subPath)
112 return result.asset.path
113 }
114 }
115
116 /**
117 * 图片生成
118 */
119 async generation(request: ImageGenerationDto) {
120 const { user, ...params } = request
121 const runtimeModel = this.resolveRuntimeImageModel(params.model, 'generation')
122
123 if (!user) {
124 throw new BadRequestException('userId is required')
125 }
126
127 if (runtimeModel === 'gpt-image-1') {
128 delete params.response_format
129 delete params.style
130 }
131
132 const result = await this.openaiService.createImageGeneration({
133 ...params,
134 model: runtimeModel,
135 } as Omit<OpenAI.Images.ImageGenerateParams, 'user'> & { apiKey?: string })
136
137 for (const image of result.data || []) {
138 if (image.url) {
139 image.url = await this.uploadImageToS3(image.url, user, `${request.model}`)
140 }
141 if (image.b64_json) {
142 const mimeType = `image/${result.output_format || 'png'}`
143 const buffer = Buffer.from(image.b64_json, 'base64')
144 const uploadResult = await this.assetsService.uploadFromBuffer(user, buffer, {
145 type: AssetType.AiImage,
146 mimeType,
147 }, `${request.model}`)
148 image.url = uploadResult.asset.path
149 delete image.b64_json
150 }
151 }
152
153 return {
154 ...result,
155 list: result.data || [],
156 }
157 }
158
159 /**
160 * 图片编辑
161 */
162 async edit(request: ImageEditDto) {
163 const { image, mask, user, ...params } = request
164 const runtimeModel = this.resolveRuntimeImageModel(params.model, 'edit')
165
166 let imageFile: Uploadable | Uploadable[]
167 if (Array.isArray(image)) {
168 imageFile = await Promise.all(image.map(async (img, index) =>
169 this.getUploadableByUrlOrDataUri(await this.resolveRelayText(img), `image-${index}`),
170 ))
171 }
172 else {
173 imageFile = await this.getUploadableByUrlOrDataUri(await this.resolveRelayText(image), 'image')
174 }
175
176 const maskFile = mask ? await this.getUploadableByUrlOrDataUri(await this.resolveRelayText(mask), 'mask') : undefined
177
178 if (runtimeModel === 'gpt-image-1') {
179 delete params.response_format
180 }
181 const imageResult = await this.openaiService.createImageEdit({
182 ...params,
183 model: runtimeModel,
184 image: imageFile,
185 mask: maskFile,
186 size: params.size as 'auto',
187 })
188
189 for (const image of imageResult.data || []) {
190 if (image.url) {
191 image.url = await this.uploadImageToS3(image.url, user!, `${request.model}`)
192 }
193 if (image.b64_json) {
194 const mimeType = 'image/png'
195 const buffer = Buffer.from(image.b64_json, 'base64')
196 const uploadResult = await this.assetsService.uploadFromBuffer(user!, buffer, {
197 type: AssetType.AiImage,
198 mimeType,
199 }, `${request.model}`)
200 image.url = uploadResult.asset.path
201 delete image.b64_json
202 }
203 }
204
205 return {
206 created: imageResult.created,
207 list: imageResult.data || [],
208 usage: imageResult.usage,
209 }
210 }
211
212 /**
213 * Gemini 图片生成
214 */
215 async geminiGeneration(userId: string, request: GeminiImageGenerationDto & { model: 'gemini-3.1-flash-image-preview' | 'gemini-3-pro-image-preview' }) {
216 const { model } = request
217 const result = await this.geminiService.generateImage({
218 prompt: request.prompt,
219 imageUrls: request.imageUrls,
220 imageSize: request.imageSize,
221 aspectRatio: request.aspectRatio,
222 model,
223 })
224
225 const images: { url: string, data: string, mimeType: string }[] = []
226 for (const image of result.images) {
227 const uploadResult = await this.assetsService.uploadFromBuffer(userId, image.imageData, {
228 type: AssetType.AiImage,
229 mimeType: image.mimeType,
230 }, `${model}`)
231 images.push({ url: uploadResult.asset.path, data: image.imageData.toString('base64'), mimeType: image.mimeType })
232 }
233
234 return {
235 images,
236 usage: result.usage,
237 }
238 }
239
240 /**
241 * 用户 Gemini 图片生成
242 */
243 async userGeminiGeneration(request: UserGeminiImageGenerationDto) {
244 const { userId, userType, model: requestedModel, ...params } = request
245 const model = requestedModel || 'gemini-3.1-flash-image-preview'
246
247 const modelConfig = await this.getGeminiImageModelConfig(model)
248 if (!modelConfig) {
249 throw new AppException(ResponseCode.InvalidModel)
250 }
251
252 const startedAt = new Date()
253 const result = await this.geminiGeneration(userId, { ...params, model })
254
255 const usage = result.usage || { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0 }
256
257 const duration = Date.now() - startedAt.getTime()
258
259 await this.aiLogRepo.create({
260 userId,
261 userType,
262 model,
263 channel: AiLogChannel.Gemini,
264 type: AiLogType.Image,
265 request: params,
266 response: { ...result, data: void 0 },
267 status: AiLogStatus.Success,
268 startedAt,
269 duration,
270 })
271
272 return {
273 ...result,
274 usage: {
275 input_tokens: usage.promptTokenCount,
276 output_tokens: usage.candidatesTokenCount,
277 total_tokens: usage.totalTokenCount,
278 input_token_details: usage.inputTokenDetails,
279 output_token_details: usage.outputTokenDetails,
280 },
281 }
282 }
283
284 /**
285 * 获取 Gemini 图片模型配置
286 */
287 private async getGeminiImageModelConfig(
288 model: 'gemini-3.1-flash-image-preview' | 'gemini-3-pro-image-preview',
289 ) {
290 const chatModels = this.modelsConfigService.config.chat
291 const modelConfig = chatModels.find(m => m.name === model)
292 if (!modelConfig) {
293 return null
294 }
295
296 return modelConfig
297 }
298
299 /**
300 * 统一的用户请求处理:日志记录和状态更新
301 */
302 private async handleUserAiAction<T>(opts: {
303 userId: string
304 userType: UserType
305 model: string
306 channel?: AiLogChannel
307 type: AiLogType
308 request: Record<string, unknown>
309 retry?: number
310 run: () => Promise<T>
311 }): Promise<T> {
312 const { userId, userType, model, channel, type, request, retry, run } = opts
313 const startedAt = new Date()
314
315 const log = await this.aiLogRepo.create({
316 userId,
317 userType,
318 model,
319 channel: channel ?? AiLogChannel.NewApi,
320 type,
321 request,
322 status: AiLogStatus.Generating,
323 startedAt,
324 })
325
326 const result = await runWithAiGenerationRetry(
327 run,
328 retry,
329 (error, attempt, maxAttempts) => {
330 this.logger.warn(
331 { error, model, attempt, maxAttempts },
332 'AI image generation failed, retrying',
333 )
334 },
335 ).catch(async (e) => {
336 const duration = Date.now() - startedAt.getTime()
337 const errorMessage = getErrorMessage(e)
338
339 await this.aiLogRepo.updateById(log.id, {
340 duration,
341 status: AiLogStatus.Failed,
342 errorMessage,
343 })
344 throw e
345 })
346 const duration = Date.now() - startedAt.getTime()
347
348 await this.aiLogRepo.updateById(log.id, {
349 duration,
350 status: AiLogStatus.Success,
351 response: result as Record<string, unknown>,
352 })
353
354 return result
355 }
356
357 /**
358 * 用户图片生成
359 */
360 async userGeneration(request: UserImageGenerationDto) {
361 const { userId, userType, ...params } = request
362
363 return await this.handleUserAiAction({
364 userId,
365 userType,
366 model: params.model,
367 type: AiLogType.Image,
368 request: params,
369 retry: this.getImageModelRetry(params.model, 'generation'),
370 run: () => this.generation({ ...params, user: userId }),
371 })
372 }
373
374 /**
375 * 用户图片编辑
376 */
377 async userEdit(request: UserImageEditDto) {
378 const { userId, userType, ...params } = request
379
380 return await this.handleUserAiAction({
381 userId,
382 userType,
383 model: params.model,
384 type: AiLogType.Image,
385 request: params,
386 retry: this.getImageModelRetry(params.model, 'edit'),
387 run: () => this.edit({ ...params, user: userId }),
388 })
389 }
390
391 /**
392 * 获取图片生成模型参数
393 * @param data 查询参数,包含可选的 userId 和 userType,可用于后续个性化模型推荐
394 */
395 async generationModelConfig(_data: ImageGenerationModelsQueryDto) {
396 return this.modelsConfigService.config.image.generation
397 }
398
399 /**
400 * 获取图片编辑模型参数
401 * @param data 查询参数,包含可选的 userId 和 userType,可用于后续个性化模型推荐
402 */
403 async editModelConfig(_data: ImageEditModelsQueryDto) {
404 return this.modelsConfigService.config.image.edit
405 }
406
407 /**
408 * 异步图片生成
409 */
410 @Transactional()
411 async userGenerationAsync(request: UserImageGenerationDto) {
412 const { userId, userType, ...params } = request
413
414 // 创建 AiLog 记录
415 const log = await this.aiLogRepo.create({
416 userId,
417 userType,
418 model: params.model,
419 channel: AiLogChannel.NewApi,
420 type: AiLogType.Image,
421 request: params,
422 status: AiLogStatus.Generating,
423 startedAt: new Date(),
424 })
425
426 // 添加队列任务
427 await this.queueService.addAiImageAsyncJob({
428 logId: log.id,
429 userId,
430 userType,
431 model: params.model,
432 channel: AiLogChannel.NewApi,
433 type: AiLogType.Image,
434 retry: this.getImageModelRetry(params.model, 'generation'),
435 request: { ...params, user: userId },
436 taskType: 'generation',
437 })
438
439 return {
440 logId: log.id,
441 status: AiLogStatus.Generating,
442 }
443 }
444
445 /**
446 * 异步图片编辑
447 */
448 @Transactional()
449 async userEditAsync(request: UserImageEditDto) {
450 const { userId, userType, ...params } = request
451
452 // 创建 AiLog 记录
453 const log = await this.aiLogRepo.create({
454 userId,
455 userType,
456 model: params.model,
457 channel: AiLogChannel.NewApi,
458 type: AiLogType.Image,
459 request: params,
460 status: AiLogStatus.Generating,
461 startedAt: new Date(),
462 })
463
464 // 添加队列任务
465 await this.queueService.addAiImageAsyncJob({
466 logId: log.id,
467 userId,
468 userType,
469 model: params.model,
470 channel: AiLogChannel.NewApi,
471 type: AiLogType.Image,
472 retry: this.getImageModelRetry(params.model, 'edit'),
473 request: { ...params, user: userId },
474 taskType: 'edit',
475 })
476
477 return {
478 logId: log.id,
479 status: AiLogStatus.Generating,
480 }
481 }
482
483 /**
484 * 查询任务状态
485 */
486 async getTaskStatus(logId: string) {
487 const log = await this.aiLogRepo.getById(logId)
488 if (!log || log.type !== AiLogType.Image) {
489 throw new NotFoundException('任务不存在')
490 }
491 const response = log.response as ImageAiLogResponse | undefined
492
493 // 提取图片信息
494 let images: AiLogImageResult[] | undefined
495 if (response?.list?.length) {
496 images = response.list
497 }
498 else if (response?.images?.length) {
499 images = response.images
500 }
501 else if (response?.image) {
502 images = [{ url: response.image }]
503 }
504 else if (response?.imageUrl) {
505 images = [{ url: response.imageUrl }]
506 }
507
508 return {
509 logId: log.id,
510 status: log.status,
511 startedAt: log.startedAt,
512 duration: log.duration,
513 request: log.request,
514 response,
515 images,
516 errorMessage: log.errorMessage,
517 createdAt: log.createdAt,
518 updatedAt: log.updatedAt,
519 }
520 }
521 }
522
522 lines TYPESCRIPT