Files
photo-live/services/api/src/uploads/queue.provider.ts
T
浅念 a2a3ccd340 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>
2026-09-17 22:42:13 +08:00

87 lines
3.4 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/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,
};