Files
wxmp_backend/src/wordcloud/wordcloud.service.ts
T

413 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
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,
private readonly cos: CosService,
) {}
/** 词云平台是否已配置(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;
}
/** 名单文本 → 单列 .xlsxwordcloud 按 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';
}
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 } });
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,
};
}
/**
* 下单后 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) {
return null;
}
try {
const res = await axios.get(`${this.apiUrl}/api/jobs/${remoteJobId}/files/png`, {
responseType: 'arraybuffer',
timeout: this.timeoutMs,
});
const key = `uploads/wordcloud/${remoteJobId}.png`;
await this.cos.putObject(key, Buffer.from(res.data), 'image/png');
return this.cos.publicUrl(key);
} catch (e) {
this.logger.warn(`转存词云结果失败: ${(e as Error).message}`);
return null;
}
}
}