diff --git a/app/admin/_components/AppearanceSettingsPanel.tsx b/app/admin/_components/AppearanceSettingsPanel.tsx index 4bcd9ef..b85936b 100644 --- a/app/admin/_components/AppearanceSettingsPanel.tsx +++ b/app/admin/_components/AppearanceSettingsPanel.tsx @@ -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: '控制素材详情页的图片编辑入口(功能预留)。', diff --git a/app/api/materials/asset/__tests__/route.test.ts b/app/api/materials/asset/__tests__/route.test.ts index cdee0c9..779cea7 100644 --- a/app/api/materials/asset/__tests__/route.test.ts +++ b/app/api/materials/asset/__tests__/route.test.ts @@ -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 = {}) => ({ 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' } }) diff --git a/app/api/materials/asset/route.ts b/app/api/materials/asset/route.ts index 95bce9b..f2eaaa0 100644 --- a/app/api/materials/asset/route.ts +++ b/app/api/materials/asset/route.ts @@ -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 diff --git a/app/api/materials/share/__tests__/route.test.ts b/app/api/materials/share/__tests__/route.test.ts index c3f0524..94e3508 100644 --- a/app/api/materials/share/__tests__/route.test.ts +++ b/app/api/materials/share/__tests__/route.test.ts @@ -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({ diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts index 92689c7..67477bd 100644 --- a/app/api/materials/share/route.ts +++ b/app/api/materials/share/route.ts @@ -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( diff --git a/app/api/materials/stream/__tests__/route.test.ts b/app/api/materials/stream/__tests__/route.test.ts index e60977c..f7634fb 100644 --- a/app/api/materials/stream/__tests__/route.test.ts +++ b/app/api/materials/stream/__tests__/route.test.ts @@ -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() + }) }) diff --git a/app/api/materials/stream/route.ts b/app/api/materials/stream/route.ts index f874a1a..ca291a6 100644 --- a/app/api/materials/stream/route.ts +++ b/app/api/materials/stream/route.ts @@ -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 diff --git a/app/api/system-settings/__tests__/route.test.ts b/app/api/system-settings/__tests__/route.test.ts index 69d3ae1..7e7f57d 100644 --- a/app/api/system-settings/__tests__/route.test.ts +++ b/app/api/system-settings/__tests__/route.test.ts @@ -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', + ) }) }) diff --git a/app/api/system-settings/route.ts b/app/api/system-settings/route.ts index 706baef..feca83f 100644 --- a/app/api/system-settings/route.ts +++ b/app/api/system-settings/route.ts @@ -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>` - SELECT "enabledBuiltinTemplates", "homeDeleteButtonMode", "premiumRatio" FROM "system_settings" LIMIT 1 + const fullSettings = await prisma.$queryRaw>` + 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) + } + } } // 清除缓存,强制下次重新加载 diff --git a/docs/商业化地基排查问题清单_20260524.md b/docs/商业化地基排查问题清单_20260524.md index 079432a..7347c3c 100644 --- a/docs/商业化地基排查问题清单_20260524.md +++ b/docs/商业化地基排查问题清单_20260524.md @@ -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 和素材流) diff --git a/docs/新手用户上手指引.md b/docs/新手用户上手指引.md index 9f4fb07..6ade6ad 100644 --- a/docs/新手用户上手指引.md +++ b/docs/新手用户上手指引.md @@ -49,7 +49,7 @@ - 收藏/点赞:可在素材卡片或详情里操作。 - 下载:是否显示下载按钮取决于当前权限和素材状态。 - 下载记录:系统会写入 `material_download_logs`,方便后续审计。 -- 分享链接:可用于外部预览;当前分享页存在下载入口,是否适合对外开放下载,需要按项目权限和交付策略确认,不能写成“外部分享一定不允许下载原文件”。 +- 分享链接:可用于外部预览,默认 7 天有效,可由有权限的用户撤销。管理员可以在后台关闭公开分享;关闭后不能新建外部分享链接,已有链接也会停止公开访问。当前公开分享页定位为预览,不直接开放源文件下载。 ### 5. 所有权移交 @@ -72,7 +72,8 @@ | 上传后长时间等待转码 | 刷新后再看;仍异常则联系管理员检查转码队列和 `ffmpeg` | | 我的素材为空 | 可能暂无上传、素材已移交、或当前账号归属变化,联系管理员核对 | | 下载按钮不可见 | 可能没有下载权限,或素材仍在处理中 | -| 分享给外部前不确定能不能下载 | 先用测试素材验证分享页行为,再决定是否对外发送 | +| 分享链接打不开 | 可能链接已过期、已撤销,或管理员关闭了公开分享 | +| 分享给外部前不确定是否合规 | 先用测试素材验证分享页行为,再决定是否对外发送 | ### 7. 建议的新手任务 diff --git a/lib/__tests__/video-transcode-service.test.ts b/lib/__tests__/video-transcode-service.test.ts index 2d7540e..ed9b56d 100644 --- a/lib/__tests__/video-transcode-service.test.ts +++ b/lib/__tests__/video-transcode-service.test.ts @@ -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') diff --git a/lib/public-share-settings.ts b/lib/public-share-settings.ts new file mode 100644 index 0000000..0696acd --- /dev/null +++ b/lib/public-share-settings.ts @@ -0,0 +1,21 @@ +import { prisma } from '@/lib/prisma' +import { ensureSystemSettingsOptionalColumns } from '@/lib/system-settings-columns' + +export async function isPublicShareEnabled(): Promise { + try { + await ensureSystemSettingsOptionalColumns() + + const rows = await prisma.$queryRaw>` + 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 + } +} diff --git a/lib/system-settings-columns.ts b/lib/system-settings-columns.ts index a55be34..4ce216e 100644 --- a/lib/system-settings-columns.ts +++ b/lib/system-settings-columns.ts @@ -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') } - diff --git a/lib/video-transcode-service.ts b/lib/video-transcode-service.ts index d57c345..c1d8f0d 100644 --- a/lib/video-transcode-service.ts +++ b/lib/video-transcode-service.ts @@ -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,46 +241,62 @@ export async function recoverStaleVideoTranscodeJobs() { return { recovered: 0, hasMore: false, skipped: true } } - const staleJobs = await videoTranscodeJob.findMany({ - where: { - OR: [ - { status: 'queued' }, - { status: 'processing', lockedAt: null }, - { status: 'processing', lockedAt: { lt: staleBefore } }, - ], - material: { - videoSourceUrl: { not: null }, - videoProcessingStatus: { in: ['pending', 'processing'] }, - }, - }, - include: { - material: { - select: { - id: true, - videoSourceUrl: true, - videoCoverUrl: true, - }, - }, - }, - take: RECOVERY_BATCH_SIZE, - orderBy: { updatedAt: 'asc' }, - }) - - const orphanMaterials = await prisma.$queryRaw>` - SELECT m.id, m."videoSourceUrl", m."videoCoverUrl" - FROM "materials" m - LEFT JOIN "video_transcode_jobs" j ON j."materialId" = m.id - WHERE (m."materialKind" = 'VIDEO' OR m."materialType" = '视频') - AND m."videoSourceUrl" IS NOT NULL - AND m."videoProcessingStatus" IN ('pending', 'processing') - AND j.id IS NULL - ORDER BY m."updatedAt" ASC - LIMIT ${RECOVERY_BATCH_SIZE} - ` + }> = [] + + try { + staleJobs = await videoTranscodeJob.findMany({ + where: { + OR: [ + { status: 'queued' }, + { status: 'processing', lockedAt: null }, + { status: 'processing', lockedAt: { lt: staleBefore } }, + ], + material: { + videoSourceUrl: { not: null }, + videoProcessingStatus: { in: ['pending', 'processing'] }, + }, + }, + include: { + material: { + select: { + id: true, + videoSourceUrl: true, + videoCoverUrl: true, + }, + }, + }, + take: RECOVERY_BATCH_SIZE, + orderBy: { updatedAt: 'asc' }, + }) + + orphanMaterials = await prisma.$queryRaw>` + SELECT m.id, m."videoSourceUrl", m."videoCoverUrl" + FROM "materials" m + LEFT JOIN "video_transcode_jobs" j ON j."materialId" = m.id + WHERE (m."materialKind" = 'VIDEO' OR m."materialType" = '视频') + AND m."videoSourceUrl" IS NOT NULL + AND m."videoProcessingStatus" IN ('pending', 'processing') + AND j.id IS NULL + 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 diff --git a/prisma/migrations/20260526090000_add_public_share_toggle/migration.sql b/prisma/migrations/20260526090000_add_public_share_toggle/migration.sql new file mode 100644 index 0000000..db814f8 --- /dev/null +++ b/prisma/migrations/20260526090000_add_public_share_toggle/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "system_settings" +ADD COLUMN IF NOT EXISTS "enablePublicShare" BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1ac403c..97b3eb9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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