chore: sync marketing pages, CMS extensions, tests and project docs
同步工作区剩余变更,主要包括: - 营销页面组件与布局持续优化(about/news/services/solutions/team 等) - 详情页四层叙事组件、布局组件、UI 组件调整 - CMS 数据模型、API 路由、权限、工作流、站内通知、媒体管理扩展 - 新增/补充单元测试与 E2E 测试(cms-workflow.spec.ts 等) - ESLint 9 迁移、jest/tsconfig 配置更新、依赖调整 - 新增 ADR、CMS 评估文档、Release Review / Acceptance 报告 - 移除水墨装饰组件与大体积未使用字体文件
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeAll } from '@jest/globals';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
isImage,
|
||||
getImageMetadata,
|
||||
generateDerivatives,
|
||||
THUMBNAIL_SIZE,
|
||||
} from './image-processor';
|
||||
|
||||
describe('image-processor', () => {
|
||||
let testPng: Buffer;
|
||||
let testJpeg: Buffer;
|
||||
const txtBuffer = Buffer.from('not an image');
|
||||
|
||||
beforeAll(async () => {
|
||||
testPng = await sharp({
|
||||
create: {
|
||||
width: 800,
|
||||
height: 600,
|
||||
channels: 3,
|
||||
background: { r: 255, g: 0, b: 0 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
testJpeg = await sharp({
|
||||
create: {
|
||||
width: 400,
|
||||
height: 300,
|
||||
channels: 3,
|
||||
background: { r: 0, g: 0, b: 255 },
|
||||
},
|
||||
})
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
});
|
||||
|
||||
describe('isImage', () => {
|
||||
it('returns true for common image mime types', () => {
|
||||
expect(isImage('image/png')).toBe(true);
|
||||
expect(isImage('image/jpeg')).toBe(true);
|
||||
expect(isImage('image/webp')).toBe(true);
|
||||
expect(isImage('image/avif')).toBe(true);
|
||||
expect(isImage('image/gif')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-image mime types', () => {
|
||||
expect(isImage('application/pdf')).toBe(false);
|
||||
expect(isImage('text/plain')).toBe(false);
|
||||
expect(isImage('video/mp4')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getImageMetadata', () => {
|
||||
it('returns width and height for a valid image buffer', async () => {
|
||||
const meta = await getImageMetadata(testPng);
|
||||
expect(meta).toEqual({ width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it('returns null for non-image buffer', async () => {
|
||||
const meta = await getImageMetadata(txtBuffer);
|
||||
expect(meta).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateDerivatives', () => {
|
||||
it('generates thumbnail, webp and avif for image/png', async () => {
|
||||
const derivatives = await generateDerivatives(testPng, 'image/png');
|
||||
|
||||
expect(derivatives.thumbnail).toBeDefined();
|
||||
expect(derivatives.webp).toBeDefined();
|
||||
expect(derivatives.avif).toBeDefined();
|
||||
|
||||
expect(derivatives.thumbnail!.width).toBeLessThanOrEqual(THUMBNAIL_SIZE);
|
||||
expect(derivatives.thumbnail!.height).toBeLessThanOrEqual(THUMBNAIL_SIZE);
|
||||
expect(derivatives.thumbnail!.path).toMatch(/-thumbnail\./);
|
||||
expect(derivatives.thumbnail!.url).toMatch(/-thumbnail\./);
|
||||
|
||||
expect(derivatives.webp!.path).toMatch(/\.webp$/);
|
||||
expect(derivatives.webp!.url).toMatch(/\.webp$/);
|
||||
|
||||
expect(derivatives.avif!.path).toMatch(/\.avif$/);
|
||||
expect(derivatives.avif!.url).toMatch(/\.avif$/);
|
||||
});
|
||||
|
||||
it('keeps original aspect ratio for thumbnail', async () => {
|
||||
const derivatives = await generateDerivatives(testPng, 'image/png');
|
||||
const thumb = derivatives.thumbnail!;
|
||||
expect(thumb.width / thumb.height).toBeCloseTo(800 / 600, 1);
|
||||
});
|
||||
|
||||
it('returns empty derivatives for non-image files', async () => {
|
||||
const derivatives = await generateDerivatives(txtBuffer, 'text/plain');
|
||||
expect(Object.keys(derivatives)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('generates webp/avif with same dimensions as original', async () => {
|
||||
const derivatives = await generateDerivatives(testJpeg, 'image/jpeg');
|
||||
expect(derivatives.webp!.width).toBe(400);
|
||||
expect(derivatives.webp!.height).toBe(300);
|
||||
expect(derivatives.avif!.width).toBe(400);
|
||||
expect(derivatives.avif!.height).toBe(300);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import sharp from 'sharp';
|
||||
import type { GeneratedDerivatives, ImageDerivative } from './types';
|
||||
|
||||
export const THUMBNAIL_SIZE = 320;
|
||||
|
||||
const IMAGE_MIME_PREFIX = 'image/';
|
||||
|
||||
/**
|
||||
* 判断 MIME 类型是否为图片
|
||||
*/
|
||||
export function isImage(mimeType: string): boolean {
|
||||
return mimeType.startsWith(IMAGE_MIME_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片宽高元数据
|
||||
* 非图片或解析失败时返回 null
|
||||
*/
|
||||
export async function getImageMetadata(
|
||||
buffer: Buffer
|
||||
): Promise<{ width: number; height: number } | null> {
|
||||
try {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
if (!metadata.width || !metadata.height) return null;
|
||||
return { width: metadata.width, height: metadata.height };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDerivativeFileName(originalName: string, suffix: string, ext: string): string {
|
||||
const baseName = originalName.replace(/\.[^.]+$/, '');
|
||||
return `${baseName}-${suffix}.${ext}`;
|
||||
}
|
||||
|
||||
function buildDerivative(
|
||||
originalName: string,
|
||||
suffix: string,
|
||||
ext: string,
|
||||
width: number,
|
||||
height: number,
|
||||
buffer: Buffer
|
||||
): ImageDerivative {
|
||||
const fileName = buildDerivativeFileName(originalName, suffix, ext);
|
||||
return {
|
||||
path: `public/uploads/${fileName}`,
|
||||
url: `/uploads/${fileName}`,
|
||||
width,
|
||||
height,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 为图片生成缩略图、WebP、AVIF 派生格式
|
||||
* 非图片返回空对象
|
||||
*
|
||||
* 实现说明:
|
||||
* - 缩略图限制长边为 THUMBNAIL_SIZE,保持比例
|
||||
* - WebP/AVIF 保持原图尺寸,用于现代浏览器优化
|
||||
*/
|
||||
export async function generateDerivatives(
|
||||
buffer: Buffer,
|
||||
mimeType: string
|
||||
): Promise<GeneratedDerivatives> {
|
||||
if (!isImage(mimeType)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const metadata = await getImageMetadata(buffer);
|
||||
if (!metadata) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const originalName = `image-${Date.now()}`;
|
||||
const derivatives: GeneratedDerivatives = {};
|
||||
|
||||
// 缩略图
|
||||
const thumbnailBuffer = await sharp(buffer)
|
||||
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, { fit: 'inside', withoutEnlargement: true })
|
||||
.toBuffer();
|
||||
const thumbnailMeta = await sharp(thumbnailBuffer).metadata();
|
||||
derivatives.thumbnail = buildDerivative(
|
||||
originalName,
|
||||
'thumbnail',
|
||||
'webp',
|
||||
thumbnailMeta.width || THUMBNAIL_SIZE,
|
||||
thumbnailMeta.height || THUMBNAIL_SIZE,
|
||||
thumbnailBuffer
|
||||
);
|
||||
|
||||
// WebP
|
||||
const webpBuffer = await sharp(buffer).webp({ quality: 85 }).toBuffer();
|
||||
const webpMeta = await sharp(webpBuffer).metadata();
|
||||
derivatives.webp = buildDerivative(
|
||||
originalName,
|
||||
'webp',
|
||||
'webp',
|
||||
webpMeta.width || metadata.width,
|
||||
webpMeta.height || metadata.height,
|
||||
webpBuffer
|
||||
);
|
||||
|
||||
// AVIF
|
||||
const avifBuffer = await sharp(buffer).avif({ quality: 80 }).toBuffer();
|
||||
const avifMeta = await sharp(avifBuffer).metadata();
|
||||
derivatives.avif = buildDerivative(
|
||||
originalName,
|
||||
'avif',
|
||||
'avif',
|
||||
avifMeta.width || metadata.width,
|
||||
avifMeta.height || metadata.height,
|
||||
avifBuffer
|
||||
);
|
||||
|
||||
return derivatives;
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
|
||||
// ─── Mock storage provider ───────────────────────────────────────────────
|
||||
const mockStorageSave = jest.fn() as jest.Mock<any>;
|
||||
const mockStorageDelete = jest.fn() as jest.Mock<any>;
|
||||
|
||||
jest.mock('./storage', () => ({
|
||||
getStorageProvider: jest.fn().mockReturnValue({
|
||||
type: 'local',
|
||||
save: mockStorageSave,
|
||||
delete: mockStorageDelete,
|
||||
}),
|
||||
}));
|
||||
|
||||
// ─── Mock image processor ────────────────────────────────────────────────
|
||||
const mockGenerateDerivatives = jest.fn() as jest.Mock<any>;
|
||||
const mockGetImageMetadata = jest.fn() as jest.Mock<any>;
|
||||
|
||||
jest.mock('./image-processor', () => ({
|
||||
generateDerivatives: mockGenerateDerivatives,
|
||||
getImageMetadata: mockGetImageMetadata,
|
||||
isImage: (mimeType: string) => mimeType.startsWith('image/'),
|
||||
THUMBNAIL_SIZE: 320,
|
||||
}));
|
||||
|
||||
// ─── Mock Prisma ─────────────────────────────────────────────────────────
|
||||
const mockMediaAssetCreate = jest.fn() as jest.Mock<any>;
|
||||
const mockMediaAssetFindUnique = jest.fn() as jest.Mock<any>;
|
||||
const mockMediaAssetDelete = jest.fn() as jest.Mock<any>;
|
||||
const mockMediaAssetFindMany = jest.fn() as jest.Mock<any>;
|
||||
const mockMediaAssetCount = jest.fn() as jest.Mock<any>;
|
||||
|
||||
jest.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
mediaAsset: {
|
||||
create: mockMediaAssetCreate,
|
||||
findUnique: mockMediaAssetFindUnique,
|
||||
delete: mockMediaAssetDelete,
|
||||
findMany: mockMediaAssetFindMany,
|
||||
count: mockMediaAssetCount,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { uploadMedia, deleteMedia, getMediaById, listMedia } from './media-service';
|
||||
|
||||
describe('media-service', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('uploadMedia', () => {
|
||||
it('uploads image and creates MediaAsset with derivatives', async () => {
|
||||
const pngBuffer = Buffer.from('fake-png');
|
||||
mockStorageSave
|
||||
.mockResolvedValueOnce({ path: 'public/uploads/original.png', url: '/uploads/original.png' })
|
||||
.mockResolvedValueOnce({ path: 'public/uploads/thumb.webp', url: '/uploads/thumb.webp' })
|
||||
.mockResolvedValueOnce({ path: 'public/uploads/original-webp.webp', url: '/uploads/original-webp.webp' })
|
||||
.mockResolvedValueOnce({ path: 'public/uploads/original-avif.avif', url: '/uploads/original-avif.avif' });
|
||||
|
||||
mockGetImageMetadata.mockResolvedValue({ width: 800, height: 600 });
|
||||
mockGenerateDerivatives.mockResolvedValue({
|
||||
thumbnail: { path: 'public/uploads/thumb.webp', url: '/uploads/thumb.webp', width: 320, height: 240, buffer: Buffer.from('thumb') },
|
||||
webp: { path: 'public/uploads/original-webp.webp', url: '/uploads/original-webp.webp', width: 800, height: 600, buffer: Buffer.from('webp') },
|
||||
avif: { path: 'public/uploads/original-avif.avif', url: '/uploads/original-avif.avif', width: 800, height: 600, buffer: Buffer.from('avif') },
|
||||
});
|
||||
|
||||
mockMediaAssetCreate.mockResolvedValue({ id: 'asset-1' });
|
||||
|
||||
const result = await uploadMedia(
|
||||
{ name: 'original.png', buffer: pngBuffer, mimeType: 'image/png', size: 1024 },
|
||||
'editor'
|
||||
);
|
||||
|
||||
expect(mockStorageSave).toHaveBeenCalledTimes(4);
|
||||
expect(mockGenerateDerivatives).toHaveBeenCalledWith(pngBuffer, 'image/png');
|
||||
expect(mockMediaAssetCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
name: 'original.png',
|
||||
path: 'public/uploads/original.png',
|
||||
url: '/uploads/original.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1024,
|
||||
width: 800,
|
||||
height: 600,
|
||||
storageType: 'local',
|
||||
derivatives: JSON.stringify({
|
||||
thumbnail: { path: 'public/uploads/thumb.webp', url: '/uploads/thumb.webp', width: 320, height: 240 },
|
||||
webp: { path: 'public/uploads/original-webp.webp', url: '/uploads/original-webp.webp', width: 800, height: 600 },
|
||||
avif: { path: 'public/uploads/original-avif.avif', url: '/uploads/original-avif.avif', width: 800, height: 600 },
|
||||
}),
|
||||
createdBy: 'editor',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(result).toEqual({ id: 'asset-1' });
|
||||
});
|
||||
|
||||
it('uploads non-image file without generating derivatives', async () => {
|
||||
const pdfBuffer = Buffer.from('fake-pdf');
|
||||
mockStorageSave.mockResolvedValueOnce({ path: 'public/uploads/file.pdf', url: '/uploads/file.pdf' });
|
||||
mockMediaAssetCreate.mockResolvedValue({ id: 'asset-2' });
|
||||
|
||||
const result = await uploadMedia(
|
||||
{ name: 'file.pdf', buffer: pdfBuffer, mimeType: 'application/pdf', size: 2048 },
|
||||
'editor'
|
||||
);
|
||||
|
||||
expect(mockStorageSave).toHaveBeenCalledTimes(1);
|
||||
expect(mockGenerateDerivatives).not.toHaveBeenCalled();
|
||||
expect(mockMediaAssetCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
name: 'file.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
size: 2048,
|
||||
width: 0,
|
||||
height: 0,
|
||||
derivatives: '{}',
|
||||
createdBy: 'editor',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(result).toEqual({ id: 'asset-2' });
|
||||
});
|
||||
|
||||
it('uses storage provider type for storageType field', async () => {
|
||||
const { getStorageProvider } = jest.requireMock('./storage') as { getStorageProvider: jest.Mock };
|
||||
getStorageProvider.mockReturnValueOnce({
|
||||
type: 's3',
|
||||
save: mockStorageSave.mockResolvedValue({ path: 'key.png', url: 'https://cdn/key.png' }),
|
||||
delete: mockStorageDelete,
|
||||
});
|
||||
|
||||
mockMediaAssetCreate.mockResolvedValue({ id: 'asset-3' });
|
||||
|
||||
await uploadMedia(
|
||||
{ name: 'key.png', buffer: Buffer.from('x'), mimeType: 'image/png', size: 100 },
|
||||
'editor'
|
||||
);
|
||||
|
||||
expect(mockMediaAssetCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ storageType: 's3' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads image with zero dimensions when metadata cannot be parsed', async () => {
|
||||
mockStorageSave.mockResolvedValue({ path: 'public/uploads/broken.png', url: '/uploads/broken.png' });
|
||||
mockGetImageMetadata.mockResolvedValue(null);
|
||||
mockGenerateDerivatives.mockResolvedValue({});
|
||||
mockMediaAssetCreate.mockResolvedValue({ id: 'asset-4' });
|
||||
|
||||
await uploadMedia(
|
||||
{ name: 'broken.png', buffer: Buffer.from('not-really-png'), mimeType: 'image/png', size: 100 },
|
||||
'editor'
|
||||
);
|
||||
|
||||
expect(mockMediaAssetCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
width: 0,
|
||||
height: 0,
|
||||
derivatives: '{}',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMedia', () => {
|
||||
it('deletes original file and all derivatives then removes db record', async () => {
|
||||
mockMediaAssetFindUnique.mockResolvedValue({
|
||||
id: 'asset-1',
|
||||
path: 'public/uploads/original.png',
|
||||
derivatives: JSON.stringify({
|
||||
thumbnail: { path: 'public/uploads/thumb.webp' },
|
||||
webp: { path: 'public/uploads/original-webp.webp' },
|
||||
avif: { path: 'public/uploads/original-avif.avif' },
|
||||
}),
|
||||
});
|
||||
|
||||
await deleteMedia('asset-1');
|
||||
|
||||
expect(mockStorageDelete).toHaveBeenCalledTimes(4);
|
||||
expect(mockStorageDelete).toHaveBeenCalledWith('public/uploads/original.png');
|
||||
expect(mockStorageDelete).toHaveBeenCalledWith('public/uploads/thumb.webp');
|
||||
expect(mockStorageDelete).toHaveBeenCalledWith('public/uploads/original-webp.webp');
|
||||
expect(mockStorageDelete).toHaveBeenCalledWith('public/uploads/original-avif.avif');
|
||||
expect(mockMediaAssetDelete).toHaveBeenCalledWith({ where: { id: 'asset-1' } });
|
||||
});
|
||||
|
||||
it('throws when media asset not found', async () => {
|
||||
mockMediaAssetFindUnique.mockResolvedValue(null);
|
||||
await expect(deleteMedia('missing')).rejects.toThrow('文件不存在');
|
||||
});
|
||||
|
||||
it('only deletes original file when derivatives JSON is invalid', async () => {
|
||||
mockMediaAssetFindUnique.mockResolvedValue({
|
||||
id: 'asset-2',
|
||||
path: 'public/uploads/original.png',
|
||||
derivatives: 'not-json',
|
||||
});
|
||||
|
||||
await deleteMedia('asset-2');
|
||||
|
||||
expect(mockStorageDelete).toHaveBeenCalledTimes(1);
|
||||
expect(mockStorageDelete).toHaveBeenCalledWith('public/uploads/original.png');
|
||||
expect(mockMediaAssetDelete).toHaveBeenCalledWith({ where: { id: 'asset-2' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMediaById', () => {
|
||||
it('returns parsed media asset with derivatives', async () => {
|
||||
mockMediaAssetFindUnique.mockResolvedValue({
|
||||
id: 'asset-1',
|
||||
derivatives: JSON.stringify({ thumbnail: { url: '/t.webp' } }),
|
||||
});
|
||||
|
||||
const result = await getMediaById('asset-1');
|
||||
expect(result).toEqual({
|
||||
id: 'asset-1',
|
||||
derivatives: { thumbnail: { url: '/t.webp' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockMediaAssetFindUnique.mockResolvedValue(null);
|
||||
const result = await getMediaById('missing');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty derivatives when JSON is invalid', async () => {
|
||||
mockMediaAssetFindUnique.mockResolvedValue({
|
||||
id: 'asset-2',
|
||||
derivatives: 'not-json',
|
||||
});
|
||||
|
||||
const result = await getMediaById('asset-2');
|
||||
expect(result).toEqual({
|
||||
id: 'asset-2',
|
||||
derivatives: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('listMedia', () => {
|
||||
it('returns paginated list', async () => {
|
||||
mockMediaAssetFindMany.mockResolvedValue([{ id: 'a1' }, { id: 'a2' }]);
|
||||
mockMediaAssetCount.mockResolvedValue(5);
|
||||
|
||||
const result = await listMedia({ page: 2, pageSize: 2 });
|
||||
|
||||
expect(mockMediaAssetFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skip: 2,
|
||||
take: 2,
|
||||
})
|
||||
);
|
||||
expect(result).toEqual({
|
||||
items: [{ id: 'a1' }, { id: 'a2' }],
|
||||
total: 5,
|
||||
page: 2,
|
||||
pageSize: 2,
|
||||
totalPages: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by mimeType prefix', async () => {
|
||||
mockMediaAssetFindMany.mockResolvedValue([]);
|
||||
mockMediaAssetCount.mockResolvedValue(0);
|
||||
|
||||
await listMedia({ mimeType: 'image' });
|
||||
|
||||
expect(mockMediaAssetFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { mimeType: { startsWith: 'image' } },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import path from 'path';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getStorageProvider } from './storage';
|
||||
import { generateDerivatives, getImageMetadata, isImage } from './image-processor';
|
||||
import type { UploadMediaInput, ListMediaOptions, MediaDerivatives, StoredFile, StoredDerivative, GeneratedDerivatives } from './types';
|
||||
|
||||
function generateUniqueFileName(originalName: string): string {
|
||||
const ext = path.extname(originalName) || '';
|
||||
const baseName = path.basename(originalName, ext) || 'file';
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).slice(2, 10);
|
||||
return `${baseName}-${timestamp}-${random}${ext}`;
|
||||
}
|
||||
|
||||
async function saveOriginalFile(
|
||||
provider: ReturnType<typeof getStorageProvider>,
|
||||
input: UploadMediaInput
|
||||
): Promise<StoredFile> {
|
||||
const fileName = generateUniqueFileName(input.name);
|
||||
return provider.save(fileName, input.buffer, input.mimeType);
|
||||
}
|
||||
|
||||
async function saveDerivative(
|
||||
provider: ReturnType<typeof getStorageProvider>,
|
||||
derivative: { path: string; url: string; buffer: Buffer; mimeType?: string }
|
||||
): Promise<StoredFile> {
|
||||
const fileName = path.basename(derivative.path);
|
||||
const mimeType = derivative.mimeType || 'application/octet-stream';
|
||||
return provider.save(fileName, derivative.buffer, mimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传媒体文件并创建 MediaAsset 记录
|
||||
* 图片会自动生成缩略图、WebP、AVIF 派生格式并持久化
|
||||
*/
|
||||
export async function uploadMedia(input: UploadMediaInput, createdBy: string) {
|
||||
const provider = getStorageProvider();
|
||||
const original = await saveOriginalFile(provider, input);
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let derivatives: MediaDerivatives = {};
|
||||
|
||||
if (isImage(input.mimeType)) {
|
||||
const metadata = await getImageMetadata(input.buffer);
|
||||
if (metadata) {
|
||||
width = metadata.width;
|
||||
height = metadata.height;
|
||||
}
|
||||
|
||||
const generated: GeneratedDerivatives = await generateDerivatives(input.buffer, input.mimeType);
|
||||
const savedDerivatives: MediaDerivatives = {};
|
||||
|
||||
for (const key of Object.keys(generated) as Array<keyof MediaDerivatives>) {
|
||||
const derivative = generated[key];
|
||||
if (!derivative) continue;
|
||||
|
||||
const mimeType = key === 'thumbnail' || key === 'webp' ? 'image/webp' : 'image/avif';
|
||||
const saved = await saveDerivative(provider, { ...derivative, mimeType });
|
||||
const stored: StoredDerivative = {
|
||||
path: saved.path,
|
||||
url: saved.url,
|
||||
width: derivative.width,
|
||||
height: derivative.height,
|
||||
};
|
||||
savedDerivatives[key] = stored;
|
||||
}
|
||||
|
||||
derivatives = savedDerivatives;
|
||||
}
|
||||
|
||||
return prisma.mediaAsset.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
path: original.path,
|
||||
url: original.url,
|
||||
mimeType: input.mimeType,
|
||||
size: input.size,
|
||||
width,
|
||||
height,
|
||||
alt: input.name,
|
||||
storageType: provider.type,
|
||||
derivatives: JSON.stringify(derivatives),
|
||||
createdBy,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除媒体资源:清理原始文件及所有派生格式,然后删除数据库记录
|
||||
*/
|
||||
export async function deleteMedia(id: string) {
|
||||
const existing = await prisma.mediaAsset.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new Error('文件不存在');
|
||||
}
|
||||
|
||||
const provider = getStorageProvider();
|
||||
const pathsToDelete = [existing.path];
|
||||
|
||||
try {
|
||||
const derivatives = JSON.parse(existing.derivatives) as MediaDerivatives;
|
||||
for (const key of Object.keys(derivatives) as Array<keyof MediaDerivatives>) {
|
||||
const derivative = derivatives[key];
|
||||
if (derivative?.path) {
|
||||
pathsToDelete.push(derivative.path);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// derivatives 字段不是有效 JSON 时仅删除原始文件
|
||||
}
|
||||
|
||||
for (const filePath of pathsToDelete) {
|
||||
await provider.delete(filePath);
|
||||
}
|
||||
|
||||
await prisma.mediaAsset.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 查询媒体资源并解析 derivatives JSON
|
||||
*/
|
||||
export async function getMediaById(id: string) {
|
||||
const asset = await prisma.mediaAsset.findUnique({ where: { id } });
|
||||
if (!asset) return null;
|
||||
|
||||
return {
|
||||
...asset,
|
||||
derivatives: parseDerivatives(asset.derivatives),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDerivatives(raw: string): MediaDerivatives {
|
||||
try {
|
||||
return JSON.parse(raw) as MediaDerivatives;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询媒体列表
|
||||
*/
|
||||
export async function listMedia(options: ListMediaOptions = {}) {
|
||||
const page = Math.max(1, options.page || 1);
|
||||
const pageSize = Math.max(1, Math.min(100, options.pageSize || 20));
|
||||
const mimeType = options.mimeType;
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (mimeType) {
|
||||
where.mimeType = { startsWith: mimeType };
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.mediaAsset.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.mediaAsset.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
jest.mock('@aws-sdk/client-s3', () => ({
|
||||
S3Client: jest.fn().mockImplementation(() => ({
|
||||
send: (jest.fn() as jest.Mock<any>).mockResolvedValue({}),
|
||||
})),
|
||||
PutObjectCommand: jest.fn().mockImplementation((input: unknown) => input),
|
||||
DeleteObjectCommand: jest.fn().mockImplementation((input: unknown) => input),
|
||||
}));
|
||||
|
||||
import {
|
||||
LocalStorageProvider,
|
||||
getStorageProvider,
|
||||
S3StorageProvider,
|
||||
} from './storage';
|
||||
import type { StorageProvider } from './types';
|
||||
|
||||
// 控制环境变量以测试 provider 选择
|
||||
const originalEnv = process.env;
|
||||
|
||||
describe('LocalStorageProvider', () => {
|
||||
let tempDir: string;
|
||||
let provider: StorageProvider;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'media-test-'));
|
||||
provider = new LocalStorageProvider(tempDir, '/test-uploads');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('saves file to the configured directory and returns path/url', async () => {
|
||||
const buffer = Buffer.from('hello world');
|
||||
const result = await provider.save('test-file.txt', buffer, 'text/plain');
|
||||
|
||||
expect(result.path).toBe(path.join(tempDir, 'test-file.txt'));
|
||||
expect(result.url).toBe('/test-uploads/test-file.txt');
|
||||
|
||||
const saved = await fs.readFile(result.path);
|
||||
expect(saved.toString()).toBe('hello world');
|
||||
});
|
||||
|
||||
it('creates nested directories when saving files', async () => {
|
||||
const nestedProvider = new LocalStorageProvider(
|
||||
path.join(tempDir, 'nested', 'deep'),
|
||||
'/test-uploads'
|
||||
);
|
||||
const buffer = Buffer.from('nested');
|
||||
const result = await nestedProvider.save('deep.txt', buffer, 'text/plain');
|
||||
|
||||
expect(result.path).toContain(path.join('nested', 'deep', 'deep.txt'));
|
||||
const saved = await fs.readFile(result.path);
|
||||
expect(saved.toString()).toBe('nested');
|
||||
});
|
||||
|
||||
it('deletes the physical file', async () => {
|
||||
const buffer = Buffer.from('to delete');
|
||||
const result = await provider.save('delete-me.txt', buffer, 'text/plain');
|
||||
|
||||
await fs.access(result.path);
|
||||
await provider.delete(result.path);
|
||||
await expect(fs.access(result.path)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('does not throw when deleting non-existent file', async () => {
|
||||
await expect(
|
||||
provider.delete(path.join(tempDir, 'not-exist.txt'))
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3StorageProvider', () => {
|
||||
const mockSend = jest.fn<(command: unknown) => Promise<unknown>>();
|
||||
|
||||
beforeEach(() => {
|
||||
mockSend.mockReset();
|
||||
mockSend.mockResolvedValue({});
|
||||
});
|
||||
|
||||
function createMockClient(): { send: typeof mockSend } {
|
||||
return { send: mockSend };
|
||||
}
|
||||
|
||||
it('saves file with PutObjectCommand and public URL prefix', async () => {
|
||||
const client = createMockClient();
|
||||
const provider = new S3StorageProvider(client as any, 'test-bucket', 'https://cdn.example.com');
|
||||
|
||||
const result = await provider.save('folder/image.png', Buffer.from('png'), 'image/png');
|
||||
|
||||
expect(result.path).toBe('folder/image.png');
|
||||
expect(result.url).toBe('https://cdn.example.com/folder/image.png');
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
|
||||
const command = mockSend.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(command).toMatchObject({
|
||||
Bucket: 'test-bucket',
|
||||
Key: 'folder/image.png',
|
||||
Body: expect.any(Buffer),
|
||||
ContentType: 'image/png',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to default S3 URL when public prefix is absent', async () => {
|
||||
const client = createMockClient();
|
||||
const provider = new S3StorageProvider(client as any, 'test-bucket');
|
||||
|
||||
const result = await provider.save('image.png', Buffer.from('png'), 'image/png');
|
||||
|
||||
expect(result.url).toBe('https://test-bucket.s3.amazonaws.com/image.png');
|
||||
});
|
||||
|
||||
it('strips leading slash from key when saving', async () => {
|
||||
const client = createMockClient();
|
||||
const provider = new S3StorageProvider(client as any, 'test-bucket', 'https://cdn.example.com/');
|
||||
|
||||
const result = await provider.save('/image.png', Buffer.from('png'), 'image/png');
|
||||
|
||||
expect(result.url).toBe('https://cdn.example.com/image.png');
|
||||
const command = mockSend.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(command.Key).toBe('image.png');
|
||||
});
|
||||
|
||||
it('deletes file with DeleteObjectCommand and strips leading slash', async () => {
|
||||
const client = createMockClient();
|
||||
const provider = new S3StorageProvider(client as any, 'test-bucket');
|
||||
|
||||
await provider.delete('/folder/image.png');
|
||||
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
const command = mockSend.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(command).toMatchObject({
|
||||
Bucket: 'test-bucket',
|
||||
Key: 'folder/image.png',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStorageProvider', () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.STORAGE_TYPE;
|
||||
delete process.env.S3_BUCKET;
|
||||
delete process.env.S3_ENDPOINT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('returns local provider by default', () => {
|
||||
const provider = getStorageProvider();
|
||||
expect(provider.type).toBe('local');
|
||||
});
|
||||
|
||||
it('returns local provider when STORAGE_TYPE=local', () => {
|
||||
process.env.STORAGE_TYPE = 'local';
|
||||
const provider = getStorageProvider();
|
||||
expect(provider.type).toBe('local');
|
||||
});
|
||||
|
||||
it('returns s3 provider when STORAGE_TYPE=s3 and required env vars are set', () => {
|
||||
process.env.STORAGE_TYPE = 's3';
|
||||
process.env.S3_BUCKET = 'test-bucket';
|
||||
process.env.S3_ENDPOINT = 'https://s3.test.com';
|
||||
process.env.S3_ACCESS_KEY_ID = 'key';
|
||||
process.env.S3_SECRET_ACCESS_KEY = 'secret';
|
||||
process.env.S3_REGION = 'us-east-1';
|
||||
|
||||
const provider = getStorageProvider();
|
||||
expect(provider.type).toBe('s3');
|
||||
expect(provider).toBeInstanceOf(S3StorageProvider);
|
||||
});
|
||||
|
||||
it('throws when STORAGE_TYPE=s3 but S3_BUCKET is missing', () => {
|
||||
process.env.STORAGE_TYPE = 's3';
|
||||
expect(() => getStorageProvider()).toThrow('S3_BUCKET');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import type { StorageProvider, StoredFile } from './types';
|
||||
|
||||
/**
|
||||
* 本地文件系统存储 Provider
|
||||
* 开发/测试环境使用,文件落盘到 baseDir,通过 publicPath 对外暴露 URL
|
||||
*/
|
||||
export class LocalStorageProvider implements StorageProvider {
|
||||
readonly type = 'local' as const;
|
||||
|
||||
constructor(
|
||||
private readonly baseDir: string,
|
||||
private readonly publicPath: string
|
||||
) {}
|
||||
|
||||
async save(fileName: string, buffer: Buffer, _mimeType: string): Promise<StoredFile> {
|
||||
const filePath = path.join(this.baseDir, fileName);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
url: `${this.publicPath.replace(/\/$/, '')}/${fileName.replace(/^\//, '')}`,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(filePath: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (error) {
|
||||
// 文件可能已被删除,忽略 ENOENT
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* S3/OSS 兼容对象存储 Provider
|
||||
* 生产环境使用,通过环境变量配置 endpoint/credentials
|
||||
*/
|
||||
export class S3StorageProvider implements StorageProvider {
|
||||
readonly type = 's3' as const;
|
||||
|
||||
constructor(
|
||||
private readonly client: S3Client,
|
||||
private readonly bucket: string,
|
||||
private readonly publicUrlPrefix?: string
|
||||
) {}
|
||||
|
||||
async save(fileName: string, buffer: Buffer, mimeType: string): Promise<StoredFile> {
|
||||
const key = fileName.replace(/^\//, '');
|
||||
await this.client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: mimeType,
|
||||
})
|
||||
);
|
||||
|
||||
const url = this.publicUrlPrefix
|
||||
? `${this.publicUrlPrefix.replace(/\/$/, '')}/${key}`
|
||||
: `https://${this.bucket}.s3.amazonaws.com/${key}`;
|
||||
|
||||
return { path: key, url };
|
||||
}
|
||||
|
||||
async delete(filePath: string): Promise<void> {
|
||||
const key = filePath.startsWith('/') ? filePath.slice(1) : filePath;
|
||||
await this.client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createS3Client(): S3Client {
|
||||
const endpoint = process.env.S3_ENDPOINT;
|
||||
const region = process.env.S3_REGION || 'us-east-1';
|
||||
const accessKeyId = process.env.S3_ACCESS_KEY_ID;
|
||||
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY;
|
||||
|
||||
return new S3Client({
|
||||
region,
|
||||
endpoint,
|
||||
credentials:
|
||||
accessKeyId && secretAccessKey
|
||||
? { accessKeyId, secretAccessKey }
|
||||
: undefined,
|
||||
forcePathStyle: Boolean(endpoint),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据环境变量获取当前存储 Provider
|
||||
* - 默认 local,文件落盘 public/uploads/
|
||||
* - STORAGE_TYPE=s3 时要求 S3_BUCKET
|
||||
*/
|
||||
export function getStorageProvider(): StorageProvider {
|
||||
const storageType = process.env.STORAGE_TYPE || 'local';
|
||||
|
||||
if (storageType === 's3') {
|
||||
const bucket = process.env.S3_BUCKET;
|
||||
if (!bucket) {
|
||||
throw new Error('S3_BUCKET environment variable is required when STORAGE_TYPE=s3');
|
||||
}
|
||||
return new S3StorageProvider(
|
||||
createS3Client(),
|
||||
bucket,
|
||||
process.env.S3_PUBLIC_URL_PREFIX
|
||||
);
|
||||
}
|
||||
|
||||
const baseDir = process.env.LOCAL_UPLOAD_DIR || path.join(process.cwd(), 'public', 'uploads');
|
||||
const publicPath = process.env.LOCAL_UPLOAD_PATH || '/uploads';
|
||||
return new LocalStorageProvider(baseDir, publicPath);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 媒体管理模块类型定义
|
||||
*
|
||||
* 信源:基于 CMS PRD 第 4 节媒体管理模块与现有 MediaAsset Prisma 模型设计
|
||||
*/
|
||||
|
||||
export interface StoredFile {
|
||||
/** 存储路径(本地为相对路径,OSS/S3 为 object key) */
|
||||
path: string;
|
||||
/** 可公开访问的 URL */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface StoredDerivative extends StoredFile {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ImageDerivative extends StoredDerivative {
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface MediaDerivatives {
|
||||
thumbnail?: StoredDerivative;
|
||||
webp?: StoredDerivative;
|
||||
avif?: StoredDerivative;
|
||||
}
|
||||
|
||||
/** 图片处理器生成的派生格式,包含未落盘的 buffer */
|
||||
export type GeneratedDerivatives = {
|
||||
[K in keyof MediaDerivatives]: ImageDerivative | undefined;
|
||||
};
|
||||
|
||||
export interface UploadMediaInput {
|
||||
name: string;
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface UploadMediaResult {
|
||||
name: string;
|
||||
original: StoredFile;
|
||||
derivatives: MediaDerivatives;
|
||||
width: number;
|
||||
height: number;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ListMediaOptions {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface StorageProvider {
|
||||
readonly type: 'local' | 's3';
|
||||
save(fileName: string, buffer: Buffer, mimeType: string): Promise<StoredFile>;
|
||||
delete(filePath: string): Promise<void>;
|
||||
}
|
||||
Reference in New Issue
Block a user