diff --git a/packages/contracts/src/constants.ts b/packages/contracts/src/constants.ts new file mode 100644 index 0000000..b78b39b --- /dev/null +++ b/packages/contracts/src/constants.ts @@ -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'; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 214733c..fd47d6e 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -2,6 +2,7 @@ // 契约统一出口:错误码 + OpenAPI 生成类型(src/generated/openapi.d.ts 提交入库, // 消费方不跑 gen;本包改动后执行 pnpm gen 并提交生成文件 diff)。 // NodeNext + ESM:相对导入必须带 .js 扩展名;生成文件仅含类型,用 export type * 在运行时擦除。 +export * from './constants.js'; export * from './errors.js'; export * from './events.js'; export type * from './generated/openapi.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4dfebc4..ea2b8e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,12 +115,21 @@ importers: '@photo/db-cn': specifier: workspace:* 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: specifier: ^0.5.1 version: 0.5.1 class-validator: specifier: ^0.15.1 version: 0.15.1 + ioredis: + specifier: ^6.0.0 + version: 6.0.0 nestjs-pino: 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) @@ -137,6 +146,9 @@ importers: specifier: ^7.8.1 version: 7.8.2 devDependencies: + '@aws-sdk/client-s3': + specifier: ^3.700.0 + version: 3.1134.0 '@nestjs/testing': 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) @@ -497,6 +509,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@ioredis/commands@2.0.0': + resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==} + '@istanbuljs/load-nyc-config@1.1.0': resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -606,6 +621,36 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} 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': resolution: {integrity: sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==} peerDependencies: @@ -1248,6 +1293,24 @@ packages: buffer-from@1.1.2: 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: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -1328,6 +1391,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -1405,6 +1472,10 @@ packages: create-require@1.1.1: 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: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1463,6 +1534,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1471,6 +1546,10 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} 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: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -1869,6 +1948,10 @@ packages: inherits@2.0.4: 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: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -2186,6 +2269,10 @@ packages: lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -2301,6 +2388,13 @@ packages: ms@2.1.3: 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: resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} 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 rxjs: ^7.1.0 + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-fetch-h2@2.3.0: resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==} engines: {node: 4.x || >=6.0.0} @@ -2337,6 +2434,10 @@ packages: encoding: optional: true + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -2627,6 +2728,10 @@ packages: real-require@1.0.0: 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: resolution: {integrity: sha512-bBbat+Sx6xKWdyoCGTtA0BWeTEW9Vs4VnEja7q7ZLOk4IM7cHQLrf+kDxWF6dKeKxT8kOBnoy/OsNXCeLttpyQ==} engines: {node: '>=6.9', npm: '>=3.0.0'} @@ -2800,6 +2905,9 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -3614,6 +3722,8 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@ioredis/commands@2.0.0': {} + '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 @@ -3820,6 +3930,24 @@ snapshots: '@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)': dependencies: file-type: 20.4.1 @@ -4578,6 +4706,16 @@ snapshots: 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: dependencies: run-applescript: 7.1.0 @@ -4657,6 +4795,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.1: {} + co@4.6.0: {} collect-v8-coverage@1.0.3: {} @@ -4728,6 +4868,10 @@ snapshots: create-require@1.1.1: {} + cron-parser@5.10.1: + dependencies: + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4765,10 +4909,15 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@2.0.0: {} destroy@1.2.0: {} + detect-libc@2.1.2: + optional: true + detect-newline@3.1.0: {} dezalgo@1.0.4: @@ -5207,6 +5356,17 @@ snapshots: 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: {} is-arrayish@0.2.1: {} @@ -5679,6 +5839,8 @@ snapshots: lunr@2.3.9: {} + luxon@3.7.2: {} + make-dir@4.0.0: dependencies: semver: 7.8.5 @@ -5760,6 +5922,22 @@ snapshots: 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: dependencies: append-field: 1.0.0 @@ -5783,6 +5961,8 @@ snapshots: pino-http: 11.0.0 rxjs: 7.8.2 + node-abort-controller@3.1.1: {} + node-fetch-h2@2.3.0: dependencies: http2-client: 1.3.5 @@ -5791,6 +5971,11 @@ snapshots: dependencies: 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-readfiles@0.2.0: @@ -6091,6 +6276,8 @@ snapshots: 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)): dependencies: '@redocly/openapi-core': 1.34.20(supports-color@10.2.2) @@ -6299,6 +6486,8 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + standard-as-callback@2.1.0: {} + statuses@2.0.2: {} stickyfill@1.1.1: {} diff --git a/services/api/jest.config.ts b/services/api/jest.config.ts index 8318705..b9b675b 100644 --- a/services/api/jest.config.ts +++ b/services/api/jest.config.ts @@ -27,6 +27,9 @@ const config: Config = { moduleNameMapper: { '^@photo/contracts$': '/../../packages/contracts/src/index.ts', '^@photo/db-cn$': '/../../packages/db-cn/src/index.ts', + // Task 18 起:@photo/storage 同样映射到 TS 源码(其依赖 @aws-sdk/* 与 + // @photo/contracts 分别从 storage 包 node_modules / 上面的映射解析)。 + '^@photo/storage$': '/../../packages/storage/src/index.ts', '^(\\.{1,2}/.*)\\.js$': '$1', }, // test/ 下的 *.spec.ts 为直连真实 DB 的集成型单测(seed.spec.ts、 diff --git a/services/api/package.json b/services/api/package.json index 8a364e5..0187a29 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -20,8 +20,11 @@ "@node-rs/argon2": "^2.2.1", "@photo/contracts": "workspace:*", "@photo/db-cn": "workspace:*", + "@photo/storage": "workspace:^", + "bullmq": "^6.3.6", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "ioredis": "^6.0.0", "nestjs-pino": "^4.6.1", "pino": "^10.0.0", "pino-http": "^11.0.0", @@ -29,6 +32,7 @@ "rxjs": "^7.8.1" }, "devDependencies": { + "@aws-sdk/client-s3": "^3.700.0", "@nestjs/testing": "^10.4.6", "@types/jest": "^29.5.13", "@types/node": "^22.10.0", diff --git a/services/api/src/app-cn.module.ts b/services/api/src/app-cn.module.ts index e3600c6..69114dc 100644 --- a/services/api/src/app-cn.module.ts +++ b/services/api/src/app-cn.module.ts @@ -21,6 +21,7 @@ import { AuthModule } from './auth/auth.module'; import { HealthController } from './health.controller'; import { PrismaModule } from './prisma/prisma.module'; import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware'; +import { UploadsModule } from './uploads/uploads.module'; import { resolveLogLevel } from './shared/log-level'; import type { ServerRequestWithId } from './shared/request-with-id'; @@ -31,6 +32,8 @@ import type { ServerRequestWithId } from './shared/request-with-id'; AuthModule, // 相册域(Task 14 起步:PermissionsService;控制器自 Task 15 加入)。 AlbumsModule, + // 上传域(Task 18 init;Task 19 renew/complete/history)。 + UploadsModule, LoggerModule.forRoot({ pinoHttp: { // 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id, diff --git a/services/api/src/uploads/dto.ts b/services/api/src/uploads/dto.ts new file mode 100644 index 0000000..7b49e29 --- /dev/null +++ b/services/api/src/uploads/dto.ts @@ -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; +} diff --git a/services/api/src/uploads/queue.provider.ts b/services/api/src/uploads/queue.provider.ts new file mode 100644 index 0000000..9266512 --- /dev/null +++ b/services/api/src/uploads/queue.provider.ts @@ -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; +} + +/** 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 { + 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 { + if (this.queue !== null) { + await this.queue.close().catch(() => undefined); + this.queue = null; + } + } +} + +export const photoProcessQueueProvider = { + provide: PHOTO_PROCESS_QUEUE, + useClass: BullMqPhotoProcessQueue, +}; diff --git a/services/api/src/uploads/storage.provider.ts b/services/api/src/uploads/storage.provider.ts new file mode 100644 index 0000000..0cd2f21 --- /dev/null +++ b/services/api/src/uploads/storage.provider.ts @@ -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(), +}; diff --git a/services/api/src/uploads/support.ts b/services/api/src/uploads/support.ts new file mode 100644 index 0000000..636cb84 --- /dev/null +++ b/services/api/src/uploads/support.ts @@ -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> = 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, + ); +} diff --git a/services/api/src/uploads/uploads.controller.ts b/services/api/src/uploads/uploads.controller.ts new file mode 100644 index 0000000..9d969f5 --- /dev/null +++ b/services/api/src/uploads/uploads.controller.ts @@ -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 { + return this.uploadsService.init(userOf(req), dto); + } +} diff --git a/services/api/src/uploads/uploads.module.ts b/services/api/src/uploads/uploads.module.ts new file mode 100644 index 0000000..d5f3bf2 --- /dev/null +++ b/services/api/src/uploads/uploads.module.ts @@ -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 {} diff --git a/services/api/src/uploads/uploads.service.ts b/services/api/src/uploads/uploads.service.ts new file mode 100644 index 0000000..0d17d11 --- /dev/null +++ b/services/api/src/uploads/uploads.service.ts @@ -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 { + // ① 相册可见性:不可见(含不存在)统一 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 + ); + } +} diff --git a/services/api/test/reset.ts b/services/api/test/reset.ts index f8e42ec..00ca5fb 100644 --- a/services/api/test/reset.ts +++ b/services/api/test/reset.ts @@ -6,7 +6,14 @@ // 表序仍按 FK 安全顺序排列(子表在前),关检查失败时也不至于中途卡死。 // 顺序 await 在 Prisma 连接池上复用同一空闲连接,会话级 FOREIGN_KEY_CHECKS // 对后续 TRUNCATE 生效;finally 恢复检查并断开,失败也不泄漏连接。 -// TODO(Task 11): 增加 MinIO `photo-live/dev` 前缀对象清理(同一函数内追加)。 +// Task 18 起 e2e 会向 MinIO 写入真实对象(staging/originals):同一函数内追加 +// `photo-live/dev` 前缀对象清理(reset.ts 文件头 TODO 的落实——只清本前缀, +// packages/storage 的 test/-/ 前缀对象不受影响,两包测试可并行)。 +import { + DeleteObjectsCommand, + ListObjectsV2Command, + S3Client, +} from '@aws-sdk/client-s3'; import { PrismaClient } from '@photo/db-cn'; export const RESET_TABLES: readonly string[] = [ @@ -31,7 +38,67 @@ export const RESET_TABLES: readonly string[] = [ 'users', ]; +/** e2e 对象隔离边界:与 src/uploads/support.ts 的 OBJECT_KEY_ROOT 一致。 */ +const RESET_OBJECT_PREFIX = 'photo-live/dev/'; + +/** 删除 MinIO 中 RESET_OBJECT_PREFIX 前缀的全部对象(分页列举 + 批量删除)。 */ +async function resetMinioObjects(): Promise { + 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 { + await resetMinioObjects(); const prisma = new PrismaClient(); try { await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0'); diff --git a/services/api/test/uploads.e2e-spec.ts b/services/api/test/uploads.e2e-spec.ts new file mode 100644 index 0000000..c70025f --- /dev/null +++ b/services/api/test/uploads.e2e-spec.ts @@ -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> = {}) => ({ + 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 => { + 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); + }); +});