feat(wordcloud): WordCloudJob 模型/迁移 + 词云生成与轮询适配器
This commit is contained in:
Generated
+768
-15
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"dayjs": "^1.11.0",
|
"dayjs": "^1.11.0",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"ioredis": "^5.4.0",
|
"ioredis": "^5.4.0",
|
||||||
"joi": "^17.13.0",
|
"joi": "^17.13.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/multer": "^2.2.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WordCloudJobStatus" AS ENUM ('QUEUED', 'RUNNING', 'SUCCESS', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WordCloudJob" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"status" "WordCloudJobStatus" NOT NULL DEFAULT 'QUEUED',
|
||||||
|
"progress" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"error" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "WordCloudJob_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WordCloudJob_userId_idx" ON "WordCloudJob"("userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WordCloudJob" ADD CONSTRAINT "WordCloudJob_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "WordCloudJob" ADD COLUMN "remoteJobId" TEXT;
|
||||||
@@ -29,6 +29,7 @@ model User {
|
|||||||
designLists DesignList[]
|
designLists DesignList[]
|
||||||
uploads Upload[]
|
uploads Upload[]
|
||||||
customizations CustomizationTask[]
|
customizations CustomizationTask[]
|
||||||
|
wordCloudJobs WordCloudJob[]
|
||||||
|
|
||||||
@@index([phone])
|
@@index([phone])
|
||||||
}
|
}
|
||||||
@@ -221,3 +222,28 @@ enum CustomizationTaskStatus {
|
|||||||
SUCCESS
|
SUCCESS
|
||||||
FAILED
|
FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 词云任务(R4:小程序交互生成 + 下单 WCD 派单共用)────────────────
|
||||||
|
model WordCloudJob {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
// wordcloud 侧的外部 job_id(POST /api/jobs 返回);空表示尚未在外部创建
|
||||||
|
remoteJobId String?
|
||||||
|
status WordCloudJobStatus @default(QUEUED)
|
||||||
|
progress Int @default(0)
|
||||||
|
imageUrl String?
|
||||||
|
error String?
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WordCloudJobStatus {
|
||||||
|
QUEUED
|
||||||
|
RUNNING
|
||||||
|
SUCCESS
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { AddressesModule } from './addresses/addresses.module';
|
|||||||
import { OrdersModule } from './orders/orders.module';
|
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 { QueueModule } from './queue/queue.module';
|
import { QueueModule } from './queue/queue.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ import { HealthModule } from './health/health.module';
|
|||||||
OrdersModule,
|
OrdersModule,
|
||||||
PaymentsModule,
|
PaymentsModule,
|
||||||
UploadModule,
|
UploadModule,
|
||||||
|
WordCloudModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
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 { WordCloudService } from './wordcloud.service';
|
||||||
|
|
||||||
|
@ApiTags('词云')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('wordcloud')
|
||||||
|
export class WordCloudController {
|
||||||
|
constructor(private readonly wordCloudService: WordCloudService) {}
|
||||||
|
|
||||||
|
/** 创建词云任务:multipart(image) + form 字段 names/params */
|
||||||
|
@Post('generate')
|
||||||
|
@ApiOperation({ summary: '创建词云任务(底图 + 名单文本)' })
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@UseInterceptors(FileInterceptor('image'))
|
||||||
|
async generate(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@UploadedFile() image: Express.Multer.File | undefined,
|
||||||
|
@Body('names') names: string,
|
||||||
|
@Body('params') params?: string,
|
||||||
|
) {
|
||||||
|
if (!image) {
|
||||||
|
throw new HttpException('缺少底图', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
const jobId = await this.wordCloudService.createGenerateJob(
|
||||||
|
user.sub,
|
||||||
|
{ buffer: image.buffer, originalname: image.originalname },
|
||||||
|
names ?? '',
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return { jobId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轮询词云任务(仅本人) */
|
||||||
|
@Get('jobs/:id')
|
||||||
|
@ApiOperation({ summary: '轮询词云任务状态(仅本人)' })
|
||||||
|
async job(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
|
||||||
|
return this.wordCloudService.getUserJob(user.sub, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WordCloudController } from './wordcloud.controller';
|
||||||
|
import { WordCloudService } from './wordcloud.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [WordCloudController],
|
||||||
|
providers: [WordCloudService],
|
||||||
|
})
|
||||||
|
export class WordCloudModule {}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios from 'axios';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
type RemoteStatus = 'queued' | 'running' | 'success' | 'failed';
|
||||||
|
|
||||||
|
/** Buffer → 独立的 ArrayBuffer(用于 Blob 构造,规避 Buffer<ArrayBufferLike> 类型不兼容) */
|
||||||
|
function toArrayBuffer(buf: Buffer): ArrayBuffer {
|
||||||
|
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* wordcloud 平台适配器。
|
||||||
|
* 契约:与 wordcloud 之间的接口见 docs/wordcloud-contract.md;对小程序暴露的接口见
|
||||||
|
* docs/api-contract-v1.md §8。
|
||||||
|
* 职责:把小程序的名字文本转成 .xlsx → 代理调 wordcloud POST /api/jobs → 落 WordCloudJob →
|
||||||
|
* 轮询 wordcloud GET /api/jobs/{id} → 回写状态/产物。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class WordCloudService {
|
||||||
|
private readonly logger = new Logger(WordCloudService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 词云平台是否已配置(WORDCLOUD_API_URL 非空) */
|
||||||
|
get configured(): boolean {
|
||||||
|
return !!this.config.get<string>('wordcloud.apiUrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
private get apiUrl(): string {
|
||||||
|
return (this.config.get<string>('wordcloud.apiUrl') ?? '').replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private get timeoutMs(): number {
|
||||||
|
return this.config.get<number>('wordcloud.timeoutMs') ?? 30000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 名单文本 → 单列 .xlsx(wordcloud 按 DATA_COL_INDEX 读名字列,A 列 index=0) */
|
||||||
|
private async buildNamesXlsx(names: string[]): Promise<Buffer> {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet('names');
|
||||||
|
names.forEach((n) => sheet.addRow([{ text: n }]));
|
||||||
|
return Buffer.from(await workbook.xlsx.writeBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 代理创建 wordcloud 任务,返回外部 job_id */
|
||||||
|
private async createRemoteJob(
|
||||||
|
xlsx: Buffer,
|
||||||
|
image: { buffer: Buffer; originalname: string },
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): Promise<string> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('name_list', new Blob([toArrayBuffer(xlsx)]), 'names.xlsx');
|
||||||
|
form.append('mask_image', new Blob([toArrayBuffer(image.buffer)]), image.originalname || 'mask.png');
|
||||||
|
form.append('params', JSON.stringify(params));
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await axios.post(`${this.apiUrl}/api/jobs`, form, {
|
||||||
|
timeout: this.timeoutMs,
|
||||||
|
maxBodyLength: Infinity,
|
||||||
|
maxContentLength: Infinity,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(`创建词云任务失败: ${(e as Error).message}`);
|
||||||
|
throw new HttpException('词云平台暂不可用', HttpStatus.SERVICE_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
const jobId = res.data?.job_id;
|
||||||
|
if (!jobId) {
|
||||||
|
throw new HttpException('词云平台未返回任务ID', HttpStatus.BAD_GATEWAY);
|
||||||
|
}
|
||||||
|
return jobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交互式生成:小程序上传底图 + 名单文本 → 在 wordcloud 创建任务并落库,返回内部 jobId。
|
||||||
|
*/
|
||||||
|
async createGenerateJob(
|
||||||
|
userId: string,
|
||||||
|
image: { buffer: Buffer; originalname: string },
|
||||||
|
namesText: string,
|
||||||
|
paramsJson = '{}',
|
||||||
|
): Promise<string> {
|
||||||
|
if (!this.configured) {
|
||||||
|
throw new HttpException('词云平台未配置', HttpStatus.SERVICE_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
if (!image?.buffer) {
|
||||||
|
throw new HttpException('缺少底图', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
const names = namesText
|
||||||
|
.split(/[\n,]|,/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!names.length) {
|
||||||
|
throw new HttpException('名单不能为空', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
let userParams: Record<string, unknown> = {};
|
||||||
|
if (paramsJson) {
|
||||||
|
try {
|
||||||
|
userParams = JSON.parse(paramsJson);
|
||||||
|
} catch {
|
||||||
|
throw new HttpException('params 必须是合法 JSON', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 名单写在 xlsx A 列,DATA_COL_INDEX 强制对齐到 0
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
MODE: 'IMAGE',
|
||||||
|
DATA_COL_INDEX: 0,
|
||||||
|
SEED: 42,
|
||||||
|
N_REPETITIONS: 20,
|
||||||
|
ENABLE_STROKE_WEIGHTS: false,
|
||||||
|
FONT_COLOR: '#000000',
|
||||||
|
...userParams,
|
||||||
|
};
|
||||||
|
params.DATA_COL_INDEX = 0;
|
||||||
|
|
||||||
|
const xlsx = await this.buildNamesXlsx(names);
|
||||||
|
const remoteJobId = await this.createRemoteJob(xlsx, image, params);
|
||||||
|
|
||||||
|
const job = await this.prisma.wordCloudJob.create({
|
||||||
|
data: { userId, remoteJobId, status: 'QUEUED' },
|
||||||
|
});
|
||||||
|
return job.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapStatus(s?: string): RemoteStatus {
|
||||||
|
if (s === 'running') return 'running';
|
||||||
|
if (s === 'success') return 'success';
|
||||||
|
if (s === 'failed') return 'failed';
|
||||||
|
return 'queued';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询本人词云任务:归属校验 → 代理轮询 wordcloud → 回写 DB → 返回小程序侧模型 */
|
||||||
|
async getUserJob(userId: string, id: string) {
|
||||||
|
const job = await this.prisma.wordCloudJob.findFirst({ where: { id, userId } });
|
||||||
|
if (!job) {
|
||||||
|
throw new HttpException('任务不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (!job.remoteJobId) {
|
||||||
|
throw new HttpException('任务尚未创建', HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
let remote: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${this.apiUrl}/api/jobs/${job.remoteJobId}`, {
|
||||||
|
timeout: this.timeoutMs,
|
||||||
|
});
|
||||||
|
remote = res.data;
|
||||||
|
} 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: updated.id,
|
||||||
|
status,
|
||||||
|
progress,
|
||||||
|
imageUrl: updated.imageUrl ?? undefined,
|
||||||
|
error: updated.error ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载 wordcloud 结果 PNG 并返回小程序可用地址(COS 转存前置,P1 实现) */
|
||||||
|
private async resolveResultImage(_remoteJobId: string): Promise<string | null> {
|
||||||
|
// TODO(P1 COS):GET /api/jobs/{id}/result → 下载 png → 写入 COS → 返回公网 URL。
|
||||||
|
// 当前 COS 写入尚未实现,结果图地址留待 P1 接入。
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user