feat(uploads): P0-1 batch 4 — server-side duplicate photo detection (409 + force + index)

契约 UploadInitRequest 加 force(bool,仅 owner/admin 跳过查重)。
服务端 init 事务前置查重:同 albumId + uploaderId + bytes 的非 deleted
照片已存在 → 409 PHOTO_DUPLICATE。photos 表加 idx_photos_dedup 索引
(迁移 20260928120000)。
App UploadInitRequest 加 force 字段 + toJson 序列化。
本地查重(层 1)+ 服务端查重(层 2)双层防线到位。

API typecheck ✅ · app analyze ✅ · 397/397 ✅ · contract lint ✅

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-28 11:29:29 +08:00
parent 33f347cdea
commit 90dcc58c33
7 changed files with 74 additions and 18 deletions
+6 -1
View File
@@ -5,7 +5,7 @@
// 惰性读取,故在服务层校验(同 filename 扩展名白名单与 categoryId 属主校验);
// · clientTaskId/filename/albumId/categoryId 列宽对齐 db-cn schema
// (VarChar 128/255/64/64);sourceDeviceId 8-64(A19 设备来源语义)。
import {
import { IsBoolean,
IsIn,
IsInt,
IsNotEmpty,
@@ -55,4 +55,9 @@ export class UploadInitRequestDto {
@MinLength(8)
@MaxLength(64)
sourceDeviceId?: string;
/** P0-1 层 2:跳过服务端查重(仅 owner/admin 生效;协作者无效)。 */
@IsOptional()
@IsBoolean()
force?: boolean;
}
@@ -120,6 +120,27 @@ export class UploadsService {
if (!album.uploadEnabled) {
throw albumUploadDisabled();
}
// ③.5 P0-1 层 2:服务端查重——同相册+同上传者+同字节数的非 deleted 照片
// 已存在时 409(force=true 且 owner/admin 可跳过;协作者 force 无效)。
if (dto.force !== true || !(await this.permissions.canManageAlbum(user, dto.albumId))) {
const duplicate = await this.prisma.photos.findFirst({
where: {
albumId: dto.albumId,
uploaderId: user.id,
bytes: dto.bytes,
visibility: { not: 'deleted' },
},
select: { id: true, uploadedAt: true },
});
if (duplicate !== null) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'PHOTO_DUPLICATE',
HttpStatus.CONFLICT,
);
}
}
// ④ 大小限额(惰性读 env)与 filename 扩展名白名单(对象键后缀派生)。
const maxBytes = uploadMaxBytes();
if (dto.bytes > maxBytes) {