From 2f2ebcb37c800f5d036b708032f41b9cb4a78512 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:28:04 +0800 Subject: [PATCH 1/5] chore(deps-dev): bump @types/nodemailer from 7.0.11 to 8.0.0 Bumps [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer) from 7.0.11 to 8.0.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/nodemailer) --- updated-dependencies: - dependency-name: "@types/nodemailer" dependency-version: 8.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 20 +++----------- package.json | 2 +- pnpm-lock.yaml | 66 ++++++++++++++--------------------------------- 3 files changed, 24 insertions(+), 64 deletions(-) diff --git a/package-lock.json b/package-lock.json index f9afbc6..5f7ac1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,7 @@ "@types/better-sqlite3": "^7.6.11", "@types/node": "^22.10.1", "@types/node-cron": "^3.0.11", - "@types/nodemailer": "^7.0.11", + "@types/nodemailer": "^8.0.0", "@types/pg": "^8.20.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", @@ -3626,9 +3626,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3645,9 +3642,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3664,9 +3658,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3683,9 +3674,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5115,9 +5103,9 @@ "license": "MIT" }, "node_modules/@types/nodemailer": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", - "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 6bdabcd..afaf66c 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "@types/better-sqlite3": "^7.6.11", "@types/node": "^22.10.1", "@types/node-cron": "^3.0.11", - "@types/nodemailer": "^7.0.11", + "@types/nodemailer": "^8.0.0", "@types/pg": "^8.20.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4bb18e..b58e316 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: ws: specifier: ^8.20.0 version: 8.20.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@electron/notarize': specifier: ^3.1.0 @@ -94,8 +97,8 @@ importers: specifier: ^3.0.11 version: 3.0.11 '@types/nodemailer': - specifier: ^7.0.11 - version: 7.0.11 + specifier: ^8.0.0 + version: 8.0.0 '@types/pg': specifier: ^8.20.0 version: 8.20.0 @@ -1198,105 +1201,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1413,28 +1400,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@resvg/resvg-js-linux-arm64-musl@2.4.1': resolution: {integrity: sha512-6mT0+JBCsermKMdi/O2mMk3m7SqOjwi9TKAwSngRZ/nQoL3Z0Z5zV+572ztgbWr0GODB422uD8e9R9zzz38dRQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@resvg/resvg-js-linux-x64-gnu@2.4.1': resolution: {integrity: sha512-60KnrscLj6VGhkYOJEmmzPlqqfcw1keDh6U+vMcNDjPhV3B5vRSkpP/D/a8sfokyeh4VEacPSYkWGezvzS2/mg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@resvg/resvg-js-linux-x64-musl@2.4.1': resolution: {integrity: sha512-0AMyZSICC1D7ge115cOZQW8Pcad6PjWuZkBFF3FJuSxC6Dgok0MQnLTs2MfMdKBlAcwO9dXsf3bv9tJZj8pATA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@resvg/resvg-js-win32-arm64-msvc@2.4.1': resolution: {integrity: sha512-76XDFOFSa3d0QotmcNyChh2xHwk+JTFiEQBVxMlHpHMeq7hNrQJ1IpE1zcHSQvrckvkdfLboKRrlGB86B10Qjw==} @@ -1495,79 +1478,66 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1672,28 +1642,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -1908,8 +1874,8 @@ packages: '@types/node@24.12.0': resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} - '@types/nodemailer@7.0.11': - resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==} + '@types/nodemailer@8.0.0': + resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==} '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} @@ -3505,28 +3471,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -3557,6 +3519,9 @@ packages: lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} @@ -4915,6 +4880,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -6419,7 +6387,7 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/nodemailer@7.0.11': + '@types/nodemailer@8.0.0': dependencies: '@types/node': 22.19.15 @@ -7565,7 +7533,7 @@ snapshots: '@electron/asar': 3.4.1 debug: 4.4.3 fs-extra: 7.0.1 - lodash: 4.17.23 + lodash: 4.18.1 temp: 0.9.4 optionalDependencies: '@electron/windows-sign': 1.2.2 @@ -8339,6 +8307,8 @@ snapshots: lodash@4.17.23: {} + lodash@4.18.1: {} + log-symbols@4.1.0: dependencies: chalk: 4.1.2 @@ -9755,4 +9725,6 @@ snapshots: yocto-queue@0.1.0: {} + zod@4.3.6: {} + zwitch@2.0.4: {} From 5cab6c83f1135aae0cea2fad8467496406263850 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:28:13 +0800 Subject: [PATCH 2/5] chore(deps-dev): bump react-router-dom from 7.13.2 to 7.14.0 Bumps the routing group with 1 update: [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom). Updates `react-router-dom` from 7.13.2 to 7.14.0 - [Release notes](https://github.com/remix-run/react-router/releases) - [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md) - [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.14.0/packages/react-router-dom) --- updated-dependencies: - dependency-name: react-router-dom dependency-version: 7.14.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: routing ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 16 ++++++++-------- package.json | 2 +- pnpm-lock.yaml | 18 +++++++++--------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5f7ac1d..0484495 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,7 @@ "mermaid": "^11.13.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^7.13.2", + "react-router-dom": "^7.14.0", "react-test-renderer": "^18.3.1", "sharp": "^0.34.5", "tailwindcss": "^4.0.0", @@ -12652,9 +12652,9 @@ } }, "node_modules/react-router": { - "version": "7.13.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz", - "integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", + "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12675,13 +12675,13 @@ } }, "node_modules/react-router-dom": { - "version": "7.13.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz", - "integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz", + "integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==", "dev": true, "license": "MIT", "dependencies": { - "react-router": "7.13.2" + "react-router": "7.14.0" }, "engines": { "node": ">=20.0.0" diff --git a/package.json b/package.json index afaf66c..4bd40d7 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "mermaid": "^11.13.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^7.13.2", + "react-router-dom": "^7.14.0", "react-test-renderer": "^18.3.1", "sharp": "^0.34.5", "tailwindcss": "^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b58e316..c2562ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,8 +142,8 @@ importers: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) react-router-dom: - specifier: ^7.13.2 - version: 7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^7.14.0 + version: 7.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-test-renderer: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) @@ -4042,15 +4042,15 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} - react-router-dom@7.13.2: - resolution: {integrity: sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==} + react-router-dom@7.14.0: + resolution: {integrity: sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' react-dom: '>=18' - react-router@7.13.2: - resolution: {integrity: sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==} + react-router@7.14.0: + resolution: {integrity: sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -8852,13 +8852,13 @@ snapshots: react-refresh@0.17.0: {} - react-router-dom@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-router-dom@7.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-router: 7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-router: 7.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-router@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-router@7.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: cookie: 1.1.1 react: 18.3.1 From 569a15e14c5e3b472497e95022d0acd2ec4b9ea2 Mon Sep 17 00:00:00 2001 From: puyujian <46592377+puyujian@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:28:25 +0800 Subject: [PATCH 3/5] feat: enhance API key management and fix routing logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ``` feat(accounts): 支持批量添加API密钥连接 - 添加accessTokens字段到账户创建载荷模式,允许传递多个API密钥 - 实现批量API密钥解析功能,支持换行符、空格和逗号分隔 - 重构账户创建逻辑以支持单个和批量API密钥处理 - 在前端界面中添加批量API密钥输入检测和处理 - 添加批量创建结果返回和错误处理 - 更新验证流程以支持批量模式下的逐条验证 - 添加详细的批量API密钥测试用例 该更改允许用户一次提交多个API密钥,系统将为每个密钥创建独立的连接并参与负载均衡。 ``` * ``` fix(tokenRouter): 调整路由匹配优先级以优先匹配显示名称别名 当精确路由和显式组显示名称冲突时,现在优先选择显式组显示名称 而不是精确路由模式。这修正了路由决策逻辑,使显式组路由能够正 确覆盖精确模型匹配。 相关的测试用例已更新以反映新的匹配行为,确保显式组路由在冲 突情况下获得更高优先级。 ``` * ``` fix(accounts): 修复API密钥验证错误消息显示逻辑 当凭证模式不为apikey时显示会话令牌提示信息,否则显示API密钥验证失败信息 feat(accounts): 更新API密钥输入界面提示文本 将批量添加说明从输入框占位符移至独立提示文本, 使界面更清晰易懂 ``` * ``` feat(workflow): 在CI/CD工作流中使用动态Docker Hub镜像名称 - 在ci.yml和release.yml工作流中添加DOCKERHUB_IMAGE环境变量 - 将硬编码的Docker Hub用户名替换为从secrets.DOCKERHUB_USERNAME动态获取 - 更新docker.metadata-action配置以使用环境变量中的镜像名称 - 添加测试用例验证Docker Hub镜像名称的正确配置 ``` * fix(accounts): 修复批量创建回归并下沉创建流程 --- .github/workflows/ci.yml | 8 +- .github/workflows/release.yml | 76 +++-- .gitignore | 1 + scripts/dev/docker.workflow.test.ts | 11 + src/server/contracts/accountsRoutePayloads.ts | 6 +- .../routes/api/accounts.apiSite.test.ts | 93 ++++++ src/server/routes/api/accounts.ts | 312 ++++++------------ .../api/tokens.route-update-rebuild.test.ts | 7 +- src/server/services/apiKeyBatch.ts | 4 + .../services/manualAccountCreationService.ts | 305 +++++++++++++++++ src/server/services/shared/apiKeyBatchCore.ts | 26 ++ .../services/tokenRouter.patterns.test.ts | 16 +- src/server/services/tokenRouter.ts | 12 +- src/shared/apiKeyBatch.ts | 4 + src/web/pages/Accounts.tsx | 56 +++- 15 files changed, 655 insertions(+), 282 deletions(-) create mode 100644 src/server/services/apiKeyBatch.ts create mode 100644 src/server/services/manualAccountCreationService.ts create mode 100644 src/server/services/shared/apiKeyBatchCore.ts create mode 100644 src/shared/apiKeyBatch.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d4b3b5..9064c4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -342,6 +342,8 @@ jobs: if: github.event_name == 'push' runs-on: ${{ matrix.runner }} timeout-minutes: 45 + env: + DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi strategy: fail-fast: false matrix: @@ -384,7 +386,7 @@ jobs: id: meta uses: docker/metadata-action@v6 with: - images: 1467078763/metapi + images: ${{ env.DOCKERHUB_IMAGE }} tags: | type=raw,value=latest,enable={{is_default_branch}} type=ref,event=branch @@ -424,6 +426,8 @@ jobs: runs-on: ubuntu-latest needs: publish-docker-arch timeout-minutes: 15 + env: + DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi steps: - name: Checkout @@ -449,7 +453,7 @@ jobs: id: meta uses: docker/metadata-action@v6 with: - images: 1467078763/metapi + images: ${{ env.DOCKERHUB_IMAGE }} tags: | type=raw,value=latest,enable={{is_default_branch}} type=ref,event=branch diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7d63bc..e9bcc9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -283,14 +283,16 @@ jobs: generate_release_notes: true files: release-assets/* - publish-docker-arch: - name: Publish Docker Image (${{ matrix.arch }}) - needs: build-packages - runs-on: ${{ matrix.runner }} - timeout-minutes: 45 - if: startsWith(github.ref, 'refs/tags/') - strategy: - fail-fast: false + publish-docker-arch: + name: Publish Docker Image (${{ matrix.arch }}) + needs: build-packages + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + if: startsWith(github.ref, 'refs/tags/') + env: + DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi + strategy: + fail-fast: false matrix: include: - arch: amd64 @@ -334,16 +336,16 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Docker metadata - id: meta - uses: docker/metadata-action@v6 - with: - images: | - 1467078763/metapi - ghcr.io/${{ github.repository }} - tags: | - type=ref,event=tag - type=raw,value=latest + - name: Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.DOCKERHUB_IMAGE }} + ghcr.io/${{ github.repository }} + tags: | + type=ref,event=tag + type=raw,value=latest - name: Add architecture suffix to tags id: arch-tags @@ -373,14 +375,16 @@ jobs: cache-from: type=gha,scope=release-${{ matrix.arch }} cache-to: type=gha,mode=max,scope=release-${{ matrix.arch }} - publish-docker: - name: Publish Docker Manifests - needs: publish-docker-arch - runs-on: ubuntu-latest - timeout-minutes: 15 - if: startsWith(github.ref, 'refs/tags/') - - steps: + publish-docker: + name: Publish Docker Manifests + needs: publish-docker-arch + runs-on: ubuntu-latest + timeout-minutes: 15 + if: startsWith(github.ref, 'refs/tags/') + env: + DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi + + steps: - name: Checkout uses: actions/checkout@v6 @@ -407,16 +411,16 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Docker metadata - id: meta - uses: docker/metadata-action@v6 - with: - images: | - 1467078763/metapi - ghcr.io/${{ github.repository }} - tags: | - type=ref,event=tag - type=raw,value=latest + - name: Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.DOCKERHUB_IMAGE }} + ghcr.io/${{ github.repository }} + tags: | + type=ref,event=tag + type=raw,value=latest - name: Create multi-arch manifests shell: bash diff --git a/.gitignore b/.gitignore index d789c71..54ca230 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ docker/.env # Local planning docs docs/plan/ docs/plans/ +.ace-tool/ diff --git a/scripts/dev/docker.workflow.test.ts b/scripts/dev/docker.workflow.test.ts index 2242fb5..8805c8d 100644 --- a/scripts/dev/docker.workflow.test.ts +++ b/scripts/dev/docker.workflow.test.ts @@ -16,6 +16,17 @@ describe('docker workflows', () => { expect(releaseWorkflow).toContain('"${tag}-armv7"'); }); + it('derives Docker Hub image names from the configured username secret', () => { + const ciWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/ci.yml'), 'utf8'); + const releaseWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8'); + + expect(ciWorkflow).toContain('DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi'); + expect(ciWorkflow).not.toContain('images: 1467078763/metapi'); + + expect(releaseWorkflow).toContain('DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi'); + expect(releaseWorkflow).not.toContain('1467078763/metapi'); + }); + it('uses an armv7-capable node base image in the Dockerfile', () => { const dockerfile = readFileSync(resolve(process.cwd(), 'docker/Dockerfile'), 'utf8'); diff --git a/src/server/contracts/accountsRoutePayloads.ts b/src/server/contracts/accountsRoutePayloads.ts index 76132f6..6d76961 100644 --- a/src/server/contracts/accountsRoutePayloads.ts +++ b/src/server/contracts/accountsRoutePayloads.ts @@ -5,7 +5,8 @@ const accountCredentialModeSchema = z.enum(['auto', 'session', 'apikey']); const accountCreatePayloadSchema = z.object({ siteId: z.number().int().positive(), username: z.string().optional(), - accessToken: z.string(), + accessToken: z.string().optional(), + accessTokens: z.array(z.string()).optional(), apiToken: z.string().optional(), platformUserId: z.number().int().positive().optional(), checkinEnabled: z.boolean().optional(), @@ -95,6 +96,9 @@ function formatAccountsPayloadError(error: z.ZodError): string { if (firstPath === 'apiToken') { return 'Invalid apiToken. Expected string or null.'; } + if (firstPath === 'accessTokens') { + return 'Invalid accessTokens. Expected string[].'; + } if (firstPath === 'checkinEnabled') { return 'Invalid checkinEnabled. Expected boolean.'; } diff --git a/src/server/routes/api/accounts.apiSite.test.ts b/src/server/routes/api/accounts.apiSite.test.ts index de5775f..0da81f6 100644 --- a/src/server/routes/api/accounts.apiSite.test.ts +++ b/src/server/routes/api/accounts.apiSite.test.ts @@ -268,4 +268,97 @@ describe('accounts api endpoint host selection', { timeout: 15_000 }, () => { expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api-create-a.example.com', 'sk-nihao-create-rotate', undefined); expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api-create-b.example.com', 'sk-nihao-create-rotate', undefined); }); + + it('supports batch creating multiple API key connections for one site', async () => { + getModelsMock + .mockResolvedValueOnce(['gpt-4o-mini']) + .mockResolvedValueOnce(['gpt-4.1-mini']); + + const site = await db.insert(schema.sites).values({ + name: 'Nihao Batch Pool', + url: 'https://console.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + await db.insert(schema.siteApiEndpoints).values({ + siteId: site.id, + url: 'https://api.example.com', + enabled: true, + sortOrder: 0, + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/accounts', + payload: { + siteId: site.id, + username: 'batch-key', + accessToken: 'sk-batch-a\nsk-batch-b', + credentialMode: 'apikey', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + batch: true, + totalCount: 2, + createdCount: 2, + failedCount: 0, + }); + expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api.example.com', 'sk-batch-a', undefined); + expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api.example.com', 'sk-batch-b', undefined); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(2); + expect(accounts.map((item) => item.apiToken)).toEqual(['sk-batch-a', 'sk-batch-b']); + expect(accounts.map((item) => item.username)).toEqual(['batch-key #1', 'batch-key #2']); + }); + + it('treats accessTokens payloads as batch API key creation even without credentialMode', async () => { + getModelsMock + .mockResolvedValueOnce(['gpt-4o-mini']) + .mockResolvedValueOnce(['gpt-4.1-mini']); + + const site = await db.insert(schema.sites).values({ + name: 'Nihao Batch Array', + url: 'https://console.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + await db.insert(schema.siteApiEndpoints).values({ + siteId: site.id, + url: 'https://api.example.com', + enabled: true, + sortOrder: 0, + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/accounts', + payload: { + siteId: site.id, + username: 'array-key', + accessTokens: ['sk-array-a', 'sk-array-b'], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + batch: true, + totalCount: 2, + createdCount: 2, + failedCount: 0, + }); + expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api.example.com', 'sk-array-a', undefined); + expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api.example.com', 'sk-array-b', undefined); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(2); + expect(accounts.map((item) => item.apiToken)).toEqual(['sk-array-a', 'sk-array-b']); + expect(accounts.map((item) => item.username)).toEqual(['array-key #1', 'array-key #2']); + }); }); diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index eafc1ce..eeda591 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -1,6 +1,6 @@ import { FastifyInstance } from 'fastify'; import { db, schema, runtimeDbDialect } from '../../db/index.js'; -import { getInsertedRowId, insertAndGetById } from '../../db/insertHelpers.js'; +import { insertAndGetById } from '../../db/insertHelpers.js'; import { and, eq, gte, lt, sql } from 'drizzle-orm'; import { refreshBalance } from '../../services/balanceService.js'; import { getAdapter } from '../../services/platforms/index.js'; @@ -34,6 +34,7 @@ import { appendSessionTokenRebindHint } from '../../services/alertRules.js'; import { parseSiteProxyUrlInput, withAccountProxyOverride, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { createRateLimitGuard } from '../../middleware/requestRateLimit.js'; import { + type AccountCreatePayload, parseAccountBatchPayload, parseAccountCreatePayload, parseAccountHealthRefreshPayload, @@ -44,6 +45,8 @@ import { parseAccountVerifyTokenPayload, } from '../../contracts/accountsRoutePayloads.js'; import { requireSiteApiBaseUrl, runWithSiteApiEndpointPool } from '../../services/siteApiEndpointService.js'; +import { buildBatchApiKeyConnectionName, parseBatchApiKeys } from '../../services/apiKeyBatch.js'; +import { createManualAccount } from '../../services/manualAccountCreationService.js'; type AccountWithSiteRow = { accounts: typeof schema.accounts.$inferSelect; @@ -65,17 +68,6 @@ type AccountCapabilities = { proxyOnly: boolean; }; -type AccountInitializationParams = { - accountId: number; - site: typeof schema.sites.$inferSelect; - adapter: NonNullable>; - tokenType: 'session' | 'apikey' | 'unknown'; - accessToken: string; - apiToken: string; - platformUserId?: number; - skipModelFetch?: boolean; -}; - type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null; const limitAccountLogin = createRateLimitGuard({ @@ -150,65 +142,18 @@ function normalizePinnedFlag(input: unknown): boolean | null { return null; } -async function initializeAccountInBackground({ - accountId, - site, - adapter, - tokenType, - accessToken, - apiToken, - platformUserId, - skipModelFetch, -}: AccountInitializationParams) { - const summary = { - accountId, - syncedTokenCount: 0, - refreshedBalance: false, - refreshedModels: false, - rebuiltRoutes: false, - }; - - let fetchedUpstreamTokens: Array<{ name?: string | null; key?: string | null; enabled?: boolean | null; tokenGroup?: string | null }> = []; - if (tokenType === 'session' && accessToken) { - try { - const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId); - summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0; - fetchedUpstreamTokens = Array.isArray(syncedTokens) ? syncedTokens : []; - } catch {} +function resolveRequestedCreateTokens( + body: AccountCreatePayload, + credentialMode: AccountCredentialMode, +): string[] { + if (credentialMode !== 'apikey') { + const single = String(body.accessToken || '').trim(); + return single ? [single] : []; } - const convergence = await convergeAccountMutation({ - accountId, - preferredApiToken: tokenType === 'session' ? apiToken : null, - defaultTokenSource: 'manual', - ensurePreferredTokenBeforeSync: tokenType === 'session', - upstreamTokens: fetchedUpstreamTokens, - refreshBalance: tokenType === 'session', - refreshModels: skipModelFetch !== true, - rebuildRoutes: skipModelFetch !== true, - continueOnError: true, - }); - summary.refreshedBalance = convergence.refreshedBalance; - summary.refreshedModels = convergence.refreshedModels; - summary.rebuiltRoutes = convergence.rebuiltRoutes; - - return summary; -} - -function buildQueuedAccountInitializationMessage( - tokenType: 'session' | 'apikey' | 'unknown', - skipModelFetch?: boolean, -) { - if (tokenType === 'session' && skipModelFetch === true) { - return '账号已添加,后台正在同步令牌和余额信息。'; - } - if (tokenType === 'session') { - return '账号已添加,后台正在同步令牌、余额和模型信息。'; - } - if (skipModelFetch === true) { - return '已添加为 API Key 账号(可用于代理转发)。'; - } - return '已添加为 API Key 账号,后台正在同步模型和路由信息。'; + const batchTokens = parseBatchApiKeys(body.accessTokens); + if (batchTokens.length > 0) return batchTokens; + return parseBatchApiKeys(body.accessToken); } function normalizeSortOrder(input: unknown): number | null { @@ -1147,174 +1092,103 @@ export async function accountsRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: `platform not supported: ${site.platform}` }); } - const credentialMode = resolveRequestedCredentialMode(body.credentialMode); - const rawAccessToken = (body.accessToken || '').trim(); - if (!rawAccessToken) { + const explicitBatchTokens = parseBatchApiKeys(body.accessTokens); + const credentialMode = explicitBatchTokens.length > 0 + ? 'apikey' + : resolveRequestedCredentialMode(body.credentialMode); + const requestedTokens = explicitBatchTokens.length > 0 + ? explicitBatchTokens + : resolveRequestedCreateTokens(body, credentialMode); + if (requestedTokens.length === 0) { return reply.code(400).send({ success: false, message: '请填写 Token' }); } - let username = (body.username || '').trim(); - let accessToken = rawAccessToken; - let apiToken = (body.apiToken || '').trim(); - let tokenType: 'session' | 'apikey' | 'unknown' = 'unknown'; - let verifiedModels: string[] = []; + if (credentialMode === 'apikey' && requestedTokens.length > 1) { + const items: Array> = []; + let createdCount = 0; - if (credentialMode === 'apikey') { - if (body.skipModelFetch === true) { - tokenType = 'apikey'; - accessToken = ''; - if (!apiToken) apiToken = rawAccessToken; - } else { + for (const [index, token] of requestedTokens.entries()) { try { - const models = await getModelsWithSiteApiEndpointPool( + const created = await createManualAccount({ + body, site, adapter, - rawAccessToken, - body.platformUserId, - ); - verifiedModels = Array.isArray(models) - ? models.filter((item) => typeof item === 'string' && item.trim().length > 0) - : []; - } catch (err: any) { - return reply.code(400).send({ - success: false, - message: err?.message || 'API Key 验证失败', + credentialMode, + rawAccessToken: token, + usernameOverride: buildBatchApiKeyConnectionName(body.username, index, requestedTokens.length) || undefined, + }); + createdCount += 1; + items.push({ + index, + status: 'created', + id: created.account.id, + username: created.account.username || null, + queued: created.queued === true, + message: created.message || null, + modelCount: created.modelCount || 0, + }); + } catch (error: any) { + items.push({ + index, + status: 'failed', + message: error?.message || '创建失败', + requiresVerification: error?.requiresVerification === true, }); } - - if (verifiedModels.length === 0) { - return reply.code(400).send({ - success: false, - requiresVerification: true, - message: 'API Key 验证失败:未获取到可用模型', - }); - } - - tokenType = 'apikey'; - accessToken = ''; - if (!apiToken) apiToken = rawAccessToken; } - } else { - let verifyResult: any; - try { - verifyResult = await adapter.verifyToken(site.url, rawAccessToken, body.platformUserId); - } catch (err: any) { + + if (createdCount === 0) { return reply.code(400).send({ success: false, - message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'), + batch: true, + totalCount: requestedTokens.length, + createdCount: 0, + failedCount: requestedTokens.length, + message: `批量添加失败(0/${requestedTokens.length})`, + items, }); } - tokenType = verifyResult.tokenType; - if (tokenType === 'unknown') { - return reply.code(400).send({ - success: false, - requiresVerification: true, - message: 'Token 验证失败,请先点击“验证 Token”,验证成功后再绑定账号', - }); - } - - if (credentialMode === 'session' && tokenType !== 'session') { - return reply.code(400).send({ - success: false, - message: '当前凭证是 API Key,请切换到 API Key 模式,或改用 Session Token', - }); - } - - if (tokenType === 'session') { - if (!username && verifyResult.userInfo?.username) username = String(verifyResult.userInfo.username).trim(); - if (!apiToken && verifyResult.apiToken) apiToken = String(verifyResult.apiToken).trim(); - } else if (tokenType === 'apikey') { - accessToken = ''; - if (!apiToken) apiToken = rawAccessToken; - verifiedModels = Array.isArray(verifyResult.models) - ? verifyResult.models.filter((item: unknown) => typeof item === 'string' && item.trim().length > 0) - : []; - } + return { + success: true, + batch: true, + totalCount: requestedTokens.length, + createdCount, + failedCount: requestedTokens.length - createdCount, + message: `批量添加完成:成功 ${createdCount},失败 ${requestedTokens.length - createdCount}`, + items, + }; } - // Store platformUserId and credential mode in extraConfig. - const resolvedPlatformUserId = - body.platformUserId || guessPlatformUserIdFromUsername(username) || undefined; - const resolvedCredentialMode: AccountCredentialMode = tokenType === 'apikey' ? 'apikey' : 'session'; - const extraConfigPatch: Record = { credentialMode: resolvedCredentialMode }; - if (resolvedPlatformUserId) { - extraConfigPatch.platformUserId = resolvedPlatformUserId; + try { + const created = await createManualAccount({ + body, + site, + adapter, + credentialMode, + rawAccessToken: requestedTokens[0]!, + }); + return { + ...created.account, + tokenType: created.tokenType, + credentialMode: resolveStoredCredentialMode(created.account), + capabilities: buildCapabilitiesForAccount(created.account), + modelCount: created.modelCount, + apiTokenFound: created.apiTokenFound, + usernameDetected: created.usernameDetected, + queued: created.queued, + jobId: created.jobId, + message: created.message, + }; + } catch (err: any) { + return reply.code(400).send({ + success: false, + requiresVerification: err?.requiresVerification === true, + message: credentialMode !== 'apikey' + ? appendSessionTokenRebindHint(err?.message || 'Token 验证失败') + : (err?.message || 'API Key 验证失败'), + }); } - if ((site.platform || '').toLowerCase() === 'sub2api') { - const managedRefreshToken = normalizeManagedRefreshToken(body.refreshToken); - const managedTokenExpiresAt = normalizeManagedTokenExpiresAt(body.tokenExpiresAt); - if (managedRefreshToken) { - extraConfigPatch.sub2apiAuth = managedTokenExpiresAt - ? { refreshToken: managedRefreshToken, tokenExpiresAt: managedTokenExpiresAt } - : { refreshToken: managedRefreshToken }; - } - } - const extraConfig = mergeAccountExtraConfig(undefined, extraConfigPatch); - - const result = await insertAndGetById({ - table: schema.accounts, - idColumn: schema.accounts.id, - values: { - siteId: body.siteId, - username: username || undefined, - accessToken, - apiToken: apiToken || undefined, - checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false, - extraConfig, - isPinned: false, - sortOrder: await getNextAccountSortOrder(), - }, - insertErrorMessage: '创建账号失败', - loadErrorMessage: '创建账号失败', - }); - - const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true; - let queuedTaskId: string | undefined; - let queuedMessage: string | undefined; - if (shouldQueueInitialization) { - const taskTitle = `初始化连接 #${result.id}`; - const { task } = startBackgroundTask( - { - type: 'account-init', - title: taskTitle, - dedupeKey: `account-init-${result.id}`, - notifyOnFailure: true, - successMessage: () => `${taskTitle}已完成`, - failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`, - }, - async () => initializeAccountInBackground({ - accountId: result.id, - site, - adapter, - tokenType, - accessToken, - apiToken, - platformUserId: resolvedPlatformUserId, - skipModelFetch: body.skipModelFetch, - }), - ); - queuedTaskId = task.id; - queuedMessage = buildQueuedAccountInitializationMessage(tokenType, body.skipModelFetch); - } - - const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get(); - const finalCredentialMode = account ? resolveStoredCredentialMode(account) : resolvedCredentialMode; - const capabilities = account - ? buildCapabilitiesForAccount(account) - : buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session', null); - return { - ...account, - tokenType, - credentialMode: finalCredentialMode, - capabilities, - modelCount: verifiedModels.length, - apiTokenFound: !!apiToken, - usernameDetected: !!(!body.username && username), - queued: !!queuedTaskId, - jobId: queuedTaskId, - message: queuedMessage, - }; }); // Update an account diff --git a/src/server/routes/api/tokens.route-update-rebuild.test.ts b/src/server/routes/api/tokens.route-update-rebuild.test.ts index 66d2025..3a0d3a9 100644 --- a/src/server/routes/api/tokens.route-update-rebuild.test.ts +++ b/src/server/routes/api/tokens.route-update-rebuild.test.ts @@ -658,7 +658,7 @@ describe('PUT /api/routes/:id route rebuild', () => { expect(updated?.tokenId).toBe(seeded.token.id); }); - it('prefers an exact route over a colliding explicit-group display name', async () => { + it('prefers an explicit-group display name over a colliding exact route', async () => { const exactCandidate = await seedAccountWithToken('claude-opus-4-6'); const groupedCandidate = await seedAccountWithToken('claude-opus-4-5'); @@ -716,9 +716,8 @@ describe('PUT /api/routes/:id route rebuild', () => { success: true, decision: { matched: true, - routeId: exactRoute.id, - modelPattern: 'claude-opus-4-6', - actualModel: 'claude-opus-4-6', + routeId: groupResponse.json().id, + actualModel: 'claude-opus-4-5', }, }); }); diff --git a/src/server/services/apiKeyBatch.ts b/src/server/services/apiKeyBatch.ts new file mode 100644 index 0000000..8cfa30e --- /dev/null +++ b/src/server/services/apiKeyBatch.ts @@ -0,0 +1,4 @@ +export { + buildBatchApiKeyConnectionName, + parseBatchApiKeys, +} from './shared/apiKeyBatchCore.js'; diff --git a/src/server/services/manualAccountCreationService.ts b/src/server/services/manualAccountCreationService.ts new file mode 100644 index 0000000..f1b3f82 --- /dev/null +++ b/src/server/services/manualAccountCreationService.ts @@ -0,0 +1,305 @@ +import { eq } from 'drizzle-orm'; +import { db, schema } from '../db/index.js'; +import { insertAndGetById } from '../db/insertHelpers.js'; +import { startBackgroundTask } from './backgroundTaskService.js'; +import { getAdapter } from './platforms/index.js'; +import { + guessPlatformUserIdFromUsername, + mergeAccountExtraConfig, + type AccountCredentialMode, +} from './accountExtraConfig.js'; +import { runWithSiteApiEndpointPool } from './siteApiEndpointService.js'; +import { type AccountCreatePayload } from '../contracts/accountsRoutePayloads.js'; +import { convergeAccountMutation } from './accountMutationWorkflow.js'; + +const ACCOUNT_VERIFY_TIMEOUT_MS = 10_000; + +type AccountInitializationParams = { + accountId: number; + site: typeof schema.sites.$inferSelect; + adapter: NonNullable>; + tokenType: 'session' | 'apikey' | 'unknown'; + accessToken: string; + apiToken: string; + platformUserId?: number; + skipModelFetch?: boolean; +}; + +export type CreateManualAccountParams = { + body: AccountCreatePayload; + site: typeof schema.sites.$inferSelect; + adapter: NonNullable>; + credentialMode: AccountCredentialMode; + rawAccessToken: string; + usernameOverride?: string; +}; + +export type CreateManualAccountResult = { + account: typeof schema.accounts.$inferSelect; + tokenType: 'session' | 'apikey' | 'unknown'; + modelCount: number; + apiTokenFound: boolean; + usernameDetected: boolean; + queued: boolean; + jobId?: string; + message?: string; +}; + +async function withTimeout(fn: () => Promise, timeoutMs: number, timeoutMessage: string): Promise { + let timer: ReturnType | null = null; + try { + return await Promise.race([ + fn(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function buildAccountVerifyTimeoutMessage(): string { + return `Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`; +} + +async function getNextAccountSortOrder(): Promise { + const rows = await db.select({ sortOrder: schema.accounts.sortOrder }).from(schema.accounts).all(); + const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1); + return max + 1; +} + +async function getModelsWithSiteApiEndpointPool( + site: typeof schema.sites.$inferSelect, + adapter: NonNullable>, + accessToken: string, + platformUserId?: number, +): Promise { + const timeoutMessage = buildAccountVerifyTimeoutMessage(); + const deadline = Date.now() + ACCOUNT_VERIFY_TIMEOUT_MS; + return runWithSiteApiEndpointPool(site, (target) => { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + throw new Error(timeoutMessage); + } + return withTimeout( + () => adapter.getModels(target.baseUrl, accessToken, platformUserId), + remainingMs, + timeoutMessage, + ); + }); +} + +async function initializeAccountInBackground({ + accountId, + site, + adapter, + tokenType, + accessToken, + apiToken, + platformUserId, + skipModelFetch, +}: AccountInitializationParams) { + const summary = { + accountId, + syncedTokenCount: 0, + refreshedBalance: false, + refreshedModels: false, + rebuiltRoutes: false, + }; + + let fetchedUpstreamTokens: Array<{ name?: string | null; key?: string | null; enabled?: boolean | null; tokenGroup?: string | null }> = []; + if (tokenType === 'session' && accessToken) { + try { + const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId); + summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0; + fetchedUpstreamTokens = Array.isArray(syncedTokens) ? syncedTokens : []; + } catch {} + } + + const convergence = await convergeAccountMutation({ + accountId, + preferredApiToken: tokenType === 'session' ? apiToken : null, + defaultTokenSource: 'manual', + ensurePreferredTokenBeforeSync: tokenType === 'session', + upstreamTokens: fetchedUpstreamTokens, + refreshBalance: tokenType === 'session', + refreshModels: skipModelFetch !== true, + rebuildRoutes: skipModelFetch !== true, + continueOnError: true, + }); + summary.refreshedBalance = convergence.refreshedBalance; + summary.refreshedModels = convergence.refreshedModels; + summary.rebuiltRoutes = convergence.rebuiltRoutes; + + return summary; +} + +function buildQueuedAccountInitializationMessage( + tokenType: 'session' | 'apikey' | 'unknown', + skipModelFetch?: boolean, +) { + if (tokenType === 'session' && skipModelFetch === true) { + return '账号已添加,后台正在同步令牌和余额信息。'; + } + if (tokenType === 'session') { + return '账号已添加,后台正在同步令牌、余额和模型信息。'; + } + if (skipModelFetch === true) { + return '已添加为 API Key 账号(可用于代理转发)。'; + } + return '已添加为 API Key 账号,后台正在同步模型和路由信息。'; +} + +export async function createManualAccount({ + body, + site, + adapter, + credentialMode, + rawAccessToken, + usernameOverride, +}: CreateManualAccountParams): Promise { + let username = typeof usernameOverride === 'string' + ? usernameOverride.trim() + : (body.username || '').trim(); + let accessToken = rawAccessToken; + let apiToken = (body.apiToken || '').trim(); + let tokenType: 'session' | 'apikey' | 'unknown' = 'unknown'; + let verifiedModels: string[] = []; + + if (credentialMode === 'apikey') { + if (body.skipModelFetch === true) { + tokenType = 'apikey'; + accessToken = ''; + if (!apiToken) apiToken = rawAccessToken; + } else { + const models = await getModelsWithSiteApiEndpointPool( + site, + adapter, + rawAccessToken, + body.platformUserId, + ); + verifiedModels = Array.isArray(models) + ? models.filter((item) => typeof item === 'string' && item.trim().length > 0) + : []; + if (verifiedModels.length === 0) { + const error = new Error('API Key 验证失败:未获取到可用模型'); + (error as Error & { requiresVerification?: boolean }).requiresVerification = true; + throw error; + } + + tokenType = 'apikey'; + accessToken = ''; + if (!apiToken) apiToken = rawAccessToken; + } + } else { + const verifyResult = await withTimeout( + () => adapter.verifyToken(site.url, rawAccessToken, body.platformUserId), + ACCOUNT_VERIFY_TIMEOUT_MS, + buildAccountVerifyTimeoutMessage(), + ); + tokenType = verifyResult.tokenType; + if (tokenType === 'unknown') { + const error = new Error('Token 验证失败,请先点击“验证 Token”,验证成功后再绑定账号'); + (error as Error & { requiresVerification?: boolean }).requiresVerification = true; + throw error; + } + + if (credentialMode === 'session' && tokenType !== 'session') { + throw new Error('当前凭证是 API Key,请切换到 API Key 模式,或改用 Session Token'); + } + + if (tokenType === 'session') { + if (!username && verifyResult.userInfo?.username) username = String(verifyResult.userInfo.username).trim(); + if (!apiToken && verifyResult.apiToken) apiToken = String(verifyResult.apiToken).trim(); + } else if (tokenType === 'apikey') { + accessToken = ''; + if (!apiToken) apiToken = rawAccessToken; + verifiedModels = Array.isArray(verifyResult.models) + ? verifyResult.models.filter((item: unknown) => typeof item === 'string' && item.trim().length > 0) + : []; + } + } + + const resolvedPlatformUserId = + body.platformUserId || guessPlatformUserIdFromUsername(username) || undefined; + const resolvedCredentialMode: AccountCredentialMode = tokenType === 'apikey' ? 'apikey' : 'session'; + const extraConfigPatch: Record = { credentialMode: resolvedCredentialMode }; + if (resolvedPlatformUserId) { + extraConfigPatch.platformUserId = resolvedPlatformUserId; + } + if ((site.platform || '').toLowerCase() === 'sub2api') { + const managedRefreshToken = typeof body.refreshToken === 'string' ? body.refreshToken.trim() : ''; + const managedTokenExpiresAt = typeof body.tokenExpiresAt === 'number' + ? Math.trunc(body.tokenExpiresAt) + : (typeof body.tokenExpiresAt === 'string' ? Number.parseInt(body.tokenExpiresAt.trim(), 10) : undefined); + if (managedRefreshToken) { + extraConfigPatch.sub2apiAuth = managedTokenExpiresAt && Number.isFinite(managedTokenExpiresAt) && managedTokenExpiresAt > 0 + ? { refreshToken: managedRefreshToken, tokenExpiresAt: managedTokenExpiresAt } + : { refreshToken: managedRefreshToken }; + } + } + const extraConfig = mergeAccountExtraConfig(undefined, extraConfigPatch); + + const result = await insertAndGetById({ + table: schema.accounts, + idColumn: schema.accounts.id, + values: { + siteId: body.siteId, + username: username || undefined, + accessToken, + apiToken: apiToken || undefined, + checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false, + extraConfig, + isPinned: false, + sortOrder: await getNextAccountSortOrder(), + }, + insertErrorMessage: '创建账号失败', + loadErrorMessage: '创建账号失败', + }); + + const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true; + let queuedTaskId: string | undefined; + let queuedMessage: string | undefined; + if (shouldQueueInitialization) { + const taskTitle = `初始化连接 #${result.id}`; + const { task } = startBackgroundTask( + { + type: 'account-init', + title: taskTitle, + dedupeKey: `account-init-${result.id}`, + notifyOnFailure: true, + successMessage: () => `${taskTitle}已完成`, + failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`, + }, + async () => initializeAccountInBackground({ + accountId: result.id, + site, + adapter, + tokenType, + accessToken, + apiToken, + platformUserId: resolvedPlatformUserId, + skipModelFetch: body.skipModelFetch, + }), + ); + queuedTaskId = task.id; + queuedMessage = buildQueuedAccountInitializationMessage(tokenType, body.skipModelFetch); + } + + const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get(); + if (!account) { + throw new Error('创建账号失败'); + } + + return { + account, + tokenType, + modelCount: verifiedModels.length, + apiTokenFound: !!apiToken, + usernameDetected: !!(!body.username && username), + queued: !!queuedTaskId, + jobId: queuedTaskId, + message: queuedMessage, + }; +} diff --git a/src/server/services/shared/apiKeyBatchCore.ts b/src/server/services/shared/apiKeyBatchCore.ts new file mode 100644 index 0000000..9ca236c --- /dev/null +++ b/src/server/services/shared/apiKeyBatchCore.ts @@ -0,0 +1,26 @@ +export function parseBatchApiKeys(input: unknown): string[] { + if (Array.isArray(input)) { + return Array.from(new Set( + input + .map((item) => String(item || '').trim()) + .filter((item) => item.length > 0), + )); + } + + const raw = String(input || '').trim(); + if (!raw) return []; + + return Array.from(new Set( + raw + .split(/[\r\n,,;;\s]+/) + .map((item) => item.trim()) + .filter((item) => item.length > 0), + )); +} + +export function buildBatchApiKeyConnectionName(baseName: string | null | undefined, index: number, total: number): string { + const normalized = String(baseName || '').trim(); + if (!normalized) return ''; + if (total <= 1) return normalized; + return `${normalized} #${index + 1}`; +} diff --git a/src/server/services/tokenRouter.patterns.test.ts b/src/server/services/tokenRouter.patterns.test.ts index f764b24..23ec683 100644 --- a/src/server/services/tokenRouter.patterns.test.ts +++ b/src/server/services/tokenRouter.patterns.test.ts @@ -189,12 +189,11 @@ describe('TokenRouter patterns and model mapping', () => { expect(exposedModels).toContain('claude-opus-4-6'); }); - it('prefers an exact route over a colliding group display-name alias', async () => { - await createRouteWithSingleChannel( - 're:^claude-(opus|sonnet)-4-5$', + it('prefers a group display-name alias over a colliding exact route', async () => { + const source = await createRouteWithSingleChannel( + 'claude-opus-4-5', undefined, { - displayName: 'claude-opus-4-6', sourceModel: 'claude-opus-4-5', }, ); @@ -205,15 +204,18 @@ describe('TokenRouter patterns and model mapping', () => { sourceModel: 'claude-opus-4-6', }, ); + const grouped = await createExplicitGroupRoute('claude-opus-4-6', [source.route.id]); const router = new TokenRouter(); const selected = await router.selectChannel('claude-opus-4-6'); const decision = await router.explainSelection('claude-opus-4-6'); expect(selected).toBeTruthy(); - expect(selected?.channel.id).toBe(exact.channel.id); - expect(selected?.actualModel).toBe('claude-opus-4-6'); - expect(decision.actualModel).toBe('claude-opus-4-6'); + expect(selected?.channel.routeId).toBe(source.route.id); + expect(selected?.channel.id).not.toBe(exact.channel.id); + expect(selected?.actualModel).toBe('claude-opus-4-5'); + expect(decision.routeId).toBe(grouped.id); + expect(decision.actualModel).toBe('claude-opus-4-5'); }); it('falls back to the source exact-route model when explicit-group channels omit sourceModel', async () => { diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index 24301c0..a9bbd27 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -3041,12 +3041,12 @@ export class TokenRouter { routes = routes.filter((route) => allowSet.has(route.id)); } - const matchedRoute = routes.find((route) => ( - !isExplicitGroupRoute(route) - && isExactRouteModelPattern(route.modelPattern) - && (route.modelPattern || '').trim() === model - )) - || routes.find((route) => isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName)) + const matchedRoute = routes.find((route) => isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName)) + || routes.find((route) => ( + !isExplicitGroupRoute(route) + && isExactRouteModelPattern(route.modelPattern) + && (route.modelPattern || '').trim() === model + )) || routes.find((route) => !isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName)) || routes.find((route) => !isExplicitGroupRoute(route) && matchesModelPattern(model, route.modelPattern)); diff --git a/src/shared/apiKeyBatch.ts b/src/shared/apiKeyBatch.ts new file mode 100644 index 0000000..12b7a6f --- /dev/null +++ b/src/shared/apiKeyBatch.ts @@ -0,0 +1,4 @@ +export { + buildBatchApiKeyConnectionName, + parseBatchApiKeys, +} from '../server/services/shared/apiKeyBatchCore.js'; diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 2341226..119dd6d 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -29,6 +29,7 @@ import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from '. import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js'; import { SITE_DOCS_URL } from '../docsLink.js'; import { getSiteInitializationPreset } from '../../shared/siteInitializationPresets.js'; +import { parseBatchApiKeys } from '../../shared/apiKeyBatch.js'; type ConnectionsSegment = 'session' | 'apikey' | 'tokens'; @@ -177,6 +178,11 @@ export default function Accounts() { () => sites.find((item) => item.id === tokenForm.siteId) || null, [sites, tokenForm.siteId], ); + const parsedApiKeys = useMemo( + () => activeSegment === 'apikey' ? parseBatchApiKeys(tokenForm.accessToken) : [], + [activeSegment, tokenForm.accessToken], + ); + const isBatchApiKeyInput = activeSegment === 'apikey' && parsedApiKeys.length > 1; const siteSelectOptions = useMemo( () => [ { value: '0', label: '选择站点' }, @@ -322,6 +328,10 @@ export default function Accounts() { const handleVerifyToken = async () => { if (!tokenForm.siteId || !tokenForm.accessToken) return; + if (isBatchApiKeyInput) { + toast.info(`检测到 ${parsedApiKeys.length} 个 API Key,批量模式会在添加时逐条校验`); + return; + } const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session'; setVerifying(true); setVerifyResult(null); @@ -352,7 +362,7 @@ export default function Accounts() { const handleTokenAdd = async () => { if (!tokenForm.siteId || !tokenForm.accessToken) return; - if (!verifyResult?.success && !tokenForm.skipModelFetch) { + if (!isBatchApiKeyInput && !verifyResult?.success && !tokenForm.skipModelFetch) { toast.error('请先验证 Token 成功后再添加账号'); return; } @@ -364,6 +374,7 @@ export default function Accounts() { siteId: tokenForm.siteId, username: tokenForm.username.trim() || undefined, accessToken: tokenForm.accessToken, + accessTokens: isBatchApiKeyInput ? parsedApiKeys : undefined, platformUserId: tokenForm.platformUserId ? parseInt(tokenForm.platformUserId) : undefined, refreshToken: isSub2ApiSelected && tokenForm.refreshToken.trim() ? tokenForm.refreshToken.trim() @@ -374,6 +385,23 @@ export default function Accounts() { credentialMode, skipModelFetch: tokenForm.skipModelFetch, }); + if (result?.batch) { + closeAddPanel(); + const createdCount = Number(result.createdCount) || 0; + const failedCount = Number(result.failedCount) || 0; + if (createdCount > 0) { + toast.success(`批量添加完成:成功 ${createdCount},失败 ${failedCount}`); + } + const failedItems = Array.isArray(result.items) + ? result.items.filter((item: any) => item?.status === 'failed') + : []; + if (failedItems.length > 0) { + const firstMessage = failedItems[0]?.message || '创建失败'; + toast.error(`失败 ${failedItems.length} 条:${firstMessage}`); + } + load(); + return; + } let seededRecommendedModels = false; const recommendedModels = initializationPreset?.recommendedModels || []; const createdAccountId = Number(result?.id) || 0; @@ -951,6 +979,9 @@ export default function Accounts() { || (activeSegment === 'session' && verifyResult.tokenType === 'session') ), ); + const canSubmitApiKeyConnection = activeSegment === 'apikey' + ? (isBatchApiKeyInput || canAddVerifiedConnection || !!tokenForm.skipModelFetch) + : canAddVerifiedConnection; return (
@@ -1421,9 +1452,18 @@ export default function Accounts() {