feat(api): upload duplicate pre-check endpoint (checkUploadDuplicates)
「上传查重前置到入队前」Task 1:POST /v1/uploads/duplicates/check 只读批量 查重预检——用户点上传(确认入队)时刻预判服务端历史重复(跨设备/重装/改名 均可查到),不再等任务在上传中心以 PHOTO_DUPLICATE 失败才暴露。 - 契约纯增量:/uploads/duplicates/check + UploadDuplicateCheck(Item|Request| Duplicate|Response) schemas;regen openapi.d.ts。 - 判定谓词与 init ③.5 服务端查重逐字一致:同 albumId + uploaderId=当前用户 + bytes 相等 + visibility != 'deleted'(filename 刻意不参与),保证「预检 即真相」;命中条目按请求下标逐项返回(同 bytes 多请求条目各一条)。 - 权限链同 init(canView 404 防枚举 / canUpload 403 / 相册空值兜底 404), 刻意不校验 uploadEnabled:只读预览,init 真正入队时仍按开关 409 兜底。 - 查询走 idx_photos_dedup:请求条目去重字节集一次 findMany,内存建 bytes→最早 uploadedAt 映射后按请求下标回射。 - items 1..500(minItems/maxItems + 嵌套逐项校验,越界 400)。 - e2e 追加 describe 7 用例:命中含双条目同 bytes/未命中/uploaderId 隔离/ deleted 不命中/非成员 404/items 边界 400/未认证 401。 契约 lint ✅ · contracts+api typecheck ✅ · api 单测 56/56 ✅ · uploads e2e 28/28 ✅ Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,18 @@
|
||||
// services/api/src/uploads/dto.ts —— 上传端点入参 DTO(Task 18;sourceDeviceId
|
||||
// 为 P3 Task 3 契约增补)。校验约束对齐契约 UploadInitRequest:
|
||||
// 为 P3 Task 3 契约增补;查重预检 DuplicateCheckRequestDto 为「上传查重前置到
|
||||
// 入队前」Task 1 增补)。校验约束对齐契约 UploadInitRequest:
|
||||
// · contentType 限白名单枚举(image/heic 暂定,G2 真机验证);
|
||||
// · bytes ≥ 1(契约 minimum);上限 UPLOAD_MAX_BYTES 是环境相关量,逐请求
|
||||
// 惰性读取,故在服务层校验(同 filename 扩展名白名单与 categoryId 属主校验);
|
||||
// · clientTaskId/filename/albumId/categoryId 列宽对齐 db-cn schema
|
||||
// (VarChar 128/255/64/64);sourceDeviceId 8-64(A19 设备来源语义)。
|
||||
import { IsBoolean,
|
||||
// DuplicateCheckRequestDto 对齐契约 UploadDuplicateCheckRequest:
|
||||
// · items 1..500(minItems/maxItems,嵌套条目逐项校验);
|
||||
// · 条目 filename 1..255 / bytes ≥ 1(同 UploadInitRequest 语义)。
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
@@ -14,6 +21,7 @@ import { IsBoolean,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { UPLOAD_CONTENT_TYPES, type UploadContentType } from './support';
|
||||
|
||||
@@ -61,3 +69,33 @@ export class UploadInitRequestDto {
|
||||
@IsBoolean()
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/** 契约 UploadDuplicateCheckRequest.items 单批上限(maxItems:500)。 */
|
||||
export const DUPLICATE_CHECK_MAX_ITEMS = 500;
|
||||
|
||||
/** 契约 UploadDuplicateCheckItem(查重预检请求条目,与 init 入参同形)。 */
|
||||
export class DuplicateCheckItemDto {
|
||||
/** 仅元数据:查重谓词不使用(与 init ③.5 查重一致)。 */
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
filename!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
bytes!: number;
|
||||
}
|
||||
|
||||
/** 契约 UploadDuplicateCheckRequest(批量查重预检)。 */
|
||||
export class DuplicateCheckRequestDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(64)
|
||||
albumId!: string;
|
||||
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(DUPLICATE_CHECK_MAX_ITEMS)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => DuplicateCheckItemDto)
|
||||
items!: DuplicateCheckItemDto[];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// services/api/src/uploads/uploads.controller.ts —— /uploads 端点(Task 18 起;
|
||||
// Task 19 增补 listUploads/renewUploadCredential/completeUpload)。方法名与契约
|
||||
// Task 19 增补 listUploads/renewUploadCredential/completeUpload;查重预检
|
||||
// checkUploadDuplicates 为「上传查重前置到入队前」Task 1 增补)。方法名与契约
|
||||
// operationId 一一对应。控制器级 SessionGuard:端点全需认证。职责:透传认证
|
||||
// 用户 + 查询参数规整(parseLimit/parseCursor 用 shared/pagination,同契约 bounds)。
|
||||
// 注意 DTO 必须值导入(非 import type):@Body 参数类型经 emitDecoratorMetadata
|
||||
@@ -22,10 +23,11 @@ import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
||||
import { UploadsService } from './uploads.service';
|
||||
import type {
|
||||
UploadCompleteResponse,
|
||||
UploadDuplicateCheckResponse,
|
||||
UploadInitResponse,
|
||||
UploadSessionListResponse,
|
||||
} from './uploads.service';
|
||||
import { UploadInitRequestDto } from './dto';
|
||||
import { UploadInitRequestDto, DuplicateCheckRequestDto } from './dto';
|
||||
|
||||
@Controller('uploads')
|
||||
@UseGuards(SessionGuard)
|
||||
@@ -55,6 +57,20 @@ export class UploadsController {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查重预检(只读批量):判定谓词与 init ③.5 服务端查重逐字一致(见 service
|
||||
* checkDuplicates)。声明于 :uploadId 参数路由之前(静态段路由,无实际冲突,
|
||||
* 仅保持「具体路径先于参数路径」的可读惯例)。
|
||||
*/
|
||||
@Post('duplicates/check')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
checkUploadDuplicates(
|
||||
@Body() dto: DuplicateCheckRequestDto,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<UploadDuplicateCheckResponse> {
|
||||
return this.uploadsService.checkDuplicates(userOf(req), dto);
|
||||
}
|
||||
|
||||
/** 续签:仅 pending 会话;返回结构同 initUpload(credential 为新值)。 */
|
||||
@Post(':uploadId/renew')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
// 被清理也不受影响)后直接回显当前 processingState——不重复 job/audit/入队。
|
||||
// · 权限逐次校验(A02):renew/complete 每次调用都重查成员权——被移除成员
|
||||
// 的下一次请求即刻 403(会话/照片 ID 均已持有也不放行)。
|
||||
// · checkDuplicates(「上传查重前置到入队前」Task 1):只读批量查重预检,
|
||||
// 谓词与 init ③.5 逐字一致;不校验 uploadEnabled(只读预览,init 兜底)。
|
||||
import { Inject, Injectable, HttpStatus } from '@nestjs/common';
|
||||
import { ErrorCodes } from '@photo/contracts';
|
||||
import type { components } from '@photo/contracts';
|
||||
@@ -52,7 +54,7 @@ import {
|
||||
PHOTO_PROCESS_QUEUE,
|
||||
type PhotoProcessQueueProducer,
|
||||
} from './queue.provider';
|
||||
import type { UploadInitRequestDto } from './dto';
|
||||
import type { UploadInitRequestDto, DuplicateCheckRequestDto } from './dto';
|
||||
import {
|
||||
albumUploadDisabled,
|
||||
cuidLikeId,
|
||||
@@ -73,7 +75,11 @@ export type UploadCompleteResponse =
|
||||
components['schemas']['UploadCompleteResponse'];
|
||||
export type UploadSessionListResponse =
|
||||
components['schemas']['UploadSessionList'];
|
||||
export type UploadDuplicateCheckResponse =
|
||||
components['schemas']['UploadDuplicateCheckResponse'];
|
||||
type UploadSessionRecord = components['schemas']['UploadSessionRecord'];
|
||||
type UploadDuplicateCheckDuplicate =
|
||||
components['schemas']['UploadDuplicateCheckDuplicate'];
|
||||
|
||||
type SessionRow = {
|
||||
id: string;
|
||||
@@ -538,4 +544,78 @@ export class UploadsService {
|
||||
hasMore: nextCursor !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查重预检(「上传查重前置到入队前」Task 1):只读批量——用户点上传(确认
|
||||
* 入队)时刻预判服务端历史重复,跨设备/重装/改名均可查到。判定谓词与 init
|
||||
* ③.5 服务端查重**逐字一致**(同 albumId + uploaderId=当前用户 + bytes 相等
|
||||
* + visibility != 'deleted';filename 刻意不参与),保证「预检即真相」——
|
||||
* 预检不命中的条目走 init 不会被 409 PHOTO_DUPLICATE 拦截。
|
||||
* 权限链同 init(①可见性 404 / ②成员权 403 / ③相册行空值兜底 404),但
|
||||
* **刻意不校验 uploadEnabled**:预检是只读预览,相册关闭上传时客户端仍可
|
||||
* 查询历史重复(init 在真正入队时仍按开关 409 兜底,语义不变)。
|
||||
* 实现:请求条目去重字节集一次 findMany(idx_photos_dedup 覆盖
|
||||
* albumId+uploaderId+bytes),内存建 bytes→最早 uploadedAt 映射(force 允许
|
||||
* 的同 bytes 多行取最早),再按请求下标逐条回射——两个请求条目共享同一命中
|
||||
* bytes 时各返回一条(duplicates 按请求下标升序)。
|
||||
*/
|
||||
async checkDuplicates(
|
||||
user: AuthUser,
|
||||
dto: DuplicateCheckRequestDto,
|
||||
): Promise<UploadDuplicateCheckResponse> {
|
||||
// ① 相册可见性:不可见(含不存在)统一 404,防 id 枚举(同 init)。
|
||||
if (!(await this.permissions.canViewAlbum(user, dto.albumId))) {
|
||||
throw albumNotFound();
|
||||
}
|
||||
// ② 上传成员权(P1a:可见即成员,此分支不可达;viewer 角色出现时启用 403)。
|
||||
if (!(await this.permissions.canUploadToAlbum(user, dto.albumId))) {
|
||||
throw albumForbidden('无该相册的上传权限');
|
||||
}
|
||||
// ③ 相册行存在性兜底(admin 对不存在 id 权限恒真,须由空值兜底同码 404)。
|
||||
// 刻意不读 uploadEnabled:见方法头注释。
|
||||
const album = await this.prisma.albums.findUnique({
|
||||
where: { id: dto.albumId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (album === null) {
|
||||
throw albumNotFound();
|
||||
}
|
||||
|
||||
const distinctBytes = [...new Set(dto.items.map((item) => item.bytes))];
|
||||
const rows = await this.prisma.photos.findMany({
|
||||
where: {
|
||||
albumId: dto.albumId,
|
||||
uploaderId: user.id,
|
||||
bytes: { in: distinctBytes },
|
||||
visibility: { not: 'deleted' },
|
||||
},
|
||||
select: { bytes: true, uploadedAt: true },
|
||||
});
|
||||
const earliest = new Map<number, Date>();
|
||||
for (const row of rows) {
|
||||
// bytes>0 的行只会经 complete 落定(同事务写 uploadedAt);null 仅理论
|
||||
// 形状(直改库等),跳过不参与映射——谓词命中与否不受影响(uploadedAt
|
||||
// 只是回显元数据)。
|
||||
if (row.uploadedAt === null) {
|
||||
continue;
|
||||
}
|
||||
const known = earliest.get(row.bytes);
|
||||
if (known === undefined || row.uploadedAt < known) {
|
||||
earliest.set(row.bytes, row.uploadedAt);
|
||||
}
|
||||
}
|
||||
|
||||
const duplicates: UploadDuplicateCheckDuplicate[] = [];
|
||||
dto.items.forEach((item, index) => {
|
||||
const hit = earliest.get(item.bytes);
|
||||
if (hit !== undefined) {
|
||||
duplicates.push({
|
||||
index,
|
||||
bytes: item.bytes,
|
||||
uploadedAt: hit.toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
return { duplicates };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user