From 6bd8cdb9cfc865ab74c104c9b613a477e4820ee3 Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Tue, 30 Dec 2025 17:15:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E9=82=AE=E7=AE=B1?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E6=B3=A8=E5=86=8C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加完整的邮箱验证注册系统,包括验证码发送、验证和限流控制: - 新增邮箱验证服务模块(email_sender, email_template, email_verification) - 更新认证API支持邮箱验证注册流程 - 添加注册对话框和验证码输入组件 - 完善IP限流器支持邮箱验证场景 - 修复前端类型定义问题,升级esbuild依赖 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- frontend/package-lock.json | 522 +++++------------- frontend/src/api/admin.ts | 9 + frontend/src/api/auth.ts | 67 +++ .../src/components/VerificationCodeInput.vue | 192 +++++++ .../features/auth/components/LoginDialog.vue | 61 +- .../auth/components/RegisterDialog.vue | 443 +++++++++++++++ .../components/BatchAssignModelsDialog.vue | 4 +- .../components/EndpointFormDialog.vue | 4 +- .../components/KeyAllowedModelsDialog.vue | 10 +- .../provider-tabs/ModelAliasesTab.vue | 10 +- frontend/src/views/admin/CacheMonitoring.vue | 5 +- frontend/src/views/admin/SystemSettings.vue | 326 ++++++++++- frontend/src/views/shared/Dashboard.vue | 2 +- src/api/admin/system.py | 67 +++ src/api/auth/routes.py | 186 +++++++ src/models/api.py | 65 +++ src/services/rate_limit/ip_limiter.py | 2 + src/services/verification/__init__.py | 9 + src/services/verification/email_sender.py | 365 ++++++++++++ src/services/verification/email_template.py | 238 ++++++++ .../verification/email_verification.py | 281 ++++++++++ 21 files changed, 2464 insertions(+), 404 deletions(-) create mode 100644 frontend/src/components/VerificationCodeInput.vue create mode 100644 frontend/src/features/auth/components/RegisterDialog.vue create mode 100644 src/services/verification/__init__.py create mode 100644 src/services/verification/email_sender.py create mode 100644 src/services/verification/email_template.py create mode 100644 src/services/verification/email_verification.py diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2962f47..a97141a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -262,6 +262,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -305,6 +306,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -316,9 +318,9 @@ "license": "Apache-2.0" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", - "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", "cpu": [ "ppc64" ], @@ -333,9 +335,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", - "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", "cpu": [ "arm" ], @@ -350,9 +352,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", - "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", "cpu": [ "arm64" ], @@ -367,9 +369,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", - "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", "cpu": [ "x64" ], @@ -384,9 +386,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", - "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", "cpu": [ "arm64" ], @@ -401,9 +403,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", - "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", "cpu": [ "x64" ], @@ -418,9 +420,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", - "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", "cpu": [ "arm64" ], @@ -435,9 +437,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", - "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", "cpu": [ "x64" ], @@ -452,9 +454,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", - "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", "cpu": [ "arm" ], @@ -469,9 +471,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", - "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", "cpu": [ "arm64" ], @@ -486,9 +488,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", - "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", "cpu": [ "ia32" ], @@ -503,9 +505,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", - "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", "cpu": [ "loong64" ], @@ -520,9 +522,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", - "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", "cpu": [ "mips64el" ], @@ -537,9 +539,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", - "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", "cpu": [ "ppc64" ], @@ -554,9 +556,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", - "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", "cpu": [ "riscv64" ], @@ -571,9 +573,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", - "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", "cpu": [ "s390x" ], @@ -588,9 +590,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", - "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", "cpu": [ "x64" ], @@ -605,9 +607,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", - "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", "cpu": [ "arm64" ], @@ -622,9 +624,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", - "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", "cpu": [ "x64" ], @@ -639,9 +641,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", - "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", "cpu": [ "arm64" ], @@ -656,9 +658,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", - "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", "cpu": [ "x64" ], @@ -673,9 +675,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", - "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", "cpu": [ "arm64" ], @@ -690,9 +692,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", - "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", "cpu": [ "x64" ], @@ -707,9 +709,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", - "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", "cpu": [ "arm64" ], @@ -724,9 +726,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", - "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", "cpu": [ "ia32" ], @@ -741,9 +743,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", - "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", "cpu": [ "x64" ], @@ -1598,6 +1600,7 @@ "integrity": "sha512-GKBNHjoNw3Kra1Qg5UXttsY5kiWMEfoHq2TmXb+b1rcm6N7B3wTrFYIf/oSZ1xNQ+hVVijgLkiDZh7jRRsh+Gw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.10.0" } @@ -1676,6 +1679,7 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -2004,6 +2008,7 @@ "integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.10", "fflate": "^0.8.2", @@ -2301,6 +2306,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2602,6 +2608,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.2", "caniuse-lite": "^1.0.30001741", @@ -2718,6 +2725,7 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -2940,6 +2948,7 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -2999,18 +3008,6 @@ "node": ">=0.4.0" } }, - "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -3134,9 +3131,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", - "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3147,32 +3144,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.9", - "@esbuild/android-arm": "0.25.9", - "@esbuild/android-arm64": "0.25.9", - "@esbuild/android-x64": "0.25.9", - "@esbuild/darwin-arm64": "0.25.9", - "@esbuild/darwin-x64": "0.25.9", - "@esbuild/freebsd-arm64": "0.25.9", - "@esbuild/freebsd-x64": "0.25.9", - "@esbuild/linux-arm": "0.25.9", - "@esbuild/linux-arm64": "0.25.9", - "@esbuild/linux-ia32": "0.25.9", - "@esbuild/linux-loong64": "0.25.9", - "@esbuild/linux-mips64el": "0.25.9", - "@esbuild/linux-ppc64": "0.25.9", - "@esbuild/linux-riscv64": "0.25.9", - "@esbuild/linux-s390x": "0.25.9", - "@esbuild/linux-x64": "0.25.9", - "@esbuild/netbsd-arm64": "0.25.9", - "@esbuild/netbsd-x64": "0.25.9", - "@esbuild/openbsd-arm64": "0.25.9", - "@esbuild/openbsd-x64": "0.25.9", - "@esbuild/openharmony-arm64": "0.25.9", - "@esbuild/sunos-x64": "0.25.9", - "@esbuild/win32-arm64": "0.25.9", - "@esbuild/win32-ia32": "0.25.9", - "@esbuild/win32-x64": "0.25.9" + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" } }, "node_modules/escalade": { @@ -3204,6 +3201,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3747,9 +3745,9 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -4084,18 +4082,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/jiti": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", - "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -4115,6 +4101,7 @@ "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.23", "@asamuzakjp/dom-selector": "^6.7.4", @@ -4194,257 +4181,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lightningcss": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", - "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", - "dev": true, - "license": "MPL-2.0", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-darwin-arm64": "1.30.1", - "lightningcss-darwin-x64": "1.30.1", - "lightningcss-freebsd-x64": "1.30.1", - "lightningcss-linux-arm-gnueabihf": "1.30.1", - "lightningcss-linux-arm64-gnu": "1.30.1", - "lightningcss-linux-arm64-musl": "1.30.1", - "lightningcss-linux-x64-gnu": "1.30.1", - "lightningcss-linux-x64-musl": "1.30.1", - "lightningcss-win32-arm64-msvc": "1.30.1", - "lightningcss-win32-x64-msvc": "1.30.1" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", - "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", - "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", - "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", - "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", - "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", - "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", - "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", - "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", - "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", - "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -4930,6 +4666,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4997,6 +4734,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6027,6 +5765,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6115,13 +5854,14 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", - "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -6195,6 +5935,7 @@ "integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.10", "@vitest/mocker": "4.0.10", @@ -6279,6 +6020,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz", "integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.21", "@vue/compiler-sfc": "3.5.21", @@ -6311,7 +6053,6 @@ "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", @@ -6336,7 +6077,6 @@ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 390de45..41d6141 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -386,5 +386,14 @@ export const adminApi = { { provider_id: providerId, api_key_id: apiKeyId } ) return response.data + }, + + // 测试 SMTP 连接,支持传入未保存的配置 + async testSmtpConnection(config: Record = {}): Promise<{ success: boolean; message: string }> { + const response = await apiClient.post<{ success: boolean; message: string }>( + '/api/admin/system/smtp/test', + config + ) + return response.data } } diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 60e527f..e0079d5 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -31,6 +31,45 @@ export interface UserStats { [key: string]: unknown // 允许扩展其他统计数据 } +export interface SendVerificationCodeRequest { + email: string +} + +export interface SendVerificationCodeResponse { + message: string + success: boolean + expire_minutes?: number +} + +export interface VerifyEmailRequest { + email: string + code: string +} + +export interface VerifyEmailResponse { + message: string + success: boolean +} + +export interface RegisterRequest { + email: string + username: string + password: string +} + +export interface RegisterResponse { + user_id: string + email: string + username: string + message: string +} + +export interface RegistrationSettingsResponse { + enable_registration: boolean + require_email_verification: boolean + verification_code_expire_minutes?: number +} + export interface User { id: string // UUID username: string @@ -87,5 +126,33 @@ export const authApi = { localStorage.setItem('refresh_token', response.data.refresh_token) } return response.data + }, + + async sendVerificationCode(email: string): Promise { + const response = await apiClient.post( + '/api/auth/send-verification-code', + { email } + ) + return response.data + }, + + async verifyEmail(email: string, code: string): Promise { + const response = await apiClient.post( + '/api/auth/verify-email', + { email, code } + ) + return response.data + }, + + async register(data: RegisterRequest): Promise { + const response = await apiClient.post('/api/auth/register', data) + return response.data + }, + + async getRegistrationSettings(): Promise { + const response = await apiClient.get( + '/api/auth/registration-settings' + ) + return response.data } } diff --git a/frontend/src/components/VerificationCodeInput.vue b/frontend/src/components/VerificationCodeInput.vue new file mode 100644 index 0000000..1fcca81 --- /dev/null +++ b/frontend/src/components/VerificationCodeInput.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/frontend/src/features/auth/components/LoginDialog.vue b/frontend/src/features/auth/components/LoginDialog.vue index 0e65ba6..a2f6d83 100644 --- a/frontend/src/features/auth/components/LoginDialog.vue +++ b/frontend/src/features/auth/components/LoginDialog.vue @@ -98,12 +98,27 @@

如需开通账户,请联系管理员配置访问权限

+ + +
+ 还没有账户? + +
+ + + diff --git a/frontend/src/features/auth/components/RegisterDialog.vue b/frontend/src/features/auth/components/RegisterDialog.vue new file mode 100644 index 0000000..9cc3632 --- /dev/null +++ b/frontend/src/features/auth/components/RegisterDialog.vue @@ -0,0 +1,443 @@ + + + diff --git a/frontend/src/features/providers/components/BatchAssignModelsDialog.vue b/frontend/src/features/providers/components/BatchAssignModelsDialog.vue index a629bbd..ba5c04c 100644 --- a/frontend/src/features/providers/components/BatchAssignModelsDialog.vue +++ b/frontend/src/features/providers/components/BatchAssignModelsDialog.vue @@ -374,8 +374,6 @@ import { } from '@/api/endpoints' import { useUpstreamModelsCache, type UpstreamModel } from '../composables/useUpstreamModelsCache' -const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache() - const props = defineProps<{ open: boolean providerId: string @@ -388,6 +386,8 @@ const emit = defineEmits<{ 'changed': [] }>() +const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache() + const { error: showError, success } = useToast() // 状态 diff --git a/frontend/src/features/providers/components/EndpointFormDialog.vue b/frontend/src/features/providers/components/EndpointFormDialog.vue index 3f4c200..222537a 100644 --- a/frontend/src/features/providers/components/EndpointFormDialog.vue +++ b/frontend/src/features/providers/components/EndpointFormDialog.vue @@ -177,8 +177,8 @@ 密码(可选) - - + + diff --git a/frontend/src/features/providers/components/provider-tabs/ModelAliasesTab.vue b/frontend/src/features/providers/components/provider-tabs/ModelAliasesTab.vue index 11ce169..322bbc2 100644 --- a/frontend/src/features/providers/components/provider-tabs/ModelAliasesTab.vue +++ b/frontend/src/features/providers/components/provider-tabs/ModelAliasesTab.vue @@ -131,8 +131,14 @@ :disabled="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`" @click="testMapping(group, mapping)" > - - + + diff --git a/frontend/src/views/admin/CacheMonitoring.vue b/frontend/src/views/admin/CacheMonitoring.vue index 8ae4521..7363597 100644 --- a/frontend/src/views/admin/CacheMonitoring.vue +++ b/frontend/src/views/admin/CacheMonitoring.vue @@ -1057,7 +1057,10 @@ onBeforeUnmount(() => {
- diff --git a/frontend/src/views/admin/SystemSettings.vue b/frontend/src/views/admin/SystemSettings.vue index 2b222a6..a95fad3 100644 --- a/frontend/src/views/admin/SystemSettings.vue +++ b/frontend/src/views/admin/SystemSettings.vue @@ -185,6 +185,218 @@
+ + +
+
+ + +

+ 邮件服务器地址 +

+
+ +
+ + +

+ 常用端口: 587 (TLS), 465 (SSL), 25 (非加密) +

+
+ +
+ + +

+ 通常是您的邮箱地址 +

+
+ +
+ + +

+ 邮箱密码或应用专用密码 +

+
+ +
+ + +

+ 显示为发件人的邮箱地址 +

+
+ +
+ + +

+ 显示为发件人的名称 +

+
+ +
+ + +

+ 验证码的有效时间 +

+
+ +
+
+ +
+ +

+ 推荐开启以提高安全性 +

+
+
+
+ +
+
+ +
+ +

+ 部分服务需要隐式 SSL,一般使用端口 465 +

+
+
+
+
+ +
+ +
+ +
+

+ {{ smtpTestResult.success ? '✓ SMTP 连接测试成功' : '✗ SMTP 连接测试失败' }} +

+

+ {{ smtpTestResult.message }} +

+
+
+ - @@ -798,6 +1009,16 @@ interface SystemConfig { // 用户注册 enable_registration: boolean require_email_verification: boolean + // SMTP 邮件配置 + smtp_host: string | null + smtp_port: number + smtp_user: string | null + smtp_password: string | null + smtp_use_tls: boolean + smtp_use_ssl: boolean + smtp_from_email: string | null + smtp_from_name: string + verification_code_expire_minutes: number // 独立余额 Key 过期管理 auto_delete_expired_keys: boolean // 日志记录 @@ -817,6 +1038,8 @@ interface SystemConfig { const loading = ref(false) const logLevelSelectOpen = ref(false) +const testSmtpLoading = ref(false) +const smtpTestResult = ref<{ success: boolean; message?: string } | null>(null) // 导出/导入相关 const exportLoading = ref(false) @@ -847,6 +1070,16 @@ const systemConfig = ref({ // 用户注册 enable_registration: false, require_email_verification: false, + // SMTP 邮件配置 + smtp_host: null, + smtp_port: 587, + smtp_user: null, + smtp_password: null, + smtp_use_tls: true, + smtp_use_ssl: false, + smtp_from_email: null, + smtp_from_name: 'Aether', + verification_code_expire_minutes: 30, // 独立余额 Key 过期管理 auto_delete_expired_keys: false, // 日志记录 @@ -903,6 +1136,16 @@ async function loadSystemConfig() { // 用户注册 'enable_registration', 'require_email_verification', + // SMTP 邮件配置 + 'smtp_host', + 'smtp_port', + 'smtp_user', + 'smtp_password', + 'smtp_use_tls', + 'smtp_use_ssl', + 'smtp_from_email', + 'smtp_from_name', + 'verification_code_expire_minutes', // 独立余额 Key 过期管理 'auto_delete_expired_keys', // 日志记录 @@ -962,6 +1205,52 @@ async function saveSystemConfig() { value: systemConfig.value.require_email_verification, description: '是否需要邮箱验证' }, + // SMTP 邮件配置 + { + key: 'smtp_host', + value: systemConfig.value.smtp_host, + description: 'SMTP 服务器地址' + }, + { + key: 'smtp_port', + value: systemConfig.value.smtp_port, + description: 'SMTP 端口' + }, + { + key: 'smtp_user', + value: systemConfig.value.smtp_user, + description: 'SMTP 用户名' + }, + { + key: 'smtp_password', + value: systemConfig.value.smtp_password, + description: 'SMTP 密码' + }, + { + key: 'smtp_use_tls', + value: systemConfig.value.smtp_use_tls, + description: '是否使用 TLS 加密' + }, + { + key: 'smtp_use_ssl', + value: systemConfig.value.smtp_use_ssl, + description: '是否使用 SSL 加密' + }, + { + key: 'smtp_from_email', + value: systemConfig.value.smtp_from_email, + description: '发件人邮箱' + }, + { + key: 'smtp_from_name', + value: systemConfig.value.smtp_from_name, + description: '发件人名称' + }, + { + key: 'verification_code_expire_minutes', + value: systemConfig.value.verification_code_expire_minutes, + description: '验证码有效期(分钟)' + }, // 独立余额 Key 过期管理 { key: 'auto_delete_expired_keys', @@ -1041,6 +1330,41 @@ async function saveSystemConfig() { } } +// 测试 SMTP 连接 +async function handleTestSmtp() { + testSmtpLoading.value = true + smtpTestResult.value = null + + try { + const result = await adminApi.testSmtpConnection({ + smtp_host: systemConfig.value.smtp_host, + smtp_port: systemConfig.value.smtp_port, + smtp_user: systemConfig.value.smtp_user, + smtp_password: systemConfig.value.smtp_password, + smtp_use_tls: systemConfig.value.smtp_use_tls, + smtp_use_ssl: systemConfig.value.smtp_use_ssl, + smtp_from_email: systemConfig.value.smtp_from_email, + smtp_from_name: systemConfig.value.smtp_from_name + }) + smtpTestResult.value = result + + if (result.success) { + success('SMTP 连接测试成功') + } else { + error('SMTP 连接测试失败') + } + } catch (err: any) { + log.error('SMTP 连接测试失败:', err) + smtpTestResult.value = { + success: false, + message: err.response?.data?.detail || err.message || 'SMTP 连接测试失败' + } + error('SMTP 连接测试失败') + } finally { + testSmtpLoading.value = false + } +} + // 导出配置 async function handleExportConfig() { exportLoading.value = true diff --git a/frontend/src/views/shared/Dashboard.vue b/frontend/src/views/shared/Dashboard.vue index b1eee04..c0a2b57 100644 --- a/frontend/src/views/shared/Dashboard.vue +++ b/frontend/src/views/shared/Dashboard.vue @@ -179,8 +179,8 @@
diff --git a/src/api/admin/system.py b/src/api/admin/system.py index fb0427b..54cf63b 100644 --- a/src/api/admin/system.py +++ b/src/api/admin/system.py @@ -119,6 +119,13 @@ async def import_users(request: Request, db: Session = Depends(get_db)): return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) +@router.post("/smtp/test") +async def test_smtp(request: Request, db: Session = Depends(get_db)): + """测试 SMTP 连接(管理员)""" + adapter = AdminTestSmtpAdapter() + return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) + + # -------- 系统设置适配器 -------- @@ -1084,3 +1091,63 @@ class AdminImportUsersAdapter(AdminApiAdapter): except Exception as e: db.rollback() raise InvalidRequestException(f"导入失败: {str(e)}") + + +class AdminTestSmtpAdapter(AdminApiAdapter): + async def handle(self, context): # type: ignore[override] + """测试 SMTP 连接""" + from src.services.system.config import ConfigService + from src.services.verification.email_sender import EmailSenderService + + db = context.db + payload = context.ensure_json_body() or {} + + # 前端可传入未保存的配置,优先使用前端值,否则回退数据库 + config = { + "smtp_host": payload.get("smtp_host") or ConfigService.get_config(db, "smtp_host"), + "smtp_port": payload.get("smtp_port") or ConfigService.get_config(db, "smtp_port", default=587), + "smtp_user": payload.get("smtp_user") or ConfigService.get_config(db, "smtp_user"), + "smtp_password": payload.get("smtp_password") or ConfigService.get_config(db, "smtp_password"), + "smtp_use_tls": payload.get("smtp_use_tls") + if payload.get("smtp_use_tls") is not None + else ConfigService.get_config(db, "smtp_use_tls", default=True), + "smtp_use_ssl": payload.get("smtp_use_ssl") + if payload.get("smtp_use_ssl") is not None + else ConfigService.get_config(db, "smtp_use_ssl", default=False), + "smtp_from_email": payload.get("smtp_from_email") + or ConfigService.get_config(db, "smtp_from_email"), + "smtp_from_name": payload.get("smtp_from_name") + or ConfigService.get_config(db, "smtp_from_name", default="Aether"), + } + + # 验证必要配置 + missing_fields = [ + field for field in ["smtp_host", "smtp_user", "smtp_password", "smtp_from_email"] if not config.get(field) + ] + if missing_fields: + return { + "success": False, + "message": f"SMTP 配置不完整,请检查 {', '.join(missing_fields)}" + } + + # 测试连接 + try: + success, error_msg = await EmailSenderService.test_smtp_connection( + db=db, override_config=config + ) + + if success: + return { + "success": True, + "message": "SMTP 连接测试成功" + } + else: + return { + "success": False, + "message": f"SMTP 连接测试失败: {error_msg}" + } + except Exception as e: + return { + "success": False, + "message": f"SMTP 连接测试失败: {str(e)}" + } diff --git a/src/api/auth/routes.py b/src/api/auth/routes.py index 208780e..6f4b963 100644 --- a/src/api/auth/routes.py +++ b/src/api/auth/routes.py @@ -23,12 +23,19 @@ from src.models.api import ( RefreshTokenResponse, RegisterRequest, RegisterResponse, + RegistrationSettingsResponse, + SendVerificationCodeRequest, + SendVerificationCodeResponse, + VerifyEmailRequest, + VerifyEmailResponse, ) from src.models.database import AuditEventType, User, UserRole from src.services.auth.service import AuthService from src.services.rate_limit.ip_limiter import IPRateLimiter from src.services.system.audit import AuditService +from src.services.system.config import ConfigService from src.services.user.service import UserService +from src.services.verification import EmailSenderService, EmailVerificationService from src.utils.request_utils import get_client_ip, get_user_agent @@ -38,6 +45,13 @@ pipeline = ApiRequestPipeline() # API端点 +@router.get("/registration-settings", response_model=RegistrationSettingsResponse) +async def registration_settings(request: Request, db: Session = Depends(get_db)): + """公开获取注册相关配置""" + adapter = AuthRegistrationSettingsAdapter() + return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) + + @router.post("/login", response_model=LoginResponse) async def login(request: Request, db: Session = Depends(get_db)): adapter = AuthLoginAdapter() @@ -75,6 +89,20 @@ async def logout(request: Request, db: Session = Depends(get_db)): return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) +@router.post("/send-verification-code", response_model=SendVerificationCodeResponse) +async def send_verification_code(request: Request, db: Session = Depends(get_db)): + """发送邮箱验证码""" + adapter = AuthSendVerificationCodeAdapter() + return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) + + +@router.post("/verify-email", response_model=VerifyEmailResponse) +async def verify_email(request: Request, db: Session = Depends(get_db)): + """验证邮箱验证码""" + adapter = AuthVerifyEmailAdapter() + return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) + + # ============== 适配器实现 ============== @@ -209,6 +237,24 @@ class AuthRefreshAdapter(AuthPublicAdapter): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌失败") +class AuthRegistrationSettingsAdapter(AuthPublicAdapter): + async def handle(self, context): # type: ignore[override] + """公开返回注册相关配置""" + db = context.db + + enable_registration = ConfigService.get_config(db, "enable_registration", default=False) + require_verification = ConfigService.get_config(db, "require_email_verification", default=False) + expire_minutes = ConfigService.get_config( + db, "verification_code_expire_minutes", default=30 + ) + + return RegistrationSettingsResponse( + enable_registration=bool(enable_registration), + require_email_verification=bool(require_verification), + verification_code_expire_minutes=expire_minutes, + ).model_dump() + + class AuthRegisterAdapter(AuthPublicAdapter): async def handle(self, context): # type: ignore[override] from src.models.database import SystemConfig @@ -241,6 +287,19 @@ class AuthRegisterAdapter(AuthPublicAdapter): db.commit() raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统暂不开放注册") + # 检查是否需要邮箱验证 + require_verification = ConfigService.get_config(db, "require_email_verification", default=False) + + if require_verification: + # 检查邮箱是否已验证 + is_verified = await EmailVerificationService.is_email_verified(register_request.email) + if not is_verified: + logger.warning(f"注册失败:邮箱未验证: {register_request.email}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="请先完成邮箱验证。请发送验证码并验证后再注册。", + ) + try: user = UserService.create_user( db=db, @@ -258,7 +317,13 @@ class AuthRegisterAdapter(AuthPublicAdapter): user_agent=user_agent, metadata={"email": user.email, "username": user.username, "role": user.role.value}, ) + + # 注册成功后清除验证状态 - 在 commit 之前清理,避免竞态条件 + if require_verification: + await EmailVerificationService.clear_verification(register_request.email) + db.commit() + return RegisterResponse( user_id=user.id, email=user.email, @@ -351,3 +416,124 @@ class AuthLogoutAdapter(AuthenticatedApiAdapter): else: logger.warning(f"用户登出失败(Redis不可用): {user.email}") return LogoutResponse(message="登出成功(降级模式)", success=False).model_dump() + + +class AuthSendVerificationCodeAdapter(AuthPublicAdapter): + async def handle(self, context): # type: ignore[override] + """发送邮箱验证码""" + db = context.db + payload = context.ensure_json_body() + + try: + send_request = SendVerificationCodeRequest.model_validate(payload) + except ValidationError as exc: + errors = [] + for error in exc.errors(): + field = " -> ".join(str(x) for x in error["loc"]) + errors.append(f"{field}: {error['msg']}") + raise InvalidRequestException("输入验证失败: " + "; ".join(errors)) + + client_ip = get_client_ip(context.request) + email = send_request.email + + # IP 速率限制检查(验证码发送:3次/分钟) + allowed, remaining, reset_after = await IPRateLimiter.check_limit( + client_ip, "verification_send" + ) + if not allowed: + logger.warning(f"验证码发送请求超过速率限制: IP={client_ip}, 剩余={remaining}") + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"请求过于频繁,请在 {reset_after} 秒后重试", + ) + + # 获取验证码过期时间配置 + expire_minutes = ConfigService.get_config( + db, "verification_code_expire_minutes", default=30 + ) + + # 检查邮箱是否已注册 - 静默处理,不暴露邮箱注册状态 + existing_user = db.query(User).filter(User.email == email).first() + if existing_user: + # 不发送验证码,但返回成功信息,防止邮箱枚举攻击 + logger.warning(f"尝试为已注册邮箱发送验证码: {email}") + return SendVerificationCodeResponse( + success=True, + message="验证码已发送", + expire_minutes=expire_minutes, + ) + + # 生成并发送验证码 + success, code_or_error, error_detail = await EmailVerificationService.send_verification_code( + email, expire_minutes=expire_minutes + ) + + if not success: + logger.error(f"发送验证码失败: {email}, 错误: {code_or_error}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=error_detail or code_or_error, + ) + + # 发送邮件 + email_success, email_error = await EmailSenderService.send_verification_code( + db=db, to_email=email, code=code_or_error, expire_minutes=expire_minutes + ) + + if not email_success: + logger.error(f"发送验证码邮件失败: {email}, 错误: {email_error}") + # 不向用户暴露 SMTP 详细错误信息,防止信息泄露 + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="发送验证码失败,请稍后重试", + ) + + logger.info(f"验证码已发送: {email}") + + return SendVerificationCodeResponse( + message="验证码已发送,请查收邮件", + success=True, + expire_minutes=expire_minutes, + ).model_dump() + + +class AuthVerifyEmailAdapter(AuthPublicAdapter): + async def handle(self, context): # type: ignore[override] + """验证邮箱验证码""" + db = context.db + payload = context.ensure_json_body() + + try: + verify_request = VerifyEmailRequest.model_validate(payload) + except ValidationError as exc: + errors = [] + for error in exc.errors(): + field = " -> ".join(str(x) for x in error["loc"]) + errors.append(f"{field}: {error['msg']}") + raise InvalidRequestException("输入验证失败: " + "; ".join(errors)) + + client_ip = get_client_ip(context.request) + email = verify_request.email + code = verify_request.code + + # IP 速率限制检查(验证码验证:10次/分钟) + allowed, remaining, reset_after = await IPRateLimiter.check_limit( + client_ip, "verification_verify" + ) + if not allowed: + logger.warning(f"验证码验证请求超过速率限制: IP={client_ip}, 剩余={remaining}") + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"请求过于频繁,请在 {reset_after} 秒后重试", + ) + + # 验证验证码 + success, message = await EmailVerificationService.verify_code(email, code) + + if not success: + logger.warning(f"验证码验证失败: {email}, 原因: {message}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message) + + logger.info(f"邮箱验证成功: {email}") + + return VerifyEmailResponse(message="邮箱验证成功", success=True).model_dump() diff --git a/src/models/api.py b/src/models/api.py index f3fc883..06f34ad 100644 --- a/src/models/api.py +++ b/src/models/api.py @@ -123,6 +123,71 @@ class LogoutResponse(BaseModel): success: bool +class SendVerificationCodeRequest(BaseModel): + """发送验证码请求""" + + email: str = Field(..., min_length=3, max_length=255, description="邮箱地址") + + @field_validator("email") + @classmethod + def validate_email(cls, v): + """验证邮箱格式""" + email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + if not re.match(email_pattern, v): + raise ValueError("邮箱格式无效") + return v.lower() + + +class SendVerificationCodeResponse(BaseModel): + """发送验证码响应""" + + message: str + success: bool + expire_minutes: Optional[int] = None + + +class VerifyEmailRequest(BaseModel): + """验证邮箱请求""" + + email: str = Field(..., min_length=3, max_length=255, description="邮箱地址") + code: str = Field(..., min_length=6, max_length=6, description="6位验证码") + + @field_validator("email") + @classmethod + def validate_email(cls, v): + """验证邮箱格式""" + email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + if not re.match(email_pattern, v): + raise ValueError("邮箱格式无效") + return v.lower() + + @classmethod + @field_validator("code") + def validate_code(cls, v): + """验证验证码格式""" + v = v.strip() + if not v.isdigit(): + raise ValueError("验证码必须是6位数字") + if len(v) != 6: + raise ValueError("验证码必须是6位数字") + return v + + +class VerifyEmailResponse(BaseModel): + """验证邮箱响应""" + + message: str + success: bool + + +class RegistrationSettingsResponse(BaseModel): + """注册设置响应(公开接口返回)""" + + enable_registration: bool + require_email_verification: bool + verification_code_expire_minutes: Optional[int] = 30 + + # ========== 用户管理 ========== class CreateUserRequest(BaseModel): """创建用户请求""" diff --git a/src/services/rate_limit/ip_limiter.py b/src/services/rate_limit/ip_limiter.py index c94036e..1052719 100644 --- a/src/services/rate_limit/ip_limiter.py +++ b/src/services/rate_limit/ip_limiter.py @@ -28,6 +28,8 @@ class IPRateLimiter: "register": 3, # 注册接口 "api": 60, # API 接口 "public": 60, # 公共接口 + "verification_send": 3, # 发送验证码接口 + "verification_verify": 10, # 验证验证码接口 } @staticmethod diff --git a/src/services/verification/__init__.py b/src/services/verification/__init__.py new file mode 100644 index 0000000..ee0b5ac --- /dev/null +++ b/src/services/verification/__init__.py @@ -0,0 +1,9 @@ +""" +邮箱验证服务模块 +""" + +from .email_sender import EmailSenderService +from .email_template import EmailTemplate +from .email_verification import EmailVerificationService + +__all__ = ["EmailVerificationService", "EmailSenderService", "EmailTemplate"] diff --git a/src/services/verification/email_sender.py b/src/services/verification/email_sender.py new file mode 100644 index 0000000..1acf3ec --- /dev/null +++ b/src/services/verification/email_sender.py @@ -0,0 +1,365 @@ +""" +邮件发送服务 +提供 SMTP 邮件发送功能 +""" + +import asyncio +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import Optional, Tuple + +try: + import aiosmtplib + + AIOSMTPLIB_AVAILABLE = True +except ImportError: + AIOSMTPLIB_AVAILABLE = False + aiosmtplib = None + +from sqlalchemy.orm import Session + +from src.core.logger import logger +from src.services.system.config import ConfigService + +from .email_template import EmailTemplate + + +class EmailSenderService: + """邮件发送服务""" + + @staticmethod + def _get_smtp_config(db: Session) -> dict: + """ + 从数据库获取 SMTP 配置 + + Args: + db: 数据库会话 + + Returns: + SMTP 配置字典 + """ + config = { + "smtp_host": ConfigService.get_config(db, "smtp_host"), + "smtp_port": ConfigService.get_config(db, "smtp_port", default=587), + "smtp_user": ConfigService.get_config(db, "smtp_user"), + "smtp_password": ConfigService.get_config(db, "smtp_password"), + "smtp_use_tls": ConfigService.get_config(db, "smtp_use_tls", default=True), + "smtp_use_ssl": ConfigService.get_config(db, "smtp_use_ssl", default=False), + "smtp_from_email": ConfigService.get_config(db, "smtp_from_email"), + "smtp_from_name": ConfigService.get_config(db, "smtp_from_name", default="Aether"), + } + return config + + @staticmethod + def _validate_smtp_config(config: dict) -> Tuple[bool, Optional[str]]: + """ + 验证 SMTP 配置 + + Args: + config: SMTP 配置字典 + + Returns: + (是否有效, 错误信息) + """ + required_fields = ["smtp_host", "smtp_from_email"] + + for field in required_fields: + if not config.get(field): + return False, f"缺少必要的 SMTP 配置: {field}" + + return True, None + + @staticmethod + async def send_verification_code( + db: Session, to_email: str, code: str, expire_minutes: int = 30 + ) -> Tuple[bool, Optional[str]]: + """ + 发送验证码邮件 + + Args: + db: 数据库会话 + to_email: 收件人邮箱 + code: 验证码 + expire_minutes: 过期时间(分钟) + + Returns: + (是否发送成功, 错误信息) + """ + # 获取 SMTP 配置 + config = EmailSenderService._get_smtp_config(db) + + # 验证配置 + valid, error = EmailSenderService._validate_smtp_config(config) + if not valid: + logger.error(f"SMTP 配置无效: {error}") + return False, error + + # 生成邮件内容 + app_name = ConfigService.get_config(db, "smtp_from_name", default="Aether") + support_email = ConfigService.get_config(db, "smtp_support_email") + + html_body = EmailTemplate.get_verification_code_html( + code=code, expire_minutes=expire_minutes, app_name=app_name, support_email=support_email + ) + text_body = EmailTemplate.get_verification_code_text( + code=code, expire_minutes=expire_minutes, app_name=app_name, support_email=support_email + ) + subject = EmailTemplate.get_subject("verification") + + # 发送邮件 + return await EmailSenderService._send_email( + config=config, to_email=to_email, subject=subject, html_body=html_body, text_body=text_body + ) + + @staticmethod + async def _send_email( + config: dict, + to_email: str, + subject: str, + html_body: Optional[str] = None, + text_body: Optional[str] = None, + ) -> Tuple[bool, Optional[str]]: + """ + 发送邮件(内部方法) + + Args: + config: SMTP 配置 + to_email: 收件人邮箱 + subject: 邮件主题 + html_body: HTML 邮件内容 + text_body: 纯文本邮件内容 + + Returns: + (是否发送成功, 错误信息) + """ + if AIOSMTPLIB_AVAILABLE: + return await EmailSenderService._send_email_async( + config, to_email, subject, html_body, text_body + ) + else: + return await EmailSenderService._send_email_sync_wrapper( + config, to_email, subject, html_body, text_body + ) + + @staticmethod + async def _send_email_async( + config: dict, + to_email: str, + subject: str, + html_body: Optional[str] = None, + text_body: Optional[str] = None, + ) -> Tuple[bool, Optional[str]]: + """ + 异步发送邮件(使用 aiosmtplib) + + Args: + config: SMTP 配置 + to_email: 收件人邮箱 + subject: 邮件主题 + html_body: HTML 邮件内容 + text_body: 纯文本邮件内容 + + Returns: + (是否发送成功, 错误信息) + """ + try: + # 构建邮件 + message = MIMEMultipart("alternative") + message["Subject"] = subject + message["From"] = f"{config['smtp_from_name']} <{config['smtp_from_email']}>" + message["To"] = to_email + + # 添加纯文本部分 + if text_body: + message.attach(MIMEText(text_body, "plain", "utf-8")) + + # 添加 HTML 部分 + if html_body: + message.attach(MIMEText(html_body, "html", "utf-8")) + + # 发送邮件 + if config["smtp_use_ssl"]: + await aiosmtplib.send( + message, + hostname=config["smtp_host"], + port=config["smtp_port"], + use_tls=True, + username=config["smtp_user"], + password=config["smtp_password"], + ) + else: + await aiosmtplib.send( + message, + hostname=config["smtp_host"], + port=config["smtp_port"], + start_tls=config["smtp_use_tls"], + username=config["smtp_user"], + password=config["smtp_password"], + ) + + logger.info(f"验证码邮件发送成功: {to_email}") + return True, None + + except Exception as e: + error_msg = f"发送邮件失败: {str(e)}" + logger.error(error_msg) + return False, error_msg + + @staticmethod + async def _send_email_sync_wrapper( + config: dict, + to_email: str, + subject: str, + html_body: Optional[str] = None, + text_body: Optional[str] = None, + ) -> Tuple[bool, Optional[str]]: + """ + 同步邮件发送的异步包装器 + + Args: + config: SMTP 配置 + to_email: 收件人邮箱 + subject: 邮件主题 + html_body: HTML 邮件内容 + text_body: 纯文本邮件内容 + + Returns: + (是否发送成功, 错误信息) + """ + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, EmailSenderService._send_email_sync, config, to_email, subject, html_body, text_body + ) + + @staticmethod + def _send_email_sync( + config: dict, + to_email: str, + subject: str, + html_body: Optional[str] = None, + text_body: Optional[str] = None, + ) -> Tuple[bool, Optional[str]]: + """ + 同步发送邮件(使用标准库 smtplib) + + Args: + config: SMTP 配置 + to_email: 收件人邮箱 + subject: 邮件主题 + html_body: HTML 邮件内容 + text_body: 纯文本邮件内容 + + Returns: + (是否发送成功, 错误信息) + """ + try: + # 构建邮件 + message = MIMEMultipart("alternative") + message["Subject"] = subject + message["From"] = f"{config['smtp_from_name']} <{config['smtp_from_email']}>" + message["To"] = to_email + + # 添加纯文本部分 + if text_body: + message.attach(MIMEText(text_body, "plain", "utf-8")) + + # 添加 HTML 部分 + if html_body: + message.attach(MIMEText(html_body, "html", "utf-8")) + + # 连接 SMTP 服务器 + server = None + try: + if config["smtp_use_ssl"]: + server = smtplib.SMTP_SSL(config["smtp_host"], config["smtp_port"]) + else: + server = smtplib.SMTP(config["smtp_host"], config["smtp_port"]) + if config["smtp_use_tls"]: + server.starttls() + + # 登录 + if config["smtp_user"] and config["smtp_password"]: + server.login(config["smtp_user"], config["smtp_password"]) + + # 发送邮件 + server.send_message(message) + + logger.info(f"验证码邮件发送成功(同步方式): {to_email}") + return True, None + finally: + # 确保服务器连接被关闭 + if server is not None: + try: + server.quit() + except Exception as quit_error: + logger.warning(f"关闭 SMTP 连接时出错: {quit_error}") + + except Exception as e: + error_msg = f"发送邮件失败: {str(e)}" + logger.error(error_msg) + return False, error_msg + + @staticmethod + async def test_smtp_connection( + db: Session, override_config: Optional[dict] = None + ) -> Tuple[bool, Optional[str]]: + """ + 测试 SMTP 连接 + + Args: + db: 数据库会话 + override_config: 可选的覆盖配置(通常来自未保存的前端表单) + + Returns: + (是否连接成功, 错误信息) + """ + config = EmailSenderService._get_smtp_config(db) + + # 用外部传入的配置覆盖(仅覆盖提供的字段) + if override_config: + config.update({k: v for k, v in override_config.items() if v is not None}) + + # 验证配置 + valid, error = EmailSenderService._validate_smtp_config(config) + if not valid: + return False, error + + try: + if AIOSMTPLIB_AVAILABLE: + # 使用异步方式测试 + smtp = aiosmtplib.SMTP( + hostname=config["smtp_host"], + port=config["smtp_port"], + use_tls=config["smtp_use_ssl"], + ) + await smtp.connect() + + if config["smtp_use_tls"] and not config["smtp_use_ssl"]: + await smtp.starttls() + + if config["smtp_user"] and config["smtp_password"]: + await smtp.login(config["smtp_user"], config["smtp_password"]) + + await smtp.quit() + else: + # 使用同步方式测试 + if config["smtp_use_ssl"]: + server = smtplib.SMTP_SSL(config["smtp_host"], config["smtp_port"]) + else: + server = smtplib.SMTP(config["smtp_host"], config["smtp_port"]) + if config["smtp_use_tls"]: + server.starttls() + + if config["smtp_user"] and config["smtp_password"]: + server.login(config["smtp_user"], config["smtp_password"]) + + server.quit() + + logger.info("SMTP 连接测试成功") + return True, None + + except Exception as e: + error_msg = f"SMTP 连接测试失败: {str(e)}" + logger.error(error_msg) + return False, error_msg diff --git a/src/services/verification/email_template.py b/src/services/verification/email_template.py new file mode 100644 index 0000000..e1f898d --- /dev/null +++ b/src/services/verification/email_template.py @@ -0,0 +1,238 @@ +""" +邮件模板 +提供验证码邮件的 HTML 和纯文本模板 +""" + +from typing import Dict + + +class EmailTemplate: + """邮件模板类""" + + @staticmethod + def get_verification_code_html(code: str, expire_minutes: int = 30, **kwargs) -> str: + """ + 获取验证码邮件 HTML 模板 + + Args: + code: 验证码 + expire_minutes: 过期时间(分钟) + **kwargs: 其他模板变量 + + Returns: + HTML 邮件内容 + """ + app_name = kwargs.get("app_name", "Aether") + support_email = kwargs.get("support_email", "") + + html = f""" + + + + + + 邮箱验证码 + + + +
+
+

{app_name}

+
+
+
您好!
+
+ 感谢您注册 {app_name}。为了验证您的邮箱地址,请使用以下验证码完成注册流程: +
+ +
+
验证码
+
{code}
+
+ 验证码有效期:{expire_minutes} 分钟 +
+
+ +
+ 安全提示: +
    +
  • 请勿将此验证码透露给任何人
  • +
  • 如果您没有请求此验证码,请忽略此邮件
  • +
  • 验证码在 {expire_minutes} 分钟后自动失效
  • +
+
+ +
+ +
+ 如果您在注册过程中遇到任何问题,请随时联系我们的支持团队。 +
+
+ +
+ + + """ + return html.strip() + + @staticmethod + def get_verification_code_text(code: str, expire_minutes: int = 30, **kwargs) -> str: + """ + 获取验证码邮件纯文本模板 + + Args: + code: 验证码 + expire_minutes: 过期时间(分钟) + **kwargs: 其他模板变量 + + Returns: + 纯文本邮件内容 + """ + app_name = kwargs.get("app_name", "Aether") + support_email = kwargs.get("support_email", "") + + text = f""" +{app_name} - 邮箱验证码 +{'=' * 50} + +您好! + +感谢您注册 {app_name}。为了验证您的邮箱地址,请使用以下验证码完成注册流程: + +验证码:{code} + +验证码有效期:{expire_minutes} 分钟 + +{'=' * 50} + +安全提示: +- 请勿将此验证码透露给任何人 +- 如果您没有请求此验证码,请忽略此邮件 +- 验证码在 {expire_minutes} 分钟后自动失效 + +{'=' * 50} + +如果您在注册过程中遇到任何问题,请随时联系我们的支持团队。 +{f'联系邮箱:{support_email}' if support_email else ''} + +此邮件由系统自动发送,请勿直接回复。 + +© {app_name}. All rights reserved. + """ + return text.strip() + + @staticmethod + def get_subject(template_type: str = "verification") -> str: + """ + 获取邮件主题 + + Args: + template_type: 模板类型 + + Returns: + 邮件主题 + """ + subjects = { + "verification": "邮箱验证码 - 请完成验证", + "welcome": "欢迎加入 Aether", + "password_reset": "密码重置验证码", + } + return subjects.get(template_type, "Aether 通知") diff --git a/src/services/verification/email_verification.py b/src/services/verification/email_verification.py new file mode 100644 index 0000000..f2cb376 --- /dev/null +++ b/src/services/verification/email_verification.py @@ -0,0 +1,281 @@ +""" +邮箱验证服务 +提供验证码生成、发送、验证等功能 +""" + +import json +import secrets +from datetime import datetime, timezone +from typing import Optional, Tuple + +from src.clients.redis_client import get_redis_client +from src.core.logger import logger + + +class EmailVerificationService: + """邮箱验证码服务""" + + # Redis key 前缀 + VERIFICATION_PREFIX = "email:verification:" + SEND_LIMIT_PREFIX = "email:send_limit:" + VERIFIED_PREFIX = "email:verified:" + + # 默认配置 + DEFAULT_CODE_EXPIRE_MINUTES = 30 + DEFAULT_MAX_ATTEMPTS = 5 + SEND_COOLDOWN_SECONDS = 60 + SEND_LIMIT_PER_HOUR = 5 + + @staticmethod + def _generate_code() -> str: + """ + 生成 6 位数字验证码 + + Returns: + 6 位数字字符串 + """ + # 使用 secrets 模块生成安全的随机数 + code = secrets.randbelow(1000000) + return f"{code:06d}" + + @staticmethod + async def send_verification_code( + email: str, expire_minutes: Optional[int] = None + ) -> Tuple[bool, str, Optional[str]]: + """ + 发送验证码(生成并存储到 Redis) + + Args: + email: 目标邮箱地址 + expire_minutes: 验证码过期时间(分钟),None 则使用默认值 + + Returns: + (是否成功, 验证码/错误信息, 错误详情) + """ + redis_client = await get_redis_client(require_redis=False) + + if redis_client is None: + logger.error("Redis 不可用,无法发送验证码") + return False, "系统错误", "Redis 服务不可用" + + try: + # 检查发送频率限制 + send_limit_key = f"{EmailVerificationService.SEND_LIMIT_PREFIX}{email}" + send_count = await redis_client.get(send_limit_key) + + if send_count: + send_count = int(send_count) + if send_count >= EmailVerificationService.SEND_LIMIT_PER_HOUR: + logger.warning(f"邮箱 {email} 发送验证码次数超限: {send_count}") + return False, "发送次数过多", "每小时最多发送 5 次验证码" + + # 检查冷却时间 + verification_key = f"{EmailVerificationService.VERIFICATION_PREFIX}{email}" + existing_data = await redis_client.get(verification_key) + + if existing_data: + data = json.loads(existing_data) + created_at = datetime.fromisoformat(data["created_at"]) + elapsed = (datetime.now(timezone.utc) - created_at).total_seconds() + + if elapsed < EmailVerificationService.SEND_COOLDOWN_SECONDS: + remaining = int(EmailVerificationService.SEND_COOLDOWN_SECONDS - elapsed) + logger.warning(f"邮箱 {email} 请求验证码过于频繁,需等待 {remaining} 秒") + return False, "请求过于频繁", f"请在 {remaining} 秒后重试" + + # 生成验证码 + code = EmailVerificationService._generate_code() + expire_time = expire_minutes or EmailVerificationService.DEFAULT_CODE_EXPIRE_MINUTES + + # 存储验证码数据 + verification_data = { + "code": code, + "attempts": 0, + "created_at": datetime.now(timezone.utc).isoformat(), + } + + # 存储到 Redis(设置过期时间) + await redis_client.setex( + verification_key, expire_time * 60, json.dumps(verification_data) + ) + + # 更新发送计数器(1 小时过期)- 使用原子操作 + current_count = await redis_client.incr(send_limit_key) + # 如果是第一次设置,需要设置过期时间 + if current_count == 1: + await redis_client.expire(send_limit_key, 3600) + + logger.info(f"验证码已生成并存储: {email}, 有效期: {expire_time} 分钟") + return True, code, None + + except Exception as e: + logger.error(f"发送验证码失败: {e}") + return False, "系统错误", str(e) + + @staticmethod + async def verify_code(email: str, code: str) -> Tuple[bool, str]: + """ + 验证验证码 + + Args: + email: 邮箱地址 + code: 用户输入的验证码 + + Returns: + (是否验证成功, 错误信息) + """ + redis_client = await get_redis_client(require_redis=False) + + if redis_client is None: + logger.error("Redis 不可用,无法验证验证码") + return False, "系统错误" + + try: + verification_key = f"{EmailVerificationService.VERIFICATION_PREFIX}{email}" + data_str = await redis_client.get(verification_key) + + if not data_str: + logger.warning(f"验证码不存在或已过期: {email}") + return False, "验证码不存在或已过期" + + data = json.loads(data_str) + + # 检查尝试次数 + if data["attempts"] >= EmailVerificationService.DEFAULT_MAX_ATTEMPTS: + logger.warning(f"验证码尝试次数过多: {email}") + await redis_client.delete(verification_key) + return False, "验证码尝试次数过多,请重新发送" + + # 增加尝试次数 + data["attempts"] += 1 + + # 验证码比对 - 使用常量时间比较防止时序攻击 + if not secrets.compare_digest(code, data["code"]): + # 更新尝试次数 + ttl = await redis_client.ttl(verification_key) + if ttl > 0: + await redis_client.setex(verification_key, ttl, json.dumps(data)) + + remaining_attempts = EmailVerificationService.DEFAULT_MAX_ATTEMPTS - data["attempts"] + logger.warning(f"验证码错误: {email}, 剩余尝试次数: {remaining_attempts}") + return False, f"验证码错误,剩余尝试次数: {remaining_attempts}" + + # 验证成功:删除验证码,标记邮箱已验证 + await redis_client.delete(verification_key) + + verified_key = f"{EmailVerificationService.VERIFIED_PREFIX}{email}" + # 已验证标记保留 1 小时,足够完成注册流程 + await redis_client.setex(verified_key, 3600, "verified") + + logger.info(f"验证码验证成功: {email}") + return True, "验证成功" + + except Exception as e: + logger.error(f"验证验证码失败: {e}") + return False, "系统错误" + + @staticmethod + async def is_email_verified(email: str) -> bool: + """ + 检查邮箱是否已验证 + + Args: + email: 邮箱地址 + + Returns: + 是否已验证 + """ + redis_client = await get_redis_client(require_redis=False) + + if redis_client is None: + logger.warning("Redis 不可用,跳过邮箱验证检查") + return False + + try: + verified_key = f"{EmailVerificationService.VERIFIED_PREFIX}{email}" + verified = await redis_client.exists(verified_key) + return bool(verified) + + except Exception as e: + logger.error(f"检查邮箱验证状态失败: {e}") + return False + + @staticmethod + async def clear_verification(email: str) -> bool: + """ + 清除邮箱验证状态(注册成功后调用) + + Args: + email: 邮箱地址 + + Returns: + 是否清除成功 + """ + redis_client = await get_redis_client(require_redis=False) + + if redis_client is None: + logger.warning("Redis 不可用,无法清除验证状态") + return False + + try: + verified_key = f"{EmailVerificationService.VERIFIED_PREFIX}{email}" + verification_key = f"{EmailVerificationService.VERIFICATION_PREFIX}{email}" + + # 删除已验证标记和验证码(如果还存在) + await redis_client.delete(verified_key) + await redis_client.delete(verification_key) + + logger.info(f"邮箱验证状态已清除: {email}") + return True + + except Exception as e: + logger.error(f"清除邮箱验证状态失败: {e}") + return False + + @staticmethod + async def get_verification_status(email: str) -> dict: + """ + 获取邮箱验证状态(用于调试和管理) + + Args: + email: 邮箱地址 + + Returns: + 验证状态信息 + """ + redis_client = await get_redis_client(require_redis=False) + + if redis_client is None: + return {"error": "Redis 不可用"} + + try: + verification_key = f"{EmailVerificationService.VERIFICATION_PREFIX}{email}" + verified_key = f"{EmailVerificationService.VERIFIED_PREFIX}{email}" + send_limit_key = f"{EmailVerificationService.SEND_LIMIT_PREFIX}{email}" + + # 获取各个状态 + verification_data = await redis_client.get(verification_key) + is_verified = await redis_client.exists(verified_key) + send_count = await redis_client.get(send_limit_key) + verification_ttl = await redis_client.ttl(verification_key) + verified_ttl = await redis_client.ttl(verified_key) + + status = { + "email": email, + "has_pending_code": bool(verification_data), + "is_verified": bool(is_verified), + "send_count_this_hour": int(send_count) if send_count else 0, + "code_expires_in": verification_ttl if verification_ttl > 0 else None, + "verified_expires_in": verified_ttl if verified_ttl > 0 else None, + } + + if verification_data: + data = json.loads(verification_data) + status["attempts"] = data.get("attempts", 0) + status["created_at"] = data.get("created_at") + + return status + + except Exception as e: + logger.error(f"获取邮箱验证状态失败: {e}") + return {"error": str(e)}