fix: allow protected video share links

This commit is contained in:
chenpei 2026-08-13 09:21:02 +08:00
parent 0255368562
commit 2f4ee50a97
6 changed files with 248 additions and 10 deletions

View File

@ -170,7 +170,12 @@ export async function GET(request: NextRequest) {
availableAt: videoPreviewPolicy.videoPreviewAvailableAt,
remainingDays: videoPreviewPolicy.videoPreviewRemainingDays,
},
{ status: 403 },
{
status: 403,
headers: {
'Cache-Control': 'private, no-store, max-age=0',
},
},
);
}

View File

@ -0,0 +1,81 @@
import { render, screen } from '@testing-library/react'
import SharePage from './page'
const mockPush = jest.fn()
const mockReplace = jest.fn()
const mockBack = jest.fn()
const originalFetch = global.fetch
jest.mock('next-auth/react', () => ({
useSession: () => ({ data: null, status: 'unauthenticated' }),
}))
jest.mock('next/navigation', () => ({
useParams: () => ({ id: 'video-material-1' }),
useRouter: () => ({
push: mockPush,
replace: mockReplace,
back: mockBack,
}),
}))
jest.mock('next/image', () => ({
__esModule: true,
default: () => null,
}))
jest.mock('@/components/Header', () => ({
__esModule: true,
default: () => <header data-testid="header" />,
}))
jest.mock('@/hooks/useToast', () => ({
useToast: () => ({ toast: null, showToast: jest.fn() }),
}))
jest.mock('@/components/ConfirmActionDialog', () => ({
useConfirmActionDialog: () => ({
confirmAction: jest.fn(),
confirmActionDialog: null,
}),
}))
describe('video material share page', () => {
beforeEach(() => {
jest.clearAllMocks()
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 403,
json: async () => ({
error: '视频类素材上传后满 15 天才能查看详情',
code: 'VIDEO_WAIT_PERIOD',
details: '视频类素材上传后满 15 天才能预览和下载,当前还需约 12 天',
availableAt: '2026-09-01T00:00:00.000Z',
remainingDays: 12,
}),
}) as jest.Mock
})
afterEach(() => {
jest.restoreAllMocks()
})
afterAll(() => {
global.fetch = originalFetch
})
test('保护期内保留分享页并明确提示同一链接稍后可用', async () => {
render(<SharePage />)
expect(await screen.findByRole('heading', { name: '视频暂不可查看' })).toBeInTheDocument()
expect(screen.getByText('当前还需约 12 天')).toBeInTheDocument()
expect(screen.getByText(/分享链接已经生效,无需重新分享/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: '重新检查' })).toBeInTheDocument()
expect(mockPush).not.toHaveBeenCalled()
expect(global.fetch).toHaveBeenCalledWith(
'/api/materials/share?id=video-material-1',
{ cache: 'no-store' },
)
})
})

View File

@ -20,7 +20,7 @@ import MaterialDetailHeader, {
} from '@/components/detail/MaterialDetailHeader'
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
import DocumentAssetViewer, { type ViewerAssetItem } from '@/components/DocumentAssetViewer'
import { parseDownloadError } from '@/lib/download-errors'
import { DownloadErrorCode, parseDownloadError } from '@/lib/download-errors'
import { validateAndTriggerDownload } from '@/lib/client-download'
import { parseApiError, parseFetchError } from '@/lib/error-handler'
import { getCertificateReportDisplayTitle, isCertificateReportMaterialType } from '@/lib/certificate-report'
@ -84,6 +84,36 @@ interface Material {
}>
}
interface VideoShareWaitState {
message: string
availableAt: string | null
remainingDays: number
}
function parseVideoShareWaitState(value: unknown): VideoShareWaitState | null {
if (!value || typeof value !== 'object') return null
const payload = value as Record<string, unknown>
if (payload.code !== DownloadErrorCode.VIDEO_WAIT_PERIOD) return null
const parsedRemainingDays = Number(payload.remainingDays)
const remainingDays = Number.isFinite(parsedRemainingDays)
? Math.max(1, Math.ceil(parsedRemainingDays))
: 15
return {
message:
typeof payload.details === 'string' && payload.details.trim()
? payload.details
: '视频类素材上传后满 15 天才能查看详情',
availableAt:
typeof payload.availableAt === 'string' && payload.availableAt.trim()
? payload.availableAt
: null,
remainingDays,
}
}
export default function SharePage() {
const { data: session, status } = useSession()
const router = useRouter()
@ -91,6 +121,7 @@ export default function SharePage() {
const materialId = params?.id as string
const [material, setMaterial] = useState<Material | null>(null)
const [videoShareWait, setVideoShareWait] = useState<VideoShareWaitState | null>(null)
const [loading, setLoading] = useState(true)
const [downloading, setDownloading] = useState(false)
const [downloadProgress, setDownloadProgress] = useState(0)
@ -172,6 +203,7 @@ export default function SharePage() {
const fetchMaterial = async () => {
try {
setLoading(true)
setVideoShareWait(null)
// 如果是视频类型,立即开始预加载视频(在获取数据前就开始)
const isVideoType = materialId && (
@ -298,7 +330,17 @@ export default function SharePage() {
})
}
} else {
const error = await parseFetchError(response)
const errorPayload = await response.json().catch(() => null)
const waitState = parseVideoShareWaitState(errorPayload)
if (response.status === 403 && waitState) {
setMaterial(null)
setVideoShareWait(waitState)
return
}
const error = errorPayload
? parseApiError(errorPayload)
: await parseFetchError(response)
showToast(error.message, { variant: 'error' })
router.push('/')
}
@ -666,6 +708,53 @@ export default function SharePage() {
)
}
if (videoShareWait) {
const availableAt = videoShareWait.availableAt
? new Date(videoShareWait.availableAt)
: null
const availableAtLabel = availableAt && !Number.isNaN(availableAt.getTime())
? format(availableAt, 'yyyy-MM-dd HH:mm')
: null
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header session={session} />
<main className="container mx-auto px-4 py-8 sm:py-12">
<section
aria-labelledby="video-share-wait-title"
className="mx-auto max-w-2xl rounded-2xl border border-gray-200 bg-white px-5 py-8 text-center shadow-lg dark:border-gray-700 dark:bg-gray-800 sm:px-8 sm:py-10"
>
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-blue-50 text-blue-600 dark:bg-blue-950/50 dark:text-blue-300">
<svg className="h-7 w-7" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
<h1 id="video-share-wait-title" className="mt-5 text-2xl font-semibold text-gray-950 dark:text-white">
视频暂不可查看
</h1>
<p className="mx-auto mt-3 max-w-xl text-base leading-7 text-gray-600 dark:text-gray-300">
{videoShareWait.message}
</p>
<div className="mx-auto mt-6 max-w-md rounded-xl bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:bg-gray-900/70 dark:text-gray-300">
<p>当前还需约 {videoShareWait.remainingDays} 天</p>
{availableAtLabel && <p className="mt-1">预计开放时间:{availableAtLabel}</p>}
</div>
<p className="mt-5 text-sm leading-6 text-gray-500 dark:text-gray-400">
分享链接已经生效,无需重新分享;保护期结束后,使用同一个链接即可查看。
</p>
<button
type="button"
onClick={() => void fetchMaterial()}
className="mt-6 inline-flex min-h-11 items-center justify-center rounded-lg bg-blue-600 px-5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900"
>
重新检查
</button>
</section>
</main>
</div>
)
}
if (!material) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">

View File

@ -140,6 +140,25 @@ describe('MaterialCard', () => {
expect(screen.getByText('15天后可查看详情')).toBeInTheDocument()
})
test('15天保护期内仍可复制分享链接', () => {
render(
<MaterialCard
{...defaultProps}
material={{
...baseMaterial,
materialType: '淘系-逛逛视频',
videoPreviewBlocked: true,
videoPreviewRemainingDays: 12,
}}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '分享' }))
expect(defaultProps.onShare).toHaveBeenCalledWith('material-1')
expect(defaultProps.showToast).not.toHaveBeenCalled()
})
test('接口未返回并发上限时按当前单任务策略展示', () => {
render(
<MaterialCard

View File

@ -763,13 +763,9 @@ export default function MaterialCard({
{/* 分享按钮 */}
<button
onClick={() => {
if (isPreviewWaiting) {
showToast(previewUnavailableMessage, { variant: 'error' })
return
}
onShare(material.id)
}}
type="button"
onClick={() => onShare(material.id)}
aria-label="分享"
className="flex-none h-10 w-10 flex items-center justify-center rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
title="分享"
>

View File

@ -5,6 +5,7 @@ import path from 'path'
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { GET } from '@/app/api/materials/share/route'
import { DownloadErrorCode } from '@/lib/download-errors'
import { prisma } from '@/lib/prisma'
jest.mock('@/lib/prisma', () => ({
@ -122,6 +123,53 @@ describe('materials share security', () => {
})
})
test('未满15天的视频分享链接保留有效,但不返回任何视频内容', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial({
materialType: '淘系-逛逛视频',
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000),
videoSourceUrl: '/uploads/materials/source.mp4',
videoPreview540Url: 'https://example.com/preview.mp4',
videoPreviewClipUrl: 'https://example.com/clip.mp4',
}))
const response = await GET(
new NextRequest('http://localhost/api/materials/share?id=material-video'),
)
const body = await response.json()
expect(response.status).toBe(403)
expect(response.headers.get('Cache-Control')).toBe('private, no-store, max-age=0')
expect(body).toMatchObject({
code: DownloadErrorCode.VIDEO_WAIT_PERIOD,
remainingDays: expect.any(Number),
availableAt: expect.any(String),
})
expect(body.details).toContain('满 15 天')
expect(body).not.toHaveProperty('videoSourceUrl')
expect(body).not.toHaveProperty('videoPreview540Url')
expect(body).not.toHaveProperty('videoPreviewClipUrl')
})
test('视频满15天后同一分享链接正常返回预览', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial({
materialType: '淘系-逛逛视频',
createdAt: new Date(Date.now() - 16 * 24 * 60 * 60 * 1000),
videoSourceUrl: '/uploads/materials/source.mp4',
videoPreview540Url: 'https://example.com/preview.mp4',
videoPreviewClipUrl: 'https://example.com/clip.mp4',
}))
const response = await GET(
new NextRequest('http://localhost/api/materials/share?id=material-video'),
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.videoPreview540Url).toBeTruthy()
expect(body.videoPreviewClipUrl).toBeTruthy()
expect(body.videoSourceUrl).toBeNull()
})
test('素材所有者可收藏和编辑,但不能获得管理员删除权限', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'owner-1' },