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 { PaymentsModule } from './payments/payments.module';
|
||||||
import { UploadModule } from './upload/upload.module';
|
import { UploadModule } from './upload/upload.module';
|
||||||
import { WordCloudModule } from './wordcloud/wordcloud.module';
|
import { WordCloudModule } from './wordcloud/wordcloud.module';
|
||||||
|
import { SketchModule } from './sketch/sketch.module';
|
||||||
import { QueueModule } from './queue/queue.module';
|
import { QueueModule } from './queue/queue.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ import { HealthModule } from './health/health.module';
|
|||||||
PaymentsModule,
|
PaymentsModule,
|
||||||
UploadModule,
|
UploadModule,
|
||||||
WordCloudModule,
|
WordCloudModule,
|
||||||
|
SketchModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
HealthModule,
|
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) {}
|
constructor(private readonly uploadService: UploadService) {}
|
||||||
|
|
||||||
@Get('credentials')
|
@Get('credentials')
|
||||||
@ApiOperation({ summary: '获取 COS 直传临时凭证(占位)' })
|
@ApiOperation({ summary: '获取 COS 直传临时凭证(预签名 PUT URL)' })
|
||||||
credentials(@CurrentUser() user: JwtPayload, @Query('key') key: string) {
|
credentials(@CurrentUser() user: JwtPayload, @Query('key') key: string) {
|
||||||
void user;
|
return this.uploadService.getUploadCredentials(
|
||||||
return this.uploadService.getUploadCredentials(key ?? `uploads/${Date.now()}`);
|
user.sub,
|
||||||
|
key ?? `uploads/${user.sub}/${Date.now()}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { CosService } from '../cos/cos.service';
|
import { CosService } from '../cos/cos.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
// 上传凭证服务:给小程序签发 COS 直传预签名 URL;未配置时返回结构化“未配置”
|
// 上传凭证服务:给小程序签发 COS 直传预签名 URL;未配置时返回结构化“未配置”
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -8,10 +9,11 @@ export class UploadService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly cos: CosService,
|
private readonly cos: CosService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** 小程序直传:签发 COS 预签名 PUT URL(小程序不持有永久密钥) */
|
/** 小程序直传:签发 COS 预签名 PUT URL 并记录 Upload 归属(满足“Upload 表有记录且归属当前用户”) */
|
||||||
async getUploadCredentials(key: string) {
|
async getUploadCredentials(userId: string, key: string) {
|
||||||
const bucket = this.config.get<string>('cos.bucket') ?? '';
|
const bucket = this.config.get<string>('cos.bucket') ?? '';
|
||||||
const region = this.config.get<string>('cos.region') ?? 'ap-guangzhou';
|
const region = this.config.get<string>('cos.region') ?? 'ap-guangzhou';
|
||||||
|
|
||||||
@@ -20,6 +22,11 @@ export class UploadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const presignedUrl = await this.cos.getPresignedPutUrl(key);
|
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 };
|
return { key, bucket, region, presignedUrl, configured: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class WordCloudController {
|
|||||||
@Post('generate')
|
@Post('generate')
|
||||||
@ApiOperation({ summary: '创建词云任务(底图 + 名单文本)' })
|
@ApiOperation({ summary: '创建词云任务(底图 + 名单文本)' })
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@UseInterceptors(FileInterceptor('image'))
|
@UseInterceptors(FileInterceptor('image', { limits: { fileSize: 10 * 1024 * 1024 } }))
|
||||||
async generate(
|
async generate(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@UploadedFile() image: Express.Multer.File | undefined,
|
@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 { ConfigService } from '@nestjs/config';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import ExcelJS from 'exceljs';
|
import ExcelJS from 'exceljs';
|
||||||
@@ -22,7 +22,7 @@ function toArrayBuffer(buf: Buffer): ArrayBuffer {
|
|||||||
* 轮询 wordcloud GET /api/jobs/{id} → 回写状态/产物。
|
* 轮询 wordcloud GET /api/jobs/{id} → 回写状态/产物。
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WordCloudService {
|
export class WordCloudService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(WordCloudService.name);
|
private readonly logger = new Logger(WordCloudService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -149,6 +149,78 @@ export class WordCloudService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 查询本人词云任务:归属校验 → 代理轮询 wordcloud → 回写 DB → 返回小程序侧模型 */
|
/** 查询本人词云任务:归属校验 → 代理轮询 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) {
|
async getUserJob(userId: string, id: string) {
|
||||||
const job = await this.prisma.wordCloudJob.findFirst({ where: { id, userId } });
|
const job = await this.prisma.wordCloudJob.findFirst({ where: { id, userId } });
|
||||||
if (!job) {
|
if (!job) {
|
||||||
@@ -158,38 +230,25 @@ export class WordCloudService {
|
|||||||
throw new HttpException('任务尚未创建', HttpStatus.CONFLICT);
|
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 {
|
try {
|
||||||
const res = await axios.get(`${this.apiUrl}/api/jobs/${job.remoteJobId}`, {
|
updated = await this.refreshRemoteJob(job);
|
||||||
timeout: this.timeoutMs,
|
|
||||||
});
|
|
||||||
remote = res.data;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.warn(`轮询词云任务失败: ${(e as Error).message}`);
|
this.logger.warn(`轮询词云任务失败: ${(e as Error).message}`);
|
||||||
throw new HttpException('词云平台暂不可用', HttpStatus.SERVICE_UNAVAILABLE);
|
throw new HttpException('词云平台暂不可用', HttpStatus.SERVICE_UNAVAILABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = this.mapStatus(remote.status as string | undefined);
|
const status = this.mapStatus(updated.status.toLowerCase()) as 'queued' | 'running' | 'success' | 'failed';
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
status,
|
status,
|
||||||
progress,
|
progress: updated.progress,
|
||||||
imageUrl: updated.imageUrl ?? undefined,
|
imageUrl: updated.imageUrl ?? undefined,
|
||||||
error: updated.error ?? undefined,
|
error: updated.error ?? undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user