From 47d74273c981c5d0fbb3b89cfd076ff763face10 Mon Sep 17 00:00:00 2001 From: no_gen_mu Date: Wed, 10 Jun 2026 11:43:31 +0800 Subject: [PATCH 1/3] =?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 From 988576e14b7d6f48594d0b5c798c9dd28f8d1465 Mon Sep 17 00:00:00 2001 From: no_gen_mu Date: Wed, 10 Jun 2026 16:29:11 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs(db):=20=E8=A1=A5=E9=A2=86=E5=88=B8?= =?UTF-8?q?=E4=BB=8A=E6=97=A5=E7=8A=B6=E6=80=81=E4=B8=89=E5=BC=A0=E8=A1=A8?= =?UTF-8?q?=E6=96=87=E6=A1=A3=20+=20=E6=9B=B4=E6=96=B0=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 coupon_state.md 合并文档,覆盖 coupon_home_ui 分支新建的三张表: coupon_prompt_engagement(弹窗频控)/ coupon_daily_completion(首页置灰)/ coupon_claim_record(领券记录沉淀)。逐列对齐 app/models/coupon_state.py, 接口取自 app/api/v1/coupon.py(/api/v1/coupon/*)。 同步更新 README.md(22→25 张业务表 + 新增「领券」总览段)与 OVERVIEW.md(功能域 / 写入路径 / 表间软关联),保持文档与代码一致。 Co-Authored-By: Claude Opus 4.8 --- docs/database/OVERVIEW.md | 20 +++++- docs/database/README.md | 11 ++- docs/database/coupon_state.md | 123 ++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 docs/database/coupon_state.md diff --git a/docs/database/OVERVIEW.md b/docs/database/OVERVIEW.md index e5d2b6f..ed28e7c 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、**无关系库**。共 **25 张业务表** + `alembic_version`(框架的迁移版本指针)。领券联动的「今日状态」三张表(`coupon_*`)同理:领券过程在 pricebot 内存态跑、**不落库**,只有结果回到 app-server 才落这三张表。 --- @@ -17,6 +17,14 @@ | profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 | | 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 | +### 领券(每日领券联动 · 今日状态) +| App 位置 / 动作 | 表 | 说明 | +|---|---|---| +| 领券**过程**(看屏→领券) | (无) | 在 pricebot-backend 内存态跑,**过程不落库**;结果回 app-server 才落下面三张表 | +| 切外卖 App 时是否弹领券引导窗 | [`coupon_prompt_engagement`](./coupon_state.md) | 今天 engage 过(点领/点拒)就不再弹;判断维度 device_id | +| 首页「去领取」卡是否置灰 | [`coupon_daily_completion`](./coupon_state.md) | 今天跑完整轮(到 done)就置灰;判断维度 device_id | +| 每张券领取结果留痕 | [`coupon_claim_record`](./coupon_state.md) | 资产/画像/排查/CPS;当前**不参与**判断 | + ### 钱包 / 福利(看广告赚钱闭环) | App 位置 / 动作 | 表 | 说明 | |---|---|---| @@ -74,6 +82,13 @@ | 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 | | 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 | | 提交反馈 `POST /feedback` | `feedback`(C) | | +| 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, 北京日)` 幂等;best-effort | +| 领券每帧结果 `POST /api/v1/coupon/step` | `coupon_claim_record`(C/U) | `(device_id, coupon_id, 北京日)` 幂等;best-effort | +| 领券跑完 `POST /api/v1/coupon/step`(action.command=done) | `coupon_daily_completion`(C/U) | `(device_id, 北京日)` 幂等;best-effort | +| 拒绝领券引导窗 `POST /api/v1/coupon/prompt/dismiss` | `coupon_prompt_engagement`(C/U `dismissed`) | 同上;客户端通知(透传链路看不到拒绝) | +| 重置今日弹窗 `POST /api/v1/coupon/prompt/reset`(开发) | `coupon_prompt_engagement`(**D** 今日条) | 删后今天又能弹 | + +> 领券三表写库**全 best-effort**:`/coupon/step` 里写失败只 `logger.warning`、不连累领券返回;**判断只看 `device_id`**,`user_id` 可空旁路(资产留痕)。 ### admin 端(管理员触发,均额外写一条 `admin_audit_log`) | 后台操作 | 写入 | 操作 | @@ -120,6 +135,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`。 +- **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record` 的 `user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。 ### ER 关系(文字版) ``` @@ -132,6 +148,8 @@ user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_reco comparison_record ─1:N─ price_report (comparison_record_id, 可空) admin_user ─1:N─ admin_audit_log app_config (独立, 无外键, key 为主键) +coupon_prompt_engagement / coupon_daily_completion / coupon_claim_record + (独立, 无硬 FK; 维度=device_id+北京日, user_id/trace_id 仅软关联) ``` --- diff --git a/docs/database/README.md b/docs/database/README.md index 9220687..e4e6668 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -3,13 +3,13 @@ > 数据库: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(新增 3 张领券今日状态表 `coupon_*`;前补全 22 张业务表 + [OVERVIEW 总览](./OVERVIEW.md)) > 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。 --- -## 表总览(22 张业务表 + `alembic_version` 框架表) +## 表总览(25 张业务表 + `alembic_version` 框架表) ### 账号 / 反馈 | 表 | 用途 | 模型 | 文档 | @@ -41,6 +41,13 @@ | `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) | | `price_report` | 上报更低价(众包纠偏,人工审核发奖) | `models/price_report.py` | [详情](./price_report.md) | +### 领券(每日领券联动 · 今日状态) +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+日) | `models/coupon_state.py` | [详情](./coupon_state.md) | +| `coupon_daily_completion` | 首页「去领取」置灰源(今日是否已跑完整轮) | `models/coupon_state.py` | [详情](./coupon_state.md) | +| `coupon_claim_record` | 每张券领取结果沉淀(资产/画像/排查,不参与判断) | `models/coupon_state.py` | [详情](./coupon_state.md) | + ### 首页门面数据 | 表 | 用途 | 模型 | 文档 | |---|---|---|---| diff --git a/docs/database/coupon_state.md b/docs/database/coupon_state.md new file mode 100644 index 0000000..956f227 --- /dev/null +++ b/docs/database/coupon_state.md @@ -0,0 +1,123 @@ +# coupon_state — 领券今日状态三张表(弹窗频控 / 首页置灰 / 领券记录) + +> 模型 `app/models/coupon_state.py` · 仓库 `app/repositories/coupon_state.py` · 接口 `app/api/v1/coupon.py`(prefix `/api/v1/coupon`) · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +领券(优惠券自动化)联动产生的三张「今日状态」表,都挂在领券透传端点 `POST /api/v1/coupon/step` 这条链路上(pricebot 跑领券,结果回 app-server 落库;**领券过程本身在 pricebot 内存态跑、不落库**)。三表各管一件事: + +- **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, 自然日)` 记「今天是否对领券引导窗表达过**意向**」(点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天 engage 过就不再弹。 +- **`coupon_daily_completion`** — 首页置灰源。按 `(device, 自然日)` 记「今天是否已**跑完整轮**领券(到 done 帧)」。首页「去领取」卡据此置灰:今天跑完了就不能再领。 +- **`coupon_claim_record`** — 资产沉淀层。按 `(device, 券, 自然日)` 记每张券的领取结果(success/already_claimed/failed/skipped),**纯沉淀**(资产/画像/排查/CPS 归因),当前**不参与**「要不要领 / 弹不弹」的判断。 + +三表共同口径: +- **判断维度是 `device_id`,不是 `user_id`**:券发到的是设备上登录的那个外卖账号,device 比 user 更贴近「哪个登录环境」,且 `device_id` 全链路现成、不依赖领券鉴权(领券 MVP 阶段 `/coupon/step` 不鉴权)。客户端 `getOrCreateDeviceId` 生成存 SP,**卸载重装会变 → 当新设备重新弹一次**(产品预期)。 +- **日期 = `Asia/Shanghai` 自然日**(`claim_date` / `engage_date` / `complete_date`,`repositories/coupon_state.today_cn()`)。每日可领的券(签到/天天红包)靠这天然每天一条。 +- **`user_id` 可空**:领券登录态有就记(资产/画像),可空、**不进唯一键、不阻塞判断**。 +- **`trace_id` 可空**:回指 pricebot work_logs,供排查(哪次任务领的)。 +- **engagement vs completion vs claim 的区别**:engagement = 用户**表达过意向**(点了领或拒,不管跑没跑完);completion = 这一轮**真跑到了 done**(整套流程走完);claim = **每张券一条**的结果留痕。 + +> 写库全部 **best-effort**:在 `/coupon/step` 里写库失败只 `logger.warning`、**绝不连累领券主流程/返回**(见 `app/api/v1/coupon.py`)。 + +--- + +## coupon_prompt_engagement — 弹窗频控(今日是否已对引导窗表达意向) + +`(device_id, engage_date)` 唯一,一台设备一天一条;今天 engage 过(领或拒)就不再弹。 + +### 用在哪 / 增删改查 +- **C / U(幂等 upsert)**:`mark_engagement`。两条触发: + - `POST /api/v1/coupon/step` 且 `step==0`(领券首帧=用户已发起领券)→ 记 `claim_started`; + - `POST /api/v1/coupon/prompt/dismiss`(用户点关闭引导窗;server 在透传链路看不到「拒绝」,必须客户端通知)→ 记 `dismissed`。 + - 已有今天那条则覆盖 `engage_type`(并补 `user_id`),否则插入。 +- **D**:`POST /api/v1/coupon/prompt/reset`(`reset_today_engagement`)—— 删这台设备今天那条,开发设置「重置今日领券弹窗状态」按钮调,测频控用;删后今天又能弹。 +- **R**:`GET /api/v1/coupon/prompt/should-show?device_id=…`(`has_engaged_today`)→ `should_show = not 今天已 engage`。客户端切外卖 App 前查,纯后台判据。 + +### 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `device_id` | String(64) | NOT NULL | 判断/聚合维度;客户端 `getOrCreateDeviceId`,重装会变 | +| `user_id` | Integer | index, 可空 | 登录态有就记(资产);不进唯一键、不阻塞判断 | +| `engage_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`) | +| `engage_type` | String(16) | NOT NULL | `claim_started`(点一键领取)/ `dismissed`(点拒绝关闭);仅记录区分,**判断只看「今天有没有这条」,type 不影响弹不弹** | +| `created_at` | DateTime(tz) | server_default now() | | +| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | | + +### 索引与约束 +- PK `id`;index `user_id`;UNIQUE(`device_id`, `engage_date`) = `uq_coupon_engage_device_date`(一台设备一天一条)。 + +### 注意 +- `device_id` 重装会变 → 重装当新设备,今天重新弹一次(产品预期)。 +- 判断只看「今天这台设备有没有这条」,不看 `engage_type`(领或拒都算 engage 过、都不再弹)。 + +--- + +## coupon_daily_completion — 首页置灰(今日是否已跑完整轮领券) + +`(device_id, complete_date)` 唯一,一台设备一天一条;今天跑完整轮(到 done 帧)就把首页「去领取」卡置灰。 + +### 用在哪 / 增删改查 +- **C / U(幂等 upsert)**:`mark_completed_today`,由 `POST /api/v1/coupon/step` 在 pricebot 返回 `action.command == "done"` 那帧调。pricebot 把中途单券 done 改写成 `wait+continue=true`,只有整套全跑完那帧才保留 `command=="done"`,故 **done 已等价「整轮完成」**。已有今天那条则补 `user_id`/`trace_id`,否则插入。 +- **U / D**:无业务删除。 +- **R**:`GET /api/v1/coupon/completed-today?device_id=…`(`has_completed_today`)→ `completed`。客户端据此把首页「去领取」卡置灰、不可点。 + +### 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `device_id` | String(64) | NOT NULL | 判断维度,与 engagement/claim 一致;客户端两端都用 ANDROID_ID | +| `user_id` | Integer | index, 可空 | 登录态有就记(资产) | +| `complete_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`) | +| `trace_id` | String(64) | index, 可空 | 哪次任务跑到 done,回指 pricebot work_logs / 排查 | +| `created_at` | DateTime(tz) | server_default now() | | +| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | | + +### 索引与约束 +- PK `id`;index `user_id`、`trace_id`;UNIQUE(`device_id`, `complete_date`) = `uq_coupon_completion_device_date`(一台设备一天一条)。 + +### 注意 +- 口径(用户决策 2026-06-10 A 方案):**到 done 即算完成,不管单券成败**——失败/跳过常是无障碍/环境问题,重复点也补不回来。 +- 与 engagement 区别:engagement 是「表达过意向」(点了就记,不管跑没跑完);completion 是「真跑到了 done」。 + +--- + +## coupon_claim_record — 领券记录(每张券一天一条,资产沉淀层) + +`(device_id, coupon_id, claim_date)` 唯一,同设备同券同一天只一条;纯沉淀,**当前不参与判断**,留作以后按券去重 / CPS 归因 / 用户画像的数据源。 + +### 用在哪 / 增删改查 +- **C / U(幂等 upsert)**:`record_claims`,由 `POST /api/v1/coupon/step` 写入。一帧的券结果来自 pricebot 的 `last_coupon_result`(最后一张)+ `action.params.coupon_results`(全量)——**会重复带同一张券**,端点 `_extract_coupon_results` 先**按 `coupon_id` 去重**(全量覆盖单张),仓库再靠唯一键幂等:已有则更新 `status`/`reason`/`claimed_count`/`extra`(以最后一次为准),否则插入。 +- **U / D**:无业务删除。 +- **R**:**当前无读取端点**(纯写入沉淀,未来做去重/归因/画像时再用)。 + +### 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `device_id` | String(64) | NOT NULL | 聚合维度;客户端 `getOrCreateDeviceId`,重装会变 | +| `user_id` | Integer | index, 可空 | 登录态有就记(资产/画像);不进唯一键 | +| `coupon_id` | String(64) | NOT NULL | 券标识(取自 pricebot 结果) | +| `claim_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`);每日可领的券靠它天然每天一条 | +| `status` | String(24) | NOT NULL | `success` / `already_claimed` / `failed` / `skipped`(原样取 pricebot coupon 结果) | +| `vendor` | String(48) | 可空 | 券提供方 | +| `coupon_name` | String(128) | 可空 | 取 pricebot `name` | +| `claimed_count` | Integer | 可空 | 这张领到几张(pricebot `display_count`,给不出时 None;兼容 `claimed_count`) | +| `trace_id` | String(64) | index, 可空 | 哪次任务领的,回指 pricebot work_logs / 排查 | +| `reason` | String(255) | 可空 | failed / skipped 原因 | +| `extra` | JSON(PG JSONB) | 可空 | 杂项兜底:券的结构化信息(面额/入口/关键节点摘要等),免得加字段就迁移;当前直接存这帧 pricebot 单券结果 dict | +| `created_at` | DateTime(tz) | server_default now() | | +| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | | + +### 索引与约束 +- PK `id`;index `user_id`、`trace_id`;UNIQUE(`device_id`, `coupon_id`, `claim_date`) = `uq_coupon_claim_device_coupon_date`;Index(`device_id`, `claim_date`) = `ix_coupon_claim_device_date`(按 device+日 取一天所有券)。 + +### 注意 +- **`extra` 别塞原始无障碍树**(几十 KB → 行膨胀);原始大树看 `trace_id` 指过去的 work_logs。 +- 同批去重很关键:`autoflush=False` 下同 `coupon_id` 两次 `add` 会撞唯一约束、`IntegrityError` 回滚整批(done/单券记录全丢),故端点 `_extract_coupon_results` + 仓库 `seen` 集合双重防御。 +- `extra` 是 `JSON().with_variant(JSONB(), "postgresql")`:PG 用 JSONB(可建 GIN 索引),SQLite 退化通用 JSON(同 `price_observation` / `comparison_record`)。 + +--- + +## 三表共性小结 +- 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。 +- 唯一键都含 `device_id` + 某个北京自然日列;`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。 +- 无硬外键:`user_id` 软指 `user.id`、`trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。 -- 2.52.0 From 43e45baecffbe1ae5832493ed3decf6a01e12578 Mon Sep 17 00:00:00 2001 From: no_gen_mu Date: Thu, 11 Jun 2026 00:26:16 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(compare):=20=E6=AF=94=E4=BB=B7?= =?UTF-8?q?=E7=BB=93=E7=AE=97=E8=AE=B0=E5=BD=95=20schema=20+=20comparison?= =?UTF-8?q?=20=E6=A8=A1=E5=9E=8B=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支撑客户端比价结算页:补 compare_record schema 字段 + comparison 模型调整。 Co-Authored-By: Claude Opus 4.8 --- app/models/comparison.py | 2 +- app/schemas/compare_record.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/models/comparison.py b/app/models/comparison.py index 7a42a4f..12f91a8 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -90,7 +90,7 @@ class ComparisonRecord(Base): # ===== 明细(JSON,越详细越好)===== # 下单菜品 [{name, qty, specs?}] items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) - # 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名) + # 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name, applied_coupons}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名;applied_coupons=[{name,amount}] 多券明细) comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) # 目标平台未找到、跳过的菜名 skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 2a6ba5f..ce8a4a0 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -24,6 +24,13 @@ class ComparisonItemIn(BaseModel): specs: list[str] | None = None +class AppliedCouponIn(BaseModel): + """单笔已用优惠(来自 comparison_results[].applied_coupons)。amount 单位:元、正数。""" + + name: str + amount: float + + class ComparisonResultIn(BaseModel): """逐平台对比项(来自 done.params.comparison_results)。price 单位:元。""" @@ -40,6 +47,10 @@ class ComparisonResultIn(BaseModel): # 优惠**来源名**(展示用, best-effort): 美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"。 # None=没抠到 → 前端走通用"红包"。同样必须显式声明否则上报边界被 pydantic 静默丢弃(pricebot#38 引入)。 coupon_name: str | None = None + # 多券明细 [{name, amount}](全口径: 平台红包+商家券+满减+配送减免, amount 单位元正数)。 + # 跟 coupon_saved 并存, 是更丰富的明细; 空=没抠到 → 前端回退单券路径。 + # 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。 + applied_coupons: list[AppliedCouponIn] = Field(default_factory=list) class ComparisonRecordIn(BaseModel): -- 2.52.0