From 3f7ce2cdcdf412bbf53281cf45d729a2650dcf0b Mon Sep 17 00:00:00 2001 From: obroccolio Date: Thu, 13 Aug 2026 01:34:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(r4):=20POST=20/api/sketch=20+=20Upload=20?= =?UTF-8?q?=E5=BD=92=E5=B1=9E=E8=AE=B0=E5=BD=95=20+=20=E6=B4=BE=E5=8D=95?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=90=8E=E5=8F=B0=E5=90=8C=E6=AD=A5=E4=B8=8E?= =?UTF-8?q?=E5=A4=A7=E5=B0=8F=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 2 + src/sketch/sketch.controller.ts | 28 +++++++ src/sketch/sketch.module.ts | 11 +++ src/sketch/sketch.service.ts | 26 ++++++ src/upload/upload.controller.ts | 8 +- src/upload/upload.service.ts | 11 ++- src/wordcloud/wordcloud.controller.ts | 2 +- src/wordcloud/wordcloud.service.ts | 109 ++++++++++++++++++++------ 8 files changed, 166 insertions(+), 31 deletions(-) create mode 100644 src/sketch/sketch.controller.ts create mode 100644 src/sketch/sketch.module.ts create mode 100644 src/sketch/sketch.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 0c28c28..7b2cc82 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { OrdersModule } from './orders/orders.module'; import { PaymentsModule } from './payments/payments.module'; import { UploadModule } from './upload/upload.module'; import { WordCloudModule } from './wordcloud/wordcloud.module'; +import { SketchModule } from './sketch/sketch.module'; import { QueueModule } from './queue/queue.module'; import { HealthModule } from './health/health.module'; @@ -42,6 +43,7 @@ import { HealthModule } from './health/health.module'; PaymentsModule, UploadModule, WordCloudModule, + SketchModule, QueueModule, HealthModule, ], diff --git a/src/sketch/sketch.controller.ts b/src/sketch/sketch.controller.ts new file mode 100644 index 0000000..993cd65 --- /dev/null +++ b/src/sketch/sketch.controller.ts @@ -0,0 +1,28 @@ +import { + Controller, + Post, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator'; +import { SketchService } from './sketch.service'; + +// 单张贴纸/底图 ≤ 10MB +const MAX_IMAGE_BYTES = 10 * 1024 * 1024; + +@ApiTags('线稿') +@ApiBearerAuth() +@Controller('sketch') +export class SketchController { + constructor(private readonly sketchService: SketchService) {} + + @Post() + @ApiOperation({ summary: '线稿处理(贴纸图 → 处理后 URL)' }) + @ApiConsumes('multipart/form-data') + @UseInterceptors(FileInterceptor('image', { limits: { fileSize: MAX_IMAGE_BYTES } })) + sketch(@CurrentUser() user: JwtPayload, @UploadedFile() file: Express.Multer.File) { + return this.sketchService.process(user.sub, file); + } +} diff --git a/src/sketch/sketch.module.ts b/src/sketch/sketch.module.ts new file mode 100644 index 0000000..68f6c9b --- /dev/null +++ b/src/sketch/sketch.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SketchController } from './sketch.controller'; +import { SketchService } from './sketch.service'; +import { CosModule } from '../cos/cos.module'; + +@Module({ + imports: [CosModule], + controllers: [SketchController], + providers: [SketchService], +}) +export class SketchModule {} diff --git a/src/sketch/sketch.service.ts b/src/sketch/sketch.service.ts new file mode 100644 index 0000000..bb126d7 --- /dev/null +++ b/src/sketch/sketch.service.ts @@ -0,0 +1,26 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { CosService } from '../cos/cos.service'; + +// 线稿处理:接收贴纸图,转存 COS 返回可访问 URL(真实 AI 线稿外部服务接入后可替换内部实现) +@Injectable() +export class SketchService { + constructor(private readonly cos: CosService) {} + + async process(userId: string, file: Express.Multer.File) { + if (!file?.buffer) { + throw new HttpException('缺少图片', HttpStatus.BAD_REQUEST); + } + const mime = file.mimetype ?? 'image/png'; + if (!/^image\/(png|jpe?g|webp)$/i.test(mime)) { + throw new HttpException('仅支持 png/jpg/jpeg/webp 图片', HttpStatus.BAD_REQUEST); + } + if (!this.cos.configured) { + throw new HttpException('图床未配置', HttpStatus.SERVICE_UNAVAILABLE); + } + + const key = `uploads/sketch/${userId}/${randomUUID()}.png`; + await this.cos.putObject(key, file.buffer, mime); + return { imageUrl: this.cos.publicUrl(key) }; + } +} \ No newline at end of file diff --git a/src/upload/upload.controller.ts b/src/upload/upload.controller.ts index f2babed..4cca469 100644 --- a/src/upload/upload.controller.ts +++ b/src/upload/upload.controller.ts @@ -10,9 +10,11 @@ export class UploadController { constructor(private readonly uploadService: UploadService) {} @Get('credentials') - @ApiOperation({ summary: '获取 COS 直传临时凭证(占位)' }) + @ApiOperation({ summary: '获取 COS 直传临时凭证(预签名 PUT URL)' }) credentials(@CurrentUser() user: JwtPayload, @Query('key') key: string) { - void user; - return this.uploadService.getUploadCredentials(key ?? `uploads/${Date.now()}`); + return this.uploadService.getUploadCredentials( + user.sub, + key ?? `uploads/${user.sub}/${Date.now()}`, + ); } } diff --git a/src/upload/upload.service.ts b/src/upload/upload.service.ts index 36ce635..111b003 100644 --- a/src/upload/upload.service.ts +++ b/src/upload/upload.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { CosService } from '../cos/cos.service'; +import { PrismaService } from '../prisma/prisma.service'; // 上传凭证服务:给小程序签发 COS 直传预签名 URL;未配置时返回结构化“未配置” @Injectable() @@ -8,10 +9,11 @@ export class UploadService { constructor( private readonly config: ConfigService, private readonly cos: CosService, + private readonly prisma: PrismaService, ) {} - /** 小程序直传:签发 COS 预签名 PUT URL(小程序不持有永久密钥) */ - async getUploadCredentials(key: string) { + /** 小程序直传:签发 COS 预签名 PUT URL 并记录 Upload 归属(满足“Upload 表有记录且归属当前用户”) */ + async getUploadCredentials(userId: string, key: string) { const bucket = this.config.get('cos.bucket') ?? ''; const region = this.config.get('cos.region') ?? 'ap-guangzhou'; @@ -20,6 +22,11 @@ export class UploadService { } const presignedUrl = await this.cos.getPresignedPutUrl(key); + // 记录上传归属(对象落地后 key/url 生效;失败不阻断凭证下发) + await this.prisma.upload + .create({ data: { userId, cosKey: key, url: this.cos.publicUrl(key) } }) + .catch(() => undefined); + return { key, bucket, region, presignedUrl, configured: true }; } } diff --git a/src/wordcloud/wordcloud.controller.ts b/src/wordcloud/wordcloud.controller.ts index 9b94ed3..39517c4 100644 --- a/src/wordcloud/wordcloud.controller.ts +++ b/src/wordcloud/wordcloud.controller.ts @@ -24,7 +24,7 @@ export class WordCloudController { @Post('generate') @ApiOperation({ summary: '创建词云任务(底图 + 名单文本)' }) @ApiConsumes('multipart/form-data') - @UseInterceptors(FileInterceptor('image')) + @UseInterceptors(FileInterceptor('image', { limits: { fileSize: 10 * 1024 * 1024 } })) async generate( @CurrentUser() user: JwtPayload, @UploadedFile() image: Express.Multer.File | undefined, diff --git a/src/wordcloud/wordcloud.service.ts b/src/wordcloud/wordcloud.service.ts index 9b07495..38c9784 100644 --- a/src/wordcloud/wordcloud.service.ts +++ b/src/wordcloud/wordcloud.service.ts @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; import ExcelJS from 'exceljs'; @@ -22,7 +22,7 @@ function toArrayBuffer(buf: Buffer): ArrayBuffer { * 轮询 wordcloud GET /api/jobs/{id} → 回写状态/产物。 */ @Injectable() -export class WordCloudService { +export class WordCloudService implements OnModuleInit { private readonly logger = new Logger(WordCloudService.name); constructor( @@ -149,6 +149,78 @@ export class WordCloudService { } /** 查询本人词云任务:归属校验 → 代理轮询 wordcloud → 回写 DB → 返回小程序侧模型 */ + /** 模块启动后开启后台同步:派单/生成的任务自动从 wordcloud 拉状态推进到 success */ + async onModuleInit() { + const SYNC_MS = 30_000; + const tick = async () => { + try { + if (this.configured) { + const active = await this.prisma.wordCloudJob.findMany({ + where: { status: { in: ['QUEUED', 'RUNNING'] } }, + take: 50, + }); + for (const job of active) { + await this.refreshRemoteJob(job).catch(() => undefined); + } + } + } catch { + /* 后台同步失败不阻断 */ + } + setTimeout(tick, SYNC_MS); + }; + setTimeout(tick, SYNC_MS); + } + + /** 从 wordcloud 拉取状态并回写 DB;success 时转存结果图并同步 CustomizationTask */ + private async refreshRemoteJob(job: { + id: string; + remoteJobId: string | null; + progress: number; + imageUrl: string | null; + error: string | null; + status: string; + }) { + if (!job.remoteJobId) return job; + + let remote: Record; + try { + const res = await axios.get(`${this.apiUrl}/api/jobs/${job.remoteJobId}`, { + timeout: this.timeoutMs, + }); + remote = res.data; + } catch { + return job; // 平台暂不可用:保持现状,下一轮再试 + } + + const status = this.mapStatus(remote.status as string | undefined); + const progress = + typeof remote.progress_percent === 'number' ? remote.progress_percent : job.progress; + const imageUrl = status === 'success' ? await this.resolveResultImage(job.remoteJobId) : job.imageUrl; + + const updated = await this.prisma.wordCloudJob.update({ + where: { id: job.id }, + data: { + status: status.toUpperCase() as 'QUEUED' | 'RUNNING' | 'SUCCESS' | 'FAILED', + progress, + imageUrl: imageUrl ?? null, + error: (remote.error as string | null) ?? job.error ?? null, + }, + }); + + // 成功时把关联的定制任务一并推进到 SUCCESS 并写结果 URL + if (status === 'success' && imageUrl) { + await this.prisma.customizationTask + .updateMany({ + where: { wordcloudJobId: job.id, status: { in: ['PENDING', 'RUNNING'] } }, + data: { status: 'SUCCESS', resultUrl: imageUrl }, + }) + .catch(() => undefined); + } + + return updated; + } + + /** 查询本人词云任务:归属校验 → 实时代理轮询 wordcloud → 返回小程序侧模型 */ async getUserJob(userId: string, id: string) { const job = await this.prisma.wordCloudJob.findFirst({ where: { id, userId } }); if (!job) { @@ -158,38 +230,25 @@ export class WordCloudService { throw new HttpException('任务尚未创建', HttpStatus.CONFLICT); } - let remote: Record; + let updated: { + id: string; + status: string; + progress: number; + imageUrl: string | null; + error: string | null; + }; try { - const res = await axios.get(`${this.apiUrl}/api/jobs/${job.remoteJobId}`, { - timeout: this.timeoutMs, - }); - remote = res.data; + updated = await this.refreshRemoteJob(job); } catch (e) { this.logger.warn(`轮询词云任务失败: ${(e as Error).message}`); throw new HttpException('词云平台暂不可用', HttpStatus.SERVICE_UNAVAILABLE); } - const status = this.mapStatus(remote.status as string | undefined); - const progress = - typeof remote.progress_percent === 'number' ? remote.progress_percent : job.progress; - - // success 时尝试解析结果图(当前 COS 写入未实现,P1 接入后返回公网 URL) - const imageUrl = status === 'success' ? await this.resolveResultImage(job.remoteJobId) : null; - - const updated = await this.prisma.wordCloudJob.update({ - where: { id: job.id }, - data: { - status: status.toUpperCase() as 'QUEUED' | 'RUNNING' | 'SUCCESS' | 'FAILED', - progress, - imageUrl, - error: (remote.error as string | null) ?? null, - }, - }); - + const status = this.mapStatus(updated.status.toLowerCase()) as 'queued' | 'running' | 'success' | 'failed'; return { id: updated.id, status, - progress, + progress: updated.progress, imageUrl: updated.imageUrl ?? undefined, error: updated.error ?? undefined, };