feat(auth+desktop): 邮箱验证码登录 + 登录窗改版(微信/邮箱双方式,无边框)
ci / server (push) Failing after 10s
ci / design-system (push) Failing after 10s

后端(协议 → 存储 → 接口,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 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-11 09:40:16 +08:00
parent 0ca1caa460
commit 19281d9c42
20 changed files with 871 additions and 86 deletions
+59 -8
View File
@@ -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' && (
<Center>
<span style={{ color: 'var(--accent)' }}><DuduLogo size={40} /></span>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>微信扫码登录</div>
<FakeQr seed={7} />
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center', lineHeight: 'var(--leading-normal)' }}>
打开微信扫一扫确认后自动登录
</div>
<Button variant="ghost" size="sm" onClick={() => setStep('plans')}>模拟手机已确认 </Button>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>登录 dudu</div>
{/* 登录方式分段切换(与设置页「外观」同控件语言) */}
<span style={{ display: 'inline-flex', background: 'var(--surface-2)', borderRadius: 'var(--radius-sm)', padding: 2, gap: 2 }}>
{[['wechat', '微信扫码'], ['email', '邮箱登录']].map(([val, lab]) => (
<button key={val} type="button" onClick={() => setMode(val)} style={{
height: 26, padding: '0 14px', borderRadius: 4, border: 'none', cursor: 'pointer',
fontSize: 'var(--text-xs)', fontFamily: 'var(--font-sans)',
background: mode === val ? 'var(--surface-card)' : 'transparent',
color: mode === val ? 'var(--text-1)' : 'var(--text-2)',
boxShadow: mode === val ? 'var(--shadow-card)' : 'none',
transition: 'background var(--dur-fast) var(--ease-out)',
}}>{lab}</button>
))}
</span>
{mode === 'wechat' ? (
<>
<FakeQr seed={7} />
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center', lineHeight: 'var(--leading-normal)' }}>
打开微信扫一扫确认后自动登录
</div>
<Button variant="ghost" size="sm" onClick={() => setStep('plans')}>模拟手机已确认 </Button>
</>
) : (
<div style={{ alignSelf: 'stretch', display: 'flex', flexDirection: 'column', gap: 10, padding: '4px 8px 0' }}>
<Input
size="lg" type="email" placeholder="邮箱地址"
value={email} onChange={(e) => setEmail(e.target.value)}
style={{ width: '100%', boxSizing: 'border-box' }}
/>
<div style={{ display: 'flex', gap: 8 }}>
<Input
size="lg" inputMode="numeric" maxLength={6} placeholder="6 位验证码"
value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
style={{ flex: 1, minWidth: 0 }}
/>
<Button variant="secondary" disabled={cd > 0 || !email.includes('@')} onClick={() => setCd(3)}>
{cd > 0 ? `${cd}s` : '发送验证码'}
</Button>
</div>
<Button variant="primary" block disabled={code.length !== 6} onClick={() => setStep('plans')}>登录</Button>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--text-3)', textAlign: 'center' }}>
未注册的邮箱将自动创建账号
</div>
</div>
)}
</Center>
)}
+4
View File
@@ -3,6 +3,10 @@
<head>
<meta charset="UTF-8" />
<title>dudu — 登录</title>
<style>
/* 窗口 transparent,圆角/边框由页面自绘(WindowFrame),阴影走 macOS 原生 */
html, body { margin: 0; background: transparent; }
</style>
</head>
<body>
<div id="root"></div>
+306
View File
@@ -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"
}
}
}
}
+1
View File
@@ -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"
},
+37
View File
@@ -109,6 +109,43 @@ pub async fn login_qr_poll(app: AppHandle, state: String) -> Option<Value> {
Some(v)
}
/// 发送邮箱登录验证码。返回 true=已发送,false=网络失败,
/// Some(json) 形式的错误码(如 RATE_LIMITED 冷却中)由前端据 status 判断。
#[tauri::command]
pub async fn login_email_code(app: AppHandle, email: String) -> Option<u16> {
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<Value> {
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::<crate::settings::SettingsStore>();
let mut s = store.get();
s.token = token.to_string();
store.set(s);
let _ = app.state::<crate::ws::WsHandle>().send(crate::ws::WsCmd::Reconnect);
}
Some(v)
}
#[tauri::command]
pub async fn create_order(app: AppHandle, pack_id: String) -> Option<Value> {
let (url, token) = base(&app);
+2
View File
@@ -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,
+9 -1
View File
@@ -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",
+164 -40
View File
@@ -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 (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14,
padding: '26px 24px 30px', fontFamily: 'var(--font-sans)',
background: 'var(--bg-app)', minHeight: '100vh', boxSizing: 'border-box',
padding: '22px 24px 28px', boxSizing: 'border-box',
}}>{children}</div>
);
}
// 简易二维码占位:真实渲染由 qr 内容字符串绘制(接入 qrcode 库或后端返图)。
function QrBox({ value }) {
function Logo({ size = 40 }) {
// 安全前提同 Icon 组件:内容为 icons.js 静态常量,无用户输入。
return (
<svg
width={size} height={size} viewBox="0 0 48 48" fill="none" stroke="currentColor"
strokeWidth="3.5" strokeLinecap="round" aria-hidden="true"
dangerouslySetInnerHTML={{ __html: logoMark }}
/>
);
}
// 二维码: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 (
<div style={{
width: 180, height: 180, borderRadius: 'var(--radius-md)', border: '1px solid var(--border-1)',
background: 'var(--surface-card)', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'var(--text-xs)', color: 'var(--text-3)', padding: 12, textAlign: 'center',
wordBreak: 'break-all', overflow: 'hidden',
}}>{value || '加载中'}</div>
opacity: dimmed ? 0.25 : 1, transition: 'opacity var(--dur-base) var(--ease-out)',
}}>
{value
? <canvas ref={ref} style={{ borderRadius: 4 }} />
: <span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-3)' }}>加载中</span>}
</div>
);
}
// 登录方式分段切换(与设置页「外观」同控件语言)
function ModeSwitch({ mode, onChange }) {
return (
<span style={{ display: 'inline-flex', background: 'var(--surface-2)', borderRadius: 'var(--radius-sm)', padding: 2, gap: 2 }}>
{[['wechat', '微信扫码'], ['email', '邮箱登录']].map(([val, lab]) => (
<button key={val} type="button" onClick={() => onChange(val)} style={{
height: 26, padding: '0 14px', borderRadius: 4, border: 'none', cursor: 'pointer',
fontSize: 'var(--text-xs)', fontFamily: 'var(--font-sans)',
background: mode === val ? 'var(--surface-card)' : 'transparent',
color: mode === val ? 'var(--text-1)' : 'var(--text-2)',
boxShadow: mode === val ? 'var(--shadow-card)' : 'none',
transition: 'background var(--dur-fast) var(--ease-out)',
}}>{lab}</button>
))}
</span>
);
}
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 (
<Center>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>微信扫码登录</div>
<QrBox value={qrExpired ? '二维码已过期' : (qr && qr.qr_url)} />
{qrExpired ? (
<>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>二维码已过期点击刷新重新扫码</div>
<Button variant="primary" size="sm" onClick={refreshQr}>刷新二维码</Button>
</>
) : (
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>打开微信扫一扫确认后自动登录</div>
)}
</Center>
<WindowFrame title="dudu — 登录">
<Center>
<span style={{ color: 'var(--accent)', marginTop: 4 }}><Logo size={40} /></span>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>登录 dudu</div>
<ModeSwitch mode={mode} onChange={setMode} />
{mode === 'wechat' ? (
<>
<QrBox value={qr && qr.qr_url} dimmed={qrExpired} />
{qrExpired ? (
<>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>二维码已过期点击刷新重新扫码</div>
<Button variant="primary" size="sm" onClick={refreshQr}>刷新二维码</Button>
</>
) : (
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)', textAlign: 'center' }}>打开微信扫一扫确认后自动登录</div>
)}
</>
) : (
<div style={{ alignSelf: 'stretch', display: 'flex', flexDirection: 'column', gap: 10, padding: '4px 8px 0' }}>
<Input
size="lg" type="email" placeholder="邮箱地址" autoFocus
value={email} onChange={(e) => setEmail(e.target.value)}
style={{ width: '100%', boxSizing: 'border-box' }}
/>
<div style={{ display: 'flex', gap: 8 }}>
<Input
size="lg" inputMode="numeric" maxLength={6} placeholder="6 位验证码"
value={code} onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
style={{ flex: 1, minWidth: 0 }}
/>
<Button variant="secondary" disabled={cooldown > 0 || !email.includes('@')} onClick={sendCode}>
{cooldown > 0 ? `${cooldown}s` : '发送验证码'}
</Button>
</div>
<Button variant="primary" block disabled={code.length !== 6 || busy} onClick={emailLogin}>
{busy ? '登录中…' : '登录'}
</Button>
<div style={{ fontSize: 'var(--text-xs)', color: emailErr ? 'var(--danger)' : 'var(--text-3)', textAlign: 'center' }}>
{emailErr || '未注册的邮箱将自动创建账号'}
</div>
</div>
)}
</Center>
</WindowFrame>
);
}
if (step === 'plans') {
return (
<Center>
<WindowFrame title="dudu — 购买时长">
<Center>
<div style={{ alignSelf: 'stretch', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>购买时长</span>
{me && <span style={{ display: 'inline-flex', gap: 8, alignItems: 'center', fontSize: 'var(--text-sm)', color: 'var(--text-2)' }}>
@@ -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 : '—'}</Button>
</Center>
</Center>
</WindowFrame>
);
}
if (step === 'pay') {
return (
<Center>
<WindowFrame title="dudu — 购买时长">
<Center>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 'var(--weight-semibold)', color: 'var(--text-1)' }}>微信扫码支付</div>
<div style={{ fontSize: 'var(--text-3xl)', fontWeight: 'var(--weight-bold)', color: 'var(--text-1)' }}>
¥{pack.price_cents / 100}
@@ -164,14 +285,16 @@ function LoginApp() {
<QrBox value={order && order.code_url} />
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-2)' }}>支付完成后自动开通</div>
<Button variant="secondary" size="sm" onClick={() => setStep('plans')}>返回</Button>
</Center>
</Center>
</WindowFrame>
);
}
return (
<Center>
<WindowFrame title="dudu — 购买时长">
<Center>
<span style={{
width: 56, height: 56, borderRadius: '50%', background: 'var(--positive-soft)', color: 'var(--positive)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28,
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28, marginTop: 24,
}}>
<Icon name="check" size={28} sw={2.5} />
</span>
@@ -180,7 +303,8 @@ function LoginApp() {
时长余额 {me ? Math.floor(me.balance_seconds / 60) : '—'} 分钟 · 不过期 · 现在就按住 Space 试试
</div>
<Button variant="primary" onClick={() => invoke('close_login')}>开始使用</Button>
</Center>
</Center>
</WindowFrame>
);
}
+4 -25
View File
@@ -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、框由页面画。
<div style={{
fontFamily: 'var(--font-sans)', position: 'fixed', inset: 0,
background: 'var(--bg-app)', borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-1)',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}>
<WindowFrame title="dudu 设置">
<style>{groupCss}</style>
{/* 自绘顶条:系统红绿灯悬浮于左上,标题绝对居中(mac 原生习惯,永不重叠);整条可拖拽 */}
<div
data-tauri-drag-region
style={{
position: 'relative', height: 36, flex: 'none', display: 'flex', alignItems: 'center',
background: 'var(--surface-2)', borderBottom: '1px solid var(--border-1)',
userSelect: 'none', WebkitUserSelect: 'none', cursor: 'default',
}}
>
<span style={{
position: 'absolute', left: '50%', transform: 'translateX(-50%)', pointerEvents: 'none',
fontSize: 'var(--text-xs)', fontWeight: 'var(--weight-medium)', color: 'var(--text-2)',
}}>dudu 设置</span>
</div>
<div style={{ overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column', gap: 16, padding: '10px 16px 16px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '10px 16px 16px' }}>
<Group title="输入" order={0}>
<SettingRow label="说话快捷键" description="按住说话,松开上屏">
<HotkeyCombo keys={['⌘', '⇧', 'Space']} />
@@ -164,6 +143,6 @@ export function SettingsApp() {
</SettingRow>
</Group>
</div>
</div>
</WindowFrame>
);
}
+31
View File
@@ -0,0 +1,31 @@
// WindowFrame:透明窗口的自绘 MacWindow 框(对齐原型 DesktopShared.MacWindow 规格)。
// 窗口须配 transparent + titleBarStyle Overlay + hiddenTitle + trafficLightPosition {18,18}
// 阴影用 macOS 原生(贴合内容圆角),窗口即卡片实际尺寸。设置 / 登录等窗口共用。
export function WindowFrame({ title, children }) {
return (
<div style={{
fontFamily: 'var(--font-sans)', position: 'fixed', inset: 0,
background: 'var(--bg-app)', borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-1)',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}>
{/* 自绘顶条:系统红绿灯悬浮左上,标题绝对居中(mac 原生习惯);整条可拖拽 */}
<div
data-tauri-drag-region
style={{
position: 'relative', height: 36, flex: 'none', display: 'flex', alignItems: 'center',
background: 'var(--surface-2)', borderBottom: '1px solid var(--border-1)',
userSelect: 'none', WebkitUserSelect: 'none', cursor: 'default',
}}
>
<span style={{
position: 'absolute', left: '50%', transform: 'translateX(-50%)', pointerEvents: 'none',
fontSize: 'var(--text-xs)', fontWeight: 'var(--weight-medium)', color: 'var(--text-2)',
}}>{title}</span>
</div>
<div style={{ overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column' }}>
{children}
</div>
</div>
);
}
+152
View File
@@ -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
}
+2 -1
View File
@@ -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
}
+4
View File
@@ -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"),
+59 -7
View File
@@ -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)
}
}
+15 -2
View File
@@ -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"。
// ⚠️⚠️ 上线前必须替换为真实 PayClientMockPay 不验签,/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 等于一个无鉴权的公开充值接口(任意人可伪造支付成功回调充值)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 B

+10 -2
View File
@@ -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{},
}
}
+2
View File
@@ -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" }
+10
View File
@@ -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"`