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 类型不兼容) */ 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('wordcloud.apiUrl'); } private get apiUrl(): string { return (this.config.get('wordcloud.apiUrl') ?? '').replace(/\/+$/, ''); } private get timeoutMs(): number { return this.config.get('wordcloud.timeoutMs') ?? 30000; } /** 名单文本 → 单列 .xlsx(wordcloud 按 DATA_COL_INDEX 读名字列,A 列 index=0) */ private async buildNamesXlsx(names: string[]): Promise { 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, ): Promise { 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 { 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 = {}; 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 = { 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; 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[]) : []; 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, ); 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, ): Promise { 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[] = []; 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[] | 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[] = []; 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 { 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 { 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; } } }