From 08bdea262597d6beb49acf61f4b0b781aed7ae1d Mon Sep 17 00:00:00 2001 From: obroccolio Date: Wed, 12 Aug 2026 19:08:26 +0800 Subject: [PATCH 1/7] =?UTF-8?q?docs:=20=E5=86=BB=E7=BB=93=20wordcloud=20WC?= =?UTF-8?q?D=20=E5=A5=91=E7=BA=A6=EF=BC=88v1.1=EF=BC=89=E4=B8=8E=20designD?= =?UTF-8?q?ata=20=E7=94=9F=E4=BA=A7=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-contract-v1.md | 44 +++++++++++++++++++++++++--- docs/wordcloud-contract.md | 59 +++++++++++++++++++++++++++----------- 2 files changed, 82 insertions(+), 21 deletions(-) diff --git a/docs/api-contract-v1.md b/docs/api-contract-v1.md index 14dd87e..0da33ca 100644 --- a/docs/api-contract-v1.md +++ b/docs/api-contract-v1.md @@ -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`。 diff --git a/docs/wordcloud-contract.md b/docs/wordcloud-contract.md index f63f69d..646a8bc 100644 --- a/docs/wordcloud-contract.md +++ b/docs/wordcloud-contract.md @@ -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/. // 每个 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 或网络白名单,防止被直接刷任务。 \ No newline at end of file From d47b62c33731cabcb3292a04043bbf1b3521ccc5 Mon Sep 17 00:00:00 2001 From: obroccolio Date: Wed, 12 Aug 2026 19:08:26 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(config):=20=E6=8E=A5=E5=85=A5=E8=AF=8D?= =?UTF-8?q?=E4=BA=91=E5=B9=B3=E5=8F=B0=20WORDCLOUD=5FAPI=5FURL/WORDCLOUD?= =?UTF-8?q?=5FTIMEOUT=5FMS=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 +++++++ src/config/configuration.ts | 6 ++++++ src/config/validation.schema.ts | 4 ++++ 3 files changed, 17 insertions(+) diff --git a/.env.example b/.env.example index 2b36ecc..c511079 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 90cb26c..fd6010b 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -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), + }, }); diff --git a/src/config/validation.schema.ts b/src/config/validation.schema.ts index 31344bb..88fe297 100644 --- a/src/config/validation.schema.ts +++ b/src/config/validation.schema.ts @@ -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), }); From 8925e83b8f5dbc7e40b3e3debd4fd180f4482b95 Mon Sep 17 00:00:00 2001 From: obroccolio Date: Wed, 12 Aug 2026 19:08:57 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat(wordcloud):=20WordCloudJob=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B/=E8=BF=81=E7=A7=BB=20+=20=E8=AF=8D=E4=BA=91=E7=94=9F?= =?UTF-8?q?=E6=88=90=E4=B8=8E=E8=BD=AE=E8=AF=A2=E9=80=82=E9=85=8D=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 783 +++++++++++++++++- package.json | 2 + .../migration.sql | 22 + .../migration.sql | 2 + prisma/schema.prisma | 26 + src/app.module.ts | 2 + src/wordcloud/wordcloud.controller.ts | 52 ++ src/wordcloud/wordcloud.module.ts | 9 + src/wordcloud/wordcloud.service.ts | 193 +++++ 9 files changed, 1076 insertions(+), 15 deletions(-) create mode 100644 prisma/migrations/20260812100838_add_word_cloud_job/migration.sql create mode 100644 prisma/migrations/20260812101101_add_remote_job_id/migration.sql create mode 100644 src/wordcloud/wordcloud.controller.ts create mode 100644 src/wordcloud/wordcloud.module.ts create mode 100644 src/wordcloud/wordcloud.service.ts diff --git a/package-lock.json b/package-lock.json index 427cd92..4cada14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dayjs": "^1.11.0", + "exceljs": "^4.4.0", "ioredis": "^5.4.0", "joi": "^17.13.0", "passport": "^0.7.0", @@ -34,6 +35,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", @@ -417,6 +419,47 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -1630,6 +1673,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", @@ -2127,6 +2180,96 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -2147,6 +2290,12 @@ "dev": true, "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2169,14 +2318,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -2206,6 +2353,28 @@ "node": ">=6.0.0" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2223,7 +2392,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -2231,6 +2399,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -2272,7 +2446,6 @@ "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -2330,7 +2503,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -2351,6 +2523,15 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -2363,6 +2544,23 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/bullmq": { "version": "5.81.3", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.3.tgz", @@ -2510,6 +2708,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2714,11 +2924,25 @@ "node": ">= 6" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -2793,6 +3017,12 @@ "node": ">=6.6.0" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -2860,6 +3090,31 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -3072,6 +3327,45 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/dynamic-dedupe": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", @@ -3141,6 +3435,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", @@ -3457,6 +3760,26 @@ "node": ">=0.8.x" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -3555,6 +3878,19 @@ "node": ">=8.0.0" } }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3784,6 +4120,12 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -3810,7 +4152,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -3828,6 +4169,34 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/fstream/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3998,7 +4367,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -4129,6 +4497,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4161,7 +4535,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -4313,6 +4686,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4495,6 +4874,48 @@ "npm": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -4526,6 +4947,48 @@ "json-buffer": "3.0.1" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4546,6 +5009,15 @@ "integrity": "sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==", "license": "MIT" }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4553,6 +5025,12 @@ "dev": true, "license": "MIT" }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/load-esm": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", @@ -4608,6 +5086,36 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -4620,12 +5128,31 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", "license": "MIT" }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", @@ -4644,6 +5171,12 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4657,6 +5190,18 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -4789,7 +5334,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -4802,7 +5346,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4994,7 +5537,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5174,6 +5716,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5265,7 +5813,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5438,6 +5985,12 @@ } } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -5556,6 +6109,36 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -5665,7 +6248,6 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, "license": "ISC", "dependencies": { "glob": "^7.1.3" @@ -5679,7 +6261,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -5747,6 +6328,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -5882,6 +6475,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -6185,6 +6784,22 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/terser": { "version": "5.49.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.1.tgz", @@ -6331,6 +6946,15 @@ "node": ">=18" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6371,6 +6995,15 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -6722,6 +7355,54 @@ "node": ">= 0.8" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -6778,6 +7459,16 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -7013,6 +7704,12 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -7068,6 +7765,62 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } } } } diff --git a/package.json b/package.json index 9830f15..f6917ac 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dayjs": "^1.11.0", + "exceljs": "^4.4.0", "ioredis": "^5.4.0", "joi": "^17.13.0", "passport": "^0.7.0", @@ -46,6 +47,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", diff --git a/prisma/migrations/20260812100838_add_word_cloud_job/migration.sql b/prisma/migrations/20260812100838_add_word_cloud_job/migration.sql new file mode 100644 index 0000000..7a5ba4d --- /dev/null +++ b/prisma/migrations/20260812100838_add_word_cloud_job/migration.sql @@ -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; diff --git a/prisma/migrations/20260812101101_add_remote_job_id/migration.sql b/prisma/migrations/20260812101101_add_remote_job_id/migration.sql new file mode 100644 index 0000000..fe94a8b --- /dev/null +++ b/prisma/migrations/20260812101101_add_remote_job_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "WordCloudJob" ADD COLUMN "remoteJobId" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2e73458..41b3a6c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,6 +29,7 @@ model User { designLists DesignList[] uploads Upload[] customizations CustomizationTask[] + wordCloudJobs WordCloudJob[] @@index([phone]) } @@ -221,3 +222,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 +} diff --git a/src/app.module.ts b/src/app.module.ts index f44e492..0c28c28 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,7 @@ 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 { QueueModule } from './queue/queue.module'; import { HealthModule } from './health/health.module'; @@ -40,6 +41,7 @@ import { HealthModule } from './health/health.module'; OrdersModule, PaymentsModule, UploadModule, + WordCloudModule, QueueModule, HealthModule, ], diff --git a/src/wordcloud/wordcloud.controller.ts b/src/wordcloud/wordcloud.controller.ts new file mode 100644 index 0000000..9b94ed3 --- /dev/null +++ b/src/wordcloud/wordcloud.controller.ts @@ -0,0 +1,52 @@ +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')) + 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); + } +} diff --git a/src/wordcloud/wordcloud.module.ts b/src/wordcloud/wordcloud.module.ts new file mode 100644 index 0000000..670041e --- /dev/null +++ b/src/wordcloud/wordcloud.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { WordCloudController } from './wordcloud.controller'; +import { WordCloudService } from './wordcloud.service'; + +@Module({ + controllers: [WordCloudController], + providers: [WordCloudService], +}) +export class WordCloudModule {} diff --git a/src/wordcloud/wordcloud.service.ts b/src/wordcloud/wordcloud.service.ts new file mode 100644 index 0000000..de9f5f0 --- /dev/null +++ b/src/wordcloud/wordcloud.service.ts @@ -0,0 +1,193 @@ +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import ExcelJS from 'exceljs'; +import { PrismaService } from '../prisma/prisma.service'; + +type RemoteStatus = 'queued' | 'running' | 'success' | 'failed'; + +/** Buffer → 独立的 ArrayBuffer(用于 Blob 构造,规避 Buffer 类型不兼容) */ +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 { + private readonly logger = new Logger(WordCloudService.name); + + constructor( + private readonly config: ConfigService, + private readonly prisma: PrismaService, + ) {} + + /** 词云平台是否已配置(WORDCLOUD_API_URL 非空) */ + get configured(): boolean { + return !!this.config.get('wordcloud.apiUrl'); + } + + private get apiUrl(): string { + return (this.config.get('wordcloud.apiUrl') ?? '').replace(/\/+$/, ''); + } + + private get timeoutMs(): number { + return this.config.get('wordcloud.timeoutMs') ?? 30000; + } + + /** 名单文本 → 单列 .xlsx(wordcloud 按 DATA_COL_INDEX 读名字列,A 列 index=0) */ + private async buildNamesXlsx(names: string[]): Promise { + 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, + ): Promise { + 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 { + 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 = {}; + 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 = { + 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'; + } + + /** 查询本人词云任务:归属校验 → 代理轮询 wordcloud → 回写 DB → 返回小程序侧模型 */ + 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 remote: Record; + try { + const res = await axios.get(`${this.apiUrl}/api/jobs/${job.remoteJobId}`, { + timeout: this.timeoutMs, + }); + remote = res.data; + } catch (e) { + this.logger.warn(`轮询词云任务失败: ${(e as Error).message}`); + throw new HttpException('词云平台暂不可用', HttpStatus.SERVICE_UNAVAILABLE); + } + + const status = this.mapStatus(remote.status as string | undefined); + const progress = + typeof remote.progress_percent === 'number' ? remote.progress_percent : job.progress; + + // success 时尝试解析结果图(当前 COS 写入未实现,P1 接入后返回公网 URL) + const imageUrl = status === 'success' ? await this.resolveResultImage(job.remoteJobId) : null; + + const updated = await this.prisma.wordCloudJob.update({ + where: { id: job.id }, + data: { + status: status.toUpperCase() as 'QUEUED' | 'RUNNING' | 'SUCCESS' | 'FAILED', + progress, + imageUrl, + error: (remote.error as string | null) ?? null, + }, + }); + + return { + id: updated.id, + status, + progress, + imageUrl: updated.imageUrl ?? undefined, + error: updated.error ?? undefined, + }; + } + + /** 下载 wordcloud 结果 PNG 并返回小程序可用地址(COS 转存前置,P1 实现) */ + private async resolveResultImage(_remoteJobId: string): Promise { + // TODO(P1 COS):GET /api/jobs/{id}/result → 下载 png → 写入 COS → 返回公网 URL。 + // 当前 COS 写入尚未实现,结果图地址留待 P1 接入。 + return null; + } +} From 910da1c369ec77a8307adbe97cddddb956f8142c Mon Sep 17 00:00:00 2001 From: obroccolio Date: Wed, 12 Aug 2026 19:30:30 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat(cos):=20=E6=8E=A5=E5=85=A5=20COS=20?= =?UTF-8?q?=E7=9B=B4=E4=BC=A0=E9=A2=84=E7=AD=BE=E5=90=8D=20+=20=E8=AF=8D?= =?UTF-8?q?=E4=BA=91=E7=BB=93=E6=9E=9C=E5=9B=BE=E8=BD=AC=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 394 ++++++++++++++++++++++++++++- package.json | 1 + src/cos/cos.module.ts | 8 + src/cos/cos.service.ts | 65 +++++ src/upload/upload.module.ts | 2 + src/upload/upload.service.ts | 28 +- src/wordcloud/wordcloud.module.ts | 2 + src/wordcloud/wordcloud.service.ts | 24 +- 8 files changed, 506 insertions(+), 18 deletions(-) create mode 100644 src/cos/cos.module.ts create mode 100644 src/cos/cos.service.ts diff --git a/package-lock.json b/package-lock.json index 4cada14..d2bc66e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "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", @@ -2290,6 +2291,24 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2302,6 +2321,21 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT" + }, "node_modules/axios": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", @@ -2353,6 +2387,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -2708,6 +2751,12 @@ ], "license": "CC-BY-4.0" }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, "node_modules/chainsaw": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", @@ -3040,6 +3089,87 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cos-fast-xml-parser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cos-fast-xml-parser/-/cos-fast-xml-parser-1.0.0.tgz", + "integrity": "sha512-kOPJb1cuj+gc5E8jN5ekZn4rgQHaxYTLcQT+jmbHtlTNcNjv7wnOBEYH2uRA0pG3h8giot4z9h4WFLDuXATZwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + }, + "engines": { + "node": ">= 9" + } + }, + "node_modules/cos-nodejs-sdk-v5": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cos-nodejs-sdk-v5/-/cos-nodejs-sdk-v5-3.0.0.tgz", + "integrity": "sha512-xUqiDdUxfEjfaWoyBA3qsSnlQVHPz13DRFErL+NliadBVUeeZhAPHGVa2inGpUvPTOs52ALIKkMbzL+inSqvXg==", + "license": "ISC", + "dependencies": { + "cos-fast-xml-parser": "^1.0.0", + "cos-request": "^1.3.0", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 9" + } + }, + "node_modules/cos-request": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/cos-request/-/cos-request-1.3.3.tgz", + "integrity": "sha512-zD7fKMAIMfJNHssx8VZE5mUQ67hs3QDi7S2BX7JuD8MzT1XYqEOAN42PJ0BtRhgutnn+8HiUF+Fup+j5zORasQ==", + "license": "Apache-2.0", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.5.6", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "^6.15.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~4.1.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cos-request/node_modules/form-data": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", + "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, "node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", @@ -3150,6 +3280,18 @@ "node": ">= 8" } }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", @@ -3376,6 +3518,16 @@ "xtend": "^4.0.0" } }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -3855,6 +4007,21 @@ "devOptional": true, "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", @@ -4058,6 +4225,15 @@ } } }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, "node_modules/fork-ts-checker-webpack-plugin": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", @@ -4243,6 +4419,15 @@ "node": ">= 0.4" } }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, "node_modules/giget": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", @@ -4438,6 +4623,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -4673,6 +4873,12 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -4699,6 +4905,12 @@ "dev": true, "license": "ISC" }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT" + }, "node_modules/iterare": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", @@ -4791,6 +5003,12 @@ "js-yaml": "bin/js-yaml.mjs" } }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT" + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4805,6 +5023,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -4819,6 +5043,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4874,6 +5104,21 @@ "npm": ">=6" } }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -5567,6 +5812,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5891,6 +6145,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6013,11 +6273,22 @@ "node": ">=10" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6056,6 +6327,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", @@ -6190,6 +6467,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -6626,6 +6909,31 @@ "node": ">=0.10.0" } }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -6709,6 +7017,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/strtok3": { "version": "10.3.5", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", @@ -6995,6 +7315,30 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/traverse": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", @@ -7217,6 +7561,24 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7444,6 +7806,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7494,6 +7866,26 @@ "node": ">= 0.8" } }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, "node_modules/watchpack": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", diff --git a/package.json b/package.json index f6917ac..bb52189 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "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", diff --git a/src/cos/cos.module.ts b/src/cos/cos.module.ts new file mode 100644 index 0000000..ebfa006 --- /dev/null +++ b/src/cos/cos.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { CosService } from './cos.service'; + +@Module({ + providers: [CosService], + exports: [CosService], +}) +export class CosModule {} diff --git a/src/cos/cos.service.ts b/src/cos/cos.service.ts new file mode 100644 index 0000000..a274d57 --- /dev/null +++ b/src/cos/cos.service.ts @@ -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('cos.secretId') ?? ''; + const secretKey = this.config.get('cos.secretKey') ?? ''; + this.client = secretId && secretKey ? new COS({ SecretId: secretId, SecretKey: secretKey }) : null; + } + + private get bucketName(): string { + return this.config.get('cos.bucket') ?? ''; + } + + private get regionName(): string { + return this.config.get('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 { + 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 { + 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}`; + } +} diff --git a/src/upload/upload.module.ts b/src/upload/upload.module.ts index 8966ac2..a124647 100644 --- a/src/upload/upload.module.ts +++ b/src/upload/upload.module.ts @@ -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], }) diff --git a/src/upload/upload.service.ts b/src/upload/upload.service.ts index 2401ba3..36ce635 100644 --- a/src/upload/upload.service.ts +++ b/src/upload/upload.service.ts @@ -1,21 +1,25 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { CosService } from '../cos/cos.service'; -// 腾讯云 COS 上传服务(本轮仅提供签名/直传凭证占位,完整实现留后续) +// 上传凭证服务:给小程序签发 COS 直传预签名 URL;未配置时返回结构化“未配置” @Injectable() export class UploadService { - constructor(private readonly config: ConfigService) {} + constructor( + private readonly config: ConfigService, + private readonly cos: CosService, + ) {} - /** 生成小程序直传所需的临时密钥 / 预签名 URL(占位) */ + /** 小程序直传:签发 COS 预签名 PUT URL(小程序不持有永久密钥) */ async getUploadCredentials(key: string) { - // TODO: 通过 COS STS 或预签名 URL 生成临时凭证 - //(SDK 已移除,接入时见 docs/cos-sdk-removal.md) - return { - key, - bucket: this.config.get('cos.bucket'), - region: this.config.get('cos.region'), - // 占位:实际应返回临时 SecretId/SecretKey/Token 或 presigned URL - credentials: null, - }; + const bucket = this.config.get('cos.bucket') ?? ''; + const region = this.config.get('cos.region') ?? 'ap-guangzhou'; + + if (!this.cos.configured) { + return { key, bucket, region, presignedUrl: null, configured: false }; + } + + const presignedUrl = await this.cos.getPresignedPutUrl(key); + return { key, bucket, region, presignedUrl, configured: true }; } } diff --git a/src/wordcloud/wordcloud.module.ts b/src/wordcloud/wordcloud.module.ts index 670041e..c323e56 100644 --- a/src/wordcloud/wordcloud.module.ts +++ b/src/wordcloud/wordcloud.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { WordCloudController } from './wordcloud.controller'; import { WordCloudService } from './wordcloud.service'; +import { CosModule } from '../cos/cos.module'; @Module({ + imports: [CosModule], controllers: [WordCloudController], providers: [WordCloudService], }) diff --git a/src/wordcloud/wordcloud.service.ts b/src/wordcloud/wordcloud.service.ts index de9f5f0..c02f656 100644 --- a/src/wordcloud/wordcloud.service.ts +++ b/src/wordcloud/wordcloud.service.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import axios from 'axios'; import ExcelJS from 'exceljs'; import { PrismaService } from '../prisma/prisma.service'; +import { CosService } from '../cos/cos.service'; type RemoteStatus = 'queued' | 'running' | 'success' | 'failed'; @@ -25,6 +26,7 @@ export class WordCloudService { constructor( private readonly config: ConfigService, private readonly prisma: PrismaService, + private readonly cos: CosService, ) {} /** 词云平台是否已配置(WORDCLOUD_API_URL 非空) */ @@ -184,10 +186,22 @@ export class WordCloudService { }; } - /** 下载 wordcloud 结果 PNG 并返回小程序可用地址(COS 转存前置,P1 实现) */ - private async resolveResultImage(_remoteJobId: string): Promise { - // TODO(P1 COS):GET /api/jobs/{id}/result → 下载 png → 写入 COS → 返回公网 URL。 - // 当前 COS 写入尚未实现,结果图地址留待 P1 接入。 - return null; + /** 下载 wordcloud 结果 PNG 转存 COS,返回小程序可访问的 COS 公网 URL;COS 未配置时返回空 */ + private async resolveResultImage(remoteJobId: string): Promise { + 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; + } } } From 34f48d0d5903dbb4ad136bc6768dc9b09018d288 Mon Sep 17 00:00:00 2001 From: obroccolio Date: Wed, 12 Aug 2026 20:55:37 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(wordcloud):=20=E4=B8=8B=E5=8D=95=20WCD?= =?UTF-8?q?=20=E6=B4=BE=E5=8D=95=EF=BC=88buildWcdPackage=20+=20dispatch=20?= =?UTF-8?q?+=20=E5=B9=82=E7=AD=89=20+=20=E8=AE=A2=E5=8D=95=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E5=85=B3=E8=81=94=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 1 + package.json | 1 + .../migration.sql | 15 ++ prisma/schema.prisma | 9 + src/orders/orders.service.ts | 2 + src/wordcloud/order-dispatch.controller.ts | 22 ++ src/wordcloud/wordcloud.module.ts | 3 +- src/wordcloud/wordcloud.service.ts | 205 ++++++++++++++++++ 8 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260812124858_r4_order_design_and_dispatch/migration.sql create mode 100644 src/wordcloud/order-dispatch.controller.ts diff --git a/package-lock.json b/package-lock.json index d2bc66e..07aba9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "exceljs": "^4.4.0", "ioredis": "^5.4.0", "joi": "^17.13.0", + "jszip": "^3.10.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", diff --git a/package.json b/package.json index bb52189..82ec5d3 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "exceljs": "^4.4.0", "ioredis": "^5.4.0", "joi": "^17.13.0", + "jszip": "^3.10.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", diff --git a/prisma/migrations/20260812124858_r4_order_design_and_dispatch/migration.sql b/prisma/migrations/20260812124858_r4_order_design_and_dispatch/migration.sql new file mode 100644 index 0000000..1244626 --- /dev/null +++ b/prisma/migrations/20260812124858_r4_order_design_and_dispatch/migration.sql @@ -0,0 +1,15 @@ +-- AlterTable +ALTER TABLE "CustomizationTask" ADD COLUMN "orderId" TEXT, +ADD COLUMN "wordcloudJobId" TEXT; + +-- AlterTable +ALTER TABLE "Order" ADD COLUMN "designListId" TEXT; + +-- CreateIndex +CREATE INDEX "CustomizationTask_orderId_idx" ON "CustomizationTask"("orderId"); + +-- CreateIndex +CREATE INDEX "Order_designListId_idx" ON "Order"("designListId"); + +-- AddForeignKey +ALTER TABLE "Order" ADD CONSTRAINT "Order_designListId_fkey" FOREIGN KEY ("designListId") REFERENCES "DesignList"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 41b3a6c..94ce1a4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -85,6 +85,7 @@ model DesignList { user User @relation(fields: [userId], references: [id]) customizations CustomizationTask[] + orders Order[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -126,7 +127,10 @@ model Order { status OrderStatus @default(PENDING) totalAmount Decimal @db.Decimal(10, 2) addressSnapshot Json + // 关联的设计清单(R4 下单后 WCD 派单据此读取 designData) + designListId String? user User @relation(fields: [userId], references: [id]) + designList DesignList? @relation(fields: [designListId], references: [id]) items OrderItem[] payment Payment? @@ -135,6 +139,7 @@ model Order { @@index([userId]) @@index([status]) + @@index([designListId]) } enum OrderStatus { @@ -203,6 +208,9 @@ model CustomizationTask { id String @id @default(cuid()) designListId String? userId String? + // R4 下单后 WCD 派单:关联的订单与词云任务 + orderId String? + wordcloudJobId String? status CustomizationTaskStatus @default(PENDING) resultUrl String? queueJobId String? @@ -213,6 +221,7 @@ model CustomizationTask { updatedAt DateTime @updatedAt @@index([designListId]) + @@index([orderId]) @@index([status]) } diff --git a/src/orders/orders.service.ts b/src/orders/orders.service.ts index da43251..cd256a5 100644 --- a/src/orders/orders.service.ts +++ b/src/orders/orders.service.ts @@ -43,6 +43,8 @@ export class OrdersService { user: { connect: { id: userId } }, totalAmount, addressSnapshot: dto.addressSnapshot as Prisma.InputJsonValue, + // 关联设计清单(R4 下单后 WCD 派单读取 designData;R3 契约 CreateOrderDto 已含该字段) + designList: dto.designListId ? { connect: { id: dto.designListId } } : undefined, items: { create: dto.items.map((it) => ({ productId: it.productId, diff --git a/src/wordcloud/order-dispatch.controller.ts b/src/wordcloud/order-dispatch.controller.ts new file mode 100644 index 0000000..5435edb --- /dev/null +++ b/src/wordcloud/order-dispatch.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Param, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator'; +import { WordCloudService } from './wordcloud.service'; + +/** + * 下单后 WCD 派单路由。 + * 挂在 orders 基路径下(R3 的 OrdersController 不负责本端点),由 R4 词云服务实现。 + * 幂等语义:同一订单重复投递由上层/CustomizationTask.orderId 唯一约束,本端点只做创建。 + */ +@ApiTags('词云下单派单') +@ApiBearerAuth() +@Controller('orders') +export class OrderDispatchController { + constructor(private readonly wordCloudService: WordCloudService) {} + + @Post(':id/dispatch') + @ApiOperation({ summary: '下单后 WCD 派单(幂等触发)' }) + dispatch(@CurrentUser() user: JwtPayload, @Param('id') id: string) { + return this.wordCloudService.dispatchToWordcloud(id, user.sub); + } +} diff --git a/src/wordcloud/wordcloud.module.ts b/src/wordcloud/wordcloud.module.ts index c323e56..a2a0ece 100644 --- a/src/wordcloud/wordcloud.module.ts +++ b/src/wordcloud/wordcloud.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { WordCloudController } from './wordcloud.controller'; +import { OrderDispatchController } from './order-dispatch.controller'; import { WordCloudService } from './wordcloud.service'; import { CosModule } from '../cos/cos.module'; @Module({ imports: [CosModule], - controllers: [WordCloudController], + controllers: [WordCloudController, OrderDispatchController], providers: [WordCloudService], }) export class WordCloudModule {} diff --git a/src/wordcloud/wordcloud.service.ts b/src/wordcloud/wordcloud.service.ts index c02f656..9b07495 100644 --- a/src/wordcloud/wordcloud.service.ts +++ b/src/wordcloud/wordcloud.service.ts @@ -2,6 +2,8 @@ import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; import ExcelJS from 'exceljs'; +import JSZip from 'jszip'; +import * as crypto from 'crypto'; import { PrismaService } from '../prisma/prisma.service'; import { CosService } from '../cos/cos.service'; @@ -139,6 +141,13 @@ export class WordCloudService { return 'queued'; } + private mapTaskStatus(s: string): 'queued' | 'running' | 'success' | 'failed' { + if (s === 'RUNNING') return 'running'; + if (s === 'SUCCESS') return 'success'; + if (s === 'FAILED') return 'failed'; + return 'queued'; + } + /** 查询本人词云任务:归属校验 → 代理轮询 wordcloud → 回写 DB → 返回小程序侧模型 */ async getUserJob(userId: string, id: string) { const job = await this.prisma.wordCloudJob.findFirst({ where: { id, userId } }); @@ -186,6 +195,202 @@ export class WordCloudService { }; } + /** + * 下单后 WCD 派单(幂等触发层面由 controller/业务方约束)。 + * 读取订单关联设计清单的 designData → 构造 .wcd → POST /api/jobs(wcd_file)。 + * WORDCLOUD_API_URL 未配置时返回结构化 not_configured,不做假成功。 + */ + async dispatchToWordcloud(orderId: string, userId: string) { + if (!this.configured) { + return { orderId, status: 'not_configured' as const, message: '词云平台未配置' }; + } + + const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } }); + if (!order) { + throw new HttpException('订单不存在', HttpStatus.NOT_FOUND); + } + + // 幂等:同一订单已投递过则直接返回既有任务,不重复创建 + const existing = await this.prisma.customizationTask.findFirst({ + where: { orderId: order.id }, + }); + if (existing) { + return { + orderId: order.id, + status: this.mapTaskStatus(existing.status), + wordcloudJobId: existing.wordcloudJobId ?? undefined, + message: '该订单已投递过', + }; + } + + if (!order.designListId) { + throw new HttpException('订单未关联设计清单', HttpStatus.BAD_REQUEST); + } + const dl = await this.prisma.designList.findFirst({ + where: { id: order.designListId, userId }, + }); + if (!dl) { + throw new HttpException('设计清单不存在', HttpStatus.NOT_FOUND); + } + const items = Array.isArray(dl.items) ? (dl.items as Record[]) : []; + const designItem = items.find((it) => it?.designData) ?? items[0]; + if (!designItem?.designData) { + throw new HttpException('订单缺少设计数据', HttpStatus.BAD_REQUEST); + } + + const wcd = await this.buildWcdPackage( + order.orderNo, + designItem.designData as Record, + ); + const remoteJobId = await this.createRemoteWcdJob(wcd, order.orderNo); + + const job = await this.prisma.wordCloudJob.create({ + data: { userId, remoteJobId, status: 'RUNNING' }, + }); + const task = await this.prisma.customizationTask.create({ + data: { + userId, + orderId: order.id, + designListId: dl.id, + wordcloudJobId: job.id, + status: 'RUNNING', + }, + }); + + this.logger.log(`订单 ${order.id} 已投递词云任务 ${remoteJobId} (task=${task.id})`); + return { orderId: order.id, status: 'queued' as const, wordcloudJobId: job.id, message: '已投递词云平台' }; + } + + /** 由 designData 构造 .wcd 包(对齐 wordcloud /api/design-templates/import 的导入契约) */ + private async buildWcdPackage( + orderNo: string, + designData: Record, + ): Promise { + const category = designData.category as { mask?: { width?: number; height?: number } } | undefined; + const mask = category?.mask; + const width = mask?.width ?? 1200; + const height = mask?.height ?? 1200; + const background = (designData.background as { color?: string })?.color ?? '#ffffff'; + + const elements: Record[] = []; + const assets: { id: string; url: string }[] = []; + + const pushImage = ( + url: unknown, + name: string, + opts: { x?: number; y?: number; width?: number; height?: number; rotation?: number } = {}, + ) => { + if (typeof url !== 'string' || !/^https?:/.test(url)) { + return; // 本地/临时路径或缺失:跳过(需先完成贴纸持久化) + } + const id = `asset_${assets.length + 1}`; + assets.push({ id, url }); + elements.push({ + id: `${name}_${assets.length}`, + type: 'sticker', + name, + assetId: id, + x: opts.x ?? 0, + y: opts.y ?? 0, + width: opts.width ?? width, + height: opts.height ?? height, + rotation: opts.rotation ?? 0, + opacity: 1, + }); + }; + + // 底图 + 词云结果图 + 贴纸 + pushImage((designData.background as { src?: string })?.src, 'background'); + pushImage((designData.wordcloud as { imageUrl?: string })?.imageUrl, 'wordcloud'); + for (const s of (designData.stickers as Record[] | undefined) ?? []) { + pushImage(s.src, String(s.id ?? 'sticker'), { + x: s.x as number, + y: s.y as number, + width: s.width as number, + height: s.height as number, + rotation: s.rotation as number, + }); + } + + const document = { + width, + height, + background, + layers: [{ id: 'layer-1', name: '设计', visible: true, locked: false }], + layerFolders: [], + elements, + }; + + const zip = new JSZip(); + const manifestAssets: Record[] = []; + for (const asset of assets) { + const { buffer, mime } = await this.downloadAsset(asset.url); + const ext = mime === 'image/svg+xml' ? 'svg' : mime === 'image/jpeg' ? 'jpg' : 'png'; + if (!buffer.length) continue; + manifestAssets.push({ + id: asset.id, + name: asset.id, + type: ext, + mimeType: mime, + sha256: crypto.createHash('sha256').update(buffer).digest('hex'), + size: buffer.length, + }); + zip.file(`assets/${asset.id}.${ext}`, buffer); + } + + const manifest = { + format: 'wordcloud-canvas', + version: 1, + name: `order-${orderNo}`, + canvas: { width, height, background }, + assets: manifestAssets, + meta: { orderNo, wordcloud: designData.wordcloud ?? null }, + }; + + zip.file('manifest.json', JSON.stringify(manifest)); + zip.file('document.json', JSON.stringify(document)); + return Buffer.from(await zip.generateAsync({ type: 'nodebuffer' })); + } + + /** 从公开 URL 下载素材字节并推断 MIME */ + private async downloadAsset(url: string): Promise<{ buffer: Buffer; mime: string }> { + const lower = url.toLowerCase(); + const mime = lower.endsWith('.svg') + ? 'image/svg+xml' + : lower.match(/\.jpe?g($|\?)/) + ? 'image/jpeg' + : 'image/png'; + const res = await axios.get(url, { responseType: 'arraybuffer', timeout: this.timeoutMs }); + return { buffer: Buffer.from(res.data), mime }; + } + + /** 代理创建 WCD 生产任务(POST /api/jobs,wcd_file 模式;wordcloud 侧实现后置) */ + private async createRemoteWcdJob(wcd: Buffer, orderNo: string): Promise { + if (!this.configured) { + throw new HttpException('词云平台未配置', HttpStatus.SERVICE_UNAVAILABLE); + } + const form = new FormData(); + form.append('wcd_file', new Blob([new Uint8Array(wcd)]), `${orderNo}.wcd`); + form.append('params', JSON.stringify({ MODE: 'WCD' })); + + let res; + try { + res = await axios.post(`${this.apiUrl}/api/jobs`, form, { + timeout: this.timeoutMs, + maxBodyLength: Infinity, + maxContentLength: Infinity, + }); + } catch (e) { + this.logger.error(`WCD 派单失败: ${(e as Error).message}`); + throw new HttpException('词云平台暂不可用', HttpStatus.SERVICE_UNAVAILABLE); + } + const jobId = res.data?.job_id; + if (!jobId) { + throw new HttpException('词云平台未返回任务ID', HttpStatus.BAD_GATEWAY); + } + return jobId; + } + /** 下载 wordcloud 结果 PNG 转存 COS,返回小程序可访问的 COS 公网 URL;COS 未配置时返回空 */ private async resolveResultImage(remoteJobId: string): Promise { if (!this.cos.configured) { From 3f7ce2cdcdf412bbf53281cf45d729a2650dcf0b Mon Sep 17 00:00:00 2001 From: obroccolio Date: Thu, 13 Aug 2026 01:34:19 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat(r4):=20POST=20/api/sketch=20+=20Upload?= =?UTF-8?q?=20=E5=BD=92=E5=B1=9E=E8=AE=B0=E5=BD=95=20+=20=E6=B4=BE?= =?UTF-8?q?=E5=8D=95=E7=8A=B6=E6=80=81=E5=90=8E=E5=8F=B0=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E4=B8=8E=E5=A4=A7=E5=B0=8F=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 2 + src/sketch/sketch.controller.ts | 28 +++++++ src/sketch/sketch.module.ts | 11 +++ src/sketch/sketch.service.ts | 26 ++++++ src/upload/upload.controller.ts | 8 +- src/upload/upload.service.ts | 11 ++- src/wordcloud/wordcloud.controller.ts | 2 +- src/wordcloud/wordcloud.service.ts | 109 ++++++++++++++++++++------ 8 files changed, 166 insertions(+), 31 deletions(-) create mode 100644 src/sketch/sketch.controller.ts create mode 100644 src/sketch/sketch.module.ts create mode 100644 src/sketch/sketch.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 0c28c28..7b2cc82 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ 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'; @@ -42,6 +43,7 @@ import { HealthModule } from './health/health.module'; PaymentsModule, UploadModule, WordCloudModule, + SketchModule, QueueModule, HealthModule, ], diff --git a/src/sketch/sketch.controller.ts b/src/sketch/sketch.controller.ts new file mode 100644 index 0000000..993cd65 --- /dev/null +++ b/src/sketch/sketch.controller.ts @@ -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); + } +} diff --git a/src/sketch/sketch.module.ts b/src/sketch/sketch.module.ts new file mode 100644 index 0000000..68f6c9b --- /dev/null +++ b/src/sketch/sketch.module.ts @@ -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 {} diff --git a/src/sketch/sketch.service.ts b/src/sketch/sketch.service.ts new file mode 100644 index 0000000..bb126d7 --- /dev/null +++ b/src/sketch/sketch.service.ts @@ -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) }; + } +} \ No newline at end of file diff --git a/src/upload/upload.controller.ts b/src/upload/upload.controller.ts index f2babed..4cca469 100644 --- a/src/upload/upload.controller.ts +++ b/src/upload/upload.controller.ts @@ -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()}`, + ); } } diff --git a/src/upload/upload.service.ts b/src/upload/upload.service.ts index 36ce635..111b003 100644 --- a/src/upload/upload.service.ts +++ b/src/upload/upload.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { CosService } from '../cos/cos.service'; +import { PrismaService } from '../prisma/prisma.service'; // 上传凭证服务:给小程序签发 COS 直传预签名 URL;未配置时返回结构化“未配置” @Injectable() @@ -8,10 +9,11 @@ export class UploadService { constructor( private readonly config: ConfigService, private readonly cos: CosService, + private readonly prisma: PrismaService, ) {} - /** 小程序直传:签发 COS 预签名 PUT URL(小程序不持有永久密钥) */ - async getUploadCredentials(key: string) { + /** 小程序直传:签发 COS 预签名 PUT URL 并记录 Upload 归属(满足“Upload 表有记录且归属当前用户”) */ + async getUploadCredentials(userId: string, key: string) { const bucket = this.config.get('cos.bucket') ?? ''; const region = this.config.get('cos.region') ?? 'ap-guangzhou'; @@ -20,6 +22,11 @@ export class UploadService { } 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 }; } } diff --git a/src/wordcloud/wordcloud.controller.ts b/src/wordcloud/wordcloud.controller.ts index 9b94ed3..39517c4 100644 --- a/src/wordcloud/wordcloud.controller.ts +++ b/src/wordcloud/wordcloud.controller.ts @@ -24,7 +24,7 @@ export class WordCloudController { @Post('generate') @ApiOperation({ summary: '创建词云任务(底图 + 名单文本)' }) @ApiConsumes('multipart/form-data') - @UseInterceptors(FileInterceptor('image')) + @UseInterceptors(FileInterceptor('image', { limits: { fileSize: 10 * 1024 * 1024 } })) async generate( @CurrentUser() user: JwtPayload, @UploadedFile() image: Express.Multer.File | undefined, diff --git a/src/wordcloud/wordcloud.service.ts b/src/wordcloud/wordcloud.service.ts index 9b07495..38c9784 100644 --- a/src/wordcloud/wordcloud.service.ts +++ b/src/wordcloud/wordcloud.service.ts @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; import ExcelJS from 'exceljs'; @@ -22,7 +22,7 @@ function toArrayBuffer(buf: Buffer): ArrayBuffer { * 轮询 wordcloud GET /api/jobs/{id} → 回写状态/产物。 */ @Injectable() -export class WordCloudService { +export class WordCloudService implements OnModuleInit { private readonly logger = new Logger(WordCloudService.name); constructor( @@ -149,6 +149,78 @@ export class WordCloudService { } /** 查询本人词云任务:归属校验 → 代理轮询 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; + 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; + } + + /** 查询本人词云任务:归属校验 → 实时代理轮询 wordcloud → 返回小程序侧模型 */ async getUserJob(userId: string, id: string) { const job = await this.prisma.wordCloudJob.findFirst({ where: { id, userId } }); if (!job) { @@ -158,38 +230,25 @@ export class WordCloudService { throw new HttpException('任务尚未创建', HttpStatus.CONFLICT); } - let remote: Record; + let updated: { + id: string; + status: string; + progress: number; + imageUrl: string | null; + error: string | null; + }; try { - const res = await axios.get(`${this.apiUrl}/api/jobs/${job.remoteJobId}`, { - timeout: this.timeoutMs, - }); - remote = res.data; + updated = await this.refreshRemoteJob(job); } catch (e) { this.logger.warn(`轮询词云任务失败: ${(e as Error).message}`); throw new HttpException('词云平台暂不可用', HttpStatus.SERVICE_UNAVAILABLE); } - const status = this.mapStatus(remote.status as string | undefined); - const progress = - typeof remote.progress_percent === 'number' ? remote.progress_percent : job.progress; - - // success 时尝试解析结果图(当前 COS 写入未实现,P1 接入后返回公网 URL) - const imageUrl = status === 'success' ? await this.resolveResultImage(job.remoteJobId) : null; - - const updated = await this.prisma.wordCloudJob.update({ - where: { id: job.id }, - data: { - status: status.toUpperCase() as 'QUEUED' | 'RUNNING' | 'SUCCESS' | 'FAILED', - progress, - imageUrl, - error: (remote.error as string | null) ?? null, - }, - }); - + const status = this.mapStatus(updated.status.toLowerCase()) as 'queued' | 'running' | 'success' | 'failed'; return { id: updated.id, status, - progress, + progress: updated.progress, imageUrl: updated.imageUrl ?? undefined, error: updated.error ?? undefined, }; From 6552b15d2e36e1d4272ca7ad9d7f34516416e74e Mon Sep 17 00:00:00 2001 From: obroccolio Date: Thu, 13 Aug 2026 01:40:14 +0800 Subject: [PATCH 7/7] =?UTF-8?q?feat(wordcloud):=20GET=20/api/wordcloud/job?= =?UTF-8?q?s/:id/result=20=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/wordcloud/wordcloud.controller.ts | 7 +++++++ src/wordcloud/wordcloud.service.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/wordcloud/wordcloud.controller.ts b/src/wordcloud/wordcloud.controller.ts index 39517c4..102409c 100644 --- a/src/wordcloud/wordcloud.controller.ts +++ b/src/wordcloud/wordcloud.controller.ts @@ -49,4 +49,11 @@ export class WordCloudController { 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); + } } diff --git a/src/wordcloud/wordcloud.service.ts b/src/wordcloud/wordcloud.service.ts index 38c9784..6600382 100644 --- a/src/wordcloud/wordcloud.service.ts +++ b/src/wordcloud/wordcloud.service.ts @@ -220,6 +220,18 @@ export class WordCloudService implements OnModuleInit { 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 } });