fix(deploy): validate public storage URLs and document image API
This commit is contained in:
parent
493dd16d83
commit
5a5f0a52dc
222
docs/外部接口文档.md
222
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 \
|
||||
|
||||
---
|
||||
|
||||
<a id="image-assets"></a>
|
||||
|
||||
### 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 的创建入口、超级管理员权限及按需查看规则。*
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)}`
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user