feat(wordcloud): 下单 WCD 派单(buildWcdPackage + dispatch + 幂等 + 订单设计关联)

This commit is contained in:
2026-08-12 20:55:37 +08:00
parent 910da1c369
commit 34f48d0d59
8 changed files with 257 additions and 1 deletions
+205
View File
@@ -2,6 +2,8 @@ import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import ExcelJS from 'exceljs';
import JSZip from 'jszip';
import * as crypto from 'crypto';
import { PrismaService } from '../prisma/prisma.service';
import { CosService } from '../cos/cos.service';
@@ -139,6 +141,13 @@ export class WordCloudService {
return 'queued';
}
private mapTaskStatus(s: string): 'queued' | 'running' | 'success' | 'failed' {
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 } });
@@ -186,6 +195,202 @@ export class WordCloudService {
};
}
/**
* 下单后 WCD 派单(幂等触发层面由 controller/业务方约束)。
* 读取订单关联设计清单的 designData → 构造 .wcd → POST /api/jobswcd_file)。
* WORDCLOUD_API_URL 未配置时返回结构化 not_configured,不做假成功。
*/
async dispatchToWordcloud(orderId: string, userId: string) {
if (!this.configured) {
return { orderId, status: 'not_configured' as const, message: '词云平台未配置' };
}
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
if (!order) {
throw new HttpException('订单不存在', HttpStatus.NOT_FOUND);
}
// 幂等:同一订单已投递过则直接返回既有任务,不重复创建
const existing = await this.prisma.customizationTask.findFirst({
where: { orderId: order.id },
});
if (existing) {
return {
orderId: order.id,
status: this.mapTaskStatus(existing.status),
wordcloudJobId: existing.wordcloudJobId ?? undefined,
message: '该订单已投递过',
};
}
if (!order.designListId) {
throw new HttpException('订单未关联设计清单', HttpStatus.BAD_REQUEST);
}
const dl = await this.prisma.designList.findFirst({
where: { id: order.designListId, userId },
});
if (!dl) {
throw new HttpException('设计清单不存在', HttpStatus.NOT_FOUND);
}
const items = Array.isArray(dl.items) ? (dl.items as Record<string, unknown>[]) : [];
const designItem = items.find((it) => it?.designData) ?? items[0];
if (!designItem?.designData) {
throw new HttpException('订单缺少设计数据', HttpStatus.BAD_REQUEST);
}
const wcd = await this.buildWcdPackage(
order.orderNo,
designItem.designData as Record<string, unknown>,
);
const remoteJobId = await this.createRemoteWcdJob(wcd, order.orderNo);
const job = await this.prisma.wordCloudJob.create({
data: { userId, remoteJobId, status: 'RUNNING' },
});
const task = await this.prisma.customizationTask.create({
data: {
userId,
orderId: order.id,
designListId: dl.id,
wordcloudJobId: job.id,
status: 'RUNNING',
},
});
this.logger.log(`订单 ${order.id} 已投递词云任务 ${remoteJobId} (task=${task.id})`);
return { orderId: order.id, status: 'queued' as const, wordcloudJobId: job.id, message: '已投递词云平台' };
}
/** 由 designData 构造 .wcd 包(对齐 wordcloud /api/design-templates/import 的导入契约) */
private async buildWcdPackage(
orderNo: string,
designData: Record<string, unknown>,
): Promise<Buffer> {
const category = designData.category as { mask?: { width?: number; height?: number } } | undefined;
const mask = category?.mask;
const width = mask?.width ?? 1200;
const height = mask?.height ?? 1200;
const background = (designData.background as { color?: string })?.color ?? '#ffffff';
const elements: Record<string, unknown>[] = [];
const assets: { id: string; url: string }[] = [];
const pushImage = (
url: unknown,
name: string,
opts: { x?: number; y?: number; width?: number; height?: number; rotation?: number } = {},
) => {
if (typeof url !== 'string' || !/^https?:/.test(url)) {
return; // 本地/临时路径或缺失:跳过(需先完成贴纸持久化)
}
const id = `asset_${assets.length + 1}`;
assets.push({ id, url });
elements.push({
id: `${name}_${assets.length}`,
type: 'sticker',
name,
assetId: id,
x: opts.x ?? 0,
y: opts.y ?? 0,
width: opts.width ?? width,
height: opts.height ?? height,
rotation: opts.rotation ?? 0,
opacity: 1,
});
};
// 底图 + 词云结果图 + 贴纸
pushImage((designData.background as { src?: string })?.src, 'background');
pushImage((designData.wordcloud as { imageUrl?: string })?.imageUrl, 'wordcloud');
for (const s of (designData.stickers as Record<string, unknown>[] | undefined) ?? []) {
pushImage(s.src, String(s.id ?? 'sticker'), {
x: s.x as number,
y: s.y as number,
width: s.width as number,
height: s.height as number,
rotation: s.rotation as number,
});
}
const document = {
width,
height,
background,
layers: [{ id: 'layer-1', name: '设计', visible: true, locked: false }],
layerFolders: [],
elements,
};
const zip = new JSZip();
const manifestAssets: Record<string, unknown>[] = [];
for (const asset of assets) {
const { buffer, mime } = await this.downloadAsset(asset.url);
const ext = mime === 'image/svg+xml' ? 'svg' : mime === 'image/jpeg' ? 'jpg' : 'png';
if (!buffer.length) continue;
manifestAssets.push({
id: asset.id,
name: asset.id,
type: ext,
mimeType: mime,
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
size: buffer.length,
});
zip.file(`assets/${asset.id}.${ext}`, buffer);
}
const manifest = {
format: 'wordcloud-canvas',
version: 1,
name: `order-${orderNo}`,
canvas: { width, height, background },
assets: manifestAssets,
meta: { orderNo, wordcloud: designData.wordcloud ?? null },
};
zip.file('manifest.json', JSON.stringify(manifest));
zip.file('document.json', JSON.stringify(document));
return Buffer.from(await zip.generateAsync({ type: 'nodebuffer' }));
}
/** 从公开 URL 下载素材字节并推断 MIME */
private async downloadAsset(url: string): Promise<{ buffer: Buffer; mime: string }> {
const lower = url.toLowerCase();
const mime = lower.endsWith('.svg')
? 'image/svg+xml'
: lower.match(/\.jpe?g($|\?)/)
? 'image/jpeg'
: 'image/png';
const res = await axios.get(url, { responseType: 'arraybuffer', timeout: this.timeoutMs });
return { buffer: Buffer.from(res.data), mime };
}
/** 代理创建 WCD 生产任务(POST /api/jobswcd_file 模式;wordcloud 侧实现后置) */
private async createRemoteWcdJob(wcd: Buffer, orderNo: string): Promise<string> {
if (!this.configured) {
throw new HttpException('词云平台未配置', HttpStatus.SERVICE_UNAVAILABLE);
}
const form = new FormData();
form.append('wcd_file', new Blob([new Uint8Array(wcd)]), `${orderNo}.wcd`);
form.append('params', JSON.stringify({ MODE: 'WCD' }));
let res;
try {
res = await axios.post(`${this.apiUrl}/api/jobs`, form, {
timeout: this.timeoutMs,
maxBodyLength: Infinity,
maxContentLength: Infinity,
});
} catch (e) {
this.logger.error(`WCD 派单失败: ${(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 结果 PNG 转存 COS,返回小程序可访问的 COS 公网 URL;COS 未配置时返回空 */
private async resolveResultImage(remoteJobId: string): Promise<string | null> {
if (!this.cos.configured) {