From 47d74273c981c5d0fbb3b89cfd076ff763face10 Mon Sep 17 00:00:00 2001 From: no_gen_mu Date: Wed, 10 Jun 2026 11:43:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(coupon):=20=E4=BB=8A=E6=97=A5=E8=B7=91?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E8=BD=AE=E9=A2=86=E5=88=B8=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=20+=20=E6=9F=A5=E8=AF=A2(=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E7=BD=AE=E7=81=B0=E6=BA=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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 --- .../versions/coupon_daily_completion_table.py | 58 +++++++++++++++++++ app/api/v1/coupon.py | 43 +++++++++++++- app/models/__init__.py | 1 + app/models/coupon_state.py | 43 ++++++++++++++ app/repositories/coupon_state.py | 47 ++++++++++++++- app/schemas/coupon_state.py | 6 ++ 6 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/coupon_daily_completion_table.py diff --git a/alembic/versions/coupon_daily_completion_table.py b/alembic/versions/coupon_daily_completion_table.py new file mode 100644 index 0000000..cb79079 --- /dev/null +++ b/alembic/versions/coupon_daily_completion_table.py @@ -0,0 +1,58 @@ +"""coupon daily completion table(今日跑完整轮领券 → 首页置灰源) + +Revision ID: coupon_daily_completion +Revises: 0cf18d590b1d +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 = "0cf18d590b1d" +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") diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index 4de69eb..01384f2 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -23,7 +23,11 @@ from app.core.config import settings from app.core.pricebot_router import pick_pricebot from app.db.session import SessionLocal 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") @@ -76,6 +80,14 @@ def _record_claims_blocking( 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)") async def coupon_step( request: Request, @@ -160,6 +172,19 @@ async def coupon_step( except Exception as e: # noqa: BLE001 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 @@ -187,3 +212,19 @@ def coupon_prompt_should_show( return CouponPromptShouldShowOut( should_show=not coupon_repo.has_engaged_today(db, device_id) ) + + +@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) + ) diff --git a/app/models/__init__.py b/app/models/__init__.py index 15f195f..37ff153 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -9,6 +9,7 @@ from app.models.comparison import ComparisonRecord # noqa: F401 from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401 from app.models.coupon_state import ( # noqa: F401 CouponClaimRecord, + CouponDailyCompletion, CouponPromptEngagement, ) from app.models.feedback import Feedback # noqa: F401 diff --git a/app/models/coupon_state.py b/app/models/coupon_state.py index 4374048..bd2aa2d 100644 --- a/app/models/coupon_state.py +++ b/app/models/coupon_state.py @@ -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"" + ) + + class CouponPromptEngagement(Base): """按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。""" diff --git a/app/repositories/coupon_state.py b/app/repositories/coupon_state.py index d36be0a..d47fd75 100644 --- a/app/repositories/coupon_state.py +++ b/app/repositories/coupon_state.py @@ -13,7 +13,11 @@ from sqlalchemy import select from sqlalchemy.exc import IntegrityError 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") @@ -65,6 +69,47 @@ def mark_engagement( db.rollback() +# ===== 今日跑完整轮(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)===== def record_claims( diff --git a/app/schemas/coupon_state.py b/app/schemas/coupon_state.py index 92043ce..fd66cb8 100644 --- a/app/schemas/coupon_state.py +++ b/app/schemas/coupon_state.py @@ -19,3 +19,9 @@ class CouponPromptShouldShowOut(BaseModel): """切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。""" should_show: bool + + +class CouponCompletedTodayOut(BaseModel): + """这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。""" + + completed: bool -- 2.52.0