diff --git a/docs/database/README.md b/docs/database/README.md index e173694..5f93769 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -42,6 +42,8 @@ | `ad_ecpm_record` | 广告展示 eCPM 上报(收益对账) | `models/ad_ecpm.py` | [详情](./ad_ecpm_record.md) | | `ad_feed_reward_record` | 信息流/Draw 广告结算记录(10 秒一份,client_event_id 幂等;`ad_type`+`feed_scene` 分形态/场景) | `models/ad_feed_reward.py` | [详情](./ad_feed_reward_record.md) | | `ad_pangle_daily_revenue` | 穿山甲 GroMore 后台收益日表(定时拉取,收益报表/大盘真实收益源,#92) | `models/ad_pangle_revenue.py` | [详情](./ad_pangle_daily_revenue.md) | +| `inactivity_reset_log` | 15 天不活跃清零审计(每次清零一行;清零前三桶余额快照+原因+不活跃天数;只清金币+现金,邀请金仅快照) | `models/inactivity.py` | [详情](./inactivity_reset_log.md) | +| `inactivity_notification_log` | 不活跃清零前预警记录(余额快照+档位+通道+状态;streak 去重依据 + 占位 outbox) | `models/inactivity.py` | [详情](./inactivity_notification_log.md) | ### 比价 / 省钱 | 表 | 用途 | 模型 | 文档 | diff --git a/docs/database/inactivity_notification_log.md b/docs/database/inactivity_notification_log.md new file mode 100644 index 0000000..e5c3b6e --- /dev/null +++ b/docs/database/inactivity_notification_log.md @@ -0,0 +1,36 @@ +# inactivity_notification_log — 不活跃清零前预警记录 + +> 模型 `app/models/inactivity.py` · 仓库 `app/repositories/inactivity.py` · 通知器 `app/integrations/notifier.py` · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +清零前按可配置节奏(`INACTIVITY_WARN_DAYS_BEFORE`,默认清零前 7 天、2 天各一次)向用户预警"账户里的 xx 金币和 xx 现金将被清零"。每发一次预警写一行,记推送时的余额快照 + 提前天数档 + 通道 + 状态。兼作两用:**预警去重**依据(同 streak 内 `stage==k 且 created_at > last_active` 即已推过、不重推)与**占位 outbox**(v1 通道=`log`,只打日志不真推;后续接 JPush/短信同层扩展)。append-only,不更新。**预警只涉及会被清的金币 + 折算现金;邀请奖励金不清、不预警**(`invite_cash_balance_cents` 仅作账户状态快照)。 + +## 用在哪 / 增删改查 +- **C(插入)**:`inactivity.run_warn_once` 命中预警档、且本 streak 未推过时,调 `notifier.warn` 后写一行(`status` = 通知器返回,占位实现为 `placeholder`)。 +- **U / D**:无(append-only)。 +- **R**:预警去重查询(`user_id + stage + created_at > last_active`);未来接真实推送时作待推送 outbox。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | **PK**, autoincrement | 主键 | +| `user_id` | Integer | NOT NULL, index | 预警对象;只索引不设外键(同 `analytics_event`) | +| `stage` | Integer | NOT NULL | 提前天数档(如 `7` / `2`,即清零前第几天推) | +| `inactive_days` | Integer | NOT NULL | 推送时的不活跃天数(北京自然日) | +| `coin_balance` | Integer | NOT NULL | 推送时金币余额快照(将被清) | +| `cash_balance_cents` | Integer | NOT NULL | 推送时折算现金余额快照(分,将被清) | +| `invite_cash_balance_cents` | Integer | NOT NULL | 推送时**邀请奖励金**余额快照(分,**不清、不在预警额度内**) | +| `channel` | String(16) | NOT NULL | 通道:`log`(占位) / `jpush` / `sms` | +| `status` | String(16) | NOT NULL | 状态:`placeholder`(占位未真推) / `sent` / `failed` | +| `created_at` | DateTime(tz) | server_default now(), index | 推送时刻;去重比 `created_at > last_active`(用户回归后 `last_active` 前移 → 旧行自然失效、开启新 streak) | + +## 关系 / Join Key +- `user_id` → `user.id`(无外键直连,靠 `user_id` 关联)。 +- 与 `inactivity_reset_log` 无直接外键;同一 streak 内先有若干预警行,到期后有一行清零。 + +## 索引与约束 +- PK `id`;`ix_inactivity_notification_log_user_id`、`ix_inactivity_notification_log_created_at`。 + +## 注意 +- **预警去重按 streak**:判据是 `created_at > last_active`;用户一有活跃(`home_view`/比价/领券),`last_active` 前移,旧预警行"失效",回归后可重新进入预警。 +- **占位实现**:v1 `LogNotifier` 只 `logger.warning("[inactivity-warn] ...")`、返回 `placeholder`,不真推(参照心跳告警"本期先不接推送"先例)。 +- **漏跑补发**:worker 漏跑数天后某用户可能同时满足多档,只补发**最紧急的未推档**(最小提前天数),避免刷屏。 diff --git a/docs/database/inactivity_reset_log.md b/docs/database/inactivity_reset_log.md new file mode 100644 index 0000000..4de9578 --- /dev/null +++ b/docs/database/inactivity_reset_log.md @@ -0,0 +1,35 @@ +# inactivity_reset_log — 15 天不活跃清零审计 + +> 模型 `app/models/inactivity.py` · 仓库 `app/repositories/inactivity.py` · worker `app/core/inactivity_reset_worker.py` · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +连续 15 天不活跃(北京自然日,活跃口径见 `app/repositories/activity.py`:`home_view` + 发起比价 + 发起领券,**不含登录**)的用户,worker 每日自动清零其**金币 + 折算现金**。每清一个用户写一行,记清零前三桶余额快照 + 原因 + 判定时的活跃时间/不活跃天数,供纠纷排查。清零同时另写 2 条钱包流水(`coin_transaction` / `cash_transaction`,`biz_type=inactivity_reset`,`ref_id=` 本表 `id`),资金流可逐笔回溯、人工恢复。**邀请奖励金(`invite_cash_balance_cents`)是产品红线、不清零**,本表 `invite_cash_balance_cents_before` 仅为清零时仍保留的邀请金快照(非被清金额)。append-only,不更新。 + +## 用在哪 / 增删改查 +- **C(插入)**:`inactivity.clear_user` 逐用户清零(独立事务、行锁)时写一行,`db.flush()` 拿 `id` 作流水 `ref_id` 交叉链接。 +- **U / D**:无(append-only 审计)。 +- **R**:纠纷排查 / 对账(与 `coin_transaction` / `cash_transaction` 的 `ref_id` 交叉核对)。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | **PK**, autoincrement | 主键;作 `ref_id` 写入两条清零流水 | +| `user_id` | Integer | NOT NULL, index | 被清零用户;只索引不设外键(同 `analytics_event`,避免删用户级联 / 留历史) | +| `coin_balance_before` | Integer | NOT NULL | 清零前金币余额(个数);= 对应 `coin_transaction.amount` 绝对值 | +| `cash_balance_cents_before` | Integer | NOT NULL | 清零前折算现金余额(分);= 对应 `cash_transaction.amount_cents` 绝对值 | +| `invite_cash_balance_cents_before` | Integer | NOT NULL | 清零时的**邀请奖励金**余额快照(分)——**不清、原封保留**,仅记录以证明"未动邀请金" | +| `last_active_at` | DateTime(tz) | nullable | 判定时的最近活跃时刻(UTC);无任何活跃信号时兜底为 `user.created_at` | +| `inactive_days` | Integer | NOT NULL | 判定时的不活跃天数(北京自然日) | +| `reason` | String(32) | NOT NULL | 清零原因,如 `inactive_15d` | +| `reset_at` | DateTime(tz) | server_default now(), index | 清零时刻 | + +## 关系 / Join Key +- `user_id` → `user.id`(无外键直连,靠 `user_id` 关联)。 +- `id` → `coin_transaction.ref_id` / `cash_transaction.ref_id`(`biz_type=inactivity_reset`):审计行 ↔ 资金流水交叉对账。 + +## 索引与约束 +- PK `id`;`ix_inactivity_reset_log_user_id`(按用户查)、`ix_inactivity_reset_log_reset_at`(按时间查)。 + +## 注意 +- **只清 2 桶**:金币 + 折算现金;**邀请现金不清**(两本账物理隔离,见 [`coin_account`](./coin_account.md) / `wallet.CoinAccount` 注释)。 +- **天然幂等**:清完余额=0,次日不再匹配;worker 重启 / 多次唤醒 / 补跑都不会重复清零或重复流水。 +- **总闸默认关**(`INACTIVITY_RESET_ENABLED=false`),灰度验证清零名单后再开。 diff --git a/docs/superpowers/plans/2026-07-16-inactivity-reset.md b/docs/superpowers/plans/2026-07-16-inactivity-reset.md index 648ab49..e715de9 100644 --- a/docs/superpowers/plans/2026-07-16-inactivity-reset.md +++ b/docs/superpowers/plans/2026-07-16-inactivity-reset.md @@ -2,7 +2,9 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** 连续 15 天不活跃(北京自然日、第 16 日 0 点对齐)的用户,自动清零金币+折算现金+邀请现金,清零前按可配置节奏预警,全程留审计。 +**Goal:** 连续 15 天不活跃(北京自然日、第 16 日 0 点对齐)的用户,自动清零金币+折算现金(**邀请现金不清**——产品红线),清零前按可配置节奏预警,全程留审计。 + +> **更新(2026-07-18):** 邀请现金(invite_cash_balance_cents)由"三桶全清"改为**不清**——遵循 wallet.CoinAccount 的"物理隔离/产品红线"、与另一实现分支对齐。**以 `inactivity.clear_user` 现码为准**;下方 Task 7 的历史代码片段仍写三桶归零,阅读时以现码为准。 **Architecture:** 活跃口径抽成共享模块 `app/repositories/activity.py`(worker 与 admin 共用,单一真源);业务逻辑在 `app/repositories/inactivity.py`(纯同步、可单测);进程内每日 asyncio worker `app/core/inactivity_reset_worker.py`(仿 `daily_exchange_worker`,文件锁 + 北京日守卫 + 总闸)。预警走可插拔 `InactivityNotifier`,v1 为日志占位。审计双写:`inactivity_reset_log` 专表 + 钱包流水 `biz_type=inactivity_reset`。 diff --git a/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md b/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md index 6d1b2a1..91cef0c 100644 --- a/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md +++ b/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md @@ -40,7 +40,7 @@ |---|---|---| | **活跃口径** | 与"用户管理"一致:`max(home_view, 比价, 领券)`,**不含 last_login_at**;无任何信号时以 `created_at` 为非空基线 | 比价可从**浮窗**触发、不进首页;`last_login_at` 只在登录/换绑动作更新(re-login 也算),代表不了"在用 App",故彻底排除 | | **"进首页"信号落地** | **方案 A:前端上报 `home_view` 埋点**(复用 `/analytics/events`),非新接口 | 三个活跃信号统一为同类埋点事件;零新接口零新列;与 admin 口径天然一致。B(鉴权接口 + 列)"更权威"的优势是假的——比价/领券仍是端上报事件,最弱环决定整体可信度 | -| **清零范围** | **金币 + 折算现金 + 邀请现金** 三桶全清 | 对应"账户里的金币和现金" | +| **清零范围** | **金币 + 折算现金**(**邀请现金不清**——产品红线,仅快照入审计) | 对应"账户里的金币和现金";邀请奖励金与金币现金物理隔离、不可累加,见 `wallet.CoinAccount` 注释 | | **预警推送** | **可插拔通知器 + 日志占位**(v1),后续接 JPush/短信 | 现状无真实推送能力;先把清零主流程 + 审计做扎实,不阻塞 | | **预警时机** | **完全可配置**(提前天数列表 + 次数 + 执行点 + 通道) | R5 | | **触发方式** | **进程内每日 worker**,仿 `daily_exchange_worker` | 与项目最新模式一致,无需外部 cron | @@ -82,7 +82,7 @@ last_active = max( - `last_active_expr(base_col, ev_sub, eng_sub, dialect)` —— 生成 `greatest`/`max`(PG `func.greatest`/SQLite `func.max`);子聚合缺失时 `coalesce(子聚合, User.created_at)` 兜底(注册基线恒非空,**替代原 last_login_at**)。 - `_norm_utc()` —— 沿用现 admin 的 naive→UTC 归一(SQLite naive / PG aware 混算保护)。 - `reset_cutoff(reset_days)` / `warn_cutoff(reset_days, k)` —— 生成**北京 0 点对齐**的边界 datetime(见口径):`reset_cutoff = 北京 00:00 of (cn_today() − (reset_days−1))`,供下面查询按 `last_active < cutoff` 比较。 -- `select_inactive_users(db, *, cutoff, with_balance=True)` —— **worker 专用**:join `CoinAccount`,筛 `last_active < cutoff`(cutoff = 北京 0 点对齐边界,见口径)且(`coin_balance>0 OR cash_balance_cents>0 OR invite_cash_balance_cents>0`),返回 `(user, account, last_active, inactive_days)`。 +- `select_inactive_users(db, *, cutoff, with_balance=True)` —— **worker 专用**:join `CoinAccount`,筛 `last_active < cutoff`(cutoff = 北京 0 点对齐边界,见口径)且(`coin_balance>0 OR cash_balance_cents>0`;**邀请现金不清、不计入候选**),返回 `(user, account, last_active, inactive_days)`。 - `select_warn_targets(db, *, reset_days, warn_days_before)` —— **worker 专用**:返回 `(user, account, last_active, inactive_days, stage)` 元组——各"提前天数"窗口内、有余额、本 streak 未推过档 `stage` 的用户(去重结合 `notification_log`,逻辑见 §9)。 > **参考现状**:现口径散落在 `app/admin/repositories/queries.py:38,91-124,199-204`(`_ACTIVE_EVENTS`/`_last_active_parts`/`greatest`)与 `app/admin/repositories/stats.py:51-52,138-146`(`COMPARE_START_EVENT`/`COUPON_START_EVENT`/活跃用户集)。这些改为从 `activity.py` 导入(§12)。 @@ -155,21 +155,21 @@ for user, acc, last_active, inactive_days in activity.select_inactive_users(db, try: acc = wallet.get_or_create_account(db, user.id, commit=False, lock=True) # 行锁 before = (acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents) - if before == (0,0,0): continue + if acc.coin_balance == 0 and acc.cash_balance_cents == 0: continue # 邀请现金不清,不算可清余额 log = InactivityResetLog(user_id=user.id, coin_balance_before=before[0], - cash_balance_cents_before=before[1], invite_cash_balance_cents_before=before[2], + cash_balance_cents_before=before[1], invite_cash_balance_cents_before=before[2], # 邀请现金仅快照 last_active_at=last_active, inactive_days=inactive_days, reason=f"inactive_{RESET_DAYS}d") db.add(log); db.flush() # 拿 log.id 作 ref_id 交叉链接 if acc.coin_balance: wallet.grant_coins(db, user.id, -acc.coin_balance, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零") if acc.cash_balance_cents: wallet.grant_cash(db, user.id, -acc.cash_balance_cents, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零") - if acc.invite_cash_balance_cents: wallet.grant_invite_cash(db, user.id, -acc.invite_cash_balance_cents, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零") + # 邀请现金(invite_cash_balance_cents)不清:产品红线、两本账物理隔离,仅快照记入审计。 db.commit() except SQLAlchemyError: db.rollback(); stats["failed"] += 1 ``` -- `grant_*` 负数出账、`balance_after=0`、写三条流水;`grant_coins` 负数**不**动 `total_coin_earned`(历史累计保留)。 -- 逐用户独立 commit:一个失败不影响其余。返回 `stats = {scanned, warned, cleared, failed}` 并 `logger.info`。 +- `grant_*` 负数出账、`balance_after=0`、写**两条**流水(金币 + 折算现金;**邀请现金不清**);`grant_coins` 负数**不**动 `total_coin_earned`(历史累计保留)。 +- 逐用户独立 commit:一个失败不影响其余。返回 `stats = {warned, warn_skipped, warn_failed, scanned, cleared, failed}` 并 `logger.info`。**预警逐用户 try/except 隔离、且预警整段异常也绝不阻塞清零**(清零是不可逆资金操作,不能被通知故障拖住)。 ---