From b5170ad1cee08557aabca45b452fe4c5158ba3b7 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 3 Nov 2026 01:04:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E9=82=AE=E7=AE=B1=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=A0=81=E6=B3=A8=E5=86=8C=EF=BC=8C=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E6=9C=89=E4=BA=86=E8=87=AA=E5=8A=A9=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 短信登录关掉后,生产没有任何注册入口——这一版补上最普通的那条路: 填邮箱、收验证码、设密码,注册完直接进首页。 先发码再注册,不是"注册时顺带发信" POST /auth/email/code 只做三件事:查重、限流、发码。 填完密码才被告知"这个邮箱注册过了"最气人,所以占用(2019)在发码前就回。 邮箱既是账号名也是唯一联系方式,所以必须真验一次码 验证通过时间落 app_user.email_verified_at,/auth/me 回 email_verified。 "随手填一个"会让账号变成没人能找回的孤岛。 app_user.phone 改为可空(迁移 0011) 邮箱注册的账号本来就没有手机号。user.phone 对这类账号回空串, 成员列表同理,不再伪装成一串星号。 防刷 同邮箱 60 秒一次(2022)、验证码 10 分钟有效、错 5 次即作废(2021)。 6 位数可以被刮,不设上限等于把注册口子交给脚本。 本机联调开关 EMAIL_CODE_DEV_ECHO 为 true 时把码回给调用方且不发真信——否则每跑一次 e2e 就往真实收件箱塞一封。 生产打开会让 validate_runtime() 拒绝启动。 前端 新增 /register(邮箱、验证码、密码、确认、昵称;60 秒倒计时; 服务端关着注册时整页只说"联系管理员开通"),登录页按 /auth/methods 决定要不要显示「还没有账号?用邮箱注册」。 验证 pytest 139 passed、ruff check / format 全绿 tsc --noEmit 0、vite build 通过 e2e register.mjs 7 项:登录页入口 → 拿码 → 注册落进首页 → 退出用邮箱+密码登回来 → 同一邮箱再发码回 2019 → 控制台零报错 e2e login-email-smtp.mjs 15 项回归通过(含真发一封测试信) e2e account.mjs 14 项、knowledge.mjs 全绿 顺带修的两处测试 test_account 的字段白名单补 email_verified(接口确实多了这个字段); test_config_prod 用 Settings(_env_file=None, ...) 构造,别被本机 .env 里的 EMAIL_CODE_DEV_ECHO=true 带偏——那个开关在生产是硬错误,本机却是跑 e2e 必需的。 --- 2.Design/API.md | 46 +++- 2.Design/ROADMAP-M0-M1.md | 45 ++++ 2.Design/screens/web-register.png | Bin 0 -> 33662 bytes backend/alembic/versions/0011_email_signup.py | 41 +++ backend/app/api/auth.py | 148 ++++++++++- backend/app/api/workspaces.py | 3 +- backend/app/config.py | 19 ++ backend/app/deps.py | 4 +- backend/app/errors.py | 4 + backend/app/models.py | 7 +- backend/app/services/mail.py | 21 ++ backend/app/stores.py | 14 + backend/tests/test_account.py | 1 + backend/tests/test_config_prod.py | 4 +- backend/tests/test_email_signup.py | 217 ++++++++++++++++ web/e2e/register.mjs | 98 +++++++ web/e2e/shots-register.mjs | 32 +++ web/src/App.tsx | 2 + web/src/pages/Login.tsx | 25 +- web/src/pages/Register.tsx | 240 ++++++++++++++++++ 20 files changed, 955 insertions(+), 16 deletions(-) create mode 100644 2.Design/screens/web-register.png create mode 100644 backend/alembic/versions/0011_email_signup.py create mode 100644 backend/tests/test_email_signup.py create mode 100644 web/e2e/register.mjs create mode 100644 web/e2e/shots-register.mjs create mode 100644 web/src/pages/Register.tsx diff --git a/2.Design/API.md b/2.Design/API.md index 1f8cc2d..c85675e 100644 --- a/2.Design/API.md +++ b/2.Design/API.md @@ -37,6 +37,8 @@ | 2018 | 422 | 邮箱格式不对 | 就地提示 | | 2019 | 409 | 邮箱已被别的账号占用 | 换一个 | | 2020 | 502 | 发信失败(SMTP 认证/收件人/网络) | 去「系统设置」核对授权码 | +| 2021 | 400 | 邮箱验证码不对 / 已过期 / 试错次数用尽 | 重新获取验证码 | +| 2022 | 429 | 验证码发送过于频繁 | 等 60 秒再发 | | 3001 | 404 | 工作区不存在 | — | | 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" | | 3004 | 403 | 角色权限不足 | 隐藏对应按钮 | @@ -69,19 +71,53 @@ ## 1. 认证 `/auth` -登录方式有三种,**默认只开密码那一种**。服务端把"现在开着哪些"告诉前端,前端据此渲染, -免得用户点进一条关着的路: +登录方式有四种,**默认只开「密码」和「邮箱注册」**。服务端把"现在开着哪些"告诉前端, +前端据此渲染,免得用户点进一条关着的路: ### `GET /auth/methods` ```json -{ "password": true, "email_password": true, "sms_code": false } +{ "password": true, "email_password": true, "sms_code": false, "email_register": true } ``` `SMS_LOGIN_ENABLED=false`(默认)时,`POST /auth/sms/send` 与 `POST /auth/login` 都回 `2017` 「短信验证码登录暂未开放」。理由:dev 环境的固定码等于"知道手机号就能进任何账号", 这条路必须显式打开,生产打开时还会强制要求真短信通道(见 `deploy` 文档的启动校验)。 +### `POST /auth/email/code` + +```json +{ "email": "someone@example.com" } +``` + +发一封注册验证码(6 位数字,`EMAIL_CODE_TTL_SECONDS` 默认 600 秒有效)。 +邮箱统一按小写处理;格式不对 → `2018`;**这个邮箱已经注册过 → `2019`**(让用户在填完密码前就知道); +60 秒内重复请求 → `2022`;发信配置没填或发不出去 → `2020`(不会假装发出去); +`EMAIL_REGISTER_ENABLED=false` → `2017`。 + +响应 `data: { sent: true, expires_in: 600 }`。**真发信时不回显验证码**; +只有本机联调(`EMAIL_CODE_DEV_ECHO=true`)时才带 `dev_code` 且不发信——这个开关在生产会直接拒绝启动。 + +### `POST /auth/register` + +```json +{ "email": "someone@example.com", "code": "123456", "password": "dadao2026", "nickname": "大道" } +``` + +```json +{ "user": { "id": "…", "phone": "", "email": "someone@example.com", "email_verified": true, … }, + "tokens": { "access_token": "…", "refresh_token": "…", "token_type": "bearer", "expires_in": 1800 } } +``` + +注册成功即登录态(直接返回 `tokens`)。规则: + +- 验证码不对 / 过期 / 试错超过 `EMAIL_CODE_MAX_ATTEMPTS`(默认 5)次 → `2021`;用完即作废,不能重放 +- 密码强度不够 → `2015`;邮箱已被占用 → `2019`;`EMAIL_REGISTER_ENABLED=false` → `2017` +- 昵称不填就取邮箱 `@` 前面那段 +- 建出来的账号**没有手机号**(`app_user.phone` 可空),所以 `user.phone` 回空串,不是星号串 + +邮箱是这类账号的唯一账号名与联系方式,所以注册必须真验一次码——"随手填一个"会让账号变成没人能找回的孤岛。 + ### `POST /auth/sms/send` ```json @@ -153,8 +189,8 @@ ### `GET /auth/me` 返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。 -`user` 里带 `has_password`(前端据此显示"设置密码"还是"修改密码")、`email`、`platform_admin` -(能不能进「系统设置」)。 +`user` 里带 `has_password`(前端据此显示"设置密码"还是"修改密码")、`email`、 +`email_verified`(这个邮箱是不是真验过)、`platform_admin`(能不能进「系统设置」)。 --- diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md index f9ad756..a9ef962 100644 --- a/2.Design/ROADMAP-M0-M1.md +++ b/2.Design/ROADMAP-M0-M1.md @@ -603,3 +603,48 @@ 自动注册"造出来,关掉连 e2e 都造不出测试用户。生产是关的,所以生产**暂时没有注册入口**—— 要开放注册得先做「邮箱验证码注册 / 邀请注册」,这是下一步的事。 - 生产实拍:`2.Design/screens/prod-account-email.png`、`prod-settings-smtp.png`。 + +## 10. 邮箱验证码注册(2026-11-03) + +### 10.1 需求 + +手机上短信登录关掉后,生产没有注册入口。用户原话:"默认用邮件验证码注册吧;" + +### 10.2 决定 + +- **先发码再注册**:`POST /auth/email/code` → `POST /auth/register`,两步而不是"注册时顺带发信"。 + 理由:用户填完密码才被告知"这个邮箱注册过了"最气人,所以发码前就查重(`2019`)。 +- **邮箱既是账号名也是唯一联系方式**:注册必须真验一次码,"随手填一个"会让账号变成没人能找回的孤岛。 + 验证通过时间落 `app_user.email_verified_at`,`/auth/me` 回 `email_verified`。 +- **`app_user.phone` 改为可空**(迁移 0011):邮箱注册的账号本来就没有手机号。 + 老账号不受影响;`user.phone` 对这类账号回空串而不是星号串(成员列表同理)。 +- **防刷**:同邮箱 60 秒内只能发一次(`2022`);验证码 10 分钟有效;错 5 次即作废(`2021`)—— + 6 位数可以被刮,不设上限等于把注册口子交给脚本。 +- **本机联调开关** `EMAIL_CODE_DEV_ECHO`:为 true 时把码直接回给调用方且**不发真信**, + 否则每跑一次 e2e 就往真实收件箱塞一封。生产打开会让 `validate_runtime()` 拒绝启动。 + +### 10.3 后端 + +| 位置 | 内容 | +|---|---| +| `app/config.py` | `email_register_enabled`、`email_code_ttl_seconds=600`、`email_code_throttle_seconds=60`、`email_code_max_attempts=5`、`email_code_dev_echo`(生产校验拒绝) | +| `app/errors.py` | `EMAIL_CODE_INVALID=2021`(400)、`EMAIL_CODE_TOO_FREQUENT=2022`(429) | +| `app/models.py` | `AppUser.phone` 可空、`email_verified_at`;`AuthUser.phone: str \| None` | +| `alembic/versions/0011_email_signup.py` | `phone DROP NOT NULL` + `email_verified_at` + GRANT(downgrade 先给空手机号补占位值) | +| `app/stores.py` | `email_code_key` / `email_tries_key` / `email_throttle_key` | +| `app/services/mail.py` | `send_verify_code(session, to=…, code=…, minutes=…)`,纯文本 + HTML 两版 | +| `app/api/auth.py` | `POST /auth/email/code`、`POST /auth/register`、`/auth/methods` 加 `email_register` | + +### 10.4 Web + +- `pages/Register.tsx`(新):邮箱 / 验证码(60 秒倒计时)/ 密码(前端先按同一套规则提示)/ 确认密码 / 昵称(可不填,留空用邮箱前缀); + 注册成功直接写令牌进首页。服务端关了注册时整页只显示"联系管理员开通"。 +- `pages/Login.tsx`:`/auth/methods` 里 `email_register` 为真才显示「还没有账号?用邮箱注册」。 +- `App.tsx`:新增 `/register` 路由。 + +### 10.5 验证 + +- `backend/tests/test_email_signup.py`(9 例):真走 SMTP(monkeypatch 记账)、dev echo 不发信、 + 注册即登录、码用完不能重放、错码/过期、试错上限、邮箱占用、发码节流、弱密码、注册关闭时两条接口都回 2017。 +- e2e `web/e2e/register.mjs`:登录页进注册页 → 拿码(dev echo)→ 注册 → 落进首页 → 退出用「邮箱 + 密码」登回来 → + 同一个邮箱再发码回 `2019`。 diff --git a/2.Design/screens/web-register.png b/2.Design/screens/web-register.png new file mode 100644 index 0000000000000000000000000000000000000000..e13d78f35534edd8ee989acb6f9ec481551dbab0 GIT binary patch literal 33662 zcmeFZcT|(xw=NuXD=GqRX-ZX5K@d<{=n;|Lq>HqTND-v>55R(hrl0$un90-gVP=^XHm+An1i2y_jk{7hEYD|L%N<)^onCP0K-eix|QEAMc< zK#(W=y}iywY10s$J?-s{Kik9E9v(=ZpYc%@kO(NuhHHPBM0i}>)$R~4{XSw__?|M? zOV;fMg2U_U3+jY(g5RzO2L|4uKX?1Iv#2JYm`vc^K3ZS^UFKRO57#w+QUJr<^uY7Lpd>{>2@IdS&d~tFt-U{3f#J#WWiem~ zY^QSq22dE+B6;}l3;%m@C_ogTz^wl^i+|@cSI&VdM{;Ki%)ImWi$~_|kcBgK75BTE zB;0#91N>T;A|(Be-nV8pC{0m-Y*dNR%TG4@eH|wG=&EH-3)y%{wOm*?&g=H9+t;^m zY_qf7T_;RPyX<{cTXRs;oTvHRQm z^MS?#z77UP*({$oo{Nt=rv?mJ7zF>=hwaQeQged9CS8Iy$-RoV@!V)`K~UPg0*rG-?o;4g_t{36SG|A)in0>v$?)Q6#+Jz2dO_ZRYe?Iw1@kC>RXP%Ab9V4w#it*U` zC5|FfIs$2a&8{3QNm{4YiL`bopnZ7J4>e=`lvm1ibXRdQTVr1Lo>HeBIL8NP^Frzz z=qD^H@O)ru5ww(t^&B6&s0#ncJ(eod2JnuWFoU@;yV5cj`j^-rh1URTgN(2y@)dr z*8bN(AhA1ni=f4yCInAKGwI_ER&e_9L5x`vHrMH={!pXG{{EIq_(1>>FP|GJZnqy4 zGv}tU((?SJVauPG&n07dll?XiA8GXeF-h*JLNT@)avKdw1}oEA;JQJT3h z#O*{ru)`F7aF$Z|(Pw2R+- z%Ijx-Ms1*`QRR0zSFMo{tQ-e(e3i+&6B87PMgPK^V0W?UCum5M;3s zBe4!cz!WD`2Lrq^S3)~WVd7R0yOo8<#N8%&0l%ZuUz-my4wuTDMm06V-iPdV`-AQ3 z+W_Q@1wi$Q2GN)drmIis7(g_}r3>3Rkco!6Donhd27bDWh4&ahW3hGsL|AJ_hq&i7 z{Sk$p?|>~0Y?J#*@$;RpWl|!~ zD&l>l=YoTA^ym39n^W}stjxk}wp~tpX3l-TU9$$cO?%^gT&q)W-gjxi1Kx$yy->{z zBtHEZDZu=7;jhzulZLiUQnQqA&CdBwYDh^(kbv1PCL-kx*wu}6yNU}+>X(8vsl-vG8kEOWAin0>G@{5*8=8X;^33%!(Ej06kB_nPYrs~w_lBU z&UN)kU$b|I;!+?z^@bA>>4BPs6Mm`dk@n_tc@uYRD_ejK%*nQRYkD?^H;IrWE**ty zj#4q-W73-2HIs~47)Mp&+Hb2|>4hM1Hh-PIpM=P`!B&hFpVZ-k%av9I`Wc=mS7?!7g>_PObJ;O3@pWW#E$uEH*^%Ni!V7&Hkpnnm@0 zwqDmY{hV2^WZ98YQO%afzd2RAdW5X0T?q#>k@B(kI3-EVqIEBtzcuD>M>p8KKD)jE za68sZfZd6s0IcbS>|CZS=yBtOP7S{wh#+moW%`ODM_w-`c3CDzy*1_VC>^TQM$ykq zB#aALm6oJENGTH)8pl>9c=Q`D_`i>EtDslc0)c44$$`qRthTM?4_3VR77;vE`}(wg z*c{H%NSvF|g|p97MzWl)&2(tl0{giVHncvY(@A7|c0sYe;Zrwmn&GO%RK4O zuC4`yeM$|ohcx@hE~VxYbq&3573(JJy!gt*=0Tc$dwG74P-sF`V|n4`gCw8u+PJ}r zF6x1Yk#wQ_*0~ZLN;M}_iiHxC_D*c#`cA@I5yE2zPK_^+&n)4Foh9Mx8LU~Rq1A6~ z&VzQR?=6CEaVdKCz;QE!a6dO+8%YF3X4_6cicSbkxOSLL#^mtKt<0rwcfapF;wG`n z-$=46;W(eIZ{nMvkCL|0t;fd zN460@jeu+*?jg;Lo#*_oN3T(da(MP6`daddTb@hjtD$^~zr} z1@w%+OrZAb)hb}Y<nkP;$lTaF7zCR zfr$S~FYE@GY{;2~;@$sFLdfe7W^fI__p+7IsDK%^r=k4w(K{Og+#t})HaG``O_MzU zZyg-XsmWXWo}&SfH#S}O!M>-VgiZ*Drm-`r9tIm-)`EEXwHn4HhC21O8>H9noIocH zn(;Gsh1DWe)+7Ocd#9L0I4Ro>7G~9aJcafhEH%=rI!zmA(}0|A;G)1yqDL?@Ll&0_ zY+=p3n?@->6#i5PJl772+t>6bx@`AXo2l0}lD3$-V8bJ(DQ3;2mi0;S3*9qMx~^br z|J}lD)O1YWM7F;f7;hc7upL`9|EbX5LA5>fm&ShG&sf4Ry+;hoI|R>(>LGxI`0Qti{{>^lFN(q_qE`m(2eAXryK2@UU3iHk|mnz#l<6eBR-|m?1=oO-g^xjGGEN9a7 zKe9FmoxmTiVa%NE(b}Yg#F!?(qoL9b77*y9=(-bV_{uD!X7k4E5Jp8E=8vb-r1o-~ zI$5vzFff$px6(N{B<1A48O`WfiE!QcS|~(qEW@8dIxE+d6T=;16$8vXGiIzFT*cc1Ed6gS0H23DJmoOmqfNT?D?Y3uMGbtcZeHF9c0Dm54026?Jp>ocUmf+5?}wOkf3 zbSB%HP&QJh5btJ;Sj{h(klG_fl3MJN+*YcYtc=0800uC9pBzkQi5C0v0Wf87#@pp- zlDkHIVq_eVm>vda5q~fq)yV~Wi}#~a^d@$?)&JRy``KUi0nT=iEynuh^}v`GXO5MC z*G{OJ&yHP8ar5ofUq|>(NUR3<%HY?N2E`J|x*ti@Fu2Fh10Vnn1)qUH;xBOf2UVy} zYN~WrUu~18rcSNyLIEBfd+TR!iF7qk)K>z##R1)9ZkH*_xg(Q=VK8aE?Q&ckqruAh zG_h1>mqRF76AWyqnpO({ID^E1PS|0gE{q;Sf)x_+q&JN|m=aTtkRQl|TP!eQK)??% z5s}5l3SJWCVCVzUjLao*)G}UfYHjAP>>3^pT#YSv(Cb!DtvlPX`;KHv?td-;bLtp3 zsI>YzMJEAJx@b2*J8^M6(}UEn7r%k{twCyvXKRbYj#zk&#D10(b6Do+Lk@yiR!U@7 zcAkh6>~b^%>nb?=;{I+^w?vh zv`A%d&H#(thL>&(e;Qy+iucKQ&m%=b^QHp{ofmGy6lz<@SzV$k&ystUSm<<%2QpJ@ zZjx!)AX+@jFf;edEc)i-H80qf`okv?sS+)E_AQoqDCC%B0icbf>-gpvl>azCD!)!8|zC4(%5u_y#!Hfk2BDDgc|5_4f4EKq=%E_+cUJ zO)Dkq8p#r*dOnUE#twOoQqtT0C|A3h9$_uRgYF}wPlm}vb0YBs>v?q^*%Cp-*(Iiq zF0FmKA{H%;k99m`mpXtXhUk6i^4pHBXX8aS9ax(wiIhwq;gi5bOwCHZuHbc-(Y1IB zfPExErAJS%JR*$D_OzlnsM`dD%kY7#ZgV7Lnx+%lG&p|q|`x-Txqvks+QM?_&O{;*#}FY9F3^I zdbYXzo$t5{@m+N&^GN6l$SIi2(4UC%L6^ZtY`!5vL*l+9HYdibO|K6TLjFgn5;MXg zCF)*cD*g+1-^tz?l0*_rS)F@kInbcGlFgRFL2-PzWbJv@5!cYltHWXD*&)Mb?f1i0 z-RIS8sRM4L^eNTFxgUQKA*u90a*+W953yTaY1gC=x-vUMGT}~$6Fg~>q3PvJrvJC; zG1CQZhqq#_c6&&5R-rtkyTBQ--o|QgRW~5#i{{)3 zp#nxC)Wg8;<=+jA4Gvew_IGzyIiy@bmR?Jt4jg`-pgP-4GM>n9FjiJ zXp?Ukj5!zGKVwbA#wA^RIBQzBX9wBZM@z4JPGetl8A~5W=Eosv%uln7to+?4rsB|~ z&P}i50SG>EIJU-`*p`{^FQFm1L2RzWY7n&=DqeY%1QldTczG8Z5XkP5DLQ>=PEX(YV`XFDOx=M^VZcbXxMcUtfNXT8vtR6`6ZZi-R zSBe=G5GNfDk3_sQNO@OD1yJ=FK9V6G2JubemmV;9fssCrynQ zX!Xl|QKhD;0+{jjug{NA2P83nZK!X2uV=tP&=UN?mf*>jYACH_z+W*0nA0{Uk>2Cx z48BS^;9z@wWk0HA1yk?&c1_tLMAMW#K}PJXp!GsU^UK+TjRSD>$HJ#2++r&n0mQ25 z6iZ+A2Zy@{_J0DfBPNow{RmVc=Jl8NI-0^*FLW8O&*Er;Rp8+zBb zMz~%&MCrwpQr}-(c-F?<0?KBrrgQ3NnN~P=U~2tkB~{fATJy`hxst6Mf&X9{`mT2gx_S=@YXWweyVXRsaFgflz9z`Qzcnz*RV2;eq(`D^cv zm)vy^ra)yAN>aQ@U%wkjpN_Qih6I!y7b(L0kJ_13nIKO7bGB<)d!y@q_{a)Ol2neP zkm8Qi>Q>7@dUYQI_G~4IJR!4%W4138pqCbt69cmaHabIR_fVaXZ5sUeULL!NL}-*IX*3efjBNi?8$hDDsy>0! z=`;gqwvo*j&w$4}1xb4#{w59lX3No@I$&aW4g=V9sRf|lQvlXQ*6-hB0738nmd4NU z2dG|L1+4QceoJ9X2C_t{9X7(cNgZ>K`Ar$_ubCSdU^i{2dKZgnjOy9?6lp}6r z3ua)mqmy)`Aa8xM*>Yc!s}u4;fn$nrc3XUkLmEWR<-Xt)yKst`@nRQj{A-TX+o>%h zmhV;M$kVQ)IAf?SQ^27;5dJ=i$z9dxBvJ2|c>((<5x>?tq5GOf&nt&2p7Ts@ut>`i z+$?-#@1Og~As|<%7vIEm(PXTktN8p>_D#ergLF0%^~q2D_^e9#ci0NMKV|St`UCRKYOh-VO|mN`txlah2Sws5B*tdC3h zke{ps_J7JyRXHMpESomZHMmV&%f3ui4_2cr-!CoNbUZtDFxFy@h7I$Y9?dDrC5Qdh zt71TJ;-ZeMKOH%>YzjrrEkZX$&J-ax(+yd2Z=d(2z0Ab#wU;}}_;z9L7Ito(R{3D^ zBB&U^mT&f!iC44OrV<~%+CSzb}M zVL8apCMZ}Xsw3fi+KHnt+FtlnefBVZf(a8mP+=S6;chFVLfyI*`-ZAqMr@Y31AJ4O znemTt1qQ^kAgSyfHc6F}9{_tUc8e%#NrM?Axvj#~K(uO90% zyY%exA26~8;59?A4$lQf{gkz0s!2D_?^8pLVgdTe#iJ{q0Hr4Om6?9Gj@g!fg`D}M!ug*eF45fvM zqW(IkzlJ3V!z>}Wr9x?ce33H6-aukwQ@X1BS~m*uC-~ETD(H1p0N>pLKvueOf*f5S1si+;p>Jh)QJ8Q16;^{5OpN-r~PS2<-GNG z{_D1Xt-SAlw$l5Nl}8M1`eu4d?8}LAP;A~HdlyqGnFMsb04~SyZ~xGD@KU_JQ95%k(0Cz|i9?IK_xbt>3VgZ*yAa zXzgr(@AVyZlNV==^NY$N{KOazHdR4OP9vUZ6gwn$TO|Od&loDFvuMr^y^?34JI?PJTizk9bfjmpW{dkQL7cN9F#0fppGmL z;aj_%rSYfqRZmB~`p@|tc_bG}A4*%6hgr>s(Fa~&hQ}-A`b^*74ieA7&FQ|3Iqn_!{o2Gq z;r$rjo&m7g^_RCNvD;jnI_u%-=)SgJ`>|e$pHbWEQnlPZS@2c#N`M4>D|XbNioi0G zH4(t5jZQwzdRqAB%KgX}yQ8PyPcpI07hmwfqn7T|%l}IYU^p;K^;AQA(>2!i8|dyV zT=B}wpZ7^S%Cr+zC+%&8Tu|Sm{NXmdOO?oHGuM63{;$N54e3a;u>l?CqV9arDHS`Uuw1`R5A`m>=R|MLd#I4?k$!2z%j9AOQX`@`hWm?)|SfEgo zM9cOVKg5c$vq{4jHQLYLOWuA*++b`PWm6U{MLcJILycQh3eZa@y!H3-`@APcZ~0w} zpXo)_)>k^6n9_k5>(9ku*6>a=e}fowK@9#eLd>7J32hpof>tQ9G2e)~$j79z?#Lqi zLsd-L$91_x`WX5s+DxldU#7n%Crl;I@u)dyaSLl)*W_507Ic+a1lM{&bD)oCA?4(C z78g!*GNo%;0MDLKznL9+8dRCx_W?l?eLKxsZw>p=Q&U-1OE=1IQ7T?mDJl_$ojF;1 zkQ5?L9qQKOQ|gn-vOKjB!UrMjMTX-bN+{1a1lPV~TP8Y^thDpf`x*t3TJr$qKlm*E zP<;q=(huZjp9*Us-MG@y6vvEmm_MZtHSCd1XKML1X-U0)GhKhRMD{MX{I4Pnhao!y z{&sBAx3cSB7Ws}zjnO_eysSjv^r7RLpQ@c{_G0}eMITv*BYy~}*P zmEAXPfcZD1S5J0R7#KJ;ScUEM-rUe0>oqqHFTH8t_C&6~@=Gn(m}L}^zR6Jd?zp^V zO0y$E=P(#HMsxN!YnrnLu&D&thZ)w}o2~|0)%L?E>kk(&<81D*vy!@tD$XyYU3?6+>Sn}2^8NJMh9_1*PNNS;D*mr$EWS1Gx}KYCzZ8A@iR=d5A|RS+SFbA zjkiHRTYxg#@RcUtD%qmvWzJA;BVq5Auo|q7tB;u7(^94tS?%Q02?-6oi)rJ_a=xdX z_U@c!W47~|26Unh`5TBP8wkso>A^Rx{AtrRfy<*gkY?LyML7q^UCyUjv*#kyuX{#+ zvVAvF=!+RB>}}g!en+L!H(~IQs>1;6N7rkmh;h<#P!xdr%`|Qe12l~)=CXKUsoopF zPA(;RT&arL$=5RAGKQXMa-_~nYJ6Ufek$jY$ZF5EQz4|M^4v&r_?z>yOlFt_B{pO= zO9b}8&Aek{BiB$Spi|RV-g!1hoJvm1g!_{Td~~rxbSoy)nTyGCR7WmV*oN!6{EbYj z#AknY?&b(cF5GhHtZH5wv63L(@OpJ@ye9(gdN8t5ktolR`rK{l8}>fQ>!qI_1aWi` zL`ha8qiNh;M7?x&yyU#+hnRvL@lD=Y^DNx zvSJlnz@t6#~H{1)Y_%ZG8^F|1Neu@%=YO)^&>QE%(D3MKSJ{))R!qSzpu zRE~^p27JT|EA=#Hh*Q<@Z94nS^=-u%WEXg4B#J(-r})`I_O28$K{5ZQ{KyjUDCSAf z@~JL7gh3tiB>^`f(G{1#m+aY}nETpZAJN;%jAwr=v}F^gS&E9LwWa)3)V*-;NZ##q zWmLtAAxb*FiL3JiUz1Nr5khVSp`ZyD_YJ~n=^p#$Syp5eMg^!Pf=%h9nf|6Y5nVID!&I{g0fgfbPfVHbV-$dW#ZD7H+Y&NIkn+PIY;8-+-j6JpKkggU*R zuFcL^dcSNJl1LO2+R{pOco?A_xfSzV;zszn#1tJ4^bF(U{MT8lU7^RE=YN`=Bg=MS zcY+q$y0-CtM#y~g`ijos9~b>C+WaYx0r7&;)-@d3fG*m6ExfQ-Zc%L__DV@#F%~g; zvPV~fGLn2~8B|T+^AJp);q{rQ7jFP%d6x30SdYWphVw$sYc>is+-3hDOqgQy=^Dz& zF9<$Q2!VUMJIW_gDHflgVqmtrB}E@${iB4eb^#%LuFj;#H-|g(($8{$zqwWl1gCVw zkT4xfhlpSY~ zfyPm3+o>Ai9zw8f740}VqW8jv8W!13@Adt4$@HyYR*c+uaumn-mA62 z{JR^&I~^V8WAb-&8Zn(!z;a?Om{91mxWB_~(r5D@0FH@Hf^M5I)s zr>!z-MTkcsvLVYrxQgeEXW)gMo??Qj2MvSSn4xBc1Hx7uRy7fan%&PYU zC5UEo%-Ju(DL_A$VmW{xv&K@hUGY|F@3Ez8FEc-{ksjdpBhWAwj}TP-5x zM^}>=sdA5#3L}uD%?tu2{=5aKAMm6JHXy%v=H8FlX4-*C@ua^zes;sn2o7|QNN(cP zrWJtSnGS5HlhUd^QtKw@oBXu8uJ-bMp3VEx^Nz22%3K}2Z_W8&v*q*G9Hor+Ps(aE zn~FTS(`3*@%kNA4FY|$Gxq*(RTh;{ruRd4EaW2q{U{-%S zdWFLd9~Q35ULTV*CmBC@8LZwYsAg7MveDbX6*7X-1fC9B`~eWNPZjP0^MX6sUU!7l z-rAbORdWYxg)3x{m-oWAzH93Oy}3>=0G+AZaE?+jhYMlc_xSJkZpZ`C2c*;pRMUXY zUMC>$0wU#qqO-U0JFQy*~_3$EI`z(o6$2IuTtegU39wf ztR{IQgl^vB9B7rSd1z!{v}T!BH$Bgbz5RWORy#oD$iXVhc4%QRP2!KhHtzcEXJ8mm zh$QboTmhPSjpe5~kiYCybagxge63;Iw8629#UvzpQUz4`+iweZP(exVcP=-iDEn0Xgv*V&tL4u8tqk0w%p^% zAECW+u9M!Qg;9xP+fz1r$L6%hS15qU20SiW_rRe6ov#X;+YOCjij?GM%@g}xYx7G_ zz@L?k>tM5^Ow+Vdb1_zhwnH-D36+{u4Sz7*#RPm$r&B|df4^jtXT*>8`91KcCd(bI2&pItfSaqy|dAk-n3~DhY{rQ=eB18WJ_T2op#q{ONN&1d z|5KqD|K&`0I?u)n!tuV&&?WbL6%U2DBbmcfkLh+bmISW0Gq1R@!aSoJ2A_qy1Amzw zIhqaSWAv5&r3DCwD&LcYNQhLeWf)P)9BuDW4u7}|`e}9@FlT$)0d%<&`X*Pl^x4%6 zlQG2a$KVHRsYXjPT3R1?rU_ydk8VES-qR6y6s#JMys#hqr-kMlJw~AEDiP=ue3B9l z>m+%t8{(@AlqS_#zwUogmGeT+x}Q&OFE&Uh@rTrKc0s+bvVTJTA*>>8i$8j?*9D;w zf1IcxysU7~K-wK`5>cK~HkQqUN?nc-f(_B!S{8kHzLeQ{PNp%gVzASzVA)5$jgQ3kDs~Wq!84}RERWPShdPm z31y_IilKtb_?%*h_UgjUn7GV!Q-(zU_+X4QVf4^Vs=l|0Z;iz{;0~_>DZ}||Y=O2n zex9&3l_#88_aQ$yT8EMhfXTpfM60YiPi6|D7y`~LptGNHM3ne{%^z;(CQt9QV&)~B zPN4?l?5Bj#lK$_b0|z^)H%6e*rd|Q{JDer0-RTZ3jYw$*e&{|Cs;K$^H(RIyMbFHl zLnBSnQ39Oo*t;Si6~7iuXhTV$<%0TNXB49q<`M|N%S!+(Y1{zgVshQLU@&DX$BPdiL<{d}#2LZK^VmL{c5d7D|EX7)S4FW;J6$42iR#sx@i zN6NtMhM}E}v-Jx?8{5V&YukUBj$1n%pKWvzcQ-!leMZt4q$AcV$I5|-^h5Kpl3Pne z>yHXSCRR+CiPePqFwY+(8mC zzy*3j^BW+3KwHuaY~@*hn$yiY{o5<*&@W!>?p3GZ&d$>WZRr2~DJXD`49$9n(zXp} zyNC%kL7K%+;SPd`;`{YW+(^(ZPauT9{I1j0+W5S}`M|?oFHr7oebX~=((gL}bA0HC zkM0G5inZ!;ZU4qt0H5eRxVwjjBK`V0Ln4FI7cDCKa@@r_){N|i{ z&rtEsLT&0gD&d>eJZUanA-tI8O71FKbGp#5V?NXPs)CJvS@XT}{UW|Wd#5KLP`C0w z%tDXJy!~_6N>{WrHY#b2v*J$J2r4syeyCoCH{%5an?TuGoNtmWrBS_aoh!gDvDDiP zSky~-pkLdCUjDkPohlzsdNZdA*a7Baxc9{qX)4^XnqbWP{%|T zL_xy&?aKbhTt-bV72)p0?8tZJXzszN8tDq}9~*jN z`2##g@}`ZR0{pqtUK5y~g$Ai%w8>pqS7JTPy|1-4RyUC~vD9QXH$dXrsgsG=F)#T? zkqhcqqdw8$CRST%?Us%JmFAEs z<8KXF6ABGOPorvohU7r>L|=&i3u?OSt6&kK+f45Fu5v#3rc!Y$_e zk)R0MgU$5>EJu8m5zfFI%S>dGore|0d%b#RaSv@4;i7i2T%&XS)H~)p6H{aVzh?#{rjK_+= z{df2rflzG2M<$3LSz?7>JeM-pm%6I5SM-RGU?Bi|S<_hQW^T`wiI-ehPRierx;Q^! za~itY8^p*1{EV0Xl=T^zoTbY6cb>0Qn`cjNs|YUp%L&QB6CjeP1St5UJBN_>@)z?R zK3ANYy1U>@$$pjX7CA9&&Vzd$zb)tLxubWcvY^28+I6`F09!3U zC!^755UctA!2#9&!IhaB-nRjZq=mwgl4%wK(^DV_=3NebZrU9YJj`FebJhr$7#F~Y z{&&uPO*$%zT`iDPK3?%z-Ltiw0yas{0PVU$3v*dJi)b5c0UFJ1YhyNC0<`k)-Ha*m z#w;WkH2D@5Jbm}|eBeai-vNElW2a!wh^^iOMK8}nA+{|4xT|Z{qq>haO@i@PZQnw{ zh7P-go6RIYX=pgr+tK}KXWloHu7=l!2}rfbq42f}*!Jnd<9P|*>A*ko^Yc$_HlrJ# z$EkJ#vWbZIcMEg;u60$Ri>7H*KUBMh(Kq2pZV0)NM1%eBVJ5zuy6C4dq zOmx_YXD9_F6FGOZ&p*t+#6wTteS1W?oXJF+c=%v}WTiJg&L+Y7K%NfqMHkg} zBQTZh#>$5sj`;U}1cxUs@-XCvP9yA2G#Z_J*0^hp^2}7;)|QUqSq9xw1ZM4`U1jaK zuEBQh(`!Vo815ms#>3IwUABRfS*0!_P5?$rK5V15-}15Mq&tSEQ6?(3($CNa-%;` zfM}W;wET%?}?C?wuwO&ueo@mMJhdNC#RX6WDcncZPL(60@ z{t4S_>V?+aJa%qdOT$Goi)3jA(KXe%S=+Qc=3;BNd&jA98WPjsE?&^1_obmwf@A#l z>Q);yiz@P^$6%7#&P0{!aceoGNNt^2b-~Yiy~KpHT?&8k+-cFQVEbfrrEzQFu6s?mZn73*2Yo;4)i<|xKBF0)n~Q?dVX+|{4qV!%;G z?Z!%5c~V5{1?rKg%GQT#Q?CUP(ZTNJSXYW8tDXX=TZ_ zggKCOjzvLEAIXg+MlgqY!zsS_fwOU(eg3J!I3u5-G}n~}PYZPKs@yZ3b}(hhOK9F*F63aKk!ju>!7e~|4LF{ab@UX1?KkP`vke@l>xx~6pd(S~>ru?GqeoPL?=)?+axS(rW(W9kf`|ss`@;8n=nU0hVo-|f1j_=?LW4o&lKX~>@GI%J1S;RfM znPR;6ipNu0%c`6^LpPB~L$A5OoRKJ}5$XW&t;U1S5etoa^X}uM(#Be0H)%{n+DL-{ zAE~x)J-Lwbrjz%I_GjHy=YD0M~3FYO!c!0TM3YjE8w6r#!HJ8f>E-#RM(lsa4>R+ z=E1S`=meX|kYwSgQR!=m&Kt&KN8=2e;9#}Aj*xMqYPN$9(2Cd=CNtq$b0mia$w8@c zxvjirc^+ay&{}eqbJ^Szc38hYh;NACr+q>tGqdkpnl7#24}GSmH%`I^AaT&?eL70N z?{!hwt$dY4mqUphUr?YNaMJMS=gf8QOrOsDXY=nzeHojMT6$^aU}9r7)t)iB`1r%s z23jI|{sik5kkNH4&EI%BQq*dnR!=)y6MT2xT2aT8Fdk7lF;q3INqN=*+;x&pbAv;j zo}T_0qO)L}qf&s|1~v}cNH1s#n5pAU-T+K}kBKrnCGRTpi9v3v+fz_r^?d*+)a8?& z<{lZ7taMrZwmM#6oo)>>1K-u!jUC++9MB~bkPaP^c?zULm> z1(EyFZV*XPe_r+V%ojD1KGm#Drp+Cn*v* zS4DZlPP`rDs&fGA7t4Sb(}mVC(QH(iBsjLE-bT675AeWxRzjTy8x>^k$79@=h0OyK z879!;H9&uS!m{Z)xT|H}Ql!QkQ?gE&|IxB^>oxkJ{*bo-D|X!KeXK?1qQz0FZWo(; zmm42xV?L>rO}cs$O^L+S_U)N9FQx9Hi@+SIRnW1#9JCno=9v2F#no@$=)=S#|D^?d zTZ9vim`HFEexjU##8A$h{)|LA9Az6CGP_ZJQ4Qs;hPCOd*V2u51eD5uRL*-wHH7>; z%}J*l?Z8>5vt&x^$a+{;fN74)}8{vYs` zPld_J$)LN2RIuenr@In@rB?zuW5~DqorqD1-I~tObUrk6aIiNuUHF_}p)g5jqWAat zr*36ge^Y>i09PMAh?eOaD2~$Kn)iKuffaG|5e(}7Ouh|$rDKi7`p*CQj(sA`tjg0- zEcV&tC_fTzK+zore*^%zIsU!0-gvm!t-1^{;FIrX4@i^-CUnR82BDmCZubyJtLueJ zz##`RN{^TQSX{A}bQihuZ#lw+)6)h|jtBCrh%fZy#Lb#G2!Jty3BJAGrfB5ZEq2Q= zW9aQ+Pm~NC{{X4f2c&02_ga9doVZ4m+=c*`d}9C(E9PAV^xU)t9;|+7iHQV*2lu6) ze*)>}IKYw=PznIdiSLxx@ZjP@Y1n26hB)|?kx7#;c44D^`^wA(P&aui^b3JNTv%QJ zmGuWWN5)r9T==+Hw9_+yEdh#TSVk1U+%WkCJ1<@gzuH#dMC<_c=G6Q9H zByo|Wp-xIEIcp!>K8|Qq0%t;jqcXrNEH_RI82&TL^8cNGHo*&s96o@W@8Wm^C;}{B zRGl1ZT02hcwOb#Qb{%f*=quOv{7uZEfcaL|{lg{l+;DmL#BFORqI)*ibV4#6SPF4y zj%csUvf68(TL0D`z>l-n1P)2p6~sJx3go2O)&n0$!-4!~o~fikM9Rf%+iFd9BF*q!v>w?Wd(*tPZIp@6wEBLW zk6LQ-bjcwKj5F~zoOgiYa%Z#R6H3I7_kjn8hG-~I02yUn%^ZhAp}p6BXPXccKaKUk zSC`G$8&q!OP%fX7ZNxkOO%dW_ArPtMzWV^W~WZ$5+$cSNp%*lYv=M)M#|(BW6> zwZW!0D#|>YUMow=b^pBYtvM796G^KI?^j*MomXvrkzeZHsJOW-KMwGwJ&b~R+mo~-uO}!&R4VIZb>S*ZcEoCD0{qD2TBoju6@qYVq{dqSI zp>j>{4Isx`y!KmkH%~NSX0oa5Oq$fJ+s7510!0zO8^EW}e|M{_RdB^5n}7`$-@3M=8N#>!?xm5*)FYFwG_g}JZmnYU%ET*yUSxu4uX zMh7%WuaVQQK=KI<;PoGh_5a4wfd*)?TVH$&3XZpm3rYarqXAKJK_GBIQ2GXKXgwyr zyYlnhE|wBh%qY*&z*9KGv>H(2YHl9-{_Lz#2_*JhL_|yXrA}RgEg&?(bawy&>DDjM zx2pdmOvob?KsI(-{d<7Cw7oC)n+&^|4f%SR{&v2;^u{0 z2Ego&^(lc8NS^#Zk`f?U{Ub_^m-H3@F-RF4*mkRa{24eEkj4HIkR4APWxq&pC{liug0v+ z-J2M2!`=Rd2US@^_6Nyk*MH3?ymXm4@g9Kj=K=>X7KP-=x-n0{A_imhIr-(|H!meV zc#*!xN+#y!Bp^K}8*>t$^>pZIX2(3jDx&^+g}k>kzBf6jF5rVschfDypj_$!bCIzC z44rI5K*nfeBo8KT7N6JAInTA=XKuS(i&3lFj2|0&P_y7|KC0UK0V^Xe0R>cZTv|p> zV(%aUjPB9vpj>iT{bsCe2MsC#8QGC_rdF zNdnZOcsSp}+%#{x*1Msa=jZL~^QIXoYQOIQ%rT(`s%QxcoXGpPPxiA0Kjqz%=nu5TQ=D!%aQ=Grk}+{JaT5GHunfm;wJ*)3{*2ExlhtY{Kv2bBzLimx2!VEGRe?=k5`O{`ak|%L#b~ZnqxsUZ z3~J0`DstvWmfK*fMds)@8p$<(e=X`BOE^F9-x5BllPk#RC~*P4RB7=+5d~9IdY~3B zWjV1*6%GiXcAfG0oDHG6;+=WU_cA-x12UJQ?I!eu#Rl8rm}naKM^T-)I_!XH^{=T- zEA{3^vjXovzM&j&iI>lD`TmJp{ymNos;-=asPHN~{2>N7Tzo49a9|f53Bp2$vl)vu z-vdMXAxH1!<5(_jI|aUIRq=3zoRqS5KJ{`>qe`)OEPiOe4~_qW&4Unc?##U3-Rf?{ zDC5*Jc2j3rPith)36?*pd6`zz$A{3)3rRg_ujiHvyPt!MMgQQWk+hm*e`Tz!-*O2= zK7`V(+%a0wR%dM`pqx!h7A7-vGsMQfDCnrDGNp?jS3mOH!>Mj|DtoUi#d7n8T@Rlf zO!dPo|CWV)yyMe@#fV?U9+oh^@v0}o>ex`W1jiQ#z$VE0GXJuJ$nugFI#F1w<#YBj zN|OCDSYr$Rdb&=I>nc@tj6i;^povqN|IWz)S z*l`;wbHen|5zeM(ZGF&x74@BN~x?=#PQ-^??2{>&es)7rcDu2uEc zTW=MqA?0FYe2n90NfetkJ8~ZLRByX}#OJQ!c_ju7_RKhXd#&TV`%i8?VWc66RLXO- zJTDG#WFKDfanV*s(l-6(Q#fH9O)N5T&d;Gcy)P7rkdL`sB3{NM&)-9$@Yra7&Zc#} z_pph`4d#OV%oZi!?mm|}d@d%iai+^aL!=vkE<8KD@;jLW+aQ!(4DW_+M|=I3@-6Vz zKhaCh4akpEX=BQ&fR|KtkOOh}@AV&wVl0^og!Fy27{sKAbz}(WB#<{w>|%(B{^mga z5_%#*E(pp>xTM$WO|_@*ftwKi+wDfLkExcG2-&aq-0|l-{ZK35Rd7hjx&?wk$blW? zH$3?J1@r^FH&I`#V!|yTzVdQ%?I1~-^QNW(IJ);YYpYyTA|%c4CC*Xa{4e^9f3UQ) z=YCbnBuZi+kTP(oKZY&2^0CX`q&Fd##ss&D8axW+Miuvk_BM8!z#4IPz)~aH z(3QGBxd63&0#DCL|Gqx|r@cLQ>JQ1n$I3)_i;j>L3%pggWjM24@50}giOCR>A97yE z39fdua_v^eCMEjA6%f4uVr^0}PJxAg1vG$p%C}(FbYCK=1-2H5l*yam#w|LjNh$JW z=nkSN-7B1x*yiqKM(u-M)K##0GDrRq@nf&H}VfVGHqlYgzh4C~ z16($c<72=7nd5tj-~Gc_0gvxBh6jD#fFjy?+}_weTBLf4^wSdN%DBO=6+j`m;ExoU!pxu$d&L#T!*nKxyGd^;& z`Ye?dvs*H$A(}m@`)W6n6*@QU+5-=qZ>^f#u|59jvBHKI5wZ*E%sMAz8@A7cdK(8< zakN!B*&4}}zhzv#7?49Nh(#K+`Ow+a;wQ@eBGx!!1@Foeo*TdRIFb1-6+$lBK?Ckf zX)PjL0<+aAt5?6)Il;ZqxIpgT)5FWi#bsKWX<}gM7PfT5?(q&D`YqIYj0Z$~j>w5} z142Y(xJ>%@BwQ$cY*fFx^omk}0)L@^$Y$I;VmXQca>FGMFasUGfYQ4D!4u{A`BkO( zIy$>vxVxl=q@{6Oa=hWDb>---XZr=o z(`_4z6A$Ji$rxN*;me;#zUhq@gh`5OQnPV zagw)=kKJF{$8UN%bh}hJuq;H%<)iJtY0QcC*6om)4|j^FqIV_)e1(S0*&lZp`OC_9~(I1G|YKNS2htOZEe09VeyGx~F=|}9v!#$cDVFlT@ zuJV5nTREIEky*IP6S+E=SbDq4?6;iPLvLlgVHGnX_!+9wMf#; z0^k_GgpXghob!x>Ez8&wP{}2vQM>lb$xL#_Hxxzg?fW^j$Cy+jL>bMCk#o11ct2Q- z_w!MwUW0g@`JEO2xO`Lo-Zj#SD%E5A!s)bDDS04U28RZ0Fz|=cB6am3h5icz{Z|6d z|7URiGYn2waSOwB55T?@+3i@Z3b{8Pa-d$%6PT|U!RRIzi(LRj(5&IkgA`^Z(43$L z^o(QpaCu@Pu6hGAtjnd2R^wwpUdDYeDG!vcnIs)T>m8Y8sRw;lA_tTVlb*UD7#=Hu zx{Q{v-WW}Q6-PyCv5o#d+;~^eawt7#(m^KL+`V#|Od&X!66WkF5_5o_(MgSV9ZV=6 zh6R!$pk?unYIyk$DS$3r5fh$EEg7|+`H?&uL1&8s04`cTI~zZ zWiqIgC?v&^5S%VPZHNd4=bK35anC@hc`qt^4NzF6lR+xv;n{V@@0Arev{dN_GC73} zyM7#q&PXJ#7EL#kU{lK1Sj%l66&^m(v3BJJL_)`(S$)Ts?NHDQXhxpgA_WG`P@2L3=`-Ez@tC`C;`wXO?A9 zC{sljBW7g}T1``hGWr3d$M>8_=QjXv+fJC9zuJC6$>Qi z6`t^9sApfG?cXvU=by_H>qD3(9W0mffD4mtfB)jcQCDfa$N^O(IhW^y3eDMZLpI<<3g3cEeWVYFEgf10QGrAc zbb**{c{Z%F0kIjG$?O4IK zrKXL36c9X?;jx;a;15NRcLhU%5B~IDOEA2&`}_KU8vUmw;V-!P^Qni2heHty$yDxN zvcw~AK;vE9-T^}Uv;T^C0k7q_Hbw^_P5hIY{WZh(;wa@>TDn^ODO$d65|+P{vl+qO zXBn(V(>lebxIb9&{(2R_JN&>}?I)9oK*<9n_%~-Eq-lQ$?t}3n6c#)e4U)e$B3>GP z>p``%0ul+Mvi4zqzgGB%jZy(!~Exg!A_I^C}kRfqEV&)qzHVJ}XF< zCLTULziOwlKCGomCmNwb==ClPBm#h%>_Wzv0F?Y^TEp-c3H!gI)c%jSX1vbkEYP7y zql4S~g~ggjcTywMMe$t`6oomao+=)xor_Kzmm%NKf9y$W#X`jbOZfK->q78s@x{cQ zks>k(VZ4!bbl67Lm}=2Z?@E=bi_U?+;BQGm>I31wlWgRJ*nMw@F}HzgSwS%fMm*^!gaK z5)*gTUI>t-|IGQ%t?w&W)CT`O`2r)b=f0(wDd<*U0CVmCi%tfjB{0p9N&W1<^+N!? z;wO(@n>BSg^*DtnSYjq}q_Sb3hLfi%ttpTPt#op9EFEcQ=X#qXnhyD8)8A$lXp{`>WMHfr{1d*?}46W{aKcA~w^( z6dE-4Cj)*=2rO~jQ`--l&0gicnBxU%Inih?wLv6u#=>>JvJtnpdgLuXG*~sKQS%vhpmf>(nEM(&Zoz{n53yP#KJE}4PBq=b4RR90 z-GZN1zd6)a|6JSG{(#QFph~QJv|{ds9w8)-1W#}DO`&3W_bP76Uqe=#OXzfM8V!Fg zf*0KKqNA8zvcH>W>#zh@TMt&;-8`O8bDr6=?tntwaxNp9Mjr(8tQZpB5~imuW5pf6 zx9X0cnKj`2W@pq|aksN#X9*z{;XM+lzv<)_5yY-V-EPDQRbr?ya4%c@;5>aWA*Qg7 z9?uM`*1)WGmD?vmpQZ)rBz#+}fvF{5bI2xwMQVS!XyVvc^`lL=(suvzYKfI!>ctMX zQcV6Tr6g6mhN6F;tNG%I`(Rgv&%gk?h0bZN-=IJ7E#kO9Jf zY~NLyiMrvdHlyl*?d!_+$`UcLg+slG+38wV*AI>^B7tSAbC(uxc%#oUW)&VJ%SHLR zPty=aidlHXEB^T0Jv-A@@0D@R(!-`dv{zHz==ATYs=kv^qkJ)g{AdGYT0&*66Z`|W zK@X0;c9M-vtUL{joj-dkX3^7AwSm7=hVtF#W4ZMmcOj;yVtI{ZM{%*St~iFR&Fwpk z2o-~Tui5JT&23oc@B`-))$26;>d}Kv5@z@P>0{3~rPd*mPcHko^UraXS0V_b1(qu` z7F`S0YGBubV@2(4gbw!>vFALodnuHXTsJS8&_mgDthcAcsu3+x6NhNDmfM7jS~Eg< ztLa1&DnJUQ=B#83pL#z49D`3gWt>2QmB;)D)2g`h8hS*MDGsWFd9+@hneHzDJ--vD zO{4U*W`zX-e_b=oh`}CdGl(bkt62j7vQ8>GfRbk-V(5!ybLbgMzgasF6E$eqI)G$WIUILL(5Pk>N@6N% zmdkO(rAA8whzR1+shu5qZNYDgDebG`>uFBaHpatm3~1R}byxrTD-J=57w$a~o+8_5Y!BIflYEwRULYG8!C@sP;;!T4h8_S+XZ$}&dQ zktfGblsjZ&G6wHVR*I}m_P{(x4x>{0+9iGEV3HaxzL#$r(;%v~CoJ2Xur3aZj?JIL zj^q|oJ*qNpy`PZdAP#_zzqabMZ{gXC8rMa7jP{^>YOJlaY#*1<^t|an9L8c-u@P3p zX+z(4KDO0z5+t&l>R%sYD{LH4wWcJkipOj@ueMA=F{V0;SKBF+4N6!wG$=hk_k)?O zNbZFpl4axVa3`x45s#d)azT|2H=E{#Sa=U1txf;%IaZkb(T*oe7_+RR5Yk&YCZ``l!P&imLH_!2-Uz)NGW2#9$T_z z$VnJHDNmD@WbT&srW9vM?tR}Vr@m6(EuP*PV3LFFvE+=vNg1+wTDN3mxO(vD#9Zdr zPqQox?RUQ2#~?V8nXplo?+H}NA6$e>^HH~CIV zfBdXpkzEFCq%RTY2lte>(ksAC3bF`WeTecU7GQMmF<;a?$`r^FynXeFM2*n z_{LA7diOrG^C~VoZu4`p9;+W=mF+26*Qe37 z#LnAj-;J@6?`g7*fjyh5gUQkk@Na7sNmjEh?OJt^ae&t>@UYoCsDiJkvVFnHzdT0$ z>N(F6DB;(=RZ_W>GqRq?%gd7@(4!o58t%TMTw3SZ75fhQ>r51B_0@wIn#j9b=UY>c z?3zNYHzK!JgPA5pk^2Xq3Nt5!>fCn37vE^Cp5s3+qeR$g;o_d6)w}RSL;OI<4K`sn z#l_Dte!VsUt4lI&^(DsjNq3YDTmv;M?%HnpN)MHwN&$v@w1jW4M}4u1wR>E$(?PPu zM}rXCYW=v8#i**DzcP62M*mzaQ6r!2wC4Dvao9kICp&RfhSpD!MkgBA_C?Rby%ut1 zji>1@Pt4duDI;^HOiZtaUqTpVYQh`+CIyTSj|kPR@r=qAk%SSH{td>`*`a6Hj*MOg>P?VJpzrk>1pU7j~oUa*i! zCQjRuqo?nw*R_rirEryBp}5}}T_sOtr#rTHt4KY)ar!+gd}zx|nS%Cfw$Kd8W0bC_ zJQu9GP#qBq=BFHQr1-INK7)JkH_ zYdv5VRk{S2;|!#GM(PV;7$E^;+3*WU;=>f_K^001zYVljQtSgokH#9Y;z4ARccSUi` zMttm36M2+taGAOEfiz>be+2um`=dAo52V9G?dn`U|GP6X?@eYil;|vx$NTqyG)#19 z`J;88s=I}bAj-Q|K>Wh^%))IaB(kh5o1bP$>{fq)BoPNcAAs!oQG87l4C89mg_s!{ zdD3nN%(LqEa_y=%Yfc6OrH+i+ZWyOX^I3AkXK9zU?xO|-#a9nJ{ZgvCmqHcjUvFv2 zMp%gE)m&DKNcKr;h8|)R@<(o+diV-omT|Kk4SO?5n;*w#dc5$F4AF;ceCM+-0rdx=%?X4eoZ>cPHJ3O63n{2UqJdKbnzU zv8?2HA6pfm7G5Q2@UtwSWx--XrLQ0fXRrAD3O~<<08LKO3&td0`@Tu_U_Fjo$I-oq z>+ce=8d5;g9_6Xlsp82Hr50_o`$1Q<&IMIon^td_tGju6u z$<{W!Ln@Dwn-%Jp3`U69FK&#S2f;PY^ZwM~vYyqfE=K})g4E=A6wJi+W#33-z}jU! zGu~lBsho28XX2%IaRk-neu>Hbsx}f-&wUpf>x0Z+SP2NvCDbf&2lc535SQL{UDrYw zGhCp>Ce(7)i}5qw=NFseuG((1?j29ix)twYUJSkN6csrxB`L$XZo85c6=vW%@ZoJ#-)fcn(o%l_ecNuc z=IB65vp3k9J6TL*DdwsPSe9NSvqz^gJl}}qVegd0me+m4E~w!TRoIK;sg^7jV}HgK zAE`97={p>Vlp+ zQtQ{B-lY2YyG9?Ol64GU)IUjYXgBK(lBCmmu(W=QGv)fWjW42iHcLWbc~TTwps{{~ zy-#iJr4=~_87a{imgZG?)^<@*I7++las2UC^3EYOQ)0%x8TtC;07j$5uYwv}>h zZ6}{pE~R-p&=eL!Z{1Xj5Z!%Y(wD~+M|Eh)W%qt)Yub}y_e2Te74b`Z5`2pA;V7O; zNc^{16mz$8z8z1D3lsd%Qz>v#_(%V38sJmpRPk-O7`2VRn1mZF`!P@$;7uU^qCg9s zER#Y{+|5$)sso+1Krir*c?{u$$=34&a=dQXGhX7>ovB)he@P%!k3`K`N-& zzICJ%I_ve0e4e_($z8L+SRFl)UdajX|2vn3I=XG$8E2MW_~I0)n(m{+NQ~6H(?R8b zna#RS0L%H43;5qDt%C~5|6~#cp4sYHXz$m8jvO|#^b4P;6c{U<=RNh!&?O#z*tPt< zveOfY+wZNr?rt{FKnR(BcP0}W=2ArE}4RVBxSc)nJ|0tfTD;96zxDkmG*<> zC?;E5w`g6z`&?H!HM)x^Q!zNjm6xAsZ9dr(3cf-(l2DGJQ(C;4R6yuOAMp zWH_kMKBf%qVQ;uJJ7UUuiU7IBZ?~pr5wdmfQkVLP$7AkTLAziVL2ge^#8Yws$d|&M zI4r~AQe+zIkHLhyBIT|lwS*5yA)Ti;%|@52T%WqSzI7^ zC_gX-dHKrw!lsAM#W9L^HPql>dS?hB&vXK4%#eAn4p>;SV^>R&gqtNem&V=l^b?^m z;gu=Xj8uM+2fJdvNaSWyNKRX<&hciCX@8v<=klKHWD|od4AXZPx0W{~>HLONBE;R)6Lk;P=?S>AD-<1G<#dhLywhBtYW+z{^M^R(ax zT9;nC$hC28cn~To_<($>FD|=*?=p{+OVm#Tx|^-HhI!Oo3B_>VZC<#`?tjXYp~ZE! zDrDzqom73*NcvFos(WVS%M({0r#U~>dF`g;HCv3Cy1J*Q=Lae#+{!xLQMDK>F8aC0 zChJxewu7IQ0+J&1h5(Y1{fc)fghPLtfOiWJZG$|IbadK#ql^thT78Y4eRuyRwV-vP zr0!bjx*phXUiahS-kQoyU6D%rk+NXq-VG#^F4LjMtE|!y;p;ms`SZQ>hmJZTH*`g5 zt|+Oit8OLaRi1&oGy@j)a>d`25eGF(l8m^L?{Z6Td!{0vzfq*SGznAB;JZ*=-fT+7`{RqZu5&wvUEsoayv|9Qn<`Q_t#_3WiN3yrD%kCIB9atn94= ztL;#9GQE;N|1L<@jFP`EP=+|M{(Pw1%RB8_8lsg;lBVZm>corYTi;H+Fs10ps5&05 z4A0|)QM<2=I^@;X+XYh_-Jg*84*Bs})nO|GG?!bMynOi$J=NZ(cH`!3P$(-&z)z#M zpRZ9J&(-%27i3V1z0lG$D6Ggh0bh_N^RwbsAw_EQ`+L2{l9uULU@^iu3n2_-B*)iL zw~zFPa!p+wiZrAl&wiXIg?#CeVKe-^@*^v=MiHqbDPk8PmVaB(mL&LShM%0RQGYV~ zaILS)hpVV4K0coBTrkVvVE=$xjA$<<#OuzpDe%K`qH-leozPlDjETq2sK0;4|2h+o zkqVlOxY6VC?2`r_;~XD9_EOYAFvq+rPH3j01*595_fT$)IbPt6;Xw4tUSj}dlaA+A z$QL27U%!;U`tl*@8~82@!EG43Ea#&cpGZNwjLzs-XO$;y*OHsLlFfto-`^f>EuQ6q z=M_K8$vD_y8AO(uH6mayRIftL8}Xd}(O2{hnNlvayE}J*miHaO^w{uf&8va6m64J3 z;NXiF7zcue=s0z+{}iNQBBENY=I2QX%Cy==lLV*J0IPR%-N_tMCS9vmab4DyI$;=b zT}9>WPxKbdJRyK^dsEe%f+(w-dbA8tNYc=!2UX`cRaL#)%8lK4Oz20Ikez)O%HyV{ z%J+@2(R*ZsoBL9>_MO0mpDU7!WylMNP1f}`C8d#%Oz+cslER&{xUX}-`Y2yxjl;+>jHG~UBsq_!QG(K0lh}30GxjLRWNs2OWbDzfWfR_~0pI*l_QjjTfYzWm4x+~TKTg8c1pnicr$gpvsVXOJuPU;_Y&s8g@uOq=&1aN{b4Ixx^i)uOA zh6xupivmg_o$C}qe_0M6glGfki)u{kkaGyiw=wvX6gRGuPguF0k3J; z%Wd?g!pPyCC-)&^lGrgCWL^RvO+~yFg!>LyK8#Ob&7$dH(=tvLary^GByUEpamzar zaZ~Ye$jf|SSXPqP1o8<*Mc;X@((jET2&xcuMdmeq6gLp*+?JZ(qo@|fwM27}t~GDt zzk@|>?C_t_by;8)C5LR^@g_DU@X|8=qzInFnBJBQ>M_3ZhTvz8h_N&cWb76QOIq`c zpgr74#{q?j;MR`MH929RBYt+;J;Z8sGlpaNy{#t61J zVPuA(sXheq@(j4N(E}EqF73`aMOD3hj>j1oxYUA`hl_0kv(m()`!VZr4mr*iW0}vZ zjhyZoUrlUY3fO?{waF66Dzw2}rLGCLG@zz?r>kqoU<`}amlSX7)aCuN6y|ZtpYnNEh6$KqjD%igKVo z%H2t0xGG!oleq`Ckv(u$YrvrC^To$04ui1+-7K1U(`dOmYms6L?9Y}7PTT z=W5?9q!8$3Hqwx_utu*)xVV^3I^psL114Rj4=p^L6od8X?!BnouK1*IJhwEO+%%jU zP+J<9F*@OH->fAC6Le763D}us)AAfaW4O$s$T%$7iDyc8$K!1ojA5fg4y7~U^kpm% z2)7T|g}VF9vBYGPtZIIPaqW?hY;ix~G`f?5pD$grbcF^`dcVD7URy95m?7=uO`yn05uKp+|l8nc|(m)(>0_n~J@A zKPR0mL^NHK_na3t#7TnahRMm0HcK^>jk=8>u6WskI5;M6i7Ir2qT9+}JP76((jmSi zKFb)ZsqC2qE)+NPI-c-%tP_gs$bJoLqrYi=-7n0*Kb8%q-J_TFzWR!=x2ZOgJT z1XahZ9+@veFNgSf&k$P5$q|+lDU0cLo|CW`c~0BXUAT(0*nHw9Yv+db&m*`ubFVtf zJ;)}(P~mXvQZUN28mE1nk9w~#i6SLWqrI<&GdU_({36AJEkpQTdr)PVXBF*X-FrMr z>KGm}Ub=BOeH<^p*7dTQ+Yc2JoZYnIFJ& zO^oxX*T%|RcHeAytuc&?{mw_y$^HDbW2sU)PSeK?V(P%^AK6ldpFM2BDEY-w z2`#3sT6OZVHk8{QO^@%dNY3A*oHw_5Z$TAYq@}+g@S?zSZ7rNk-pa+ggUC0+xL4^S zfsi~0>st2xx$Q%S_f2uvU6;eVx+)#OG?Z_FpdezIyD=QokjY)Cz8afSa`7D_-}}kH zVU6NawwA9>P>ZpxqlL~%(U1CPn!fH(Hfr0nmxCm#$ohKTP1(cL7zI&Vtrbbf?t^f) z*TLi03m{VJdm%+!BA2Z+X9uL5s$K0{F%vx^pX;gx(6cNNKDEyxmPaRu!8W>u-$n)* z{=w8mdCY8=);Smnwb@-U`?U;lzBV&Hi~`rJoK21OoTagTlf~=K$WlfNbxcdSoH^zs zzsRl~ZRZ$U%{@UJndjCkZz3@KYy<^c7eZUhMiM#922_m-*T&v^$NTtXH1V910a7gW zDC4?ZB$ocLKZAwLHy7GAR=nKP)kEXUUUdrc{0+Dc>z5B}Hzh(plFXZE?YBv2Wnaa< zjd^)S%54-jxHfTFGjj6$JX;9oST1~?Cg(xX2K@$ea*W2^==C?;PwPgJBEn^yuZ>W7 zB@vudv10c<&1Huwj)x{L2QfkgJ)1M6CQmldH|h16lMFg=D~rdz3~82DX@~tNPR`+x z-1VttEML1i5cb_oCxzJ1M4JnH=ylgNh_`;&*tgspj3FNkqD>v2?4#zhcu{CmCo4>E zb!ce5xF6f`;tVM=Z#4$<;(=q%5j$US2Tkbc^1$kK#y3>4VjqC}C22_K9Q)}e$1GF&9a_g*X!bYcWCMXpa+P7{7hYbOivi}DCguQk(-_~Yq;DD`kYx)uD$w+i zdV1A~qm0`<9eA%|r$`JJtswDj2 z*i0+ zpF$v~d!I@?Qu)sw`Trm7KM#!lvoP%c< None: + # 邮箱注册的账号没有手机号:老账号都有值,放开非空不影响它们 + op.execute("ALTER TABLE app_user ALTER COLUMN phone DROP NOT NULL") + op.execute("ALTER TABLE app_user ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ") + op.execute( + "COMMENT ON COLUMN app_user.email_verified_at IS " + "'邮箱验证通过的时间;为空表示还没验证过这个地址'" + ) + # 本表全局(不挂租户),只补列不新增表,权限照旧:应用角色要能读写这些列 + op.execute(f"GRANT SELECT, UPDATE ON app_user TO {APP_ROLE}") + + +def downgrade() -> None: + # 回滚前先把没有手机号的账号补上占位值,否则加回 NOT NULL 会失败 + op.execute("UPDATE app_user SET phone = 'redeem-' || replace(id::text, '-', '') WHERE phone IS NULL") + op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS email_verified_at") + op.execute("ALTER TABLE app_user ALTER COLUMN phone SET NOT NULL") diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index dadda16..9d00c57 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,9 +1,12 @@ -"""认证:手机号 / 邮箱 + 密码,手机验证码可选。 +"""认证:手机号 / 邮箱 + 密码,邮箱验证码注册,手机验证码可选。 当前**默认关掉短信验证码**(`SMS_LOGIN_ENABLED=false`):内测阶段的固定验证码 等于"知道手机号就能进任意账号"。关掉后 `/auth/sms/send` 与 `/auth/login` 直接 回 2017,登录走「手机号 或 邮箱 + 密码」。登录页先问 `/auth/methods`,只显示 服务端真开着的登录方式,免得点进去才报错。 + +新用户走「邮箱 + 验证码」注册(`POST /auth/email/code` → `POST /auth/register`): +短信一关,就没有别的注册入口了。注册出来的账号没有手机号,`app_user.phone` 因此可空。 """ from __future__ import annotations @@ -11,10 +14,12 @@ from __future__ import annotations import hmac import re import secrets +from datetime import datetime, timezone from fastapi import APIRouter from pydantic import BaseModel, Field, model_validator from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession from ..config import get_settings from ..deps import CurrentUser, SessionDep @@ -28,7 +33,15 @@ from ..security import ( password_problem, verify_password, ) -from ..stores import get_store, sms_code_key, sms_throttle_key +from ..services import mail +from ..stores import ( + email_code_key, + email_throttle_key, + email_tries_key, + get_store, + sms_code_key, + sms_throttle_key, +) router = APIRouter(prefix="/auth", tags=["认证"]) settings = get_settings() @@ -42,7 +55,9 @@ PASSWORD_FAIL_LIMIT = 10 PASSWORD_FAIL_TTL = 900 -def mask_phone(phone: str) -> str: +def mask_phone(phone: str | None) -> str: + if not phone: + return "" return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***" @@ -60,10 +75,11 @@ def _user_out(user: AppUser) -> dict: "id": str(user.id), "phone": mask_phone(user.phone), "email": user.email, + "email_verified": user.email_verified_at is not None, "nickname": user.nickname, "avatar_url": user.avatar_url, "has_password": bool(user.password_hash), - "platform_admin": user.phone in settings.platform_admin_phones, + "platform_admin": bool(user.phone) and user.phone in settings.platform_admin_phones, "created_at": user.created_at, } @@ -125,6 +141,7 @@ async def methods() -> dict: "password": True, "email_password": True, "sms_code": settings.sms_login_enabled, + "email_register": settings.email_register_enabled, } ) @@ -321,3 +338,126 @@ async def set_password(body: PasswordIn, user: CurrentUser, session: SessionDep) row.password_hash = hash_password(body.password) await session.flush() return ok({"has_password": True}) + + +# ── 邮箱注册 ──────────────────────────────────────────── +class EmailCodeIn(BaseModel): + email: str = Field(max_length=255, examples=["someone@example.com"]) + + +class RegisterIn(BaseModel): + """邮箱 + 验证码 + 密码,一步把账号建出来。 + + 昵称不填就取邮箱 @ 前面那段,别让新用户第一眼看到一个空名字。 + """ + + email: str = Field(max_length=255) + code: str = Field(min_length=4, max_length=8) + password: str = Field(min_length=1, max_length=64) + nickname: str | None = Field(default=None, max_length=32) + + +async def _email_taken(session: AsyncSession, email: str) -> bool: + stmt = select(AppUser.id).where(func.lower(AppUser.email) == email) + return (await session.execute(stmt)).first() is not None + + +@router.post("/email/code", summary="发送邮箱注册验证码") +async def send_email_code(body: EmailCodeIn, session: SessionDep) -> dict: + """发码前先查邮箱是否已被占用:让用户填完密码才发现"这个邮箱注册过了"最气人。""" + if not settings.email_register_enabled: + raise AppError( + ErrorCode.LOGIN_METHOD_DISABLED, + "邮箱注册暂未开放,请联系管理员开通账号", + ) + + email = normalize_email(body.email) + if not EMAIL_RE.match(email): + raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对") + if await _email_taken(session, email): + raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经注册过了,直接登录即可") + + store = get_store() + if settings.email_code_throttle_seconds > 0: + hits = await store.incr(email_throttle_key(email), settings.email_code_throttle_seconds) + if hits > 1: + raise AppError( + ErrorCode.EMAIL_CODE_TOO_FREQUENT, + f"发送过于频繁,请 {settings.email_code_throttle_seconds} 秒后重试", + ) + + code = f"{secrets.randbelow(1_000_000):06d}" + await store.set(email_code_key(email), code, settings.email_code_ttl_seconds) + await store.delete(email_tries_key(email)) + + minutes = max(1, settings.email_code_ttl_seconds // 60) + if settings.email_code_dev_echo: + # 本机联调:不发真信,直接把码回给调用方(生产打开会被 validate_runtime 拒掉) + return ok( + { + "sent": True, + "expires_in": settings.email_code_ttl_seconds, + "dev_code": code, + } + ) + + await mail.send_verify_code(session, to=email, code=code, minutes=minutes) + return ok({"sent": True, "expires_in": settings.email_code_ttl_seconds}) + + +@router.post("/register", summary="邮箱注册(验证码 + 密码)") +async def register(body: RegisterIn, session: SessionDep) -> dict: + """邮箱注册:验码 → 建账号 → 直接发令牌,注册完就是登录状态。 + + 账号没有手机号(`phone` 为空),所以邮件是唯一联系方式——这也是为什么注册必须 + 真验一次码,而不是"填个邮箱就算"。 + """ + if not settings.email_register_enabled: + raise AppError( + ErrorCode.LOGIN_METHOD_DISABLED, + "邮箱注册暂未开放,请联系管理员开通账号", + ) + + email = normalize_email(body.email) + if not EMAIL_RE.match(email): + raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对") + + store = get_store() + stored = await store.get(email_code_key(email)) + if not stored: + raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码已过期,请重新获取") + + tries = await store.incr(email_tries_key(email), settings.email_code_ttl_seconds) + if tries > settings.email_code_max_attempts: + await store.delete(email_code_key(email)) + raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码错太多次,请重新获取") + + if not hmac.compare_digest(stored, body.code.strip()): + raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码不对") + + problem = password_problem(body.password) + if problem: + raise AppError(ErrorCode.PASSWORD_WEAK, problem) + + # 拿到验证码之后这段时间里,别人可能已经用同一个邮箱注册完了 + if await _email_taken(session, email): + raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经注册过了,直接登录即可") + + nickname = (body.nickname or "").strip() or email.split("@", 1)[0][:32] + user = AppUser( + email=email, + phone=None, + nickname=nickname, + password_hash=hash_password(body.password), + email_verified_at=datetime.now(timezone.utc), + status="active", + ) + session.add(user) + await session.flush() + + await store.delete(email_code_key(email)) + await store.delete(email_tries_key(email)) + await store.delete(email_throttle_key(email)) + + tokens = create_token_pair(user.id) + return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)}) diff --git a/backend/app/api/workspaces.py b/backend/app/api/workspaces.py index 1ba9d81..332242b 100644 --- a/backend/app/api/workspaces.py +++ b/backend/app/api/workspaces.py @@ -132,7 +132,8 @@ async def list_members( "role": m.role, "status": m.status, "nickname": u.nickname, - "phone": f"{u.phone[:3]}****{u.phone[-4:]}", + # 邮箱注册的账号没有手机号:留空比显示一串星号诚实 + "phone": f"{u.phone[:3]}****{u.phone[-4:]}" if u.phone else "", "joined_at": m.joined_at, } for m, u in rows diff --git a/backend/app/config.py b/backend/app/config.py index 231c99d..0d67ddb 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -85,6 +85,20 @@ class Settings(BaseSettings): smtp_use_ssl: bool = True smtp_timeout_seconds: int = 10 + # ── 邮箱注册 ────────────────────────────────────────── + # 手机验证码关掉以后,新用户靠「邮箱 + 验证码」自己注册进来。 + # 注册必须真的发出验证码(发信配置在「系统设置」里),所以这个开关跟 SMTP 是否配好绑定: + # 没配 SMTP 时发码接口会回 2020,而不是假装发出去。 + email_register_enabled: bool = True + email_code_ttl_seconds: int = 600 + # 同一邮箱两次发码的最小间隔;测试里设为 0 可关闭节流 + email_code_throttle_seconds: int = 60 + # 一个邮箱最多试错几次验证码,超了就作废(防止把 6 位数当彩票刮) + email_code_max_attempts: int = 5 + # 开发/联调用:把验证码直接回给调用方,**不发真信**。 + # 生产打开等于任何人都能注册任意邮箱,所以 validate_runtime() 会直接拒绝启动。 + email_code_dev_echo: bool = False + # ── 幂等 ────────────────────────────────────────────── idempotency_enabled: bool = True idempotency_ttl_seconds: int = 86_400 @@ -163,6 +177,11 @@ class Settings(BaseSettings): "短信验证码登录开着(SMS_LOGIN_ENABLED=true),但 SMS_PROVIDER=dev 只会发固定验证码" "(等于知道手机号就能进);接真短信通道之前请把 SMS_LOGIN_ENABLED 关掉" ) + if self.email_code_dev_echo: + problems.append( + "EMAIL_CODE_DEV_ECHO=true 会把注册验证码直接回给调用方," + "等于任何人都能注册任意邮箱;生产必须关掉(只在本地联调时打开)" + ) if problems: raise RuntimeError("配置校验失败:" + ";".join(problems)) diff --git a/backend/app/deps.py b/backend/app/deps.py index 987a672..27174ba 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -27,7 +27,9 @@ ROLE_RANK: dict[str, int] = {"owner": 4, "admin": 3, "member": 2, "guest": 1} @dataclass(frozen=True) class AuthUser: id: uuid.UUID - phone: str + # 邮箱注册的账号还没有手机号,所以可空;用手机号的地方(平台管理员白名单)按 + # "空就不匹配" 处理,别拿 None 去比字符串。 + phone: str | None nickname: str diff --git a/backend/app/errors.py b/backend/app/errors.py index 9ca9538..7164b2a 100644 --- a/backend/app/errors.py +++ b/backend/app/errors.py @@ -33,6 +33,8 @@ class ErrorCode(IntEnum): EMAIL_INVALID = 2018 EMAIL_TAKEN = 2019 MAIL_SEND_FAILED = 2020 + EMAIL_CODE_INVALID = 2021 + EMAIL_CODE_TOO_FREQUENT = 2022 # 3xxx 租户 / 权限 WORKSPACE_NOT_FOUND = 3001 @@ -102,6 +104,8 @@ _HTTP_STATUS: dict[ErrorCode, int] = { ErrorCode.EMAIL_INVALID: 422, ErrorCode.EMAIL_TAKEN: 409, ErrorCode.MAIL_SEND_FAILED: 502, + ErrorCode.EMAIL_CODE_INVALID: 400, + ErrorCode.EMAIL_CODE_TOO_FREQUENT: 429, ErrorCode.WORKSPACE_NOT_FOUND: 404, ErrorCode.NOT_A_MEMBER: 403, ErrorCode.INSUFFICIENT_ROLE: 403, diff --git a/backend/app/models.py b/backend/app/models.py index 7b3d6eb..663d9ae 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -41,7 +41,9 @@ class AppUser(Base, Timestamps): __tablename__ = "app_user" id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid) - phone: Mapped[str] = mapped_column(String(32), nullable=False, unique=True) + # 手机号可空:短信登录关掉以后,新账号是「邮箱 + 验证码」注册的,没有手机号。 + # 老账号照旧有;将来绑手机号(换绑/找回)再补。 + phone: Mapped[str | None] = mapped_column(String(32), unique=True) nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="") avatar_url: Mapped[str | None] = mapped_column(String(512)) status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") @@ -50,6 +52,9 @@ class AppUser(Base, Timestamps): # 邮箱:既是「邮箱 + 密码」登录的账号名,也是以后找回密码/发通知的去处。 # 可空(老用户还没有),唯一性由迁移里的 lower(email) 部分唯一索引保证。 email: Mapped[str | None] = mapped_column(String(255)) + # 邮箱验过的时间:注册时发码验证过就有值;账号页手工绑邮箱时也走同一个验证流程。 + # 用"有没有值"区分"填了个字符串"和"这个地址真是他的"。 + email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) class SystemSetting(Base, Timestamps): diff --git a/backend/app/services/mail.py b/backend/app/services/mail.py index 1ce8703..e222f92 100644 --- a/backend/app/services/mail.py +++ b/backend/app/services/mail.py @@ -200,3 +200,24 @@ async def config_payload(session: AsyncSession) -> dict: "source": "db" if row else "env", "updated_at": row.updated_at if row else None, } + + +async def send_verify_code(session: AsyncSession, *, to: str, code: str, minutes: int) -> None: + """发注册/验证用的数字验证码。 + + 模板写得朴素但说清楚了"谁在什么地方注册"和"不是你就算了"——收件箱里躺着一条 + 没头没尾的六位数,用户只会当成钓鱼。 + """ + text = ( + f"你的达道Task验证码是 {code}\n\n" + f"{minutes} 分钟内有效。如果不是本人操作,忽略这封邮件即可,账号不会有任何变化。" + ) + html = ( + '
' + "

你的达道Task验证码是:

" + f'

{code}

' + f"

{minutes} 分钟内有效。如果不是本人操作,忽略这封邮件即可,账号不会有任何变化。

" + "
" + ) + await send_mail(session, to=to, subject="达道Task 验证码", text=text, html=html) diff --git a/backend/app/stores.py b/backend/app/stores.py index 71c729e..d8ce20b 100644 --- a/backend/app/stores.py +++ b/backend/app/stores.py @@ -107,5 +107,19 @@ def sms_throttle_key(phone: str) -> str: return f"sms:throttle:{phone}" +# ── 邮箱注册 ──────────────────────────────────────────── +def email_code_key(email: str) -> str: + return f"email:code:{email}" + + +def email_tries_key(email: str) -> str: + """试错计数:错一次算一次,超过上限就把验证码作废,别让人拿 6 位数当彩票刮。""" + return f"email:tries:{email}" + + +def email_throttle_key(email: str) -> str: + return f"email:throttle:{email}" + + def idempotency_key(user_id: str, key: str) -> str: return f"idem:{user_id}:{key}" diff --git a/backend/tests/test_account.py b/backend/tests/test_account.py index 83dabb2..33e702f 100644 --- a/backend/tests/test_account.py +++ b/backend/tests/test_account.py @@ -30,6 +30,7 @@ async def test_me_reports_profile_without_leaking_password(client: AsyncClient) "id", "phone", "email", + "email_verified", "nickname", "avatar_url", "has_password", diff --git a/backend/tests/test_config_prod.py b/backend/tests/test_config_prod.py index 554383b..e7eb49a 100644 --- a/backend/tests/test_config_prod.py +++ b/backend/tests/test_config_prod.py @@ -23,7 +23,9 @@ def _prod(**overrides: object) -> Settings: "sms_provider": "aliyun", } values.update(overrides) - return Settings(**values) + # _env_file=None:本机 backend/.env 里有 EMAIL_CODE_DEV_ECHO=true(跑 e2e 用的), + # 那条在生产是硬错误;这里要验的是 prod 自己的规则,不能被开发机的 .env 带偏。 + return Settings(_env_file=None, **values) def test_prod_rejects_dev_sms_when_login_enabled() -> None: diff --git a/backend/tests/test_email_signup.py b/backend/tests/test_email_signup.py new file mode 100644 index 0000000..97a91d5 --- /dev/null +++ b/backend/tests/test_email_signup.py @@ -0,0 +1,217 @@ +"""邮箱注册:发码 → 验码 → 建账号 → 直接登录。 + +手机验证码关掉以后这是唯一的新用户入口,所以三条都要真的走通: +① 发码真的走到 SMTP(不是"调了函数就算");② 验证码错了/过期了要报错; +③ 建出来的账号没有手机号也能正常用(`app_user.phone` 因此可空)。 + +本机联调用的 `EMAIL_CODE_DEV_ECHO` 只在 dev 生效,生产打开会被 validate_runtime 拒掉。 +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from httpx import AsyncClient + +from app.api import auth as auth_api +from app.errors import ErrorCode +from app.services import mail +from tests.conftest import unwrap + + +def _settings() -> Any: + """settings 是模块级的缓存对象,monkeypatch 改它、用例结束自动还原。""" + return auth_api.settings + + +@pytest.fixture +async def sent(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: + """把真发信换成记账:记下收件人与验证码,不碰网络。""" + box: list[dict[str, Any]] = [] + + async def fake_send(session: Any, *, to: str, code: str, minutes: int) -> None: + box.append({"to": to, "code": code, "minutes": minutes}) + + monkeypatch.setattr(_settings(), "email_code_dev_echo", False) + monkeypatch.setattr(_settings(), "email_code_throttle_seconds", 0) + monkeypatch.setattr(mail, "send_verify_code", fake_send) + return box + + +async def _send(client: AsyncClient, email: str) -> dict[str, Any]: + return unwrap(await client.post("/api/v1/auth/email/code", json={"email": email})) + + +async def test_send_code_goes_through_smtp(client: AsyncClient, sent: list[dict[str, Any]]) -> None: + """发码必须真的交给发信服务,并且邮箱统一小写。""" + data = await _send(client, " Newcomer@Example.COM ") + assert data["sent"] is True + assert data["expires_in"] > 0 + # 不带 dev_code:真发信时把码回给客户端等于白验 + assert "dev_code" not in data + assert sent == [{"to": "newcomer@example.com", "code": sent[0]["code"], "minutes": 10}] + assert len(sent[0]["code"]) == 6 and sent[0]["code"].isdigit() + + +async def test_dev_echo_returns_code_without_sending( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """本机联调:不回真信,把码回给调用方(生产会拒绝启动)。""" + called: list[str] = [] + + async def fake_send(session: Any, *, to: str, code: str, minutes: int) -> None: + called.append(to) + + monkeypatch.setattr(_settings(), "email_code_dev_echo", True) + monkeypatch.setattr(_settings(), "email_code_throttle_seconds", 0) + monkeypatch.setattr(mail, "send_verify_code", fake_send) + data = await _send(client, "dev@example.com") + assert data["sent"] is True + assert len(data["dev_code"]) == 6 + assert called == [] + + +async def test_register_creates_account_and_logs_in( + client: AsyncClient, sent: list[dict[str, Any]] +) -> None: + """注册完就是登录态:能拿到令牌,/auth/me 也认。""" + email = "fresh@example.com" + await _send(client, email) + code = sent[-1]["code"] + + data = unwrap( + await client.post( + "/api/v1/auth/register", + json={"email": " FRESH@example.com ", "code": code, "password": "dadao2026"}, + ) + ) + + assert data["user"]["email"] == email + assert data["user"]["email_verified"] is True + assert data["user"]["has_password"] is True + # 邮箱注册没有手机号:脱敏字段留空,别拼出一串星号 + assert data["user"]["phone"] == "" + # 没填昵称就取 @ 前面那段,别让新账号顶着一个空名字 + assert data["user"]["nickname"] == "fresh" + + headers = {"Authorization": f"Bearer {data['tokens']['access_token']}"} + me = unwrap(await client.get("/api/v1/auth/me", headers=headers)) + assert me["user"]["id"] == data["user"]["id"] + + # 验证码用过就作废,不能拿同一个码再注册一次 + replay = await client.post( + "/api/v1/auth/register", + json={"email": email, "code": code, "password": "dadao2026"}, + ) + assert replay.json()["code"] == ErrorCode.EMAIL_CODE_INVALID + + # 注册完可以用「邮箱 + 密码」登录 + login = unwrap( + await client.post( + "/api/v1/auth/login/password", + json={"email": email, "password": "dadao2026"}, + ) + ) + assert login["user"]["id"] == data["user"]["id"] + + +async def test_register_rejects_wrong_or_missing_code( + client: AsyncClient, sent: list[dict[str, Any]] +) -> None: + email = "oops@example.com" + await _send(client, email) + wrong = await client.post( + "/api/v1/auth/register", + json={"email": email, "code": "000000", "password": "dadao2026"}, + ) + assert wrong.json()["code"] == ErrorCode.EMAIL_CODE_INVALID + + # 压根没发过码:同样"过期",不告诉对方"这个邮箱存不存在" + never = await client.post( + "/api/v1/auth/register", + json={"email": "never@example.com", "code": "123456", "password": "dadao2026"}, + ) + assert never.json()["code"] == ErrorCode.EMAIL_CODE_INVALID + + +async def test_register_code_burns_out_after_too_many_tries( + client: AsyncClient, sent: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch +) -> None: + """6 位数可以被刮,所以错够次数就把码作废。""" + monkeypatch.setattr(_settings(), "email_code_max_attempts", 2) + email = "guess@example.com" + await _send(client, email) + good = sent[-1]["code"] + wrong = "000000" if good != "000000" else "111111" + + for _ in range(2): + resp = await client.post( + "/api/v1/auth/register", + json={"email": email, "code": wrong, "password": "dadao2026"}, + ) + assert resp.json()["code"] == ErrorCode.EMAIL_CODE_INVALID + + burned = await client.post( + "/api/v1/auth/register", + json={"email": email, "code": good, "password": "dadao2026"}, + ) + assert burned.json()["code"] == ErrorCode.EMAIL_CODE_INVALID + assert "重新获取" in burned.json()["message"] + + +async def test_send_code_refuses_registered_email( + client: AsyncClient, sent: list[dict[str, Any]] +) -> None: + email = "taken@example.com" + await _send(client, email) + unwrap( + await client.post( + "/api/v1/auth/register", + json={"email": email, "code": sent[-1]["code"], "password": "dadao2026"}, + ) + ) + again = await client.post("/api/v1/auth/email/code", json={"email": email}) + assert again.json()["code"] == ErrorCode.EMAIL_TAKEN + + +async def test_send_code_throttles(client: AsyncClient, sent: list[dict[str, Any]]) -> None: + _settings().email_code_throttle_seconds = 60 + try: + await _send(client, "throttle@example.com") + again = await client.post("/api/v1/auth/email/code", json={"email": "throttle@example.com"}) + assert again.json()["code"] == ErrorCode.EMAIL_CODE_TOO_FREQUENT + finally: + _settings().email_code_throttle_seconds = 0 + + +async def test_register_validates_password_and_email( + client: AsyncClient, sent: list[dict[str, Any]] +) -> None: + await _send(client, "weak@example.com") + weak = await client.post( + "/api/v1/auth/register", + json={"email": "weak@example.com", "code": sent[-1]["code"], "password": "short"}, + ) + assert weak.json()["code"] == ErrorCode.PASSWORD_WEAK + + bad = await client.post("/api/v1/auth/email/code", json={"email": "not-an-email"}) + assert bad.json()["code"] == ErrorCode.EMAIL_INVALID + + +async def test_registration_can_be_closed( + client: AsyncClient, sent: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(_settings(), "email_register_enabled", False) + assert (await client.post("/api/v1/auth/email/code", json={"email": "x@example.com"})).json()[ + "code" + ] == ErrorCode.LOGIN_METHOD_DISABLED + assert ( + await client.post( + "/api/v1/auth/register", + json={"email": "x@example.com", "code": "123456", "password": "dadao2026"}, + ) + ).json()["code"] == ErrorCode.LOGIN_METHOD_DISABLED + # /auth/methods 也要如实告诉前端这条路关着 + methods = unwrap(await client.get("/api/v1/auth/methods")) + assert methods["email_register"] is False diff --git a/web/e2e/register.mjs b/web/e2e/register.mjs new file mode 100644 index 0000000..3e78bbd --- /dev/null +++ b/web/e2e/register.mjs @@ -0,0 +1,98 @@ +/** + * 注册的验收:从登录页走到注册页,把账号建出来,再用它登回来。 + * + * 这里验的是"一个人真能自己进来",所以每一步都从界面走: + * ① 登录页有「用邮箱注册」(服务端开着才会出现) + * ② 注册页拿到验证码(本机 EMAIL_CODE_DEV_ECHO 会把码回给页面,不发真信) + * ③ 提交后直接进首页,说明注册即登录 + * ④ 退出、用「邮箱 + 密码」再登一次,证明邮箱真的是账号名 + * ⑤ 同一个邮箱再发码,必须被挡(2019),而不是又建一个号 + */ +import { chromium } from "playwright-core"; + +const WEB = process.env.WEB ?? "http://127.0.0.1:5180"; +const API = process.env.API ?? "http://127.0.0.1:8100/api/v1"; +const CHROME = + process.env.CHROME ?? + `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`; + +let pass = 0; +let fail = 0; +function check(name, ok, extra = "") { + if (ok) { + pass += 1; + console.log(`PASS ${name}`); + } else { + fail += 1; + console.log(`FAIL ${name}${extra ? ` — ${extra}` : ""}`); + } +} + +const email = `reg${Date.now()}@example.com`; +const password = `dadao${Math.floor(Math.random() * 900000 + 1000)}`; + +const browser = await chromium.launch({ executablePath: CHROME }); +const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); +const errors = []; +page.on("console", (m) => m.type() === "error" && errors.push(m.text())); +page.on("pageerror", (e) => errors.push(String(e))); + +try { + await page.goto(`${WEB}/login`); + await page.waitForSelector("#account"); + + const registerLink = page.getByRole("link", { name: "用邮箱注册" }); + check("登录页有「用邮箱注册」入口", (await registerLink.count()) > 0); + + await registerLink.click(); + await page.waitForURL("**/register"); + await page.waitForSelector("#email"); + check("能进注册页", true); + + await page.locator("#email").fill(email); + await page.getByRole("button", { name: "获取验证码" }).click(); + await page.waitForSelector("text=开发环境验证码", { timeout: 15000 }); + const notice = await page.locator("text=开发环境验证码").first().innerText(); + const code = notice.match(/\d{6}/)?.[0] ?? ""; + check("本机拿得到验证码(生产只发到邮箱)", code.length === 6, notice); + + await page.locator("#code").fill(code); + await page.locator("#password").fill(password); + await page.locator("#confirm").fill(password); + await page.locator("#nickname").fill("注册验收"); + await page.getByRole("button", { name: "注册并登录" }).click(); + + const landed = await page + .waitForURL(`${WEB}/`, { timeout: 15000 }) + .then(() => true) + .catch(() => false); + check("注册完直接进首页(注册即登录)", landed, page.url()); + + // 退出后用「邮箱 + 密码」登回来:邮箱真是账号名 + await page.getByRole("button", { name: "退出登录" }).last().click(); + await page.waitForURL("**/login"); + await page.getByRole("tab", { name: "邮箱" }).click(); + await page.locator("#account").fill(email); + await page.locator("#password").fill(password); + await page.getByRole("button", { name: "登录", exact: true }).click(); + const back = await page + .waitForURL(`${WEB}/`, { timeout: 15000 }) + .then(() => true) + .catch(() => false); + check("邮箱 + 密码能登回来", back); + + // 同一个邮箱再发码:必须拦住,别再建一个号 + const again = await page.request.post(`${API}/auth/email/code`, { data: { email } }); + const body = await again.json(); + check("已注册的邮箱再发码回 2019", body.code === 2019, JSON.stringify(body)); + + check("控制台无报错", errors.length === 0, errors.slice(0, 2).join(" | ")); +} catch (err) { + fail += 1; + console.log(`FAIL 脚本异常 — ${err}`); +} finally { + await browser.close(); +} + +console.log(`\n${fail === 0 ? "ALL PASS" : "FAILED"} — pass ${pass}, fail ${fail}`); +process.exit(fail === 0 ? 0 : 1); diff --git a/web/e2e/shots-register.mjs b/web/e2e/shots-register.mjs new file mode 100644 index 0000000..e8475a4 --- /dev/null +++ b/web/e2e/shots-register.mjs @@ -0,0 +1,32 @@ +/** + * 拍注册页的截图(文档用)。需要前端 5180、后端 8100 起着。 + * + * 用本机 dev 的 EMAIL_CODE_DEV_ECHO:把码回给页面,截图里能看到"开发环境验证码"那一行, + * 正好说明生产只会发到邮箱。真提交一次再拍,页面右侧是"下一步会怎样"的说明,不必留成功态。 + */ +import { chromium } from "playwright-core"; +import { mkdirSync } from "node:fs"; + +const WEB = process.env.WEB ?? "http://127.0.0.1:5180"; +const OUT = "e2e/artifacts"; +mkdirSync(OUT, { recursive: true }); + +const browser = await chromium.launch({ + executablePath: + process.env.CHROME ?? + `${process.env.LOCALAPPDATA}/ms-playwright/chromium-1223/chrome-win64/chrome.exe`, +}); +const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); + +await page.goto(`${WEB}/register`); +await page.waitForSelector("#email"); +await page.locator("#email").fill("shufen@126.com"); +await page.locator("#password").fill("dadao2026"); +await page.locator("#confirm").fill("dadao2026"); +await page.locator("#nickname").fill("淑芬"); +await page.getByRole("button", { name: "获取验证码" }).click(); +await page.waitForSelector("text=开发环境验证码", { timeout: 15000 }); +await page.screenshot({ path: `${OUT}/register.png` }); + +await browser.close(); +console.log(`${OUT}/register.png`); diff --git a/web/src/App.tsx b/web/src/App.tsx index 42971ee..967d0ab 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { UpdatePrompt } from "./components/UpdatePrompt"; import { api, authExpired, session } from "./lib/client"; import type { User, Workspace } from "./lib/types"; import { LoginPage } from "./pages/Login"; +import { RegisterPage } from "./pages/Register"; import { HomePage } from "./pages/Home"; import { WorkspacePage } from "./pages/Workspace"; import { TasksPage } from "./pages/Tasks"; @@ -89,6 +90,7 @@ export function App() { } /> + } /> } /> } /> } /> diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 26b7bc4..c393915 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { useAuth } from "../App"; import { Book, Check, Logo, TicketIcon } from "../components/icons"; import { Button, Field, InlineAlert } from "../components/ui"; @@ -34,7 +34,12 @@ const PHONE_RE = /^1[3-9]\d{9}$/; const EMAIL_RE = /^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$/; /** 服务端现在开放哪些登录方式。先问再渲染,免得用户点进去才发现这条路关着。 */ -type Methods = { password: boolean; email_password: boolean; sms_code: boolean }; +type Methods = { + password: boolean; + email_password: boolean; + sms_code: boolean; + email_register: boolean; +}; export function LoginPage() { const { reload } = useAuth(); @@ -46,7 +51,12 @@ export function LoginPage() { const [code, setCode] = useState(""); const [password, setPassword] = useState(""); const [mode, setMode] = useState<"code" | "password">("password"); - const [methods, setMethods] = useState({ password: true, email_password: true, sms_code: false }); + const [methods, setMethods] = useState({ + password: true, + email_password: true, + sms_code: false, + email_register: true, + }); const [devCode, setDevCode] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -312,6 +322,15 @@ export function LoginPage() { + {methods.email_register && ( +

+ 还没有账号? + + 用邮箱注册 + +

+ )} +

登录即表示同意《服务条款》与《隐私政策》。我们不会把你的内容用于训练模型。

diff --git a/web/src/pages/Register.tsx b/web/src/pages/Register.tsx new file mode 100644 index 0000000..b027b9b --- /dev/null +++ b/web/src/pages/Register.tsx @@ -0,0 +1,240 @@ +import { useEffect, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useAuth } from "../App"; +import { Logo } from "../components/icons"; +import { Button, Field, InlineAlert } from "../components/ui"; +import { ApiError, api, session, type Tokens } from "../lib/client"; +import type { User } from "../lib/types"; + +const EMAIL_RE = /^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$/; + +/** 跟后端 `password_problem` 同一套规则;本地只做"别白跑一趟"的提示,真判定在服务端。 */ +function passwordHint(password: string): string | null { + if (password.length < 8) return "至少 8 位"; + if (password.length > 64) return "最多 64 位"; + if (!/[A-Za-z]/.test(password) || !/\d/.test(password)) return "要同时有字母和数字"; + return null; +} + +/** + * 注册:邮箱 + 验证码 + 密码。 + * + * 为什么必须先验一次邮箱:短信登录关掉以后,邮箱是账号名也是唯一联系方式, + * "随手填一个" 会让账号变成没人能找回的孤岛。 + */ +export function RegisterPage() { + const { reload } = useAuth(); + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [nickname, setNickname] = useState(""); + const [countdown, setCountdown] = useState(0); + const [sent, setSent] = useState(false); + const [devCode, setDevCode] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [open, setOpen] = useState(true); + + useEffect(() => { + let alive = true; + api + .get<{ email_register: boolean }>("/auth/methods") + .then((data) => { + if (alive) setOpen(data.email_register); + }) + .catch(() => { + /* 拿不到就按开着渲染,提交时服务端会给出真答案 */ + }); + return () => { + alive = false; + }; + }, []); + + useEffect(() => { + if (countdown <= 0) return; + const timer = setTimeout(() => setCountdown((v) => v - 1), 1000); + return () => clearTimeout(timer); + }, [countdown]); + + const emailValid = EMAIL_RE.test(email.trim()); + const weak = password ? passwordHint(password) : null; + const mismatch = confirm.length > 0 && confirm !== password; + const canSubmit = + !busy && emailValid && code.length >= 4 && !weak && password.length > 0 && confirm === password; + + async function sendCode() { + setError(null); + setBusy(true); + try { + const data = await api.post<{ dev_code?: string }>("/auth/email/code", { + email: email.trim(), + }); + setDevCode(data.dev_code ?? null); + setSent(true); + setCountdown(60); + } catch (err) { + setError(err instanceof ApiError ? err.message : "发送失败"); + } finally { + setBusy(false); + } + } + + async function submit(event: React.FormEvent) { + event.preventDefault(); + if (!canSubmit) return; + setError(null); + setBusy(true); + try { + const data = await api.post<{ user: User; tokens: Tokens }>("/auth/register", { + email: email.trim(), + code, + password, + ...(nickname.trim() ? { nickname: nickname.trim() } : {}), + }); + session.save(data.tokens); + await reload(); + navigate("/", { replace: true }); + } catch (err) { + setError(err instanceof ApiError ? err.message : "注册失败"); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+ +
+
达道Task
+
任务 · 工单 · 知识
+
+
+ +

创建账号

+

+ 用邮箱注册:先收一个验证码,再设一个密码。邮箱就是你的登录账号。 +

+ + {!open && ( +
+ 这台服务暂未开放自助注册,请联系管理员开通账号。 +
+ )} + +
+ + setEmail(e.target.value)} + maxLength={255} + disabled={!open} + /> + + + +
+ setCode(e.target.value.trim())} + maxLength={6} + disabled={!open} + /> + +
+
+ + + setPassword(e.target.value)} + maxLength={64} + disabled={!open} + /> + {weak && {weak}} + + + + setConfirm(e.target.value)} + maxLength={64} + disabled={!open} + /> + {mismatch && 两次输入不一致} + + + + setNickname(e.target.value)} + maxLength={32} + disabled={!open} + /> + + + {devCode && ( +
+ 开发环境验证码:{devCode} + (生产环境只发到邮箱,不回显) +
+ )} + {error && {error}} + + +
+ +

+ 已经有账号了? + + 去登录 + +

+
+
+ ); +}