diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea2b8e6..60ef444 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,7 +46,7 @@ importers: version: 7.13.0(typescript@5.9.3) ts-jest: specifier: ^29.2.0 - version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3))(typescript@5.9.3) + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.6.0 version: 5.9.3 @@ -90,7 +90,7 @@ importers: version: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)) ts-jest: specifier: ^29.2.5 - version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3))(typescript@5.9.3) + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.6.0 version: 5.9.3 @@ -172,7 +172,7 @@ importers: version: 7.2.2 ts-jest: specifier: ^29.2.5 - version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3))(typescript@5.9.3) + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.20.3)(typescript@5.9.3) @@ -180,6 +180,52 @@ importers: specifier: ^5.6.0 version: 5.9.3 + services/worker: + dependencies: + '@photo/contracts': + specifier: workspace:* + version: link:../../packages/contracts + '@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) + ioredis: + specifier: ^6.0.0 + version: 6.0.0 + pino: + specifier: ^10.0.0 + version: 10.3.1 + sharp: + specifier: ^0.35.0 + version: 0.35.4(@types/node@22.20.3) + devDependencies: + '@aws-sdk/client-s3': + specifier: ^3.700.0 + version: 3.1134.0 + '@types/jest': + specifier: ^29.5.13 + version: 29.5.14 + '@types/node': + specifier: ^22.10.0 + version: 22.20.3 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)) + ts-jest: + specifier: ^29.2.5 + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.6.0 + version: 5.9.3 + packages: '@aws-sdk/checksums@3.1001.0': @@ -434,6 +480,9 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emotion/is-prop-valid@1.4.0': resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} @@ -509,6 +558,152 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@ioredis/commands@2.0.0': resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==} @@ -2825,6 +3020,15 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3648,6 +3852,11 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emotion/is-prop-valid@1.4.0': dependencies: '@emotion/memoize': 0.9.0 @@ -3722,6 +3931,112 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.4': + optional: true + + '@img/sharp-win32-ia32@0.35.4': + optional: true + + '@img/sharp-win32-x64@0.35.4': + optional: true + '@ioredis/commands@2.0.0': {} '@istanbuljs/load-nyc-config@1.1.0': @@ -4915,8 +5230,7 @@ snapshots: destroy@1.2.0: {} - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} detect-newline@3.1.0: {} @@ -6387,6 +6701,39 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.35.4(@types/node@22.20.3): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 22.20.3 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -6623,7 +6970,7 @@ snapshots: dependencies: typescript: 5.9.3 - ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3))(typescript@5.9.3): + ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 diff --git a/services/worker/jest.config.cjs b/services/worker/jest.config.cjs new file mode 100644 index 0000000..e50cbb8 --- /dev/null +++ b/services/worker/jest.config.cjs @@ -0,0 +1,26 @@ +// services/worker/jest.config.cjs —— 单测(src/*.spec.ts;Task 20 pipeline 单测)。 +// 镜像 services/api/jest.config.ts 的约定(CJS 包直接用 .cjs,无需 ts-node): +// · maxWorkers 1:单进程串行(e2e 配置继承后共享同一 MySQL/Redis/MinIO, +// spec 间以 test/reset.ts TRUNCATE + 前缀清理隔离,并行会互相清掉对方数据); +// · setupFiles 经 test/load-env.ts 载入仓库根 .env(全仓库唯一 env 文件); +// · @photo/contracts(ESM)/@photo/db-cn/@photo/storage(CJS)经 moduleNameMapper +// 直指 TS 源码,ts-jest 以 CJS 编译,无需先 build 依赖包 dist; +// · contracts 源码 NodeNext 风格 .js 后缀导入 → 映射回 .ts。 +/** @type {import('jest').Config} */ +module.exports = { + maxWorkers: 1, + testEnvironment: 'node', + rootDir: '.', + setupFiles: ['./test/load-env.ts'], + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }], + }, + moduleNameMapper: { + '^@photo/contracts$': '/../../packages/contracts/src/index.ts', + '^@photo/db-cn$': '/../../packages/db-cn/src/index.ts', + '^@photo/storage$': '/../../packages/storage/src/index.ts', + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + testMatch: ['/src/**/*.spec.ts', '/test/**/*.spec.ts'], + testPathIgnorePatterns: ['/node_modules/', '/dist/'], +}; diff --git a/services/worker/jest.e2e.config.cjs b/services/worker/jest.e2e.config.cjs new file mode 100644 index 0000000..8e5a154 --- /dev/null +++ b/services/worker/jest.e2e.config.cjs @@ -0,0 +1,9 @@ +// services/worker/jest.e2e.config.cjs —— e2e(test/*.e2e-spec.ts,Task 21 发布事务/ +// 补偿扫描),复用单测配置仅切换 testMatch;隔离工具见 test/reset.ts。 +/** @type {import('jest').Config} */ +module.exports = { + // CJS 配置文件复用基础配置只能经 require(node10 互操作),局部豁免 lint 规则。 + // eslint-disable-next-line @typescript-eslint/no-require-imports + ...require('./jest.config.cjs'), + testMatch: ['/test/**/*.e2e-spec.ts'], +}; diff --git a/services/worker/package.json b/services/worker/package.json new file mode 100644 index 0000000..f4dd3c7 --- /dev/null +++ b/services/worker/package.json @@ -0,0 +1,32 @@ +{ + "name": "@photo/worker", + "version": "0.1.0", + "private": true, + "description": "CN photo worker (sharp 变体流水线、发布事务 + outbox、补偿扫描;Task 20/21)", + "scripts": { + "build": "tsc -p tsconfig.build.json", + "start": "tsc -p tsconfig.build.json && node dist/main.js", + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "test": "jest", + "test:e2e": "jest --config jest.e2e.config.cjs" + }, + "dependencies": { + "@photo/contracts": "workspace:*", + "@photo/db-cn": "workspace:*", + "@photo/storage": "workspace:^", + "bullmq": "^6.3.6", + "ioredis": "^6.0.0", + "pino": "^10.0.0", + "sharp": "^0.35.0" + }, + "devDependencies": { + "@aws-sdk/client-s3": "^3.700.0", + "@types/jest": "^29.5.13", + "@types/node": "^22.10.0", + "dotenv": "^16.4.5", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typescript": "^5.6.0" + } +} diff --git a/services/worker/src/pipeline.spec.ts b/services/worker/src/pipeline.spec.ts new file mode 100644 index 0000000..d8f4614 --- /dev/null +++ b/services/worker/src/pipeline.spec.ts @@ -0,0 +1,154 @@ +// services/worker/src/pipeline.spec.ts —— runPipeline 单测(Task 20,真实 sharp 缓冲)。 +// 图像 fixture 全部在测试内经 sharp 生成(无二进制入库): +// · EXIF orientation 用 withMetadata({ orientation }) 构造(sharp 写入 EXIF 标签, +// 像素不旋转——正好模拟相机旁路存储); +// · HEIC 用例分两层:HEVC 压缩 HEIF 的编码能力视 sharp 预编译二进制而定 +// (当前 win32 预构建 libvips 缺 hevc 编码器 → 跳过并留 console 备注,G2 真机 +// iPhone 样本验收);AV1 压缩 HEIF 走同一 libheif 解码路径与 'heif' 格式桶 +// (sharp metadata.format 同为 heif),可稳定覆盖流水线的 HEIF 解码分支。 +import { randomBytes } from 'node:crypto'; +import sharp from 'sharp'; +import { PipelineError, runPipeline } from './pipeline'; + +/** 生成纯色 JPEG(可选 EXIF orientation)。 */ +async function makeJpeg( + width: number, + height: number, + orientation?: number, +): Promise { + let image = sharp({ + create: { width, height, channels: 3, background: { r: 200, g: 100, b: 50 } }, + }); + if (orientation !== undefined) { + image = image.withMetadata({ orientation }); + } + return image.jpeg({ quality: 85 }).toBuffer(); +} + +describe('runPipeline', () => { + test('合法 JPEG:thumb ≤480w / browse ≤1920w,双 jpeg,EXIF 全剥离', async () => { + const input = await makeJpeg(2400, 1600, 1); // 携带 EXIF(orientation 1) + const out = await runPipeline(input); + + expect(out.width).toBe(2400); + expect(out.height).toBe(1600); + expect(Buffer.isBuffer(out.thumb)).toBe(true); + expect(Buffer.isBuffer(out.browse)).toBe(true); + + const thumbMeta = await sharp(out.thumb).metadata(); + expect(thumbMeta.format).toBe('jpeg'); + expect(thumbMeta.width).toBe(480); + expect(thumbMeta.height).toBe(320); + + const browseMeta = await sharp(out.browse).metadata(); + expect(browseMeta.format).toBe('jpeg'); + expect(browseMeta.width).toBe(1920); + expect(browseMeta.height).toBe(1280); + + // EXIF 剥离(GPS 等全部元数据随 EXIF 段一并去除)。 + expect(thumbMeta.orientation).toBeUndefined(); + expect(thumbMeta.exif).toBeUndefined(); + expect(browseMeta.orientation).toBeUndefined(); + expect(browseMeta.exif).toBeUndefined(); + }); + + test('orientation 6 JPEG:自动转正,宽高交换', async () => { + // 旁路存储 400x300 + orientation 6(顺时针 90°)→ 显示 300x400。 + const input = await makeJpeg(400, 300, 6); + const out = await runPipeline(input); + + expect(out.width).toBe(300); + expect(out.height).toBe(400); + // 像素真的转了:thumb(≤480 不放大)即 300x400。 + const thumbMeta = await sharp(out.thumb).metadata(); + expect(thumbMeta.width).toBe(300); + expect(thumbMeta.height).toBe(400); + // 输出不再携带 orientation(已物理转正)。 + expect(thumbMeta.orientation).toBeUndefined(); + }); + + test('损坏缓冲(jpeg 魔数 + 随机字节)→ PipelineError(DECODE_FAILED)', async () => { + const corrupt = Buffer.concat([ + Buffer.from([0xff, 0xd8, 0xff, 0xe0]), + randomBytes(8192), + ]); + const err = await runPipeline(corrupt).then( + () => null, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(PipelineError); + expect((err as PipelineError).code).toBe('DECODE_FAILED'); + }); + + test('PNG 输入可处理,输出仍为 jpeg', async () => { + const input = await sharp({ + create: { width: 640, height: 480, channels: 3, background: { r: 10, g: 20, b: 30 } }, + }) + .png() + .toBuffer(); + const out = await runPipeline(input); + expect(out.width).toBe(640); + expect(out.height).toBe(480); + expect((await sharp(out.thumb).metadata()).format).toBe('jpeg'); + expect((await sharp(out.browse).metadata()).format).toBe('jpeg'); + }); + + test('小图不放大(withoutEnlargement):300px 原图 thumb/browse 均原尺寸', async () => { + const input = await makeJpeg(300, 200); + const out = await runPipeline(input); + expect(out.thumbWidth).toBe(300); + expect(out.thumbHeight).toBe(200); + expect(out.browseWidth).toBe(300); + expect(out.browseHeight).toBe(200); + }); + + test('白名单外格式(gif)→ PipelineError(UNSUPPORTED_TYPE)', async () => { + const gif = await sharp({ + create: { width: 24, height: 24, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }) + .gif() + .toBuffer(); + const err = await runPipeline(gif).then( + () => null, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(PipelineError); + expect((err as PipelineError).code).toBe('UNSUPPORTED_TYPE'); + }); + + test('HEIF(HVEC) 输入可解码出 jpeg 变体(缺 hevc 编码器则跳过,G2 真机验证)', async () => { + let heic: Buffer; + try { + heic = await sharp({ + create: { width: 300, height: 200, channels: 3, background: { r: 90, g: 120, b: 150 } }, + }) + .heif({ compression: 'hevc', quality: 50 }) + .toBuffer(); + } catch (err) { + // 预编译 libvips 不含 hevc 编码器:HEVC 压缩 HEIF(iPhone 默认)的端到端 + // 验证留给 G2 真机样本;解码能力由下方 AV1-HEIF 用例覆盖同一 libheif 路径。 + console.warn( + `[pipeline.spec] HEVC 编码不可用,跳过 HEVC 分支(G2 真机样本验证): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return; + } + const out = await runPipeline(heic); + expect(out.width).toBe(300); + expect(out.height).toBe(200); + expect((await sharp(out.browse).metadata()).format).toBe('jpeg'); + }); + + test('HEIF(AV1) 输入(同一 heif 解码路径/格式桶)产出 jpeg 变体', async () => { + const avif = await sharp({ + create: { width: 300, height: 200, channels: 3, background: { r: 90, g: 120, b: 150 } }, + }) + .avif({ quality: 50 }) + .toBuffer(); + const out = await runPipeline(avif); + expect(out.width).toBe(300); + expect(out.height).toBe(200); + expect((await sharp(out.browse).metadata()).format).toBe('jpeg'); + }); +}); diff --git a/services/worker/src/pipeline.ts b/services/worker/src/pipeline.ts new file mode 100644 index 0000000..db7cabd --- /dev/null +++ b/services/worker/src/pipeline.ts @@ -0,0 +1,113 @@ +// services/worker/src/pipeline.ts —— sharp 处理流水线(Task 20)。 +// 输入原图 Buffer(jpeg/png/heic 等白名单格式),输出 thumb(≤480w, q75)与 +// browse(≤1920w, q82)两个 JPEG 变体 + 原图尺寸。核心约定: +// · .rotate() 无参 = 按 EXIF orientation 自动转正(orientation 5-8 交换宽高); +// · 不调用 withMetadata → 输出不含任何 EXIF(含 GPS)——公开变体不携带拍摄 +// 元数据(隐私),输出 metadata 的 orientation/exif 字段均为 undefined; +// · resize withoutEnlargement → 小图不放大(300px 原图的 thumb/browse 均为原尺寸); +// · 解码失败/尺寸缺失 → PipelineError('DECODE_FAILED')(processor 落 +// photos.errorCode 并驱动 BullMQ 重试/终败);格式不在白名单 → +// PipelineError('UNSUPPORTED_TYPE')(与 api UPLOAD_CONTENT_TYPES 对齐, +// 以 sharp format 名表示;G2 真机 HEIC 样本验证后收口)。 +import sharp from 'sharp'; +import type { Metadata } from 'sharp'; + +export type PipelineErrorCode = 'DECODE_FAILED' | 'UNSUPPORTED_TYPE'; + +export class PipelineError extends Error { + constructor( + readonly code: PipelineErrorCode, + message: string, + ) { + super(message); + this.name = 'PipelineError'; + } +} + +export interface PipelineResult { + thumb: Buffer; + browse: Buffer; + /** 原图(自动转向后)尺寸 → photos.width/height。 */ + width: number; + height: number; + /** 变体实际尺寸(withoutEnlargement 下小于等于上限)→ photo_variants.width/height。 */ + thumbWidth: number; + thumbHeight: number; + browseWidth: number; + browseHeight: number; +} + +export const THUMB_MAX_WIDTH = 480; +export const BROWSE_MAX_WIDTH = 1920; + +/** 可解码格式白名单(sharp metadata.format 取值)。 */ +const SUPPORTED_FORMATS: ReadonlySet = new Set([ + 'jpeg', + 'png', + 'webp', + 'avif', + 'heif', + 'tiff', +]); + +export async function runPipeline(input: Buffer): Promise { + let meta: Metadata; + try { + meta = await sharp(input).metadata(); + } catch (err) { + // 头部/解码抛错一律 DECODE_FAILED(含魔数残缺、截断、随机字节拼接)。 + throw new PipelineError( + 'DECODE_FAILED', + `原图解码失败: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (meta.format === undefined || !SUPPORTED_FORMATS.has(meta.format)) { + throw new PipelineError( + 'UNSUPPORTED_TYPE', + `不支持的图片格式: ${meta.format ?? 'unknown'}`, + ); + } + if (meta.width === undefined || meta.height === undefined) { + throw new PipelineError('DECODE_FAILED', '无法解析原图尺寸'); + } + // EXIF orientation 5-8(90°/270° 旋转):显示尺寸交换宽高;像素级转正由 + // 下方 .rotate() 在变体输出时完成(输出无 EXIF,尺寸即转向后尺寸)。 + const swap = (meta.orientation ?? 1) >= 5; + const width = swap ? meta.height : meta.width; + const height = swap ? meta.width : meta.height; + + let thumb: Buffer; + let browse: Buffer; + try { + thumb = await sharp(input) + .rotate() + .resize({ width: THUMB_MAX_WIDTH, withoutEnlargement: true }) + .jpeg({ quality: 75 }) + .toBuffer(); + browse = await sharp(input) + .rotate() + .resize({ width: BROWSE_MAX_WIDTH, withoutEnlargement: true }) + .jpeg({ quality: 82 }) + .toBuffer(); + } catch (err) { + throw new PipelineError( + 'DECODE_FAILED', + `变体生成失败: ${err instanceof Error ? err.message : String(err)}`, + ); + } + // 变体尺寸以输出头部为准(精确反映 withoutEnlargement/转向结果)。 + const [thumbMeta, browseMeta] = await Promise.all([ + sharp(thumb).metadata(), + sharp(browse).metadata(), + ]); + return { + thumb, + browse, + width, + height, + thumbWidth: thumbMeta.width ?? 0, + thumbHeight: thumbMeta.height ?? 0, + browseWidth: browseMeta.width ?? 0, + browseHeight: browseMeta.height ?? 0, + }; +} diff --git a/services/worker/test/load-env.ts b/services/worker/test/load-env.ts new file mode 100644 index 0000000..60aee96 --- /dev/null +++ b/services/worker/test/load-env.ts @@ -0,0 +1,7 @@ +// services/worker/test/load-env.ts +// 全仓库唯一 env 加载机制(Jest 侧):仓库根 .env(与 services/api 同约定)。 +// 开发/生产经根 dotenv-cli 注入;运行时代码只读 process.env。 +import { join } from 'node:path'; +import { config } from 'dotenv'; + +config({ path: join(__dirname, '../../../.env') }); diff --git a/services/worker/test/reset.ts b/services/worker/test/reset.ts new file mode 100644 index 0000000..a68a7af --- /dev/null +++ b/services/worker/test/reset.ts @@ -0,0 +1,104 @@ +// services/worker/test/reset.ts +// worker e2e 共用隔离工具(镜像 services/api/test/reset.ts):每个 e2e spec 的 +// beforeAll 调用 resetDb()。MySQL 逐表 TRUNCATE(SET FOREIGN_KEY_CHECKS=0 包裹, +// users 被子表引用);MinIO 侧清理 photo-live/dev 前缀对象(worker 的 variants +// 写入与 api 的 staging/originals 同前缀)。S3Client 为 devDependencies +// (与 @photo/storage 内部同款)——StoragePort 不含列举/删除(YAGNI),隔离工具 +// 直连 S3 协议。 +import { + DeleteObjectsCommand, + ListObjectsV2Command, + S3Client, +} from '@aws-sdk/client-s3'; +import { PrismaClient } from '@photo/db-cn'; + +export const RESET_TABLES: readonly string[] = [ + // 与 api/test/reset.ts 同表同序(子表在前)。 + 'outbox_events', + 'photo_jobs', + 'photo_variants', + 'upload_sessions', + 'photos', + 'category_translations', + 'categories', + 'album_assets', + 'album_translations', + 'album_members', + 'albums', + 'audit_logs', + 'sessions', + 'users', +]; + +/** e2e 对象隔离边界:与 api 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'); + for (const table of RESET_TABLES) { + await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${table}`); + } + } finally { + await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1'); + await prisma.$disconnect(); + } +} diff --git a/services/worker/tsconfig.build.json b/services/worker/tsconfig.build.json new file mode 100644 index 0000000..fc2329d --- /dev/null +++ b/services/worker/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.e2e-spec.ts"] +} diff --git a/services/worker/tsconfig.json b/services/worker/tsconfig.json new file mode 100644 index 0000000..1fbd72a --- /dev/null +++ b/services/worker/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node10", + "target": "ES2022", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": true, + "outDir": "./dist", + "types": ["node", "jest"] + }, + "include": ["src", "test"] +}