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:
@@ -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(),
|
||||
),
|
||||
)
|
||||
@@ -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=取消"
|
||||
)
|
||||
|
||||
+24
-4
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
"""注销账号:软删除 + 匿名化。
|
||||
|
||||
|
||||
@@ -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 通用结构 =====
|
||||
|
||||
@@ -23,5 +23,9 @@ class OnboardingCompleteRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class OnboardingStatusResponse(BaseModel):
|
||||
completed: bool = Field(..., description="该 设备+账号 是否已走过新手引导(false → 客户端应重走)")
|
||||
|
||||
|
||||
class OkResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user