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_SECRET_KEY=
|
||||||
COS_BUCKET=
|
COS_BUCKET=
|
||||||
COS_REGION=ap-guangzhou
|
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
|
unitPrice: number
|
||||||
count: number
|
count: number
|
||||||
designData?: {
|
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[]
|
stickers?: unknown[]
|
||||||
category?: unknown
|
imageSrc?: string // 兼容旧版
|
||||||
imageSrc?: string
|
imagePos?: { x: number; y: number; scale: number } // 兼容旧版
|
||||||
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
|
### PATCH /api/design-list/:id
|
||||||
|
|
||||||
@@ -298,6 +305,35 @@ multipart 请求:`image`(底图)+ `names`(文本名单)+ `params?`。
|
|||||||
|
|
||||||
multipart:`image`。返回处理后图片 URL;失败时前端降级本地灰度。
|
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. 契约维护规则
|
## 9. 契约维护规则
|
||||||
|
|
||||||
- 前端与后端同名分支成对开发:`feat/r1-catalog`、`feat/r2-address-design`、`feat/r3-order-pay`、`feat/r4-upload-wordcloud`。
|
- 前端与后端同名分支成对开发:`feat/r1-catalog`、`feat/r2-address-design`、`feat/r3-order-pay`、`feat/r4-upload-wordcloud`。
|
||||||
|
|||||||
+41
-16
@@ -1,13 +1,14 @@
|
|||||||
# wordcloud 外部服务契约 v1
|
# wordcloud 外部服务契约 v1.1
|
||||||
|
|
||||||
本文档冻结 `wxmp_backend` 与 `/Users/broccoli/Project/wordcloud` 之间依赖的最小接口。
|
本文档冻结 `wxmp_backend` 与 `/Users/broccoli/Project/wordcloud` 之间依赖的最小接口。
|
||||||
wordcloud 项目内部仍在大量变更,但只要不违反本文档,`wxmp_backend` 的适配层不受影响。
|
wordcloud 项目内部仍在大量变更,但只要不违反本文档,`wxmp_backend` 的适配层不受影响。
|
||||||
|
|
||||||
## 1. 版本与状态
|
## 1. 版本与状态
|
||||||
|
|
||||||
- 契约版本:`v1`
|
- 契约版本:`v1.1`(相对 v1 为**兼容扩展**,仅新增可选字段,未改任何既有字段语义)。
|
||||||
- 状态:*.xlsx 名单模式冻结;`names` 直接文本/JSON 模式为推荐扩展点,尚未冻结。
|
- 状态:*.xlsx 名单模式冻结;`names` 直接文本/JSON 模式为推荐扩展点,尚未冻结;
|
||||||
- 服务地址:由 `wxmp_backend` 环境变量配置,禁止硬编码到代码或小程序前端。
|
**WCD 生产任务输入(`wcd_file`)已冻结(v1.1)**,wordcloud 侧实现可后置。
|
||||||
|
- 服务地址:由 `wxmp_backend` 环境变量 `WORDCLOUD_API_URL` 配置,禁止硬编码到代码或小程序前端。
|
||||||
- 小程序前端永远不直接访问 wordcloud,只访问 `wxmp_backend`。
|
- 小程序前端永远不直接访问 wordcloud,只访问 `wxmp_backend`。
|
||||||
|
|
||||||
## 2. 冻结接口
|
## 2. 冻结接口
|
||||||
@@ -18,30 +19,44 @@ wordcloud 项目内部仍在大量变更,但只要不违反本文档,`wxmp_b
|
|||||||
|
|
||||||
| 字段 | 必填 | 说明 |
|
| 字段 | 必填 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `name_list` | 是 | `.xlsx` 名单文件,v1 必填 |
|
| `name_list` | 与 `wcd_file` 二选一 | `.xlsx` 名单文件,v1 必填 |
|
||||||
| `mask_image` | IMAGE 模式必填 | `.png/.jpg/.jpeg` 掩膜 |
|
| `wcd_file` | 与 `name_list` 二选一 | `.wcd` 画布导入导出包(v1.1 新增)。存在时进入“还原设计 → 生产任务”模式 |
|
||||||
|
| `mask_image` | IMAGE 模式必填 | `.png/.jpg/.jpeg` 掩膜;`wcd_file` 模式下不要求 |
|
||||||
| `font_file` | 否 | `.ttf/.ttc/.otf` 临时字体 |
|
| `font_file` | 否 | `.ttf/.ttc/.otf` 临时字体 |
|
||||||
| `font_id` | 否 | 已上传字体 id |
|
| `font_id` | 否 | 已上传字体 id |
|
||||||
| `params` | 否 | JSON 字符串,顶层必须是对象 |
|
| `params` | 否 | JSON 字符串,顶层必须是对象 |
|
||||||
|
|
||||||
`params` 关键值:
|
`wcd_file` 模式下 `params` 关键值建议带:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"MODE": "IMAGE",
|
"MODE": "WCD",
|
||||||
"DATA_COL_INDEX": 1,
|
|
||||||
"SEED": 42,
|
"SEED": 42,
|
||||||
"N_REPETITIONS": 20,
|
|
||||||
"ENABLE_STROKE_WEIGHTS": false,
|
"ENABLE_STROKE_WEIGHTS": false,
|
||||||
"FONT_COLOR": "#000000"
|
"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}
|
### GET /api/jobs/{job_id}
|
||||||
|
|
||||||
返回:
|
返回(与 v1 一致):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -91,19 +106,29 @@ wordcloud 项目内部仍在大量变更,但只要不违反本文档,`wxmp_b
|
|||||||
## 4. 错误语义
|
## 4. 错误语义
|
||||||
|
|
||||||
- 4xx:参数错误,错误信息在 `detail` 或 `message`。
|
- 4xx:参数错误,错误信息在 `detail` 或 `message`。
|
||||||
|
- `wcd_file` 非 `.wcd` / 非合法 Zip => 400。
|
||||||
|
- `manifest.format != "wordcloud-canvas"` 或 `version != 1` => 400。
|
||||||
|
- `name_list` 与 `wcd_file` 都缺失 => 400。
|
||||||
- 5xx:服务/任务异常,wxmp_backend 将任务标记 `failed` 并在超时后清理。
|
- 5xx:服务/任务异常,wxmp_backend 将任务标记 `failed` 并在超时后清理。
|
||||||
- wxmp_backend 适配层应设置请求超时(例如 30s),任务超时上限(例如 10 分钟)。
|
- wxmp_backend 适配层应设置请求超时(例如 30s),任务超时上限(例如 10 分钟)。
|
||||||
|
|
||||||
## 5. 稳定性规则
|
## 5. 稳定性规则
|
||||||
|
|
||||||
- 上述 4 个端点的路径、字段名、状态枚举、分页/轮询语义冻结为 v1。
|
- 上述端点的路径、字段名、状态枚举、分页/轮询语义冻结。
|
||||||
|
- `wcd_file` 为 v1.1 兼容扩展:对既有调用方完全向后兼容(纯新增可选字段)。
|
||||||
- wordcloud 内部重构、新增 canvas/assets/projects/templates 能力不影响本契约。
|
- wordcloud 内部重构、新增 canvas/assets/projects/templates 能力不影响本契约。
|
||||||
- 契约变更流程:定义 v2 -> 更新本文档 -> wxmp_backend 在 R4 分支升级适配器 -> 双版本并存一个发布周期。
|
- 契约变更流程:定义 v2 -> 更新本文档 -> wxmp_backend 在 R4 分支升级适配器 -> 双版本并存一个发布周期。
|
||||||
- 若 wordcloud 希望新增“直接传名字列表”能力,默认视为 v1 兼容扩展,字段必须是 optional。
|
|
||||||
|
|
||||||
## 6. 集成要点
|
## 6. 集成要点
|
||||||
|
|
||||||
- `wxmp_backend` 负责把小程序文本名单转换为 `.xlsx` 再调用 `POST /api/jobs`。
|
- `wxmp_backend` 负责把小程序文本名单转换为 `.xlsx` 再调用 `POST /api/jobs`(名单模式)。
|
||||||
- `wxmp_backend` 保存 `job_id -> userId` 映射,轮询与结果查询必须校验归属。
|
- **`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。
|
- 结果图由 wxmp_backend 下载并转存 COS,返回给小程序的是 COS 公网 URL。
|
||||||
|
- `WORDCLOUD_API_URL` 未配置时,下单派单返回结构化“未配置”错误(状态字面量
|
||||||
|
`not_configured`,见 `api-contract-v1.md` §8 的 `POST /api/orders/:id/dispatch`),不做假成功。
|
||||||
- wordcloud 若对外暴露公网,需要加访问 token 或网络白名单,防止被直接刷任务。
|
- wordcloud 若对外暴露公网,需要加访问 token 或网络白名单,防止被直接刷任务。
|
||||||
Generated
+1162
-16
File diff suppressed because it is too large
Load Diff
@@ -34,9 +34,12 @@
|
|||||||
"bullmq": "^5.0.0",
|
"bullmq": "^5.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
"cos-nodejs-sdk-v5": "^3.0.0",
|
||||||
"dayjs": "^1.11.0",
|
"dayjs": "^1.11.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",
|
||||||
@@ -46,6 +49,7 @@
|
|||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/multer": "^2.2.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"eslint": "^9.0.0",
|
"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[]
|
designLists DesignList[]
|
||||||
uploads Upload[]
|
uploads Upload[]
|
||||||
customizations CustomizationTask[]
|
customizations CustomizationTask[]
|
||||||
|
wordCloudJobs WordCloudJob[]
|
||||||
|
|
||||||
@@index([phone])
|
@@index([phone])
|
||||||
}
|
}
|
||||||
@@ -84,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
|
||||||
@@ -125,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?
|
||||||
|
|
||||||
@@ -134,6 +139,7 @@ model Order {
|
|||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([designListId])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OrderStatus {
|
enum OrderStatus {
|
||||||
@@ -202,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?
|
||||||
@@ -212,6 +221,7 @@ model CustomizationTask {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([designListId])
|
@@index([designListId])
|
||||||
|
@@index([orderId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,3 +231,28 @@ enum CustomizationTaskStatus {
|
|||||||
SUCCESS
|
SUCCESS
|
||||||
FAILED
|
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 { OrdersModule } from './orders/orders.module';
|
||||||
import { PaymentsModule } from './payments/payments.module';
|
import { PaymentsModule } from './payments/payments.module';
|
||||||
import { UploadModule } from './upload/upload.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 { QueueModule } from './queue/queue.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
|
||||||
@@ -40,6 +42,8 @@ import { HealthModule } from './health/health.module';
|
|||||||
OrdersModule,
|
OrdersModule,
|
||||||
PaymentsModule,
|
PaymentsModule,
|
||||||
UploadModule,
|
UploadModule,
|
||||||
|
WordCloudModule,
|
||||||
|
SketchModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -35,4 +35,10 @@ export default () => ({
|
|||||||
bucket: process.env.COS_BUCKET ?? '',
|
bucket: process.env.COS_BUCKET ?? '',
|
||||||
region: process.env.COS_REGION ?? 'ap-guangzhou',
|
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_SECRET_KEY: Joi.string().allow('').default(''),
|
||||||
COS_BUCKET: Joi.string().allow('').default(''),
|
COS_BUCKET: Joi.string().allow('').default(''),
|
||||||
COS_REGION: Joi.string().default('ap-guangzhou'),
|
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 } },
|
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,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) {}
|
constructor(private readonly uploadService: UploadService) {}
|
||||||
|
|
||||||
@Get('credentials')
|
@Get('credentials')
|
||||||
@ApiOperation({ summary: '获取 COS 直传临时凭证(占位)' })
|
@ApiOperation({ summary: '获取 COS 直传临时凭证(预签名 PUT URL)' })
|
||||||
credentials(@CurrentUser() user: JwtPayload, @Query('key') key: string) {
|
credentials(@CurrentUser() user: JwtPayload, @Query('key') key: string) {
|
||||||
void user;
|
return this.uploadService.getUploadCredentials(
|
||||||
return this.uploadService.getUploadCredentials(key ?? `uploads/${Date.now()}`);
|
user.sub,
|
||||||
|
key ?? `uploads/${user.sub}/${Date.now()}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { UploadController } from './upload.controller';
|
import { UploadController } from './upload.controller';
|
||||||
import { UploadService } from './upload.service';
|
import { UploadService } from './upload.service';
|
||||||
|
import { CosModule } from '../cos/cos.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [CosModule],
|
||||||
controllers: [UploadController],
|
controllers: [UploadController],
|
||||||
providers: [UploadService],
|
providers: [UploadService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { CosService } from '../cos/cos.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
// 腾讯云 COS 上传服务(本轮仅提供签名/直传凭证占位,完整实现留后续)
|
// 上传凭证服务:给小程序签发 COS 直传预签名 URL;未配置时返回结构化“未配置”
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UploadService {
|
export class UploadService {
|
||||||
constructor(private readonly config: ConfigService) {}
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly cos: CosService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/** 生成小程序直传所需的临时密钥 / 预签名 URL(占位) */
|
/** 小程序直传:签发 COS 预签名 PUT URL 并记录 Upload 归属(满足“Upload 表有记录且归属当前用户”) */
|
||||||
async getUploadCredentials(key: string) {
|
async getUploadCredentials(userId: string, key: string) {
|
||||||
// TODO: 通过 COS STS 或预签名 URL 生成临时凭证
|
const bucket = this.config.get<string>('cos.bucket') ?? '';
|
||||||
//(SDK 已移除,接入时见 docs/cos-sdk-removal.md)
|
const region = this.config.get<string>('cos.region') ?? 'ap-guangzhou';
|
||||||
return {
|
|
||||||
key,
|
if (!this.cos.configured) {
|
||||||
bucket: this.config.get<string>('cos.bucket'),
|
return { key, bucket, region, presignedUrl: null, configured: false };
|
||||||
region: this.config.get<string>('cos.region'),
|
}
|
||||||
// 占位:实际应返回临时 SecretId/SecretKey/Token 或 presigned URL
|
|
||||||
credentials: null,
|
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