Merge feature R4 (upload + wordcloud + WCD dispatch) into main

This commit is contained in:
2026-08-13 01:50:04 +08:00
25 changed files with 2090 additions and 53 deletions
+7
View File
@@ -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
View File
@@ -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/dispatchR4 新增,下单后 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
View File
@@ -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 或网络白名单,防止被直接刷任务。
+1162 -16
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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;
+35
View File
@@ -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_idPOST /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
}
+4
View File
@@ -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,
], ],
+6
View File
@@ -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),
},
}); });
+4
View File
@@ -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),
}); });
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { CosService } from './cos.service';
@Module({
providers: [CosService],
exports: [CosService],
})
export class CosModule {}
+65
View File
@@ -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}`;
}
}
+2
View File
@@ -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 派单读取 designDataR3 契约 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,
+28
View File
@@ -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);
}
}
+11
View 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 {}
+26
View File
@@ -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) };
}
}
+5 -3
View File
@@ -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()}`,
);
} }
} }
+2
View File
@@ -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],
}) })
+24 -13
View File
@@ -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);
}
}
+59
View File
@@ -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);
}
}
+12
View File
@@ -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 {}
+483
View File
@@ -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;
}
/** 名单文本 → 单列 .xlsxwordcloud 按 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 拉取状态并回写 DBsuccess 时转存结果图并同步 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/jobswcd_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/jobswcd_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;
}
}
}