From 6a5077312ec1bc6f9c535c37c73108a97e2640ce Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 25 May 2026 17:12:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E4=B8=BB=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E6=9D=83=E9=99=90=E4=B8=8E=E8=B7=A8=E5=9F=9F?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __tests__/middleware-cors.test.ts | 129 ++++++++++++++++ .../materials/export/__tests__/route.test.ts | 107 ++++++++++++++ app/api/admin/materials/export/route.ts | 90 ++++++++++++ app/api/restore-data/route.ts | 40 +++-- app/api/tunnel/__tests__/route.test.ts | 138 ++++++++++++++++++ app/api/tunnel/start/route.ts | 11 +- app/api/tunnel/status/route.ts | 14 +- app/api/tunnel/stop/route.ts | 10 +- app/api/user/favorites/route.ts | 3 +- app/api/user/history/route.ts | 3 +- app/api/user/likes/route.ts | 3 +- instrumentation.ts | 8 + jest.config.js | 15 ++ lib/config.ts | 11 +- lib/prisma.ts | 7 + lib/system-integrity.ts | 125 ++++++---------- middleware.ts | 90 ++++++++---- next.config.js | 2 +- tsconfig.json | 9 +- 19 files changed, 674 insertions(+), 141 deletions(-) create mode 100644 __tests__/middleware-cors.test.ts create mode 100644 app/api/admin/materials/export/__tests__/route.test.ts create mode 100644 app/api/admin/materials/export/route.ts create mode 100644 app/api/tunnel/__tests__/route.test.ts diff --git a/__tests__/middleware-cors.test.ts b/__tests__/middleware-cors.test.ts new file mode 100644 index 0000000..c2f1d13 --- /dev/null +++ b/__tests__/middleware-cors.test.ts @@ -0,0 +1,129 @@ +jest.mock('next/server', () => { + class MockNextResponse extends Response { + static next() { + return new MockNextResponse(null, { status: 200 }) + } + + static redirect(url: URL | string) { + return new MockNextResponse(null, { + status: 307, + headers: { Location: url.toString() }, + }) + } + } + + return { NextResponse: MockNextResponse } +}) + +jest.mock('next-auth/jwt', () => ({ + getToken: jest.fn(), +})) + +function createRequest(url: string, options: { method?: string; origin?: string } = {}) { + const parsed = new URL(url) + const headers = new Headers() + if (options.origin) { + headers.set('origin', options.origin) + } + + return { + method: options.method || 'GET', + url, + headers, + nextUrl: { + pathname: parsed.pathname, + origin: parsed.origin, + }, + } as any +} + +describe('middleware CORS', () => { + const originalEnv = process.env + + beforeEach(() => { + jest.resetModules() + process.env = { + ...originalEnv, + ALLOWED_ORIGINS: 'https://portal.example.com', + APP_PUBLIC_URL: 'https://app.example.com', + NEXTAUTH_URL: 'https://app.example.com', + } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it('允许白名单来源携带凭据访问 API', async () => { + const { middleware } = await import('../middleware') + + const response = await middleware(createRequest('https://app.example.com/api/materials', { + origin: 'https://portal.example.com', + })) + + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://portal.example.com') + expect(response.headers.get('Access-Control-Allow-Credentials')).toBe('true') + expect(response.headers.get('Vary')).toBe('Origin') + }) + + it('拒绝陌生来源的预检请求', async () => { + const { middleware } = await import('../middleware') + + const response = await middleware(createRequest('https://app.example.com/api/materials', { + method: 'OPTIONS', + origin: 'https://evil.example.com', + })) + + expect(response.status).toBe(403) + expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull() + }) + + it('陌生来源的普通 API 响应不返回 CORS 凭据头', async () => { + const { middleware } = await import('../middleware') + + const response = await middleware(createRequest('https://app.example.com/api/materials', { + origin: 'https://evil.example.com', + })) + + expect(response.status).toBe(200) + expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull() + expect(response.headers.get('Access-Control-Allow-Credentials')).toBeNull() + }) +}) + +describe('middleware admin pages', () => { + const originalEnv = process.env + + beforeEach(() => { + jest.resetModules() + process.env = { + ...originalEnv, + NEXTAUTH_SECRET: 'test-secret', + } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it('普通用户访问监控页会被带回首页', async () => { + const { getToken } = require('next-auth/jwt') + getToken.mockResolvedValue({ role: 'USER' }) + + const { middleware } = await import('../middleware') + const response = await middleware(createRequest('https://app.example.com/admin/monitor')) + + expect(response.status).toBe(307) + expect(response.headers.get('Location')).toBe('https://app.example.com/') + }) + + it('管理员可以访问监控页', async () => { + const { getToken } = require('next-auth/jwt') + getToken.mockResolvedValue({ role: 'ADMIN' }) + + const { middleware } = await import('../middleware') + const response = await middleware(createRequest('https://app.example.com/admin/monitor')) + + expect(response.status).toBe(200) + }) +}) diff --git a/app/api/admin/materials/export/__tests__/route.test.ts b/app/api/admin/materials/export/__tests__/route.test.ts new file mode 100644 index 0000000..7c62fb4 --- /dev/null +++ b/app/api/admin/materials/export/__tests__/route.test.ts @@ -0,0 +1,107 @@ +import { NextRequest } from 'next/server' + +jest.mock('@/lib/auth', () => ({ + authOptions: {}, +})) + +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})) + +jest.mock('@/lib/prisma', () => ({ + prisma: { + material: { + count: jest.fn(), + findMany: jest.fn(), + }, + }, +})) + +const { getServerSession } = require('next-auth') +const { prisma } = require('@/lib/prisma') + +import { GET } from '../route' + +function createRequest(path = 'http://localhost/api/admin/materials/export') { + return new NextRequest(path) +} + +describe('admin materials export', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('未登录不能导出素材数据', async () => { + getServerSession.mockResolvedValue(null) + + const response = await GET(createRequest()) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data.error).toContain('未登录') + expect(prisma.material.findMany).not.toHaveBeenCalled() + }) + + it('普通用户不能导出素材数据', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'user-1', role: 'USER' }, + }) + + const response = await GET(createRequest()) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('管理员') + expect(prisma.material.findMany).not.toHaveBeenCalled() + }) + + it('管理员可以导出素材数据,并且不会返回 BigInt 或 Date 原始对象', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'admin-1', role: 'ADMIN' }, + }) + prisma.material.count.mockResolvedValue(1) + prisma.material.findMany.mockResolvedValue([ + { + id: 'material-1', + productModel: 'SPU-001', + brandName: 'FLNXI', + seriesName: 'A', + skuCode: 'SKU-001', + materialTexture: '木纹', + categoryName: '使用店铺', + sceneName: '场景类别', + platformName: '平台', + materialType: '3DMAX', + renderer: 'CR', + renderTime: '10min', + imageResolution: '2000x2000', + colorMode: 'RGB', + authorName: '张三', + zipFileName: 'asset.zip', + zipFileSize: BigInt(123456), + downloadCount: 2, + likeCount: 3, + isPremium: true, + createdAt: new Date('2026-05-25T01:02:03.000Z'), + updatedAt: new Date('2026-05-25T02:03:04.000Z'), + user: { username: 'admin' }, + }, + ]) + + const response = await GET(createRequest('http://localhost/api/admin/materials/export?limit=999999')) + const data = await response.json() + + expect(response.status).toBe(200) + expect(prisma.material.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { deletedAt: null }, + take: 10000, + }) + ) + expect(data.totalCount).toBe(1) + expect(data.exportedCount).toBe(1) + expect(data.materials[0].zipFileSize).toBe('123456') + expect(data.materials[0].createdAt).toBe('2026-05-25T01:02:03.000Z') + expect(data.materials[0].updatedAt).toBe('2026-05-25T02:03:04.000Z') + }) +}) diff --git a/app/api/admin/materials/export/route.ts b/app/api/admin/materials/export/route.ts new file mode 100644 index 0000000..e313fe4 --- /dev/null +++ b/app/api/admin/materials/export/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +export const dynamic = 'force-dynamic' + +const DEFAULT_EXPORT_LIMIT = 10000 +const MAX_EXPORT_LIMIT = 10000 + +function parseExportLimit(value: string | null) { + const parsed = Number.parseInt(value || '', 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_EXPORT_LIMIT + } + return Math.min(parsed, MAX_EXPORT_LIMIT) +} + +export async function GET(req: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + return NextResponse.json({ error: '未登录' }, { status: 401 }) + } + + if ((session.user as any).role !== 'ADMIN') { + return NextResponse.json({ error: '需要管理员权限' }, { status: 403 }) + } + + const { searchParams } = new URL(req.url) + const limit = parseExportLimit(searchParams.get('limit')) + const where = { deletedAt: null } + + const [totalCount, rawMaterials] = await Promise.all([ + prisma.material.count({ where }), + prisma.material.findMany({ + where, + select: { + id: true, + productModel: true, + brandName: true, + seriesName: true, + skuCode: true, + materialTexture: true, + categoryName: true, + sceneName: true, + platformName: true, + materialType: true, + renderer: true, + renderTime: true, + imageResolution: true, + colorMode: true, + authorName: true, + zipFileName: true, + zipFileSize: true, + downloadCount: true, + likeCount: true, + isPremium: true, + createdAt: true, + updatedAt: true, + user: { + select: { + username: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: limit, + }), + ]) + + const materials = rawMaterials.map((material) => ({ + ...material, + zipFileSize: material.zipFileSize.toString(), + createdAt: material.createdAt.toISOString(), + updatedAt: material.updatedAt.toISOString(), + })) + + return NextResponse.json({ + materials, + totalCount, + exportedCount: materials.length, + limit, + }) + } catch (error) { + console.error('[AdminMaterialsExport] GET error:', error) + return NextResponse.json({ error: '导出素材数据失败' }, { status: 500 }) + } +} diff --git a/app/api/restore-data/route.ts b/app/api/restore-data/route.ts index 2931b41..7210c38 100644 --- a/app/api/restore-data/route.ts +++ b/app/api/restore-data/route.ts @@ -3,22 +3,18 @@ import { prisma } from '@/lib/prisma'; import https from 'https'; import fs from 'fs'; import path from 'path'; -import crypto from 'crypto'; - -// 简单的密码hash函数(临时使用) -function hashPassword(password: string): string { - return crypto.createHash('sha256').update(password).digest('hex'); -} +import bcrypt from 'bcryptjs'; // 配置 const UPLOADS_DIR = '/app/uploads'; const S3_BUCKET_URL = process.env.RESTORE_S3_BUCKET_URL as string; const S3_PUBLIC_URL = process.env.RESTORE_S3_PUBLIC_URL as string; +const RESTORE_DATA_TOKEN = process.env.RESTORE_DATA_TOKEN?.trim() || ''; // 默认配置 -const ADMIN_EMAIL = 'admin'; -const ADMIN_USERNAME = 'admin'; -const ADMIN_PASSWORD = 'admin123456'; +const ADMIN_EMAIL = process.env.RESTORE_ADMIN_EMAIL?.trim() || 'admin@local.flnxi'; +const ADMIN_USERNAME = process.env.RESTORE_ADMIN_USERNAME?.trim() || 'admin'; +const ADMIN_PASSWORD = process.env.RESTORE_ADMIN_PASSWORD?.trim() || ''; const DEFAULT_CATEGORY = '默认店铺'; const DEFAULT_SCENE = '默认场景'; const DEFAULT_RENDERER = 'CR'; @@ -111,6 +107,28 @@ function matchFiles(previewFiles: string[], materialFiles: string[]): Match[] { export async function POST(req: NextRequest) { try { + if (!RESTORE_DATA_TOKEN) { + return NextResponse.json({ + success: false, + message: '未配置 RESTORE_DATA_TOKEN,恢复接口已禁用', + }, { status: 503 }); + } + + const requestToken = req.headers.get('x-restore-data-token')?.trim() || ''; + if (requestToken !== RESTORE_DATA_TOKEN) { + return NextResponse.json({ + success: false, + message: '恢复令牌无效', + }, { status: 401 }); + } + + if (!ADMIN_PASSWORD) { + return NextResponse.json({ + success: false, + message: '未配置 RESTORE_ADMIN_PASSWORD,无法创建恢复管理员', + }, { status: 503 }); + } + const output: string[] = []; output.push('🚀 开始数据恢复...\n'); @@ -119,7 +137,7 @@ export async function POST(req: NextRequest) { output.push('📝 步骤1: 创建管理员用户'); let admin; try { - const hashedPassword = hashPassword(ADMIN_PASSWORD); + const hashedPassword = await bcrypt.hash(ADMIN_PASSWORD, 10); admin = await prisma.user.create({ data: { email: ADMIN_EMAIL, @@ -233,7 +251,7 @@ export async function POST(req: NextRequest) { output.push(`\n🎉 数据恢复完成!`); output.push(`\n📌 默认信息:`); output.push(` 管理员账号: ${ADMIN_USERNAME}`); - output.push(` 管理员密码: ${ADMIN_PASSWORD}`); + output.push(` 管理员密码: 已使用 RESTORE_ADMIN_PASSWORD 配置`); output.push(` 默认分类: ${DEFAULT_CATEGORY}`); output.push(` 默认场景: ${DEFAULT_SCENE}`); output.push(`\n⚠️ 提示: 请登录后台修改素材的SPU、分类、场景等信息`); diff --git a/app/api/tunnel/__tests__/route.test.ts b/app/api/tunnel/__tests__/route.test.ts new file mode 100644 index 0000000..da7be80 --- /dev/null +++ b/app/api/tunnel/__tests__/route.test.ts @@ -0,0 +1,138 @@ +import { NextRequest } from 'next/server' + +jest.mock('@/lib/auth', () => ({ + authOptions: {}, +})) + +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})) + +jest.mock('@/lib/config', () => ({ + featureFlags: { + tunnelEnabled: false, + }, +})) + +jest.mock('@/lib/license', () => ({ + canUseIntranetPenetration: jest.fn(), +})) + +jest.mock('@/lib/prisma', () => ({ + prisma: { + user: { + findUnique: jest.fn(), + }, + systemSettings: { + findFirst: jest.fn(), + }, + }, +})) + +jest.mock('@/lib/admin-permissions', () => ({ + isCurrentSuperAdmin: jest.fn(), +})) + +import { POST as startTunnel } from '../start/route' +import { GET as getTunnelStatus } from '../status/route' +import { POST as stopTunnel } from '../stop/route' + +const { getServerSession } = require('next-auth') +const { canUseIntranetPenetration } = require('@/lib/license') +const { prisma } = require('@/lib/prisma') +const { isCurrentSuperAdmin } = require('@/lib/admin-permissions') + +function buildRequest(method: string) { + return new NextRequest('http://localhost:3000/api/tunnel', { method }) +} + +describe('内网穿透 API 许可证边界', () => { + beforeEach(() => { + jest.resetAllMocks() + + getServerSession.mockResolvedValue({ + user: { + id: 'super-admin-1', + role: 'ADMIN', + isSuperAdmin: true, + }, + }) + + prisma.systemSettings.findFirst.mockResolvedValue({ + enableTunnelService: true, + }) + + isCurrentSuperAdmin.mockResolvedValue(true) + canUseIntranetPenetration.mockResolvedValue(true) + }) + + test('未登录不能启动内网穿透', async () => { + getServerSession.mockResolvedValue(null) + + const response = await startTunnel(buildRequest('POST')) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data.error).toBe('未登录') + expect(canUseIntranetPenetration).not.toHaveBeenCalled() + }) + + test('旧会话带最高权限标记但数据库已撤销时不能启动', async () => { + getServerSession.mockResolvedValue({ + user: { + id: 'admin-1', + role: 'ADMIN', + isSuperAdmin: true, + }, + }) + isCurrentSuperAdmin.mockResolvedValue(false) + + const response = await startTunnel(buildRequest('POST')) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(prisma.systemSettings.findFirst).not.toHaveBeenCalled() + expect(canUseIntranetPenetration).not.toHaveBeenCalled() + }) + + test('许可证不包含内网穿透时不能启动', async () => { + canUseIntranetPenetration.mockResolvedValue(false) + + const response = await startTunnel(buildRequest('POST')) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('许可证') + }) + + test('许可证不包含内网穿透时不能查看状态', async () => { + canUseIntranetPenetration.mockResolvedValue(false) + + const response = await getTunnelStatus(buildRequest('GET')) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.status).toBe('license_restricted') + expect(data.error).toContain('许可证') + }) + + test('许可证不包含内网穿透时不能停止', async () => { + canUseIntranetPenetration.mockResolvedValue(false) + + const response = await stopTunnel(buildRequest('POST')) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('许可证') + }) + + test('最高权限账号且许可证允许时可以启动占位隧道', async () => { + const response = await startTunnel(buildRequest('POST')) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.tunnelUrl).toBe('https://mock-tunnel.example.com') + expect(canUseIntranetPenetration).toHaveBeenCalled() + }) +}) diff --git a/app/api/tunnel/start/route.ts b/app/api/tunnel/start/route.ts index af952cb..e47f77b 100644 --- a/app/api/tunnel/start/route.ts +++ b/app/api/tunnel/start/route.ts @@ -2,7 +2,9 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { featureFlags } from '@/lib/config' +import { canUseIntranetPenetration } from '@/lib/license' import { prisma } from '@/lib/prisma' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' async function ensureTunnelEnabled() { if (featureFlags.tunnelEnabled) return true @@ -17,10 +19,7 @@ export async function POST(req: NextRequest) { const session = await getServerSession(authOptions) if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 }) - const isAdmin = (session.user as any)?.role === 'ADMIN' - const isSuperAdmin = (session.user as any)?.isSuperAdmin - // 仅超级管理员或已授权管理员可操作(目前限制最高权限) - if (!isAdmin || !isSuperAdmin) { + if ((session.user as any)?.role !== 'ADMIN' || !(await isCurrentSuperAdmin(session))) { return NextResponse.json({ error: '仅最高权限账号可操作内网穿透' }, { status: 403 }) } @@ -29,6 +28,10 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: '内网穿透未启用' }, { status: 501 }) } + if (!(await canUseIntranetPenetration())) { + return NextResponse.json({ error: '当前许可证不包含内网穿透功能,请联系管理员处理许可证' }, { status: 403 }) + } + // TODO: 调用具体的隧道提供商启动逻辑 return NextResponse.json({ message: '隧道启动成功(占位)', diff --git a/app/api/tunnel/status/route.ts b/app/api/tunnel/status/route.ts index 6d6ec01..55a66ef 100644 --- a/app/api/tunnel/status/route.ts +++ b/app/api/tunnel/status/route.ts @@ -2,7 +2,9 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { featureFlags } from '@/lib/config' +import { canUseIntranetPenetration } from '@/lib/license' import { prisma } from '@/lib/prisma' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' export const dynamic = 'force-dynamic' @@ -19,9 +21,7 @@ export async function GET(req: NextRequest) { const session = await getServerSession(authOptions) if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 }) - const isAdmin = (session.user as any)?.role === 'ADMIN' - const isSuperAdmin = (session.user as any)?.isSuperAdmin - if (!isAdmin || !isSuperAdmin) { + if ((session.user as any)?.role !== 'ADMIN' || !(await isCurrentSuperAdmin(session))) { return NextResponse.json({ error: '仅最高权限账号可操作内网穿透' }, { status: 403 }) } @@ -30,6 +30,14 @@ export async function GET(req: NextRequest) { return NextResponse.json({ enabled: false, status: 'disabled' }) } + if (!(await canUseIntranetPenetration())) { + return NextResponse.json({ + enabled: false, + status: 'license_restricted', + error: '当前许可证不包含内网穿透功能,请联系管理员处理许可证', + }, { status: 403 }) + } + // TODO: 查询隧道状态 return NextResponse.json({ enabled: true, status: 'stopped' }) } catch (e) { diff --git a/app/api/tunnel/stop/route.ts b/app/api/tunnel/stop/route.ts index 3922752..1f3d3a8 100644 --- a/app/api/tunnel/stop/route.ts +++ b/app/api/tunnel/stop/route.ts @@ -2,7 +2,9 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { featureFlags } from '@/lib/config' +import { canUseIntranetPenetration } from '@/lib/license' import { prisma } from '@/lib/prisma' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' async function ensureTunnelEnabled() { if (featureFlags.tunnelEnabled) return true @@ -17,9 +19,7 @@ export async function POST(req: NextRequest) { const session = await getServerSession(authOptions) if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 }) - const isAdmin = (session.user as any)?.role === 'ADMIN' - const isSuperAdmin = (session.user as any)?.isSuperAdmin - if (!isAdmin || !isSuperAdmin) { + if ((session.user as any)?.role !== 'ADMIN' || !(await isCurrentSuperAdmin(session))) { return NextResponse.json({ error: '仅最高权限账号可操作内网穿透' }, { status: 403 }) } @@ -28,6 +28,10 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: '内网穿透未启用' }, { status: 501 }) } + if (!(await canUseIntranetPenetration())) { + return NextResponse.json({ error: '当前许可证不包含内网穿透功能,请联系管理员处理许可证' }, { status: 403 }) + } + // TODO: 调用隧道提供商停止逻辑 return NextResponse.json({ message: '隧道已停止(占位)' }) } catch (e) { diff --git a/app/api/user/favorites/route.ts b/app/api/user/favorites/route.ts index 4e4bcfb..211a623 100644 --- a/app/api/user/favorites/route.ts +++ b/app/api/user/favorites/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' +import { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite' function parseDisplayImages(value: string | null): string[] { if (!value) return [] @@ -53,7 +54,7 @@ export async function GET(req: NextRequest) { const materials = favorites .map((favorite) => favorite.material) .filter((material): material is NonNullable => Boolean(material)) - .map((material) => ({ + .map((material) => rewriteMaterialStorageUrlsForClient({ ...material, displayImages: parseDisplayImages(material.displayImages as unknown as string | null), zipFileSize: material.zipFileSize.toString(), diff --git a/app/api/user/history/route.ts b/app/api/user/history/route.ts index 3ce4e82..37cb783 100644 --- a/app/api/user/history/route.ts +++ b/app/api/user/history/route.ts @@ -12,6 +12,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' +import { toMaterialAssetProxyUrl } from '@/lib/url-rewrite' /** * GET /api/user/history @@ -65,7 +66,7 @@ export async function GET(request: NextRequest) { material: { id: item.material.id, title: item.material.productModel, - thumbnailUrl: item.material.previewUrl, + thumbnailUrl: toMaterialAssetProxyUrl(item.material.previewUrl), materialType: item.material.materialType, materialKind: item.material.materialKind, viewCount: item.material.downloadCount, diff --git a/app/api/user/likes/route.ts b/app/api/user/likes/route.ts index a1dcc63..205ee6a 100644 --- a/app/api/user/likes/route.ts +++ b/app/api/user/likes/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' +import { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite' function parseDisplayImages(value: string | null): string[] { if (!value) return [] @@ -53,7 +54,7 @@ export async function GET(req: NextRequest) { const materials = likes .map((like) => like.material) .filter((material): material is NonNullable => Boolean(material)) - .map((material) => ({ + .map((material) => rewriteMaterialStorageUrlsForClient({ ...material, displayImages: parseDisplayImages(material.displayImages as unknown as string | null), zipFileSize: material.zipFileSize.toString(), diff --git a/instrumentation.ts b/instrumentation.ts index 3aff849..09db87d 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -15,5 +15,13 @@ export async function register() { } catch (error) { console.error('❌ 许可证服务初始化失败:', error); } + + try { + const { recoverStaleVideoTranscodeJobs } = await import('@/lib/video-transcode-service'); + await recoverStaleVideoTranscodeJobs(); + console.log('✅ 视频转码任务恢复检查已执行'); + } catch (error) { + console.error('❌ 视频转码任务恢复检查失败:', error); + } } } diff --git a/jest.config.js b/jest.config.js index 025eb43..d57e79b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,6 +12,18 @@ const customJestConfig = { moduleNameMapper: { '^@/(.*)$': '/$1', }, + testPathIgnorePatterns: [ + '/.next/', + '/gongxiangp2-source-reference-20260309-202917/', + '/gongxiangp2-source-reference-20260413-172354/', + '/gongxiangp2-source-reference-old-style-20260513-132942/', + ], + modulePathIgnorePatterns: [ + '/.next/', + '/gongxiangp2-source-reference-20260309-202917/', + '/gongxiangp2-source-reference-20260413-172354/', + '/gongxiangp2-source-reference-old-style-20260513-132942/', + ], testMatch: [ '**/__tests__/**/*.test.ts', '**/__tests__/**/*.test.tsx', @@ -21,6 +33,9 @@ const customJestConfig = { 'app/api/**/*.{ts,tsx}', '!**/*.d.ts', '!**/node_modules/**', + '!gongxiangp2-source-reference-20260309-202917/**', + '!gongxiangp2-source-reference-20260413-172354/**', + '!gongxiangp2-source-reference-old-style-20260513-132942/**', ], } diff --git a/lib/config.ts b/lib/config.ts index a0a853d..9d5f046 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -33,16 +33,15 @@ export function resolveBaseUrl(req?: NextRequest): string { export const defaultLogos = { // 后台默认使用内置静态资源;如需自定义,可通过环境变量覆盖 - // logo1:浅色 Logo,适合深色背景(导航栏) - // logo2:深色 Logo,适合浅色背景(登录页) - header: process.env.DEFAULT_HEADER_LOGO_URL || '/logo/logo1.png', - login: process.env.DEFAULT_LOGIN_LOGO_URL || '/logo/logo2.png', + // 当前仓库不再内置公共 Logo 资源;若未配置则走文字占位 + header: process.env.DEFAULT_HEADER_LOGO_URL || '', + login: process.env.DEFAULT_LOGIN_LOGO_URL || '', } export const publicDefaults = { // 前端默认 Logo,与后端 defaultLogos 保持一致,方便通过 env 统一覆盖 - header: process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '/logo/logo1.png', - login: process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '/logo/logo2.png', + header: process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '', + login: process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '', intranetUrl: process.env.NEXT_PUBLIC_INTRANET_URL || '', } diff --git a/lib/prisma.ts b/lib/prisma.ts index 259d6e7..7880ac5 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -5,6 +5,13 @@ const globalForPrisma = global as unknown as { prisma: PrismaClient } export const prisma = globalForPrisma.prisma || new PrismaClient({ + datasources: process.env.DATABASE_URL + ? { + db: { + url: process.env.DATABASE_URL, + }, + } + : undefined, log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], }) diff --git a/lib/system-integrity.ts b/lib/system-integrity.ts index f375c79..d64cd9a 100644 --- a/lib/system-integrity.ts +++ b/lib/system-integrity.ts @@ -12,19 +12,16 @@ */ import { prisma } from '@/lib/prisma' +import { + BUILTIN_TEMPLATE_KEYS as BUILTIN_TEMPLATE_KEYS_SOURCE, + createBuiltinTemplate, +} from './upload-template-presets' // ============================================================================ // 内置模板配置(单一数据源) // ============================================================================ -export const BUILTIN_TEMPLATE_KEYS = [ - '3dmax-material', - 'psd-material', - 'audio-material', - 'video-material', - 'model-material', - 'other-material', -] as const +export const BUILTIN_TEMPLATE_KEYS = BUILTIN_TEMPLATE_KEYS_SOURCE export type BuiltinTemplateKey = (typeof BUILTIN_TEMPLATE_KEYS)[number] @@ -46,76 +43,48 @@ export const BUILTIN_TEMPLATE_CONFIG: Record< addToCategory?: boolean }> } -> = { - '3dmax-material': { - name: '3DMAX 素材模板', - description: '适用于 3D 模型类素材', - materialType: '3DMAX', - materialKind: 'IMAGE', - fields: [ - { key: 'productModel', label: 'SPU型号', type: 'text', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true }, - { key: 'categoryName', label: '使用店铺', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, - { key: 'sceneName', label: '场景类别', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, - { key: 'renderer', label: '渲染器', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true, addToCategory: true }, - ], - }, - 'psd-material': { - name: 'PSD 素材模板', - description: '针对平面设计 PSD 素材', - materialType: 'PSD', - materialKind: 'IMAGE', - fields: [ - { key: 'productModel', label: 'SPU型号', type: 'text', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true }, - { key: 'categoryName', label: '使用店铺', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, - { key: 'sceneName', label: '场景类别', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, - ], - }, - 'audio-material': { - name: '音频素材模板', - description: '适用于音频文件素材,支持 MP3、WAV、FLAC、AAC 等格式', - materialType: '音频', - materialKind: 'AUDIO', - fields: [ - { key: 'productModel', label: '音频名称', type: 'text', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true }, - { key: 'audioCategory', label: '音频分类', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, - { key: 'audioScene', label: '使用场景', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, - { key: 'authorName', label: '作者', type: 'text', required: false, layoutWidth: 'half', visibleOnHomeFilter: false, isSearchable: true }, - ], - }, - 'video-material': { - name: '视频素材模板', - description: '适用于视频文件素材', - materialType: '视频', - materialKind: 'VIDEO', - fields: [ - { key: 'productModel', label: 'SPU型号', type: 'text', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true }, - { key: 'categoryName', label: '使用店铺', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, - { key: 'sceneName', label: '场景类别', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, - ], - }, - 'model-material': { - name: '模特素材模板', - description: '用于模特/人像素材', - materialType: '模特', - materialKind: 'IMAGE', - fields: [ - { key: 'productModel', label: 'SPU型号', type: 'text', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true }, - { key: 'categoryName', label: '使用店铺', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, - { key: 'sceneName', label: '场景类别', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, - ], - }, - 'other-material': { - name: '其他素材模板', - description: '适用于其他类型的素材', - materialType: '其他', - materialKind: 'OTHER', - fields: [ - { key: 'productModel', label: 'SPU型号', type: 'text', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true }, - { key: 'categoryName', label: '使用店铺', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, - { key: 'sceneName', label: '场景类别', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, - ], - }, -} +> = Object.fromEntries( + BUILTIN_TEMPLATE_KEYS.map((key) => { + const template = createBuiltinTemplate(key) + return [ + key, + { + name: template.name, + description: template.description, + materialType: template.materialType, + materialKind: template.materialKind, + fields: template.fields.map((field) => ({ + key: field.key, + label: field.label, + type: field.type, + required: field.required, + layoutWidth: field.layoutWidth || 'full', + visibleOnHomeFilter: field.visibleOnHomeFilter, + isSearchable: field.isSearchable, + addToCategory: field.addToCategory, + })), + }, + ] + }) +) as Record< + BuiltinTemplateKey, + { + name: string + description: string + materialType: string + materialKind: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER' + fields: Array<{ + key: string + label: string + type: string + required: boolean + layoutWidth: string + visibleOnHomeFilter: boolean + isSearchable: boolean + addToCategory?: boolean + }> + } +> // ============================================================================ // 数据完整性检查结果类型 diff --git a/middleware.ts b/middleware.ts index 9a1fb7a..73293cd 100644 --- a/middleware.ts +++ b/middleware.ts @@ -5,61 +5,80 @@ import { getToken } from 'next-auth/jwt' // 允许的来源列表(内网和公网) - 支持通过环境变量配置,逗号分隔 const ALLOWED_ORIGINS = (() => { const fromEnv = process.env.ALLOWED_ORIGINS || '' - const list = fromEnv.split(',').map(s => s.trim()).filter(Boolean) + const configuredUrls = [ + fromEnv, + process.env.APP_PUBLIC_URL || '', + process.env.NEXTAUTH_URL || '', + ] + const list = configuredUrls.flatMap(value => value.split(',')).map(s => normalizeOrigin(s)).filter(Boolean) as string[] const defaults = ['http://localhost:3000', 'http://localhost:4320'] return Array.from(new Set([...defaults, ...list])) })() -// 不需要许可证检查的路径 -const LICENSE_EXEMPT_PATHS = [ - '/activate', - '/api/license', - '/api/auth', - '/login', - '/_next', - '/favicon.ico', -] +function normalizeOrigin(value?: string | null) { + const trimmed = (value || '').trim() + if (!trimmed) return null + + try { + const url = new URL(trimmed) + return url.origin + } catch { + return null + } +} + +function getAllowedCorsOrigin(requestOrigin: string | null, sameOrigin: string) { + if (!requestOrigin) return sameOrigin + + const normalized = normalizeOrigin(requestOrigin) + if (!normalized) return null + + if (normalized === sameOrigin || ALLOWED_ORIGINS.includes(normalized)) { + return normalized + } + + return null +} export async function middleware(request: NextRequest) { const { pathname, origin } = request.nextUrl const requestOrigin = request.headers.get('origin') + const corsOrigin = getAllowedCorsOrigin(requestOrigin, origin) - // 检查是否需要许可证验证 - const needsLicenseCheck = !LICENSE_EXEMPT_PATHS.some(path => pathname.startsWith(path)) - - // 许可证检查(通过 cookie 判断是否已激活) - if (needsLicenseCheck) { - const licenseActivated = request.cookies.get('license_activated')?.value - if (!licenseActivated) { - // 首次访问,重定向到激活页面 - // 注意:实际的许可证验证在服务端进行,这里只是快速检查 - const url = new URL('/activate', request.url) - return NextResponse.redirect(url) - } - } + // 许可证检查移至后端 API 进行,不再在 middleware 中做快速检查 + // 原因:1) Cookie 可伪造 2) 对弱网用户友好 3) 真正的安全验证在后端 + // 用户首次访问时,前端会调用 /api/license/check 检查许可证状态 // 处理API路由的CORS if (pathname.startsWith('/api/')) { // 处理OPTIONS预检请求 if (request.method === 'OPTIONS') { + if (!corsOrigin) { + return new NextResponse(null, { status: 403 }) + } + return new NextResponse(null, { status: 204, headers: { 'Access-Control-Allow-Credentials': 'true', - 'Access-Control-Allow-Origin': requestOrigin || origin, + 'Access-Control-Allow-Origin': corsOrigin, 'Access-Control-Allow-Methods': 'GET,DELETE,PATCH,POST,PUT,OPTIONS', 'Access-Control-Allow-Headers': 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization, Cookie', 'Access-Control-Max-Age': '86400', + 'Vary': 'Origin', }, }) } // 为所有API响应添加CORS头 const response = NextResponse.next() - response.headers.set('Access-Control-Allow-Credentials', 'true') - response.headers.set('Access-Control-Allow-Origin', requestOrigin || origin) - response.headers.set('Access-Control-Allow-Methods', 'GET,DELETE,PATCH,POST,PUT,OPTIONS') - response.headers.set('Access-Control-Allow-Headers', 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization, Cookie') + if (corsOrigin) { + response.headers.set('Access-Control-Allow-Credentials', 'true') + response.headers.set('Access-Control-Allow-Origin', corsOrigin) + response.headers.set('Access-Control-Allow-Methods', 'GET,DELETE,PATCH,POST,PUT,OPTIONS') + response.headers.set('Access-Control-Allow-Headers', 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization, Cookie') + response.headers.set('Vary', 'Origin') + } return response } @@ -74,8 +93,7 @@ export async function middleware(request: NextRequest) { } // 管理员页面需要管理员权限 - const requiresAdmin = - pathname.startsWith('/admin') && !pathname.startsWith('/admin/monitor') + const requiresAdmin = pathname.startsWith('/admin') if (requiresAdmin) { if (token.role !== 'ADMIN') { const url = new URL('/', request.url) @@ -88,6 +106,16 @@ export async function middleware(request: NextRequest) { } export const config = { - matcher: ['/', '/admin/:path*', '/api/:path*'], + // 匹配所有页面路由,排除静态资源 + matcher: [ + /* + * 匹配所有路径,除了: + * - _next/static (静态文件) + * - _next/image (图片优化) + * - favicon.ico (网站图标) + * - public 目录下的文件 (如 .svg, .png, .jpg 等) + */ + '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)', + ], } diff --git a/next.config.js b/next.config.js index 619c716..80cfefb 100644 --- a/next.config.js +++ b/next.config.js @@ -30,7 +30,7 @@ const nextConfig = { images: { remotePatterns: (() => { const hosts = (process.env.IMAGE_REMOTE_HOSTS || '').split(',').map(s => s.trim()).filter(Boolean) - const defaults = ['s3.1080p.hk', '45.249.208.118', 'localhost'] // 添加服务器IP和localhost支持 + const defaults = ['localhost'] const all = Array.from(new Set([...defaults, ...hosts])) const patterns = [] for (const h of all) { diff --git a/tsconfig.json b/tsconfig.json index 91940e5..6e97631 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,13 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules", "license-server", "flnxi-agent"] + "exclude": [ + "node_modules", + "license-server", + "flnxi-agent", + "gongxiangp2-source-reference-20260309-202917", + "gongxiangp2-source-reference-20260413-172354", + "gongxiangp2-source-reference-old-style-20260513-132942" + ] }