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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user