diff --git a/ecosystem.config.js b/ecosystem.config.js index e926f8a..8a877b9 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -8,6 +8,12 @@ module.exports = { NODE_ENV: 'production', }, max_memory_restart: '512M', + kill_timeout: 10000, + max_restarts: 10, + min_uptime: '10s', + listen_timeout: 10000, + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', merge_logs: true, + wait_ready: false, }], }; diff --git a/package-lock.json b/package-lock.json index 640ee9b..38bde2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,8 @@ "express-rate-limit": "^8.4.1", "helmet": "^8.2.0", "jsonwebtoken": "^9.0.2", + "mammoth": "^1.12.0", + "nodemailer": "^8.0.10", "pg": "^8.13.0", "wechatpay-node-v3": "^2.2.1" } @@ -64,6 +66,15 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -97,6 +108,15 @@ "node": ">=18.0.0" } }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -115,6 +135,26 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", @@ -130,10 +170,16 @@ "node": "*" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -144,7 +190,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -302,6 +348,12 @@ "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -372,6 +424,12 @@ "wrappy": "1" } }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, "node_modules/dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -394,6 +452,15 @@ "url": "https://dotenvx.com" } }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -493,14 +560,14 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -519,7 +586,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -539,12 +606,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.4.1", - "resolved": "https://registry.npmmirror.com/express-rate-limit/-/express-rate-limit-8.4.1.tgz", - "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -794,6 +861,12 @@ "node": ">=0.10.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -801,9 +874,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -827,6 +900,12 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -855,6 +934,18 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -876,6 +967,15 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -918,6 +1018,17 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -927,6 +1038,30 @@ "tslib": "^2.0.3" } }, + "node_modules/mammoth": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/map-obj": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", @@ -1042,6 +1177,15 @@ "node": ">=8.0.0" } }, + "node_modules/nodemailer": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz", + "integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1084,6 +1228,18 @@ "wrappy": "1" } }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1093,6 +1249,15 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -1239,6 +1404,12 @@ "node": ">=0.10.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1253,9 +1424,9 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -1303,6 +1474,27 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1398,6 +1590,12 @@ "node": ">= 0.8.0" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -1521,6 +1719,12 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/sse-decoder": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/sse-decoder/-/sse-decoder-1.0.0.tgz", @@ -1547,6 +1751,21 @@ "node": ">=10.0.0" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/superagent": { "version": "8.0.6", "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.6.tgz", @@ -1644,6 +1863,12 @@ "node": ">= 0.6" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/undici": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", @@ -1704,6 +1929,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utility": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/utility/-/utility-2.5.0.tgz", @@ -1752,6 +1983,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 53a6d93..4d6c1b5 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "express-rate-limit": "^8.4.1", "helmet": "^8.2.0", "jsonwebtoken": "^9.0.2", + "mammoth": "^1.12.0", + "nodemailer": "^8.0.10", "pg": "^8.13.0", "wechatpay-node-v3": "^2.2.1" } diff --git a/src/dbSetup.js b/src/dbSetup.js index be2e098..c1ee1c8 100644 --- a/src/dbSetup.js +++ b/src/dbSetup.js @@ -958,6 +958,7 @@ async function ensureSchema() { await runMigration("029_task_poll_heartbeat", migrateTaskPollHeartbeat); await runMigration("030_generation_tasks_user_status_index", migrateGenerationTasksUserStatusIndex); await runMigration("031_generation_tasks_billing_columns", migrateGenerationTasksBillingColumns); + await runMigration("032_ecommerce_video_history", migrateEcommerceVideoHistorySchema); await ensureModelPriceSeed(); } @@ -1105,3 +1106,22 @@ module.exports = { hasColumn, addColumnIfMissing, }; + +async function migrateEcommerceVideoHistorySchema(client) { + await client.query(` + CREATE TABLE IF NOT EXISTS ecommerce_video_history ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL DEFAULT '', + config_json TEXT NOT NULL DEFAULT '{}', + plan_json TEXT NOT NULL DEFAULT '{}', + scenes_json TEXT NOT NULL DEFAULT '[]', + source_image_urls TEXT NOT NULL DEFAULT '[]', + status VARCHAR(32) NOT NULL DEFAULT 'completed', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_ecommerce_video_history_user + ON ecommerce_video_history(user_id, created_at DESC); + `); +} diff --git a/src/enterpriseVideoBilling.js b/src/enterpriseVideoBilling.js index 8a01ab1..d44da31 100644 --- a/src/enterpriseVideoBilling.js +++ b/src/enterpriseVideoBilling.js @@ -11,6 +11,12 @@ const ENTERPRISE_VIDEO_ALLOWED_MODELS = new Set([ "kling-3.0-dashscope", "kling-v3-omni-dashscope", "kling/kling-v3-omni-video-generation", + "vidu-q3-turbo", + "vidu-q3-turbo-t2v", + "vidu-q3-turbo-i2v", + "pixverse-c1", + "pixverse-c1-t2v", + "pixverse-c1-i2v", ]); function normalizeModel(value) { @@ -43,6 +49,8 @@ function isEnterpriseVideoModelAllowed(providerConfig, model) { if (protocol === "wan-animate-mix") return true; if (protocol === "wan-s2v") return true; if (protocol === "kling-dashscope") return true; + if (protocol === "vidu") return true; + if (protocol === "pixverse") return true; return false; } @@ -78,6 +86,14 @@ function getEnterpriseVideoCreditRate(input) { return resolution === "720P" ? 0.9 : 1.2; } + if (model.includes("vidu")) { + return resolution === "720P" ? 0.6 : 1.0; + } + + if (model.includes("pixverse")) { + return resolution === "720P" ? 0.6 : 1.0; + } + const error = new Error(`Unsupported enterprise video model: ${input.model || input.requestedModel}`); error.status = 403; error.code = "ENTERPRISE_VIDEO_MODEL_NOT_ALLOWED"; @@ -194,6 +210,21 @@ async function markEnterpriseVideoCreditsAccepted(clientOrPool, creditLedgerId) return rowCount > 0; } +async function refundEnterpriseVideoCredits(clientOrPool, billing, reason) { + if (!billing || !billing.creditLedgerId) return false; + const { rowCount } = await clientOrPool.query( + "UPDATE credit_ledger SET status = 'refunded', refund_reason = $1, updated_at = NOW() WHERE id = $2 AND status = 'reserved'", + [reason || null, billing.creditLedgerId], + ); + if (rowCount > 0 && billing.amountCents > 0 && billing.enterpriseId) { + await clientOrPool.query( + "UPDATE enterprises SET balance_cents = balance_cents + $1, updated_at = NOW() WHERE id = $2", + [billing.amountCents, billing.enterpriseId], + ); + } + return rowCount > 0; +} + module.exports = { ENTERPRISE_VIDEO_ALLOWED_MODELS, assertEnterpriseVideoModelAllowed, @@ -206,5 +237,6 @@ module.exports = { normalizeEnterpriseVideoDuration, normalizeEnterpriseVideoResolution, prepareEnterpriseVideoBilling, + refundEnterpriseVideoCredits, reserveEnterpriseVideoCredits, }; diff --git a/src/index.js b/src/index.js index 2a1533d..0775847 100644 --- a/src/index.js +++ b/src/index.js @@ -16,6 +16,7 @@ const routes = require('./routes') const PORT = Number(process.env.PORT) || 3600 const HOST = process.env.HOST || '0.0.0.0' const IS_PRODUCTION = process.env.NODE_ENV === 'production' +let server = null // CORS: in production, require explicit allowlist; in dev, allow all with credentials function buildCorsOptions() { @@ -103,6 +104,7 @@ async function main() { // Skip JSON body-parser for binary upload routes (busboy handles multipart parsing) app.use('/api/oss/upload-binary', (req, res, next) => { req._body = true; next(); }) + app.use("/api/files/extract-text", (req, res, next) => { req._body = true; next(); }) // JSON body limit: 5MB globally (upload routes override locally) app.use('/api/oss/upload', express.json({ limit: '200mb' })) app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '5mb' })) @@ -145,7 +147,7 @@ async function main() { const { startStaleTaskCleanup } = require('./aiTaskWorker') startStaleTaskCleanup() - app.listen(PORT, HOST, () => { + server = app.listen(PORT, HOST, () => { console.log(`OmniAI Key Server running at http://${HOST}:${PORT}`) console.log(`Health check: http://${HOST}:${PORT}/api/health`) }) @@ -158,9 +160,45 @@ main().catch((err) => { process.on('unhandledRejection', (reason) => { console.error('[fatal] Unhandled promise rejection:', reason) + process.exitCode = 1 + setTimeout(() => process.exit(1), 5000).unref() }) process.on('uncaughtException', (err) => { console.error('[fatal] Uncaught exception:', err) process.exit(1) }) + + +// ── Graceful shutdown ─────────────────────────────────────────────────── +let shuttingDown = false + +function gracefulShutdown(signal) { + if (shuttingDown) return + shuttingDown = true + console.log('[shutdown] Received ' + signal + ', draining connections...') + + if (server && server.listening) { + server.close(() => { + console.log('[shutdown] Server closed, cleaning up...') + const { stopProviderHealthMonitor } = require('./providerHealthMonitor') + stopProviderHealthMonitor() + const { pool } = require('./db') + pool.end().then(() => { + console.log('[shutdown] Database pool closed') + process.exit(0) + }).catch(() => process.exit(0)) + }) + + // Force exit after timeout + setTimeout(() => { + console.error('[shutdown] Forced exit after timeout') + process.exit(1) + }, 15000).unref() + } else { + process.exit(0) + } +} + +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')) +process.on('SIGINT', () => gracefulShutdown('SIGINT')) diff --git a/src/ossClient.js b/src/ossClient.js index 1dce761..5a0dc68 100644 --- a/src/ossClient.js +++ b/src/ossClient.js @@ -111,4 +111,24 @@ async function putObject(objectKey, body, contentType = "application/octet-strea return { ossKey: objectKey, url }; } -module.exports = { getObject, putObject, isOssConfigured, createSignedReadUrl }; +module.exports = { getObject, putObject, deleteObject, isOssConfigured, createSignedReadUrl }; + +async function deleteObject(objectKey) { + if (!isOssConfigured()) { + throw new Error("OSS is not configured"); + } + + const date = new Date().toUTCString(); + const authorization = signOssRequest("DELETE", objectKey, date); + const url = `${getOssEndpoint()}/${encodeURIComponent(objectKey).replace(/%2F/g, "/")}`; + + const response = await fetch(url, { + method: "DELETE", + headers: { Date: date, Authorization: authorization }, + }); + + if (!response.ok && response.status !== 404) { + const text = await response.text().catch(() => ""); + throw new Error(`OSS DELETE failed (${response.status}): ${text.slice(0, 200)}`); + } +} diff --git a/src/routes/ai.js b/src/routes/ai.js index a664519..b8f57c1 100644 --- a/src/routes/ai.js +++ b/src/routes/ai.js @@ -1495,7 +1495,7 @@ function registerAiRoutes(router) { res.flushHeaders(); const abortController = new AbortController(); - const streamTimer = setTimeout(() => abortController.abort(), 60000); + const streamTimer = setTimeout(() => abortController.abort(), 120000); req.on("close", () => { clearTimeout(streamTimer); abortController.abort(); }); try { @@ -1554,7 +1554,7 @@ function registerAiRoutes(router) { } } else { const nonStreamAbort = new AbortController(); - const nonStreamTimer = setTimeout(() => nonStreamAbort.abort(), 60000); + const nonStreamTimer = setTimeout(() => nonStreamAbort.abort(), 120000); const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody, signal: nonStreamAbort.signal }); clearTimeout(nonStreamTimer); const text = await upstream.text().catch(() => ""); @@ -1578,7 +1578,7 @@ function registerAiRoutes(router) { const fallbackHeaders = { "Content-Type": "application/json", Authorization: "Bearer " + slotResult.apiKey }; const fallbackBody = JSON.stringify({ model: fallbackConfig.model, messages, stream: false, temperature: temperature || 0.7, max_tokens: 4096 }); const fbAbort = new AbortController(); - const fbTimer = setTimeout(() => fbAbort.abort(), 60000); + const fbTimer = setTimeout(() => fbAbort.abort(), 90000); const fbUpstream = await fetch(fallbackUrl, { method: "POST", headers: fallbackHeaders, body: fallbackBody, signal: fbAbort.signal }); clearTimeout(fbTimer); const fbText = await fbUpstream.text().catch(() => ""); @@ -1609,7 +1609,7 @@ function registerAiRoutes(router) { } catch (err) { releaseLease(slotResult); console.error("[ai/chat] error:", err.message); - res.status(500).json({ error: err.message }); + res.status(err.name === "AbortError" ? 504 : 500).json({ error: err.name === "AbortError" ? "AI 上游响应超时,请重试" : err.message }); } }); @@ -1690,7 +1690,7 @@ function registerAiRoutes(router) { res.json({ taskId: String(rows[0].id), conversationId: rows[0].conversation_id }); } catch (err) { - res.status(500).json({ error: err.message }); + res.status(err.name === "AbortError" ? 504 : 500).json({ error: err.name === "AbortError" ? "AI 上游响应超时,请重试" : err.message }); } }); @@ -1714,7 +1714,7 @@ function registerAiRoutes(router) { res.json(formatAiTaskRow(rows[0])); } catch (err) { - res.status(500).json({ error: err.message }); + res.status(err.name === "AbortError" ? 504 : 500).json({ error: err.name === "AbortError" ? "AI 上游响应超时,请重试" : err.message }); } }); @@ -1761,7 +1761,7 @@ function registerAiRoutes(router) { taskEvents.off(`task:${taskId}`, onUpdate); }); } catch (err) { - if (!res.headersSent) res.status(500).json({ error: err.message }); + if (!res.headersSent) res.status(err.name === "AbortError" ? 504 : 500).json({ error: err.name === "AbortError" ? "AI 上游响应超时,请重试" : err.message }); } }); @@ -1826,7 +1826,7 @@ function registerAiRoutes(router) { res.end(buffer); } catch (err) { console.error("[ai/tasks/download] failed:", err.message); - if (!res.headersSent) res.status(500).json({ error: err.message }); + if (!res.headersSent) res.status(err.name === "AbortError" ? 504 : 500).json({ error: err.name === "AbortError" ? "AI 上游响应超时,请重试" : err.message }); } }); @@ -1854,7 +1854,7 @@ function registerAiRoutes(router) { res.end(buffer); } catch (err) { console.error("[ai/proxy-download] failed:", err.message); - if (!res.headersSent) res.status(500).json({ error: err.message }); + if (!res.headersSent) res.status(err.name === "AbortError" ? 504 : 500).json({ error: err.name === "AbortError" ? "AI 上游响应超时,请重试" : err.message }); } }); } diff --git a/src/routes/auth.js b/src/routes/auth.js index 76b393d..1dbc463 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -30,6 +30,13 @@ const { buildOssPublicUrl, normalizeAvatarOssKey, normalizeProfileMediaUrl, + EMAIL_PURPOSES, + EMAIL_CODE_TTL_MINUTES, + EMAIL_CODE_COOLDOWN_SECONDS, + EMAIL_CODE_MAX_ATTEMPTS, + hashEmailCode, + sendEmailCode, + consumeEmailCode, } = require("./context"); const { checkBetaInviteCodeForRegistration, @@ -208,15 +215,20 @@ function registerAuthRoutes(router) { const email = normalizeEmail(req.body?.email); const usernameInput = String(req.body?.username || "").trim(); const password = String(req.body?.password || ""); + const code = String(req.body?.code || "").trim(); const emailError = validateEmail(email); if (emailError) return res.status(400).json({ error: emailError }); + if (!code) return res.status(400).json({ error: "缺少验证码" }); const passwordError = validatePassword(password); if (passwordError) return res.status(400).json({ error: passwordError }); const registrationInvite = await ensureRegistrationInvite(req, res); if (!registrationInvite) return; try { + const verified = await consumeEmailCode(email, code, "register"); + if (!verified) return res.status(400).json({ error: "验证码错误或已过期" }); + const { rows: existingEmail } = await pool.query( "SELECT id FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1", [email], @@ -751,6 +763,140 @@ function registerAuthRoutes(router) { res.status(500).json({ error: "更新个人资料失败" }); } }); + + // ============================================================ + // Email verification routes + // ============================================================ + + router.post("/auth/email/send-code", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const purpose = String(req.body?.purpose || "register"); + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "验证码用途无效" }); + + if (purpose === "register") { + const inviteOk = await ensureBetaInviteCode(req, res); + if (!inviteOk) return; + } + + try { + const { rows: recentCodes } = await pool.query( + "SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND created_at > NOW() - ($3::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1", + [email, purpose, EMAIL_CODE_COOLDOWN_SECONDS] + ); + if (recentCodes.length > 0) { + return res.status(429).json({ error: "验证码发送太频繁,请 " + EMAIL_CODE_COOLDOWN_SECONDS + " 秒后再试" }); + } + + if (purpose === "register") { + const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1", [email]); + if (existing.length > 0) return res.status(409).json({ error: "该邮箱已注册" }); + } + + if (purpose === "login" || purpose === "reset") { + const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]); + if (existing.length === 0) return res.status(404).json({ error: "该邮箱尚未注册" }); + } + + const code = generateSmsCode(); + const codeHash = hashEmailCode(email, code); + await pool.query( + "INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, $2, $3, NOW() + ($4::text || ' minutes')::interval)", + [email, purpose, codeHash, EMAIL_CODE_TTL_MINUTES] + ); + + const sendResult = await sendEmailCode(email, code, purpose); + res.json({ + success: true, + provider: sendResult.provider, + ttlSeconds: EMAIL_CODE_TTL_MINUTES * 60, + cooldownSeconds: EMAIL_CODE_COOLDOWN_SECONDS, + ...(sendResult.devCode ? { devCode: sendResult.devCode } : {}), + }); + } catch (error) { + console.error("[auth/email/send-code] failed", error); + res.status(500).json({ error: "验证码发送失败" }); + } + }); + + router.post("/auth/email/verify", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const code = String(req.body?.code || "").trim(); + const purpose = String(req.body?.purpose || "register"); + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + if (!code) return res.status(400).json({ error: "缺少验证码" }); + if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "验证码用途无效" }); + + try { + const verified = await consumeEmailCode(email, code, purpose); + if (!verified) return res.status(400).json({ error: "验证码错误或已过期" }); + if (purpose === "register" || purpose === "login") { + await pool.query("UPDATE users SET email_verified = 1 WHERE LOWER(email) = LOWER($1)", [email]); + } + res.json({ success: true }); + } catch (error) { + console.error("[auth/email/verify] failed", error); + res.status(500).json({ error: "验证失败" }); + } + }); + + router.post("/auth/forgot-password", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + + try { + const { rows } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]); + if (rows.length === 0) { + return res.json({ success: true, message: "如果该邮箱已注册,重置链接已发送" }); + } + + const { rows: recentCodes } = await pool.query( + "SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = 'reset' AND created_at > NOW() - ($2::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1", + [email, EMAIL_CODE_COOLDOWN_SECONDS] + ); + if (recentCodes.length > 0) { + return res.status(429).json({ error: "发送太频繁,请 " + EMAIL_CODE_COOLDOWN_SECONDS + " 秒后再试" }); + } + + const code = generateSmsCode(); + const codeHash = hashEmailCode(email, code); + await pool.query( + "INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, 'reset', $2, NOW() + ($3::text || ' minutes')::interval)", + [email, codeHash, EMAIL_CODE_TTL_MINUTES] + ); + await sendEmailCode(email, code, "reset"); + res.json({ success: true, message: "重置验证码已发送到您的邮箱" }); + } catch (error) { + console.error("[auth/forgot-password] failed", error); + res.status(500).json({ error: "发送失败" }); + } + }); + + router.post("/auth/reset-password", async (req, res) => { + const email = normalizeEmail(req.body?.email); + const code = String(req.body?.code || "").trim(); + const newPassword = String(req.body?.newPassword || ""); + const emailError = validateEmail(email); + if (emailError) return res.status(400).json({ error: emailError }); + if (!code) return res.status(400).json({ error: "缺少验证码" }); + const passwordError = validatePassword(newPassword); + if (passwordError) return res.status(400).json({ error: passwordError }); + + try { + const verified = await consumeEmailCode(email, code, "reset"); + if (!verified) return res.status(400).json({ error: "验证码错误或已过期" }); + const hash = await bcrypt.hash(newPassword, 10); + await pool.query("UPDATE users SET password_hash = $1 WHERE LOWER(email) = LOWER($2)", [hash, email]); + res.json({ success: true, message: "密码重置成功,请重新登录" }); + } catch (error) { + console.error("[auth/reset-password] failed", error); + res.status(500).json({ error: "密码重置失败" }); + } + }); + } module.exports = { diff --git a/src/routes/context.js b/src/routes/context.js index 85a384b..2a98229 100644 --- a/src/routes/context.js +++ b/src/routes/context.js @@ -53,6 +53,10 @@ const SMS_PURPOSES = new Set(["register", "login"]); const SMS_CODE_TTL_MINUTES = Math.max(1, Number(process.env.SMS_CODE_TTL_MINUTES) || 10); const SMS_CODE_COOLDOWN_SECONDS = Math.max(10, Number(process.env.SMS_CODE_COOLDOWN_SECONDS) || 60); const SMS_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.SMS_CODE_MAX_ATTEMPTS) || 5); +const EMAIL_PURPOSES = new Set(["register", "login", "reset"]); +const EMAIL_CODE_TTL_MINUTES = Math.max(1, Number(process.env.EMAIL_CODE_TTL_MINUTES) || 10); +const EMAIL_CODE_COOLDOWN_SECONDS = Math.max(10, Number(process.env.EMAIL_CODE_COOLDOWN_SECONDS) || 60); +const EMAIL_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.EMAIL_CODE_MAX_ATTEMPTS) || 5); function validateUsername(username) { if (!username) return "缺少用户名"; @@ -201,6 +205,66 @@ async function consumeSmsCode(phone, code, purpose) { await pool.query("UPDATE sms_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]); return true; } +function hashEmailCode(email, code) { + const secret = process.env.EMAIL_CODE_SECRET || process.env.JWT_SECRET || "omniai-dev-email-secret"; + return crypto.createHash("sha256").update(email + ":" + code + ":" + secret).digest("hex"); +} + +async function sendEmailCode(email, code, purpose) { + const provider = String(process.env.EMAIL_PROVIDER || "mock").trim().toLowerCase(); + + if (provider === "smtp") { + const nodemailer = require("nodemailer"); + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT) || 587, + secure: process.env.SMTP_SECURE === "1", + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }); + + const purposeText = purpose === "register" ? "注册" : purpose === "login" ? "登录" : "重置密码"; + await transporter.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER, + to: email, + subject: "[OmniAI] \u90ae\u7bb1\u9a8c\u8bc1\u7801", + text: "\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a" + code + "\n\u7528\u9014\uff1a" + purposeText + "\n\u6709\u6548\u671f\uff1a" + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + " \u5206\u949f\n\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002", + html: "

OmniAI \u90ae\u7bb1\u9a8c\u8bc1

\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a

" + code + "

\u7528\u9014\uff1a" + purposeText + "

\u6709\u6548\u671f\uff1a" + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + " \u5206\u949f


\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002

", + }); + return { provider: "smtp" }; + } + + console.log("[email:" + purpose + "] " + email + " verification code: " + code + " (mock provider)"); + return { + provider: "mock", + devCode: process.env.EMAIL_DEV_RETURN_CODE === "1" ? code : undefined, + }; +} + +async function consumeEmailCode(email, code, purpose) { + const { rows } = await pool.query( + "SELECT id, code_hash, attempts FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND consumed_at IS NULL AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1", + [email, purpose] + ); + + const row = rows[0]; + if (!row) return false; + + if (Number(row.attempts || 0) >= EMAIL_CODE_MAX_ATTEMPTS) { + return false; + } + + const expectedHash = hashEmailCode(email, String(code || "").trim()); + if (row.code_hash !== expectedHash) { + await pool.query("UPDATE email_verification_codes SET attempts = attempts + 1 WHERE id = $1", [row.id]); + return false; + } + + await pool.query("UPDATE email_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]); + return true; +} function getWechatLoginConfig() { const appId = process.env.WECHAT_LOGIN_APP_ID || process.env.WECHAT_APP_ID || ""; @@ -742,6 +806,10 @@ module.exports = { PRICE_TYPES, PHONE_PATTERN, EMAIL_PATTERN, + EMAIL_PURPOSES, + EMAIL_CODE_TTL_MINUTES, + EMAIL_CODE_COOLDOWN_SECONDS, + EMAIL_CODE_MAX_ATTEMPTS, SMS_PURPOSES, SMS_CODE_TTL_MINUTES, SMS_CODE_COOLDOWN_SECONDS, @@ -755,6 +823,9 @@ module.exports = { hashSmsCode, generateSmsCode, sendSmsCode, + hashEmailCode, + sendEmailCode, + consumeEmailCode, createLoginResultForUserId, sanitizeUsernameSeed, generateUniqueUsername, diff --git a/src/routes/ecommerce.js b/src/routes/ecommerce.js index f81f0d3..aca3cd8 100644 --- a/src/routes/ecommerce.js +++ b/src/routes/ecommerce.js @@ -131,3 +131,172 @@ function registerEcommerceRoutes(router) { } module.exports = { registerEcommerceRoutes }; + +function registerEcommerceHistoryRoutes(router) { + router.post("/ai/ecommerce/video-history", requireAuth, async (req, res) => { + const userId = req.user.id; + const { title, config, plan, scenes, sourceImageUrls } = req.body; + + if (!scenes || !Array.isArray(scenes) || scenes.length === 0) { + return res.status(400).json({ error: "Missing scenes data" }); + } + + try { + const { rows } = await pool.query( + `INSERT INTO ecommerce_video_history (user_id, title, config_json, plan_json, scenes_json, source_image_urls) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, created_at`, + [ + userId, + title || "", + JSON.stringify(config || {}), + JSON.stringify(plan || {}), + JSON.stringify(scenes), + JSON.stringify(sourceImageUrls || []), + ] + ); + res.json({ id: rows[0].id, createdAt: rows[0].created_at }); + } catch (err) { + console.error("[ecommerce/video-history] save error:", err.message); + res.status(500).json({ error: "保存失败" }); + } + }); + + router.get("/ai/ecommerce/video-history", requireAuth, async (req, res) => { + const userId = req.user.id; + const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), 100); + const offset = Math.max(Number(req.query.offset) || 0, 0); + + try { + const { rows } = await pool.query( + `SELECT id, title, config_json, scenes_json, source_image_urls, created_at + FROM ecommerce_video_history + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3`, + [userId, limit, offset] + ); + + const { rows: countRows } = await pool.query( + "SELECT COUNT(*)::int AS total FROM ecommerce_video_history WHERE user_id = $1", + [userId] + ); + + res.json({ + items: rows.map(r => ({ + id: r.id, + title: r.title, + config: JSON.parse(r.config_json || "{}"), + scenes: JSON.parse(r.scenes_json || "[]"), + sourceImageUrls: JSON.parse(r.source_image_urls || "[]"), + createdAt: r.created_at, + })), + total: countRows[0].total, + limit, + offset, + }); + } catch (err) { + console.error("[ecommerce/video-history] list error:", err.message); + res.status(500).json({ error: "查询失败" }); + } + }); + + router.get("/ai/ecommerce/video-history/:id", requireAuth, async (req, res) => { + const userId = req.user.id; + const id = Number(req.params.id); + + try { + const { rows } = await pool.query( + `SELECT id, title, config_json, plan_json, scenes_json, source_image_urls, created_at + FROM ecommerce_video_history + WHERE id = $1 AND user_id = $2`, + [id, userId] + ); + + if (rows.length === 0) { + return res.status(404).json({ error: "记录不存在" }); + } + + const r = rows[0]; + res.json({ + id: r.id, + title: r.title, + config: JSON.parse(r.config_json || "{}"), + plan: JSON.parse(r.plan_json || "{}"), + scenes: JSON.parse(r.scenes_json || "[]"), + sourceImageUrls: JSON.parse(r.source_image_urls || "[]"), + createdAt: r.created_at, + }); + } catch (err) { + console.error("[ecommerce/video-history] get error:", err.message); + res.status(500).json({ error: "查询失败" }); + } + }); + + router.delete("/ai/ecommerce/video-history/:id", requireAuth, async (req, res) => { + const userId = req.user.id; + const id = Number(req.params.id); + + try { + // Fetch record first to get OSS URLs before deletion + const { rows } = await pool.query( + "SELECT scenes_json, source_image_urls FROM ecommerce_video_history WHERE id = $1 AND user_id = $2", + [id, userId] + ); + if (rows.length === 0) { + return res.status(404).json({ error: "记录不存在" }); + } + + // Extract all OSS URLs from scenes and source images + const scenes = JSON.parse(rows[0].scenes_json || "[]"); + const sourceUrls = JSON.parse(rows[0].source_image_urls || "[]"); + const ossUrlsToDelete = []; + for (const scene of scenes) { + if (scene.imageUrl) ossUrlsToDelete.push(scene.imageUrl); + if (scene.videoUrl) ossUrlsToDelete.push(scene.videoUrl); + } + for (const url of sourceUrls) { + if (url) ossUrlsToDelete.push(url); + } + + // Delete from database + await pool.query( + "DELETE FROM ecommerce_video_history WHERE id = $1 AND user_id = $2", + [id, userId] + ); + + // Delete OSS files in background (best-effort) + const { deleteObject, isOssConfigured } = require("../ossClient"); + if (isOssConfigured() && ossUrlsToDelete.length > 0) { + const bucket = String(process.env.OSS_BUCKET || "").trim(); + const region = String(process.env.OSS_REGION || "").trim().replace(/^oss-/, ""); + const ossHost = bucket + ".oss-" + region + ".aliyuncs.com"; + const publicBase = String(process.env.OSS_PUBLIC_BASE_URL || "").trim().replace(/\/+$/, ""); + + for (const url of ossUrlsToDelete) { + try { + let ossKey = ""; + if (publicBase && url.startsWith(publicBase)) { + ossKey = url.slice(publicBase.length + 1); + } else if (url.includes(ossHost)) { + const parsed = new URL(url); + ossKey = decodeURIComponent(parsed.pathname.slice(1)); + } + if (ossKey) { + await deleteObject(ossKey); + } + } catch (delErr) { + console.warn("[ecommerce/video-history] OSS delete failed for:", url, delErr.message); + } + } + } + + res.json({ success: true }); + } catch (err) { + console.error("[ecommerce/video-history] delete error:", err.message); + res.status(500).json({ error: "删除失败" }); + } + }); +} + +module.exports.registerEcommerceHistoryRoutes = registerEcommerceHistoryRoutes; diff --git a/src/routes/fileExtract.js b/src/routes/fileExtract.js new file mode 100644 index 0000000..d792363 --- /dev/null +++ b/src/routes/fileExtract.js @@ -0,0 +1,110 @@ +"use strict"; + +const mammoth = require("mammoth"); +const { requireAuth } = require("./context"); + +function registerFileExtractRoutes(router) { + router.post("/files/extract-text", requireAuth, async (req, res) => { + try { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const body = Buffer.concat(chunks); + + const contentType = req.headers["content-type"] || ""; + const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;\s]+))/); + if (!boundaryMatch) { + return res.status(400).json({ error: "Missing multipart boundary" }); + } + const boundary = boundaryMatch[1] || boundaryMatch[2]; + const parts = parseMultipart(body, boundary); + const filePart = parts.find(p => p.name === "file"); + if (!filePart || !filePart.data || filePart.data.length === 0) { + return res.status(400).json({ error: "No file uploaded" }); + } + + const filename = filePart.filename || "unknown"; + const ext = filename.lastIndexOf(".") >= 0 + ? filename.slice(filename.lastIndexOf(".")).toLowerCase() + : ""; + + let text = ""; + if (ext === ".docx") { + const result = await mammoth.extractRawText({ buffer: filePart.data }); + text = result.value || ""; + } else if (ext === ".doc") { + text = filePart.data + .toString("utf-8") + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "") + .replace(/\s{3,}/g, "\n\n") + .trim(); + } else { + text = filePart.data.toString("utf-8"); + } + + if (!text || text.trim().length === 0) { + return res.status(422).json({ error: "Unable to extract text" }); + } + + res.json({ text: text.trim(), filename }); + } catch (err) { + console.error("[files/extract-text] error:", err.message); + res.status(500).json({ error: err.message }); + } + }); +} + +function parseMultipart(body, boundary) { + const parts = []; + const boundaryBuf = Buffer.from("--" + boundary); + const crlf = Buffer.from("\r\n"); + const doubleCrlf = Buffer.from("\r\n\r\n"); + + let start = bufIndexOf(body, boundaryBuf, 0); + if (start === -1) return parts; + start += boundaryBuf.length + crlf.length; + + while (true) { + const end = bufIndexOf(body, boundaryBuf, start); + if (end === -1) break; + + const partData = body.slice(start, end - crlf.length); + const headerEnd = bufIndexOf(partData, doubleCrlf, 0); + if (headerEnd === -1) { + start = end + boundaryBuf.length + crlf.length; + continue; + } + + const headerStr = partData.slice(0, headerEnd).toString("utf-8"); + const content = partData.slice(headerEnd + doubleCrlf.length); + + const nameMatch = headerStr.match(/name="([^"]+)"/); + const filenameMatch = headerStr.match(/filename="([^"]*)"/); + + parts.push({ + name: nameMatch ? nameMatch[1] : "", + filename: filenameMatch ? filenameMatch[1] : null, + data: content, + }); + + const afterBoundary = body.slice(end + boundaryBuf.length, end + boundaryBuf.length + 2); + if (afterBoundary.toString() === "--") break; + start = end + boundaryBuf.length + crlf.length; + } + + return parts; +} + +function bufIndexOf(buf, search, from) { + for (let i = from; i <= buf.length - search.length; i++) { + let found = true; + for (let j = 0; j < search.length; j++) { + if (buf[i + j] !== search[j]) { found = false; break; } + } + if (found) return i; + } + return -1; +} + +module.exports = { registerFileExtractRoutes }; diff --git a/src/routes/index.js b/src/routes/index.js index 8129e36..7889472 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -1,6 +1,6 @@ const express = require('express') const { registerAuthRoutes } = require('./auth') -const { registerPriceRoutes, registerPackageRoutes, registerHealthRoutes } = require('./public') +const { registerPriceRoutes, registerPackageRoutes, registerPublicConfigRoutes, registerHealthRoutes } = require('./public') const { registerKeyRoutes } = require('./keys') const { registerAdminRoutes, registerAdminInvoiceRoutes } = require('./admin') const { registerEnterpriseRoutes } = require('./enterprise') @@ -12,18 +12,20 @@ const { registerProjectRoutes } = require('./projects') const { registerOssRoutes } = require('./oss') const { registerCommunityRoutes, registerAdminCommunityRoutes } = require('./community') const { registerAiRoutes } = require('./ai') -const { registerEcommerceRoutes } = require("./ecommerce") +const { registerEcommerceRoutes, registerEcommerceHistoryRoutes } = require("./ecommerce") const { registerConversationRoutes } = require('./conversations') const { registerReportRoutes } = require('./reports') const { registerAssetRoutes } = require('./assets') const { registerNotificationRoutes } = require('./notifications') const { registerDraftRoutes } = require('./drafts'); +const { registerFileExtractRoutes } = require('./fileExtract'); const mountClientErrorRoutes = require('./clientErrors') const router = express.Router() registerAuthRoutes(router) registerPriceRoutes(router) +registerPublicConfigRoutes(router) registerKeyRoutes(router) registerAdminRoutes(router) registerPackageRoutes(router) @@ -41,11 +43,13 @@ registerCommunityRoutes(router) registerAdminCommunityRoutes(router) registerAiRoutes(router) registerEcommerceRoutes(router) +registerEcommerceHistoryRoutes(router) registerConversationRoutes(router) registerReportRoutes(router) registerAssetRoutes(router) registerNotificationRoutes(router) registerDraftRoutes(router) +registerFileExtractRoutes(router) registerHealthRoutes(router) module.exports = router diff --git a/src/routes/public.js b/src/routes/public.js index 9d3ee5e..afcac4e 100644 --- a/src/routes/public.js +++ b/src/routes/public.js @@ -36,14 +36,50 @@ function registerPackageRoutes(router) { function registerHealthRoutes(router) { // ── Health ─────────────────────────────────────────────────────────── + // Public health: minimal response, no provider/key details exposed router.get("/health", async (_req, res) => { + res.json({ status: "ok", uptime: process.uptime() }); + }); + + // Admin-only health: full provider status (requires auth via admin middleware) + router.get("/admin/providers/status", async (_req, res) => { const status = await keyManager.getAllStatus(); res.json({ status: "ok", uptime: process.uptime(), providers: status }); }); } + +const PUBLIC_CONFIG_PROFILES = new Set(["web-public-config", "web-model-capabilities"]); + +function createPublicConfigFallback(name) { + if (name === "web-model-capabilities") { + return { + imageModels: [], + videoModels: [], + chatModels: [], + }; + } + return {}; +} + +function registerPublicConfigRoutes(router) { + router.get("/public/config/profile", async (req, res) => { + const name = String(req.query.name || "web-public-config").trim() || "web-public-config"; + if (!PUBLIC_CONFIG_PROFILES.has(name)) return res.status(404).json({ error: "Public config profile not found" }); + const { rows: [row] } = await pool.query( + "SELECT config_json, description, updated_at FROM config_profiles WHERE name = $1", + [name], + ); + if (!row) return res.json({ name, config: createPublicConfigFallback(name), description: "", updatedAt: null }); + let config = {}; + try { config = JSON.parse(row.config_json || "{}"); } catch {} + return res.json({ name, config, description: row.description || "", updatedAt: row.updated_at }); + }); +} + module.exports = { registerPriceRoutes, registerPackageRoutes, + registerPublicConfigRoutes, registerHealthRoutes, };