继续收口商业化地基八项问题
This commit is contained in:
parent
5be1d5ba45
commit
487be0bf02
@ -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`
|
||||
|
||||
|
||||
@ -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: '首页删除按钮仅管理员可见',
|
||||
|
||||
@ -491,7 +491,13 @@ const requestJson = async <T,>(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<string[]>(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}
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -349,7 +349,7 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
|
||||
{/* 模板管理 */}
|
||||
{activeTab === 'templates' && (
|
||||
<TabErrorBoundary tabKey="templates" onRetry={fetchData}>
|
||||
<TemplateManager showToast={showToast} />
|
||||
<TemplateManager showToast={showToast} currentIsSuperAdmin={currentIsSuperAdmin} />
|
||||
</TabErrorBoundary>
|
||||
)}
|
||||
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<string, number> = {}
|
||||
|
||||
// 1. 清理过期的邀请码(已使用且过期超过30天)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
96
app/api/admin/video-transcode-stats/__tests__/route.test.ts
Normal file
96
app/api/admin/video-transcode-stats/__tests__/route.test.ts
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@ -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('尚未接入真实任务引擎')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}`)
|
||||
|
||||
@ -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<string, { oldValue: any; newValue: any }> = {}
|
||||
|
||||
@ -253,6 +253,7 @@ async function parseUploadForm(req: NextRequest): Promise<ParsedUploadForm> {
|
||||
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<string, string | undefined> = { 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 }
|
||||
))
|
||||
|
||||
@ -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 权限边界', () => {
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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"
|
||||
>
|
||||
<svg style={{ width: '28px', height: '28px' }} viewBox="0 0 1024 1024">
|
||||
<svg style={{ width: '20px', height: '20px' }} viewBox="0 0 1024 1024">
|
||||
<path d="M512 2C230.2 2 2 230.2 2 512s228.2 510 510 510 510-228.2 510-510S793.3 2 512 2z m235.9 442c-1 4.6-3.6 10.8-7.2 19.1l-0.5 0.5c-21.6 45.8-77.3 135.5-77.3 135.5l-0.5-0.5-16.5 28.3h78.8L574.3 826.8l34-136h-61.8l21.6-90.2c-17.5 4.1-38.1 9.8-62.3 18 0 0-33 19.1-94.8-37.1 0 0-41.7-37.1-17.5-45.8 10.3-4.1 50-8.8 81.4-12.9 42.2-5.7 68.5-8.8 68.5-8.8s-130.3 2.1-161.2-3.1c-30.9-4.6-70.1-56.7-78.3-102 0 0-12.9-24.7 27.8-12.9 40.2 11.8 209.2 45.8 209.2 45.8S321.4 375 307 358.5c-14.4-16.5-42.8-89.6-39.2-134.5 0 0 1.5-11.3 12.9-8.2 0 0 161.8 74.2 272.5 114.4C664.5 371.4 760.8 392 747.9 444z" fill="#3296FA"></path>
|
||||
</svg>
|
||||
<span className="text-lg font-medium text-slate-700 dark:text-slate-300">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{!dingtalkConfigLoaded
|
||||
? '正在检查钉钉配置'
|
||||
: dingtalkConfig.enabled
|
||||
|
||||
@ -681,30 +681,32 @@ export default function Header({ session, compactMode }: HeaderProps) {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/admin/monitor"
|
||||
onClick={() => 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'
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
{isSuperAdmin && (
|
||||
<Link
|
||||
href="/admin/monitor"
|
||||
onClick={() => 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'
|
||||
)}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 10h4l3 8 4-16 3 8h4"
|
||||
/>
|
||||
</svg>
|
||||
<span>监控仪表板</span>
|
||||
</Link>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 10h4l3 8 4-16 3 8h4"
|
||||
/>
|
||||
</svg>
|
||||
<span>监控仪表板</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* 退出登录 */}
|
||||
<button
|
||||
|
||||
@ -313,17 +313,17 @@ export default function MaterialPreviewModal({
|
||||
<div className="flex flex-col lg:flex-row gap-3 lg:h-[90vh]">
|
||||
<div
|
||||
className={cn(
|
||||
'flex-shrink min-h-[450px] max-h-[90vh]',
|
||||
'w-full max-w-full flex-shrink min-h-[360px] max-h-[90vh]',
|
||||
'overflow-y-auto pr-2 lg:pr-3',
|
||||
isAudioMaterial ? 'lg:min-w-[500px]' : 'lg:max-w-[600px]'
|
||||
isAudioMaterial ? 'lg:max-w-[500px]' : 'lg:max-w-[600px]'
|
||||
)}
|
||||
>
|
||||
<div className={cn('w-auto', isAudioMaterial ? 'min-w-[450px]' : 'max-w-[600px]')}>
|
||||
<div className={cn('w-full max-w-full', isAudioMaterial ? 'lg:max-w-[500px]' : 'lg:max-w-[600px]')}>
|
||||
{isAudioMaterial ? (
|
||||
// 音频素材播放器
|
||||
<div
|
||||
className={cn(
|
||||
'w-full min-w-[450px] rounded-lg overflow-hidden border',
|
||||
'w-full rounded-lg overflow-hidden border',
|
||||
theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-white border-slate-200'
|
||||
)}
|
||||
>
|
||||
|
||||
@ -44,6 +44,8 @@
|
||||
只在需要核对旧参考源码能力边界时阅读;它不是当前产品功能清单,也不是搬旧功能的依据。
|
||||
- `商业化地基排查问题清单_20260524.md`
|
||||
内部整改跟踪清单,混有已完成项、待整改项和审计痕迹;只在追问题和核对状态时查看,不作为部署、接入或普通使用手册。
|
||||
- `商业化地基8项收口记录_20260530.md`
|
||||
本轮按商业化私有交付目标收口的 8 项事实记录,作为后续继续推进的短入口。
|
||||
- `许可证链路安全评估_20260312.md`
|
||||
历史安全评估快照,部分条目已被后续代码和部署配置修正;保留作溯源参考,不作为当前整改入口。
|
||||
|
||||
|
||||
139
docs/商业化地基8项收口记录_20260530.md
Normal file
139
docs/商业化地基8项收口记录_20260530.md
Normal file
@ -0,0 +1,139 @@
|
||||
# 商业化地基 8 项收口记录
|
||||
|
||||
更新时间:2026-05-30
|
||||
|
||||
本文件记录本轮按“商业化私有交付”目标推进的 8 项地基工作。它不是销售文案,也不是旧参考源码搬运清单;后续判断以当前代码、配置和本文档记录为准。
|
||||
|
||||
## 1. 客户安装和升级演练
|
||||
|
||||
当前事实:
|
||||
|
||||
- 标准客户入口仍是 `docker-compose.prod.yml + .env`。
|
||||
- `deploy-registry.ps1` 是版本化发布和已有客户升级入口,不是完整首装向导。
|
||||
- 当前自动备份主要覆盖 Compose 管理的 PostgreSQL;外部数据库、外部对象存储和离线包升级还需要单独演练。
|
||||
|
||||
本轮收口:
|
||||
|
||||
- 文档继续明确首装、发布、升级三件事不能混成一个命令。
|
||||
- 全库清理接口新增确认短语,避免最高权限账号误触发后直接清空素材。
|
||||
|
||||
仍不能假装完成:
|
||||
|
||||
- 真实客户机上的外部数据库备份、对象存储快照、离线升级包、失败回滚,还需要单独实机演练。
|
||||
|
||||
## 2. 对象存储和历史公开权限
|
||||
|
||||
当前事实:
|
||||
|
||||
- 新安装和新上传默认不再公开素材桶,素材文件走登录态代理或分享票据。
|
||||
- MinIO 初始化只能处理当前 Compose 管理的 bucket 匿名策略。
|
||||
|
||||
本轮收口:
|
||||
|
||||
- 上传失败时,生产环境不再把原始错误详情返回给前端,内部路径、数据库错误和存储错误只留在服务端日志。
|
||||
|
||||
仍不能假装完成:
|
||||
|
||||
- 外部 S3、历史 bucket、历史对象 ACL、公共 Logo / 头像与私有素材的拆分策略,需要单独只读审计。
|
||||
|
||||
## 3. 大文件和文件一致性
|
||||
|
||||
当前事实:
|
||||
|
||||
- 原始素材上传、下载、播放已经做了主要流式收口。
|
||||
- 预览图、PSD 展示图、图片处理仍可能进内存,后续需要更明确限制。
|
||||
|
||||
本轮收口:
|
||||
|
||||
- 素材编辑换预览图时,改为先上传新图、数据库更新成功后再删旧图;如果数据库更新失败,会清理新上传图,避免数据库指向已被提前删掉的旧图。
|
||||
- 软删除素材不再能通过下载接口继续下载。
|
||||
|
||||
仍不能假装完成:
|
||||
|
||||
- 分片上传、断点续传、上传失败重试、PSD 大图像素限制还没有完成。
|
||||
|
||||
## 4. 自定义模板深水区
|
||||
|
||||
当前事实:
|
||||
|
||||
- 自定义模板、字段池、分类字段、模板迁移已有第一轮加固。
|
||||
- 大批量迁移、复杂字段、多选字段和大数据性能仍是后续重点。
|
||||
|
||||
本轮收口:
|
||||
|
||||
- 模板管理前端操作权限跟后端对齐:普通管理员可查看,但创建、复制、编辑、删除、迁移自定义模板只给最高权限账号开放。
|
||||
- 未授权或非最高权限账号点击时,不再表现成“先能点、再失败”的错位体验。
|
||||
|
||||
仍不能假装完成:
|
||||
|
||||
- 大批量模板迁移任务化、迁移进度、失败清单、回滚方案仍待做。
|
||||
|
||||
## 5. 权限和监控可见性
|
||||
|
||||
当前事实:
|
||||
|
||||
- 多数破坏性操作已经收为最高权限账号。
|
||||
- 系统监控、转码统计属于全局状态,不适合普通管理员默认可见。
|
||||
- 私有化交付默认不应开放公开注册,账号入口应优先走最高权限账号创建或邀请链接。
|
||||
|
||||
本轮收口:
|
||||
|
||||
- `/api/admin/system-status` 改为最高权限账号可读。
|
||||
- `/api/admin/video-transcode-stats` 改为最高权限账号可读。
|
||||
- 头像菜单里的“监控仪表板”只对最高权限账号显示。
|
||||
- 监控接口遇到数据库查询失败时不再继续报告 `healthy`,避免验收误判。
|
||||
- 公开注册默认值已经贯穿数据库字段、系统设置兜底、首次创建设置和注册接口;没有系统设置记录时也不会默认开放普通自助注册。
|
||||
- 后台“用户手动注册”开关只允许最高权限账号修改,普通管理员不能打开客户系统的公开注册入口。
|
||||
|
||||
仍不能假装完成:
|
||||
|
||||
- 剩余 `ADMIN` 写接口、批量导入、维护类入口仍需继续逐项扫。
|
||||
|
||||
## 6. 前端 UI 去模板感
|
||||
|
||||
当前事实:
|
||||
|
||||
- 当前 UI 已经比早期更克制,但局部仍有模板式视觉:渐变底、弹窗套壳、移动端固定宽度、彩色标签过多。
|
||||
|
||||
本轮收口:
|
||||
|
||||
- 全局 `body` 背景从渐变改为纯色。
|
||||
- 登录页钉钉按钮降为普通次按钮规格,不再比主登录按钮抢眼。
|
||||
- 素材预览弹窗去掉移动端固定最小宽度,降低窄屏横向溢出风险。
|
||||
|
||||
仍不能假装完成:
|
||||
|
||||
- 上传弹窗、模板编辑弹窗、首页素材卡动作区、邀请管理移动端表格还要继续收敛。
|
||||
|
||||
## 7. Go Agent 和版本来源
|
||||
|
||||
当前事实:
|
||||
|
||||
- Go Agent 已不再默认拉旧 Harbor / `latest`。
|
||||
- 版本列表目前仍是 `FLNXI_VERSION_LIST_URL` 过渡口径。
|
||||
|
||||
本轮收口:
|
||||
|
||||
- 没有把 Go Agent 当成已经可正式客户交付的主入口写入默认安装流程。
|
||||
- 内网穿透启动、停止和状态查询不再返回占位成功;即使最高权限账号和许可证都通过,真实提供商未接入时也会明确返回未实现。
|
||||
|
||||
仍不能假装完成:
|
||||
|
||||
- 正式版本来源要在许可证服务、发布管理接口、Registry tags 清单三种方案里定一个。
|
||||
- Go Agent Web 管理界面还需要和主应用工作台风格统一。
|
||||
|
||||
## 8. 本机测试环境
|
||||
|
||||
当前事实:
|
||||
|
||||
- 本机调试默认入口是 `npm run dev:local` 和 `http://localhost:3002`。
|
||||
- `.env.development.local` 应指向本机数据库和对象存储;如果本机 PostgreSQL / MinIO 没启动,浏览器管理页验收会失败。
|
||||
|
||||
本轮收口:
|
||||
|
||||
- 文档继续保留旧测试服务器不可作为事实来源的提示。
|
||||
- 后续前端浏览器验收前,必须先确认本机数据库和对象存储已恢复。
|
||||
|
||||
仍不能假装完成:
|
||||
|
||||
- 当前没有在飞牛虚拟机或本机 Docker 里完成一轮完整客户安装验收;这仍是下一轮部署演练任务。
|
||||
@ -1,6 +1,6 @@
|
||||
# 商业化地基排查问题清单
|
||||
|
||||
更新时间:2026-05-28
|
||||
更新时间:2026-05-30
|
||||
|
||||
本文件是当前项目面向企业/团队私有化交付前的地基排查记录。它不是对外销售文案,也不是旧参考源码功能吸收清单,而是后续整改的事实入口。它是内部整改跟踪记录,已修复、已完成和待整改条目会同时保留,阅读时必须先看每条状态,不能把整份文件理解成纯待办清单。
|
||||
|
||||
@ -1523,3 +1523,28 @@ NAS 私有化系统可能在平板或窄屏浏览器里使用。模板编辑器
|
||||
- Go Agent 的版本来源还只是 `FLNXI_VERSION_LIST_URL`,后续需要决定正式接许可证服务、发布管理接口,还是 Registry tags 清单。
|
||||
- Go Agent Web 管理界面仍偏原型,需要后续和主项目 UI 风格一起收口。
|
||||
- 镜像瘦身单独立项:先量化当前镜像大小,再决定 ffmpeg、canvas、字体和 Prisma CLI 是否拆成可选能力或独立镜像。
|
||||
|
||||
## 10. 2026-05-30 商业化地基 8 项收口记录
|
||||
|
||||
本轮按“客户私有化交付是否可信”继续推进,并把短入口沉淀到 `docs/商业化地基8项收口记录_20260530.md`。
|
||||
|
||||
已完成收口:
|
||||
|
||||
- 系统监控和视频转码统计改为最高权限账号可读;头像菜单里的“监控仪表板”也只对最高权限账号显示。
|
||||
- 监控接口遇到数据库查询失败时不再继续报告 `healthy`,避免安装或升级验收误判。
|
||||
- 模板管理前端操作权限与后端一致:普通管理员可查看,自定义模板的创建、复制、编辑、删除、迁移只给最高权限账号开放。
|
||||
- 内网穿透启动、停止、状态查询和 AI 改图不再返回占位成功结果;未接入真实提供商或真实任务引擎时直接返回未实现。
|
||||
- 上传失败时,生产环境不再把原始错误详情、数据库错误或存储错误返回给前端。
|
||||
- 素材编辑换预览图时,先上传新图、数据库更新成功后再删旧图;数据库更新失败会清理新上传图。
|
||||
- 软删除素材不再能通过下载接口继续下载。
|
||||
- 全库清理接口增加确认短语,避免最高权限账号误触发后直接清空素材。
|
||||
- 公开注册默认值已贯穿数据库字段、系统设置兜底、首次创建设置和注册接口;没有系统设置记录时也不会默认开放公开注册,后台开关只允许最高权限账号修改。
|
||||
- 前端先收三处明显模板感:全局底色去渐变、登录页钉钉按钮降级、素材预览弹窗去掉移动端固定最小宽度。
|
||||
|
||||
仍需继续:
|
||||
|
||||
- 外部数据库、外部对象存储、大体量客户和离线包升级仍需要实机演练。
|
||||
- 外部 S3、历史 bucket、历史对象 ACL、公共 Logo / 头像与私有素材的拆分策略仍需要只读审计。
|
||||
- 上传分片、断点续传、PSD 大图限制和大批量模板迁移任务化仍未完成。
|
||||
- Go Agent 正式版本来源和管理界面仍未定稿。
|
||||
- 上传弹窗、模板编辑弹窗、首页素材卡动作区、邀请管理移动端表格仍需要继续 UI 收口。
|
||||
|
||||
@ -381,7 +381,7 @@ model SystemSettings {
|
||||
s3PublicUrl String @default("")
|
||||
s3Region String @default("us-east-1")
|
||||
s3SecretKey String @default("")
|
||||
enableRegistration Boolean @default(true)
|
||||
enableRegistration Boolean @default(false)
|
||||
enableUserActivation Boolean @default(false)
|
||||
enableAiImageEdit Boolean @default(false)
|
||||
enableTunnelService Boolean @default(false)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user