413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
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;
|
||
}
|
||
|
||
/** 名单文本 → 单列 .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';
|
||
}
|
||
|
||
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/jobs(wcd_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/jobs,wcd_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;
|
||
}
|
||
}
|
||
}
|