From 5a5f0a52dc882688c4132c329d635805666ece9e Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 15 Oct 2026 17:43:35 +0800 Subject: [PATCH] fix(deploy): validate public storage URLs and document image API --- docs/外部接口文档.md | 222 ++++++++++++++++++++++-- scripts/git-incremental-deploy.ps1 | 8 +- scripts/git-incremental-deploy.test.mjs | 30 ++++ 3 files changed, 242 insertions(+), 18 deletions(-) diff --git a/docs/外部接口文档.md b/docs/外部接口文档.md index a24b167..829cdf9 100644 --- a/docs/外部接口文档.md +++ b/docs/外部接口文档.md @@ -4,10 +4,11 @@ > 当前主域名:`https://sc.jiansun.vip` > 旧域名:`https://3d.yydd.ai`,保留兼容并跳转到新域名 > 资源主域名:`https://s3.jiansun.vip` +> 本次图片资源对接:[按 SKU/条码查询图片原始资源](#image-assets)。本文件已包含完整说明,可直接转发给外部系统负责人;真实 API Key 请单独提供。 ## 概述 -素材平台提供外部接口,允许任务系统通过任务号查询、下载和删除各类素材(包括 `任务-详情`、`任务-其他`、3D 场景、详情模板、主图视频等所有素材类型)。 +素材平台提供外部接口,支持任务号/素材 ID 查询、SKU/条码图片原始资源查询,以及各类素材下载和任务系统删除操作。不同接口的查询范围和行为见各节说明。 ### 基础信息 @@ -20,17 +21,18 @@ #### 获取 API Key 1. **登录素材平台管理后台** - - 访问:`https://sc.jiansun.vip/admin/api-keys` - - 需要**超级管理员**或**系统管理员**权限 + - 访问:`https://sc.jiansun.vip/admin`,进入「超级管理员 → 权限与凭证」中的素材平台 API Key 管理区域。 + - 也可直接访问独立管理页:`https://sc.jiansun.vip/admin/api-keys`。 + - 需要**超级管理员**权限,服务端会复核当前资格;普通管理员和仅有系统管理员角色的账号不能创建或管理 Key。 2. **创建 API Key** - 点击「创建 API Key」按钮 - - 输入密钥名称(如:任务系统、生产环境等) - - 点击确认后,系统会生成一个 32 位的 API Key - - **重要**:API Key 仅在创建时显示一次,请立即复制保存 + - 输入密钥名称,建议按外部系统和环境命名,如「某某系统-生产」。 + - 点击确认后,系统生成一个 32 位十六进制随机 API Key,复制后单独提供给该系统负责人。 + - 新创建的 Key 也可由超级管理员在面板中按需查看、复制,查看操作留有审计;部分只保存哈希的历史 Key 无法恢复,需要创建新 Key 后替换。 3. **管理 API Key** - - 查看列表:可查看所有已创建的 API Key(仅显示前 16 位) + - 查看列表:可查看已创建的 API Key;列表默认只显示前 8 位和省略号。 - 启用/禁用:可以临时禁用某个 API Key,无需删除 - 删除:删除后该 API Key 将永久失效 @@ -42,6 +44,10 @@ X-API-Key: your-api-key-here ``` +`image-assets` 查询只接受请求头中的 Key,不接受 URL 参数中的 Key。外部系统应在服务端保存凭据。 + +当前 API Key 是平台通用外部接口凭证,暂不支持按单个接口或只读用途设置独立权限;`image-assets` 接口自身只读。停用/删除 Key 会拒绝后续鉴权查询,但不会自动撤销此前已签发的长期下载链接。 + #### 安全建议 - ✅ 妥善保管 API Key,不要泄露给他人 @@ -53,6 +59,8 @@ X-API-Key: your-api-key-here ### 调用流程 +下列任务号流程适用于任务系统。按 SKU/条码获取图片资源时,直接调用[图片原始资源查询接口](#image-assets),再下载返回的 `downloadUrl`,无需创建任务号或调用任务领取、删除接口。 + ``` 1. 任务系统生成任务号(如 TASK202601030184) 2. 员工在素材平台上传素材时填入任务号(所有素材类型都支持任务号) @@ -1127,26 +1135,206 @@ curl -X GET \ --- + + +### 8. 图片原始资源查询(按 SKU/条码) + +供外部系统按 SKU 或条码获取图片类素材、AI 原图、渲染原图的原始资源。该接口已于 **2026-10-15** 上线,使用现有鉴权和下载能力;原有外部接口的路径、参数和返回格式保持不变。 + +**请求与鉴权** + +```http +GET https://sc.jiansun.vip/api/external/image-assets?sku=SKU001 +X-API-Key: <素材平台 API Key> +``` + +SKU 与条码二选一。查询参数需要进行 URL 编码,不要把 API Key 放入 URL。 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `sku` | string | 与 `barcode` 二选一 | — | SKU,大小写不敏感,最长 200 字符 | +| `barcode` | string | 与 `sku` 二选一 | — | 条码精确查询,最长 200 字符;按字符串传递,保留前导零和大小写 | +| `resourceType` | string | 否 | `all` | `image`、`ai-original`、`render-original` 或 `all` | +| `page` | integer | 否 | `1` | 页码,范围 1–10000 | +| `pageSize` | integer | 否 | `20` | 每页素材数,范围 1–50;一条素材可能对应多个文件 | + +`sku` 与 `barcode` 必须且只能提供一个有效值。参数名固定为上表所列;重复、未知参数或非法分页返回 400。 + +| `resourceType` | 查询范围 | +|---|---| +| `all` | 以下全部资源 | +| `image` | 实拍图片、买家秀图片、任务-实拍原图、任务-视频截图、任务-详情图片原包,以及标准模型白底原图 | +| `ai-original` | 任务-AI原图 | +| `render-original` | 任务-3D原图,即渲染原图 | + +不将模型文件、PSD 资产、模板、视频或预览图作为图片原件返回;压缩包内的内容由外部系统自行处理,素材平台不解包检查。 + +**调用示例** + +按 SKU 获取全部类型: + +```bash +curl --get 'https://sc.jiansun.vip/api/external/image-assets' \ + --header 'X-API-Key: <素材平台 API Key>' \ + --data-urlencode 'sku=SKU001' \ + --data-urlencode 'resourceType=all' \ + --data-urlencode 'page=1' \ + --data-urlencode 'pageSize=20' +``` + +按条码获取 AI 原图: + +```bash +curl --get 'https://sc.jiansun.vip/api/external/image-assets' \ + --header 'X-API-Key: <素材平台 API Key>' \ + --data-urlencode 'barcode=0001234567890' \ + --data-urlencode 'resourceType=ai-original' \ + --data-urlencode 'page=1' \ + --data-urlencode 'pageSize=20' +``` + +以上编号、Key 及下方 Token 均为示例占位内容,接入时使用真实编号和分配的 Key。 + +**成功响应示例** + +```json +{ + "success": true, + "data": { + "query": { + "type": "sku", + "value": "SKU001", + "resourceType": "all" + }, + "resolution": { + "sku": "SKU001", + "spu": "SPU001", + "resourceScope": "spu" + }, + "count": 2, + "items": [ + { + "materialId": "material-image-id", + "assetId": "asset-image-id", + "materialType": "标准模型", + "resourceType": "image", + "taskNo": null, + "productModel": "SPU001", + "skuCode": "SKU001", + "matchScope": "sku", + "group": "white-background", + "fileName": "SKU001-右前45度.png", + "fileSize": 123456, + "kind": "image", + "downloadUrl": "https://sc.jiansun.vip/api/external/download?token=SIGNED_IMAGE_TOKEN", + "uploadedAt": "2026-10-15T00:00:00.000Z" + }, + { + "materialId": "material-ai-id", + "assetId": "asset-archive-id", + "materialType": "任务-AI原图", + "resourceType": "ai-original", + "taskNo": null, + "productModel": "SPU001", + "skuCode": null, + "matchScope": "spu", + "group": null, + "fileName": "产品AI原图.rar", + "fileSize": 5921050, + "kind": "archive", + "downloadUrl": "https://sc.jiansun.vip/api/external/download?token=SIGNED_ARCHIVE_TOKEN", + "uploadedAt": "2026-10-15T00:00:00.000Z" + } + ], + "issues": [], + "pagination": { + "page": 1, + "pageSize": 20, + "totalMaterials": 2, + "hasMore": false + } + } +} +``` + +**关键字段和匹配范围** + +| 字段 | 含义 | +|---|---| +| `resolution.sku` / `resolution.spu` | 根据有效数据库绑定解析的 SKU 和 SPU;无法取得具体 SKU 时 `sku` 为 `null` | +| `resolution.resourceScope` | 当前为 `spu`,表示基于该产品查询资源 | +| `count` | 本页已登记的资源数,不是全部页资源总数 | +| `items[].materialId` / `assetId` | 素材和资产标识;素材级原件的 `assetId` 可能为 `null` | +| `items[].resourceType` | 资源分类:`image`、`ai-original` 或 `render-original` | +| `items[].skuCode` / `matchScope` | `matchScope=sku` 表示该具体 SKU 的原图;`matchScope=spu` 且 `skuCode=null` 表示产品级资源 | +| `items[].kind` | `image` 为图片、`archive` 为 ZIP/RAR、`source` 按普通源文件处理;按文件扩展名归类 | +| `items[].group` | 白底图为 `white-background`;任务详情原包为 `main`/`sku`/`detail`/`all`;其余为 `null` | +| `items[].fileName` / `fileSize` | 原始文件名和字节数;大小为 `0` 表示未知,以下载响应为准 | +| `items[].downloadUrl` | 带签名的原件下载地址,完整保留并直接 GET | +| `items[].uploadedAt` | 素材上传时间,ISO 8601 格式 | +| `issues` | 有素材但缺少原件记录等情况的说明 | +| `pagination.totalMaterials` | 符合查询条件的素材数,不是文件总数 | +| `pagination.hasMore` | 是否还有下一页 | + +SKU/条码先通过有效的 SKU、素材图片条码绑定解析到 SPU,再查询该产品下的图片资源。不会按名称模糊搜索或截断 SKU 猜测 SPU;没有有效绑定返回 404,关联多个产品或规格返回 409。 + +AI、渲染及普通图片目前主要按 SPU 关联,因此产品级结果可能包含不同规格的图片。外部系统应保留并展示 `matchScope`,不能把 `spu` 级结果直接当成输入 SKU 的专属图片。标准模型白底图只返回当前 SKU 对应的原图。 + +**下载与分页规则** + +- 单张图片返回原图下载地址;资源是 ZIP/RAR 时直接返回原包。**素材平台不解压、不重新打包、不缩放、不重新编码,外部系统自行决定如何处理。** +- 直接 GET `downloadUrl`,无需再次提供 API Key,也不要自行拼接 Token。新接口使用专用 `image-source` 下载用途,旧用途继续保留原有行为。支持现有 HTTP `Range`/断点续传。 +- 查询 Key 停用/删除不会自动撤销已签发的长期下载地址。请按凭据保管下载地址;下载仍会复核素材状态。 +- 任务详情返回已有的主图、SKU 图、详情图原包;只有没有分类原包且已有完整原包时才返回 `all`,不触发动态打包。 +- **根据 `pagination.hasMore` 翻页**:为 `true` 时将 `page` 加一,保持其余参数不变。即使本页 `items` 为空,也不能据此提前停止。 +- 有效产品暂无资源时,成功返回空 `items`。有素材但缺少原件记录时,`issues` 包含 `SOURCE_NOT_AVAILABLE`;下载时实际文件缺失则返回 404,不用预览图代替。 +- 私密、回收站、未完成上传的素材不参与解析或查询。查询只记录接口访问审计,不领取任务、不修改素材下载次数、产品关联或文件。响应使用 `Cache-Control: private, no-store`。 + +**失败响应与错误码** + +```json +{ + "success": false, + "error": { + "code": "SKU_NOT_FOUND", + "message": "未找到有效的产品编号绑定" + } +} +``` + +| HTTP 状态 | code | 说明 | +|---|---|---| +| 400 | `INVALID_QUERY` | 缺少/同时提供有效 SKU 和条码,编号无效,或参数重复/未知 | +| 400 | `INVALID_RESOURCE_TYPE` | 资源类型无效 | +| 400 | `INVALID_PAGINATION` | 分页无效 | +| 401 | `INVALID_API_KEY` | Key 缺失、错误或停用 | +| 404 | `SKU_NOT_FOUND` / `BARCODE_NOT_FOUND` | 未找到有效编号绑定 | +| 409 | `PRODUCT_BINDING_AMBIGUOUS` | 编号关联多个产品或规格,需核对绑定 | +| 409 | `PRODUCT_BINDING_LIMIT` | 某类编号绑定记录超过 100 条查询上限,需核对数据 | +| 500 | `SERVER_ERROR` | 服务端异常;保留响应头 `X-Trace-Id` 供排查 | + ## API Key 管理 ### 管理页面 -- **访问地址**: `https://sc.jiansun.vip/admin/api-keys` -- **权限要求**: 超级管理员或系统管理员 +- **主要入口**: `https://sc.jiansun.vip/admin` → 超级管理员 → 权限与凭证 → 素材平台 API Key。 +- **独立管理页**: `https://sc.jiansun.vip/admin/api-keys`。 +- **权限要求**: 仅超级管理员,服务端复核当前资格。 ### 功能说明 | 功能 | 说明 | |-----|------| -| 创建 API Key | 创建新的 API Key,创建后立即显示完整密钥(仅显示一次) | +| 创建 API Key | 输入系统/环境名称,创建后显示完整密钥,单独提供给该外部系统 | | 查看列表 | 查看所有 API Key,显示名称、状态、最后使用时间等信息 | -| 启用/禁用 | 临时禁用 API Key,禁用后无法调用接口,可随时重新启用 | -| 删除 | 永久删除 API Key,删除后无法恢复 | +| 查看/复制 Key | 超级管理员面板可按需查看可恢复的 Key,并记录查看审计;部分历史哈希 Key 无法恢复 | +| 启用/禁用 | 控制后续鉴权查询;禁用后可重新启用,不自动撤销已签发的下载链接 | +| 删除 | 删除后不再接受该 Key 的鉴权,不自动撤销已签发的下载链接 | ### API Key 格式 -- 长度:32 位随机字符串 -- 示例:`a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6` +- 长度:32 位十六进制随机字符串。 +- 文档统一使用 `<素材平台 API Key>` 或 `your-api-key-here` 占位,不含真实凭据。 - 唯一性:每个 API Key 在系统中唯一 --- @@ -1157,7 +1345,7 @@ curl -X GET \ --- -*文档版本: 1.9* -*更新日期: 2026-07-01* -*更新内容: 新增视频素材查询接口(按 SPU/SKU/条码),含 15 天保护期控制* +*文档版本: 1.10* +*更新日期: 2026-10-15* +*更新内容: 新增按 SKU/条码查询图片原始资源的完整对接说明(含原图、ZIP/RAR 原包下载、匹配范围、分页和错误码);校正 API Key 的创建入口、超级管理员权限及按需查看规则。* diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index 8181165..6086dac 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -1697,8 +1697,14 @@ verify_candidate_authenticated_s3() { local name="$1" docker exec "$name" node -e ' const { S3Client, HeadBucketCommand } = require("@aws-sdk/client-s3"); -const required = ["S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"]; +const required = ["S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY", "S3_PUBLIC_DOMAIN"]; if (required.some((name) => !String(process.env[name] || "").trim())) process.exit(1); +const publicDomain = new URL(String(process.env.S3_PUBLIC_DOMAIN).trim()); +const publicBucketPath = decodeURIComponent(publicDomain.pathname).replace(/\/+$/, ""); +if (publicDomain.protocol !== "https:" || publicDomain.username || publicDomain.password + || publicDomain.search || publicDomain.hash || publicBucketPath !== `/${process.env.S3_BUCKET}`) { + throw new Error("S3_PUBLIC_DOMAIN must be the HTTPS path-style URL including the configured S3_BUCKET"); +} const endpoint = String(process.env.S3_ENDPOINT).split("#")[0].trim(); const client = new S3Client({ endpoint, diff --git a/scripts/git-incremental-deploy.test.mjs b/scripts/git-incremental-deploy.test.mjs index 5a34367..5cd307c 100644 --- a/scripts/git-incremental-deploy.test.mjs +++ b/scripts/git-incremental-deploy.test.mjs @@ -124,6 +124,36 @@ function shellFunction(name) { return source.slice(start, end + 3) } +test('storage deployment gate rejects broken public bucket URLs before authenticating', async () => { + const fn = shellFunction('verify_candidate_authenticated_s3') + const code = fn.slice(fn.indexOf("node -e '\n") + "node -e '\n".length, fn.lastIndexOf("\n'")) + const base = { S3_ENDPOINT: 'http://minio:9000', S3_BUCKET: 'gongxiangpan', S3_ACCESS_KEY_ID: 'fixture', S3_SECRET_ACCESS_KEY: 'fixture' } + for (const domain of ['', 'https://s3.jiansun.vip', 'https://s3.jiansun.vip/other-bucket', 'http://s3.jiansun.vip/gongxiangpan', 'https://user:pass@s3.jiansun.vip/gongxiangpan', 'https://s3.jiansun.vip/gongxiangpan?token=fixture', 'https://s3.jiansun.vip/gongxiangpan#fragment']) { + let calls = 0 + assert.throws(() => runInNewContext(code, { + URL, AbortSignal, + process: { env: { ...base, S3_PUBLIC_DOMAIN: domain }, exit() { throw new Error('missing public bucket URL') } }, + require() { return { S3Client: class { constructor() { calls++ } }, HeadBucketCommand: class {} } }, + }), /S3_PUBLIC_DOMAIN|missing public bucket URL/) + assert.equal(calls, 0, 'invalid public URL must fail before touching storage') + } +}) + +test('storage deployment gate accepts and authenticates the complete public bucket URL', async () => { + const fn = shellFunction('verify_candidate_authenticated_s3') + const code = fn.slice(fn.indexOf("node -e '\n") + "node -e '\n".length, fn.lastIndexOf("\n'")) + for (const domain of ['https://s3.jiansun.vip/gongxiangpan', 'https://s3.jiansun.vip/gongxiangpan/']) { + let calls = 0, destroyed = false + await runInNewContext(code, { + URL, AbortSignal, + process: { env: { S3_ENDPOINT: 'http://minio:9000', S3_BUCKET: 'gongxiangpan', S3_PUBLIC_DOMAIN: domain, S3_ACCESS_KEY_ID: 'fixture', S3_SECRET_ACCESS_KEY: 'fixture' }, stderr: { write(message) { throw new Error(message) } }, exit() { throw new Error('unexpected exit') } }, + require() { return { S3Client: class { async send(command) { assert.equal(command.input.Bucket, 'gongxiangpan'); calls++ } destroy() { destroyed = true } }, HeadBucketCommand: class { constructor(input) { this.input = input } } } }, + }) + assert.equal(calls, 1) + assert.equal(destroyed, true) + } +}) + test('direct rollback restores an absent or partial replacement from the immutable image', () => { const rollback = shellFunction('restore_direct_previous_container') const image = `sha256:${'c'.repeat(64)}`