From e8bd12cc1f87f63817d8f0387fc14f5c893252bb Mon Sep 17 00:00:00 2001 From: marco Date: Fri, 12 Jun 2026 00:51:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(onboarding):=20force=5Fonboarding=20?= =?UTF-8?q?=E6=94=B9=E8=AE=BE=E5=A4=87=E7=BB=B4=E5=BA=A6=20+=20admin=20?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E7=AE=A1=E7=90=86=20+=20status=20=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 废弃 force_onboarding(运营按用户强制引导,客户端不认——被 onboarding_completed 压过), 改用 onboarding_completion(设备+账号 维度),admin 直接增删该表记录控制重走。 - 删 force_onboarding: User 列 + auth/admin schema + admin 接口 + mutations + repo clear + /onboarding/complete 调用 + 3 测试;新增 alembic 迁移删列 - admin 设备管理: 列设备(按 device 聚合) / 重置单设备 / 全部重置(清 onboarding_completion) - 新增 GET /api/v1/user/onboarding/status: 客户端已登录启动查"本设备是否要重走引导" Co-Authored-By: Claude Opus 4.8 --- .../versions/drop_user_force_onboarding.py | 40 +++++++++ app/admin/main.py | 2 + app/admin/repositories/mutations.py | 41 +++++---- app/admin/repositories/queries.py | 21 +++++ app/admin/routers/onboarding.py | 86 +++++++++++++++++++ app/admin/routers/users.py | 28 ------ app/admin/schemas/onboarding.py | 24 ++++++ app/admin/schemas/user.py | 8 -- app/api/v1/user.py | 28 +++++- app/models/user.py | 7 -- app/repositories/user.py | 10 --- app/schemas/auth.py | 3 - app/schemas/user.py | 4 + tests/test_admin_write.py | 42 --------- tests/test_onboarding.py | 30 ------- 15 files changed, 227 insertions(+), 147 deletions(-) create mode 100644 alembic/versions/drop_user_force_onboarding.py create mode 100644 app/admin/routers/onboarding.py create mode 100644 app/admin/schemas/onboarding.py diff --git a/alembic/versions/drop_user_force_onboarding.py b/alembic/versions/drop_user_force_onboarding.py new file mode 100644 index 0000000..2afa605 --- /dev/null +++ b/alembic/versions/drop_user_force_onboarding.py @@ -0,0 +1,40 @@ +"""drop user.force_onboarding column + +force_onboarding(运营「按用户」强制新手引导)机制废弃:客户端实际按 onboarding_completion +(设备+账号 维度)判断是否走引导,该 force_onboarding 列从未被客户端正确响应(被 +onboarding_completed 压过)。改用 admin「设备维度」重置(删 onboarding_completion 记录)替代, +见 app/admin/routers/onboarding.py。本迁移删列。 + +Revision ID: drop_force_onboarding +Revises: 9b894f5fff05 +Create Date: 2026-06-12 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "drop_force_onboarding" +down_revision: Union[str, Sequence[str], None] = "9b894f5fff05" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_column("user", "force_onboarding") + + +def downgrade() -> None: + # 回滚:重建列(同 user_force_onboarding 迁移的 upgrade)。sa.false() 两端兼容。 + op.add_column( + "user", + sa.Column( + "force_onboarding", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) diff --git a/app/admin/main.py b/app/admin/main.py index b5948a9..a422885 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -21,6 +21,7 @@ from app.admin.routers.config import router as config_router from app.admin.routers.dashboard import router as dashboard_router from app.admin.routers.ops_stat_config import router as ops_stat_config_router from app.admin.routers.feedback import router as feedback_router +from app.admin.routers.onboarding import router as onboarding_router from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router from app.admin.routers.price_report import router as price_report_router from app.admin.routers.users import router as users_router @@ -80,6 +81,7 @@ admin_app.include_router(dashboard_router) admin_app.include_router(ops_stat_config_router) admin_app.include_router(ops_marquee_seed_router) admin_app.include_router(users_router) +admin_app.include_router(onboarding_router) admin_app.include_router(wallet_router) admin_app.include_router(withdraw_router) admin_app.include_router(price_report_router) diff --git a/app/admin/repositories/mutations.py b/app/admin/repositories/mutations.py index e1a2e91..5f3ed6e 100644 --- a/app/admin/repositories/mutations.py +++ b/app/admin/repositories/mutations.py @@ -10,10 +10,12 @@ from __future__ import annotations from datetime import datetime +from sqlalchemy import delete from sqlalchemy.orm import Session from app.core.rewards import CN_TZ from app.models.feedback import Feedback +from app.models.onboarding import OnboardingCompletion from app.models.price_report import PriceReport from app.models.user import User @@ -42,21 +44,6 @@ 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: @@ -94,3 +81,27 @@ def review_price_report( else: db.flush() return report + + +def delete_device_onboarding(db: Session, device_id: str, *, commit: bool = True) -> int: + """删某设备(device_id)的全部 onboarding 完成记录 → 该设备上所有账号下次登录重走引导。 + 返回删除行数。支持 commit=False 让 router 把业务写 + 审计写放进同一事务。""" + n = db.execute( + delete(OnboardingCompletion).where(OnboardingCompletion.device_id == device_id) + ).rowcount + if commit: + db.commit() + else: + db.flush() + return n + + +def delete_all_onboarding(db: Session, *, commit: bool = True) -> int: + """清空 onboarding_completion 表 → 所有用户在所有设备下次登录都重走引导。返回删除行数。 + 支持 commit=False(同 delete_device_onboarding)。""" + n = db.execute(delete(OnboardingCompletion)).rowcount + if commit: + db.commit() + else: + db.flush() + return n diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index ce88603..ab306d1 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -14,6 +14,7 @@ from sqlalchemy.orm import Session from app.models.admin import AdminAuditLog from app.models.comparison import ComparisonRecord from app.models.feedback import Feedback +from app.models.onboarding import OnboardingCompletion from app.models.price_report import PriceReport from app.models.user import User from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder @@ -56,6 +57,26 @@ def list_users( return cursor_paginate(db, stmt, User.id, limit=limit, cursor=cursor) +def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]: + """按设备(device_id, ANDROID_ID)聚合 onboarding_completion:每台设备走过引导的账号数 + + 最近完成时间,按最近完成倒序。设备维度新手引导管理用。没走过引导的设备不在表里(本就会 + 引导、无需管理)。当前全量返回(上限 limit;调试期设备少,量大再加分页/搜索)。""" + rows = db.execute( + select( + OnboardingCompletion.device_id, + func.count(OnboardingCompletion.id), + func.max(OnboardingCompletion.completed_at), + ) + .group_by(OnboardingCompletion.device_id) + .order_by(func.max(OnboardingCompletion.completed_at).desc()) + .limit(limit) + ).all() + return [ + {"device_id": did, "account_count": int(cnt), "last_completed_at": last} + for did, cnt, last in rows + ] + + def list_all_coin_transactions( db: Session, *, diff --git a/app/admin/routers/onboarding.py b/app/admin/routers/onboarding.py new file mode 100644 index 0000000..89fbefe --- /dev/null +++ b/app/admin/routers/onboarding.py @@ -0,0 +1,86 @@ +"""admin 设备维度新手引导管理:列设备 / 重置单设备 / 全部重置。 + +背景:原 force_onboarding(运营「按用户」强制引导)客户端不认(被 onboarding_completed 压过), +已废弃。改为直接操作 onboarding_completion(设备+账号 维度):删记录 → 该(设备,账号)的 +onboarding_completed 变 false → 客户端任何版本下次登录都重走引导。可靠、不依赖客户端实现。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role +from app.admin.repositories import mutations, queries +from app.admin.schemas.common import CursorPage, OkResponse +from app.admin.schemas.onboarding import DeviceOnboardingItem, ResetAllRequest +from app.models.admin import AdminUser + +router = APIRouter( + prefix="/admin/api/onboarding", + tags=["admin-onboarding"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get( + "/devices", + response_model=CursorPage[DeviceOnboardingItem], + summary="设备列表(按设备聚合走过引导的账号)", +) +def list_devices(db: AdminDb) -> CursorPage[DeviceOnboardingItem]: + """列出走过新手引导的设备(ANDROID_ID),按最近完成时间倒序。没走过引导的设备不在表里、 + 本就会引导,无需管理,故不列。当前全量返回(上限 500;设备量大再加分页/搜索), + next_cursor 恒 None。""" + rows = queries.list_onboarding_devices(db) + return CursorPage( + items=[DeviceOnboardingItem(**r) for r in rows], + next_cursor=None, + ) + + +@router.post( + "/devices/{device_id}/reset", + response_model=OkResponse, + summary="重置单个设备(该设备所有账号下次重走引导)", +) +def reset_device( + device_id: str, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> OkResponse: + """删该设备的全部 onboarding 完成记录 → 该设备上所有账号下次登录重走引导。""" + # 业务写 + 审计写同一事务(commit=False),最后一起 commit(同 users 各写接口) + deleted = mutations.delete_device_onboarding(db, device_id, commit=False) + write_audit( + db, admin, action="device_onboarding.reset", target_type="device", target_id=device_id, + detail={"deleted_rows": deleted}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return OkResponse() + + +@router.post( + "/reset-all", + response_model=OkResponse, + summary="全部重设(清空所有设备引导记录)", +) +def reset_all( + body: ResetAllRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> OkResponse: + """清空 onboarding_completion 表 → 所有用户在所有设备下次登录都重走引导。 + 需 confirm=true 防误触(前端另有二次确认弹窗)。""" + if not body.confirm: + raise HTTPException(status_code=400, detail="需 confirm=true 确认") + deleted = mutations.delete_all_onboarding(db, commit=False) + write_audit( + db, admin, action="device_onboarding.reset_all", target_type="onboarding", target_id=None, + detail={"deleted_rows": deleted}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return OkResponse() diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index 08d951c..c7bb408 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -15,7 +15,6 @@ from app.admin.schemas.user import ( GrantCashRequest, GrantCoinsRequest, SetDebugTraceRequest, - SetForceOnboardingRequest, SetUserStatusRequest, ) from app.models.admin import AdminUser @@ -102,33 +101,6 @@ 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/onboarding.py b/app/admin/schemas/onboarding.py new file mode 100644 index 0000000..ea68e7b --- /dev/null +++ b/app/admin/schemas/onboarding.py @@ -0,0 +1,24 @@ +"""admin 设备维度新手引导管理 schemas。 + +force_onboarding(按用户)废弃后改用「按设备」:直接操作 onboarding_completion(设备+账号 维度), +客户端任何版本都按它判断是否走引导,故可靠。 +""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class DeviceOnboardingItem(BaseModel): + """一台设备(ANDROID_ID)的引导聚合:在该设备走过引导的账号数 + 最近完成时间。""" + + device_id: str + account_count: int + last_completed_at: datetime + + +class ResetAllRequest(BaseModel): + confirm: bool = Field( + False, description="必须 true 才清空全部设备的引导记录(防误调接口;前端另有二次确认弹窗)" + ) diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index c615a9e..0b49582 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -16,8 +16,6 @@ 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 @@ -57,9 +55,3 @@ 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 c7125f5..6be689f 100644 --- a/app/api/v1/user.py +++ b/app/api/v1/user.py @@ -12,14 +12,19 @@ from __future__ import annotations import logging -from fastapi import APIRouter, File, HTTPException, UploadFile +from fastapi import APIRouter, File, HTTPException, Query, UploadFile from app.api.deps import CurrentUser, DbSession from app.core import media from app.repositories import onboarding as onboarding_repo from app.repositories import user as user_repo from app.schemas.auth import UserOut -from app.schemas.user import OkResponse, OnboardingCompleteRequest, ProfileUpdateRequest +from app.schemas.user import ( + OkResponse, + OnboardingCompleteRequest, + OnboardingStatusResponse, + ProfileUpdateRequest, +) logger = logging.getLogger("shagua.user") @@ -59,12 +64,27 @@ 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() +@router.get( + "/onboarding/status", + response_model=OnboardingStatusResponse, + summary="查新手引导是否已完成(按 设备+账号)", +) +def onboarding_status( + user: CurrentUser, + db: DbSession, + device_id: str = Query("", max_length=64, description="硬件级 ANDROID_ID;空=按未完成处理"), +) -> OnboardingStatusResponse: + """已登录用户启动时查:该 (账号, 设备) 走过引导没。false → 客户端重走(运营在 admin 删了该设备 + 记录即触发)。替代原 force_onboarding(按用户)——改设备维度后这是"运营触发重走"的查询入口。 + device_id 为空一律按未完成(同登录:不误跳过)。""" + completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=device_id) + return OnboardingStatusResponse(completed=completed) + + @router.delete("", response_model=OkResponse, summary="注销账号(软删除)") def delete_account(user: CurrentUser, db: DbSession) -> OkResponse: media.delete_avatar(user.avatar_url) diff --git a/app/models/user.py b/app/models/user.py index b883215..67d6600 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -58,13 +58,6 @@ 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 5f7dfe7..697d7e8 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -62,16 +62,6 @@ 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 82609ca..a5eb077 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -27,9 +27,6 @@ class UserOut(BaseModel): last_login_at: datetime # 调试链接权限:前端据此在比价结果弹窗/记录页显示「复制调试链接」按钮。默认 false。 debug_trace_enabled: bool = False - # 运营后台「一键开启新手引导」:true → 客户端下次启动强制重走引导(即便本地已完成); - # 走完引导后端自动清回 false。默认 false 兼容老客户端。 - force_onboarding: bool = False # ===== Token 通用结构 ===== diff --git a/app/schemas/user.py b/app/schemas/user.py index cbe6469..c5420b6 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -23,5 +23,9 @@ class OnboardingCompleteRequest(BaseModel): ) +class OnboardingStatusResponse(BaseModel): + completed: bool = Field(..., description="该 设备+账号 是否已走过新手引导(false → 客户端应重走)") + + class OkResponse(BaseModel): ok: bool = True diff --git a/tests/test_admin_write.py b/tests/test_admin_write.py index 9fd7546..67bc719 100644 --- a/tests/test_admin_write.py +++ b/tests/test_admin_write.py @@ -160,48 +160,6 @@ 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 46fd73a..3b6f533 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -65,33 +65,3 @@ 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