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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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 才清空全部设备的引导记录(防误调接口;前端另有二次确认弹窗)"
|
||||
)
|
||||
@@ -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=取消"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user