Compare commits

...

2 Commits

Author SHA1 Message Date
zzhyyyyy dacb37e3e1 feat(onboarding): 新手引导完成按 设备+账号 去重(表/模型/仓储/端点/迁移/测试)
- 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) <noreply@anthropic.com>
2026-06-10 18:32:51 +08:00
liujiahui 0890e693d7 feat(coupon): 今日跑完整轮领券后端记录 + 查询(首页置灰源) (#34)
新增 coupon_daily_completion 表:按 (device_id, 自然日) 记"今天是否已跑完
整轮领券(到 done 帧)"。客户端据此把首页「去领取」卡置灰、不可点。

口径(用户决策 A 方案):到 done 即算,不管单券成败 —— 失败/跳过常是无障碍/
环境问题,重复点也补不回来。判断维度 device_id,与 engagement/claim 一致。

- model CouponDailyCompletion(uq device+complete_date,仿 CouponPromptEngagement)
- repo has_completed_today / mark_completed_today(幂等 upsert + IntegrityError 兜底)
- schema CouponCompletedTodayOut(completed: bool)
- coupon_step 透传链路在 action.command=="done" 那帧 best-effort 写完成记录
  (pricebot 只在整轮全跑完才保留 command=="done",故无需再判 continue)
- GET /api/v1/coupon/completed-today?device_id= 供首页查询
- alembic 迁移 coupon_daily_completion(down_revision=0cf18d590b1d 当前 head)

合并后需 alembic upgrade head,否则 /completed-today 500。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #34
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-06-10 16:07:33 +08:00
18 changed files with 509 additions and 13 deletions
@@ -0,0 +1,58 @@
"""coupon daily completion table(今日跑完整轮领券 → 首页置灰源)
Revision ID: coupon_daily_completion
Revises: f8d3b1e60a27
Create Date: 2026-06-10 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "coupon_daily_completion"
down_revision: str | Sequence[str] | None = "f8d3b1e60a27"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"coupon_daily_completion",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("device_id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("complete_date", sa.Date(), nullable=False),
sa.Column("trace_id", sa.String(length=64), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("device_id", "complete_date", name="uq_coupon_completion_device_date"),
)
with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op:
batch_op.create_index(
batch_op.f("ix_coupon_daily_completion_user_id"), ["user_id"], unique=False
)
batch_op.create_index(
batch_op.f("ix_coupon_daily_completion_trace_id"), ["trace_id"], unique=False
)
def downgrade() -> None:
with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_trace_id"))
batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_user_id"))
op.drop_table("coupon_daily_completion")
@@ -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')
+10 -6
View File
@@ -19,6 +19,7 @@ from app.core.ratelimit import rate_limit
from app.core.security import TokenError, decode_token, issue_token_pair 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.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.integrations.sms import SmsError, send_code, verify_code 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.repositories import user as user_repo
from app.schemas.auth import ( from app.schemas.auth import (
JverifyLoginRequest, JverifyLoginRequest,
@@ -37,12 +38,13 @@ logger = logging.getLogger("shagua.auth")
router = APIRouter(prefix="/api/v1/auth", tags=["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) tokens = issue_token_pair(user.id)
return TokenWithUser( return TokenWithUser(
**tokens, **tokens,
user=UserOut.model_validate(user), 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": if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled") raise HTTPException(status_code=403, detail="account disabled")
logger.info("jverify_login ok user_id=%d phone=%s", user.id, mask_phone(phone)) completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
return _login_response(db, user) 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": if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled") raise HTTPException(status_code=403, detail="account disabled")
logger.info("sms_login ok user_id=%d phone=%s", user.id, mask_phone(req.phone)) completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
return _login_response(db, user) 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 ===================== # ===================== Refresh =====================
+42 -1
View File
@@ -23,7 +23,11 @@ from app.core.config import settings
from app.core.pricebot_router import pick_pricebot from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.repositories import coupon_state as coupon_repo from app.repositories import coupon_state as coupon_repo
from app.schemas.coupon_state import CouponPromptDismissIn, CouponPromptShouldShowOut from app.schemas.coupon_state import (
CouponCompletedTodayOut,
CouponPromptDismissIn,
CouponPromptShouldShowOut,
)
logger = logging.getLogger("shagua.coupon") logger = logging.getLogger("shagua.coupon")
@@ -76,6 +80,14 @@ def _record_claims_blocking(
coupon_repo.record_claims(db, device_id, user_id, trace_id, results) coupon_repo.record_claims(db, device_id, user_id, trace_id, results)
def _mark_completed_blocking(
device_id: str, user_id: int | None, trace_id: str | None
) -> None:
"""独立 session 写"今日已跑完整轮"(到 done 帧那刻调,best-effort 不阻塞领券)。"""
with SessionLocal() as db:
coupon_repo.mark_completed_today(db, device_id, user_id, trace_id)
@router.post("/step", summary="领券任务步进 (透传到 pricebot)") @router.post("/step", summary="领券任务步进 (透传到 pricebot)")
async def coupon_step( async def coupon_step(
request: Request, request: Request,
@@ -160,6 +172,19 @@ async def coupon_step(
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("coupon claim write failed: %s", e) logger.warning("coupon claim write failed: %s", e)
# 整轮跑完(done 帧)→ 记一条"今日已完成",首页「去领取」卡据此置灰。
# pricebot 把中途单券 done 改写成 wait+continue=true,只有整套全跑完那帧才保留
# command=="done"(见 pricebot main.py),故 done 已等价"整轮完成"(用户决策 A 方案:
# 到 done 即算,不管单券成败),无需再判 continue。写库失败绝不连累领券返回。
action = resp_json.get("action") or {}
if device_id and action.get("command") == "done":
try:
await run_in_threadpool(
_mark_completed_blocking, device_id, user_id, trace_id
)
except Exception as e: # noqa: BLE001
logger.warning("coupon completion write failed: %s", e)
return resp_json return resp_json
@@ -195,3 +220,19 @@ def coupon_prompt_reset(payload: CouponPromptDismissIn, db: DbSession) -> dict[s
开发设置「重置今日领券弹窗状态」按钮调。MVP 不鉴权,按 device_id。""" 开发设置「重置今日领券弹窗状态」按钮调。MVP 不鉴权,按 device_id。"""
coupon_repo.reset_today_engagement(db, payload.device_id) coupon_repo.reset_today_engagement(db, payload.device_id)
return {"ok": True} return {"ok": True}
@router.get(
"/completed-today",
response_model=CouponCompletedTodayOut,
summary="这台设备今天是否已跑完整轮领券(到 done 帧)",
)
def coupon_completed_today(
device_id: str, db: DbSession
) -> CouponCompletedTodayOut:
"""今天这台设备已跑完整轮(到 done)→ completed=true。客户端据此把首页「去领取」
卡置灰、不可点(用户决策 A 方案:到 done 即算,不管单券成败)。判断维度 device_id
必须与领券循环上报的一致(客户端两端都用 ANDROID_ID)。"""
return CouponCompletedTodayOut(
completed=coupon_repo.has_completed_today(db, device_id)
)
+13 -1
View File
@@ -16,9 +16,10 @@ from fastapi import APIRouter, File, HTTPException, UploadFile
from app.api.deps import CurrentUser, DbSession from app.api.deps import CurrentUser, DbSession
from app.core import media from app.core import media
from app.repositories import onboarding as onboarding_repo
from app.repositories import user as user_repo from app.repositories import user as user_repo
from app.schemas.auth import UserOut 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") logger = logging.getLogger("shagua.user")
@@ -51,6 +52,17 @@ async def upload_avatar(
return UserOut.model_validate(updated) 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="注销账号(软删除)") @router.delete("", response_model=OkResponse, summary="注销账号(软删除)")
def delete_account(user: CurrentUser, db: DbSession) -> OkResponse: def delete_account(user: CurrentUser, db: DbSession) -> OkResponse:
media.delete_avatar(user.avatar_url) media.delete_avatar(user.avatar_url)
+2
View File
@@ -9,12 +9,14 @@ from app.models.comparison import ComparisonRecord # noqa: F401
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401 from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
from app.models.coupon_state import ( # noqa: F401 from app.models.coupon_state import ( # noqa: F401
CouponClaimRecord, CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement, CouponPromptEngagement,
) )
from app.models.feedback import Feedback # noqa: F401 from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401 from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401 from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
from app.models.meituan_coupon import MeituanCoupon # 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_marquee_seed import OpsMarqueeSeed # noqa: F401
from app.models.ops_stat_config import OpsStatConfig # noqa: F401 from app.models.ops_stat_config import OpsStatConfig # noqa: F401
from app.models.price_observation import PriceObservation # noqa: F401 from app.models.price_observation import PriceObservation # noqa: F401
+43
View File
@@ -93,6 +93,49 @@ class CouponClaimRecord(Base):
) )
class CouponDailyCompletion(Base):
"""按 (device, 自然日) 记"今天是否已跑完整轮领券(到 done 帧)"——首页置灰源。
与 engagement 区别:engagement = 用户表达过意向(点了一键领取/拒绝即记,不管跑没跑完);
completion = 这一轮真跑到了 done(整套流程走完)。首页「去领取」卡据此置灰:跑完了
今天就不能再领。判断口径(用户决策 2026-06-10 A 方案):**到 done 即算**,不管单券
成败(失败/跳过常是无障碍/环境问题,重复点也补不回来)。判断维度 device_id,与
engagement/claim 一致。
"""
__tablename__ = "coupon_daily_completion"
__table_args__ = (
# 一台设备一天一条:今天跑完过就置灰。
UniqueConstraint(
"device_id", "complete_date",
name="uq_coupon_completion_device_date",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# Asia/Shanghai 自然日。
complete_date: Mapped[date] = mapped_column(Date, nullable=False)
# 哪次任务跑到 done,回指 pricebot work_logs / 排查。
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<CouponDailyCompletion device={self.device_id} "
f"date={self.complete_date}>"
)
class CouponPromptEngagement(Base): class CouponPromptEngagement(Base):
"""按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。""" """按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。"""
+40
View File
@@ -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"<OnboardingCompletion user={self.user_id} device={self.device_id}>"
+46 -1
View File
@@ -13,7 +13,11 @@ from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.coupon_state import CouponClaimRecord, CouponPromptEngagement from app.models.coupon_state import (
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
)
logger = logging.getLogger("shagua.coupon_state") logger = logging.getLogger("shagua.coupon_state")
@@ -78,6 +82,47 @@ def reset_today_engagement(db: Session, device_id: str) -> int:
return result.rowcount or 0 return result.rowcount or 0
# ===== 今日跑完整轮(coupon_daily_completion)=====
def has_completed_today(db: Session, device_id: str) -> bool:
"""这台设备今天是否已跑完整轮领券(到 done 帧)。有 = 首页置灰、不能再领。"""
row = db.execute(
select(CouponDailyCompletion.id).where(
CouponDailyCompletion.device_id == device_id,
CouponDailyCompletion.complete_date == today_cn(),
)
).first()
return row is not None
def mark_completed_today(
db: Session, device_id: str, user_id: int | None, trace_id: str | None = None
) -> None:
"""记今日已跑完整轮。(device, 今天) 唯一,幂等 upsert。到 done 即记,不管单券成败。"""
today = today_cn()
row = db.execute(
select(CouponDailyCompletion).where(
CouponDailyCompletion.device_id == device_id,
CouponDailyCompletion.complete_date == today,
)
).scalar_one_or_none()
if row is not None:
if user_id is not None:
row.user_id = user_id
if trace_id is not None:
row.trace_id = trace_id
else:
db.add(CouponDailyCompletion(
device_id=device_id, user_id=user_id,
complete_date=today, trace_id=trace_id,
))
try:
db.commit()
except IntegrityError:
# 并发下另一请求刚插了同 (device, 日) → 唯一约束撞,回滚忽略(本就幂等)。
db.rollback()
# ===== 领券记录(coupon_claim_record)===== # ===== 领券记录(coupon_claim_record)=====
def record_claims( def record_claims(
+35
View File
@@ -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()
+13
View File
@@ -41,6 +41,11 @@ class TokenPair(BaseModel):
class TokenWithUser(TokenPair): class TokenWithUser(TokenPair):
user: UserOut user: UserOut
onboarding_completed: bool = Field(
False,
description="该 设备+账号 是否已走完新手引导(据登录请求里的 device_id 计算)。"
"true → 客户端登录后直接进首页,跳过引导。",
)
# ===== 极光一键登录 ===== # ===== 极光一键登录 =====
@@ -48,6 +53,10 @@ class TokenWithUser(TokenPair):
class JverifyLoginRequest(BaseModel): class JverifyLoginRequest(BaseModel):
login_token: str = Field(..., description="客户端 loginAuth 拿到的 loginToken", min_length=1) login_token: str = Field(..., description="客户端 loginAuth 拿到的 loginToken", min_length=1)
operator: str = Field("", description="CM/CU/CT,用于日志,可选") 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): class SmsLoginRequest(BaseModel):
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$") phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
code: str = Field(..., min_length=4, max_length=8) code: str = Field(..., min_length=4, max_length=8)
device_id: str = Field(
"", max_length=64,
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
)
# ===== Refresh ===== # ===== Refresh =====
+6
View File
@@ -19,3 +19,9 @@ class CouponPromptShouldShowOut(BaseModel):
"""切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。""" """切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。"""
should_show: bool should_show: bool
class CouponCompletedTodayOut(BaseModel):
"""这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。"""
completed: bool
+7
View File
@@ -16,5 +16,12 @@ class ProfileUpdateRequest(BaseModel):
return v return v
class OnboardingCompleteRequest(BaseModel):
device_id: str = Field(
..., min_length=1, max_length=64,
description="硬件级设备标识(Android ANDROID_ID),与登录时一致,用于按 设备+账号 标记引导完成",
)
class OkResponse(BaseModel): class OkResponse(BaseModel):
ok: bool = True ok: bool = True
+6 -2
View File
@@ -2,7 +2,7 @@
> 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。 > 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。
> 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。 > 本文专门回答三件「跨表」的事:**① 每块 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 位置 / 动作 | 表 | 说明 | | App 位置 / 动作 | 表 | 说明 |
|---|---|---| |---|---|---|
| 登录(极光/短信)/ 改资料 / 注销 | [`user`](./user.md) | 登录主体,注册即登录 | | 登录(极光/短信)/ 改资料 / 注销 | [`user`](./user.md) | 登录主体,注册即登录 |
| 新手引导是否再展示 | [`onboarding_completion`](./onboarding_completion.md) | 按 设备+账号 去重;登录响应回 `onboarding_completed`,走完引导时标记,跨卸载重装 |
| 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 | | 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 |
### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权) ### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权)
@@ -54,7 +55,8 @@
### C 端(App 用户触发) ### C 端(App 用户触发)
| 触发(用户动作 / endpoint / 回调) | 写入 | 操作 | | 触发(用户动作 / 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 | | 改昵称 `PATCH /user/profile`、传头像 `POST /user/avatar` | `user` | U |
| 注销 `DELETE /user` | `user` | U(软删:`phone→deleted_<id>``status=deleted`) | | 注销 `DELETE /user` | `user` | U(软删:`phone→deleted_<id>``status=deleted`) |
| 绑/解绑微信 `POST /wallet/bind-wechat``/unbind-wechat` | `user`.wechat_* | U | | 绑/解绑微信 `POST /wallet/bind-wechat``/unbind-wechat` | `user`.wechat_* | U |
@@ -120,6 +122,7 @@
- **`comparison_record.store_name``savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。 - **`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` 仍是旧版兼容统计,不逐条参与发奖。 - **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖。
- **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record``status='success'``count` - **里程碑解锁进度不存库**:`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 关系(文字版) ### 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, 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, savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record,
price_report, feedback } price_report, feedback }
user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,device_id) 去重)
comparison_record ─1:N─ price_report (comparison_record_id, 可空) comparison_record ─1:N─ price_report (comparison_record_id, 可空)
admin_user ─1:N─ admin_audit_log admin_user ─1:N─ admin_audit_log
app_config (独立, 无外键, key 为主键) app_config (独立, 无外键, key 为主键)
+3 -2
View File
@@ -3,18 +3,19 @@
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。 > 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。 > ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。 > 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。
> 最后更新:2026-06-07(补全 22 张业务表 + 新增 [OVERVIEW 总览](./OVERVIEW.md)) > 最后更新:2026-06-10(新增 `onboarding_completion` 新手引导完成表 → 23 张业务表)
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。 > 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
--- ---
## 表总览(22 张业务表 + `alembic_version` 框架表) ## 表总览(23 张业务表 + `alembic_version` 框架表)
### 账号 / 反馈 ### 账号 / 反馈
| 表 | 用途 | 模型 | 文档 | | 表 | 用途 | 模型 | 文档 |
|---|---|---|---| |---|---|---|---|
| `user` | 用户(登录主体);几乎所有表的外键宿主 | `models/user.py` | [详情](./user.md) | | `user` | 用户(登录主体);几乎所有表的外键宿主 | `models/user.py` | [详情](./user.md) |
| `onboarding_completion` | 新手引导完成标记(按 设备+账号 去重,登录时据此跳过引导) | `models/onboarding.py` | [详情](./onboarding_completion.md) |
| `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) | | `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) |
### 钱包 / 福利(看广告赚钱闭环) ### 钱包 / 福利(看广告赚钱闭环)
+44
View File
@@ -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)。
+35
View File
@@ -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
+67
View File
@@ -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