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

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 https from 'https';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import crypto from 'crypto'; import bcrypt from 'bcryptjs';
// 简单的密码hash函数(临时使用)
function hashPassword(password: string): string {
return crypto.createHash('sha256').update(password).digest('hex');
}
// 配置 // 配置
const UPLOADS_DIR = '/app/uploads'; const UPLOADS_DIR = '/app/uploads';
const S3_BUCKET_URL = process.env.RESTORE_S3_BUCKET_URL as string; 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 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_EMAIL = process.env.RESTORE_ADMIN_EMAIL?.trim() || 'admin@local.flnxi';
const ADMIN_USERNAME = 'admin'; const ADMIN_USERNAME = process.env.RESTORE_ADMIN_USERNAME?.trim() || 'admin';
const ADMIN_PASSWORD = 'admin123456'; const ADMIN_PASSWORD = process.env.RESTORE_ADMIN_PASSWORD?.trim() || '';
const DEFAULT_CATEGORY = '默认店铺'; const DEFAULT_CATEGORY = '默认店铺';
const DEFAULT_SCENE = '默认场景'; const DEFAULT_SCENE = '默认场景';
const DEFAULT_RENDERER = 'CR'; const DEFAULT_RENDERER = 'CR';
@ -111,6 +107,28 @@ function matchFiles(previewFiles: string[], materialFiles: string[]): Match[] {
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { 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[] = []; const output: string[] = [];
output.push('🚀 开始数据恢复...\n'); output.push('🚀 开始数据恢复...\n');
@ -119,7 +137,7 @@ export async function POST(req: NextRequest) {
output.push('📝 步骤1: 创建管理员用户'); output.push('📝 步骤1: 创建管理员用户');
let admin; let admin;
try { try {
const hashedPassword = hashPassword(ADMIN_PASSWORD); const hashedPassword = await bcrypt.hash(ADMIN_PASSWORD, 10);
admin = await prisma.user.create({ admin = await prisma.user.create({
data: { data: {
email: ADMIN_EMAIL, email: ADMIN_EMAIL,
@ -233,7 +251,7 @@ export async function POST(req: NextRequest) {
output.push(`\n🎉 数据恢复完成!`); output.push(`\n🎉 数据恢复完成!`);
output.push(`\n📌 默认信息:`); output.push(`\n📌 默认信息:`);
output.push(` 管理员账号: ${ADMIN_USERNAME}`); output.push(` 管理员账号: ${ADMIN_USERNAME}`);
output.push(` 管理员密码: ${ADMIN_PASSWORD}`); output.push(` 管理员密码: 已使用 RESTORE_ADMIN_PASSWORD 配置`);
output.push(` 默认分类: ${DEFAULT_CATEGORY}`); output.push(` 默认分类: ${DEFAULT_CATEGORY}`);
output.push(` 默认场景: ${DEFAULT_SCENE}`); output.push(` 默认场景: ${DEFAULT_SCENE}`);
output.push(`\n⚠️ 提示: 请登录后台修改素材的SPU、分类、场景等信息`); 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 { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { featureFlags } from '@/lib/config' import { featureFlags } from '@/lib/config'
import { canUseIntranetPenetration } from '@/lib/license'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
async function ensureTunnelEnabled() { async function ensureTunnelEnabled() {
if (featureFlags.tunnelEnabled) return true if (featureFlags.tunnelEnabled) return true
@ -17,10 +19,7 @@ export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 }) if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
const isAdmin = (session.user as any)?.role === 'ADMIN' if ((session.user as any)?.role !== 'ADMIN' || !(await isCurrentSuperAdmin(session))) {
const isSuperAdmin = (session.user as any)?.isSuperAdmin
// 仅超级管理员或已授权管理员可操作(目前限制最高权限)
if (!isAdmin || !isSuperAdmin) {
return NextResponse.json({ error: '仅最高权限账号可操作内网穿透' }, { status: 403 }) return NextResponse.json({ error: '仅最高权限账号可操作内网穿透' }, { status: 403 })
} }
@ -29,6 +28,10 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: '内网穿透未启用' }, { status: 501 }) return NextResponse.json({ error: '内网穿透未启用' }, { status: 501 })
} }
if (!(await canUseIntranetPenetration())) {
return NextResponse.json({ error: '当前许可证不包含内网穿透功能,请联系管理员处理许可证' }, { status: 403 })
}
// TODO: 调用具体的隧道提供商启动逻辑 // TODO: 调用具体的隧道提供商启动逻辑
return NextResponse.json({ return NextResponse.json({
message: '隧道启动成功(占位)', message: '隧道启动成功(占位)',

View File

@ -2,7 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { featureFlags } from '@/lib/config' import { featureFlags } from '@/lib/config'
import { canUseIntranetPenetration } from '@/lib/license'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@ -19,9 +21,7 @@ export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 }) if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
const isAdmin = (session.user as any)?.role === 'ADMIN' if ((session.user as any)?.role !== 'ADMIN' || !(await isCurrentSuperAdmin(session))) {
const isSuperAdmin = (session.user as any)?.isSuperAdmin
if (!isAdmin || !isSuperAdmin) {
return NextResponse.json({ error: '仅最高权限账号可操作内网穿透' }, { status: 403 }) return NextResponse.json({ error: '仅最高权限账号可操作内网穿透' }, { status: 403 })
} }
@ -30,6 +30,14 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ enabled: false, status: 'disabled' }) return NextResponse.json({ enabled: false, status: 'disabled' })
} }
if (!(await canUseIntranetPenetration())) {
return NextResponse.json({
enabled: false,
status: 'license_restricted',
error: '当前许可证不包含内网穿透功能,请联系管理员处理许可证',
}, { status: 403 })
}
// TODO: 查询隧道状态 // TODO: 查询隧道状态
return NextResponse.json({ enabled: true, status: 'stopped' }) return NextResponse.json({ enabled: true, status: 'stopped' })
} catch (e) { } catch (e) {

View File

@ -2,7 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { featureFlags } from '@/lib/config' import { featureFlags } from '@/lib/config'
import { canUseIntranetPenetration } from '@/lib/license'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
async function ensureTunnelEnabled() { async function ensureTunnelEnabled() {
if (featureFlags.tunnelEnabled) return true if (featureFlags.tunnelEnabled) return true
@ -17,9 +19,7 @@ export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 }) if (!session) return NextResponse.json({ error: '未登录' }, { status: 401 })
const isAdmin = (session.user as any)?.role === 'ADMIN' if ((session.user as any)?.role !== 'ADMIN' || !(await isCurrentSuperAdmin(session))) {
const isSuperAdmin = (session.user as any)?.isSuperAdmin
if (!isAdmin || !isSuperAdmin) {
return NextResponse.json({ error: '仅最高权限账号可操作内网穿透' }, { status: 403 }) return NextResponse.json({ error: '仅最高权限账号可操作内网穿透' }, { status: 403 })
} }
@ -28,6 +28,10 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: '内网穿透未启用' }, { status: 501 }) return NextResponse.json({ error: '内网穿透未启用' }, { status: 501 })
} }
if (!(await canUseIntranetPenetration())) {
return NextResponse.json({ error: '当前许可证不包含内网穿透功能,请联系管理员处理许可证' }, { status: 403 })
}
// TODO: 调用隧道提供商停止逻辑 // TODO: 调用隧道提供商停止逻辑
return NextResponse.json({ message: '隧道已停止(占位)' }) return NextResponse.json({ message: '隧道已停止(占位)' })
} catch (e) { } catch (e) {

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite'
function parseDisplayImages(value: string | null): string[] { function parseDisplayImages(value: string | null): string[] {
if (!value) return [] if (!value) return []
@ -53,7 +54,7 @@ export async function GET(req: NextRequest) {
const materials = favorites const materials = favorites
.map((favorite) => favorite.material) .map((favorite) => favorite.material)
.filter((material): material is NonNullable<typeof material> => Boolean(material)) .filter((material): material is NonNullable<typeof material> => Boolean(material))
.map((material) => ({ .map((material) => rewriteMaterialStorageUrlsForClient({
...material, ...material,
displayImages: parseDisplayImages(material.displayImages as unknown as string | null), displayImages: parseDisplayImages(material.displayImages as unknown as string | null),
zipFileSize: material.zipFileSize.toString(), zipFileSize: material.zipFileSize.toString(),

View File

@ -12,6 +12,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { toMaterialAssetProxyUrl } from '@/lib/url-rewrite'
/** /**
* GET /api/user/history * GET /api/user/history
@ -65,7 +66,7 @@ export async function GET(request: NextRequest) {
material: { material: {
id: item.material.id, id: item.material.id,
title: item.material.productModel, title: item.material.productModel,
thumbnailUrl: item.material.previewUrl, thumbnailUrl: toMaterialAssetProxyUrl(item.material.previewUrl),
materialType: item.material.materialType, materialType: item.material.materialType,
materialKind: item.material.materialKind, materialKind: item.material.materialKind,
viewCount: item.material.downloadCount, viewCount: item.material.downloadCount,

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite'
function parseDisplayImages(value: string | null): string[] { function parseDisplayImages(value: string | null): string[] {
if (!value) return [] if (!value) return []
@ -53,7 +54,7 @@ export async function GET(req: NextRequest) {
const materials = likes const materials = likes
.map((like) => like.material) .map((like) => like.material)
.filter((material): material is NonNullable<typeof material> => Boolean(material)) .filter((material): material is NonNullable<typeof material> => Boolean(material))
.map((material) => ({ .map((material) => rewriteMaterialStorageUrlsForClient({
...material, ...material,
displayImages: parseDisplayImages(material.displayImages as unknown as string | null), displayImages: parseDisplayImages(material.displayImages as unknown as string | null),
zipFileSize: material.zipFileSize.toString(), zipFileSize: material.zipFileSize.toString(),

View File

@ -15,5 +15,13 @@ export async function register() {
} catch (error) { } catch (error) {
console.error('❌ 许可证服务初始化失败:', 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: { moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1', '^@/(.*)$': '<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: [ testMatch: [
'**/__tests__/**/*.test.ts', '**/__tests__/**/*.test.ts',
'**/__tests__/**/*.test.tsx', '**/__tests__/**/*.test.tsx',
@ -21,6 +33,9 @@ const customJestConfig = {
'app/api/**/*.{ts,tsx}', 'app/api/**/*.{ts,tsx}',
'!**/*.d.ts', '!**/*.d.ts',
'!**/node_modules/**', '!**/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 = { export const defaultLogos = {
// 后台默认使用内置静态资源;如需自定义,可通过环境变量覆盖 // 后台默认使用内置静态资源;如需自定义,可通过环境变量覆盖
// logo1:浅色 Logo,适合深色背景(导航栏) // 当前仓库不再内置公共 Logo 资源;若未配置则走文字占位
// logo2:深色 Logo,适合浅色背景(登录页) header: process.env.DEFAULT_HEADER_LOGO_URL || '',
header: process.env.DEFAULT_HEADER_LOGO_URL || '/logo/logo1.png', login: process.env.DEFAULT_LOGIN_LOGO_URL || '',
login: process.env.DEFAULT_LOGIN_LOGO_URL || '/logo/logo2.png',
} }
export const publicDefaults = { export const publicDefaults = {
// 前端默认 Logo,与后端 defaultLogos 保持一致,方便通过 env 统一覆盖 // 前端默认 Logo,与后端 defaultLogos 保持一致,方便通过 env 统一覆盖
header: process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '/logo/logo1.png', header: process.env.NEXT_PUBLIC_DEFAULT_HEADER_LOGO_URL || '',
login: process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '/logo/logo2.png', login: process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '',
intranetUrl: process.env.NEXT_PUBLIC_INTRANET_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 = export const prisma =
globalForPrisma.prisma || globalForPrisma.prisma ||
new PrismaClient({ new PrismaClient({
datasources: process.env.DATABASE_URL
? {
db: {
url: process.env.DATABASE_URL,
},
}
: undefined,
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
}) })

View File

@ -12,19 +12,16 @@
*/ */
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import {
BUILTIN_TEMPLATE_KEYS as BUILTIN_TEMPLATE_KEYS_SOURCE,
createBuiltinTemplate,
} from './upload-template-presets'
// ============================================================================ // ============================================================================
// 内置模板配置(单一数据源) // 内置模板配置(单一数据源)
// ============================================================================ // ============================================================================
export const BUILTIN_TEMPLATE_KEYS = [ export const BUILTIN_TEMPLATE_KEYS = BUILTIN_TEMPLATE_KEYS_SOURCE
'3dmax-material',
'psd-material',
'audio-material',
'video-material',
'model-material',
'other-material',
] as const
export type BuiltinTemplateKey = (typeof BUILTIN_TEMPLATE_KEYS)[number] export type BuiltinTemplateKey = (typeof BUILTIN_TEMPLATE_KEYS)[number]
@ -46,76 +43,48 @@ export const BUILTIN_TEMPLATE_CONFIG: Record<
addToCategory?: boolean addToCategory?: boolean
}> }>
} }
> = { > = Object.fromEntries(
'3dmax-material': { BUILTIN_TEMPLATE_KEYS.map((key) => {
name: '3DMAX 素材模板', const template = createBuiltinTemplate(key)
description: '适用于 3D 模型类素材', return [
materialType: '3DMAX', key,
materialKind: 'IMAGE', {
fields: [ name: template.name,
{ key: 'productModel', label: 'SPU型号', type: 'text', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true }, description: template.description,
{ key: 'categoryName', label: '使用店铺', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, materialType: template.materialType,
{ key: 'sceneName', label: '场景类别', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, materialKind: template.materialKind,
{ key: 'renderer', label: '渲染器', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true, addToCategory: true }, fields: template.fields.map((field) => ({
], key: field.key,
}, label: field.label,
'psd-material': { type: field.type,
name: 'PSD 素材模板', required: field.required,
description: '针对平面设计 PSD 素材', layoutWidth: field.layoutWidth || 'full',
materialType: 'PSD', visibleOnHomeFilter: field.visibleOnHomeFilter,
materialKind: 'IMAGE', isSearchable: field.isSearchable,
fields: [ addToCategory: field.addToCategory,
{ 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 },
],
}, },
]
})
) 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 ALLOWED_ORIGINS = (() => {
const fromEnv = process.env.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'] const defaults = ['http://localhost:3000', 'http://localhost:4320']
return Array.from(new Set([...defaults, ...list])) return Array.from(new Set([...defaults, ...list]))
})() })()
// 不需要许可证检查的路径 function normalizeOrigin(value?: string | null) {
const LICENSE_EXEMPT_PATHS = [ const trimmed = (value || '').trim()
'/activate', if (!trimmed) return null
'/api/license',
'/api/auth', try {
'/login', const url = new URL(trimmed)
'/_next', return url.origin
'/favicon.ico', } 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) { export async function middleware(request: NextRequest) {
const { pathname, origin } = request.nextUrl const { pathname, origin } = request.nextUrl
const requestOrigin = request.headers.get('origin') const requestOrigin = request.headers.get('origin')
const corsOrigin = getAllowedCorsOrigin(requestOrigin, origin)
// 检查是否需要许可证验证 // 许可证检查移至后端 API 进行,不再在 middleware 中做快速检查
const needsLicenseCheck = !LICENSE_EXEMPT_PATHS.some(path => pathname.startsWith(path)) // 原因:1) Cookie 可伪造 2) 对弱网用户友好 3) 真正的安全验证在后端
// 用户首次访问时,前端会调用 /api/license/check 检查许可证状态
// 许可证检查(通过 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路由的CORS // 处理API路由的CORS
if (pathname.startsWith('/api/')) { if (pathname.startsWith('/api/')) {
// 处理OPTIONS预检请求 // 处理OPTIONS预检请求
if (request.method === 'OPTIONS') { if (request.method === 'OPTIONS') {
if (!corsOrigin) {
return new NextResponse(null, { status: 403 })
}
return new NextResponse(null, { return new NextResponse(null, {
status: 204, status: 204,
headers: { headers: {
'Access-Control-Allow-Credentials': 'true', '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-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-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', 'Access-Control-Max-Age': '86400',
'Vary': 'Origin',
}, },
}) })
} }
// 为所有API响应添加CORS头 // 为所有API响应添加CORS头
const response = NextResponse.next() const response = NextResponse.next()
if (corsOrigin) {
response.headers.set('Access-Control-Allow-Credentials', 'true') response.headers.set('Access-Control-Allow-Credentials', 'true')
response.headers.set('Access-Control-Allow-Origin', requestOrigin || origin) 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-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('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 return response
} }
@ -74,8 +93,7 @@ export async function middleware(request: NextRequest) {
} }
// 管理员页面需要管理员权限 // 管理员页面需要管理员权限
const requiresAdmin = const requiresAdmin = pathname.startsWith('/admin')
pathname.startsWith('/admin') && !pathname.startsWith('/admin/monitor')
if (requiresAdmin) { if (requiresAdmin) {
if (token.role !== 'ADMIN') { if (token.role !== 'ADMIN') {
const url = new URL('/', request.url) const url = new URL('/', request.url)
@ -88,6 +106,16 @@ export async function middleware(request: NextRequest) {
} }
export const config = { 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: { images: {
remotePatterns: (() => { remotePatterns: (() => {
const hosts = (process.env.IMAGE_REMOTE_HOSTS || '').split(',').map(s => s.trim()).filter(Boolean) 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 all = Array.from(new Set([...defaults, ...hosts]))
const patterns = [] const patterns = []
for (const h of all) { for (const h of all) {

View File

@ -24,6 +24,13 @@
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "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"
]
} }