Files
photo-live/services/api/src/uploads/support.ts
T
2026-09-20 17:53:44 +08:00

166 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<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}`;
}
/**
* 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,
);
}