强化商业化升级备份与权限 UI 地基

This commit is contained in:
WuYongJie 2026-06-01 13:01:57 +08:00
parent 7026508a4a
commit 12f248dc69
42 changed files with 1441 additions and 256 deletions

View File

@ -238,6 +238,10 @@ RUN addgroup --system --gid 1001 nodejs && \
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# standalone 可能会把构建期的 prisma/engine-cache、dev.db 一起追踪进来;
# 运行镜像只保留 schema.prisma 和 migrations,避免客户每次升级重复拉无用缓存。
RUN rm -rf ./prisma
# 复制必要的额外文件
# 1. public 目录(静态资源)
COPY --from=builder --chown=nextjs:nodejs /app/public ./public

View File

@ -213,7 +213,7 @@ docker compose -f docker-compose.prod.yml up -d
- `发布管理.ps1`
- `license-server/deploy.ps1`
其中 `scripts/customer-install.ps1` 是客户首装分步骤入口,内部调用 `scripts/smoke-install.ps1` 做预检、启动和验收;`scripts/backup-restore.ps1` 用于客户环境备份、恢复和恢复后验收,`scripts/measure-image-size.ps1` 用于发布前记录镜像体积和分层。其它脚本执行前必须先读内容和参数,不应把内部固定服务器或固定镜像仓库当成客户默认交付配置。客户交付时要显式写入版本化 `APP_IMAGE` / `IMAGE_TAG`,`latest` 只保留给内部测试或兼容场景。当前新 Registry 发布脚本是主线,旧远程部署脚本只作历史或内部维护参考。AI、钉钉、内网穿透等可选能力依赖真实外部服务和密钥,不能只凭页面入口当成客户验收已完成。
其中 `scripts/customer-install.ps1` 是客户首装分步骤入口,内部调用 `scripts/smoke-install.ps1` 做预检、启动和验收;`scripts/backup-restore.ps1` 用于客户环境备份、恢复和恢复后验收;`scripts/measure-image-size.ps1` 用于发布前记录镜像体积和分层,也可以设置体积/层数阈值,超限时阻止发布继续。其它脚本执行前必须先读内容和参数,不应把内部固定服务器或固定镜像仓库当成客户默认交付配置。客户交付时要显式写入版本化 `APP_IMAGE` / `IMAGE_TAG`,`latest` 只保留给内部测试或兼容场景。当前新 Registry 发布脚本是主线,旧远程部署脚本只作历史或内部维护参考。AI、钉钉、内网穿透等可选能力依赖真实外部服务和密钥,不能只凭页面入口当成客户验收已完成。
## 10. 文档入口

View File

@ -20,6 +20,7 @@ describe('production build hardening', () => {
})
it('生产运行镜像只复制 Prisma 运行迁移所需文件', () => {
expect(dockerfile).toContain('RUN rm -rf ./prisma')
expect(dockerfile).toContain('/app/prisma/schema.prisma ./prisma/schema.prisma')
expect(dockerfile).toContain('/app/prisma/migrations ./prisma/migrations')
expect(dockerfile).not.toContain('/app/prisma ./prisma')

View File

@ -274,23 +274,20 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
}
return (
<div className="space-y-8">
<div className="space-y-6">
<section
className={cn(
'rounded-lg border p-5',
'border-b pb-5',
theme === 'dark'
? 'border-slate-700 bg-slate-900 text-white'
: 'border-slate-200 bg-white text-slate-900',
? 'border-slate-800 text-white'
: 'border-slate-200 text-slate-900',
)}
>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3 max-w-2xl">
<p className={cn('text-xs font-medium text-slate-500', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
外观
</p>
<h2 className={cn('text-xl font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>品牌、标识与系统开关</h2>
<p className={cn('text-sm leading-relaxed', theme === 'dark' ? 'text-slate-100/90' : 'text-slate-700/90')}>
这里集中处理品牌主题、LOGO、功能开关与对象存储。保存后会同步到后台界面与素材上传入口。
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="space-y-2 max-w-2xl">
<h2 className={cn('text-lg font-semibold', theme === 'dark' ? 'text-white' : 'text-slate-900')}>外观设置</h2>
<p className={cn('text-sm leading-relaxed', theme === 'dark' ? 'text-slate-400' : 'text-slate-500')}>
管理站点名称、LOGO、功能开关和对象存储配置。
</p>
{lastSavedAt && (
<p className={cn('text-xs', theme === 'dark' ? 'text-slate-300' : 'text-slate-600')}>
@ -298,7 +295,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
</p>
)}
</div>
<div className="flex flex-wrap gap-3">
<div className="flex flex-wrap gap-2">
{variant === 'standalone' && (
<button
type="button"
@ -383,40 +380,6 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
))}
</div>
</div>
<div
id="status-card"
className={cn(
styles.cardStyles.base,
'p-5 space-y-4 flex flex-col'
)}
>
<div>
<p className="text-xs font-medium text-slate-500">状态概览</p>
<p className={cn('text-base font-semibold mt-1', theme === 'dark' ? 'text-white' : 'text-slate-900')}>当前主题色</p>
</div>
<div className={cn(
'w-full h-24 rounded-lg border',
theme === 'dark' ? 'border-slate-700' : 'border-slate-200'
)} style={{ backgroundColor: headerBgColor }}>
<div className="flex items-center justify-between h-full px-4 text-white/90">
<div>
<p className="text-sm font-semibold">{siteName}</p>
<p className="text-xs opacity-80 font-mono">{headerBgColor}</p>
</div>
<span className={cn(
'text-xs font-semibold px-3 py-1 rounded-md',
theme === 'dark' ? 'bg-white/15 text-white/80' : 'bg-white/25 text-white/90'
)}>导航示例</span>
</div>
</div>
<button
onClick={handleColorReset}
className={cn(styles.buttonStyles.secondary, 'w-full')}
>
恢复默认主题色
</button>
</div>
</div>
<div className="space-y-8">
@ -469,9 +432,16 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
onChange={(e) => setHeaderBgColor(e.target.value)}
className={cn(styles.inputStyles.base, 'flex-1 text-sm font-mono')}
/>
<button
type="button"
onClick={handleColorReset}
className={cn(styles.buttonStyles.secondary, styles.buttonModifiers.compact, 'shrink-0')}
>
恢复默认
</button>
</div>
<div className="flex flex-wrap gap-2 pt-2">
{['#1f2937', '#4f46e5', '#1e40af', '#7c3aed', '#0891b2', '#059669', '#dc2626', '#d97706'].map((color) => (
{['#1f2937', '#334155', '#0f766e', '#166534', '#b45309', '#b91c1c'].map((color) => (
<button
key={color}
onClick={() => setHeaderBgColor(color)}

View File

@ -36,7 +36,7 @@ export default function BuiltInTemplateGrid({
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">预制模板(推荐)</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">常用场景一键启用,也可作为自定义模板的起点</p>
<p className="text-[11px] text-slate-400 dark:text-slate-500">
点击卡片仅预览示例,内容不可直接编辑,需复制为自定义模板后才可修改。
点击条目仅预览示例,内容不可直接编辑,需复制为自定义模板后才可修改。
</p>
</div>
{hasDisabledTemplates && onEnableAll && (
@ -54,32 +54,24 @@ export default function BuiltInTemplateGrid({
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-2">
{presets.map((preset) => {
const fieldCount = preset.getFields().length
return (
<div
key={preset.key}
className={cn(styles.cardStyles.hover, 'group relative overflow-hidden cursor-pointer')}
className="group relative cursor-pointer rounded-lg border border-slate-200 bg-white px-4 py-3 transition-colors hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:hover:border-slate-600 dark:hover:bg-slate-800/70"
onClick={() => onPreview(preset.key)}
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0 space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-white">{preset.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 leading-snug">{preset.description}</p>
</div>
<span className="px-2.5 py-0.5 rounded-md text-[11px] font-medium text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
预制
</span>
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-1.5 text-[11px] text-slate-400 dark:text-slate-500">
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 7h16M4 12h10m-10 5h7" />
</svg>
<span>{fieldCount} 个字段</span>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center justify-start gap-2 lg:justify-end">
<span className="rounded-md border border-slate-200 bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
{fieldCount} 个字段
</span>
{onToggleEnabled && (
<button
type="button"

View File

@ -48,7 +48,7 @@ export default function CustomTemplateSection({
return (
<div className="px-6 pt-5 pb-4">
<div className="flex items-center justify-between gap-4 mb-3">
<div className="flex flex-col gap-3 mb-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">自定义模板</h3>
@ -64,6 +64,23 @@ export default function CustomTemplateSection({
: '当前许可证未包含自定义模板功能,请联系管理员处理授权'}
</p>
</div>
<button
type="button"
onClick={() => {
if (!canManageCustomTemplates) {
onRequestUpgrade()
return
}
onCreate()
}}
className={cn(
canManageCustomTemplates ? styles.buttonStyles.primary : styles.buttonStyles.secondary,
styles.buttonModifiers.compactXs,
'w-full sm:w-auto'
)}
>
{canManageCustomTemplates ? '新建自定义模板' : '授权后可新建'}
</button>
</div>
{loading ? (
@ -71,57 +88,26 @@ export default function CustomTemplateSection({
) : error ? (
<div className="p-8 text-center text-red-500 dark:text-red-300">{error}</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<button
type="button"
onClick={() => {
if (!canManageCustomTemplates) {
onRequestUpgrade()
return
}
onCreate()
}}
className={cn(
styles.cardStyles.flat,
'border-dashed border-2 border-slate-300 dark:border-slate-600 flex items-center justify-center gap-3'
)}
>
<div className="w-9 h-9 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center flex-shrink-0 transition-colors">
<svg className="w-5 h-5 text-slate-400 dark:text-slate-300 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div className="text-left">
<p className="text-sm font-semibold text-slate-800 dark:text-slate-200">
{canManageCustomTemplates ? '新建自定义模板' : '授权后可新建'}
</p>
<p className="text-[11px] text-slate-500 dark:text-slate-400 mt-1 leading-snug">
{canManageCustomTemplates
? '从空白模板起步,按照业务需求配置字段'
: '当前不可新建、复制或迁移自定义模板'}
</p>
</div>
</button>
<div className="space-y-2">
{templates.length === 0 ? (
<div className="col-span-2 p-8 text-center text-slate-500 dark:text-slate-400">暂无自定义模板,点击上方卡片开始创建。</div>
<div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">暂无自定义模板。</div>
) : filteredTemplates.length === 0 ? (
<div className="col-span-2 p-8 text-center text-slate-500 dark:text-slate-400">未找到匹配的模板名称,请调整搜索关键词。</div>
<div className="rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">未找到匹配的模板名称,请调整搜索关键词。</div>
) : (
filteredTemplates.map((tpl) => (
<div
key={tpl.id}
onClick={() => onPreview(tpl)}
className={cn(styles.cardStyles.hover, 'group relative overflow-hidden cursor-pointer')}
className="group relative cursor-pointer rounded-lg border border-slate-200 bg-white px-4 py-3 transition-colors hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:hover:border-slate-600 dark:hover:bg-slate-800/70"
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0 space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-white">{tpl.name}</p>
{tpl.description && (
<p className="text-xs text-slate-500 dark:text-slate-400 leading-snug line-clamp-2">{tpl.description}</p>
)}
</div>
<div className="flex flex-col items-end gap-1 text-right">
<div className="flex flex-wrap items-center justify-start gap-2 lg:justify-end">
<span
className={`px-2 py-0.5 text-xs font-medium rounded-md transition-colors ${
tpl.status === 'active'
@ -133,9 +119,9 @@ export default function CustomTemplateSection({
>
{tpl.statusLabel}
</span>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-slate-200/70 dark:border-slate-700/70 pt-3">
<span className="text-[11px] text-slate-400 dark:text-slate-500">
{tpl.updatedLabel} · 版本 {tpl.version}
</span>
{tpl.status === 'active' ? (
<button
type="button"
@ -213,11 +199,9 @@ export default function CustomTemplateSection({
>
{deletingId === tpl.id ? '删除中...' : '删除'}
</button>
<span className="ml-auto text-[11px] text-slate-400 dark:text-slate-500">
{tpl.updatedLabel} · 版本 {tpl.version}
</span>
</div>
</div>
</div>
))
)}
</div>

View File

@ -19,10 +19,10 @@ export default function TemplateManagerHeader({
return (
<section
className={cn(
'mb-4 rounded-lg border px-4 py-4',
'mb-4 border-b px-1 pb-4',
theme === 'dark'
? 'border-slate-700 bg-slate-900/85 text-white'
: 'border-slate-200 bg-white text-slate-900'
? 'border-slate-800 text-white'
: 'border-slate-200 text-slate-900'
)}
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">

View File

@ -164,7 +164,7 @@ export default function FieldConditionsConfig({
{index === 0 && <div className="w-10 flex-shrink-0" />}
{/* 条件配置 */}
<div className="flex-1 grid grid-cols-3 gap-2">
<div className="flex-1 grid grid-cols-1 gap-2 sm:grid-cols-3">
{/* 字段选择 */}
<select
value={condition.fieldKey}

View File

@ -142,7 +142,7 @@ export default function FieldValidationConfig({
{/* 长度验证 - text/textarea */}
{supportedValidations.includes('minLength') && (
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className={labelClass}>最小长度</label>
<input
@ -179,7 +179,7 @@ export default function FieldValidationConfig({
{/* 自定义长度错误提示 */}
{supportedValidations.includes('minLength') &&
(typeof rules.minLength === 'number' || typeof rules.maxLength === 'number') && (
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{typeof rules.minLength === 'number' && (
<div>
<label className={labelClass}>最小长度提示(可选)</label>
@ -213,7 +213,7 @@ export default function FieldValidationConfig({
{/* 数值范围验证 - number */}
{supportedValidations.includes('min') && (
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className={labelClass}>最小值</label>
<input
@ -248,7 +248,7 @@ export default function FieldValidationConfig({
{/* 自定义数值范围错误提示 */}
{supportedValidations.includes('min') &&
(typeof rules.min === 'number' || typeof rules.max === 'number') && (
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{typeof rules.min === 'number' && (
<div>
<label className={labelClass}>最小值提示(可选)</label>

View File

@ -327,7 +327,7 @@ export default function TemplateFieldsEditor({
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
字段键名 <span className="text-red-500">*</span>

View File

@ -352,14 +352,14 @@ export default function TemplatePreviewPanel({
<div
className={
isPreviewMode
? 'border border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-4 bg-slate-50/30 dark:bg-slate-800/30'
? ''
: ''
}
>
<div
className={cn(
'rounded-lg bg-white p-5 space-y-5 dark:bg-slate-800',
isPreviewMode ? '' : 'border border-slate-200 dark:border-slate-700',
'border border-slate-200 dark:border-slate-700',
)}
>
<div className="relative">

View File

@ -37,10 +37,10 @@ function getSessionUser(session: { user?: unknown } | null): SessionUser | null
export default function AdminPage() {
const styles = useThemeStyles()
const theme = useTheme()
const tabIndicatorClass = theme === 'dark' ? 'bg-indigo-300' : 'bg-indigo-600'
const tabIndicatorClass = theme === 'dark' ? 'bg-slate-100' : 'bg-slate-900'
const tabIndicatorLineClass = 'absolute bottom-0 left-3 right-3 h-[2px] rounded-full'
const tabFocusRingClass =
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900'
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900'
const { data: session, status } = useSession()
const sessionUser = getSessionUser(session)
const router = useRouter()
@ -218,7 +218,8 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'users' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
tabFocusRingClass
tabFocusRingClass,
'shrink-0 whitespace-nowrap'
)}
>
<span className="relative z-10">用户管理</span>
@ -241,7 +242,8 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'departments' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
tabFocusRingClass
tabFocusRingClass,
'shrink-0 whitespace-nowrap'
)}
>
<span className="relative z-10">架构管理</span>
@ -264,7 +266,8 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'templates' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
tabFocusRingClass
tabFocusRingClass,
'shrink-0 whitespace-nowrap'
)}
>
<span className="relative z-10">模板管理</span>
@ -287,7 +290,8 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'categories' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
tabFocusRingClass
tabFocusRingClass,
'shrink-0 whitespace-nowrap'
)}
>
<span className="relative z-10">分类管理</span>
@ -312,7 +316,8 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'invitations' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
tabFocusRingClass
tabFocusRingClass,
'shrink-0 whitespace-nowrap'
)}
>
<span className="relative z-10">邀请管理</span>
@ -336,7 +341,8 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
}}
className={cn(
activeTab === 'materials' ? styles.navItemStyles.tabActive : styles.navItemStyles.tabBase,
tabFocusRingClass
tabFocusRingClass,
'shrink-0 whitespace-nowrap'
)}
>
<span className="relative z-10">素材管理</span>

View File

@ -4,7 +4,7 @@ import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { syncFieldPoolFromTemplates } from '@/lib/field-pool'
import { canUseCustomTemplates } from '@/lib/license'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { isCurrentAdmin, isCurrentSuperAdmin } from '@/lib/admin-permissions'
import {
isValidUploadTemplateStatus,
validateAndFormatUploadTemplateFields,
@ -47,9 +47,11 @@ const selectTemplate = {
},
} as const
const MAX_SYNC_FIELD_STATS_MATERIALS = 1000
async function ensureAdmin() {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
if (!session || !(await isCurrentAdmin(session))) {
return null
}
return session
@ -105,6 +107,74 @@ type TemplateStructureChange =
fieldKey: string
fieldLabel: string
}
| {
type: 'fieldMadeRequired'
fieldId: string
fieldKey: string
fieldLabel: string
}
| {
type: 'fieldValidationChanged'
fieldId: string
fieldKey: string
fieldLabel: string
}
| {
type: 'fieldOptionsRemoved'
fieldId: string
fieldKey: string
fieldLabel: string
removedOptions: string[]
}
| {
type: 'fieldConditionsChanged'
fieldId: string
fieldKey: string
fieldLabel: string
}
function normalizeJsonValue(value: unknown) {
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return ''
try {
return JSON.parse(trimmed)
} catch {
return trimmed
}
}
return value ?? null
}
function stableStringify(value: unknown): string {
const normalized = normalizeJsonValue(value)
if (Array.isArray(normalized)) {
return JSON.stringify(normalized.map((item) => stableStringify(item)))
}
if (normalized && typeof normalized === 'object') {
const entries = Object.entries(normalized as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => [key, stableStringify(item)])
return JSON.stringify(entries)
}
return JSON.stringify(normalized)
}
function parseStaticOptions(value: unknown): string[] {
const normalized = normalizeJsonValue(value)
if (!Array.isArray(normalized)) {
return []
}
return normalized
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean)
}
function detectDangerousTemplateChanges(args: {
existingTemplate: {
@ -115,9 +185,13 @@ function detectDangerousTemplateChanges(args: {
key: string
type: string
label: string
required: boolean
addToCategory: boolean
isSearchable: boolean
isReadOnly: boolean
validations: string | null
optionsStatic: string | null
conditions: string | null
}>
}
nextMaterialType?: string
@ -127,9 +201,13 @@ function detectDangerousTemplateChanges(args: {
key: string
type: string
label: string
required?: boolean
addToCategory?: boolean
isSearchable?: boolean
isReadOnly?: boolean
validations?: string | null
optionsStatic?: string | null
conditions?: string | null
}>
}): TemplateStructureChange[] {
const { existingTemplate, nextMaterialType, nextMaterialKind, nextFields } = args
@ -221,6 +299,48 @@ function detectDangerousTemplateChanges(args: {
fieldLabel: existingField.label,
})
}
if (!existingField.required && incomingField.required === true) {
changes.push({
type: 'fieldMadeRequired',
fieldId: existingField.id,
fieldKey: existingField.key,
fieldLabel: existingField.label,
})
}
if (stableStringify(incomingField.validations) !== stableStringify(existingField.validations)) {
changes.push({
type: 'fieldValidationChanged',
fieldId: existingField.id,
fieldKey: existingField.key,
fieldLabel: existingField.label,
})
}
const existingOptions = parseStaticOptions(existingField.optionsStatic)
if (existingOptions.length > 0) {
const incomingOptions = new Set(parseStaticOptions(incomingField.optionsStatic))
const removedOptions = existingOptions.filter((option) => !incomingOptions.has(option))
if (removedOptions.length > 0) {
changes.push({
type: 'fieldOptionsRemoved',
fieldId: existingField.id,
fieldKey: existingField.key,
fieldLabel: existingField.label,
removedOptions,
})
}
}
if (stableStringify(incomingField.conditions) !== stableStringify(existingField.conditions)) {
changes.push({
type: 'fieldConditionsChanged',
fieldId: existingField.id,
fieldKey: existingField.key,
fieldLabel: existingField.label,
})
}
}
return changes
@ -273,7 +393,14 @@ export async function GET(_: NextRequest, { params }: { params: { id: string } }
// 统计字段填写率(如果有素材使用该模板)
let fieldStats = null
if (template._count.materials > 0) {
if (template._count.materials > MAX_SYNC_FIELD_STATS_MATERIALS) {
fieldStats = {
skipped: true,
reason: 'TOO_MANY_MATERIALS',
totalMaterials: template._count.materials,
maxSynchronousMaterials: MAX_SYNC_FIELD_STATS_MATERIALS,
}
} else if (template._count.materials > 0) {
fieldStats = await calculateFieldStats(id, template.fields)
}
@ -418,9 +545,13 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
key: true,
type: true,
label: true,
required: true,
addToCategory: true,
isSearchable: true,
isReadOnly: true,
validations: true,
optionsStatic: true,
conditions: true,
},
orderBy: { orderIndex: 'asc' },
},
@ -537,6 +668,25 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
}
formattedFields = formattedFieldsResult.fields
const existingFieldIds = new Set(existing.fields.map((field) => field.id))
const foreignField = formattedFields.find((field) => field.id && !existingFieldIds.has(field.id))
if (foreignField) {
console.warn('[TemplateUpdate] Field id does not belong to template:', {
templateId: id,
fieldId: foreignField.id,
fieldKey: foreignField.key,
})
return NextResponse.json({
error: '字段不属于当前模板,请刷新后重试',
errorCode: 'FIELD_NOT_IN_TEMPLATE',
details: {
fieldId: foreignField.id,
fieldKey: foreignField.key,
},
}, { status: 400 })
}
}
if (existing._count.materials > 0) {
@ -560,7 +710,7 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
})
return NextResponse.json({
error: `模板“${existing.name}”已有 ${existing._count.materials} 个素材在使用,不能直接修改素材大类、素材类型,也不能删除字段、修改字段 key、修改字段类型、取消已加入分类的字段、关闭已启用搜索的字段或把可填写字段改为只读。请新建模板后迁移素材。`,
error: `模板“${existing.name}”已有 ${existing._count.materials} 个素材在使用,不能直接修改素材大类、素材类型,也不能删除字段、修改字段 key、修改字段类型、取消已加入分类的字段、关闭已启用搜索的字段、把可填写字段改为只读、把字段改为必填、修改校验规则、删除选项或修改显示条件。请新建模板后迁移素材。`,
errorCode: 'TEMPLATE_STRUCTURE_LOCKED',
details: {
templateName: existing.name,

View File

@ -41,6 +41,7 @@ jest.mock('@/lib/license', () => ({
}))
jest.mock('@/lib/admin-permissions', () => ({
isCurrentAdmin: jest.fn(),
isCurrentSuperAdmin: jest.fn(),
}))
@ -48,7 +49,7 @@ const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { syncFieldPoolFromTemplates } = require('@/lib/field-pool')
const { canUseCustomTemplates } = require('@/lib/license')
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
const { isCurrentAdmin, isCurrentSuperAdmin } = require('@/lib/admin-permissions')
function makeTemplateTransactionMock(overrides: any = {}) {
return {
@ -82,6 +83,7 @@ describe('模板CRUD API测试', () => {
user: { id: 'admin-id', role: 'ADMIN' }
})
canUseCustomTemplates.mockResolvedValue(true)
isCurrentAdmin.mockResolvedValue(true)
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.$transaction.mockImplementation(async (callback: (tx: any) => Promise<any>) =>
callback(makeTemplateTransactionMock())
@ -122,6 +124,7 @@ describe('模板CRUD API测试', () => {
getServerSession.mockResolvedValue({
user: { id: 'user-id', role: 'USER' }
})
isCurrentAdmin.mockResolvedValue(false)
const response = await listTemplates()
const data = await response.json()
@ -417,6 +420,59 @@ describe('模板CRUD API测试', () => {
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('应该限制单个模板字段数量,避免超大模板拖垮客户现场', async () => {
const fields = Array.from({ length: 81 }, (_, index) => ({
key: `field${index}`,
label: `字段${index}`,
type: 'text',
}))
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
method: 'POST',
body: JSON.stringify({
name: '超大模板',
materialType: '3DMAX',
materialKind: 'IMAGE',
fields,
}),
})
const response = await createTemplate(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.errorCode).toBe('VALIDATION_ERROR')
expect(data.details.maxFields).toBe(80)
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('应该限制下拉选项数量,避免分类同步和筛选统计失控', async () => {
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
method: 'POST',
body: JSON.stringify({
name: '选项过多模板',
materialType: '3DMAX',
materialKind: 'IMAGE',
fields: [
{
key: 'brand',
label: '品牌',
type: 'select',
optionsStatic: Array.from({ length: 201 }, (_, index) => `品牌${index}`),
},
],
}),
})
const response = await createTemplate(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.errorCode).toBe('VALIDATION_ERROR')
expect(data.details.maxOptions).toBe(200)
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('应该拒绝和字段类型不匹配的验证规则', async () => {
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
method: 'POST',
@ -558,6 +614,20 @@ describe('模板CRUD API测试', () => {
})
describe('GET /api/admin/upload-templates/[id] - 获取单个模板', () => {
test('数据库中已撤销管理员权限时不能读取模板详情', async () => {
isCurrentAdmin.mockResolvedValue(false)
const response = await getTemplate(
new NextRequest('http://localhost'),
{ params: { id: 'template-1' } }
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.errorCode).toBe('UNAUTHORIZED')
expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled()
})
test('应该返回模板详情(包含统计信息)', async () => {
const mockTemplate = {
id: 'template-1',
@ -585,6 +655,36 @@ describe('模板CRUD API测试', () => {
expect(data.materials).toHaveLength(1)
})
test('模板使用量过大时不应同步读取全部 metadata 计算字段填写率', async () => {
const mockTemplate = {
id: 'template-heavy',
name: '大模板',
version: 1,
fields: [{ key: 'brand', label: '品牌', type: 'text' }],
_count: { materials: 1001 },
}
prisma.uploadTemplate.findUnique.mockResolvedValue(mockTemplate)
prisma.material.findMany.mockResolvedValue([
{ id: 'mat-1', productModel: '产品1', previewUrl: 'url1', createdAt: new Date() },
])
const response = await getTemplate(
new NextRequest('http://localhost'),
{ params: { id: 'template-heavy' } }
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.fieldStats).toMatchObject({
skipped: true,
reason: 'TOO_MANY_MATERIALS',
totalMaterials: 1001,
maxSynchronousMaterials: 1000,
})
expect(prisma.material.findMany).toHaveBeenCalledTimes(1)
})
test('应该返回404当模板不存在', async () => {
prisma.uploadTemplate.findUnique.mockResolvedValue(null)
@ -728,6 +828,40 @@ describe('模板CRUD API测试', () => {
expect(syncFieldPoolFromTemplates).toHaveBeenCalledWith(tx)
})
test('更新模板字段时不允许传入其他模板的字段 ID', async () => {
const existingTemplate = {
id: 'template-1',
name: '模板',
version: 1,
materialType: '3DMAX',
materialKind: 'IMAGE',
updatedAt: new Date(),
fields: [
{ id: 'field-1', key: 'productModel', type: 'text', label: '型号' },
],
_count: { materials: 0 },
}
prisma.uploadTemplate.findUnique.mockResolvedValue(existingTemplate)
const request = new NextRequest('http://localhost', {
method: 'PUT',
body: JSON.stringify({
version: 1,
fields: [
{ id: 'foreign-field', key: 'productModel', label: '型号', type: 'text' },
],
}),
})
const response = await updateTemplate(request, { params: { id: 'template-1' } })
const data = await response.json()
expect(response.status).toBe(400)
expect(data.errorCode).toBe('FIELD_NOT_IN_TEMPLATE')
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('应该允许将模板更新为音频大类', async () => {
prisma.uploadTemplate.findUnique.mockResolvedValueOnce({
id: 'template-1',
@ -1078,6 +1212,125 @@ describe('模板CRUD API测试', () => {
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('已被素材使用的模板不应允许把可选字段改成必填', async () => {
prisma.uploadTemplate.findUnique.mockResolvedValue({
id: 'template-1',
version: 1,
name: '使用中的模板',
materialType: '3DMAX',
materialKind: 'IMAGE',
updatedAt: new Date('2026-03-13T00:00:00.000Z'),
fields: [
{ id: 'field-1', key: 'brand', type: 'text', label: '品牌', required: false, addToCategory: false, isSearchable: false, isReadOnly: false },
],
_count: { materials: 2 },
})
const request = new NextRequest('http://localhost', {
method: 'PUT',
body: JSON.stringify({
version: 1,
fields: [
{ id: 'field-1', key: 'brand', label: '品牌', type: 'text', required: true, isSearchable: false, isReadOnly: false },
],
}),
})
const response = await updateTemplate(request, { params: { id: 'template-1' } })
const data = await response.json()
expect(response.status).toBe(409)
expect(data.errorCode).toBe('TEMPLATE_STRUCTURE_LOCKED')
expect(data.details.dangerousChanges[0].type).toBe('fieldMadeRequired')
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('已被素材使用的模板不应允许修改字段校验规则', async () => {
prisma.uploadTemplate.findUnique.mockResolvedValue({
id: 'template-1',
version: 1,
name: '使用中的模板',
materialType: '3DMAX',
materialKind: 'IMAGE',
updatedAt: new Date('2026-03-13T00:00:00.000Z'),
fields: [
{ id: 'field-1', key: 'skuCode', type: 'text', label: 'SKU', required: false, addToCategory: false, isSearchable: false, isReadOnly: false, validations: null },
],
_count: { materials: 2 },
})
const request = new NextRequest('http://localhost', {
method: 'PUT',
body: JSON.stringify({
version: 1,
fields: [
{ id: 'field-1', key: 'skuCode', label: 'SKU', type: 'text', validations: { maxLength: 12 } },
],
}),
})
const response = await updateTemplate(request, { params: { id: 'template-1' } })
const data = await response.json()
expect(response.status).toBe(409)
expect(data.errorCode).toBe('TEMPLATE_STRUCTURE_LOCKED')
expect(data.details.dangerousChanges[0].type).toBe('fieldValidationChanged')
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('已被素材使用的模板不应允许删除已有选项或修改显示条件', async () => {
prisma.uploadTemplate.findUnique.mockResolvedValue({
id: 'template-1',
version: 1,
name: '使用中的模板',
materialType: '3DMAX',
materialKind: 'IMAGE',
updatedAt: new Date('2026-03-13T00:00:00.000Z'),
fields: [
{
id: 'field-1',
key: 'usage',
type: 'select',
label: '用途',
required: false,
addToCategory: false,
isSearchable: false,
isReadOnly: false,
optionsStatic: JSON.stringify(['电商', '广告']),
conditions: null,
},
],
_count: { materials: 2 },
})
const request = new NextRequest('http://localhost', {
method: 'PUT',
body: JSON.stringify({
version: 1,
fields: [
{ key: 'brand', label: '品牌', type: 'text' },
{
id: 'field-1',
key: 'usage',
label: '用途',
type: 'select',
optionsStatic: ['电商'],
conditions: { logic: 'AND', conditions: [{ fieldKey: 'brand', operator: 'notEmpty' }] },
},
],
}),
})
const response = await updateTemplate(request, { params: { id: 'template-1' } })
const data = await response.json()
const changeTypes = data.details.dangerousChanges.map((change: any) => change.type)
expect(response.status).toBe(409)
expect(data.errorCode).toBe('TEMPLATE_STRUCTURE_LOCKED')
expect(changeTypes).toEqual(expect.arrayContaining(['fieldOptionsRemoved', 'fieldConditionsChanged']))
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('已被素材使用的模板不应允许修改素材大类', async () => {
prisma.uploadTemplate.findUnique.mockResolvedValue({
id: 'template-1',

View File

@ -5,7 +5,7 @@ import { prisma } from '@/lib/prisma'
import { Prisma } from '@prisma/client'
import { syncFieldPoolFromTemplates } from '@/lib/field-pool'
import { canUseCustomTemplates } from '@/lib/license'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { isCurrentAdmin, isCurrentSuperAdmin } from '@/lib/admin-permissions'
import {
isValidUploadTemplateStatus,
validateAndFormatUploadTemplateFields,
@ -51,7 +51,7 @@ const baseSelect = {
export async function GET() {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
if (!session || !(await isCurrentAdmin(session))) {
console.warn('[TemplateList] Unauthorized access attempt')
return NextResponse.json({
error: '无权限',
@ -98,7 +98,7 @@ export async function POST(req: NextRequest) {
let body: any = null
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
if (!session || !(await isCurrentAdmin(session))) {
return NextResponse.json({ error: '无权限' }, { status: 403 })
}
if (!(await isCurrentSuperAdmin(session))) {

View File

@ -0,0 +1,59 @@
import { GET } from '../route'
export {}
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
$queryRaw: jest.fn(),
uploadTemplate: {
findMany: jest.fn(),
},
},
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
describe('GET /api/material-types 权限与模板过滤', () => {
beforeEach(() => {
jest.resetAllMocks()
prisma.$queryRaw.mockResolvedValue([{ enabledBuiltinTemplates: null }])
prisma.uploadTemplate.findMany.mockResolvedValue([])
})
it('未登录时拒绝读取素材类型', async () => {
getServerSession.mockResolvedValue(null)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('请先登录')
expect(prisma.uploadTemplate.findMany).not.toHaveBeenCalled()
})
it('只返回 active 自定义模板,不回退暴露草稿或归档模板', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.uploadTemplate.findMany.mockResolvedValue([
{ id: 'custom-active', name: '客户模板素材模板', materialType: '客户模板', status: 'active' },
])
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toContain('template:custom-active:客户模板')
expect(prisma.uploadTemplate.findMany).toHaveBeenCalledTimes(1)
expect(prisma.uploadTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ status: 'active' }),
}))
})
})

View File

@ -5,6 +5,8 @@
*/
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import {
BUILTIN_TEMPLATE_KEYS,
@ -19,6 +21,14 @@ export async function GET() {
console.error('[material-types] ========== API CALLED ==========')
console.error('[material-types] Timestamp:', new Date().toISOString())
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
)
}
let enabledBuiltinTemplates: string[] = []
try {
const settings = await prisma.$queryRaw<Array<{ enabledBuiltinTemplates?: string | null }>>`
@ -67,33 +77,6 @@ export async function GET() {
console.error('[material-types] Active templates:', JSON.stringify(customTemplates.map(t => ({ id: t.id, name: t.name, status: t.status })), null, 2))
}
// 如果没有找到活跃的自定义模板,查询所有非内置模板用于调试
if (customTemplates.length === 0) {
console.error('[material-types] WARNING: No active custom templates found, querying all non-builtin templates for debugging...')
const allTemplates = await prisma.uploadTemplate.findMany({
where: {
NOT: {
id: {
startsWith: 'builtin-'
}
}
},
select: { id: true, name: true, materialType: true, status: true },
})
console.error(`[material-types] Total custom templates in DB: ${allTemplates.length}`)
if (allTemplates.length > 0) {
console.error('[material-types] All custom template statuses:', JSON.stringify(allTemplates.map(t => ({ id: t.id, name: t.name, status: t.status })), null, 2))
// 如果数据库中有模板但status不是'active',尝试查询所有非隐藏的自定义模板
const nonHiddenTemplates = allTemplates.filter(
(t) => t.status !== 'hidden' && t.status !== null
)
console.error(`[material-types] Non-hidden custom templates: ${nonHiddenTemplates.length}`)
if (nonHiddenTemplates.length > 0) {
console.error('[material-types] Using non-hidden custom templates instead of active templates')
customTemplates = nonHiddenTemplates
}
}
}
} catch (error) {
console.error('[material-types] Error querying custom templates:', error)
console.error('[material-types] Error details:', JSON.stringify(error, null, 2))

View File

@ -15,6 +15,12 @@ jest.mock('@/lib/prisma', () => ({
material: {
findMany: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
department: {
findMany: jest.fn(),
},
},
}))
@ -57,6 +63,20 @@ describe('GET /api/materials/asset', () => {
jest.resetAllMocks()
isS3Url.mockImplementation((value?: string | null) => /^https?:\/\//.test(value || ''))
isPublicShareEnabled.mockResolvedValue(true)
prisma.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
isActive: true,
canDownload: true,
department: '设计部',
managedDepartment: null,
})
prisma.department.findMany.mockResolvedValue([
{ name: '设计部' },
{ name: '设计部-三维组' },
{ name: '运营部' },
])
})
it('未登录时拒绝读取素材文件', async () => {
@ -109,6 +129,109 @@ describe('GET /api/materials/asset', () => {
expect(getS3ObjectStream).toHaveBeenCalledWith(fileUrl, null)
})
it('登录用户读取素材文件时会套用自己的素材范围', async () => {
const fileUrl = 'http://minio:9000/bucket/preview.webp'
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.material.findMany.mockResolvedValue([buildMaterialRecord({ previewUrl: fileUrl })])
getS3ObjectStream.mockResolvedValue({
body: Readable.from([Buffer.from('asset-body')]),
contentLength: 10,
contentType: 'image/webp',
acceptRanges: 'bytes',
})
const response = await GET(
new NextRequest(`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}`)
)
expect(response.status).toBe(200)
expect(prisma.material.findMany.mock.calls[0][0].where.AND).toEqual([
{
OR: [
{ userId: 'user-1' },
{ ownerType: 'USER', ownerId: 'user-1' },
],
},
])
})
it('部门管理员读取素材文件时只放行自己和管理部门范围', async () => {
const fileUrl = 'http://minio:9000/bucket/preview.webp'
getServerSession.mockResolvedValue({ user: { id: 'manager-1', role: 'ADMIN' } })
prisma.user.findUnique.mockResolvedValueOnce({
id: 'manager-1',
role: 'ADMIN',
isSuperAdmin: false,
isActive: true,
canDownload: true,
department: '设计部',
managedDepartment: '设计部',
})
prisma.material.findMany.mockResolvedValue([buildMaterialRecord({ previewUrl: fileUrl })])
getS3ObjectStream.mockResolvedValue({
body: Readable.from([Buffer.from('asset-body')]),
contentLength: 10,
contentType: 'image/webp',
acceptRanges: 'bytes',
})
const response = await GET(
new NextRequest(`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}`)
)
expect(response.status).toBe(200)
expect(prisma.material.findMany.mock.calls[0][0].where.AND).toEqual([
{
OR: expect.arrayContaining([
{ userId: 'manager-1' },
{ ownerType: 'USER', ownerId: 'manager-1' },
{ user: { department: { in: expect.arrayContaining(['设计部', '设计部-三维组']) } } },
{ user: { department: { startsWith: '设计部-' } } },
]),
},
])
})
it('禁用用户即使带旧会话也不能读取素材文件', async () => {
const fileUrl = 'http://minio:9000/bucket/preview.webp'
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.user.findUnique.mockResolvedValueOnce({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
isActive: false,
canDownload: true,
department: '设计部',
managedDepartment: null,
})
const response = await GET(
new NextRequest(`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}`)
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('账号不可用')
expect(prisma.material.findMany).not.toHaveBeenCalled()
expect(getS3ObjectStream).not.toHaveBeenCalled()
})
it('不允许通过素材代理读取原始素材包,避免绕过下载权限', async () => {
const fileUrl = 'http://minio:9000/bucket/material.zip'
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.material.findMany.mockResolvedValue([buildMaterialRecord({ zipFilePath: fileUrl })])
const response = await GET(
new NextRequest(`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}`)
)
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('文件不属于当前素材库记录')
expect(JSON.stringify(prisma.material.findMany.mock.calls[0][0].where.OR)).not.toContain('zipFilePath')
expect(getS3ObjectStream).not.toHaveBeenCalled()
})
it('未登录但分享票据有效时允许读取素材文件', async () => {
const fileUrl = 'http://minio:9000/bucket/preview.webp'
getServerSession.mockResolvedValue(null)

View File

@ -6,6 +6,8 @@ import { getS3ObjectStream, isS3Url } from '@/lib/s3'
import { findValidMaterialShareToken } from '@/lib/material-share-token'
import { createLocalFileStreamResponse } from '@/lib/local-file-stream'
import { isPublicShareEnabled } from '@/lib/public-share-settings'
import { buildMaterialManagementScopeWhere, getMaterialPermissionActor } from '@/lib/material-permissions'
import type { Prisma } from '@prisma/client'
import { access } from 'fs/promises'
import path from 'path'
import { Readable } from 'stream'
@ -40,7 +42,22 @@ export async function GET(req: NextRequest) {
)
}
const materialId = await findKnownMaterialFileId(fileUrl)
let materialId: string | null = null
if (session?.user) {
const actor = await getMaterialPermissionActor(session)
if (!actor) {
return NextResponse.json(
{ error: '账号不可用,请重新登录或联系管理员' },
{ status: 403 }
)
}
const scopeWhere = await buildMaterialManagementScopeWhere(actor)
materialId = await findKnownMaterialFileId(fileUrl, scopeWhere)
} else {
materialId = await findKnownMaterialFileId(fileUrl)
}
if (!materialId) {
return NextResponse.json(
{ error: '文件不属于当前素材库记录' },
@ -134,33 +151,33 @@ function isAllowedMaterialFile(fileUrl: string) {
return Boolean(resolveLocalMaterialPath(fileUrl))
}
async function findKnownMaterialFileId(fileUrl: string) {
async function findKnownMaterialFileId(fileUrl: string, scopeWhere: Prisma.MaterialWhereInput = {}) {
const where: Prisma.MaterialWhereInput = {
deletedAt: null,
OR: [
{ previewUrl: fileUrl },
{ videoCoverUrl: fileUrl },
{ videoPreview540Url: fileUrl },
{ videoPreviewClipUrl: fileUrl },
{ displayImages: { contains: fileUrl } },
{ previewUrls: { contains: fileUrl } },
{ metadata: { contains: fileUrl } },
],
}
if (Object.keys(scopeWhere).length > 0) {
where.AND = [scopeWhere]
}
const candidates = await prisma.material.findMany({
where: {
deletedAt: null,
OR: [
{ previewUrl: fileUrl },
{ zipFilePath: fileUrl },
{ videoSourceUrl: fileUrl },
{ videoCoverUrl: fileUrl },
{ videoPreview540Url: fileUrl },
{ videoPreviewClipUrl: fileUrl },
{ audioUrl: fileUrl },
{ displayImages: { contains: fileUrl } },
{ previewUrls: { contains: fileUrl } },
{ metadata: { contains: fileUrl } },
],
},
where,
take: 50,
select: {
id: true,
previewUrl: true,
zipFilePath: true,
videoSourceUrl: true,
videoCoverUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
audioUrl: true,
displayImages: true,
previewUrls: true,
metadata: true,
@ -175,12 +192,9 @@ async function findKnownMaterialFileId(fileUrl: string) {
function materialContainsFileUrl(
material: {
previewUrl: string
zipFilePath: string
videoSourceUrl: string | null
videoCoverUrl: string | null
videoPreview540Url: string | null
videoPreviewClipUrl: string | null
audioUrl: string | null
displayImages: string
previewUrls: string | null
metadata: string | null
@ -189,12 +203,9 @@ function materialContainsFileUrl(
) {
const directFields = [
material.previewUrl,
material.zipFilePath,
material.videoSourceUrl,
material.videoCoverUrl,
material.videoPreview540Url,
material.videoPreviewClipUrl,
material.audioUrl,
]
if (directFields.some((value) => value === fileUrl)) {

View File

@ -0,0 +1,143 @@
import { GET } from '../route'
export {}
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
count: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
department: {
findMany: jest.fn(),
},
$queryRaw: jest.fn(),
},
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
describe('GET /api/materials/stats', () => {
beforeEach(() => {
jest.resetAllMocks()
prisma.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
isActive: true,
canDownload: true,
department: '设计部',
managedDepartment: null,
})
prisma.department.findMany.mockResolvedValue([
{ name: '设计部' },
{ name: '设计部-建模组' },
{ name: '运营部' },
])
})
it('未登录时不暴露素材总量和月份趋势', async () => {
getServerSession.mockResolvedValue(null)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('请先登录')
expect(prisma.material.count).not.toHaveBeenCalled()
})
it('已登录时返回素材统计', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-1' } })
prisma.material.count.mockResolvedValue(12)
prisma.$queryRaw.mockResolvedValue([
{ year: 2026, month: 6, count: BigInt(5) },
])
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data.total).toBe(12)
expect(data.months).toEqual([
{ value: '2026-06', label: '2026年6月', count: 5 },
])
expect(prisma.material.count).toHaveBeenCalledWith({
where: {
AND: [
{ deletedAt: null },
{
OR: [
{ userId: 'user-1' },
{ ownerType: 'USER', ownerId: 'user-1' },
],
},
],
},
})
})
it('禁用用户即使有旧会话也不能读取统计', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-1' } })
prisma.user.findUnique.mockResolvedValueOnce({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
isActive: false,
canDownload: true,
department: '设计部',
managedDepartment: null,
})
const response = await GET()
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('账号不可用')
expect(prisma.material.count).not.toHaveBeenCalled()
expect(prisma.$queryRaw).not.toHaveBeenCalled()
})
it('部门管理员统计只覆盖自己和管理部门范围内的素材', async () => {
getServerSession.mockResolvedValue({ user: { id: 'manager-1', role: 'ADMIN' } })
prisma.user.findUnique.mockResolvedValueOnce({
id: 'manager-1',
role: 'ADMIN',
isSuperAdmin: false,
isActive: true,
canDownload: true,
department: '设计部',
managedDepartment: '设计部',
})
prisma.material.count.mockResolvedValue(3)
prisma.$queryRaw.mockResolvedValue([
{ year: 2026, month: 5, count: BigInt(3) },
])
const response = await GET()
expect(response.status).toBe(200)
expect(prisma.material.count.mock.calls[0][0].where.AND).toEqual([
{ deletedAt: null },
{
OR: expect.arrayContaining([
{ userId: 'manager-1' },
{ ownerType: 'USER', ownerId: 'manager-1' },
{ user: { department: { in: expect.arrayContaining(['设计部', '设计部-建模组']) } } },
{ user: { department: { startsWith: '设计部-' } } },
]),
},
])
})
})

View File

@ -1,15 +1,82 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { Prisma } from '@prisma/client'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { buildDepartmentDescendants } from '@/lib/departments'
import {
buildMaterialManagementScopeWhere,
getMaterialPermissionActor,
type MaterialPermissionActor,
} from '@/lib/material-permissions'
export const dynamic = 'force-dynamic'
const normalizeDepartment = (department?: string | null) => (department || '').trim()
async function buildMonthlyStatsWhereSql(actor: MaterialPermissionActor) {
const conditions: Prisma.Sql[] = [Prisma.sql`m."deletedAt" IS NULL`]
if (!actor.isSuperAdmin) {
const visibleConditions: Prisma.Sql[] = [
Prisma.sql`m."userId" = ${actor.id}`,
Prisma.sql`(m."ownerType" = 'USER' AND m."ownerId" = ${actor.id})`,
]
const scopeDepartment = normalizeDepartment(actor.managedDepartment)
if (actor.role === 'ADMIN' && scopeDepartment) {
const departments = await prisma.department.findMany({ select: { name: true } })
const descendants = buildDepartmentDescendants(departments).get(scopeDepartment)
const scopedDepartments = Array.from(descendants || new Set([scopeDepartment]))
visibleConditions.push(Prisma.sql`
EXISTS (
SELECT 1
FROM "users" u
WHERE u."id" = m."userId"
AND (
u."department" IN (${Prisma.join(scopedDepartments)})
OR u."department" LIKE ${`${scopeDepartment}-%`}
)
)
`)
}
conditions.push(Prisma.sql`(${Prisma.join(visibleConditions, ' OR ')})`)
}
return Prisma.sql`WHERE ${Prisma.join(conditions, ' AND ')}`
}
// Get material statistics (total count and monthly breakdown)
// 优化:使用SQL聚合查询,避免加载所有数据到内存
export async function GET() {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
)
}
const actor = await getMaterialPermissionActor(session)
if (!actor) {
return NextResponse.json(
{ error: '账号不可用,请重新登录或联系管理员' },
{ status: 403 }
)
}
const scopeWhere = await buildMaterialManagementScopeWhere(actor)
const where: Prisma.MaterialWhereInput = Object.keys(scopeWhere).length > 0
? { AND: [{ deletedAt: null }, scopeWhere] }
: { deletedAt: null }
const monthlyStatsWhereSql = await buildMonthlyStatsWhereSql(actor)
// 并行查询总数和按月统计
const [total, monthlyStats] = await Promise.all([
prisma.material.count(),
prisma.material.count({ where }),
prisma.$queryRaw<Array<{
year: number
month: number
@ -19,7 +86,8 @@ export async function GET() {
EXTRACT(YEAR FROM "createdAt")::int as year,
EXTRACT(MONTH FROM "createdAt")::int as month,
COUNT(*)::bigint as count
FROM "materials"
FROM "materials" m
${monthlyStatsWhereSql}
GROUP BY EXTRACT(YEAR FROM "createdAt"), EXTRACT(MONTH FROM "createdAt")
ORDER BY year DESC, month DESC
`

View File

@ -144,7 +144,7 @@ describe('GET /api/system-settings', () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-id', role: 'ADMIN', isSuperAdmin: false },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false })
prisma.user.findUnique.mockResolvedValue({ role: 'ADMIN', isSuperAdmin: false, isActive: true })
const response = await GET()
const data = await response.json()
@ -160,11 +160,26 @@ describe('GET /api/system-settings', () => {
expect(data.homeDeleteButtonMode).toBe('ADMIN_ONLY')
})
it('数据库中已禁用的管理员只能读取公开设置', async () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-id', role: 'ADMIN', isSuperAdmin: false },
})
prisma.user.findUnique.mockResolvedValue({ role: 'ADMIN', isSuperAdmin: false, isActive: false })
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data.s3Bucket).toBeUndefined()
expect(data.enableRegistration).toBeUndefined()
expect(data.headerBgColor).toBe('#1f2937')
})
it('最高权限账号登录时才返回对象存储密钥', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
prisma.user.findUnique.mockResolvedValue({ role: 'ADMIN', isSuperAdmin: true, isActive: true })
const response = await GET()
const data = await response.json()
@ -231,7 +246,7 @@ describe('GET /api/system-settings', () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-id', role: 'ADMIN', isSuperAdmin: false },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false })
prisma.user.findUnique.mockResolvedValue({ role: 'ADMIN', isSuperAdmin: false, isActive: true })
prisma.systemSettings.findFirst.mockResolvedValue(null)
const response = await GET()
@ -308,7 +323,7 @@ describe('PUT /api/system-settings 权限边界', () => {
expect(response.status).toBe(403)
expect(data.error).toContain('最高权限')
expect(ensureSystemSettingsOptionalColumns).toHaveBeenCalled()
expect(ensureSystemSettingsOptionalColumns).not.toHaveBeenCalled()
expect(canUseSystemSettings).not.toHaveBeenCalled()
expect(prisma.systemSettings.update).not.toHaveBeenCalled()
})
@ -366,7 +381,7 @@ describe('PUT /api/system-settings 权限边界', () => {
getServerSession.mockResolvedValue({
user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
prisma.user.findUnique.mockResolvedValue({ role: 'ADMIN', isSuperAdmin: true, isActive: true })
prisma.systemSettings.findFirst.mockResolvedValue(null)
prisma.systemSettings.create.mockResolvedValue({
...mockSettings,

View File

@ -6,7 +6,7 @@ import { defaultLogos, featureFlags } from '@/lib/config'
import { cache } from '@/lib/cache'
import { ensureSystemSettingsOptionalColumns } from '@/lib/system-settings-columns'
import { canUseSystemSettings } from '@/lib/license'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { isCurrentAdmin, isCurrentSuperAdmin } from '@/lib/admin-permissions'
const CACHE_KEY = 'system-settings'
const CACHE_TTL = 60000 // 1分钟缓存
@ -56,10 +56,6 @@ function getAdminSystemSettings(settings: SystemSettingsRow, includeSensitiveSec
return safeSettings
}
function isSystemSettingsAdmin(session: unknown) {
return Boolean((session as any)?.user?.role === 'ADMIN')
}
function normalizeLogoUrl(url: string | null | undefined) {
if (!url) return ''
const trimmed = url.trim()
@ -99,7 +95,7 @@ function buildPublicFallbackSettings(): SystemSettingsRow {
export async function GET() {
try {
const session = await getServerSession(authOptions)
const canReadAdminSettings = isSystemSettingsAdmin(session)
const canReadAdminSettings = await isCurrentAdmin(session)
const canReadSensitiveSecrets = canReadAdminSettings && await isCurrentSuperAdmin(session)
// 先尝试从缓存获取
@ -196,7 +192,6 @@ export async function GET() {
// PUT - 更新系统设置(最高权限)
export async function PUT(request: NextRequest) {
try {
await ensureSystemSettingsOptionalColumns()
const session = await getServerSession(authOptions)
if (!session || !(await isCurrentSuperAdmin(session))) {
@ -206,6 +201,8 @@ export async function PUT(request: NextRequest) {
)
}
await ensureSystemSettingsOptionalColumns()
// 检查系统设置功能是否可用(免费版只能查看,不能修改)
if (!(await canUseSystemSettings())) {
return NextResponse.json(

View File

@ -244,10 +244,10 @@ export default function Header({ session, compactMode }: HeaderProps) {
}}
>
<div className="container mx-auto px-4 py-3 max-w-7xl">
<div className="flex items-center justify-between gap-6 h-12">
<div className="flex items-center justify-between gap-3 sm:gap-6 h-12">
{/* Logo + 首页收缩模式下的分类与搜索卡片 */}
<div className="flex items-center gap-4 flex-1 min-w-0 h-full">
<Link href="/" className="flex items-center space-x-3 flex-shrink-0 h-full">
<Link href="/" className="flex min-w-0 items-center space-x-3 h-full">
{headerLogoUrl && !logoError ? (
<Image
key={logoRetryCount}
@ -327,7 +327,7 @@ export default function Header({ session, compactMode }: HeaderProps) {
)}
<span
className={cn(
'text-2xl font-semibold transition-colors',
'max-w-[150px] truncate text-xl font-semibold transition-colors sm:max-w-none sm:text-2xl',
headerPrimaryTextClass
)}
>
@ -482,10 +482,10 @@ export default function Header({ session, compactMode }: HeaderProps) {
</div>
{/* Navigation */}
<nav className="flex items-center space-x-4">
<nav className="flex shrink-0 items-center space-x-2 sm:space-x-4">
{/* Network Status Indicator */}
{networkChecked && (
<div className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
<div className={`flex items-center gap-1.5 px-2 py-1.5 rounded-full text-xs font-medium transition-colors sm:px-3 ${
isInternalNetwork
? 'bg-green-500/10 text-green-400 border border-green-500/20'
: 'bg-blue-500/10 text-blue-400 border border-blue-500/20'
@ -493,7 +493,7 @@ export default function Header({ session, compactMode }: HeaderProps) {
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="10" r="4"/>
</svg>
<span>{isInternalNetwork ? '内网' : '公网'}</span>
<span className="hidden sm:inline">{isInternalNetwork ? '内网' : '公网'}</span>
</div>
)}
{/* Theme toggle */}
@ -525,7 +525,7 @@ export default function Header({ session, compactMode }: HeaderProps) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<svg
className={`w-4 h-4 transition-colors ${theme === 'dark' ? 'text-indigo-200' : 'text-white/35'}`}
className={`w-4 h-4 transition-colors ${theme === 'dark' ? 'text-slate-200' : 'text-white/35'}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@ -549,7 +549,7 @@ export default function Header({ session, compactMode }: HeaderProps) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<svg
className={`absolute inset-0 m-auto w-3.5 h-3.5 transition-opacity ${theme === 'dark' ? 'opacity-100 text-indigo-400' : 'opacity-0'}`}
className={`absolute inset-0 m-auto w-3.5 h-3.5 transition-opacity ${theme === 'dark' ? 'opacity-100 text-slate-600' : 'opacity-0'}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"

View File

@ -1367,13 +1367,13 @@ const isVideoFile = (file: File) => {
className="hidden"
/>
{previewImages.length === 0 ? (
<div className="flex flex-wrap items-center gap-4 sm:flex-nowrap">
<div className="flex flex-wrap items-center gap-4">
<div className={cn(uploadTileClass, 'w-14 h-14 sm:w-16 sm:h-16')}>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div className="text-left flex-1 min-w-[200px] space-y-1">
<div className="text-left flex-1 min-w-0 space-y-1">
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">
上传{isVideoTemplate ? '视频封面(可选,最多5张)' : '预览图(最多5张)'}
</p>
@ -1634,7 +1634,7 @@ const isVideoFile = (file: File) => {
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-4">
模板字段
</h3>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
{visibleTemplateFields.map((field: any) => {
const fieldValue = templateFieldValues[field.key] || ''
const isHalfWidth = field.layoutWidth === 'half'
@ -1643,13 +1643,13 @@ const isVideoFile = (file: File) => {
return (
<div
key={field.id}
className={isHalfWidth ? 'w-full sm:w-1/2 inline-block sm:pr-2' : 'w-full'}
className={isHalfWidth ? 'min-w-0 sm:col-span-1' : 'min-w-0 sm:col-span-2'}
>
<div className="space-y-2.5">
<div className="flex items-center justify-between gap-3">
<label
htmlFor={field.type === 'radio' ? undefined : fieldInputId}
className="text-xs font-semibold text-slate-800 dark:text-slate-200 tracking-tight"
className="text-xs font-semibold text-slate-800 dark:text-slate-200"
>
{field.label}
</label>

View File

@ -579,6 +579,34 @@ read_env_value() {
' .env | tail -n 1
}
is_external_database() {
database_url="$(read_env_value DATABASE_URL || true)"
if [ -z "$database_url" ]; then
return 1
fi
db_host="$(printf '%s' "$database_url" | sed -E 's#^[^@]*@([^/:?]+).*$#\1#')"
if [ "$db_host" = "$database_url" ]; then
return 1
fi
[ "$db_host" != "postgres" ]
}
is_external_object_storage() {
endpoint="$(read_env_value S3_ENDPOINT || true)"
if [ -z "$endpoint" ]; then
return 1
fi
storage_host="$(printf '%s' "$endpoint" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://([^/:]+).*$#\1#')"
if [ "$storage_host" = "$endpoint" ]; then
return 1
fi
[ "$storage_host" != "minio" ]
}
get_service_container_id() {
docker compose -f "$compose_file" ps -q "$compose_service" 2>/dev/null || true
}
@ -622,7 +650,7 @@ write_restore_plan() {
echo ""
echo "L3 对象存储恢复(执行前必须确认不会覆盖新上传素材):"
echo " docker compose -f \"$compose_file\" stop \"$compose_service\" minio"
echo " docker run --rm -v \"${minio_volume_name}:/target\" -v \"$(pwd)/${backup_dir#./}:/backup:ro\" \"$remote_backup_helper_image\" sh -c 'rm -rf /target/* && tar -xzf /backup/$object_backup_name -C /target'"
echo " docker run --rm -v \"${minio_volume_name}:/target\" -v \"$(pwd)/${backup_dir#./}:/backup:ro\" \"$remote_backup_helper_image\" sh -c 'rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true; cd /target && tar -xzf /backup/$object_backup_name'"
echo " docker compose -f \"$compose_file\" up -d"
fi
echo ""
@ -681,12 +709,32 @@ rollback() {
exit "$code"
}
if [ "$rollback_enabled" = "1" ]; then
trap rollback ERR
fi
enter_maintenance_for_backup() {
if [ -n "$old_container_id" ]; then
echo "进入升级维护窗口:暂时停止 app 服务,避免备份期间继续写入数据库或素材文件"
docker compose -f "$compose_file" stop "$compose_service" || true
printf '%s\n' "已在升级备份前停止 app 服务,以减少数据库与对象存储备份不一致风险。" > "$backup_dir/APP_STOPPED_FOR_BACKUP.txt"
else
echo "未发现运行中的 app 服务,跳过停写入步骤"
fi
}
backup_postgres() {
if [ "$remote_db_backup_enabled" != "1" ]; then
echo "已跳过远程数据库备份"
return
fi
if is_external_database; then
echo "检测到 DATABASE_URL 指向外部数据库,跳过 Compose 内置 postgres 备份"
printf '%s\n' "检测到 DATABASE_URL 指向外部数据库。本脚本不会自动备份外部数据库,请按客户现场数据库方案单独备份。" > "$backup_dir/EXTERNAL_DATABASE_BACKUP_REQUIRED.txt"
return
fi
postgres_id="$(docker compose -f "$compose_file" ps -q postgres 2>/dev/null || true)"
if [ -z "$postgres_id" ]; then
echo "未发现 Compose 管理的 postgres 服务,可能使用外部数据库;请确认已有外部备份"
@ -707,6 +755,12 @@ backup_minio_volume() {
return
fi
if is_external_object_storage; then
echo "检测到 S3_ENDPOINT 指向外部对象存储,跳过 Compose 内置 MinIO 卷备份"
printf '%s\n' "检测到 S3_ENDPOINT 指向外部 S3 / MinIO。本脚本不会自动备份外部对象存储,请按客户现场方案单独备份素材对象。" > "$backup_dir/EXTERNAL_OBJECT_STORAGE_BACKUP_REQUIRED.txt"
return
fi
minio_id="$(docker compose -f "$compose_file" ps -q minio 2>/dev/null || true)"
if [ -z "$minio_id" ]; then
echo "未发现 Compose 管理的 minio 服务,可能使用外部对象存储;请确认已有外部对象存储备份"
@ -778,15 +832,12 @@ verify_running_image() {
echo "数据库迁移状态校验通过"
}
enter_maintenance_for_backup
backup_postgres
backup_minio_volume
write_restore_plan
'@)
if (-not $NoRollback) {
$lines.Add("trap rollback ERR")
}
if ($UpdateRemoteEnv) {
$lines.Add("upsert_env APP_IMAGE $quotedRegistryImage")
$lines.Add("upsert_env NEXTAUTH_URL $quotedNextAuthUrl")
@ -822,13 +873,20 @@ write_restore_plan
function Test-RemoteHealth {
Assert-RemoteServerConfigured
$url = "http://${Server}:${AppPort}/api/health"
$baseUrl = if (-not [string]::IsNullOrWhiteSpace($AppPublicUrl)) {
$AppPublicUrl
} elseif (-not [string]::IsNullOrWhiteSpace($NextAuthUrl)) {
$NextAuthUrl
} else {
"http://${Server}:${AppPort}"
}
$url = $baseUrl.TrimEnd("/") + "/api/health"
Write-Info "检查应用健康状态:$url"
if ($DryRun) {
return
}
$deadline = (Get-Date).AddSeconds(60)
$deadline = (Get-Date).AddSeconds($RemoteHealthTimeoutSec)
do {
try {
$resp = Invoke-WebRequest -Uri $url -TimeoutSec 5 -ErrorAction Stop
@ -841,7 +899,7 @@ function Test-RemoteHealth {
}
} while ((Get-Date) -lt $deadline)
throw "部署命令执行完成,但健康检查没有在 60 秒内通过:$url"
throw "部署命令执行完成,但健康检查没有在 $RemoteHealthTimeoutSec 秒内通过:$url"
}
function Invoke-ProjectCheck {

View File

@ -215,6 +215,7 @@ docker logs flnxi-material --tail 100
`DeployApp` 远程部署现在默认包含第一轮升级保护:
- 升级前在远端创建 `backups/deploy-时间戳/`,保存升级前 `.env`。
- 发现已有 app 容器运行时,会先短暂停止 app,让系统进入升级维护窗口,减少备份期间继续上传、编辑或删除素材造成的数据库和对象存储不一致。
- 如果远端 PostgreSQL 是当前 Compose 管理的 `postgres` 服务,会先执行 `pg_dump` 备份;如果使用外部数据库,会提示需要确认外部备份。
- 如果远端 MinIO 是当前 Compose 管理的 `minio` 服务,会先把 `MINIO_VOLUME_NAME` 对应的数据卷打包备份;如果使用外部 S3 / 外部 MinIO,会在备份目录写入提示文件,要求按客户现场方案单独备份对象存储。
- 备份目录会生成 `RESTORE_PLAN.txt`,把本次 `.env`、PostgreSQL、MinIO 备份文件和 L1/L2/L3 恢复命令列出来,便于升级失败时按影响范围恢复。
@ -241,7 +242,7 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
- 客户升级和内部发布优先按版本 tag 走,不要把 `latest` 当成客户部署依据。
- 客户现场部署使用 `-DeployChannel release -Version x.x.x`;测试通道只用于内部验证,必须显式传 `-DeployChannel test`。
- `DeployApp` 远程部署时默认会更新远端 `.env` 的 `APP_IMAGE`、访问地址和运行时镜像;只有临时验证时才使用 `-NoUpdateRemoteEnv`。
- `DeployApp` 默认会做远端 `.env` 备份、Compose PostgreSQL 备份、Compose MinIO 备份、恢复分级说明、失败自动回滚、运行镜像校验、版本文件校验和 Prisma 迁移状态校验。
- `DeployApp` 默认会做远端维护窗口停写入、`.env` 备份、Compose PostgreSQL 备份、Compose MinIO 备份、恢复分级说明、失败自动回滚、运行镜像校验、版本文件校验和 Prisma 迁移状态校验。
- 旧的 `deploy-new-fixed.ps1` 仍可作为内部维护参考,但不再作为当前主线部署入口。
- 如果要做远程更新,先确认 `Registry`、SSH、`.env` 和目标机器都属于当前维护环境。
@ -252,10 +253,13 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
```powershell
.\scripts\measure-image-size.ps1 `
-Image "registry.example.com/flnxi/material/flnxi-material:1.0.1" `
-OutputPath ".\reports\image-size-1.0.1.md"
-OutputPath ".\reports\image-size-1.0.1.md" `
-MaxImageSizeMB 650 `
-MaxLayerCount 80 `
-MaxLargestLayerMB 250
```
这个脚本不会改镜像,只会读取 Docker 本地镜像信息,列出总大小、层数和最大的若干层。后续做镜像瘦身时,先用这个报告确认是 `ffmpeg`、字体、`canvas`、`node_modules` 还是 Prisma 迁移工具占用更大,再决定是否拆成可选能力或独立迁移镜像。
这个脚本不会改镜像,只会读取 Docker 本地镜像信息,列出总大小、层数和最大的若干层。传入 `-MaxImageSizeMB`、`-MaxLayerCount` 或 `-MaxLargestLayerMB` 后,超出阈值会以非 0 退出,适合发布前挡住明显变胖的镜像。后续做镜像瘦身时,先用这个报告确认是 `ffmpeg`、字体、`canvas`、`node_modules` 还是 Prisma 迁移工具占用更大,再决定是否拆成可选能力或独立迁移镜像。
## 5. 部署前检查清单
@ -351,8 +355,11 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
- `compose-config.txt`、`compose-ps.txt`、`docker-images.txt`
- Compose 管理的 PostgreSQL:`postgres.sql`
- Compose 管理的 MinIO 数据卷:`minio-data.tar.gz`
- 本地挂载目录:`data.zip`、`uploads.zip`,用于保留许可证实例数据和历史本地上传残留
- `manifest.json`:记录备份时间、应用镜像、访问地址、服务名等关键信息
备份脚本默认也会在备份期间短暂停止 app,并在备份结束后恢复启动,目的是让数据库、MinIO、`data/`、`uploads/` 尽量处在同一个时间点。只有客户现场已经有其它停写入机制时,才考虑加 `-SkipStopAppForBackup`。
`DeployApp` 升级时生成的 `backups/deploy-时间戳/RESTORE_PLAN.txt` 是升级恢复指引;`backup-restore.ps1 -Action Backup` 生成的 `flnxi-backup-时间戳/manifest.json` 是完整备份包记录。两者用途不同,客户升级失败时优先看当次升级目录里的 `RESTORE_PLAN.txt`。
如果 `DATABASE_URL` 指向外部 PostgreSQL,脚本不会假装自动备份,会在备份目录写入 `EXTERNAL_DATABASE_BACKUP_REQUIRED.txt`,客户现场必须按外部数据库方案单独备份。
@ -387,7 +394,7 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
说明:
- 默认恢复 `postgres.sql` 和 `minio-data.tar.gz`,然后启动服务并执行健康验收。
- 默认恢复 `postgres.sql`、`minio-data.tar.gz`、`data.zip` 和 `uploads.zip`,然后启动服务并执行健康验收。
- 如果只恢复数据库、不恢复对象存储,加 `-SkipObjectStorageRestore`。
- 如果需要把备份包中的 `.env` 一起恢复,加 `-RestoreEnv`;脚本会先把当前 `.env` 另存为 `.env.before-restore-时间戳`。
- 外部数据库和外部对象存储不受该脚本自动恢复,必须按客户现场备份方案操作。

View File

@ -20,4 +20,11 @@ describe('flnxi-agent-go/install.sh', () => {
expect(script).toContain('LOCAL_ENCRYPTION_KEY=${LOCAL_ENCRYPTION_KEY:-}')
expect(script).toContain('未完整配置许可证服务参数')
})
it('文案明确这是 Agent 安装脚本,不冒充客户最终首装入口', () => {
expect(script).toContain('FLNXI Agent 安装脚本')
expect(script).toContain('Agent 安装脚本')
expect(script).toContain('Agent 不是唯一安装入口')
expect(script).not.toContain('FLNXI 一键部署脚本')
})
})

View File

@ -54,7 +54,7 @@ show_banner() {
| _| | |___| |\ |/ \ | |
|_| |_____|_| \_/_/\_\|___|
智能运维平台 - 一键部署脚本
智能运维平台 - Agent 安装脚本
EOF
echo -e "${NC}"
echo ""
@ -344,7 +344,7 @@ parse_args() {
# 显示帮助信息
show_help() {
echo "FLNXI 一键部署脚本"
echo "FLNXI Agent 安装脚本"
echo ""
echo "用法: curl -sSL https://install.flnxi.com | bash -s -- [选项]"
echo ""

View File

@ -117,7 +117,7 @@ function composeCardStyles(colors: ColorTheme) {
function composeButtonStyles(colors: ColorTheme) {
return {
primary: `${baseStyles.baseButtonStyles.primary} ${colors.button.primary.bg} ${colors.button.primary.hover} ${colors.button.primary.shadow} ${colors.button.primary.shadowHover}`,
secondary: `${baseStyles.baseButtonStyles.secondary} ${colors.button.secondary.border} ${colors.button.secondary.bg} ${colors.button.secondary.text} ${colors.button.secondary.hover} hover:border-indigo-400 dark:hover:border-indigo-400`,
secondary: `${baseStyles.baseButtonStyles.secondary} ${colors.button.secondary.border} ${colors.button.secondary.bg} ${colors.button.secondary.text} ${colors.button.secondary.hover} hover:border-slate-400 dark:hover:border-slate-500`,
ghost: `${baseStyles.baseButtonStyles.ghost} ${colors.button.ghost.text} ${colors.button.ghost.hover}`,
danger: `${baseStyles.baseButtonStyles.danger} ${colors.button.danger.bg} ${colors.button.danger.hover} ${colors.button.danger.shadow} ${colors.button.danger.shadowHover}`,
success: `${baseStyles.baseButtonStyles.success} ${colors.button.success.bg} ${colors.button.success.hover} ${colors.button.success.shadow} ${colors.button.success.shadowHover}`,
@ -133,7 +133,7 @@ function composeButtonStyles(colors: ColorTheme) {
*/
function composeInputStyles(colors: ColorTheme) {
return {
base: `${baseStyles.baseInputStyles.base} ${colors.input.border} ${colors.input.bg} ${colors.input.text} ${colors.input.placeholder} ${colors.input.focusRing} focus:border-indigo-500 dark:focus:border-indigo-400`,
base: `${baseStyles.baseInputStyles.base} ${colors.input.border} ${colors.input.bg} ${colors.input.text} ${colors.input.placeholder} ${colors.input.focusRing} focus:border-slate-500 dark:focus:border-slate-300`,
error: `${baseStyles.baseInputStyles.error} ${colors.input.errorBorder} ${colors.input.errorBg} ${colors.input.errorText} ${colors.input.errorPlaceholder} ${colors.input.errorFocusRing}`,
} as const
}
@ -144,8 +144,8 @@ function composeInputStyles(colors: ColorTheme) {
function composeNavItemStyles(colors: ColorTheme) {
return {
base: `${baseStyles.baseNavItemStyles.base} ${colors.navItem.border} ${colors.navItem.bg} ${colors.navItem.text}`,
hover: `${baseStyles.baseNavItemStyles.hover} ${colors.navItem.border} ${colors.navItem.bg} ${colors.navItem.hoverBg} ${colors.navItem.hoverShadow} ${colors.navItem.text} hover:border-indigo-400 dark:hover:border-indigo-400`,
active: `${baseStyles.baseNavItemStyles.active} ${colors.navItem.activeBg} ${colors.navItem.activeText} ${colors.navItem.activeShadow} border-indigo-500 dark:border-indigo-400`,
hover: `${baseStyles.baseNavItemStyles.hover} ${colors.navItem.border} ${colors.navItem.bg} ${colors.navItem.hoverBg} ${colors.navItem.hoverShadow} ${colors.navItem.text} hover:border-slate-400 dark:hover:border-slate-500`,
active: `${baseStyles.baseNavItemStyles.active} ${colors.navItem.activeBg} ${colors.navItem.activeText} ${colors.navItem.activeShadow} border-slate-900 dark:border-slate-100`,
rainbow: `${baseStyles.baseNavItemStyles.rainbow} ${colors.navItem.border} ${colors.navItem.rainbowBg} ${colors.navItem.rainbowShadow} ${colors.navItem.text}`,
tabBase: `${baseStyles.baseNavItemStyles.tabBase} ${colors.navItem.tabText} ${colors.navItem.tabTextHover}`,
tabActive: `${baseStyles.baseNavItemStyles.tabActive} ${colors.navItem.tabTextActive}`,

View File

@ -105,7 +105,7 @@ export const navItemStyles = {
export const badgeStyles = {
base: 'px-3 py-1 rounded-lg text-xs font-medium',
primary: 'px-3 py-1 rounded-lg bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 text-xs font-medium',
primary: 'px-3 py-1 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-medium',
success: 'px-3 py-1 rounded-lg bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs font-medium',
warning: 'px-3 py-1 rounded-lg bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 text-xs font-medium',
danger: 'px-3 py-1 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-xs font-medium',

View File

@ -27,8 +27,8 @@ export const themeColors = {
// 按钮颜色
button: {
primary: {
bg: 'bg-indigo-500',
hover: 'hover:bg-indigo-600',
bg: 'bg-slate-100 text-slate-950',
hover: 'hover:bg-white',
shadow: 'shadow-none',
shadowHover: '',
},
@ -67,7 +67,7 @@ export const themeColors = {
bg: 'bg-slate-800',
text: 'text-white',
placeholder: 'placeholder:text-slate-400',
focusRing: 'focus:ring-indigo-800',
focusRing: 'focus:ring-slate-700',
errorBorder: 'border-red-600',
errorBg: 'bg-slate-800',
errorText: 'text-white',
@ -95,8 +95,8 @@ export const themeColors = {
// 徽章颜色
badge: {
primary: {
bg: 'bg-indigo-900/30',
text: 'text-indigo-300',
bg: 'bg-slate-800',
text: 'text-slate-200',
},
success: {
bg: 'bg-green-900/30',
@ -174,7 +174,7 @@ export const themeColors = {
// 开关切换器颜色
toggle: {
trackBg: 'bg-slate-700',
trackChecked: 'peer-checked:bg-indigo-500',
trackChecked: 'peer-checked:bg-slate-300',
thumbBg: 'after:bg-white',
thumbBorder: 'after:border-slate-600',
},

View File

@ -27,8 +27,8 @@ export const themeColors = {
// 按钮颜色
button: {
primary: {
bg: 'bg-indigo-600',
hover: 'hover:bg-indigo-700',
bg: 'bg-slate-900',
hover: 'hover:bg-slate-800',
shadow: 'shadow-none',
shadowHover: '',
},
@ -67,7 +67,7 @@ export const themeColors = {
bg: 'bg-white',
text: 'text-slate-900',
placeholder: 'placeholder:text-slate-400',
focusRing: 'focus:ring-indigo-200',
focusRing: 'focus:ring-slate-200',
errorBorder: 'border-red-500',
errorBg: 'bg-white',
errorText: 'text-slate-900',
@ -95,8 +95,8 @@ export const themeColors = {
// 徽章颜色
badge: {
primary: {
bg: 'bg-indigo-100',
text: 'text-indigo-700',
bg: 'bg-slate-100',
text: 'text-slate-700',
},
success: {
bg: 'bg-green-100',
@ -174,7 +174,7 @@ export const themeColors = {
// 开关切换器颜色
toggle: {
trackBg: 'bg-slate-200',
trackChecked: 'peer-checked:bg-indigo-600',
trackChecked: 'peer-checked:bg-slate-900',
thumbBg: 'after:bg-white',
thumbBorder: 'after:border-slate-300',
},

View File

@ -12,6 +12,14 @@ import {
} from './field-validation'
const ALLOWED_TEMPLATE_STATUSES = new Set(['draft', 'active', 'archived'])
export const UPLOAD_TEMPLATE_FIELD_LIMITS = {
maxFields: 80,
maxKeyLength: 64,
maxLabelLength: 80,
maxOptions: 200,
maxOptionLength: 80,
} as const
const ALLOWED_FIELD_TYPES = new Set([
'text',
'textarea',
@ -237,6 +245,18 @@ export function validateAndFormatUploadTemplateFields(
}
}
if (fields.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxFields) {
return {
valid: false,
error: `单个模板最多支持 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxFields} 个字段,请拆分模板或减少字段数量`,
field: 'fields',
details: {
maxFields: UPLOAD_TEMPLATE_FIELD_LIMITS.maxFields,
currentFields: fields.length,
},
}
}
const fieldKeys = new Set<string>()
const duplicateKeys: string[] = []
const formattedFields: FormattedUploadTemplateField[] = []
@ -278,6 +298,30 @@ export function validateAndFormatUploadTemplateFields(
}
}
if (key.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxKeyLength) {
return {
valid: false,
error: `字段“${label}”的 key 不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxKeyLength} 个字符`,
field: 'fields',
details: {
fieldKey: key,
maxKeyLength: UPLOAD_TEMPLATE_FIELD_LIMITS.maxKeyLength,
},
}
}
if (label.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxLabelLength) {
return {
valid: false,
error: `字段“${label.slice(0, 20)}...”的名称不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxLabelLength} 个字符`,
field: 'fields',
details: {
fieldKey: key,
maxLabelLength: UPLOAD_TEMPLATE_FIELD_LIMITS.maxLabelLength,
},
}
}
if (!ALLOWED_FIELD_TYPES.has(type)) {
return {
valid: false,
@ -302,6 +346,33 @@ export function validateAndFormatUploadTemplateFields(
}
const options = parseNormalizedOptions(optionsStatic)
if (options.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptions) {
return {
valid: false,
error: `字段“${label}”的选项不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptions} 个`,
field: 'fields',
details: {
fieldKey: key,
maxOptions: UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptions,
currentOptions: options.length,
},
}
}
const tooLongOption = options.find((option) => option.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptionLength)
if (tooLongOption) {
return {
valid: false,
error: `字段“${label}”的单个选项不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptionLength} 个字符`,
field: 'fields',
details: {
fieldKey: key,
maxOptionLength: UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptionLength,
sample: tooLongOption.slice(0, 20),
},
}
}
if (field.required && !addToCategory && options.length === 0) {
return {
valid: false,

View File

@ -156,13 +156,17 @@ npx tsx scripts/fix-missing-templates.ts
```powershell
.\scripts\measure-image-size.ps1 `
-Image "registry.example.com/flnxi/material/flnxi-material:1.0.1" `
-OutputPath ".\reports\image-size-1.0.1.md"
-OutputPath ".\reports\image-size-1.0.1.md" `
-MaxImageSizeMB 650 `
-MaxLayerCount 80 `
-MaxLargestLayerMB 250
```
说明:
- 该脚本只读取本地 Docker 镜像,不构建、不推送、不删除。
- 报告会列出镜像总大小、RootFS 层数和最大的若干层。
- 传入体积或分层阈值后,超限会以非 0 退出,适合发布前阻止镜像明显变胖。
- 先有基线,再做 `ffmpeg`、字体、`canvas`、Prisma CLI 或生产依赖瘦身,避免凭感觉判断体积来源。
### 手工触发单个视频转码

View File

@ -20,11 +20,26 @@ describe('scripts/backup-restore.ps1', () => {
expect(script).toContain('compose-config.txt')
expect(script).toContain('compose-ps.txt')
expect(script).toContain('docker-images.txt')
expect(script).toContain('Backup-LocalDirectory')
expect(script).toContain('data.zip')
expect(script).toContain('uploads.zip')
expect(script).toContain('manifest.json')
})
it('完整备份默认短暂停止 app,保证数据库和素材文件尽量同一时间点', () => {
expect(script).toContain('[switch]$SkipStopAppForBackup')
expect(script).toContain('Stop-AppForConsistentBackup')
expect(script).toContain('"stop", $Service')
expect(script).toContain('Restart-AppAfterConsistentBackup')
expect(script).toContain('"up", "-d", $Service')
expect(script).toContain('appStoppedForConsistentBackup')
})
it('内置数据库走 pg_dump/psql,外部数据库不假装自动备份', () => {
expect(script).toContain('Test-ExternalDatabase')
expect(script).toContain('$uri = [Uri]$databaseUrl')
expect(script).toContain('return ($uri.Host -ne "postgres")')
expect(script).not.toContain('@[^/]*postgres[^/]*:5432')
expect(script).toContain('EXTERNAL_DATABASE_BACKUP_REQUIRED.txt')
expect(script).toContain('pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"')
expect(script).toContain('psql -v ON_ERROR_STOP=1')
@ -33,9 +48,14 @@ describe('scripts/backup-restore.ps1', () => {
it('内置 MinIO 数据卷有备份和恢复路径', () => {
expect(script).toContain('Backup-ObjectStorage')
expect(script).toContain('Restore-ObjectStorage')
expect(script).toContain('Restore-LocalDirectory')
expect(script).toContain('minio-data.tar.gz')
expect(script).toContain('MINIO_VOLUME_NAME')
expect(script).toContain('$HelperImage = "busybox:1.36"')
expect(script).toContain('Test-ExternalObjectStorage')
expect(script).toContain('EXTERNAL_OBJECT_STORAGE_BACKUP_REQUIRED.txt')
expect(script).toContain('externalObjectStorage')
expect(script).toContain('脚本不会恢复 Compose 内置 MinIO 卷')
})
it('验收动作会检查 Compose、容器状态和健康接口', () => {

View File

@ -12,13 +12,20 @@ describe('deploy-registry.ps1', () => {
expect(script).toContain('[switch]$NoRemoteObjectStorageBackup')
expect(script).toContain('[string]$RemoteBackupHelperImage = "busybox:1.36"')
expect(script).toContain('env_backup="$backup_dir/.env.before-upgrade"')
expect(script).toContain('enter_maintenance_for_backup')
expect(script).toContain('APP_STOPPED_FOR_BACKUP.txt')
expect(script).toContain("docker compose -f \"$compose_file\" exec -T postgres sh -c 'pg_dump")
expect(script).toContain('test -s "$db_backup"')
expect(script).toContain('backup_minio_volume()')
expect(script).toContain('docker volume inspect "$minio_volume_name"')
expect(script).toContain('tar -czf /backup/minio-data.tar.gz')
expect(script).toContain('rm -rf /target/* /target/.[!.]* /target/..?*')
expect(script).toContain('OBJECT_STORAGE_EXTERNAL.txt')
expect(script).toContain('backup_minio_volume')
expect(script).toContain('is_external_database()')
expect(script).toContain('is_external_object_storage()')
expect(script).toContain('EXTERNAL_DATABASE_BACKUP_REQUIRED.txt')
expect(script).toContain('EXTERNAL_OBJECT_STORAGE_BACKUP_REQUIRED.txt')
})
it('远程部署失败时默认会恢复旧 APP_IMAGE 或旧容器镜像', () => {
@ -39,6 +46,14 @@ describe('deploy-registry.ps1', () => {
expect(script).toContain('npx prisma migrate status --schema=prisma/schema.prisma')
})
it('远程健康检查使用可配置超时和最终访问入口', () => {
expect(script).toContain('[int]$RemoteHealthTimeoutSec = 120')
expect(script).toContain('$baseUrl = if (-not [string]::IsNullOrWhiteSpace($AppPublicUrl))')
expect(script).toContain('$deadline = (Get-Date).AddSeconds($RemoteHealthTimeoutSec)')
expect(script).toContain('健康检查没有在 $RemoteHealthTimeoutSec 秒内通过')
expect(script).not.toContain('健康检查没有在 60 秒内通过')
})
it('运行时镜像同步包含对象存储备份 helper', () => {
expect(script).toContain('Source = "busybox:1.36"')
expect(script).toContain('Repository = "runtime/busybox"')

View File

@ -0,0 +1,27 @@
import fs from 'fs'
import path from 'path'
describe('scripts/measure-image-size.ps1', () => {
const script = fs.readFileSync(
path.join(process.cwd(), 'scripts/measure-image-size.ps1'),
'utf8'
)
it('支持发布前设置镜像体积和分层预算,超限时失败', () => {
expect(script).toContain('[int]$MaxImageSizeMB = 0')
expect(script).toContain('[int]$MaxLayerCount = 0')
expect(script).toContain('[int]$MaxLargestLayerMB = 0')
expect(script).toContain('$thresholdProblems')
expect(script).toContain('镜像总大小超过阈值')
expect(script).toContain('RootFS 层数超过阈值')
expect(script).toContain('最大分层超过阈值')
expect(script).toContain('exit 64')
})
it('报告中记录最大分层和阈值检查结果', () => {
expect(script).toContain('- 最大分层:$(Format-Bytes $largestLayerSize)')
expect(script).toContain('## 阈值检查')
expect(script).toContain('- 结果:通过')
expect(script).toContain('- 结果:未通过')
})
})

View File

@ -35,7 +35,11 @@ describe('scripts/smoke-install.ps1', () => {
it('验收内置对象存储 bucket,并允许外部对象存储跳过', () => {
expect(script).toContain('[switch]$SkipObjectStorageCheck')
expect(script).toContain('Test-ExternalObjectStorage')
expect(script).toContain('"S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"')
expect(script).toContain('"MINIO_ROOT_USER", "MINIO_ROOT_PASSWORD", "S3_BUCKET"')
expect(script).toContain('S3_BUCKET')
expect(script).toContain('检测到外部对象存储,跳过内置 MinIO bucket 检查')
expect(script).toContain('test -d /data/$bucket')
})

View File

@ -13,6 +13,7 @@
[string]$MinioService = "minio",
[string]$HelperImage = "busybox:1.36",
[switch]$SkipStopAppForBackup,
[switch]$SkipDatabaseBackup,
[switch]$SkipObjectStorageBackup,
[switch]$SkipObjectStorageRestore,
@ -136,6 +137,53 @@ function Get-ServiceContainerId {
return $containerId
}
function Get-OptionalServiceContainerId {
param([string[]]$ComposeArgs, [string]$Service)
$containerId = (& docker @ComposeArgs ps -q $Service 2>$null).Trim()
if ($LASTEXITCODE -ne 0) {
return ""
}
return $containerId
}
function Test-ComposeServiceRunning {
param([string[]]$ComposeArgs, [string]$Service)
$containerId = Get-OptionalServiceContainerId -ComposeArgs $ComposeArgs -Service $Service
if ([string]::IsNullOrWhiteSpace($containerId)) {
return $false
}
$status = (& docker inspect $containerId --format '{{.State.Status}}' 2>$null).Trim()
return ($LASTEXITCODE -eq 0 -and $status -eq "running")
}
function Stop-AppForConsistentBackup {
param([string[]]$ComposeArgs, [string]$Service)
if ($SkipStopAppForBackup) {
Write-Warn "已跳过备份停写入步骤;请确认客户现场没有人正在上传、编辑或删除素材。"
return $false
}
if (-not (Test-ComposeServiceRunning -ComposeArgs $ComposeArgs -Service $Service)) {
Write-Info "$Service 服务未运行或不存在,跳过停写入步骤。"
return $false
}
Write-Info "进入备份维护窗口:暂时停止 $Service 服务,避免备份期间继续写入数据库或素材文件。"
Invoke-DockerCompose ($ComposeArgs + @("stop", $Service))
return $true
}
function Restart-AppAfterConsistentBackup {
param([string[]]$ComposeArgs, [string]$Service)
Write-Info "备份维护窗口结束:恢复启动 $Service 服务。"
Invoke-DockerCompose ($ComposeArgs + @("up", "-d", $Service))
}
function Test-ExternalDatabase {
param([hashtable]$EnvValues)
@ -144,7 +192,75 @@ function Test-ExternalDatabase {
return $false
}
return ($databaseUrl -notmatch '@postgres:5432' -and $databaseUrl -notmatch '@[^/]*postgres[^/]*:5432')
try {
$uri = [Uri]$databaseUrl
if (-not [string]::IsNullOrWhiteSpace($uri.Host)) {
return ($uri.Host -ne "postgres")
}
} catch {
# 继续走兼容解析,避免异常 URL 直接中断备份入口。
}
if ($databaseUrl -match '@([^/:?]+)') {
return ($Matches[1] -ne "postgres")
}
return $false
}
function Test-ExternalObjectStorage {
param([hashtable]$EnvValues)
$endpoint = Get-EnvValue $EnvValues "S3_ENDPOINT"
if ([string]::IsNullOrWhiteSpace($endpoint)) {
return $false
}
try {
$uri = [Uri]$endpoint
return ($uri.Host -ne "minio")
} catch {
return $false
}
}
function Backup-LocalDirectory {
param(
[string]$ProjectDir,
[string]$Name,
[string]$OutputPath
)
$sourcePath = Join-Path $ProjectDir $Name
if (-not (Test-Path -LiteralPath $sourcePath -PathType Container)) {
return $false
}
Compress-Archive -LiteralPath $sourcePath -DestinationPath $OutputPath -Force
Write-Ok "本地目录备份完成:$Name -> $OutputPath"
return $true
}
function Restore-LocalDirectory {
param(
[string]$ProjectDir,
[string]$Name,
[string]$InputPath
)
if (-not (Test-Path -LiteralPath $InputPath -PathType Leaf)) {
return
}
$targetPath = Join-Path $ProjectDir $Name
if (Test-Path -LiteralPath $targetPath -PathType Container) {
$beforeRestorePath = Join-Path $ProjectDir ("$Name.before-restore-" + (Get-Date -Format "yyyyMMdd-HHmmss"))
Move-Item -LiteralPath $targetPath -Destination $beforeRestorePath -Force
Write-Ok "$Name 恢复前副本:$beforeRestorePath"
}
Expand-Archive -LiteralPath $InputPath -DestinationPath $ProjectDir -Force
Write-Ok "本地目录恢复完成:$Name"
}
function Backup-Database {
@ -296,6 +412,8 @@ try {
switch ($Action) {
"Backup" {
$appWasRunningBeforeBackup = Stop-AppForConsistentBackup -ComposeArgs $composeArgs -Service $AppService
try {
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$backupRoot = Join-Path $resolvedProjectDir $BackupDir
$targetDir = Join-Path $backupRoot "flnxi-backup-$timestamp"
@ -323,12 +441,18 @@ try {
}
$minioVolumeName = Get-EnvValue $envValues "MINIO_VOLUME_NAME" "flnxi-minio-data"
$externalObjectStorage = Test-ExternalObjectStorage $envValues
if ($SkipObjectStorageBackup) {
"已按参数跳过对象存储备份。请确认外部 S3 / MinIO 或 Docker 卷已有独立备份。" | Out-File -LiteralPath (Join-Path $targetDir "OBJECT_STORAGE_BACKUP_SKIPPED.txt") -Encoding utf8
} elseif ($externalObjectStorage) {
"检测到 S3_ENDPOINT 指向外部 S3 / MinIO。本脚本不会自动备份外部对象存储,请按客户现场方案单独备份素材对象。" | Out-File -LiteralPath (Join-Path $targetDir "EXTERNAL_OBJECT_STORAGE_BACKUP_REQUIRED.txt") -Encoding utf8
} else {
Backup-ObjectStorage -VolumeName $minioVolumeName -OutputPath (Join-Path $targetDir "minio-data.tar.gz") -HelperImage $HelperImage
}
$localDataBackedUp = Backup-LocalDirectory -ProjectDir $resolvedProjectDir -Name "data" -OutputPath (Join-Path $targetDir "data.zip")
$localUploadsBackedUp = Backup-LocalDirectory -ProjectDir $resolvedProjectDir -Name "uploads" -OutputPath (Join-Path $targetDir "uploads.zip")
$manifest = [ordered]@{
createdAt = (Get-Date).ToString("s")
projectDir = $resolvedProjectDir
@ -339,10 +463,21 @@ try {
postgresService = $PostgresService
minioVolumeName = $minioVolumeName
externalDatabase = $externalDb
externalObjectStorage = $externalObjectStorage
localDirectories = [ordered]@{
data = $localDataBackedUp
uploads = $localUploadsBackedUp
}
appStoppedForConsistentBackup = $appWasRunningBeforeBackup
restoreConfirmPhrase = $RestoreConfirmPhrase
}
$manifest | ConvertTo-Json -Depth 5 | Out-File -LiteralPath (Join-Path $targetDir "manifest.json") -Encoding utf8
Write-Ok "备份目录:$targetDir"
} finally {
if ($appWasRunningBeforeBackup) {
Restart-AppAfterConsistentBackup -ComposeArgs $composeArgs -Service $AppService
}
}
}
"Restore" {
@ -376,7 +511,10 @@ try {
Write-Warn "备份目录没有 postgres.sql,跳过数据库恢复。"
}
if (-not $SkipObjectStorageRestore -and (Test-Path -LiteralPath $objectBackup -PathType Leaf)) {
$externalObjectStorage = Test-ExternalObjectStorage $envValues
if ($externalObjectStorage) {
Write-Warn "检测到当前 .env 使用外部对象存储,脚本不会恢复 Compose 内置 MinIO 卷;请按客户现场对象存储备份方案恢复。"
} elseif (-not $SkipObjectStorageRestore -and (Test-Path -LiteralPath $objectBackup -PathType Leaf)) {
Invoke-DockerCompose ($composeArgs + @("stop", $MinioService))
$minioVolumeName = Get-EnvValue $envValues "MINIO_VOLUME_NAME" "flnxi-minio-data"
Restore-ObjectStorage -VolumeName $minioVolumeName -InputPath $objectBackup -HelperImage $HelperImage
@ -386,6 +524,9 @@ try {
Write-Warn "备份目录没有 minio-data.tar.gz,跳过对象存储恢复。"
}
Restore-LocalDirectory -ProjectDir $resolvedProjectDir -Name "data" -InputPath (Join-Path $sourceDir "data.zip")
Restore-LocalDirectory -ProjectDir $resolvedProjectDir -Name "uploads" -InputPath (Join-Path $sourceDir "uploads.zip")
Invoke-DockerCompose ($composeArgs + @("up", "-d"))
Invoke-Verify -ComposeArgs $composeArgs -EnvValues (Read-EnvFile $envPath) -TimeoutSeconds $HealthTimeoutSeconds
Write-Ok "恢复和基础验收完成"

View File

@ -7,7 +7,13 @@ param(
[int]$TopLayers = 12,
[string]$OutputPath = ""
[string]$OutputPath = "",
[int]$MaxImageSizeMB = 0,
[int]$MaxLayerCount = 0,
[int]$MaxLargestLayerMB = 0
)
$ErrorActionPreference = "Stop"
@ -91,6 +97,18 @@ $layers = @($historyRows | ForEach-Object {
$largestLayers = @($layers | Sort-Object SizeBytes -Descending | Select-Object -First $TopLayers)
$layerCount = if ($inspect.RootFS -and $inspect.RootFS.Layers) { @($inspect.RootFS.Layers).Count } else { $layers.Count }
$totalSize = [double]$inspect.Size
$largestLayerSize = if ($largestLayers.Count -gt 0) { [double]$largestLayers[0].SizeBytes } else { 0 }
$thresholdProblems = New-Object System.Collections.Generic.List[string]
if ($MaxImageSizeMB -gt 0 -and $totalSize -gt ($MaxImageSizeMB * 1MB)) {
$thresholdProblems.Add("镜像总大小超过阈值:$(Format-Bytes $totalSize) > ${MaxImageSizeMB} MB")
}
if ($MaxLayerCount -gt 0 -and $layerCount -gt $MaxLayerCount) {
$thresholdProblems.Add("RootFS 层数超过阈值:$layerCount > $MaxLayerCount")
}
if ($MaxLargestLayerMB -gt 0 -and $largestLayerSize -gt ($MaxLargestLayerMB * 1MB)) {
$thresholdProblems.Add("最大分层超过阈值:$(Format-Bytes $largestLayerSize) > ${MaxLargestLayerMB} MB")
}
$report = New-Object System.Collections.Generic.List[string]
$report.Add("# FLNXI 镜像体积基线")
@ -98,7 +116,26 @@ $report.Add("")
$report.Add("- 镜像:``$Image``")
$report.Add("- 总大小:$(Format-Bytes $totalSize)")
$report.Add("- RootFS 层数:$layerCount")
$report.Add("- 最大分层:$(Format-Bytes $largestLayerSize)")
$report.Add("- 生成时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
if ($MaxImageSizeMB -gt 0 -or $MaxLayerCount -gt 0 -or $MaxLargestLayerMB -gt 0) {
$report.Add("")
$report.Add("## 阈值检查")
$report.Add("")
$report.Add("- 镜像总大小上限:$(if ($MaxImageSizeMB -gt 0) { "$MaxImageSizeMB MB" } else { '未设置' })")
$report.Add("- RootFS 层数上限:$(if ($MaxLayerCount -gt 0) { "$MaxLayerCount" } else { '未设置' })")
$report.Add("- 最大分层上限:$(if ($MaxLargestLayerMB -gt 0) { "$MaxLargestLayerMB MB" } else { '未设置' })")
if ($thresholdProblems.Count -eq 0) {
$report.Add("- 结果:通过")
} else {
$report.Add("- 结果:未通过")
foreach ($problem in $thresholdProblems) {
$report.Add(" - $problem")
}
}
}
$report.Add("")
$report.Add("## 最大分层")
$report.Add("")
@ -132,3 +169,10 @@ if ([string]::IsNullOrWhiteSpace($OutputPath)) {
$text | Out-File -LiteralPath $OutputPath -Encoding utf8
Write-Host "[OK] 已写入镜像体积报告:$OutputPath" -ForegroundColor Green
}
if ($thresholdProblems.Count -gt 0) {
foreach ($problem in $thresholdProblems) {
Write-Host "[ERROR] $problem" -ForegroundColor Red
}
exit 64
}

View File

@ -127,6 +127,22 @@ function Test-AppImage {
}
}
function Test-ExternalObjectStorage {
param([hashtable]$EnvValues)
$endpoint = Get-EnvValue $EnvValues "S3_ENDPOINT"
if ([string]::IsNullOrWhiteSpace($endpoint)) {
return $false
}
try {
$uri = [Uri]$endpoint
return ($uri.Host -ne "minio")
} catch {
return $false
}
}
function Test-DirectoryReady {
param(
[string]$ProjectDir,
@ -180,8 +196,6 @@ function Invoke-InstallPreflight {
"LICENSE_PUBLIC_KEY",
"LOCAL_ENCRYPTION_KEY",
"POSTGRES_PASSWORD",
"MINIO_ROOT_USER",
"MINIO_ROOT_PASSWORD",
"S3_PUBLIC_DOMAIN"
)) {
Test-RequiredEnv -EnvValues $EnvValues -Problems $problems -Name $name
@ -194,6 +208,17 @@ function Invoke-InstallPreflight {
Add-Problem $problems "DATABASE_URL 仍是示例地址;内置 PostgreSQL 请留空,外部 PostgreSQL 请填真实连接串"
}
$externalObjectStorage = Test-ExternalObjectStorage $EnvValues
if ($externalObjectStorage) {
foreach ($name in @("S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY")) {
Test-RequiredEnv -EnvValues $EnvValues -Problems $problems -Name $name
}
} else {
foreach ($name in @("MINIO_ROOT_USER", "MINIO_ROOT_PASSWORD", "S3_BUCKET")) {
Test-RequiredEnv -EnvValues $EnvValues -Problems $problems -Name $name
}
}
$appPublicUrl = (Get-EnvValue $EnvValues "APP_PUBLIC_URL").TrimEnd("/")
$nextAuthUrl = (Get-EnvValue $EnvValues "NEXTAUTH_URL").TrimEnd("/")
if (-not [string]::IsNullOrWhiteSpace($appPublicUrl) -and -not [string]::IsNullOrWhiteSpace($nextAuthUrl) -and $appPublicUrl -ne $nextAuthUrl) {
@ -341,7 +366,10 @@ try {
Invoke-DockerCompose ($composeArgs + @("exec", "-T", $AppService, "npx", "prisma", "migrate", "status", "--schema=prisma/schema.prisma"))
}
if (-not $SkipObjectStorageCheck) {
$externalObjectStorage = Test-ExternalObjectStorage $envValues
if ($externalObjectStorage -and -not $SkipObjectStorageCheck) {
Write-Warn "检测到外部对象存储,跳过内置 MinIO bucket 检查;请按客户现场对象存储方案单独验收。"
} elseif (-not $SkipObjectStorageCheck) {
$bucket = Get-EnvValue $envValues "S3_BUCKET" "flnxi-materials"
Write-Info "检查 MinIO bucket:$bucket"
Invoke-DockerCompose ($composeArgs + @("exec", "-T", $MinioService, "sh", "-c", "test -d /data/$bucket"))