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:
浅念
2026-09-17 22:42:13 +08:00
parent e04e1f3bdf
commit a2a3ccd340
15 changed files with 1252 additions and 1 deletions
+147
View File
@@ -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,
);
}