@@ -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/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 ) {