From 5ad2152accb316618a212728092ceb6da267a0ad Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:05:20 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20register=20=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=E6=8B=86=E8=A7=A3=EF=BC=8884=20=E8=A1=8C=20/=20=E5=9C=88=2034?= =?UTF-8?q?=20=E2=86=92=2058=20=E8=A1=8C=20/=20=E5=9C=88=2020=20/=20?= =?UTF-8?q?=E5=B5=8C=E5=A5=97=201=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:注册是站点唯一的建号入口,84 行里五条「建号前失败」路径各自 手写一遍 restore_verify_code(有的还带失败日志),防刷双闸与三种建号 异常的口径全埋在分支里,改任何一条都要通读全函数核对还原时序。 拆出(顺序 = 原执行顺序,逐条语义对应): - _validate_registration_input:入参四道闸(协议/用户名/密码/邮箱), 按序返回第一条错误文案 - _reject_registration:统一拒绝出口——还原已消费验证码(可选失败 日志);五处 restore 收敛为一处,docstring 写明「为何必须还原」 - _register_credits:赠送闸(同设备重复注册归零;顺带删除 credits<0 死分支——_safe_register_credits 已保证非负) - _issue_registration_session:成功收尾(login_seq 递增 + 日志 + Cookie) 先写测试后动代码:新增 tests/test_register_flow.py 9 例,在未改动 实现上一次跑绿。验证码由 check_verify_code DELETE 原子消费,测试用 「失败路径后 email_codes 行仍在」做 restore 实证断言(不 mock 还原); 其中用户名重名路径用端到端方式验证——同一验证码不重发即可二次注册。 覆盖此前零测试的路径:IP 闸、设备赠送闸、IntegrityError/ValueError/ 读回 None 三种建号异常映射、非预期异常 500 泛化文案。 门禁:ruff 全过 · pyright 0/0 · pytest 339 passed(330→339)。 --- tests/test_register_flow.py | 188 ++++++++++++++++++++++++++++++++++++ web/routers/register.py | 131 +++++++++++++++---------- 2 files changed, 269 insertions(+), 50 deletions(-) create mode 100644 tests/test_register_flow.py diff --git a/tests/test_register_flow.py b/tests/test_register_flow.py new file mode 100644 index 0000000..cdf4bc7 --- /dev/null +++ b/tests/test_register_flow.py @@ -0,0 +1,188 @@ +# -*- coding: utf-8 -*- +"""register 端点失败路径契约(restore_verify_code / 防刷闸 / create_user 异常映射)。 + +test_register.py 覆盖成功主路径;本文件钉住五条「还原验证码」失败路径与 +create_user 的三种异常映射,作为 register 拆解的等价性验证器。 + +验证码由 check_verify_code 以 DELETE 原子消费:失败路径后 email_codes 行 +仍然存在 ⇔ restore_verify_code 确实被调用。以此做实证断言,不 mock 还原。 +""" +import sqlite3 +import time + +import pytest +from fastapi.testclient import TestClient + +import auth_system as auth +import core.db as db + +SECRET = 'register-flow-test-secret' + + +@pytest.fixture() +def env(tmp_path, monkeypatch): + db_path = tmp_path / 'site.db' + monkeypatch.setattr(auth.db, 'DATA_DIR', tmp_path) + monkeypatch.setattr(auth.db, 'DB_PATH', db_path) + monkeypatch.setattr(auth.db, 'BACKUP_DIR', tmp_path / 'backups') + monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') + monkeypatch.setattr(db, 'DATA_DIR', tmp_path) + monkeypatch.setattr(db, 'DB_PATH', db_path) + auth.init_db() + monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) + import web.auth_state as auth_state + auth_state._SEND_CODE_IP.clear() + from web.app import create_app + return TestClient(create_app()) + + +def _seed_code(email: str, code: str, purpose: str = 'register'): + conn = auth.get_conn() + conn.execute('DELETE FROM email_codes WHERE email = ? AND purpose = ?', (email, purpose)) + conn.execute('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) ' + 'VALUES (?, ?, ?, ?, ?)', + (email, code, int(time.time()) + 600, int(time.time()), purpose)) + conn.commit() + conn.close() + + +def _code_rows(email: str, purpose: str = 'register') -> list[str]: + conn = auth.get_conn() + rows = conn.execute('SELECT code FROM email_codes WHERE email = ? AND purpose = ?', + (email, purpose)).fetchall() + conn.close() + return [r['code'] for r in rows] + + +def _register_logs() -> list[tuple[str, str]]: + conn = auth.get_conn() + rows = conn.execute("SELECT type, detail FROM logs WHERE action = '注册'").fetchall() + conn.close() + return [(r['type'], r['detail']) for r in rows] + + +def _post(env, username: str, email: str, code: str, **extra): + return env.post('/auth/register', json={ + 'username': username, 'password': 'Passw0rd12', 'email': email, + 'code': code, 'agree': '1', **extra}) + + +def test_register_input_validation_order(env): + """四项入参校验按序:协议 → 用户名 → 密码 → 邮箱长度。""" + payload = {'username': 'okuser', 'password': 'Passw0rd12', 'email': 'v@example.com', + 'code': '000000', 'agree': '1'} + r = env.post('/auth/register', json={**payload, 'agree': '0'}) + assert r.json()['message'].startswith('请先阅读并同意') + + r = env.post('/auth/register', json={**payload, 'username': 'ab'}) + assert '用户名需 3-20 位' in r.json()['message'] + + r = env.post('/auth/register', json={**payload, 'password': 'short'}) + assert '密码长度需在 8-128 位之间' in r.json()['message'] + + r = env.post('/auth/register', json={**payload, 'email': 'e' * 300}) + assert r.json()['message'] == '邮箱格式不正确' + + +def test_dup_username_restores_code_end_to_end(env): + """用户名重名拒绝后,同一验证码无需重发即可再次使用(restore 实证)。""" + auth.create_user('taken', 'Passw0rd12') + _seed_code('x@example.com', '123456') + + r = _post(env, 'taken', 'x@example.com', '123456') + assert r.json()['message'] == '用户名已被注册' + + r2 = _post(env, 'freshname', 'x@example.com', '123456') + assert r2.json()['success'] is True + + +def test_dup_email_keeps_code_row(env): + """邮箱重名拒绝后验证码行仍在(被 check_verify_code 消费后又还原)。""" + auth.create_user('holder', 'Passw0rd12', email='y@example.com') + _seed_code('y@example.com', '111111') + + r = _post(env, 'maildup', 'y@example.com', '111111') + assert '该邮箱已被注册' in r.json()['message'] + assert _code_rows('y@example.com') == ['111111'] + + +def test_ip_exceeded_restores_code_and_logs(env, monkeypatch): + """IP 闸触发:还原验证码 + 写失败日志 + 专属文案。""" + monkeypatch.setattr(auth, 'register_ip_exceeded', lambda ip, **k: True) + _seed_code('ip@example.com', '222222') + + r = _post(env, 'ipuser', 'ip@example.com', '222222') + assert r.status_code == 400 + assert '注册过于频繁' in r.json()['message'] + assert _code_rows('ip@example.com') == ['222222'] + assert ('fail', '同 IP 24h 注册次数超限') in _register_logs() + + +def test_device_used_grants_zero_credits(env): + """同设备重复注册:账号照建、赠送归零;首注设备正常赠送。""" + _seed_code('d1@example.com', '333333') + r1 = _post(env, 'firstdev', 'd1@example.com', '333333', device_id='dev-9') + assert r1.json()['success'] is True + assert '已赠送' in r1.json()['message'] + + _seed_code('d2@example.com', '444444') + r2 = _post(env, 'seconddev', 'd2@example.com', '444444', device_id='dev-9') + assert r2.json()['success'] is True + assert r2.json()['message'] == '注册成功' + + conn = auth.get_conn() + row = conn.execute("SELECT duration_seconds FROM users WHERE username = 'seconddev'").fetchone() + conn.close() + assert row['duration_seconds'] == 0 + + +def test_integrityerror_maps_and_restores(env, monkeypatch): + """并发撞唯一索引(建号 IntegrityError):统一文案 + 还原验证码。""" + def _boom(*a, **k): + raise sqlite3.IntegrityError('UNIQUE constraint failed: users.username') + + monkeypatch.setattr(auth, 'create_user', _boom) + _seed_code('ie@example.com', '555555') + + r = _post(env, 'ieuser', 'ie@example.com', '555555') + assert r.status_code == 400 + assert r.json()['message'] == '用户名或邮箱已被注册,请换一个再试' + assert _code_rows('ie@example.com') == ['555555'] + + +def test_valueerror_maps_and_restores(env, monkeypatch): + """create_user 业务拒绝(ValueError):原文案透出 + 失败日志 + 还原验证码。""" + def _boom(*a, **k): + raise ValueError('用户名含敏感词') + + monkeypatch.setattr(auth, 'create_user', _boom) + _seed_code('ve@example.com', '666666') + + r = _post(env, 'veuser', 've@example.com', '666666') + assert r.status_code == 400 + assert r.json()['message'] == '用户名含敏感词' + assert _code_rows('ve@example.com') == ['666666'] + assert ('fail', '用户名含敏感词') in _register_logs() + + +def test_user_none_maps_and_restores(env, monkeypatch): + """建号事务提交却读不回:按注册失败处理 + 还原验证码 + 失败日志。""" + monkeypatch.setattr(auth, 'create_user', lambda *a, **k: None) + _seed_code('none@example.com', '777777') + + r = _post(env, 'noneuser', 'none@example.com', '777777') + assert r.status_code == 400 + assert r.json()['message'] == '注册失败,请稍后重试' + assert _code_rows('none@example.com') == ['777777'] + assert ('fail', '建号后读取用户失败') in _register_logs() + + +def test_unexpected_error_is_500(env, monkeypatch): + """非预期异常:泛化文案 + HTTP 500(不透出内部信息)。""" + def _boom(*a, **k): + raise RuntimeError('db busy') + + monkeypatch.setattr(auth, 'check_verify_code', _boom) + r = _post(env, 'boomuser', 'boom@example.com', '888888') + assert r.status_code == 500 + assert r.json()['message'] == '注册失败,请稍后重试' diff --git a/web/routers/register.py b/web/routers/register.py index 6c34cf5..2ccda60 100644 --- a/web/routers/register.py +++ b/web/routers/register.py @@ -138,11 +138,71 @@ async def forgot_password(request: Request): return error('重置失败,请稍后重试', 500) -@router.post('/auth/register') -async def register(request: Request): - """注册:验证码校验 → 用户名/邮箱查重 → 防刷双闸 → 建号赠送 → 建立会话。""" +def _validate_registration_input(username: str, password: str, email: str, + agree: str) -> str | None: + """注册入参四道闸,按序返回第一条错误文案(None = 全部通过)。""" from web.auth_state import re_username_ok + if agree.strip() != '1': + return '请先阅读并同意《用户协议》与《隐私政策》' + if not (3 <= len(username) <= 20 and re_username_ok(username)): + return '用户名需 3-20 位,仅支持中文、字母、数字、下划线,且不能纯下划线' + if len(password) < 8 or len(password) > 128: + return '密码长度需在 8-128 位之间' + if len(email) > 254: + return '邮箱格式不正确' + return None + + +async def _reject_registration(message: str, *, email: str, code: str, ttl: int, + username: str = '', ip: str = '', + log_detail: str | None = None) -> JSONResponse: + """注册流程后续步骤失败的统一拒绝:还原已消费的验证码(可选失败日志)。 + + 验证码在 check_verify_code 里已被 DELETE 原子消费,凡走到建号/查重/防刷 + 之后才失败的路径都必须还原,否则用户被迫重新收码。 + """ + await asyncio.to_thread(auth.restore_verify_code, email, code, ttl, 'register') + if log_detail: + await asyncio.to_thread(auth.add_log, 'fail', username, ip, '注册', log_detail) + return error(message) + + +async def _register_credits(device_used: bool) -> int: + """注册赠送次数:同设备重复注册账号照建,但不再赠送。""" + if device_used: + return 0 + return await _safe_register_credits() + + +async def _issue_registration_session(user, credits: int, username: str, + ip: str) -> JSONResponse: + """注册成功收尾:login_seq 递增 + 成功日志 + 签发会话 Cookie。""" + uid = int(user['id']) + now = int(time.time()) + await db.execute('UPDATE users SET last_active=?, last_login=?, ' + 'login_seq=COALESCE(login_seq,0)+1 WHERE id=?', (now, now, uid)) + fresh = await repo.get_user_by_id(uid) or {} + seq = int(fresh.get('login_seq') or 1) + credits = min(credits, max(0, int(fresh.get('duration_seconds') or 0))) + await asyncio.to_thread(auth.add_log, 'success', username, ip, '注册账号', + f'赠送 {credits} 次' if credits else '未赠送次数') + secure = os.environ.get('ENABLE_SECURE_COOKIE', '').strip().lower() in ('1', 'true', 'yes') + return _login_cookie_response({ + 'user': _user_public(fresh), + 'duration_text': auth.format_credits(int(fresh.get('duration_seconds') or 0)), + 'redirect': 'admin' if fresh.get('role') == 'admin' else 'user', + }, '注册成功' + (f',已赠送 {credits} 次下载次数' if credits else ''), uid, seq, secure) + + +@router.post('/auth/register') +async def register(request: Request): + """注册:入参校验 → 验证码 → 查重/防刷 → 建号 → 会话。 + + 五条「建号前失败」路径共用 _reject_registration(还原验证码), + 顺序与历史实现逐条对应:用户名重名 / 邮箱重名 / IP 超限(+日志)/ + 建号 IntegrityError / 建号 ValueError(+日志)/ 读回 None(+日志)。 + """ data = await _request_data(request) username = str(data.get('username') or '').strip() password = str(data.get('password') or '') @@ -151,38 +211,28 @@ async def register(request: Request): device_id = str(data.get('device_id') or '').strip()[:64] ip = ratelimit.client_ip(request) - if str(data.get('agree') or '').strip() != '1': - return error('请先阅读并同意《用户协议》与《隐私政策》') - if not (3 <= len(username) <= 20 and re_username_ok(username)): - return error('用户名需 3-20 位,仅支持中文、字母、数字、下划线,且不能纯下划线') - if len(password) < 8 or len(password) > 128: - return error('密码长度需在 8-128 位之间') - if len(email) > 254: - return error('邮箱格式不正确') + bad_input = _validate_registration_input( + username, password, email, str(data.get('agree') or '')) + if bad_input: + return error(bad_input) try: code_ttl = await asyncio.to_thread(auth.check_verify_code, email, code, 'register') if not code_ttl: return error('邮箱验证码错误或已过期') + rej = {'email': email, 'code': code, 'ttl': code_ttl} if await asyncio.to_thread(auth.get_user_by_name, username): - await asyncio.to_thread(auth.restore_verify_code, email, code, code_ttl, 'register') - return error('用户名已被注册') + return await _reject_registration('用户名已被注册', **rej) if email and await asyncio.to_thread(auth.get_user_by_email, email): - await asyncio.to_thread(auth.restore_verify_code, email, code, code_ttl, 'register') - return error('该邮箱已被注册,请更换邮箱或联系管理员') + return await _reject_registration('该邮箱已被注册,请更换邮箱或联系管理员', **rej) device_used_flag = bool(device_id) and await asyncio.to_thread(auth.device_used, device_id) if ip and await asyncio.to_thread(auth.register_ip_exceeded, ip): - await asyncio.to_thread(auth.restore_verify_code, email, code, code_ttl, 'register') - await asyncio.to_thread(auth.add_log, 'fail', username, ip, '注册', '同 IP 24h 注册次数超限') - return error('当前网络注册过于频繁,请 24 小时后再试或联系管理员') - - credits = await _safe_register_credits() - if credits < 0: - credits = 0 - if device_used_flag: - credits = 0 # 同设备重复注册:账号照建,不再赠送 + return await _reject_registration( + '当前网络注册过于频繁,请 24 小时后再试或联系管理员', + username=username, ip=ip, log_detail='同 IP 24h 注册次数超限', **rej) + credits = await _register_credits(device_used_flag) try: user = await asyncio.to_thread( auth.create_user, username, password, email=email, credits=credits, @@ -191,35 +241,16 @@ async def register(request: Request): ua=str(request.headers.get('user-agent') or ''), ip_limit=3, device_once=True)) except sqlite3.IntegrityError: - await asyncio.to_thread(auth.restore_verify_code, email, code, code_ttl, 'register') - return error('用户名或邮箱已被注册,请换一个再试') + return await _reject_registration('用户名或邮箱已被注册,请换一个再试', **rej) except ValueError as e: - await asyncio.to_thread(auth.restore_verify_code, email, code, code_ttl, 'register') - await asyncio.to_thread(auth.add_log, 'fail', username, ip, '注册', str(e)) - return error(str(e)) + return await _reject_registration(str(e), username=username, ip=ip, + log_detail=str(e), **rej) - # 建号事务已提交却读不回(并发删除/库损坏):按注册失败处理并把验证码还回去, - # 不让 user['id'] 直接 TypeError 崩成 500 + # 建号事务已提交却读不回(并发删除/库损坏):按注册失败处理 if user is None: - await asyncio.to_thread(auth.restore_verify_code, email, code, code_ttl, 'register') - await asyncio.to_thread(auth.add_log, 'fail', username, ip, '注册', '建号后读取用户失败') - return error('注册失败,请稍后重试') + return await _reject_registration('注册失败,请稍后重试', username=username, + ip=ip, log_detail='建号后读取用户失败', **rej) - # 建立会话(login_seq 递增 + 签发 Cookie) - uid = int(user['id']) - now = int(time.time()) - await db.execute('UPDATE users SET last_active=?, last_login=?, ' - 'login_seq=COALESCE(login_seq,0)+1 WHERE id=?', (now, now, uid)) - fresh = await repo.get_user_by_id(uid) or {} - seq = int(fresh.get('login_seq') or 1) - credits = min(credits, max(0, int(fresh.get('duration_seconds') or 0))) - await asyncio.to_thread(auth.add_log, 'success', username, ip, '注册账号', - f'赠送 {credits} 次' if credits else '未赠送次数') - secure = os.environ.get('ENABLE_SECURE_COOKIE', '').strip().lower() in ('1', 'true', 'yes') - return _login_cookie_response({ - 'user': _user_public(fresh), - 'duration_text': auth.format_credits(int(fresh.get('duration_seconds') or 0)), - 'redirect': 'admin' if fresh.get('role') == 'admin' else 'user', - }, '注册成功' + (f',已赠送 {credits} 次下载次数' if credits else ''), uid, seq, secure) + return await _issue_registration_session(user, credits, username, ip) except Exception: return error('注册失败,请稍后重试', 500)