feat(wordcloud): 下单 WCD 派单(buildWcdPackage + dispatch + 幂等 + 订单设计关联)
This commit is contained in:
Generated
+1
@@ -27,6 +27,7 @@
|
|||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"ioredis": "^5.4.0",
|
"ioredis": "^5.4.0",
|
||||||
"joi": "^17.13.0",
|
"joi": "^17.13.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"ioredis": "^5.4.0",
|
"ioredis": "^5.4.0",
|
||||||
"joi": "^17.13.0",
|
"joi": "^17.13.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -85,6 +85,7 @@ model DesignList {
|
|||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
customizations CustomizationTask[]
|
customizations CustomizationTask[]
|
||||||
|
orders Order[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -126,7 +127,10 @@ model Order {
|
|||||||
status OrderStatus @default(PENDING)
|
status OrderStatus @default(PENDING)
|
||||||
totalAmount Decimal @db.Decimal(10, 2)
|
totalAmount Decimal @db.Decimal(10, 2)
|
||||||
addressSnapshot Json
|
addressSnapshot Json
|
||||||
|
// 关联的设计清单(R4 下单后 WCD 派单据此读取 designData)
|
||||||
|
designListId String?
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
designList DesignList? @relation(fields: [designListId], references: [id])
|
||||||
items OrderItem[]
|
items OrderItem[]
|
||||||
payment Payment?
|
payment Payment?
|
||||||
|
|
||||||
@@ -135,6 +139,7 @@ model Order {
|
|||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([designListId])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OrderStatus {
|
enum OrderStatus {
|
||||||
@@ -203,6 +208,9 @@ model CustomizationTask {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
designListId String?
|
designListId String?
|
||||||
userId String?
|
userId String?
|
||||||
|
// R4 下单后 WCD 派单:关联的订单与词云任务
|
||||||
|
orderId String?
|
||||||
|
wordcloudJobId String?
|
||||||
status CustomizationTaskStatus @default(PENDING)
|
status CustomizationTaskStatus @default(PENDING)
|
||||||
resultUrl String?
|
resultUrl String?
|
||||||
queueJobId String?
|
queueJobId String?
|
||||||
@@ -213,6 +221,7 @@ model CustomizationTask {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([designListId])
|
@@index([designListId])
|
||||||
|
@@index([orderId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export class OrdersService {
|
|||||||
user: { connect: { id: userId } },
|
user: { connect: { id: userId } },
|
||||||
totalAmount,
|
totalAmount,
|
||||||
addressSnapshot: dto.addressSnapshot as Prisma.InputJsonValue,
|
addressSnapshot: dto.addressSnapshot as Prisma.InputJsonValue,
|
||||||
|
// 关联设计清单(R4 下单后 WCD 派单读取 designData;R3 契约 CreateOrderDto 已含该字段)
|
||||||
|
designList: dto.designListId ? { connect: { id: dto.designListId } } : undefined,
|
||||||
items: {
|
items: {
|
||||||
create: dto.items.map((it) => ({
|
create: dto.items.map((it) => ({
|
||||||
productId: it.productId,
|
productId: it.productId,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { WordCloudController } from './wordcloud.controller';
|
import { WordCloudController } from './wordcloud.controller';
|
||||||
|
import { OrderDispatchController } from './order-dispatch.controller';
|
||||||
import { WordCloudService } from './wordcloud.service';
|
import { WordCloudService } from './wordcloud.service';
|
||||||
import { CosModule } from '../cos/cos.module';
|
import { CosModule } from '../cos/cos.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CosModule],
|
imports: [CosModule],
|
||||||
controllers: [WordCloudController],
|
controllers: [WordCloudController, OrderDispatchController],
|
||||||
providers: [WordCloudService],
|
providers: [WordCloudService],
|
||||||
})
|
})
|
||||||
export class WordCloudModule {}
|
export class WordCloudModule {}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import ExcelJS from 'exceljs';
|
import ExcelJS from 'exceljs';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CosService } from '../cos/cos.service';
|
import { CosService } from '../cos/cos.service';
|
||||||
|
|
||||||
@@ -139,6 +141,13 @@ export class WordCloudService {
|
|||||||
return 'queued';
|
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 → 返回小程序侧模型 */
|
/** 查询本人词云任务:归属校验 → 代理轮询 wordcloud → 回写 DB → 返回小程序侧模型 */
|
||||||
async getUserJob(userId: string, id: string) {
|
async getUserJob(userId: string, id: string) {
|
||||||
const job = await this.prisma.wordCloudJob.findFirst({ where: { id, userId } });
|
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 未配置时返回空 */
|
/** 下载 wordcloud 结果 PNG 转存 COS,返回小程序可访问的 COS 公网 URL;COS 未配置时返回空 */
|
||||||
private async resolveResultImage(remoteJobId: string): Promise<string | null> {
|
private async resolveResultImage(remoteJobId: string): Promise<string | null> {
|
||||||
if (!this.cos.configured) {
|
if (!this.cos.configured) {
|
||||||
|
|||||||
Reference in New Issue
Block a user