feat(onboarding): force_onboarding 改设备维度 + admin 设备管理 + status 查询

废弃 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:51:47 +08:00
parent 57ddcd356b
commit e8bd12cc1f
15 changed files with 227 additions and 147 deletions
@@ -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(),
),
)
+2
View File
@@ -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)
+26 -15
View File
@@ -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
+21
View File
@@ -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,
*,
+86
View File
@@ -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()
-28
View File
@@ -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,
+24
View File
@@ -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 才清空全部设备的引导记录(防误调接口;前端另有二次确认弹窗)"
)
-8
View File
@@ -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=取消"
)
+24 -4
View File
@@ -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)
-7
View File
@@ -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
)
-10
View File
@@ -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:
"""注销账号:软删除 + 匿名化。
-3
View File
@@ -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 通用结构 =====
+4
View File
@@ -23,5 +23,9 @@ class OnboardingCompleteRequest(BaseModel):
)
class OnboardingStatusResponse(BaseModel):
completed: bool = Field(..., description="该 设备+账号 是否已走过新手引导(false → 客户端应重走)")
class OkResponse(BaseModel):
ok: bool = True
-42
View File
@@ -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:
-30
View File
@@ -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