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:
浅念
2026-09-28 18:56:07 +08:00
parent 7d744da56e
commit 805f77be76
6 changed files with 564 additions and 5 deletions
+111
View File
@@ -909,6 +909,56 @@ paths:
'500': '500':
$ref: '#/components/responses/InternalError' $ref: '#/components/responses/InternalError'
/uploads/duplicates/check:
post:
operationId: checkUploadDuplicates
tags: [uploads]
summary: 上传查重预检(批量,只读)
description: |
批量查重预检:对每个请求条目判定「同相册 + 同上传者(当前用户)+ 同字节数」
的非 deleted 照片是否已存在——判定谓词与 initUpload 的 PHOTO_DUPLICATE
查重逐字一致(含 uploaderId 隔离;filename 刻意不参与)。命中条目按请求
下标逐项返回(两个请求条目共享同一命中 bytes 时各返回一条)。
不校验 uploadEnabled:预检是只读预览,相册关闭上传时仍可查询历史重复
(init 在真正入队时仍按开关 409 兜底)。
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UploadDuplicateCheckRequest'
responses:
'200':
description: 查重结果(命中子集;无命中时 duplicates 为空数组)。
content:
application/json:
schema:
$ref: '#/components/schemas/UploadDuplicateCheckResponse'
'400':
description: 参数校验失败(VALIDATION_FAILED):items 为空/超 500 项或条目字段非法。
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: 无该相册的上传权限(AUTH_FORBIDDEN)。
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: 相册不可见或不存在(ALBUM_NOT_FOUND,不区分以避免枚举)。
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
$ref: '#/components/responses/InternalError'
/uploads/{uploadId}/renew: /uploads/{uploadId}/renew:
parameters: parameters:
- $ref: '#/components/parameters/UploadId' - $ref: '#/components/parameters/UploadId'
@@ -3536,6 +3586,67 @@ components:
items: items:
$ref: '#/components/schemas/UploadSessionRecord' $ref: '#/components/schemas/UploadSessionRecord'
UploadDuplicateCheckItem:
type: object
description: 查重预检请求条目(与 initUpload 入参同形;filename 仅元数据)。
required: [filename, bytes]
properties:
filename:
type: string
minLength: 1
maxLength: 255
description: 原始文件名(仅元数据;查重谓词不使用,与 init 查重一致)。
bytes:
type: integer
format: int64
minimum: 1
description: 申明文件字节数(查重键)。
UploadDuplicateCheckRequest:
type: object
description: 批量查重预检请求;单批上限 500 项,客户端按 500 分块顺序调用。
required: [albumId, items]
properties:
albumId:
type: string
description: 目标相册 ID。
items:
type: array
minItems: 1
maxItems: 500
description: 待查重条目(1..500 项)。
items:
$ref: '#/components/schemas/UploadDuplicateCheckItem'
UploadDuplicateCheckDuplicate:
type: object
description: 单条命中结果;每个命中的「请求条目」一条(index 指向请求数组)。
required: [index, bytes, uploadedAt]
properties:
index:
type: integer
minimum: 0
description: 命中条目在请求 items 数组中的下标(0 起)。
bytes:
type: integer
format: int64
description: 命中字节数(与对应请求条目一致)。
uploadedAt:
type: string
format: date-time
description: 服务端既有照片的上传时间(同 bytes 多条命中取最早)。
UploadDuplicateCheckResponse:
type: object
description: 查重预检结果(命中子集,按请求下标升序)。
required: [duplicates]
properties:
duplicates:
type: array
description: 命中子集;无命中为空数组。
items:
$ref: '#/components/schemas/UploadDuplicateCheckDuplicate'
# ── 照片(管理端) ── # ── 照片(管理端) ──
PhotoVisibilityUpdateRequest: PhotoVisibilityUpdateRequest:
+115
View File
@@ -364,6 +364,31 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/uploads/duplicates/check": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* 上传查重预检(批量,只读)
* @description 批量查重预检:对每个请求条目判定「同相册 + 同上传者(当前用户)+ 同字节数」
* 的非 deleted 照片是否已存在——判定谓词与 initUpload 的 PHOTO_DUPLICATE
* 查重逐字一致(含 uploaderId 隔离;filename 刻意不参与)。命中条目按请求
* 下标逐项返回(两个请求条目共享同一命中 bytes 时各返回一条)。
* 不校验 uploadEnabled:预检是只读预览,相册关闭上传时仍可查询历史重复
* (init 在真正入队时仍按开关 409 兜底)。
*/
post: operations["checkUploadDuplicates"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/uploads/{uploadId}/renew": { "/uploads/{uploadId}/renew": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1732,6 +1757,43 @@ export interface components {
UploadSessionList: components["schemas"]["CursorPageMeta"] & { UploadSessionList: components["schemas"]["CursorPageMeta"] & {
items: components["schemas"]["UploadSessionRecord"][]; items: components["schemas"]["UploadSessionRecord"][];
}; };
/** @description 查重预检请求条目(与 initUpload 入参同形;filename 仅元数据)。 */
UploadDuplicateCheckItem: {
/** @description 原始文件名(仅元数据;查重谓词不使用,与 init 查重一致)。 */
filename: string;
/**
* Format: int64
* @description 申明文件字节数(查重键)。
*/
bytes: number;
};
/** @description 批量查重预检请求;单批上限 500 项,客户端按 500 分块顺序调用。 */
UploadDuplicateCheckRequest: {
/** @description 目标相册 ID。 */
albumId: string;
/** @description 待查重条目(1..500 项)。 */
items: components["schemas"]["UploadDuplicateCheckItem"][];
};
/** @description 单条命中结果;每个命中的「请求条目」一条(index 指向请求数组)。 */
UploadDuplicateCheckDuplicate: {
/** @description 命中条目在请求 items 数组中的下标(0 起)。 */
index: number;
/**
* Format: int64
* @description 命中字节数(与对应请求条目一致)。
*/
bytes: number;
/**
* Format: date-time
* @description 服务端既有照片的上传时间(同 bytes 多条命中取最早)。
*/
uploadedAt: string;
};
/** @description 查重预检结果(命中子集,按请求下标升序)。 */
UploadDuplicateCheckResponse: {
/** @description 命中子集;无命中为空数组。 */
duplicates: components["schemas"]["UploadDuplicateCheckDuplicate"][];
};
PhotoVisibilityUpdateRequest: { PhotoVisibilityUpdateRequest: {
/** /**
* @description 目标可见性;仅允许 hidden 与 published 间切换。 * @description 目标可见性;仅允许 hidden 与 published 间切换。
@@ -3705,6 +3767,59 @@ export interface operations {
500: components["responses"]["InternalError"]; 500: components["responses"]["InternalError"];
}; };
}; };
checkUploadDuplicates: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UploadDuplicateCheckRequest"];
};
};
responses: {
/** @description 查重结果(命中子集;无命中时 duplicates 为空数组)。 */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UploadDuplicateCheckResponse"];
};
};
/** @description 参数校验失败(VALIDATION_FAILED):items 为空/超 500 项或条目字段非法。 */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
/** @description 无该相册的上传权限(AUTH_FORBIDDEN)。 */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description 相册不可见或不存在(ALBUM_NOT_FOUND,不区分以避免枚举)。 */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
500: components["responses"]["InternalError"];
};
};
renewUploadCredential: { renewUploadCredential: {
parameters: { parameters: {
query?: never; query?: never;
+40 -2
View File
@@ -1,11 +1,18 @@
// services/api/src/uploads/dto.ts —— 上传端点入参 DTO(Task 18;sourceDeviceId // 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 真机验证); // · contentType 限白名单枚举(image/heic 暂定,G2 真机验证);
// · bytes ≥ 1(契约 minimum);上限 UPLOAD_MAX_BYTES 是环境相关量,逐请求 // · bytes ≥ 1(契约 minimum);上限 UPLOAD_MAX_BYTES 是环境相关量,逐请求
// 惰性读取,故在服务层校验(同 filename 扩展名白名单与 categoryId 属主校验); // 惰性读取,故在服务层校验(同 filename 扩展名白名单与 categoryId 属主校验);
// · clientTaskId/filename/albumId/categoryId 列宽对齐 db-cn schema // · clientTaskId/filename/albumId/categoryId 列宽对齐 db-cn schema
// (VarChar 128/255/64/64);sourceDeviceId 8-64(A19 设备来源语义)。 // (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, IsIn,
IsInt, IsInt,
IsNotEmpty, IsNotEmpty,
@@ -14,6 +21,7 @@ import { IsBoolean,
MaxLength, MaxLength,
Min, Min,
MinLength, MinLength,
ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { UPLOAD_CONTENT_TYPES, type UploadContentType } from './support'; import { UPLOAD_CONTENT_TYPES, type UploadContentType } from './support';
@@ -61,3 +69,33 @@ export class UploadInitRequestDto {
@IsBoolean() @IsBoolean()
force?: boolean; 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[];
}
+18 -2
View File
@@ -1,5 +1,6 @@
// services/api/src/uploads/uploads.controller.ts —— /uploads 端点(Task 18 起; // 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:端点全需认证。职责:透传认证 // operationId 一一对应。控制器级 SessionGuard:端点全需认证。职责:透传认证
// 用户 + 查询参数规整(parseLimit/parseCursor 用 shared/pagination,同契约 bounds)。 // 用户 + 查询参数规整(parseLimit/parseCursor 用 shared/pagination,同契约 bounds)。
// 注意 DTO 必须值导入(非 import type):@Body 参数类型经 emitDecoratorMetadata // 注意 DTO 必须值导入(非 import type):@Body 参数类型经 emitDecoratorMetadata
@@ -22,10 +23,11 @@ import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
import { UploadsService } from './uploads.service'; import { UploadsService } from './uploads.service';
import type { import type {
UploadCompleteResponse, UploadCompleteResponse,
UploadDuplicateCheckResponse,
UploadInitResponse, UploadInitResponse,
UploadSessionListResponse, UploadSessionListResponse,
} from './uploads.service'; } from './uploads.service';
import { UploadInitRequestDto } from './dto'; import { UploadInitRequestDto, DuplicateCheckRequestDto } from './dto';
@Controller('uploads') @Controller('uploads')
@UseGuards(SessionGuard) @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 为新值)。 */ /** 续签:仅 pending 会话;返回结构同 initUpload(credential 为新值)。 */
@Post(':uploadId/renew') @Post(':uploadId/renew')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
+81 -1
View File
@@ -27,6 +27,8 @@
// 被清理也不受影响)后直接回显当前 processingState——不重复 job/audit/入队。 // 被清理也不受影响)后直接回显当前 processingState——不重复 job/audit/入队。
// · 权限逐次校验(A02):renew/complete 每次调用都重查成员权——被移除成员 // · 权限逐次校验(A02):renew/complete 每次调用都重查成员权——被移除成员
// 的下一次请求即刻 403(会话/照片 ID 均已持有也不放行)。 // 的下一次请求即刻 403(会话/照片 ID 均已持有也不放行)。
// · checkDuplicates(「上传查重前置到入队前」Task 1):只读批量查重预检,
// 谓词与 init ③.5 逐字一致;不校验 uploadEnabled(只读预览,init 兜底)。
import { Inject, Injectable, HttpStatus } from '@nestjs/common'; import { Inject, Injectable, HttpStatus } from '@nestjs/common';
import { ErrorCodes } from '@photo/contracts'; import { ErrorCodes } from '@photo/contracts';
import type { components } from '@photo/contracts'; import type { components } from '@photo/contracts';
@@ -52,7 +54,7 @@ import {
PHOTO_PROCESS_QUEUE, PHOTO_PROCESS_QUEUE,
type PhotoProcessQueueProducer, type PhotoProcessQueueProducer,
} from './queue.provider'; } from './queue.provider';
import type { UploadInitRequestDto } from './dto'; import type { UploadInitRequestDto, DuplicateCheckRequestDto } from './dto';
import { import {
albumUploadDisabled, albumUploadDisabled,
cuidLikeId, cuidLikeId,
@@ -73,7 +75,11 @@ export type UploadCompleteResponse =
components['schemas']['UploadCompleteResponse']; components['schemas']['UploadCompleteResponse'];
export type UploadSessionListResponse = export type UploadSessionListResponse =
components['schemas']['UploadSessionList']; components['schemas']['UploadSessionList'];
export type UploadDuplicateCheckResponse =
components['schemas']['UploadDuplicateCheckResponse'];
type UploadSessionRecord = components['schemas']['UploadSessionRecord']; type UploadSessionRecord = components['schemas']['UploadSessionRecord'];
type UploadDuplicateCheckDuplicate =
components['schemas']['UploadDuplicateCheckDuplicate'];
type SessionRow = { type SessionRow = {
id: string; id: string;
@@ -538,4 +544,78 @@ export class UploadsService {
hasMore: nextCursor !== null, 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 };
}
} }
+199
View File
@@ -948,3 +948,202 @@ describe('uploads renew & complete & history (e2e)', () => {
expect(tooLong.body.code).toBe(ErrorCodes.VALIDATION_FAILED); expect(tooLong.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
}); });
}); });
// ── 查重预检(checkUploadDuplicates,「上传查重前置到入队前」计划 Task 1)──
describe('uploads duplicates check (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let memberAId: string;
let memberBId: string;
let memberAToken: string;
let outsiderToken: string;
let albumId: string;
/** memberA 既有可见照片字节数(固定 uploadedAt 供逐字段断言回显)。 */
const BYTES_A = 777_777;
const UPLOADED_AT_A = '2026-09-20T08:00:00.000Z';
/** memberB(同册协作者)照片字节数(uploaderId 隔离用例)。 */
const BYTES_B_OTHER = 888_888;
/** memberA 已软删除照片字节数(visibility=deleted 不参与查重)。 */
const BYTES_A_DELETED = 999_999;
const check = (token: string, body: object) =>
request(app.getHttpServer())
.post(`/${API_PREFIX}/uploads/duplicates/check`)
.set('Authorization', `Bearer ${token}`)
.send(body);
const item = (filename: string, bytes: number) => ({ filename, bytes });
/** 直种 photos 裸行:预检只读 photos 列,无变体/对象存储依赖。 */
const seedPhoto = (
uploaderId: string,
bytes: number,
options: { visibility?: string; uploadedAt?: Date } = {},
) =>
prisma.photos.create({
data: {
albumId,
uploaderId,
objectKey: `photo-live/dev/albums/${albumId}/originals/dc-${uploaderId}-${bytes}-${Math.random().toString(36).slice(2, 8)}.jpg`,
bytes,
uploadedAt: options.uploadedAt ?? new Date(),
visibility: options.visibility ?? 'unpublished',
},
select: { id: true },
});
beforeAll(async () => {
await resetDb();
const moduleRef = await Test.createTestingModule({
imports: [AppCnModule],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix(API_PREFIX);
await app.init();
prisma = new PrismaClient();
const mkUser = (account: string) =>
prisma.users.create({
data: { account, passwordHash: 'x', displayName: account },
select: { id: true },
});
const ownerId = (await mkUser('dc-owner')).id;
memberAId = (await mkUser('dc-member-a')).id;
memberBId = (await mkUser('dc-member-b')).id;
const outsiderId = (await mkUser('dc-outsider')).id;
const mkToken = async (userId: string): Promise<string> => {
const token = randomBytes(32).toString('hex');
await prisma.sessions.create({
data: {
userId,
tokenHash: hashSessionToken(token),
expiresAt: new Date(Date.now() + 3_600_000),
},
});
return token;
};
const ownerToken = await mkToken(ownerId);
memberAToken = await mkToken(memberAId);
outsiderToken = await mkToken(outsiderId);
// 相册 fixture:owner 建 A(API 建册自动落 owner 成员行),A/B 为协作者。
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/albums`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({
translations: [
{ locale: 'zh-CN', title: '查重预检相册' },
{ locale: 'en', title: 'DupCheck' },
],
defaultLocale: 'zh-CN',
startAt: '2026-12-01T02:00:00.000Z',
});
expect(res.status).toBe(201);
albumId = res.body.id as string;
await prisma.album_members.createMany({
data: [
{ albumId, userId: memberAId, role: 'collaborator' },
{ albumId, userId: memberBId, role: 'collaborator' },
],
});
// memberA 既有可见照片(非 deleted,uploadedAt 固定)。
await seedPhoto(memberAId, BYTES_A, { uploadedAt: new Date(UPLOADED_AT_A) });
// memberB 同册照片:memberA 查同 bytes 不得命中(uploaderId 隔离)。
await seedPhoto(memberBId, BYTES_B_OTHER);
// memberA 已软删除照片:谓词 visibility != deleted,不得命中。
await seedPhoto(memberAId, BYTES_A_DELETED, { visibility: 'deleted' });
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
});
it('命中:既有可见照片同 bytes → 按请求下标逐项返回(两个同 bytes 条目各一条)', async () => {
const res = await check(memberAToken, {
albumId,
items: [
item('first.jpg', 111_111),
item('a.jpg', BYTES_A),
item('b.jpg', BYTES_A),
],
});
expect(res.status).toBe(200);
expect(res.body.duplicates).toHaveLength(2);
// 命中子集按请求下标升序;每命中一个「请求条目」一条(index=1/2 共享命中)。
expect(res.body.duplicates[0]).toEqual({
index: 1,
bytes: BYTES_A,
uploadedAt: UPLOADED_AT_A,
});
expect(res.body.duplicates[1]).toEqual({
index: 2,
bytes: BYTES_A,
uploadedAt: UPLOADED_AT_A,
});
});
it('未命中字节数 → 空数组', async () => {
const res = await check(memberAToken, {
albumId,
items: [item('x.jpg', 111_111), item('y.png', 222_222)],
});
expect(res.status).toBe(200);
expect(res.body.duplicates).toEqual([]);
});
it('uploaderId 隔离:同册他人上传的同 bytes 照片不命中(谓词同 init ③.5)', async () => {
const res = await check(memberAToken, {
albumId,
items: [item('other.jpg', BYTES_B_OTHER)],
});
expect(res.status).toBe(200);
expect(res.body.duplicates).toEqual([]);
});
it('visibility=deleted 的照片不命中', async () => {
const res = await check(memberAToken, {
albumId,
items: [item('gone.jpg', BYTES_A_DELETED)],
});
expect(res.status).toBe(200);
expect(res.body.duplicates).toEqual([]);
});
it('非成员 → 404 ALBUM_NOT_FOUND(防枚举,同 init)', async () => {
const res = await check(outsiderToken, {
albumId,
items: [item('a.jpg', BYTES_A)],
});
expect(res.status).toBe(404);
expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
});
it('items 边界:空数组 → 400;501 项 → 400 VALIDATION_FAILED', async () => {
const empty = await check(memberAToken, { albumId, items: [] });
expect(empty.status).toBe(400);
expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const overflow = await check(memberAToken, {
albumId,
items: Array.from({ length: 501 }, (_, i) => item(`f${i}.jpg`, 1_000_000 + i)),
});
expect(overflow.status).toBe(400);
expect(overflow.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
it('未认证 → 401', async () => {
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/uploads/duplicates/check`)
.send({ albumId, items: [item('a.jpg', BYTES_A)] });
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
});
});