feat(force-onboarding): 运营一键开启新手引导 — User.force_onboarding + admin 接口 + /me 带出 + 走完自动清 (#43)

- 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) <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #43
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
This commit was merged in pull request #43.
This commit is contained in:
2026-06-10 22:27:06 +08:00
committed by marco
parent db399a40fa
commit 3d07461ef5
10 changed files with 184 additions and 0 deletions
+39
View File
@@ -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")
+15
View File
@@ -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:
+28
View File
@@ -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,
+8
View File
@@ -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=取消"
)
+2
View File
@@ -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()
+7
View File
@@ -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
)
+10
View File
@@ -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:
"""注销账号:软删除 + 匿名化。
+3
View File
@@ -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 通用结构 =====
+42
View File
@@ -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:
+30
View File
@@ -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