feat(api): idempotent upload session init with presigned credential
POST /v1/uploads (Task 18, A03): @@unique(userId, clientTaskId) idempotency anchor — same params return the original session (201, no duplicate photo); param drift (album/bytes/contentType/extension) -> 409 UPLOAD_SESSION_CONFLICT. photos.objectKey is set to the FINAL originals key at init while the session holds the staging key; IDs come from an app-side cuid-like generator so both self-referencing keys are written in one transaction. Credential is a real MinIO presigned PUT (SIGNED_URL_TTL_SECONDS, lazily read). Permission order: album invisible 404 -> member 403 -> uploadEnabled 409 -> size/ext 400 -> category ownership 400. No audit on init (audit anchors on complete). Pre-step for Task 19: QUEUE_PHOTO_PROCESS contract constant + lazy-safe BullMQ queue producer (Redis down at boot must not crash the app; enqueue failure only logs — sweep compensates). e2e reset now also wipes the MinIO photo-live/dev prefix; jest maps @photo/storage to TS source. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,14 @@
|
||||
// 表序仍按 FK 安全顺序排列(子表在前),关检查失败时也不至于中途卡死。
|
||||
// 顺序 await 在 Prisma 连接池上复用同一空闲连接,会话级 FOREIGN_KEY_CHECKS
|
||||
// 对后续 TRUNCATE 生效;finally 恢复检查并断开,失败也不泄漏连接。
|
||||
// TODO(Task 11): 增加 MinIO `photo-live/dev` 前缀对象清理(同一函数内追加)。
|
||||
// Task 18 起 e2e 会向 MinIO 写入真实对象(staging/originals):同一函数内追加
|
||||
// `photo-live/dev` 前缀对象清理(reset.ts 文件头 TODO 的落实——只清本前缀,
|
||||
// packages/storage 的 test/<ts>-<uuid>/ 前缀对象不受影响,两包测试可并行)。
|
||||
import {
|
||||
DeleteObjectsCommand,
|
||||
ListObjectsV2Command,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { PrismaClient } from '@photo/db-cn';
|
||||
|
||||
export const RESET_TABLES: readonly string[] = [
|
||||
@@ -31,7 +38,67 @@ export const RESET_TABLES: readonly string[] = [
|
||||
'users',
|
||||
];
|
||||
|
||||
/** e2e 对象隔离边界:与 src/uploads/support.ts 的 OBJECT_KEY_ROOT 一致。 */
|
||||
const RESET_OBJECT_PREFIX = 'photo-live/dev/';
|
||||
|
||||
/** 删除 MinIO 中 RESET_OBJECT_PREFIX 前缀的全部对象(分页列举 + 批量删除)。 */
|
||||
async function resetMinioObjects(): Promise<void> {
|
||||
const required = [
|
||||
'S3_ENDPOINT',
|
||||
'S3_REGION',
|
||||
'S3_ACCESS_KEY',
|
||||
'S3_SECRET_KEY',
|
||||
'S3_BUCKET',
|
||||
] as const;
|
||||
const missing = required.filter((key) => !process.env[key]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`resetMinioObjects: 缺少环境变量 ${missing.join(', ')}`);
|
||||
}
|
||||
const client = new S3Client({
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
region: process.env.S3_REGION,
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY as string,
|
||||
secretAccessKey: process.env.S3_SECRET_KEY as string,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
const bucket = process.env.S3_BUCKET as string;
|
||||
try {
|
||||
for (;;) {
|
||||
const listed = await client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
Prefix: RESET_OBJECT_PREFIX,
|
||||
MaxKeys: 1000,
|
||||
}),
|
||||
);
|
||||
const objects = (listed.Contents ?? []).filter(
|
||||
(obj) => typeof obj.Key === 'string',
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
break;
|
||||
}
|
||||
await client.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: {
|
||||
Objects: objects.map((obj) => ({ Key: obj.Key as string })),
|
||||
Quiet: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!listed.IsTruncated) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetDb(): Promise<void> {
|
||||
await resetMinioObjects();
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
|
||||
|
||||
Reference in New Issue
Block a user