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 && ( - -