收紧主应用接口权限与跨域边界
This commit is contained in:
parent
c649b939c4
commit
6a5077312e
129
__tests__/middleware-cors.test.ts
Normal file
129
__tests__/middleware-cors.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
107
app/api/admin/materials/export/__tests__/route.test.ts
Normal file
107
app/api/admin/materials/export/__tests__/route.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
90
app/api/admin/materials/export/route.ts
Normal file
90
app/api/admin/materials/export/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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、分类、场景等信息`);
|
||||||
|
|||||||
138
app/api/tunnel/__tests__/route.test.ts
Normal file
138
app/api/tunnel/__tests__/route.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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: '隧道启动成功(占位)',
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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/**',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 || '',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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'],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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 },
|
]
|
||||||
],
|
})
|
||||||
},
|
) as Record<
|
||||||
'audio-material': {
|
BuiltinTemplateKey,
|
||||||
name: '音频素材模板',
|
{
|
||||||
description: '适用于音频文件素材,支持 MP3、WAV、FLAC、AAC 等格式',
|
name: string
|
||||||
materialType: '音频',
|
description: string
|
||||||
materialKind: 'AUDIO',
|
materialType: string
|
||||||
fields: [
|
materialKind: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'
|
||||||
{ key: 'productModel', label: '音频名称', type: 'text', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true },
|
fields: Array<{
|
||||||
{ key: 'audioCategory', label: '音频分类', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true },
|
key: string
|
||||||
{ key: 'audioScene', label: '使用场景', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true },
|
label: string
|
||||||
{ key: 'authorName', label: '作者', type: 'text', required: false, layoutWidth: 'half', visibleOnHomeFilter: false, isSearchable: true },
|
type: string
|
||||||
],
|
required: boolean
|
||||||
},
|
layoutWidth: string
|
||||||
'video-material': {
|
visibleOnHomeFilter: boolean
|
||||||
name: '视频素材模板',
|
isSearchable: boolean
|
||||||
description: '适用于视频文件素材',
|
addToCategory?: boolean
|
||||||
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 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 数据完整性检查结果类型
|
// 数据完整性检查结果类型
|
||||||
|
|||||||
@ -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()
|
||||||
response.headers.set('Access-Control-Allow-Credentials', 'true')
|
if (corsOrigin) {
|
||||||
response.headers.set('Access-Control-Allow-Origin', requestOrigin || origin)
|
response.headers.set('Access-Control-Allow-Credentials', 'true')
|
||||||
response.headers.set('Access-Control-Allow-Methods', 'GET,DELETE,PATCH,POST,PUT,OPTIONS')
|
response.headers.set('Access-Control-Allow-Origin', corsOrigin)
|
||||||
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-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
|
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)$).*)',
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user