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:
张翔
2026-07-25 08:04:01 +08:00
parent e35090b914
commit 10404dbb36
208 changed files with 18107 additions and 8330 deletions
+106
View File
@@ -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);
});
});
});
+117
View File
@@ -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;
}
+284
View File
@@ -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' } },
})
);
});
});
});
+171
View File
@@ -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),
};
}
+183
View File
@@ -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');
});
});
+123
View File
@@ -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);
}
+61
View File
@@ -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>;
}