243b2f8721
Co-Authored-By: Claude Code <noreply@anthropic.com>
166 lines
5.9 KiB
TypeScript
166 lines
5.9 KiB
TypeScript
// 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,
|
||
);
|
||
}
|