fix(coupon): 领券弹窗频控按 App 独立 + debug 全重置补全 (#52)
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 <noreply@anthropic.com> --------- Co-authored-by: no_gen_mu <liujianhishen@gmail.com> Reviewed-on: #52 Co-authored-by: liujiahui <liujiahui@wonderable.ai> Co-committed-by: liujiahui <liujiahui@wonderable.ai>
This commit was merged in pull request #52.
This commit is contained in:
@@ -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')
|
||||
+43
-11
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user