支持自定义字段搜索并保护模板读取接口

This commit is contained in:
WuYongJie 2026-05-25 16:37:39 +08:00
parent ab73b5ec75
commit 0b5bd8510a
8 changed files with 569 additions and 34 deletions

View File

@ -0,0 +1,114 @@
import { GET } from '../route'
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
$queryRaw: jest.fn(),
$queryRawUnsafe: jest.fn(),
},
}))
jest.mock('@/lib/field-pool', () => ({
getFieldPoolRecords: jest.fn(),
syncFieldPoolFromTemplates: jest.fn(),
formatFieldOptionEntries: jest.fn((options) => options),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { getFieldPoolRecords, syncFieldPoolFromTemplates, formatFieldOptionEntries } = require('@/lib/field-pool')
describe('GET /api/home-filter-fields 权限边界', () => {
beforeEach(() => {
jest.resetAllMocks()
formatFieldOptionEntries.mockImplementation((options: Array<{ label: string; value: string }>) => options)
})
it('未登录时拒绝读取首页筛选字段', async () => {
getServerSession.mockResolvedValue(null)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('请先登录')
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
expect(getFieldPoolRecords).not.toHaveBeenCalled()
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
})
it('已登录时正常读取首页筛选字段', async () => {
getServerSession.mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
getFieldPoolRecords.mockResolvedValue([
{
key: 'categoryName',
label: '使用店铺',
filterDisplayName: '店铺',
isFilterEnabled: true,
options: [{ label: '店铺A', value: '店铺A' }],
},
])
prisma.$queryRawUnsafe
.mockResolvedValueOnce([{ value: '店铺A', count: BigInt(2) }])
.mockResolvedValueOnce([{ count: BigInt(3) }])
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({
fields: [
{
key: 'categoryName',
label: '店铺',
totalCount: 3,
options: [{ label: '店铺A', value: '店铺A', count: 2 }],
},
],
})
})
it('自定义首页筛选字段会从 metadata.templateFields 统计数量', async () => {
getServerSession.mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
getFieldPoolRecords.mockResolvedValue([
{
key: 'customCode',
label: '自定义编码',
filterDisplayName: '编码',
isFilterEnabled: true,
options: [{ label: 'CODE-001', value: 'CODE-001' }],
},
])
prisma.$queryRaw
.mockResolvedValueOnce([{ value: 'CODE-001', count: BigInt(2) }])
.mockResolvedValueOnce([{ count: BigInt(2) }])
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2)
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
expect(data).toEqual({
fields: [
{
key: 'customCode',
label: '编码',
totalCount: 2,
options: [{ label: 'CODE-001', value: 'CODE-001', count: 2 }],
},
],
})
})
})

View File

@ -1,4 +1,7 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { Prisma } from '@prisma/client'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { getFieldPoolRecords, syncFieldPoolFromTemplates, formatFieldOptionEntries } from '@/lib/field-pool'
@ -20,6 +23,19 @@ const MATERIAL_FIELD_COLUMN_MAP: Record<string, string> = {
authorName: 'authorName',
}
function metadataTemplateFieldsJson() {
return Prisma.sql`COALESCE(
(
CASE
WHEN "metadata" IS NOT NULL AND "metadata" ~ '^\\s*\\{'
THEN "metadata"::jsonb
ELSE '{}'::jsonb
END
) -> 'templateFields',
'{}'::jsonb
)`
}
/**
* 构建每个字段的素材统计
* @returns counts: 每个选项的素材数量, totals: 每个字段有非空值的素材总数
@ -31,7 +47,33 @@ async function buildMaterialCounts(fieldKeys: string[]) {
await Promise.all(
fieldKeys.map(async (fieldKey) => {
const column = MATERIAL_FIELD_COLUMN_MAP[fieldKey]
if (!column) return
if (!column) {
const rows = await prisma.$queryRaw<Array<{ value: string | null; count: bigint }>>(Prisma.sql`
SELECT (${metadataTemplateFieldsJson()} ->> ${fieldKey}) AS value, COUNT(*)::bigint AS count
FROM "materials"
WHERE (${metadataTemplateFieldsJson()} ->> ${fieldKey}) IS NOT NULL
AND (${metadataTemplateFieldsJson()} ->> ${fieldKey}) != ''
AND "deletedAt" IS NULL
GROUP BY (${metadataTemplateFieldsJson()} ->> ${fieldKey})
`)
const map = new Map<string, number>()
rows.forEach((row) => {
if (!row.value) return
map.set(row.value, Number(row.count ?? 0))
})
counts[fieldKey] = map
const totalResult = await prisma.$queryRaw<Array<{ count: bigint }>>(Prisma.sql`
SELECT COUNT(*)::bigint AS count
FROM "materials"
WHERE (${metadataTemplateFieldsJson()} ->> ${fieldKey}) IS NOT NULL
AND (${metadataTemplateFieldsJson()} ->> ${fieldKey}) != ''
AND "deletedAt" IS NULL
`)
totals[fieldKey] = Number(totalResult[0]?.count ?? 0)
return
}
// 获取每个选项的素材数量(排除已删除的素材)
const rows = await prisma.$queryRawUnsafe<Array<{ value: string | null; count: bigint }>>(
@ -57,6 +99,14 @@ async function buildMaterialCounts(fieldKeys: string[]) {
export async function GET() {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
)
}
await syncFieldPoolFromTemplates()
const fields = (await getFieldPoolRecords()) as Array<{
key: string

View File

@ -0,0 +1,141 @@
import { NextRequest } from 'next/server'
import { GET } from '../route'
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
count: jest.fn(),
findMany: jest.fn(),
},
uploadField: {
findMany: jest.fn(),
},
fieldDefinition: {
findMany: jest.fn(),
},
$queryRaw: jest.fn(),
materialLike: {
findMany: jest.fn(),
},
materialFavorite: {
findMany: jest.fn(),
},
},
}))
jest.mock('@/lib/url-rewrite', () => ({
rewriteMaterialStorageUrlsForClient: jest.fn((material) => material),
}))
jest.mock('@/lib/material-log', () => ({
createMaterialLog: jest.fn(),
}))
jest.mock('@/lib/s3', () => ({
deleteFromS3: jest.fn(),
isS3Url: jest.fn(() => false),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
describe('GET /api/materials 权限边界', () => {
beforeEach(() => {
jest.resetAllMocks()
})
it('未登录时拒绝读取素材列表', async () => {
getServerSession.mockResolvedValue(null)
const response = await GET(new NextRequest('http://localhost/api/materials?pageSize=1'))
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('请先登录')
expect(prisma.material.count).not.toHaveBeenCalled()
expect(prisma.material.findMany).not.toHaveBeenCalled()
})
it('已登录时正常读取素材列表', async () => {
getServerSession.mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
prisma.material.count.mockResolvedValue(0)
prisma.material.findMany.mockResolvedValue([])
prisma.uploadField.findMany.mockResolvedValue([])
prisma.fieldDefinition.findMany.mockResolvedValue([])
const response = await GET(new NextRequest('http://localhost/api/materials?pageSize=1'))
const data = await response.json()
expect(response.status).toBe(200)
expect(data.materials).toEqual([])
expect(data.totalCount).toBe(0)
expect(prisma.material.count).toHaveBeenCalled()
expect(prisma.material.findMany).toHaveBeenCalled()
})
it('搜索时会把可搜索自定义字段纳入查询', async () => {
getServerSession.mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
prisma.uploadField.findMany.mockResolvedValue([
{ key: 'customCode' },
{ key: 'productModel' },
])
prisma.$queryRaw.mockResolvedValue([{ id: 'material-custom-1' }])
prisma.fieldDefinition.findMany.mockResolvedValue([])
prisma.material.count.mockResolvedValue(1)
prisma.material.findMany.mockResolvedValue([])
const response = await GET(new NextRequest('http://localhost/api/materials?search=CODE-001&pageSize=1'))
expect(response.status).toBe(200)
expect(prisma.uploadField.findMany).toHaveBeenCalledWith({
where: { isSearchable: true },
select: { key: true },
})
expect(prisma.$queryRaw).toHaveBeenCalled()
expect(prisma.material.count.mock.calls[0][0].where.AND).toEqual(
expect.arrayContaining([
expect.objectContaining({
OR: expect.arrayContaining([{ id: { in: ['material-custom-1'] } }]),
}),
])
)
})
it('首页自定义字段筛选会按 metadata 字段值过滤素材', async () => {
getServerSession.mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
prisma.fieldDefinition.findMany.mockResolvedValue([{ key: 'customCode' }])
prisma.$queryRaw.mockResolvedValue([{ id: 'material-custom-1' }])
prisma.material.count.mockResolvedValue(1)
prisma.material.findMany.mockResolvedValue([])
const response = await GET(
new NextRequest('http://localhost/api/materials?homeField_customCode=CODE-001&pageSize=1')
)
expect(response.status).toBe(200)
expect(prisma.fieldDefinition.findMany).toHaveBeenCalledWith({
where: {
key: { in: ['customCode'] },
isFilterEnabled: true,
},
select: { key: true },
})
expect(prisma.material.count.mock.calls[0][0].where.AND).toEqual(
expect.arrayContaining([{ id: { in: ['material-custom-1'] } }])
)
})
})

View File

@ -1,10 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { Prisma } from '@prisma/client'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite'
import { createMaterialLog } from '@/lib/material-log'
import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments'
import { isStandardField } from '@/lib/field-data-storage'
import {
collectMaterialStorageUrls,
deleteMaterialStorageFiles,
@ -55,10 +57,94 @@ function buildMaterialSnapshot(material: {
}
}
function metadataTemplateFieldsJson() {
return Prisma.sql`COALESCE(
(
CASE
WHEN "metadata" IS NOT NULL AND "metadata" ~ '^\\s*\\{'
THEN "metadata"::jsonb
ELSE '{}'::jsonb
END
) -> 'templateFields',
'{}'::jsonb
)`
}
async function getSearchableCustomFieldKeys(): Promise<string[]> {
const fields = await prisma.uploadField.findMany({
where: {
isSearchable: true,
},
select: {
key: true,
},
})
return Array.from(
new Set(fields.map((field) => field.key).filter((key) => !isStandardField(key)))
)
}
async function getEnabledCustomHomeFilterKeys(fieldKeys: string[]): Promise<Set<string>> {
const customKeys = fieldKeys.filter((key) => !ALLOWED_DYNAMIC_FILTER_FIELDS.has(key))
if (customKeys.length === 0) {
return new Set()
}
const enabledFields = await prisma.fieldDefinition.findMany({
where: {
key: { in: customKeys },
isFilterEnabled: true,
},
select: {
key: true,
},
})
return new Set(enabledFields.map((field) => field.key))
}
async function findMaterialIdsByCustomFieldSearch(search: string): Promise<string[]> {
const fieldKeys = await getSearchableCustomFieldKeys()
if (fieldKeys.length === 0) {
return []
}
const rows = await prisma.$queryRaw<Array<{ id: string }>>(Prisma.sql`
SELECT "id"
FROM "materials"
WHERE EXISTS (
SELECT 1
FROM jsonb_each_text(${metadataTemplateFieldsJson()}) AS field("key", "value")
WHERE field."key" IN (${Prisma.join(fieldKeys)})
AND field."value" ILIKE ${`%${search}%`}
)
`)
return rows.map((row) => row.id)
}
async function findMaterialIdsByCustomFieldValue(fieldKey: string, value: string): Promise<string[]> {
const rows = await prisma.$queryRaw<Array<{ id: string }>>(Prisma.sql`
SELECT "id"
FROM "materials"
WHERE ${metadataTemplateFieldsJson()} ->> ${fieldKey} = ${value}
`)
return rows.map((row) => row.id)
}
// Get all materials with search and filter support
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
)
}
const { searchParams } = new URL(req.url)
const search = searchParams.get('search') || ''
const categoryName = searchParams.get('category') || ''
@ -73,14 +159,25 @@ export async function GET(req: NextRequest) {
const showDeleted = searchParams.get('showDeleted') || ''
const year = searchParams.get('year') || ''
const dynamicFieldFilters: Array<{ key: string; value: string }> = []
const requestedDynamicFieldKeys: string[] = []
searchParams.forEach((value, key) => {
if (key.startsWith('homeField_') && value) {
const fieldKey = key.replace('homeField_', '')
requestedDynamicFieldKeys.push(fieldKey)
if (ALLOWED_DYNAMIC_FILTER_FIELDS.has(fieldKey)) {
dynamicFieldFilters.push({ key: fieldKey, value })
}
}
})
const enabledCustomHomeFilterKeys = await getEnabledCustomHomeFilterKeys(requestedDynamicFieldKeys)
searchParams.forEach((value, key) => {
if (key.startsWith('homeField_') && value) {
const fieldKey = key.replace('homeField_', '')
if (enabledCustomHomeFilterKeys.has(fieldKey)) {
dynamicFieldFilters.push({ key: fieldKey, value })
}
}
})
// Build where clause
const where: any = {}
@ -99,6 +196,7 @@ export async function GET(req: NextRequest) {
// Add search condition (search in SPU, shop, scene category, author)
if (search) {
const customSearchMaterialIds = await findMaterialIdsByCustomFieldSearch(search)
andConditions.push({
OR: [
{ productModel: { contains: search, mode: 'insensitive' } }, // SPU
@ -109,6 +207,9 @@ export async function GET(req: NextRequest) {
{ categoryName: { contains: search, mode: 'insensitive' } }, // 使用店铺
{ sceneName: { contains: search, mode: 'insensitive' } }, // 场景类别
{ authorName: { contains: search, mode: 'insensitive' } }, // 作者
...(customSearchMaterialIds.length > 0
? [{ id: { in: customSearchMaterialIds } }]
: []),
]
})
}
@ -191,9 +292,14 @@ export async function GET(req: NextRequest) {
}
}
dynamicFieldFilters.forEach(({ key, value }) => {
andConditions.push({ [key]: value } as Record<string, string>)
})
for (const { key, value } of dynamicFieldFilters) {
if (ALLOWED_DYNAMIC_FILTER_FIELDS.has(key)) {
andConditions.push({ [key]: value } as Record<string, string>)
} else {
const materialIds = await findMaterialIdsByCustomFieldValue(key, value)
andConditions.push({ id: { in: materialIds } })
}
}
// 合并所有条件
if (andConditions.length > 0) {
@ -486,6 +592,7 @@ export async function DELETE(req: NextRequest) {
},
materialSnapshot: buildMaterialSnapshot(material),
})
// 永久删除数据库记录
await prisma.material.delete({
where: { id },
@ -521,5 +628,3 @@ export async function DELETE(req: NextRequest) {
)
}
}

View File

@ -0,0 +1,58 @@
import { GET } from '../route'
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
$queryRaw: jest.fn(),
uploadTemplate: {
findMany: jest.fn(),
},
},
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
describe('GET /api/templates 权限边界', () => {
beforeEach(() => {
jest.resetAllMocks()
})
it('未登录时拒绝读取模板列表', async () => {
getServerSession.mockResolvedValue(null)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('请先登录')
expect(prisma.$queryRaw).not.toHaveBeenCalled()
expect(prisma.uploadTemplate.findMany).not.toHaveBeenCalled()
})
it('已登录时正常读取模板列表', async () => {
getServerSession.mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
prisma.$queryRaw.mockResolvedValue([{ enabledBuiltinTemplates: '["3dmax-material"]' }])
prisma.uploadTemplate.findMany.mockResolvedValue([
{ id: 'custom-template', name: '自定义模板' },
])
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual([
{ id: 'builtin-3dmax-material', name: '3DMAX 素材模板', isBuiltin: true },
{ id: 'custom-template', name: '自定义模板', isBuiltin: false },
])
})
})

View File

@ -6,37 +6,24 @@
*/
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
// 预制模板配置
const BUILTIN_TEMPLATE_KEYS = [
'3dmax-material',
'psd-material',
'audio-material',
'video-material',
'model-material',
'other-material',
]
const BUILTIN_TEMPLATE_NAMES: Record<string, string> = {
'3dmax-material': '3DMAX 素材模板',
'psd-material': 'PSD 素材模板',
'audio-material': '音频素材模板',
'video-material': '视频素材模板',
'model-material': '模特素材模板',
'other-material': '其他素材模板',
}
function createBuiltinTemplate(key: string) {
return {
id: `builtin-${key}`,
name: BUILTIN_TEMPLATE_NAMES[key] || key,
isBuiltin: true,
}
}
import {
BUILTIN_TEMPLATE_KEYS,
createBuiltinTemplate,
} from '@/lib/upload-template-presets'
export async function GET() {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
)
}
// 获取启用的预制模板列表
let enabledBuiltinTemplates: string[] = []
const ALL_BUILTIN_KEYS = BUILTIN_TEMPLATE_KEYS // 所有预制模板key
@ -78,7 +65,14 @@ export async function GET() {
// 如果 enabledBuiltinTemplates 为空数组,表示全部启用
const builtinTemplates = BUILTIN_TEMPLATE_KEYS
.filter(key => enabledBuiltinTemplates.length === 0 || enabledBuiltinTemplates.includes(key))
.map(key => createBuiltinTemplate(key))
.map(key => {
const template = createBuiltinTemplate(key)
return {
id: template.id,
name: template.name,
isBuiltin: true,
}
})
// 合并预制模板和自定义模板,预制模板在前
const allTemplates = [

View File

@ -0,0 +1,63 @@
import { GET } from '../route'
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
$queryRaw: jest.fn(),
uploadTemplate: {
findMany: jest.fn(),
},
material: {
count: jest.fn(),
},
},
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
describe('GET /api/upload-templates 权限边界', () => {
beforeEach(() => {
jest.resetAllMocks()
})
it('未登录时拒绝读取上传模板', async () => {
getServerSession.mockResolvedValue(null)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('请先登录')
expect(prisma.$queryRaw).not.toHaveBeenCalled()
expect(prisma.uploadTemplate.findMany).not.toHaveBeenCalled()
expect(prisma.material.count).not.toHaveBeenCalled()
})
it('已登录时正常读取上传模板', async () => {
getServerSession.mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
prisma.$queryRaw.mockResolvedValue([{ enabledBuiltinTemplates: '["other-material"]' }])
prisma.uploadTemplate.findMany.mockResolvedValue([])
prisma.material.count.mockResolvedValue(0)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveLength(1)
expect(data[0]).toMatchObject({
id: 'builtin-other-material',
isBuiltin: true,
materialCount: 0,
})
})
})

View File

@ -7,6 +7,8 @@
*/
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import {
BUILTIN_TEMPLATE_KEYS,
@ -55,6 +57,14 @@ const publicSelect = {
export async function GET() {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
)
}
// 获取启用的预制模板列表
let enabledBuiltinTemplates: string[] = []
try {