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:
@@ -0,0 +1,147 @@
|
||||
// services/api/src/uploads/support.ts —— 上传域共用小工具(Task 18 起)。
|
||||
// 对象键布局(契约 UploadInitResponse.objectKey / docs/03 §4 对象键规范):
|
||||
// staging: photo-live/{env}/staging/{uploadId}/{photoId}.{ext}
|
||||
// originals: photo-live/{env}/albums/{albumId}/originals/{photoId}.{ext}
|
||||
// P1a 单环境(env=dev)硬编码前缀;多环境前缀化属 P5 部署事项。
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { ErrorCodes } from '@photo/contracts';
|
||||
import { ApiException } from '../api-exception';
|
||||
import { albumNotFound } from '../albums/support';
|
||||
|
||||
/** 对象键根(与 test/reset.ts 的 MinIO 清理前缀一致)。 */
|
||||
export const OBJECT_KEY_ROOT = 'photo-live/dev';
|
||||
|
||||
/** UPLOAD_MAX_BYTES 缺省 50MB(与 .env.example 同步)。 */
|
||||
export const DEFAULT_UPLOAD_MAX_BYTES = 52_428_800;
|
||||
/** SIGNED_URL_TTL_SECONDS 缺省 15 分钟(与 .env.example 同步)。 */
|
||||
export const DEFAULT_SIGNED_URL_TTL_SECONDS = 900;
|
||||
|
||||
/**
|
||||
* 逐请求惰性读取 UPLOAD_MAX_BYTES:配置不驻内存,测试可在进程内改 env 验证
|
||||
* 边界(无需重启应用);运行时由根 dotenv-cli 注入。
|
||||
*/
|
||||
export function uploadMaxBytes(): number {
|
||||
const parsed = Number.parseInt(process.env.UPLOAD_MAX_BYTES ?? '', 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_UPLOAD_MAX_BYTES;
|
||||
}
|
||||
|
||||
/** 同上:凭证/会话 TTL 惰性读取(秒)。 */
|
||||
export function signedUrlTtlSeconds(): number {
|
||||
const parsed = Number.parseInt(process.env.SIGNED_URL_TTL_SECONDS ?? '', 10);
|
||||
return Number.isFinite(parsed) && parsed > 0
|
||||
? parsed
|
||||
: DEFAULT_SIGNED_URL_TTL_SECONDS;
|
||||
}
|
||||
|
||||
/** 契约 contentType 白名单(image/heic 为暂定项,解码能力待 G2 真机验证)。 */
|
||||
export const UPLOAD_CONTENT_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/heic',
|
||||
] as const;
|
||||
export type UploadContentType = (typeof UPLOAD_CONTENT_TYPES)[number];
|
||||
|
||||
/** filename 扩展名白名单(jpeg 归一为 jpg,避免同格式双扩展名键漂移)。 */
|
||||
const EXTENSION_WHITELIST: Readonly<Record<string, string>> = Object.freeze({
|
||||
jpg: 'jpg',
|
||||
jpeg: 'jpg',
|
||||
png: 'png',
|
||||
heic: 'heic',
|
||||
});
|
||||
|
||||
/**
|
||||
* 从原始文件名取归一化扩展名;不在白名单返回 null(服务层 400)。
|
||||
* filename 仅元数据(契约语义),扩展名唯一的结构化用途是对象键后缀。
|
||||
*/
|
||||
export function extOfFilename(filename: string): string | null {
|
||||
const dot = filename.lastIndexOf('.');
|
||||
if (dot < 0 || dot === filename.length - 1) {
|
||||
return null;
|
||||
}
|
||||
return EXTENSION_WHITELIST[filename.slice(dot + 1).toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
/** staging 对象键(会话 objectKey;uploadId 前置使会话与键一一对应)。 */
|
||||
export function stagingObjectKey(
|
||||
uploadId: string,
|
||||
photoId: string,
|
||||
ext: string,
|
||||
): string {
|
||||
return `${OBJECT_KEY_ROOT}/staging/${uploadId}/${photoId}.${ext}`;
|
||||
}
|
||||
|
||||
/** originals 正式键(photos.objectKey,init 即写入,complete 受控复制目标)。 */
|
||||
export function originalsObjectKey(
|
||||
albumId: string,
|
||||
photoId: string,
|
||||
ext: string,
|
||||
): string {
|
||||
return `${OBJECT_KEY_ROOT}/albums/${albumId}/originals/${photoId}.${ext}`;
|
||||
}
|
||||
|
||||
/** 取对象键尾缀扩展名(幂等冲突比对用:filename 未持久化,以扩展名近似锚定)。 */
|
||||
export function extOfObjectKey(objectKey: string): string | null {
|
||||
const dot = objectKey.lastIndexOf('.');
|
||||
if (dot < 0) {
|
||||
return null;
|
||||
}
|
||||
return objectKey.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
const CUID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
/**
|
||||
* cuid 形状的应用侧 ID 生成('c'+base36 时间戳+随机,25 字符):对象键包含
|
||||
* 自引用键段(staging 键含 uploadId、photos.objectKey 含 photoId),须先有 ID
|
||||
* 才能一次 insert 成形(避免「先建行再回填键」的双写)。随机段 36^16≈8e24,
|
||||
* 碰撞概率可忽略;真撞 @id 唯一索引按 5xx 兜底(同 publicToken 先例不做重试)。
|
||||
*/
|
||||
export function cuidLikeId(): string {
|
||||
let id = `c${Date.now().toString(36)}`;
|
||||
const bytes = randomBytes(16);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
id += CUID_ALPHABET[bytes[i] % CUID_ALPHABET.length];
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── 统一错误出口 ──
|
||||
|
||||
export { albumNotFound };
|
||||
|
||||
/** 409:相册已关闭上传(相册级运营状态,owner 亦受限)。 */
|
||||
export function albumUploadDisabled(): ApiException {
|
||||
return new ApiException(
|
||||
ErrorCodes.ALBUM_UPLOAD_DISABLED,
|
||||
'相册已关闭上传',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
/** 409:同 clientTaskId 已存在且参数不一致。 */
|
||||
export function uploadSessionConflict(message: string): ApiException {
|
||||
return new ApiException(
|
||||
ErrorCodes.UPLOAD_SESSION_CONFLICT,
|
||||
message,
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
/** 400:完成核验失败(对象缺失/字节数不符/完整性信息不可用)。 */
|
||||
export function uploadVerifyFailed(message: string): ApiException {
|
||||
return new ApiException(
|
||||
ErrorCodes.UPLOAD_VERIFY_FAILED,
|
||||
message,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
/** 403:无权操作该会话或会话不存在(不区分,防 uploadId 枚举;契约语义)。 */
|
||||
export function uploadSessionForbidden(): ApiException {
|
||||
return new ApiException(
|
||||
ErrorCodes.AUTH_FORBIDDEN,
|
||||
'无权操作该上传会话',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user