diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index 301932a..e6e39c9 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -909,6 +909,56 @@ paths: '500': $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: parameters: - $ref: '#/components/parameters/UploadId' @@ -3536,6 +3586,67 @@ components: items: $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: diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index 73c214a..6ad9491 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -364,6 +364,31 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -1732,6 +1757,43 @@ export interface components { UploadSessionList: components["schemas"]["CursorPageMeta"] & { 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: { /** * @description 目标可见性;仅允许 hidden 与 published 间切换。 @@ -3705,6 +3767,59 @@ export interface operations { 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: { parameters: { query?: never; diff --git a/services/api/src/uploads/dto.ts b/services/api/src/uploads/dto.ts index 7966aea..0a8301a 100644 --- a/services/api/src/uploads/dto.ts +++ b/services/api/src/uploads/dto.ts @@ -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[]; +} diff --git a/services/api/src/uploads/uploads.controller.ts b/services/api/src/uploads/uploads.controller.ts index ebea76d..65c01cc 100644 --- a/services/api/src/uploads/uploads.controller.ts +++ b/services/api/src/uploads/uploads.controller.ts @@ -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 { + return this.uploadsService.checkDuplicates(userOf(req), dto); + } + /** 续签:仅 pending 会话;返回结构同 initUpload(credential 为新值)。 */ @Post(':uploadId/renew') @HttpCode(HttpStatus.OK) diff --git a/services/api/src/uploads/uploads.service.ts b/services/api/src/uploads/uploads.service.ts index 5978ded..dc72c80 100644 --- a/services/api/src/uploads/uploads.service.ts +++ b/services/api/src/uploads/uploads.service.ts @@ -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 { + // ① 相册可见性:不可见(含不存在)统一 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(); + 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 }; + } } diff --git a/services/api/test/uploads.e2e-spec.ts b/services/api/test/uploads.e2e-spec.ts index 851a2ee..39757fb 100644 --- a/services/api/test/uploads.e2e-spec.ts +++ b/services/api/test/uploads.e2e-spec.ts @@ -948,3 +948,202 @@ describe('uploads renew & complete & history (e2e)', () => { 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 => { + 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); + }); +});