更新文档0709 (#128)

Reviewed-on: #128
This commit was merged in pull request #128.
This commit is contained in:
2026-07-09 14:24:46 +08:00
parent 0cf5b3816f
commit fa4127b9e5
43 changed files with 756 additions and 279 deletions
+69 -28
View File
@@ -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、**无关系库**。共 **40 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表;比价结果分两路——客户端带 JWT 上报「我的记录」落 `comparison_record`,pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)+ 启动确认窗兜底样本落 `launch_confirm_sample`。此外好友邀请(`invite_*` 2 张)与美团 CPS 群发联盟(`cps_*` 6 张,含落地页微信身份 `cps_wx_user`)是两个独立子系统。无障碍存活监控 `device_liveness`(#65)按用户设备维度记心跳、检掉线召回。
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **45 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表 + `coupon_session` 任务流水(#99);比价记录 2026-07 起**由 app-server 透传壳 harvest 直接落库**(`comparison_record`:帧0 建 running 行 → done/finalize 写终态,老客户端带 JWT 的 `POST /compare/record` 仅作兜底),pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)+ 启动确认窗兜底样本落 `launch_confirm_sample`。此外好友邀请(`invite_*` 2 张 + 独立账本 `invite_cash_transaction`)与 CPS 群发联盟(`cps_*` 6 张,含落地页微信身份 `cps_wx_user`;`cps_order` 已含京东联盟单)是两个独立子系统。无障碍存活监控 `device_liveness`(#65)按用户设备维度记心跳、检掉线召回;客户端埋点落 `analytics_event`(#83)
---
@@ -12,7 +12,7 @@
| App 位置 / 动作 | 表 | 说明 |
|---|---|---|
| 比价/领券**过程**(看屏→决策→操作) | (无) | 在 pricebot-backend 内存态跑,**过程不落库**;只有结果回到 app-server 才落库 |
| 「我的比价记录」列表 / 详情 | [`comparison_record`](./comparison_record.md) | 每次比价 done 后客户端带 JWT 上报一条完整明细 |
| 「我的比价记录」列表 / 详情 | [`comparison_record`](./comparison_record.md) | **app-server 透传壳 harvest 落库**(2026-07 起):帧0 建 `running` 行 → done 帧写 success/failed → `trace/finalize` 写 cancelled/failed;老客户端带 JWT 的 `POST /compare/record` 兜底 |
| 比价战绩里程碑(逐档领金币) | [`comparison_milestone_claim`](./comparison_milestone_claim.md) | 累计成功比价 N 次解锁;进度读 `comparison_record` 计数 |
| profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 |
| 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 |
@@ -27,6 +27,7 @@
| 切外卖 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;当前**不参与**判断 |
| (admin 领券看板)一次领券任务全程流水 | [`coupon_session`](./coupon_session.md) | 客户端 `POST /coupon/session` 两段上报(发起建行/收尾更新);发起数、完成率、中途流失、平均耗时都从这算(#99) |
### 钱包 / 福利(看广告赚钱闭环)
| App 位置 / 动作 | 表 | 说明 |
@@ -37,7 +38,9 @@
| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 7 天循环发币;签到后看广告可膨胀一次 |
| 一次性任务(开消息提醒等) | [`user_task`](./user_task.md) | 领一次发币 |
| 看激励视频赚金币 | [`ad_reward_record`](./ad_reward_record.md) + [`ad_watch_log`](./ad_watch_log.md) + [`ad_ecpm_record`](./ad_ecpm_record.md) | 独立数据流:发奖 / 旧版观看时长 / 收益对账 |
| 信息流广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账 |
| 信息流/Draw 广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账;`ad_type`(feed/draw)+`feed_scene`(compare/coupon)分形态/场景 |
| (无 App UI)穿山甲后台收益对账 | [`ad_pangle_daily_revenue`](./ad_pangle_daily_revenue.md) | 定时脚本拉 GroMore 数据 API 落日表(#92);admin 收益报表/大盘的「真实收益」侧 |
| 好友比价并下单发奖 / 邀请奖励金 | [`invite_cash_transaction`](./invite_cash_transaction.md) + `coin_account.invite_cash_balance_cents` | 与现金**物理隔离**的第二本账(#82/#113):`POST /order/report` 触发 `invite_reward` 入账;`source=invite_cash` 提现出账 |
| 金币兑现金 | `coin_account` + `coin_transaction` + `cash_transaction` | exchange_out + exchange_in 两笔流水 |
| 提现到微信零钱 | [`withdraw_order`](./withdraw_order.md) + [`wechat_transfer_authorization`](./wechat_transfer_authorization.md) + `cash_transaction` | 人工审核 + 微信商家转账 |
| 绑定微信(提现前置) | `user`.wechat_* | openid 唯一,一微信一账号 |
@@ -53,8 +56,9 @@
### 好友邀请(注册增长)
| App 位置 / 动作 | 表 | 说明 |
|---|---|---|
| 输入/剪贴板邀请码绑定 | [`invite_relation`](./invite_relation.md) | 注册即生效,邀请人+被邀请人各发 1 万金币;`invitee_user_id` 唯一=幂等防重复发奖 |
| 输入/剪贴板邀请码绑定 | [`invite_relation`](./invite_relation.md) | 注册即生效(**绑定不发奖**,#113);`invitee_user_id` 唯一;`compare_reward_granted` 幂等闸=好友**比价并下单**后才给邀请人发奖励金 |
| 落地页访问指纹(剪贴板归因兜底) | [`invite_fingerprint`](./invite_fingerprint.md) | 剪贴板没拿到码时,用 (ip+机型+屏幕) 7 天内反查邀请人 |
| 邀请奖励金入账/提现 | [`invite_cash_transaction`](./invite_cash_transaction.md) | 独立账本(见上钱包节);余额在 `coin_account.invite_cash_balance_cents` |
### 美团 CPS 群发联盟(私域社群比价,运营后台驱动 · 群发选品→点击→对账漏斗)
| 后台/用户动作 | 表 | 说明 |
@@ -66,10 +70,19 @@
| 微信内打开落地页授权 | [`cps_wx_user`](./cps_wx_user.md) | 服务号网页授权拿 openid(base 静默)/ 昵称头像 unionid(userinfo,点领券触发),记首次来源群 |
| 定时拉美团联盟订单对账 | [`cps_order`](./cps_order.md) | `query_order` 按 sid 归群,串成点击→下单→佣金漏斗 |
### 埋点 / 首页门面 / 选品缓存
| 位置 / 动作 | 表 | 说明 |
|---|---|---|
| 客户端行为埋点 | [`analytics_event`](./analytics_event.md) | `POST /analytics/events` 批量上报,一事件一行(#83);admin「埋点日志」检索 |
| 首页三统计数字 | [`ops_stat_config`](./ops_stat_config.md) | real/manual/random 三模式,admin 配 |
| 首页轮播(省钱动态) | [`ops_marquee_seed`](./ops_marquee_seed.md) | 种子条目,与真实 `comparison_record` 混播(数据源模式 mixed/real/seed 可切) |
| 首页推荐/销量榜离线选品 | [`meituan_coupon`](./meituan_coupon.md) | 美团 CPS 券缓存,定时 ETL 灌入;`feed?tab=rec``top-sales` 纯库出、不实时打美团 |
### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权)
| 后台模块 | 表 | 说明 |
|---|---|---|
| 管理员账号 / 登录 | [`admin_user`](./admin_user.md) | 与 C 端 `user` 完全隔离,独立 JWT + RBAC |
| 管理员账号 / 登录 | [`admin_user`](./admin_user.md) | 与 C 端 `user` 完全隔离,独立 JWT + RBAC;`pages_override` 个人可见页覆盖 |
| 角色 / 可见页配置 | [`admin_role`](./admin_role.md) | 内建三角色 + 自定义角色(#117/#126);`admin_user.role` 按名引用 |
| 操作审计 | [`admin_audit_log`](./admin_audit_log.md) | 每个写操作落一条,只增不改不删 |
| 运营可配置项(改奖励常量) | [`app_config`](./app_config.md) | 空表 = 用代码默认;后台改了即覆盖 |
| 用户/钱包/提现/反馈管理 | 跨读写上面的 C 端表 | 见下「写入路径」admin 段 |
@@ -92,8 +105,8 @@
| 签到膨胀 `POST /signin/boost` | `signin_boost_record`(C) + `coin_account`(U) + `coin_transaction`(C `signin_boost`) | 同事务;同日一次 |
| 领任务 `POST /tasks/claim` | `user_task`(C) + `coin_account`(U) + `coin_transaction`(C `task_<key>`) | 同事务 |
| 金币兑现金 `POST /wallet/exchange` | `coin_account`(U) + `coin_transaction`(C `exchange_out` ) + `cash_transaction`(C `exchange_in` +) | 同事务 |
| 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`) + `coin_account`(U 扣现金) + `cash_transaction`(C `withdraw` ) | 同事务,**不打款** |
| 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→`cash_transaction`(C `withdraw_refund` +) | |
| 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`,记 `source`) + `coin_account`(U 按 source 扣对应余额) + 流水(C :`cash_transaction.withdraw``invite_cash_transaction.invite_withdraw`) | 同事务,**不打款**;#121`source` 分账 |
| 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→对应账本退款流水(C `withdraw_refund` / `invite_withdraw_refund` +) | |
| 穿山甲发奖 S2S 回调 `POST /ad/pangle-callback` | `ad_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `reward_video`/`signin_boost`) | `trans_id` 幂等 |
| 看广告时长上报 `POST /ad/watch-report` | `ad_watch_log`(C) | |
| 广告 eCPM 上报 `POST /ad/ecpm-report` | `ad_ecpm_record`(C) | |
@@ -101,13 +114,17 @@
| 注册设备 / 更新 push token `POST /device/register` | `device_liveness`(C/U upsert) | `(user_id, device_id)` 幂等;只在传入非空时更新 `registration_id`/版本 |
| 无障碍服务心跳 `POST /device/heartbeat` | `device_liveness`(C/U) | 心跳也能自注册;`accessibility_enabled=true` 时刷 `last_heartbeat_at`、置 `alive`、清 `notified_at` |
| 客户端 ack 掉线提醒 `POST /device/liveness/ack` | `device_liveness`(U) | 清 `kill_alert_pending`(幂等;下次真掉线 worker 再置) |
| 比价 done 上报 `POST /compare/record` | `comparison_record`(C 或 U) | `(user_id, trace_id)` 幂等覆盖 |
| 比价透传(帧0 / done / finalize)`POST /intent/recognize``/price/step``/trace/finalize` | `comparison_record`(C `running` → U 终态) | **harvest 三段式**(2026-07):帧0 mint trace_id 建 running 行 → done 写 success/failed → finalize 写 cancelled/failed(不降级 success);best-effort,写失败不连累透传 |
| 比价 done 上报 `POST /compare/record`(老客户端兜底) | `comparison_record`(C 或 U) | `trace_id` 幂等覆盖(唯一键已从 `(user_id,trace_id)` 改单列) |
| 领里程碑 `POST /compare/milestone/claim` | `comparison_milestone_claim`(C) | **当前不发币**(coin_awarded=0) |
| 支付归因上报 `POST /order/report` | `savings_record`(C `source=compare`) | `(user_id, client_event_id)` 幂等 |
| 支付归因上报 `POST /order/report` | `savings_record`(C `source=compare`)+ 触发邀请发奖:`invite_relation`(U `compare_reward_granted`)+`coin_account`(U invite_cash)+`invite_cash_transaction`(C `invite_reward`) | `(user_id, client_event_id)` 幂等;#113 邀请发奖口径=好友**比价并下单**,`compare_reward_granted` 幂等闸,同事务 |
| 批量埋点 `POST /analytics/events` | `analytics_event`(C 批量) | 不强制登录;每批 ≤200 条 |
| 领券任务流水 `POST /coupon/session` | `coupon_session`(C/U upsert) | `trace_id` 幂等:发起建行、收尾更新同一行(#99) |
| 重置新手引导 `POST /user/onboarding/reset` | `onboarding_completion`(**D** 该 设备+账号 行) | #114,删完成标记 → 下次登录重走引导 |
| 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 |
| 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 |
| 提交反馈 `POST /feedback` | `feedback`(C) | |
| 绑定邀请 `POST /invite/bind` | `invite_relation`(C `effective`) + `coin_account`(U×2) + `coin_transaction`(C `invite_inviter` + `invite_invitee`) | 同事务;`invitee_user_id` 唯一幂等,双方各发 1 万金币 |
| 绑定邀请 `POST /invite/bind` | `invite_relation`(C `effective`) | `invitee_user_id` 唯一幂等。**#113 起绑定不发奖**——发奖延后到好友比价并下单(`POST /order/report` 行),发的是邀请奖励金非金币 |
| 落地页归因 `POST /invite/landing-track` | `invite_fingerprint`(C) | 剪贴板归因兜底线索,登录后用 (ip+机型+屏幕) 反查 |
| 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, package, 北京日)` 幂等;best-effort |
| 领券每帧结果 `POST /api/v1/coupon/step` | `coupon_claim_record`(C/U) | `(device_id, coupon_id, 北京日)` 幂等;best-effort |
@@ -121,11 +138,17 @@
| 后台操作 | 写入 | 操作 |
|---|---|---|
| 手动增减金币 | `coin_account`(U)+`coin_transaction`(C `admin_grant`/`admin_deduct`)+`admin_audit_log`(C) | 同事务 |
| 改用户状态(禁用/启用) | `user`(U)+`admin_audit_log`(C) | 同事务 |
| 审核通过提现 | `withdraw_order`(U→pending/success/failed)+`wechat_transfer_authorization`(C/U)+失败时`cash_transaction`(refund)+`admin_audit_log`(C) | |
| 审核拒绝提现 | `withdraw_order`(U→rejected)+`cash_transaction`(C `withdraw_refund`)+`admin_audit_log`(C) | |
| 处理反馈 | `feedback`(U)+`admin_audit_log`(C) | 同事务 |
| 改运营配置 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 |
| 手动增减现金(#95 支持目标账户) | `coin_account`(U 按 `account` 选列)+ 流水(C:`cash_transaction``invite_cash_transaction`,`admin_grant`/`admin_deduct`)+`admin_audit_log`(C) | 同事务;`account=coin_cash`/`invite_cash` 两本账各调各 |
| 改用户状态(禁用/启用)/ 调试链接权限 | `user`(U `status` / `debug_trace_enabled`)+`admin_audit_log`(C) | 同事务 |
| 审核通过提现(含批量) | `withdraw_order`(U→pending/success/failed)+`wechat_transfer_authorization`(C/U)+失败时按 source 退款流水+`admin_audit_log`(C) | |
| 审核拒绝提现(含批量) | `withdraw_order`(U→rejected)+按 source 退款流水(C `withdraw_refund`/`invite_withdraw_refund`)+`admin_audit_log`(C) | |
| 反馈采纳/拒绝/标记处理 | `feedback`(U→adopted/rejected/handled + `admin_reply`/`reject_reason`)+采纳发币时 `coin_account`(U)+`coin_transaction`(C)+`admin_audit_log`(C) | 同事务(#94/#105) |
| 上报更低价审核 | `price_report`(U→approved/rejected)+通过发币 `coin_account`(U)+`coin_transaction`(C)+`admin_audit_log`(C) | 同事务 |
| 改运营配置 / 广告配置 / 反馈页二维码 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 |
| 轮播种子/数据源模式 | `ops_marquee_seed`(C/U/D)+`app_config`(模式 mixed/real/seed)+`admin_audit_log`(C) | #122/#123 预览/浏览只读 |
| 角色管理(#117/#126) | `admin_role`(C/U/D)+`admin_audit_log`(C) | 内建/在用角色不可删 |
| 管理员管理 | `admin_user`(C/U/**D** #126)+`admin_audit_log`(C) | 删除为物理删,不可删自己 |
| CPS 建群/活动/生成短链/拉单对账 | `cps_group`/`cps_activity`(C/U/D)、`cps_link`(C)、`cps_order`(C/U upsert;美团 `query_order` + 京东联盟 #90) | 见 CPS 段 |
| 任意写操作 | `admin_audit_log`(C,**永不 U/D**) | |
> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U(领券 `/prompt/reset`、`/completed-today/reset` 是开发用删除,非业务流程)。
@@ -137,7 +160,9 @@
| 后台批量生成短链 `POST /admin/api/cps/referral-links` | `cps_link`(C) | 每 群×活动 一条;美团经 `sid` 转链拿 `target_url`(同群同活动重复生成产生多条) |
| 用户点群发短链 `GET /c/{code}` / `POST /c/{code}/copy` | `cps_click`(C `visit`/`copy`) | 公开端点不鉴权;`group_id`/`sid` 从 link 冗余进来免 join |
| 微信落地页授权回调 `GET /wx/oauth/cb` | `cps_wx_user`(C/U upsert) | 按 `openid` 幂等;base 拿 openid,userinfo 补昵称/unionid(非 None 才覆盖);任何失败兜底回落地页不阻断领券 |
| 定时拉美团联盟订单对账 | `cps_order`(C/U upsert) | `query_order``sid` 归群;`order_id` 幂等(状态会变,重复拉则更新) |
| 定时拉联盟订单对账(美团 `query_order` + 京东联盟 #90) | `cps_order`(C/U upsert) | 按 `sid` 归群;`order_id` 幂等(状态会变,重复拉则更新);京东单 `platform='jd'` + `jd_valid_code` 判有效 |
| 定时拉穿山甲 GroMore 后台收益(#92,systemd 每天 10:30) | `ad_pangle_daily_revenue`(C/U upsert) | 收益报表/大盘「真实收益」侧;与客户端上报的 eCPM 侧互为对照 |
| 定时 ETL 灌美团 CPS 选品库 | `meituan_coupon`(C/U) | `feed?tab=rec` / `top-sales` 纯库出的数据源 |
| pricebot 比价 done 内部上报 `POST /internal/price-observation` | `price_observation`(C 批量) | `(trace_id,platform,scope)` 幂等;**不走 JWT、靠 `X-Internal-Secret`**(未配→503) |
| pricebot 比价 done 内部上报 `POST /internal/store-mapping` | `store_mapping`(C/U 填空合并) | `trace_id` 幂等;另有 `lookup` 反查 + `invalidate` deeplink 失效标记(淘宝/京东) |
| pricebot LLM 兜底放行启动确认窗后上报 `POST /internal/launch-confirm-sample` | `launch_confirm_sample`(C) | **都上报、不去重**;靠 `X-Internal-Secret`(未配→503) |
@@ -150,7 +175,7 @@
## 三、表间关系 & Join Key
### 硬外键(数据库 FK 约束)
- **18 张用户维度表 `.user_id``user.id`**:`coin_account`(同时是 PK)、`coin_transaction``cash_transaction``withdraw_order``wechat_transfer_authorization`(同时是 PK)、`signin_record``signin_boost_record``user_task``comparison_record``comparison_milestone_claim``savings_record``ad_reward_record``ad_watch_log``ad_ecpm_record``ad_feed_reward_record``price_report``feedback``device_liveness`
- **19 张用户维度表 `.user_id``user.id`**:`coin_account`(同时是 PK)、`coin_transaction``cash_transaction``invite_cash_transaction``withdraw_order``wechat_transfer_authorization`(同时是 PK)、`signin_record``signin_boost_record``user_task``comparison_record`(2026-07 起 `user_id` **可空**——harvest 帧0 建行时软鉴权可能拿不到)`comparison_milestone_claim``savings_record``ad_reward_record``ad_watch_log``ad_ecpm_record``ad_feed_reward_record``price_report``feedback``device_liveness`
- `admin_audit_log.admin_id``admin_user.id`
- `price_report.comparison_record_id``comparison_record.id`(可空:关联记录被删后仍留上报历史)。
- **邀请两表** → `user.id`:`invite_relation.inviter_user_id``invite_relation.invitee_user_id`(唯一)、`invite_fingerprint.inviter_user_id`——注意 FK 列名是 `inviter`/`invitee_user_id`,不是 `user_id`
@@ -173,13 +198,24 @@
| biz_type | ref_id 指向 | amount 符号 |
|---|---|---|
| `withdraw` / `withdraw_refund` | `withdraw_order.out_bill_no` | / + |
| `withdraw` / `withdraw_refund` | `withdraw_order.out_bill_no`(`source=coin_cash` 的单) | / + |
| `exchange_in` | null | + |
| `admin_grant` / `admin_deduct` | null(原因在 `remark`=`admin:<reason>`) | + / |
- **`invite_cash_transaction.ref_id`**(邀请奖励金账本,#82):
| biz_type | ref_id 指向 | amount 符号 |
|---|---|---|
| `invite_reward` | 被邀请人 `user.id`(字符串) | + |
| `invite_withdraw` / `invite_withdraw_refund` | `withdraw_order.out_bill_no`(`source=invite_cash` 的单) | / + |
| `admin_grant` / `admin_deduct` | null | + / |
- **`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))。
- **领券三表无硬 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))。[`coupon_session`](./coupon_session.md)(#99)同口径:`trace_id` 唯一 upsert、`user_id` 软指(admin 明细 LEFT JOIN 出手机号)。
- **`analytics_event`**(#83)与 **`coupon_session`** 均无硬 FK:埋点/流水不鉴权也收,`device_id`/`user_id` 只作维度。
- **`admin_user.role` 按名语义引用 `admin_role.name`**(无硬 FK,#117):删除保护在应用层(在用/内建角色不可删);个人 `pages_override` 优先于角色 `pages`
- **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。
- **CPS 群发 6 表全靠 `sid` / id / `openid` 语义串联(无硬 FK)**:`cps_link.group_id``cps_group.id``cps_link.activity_id``cps_activity.id``cps_click.link_id``cps_link.id``cps_wx_user.first_group_id``cps_group.id`;**点击与订单无法对到单笔**,只在群维度(`sid`)汇合——`cps_order.sid``cps_group.sid``cps_link.sid``cps_click.sid`(仅美团有 sid,淘宝/京东无)。统计按群聚合,故 `group_id`/`sid` 冗余进 `cps_click` 免 join。`cps_wx_user``openid` 自成用户身份维度,与 `cps_click`/`cps_order` 无 id 级 join。
- **比价沉淀两表(`price_observation` / `store_mapping`)**:`trace_id` 软指 pricebot work_logs(与 `comparison_record.trace_id` 同源但不互 join,各存各视角);`source_user_id` / `source_device_id` 软指用户/设备(可空,匿名也记)。`store_mapping.lookup` 靠**店名字符串精确相等** + geo 取最近,非 id 级 join。
@@ -188,10 +224,10 @@
```
user ─1:1─ coin_account
user ─1:1─ wechat_transfer_authorization
user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_record,
signin_boost_record, user_task, comparison_record, comparison_milestone_claim,
savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record,
price_report, feedback, device_liveness }
user ─1:N─ { coin_transaction, cash_transaction, invite_cash_transaction, withdraw_order,
signin_record, signin_boost_record, user_task, comparison_record(user_id 可空),
comparison_milestone_claim, savings_record, ad_reward_record, ad_watch_log,
ad_ecpm_record, ad_feed_reward_record, price_report, feedback, device_liveness }
(device_liveness 硬 FK; (user_id,device_id) 唯一)
user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,device_id) 去重)
comparison_record ─1:N─ price_report (comparison_record_id, 可空)
@@ -199,6 +235,11 @@ 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 仅软关联)
coupon_session (独立, 无硬 FK; trace_id 唯一=一次领券任务一行, #99)
analytics_event (独立, 无硬 FK; 埋点事件流, device/user 仅维度, #83)
admin_role ◀──语义(role 按 name 引用, 无FK)── admin_user (pages_override 个人覆盖, #117/#126)
ops_stat_config / ops_marquee_seed / meituan_coupon / ad_pangle_daily_revenue
(独立运营/缓存/对账表, 无外键)
user ─1:N─ invite_relation (inviter_user_id 硬 FK); invitee_user_id ─1:1─ user (唯一硬 FK)
user ─1:N─ invite_fingerprint (inviter_user_id 硬 FK)
cps_activity / cps_group ──语义(无FK)──▶ cps_link ─1:N─ cps_click
@@ -212,13 +253,13 @@ launch_confirm_sample (独立, 无硬 FK; 都上报不去
## 四、资金模型(金币 / 现金 / 提现,三层)
1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分),一用户一行,读取展示用。
2. **流水账本** `coin_transaction` / `cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。
3. **唯一发金币入口** `repositories/wallet.grant_coins`:更新快照 + 写流水,**不 commit**,由调用方同一事务 commit(保证"记录"和"加币"原子化)。signin / signin_boost / task / ad_reward / feed_ad_reward / exchange / admin 都走它,靠 `biz_type` 区分来源。
1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分)+ `invite_cash_balance_cents`(邀请奖励金分,#82),一用户一行,读取展示用。
2. **流水账本** `coin_transaction` / `cash_transaction` / `invite_cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。**现金与邀请奖励金是两本物理隔离的账**——发放口径与提现对账各自独立。
3. **唯一变动入口**:金币走 `repositories/wallet.grant_coins`,邀请奖励金走 `grant_invite_cash`——都是「更新快照 + 写流水,**不 commit**,由调用方同一事务 commit。signin / signin_boost / task / ad_reward / feed_ad_reward / exchange / admin `grant_coins`;`invite_reward` / admin 调整走 `grant_invite_cash`,靠 `biz_type` 区分来源。
- **汇率**:`10000 金币 = 1 元 = 100 分`(`rewards.COIN_PER_YUAN`);兑换额必须是整分倍数。
- **提现状态机**:`reviewing`(发起即原子扣现金、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。扣款/退款`cash_transaction`,`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底。
- **防超额**:扣现金用带条件 `UPDATE ... WHERE cash_balance_cents >= amount`,并发/重试不会双扣。
- **提现状态机**:`reviewing`(发起即原子扣、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。**按 `withdraw_order.source` 分账**(#121):`coin_cash` 单的扣款/退款写 `cash_transaction`,`invite_cash` 单写 `invite_cash_transaction`;`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底,admin `withdraws/ledger-check` 分账校验「单 ↔ 流水」
- **防超额**:扣用带条件 `UPDATE ... WHERE <对应余额列> >= amount`(按 source 选 `cash_balance_cents` / `invite_cash_balance_cents`),并发/重试不会双扣。
---
+16 -7
View File
@@ -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-23(补 3 张表文档:`device_liveness`(#65 无障碍存活监控)、`cps_wx_user`(CPS 落地页微信身份)、`launch_confirm_sample`(启动确认窗兜底样本);`comparison_record` 补 `input_tokens`/`output_tokens`。上一次 2026-06-17 补全 CPS 群发 5 张 `cps_*` + 好友邀请 2 张 `invite_*` + 比价沉淀 `price_observation`/`store_mapping` 并全表 review 对齐 model;含 [OVERVIEW 总览](./OVERVIEW.md))
> 最后更新:2026-07-09(补 5表文档并入索引:`coupon_session`(#99 领券任务流水)、`analytics_event`(#83 埋点)、`invite_cash_transaction`(#82 邀请奖励金账本)、`admin_role`(#117/#126 RBAC 角色)、`ad_pangle_daily_revenue`(#92,文档已有、补进索引);同步改动列:`comparison_record.product_names`、`withdraw_order.source`、`coin_account.invite_cash_balance_cents`、`feedback` 审核/环境列、`cps_order` 京东列、`device_liveness.first_protected_at`、`ad_feed_reward_record.ad_type/feed_scene`、`admin_user.plain_password/pages_override`。上一次 2026-06-23)
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
---
## 表总览(40 张业务表 + `alembic_version` 框架表)
## 表总览(45 张业务表 + `alembic_version` 框架表)
### 账号 / 反馈
| 表 | 用途 | 模型 | 文档 |
@@ -22,7 +22,7 @@
### 好友邀请(注册增长)
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `invite_relation` | 邀请绑定关系(注册即生效,双方各发1万金币;`invitee_user_id` 唯一=幂等防重复发奖) | `models/invite.py` | [详情](./invite_relation.md) |
| `invite_relation` | 邀请绑定关系(注册即生效但**绑定不发奖** #113;好友比价并下单后发邀请奖励金,`compare_reward_granted` 幂等闸;`invitee_user_id` 唯一) | `models/invite.py` | [详情](./invite_relation.md) |
| `invite_fingerprint` | 剪贴板归因失败时的指纹兜底(落地页记 ip+屏幕+机型,登录后反查邀请人) | `models/invite_fingerprint.py` | [详情](./invite_fingerprint.md) |
### 钱包 / 福利(看广告赚钱闭环)
@@ -30,8 +30,9 @@
|---|---|---|---|
| `coin_account` | 金币+现金余额快照(一用户一行) | `models/wallet.py` | [详情](./coin_account.md) |
| `coin_transaction` | 金币流水账本 | `models/wallet.py` | [详情](./coin_transaction.md) |
| `cash_transaction` | 现金流水账本(分) | `models/wallet.py` | [详情](./cash_transaction.md) |
| `withdraw_order` | 提现单(现金→微信零钱,含人工审核态) | `models/wallet.py` | [详情](./withdraw_order.md) |
| `cash_transaction` | 现金流水账本(分,金币兑换账) | `models/wallet.py` | [详情](./cash_transaction.md) |
| `invite_cash_transaction` | 邀请奖励金流水账本(分,与现金物理隔离;好友比价并下单发奖 + `source=invite_cash` 提现) | `models/wallet.py` | [详情](./invite_cash_transaction.md) |
| `withdraw_order` | 提现单(现金→微信零钱,含人工审核态;`source` 分账 coin_cash/invite_cash) | `models/wallet.py` | [详情](./withdraw_order.md) |
| `wechat_transfer_authorization` | 微信免确认转账授权(一用户一行) | `models/wallet.py` | [详情](./wechat_transfer_authorization.md) |
| `signin_record` | 签到记录(7 天循环) | `models/signin.py` | [详情](./signin_record.md) |
| `signin_boost_record` | 签到后看广告膨胀记录 | `models/signin.py` | [详情](./signin_boost_record.md) |
@@ -39,7 +40,8 @@
| `ad_reward_record` | 看激励视频发奖记录(S2S 回调,trans_id 幂等) | `models/ad_reward.py` | [详情](./ad_reward_record.md) |
| `ad_watch_log` | 看广告观看时长(旧版兼容字段) | `models/ad_watch_log.py` | [详情](./ad_watch_log.md) |
| `ad_ecpm_record` | 广告展示 eCPM 上报(收益对账) | `models/ad_ecpm.py` | [详情](./ad_ecpm_record.md) |
| `ad_feed_reward_record` | 信息流广告结算记录(10 秒一份,client_event_id 幂等) | `models/ad_feed_reward.py` | [详情](./ad_feed_reward_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) |
### 比价 / 省钱
| 表 | 用途 | 模型 | 文档 |
@@ -62,6 +64,7 @@
| `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+package+日) | `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) |
| `coupon_session` | 一次领券任务一行的全程流水(发起/终态/耗时/机型,admin 领券看板数据源,#99) | `models/coupon_state.py` | [详情](./coupon_session.md) |
### 美团 CPS 券缓存
| 表 | 用途 | 模型 | 文档 |
@@ -84,10 +87,16 @@
| `ops_stat_config` | 首页三统计展示配置(real/manual/random) | `models/ops_stat_config.py` | [详情](./ops_stat_config.md) |
| `ops_marquee_seed` | 首页轮播种子(真实不足时兜底混播) | `models/ops_marquee_seed.py` | [详情](./ops_marquee_seed.md) |
### 埋点
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `analytics_event` | 客户端埋点事件流(批量上报,一事件一行;admin 埋点日志检索,#83) | `models/analytics_event.py` | [详情](./analytics_event.md) |
### 运营后台 admin(独立子应用 `app/admin/`,独立鉴权)
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `admin_user` | 管理员账号(独立 JWT + RBAC) | `models/admin.py` | [详情](./admin_user.md) |
| `admin_user` | 管理员账号(独立 JWT + RBAC;`pages_override` 个人可见页覆盖) | `models/admin.py` | [详情](./admin_user.md) |
| `admin_role` | 后台角色→可见页配置(内建三角色 + 自定义角色,#117/#126) | `models/admin_role.py` | [详情](./admin_role.md) |
| `admin_audit_log` | 操作审计日志(只追加) | `models/admin.py` | [详情](./admin_audit_log.md) |
| `app_config` | 运营可配置项(覆盖 rewards 常量) | `models/app_config.py` | [详情](./app_config.md) |
+2
View File
@@ -11,6 +11,8 @@
| `id` | Integer | PK | 自增主键 |
| `client_event_id` | String(64) | UNIQUE, NOT NULL | 客户端幂等事件 id |
| `ad_session_id` | String(64) | index, nullable | 客户端生成的一次信息流广告会话 id |
| `ad_type` | String(16) | nullable, default `feed` | 广告形态:`feed`(信息流)/ `draw`(Draw 视频流)。旧数据 NULL 视为 feed(迁移随 #83 入库) |
| `feed_scene` | String(16) | nullable | 展示场景:`compare`(比价期)/ `coupon`(领券期)。比价与领券共用同一 Draw 代码位,收益/大盘按它分场景归集(#125 修比价/领券奖励金币恒 0 即改按本列汇总) |
| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 |
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期 `YYYY-MM-DD` |
| `duration_seconds` | Integer | NOT NULL | 整场比价累计观看秒数(轮播各条相加) |
+32
View File
@@ -0,0 +1,32 @@
# admin_role — 运营后台角色(RBAC 可见页配置)
> 模型 `app/models/admin_role.py` · 仓库 `app/admin/repositories/admin_role.py` · 权限目录 `app/admin/permissions.py` · 接口 admin `GET/POST /admin/api/roles`、`PATCH/DELETE /admin/api/roles/{id}`、`GET /admin/api/roles/catalog`(`app/admin/routers/roles.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
运营后台的**角色 → 可见页面**配置表。#117 把 RBAC 从「代码写死三角色」升级为数据驱动:内建角色(`super_admin`/`finance`/`operator`)种子进表(`is_builtin=true`,不可删),`super_admin` 可另建**自定义角色**(#126)勾选任意页面组合。`admin_user.role` 存角色 `name` 按名引用本表;单个管理员还可用 `admin_user.pages_override` 在角色之上覆盖个人可见页。
## 用在哪 / 增删改查
- **C(插入)**:内建三角色随迁移种子;`POST /admin/api/roles`(super_admin)新建自定义角色。
- **U(更新)**:`PATCH /admin/api/roles/{id}` 改展示名/可见页(内建角色的 `pages` 也可调)。
- **D(删除)**:`DELETE /admin/api/roles/{id}`——**内建角色与在用角色(有 admin_user.role 引用)不可删**。
- **R**:登录/每次 admin 鉴权时按 `admin_user.role` 查本表算有效可见页(`pages_override` 非空则以覆盖为准);`GET /admin/api/roles/catalog` 返回全部可配页面目录(分组,来自 `permissions.py` 常量,不落库)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `name` | String(32) | UNIQUE, index, NOT NULL | 角色标识(被 `admin_user.role` 按名引用):内建 `super_admin`/`finance`/`operator`;**自定义角色 name(key)= label = 创建时的输入名称**(不能叫 super_admin,重名 409) |
| `label` | String(32) | NOT NULL, default `""` | 展示名(后台下拉里显示) |
| `pages` | JSON | NOT NULL, default `[]` | 可见页面 key 列表(全集见 `permissions.py` 目录;前端按它渲染菜单,后端接口守卫同源校验)。**`super_admin` 行 pages 存空数组**,有效可见页特判为全部 |
| `is_builtin` | Boolean | NOT NULL, default false | 内建角色标记(不可删;`super_admin` 恒过所有守卫,不依赖 pages) |
| `created_at` | DateTime(tz) | server_default now() | |
## 关系 / Join Key
-`admin_user.role` **按 `name` 语义引用**(无硬 FK;删除保护在应用层:在用角色不可删)。
-`admin_user.pages_override` 的关系:有效可见页 = `pages_override`(个人覆盖,非空优先) 否则取角色 `pages`;`super_admin` 无视两者恒全通。
## 索引与约束
- PK `id`;UNIQUE+index `name`
## 注意
- 页面 key 目录维护在代码 `app/admin/permissions.py`(加新后台页面要同步登记),表里只存勾选结果——目录变更不需要迁移。
- 角色守卫兼容旧语义:`require_role("finance")` 等旧代码路径仍工作,内建角色名不可改。
+8 -6
View File
@@ -1,13 +1,13 @@
# admin_user — 运营后台管理员账号
> 模型 `app/models/admin.py` · 仓库 `app/admin/repositories/admin_user.py` · 接口 [admin-auth-login](../api/admin-auth-login.md) / [admin-admins-list](../api/admin-admins-list.md) / [admin-admin-create](../api/admin-admin-create.md) / [admin-admin-update](../api/admin-admin-update.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/admin.py` · 仓库 `app/admin/repositories/admin_user.py` · 接口 [admin-auth-login](../api/admin/auth/admin-auth-login.md) / [admin-admins-list](../api/admin/admins/admin-admins-list.md) / [admin-admin-create](../api/admin/admins/admin-admin-create.md) / [admin-admin-update](../api/admin/admins/admin-admin-update.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
运营后台(`app/admin/` 子应用,端口 8771)的管理员账号,与 App 用户(`user` 表)**完全隔离**:独立 JWT secret、独立鉴权链。密码 bcrypt 存哈希,带角色做 RBAC 权限分级。
## 用在哪 / 增删改查
- **C(插入)**:① 首个管理员用 `scripts/create_admin.py` 命令行创建(无自助注册);② `super_admin` 在后台「管理员管理」`POST` 新建子管理员。
- **U(更新)**:登录成功刷 `last_login_at`;`super_admin` 改他人 `role`/`status`/重置密码(`admin-admin-update`)。
- **D**:无(禁用走 `status='disabled'`,token 立即失效)。
- **U(更新)**:登录成功刷 `last_login_at`;`super_admin` 改他人 `role`/`status`/重置密码/`pages_override`(`admin-admin-update`)。
- **D**:`DELETE /admin/api/admins/{id}`(#126,super_admin,带审计;不可删自己)。禁用`status='disabled'`(token 立即失效)。
- **R**:每个 admin 请求经 `admin/deps` 解 admin token 查本表(校验 `status=='active'` + 角色守卫);管理员列表。
## 字段
@@ -15,8 +15,10 @@
|---|---|---|---|
| `id` | Integer | PK, autoincrement | 被 `admin_audit_log.admin_id` 引用 |
| `username` | String(64) | UNIQUE, index, NOT NULL | 登录名 |
| `password_hash` | String(255) | NOT NULL | bcrypt 哈希(明文不落库;⚠️ bcrypt 72 字节截断) |
| `role` | String(20) | NOT NULL, default `operator` | 取值:`super_admin`(全权+管账号)/ `finance`(钱:提现+金币)/ `operator`(用户+反馈+大盘) |
| `password_hash` | String(255) | NOT NULL | bcrypt 哈希(⚠️ bcrypt 72 字节截断) |
| `plain_password` | String(128) | nullable | **明文密码副本**(#117,`super_admin` 在管理员列表可见,便于线下派发/找回;创建/重置密码时同步写)。安全上是有意取舍:后台仅内网+super_admin 可见 |
| `role` | String(20) | NOT NULL, default `operator` | 角色名,按 `name` 引用 [`admin_role`](./admin_role.md)(#117 起数据驱动):内建 `super_admin`(恒全权)/ `finance` / `operator`,或自定义角色(#126) |
| `pages_override` | JSON | nullable | **个人可见页覆盖**(#126):非空时优先于角色 `pages`;NULL=跟随角色。页面 key 目录见 `app/admin/permissions.py` |
| `status` | String(20) | NOT NULL, default `active` | 取值:`active` / `disabled`(禁用后 token 立即失效) |
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 |
| `last_login_at` | DateTime(tz) | nullable | 最近登录时间(登录成功时更新) |
@@ -30,4 +32,4 @@
## 注意
- **鉴权隔离**:admin token `typ=admin` + 独立 `ADMIN_JWT_SECRET`(≠ App 的 `JWT_SECRET_KEY`),App 用户 token 无法当 admin 用;admin 无 refresh,过期(默认 12h)重登。
- **RBAC**:`super_admin` 恒过所有角色守卫(`require_role`);`finance` 管钱、`operator` 管用户/反馈/大盘。具体守卫见各接口文档。
- **RBAC**:`super_admin` 恒过所有角色守卫(`require_role`);`finance` 管钱、`operator` 管用户/反馈/大盘;#117 起可见页由 [`admin_role`](./admin_role.md)`.pages` 数据驱动 + `pages_override` 个人覆盖,自定义角色见 `POST /admin/api/roles`。具体守卫见各接口文档。
+39
View File
@@ -0,0 +1,39 @@
# analytics_event — 客户端埋点事件流
> 模型 `app/models/analytics_event.py` · 仓库 `app/repositories/analytics.py` · 接口 C 端 `POST /api/v1/analytics/events`([analytics-events](../api/other/analytics-events.md),批量,不强制登录);admin `GET /admin/api/event-logs`(`app/admin/routers/event_logs.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
客户端行为埋点的落地表,**一事件一行**(append-only)。客户端攒批上报(每批 ≤200 条),服务端展开逐条插入;`props` 装事件自带的任意维度。当前主要供 admin「埋点日志」检索与手工分析,无自动聚合任务。#83 新增(2026-06-27)。
## 用在哪 / 增删改查
- **C(插入)**:`POST /api/v1/analytics/events`(不强制登录:带 JWT 则记 `user_id`,匿名只记 `device_id`)。服务端补 `client_ip`
- **U / D**:无。只追加。
- **R**:admin `GET /admin/api/event-logs`(游标分页,可按 `event`/`device_id`/`user_id` 筛)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `event` | String(64) | index, NOT NULL | 事件名(客户端约定,如页面曝光/点击类事件名) |
| `props` | JSON | nullable | 事件属性(任意维度,客户端原样传) |
| `device_id` | String(64) | index, NOT NULL | 客户端 per-install id(匿名也有) |
| `user_id` | Integer | index, nullable | 登录态才有。**无硬 FK** |
| `session_id` | String(64) | index, nullable | 客户端会话 id(一次冷启动一个) |
| `client_ts` | BigInteger | NOT NULL | 事件发生时间(客户端 epoch ms) |
| `sent_at` | BigInteger | nullable | 客户端上报时间(epoch ms);与 `client_ts` 差 = 攒批延迟 |
| `page` | String(64) | nullable | 事件所在页面 |
| `client_ip` | String(64) | nullable | 服务端从请求头取 |
| `oem` / `os` / `model` | String | nullable | 厂商 / 系统版本 / 机型 |
| `app_ver` | String(32) | nullable | App versionName |
| `network` | String(16) | nullable | 网络类型(wifi/cellular) |
| `channel` | String(32) | nullable | 分发渠道 |
| `created_at` | DateTime(tz) | server_default now() | 入库时间 |
## 关系 / Join Key
- `user_id` 软指 `user.id``device_id` 与其他表的 per-install id 同源——均无硬 FK(埋点不鉴权、匿名也收,不能被外键约束卡住)。
## 索引与约束
- PK `id`;index `event``device_id``user_id``session_id`
## 注意
- 每批最多 200 条,超出整批 422;单条字段超长按模型截断口径处理。
- 时间轴分析用 `client_ts`(事件真实发生时刻),`created_at` 只是入库时刻(受攒批影响)。
+6 -3
View File
@@ -1,17 +1,20 @@
# cash_transaction — 现金流水账本(分)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-cash-transactions](../api/wallet-cash-transactions.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-cash-transactions](../api/wallet/wallet-cash-transactions.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
现金每变动一笔就记一行(单位:**分**,记变动后余额)。金币兑现金、提现、提现退款都落这里。**只增不改不删**。
> **只管「金币兑换现金」这本账**(`coin_account.cash_balance_cents`)。**邀请奖励金**是另一本物理隔离的账 → [`invite_cash_transaction`](./invite_cash_transaction.md)(同构表);`source=invite_cash` 的提现流水**不落本表**。
## 用在哪 / 增删改查
- **C(插入)**:个来源,每次写一笔:
- **C(插入)**:个来源,每次写一笔:
| 动作 / endpoint | `biz_type` | `amount_cents` | `ref_id` 指向 |
|---|---|---|---|
| 金币兑现金 `POST /wallet/exchange` | `exchange_in` | + | null(配套 `coin_transaction.exchange_out`) |
| 发起提现 `POST /wallet/withdraw` | `withdraw` | (扣现金) | `withdraw_order.out_bill_no` |
| 发起提现 `POST /wallet/withdraw`(`source=coin_cash`) | `withdraw` | (扣现金) | `withdraw_order.out_bill_no` |
| 提现失败/取消/审核拒绝退款 | `withdraw_refund` | +(退回) | `withdraw_order.out_bill_no` |
| admin 手动调整 `POST /admin/api/users/{id}/cash`(`account=coin_cash`) | `admin_grant` / `admin_deduct` | + / | null(原因记 `remark`=`admin:<reason>`) |
- **U / D**:无。账本只追加。
- **R**:`GET /wallet/cash-transactions`(现金明细,`id` 倒序游标);admin 跨用户现金流水。
+5 -4
View File
@@ -1,6 +1,6 @@
# coin_account — 金币 + 现金余额快照
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-account](../api/wallet-account.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-account](../api/wallet/wallet-account.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
一用户一行的余额快照,App「资产卡 / 钱包」读它展示。每次余额变动都另写一笔流水(`coin_transaction` / `cash_transaction`)并记 `balance_after`,出问题逐笔回溯。`user_id` 既是主键也是外键(一对一)。详见 [总览 §四 资金模型](./OVERVIEW.md#四资金模型金币--现金--提现三层)。
@@ -15,7 +15,8 @@
|---|---|---|---|
| `user_id` | Integer | **PK + FK→user.id** | 用户(一用户一行);既是主键也是外键 |
| `coin_balance` | Integer | NOT NULL, default 0 | 当前金币余额(个数);= 历次 `coin_transaction.amount` 之和 |
| `cash_balance_cents` | Integer | NOT NULL, default 0 | 当前现金余额(分);= 历次 `cash_transaction.amount_cents` 之和 |
| `cash_balance_cents` | Integer | NOT NULL, default 0 | 当前现金余额(分,**金币兑换账本**);= 历次 `cash_transaction.amount_cents` 之和 |
| `invite_cash_balance_cents` | Integer | NOT NULL, default 0 | 当前**邀请奖励金**余额(分,与现金物理隔离的第二本现金账);= 历次 [`invite_cash_transaction`](./invite_cash_transaction.md)`.amount_cents` 之和。好友比价并下单发奖入账(#113),`source=invite_cash` 提现出账(#121) |
| `total_coin_earned` | Integer | NOT NULL, default 0 | 累计赚取金币(**只增不减**,仅正向 grant 累加),用于"历史总收益"展示 |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后更新时间 |
@@ -27,5 +28,5 @@
- PK `user_id`(同时是 FK→user.id)。
## 注意
- **唯一发金币入口** `wallet.grant_coins`:更新本表 + 写 `coin_transaction`,**不 commit**,由调用方同事务提交(发币与业务记录原子化)。
- 扣现金用 `UPDATE ... WHERE cash_balance_cents >= amount` 原子条件扣减,并发/重试不会超额。
- **唯一发金币入口** `wallet.grant_coins`:更新本表 + 写 `coin_transaction`,**不 commit**,由调用方同事务提交(发币与业务记录原子化)。邀请奖励金同款:**唯一变动入口 `wallet.grant_invite_cash`**(更新 `invite_cash_balance_cents` + 写 `invite_cash_transaction`)。
- 扣现金用 `UPDATE ... WHERE <对应余额列> >= amount` 原子条件扣减(按提现 `source``cash_balance_cents``invite_cash_balance_cents`),并发/重试不会超额。
+4 -3
View File
@@ -1,6 +1,6 @@
# comparison_record — 比价记录(每次比价完整明细)
> 模型 `app/models/comparison.py` · 仓库 `app/repositories/comparison.py` · 接口 [compare-record-report](../api/compare-record-report.md) / [compare-records](../api/compare-records.md) / [compare-record-detail](../api/compare-record-detail.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/comparison.py` · 仓库 `app/repositories/comparison.py` · 接口 [compare-record-report](../api/compare/compare-record-report.md) / [compare-records](../api/compare/compare-records.md) / [compare-record-detail](../api/compare/compare-record-detail.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
每完成一次比价(外卖/电商/领券)记一行。**写入以 app-server 后端 harvest 为主**(2026-07 起):比价透传壳 `compare.py` 在帧0(pricebot 出 trace_id)即建 `running` 行,随 done / `trace/finalize` 逐步补全成终态,客户端不再主动 POST 记录;老客户端仍可走**带 JWT** 的 `POST /compare/record` 兜底(灰度期两条写路径按 `trace_id` reconcile)。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数),还被「上报更低价」反查原最低价。
@@ -8,9 +8,9 @@
> 与 [`price_observation`](./price_observation.md) / `store_mapping` 的区别:本表是**用户视角**(登录后按 `user_id` 存「我的比价记录」);后两张是 server 侧无条件沉淀的**平台/门店视角客观事实**(价格事实 / 跨平台店铺身份映射),与本表 `trace_id` 同源但不互相 join,各存各的视角。
## 用在哪 / 增删改查
- **C / U(harvest 为主,按 `trace_id` 幂等)**:透传壳 `compare.py` 三段式落库(`app/repositories/comparison.py`)——`harvest_running`(帧0 建 `running` 行)→ `harvest_done`(done 帧转 `success`/`failed` + 派生 `best_*`/`saved_amount_cents` + 返 `newly_success` 供幂等发邀请奖)→ `harvest_abort`(`trace/finalize``cancelled`/`failed`,**不降级已 success**)。老客户端仍可 `POST /compare/record`(`upsert_record`,按 `trace_id` 查、整行覆盖)兜底。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 一律由 `_derive``comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。
- **C / U(harvest 为主,按 `trace_id` 幂等)**:透传壳 `compare.py` 三段式落库(`app/repositories/comparison.py`)——`harvest_running`(帧0 建 `running` 行)→ `harvest_done`(done 帧转 `success`/`failed` + 派生 `best_*`/`saved_amount_cents`;`newly_success` 仅留日志观测,**不在此发邀请奖**——#113 已把发奖口径移到「实际下单」`POST /order/report`)→ `harvest_abort`(`trace/finalize``cancelled`/`failed`,**不降级已 success**)。老客户端仍可 `POST /compare/record`(`upsert_record`,按 `trace_id` 查、整行覆盖)兜底。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 一律由 `_derive``comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。
- **D**:无(关联的 `price_report` 也只把 `comparison_record_id` 置空,不删本表)。
- **R**:`GET /compare/records`(列表,`created_at` 倒序游标 + 「已下单」标记)、`GET /compare/records/{id}`(详情,限本人);`count_success` 给里程碑;`get_stats`(`status='success'` 计数 + `saved_amount_cents` 求和)给 [`GET /compare/stats`](../api/compare-stats.md) 喂「我的」页省钱战绩卡(完成比价 + 累计发现可省,**比价口径**);`report.py` 反查 `best_*`;admin 大盘/明细。
- **R**:`GET /compare/records`(列表,`created_at` 倒序游标 + 「已下单」标记)、`GET /compare/records/{id}`(详情,限本人);`count_success` 给里程碑;`get_stats`(`status='success'` 计数 + `saved_amount_cents` 求和)给 [`GET /compare/stats`](../api/compare/compare-stats.md) 喂「我的」页省钱战绩卡(完成比价 + 累计发现可省,**比价口径**);`report.py` 反查 `best_*`;admin 大盘/明细。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
@@ -30,6 +30,7 @@
| `saved_amount_cents` | Integer | nullable | 源价 最优价(可 0/负:源平台本就最便宜) |
| `is_source_best` | Boolean | nullable | 源平台就是最便宜(= 这次没省到) |
| `store_name` | String(128) | nullable | 店铺名。**与 `savings_record.shop_name` 按字符串相等关联**,给本记录打「已下单」 |
| `product_names` | String(512) | nullable | 菜品/商品名拼接串(`items[].name` 顿号连接,超长截断),**专供 admin 比价记录按店/商品模糊搜索**(#117:对 JSON 列做 LIKE 不可移植,冗余成扁平列;写入时随 harvest/upsert 同步生成)。C 端不读它 |
| `total_dish_count` / `skipped_dish_count` | Integer | nullable | 菜品总数 / 目标平台没找到被跳过数 |
| `status` | String(16) | NOT NULL, default `success` | 取值:`running`(harvest 帧0 建行、比价进行中)/ `success`(有非源且有价的目标结果)/ `failed`(出错/没采到目标价)/ `cancelled`(用户终止 / Phase1 未识别,`harvest_abort` 写,**不降级已 success**)。**里程碑只数 success** |
| `information` | String(256) | nullable | done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) |
+44
View File
@@ -0,0 +1,44 @@
# coupon_session — 领券任务全程流水(admin「领券数据」看板数据源)
> 模型 `app/models/coupon_state.py`(`CouponSession`) · 仓库 `app/repositories/coupon_state.py`(`upsert_session`) · 接口 C 端 `POST /api/v1/coupon/session`([coupon-session](../api/coupon/coupon-session.md));admin `GET /admin/api/coupon-data/*`(聚合看板 + 明细,`app/admin/routers/coupon_data.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
**一次领券任务一行**(`trace_id` 唯一),记从发起(`started`)到收尾(`completed`/`failed`/`abandoned`)的全程:总耗时、各平台耗时、领到张数、机型/ROM、发起来源。与同文件的三张「今日状态」表([coupon_state](./coupon_state.md))分工不同:那三张按「设备×日」管**频控/置灰**,本表按「一次任务」管**漏斗与体验指标**——admin「领券数据」看板的发起数、完成率、中途流失(started 无终态)、平均耗时都从这算。#99 新增(2026-06-30)。
## 用在哪 / 增删改查
- **C / U(upsert,按 `trace_id`)**:客户端 `POST /api/v1/coupon/session` **两段上报**——发起时建行(`status='started'`,带 platforms/origin_package/机型),收尾时按同 `trace_id` 更新同一行(终态 + `elapsed_ms` + `platform_elapsed` + `claimed_count`)。发起即落库 → 收尾丢失(App 被杀/断网)的行永远停在 `started` = 中途流失,可量化。
- **D**:无。
- **R**:admin `GET /admin/api/coupon-data`(按 `started_date` × `app_env` 聚合趋势/漏斗 + 逐条明细,join `user` 出手机号)+ `GET /admin/api/coupon-data/user-records`(用户抽屉:某用户全部领券记录)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `trace_id` | String(64) | NOT NULL, **UNIQUE**(`uq_coupon_session_trace`) | 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 幂等键;与 pricebot work_logs / `coupon_claim_record.trace_id` 同源 |
| `device_id` | String(64) | NOT NULL | 客户端 per-install id |
| `user_id` | Integer | index, nullable | 登录态才带(admin join `user` 出手机号/昵称);匿名领券为空。**无硬 FK** |
| `status` | String(16) | NOT NULL | `started`(发起)/ `completed` / `failed` / `abandoned`(用户中止)。`started` 无终态 = 中途流失 |
| `app_env` | String(16) | index, nullable | `prod` / `dev`(客户端 BuildConfig.DEBUG)。admin 报表默认只看 prod,防测试数据串台 |
| `platforms` | JSON(PG: JSONB) | nullable | 发起勾选平台 `["meituan-waimai", ...]`;空=全领 |
| `origin_package` | String(64) | nullable | 发起来源外卖 App 包名;null=App 内(傻瓜比价首页)发起,非空=切到美团/淘宝/京东被弹券引导发起。admin「发起平台」列据此区分 |
| `device_model` | String(128) | nullable | 机型(Build.MANUFACTURER + MODEL) |
| `rom` | String(64) | nullable | ROM(OemDetector,如 `ColorOS 14`) |
| `started_at` | DateTime(tz) | NOT NULL | 发起时刻(客户端墙钟) |
| `started_date` | Date | NOT NULL | 发起的 Asia/Shanghai 自然日;admin 按天聚合/筛选(复合索引) |
| `finished_at` | DateTime(tz) | nullable | 收尾时刻(服务端 now);未收尾(流失)为空 |
| `elapsed_ms` | Integer | nullable | 全程耗时(客户端点发起→收尾计时,权威);平均/分位只统计 completed |
| `platform_elapsed` | JSON(PG: JSONB) | nullable | 各平台领券耗时 `{"meituan-waimai": 3200, ...}`(ms) |
| `claimed_count` | Integer | nullable | 本次领到张数(收尾上报) |
| `trace_url` | String(512) | nullable | pricebot 公网调试链接(admin 明细可点开复盘) |
| `created_at` / `updated_at` | DateTime(tz) | server_default now() / +onupdate | |
## 关系 / Join Key
- `user_id` 软指 `user.id`(无硬 FK,可空;admin 明细 LEFT JOIN 出手机号)。
- `trace_id` 与 pricebot work_logs、[`coupon_claim_record`](./coupon_state.md).`trace_id` 同源(一次任务),无 id 级 join。
- `device_id` 与领券三表同源(per-install id)。
## 索引与约束
- PK `id`;UNIQUE `trace_id`;index `user_id``app_env`;复合 index `ix_coupon_session_date_env`(`started_date`, `app_env`)= admin 看板主查询路径。
## 注意
- 写库 best-effort 口径与领券三表一致:上报失败不影响领券本身(且本表数据源是客户端**独立上报**,不是 `/coupon/step` 透传顺手写)。
- `elapsed_ms` 以客户端计时为准(墙钟差不影响);`finished_at - started_at` 只做时刻留痕,不用来算时长。
+1
View File
@@ -1,6 +1,7 @@
# 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)
> 同模型文件里还有第四张表 [`coupon_session`](./coupon_session.md)(一次领券任务一行的全程流水,admin「领券数据」看板数据源,#99)——维度与本文三张「设备×日」状态表不同,单独成文。
领券(优惠券自动化)联动产生的三张「今日状态」表,都挂在领券透传端点 `POST /api/v1/coupon/step` 这条链路上(pricebot 跑领券,结果回 app-server 落库;**领券过程本身在 pricebot 内存态跑、不落库**)。三表各管一件事:
+13 -4
View File
@@ -2,7 +2,7 @@
> 模型 `app/models/cps_order.py` · 仓库 `app/admin/repositories/cps.py`(`reconcile_orders` / `_map_order_fields` / `list_orders`、统计 `group_stats`) · 接口 admin `POST /admin/api/cps/orders/reconcile``GET /admin/api/cps/orders``GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
美团联盟 `query_order` 按时间窗拉回、按 `sid` 归群的订单明细,是 CPS 群发漏斗的**最下游"赚了多少佣金"**:用户点 [`cps_link`](./cps_link.md)(携群 [`cps_group`](./cps_group.md) 的 `sid`)下单后,美团把订单连同 `sid` 回传,这里按 `sid` 归群做对账,与 [`cps_click`](./cps_click.md) 的点击量在 `group_stats` 汇成"点击→下单→佣金"漏斗。**仅美团有此表**——淘宝/京东无对账 API,统计里对账字段显示 `-`
从联盟 API 按时间窗拉回、按 `sid` 归群的订单明细,是 CPS 群发漏斗的**最下游"赚了多少佣金"**:用户点 [`cps_link`](./cps_link.md)(携群 [`cps_group`](./cps_group.md) 的 `sid`)下单后,联盟把订单连同 `sid` 回传,这里按 `sid` 归群做对账,与 [`cps_click`](./cps_click.md) 的点击量在 `group_stats` 汇成"点击→下单→佣金"漏斗。**平台覆盖**:初版仅美团(`query_order`);#90(2026-06-30)接入**京东联盟**订单(拉单进同一张表,`platform='jd'`,配 `jd_*`/`external_*` 列,喂数据大盘);淘宝仍无对账 API,统计里对账字段显示 `-`
## 用在哪 / 增删改查
- **C/U(upsert)**:`POST /admin/api/cps/orders/reconcile`(`reconcile_orders`,需 `finance` 角色)。调 `meituan.query_order(sid?, start_time, end_time, page, limit=100)` 分页拉单(`max_pages=200` 防死循环),每条经 `_map_order_fields` 转字段,按 `order_id` **upsert**:不存在则 insert,存在则逐字段覆盖(订单状态会随时间变 付款→完成→结算/退款,重复拉则更新)。返回 `{fetched, inserted, updated, pages}``order_id` 全局唯一即幂等键。
@@ -15,7 +15,10 @@
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join / 源字段) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `order_id` | String(64) | **UNIQUE, index, NOT NULL** | 美团订单号(加密串),`orderId`。upsert 幂等键 |
| `platform` | String(20) | index, NOT NULL, default `meituan` | 订单来源联盟:`meituan` / `jd`(#90)。统计/大盘按它分平台 |
| `order_id` | String(64) | **UNIQUE, index, NOT NULL** | 订单号(美团加密串 `orderId`;京东为联盟订单号)。upsert 幂等键 |
| `external_order_id` | String(128) | index, nullable | 平台原始订单号(#90,京东 `orderId`;美团 null) |
| `external_row_id` | String(128) | index, nullable | 平台订单行号(#90,京东一单多 sku 时区分行;美团 null) |
| `sid` | String(64) | index, **nullable** | 渠道追踪位 = 群 `sid`,源 `sid`。**按它归群聚合**;历史无 sid 订单为空 |
| `act_id` | String(64) | index, nullable | 活动物料 ID,源 `actId`(转字符串) |
| `biz_line` | Integer | nullable | 业务线,源 `businessLine``1=外卖` |
@@ -25,9 +28,14 @@
| `commission_rate` | String(16) | nullable | 佣金率,源 `commissionRate``"300"=3%``"10"=0.1%`(原样字符串,前端解释) |
| `refund_price_cents` | Integer | nullable | 退款金额(分),源 `refundPrice`(元) |
| `refund_profit_cents` | Integer | nullable | 退款佣金(分),源 `refundProfit`(元) |
| `mt_status` | String(8) | index, nullable | 美团订单状态,源 `status`:`2`付款 `3`完成 `4`取消 `5`风控 `6`结算 |
| `estimated_commission_cents` | Integer | nullable | 预估佣金(分,#90 京东口径;美团用 `commission_cents`) |
| `actual_commission_cents` | Integer | nullable | 实际/结算佣金(分,#90 京东口径) |
| `mt_status` | String(8) | index, nullable | 美团订单状态,源 `status`:`2`付款 `3`完成 `4`取消 `5`风控 `6`结算;京东订单为 null |
| `jd_valid_code` | String(16) | index, nullable | 京东订单有效码(#90,联盟 `validCode`,判有效/无效/风控);美团订单为 null |
| `invalid_reason` | String(128) | nullable | 失效原因,源 `invalidReason` |
| `product_name` | String(512) | nullable | 商品名,源 `productName`(超 500 截断) |
| `settle_month` | String(16) | nullable | 结算月份(#90 京东) |
| `site_id` / `position_id` / `pid` | String(128) | nullable | 京东推广位维度(#90):站点/推广位/联盟 pid,归因用 |
| `pay_time` | DateTime(tz) | index, nullable | 付款时间,源 `payTime`(秒级 ts)。统计按时间窗过滤的就是它 |
| `mt_update_time` | DateTime(tz) | nullable | 美团侧更新时间,源 `updateTime`(秒级 ts) |
| `raw` | JSON / JSONB | NOT NULL, default `{}` | `query_order` 单条原始 dataList,留底排查/补字段 |
@@ -48,4 +56,5 @@
- **"未归群"独立行**:`group_stats` 遍历完所有群后,对剩下的、有订单但 `sid` 不属任何现存群的 sid(历史遗留如 `wonderableai`、或别处来源、或群被删),单列一行 `group_id=None`、有对账无点击。所以本表 `sid` 可空/可孤立是设计内的,不是脏数据。
- **金额/时间统一转换的原因**:`query_order` 返回金额是「元」字符串、时间是秒级 ts;`_yuan_to_cents`(Decimal 防浮点,`"null"`/空 → None)与 `_ts_to_dt`(转 tz-aware UTC,前端按北京展示)在入库时归一,与全站"金额存分、时间存 tz-aware"口径对齐。`raw` 整条留底,字段不够时不用重拉。
- **upsert 而非 append**:同一订单会被多次拉到(状态变化),按 `order_id` 覆盖即可拿到最新状态;`reconcile` 可重复跑(幂等)。
- **本表自始至终只服务美团**:初版(`277f9b1`)就定型,`3a40f61` 接淘宝/京东时**没动本表**——那俩平台无对账 API,统计对账列直接`None`(前端显示 `-`)。这是"对账 = 美团专属"的边界。
- **平台边界的演变**:初版(`277f9b1`)只服务美团;#90(2026-06-30)接入**京东联盟**拉单进同一张表(`platform='jd'` + `external_*`/`jd_valid_code`/`estimated|actual_commission_cents` 等列,有效性按 `jd_valid_code` 判,喂 admin 数据大盘的京东收益)。**淘宝仍无对账 API**,统计对账列给 `None`(前端显示 `-`)。
- **#119`pay_time` 缺失**:早期美团拉单部分订单 `payTime` 空导致 `pay_time` 为 null → 大盘按时间窗过滤漏算美团收益;#119 起入库补齐/回填,时间窗统计以 `pay_time` 为准。
+3 -2
View File
@@ -9,7 +9,7 @@
## 用在哪 / 增删改查
- **C / U(upsert)**:`register_or_update`(`POST /device/register`,App 拿到 push token 时调)按 `(user_id, device_id)` upsert,只在传入非空时更新 `registration_id`/`platform`/`app_version`;`touch_heartbeat`(`POST /device/heartbeat`,无障碍服务存活时周期调,**心跳也能自注册**)在 `accessibility_enabled=true` 时刷 `last_heartbeat_at`、置 `ever_protected=true`、状态机重置回 `alive`、清 `notified_at`(掉线恢复→下次再断才再推一条)。
- **U(worker)**:`mark_notified`(`heartbeat_monitor_worker` 检出掉线后)置 `liveness_state='notified'` + `notified_at` + **`kill_alert_pending=True`**;`ack_kill_alert`(`POST /device/liveness/ack`,客户端弹过引导后)清 `kill_alert_pending`(幂等)。
- **R**:`list_overdue`(worker 扫描:`ever_protected=true` + `liveness_state='alive'` + `last_heartbeat_at` 早于 `now - timeout`)→ 掉线设备列表;`get_device`(`GET /device/liveness`,客户端进 App 拉本机是否被判掉线过)。
- **R**:`list_overdue`(worker 扫描:`ever_protected=true` + `liveness_state='alive'` + `last_heartbeat_at` 早于 `now - timeout`)→ 掉线设备列表;`get_device`(`GET /device/liveness`,客户端进 App 拉本机是否被判掉线过);admin `GET /admin/api/device-liveness/stats` + 列表(#80,后台「设备存活监控」页:总数/在线/掉线卡片 + 明细)
- **D**:无。
## 字段
@@ -22,6 +22,7 @@
| `platform` | String(16) | NOT NULL, default `android` | |
| `app_version` | String(32) | nullable | 上报时 App 版本 |
| `ever_protected` | Boolean | NOT NULL, default false | 收到过 service 心跳即 true(=该设备开过无障碍,功能对它有意义)。`list_overdue` 的过滤前提 |
| `first_protected_at` | DateTime(tz) | nullable | **首次**开启无障碍(首次收到心跳)的时刻(#80);置 `ever_protected=true` 时一并写、之后不再变。admin 设备存活监控用它算「开启保护耗时/转化」 |
| `last_heartbeat_at` | DateTime(tz) | index, nullable | 最近一次 service 心跳时间(存活证明);超时即视为保护掉线 |
| `last_report_protection_on` | Boolean | NOT NULL, default false | 最近一次上报的无障碍开关状态(观测用) |
| `liveness_state` | String(16) | NOT NULL, default `unknown` | 状态机:`unknown``alive`(收到 service 心跳)→ `silent`/`notified`(扫描发现超时并已推送);心跳恢复 handler 重置回 `alive` |
@@ -40,4 +41,4 @@
## 注意
- **`kill_alert_pending` 为什么和 `liveness_state` 解耦**:服务随 App 重启会先发心跳把 `state` 重置回 `alive`,若复用 `state` 判「待提醒」,客户端进 App 这一刻可能恰好已被重置 → 漏看这次掉线。故另设一个只由 worker 置、只由客户端 ack 清的标记,规避竞态。
- **`list_overdue` 本期不要求有 `registration_id`**:本期只做终端打印检测、未真推送,没接极光 token 的设备也要检出。
- 心跳超时阈值由 worker 的 `timeout_minutes` 决定(不在表里)。
- 心跳超时阈值由 worker 的 `timeout_minutes` 决定(不在表里);#107 起默认 **1 小时**(原 10 分钟误报率高:息屏/省电模式下心跳会正常停发)
+25 -11
View File
@@ -1,14 +1,14 @@
# feedback — 用户帮助与反馈
# feedback — 用户帮助与反馈(含审核发奖)
> 模型 `app/models/feedback.py` · 仓库 `app/repositories/feedback.py` · 接口 [feedback](../api/feedback.md) · admin [admin-feedbacks-list](../api/admin-feedbacks-list.md) / [admin-feedback-handle](../api/admin-feedback-handle.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/feedback.py` · 仓库 `app/repositories/feedback.py` · 接口 C 端 [feedback](../api/other/feedback.md) / [feedback-records](../api/other/feedback-records.md);admin `GET /admin/api/feedbacks``/summary``POST /{id}/approve|reject|handle`(`app/admin/routers/feedback.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
App「帮助与反馈」每次提交写一行。`content` 必填;`contact` 原必填,**原型改版后客户端不再采集,新数据存空串**(列保持 NOT NULL、免迁移,历史数据仍有值);`images` 为可选截图(≤6 张)。后台人工处理后置 `handled`。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈。
App「帮助与反馈」每次提交写一行。2026-06 起演进为**轻审核工单**:运营在后台采纳(`adopted`,可发金币)/拒绝(`rejected`,填原因)并可写**运营回复**(#105),C 端「我的反馈」列表把状态与回复展示给用户;提交侧自动采集**来源/场景 + 端环境**(App 版本/机型/ROM/Android 版本,#94)供排障。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈。
## 用在哪 / 增删改查
- **C(插入)**:`POST /api/v1/feedback`(multipart:`content` + 可选 `contact` + 可选 `images`;`create_feedback`)。截图`core.media``/media/feedback/` 拿相对路径,再随反馈写入,`status='new'`
- **U(更新)**:admin 处理反馈 `update_feedback_status``status='handled'`(同事务写 `admin_audit_log`)。
- **C(插入)**:`POST /api/v1/feedback`(multipart:`content` + 可选 `contact`/`images`/`source`/`scene`;客户端自动带 `app_version`/`device_model`/`rom_name`/`android_version`)→ `status='pending'`。截图经 `core.media``/media/feedback/`
- **U(更新,admin,均写 `admin_audit_log`)**:`approve`(→`adopted`,可选发金币 `reward_coins`,走 `grant_coins` 同事务)/ `reject`(→`rejected`,`reject_reason`)/ `handle`(→`handled`,旧口径「标记已处理」保留)。三者均可写 `admin_reply`(用户可见回复)与 `review_note`(内部备注)。
- **D**:无。
- **R**:admin 反馈列表(可按 `status` 筛)。C 端当前无"我的反馈列表"读接口
- **R**:C 端 `GET /api/v1/feedback/records`(我的反馈历史,展示状态/回复/奖励);admin 列表(状态/来源筛选)+ `summary`(各状态计数)
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
@@ -16,17 +16,31 @@ App「帮助与反馈」每次提交写一行。`content` 必填;`contact` 原
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 提交用户 |
| `content` | Text | NOT NULL | 反馈正文 |
| `contact` | String(128) | NOT NULL | 联系方式(微信/QQ/手机)。客户端改版后不再采集,新数据为空串;列仍 NOT NULL |
| `images` | JSON | nullable | 截图相对 URL 列表 `/media/feedback/...`;无图为 NULL |
| `status` | String(16) | NOT NULL, default `new` | 取值:`new`(待处理)/ `handled`(已处理) |
| `contact` | String(128) | NOT NULL | 联系方式。客户端改版后不再采集,新数据为空串;列仍 NOT NULL |
| `source` | String(16) | NOT NULL, default `profile`, index | 提交入口来源(#105):`profile`(设置/我的页普通反馈)/ 比价场景值;客户端显式传优先,未传由 `scene` 有无派生 |
| `scene` | String(32) | nullable | 比价反馈的「问题场景」(找错商品/优惠不对/比价太慢…,#105);比价结果页反馈才有,普通反馈为 NULL |
| `images` | JSON | nullable | 截图相对 URL 列表 `/media/feedback/...` |
| `app_version` | String(32) | nullable | 提交时 App versionName(#94) |
| `device_model` | String(64) | nullable | 机型 Build.MODEL(#94) |
| `rom_name` | String(32) | nullable | ROM(OemDetector:ColorOS/MIUI/…,#94) |
| `android_version` | String(16) | nullable | Android 版本(#94) |
| `status` | String(16) | NOT NULL, default `pending`, index | `pending`(待处理)/ `adopted`(已采纳,#94)/ `rejected`(已拒绝)/ `handled`(旧「已处理」口径,兼容保留;历史另有 `new`) |
| `reject_reason` | String(256) | nullable | 拒绝原因(用户可见) |
| `reward_coins` | Integer | nullable | 采纳发的金币数(未发为 null;发币走 `coin_transaction`) |
| `review_note` | String(256) | nullable | 运营内部备注(不外露) |
| `admin_reply` | String(256) | nullable | **运营回复**(用户可见,#105;C 端反馈历史展示) |
| `reviewed_by_admin_id` | Integer | nullable | 审核管理员 id(软指 `admin_user.id`) |
| `reviewed_at` | DateTime(tz) | nullable | 审核时间 |
| `created_at` | DateTime(tz) | server_default now(), index | 提交时间 |
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- admin 处理时`admin_audit_log` 记录(`target_type='feedback'``target_id`=本行 id)。
- admin 审核动作`admin_audit_log` 记录(`target_type='feedback'`);`reviewed_by_admin_id` 软指 `admin_user.id`(无硬 FK)。
- 采纳发奖时写 `coin_transaction`(发币入口 `grant_coins` 同事务)。
## 索引与约束
- PK `id`;index `user_id``created_at`
- PK `id`;index `user_id``status``created_at`
## 注意
- `images` 用通用 `JSON`(本表**未**用 JSONB variant,与 comparison/savings 不同)。
- 状态机是单向的:`pending → adopted/rejected/handled``approve`/`reject` 只接受 `pending`(或历史 `new`)态,重复审核报 400;旧口径 `handle` **幂等不校验原状态**(重复调用结果一致)。
+42
View File
@@ -0,0 +1,42 @@
# invite_cash_transaction — 邀请奖励金流水账本(分)
> 模型 `app/models/wallet.py`(`InviteCashTransaction`) · 仓库 `app/repositories/wallet.py`(`grant_invite_cash` / 提现路径按 `source` 分账) · 接口 [wallet-account](../api/wallet/wallet-account.md)(余额)/ [wallet-withdraw](../api/wallet/wallet-withdraw.md)(`source=invite_cash` 提现) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
**邀请奖励金**(现金,分)的流水账本,与金币兑换的 `cash_transaction` **物理隔离**、结构同构:`balance_after_cents` 记的是 `coin_account.invite_cash_balance_cents`。隔离原因:邀请奖励金有独立的发放口径(好友**比价并下单**才发,#113)与独立的提现对账(`withdraw_order.source='invite_cash'`,#121),混在现金账本里无法分账校验。#82 新增(2026-06-27)。
## 用在哪 / 增删改查
- **C(插入)**:每次奖励金变动一笔:
| 动作 / endpoint | `biz_type` | `amount_cents` | `ref_id` 指向 |
|---|---|---|---|
| 好友比价并下单发奖(`POST /order/report``invite.try_reward_on_compare``grant_invite_cash`) | `invite_reward` | + | 被邀请人 `user.id`(字符串) |
| 发起提现 `POST /wallet/withdraw`(`source=invite_cash`) | `invite_withdraw` | | `withdraw_order.out_bill_no` |
| 该提现失败/审核拒绝退款 | `invite_withdraw_refund` | + | `withdraw_order.out_bill_no` |
| admin 手动调整 `POST /admin/api/users/{id}/cash`(`account=invite_cash`,#95) | `admin_grant` / `admin_deduct` | + / | null(原因记 `remark`=`admin:<reason>`) |
- **U / D**:无。账本只追加。
- **R**:admin 用户 360 / 提现资金账本校验 `GET /admin/api/withdraws/ledger-check`(按 `source` 分账对账,#121);C 端邀请页战绩(`invite.get_reward_stats`:余额 + 累计已提)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(= 邀请人) |
| `amount_cents` | Integer | NOT NULL | 本笔变动(分);正=入账(发奖/退款),负=出账(提现) |
| `balance_after_cents` | Integer | NOT NULL | 本笔后奖励金余额(= 当时 `coin_account.invite_cash_balance_cents`) |
| `biz_type` | String(32) | NOT NULL | `invite_reward` / `invite_withdraw` / `invite_withdraw_refund` |
| `ref_id` | String(64) | nullable | 见上表:发奖=被邀请人 id;提现/退款=`withdraw_order.out_bill_no` |
| `remark` | String(128) | nullable | 用户可见备注(如「好友比价奖励」「提现到微信零钱(待审核)」) |
| `created_at` | DateTime(tz) | server_default now(), index | |
## 关系 / Join Key
- `user_id``user.id`(多对一,硬 FK)。
- `ref_id` →(withdraw 类)`withdraw_order.out_bill_no`(软关联;该单 `source='invite_cash'`)/(invite_reward)被邀请人 `user.id`
- 与 `invite_relation` 的联动:发奖同事务置 `invite_relation.compare_reward_granted=true`(幂等闸,一个被邀请人只发一次)。
## 索引与约束
- PK `id`;index `user_id``created_at`
## 注意
- **唯一变动入口 `wallet.grant_invite_cash`**:更新 `coin_account.invite_cash_balance_cents` + 写本表,**不 commit**,调用方同事务提交(与 `grant_coins` 同款约定)。
- 提现按 `source` 走不同账本:`coin_cash``cash_transaction`,`invite_cash` → 本表;`withdraw_order` 两种共用一张表靠 `source` 列区分。
+17 -13
View File
@@ -2,15 +2,16 @@
> 模型 `app/models/invite.py`(`InviteRelation`) · 仓库 `app/repositories/invite.py` · 接口 `app/api/v1/invite.py`:`POST /api/v1/invite/bind`(写)、`GET /api/v1/invite/me`(战绩)、`GET /api/v1/invite/invitees`(列表) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
一行 = 一次**成功的**邀请绑定。被邀请人(B)用邀请人(A)的邀请码完成绑定后写入,**注册即生效**:同一事务里给 A、B 各发 1 万金币(= 1 元,可提现)。数据来自 `bind()`,触发它的归因来源有三种(`channel`)。这是邀请功能的**结果表 / 账本**;邀请码本身存在 `user.invite_code`,不在这张表。
一行 = 一次**成功的**邀请绑定。被邀请人(B)用邀请人(A)的邀请码完成绑定后写入,**绑定关系注册即生效,但绑定本身不发奖**(#113 起):发奖后置到 B **完成比价并实际下单**(`POST /order/report``try_reward_on_compare`),给 A 发**邀请奖励金**(入独立账本 [invite_cash_transaction](./invite_cash_transaction.md),非金币),本表 `compare_reward_granted` 做幂等闸。数据来自 `bind()`,触发它的归因来源有三种(`channel`)。这是邀请功能的**结果表 / 账本**;邀请码本身存在 `user.invite_code`,不在这张表。
## 用在哪 / 增删改查
- **C(插入)**:`POST /api/v1/invite/bind`(`bind_invite`)→ `invite_repo.bind()`。过四道防线后建一行 `status='effective'`,**同事务**复用 `wallet.grant_coins` 给双方发金币(`grant_coins` 只 flush,由 `bind()` 统一 commit)→ "建关系 + 双方加金币"原子
- **幂等键 = `invitee_user_id` 唯一**:一个 B 只能被绑一次,重复请求返回 `already_bound`,不重复发奖(仿 `ad_reward_record.trans_id` 思路)。并发下两请求同时插同一 invitee → 后者撞唯一约束 `IntegrityError`,`bind()` rollback 后改判 `already_bound` 兜底。
- 四道防线(`bind()` 内):① invitee 已绑 → `already_bound`;② 无效码 / 邀请人非 `active``invalid_code`;③ 自邀(inviter==invitee)→ `self_invite`;④ 新人闸 `_is_new_user`(B 的 `created_at``rewards.INVITE_NEW_USER_WINDOW_HOURS`=72h 内)→ 否则 `not_eligible`只有全过才插行 + 发奖。
- **U / D**:无。这张表只增不改不删(纯账本)
- **C(插入)**:`POST /api/v1/invite/bind`(`bind_invite`)→ `invite_repo.bind()`。过四道防线后建一行 `status='effective'`**#113 起绑定只建关系、不发奖**(`inviter_coin`/`invitee_coin` 新行恒 0)
- **幂等键 = `invitee_user_id` 唯一**:一个 B 只能被绑一次,重复请求返回 `already_bound`(仿 `ad_reward_record.trans_id` 思路)。并发下两请求同时插同一 invitee → 后者撞唯一约束 `IntegrityError`,`bind()` rollback 后改判 `already_bound` 兜底。
- 四道防线(`bind()` 内):① invitee 已绑 → `already_bound`;② 无效码 / 邀请人非 `active``invalid_code`;③ 自邀(inviter==invitee)→ `self_invite`;④ 新人闸 `_is_new_user`(B 的 `created_at``rewards.INVITE_NEW_USER_WINDOW_HOURS`=72h 内)→ 否则 `not_eligible`
- **U(发奖,#113)**:B 比价后下单 `POST /order/report``try_reward_on_compare(invitee_user_id)`:`compare_reward_granted=False` 才发 → 置 `True` + 记 `compare_reward_cents`/`compare_rewarded_at`,**同事务** `wallet.grant_invite_cash` 给 A 入邀请奖励金 → "置闸 + 入账"原子,一个 B 只发一次
- **D**:无。
- **R**:
- `GET /api/v1/invite/me`(`my_invite`)→ `get_stats(inviter_id)`:`count(*)` 得已邀人数、`sum(inviter_coin)` 得累计金币。
- `GET /api/v1/invite/me`(`my_invite`)→ `get_stats(inviter_id)`:`count(*)` 得已邀人数、`sum(inviter_coin)` 得累计金币(历史口径);另调 `get_reward_stats` 出邀请奖励金余额/累计已提(读 `coin_account.invite_cash_balance_cents` + `withdraw_order(source=invite_cash)`)
- `GET /api/v1/invite/invitees`(`my_invitees`)→ `get_invitees(inviter_id, limit, offset)`:join `user` 出被邀请人列表(倒序分页),名字降级兜底 `nickname → wechat_nickname → 脱敏手机号`,`coins``inviter_coin`
## 字段
@@ -20,15 +21,18 @@
| `inviter_user_id` | Integer | FK→user.id, index, NOT NULL | 邀请人(A) |
| `invitee_user_id` | Integer | FK→user.id, **UNIQUE**, index, NOT NULL | 被邀请人(B)。**唯一 = 幂等键**,一个 B 只能被归因一次 |
| `channel` | String(16) | NOT NULL, default `clipboard` | 归因来源:`clipboard`(剪贴板自动)/ `manual`(手动填码)/ `fingerprint`(指纹反查兜底)。入库前 `[:16]` 截断 |
| `status` | String(16) | NOT NULL, default `effective` | 当前只有 `effective`(注册即生效)。**预留** `pending`/`effective`:将来若改"完成首单才生效"时启用 |
| `inviter_coin` | Integer | NOT NULL, default 0 | 本次给 A 发的金币(记账留痕,=`rewards.INVITE_INVITER_COINS`=10000) |
| `invitee_coin` | Integer | NOT NULL, default 0 | 本次给 B 发的金币(=`rewards.INVITE_INVITEE_COINS`=10000) |
| `status` | String(16) | NOT NULL, default `effective` | 当前只有 `effective`(绑定关系注册即生效)。「发奖后置」没有走 status,而是用下面的 `compare_reward_granted` 闸表达 |
| `inviter_coin` | Integer | NOT NULL, default 0 | **历史留痕**(#113 前"绑定即发金币"时代给 A 发的金币,当时=10000);#113 起新绑定恒 0 |
| `invitee_coin` | Integer | NOT NULL, default 0 | 同上,给 B 发的金币历史留痕;新绑定恒 0 |
| `compare_reward_granted` | Boolean | NOT NULL, default false | **发奖幂等闸**(#113):B 首次「比价并下单」后置 true,一个 B 只给 A 发一次邀请奖励金 |
| `compare_reward_cents` | Integer | NOT NULL, default 0 | 实发的邀请奖励金(分,留痕;改常量不影响历史行) |
| `compare_rewarded_at` | DateTime(tz) | nullable | 发奖时间 |
| `created_at` | DateTime(tz) | server_default now(), index | 绑定时间(= 列表倒序键) |
## 关系 / Join Key
- `inviter_user_id``user.id`(硬 FK,多对一):一个 A 可邀多个 B。
- `invitee_user_id``user.id`(硬 FK,**一对一**,唯一约束):一个 B 至多一行。
- 发金币`coin_transaction`:`biz_type='invite_inviter'`(给 A,`ref_id=invitee.id`)/ `biz_type='invite_invitee'`(给 B,`ref_id=inviter.id`),双方 `ref_id` 互指对方便于对账
- 流水关联:**#113 前**的绑定发金币落 `coin_transaction`(`biz_type='invite_inviter'`/`'invite_invitee'`,`ref_id` 互指对方)——现为历史类型,新绑定不再产生;**#113 起**发奖落 [`invite_cash_transaction`](./invite_cash_transaction.md)(`biz_type='invite_reward'`,`ref_id=被邀请人 id`)
- `get_invitees``InviteRelation JOIN user ON user.id = invitee_user_id` 取被邀请人资料;`total` 单独 `count``has_more`
## 索引与约束
@@ -39,8 +43,8 @@
## 注意
- **防重复发奖三道**(`repositories/invite.py` docstring):① `invitee_user_id` 唯一(应用层 `_relation_of_invitee` 先查 + DB 唯一约束并发兜底);② 自邀屏蔽;③ 手机号天然唯一(每个 B = 一个真实手机号账号)= 限制刷量规模。
- **`status` 当前恒为 `effective`**:产品取"注册即生效"而非"完成首单才生效",`pending` 取值是为后者预留、目前不写入
- **`inviter_coin`/`invitee_coin` 是留痕字段**:写死当时发的金币值,即便日后改奖励常量,历史行仍保留发奖时的额度,便于对账
- **`status` 当前恒为 `effective`**(绑定关系维度);「发奖后置」由 `compare_reward_granted` 闸表达,没有引入 `pending` 状态
- **金币留痕字段已冻结**:`inviter_coin`/`invitee_coin` 只反映 #113 前旧口径的历史发放额,便于对账;新奖励额看 `compare_reward_cents`
- **`channel` 三种取值**:`clipboard`(deferred-deeplink 主路径,落地页写剪贴板、首启读回)/ `manual`(用户在邀请页手输)/ `fingerprint`(剪贴板被覆盖时走指纹兜底,见 [invite_fingerprint](./invite_fingerprint.md));三者都汇入同一个 `bind()`,只 `channel` 不同。
- **风控缺口(#24 设计文档「上线前还差什么」标注)**:1 万金币可提现且**邀请人无总数上限**,接码平台批量注册新号绑同码即可刷;上线前需加 inviter 上限 + 基础风控。当前 MVP 仅靠"手机号唯一 + 72h 新人闸"挡
- **风控演进**:#24 时代的缺口是"注册即发 1 万可提现金币、接码批量刷"——**#113 把发奖后置到真实比价+下单**(且发的是邀请奖励金独立账本),批量注册空号不再直接得利,刷奖成本显著抬高;inviter 总数上限等进一步风控仍待补
- **alembic 多 head**:本表迁移 `invite_code_and_relation`(`down_revision=11a1d08c6f55`)与 `coupon_state_tables` 是同一父的兄弟迁移,合 main 前需建 merge 迁移,否则 prod `alembic upgrade head``Multiple head revisions`(#24 设计文档已警示)。
+4 -3
View File
@@ -1,6 +1,6 @@
# withdraw_order — 提现单(现金 → 微信零钱)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-withdraw](../api/wallet-withdraw.md) / [wallet-withdraw-status](../api/wallet-withdraw-status.md) / [wallet-withdraw-orders](../api/wallet-withdraw-orders.md) · admin [admin-withdraws-list](../api/admin-withdraws-list.md) / [admin-withdraw-refresh](../api/admin-withdraw-refresh.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-withdraw](../api/wallet/wallet-withdraw.md) / [wallet-withdraw-status](../api/wallet/wallet-withdraw-status.md) / [wallet-withdraw-orders](../api/wallet/wallet-withdraw-orders.md) · admin [admin-withdraws-list](../api/admin/withdraws/admin-withdraws-list.md) / [admin-withdraw-refresh](../api/admin/withdraws/admin-withdraw-refresh.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
用户把现金余额提到微信零钱的工单。**含人工审核**(2026-06 起):发起即扣现金、进 `reviewing` 待审核、**不打款**;管理员后台审核通过才真正发起微信商家转账,拒绝则退款。
@@ -26,7 +26,8 @@ reviewing ──admin 审核拒绝──▶ rejected(已退款)
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单键)。客户端可传(`[0-9A-Za-z_-]{8,32}`),不传则服务端 `uuid4().hex`。**被 `cash_transaction.ref_id` 引用** |
| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单键)。客户端可传(`[0-9A-Za-z_-]{8,32}`),不传则服务端 `uuid4().hex`。**被 `cash_transaction.ref_id` / `invite_cash_transaction.ref_id` 引用**(按 `source` 落对应账本) |
| `source` | String(16) | NOT NULL, default `coin_cash` | 提现账户来源(#121 分账):`coin_cash`(金币兑换的现金,扣 `coin_account.cash_balance_cents`、流水落 `cash_transaction`)/ `invite_cash`(邀请奖励金,扣 `invite_cash_balance_cents`、流水落 [`invite_cash_transaction`](./invite_cash_transaction.md)) |
| `amount_cents` | Integer | NOT NULL | 提现金额(分) |
| `user_name` | String(64) | nullable | 提现实名;微信**达额转账要求实名**,发起时存下、审核打款时传给微信 |
| `status` | String(16) | NOT NULL, default `reviewing` | 归一化状态:`reviewing`(待审核,已扣款未打款)/ `pending`(打款在途)/ `success` / `failed`(打款失败已退)/ `rejected`(审核拒绝已退) |
@@ -39,7 +40,7 @@ reviewing ──admin 审核拒绝──▶ rejected(已退款)
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- `out_bill_no` ← 被 `cash_transaction.ref_id` 引用(发起 `withdraw` 一笔 ,失败/拒绝 `withdraw_refund` 一笔 +)。
- `out_bill_no` ← 被流水 `ref_id` 引用,**账本按 `source` 分**:`coin_cash` 单 → `cash_transaction`(`withdraw` / `withdraw_refund` +);`invite_cash` 单 → `invite_cash_transaction`(`invite_withdraw` / `invite_withdraw_refund` +)。admin `GET /admin/api/withdraws/ledger-check` 按 source 分账做「单 ↔ 流水」对账(#121)。
- 打款方式依赖 `wechat_transfer_authorization`(用户有生效授权 → 免确认转账,否则确认模式)。
## 索引与约束