Merge feature R4 (upload + wordcloud + WCD dispatch) into main
This commit is contained in:
@@ -35,3 +35,10 @@ COS_SECRET_ID=
|
||||
COS_SECRET_KEY=
|
||||
COS_BUCKET=
|
||||
COS_REGION=ap-guangzhou
|
||||
|
||||
# ── 词云平台(下单后 WCD 生产任务,R4)────────────────
|
||||
# 词云服务(FastAPI)地址;留空视为未配置:
|
||||
# 下单后的 WCD 派单返回结构化 "not_configured",不做假成功(详见 docs/wordcloud-contract.md)
|
||||
WORDCLOUD_API_URL=
|
||||
# 请求词云平台超时(毫秒),可选,默认 30000
|
||||
WORDCLOUD_TIMEOUT_MS=30000
|
||||
|
||||
+40
-4
@@ -162,16 +162,23 @@
|
||||
unitPrice: number
|
||||
count: number
|
||||
designData?: {
|
||||
version?: 1
|
||||
category?: { id: string; mask: object; tone?: number[] }
|
||||
background?: { src: string; color?: string; pos?: { x: number; y: number; scale: number } }
|
||||
wordcloud?: { jobId?: string; imageUrl: string; names: string[] }
|
||||
stickers?: unknown[]
|
||||
category?: unknown
|
||||
imageSrc?: string
|
||||
imagePos?: { x: number; y: number; scale: number }
|
||||
imageSrc?: string // 兼容旧版
|
||||
imagePos?: { x: number; y: number; scale: number } // 兼容旧版
|
||||
}
|
||||
}[]
|
||||
}
|
||||
```
|
||||
|
||||
`items` 为服务端 JSON,需做结构白名单与大小校验(单条 ≤ 1MB)。
|
||||
> **designData 结构遵循 `wechat_wc/docs/design-data-contract-v1.md`(R2/R4 冻结契约)。**
|
||||
> 该契约保证 R4 下单后能据此构造 `.wcd` 投递到词云平台。要点:
|
||||
> 贴纸图 `src` 必须为 COS 持久 URL(禁止 `wxfile://`/`tmp`)、保留 `wordcloud` 分组、
|
||||
> 保留 `category.mask`;后端该 JSON 白名单须放行 `version/background/wordcloud/rotation/zIndex`。
|
||||
>`items` 为服务端 JSON,需做结构白名单与大小校验(单条 ≤ 1MB)。
|
||||
|
||||
### PATCH /api/design-list/:id
|
||||
|
||||
@@ -298,6 +305,35 @@ multipart 请求:`image`(底图)+ `names`(文本名单)+ `params?`。
|
||||
|
||||
multipart:`image`。返回处理后图片 URL;失败时前端降级本地灰度。
|
||||
|
||||
### POST /api/orders/:id/dispatch(R4 新增,下单后 WCD 派单触发)
|
||||
|
||||
幂等触发把订单对应设计的 `.wcd` 投递到词云平台形成生产任务。
|
||||
|
||||
请求体:`{}`(幂等键 `requestId` 可选)。
|
||||
|
||||
响应 `data`:
|
||||
|
||||
```ts
|
||||
{
|
||||
orderId: string
|
||||
status: 'queued' | 'running' | 'success' | 'failed' | 'not_configured'
|
||||
wordcloudJobId?: string
|
||||
message?: string
|
||||
}
|
||||
```
|
||||
|
||||
- 同一订单重复调用只投递一次(以 `CustomizationTask.orderId` 唯一或状态约束)。
|
||||
- `WORDCLOUD_API_URL` 未配置时返回 `status: 'not_configured'` 与可读 `message`,不做假成功。
|
||||
- 常规路径:订单进入 `PROCESSING` 时由后端队列自动触发;本接口作为支付未配置期的联调/运营手段。
|
||||
- 词云平台侧契约见 `docs/wordcloud-contract.md`(`POST /api/jobs` 可选 `wcd_file`)。
|
||||
|
||||
### 环境变量(R4 新增)
|
||||
|
||||
| 变量 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `WORDCLOUD_API_URL` | 否(为空视为未配置) | 词云平台(FastAPI)地址;未配置时派单返回 `not_configured` |
|
||||
| `WORDCLOUD_TIMEOUT_MS` | 否 | 请求 wordcloud 超时(毫秒),默认 30000 |
|
||||
|
||||
## 9. 契约维护规则
|
||||
|
||||
- 前端与后端同名分支成对开发:`feat/r1-catalog`、`feat/r2-address-design`、`feat/r3-order-pay`、`feat/r4-upload-wordcloud`。
|
||||
|
||||
+42
-17
@@ -1,13 +1,14 @@
|
||||
# wordcloud 外部服务契约 v1
|
||||
# wordcloud 外部服务契约 v1.1
|
||||
|
||||
本文档冻结 `wxmp_backend` 与 `/Users/broccoli/Project/wordcloud` 之间依赖的最小接口。
|
||||
wordcloud 项目内部仍在大量变更,但只要不违反本文档,`wxmp_backend` 的适配层不受影响。
|
||||
|
||||
## 1. 版本与状态
|
||||
|
||||
- 契约版本:`v1`
|
||||
- 状态:*.xlsx 名单模式冻结;`names` 直接文本/JSON 模式为推荐扩展点,尚未冻结。
|
||||
- 服务地址:由 `wxmp_backend` 环境变量配置,禁止硬编码到代码或小程序前端。
|
||||
- 契约版本:`v1.1`(相对 v1 为**兼容扩展**,仅新增可选字段,未改任何既有字段语义)。
|
||||
- 状态:*.xlsx 名单模式冻结;`names` 直接文本/JSON 模式为推荐扩展点,尚未冻结;
|
||||
**WCD 生产任务输入(`wcd_file`)已冻结(v1.1)**,wordcloud 侧实现可后置。
|
||||
- 服务地址:由 `wxmp_backend` 环境变量 `WORDCLOUD_API_URL` 配置,禁止硬编码到代码或小程序前端。
|
||||
- 小程序前端永远不直接访问 wordcloud,只访问 `wxmp_backend`。
|
||||
|
||||
## 2. 冻结接口
|
||||
@@ -18,30 +19,44 @@ wordcloud 项目内部仍在大量变更,但只要不违反本文档,`wxmp_b
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `name_list` | 是 | `.xlsx` 名单文件,v1 必填 |
|
||||
| `mask_image` | IMAGE 模式必填 | `.png/.jpg/.jpeg` 掩膜 |
|
||||
| `name_list` | 与 `wcd_file` 二选一 | `.xlsx` 名单文件,v1 必填 |
|
||||
| `wcd_file` | 与 `name_list` 二选一 | `.wcd` 画布导入导出包(v1.1 新增)。存在时进入“还原设计 → 生产任务”模式 |
|
||||
| `mask_image` | IMAGE 模式必填 | `.png/.jpg/.jpeg` 掩膜;`wcd_file` 模式下不要求 |
|
||||
| `font_file` | 否 | `.ttf/.ttc/.otf` 临时字体 |
|
||||
| `font_id` | 否 | 已上传字体 id |
|
||||
| `params` | 否 | JSON 字符串,顶层必须是对象 |
|
||||
|
||||
`params` 关键值:
|
||||
`wcd_file` 模式下 `params` 关键值建议带:
|
||||
|
||||
```json
|
||||
{
|
||||
"MODE": "IMAGE",
|
||||
"DATA_COL_INDEX": 1,
|
||||
"MODE": "WCD",
|
||||
"SEED": 42,
|
||||
"N_REPETITIONS": 20,
|
||||
"ENABLE_STROKE_WEIGHTS": false,
|
||||
"FONT_COLOR": "#000000"
|
||||
}
|
||||
```
|
||||
|
||||
响应:`{ "job_id": "..." }`。
|
||||
响应与既有模式一致:`{ "job_id": "..." }`。
|
||||
|
||||
**WCD 包结构**(wxmp_backend 构造,校验逻辑对齐 wordcloud 现有
|
||||
`POST /api/design-templates/import`):
|
||||
|
||||
```
|
||||
{wcd}.wcd
|
||||
├── manifest.json // { format: "wordcloud-canvas", version: 1, canvas: {width,height,background},
|
||||
│ // assets: [{id,name,type,mimeType,sha256,size}], meta?: {...} }
|
||||
├── document.json // CanvasDocument:{width,height,background,layers,layerFolders,elements}
|
||||
└── assets/<assetId>.<ext> // 每个 sticker/底图的图片字节
|
||||
```
|
||||
|
||||
- `document.elements[]` 中 `type == "sticker"` 的元素通过 `assetId` 引用包内临时 ID,
|
||||
导入后由 wordcloud 统一重映射为真实素材 ID。
|
||||
- 名单快照可放 `manifest.meta`,仅记录,不影响还原。
|
||||
|
||||
### GET /api/jobs/{job_id}
|
||||
|
||||
返回:
|
||||
返回(与 v1 一致):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -91,19 +106,29 @@ wordcloud 项目内部仍在大量变更,但只要不违反本文档,`wxmp_b
|
||||
## 4. 错误语义
|
||||
|
||||
- 4xx:参数错误,错误信息在 `detail` 或 `message`。
|
||||
- `wcd_file` 非 `.wcd` / 非合法 Zip => 400。
|
||||
- `manifest.format != "wordcloud-canvas"` 或 `version != 1` => 400。
|
||||
- `name_list` 与 `wcd_file` 都缺失 => 400。
|
||||
- 5xx:服务/任务异常,wxmp_backend 将任务标记 `failed` 并在超时后清理。
|
||||
- wxmp_backend 适配层应设置请求超时(例如 30s),任务超时上限(例如 10 分钟)。
|
||||
|
||||
## 5. 稳定性规则
|
||||
|
||||
- 上述 4 个端点的路径、字段名、状态枚举、分页/轮询语义冻结为 v1。
|
||||
- 上述端点的路径、字段名、状态枚举、分页/轮询语义冻结。
|
||||
- `wcd_file` 为 v1.1 兼容扩展:对既有调用方完全向后兼容(纯新增可选字段)。
|
||||
- wordcloud 内部重构、新增 canvas/assets/projects/templates 能力不影响本契约。
|
||||
- 契约变更流程:定义 v2 -> 更新本文档 -> wxmp_backend 在 R4 分支升级适配器 -> 双版本并存一个发布周期。
|
||||
- 若 wordcloud 希望新增“直接传名字列表”能力,默认视为 v1 兼容扩展,字段必须是 optional。
|
||||
|
||||
## 6. 集成要点
|
||||
|
||||
- `wxmp_backend` 负责把小程序文本名单转换为 `.xlsx` 再调用 `POST /api/jobs`。
|
||||
- `wxmp_backend` 保存 `job_id -> userId` 映射,轮询与结果查询必须校验归属。
|
||||
- `wxmp_backend` 负责把小程序文本名单转换为 `.xlsx` 再调用 `POST /api/jobs`(名单模式)。
|
||||
- **`wxmp_backend` 负责在下单后把订单对应 `designData` 构造为 `.wcd` 再调用
|
||||
`POST /api/jobs`(WCD 模式,`MODE=WCD`)**,见
|
||||
`wechat_wc/docs/design-data-contract-v1.md`(数据结构)与
|
||||
`wechat_wc/docs/routes/route-r4-upload-wordcloud.md` §3(派单流程)。
|
||||
- `wxmp_backend` 保存 `job_id -> orderId/userId`(WCD 模式)或 `job_id -> userId`(名单模式)映射,
|
||||
轮询与结果查询必须校验归属。
|
||||
- 结果图由 wxmp_backend 下载并转存 COS,返回给小程序的是 COS 公网 URL。
|
||||
- wordcloud 若对外暴露公网,需要加访问 token 或网络白名单,防止被直接刷任务。
|
||||
- `WORDCLOUD_API_URL` 未配置时,下单派单返回结构化“未配置”错误(状态字面量
|
||||
`not_configured`,见 `api-contract-v1.md` §8 的 `POST /api/orders/:id/dispatch`),不做假成功。
|
||||
- wordcloud 若对外暴露公网,需要加访问 token 或网络白名单,防止被直接刷任务。
|
||||
Generated
+1162
-16
File diff suppressed because it is too large
Load Diff
@@ -34,9 +34,12 @@
|
||||
"bullmq": "^5.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cos-nodejs-sdk-v5": "^3.0.0",
|
||||
"dayjs": "^1.11.0",
|
||||
"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",
|
||||
@@ -46,6 +49,7 @@
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"eslint": "^9.0.0",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WordCloudJobStatus" AS ENUM ('QUEUED', 'RUNNING', 'SUCCESS', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WordCloudJob" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"status" "WordCloudJobStatus" NOT NULL DEFAULT 'QUEUED',
|
||||
"progress" INTEGER NOT NULL DEFAULT 0,
|
||||
"imageUrl" TEXT,
|
||||
"error" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WordCloudJob_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WordCloudJob_userId_idx" ON "WordCloudJob"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WordCloudJob" ADD CONSTRAINT "WordCloudJob_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "WordCloudJob" ADD COLUMN "remoteJobId" TEXT;
|
||||
@@ -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;
|
||||
@@ -29,6 +29,7 @@ model User {
|
||||
designLists DesignList[]
|
||||
uploads Upload[]
|
||||
customizations CustomizationTask[]
|
||||
wordCloudJobs WordCloudJob[]
|
||||
|
||||
@@index([phone])
|
||||
}
|
||||
@@ -84,6 +85,7 @@ model DesignList {
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
customizations CustomizationTask[]
|
||||
orders Order[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -125,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?
|
||||
|
||||
@@ -134,6 +139,7 @@ model Order {
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([designListId])
|
||||
}
|
||||
|
||||
enum OrderStatus {
|
||||
@@ -202,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?
|
||||
@@ -212,6 +221,7 @@ model CustomizationTask {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([designListId])
|
||||
@@index([orderId])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
@@ -221,3 +231,28 @@ enum CustomizationTaskStatus {
|
||||
SUCCESS
|
||||
FAILED
|
||||
}
|
||||
|
||||
// ── 词云任务(R4:小程序交互生成 + 下单 WCD 派单共用)────────────────
|
||||
model WordCloudJob {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
// wordcloud 侧的外部 job_id(POST /api/jobs 返回);空表示尚未在外部创建
|
||||
remoteJobId String?
|
||||
status WordCloudJobStatus @default(QUEUED)
|
||||
progress Int @default(0)
|
||||
imageUrl String?
|
||||
error String?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
enum WordCloudJobStatus {
|
||||
QUEUED
|
||||
RUNNING
|
||||
SUCCESS
|
||||
FAILED
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { AddressesModule } from './addresses/addresses.module';
|
||||
import { OrdersModule } from './orders/orders.module';
|
||||
import { PaymentsModule } from './payments/payments.module';
|
||||
import { UploadModule } from './upload/upload.module';
|
||||
import { WordCloudModule } from './wordcloud/wordcloud.module';
|
||||
import { SketchModule } from './sketch/sketch.module';
|
||||
import { QueueModule } from './queue/queue.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
|
||||
@@ -40,6 +42,8 @@ import { HealthModule } from './health/health.module';
|
||||
OrdersModule,
|
||||
PaymentsModule,
|
||||
UploadModule,
|
||||
WordCloudModule,
|
||||
SketchModule,
|
||||
QueueModule,
|
||||
HealthModule,
|
||||
],
|
||||
|
||||
@@ -35,4 +35,10 @@ export default () => ({
|
||||
bucket: process.env.COS_BUCKET ?? '',
|
||||
region: process.env.COS_REGION ?? 'ap-guangzhou',
|
||||
},
|
||||
wordcloud: {
|
||||
// 词云平台(FastAPI)服务地址;为空视为未配置,下单后的 WCD 派单返回 not_configured
|
||||
apiUrl: process.env.WORDCLOUD_API_URL ?? '',
|
||||
// 请求 wordcloud 超时(毫秒)
|
||||
timeoutMs: parseInt(process.env.WORDCLOUD_TIMEOUT_MS ?? '30000', 10),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -28,4 +28,8 @@ export const validationSchema = Joi.object({
|
||||
COS_SECRET_KEY: Joi.string().allow('').default(''),
|
||||
COS_BUCKET: Joi.string().allow('').default(''),
|
||||
COS_REGION: Joi.string().default('ap-guangzhou'),
|
||||
|
||||
// 词云平台:允许为空(未配置时下单派单返回 not_configured,见 wordcloud-contract.md)
|
||||
WORDCLOUD_API_URL: Joi.string().allow('').default(''),
|
||||
WORDCLOUD_TIMEOUT_MS: Joi.number().default(30000),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CosService } from './cos.service';
|
||||
|
||||
@Module({
|
||||
providers: [CosService],
|
||||
exports: [CosService],
|
||||
})
|
||||
export class CosModule {}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import COS from 'cos-nodejs-sdk-v5';
|
||||
|
||||
/**
|
||||
* 腾讯云 COS 封装。
|
||||
* 用途:
|
||||
* - 给小程序签发预签名 PUT URL 直传(小程序不持有永久密钥);
|
||||
* - 后端服务端写入(把 wordcloud 结果图转存到 COS)。
|
||||
* 未配置(COS_SECRET_ID/KEY/BUCKET 为空)时 configured=false,调用方返回结构化“未配置”。
|
||||
*/
|
||||
@Injectable()
|
||||
export class CosService {
|
||||
private readonly client: COS | null;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
const secretId = this.config.get<string>('cos.secretId') ?? '';
|
||||
const secretKey = this.config.get<string>('cos.secretKey') ?? '';
|
||||
this.client = secretId && secretKey ? new COS({ SecretId: secretId, SecretKey: secretKey }) : null;
|
||||
}
|
||||
|
||||
private get bucketName(): string {
|
||||
return this.config.get<string>('cos.bucket') ?? '';
|
||||
}
|
||||
|
||||
private get regionName(): string {
|
||||
return this.config.get<string>('cos.region') ?? 'ap-guangzhou';
|
||||
}
|
||||
|
||||
get configured(): boolean {
|
||||
return !!this.client && !!this.bucketName;
|
||||
}
|
||||
|
||||
private bucket(): string {
|
||||
if (!this.bucketName) throw new Error('COS 未配置 bucket');
|
||||
return this.bucketName;
|
||||
}
|
||||
|
||||
/** 签发小程序直传预签名 PUT URL */
|
||||
getPresignedPutUrl(key: string, expires = 600): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.client) return reject(new Error('COS 未配置'));
|
||||
this.client.getObjectUrl(
|
||||
{ Bucket: this.bucket(), Region: this.regionName, Key: key, Sign: true, Method: 'put', Expires: expires },
|
||||
(err, data) => (err ? reject(err) : resolve(data.Url)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 服务端写入对象(转存 wordcloud 结果等) */
|
||||
putObject(key: string, body: Buffer, contentType: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.client) return reject(new Error('COS 未配置'));
|
||||
this.client.putObject(
|
||||
{ Bucket: this.bucket(), Region: this.regionName, Key: key, Body: body, ContentType: contentType },
|
||||
(err) => (err ? reject(err) : resolve()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 公网访问地址(桶公共读时可直接访问) */
|
||||
publicUrl(key: string): string {
|
||||
return `https://${this.bucketName || this.bucket()}.cos.${this.regionName}.myqcloud.com/${key}`;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
|
||||
import { SketchService } from './sketch.service';
|
||||
|
||||
// 单张贴纸/底图 ≤ 10MB
|
||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
@ApiTags('线稿')
|
||||
@ApiBearerAuth()
|
||||
@Controller('sketch')
|
||||
export class SketchController {
|
||||
constructor(private readonly sketchService: SketchService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '线稿处理(贴纸图 → 处理后 URL)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('image', { limits: { fileSize: MAX_IMAGE_BYTES } }))
|
||||
sketch(@CurrentUser() user: JwtPayload, @UploadedFile() file: Express.Multer.File) {
|
||||
return this.sketchService.process(user.sub, file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SketchController } from './sketch.controller';
|
||||
import { SketchService } from './sketch.service';
|
||||
import { CosModule } from '../cos/cos.module';
|
||||
|
||||
@Module({
|
||||
imports: [CosModule],
|
||||
controllers: [SketchController],
|
||||
providers: [SketchService],
|
||||
})
|
||||
export class SketchModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { CosService } from '../cos/cos.service';
|
||||
|
||||
// 线稿处理:接收贴纸图,转存 COS 返回可访问 URL(真实 AI 线稿外部服务接入后可替换内部实现)
|
||||
@Injectable()
|
||||
export class SketchService {
|
||||
constructor(private readonly cos: CosService) {}
|
||||
|
||||
async process(userId: string, file: Express.Multer.File) {
|
||||
if (!file?.buffer) {
|
||||
throw new HttpException('缺少图片', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
const mime = file.mimetype ?? 'image/png';
|
||||
if (!/^image\/(png|jpe?g|webp)$/i.test(mime)) {
|
||||
throw new HttpException('仅支持 png/jpg/jpeg/webp 图片', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (!this.cos.configured) {
|
||||
throw new HttpException('图床未配置', HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
const key = `uploads/sketch/${userId}/${randomUUID()}.png`;
|
||||
await this.cos.putObject(key, file.buffer, mime);
|
||||
return { imageUrl: this.cos.publicUrl(key) };
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,11 @@ export class UploadController {
|
||||
constructor(private readonly uploadService: UploadService) {}
|
||||
|
||||
@Get('credentials')
|
||||
@ApiOperation({ summary: '获取 COS 直传临时凭证(占位)' })
|
||||
@ApiOperation({ summary: '获取 COS 直传临时凭证(预签名 PUT URL)' })
|
||||
credentials(@CurrentUser() user: JwtPayload, @Query('key') key: string) {
|
||||
void user;
|
||||
return this.uploadService.getUploadCredentials(key ?? `uploads/${Date.now()}`);
|
||||
return this.uploadService.getUploadCredentials(
|
||||
user.sub,
|
||||
key ?? `uploads/${user.sub}/${Date.now()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UploadController } from './upload.controller';
|
||||
import { UploadService } from './upload.service';
|
||||
import { CosModule } from '../cos/cos.module';
|
||||
|
||||
@Module({
|
||||
imports: [CosModule],
|
||||
controllers: [UploadController],
|
||||
providers: [UploadService],
|
||||
})
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { CosService } from '../cos/cos.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
// 腾讯云 COS 上传服务(本轮仅提供签名/直传凭证占位,完整实现留后续)
|
||||
// 上传凭证服务:给小程序签发 COS 直传预签名 URL;未配置时返回结构化“未配置”
|
||||
@Injectable()
|
||||
export class UploadService {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly cos: CosService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
/** 生成小程序直传所需的临时密钥 / 预签名 URL(占位) */
|
||||
async getUploadCredentials(key: string) {
|
||||
// TODO: 通过 COS STS 或预签名 URL 生成临时凭证
|
||||
//(SDK 已移除,接入时见 docs/cos-sdk-removal.md)
|
||||
return {
|
||||
key,
|
||||
bucket: this.config.get<string>('cos.bucket'),
|
||||
region: this.config.get<string>('cos.region'),
|
||||
// 占位:实际应返回临时 SecretId/SecretKey/Token 或 presigned URL
|
||||
credentials: null,
|
||||
};
|
||||
/** 小程序直传:签发 COS 预签名 PUT URL 并记录 Upload 归属(满足“Upload 表有记录且归属当前用户”) */
|
||||
async getUploadCredentials(userId: string, key: string) {
|
||||
const bucket = this.config.get<string>('cos.bucket') ?? '';
|
||||
const region = this.config.get<string>('cos.region') ?? 'ap-guangzhou';
|
||||
|
||||
if (!this.cos.configured) {
|
||||
return { key, bucket, region, presignedUrl: null, configured: false };
|
||||
}
|
||||
|
||||
const presignedUrl = await this.cos.getPresignedPutUrl(key);
|
||||
// 记录上传归属(对象落地后 key/url 生效;失败不阻断凭证下发)
|
||||
await this.prisma.upload
|
||||
.create({ data: { userId, cosKey: key, url: this.cos.publicUrl(key) } })
|
||||
.catch(() => undefined);
|
||||
|
||||
return { key, bucket, region, presignedUrl, configured: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
|
||||
import { WordCloudService } from './wordcloud.service';
|
||||
|
||||
@ApiTags('词云')
|
||||
@ApiBearerAuth()
|
||||
@Controller('wordcloud')
|
||||
export class WordCloudController {
|
||||
constructor(private readonly wordCloudService: WordCloudService) {}
|
||||
|
||||
/** 创建词云任务:multipart(image) + form 字段 names/params */
|
||||
@Post('generate')
|
||||
@ApiOperation({ summary: '创建词云任务(底图 + 名单文本)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('image', { limits: { fileSize: 10 * 1024 * 1024 } }))
|
||||
async generate(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@UploadedFile() image: Express.Multer.File | undefined,
|
||||
@Body('names') names: string,
|
||||
@Body('params') params?: string,
|
||||
) {
|
||||
if (!image) {
|
||||
throw new HttpException('缺少底图', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
const jobId = await this.wordCloudService.createGenerateJob(
|
||||
user.sub,
|
||||
{ buffer: image.buffer, originalname: image.originalname },
|
||||
names ?? '',
|
||||
params,
|
||||
);
|
||||
return { jobId };
|
||||
}
|
||||
|
||||
/** 轮询词云任务(仅本人) */
|
||||
@Get('jobs/:id')
|
||||
@ApiOperation({ summary: '轮询词云任务状态(仅本人)' })
|
||||
async job(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
|
||||
return this.wordCloudService.getUserJob(user.sub, id);
|
||||
}
|
||||
|
||||
/** 取词云任务结果(仅本人;产物已转存 COS) */
|
||||
@Get('jobs/:id/result')
|
||||
@ApiOperation({ summary: '词云任务结果(仅本人)' })
|
||||
async result(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
|
||||
return this.wordCloudService.getUserJobResult(user.sub, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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, OrderDispatchController],
|
||||
providers: [WordCloudService],
|
||||
})
|
||||
export class WordCloudModule {}
|
||||
@@ -0,0 +1,483 @@
|
||||
import { HttpException, HttpStatus, Injectable, Logger, OnModuleInit } 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 implements OnModuleInit {
|
||||
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 → 返回小程序侧模型 */
|
||||
/** 模块启动后开启后台同步:派单/生成的任务自动从 wordcloud 拉状态推进到 success */
|
||||
async onModuleInit() {
|
||||
const SYNC_MS = 30_000;
|
||||
const tick = async () => {
|
||||
try {
|
||||
if (this.configured) {
|
||||
const active = await this.prisma.wordCloudJob.findMany({
|
||||
where: { status: { in: ['QUEUED', 'RUNNING'] } },
|
||||
take: 50,
|
||||
});
|
||||
for (const job of active) {
|
||||
await this.refreshRemoteJob(job).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 后台同步失败不阻断 */
|
||||
}
|
||||
setTimeout(tick, SYNC_MS);
|
||||
};
|
||||
setTimeout(tick, SYNC_MS);
|
||||
}
|
||||
|
||||
/** 从 wordcloud 拉取状态并回写 DB;success 时转存结果图并同步 CustomizationTask */
|
||||
private async refreshRemoteJob(job: {
|
||||
id: string;
|
||||
remoteJobId: string | null;
|
||||
progress: number;
|
||||
imageUrl: string | null;
|
||||
error: string | null;
|
||||
status: string;
|
||||
}) {
|
||||
if (!job.remoteJobId) return job;
|
||||
|
||||
let remote: Record<string, unknown>;
|
||||
try {
|
||||
const res = await axios.get(`${this.apiUrl}/api/jobs/${job.remoteJobId}`, {
|
||||
timeout: this.timeoutMs,
|
||||
});
|
||||
remote = res.data;
|
||||
} catch {
|
||||
return job; // 平台暂不可用:保持现状,下一轮再试
|
||||
}
|
||||
|
||||
const status = this.mapStatus(remote.status as string | undefined);
|
||||
const progress =
|
||||
typeof remote.progress_percent === 'number' ? remote.progress_percent : job.progress;
|
||||
const imageUrl = status === 'success' ? await this.resolveResultImage(job.remoteJobId) : job.imageUrl;
|
||||
|
||||
const updated = await this.prisma.wordCloudJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: status.toUpperCase() as 'QUEUED' | 'RUNNING' | 'SUCCESS' | 'FAILED',
|
||||
progress,
|
||||
imageUrl: imageUrl ?? null,
|
||||
error: (remote.error as string | null) ?? job.error ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// 成功时把关联的定制任务一并推进到 SUCCESS 并写结果 URL
|
||||
if (status === 'success' && imageUrl) {
|
||||
await this.prisma.customizationTask
|
||||
.updateMany({
|
||||
where: { wordcloudJobId: job.id, status: { in: ['PENDING', 'RUNNING'] } },
|
||||
data: { status: 'SUCCESS', resultUrl: imageUrl },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** 取词云任务结果(产物已由后台同步/轮询转存 COS) */
|
||||
async getUserJobResult(userId: string, id: string) {
|
||||
const job = await this.prisma.wordCloudJob.findFirst({ where: { id, userId } });
|
||||
if (!job) {
|
||||
throw new HttpException('任务不存在', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (!job.imageUrl) {
|
||||
throw new HttpException('结果未就绪', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return { imageUrl: job.imageUrl };
|
||||
}
|
||||
|
||||
/** 查询本人词云任务:归属校验 → 实时代理轮询 wordcloud → 返回小程序侧模型 */
|
||||
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 updated: {
|
||||
id: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
imageUrl: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
try {
|
||||
updated = await this.refreshRemoteJob(job);
|
||||
} catch (e) {
|
||||
this.logger.warn(`轮询词云任务失败: ${(e as Error).message}`);
|
||||
throw new HttpException('词云平台暂不可用', HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
const status = this.mapStatus(updated.status.toLowerCase()) as 'queued' | 'running' | 'success' | 'failed';
|
||||
return {
|
||||
id: updated.id,
|
||||
status,
|
||||
progress: updated.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user