diff --git a/app/api/materials/asset/__tests__/route.test.ts b/app/api/materials/asset/__tests__/route.test.ts
new file mode 100644
index 0000000..cdee0c9
--- /dev/null
+++ b/app/api/materials/asset/__tests__/route.test.ts
@@ -0,0 +1,213 @@
+import { NextRequest } from 'next/server'
+import { Readable } from 'stream'
+import { GET } from '../route'
+
+jest.mock('@/lib/auth', () => ({
+ authOptions: {},
+}))
+
+jest.mock('next-auth', () => ({
+ getServerSession: jest.fn(),
+}))
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ material: {
+ findMany: jest.fn(),
+ },
+ },
+}))
+
+jest.mock('@/lib/s3', () => ({
+ getS3ObjectStream: jest.fn(),
+ isS3Url: jest.fn((value?: string | null) => /^https?:\/\//.test(value || '')),
+}))
+
+jest.mock('@/lib/material-share-token', () => ({
+ findValidMaterialShareToken: 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 buildMaterialRecord = (overrides: Record = {}) => ({
+ id: 'material-1',
+ previewUrl: 'http://minio:9000/bucket/preview.webp',
+ zipFilePath: 'http://minio:9000/bucket/material.zip',
+ videoSourceUrl: null,
+ videoCoverUrl: null,
+ videoPreview540Url: null,
+ videoPreviewClipUrl: null,
+ audioUrl: null,
+ displayImages: '[]',
+ previewUrls: null,
+ metadata: null,
+ ...overrides,
+})
+
+describe('GET /api/materials/asset', () => {
+ beforeEach(() => {
+ jest.resetAllMocks()
+ isS3Url.mockImplementation((value?: string | null) => /^https?:\/\//.test(value || ''))
+ })
+
+ it('未登录时拒绝读取素材文件', async () => {
+ getServerSession.mockResolvedValue(null)
+
+ const response = await GET(
+ new NextRequest('http://localhost/api/materials/asset?url=http%3A%2F%2Fminio%3A9000%2Fbucket%2Fpreview.webp')
+ )
+ const data = await response.json()
+
+ expect(response.status).toBe(401)
+ expect(data.error).toBe('请先登录或使用有效分享链接')
+ expect(prisma.material.findMany).not.toHaveBeenCalled()
+ expect(getS3ObjectStream).not.toHaveBeenCalled()
+ })
+
+ it('文件地址不属于素材记录时拒绝读取', async () => {
+ getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
+ prisma.material.findMany.mockResolvedValue([])
+
+ const response = await GET(
+ new NextRequest('http://localhost/api/materials/asset?url=http%3A%2F%2Fminio%3A9000%2Fbucket%2Funknown.webp')
+ )
+ const data = await response.json()
+
+ expect(response.status).toBe(404)
+ expect(data.error).toBe('文件不属于当前素材库记录')
+ expect(getS3ObjectStream).not.toHaveBeenCalled()
+ })
+
+ it('已登录且文件属于素材记录时代理返回 S3 文件流', async () => {
+ const fileUrl = 'http://minio:9000/bucket/preview.webp'
+ getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
+ prisma.material.findMany.mockResolvedValue([buildMaterialRecord({ previewUrl: fileUrl })])
+ getS3ObjectStream.mockResolvedValue({
+ body: Readable.from([Buffer.from('asset-body')]),
+ contentLength: 10,
+ contentType: 'image/webp',
+ acceptRanges: 'bytes',
+ })
+
+ const response = await GET(
+ new NextRequest(`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}`)
+ )
+ const body = await response.text()
+
+ expect(response.status).toBe(200)
+ expect(response.headers.get('content-type')).toBe('image/webp')
+ expect(body).toBe('asset-body')
+ expect(getS3ObjectStream).toHaveBeenCalledWith(fileUrl, null)
+ })
+
+ it('未登录但分享票据有效时允许读取素材文件', async () => {
+ const fileUrl = 'http://minio:9000/bucket/preview.webp'
+ getServerSession.mockResolvedValue(null)
+ prisma.material.findMany.mockResolvedValue([buildMaterialRecord({ previewUrl: fileUrl })])
+ findValidMaterialShareToken.mockResolvedValue({ id: 'token-row', materialId: 'material-1' })
+ getS3ObjectStream.mockResolvedValue({
+ body: Readable.from([Buffer.from('asset-body')]),
+ contentLength: 10,
+ contentType: 'image/webp',
+ acceptRanges: 'bytes',
+ })
+
+ const response = await GET(
+ new NextRequest(
+ `http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}&shareToken=share-token`
+ )
+ )
+ const body = await response.text()
+
+ expect(response.status).toBe(200)
+ expect(body).toBe('asset-body')
+ expect(findValidMaterialShareToken).toHaveBeenCalledWith('share-token', 'material-1')
+ })
+
+ it('允许读取 metadata.previewUrls 中登记过的预览图', async () => {
+ const fileUrl = 'http://minio:9000/bucket/metadata-preview.webp'
+ getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
+ prisma.material.findMany.mockResolvedValue([
+ buildMaterialRecord({
+ metadata: JSON.stringify({
+ previewUrls: [fileUrl],
+ templateFields: {
+ externalRef: 'http://minio:9000/bucket/not-this.webp',
+ },
+ }),
+ })
+ ])
+ getS3ObjectStream.mockResolvedValue({
+ body: Readable.from([Buffer.from('metadata-preview')]),
+ contentLength: 16,
+ contentType: 'image/webp',
+ acceptRanges: 'bytes',
+ })
+
+ const response = await GET(
+ new NextRequest(`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}`)
+ )
+
+ await expect(response.text()).resolves.toBe('metadata-preview')
+ expect(response.status).toBe(200)
+ expect(getS3ObjectStream).toHaveBeenCalledWith(fileUrl, null)
+ })
+
+ it('不会把 metadata 普通自定义字段里的 URL 当成可代理文件', async () => {
+ const fileUrl = 'http://minio:9000/bucket/custom-field.webp'
+ getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
+ prisma.material.findMany.mockResolvedValue([
+ buildMaterialRecord({
+ metadata: JSON.stringify({
+ templateFields: {
+ externalRef: fileUrl,
+ },
+ }),
+ })
+ ])
+
+ const response = await GET(
+ new NextRequest(`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}`)
+ )
+ const data = await response.json()
+
+ expect(response.status).toBe(404)
+ expect(data.error).toBe('文件不属于当前素材库记录')
+ expect(getS3ObjectStream).not.toHaveBeenCalled()
+ })
+
+ it('第一条候选只是 metadata 普通字段命中时,会继续寻找真实文件归属素材', async () => {
+ const fileUrl = 'http://minio:9000/bucket/shared.webp'
+ getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
+ prisma.material.findMany.mockResolvedValue([
+ buildMaterialRecord({
+ id: 'material-false-positive',
+ previewUrl: 'http://minio:9000/bucket/other.webp',
+ metadata: JSON.stringify({
+ templateFields: { externalRef: fileUrl },
+ }),
+ }),
+ buildMaterialRecord({
+ id: 'material-real-owner',
+ previewUrl: fileUrl,
+ }),
+ ])
+ getS3ObjectStream.mockResolvedValue({
+ body: Readable.from([Buffer.from('real-owner')]),
+ contentLength: 10,
+ contentType: 'image/webp',
+ acceptRanges: 'bytes',
+ })
+
+ const response = await GET(
+ new NextRequest(`http://localhost/api/materials/asset?url=${encodeURIComponent(fileUrl)}`)
+ )
+
+ await expect(response.text()).resolves.toBe('real-owner')
+ expect(response.status).toBe(200)
+ expect(getS3ObjectStream).toHaveBeenCalledWith(fileUrl, null)
+ })
+})
diff --git a/app/api/materials/asset/route.ts b/app/api/materials/asset/route.ts
new file mode 100644
index 0000000..95bce9b
--- /dev/null
+++ b/app/api/materials/asset/route.ts
@@ -0,0 +1,291 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+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 { access } from 'fs/promises'
+import path from 'path'
+import { Readable } from 'stream'
+
+export const dynamic = 'force-dynamic'
+
+export async function GET(req: NextRequest) {
+ try {
+ const { searchParams } = new URL(req.url)
+ const fileUrl = searchParams.get('url') || ''
+ const shareToken = searchParams.get('shareToken') || ''
+
+ if (!fileUrl) {
+ return NextResponse.json(
+ { error: '缺少文件地址' },
+ { status: 400 }
+ )
+ }
+
+ if (!isAllowedMaterialFile(fileUrl)) {
+ return NextResponse.json(
+ { error: '文件地址不允许访问' },
+ { status: 403 }
+ )
+ }
+
+ const session = await getServerSession(authOptions)
+ if (!session?.user && !shareToken) {
+ return NextResponse.json(
+ { error: '请先登录或使用有效分享链接' },
+ { status: 401 }
+ )
+ }
+
+ const materialId = await findKnownMaterialFileId(fileUrl)
+ if (!materialId) {
+ return NextResponse.json(
+ { error: '文件不属于当前素材库记录' },
+ { status: 404 }
+ )
+ }
+
+ if (!session?.user) {
+ const validShareToken = shareToken
+ ? await findValidMaterialShareToken(shareToken, materialId)
+ : null
+
+ if (!validShareToken) {
+ return NextResponse.json(
+ { error: '请先登录或使用有效分享链接' },
+ { status: 401 }
+ )
+ }
+ }
+
+ const range = req.headers.get('range')
+
+ if (isS3Url(fileUrl)) {
+ const s3Object = await getS3ObjectStream(fileUrl, range)
+ const bodyStream = toWebStream(s3Object.body)
+ const headers: Record = {
+ 'Content-Type': s3Object.contentType || inferContentType(fileUrl),
+ 'Accept-Ranges': s3Object.acceptRanges || 'bytes',
+ 'Cache-Control': 'private, max-age=300',
+ }
+
+ if (range && s3Object.contentRange) {
+ headers['Content-Range'] = s3Object.contentRange
+ }
+
+ if (s3Object.contentLength) {
+ headers['Content-Length'] = s3Object.contentLength.toString()
+ }
+
+ return new NextResponse(bodyStream as any, {
+ status: range ? 206 : 200,
+ headers,
+ })
+ }
+
+ const filePath = resolveLocalMaterialPath(fileUrl)
+ if (!filePath) {
+ return NextResponse.json(
+ { error: '文件地址不允许访问' },
+ { status: 403 }
+ )
+ }
+
+ try {
+ await access(filePath)
+ } catch {
+ return NextResponse.json(
+ { error: '文件不存在,可能已被删除' },
+ { status: 404 }
+ )
+ }
+
+ return createLocalFileStreamResponse(filePath, {
+ contentType: inferContentType(filePath),
+ range,
+ headers: {
+ 'Cache-Control': 'private, max-age=300',
+ },
+ })
+ } catch (error) {
+ console.error('[MaterialAsset] GET error:', error)
+ return NextResponse.json(
+ { error: '读取素材文件失败' },
+ { status: 500 }
+ )
+ }
+}
+
+function isAllowedMaterialFile(fileUrl: string) {
+ if (isS3Url(fileUrl)) {
+ return true
+ }
+
+ return Boolean(resolveLocalMaterialPath(fileUrl))
+}
+
+async function findKnownMaterialFileId(fileUrl: string) {
+ const candidates = await prisma.material.findMany({
+ where: {
+ deletedAt: null,
+ OR: [
+ { previewUrl: fileUrl },
+ { zipFilePath: fileUrl },
+ { videoSourceUrl: fileUrl },
+ { videoCoverUrl: fileUrl },
+ { videoPreview540Url: fileUrl },
+ { videoPreviewClipUrl: fileUrl },
+ { audioUrl: fileUrl },
+ { displayImages: { contains: fileUrl } },
+ { previewUrls: { contains: fileUrl } },
+ { metadata: { contains: fileUrl } },
+ ],
+ },
+ take: 50,
+ select: {
+ id: true,
+ previewUrl: true,
+ zipFilePath: true,
+ videoSourceUrl: true,
+ videoCoverUrl: true,
+ videoPreview540Url: true,
+ videoPreviewClipUrl: true,
+ audioUrl: true,
+ displayImages: true,
+ previewUrls: true,
+ metadata: true,
+ },
+ })
+
+ const material = candidates.find((candidate) => materialContainsFileUrl(candidate, fileUrl))
+
+ return material?.id ?? null
+}
+
+function materialContainsFileUrl(
+ material: {
+ previewUrl: string
+ zipFilePath: string
+ videoSourceUrl: string | null
+ videoCoverUrl: string | null
+ videoPreview540Url: string | null
+ videoPreviewClipUrl: string | null
+ audioUrl: string | null
+ displayImages: string
+ previewUrls: string | null
+ metadata: string | null
+ },
+ fileUrl: string
+) {
+ const directFields = [
+ material.previewUrl,
+ material.zipFilePath,
+ material.videoSourceUrl,
+ material.videoCoverUrl,
+ material.videoPreview540Url,
+ material.videoPreviewClipUrl,
+ material.audioUrl,
+ ]
+
+ if (directFields.some((value) => value === fileUrl)) {
+ return true
+ }
+
+ const displayImages = parseUrlList(material.displayImages)
+ const previewUrls = [
+ ...parseUrlList(material.previewUrls),
+ ...parseMetadataPreviewUrls(material.metadata),
+ ]
+
+ return displayImages.includes(fileUrl) || previewUrls.includes(fileUrl)
+}
+
+function parseUrlList(value: string | null | undefined): string[] {
+ if (!value) return []
+
+ try {
+ const parsed = JSON.parse(value)
+ if (Array.isArray(parsed)) {
+ return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
+ }
+ } catch {
+ // Legacy rows may store a single URL string instead of a JSON array.
+ }
+
+ const trimmed = value.trim()
+ return trimmed ? [trimmed] : []
+}
+
+function parseMetadataPreviewUrls(value: string | null | undefined): string[] {
+ if (!value) return []
+
+ try {
+ const parsed = JSON.parse(value)
+ if (parsed && typeof parsed === 'object' && Array.isArray(parsed.previewUrls)) {
+ return parsed.previewUrls.filter((item: unknown): item is string =>
+ typeof item === 'string' && item.trim().length > 0
+ )
+ }
+ } catch {
+ return []
+ }
+
+ return []
+}
+
+function resolveLocalMaterialPath(filePathOrUrl: string) {
+ if (!filePathOrUrl || isS3Url(filePathOrUrl)) return null
+
+ const workspace = process.cwd()
+ const rawPath = path.isAbsolute(filePathOrUrl)
+ ? filePathOrUrl
+ : path.join(workspace, filePathOrUrl)
+ const resolved = path.resolve(rawPath)
+ const allowedRoots = [
+ path.resolve(workspace, 'uploads'),
+ path.resolve(workspace, 'public'),
+ ]
+
+ return allowedRoots.some((root) => resolved === root || resolved.startsWith(`${root}${path.sep}`))
+ ? resolved
+ : null
+}
+
+function inferContentType(filePathOrUrl: string) {
+ const ext = path.extname(filePathOrUrl.split('?')[0] || '').toLowerCase()
+ const types: Record = {
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.png': 'image/png',
+ '.webp': 'image/webp',
+ '.gif': 'image/gif',
+ '.svg': 'image/svg+xml',
+ '.mp4': 'video/mp4',
+ '.webm': 'video/webm',
+ '.mov': 'video/quicktime',
+ '.mp3': 'audio/mpeg',
+ '.wav': 'audio/wav',
+ '.ogg': 'audio/ogg',
+ '.m4a': 'audio/mp4',
+ }
+
+ return types[ext] || 'application/octet-stream'
+}
+
+function toWebStream(body: any) {
+ if (!body) {
+ throw new Error('S3 object body is empty')
+ }
+
+ if (typeof (body as any).transformToWebStream === 'function') {
+ return (body as any).transformToWebStream()
+ }
+
+ if (body instanceof Readable) {
+ return Readable.toWeb(body)
+ }
+
+ return body as ReadableStream
+}
diff --git a/app/api/materials/download/__tests__/route.test.ts b/app/api/materials/download/__tests__/route.test.ts
new file mode 100644
index 0000000..80ed555
--- /dev/null
+++ b/app/api/materials/download/__tests__/route.test.ts
@@ -0,0 +1,104 @@
+import { mkdtemp, rm, writeFile } from 'fs/promises'
+import { tmpdir } from 'os'
+import path from 'path'
+import { NextRequest } from 'next/server'
+import { GET } from '../route'
+
+jest.mock('@/lib/auth', () => ({
+ authOptions: {},
+}))
+
+jest.mock('next-auth', () => ({
+ getServerSession: jest.fn(),
+}))
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ user: {
+ findUnique: jest.fn(),
+ },
+ material: {
+ findUnique: jest.fn(),
+ update: jest.fn(),
+ },
+ $executeRaw: jest.fn(),
+ },
+}))
+
+jest.mock('@/lib/s3', () => ({
+ getS3ObjectStream: jest.fn(),
+ isS3Url: jest.fn(() => false),
+}))
+
+jest.mock('@/lib/material-download-log', () => ({
+ ensureMaterialDownloadLogsTable: jest.fn(),
+}))
+
+const { getServerSession } = require('next-auth')
+const { prisma } = require('@/lib/prisma')
+const { getS3ObjectStream } = require('@/lib/s3')
+const { ensureMaterialDownloadLogsTable } = require('@/lib/material-download-log')
+
+describe('GET /api/materials/download', () => {
+ let tempDir: string
+ let filePath: string
+
+ beforeEach(async () => {
+ jest.resetAllMocks()
+ tempDir = await mkdtemp(path.join(tmpdir(), 'flnxi-download-'))
+ filePath = path.join(tempDir, 'source.zip')
+ await writeFile(filePath, '0123456789')
+
+ getServerSession.mockResolvedValue({
+ user: {
+ id: 'owner-1',
+ role: 'ADMIN',
+ email: 'owner@example.com',
+ },
+ })
+
+ prisma.material.findUnique.mockResolvedValue({
+ id: 'material-1',
+ userId: 'owner-1',
+ productModel: 'MODEL',
+ zipFilePath: filePath,
+ zipFileName: 'source.zip',
+ materialType: '3DMAX',
+ categoryName: '分类',
+ sceneName: '场景',
+ authorName: '作者',
+ })
+ })
+
+ afterEach(async () => {
+ await rm(tempDir, { recursive: true, force: true })
+ })
+
+ test('legacy 本地文件下载应流式返回完整文件', async () => {
+ const response = await GET(new NextRequest('http://localhost/api/materials/download?id=material-1'))
+
+ await expect(response.text()).resolves.toBe('0123456789')
+ expect(response.status).toBe(200)
+ expect(response.headers.get('content-length')).toBe('10')
+ expect(response.headers.get('accept-ranges')).toBe('bytes')
+ expect(response.headers.get('content-disposition')).toContain("filename*=UTF-8''")
+ expect(getS3ObjectStream).not.toHaveBeenCalled()
+ expect(ensureMaterialDownloadLogsTable).not.toHaveBeenCalled()
+ })
+
+ test('legacy 本地文件 Range 下载应只返回指定范围', async () => {
+ const response = await GET(
+ new NextRequest('http://localhost/api/materials/download?id=material-1', {
+ headers: {
+ range: 'bytes=3-6',
+ },
+ })
+ )
+
+ await expect(response.text()).resolves.toBe('3456')
+ expect(response.status).toBe(206)
+ expect(response.headers.get('content-range')).toBe('bytes 3-6/10')
+ expect(response.headers.get('content-length')).toBe('4')
+ expect(response.headers.get('content-disposition')).toContain("filename*=UTF-8''")
+ })
+})
diff --git a/app/api/materials/download/route.ts b/app/api/materials/download/route.ts
index a536672..10cd767 100644
--- a/app/api/materials/download/route.ts
+++ b/app/api/materials/download/route.ts
@@ -4,7 +4,8 @@ import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { getS3ObjectStream, isS3Url } from '@/lib/s3'
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'
-import { readFile, access } from 'fs/promises'
+import { createLocalFileStreamResponse } from '@/lib/local-file-stream'
+import { access } from 'fs/promises'
import path from 'path'
import { Readable } from 'stream'
@@ -214,33 +215,12 @@ export async function GET(req: NextRequest) {
)
}
- const fileBuffer = await readFile(filePath)
-
- if (material.materialType === '视频' && rangeHeader) {
- const fileSize = fileBuffer.length
- const parts = rangeHeader.replace(/bytes=/, '').split('-')
- const start = parseInt(parts[0], 10)
- const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
- const chunkSize = end - start + 1
- const chunk = fileBuffer.slice(start, end + 1)
-
- return new NextResponse(chunk as any, {
- status: 206,
- headers: {
- 'Content-Type': contentType,
- 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
- 'Accept-Ranges': 'bytes',
- 'Content-Length': chunkSize.toString(),
- },
- })
- }
-
- return new NextResponse(fileBuffer as any, {
+ return createLocalFileStreamResponse(filePath, {
+ contentType,
+ range: rangeHeader,
headers: {
- 'Content-Type': contentType,
'Content-Disposition': contentDispositionHeader,
- 'Content-Length': fileBuffer.length.toString(),
- 'Accept-Ranges': 'bytes', // 支持Range请求(用于视频流式传输)
+ 'Cache-Control': 'no-store',
},
})
} catch (error) {
@@ -272,4 +252,3 @@ function toWebStream(body: any) {
return body as ReadableStream
}
-
diff --git a/app/api/materials/share/__tests__/route.test.ts b/app/api/materials/share/__tests__/route.test.ts
new file mode 100644
index 0000000..c3f0524
--- /dev/null
+++ b/app/api/materials/share/__tests__/route.test.ts
@@ -0,0 +1,185 @@
+import { NextRequest } from 'next/server'
+import { GET, POST } from '../route'
+
+jest.mock('@/lib/auth', () => ({
+ authOptions: {},
+}))
+
+jest.mock('next-auth', () => ({
+ getServerSession: jest.fn(),
+}))
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ material: {
+ findFirst: jest.fn(),
+ findUnique: jest.fn(),
+ },
+ user: {
+ findUnique: jest.fn(),
+ },
+ },
+}))
+
+jest.mock('@/lib/material-share-token', () => ({
+ createMaterialShareToken: jest.fn(),
+ findValidMaterialShareToken: jest.fn(),
+}))
+
+jest.mock('@/lib/url-rewrite', () => ({
+ toSharedMaterialAssetProxyUrl: jest.fn((url, token) =>
+ url ? `/api/materials/asset?url=${encodeURIComponent(url)}&shareToken=${encodeURIComponent(token)}` : null
+ ),
+}))
+
+const { getServerSession } = require('next-auth')
+const { prisma } = require('@/lib/prisma')
+const { createMaterialShareToken, findValidMaterialShareToken } = require('@/lib/material-share-token')
+
+describe('/api/materials/share', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('未登录时拒绝创建分享票据', async () => {
+ getServerSession.mockResolvedValue(null)
+
+ 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(401)
+ expect(data.error).toBe('请先登录')
+ 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 })
+ prisma.material.findFirst.mockResolvedValue({
+ id: 'material-1',
+ userId: 'user-1',
+ ownerType: 'USER',
+ ownerId: 'user-1',
+ createdByUserId: 'user-1',
+ })
+ createMaterialShareToken.mockResolvedValue({
+ token: 'share-token',
+ expiresAt: new Date('2026-05-31T00:00:00.000Z'),
+ })
+
+ 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(200)
+ expect(prisma.material.findFirst).toHaveBeenCalledWith({
+ where: { id: 'material-1', deletedAt: null },
+ select: {
+ id: true,
+ userId: true,
+ ownerType: true,
+ ownerId: true,
+ createdByUserId: true,
+ },
+ })
+ expect(createMaterialShareToken).toHaveBeenCalledWith('material-1', 'user-1')
+ expect(data.shareToken).toBe('share-token')
+ })
+
+ it('无关普通用户不能为别人的素材创建分享票据', async () => {
+ getServerSession.mockResolvedValue({ user: { id: 'user-3', role: 'USER' } })
+ prisma.user.findUnique.mockResolvedValue({ id: 'user-3', role: 'USER', isSuperAdmin: false })
+ prisma.material.findFirst.mockResolvedValue({
+ id: 'material-1',
+ userId: 'user-1',
+ ownerType: 'USER',
+ ownerId: 'user-1',
+ createdByUserId: 'user-1',
+ })
+
+ 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(createMaterialShareToken).not.toHaveBeenCalled()
+ })
+
+ it('没有有效分享票据时拒绝公开读取素材', async () => {
+ findValidMaterialShareToken.mockResolvedValue(null)
+
+ const response = await GET(
+ new NextRequest('http://localhost/api/materials/share?id=material-1&token=bad-token')
+ )
+ const data = await response.json()
+
+ expect(response.status).toBe(403)
+ expect(data.error).toBe('分享链接无效或已过期')
+ expect(prisma.material.findFirst).not.toHaveBeenCalled()
+ })
+
+ it('有效分享票据可读取分享数据并把图片改成带票据的代理地址', async () => {
+ findValidMaterialShareToken.mockResolvedValue({ id: 'token-row', materialId: 'material-1' })
+ prisma.material.findFirst.mockResolvedValue({
+ id: 'material-1',
+ previewUrl: 'http://minio:9000/bucket/preview.webp',
+ productModel: 'SPU-001',
+ brandName: null,
+ seriesName: null,
+ skuCode: null,
+ materialTexture: null,
+ categoryName: '店铺',
+ sceneName: '场景',
+ materialType: '3DMAX',
+ renderer: 'CR',
+ renderTime: null,
+ imageResolution: null,
+ colorMode: null,
+ authorName: '作者',
+ zipFileName: 'material.zip',
+ zipFileSize: BigInt(1024),
+ downloadCount: 0,
+ likeCount: 0,
+ displayImages: JSON.stringify(['http://minio:9000/bucket/detail.webp']),
+ createdAt: new Date('2026-05-24T00:00:00.000Z'),
+ isPremium: false,
+ videoCoverUrl: null,
+ videoPreview540Url: null,
+ videoPreviewClipUrl: null,
+ audioUrl: null,
+ })
+
+ 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(200)
+ expect(findValidMaterialShareToken).toHaveBeenCalledWith('share-token', 'material-1')
+ expect(prisma.material.findFirst).toHaveBeenCalledWith({
+ where: { id: 'material-1', deletedAt: null },
+ select: expect.objectContaining({
+ id: true,
+ previewUrl: true,
+ audioUrl: true,
+ }),
+ })
+ expect(data.zipFileSize).toBe('1024')
+ expect(data.previewUrl).toContain('shareToken=share-token')
+ expect(data.displayImages[0]).toContain('shareToken=share-token')
+ })
+})
diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts
index ff9263a..92689c7 100644
--- a/app/api/materials/share/route.ts
+++ b/app/api/materials/share/route.ts
@@ -2,6 +2,12 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
+import { createMaterialShareToken, findValidMaterialShareToken } from '@/lib/material-share-token'
+import { toSharedMaterialAssetProxyUrl } from '@/lib/url-rewrite'
+import {
+ canManageMaterialShare,
+ getMaterialShareActor,
+} from '@/lib/material-share-permissions'
function parseDisplayImages(value: string | null): string[] {
if (!value) return []
@@ -18,11 +24,78 @@ function parseDisplayImages(value: string | null): string[] {
export const dynamic = 'force-dynamic'
-// Get single material for sharing (公开访问,无需登录)
+// 创建公开分享票据(登录用户)
+export async function POST(req: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions)
+ const actor = await getMaterialShareActor(session)
+ if (!actor) {
+ return NextResponse.json(
+ { error: '请先登录' },
+ { status: 401 }
+ )
+ }
+
+ const body = await req.json().catch(() => ({}))
+ const materialId = typeof body?.materialId === 'string' ? body.materialId.trim() : ''
+
+ if (!materialId) {
+ return NextResponse.json(
+ { error: '缺少素材ID' },
+ { status: 400 }
+ )
+ }
+
+ const material = await prisma.material.findFirst({
+ where: {
+ id: materialId,
+ deletedAt: null,
+ },
+ select: {
+ id: true,
+ userId: true,
+ ownerType: true,
+ ownerId: true,
+ createdByUserId: true,
+ },
+ })
+
+ if (!material) {
+ return NextResponse.json(
+ { error: '素材不存在或已删除' },
+ { status: 404 }
+ )
+ }
+
+ if (!canManageMaterialShare(material, actor)) {
+ return NextResponse.json(
+ { error: '无权分享该素材' },
+ { status: 403 }
+ )
+ }
+
+ const tokenResult = await createMaterialShareToken(material.id, actor.id)
+
+ return NextResponse.json({
+ materialId: material.id,
+ shareToken: tokenResult.token,
+ expiresAt: tokenResult.expiresAt.toISOString(),
+ })
+ } catch (error) {
+ console.error('Error creating material share token:', error)
+ return NextResponse.json(
+ { error: '创建分享链接失败' },
+ { status: 500 }
+ )
+ }
+}
+
+// Get single material for sharing (公开访问,但必须携带有效分享票据)
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const id = searchParams.get('id')
+ const shareToken = searchParams.get('token') || searchParams.get('shareToken') || ''
if (!id) {
return NextResponse.json(
@@ -31,8 +104,19 @@ export async function GET(req: NextRequest) {
)
}
- const material = await prisma.material.findUnique({
- where: { id },
+ const validShareToken = await findValidMaterialShareToken(shareToken, id)
+ if (!validShareToken) {
+ return NextResponse.json(
+ { error: '分享链接无效或已过期' },
+ { status: 403 }
+ )
+ }
+
+ const material = await prisma.material.findFirst({
+ where: {
+ id,
+ deletedAt: null,
+ },
select: {
id: true,
previewUrl: true,
@@ -56,6 +140,10 @@ export async function GET(req: NextRequest) {
displayImages: true,
createdAt: true,
isPremium: true,
+ videoCoverUrl: true,
+ videoPreview540Url: true,
+ videoPreviewClipUrl: true,
+ audioUrl: true,
}
})
@@ -70,7 +158,14 @@ export async function GET(req: NextRequest) {
const materialData = {
...material,
zipFileSize: material.zipFileSize.toString(),
- displayImages: parseDisplayImages(material.displayImages),
+ previewUrl: toSharedMaterialAssetProxyUrl(material.previewUrl, shareToken),
+ displayImages: parseDisplayImages(material.displayImages).map((url) =>
+ toSharedMaterialAssetProxyUrl(url, shareToken)
+ ),
+ videoCoverUrl: toSharedMaterialAssetProxyUrl(material.videoCoverUrl, shareToken),
+ videoPreview540Url: toSharedMaterialAssetProxyUrl(material.videoPreview540Url, shareToken),
+ videoPreviewClipUrl: toSharedMaterialAssetProxyUrl(material.videoPreviewClipUrl, shareToken),
+ audioUrl: toSharedMaterialAssetProxyUrl(material.audioUrl, shareToken),
}
return NextResponse.json(materialData)
diff --git a/app/api/materials/share/tokens/[id]/__tests__/route.test.ts b/app/api/materials/share/tokens/[id]/__tests__/route.test.ts
new file mode 100644
index 0000000..fe838bc
--- /dev/null
+++ b/app/api/materials/share/tokens/[id]/__tests__/route.test.ts
@@ -0,0 +1,130 @@
+import { NextRequest } from 'next/server'
+import { DELETE } from '../route'
+
+jest.mock('@/lib/auth', () => ({
+ authOptions: {},
+}))
+
+jest.mock('next-auth', () => ({
+ getServerSession: jest.fn(),
+}))
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ materialShareToken: {
+ findUnique: jest.fn(),
+ update: jest.fn(),
+ },
+ user: {
+ findUnique: jest.fn(),
+ },
+ },
+}))
+
+const { getServerSession } = require('next-auth')
+const { prisma } = require('@/lib/prisma')
+
+describe('DELETE /api/materials/share/tokens/[id]', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('未登录时拒绝撤销分享链接', async () => {
+ getServerSession.mockResolvedValue(null)
+
+ const response = await DELETE(
+ new NextRequest('http://localhost/api/materials/share/tokens/token-1', { method: 'DELETE' }),
+ { params: { id: 'token-1' } }
+ )
+ const data = await response.json()
+
+ expect(response.status).toBe(401)
+ expect(data.error).toBe('请先登录')
+ expect(prisma.materialShareToken.findUnique).not.toHaveBeenCalled()
+ })
+
+ it('无关普通用户不能撤销别人的素材分享链接', async () => {
+ getServerSession.mockResolvedValue({ user: { id: 'user-3', role: 'USER' } })
+ prisma.user.findUnique.mockResolvedValue({ id: 'user-3', role: 'USER', isSuperAdmin: false })
+ prisma.materialShareToken.findUnique.mockResolvedValue({
+ id: 'token-1',
+ revokedAt: null,
+ material: {
+ id: 'material-1',
+ userId: 'user-1',
+ ownerType: 'USER',
+ ownerId: 'user-1',
+ createdByUserId: 'user-1',
+ deletedAt: null,
+ },
+ })
+
+ const response = await DELETE(
+ new NextRequest('http://localhost/api/materials/share/tokens/token-1', { method: 'DELETE' }),
+ { params: { id: 'token-1' } }
+ )
+ const data = await response.json()
+
+ expect(response.status).toBe(403)
+ expect(data.error).toBe('无权撤销该分享链接')
+ expect(prisma.materialShareToken.update).not.toHaveBeenCalled()
+ })
+
+ it('素材负责人可以撤销分享链接', async () => {
+ getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
+ prisma.user.findUnique.mockResolvedValue({ id: 'user-1', role: 'USER', isSuperAdmin: false })
+ prisma.materialShareToken.findUnique.mockResolvedValue({
+ id: 'token-1',
+ revokedAt: null,
+ material: {
+ id: 'material-1',
+ userId: 'user-1',
+ ownerType: 'USER',
+ ownerId: 'user-1',
+ createdByUserId: 'user-1',
+ deletedAt: null,
+ },
+ })
+ prisma.materialShareToken.update.mockResolvedValue({ id: 'token-1' })
+
+ const response = await DELETE(
+ new NextRequest('http://localhost/api/materials/share/tokens/token-1', { method: 'DELETE' }),
+ { params: { id: 'token-1' } }
+ )
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.success).toBe(true)
+ expect(prisma.materialShareToken.update).toHaveBeenCalledWith({
+ where: { id: 'token-1' },
+ data: { revokedAt: expect.any(Date) },
+ })
+ })
+
+ it('已经撤销的分享链接再次撤销也返回成功且不重复写入', async () => {
+ getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } })
+ prisma.user.findUnique.mockResolvedValue({ id: 'admin-1', role: 'ADMIN', isSuperAdmin: false })
+ prisma.materialShareToken.findUnique.mockResolvedValue({
+ id: 'token-1',
+ revokedAt: new Date('2026-05-24T00:00:00.000Z'),
+ material: {
+ id: 'material-1',
+ userId: 'user-1',
+ ownerType: 'USER',
+ ownerId: 'user-1',
+ createdByUserId: 'user-1',
+ deletedAt: null,
+ },
+ })
+
+ const response = await DELETE(
+ new NextRequest('http://localhost/api/materials/share/tokens/token-1', { method: 'DELETE' }),
+ { params: { id: 'token-1' } }
+ )
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.success).toBe(true)
+ expect(prisma.materialShareToken.update).not.toHaveBeenCalled()
+ })
+})
diff --git a/app/api/materials/share/tokens/[id]/route.ts b/app/api/materials/share/tokens/[id]/route.ts
new file mode 100644
index 0000000..54ec1df
--- /dev/null
+++ b/app/api/materials/share/tokens/[id]/route.ts
@@ -0,0 +1,82 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { prisma } from '@/lib/prisma'
+import {
+ canManageMaterialShare,
+ getMaterialShareActor,
+} from '@/lib/material-share-permissions'
+
+export const dynamic = 'force-dynamic'
+
+export async function DELETE(
+ _req: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ try {
+ const session = await getServerSession(authOptions)
+ const actor = await getMaterialShareActor(session)
+ if (!actor) {
+ return NextResponse.json(
+ { error: '请先登录' },
+ { status: 401 }
+ )
+ }
+
+ const tokenId = params.id?.trim()
+ if (!tokenId) {
+ return NextResponse.json(
+ { error: '缺少分享链接ID' },
+ { status: 400 }
+ )
+ }
+
+ const token = await prisma.materialShareToken.findUnique({
+ where: { id: tokenId },
+ include: {
+ material: {
+ select: {
+ id: true,
+ userId: true,
+ ownerType: true,
+ ownerId: true,
+ createdByUserId: true,
+ deletedAt: true,
+ },
+ },
+ },
+ })
+
+ if (!token || token.material.deletedAt) {
+ return NextResponse.json(
+ { error: '分享链接不存在或素材已删除' },
+ { status: 404 }
+ )
+ }
+
+ if (!canManageMaterialShare(token.material, actor)) {
+ return NextResponse.json(
+ { error: '无权撤销该分享链接' },
+ { status: 403 }
+ )
+ }
+
+ if (!token.revokedAt) {
+ await prisma.materialShareToken.update({
+ where: { id: token.id },
+ data: { revokedAt: new Date() },
+ })
+ }
+
+ return NextResponse.json({
+ success: true,
+ id: token.id,
+ })
+ } catch (error) {
+ console.error('[MaterialShareTokens] DELETE error:', error)
+ return NextResponse.json(
+ { error: '撤销分享链接失败' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/materials/share/tokens/__tests__/route.test.ts b/app/api/materials/share/tokens/__tests__/route.test.ts
new file mode 100644
index 0000000..e478ac7
--- /dev/null
+++ b/app/api/materials/share/tokens/__tests__/route.test.ts
@@ -0,0 +1,129 @@
+import { NextRequest } from 'next/server'
+import { GET } from '../route'
+
+jest.mock('@/lib/auth', () => ({
+ authOptions: {},
+}))
+
+jest.mock('next-auth', () => ({
+ getServerSession: jest.fn(),
+}))
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ material: {
+ findFirst: jest.fn(),
+ },
+ materialShareToken: {
+ findMany: jest.fn(),
+ },
+ user: {
+ findUnique: jest.fn(),
+ },
+ },
+}))
+
+const { getServerSession } = require('next-auth')
+const { prisma } = require('@/lib/prisma')
+
+describe('GET /api/materials/share/tokens', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('未登录时拒绝查看分享链接列表', async () => {
+ getServerSession.mockResolvedValue(null)
+
+ const response = await GET(
+ new NextRequest('http://localhost/api/materials/share/tokens?materialId=material-1')
+ )
+ const data = await response.json()
+
+ expect(response.status).toBe(401)
+ expect(data.error).toBe('请先登录')
+ expect(prisma.material.findFirst).not.toHaveBeenCalled()
+ })
+
+ it('无关普通用户不能查看别人的素材分享链接', async () => {
+ getServerSession.mockResolvedValue({ user: { id: 'user-3', role: 'USER' } })
+ prisma.user.findUnique.mockResolvedValue({ id: 'user-3', role: 'USER', isSuperAdmin: false })
+ prisma.material.findFirst.mockResolvedValue({
+ id: 'material-1',
+ userId: 'user-1',
+ ownerType: 'USER',
+ ownerId: 'user-1',
+ createdByUserId: 'user-1',
+ })
+
+ const response = await GET(
+ new NextRequest('http://localhost/api/materials/share/tokens?materialId=material-1')
+ )
+ const data = await response.json()
+
+ expect(response.status).toBe(403)
+ expect(data.error).toBe('无权查看该素材的分享链接')
+ expect(prisma.materialShareToken.findMany).not.toHaveBeenCalled()
+ })
+
+ it('素材负责人可以查看分享链接状态,但不会返回 token 明文或 hash', async () => {
+ getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
+ prisma.user.findUnique.mockResolvedValue({ id: 'user-1', role: 'USER', isSuperAdmin: false })
+ prisma.material.findFirst.mockResolvedValue({
+ id: 'material-1',
+ userId: 'user-1',
+ ownerType: 'USER',
+ ownerId: 'user-1',
+ createdByUserId: 'user-1',
+ })
+ prisma.materialShareToken.findMany.mockResolvedValue([
+ {
+ id: 'token-1',
+ materialId: 'material-1',
+ createdByUserId: 'user-1',
+ createdAt: new Date('2026-05-24T00:00:00.000Z'),
+ expiresAt: new Date('2999-01-01T00:00:00.000Z'),
+ revokedAt: null,
+ createdBy: {
+ id: 'user-1',
+ username: 'owner',
+ email: 'owner@example.com',
+ realName: null,
+ },
+ },
+ {
+ id: 'token-2',
+ materialId: 'material-1',
+ createdByUserId: 'user-1',
+ createdAt: new Date('2026-05-23T00:00:00.000Z'),
+ expiresAt: new Date('2026-05-30T00:00:00.000Z'),
+ revokedAt: new Date('2026-05-24T01:00:00.000Z'),
+ createdBy: {
+ id: 'user-1',
+ username: 'owner',
+ email: 'owner@example.com',
+ realName: '负责人',
+ },
+ },
+ ])
+
+ const response = await GET(
+ new NextRequest('http://localhost/api/materials/share/tokens?materialId=material-1')
+ )
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.tokens).toHaveLength(2)
+ expect(data.tokens[0]).toEqual(expect.objectContaining({
+ id: 'token-1',
+ status: 'active',
+ createdByName: 'owner',
+ }))
+ expect(data.tokens[1]).toEqual(expect.objectContaining({
+ id: 'token-2',
+ status: 'revoked',
+ createdByName: '负责人',
+ }))
+ expect(JSON.stringify(data)).not.toContain('tokenHash')
+ expect(JSON.stringify(data)).not.toContain('share-token')
+ })
+})
diff --git a/app/api/materials/share/tokens/route.ts b/app/api/materials/share/tokens/route.ts
new file mode 100644
index 0000000..3c982df
--- /dev/null
+++ b/app/api/materials/share/tokens/route.ts
@@ -0,0 +1,107 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
+import { prisma } from '@/lib/prisma'
+import {
+ canManageMaterialShare,
+ getMaterialShareActor,
+} from '@/lib/material-share-permissions'
+
+export const dynamic = 'force-dynamic'
+
+export async function GET(req: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions)
+ const actor = await getMaterialShareActor(session)
+ if (!actor) {
+ return NextResponse.json(
+ { error: '请先登录' },
+ { status: 401 }
+ )
+ }
+
+ const { searchParams } = new URL(req.url)
+ const materialId = searchParams.get('materialId')?.trim() || ''
+ if (!materialId) {
+ return NextResponse.json(
+ { error: '缺少素材ID' },
+ { status: 400 }
+ )
+ }
+
+ const material = await prisma.material.findFirst({
+ where: {
+ id: materialId,
+ deletedAt: null,
+ },
+ select: {
+ id: true,
+ userId: true,
+ ownerType: true,
+ ownerId: true,
+ createdByUserId: true,
+ },
+ })
+
+ if (!material) {
+ return NextResponse.json(
+ { error: '素材不存在或已删除' },
+ { status: 404 }
+ )
+ }
+
+ if (!canManageMaterialShare(material, actor)) {
+ return NextResponse.json(
+ { error: '无权查看该素材的分享链接' },
+ { status: 403 }
+ )
+ }
+
+ const now = new Date()
+ const tokens = await prisma.materialShareToken.findMany({
+ where: { materialId },
+ orderBy: { createdAt: 'desc' },
+ take: 50,
+ select: {
+ id: true,
+ materialId: true,
+ createdByUserId: true,
+ expiresAt: true,
+ revokedAt: true,
+ createdAt: true,
+ createdBy: {
+ select: {
+ id: true,
+ username: true,
+ email: true,
+ realName: true,
+ },
+ },
+ },
+ })
+
+ return NextResponse.json({
+ materialId,
+ tokens: tokens.map((token) => ({
+ id: token.id,
+ materialId: token.materialId,
+ createdByUserId: token.createdByUserId,
+ createdByName: token.createdBy.realName || token.createdBy.username || token.createdBy.email,
+ createdAt: token.createdAt.toISOString(),
+ expiresAt: token.expiresAt.toISOString(),
+ revokedAt: token.revokedAt ? token.revokedAt.toISOString() : null,
+ status: token.revokedAt
+ ? 'revoked'
+ : token.expiresAt <= now
+ ? 'expired'
+ : 'active',
+ })),
+ })
+ } catch (error) {
+ console.error('[MaterialShareTokens] GET error:', error)
+ return NextResponse.json(
+ { error: '获取分享链接失败' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/materials/stream/__tests__/route.test.ts b/app/api/materials/stream/__tests__/route.test.ts
new file mode 100644
index 0000000..e60977c
--- /dev/null
+++ b/app/api/materials/stream/__tests__/route.test.ts
@@ -0,0 +1,87 @@
+import { NextRequest } from 'next/server'
+import { Readable } from 'stream'
+import { GET } from '../route'
+
+jest.mock('@/lib/auth', () => ({
+ authOptions: {},
+}))
+
+jest.mock('next-auth', () => ({
+ getServerSession: jest.fn(),
+}))
+
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ material: {
+ findFirst: jest.fn(),
+ },
+ },
+}))
+
+jest.mock('@/lib/s3', () => ({
+ getS3ObjectStream: jest.fn(),
+ isS3Url: jest.fn((value?: string | null) => /^https?:\/\//.test(value || '')),
+}))
+
+jest.mock('@/lib/material-share-token', () => ({
+ findValidMaterialShareToken: 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')
+
+describe('GET /api/materials/stream', () => {
+ beforeEach(() => {
+ jest.resetAllMocks()
+ isS3Url.mockImplementation((value?: string | null) => /^https?:\/\//.test(value || ''))
+ })
+
+ 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)
+ findValidMaterialShareToken.mockResolvedValue(null)
+
+ const response = await GET(new NextRequest('http://localhost/api/materials/stream?id=material-1'))
+ const data = await response.json()
+
+ expect(response.status).toBe(401)
+ expect(data.error).toBe('请先登录或使用有效分享链接')
+ expect(getS3ObjectStream).not.toHaveBeenCalled()
+ })
+
+ 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)
+ findValidMaterialShareToken.mockResolvedValue({ id: 'token-row', materialId: 'material-1' })
+ getS3ObjectStream.mockResolvedValue({
+ body: Readable.from([Buffer.from('video-body')]),
+ contentLength: 10,
+ contentType: 'video/mp4',
+ acceptRanges: 'bytes',
+ })
+
+ const response = await GET(
+ new NextRequest('http://localhost/api/materials/stream?id=material-1&shareToken=share-token')
+ )
+ const body = await response.text()
+
+ expect(response.status).toBe(200)
+ expect(body).toBe('video-body')
+ expect(findValidMaterialShareToken).toHaveBeenCalledWith('share-token', 'material-1')
+ expect(response.headers.get('access-control-allow-origin')).toBeNull()
+ })
+})
diff --git a/app/api/materials/stream/route.ts b/app/api/materials/stream/route.ts
index dfdea75..f874a1a 100644
--- a/app/api/materials/stream/route.ts
+++ b/app/api/materials/stream/route.ts
@@ -1,17 +1,22 @@
import { NextRequest, NextResponse } from 'next/server'
+import { getServerSession } from 'next-auth'
+import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { getS3ObjectStream, isS3Url } from '@/lib/s3'
-import { readFile, access } from 'fs/promises'
+import { findValidMaterialShareToken } from '@/lib/material-share-token'
+import { createLocalFileStreamResponse } from '@/lib/local-file-stream'
+import { access } from 'fs/promises'
import path from 'path'
import { Readable } from 'stream'
export const dynamic = 'force-dynamic'
-// 视频流式传输API(允许未登录用户访问,仅用于视频播放)
+// 视频/音频流式传输 API:内部播放需登录,公开分享需有效分享票据。
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const id = searchParams.get('id')
+ const shareToken = searchParams.get('shareToken') || searchParams.get('token') || ''
if (!id) {
return NextResponse.json(
@@ -20,8 +25,18 @@ export async function GET(req: NextRequest) {
)
}
- const material = await prisma.material.findUnique({
- where: { id },
+ const material = await prisma.material.findFirst({
+ where: {
+ id,
+ deletedAt: null,
+ },
+ select: {
+ id: true,
+ zipFilePath: true,
+ zipFileName: true,
+ materialType: true,
+ materialKind: true,
+ },
})
if (!material) {
@@ -31,6 +46,20 @@ export async function GET(req: NextRequest) {
)
}
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ const validShareToken = shareToken
+ ? await findValidMaterialShareToken(shareToken, material.id)
+ : null
+
+ if (!validShareToken) {
+ return NextResponse.json(
+ { error: '请先登录或使用有效分享链接' },
+ { status: 401 }
+ )
+ }
+ }
+
// 只允许视频和音频类型
const isVideo = material.materialType === '视频' || material.materialKind === 'VIDEO'
const isAudio = material.materialType === '音频' || material.materialKind === 'AUDIO'
@@ -79,12 +108,7 @@ export async function GET(req: NextRequest) {
const headers: Record = {
'Content-Type': s3Object.contentType || contentType,
'Accept-Ranges': s3Object.acceptRanges || 'bytes',
- 'Cache-Control': 'public, max-age=3600',
- // CORS headers for audio visualization
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, OPTIONS',
- 'Access-Control-Allow-Headers': 'Range',
- 'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges',
+ 'Cache-Control': 'private, max-age=300',
}
if (range && s3Object.contentRange) {
@@ -116,44 +140,11 @@ export async function GET(req: NextRequest) {
{ status: 404 }
)
}
- const fileBuffer = await readFile(filePath)
-
- // CORS headers for audio visualization
- const corsHeaders = {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, OPTIONS',
- 'Access-Control-Allow-Headers': 'Range',
- 'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges',
- }
-
- if (range) {
- const fileSize = fileBuffer.length
- const parts = range.replace(/bytes=/, '').split('-')
- const start = parseInt(parts[0], 10)
- const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
- const chunkSize = end - start + 1
- const chunk = fileBuffer.slice(start, end + 1)
-
- return new NextResponse(chunk as any, {
- status: 206,
- headers: {
- 'Content-Type': contentType,
- 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
- 'Accept-Ranges': 'bytes',
- 'Content-Length': chunkSize.toString(),
- 'Cache-Control': 'public, max-age=3600',
- ...corsHeaders,
- },
- })
- }
-
- return new NextResponse(fileBuffer as any, {
+ return createLocalFileStreamResponse(filePath, {
+ contentType,
+ range,
headers: {
- 'Content-Type': contentType,
- 'Content-Length': fileBuffer.length.toString(),
- 'Accept-Ranges': 'bytes',
- 'Cache-Control': 'public, max-age=3600',
- ...corsHeaders,
+ 'Cache-Control': 'private, max-age=300',
},
})
} catch (error) {
diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx
index 84827ea..faf656c 100644
--- a/app/share/[id]/page.tsx
+++ b/app/share/[id]/page.tsx
@@ -2,7 +2,7 @@
* SharePage 组件
*
* 素材分享页面
- * 显示素材详情、预览图、下载等功能
+ * 显示素材详情和预览图
* 支持PSD/模特类型的多图展示
* 使用三层架构样式系统,支持深浅色主题
*
@@ -39,6 +39,10 @@ interface Material {
authorName: string
zipFileName: string
zipFileSize: string | number
+ videoCoverUrl?: string | null
+ videoPreview540Url?: string | null
+ videoPreviewClipUrl?: string | null
+ audioUrl?: string | null
downloadCount: number
likeCount: number
displayImages: string[] // PSD展示图片URLs
@@ -53,11 +57,10 @@ export default function SharePage() {
const { alert } = useDialog()
const params = useParams()
const materialId = params.id as string
+ const [shareToken, setShareToken] = useState('')
const [material, setMaterial] = useState(null)
const [loading, setLoading] = useState(true)
- const [downloading, setDownloading] = useState(false)
- const [downloadProgress, setDownloadProgress] = useState(0)
const [showBackToTop, setShowBackToTop] = useState(false)
const [aiEditEnabled, setAiEditEnabled] = useState(
(process.env.NEXT_PUBLIC_AI_EDIT_ENABLED || '').toLowerCase() === 'true'
@@ -65,7 +68,11 @@ export default function SharePage() {
const fetchMaterial = useCallback(async () => {
try {
- const response = await fetch(`/api/materials/share?id=${materialId}`)
+ const tokenParam = new URLSearchParams(window.location.search).get('token') || ''
+ setShareToken(tokenParam)
+ const response = await fetch(
+ `/api/materials/share?id=${encodeURIComponent(materialId)}&token=${encodeURIComponent(tokenParam)}`
+ )
if (response.ok) {
const data = await response.json()
setMaterial(data)
@@ -96,7 +103,7 @@ export default function SharePage() {
} finally {
setLoading(false)
}
- }, [materialId, router, session])
+ }, [materialId, router, session, alert])
useEffect(() => {
if (status !== 'loading') {
@@ -181,77 +188,6 @@ export default function SharePage() {
)
}
- const handleDownload = () => {
- if (!material) return
-
- setDownloading(true)
- setDownloadProgress(0)
-
- const xhr = new XMLHttpRequest()
-
- xhr.addEventListener('progress', (e) => {
- if (e.lengthComputable) {
- const percentComplete = Math.round((e.loaded / e.total) * 100)
- setDownloadProgress(percentComplete)
- }
- })
-
- xhr.addEventListener('load', () => {
- if (xhr.status === 200) {
- try {
- const blob = new Blob([xhr.response], { type: 'application/zip' })
-
- const contentDisposition = xhr.getResponseHeader('Content-Disposition')
- let filename = 'material.zip'
- if (contentDisposition) {
- const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
- if (filenameMatch && filenameMatch[1]) {
- filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''))
- }
- }
-
- const url = window.URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = filename
- document.body.appendChild(a)
- a.click()
- a.remove()
- window.URL.revokeObjectURL(url)
- } catch (error) {
- console.error('下载处理失败:', error)
- alert({
- title: '下载失败',
- message: '下载失败,请稍后重试',
- variant: 'error',
- })
- }
- } else {
- alert({
- title: '下载失败',
- message: '下载失败',
- variant: 'error',
- })
- }
- setDownloading(false)
- setDownloadProgress(0)
- })
-
- xhr.addEventListener('error', () => {
- alert({
- title: '下载失败',
- message: '下载失败,请稍后重试',
- variant: 'error',
- })
- setDownloading(false)
- setDownloadProgress(0)
- })
-
- xhr.open('GET', `/api/materials/download?id=${material.id}`)
- xhr.responseType = 'arraybuffer'
- xhr.send()
- }
-
if (loading) {
return (
@@ -357,39 +293,6 @@ export default function SharePage() {
{renderAdditionalInfo('compact')}
- {/* Download Button */}
-
-
{/* AI改图(占位,默认隐藏。后续由开关控制显示) */}
{aiEditEnabled && (
- {/* Download Button */}
-
-