diff --git a/package.json b/package.json index 1fa73fe..48c77c2 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "lint-fix": "eslint --fix src/" }, "dependencies": { + "archiver": "^7.0.1", "axios": "^1.13.5", "cheerio": "^1.2.0", "cos-nodejs-sdk-v5": "^2.14.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6df3dc6..d3344af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + archiver: + specifier: ^7.0.1 + version: 7.0.1 axios: specifier: ^1.13.5 version: 1.13.5 @@ -264,6 +267,10 @@ packages: cpu: [x64] os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -277,6 +284,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@puppeteer/browsers@2.13.0': resolution: {integrity: sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==} engines: {node: '>=18'} @@ -309,6 +320,10 @@ packages: a-sync-waterfall@1.0.1: resolution: {integrity: sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -375,6 +390,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@1.0.0: resolution: {integrity: sha512-3iF4FIKdxaVYT3JqQuY3Wat/T2t7TRbbQ94Fu50ZUCbLy4TFbTzr90NOHQodQkNqmeEGCw8WbeP78WNi6SKYUA==} engines: {node: '>=0.8.0'} @@ -391,6 +410,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -405,10 +428,18 @@ packages: resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} engines: {node: '>= 10'} + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + archiver@5.3.2: resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} engines: {node: '>= 10'} + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -784,6 +815,10 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -797,6 +832,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + buffers@0.1.1: resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} engines: {node: '>=0.2.0'} @@ -1003,6 +1041,10 @@ packages: resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} engines: {node: '>= 10'} + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1081,6 +1123,10 @@ packages: resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} engines: {node: '>= 10'} + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + create-error-class@3.0.2: resolution: {integrity: sha512-gYTKKexFO3kh200H1Nit76sRwRtOY32vQd3jpAQKpLtZqyNsSQNfI4N7o3eP2wUjV35pTWKRYqFUDBvUha/Pkw==} engines: {node: '>=0.10.0'} @@ -1092,6 +1138,10 @@ packages: cross-spawn@5.1.0: resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -1286,6 +1336,9 @@ packages: duplexer2@0.1.4: resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecc-jsbn@0.1.2: resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} @@ -1298,6 +1351,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + empower-core@0.6.2: resolution: {integrity: sha512-w9QJ4ROqcjJHWNw+TvpKVeLQV1GQtoFO6aqKoj5IlHi0qxG1Y2157Kg6+5ujs5Bxzm8AgOiOvBCRbNkt6RPe9Q==} @@ -1484,9 +1540,17 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + exceljs@4.4.0: resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} engines: {node: '>=8.3.0'} @@ -1643,6 +1707,10 @@ packages: resolution: {integrity: sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==} engines: {node: '>=0.10.0'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + forever-agent@0.6.1: resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} @@ -1762,6 +1830,10 @@ packages: glob-parent@2.0.0: resolution: {integrity: sha512-JDYOvfxio/t42HKdxkAYaCiBN7oYiuxykOxKxdaUW5Qn0zaYN3gRQWolrwdnf0shM9/EP0ebuuTmyoXNr1cC5w==} + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -2268,6 +2340,9 @@ packages: isstream@0.1.2: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jest-diff@18.1.0: resolution: {integrity: sha512-PzsL3/aLCOfJyvF6cqp6N6kzkImNfDXAkWIU/9y84WPPTf82Dnhkxex/LD/3nR6Z38VBrsefGTQLSF4yoPlMgg==} @@ -2537,6 +2612,9 @@ packages: resolution: {integrity: sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==} engines: {node: '>=0.10.0'} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@4.1.5: resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} @@ -2637,9 +2715,17 @@ packages: resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} @@ -2919,6 +3005,9 @@ packages: package-hash@1.2.0: resolution: {integrity: sha512-W5ILqaI3G6bXDuYb7TrQ95TFHfFdjiunpp61PAXj7z32TgJ5NIBaoqZVI6AXUQy/qcqPoFnz0hAZY9KyKd4xNA==} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-json@2.4.0: resolution: {integrity: sha512-PRg65iXMTt/uK8Rfh5zvzkUbfAPitF17YaCY+IbHsYgksiLvtzWWTUildHth3mVaZ7871OJ7gtP4LBRBlmAdXg==} engines: {node: '>=0.10.0'} @@ -2990,9 +3079,17 @@ packages: resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} engines: {node: '>=4'} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@1.9.0: resolution: {integrity: sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==} @@ -3100,6 +3197,10 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -3179,6 +3280,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdir-glob@1.1.3: resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} @@ -3417,10 +3522,18 @@ packages: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} engines: {node: '>=0.10.0'} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + shebang-regex@1.0.0: resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} engines: {node: '>=0.10.0'} + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -3440,6 +3553,10 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} @@ -3581,6 +3698,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -3596,6 +3717,9 @@ packages: string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@0.1.1: resolution: {integrity: sha512-behete+3uqxecWlDAm5lmskaSaISA+ThQ4oNNBDTBJt0x2ppR6IPqfZNuj6BLaLJ/Sji4TPZlcRyOis8wXQTLg==} engines: {node: '>=0.8.0'} @@ -3613,6 +3737,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom-buf@1.0.0: resolution: {integrity: sha512-1sUIL1jck0T1mhOLP2c696BIznzT525Lkub+n4jjMHjhjhoAQA6Ye659DxdlZBr0aLDMQoTxKIpnlqxgtwjsuQ==} engines: {node: '>=4'} @@ -4070,6 +4198,11 @@ packages: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + widest-line@1.0.0: resolution: {integrity: sha512-r5vvGtqsHUHn98V0jURY4Ts86xJf6+SzK9rpWdV8/73nURB3WFPIHd67aOvPw2fSuunIyHjAUqiJ2TY0x4E5gw==} engines: {node: '>=0.10.0'} @@ -4082,6 +4215,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -4160,6 +4297,10 @@ packages: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -4341,6 +4482,15 @@ snapshots: '@img/sharp-win32-x64@0.33.5': optional: true + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4355,6 +4505,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@pkgjs/parseargs@0.11.0': + optional: true + '@puppeteer/browsers@2.13.0': dependencies: debug: 4.4.3 @@ -4397,6 +4550,10 @@ snapshots: a-sync-waterfall@1.0.1: {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -4459,6 +4616,8 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@1.0.0: {} ansi-styles@2.2.1: {} @@ -4471,6 +4630,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + any-promise@1.3.0: {} anymatch@1.3.2: @@ -4504,6 +4665,16 @@ snapshots: normalize-path: 3.0.0 readable-stream: 3.6.2 + archiver-utils@5.0.2: + dependencies: + glob: 10.4.5 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.17.23 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + archiver@5.3.2: dependencies: archiver-utils: 2.1.0 @@ -4514,6 +4685,20 @@ snapshots: tar-stream: 2.2.0 zip-stream: 4.1.1 + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.1.8 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -5128,6 +5313,8 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-crc32@1.0.0: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -5139,6 +5326,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + buffers@0.1.1: {} bytes@3.1.2: {} @@ -5377,6 +5569,14 @@ snapshots: normalize-path: 3.0.0 readable-stream: 3.6.2 + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + concat-map@0.0.1: {} concat-stream@1.6.2: @@ -5466,6 +5666,11 @@ snapshots: crc-32: 1.2.2 readable-stream: 3.6.2 + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + create-error-class@3.0.2: dependencies: capture-stack-trace: 1.0.2 @@ -5481,6 +5686,12 @@ snapshots: shebang-command: 1.2.0 which: 1.3.1 + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -5659,6 +5870,8 @@ snapshots: dependencies: readable-stream: 2.3.8 + eastasianwidth@0.2.0: {} + ecc-jsbn@0.1.2: dependencies: jsbn: 0.1.1 @@ -5672,6 +5885,8 @@ snapshots: emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + empower-core@0.6.2: dependencies: call-signature: 0.0.2 @@ -5954,12 +6169,16 @@ snapshots: esutils@2.0.3: {} + event-target-shim@5.0.1: {} + events-universal@1.0.1: dependencies: bare-events: 2.8.2 transitivePeerDependencies: - bare-abort-controller + events@3.3.0: {} + exceljs@4.4.0: dependencies: archiver: 5.3.2 @@ -6146,6 +6365,11 @@ snapshots: dependencies: for-in: 1.0.2 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + forever-agent@0.6.1: {} form-data@2.3.3: @@ -6281,6 +6505,15 @@ snapshots: dependencies: is-glob: 2.0.1 + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -6792,6 +7025,12 @@ snapshots: isstream@0.1.2: {} + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jest-diff@18.1.0: dependencies: chalk: 1.1.3 @@ -7099,6 +7338,8 @@ snapshots: lowercase-keys@1.0.1: {} + lru-cache@10.4.3: {} + lru-cache@4.1.5: dependencies: pseudomap: 1.0.2 @@ -7213,8 +7454,14 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.2 + minimist@1.2.8: {} + minipass@7.1.3: {} + mitt@3.0.1: {} mixin-deep@1.3.2: @@ -7490,6 +7737,8 @@ snapshots: dependencies: md5-hex: 1.3.0 + package-json-from-dist@1.0.1: {} + package-json@2.4.0: dependencies: got: 5.7.1 @@ -7559,8 +7808,15 @@ snapshots: path-key@2.0.1: {} + path-key@3.1.1: {} + path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-to-regexp@1.9.0: dependencies: isarray: 0.0.1 @@ -7646,6 +7902,8 @@ snapshots: process-nextick-args@2.0.1: {} + process@0.11.10: {} + progress@2.0.3: {} proxy-agent@6.5.0: @@ -7785,6 +8043,14 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + readdir-glob@1.1.3: dependencies: minimatch: 5.1.9 @@ -8062,8 +8328,14 @@ snapshots: dependencies: shebang-regex: 1.0.0 + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + shebang-regex@1.0.0: {} + shebang-regex@3.0.0: {} + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -8094,6 +8366,8 @@ snapshots: signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + simple-swizzle@0.2.4: dependencies: is-arrayish: 0.3.4 @@ -8261,6 +8535,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.8 @@ -8288,6 +8568,10 @@ snapshots: dependencies: safe-buffer: 5.1.2 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@0.1.1: {} strip-ansi@3.0.1: @@ -8302,6 +8586,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom-buf@1.0.0: dependencies: is-utf8: 0.2.1 @@ -8942,6 +9230,10 @@ snapshots: dependencies: isexe: 2.0.0 + which@2.0.2: + dependencies: + isexe: 2.0.0 + widest-line@1.0.0: dependencies: string-width: 1.0.2 @@ -8954,6 +9246,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} write-file-atomic@1.3.4: @@ -9032,4 +9330,10 @@ snapshots: compress-commons: 4.1.2 readable-stream: 3.6.2 + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 + zod@3.25.76: {} diff --git a/sql/export_task.sql b/sql/export_task.sql new file mode 100644 index 0000000..531705f --- /dev/null +++ b/sql/export_task.sql @@ -0,0 +1,22 @@ +CREATE TABLE `export_task` ( + `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `task_no` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '任务编号', + `title` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '任务标题', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=待处理 1=打包中 2=已完成 3=失败', + `file_types` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '勾选的附件类型JSON', + `filter_params` TEXT COMMENT '筛选条件JSON', + `total_files` INT NOT NULL DEFAULT 0 COMMENT '总文件数', + `processed_files` INT NOT NULL DEFAULT 0 COMMENT '已处理文件数', + `file_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'COS下载地址', + `file_size` BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)', + `error_log` TEXT COMMENT '错误日志', + `started_at` DATETIME DEFAULT NULL COMMENT '开始打包时间', + `finished_at` DATETIME DEFAULT NULL COMMENT '完成时间', + `create_by` INT NOT NULL DEFAULT 0 COMMENT '创建人ID', + `create_by_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '创建人姓名', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` TINYINT NOT NULL DEFAULT 0, + KEY `idx_status` (`status`), + KEY `idx_create_by` (`create_by`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='导出任务表'; diff --git a/src/bootstrap/worker.js b/src/bootstrap/worker.js index b53b75d..8676747 100644 --- a/src/bootstrap/worker.js +++ b/src/bootstrap/worker.js @@ -1 +1,20 @@ // invoked in worker +think.beforeStartServer(async () => { + // 恢复中断的导出任务 + try { + const taskModel = think.model('export_task'); + const recovered = await taskModel.recoverStuckTasks(); + if (recovered) { + think.logger.info(`[Bootstrap] 恢复了 ${recovered} 个中断的导出任务`); + } + // 处理待执行的任务 + const pending = await taskModel.getPendingTask(); + if (!think.isEmpty(pending)) { + think.logger.info('[Bootstrap] 发现待处理的导出任务,启动处理'); + const exportService = think.service('export'); + setImmediate(() => exportService.startProcessing()); + } + } catch (e) { + think.logger.error('[Bootstrap] 导出任务恢复失败:', e); + } +}); diff --git a/src/config/router.js b/src/config/router.js index 9aba426..cd4fdf2 100644 --- a/src/config/router.js +++ b/src/config/router.js @@ -91,6 +91,17 @@ module.exports = [ ['/admin/content/edit', 'admin/content/edit', 'post'], ['/admin/content/delete', 'admin/content/delete', 'post'], + // 下载管理 + ['/admin/export_task', 'admin/export_task/index'], + ['/admin/export_task/list', 'admin/export_task/list'], + ['/admin/export_task/status', 'admin/export_task/status'], + ['/admin/export_task/batchStatus', 'admin/export_task/batchStatus'], + ['/admin/export_task/create', 'admin/export_task/create', 'post'], + ['/admin/export_task/delete', 'admin/export_task/delete', 'post'], + ['/admin/export_task/retry', 'admin/export_task/retry', 'post'], + // 患者附件导出(从患者页面触发) + ['/admin/patient/exportFiles', 'admin/export_task/create', 'post'], + // 文件上传 ['/admin/upload', 'admin/upload/index', 'post'], ['/admin/upload/config', 'admin/upload/config'], diff --git a/src/controller/admin/export_task.js b/src/controller/admin/export_task.js new file mode 100644 index 0000000..be09d60 --- /dev/null +++ b/src/controller/admin/export_task.js @@ -0,0 +1,162 @@ +const Base = require('../base.js'); + +module.exports = class extends Base { + // 权限校验:所有操作需要 patient:export 权限 + async __before() { + const ret = await super.__before(); + // 父类已处理(未登录等),直接返回 + if (ret === false) return false; + // 非页面请求时 loadUserPermissions 未执行,需手动加载 + if (this.isAjax() && this.adminUser && !this.userPermissions) { + await this.loadUserPermissions(); + } + const hasPermission = this.isSuperAdmin || (this.userPermissions || []).includes('patient:export'); + if (!hasPermission) { + if (this.isAjax()) { + return this.fail('暂无操作权限'); + } + return this.redirect('/admin/dashboard.html'); + } + } + + // 下载管理页面 + async indexAction() { + this.assign('currentPage', 'export_task'); + this.assign('pageTitle', '下载管理'); + this.assign('breadcrumb', [{ name: '下载管理' }]); + this.assign('adminUser', this.adminUser || {}); + return this.display(); + } + + // 获取任务列表 + async listAction() { + const { page = 1, pageSize = 10, status } = this.get(); + const model = this.model('export_task'); + const list = await model.getList({ page, pageSize, status }); + return this.success(list); + } + + // 查询单个任务状态(轮询用) + async statusAction() { + const { id } = this.get(); + if (!id) return this.fail('参数错误'); + const task = await this.model('export_task') + .field('id, task_no, status, total_files, processed_files, file_url, file_size, error_log, finished_at') + .where({ id, is_deleted: 0 }) + .find(); + if (think.isEmpty(task)) return this.fail('任务不存在'); + return this.success(task); + } + + // 批量查询任务状态(轮询用) + async batchStatusAction() { + const { ids } = this.get(); + if (!ids) return this.success([]); + const idArr = ids.split(',').map(Number).filter(Boolean); + if (!idArr.length) return this.success([]); + const tasks = await this.model('export_task') + .field('id, status, processed_files, total_files, file_url, file_size') + .where({ id: ['in', idArr], is_deleted: 0 }) + .select(); + return this.success(tasks); + } + + // 创建导出任务 + async createAction() { + const { file_types, filter_params } = this.post(); + + if (!file_types || !file_types.length) { + return this.fail('请选择要导出的附件类型'); + } + + // 防重复:同一用户5分钟内不允许创建相同条件的任务 + const fiveMinAgo = think.datetime(new Date(Date.now() - 5 * 60 * 1000)); + const duplicate = await this.model('export_task').where({ + create_by: this.adminUser?.id || 0, + file_types: JSON.stringify(file_types), + filter_params: JSON.stringify(filter_params || {}), + create_time: ['>', fiveMinAgo], + is_deleted: 0, + status: ['in', [0, 1]] + }).find(); + if (!think.isEmpty(duplicate)) { + return this.fail('您有相同条件的任务正在处理中,请勿重复提交'); + } + + const model = this.model('export_task'); + const taskNo = model.generateTaskNo(); + + // 构建标题 + const typeLabels = { id_photos: '实名认证照片', documents: '上传资料', signs: '签字材料' }; + const title = file_types.map(t => typeLabels[t] || t).join('、'); + + const id = await model.add({ + task_no: taskNo, + title, + status: 0, + file_types: JSON.stringify(file_types), + filter_params: JSON.stringify(filter_params || {}), + create_by: this.adminUser?.id || 0, + create_by_name: this.adminUser?.nickname || this.adminUser?.username || '' + }); + + await this.log('export', '下载管理', `创建导出任务「${title}」编号:${taskNo}`); + + // 触发后台处理 + const exportService = this.service('export'); + // 异步执行,不等待 + setImmediate(() => exportService.startProcessing()); + + return this.success({ id, task_no: taskNo }); + } + + // 删除任务(软删除) + async deleteAction() { + const { id } = this.post(); + if (!id) return this.fail('参数错误'); + + const task = await this.model('export_task') + .where({ id, is_deleted: 0 }) + .find(); + if (think.isEmpty(task)) return this.fail('任务不存在'); + + // 打包中的任务不允许删除 + if (task.status === 1) { + return this.fail('打包中的任务不能删除'); + } + + await this.model('export_task').where({ id }).update({ is_deleted: 1 }); + await this.log('delete', '下载管理', `删除导出任务(${task.task_no})`); + return this.success(); + } + + // 重试失败的任务 + async retryAction() { + const { id } = this.post(); + if (!id) return this.fail('参数错误'); + + const task = await this.model('export_task') + .where({ id, is_deleted: 0 }) + .find(); + if (think.isEmpty(task)) return this.fail('任务不存在'); + if (task.status !== 3) return this.fail('只有失败的任务可以重试'); + + await this.model('export_task').where({ id }).update({ + status: 0, + error_log: '', + started_at: null, + finished_at: null, + processed_files: 0, + total_files: 0, + file_url: '', + file_size: 0 + }); + + await this.log('edit', '下载管理', `重试导出任务(${task.task_no})`); + + const exportService = this.service('export'); + setImmediate(() => exportService.startProcessing()); + + return this.success(); + } +}; diff --git a/src/model/export_task.js b/src/model/export_task.js new file mode 100644 index 0000000..d0d3e74 --- /dev/null +++ b/src/model/export_task.js @@ -0,0 +1,64 @@ +module.exports = class extends think.Model { + get tableName() { + return 'export_task'; + } + + /** + * 生成任务编号: EX + 时间戳 + 随机数 + */ + generateTaskNo() { + const ts = String(Date.now()).slice(-10); + const rand = String(Math.floor(Math.random() * 1000)).padStart(3, '0'); + return 'EX' + ts + rand; + } + + /** + * 获取任务列表(分页) + */ + async getList({ page = 1, pageSize = 10, status, createBy }) { + const where = { is_deleted: 0 }; + if (status !== undefined && status !== '') { + where.status = parseInt(status, 10); + } + if (createBy) { + where.create_by = createBy; + } + return this.where(where) + .order('id DESC') + .page(page, pageSize) + .countSelect(); + } + + /** + * 获取待处理的任务(用于后台执行) + */ + async getPendingTask() { + return this.where({ status: 0, is_deleted: 0 }) + .order('id ASC') + .find(); + } + + /** + * 恢复中断的任务(processing 超过30分钟的标记为失败) + */ + async recoverStuckTasks() { + const thirtyMinAgo = think.datetime(new Date(Date.now() - 30 * 60 * 1000)); + const stuck = await this.where({ + status: 1, + started_at: ['<', thirtyMinAgo], + is_deleted: 0 + }).select(); + if (stuck.length) { + await this.where({ + status: 1, + started_at: ['<', thirtyMinAgo], + is_deleted: 0 + }).update({ + status: 3, + error_log: '任务超时,服务重启后自动标记为失败' + }); + think.logger.warn(`[ExportTask] 恢复了 ${stuck.length} 个中断任务`); + } + return stuck.length; + } +}; diff --git a/src/service/export.js b/src/service/export.js new file mode 100644 index 0000000..fb4e781 --- /dev/null +++ b/src/service/export.js @@ -0,0 +1,259 @@ +const fs = require('fs'); +const path = require('path'); +const archiver = require('archiver'); +const axios = require('axios'); +const COS = require('cos-nodejs-sdk-v5'); +const dayjs = require('dayjs'); +const cosConfig = require('../config/cos.js'); + +// 并发控制:同时最多执行的打包任务数 +const MAX_CONCURRENT = 2; +let runningCount = 0; + +module.exports = class extends think.Service { + /** + * 启动任务处理循环 + */ + async startProcessing() { + if (runningCount >= MAX_CONCURRENT) return; + const taskModel = this.model('export_task'); + const task = await taskModel.getPendingTask(); + if (think.isEmpty(task)) return; + + runningCount++; + try { + await this.processTask(task); + } catch (e) { + think.logger.error(`[ExportTask] 任务 ${task.task_no} 异常:`, e); + await taskModel.where({ id: task.id }).update({ + status: 3, + error_log: e.message || '未知错误', + finished_at: think.datetime(new Date()) + }); + } finally { + runningCount--; + // 继续处理下一个 + setTimeout(() => this.startProcessing(), 500); + } + } + + /** + * 处理单个任务 + */ + async processTask(task) { + const taskModel = this.model('export_task'); + const patientModel = this.model('patient'); + + // 标记为打包中 + await taskModel.where({ id: task.id }).update({ + status: 1, + started_at: think.datetime(new Date()) + }); + + // 解析参数 + let fileTypes = []; + let filterParams = {}; + try { + fileTypes = JSON.parse(task.file_types || '[]'); + filterParams = JSON.parse(task.filter_params || '{}'); + } catch (e) { + throw new Error('任务参数解析失败'); + } + + // 查询患者列表 + const patients = await patientModel.getAll(filterParams); + if (!patients.length) { + await taskModel.where({ id: task.id }).update({ + status: 2, + total_files: 0, + processed_files: 0, + finished_at: think.datetime(new Date()), + error_log: '没有符合条件的患者数据' + }); + return; + } + + // 收集所有需要下载的文件 + const downloadList = this._buildDownloadList(patients, fileTypes); + const totalFiles = downloadList.reduce((sum, p) => sum + p.files.length, 0); + + await taskModel.where({ id: task.id }).update({ total_files: totalFiles }); + + if (totalFiles === 0) { + await taskModel.where({ id: task.id }).update({ + status: 2, + processed_files: 0, + finished_at: think.datetime(new Date()), + error_log: '所选类型下没有可导出的附件' + }); + return; + } + + // 创建临时 ZIP 文件 + const tmpDir = path.join(think.ROOT_PATH, 'runtime/export'); + if (!fs.existsSync(tmpDir)) { + fs.mkdirSync(tmpDir, { recursive: true }); + } + const zipFileName = `${task.task_no}.zip`; + const zipFilePath = path.join(tmpDir, zipFileName); + + // 打包 + let processedFiles = 0; + const errors = []; + + await new Promise((resolve, reject) => { + const output = fs.createWriteStream(zipFilePath); + const archive = archiver('zip', { zlib: { level: 5 } }); + + output.on('close', resolve); + archive.on('error', reject); + archive.pipe(output); + + // 用 Promise 链串行处理每个患者 + const processAll = async () => { + for (const patient of downloadList) { + for (const file of patient.files) { + try { + const response = await axios.get(file.url, { + responseType: 'arraybuffer', + timeout: 30000 + }); + archive.append(Buffer.from(response.data), { + name: `${patient.folder}/${file.name}` + }); + processedFiles++; + // 每处理10个文件更新一次进度 + if (processedFiles % 10 === 0) { + await taskModel.where({ id: task.id }).update({ + processed_files: processedFiles + }); + } + } catch (e) { + errors.push(`${patient.folder}/${file.name}: ${e.message}`); + think.logger.warn(`[ExportTask] 下载失败: ${file.url} - ${e.message}`); + } + } + } + }; + + processAll().then(() => { + archive.finalize(); + }).catch(reject); + }); + + // 更新已处理数 + await taskModel.where({ id: task.id }).update({ processed_files: processedFiles }); + + // 上传到 COS + const cosKey = `uploads/cytx/zip/${dayjs().format('YYYY/MM')}/${zipFileName}`; + const fileSize = fs.statSync(zipFilePath).size; + + const cos = new COS({ + SecretId: cosConfig.secretId, + SecretKey: cosConfig.secretKey + }); + + await new Promise((resolve, reject) => { + cos.putObject({ + Bucket: cosConfig.bucket, + Region: cosConfig.region, + Key: cosKey, + Body: fs.createReadStream(zipFilePath), + ContentType: 'application/zip' + }, (err, data) => { + if (err) reject(err); + else resolve(data); + }); + }); + + // 生成下载 URL + const fileUrl = `${cosConfig.cdnUrl}/${cosKey}`; + + // 更新任务状态 + await taskModel.where({ id: task.id }).update({ + status: 2, + file_url: fileUrl, + file_size: fileSize, + processed_files: processedFiles, + finished_at: think.datetime(new Date()), + error_log: errors.length ? errors.join('\n') : '' + }); + + // 删除本地临时文件 + try { + if (fs.existsSync(zipFilePath)) fs.unlinkSync(zipFilePath); + } catch (e) { + think.logger.warn(`[ExportTask] 删除临时文件失败: ${e.message}`); + } + + think.logger.info(`[ExportTask] 任务 ${task.task_no} 完成,共 ${processedFiles}/${totalFiles} 个文件,${errors.length} 个失败`); + } + + /** + * 构建下载文件列表 + */ + _buildDownloadList(patients, fileTypes) { + const list = []; + for (const p of patients) { + const folder = `${p.name}_${p.patient_no}`; + const files = []; + + // 实名认证照片 + if (fileTypes.includes('id_photos')) { + if (p.id_card_front) { + files.push({ name: `身份证人像面${this._getExt(p.id_card_front)}`, url: p.id_card_front }); + } + if (p.id_card_back) { + files.push({ name: `身份证国徽面${this._getExt(p.id_card_back)}`, url: p.id_card_back }); + } + if (p.photo) { + files.push({ name: `免冠照片${this._getExt(p.photo)}`, url: p.photo }); + } + } + + // 上传资料(检查报告/诊断证明) + if (fileTypes.includes('documents')) { + let docs = []; + try { + docs = JSON.parse(p.documents || '[]'); + } catch (e) { /* ignore */ } + docs.forEach((url, idx) => { + if (url) { + files.push({ name: `检查报告_${idx + 1}${this._getExt(url)}`, url }); + } + }); + } + + // 签字材料 + if (fileTypes.includes('signs')) { + if (p.sign_income) { + files.push({ name: `个人可支配收入声明${this._getExt(p.sign_income)}`, url: p.sign_income }); + } + if (p.sign_privacy) { + files.push({ name: `个人信息处理同意书${this._getExt(p.sign_privacy)}`, url: p.sign_privacy }); + } + if (p.sign_promise) { + files.push({ name: `声明与承诺${this._getExt(p.sign_promise)}`, url: p.sign_promise }); + } + if (p.sign_privacy_jhr) { + files.push({ name: `监护人个人信息处理同意书${this._getExt(p.sign_privacy_jhr)}`, url: p.sign_privacy_jhr }); + } + } + + if (files.length) { + list.push({ folder, files }); + } + } + return list; + } + + /** + * 从 URL 提取文件扩展名 + */ + _getExt(url) { + if (!url) return '.jpg'; + const pathname = url.split('?')[0]; + const ext = path.extname(pathname); + return ext || '.jpg'; + } +}; diff --git a/view/admin/common/_sidebar.html b/view/admin/common/_sidebar.html index a5e7ad1..1dbf996 100644 --- a/view/admin/common/_sidebar.html +++ b/view/admin/common/_sidebar.html @@ -42,6 +42,16 @@ {% endif %} + {# 下载管理 #} + {% if isSuperAdmin or (userPermissions and 'patient:export' in userPermissions) %} +
+ {% endif %} + {# 系统设置 #} {% set hasSysPerm = isSuperAdmin or (userPermissions and ('setting:system:user' in userPermissions or 'setting:system:role' in userPermissions or 'setting:system:log' in userPermissions or 'setting:system:sms' in userPermissions)) %} {% if hasSysPerm %} diff --git a/view/admin/export_task_index.html b/view/admin/export_task_index.html new file mode 100644 index 0000000..299264d --- /dev/null +++ b/view/admin/export_task_index.html @@ -0,0 +1,255 @@ +{% extends "./layout.html" %} + +{% block title %}下载管理{% endblock %} + +{% block css %} + +{% endblock %} + +{% block content %} +${ logContent }
+ 将按当前筛选条件导出患者附件,打包为 ZIP 文件。请选择需要导出的附件类型:
+导出任务已创建,系统正在后台打包中。
+您可以前往「下载管理」页面查看进度和下载文件。
+