收紧主应用接口权限与跨域边界

This commit is contained in:
WuYongJie 2026-05-25 17:12:11 +08:00
parent c649b939c4
commit 6a5077312e
19 changed files with 674 additions and 141 deletions

View File

@ -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)
})
})

View File

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

View File

@ -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 })
}
}

View File

@ -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、分类、场景等信息`);

View File

@ -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()
})
})

View File

@ -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: '隧道启动成功(占位)',

View File

@ -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) {

View File

@ -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) {

View File

@ -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<typeof material> => Boolean(material))
.map((material) => ({
.map((material) => rewriteMaterialStorageUrlsForClient({
...material,
displayImages: parseDisplayImages(material.displayImages as unknown as string | null),
zipFileSize: material.zipFileSize.toString(),

View File

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

View File

@ -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<typeof material> => Boolean(material))
.map((material) => ({
.map((material) => rewriteMaterialStorageUrlsForClient({
...material,
displayImages: parseDisplayImages(material.displayImages as unknown as string | null),
zipFileSize: material.zipFileSize.toString(),

View File

@ -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);
}
}
}

View File

@ -12,6 +12,18 @@ const customJestConfig = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
testPathIgnorePatterns: [
'<rootDir>/.next/',
'<rootDir>/gongxiangp2-source-reference-20260309-202917/',
'<rootDir>/gongxiangp2-source-reference-20260413-172354/',
'<rootDir>/gongxiangp2-source-reference-old-style-20260513-132942/',
],
modulePathIgnorePatterns: [
'<rootDir>/.next/',
'<rootDir>/gongxiangp2-source-reference-20260309-202917/',
'<rootDir>/gongxiangp2-source-reference-20260413-172354/',
'<rootDir>/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/**',
],
}

View File

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

View File

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

View File

@ -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
}>
}
>
// ============================================================================
// 数据完整性检查结果类型

View File

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

View File

@ -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) {

View File

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