feat(r4): POST /api/sketch + Upload 归属记录 + 派单状态后台同步与大小校验

This commit is contained in:
2026-08-13 01:34:19 +08:00
parent 34f48d0d59
commit 3f7ce2cdcd
8 changed files with 166 additions and 31 deletions
+2
View File
@@ -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,
],
+28
View File
@@ -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);
}
}
+11
View File
@@ -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 {}
+26
View File
@@ -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) };
}
}
+5 -3
View File
@@ -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()}`,
);
}
}
+9 -2
View File
@@ -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<string>('cos.bucket') ?? '';
const region = this.config.get<string>('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 };
}
}
+1 -1
View File
@@ -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,
+84 -25
View File
@@ -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 拉取状态并回写 DBsuccess 时转存结果图并同步 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<string, unknown>;
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<string, unknown>;
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,
};