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
+115
View File
@@ -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;