From 34f48d0d5903dbb4ad136bc6768dc9b09018d288 Mon Sep 17 00:00:00 2001 From: obroccolio Date: Wed, 12 Aug 2026 20:55:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(wordcloud):=20=E4=B8=8B=E5=8D=95=20WCD=20?= =?UTF-8?q?=E6=B4=BE=E5=8D=95=EF=BC=88buildWcdPackage=20+=20dispatch=20+?= =?UTF-8?q?=20=E5=B9=82=E7=AD=89=20+=20=E8=AE=A2=E5=8D=95=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E5=85=B3=E8=81=94=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 1 + package.json | 1 + .../migration.sql | 15 ++ prisma/schema.prisma | 9 + src/orders/orders.service.ts | 2 + src/wordcloud/order-dispatch.controller.ts | 22 ++ src/wordcloud/wordcloud.module.ts | 3 +- src/wordcloud/wordcloud.service.ts | 205 ++++++++++++++++++ 8 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260812124858_r4_order_design_and_dispatch/migration.sql create mode 100644 src/wordcloud/order-dispatch.controller.ts diff --git a/package-lock.json b/package-lock.json index d2bc66e..07aba9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "exceljs": "^4.4.0", "ioredis": "^5.4.0", "joi": "^17.13.0", + "jszip": "^3.10.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", diff --git a/package.json b/package.json index bb52189..82ec5d3 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "exceljs": "^4.4.0", "ioredis": "^5.4.0", "joi": "^17.13.0", + "jszip": "^3.10.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", diff --git a/prisma/migrations/20260812124858_r4_order_design_and_dispatch/migration.sql b/prisma/migrations/20260812124858_r4_order_design_and_dispatch/migration.sql new file mode 100644 index 0000000..1244626 --- /dev/null +++ b/prisma/migrations/20260812124858_r4_order_design_and_dispatch/migration.sql @@ -0,0 +1,15 @@ +-- AlterTable +ALTER TABLE "CustomizationTask" ADD COLUMN "orderId" TEXT, +ADD COLUMN "wordcloudJobId" TEXT; + +-- AlterTable +ALTER TABLE "Order" ADD COLUMN "designListId" TEXT; + +-- CreateIndex +CREATE INDEX "CustomizationTask_orderId_idx" ON "CustomizationTask"("orderId"); + +-- CreateIndex +CREATE INDEX "Order_designListId_idx" ON "Order"("designListId"); + +-- AddForeignKey +ALTER TABLE "Order" ADD CONSTRAINT "Order_designListId_fkey" FOREIGN KEY ("designListId") REFERENCES "DesignList"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 41b3a6c..94ce1a4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -85,6 +85,7 @@ model DesignList { user User @relation(fields: [userId], references: [id]) customizations CustomizationTask[] + orders Order[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -126,7 +127,10 @@ model Order { status OrderStatus @default(PENDING) totalAmount Decimal @db.Decimal(10, 2) addressSnapshot Json + // 关联的设计清单(R4 下单后 WCD 派单据此读取 designData) + designListId String? user User @relation(fields: [userId], references: [id]) + designList DesignList? @relation(fields: [designListId], references: [id]) items OrderItem[] payment Payment? @@ -135,6 +139,7 @@ model Order { @@index([userId]) @@index([status]) + @@index([designListId]) } enum OrderStatus { @@ -203,6 +208,9 @@ model CustomizationTask { id String @id @default(cuid()) designListId String? userId String? + // R4 下单后 WCD 派单:关联的订单与词云任务 + orderId String? + wordcloudJobId String? status CustomizationTaskStatus @default(PENDING) resultUrl String? queueJobId String? @@ -213,6 +221,7 @@ model CustomizationTask { updatedAt DateTime @updatedAt @@index([designListId]) + @@index([orderId]) @@index([status]) } diff --git a/src/orders/orders.service.ts b/src/orders/orders.service.ts index da43251..cd256a5 100644 --- a/src/orders/orders.service.ts +++ b/src/orders/orders.service.ts @@ -43,6 +43,8 @@ export class OrdersService { user: { connect: { id: userId } }, totalAmount, addressSnapshot: dto.addressSnapshot as Prisma.InputJsonValue, + // 关联设计清单(R4 下单后 WCD 派单读取 designData;R3 契约 CreateOrderDto 已含该字段) + designList: dto.designListId ? { connect: { id: dto.designListId } } : undefined, items: { create: dto.items.map((it) => ({ productId: it.productId, diff --git a/src/wordcloud/order-dispatch.controller.ts b/src/wordcloud/order-dispatch.controller.ts new file mode 100644 index 0000000..5435edb --- /dev/null +++ b/src/wordcloud/order-dispatch.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Param, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator'; +import { WordCloudService } from './wordcloud.service'; + +/** + * 下单后 WCD 派单路由。 + * 挂在 orders 基路径下(R3 的 OrdersController 不负责本端点),由 R4 词云服务实现。 + * 幂等语义:同一订单重复投递由上层/CustomizationTask.orderId 唯一约束,本端点只做创建。 + */ +@ApiTags('词云下单派单') +@ApiBearerAuth() +@Controller('orders') +export class OrderDispatchController { + constructor(private readonly wordCloudService: WordCloudService) {} + + @Post(':id/dispatch') + @ApiOperation({ summary: '下单后 WCD 派单(幂等触发)' }) + dispatch(@CurrentUser() user: JwtPayload, @Param('id') id: string) { + return this.wordCloudService.dispatchToWordcloud(id, user.sub); + } +} diff --git a/src/wordcloud/wordcloud.module.ts b/src/wordcloud/wordcloud.module.ts index c323e56..a2a0ece 100644 --- a/src/wordcloud/wordcloud.module.ts +++ b/src/wordcloud/wordcloud.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { WordCloudController } from './wordcloud.controller'; +import { OrderDispatchController } from './order-dispatch.controller'; import { WordCloudService } from './wordcloud.service'; import { CosModule } from '../cos/cos.module'; @Module({ imports: [CosModule], - controllers: [WordCloudController], + controllers: [WordCloudController, OrderDispatchController], providers: [WordCloudService], }) export class WordCloudModule {} diff --git a/src/wordcloud/wordcloud.service.ts b/src/wordcloud/wordcloud.service.ts index c02f656..9b07495 100644 --- a/src/wordcloud/wordcloud.service.ts +++ b/src/wordcloud/wordcloud.service.ts @@ -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[]) : []; + 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) {