feat(r4): POST /api/sketch + Upload 归属记录 + 派单状态后台同步与大小校验
This commit is contained in:
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user