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
@@ -0,0 +1,86 @@
// services/api/src/uploads/queue.provider.ts —— photo-process 队列生产者(Task 19
// 入队;队列名常量 QUEUE_PHOTO_PROCESS 由 @photo/contracts 导出,worker(Task 20/21)
// 消费同一队列)。lazy-safe 约定:
// · Queue 实例(含 ioredis 连接)在首次 enqueue 时才创建——Redis 不可用时
// 应用启动/请求管线均不受影响(BullMQ 连接仅在入队时使用);
// · 入队失败(连接拒绝/超时等)只记日志不抛错:complete 事务已提交,
// photo_jobs(pending) 行是持久事实源,Task 21 的补偿扫描会认领孤儿任务
// (总纲 §5.6「complete 后入队失败」的补偿路径);
// · ioredis 的 'error' 事件挂 no-op 监听:重连风暴期间未监听的 error 事件
// 会让进程崩溃,这里吞掉并交给 enqueue 的 catch 记日志。
import { Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { QUEUE_PHOTO_PROCESS } from '@photo/contracts';
/** DI token。 */
export const PHOTO_PROCESS_QUEUE = Symbol('PHOTO_PROCESS_QUEUE');
/** 生产者窄接口:服务只依赖 enqueue,便于测试替身。 */
export interface PhotoProcessQueueProducer {
enqueuePhotoProcess(photoId: string): Promise<void>;
}
/** BullMQ job 名称(与 photo_jobs.type 同值)。 */
const PHOTO_PROCESS_JOB_NAME = 'process';
@Injectable()
export class BullMqPhotoProcessQueue
implements PhotoProcessQueueProducer, OnModuleDestroy
{
private readonly logger = new Logger(BullMqPhotoProcessQueue.name);
private queue: Queue | null = null;
/** 首次使用时构造;连接错误经 no-op 监听吞掉(见文件头注释)。 */
private getQueue(): Queue {
if (this.queue === null) {
const url = process.env.REDIS_URL_CN ?? 'redis://localhost:6379';
const connection = new IORedis(url, {
// BullMQ 对阻塞命令的要求:禁用 per-request 重试上限(等待 Worker 语义)。
maxRetriesPerRequest: null,
});
connection.on('error', (err: Error) => {
this.logger.warn(
`photo-process 队列连接异常(将由 ioredis 自动重连): ${err.message}`,
);
});
this.queue = new Queue(QUEUE_PHOTO_PROCESS, { connection });
}
return this.queue;
}
async enqueuePhotoProcess(photoId: string): Promise<void> {
try {
await this.getQueue().add(
PHOTO_PROCESS_JOB_NAME,
{ photoId },
{
// jobId 以 photoId 派生:并发 complete/补偿扫描重复入队天然去重(A03)。
jobId: `${PHOTO_PROCESS_JOB_NAME}:${photoId}`,
// Task 21 消费侧重试策略:3 次指数退避。
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
},
);
} catch (err) {
// 不抛错:photo_jobs(pending) 已是持久事实源,Task 21 sweep 补偿。
this.logger.error(
`photo-process 入队失败(sweep 将补偿)photoId=${photoId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
async onModuleDestroy(): Promise<void> {
if (this.queue !== null) {
await this.queue.close().catch(() => undefined);
this.queue = null;
}
}
}
export const photoProcessQueueProvider = {
provide: PHOTO_PROCESS_QUEUE,
useClass: BullMqPhotoProcessQueue,
};