补充公开分享总开关并兼容转码恢复缺表场景

This commit is contained in:
WuYongJie 2026-05-26 11:18:18 +08:00
parent 1a9921c9fe
commit c7d93111c4
17 changed files with 295 additions and 47 deletions

View File

@ -59,6 +59,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
const [enableRegistration, setEnableRegistration] = useState(true)
const [enableAiImageEdit, setEnableAiImageEdit] = useState(false)
const [enableTunnelService, setEnableTunnelService] = useState(false)
const [enablePublicShare, setEnablePublicShare] = useState(true)
// 首页删除按钮:true = 仅管理员可见,false = 所有人可见
const [homeDeleteButtonAdminOnly, setHomeDeleteButtonAdminOnly] = useState(true)
const [premiumRatio, setPremiumRatio] = useState(10)
@ -112,6 +113,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
setEnableRegistration(data.enableRegistration ?? true)
setEnableAiImageEdit(data.enableAiImageEdit ?? false)
setEnableTunnelService(data.enableTunnelService ?? false)
setEnablePublicShare(data.enablePublicShare ?? true)
setHomeDeleteButtonAdminOnly((data.homeDeleteButtonMode ?? 'ADMIN_ONLY') !== 'ALL')
setPremiumRatio(typeof data.premiumRatio === 'number' ? data.premiumRatio : 10)
setLastSavedAt(data.updatedAt ? new Date(data.updatedAt) : null)
@ -161,6 +163,7 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
enableRegistration,
enableAiImageEdit,
enableTunnelService,
enablePublicShare,
homeDeleteButtonMode: homeDeleteButtonAdminOnly ? 'ADMIN_ONLY' : 'ALL',
premiumRatio: normalizedRatio,
}),
@ -615,6 +618,13 @@ export default function AppearanceSettingsPanel({ sessionUser, variant = 'embedd
onChange: setHomeDeleteButtonAdminOnly,
disabled: false,
},
{
title: '公开分享链接',
desc: '关闭后不能新建外部分享链接,已有分享链接也会停止公开访问。',
value: enablePublicShare,
onChange: setEnablePublicShare,
disabled: !sessionUser?.isSuperAdmin,
},
{
title: '图片编辑能力(预留)',
desc: '控制素材详情页的图片编辑入口(功能预留)。',

View File

@ -27,10 +27,15 @@ jest.mock('@/lib/material-share-token', () => ({
findValidMaterialShareToken: jest.fn(),
}))
jest.mock('@/lib/public-share-settings', () => ({
isPublicShareEnabled: jest.fn(),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { getS3ObjectStream, isS3Url } = require('@/lib/s3')
const { findValidMaterialShareToken } = require('@/lib/material-share-token')
const { isPublicShareEnabled } = require('@/lib/public-share-settings')
const buildMaterialRecord = (overrides: Record<string, unknown> = {}) => ({
id: 'material-1',
@ -51,6 +56,7 @@ describe('GET /api/materials/asset', () => {
beforeEach(() => {
jest.resetAllMocks()
isS3Url.mockImplementation((value?: string | null) => /^https?:\/\//.test(value || ''))
isPublicShareEnabled.mockResolvedValue(true)
})
it('未登录时拒绝读取素材文件', async () => {
@ -127,6 +133,25 @@ describe('GET /api/materials/asset', () => {
expect(findValidMaterialShareToken).toHaveBeenCalledWith('share-token', 'material-1')
})
it('公开分享开关关闭时拒绝通过分享票据读取素材文件', async () => {
const fileUrl = 'http://minio:9000/bucket/preview.webp'
getServerSession.mockResolvedValue(null)
prisma.material.findMany.mockResolvedValue([buildMaterialRecord({ previewUrl: fileUrl })])
isPublicShareEnabled.mockResolvedValue(false)
const response = await GET(
new NextRequest(
`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}&shareToken=share-token`
)
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('公开分享功能已关闭')
expect(findValidMaterialShareToken).not.toHaveBeenCalled()
expect(getS3ObjectStream).not.toHaveBeenCalled()
})
it('允许读取 metadata.previewUrls 中登记过的预览图', async () => {
const fileUrl = 'http://minio:9000/bucket/metadata-preview.webp'
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })

View File

@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
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 { access } from 'fs/promises'
import path from 'path'
import { Readable } from 'stream'
@ -48,6 +49,13 @@ export async function GET(req: NextRequest) {
}
if (!session?.user) {
if (!(await isPublicShareEnabled())) {
return NextResponse.json(
{ error: '公开分享功能已关闭' },
{ status: 403 }
)
}
const validShareToken = shareToken
? await findValidMaterialShareToken(shareToken, materialId)
: null

View File

@ -32,13 +32,19 @@ jest.mock('@/lib/url-rewrite', () => ({
),
}))
jest.mock('@/lib/public-share-settings', () => ({
isPublicShareEnabled: jest.fn(),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { createMaterialShareToken, findValidMaterialShareToken } = require('@/lib/material-share-token')
const { isPublicShareEnabled } = require('@/lib/public-share-settings')
describe('/api/materials/share', () => {
beforeEach(() => {
jest.clearAllMocks()
isPublicShareEnabled.mockResolvedValue(true)
})
it('未登录时拒绝创建分享票据', async () => {
@ -57,6 +63,25 @@ describe('/api/materials/share', () => {
expect(createMaterialShareToken).not.toHaveBeenCalled()
})
it('公开分享开关关闭时拒绝创建新分享票据', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.user.findUnique.mockResolvedValue({ id: 'user-1', role: 'USER', isSuperAdmin: false })
isPublicShareEnabled.mockResolvedValue(false)
const response = await POST(
new NextRequest('http://localhost/api/materials/share', {
method: 'POST',
body: JSON.stringify({ materialId: 'material-1' }),
})
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('公开分享功能已关闭')
expect(prisma.material.findFirst).not.toHaveBeenCalled()
expect(createMaterialShareToken).not.toHaveBeenCalled()
})
it('已登录用户可以为未删除素材创建分享票据', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.user.findUnique.mockResolvedValue({ id: 'user-1', role: 'USER', isSuperAdmin: false })
@ -132,6 +157,20 @@ describe('/api/materials/share', () => {
expect(prisma.material.findFirst).not.toHaveBeenCalled()
})
it('公开分享开关关闭时旧分享链接也不能继续公开读取', async () => {
isPublicShareEnabled.mockResolvedValue(false)
const response = await GET(
new NextRequest('http://localhost/api/materials/share?id=material-1&token=share-token')
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('公开分享功能已关闭')
expect(findValidMaterialShareToken).not.toHaveBeenCalled()
expect(prisma.material.findFirst).not.toHaveBeenCalled()
})
it('有效分享票据可读取分享数据并把图片改成带票据的代理地址', async () => {
findValidMaterialShareToken.mockResolvedValue({ id: 'token-row', materialId: 'material-1' })
prisma.material.findFirst.mockResolvedValue({

View File

@ -8,6 +8,7 @@ import {
canManageMaterialShare,
getMaterialShareActor,
} from '@/lib/material-share-permissions'
import { isPublicShareEnabled } from '@/lib/public-share-settings'
function parseDisplayImages(value: string | null): string[] {
if (!value) return []
@ -36,6 +37,13 @@ export async function POST(req: NextRequest) {
)
}
if (!(await isPublicShareEnabled())) {
return NextResponse.json(
{ error: '公开分享功能已关闭' },
{ status: 403 }
)
}
const body = await req.json().catch(() => ({}))
const materialId = typeof body?.materialId === 'string' ? body.materialId.trim() : ''
@ -104,6 +112,13 @@ export async function GET(req: NextRequest) {
)
}
if (!(await isPublicShareEnabled())) {
return NextResponse.json(
{ error: '公开分享功能已关闭' },
{ status: 403 }
)
}
const validShareToken = await findValidMaterialShareToken(shareToken, id)
if (!validShareToken) {
return NextResponse.json(

View File

@ -27,15 +27,21 @@ jest.mock('@/lib/material-share-token', () => ({
findValidMaterialShareToken: jest.fn(),
}))
jest.mock('@/lib/public-share-settings', () => ({
isPublicShareEnabled: jest.fn(),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { getS3ObjectStream, isS3Url } = require('@/lib/s3')
const { findValidMaterialShareToken } = require('@/lib/material-share-token')
const { isPublicShareEnabled } = require('@/lib/public-share-settings')
describe('GET /api/materials/stream', () => {
beforeEach(() => {
jest.resetAllMocks()
isS3Url.mockImplementation((value?: string | null) => /^https?:\/\//.test(value || ''))
isPublicShareEnabled.mockResolvedValue(true)
})
it('未登录且没有有效分享票据时拒绝播放', async () => {
@ -84,4 +90,26 @@ describe('GET /api/materials/stream', () => {
expect(findValidMaterialShareToken).toHaveBeenCalledWith('share-token', 'material-1')
expect(response.headers.get('access-control-allow-origin')).toBeNull()
})
it('公开分享开关关闭时拒绝通过分享票据播放', async () => {
prisma.material.findFirst.mockResolvedValue({
id: 'material-1',
zipFilePath: 'http://minio:9000/bucket/video.mp4',
zipFileName: 'video.mp4',
materialType: '视频',
materialKind: 'VIDEO',
})
getServerSession.mockResolvedValue(null)
isPublicShareEnabled.mockResolvedValue(false)
const response = await GET(
new NextRequest('http://localhost/api/materials/stream?id=material-1&shareToken=share-token')
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('公开分享功能已关闭')
expect(findValidMaterialShareToken).not.toHaveBeenCalled()
expect(getS3ObjectStream).not.toHaveBeenCalled()
})
})

View File

@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
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 { access } from 'fs/promises'
import path from 'path'
import { Readable } from 'stream'
@ -48,6 +49,13 @@ export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user) {
if (!(await isPublicShareEnabled())) {
return NextResponse.json(
{ error: '公开分享功能已关闭' },
{ status: 403 }
)
}
const validShareToken = shareToken
? await findValidMaterialShareToken(shareToken, material.id)
: null

View File

@ -81,6 +81,7 @@ const mockSettings = {
enableUserActivation: false,
enableAiImageEdit: true,
enableTunnelService: true,
enablePublicShare: true,
}
describe('GET /api/system-settings', () => {
@ -125,6 +126,7 @@ describe('GET /api/system-settings', () => {
expect(data.enableUserActivation).toBeUndefined()
expect(data.enableAiImageEdit).toBeUndefined()
expect(data.enableTunnelService).toBeUndefined()
expect(data.enablePublicShare).toBeUndefined()
expect(data.enabledBuiltinTemplates).toBeUndefined()
expect(data.homeDeleteButtonMode).toBeUndefined()
})
@ -144,6 +146,7 @@ describe('GET /api/system-settings', () => {
expect(data.s3Bucket).toBe('private-bucket')
expect(data.enableRegistration).toBe(true)
expect(data.enableAiImageEdit).toBe(true)
expect(data.enablePublicShare).toBe(true)
expect(data.enabledBuiltinTemplates).toBe('["3dmax-material"]')
expect(data.homeDeleteButtonMode).toBe('ADMIN_ONLY')
})
@ -237,6 +240,7 @@ describe('PUT /api/system-settings 权限边界', () => {
siteName: '普通管理员不应能改',
s3SecretKey: 'new-secret',
enableRegistration: false,
enablePublicShare: false,
}),
}))
const data = await response.json()
@ -274,6 +278,7 @@ describe('PUT /api/system-settings 权限边界', () => {
siteName: '新系统名',
s3SecretKey: 'new-secret',
enableRegistration: false,
enablePublicShare: false,
}),
}))
const data = await response.json()
@ -288,6 +293,11 @@ describe('PUT /api/system-settings 权限边界', () => {
enableRegistration: false,
}),
})
expect(prisma.$executeRawUnsafe).toHaveBeenCalledWith(
`UPDATE "system_settings" SET "enablePublicShare" = $1 WHERE "id" = $2`,
false,
'settings-1',
)
})
})

View File

@ -31,6 +31,7 @@ type SystemSettingsRow = {
enableUserActivation: boolean
enableAiImageEdit: boolean
enableTunnelService: boolean
enablePublicShare?: boolean | null
enabledBuiltinTemplates?: string | null
homeDeleteButtonMode?: string | null
}
@ -88,6 +89,7 @@ function buildPublicFallbackSettings(): SystemSettingsRow {
enableUserActivation: false,
enableAiImageEdit: false,
enableTunnelService: false,
enablePublicShare: true,
enabledBuiltinTemplates: null,
homeDeleteButtonMode: 'ADMIN_ONLY',
}
@ -135,19 +137,25 @@ export async function GET() {
enableAiImageEdit: true,
enableTunnelService: true,
// homeDeleteButtonMode 是可选列,这里不直接在 select 中声明,避免旧版本类型不匹配
// enabledBuiltinTemplates 可能不存在,先不查询
// enabledBuiltinTemplates / enablePublicShare 可能不存在,先不查询
},
}) as SystemSettingsRow | null
// 如果字段存在,尝试查询它
try {
const fullSettings = await prisma.$queryRaw<Array<{ enabledBuiltinTemplates?: string | null; homeDeleteButtonMode?: string | null; premiumRatio?: number | null }>>`
SELECT "enabledBuiltinTemplates", "homeDeleteButtonMode", "premiumRatio" FROM "system_settings" LIMIT 1
const fullSettings = await prisma.$queryRaw<Array<{
enabledBuiltinTemplates?: string | null
homeDeleteButtonMode?: string | null
premiumRatio?: number | null
enablePublicShare?: boolean | null
}>>`
SELECT "enabledBuiltinTemplates", "homeDeleteButtonMode", "premiumRatio", "enablePublicShare" FROM "system_settings" LIMIT 1
`
if (fullSettings[0] && settings) {
settings.enabledBuiltinTemplates = fullSettings[0].enabledBuiltinTemplates
settings.homeDeleteButtonMode = fullSettings[0].homeDeleteButtonMode || 'ADMIN_ONLY'
settings.premiumRatio = fullSettings[0].premiumRatio ?? 10 // 默认值10%
settings.enablePublicShare = fullSettings[0].enablePublicShare !== false
}
} catch {
// 字段不存在,设置为默认值
@ -155,6 +163,7 @@ export async function GET() {
settings.enabledBuiltinTemplates = null
settings.homeDeleteButtonMode = 'ADMIN_ONLY'
settings.premiumRatio = settings.premiumRatio ?? 10 // 确保有默认值
settings.enablePublicShare = true
}
}
@ -173,6 +182,8 @@ export async function GET() {
enableTunnelService: featureFlags.tunnelEnabled,
},
}) as SystemSettingsRow
settings.enablePublicShare = true
} else {
// 已存在的旧数据,如果 LOGO 为空则自动补上默认值,并保存一次;不要在 GET 中补写对象存储密钥
let needUpdate = false
@ -269,6 +280,7 @@ export async function PUT(request: NextRequest) {
enableRegistration,
enableAiImageEdit,
enableTunnelService,
enablePublicShare,
enabledBuiltinTemplates,
homeDeleteButtonMode,
premiumRatio,
@ -310,12 +322,13 @@ export async function PUT(request: NextRequest) {
// 通过原生 SQL 写入可选列,避免类型不匹配
try {
await prisma.$executeRawUnsafe(
`UPDATE "system_settings" SET "homeDeleteButtonMode" = $1 WHERE "id" = $2`,
`UPDATE "system_settings" SET "homeDeleteButtonMode" = $1, "enablePublicShare" = $2 WHERE "id" = $3`,
homeDeleteButtonMode ?? 'ADMIN_ONLY',
enablePublicShare !== undefined ? Boolean(enablePublicShare) : true,
settings.id
)
} catch (e) {
console.error('[SystemSettings] Failed to set homeDeleteButtonMode on create:', e)
console.error('[SystemSettings] Failed to set optional columns on create:', e)
}
} else {
// 更新
@ -354,6 +367,18 @@ export async function PUT(request: NextRequest) {
console.error('[SystemSettings] Failed to update homeDeleteButtonMode:', e)
}
}
if (enablePublicShare !== undefined) {
try {
await prisma.$executeRawUnsafe(
`UPDATE "system_settings" SET "enablePublicShare" = $1 WHERE "id" = $2`,
Boolean(enablePublicShare),
settings.id
)
} catch (e) {
console.error('[SystemSettings] Failed to update enablePublicShare:', e)
}
}
}
// 清除缓存,强制下次重新加载

View File

@ -74,7 +74,7 @@
客户影响:
即使业务接口加了登录,如果对象存储本身公开下载,拿到文件 URL 的人仍能绕过系统直接访问素材文件。当前已完成默认私有化、内部播放、分享票据、前端撤销和 MinIO 匿名访问收口:新部署和升级初始化默认会把素材 bucket 的匿名访问设为 `none`;新上传不再默认设置 `public-read`;内部素材预览类 URL 默认改为应用内代理地址;视频/音频流接口默认要求登录;公开分享必须携带短期分享票据;素材负责人/上传者/管理员可以在前端查看、刷新和撤销该素材的分享链接。
即使业务接口加了登录,如果对象存储本身公开下载,拿到文件 URL 的人仍能绕过系统直接访问素材文件。当前已完成默认私有化、内部播放、分享票据、前端撤销、系统级公开分享开关和 MinIO 匿名访问收口:新部署和升级初始化默认会把素材 bucket 的匿名访问设为 `none`;新上传不再默认设置 `public-read`;内部素材预览类 URL 默认改为应用内代理地址;视频/音频流接口默认要求登录;公开分享必须携带短期分享票据;素材负责人/上传者/管理员可以在前端查看、刷新和撤销该素材的分享链接;最高权限账号可以在系统设置里关闭公开分享,关闭后不能新建外部分享链接,已有分享链接也不能继续公开访问。
证据:
@ -95,7 +95,12 @@
- `app/api/materials/share/tokens/[id]/route.ts`:新增撤销分享链接接口,通过写入 `revokedAt` 让链接失效,不硬删除记录。
- `app/page.tsx`:分享弹窗已接入已有分享链接列表、到期时间、撤销状态、刷新和撤销操作。
- `app/share/[id]/page.tsx`:公开分享页已隐藏下载按钮,避免外部访问者点击一个后端必然要求登录的下载入口;当前公开分享定位为可预览,不直接开放源文件下载。
- `prisma/schema.prisma`、`prisma/migrations/20260526090000_add_public_share_toggle/migration.sql`:系统设置新增 `enablePublicShare`,默认开启,旧库升级会自动补列。
- `app/admin/_components/AppearanceSettingsPanel.tsx`:后台“功能开关”新增公开分享链接开关,仅最高权限账号可修改。
- `lib/public-share-settings.ts`:新增公开分享开关读取函数,读取失败时按关闭处理,避免数据库异常时误放开外部分享。
- `app/api/materials/share/route.ts`:公开分享开关关闭时拒绝创建新分享票据,也拒绝旧分享票据继续读取分享数据。
- `app/api/materials/asset/route.ts`:素材文件代理默认要求登录;未登录时必须带有效分享票据,且文件地址必须属于对应素材记录。
- `app/api/materials/asset/route.ts`、`app/api/materials/stream/route.ts`:公开分享开关关闭时,未登录用户不能再通过旧分享票据读取素材文件或播放音视频;登录用户内部预览不受影响。
- `app/api/materials/stream/route.ts`:视频/音频流接口默认要求登录;未登录时必须带有效分享票据,并只查询未软删除素材。
- `app/api/materials/stream/route.ts`:S3 和本地 legacy 流响应已改为 `Cache-Control: private, max-age=300`,不再返回 `Access-Control-Allow-Origin: *`。
- `app/api/materials/share/__tests__/route.test.ts`、`app/api/materials/share/tokens/__tests__/route.test.ts`、`app/api/materials/share/tokens/[id]/__tests__/route.test.ts`、`app/api/materials/asset/__tests__/route.test.ts`、`app/api/materials/stream/__tests__/route.test.ts`、`lib/__tests__/material-share-token.test.ts`、`lib/__tests__/s3.test.ts`、`lib/__tests__/url-rewrite.test.ts`:新增/更新测试,锁定默认私有上传、素材代理 URL、代理接口权限边界、分享票据校验、分享列表不泄露 token、撤销链接后失效、无票据播放拒绝、有票据播放可用且不返回通配 CORS。
@ -107,9 +112,9 @@
- 已完成:内部视频/音频播放不再允许未登录裸 ID 访问,改为登录态内同源播放。
- 已完成:公开分享不再只靠裸素材 ID,第一版改为“有权限用户创建分享票据,公开访问携带票据,票据短期有效并可后端撤销”。
- 已完成:主页分享弹窗已接入分享链接列表、有效期展示、撤销入口和刷新入口。
- 已完成:后台新增系统级公开分享开关,关闭后不能新建外部分享链接,已有分享链接也会停止公开访问。
- 已完成:公开分享页不再展示下载按钮;源文件下载继续走登录态和用户下载权限。
- 仍需继续:外部 S3 或历史对象级 ACL 需要单独运维审计;MinIO 初始化只能处理当前 Compose 管理的 MinIO bucket 匿名策略。
- 仍需继续:必要时再加系统级分享开关。
- 仍需继续:Logo、头像等公共资源和私有素材需要拆分策略;登录页 Logo 不能简单套用登录后的素材代理。
### P0-04 CORS 反射任意来源且允许携带凭据(已修复主应用 API 和素材流)

View File

@ -49,7 +49,7 @@
- 收藏/点赞:可在素材卡片或详情里操作。
- 下载:是否显示下载按钮取决于当前权限和素材状态。
- 下载记录:系统会写入 `material_download_logs`,方便后续审计。
- 分享链接:可用于外部预览;当前分享页存在下载入口,是否适合对外开放下载,需要按项目权限和交付策略确认,不能写成“外部分享一定不允许下载原文件”。
- 分享链接:可用于外部预览,默认 7 天有效,可由有权限的用户撤销。管理员可以在后台关闭公开分享;关闭后不能新建外部分享链接,已有链接也会停止公开访问。当前公开分享页定位为预览,不直接开放源文件下载。
### 5. 所有权移交
@ -72,7 +72,8 @@
| 上传后长时间等待转码 | 刷新后再看;仍异常则联系管理员检查转码队列和 `ffmpeg` |
| 我的素材为空 | 可能暂无上传、素材已移交、或当前账号归属变化,联系管理员核对 |
| 下载按钮不可见 | 可能没有下载权限,或素材仍在处理中 |
| 分享给外部前不确定能不能下载 | 先用测试素材验证分享页行为,再决定是否对外发送 |
| 分享链接打不开 | 可能链接已过期、已撤销,或管理员关闭了公开分享 |
| 分享给外部前不确定是否合规 | 先用测试素材验证分享页行为,再决定是否对外发送 |
### 7. 建议的新手任务

View File

@ -127,6 +127,28 @@ describe('video-transcode-service', () => {
}
})
it('数据库缺少 video_transcode_jobs 表时,启动恢复会安全跳过', async () => {
const { recoverStaleVideoTranscodeJobs } = require('../video-transcode-service')
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
prisma.videoTranscodeJob.findMany.mockRejectedValue({
code: 'P2021',
meta: { table: 'public.video_transcode_jobs' },
message: 'The table public.video_transcode_jobs does not exist',
})
try {
const result = await recoverStaleVideoTranscodeJobs()
expect(result).toEqual({ recovered: 0, hasMore: false, skipped: true })
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('跳过启动恢复'))
expect(prisma.$queryRaw).not.toHaveBeenCalled()
expect(transcodeQueue.add).not.toHaveBeenCalled()
} finally {
warnSpy.mockRestore()
}
})
it('恢复批次达到上限时会标记 hasMore 并安排继续恢复', async () => {
jest.useFakeTimers()
const { recoverStaleVideoTranscodeJobs } = require('../video-transcode-service')

View File

@ -0,0 +1,21 @@
import { prisma } from '@/lib/prisma'
import { ensureSystemSettingsOptionalColumns } from '@/lib/system-settings-columns'
export async function isPublicShareEnabled(): Promise<boolean> {
try {
await ensureSystemSettingsOptionalColumns()
const rows = await prisma.$queryRaw<Array<{ enablePublicShare: boolean | null }>>`
SELECT "enablePublicShare" FROM "system_settings" LIMIT 1
`
if (!rows[0]) {
return true
}
return rows[0].enablePublicShare !== false
} catch (error) {
console.warn('[PublicShare] 读取公开分享开关失败,已按关闭处理:', error)
return false
}
}

View File

@ -22,5 +22,5 @@ export async function ensureSystemSettingsOptionalColumns() {
// 首页删除按钮模式:ADMIN_ONLY / ALL
await addColumnIfMissing('homeDeleteButtonMode')
await addColumnIfMissing('premiumRatio', 'DOUBLE PRECISION', '10')
await addColumnIfMissing('enablePublicShare', 'BOOLEAN', 'TRUE')
}

View File

@ -65,6 +65,18 @@ function getErrorMessage(error: unknown) {
return '未知错误'
}
function isVideoTranscodeSchemaMissing(error: unknown) {
const code = (error as any)?.code
const table = String((error as any)?.meta?.table || '')
const message = getErrorMessage(error)
return (
(code === 'P2021' && (table.includes('video_transcode_jobs') || message.includes('video_transcode_jobs'))) ||
(code === 'P2022' && message.includes('video_transcode_jobs')) ||
(code === 'P2010' && message.includes('video_transcode_jobs'))
)
}
async function runVideoTranscodeTask(
materialId: string,
videoSourceUrl: string,
@ -229,7 +241,15 @@ export async function recoverStaleVideoTranscodeJobs() {
return { recovered: 0, hasMore: false, skipped: true }
}
const staleJobs = await videoTranscodeJob.findMany({
let staleJobs: any[] = []
let orphanMaterials: Array<{
id: string
videoSourceUrl: string | null
videoCoverUrl: string | null
}> = []
try {
staleJobs = await videoTranscodeJob.findMany({
where: {
OR: [
{ status: 'queued' },
@ -254,7 +274,7 @@ export async function recoverStaleVideoTranscodeJobs() {
orderBy: { updatedAt: 'asc' },
})
const orphanMaterials = await prisma.$queryRaw<Array<{
orphanMaterials = await prisma.$queryRaw<Array<{
id: string
videoSourceUrl: string | null
videoCoverUrl: string | null
@ -269,6 +289,14 @@ export async function recoverStaleVideoTranscodeJobs() {
ORDER BY m."updatedAt" ASC
LIMIT ${RECOVERY_BATCH_SIZE}
`
} catch (error) {
if (isVideoTranscodeSchemaMissing(error)) {
console.warn('[Transcode] 跳过启动恢复:数据库缺少 video_transcode_jobs 表,请执行数据库迁移')
return { recovered: 0, hasMore: false, skipped: true }
}
throw error
}
let recovered = 0

View File

@ -0,0 +1,2 @@
ALTER TABLE "system_settings"
ADD COLUMN IF NOT EXISTS "enablePublicShare" BOOLEAN NOT NULL DEFAULT TRUE;

View File

@ -385,6 +385,7 @@ model SystemSettings {
enableUserActivation Boolean @default(false)
enableAiImageEdit Boolean @default(false)
enableTunnelService Boolean @default(false)
enablePublicShare Boolean @default(true)
enabledBuiltinTemplates String? // JSON array of enabled builtin template keys (empty array or null = all enabled)
categoryManagedFields String? // JSON array,记录分类管理已启用的字段 key