diff --git a/README.md b/README.md index f2eaef4..8b1d3fc 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ docker compose -f docker-compose.prod.yml up -d - `docs/deployment/多品牌 NAS 与 Linux 交付差异清单.md` - `docs/初次部署与出厂设置指南.md` - `docs/许可证服务接入说明.md` +- `docs/商业化地基8项收口记录_20260530.md` - `docs/参考源码功能吸收整理_20260513.md` - `scripts/README.md` diff --git a/app/admin/_components/AppearanceSettingsPanel.tsx b/app/admin/_components/AppearanceSettingsPanel.tsx index 95eb758..b967197 100644 --- a/app/admin/_components/AppearanceSettingsPanel.tsx +++ b/app/admin/_components/AppearanceSettingsPanel.tsx @@ -56,7 +56,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd const [s3PublicUrl, setS3PublicUrl] = useState('') const [enableUserActivation, setEnableUserActivation] = useState(false) - const [enableRegistration, setEnableRegistration] = useState(true) + const [enableRegistration, setEnableRegistration] = useState(false) const [enableAiImageEdit, setEnableAiImageEdit] = useState(false) const [enableTunnelService, setEnableTunnelService] = useState(false) const [enablePublicShare, setEnablePublicShare] = useState(true) @@ -110,7 +110,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd setS3Region(data.s3Region || 'us-east-1') setS3PublicUrl(data.s3PublicUrl || '') setEnableUserActivation(data.enableUserActivation ?? false) - setEnableRegistration(data.enableRegistration ?? true) + setEnableRegistration(data.enableRegistration ?? false) setEnableAiImageEdit(data.enableAiImageEdit ?? false) setEnableTunnelService(data.enableTunnelService ?? false) setEnablePublicShare(data.enablePublicShare ?? true) @@ -610,7 +610,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd desc: '关闭后仅能通过邀请进入系统。', value: enableRegistration, onChange: setEnableRegistration, - disabled: false, + disabled: !sessionUser?.isSuperAdmin, }, { title: '首页删除按钮仅管理员可见', diff --git a/app/admin/_components/TemplateManager.tsx b/app/admin/_components/TemplateManager.tsx index a55d7f8..6481146 100644 --- a/app/admin/_components/TemplateManager.tsx +++ b/app/admin/_components/TemplateManager.tsx @@ -491,7 +491,13 @@ const requestJson = async (input: RequestInfo, init?: RequestInit): Promise< return res.json() } -export default function TemplateManager({ showToast }: { showToast: ToastFn }) { +export default function TemplateManager({ + showToast, + currentIsSuperAdmin = false, +}: { + showToast: ToastFn + currentIsSuperAdmin?: boolean +}) { const styles = useThemeStyles() const { confirm } = useDialog() const { isFree, isFeatureEnabled } = useLicenseStatus() @@ -519,6 +525,14 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { const [categoryFieldKeys, setCategoryFieldKeys] = useState(Array.from(GLOBAL_CATEGORY_FIELDS)) const ALL_BUILTIN_KEYS = useMemo(() => BUILTIN_TEMPLATES.map(t => t.key), []) // 所有预制模板key const canUseCustomTemplates = !isFree && isFeatureEnabled('customTemplates') + const canManageCustomTemplates = currentIsSuperAdmin && canUseCustomTemplates + const showCustomTemplateBlocked = () => { + if (!currentIsSuperAdmin) { + showToast('只有最高权限账号可以创建、编辑、复制、删除和迁移自定义模板', 'error') + return + } + setShowUpgradeModal(true) + } // 素材迁移弹窗状态 const [migrationDialogOpen, setMigrationDialogOpen] = useState(false) @@ -753,8 +767,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { }, [fetchTemplates]) const openCreate = (presetKey?: string) => { - if (!canUseCustomTemplates) { - setShowUpgradeModal(true) + if (!canManageCustomTemplates) { + showCustomTemplateBlocked() return } const preset = @@ -795,8 +809,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { } const openEdit = (tpl: UploadTemplateDto) => { - if (!canUseCustomTemplates) { - setShowUpgradeModal(true) + if (!canManageCustomTemplates) { + showCustomTemplateBlocked() return } setForm({ @@ -839,8 +853,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { } const openDuplicate = (tpl: UploadTemplateDto) => { - if (!canUseCustomTemplates) { - setShowUpgradeModal(true) + if (!canManageCustomTemplates) { + showCustomTemplateBlocked() return } setForm({ @@ -1156,8 +1170,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { }, []) const createTemplateFromPreset = async (presetKey: string, status: 'draft' | 'active' = 'draft') => { - if (!canUseCustomTemplates) { - setShowUpgradeModal(true) + if (!canManageCustomTemplates) { + showCustomTemplateBlocked() return } const preset = BUILTIN_TEMPLATES.find((item) => item.key === presetKey) @@ -1858,8 +1872,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { presets={BUILTIN_TEMPLATES.filter(p => enabledBuiltinTemplates.has(p.key))} onPreview={openPresetPreview} onQuickCreate={(presetKey) => createTemplateFromPreset(presetKey, 'draft')} - canQuickCreate={canUseCustomTemplates} - onRequestUpgrade={() => setShowUpgradeModal(true)} + canQuickCreate={canManageCustomTemplates} + onRequestUpgrade={showCustomTemplateBlocked} onToggleEnabled={toggleBuiltinTemplateEnabled} onEnableAll={enableAllBuiltinTemplates} enabledTemplates={enabledBuiltinTemplates} @@ -1872,8 +1886,8 @@ export default function TemplateManager({ showToast }: { showToast: ToastFn }) { templates={displayTemplates} filteredTemplates={filteredTemplates} deletingId={deletingId} - canManageCustomTemplates={canUseCustomTemplates} - onRequestUpgrade={() => setShowUpgradeModal(true)} + canManageCustomTemplates={canManageCustomTemplates} + onRequestUpgrade={showCustomTemplateBlocked} onCreate={openCreate} onPreview={openCustomPreview} onActivate={handleActivate} diff --git a/app/admin/monitor/page.tsx b/app/admin/monitor/page.tsx index 3d67d3d..d1004ed 100644 --- a/app/admin/monitor/page.tsx +++ b/app/admin/monitor/page.tsx @@ -19,7 +19,7 @@ export default function MonitorPage() { return } - if (status === 'authenticated' && (session.user as any)?.role !== 'ADMIN') { + if (status === 'authenticated' && !(session.user as any)?.isSuperAdmin) { router.push('/') } }, [status, session, router]) diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 467590f..6311893 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -349,7 +349,7 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), []) {/* 模板管理 */} {activeTab === 'templates' && ( - + )} diff --git a/app/api/admin/__tests__/destructive-actions-superadmin.test.ts b/app/api/admin/__tests__/destructive-actions-superadmin.test.ts index 943f98e..8925c7d 100644 --- a/app/api/admin/__tests__/destructive-actions-superadmin.test.ts +++ b/app/api/admin/__tests__/destructive-actions-superadmin.test.ts @@ -93,7 +93,10 @@ describe('admin destructive actions require super admin', () => { ]) const response = await cleanupDatabase( - new NextRequest('http://localhost/api/admin/cleanup-database', { method: 'POST' }) + new NextRequest('http://localhost/api/admin/cleanup-database', { + method: 'POST', + body: JSON.stringify({ confirmPhrase: '清理全部素材' }), + }) ) const data = await response.json() @@ -102,6 +105,27 @@ describe('admin destructive actions require super admin', () => { expect(prisma.$executeRawUnsafe).toHaveBeenCalled() }) + it('最高权限账号清理数据库时也必须输入确认短语', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) + + const response = await cleanupDatabase( + new NextRequest('http://localhost/api/admin/cleanup-database', { + method: 'POST', + body: JSON.stringify({ confirmPhrase: '删除' }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error).toContain('确认短语') + expect(data.requiredConfirmPhrase).toBe('清理全部素材') + expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled() + expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled() + }) + it('普通管理员不能重置排行榜数据', async () => { getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false }, diff --git a/app/api/admin/__tests__/system-status-auth.test.ts b/app/api/admin/__tests__/system-status-auth.test.ts index a2e3b5f..e5cc6b4 100644 --- a/app/api/admin/__tests__/system-status-auth.test.ts +++ b/app/api/admin/__tests__/system-status-auth.test.ts @@ -27,8 +27,13 @@ jest.mock('@/lib/prisma', () => ({ }, })) +jest.mock('@/lib/admin-permissions', () => ({ + isCurrentSuperAdmin: jest.fn(), +})) + const { getServerSession } = require('next-auth') const { prisma } = require('@/lib/prisma') +const { isCurrentSuperAdmin } = require('@/lib/admin-permissions') import { GET } from '../system-status/route' @@ -49,6 +54,7 @@ describe('admin system status auth', () => { expect(response.status).toBe(401) expect(data.error).toContain('未登录') + expect(isCurrentSuperAdmin).not.toHaveBeenCalled() expect(prisma.user.count).not.toHaveBeenCalled() expect(prisma.material.count).not.toHaveBeenCalled() }) @@ -57,20 +63,37 @@ describe('admin system status auth', () => { getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' }, }) + isCurrentSuperAdmin.mockResolvedValue(false) const response = await GET(createRequest()) const data = await response.json() expect(response.status).toBe(403) - expect(data.error).toContain('管理员') + expect(data.error).toContain('最高权限') expect(prisma.user.count).not.toHaveBeenCalled() expect(prisma.material.count).not.toHaveBeenCalled() }) - it('管理员可以读取系统监控数据', async () => { + it('普通管理员不能读取系统监控数据', async () => { getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' }, }) + isCurrentSuperAdmin.mockResolvedValue(false) + + const response = await GET(createRequest()) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(prisma.user.count).not.toHaveBeenCalled() + expect(prisma.material.count).not.toHaveBeenCalled() + }) + + it('最高权限账号可以读取系统监控数据', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true }, + }) + isCurrentSuperAdmin.mockResolvedValue(true) prisma.user.aggregate.mockResolvedValue({ _count: { id: 1 } }) prisma.material.aggregate.mockResolvedValue({ _count: { id: 0 } }) @@ -98,4 +121,21 @@ describe('admin system status auth', () => { expect(data.overview.totalUsers).toBe(1) expect(data.status.database).toBe('healthy') }) + + it('最高权限账号读取时数据库查询失败会标记为 unhealthy', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true }, + }) + isCurrentSuperAdmin.mockResolvedValue(true) + + prisma.user.aggregate.mockRejectedValueOnce(new Error('database connection failed')) + prisma.$queryRaw.mockRejectedValueOnce(new Error('trend query failed')) + + const response = await GET(createRequest()) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.overview.totalUsers).toBe(0) + expect(data.status.database).toBe('unhealthy') + }) }) diff --git a/app/api/admin/cleanup-database/route.ts b/app/api/admin/cleanup-database/route.ts index 806cacb..6495a2b 100644 --- a/app/api/admin/cleanup-database/route.ts +++ b/app/api/admin/cleanup-database/route.ts @@ -21,6 +21,17 @@ export async function POST(req: NextRequest) { ) } + const body = await req.json().catch(() => ({})) + if (body?.confirmPhrase !== '清理全部素材') { + return NextResponse.json( + { + error: '数据库清理是高风险操作,请输入确认短语后再执行', + requiredConfirmPhrase: '清理全部素材', + }, + { status: 400 } + ) + } + const results: Record = {} // 1. 清理过期的邀请码(已使用且过期超过30天) diff --git a/app/api/admin/system-status/route.ts b/app/api/admin/system-status/route.ts index 7216d63..9452404 100644 --- a/app/api/admin/system-status/route.ts +++ b/app/api/admin/system-status/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' export const dynamic = 'force-dynamic' @@ -26,9 +27,9 @@ export async function GET(req: NextRequest) { ) } - if ((session.user as any)?.role !== 'ADMIN') { + if (!(await isCurrentSuperAdmin(session))) { return NextResponse.json( - { error: '需要管理员权限' }, + { error: '需要最高权限账号' }, { status: 403 } ) } @@ -46,6 +47,7 @@ export async function GET(req: NextRequest) { let activeUsers: number let departmentStats: Array<{ department: string | null; _count: { id: number } }> let roleDistribution: Array<{ role: string; isSuperAdmin: boolean | null; _count: { id: number } }> + let databaseHealthy = true try { [ @@ -121,6 +123,7 @@ export async function GET(req: NextRequest) { ]) } catch (dbError) { console.error('[SystemStatus] Database query error:', dbError) + databaseHealthy = false // 如果数据库查询失败,返回默认值 userStats = { _count: { id: 0 } } materialStats = { _count: { id: 0 } } @@ -225,6 +228,7 @@ export async function GET(req: NextRequest) { materialTrendDaily = [] materialTrendMonthly = [] userGrowthTrend = [] + databaseHealthy = false } const statusData = { @@ -291,7 +295,7 @@ export async function GET(req: NextRequest) { // 系统状态 status: { - database: 'healthy', // 可以扩展为实际数据库连接检查 + database: databaseHealthy ? 'healthy' : 'unhealthy', timestamp: new Date().toISOString(), uptime: process.uptime() } diff --git a/app/api/admin/video-transcode-stats/__tests__/route.test.ts b/app/api/admin/video-transcode-stats/__tests__/route.test.ts new file mode 100644 index 0000000..e56126e --- /dev/null +++ b/app/api/admin/video-transcode-stats/__tests__/route.test.ts @@ -0,0 +1,96 @@ +import { NextRequest } from 'next/server' + +jest.mock('@/lib/auth', () => ({ + authOptions: {}, +})) + +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})) + +jest.mock('@/lib/admin-permissions', () => ({ + isCurrentSuperAdmin: jest.fn(), +})) + +jest.mock('@/lib/video-transcode-queue', () => ({ + transcodeQueue: { + getStatus: jest.fn(() => ({ + queueLength: 0, + processingCount: 0, + maxConcurrent: 2, + processingIds: [], + queuedIds: [], + })), + }, +})) + +jest.mock('@/lib/prisma', () => ({ + prisma: { + material: { + count: jest.fn(), + findMany: jest.fn(), + }, + videoTranscodeJob: { + count: jest.fn(), + }, + }, +})) + +const { getServerSession } = require('next-auth') +const { isCurrentSuperAdmin } = require('@/lib/admin-permissions') +const { prisma } = require('@/lib/prisma') + +import { GET } from '../route' + +function createRequest() { + return new NextRequest('http://localhost/api/admin/video-transcode-stats') +} + +describe('admin video transcode stats auth', () => { + 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(isCurrentSuperAdmin).not.toHaveBeenCalled() + expect(prisma.material.count).not.toHaveBeenCalled() + }) + + it('普通管理员不能读取转码统计', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'admin-1', role: 'ADMIN' }, + }) + isCurrentSuperAdmin.mockResolvedValue(false) + + const response = await GET(createRequest()) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(prisma.material.count).not.toHaveBeenCalled() + }) + + it('最高权限账号可以读取转码统计', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true }, + }) + isCurrentSuperAdmin.mockResolvedValue(true) + prisma.material.count.mockResolvedValue(0) + prisma.videoTranscodeJob.count.mockResolvedValue(0) + prisma.material.findMany.mockResolvedValue([]) + + const response = await GET(createRequest()) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.statistics.total).toBe(0) + expect(data.jobs.failed).toBe(0) + }) +}) diff --git a/app/api/admin/video-transcode-stats/route.ts b/app/api/admin/video-transcode-stats/route.ts index 0af6f0f..ff23baa 100644 --- a/app/api/admin/video-transcode-stats/route.ts +++ b/app/api/admin/video-transcode-stats/route.ts @@ -9,7 +9,7 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { transcodeQueue } from '@/lib/video-transcode-queue' -import { isCurrentAdmin } from '@/lib/admin-permissions' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' export const dynamic = 'force-dynamic' @@ -27,9 +27,9 @@ export async function GET(req: NextRequest) { ) } - if (!(await isCurrentAdmin(session))) { + if (!(await isCurrentSuperAdmin(session))) { return NextResponse.json( - { error: '需要管理员权限' }, + { error: '需要最高权限账号' }, { status: 403 } ) } diff --git a/app/api/ai/edit/__tests__/route.test.ts b/app/api/ai/edit/__tests__/route.test.ts index 00cc9fc..356510f 100644 --- a/app/api/ai/edit/__tests__/route.test.ts +++ b/app/api/ai/edit/__tests__/route.test.ts @@ -161,14 +161,11 @@ describe('AI 改图接口权限边界', () => { expect(data.error).toBe('您没有权限操作此素材') }) - test('素材本人可以创建占位任务', async () => { + test('素材本人也不能创建占位任务', async () => { const response = await POST(buildRequest({ params: { prompt: '调整亮度' } })) const data = await response.json() - expect(response.status).toBe(200) - expect(data.status).toBe('queued') - expect(data.materialId).toBe('material-1') - expect(data.operation).toBe('remove-background') - expect(data.params).toEqual({ prompt: '调整亮度' }) + expect(response.status).toBe(501) + expect(data.error).toContain('尚未接入真实任务引擎') }) }) diff --git a/app/api/ai/edit/route.ts b/app/api/ai/edit/route.ts index eebd727..15ef5cd 100644 --- a/app/api/ai/edit/route.ts +++ b/app/api/ai/edit/route.ts @@ -56,16 +56,10 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: '您没有权限操作此素材' }, { status: 403 }) } - // TODO: 接入外部AI服务(基于你提供的API),并以任务形式异步执行 - - return NextResponse.json({ - message: 'AI改图任务创建成功(占位)', - taskId: 'mock_task_id', - materialId, - operation, - params: params || {}, - status: 'queued', - }) + return NextResponse.json( + { error: 'AI改图服务尚未接入真实任务引擎,不能创建占位任务' }, + { status: 501 } + ) } catch (error) { console.error('AI Edit Error:', error) return NextResponse.json({ error: '服务异常' }, { status: 500 }) diff --git a/app/api/auth/register/__tests__/route.test.ts b/app/api/auth/register/__tests__/route.test.ts index f40cc0a..e0af914 100644 --- a/app/api/auth/register/__tests__/route.test.ts +++ b/app/api/auth/register/__tests__/route.test.ts @@ -98,6 +98,18 @@ describe('POST /api/auth/register', () => { expect(prisma.$transaction).not.toHaveBeenCalled() }) + test('缺少系统设置时默认不开放公开注册', async () => { + prisma.systemSettings.findFirst.mockResolvedValue(null) + + const response = await POST(createRequest({ password: 'Flnxi-2026-Safe' })) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toBe('注册功能已关闭,请使用邀请链接注册') + expect(prisma.invitation.findUnique).not.toHaveBeenCalled() + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + test('关闭公开注册时,有效邀请链接仍可注册', async () => { prisma.systemSettings.findFirst.mockResolvedValue({ enableRegistration: false, diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 0df3fe3..e3e7228 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -20,7 +20,7 @@ export async function POST(req: NextRequest) { // 检查注册功能是否开启。关闭公开注册时,仍允许管理员发出的邀请链接注册。 const settings = await prisma.systemSettings.findFirst() - const registrationEnabled = settings?.enableRegistration !== false + const registrationEnabled = settings?.enableRegistration === true let validInvitation: { code: string } | null = null diff --git a/app/api/materials/__tests__/template-fields-routes.test.ts b/app/api/materials/__tests__/template-fields-routes.test.ts index 37901b0..928d8ef 100644 --- a/app/api/materials/__tests__/template-fields-routes.test.ts +++ b/app/api/materials/__tests__/template-fields-routes.test.ts @@ -64,6 +64,7 @@ const { uploadToS3, uploadStreamToS3 } = require('@/lib/s3') const { parseAudioMetadata, parseAudioMetadataFromFile, isAudioFile } = require('@/lib/audio-processor') const { canUploadMaterial } = require('@/lib/license') const { validateTemplateId, getTemplateWithFields } = require('@/lib/template-validator') +const { processImage, getProcessedFileName, sliceImage } = require('@/lib/image-processor') import { POST as uploadMaterial } from '../upload/route' import { PUT as updateMaterial } from '../update/route' @@ -242,6 +243,7 @@ describe('素材模板字段链路测试', () => { createMaterialLog.mockResolvedValue(undefined) + getProcessedFileName.mockImplementation((name: string) => name) uploadToS3.mockResolvedValue('s3://bucket/buffer-upload') uploadStreamToS3.mockResolvedValue('s3://bucket/stream-upload') parseAudioMetadata.mockResolvedValue(null) @@ -352,6 +354,88 @@ describe('素材模板字段链路测试', () => { expect(prisma.material.create).not.toHaveBeenCalled() }) + test('预览图处理失败时不向前端暴露底层错误细节', async () => { + processImage.mockRejectedValueOnce(new Error('sharp failed at /private/storage/source.png')) + + const response = await uploadMaterial( + buildUploadRequest({ + materialType: '3DMAX', + categoryName: '空间', + sceneName: '客厅', + previewImage: createMockFile('preview.png', 'image/png', 'preview-data'), + zipFile: createMockFile('model.zip', 'application/zip', 'zip-data'), + templateFields: JSON.stringify({ + productModel: '稳定材质', + customCode: 'CODE-PREVIEW', + }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data.error).toBe('预览图处理失败,请检查图片格式后重试') + expect(data.error).not.toContain('sharp') + expect(data.details).toBeUndefined() + expect(data.errorType).toBeUndefined() + expect(data.errorStack).toBeUndefined() + expect(prisma.material.create).not.toHaveBeenCalled() + }) + + test('PSD展示图处理失败时不向前端暴露底层错误细节', async () => { + sliceImage.mockRejectedValueOnce(new Error('sharp failed at /private/storage/display.png')) + + const response = await uploadMaterial( + buildUploadRequest({ + materialType: 'PSD', + categoryName: '空间', + sceneName: '客厅', + displayImage: createMockFile('display.png', 'image/png', 'display-data'), + zipFile: createMockFile('source.psd', 'application/octet-stream', 'psd-data'), + templateFields: JSON.stringify({ + productModel: '稳定材质', + customCode: 'CODE-PSD', + }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data.error).toBe('PSD展示图片处理失败,请检查图片格式后重试') + expect(data.error).not.toContain('sharp') + expect(data.details).toBeUndefined() + expect(data.errorType).toBeUndefined() + expect(data.errorStack).toBeUndefined() + expect(prisma.material.create).not.toHaveBeenCalled() + }) + + test('原始文件保存失败时不向前端暴露底层错误细节', async () => { + processImage.mockResolvedValueOnce(Buffer.from('processed-preview')) + uploadStreamToS3.mockRejectedValueOnce(new Error('s3 secret path /private/bucket/source.zip')) + + const response = await uploadMaterial( + buildUploadRequest({ + materialType: '3DMAX', + categoryName: '空间', + sceneName: '客厅', + previewImage: createMockFile('preview.png', 'image/png', 'preview-data'), + zipFile: createMockFile('model.zip', 'application/zip', 'zip-data'), + templateFields: JSON.stringify({ + productModel: '稳定材质', + customCode: 'CODE-FILE', + }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data.error).toBe('文件保存失败,请检查存储配置后重试') + expect(data.error).not.toContain('private') + expect(data.details).toBeUndefined() + expect(data.errorType).toBeUndefined() + expect(data.errorStack).toBeUndefined() + expect(prisma.material.create).not.toHaveBeenCalled() + }) + test('非音频原始素材上传应走流式上传,避免二次整文件 Buffer', async () => { const zipFile = createMockFile('model.zip', 'application/zip', 'zip-data') diff --git a/app/api/materials/download/__tests__/route.test.ts b/app/api/materials/download/__tests__/route.test.ts index 216acfb..b7ce2b4 100644 --- a/app/api/materials/download/__tests__/route.test.ts +++ b/app/api/materials/download/__tests__/route.test.ts @@ -18,7 +18,7 @@ jest.mock('@/lib/prisma', () => ({ findUnique: jest.fn(), }, material: { - findUnique: jest.fn(), + findFirst: jest.fn(), update: jest.fn(), }, $executeRaw: jest.fn(), @@ -52,7 +52,7 @@ describe('GET /api/materials/download', () => { }, }) - prisma.material.findUnique.mockResolvedValue({ + prisma.material.findFirst.mockResolvedValue({ id: 'material-1', userId: 'owner-1', productModel: 'MODEL', @@ -140,7 +140,7 @@ describe('GET /api/materials/download', () => { email: 'user@example.com', username: 'user', }) - prisma.material.findUnique.mockResolvedValue({ + prisma.material.findFirst.mockResolvedValue({ id: 'material-1', userId: 'owner-1', productModel: 'MODEL', @@ -160,4 +160,21 @@ describe('GET /api/materials/download', () => { expect(prisma.$executeRaw).not.toHaveBeenCalled() expect(prisma.material.update).not.toHaveBeenCalled() }) + + test('软删除素材不能下载', async () => { + prisma.material.findFirst.mockResolvedValue(null) + + const response = await GET(new NextRequest('http://localhost/api/materials/download?id=material-1')) + const data = await response.json() + + expect(response.status).toBe(404) + expect(data.error).toContain('素材不存在') + expect(prisma.material.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'material-1', deletedAt: null }, + }) + ) + expect(prisma.$executeRaw).not.toHaveBeenCalled() + expect(prisma.material.update).not.toHaveBeenCalled() + }) }) diff --git a/app/api/materials/download/route.ts b/app/api/materials/download/route.ts index 7117c25..d937ac8 100644 --- a/app/api/materials/download/route.ts +++ b/app/api/materials/download/route.ts @@ -66,8 +66,8 @@ export async function GET(req: NextRequest) { ) } - const material = await prisma.material.findUnique({ - where: { id }, + const material = await prisma.material.findFirst({ + where: { id, deletedAt: null }, select: { id: true, userId: true, @@ -83,10 +83,7 @@ export async function GET(req: NextRequest) { if (!material) { console.error(`[Download] Material ${id} not found`) - return NextResponse.json( - { error: '素材不存在' }, - { status: 404 } - ) + return NextResponse.json({ error: '素材不存在' }, { status: 404 }) } console.log(`[Download] Material ${id} (${material.productModel}) found. Owner: ${material.userId}, Requester: ${userId}`) diff --git a/app/api/materials/update/route.ts b/app/api/materials/update/route.ts index 269f6c5..4c9d5e3 100644 --- a/app/api/materials/update/route.ts +++ b/app/api/materials/update/route.ts @@ -113,16 +113,11 @@ export async function PUT(req: NextRequest) { const userId = actor!.id let previewUrl = existingMaterial.previewUrl + let uploadedPreviewUrl: string | null = null + const oldPreviewUrl = existingMaterial.previewUrl // If new preview image is uploaded if (previewImage && previewImage.size > 0) { - // Delete old preview image from S3 - try { - await deleteFromS3(existingMaterial.previewUrl) - } catch (error) { - console.error('Error deleting old preview:', error) - } - // 处理预览图:压缩到300KB以内,转换为webP,缩放到600px const originalBuffer = Buffer.from(await previewImage.arrayBuffer()) const processedBuffer = await processImage(originalBuffer, { @@ -134,11 +129,12 @@ export async function PUT(req: NextRequest) { // Upload processed preview image to S3 const processedFileName = getProcessedFileName(previewImage.name) const previewFileName = `preview-${Date.now()}-${processedFileName}` - previewUrl = await uploadToS3( + uploadedPreviewUrl = await uploadToS3( processedBuffer, previewFileName, 'image/webp' ) + previewUrl = uploadedPreviewUrl } // 构建更新数据,根据素材类型决定哪些字段需要更新 @@ -234,17 +230,37 @@ export async function PUT(req: NextRequest) { } // Update material record - const material = await prisma.material.update({ - where: { id }, - data: updateData, - include: { - user: { - select: { - username: true, + let material + try { + material = await prisma.material.update({ + where: { id }, + data: updateData, + include: { + user: { + select: { + username: true, + }, }, }, - }, - }) + }) + } catch (updateError) { + if (uploadedPreviewUrl) { + try { + await deleteFromS3(uploadedPreviewUrl) + } catch (cleanupError) { + console.error('Error deleting newly uploaded preview after update failure:', cleanupError) + } + } + throw updateError + } + + if (uploadedPreviewUrl && oldPreviewUrl && oldPreviewUrl !== uploadedPreviewUrl) { + try { + await deleteFromS3(oldPreviewUrl) + } catch (error) { + console.error('Error deleting old preview after update:', error) + } + } // 记录变更日志 const changedFields: Record = {} diff --git a/app/api/materials/upload/route.ts b/app/api/materials/upload/route.ts index dd0f5f6..d4ce541 100644 --- a/app/api/materials/upload/route.ts +++ b/app/api/materials/upload/route.ts @@ -253,6 +253,7 @@ async function parseUploadForm(req: NextRequest): Promise { export async function POST(req: NextRequest) { let parsedUploadForm: ParsedUploadForm | null = null const uploadedArtifactUrls: string[] = [] + const isDevelopment = process.env.NODE_ENV === 'development' const trackUploadedArtifact = (url: string) => { if (url) { uploadedArtifactUrls.push(url) @@ -284,6 +285,19 @@ export async function POST(req: NextRequest) { await cleanupUploadedArtifacts() return response } + const createSafeErrorPayload = (message: string, error: unknown, includeStack = false) => { + const payload: Record = { error: message } + + if (isDevelopment && error instanceof Error) { + payload.details = error.message + payload.errorType = error.name + if (includeStack) { + payload.errorStack = error.stack + } + } + + return payload + } try { console.log('[Upload] Starting upload process...') @@ -600,11 +614,7 @@ export async function POST(req: NextRequest) { console.error('[Upload] Preview image error stack:', imageError.stack) } return await failWithCleanup(NextResponse.json( - { - error: `预览图处理失败:${imageError instanceof Error ? imageError.message : '未知错误'}`, - details: imageError instanceof Error ? imageError.stack : undefined, - errorType: imageError instanceof Error ? imageError.name : 'UnknownError' - }, + createSafeErrorPayload('预览图处理失败,请检查图片格式后重试', imageError, true), { status: 500 } )) } @@ -640,7 +650,7 @@ export async function POST(req: NextRequest) { } catch (displayImageError) { console.error('[Upload] Display image processing error:', displayImageError) return await failWithCleanup(NextResponse.json( - { error: `PSD展示图片处理失败:${displayImageError instanceof Error ? displayImageError.message : '未知错误'}` }, + createSafeErrorPayload('PSD展示图片处理失败,请检查图片格式后重试', displayImageError), { status: 500 } )) } @@ -706,7 +716,7 @@ export async function POST(req: NextRequest) { console.error('[Upload] File save error stack:', fileError.stack) } return await failWithCleanup(NextResponse.json( - { error: `文件保存失败:${fileError instanceof Error ? fileError.message : '未知错误'}` }, + createSafeErrorPayload('文件保存失败,请检查存储配置后重试', fileError), { status: 500 } )) } @@ -979,12 +989,15 @@ export async function POST(req: NextRequest) { // 提供更具体的数据库错误信息 const errorMsg = dbError.message.toLowerCase() + const debugDetails = isDevelopment ? dbError.message : undefined + const debugErrorType = isDevelopment ? dbError.name : undefined + if (errorMsg.includes('unique constraint') || errorMsg.includes('duplicate')) { return await failWithCleanup(NextResponse.json( { error: '数据已存在,请检查是否重复上传', - details: dbError.message, - errorType: dbError.name + details: debugDetails, + errorType: debugErrorType }, { status: 400 } )) @@ -1000,8 +1013,8 @@ export async function POST(req: NextRequest) { return await failWithCleanup(NextResponse.json( { error: detailMsg, - details: dbError.message, - errorType: dbError.name + details: debugDetails, + errorType: debugErrorType }, { status: 400 } )) @@ -1009,9 +1022,9 @@ export async function POST(req: NextRequest) { if (errorMsg.includes('required') || errorMsg.includes('not null')) { return await failWithCleanup(NextResponse.json( { - error: `必填字段缺失:${dbError.message}`, - details: dbError.message, - errorType: dbError.name + error: '必填字段缺失,请检查上传表单', + details: debugDetails, + errorType: debugErrorType }, { status: 400 } )) @@ -1020,8 +1033,8 @@ export async function POST(req: NextRequest) { return await failWithCleanup(NextResponse.json( { error: '字段值过长,请检查产品型号、分类、场景等字段长度', - details: dbError.message, - errorType: dbError.name + details: debugDetails, + errorType: debugErrorType }, { status: 400 } )) @@ -1030,8 +1043,8 @@ export async function POST(req: NextRequest) { return await failWithCleanup(NextResponse.json( { error: '数据格式不正确,请检查所有字段值', - details: dbError.message, - errorType: dbError.name + details: debugDetails, + errorType: debugErrorType }, { status: 400 } )) @@ -1041,9 +1054,9 @@ export async function POST(req: NextRequest) { return await failWithCleanup(NextResponse.json( { error: '数据库操作失败', - details: dbError.message, - errorType: dbError.name, - errorStack: process.env.NODE_ENV === 'development' ? dbError.stack : undefined + details: debugDetails, + errorType: debugErrorType, + errorStack: isDevelopment ? dbError.stack : undefined }, { status: 500 } )) @@ -1064,7 +1077,7 @@ export async function POST(req: NextRequest) { return await failWithCleanup(NextResponse.json( { error: '文件上传到存储失败,请稍后重试', - details: process.env.NODE_ENV === 'development' ? error.message : undefined + details: isDevelopment ? error.message : undefined }, { status: 500 } )) @@ -1073,7 +1086,7 @@ export async function POST(req: NextRequest) { return await failWithCleanup(NextResponse.json( { error: '图片处理失败,请检查图片格式是否正确', - details: process.env.NODE_ENV === 'development' ? error.message : undefined + details: isDevelopment ? error.message : undefined }, { status: 500 } )) @@ -1082,7 +1095,7 @@ export async function POST(req: NextRequest) { return await failWithCleanup(NextResponse.json( { error: '数据库操作失败,请稍后重试', - details: process.env.NODE_ENV === 'development' ? error.message : undefined + details: isDevelopment ? error.message : undefined }, { status: 500 } )) @@ -1090,14 +1103,16 @@ export async function POST(req: NextRequest) { } // Return more specific error message for debugging - const errorMessage = error instanceof Error ? error.message : '上传失败,请稍后再试' + const errorMessage = isDevelopment && error instanceof Error + ? error.message + : undefined - // 在生产环境也返回错误详情,帮助排查问题 + // 生产环境不返回底层错误细节,避免暴露存储路径、SQL 或依赖栈信息。 return await failWithCleanup(NextResponse.json( { error: '上传失败,请稍后再试', - details: errorMessage, // 始终返回错误详情,帮助排查问题 - errorType: error instanceof Error ? error.name : 'UnknownError' + details: errorMessage, + errorType: isDevelopment && error instanceof Error ? error.name : undefined }, { status: 500 } )) diff --git a/app/api/system-settings/__tests__/route.test.ts b/app/api/system-settings/__tests__/route.test.ts index 28276fc..d63a223 100644 --- a/app/api/system-settings/__tests__/route.test.ts +++ b/app/api/system-settings/__tests__/route.test.ts @@ -227,6 +227,22 @@ describe('GET /api/system-settings', () => { expect(prisma.systemSettings.update).not.toHaveBeenCalled() }) + it('GET 没有系统设置记录时后台兜底也默认关闭手动注册', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'admin-id', role: 'ADMIN', isSuperAdmin: false }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + prisma.systemSettings.findFirst.mockResolvedValue(null) + + const response = await GET() + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.enableRegistration).toBe(false) + expect(prisma.systemSettings.create).not.toHaveBeenCalled() + expect(prisma.systemSettings.update).not.toHaveBeenCalled() + }) + it('GET 只在响应中整理旧 Logo,不写回数据库', async () => { getServerSession.mockResolvedValue(null) prisma.systemSettings.findFirst.mockResolvedValue({ @@ -345,6 +361,35 @@ describe('PUT /api/system-settings 权限边界', () => { 'settings-1', ) }) + + it('首次创建系统设置时默认关闭手动注册', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) + prisma.systemSettings.findFirst.mockResolvedValue(null) + prisma.systemSettings.create.mockResolvedValue({ + ...mockSettings, + id: 'settings-new', + siteName: '首次配置', + enableRegistration: false, + }) + + const response = await PUT(new NextRequest('http://localhost/api/system-settings', { + method: 'PUT', + body: JSON.stringify({ + siteName: '首次配置', + }), + })) + + expect(response.status).toBe(200) + expect(prisma.systemSettings.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + siteName: '首次配置', + enableRegistration: false, + }), + }) + }) }) describe('POST /api/system-settings/upload-logo 权限边界', () => { diff --git a/app/api/system-settings/route.ts b/app/api/system-settings/route.ts index 0362ab1..97cbd9d 100644 --- a/app/api/system-settings/route.ts +++ b/app/api/system-settings/route.ts @@ -85,7 +85,7 @@ function buildPublicFallbackSettings(): SystemSettingsRow { s3PublicUrl: '', s3Region: 'us-east-1', s3SecretKey: '', - enableRegistration: true, + enableRegistration: false, enableUserActivation: false, enableAiImageEdit: false, enableTunnelService: false, @@ -261,7 +261,7 @@ export async function PUT(request: NextRequest) { siteName: siteName || 'FLNXI素材库', premiumRatio: sanitizedPremiumRatio ?? 10, enableUserActivation: enableUserActivation !== undefined ? enableUserActivation : false, - enableRegistration: enableRegistration !== undefined ? enableRegistration : true, + enableRegistration: enableRegistration !== undefined ? enableRegistration : false, enableAiImageEdit: enableAiImageEdit !== undefined ? enableAiImageEdit : featureFlags.aiEditEnabled, enableTunnelService: diff --git a/app/api/tunnel/__tests__/route.test.ts b/app/api/tunnel/__tests__/route.test.ts index da7be80..98299c1 100644 --- a/app/api/tunnel/__tests__/route.test.ts +++ b/app/api/tunnel/__tests__/route.test.ts @@ -127,12 +127,31 @@ describe('内网穿透 API 许可证边界', () => { expect(data.error).toContain('许可证') }) - test('最高权限账号且许可证允许时可以启动占位隧道', async () => { + 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(response.status).toBe(501) + expect(data.error).toContain('尚未接入真实提供商') + expect(canUseIntranetPenetration).toHaveBeenCalled() + }) + + test('最高权限账号且许可证允许时也不能查看占位隧道状态', async () => { + const response = await getTunnelStatus(buildRequest('GET')) + const data = await response.json() + + expect(response.status).toBe(501) + expect(data.status).toBe('provider_not_configured') + expect(data.error).toContain('尚未接入真实提供商') + expect(canUseIntranetPenetration).toHaveBeenCalled() + }) + + test('最高权限账号且许可证允许时也不能停止占位隧道', async () => { + const response = await stopTunnel(buildRequest('POST')) + const data = await response.json() + + expect(response.status).toBe(501) + expect(data.error).toContain('尚未接入真实提供商') expect(canUseIntranetPenetration).toHaveBeenCalled() }) }) diff --git a/app/api/tunnel/start/route.ts b/app/api/tunnel/start/route.ts index e47f77b..183ae36 100644 --- a/app/api/tunnel/start/route.ts +++ b/app/api/tunnel/start/route.ts @@ -32,12 +32,10 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: '当前许可证不包含内网穿透功能,请联系管理员处理许可证' }, { status: 403 }) } - // TODO: 调用具体的隧道提供商启动逻辑 - return NextResponse.json({ - message: '隧道启动成功(占位)', - tunnelUrl: 'https://mock-tunnel.example.com', - expiresAt: null, - }) + return NextResponse.json( + { error: '内网穿透服务尚未接入真实提供商,不能启动占位隧道' }, + { status: 501 } + ) } catch (e) { console.error('Tunnel start error:', e) return NextResponse.json({ error: '服务异常' }, { status: 500 }) diff --git a/app/api/tunnel/status/route.ts b/app/api/tunnel/status/route.ts index 55a66ef..cb358a3 100644 --- a/app/api/tunnel/status/route.ts +++ b/app/api/tunnel/status/route.ts @@ -38,8 +38,14 @@ export async function GET(req: NextRequest) { }, { status: 403 }) } - // TODO: 查询隧道状态 - return NextResponse.json({ enabled: true, status: 'stopped' }) + return NextResponse.json( + { + enabled: true, + status: 'provider_not_configured', + error: '内网穿透服务尚未接入真实提供商,不能返回占位状态', + }, + { status: 501 } + ) } catch (e) { console.error('Tunnel status error:', e) return NextResponse.json({ error: '服务异常' }, { status: 500 }) diff --git a/app/api/tunnel/stop/route.ts b/app/api/tunnel/stop/route.ts index 1f3d3a8..e005eab 100644 --- a/app/api/tunnel/stop/route.ts +++ b/app/api/tunnel/stop/route.ts @@ -32,8 +32,10 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: '当前许可证不包含内网穿透功能,请联系管理员处理许可证' }, { status: 403 }) } - // TODO: 调用隧道提供商停止逻辑 - return NextResponse.json({ message: '隧道已停止(占位)' }) + return NextResponse.json( + { error: '内网穿透服务尚未接入真实提供商,不能停止占位隧道' }, + { status: 501 } + ) } catch (e) { console.error('Tunnel stop error:', e) return NextResponse.json({ error: '服务异常' }, { status: 500 }) diff --git a/app/globals.css b/app/globals.css index 4a5bf93..a43363f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -26,12 +26,7 @@ html { body { color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); + background: rgb(var(--background-start-rgb)); } /* Legacy button utilities, kept visually neutral for the FLNXI workspace. */ diff --git a/app/login/page.tsx b/app/login/page.tsx index 8f0d5c9..ebe24d9 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -404,12 +404,12 @@ export default function LoginPage() { type="button" onClick={handleDingtalkLogin} disabled={!dingtalkConfig.enabled || !dingtalkConfig.clientId} - className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-slate-300 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-60 transition-colors" + className="w-full flex items-center justify-center gap-2 px-4 py-2.5 border border-slate-300 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-60 transition-colors" > - + - + {!dingtalkConfigLoaded ? '正在检查钉钉配置' : dingtalkConfig.enabled diff --git a/components/Header.tsx b/components/Header.tsx index e05dee1..66644e5 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -681,30 +681,32 @@ export default function Header({ session, compactMode }: HeaderProps) { )} - setShowDropdown(false)} - className={cn( - 'w-full text-left px-4 py-2 text-sm transition-colors flex items-center space-x-2', - 'text-slate-700 dark:text-slate-200', - 'hover:bg-slate-100 dark:hover:bg-slate-700' - )} - > - setShowDropdown(false)} + className={cn( + 'w-full text-left px-4 py-2 text-sm transition-colors flex items-center space-x-2', + 'text-slate-700 dark:text-slate-200', + 'hover:bg-slate-100 dark:hover:bg-slate-700' + )} > - - - 监控仪表板 - + + + + 监控仪表板 + + )} {/* 退出登录 */}