From dacb37e3e1ed492db88cc5e55cc8faedc2daf729 Mon Sep 17 00:00:00 2001 From: zzhyyyyy <2685922758@qq.com> Date: Wed, 10 Jun 2026 18:32:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(onboarding):=20=E6=96=B0=E6=89=8B=E5=BC=95?= =?UTF-8?q?=E5=AF=BC=E5=AE=8C=E6=88=90=E6=8C=89=20=E8=AE=BE=E5=A4=87+?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=20=E5=8E=BB=E9=87=8D(=E8=A1=A8/=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B/=E4=BB=93=E5=82=A8/=E7=AB=AF=E7=82=B9/=E8=BF=81?= =?UTF-8?q?=E7=A7=BB/=E6=B5=8B=E8=AF=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onboarding_completion 表(user_id+device_id 唯一约束)+ model + repository - POST /api/v1/user/onboarding/complete 标记完成;登录响应 TokenWithUser.onboarding_completed (按登录请求带的 device_id 判定是否已走过引导,跨卸载重装稳定) - alembic 迁移 onboarding_completion(rebase 到最新 main 后已链在 coupon_daily_completion 之后,单 head) - docs/database 文档 + tests/test_onboarding.py + run.bat(本地启动脚本) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../versions/onboarding_completion_table.py | 39 +++++++++++ app/api/v1/auth.py | 16 +++-- app/api/v1/user.py | 14 +++- app/models/__init__.py | 1 + app/models/onboarding.py | 40 +++++++++++ app/repositories/onboarding.py | 35 ++++++++++ app/schemas/auth.py | 13 ++++ app/schemas/user.py | 7 ++ docs/database/OVERVIEW.md | 8 ++- docs/database/README.md | 5 +- docs/database/onboarding_completion.md | 44 ++++++++++++ run.bat | 35 ++++++++++ tests/test_onboarding.py | 67 +++++++++++++++++++ 13 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 alembic/versions/onboarding_completion_table.py create mode 100644 app/models/onboarding.py create mode 100644 app/repositories/onboarding.py create mode 100644 docs/database/onboarding_completion.md create mode 100644 run.bat create mode 100644 tests/test_onboarding.py diff --git a/alembic/versions/onboarding_completion_table.py b/alembic/versions/onboarding_completion_table.py new file mode 100644 index 0000000..bd6f725 --- /dev/null +++ b/alembic/versions/onboarding_completion_table.py @@ -0,0 +1,39 @@ +"""onboarding_completion table (新手引导完成标记,按 user+device 去重) + +Revision ID: onboarding_completion +Revises: coupon_daily_completion +Create Date: 2026-06-10 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'onboarding_completion' +down_revision: Union[str, Sequence[str], None] = 'coupon_daily_completion' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # (账号, 设备) 一条完成标记;device_id = 客户端硬件级 ANDROID_ID(跨卸载重装稳定)。 + op.create_table( + 'onboarding_completion', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('device_id', sa.String(length=64), nullable=False), + sa.Column('completed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'device_id', name='uq_onboarding_user_device'), + ) + with op.batch_alter_table('onboarding_completion', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_onboarding_completion_user_id'), ['user_id'], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table('onboarding_completion', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_onboarding_completion_user_id')) + op.drop_table('onboarding_completion') diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index fd31641..2ba8521 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -19,6 +19,7 @@ from app.core.ratelimit import rate_limit from app.core.security import TokenError, decode_token, issue_token_pair from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone from app.integrations.sms import SmsError, send_code, verify_code +from app.repositories import onboarding as onboarding_repo from app.repositories import user as user_repo from app.schemas.auth import ( JverifyLoginRequest, @@ -37,12 +38,13 @@ logger = logging.getLogger("shagua.auth") router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) -def _login_response(db, user) -> TokenWithUser: - """登录类接口共用的响应组装。""" +def _login_response(user, *, onboarding_completed: bool) -> TokenWithUser: + """登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入。""" tokens = issue_token_pair(user.id) return TokenWithUser( **tokens, user=UserOut.model_validate(user), + onboarding_completed=onboarding_completed, ) @@ -66,8 +68,9 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser: if user.status != "active": raise HTTPException(status_code=403, detail="account disabled") - logger.info("jverify_login ok user_id=%d phone=%s", user.id, mask_phone(phone)) - return _login_response(db, user) + completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id) + logger.info("jverify_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(phone), completed) + return _login_response(user, onboarding_completed=completed) # ===================== 短信登录 ===================== @@ -103,8 +106,9 @@ def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser: if user.status != "active": raise HTTPException(status_code=403, detail="account disabled") - logger.info("sms_login ok user_id=%d phone=%s", user.id, mask_phone(req.phone)) - return _login_response(db, user) + completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id) + logger.info("sms_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(req.phone), completed) + return _login_response(user, onboarding_completed=completed) # ===================== Refresh ===================== diff --git a/app/api/v1/user.py b/app/api/v1/user.py index d9cb31b..5e96d75 100644 --- a/app/api/v1/user.py +++ b/app/api/v1/user.py @@ -16,9 +16,10 @@ from fastapi import APIRouter, File, HTTPException, 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, ProfileUpdateRequest +from app.schemas.user import OkResponse, OnboardingCompleteRequest, ProfileUpdateRequest logger = logging.getLogger("shagua.user") @@ -51,6 +52,17 @@ async def upload_avatar( return UserOut.model_validate(updated) +@router.post("/onboarding/complete", response_model=OkResponse, summary="标记新手引导完成(按 设备+账号)") +def complete_onboarding( + req: OnboardingCompleteRequest, user: CurrentUser, db: DbSession +) -> OkResponse: + """走完新手引导时调一次。按 (当前账号, device_id) 落一条完成标记,跨卸载重装持久。 + 幂等:重复调用不报错。device_id 取客户端硬件级 ANDROID_ID,与登录请求一致。""" + onboarding_repo.mark_completed(db, user_id=user.id, device_id=req.device_id) + logger.info("onboarding complete user_id=%d device_len=%d", user.id, len(req.device_id)) + return OkResponse() + + @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/__init__.py b/app/models/__init__.py index a9d0488..4ed0d1b 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -16,6 +16,7 @@ from app.models.feedback import Feedback # noqa: F401 from app.models.invite import InviteRelation # noqa: F401 from app.models.invite_fingerprint import InviteFingerprint # noqa: F401 from app.models.meituan_coupon import MeituanCoupon # noqa: F401 +from app.models.onboarding import OnboardingCompletion # noqa: F401 from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401 from app.models.ops_stat_config import OpsStatConfig # noqa: F401 from app.models.price_observation import PriceObservation # noqa: F401 diff --git a/app/models/onboarding.py b/app/models/onboarding.py new file mode 100644 index 0000000..9862d93 --- /dev/null +++ b/app/models/onboarding.py @@ -0,0 +1,40 @@ +"""新手引导完成标记(按 设备 + 账号 去重)。 + +产品规则:同一台设备 + 同一个账号,新手引导只跑一次;卸载重装(同设备同账号) +不再触发。本地标记(SharedPreferences)卸载即丢,所以"完成"必须落后端,按 +(user_id, device_id) 唯一一条。 + +⚠️ device_id 这里用客户端的**硬件级稳定标识**(Android `Settings.Secure.ANDROID_ID`), +同签名 app 卸载重装不变、仅恢复出厂才重置——区别于领券/比价用的 per-install device_id +(见 coupon_state,存 SP、重装会变)。换新设备 → 无此行 → 重新走引导。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class OnboardingCompletion(Base): + """一台设备 + 一个账号 一条:走完新手引导即写入,登录时据此跳过引导。""" + + __tablename__ = "onboarding_completion" + __table_args__ = ( + # 同账号、同设备只一条:重复 mark / 并发提交靠它幂等。 + UniqueConstraint("user_id", "device_id", name="uq_onboarding_user_device"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 登录态用户。引导是登录后才走的,故必填(非空)、进唯一键。 + user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + # 硬件级稳定设备标识(ANDROID_ID),卸载重装不变。 + device_id: Mapped[str] = mapped_column(String(64), nullable=False) + completed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/repositories/onboarding.py b/app/repositories/onboarding.py new file mode 100644 index 0000000..03e51d3 --- /dev/null +++ b/app/repositories/onboarding.py @@ -0,0 +1,35 @@ +"""新手引导完成标记的读写(按 user_id + device_id 去重)。 + +登录时 [is_completed] 判断该 (账号, 设备) 是否走过引导;走完引导时 [mark_completed] 写一条。 +device_id 为空(老客户端 / 取不到 ANDROID_ID)一律按"未完成"处理,即照常走引导,不误跳过。 +""" +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.models.onboarding import OnboardingCompletion + + +def is_completed(db: Session, *, user_id: int, device_id: str) -> bool: + """该 (账号, 设备) 是否已完成新手引导。device_id 为空 → 一律 False。""" + if not device_id: + return False + stmt = select(OnboardingCompletion.id).where( + OnboardingCompletion.user_id == user_id, + OnboardingCompletion.device_id == device_id, + ) + return db.execute(stmt).first() is not None + + +def mark_completed(db: Session, *, user_id: int, device_id: str) -> None: + """走完引导时写入。device_id 为空忽略;同 (账号, 设备) 已存在则幂等(靠唯一约束兜并发)。""" + if not device_id: + return + db.add(OnboardingCompletion(user_id=user_id, device_id=device_id)) + try: + db.commit() + except IntegrityError: + # 并发 / 重复提交撞唯一约束:已有行即视为成功。 + db.rollback() diff --git a/app/schemas/auth.py b/app/schemas/auth.py index b878a91..a5eb077 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -41,6 +41,11 @@ class TokenPair(BaseModel): class TokenWithUser(TokenPair): user: UserOut + onboarding_completed: bool = Field( + False, + description="该 设备+账号 是否已走完新手引导(据登录请求里的 device_id 计算)。" + "true → 客户端登录后直接进首页,跳过引导。", + ) # ===== 极光一键登录 ===== @@ -48,6 +53,10 @@ class TokenWithUser(TokenPair): class JverifyLoginRequest(BaseModel): login_token: str = Field(..., description="客户端 loginAuth 拿到的 loginToken", min_length=1) operator: str = Field("", description="CM/CU/CT,用于日志,可选") + device_id: str = Field( + "", max_length=64, + description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理", + ) # ===== 短信验证码 ===== @@ -65,6 +74,10 @@ class SmsSendResponse(BaseModel): class SmsLoginRequest(BaseModel): phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$") code: str = Field(..., min_length=4, max_length=8) + device_id: str = Field( + "", max_length=64, + description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理", + ) # ===== Refresh ===== diff --git a/app/schemas/user.py b/app/schemas/user.py index ebaba24..cbe6469 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -16,5 +16,12 @@ class ProfileUpdateRequest(BaseModel): return v +class OnboardingCompleteRequest(BaseModel): + device_id: str = Field( + ..., min_length=1, max_length=64, + description="硬件级设备标识(Android ANDROID_ID),与登录时一致,用于按 设备+账号 标记引导完成", + ) + + class OkResponse(BaseModel): ok: bool = True diff --git a/docs/database/OVERVIEW.md b/docs/database/OVERVIEW.md index e5d2b6f..5e298d4 100644 --- a/docs/database/OVERVIEW.md +++ b/docs/database/OVERVIEW.md @@ -2,7 +2,7 @@ > 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。 > 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。 -> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **22 张业务表** + `alembic_version`(框架的迁移版本指针)。 +> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **23 张业务表** + `alembic_version`(框架的迁移版本指针)。 --- @@ -35,6 +35,7 @@ | App 位置 / 动作 | 表 | 说明 | |---|---|---| | 登录(极光/短信)/ 改资料 / 注销 | [`user`](./user.md) | 登录主体,注册即登录 | +| 新手引导是否再展示 | [`onboarding_completion`](./onboarding_completion.md) | 按 设备+账号 去重;登录响应回 `onboarding_completed`,走完引导时标记,跨卸载重装 | | 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 | ### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权) @@ -54,7 +55,8 @@ ### C 端(App 用户触发) | 触发(用户动作 / endpoint / 回调) | 写入 | 操作 | |---|---|---| -| 登录 `POST /auth/jverify-login`、`/auth/sms/login` | `user` | C(首次=注册)/ U(`last_login_at`) | +| 登录 `POST /auth/jverify-login`、`/auth/sms/login` | `user` | C(首次=注册)/ U(`last_login_at`);并**读** `onboarding_completion` 回 `onboarding_completed` | +| 走完新手引导 `POST /user/onboarding/complete` | `onboarding_completion`(C) | `(user_id, device_id)` 幂等,撞唯一约束即忽略 | | 改昵称 `PATCH /user/profile`、传头像 `POST /user/avatar` | `user` | U | | 注销 `DELETE /user` | `user` | U(软删:`phone→deleted_`、`status=deleted`) | | 绑/解绑微信 `POST /wallet/bind-wechat`、`/unbind-wechat` | `user`.wechat_* | U | @@ -120,6 +122,7 @@ - **`comparison_record.store_name` ≈ `savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。 - **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖。 - **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record` 里 `status='success'` 的 `count`。 +- **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。 ### ER 关系(文字版) ``` @@ -129,6 +132,7 @@ user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_reco signin_boost_record, user_task, comparison_record, comparison_milestone_claim, savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record, price_report, feedback } +user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,device_id) 去重) comparison_record ─1:N─ price_report (comparison_record_id, 可空) admin_user ─1:N─ admin_audit_log app_config (独立, 无外键, key 为主键) diff --git a/docs/database/README.md b/docs/database/README.md index 9220687..abffd58 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -3,18 +3,19 @@ > 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。 > ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。 > 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。 -> 最后更新:2026-06-07(补全 22 张业务表 + 新增 [OVERVIEW 总览](./OVERVIEW.md)) +> 最后更新:2026-06-10(新增 `onboarding_completion` 新手引导完成表 → 23 张业务表) > 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。 --- -## 表总览(22 张业务表 + `alembic_version` 框架表) +## 表总览(23 张业务表 + `alembic_version` 框架表) ### 账号 / 反馈 | 表 | 用途 | 模型 | 文档 | |---|---|---|---| | `user` | 用户(登录主体);几乎所有表的外键宿主 | `models/user.py` | [详情](./user.md) | +| `onboarding_completion` | 新手引导完成标记(按 设备+账号 去重,登录时据此跳过引导) | `models/onboarding.py` | [详情](./onboarding_completion.md) | | `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) | ### 钱包 / 福利(看广告赚钱闭环) diff --git a/docs/database/onboarding_completion.md b/docs/database/onboarding_completion.md new file mode 100644 index 0000000..8e450c1 --- /dev/null +++ b/docs/database/onboarding_completion.md @@ -0,0 +1,44 @@ +# onboarding_completion — 新手引导完成标记(按 设备 + 账号 去重) + +> 模型 `app/models/onboarding.py` · 仓库 `app/repositories/onboarding.py` · 迁移 `alembic/versions/onboarding_completion_table.py`(revision `onboarding_completion`) · 读 `POST /api/v1/auth/jverify-login`·`/sms/login` · 写 `POST /api/v1/user/onboarding/complete` · 测试 `tests/test_onboarding.py` · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +记录「**某账号在某台设备上已走完新手引导**」,一条 `(user_id, device_id)`。用途单一:决定**登录后要不要再展示新手引导教程**——本表有这条 → 跳过引导直接进首页;没有 → 走引导。 + +**为什么要落后端**:此前「引导只跑一次」只存客户端本地 `OnboardingPrefs`(SharedPreferences),**卸载重装即丢** → 重装会重弹引导。产品需求是「同设备 + 同账号只触发一次,且跨卸载重装」,本地存不住,故把"真相"放到服务端,按 **账号 + 硬件设备号** 去重。 + +**去重维度 = 设备 + 账号**(产品决议): + +| 场景 | 命中本表? | 是否再展示引导 | +|---|---|---| +| 新账号(从未走过) | 否 | ✅ 展示 | +| 同账号 + **同设备**(卸载重装 / 退登重登) | 是 | ❌ 跳过 | +| 同账号 + **换新设备** | 否 | ✅ 展示(新机需重新授权悬浮窗/无障碍等) | + +> ⚠️ `device_id` 用客户端**硬件级稳定标识** `Settings.Secure.ANDROID_ID`(`util/DeviceId.hardwareId()`,同签名 app 卸载重装不变、仅恢复出厂重置),**不是**领券/比价用的 per-install `DeviceId.get()`(存 SP、重装会变)。两套 device_id 不互通,别混用。 + +## 用在哪 / 增删改查 +- **R(读)**:登录 `POST /auth/jverify-login`、`/auth/sms/login` —— 客户端在登录请求体里带 `device_id`,`onboarding_repo.is_completed(user_id, device_id)` 查本表有无行,结果塞进登录响应 `TokenWithUser.onboarding_completed`(true=已完成)。客户端 `AuthRepository.persist` 据此回写本地 `OnboardingPrefs`(只回写 true),既有 gating(`OnboardingHost` / `AppNavHost`)逻辑不变。 +- **C(插入)**:走完引导最后一步(后台耗电屏)时,客户端 `POST /api/v1/user/onboarding/complete`(带 Bearer + `device_id`)→ `onboarding_repo.mark_completed` 插一行。**幂等**:同 `(user_id, device_id)` 已存在则撞唯一约束,`IntegrityError` 被 rollback 吞掉、视为成功;`device_id` 为空一律忽略不写。best-effort:客户端上报失败不阻断进首页(本地已先标记)。 +- **U / D**:无。一台设备 + 一账号一条,完成即永久;账号注销(软删 `user`)**不清本表**(留着无害——user_id 指向的是已 `deleted` 的行;同手机号重新注册是新 user.id,自然重新走引导)。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `user_id` | Integer | index, NOT NULL | 归属账号 → `user.id`(语义外键,未建 DB 级 FK,同 `coupon_*` 设备表) | +| `device_id` | String(64) | NOT NULL | 硬件级设备标识 = 客户端 `Settings.Secure.ANDROID_ID`;卸载重装不变 | +| `completed_at` | DateTime(tz) | server_default now() | 标记完成的时间 | + +## 关系 / Join Key +- `user_id` → `user.id`(多对一:一账号在 N 台设备各一条)。**无硬 FK 约束**(同 `coupon_claim_record` / `coupon_prompt_engagement`),靠业务字段对齐。 +- 判断维度 = `(user_id, device_id)` 组合等值查。 +- `device_id` 与客户端 `DeviceId.hardwareId()`(ANDROID_ID)同源;**不**与领券侧 per-install `device_id` 互通。 + +## 索引与约束 +- PK `id`;index `user_id`(`ix_onboarding_completion_user_id`);UNIQUE(`user_id`, `device_id`) = `uq_onboarding_user_device`(防同账号同设备重复写 + 兜并发 upsert)。 + +## 注意 +- **去重维度是「设备 + 账号」**,不是纯账号:同一个人在新手机上登录会重新看引导(产品预期:新机要重新授权权限)。若日后想改「纯账号、换机也不弹」,把 `is_completed`/`mark_completed` 去掉 `device_id` 维度即可。 +- `device_id` 取不到 / 为已知脏值(`9774d56d682e549c`)时客户端退回 per-install id,这类极少数设备重装会重弹一次(尽力而为)。 +- 本地 `OnboardingPrefs` 现仅作**同安装快速跳过缓存**;真相以本表为准,每次登录响应同步。 +- 客户端实现:`ui/onboarding/OnboardingViewModel.kt`(上报)、`data/auth/AuthRepository.kt`(读 + 同步本地)、`util/DeviceId.kt`(ANDROID_ID)。 diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..1a83f75 --- /dev/null +++ b/run.bat @@ -0,0 +1,35 @@ +@echo off +REM Local dev startup script (Windows) - mirror of run.sh +REM +REM Usage: +REM cd shaguabijia-app-server +REM run.bat +REM +REM Listens on 0.0.0.0:8770 (works for both real device via adb reverse +REM and LAN). Auto-reload on code change. +REM +REM Prerequisite (first time): +REM conda activate pricebot ^&^& pip install -e . +REM copy .env.example .env ^&^& fill JWT_SECRET_KEY +REM +REM This file is the Windows-native peer of run.sh. Keeping run.sh untouched +REM avoids CRLF/encoding fights every time someone bash-runs the .sh on Win. + +cd /d "%~dp0" + +if not exist .env ( + echo [X] Missing .env. Run: copy .env.example .env and fill JWT_SECRET_KEY ^(plus MT_CPS_* if you test Meituan^) + exit /b 1 +) + +if not exist data mkdir data + +REM Build/upgrade SQLite schema (idempotent; no-op if already at head) +call alembic upgrade head +if errorlevel 1 ( + echo [X] alembic upgrade head failed + exit /b %errorlevel% +) + +REM Long-running foreground process. Ctrl+C to stop. +uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py new file mode 100644 index 0000000..3b6f533 --- /dev/null +++ b/tests/test_onboarding.py @@ -0,0 +1,67 @@ +"""新手引导"按 设备+账号 只触发一次"的后端闭环测试。 + +覆盖: +- 全新 (账号, 设备) 登录 → onboarding_completed=False(照常走引导) +- 标记完成后,同 (账号, 设备) 再登录 → True(跳过引导,跨"重装"= 同 device_id 重新登录) +- 同账号换设备(device_id 不同)→ False(新设备重新引导) +- 标记幂等;缺 device_id 时按未完成处理 +""" +from __future__ import annotations + + +def _login(client, phone: str, device_id: str | None): + body = {"phone": phone, "code": "123456"} + if device_id is not None: + body["device_id"] = device_id + r = client.post("/api/v1/auth/sms/login", json=body) + assert r.status_code == 200, r.text + return r.json() + + +def _mark_complete(client, access: str, device_id: str): + return client.post( + "/api/v1/user/onboarding/complete", + json={"device_id": device_id}, + headers={"Authorization": f"Bearer {access}"}, + ) + + +def test_onboarding_once_per_device_and_account(client) -> None: + phone = "13800138001" + dev_a = "android-id-aaaa" + dev_b = "android-id-bbbb" + + # ① 全新账号+设备:未完成 + data = _login(client, phone, dev_a) + assert data["onboarding_completed"] is False + access = data["access_token"] + + # ② 走完引导 → 标记完成(幂等:再标一次也 200) + assert _mark_complete(client, access, dev_a).status_code == 200 + assert _mark_complete(client, access, dev_a).json()["ok"] is True + + # ③ 同账号 + 同设备(模拟卸载重装后重新登录)→ 已完成,跳过引导 + assert _login(client, phone, dev_a)["onboarding_completed"] is True + + # ④ 同账号 + 新设备 → 仍未完成,新设备重新走引导 + assert _login(client, phone, dev_b)["onboarding_completed"] is False + + +def test_onboarding_mark_on_one_device_does_not_leak_to_other(client) -> None: + """B 设备标记完成,不影响 A 设备(逐设备独立)。""" + phone = "13800138002" + data_a = _login(client, phone, "devA") + assert data_a["onboarding_completed"] is False + + data_b = _login(client, phone, "devB") + assert _mark_complete(client, data_b["access_token"], "devB").status_code == 200 + + assert _login(client, phone, "devB")["onboarding_completed"] is True + assert _login(client, phone, "devA")["onboarding_completed"] is False + + +def test_onboarding_missing_device_id_treated_as_incomplete(client) -> None: + """老客户端不传 device_id:按未完成处理,不报错、不误跳过。""" + phone = "13800138003" + data = _login(client, phone, device_id=None) + assert data["onboarding_completed"] is False