diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts index 6caa869..3b08d79 100644 --- a/app/api/materials/share/route.ts +++ b/app/api/materials/share/route.ts @@ -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', + }, + }, ); } diff --git a/app/share/[id]/page.test.tsx b/app/share/[id]/page.test.tsx new file mode 100644 index 0000000..9f20f32 --- /dev/null +++ b/app/share/[id]/page.test.tsx @@ -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: () =>
, +})) + +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() + + 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' }, + ) + }) +}) diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 7605714..15e3fa9 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -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 + 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(null) + const [videoShareWait, setVideoShareWait] = useState(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 ( +
+
+
+
+
+ +
+

+ 视频暂不可查看 +

+

+ {videoShareWait.message} +

+
+

当前还需约 {videoShareWait.remainingDays} 天

+ {availableAtLabel &&

预计开放时间:{availableAtLabel}

} +
+

+ 分享链接已经生效,无需重新分享;保护期结束后,使用同一个链接即可查看。 +

+ +
+
+
+ ) + } + if (!material) { return (
diff --git a/components/home/MaterialCard.test.tsx b/components/home/MaterialCard.test.tsx index 0e83ecd..a913044 100644 --- a/components/home/MaterialCard.test.tsx +++ b/components/home/MaterialCard.test.tsx @@ -140,6 +140,25 @@ describe('MaterialCard', () => { expect(screen.getByText('15天后可查看详情')).toBeInTheDocument() }) + test('15天保护期内仍可复制分享链接', () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: '分享' })) + + expect(defaultProps.onShare).toHaveBeenCalledWith('material-1') + expect(defaultProps.showToast).not.toHaveBeenCalled() + }) + test('接口未返回并发上限时按当前单任务策略展示', () => { render( { - 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="分享" > diff --git a/lib/__tests__/materials-share-security.test.ts b/lib/__tests__/materials-share-security.test.ts index 9426136..c4050be 100644 --- a/lib/__tests__/materials-share-security.test.ts +++ b/lib/__tests__/materials-share-security.test.ts @@ -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' },