feat(api): idempotent upload session init with presigned credential
POST /v1/uploads (Task 18, A03): @@unique(userId, clientTaskId) idempotency anchor — same params return the original session (201, no duplicate photo); param drift (album/bytes/contentType/extension) -> 409 UPLOAD_SESSION_CONFLICT. photos.objectKey is set to the FINAL originals key at init while the session holds the staging key; IDs come from an app-side cuid-like generator so both self-referencing keys are written in one transaction. Credential is a real MinIO presigned PUT (SIGNED_URL_TTL_SECONDS, lazily read). Permission order: album invisible 404 -> member 403 -> uploadEnabled 409 -> size/ext 400 -> category ownership 400. No audit on init (audit anchors on complete). Pre-step for Task 19: QUEUE_PHOTO_PROCESS contract constant + lazy-safe BullMQ queue producer (Redis down at boot must not crash the app; enqueue failure only logs — sweep compensates). e2e reset now also wipes the MinIO photo-live/dev prefix; jest maps @photo/storage to TS source. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
// packages/contracts/src/constants.ts
|
||||||
|
// 跨服务共享的运行时常量(Task 19 起)。队列名等标识符放契约包:api(生产者,
|
||||||
|
// complete 后入队)与 worker(Task 20/21 消费者)经同一常量引用,杜绝两处字符串漂移。
|
||||||
|
/** 照片处理 BullMQ 队列名(api enqueue → worker 消费;补偿扫描 Task 21)。 */
|
||||||
|
export const QUEUE_PHOTO_PROCESS = 'photo-process';
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// 契约统一出口:错误码 + OpenAPI 生成类型(src/generated/openapi.d.ts 提交入库,
|
// 契约统一出口:错误码 + OpenAPI 生成类型(src/generated/openapi.d.ts 提交入库,
|
||||||
// 消费方不跑 gen;本包改动后执行 pnpm gen 并提交生成文件 diff)。
|
// 消费方不跑 gen;本包改动后执行 pnpm gen 并提交生成文件 diff)。
|
||||||
// NodeNext + ESM:相对导入必须带 .js 扩展名;生成文件仅含类型,用 export type * 在运行时擦除。
|
// NodeNext + ESM:相对导入必须带 .js 扩展名;生成文件仅含类型,用 export type * 在运行时擦除。
|
||||||
|
export * from './constants.js';
|
||||||
export * from './errors.js';
|
export * from './errors.js';
|
||||||
export * from './events.js';
|
export * from './events.js';
|
||||||
export type * from './generated/openapi.js';
|
export type * from './generated/openapi.js';
|
||||||
|
|||||||
Generated
+189
@@ -115,12 +115,21 @@ importers:
|
|||||||
'@photo/db-cn':
|
'@photo/db-cn':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/db-cn
|
version: link:../../packages/db-cn
|
||||||
|
'@photo/storage':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../packages/storage
|
||||||
|
bullmq:
|
||||||
|
specifier: ^6.3.6
|
||||||
|
version: 6.3.6(ioredis@6.0.0)
|
||||||
class-transformer:
|
class-transformer:
|
||||||
specifier: ^0.5.1
|
specifier: ^0.5.1
|
||||||
version: 0.5.1
|
version: 0.5.1
|
||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.15.1
|
specifier: ^0.15.1
|
||||||
version: 0.15.1
|
version: 0.15.1
|
||||||
|
ioredis:
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0
|
||||||
nestjs-pino:
|
nestjs-pino:
|
||||||
specifier: ^4.6.1
|
specifier: ^4.6.1
|
||||||
version: 4.6.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2)
|
version: 4.6.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2)
|
||||||
@@ -137,6 +146,9 @@ importers:
|
|||||||
specifier: ^7.8.1
|
specifier: ^7.8.1
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@aws-sdk/client-s3':
|
||||||
|
specifier: ^3.700.0
|
||||||
|
version: 3.1134.0
|
||||||
'@nestjs/testing':
|
'@nestjs/testing':
|
||||||
specifier: ^10.4.6
|
specifier: ^10.4.6
|
||||||
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22)
|
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22)
|
||||||
@@ -497,6 +509,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
|
|
||||||
|
'@ioredis/commands@2.0.0':
|
||||||
|
resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==}
|
||||||
|
|
||||||
'@istanbuljs/load-nyc-config@1.1.0':
|
'@istanbuljs/load-nyc-config@1.1.0':
|
||||||
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
|
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -606,6 +621,36 @@ packages:
|
|||||||
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
|
||||||
|
resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4':
|
||||||
|
resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4':
|
||||||
|
resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4':
|
||||||
|
resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4':
|
||||||
|
resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4':
|
||||||
|
resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@nestjs/common@10.4.22':
|
'@nestjs/common@10.4.22':
|
||||||
resolution: {integrity: sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==}
|
resolution: {integrity: sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1248,6 +1293,24 @@ packages:
|
|||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
|
bullmq@6.3.6:
|
||||||
|
resolution: {integrity: sha512-+T6T6jeONZ5AvCj4xDkjuqdXI+/Kezvi/PvNS2YrIiO3HZNS4MgI+397ORyfO3Mx6jP127jowte6fNOxAfUN2w==}
|
||||||
|
engines: {node: '>=14.17.0'}
|
||||||
|
peerDependencies:
|
||||||
|
bullmq-otel: '>=2.0.0'
|
||||||
|
ioredis: '>=5.0.0'
|
||||||
|
pg: '>=8.0.0'
|
||||||
|
redis: '>=5.0.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bullmq-otel:
|
||||||
|
optional: true
|
||||||
|
ioredis:
|
||||||
|
optional: true
|
||||||
|
pg:
|
||||||
|
optional: true
|
||||||
|
redis:
|
||||||
|
optional: true
|
||||||
|
|
||||||
bundle-name@4.1.0:
|
bundle-name@4.1.0:
|
||||||
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1328,6 +1391,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.1:
|
||||||
|
resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
co@4.6.0:
|
co@4.6.0:
|
||||||
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
|
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
|
||||||
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
|
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
|
||||||
@@ -1405,6 +1472,10 @@ packages:
|
|||||||
create-require@1.1.1:
|
create-require@1.1.1:
|
||||||
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
||||||
|
|
||||||
|
cron-parser@5.10.1:
|
||||||
|
resolution: {integrity: sha512-pKRrRagItwk9rGIStcUyMxFX34x56zoX3KDf9HJ4ttwkXIK1qxK63EvXvEDmlxI9AdPJ+Y7TEKRftRlW5eSS7A==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -1463,6 +1534,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
|
denque@2.1.0:
|
||||||
|
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||||
|
engines: {node: '>=0.10'}
|
||||||
|
|
||||||
depd@2.0.0:
|
depd@2.0.0:
|
||||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -1471,6 +1546,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
|
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
|
||||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
detect-newline@3.1.0:
|
detect-newline@3.1.0:
|
||||||
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
|
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1869,6 +1948,10 @@ packages:
|
|||||||
inherits@2.0.4:
|
inherits@2.0.4:
|
||||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||||
|
|
||||||
|
ioredis@6.0.0:
|
||||||
|
resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
ipaddr.js@1.9.1:
|
ipaddr.js@1.9.1:
|
||||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@@ -2186,6 +2269,10 @@ packages:
|
|||||||
lunr@2.3.9:
|
lunr@2.3.9:
|
||||||
resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==}
|
resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==}
|
||||||
|
|
||||||
|
luxon@3.7.2:
|
||||||
|
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
make-dir@4.0.0:
|
make-dir@4.0.0:
|
||||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2301,6 +2388,13 @@ packages:
|
|||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
|
msgpackr-extract@3.0.4:
|
||||||
|
resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
msgpackr@2.1.0:
|
||||||
|
resolution: {integrity: sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==}
|
||||||
|
|
||||||
multer@2.0.2:
|
multer@2.0.2:
|
||||||
resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==}
|
resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==}
|
||||||
engines: {node: '>= 10.16.0'}
|
engines: {node: '>= 10.16.0'}
|
||||||
@@ -2324,6 +2418,9 @@ packages:
|
|||||||
pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||||
rxjs: ^7.1.0
|
rxjs: ^7.1.0
|
||||||
|
|
||||||
|
node-abort-controller@3.1.1:
|
||||||
|
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
||||||
|
|
||||||
node-fetch-h2@2.3.0:
|
node-fetch-h2@2.3.0:
|
||||||
resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==}
|
resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==}
|
||||||
engines: {node: 4.x || >=6.0.0}
|
engines: {node: 4.x || >=6.0.0}
|
||||||
@@ -2337,6 +2434,10 @@ packages:
|
|||||||
encoding:
|
encoding:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
node-gyp-build-optional-packages@5.2.2:
|
||||||
|
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
node-int64@0.4.0:
|
node-int64@0.4.0:
|
||||||
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
|
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
|
||||||
|
|
||||||
@@ -2627,6 +2728,10 @@ packages:
|
|||||||
real-require@1.0.0:
|
real-require@1.0.0:
|
||||||
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
|
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
|
||||||
|
|
||||||
|
redis-errors@1.2.0:
|
||||||
|
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
redoc@2.5.3:
|
redoc@2.5.3:
|
||||||
resolution: {integrity: sha512-bBbat+Sx6xKWdyoCGTtA0BWeTEW9Vs4VnEja7q7ZLOk4IM7cHQLrf+kDxWF6dKeKxT8kOBnoy/OsNXCeLttpyQ==}
|
resolution: {integrity: sha512-bBbat+Sx6xKWdyoCGTtA0BWeTEW9Vs4VnEja7q7ZLOk4IM7cHQLrf+kDxWF6dKeKxT8kOBnoy/OsNXCeLttpyQ==}
|
||||||
engines: {node: '>=6.9', npm: '>=3.0.0'}
|
engines: {node: '>=6.9', npm: '>=3.0.0'}
|
||||||
@@ -2800,6 +2905,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
|
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
standard-as-callback@2.1.0:
|
||||||
|
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||||
|
|
||||||
statuses@2.0.2:
|
statuses@2.0.2:
|
||||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -3614,6 +3722,8 @@ snapshots:
|
|||||||
|
|
||||||
'@humanwhocodes/retry@0.4.3': {}
|
'@humanwhocodes/retry@0.4.3': {}
|
||||||
|
|
||||||
|
'@ioredis/commands@2.0.0': {}
|
||||||
|
|
||||||
'@istanbuljs/load-nyc-config@1.1.0':
|
'@istanbuljs/load-nyc-config@1.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
camelcase: 5.3.1
|
camelcase: 5.3.1
|
||||||
@@ -3820,6 +3930,24 @@ snapshots:
|
|||||||
|
|
||||||
'@lukeed/csprng@1.1.0': {}
|
'@lukeed/csprng@1.1.0': {}
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
'@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
file-type: 20.4.1
|
file-type: 20.4.1
|
||||||
@@ -4578,6 +4706,16 @@ snapshots:
|
|||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
|
bullmq@6.3.6(ioredis@6.0.0):
|
||||||
|
dependencies:
|
||||||
|
cron-parser: 5.10.1
|
||||||
|
msgpackr: 2.1.0
|
||||||
|
node-abort-controller: 3.1.1
|
||||||
|
semver: 7.8.5
|
||||||
|
tslib: 2.8.1
|
||||||
|
optionalDependencies:
|
||||||
|
ioredis: 6.0.0
|
||||||
|
|
||||||
bundle-name@4.1.0:
|
bundle-name@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
run-applescript: 7.1.0
|
run-applescript: 7.1.0
|
||||||
@@ -4657,6 +4795,8 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.1: {}
|
||||||
|
|
||||||
co@4.6.0: {}
|
co@4.6.0: {}
|
||||||
|
|
||||||
collect-v8-coverage@1.0.3: {}
|
collect-v8-coverage@1.0.3: {}
|
||||||
@@ -4728,6 +4868,10 @@ snapshots:
|
|||||||
|
|
||||||
create-require@1.1.1: {}
|
create-require@1.1.1: {}
|
||||||
|
|
||||||
|
cron-parser@5.10.1:
|
||||||
|
dependencies:
|
||||||
|
luxon: 3.7.2
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -4765,10 +4909,15 @@ snapshots:
|
|||||||
|
|
||||||
delayed-stream@1.0.0: {}
|
delayed-stream@1.0.0: {}
|
||||||
|
|
||||||
|
denque@2.1.0: {}
|
||||||
|
|
||||||
depd@2.0.0: {}
|
depd@2.0.0: {}
|
||||||
|
|
||||||
destroy@1.2.0: {}
|
destroy@1.2.0: {}
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
detect-newline@3.1.0: {}
|
detect-newline@3.1.0: {}
|
||||||
|
|
||||||
dezalgo@1.0.4:
|
dezalgo@1.0.4:
|
||||||
@@ -5207,6 +5356,17 @@ snapshots:
|
|||||||
|
|
||||||
inherits@2.0.4: {}
|
inherits@2.0.4: {}
|
||||||
|
|
||||||
|
ioredis@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@ioredis/commands': 2.0.0
|
||||||
|
cluster-key-slot: 1.1.1
|
||||||
|
debug: 4.4.3(supports-color@10.2.2)
|
||||||
|
denque: 2.1.0
|
||||||
|
redis-errors: 1.2.0
|
||||||
|
standard-as-callback: 2.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
ipaddr.js@1.9.1: {}
|
ipaddr.js@1.9.1: {}
|
||||||
|
|
||||||
is-arrayish@0.2.1: {}
|
is-arrayish@0.2.1: {}
|
||||||
@@ -5679,6 +5839,8 @@ snapshots:
|
|||||||
|
|
||||||
lunr@2.3.9: {}
|
lunr@2.3.9: {}
|
||||||
|
|
||||||
|
luxon@3.7.2: {}
|
||||||
|
|
||||||
make-dir@4.0.0:
|
make-dir@4.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.8.5
|
semver: 7.8.5
|
||||||
@@ -5760,6 +5922,22 @@ snapshots:
|
|||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
|
msgpackr-extract@3.0.4:
|
||||||
|
dependencies:
|
||||||
|
node-gyp-build-optional-packages: 5.2.2
|
||||||
|
optionalDependencies:
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4
|
||||||
|
'@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4
|
||||||
|
'@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4
|
||||||
|
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
msgpackr@2.1.0:
|
||||||
|
optionalDependencies:
|
||||||
|
msgpackr-extract: 3.0.4
|
||||||
|
|
||||||
multer@2.0.2:
|
multer@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
append-field: 1.0.0
|
append-field: 1.0.0
|
||||||
@@ -5783,6 +5961,8 @@ snapshots:
|
|||||||
pino-http: 11.0.0
|
pino-http: 11.0.0
|
||||||
rxjs: 7.8.2
|
rxjs: 7.8.2
|
||||||
|
|
||||||
|
node-abort-controller@3.1.1: {}
|
||||||
|
|
||||||
node-fetch-h2@2.3.0:
|
node-fetch-h2@2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
http2-client: 1.3.5
|
http2-client: 1.3.5
|
||||||
@@ -5791,6 +5971,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
whatwg-url: 5.0.0
|
whatwg-url: 5.0.0
|
||||||
|
|
||||||
|
node-gyp-build-optional-packages@5.2.2:
|
||||||
|
dependencies:
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
node-int64@0.4.0: {}
|
node-int64@0.4.0: {}
|
||||||
|
|
||||||
node-readfiles@0.2.0:
|
node-readfiles@0.2.0:
|
||||||
@@ -6091,6 +6276,8 @@ snapshots:
|
|||||||
|
|
||||||
real-require@1.0.0: {}
|
real-require@1.0.0: {}
|
||||||
|
|
||||||
|
redis-errors@1.2.0: {}
|
||||||
|
|
||||||
redoc@2.5.3(core-js@3.32.1)(mobx@6.12.3)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(styled-components@6.5.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0)):
|
redoc@2.5.3(core-js@3.32.1)(mobx@6.12.3)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(styled-components@6.5.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@redocly/openapi-core': 1.34.20(supports-color@10.2.2)
|
'@redocly/openapi-core': 1.34.20(supports-color@10.2.2)
|
||||||
@@ -6299,6 +6486,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
escape-string-regexp: 2.0.0
|
escape-string-regexp: 2.0.0
|
||||||
|
|
||||||
|
standard-as-callback@2.1.0: {}
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
|
|
||||||
stickyfill@1.1.1: {}
|
stickyfill@1.1.1: {}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ const config: Config = {
|
|||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^@photo/contracts$': '<rootDir>/../../packages/contracts/src/index.ts',
|
'^@photo/contracts$': '<rootDir>/../../packages/contracts/src/index.ts',
|
||||||
'^@photo/db-cn$': '<rootDir>/../../packages/db-cn/src/index.ts',
|
'^@photo/db-cn$': '<rootDir>/../../packages/db-cn/src/index.ts',
|
||||||
|
// Task 18 起:@photo/storage 同样映射到 TS 源码(其依赖 @aws-sdk/* 与
|
||||||
|
// @photo/contracts 分别从 storage 包 node_modules / 上面的映射解析)。
|
||||||
|
'^@photo/storage$': '<rootDir>/../../packages/storage/src/index.ts',
|
||||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||||
},
|
},
|
||||||
// test/ 下的 *.spec.ts 为直连真实 DB 的集成型单测(seed.spec.ts、
|
// test/ 下的 *.spec.ts 为直连真实 DB 的集成型单测(seed.spec.ts、
|
||||||
|
|||||||
@@ -20,8 +20,11 @@
|
|||||||
"@node-rs/argon2": "^2.2.1",
|
"@node-rs/argon2": "^2.2.1",
|
||||||
"@photo/contracts": "workspace:*",
|
"@photo/contracts": "workspace:*",
|
||||||
"@photo/db-cn": "workspace:*",
|
"@photo/db-cn": "workspace:*",
|
||||||
|
"@photo/storage": "workspace:^",
|
||||||
|
"bullmq": "^6.3.6",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
"nestjs-pino": "^4.6.1",
|
"nestjs-pino": "^4.6.1",
|
||||||
"pino": "^10.0.0",
|
"pino": "^10.0.0",
|
||||||
"pino-http": "^11.0.0",
|
"pino-http": "^11.0.0",
|
||||||
@@ -29,6 +32,7 @@
|
|||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.700.0",
|
||||||
"@nestjs/testing": "^10.4.6",
|
"@nestjs/testing": "^10.4.6",
|
||||||
"@types/jest": "^29.5.13",
|
"@types/jest": "^29.5.13",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { AuthModule } from './auth/auth.module';
|
|||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware';
|
import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware';
|
||||||
|
import { UploadsModule } from './uploads/uploads.module';
|
||||||
import { resolveLogLevel } from './shared/log-level';
|
import { resolveLogLevel } from './shared/log-level';
|
||||||
import type { ServerRequestWithId } from './shared/request-with-id';
|
import type { ServerRequestWithId } from './shared/request-with-id';
|
||||||
|
|
||||||
@@ -31,6 +32,8 @@ import type { ServerRequestWithId } from './shared/request-with-id';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
// 相册域(Task 14 起步:PermissionsService;控制器自 Task 15 加入)。
|
// 相册域(Task 14 起步:PermissionsService;控制器自 Task 15 加入)。
|
||||||
AlbumsModule,
|
AlbumsModule,
|
||||||
|
// 上传域(Task 18 init;Task 19 renew/complete/history)。
|
||||||
|
UploadsModule,
|
||||||
LoggerModule.forRoot({
|
LoggerModule.forRoot({
|
||||||
pinoHttp: {
|
pinoHttp: {
|
||||||
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
|
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// services/api/src/uploads/dto.ts —— 上传端点入参 DTO(Task 18)。校验约束对齐
|
||||||
|
// 契约 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)。
|
||||||
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { UPLOAD_CONTENT_TYPES, type UploadContentType } from './support';
|
||||||
|
|
||||||
|
export class UploadInitRequestDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(64)
|
||||||
|
albumId!: string;
|
||||||
|
|
||||||
|
/** 缺省为相册默认分类(P1a 无默认分类概念,落 null)。 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(64)
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
/** 客户端任务幂等键(A03:同任务重试复用会话与照片 ID)。 */
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(128)
|
||||||
|
clientTaskId!: string;
|
||||||
|
|
||||||
|
/** 原始文件名,仅元数据;扩展名白名单校验在服务层(对象键后缀派生)。 */
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
|
filename!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
bytes!: number;
|
||||||
|
|
||||||
|
@IsIn(UPLOAD_CONTENT_TYPES)
|
||||||
|
contentType!: UploadContentType;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// services/api/src/uploads/queue.provider.ts —— photo-process 队列生产者(Task 19
|
||||||
|
// 入队;队列名常量 QUEUE_PHOTO_PROCESS 由 @photo/contracts 导出,worker(Task 20/21)
|
||||||
|
// 消费同一队列)。lazy-safe 约定:
|
||||||
|
// · Queue 实例(含 ioredis 连接)在首次 enqueue 时才创建——Redis 不可用时
|
||||||
|
// 应用启动/请求管线均不受影响(BullMQ 连接仅在入队时使用);
|
||||||
|
// · 入队失败(连接拒绝/超时等)只记日志不抛错:complete 事务已提交,
|
||||||
|
// photo_jobs(pending) 行是持久事实源,Task 21 的补偿扫描会认领孤儿任务
|
||||||
|
// (总纲 §5.6「complete 后入队失败」的补偿路径);
|
||||||
|
// · ioredis 的 'error' 事件挂 no-op 监听:重连风暴期间未监听的 error 事件
|
||||||
|
// 会让进程崩溃,这里吞掉并交给 enqueue 的 catch 记日志。
|
||||||
|
import { Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import IORedis from 'ioredis';
|
||||||
|
import { QUEUE_PHOTO_PROCESS } from '@photo/contracts';
|
||||||
|
|
||||||
|
/** DI token。 */
|
||||||
|
export const PHOTO_PROCESS_QUEUE = Symbol('PHOTO_PROCESS_QUEUE');
|
||||||
|
|
||||||
|
/** 生产者窄接口:服务只依赖 enqueue,便于测试替身。 */
|
||||||
|
export interface PhotoProcessQueueProducer {
|
||||||
|
enqueuePhotoProcess(photoId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BullMQ job 名称(与 photo_jobs.type 同值)。 */
|
||||||
|
const PHOTO_PROCESS_JOB_NAME = 'process';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BullMqPhotoProcessQueue
|
||||||
|
implements PhotoProcessQueueProducer, OnModuleDestroy
|
||||||
|
{
|
||||||
|
private readonly logger = new Logger(BullMqPhotoProcessQueue.name);
|
||||||
|
private queue: Queue | null = null;
|
||||||
|
|
||||||
|
/** 首次使用时构造;连接错误经 no-op 监听吞掉(见文件头注释)。 */
|
||||||
|
private getQueue(): Queue {
|
||||||
|
if (this.queue === null) {
|
||||||
|
const url = process.env.REDIS_URL_CN ?? 'redis://localhost:6379';
|
||||||
|
const connection = new IORedis(url, {
|
||||||
|
// BullMQ 对阻塞命令的要求:禁用 per-request 重试上限(等待 Worker 语义)。
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
});
|
||||||
|
connection.on('error', (err: Error) => {
|
||||||
|
this.logger.warn(
|
||||||
|
`photo-process 队列连接异常(将由 ioredis 自动重连): ${err.message}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this.queue = new Queue(QUEUE_PHOTO_PROCESS, { connection });
|
||||||
|
}
|
||||||
|
return this.queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async enqueuePhotoProcess(photoId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.getQueue().add(
|
||||||
|
PHOTO_PROCESS_JOB_NAME,
|
||||||
|
{ photoId },
|
||||||
|
{
|
||||||
|
// jobId 以 photoId 派生:并发 complete/补偿扫描重复入队天然去重(A03)。
|
||||||
|
jobId: `${PHOTO_PROCESS_JOB_NAME}:${photoId}`,
|
||||||
|
// Task 21 消费侧重试策略:3 次指数退避。
|
||||||
|
attempts: 3,
|
||||||
|
backoff: { type: 'exponential', delay: 1000 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// 不抛错:photo_jobs(pending) 已是持久事实源,Task 21 sweep 补偿。
|
||||||
|
this.logger.error(
|
||||||
|
`photo-process 入队失败(sweep 将补偿)photoId=${photoId}: ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
if (this.queue !== null) {
|
||||||
|
await this.queue.close().catch(() => undefined);
|
||||||
|
this.queue = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const photoProcessQueueProvider = {
|
||||||
|
provide: PHOTO_PROCESS_QUEUE,
|
||||||
|
useClass: BullMqPhotoProcessQueue,
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// services/api/src/uploads/storage.provider.ts —— StoragePort 的 DI 装配(Task 18)。
|
||||||
|
// api 只依赖存储端口抽象(packages/storage),测试/本地指向 MinIO、生产指向 OSS,
|
||||||
|
// 由 createStorageFromEnv() 依 S3_* 环境变量构造。构造期无网络请求(S3Client
|
||||||
|
// 懒连接),缺 env 时快速失败(storage 包哲学:禁止静默回退默认值)。
|
||||||
|
import { createStorageFromEnv, type StoragePort } from '@photo/storage';
|
||||||
|
|
||||||
|
/** DI token:photos/public 等后续模块经模块导出复用同一实例。 */
|
||||||
|
export const STORAGE_PORT = Symbol('STORAGE_PORT');
|
||||||
|
|
||||||
|
export const storageProvider = {
|
||||||
|
provide: STORAGE_PORT,
|
||||||
|
useFactory: (): StoragePort => createStorageFromEnv(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// services/api/src/uploads/support.ts —— 上传域共用小工具(Task 18 起)。
|
||||||
|
// 对象键布局(契约 UploadInitResponse.objectKey / docs/03 §4 对象键规范):
|
||||||
|
// staging: photo-live/{env}/staging/{uploadId}/{photoId}.{ext}
|
||||||
|
// originals: photo-live/{env}/albums/{albumId}/originals/{photoId}.{ext}
|
||||||
|
// P1a 单环境(env=dev)硬编码前缀;多环境前缀化属 P5 部署事项。
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { HttpStatus } from '@nestjs/common';
|
||||||
|
import { ErrorCodes } from '@photo/contracts';
|
||||||
|
import { ApiException } from '../api-exception';
|
||||||
|
import { albumNotFound } from '../albums/support';
|
||||||
|
|
||||||
|
/** 对象键根(与 test/reset.ts 的 MinIO 清理前缀一致)。 */
|
||||||
|
export const OBJECT_KEY_ROOT = 'photo-live/dev';
|
||||||
|
|
||||||
|
/** UPLOAD_MAX_BYTES 缺省 50MB(与 .env.example 同步)。 */
|
||||||
|
export const DEFAULT_UPLOAD_MAX_BYTES = 52_428_800;
|
||||||
|
/** SIGNED_URL_TTL_SECONDS 缺省 15 分钟(与 .env.example 同步)。 */
|
||||||
|
export const DEFAULT_SIGNED_URL_TTL_SECONDS = 900;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逐请求惰性读取 UPLOAD_MAX_BYTES:配置不驻内存,测试可在进程内改 env 验证
|
||||||
|
* 边界(无需重启应用);运行时由根 dotenv-cli 注入。
|
||||||
|
*/
|
||||||
|
export function uploadMaxBytes(): number {
|
||||||
|
const parsed = Number.parseInt(process.env.UPLOAD_MAX_BYTES ?? '', 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_UPLOAD_MAX_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同上:凭证/会话 TTL 惰性读取(秒)。 */
|
||||||
|
export function signedUrlTtlSeconds(): number {
|
||||||
|
const parsed = Number.parseInt(process.env.SIGNED_URL_TTL_SECONDS ?? '', 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0
|
||||||
|
? parsed
|
||||||
|
: DEFAULT_SIGNED_URL_TTL_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 契约 contentType 白名单(image/heic 为暂定项,解码能力待 G2 真机验证)。 */
|
||||||
|
export const UPLOAD_CONTENT_TYPES = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/heic',
|
||||||
|
] as const;
|
||||||
|
export type UploadContentType = (typeof UPLOAD_CONTENT_TYPES)[number];
|
||||||
|
|
||||||
|
/** filename 扩展名白名单(jpeg 归一为 jpg,避免同格式双扩展名键漂移)。 */
|
||||||
|
const EXTENSION_WHITELIST: Readonly<Record<string, string>> = Object.freeze({
|
||||||
|
jpg: 'jpg',
|
||||||
|
jpeg: 'jpg',
|
||||||
|
png: 'png',
|
||||||
|
heic: 'heic',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从原始文件名取归一化扩展名;不在白名单返回 null(服务层 400)。
|
||||||
|
* filename 仅元数据(契约语义),扩展名唯一的结构化用途是对象键后缀。
|
||||||
|
*/
|
||||||
|
export function extOfFilename(filename: string): string | null {
|
||||||
|
const dot = filename.lastIndexOf('.');
|
||||||
|
if (dot < 0 || dot === filename.length - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return EXTENSION_WHITELIST[filename.slice(dot + 1).toLowerCase()] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** staging 对象键(会话 objectKey;uploadId 前置使会话与键一一对应)。 */
|
||||||
|
export function stagingObjectKey(
|
||||||
|
uploadId: string,
|
||||||
|
photoId: string,
|
||||||
|
ext: string,
|
||||||
|
): string {
|
||||||
|
return `${OBJECT_KEY_ROOT}/staging/${uploadId}/${photoId}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** originals 正式键(photos.objectKey,init 即写入,complete 受控复制目标)。 */
|
||||||
|
export function originalsObjectKey(
|
||||||
|
albumId: string,
|
||||||
|
photoId: string,
|
||||||
|
ext: string,
|
||||||
|
): string {
|
||||||
|
return `${OBJECT_KEY_ROOT}/albums/${albumId}/originals/${photoId}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取对象键尾缀扩展名(幂等冲突比对用:filename 未持久化,以扩展名近似锚定)。 */
|
||||||
|
export function extOfObjectKey(objectKey: string): string | null {
|
||||||
|
const dot = objectKey.lastIndexOf('.');
|
||||||
|
if (dot < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return objectKey.slice(dot + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cuid 形状的应用侧 ID 生成('c'+base36 时间戳+随机,25 字符):对象键包含
|
||||||
|
* 自引用键段(staging 键含 uploadId、photos.objectKey 含 photoId),须先有 ID
|
||||||
|
* 才能一次 insert 成形(避免「先建行再回填键」的双写)。随机段 36^16≈8e24,
|
||||||
|
* 碰撞概率可忽略;真撞 @id 唯一索引按 5xx 兜底(同 publicToken 先例不做重试)。
|
||||||
|
*/
|
||||||
|
export function cuidLikeId(): string {
|
||||||
|
let id = `c${Date.now().toString(36)}`;
|
||||||
|
const bytes = randomBytes(16);
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
id += CUID_ALPHABET[bytes[i] % CUID_ALPHABET.length];
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 统一错误出口 ──
|
||||||
|
|
||||||
|
export { albumNotFound };
|
||||||
|
|
||||||
|
/** 409:相册已关闭上传(相册级运营状态,owner 亦受限)。 */
|
||||||
|
export function albumUploadDisabled(): ApiException {
|
||||||
|
return new ApiException(
|
||||||
|
ErrorCodes.ALBUM_UPLOAD_DISABLED,
|
||||||
|
'相册已关闭上传',
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 409:同 clientTaskId 已存在且参数不一致。 */
|
||||||
|
export function uploadSessionConflict(message: string): ApiException {
|
||||||
|
return new ApiException(
|
||||||
|
ErrorCodes.UPLOAD_SESSION_CONFLICT,
|
||||||
|
message,
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 400:完成核验失败(对象缺失/字节数不符/完整性信息不可用)。 */
|
||||||
|
export function uploadVerifyFailed(message: string): ApiException {
|
||||||
|
return new ApiException(
|
||||||
|
ErrorCodes.UPLOAD_VERIFY_FAILED,
|
||||||
|
message,
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 403:无权操作该会话或会话不存在(不区分,防 uploadId 枚举;契约语义)。 */
|
||||||
|
export function uploadSessionForbidden(): ApiException {
|
||||||
|
return new ApiException(
|
||||||
|
ErrorCodes.AUTH_FORBIDDEN,
|
||||||
|
'无权操作该上传会话',
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// services/api/src/uploads/uploads.controller.ts —— /uploads 端点(Task 18 起)。
|
||||||
|
// 方法名与契约 operationId 一一对应(initUpload;Task 19 增补 renewUploadCredential/
|
||||||
|
// completeUpload/listUploads)。控制器级 SessionGuard:端点全需认证。职责:
|
||||||
|
// 透传认证用户 + 查询参数规整(parseLimit/parseCursor 复用相册控制器导出)。
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { userOf } from '../albums/albums.controller';
|
||||||
|
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
||||||
|
import { UploadsService } from './uploads.service';
|
||||||
|
import type { UploadInitResponse } from './uploads.service';
|
||||||
|
import { UploadInitRequestDto } from './dto';
|
||||||
|
|
||||||
|
@Controller('uploads')
|
||||||
|
@UseGuards(SessionGuard)
|
||||||
|
export class UploadsController {
|
||||||
|
constructor(private readonly uploadsService: UploadsService) {}
|
||||||
|
|
||||||
|
/** 契约 201(幂等重复调用返回原会话,同 201)。 */
|
||||||
|
@Post()
|
||||||
|
initUpload(
|
||||||
|
@Body() dto: UploadInitRequestDto,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
): Promise<UploadInitResponse> {
|
||||||
|
return this.uploadsService.init(userOf(req), dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// services/api/src/uploads/uploads.module.ts —— 上传模块(Task 18 起步,Task 19
|
||||||
|
// 增补 renew/complete/history)。装配:PermissionsService(经 AlbumsModule 导入
|
||||||
|
// 复用 A02 逐次权限判定)、StoragePort(packages/storage env 工厂)、
|
||||||
|
// photo-process 队列生产者(lazy-safe,见 queue.provider.ts)。
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AlbumsModule } from '../albums/albums.module';
|
||||||
|
import { UploadsController } from './uploads.controller';
|
||||||
|
import { UploadsService } from './uploads.service';
|
||||||
|
import { storageProvider, STORAGE_PORT } from './storage.provider';
|
||||||
|
import {
|
||||||
|
photoProcessQueueProvider,
|
||||||
|
PHOTO_PROCESS_QUEUE,
|
||||||
|
} from './queue.provider';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AlbumsModule],
|
||||||
|
controllers: [UploadsController],
|
||||||
|
providers: [UploadsService, storageProvider, photoProcessQueueProvider],
|
||||||
|
exports: [STORAGE_PORT, PHOTO_PROCESS_QUEUE],
|
||||||
|
})
|
||||||
|
export class UploadsModule {}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// services/api/src/uploads/uploads.service.ts —— 上传域核心逻辑(Task 18 起)。
|
||||||
|
// 契约权威:packages/contracts/openapi.yaml tags [uploads]。要点:
|
||||||
|
// · initUpload 幂等初始化(A03):@@unique([userId, clientTaskId]) 为幂等锚,
|
||||||
|
// 同键同参返回原会话(201,不重复建照片);同键异参 409 UPLOAD_SESSION_CONFLICT。
|
||||||
|
// 并发同键插入由 P2002 捕获后回读既有会话裁决(MySQL 约束冲突不中止事务)。
|
||||||
|
// · 双键分离:photos.objectKey 在 init 即写为最终 originals 键,
|
||||||
|
// upload_sessions.objectKey 为 staging 键——杜绝「一个键两种含义」的二义。
|
||||||
|
// 键段含自引用 ID(staging 含 uploadId、originals 含 photoId),ID 由应用侧
|
||||||
|
// cuid 形状生成器产出(见 support.cuidLikeId),保证事务内一次 insert 成形。
|
||||||
|
// · 校验顺序(契约错误优先级):相册不可见 404 → 非成员 403(P1a 不可达,
|
||||||
|
// canView ⊆ canUpload,为角色扩展预留)→ uploadEnabled 409 → bytes/扩展名
|
||||||
|
// 400 → categoryId 属主 400 → 幂等裁决。
|
||||||
|
// · init 不落 audit(高频低风险;审计锚点在 complete,A06 前置约定)。
|
||||||
|
// · UPLOAD_MAX_BYTES/SIGNED_URL_TTL_SECONDS 逐请求惰性读取(support),
|
||||||
|
// presign 在事务外执行(SigV4 为本地计算,无网络副作用)。
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { components } from '@photo/contracts';
|
||||||
|
import type { StoragePort } from '@photo/storage';
|
||||||
|
import type { AuthUser } from '../auth/session.guard';
|
||||||
|
import { PermissionsService } from '../albums/permissions.service';
|
||||||
|
import {
|
||||||
|
albumForbidden,
|
||||||
|
albumNotFound,
|
||||||
|
isUniqueViolationOn,
|
||||||
|
validationFailed,
|
||||||
|
} from '../albums/support';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { STORAGE_PORT } from './storage.provider';
|
||||||
|
import type { UploadInitRequestDto } from './dto';
|
||||||
|
import {
|
||||||
|
albumUploadDisabled,
|
||||||
|
cuidLikeId,
|
||||||
|
extOfFilename,
|
||||||
|
extOfObjectKey,
|
||||||
|
originalsObjectKey,
|
||||||
|
signedUrlTtlSeconds,
|
||||||
|
stagingObjectKey,
|
||||||
|
uploadMaxBytes,
|
||||||
|
uploadSessionConflict,
|
||||||
|
} from './support';
|
||||||
|
|
||||||
|
export type UploadInitResponse =
|
||||||
|
components['schemas']['UploadInitResponse'];
|
||||||
|
|
||||||
|
type SessionRow = {
|
||||||
|
id: string;
|
||||||
|
photoId: string;
|
||||||
|
objectKey: string;
|
||||||
|
expectedBytes: number;
|
||||||
|
contentType: string;
|
||||||
|
albumId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UploadsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等初始化:权限/开关/参数校验 → 事务(幂等裁决 + 双行创建)→ presign。
|
||||||
|
* 返回的 credential 每次调用现签(幂等重发也拿新凭证,旧凭证不受影响)。
|
||||||
|
*/
|
||||||
|
async init(user: AuthUser, dto: UploadInitRequestDto): Promise<UploadInitResponse> {
|
||||||
|
// ① 相册可见性:不可见(含不存在)统一 404,防 id 枚举(同相册域语义)。
|
||||||
|
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)+ 上传开关。
|
||||||
|
const album = await this.prisma.albums.findUnique({
|
||||||
|
where: { id: dto.albumId },
|
||||||
|
select: { uploadEnabled: true },
|
||||||
|
});
|
||||||
|
if (album === null) {
|
||||||
|
throw albumNotFound();
|
||||||
|
}
|
||||||
|
if (!album.uploadEnabled) {
|
||||||
|
throw albumUploadDisabled();
|
||||||
|
}
|
||||||
|
// ④ 大小限额(惰性读 env)与 filename 扩展名白名单(对象键后缀派生)。
|
||||||
|
const maxBytes = uploadMaxBytes();
|
||||||
|
if (dto.bytes > maxBytes) {
|
||||||
|
throw validationFailed(`文件超过大小限额(limit=${maxBytes})`);
|
||||||
|
}
|
||||||
|
const ext = extOfFilename(dto.filename);
|
||||||
|
if (ext === null) {
|
||||||
|
throw validationFailed(
|
||||||
|
'filename 扩展名不在白名单(jpg/jpeg/png/heic)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// ⑤ categoryId 属主:须存在且属于目标相册(跨册分类不可挂入)。
|
||||||
|
if (dto.categoryId !== undefined) {
|
||||||
|
const category = await this.prisma.categories.findUnique({
|
||||||
|
where: { id: dto.categoryId },
|
||||||
|
select: { albumId: true },
|
||||||
|
});
|
||||||
|
if (category === null || category.albumId !== dto.albumId) {
|
||||||
|
throw validationFailed('categoryId 不属于该相册');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑥ 事务:幂等裁决(锚 @@unique([userId, clientTaskId]))+ 双行创建。
|
||||||
|
const session = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const existing = await tx.upload_sessions.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_clientTaskId: { userId: user.id, clientTaskId: dto.clientTaskId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing !== null) {
|
||||||
|
if (this.paramsMatch(existing, dto, ext)) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
throw uploadSessionConflict(
|
||||||
|
'clientTaskId 已存在且参数不一致(albumId/bytes/contentType/扩展名)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const photoId = cuidLikeId();
|
||||||
|
const uploadId = cuidLikeId();
|
||||||
|
await tx.photos.create({
|
||||||
|
data: {
|
||||||
|
id: photoId,
|
||||||
|
albumId: dto.albumId,
|
||||||
|
uploaderId: user.id,
|
||||||
|
categoryId: dto.categoryId ?? null,
|
||||||
|
objectKey: originalsObjectKey(dto.albumId, photoId, ext),
|
||||||
|
// bytes=0/processingState=pending_upload/visibility=unpublished 走默认。
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const created = await tx.upload_sessions.create({
|
||||||
|
data: {
|
||||||
|
id: uploadId,
|
||||||
|
userId: user.id,
|
||||||
|
albumId: dto.albumId,
|
||||||
|
clientTaskId: dto.clientTaskId,
|
||||||
|
photoId,
|
||||||
|
objectKey: stagingObjectKey(uploadId, photoId, ext),
|
||||||
|
expectedBytes: dto.bytes,
|
||||||
|
contentType: dto.contentType,
|
||||||
|
expiresAt: new Date(Date.now() + signedUrlTtlSeconds() * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return created;
|
||||||
|
} catch (err) {
|
||||||
|
// 并发同键:唯一索引竞争的败方回读既有会话再做幂等裁决(MySQL 约束
|
||||||
|
// 冲突不中止事务,可继续查询)。
|
||||||
|
if (isUniqueViolationOn(err, 'userId_clientTaskId')) {
|
||||||
|
const winner = await tx.upload_sessions.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_clientTaskId: {
|
||||||
|
userId: user.id,
|
||||||
|
clientTaskId: dto.clientTaskId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (winner !== null && this.paramsMatch(winner, dto, ext)) {
|
||||||
|
return winner;
|
||||||
|
}
|
||||||
|
throw uploadSessionConflict(
|
||||||
|
'clientTaskId 已存在且参数不一致(albumId/bytes/contentType/扩展名)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ⑦ presign(SigV4 本地计算,无网络副作用):幂等返回亦现签新凭证。
|
||||||
|
const credential = await this.storage.presignPut(
|
||||||
|
session.objectKey,
|
||||||
|
session.expectedBytes,
|
||||||
|
session.contentType,
|
||||||
|
signedUrlTtlSeconds(),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
uploadId: session.id,
|
||||||
|
photoId: session.photoId,
|
||||||
|
objectKey: session.objectKey,
|
||||||
|
credential,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等参数一致性:对「会话行可持久化的事实」逐项比对——albumId/expectedBytes/
|
||||||
|
* contentType,外加 staging 键扩展名 vs 新 filename 扩展名(filename 本身不落库,
|
||||||
|
* schema 无该列;同扩展名改名视为同一任务意图,返回原会话)。
|
||||||
|
*/
|
||||||
|
private paramsMatch(
|
||||||
|
session: SessionRow,
|
||||||
|
dto: UploadInitRequestDto,
|
||||||
|
ext: string,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
session.albumId === dto.albumId &&
|
||||||
|
session.expectedBytes === dto.bytes &&
|
||||||
|
session.contentType === dto.contentType &&
|
||||||
|
extOfObjectKey(session.objectKey) === ext
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,14 @@
|
|||||||
// 表序仍按 FK 安全顺序排列(子表在前),关检查失败时也不至于中途卡死。
|
// 表序仍按 FK 安全顺序排列(子表在前),关检查失败时也不至于中途卡死。
|
||||||
// 顺序 await 在 Prisma 连接池上复用同一空闲连接,会话级 FOREIGN_KEY_CHECKS
|
// 顺序 await 在 Prisma 连接池上复用同一空闲连接,会话级 FOREIGN_KEY_CHECKS
|
||||||
// 对后续 TRUNCATE 生效;finally 恢复检查并断开,失败也不泄漏连接。
|
// 对后续 TRUNCATE 生效;finally 恢复检查并断开,失败也不泄漏连接。
|
||||||
// TODO(Task 11): 增加 MinIO `photo-live/dev` 前缀对象清理(同一函数内追加)。
|
// Task 18 起 e2e 会向 MinIO 写入真实对象(staging/originals):同一函数内追加
|
||||||
|
// `photo-live/dev` 前缀对象清理(reset.ts 文件头 TODO 的落实——只清本前缀,
|
||||||
|
// packages/storage 的 test/<ts>-<uuid>/ 前缀对象不受影响,两包测试可并行)。
|
||||||
|
import {
|
||||||
|
DeleteObjectsCommand,
|
||||||
|
ListObjectsV2Command,
|
||||||
|
S3Client,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
import { PrismaClient } from '@photo/db-cn';
|
import { PrismaClient } from '@photo/db-cn';
|
||||||
|
|
||||||
export const RESET_TABLES: readonly string[] = [
|
export const RESET_TABLES: readonly string[] = [
|
||||||
@@ -31,7 +38,67 @@ export const RESET_TABLES: readonly string[] = [
|
|||||||
'users',
|
'users',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** e2e 对象隔离边界:与 src/uploads/support.ts 的 OBJECT_KEY_ROOT 一致。 */
|
||||||
|
const RESET_OBJECT_PREFIX = 'photo-live/dev/';
|
||||||
|
|
||||||
|
/** 删除 MinIO 中 RESET_OBJECT_PREFIX 前缀的全部对象(分页列举 + 批量删除)。 */
|
||||||
|
async function resetMinioObjects(): Promise<void> {
|
||||||
|
const required = [
|
||||||
|
'S3_ENDPOINT',
|
||||||
|
'S3_REGION',
|
||||||
|
'S3_ACCESS_KEY',
|
||||||
|
'S3_SECRET_KEY',
|
||||||
|
'S3_BUCKET',
|
||||||
|
] as const;
|
||||||
|
const missing = required.filter((key) => !process.env[key]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`resetMinioObjects: 缺少环境变量 ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
const client = new S3Client({
|
||||||
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
|
region: process.env.S3_REGION,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.S3_ACCESS_KEY as string,
|
||||||
|
secretAccessKey: process.env.S3_SECRET_KEY as string,
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
const bucket = process.env.S3_BUCKET as string;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const listed = await client.send(
|
||||||
|
new ListObjectsV2Command({
|
||||||
|
Bucket: bucket,
|
||||||
|
Prefix: RESET_OBJECT_PREFIX,
|
||||||
|
MaxKeys: 1000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const objects = (listed.Contents ?? []).filter(
|
||||||
|
(obj) => typeof obj.Key === 'string',
|
||||||
|
);
|
||||||
|
if (objects.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await client.send(
|
||||||
|
new DeleteObjectsCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Delete: {
|
||||||
|
Objects: objects.map((obj) => ({ Key: obj.Key as string })),
|
||||||
|
Quiet: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!listed.IsTruncated) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function resetDb(): Promise<void> {
|
export async function resetDb(): Promise<void> {
|
||||||
|
await resetMinioObjects();
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
try {
|
try {
|
||||||
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
|
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
// services/api/test/uploads.e2e-spec.ts —— Task 18/19 上传域 e2e(先写后实现)。
|
||||||
|
// 认证同 Task 15:Prisma 直种 sessions 行换 Bearer 令牌;相册/成员 fixture 经
|
||||||
|
// Prisma 直写。真实 MinIO(compose photo-live-minio-1):presign PUT 实际写入、
|
||||||
|
// head 核验、copyIfAbsent 受控复制;真实 Redis(BullMQ photo-process 队列 peek)。
|
||||||
|
// 覆盖(A03 幂等 / A02 逐次权限):
|
||||||
|
// init:成员初始化→201 有效凭证(PUT 实写 MinIO head 可见)+ DB 双行
|
||||||
|
// (photos.objectKey=正式 originals 键;upload_sessions.objectKey=staging 键);
|
||||||
|
// 同 clientTaskId 同参→201 复用同一 uploadId+photoId(不重复建行);
|
||||||
|
// 同 clientTaskId 异参(album/bytes/contentType/扩展名)→409 UPLOAD_SESSION_CONFLICT;
|
||||||
|
// 不同 clientTaskId 同名文件→两会话两照片(同名不覆盖);
|
||||||
|
// 非成员(含他册成员)→404 ALBUM_NOT_FOUND;uploadEnabled=false→409
|
||||||
|
// (owner 亦拒);超限/类型/缺字段/坏扩展名/异册 categoryId→400;
|
||||||
|
// init 不落 audit(高频低风险;审计在 complete)。
|
||||||
|
// renew/complete/history(Task 19,见下方 describe)。
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { type INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { PrismaClient } from '@photo/db-cn';
|
||||||
|
import { ErrorCodes } from '@photo/contracts';
|
||||||
|
import { AppCnModule } from '../src/app-cn.module';
|
||||||
|
import { hashSessionToken } from '../src/auth/session.guard';
|
||||||
|
import { API_PREFIX } from '../src/shared/constants';
|
||||||
|
import { resetDb } from './reset';
|
||||||
|
|
||||||
|
/** 契约 UploadInitResponse 字段全集(形状精确断言用)。 */
|
||||||
|
const INIT_KEYS = ['uploadId', 'photoId', 'objectKey', 'credential'].sort();
|
||||||
|
const CREDENTIAL_KEYS = ['mode', 'url', 'method', 'headers', 'expiresAt'].sort();
|
||||||
|
|
||||||
|
/** 测试隔离边界内的对象键根(与 reset.ts 的 MinIO 清理前缀一致)。 */
|
||||||
|
const KEY_ROOT = 'photo-live/dev';
|
||||||
|
|
||||||
|
describe('uploads init (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
|
||||||
|
let ownerId: string;
|
||||||
|
let memberId: string;
|
||||||
|
/** 仅属于 otherAlbum 的成员(「他册成员对本册不可见」用例)。 */
|
||||||
|
let otherMemberId: string;
|
||||||
|
let outsiderId: string;
|
||||||
|
let ownerToken: string;
|
||||||
|
let memberToken: string;
|
||||||
|
let otherMemberToken: string;
|
||||||
|
let outsiderToken: string;
|
||||||
|
/** T1 幂等锚会话 id(首个用例捕获,后续用例复用断言)。 */
|
||||||
|
let t1UploadId: string;
|
||||||
|
|
||||||
|
let albumId: string;
|
||||||
|
let otherAlbumId: string;
|
||||||
|
/** album 内合法分类(Prisma 直建)。 */
|
||||||
|
let categoryId: string;
|
||||||
|
/** otherAlbum 的分类(跨册 400 用例)。 */
|
||||||
|
let foreignCategoryId: string;
|
||||||
|
|
||||||
|
const postInit = (token: string, body: object) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.post(`/${API_PREFIX}/uploads`)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send(body);
|
||||||
|
|
||||||
|
const initBody = (overrides: Partial<Record<string, unknown>> = {}) => ({
|
||||||
|
albumId,
|
||||||
|
clientTaskId: 'T1',
|
||||||
|
filename: 'a.jpg',
|
||||||
|
bytes: 1000,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
ownerId = (await mkUser('up-owner')).id;
|
||||||
|
memberId = (await mkUser('up-member')).id;
|
||||||
|
otherMemberId = (await mkUser('up-other-member')).id;
|
||||||
|
outsiderId = (await mkUser('up-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;
|
||||||
|
};
|
||||||
|
ownerToken = await mkToken(ownerId);
|
||||||
|
memberToken = await mkToken(memberId);
|
||||||
|
otherMemberToken = await mkToken(otherMemberId);
|
||||||
|
outsiderToken = await mkToken(outsiderId);
|
||||||
|
|
||||||
|
// 相册 fixture:owner 建 A(API 建册自动落 owner 成员行),member 为协作者;
|
||||||
|
// 相册 B 属 owner(Prisma 直建 + owner 成员行),member 亦加入 B(跨册幂等
|
||||||
|
// 冲突用例需要同一用户在两册均可见),otherMember 仅在 B(他册成员 404 用例)。
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post(`/${API_PREFIX}/albums`)
|
||||||
|
.set('Authorization', `Bearer ${ownerToken}`)
|
||||||
|
.send({
|
||||||
|
translations: [
|
||||||
|
{ locale: 'zh-CN', title: '上传相册' },
|
||||||
|
{ locale: 'en', title: 'Uploads' },
|
||||||
|
],
|
||||||
|
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.create({
|
||||||
|
data: { albumId, userId: memberId, role: 'collaborator' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const other = await prisma.albums.create({
|
||||||
|
data: {
|
||||||
|
publicToken: 'otheralbum0000000000000a',
|
||||||
|
ownerId,
|
||||||
|
startAt: new Date(),
|
||||||
|
defaultLocale: 'zh-CN',
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
otherAlbumId = other.id;
|
||||||
|
await prisma.album_members.createMany({
|
||||||
|
data: [
|
||||||
|
{ albumId: otherAlbumId, userId: ownerId, role: 'owner' },
|
||||||
|
{ albumId: otherAlbumId, userId: memberId, role: 'collaborator' },
|
||||||
|
{ albumId: otherAlbumId, userId: otherMemberId, role: 'collaborator' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
categoryId = (
|
||||||
|
await prisma.categories.create({
|
||||||
|
data: { albumId, sortOrder: 0 },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
await prisma.category_translations.create({
|
||||||
|
data: { categoryId, locale: 'zh-CN', name: '默认' },
|
||||||
|
});
|
||||||
|
foreignCategoryId = (
|
||||||
|
await prisma.categories.create({
|
||||||
|
data: { albumId: otherAlbumId, sortOrder: 0 },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成员初始化 → 201;凭证可实写 MinIO;DB:staging 会话行 + 正式键 photos 行', async () => {
|
||||||
|
const res = await postInit(memberToken, initBody());
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(Object.keys(res.body).sort()).toEqual(INIT_KEYS);
|
||||||
|
expect(Object.keys(res.body.credential).sort()).toEqual(CREDENTIAL_KEYS);
|
||||||
|
expect(res.body.credential).toMatchObject({
|
||||||
|
mode: 'presigned',
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'image/jpeg' },
|
||||||
|
});
|
||||||
|
expect(new Date(res.body.credential.expiresAt).getTime()).toBeGreaterThan(
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { uploadId, photoId, objectKey } = res.body as {
|
||||||
|
uploadId: string;
|
||||||
|
photoId: string;
|
||||||
|
objectKey: string;
|
||||||
|
};
|
||||||
|
t1UploadId = uploadId;
|
||||||
|
expect(objectKey).toBe(`${KEY_ROOT}/staging/${uploadId}/${photoId}.jpg`);
|
||||||
|
|
||||||
|
// 凭证真实可用:按凭证 PUT 后 head 可见精确字节数(fetch 全局可用,Node 22)。
|
||||||
|
const body = Buffer.alloc(1000, 1);
|
||||||
|
const put = await fetch(res.body.credential.url, {
|
||||||
|
method: res.body.credential.method,
|
||||||
|
headers: res.body.credential.headers,
|
||||||
|
body: new Uint8Array(body),
|
||||||
|
});
|
||||||
|
expect(put.ok).toBe(true);
|
||||||
|
|
||||||
|
// DB:会话行(pending、expiresAt≈now+SIGNED_URL_TTL_SECONDS)。
|
||||||
|
const ttl = Number.parseInt(
|
||||||
|
process.env.SIGNED_URL_TTL_SECONDS ?? '900',
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const session = await prisma.upload_sessions.findUnique({
|
||||||
|
where: { id: uploadId },
|
||||||
|
});
|
||||||
|
expect(session).toMatchObject({
|
||||||
|
userId: memberId,
|
||||||
|
albumId,
|
||||||
|
clientTaskId: 'T1',
|
||||||
|
photoId,
|
||||||
|
objectKey,
|
||||||
|
expectedBytes: 1000,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
state: 'pending',
|
||||||
|
});
|
||||||
|
const drift =
|
||||||
|
(session?.expiresAt.getTime() ?? Number.NaN) - (Date.now() + ttl * 1000);
|
||||||
|
expect(Math.abs(drift)).toBeLessThan(10_000);
|
||||||
|
|
||||||
|
// DB:photos 行——objectKey 是 FINAL originals 键(init 即写入),未发布待传。
|
||||||
|
const photo = await prisma.photos.findUnique({ where: { id: photoId } });
|
||||||
|
expect(photo).toMatchObject({
|
||||||
|
albumId,
|
||||||
|
uploaderId: memberId,
|
||||||
|
categoryId: null,
|
||||||
|
objectKey: `${KEY_ROOT}/albums/${albumId}/originals/${photoId}.jpg`,
|
||||||
|
bytes: 0,
|
||||||
|
processingState: 'pending_upload',
|
||||||
|
visibility: 'unpublished',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同 clientTaskId 同参重发 → 201 复用同一 uploadId+photoId+objectKey(A03,无第二行)', async () => {
|
||||||
|
const first = await postInit(memberToken, initBody());
|
||||||
|
expect(first.status).toBe(201);
|
||||||
|
const again = await postInit(memberToken, initBody());
|
||||||
|
expect(again.status).toBe(201);
|
||||||
|
expect(again.body.uploadId).toBe(first.body.uploadId);
|
||||||
|
expect(again.body.photoId).toBe(first.body.photoId);
|
||||||
|
expect(again.body.objectKey).toBe(first.body.objectKey);
|
||||||
|
|
||||||
|
expect(await prisma.upload_sessions.count({ where: { userId: memberId } })).toBe(1);
|
||||||
|
expect(await prisma.photos.count({ where: { albumId } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同 clientTaskId 异参(album/bytes/contentType/扩展名)→ 409 UPLOAD_SESSION_CONFLICT', async () => {
|
||||||
|
// T1 锚定 albumId/1000/image/jpeg/a.jpg(前述用例)。
|
||||||
|
const albumConflict = await postInit(
|
||||||
|
memberToken,
|
||||||
|
initBody({ albumId: otherAlbumId }),
|
||||||
|
);
|
||||||
|
expect(albumConflict.status).toBe(409);
|
||||||
|
expect(albumConflict.body.code).toBe(ErrorCodes.UPLOAD_SESSION_CONFLICT);
|
||||||
|
|
||||||
|
const bytesConflict = await postInit(memberToken, initBody({ bytes: 2000 }));
|
||||||
|
expect(bytesConflict.status).toBe(409);
|
||||||
|
expect(bytesConflict.body.code).toBe(ErrorCodes.UPLOAD_SESSION_CONFLICT);
|
||||||
|
|
||||||
|
const typeConflict = await postInit(
|
||||||
|
memberToken,
|
||||||
|
initBody({ filename: 'a.png', contentType: 'image/png', bytes: 2000 }),
|
||||||
|
);
|
||||||
|
expect(typeConflict.status).toBe(409);
|
||||||
|
|
||||||
|
// filename 未持久化(schema 无该列):以扩展名近似锚定——同 clientTaskId 换
|
||||||
|
// 扩展名视为参数漂移 → 409。
|
||||||
|
const extConflict = await postInit(
|
||||||
|
memberToken,
|
||||||
|
initBody({ filename: 'b.png' }),
|
||||||
|
);
|
||||||
|
expect(extConflict.status).toBe(409);
|
||||||
|
expect(extConflict.body.code).toBe(ErrorCodes.UPLOAD_SESSION_CONFLICT);
|
||||||
|
|
||||||
|
// 对应地:同扩展名改名(a2.jpg)不构成漂移 → 仍复用原会话(幂等意图)。
|
||||||
|
const rename = await postInit(memberToken, initBody({ filename: 'a2.jpg' }));
|
||||||
|
expect(rename.status).toBe(201);
|
||||||
|
expect(rename.body.uploadId).toBe(t1UploadId);
|
||||||
|
expect(rename.body.photoId).toBe(
|
||||||
|
(await prisma.upload_sessions.findUnique({ where: { id: t1UploadId } }))
|
||||||
|
?.photoId,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不同 clientTaskId 同名文件 → 两会话两照片(同名不覆盖,A03)', async () => {
|
||||||
|
const one = await postInit(memberToken, initBody({ clientTaskId: 'same-name-1' }));
|
||||||
|
const two = await postInit(memberToken, initBody({ clientTaskId: 'same-name-2' }));
|
||||||
|
expect(one.status).toBe(201);
|
||||||
|
expect(two.status).toBe(201);
|
||||||
|
expect(one.body.uploadId).not.toBe(two.body.uploadId);
|
||||||
|
expect(one.body.photoId).not.toBe(two.body.photoId);
|
||||||
|
expect(one.body.objectKey).not.toBe(two.body.objectKey);
|
||||||
|
|
||||||
|
expect(await prisma.upload_sessions.count({ where: { userId: memberId } })).toBe(3);
|
||||||
|
expect(await prisma.photos.count({ where: { albumId } })).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非成员 → 404 ALBUM_NOT_FOUND(他册成员同)', async () => {
|
||||||
|
const res = await postInit(outsiderToken, initBody({ clientTaskId: 'T-out' }));
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||||||
|
|
||||||
|
// otherMember(B 册成员)对 A 不可见 → 同码 404。
|
||||||
|
const foreign = await request(app.getHttpServer())
|
||||||
|
.post(`/${API_PREFIX}/uploads`)
|
||||||
|
.set('Authorization', `Bearer ${otherMemberToken}`)
|
||||||
|
.send(initBody({ clientTaskId: 'T-foreign' }));
|
||||||
|
expect(foreign.status).toBe(404);
|
||||||
|
expect(foreign.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uploadEnabled=false → 409 ALBUM_UPLOAD_DISABLED(owner 亦拒)', async () => {
|
||||||
|
await prisma.albums.update({
|
||||||
|
where: { id: albumId },
|
||||||
|
data: { uploadEnabled: false },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await postInit(ownerToken, initBody({ clientTaskId: 'T-off' }));
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body.code).toBe(ErrorCodes.ALBUM_UPLOAD_DISABLED);
|
||||||
|
// 未创建任何行(开关是相册级运营状态,先于参数与建行)。
|
||||||
|
expect(
|
||||||
|
await prisma.upload_sessions.count({ where: { clientTaskId: 'T-off' } }),
|
||||||
|
).toBe(0);
|
||||||
|
} finally {
|
||||||
|
await prisma.albums.update({
|
||||||
|
where: { id: albumId },
|
||||||
|
data: { uploadEnabled: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bytes 超 UPLOAD_MAX_BYTES(env 临时下调,逐请求惰性读取)→ 400', async () => {
|
||||||
|
const previous = process.env.UPLOAD_MAX_BYTES;
|
||||||
|
process.env.UPLOAD_MAX_BYTES = '500';
|
||||||
|
try {
|
||||||
|
const res = await postInit(memberToken, initBody({ clientTaskId: 'T-big' }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
// 边界内(=500)通过。
|
||||||
|
const edge = await postInit(
|
||||||
|
memberToken,
|
||||||
|
initBody({ clientTaskId: 'T-edge', bytes: 500 }),
|
||||||
|
);
|
||||||
|
expect(edge.status).toBe(201);
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) {
|
||||||
|
delete process.env.UPLOAD_MAX_BYTES;
|
||||||
|
} else {
|
||||||
|
process.env.UPLOAD_MAX_BYTES = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contentType 白名单外 / 缺字段 / bytes<1 / 坏扩展名 → 400 VALIDATION_FAILED', async () => {
|
||||||
|
const badType = await postInit(
|
||||||
|
memberToken,
|
||||||
|
initBody({ clientTaskId: 'T-plain', contentType: 'text/plain' }),
|
||||||
|
);
|
||||||
|
expect(badType.status).toBe(400);
|
||||||
|
expect(badType.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
const missing = await postInit(memberToken, {
|
||||||
|
albumId,
|
||||||
|
clientTaskId: 'T-missing',
|
||||||
|
bytes: 100,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
});
|
||||||
|
expect(missing.status).toBe(400);
|
||||||
|
expect(missing.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
const zeroBytes = await postInit(
|
||||||
|
memberToken,
|
||||||
|
initBody({ clientTaskId: 'T-zero', bytes: 0 }),
|
||||||
|
);
|
||||||
|
expect(zeroBytes.status).toBe(400);
|
||||||
|
|
||||||
|
const badExt = await postInit(
|
||||||
|
memberToken,
|
||||||
|
initBody({ clientTaskId: 'T-ext', filename: 'virus.exe' }),
|
||||||
|
);
|
||||||
|
expect(badExt.status).toBe(400);
|
||||||
|
expect(badExt.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('categoryId:未知/异册 → 400;本册合法 → 201 且 photos.categoryId 落库', async () => {
|
||||||
|
const unknown = await postInit(
|
||||||
|
memberToken,
|
||||||
|
initBody({ clientTaskId: 'T-cat-unknown', categoryId: 'no-such-category' }),
|
||||||
|
);
|
||||||
|
expect(unknown.status).toBe(400);
|
||||||
|
expect(unknown.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
const foreign = await postInit(
|
||||||
|
memberToken,
|
||||||
|
initBody({ clientTaskId: 'T-cat-foreign', categoryId: foreignCategoryId }),
|
||||||
|
);
|
||||||
|
expect(foreign.status).toBe(400);
|
||||||
|
expect(foreign.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
const ok = await postInit(
|
||||||
|
memberToken,
|
||||||
|
initBody({ clientTaskId: 'T-cat-ok', categoryId }),
|
||||||
|
);
|
||||||
|
expect(ok.status).toBe(201);
|
||||||
|
const photo = await prisma.photos.findUnique({
|
||||||
|
where: { id: ok.body.photoId },
|
||||||
|
});
|
||||||
|
expect(photo?.categoryId).toBe(categoryId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('init 不落 audit(高频低风险;审计在 complete,A06 前置约定)', async () => {
|
||||||
|
const audits = await prisma.audit_logs.count({
|
||||||
|
where: { action: { startsWith: 'upload.' } },
|
||||||
|
});
|
||||||
|
expect(audits).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未认证 → 401', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post(`/${API_PREFIX}/uploads`)
|
||||||
|
.send(initBody({ clientTaskId: 'T-anon' }));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user