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