diff --git a/app/api/home-filter-fields/__tests__/route-auth.test.ts b/app/api/home-filter-fields/__tests__/route-auth.test.ts new file mode 100644 index 0000000..9bf0850 --- /dev/null +++ b/app/api/home-filter-fields/__tests__/route-auth.test.ts @@ -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 }], + }, + ], + }) + }) +}) diff --git a/app/api/home-filter-fields/route.ts b/app/api/home-filter-fields/route.ts index 8857c67..f59612e 100644 --- a/app/api/home-filter-fields/route.ts +++ b/app/api/home-filter-fields/route.ts @@ -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 = { 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>(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() + rows.forEach((row) => { + if (!row.value) return + map.set(row.value, Number(row.count ?? 0)) + }) + counts[fieldKey] = map + + const totalResult = await prisma.$queryRaw>(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>( @@ -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 diff --git a/app/api/materials/__tests__/materials-list-auth.test.ts b/app/api/materials/__tests__/materials-list-auth.test.ts new file mode 100644 index 0000000..99ba5d1 --- /dev/null +++ b/app/api/materials/__tests__/materials-list-auth.test.ts @@ -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'] } }]) + ) + }) +}) diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index 790833b..bfc3e7f 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -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 { + 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> { + 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 { + const fieldKeys = await getSearchableCustomFieldKeys() + if (fieldKeys.length === 0) { + return [] + } + + const rows = await prisma.$queryRaw>(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 { + const rows = await prisma.$queryRaw>(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) - }) + for (const { key, value } of dynamicFieldFilters) { + if (ALLOWED_DYNAMIC_FILTER_FIELDS.has(key)) { + andConditions.push({ [key]: value } as Record) + } 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) { ) } } - - diff --git a/app/api/templates/__tests__/templates-route-auth.test.ts b/app/api/templates/__tests__/templates-route-auth.test.ts new file mode 100644 index 0000000..a40d5df --- /dev/null +++ b/app/api/templates/__tests__/templates-route-auth.test.ts @@ -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 }, + ]) + }) +}) diff --git a/app/api/templates/route.ts b/app/api/templates/route.ts index ec93c3b..1d18c9b 100644 --- a/app/api/templates/route.ts +++ b/app/api/templates/route.ts @@ -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 = { - '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 = [ diff --git a/app/api/upload-templates/__tests__/route-auth.test.ts b/app/api/upload-templates/__tests__/route-auth.test.ts new file mode 100644 index 0000000..b7b73dc --- /dev/null +++ b/app/api/upload-templates/__tests__/route-auth.test.ts @@ -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, + }) + }) +}) diff --git a/app/api/upload-templates/route.ts b/app/api/upload-templates/route.ts index 001865e..3386bea 100644 --- a/app/api/upload-templates/route.ts +++ b/app/api/upload-templates/route.ts @@ -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 {