继续收口商业化地基八项问题

This commit is contained in:
WuYongJie 2026-05-30 17:09:42 +08:00
parent 5be1d5ba45
commit 487be0bf02
34 changed files with 706 additions and 151 deletions

View File

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

View File

@ -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: '首页删除按钮仅管理员可见',

View File

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

View File

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

View File

@ -349,7 +349,7 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
{/* 模板管理 */}
{activeTab === 'templates' && (
<TabErrorBoundary tabKey="templates" onRetry={fetchData}>
<TemplateManager showToast={showToast} />
<TemplateManager showToast={showToast} currentIsSuperAdmin={currentIsSuperAdmin} />
</TabErrorBoundary>
)}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -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('尚未接入真实任务引擎')
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 }> = {}

View File

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

View File

@ -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 权限边界', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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. */

View File

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

View File

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

View File

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

View File

@ -44,6 +44,8 @@
只在需要核对旧参考源码能力边界时阅读;它不是当前产品功能清单,也不是搬旧功能的依据。
- `商业化地基排查问题清单_20260524.md`
内部整改跟踪清单,混有已完成项、待整改项和审计痕迹;只在追问题和核对状态时查看,不作为部署、接入或普通使用手册。
- `商业化地基8项收口记录_20260530.md`
本轮按商业化私有交付目标收口的 8 项事实记录,作为后续继续推进的短入口。
- `许可证链路安全评估_20260312.md`
历史安全评估快照,部分条目已被后续代码和部署配置修正;保留作溯源参考,不作为当前整改入口。

View 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 里完成一轮完整客户安装验收;这仍是下一轮部署演练任务。

View File

@ -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 收口。

View File

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