feat(storage): storage port and minio adapter with forbid-overwrite semantics
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
/** @type {import('jest').Config} */
|
||||
// packages/storage/jest.config.cjs —— 真实 MinIO 集成测试(*.spec.ts,compose 起
|
||||
// photo-live-minio-1)。ts-jest 保持类型检查模式(非 isolatedModules)。
|
||||
// env 约定:setupFiles 经 test/load-env.ts 向上定位仓库根 .env(唯一 env 文件)。
|
||||
// @photo/contracts 为 ESM 包且是本包运行时依赖:moduleNameMapper 指向其 TS 源码,
|
||||
// 由 ts-jest 以 CJS 编译(.js -> $1 映射同 packages/contracts/jest.config.cjs),
|
||||
// 与 services/api/jest.config.ts 的消费方式一致。
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
rootDir: '.',
|
||||
setupFiles: ['./test/load-env.ts'],
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: {
|
||||
module: 'commonjs',
|
||||
moduleResolution: 'node10',
|
||||
target: 'es2022',
|
||||
lib: ['es2022'],
|
||||
strict: true,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
types: ['node', 'jest'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^@photo/contracts$': '<rootDir>/../../packages/contracts/src/index.ts',
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
testMatch: ['<rootDir>/test/**/*.spec.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@photo/storage",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "StoragePort 抽象 + MinIO 适配器(presigned PUT/GET、head、流式读取、禁止覆盖语义的 copyIfAbsent;P5/G1 生产 OSS 适配器实现同一端口)",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
||||
"@photo/contracts": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/node": "^22.10.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// packages/storage/src/errors.ts —— 存储层错误:携带契约错误码(如
|
||||
// UPLOAD_VERIFY_FAILED),api 全局异常过滤器据此映射为错误 envelope。
|
||||
import { ErrorCodes, type ErrorCode } from '@photo/contracts';
|
||||
|
||||
export class StorageError extends Error {
|
||||
/** 契约稳定错误码(packages/contracts/src/errors.ts),非存储实现细节。 */
|
||||
readonly code: ErrorCode;
|
||||
|
||||
constructor(code: ErrorCode, message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = 'StorageError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export { ErrorCodes };
|
||||
@@ -0,0 +1,32 @@
|
||||
// packages/storage/src/index.ts —— 存储包统一出口:端口类型 + MinIO 适配器 + 错误 +
|
||||
// env 工厂。api(Task 18 presign/verify)与 worker(Task 20 variants put/get)经
|
||||
// `createStorageFromEnv()` 构造实例,测试/本地指向 MinIO,生产指向 OSS。
|
||||
// commonjs 包(消费方 api/worker 均为 CJS);@photo/contracts 为 ESM dist,运行时经
|
||||
// Node require(esm) 消费(Node >= 22.12,见根 engines)。
|
||||
import { MinioStorageAdapter, type MinioStorageConfig } from './minio.adapter';
|
||||
|
||||
export { MinioStorageAdapter } from './minio.adapter';
|
||||
export type { MinioStorageConfig } from './minio.adapter';
|
||||
export { StorageError, ErrorCodes } from './errors';
|
||||
export type { StoragePort, UploadCredential } from './port';
|
||||
|
||||
/**
|
||||
* 从 S3_* 环境变量构造 MinIO/S3 兼容适配器(仓库根 .env 经 dotenv-cli 或
|
||||
* test/load-env.ts 注入)。任一变量缺失即抛错,禁止静默回退默认值。
|
||||
*/
|
||||
export function createStorageFromEnv(): MinioStorageAdapter {
|
||||
const config = {
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
region: process.env.S3_REGION,
|
||||
accessKey: process.env.S3_ACCESS_KEY,
|
||||
secretKey: process.env.S3_SECRET_KEY,
|
||||
bucket: process.env.S3_BUCKET,
|
||||
} satisfies Record<keyof MinioStorageConfig, string | undefined>;
|
||||
const missing = Object.entries(config)
|
||||
.filter(([, value]) => !value)
|
||||
.map(([key]) => `S3_${key.toUpperCase()}`);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`createStorageFromEnv: 缺少环境变量 ${missing.join(', ')}(S3_* 配置见仓库根 .env)`);
|
||||
}
|
||||
return new MinioStorageAdapter(config as MinioStorageConfig);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// packages/storage/src/minio.adapter.ts —— StoragePort 的 MinIO 实现(S3 兼容协议,
|
||||
// AWS SDK v3 + path-style)。生产 OSS 适配器(P5/G1)实现同端口,行为契约以此处注释为准。
|
||||
import {
|
||||
CopyObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { Readable } from 'node:stream';
|
||||
import { ErrorCodes, StorageError } from './errors';
|
||||
import type { StoragePort, UploadCredential } from './port';
|
||||
|
||||
export interface MinioStorageConfig {
|
||||
endpoint: string;
|
||||
region: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
bucket: string;
|
||||
}
|
||||
|
||||
/** S3 错误统一判定:HeadObject 对缺失键返回 404/NotFound(无 body 错误码),GetObject 为 NoSuchKey。 */
|
||||
function isNotFound(error: unknown): boolean {
|
||||
if (typeof error !== 'object' || error === null) return false;
|
||||
const e = error as { name?: string; $metadata?: { httpStatusCode?: number } };
|
||||
return e.$metadata?.httpStatusCode === 404 || e.name === 'NotFound' || e.name === 'NoSuchKey';
|
||||
}
|
||||
|
||||
export class MinioStorageAdapter implements StoragePort {
|
||||
private readonly client: S3Client;
|
||||
private readonly bucket: string;
|
||||
|
||||
constructor(config: MinioStorageConfig) {
|
||||
const missing = (Object.keys(config) as Array<keyof MinioStorageConfig>).filter((k) => !config[k]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`MinioStorageAdapter: 存储配置缺失 ${missing.join(', ')}(S3_* 环境变量见仓库根 .env)`);
|
||||
}
|
||||
this.bucket = config.bucket;
|
||||
this.client = new S3Client({
|
||||
endpoint: config.endpoint,
|
||||
region: config.region,
|
||||
credentials: { accessKeyId: config.accessKey, secretAccessKey: config.secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 预签名上传。S3 预签名无法对 Content-Length 签名(SigV4 规范排除该头),
|
||||
* bytes 参数仅作为端口契约保留:字节数核验发生在上传完成后的 head/verify 阶段。
|
||||
* Content-Type 经 PutObjectCommand 的 ContentType 参数参与签名,客户端必须
|
||||
* 原样携带返回的 headers,否则 403 SignatureDoesNotMatch。
|
||||
*/
|
||||
async presignPut(objectKey: string, bytes: number, contentType: string, ttlSeconds: number): Promise<UploadCredential> {
|
||||
void bytes; // 见上:签名不含 Content-Length,大小核验由 verify 阶段负责
|
||||
const url = await getSignedUrl(
|
||||
this.client,
|
||||
new PutObjectCommand({ Bucket: this.bucket, Key: objectKey, ContentType: contentType }),
|
||||
{ expiresIn: ttlSeconds },
|
||||
);
|
||||
return {
|
||||
mode: 'presigned',
|
||||
url,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': contentType },
|
||||
expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async presignGet(objectKey: string, ttlSeconds: number): Promise<string> {
|
||||
return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }), {
|
||||
expiresIn: ttlSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
async head(objectKey: string): Promise<{ bytes: number; etag: string } | null> {
|
||||
try {
|
||||
const out = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: objectKey }));
|
||||
return { bytes: out.ContentLength ?? 0, etag: out.ETag ?? '' };
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getStream(objectKey: string): Promise<NodeJS.ReadableStream> {
|
||||
const out = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }));
|
||||
if (!out.Body) {
|
||||
throw new StorageError(ErrorCodes.UPLOAD_VERIFY_FAILED, `getStream: 对象无 Body: ${objectKey}`);
|
||||
}
|
||||
return out.Body as Readable;
|
||||
}
|
||||
|
||||
async put(objectKey: string, body: Buffer, contentType: string): Promise<{ etag: string }> {
|
||||
const out = await this.client.send(
|
||||
new PutObjectCommand({ Bucket: this.bucket, Key: objectKey, ContentType: contentType, Body: body }),
|
||||
);
|
||||
return { etag: out.ETag ?? '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁止覆盖语义的复制(staging → 正式键):
|
||||
* - dest 已存在且字节数与 expectedBytes 一致 → 幂等返回 created:false(同 etag);
|
||||
* - dest 已存在但字节数不符 → StorageError(UPLOAD_VERIFY_FAILED);
|
||||
* - dest 不存在 → CopyObject(MetadataDirective=COPY,MinIO 需显式指定以保留源元数据)
|
||||
* 后 head 复核字节数再返回 created:true。
|
||||
*
|
||||
* 已知竞态窗口:MinIO 的 CopyObject 不支持条件写(无 forbid-overwrite 头),两个并发
|
||||
* copyIfAbsent 同时通过 head 检查后,后发的 CopyObject 会覆盖先发的结果。本项目接受该
|
||||
* 窗口:同一会话的并发调用复制的是同一个 staging 源对象,覆盖结果字节一致(幂等等价);
|
||||
* 跨会话撞键理论上不可能(正式键含会话/照片唯一标识)。生产 OSS 适配器将使用
|
||||
* x-oss-forbid-overwrite 头消除该窗口(G1 验收点)。
|
||||
*/
|
||||
async copyIfAbsent(src: string, dest: string, expectedBytes: number): Promise<{ created: boolean; etag: string }> {
|
||||
const existing = await this.head(dest);
|
||||
if (existing) {
|
||||
if (existing.bytes === expectedBytes) return { created: false, etag: existing.etag };
|
||||
throw new StorageError(
|
||||
ErrorCodes.UPLOAD_VERIFY_FAILED,
|
||||
`copyIfAbsent: dest 已存在且字节数不符(expected=${expectedBytes}, actual=${existing.bytes}): ${dest}`,
|
||||
);
|
||||
}
|
||||
// CopySource 须为 "bucket/key" 且 key 部分 URL 编码(保留 / 分隔符)
|
||||
const copySource = [this.bucket, ...src.split('/').map(encodeURIComponent)].join('/');
|
||||
await this.client.send(
|
||||
new CopyObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: dest,
|
||||
CopySource: copySource,
|
||||
MetadataDirective: 'COPY',
|
||||
}),
|
||||
);
|
||||
const verified = await this.head(dest);
|
||||
if (!verified || verified.bytes !== expectedBytes) {
|
||||
throw new StorageError(
|
||||
ErrorCodes.UPLOAD_VERIFY_FAILED,
|
||||
`copyIfAbsent: 复制后复核失败(对象缺失或字节数不符): ${dest}`,
|
||||
);
|
||||
}
|
||||
return { created: true, etag: verified.etag };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// packages/storage/src/port.ts —— 存储端口(唯一抽象):生产 OSS 适配器(P5/G1)
|
||||
// 与本包 MinIO 适配器实现同一接口;api(presign/verify)与 worker(variants put/get)
|
||||
// 只依赖此端口,不感知后端差异。
|
||||
export interface UploadCredential {
|
||||
mode: 'presigned';
|
||||
url: string;
|
||||
method: 'PUT';
|
||||
headers: Record<string, string>;
|
||||
expiresAt: string; // ISO
|
||||
}
|
||||
export interface StoragePort {
|
||||
presignPut(objectKey: string, bytes: number, contentType: string, ttlSeconds: number): Promise<UploadCredential>;
|
||||
presignGet(objectKey: string, ttlSeconds: number): Promise<string>;
|
||||
head(objectKey: string): Promise<{ bytes: number; etag: string } | null>;
|
||||
getStream(objectKey: string): Promise<NodeJS.ReadableStream>;
|
||||
put(objectKey: string, body: Buffer, contentType: string): Promise<{ etag: string }>;
|
||||
/** 复制到正式键;目标已存在时校验一致性并幂等返回(禁止覆盖语义)。 */
|
||||
copyIfAbsent(src: string, dest: string, expectedBytes: number): Promise<{ created: boolean; etag: string }>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// packages/storage/test/load-env.ts —— 与 services/api/test/load-env.ts 同约定:
|
||||
// Jest 侧唯一 env 来源是仓库根 .env。本包嵌套层级与 api 不同(packages/storage/test
|
||||
// vs services/api/test),故向上查找 pnpm-workspace.yaml 定位仓库根,不硬编码层数。
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
let dir = __dirname;
|
||||
while (!existsSync(join(dir, 'pnpm-workspace.yaml'))) {
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) {
|
||||
throw new Error('load-env: 未找到 pnpm-workspace.yaml,无法定位仓库根(.env)');
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
config({ path: join(dir, '.env') });
|
||||
@@ -0,0 +1,93 @@
|
||||
// packages/storage/test/minio.adapter.spec.ts —— MinIO 真实集成测试(依赖
|
||||
// docker compose 的 photo-live-minio-1 与仓库根 .env 的 S3_* 配置)。
|
||||
// 每次运行使用唯一前缀 test/<ts>-<uuid>/,避免历史残留对象干扰断言。
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { ErrorCodes } from '@photo/contracts';
|
||||
import { StorageError, createStorageFromEnv, type MinioStorageAdapter } from '../src/index';
|
||||
|
||||
const PREFIX = `test/${Date.now()}-${randomUUID()}`;
|
||||
|
||||
describe('MinioStorageAdapter(真实 MinIO 集成)', () => {
|
||||
let storage: MinioStorageAdapter;
|
||||
|
||||
beforeAll(() => {
|
||||
storage = createStorageFromEnv();
|
||||
});
|
||||
|
||||
it('presignPut: 客户端按凭证直接 PUT 后,head 返回精确字节数与 etag', async () => {
|
||||
const key = `${PREFIX}/presign-put.bin`;
|
||||
const body = randomBytes(2048);
|
||||
const credential = await storage.presignPut(key, body.length, 'application/octet-stream', 300);
|
||||
|
||||
expect(credential.mode).toBe('presigned');
|
||||
expect(credential.method).toBe('PUT');
|
||||
expect(credential.headers['Content-Type']).toBe('application/octet-stream');
|
||||
expect(new Date(credential.expiresAt).getTime()).toBeGreaterThan(Date.now());
|
||||
|
||||
const res = await fetch(credential.url, { method: credential.method, headers: credential.headers, body });
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const head = await storage.head(key);
|
||||
expect(head).not.toBeNull();
|
||||
expect(head?.bytes).toBe(body.length);
|
||||
expect(head?.etag).toBeTruthy();
|
||||
});
|
||||
|
||||
it('put + getStream: 写入后流式读回的字节与写入一致', async () => {
|
||||
const key = `${PREFIX}/put-get.bin`;
|
||||
const body = randomBytes(4096);
|
||||
const { etag } = await storage.put(key, body, 'application/octet-stream');
|
||||
expect(etag).toBeTruthy();
|
||||
|
||||
const stream = await storage.getStream(key);
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk as Uint8Array));
|
||||
}
|
||||
expect(Buffer.concat(chunks).equals(body)).toBe(true);
|
||||
});
|
||||
|
||||
it('copyIfAbsent: 首次 created:true;同参数重复调用幂等 created:false 且 etag 不变', async () => {
|
||||
const srcKey = `${PREFIX}/copy-src.bin`;
|
||||
const destKey = `${PREFIX}/copy-dest.bin`;
|
||||
const body = randomBytes(512);
|
||||
await storage.put(srcKey, body, 'application/octet-stream');
|
||||
|
||||
const first = await storage.copyIfAbsent(srcKey, destKey, body.length);
|
||||
expect(first.created).toBe(true);
|
||||
expect(first.etag).toBeTruthy();
|
||||
|
||||
const second = await storage.copyIfAbsent(srcKey, destKey, body.length);
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.etag).toBe(first.etag);
|
||||
});
|
||||
|
||||
it('copyIfAbsent: dest 已存在但字节数不符 → StorageError(UPLOAD_VERIFY_FAILED)', async () => {
|
||||
const srcKey = `${PREFIX}/mismatch-src.bin`;
|
||||
const destKey = `${PREFIX}/mismatch-dest.bin`;
|
||||
const srcBody = randomBytes(64);
|
||||
await storage.put(srcKey, srcBody, 'application/octet-stream');
|
||||
// 预置一个不同大小的 dest 对象,制造核验失败
|
||||
await storage.put(destKey, randomBytes(128), 'application/octet-stream');
|
||||
|
||||
const promise = storage.copyIfAbsent(srcKey, destKey, srcBody.length);
|
||||
await expect(promise).rejects.toBeInstanceOf(StorageError);
|
||||
await expect(promise).rejects.toMatchObject({ code: ErrorCodes.UPLOAD_VERIFY_FAILED });
|
||||
});
|
||||
|
||||
it('head: 缺失键返回 null(不抛错)', async () => {
|
||||
const head = await storage.head(`${PREFIX}/missing.bin`);
|
||||
expect(head).toBeNull();
|
||||
});
|
||||
|
||||
it('presignGet: 预签名 URL 取回的字节与对象一致', async () => {
|
||||
const key = `${PREFIX}/presign-get.bin`;
|
||||
const body = randomBytes(1024);
|
||||
await storage.put(key, body, 'application/octet-stream');
|
||||
|
||||
const url = await storage.presignGet(key, 300);
|
||||
const res = await fetch(url);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(Buffer.from(await res.arrayBuffer()).equals(body)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user