// 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 { OBJECT_KEY_ROOT } from '@photo/storage'; import { ApiException } from '../api-exception'; import { albumNotFound } from '../albums/support'; /** * 对象键根:P4a Task 3 起权威出处收敛到 @photo/storage keys.ts(ov 公开侧 * 水印模式键推导引入第三消费方),此处转出口维持既有导入面。 */ export { OBJECT_KEY_ROOT }; /** 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> = 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}`; } /** * assets 正式键(album_assets.objectKey,P1b Task 3)。与照片域不同,资产无 * staging→originals 受控复制:键含 assetId(每次 init 新键,不存在覆盖他行 * 对象的风险),行 state='pending' 期间公开查询不可见即「逻辑 staging」, * complete 核验通过置 effective 后同一键即最终键。 */ export function assetsObjectKey( albumId: string, assetId: string, ext: string, ): string { return `${OBJECT_KEY_ROOT}/albums/${albumId}/assets/${assetId}.${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, ); }