(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' },