From ea60dc391bd06fc2db19c7fca109a7a1d7c0c35c Mon Sep 17 00:00:00 2001 From: marco Date: Fri, 3 Jul 2026 18:30:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(onboarding):=20=E6=96=B0=E5=A2=9E=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=E6=96=B0=E6=89=8B=E5=BC=95=E5=AF=BC=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=20/api/v1/user/onboarding/reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - user.py:新增 reset_onboarding 端点(需登录),删当前账号+该设备的引导完成标记, 与 /onboarding/complete 互逆;幂等(无记录也返回 ok),只删自己不影响他人 - repositories/onboarding.py:新增 delete_completion(user_id, device_id), device_id 为空忽略,返回删除行数 - tests:补 test_onboarding_reset_makes_relogin_reonboard,覆盖 完成 → 重置 → 再登录变未完成 + 重复重置幂等 配合客户端「开发设置 → 重置新手引导」:先删后端行再退登录重开,复现全新用户。 Co-Authored-By: Claude Opus 4.8 --- app/api/v1/user.py | 12 ++++++++++++ app/repositories/onboarding.py | 17 ++++++++++++++++- tests/test_onboarding.py | 25 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/app/api/v1/user.py b/app/api/v1/user.py index dc23388..f310b2b 100644 --- a/app/api/v1/user.py +++ b/app/api/v1/user.py @@ -69,6 +69,18 @@ def complete_onboarding( return OkResponse() +@router.post("/onboarding/reset", response_model=OkResponse, summary="重置新手引导(删该 设备+账号 完成标记,下次登录重走)") +def reset_onboarding( + req: OnboardingCompleteRequest, user: CurrentUser, db: DbSession +) -> OkResponse: + """删该 (当前账号, device_id) 的引导完成标记 → 下次登录 onboarding_completed=false,客户端重走。 + 与 /onboarding/complete 互逆。device_id 取客户端硬件级 ANDROID_ID(与登录/complete 一致)。 + 幂等:无记录也返回 ok。仅删自己(当前 JWT 用户)这台设备的记录,不影响别的账号/设备。""" + deleted = onboarding_repo.delete_completion(db, user_id=user.id, device_id=req.device_id) + logger.info("onboarding reset user_id=%d device_len=%d deleted=%d", user.id, len(req.device_id), deleted) + return OkResponse() + + @router.get( "/onboarding/status", response_model=OnboardingStatusResponse, diff --git a/app/repositories/onboarding.py b/app/repositories/onboarding.py index 03e51d3..0750dd1 100644 --- a/app/repositories/onboarding.py +++ b/app/repositories/onboarding.py @@ -5,7 +5,7 @@ device_id 为空(老客户端 / 取不到 ANDROID_ID)一律按"未完成"处理, """ from __future__ import annotations -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -33,3 +33,18 @@ def mark_completed(db: Session, *, user_id: int, device_id: str) -> None: except IntegrityError: # 并发 / 重复提交撞唯一约束:已有行即视为成功。 db.rollback() + + +def delete_completion(db: Session, *, user_id: int, device_id: str) -> int: + """删该 (账号, 设备) 的引导完成标记 → 下次登录 is_completed=False → 客户端重走。 + 与 [mark_completed] 互逆。device_id 为空忽略(返 0);无记录也幂等(返 0)。返回删除行数。""" + if not device_id: + return 0 + result = db.execute( + delete(OnboardingCompletion).where( + OnboardingCompletion.user_id == user_id, + OnboardingCompletion.device_id == device_id, + ) + ) + db.commit() + return result.rowcount diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 3b6f533..a56c9f0 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -65,3 +65,28 @@ def test_onboarding_missing_device_id_treated_as_incomplete(client) -> None: phone = "13800138003" data = _login(client, phone, device_id=None) assert data["onboarding_completed"] is False + + +def _reset(client, access: str, device_id: str): + return client.post( + "/api/v1/user/onboarding/reset", + json={"device_id": device_id}, + headers={"Authorization": f"Bearer {access}"}, + ) + + +def test_onboarding_reset_makes_relogin_reonboard(client) -> None: + """重置(删完成标记)后,同 (账号, 设备) 再登录 → onboarding_completed=False,重走引导。 + 对应客户端「开发设置 → 重置新手引导」:先删后端行,再退登录重开。""" + phone = "13800138004" + dev = "android-id-reset" + + # ① 走完引导 → 标记完成 → 再登录已完成 + access = _login(client, phone, dev)["access_token"] + assert _mark_complete(client, access, dev).status_code == 200 + assert _login(client, phone, dev)["onboarding_completed"] is True + + # ② 重置(幂等:重复重置也 200)→ 再登录变未完成 → 重走引导 + assert _reset(client, access, dev).status_code == 200 + assert _reset(client, access, dev).json()["ok"] is True + assert _login(client, phone, dev)["onboarding_completed"] is False