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