diff --git a/app/api/external/maixiang-spu-assets/route.ts b/app/api/external/maixiang-spu-assets/route.ts index 7f6e1548..ea884454 100644 --- a/app/api/external/maixiang-spu-assets/route.ts +++ b/app/api/external/maixiang-spu-assets/route.ts @@ -9,6 +9,7 @@ import { validateApiKey, } from '@/lib/external-api' import { getPublicAppOrigin } from '@/lib/public-app-url' +import { buildMaterialSearchOrConditions } from '@/lib/material-query' import { prisma } from '@/lib/prisma' import { rewriteStorageUrlForClient } from '@/lib/url-rewrite' import { LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES } from '@/lib/video-material-types' @@ -246,10 +247,11 @@ export async function GET(request: NextRequest) { } try { + const searchOrConditions = await buildMaterialSearchOrConditions(trace.normalizedSpu) const materials = await prisma.material.findMany({ where: { deletedAt: null, - productModel: trace.normalizedSpu, + ...(searchOrConditions ? { OR: searchOrConditions } : {}), materialType: { in: [BUYER_SHOW_MATERIAL_TYPE, ...VIDEO_MATERIAL_TYPES], }, diff --git a/docs/外部接口文档.md b/docs/外部接口文档.md index f88e3826..45909ea2 100644 --- a/docs/外部接口文档.md +++ b/docs/外部接口文档.md @@ -90,7 +90,7 @@ X-API-Key: your-api-key-here ### 脉象平台:按 SPU 查询买家秀图片和视频下载链接 -脉象平台传入 SPU,素材平台返回该 SPU 下全部 `买家秀图片` 和全部视频类素材的预览地址与正式下载链接。 +脉象平台传入搜索关键词,通常是 SPU,素材平台按首页搜索栏同口径模糊匹配,并返回命中结果中的全部 `买家秀图片` 和全部视频类素材的预览地址与正式下载链接。 **请求** @@ -108,7 +108,9 @@ GET /api/external/maixiang-spu-assets?spu={SPU} | 参数 | 类型 | 必填 | 说明 | |-----|------|-----|------| -| spu | string | 是 | SPU 型号,接口会转为大写并做精确匹配 | +| spu | string | 是 | 搜索关键词,通常传 SPU;接口会转为大写并使用与首页搜索栏一致的模糊匹配 | + +匹配范围与首页搜索栏一致:`SPU / 任务号 / 报告标题 / 店铺 / 场景 / 作者 / SKU 名称 / 上传人姓名`。接口只返回买家秀和视频素材;如果关键词只命中其它素材类型,仍返回 `404 SPU_ASSETS_NOT_FOUND`。 **视频素材范围** diff --git a/docs/脉象平台SPU素材下载接口文档.md b/docs/脉象平台SPU素材下载接口文档.md new file mode 100644 index 00000000..cee5270d --- /dev/null +++ b/docs/脉象平台SPU素材下载接口文档.md @@ -0,0 +1,263 @@ +# 脉象平台 SPU 素材下载接口文档 + +版本日期:2026-05-21 + +## 1. 接口说明 + +脉象平台传入一个搜索关键词,通常是 SPU,素材平台按首页搜索栏同口径模糊匹配,并返回命中结果中的: + +- 买家秀图片 +- 视频类素材 + +接口不会返回 `3D场景`、`任务-详情`、`标准模型`、`实拍图片`、`任务-3D原图` 等其它素材类型。 + +返回结构中,`buyerShow` 为买家秀图片,放在前面;`videos` 为视频类素材,放在后面。 + +## 2. 请求地址 + +```http +GET https://sc.jiansun.vip/api/external/maixiang-spu-assets?spu={SPU} +``` + +示例: + +```http +GET https://sc.jiansun.vip/api/external/maixiang-spu-assets?spu=GBGJ-DNZ0366 +``` + +## 3. 鉴权方式 + +使用请求头 `X-API-Key` 鉴权。 + +```http +X-API-Key: your-api-key-here +``` + +注意: + +- API Key 必须放在请求头中。 +- 不支持把 API Key 放在 URL 参数中。 +- 正式 API Key 请通过单独的安全方式提供,不要写入代码仓库或公开文档。 + +## 4. 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| spu | string | 是 | 搜索关键词。通常传 SPU,接口会自动去掉首尾空格并转为大写,然后使用与首页搜索栏一致的模糊匹配。 | + +说明: + +- 只支持单个关键词查询。 +- 匹配方式与素材库首页搜索栏一致:会模糊匹配 `SPU / 任务号 / 报告标题 / 店铺 / 场景 / 作者 / SKU 名称 / 上传人姓名`。 +- 即使命中其它类型素材,接口也只返回 `买家秀图片` 和视频类素材。 +- 组合 SPU 中的 `+` 会被保留,例如 `GBSFY-AD0297+GBSFY-CZ0102`。 + +## 5. 返回素材范围 + +### 5.1 买家秀图片 + +只返回素材类型为: + +- `买家秀图片` + +### 5.2 视频类素材 + +返回以下视频类素材: + +- `淘系-主图视频` +- `安装视频` +- `淘系-逛逛视频` +- `抖音-千川素材` +- `抖音-账号维护` +- `主图视频` +- `种草视频` +- `视频` +- `VIDEO` + +## 6. 请求示例 + +```bash +curl -X GET \ + 'https://sc.jiansun.vip/api/external/maixiang-spu-assets?spu=GBGJ-DNZ0366' \ + -H 'X-API-Key: your-api-key-here' +``` + +## 7. 成功响应示例 + +```json +{ + "success": true, + "data": { + "spu": "GBGJ-DNZ0366", + "buyerShow": { + "total": 1, + "items": [ + { + "materialId": "buyer-material-id", + "productModel": "GBGJ-DNZ0366", + "materialType": "买家秀图片", + "categoryName": "顾家", + "shopName": "顾家", + "sceneName": "客厅", + "fileName": "buyer-show.zip", + "fileSize": 1024000, + "uploadedAt": "2026-05-13T10:00:00.000Z", + "coverUrl": "https://s3.jiansun.vip/gongxiangpan/example-cover.webp", + "previewUrls": [ + "https://s3.jiansun.vip/gongxiangpan/example-1.webp", + "https://s3.jiansun.vip/gongxiangpan/example-2.webp" + ], + "downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx" + } + ] + }, + "videos": { + "total": 1, + "items": [ + { + "materialId": "video-material-id", + "productModel": "GBGJ-DNZ0366", + "materialType": "抖音-千川素材", + "categoryName": "顾家", + "shopName": "顾家", + "sceneName": "客厅", + "fileName": "video.mp4", + "fileSize": 2048000, + "duration": 18, + "uploadedAt": "2026-05-13T10:05:00.000Z", + "coverUrl": "https://s3.jiansun.vip/gongxiangpan/video-cover.jpg", + "previewUrl": "https://s3.jiansun.vip/gongxiangpan/video-preview.mp4", + "previewClipUrl": "https://s3.jiansun.vip/gongxiangpan/video-clip-10s.mp4", + "downloadUrl": "https://sc.jiansun.vip/api/external/download?token=yyy" + } + ] + } + } +} +``` + +## 8. 响应字段说明 + +### 8.1 顶层字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| success | boolean | 请求是否成功 | +| data.spu | string | 本次查询的标准化关键词,通常是 SPU | +| data.buyerShow.total | number | 买家秀图片数量 | +| data.buyerShow.items | array | 买家秀图片列表 | +| data.videos.total | number | 视频素材数量 | +| data.videos.items | array | 视频素材列表 | + +### 8.2 买家秀图片字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| materialId | string | 素材 ID | +| productModel | string | SPU 型号 | +| materialType | string | 素材类型,固定为 `买家秀图片` | +| categoryName | string | 店铺/分类名称 | +| shopName | string | 店铺名称 | +| sceneName | string | 场景名称 | +| fileName | string | 下载包文件名 | +| fileSize | number | 文件大小,单位 byte | +| uploadedAt | string | 上传时间,ISO 8601 格式 | +| coverUrl | string \| null | 封面或首图预览地址 | +| previewUrls | string[] | 预览图地址数组 | +| downloadUrl | string | 正式下载链接,下载买家秀 ZIP 包 | + +### 8.3 视频字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| materialId | string | 素材 ID | +| productModel | string | SPU 型号 | +| materialType | string | 视频素材类型 | +| categoryName | string | 店铺/分类名称 | +| shopName | string | 店铺名称 | +| sceneName | string | 场景名称 | +| fileName | string | 视频文件名 | +| fileSize | number | 文件大小,单位 byte | +| duration | number \| null | 视频时长,单位秒 | +| uploadedAt | string | 上传时间,ISO 8601 格式 | +| coverUrl | string \| null | 视频封面地址 | +| previewUrl | string \| null | 视频预览文件地址 | +| previewClipUrl | string \| null | 10 秒预览视频地址 | +| downloadUrl | string | 正式下载链接,下载原视频文件 | + +## 9. 错误响应 + +### 9.1 缺少 SPU + +HTTP 状态码:`400` + +```json +{ + "success": false, + "error": { + "code": "MISSING_SPU", + "message": "SPU 为必填项" + } +} +``` + +### 9.2 API Key 无效或缺失 + +HTTP 状态码:`401` + +```json +{ + "success": false, + "error": { + "code": "INVALID_API_KEY", + "message": "API Key 无效或缺失" + } +} +``` + +### 9.3 没有找到买家秀或视频素材 + +HTTP 状态码:`404` + +```json +{ + "success": false, + "error": { + "code": "SPU_ASSETS_NOT_FOUND", + "message": "该 SPU 下暂无买家秀图片或视频素材" + } +} +``` + +### 9.4 服务异常 + +HTTP 状态码:`500` + +```json +{ + "success": false, + "error": { + "code": "SERVER_ERROR", + "message": "服务器内部错误" + } +} +``` + +## 10. 错误码汇总 + +| HTTP 状态 | code | 说明 | +|---|---|---| +| 400 | MISSING_SPU | 缺少 SPU 参数 | +| 401 | INVALID_API_KEY | API Key 无效或缺失 | +| 404 | SPU_ASSETS_NOT_FOUND | 该 SPU 下暂无买家秀图片或视频素材 | +| 500 | SERVER_ERROR | 服务器内部错误 | + +## 11. 对接注意事项 + +- 同一 SPU 命中多条素材时,接口会全部返回,并按上传时间倒序排列。 +- 如果只有买家秀图片,没有视频,接口仍返回 `200`,`videos.total` 为 `0`。 +- 如果只有视频,没有买家秀图片,接口仍返回 `200`,`buyerShow.total` 为 `0`。 +- 如果首页搜索能搜到该关键词,但命中的素材没有 `买家秀图片` 或视频类素材,接口仍返回 `404`。 +- `downloadUrl` 是正式下载链接,脉象平台可直接请求该链接下载文件。 +- `previewUrl`、`previewClipUrl`、`coverUrl`、`previewUrls` 是预览地址,不等同于正式下载地址。 +- 不建议脉象平台解析 `downloadUrl` 里的 token,只需要把它当成完整 URL 使用。 diff --git a/lib/__tests__/maixiang-spu-assets-route.test.ts b/lib/__tests__/maixiang-spu-assets-route.test.ts index df76b922..f110661a 100644 --- a/lib/__tests__/maixiang-spu-assets-route.test.ts +++ b/lib/__tests__/maixiang-spu-assets-route.test.ts @@ -10,6 +10,12 @@ jest.mock('@/lib/prisma', () => ({ material: { findMany: jest.fn(), }, + materialSku: { + findMany: jest.fn(), + }, + user: { + findMany: jest.fn(), + }, }, })) @@ -54,6 +60,8 @@ describe('external maixiang-spu-assets route', () => { isActive: true, }) ;(prisma.material.findMany as jest.Mock).mockResolvedValue([]) + ;(prisma.materialSku.findMany as jest.Mock).mockResolvedValue([]) + ;(prisma.user.findMany as jest.Mock).mockResolvedValue([]) }) function createRequest(url: string, apiKey = 'test-key') { @@ -102,19 +110,60 @@ describe('external maixiang-spu-assets route', () => { expect(prisma.material.findMany).not.toHaveBeenCalled() }) - test('应使用标准化后的 SPU 精确匹配,不做包含搜索', async () => { + test('应使用与首页搜索栏一致的模糊匹配条件', async () => { ;(prisma.material.findMany as jest.Mock).mockResolvedValue([]) + ;(prisma.materialSku.findMany as jest.Mock).mockResolvedValue([ + { materialId: 'sku-hit-material' }, + ]) + ;(prisma.user.findMany as jest.Mock).mockResolvedValue([ + { id: 'author-user-id' }, + ]) const response = await GET( createRequest('https://example.com/api/external/maixiang-spu-assets?spu=gbsfy-ad0297%20%2B%20gbsfy-cz0102'), ) expect(response.status).toBe(404) + expect(prisma.materialSku.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + skuName: { + contains: 'GBSFY-AD0297+GBSFY-CZ0102', + mode: 'insensitive', + }, + deletedAt: null, + }), + select: { materialId: true }, + take: 1000, + }), + ) + expect(prisma.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + OR: [ + { realName: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } }, + { username: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } }, + ], + }, + select: { id: true }, + take: 1000, + }), + ) expect(prisma.material.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ deletedAt: null, - productModel: 'GBSFY-AD0297+GBSFY-CZ0102', + materialType: expect.any(Object), + OR: expect.arrayContaining([ + { productModel: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } }, + { reportTitle: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } }, + { taskNo: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } }, + { categoryName: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } }, + { sceneName: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } }, + { authorName: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } }, + { id: { in: ['sku-hit-material'] } }, + { userId: { in: ['author-user-id'] } }, + ]), }), }), ) diff --git a/lib/material-query.ts b/lib/material-query.ts index 099abba3..00639a24 100644 --- a/lib/material-query.ts +++ b/lib/material-query.ts @@ -135,7 +135,7 @@ export async function canAccessDeletedMaterials(user: MaterialSessionUser): Prom return isSystemAdmin(user.role || '') } -async function buildSearchOrConditions(search: string): Promise { +export async function buildMaterialSearchOrConditions(search: string): Promise { const keyword = search.trim() if (!keyword) { return undefined @@ -228,7 +228,7 @@ export async function buildMaterialWhereFromSearchParams( const productCategory = searchParams.get('productCategory') || '' const month = searchParams.get('month') || '' - const searchOrConditions = await buildSearchOrConditions(search) + const searchOrConditions = await buildMaterialSearchOrConditions(search) if (searchOrConditions) { where.OR = searchOrConditions }