fix: align maixiang asset search with home search

This commit is contained in:
chenpei 2026-05-21 18:19:34 +08:00
parent 998d311254
commit efc8f35a83
5 changed files with 323 additions and 7 deletions

View File

@ -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],
},

View File

@ -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`。
**视频素材范围**

View File

@ -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 使用。

View File

@ -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'] } },
]),
}),
}),
)

View File

@ -135,7 +135,7 @@ export async function canAccessDeletedMaterials(user: MaterialSessionUser): Prom
return isSystemAdmin(user.role || '')
}
async function buildSearchOrConditions(search: string): Promise<any[] | undefined> {
export async function buildMaterialSearchOrConditions(search: string): Promise<any[] | undefined> {
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
}