From 3d07461ef5768dc52d312a1860b8b74f447e529f Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Wed, 10 Jun 2026 22:27:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(force-onboarding):=20=E8=BF=90=E8=90=A5?= =?UTF-8?q?=E4=B8=80=E9=94=AE=E5=BC=80=E5=90=AF=E6=96=B0=E6=89=8B=E5=BC=95?= =?UTF-8?q?=E5=AF=BC=20=E2=80=94=20User.force=5Fonboarding=20+=20admin=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=20+=20/me=20=E5=B8=A6=E5=87=BA=20+=20?= =?UTF-8?q?=E8=B5=B0=E5=AE=8C=E8=87=AA=E5=8A=A8=E6=B8=85=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User 新增 force_onboarding 列(仿 debug_trace_enabled)+ 迁移 user_force_onboarding。 - POST /admin/api/users/{id}/force-onboarding(operator 角色 + 审计 user.force_onboarding.set)。 - UserOut(/me 与登录响应)带出该字段;/api/v1/user/onboarding/complete 走完即自动清回 false,只触发一次。 - 测试:admin 设置+审计+角色守卫、走完引导自动清标记。 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/43 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- alembic/versions/user_force_onboarding.py | 39 +++++++++++++++++++++ app/admin/repositories/mutations.py | 15 ++++++++ app/admin/routers/users.py | 28 +++++++++++++++ app/admin/schemas/user.py | 8 +++++ app/api/v1/user.py | 2 ++ app/models/user.py | 7 ++++ app/repositories/user.py | 10 ++++++ app/schemas/auth.py | 3 ++ tests/test_admin_write.py | 42 +++++++++++++++++++++++ tests/test_onboarding.py | 30 ++++++++++++++++ 10 files changed, 184 insertions(+) create mode 100644 alembic/versions/user_force_onboarding.py diff --git a/alembic/versions/user_force_onboarding.py b/alembic/versions/user_force_onboarding.py new file mode 100644 index 0000000..3ed812f --- /dev/null +++ b/alembic/versions/user_force_onboarding.py @@ -0,0 +1,39 @@ +"""add user.force_onboarding + +运营后台「一键开启新手引导」:置 true → 用户下次启动 App 被强制重走引导教程(即便本地已完成), +走完引导(/api/v1/user/onboarding/complete)后端自动清回 false,不无限循环。 +见 app/models/user.py force_onboarding 列、admin POST /admin/api/users/{id}/force-onboarding。 + +Revision ID: user_force_onboarding +Revises: onboarding_completion +Create Date: 2026-06-10 21:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "user_force_onboarding" +down_revision: Union[str, Sequence[str], None] = "onboarding_completion" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # sa.false() 渲染成 PG 的 false / SQLite 的 0,两端兼容(同 debug_trace_enabled)。 + op.add_column( + "user", + sa.Column( + "force_onboarding", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + + +def downgrade() -> None: + op.drop_column("user", "force_onboarding") diff --git a/app/admin/repositories/mutations.py b/app/admin/repositories/mutations.py index 1f7d230..e1a2e91 100644 --- a/app/admin/repositories/mutations.py +++ b/app/admin/repositories/mutations.py @@ -42,6 +42,21 @@ def set_user_debug_trace( return user +def set_user_force_onboarding( + db: Session, user: User, *, enabled: bool, commit: bool = True +) -> User: + """运营「一键开启/取消新手引导」:置 user.force_onboarding。开启后该用户下次启动 App 被强制 + 重走引导教程,走完即由 /onboarding/complete 自动清回 false。同 set_user_debug_trace:支持 + commit=False 让 router 把业务写 + 审计写放进同一事务。""" + user.force_onboarding = enabled + if commit: + db.commit() + db.refresh(user) + else: + db.flush() + return user + + def update_feedback_status( db: Session, feedback: Feedback, *, status: str, commit: bool = True ) -> Feedback: diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index c7bb408..08d951c 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -15,6 +15,7 @@ from app.admin.schemas.user import ( GrantCashRequest, GrantCoinsRequest, SetDebugTraceRequest, + SetForceOnboardingRequest, SetUserStatusRequest, ) from app.models.admin import AdminUser @@ -101,6 +102,33 @@ def set_user_debug_trace( return OkResponse() +@router.post("/{user_id}/force-onboarding", response_model=OkResponse, summary="一键开启/取消新手引导") +def set_user_force_onboarding( + user_id: int, + body: SetForceOnboardingRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> OkResponse: + """运营「一键开启新手引导」:置 user.force_onboarding。开启后该用户下次启动 App(/me 带出此字段) + 会被强制重走引导教程——即便此前已看完;走完引导后端自动清回 false,不无限循环。 + 仅对支持 force_onboarding 的 App 版本生效(老版本忽略该字段)。""" + user = user_repo.get_user_by_id(db, user_id) + if user is None: + raise HTTPException(status_code=404, detail="用户不存在") + if user.status == "deleted": + raise HTTPException(status_code=400, detail="已注销账号不可操作") + before = user.force_onboarding + # 业务写 + 审计写同一事务(commit=False),最后一起 commit(同 set_user_status / debug-trace) + mutations.set_user_force_onboarding(db, user, enabled=body.enabled, commit=False) + write_audit( + db, admin, action="user.force_onboarding.set", target_type="user", target_id=user_id, + detail={"before": before, "after": body.enabled}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return OkResponse() + + @router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)") def grant_user_coins( user_id: int, diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index 0b49582..c615a9e 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -16,6 +16,8 @@ class AdminUserListItem(BaseModel): register_channel: str status: str debug_trace_enabled: bool = False + # 运营「一键开启新手引导」:true=该用户下次启动 App 被强制重走引导(走完自动清回 false) + force_onboarding: bool = False wechat_openid: str | None = None created_at: datetime last_login_at: datetime @@ -55,3 +57,9 @@ class SetUserStatusRequest(BaseModel): class SetDebugTraceRequest(BaseModel): enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限") + + +class SetForceOnboardingRequest(BaseModel): + enabled: bool = Field( + ..., description="true=一键开启(强制该用户下次启动 App 重走新手引导)/ false=取消" + ) diff --git a/app/api/v1/user.py b/app/api/v1/user.py index 5e96d75..c7125f5 100644 --- a/app/api/v1/user.py +++ b/app/api/v1/user.py @@ -59,6 +59,8 @@ def complete_onboarding( """走完新手引导时调一次。按 (当前账号, device_id) 落一条完成标记,跨卸载重装持久。 幂等:重复调用不报错。device_id 取客户端硬件级 ANDROID_ID,与登录请求一致。""" onboarding_repo.mark_completed(db, user_id=user.id, device_id=req.device_id) + # 若该用户被运营「一键开启新手引导」强制拉回引导,走完即清除强制标记(否则下次启动还会再触发)。 + user_repo.clear_force_onboarding(db, user) logger.info("onboarding complete user_id=%d device_len=%d", user.id, len(req.device_id)) return OkResponse() diff --git a/app/models/user.py b/app/models/user.py index 67d6600..b883215 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -58,6 +58,13 @@ class User(Base): Boolean, nullable=False, default=False, server_default=false() ) + # 运营后台「一键开启新手引导」:置 true 后,该用户下次启动 App(/me 与登录响应带出此字段)会被 + # 强制重走新手引导教程——即便本地早已标记完成。走完引导(/onboarding/complete)即自动清回 false, + # 不会无限循环。默认 false。 + force_onboarding: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default=false() + ) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) diff --git a/app/repositories/user.py b/app/repositories/user.py index 697d7e8..5f7dfe7 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -62,6 +62,16 @@ def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User: return user +def clear_force_onboarding(db: Session, user: User) -> None: + """用户走完(被运营强制开启的)新手引导后,清除强制标记,避免下次启动再被拉回引导。 + + 幂等:未置位时直接返回,不空 commit。由 /onboarding/complete 在标记完成后调用。 + """ + if user.force_onboarding: + user.force_onboarding = False + db.commit() + + def soft_delete_account(db: Session, user: User) -> None: """注销账号:软删除 + 匿名化。 diff --git a/app/schemas/auth.py b/app/schemas/auth.py index a5eb077..82609ca 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -27,6 +27,9 @@ class UserOut(BaseModel): last_login_at: datetime # 调试链接权限:前端据此在比价结果弹窗/记录页显示「复制调试链接」按钮。默认 false。 debug_trace_enabled: bool = False + # 运营后台「一键开启新手引导」:true → 客户端下次启动强制重走引导(即便本地已完成); + # 走完引导后端自动清回 false。默认 false 兼容老客户端。 + force_onboarding: bool = False # ===== Token 通用结构 ===== diff --git a/tests/test_admin_write.py b/tests/test_admin_write.py index 67bc719..9fd7546 100644 --- a/tests/test_admin_write.py +++ b/tests/test_admin_write.py @@ -160,6 +160,48 @@ def test_set_user_status_and_audit(admin_client: TestClient, operator_token: str ).status_code == 200 +# ===== 一键开启新手引导 ===== + +def test_set_force_onboarding_and_audit(admin_client: TestClient, operator_token: str) -> None: + uid = _seed_user("13900000009") + r = admin_client.post( + f"/admin/api/users/{uid}/force-onboarding", json={"enabled": True}, + headers=_auth(operator_token), + ) + assert r.status_code == 200, r.text + db = SessionLocal() + try: + assert db.get(User, uid).force_onboarding is True + logs = db.execute( + select(AdminAuditLog).where( + AdminAuditLog.action == "user.force_onboarding.set", + AdminAuditLog.target_id == str(uid), + ) + ).scalars().all() + assert logs[0].detail == {"before": False, "after": True} + finally: + db.close() + # 取消(回到 false) + assert admin_client.post( + f"/admin/api/users/{uid}/force-onboarding", json={"enabled": False}, + headers=_auth(operator_token), + ).status_code == 200 + db = SessionLocal() + try: + assert db.get(User, uid).force_onboarding is False + finally: + db.close() + + +def test_finance_cannot_force_onboarding(admin_client: TestClient, finance_token: str) -> None: + uid = _seed_user("13900000010") + r = admin_client.post( + f"/admin/api/users/{uid}/force-onboarding", json={"enabled": True}, + headers=_auth(finance_token), + ) + assert r.status_code == 403 + + # ===== 角色守卫 ===== def test_operator_cannot_grant_coins(admin_client: TestClient, operator_token: str) -> None: diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 3b6f533..46fd73a 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -65,3 +65,33 @@ 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 test_complete_clears_force_onboarding(client) -> None: + """运营「一键开启新手引导」(force_onboarding=True)后,用户走完引导即自动清除该标记, + /me 随之回到 false——否则下次启动会被无限拉回引导。""" + from app.db.session import SessionLocal + from app.models.user import User + + phone = "13800138004" + dev = "android-id-force" + data = _login(client, phone, dev) + access = data["access_token"] + uid = data["user"]["id"] + + # 模拟运营在后台一键开启(admin 接口本身另在 test_admin_write 覆盖) + db = SessionLocal() + try: + db.get(User, uid).force_onboarding = True + db.commit() + finally: + db.close() + + def _me() -> dict: + return client.get( + "/api/v1/auth/me", headers={"Authorization": f"Bearer {access}"} + ).json() + + assert _me()["force_onboarding"] is True + assert _mark_complete(client, access, dev).status_code == 200 + assert _me()["force_onboarding"] is False