From 8711635e782e5332dea33e0b875d8a10da082b7c Mon Sep 17 00:00:00 2001 From: no_gen_mu Date: Sun, 14 Jun 2026 19:44:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(coupon):=20=E9=A2=86=E5=88=B8=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E9=A2=91=E6=8E=A7=E6=8C=89=20App=20=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=20+=20debug=20=E5=85=A8=E9=87=8D=E7=BD=AE=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bug: 弹窗 engagement 表只按 (device, 日) 全局记一条,美团弹过/领过就把整台设备当天 标记 engage,淘宝/京东被压住不弹。需求是美团/淘宝/京东各自独立、每日各弹一次。 改动: - model: CouponPromptEngagement 加 package 列,唯一约束 (device,日) → (device,package,日) - alembic: 新增迁移 coupon_engage_per_package(加列 + 改唯一约束, batch_alter_table) - repository: has_engaged_today / mark_engagement 加 package 维度;新增 reset_today_completion - api: should-show / dismiss 接收 package;coupon_step(step=0) 按 App 记 engagement; 补 /prompt/shown 接口(客户端一直在调但后端缺失, 原 404); 补 /completed-today/reset(开发设置全重置用, 解首页卡置灰) 验证: curl 端到端 —— 美团弹过后 should-show 美团=false 淘宝/京东=true; shown/reset/completion-reset 端点全 ok。 Co-Authored-By: Claude Opus 4.8 --- alembic/versions/coupon_engage_per_package.py | 53 ++++++++++++++++++ app/api/v1/coupon.py | 54 +++++++++++++++---- app/models/coupon_state.py | 22 +++++--- app/repositories/coupon_state.py | 37 ++++++++++--- app/schemas/coupon_state.py | 18 ++++++- 5 files changed, 157 insertions(+), 27 deletions(-) create mode 100644 alembic/versions/coupon_engage_per_package.py diff --git a/alembic/versions/coupon_engage_per_package.py b/alembic/versions/coupon_engage_per_package.py new file mode 100644 index 0000000..58d1e70 --- /dev/null +++ b/alembic/versions/coupon_engage_per_package.py @@ -0,0 +1,53 @@ +"""coupon_prompt_engagement 频控加 package 维度(美团/淘宝/京东各自独立弹) + +Revision ID: coupon_engage_per_package +Revises: store_mapping_jd_cols +Create Date: 2026-06-14 11:00:00.000000 + +需求:领券引导窗按 (device, App, 自然日) 频控——在美团弹过/领过,不影响淘宝、京东今天 +仍各弹一次。原表唯一键是 (device_id, engage_date),缺 package → 任一 App 弹过就把整台 +设备当天标记 engage,其余 App 被压住不弹(bug)。 + +本迁移: + 1. 加 package 列(NOT NULL,旧行用 server_default "" 填占位,不影响新逻辑判断)。 + 2. 旧唯一约束 (device_id, engage_date) → 新 (device_id, package, engage_date)。 + +SQLite 不支持直接 drop/add 约束,用 batch_alter_table(建临时表 + 拷数据 + 换名, +与 store_mapping_* 同款)。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'coupon_engage_per_package' +down_revision: Union[str, Sequence[str], None] = 'store_mapping_jd_cols' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op: + # 加 package 列。旧行(改造前的全局记录)填 "" 占位:它们对应的是"老的全局态", + # 新逻辑按 (device, package, 日) 判,占位 "" 不会与真实包名(com.xxx)碰撞。 + batch_op.add_column( + sa.Column('package', sa.String(length=64), nullable=False, server_default='') + ) + # 旧唯一约束 (device_id, engage_date) → 新三元组 (device_id, package, engage_date)。 + batch_op.drop_constraint('uq_coupon_engage_device_date', type_='unique') + batch_op.create_unique_constraint( + 'uq_coupon_engage_device_pkg_date', + ['device_id', 'package', 'engage_date'], + ) + + +def downgrade() -> None: + with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op: + batch_op.drop_constraint('uq_coupon_engage_device_pkg_date', type_='unique') + batch_op.create_unique_constraint( + 'uq_coupon_engage_device_date', + ['device_id', 'engage_date'], + ) + batch_op.drop_column('package') diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index e44adb5..6e07120 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -27,6 +27,7 @@ from app.schemas.coupon_state import ( CouponCompletedTodayOut, CouponPromptDismissIn, CouponPromptShouldShowOut, + CouponPromptShownIn, ) logger = logging.getLogger("shagua.coupon") @@ -66,11 +67,11 @@ def _extract_coupon_results(resp_json: dict) -> list[dict]: def _mark_engagement_blocking( - device_id: str, user_id: int | None, engage_type: str + device_id: str, package: str, user_id: int | None, engage_type: str ) -> None: """独立 session 写 engagement(async 端点经 run_in_threadpool 调,不阻塞事件循环)。""" with SessionLocal() as db: - coupon_repo.mark_engagement(db, device_id, user_id, engage_type) + coupon_repo.mark_engagement(db, device_id, package, user_id, engage_type) def _record_claims_blocking( @@ -111,14 +112,17 @@ async def coupon_step( device_id = meta.get("device_id") user_id = _to_int(meta.get("user_id")) # 登录态才带;判断不靠它,资产留痕用 trace_id = meta.get("trace_id") + # 发起领券时前台 App 包名(step body 带 "package")。频控按 App,这条 engagement 要记到 + # 对应 App 上。App 内「去领取」发起时 package 可能缺/为空 → 退化为 "" 占位(全局态)。 + pkg = meta.get("package") or "" # 领券任务首帧(step=0)= 用户已发起领券 → 记一条今日 engagement(claim_started), - # 今天这台设备不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能 + # 今天**这个 App** 不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能 # 连累领券主流程,整段吞掉。 if device_id and meta.get("step") == 0: try: await run_in_threadpool( - _mark_engagement_blocking, device_id, user_id, "claim_started" + _mark_engagement_blocking, device_id, pkg, user_id, "claim_started" ) except Exception as e: # noqa: BLE001 logger.warning("coupon engagement write failed: %s", e) @@ -188,14 +192,29 @@ async def coupon_step( return resp_json +@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)") +def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]: + """客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。 + + 频控主判据(管跨重装):弹出即占用今天这个 App 的"一次"。用户领/拒/无视都算用掉。 + 后续点领取/拒绝再由 step/dismiss 把 type 升级。按 (device, package, 日) 记。 + """ + coupon_repo.mark_engagement( + db, payload.device_id, payload.package, payload.user_id, "shown" + ) + return {"ok": True} + + @router.post("/prompt/dismiss", summary="用户拒绝/关闭领券引导窗(记今日已 engage)") def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]: - """客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天不再弹。 + """客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天**这个 App** 不再弹。 server 在透传链路里看不到"用户拒绝"(拒绝不发起领券),故必须客户端通知。 - MVP 不鉴权,按 device_id 记。 + 频控按 (device, package, 日),各 App 独立。MVP 不鉴权,按 device_id 记。 """ - coupon_repo.mark_engagement(db, payload.device_id, payload.user_id, "dismissed") + coupon_repo.mark_engagement( + db, payload.device_id, payload.package, payload.user_id, "dismissed" + ) return {"ok": True} @@ -205,12 +224,12 @@ def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict summary="切到外卖 App 时是否还应弹领券引导窗", ) def coupon_prompt_should_show( - device_id: str, db: DbSession + device_id: str, db: DbSession, package: str = "" ) -> CouponPromptShouldShowOut: - """今天这台设备已 engage(领或拒)过 → should_show=false。客户端据此决定弹不弹 - (纯后台判据,客户端不再做前台 SP 缓存判断)。""" + """今天这台设备**这个 App** 已 engage(弹/领/拒)过 → should_show=false。各 App 独立: + 美团弹过不压淘宝/京东。客户端切到目标 App 时带 package 查(老客户端不带 → "" 全局态)。""" return CouponPromptShouldShowOut( - should_show=not coupon_repo.has_engaged_today(db, device_id) + should_show=not coupon_repo.has_engaged_today(db, device_id, package) ) @@ -236,3 +255,16 @@ def coupon_completed_today( return CouponCompletedTodayOut( completed=coupon_repo.has_completed_today(db, device_id) ) + + +@router.post( + "/completed-today/reset", + summary="重置今日已完成(开发设置全重置用,恢复首页「去领取」卡可点)", +) +def coupon_completed_today_reset( + payload: CouponPromptDismissIn, db: DbSession +) -> dict[str, bool]: + """删这台设备今天的 completion → has_completed_today 变 false,首页「去领取」卡恢复可点。 + 与 /prompt/reset 配套:开发设置「重置今日领券弹窗状态」一键把今日状态全清。MVP 不鉴权。""" + coupon_repo.reset_today_completion(db, payload.device_id) + return {"ok": True} diff --git a/app/models/coupon_state.py b/app/models/coupon_state.py index bd2aa2d..3f9364d 100644 --- a/app/models/coupon_state.py +++ b/app/models/coupon_state.py @@ -137,25 +137,35 @@ class CouponDailyCompletion(Base): class CouponPromptEngagement(Base): - """按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。""" + """按 (device, **App**, 自然日) 记"今天这个 App 是否对领券引导窗表达过意向"——弹窗频控源。 + + 2026-06-14:频控维度从 (device, 日) 改为 (device, package, 日)。需求是美团/淘宝/京东 + 各自独立——在美团弹过/领过,不影响淘宝、京东今天仍各弹一次。原来缺 package → 任一 App + 弹过就把整台设备当天标记 engage,其余 App 被压住不弹(bug)。 + """ __tablename__ = "coupon_prompt_engagement" __table_args__ = ( - # 一台设备一天一条:今天 engage 过(领或拒)就不再弹。 + # 一台设备、一个 App、一天一条:今天**这个 App** engage 过(领或拒)才不再弹该 App。 UniqueConstraint( - "device_id", "engage_date", - name="uq_coupon_engage_device_date", + "device_id", "package", "engage_date", + name="uq_coupon_engage_device_pkg_date", ), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) device_id: Mapped[str] = mapped_column(String(64), nullable=False) + # 触发弹窗的目标 App 包名(com.sankuai.meituan / com.taobao.taobao / com.jingdong.app.mall)。 + # 频控维度,各 App 独立。旧行(改造前)无此值 → 迁移用占位 "" 填,不影响新逻辑判断。 + package: Mapped[str] = mapped_column( + String(64), nullable=False, server_default="" + ) user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True) # Asia/Shanghai 自然日。 engage_date: Mapped[date] = mapped_column(Date, nullable=False) - # claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)。仅记录区分, - # 判断只看"今天有没有这条",type 不影响弹不弹。 + # claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)/ shown(自动弹出即记)。 + # 仅记录区分,判断只看"今天这个 App 有没有这条",type 不影响弹不弹。 engage_type: Mapped[str] = mapped_column(String(16), nullable=False) created_at: Mapped[datetime] = mapped_column( diff --git a/app/repositories/coupon_state.py b/app/repositories/coupon_state.py index c5012b2..33f4781 100644 --- a/app/repositories/coupon_state.py +++ b/app/repositories/coupon_state.py @@ -31,11 +31,13 @@ def today_cn() -> date: # ===== 弹窗频控(coupon_prompt_engagement)===== -def has_engaged_today(db: Session, device_id: str) -> bool: - """这台设备今天是否已对领券引导窗表达过意向(领或拒)。有 = 不再弹。""" +def has_engaged_today(db: Session, device_id: str, package: str) -> bool: + """这台设备今天**这个 App** 是否已对领券引导窗表达过意向(领/拒/弹出)。有 = 该 App 不再弹。 + 频控按 (device, package, 日):美团弹过不影响淘宝/京东今天各自仍弹一次。""" row = db.execute( select(CouponPromptEngagement.id).where( CouponPromptEngagement.device_id == device_id, + CouponPromptEngagement.package == package, CouponPromptEngagement.engage_date == today_cn(), ) ).first() @@ -43,13 +45,18 @@ def has_engaged_today(db: Session, device_id: str) -> bool: def mark_engagement( - db: Session, device_id: str, user_id: int | None, engage_type: str + db: Session, device_id: str, package: str, user_id: int | None, engage_type: str ) -> None: - """记今日意向(claim_started / dismissed)。(device, 今天) 唯一,幂等 upsert。""" + """记今日意向(shown / claim_started / dismissed)。(device, package, 今天) 唯一,幂等 upsert。 + + engage_type 升级口径(同一 (device,package,日) 多次调,只覆盖 type,不新增行): + shown(自动弹出)→ claim_started(点一键领取)/ dismissed(点关闭)。判断只看"有没有这条"。 + """ today = today_cn() row = db.execute( select(CouponPromptEngagement).where( CouponPromptEngagement.device_id == device_id, + CouponPromptEngagement.package == package, CouponPromptEngagement.engage_date == today, ) ).scalar_one_or_none() @@ -59,19 +66,20 @@ def mark_engagement( row.user_id = user_id else: db.add(CouponPromptEngagement( - device_id=device_id, user_id=user_id, + device_id=device_id, package=package, user_id=user_id, engage_date=today, engage_type=engage_type, )) try: db.commit() except IntegrityError: - # 并发下另一请求刚插了同 (device, 日) → 唯一约束撞,回滚忽略(本就幂等)。 + # 并发下另一请求刚插了同 (device, package, 日) → 唯一约束撞,回滚忽略(本就幂等)。 db.rollback() def reset_today_engagement(db: Session, device_id: str) -> int: - """删这台设备今天的 engagement(开发设置「重置今日领券弹窗状态」调,测频控用)。 - 删后 has_engaged_today → false,今天又能弹。返回删除行数。""" + """删这台设备今天**所有 App** 的 engagement(开发设置「重置今日领券弹窗状态」调,测频控用)。 + 删后各 App has_engaged_today → false,今天又都能弹。返回删除行数。 + (不按 package 过滤:重置是"把今天清干净从头测",清全部 App 最符合预期。)""" result = db.execute( delete(CouponPromptEngagement).where( CouponPromptEngagement.device_id == device_id, @@ -123,6 +131,19 @@ def mark_completed_today( db.rollback() +def reset_today_completion(db: Session, device_id: str) -> int: + """删这台设备今天的"已完成"记录(开发设置「重置今日领券弹窗状态」全重置时调)。 + 删后 has_completed_today → false,首页「去领取」卡恢复可点。返回删除行数。""" + result = db.execute( + delete(CouponDailyCompletion).where( + CouponDailyCompletion.device_id == device_id, + CouponDailyCompletion.complete_date == today_cn(), + ) + ) + db.commit() + return result.rowcount or 0 + + # ===== 领券记录(coupon_claim_record)===== def record_claims( diff --git a/app/schemas/coupon_state.py b/app/schemas/coupon_state.py index fd66cb8..15e9c16 100644 --- a/app/schemas/coupon_state.py +++ b/app/schemas/coupon_state.py @@ -7,16 +7,30 @@ from pydantic import BaseModel class CouponPromptDismissIn(BaseModel): """客户端拒绝/关闭领券引导窗的通知体。 - server 据此记一条今日 engagement(dismissed)→ 今天这台设备不再弹引导窗。 + server 据此记一条今日 engagement(dismissed)→ 今天**这个 App** 不再弹引导窗。 + 频控按 (device, package, 日):各 App 独立。package 缺省 ""(老客户端兼容,退化为全局态)。 MVP 不鉴权,按 device_id 判断;user_id 登录态带上就一并记(资产),可空。 """ device_id: str + package: str = "" + user_id: int | None = None + + +class CouponPromptShownIn(BaseModel): + """客户端弹出领券引导窗即上报(记 shown)。 + + 弹出那刻就记一条今日 engagement(shown)→ 今天**这个 App** 不再自动弹(频控主判据, + 管跨重装;本地 SP 兜后台抖动)。后续用户点领取/拒绝再把 type 升级成 claim_started/dismissed。 + """ + + device_id: str + package: str user_id: int | None = None class CouponPromptShouldShowOut(BaseModel): - """切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。""" + """切到外卖 App 时是否还应弹领券引导窗。今天**这个 App** 已 engage(弹/领/拒)过 → false。""" should_show: bool