From 19281d9c42c6465b1281e1f021bca6d27d0c5d4e Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 11 Jul 2026 09:40:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth+desktop):=20=E9=82=AE=E7=AE=B1?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=A0=81=E7=99=BB=E5=BD=95=20+=20=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E7=AA=97=E6=94=B9=E7=89=88=EF=BC=88=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?/=E9=82=AE=E7=AE=B1=E5=8F=8C=E6=96=B9=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E6=97=A0=E8=BE=B9=E6=A1=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端(协议 → 存储 → 接口,E2E 测试全绿): - protocol:AuthEmailCodeRequest / AuthEmailRequest DTO - store:EmailIdentity 表(邮箱唯一索引)+ authmail:* Redis key - auth/email.go:POST /v1/auth/email/code 发 6 位码(crypto/rand, 60s 冷却 SetNX,10 分钟 TTL);POST /v1/auth/email 常量时间比对、 码一次性、邮箱建号(昵称取前缀)→ JWT(与微信登录同响应形态) - Mailer 接口 + MockMailer(验证码打日志供联调;SMTP 未配置自动降级, 装配结果入启动日志,上线前须换真实实现) - TestEmailLogin E2E:发码/冷却 429/错码拒绝/登录/token 可用/码防复用 桌面(Rust 命令 + UI 改版): - api.rs:login_email_code / login_email(成功保存 token + ws 重连, 与扫码登录同路径) - 登录窗改版(原型 LoginPurchase 先行,dark 截图验收):logo + 分段 切换(微信扫码 / 邮箱登录,与设置页同控件语言)+ 邮箱表单 (60s 倒计时、错误文案、未注册自动建号提示) - 二维码真渲染:qrcode 库画 canvas(前景/背景取主题 token), 替换原 URL 文本占位;过期态半透明化 - 登录窗无边框化:transparent + Overlay 标题栏 + 原生贴形阴影; 抽公共 WindowFrame 组件(设置窗同步重构复用) Co-Authored-By: Claude Fable 5 --- design/ui_kits/desktop/LoginPurchase.jsx | 67 +++- desktop/login.html | 4 + desktop/package-lock.json | 306 ++++++++++++++++++ desktop/package.json | 1 + desktop/src-tauri/src/api.rs | 37 +++ desktop/src-tauri/src/lib.rs | 2 + desktop/src-tauri/tauri.conf.json | 10 +- desktop/src/login/main.jsx | 204 +++++++++--- desktop/src/settings/SettingsApp.jsx | 29 +- desktop/src/shared/WindowFrame.jsx | 31 ++ server/internal/auth/email.go | 152 +++++++++ server/internal/auth/handlers.go | 3 +- server/internal/config/config.go | 4 + server/internal/httpapi/api_test.go | 66 +++- server/internal/httpapi/router.go | 17 +- .../uploads/fb/fb26c952f029ea46508af6bf/1.png | Bin 0 -> 12 bytes .../uploads/fb/fb310c1bc197f04014896e6d/1.png | Bin 0 -> 12 bytes server/internal/store/models.go | 12 +- server/internal/store/redis.go | 2 + server/pkg/protocol/dto.go | 10 + 20 files changed, 871 insertions(+), 86 deletions(-) create mode 100644 desktop/src/shared/WindowFrame.jsx create mode 100644 server/internal/auth/email.go create mode 100644 server/internal/httpapi/var/uploads/fb/fb26c952f029ea46508af6bf/1.png create mode 100644 server/internal/httpapi/var/uploads/fb/fb310c1bc197f04014896e6d/1.png diff --git a/design/ui_kits/desktop/LoginPurchase.jsx b/design/ui_kits/desktop/LoginPurchase.jsx index b9ecfe0..21fe283 100644 --- a/design/ui_kits/desktop/LoginPurchase.jsx +++ b/design/ui_kits/desktop/LoginPurchase.jsx @@ -1,9 +1,19 @@ -// dudu 桌面端 · 登录 / 购买窗口(点击走完整流程:扫码登录 → 选时长包 → 支付 → 完成) +// dudu 桌面端 · 登录 / 购买窗口(点击走完整流程:登录(微信扫码 / 邮箱验证码)→ 选时长包 → 支付 → 完成) function LoginPurchaseWindow() { - const { Button, Badge } = window.DuduDesignSystem_2e0172; + const { Button, Badge, Input } = window.DuduDesignSystem_2e0172; const [step, setStep] = React.useState('login'); // login | plans | pay | done const [plan, setPlan] = React.useState('m'); + const [mode, setMode] = React.useState('wechat'); // wechat | email + const [email, setEmail] = React.useState(''); + const [code, setCode] = React.useState(''); + const [cd, setCd] = React.useState(0); // 验证码冷却秒数(演示 3s) + + React.useEffect(() => { + if (cd <= 0) return; + const t = setTimeout(() => setCd(cd - 1), 1000); + return () => clearTimeout(t); + }, [cd]); const PACKS = { s: { label: '100 分钟', price: 9, desc: '约 ¥0.09 / 分钟', tag: '' }, @@ -22,12 +32,53 @@ function LoginPurchaseWindow() { {step === 'login' && (
-
微信扫码登录
- -
- 打开微信扫一扫,确认后自动登录 -
- +
登录 dudu
+ + {/* 登录方式分段切换(与设置页「外观」同控件语言) */} + + {[['wechat', '微信扫码'], ['email', '邮箱登录']].map(([val, lab]) => ( + + ))} + + + {mode === 'wechat' ? ( + <> + +
+ 打开微信扫一扫,确认后自动登录 +
+ + + ) : ( +
+ setEmail(e.target.value)} + style={{ width: '100%', boxSizing: 'border-box' }} + /> +
+ setCode(e.target.value.replace(/\D/g, ''))} + style={{ flex: 1, minWidth: 0 }} + /> + +
+ +
+ 未注册的邮箱将自动创建账号 +
+
+ )}
)} diff --git a/desktop/login.html b/desktop/login.html index e88e0d1..08e9c7b 100644 --- a/desktop/login.html +++ b/desktop/login.html @@ -3,6 +3,10 @@ dudu — 登录 +
diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 8b6dad3..25d1dab 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tauri-apps/api": "^2.4.0", "@tauri-apps/plugin-global-shortcut": "^2.2.0", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -1459,6 +1460,30 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.35", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", @@ -1506,6 +1531,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", @@ -1527,6 +1561,35 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1552,6 +1615,21 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.371", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", @@ -1559,6 +1637,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1629,6 +1713,19 @@ } } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1654,6 +1751,24 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1686,6 +1801,18 @@ "node": ">=6" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1744,6 +1871,51 @@ "node": ">=18" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1764,6 +1936,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -1793,6 +1974,23 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1828,6 +2026,21 @@ "node": ">=0.10.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/rollup": { "version": "4.61.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", @@ -1892,6 +2105,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1902,6 +2121,32 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2025,12 +2270,73 @@ } } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } } } } diff --git a/desktop/package.json b/desktop/package.json index c12fef3..cc9224a 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -11,6 +11,7 @@ "dependencies": { "@tauri-apps/api": "^2.4.0", "@tauri-apps/plugin-global-shortcut": "^2.2.0", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/desktop/src-tauri/src/api.rs b/desktop/src-tauri/src/api.rs index 170907e..fad4e17 100644 --- a/desktop/src-tauri/src/api.rs +++ b/desktop/src-tauri/src/api.rs @@ -109,6 +109,43 @@ pub async fn login_qr_poll(app: AppHandle, state: String) -> Option { Some(v) } +/// 发送邮箱登录验证码。返回 true=已发送,false=网络失败, +/// Some(json) 形式的错误码(如 RATE_LIMITED 冷却中)由前端据 status 判断。 +#[tauri::command] +pub async fn login_email_code(app: AppHandle, email: String) -> Option { + let (url, _) = base(&app); + let resp = http() + .post(format!("{url}/v1/auth/email/code")) + .json(&serde_json::json!({ "email": email })) + .send() + .await + .ok()?; + Some(resp.status().as_u16()) +} + +/// 邮箱验证码登录:成功保存 token 并重连 ws(与扫码登录同路径)。 +#[tauri::command] +pub async fn login_email(app: AppHandle, email: String, code: String) -> Option { + let (url, _) = base(&app); + let v: Value = http() + .post(format!("{url}/v1/auth/email")) + .json(&serde_json::json!({ "email": email, "code": code })) + .send() + .await + .ok()? + .json() + .await + .ok()?; + if let Some(token) = v.get("token").and_then(|t| t.as_str()) { + let store = app.state::(); + let mut s = store.get(); + s.token = token.to_string(); + store.set(s); + let _ = app.state::().send(crate::ws::WsCmd::Reconnect); + } + Some(v) +} + #[tauri::command] pub async fn create_order(app: AppHandle, pack_id: String) -> Option { let (url, token) = base(&app); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index fc1a876..7072c5c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -152,6 +152,8 @@ pub fn run() { api::fetch_packs, api::login_qr_create, api::login_qr_poll, + api::login_email_code, + api::login_email, api::create_order, api::order_status, api::submit_feedback, diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index f86f0bb..6d6dfa7 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -49,7 +49,15 @@ "width": 400, "height": 520, "resizable": false, - "visible": false + "visible": false, + "transparent": true, + "shadow": true, + "titleBarStyle": "Overlay", + "hiddenTitle": true, + "trafficLightPosition": { + "x": 18, + "y": 18 + } }, { "label": "tray", diff --git a/desktop/src/login/main.jsx b/desktop/src/login/main.jsx index 4ce400d..d4b55cb 100644 --- a/desktop/src/login/main.jsx +++ b/desktop/src/login/main.jsx @@ -1,39 +1,89 @@ -// 登录 / 购买窗口四步流:扫码登录 → 时长包 → 支付二维码 → 完成 -// 规格见 doc/frontend-design.html 4.2;二维码内容来自后端,轮询自动推进。 -import { useEffect, useState } from 'react'; +// 登录 / 购买窗口四步流:登录(微信扫码 / 邮箱验证码)→ 时长包 → 支付二维码 → 完成 +// 规格见 doc/frontend-design.html 4.2 与 ui_kits/desktop/LoginPurchase.jsx 原型; +// 二维码内容来自后端(qrcode 库本地渲染),轮询自动推进。 +import { useEffect, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; +import QRCode from 'qrcode'; import '@dudu/design/styles.css'; import { Button } from '@dudu/design/components/core/Button'; import { Badge } from '@dudu/design/components/core/Badge'; import { Icon } from '@dudu/design/components/core/Icon'; +import { Input } from '@dudu/design/components/forms/Input'; +import { logoMark } from '@dudu/design/icons.js'; import { invoke } from '../shared/tauri'; import { setThemePref } from '../shared/theme'; import { usePoll } from '../shared/usePoll'; +import { WindowFrame } from '../shared/WindowFrame'; function Center({ children }) { return (
{children}
); } -// 简易二维码占位:真实渲染由 qr 内容字符串绘制(接入 qrcode 库或后端返图)。 -function QrBox({ value }) { +function Logo({ size = 40 }) { + // 安全前提同 Icon 组件:内容为 icons.js 静态常量,无用户输入。 + return ( + + ); +} + +// 二维码:qrcode 库画到 canvas(前景/背景取当前主题 token 计算值)。 +function QrBox({ value, dimmed }) { + const ref = useRef(null); + useEffect(() => { + if (!value || !ref.current) return; + const css = getComputedStyle(document.documentElement); + QRCode.toCanvas(ref.current, value, { + width: 168, + margin: 1, + color: { + dark: css.getPropertyValue('--text-1').trim() || undefined, + light: css.getPropertyValue('--surface-card').trim() || undefined, + }, + }).catch(() => {}); + }, [value]); return (
{value || '加载中'}
+ opacity: dimmed ? 0.25 : 1, transition: 'opacity var(--dur-base) var(--ease-out)', + }}> + {value + ? + : 加载中} + + ); +} + +// 登录方式分段切换(与设置页「外观」同控件语言) +function ModeSwitch({ mode, onChange }) { + return ( + + {[['wechat', '微信扫码'], ['email', '邮箱登录']].map(([val, lab]) => ( + + ))} + ); } function LoginApp() { const [step, setStep] = useState('login'); // login | plans | pay | done + const [mode, setMode] = useState('wechat'); // wechat | email const [qr, setQr] = useState(null); const [qrExpired, setQrExpired] = useState(false); const [packs, setPacks] = useState([]); @@ -42,41 +92,56 @@ function LoginApp() { const [me, setMe] = useState(null); const [qrNonce, setQrNonce] = useState(0); // 递增以触发二维码重建(刷新) + // 邮箱登录态 + const [email, setEmail] = useState(''); + const [code, setCode] = useState(''); + const [cooldown, setCooldown] = useState(0); + const [emailErr, setEmailErr] = useState(''); + const [busy, setBusy] = useState(false); + // 启动应用主题:随系统/已存偏好(深色模式下不再停在浅色)。 useEffect(() => { invoke('get_settings').then((s) => s && setThemePref(s.theme)); }, []); - // 扫码登录:创建二维码(进入 login 步或点击刷新时重建)。 useEffect(() => { - if (step !== 'login') return undefined; + if (cooldown <= 0) return undefined; + const t = setTimeout(() => setCooldown(cooldown - 1), 1000); + return () => clearTimeout(t); + }, [cooldown]); + + // 扫码登录:创建二维码(进入 login 步或点击刷新时重建;邮箱模式下不建)。 + useEffect(() => { + if (step !== 'login' || mode !== 'wechat') return undefined; let live = true; setQrExpired(false); setQr(null); invoke('login_qr_create').then((r) => { if (live && r) setQr(r); }); return () => { live = false; }; - }, [step, qrNonce]); + }, [step, mode, qrNonce]); + + const enterPlans = async () => { + const m = await invoke('fetch_me'); + const ps = await invoke('fetch_packs'); + setMe(m); + setPacks(ps || []); + setStep('plans'); + }; // 扫码登录轮询:拿到 confirmed/expired 即停(不再永久 1Hz 打后端)。 usePoll(async (alive) => { const st = await invoke('login_qr_poll', { state: qr.state }); if (!alive()) return 'stop'; if (st && st.status === 'confirmed') { - const m = await invoke('fetch_me'); - const ps = await invoke('fetch_packs'); - if (!alive()) return 'stop'; - setMe(m); - setPacks(ps || []); - setStep('plans'); + await enterPlans(); return 'stop'; } - // 后端 2 分钟 qrTTL 过期后返回 expired:停轮询并提示点击刷新。 if (st && st.status === 'expired') { setQrExpired(true); return 'stop'; } return undefined; - }, 1000, [qr && qr.state], step === 'login' && !!qr && !qrExpired); + }, 1000, [qr && qr.state], step === 'login' && mode === 'wechat' && !!qr && !qrExpired); // 支付轮询:付款成功即停。 usePoll(async (alive) => { @@ -94,27 +159,81 @@ function LoginApp() { const refreshQr = () => setQrNonce((n) => n + 1); + const sendCode = async () => { + setEmailErr(''); + const status = await invoke('login_email_code', { email: email.trim() }); + if (status === 204) setCooldown(60); + else if (status === 429) setEmailErr('发送太频繁,稍后再试'); + else setEmailErr('发送失败,检查邮箱地址'); + }; + + const emailLogin = async () => { + setBusy(true); + setEmailErr(''); + const r = await invoke('login_email', { email: email.trim(), code }); + setBusy(false); + if (r && r.token) { + await enterPlans(); + } else { + setEmailErr('验证码错误或已过期'); + } + }; + const pack = packs.find((p) => p.id === sel); if (step === 'login') { return ( -
-
微信扫码登录
- - {qrExpired ? ( - <> -
二维码已过期,点击刷新重新扫码
- - - ) : ( -
打开微信扫一扫,确认后自动登录
- )} -
+ +
+ +
登录 dudu
+ + + {mode === 'wechat' ? ( + <> + + {qrExpired ? ( + <> +
二维码已过期,点击刷新重新扫码
+ + + ) : ( +
打开微信扫一扫,确认后自动登录
+ )} + + ) : ( +
+ setEmail(e.target.value)} + style={{ width: '100%', boxSizing: 'border-box' }} + /> +
+ setCode(e.target.value.replace(/\D/g, ''))} + style={{ flex: 1, minWidth: 0 }} + /> + +
+ +
+ {emailErr || '未注册的邮箱将自动创建账号'} +
+
+ )} +
+
); } if (step === 'plans') { return ( -
+ +
购买时长 {me && @@ -150,12 +269,14 @@ function LoginApp() { const o = await invoke('create_order', { packId: sel }); if (o) { setOrder(o); setStep('pay'); } }}>微信支付 ¥{pack ? pack.price_cents / 100 : '—'} -
+
+ ); } if (step === 'pay') { return ( -
+ +
微信扫码支付
¥{pack.price_cents / 100} @@ -164,14 +285,16 @@ function LoginApp() {
支付完成后自动开通
-
+
+ ); } return ( -
+ +
@@ -180,7 +303,8 @@ function LoginApp() { 时长余额 {me ? Math.floor(me.balance_seconds / 60) : '—'} 分钟 · 不过期 · 现在就按住 ⌘⇧Space 试试 -
+
+ ); } diff --git a/desktop/src/settings/SettingsApp.jsx b/desktop/src/settings/SettingsApp.jsx index 1594111..3173918 100644 --- a/desktop/src/settings/SettingsApp.jsx +++ b/desktop/src/settings/SettingsApp.jsx @@ -10,6 +10,7 @@ import { SettingRow } from '@dudu/design/components/forms/SettingRow'; import { Select } from '@dudu/design/components/forms/Select'; import { invoke } from '../shared/tauri'; import { setThemePref } from '../shared/theme'; +import { WindowFrame } from '../shared/WindowFrame'; const THEME_OPTIONS = [['system', '跟随系统'], ['light', '浅色'], ['dark', '深色']]; @@ -67,31 +68,9 @@ export function SettingsApp() { const trialUsedMin = me ? Math.floor(me.trial_used_today / 60) : 0; return ( - // 透明窗口自绘 MacWindow 框(对齐原型):radius-md + border-1,占满窗口, - // 阴影用 macOS 原生(贴合内容圆角,窗口即卡片实际尺寸)。 - // 原生 NSWindow 底色在深色内容外露白边,故窗口 transparent、框由页面画。 -
+ - {/* 自绘顶条:系统红绿灯悬浮于左上,标题绝对居中(mac 原生习惯,永不重叠);整条可拖拽 */} -
- dudu 设置 -
-
+
@@ -164,6 +143,6 @@ export function SettingsApp() {
-
+
); } diff --git a/desktop/src/shared/WindowFrame.jsx b/desktop/src/shared/WindowFrame.jsx new file mode 100644 index 0000000..d040427 --- /dev/null +++ b/desktop/src/shared/WindowFrame.jsx @@ -0,0 +1,31 @@ +// WindowFrame:透明窗口的自绘 MacWindow 框(对齐原型 DesktopShared.MacWindow 规格)。 +// 窗口须配 transparent + titleBarStyle Overlay + hiddenTitle + trafficLightPosition {18,18}; +// 阴影用 macOS 原生(贴合内容圆角),窗口即卡片实际尺寸。设置 / 登录等窗口共用。 +export function WindowFrame({ title, children }) { + return ( +
+ {/* 自绘顶条:系统红绿灯悬浮左上,标题绝对居中(mac 原生习惯);整条可拖拽 */} +
+ {title} +
+
+ {children} +
+
+ ); +} diff --git a/server/internal/auth/email.go b/server/internal/auth/email.go new file mode 100644 index 0000000..d32ad02 --- /dev/null +++ b/server/internal/auth/email.go @@ -0,0 +1,152 @@ +// 邮箱验证码登录(桌面端第二登录方式): +// POST /v1/auth/email/code 发送 6 位验证码(60s 冷却,码 10 分钟有效) +// POST /v1/auth/email 校验验证码 → 建号/登录 → JWT +// 邮件发送经 Mailer 接口;SMTP 未配置时装配 MockMailer(验证码打日志,供联调)。 +package auth + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" + + "dudu/server/internal/store" + "dudu/server/pkg/protocol" +) + +const ( + emailCodeTTL = 10 * time.Minute + emailCodeCooldown = 60 * time.Second +) + +// Mailer 验证码邮件发送。 +type Mailer interface { + SendCode(ctx context.Context, email, code string) error +} + +// MockMailer 开发期 mock:验证码打日志不真发(⚠️ 上线前必须替换 SMTP 实现)。 +type MockMailer struct{} + +func (MockMailer) SendCode(_ context.Context, email, code string) error { + slog.Info("mock mailer: email login code", "email", email, "code", code) + return nil +} + +// randCode 6 位数字验证码(crypto/rand)。 +func randCode() string { + b := make([]byte, 6) + _, _ = rand.Read(b) + digits := make([]byte, 6) + for i, v := range b { + digits[i] = '0' + v%10 + } + return string(digits) +} + +// EmailCode POST /v1/auth/email/code 🔓 +func (h *Handlers) EmailCode(c *gin.Context) { + var req protocol.AuthEmailCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest)) + return + } + email := strings.ToLower(strings.TrimSpace(req.Email)) + + // 60s 冷却(SET NX),防轰炸 + ok, err := h.RDB.SetNX(c, store.KeyAuthEmailCd(email), 1, emailCodeCooldown).Result() + if err != nil { + c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal)) + return + } + if !ok { + c.JSON(http.StatusTooManyRequests, protocol.NewAPIError(protocol.ErrRateLimited)) + return + } + + code := randCode() + if err := h.RDB.Set(c, store.KeyAuthEmail(email), code, emailCodeTTL).Err(); err != nil { + c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal)) + return + } + if err := h.Mailer.SendCode(c, email, code); err != nil { + slog.Error("send email code failed", "email", email, "err", err) + c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal)) + return + } + c.Status(http.StatusNoContent) +} + +// EmailLogin POST /v1/auth/email 🔓 +func (h *Handlers) EmailLogin(c *gin.Context) { + var req protocol.AuthEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest)) + return + } + email := strings.ToLower(strings.TrimSpace(req.Email)) + + want, err := h.RDB.Get(c, store.KeyAuthEmail(email)).Result() + if err == redis.Nil || subtle.ConstantTimeCompare([]byte(want), []byte(req.Code)) != 1 { + c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest)) + return + } + if err != nil && err != redis.Nil { + c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal)) + return + } + // 一次性:校验通过即删,防复用 + h.RDB.Del(c, store.KeyAuthEmail(email)) + + user, err := findOrCreateUserByEmail(h.DB, email) + if err != nil { + c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal)) + return + } + token, err := h.JWT.Sign(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal)) + return + } + c.JSON(http.StatusOK, protocol.AuthTokenResponse{ + Token: token, + User: protocol.UserInfo{UserID: user.ID, NicknameMasked: MaskNickname(user.Nickname)}, + }) +} + +// findOrCreateUserByEmail 邮箱身份建号/登录(昵称取邮箱前缀)。 +func findOrCreateUserByEmail(db *gorm.DB, email string) (*store.User, error) { + var ident store.EmailIdentity + err := db.Where("email = ?", email).First(&ident).Error + if err == nil { + var u store.User + return &u, db.First(&u, "id = ?", ident.UserID).Error + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + nick := email + if i := strings.IndexByte(email, '@'); i > 0 { + nick = email[:i] + } + user := store.User{ID: strings.ReplaceAll(uuid.NewString(), "-", "")[:24], Nickname: nick} + err = db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&user).Error; err != nil { + return err + } + return tx.Create(&store.EmailIdentity{UserID: user.ID, Email: email}).Error + }) + if err != nil { + return nil, err + } + return &user, nil +} diff --git a/server/internal/auth/handlers.go b/server/internal/auth/handlers.go index 66c454e..1dbfa8a 100644 --- a/server/internal/auth/handlers.go +++ b/server/internal/auth/handlers.go @@ -14,12 +14,13 @@ import ( "dudu/server/pkg/protocol" ) -// Handlers 认证路由:扫码(5B)、微信 OAuth(5C)、logout。 +// Handlers 认证路由:扫码(5B)、微信 OAuth(5C)、邮箱验证码、logout。 type Handlers struct { DB *gorm.DB RDB *redis.Client JWT *JWT Wechat WechatClient + Mailer Mailer // QrAuthURL 二维码内容模板(真实环境为微信开放平台授权页,%s 为 state) QrAuthURL string } diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 2e20a45..b0ccdbb 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -40,6 +40,9 @@ type Config struct { OSSKeyID string OSSKeySecret string + // SMTP(邮箱验证码登录;未配置降级 mock mailer) + SMTPHost string + // AppLatest 启动时解析 APP_LATEST_JSON 一次(17G):平台→版本信息。 // 解析失败或未配置则为 nil/空,handler 据此返回 204,不 fail-fast。 AppLatest map[string]protocol.AppLatestResponse @@ -70,6 +73,7 @@ func Load() Config { OSSEndpoint: os.Getenv("OSS_ENDPOINT"), OSSBucket: os.Getenv("OSS_BUCKET"), + SMTPHost: os.Getenv("SMTP_HOST"), OSSKeyID: os.Getenv("OSS_KEY_ID"), OSSKeySecret: os.Getenv("OSS_KEY_SECRET"), diff --git a/server/internal/httpapi/api_test.go b/server/internal/httpapi/api_test.go index 75ba5cf..368584f 100644 --- a/server/internal/httpapi/api_test.go +++ b/server/internal/httpapi/api_test.go @@ -24,7 +24,7 @@ import ( "dudu/server/pkg/protocol" ) -func newAPI(t *testing.T) (*httptest.Server, *gorm.DB) { +func newAPI(t *testing.T) (*httptest.Server, *gorm.DB, *redis.Client) { t.Helper() gin.SetMode(gin.TestMode) mr := miniredis.RunT(t) @@ -45,7 +45,7 @@ func newAPI(t *testing.T) (*httptest.Server, *gorm.DB) { Register(r, &Deps{Cfg: cfg, DB: db, RDB: rdb, JWT: jwt, Quota: quota.New(rdb, db)}) srv := httptest.NewServer(r) t.Cleanup(srv.Close) - return srv, db + return srv, db, rdb } func postJSON(t *testing.T, url, token string, body any) *http.Response { @@ -93,7 +93,7 @@ func login(t *testing.T, srv *httptest.Server, code string) string { } func TestMobileLoginAndMe(t *testing.T) { - srv, _ := newAPI(t) + srv, _, _ := newAPI(t) token := login(t, srv, "wangjia99") var me protocol.MeResponse @@ -120,7 +120,7 @@ func TestMobileLoginAndMe(t *testing.T) { } func TestQrLoginFlow(t *testing.T) { - srv, _ := newAPI(t) + srv, _, _ := newAPI(t) resp := postJSON(t, srv.URL+"/v1/auth/qr", "", nil) var qr protocol.AuthQrResponse _ = json.NewDecoder(resp.Body).Decode(&qr) @@ -154,7 +154,7 @@ func TestQrLoginFlow(t *testing.T) { } func TestOrderAndPayNotifyIdempotent(t *testing.T) { - srv, db := newAPI(t) + srv, db, _ := newAPI(t) token := login(t, srv, "buyer1") // packs @@ -235,7 +235,7 @@ func postFeedback(t *testing.T, srv *httptest.Server, token, content string, ima } func TestFeedback(t *testing.T) { - srv, db := newAPI(t) + srv, db, _ := newAPI(t) token := login(t, srv, "fbuser") t.Cleanup(func() { _ = removeUploads() }) @@ -287,7 +287,7 @@ func TestFeedback(t *testing.T) { func removeUploads() error { return nil } // LocalStorage 写入 var/uploads,测试容忍残留 func TestMetricsBatchAndAggregate(t *testing.T) { - srv, db := newAPI(t) + srv, db, _ := newAPI(t) body := protocol.MetricsBatchRequest{ DeviceID: "dev-m1", Platform: "mac", AppVersion: "0.1.0", Events: []protocol.MetricEvent{ @@ -314,3 +314,55 @@ func TestMetricsBatchAndAggregate(t *testing.T) { t.Fatalf("want 2 events stored (whitelist), got %d", n) } } + +// 邮箱验证码登录 E2E:发码(60s 冷却)→ 取码登录 → token 可用 → 码一次性。 +func TestEmailLogin(t *testing.T) { + srv, _, rdb := newAPI(t) + + // 发码 + resp := postJSON(t, srv.URL+"/v1/auth/email/code", "", map[string]string{"email": "Dev@Example.com"}) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("send code: want 204, got %d", resp.StatusCode) + } + // 冷却期内再发 → 429 + resp = postJSON(t, srv.URL+"/v1/auth/email/code", "", map[string]string{"email": "dev@example.com"}) + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("cooldown: want 429, got %d", resp.StatusCode) + } + + code, err := rdb.Get(t.Context(), store.KeyAuthEmail("dev@example.com")).Result() + if err != nil { + t.Fatal(err) + } + + // 错码拒绝 + resp = postJSON(t, srv.URL+"/v1/auth/email", "", map[string]string{"email": "dev@example.com", "code": "000000"}) + if code != "000000" && resp.StatusCode != http.StatusBadRequest { + t.Fatalf("wrong code: want 400, got %d", resp.StatusCode) + } + + // 正确码登录 + resp = postJSON(t, srv.URL+"/v1/auth/email", "", map[string]string{"email": "DEV@example.com", "code": code}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("login: want 200, got %d", resp.StatusCode) + } + var tok protocol.AuthTokenResponse + _ = json.NewDecoder(resp.Body).Decode(&tok) + if tok.Token == "" || tok.User.UserID == "" { + t.Fatalf("empty token/user: %+v", tok) + } + + // token 可访问 /v1/me + req, _ := http.NewRequest("GET", srv.URL+"/v1/me", nil) + req.Header.Set("Authorization", "Bearer "+tok.Token) + meResp, err := http.DefaultClient.Do(req) + if err != nil || meResp.StatusCode != http.StatusOK { + t.Fatalf("me with email token: %v %d", err, meResp.StatusCode) + } + + // 码一次性:复用被拒 + resp = postJSON(t, srv.URL+"/v1/auth/email", "", map[string]string{"email": "dev@example.com", "code": code}) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("code reuse: want 400, got %d", resp.StatusCode) + } +} diff --git a/server/internal/httpapi/router.go b/server/internal/httpapi/router.go index f7a4a10..382ee43 100644 --- a/server/internal/httpapi/router.go +++ b/server/internal/httpapi/router.go @@ -53,6 +53,15 @@ func pickWechat(cfg config.Config) (auth.WechatClient, string) { return auth.MockWechat{}, "mock" // TODO(5C-真实): 开放平台实现,凭证就绪后替换 } +// pickMailer SMTP 未配置时使用 mock(验证码打日志,供联调)。返回 kind。 +// ⚠️ 上线前必须配置真实 SMTP,否则邮箱登录形同虚设。 +func pickMailer(cfg config.Config) (auth.Mailer, string) { + if cfg.SMTPHost == "" { + return auth.MockMailer{}, "mock" + } + return auth.MockMailer{}, "mock" // TODO(邮箱登录-真实): SMTP 实现,凭证就绪后替换 +} + // pickPay 商户号未配置(#2B 申请中)时使用 mock。返回 kind="mock"。 // ⚠️⚠️ 上线前必须替换为真实 PayClient!MockPay 不验签,/v1/pay/notify 等于公开充值接口。 func pickPay(cfg config.Config) (billing.PayClient, string) { @@ -78,12 +87,14 @@ func Register(r *gin.Engine, d *Deps) { wechat, wechatKind := pickWechat(d.Cfg) pay, payKind := pickPay(d.Cfg) storage, storageKind := pickStorage(d.Cfg) + mailer, mailerKind := pickMailer(d.Cfg) // 启动时打印各外部依赖的装配结果(real/mock),便于部署核对(17E)。 slog.Info("dependency assembly", - "asr_provider", providerKind, "wechat", wechatKind, "pay", payKind, "storage", storageKind) + "asr_provider", providerKind, "wechat", wechatKind, "pay", payKind, + "storage", storageKind, "mailer", mailerKind) - authH := &auth.Handlers{DB: d.DB, RDB: d.RDB, JWT: d.JWT, Wechat: wechat} + authH := &auth.Handlers{DB: d.DB, RDB: d.RDB, JWT: d.JWT, Wechat: wechat, Mailer: mailer} billH := &billing.Handlers{DB: d.DB, Pay: pay, Quota: d.Quota} userH := &user.Handlers{DB: d.DB, Quota: d.Quota, AppVersions: d.Cfg.AppLatest} fbH := &feedback.Handlers{DB: d.DB, RDB: d.RDB, Storage: storage} @@ -103,6 +114,8 @@ func Register(r *gin.Engine, d *Deps) { v1.GET("/auth/qr/:state", authH.PollQr) v1.GET("/auth/wechat/callback", authH.QrCallback) v1.POST("/auth/wechat", authH.MobileLogin) + v1.POST("/auth/email/code", authH.EmailCode) + v1.POST("/auth/email", authH.EmailLogin) v1.GET("/packs", billH.Packs) // ⚠️ 部署前必须切换到真实 PayClient(验签)!当前 MockPay 不验签, // /v1/pay/notify 等于一个无鉴权的公开充值接口(任意人可伪造支付成功回调充值)。 diff --git a/server/internal/httpapi/var/uploads/fb/fb26c952f029ea46508af6bf/1.png b/server/internal/httpapi/var/uploads/fb/fb26c952f029ea46508af6bf/1.png new file mode 100644 index 0000000000000000000000000000000000000000..c9576d58484097aab3c75dd70d9702a2e167bfdf GIT binary patch literal 12 RcmeAS@N?(olHy_j0ss*20jdB1 literal 0 HcmV?d00001 diff --git a/server/internal/httpapi/var/uploads/fb/fb310c1bc197f04014896e6d/1.png b/server/internal/httpapi/var/uploads/fb/fb310c1bc197f04014896e6d/1.png new file mode 100644 index 0000000000000000000000000000000000000000..c9576d58484097aab3c75dd70d9702a2e167bfdf GIT binary patch literal 12 RcmeAS@N?(olHy_j0ss*20jdB1 literal 0 HcmV?d00001 diff --git a/server/internal/store/models.go b/server/internal/store/models.go index 97b7997..880b02b 100644 --- a/server/internal/store/models.go +++ b/server/internal/store/models.go @@ -24,6 +24,14 @@ type WechatIdentity struct { CreatedAt time.Time } +// EmailIdentity 邮箱验证码登录身份(桌面端第二登录方式)。 +type EmailIdentity struct { + ID uint `gorm:"primaryKey"` + UserID string `gorm:"size:32;index;not null"` + Email string `gorm:"size:255;uniqueIndex;not null"` + CreatedAt time.Time +} + type DurationPack struct { ID string `gorm:"primaryKey;size:32"` Minutes int `gorm:"not null"` @@ -141,10 +149,10 @@ type MetricDaily struct { P95Ms float64 `gorm:"not null;default:0"` } -// AllModels 迁移清单(11 张表)。 +// AllModels 迁移清单(12 张表)。 func AllModels() []any { return []any{ - &User{}, &WechatIdentity{}, &DurationPack{}, &Order{}, &BalanceLedger{}, + &User{}, &WechatIdentity{}, &EmailIdentity{}, &DurationPack{}, &Order{}, &BalanceLedger{}, &TrialUsage{}, &ASRSession{}, &Device{}, &Feedback{}, &MetricEvent{}, &MetricDaily{}, } } diff --git a/server/internal/store/redis.go b/server/internal/store/redis.go index b362bb4..50b4463 100644 --- a/server/internal/store/redis.go +++ b/server/internal/store/redis.go @@ -13,6 +13,8 @@ func KeyQuotaTrial(uid string, day string) string { return "quota:" + uid + ":trial:" + day } func KeyAuthQr(state string) string { return "authqr:" + state } +func KeyAuthEmail(email string) string { return "authmail:" + email } +func KeyAuthEmailCd(email string) string { return "authmail:cd:" + email } func KeyJwtBlock(jti string) string { return "jwt:block:" + jti } func KeyRateCnt(did string) string { return "rate:" + did + ":asr:cnt" } func KeyRateSecs(did string) string { return "rate:" + did + ":asr:secs" } diff --git a/server/pkg/protocol/dto.go b/server/pkg/protocol/dto.go index cbdc42b..1721b93 100644 --- a/server/pkg/protocol/dto.go +++ b/server/pkg/protocol/dto.go @@ -17,6 +17,16 @@ type AuthWechatRequest struct { Code string `json:"code" binding:"required"` } +// 邮箱验证码登录(桌面端第二登录方式) +type AuthEmailCodeRequest struct { + Email string `json:"email" binding:"required,email"` +} + +type AuthEmailRequest struct { + Email string `json:"email" binding:"required,email"` + Code string `json:"code" binding:"required,len=6"` +} + type AuthTokenResponse struct { Token string `json:"token"` User UserInfo `json:"user"`