16 Commits

Author SHA1 Message Date
stringadmin b3d5d6dfc6 Merge pull request 'Codex/beta application review' (#5) from codex/beta-application-review into master
Reviewed-on: #5
2026-06-08 10:31:29 +00:00
stringadmin 3e41749edb Merge branch 'master' into codex/beta-application-review 2026-06-08 10:31:24 +00:00
stringadmin 47b7bff2ac Add beta email review and task safeguards 2026-06-08 18:30:20 +08:00
stringadmin 6f8658bf85 Require auth for beta application review routes 2026-06-08 16:20:45 +08:00
stringadmin 9d604b5dd9 Restore image generation charge to 20 credits 2026-06-08 16:07:01 +08:00
stringadmin 071a98bd96 Scale generation billing charges to 1-to-100 credits 2026-06-08 16:03:49 +08:00
stringadmin 0f94010104 Use latest login session as active device 2026-06-08 15:55:43 +08:00
stringadmin 2f44f905f1 Merge pull request 'Codex/beta application review' (#4) from codex/beta-application-review into master
Reviewed-on: #4
2026-06-08 07:47:51 +00:00
stringadmin ac088e1e0f Merge branch 'master' into codex/beta-application-review 2026-06-08 07:47:38 +00:00
stringadmin 9436036d65 Merge pull request 'fix: harden generation task coordination' (#3) from codex/generation-task-coordination into master
Reviewed-on: #3
2026-06-08 07:47:29 +00:00
stringadmin fdd408d06b Convert billing to 1-to-100 credits 2026-06-08 15:46:28 +08:00
stringadmin 855fdfc4ff feat: add beta application review APIs 2026-06-08 15:24:18 +08:00
stringadmin ea91155f9e fix: harden generation task coordination 2026-06-08 15:00:19 +08:00
stringadmin 5367e8c2fd Merge pull request 'fix: ????????????????' (#2) from fix/ecommerce-502-bug into master
Reviewed-on: #2
2026-06-04 12:14:22 +00:00
stringadmin 80c10971ea Merge remote-tracking branch 'origin/master' into fix/ecommerce-502-bug 2026-06-04 19:26:27 +08:00
stringadmin df5ea8c65e fix: harden launch server runtime and public config 2026-06-04 18:58:45 +08:00
25 changed files with 2110 additions and 124 deletions
+6
View File
@@ -8,6 +8,12 @@ module.exports = {
NODE_ENV: 'production', NODE_ENV: 'production',
}, },
max_memory_restart: '512M', 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, merge_logs: true,
wait_ready: false,
}], }],
}; };
+259 -19
View File
@@ -17,6 +17,8 @@
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"helmet": "^8.2.0", "helmet": "^8.2.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mammoth": "^1.12.0",
"nodemailer": "^8.0.10",
"pg": "^8.13.0", "pg": "^8.13.0",
"wechatpay-node-v3": "^2.2.1" "wechatpay-node-v3": "^2.2.1"
} }
@@ -64,6 +66,15 @@
"@noble/hashes": "^1.1.5" "@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": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -97,6 +108,15 @@
"node": ">=18.0.0" "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": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -115,6 +135,26 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT" "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": { "node_modules/bcryptjs": {
"version": "2.4.3", "version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
@@ -130,10 +170,16 @@
"node": "*" "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": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "~3.1.2", "bytes": "~3.1.2",
@@ -144,7 +190,7 @@
"http-errors": "~2.0.1", "http-errors": "~2.0.1",
"iconv-lite": "~0.4.24", "iconv-lite": "~0.4.24",
"on-finished": "~2.4.1", "on-finished": "~2.4.1",
"qs": "~6.14.0", "qs": "~6.15.1",
"raw-body": "~2.5.3", "raw-body": "~2.5.3",
"type-is": "~1.6.18", "type-is": "~1.6.18",
"unpipe": "~1.0.0" "unpipe": "~1.0.0"
@@ -302,6 +348,12 @@
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"license": "MIT" "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": { "node_modules/cors": {
"version": "2.8.6", "version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -372,6 +424,12 @@
"wrappy": "1" "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": { "node_modules/dot-case": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
@@ -394,6 +452,15 @@
"url": "https://dotenvx.com" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -493,14 +560,14 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "4.22.1", "version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
"body-parser": "~1.20.3", "body-parser": "~1.20.5",
"content-disposition": "~0.5.4", "content-disposition": "~0.5.4",
"content-type": "~1.0.4", "content-type": "~1.0.4",
"cookie": "~0.7.1", "cookie": "~0.7.1",
@@ -519,7 +586,7 @@
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12", "path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7", "proxy-addr": "~2.0.7",
"qs": "~6.14.0", "qs": "~6.15.1",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
"safe-buffer": "5.2.1", "safe-buffer": "5.2.1",
"send": "~0.19.0", "send": "~0.19.0",
@@ -539,12 +606,12 @@
} }
}, },
"node_modules/express-rate-limit": { "node_modules/express-rate-limit": {
"version": "8.4.1", "version": "8.5.2",
"resolved": "https://registry.npmmirror.com/express-rate-limit/-/express-rate-limit-8.4.1.tgz", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ip-address": "10.1.0" "ip-address": "^10.2.0"
}, },
"engines": { "engines": {
"node": ">= 16" "node": ">= 16"
@@ -794,6 +861,12 @@
"node": ">=0.10.0" "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": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -801,9 +874,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.1.0", "version": "10.2.0",
"resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 12" "node": ">= 12"
@@ -827,6 +900,12 @@
"node": ">=0.10.0" "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": { "node_modules/jsonwebtoken": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -855,6 +934,18 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "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": { "node_modules/jwa": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -876,6 +967,15 @@
"safe-buffer": "^5.0.1" "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": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -918,6 +1018,17 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT" "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": { "node_modules/lower-case": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
@@ -927,6 +1038,30 @@
"tslib": "^2.0.3" "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": { "node_modules/map-obj": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz",
@@ -1042,6 +1177,15 @@
"node": ">=8.0.0" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -1084,6 +1228,18 @@
"wrappy": "1" "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": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1093,6 +1249,15 @@
"node": ">= 0.8" "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": { "node_modules/path-to-regexp": {
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -1239,6 +1404,12 @@
"node": ">=0.10.0" "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": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1253,9 +1424,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.2", "version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
@@ -1303,6 +1474,27 @@
"node": ">= 0.8" "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": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1398,6 +1590,12 @@
"node": ">= 0.8.0" "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": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -1521,6 +1719,12 @@
"node": ">= 10.x" "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": { "node_modules/sse-decoder": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/sse-decoder/-/sse-decoder-1.0.0.tgz", "resolved": "https://registry.npmjs.org/sse-decoder/-/sse-decoder-1.0.0.tgz",
@@ -1547,6 +1751,21 @@
"node": ">=10.0.0" "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": { "node_modules/superagent": {
"version": "8.0.6", "version": "8.0.6",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.6.tgz", "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.6.tgz",
@@ -1644,6 +1863,12 @@
"node": ">= 0.6" "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": { "node_modules/undici": {
"version": "7.25.0", "version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
@@ -1704,6 +1929,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/utility": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/utility/-/utility-2.5.0.tgz", "resolved": "https://registry.npmjs.org/utility/-/utility-2.5.0.tgz",
@@ -1752,6 +1983,15 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+2
View File
@@ -26,6 +26,8 @@
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"helmet": "^8.2.0", "helmet": "^8.2.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mammoth": "^1.12.0",
"nodemailer": "^8.0.10",
"pg": "^8.13.0", "pg": "^8.13.0",
"wechatpay-node-v3": "^2.2.1" "wechatpay-node-v3": "^2.2.1"
} }
+303 -4
View File
@@ -13,6 +13,15 @@ const activePollers = new Map();
const POLL_INTERVAL_MS = 3000; const POLL_INTERVAL_MS = 3000;
const MAX_POLL_ATTEMPTS = 120; const MAX_POLL_ATTEMPTS = 120;
const GRS_IMAGE_MAX_POLL_ATTEMPTS = Number(process.env.GRSAI_IMAGE_MAX_POLL_ATTEMPTS || 60); const GRS_IMAGE_MAX_POLL_ATTEMPTS = Number(process.env.GRSAI_IMAGE_MAX_POLL_ATTEMPTS || 60);
const TASK_EVENT_CHANNEL = "generation_task_events";
const TASK_EVENT_ORIGIN = `${process.pid}-${crypto.randomUUID()}`;
const POLLER_OWNER_ID = `${process.pid}-${crypto.randomUUID()}`;
const POLLER_OWNER_STALE_MS = Number(process.env.TASK_POLLER_OWNER_STALE_MS || 20_000);
const POLLER_RECOVERY_INTERVAL_MS = Number(process.env.TASK_POLLER_RECOVERY_INTERVAL_MS || 30_000);
let taskEventListenerClient = null;
let taskEventListenerStarting = null;
let pollerStoreReady = null;
let pollerRecoveryTimer = null;
function normalizeTaskProgress(value) { function normalizeTaskProgress(value) {
const numeric = Number(value); const numeric = Number(value);
@@ -30,6 +39,156 @@ function formatTaskEvent(row) {
}; };
} }
function emitTaskEvent(event) {
if (!event?.taskId) return;
taskEvents.emit(`task:${event.taskId}`, event);
}
async function publishTaskEvent(event) {
if (!event?.taskId) return;
emitTaskEvent(event);
try {
await pool.query("SELECT pg_notify($1, $2)", [
TASK_EVENT_CHANNEL,
JSON.stringify({ origin: TASK_EVENT_ORIGIN, event }),
]);
} catch (err) {
console.error(`[aiTaskWorker] task event publish failed for task ${event.taskId}:`, err.message);
}
}
function serializeProviderConfig(providerConfig) {
if (!providerConfig || typeof providerConfig !== "object") return {};
const allowedKeys = [
"provider",
"transport",
"protocol",
"baseUrl",
"endpoint",
"resultEndpoint",
"model",
"requestedModel",
];
const result = {};
for (const key of allowedKeys) {
if (providerConfig[key] !== undefined) result[key] = providerConfig[key];
}
return result;
}
function parseProviderConfig(value) {
if (!value) return {};
if (typeof value === "object") return value;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
async function ensureTaskPollerStore() {
if (pollerStoreReady) return pollerStoreReady;
pollerStoreReady = pool.query(`
CREATE TABLE IF NOT EXISTS generation_task_pollers (
task_id INTEGER PRIMARY KEY REFERENCES generation_tasks(id) ON DELETE CASCADE,
provider_task_id TEXT NOT NULL,
task_type TEXT NOT NULL,
provider_config_json TEXT NOT NULL,
lease_token TEXT,
owner_id TEXT,
owner_heartbeat_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_generation_task_pollers_owner
ON generation_task_pollers(owner_heartbeat_at);
`).catch((err) => {
pollerStoreReady = null;
throw err;
});
return pollerStoreReady;
}
async function persistPollerState(taskDbId, { providerTaskId, type, providerConfig, leaseToken }) {
await ensureTaskPollerStore();
await pool.query(
`
INSERT INTO generation_task_pollers (
task_id, provider_task_id, task_type, provider_config_json, lease_token,
owner_id, owner_heartbeat_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (task_id) DO UPDATE SET
provider_task_id = EXCLUDED.provider_task_id,
task_type = EXCLUDED.task_type,
provider_config_json = EXCLUDED.provider_config_json,
lease_token = EXCLUDED.lease_token,
owner_id = EXCLUDED.owner_id,
owner_heartbeat_at = NOW(),
updated_at = NOW()
`,
[
taskDbId,
providerTaskId,
type,
JSON.stringify(serializeProviderConfig(providerConfig)),
leaseToken || null,
POLLER_OWNER_ID,
],
);
}
async function refreshPollerHeartbeat(taskDbId) {
await ensureTaskPollerStore();
await pool.query(
"UPDATE generation_task_pollers SET owner_id = $1, owner_heartbeat_at = NOW(), updated_at = NOW() WHERE task_id = $2",
[POLLER_OWNER_ID, taskDbId],
);
}
async function clearPollerState(taskDbId) {
await ensureTaskPollerStore();
await pool.query("DELETE FROM generation_task_pollers WHERE task_id = $1", [taskDbId]);
}
async function getLeaseKey(leaseToken) {
if (!leaseToken) return null;
const { rows } = await pool.query(
`
SELECT k.api_key
FROM key_leases l
JOIN api_keys k ON k.id = l.key_id
WHERE l.lease_token = $1
AND l.released_at IS NULL
AND k.enabled = 1
LIMIT 1
`,
[leaseToken],
);
const apiKey = rows[0]?.api_key;
return apiKey === "pool-slot" ? "" : apiKey || null;
}
async function claimPoller(taskId) {
await ensureTaskPollerStore();
const staleInterval = `${Math.max(5, Math.ceil(POLLER_OWNER_STALE_MS / 1000))} seconds`;
const { rows } = await pool.query(
`
UPDATE generation_task_pollers
SET owner_id = $1, owner_heartbeat_at = NOW(), updated_at = NOW()
WHERE task_id = $2
AND (
owner_heartbeat_at IS NULL
OR owner_heartbeat_at < NOW() - ($3::text)::interval
)
RETURNING *
`,
[POLLER_OWNER_ID, taskId, staleInterval],
);
return rows[0] || null;
}
async function createTaskLifecycleNotification(task) { async function createTaskLifecycleNotification(task) {
if (!task || !task.user_id || !task.id) return; if (!task || !task.user_id || !task.id) return;
@@ -99,7 +258,7 @@ async function updateTaskInDb(taskId, updates) {
let updatedTask = rows[0]; let updatedTask = rows[0];
if (updatedTask) { if (updatedTask) {
taskEvents.emit(`task:${taskId}`, formatTaskEvent(updatedTask)); await publishTaskEvent(formatTaskEvent(updatedTask));
} }
if (nextUpdates.status === "completed" && updatedTask?.result_url) { if (nextUpdates.status === "completed" && updatedTask?.result_url) {
@@ -636,8 +795,13 @@ function getMaxPollAttempts(type, providerConfig) {
return MAX_POLL_ATTEMPTS; return MAX_POLL_ATTEMPTS;
} }
function startPolling(taskDbId, { providerTaskId, apiKey, type, providerConfig, leaseToken, keyManager, onTaskFailed }) { function startPolling(taskDbId, { providerTaskId, apiKey, type, providerConfig, leaseToken, keyManager, onTaskFailed, skipPersist = false }) {
if (activePollers.has(taskDbId)) return; if (activePollers.has(taskDbId)) return;
if (!skipPersist) {
persistPollerState(taskDbId, { providerTaskId, type, providerConfig, leaseToken }).catch((err) => {
console.error(`[aiTaskWorker] failed to persist poller state for task ${taskDbId}:`, err.message);
});
}
let attempts = 0; let attempts = 0;
const maxPollAttempts = getMaxPollAttempts(type, providerConfig); const maxPollAttempts = getMaxPollAttempts(type, providerConfig);
@@ -655,6 +819,7 @@ function startPolling(taskDbId, { providerTaskId, apiKey, type, providerConfig,
if (handled) return; if (handled) return;
} }
await updateTaskInDb(taskDbId, { status: "failed", error: "Task timed out" }); await updateTaskInDb(taskDbId, { status: "failed", error: "Task timed out" });
await clearPollerState(taskDbId).catch(() => {});
return; return;
} }
@@ -664,9 +829,11 @@ function startPolling(taskDbId, { providerTaskId, apiKey, type, providerConfig,
if (!taskRow || taskRow.status === "cancelled") { if (!taskRow || taskRow.status === "cancelled") {
clearInterval(interval); clearInterval(interval);
activePollers.delete(taskDbId); activePollers.delete(taskDbId);
await clearPollerState(taskDbId).catch(() => {});
if (leaseToken && keyManager) await keyManager.releaseKey(leaseToken).catch(() => {}); if (leaseToken && keyManager) await keyManager.releaseKey(leaseToken).catch(() => {});
return; return;
} }
await refreshPollerHeartbeat(taskDbId).catch(() => {});
let result; let result;
if (type === "image") { if (type === "image") {
@@ -693,6 +860,9 @@ function startPolling(taskDbId, { providerTaskId, apiKey, type, providerConfig,
} }
await updateTaskInDb(taskDbId, result); await updateTaskInDb(taskDbId, result);
if (result.status === "completed" || result.status === "failed") {
await clearPollerState(taskDbId).catch(() => {});
}
} catch (err) { } catch (err) {
console.error(`[aiTaskWorker] poll error for task ${taskDbId}:`, err.message); console.error(`[aiTaskWorker] poll error for task ${taskDbId}:`, err.message);
} }
@@ -707,12 +877,57 @@ function stopPolling(taskDbId) {
clearInterval(poller.interval); clearInterval(poller.interval);
activePollers.delete(taskDbId); activePollers.delete(taskDbId);
} }
clearPollerState(taskDbId).catch(() => {});
} }
function getActiveCount() { function getActiveCount() {
return activePollers.size; return activePollers.size;
} }
async function recoverRunnablePollers() {
await ensureTaskPollerStore();
const staleInterval = `${Math.max(5, Math.ceil(POLLER_OWNER_STALE_MS / 1000))} seconds`;
const { rows } = await pool.query(
`
SELECT p.task_id
FROM generation_task_pollers p
JOIN generation_tasks t ON t.id = p.task_id
WHERE t.status IN ('pending', 'running')
AND (
p.owner_heartbeat_at IS NULL
OR p.owner_heartbeat_at < NOW() - ($1::text)::interval
)
ORDER BY p.owner_heartbeat_at NULLS FIRST, p.updated_at ASC
LIMIT 20
`,
[staleInterval],
);
for (const row of rows) {
const taskId = row.task_id;
if (activePollers.has(taskId)) continue;
const poller = await claimPoller(taskId);
if (!poller || activePollers.has(taskId)) continue;
const apiKey = await getLeaseKey(poller.lease_token);
if (apiKey == null) {
console.warn(`[aiTaskWorker] cannot recover task ${taskId}: active lease not found`);
continue;
}
console.info(`[aiTaskWorker] recovering poller for task ${taskId}`);
startPolling(taskId, {
providerTaskId: poller.provider_task_id,
apiKey,
type: poller.task_type,
providerConfig: parseProviderConfig(poller.provider_config_json),
leaseToken: poller.lease_token,
keyManager: require("./keyManager"),
skipPersist: true,
});
}
}
// --- Periodic stale task cleanup --- // --- Periodic stale task cleanup ---
// Runs every 5 minutes, marks tasks stuck in 'pending'/'running' for too long as 'failed'. // Runs every 5 minutes, marks tasks stuck in 'pending'/'running' for too long as 'failed'.
// This catches cases where the worker crashed, the provider API never responded, // This catches cases where the worker crashed, the provider API never responded,
@@ -730,7 +945,7 @@ async function runStaleTaskCleanup() {
RETURNING id`, RETURNING id`,
); );
for (const row of rows) { for (const row of rows) {
taskEvents.emit(`task:${row.id}`, { await publishTaskEvent({
taskId: row.id, taskId: row.id,
status: "failed", status: "failed",
progress: null, progress: null,
@@ -740,9 +955,10 @@ async function runStaleTaskCleanup() {
// Also stop any active poller for this task // Also stop any active poller for this task
const poller = activePollers.get(row.id); const poller = activePollers.get(row.id);
if (poller) { if (poller) {
clearInterval(poller.timer); clearInterval(poller.interval);
activePollers.delete(row.id); activePollers.delete(row.id);
} }
await clearPollerState(row.id).catch(() => {});
} }
if (rows.length > 0) { if (rows.length > 0) {
console.log(`[aiTaskWorker] Cleaned up ${rows.length} stale task(s)`); console.log(`[aiTaskWorker] Cleaned up ${rows.length} stale task(s)`);
@@ -752,6 +968,66 @@ async function runStaleTaskCleanup() {
} }
} }
async function startTaskEventListener() {
if (taskEventListenerClient) return;
if (taskEventListenerStarting) return taskEventListenerStarting;
taskEventListenerStarting = (async () => {
const client = await pool.connect();
let released = false;
const releaseClient = () => {
if (released) return;
released = true;
taskEventListenerClient = null;
try {
client.release();
} catch {}
};
client.on("notification", (message) => {
if (message.channel !== TASK_EVENT_CHANNEL || !message.payload) return;
try {
const payload = JSON.parse(message.payload);
if (payload?.origin === TASK_EVENT_ORIGIN) return;
emitTaskEvent(payload?.event || payload);
} catch (err) {
console.error("[aiTaskWorker] task event notification parse failed:", err.message);
}
});
client.on("error", (err) => {
console.error("[aiTaskWorker] task event listener error:", err.message);
releaseClient();
setTimeout(() => {
startTaskEventListener().catch((restartErr) => {
console.error("[aiTaskWorker] task event listener restart failed:", restartErr.message);
});
}, 5000).unref?.();
});
await client.query(`LISTEN ${TASK_EVENT_CHANNEL}`);
taskEventListenerClient = client;
console.log(`[aiTaskWorker] listening for task events on ${TASK_EVENT_CHANNEL}`);
})();
try {
await taskEventListenerStarting;
} finally {
taskEventListenerStarting = null;
}
}
async function stopTaskEventListener() {
const client = taskEventListenerClient;
taskEventListenerClient = null;
if (!client) return;
try {
await client.query(`UNLISTEN ${TASK_EVENT_CHANNEL}`);
} catch {}
client.release();
}
function startStaleTaskCleanup() { function startStaleTaskCleanup() {
if (staleTaskCleanupTimer) return; if (staleTaskCleanupTimer) return;
staleTaskCleanupTimer = setInterval(runStaleTaskCleanup, STALE_TASK_CLEANUP_INTERVAL_MS); staleTaskCleanupTimer = setInterval(runStaleTaskCleanup, STALE_TASK_CLEANUP_INTERVAL_MS);
@@ -766,6 +1042,25 @@ function stopStaleTaskCleanup() {
} }
} }
function startPollerRecovery() {
if (pollerRecoveryTimer) return;
ensureTaskPollerStore()
.then(() => recoverRunnablePollers())
.catch((err) => console.error("[aiTaskWorker] initial poller recovery failed:", err.message));
pollerRecoveryTimer = setInterval(() => {
recoverRunnablePollers().catch((err) => {
console.error("[aiTaskWorker] poller recovery failed:", err.message);
});
}, POLLER_RECOVERY_INTERVAL_MS);
}
function stopPollerRecovery() {
if (pollerRecoveryTimer) {
clearInterval(pollerRecoveryTimer);
pollerRecoveryTimer = null;
}
}
module.exports = { module.exports = {
startPolling, startPolling,
stopPolling, stopPolling,
@@ -778,6 +1073,10 @@ module.exports = {
parseKlingCredential, parseKlingCredential,
createKlingJwt, createKlingJwt,
taskEvents, taskEvents,
startTaskEventListener,
stopTaskEventListener,
startPollerRecovery,
stopPollerRecovery,
startStaleTaskCleanup, startStaleTaskCleanup,
stopStaleTaskCleanup, stopStaleTaskCleanup,
}; };
+20 -20
View File
@@ -6,7 +6,6 @@ const { getJwtSecret } = require("./securityConfig");
const JWT_SECRET = getJwtSecret(); const JWT_SECRET = getJwtSecret();
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d"; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d";
const MAX_CONCURRENT_SESSIONS = 2;
const USER_CONTEXT_SELECT = ` const USER_CONTEXT_SELECT = `
SELECT SELECT
@@ -170,25 +169,26 @@ function verifyToken(token) {
async function startUserSession(userId, userAgent) { async function startUserSession(userId, userAgent) {
const sessionId = crypto.randomUUID(); const sessionId = crypto.randomUUID();
await pool.query( const client = await pool.connect();
"INSERT INTO user_sessions (id, user_id, user_agent, created_at) VALUES ($1, $2, $3, NOW())", try {
[sessionId, userId, userAgent || null], await client.query("BEGIN");
); await client.query("SELECT id FROM users WHERE id = $1 FOR UPDATE", [userId]);
await pool.query( await client.query("DELETE FROM user_sessions WHERE user_id = $1", [userId]);
`DELETE FROM user_sessions await client.query(
WHERE user_id = $1 "INSERT INTO user_sessions (id, user_id, user_agent, created_at) VALUES ($1, $2, $3, NOW())",
AND id NOT IN ( [sessionId, userId, userAgent || null],
SELECT id FROM user_sessions );
WHERE user_id = $1 await client.query(
ORDER BY created_at DESC "UPDATE users SET current_session_id = $1, current_session_started_at = NOW(), updated_at = NOW() WHERE id = $2",
LIMIT $2 [sessionId, userId],
)`, );
[userId, MAX_CONCURRENT_SESSIONS], await client.query("COMMIT");
); } catch (error) {
await pool.query( await client.query("ROLLBACK");
"UPDATE users SET current_session_id = $1, current_session_started_at = NOW(), updated_at = NOW() WHERE id = $2", throw error;
[sessionId, userId], } finally {
); client.release();
}
return sessionId; return sessionId;
} }
+40 -25
View File
@@ -1,29 +1,46 @@
/** /**
* Billing module — handles balance deduction (in cents), package quotas, * Billing module — handles balance deduction, package quotas,
* transactions, and key-lease pre-authorization. * transactions, and key-lease pre-authorization.
* *
* Money conventions: * Unit conventions:
* - balance: cents (分, 1/100 CNY) — stored in users.balance_cents and enterprises.balance_cents * - payment_orders.amount_cents / packages.price_cents: cash amount in CNY cents.
* - prices: mills (厘, 1/1000 CNY) — stored in model_prices.*_mills * - users.balance_cents / enterprises.balance_cents / transactions.amount_cents:
* - cost calculation: mills → convert to cents at deduction time (divide by 10, floor) * credit units, where 100 units = 1 platform credit.
* - transactions: cents — amount_cents, balance_after_cents * - model_prices.*_mills: CNY mills. 1 CNY = 100 credits, so 1 mill = 10 credit units.
* *
* Flow: * Flow:
* - Enterprise admin recharges enterprise pool → distributes to employee users * - Enterprise admin recharges enterprise pool → distributes to employee users
* - API deductions come from users.balance_cents (per-user) * - API deductions come from users.balance_cents (per-user credit balance)
* - Personal users recharge their own users.balance_cents directly * - Personal users recharge their own users.balance_cents directly
*/ */
const { pool, withTransaction } = require("./db"); const { pool, withTransaction } = require("./db");
const { calculateCostMills, getModelPrice } = require("./pricing"); const { calculateCostMills, getModelPrice } = require("./pricing");
const IMAGE_GENERATION_FLAT_COST_CENTS = 20; const CREDIT_UNITS_PER_CREDIT = 100;
const CREDITS_PER_CNY = 100;
const CREDIT_UNITS_PER_CNY_CENT = 100;
const CREDIT_UNITS_PER_CNY_MILL = 10;
const IMAGE_GENERATION_FLAT_COST_CREDITS = 20;
const IMAGE_GENERATION_FLAT_COST_CENTS = IMAGE_GENERATION_FLAT_COST_CREDITS * CREDIT_UNITS_PER_CREDIT;
function creditsToCreditUnits(credits) {
return Math.max(0, Math.round(Number(credits || 0) * CREDIT_UNITS_PER_CREDIT));
}
function formatCreditsFromCents(amountCents) { function formatCreditsFromCents(amountCents) {
const value = Number(amountCents || 0) / 100; const value = Number(amountCents || 0) / CREDIT_UNITS_PER_CREDIT;
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2))); return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2)));
} }
function cashCentsToCreditUnits(amountCents) {
return Math.max(0, Math.round(Number(amountCents || 0) * CREDIT_UNITS_PER_CNY_CENT));
}
function millsToCreditUnits(mills) {
return Math.max(0, Math.round(Number(mills || 0) * CREDIT_UNITS_PER_CNY_MILL));
}
async function recordEnterpriseCreditLedger(client, entry) { async function recordEnterpriseCreditLedger(client, entry) {
const enterpriseId = entry?.enterpriseId || null; const enterpriseId = entry?.enterpriseId || null;
const userId = entry?.userId || null; const userId = entry?.userId || null;
@@ -114,10 +131,6 @@ async function getEnterpriseName(enterpriseId) {
return rows[0] ? rows[0].name : null; return rows[0] ? rows[0].name : null;
} }
function millsToCents(mills) {
return Math.floor(mills / 10);
}
// ── Atomic balance helpers ─────────────────────────────────────────── // ── Atomic balance helpers ───────────────────────────────────────────
async function atomicDeductUserBalance(client, userId, amountCents) { async function atomicDeductUserBalance(client, userId, amountCents) {
@@ -167,7 +180,7 @@ async function preauthorizeCall(userId, provider) {
const { rows } = await pool.query( const { rows } = await pool.query(
` `
SELECT COALESCE(CAST(ROUND(AVG(cost_estimate * 100)::numeric) AS INTEGER), 0) AS avg_cents SELECT COALESCE(CAST(ROUND(AVG(cost_estimate * 10000)::numeric) AS INTEGER), 0) AS avg_cents
FROM api_call_logs FROM api_call_logs
WHERE provider = $1 WHERE provider = $1
AND status = 'success' AND status = 'success'
@@ -185,10 +198,9 @@ async function preauthorizeCall(userId, provider) {
const bufferedEstimate = Math.ceil(estimatedCostCents * 1.2); const bufferedEstimate = Math.ceil(estimatedCostCents * 1.2);
if (balanceCents < bufferedEstimate) { if (balanceCents < bufferedEstimate) {
const credits = Math.floor(balanceCents / 100);
return { return {
authorized: false, authorized: false,
message: `账户积分不足,请充值 (当前 ${credits} 积分,预估需要 ${Math.ceil(bufferedEstimate / 100)} 积分)`, message: `账户积分不足,请充值 (当前 ${formatCreditsFromCents(balanceCents)} 积分,预估需要 ${formatCreditsFromCents(bufferedEstimate)} 积分)`,
}; };
} }
@@ -205,9 +217,9 @@ async function deductForApiCall(userId, model, promptTokens, completionTokens) {
return { success: true, costCents: 0, deductionType: "none", message: "No pricing" }; return { success: true, costCents: 0, deductionType: "none", message: "No pricing" };
} }
const costCents = millsToCents(costMills); const costCents = millsToCreditUnits(costMills);
if (costCents <= 0) { if (costCents <= 0) {
return { success: true, costCents: 0, deductionType: "none", message: "Cost below 1 cent" }; return { success: true, costCents: 0, deductionType: "none", message: "Cost below minimum credit unit" };
} }
const billingState = await getUserBillingState(userId); const billingState = await getUserBillingState(userId);
@@ -408,7 +420,7 @@ async function tryDeductFromUserBalance(userId, enterpriseId, amountCents, ledge
userId, userId,
-amountCents, -amountCents,
newBal, newBal,
`API 调用扣费 ${Math.ceil(amountCents / 100)} 积分`, `API 调用扣费 ${formatCreditsFromCents(amountCents)} 积分`,
], ],
); );
@@ -429,16 +441,15 @@ async function tryDeductFromUserBalance(userId, enterpriseId, amountCents, ledge
if (newBalanceCents == null) { if (newBalanceCents == null) {
const currentBalance = await getUserBalanceCents(userId); const currentBalance = await getUserBalanceCents(userId);
const credits = Math.floor((currentBalance || 0) / 100);
return { return {
success: false, success: false,
message: `积分不足 (当前 ${credits} 积分,需要 ${Math.ceil(amountCents / 100)} 积分)`, message: `积分不足 (当前 ${formatCreditsFromCents(currentBalance || 0)} 积分,需要 ${formatCreditsFromCents(amountCents)} 积分)`,
}; };
} }
return { return {
success: true, success: true,
message: `Deducted ${Math.ceil(amountCents / 100)} credits, balance: ${Math.floor(newBalanceCents / 100)} credits`, message: `Deducted ${formatCreditsFromCents(amountCents)} credits, balance: ${formatCreditsFromCents(newBalanceCents)} credits`,
}; };
} }
@@ -484,7 +495,7 @@ async function settleLease(leaseId, actualCostCents) {
userId, userId,
-diffCents, -diffCents,
newBal, newBal,
`API 预估差额扣费 ${Math.ceil(diffCents / 100)} 积分`, `API 预估差额扣费 ${formatCreditsFromCents(diffCents)} 积分`,
], ],
); );
} }
@@ -503,7 +514,7 @@ async function settleLease(leaseId, actualCostCents) {
userId, userId,
refundCents, refundCents,
newBal, newBal,
`API 预估差额退回 ${Math.ceil(refundCents / 100)} 积分`, `API 预估差额退回 ${formatCreditsFromCents(refundCents)} 积分`,
], ],
); );
} }
@@ -628,7 +639,7 @@ async function distributeCredits(enterpriseId, targetUserId, amountCents, adminU
targetUserId, targetUserId,
amountCents, amountCents,
newUserBal, newUserBal,
`从企业池获得 ${Math.floor(amountCents / 100)} 积分`, `从企业池获得 ${formatCreditsFromCents(amountCents)} 积分`,
adminUserId, adminUserId,
], ],
); );
@@ -761,4 +772,8 @@ module.exports = {
preauthorizeCall, preauthorizeCall,
settleLease, settleLease,
forceSettleLease, forceSettleLease,
creditsToCreditUnits,
cashCentsToCreditUnits,
millsToCreditUnits,
formatCreditsFromCents,
}; };
+20
View File
@@ -958,6 +958,7 @@ async function ensureSchema() {
await runMigration("029_task_poll_heartbeat", migrateTaskPollHeartbeat); await runMigration("029_task_poll_heartbeat", migrateTaskPollHeartbeat);
await runMigration("030_generation_tasks_user_status_index", migrateGenerationTasksUserStatusIndex); await runMigration("030_generation_tasks_user_status_index", migrateGenerationTasksUserStatusIndex);
await runMigration("031_generation_tasks_billing_columns", migrateGenerationTasksBillingColumns); await runMigration("031_generation_tasks_billing_columns", migrateGenerationTasksBillingColumns);
await runMigration("032_ecommerce_video_history", migrateEcommerceVideoHistorySchema);
await ensureModelPriceSeed(); await ensureModelPriceSeed();
} }
@@ -1105,3 +1106,22 @@ module.exports = {
hasColumn, hasColumn,
addColumnIfMissing, 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);
`);
}
+37 -2
View File
@@ -11,8 +11,17 @@ const ENTERPRISE_VIDEO_ALLOWED_MODELS = new Set([
"kling-3.0-dashscope", "kling-3.0-dashscope",
"kling-v3-omni-dashscope", "kling-v3-omni-dashscope",
"kling/kling-v3-omni-video-generation", "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",
]); ]);
const CREDITS_PER_CNY = 100;
const CREDIT_UNITS_PER_CREDIT = 100;
function normalizeModel(value) { function normalizeModel(value) {
return String(value || "").trim().toLowerCase(); return String(value || "").trim().toLowerCase();
} }
@@ -43,6 +52,8 @@ function isEnterpriseVideoModelAllowed(providerConfig, model) {
if (protocol === "wan-animate-mix") return true; if (protocol === "wan-animate-mix") return true;
if (protocol === "wan-s2v") return true; if (protocol === "wan-s2v") return true;
if (protocol === "kling-dashscope") return true; if (protocol === "kling-dashscope") return true;
if (protocol === "vidu") return true;
if (protocol === "pixverse") return true;
return false; return false;
} }
@@ -78,6 +89,14 @@ function getEnterpriseVideoCreditRate(input) {
return resolution === "720P" ? 0.9 : 1.2; 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}`); const error = new Error(`Unsupported enterprise video model: ${input.model || input.requestedModel}`);
error.status = 403; error.status = 403;
error.code = "ENTERPRISE_VIDEO_MODEL_NOT_ALLOWED"; error.code = "ENTERPRISE_VIDEO_MODEL_NOT_ALLOWED";
@@ -86,7 +105,7 @@ function getEnterpriseVideoCreditRate(input) {
function calculateEnterpriseVideoCredits(input) { function calculateEnterpriseVideoCredits(input) {
const duration = normalizeEnterpriseVideoDuration(input.durationSeconds || input.duration); const duration = normalizeEnterpriseVideoDuration(input.durationSeconds || input.duration);
return Number((getEnterpriseVideoCreditRate(input) * duration).toFixed(2)); return Number((getEnterpriseVideoCreditRate(input) * duration * CREDITS_PER_CNY).toFixed(2));
} }
function calculateEnterpriseVideoCost(input) { function calculateEnterpriseVideoCost(input) {
@@ -97,7 +116,7 @@ function calculateEnterpriseVideoCost(input) {
resolution, resolution,
durationSeconds, durationSeconds,
}); });
const rateCentsPerSecond = Math.round(rateCreditsPerSecond * 100); const rateCentsPerSecond = Math.round(rateCreditsPerSecond * CREDITS_PER_CNY * CREDIT_UNITS_PER_CREDIT);
return { return {
resolution, resolution,
durationSeconds, durationSeconds,
@@ -194,6 +213,21 @@ async function markEnterpriseVideoCreditsAccepted(clientOrPool, creditLedgerId)
return rowCount > 0; 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 = { module.exports = {
ENTERPRISE_VIDEO_ALLOWED_MODELS, ENTERPRISE_VIDEO_ALLOWED_MODELS,
assertEnterpriseVideoModelAllowed, assertEnterpriseVideoModelAllowed,
@@ -206,5 +240,6 @@ module.exports = {
normalizeEnterpriseVideoDuration, normalizeEnterpriseVideoDuration,
normalizeEnterpriseVideoResolution, normalizeEnterpriseVideoResolution,
prepareEnterpriseVideoBilling, prepareEnterpriseVideoBilling,
refundEnterpriseVideoCredits,
reserveEnterpriseVideoCredits, reserveEnterpriseVideoCredits,
}; };
+45 -2
View File
@@ -16,6 +16,7 @@ const routes = require('./routes')
const PORT = Number(process.env.PORT) || 3600 const PORT = Number(process.env.PORT) || 3600
const HOST = process.env.HOST || '0.0.0.0' const HOST = process.env.HOST || '0.0.0.0'
const IS_PRODUCTION = process.env.NODE_ENV === 'production' const IS_PRODUCTION = process.env.NODE_ENV === 'production'
let server = null
// CORS: in production, require explicit allowlist; in dev, allow all with credentials // CORS: in production, require explicit allowlist; in dev, allow all with credentials
function buildCorsOptions() { function buildCorsOptions() {
@@ -103,6 +104,7 @@ async function main() {
// Skip JSON body-parser for binary upload routes (busboy handles multipart parsing) // 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/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) // JSON body limit: 5MB globally (upload routes override locally)
app.use('/api/oss/upload', express.json({ limit: '200mb' })) app.use('/api/oss/upload', express.json({ limit: '200mb' }))
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '5mb' })) app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '5mb' }))
@@ -142,10 +144,12 @@ async function main() {
startSettlementWorker() startSettlementWorker()
startProviderHealthMonitor() startProviderHealthMonitor()
const { startStaleTaskCleanup } = require('./aiTaskWorker') const { startStaleTaskCleanup, startTaskEventListener, startPollerRecovery } = require('./aiTaskWorker')
await startTaskEventListener()
startPollerRecovery()
startStaleTaskCleanup() startStaleTaskCleanup()
app.listen(PORT, HOST, () => { server = app.listen(PORT, HOST, () => {
console.log(`OmniAI Key Server running at http://${HOST}:${PORT}`) console.log(`OmniAI Key Server running at http://${HOST}:${PORT}`)
console.log(`Health check: http://${HOST}:${PORT}/api/health`) console.log(`Health check: http://${HOST}:${PORT}/api/health`)
}) })
@@ -158,9 +162,48 @@ main().catch((err) => {
process.on('unhandledRejection', (reason) => { process.on('unhandledRejection', (reason) => {
console.error('[fatal] Unhandled promise rejection:', reason) console.error('[fatal] Unhandled promise rejection:', reason)
process.exitCode = 1
setTimeout(() => process.exit(1), 5000).unref()
}) })
process.on('uncaughtException', (err) => { process.on('uncaughtException', (err) => {
console.error('[fatal] Uncaught exception:', err) console.error('[fatal] Uncaught exception:', err)
process.exit(1) 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 { stopTaskEventListener, stopPollerRecovery } = require('./aiTaskWorker')
stopPollerRecovery()
void stopTaskEventListener()
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'))
+21 -1
View File
@@ -111,4 +111,24 @@ async function putObject(objectKey, body, contentType = "application/octet-strea
return { ossKey: objectKey, url }; 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)}`);
}
}
+7 -5
View File
@@ -1,7 +1,7 @@
const fs = require("node:fs"); const fs = require("node:fs");
const { AlipaySdk } = require("alipay-sdk"); const { AlipaySdk } = require("alipay-sdk");
const { pool, withTransaction } = require("./db"); const { pool, withTransaction } = require("./db");
const { creditBalance, creditUserBalance, activatePackage } = require("./billing"); const { cashCentsToCreditUnits, creditBalance, creditUserBalance, activatePackage, formatCreditsFromCents } = require("./billing");
let alipayInstance = null; let alipayInstance = null;
@@ -130,17 +130,19 @@ async function handlePaymentSuccess(orderNo, tradeNo) {
); );
if (order.type === "personal_recharge" && order.user_id) { if (order.type === "personal_recharge" && order.user_id) {
const creditUnits = cashCentsToCreditUnits(order.amount_cents);
await creditUserBalance( await creditUserBalance(
order.user_id, order.user_id,
order.amount_cents, creditUnits,
`支付宝充值 ${Math.floor(order.amount_cents / 100)} 积分`, `支付宝充值 ${formatCreditsFromCents(creditUnits)} 积分`,
orderNo, orderNo,
); );
} else if (order.type === "recharge") { } else if (order.type === "recharge") {
const creditUnits = cashCentsToCreditUnits(order.amount_cents);
await creditBalance( await creditBalance(
order.enterprise_id, order.enterprise_id,
order.amount_cents, creditUnits,
`支付宝充值 ${Math.floor(order.amount_cents / 100)} 积分`, `支付宝充值 ${formatCreditsFromCents(creditUnits)} 积分`,
orderNo, orderNo,
); );
} else if (order.type === "package" && order.package_id) { } else if (order.type === "package" && order.package_id) {
+7 -5
View File
@@ -2,7 +2,7 @@ const _crypto = require("node:crypto");
const fs = require("node:fs"); const fs = require("node:fs");
const WxPay = require("wechatpay-node-v3"); const WxPay = require("wechatpay-node-v3");
const { pool, withTransaction } = require("./db"); const { pool, withTransaction } = require("./db");
const { creditBalance, creditUserBalance, activatePackage } = require("./billing"); const { cashCentsToCreditUnits, creditBalance, creditUserBalance, activatePackage, formatCreditsFromCents } = require("./billing");
let wxPayInstance = null; let wxPayInstance = null;
@@ -140,17 +140,19 @@ async function handlePaymentSuccess(orderNo, transactionId) {
); );
if (order.type === "personal_recharge" && order.user_id) { if (order.type === "personal_recharge" && order.user_id) {
const creditUnits = cashCentsToCreditUnits(order.amount_cents);
await creditUserBalance( await creditUserBalance(
order.user_id, order.user_id,
order.amount_cents, creditUnits,
`微信充值 ${Math.floor(order.amount_cents / 100)} 积分`, `微信充值 ${formatCreditsFromCents(creditUnits)} 积分`,
orderNo, orderNo,
); );
} else if (order.type === "recharge") { } else if (order.type === "recharge") {
const creditUnits = cashCentsToCreditUnits(order.amount_cents);
await creditBalance( await creditBalance(
order.enterprise_id, order.enterprise_id,
order.amount_cents, creditUnits,
`微信充值 ${Math.floor(order.amount_cents / 100)} 积分`, `微信充值 ${formatCreditsFromCents(creditUnits)} 积分`,
orderNo, orderNo,
); );
} else if (order.type === "package" && order.package_id) { } else if (order.type === "package" && order.package_id) {
+1 -1
View File
@@ -176,7 +176,7 @@ async function getAverageCostCents(provider) {
const { rows } = await pool.query( const { rows } = await pool.query(
` `
SELECT CAST(ROUND(AVG(CASE SELECT CAST(ROUND(AVG(CASE
WHEN cost_estimate IS NOT NULL THEN cost_estimate * 100 WHEN cost_estimate IS NOT NULL THEN cost_estimate * 10000
ELSE 0 ELSE 0
END)::numeric) AS INTEGER) AS avg_cents END)::numeric) AS INTEGER) AS avg_cents
FROM api_call_logs FROM api_call_logs
+21 -6
View File
@@ -6,6 +6,8 @@ const {
listModelPrices, listModelPrices,
loadPriceCache, loadPriceCache,
creditUserBalance, creditUserBalance,
creditsToCreditUnits,
formatCreditsFromCents,
pool, pool,
validateUsername, validateUsername,
validatePassword, validatePassword,
@@ -156,14 +158,18 @@ function registerAdminRoutes(router) {
router.post("/admin/users/:id/credit", requireAuth, requireAdmin, async (req, res) => { router.post("/admin/users/:id/credit", requireAuth, requireAdmin, async (req, res) => {
const targetUserId = Number(req.params.id); const targetUserId = Number(req.params.id);
const { amountCents } = req.body; const { amountCredits, amountCents } = req.body;
if (!amountCents || amountCents <= 0) return res.status(400).json({ error: "积分必须大于 0" }); const creditUnits =
amountCredits !== undefined && amountCredits !== null && amountCredits !== ""
? creditsToCreditUnits(amountCredits)
: Number(amountCents);
if (!creditUnits || creditUnits <= 0) return res.status(400).json({ error: "积分必须大于 0" });
try { try {
const newBalance = await creditUserBalance( const newBalance = await creditUserBalance(
targetUserId, targetUserId,
amountCents, creditUnits,
`管理员 ${req.user.username} 发放 ${Math.floor(amountCents / 100)} 积分`, `管理员 ${req.user.username} 发放 ${formatCreditsFromCents(creditUnits)} 积分`,
); );
res.json({ success: true, newBalanceCents: newBalance }); res.json({ success: true, newBalanceCents: newBalance });
} catch (err) { } catch (err) {
@@ -547,6 +553,8 @@ function registerAdminRoutes(router) {
name, name,
description = "", description = "",
priceCents, priceCents,
credits,
amountCredits,
creditsCents = 0, creditsCents = 0,
imageQuota = 0, imageQuota = 0,
videoQuota = 0, videoQuota = 0,
@@ -572,7 +580,9 @@ function registerAdminRoutes(router) {
name, name,
description, description,
Number(priceCents), Number(priceCents),
Number(creditsCents || 0), credits !== undefined || amountCredits !== undefined
? creditsToCreditUnits(credits ?? amountCredits)
: Number(creditsCents || 0),
Number(imageQuota || 0), Number(imageQuota || 0),
Number(videoQuota || 0), Number(videoQuota || 0),
Number(textQuota || 0), Number(textQuota || 0),
@@ -599,6 +609,8 @@ function registerAdminRoutes(router) {
name, name,
description, description,
priceCents, priceCents,
credits,
amountCredits,
creditsCents, creditsCents,
imageQuota, imageQuota,
videoQuota, videoQuota,
@@ -623,7 +635,10 @@ function registerAdminRoutes(router) {
updates.push(`price_cents = $${idx++}`); updates.push(`price_cents = $${idx++}`);
params.push(Number(priceCents)); params.push(Number(priceCents));
} }
if (creditsCents !== undefined) { if (credits !== undefined || amountCredits !== undefined) {
updates.push(`credits_cents = $${idx++}`);
params.push(creditsToCreditUnits(credits ?? amountCredits));
} else if (creditsCents !== undefined) {
updates.push(`credits_cents = $${idx++}`); updates.push(`credits_cents = $${idx++}`);
params.push(Number(creditsCents)); params.push(Number(creditsCents));
} }
+42 -18
View File
@@ -97,6 +97,18 @@ function clampImageQualityForModel(model, quality) {
return normalized; return normalized;
} }
function isDashscopeWan27Limited2KScene(params) {
const model = String(params?.model || "").toLowerCase();
if (model !== "wan2.7-image-pro") return false;
const hasReferenceImages = Array.isArray(params.referenceUrls) && params.referenceUrls.some(Boolean);
return hasReferenceImages || getGridCount(params.gridMode) > 1;
}
function resolveDashscopeImageQuality(params) {
const quality = clampImageQualityForModel(params.model, params.quality);
return isDashscopeWan27Limited2KScene(params) && quality === "4K" ? "2K" : quality;
}
function clampGrsaiImageQualityForModel(model, quality) { function clampGrsaiImageQualityForModel(model, quality) {
const normalized = normalizeQuality(quality, "1K"); const normalized = normalizeQuality(quality, "1K");
const maxQuality = GRSAI_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase()); const maxQuality = GRSAI_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase());
@@ -334,18 +346,25 @@ async function assertUserGenerationConcurrencyLimit(userId, client = pool) {
[userId], [userId],
); );
const { rows: limitRows } = await client.query(
"SELECT max_concurrency FROM users WHERE id = $1",
[userId],
);
const rawLimit = Number(limitRows[0]?.max_concurrency);
const concurrencyLimit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : MAX_USER_ACTIVE_GENERATION_TASKS;
const { rows } = await client.query( const { rows } = await client.query(
"SELECT COUNT(*)::int AS active_count FROM generation_tasks WHERE user_id = $1 AND status IN ('pending', 'running')", "SELECT COUNT(*)::int AS active_count FROM generation_tasks WHERE user_id = $1 AND status IN ('pending', 'running')",
[userId], [userId],
); );
const activeCount = Number(rows[0]?.active_count ?? rows[0]?.count ?? 0); const activeCount = Number(rows[0]?.active_count ?? rows[0]?.count ?? 0);
if (activeCount < MAX_USER_ACTIVE_GENERATION_TASKS) return; if (activeCount < concurrencyLimit) return;
const error = new Error(GENERATION_CONCURRENCY_LIMIT_MESSAGE); const error = new Error(`最多只能同时进行${concurrencyLimit}个任务`);
error.status = 429; error.status = 429;
error.code = "GENERATION_CONCURRENCY_LIMIT"; error.code = "GENERATION_CONCURRENCY_LIMIT";
error.activeCount = activeCount; error.activeCount = activeCount;
error.maxActiveTasks = MAX_USER_ACTIVE_GENERATION_TASKS; error.maxActiveTasks = concurrencyLimit;
throw error; throw error;
} }
@@ -469,17 +488,22 @@ function buildDashscopeImageBody(params) {
if (url) content.push({ image: url }); if (url) content.push({ image: url });
} }
content.push({ text: params.prompt }); content.push({ text: params.prompt });
const quality = clampImageQualityForModel(params.model, params.quality); const quality = resolveDashscopeImageQuality(params);
const gridCount = getGridCount(params.gridMode);
const parameters = {
size: mapAspectRatioToDashscopeSize(params.ratio, quality),
n: gridCount,
watermark: false,
};
if (gridCount > 1) {
parameters.enable_sequential = true;
}
return { return {
model: params.model, model: params.model,
input: { input: {
messages: [{ role: "user", content }], messages: [{ role: "user", content }],
}, },
parameters: { parameters,
size: mapAspectRatioToDashscopeSize(params.ratio, quality),
n: params.gridMode === "grid-4" ? 4 : params.gridMode === "grid-9" ? 9 : 1,
watermark: false,
},
}; };
} }
@@ -1495,7 +1519,7 @@ function registerAiRoutes(router) {
res.flushHeaders(); res.flushHeaders();
const abortController = new AbortController(); const abortController = new AbortController();
const streamTimer = setTimeout(() => abortController.abort(), 60000); const streamTimer = setTimeout(() => abortController.abort(), 120000);
req.on("close", () => { clearTimeout(streamTimer); abortController.abort(); }); req.on("close", () => { clearTimeout(streamTimer); abortController.abort(); });
try { try {
@@ -1554,7 +1578,7 @@ function registerAiRoutes(router) {
} }
} else { } else {
const nonStreamAbort = new AbortController(); 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 }); const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody, signal: nonStreamAbort.signal });
clearTimeout(nonStreamTimer); clearTimeout(nonStreamTimer);
const text = await upstream.text().catch(() => ""); const text = await upstream.text().catch(() => "");
@@ -1578,7 +1602,7 @@ function registerAiRoutes(router) {
const fallbackHeaders = { "Content-Type": "application/json", Authorization: "Bearer " + slotResult.apiKey }; 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 fallbackBody = JSON.stringify({ model: fallbackConfig.model, messages, stream: false, temperature: temperature || 0.7, max_tokens: 4096 });
const fbAbort = new AbortController(); 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 }); const fbUpstream = await fetch(fallbackUrl, { method: "POST", headers: fallbackHeaders, body: fallbackBody, signal: fbAbort.signal });
clearTimeout(fbTimer); clearTimeout(fbTimer);
const fbText = await fbUpstream.text().catch(() => ""); const fbText = await fbUpstream.text().catch(() => "");
@@ -1609,7 +1633,7 @@ function registerAiRoutes(router) {
} catch (err) { } catch (err) {
releaseLease(slotResult); releaseLease(slotResult);
console.error("[ai/chat] error:", err.message); 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 +1714,7 @@ function registerAiRoutes(router) {
res.json({ taskId: String(rows[0].id), conversationId: rows[0].conversation_id }); res.json({ taskId: String(rows[0].id), conversationId: rows[0].conversation_id });
} catch (err) { } 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 +1738,7 @@ function registerAiRoutes(router) {
res.json(formatAiTaskRow(rows[0])); res.json(formatAiTaskRow(rows[0]));
} catch (err) { } 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 +1785,7 @@ function registerAiRoutes(router) {
taskEvents.off(`task:${taskId}`, onUpdate); taskEvents.off(`task:${taskId}`, onUpdate);
}); });
} catch (err) { } 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 +1850,7 @@ function registerAiRoutes(router) {
res.end(buffer); res.end(buffer);
} catch (err) { } catch (err) {
console.error("[ai/tasks/download] failed:", err.message); 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 +1878,7 @@ function registerAiRoutes(router) {
res.end(buffer); res.end(buffer);
} catch (err) { } catch (err) {
console.error("[ai/proxy-download] failed:", err.message); 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 });
} }
}); });
} }
+146
View File
@@ -30,6 +30,13 @@ const {
buildOssPublicUrl, buildOssPublicUrl,
normalizeAvatarOssKey, normalizeAvatarOssKey,
normalizeProfileMediaUrl, normalizeProfileMediaUrl,
EMAIL_PURPOSES,
EMAIL_CODE_TTL_MINUTES,
EMAIL_CODE_COOLDOWN_SECONDS,
EMAIL_CODE_MAX_ATTEMPTS,
hashEmailCode,
sendEmailCode,
consumeEmailCode,
} = require("./context"); } = require("./context");
const { const {
checkBetaInviteCodeForRegistration, checkBetaInviteCodeForRegistration,
@@ -208,15 +215,20 @@ function registerAuthRoutes(router) {
const email = normalizeEmail(req.body?.email); const email = normalizeEmail(req.body?.email);
const usernameInput = String(req.body?.username || "").trim(); const usernameInput = String(req.body?.username || "").trim();
const password = String(req.body?.password || ""); const password = String(req.body?.password || "");
const code = String(req.body?.code || "").trim();
const emailError = validateEmail(email); const emailError = validateEmail(email);
if (emailError) return res.status(400).json({ error: emailError }); if (emailError) return res.status(400).json({ error: emailError });
if (!code) return res.status(400).json({ error: "缺少验证码" });
const passwordError = validatePassword(password); const passwordError = validatePassword(password);
if (passwordError) return res.status(400).json({ error: passwordError }); if (passwordError) return res.status(400).json({ error: passwordError });
const registrationInvite = await ensureRegistrationInvite(req, res); const registrationInvite = await ensureRegistrationInvite(req, res);
if (!registrationInvite) return; if (!registrationInvite) return;
try { try {
const verified = await consumeEmailCode(email, code, "register");
if (!verified) return res.status(400).json({ error: "验证码错误或已过期" });
const { rows: existingEmail } = await pool.query( const { rows: existingEmail } = await pool.query(
"SELECT id FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1", "SELECT id FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1",
[email], [email],
@@ -751,6 +763,140 @@ function registerAuthRoutes(router) {
res.status(500).json({ error: "更新个人资料失败" }); 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 = { module.exports = {
+519
View File
@@ -0,0 +1,519 @@
"use strict";
const { getUserContextById, requireAuth, verifyToken } = require("../auth");
const { pool, withTransaction } = require("../db");
const { loadBetaInviteCodes, normalizeBetaInviteCode } = require("../betaInviteCodes");
const REVIEW_USERNAMES = new Set(["xqy1912"]);
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function cleanText(value, maxLength) {
return String(value || "").trim().slice(0, maxLength);
}
function cleanTextArray(value, maxItems = 20, maxLength = 200) {
if (!Array.isArray(value)) return [];
return value.map((item) => cleanText(item, maxLength)).filter(Boolean).slice(0, maxItems);
}
function normalizeEmail(email) {
return String(email || "").trim().toLowerCase();
}
function validateEmail(email) {
const normalized = normalizeEmail(email);
if (!normalized) return "请填写用于接收内测码的邮箱";
if (!EMAIL_PATTERN.test(normalized)) return "邮箱格式不正确";
return null;
}
function parseJson(value, fallback) {
if (!value || typeof value !== "string") return fallback;
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function safeJsonString(value, fallback) {
try {
return JSON.stringify(value ?? fallback);
} catch {
return JSON.stringify(fallback);
}
}
function buildSmtpTransportOptions(scope) {
const prefix = scope ? `${scope}_` : "";
return {
host: process.env[`${prefix}SMTP_HOST`] || process.env.SMTP_HOST,
port: Number(process.env[`${prefix}SMTP_PORT`] || process.env.SMTP_PORT) || 587,
secure: String(process.env[`${prefix}SMTP_SECURE`] || process.env.SMTP_SECURE || "") === "1",
auth: {
user: process.env[`${prefix}SMTP_USER`] || process.env.SMTP_USER,
pass: process.env[`${prefix}SMTP_PASS`] || process.env.SMTP_PASS,
},
};
}
function formatEmailAddress(address, displayName) {
const email = String(address || "").trim();
const name = String(displayName || "").trim();
if (!name) return email;
const escapedName = name.replace(/"/g, '\\"');
return `"${escapedName}" <${email}>`;
}
function getRequestIp(req) {
const forwardedFor = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim();
return forwardedFor || req.socket?.remoteAddress || "";
}
async function optionalAuth(req, _res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
next();
return;
}
try {
const payload = verifyToken(authHeader.slice(7));
const user = await getUserContextById(payload.userId);
if (user?.enabled) req.user = user;
} catch {
// Public application submission should still work without a valid session.
}
next();
}
function canReviewBetaApplications(user) {
if (!user) return false;
const role = String(user.role || "").trim().toLowerCase();
const username = String(user.username || "").trim().toLowerCase();
return role === "admin" || REVIEW_USERNAMES.has(username);
}
function requireBetaApplicationReviewer(req, res, next) {
if (!canReviewBetaApplications(req.user)) {
return res.status(403).json({ error: "无权审核内测申请" });
}
next();
}
async function ensureBetaApplicationSchema() {
await pool.query(`
CREATE TABLE IF NOT EXISTS beta_applications (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
name TEXT,
email TEXT,
phone TEXT,
wechat TEXT,
industry TEXT,
company TEXT,
city TEXT,
ai_tools TEXT,
ai_duration TEXT,
ai_track TEXT,
ai_direction_json TEXT NOT NULL DEFAULT '[]',
weekly_usage TEXT,
feedback_willing TEXT,
want_feature_json TEXT NOT NULL DEFAULT '[]',
self_statement TEXT,
signature TEXT,
application_date TEXT,
agree_rules INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
invite_code TEXT,
review_note TEXT,
reviewed_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
reviewed_at TIMESTAMPTZ,
ip_address TEXT,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_beta_applications_status_created
ON beta_applications(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_beta_applications_user_created
ON beta_applications(user_id, created_at DESC);
ALTER TABLE beta_applications
ADD COLUMN IF NOT EXISTS email TEXT;
ALTER TABLE beta_applications
ADD COLUMN IF NOT EXISTS application_date TEXT;
CREATE INDEX IF NOT EXISTS idx_beta_applications_email
ON beta_applications(LOWER(email));
`);
}
function normalizeApplicationBody(body) {
return {
name: cleanText(body?.name, 120),
email: normalizeEmail(body?.email),
phone: cleanText(body?.phone, 60),
wechat: cleanText(body?.wechat, 120),
industry: cleanText(body?.industry, 160),
company: cleanText(body?.company, 200),
city: cleanText(body?.city, 120),
aiTools: cleanText(body?.aiTools ?? body?.ai_tools, 1000),
aiDuration: cleanText(body?.aiDuration ?? body?.ai_duration, 120),
aiTrack: cleanText(body?.aiTrack ?? body?.ai_track, 160),
aiDirection: cleanTextArray(body?.aiDirection ?? body?.ai_direction),
weeklyUsage: cleanText(body?.weeklyUsage ?? body?.weekly_usage, 120),
feedbackWilling: cleanText(body?.feedbackWilling ?? body?.feedback_willing, 160),
wantFeature: cleanTextArray(body?.wantFeature ?? body?.want_feature),
selfStatement: cleanText(body?.selfStatement ?? body?.self_statement, 5000),
signature: cleanText(body?.signature, 120),
applicationDate: cleanText(body?.applicationDate ?? body?.application_date, 120),
agreeRules: body?.agreeRules === true || body?.agree_rules === true || body?.agreeRules === 1 || body?.agree_rules === 1,
};
}
function formatApplication(row) {
return {
id: Number(row.id),
userId: row.user_id == null ? null : Number(row.user_id),
username: row.username || null,
name: row.name || "",
email: row.email || "",
phone: row.phone || "",
wechat: row.wechat || "",
industry: row.industry || "",
company: row.company || "",
city: row.city || "",
aiTools: row.ai_tools || "",
aiDuration: row.ai_duration || "",
aiTrack: row.ai_track || "",
aiDirection: parseJson(row.ai_direction_json, []),
weeklyUsage: row.weekly_usage || "",
feedbackWilling: row.feedback_willing || "",
wantFeature: parseJson(row.want_feature_json, []),
selfStatement: row.self_statement || "",
signature: row.signature || "",
applicationDate: row.application_date || "",
agreeRules: Boolean(row.agree_rules),
status: row.status || "pending",
inviteCode: row.invite_code || null,
reviewNote: row.review_note || null,
reviewedBy: row.reviewed_by == null ? null : Number(row.reviewed_by),
reviewerUsername: row.reviewer_username || null,
reviewedAt: row.reviewed_at || null,
ipAddress: row.ip_address || null,
userAgent: row.user_agent || null,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
async function selectApplicationById(client, id) {
const { rows } = await client.query(
`
SELECT a.*, u.username, reviewer.username AS reviewer_username
FROM beta_applications a
LEFT JOIN users u ON u.id = a.user_id
LEFT JOIN users reviewer ON reviewer.id = a.reviewed_by
WHERE a.id = $1
LIMIT 1
`,
[id],
);
return rows[0] || null;
}
async function issueNextBetaInviteCode(client) {
const codes = Array.from(loadBetaInviteCodes()).map(normalizeBetaInviteCode).filter(Boolean).sort();
for (const code of codes) {
const { rows } = await client.query(
`
SELECT 1
FROM beta_invite_code_uses
WHERE code = $1
UNION ALL
SELECT 1
FROM beta_applications
WHERE invite_code = $1 AND status = 'approved'
LIMIT 1
`,
[code],
);
if (rows.length === 0) return code;
}
return null;
}
async function createNotification(client, userId, input) {
if (!userId) return;
await client.query(
`
INSERT INTO web_notifications (
user_id, type, title, description, target_type, target_id, metadata_json
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`,
[
userId,
input.type,
input.title,
input.description || null,
input.targetType || "beta_application",
input.targetId ? String(input.targetId) : null,
safeJsonString(input.metadata, {}),
],
);
}
function buildReviewEmailContent(application, action, inviteCode, reviewNote) {
const name = application.name || "内测申请人";
if (action === "approve") {
const text = [
`${name},您好:`,
"",
"您的 OmniAI 内测申请已通过。",
`内测码:${inviteCode}`,
"",
"请在注册页面填写该内测码完成账号注册。内测码仅限本人使用,请勿转发。",
"",
"OmniAI 团队",
].join("\n");
const html = `
<div style="font-family:Arial,'Microsoft YaHei',sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#222">
<h2 style="margin:0 0 16px;color:#166534">OmniAI 内测申请已通过</h2>
<p>${name},您好:</p>
<p>您的 OmniAI 内测申请已通过。</p>
<p style="padding:14px 16px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;font-size:20px;font-weight:700;letter-spacing:1px;color:#166534">内测码:${inviteCode}</p>
<p>请在注册页面填写该内测码完成账号注册。内测码仅限本人使用,请勿转发。</p>
<p style="margin-top:24px;color:#666">OmniAI 团队</p>
</div>
`;
return { subject: "[OmniAI] 内测申请已通过", text, html };
}
const reason = reviewNote || "很遗憾,您的内测申请暂未通过。";
const text = [
`${name},您好:`,
"",
"您未通过 OmniAI 内测申请。",
`审核备注:${reason}`,
"",
"感谢您的关注。",
"",
"OmniAI 团队",
].join("\n");
const html = `
<div style="font-family:Arial,'Microsoft YaHei',sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#222">
<h2 style="margin:0 0 16px;color:#991b1b">OmniAI 内测申请未通过</h2>
<p>${name},您好:</p>
<p>您未通过 OmniAI 内测申请。</p>
<p style="padding:12px 14px;background:#fef2f2;border:1px solid #fecaca;border-radius:8px;color:#7f1d1d">审核备注:${reason}</p>
<p>感谢您的关注。</p>
<p style="margin-top:24px;color:#666">OmniAI 团队</p>
</div>
`;
return { subject: "[OmniAI] 内测申请未通过", text, html };
}
async function sendBetaApplicationReviewEmail(application, action, inviteCode, reviewNote) {
const email = normalizeEmail(application.email);
const emailError = validateEmail(email);
if (emailError) {
const err = new Error(`申请邮箱无效,无法发送审核结果:${emailError}`);
err.status = 409;
throw err;
}
const provider = String(process.env.EMAIL_PROVIDER || "mock").trim().toLowerCase();
const content = buildReviewEmailContent(application, action, inviteCode, reviewNote);
if (provider === "smtp") {
const nodemailer = require("nodemailer");
const smtpOptions = buildSmtpTransportOptions("BETA");
const transporter = nodemailer.createTransport(smtpOptions);
const fromAddress = process.env.BETA_SMTP_FROM || process.env.SMTP_FROM || smtpOptions.auth.user;
const fromName = process.env.BETA_SMTP_FROM_NAME || process.env.SMTP_FROM_NAME || "万物可爱";
await transporter.sendMail({
from: formatEmailAddress(fromAddress, fromName),
to: email,
subject: content.subject,
text: content.text,
html: content.html,
});
return { provider: "smtp" };
}
console.log(`[beta-application-email:${action}] ${email} ${content.subject}`);
return { provider: "mock" };
}
function registerBetaApplicationRoutes(router) {
router.post("/beta-applications", optionalAuth, async (req, res) => {
try {
await ensureBetaApplicationSchema();
const app = normalizeApplicationBody(req.body);
const emailError = validateEmail(app.email);
if (!app.name || emailError || !app.phone || !app.wechat || !app.selfStatement || !app.signature || !app.applicationDate || !app.agreeRules) {
return res.status(400).json({ error: emailError || "请填写姓名、手机号、微信、申请自述、签名、申请日期并同意内测规则" });
}
const { rows } = await pool.query(
`
INSERT INTO beta_applications (
user_id, name, email, phone, wechat, industry, company, city,
ai_tools, ai_duration, ai_track, ai_direction_json,
weekly_usage, feedback_willing, want_feature_json,
self_statement, signature, application_date, agree_rules, ip_address, user_agent
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
RETURNING id, status, created_at
`,
[
req.user?.id || null,
app.name,
app.email,
app.phone,
app.wechat,
app.industry || null,
app.company || null,
app.city || null,
app.aiTools || null,
app.aiDuration || null,
app.aiTrack || null,
safeJsonString(app.aiDirection, []),
app.weeklyUsage || null,
app.feedbackWilling || null,
safeJsonString(app.wantFeature, []),
app.selfStatement,
app.signature,
app.applicationDate,
app.agreeRules ? 1 : 0,
getRequestIp(req),
cleanText(req.headers["user-agent"], 1000) || null,
],
);
res.status(201).json({
application: {
id: rows[0].id,
status: rows[0].status,
createdAt: rows[0].created_at,
},
});
} catch (err) {
console.error("[beta-applications] create failed:", err.message);
res.status(500).json({ error: "提交内测申请失败" });
}
});
router.get("/admin/beta-applications", requireAuth, requireBetaApplicationReviewer, async (req, res) => {
try {
await ensureBetaApplicationSchema();
const status = cleanText(req.query.status, 32);
const params = [];
const where = [];
if (status) {
params.push(status);
where.push(`a.status = $${params.length}`);
}
const { rows } = await pool.query(
`
SELECT a.*, u.username, reviewer.username AS reviewer_username
FROM beta_applications a
LEFT JOIN users u ON u.id = a.user_id
LEFT JOIN users reviewer ON reviewer.id = a.reviewed_by
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
ORDER BY
CASE a.status WHEN 'pending' THEN 0 WHEN 'approved' THEN 1 ELSE 2 END,
a.created_at DESC
LIMIT 300
`,
params,
);
res.json({ applications: rows.map(formatApplication) });
} catch (err) {
console.error("[admin/beta-applications] list failed:", err.message);
res.status(500).json({ error: "读取内测申请失败" });
}
});
router.patch("/admin/beta-applications/:id", requireAuth, requireBetaApplicationReviewer, async (req, res) => {
const id = Number(req.params.id);
const action = cleanText(req.body?.action, 32);
const reviewNote = cleanText(req.body?.reviewNote ?? req.body?.review_note, 1000) || null;
if (!Number.isFinite(id)) return res.status(400).json({ error: "申请 ID 不正确" });
if (action !== "approve" && action !== "reject") return res.status(400).json({ error: "审核动作不正确" });
try {
await ensureBetaApplicationSchema();
const application = await withTransaction(async (client) => {
const current = await selectApplicationById(client, id);
if (!current) {
const err = new Error("申请不存在");
err.status = 404;
throw err;
}
if (current.status !== "pending") {
const err = new Error("该申请已审核");
err.status = 409;
throw err;
}
let inviteCode = null;
if (action === "approve") {
inviteCode = await issueNextBetaInviteCode(client);
if (!inviteCode) {
const err = new Error("暂无可用内测码,请先补充内测码");
err.status = 409;
throw err;
}
}
const { rows } = await client.query(
`
UPDATE beta_applications
SET status = $1,
invite_code = $2,
review_note = $3,
reviewed_by = $4,
reviewed_at = NOW(),
updated_at = NOW()
WHERE id = $5
RETURNING *
`,
[action === "approve" ? "approved" : "rejected", inviteCode, reviewNote, req.user.id, id],
);
const updated = rows[0];
await sendBetaApplicationReviewEmail(updated, action, inviteCode, reviewNote);
if (updated.user_id) {
if (action === "approve") {
await createNotification(client, updated.user_id, {
type: "review_passed",
title: "内测申请已通过",
description: `您的内测申请已通过,内测码:${inviteCode}`,
targetId: updated.id,
metadata: { inviteCode },
});
} else {
await createNotification(client, updated.user_id, {
type: "review_rejected",
title: "您未通过内测申请",
description: reviewNote || "很遗憾,您的内测申请暂未通过。",
targetId: updated.id,
});
}
}
return selectApplicationById(client, id);
});
res.json({ application: formatApplication(application) });
} catch (err) {
const status = Number(err.status || 500);
if (status >= 400 && status < 500) return res.status(status).json({ error: err.message });
console.error("[admin/beta-applications] review failed:", err.message);
res.status(500).json({ error: "审核内测申请失败" });
}
});
}
module.exports = { registerBetaApplicationRoutes, canReviewBetaApplications };
+96
View File
@@ -0,0 +1,96 @@
const express = require("express");
const { requireAuth, requireAdmin } = require("../auth");
const { pool } = require("../db");
const { creditUserBalance } = require("../billing");
const router = express.Router();
router.post("/bug-feedback", requireAuth, async (req, res) => {
const userId = req.user.id;
const { title, description, screenshotUrl } = req.body;
if (!title || String(title).trim().length === 0) return res.status(400).json({ error: "标题不能为空" });
if (!description || String(description).trim().length === 0) return res.status(400).json({ error: "描述不能为空" });
if (String(title).length > 200) return res.status(400).json({ error: "标题不能超过200字" });
if (String(description).length > 5000) return res.status(400).json({ error: "描述不能超过5000字" });
try {
const result = await pool.query(
`INSERT INTO bug_feedback (user_id, title, description, screenshot_url) VALUES ($1, $2, $3, $4) RETURNING id, status, created_at`,
[userId, String(title).trim(), String(description).trim(), screenshotUrl || null]
);
res.json({ feedback: { id: result.rows[0].id, status: result.rows[0].status, createdAt: result.rows[0].created_at } });
} catch (err) {
console.error("[bug-feedback] submit failed:", err.message);
res.status(500).json({ error: "提交失败,请稍后重试" });
}
});
router.get("/bug-feedback/mine", requireAuth, async (req, res) => {
const userId = req.user.id;
try {
const result = await pool.query(
`SELECT id, title, description, screenshot_url, status, admin_note, created_at FROM bug_feedback WHERE user_id = $1 ORDER BY created_at DESC LIMIT 50`,
[userId]
);
res.json({ feedbacks: result.rows.map(r => ({ id: r.id, title: r.title, description: r.description, screenshotUrl: r.screenshot_url, status: r.status, adminNote: r.admin_note, createdAt: r.created_at })) });
} catch (err) {
console.error("[bug-feedback] list mine failed:", err.message);
res.status(500).json({ error: "获取反馈列表失败" });
}
});
router.get("/admin/bug-feedback", requireAuth, requireAdmin, async (req, res) => {
const status = req.query.status || null;
const limit = Math.min(Number(req.query.limit) || 20, 100);
const offset = Number(req.query.offset) || 0;
try {
const where = status ? "WHERE bf.status = $1" : "";
const params = status ? [status, limit, offset] : [limit, offset];
const countWhere = status ? "WHERE status = $1" : "";
const countParams = status ? [status] : [];
const [dataRes, countRes] = await Promise.all([
pool.query(`SELECT bf.id, bf.title, bf.description, bf.screenshot_url, bf.status, bf.admin_note, bf.reward_credited, bf.created_at, u.username FROM bug_feedback bf JOIN users u ON u.id = bf.user_id ${where} ORDER BY bf.created_at DESC LIMIT $${status ? 2 : 1} OFFSET $${status ? 3 : 2}`, params),
pool.query(`SELECT COUNT(*)::int AS total FROM bug_feedback ${countWhere}`, countParams),
]);
res.json({
feedbacks: dataRes.rows.map(r => ({ id: r.id, title: r.title, description: r.description, screenshotUrl: r.screenshot_url, status: r.status, adminNote: r.admin_note, rewardCredited: r.reward_credited, username: r.username, createdAt: r.created_at })),
total: countRes.rows[0].total,
});
} catch (err) {
console.error("[admin/bug-feedback] list failed:", err.message);
res.status(500).json({ error: "获取反馈列表失败" });
}
});
router.patch("/admin/bug-feedback/:id", requireAuth, requireAdmin, async (req, res) => {
const feedbackId = Number(req.params.id);
const { status, adminNote } = req.body;
if (!["approved", "rejected"].includes(status)) return res.status(400).json({ error: "状态只能是 approved 或 rejected" });
const client = await pool.connect();
try {
await client.query("BEGIN");
const existing = await client.query("SELECT id, user_id, status, reward_credited FROM bug_feedback WHERE id = $1 FOR UPDATE", [feedbackId]);
if (existing.rows.length === 0) { await client.query("ROLLBACK"); return res.status(404).json({ error: "反馈不存在" }); }
const row = existing.rows[0];
await client.query("UPDATE bug_feedback SET status = $1, admin_note = $2, updated_at = NOW() WHERE id = $3", [status, adminNote || null, feedbackId]);
let rewardCredited = row.reward_credited;
if (status === "approved" && !row.reward_credited) {
await creditUserBalance(row.user_id, 100, "Bug反馈奖励 1 积分");
await client.query("UPDATE bug_feedback SET reward_credited = TRUE WHERE id = $1", [feedbackId]);
rewardCredited = true;
}
await client.query("COMMIT");
res.json({ success: true, rewardCredited });
} catch (err) {
await client.query("ROLLBACK");
console.error("[admin/bug-feedback] patch failed:", err.message);
res.status(500).json({ error: "操作失败" });
} finally {
client.release();
}
});
module.exports = router;
+170
View File
@@ -32,6 +32,8 @@ const {
getUserEnterpriseId, getUserEnterpriseId,
getEnterpriseName, getEnterpriseName,
preauthorizeCall, preauthorizeCall,
creditsToCreditUnits,
formatCreditsFromCents,
} = require("../billing"); } = require("../billing");
const wechatPay = require("../paymentWechat"); const wechatPay = require("../paymentWechat");
const alipay = require("../paymentAlipay"); const alipay = require("../paymentAlipay");
@@ -53,6 +55,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_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_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 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) { function validateUsername(username) {
if (!username) return "缺少用户名"; if (!username) return "缺少用户名";
@@ -201,6 +207,161 @@ async function consumeSmsCode(phone, code, purpose) {
await pool.query("UPDATE sms_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]); await pool.query("UPDATE sms_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]);
return true; 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");
}
function buildSmtpTransportOptions(scope) {
const prefix = scope ? `${scope}_` : "";
return {
host: process.env[`${prefix}SMTP_HOST`] || process.env.SMTP_HOST,
port: Number(process.env[`${prefix}SMTP_PORT`] || process.env.SMTP_PORT) || 587,
secure: String(process.env[`${prefix}SMTP_SECURE`] || process.env.SMTP_SECURE || "") === "1",
auth: {
user: process.env[`${prefix}SMTP_USER`] || process.env.SMTP_USER,
pass: process.env[`${prefix}SMTP_PASS`] || process.env.SMTP_PASS,
},
};
}
function formatEmailAddress(address, displayName) {
const email = String(address || "").trim();
const name = String(displayName || "").trim();
if (!name) return email;
const escapedName = name.replace(/"/g, '\\"');
return `"${escapedName}" <${email}>`;
}
function escapeEmailHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function buildEmailCodeContent(code, purpose) {
const purposeText = purpose === "register" ? "注册" : purpose === "login" ? "登录" : "重置密码";
const ttlText = String(EMAIL_CODE_TTL_MINUTES);
const safeCode = escapeEmailHtml(code);
const safePurposeText = escapeEmailHtml(purposeText);
const preheader = `您的 OmniAI ${purposeText}验证码是 ${code}${ttlText} 分钟内有效。`;
return {
subject: "[OmniAI] 邮箱验证码",
text:
`您的验证码是:${code}\n` +
`用途:${purposeText}\n` +
`有效期:${ttlText} 分钟\n` +
"请勿将验证码转发给他人。如非本人操作,请忽略此邮件。",
html: `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OmniAI 邮箱验证码</title>
</head>
<body style="margin:0;padding:0;background:#f4f7fb;color:#1f2937;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',Arial,sans-serif;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">${escapeEmailHtml(preheader)}</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="width:100%;background:#f4f7fb;margin:0;padding:28px 12px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="width:100%;max-width:560px;background:#ffffff;border-radius:16px;overflow:hidden;border:1px solid #e5ebf3;box-shadow:0 18px 45px rgba(31,41,55,0.08);">
<tr>
<td style="padding:28px 28px 20px;background:#101827;color:#ffffff;">
<div style="font-size:13px;letter-spacing:2px;text-transform:uppercase;color:#a7f3d0;font-weight:700;">OmniAI</div>
<h1 style="margin:10px 0 0;font-size:24px;line-height:1.35;font-weight:800;color:#ffffff;">万物可爱邮箱验证</h1>
<p style="margin:10px 0 0;font-size:14px;line-height:1.8;color:#cbd5e1;">请使用下方验证码完成${safePurposeText}操作。</p>
</td>
</tr>
<tr>
<td style="padding:28px;">
<div style="border:1px solid #dbe6f4;background:#f8fbff;border-radius:14px;padding:22px 18px;text-align:center;">
<div style="font-size:13px;color:#64748b;margin-bottom:10px;">验证码</div>
<div style="font-size:38px;line-height:1.2;letter-spacing:8px;font-weight:800;color:#0f766e;font-family:'SFMono-Regular',Consolas,'Liberation Mono',monospace;">${safeCode}</div>
<div style="font-size:13px;color:#64748b;margin-top:14px;">${ttlText} 分钟内有效</div>
</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin-top:22px;border-collapse:collapse;">
<tr>
<td style="padding:12px 0;border-bottom:1px solid #edf2f7;color:#64748b;font-size:14px;">用途</td>
<td align="right" style="padding:12px 0;border-bottom:1px solid #edf2f7;color:#111827;font-size:14px;font-weight:700;">${safePurposeText}</td>
</tr>
<tr>
<td style="padding:12px 0;border-bottom:1px solid #edf2f7;color:#64748b;font-size:14px;">有效期</td>
<td align="right" style="padding:12px 0;border-bottom:1px solid #edf2f7;color:#111827;font-size:14px;font-weight:700;">${ttlText} 分钟</td>
</tr>
</table>
<div style="margin-top:22px;padding:14px 16px;border-radius:12px;background:#fff7ed;border:1px solid #fed7aa;color:#9a3412;font-size:13px;line-height:1.8;">
请勿将验证码转发给他人。万物可爱工作人员不会向您索要邮箱验证码。
</div>
<p style="margin:22px 0 0;color:#64748b;font-size:13px;line-height:1.8;">如果不是您本人操作,可以直接忽略此邮件。</p>
</td>
</tr>
<tr>
<td style="padding:18px 28px;background:#f8fafc;border-top:1px solid #edf2f7;color:#94a3b8;font-size:12px;line-height:1.7;text-align:center;">
此邮件由系统自动发送,请勿直接回复。<br>OmniAI · 万物可爱
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`,
};
}
async function sendEmailCode(email, code, purpose) {
const provider = String(process.env.EMAIL_PROVIDER || "mock").trim().toLowerCase();
if (provider === "smtp") {
const nodemailer = require("nodemailer");
const smtpOptions = buildSmtpTransportOptions("SYSTEM");
const transporter = nodemailer.createTransport(smtpOptions);
const fromAddress = process.env.SYSTEM_SMTP_FROM || process.env.SMTP_FROM || smtpOptions.auth.user;
const fromName = process.env.SYSTEM_SMTP_FROM_NAME || process.env.SMTP_FROM_NAME || "万物可爱";
const content = buildEmailCodeContent(code, purpose);
await transporter.sendMail({
from: formatEmailAddress(fromAddress, fromName),
to: email,
subject: content.subject,
text: content.text,
html: content.html,
});
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() { function getWechatLoginConfig() {
const appId = process.env.WECHAT_LOGIN_APP_ID || process.env.WECHAT_APP_ID || ""; const appId = process.env.WECHAT_LOGIN_APP_ID || process.env.WECHAT_APP_ID || "";
@@ -729,6 +890,8 @@ module.exports = {
getUserEnterpriseId, getUserEnterpriseId,
getEnterpriseName, getEnterpriseName,
preauthorizeCall, preauthorizeCall,
creditsToCreditUnits,
formatCreditsFromCents,
wechatPay, wechatPay,
alipay, alipay,
crypto, crypto,
@@ -742,6 +905,10 @@ module.exports = {
PRICE_TYPES, PRICE_TYPES,
PHONE_PATTERN, PHONE_PATTERN,
EMAIL_PATTERN, EMAIL_PATTERN,
EMAIL_PURPOSES,
EMAIL_CODE_TTL_MINUTES,
EMAIL_CODE_COOLDOWN_SECONDS,
EMAIL_CODE_MAX_ATTEMPTS,
SMS_PURPOSES, SMS_PURPOSES,
SMS_CODE_TTL_MINUTES, SMS_CODE_TTL_MINUTES,
SMS_CODE_COOLDOWN_SECONDS, SMS_CODE_COOLDOWN_SECONDS,
@@ -755,6 +922,9 @@ module.exports = {
hashSmsCode, hashSmsCode,
generateSmsCode, generateSmsCode,
sendSmsCode, sendSmsCode,
hashEmailCode,
sendEmailCode,
consumeEmailCode,
createLoginResultForUserId, createLoginResultForUserId,
sanitizeUsernameSeed, sanitizeUsernameSeed,
generateUniqueUsername, generateUniqueUsername,
+169
View File
@@ -131,3 +131,172 @@ function registerEcommerceRoutes(router) {
} }
module.exports = { registerEcommerceRoutes }; 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;
+17 -8
View File
@@ -2,6 +2,7 @@ const {
requireAuth, requireAuth,
requireEnterpriseAdmin, requireEnterpriseAdmin,
distributeCredits, distributeCredits,
creditsToCreditUnits,
getEnterpriseFinancials, getEnterpriseFinancials,
getEnterpriseName, getEnterpriseName,
pool, pool,
@@ -302,25 +303,33 @@ function registerEnterpriseRoutes(router) {
}); });
router.post("/enterprise/distribute", requireAuth, requireEnterpriseAdmin, async (req, res) => { router.post("/enterprise/distribute", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const { userId, amountCents, distributions } = req.body; const { userId, amountCredits, amountCents, distributions } = req.body;
try { try {
if (distributions && Array.isArray(distributions)) { if (distributions && Array.isArray(distributions)) {
for (const d of distributions) { for (const d of distributions) {
if (!d.userId || !d.amountCents || d.amountCents <= 0) { const creditUnits =
d.amountCredits !== undefined && d.amountCredits !== null && d.amountCredits !== ""
? creditsToCreditUnits(d.amountCredits)
: Number(d.amountCents);
if (!d.userId || !creditUnits || creditUnits <= 0) {
return res return res
.status(400) .status(400)
.json({ error: "每条分发记录必须包含有效的 userId 和 amountCents" }); .json({ error: "每条分发记录必须包含有效的 userId 和 amountCredits" });
} }
await distributeCredits(req.user.enterpriseId, d.userId, d.amountCents, req.user.id); await distributeCredits(req.user.enterpriseId, d.userId, creditUnits, req.user.id);
} }
res.json({ success: true, count: distributions.length }); res.json({ success: true, count: distributions.length });
} else if (userId && amountCents) { } else if (userId && (amountCredits || amountCents)) {
if (amountCents <= 0) return res.status(400).json({ error: "分发积分必须大于0" }); const creditUnits =
amountCredits !== undefined && amountCredits !== null && amountCredits !== ""
? creditsToCreditUnits(amountCredits)
: Number(amountCents);
if (!creditUnits || creditUnits <= 0) return res.status(400).json({ error: "分发积分必须大于0" });
const result = await distributeCredits( const result = await distributeCredits(
req.user.enterpriseId, req.user.enterpriseId,
userId, userId,
amountCents, creditUnits,
req.user.id, req.user.id,
); );
res.json({ success: true, ...result }); res.json({ success: true, ...result });
@@ -349,7 +358,7 @@ function registerEnterpriseRoutes(router) {
u.username, u.username,
u.balance_cents AS current_balance_cents, u.balance_cents AS current_balance_cents,
COUNT(acl.id) AS total_calls, COUNT(acl.id) AS total_calls,
COALESCE(SUM(CASE WHEN acl.cost_estimate IS NOT NULL THEN CAST(ROUND((acl.cost_estimate * 100)::numeric) AS INTEGER) ELSE 0 END), 0) AS total_cost_cents, COALESCE(SUM(CASE WHEN acl.cost_estimate IS NOT NULL THEN CAST(ROUND((acl.cost_estimate * 10000)::numeric) AS INTEGER) ELSE 0 END), 0) AS total_cost_cents,
MAX(acl.created_at) AS last_active MAX(acl.created_at) AS last_active
FROM users u FROM users u
LEFT JOIN api_call_logs acl ON acl.user_id = u.id AND acl.status = 'success' LEFT JOIN api_call_logs acl ON acl.user_id = u.id AND acl.status = 'success'
+110
View File
@@ -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 };
+10 -2
View File
@@ -1,6 +1,6 @@
const express = require('express') const express = require('express')
const { registerAuthRoutes } = require('./auth') const { registerAuthRoutes } = require('./auth')
const { registerPriceRoutes, registerPackageRoutes, registerHealthRoutes } = require('./public') const { registerPriceRoutes, registerPackageRoutes, registerPublicConfigRoutes, registerHealthRoutes } = require('./public')
const { registerKeyRoutes } = require('./keys') const { registerKeyRoutes } = require('./keys')
const { registerAdminRoutes, registerAdminInvoiceRoutes } = require('./admin') const { registerAdminRoutes, registerAdminInvoiceRoutes } = require('./admin')
const { registerEnterpriseRoutes } = require('./enterprise') const { registerEnterpriseRoutes } = require('./enterprise')
@@ -12,18 +12,22 @@ const { registerProjectRoutes } = require('./projects')
const { registerOssRoutes } = require('./oss') const { registerOssRoutes } = require('./oss')
const { registerCommunityRoutes, registerAdminCommunityRoutes } = require('./community') const { registerCommunityRoutes, registerAdminCommunityRoutes } = require('./community')
const { registerAiRoutes } = require('./ai') const { registerAiRoutes } = require('./ai')
const { registerEcommerceRoutes } = require("./ecommerce") const { registerEcommerceRoutes, registerEcommerceHistoryRoutes } = require("./ecommerce")
const { registerConversationRoutes } = require('./conversations') const { registerConversationRoutes } = require('./conversations')
const { registerReportRoutes } = require('./reports') const { registerReportRoutes } = require('./reports')
const { registerAssetRoutes } = require('./assets') const { registerAssetRoutes } = require('./assets')
const { registerNotificationRoutes } = require('./notifications') const { registerNotificationRoutes } = require('./notifications')
const { registerBetaApplicationRoutes } = require('./betaApplications')
const { registerDraftRoutes } = require('./drafts'); const { registerDraftRoutes } = require('./drafts');
const { registerFileExtractRoutes } = require('./fileExtract');
const mountClientErrorRoutes = require('./clientErrors') const mountClientErrorRoutes = require('./clientErrors')
const bugFeedbackRouter = require("./bugFeedback")
const router = express.Router() const router = express.Router()
registerAuthRoutes(router) registerAuthRoutes(router)
registerPriceRoutes(router) registerPriceRoutes(router)
registerPublicConfigRoutes(router)
registerKeyRoutes(router) registerKeyRoutes(router)
registerAdminRoutes(router) registerAdminRoutes(router)
registerPackageRoutes(router) registerPackageRoutes(router)
@@ -41,11 +45,15 @@ registerCommunityRoutes(router)
registerAdminCommunityRoutes(router) registerAdminCommunityRoutes(router)
registerAiRoutes(router) registerAiRoutes(router)
registerEcommerceRoutes(router) registerEcommerceRoutes(router)
registerEcommerceHistoryRoutes(router)
registerConversationRoutes(router) registerConversationRoutes(router)
registerReportRoutes(router) registerReportRoutes(router)
registerAssetRoutes(router) registerAssetRoutes(router)
registerNotificationRoutes(router) registerNotificationRoutes(router)
registerBetaApplicationRoutes(router)
registerDraftRoutes(router) registerDraftRoutes(router)
registerFileExtractRoutes(router)
router.use(bugFeedbackRouter)
registerHealthRoutes(router) registerHealthRoutes(router)
module.exports = router module.exports = router
+36
View File
@@ -36,14 +36,50 @@ function registerPackageRoutes(router) {
function registerHealthRoutes(router) { function registerHealthRoutes(router) {
// ── Health ─────────────────────────────────────────────────────────── // ── Health ───────────────────────────────────────────────────────────
// Public health: minimal response, no provider/key details exposed
router.get("/health", async (_req, res) => { 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(); const status = await keyManager.getAllStatus();
res.json({ status: "ok", uptime: process.uptime(), providers: status }); 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 = { module.exports = {
registerPriceRoutes, registerPriceRoutes,
registerPackageRoutes, registerPackageRoutes,
registerPublicConfigRoutes,
registerHealthRoutes, registerHealthRoutes,
}; };
+6 -6
View File
@@ -136,8 +136,8 @@ function registerUserRoutes(router) {
CASE CASE
WHEN billing_refunded = 1 THEN 0 WHEN billing_refunded = 1 THEN 0
WHEN cost_cents > 0 THEN cost_cents WHEN cost_cents > 0 THEN cost_cents
WHEN status = 'completed' AND type = 'image' THEN 20 WHEN status = 'completed' AND type = 'image' THEN 2000
WHEN status = 'completed' AND type = 'video' THEN 500 WHEN status = 'completed' AND type = 'video' THEN 50000
ELSE 0 ELSE 0
END END
), 0) AS used_cents ), 0) AS used_cents
@@ -162,7 +162,7 @@ function registerUserRoutes(router) {
resolution = params.resolution || params.quality || params.ratio || null; resolution = params.resolution || params.quality || params.ratio || null;
if (row.status === "completed") { if (row.status === "completed") {
if (row.type === "image") { if (row.type === "image") {
estimatedCents = 20; estimatedCents = 2000;
} else if (row.type === "video") { } else if (row.type === "video") {
const dur = params.duration || 5; const dur = params.duration || 5;
const res = String(params.resolution || params.quality || "").toUpperCase(); const res = String(params.resolution || params.quality || "").toUpperCase();
@@ -172,7 +172,7 @@ function registerUserRoutes(router) {
else if (model.includes("wan2.7-i2v") || model.includes("wanxiang")) rate = res === "720P" ? 0.6 : 1; else if (model.includes("wan2.7-i2v") || model.includes("wanxiang")) rate = res === "720P" ? 0.6 : 1;
else if (model.includes("animate-mix") || model.includes("s2v")) rate = res === "720P" ? 0.6 : 1; else if (model.includes("animate-mix") || model.includes("s2v")) rate = res === "720P" ? 0.6 : 1;
else if (model.includes("kling")) rate = res === "720P" ? 0.6 : 0.8; else if (model.includes("kling")) rate = res === "720P" ? 0.6 : 0.8;
estimatedCents = Math.ceil(rate * dur * 100); estimatedCents = Math.ceil(rate * dur * 10000);
} }
} }
} catch { } catch {
@@ -209,8 +209,8 @@ function registerUserRoutes(router) {
CASE CASE
WHEN billing_refunded = 1 THEN 0 WHEN billing_refunded = 1 THEN 0
WHEN cost_cents > 0 THEN cost_cents WHEN cost_cents > 0 THEN cost_cents
WHEN status = 'completed' AND type = 'image' THEN 20 WHEN status = 'completed' AND type = 'image' THEN 2000
WHEN status = 'completed' AND type = 'video' THEN 500 WHEN status = 'completed' AND type = 'video' THEN 50000
ELSE 0 ELSE 0
END END
), 0) AS used_cents ), 0) AS used_cents