feat(api): upload verify, forbid-overwrite copy, idempotent complete

Task 19: POST /uploads/{id}/renew re-signs the SAME staging key for pending
sessions (uploadEnabled=false -> 409 ALBUM_UPLOAD_DISABLED; non-owner/unknown/
removed-member -> 403 AUTH_FORBIDDEN, indistinguishable per contract) and
slides the session expiry — renew is the documented recovery path for an
expired credential. POST /uploads/{id}/complete verifies the staging object
out-of-transaction first (head: missing / byte-count mismatch / contentType
mismatch — the server-side backstop for presign not signing content-type),
then copyIfAbsent's it to the finals key with forbid-overwrite semantics,
then in ONE transaction updates photo(bytes/uploadedAt/uploaded), claims the
session pending->confirmed via conditional updateMany (concurrent completes:
exactly one winner creates the photo_jobs row + upload.complete audit), and
enqueues QUEUE_PHOTO_PROCESS post-commit (jobId derived from photoId for
dedupe; failure only logs — Task 21 sweep compensates on photo_jobs). Verify
failures are request-level 400s: the session stays pending and retryable.
Repeat complete on a confirmed session re-runs copyIfAbsent (created:false,
staging already cleaned is fine) and returns the current processingState —
no second job/audit/enqueue (A03). GET /uploads lists the caller's own
sessions newest-first with (createdAt,id) keyset pagination and the joined
photo processingState/visibility. Both producer and test Queue instances now
quit their caller-owned ioredis connection (BullMQ does not take ownership),
otherwise jest hangs on open handles.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 23:00:19 +08:00
parent a2a3ccd340
commit f5f1855b58
4 changed files with 850 additions and 14 deletions
+12 -4
View File
@@ -30,21 +30,22 @@ export class BullMqPhotoProcessQueue
{
private readonly logger = new Logger(BullMqPhotoProcessQueue.name);
private queue: Queue | null = null;
private connection: IORedis | 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, {
this.connection = new IORedis(url, {
// BullMQ 对阻塞命令的要求:禁用 per-request 重试上限(等待 Worker 语义)。
maxRetriesPerRequest: null,
});
connection.on('error', (err: Error) => {
this.connection.on('error', (err: Error) => {
this.logger.warn(
`photo-process 队列连接异常(将由 ioredis 自动重连): ${err.message}`,
);
});
this.queue = new Queue(QUEUE_PHOTO_PROCESS, { connection });
this.queue = new Queue(QUEUE_PHOTO_PROCESS, { connection: this.connection });
}
return this.queue;
}
@@ -56,7 +57,8 @@ export class BullMqPhotoProcessQueue
{ photoId },
{
// jobId 以 photoId 派生:并发 complete/补偿扫描重复入队天然去重(A03)。
jobId: `${PHOTO_PROCESS_JOB_NAME}:${photoId}`,
// 注意 BullMQ 禁止 jobId 含 ':'(保留分隔符),用 '-' 连接。
jobId: `${PHOTO_PROCESS_JOB_NAME}-${photoId}`,
// Task 21 消费侧重试策略:3 次指数退避。
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
@@ -77,6 +79,12 @@ export class BullMqPhotoProcessQueue
await this.queue.close().catch(() => undefined);
this.queue = null;
}
// BullMQ 不接管外部传入连接的生命周期:queue.close 不 disconnect 它,
// 这里显式断开,否则进程(含 e2e 的 app 实例)残留打开的 Redis 句柄。
if (this.connection !== null) {
await this.connection.quit().catch(() => undefined);
this.connection = null;
}
}
}
+55 -6
View File
@@ -1,18 +1,33 @@
// services/api/src/uploads/uploads.controller.ts —— /uploads 端点(Task 18 起)。
// 方法名与契约 operationId 一一对应(initUpload;Task 19 增补 renewUploadCredential/
// completeUpload/listUploads)。控制器级 SessionGuard:端点全需认证。职责:
// 透传认证用户 + 查询参数规整(parseLimit/parseCursor 复用相册控制器导出)。
// services/api/src/uploads/uploads.controller.ts —— /uploads 端点(Task 18 起;
// Task 19 增补 listUploads/renewUploadCredential/completeUpload)。方法名与契约
// operationId 一一对应。控制器级 SessionGuard:端点全需认证。职责:透传认证
// 用户 + 查询参数规整(parseLimit/parseCursor 复用相册控制器导出,同契约 bounds)。
// 注意 DTO 必须值导入(非 import type):@Body 参数类型经 emitDecoratorMetadata
// 进入设计期元数据,import type 会令参数在管线中被置为 undefined(Task 18 实测)。
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { userOf } from '../albums/albums.controller';
import {
parseCursor,
parseLimit,
userOf,
} from '../albums/albums.controller';
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
import { UploadsService } from './uploads.service';
import type { UploadInitResponse } from './uploads.service';
import type {
UploadCompleteResponse,
UploadInitResponse,
UploadSessionListResponse,
} from './uploads.service';
import { UploadInitRequestDto } from './dto';
@Controller('uploads')
@@ -28,4 +43,38 @@ export class UploadsController {
): Promise<UploadInitResponse> {
return this.uploadsService.init(userOf(req), dto);
}
/** 上传历史:当前用户自己的会话,新在前,(createdAt,id) 键集游标。 */
@Get()
listUploads(
@Query('cursor') cursor: string | undefined,
@Query('limit') limit: string | undefined,
@Req() req: AuthenticatedRequest,
): Promise<UploadSessionListResponse> {
return this.uploadsService.list(
userOf(req),
parseCursor(cursor),
parseLimit(limit),
);
}
/** 续签:仅 pending 会话;返回结构同 initUpload(credential 为新值)。 */
@Post(':uploadId/renew')
@HttpCode(HttpStatus.OK)
renewUploadCredential(
@Param('uploadId') uploadId: string,
@Req() req: AuthenticatedRequest,
): Promise<UploadInitResponse> {
return this.uploadsService.renew(userOf(req), uploadId);
}
/** 完成核验:幂等(重复调用返回相同结果,不重复建 job)。 */
@Post(':uploadId/complete')
@HttpCode(HttpStatus.OK)
completeUpload(
@Param('uploadId') uploadId: string,
@Req() req: AuthenticatedRequest,
): Promise<UploadCompleteResponse> {
return this.uploadsService.complete(userOf(req), uploadId);
}
}
+314 -4
View File
@@ -1,5 +1,6 @@
// services/api/src/uploads/uploads.service.ts —— 上传域核心逻辑(Task 18 起)。
// 契约权威:packages/contracts/openapi.yaml tags [uploads]。要点:
// services/api/src/uploads/uploads.service.ts —— 上传域核心逻辑(Task 18 起,
// Task 19 增补 renew/complete/list)。契约权威:packages/contracts/openapi.yaml
// tags [uploads]。要点:
// · initUpload 幂等初始化(A03):@@unique([userId, clientTaskId]) 为幂等锚,
// 同键同参返回原会话(201,不重复建照片);同键异参 409 UPLOAD_SESSION_CONFLICT。
// 并发同键插入由 P2002 捕获后回读既有会话裁决(MySQL 约束冲突不中止事务)。
@@ -13,10 +14,26 @@
// · init 不落 audit(高频低风险;审计锚点在 complete,A06 前置约定)。
// · UPLOAD_MAX_BYTES/SIGNED_URL_TTL_SECONDS 逐请求惰性读取(support),
// presign 在事务外执行(SigV4 为本地计算,无网络副作用)。
import { Inject, Injectable } from '@nestjs/common';
// · renew(Task 19):仅 pending 会话可续签;凭证过期→renew 是契约规定的恢复
// 路径,故 renew 同时滑动顺延会话 expiresAt;相册关闭上传即拒签(§4.9)。
// · complete(Task 19):核验失败(head staging:缺失/字节数不符/contentType
// 与申明不符——presign 不签名 content-type,Task 11 发现的服务端兜底)是
// 请求级 400,会话保持 pending 可重试;通过后 copyIfAbsent(禁止覆盖语义)
// 受控复制到正式键,再进事务:photo(bytes/uploadedAt/uploaded) + 会话抢占
// 置 confirmed(updateMany 条件更新,并发 complete 恰一人胜出)+ 胜者独占
// photo_jobs upsert 与 audit;提交后入队 photo-process(失败仅日志,Task 21
// sweep 以 photo_jobs(pending) 为事实源补偿)。confirmed 会话重复 complete
// 走幂等路径:copyIfAbsent 复核(originals 已在则 created:false,staging 已
// 被清理也不受影响)后直接回显当前 processingState——不重复 job/audit/入队。
// · 权限逐次校验(A02):renew/complete 每次调用都重查成员权——被移除成员
// 的下一次请求即刻 403(会话/照片 ID 均已持有也不放行)。
import { Inject, Injectable, HttpStatus } from '@nestjs/common';
import { ErrorCodes } from '@photo/contracts';
import type { components } from '@photo/contracts';
import type { StoragePort } from '@photo/storage';
import { StorageError, type StoragePort } from '@photo/storage';
import type { Prisma } from '@photo/db-cn';
import type { AuthUser } from '../auth/session.guard';
import { ApiException } from '../api-exception';
import { PermissionsService } from '../albums/permissions.service';
import {
albumForbidden,
@@ -25,7 +42,16 @@ import {
validationFailed,
} from '../albums/support';
import { PrismaService } from '../prisma/prisma.service';
import {
decodeKeysetCursor,
encodeKeysetCursor,
keysetWhereDesc,
} from '../shared/cursor';
import { STORAGE_PORT } from './storage.provider';
import {
PHOTO_PROCESS_QUEUE,
type PhotoProcessQueueProducer,
} from './queue.provider';
import type { UploadInitRequestDto } from './dto';
import {
albumUploadDisabled,
@@ -37,10 +63,17 @@ import {
stagingObjectKey,
uploadMaxBytes,
uploadSessionConflict,
uploadSessionForbidden,
uploadVerifyFailed,
} from './support';
export type UploadInitResponse =
components['schemas']['UploadInitResponse'];
export type UploadCompleteResponse =
components['schemas']['UploadCompleteResponse'];
export type UploadSessionListResponse =
components['schemas']['UploadSessionList'];
type UploadSessionRecord = components['schemas']['UploadSessionRecord'];
type SessionRow = {
id: string;
@@ -51,12 +84,16 @@ type SessionRow = {
albumId: string;
};
type UploadSessionRow = Prisma.upload_sessionsGetPayload<Prisma.upload_sessionsDefaultArgs>;
@Injectable()
export class UploadsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
@Inject(PHOTO_PROCESS_QUEUE)
private readonly queue: PhotoProcessQueueProducer,
) {}
/**
@@ -202,4 +239,277 @@ export class UploadsService {
extOfObjectKey(session.objectKey) === ext
);
}
/**
* 会话定位 + 归属 + 逐次权限核验(renew/complete 共用)。任何一项不过都抛
* 同一 403 AUTH_FORBIDDEN(契约:无权/不存在不区分,防 uploadId 枚举)。
* 返回值保证非空且 userId === user.id。
*/
private async locateAuthorizedSession(
user: AuthUser,
uploadId: string,
): Promise<UploadSessionRow> {
const session = await this.prisma.upload_sessions.findUnique({
where: { id: uploadId },
});
if (session === null || session.userId !== user.id) {
throw uploadSessionForbidden();
}
// A02 逐次校验:被移除成员/册消失后的下一次 renew/complete 即刻拦截。
if (!(await this.permissions.canUploadToAlbum(user, session.albumId))) {
throw uploadSessionForbidden();
}
return session;
}
/**
* 续签(Task 19):仅 pending 会话;同 staging 键现签新凭证,并滑动顺延会话
* expiresAt(契约 UploadCredential.expiresAt:过期后续签再重试——renew 是
* 凭证过期的恢复路径,不得因过期而拒绝)。相册关闭上传即拒(§4.9)。
*/
async renew(
user: AuthUser,
uploadId: string,
): Promise<UploadInitResponse> {
const session = await this.locateAuthorizedSession(user, uploadId);
if (session.state !== 'pending') {
throw uploadSessionConflict('会话状态不允许续签(已完成/取消/过期)');
}
const album = await this.prisma.albums.findUnique({
where: { id: session.albumId },
select: { uploadEnabled: true },
});
if (album === null) {
// 相册行已不存在:同 403 防枚举(与不可见同码)。
throw uploadSessionForbidden();
}
if (!album.uploadEnabled) {
throw albumUploadDisabled();
}
const ttl = signedUrlTtlSeconds();
const credential = await this.storage.presignPut(
session.objectKey,
session.expectedBytes,
session.contentType,
ttl,
);
await this.prisma.upload_sessions.update({
where: { id: session.id },
data: { expiresAt: new Date(Date.now() + ttl * 1000) },
});
return {
uploadId: session.id,
photoId: session.photoId,
objectKey: session.objectKey,
credential,
};
}
/**
* 完成核验(Task 19):head 核验 → 受控复制 → 事务三表 + audit → 提交后入队。
* 幂等(A03):confirmed 会话重复调用经 copyIfAbsent 复核(originals 已在则
* created:false;staging 已清理亦不受影响)后回显当前状态,不产生第二个
* job/audit/入队。核验失败是请求级 400,会话保持 pending 可重试。
*/
async complete(
user: AuthUser,
uploadId: string,
): Promise<UploadCompleteResponse> {
const session = await this.locateAuthorizedSession(user, uploadId);
// 注意:契约 complete 无 ALBUM_UPLOAD_DISABLED——已完成的上传不因运营开关
// 被阻断(开关拒绝的是新直传与续签)。
if (session.state === 'cancelled' || session.state === 'expired') {
throw uploadSessionConflict('会话状态不允许完成(已取消或已过期)');
}
if (
session.state === 'pending' &&
session.expiresAt.getTime() <= Date.now()
) {
throw uploadSessionConflict('会话已过期(已取消或已过期)');
}
const photo = await this.prisma.photos.findUnique({
where: { id: session.photoId },
});
if (photo === null) {
// 会话与照片同事务创建,缺行只可能是装配异常/外部破坏,按 5xx 兜底。
throw new ApiException(
ErrorCodes.INTERNAL_ERROR,
'Internal server error',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
if (session.state === 'confirmed') {
// 幂等快路径:originals 已核验过一次;copyIfAbsent 复核(created:false
// 路径,不依赖 staging 源仍存在),不重复 job/audit/入队。
await this.copyWithVerify(
session.objectKey,
photo.objectKey,
session.expectedBytes,
);
const current = await this.prisma.photos.findUnique({
where: { id: photo.id },
select: { processingState: true },
});
const processingState = (current ?? photo)
.processingState as UploadCompleteResponse['processingState'];
return { photoId: photo.id, processingState };
}
// ① 事务外存储核验:存在性/字节数/contentType(presign 不签名 content-type,
// 服务端兜底——Task 11 审查发现)。
const head = await this.storage.head(session.objectKey);
if (head === null) {
throw uploadVerifyFailed('staging 对象不存在或尚未上传');
}
if (head.bytes !== session.expectedBytes) {
throw uploadVerifyFailed(
`字节数与申明不符(expected=${session.expectedBytes}, actual=${head.bytes})`,
);
}
if (head.contentType !== session.contentType) {
throw uploadVerifyFailed(
`Content-Type 与申明不符(expected=${session.contentType}, actual=${head.contentType})`,
);
}
// ② 受控复制 staging → 正式键(禁止覆盖语义:目标已在且一致则幂等返回)。
await this.copyWithVerify(
session.objectKey,
photo.objectKey,
session.expectedBytes,
);
// ③ 事务:photo 更新 + 会话条件更新抢占(并发 complete 恰一人胜出)+
// 胜者独占 photo_jobs 与 audit。
let claimed = false;
await this.prisma.$transaction(async (tx) => {
await tx.photos.update({
where: { id: photo.id },
data: {
bytes: session.expectedBytes,
uploadedAt: new Date(),
processingState: 'uploaded',
},
});
const race = await tx.upload_sessions.updateMany({
where: { id: session.id, state: 'pending' },
data: { state: 'confirmed' },
});
if (race.count === 1) {
claimed = true;
await tx.photo_jobs.upsert({
where: { photoId_type: { photoId: photo.id, type: 'process' } },
update: {}, // 已有 job(外部补偿先行/重复投递)不重复创建。
create: { photoId: photo.id, type: 'process', state: 'pending' },
});
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'upload.complete',
resourceId: photo.id,
metadata: {
uploadId: session.id,
bytes: session.expectedBytes,
},
},
});
}
});
// ④ 提交后入队(失败仅日志:photo_jobs(pending) 是持久事实源,Task 21
// sweep 补偿;jobId 以 photoId 派生,重复入队天然去重)。
if (claimed) {
await this.queue.enqueuePhotoProcess(photo.id);
}
return { photoId: photo.id, processingState: 'uploaded' };
}
/** copyIfAbsent 包装:StorageError(UPLOAD_VERIFY_FAILED) → 同码 400 业务异常。 */
private async copyWithVerify(
src: string,
dest: string,
expectedBytes: number,
): Promise<void> {
try {
await this.storage.copyIfAbsent(src, dest, expectedBytes);
} catch (err) {
if (err instanceof StorageError) {
throw uploadVerifyFailed(err.message);
}
throw err;
}
}
/**
* 上传历史(Task 19):当前用户自己的会话(不分相册/状态),新在前
* (createdAt,id) 键集游标;photo 子对象内嵌服务端处理进度与可见性。
*/
async list(
user: AuthUser,
cursor: string | undefined,
limit: number,
): Promise<UploadSessionListResponse> {
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
if (cursor !== undefined && cursor !== '') {
decoded = decodeKeysetCursor(cursor);
if (decoded === null) {
throw validationFailed('cursor 不合法');
}
}
const where: Prisma.upload_sessionsWhereInput = { userId: user.id };
if (decoded !== null) {
Object.assign(where, keysetWhereDesc('createdAt', 'id', decoded));
}
const rows = await this.prisma.upload_sessions.findMany({
where,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: limit + 1, // 多取一条探测 hasMore(同相册列表)
});
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
// 相册域式扁平外键:照片二次查询后应用层组装(会话与照片 1:1 同事务创建)。
const photos =
page.length === 0
? []
: await this.prisma.photos.findMany({
where: { id: { in: page.map((row) => row.photoId) } },
select: { id: true, processingState: true, visibility: true },
});
const photoById = new Map(photos.map((photo) => [photo.id, photo]));
const last = page.at(-1);
const nextCursor =
hasMore && last !== undefined
? encodeKeysetCursor({
sort: last.createdAt.toISOString(),
id: last.id,
})
: null;
return {
items: page.map((row) => {
const photo = photoById.get(row.photoId);
const record: UploadSessionRecord = {
uploadId: row.id,
albumId: row.albumId,
clientTaskId: row.clientTaskId,
state: row.state as UploadSessionRecord['state'],
expectedBytes: row.expectedBytes,
contentType: row.contentType,
createdAt: row.createdAt.toISOString(),
photo: {
photoId: row.photoId,
processingState:
(photo?.processingState ?? 'pending_upload') as UploadSessionRecord['photo']['processingState'],
visibility: (photo?.visibility ?? 'unpublished') as UploadSessionRecord['photo']['visibility'],
},
};
return record;
}),
nextCursor,
hasMore: nextCursor !== null,
};
}
}