Compare commits

..

26 Commits

Author SHA1 Message Date
zhuzihao 357b2312af chore(deploy): 穿山甲收益拉取 systemd 定时器(每天10:30)+ 运维手册 (#100)
#92 的 scripts/sync_pangle_revenue.py 补 systemd 部署单元,让穿山甲 GroMore T+1 后台收益每天自动拉取入库,免人工敲命令。纯部署/文档文件,不改运行时代码。

- service:oneshot,跑 `--days 3` 回补近 3 天(幂等 upsert,扛偶发漏跑与穿山甲历史订正);10min 硬超时;加固块对齐主服务。
- timer:每天 10:30 触发(穿山甲约 10:00 出数留余量),Persistent 宕机后补跑。
- md:运维手册——上线前置(.env 填 PANGLE_REPORT_* 凭证、子账号需授权否则接口 118)、部署/健康检查/参数/注意事项(join key 用 ad_unit_id 非 code_id 等)。
- 风格对齐现有 deploy/daily-exchange.* 与 deploy/meituan-etl.*。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #100
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-30 22:59:41 +08:00
zhuzihao 78d970f420 feat: 领券数据看板 — coupon_session 流水表 + admin 聚合接口 (#99)
- 新表 coupon_session(一次领券一行,trace_id 唯一):POST /api/v1/coupon/session
  两段 upsert(发起 started / 收尾 completed·failed·abandoned),记全程耗时、各平台耗时、
  机型/ROM、app_env、trace_url、origin_package(发起来源)。
- admin GET /admin/api/coupon-data:发起/完成数 + 平均耗时 + P5/P50/P95/P99(Python 算分位,
  SQLite 无 percentile)+ 按天/小时趋势 + 明细分页 + join 用户手机号/昵称,app_env 默认 prod;
  另加 GET /admin/api/coupon-data/user-records 供「点手机号看该用户全部领券」抽屉。
- 迁移拆 3 个:建表 coupon_session_table + trace_url 加列 + origin_package 加列
  (建表迁移已被某环境应用后改它不重跑,故新列单独加列迁移)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #99
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-30 22:59:36 +08:00
chenshuobo a3a14b484a feat: 接入京东联盟订单到数据大盘 (#90)
改了什么:新增京东联盟订单查询客户端、cps_order 京东字段迁移、CPS 对账接口 platform=all/jd、数据大盘京东订单与佣金聚合。
为什么改:数据大盘需要展示京东 CPS 真实订单佣金,不再保持占位。
验证方式:python -m pytest tests\\test_admin_read.py::test_dashboard_overview tests\\test_cps_admin.py::test_jd_reconcile_updates_dashboard -q;本地真实拉取 2026-06-27 京东订单 3 行,页面显示京东有效 1 单/佣金 ¥0.13。

---------

Co-authored-by: lowmaster-chen <1119780489@qq.com>
Reviewed-on: #90
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-30 21:56:58 +08:00
liujiahui 208112ff24 fix(dl): 落地页底图+logo 纳入 git → 修生产 /media 404 毛坯 (#97)
## 背景
用户反馈浏览器落地页仍是「毛坯」(裸黄底、缺优惠券卡/价格卡/logo)。

## 真因(实测线上)
`https://app-api.shaguabijia.com/media/dl.html` 的 **HTML 早已是完善版且已部署**,但它引用的两张静态图被 `.gitignore` 的 `data/media/*` 规则挡在库外、从未随部署进生产 `/media`:

| 资源 | 线上 | 本地/git |
|---|---|---|
| `/media/coupon-page-bg.png`(底图:优惠券卡+跨平台比价价格卡) | **404**(22B `{"detail":"Not Found"}`) | 之前被 gitignore |
| `/media/sb-brand.png`(logo) | **404** | 之前被 gitignore |
| `/media/shaguabijia.apk`(对照) | 200 ✓ | — |

→ 两图加载失败 → 页面回退裸黄底 = 毛坯。

## 改动
照 `dl.html` / `taobao_landing.jpg` 既有白名单写法,把这两张图加进 `.gitignore` 例外并入库。
- `.gitignore`:`!data/media/coupon-page-bg.png`、`!data/media/sb-brand.png`
- 入库两图,md5 与原型一致(coupon-page-bg `f2adf28a` / sb-brand `e5b6e004`)

**无代码/HTML 改动**(dl.html 本就对齐原型,见上一 commit `6d4cbc8`)。

## 部署后生效
合并并部署本分支后,生产 `/media/` 即含这两图,落地页恢复完整。对应 6-29 UI 对齐 #10。

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #97
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-06-30 20:48:31 +08:00
zhuzihao 32f300b5a2 调现金接口支持目标账户(金币现金 / 邀请奖励金)+ overview 带邀请余额 (#95)
- GrantCashRequest 加 account(coin_cash / invite_cash,默认 coin_cash 兼容旧调用)
- 调现金接口按 account 分支:邀请账户走 grant_invite_cash,余额读取/扣负保护/审计
  detail 都按对应账户走;两本账物理隔离不可串
- 用户 360 overview 返回 invite_cash_balance_cents(admin 调现金弹窗展示邀请余额)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #95
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-30 12:02:56 +08:00
zhuzihao 81639ed388 fix(ad-revenue): 报表收益按满额钳顶 + 新增 app_env 过滤 (#93)
广告收益报表两处隐患修复(admin/repositories/ad_revenue.py + routers/ad_revenue.py):

- 收益钳顶:单次展示收益原用裸 parse_ecpm_yuan、未钳上限;发奖侧 calculate_ad_reward_coin
  已钳 AD_ECPM_MAX_FEN(¥500 CPM)。异常/伪造天价 eCPM 会让报表预估收益虚高任意大。改为
  min(parse_ecpm_yuan, AD_ECPM_MAX_FEN/100)/1000,与发奖同口径。
- app_env 过滤:报表聚合原无 app_env 过滤,测试应用假 eCPM(如 ¥678 CPM)会进正式收益合计/平均。
  新增 app_env 参数(repository + router Query),显式传 prod/test 才过滤;默认仍全部(不擅自改成
  默认排除 test:本地 dev 库多为 test 会空、且属产品口径)。穿山甲后台收益列暂未联动此过滤。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #93
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-29 23:11:41 +08:00
zhuzihao 5efe624340 admin 反馈/低价审核接口增强 + 反馈采集端环境(版本/机型) (#94)
- 反馈:新增 GET /admin/api/feedbacks/summary(待审核/已采纳/未采纳/合计计数);
  列表联表带出完整手机号 + 昵称(供 admin 点手机号查该用户全部反馈)
- 低价审核:列表加 sort_by/sort_order(提交时间排序);联表带出手机号/昵称,
  并按 comparison_record_id 关联比价记录带出 trace_id/trace_url + 机型/ROM/Android/app 版本
- feedback 表新增 app_version/device_model/rom_name/android_version 四列 + alembic 迁移;
  /api/v1/feedback 提交接口接收并落库,供 admin 反馈页展示「提交版本号」「机型OS版本」
  (字段可空,旧端不传不影响提交)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #94
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-29 23:11:30 +08:00
zhuzihao b622f76a02 feat(ad-revenue): 接入穿山甲 GroMore 数据 API 拉取后台收益(预估+收益API) (#92)
- 新增穿山甲 GroMore「聚合数据报告 API」对接,按天 T+1 拉取后台 revenue(预估)与
  api_revenue(收益API,更接近结算),在广告收益报表大盘 + 按天趋势级展示,与客户端自报
  eCPM 折算的预估并列对照;逐条广告事件行不动(仍是客户端预估)。
- 链路:integrations/pangle_report.py(MD5 签名,与官方文档两个测试向量逐字节一致)→
  scripts/sync_pangle_revenue.py(拉昨天/--days 回补)→ 新表 ad_pangle_daily_revenue
  (repositories/ad_pangle_revenue.py:upsert + 按日聚合)→ admin/repositories/ad_revenue.py
  汇总,新增 total_pangle_revenue_yuan / total_pangle_api_revenue_yuan / daily[].pangle_*。
- 穿山甲无用户/类型/场景维度:仅全量视图(未按 user/ad_type/feed_scene 过滤)给值,否则置
  None;join key 用 ad_unit_id(=客户端配的 104xxx),非 code_id。
- 新增配置 PANGLE_REPORT_USER_ID/ROLE_ID/SECURITY_KEY(≠发奖 m-key)+ site_id→应用映射;
  含单测(签名向量+分页解析);已真连穿山甲验证。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #92
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-29 09:54:13 +08:00
wuqi c0b67fd879 feat(launch_confirm): 新增启动确认样本列表内部接口(供 pricebot distill 回读) (#91)
补齐「启动确认窗自进化闭环」里 app-server 这一侧的读路径:pricebot 的 launch_confirm
LLM 兜底成功样本会 server→server 落到 app-server 的 launch_confirm_sample 表;本提交
新增「样本列表查询」内部接口, 供 pricebot 的 scripts/distill_launch_confirm.py 回读这些
样本、按「宿主包 × 文案变体」聚合沉淀出候选规则。

- app/api/internal/launch_confirm.py: 新增样本列表内部接口(server→server, 不走用户 JWT)
- app/repositories/launch_confirm_sample.py: 样本列表查询
- app/schemas/launch_confirm_sample.py: 列表响应 schema

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Reviewed-on: #91
Co-authored-by: wuqi <wuqi@wonderable.ai>
Co-committed-by: wuqi <wuqi@wonderable.ai>
2026-06-28 21:40:42 +08:00
xiebing 2ed62c789f feat(h5): 我的页改 H5(mine 页 + shared bridge/api) (#89)
- h5/mine/index.html: 个人中心「我的页」H5 实现
- h5/shared/bridge.js, api.js: H5↔客户端桥与 API 封装(mine 依赖)

Reviewed-on: #89
Co-authored-by: xiebing <xiebing@wonderable.ai>
Co-committed-by: xiebing <xiebing@wonderable.ai>
2026-06-28 14:16:32 +08:00
chenshuobo 1548406f29 美团 feed 改用离线库 + 三 tab 降级兜底(替换 main 的实时版 feed) (#88)
Co-authored-by: lowmaster-chen <1119780489@qq.com>
Co-authored-by: chenshuobo <1119780489@qq.com>
Reviewed-on: #88
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-28 10:38:31 +08:00
guke b7b958ed58 perf(pricebot): 透传复用共享 httpx 单例,免每请求重建 SSL 上下文 + 绕过进程代理 (#87)
coupon/compare 透传原先每请求 async with httpx.AsyncClient(...) 新建:
每次重建 SSL 上下文(加载 certifi CA)实测 ~1s+,而 pricebot 是纯 HTTP
透传根本用不到 TLS;且 trust_env 默认 True 会把 http://localhost:8000
经进程代理(Clash)再绕几秒。

抽 app/core/pricebot_client.py 共享单例:trust_env=False 直连,lifespan
启动预热(SSL 一次性成本付在启动)、关停 aclose;timeout 下放到 .post()
保留 coupon 30s / compare 60s 差异。keep-alive 复用 TCP,每帧降到个位数 ms。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #87
Co-authored-by: guke <guke@wonderable.ai>
Co-committed-by: guke <guke@wonderable.ai>
2026-06-28 09:36:31 +08:00
wuqi f3cd97a190 feat: 信息流广告改"所见即所得"发奖——直接发客户端小球显示金币 (#86)
Reviewed-on: #86
Co-authored-by: wuqi <wuqi@wonderable.ai>
Co-committed-by: wuqi <wuqi@wonderable.ai>
2026-06-27 23:49:51 +08:00
zhuzihao b4c27f4d88 fix(ad): eCPM 上报撞会话唯一约束不再抛 500,按全局口径兜底去重 (#85)
create_ecpm_record 撞 uq_ad_ecpm_record_session(只含 ad_session_id、全局唯一)
时,改用与约束同口径的全局查找 _find_by_session_global(仅按 ad_session_id)兜底,
并去掉末尾的 raise。原逻辑兜底用带 user_id 的 find_by_session:不同 user 上报了
同一 ad_session_id 时二次查找仍为 None → raise → 接口 500,违背该接口
best-effort / fire-and-forget 约定(丢一两条不影响业务,穿山甲后台才是结算权威)。
find_by_session 签名与行为不变,另两个调用方(ad_reward、ad.py)不受影响。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #85
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-27 22:42:58 +08:00
zhuzihao e38120ad49 feat(ad-revenue): 收益报表分页/场景筛选/倒序,并修复信息流金币审计复算口径+ 大盘改版 (#84)
收益报表(/admin/api/ad-revenue-report):
- 明细改按时间倒序;新增 offset 真分页(limit 作每页大小、total 为全量),可翻页看当前筛选下全部数据,突破原 1000 条上限。
- 「场景」(feed_scene)下推后端做全局筛选,同时作用于明细/合计/daily·hourly 趋势(原为前端仅过滤明细)。
- 新增全量 hourly 序列,按小时趋势改用它,不再受分页截断影响。

修复信息流金币审计复算口径漂移(ad_audit):
- 发奖侧 grant_feed_reward 早已是「一条广告=1份、LT 按账号累计条数(COUNT)」,但审计仍按 unit_count 逐份累加 + SUM(unit_count) 做 LT 基线,导致单条停留>20s(份数>1)时应发虚高、必然「✗ 不符」。
- 审计改为每条 granted 按 1 份复算、LT 基线用 COUNT,与发奖对齐;金币审计页与收益报表(复用同一复算)一并恢复正确。纯复算口径修正,不改实际发奖、不动钱。

文档同步更新 admin-ad-revenue-report.md。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #84
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-27 22:42:54 +08:00
marco 3d67749101 fix(compare-record): specs 兼容 pricebot 嵌套规格对象
pricebot 嵌套规格统一(2026-06-27)后 calibration.specs 可能是规格对象
[{name, qty, sub_specs}] 而非字符串。ComparisonItemIn.specs 加 before-validator
把对象拍平成可读字符串"主项(子1,子2)",兼容新旧输入、保持 list[str] 契约(下游零改动)。
防两坑:① 直接声明 list[str] → 对象 specs 整条 422 被拒、不入库;
② 仅放宽成裸 list → 下游 join 出"[object Object]"乱码。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 09:26:00 +08:00
marco f1c2ea662b fix(migration): merge alembic 3-way 分叉为单 head(修本地 upgrade head 报 Multiple head revisions)
#82(invite_cash)/埋点(c2874d2bf705)/device_first_protected_at 三个 PR 并行合 main 留下 3 个 head,
`alembic upgrade head`(单数)报 Multiple head revisions。加一个空 merge 迁移把三支并成单 head,
deploy 的 `upgrade heads`(复数)和本地 `upgrade head` 都干净。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:06:53 +08:00
marco be15a119a7 fix(migration): 缩短 revision id 至 26 字符(修 alembic_version varchar(32) 截断致 0.2.1 部署失败)
invite_cash_account_and_compare_reward(38字符) > alembic_version.version_num varchar(32),
INSERT 时 psycopg StringDataRightTruncation → 部署 alembic 迁移失败。改为 invite_cash_compare_reward(26)。
该迁移是链末端(无后续 down_revision 依赖)、首次部署(任何环境都没应用过、PG DDL 事务失败已回滚无残留), 改 id 安全。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 02:58:29 +08:00
zhuzihao 59fe715245 feat(analytics): 埋点事件接收接口 + admin 查询接口 + 埋点表 (#83)
Reviewed-on: #83
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-26 23:56:29 +08:00
zhuzihao 667cda566f feat(analytics): 埋点事件接收接口 + admin 查询接口 + 埋点表 (#81)
新手引导埋点的服务端:
- 表 analytics_event(五维硬性列 + props JSON 扩展字段;event/device_id/user_id/session_id/created_at 带索引)
- POST /api/v1/analytics/events:客户端批量上报(不鉴权、body 读可选 user_id、补 client_ip + server_at)
- admin GET /admin/api/event-logs:列表 + 按 事件/设备/用户/会话/时间 筛选(offset 分页,照 list_feedbacks)
- alembic migration 建表(autogenerate 顺带检出的 ad/cps 历史索引漂移已手动剔除)

app 主后端 :8770 与 admin :8771 共用同一 SQLite,admin 同库直接查、无需跨库。
配套客户端五维上报 + admin 日志页(另两仓库 PR)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #81
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-26 23:33:31 +08:00
xiebing 19f5987436 feat(invite): 邀请奖励金账户(与金币隔离) + 比价发奖 + 奖励金提现 (#82)
Reviewed-on: #82
Co-authored-by: xiebing <xiebing@wonderable.ai>
Co-committed-by: xiebing <xiebing@wonderable.ai>
2026-06-26 21:10:21 +08:00
chenshirui 1d6432d8bb feat(admin): 设备存活监控接口 + 首次无障碍开启时间(first_protected_at) (#80)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------

Co-authored-by: 陈世睿 <2839904623@qq.com>
Reviewed-on: #80
Co-authored-by: chenshirui <chenshirui@wonderable.ai>
Co-committed-by: chenshirui <chenshirui@wonderable.ai>
2026-06-26 15:28:16 +08:00
guke 2eb44fe947 feat(cps): 每日明细按天按用户领券下钻接口 + /daily date 改全日期 (#79)
- 新增 GET /admin/api/cps/groups/{id}/day-users?date=YYYY-MM-DD:
  按 openid 聚合当天领券(copy)/点击(visit) + 每人 visit 过的券(券×次数)

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #79
2026-06-26 15:17:37 +08:00
zhuzihao ea22a11ef9 fix(ad): 激励视频发奖按环境隔离,堵测试包看广告给生产同 id 用户串号发币 (#77)
## 问题
激励视频发奖走穿山甲 S2S 回调 /api/v1/ad/pangle-callback,发币时只认回调里的裸数字
user_id(grant_ad_reward 仅校验「本库存在该 id」),无任何环境归属校验。

同一 user_id 在测试库(本地 localhost)与正式库(线上)是两个不同的人;穿山甲 S2S 回调
地址只配一个(指向生产),测试包(dev)看广告的回调也会打到生产 → 生产把奖发给「本库里
同 user_id 的另一个真人」,造成跨库串号:测试看一次广告,正式库同号用户也被发币(线上
广告收益页因此多出一条本不该有的发奖记录)。

## 修复(服务端,按环境隔离)
pangle_callback 解析激励视频 mediaExtra 透传的 srv_env(dev/prod),与本服 settings.APP_ENV
不符则受理但不发币、不写记录(is_verify=true 防穿山甲重试)。不带 srv_env 的旧客户端按本
环境放行(兼容,不误伤存量正式用户)。

- 新增回归测试 test_callback_foreign_env_skipped(srv_env=prod 打到 APP_ENV=dev 的服务 → 不发)
- sim_pangle_callback 增加 --srv-env 便于本地手验

客户端配套(mediaExtra 写入 srv_env)见 app-android 对应 PR。

## 生效前提(三者缺一仍漏)
1. 生产 app-server 部署本代码并重启
2. 生产 .env 为 APP_ENV=prod
3. app 重新构建(旧包不带 srv_env 仍走兼容放行)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #77
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-26 13:17:27 +08:00
zhuzihao 2edebfad83 feat(auth): 短信登录防刷 — 同一设备id同IP 每小时限 5 次 (#78)
Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #78
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-26 13:17:07 +08:00
guke 3dca126411 fix(dev): 启动脚本钉定 .venv 解释器 + watchfiles 降噪 (#75)
微信优惠券增加美团/京东中间页,领券前获取微信头像授权(主功能)

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #75
Co-authored-by: guke <guke@wonderable.ai>
Co-committed-by: guke <guke@wonderable.ai>
2026-06-25 17:47:30 +08:00
121 changed files with 15110 additions and 426 deletions
+20
View File
@@ -58,6 +58,13 @@ MT_CPS_DEFAULT_SID=sgbjia
# 线上国内服务器留空(=直连)。留空且本机直连失败时 /feed、/coupons、/top-sales 会返回空。
MT_CPS_PROXY=
# ===== 京东联盟 CPS =====
# 京东联盟/京东宙斯开放平台创建应用后填写。AUTH_KEY 是工具商授权 key,自有应用可留空。
JD_UNION_APP_KEY=
JD_UNION_APP_SECRET=
JD_UNION_SITE_ID=
JD_UNION_AUTH_KEY=
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
@@ -117,3 +124,16 @@ PANGLE_REWARD_SECRET=
# ⚠️ 仅本地联调:true 时开放 POST /api/v1/ad/test-grant,让 debug 客户端看完广告直接发奖,
# 验证"看广告→金币到账"全链路(未部署公网、穿山甲 S2S 打不到本地时用)。生产必须 false(绕过反作弊)。
AD_REWARD_TEST_GRANT_ENABLED=false
# ===== 穿山甲 GroMore 数据 API(按天拉收益报表,供后台广告收益报表的「穿山甲后台收益」)=====
# ⚠️ 与上面发奖回调的 m-key 是【两套不同凭证】:这三样在穿山甲后台「接入中心 → GroMore-API →
# 聚合数据报告 API」文档页领取。只读拉取 GroMore 天级 revenue(预估)/ api_revenue(收益Api),
# 不参与发奖。三样齐全才生效;留空 = scripts/sync_pangle_revenue 直接 no-op。
# 子账号(role_id≠user_id)需主账号在「角色管理」授予「查看全部数据」权限,否则查不到收益(接口 118);
# role_id 填成 = user_id 即查主账号数据。同步:线上每天 ~10:30 由 timer 跑 python -m scripts.sync_pangle_revenue。
PANGLE_REPORT_USER_ID=0
PANGLE_REPORT_ROLE_ID=0
PANGLE_REPORT_SECURITY_KEY=
# GroMore AppId(报表 site_id 维度)→ 应用环境;默认取现网两个应用,按需覆盖。
PANGLE_REPORT_SITE_ID_PROD=5830519
PANGLE_REPORT_SITE_ID_TEST=5832303
+5 -2
View File
@@ -23,14 +23,17 @@ dist/
*.db-journal
*.db-shm
*.db-wal
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html——
# 它既是生产落地页又是本地测试资产,纳入 git 便于同事一致测试
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html 及其
# 引用的静态插画(coupon-page-bg.png 底图 + sb-brand.png logo)——既是生产落地页资产
# 又是本地测试资产,纳入 git 便于同事一致测试、且随部署进生产 /media(否则线上 404→落地页毛坯)。
# (见 docs/邀请功能-实现原理与本地测试.md)。其余(avatars/ / *.apk / app.db 等)仍忽略。
data/*
!data/media/
data/media/*
!data/media/dl.html
!data/media/taobao_landing.jpg
!data/media/coupon-page-bg.png
!data/media/sb-brand.png
secrets/*
!secrets/.gitkeep
+116
View File
@@ -0,0 +1,116 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project overview
Shaguabijia (傻瓜比价) App backend — FastAPI + SQLAlchemy 2.0 + JWT. Covers user auth (Jiguang one-click / SMS), welfare wallet (coins/cash/signin/tasks/savings), WeChat Pay withdrawals, ad-reward callbacks (Pangle/GroMore S2S), Meituan CPS (coupon forwarding / price comparison), and an admin backend.
## Commands
```bash
# Install
pip install -e ".[dev]"
# Run app server (port 8770, auto-migrates, auto-reload)
./run.sh # or: uvicorn app.main:app --reload --port 8770
# Run admin server (port 8771, separate process)
uvicorn app.admin.main:admin_app --reload --port 8771
# Database
alembic upgrade head # apply all migrations (idempotent)
alembic revision --autogenerate -m "description" # generate new migration
# Tests
pytest -q # all tests
pytest tests/test_auth.py -q # single file
pytest -k "test_sms_login" -q # single test by name
# Lint
ruff check .
ruff check --fix .
```
## Architecture: two FastAPI apps
This repo runs **two separate FastAPI processes** sharing the same `app/` codebase (models, repositories, integrations, config):
| | App server | Admin server |
|---|---|---|
| Entry | `app/main.py``app:app` | `app/admin/main.py``admin_app` |
| Port | 8770 | 8771 |
| Auth | User JWT (`JWT_SECRET_KEY`) | Admin JWT (`ADMIN_JWT_SECRET`, independent) |
| Audience | Mobile app clients | Internal admin dashboard |
| Docs | `/docs` (non-prod only) | `/admin/docs` (non-prod only) |
The two apps are intentionally decoupled — `app.main` never imports `app.admin`. Admin has its own auth chain (`app/admin/deps.py`, `app/admin/security.py`), role-based guards (`require_role`), and routers under `app/admin/routers/`.
## Layered request flow
```
api/v1/ (thin: parse → delegate → respond + HTTP errors)
├── integrations/ (external SDKs: signature, encryption, HTTP calls)
└── repositories/ (data access + transactions)
└── models/ (SQLAlchemy ORM, DeclarativeBase)
```
- **`api/v1/`**: Route handlers. Keep these thin — parse request, call repository or integration, return response. Never put business logic or external HTTP here.
- **`api/deps.py`**: Shared FastAPI dependencies — `get_current_user` (Bearer JWT → User ORM object), `get_db` (request-scoped session).
- **`integrations/`**: All external service logic — Jiguang REST + RSA decryption, WeChat Pay V3 signing/encryption, Meituan CPS gateway signing, Pangle callback signature verification, SMS sending. This is the layer you change when swapping vendors.
- **`repositories/`**: Data access. Each file wraps SQLAlchemy queries + transactions for one domain (user, wallet, signin, savings, ad_reward, etc.). Some repositories also call integrations (e.g., `wallet.py` calls `integrations/wxpay.py` for withdrawals).
- **`models/`**: ORM table definitions (SQLAlchemy 2.0 `Mapped` style, `DeclarativeBase`). Every new model must be imported in `app/models/__init__.py` so Alembic can discover it.
- **`schemas/`**: Pydantic request/response contracts.
- **`core/`**: Infrastructure — config (`pydantic-settings`), JWT (`security.py`), in-memory rate limiter (`ratelimit.py`), reward constants (`rewards.py`), logging setup, pricebot router (consistent-hash load balancing), withdraw reconcile worker.
## Internal (server-to-server) endpoints
Endpoints under `app/api/internal/` are for server-to-server communication (pricebot → app-server), NOT for clients. They use a shared secret header `X-Internal-Secret` (compared via `hmac.compare_digest`) instead of user JWT. If `INTERNAL_API_SECRET` is empty, these endpoints return 503.
## Auth system
- **User login**: Jiguang one-click (`integrations/jiguang.py` — REST token verification + RSA decryption with multi-padding retry) or SMS code (mock by default; `SMS_MOCK=true`).
- **Tokens**: JWT access (2h) + refresh (30d). Both are JWT with `typ` claim (`"access"` vs `"refresh"`) to prevent refresh-as-access. See `core/security.py`.
- **Admin auth**: Separate JWT secret (`ADMIN_JWT_SECRET`), 12h expiry, no refresh. Username + bcrypt password login. Role-based access via `require_role()` guard in `app/admin/deps.py` (`super_admin` bypasses all role checks).
- **Rate limiting**: In-memory fixed-window by client IP (`core/ratelimit.py`). Single-worker only; disabled in tests via `RATE_LIMIT_ENABLED=false`.
## Database
- **Dev**: SQLite (`sqlite:///./data/app.db`), `check_same_thread=False`, no connection pool.
- **Prod**: PostgreSQL — just change `DATABASE_URL` in `.env`. Pool size 10 + max overflow 20, pool_recycle 3600.
- **Migrations**: Alembic with `render_as_batch` for SQLite compatibility. ~60+ migration files in `alembic/versions/` (filenames are descriptive, not hex prefixes). Migration chain uses `down_revision` within each file.
- **New models**: Define in `app/models/`, import in `app/models/__init__.py`, then run `alembic revision --autogenerate`.
## Config
All config via `pydantic-settings` in `app/core/config.py`. Single `Settings` class with env vars / `.env` file. Access anywhere via `from app.core.config import settings`. Key patterns:
- `*_configured` properties gate features gracefully (e.g., `mt_cps_configured`, `wxpay_configured`, `pangle_callback_configured`) — missing credentials → endpoints return empty/503 rather than crashing at startup.
- Prod validation: `_enforce_prod_secrets` model validator blocks startup if `APP_ENV=prod` with weak JWT secrets.
## Testing
- `tests/conftest.py`: Sets env vars BEFORE imports, creates temp SQLite file, builds all tables with `Base.metadata.create_all()`, tears down with `drop_all()` + unlink.
- External integrations are monkeypatched in tests (e.g., WeChat Pay, Jiguang, Pangle callbacks) — tests never make real HTTP calls.
- `TestClient` from FastAPI is used for all tests. Rate limiting is disabled globally in tests.
## Key integration details
- **Jiguang one-click login**: REST call to verify `loginToken`, then RSA decrypt the returned phone number. Multiple padding schemes tried in order (PKCS1v15, OAEP with SHA1/SHA256) because Jiguang's encryption padding varies.
- **WeChat Pay withdrawals**: V3 API merchant transfer to user WeChat balance. Lazy-loads merchant certificates from `secrets/`. Withdrawal flow: bind WeChat → create withdraw order → auto-reconcile worker polls pending orders.
- **Pangle ad rewards**: S2S callback verification via SHA256 signature. Multiple `m-key` secrets supported (one per ad placement). Callback is idempotent by `trans_id`. Test grant endpoint (`AD_REWARD_TEST_GRANT_ENABLED`) for local debugging — must be false in prod.
- **Meituan CPS**: Gateway signature-based API calls. Proxy support (`MT_CPS_PROXY`) for local dev (direct connection causes SSL EOF). Coupon endpoints gracefully return empty when credentials are missing.
- **Pricebot forwarding**: `/api/v1/coupon/step` and `/api/v1/compare/*` proxy to pricebot-backend. Multi-instance support with consistent-hash routing by `trace_id` (see `core/pricebot_router.py`).
- **CPS redirect**: `/c/{code}` is a public (no auth) short-link redirect — records a click then 302s to Meituan. Click recording failure never blocks the redirect.
## Money and units
All monetary amounts are in **cents** (`*_cents` fields). Coins/gold have their own unit. Conversion constants are in `core/rewards.py`.
## Scripts
Key operational scripts in `scripts/`:
- `migrate.sh` — run migrations standalone
- `create_admin.py` — create admin user
- `daily_auto_exchange.py` — auto-convert coins to cash (triggered by systemd timer)
- `reconcile_withdraws.py` — reconcile withdrawal orders with WeChat Pay
- `sim_pangle_callback.py` — simulate Pangle S2S callback for testing
@@ -0,0 +1,65 @@
"""add analytics_event table
Revision ID: 1699fc2c069f
Revises: bcfcaf07152b
Create Date: 2026-06-26 16:35:16.133975
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '1699fc2c069f'
down_revision: str | Sequence[str] | None = 'bcfcaf07152b'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('analytics_event',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('event', sa.String(length=64), nullable=False),
sa.Column('props', sa.JSON(), nullable=True),
sa.Column('device_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('session_id', sa.String(length=64), nullable=True),
sa.Column('client_ts', sa.BigInteger(), nullable=False),
sa.Column('sent_at', sa.BigInteger(), nullable=True),
sa.Column('page', sa.String(length=64), nullable=True),
sa.Column('client_ip', sa.String(length=64), nullable=True),
sa.Column('oem', sa.String(length=32), nullable=True),
sa.Column('os', sa.String(length=32), nullable=True),
sa.Column('model', sa.String(length=64), nullable=True),
sa.Column('app_ver', sa.String(length=32), nullable=True),
sa.Column('network', sa.String(length=16), nullable=True),
sa.Column('channel', sa.String(length=32), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('analytics_event', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_analytics_event_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_event_device_id'), ['device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_event_event'), ['event'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_event_session_id'), ['session_id'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_event_user_id'), ['user_id'], unique=False)
# 注:autogenerate 顺带检出 ad_ecpm/cps_*/invite_fingerprint 的历史索引漂移,
# 与本次「新增埋点表」无关,已手动移除,避免本迁移误改他人表。
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('analytics_event', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_analytics_event_user_id'))
batch_op.drop_index(batch_op.f('ix_analytics_event_session_id'))
batch_op.drop_index(batch_op.f('ix_analytics_event_event'))
batch_op.drop_index(batch_op.f('ix_analytics_event_device_id'))
batch_op.drop_index(batch_op.f('ix_analytics_event_created_at'))
op.drop_table('analytics_event')
# ### end Alembic commands ###
@@ -0,0 +1,26 @@
"""merge invite_cash + analytics + device heads
Revision ID: 7db22acee504
Revises: c2874d2bf705, device_first_protected_at, invite_cash_compare_reward
Create Date: 2026-06-27 03:06:52.594401
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7db22acee504'
down_revision: Union[str, Sequence[str], None] = ('c2874d2bf705', 'device_first_protected_at', 'invite_cash_compare_reward')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,63 @@
"""ad_pangle_daily_revenue: 穿山甲 GroMore 天级收益报表(后台结算口径)
新建表存放从 GroMore 数据 API 按天拉取的收益(revenue 预估 + api_revenue 收益Api),
粒度 = 日期 × 应用(app_env) × 代码位(our_code_id) × 广告源(adn)。供广告收益报表的
汇总/趋势级展示「穿山甲后台收益」,与客户端自报 eCPM 折算的预估互为对照。
Revision ID: ad_pangle_daily_revenue
Revises: 7db22acee504
Create Date: 2026-06-28
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "ad_pangle_daily_revenue"
down_revision = "7db22acee504"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"ad_pangle_daily_revenue",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("report_date", sa.String(length=10), nullable=False),
sa.Column("app_env", sa.String(length=16), nullable=False),
sa.Column("site_id", sa.String(length=32), nullable=True),
sa.Column("our_code_id", sa.String(length=64), nullable=False),
sa.Column("adn", sa.String(length=16), nullable=False, server_default=""),
sa.Column("revenue_yuan", sa.Float(), nullable=False, server_default="0"),
sa.Column("api_revenue_yuan", sa.Float(), nullable=True),
sa.Column("ecpm", sa.String(length=32), nullable=True),
sa.Column("impressions", sa.Integer(), nullable=False, server_default="0"),
sa.Column("currency", sa.String(length=8), nullable=False, server_default="cny"),
sa.Column(
"synced_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.UniqueConstraint(
"report_date", "app_env", "our_code_id", "adn", name="uq_ad_pangle_daily"
),
)
op.create_index(
"ix_ad_pangle_daily_revenue_report_date",
"ad_pangle_daily_revenue",
["report_date"],
)
op.create_index(
"ix_ad_pangle_daily_revenue_our_code_id",
"ad_pangle_daily_revenue",
["our_code_id"],
)
def downgrade() -> None:
op.drop_index("ix_ad_pangle_daily_revenue_our_code_id", table_name="ad_pangle_daily_revenue")
op.drop_index("ix_ad_pangle_daily_revenue_report_date", table_name="ad_pangle_daily_revenue")
op.drop_table("ad_pangle_daily_revenue")
@@ -0,0 +1,46 @@
"""add ad_type to ad_feed_reward and feed_scene to ad_ecpm
把信息流广告全面改造成 Draw 信息流(draw):
- ad_feed_reward_record.ad_type:广告形态 feed(信息流) / draw(Draw 信息流)。可空,旧数据 NULL
一律视为 feed(向后兼容);每日上限与因子2(LT)仍按本表全表 unit 累计,不按 ad_type 拆。
- ad_ecpm_record.feed_scene:点位场景 comparison(比价) / coupon(领券) / welfare(福利),供广告
收益报表区分比价/领券 Draw 收益;仅信息流/Draw 上报,激励视频为 NULL。
注:autogenerate 会顺带探测到 analytics_event / cps_* / invite_fingerprint 等无关索引差异(本地库与
metadata 漂移、analytics 模型尚未并入 __init__),与本次改动无关,已手工剔除——本迁移只 add 两列。
SQLite 经 env.py 的 render_as_batch 自动走 batch_alter_table 重建表。
Revision ID: c2874d2bf705
Revises: 1699fc2c069f
Create Date: 2026-06-26 16:48:52.158609
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c2874d2bf705"
down_revision: str | Sequence[str] | None = "1699fc2c069f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# 信息流发奖记录:新增广告形态 feed/draw(可空,旧数据 NULL=feed)
op.add_column(
"ad_feed_reward_record",
sa.Column("ad_type", sa.String(length=16), nullable=True),
)
# eCPM 展示上报:新增点位场景 comparison/coupon/welfare(可空,激励视频/旧数据 NULL)
op.add_column(
"ad_ecpm_record",
sa.Column("feed_scene", sa.String(length=16), nullable=True),
)
def downgrade() -> None:
op.drop_column("ad_ecpm_record", "feed_scene")
op.drop_column("ad_feed_reward_record", "ad_type")
@@ -0,0 +1,32 @@
"""coupon_session 加 origin_package 列(发起来源 App 包名 → admin「发起平台」)
null=App 内(傻瓜比价首页)发起,非空=从美团/淘宝/京东弹券发起。与 trace_url 同理单独成迁移,
已建表环境靠它补列、全新环境顺序应用,不重复加列。
Revision ID: coupon_session_origin_package
Revises: coupon_session_trace_url
Create Date: 2026-06-30 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_session_origin_package"
down_revision: str | Sequence[str] | None = "coupon_session_trace_url"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.add_column(sa.Column("origin_package", sa.String(length=64), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.drop_column("origin_package")
+81
View File
@@ -0,0 +1,81 @@
"""coupon session table(领券任务全程流水 → admin「领券数据」看板数据源)
一次领券一行(trace_id 唯一):客户端 POST /api/v1/coupon/session 两段上报 —— 发起建行
(status=started)、收尾(completed/failed/abandoned)按 trace_id 更新同一行。记全程耗时
elapsed_ms + 各平台耗时 platform_elapsed + 机型/ROM,供 admin 算发起/完成数、耗时分位、机型维度。
Revision ID: coupon_session_table
Revises: feedback_submit_env
Create Date: 2026-06-30 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "coupon_session_table"
down_revision: str | Sequence[str] | None = "feedback_submit_env"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 model 的 _JSON variant)。
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
def upgrade() -> None:
op.create_table(
"coupon_session",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("trace_id", sa.String(length=64), nullable=False),
sa.Column("device_id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("app_env", sa.String(length=16), nullable=True),
sa.Column("platforms", _JSON, nullable=True),
sa.Column("device_model", sa.String(length=128), nullable=True),
sa.Column("rom", sa.String(length=64), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("started_date", sa.Date(), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("elapsed_ms", sa.Integer(), nullable=True),
sa.Column("platform_elapsed", _JSON, nullable=True),
sa.Column("claimed_count", sa.Integer(), 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("trace_id", name="uq_coupon_session_trace"),
)
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.create_index(
batch_op.f("ix_coupon_session_user_id"), ["user_id"], unique=False
)
batch_op.create_index(
batch_op.f("ix_coupon_session_app_env"), ["app_env"], unique=False
)
# admin 主聚合/筛选:按上海自然日 + 环境。
batch_op.create_index(
"ix_coupon_session_date_env", ["started_date", "app_env"], unique=False
)
def downgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.drop_index("ix_coupon_session_date_env")
batch_op.drop_index(batch_op.f("ix_coupon_session_app_env"))
batch_op.drop_index(batch_op.f("ix_coupon_session_user_id"))
op.drop_table("coupon_session")
@@ -0,0 +1,32 @@
"""coupon_session 加 trace_url 列(pricebot done 帧公网调试链接)
建表迁移 coupon_session_table 落地后才追加本列,故单独成一个迁移:已建表的环境(本地/已 upgrade 过)
靠它补列,全新环境则「建表(无 trace_url)→ 本迁移加列」,两条路一致、不重复加列。
Revision ID: coupon_session_trace_url
Revises: coupon_session_table
Create Date: 2026-06-30 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_session_trace_url"
down_revision: str | Sequence[str] | None = "coupon_session_table"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.add_column(sa.Column("trace_url", sa.String(length=512), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.drop_column("trace_url")
+52 -27
View File
@@ -23,39 +23,64 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 活动:淘宝淘口令 / 京东链接
op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True))
def _has_column(insp: sa.Inspector, table: str, column: str) -> bool:
return any(c["name"] == column for c in insp.get_columns(table))
# 群:多平台(现有群都是美团,server_default 回填)+ sid 可空
op.add_column(
"cps_group",
sa.Column(
"platforms",
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
nullable=False,
server_default=sa.text("'[\"meituan\"]'::jsonb"),
),
def upgrade() -> None:
bind = op.get_bind()
insp = sa.inspect(bind)
# SQLite 不认 ::jsonb cast(会报 "unrecognized token: :"),只有 PG 用显式 cast。
# 两边列类型都靠上面的 with_variant 区分,这里只管 server_default 文本。
platforms_default = (
sa.text("'[\"meituan\"]'::jsonb")
if bind.dialect.name == "postgresql"
else sa.text("'[\"meituan\"]'")
)
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=True)
# 活动:淘宝淘口令 / 京东链接
# 加 add_column 存在性守卫:本迁移历史上在 SQLite 第一次跑时加完 payload 即在
# platforms 那步炸掉(::jsonb),DDL 非事务不回滚 → 半应用状态。守卫让其可自愈重跑。
if not _has_column(insp, "cps_activity", "payload"):
op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True))
# 群:多平台(现有群都是美团,server_default 回填)+ sid 可空。
# SQLite 不支持 ALTER COLUMN,必须用 batch(重建表)才能改 nullable;PG 走原生 ALTER。
with op.batch_alter_table("cps_group") as batch_op:
if not _has_column(insp, "cps_group", "platforms"):
batch_op.add_column(
sa.Column(
"platforms",
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
nullable=False,
server_default=platforms_default,
)
)
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
# link:sid 可空 + target 加长
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=True)
op.alter_column("cps_link", "target_url", existing_type=sa.String(1024), type_=sa.String(2048))
with op.batch_alter_table("cps_link") as batch_op:
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
batch_op.alter_column("target_url", existing_type=sa.String(1024), type_=sa.String(2048))
# click:sid 可空 + 事件类型
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=True)
op.add_column(
"cps_click",
sa.Column("event_type", sa.String(16), nullable=False, server_default="visit"),
)
with op.batch_alter_table("cps_click") as batch_op:
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
if not _has_column(insp, "cps_click", "event_type"):
batch_op.add_column(
sa.Column("event_type", sa.String(16), nullable=False, server_default="visit")
)
def downgrade() -> None:
op.drop_column("cps_click", "event_type")
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=False)
op.alter_column("cps_link", "target_url", existing_type=sa.String(2048), type_=sa.String(1024))
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=False)
op.drop_column("cps_group", "platforms")
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=False)
op.drop_column("cps_activity", "payload")
with op.batch_alter_table("cps_click") as batch_op:
batch_op.drop_column("event_type")
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
with op.batch_alter_table("cps_link") as batch_op:
batch_op.alter_column("target_url", existing_type=sa.String(2048), type_=sa.String(1024))
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
with op.batch_alter_table("cps_group") as batch_op:
batch_op.drop_column("platforms")
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
with op.batch_alter_table("cps_activity") as batch_op:
batch_op.drop_column("payload")
+34
View File
@@ -0,0 +1,34 @@
"""feedback 加提交端环境快照(app_version / device_model / rom_name / android_version)
Revision ID: feedback_submit_env
Revises: ad_pangle_daily_revenue
Create Date: 2026-06-29 00:00:00.000000
admin 用户反馈页要展示「提交版本号」「机型OS版本」,需在提交时落库端环境。仅新增可空列,
SQLite 原生支持 add_column、不用 batch;downgrade 的 drop_column 在 SQLite 走 batch 兜底。
历史反馈无此快照 → 留 NULL(无法回填);客户端改版带上后的新反馈才有值。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "feedback_submit_env"
down_revision: Union[str, Sequence[str], None] = "ad_pangle_daily_revenue"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("feedback", sa.Column("app_version", sa.String(length=32), nullable=True))
op.add_column("feedback", sa.Column("device_model", sa.String(length=64), nullable=True))
op.add_column("feedback", sa.Column("rom_name", sa.String(length=32), nullable=True))
op.add_column("feedback", sa.Column("android_version", sa.String(length=16), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("feedback") as batch_op:
batch_op.drop_column("android_version")
batch_op.drop_column("rom_name")
batch_op.drop_column("device_model")
batch_op.drop_column("app_version")
@@ -0,0 +1,88 @@
"""invite cash account isolation + compare reward tracking (🅱-1)
Revision ID: invite_cash_compare_reward
Revises: feedback_review_fields
Create Date: 2026-06-23 00:00:00.000000
邀请功能 v2 账户隔离 + 比价发奖追踪:
- coin_account 加 invite_cash_balance_cents(邀请奖励金独立余额,与金币兑换的 cash 物理隔离)
- withdraw_order 加 source(标记提现扣哪个账户,退款退回对应账户;旧单默认 coin_cash)
- invite_relation 加比价发奖追踪三列(好友比价多次只发一次)
- 新增 invite_cash_transaction 表(邀请奖励金独立流水账本)
加列均 NOT NULL + server_default,存量行自动填默认值,安全。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'invite_cash_compare_reward'
down_revision: Union[str, Sequence[str], None] = 'feedback_review_fields'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. 邀请奖励金独立余额(与金币兑换的 cash_balance_cents 物理隔离;红线:两本账不可累加)
op.add_column(
'coin_account',
sa.Column('invite_cash_balance_cents', sa.Integer(), nullable=False, server_default='0'),
)
# 2. 提现单标记账户来源:coin_cash / invite_cash,退款退回对应账户(旧单默认 coin_cash)
op.add_column(
'withdraw_order',
sa.Column('source', sa.String(length=16), nullable=False, server_default='coin_cash'),
)
# 3. 邀请关系加比价发奖追踪(好友"下载+登录+比价一次"→ 给邀请人发奖,只发一次)
op.add_column(
'invite_relation',
sa.Column('compare_reward_granted', sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
'invite_relation',
sa.Column('compare_reward_cents', sa.Integer(), nullable=False, server_default='0'),
)
op.add_column(
'invite_relation',
sa.Column('compare_rewarded_at', sa.DateTime(timezone=True), nullable=True),
)
# 4. 邀请奖励金独立流水表(结构同 cash_transaction;balance_after 记 invite_cash_balance_cents)
op.create_table(
'invite_cash_transaction',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('amount_cents', sa.Integer(), nullable=False),
sa.Column('balance_after_cents', sa.Integer(), nullable=False),
sa.Column('biz_type', sa.String(length=32), nullable=False),
sa.Column('ref_id', sa.String(length=64), nullable=True),
sa.Column('remark', sa.String(length=128), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_invite_cash_transaction_user_id', 'invite_cash_transaction', ['user_id'])
op.create_index('ix_invite_cash_transaction_created_at', 'invite_cash_transaction', ['created_at'])
# 提现退款幂等:一个提现单只退一次(partial unique,对齐 cash_transaction 的 withdraw_refund 去重)
op.create_index(
'ux_invite_cash_txn_refund_ref',
'invite_cash_transaction',
['ref_id'],
unique=True,
sqlite_where=sa.text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
postgresql_where=sa.text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index('ux_invite_cash_txn_refund_ref', table_name='invite_cash_transaction')
op.drop_index('ix_invite_cash_transaction_created_at', table_name='invite_cash_transaction')
op.drop_index('ix_invite_cash_transaction_user_id', table_name='invite_cash_transaction')
op.drop_table('invite_cash_transaction')
op.drop_column('invite_relation', 'compare_rewarded_at')
op.drop_column('invite_relation', 'compare_reward_cents')
op.drop_column('invite_relation', 'compare_reward_granted')
op.drop_column('withdraw_order', 'source')
op.drop_column('coin_account', 'invite_cash_balance_cents')
+56
View File
@@ -0,0 +1,56 @@
"""add jd cps order fields
Revision ID: jd_cps_order_fields
Revises: 7db22acee504
Create Date: 2026-06-28 20:30:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "jd_cps_order_fields"
down_revision = "7db22acee504"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("cps_order") as batch_op:
batch_op.add_column(
sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan")
)
batch_op.add_column(sa.Column("external_order_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("external_row_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("estimated_commission_cents", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("actual_commission_cents", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("jd_valid_code", sa.String(length=16), nullable=True))
batch_op.add_column(sa.Column("settle_month", sa.String(length=16), nullable=True))
batch_op.add_column(sa.Column("site_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("position_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("pid", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("sub_union_id", sa.String(length=128), nullable=True))
batch_op.create_index("ix_cps_order_platform", ["platform"])
batch_op.create_index("ix_cps_order_external_order_id", ["external_order_id"])
batch_op.create_index("ix_cps_order_external_row_id", ["external_row_id"])
batch_op.create_index("ix_cps_order_jd_valid_code", ["jd_valid_code"])
def downgrade() -> None:
with op.batch_alter_table("cps_order") as batch_op:
batch_op.drop_index("ix_cps_order_jd_valid_code")
batch_op.drop_index("ix_cps_order_external_row_id")
batch_op.drop_index("ix_cps_order_external_order_id")
batch_op.drop_index("ix_cps_order_platform")
batch_op.drop_column("sub_union_id")
batch_op.drop_column("pid")
batch_op.drop_column("position_id")
batch_op.drop_column("site_id")
batch_op.drop_column("settle_month")
batch_op.drop_column("jd_valid_code")
batch_op.drop_column("actual_commission_cents")
batch_op.drop_column("estimated_commission_cents")
batch_op.drop_column("external_row_id")
batch_op.drop_column("external_order_id")
batch_op.drop_column("platform")
+4
View File
@@ -21,10 +21,12 @@ from app.admin.routers.audit import router as audit_router
from app.admin.routers.auth import router as auth_router
from app.admin.routers.comparison import router as comparison_router
from app.admin.routers.config import router as config_router
from app.admin.routers.coupon_data import router as coupon_data_router
from app.admin.routers.cps import router as cps_router
from app.admin.routers.dashboard import router as dashboard_router
from app.admin.routers.device_liveness import router as device_liveness_router
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
from app.admin.routers.event_logs import router as event_logs_router
from app.admin.routers.feedback import router as feedback_router
from app.admin.routers.feedback_qr import router as feedback_qr_router
from app.admin.routers.onboarding import router as onboarding_router
@@ -93,12 +95,14 @@ admin_app.include_router(wallet_router)
admin_app.include_router(withdraw_router)
admin_app.include_router(price_report_router)
admin_app.include_router(feedback_router)
admin_app.include_router(event_logs_router)
admin_app.include_router(feedback_qr_router)
admin_app.include_router(admins_router)
admin_app.include_router(audit_router)
admin_app.include_router(config_router)
admin_app.include_router(comparison_router)
admin_app.include_router(cps_router)
admin_app.include_router(coupon_data_router)
admin_app.include_router(ad_audit_router)
admin_app.include_router(ad_config_router)
admin_app.include_router(ad_revenue_router)
+61 -27
View File
@@ -4,9 +4,9 @@
- 看视频:每条 granted = 1 份,第 N 份 = 该用户 granted 的 reward_video **账号累计**顺序号
(与 ad_reward.grant_ad_reward 里 `_granted_cumulative + 1` 一致;LT 因子不按天重置,
故复算时要把当日序号叠加上该用户在本日**之前**的累计已发份数)。
- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 该用户 granted 份数**账号累计**
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致;同样不按天重置,
复算需叠加本日之前的累计数)。
- 信息流:**每条 granted = 1 份**(与 ad_feed_reward.grant_feed_reward 同口径:看满一份即发该条
满额,**不按 unit_count 逐份累加**),LT 序号 = 该用户 granted **条数**账号累计
(与 ad_feed_reward.granted_unit_total 的 COUNT 一致;不按天重置,复算需叠加本日之前的累计数)。
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
"""
@@ -108,14 +108,18 @@ def _reward_video_rows(
return rows
def _feed_prior_granted_units(
def _feed_prior_granted_count(
db: Session, *, date: str, user_id: int | None
) -> dict[int, int]:
"""各用户在 date **之前** granted 的信息流份数累计,作为当日复算的 LT 序号起点。"""
"""各用户在 date **之前** granted 的信息流**条数**累计,作为当日复算的 LT 序号起点。
与发奖侧 ad_feed_reward.granted_unit_total(COUNT status=granted)对齐:一条广告 = 1 份,
LT 按账号累计**条数**递进。**不再用 SUM(unit_count)**——那是「一条按时长折多份」的过时口径,
与现行发奖(每条 1 份)漂移,会让 unit_count>1 的记录复算虚高、对账恒「不符」。"""
stmt = (
select(
AdFeedRewardRecord.user_id,
func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0),
func.count(),
)
.where(
AdFeedRewardRecord.reward_date < date,
@@ -128,8 +132,29 @@ def _feed_prior_granted_units(
return {uid: int(n) for uid, n in db.execute(stmt).all()}
def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)。"""
def _feed_scene_matches(rec: AdFeedRewardRecord, scene: str | None) -> bool:
"""信息流记录是否落入请求的展示筛选 scene。
- scene=="feed":ad_type in ("feed", NULL)(旧数据 NULL 视为 feed,向后兼容)
- scene=="draw":ad_type=="draw"
- scene 为 None:不筛(两类都要)。
"""
if scene == "feed":
return rec.ad_type in (None, "feed")
if scene == "draw":
return rec.ad_type == "draw"
return True
def _feed_rows(
db: Session, *, date: str, user_id: int | None, scene: str | None = None
) -> list[dict]:
"""信息流记录复算。**每条 granted = 1 份**(与发奖同口径,不按 unit_count 累加),
LT 序号沿用账号累计**条数**(含本日之前)。
**关键:LT 因子账号累计按全表 granted 条数累计(feed+draw 共享同一发奖池/上限),不按 ad_type 拆分**——
故无论 scene 怎么筛展示,这里都遍历当日**全部**信息流记录维持 granted_count 累加;scene 只决定
哪些行被**留下展示**(由 _feed_scene_matches 判断),不影响累计基线,保证复算序号与正式发奖一致。
"""
stmt = (
select(AdFeedRewardRecord)
.where(AdFeedRewardRecord.reward_date == date)
@@ -138,22 +163,24 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
if user_id is not None:
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
# 本日之前的累计份数做起点,与 _unit_reward_total 的 existing_units(累计)对齐
granted_units: dict[int, int] = _feed_prior_granted_units(db, date=date, user_id=user_id)
# 本日之前的累计**条数**做起点,与发奖侧 granted_unit_total(COUNT granted)对齐
granted_count: dict[int, int] = _feed_prior_granted_count(db, date=date, user_id=user_id)
rows: list[dict] = []
for rec in db.execute(stmt).scalars():
keep = _feed_scene_matches(rec, scene) # 累计照常推进,这里只决定是否展示本行
if rec.status == "granted":
existing = granted_units.get(rec.user_id, 0)
units = rec.unit_count
expected = sum(
rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset)
for offset in range(1, units + 1)
)
granted_units[rec.user_id] = existing + units
start = existing + 1 if units > 0 else None
end = existing + units if units > 0 else None
# 一条广告 = 1 份(与 grant_feed_reward 同口径:看满一份即发该条满额,不按 unit_count 累加)。
# nth = 账号累计第几**条**(含本日之前),与发奖侧 granted_unit_total+1 对齐;累计照常推进
# (即便 scene 不匹配不展示也要 +1,保证序号与正式发奖一致)。
nth = granted_count.get(rec.user_id, 0) + 1
granted_count[rec.user_id] = nth
if not keep:
continue
expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth)
rows.append({
"scene": "feed",
"ad_type": rec.ad_type or "feed",
"feed_scene": rec.feed_scene,
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
@@ -163,18 +190,22 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
"units": units,
"lt_index_start": start,
"lt_index_end": end,
"lt_factor_start": rewards.ad_lt_factor(start) if start else None,
"lt_factor_end": rewards.ad_lt_factor(end) if end else None,
"units": 1,
"lt_index_start": nth,
"lt_index_end": nth,
"lt_factor_start": rewards.ad_lt_factor(nth),
"lt_factor_end": rewards.ad_lt_factor(nth),
"expected_coin": expected,
"actual_coin": rec.coin,
"matched": expected == rec.coin,
})
else:
if not keep:
continue
rows.append({
"scene": "feed",
"ad_type": rec.ad_type or "feed",
"feed_scene": rec.feed_scene,
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
@@ -199,16 +230,19 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
def audit_rows(
db: Session, *, date: str, user_id: int | None, scene: str | None = None
) -> list[dict]:
"""当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed"
"""当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed" / "draw"
"feed""draw" 都查 ad_feed_reward_record(同一发奖表),按 ad_type 区分:feed 含历史 NULL,
draw 仅 ad_type=="draw"。信息流行额外带 `ad_type`/`feed_scene`,供收益报表区分比价/领券 Draw 收益。
每行含 `app_env`/`our_code_id`/`expected_coin`/`actual_coin` 等,供金币审计逐条对账,
也供广告收益报表把「应发/实发」按 用户×类型×应用×代码位 聚合(见 ad_revenue,复用同一复算口径)。
**LT 因子账号累计仍按全表 unit 累计(feed+draw 共享),scene 只筛展示,不拆累计。**
"""
rows: list[dict] = []
if scene in (None, "reward_video"):
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
if scene in (None, "feed"):
rows.extend(_feed_rows(db, date=date, user_id=user_id))
if scene in (None, "feed", "draw"):
rows.extend(_feed_rows(db, date=date, user_id=user_id, scene=scene))
return rows
+134 -15
View File
@@ -11,26 +11,33 @@
(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计,
不受 limit(只截断 items)影响。
⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 feed。
② 跨天 S2S 回调:同一次广告的展示与发奖偶尔落相邻日,各自按 report_date / reward_date 归日
每行带 ad_type(reward_video/feed/draw)与 feed_scene(comparison/coupon/welfare),供前端区分
「比价 Draw 收益」与「领券 Draw 收益」(比价/领券共用同一代码位,只能靠 feed_scene 分)
⚠️ 局限:① 历史信息流/Draw 发奖 ad_type 为 NULL 的旧记录统一视为 feed(向后兼容);Draw 仅
ad_type=="draw" 的新记录单独成类。② 跨天 S2S 回调:同一次广告的展示与发奖偶尔落相邻日,各自按
report_date / reward_date 归日。
"""
from __future__ import annotations
from datetime import date as _date, datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from datetime import date as _date
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.admin.repositories import ad_audit
from app.admin.repositories import stats as admin_stats
from app.core import rewards
from app.models.ad_ecpm import AdEcpmRecord
from app.models.user import User
from app.repositories import ad_pangle_revenue
def _cn_hour(dt: datetime) -> int:
"""created_at(UTC 口径)→ 北京时间小时(023)。naive 当 UTC 处理(sqlite),tz-aware 直接换算(pg)。"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
dt = dt.replace(tzinfo=UTC)
return dt.astimezone(rewards.CN_TZ).hour
@@ -46,8 +53,18 @@ def _date_range(date_from: str, date_to: str) -> list[str]:
return out
# 审计行的 scene 与报表 ad_type 一一对应
_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"}
# 报表 ad_type 与审计 scene 取值一致(reward_video / feed / draw):feed 与 draw 同查发奖表
# ad_feed_reward_record,由 audit 内部按 ad_type 区分(feed 含历史 NULL,draw 仅 ad_type=="draw")。
_AUDIT_SCENES = {"reward_video", "feed", "draw"}
def _event_ad_type(row: dict) -> str:
"""纯发奖事件行的 ad_type:信息流行用 audit 带回的真实 ad_type(feed/draw),回退 feed;
激励视频行恒 reward_video。不再用 scene 硬映射,避免把 draw 丢成 feed。"""
if row["scene"] == "reward_video":
return "reward_video"
return row.get("ad_type") or "feed"
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
_REWARD_DETAIL_KEYS = (
@@ -69,14 +86,21 @@ def ad_revenue_report(
date_to: str,
user_id: int | None = None,
ad_type: str | None = None,
feed_scene: str | None = None,
app_env: str | None = None,
granularity: str = "day",
limit: int = 500,
offset: int = 0,
sort: str = "time",
) -> dict:
"""日期区间(北京时间,闭区间)**逐条广告事件**列表 + 发奖对账。单日时 date_from==date_to。
每个 item = 一次广告事件(展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行)。
ad_type: None=全部 / reward_video / feed / draw。granularity=hour 时每行带北京小时(由各自时间算)。
limit 只截断 items(事件明细),total 与 total_* / daily 在全量上统计,数字始终可信
ad_type: None=全部 / reward_video / feed / draw。feed_scene: None=全部 /
comparison / coupon / welfare,作为全局筛选(同时作用于明细、合计与 daily/hourly 趋势)
granularity=hour 时每行带北京小时(由各自时间算),并额外返回全量 hourly 序列。
事件按时间倒序(新→旧)排列;limit/offset 对排序后的全量做分页切片(items 为当前页),
total 与 total_* / daily / hourly 在全量上统计,不受分页影响。
"""
by_hour = granularity == "hour"
@@ -84,7 +108,9 @@ def ad_revenue_report(
# 同时保留全量列表,未被展示合并的成「纯发奖」事件。
reward_by_session: dict[tuple[int, str], list[dict]] = {}
all_reward_rows: list[dict] = []
audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None
# 报表 ad_type 直接当 audit scene 用(取值一致);未知/无效 ad_type 不取发奖行。draw 在此被
# 正确传成 scene="draw",audit 会按 ad_type 筛出 Draw 发奖,不再丢成 feed。
audit_scene = ad_type if ad_type in _AUDIT_SCENES else None
if ad_type is None or audit_scene is not None:
for d in _date_range(date_from, date_to):
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
@@ -123,6 +149,7 @@ def ad_revenue_report(
"report_date": rec.report_date,
"user_id": rec.user_id,
"ad_type": rec.ad_type,
"feed_scene": rec.feed_scene,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
@@ -130,8 +157,12 @@ def ad_revenue_report(
"has_impression": True,
"impressions": 1,
"ecpm": rec.ecpm_raw,
# 单次展示收益(元)= eCPM元 ÷ 1000(每千次→单次);与发奖同源解析,口径一致。
"revenue_yuan": round(rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0, 6),
# 单次展示收益(元)= eCPM元 ÷ 1000(每千次→单次)。eCPM 先钳到 AD_ECPM_MAX_FEN(¥500 CPM)
# 再折收益,与发奖口径 [rewards.calculate_ad_reward_coin] 一致(2026-06-29 修:原裸 parse_ecpm_yuan
# 不钳,伪造/异常天价 eCPM 会把报表预估收益冲到任意大;金币侧已钳、收益侧漏钳)。
"revenue_yuan": round(
min(rewards.parse_ecpm_yuan(rec.ecpm_raw), rewards.AD_ECPM_MAX_FEN / 100.0) / 1000.0, 6,
),
"adn": rec.adn,
"slot_id": rec.slot_id,
}
@@ -162,7 +193,8 @@ def ad_revenue_report(
"event_key": f"rwd-{row['record_id']}",
"report_date": row["_report_date"],
"user_id": row["user_id"],
"ad_type": _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"]),
"ad_type": _event_ad_type(row),
"feed_scene": row.get("feed_scene"),
"app_env": row.get("app_env"),
"our_code_id": row.get("our_code_id"),
"created_at": row["created_at"],
@@ -181,7 +213,25 @@ def ad_revenue_report(
"reward_detail": _reward_detail(row),
})
events.sort(key=lambda e: (e["report_date"], e["user_id"], e["created_at"]))
# 「场景」作为全局筛选(与 user_id/ad_type 一致):同时作用于明细、合计与 daily/hourly 趋势。
# feed_scene 仅信息流 / Draw 有值,激励视频与旧数据为 None;选中后只保留该场景事件。
if feed_scene is not None:
events = [e for e in events if e.get("feed_scene") == feed_scene]
# app_env 过滤(2026-06-29 新增能力,修隐患:测试应用上报的假 eCPM 如 ¥678 CPM 会污染正式收益合计/平均):
# 显式传 "prod"/"test" 只看该环境;不传=全部(维持现状)。**不擅自把默认改成排除 test**——本地 dev 库多为
# test 数据、默认排除会使本地报表空,且「正式报表是否含 test」属产品口径。建议前端报表页加 app_env 筛选器
# (默认选 prod),或产品确认后再把默认改成排除 test。注:穿山甲后台收益列(total_pangle_*)暂未联动此过滤
# (它是独立对照列,且 pangle 的 test 是真实小额、非客户端那种假值)。
if app_env is not None:
events = [e for e in events if e.get("app_env") == app_env]
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
if sort == "ecpm":
events.sort(key=lambda e: rewards.parse_ecpm_fen(e["ecpm"]), reverse=True)
else:
events.sort(key=lambda e: (e["report_date"], e["created_at"]), reverse=True)
# 补手机号(admin 展示用,完整不脱敏,与用户 / 钱包 / 比价记录页一致):批量一次查,避免 N+1。
uids = {e["user_id"] for e in events}
@@ -219,14 +269,83 @@ def ad_revenue_report(
for d in sorted(daily_map.values(), key=lambda x: x["date"])
]
# 穿山甲后台收益(GroMore 数据 API,T+1 入库 ad_pangle_daily_revenue):汇总 + 按天趋势级展示,
# 与上面客户端自报 eCPM 折算的预估并列对照(看 gap)。穿山甲数据**无用户/场景/类型维度**,故仅在
# 「全量视图」(未按 user_id / ad_type / feed_scene 过滤)给值;一旦带这些过滤,穿山甲数无法对应口径
# → 置 None,前端显示「-」并提示。逐条事件行不动(仍是客户端预估)。
pangle_filterable = user_id is None and ad_type is None and feed_scene is None
total_pangle_revenue_yuan: float | None = None
total_pangle_api_revenue_yuan: float | None = None
if pangle_filterable:
pangle_aggs = ad_pangle_revenue.aggregate_by_date(db, date_from=date_from, date_to=date_to)
if pangle_aggs:
by_date = {a["date"]: a for a in pangle_aggs}
for d in daily:
pa = by_date.get(d["date"])
d["pangle_revenue_yuan"] = pa["revenue_yuan"] if pa else None
d["pangle_api_revenue_yuan"] = pa["api_revenue_yuan"] if pa else None
total_pangle_revenue_yuan = round(sum(a["revenue_yuan"] for a in pangle_aggs), 6)
api_vals = [a["api_revenue_yuan"] for a in pangle_aggs if a["api_revenue_yuan"] is not None]
total_pangle_api_revenue_yuan = round(sum(api_vals), 6) if api_vals else None
# 按小时汇总(全量,不受分页 limit/offset 影响):供前端按小时趋势图(单日 granularity=hour 时用)。
# 只在 by_hour 下聚合(此时每个 event 带 hour);否则空。前端按天趋势仍用 daily。
hourly: list[dict] = []
if by_hour:
hour_map: dict[int, dict] = {}
for e in events:
h = e["hour"]
if h is None:
continue
hd = hour_map.get(h)
if hd is None:
hd = {"hour": h, "impressions": 0, "revenue_yuan": 0.0,
"expected_coin": 0, "actual_coin": 0}
hour_map[h] = hd
hd["impressions"] += e["impressions"]
hd["revenue_yuan"] += e["revenue_yuan"]
hd["expected_coin"] += e["expected_coin"]
hd["actual_coin"] += e["actual_coin"]
hourly = [
{**hd, "revenue_yuan": round(hd["revenue_yuan"], 6)}
for hd in sorted(hour_map.values(), key=lambda x: x["hour"])
]
# 分广告类型小计(按 ad_type:展示条数 + 预估收益;eCPM 由前端用 收益÷展示×1000 算)。
# 基于全量(已按 feed_scene 过滤)events;前端只取 draw / reward_video 两类展示。
type_map: dict[str, dict] = {}
for e in events:
t = type_map.get(e["ad_type"])
if t is None:
t = {"impressions": 0, "revenue_yuan": 0.0}
type_map[e["ad_type"]] = t
t["impressions"] += e["impressions"]
t["revenue_yuan"] += e["revenue_yuan"]
type_stats = {
k: {"impressions": v["impressions"], "revenue_yuan": round(v["revenue_yuan"], 6)}
for k, v in type_map.items()
}
# DAU:复用大盘「今日活跃」口径(stats.today_dau,last_login_at)。该口径只能算今日,
# 故仅当查询=今日单天时给值;历史 / 多天区间返回 None,前端显示「-」。
is_today = date_from == date_to == rewards.cn_today().isoformat()
dau = admin_stats.today_dau(db) if is_today else None
return {
"total": len(events),
"truncated": len(events) > limit,
"truncated": len(events) > offset + limit,
"total_impressions": total_impressions,
"total_revenue_yuan": total_revenue_yuan,
# 穿山甲后台收益合计(元):预估 revenue + 收益Api;非全量视图(带 user/类型/场景过滤)或无数据为 None。
"total_pangle_revenue_yuan": total_pangle_revenue_yuan,
"total_pangle_api_revenue_yuan": total_pangle_api_revenue_yuan,
"pangle_revenue_available": total_pangle_revenue_yuan is not None,
"total_expected_coin": total_expected_coin,
"total_actual_coin": total_actual_coin,
"mismatch_count": mismatch_count,
"daily": daily,
"items": events[:limit],
"hourly": hourly,
"type_stats": type_stats,
"dau": dau,
"items": events[offset:offset + limit],
}
+225
View File
@@ -0,0 +1,225 @@
"""admin「领券数据」看板聚合:发起/完成数、耗时均值与分位、按天/小时趋势、逐条明细。
数据源 coupon_session(一次领券一行,客户端 /api/v1/coupon/session 两段上报)。量级不大,全量拉
区间数据后 Python 聚合(分位 SQLite 无 percentile,统一 Python 算,PG 上也一致)。
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
"""
from __future__ import annotations
from datetime import UTC, date as _date, datetime
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.coupon_state import CouponSession
from app.models.user import User
def _cn_hour(dt: datetime) -> int:
"""started_at(UTC 口径)→ 北京时间小时(023)。naive 当 UTC(sqlite),tz-aware 直接换算(pg)。"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.astimezone(rewards.CN_TZ).hour
def _percentile(sorted_vals: list[int], q: float) -> int | None:
"""线性插值分位(q=0..100,numpy 默认法)。sorted_vals 须已升序;空返回 None。"""
if not sorted_vals:
return None
if len(sorted_vals) == 1:
return sorted_vals[0]
idx = (len(sorted_vals) - 1) * q / 100.0
lo = int(idx)
hi = min(lo + 1, len(sorted_vals) - 1)
frac = idx - lo
return round(sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac)
def _avg(vals: list[int]) -> int | None:
return round(sum(vals) / len(vals)) if vals else None
def _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> dict:
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
return {
"id": r.id,
"trace_id": r.trace_id,
"user_id": r.user_id,
"user_phone": phone,
"user_nickname": nickname,
"status": r.status,
"platforms": r.platforms,
"origin_package": r.origin_package,
"elapsed_ms": r.elapsed_ms,
"platform_elapsed": r.platform_elapsed,
"device_model": r.device_model,
"rom": r.rom,
"app_env": r.app_env,
"started_at": r.started_at,
"claimed_count": r.claimed_count,
"trace_url": r.trace_url,
}
def _empty_result() -> dict:
return {
"summary": {
"started_count": 0, "completed_count": 0, "avg_elapsed_ms": None,
"p5_ms": None, "p50_ms": None, "p95_ms": None, "p99_ms": None,
},
"daily": [],
"hourly": [],
"total": 0,
"items": [],
}
def coupon_data_report(
db: Session,
*,
date_from: str,
date_to: str,
user: str | None = None,
app_env: str | None = None,
granularity: str = "day",
limit: int = 500,
offset: int = 0,
sort: str = "time",
) -> dict:
"""日期区间(北京自然日 started_date,闭区间)领券数据:汇总卡 + 趋势 + 逐条明细。
- user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。
- app_env:prod/dev 精确;None=全部。
- sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。
"""
by_hour = granularity == "hour"
d_from = _date.fromisoformat(date_from)
d_to = _date.fromisoformat(date_to)
# user 模糊 → 先定位匹配用户 id;匹配不到直接空结果(不全表扫)。
user_ids: set[int] | None = None
if user:
like = f"%{user}%"
user_ids = set(db.execute(
select(User.id).where(or_(User.phone.like(like), User.nickname.like(like)))
).scalars().all())
if not user_ids:
return _empty_result()
stmt = select(CouponSession).where(
CouponSession.started_date >= d_from,
CouponSession.started_date <= d_to,
)
if app_env is not None:
stmt = stmt.where(CouponSession.app_env == app_env)
if user_ids is not None:
stmt = stmt.where(CouponSession.user_id.in_(user_ids))
rows = list(db.execute(stmt).scalars())
# ── 汇总卡 ──
completed_elapsed = sorted(
r.elapsed_ms for r in rows if r.status == "completed" and r.elapsed_ms is not None
)
summary = {
"started_count": len(rows),
"completed_count": sum(1 for r in rows if r.status == "completed"),
"avg_elapsed_ms": _avg(completed_elapsed),
"p5_ms": _percentile(completed_elapsed, 5),
"p50_ms": _percentile(completed_elapsed, 50),
"p95_ms": _percentile(completed_elapsed, 95),
"p99_ms": _percentile(completed_elapsed, 99),
}
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
daily_map: dict[str, dict] = {}
for r in rows:
d = r.started_date.isoformat()
b = daily_map.get(d)
if b is None:
b = {"date": d, "started_count": 0, "completed_count": 0, "_elapsed": []}
daily_map[d] = b
b["started_count"] += 1
if r.status == "completed":
b["completed_count"] += 1
if r.elapsed_ms is not None:
b["_elapsed"].append(r.elapsed_ms)
daily = [
{
"date": b["date"],
"started_count": b["started_count"],
"completed_count": b["completed_count"],
"avg_elapsed_ms": _avg(b["_elapsed"]),
}
for b in sorted(daily_map.values(), key=lambda x: x["date"])
]
# ── 按小时趋势(单日 hour 粒度)──
hourly: list[dict] = []
if by_hour:
hour_map: dict[int, dict] = {}
for r in rows:
h = _cn_hour(r.started_at)
b = hour_map.get(h)
if b is None:
b = {"hour": h, "started_count": 0, "completed_count": 0, "_elapsed": []}
hour_map[h] = b
b["started_count"] += 1
if r.status == "completed":
b["completed_count"] += 1
if r.elapsed_ms is not None:
b["_elapsed"].append(r.elapsed_ms)
hourly = [
{
"hour": b["hour"],
"started_count": b["started_count"],
"completed_count": b["completed_count"],
"avg_elapsed_ms": _avg(b["_elapsed"]),
}
for b in sorted(hour_map.values(), key=lambda x: x["hour"])
]
# ── 明细:排序 + 分页 + 补用户手机号/昵称(批量,防 N+1)──
if sort == "elapsed":
rows.sort(key=lambda r: (r.elapsed_ms is None, -(r.elapsed_ms or 0)))
else: # time:发起时刻倒序
rows.sort(key=lambda r: r.started_at, reverse=True)
page = rows[offset:offset + limit]
uids = {r.user_id for r in page if r.user_id is not None}
user_map: dict[int, tuple[str | None, str | None]] = {}
if uids:
user_map = {
uid: (phone, nickname)
for uid, phone, nickname in db.execute(
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
).all()
}
items = []
for r in page:
phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None)
items.append(_session_to_row(r, phone, nickname))
return {
"summary": summary,
"daily": daily,
"hourly": hourly,
"total": len(rows),
"items": items,
}
def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
"""某用户全部领券记录(点手机号抽屉用):按发起时刻倒序、不限日期,total=该用户领券总次数。"""
rows = list(db.execute(
select(CouponSession)
.where(CouponSession.user_id == user_id)
.order_by(CouponSession.started_at.desc())
.limit(limit)
).scalars())
total = db.execute(
select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id)
).scalar_one()
return {"items": [_session_to_row(r) for r in rows], "total": int(total)}
+254 -2
View File
@@ -7,23 +7,29 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from typing import Any
from uuid import uuid4
from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session
from app.admin.repositories.queries import _as_utc, offset_paginate
from app.integrations import meituan
from app.integrations import jd_union, meituan
from app.repositories import cps_link as cps_link_repo
from app.models.cps_activity import CpsActivity
from app.models.cps_group import CpsGroup
from app.models.cps_link import CpsClick
from app.models.cps_link import CpsClick, CpsLink
from app.models.cps_order import CpsOrder
from app.models.cps_wx_user import CpsWxUser
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
_INVALID_STATUS = {"4", "5"}
_SETTLED_STATUS = "6"
_JD_INVALID_CODES = {
"2", "3", "4", "5", "6", "7", "8", "9", "11", "13", "14", "19", "20", "21",
"22", "23", "25", "26", "27", "28", "29", "30", "31", "34", "35", "36",
}
_JD_UNPAID_CODES = {"15"}
# CPS 点击时序按北京时区分桶(运营看的是北京时间)
_BJ_TZ = timezone(timedelta(hours=8))
@@ -47,6 +53,36 @@ def _ts_to_dt(ts: object) -> datetime | None:
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
if not ts:
return None
def _jd_dt_to_utc(value: object) -> datetime | None:
"""京东时间字符串(北京时间) → UTC aware datetime。"""
if value is None:
return None
s = str(value).strip()
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
try:
dt = datetime.strptime(s, fmt)
return dt.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc)
except ValueError:
continue
return None
def _text(value: object) -> str | None:
if value is None:
return None
s = str(value).strip()
return s or None
def _pick(row: dict[str, Any], *keys: str) -> Any:
for key in keys:
if key in row and row[key] is not None:
return row[key]
return None
try:
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
except (ValueError, OSError, TypeError):
@@ -249,6 +285,80 @@ def _map_order_fields(r: dict) -> dict:
}
def _jd_order_key(r: dict[str, Any]) -> str | None:
row_id = _text(_pick(r, "id", "rowId", "orderRowId"))
if row_id:
return f"jd:{row_id}"
order_id = _text(_pick(r, "orderId", "parentOrderId"))
sku_id = _text(_pick(r, "skuId"))
if order_id and sku_id:
return f"jd:{order_id}:{sku_id}"
if order_id:
return f"jd:{order_id}"
return None
def _map_jd_order_fields(r: dict[str, Any]) -> dict:
"""京东 order.row.query 单条订单行 → CpsOrder 字段。"""
sku_name = _text(_pick(r, "skuName", "goodsName", "productName"))
if sku_name and len(sku_name) > 500:
sku_name = sku_name[:500]
valid_code = _text(_pick(r, "validCode", "valid_code"))
actual_fee = _yuan_to_cents(_pick(r, "actualFee", "actual_fee"))
estimate_fee = _yuan_to_cents(_pick(r, "estimateFee", "estimate_fee"))
commission = actual_fee if actual_fee not in (None, 0) else estimate_fee
order_time = _jd_dt_to_utc(_pick(r, "orderTime", "order_time"))
return {
"platform": "jd",
"external_order_id": _text(_pick(r, "orderId", "parentOrderId")),
"external_row_id": _text(_pick(r, "id", "rowId", "orderRowId")),
"sid": _text(_pick(r, "subUnionId", "sub_union_id")),
"act_id": None,
"biz_line": None,
"trade_type": None,
"pay_price_cents": _yuan_to_cents(
_pick(r, "actualCosPrice", "estimateCosPrice", "price")
),
"commission_cents": commission,
"commission_rate": _text(_pick(r, "commissionRate", "commission_rate")),
"refund_price_cents": None,
"refund_profit_cents": None,
"estimated_commission_cents": estimate_fee,
"actual_commission_cents": actual_fee,
"mt_status": None,
"jd_valid_code": valid_code,
"invalid_reason": None if _is_jd_valid_code(valid_code) else f"validCode={valid_code}",
"product_name": sku_name,
"settle_month": _text(_pick(r, "payMonth", "settleMonth", "pay_month")),
"site_id": _text(_pick(r, "siteId", "site_id")),
"position_id": _text(_pick(r, "positionId", "position_id")),
"pid": _text(_pick(r, "pid")),
"sub_union_id": _text(_pick(r, "subUnionId", "sub_union_id")),
"pay_time": order_time,
"mt_update_time": _jd_dt_to_utc(_pick(r, "modifyTime", "updateTime", "modify_time"))
or order_time,
"raw": r,
}
def _is_jd_valid_code(valid_code: str | None) -> bool:
code = str(valid_code).strip() if valid_code is not None else ""
return bool(code and code not in _JD_INVALID_CODES and code not in _JD_UNPAID_CODES)
def is_jd_order_valid(order: CpsOrder) -> bool:
return _is_jd_valid_code(order.jd_valid_code)
def effective_commission_cents(order: CpsOrder) -> int:
if order.platform == "jd":
if order.actual_commission_cents not in (None, 0):
return order.actual_commission_cents or 0
if order.estimated_commission_cents is not None:
return order.estimated_commission_cents or 0
return order.commission_cents or 0
def reconcile_orders(
db: Session, *, start_time: int, end_time: int,
query_time_type: int = 1, sid: str | None = None, max_pages: int = 200,
@@ -274,6 +384,11 @@ def reconcile_orders(
continue
fetched += 1
fields = _map_order_fields(r)
fields.setdefault("platform", "meituan")
fields.setdefault("external_order_id", order_id)
fields.setdefault("external_row_id", None)
fields.setdefault("estimated_commission_cents", fields.get("commission_cents"))
fields.setdefault("actual_commission_cents", None)
existing = db.execute(
select(CpsOrder).where(CpsOrder.order_id == order_id)
).scalar_one_or_none()
@@ -291,6 +406,56 @@ def reconcile_orders(
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
def reconcile_jd_orders(
db: Session, *, start_time: datetime, end_time: datetime,
query_time_type: int = 3, max_pages: int = 100,
) -> dict:
"""调京东 order.row.query 拉单 → 按订单行 upsert。
京东单次查询窗口最多 1 小时,这里按北京自然时间切窗并逐页拉取。
"""
fetched = inserted = updated = pages = 0
cur = start_time
while cur < end_time:
win_end = min(cur + timedelta(hours=1), end_time)
page = 1
while page <= max_pages:
resp = jd_union.query_order_rows(
start_time=cur,
end_time=win_end,
query_time_type=query_time_type,
page_index=page,
page_size=200,
)
rows = resp.get("rows") or []
has_more = bool(resp.get("has_more"))
if not rows:
break
pages += 1
for r in rows:
order_id = _jd_order_key(r)
if not order_id:
continue
fetched += 1
fields = _map_jd_order_fields(r)
existing = db.execute(
select(CpsOrder).where(CpsOrder.order_id == order_id)
).scalar_one_or_none()
if existing is None:
db.add(CpsOrder(order_id=order_id, **fields))
inserted += 1
else:
for k, v in fields.items():
setattr(existing, k, v)
updated += 1
if not has_more or len(rows) < 200:
break
page += 1
cur = win_end
db.commit()
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
def list_orders(
db: Session, *, sid: str | None = None, mt_status: str | None = None,
limit: int = 20, cursor: int | None = None,
@@ -524,3 +689,90 @@ def group_wx_users(db: Session, *, group_id: int, limit: int = 200) -> list[dict
]
result.sort(key=lambda x: x["first_seen"], reverse=True)
return result[:limit]
def group_day_users(
db: Session, *, group_id: int, start: datetime, end: datetime, limit: int = 200,
) -> list[dict]:
"""该群某天(北京)以用户为单位的领券/点击 + 每人 visit 过的券。
时间窗为半开区间 [start, end)(end=次日 00:00),避免午夜双计。只统计 openid 非空
(可归属到人)的点击 —— 匿名点击(美团/京东 302 多为匿名)不计入。券名 = 该点击 link
对应活动名;活动被硬删则兜底 活动#{id}。copy=领券次数、visit=点击次数;coupons 仅
取 visit 事件按活动分组、按次数倒序(合计 = visit_count)。排序:领券 desc、再点击 desc。
与 group_wx_users 同风格(Python 侧聚合,跨 PG/SQLite 无方言坑)。
注:每日明细行的 click_pv/copy_pv 计全部点击(含匿名、UV 按 ip,ua);本函数只计 openid
用户,故各用户求和 <= 当天行总数,二者口径不同、不必相等。
"""
rows = db.execute(
select(CpsClick.openid, CpsClick.event_type, CpsClick.link_id)
.where(CpsClick.group_id == group_id)
.where(CpsClick.clicked_at >= _as_utc(start))
.where(CpsClick.clicked_at < _as_utc(end))
.where(CpsClick.openid.is_not(None))
).all()
if not rows:
return []
# link_id -> activity_id -> 券名(活动名)
link_ids = {r.link_id for r in rows}
link_to_act = dict(
db.execute(
select(CpsLink.id, CpsLink.activity_id).where(CpsLink.id.in_(link_ids))
).all()
)
act_ids = {aid for aid in link_to_act.values() if aid is not None}
act_name = (
dict(
db.execute(
select(CpsActivity.id, CpsActivity.name).where(CpsActivity.id.in_(act_ids))
).all()
)
if act_ids
else {}
)
def _coupon_name(link_id: int) -> str:
aid = link_to_act.get(link_id)
if aid is None:
return f"链接#{link_id}"
return act_name.get(aid) or f"活动#{aid}"
stat: dict[str, dict] = {}
for openid, event_type, link_id in rows:
s = stat.setdefault(openid, {"copy": 0, "visit": 0, "coupons": {}})
if event_type == "copy":
s["copy"] += 1
else:
s["visit"] += 1
name = _coupon_name(link_id)
s["coupons"][name] = s["coupons"].get(name, 0) + 1
openids = list(stat.keys())
users = {
u.openid: u
for u in db.execute(
select(CpsWxUser).where(CpsWxUser.openid.in_(openids))
).scalars().all()
}
result = [
{
"openid": openid,
"nickname": users[openid].nickname if openid in users else None,
"headimgurl": users[openid].headimgurl if openid in users else None,
"copy_count": s["copy"],
"visit_count": s["visit"],
"coupons": [
{"name": name, "count": cnt}
# 次数倒序;同次数按券名升序兜底,保证 PG 无 ORDER BY 行序下输出稳定
for name, cnt in sorted(
s["coupons"].items(), key=lambda kv: (-kv[1], kv[0])
)
],
}
for openid, s in stat.items()
]
result.sort(key=lambda x: (x["copy_count"], x["visit_count"]), reverse=True)
return result[:limit]
+108 -4
View File
@@ -16,6 +16,7 @@ from app.core.config import settings
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.admin import AdminAuditLog
from app.models.analytics_event import AnalyticsEvent
from app.models.comparison import ComparisonRecord
from app.models.device import DeviceLiveness
from app.models.feedback import Feedback
@@ -124,8 +125,9 @@ def list_users(
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
def _attach_user_info(db: Session, records: list[ComparisonRecord]) -> None:
"""给每条比价记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。"""
def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | PriceReport]) -> None:
"""给每条记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。
按 user_id 鸭子类型,比价记录/反馈/上报通用。"""
uids = {r.user_id for r in records}
if not uids:
return
@@ -561,6 +563,62 @@ def list_feedbacks(
sort_col = sort_cols.get(sort_by, Feedback.id)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id)
items, next_cursor, total = offset_paginate(
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
)
_attach_user_info(db, items) # 列表展示完整手机号(点手机号查该用户全部反馈)
return items, next_cursor, total
def feedback_summary(db: Session) -> dict:
"""反馈审核台各状态计数(待审核/已采纳/未采纳/合计)。待审核含历史 new 态(与前端 isPending 一致)。"""
rows = db.execute(
select(Feedback.status, func.count(Feedback.id)).group_by(Feedback.status)
).all()
by_status = {status: int(count) for status, count in rows}
return {
"pending": by_status.get("pending", 0) + by_status.get("new", 0),
"adopted": by_status.get("adopted", 0),
"rejected": by_status.get("rejected", 0),
"total": sum(by_status.values()),
}
def list_analytics_events(
db: Session,
*,
event: str | None = None,
device_id: str | None = None,
user_id: int | None = None,
session_id: str | None = None,
created_from: datetime | None = None,
created_to: datetime | None = None,
sort_by: str = "id",
sort_order: str = "desc",
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[AnalyticsEvent], int | None, int]:
"""埋点日志列表(admin 全量)。按 事件名 / 设备ID前缀 / 用户ID / 会话ID / 接收时间范围 筛选,
按 id·接收时间排序。offset 分页(同 [list_feedbacks])。created_at 为 timestamptz,
日期入参统一转 tz-aware UTC 比较。"""
stmt = select(AnalyticsEvent)
if event:
stmt = stmt.where(AnalyticsEvent.event == event)
if device_id and device_id.strip():
stmt = stmt.where(AnalyticsEvent.device_id.like(f"{device_id.strip()}%"))
if user_id is not None:
stmt = stmt.where(AnalyticsEvent.user_id == user_id)
if session_id and session_id.strip():
stmt = stmt.where(AnalyticsEvent.session_id == session_id.strip())
if created_from is not None:
stmt = stmt.where(AnalyticsEvent.created_at >= _as_utc(created_from))
if created_to is not None:
stmt = stmt.where(AnalyticsEvent.created_at <= _as_utc(created_to))
sort_cols = {"id": AnalyticsEvent.id, "created_at": AnalyticsEvent.created_at}
sort_col = sort_cols.get(sort_by, AnalyticsEvent.id)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(AnalyticsEvent.id) if sort_order == "asc" else desc(AnalyticsEvent.id)
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
@@ -730,6 +788,7 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
"user": user,
"coin_balance": acc.coin_balance if acc else 0,
"cash_balance_cents": acc.cash_balance_cents if acc else 0,
"invite_cash_balance_cents": acc.invite_cash_balance_cents if acc else 0,
"total_coin_earned": acc.total_coin_earned if acc else 0,
"comparison_total": _count(ComparisonRecord, ComparisonRecord.user_id == user_id),
"comparison_success": _count(
@@ -939,21 +998,66 @@ def user_coin_records(
return rows[offset:offset + limit], (offset + limit if has_more else None), total
def _attach_price_report_comparison(db: Session, records: list[PriceReport]) -> None:
"""给每条上报瞬态挂关联比价记录的 trace + 设备/版本快照:
trace_id/trace_url(点 Trace 看完整比价过程)、device_model/rom_name/android_version(机型OS版本列)、
app_version(提交版本号列,= 提交时我们 app 的 versionName)。
comparison_record_id 为空 / 关联记录查不到 → 全 None。"""
cids = {r.comparison_record_id for r in records if r.comparison_record_id is not None}
rows = (
db.execute(
select(
ComparisonRecord.id,
ComparisonRecord.trace_id,
ComparisonRecord.trace_url,
ComparisonRecord.device_model,
ComparisonRecord.rom_name,
ComparisonRecord.android_version,
ComparisonRecord.app_version,
).where(ComparisonRecord.id.in_(cids))
).all()
if cids
else []
)
cmap = {row.id: row for row in rows}
for r in records:
row = cmap.get(r.comparison_record_id)
r.trace_id = row.trace_id if row else None
r.trace_url = row.trace_url if row else None
r.device_model = row.device_model if row else None
r.rom_name = row.rom_name if row else None
r.android_version = row.android_version if row else None
r.app_version = row.app_version if row else None
def list_price_reports(
db: Session,
*,
status: str | None = None,
user_id: int | None = None,
sort_by: str = "id",
sort_order: str = "desc",
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[PriceReport], int | None, int]:
"""上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,id 倒序。"""
"""上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,按 id·提交时间排序。
join User 取 phone 瞬态挂(列表展示完整手机号);按 comparison_record_id 挂 trace_id/trace_url。"""
stmt = select(PriceReport)
if status:
stmt = stmt.where(PriceReport.status == status)
if user_id is not None:
stmt = stmt.where(PriceReport.user_id == user_id)
return offset_paginate(db, stmt, (PriceReport.id.desc(),), limit=limit, cursor=cursor)
sort_cols = {"id": PriceReport.id, "created_at": PriceReport.created_at}
sort_col = sort_cols.get(sort_by, PriceReport.id)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(PriceReport.id) if sort_order == "asc" else desc(PriceReport.id)
items, next_cursor, total = offset_paginate(
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
)
_attach_user_info(db, items)
_attach_price_report_comparison(db, items)
return items, next_cursor, total
def price_report_summary(db: Session) -> dict:
+425 -8
View File
@@ -5,20 +5,44 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal, InvalidOperation
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.analytics_event import AnalyticsEvent
from app.models.comparison import ComparisonRecord
from app.models.coupon_state import CouponPromptEngagement
from app.models.cps_order import CpsOrder
from app.models.feedback import Feedback
from app.models.savings import SavingsRecord
from app.models.signin import SigninBoostRecord, SigninRecord
from app.models.user import User
from app.models.wallet import CoinTransaction, WithdrawOrder
_BEIJING = timezone(timedelta(hours=8))
COUPON_REWARD_BIZ_TYPES = ("reward_video", "ad_reward", "coupon", "coupon_reward")
COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward")
EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant")
UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",)
REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
*COUPON_REWARD_BIZ_TYPES,
*COMPARISON_REWARD_BIZ_TYPES,
*EXCLUDED_REWARD_BIZ_TYPES,
*UNCLASSIFIED_FEED_BIZ_TYPES,
)
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
MEITUAN_CPS_SETTLED_STATUS = "6"
COMPARE_START_EVENT = "real_compare_start"
COUPON_START_EVENT = "real_coupon_start"
JD_CPS_INVALID_CODES = {
"2", "3", "4", "5", "6", "7", "8", "9", "11", "13", "14", "19", "20", "21",
"22", "23", "25", "26", "27", "28", "29", "30", "31", "34", "35", "36",
}
JD_CPS_UNPAID_CODES = {"15"}
def _beijing_today_start_utc() -> datetime:
@@ -28,20 +52,130 @@ def _beijing_today_start_utc() -> datetime:
return start_bj.astimezone(timezone.utc)
def dashboard_overview(db: Session) -> dict:
def today_dau(db: Session) -> int:
"""今日活跃用户数(DAU):登录 + 开始比价 + 开始领券,按用户去重。
广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算。
"""
today_bj = datetime.now(_BEIJING).date()
today_start = _beijing_today_start_utc()
tomorrow_start = today_start + timedelta(days=1)
login_user_ids = _id_set(
db,
select(User.id).where(User.last_login_at >= today_start, User.last_login_at < tomorrow_start),
)
compare_start_user_ids = _event_user_ids(
db, (COMPARE_START_EVENT,), today_start, tomorrow_start
)
coupon_event_user_ids = _event_user_ids(
db, (COUPON_START_EVENT,), today_start, tomorrow_start
)
coupon_claim_user_ids = _id_set(
db,
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date == today_bj,
CouponPromptEngagement.engage_type == "claim_started",
),
)
return len(login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids)
def _default_period_end() -> date:
"""新版大盘不含今日,默认窗口结束日=北京时间昨天。"""
return datetime.now(_BEIJING).date() - timedelta(days=1)
def _normalize_period(date_from: date | None, date_to: date | None) -> tuple[date, date]:
end = date_to or _default_period_end()
start = date_from or end
if start > end:
start, end = end, start
return start, end
def _period_bounds(date_from: date, date_to: date) -> tuple[datetime, datetime, datetime, datetime]:
"""返回同一北京自然日窗口的 UTC aware 边界和北京 naive 边界。
user.created_at / last_login_at 是 UTC aware 口径;比较/金币等历史上有北京 naive
写入,所以两套边界同时保留。
"""
start_bj = datetime.combine(date_from, time.min, tzinfo=_BEIJING)
end_bj = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=_BEIJING)
start_utc = start_bj.astimezone(timezone.utc)
end_utc = end_bj.astimezone(timezone.utc)
return (
start_utc,
end_utc,
start_bj.replace(tzinfo=None),
end_bj.replace(tzinfo=None),
)
def _date_range(date_from: date, date_to: date) -> list[date]:
days = (date_to - date_from).days
return [date_from + timedelta(days=i) for i in range(days + 1)]
def _id_set(db: Session, stmt) -> set[int]:
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
def _event_user_ids(
db: Session, event_names: tuple[str, ...], start_utc: datetime, end_utc: datetime
) -> set[int]:
return _id_set(
db,
select(AnalyticsEvent.user_id).where(
AnalyticsEvent.user_id.is_not(None),
AnalyticsEvent.event.in_(event_names),
AnalyticsEvent.created_at >= start_utc,
AnalyticsEvent.created_at < end_utc,
),
)
def _commission_rate_percent(raw: str | None) -> Decimal | None:
"""美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%""""
if raw is None:
return None
s = str(raw).strip()
if not s:
return None
try:
if s.endswith("%"):
return Decimal(s[:-1])
val = Decimal(s)
except (InvalidOperation, ValueError):
return None
return val / Decimal("100")
def _jd_valid_order(order: CpsOrder) -> bool:
code = str(order.jd_valid_code).strip() if order.jd_valid_code is not None else ""
return bool(code and code not in JD_CPS_INVALID_CODES and code not in JD_CPS_UNPAID_CODES)
def dashboard_overview(
db: Session, *, date_from: date | None = None, date_to: date | None = None
) -> dict:
today_start = _beijing_today_start_utc()
period_from, period_to = _normalize_period(date_from, date_to)
start_utc, end_utc, start_local, end_local = _period_bounds(period_from, period_to)
def _count(model, *conds) -> int:
stmt = select(func.count(model.id))
if conds:
stmt = stmt.where(*conds)
return db.execute(stmt).scalar_one()
return int(db.execute(stmt).scalar_one())
def _sum(col, *conds) -> int:
stmt = select(func.coalesce(func.sum(col), 0))
if conds:
stmt = stmt.where(*conds)
return db.execute(stmt).scalar_one()
return int(db.execute(stmt).scalar_one())
def _user_id_set(stmt) -> set[int]:
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
# ===== 用户 =====
by_status = dict(
@@ -61,6 +195,223 @@ def dashboard_overview(db: Session) -> dict:
comparison_total = _count(ComparisonRecord)
comparison_success = _count(ComparisonRecord, ComparisonRecord.status == "success")
success_rate = round(comparison_success / comparison_total, 4) if comparison_total else 0.0
period_comparison_conds = (
ComparisonRecord.created_at >= start_local,
ComparisonRecord.created_at < end_local,
)
period_comparison_total = _count(ComparisonRecord, *period_comparison_conds)
period_comparison_success = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.status == "success",
)
period_comparison_success_rate = (
round(period_comparison_success / period_comparison_total, 4)
if period_comparison_total
else 0.0
)
period_saved_positive_count = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0,
)
period_saved_positive_sum = _sum(
ComparisonRecord.saved_amount_cents,
*period_comparison_conds,
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0,
)
period_avg_saved_cents = (
round(period_saved_positive_sum / period_saved_positive_count)
if period_saved_positive_count
else None
)
period_avg_duration_ms = db.execute(
select(func.avg(ComparisonRecord.total_ms)).where(
*period_comparison_conds,
ComparisonRecord.total_ms.is_not(None),
ComparisonRecord.total_ms > 0,
)
).scalar_one()
period_avg_duration_ms = (
round(float(period_avg_duration_ms))
if period_avg_duration_ms is not None
else None
)
ordered_exists = (
select(SavingsRecord.id)
.where(
SavingsRecord.user_id == ComparisonRecord.user_id,
SavingsRecord.source == "compare",
SavingsRecord.shop_name.is_not(None),
SavingsRecord.shop_name == ComparisonRecord.store_name,
)
.exists()
)
period_ordered_count = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.store_name.is_not(None),
ordered_exists,
)
# ===== 日期窗口用户 =====
period_new_user_ids = _user_id_set(
select(User.id).where(User.created_at >= start_utc, User.created_at < end_utc)
)
login_user_ids = _user_id_set(
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc)
)
compare_start_user_ids = _event_user_ids(
db, (COMPARE_START_EVENT,), start_utc, end_utc
)
coupon_event_user_ids = _event_user_ids(
db, (COUPON_START_EVENT,), start_utc, end_utc
)
coupon_claim_user_ids = _user_id_set(
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date >= period_from,
CouponPromptEngagement.engage_date <= period_to,
CouponPromptEngagement.engage_type == "claim_started",
)
)
period_active_user_ids = (
login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids
)
period_retained_new_user_ids = period_new_user_ids & period_active_user_ids
period_retention_rate = (
round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4)
if period_new_user_ids
else None
)
trend_points: list[dict] = []
for cur_date in _date_range(period_from, period_to):
day_start_utc, day_end_utc, day_start_local, day_end_local = _period_bounds(
cur_date, cur_date
)
daily_comparison_conds = (
ComparisonRecord.created_at >= day_start_local,
ComparisonRecord.created_at < day_end_local,
)
daily_login_user_ids = _user_id_set(
select(User.id).where(
User.last_login_at >= day_start_utc,
User.last_login_at < day_end_utc,
)
)
daily_compare_start_user_ids = _event_user_ids(
db, (COMPARE_START_EVENT,), day_start_utc, day_end_utc
)
daily_coupon_event_user_ids = _event_user_ids(
db, (COUPON_START_EVENT,), day_start_utc, day_end_utc
)
daily_coupon_claim_user_ids = _user_id_set(
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date == cur_date,
CouponPromptEngagement.engage_type == "claim_started",
)
)
trend_points.append(
{
"date": cur_date,
"active_users": len(
daily_login_user_ids
| daily_compare_start_user_ids
| daily_coupon_event_user_ids
| daily_coupon_claim_user_ids
),
"new_users": _count(
User,
User.created_at >= day_start_utc,
User.created_at < day_end_utc,
),
"comparisons": _count(ComparisonRecord, *daily_comparison_conds),
}
)
period_coin_conds = (
CoinTransaction.created_at >= start_local,
CoinTransaction.created_at < end_local,
CoinTransaction.amount > 0,
)
period_reward_video_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
)
period_feed_ad_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type == "feed_ad_reward",
)
period_signin_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type == "signin",
)
period_signin_boost_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type == "signin_boost",
)
period_task_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.like("task_%"),
)
period_coupon_reward_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
)
period_comparison_reward_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
)
period_regular_task_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
)
period_cps_orders = list(
db.execute(
select(CpsOrder).where(
CpsOrder.pay_time >= start_utc,
CpsOrder.pay_time < end_utc,
)
).scalars()
)
period_meituan_orders = [
o for o in period_cps_orders if (o.platform or "meituan") == "meituan"
]
period_meituan_valid_orders = [
o for o in period_meituan_orders if o.mt_status not in MEITUAN_CPS_INVALID_STATUSES
]
period_jd_orders = [o for o in period_cps_orders if o.platform == "jd"]
period_jd_valid_orders = [o for o in period_jd_orders if _jd_valid_order(o)]
period_jd_invalid_orders = [
o for o in period_jd_orders if o.jd_valid_code and not _jd_valid_order(o)
]
period_meituan_hit_count = 0
period_meituan_miss_count = 0
period_meituan_unknown_rate_count = 0
for order in period_meituan_valid_orders:
rate = _commission_rate_percent(order.commission_rate)
if rate is None:
period_meituan_unknown_rate_count += 1
elif rate < Decimal("1"):
period_meituan_miss_count += 1
else:
period_meituan_hit_count += 1
period_meituan_hit_denominator = period_meituan_hit_count + period_meituan_miss_count
period_meituan_hit_rate = (
round(period_meituan_hit_count / period_meituan_hit_denominator, 4)
if period_meituan_hit_denominator
else None
)
return {
"users": {
@@ -69,7 +420,7 @@ def dashboard_overview(db: Session) -> dict:
"disabled": by_status.get("disabled", 0),
"deleted": by_status.get("deleted", 0),
"new_today": _count(User, User.created_at >= today_start),
"dau": _count(User, User.last_login_at >= today_start),
"dau": today_dau(db),
},
"coins": {
# 累计发放金币(coin_transaction 里所有 amount>0 之和;负数是兑换/扣减不计)
@@ -119,7 +470,73 @@ def dashboard_overview(db: Session) -> dict:
"success": comparison_success,
"success_rate": success_rate,
},
"feedback": {"new": _count(Feedback, Feedback.status.in_(("pending", "new")))},
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
"period": {
"date_from": period_from,
"date_to": period_to,
"users": {
"new": len(period_new_user_ids),
"active": len(period_active_user_ids),
"retained_new_users": len(period_retained_new_user_ids),
"retention_rate": period_retention_rate,
"retention_note": (
"口径:登录(last_login_at)+开始比价(real_compare_start)+"
"开始领券(real_coupon_start/claim_started),按用户去重"
),
},
"comparison": {
"total": period_comparison_total,
"success": period_comparison_success,
"success_rate": period_comparison_success_rate,
"ordered": period_ordered_count,
"average_duration_ms": period_avg_duration_ms,
"average_saved_cents": period_avg_saved_cents,
},
"coins": {
"granted_total": _sum(CoinTransaction.amount, *period_coin_conds),
"reward_video_coin_total": period_reward_video_coin_total,
"feed_ad_coin_total": period_feed_ad_coin_total,
"signin_coin_total": period_signin_coin_total,
"signin_boost_coin_total": period_signin_boost_coin_total,
"task_coin_total": period_task_coin_total,
"coupon_reward_coin_total": period_coupon_reward_coin_total,
"comparison_reward_coin_total": period_comparison_reward_coin_total,
"regular_task_coin_total": period_regular_task_coin_total,
},
"cash": {
"withdraw_success_cents": _sum(
WithdrawOrder.amount_cents,
WithdrawOrder.status == "success",
WithdrawOrder.created_at >= start_local,
WithdrawOrder.created_at < end_local,
),
},
"trend": trend_points,
},
"feedback": {
"new": _count(Feedback, Feedback.status.in_(("pending", "new"))),
},
"cps": {
"available": True,
"note": "美团/JD CPS 读 cps_order 对账订单;淘宝佣金暂空",
"meituan_order_count": len(period_meituan_valid_orders),
"meituan_commission_cents": sum(
o.commission_cents or 0 for o in period_meituan_valid_orders
),
"meituan_hit_count": period_meituan_hit_count,
"meituan_miss_count": period_meituan_miss_count,
"meituan_unknown_rate_count": period_meituan_unknown_rate_count,
"meituan_hit_rate": period_meituan_hit_rate,
"jd_order_count": len(period_jd_valid_orders),
# 数据大盘京东 CPS 只看实际佣金,不再用预估佣金兜底。
"jd_commission_cents": sum(
o.actual_commission_cents or 0 for o in period_jd_valid_orders
),
"jd_actual_commission_cents": sum(
o.actual_commission_cents or 0 for o in period_jd_valid_orders
),
"jd_estimated_commission_cents": sum(
o.estimated_commission_cents or 0 for o in period_jd_valid_orders
),
"jd_invalid_count": len(period_jd_invalid_orders),
},
}
+1 -1
View File
@@ -26,7 +26,7 @@ def get_ad_coin_audit(
date: Annotated[str | None, Query(description="北京时间 YYYY-MM-DD,默认今天")] = None,
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
scene: Annotated[
str | None, Query(description="reward_video / feed;不传=两类都要")
str | None, Query(description="reward_video / feed / draw;不传=全部")
] = None,
limit: Annotated[int, Query(ge=1, le=500)] = 100,
only_mismatch: Annotated[
+34 -3
View File
@@ -11,7 +11,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import ad_revenue
from app.admin.schemas.ad_revenue import AdRevenueDaily, AdRevenueReportOut, AdRevenueRow
from app.admin.schemas.ad_revenue import (
AdRevenueDaily,
AdRevenueHourly,
AdRevenueReportOut,
AdRevenueRow,
AdRevenueTypeStat,
)
from app.core.rewards import cn_today
router = APIRouter(
@@ -43,10 +49,28 @@ def get_ad_revenue_report(
str | None,
Query(description="reward_video / feed / draw;不传=全部类型"),
] = None,
feed_scene: Annotated[
str | None,
Query(
description="comparison(比价) / coupon(领券) / welfare(福利);不传=全部场景。"
"全局筛选,同时影响明细 / 合计 / 趋势"
),
] = None,
app_env: Annotated[
str | None,
Query(
description="prod(正式应用) / test(测试应用);不传=全部。"
"建议正式收益报表选 prod,避免测试应用的假 eCPM 污染收益合计/平均"
),
] = None,
granularity: Annotated[
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
] = "day",
limit: Annotated[int, Query(ge=1, le=1000)] = 500,
limit: Annotated[int, Query(ge=1, le=1000, description="每页条数(分页大小)")] = 500,
offset: Annotated[int, Query(ge=0, description="分页偏移(已跳过的条数)=(页码-1)×每页条数")] = 0,
sort: Annotated[
str, Query(description="排序:time=时间倒序(默认) / ecpm=按 eCPM 数值倒序")
] = "time",
) -> AdRevenueReportOut:
today = cn_today()
d_from = _parse_day(date_from, field="date_from", default=today)
@@ -58,16 +82,23 @@ def get_ad_revenue_report(
result = ad_revenue.ad_revenue_report(
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
user_id=user_id, ad_type=ad_type, granularity=granularity, limit=limit,
user_id=user_id, ad_type=ad_type, feed_scene=feed_scene, app_env=app_env,
granularity=granularity, limit=limit, offset=offset, sort=sort,
)
return AdRevenueReportOut(
date_from=d_from.isoformat(),
date_to=d_to.isoformat(),
daily=[AdRevenueDaily(**d) for d in result["daily"]],
hourly=[AdRevenueHourly(**h) for h in result["hourly"]],
type_stats={k: AdRevenueTypeStat(**v) for k, v in result["type_stats"].items()},
dau=result["dau"],
total=result["total"],
truncated=result["truncated"],
total_impressions=result["total_impressions"],
total_revenue_yuan=result["total_revenue_yuan"],
total_pangle_revenue_yuan=result["total_pangle_revenue_yuan"],
total_pangle_api_revenue_yuan=result["total_pangle_api_revenue_yuan"],
pangle_revenue_available=result["pangle_revenue_available"],
total_expected_coin=result["total_expected_coin"],
total_actual_coin=result["total_actual_coin"],
mismatch_count=result["mismatch_count"],
+106
View File
@@ -0,0 +1,106 @@
"""admin「领券数据」看板:发起/完成数 + 领券耗时(均值 + P5/P50/P95/P99)+ 按天趋势 + 逐条明细。
任意已登录 admin 可看(只读)。聚合逻辑在 app/admin/repositories/coupon_data.py。
数据源 coupon_session(客户端 /api/v1/coupon/session 两段上报)。
"""
from __future__ import annotations
from datetime import date as _date
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import coupon_data
from app.admin.schemas.coupon_data import (
CouponDataDaily,
CouponDataHourly,
CouponDataOut,
CouponDataRow,
CouponDataSummary,
CouponUserRecordsOut,
)
from app.core.rewards import cn_today
router = APIRouter(
prefix="/admin/api/coupon-data",
tags=["admin-coupon-data"],
dependencies=[Depends(get_current_admin)],
)
# 区间最大跨度(天);超出拒绝,避免一次拉过多天拖垮接口(对齐广告收益报表)。
_MAX_RANGE_DAYS = 92
def _parse_day(value: str | None, *, field: str, default: _date) -> _date:
if value is None:
return default
try:
return _date.fromisoformat(value)
except ValueError as e:
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
@router.get(
"",
response_model=CouponDataOut,
summary="领券数据看板(发起/完成数 + 耗时分位 + 按天趋势 + 逐条明细)",
)
def get_coupon_data(
db: AdminDb,
date_from: Annotated[str | None, Query(description="起始日 北京 YYYY-MM-DD,默认今天")] = None,
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
user: Annotated[str | None, Query(description="用户手机号/昵称模糊搜;不传=全部")] = None,
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
granularity: Annotated[
str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day")
] = "day",
limit: Annotated[int, Query(ge=1, le=1000, description="每页条数(分页大小)")] = 500,
offset: Annotated[int, Query(ge=0, description="分页偏移(已跳过条数)=(页码-1)×每页条数")] = 0,
sort: Annotated[
str, Query(description="排序:time=发起时间倒序(默认) / elapsed=耗时倒序")
] = "time",
) -> CouponDataOut:
today = cn_today()
d_from = _parse_day(date_from, field="date_from", default=today)
d_to = _parse_day(date_to, field="date_to", default=d_from)
if d_to < d_from:
raise HTTPException(status_code=422, detail="date_to 不能早于 date_from")
if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS:
raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS}")
# 报表默认只看 prod(对齐广告报表防串台口径);app_env=all 时不过滤、看全部环境。
env = None if app_env == "all" else app_env
result = coupon_data.coupon_data_report(
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
user=user, app_env=env, granularity=granularity,
limit=limit, offset=offset, sort=sort,
)
return CouponDataOut(
date_from=d_from.isoformat(),
date_to=d_to.isoformat(),
summary=CouponDataSummary(**result["summary"]),
daily=[CouponDataDaily(**d) for d in result["daily"]],
hourly=[CouponDataHourly(**h) for h in result["hourly"]],
total=result["total"],
items=[CouponDataRow(**r) for r in result["items"]],
)
@router.get(
"/user-records",
response_model=CouponUserRecordsOut,
summary="某用户全部领券记录(点手机号抽屉:领券次数 + 记录列表)",
)
def get_user_coupon_records(
db: AdminDb,
user_id: Annotated[int, Query(description="用户 id")],
limit: Annotated[int, Query(ge=1, le=500, description="最多返回条数")] = 100,
sort_by: Annotated[str, Query(description="兼容 UserRecordsDrawer 参数;固定按发起时间倒序")] = "created_at",
sort_order: Annotated[str, Query(description="兼容参数,忽略")] = "desc",
) -> CouponUserRecordsOut:
result = coupon_data.coupon_user_records(db, user_id=user_id, limit=limit)
return CouponUserRecordsOut(
items=[CouponDataRow(**r) for r in result["items"]],
total=result["total"],
)
+121 -11
View File
@@ -1,13 +1,13 @@
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 联盟订单对账 + 统计。
平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。
淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"
淘宝暂未接 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"
群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。
"""
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
from datetime import date as _date, datetime, time as _dt_time, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
@@ -34,6 +34,7 @@ from app.admin.schemas.cps import (
from app.core import media
from app.core.config import settings
from app.integrations import meituan
from app.integrations.jd_union import JdUnionError
from app.integrations.meituan import MeituanCpsError
from app.models.admin import AdminUser
from app.models.cps_activity import CpsActivity
@@ -340,24 +341,108 @@ def generate_referral_links(
# ───────────── 订单对账 ─────────────
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账")
_BEIJING = timezone(timedelta(hours=8))
def _parse_day(value: str | None, *, field: str) -> _date | None:
if value is None:
return None
try:
return _date.fromisoformat(value)
except ValueError as e:
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
def _reconcile_range_to_ts(
date_from: _date | None, date_to: _date | None, days: int
) -> tuple[int, int]:
if date_from is None and date_to is None:
now = int(time.time())
return now - days * 86400, now
start_day = date_from or date_to
end_day = date_to or date_from
if start_day is None or end_day is None:
raise HTTPException(status_code=422, detail="日期参数不完整")
if start_day > end_day:
start_day, end_day = end_day, start_day
if (end_day - start_day).days + 1 > 90:
raise HTTPException(status_code=422, detail="美团订单查询最长 90 天")
start_dt = datetime.combine(start_day, _dt_time.min, tzinfo=_BEIJING)
end_dt = datetime.combine(end_day + timedelta(days=1), _dt_time.min, tzinfo=_BEIJING)
return int(start_dt.timestamp()), int(end_dt.timestamp())
def _reconcile_range_to_bj_dt(
date_from: _date | None, date_to: _date | None, days: int
) -> tuple[datetime, datetime]:
start_ts, end_ts = _reconcile_range_to_ts(date_from, date_to, days)
return (
datetime.fromtimestamp(start_ts, tz=_BEIJING),
datetime.fromtimestamp(end_ts, tz=_BEIJING),
)
def _merge_reconcile_result(total: dict, current: dict) -> None:
for key in ("fetched", "inserted", "updated", "pages"):
total[key] = int(total.get(key, 0)) + int(current.get(key, 0))
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取联盟订单对账")
def reconcile_orders(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
days: Annotated[int, Query(ge=1, le=90)] = 7,
date_from: Annotated[str | None, Query(description="起始日 YYYY-MM-DD")] = None,
date_to: Annotated[str | None, Query(description="结束日 YYYY-MM-DD")] = None,
days: Annotated[int, Query(ge=1, le=90, description="未传日期时默认拉近 N 天")] = 7,
sid: Annotated[str | None, Query(max_length=64)] = None,
query_time_type: Annotated[int, Query(ge=1, le=3)] = 1,
platform: Annotated[str, Query(pattern="^(all|meituan|jd)$")] = "all",
) -> CpsReconcileResult:
now = int(time.time())
parsed_from = _parse_day(date_from, field="date_from")
parsed_to = _parse_day(date_to, field="date_to")
result = {"fetched": 0, "inserted": 0, "updated": 0, "pages": 0}
try:
result = cps_repo.reconcile_orders(
db, start_time=now - days * 86400, end_time=now, sid=sid,
)
if platform in {"all", "meituan"}:
start_ts, end_ts = _reconcile_range_to_ts(parsed_from, parsed_to, days)
mt_result = cps_repo.reconcile_orders(
db,
start_time=start_ts,
end_time=end_ts,
query_time_type=query_time_type if query_time_type in (1, 2) else 2,
sid=sid,
)
_merge_reconcile_result(result, mt_result)
if platform in {"all", "jd"}:
if sid:
raise HTTPException(status_code=422, detail="京东订单刷新不支持 sid 筛选")
start_dt, end_dt = _reconcile_range_to_bj_dt(parsed_from, parsed_to, days)
jd_result = cps_repo.reconcile_jd_orders(
db,
start_time=start_dt,
end_time=end_dt,
query_time_type=query_time_type,
)
_merge_reconcile_result(result, jd_result)
except MeituanCpsError as e:
raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e
except JdUnionError as e:
raise HTTPException(status_code=502, detail=f"京东拉单失败: {e}") from e
write_audit(
db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None,
detail={"days": days, "sid": sid, **result}, ip=get_client_ip(request), commit=True,
detail={
"platform": platform,
"date_from": date_from,
"date_to": date_to,
"days": days,
"sid": sid,
"query_time_type": query_time_type,
**result,
},
ip=get_client_ip(request),
commit=True,
)
return CpsReconcileResult(**result)
@@ -468,7 +553,7 @@ def group_daily(
while cur <= last:
cp = click_points[idx] if idx < len(click_points) else None
row = {
"date": cur.strftime("%m-%d"),
"date": cur.strftime("%Y-%m-%d"),
"click_pv": cp["click_pv"] if cp else 0,
"click_uv": cp["click_uv"] if cp else 0,
"copy_pv": cp["copy_pv"] if cp else 0,
@@ -496,3 +581,28 @@ def group_wx_users(group_id: int, db: AdminDb) -> dict:
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
return {"users": cps_repo.group_wx_users(db, group_id=group_id)}
@router.get("/groups/{group_id}/day-users", summary="某天该群按用户的领券/点击 + 每人点过的券")
def group_day_users(
group_id: int,
db: AdminDb,
date: Annotated[str, Query(description="北京日期 YYYY-MM-DD")],
) -> dict:
group = cps_repo.get_group(db, group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
bj = timezone(timedelta(hours=8))
try:
day0 = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=bj)
except ValueError as e:
raise HTTPException(status_code=400, detail="date 格式应为 YYYY-MM-DD") from e
start = day0.replace(hour=0, minute=0, second=0, microsecond=0)
end = start + timedelta(days=1)
users = cps_repo.group_day_users(db, group_id=group_id, start=start, end=end)
return {
"group_id": group.id,
"group_name": group.name,
"date": date,
"users": users,
}
+11 -3
View File
@@ -1,7 +1,9 @@
"""admin 数据大盘(只读聚合)。"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from datetime import date
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import stats
@@ -15,5 +17,11 @@ router = APIRouter(
@router.get("/overview", response_model=DashboardOverview, summary="大盘核心指标")
def overview(db: AdminDb) -> DashboardOverview:
return DashboardOverview.model_validate(stats.dashboard_overview(db))
def overview(
db: AdminDb,
date_from: date | None = Query(None, description="北京时间自然日起始日 YYYY-MM-DD"),
date_to: date | None = Query(None, description="北京时间自然日结束日 YYYY-MM-DD"),
) -> DashboardOverview:
return DashboardOverview.model_validate(
stats.dashboard_overview(db, date_from=date_from, date_to=date_to)
)
+52
View File
@@ -0,0 +1,52 @@
"""admin 埋点日志:列表 + 按事件 / 设备 / 用户 / 会话 / 时间筛选(只读,同库直接查 analytics_event)。"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import queries
from app.admin.schemas.analytics import AnalyticsEventOut
from app.admin.schemas.common import CursorPage
router = APIRouter(
prefix="/admin/api/event-logs",
tags=["admin-event-logs"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=CursorPage[AnalyticsEventOut], summary="埋点日志列表")
def list_event_logs(
db: AdminDb,
event: Annotated[str | None, Query(max_length=64)] = None,
device_id: Annotated[str | None, Query(max_length=64)] = None,
user_id: Annotated[int | None, Query()] = None,
session_id: Annotated[str | None, Query(max_length=64)] = None,
created_from: Annotated[datetime | None, Query()] = None,
created_to: Annotated[datetime | None, Query()] = None,
sort_by: Annotated[str, Query(pattern="^(id|created_at)$")] = "id",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AnalyticsEventOut]:
items, next_cursor, total = queries.list_analytics_events(
db,
event=event,
device_id=device_id,
user_id=user_id,
session_id=session_id,
created_from=created_from,
created_to=created_to,
sort_by=sort_by,
sort_order=sort_order,
limit=limit,
cursor=cursor,
)
return CursorPage(
items=[AnalyticsEventOut.model_validate(e) for e in items],
next_cursor=next_cursor,
total=total,
)
+11 -1
View File
@@ -10,7 +10,12 @@ from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.repositories import mutations, queries
from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.feedback import FeedbackApproveRequest, FeedbackOut, FeedbackRejectRequest
from app.admin.schemas.feedback import (
FeedbackApproveRequest,
FeedbackOut,
FeedbackRejectRequest,
FeedbackSummary,
)
from app.models.admin import AdminUser
from app.models.feedback import Feedback
from app.repositories import wallet as wallet_repo
@@ -59,6 +64,11 @@ def list_feedbacks(
)
@router.get("/summary", response_model=FeedbackSummary, summary="反馈审核统计(各状态计数)")
def feedback_summary(db: AdminDb) -> FeedbackSummary:
return FeedbackSummary.model_validate(queries.feedback_summary(db))
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
def handle_feedback(
feedback_id: int,
+4 -1
View File
@@ -37,11 +37,14 @@ def list_price_reports(
db: AdminDb,
status: Annotated[str | None, Query()] = None,
user_id: Annotated[int | None, Query()] = None,
sort_by: Annotated[str, Query(pattern="^(id|created_at)$")] = "id",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[PriceReportOut]:
items, next_cursor, total = queries.list_price_reports(
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
db, status=status, user_id=user_id,
sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor,
)
return CursorPage(
items=[PriceReportOut.model_validate(r) for r in items],
+19 -11
View File
@@ -210,22 +210,28 @@ def grant_user_cash(
db: AdminDb,
) -> OkResponse:
"""给指定用户增/减或设值现金(分)。delta:正=发放、负=扣减;set:直接设为目标值。
account=coin_cash(金币兑现金)/ invite_cash(邀请奖励金):两本账物理隔离各调各的
主要用于让无现金用户直接测试提现"""
user = user_repo.get_user_by_id(db, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
# set=设为目标值:读当前余额算差值,仍复用 grant_cash 写一笔流水(沿用原子/审计/扣负保护)
# 按目标账户选「余额字段 + 变动入口」(grant_invite_cash 与 grant_cash 同构)
is_invite = body.account == "invite_cash"
balance_attr = "invite_cash_balance_cents" if is_invite else "cash_balance_cents"
grant_fn = wallet_repo.grant_invite_cash if is_invite else wallet_repo.grant_cash
acct_label = "邀请奖励金" if is_invite else "现金"
before: int | None = None
# set=设为目标值:读当前余额算差值,仍复用 grant 写一笔流水(沿用原子/审计/扣负保护)
if body.mode == "set":
if body.amount_cents < 0:
raise HTTPException(status_code=400, detail="目标现金值不能为负")
raise HTTPException(status_code=400, detail=f"目标{acct_label}值不能为负")
# lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
before = wallet_repo.get_or_create_account(
db, user_id, commit=False, lock=True
).cash_balance_cents
acc_locked = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
before = getattr(acc_locked, balance_attr)
delta = body.amount_cents - before
if delta == 0:
raise HTTPException(
status_code=400, detail=f"当前现金已为 {body.amount_cents} 分,无需调整"
status_code=400, detail=f"当前{acct_label}已为 {body.amount_cents} 分,无需调整"
)
else:
if body.amount_cents == 0:
@@ -234,18 +240,20 @@ def grant_user_cash(
# 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿
if delta < 0:
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
if acc_now.cash_balance_cents + delta < 0:
if getattr(acc_now, balance_attr) + delta < 0:
raise HTTPException(
status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)"
status_code=400,
detail=f"扣减后{acct_label}为负(当前余额 {getattr(acc_now, balance_attr)} 分)",
)
biz_type = "admin_grant" if delta > 0 else "admin_deduct"
# grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
acc, _ = wallet_repo.grant_cash(
# grant 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
acc, _ = grant_fn(
db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
)
detail = {
"account": body.account,
"amount_cents": delta,
"balance_after_cents": acc.cash_balance_cents,
"balance_after_cents": getattr(acc, balance_attr),
"reason": body.reason,
}
if body.mode == "set":
+4 -4
View File
@@ -9,8 +9,8 @@ class AdConfigOut(BaseModel):
app_id: str
reward_code_id: str
compare_feed_code_id: str
coupon_feed_code_id: str
compare_draw_code_id: str
coupon_draw_code_id: str
reward_mkey: str
reward_enabled: bool
compare_ad_enabled: bool
@@ -23,8 +23,8 @@ class AdConfigUpdate(BaseModel):
app_id: str | None = None
reward_code_id: str | None = None
compare_feed_code_id: str | None = None
coupon_feed_code_id: str | None = None
compare_draw_code_id: str | None = None
coupon_draw_code_id: str | None = None
reward_mkey: str | None = None
reward_enabled: bool | None = None
compare_ad_enabled: bool | None = None
+60 -6
View File
@@ -40,15 +40,38 @@ class AdRevenueRecord(BaseModel):
class AdRevenueDaily(BaseModel):
"""按日期汇总的一天(供前端按天趋势图;全量,不受 limit 影响)。"""
"""按日期汇总的一天(供前端按天趋势图;全量,不受分页影响)。"""
date: str = Field(..., description="北京时间 YYYY-MM-DD")
impressions: int = Field(..., description="当天展示条数合计")
revenue_yuan: float = Field(..., description="当天预估收益合计(元)")
revenue_yuan: float = Field(..., description="当天客户端预估收益合计(元;eCPM 折算)")
pangle_revenue_yuan: float | None = Field(
None, description="当天穿山甲后台预估收益(元;GroMore revenue);非全量视图/无数据为空"
)
pangle_api_revenue_yuan: float | None = Field(
None, description="当天穿山甲收益Api(元;GroMore api_revenue,更接近结算);未配/当天/无数据为空"
)
expected_coin: int = Field(..., description="当天应发金币合计")
actual_coin: int = Field(..., description="当天实发金币合计")
class AdRevenueHourly(BaseModel):
"""按北京小时(0–23)汇总的一小时(供前端按小时趋势图;全量,不受分页影响,单日 granularity=hour 时非空)。"""
hour: int = Field(..., description="北京时间小时 023")
impressions: int = Field(..., description="该小时展示条数合计")
revenue_yuan: float = Field(..., description="该小时预估收益合计(元)")
expected_coin: int = Field(..., description="该小时应发金币合计")
actual_coin: int = Field(..., description="该小时实发金币合计")
class AdRevenueTypeStat(BaseModel):
"""按广告类型(ad_type)的小计:展示条数 + 预估收益(eCPM 由前端用 收益÷展示×1000 算)。"""
impressions: int = Field(..., description="该类型展示条数合计")
revenue_yuan: float = Field(..., description="该类型预估收益合计(元)")
class AdRevenueRow(BaseModel):
"""一次广告事件(逐条一行):激励视频展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行。"""
@@ -56,7 +79,12 @@ class AdRevenueRow(BaseModel):
report_date: str = Field(..., description="该事件所属日期(北京时间 YYYY-MM-DD)")
user_id: int
user_phone: str | None = Field(None, description="用户手机号(admin 展示用,完整;用户已删 / 查不到为空)")
ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(历史 Draw 信息流)")
ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(Draw 信息流);历史 NULL 视为 feed")
feed_scene: str | None = Field(
None,
description="点位场景:comparison(比价) / coupon(领券) / welfare(福利);供区分比价/领券 Draw 收益;"
"激励视频与旧数据为空",
)
app_env: str | None = Field(None, description="我们的应用:prod(傻瓜比价正式) / test(测试应用);旧数据为空")
our_code_id: str | None = Field(None, description="我们后台配置的代码位 ID(104xxx);旧数据为空")
hour: int | None = Field(None, description="北京时间小时 023(granularity=hour 时有值;按天为 null)")
@@ -86,10 +114,36 @@ class AdRevenueReportOut(BaseModel):
date_from: str = Field(..., description="报表起始日期(北京时间 YYYY-MM-DD)")
date_to: str = Field(..., description="报表结束日期(北京时间 YYYY-MM-DD,闭区间;单日时与 date_from 相同)")
daily: list[AdRevenueDaily] = Field(..., description="按日期汇总序列(全量,供按天趋势图)")
total: int = Field(..., description="广告事件总数(全量,不受 limit 影响)")
truncated: bool = Field(..., description="明细是否被 limit 截断")
hourly: list[AdRevenueHourly] = Field(
default_factory=list,
description="按小时汇总序列(全量,供按小时趋势图;按天查询时为空)",
)
type_stats: dict[str, AdRevenueTypeStat] = Field(
default_factory=dict,
description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘",
)
dau: int | None = Field(
None,
description="今日活跃用户数(复用大盘口径,last_login_at);**仅查询=今日单天时有值**,历史/多天为 null",
)
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
total_impressions: int = Field(..., description="全量展示条数合计")
total_revenue_yuan: float = Field(..., description="全量收益合计(元)")
total_revenue_yuan: float = Field(..., description="全量客户端预估收益合计(元;eCPM 折算)")
total_pangle_revenue_yuan: float | None = Field(
None,
description="全量穿山甲后台预估收益合计(元;GroMore revenue)。穿山甲无用户/类型/场景维度,"
"仅「全量视图」(未按 user_id/ad_type/feed_scene 过滤)时有值,否则为 null",
)
total_pangle_api_revenue_yuan: float | None = Field(
None,
description="全量穿山甲收益Api合计(元;GroMore api_revenue,各 ADN 回传、更接近结算);"
"未配 Reporting / 查当天 / 非全量视图 时为 null",
)
pangle_revenue_available: bool = Field(
False,
description="本次结果是否带穿山甲后台收益(=全量视图且已同步到数据)。false 时前端「穿山甲收益」显示「-」",
)
total_expected_coin: int = Field(..., description="全量应发金币合计")
total_actual_coin: int = Field(..., description="全量实发金币合计")
mismatch_count: int = Field(..., description="应发≠实发的发奖条数(=0 说明全部按公式发放)")
+33
View File
@@ -0,0 +1,33 @@
"""admin 埋点日志列表响应。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class AnalyticsEventOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
event: str
# Who
device_id: str
user_id: int | None
# When
session_id: str | None
client_ts: int
sent_at: int | None
created_at: datetime # 服务端接收时间(server_at)
# Where
page: str | None
client_ip: str | None
# How
oem: str | None
os: str | None
model: str | None
app_ver: str | None
network: str | None
channel: str | None
# What 专属
props: dict | None
+83
View File
@@ -0,0 +1,83 @@
"""admin「领券数据」看板 schemas:汇总卡 + 按天/小时趋势 + 逐条领券明细。
数据源 coupon_session(一次领券一行)耗时单位 ms(前端按需折秒);均值/分位只统计 completed
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class CouponDataSummary(BaseModel):
"""汇总卡:发起/完成数 + 耗时均值与分位(P5/P50/P95/P99,基于 completed 的 elapsed_ms)。"""
started_count: int = Field(..., description="发起数(区间内所有领券 session)")
completed_count: int = Field(..., description="完成数(status=completed)")
avg_elapsed_ms: int | None = Field(None, description="平均耗时(ms,仅 completed;无数据为空)")
p5_ms: int | None = Field(None, description="耗时 5 分位(ms)")
p50_ms: int | None = Field(None, description="耗时 50 分位(ms,中位数)")
p95_ms: int | None = Field(None, description="耗时 95 分位(ms)")
p99_ms: int | None = Field(None, description="耗时 99 分位(ms)")
class CouponDataDaily(BaseModel):
"""按天趋势(全量,不受分页影响):柱=发起/完成数,线=平均耗时。"""
date: str = Field(..., description="北京时间 YYYY-MM-DD")
started_count: int
completed_count: int
avg_elapsed_ms: int | None = Field(None, description="当天平均耗时(ms,仅 completed)")
class CouponDataHourly(BaseModel):
"""按北京小时(023)趋势(单日 granularity=hour 时非空)。"""
hour: int = Field(..., description="北京时间小时 023")
started_count: int
completed_count: int
avg_elapsed_ms: int | None = None
class CouponDataRow(BaseModel):
"""一条领券明细(一次领券任务)。"""
id: int = Field(..., description="coupon_session 主键(抽屉 rowKey 用)")
trace_id: str
user_id: int | None = None
user_phone: str | None = Field(None, description="手机号(admin 展示;匿名领券/查不到为空)")
user_nickname: str | None = Field(None, description="昵称")
status: str = Field(..., description="started / completed / failed / abandoned")
platforms: list[str] | None = Field(None, description="发起勾选平台")
origin_package: str | None = Field(None, description="发起来源 App 包名;null=App 内(傻瓜比价首页)发起")
elapsed_ms: int | None = Field(None, description="全程耗时(ms)")
platform_elapsed: dict[str, int] | None = Field(
None, description="各平台耗时 {meituan-waimai/taobao-shanguang/jd-waimai: ms}"
)
device_model: str | None = None
rom: str | None = None
app_env: str | None = None
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
claimed_count: int | None = None
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
class CouponDataOut(BaseModel):
"""领券数据看板响应:汇总卡 + 趋势 + 明细分页。"""
date_from: str
date_to: str
summary: CouponDataSummary
daily: list[CouponDataDaily] = Field(default_factory=list, description="按天趋势(全量)")
hourly: list[CouponDataHourly] = Field(
default_factory=list, description="按小时趋势(单日 hour 粒度时非空)"
)
total: int = Field(..., description="明细总条数(全量,不受分页)")
items: list[CouponDataRow] = Field(..., description="逐条领券明细(当前页)")
class CouponUserRecordsOut(BaseModel):
"""某用户全部领券记录(点手机号抽屉用):total=该用户领券总次数,items=记录列表(UserRecordsDrawer 渲染)。"""
items: list[CouponDataRow]
total: int
+9 -2
View File
@@ -1,7 +1,7 @@
"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)
对账类字段对淘宝/京东 None 前端显示 "-"(无法对账)
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接 + 订单 API 对账)
对账类字段对淘宝为 None 前端显示 "-"(暂未对账)
"""
from __future__ import annotations
@@ -116,15 +116,22 @@ class CpsOrderOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
platform: str = "meituan"
order_id: str
external_order_id: str | None = None
external_row_id: str | None = None
sid: str | None = None
act_id: str | None = None
pay_price_cents: int | None = None
commission_cents: int | None = None
estimated_commission_cents: int | None = None
actual_commission_cents: int | None = None
commission_rate: str | None = None
mt_status: str | None = None
jd_valid_code: str | None = None
invalid_reason: str | None = None
product_name: str | None = None
settle_month: str | None = None
pay_time: datetime | None = None
+64
View File
@@ -1,6 +1,8 @@
"""admin 大盘 schemas(对应 stats.dashboard_overview 的嵌套结构)。"""
from __future__ import annotations
from datetime import date
from pydantic import BaseModel
@@ -38,6 +40,56 @@ class DashboardComparison(BaseModel):
success_rate: float
class DashboardPeriodUsers(BaseModel):
new: int
active: int
retained_new_users: int
retention_rate: float | None = None
retention_note: str
class DashboardPeriodComparison(BaseModel):
total: int
success: int
success_rate: float
ordered: int
average_duration_ms: int | None = None
average_saved_cents: int | None = None
class DashboardPeriodCoins(BaseModel):
granted_total: int
reward_video_coin_total: int = 0
feed_ad_coin_total: int = 0
signin_coin_total: int = 0
signin_boost_coin_total: int = 0
task_coin_total: int = 0
coupon_reward_coin_total: int = 0
comparison_reward_coin_total: int = 0
regular_task_coin_total: int = 0
class DashboardPeriodCash(BaseModel):
withdraw_success_cents: int
class DashboardTrendPoint(BaseModel):
date: date
active_users: int
new_users: int
comparisons: int
class DashboardPeriod(BaseModel):
date_from: date
date_to: date
users: DashboardPeriodUsers
comparison: DashboardPeriodComparison
coins: DashboardPeriodCoins
cash: DashboardPeriodCash
trend: list[DashboardTrendPoint] = []
class DashboardFeedback(BaseModel):
new: int
@@ -45,6 +97,17 @@ class DashboardFeedback(BaseModel):
class DashboardCps(BaseModel):
available: bool
note: str
meituan_order_count: int = 0
meituan_commission_cents: int = 0
meituan_hit_count: int = 0
meituan_miss_count: int = 0
meituan_unknown_rate_count: int = 0
meituan_hit_rate: float | None = None
jd_order_count: int = 0
jd_commission_cents: int = 0
jd_actual_commission_cents: int = 0
jd_estimated_commission_cents: int = 0
jd_invalid_count: int = 0
class DashboardOverview(BaseModel):
@@ -52,5 +115,6 @@ class DashboardOverview(BaseModel):
coins: DashboardCoins
cash: DashboardCash
comparison: DashboardComparison
period: DashboardPeriod
feedback: DashboardFeedback
cps: DashboardCps
+17
View File
@@ -23,6 +23,14 @@ class FeedbackOut(BaseModel):
reviewed_by_admin_id: int | None = None
reviewed_at: datetime | None = None
created_at: datetime
# 提交端环境快照(feedback 表列):提交版本号 / 机型OS版本;改版前的历史反馈为 None
app_version: str | None = None
device_model: str | None = None
rom_name: str | None = None
android_version: str | None = None
# 联表瞬态字段(queries._attach_user_info 挂):列表展示完整手机号,点手机号查该用户全部反馈
phone: str | None = None
nickname: str | None = None
class FeedbackApproveRequest(BaseModel):
@@ -37,3 +45,12 @@ class FeedbackApproveRequest(BaseModel):
class FeedbackRejectRequest(BaseModel):
reason: str = Field(min_length=1, max_length=256, description="未采纳原因,用户端可见")
note: str | None = Field(default=None, max_length=256, description="运营内部审核备注")
class FeedbackSummary(BaseModel):
"""审核台顶部各状态计数(pending 含历史 new 态)。"""
pending: int
adopted: int
rejected: int
total: int
+11
View File
@@ -31,6 +31,17 @@ class PriceReportOut(BaseModel):
reward_coins: int | None = None
reviewed_at: datetime | None = None
created_at: datetime
# 联表瞬态字段:phone/nickname 由 _attach_user_info 挂(展示完整手机号、点击查该用户全部上报);
# 其余由 _attach_price_report_comparison 从关联比价记录挂:trace_*(Trace 列点跳调试链接)、
# device_model/rom_name/android_version(机型OS版本列)、app_version(提交版本号列)
phone: str | None = None
nickname: str | None = None
trace_id: str | None = None
trace_url: str | None = None
device_model: str | None = None
rom_name: str | None = None
android_version: str | None = None
app_version: str | None = None
class PriceReportRejectRequest(BaseModel):
+6
View File
@@ -29,6 +29,7 @@ class AdminUserOverview(BaseModel):
user: AdminUserListItem
coin_balance: int
cash_balance_cents: int
invite_cash_balance_cents: int # 邀请奖励金余额(与 cash_balance_cents 物理隔离)
total_coin_earned: int
comparison_total: int
comparison_success: int
@@ -88,6 +89,11 @@ class GrantCoinsRequest(BaseModel):
class GrantCashRequest(BaseModel):
# 目标账户:金币兑换的现金(cash_balance_cents)与邀请奖励金(invite_cash_balance_cents)物理隔离,
# 各调各的、不可串。默认 coin_cash 兼容旧调用。
account: Literal["coin_cash", "invite_cash"] = Field(
"coin_cash", description="目标账户:coin_cash=金币兑现金账户 / invite_cash=邀请奖励金账户"
)
mode: Literal["delta", "set"] = Field(
"delta", description="delta=增减(amount_cents 为变动量) / set=设为(amount_cents 为目标值,须≥0)"
)
+33
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import hmac
import logging
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Header, HTTPException, status
@@ -21,6 +22,7 @@ from app.repositories import launch_confirm_sample as repo
from app.schemas.launch_confirm_sample import (
LaunchConfirmSampleIn,
LaunchConfirmSampleOut,
LaunchConfirmSampleRow,
)
logger = logging.getLogger("shagua.internal.launch_confirm")
@@ -61,3 +63,34 @@ def report_launch_confirm_sample(
payload.exec_success, payload.trace_id,
)
return LaunchConfirmSampleOut(id=sid)
@router.get(
"/launch-confirm-samples",
response_model=list[LaunchConfirmSampleRow],
summary="启动确认窗兜底样本列表(沉淀脚本 distill 读; server→server, 不走 JWT)",
)
def list_launch_confirm_samples(
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
exec_success: bool | None = None,
host_package: str | None = None,
since_days: int | None = None,
limit: int = 1000,
) -> list[LaunchConfirmSampleRow]:
"""读样本供 pricebot 的 distill_launch_confirm.py 聚合沉淀回 PROFILES。
与上报端点同一把共享密钥;exec_success/host_package/since_days 均可选,limit 默认 1000
"""
_check_secret(x_internal_secret)
since = None
if since_days and since_days > 0:
since = datetime.now(timezone.utc) - timedelta(days=since_days)
rows = repo.list_samples(
db,
exec_success=exec_success,
host_package=host_package,
since=since,
limit=limit,
)
return [LaunchConfirmSampleRow.model_validate(r) for r in rows]
+23 -5
View File
@@ -9,8 +9,8 @@
"""
from __future__ import annotations
import logging
import json
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -18,8 +18,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.api.deps import CurrentUser, DbSession
from app.core import rewards
from app.core.config import settings
from app.integrations import pangle
from app.core.ratelimit import rate_limit
from app.integrations import pangle
from app.repositories import ad_ecpm as crud_ecpm
from app.repositories import ad_feed_reward as crud_feed
from app.repositories import ad_reward as crud_ad
@@ -120,6 +120,21 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
ad_session_id = extra.get("ad_session_id")
ecpm = params.get("ecpm")
# 环境隔离:激励视频 mediaExtra 里带「这次观看属于哪个后端环境」(srv_env=dev/prod,客户端按
# BuildConfig.DEBUG 决定),穿山甲 S2S 回调原样带回。回调 URL 在穿山甲后台只配一个(指向生产),
# 所以测试包(dev)看广告的 S2S 也会打到生产——若不拦,生产会把奖发给「本库里同 user_id 的另一个
# 真人」(user_id 是跨库不隔离的裸数字 → 跨库串号)。这里只处理「属于本服环境」的回调:环境不符
# 直接受理但不发币、不写任何记录,保证各环境后台广告收益页只含本环境用户。is_verify=true 让穿山甲
# 不再重试(测试用户的币由 localhost 的 test-grant 单独发,不依赖这条 S2S)。
# 兼容:旧客户端不带 srv_env(取不到)→ 视为本环境,照常处理,不误伤存量正式用户。
callback_env = extra.get("srv_env")
if callback_env and callback_env != settings.APP_ENV:
logger.info(
"pangle callback foreign env skip: callback_env=%s self_env=%s user_id=%d trans_id=%s",
callback_env, settings.APP_ENV, user_id, trans_id,
)
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
existing = crud_ad.find_by_trans(db, trans_id)
if existing is not None:
logger.info(
@@ -270,12 +285,13 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
ad_type=payload.ad_type, ecpm_raw=payload.ecpm,
ad_session_id=payload.ad_session_id,
adn=payload.adn, slot_id=payload.slot_id,
feed_scene=payload.feed_scene,
app_env=payload.app_env, our_code_id=payload.our_code_id,
)
logger.info(
"ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s",
user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id,
payload.app_env, payload.our_code_id,
"ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s",
user.id, payload.ad_type, payload.feed_scene, payload.ad_session_id, payload.ecpm,
payload.adn, payload.slot_id, payload.app_env, payload.our_code_id,
)
return EcpmReportOut(ok=True)
@@ -386,6 +402,7 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
client_event_id=payload.client_event_id,
ecpm=payload.ecpm,
duration_seconds=payload.duration_seconds,
ad_type=payload.ad_type,
ad_session_id=payload.ad_session_id,
adn=payload.adn,
slot_id=payload.slot_id,
@@ -394,6 +411,7 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
app_env=payload.app_env,
our_code_id=payload.our_code_id,
aborted=payload.aborted,
display_coin=payload.display_coin,
)
logger.info(
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
+31
View File
@@ -0,0 +1,31 @@
"""客户端埋点上报接口。
POST /api/v1/analytics/events 批量接收新手引导(及后续)埋点,append analytics_event
**不强制登录**(未登录态也要采集行为):user_id 由客户端在 body 里可选带上,不靠 Bearer
服务端补 client_ip(X-Forwarded-For) created_at(接收时间 = server_at)
"""
from __future__ import annotations
from fastapi import APIRouter, Request
from app.api.deps import DbSession
from app.repositories import analytics as analytics_repo
from app.schemas.analytics import AnalyticsBatchIn, AnalyticsIngestOut
router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
def _client_ip(request: Request) -> str:
"""取客户端 IP:生产经 nginx 反代优先 X-Forwarded-For 第一段,否则直连 IP(同 admin get_client_ip)。"""
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else ""
@router.post("/events", response_model=AnalyticsIngestOut, summary="批量上报埋点事件")
def ingest_events(
batch: AnalyticsBatchIn, request: Request, db: DbSession
) -> AnalyticsIngestOut:
n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request))
return AnalyticsIngestOut(received=n)
+37 -5
View File
@@ -12,11 +12,11 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.api.deps import CurrentUser, DbSession
from app.core import test_account
from app.core.ratelimit import rate_limit
from app.core.ratelimit import enforce_rate_limit, rate_limit
from app.core.security import TokenError, decode_token, issue_token_pair
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.integrations.sms import SmsError, send_code, verify_code
@@ -38,6 +38,12 @@ logger = logging.getLogger("shagua.auth")
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
SMS_LOGIN_MAX_PER_HOUR = 5
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
# 堵「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞 —— 那两道是单号维度,一机换号能绕开。
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5
def _login_response(
user, *, onboarding_completed: bool, force_onboarding: bool = False
@@ -84,15 +90,27 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
"/sms/send",
response_model=SmsSendResponse,
summary="发送短信验证码",
dependencies=[Depends(rate_limit(10, 60, "sms-send"))], # 同 IP 每分钟≤10 次(防一 IP 刷不同号)
)
def sms_send(req: SmsSendRequest) -> SmsSendResponse:
def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
# 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。
# 真正的"免验证码"在 /sms/login 跳过校验;每日上限也在 login 处算(send 不耗额度)。
# (也不受下面设备发码限流约束:QA 联调要反复发码。)
if test_account.is_test_account(req.phone):
logger.info("test_account sms_send short-circuit (不真发)")
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。
# 补「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞(那两道是单号维度,一机换号能绕);设备维度按机器封顶,
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。与路由上 IP 维度(10次/分钟)互补。
enforce_rate_limit(
request,
scope="sms-send-device",
subject=req.device_id,
limit=SMS_SEND_MAX_PER_HOUR_PER_DEVICE,
window_sec=3600,
detail="操作过于频繁,请稍后再试",
)
try:
cooldown = send_code(req.phone)
except SmsError as e:
@@ -109,9 +127,10 @@ def sms_send(req: SmsSendRequest) -> SmsSendResponse:
summary="手机号+验证码登录",
dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限)
)
def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser:
# 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。
# 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。
# 测试账号走自己的每日额度、不受下面 (设备+IP) 每小时限流约束(QA 需在一小时内反复登录联调)。
if test_account.is_test_account(req.phone):
if not test_account.try_consume_quota():
raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试")
@@ -123,6 +142,19 @@ def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
# 不读/不写 onboarding_completion 表。
return _login_response(user, onboarding_completed=False, force_onboarding=True)
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试。放在验证码校验
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破。与路由上 IP 维度的 sms-login 限流(同 IP)互补。
# ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时
# 退化为该 IP 下所有空设备聚一桶,仍受限。
enforce_rate_limit(
request,
scope="sms-login-device",
subject=req.device_id,
limit=SMS_LOGIN_MAX_PER_HOUR,
window_sec=3600,
detail="登录尝试过于频繁,请稍后再试",
)
if not verify_code(req.phone, req.code):
raise HTTPException(status_code=400, detail="invalid sms code")
+5 -4
View File
@@ -26,6 +26,7 @@ import httpx
from fastapi import APIRouter, HTTPException, Request, status
from app.core.config import settings
from app.core.pricebot_client import get_pricebot_client
from app.core.pricebot_router import pick_pricebot
logger = logging.getLogger("shagua.compare")
@@ -65,10 +66,10 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
client = get_pricebot_client()
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
+13
View File
@@ -21,6 +21,7 @@ from app.api.deps import CurrentUser, DbSession
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.repositories import comparison as crud_compare
from app.repositories import invite as crud_invite
from app.services.pricebot_llm_calls import fetch_llm_calls
from app.schemas.compare_record import (
CompareStatsOut,
@@ -52,6 +53,18 @@ def report_record(
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
# 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。
background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id)
# 邀请 v2 发奖:被邀请人完成一次【成功】比价 → 给邀请人发邀请奖励金(幂等,只发一次)。
# best-effort:发奖异常不影响比价上报本身(rec 已 commit),只 log;邀请人补偿靠后续对账。
if rec.status == "success":
try:
reward = crud_invite.try_reward_on_compare(db, user.id)
if reward.status == "granted":
logger.info(
"invite compare reward granted inviter=%s invitee=%s cents=%s",
reward.inviter_user_id, user.id, reward.reward_cents,
)
except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞上报
logger.warning("invite compare reward failed invitee=%s: %s", user.id, e)
logger.info(
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
user.id,
+34 -4
View File
@@ -20,6 +20,7 @@ from fastapi.concurrency import run_in_threadpool
from app.api.deps import CurrentUser, DbSession
from app.core.config import settings
from app.core.pricebot_client import get_pricebot_client
from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal
from app.repositories import coupon_state as coupon_repo
@@ -28,6 +29,7 @@ from app.schemas.coupon_state import (
CouponPromptDismissIn,
CouponPromptShouldShowOut,
CouponPromptShownIn,
CouponSessionIn,
CouponStatsOut,
)
@@ -141,10 +143,10 @@ async def coupon_step(
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
client = get_pricebot_client()
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
@@ -193,6 +195,34 @@ async def coupon_step(
return resp_json
@router.post("/session", summary="领券任务流水上报(admin 领券数据看板数据源)")
def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]:
"""客户端两段上报一次领券流水(发起 started / 收尾 completed-failed-abandoned),按 trace_id upsert
coupon_session不鉴权(同领券循环 MVP, device_id/trace_id); admin领券数据看板算
发起/完成数耗时分位机型维度写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok"""
try:
coupon_repo.upsert_coupon_session(
db,
trace_id=payload.trace_id,
device_id=payload.device_id,
status=payload.status,
started_at_ms=payload.started_at_ms,
user_id=payload.user_id,
platforms=payload.platforms,
origin_package=payload.origin_package,
device_model=payload.device_model,
rom=payload.rom,
app_env=payload.app_env,
elapsed_ms=payload.elapsed_ms,
platform_elapsed=payload.platform_elapsed,
claimed_count=payload.claimed_count,
trace_url=payload.trace_url,
)
except Exception as e: # noqa: BLE001
logger.warning("coupon session write failed: %s", e)
return {"ok": True}
@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]:
"""客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。
+93 -5
View File
@@ -94,16 +94,35 @@ def cps_landing(code: str, request: Request, db: Session = Depends(get_db)):
return HTMLResponse(
_taobao_landing_html(link.target_url, image_url, code, openid, has_uinfo)
)
# 美团短链 / 京东链接:直接 302 跳
# 美团短链 / 京东链接:微信内 + 已拿 openid + 还没授权过头像 → 先出「领券前授权」插页,
# 点按钮(交互手势,避免微信快照页)走 snsapi_userinfo 拿昵称头像,授权回来经
# /c/{code}?authed=1 自动 302 跳券。其余(非微信 / 未配授权 / 无 openid / 已授权)直接 302。
# 产品决定:每次未授权都提示(不做冷却、不种"已提示"cookie)。取消授权由插页前端自动
# 转跳目标券页(无单独"暂不授权"出口),拒绝也能拿到券(对齐「绝不阻断领券」)。
if (
settings.wx_oauth_active
and _is_wechat(request)
and openid is not None
and request.cookies.get(_WX_UINFO_COOKIE) != "1"
):
return HTMLResponse(_coupon_auth_landing_html(link.target_url, code))
return RedirectResponse(link.target_url, status_code=302)
@router.get("/wx/oauth/cb", include_in_schema=False)
def wx_oauth_cb(code: str, state: str, db: Session = Depends(get_db)):
def wx_oauth_cb(
code: str | None = None, state: str | None = None, db: Session = Depends(get_db)
):
"""微信网页授权回调。state='base:{原code}'(静默拿 openid) 或 'uinfo:{原code}'(补昵称头像)。
openid(+userinfo) upsert 用户 cookie 302 回落地页任何失败兜底回落地页,
绝不阻断用户领券"""
kind, _, orig_code = state.partition(":")
kind, _, orig_code = (state or "").partition(":")
# 授权弹窗点「取消」时,部分微信版本会带空 code 回调本端点(而非退回落地页)。无 code 换
# 不到 openid,直接兜底跳回落地页(美团/京东落地页前端会再转跳目标券页),不报 422。
if not code:
return RedirectResponse(
f"/c/{orig_code}" if orig_code else _FALLBACK_URL, status_code=302
)
try:
token = wx_oauth.exchange_code(code) # {openid, access_token, scope, ...}
openid = token["openid"]
@@ -139,6 +158,12 @@ def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict
return {"ok": True}
def _js_str(s: str) -> str:
"""把字符串安全嵌进 <script> 的 JS 字面量:json.dumps 不转义 '<',值里含 '</script>'
会逃逸标签 '<' 转成 \\u003c(JS 仍解析为 '<')auth/target/淘口令等都走它"""
return json.dumps(s).replace("<", "\\u003c")
def _taobao_landing_html(
token: str, image_url: str, code: str, openid: str | None, has_uinfo: bool
) -> str:
@@ -156,8 +181,8 @@ def _taobao_landing_html(
return (
_TAOBAO_HTML
.replace("__IMAGE_URL__", safe_img)
.replace("__UINFO_URL__", json.dumps(uinfo_url))
.replace("__TOKEN_JS__", json.dumps(token))
.replace("__UINFO_URL__", _js_str(uinfo_url))
.replace("__TOKEN_JS__", _js_str(token))
)
@@ -210,3 +235,66 @@ if(location.search.indexOf('authed=1')>=0){doCopy();history.replaceState(null,''
</script>
</body>
</html>"""
def _coupon_auth_landing_html(target_url: str, code: str) -> str:
"""美团/京东「领券前先授权头像」插页(单按钮)。
唯一按钮立即领取优惠券 snsapi_userinfo 授权(用户点击=交互手势,避免微信快照页)
- 允许 回调拿昵称头像 + wx_uinfo cookie /c/{code}?authed=1 自动 302 跳券
- 取消 微信退回本插页,前端 pageshow/visibilitychange 检测到"刚点过领取"即直接
转跳 target(不再设单独的"暂不授权"出口),保证拒绝也能拿到券(对齐绝不阻断领券)
AUTH/TARGET json.dumps 安全嵌入 JS 字符串(URL 里的 & 不会被 HTML 转义,直跳更稳)
"""
redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
auth_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_userinfo", f"uinfo:{code}")
return (
_COUPON_AUTH_HTML
.replace("__AUTH_URL__", _js_str(auth_url))
.replace("__TARGET_URL__", _js_str(target_url))
)
_COUPON_AUTH_HTML = """<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>领取优惠券</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
body{font-family:-apple-system,"PingFang SC",sans-serif;background:#fff0ef;color:#333;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.card{width:100%;max-width:340px;background:#fff;border-radius:18px;padding:34px 24px;text-align:center;box-shadow:0 8px 24px rgba(255,59,59,.12)}
.title{font-size:22px;font-weight:800;color:#ff3b3b;margin-bottom:10px}
.sub{font-size:14px;color:#999;line-height:1.6;margin-bottom:28px}
.btn{display:block;width:100%;background:linear-gradient(90deg,#ff5b5b,#ff3b3b);color:#fff;font-size:18px;font-weight:800;font-family:inherit;text-align:center;padding:15px;border:none;border-radius:28px;cursor:pointer;box-shadow:0 6px 16px rgba(255,59,59,.4)}
.btn:active{transform:scale(.98)}
</style>
</head>
<body>
<div class="card">
<div class="title">🎁 优惠券待领取</div>
<div class="sub">点击下方按钮领取你的专属优惠券</div>
<button class="btn" onclick="claim()">立即领取优惠券</button>
</div>
<script>
var AUTH=__AUTH_URL__,TARGET=__TARGET_URL__;
function claim(){
try{sessionStorage.setItem('mt_auth_ts',String(Date.now()))}catch(e){}
location.href=AUTH;
}
// 从授权页返回(点了取消/未完成授权):若刚点过领取(3 分钟内) 直接进目标券页,
// 保证拒绝也能拿到券(绝不阻断领券)pageshow 覆盖 BFCache 与整页重载,visibilitychange
// 兜底"页面保活只切可见性"的情况; sessionStorage 时间戳防陈旧/误触发
function maybeForward(){
var ts=0;try{ts=parseInt(sessionStorage.getItem('mt_auth_ts')||'0',10)}catch(e){}
if(ts&&Date.now()-ts<180000){
try{sessionStorage.removeItem('mt_auth_ts')}catch(e){}
location.href=TARGET;
}
}
window.addEventListener('pageshow',maybeForward);
document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible')maybeForward()});
</script>
</body>
</html>"""
+7
View File
@@ -61,6 +61,11 @@ async def submit_feedback(
content: str = Form(...),
# 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
contact: str = Form(default=""),
# 提交端环境快照(admin 反馈页展示「提交版本号」「机型OS版本」);旧端不带 → 空串 → 存 NULL。
app_version: str = Form(default=""),
device_model: str = Form(default=""),
rom_name: str = Form(default=""),
android_version: str = Form(default=""),
images: list[UploadFile] = File(default=[]),
) -> FeedbackOut:
content = content.strip()
@@ -86,6 +91,8 @@ async def submit_feedback(
fb = feedback_repo.create_feedback(
db, user_id=user.id, content=content, contact=contact, images=urls,
app_version=app_version.strip(), device_model=device_model.strip(),
rom_name=rom_name.strip(), android_version=android_version.strip(),
)
logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls))
return FeedbackOut.model_validate(fb)
+9 -2
View File
@@ -38,7 +38,7 @@ logger = logging.getLogger("shagua.invite")
router = APIRouter(prefix="/api/v1/invite", tags=["invite"])
_BIND_MESSAGES = {
"success": "邀请绑定成功,金币已到账",
"success": "邀请绑定成功",
"already_bound": "你已绑定过邀请人",
"invalid_code": "邀请码无效",
"self_invite": "不能填写自己的邀请码",
@@ -84,6 +84,8 @@ def _parse_device_model(ua: str) -> str:
def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
code = invite_repo.ensure_code(db, user)
invited, coins = invite_repo.get_stats(db, user.id)
reward_balance, reward_withdrawn = invite_repo.get_reward_stats(db, user.id)
days_left, is_fresh_round, countdown_text = invite_repo.compute_invite_countdown(user.created_at)
sep = "&" if "?" in settings.INVITE_LANDING_URL else "?"
share_url = f"{settings.INVITE_LANDING_URL}{sep}ref={code}"
return InviteInfoOut(
@@ -91,6 +93,11 @@ def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
share_url=share_url,
invited_count=invited,
coins_earned=coins,
reward_balance_cents=reward_balance,
reward_withdrawn_cents=reward_withdrawn,
countdown_days_left=days_left,
countdown_is_fresh_round=is_fresh_round,
countdown_text=countdown_text,
)
@@ -154,7 +161,7 @@ def landing_track(
return LandingTrackOut(status="ok")
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,绑定不发奖)")
def bind_invite(
req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request
) -> BindInviteOut:
+2 -2
View File
@@ -63,8 +63,8 @@ def ad_config(db: DbSession) -> AdConfigPublicOut:
return AdConfigPublicOut(
app_id=c["app_id"],
reward_code_id=c["reward_code_id"],
compare_feed_code_id=c["compare_feed_code_id"],
coupon_feed_code_id=c["coupon_feed_code_id"],
compare_draw_code_id=c["compare_draw_code_id"],
coupon_draw_code_id=c["coupon_draw_code_id"],
reward_enabled=c["reward_enabled"],
compare_ad_enabled=c["compare_ad_enabled"],
coupon_ad_enabled=c["coupon_ad_enabled"],
+7 -2
View File
@@ -200,7 +200,11 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured")
try:
order = crud_wallet.create_withdraw(
db, user.id, req.amount_cents, user_name=req.user_name, out_bill_no=req.out_bill_no
db, user.id, req.amount_cents, source=req.source,
user_name=req.user_name, out_bill_no=req.out_bill_no,
# 0.01 元调试提现:放行低于最低额的小额。双闸——客户端仅 debug 包在「0.01 元提现」开关开时
# 连同 skip_review 一起下发;服务端仅非 prod 才认。生产恒 False,最低额校验照常。
allow_sub_min=(req.skip_review and not settings.is_prod),
)
except crud_wallet.InvalidWithdrawAmountError as e:
raise HTTPException(
@@ -274,10 +278,11 @@ def withdraw_status(
def withdraw_orders(
user: CurrentUser,
db: DbSession,
source: str | None = Query(None, description="按账户来源过滤:coin_cash / invite_cash;不传=全部"),
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
) -> WithdrawOrderPage:
items, next_cursor = crud_wallet.list_withdraw_orders(db, user.id, limit=limit, cursor=cursor)
items, next_cursor = crud_wallet.list_withdraw_orders(db, user.id, source=source, limit=limit, cursor=cursor)
return WithdrawOrderPage(
items=[WithdrawOrderOut.model_validate(it) for it in items],
next_cursor=next_cursor,
+50
View File
@@ -113,6 +113,21 @@ class Settings(BaseSettings):
"""美团 CPS 凭证齐全(缺则接口返空,而非 502)。"""
return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET)
# ===== 京东联盟 CPS =====
# app_key/app_secret 来自京东联盟应用;site_id 是推广管理里的 APP/网站 ID;
# auth_key 是工具商授权 key,自有应用查询可留空。
JD_UNION_APP_KEY: str = ""
JD_UNION_APP_SECRET: str = ""
JD_UNION_SITE_ID: str = ""
JD_UNION_AUTH_KEY: str = ""
JD_UNION_GATEWAY: str = "https://api.jd.com/routerjson"
JD_UNION_TIMEOUT_SEC: int = 15
@property
def jd_union_configured(self) -> bool:
"""京东联盟订单查询凭证齐全。"""
return bool(self.JD_UNION_APP_KEY and self.JD_UNION_APP_SECRET)
# ===== 微信服务号(网页授权) =====
# CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。
# ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。
@@ -211,6 +226,41 @@ class Settings(BaseSettings):
"""回调开关打开且至少配了一个验签密钥,才接受发奖回调。"""
return bool(self.PANGLE_CALLBACK_ENABLED and self.pangle_reward_secrets)
# ===== 穿山甲 GroMore 数据 API(报表收益拉取,T+1)=====
# ⚠️ 与上面发奖回调的 m-key 是【两套完全不同的凭证】:这三样在穿山甲后台
# 「接入中心 → GroMore-API → 聚合数据报告 API」文档页领取(user_id / role_id / Security Key),
# 仅用于按天拉 GroMore 收益报表(revenue 预估收益 + api_revenue 收益Api),不参与发奖。
# 该 API 只能查【GroMore 聚合代码位】的数据(=我们 useMediation 的口径),非穿山甲 SDK 数据;
# 且不提供用户/设备维度(官方明确),故收益只能落到 日期×代码位 汇总,不能挂到逐条事件。
# 子账号(role_id≠user_id)需主账号在「角色管理」授予「查看全部数据」权限,否则查不到
# ecpm/revenue(接口返回 118);role_id 填成与 user_id 一致 = 查主账号数据。
PANGLE_REPORT_USER_ID: int = 0 # 媒体账号 user_id
PANGLE_REPORT_ROLE_ID: int = 0 # 子账号 role_id(=user_id 时查主账号)
PANGLE_REPORT_SECURITY_KEY: str = "" # 该账号的 Security Key(secure_key,≠ 发奖 m-key)
# GroMore 聚合 AppId(报表 site_id 维度)→ 我们的应用环境。报表按 site_id 区分两个穿山甲应用,
# 用它把每行归到 prod(傻瓜比价正式)/ test(测试应用)。默认值取自现网两个应用,部署时按需覆盖。
PANGLE_REPORT_SITE_ID_PROD: str = "5830519" # 傻瓜比价正式应用 AppId
PANGLE_REPORT_SITE_ID_TEST: str = "5832303" # 测试应用 AppId
@property
def pangle_report_configured(self) -> bool:
"""三样齐全(user_id / role_id / security_key)才能拉 GroMore 报表;缺任一 → 同步脚本 no-op。"""
return bool(
self.PANGLE_REPORT_USER_ID
and self.PANGLE_REPORT_ROLE_ID
and self.PANGLE_REPORT_SECURITY_KEY
)
@property
def pangle_report_site_id_to_env(self) -> dict[str, str]:
"""site_id(穿山甲 AppId)→ app_env(prod/test);供同步脚本把报表行归到我们的应用。留空项忽略。"""
out: dict[str, str] = {}
if self.PANGLE_REPORT_SITE_ID_PROD.strip():
out[self.PANGLE_REPORT_SITE_ID_PROD.strip()] = "prod"
if self.PANGLE_REPORT_SITE_ID_TEST.strip():
out[self.PANGLE_REPORT_SITE_ID_TEST.strip()] = "test"
return out
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
# pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step
PRICEBOT_BASE_URL: str = "http://localhost:8000"
+4
View File
@@ -87,5 +87,9 @@ def setup_logging(debug: bool = False) -> None:
# 第三方库降噪
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
# uvicorn --reload 的文件监视器: DEBUG 下会把每次"检测到变更"打出来。我们的 root 文件
# handler 又把这行写回 logs/app-server.log → watchfiles 再次检测 → 自我喂食死循环。
# 降到 WARNING 同时消除噪音和这个回环(真正的 .py 热重载不受影响)。
logging.getLogger("watchfiles").setLevel(logging.WARNING)
_CONFIGURED = True
+35
View File
@@ -0,0 +1,35 @@
"""透传到 pricebot 的共享 httpx.AsyncClient 单例。
为什么不能每请求新建(coupon.py / compare.py 老写法 async with httpx.AsyncClient(...)):
每次构造都重建一套 SSL 上下文(httpx.create_ssl_context 加载 certifi CA),实测
~1s+/; pricebot 是纯 http 透传,根本用不到 TLS 纯浪费,且每帧重交一次
trust_env 默认 True 会读进程 HTTP_PROXY, http://localhost:8000 这条本地透传整个
塞进本机代理( Clash 7897),恒定再多几秒
单例:启动只建一次(SSL/连接池一次性),keep-alive 复用 TCP,每帧降到个位数 ms
trust_env=False:对齐 integrations/meituan.py 的既有约定,不被进程代理误导,直连 pricebot
"""
from __future__ import annotations
import httpx
_client: httpx.AsyncClient | None = None
def get_pricebot_client() -> httpx.AsyncClient:
"""取透传单例。lifespan 启动会预热;未预热(如测试态)懒建兜底。
超时不在此固化(coupon 30s / compare 60s 不同),由调用点 client.post(timeout=...)
懒建无 await,asyncio 单线程下不会有并发竞态
"""
global _client
if _client is None:
_client = httpx.AsyncClient(trust_env=False)
return _client
async def aclose_pricebot_client() -> None:
"""lifespan 关停时调,优雅关连接池。"""
global _client
if _client is not None:
await _client.aclose()
_client = None
+26
View File
@@ -57,3 +57,29 @@ def rate_limit(limit: int, window_sec: float, scope: str):
)
return _dep
def enforce_rate_limit(
request: Request,
scope: str,
subject: str,
limit: int,
window_sec: float,
*,
detail: str = "操作过于频繁,请稍后再试",
) -> None:
"""在路由内部手动限流,按 (subject, 客户端 IP) 计数。
用于限流 key 需要请求体字段(如手机号)Depends 阶段还拿不到 body
此时无法用 [rate_limit] 依赖,改在 handler 解析完 body 后调用本函数
key = `scope:subject:client_ip`;同一 (subject, IP) window_sec 内超过 limit 429
[settings.RATE_LIMIT_ENABLED] 总开关控制( [rate_limit] 一致)
"""
if not settings.RATE_LIMIT_ENABLED:
return
key = f"{scope}:{subject}:{_client_ip(request)}"
if not _hit(key, limit, window_sec):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=detail,
)
+10 -4
View File
@@ -113,10 +113,9 @@ PRICE_REPORT_REWARD_COINS: int = 1000
FEEDBACK_REWARD_MAX_COINS: int = 10000
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
INVITE_INVITER_COINS: int = 10000
INVITE_INVITEE_COINS: int = 10000
# ===== 邀请好友(绑定即生效,绑定只建归因关系、双方都不发钱)=====
# v3(冰 2026-06-26):废除 v1 的"绑定双方各发金币"——被邀请人无奖励、邀请人改比价后发现金
# (见下方 INVITE_COMPARE_REWARD_CENTS)。原 INVITE_INVITER_COINS / INVITE_INVITEE_COINS 已删。
# "新用户闸":被邀请人必须在注册后此窗口内绑定才发奖(挡存量老用户互相填码薅羊毛)。
# 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。
INVITE_NEW_USER_WINDOW_HOURS: int = 72
@@ -127,6 +126,13 @@ INVITE_NEW_USER_WINDOW_HOURS: int = 72
INVITE_FP_WINDOW_DAYS: int = 7
# ===== 邀请好友 v2(好友"下载+登录+比价一次"→ 给邀请人发邀请奖励金·现金)=====
# v2 新规则:不再注册即发金币,改"好友完成首次成功比价"才给【邀请人】发奖,发的是【现金·分】
# 进独立的邀请奖励金账户(coin_account.invite_cash_balance_cents),与金币体系物理隔离。
# 200 分 = 2 元。⚠️ 金额待产品定准:邀请主页=2元 / 福利入口=3.5元 不一致,定后改此处。
INVITE_COMPARE_REWARD_CENTS: int = 200
# ===== 看激励视频 / 信息流广告发金币 =====
# eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元!
# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元;
+163
View File
@@ -0,0 +1,163 @@
"""京东联盟 OpenAPI 客户端。
当前只接数据大盘需要的订单明细接口:
`jd.union.open.order.row.query`京东要求订单查询时间窗最长 1 小时,
调用方负责切窗分页
"""
from __future__ import annotations
import hashlib
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
_BEIJING = timezone(timedelta(hours=8))
class JdUnionError(RuntimeError):
"""京东联盟 API 调用失败。"""
def _parse_json_maybe(value: Any) -> Any:
if not isinstance(value, str):
return value
text = value.strip()
if not text:
return value
try:
return json.loads(text)
except json.JSONDecodeError:
return value
def _sign(params: dict[str, Any], secret: str) -> str:
pieces = [secret]
for key in sorted(k for k in params if k != "sign"):
value = params[key]
if value is None:
continue
pieces.append(f"{key}{value}")
pieces.append(secret)
raw = "".join(pieces)
return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()
def _unwrap_response(data: dict[str, Any]) -> dict[str, Any]:
if "error_response" in data:
err = data["error_response"] or {}
msg = err.get("zh_desc") or err.get("en_desc") or err.get("msg") or err
raise JdUnionError(f"京东 API 错误: {msg}")
body: Any = data
for key, value in data.items():
if key.endswith("_responce") or key.endswith("_response"):
body = value
break
body = _parse_json_maybe(body)
if not isinstance(body, dict):
raise JdUnionError("京东 API 返回格式异常")
result = body.get("queryResult", body.get("result", body))
result = _parse_json_maybe(result)
if not isinstance(result, dict):
raise JdUnionError("京东 API 业务结果格式异常")
code = str(result.get("code", result.get("resultCode", "200")))
if code not in {"0", "200"}:
msg = result.get("message") or result.get("msg") or result.get("resultMsg") or result
raise JdUnionError(f"京东 API 业务错误: {msg}")
return result
def call(method: str, payload: dict[str, Any], *, version: str = "1.0") -> dict[str, Any]:
if not settings.jd_union_configured:
raise JdUnionError("京东联盟凭证未配置")
biz_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
params: dict[str, Any] = {
"method": method,
"app_key": settings.JD_UNION_APP_KEY,
"timestamp": datetime.now(_BEIJING).strftime("%Y-%m-%d %H:%M:%S"),
"format": "json",
"v": version,
"sign_method": "md5",
"360buy_param_json": biz_json,
}
params["sign"] = _sign(params, settings.JD_UNION_APP_SECRET)
try:
with httpx.Client(timeout=settings.JD_UNION_TIMEOUT_SEC, trust_env=False) as client:
resp = client.post(settings.JD_UNION_GATEWAY, data=params)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPError as e:
raise JdUnionError(f"京东 API 网络错误: {e}") from e
except json.JSONDecodeError as e:
raise JdUnionError("京东 API 返回非 JSON") from e
return _unwrap_response(data)
def _extract_rows(result: dict[str, Any]) -> tuple[list[dict[str, Any]], bool]:
payload = _parse_json_maybe(result.get("data", result.get("result", result)))
has_more = bool(result.get("hasMore") or result.get("has_more"))
if isinstance(payload, dict):
for key in ("orderRowResp", "orderRows", "orderList", "orders", "list", "rows"):
rows = _parse_json_maybe(payload.get(key))
if isinstance(rows, list):
return [r for r in rows if isinstance(r, dict)], bool(
payload.get("hasMore") or payload.get("has_more") or has_more
)
return [], bool(payload.get("hasMore") or payload.get("has_more") or has_more)
if isinstance(payload, list):
return [r for r in payload if isinstance(r, dict)], has_more
return [], has_more
def query_order_rows(
*,
start_time: datetime,
end_time: datetime,
query_time_type: int = 3,
page_index: int = 1,
page_size: int = 200,
) -> dict[str, Any]:
"""查询京东 CPS 订单行。
query_time_type: 1 下单时间, 2 完成时间, 3 更新时间
start_time/end_time 用北京时间展示给京东;调用方需保证窗口不超过 1 小时
"""
start_bj = start_time.astimezone(_BEIJING)
end_bj = end_time.astimezone(_BEIJING)
if end_bj <= start_bj:
return {"rows": [], "has_more": False}
if end_bj - start_bj > timedelta(hours=1):
raise JdUnionError("京东订单查询单次时间窗不能超过 1 小时")
order_req: dict[str, Any] = {
"pageIndex": page_index,
"pageSize": min(max(page_size, 1), 200),
"type": query_time_type,
"startTime": start_bj.strftime("%Y-%m-%d %H:%M:%S"),
"endTime": end_bj.strftime("%Y-%m-%d %H:%M:%S"),
}
if settings.JD_UNION_AUTH_KEY:
order_req["key"] = settings.JD_UNION_AUTH_KEY
result = call("jd.union.open.order.row.query", {"orderReq": order_req})
rows, has_more = _extract_rows(result)
logger.info(
"jd.union.open.order.row.query fetched rows=%s page=%s has_more=%s",
len(rows),
page_index,
has_more,
)
return {"rows": rows, "has_more": has_more, "raw": result}
+148
View File
@@ -0,0 +1,148 @@
"""穿山甲 GroMore「聚合数据报告 API」客户端 —— 按天拉取收益报表(只读、T+1)。
这是 **GroMore 数据拉取 API**,与发奖回调验签(integrations/pangle.py m-key)
两套完全不同的凭证与用途凭证在后台接入中心 GroMore-API 聚合数据报告 API领取:
媒体账号 user_id子账号 role_idSecurity Key(secure_key)
鉴权(文档 v2.x方法一):
1. 去掉请求参数里的 `sign` 字段与值为空的字段;
2. 其余参数按 key 字典序升序,拼成 `k1=v1&k2=v2&...&kn=vn`;
3. 末尾直接拼接 security_key(无分隔符),对整串做 MD5, 32 位小写十六进制 = sign
签名有 3 分钟过期(天级用 `timestamp` 秒级时间戳;小时级才用 `current_time` 字符串)
数据口径要点(来自官方文档):
- 只返回GroMore 聚合代码位 GroMore 内的数据(=我们 useMediation 的口径),
查不到穿山甲 SDK 自身的数据;
- **不提供分用户/设备维度**(官方 FAQ 明确拒绝),最细到 日期×应用×代码位×广告源;
- `revenue` = 预估收益(,所有 ADN 都有);`api_revenue` = 收益Api( ADN Reporting
回传按实时汇率折算账号币种,更接近结算),需后台为该 ADN 配置 Reporting 才有且不支持当天;
- 今天今天以前必须分开查;天级跨度 1 个月不早于 12 个月
"""
from __future__ import annotations
import hashlib
import logging
import time
from collections.abc import Mapping
from typing import Any
import httpx
from app.core.config import settings
logger = logging.getLogger("shagua.pangle_report")
HOST = "https://www.csjplatform.com"
# 天级收益报表(路径与文档代码示例一致;另有小时级 get_hour_report_data,本服务只用天级)。
DAILY_PATH = "/union_media/open/api/mediation/get_daily_income_report_data"
VERSION = "2.0"
SIGN_TYPE = "MD5"
PAGE_LIMIT = 5000 # 文档上限;一次尽量多取,减少翻页
DEFAULT_TIMEOUT = 30.0
_MAX_PAGES = 200 # 翻页安全阀(5000×200=100w 行,远超我们规模),防异常 has_next 死循环
class PangleReportError(Exception):
"""GroMore 报表接口调用失败(未配置 / 网络 / 业务码非 100)。"""
def build_sign(params: Mapping[str, Any], security_key: str) -> str:
"""按文档「方法一」生成 sign:去 sign/空值 → key 升序 → k=v& 拼接 → 末尾接 secure_key → MD5(32 位小写)。
与官方 Python/Java 示例逐字节一致( tests/test_pangle_report.py 的两个测试向量)
"""
items = [
(str(k), str(v))
for k, v in params.items()
if k != "sign" and v is not None and str(v) != ""
]
items.sort(key=lambda kv: kv[0])
raw = "&".join(f"{k}={v}" for k, v in items)
return hashlib.md5((raw + security_key).encode("utf-8")).hexdigest()
def fetch_daily_report(
*,
start_date: str,
end_date: str,
# ⚠️ 用 ad_unit_id(GroMore 广告位ID = 我们的 104xxx),不要用 code_id(底层各 ADN 代码位,对不上)
dimensions: str = "date,site_id,ad_unit_id",
metrics: str = "revenue,api_revenue,ecpm,imp_cnt",
site_ids: str | None = None,
code_ids: str | None = None,
os: str | None = None,
network: str | None = None,
time_zone: int = 8,
limit: int = PAGE_LIMIT,
timeout: float = DEFAULT_TIMEOUT,
) -> list[dict[str, Any]]:
"""拉一个日期区间(北京时间,time_zone=8)的天级收益报表,自动翻页,返回 report_list 全量行。
每行是字符串字典(接口原值),形如
{"start_date": "2026-06-27", "site_id": "5830519", "ad_unit_id": "104142227",
"revenue": "1.23", "api_revenue": "1.05", "ecpm": "0.80", "imp_cnt": "1537", ...}
业务码非 100 直接抛 PangleReportError(由调用方/脚本兜底,不静默吞)
"""
if not settings.pangle_report_configured:
raise PangleReportError(
"PANGLE_REPORT_USER_ID / ROLE_ID / SECURITY_KEY 未配置,无法拉取 GroMore 报表"
)
base_params: dict[str, Any] = {
"user_id": settings.PANGLE_REPORT_USER_ID,
"role_id": settings.PANGLE_REPORT_ROLE_ID,
"version": VERSION,
"sign_type": SIGN_TYPE,
"start_date": start_date,
"end_date": end_date,
"dimensions": dimensions,
"metrics": metrics,
"time_zone": time_zone,
"limit": limit,
}
# 可选过滤(空则不传,避免进签名串)
for key, val in (
("site_ids", site_ids), ("code_ids", code_ids),
("os", os), ("network", network),
):
if val:
base_params[key] = val
rows: list[dict[str, Any]] = []
offset = 0
try:
with httpx.Client(timeout=timeout) as client:
for _ in range(_MAX_PAGES):
params = dict(base_params)
params["offset"] = offset
# timestamp 每页临请求时取最新(3 分钟过期),并参与签名
params["timestamp"] = int(time.time())
params["sign"] = build_sign(params, settings.PANGLE_REPORT_SECURITY_KEY)
resp = client.get(HOST + DAILY_PATH, params=params)
resp.raise_for_status()
body = resp.json()
code = str(body.get("code"))
if code != "100":
raise PangleReportError(
f"GroMore 报表业务失败 code={code} message={body.get('message')!r} "
f"(101=验签失败/102=userid无效/107=无权限/118=无收益查看权限,详见文档状态码)"
)
data = body.get("data") or {}
page = data.get("report_list") or []
rows.extend(page)
# has_next=1 还有下一页;无该字段则按本页是否取满判断
has_next = str(data.get("has_next", "")) == "1"
if not page or not has_next:
break
offset += len(page)
except httpx.HTTPError as e:
raise PangleReportError(f"GroMore 报表请求异常: {e}") from e
logger.info(
"GroMore 天级报表拉取完成 %s~%s dims=%s 行数=%d", start_date, end_date, dimensions, len(rows)
)
return rows
+3 -1
View File
@@ -14,7 +14,9 @@ worker / 多机时内存不共享 → 冷却、每日上限、校验都会失效
防刷三层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
2. 单号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限(本文件)
3. IP 频控(api rate_limit 依赖)+ 极光控制台 IP 白名单/防轰炸(运维侧)
3. 设备(device_id)每小时频控(api auth.sms_send enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)
IP 频控(rate_limit 依赖)2026-06-26 按产品要求删除改设备维度; device_id 客户端可伪造/轮换,
脚本轮换 id 能绕过本层 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)
:单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废(防爆破),验过即作废(一次性)
"""
from __future__ import annotations
+40 -10
View File
@@ -2,30 +2,32 @@
通过 `uvicorn app.main:app --reload` 启动
"""
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.api.internal.app_version import router as internal_app_version_router
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
from app.api.internal.price import router as internal_price_router
from app.api.internal.store import router as internal_store_router
from app.api.v1.ad import router as ad_router
from app.api.v1.analytics import router as analytics_router
from app.api.v1.auth import router as auth_router
from app.api.v1.compare import router as compare_router
from app.api.v1.compare_milestone import router as compare_milestone_router
from app.api.v1.compare_record import router as compare_record_router
from app.api.v1.coupon import router as coupon_router
from app.api.v1.device import router as device_router
from app.api.v1.cps_redirect import router as cps_redirect_router
from app.api.internal.app_version import router as internal_app_version_router
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
from app.api.internal.price import router as internal_price_router
from app.api.internal.store import router as internal_store_router
from app.api.v1.device import router as device_router
from app.api.v1.feedback import router as feedback_router
from app.api.v1.invite import router as invite_router
from app.api.v1.meituan import router as meituan_router
@@ -39,15 +41,16 @@ from app.api.v1.user import router as user_router
from app.api.v1.wallet import router as wallet_router
from app.api.v1.wxpay import router as wxpay_router
from app.core.config import settings
from app.core.heartbeat_monitor_worker import (
start_heartbeat_monitor,
stop_heartbeat_monitor,
)
from app.core.daily_exchange_worker import (
start_daily_exchange_worker,
stop_daily_exchange_worker,
)
from app.core.heartbeat_monitor_worker import (
start_heartbeat_monitor,
stop_heartbeat_monitor,
)
from app.core.logging import setup_logging
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
from app.core.withdraw_reconcile_worker import (
start_withdraw_reconcile_worker,
stop_withdraw_reconcile_worker,
@@ -67,6 +70,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.APP_DEBUG,
settings.DATABASE_URL.split("://", 1)[0],
)
get_pricebot_client() # 预热透传 client:把建 SSL 上下文的一次性成本付在启动,首个领券请求即热
reconcile_task = start_withdraw_reconcile_worker()
heartbeat_task = start_heartbeat_monitor()
daily_exchange_task = start_daily_exchange_worker()
@@ -76,6 +80,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
await stop_heartbeat_monitor(heartbeat_task)
await stop_withdraw_reconcile_worker(reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
await aclose_pricebot_client()
logger.info("shutting down")
@@ -105,6 +110,7 @@ def health() -> dict[str, str]:
app.include_router(auth_router)
app.include_router(user_router)
app.include_router(feedback_router)
app.include_router(analytics_router)
app.include_router(invite_router)
app.include_router(coupon_router)
app.include_router(device_router)
@@ -132,6 +138,30 @@ app.include_router(cps_redirect_router)
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
_media_root = Path(settings.MEDIA_ROOT)
_media_root.mkdir(parents=True, exist_ok=True)
# 官网下载的 APK 直链(落地页 dl.html「官网下载」按钮指向 /media/shaguabijia.apk)。
# 必须在 StaticFiles 挂载【之前】注册,否则被静态挂载吃掉。
# StaticFiles 给 .apk 的 Content-Type 不对、且无 attachment 头 → 部分国产浏览器不触发下载、转甩应用市场;
# 这里显式回 application/vnd.android.package-archive + Content-Disposition:attachment 强制浏览器下载。
# 文件由 scripts/publish_apk.sh 编 release 包后放到 data/media/shaguabijia.apk(*.apk 不入 git,需部署时放)。
_APK_PATH = _media_root / "shaguabijia.apk"
@app.get(f"{settings.MEDIA_URL_PREFIX}/shaguabijia.apk", tags=["meta"], include_in_schema=False)
def download_apk() -> FileResponse:
if not _APK_PATH.is_file():
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="安装包未就绪")
return FileResponse(
_APK_PATH,
media_type="application/vnd.android.package-archive",
filename="shaguabijia.apk",
headers={"Content-Disposition": 'attachment; filename="shaguabijia.apk"'},
)
app.mount(
settings.MEDIA_URL_PREFIX,
StaticFiles(directory=str(_media_root)),
+4
View File
@@ -1,9 +1,11 @@
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
from app.models.ad_ecpm import AdEcpmRecord # noqa: F401
from app.models.ad_feed_reward import AdFeedRewardRecord # noqa: F401
from app.models.ad_pangle_revenue import AdPangleDailyRevenue # noqa: F401
from app.models.ad_reward import AdRewardRecord # noqa: F401
from app.models.ad_watch_log import AdWatchLog # noqa: F401
from app.models.admin import AdminAuditLog, AdminUser # noqa: F401
from app.models.analytics_event import AnalyticsEvent # noqa: F401
from app.models.app_config import AppConfig # noqa: F401
from app.models.comparison import ComparisonRecord # noqa: F401
from app.models.cps_activity import CpsActivity # noqa: F401
@@ -17,6 +19,7 @@ from app.models.coupon_state import ( # noqa: F401
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
CouponSession,
)
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
@@ -37,5 +40,6 @@ from app.models.wallet import ( # noqa: F401
CashTransaction,
CoinAccount,
CoinTransaction,
InviteCashTransaction,
WithdrawOrder,
)
+3
View File
@@ -29,6 +29,9 @@ class AdEcpmRecord(Base):
)
# 广告类型:reward_video(激励视频) / draw(Draw 信息流) 等;不强行统一代码位,各类型各自上报
ad_type: Mapped[str] = mapped_column(String(32), nullable=False)
# 点位场景:comparison(比价) / coupon(领券) / welfare(福利),供收益报表区分比价/领券 Draw 收益;
# 仅信息流/Draw 上报(比价与领券共用同一代码位,只能客户端各调用点显式打标),激励视频为 NULL。
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
# 客户端生成的一次广告会话 id;激励视频 S2S 回调 extra 会透传同值
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt)
+3
View File
@@ -30,6 +30,9 @@ class AdFeedRewardRecord(Base):
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 广告类型:feed(信息流) / draw(Draw 信息流)。比价与领券共用同一 Draw 代码位,靠 feed_scene
# 区分收益;ad_type 区分广告形态。旧数据(未升级客户端)为 NULL,一律视为 feed,保持向后兼容。
ad_type: Mapped[str | None] = mapped_column(String(16), nullable=True, default="feed")
# 点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。比价与领券共用同一信息流
# 代码位,slot_id/our_code_id 分不出,只能客户端各调用点显式打标;NULL=历史/未升级客户端=未分类。
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
+73
View File
@@ -0,0 +1,73 @@
"""穿山甲 GroMore 天级收益报表(后台结算口径,定时拉取入库)。
每行 = GroMore 数据 API 返回的一条日期 × 应用 × 代码位聚合收益(`integrations/pangle_report`
+ `scripts/sync_pangle_revenue` 落库)**权威/预估收益的来源**, `ad_ecpm_record`(客户端自报
eCPM 折算的预估)互为对照:
- `revenue_yuan` 接口 `revenue`(预估收益,;排序价×展示/1000,所有 ADN 都有);
- `api_revenue_yuan` 接口 `api_revenue`(收益Api,; ADN Reporting 回传更接近结算;
未配置该 ADN Reporting 或查当天时为空)
穿山甲不提供分用户/设备维度,故本表最细只到 日期×应用×代码位,**无法挂到逐条广告事件**;
广告收益报表里只用于汇总/趋势级的穿山甲后台收益,不改逐条行的客户端预估
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
DateTime,
Float,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AdPangleDailyRevenue(Base):
__tablename__ = "ad_pangle_daily_revenue"
__table_args__ = (
# 一行 = (日期, 应用, 代码位, 广告源);adn 用 "" 表示「未分广告源、该代码位汇总」,
# 避免 NULL 在唯一约束里被视为各不相同导致 upsert 重复(SQLite/PG 行为一致)。
UniqueConstraint(
"report_date", "app_env", "our_code_id", "adn",
name="uq_ad_pangle_daily",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 北京时间日期串 'YYYY-MM-DD'(拉取时 time_zone=8),与 ad_ecpm_record.report_date 同口径,可直接 join。
report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
# 我们的应用环境:prod(傻瓜比价正式)/ test(测试);由 site_id 经 settings 映射而来。
app_env: Mapped[str] = mapped_column(String(16), nullable=False)
# 原始 GroMore AppId(site_id),留痕便于排查映射。
site_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 广告位 ID(= 接口 ad_unit_id = 我们客户端配的 104xxx = ad_ecpm_record.our_code_id),join key。
# ⚠️ 不是接口的 code_id —— 那是底层各 ADN 的代码位(如 983674557),对不上我们的口径。
our_code_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
# 广告源(接口 network 数字→名,如 pangle/gdt);"" = 未分广告源的代码位汇总行(当前默认口径)。
adn: Mapped[str] = mapped_column(String(16), nullable=False, default="")
# 预估收益(元)← 接口 revenue。
revenue_yuan: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
# 收益Api(元)← 接口 api_revenue;未配 Reporting / 当天 等情况接口不返回 → NULL。
api_revenue_yuan: Mapped[float | None] = mapped_column(Float, nullable=True)
# 预估 eCPM 原值(接口 ecpm,单位元/千次,**与客户端 getEcpm 的「分」不同**),参考用原样存。
ecpm: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 展示次数 ← 接口 imp_cnt。
impressions: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 货币类型(接口 currency,正常为 cny)。
currency: Mapped[str] = mapped_column(String(8), nullable=False, default="cny")
# 最近一次同步写入时间(同一行可被多次回补覆盖;T+1 数据穿山甲会订正)。
synced_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"<AdPangleDailyRevenue {self.report_date} {self.app_env} "
f"code={self.our_code_id} revenue={self.revenue_yuan}>"
)
+60
View File
@@ -0,0 +1,60 @@
"""新手引导(及后续)埋点事件表。
每行 = 客户端上报的一条行为埋点,Who / When / Where / What / How五维组织:
- What :event(事件名, video_play)+ props(事件专属属性,JSON)
- Who :device_id(硬件级设备标识)+ user_id(登录后才有,可空)
- When :client_ts(端事件时间 epoch ms)+ session_id(本次引导会话)+ sent_at(端上报时间)
+ created_at(服务端接收时间 = server_at)
- Where:page(引导步/页面)+ client_ip(服务端从 X-Forwarded-For )
- How :oem / os / model / app_ver / network / channel(设备与环境)
append-only,不更新;客户端批量上报( app/api/v1/analytics.py),admin 同库直接查(
app/admin/routers/event_logs.py)未登录态也允许上报(user_id 为空), user_id 不设外键只索引
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, BigInteger, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AnalyticsEvent(Base):
__tablename__ = "analytics_event"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# ---- What:做了什么 ----
event: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
props: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# ---- Who:谁 ----
device_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# ---- When:何时 ----
session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
client_ts: Mapped[int] = mapped_column(BigInteger, nullable=False) # 端事件时间 epoch ms
sent_at: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 端上报时间 epoch ms
# ---- Where:何地 ----
page: Mapped[str | None] = mapped_column(String(64), nullable=True)
client_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
# ---- How:用什么环境 ----
oem: Mapped[str | None] = mapped_column(String(32), nullable=True)
os: Mapped[str | None] = mapped_column(String(32), nullable=True)
model: Mapped[str | None] = mapped_column(String(64), nullable=True)
app_ver: Mapped[str | None] = mapped_column(String(32), nullable=True)
network: Mapped[str | None] = mapped_column(String(16), nullable=True)
channel: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 服务端接收时间(= When.server_at);客户端时间不可信,以此为权威落库时刻。
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<AnalyticsEvent id={self.id} event={self.event} device={self.device_id}>"
+76
View File
@@ -181,3 +181,79 @@ class CouponPromptEngagement(Base):
f"<CouponPromptEngagement device={self.device_id} "
f"date={self.engage_date} type={self.engage_type}>"
)
class CouponSession(Base):
"""一次领券任务的全程流水(admin「领券数据」看板数据源,2026-06-30)。
coupon_claim_record(按券一天一条去重)coupon_daily_completion(按设备一天一条)都不同:
本表**一次领券一条**(trace_id 唯一),记从发起(started)到收尾(completed/failed/abandoned)
全程耗时 + 各平台耗时 + 机型/ROM客户端 POST /api/v1/coupon/session 两段上报:发起建行
收尾按 trace_id 更新同一行发起即落库 admin 可算发起数与中途流失(started 无终态=未完成)
口径:elapsed_ms 由客户端全程计时(点发起收尾)权威;started_at/finished_at 为时刻留痕
started_date = started_at Asia/Shanghai 自然日, admin 按天聚合 / 日期筛选(索引)
"""
__tablename__ = "coupon_session"
__table_args__ = (
# 一次领券一行:trace_id 幂等 upsert(发起建、收尾更新同一行)。
UniqueConstraint("trace_id", name="uq_coupon_session_trace"),
# admin 主聚合/筛选:按上海自然日 + 环境(报表默认只看 prod,避免测试数据串台)。
Index("ix_coupon_session_date_env", "started_date", "app_env"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 键。
trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
# 登录态才带(admin join 用户表出手机号/昵称);匿名领券为空。
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# started / completed / failed / abandoned。started 无终态 = 中途流失(未完成)。
status: Mapped[str] = mapped_column(String(16), nullable=False)
# prod / dev(客户端 BuildConfig.DEBUG)。admin 报表默认只看 prod(对齐广告报表防串台口径)。
app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
# 发起勾选平台 ["meituan-waimai", ...](空=全领)。
platforms: Mapped[list | None] = mapped_column(_JSON, nullable=True)
# 发起来源外卖 App 包名;null=App 内(傻瓜比价首页)发起,非空=从美团/淘宝/京东弹券发起。
# admin「发起平台」列据此区分(空→傻瓜比价,包名→对应平台)。
origin_package: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 机型(Build.MANUFACTURER + MODEL)与 ROM(OemDetector,如 "ColorOS 14")。明细「机型/ROM」列 + 维度筛选。
device_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
rom: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 发起时刻(客户端墙钟):明细「时间」列、趋势 X 轴。
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# 发起的 Asia/Shanghai 自然日:按天聚合 / 日期范围筛选(索引)。
started_date: Mapped[date] = mapped_column(Date, nullable=False)
# 收尾时刻(服务端 now);未收尾(流失)为空。
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# 全程耗时(ms,客户端点发起→收尾):平均 / 分位都基于它(只统计 completed)。
elapsed_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 各平台领券耗时 {"meituan-waimai":3200,...}(ms)。明细美团/淘宝/京东耗时列。
platform_elapsed: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
# 领到总张数(收尾帧带)。
claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# pricebot done 帧回传的公网调试链接(price.shaguabijia.com/traces/{dir});含落盘时分秒、拼不出,只能存
# (同 ComparisonRecord.trace_url)。admin「领券数据」明细据此渲染可点 trace 链接;未到 done(failed/abandoned)为空。
trace_url: Mapped[str | None] = mapped_column(String(512), 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"<CouponSession trace={self.trace_id} status={self.status} "
f"elapsed_ms={self.elapsed_ms}>"
)
+22 -4
View File
@@ -1,11 +1,13 @@
"""CPS 对账订单(cps_order)。
美团联盟 query_order 按时间窗拉回 sid 归群的订单明细字段对齐 query_order
从联盟 API 按时间窗拉回平台落库的 CPS 订单明细字段最初对齐美团 query_order,
后续兼容京东订单报表:
实测返回:
- payPrice / profit 字符串 入库统一转(与全站口径一致)
- payTime / updateTime 是秒级时间戳 入库转 tz-aware datetime
- status: 2付款 3完成 4取消 5风控 6结算(取消/风控不计佣金)
order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)
order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)京东订单用
`jd:<row_id>` 前缀避免与美团订单号碰撞
"""
from __future__ import annotations
@@ -22,8 +24,13 @@ class CpsOrder(Base):
__tablename__ = "cps_order"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 美团订单号(加密串),全局唯一,upsert 幂等键
# 平台:meituan / jd。历史数据迁移默认 meituan
platform: Mapped[str] = mapped_column(String(20), default="meituan", index=True, nullable=False)
# 平台订单号/行号包装后的全局唯一键,upsert 幂等。
order_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
# 平台原始订单号/行号。京东一笔订单多 SKU 时可按行号区分。
external_order_id: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
external_row_id: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
# 渠道追踪位 = 群 sid(历史无 sid 订单为空)。按它归群聚合。
sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
act_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
@@ -35,11 +42,21 @@ class CpsOrder(Base):
commission_rate: Mapped[str | None] = mapped_column(String(16), nullable=True) # "300"=3% "10"=0.1%
refund_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
refund_profit_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 通用佣金拆分。美团只有预估 profit;京东有预估/实际佣金。
estimated_commission_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
actual_commission_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 美团订单状态: 2付款 3完成 4取消 5风控 6结算
mt_status: Mapped[str | None] = mapped_column(String(8), index=True, nullable=True)
# 京东订单有效码(validCode),用于判断是否有效/已完成。
jd_valid_code: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
invalid_reason: Mapped[str | None] = mapped_column(String(128), nullable=True)
product_name: Mapped[str | None] = mapped_column(String(512), nullable=True)
settle_month: Mapped[str | None] = mapped_column(String(16), nullable=True)
site_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
position_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
pid: Mapped[str | None] = mapped_column(String(128), nullable=True)
sub_union_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
pay_time: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True, nullable=True
@@ -59,5 +76,6 @@ class CpsOrder(Base):
def __repr__(self) -> str: # pragma: no cover
return (
f"<CpsOrder id={self.id} order_id={self.order_id!r} "
f"sid={self.sid!r} status={self.mt_status} profit_cents={self.commission_cents}>"
f"platform={self.platform!r} sid={self.sid!r} "
f"status={self.mt_status or self.jd_valid_code} profit_cents={self.commission_cents}>"
)
+5
View File
@@ -25,6 +25,11 @@ class Feedback(Base):
contact: Mapped[str] = mapped_column(String(128), nullable=False)
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
# 提交时的端环境快照(admin 排查用;客户端改版带上后的新反馈才有,历史数据为 NULL)
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) # 我们 app versionName
device_model: Mapped[str | None] = mapped_column(String(64), nullable=True) # Build.MODEL
rom_name: Mapped[str | None] = mapped_column(String(32), nullable=True) # OemDetector os:ColorOS/MIUI/...
android_version: Mapped[str | None] = mapped_column(String(16), nullable=True) # Build.VERSION.RELEASE
# pending(审核中) / adopted(已采纳) / rejected(未采纳)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
reject_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
+15 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, false, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -35,6 +35,20 @@ class InviteRelation(Base):
inviter_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
invitee_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# ===== v2 比价发奖追踪(好友"下载+登录+比价一次"→ 给邀请人发邀请奖励金)=====
# 是否已因"好友完成比价"发过奖:好友比价多次只发一次(防重复发,与 invitee 唯一约束双保险)
compare_reward_granted: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=false()
)
# 实发给邀请人的邀请奖励金(分);未发为 0
compare_reward_cents: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
# 发奖时间(未发为 None)
compare_rewarded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
+48
View File
@@ -25,6 +25,11 @@ class CoinAccount(Base):
)
coin_balance: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 邀请奖励金余额(分)——与金币兑换来的 cash_balance_cents **物理隔离**(产品红线:
# 邀请奖励金 ≠ 看广告/金币现金,两本账不可累加)。好友比价发奖入账、提现出账走它。
invite_cash_balance_cents: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
# 累计赚取的金币(只增不减),用于"历史总收益"类展示
total_coin_earned: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
@@ -108,6 +113,11 @@ class WithdrawOrder(Base):
# 商户单号(我们生成,微信查单的 out_bill_no),唯一
out_bill_no: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
# 这笔提现扣的是哪个账户:coin_cash(金币兑换的现金) / invite_cash(邀请奖励金)。
# 退款时据此退回**对应**账户,两本账不串。旧单默认 coin_cash。
source: Mapped[str] = mapped_column(
String(16), nullable=False, default="coin_cash", server_default="coin_cash"
)
# 提现实名(微信达额转账要求):审核后异步打款时要用,发起提现时存下,可空
user_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 归一化状态:reviewing(待审核) / pending(打款在途) / success / failed(打款失败已退) / rejected(审核拒绝已退)
@@ -200,3 +210,41 @@ class CashTransaction(Base):
def __repr__(self) -> str: # pragma: no cover
return f"<CashTransaction id={self.id} user_id={self.user_id} cents={self.amount_cents}>"
class InviteCashTransaction(Base):
"""邀请奖励金流水(单位:分)。与 cash_transaction(金币兑换现金)**物理隔离**——
产品红线:邀请奖励金 看广告/金币现金,两本账不可累加各自提现
入账=好友比价发奖(invite_reward),出账=提现(invite_withdraw)/退款(invite_withdraw_refund)
结构与 cash_transaction 同构,balance_after_cents 记的是 coin_account.invite_cash_balance_cents"""
__tablename__ = "invite_cash_transaction"
__table_args__ = (
# 提现退款幂等:一个提现单只退一次(同 cash_transaction 的 withdraw_refund 去重)
Index(
"ux_invite_cash_txn_refund_ref",
"ref_id",
unique=True,
sqlite_where=text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
postgresql_where=text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 正数=入账(发奖),负数=出账(提现)
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
balance_after_cents: Mapped[int] = mapped_column(Integer, nullable=False)
# 业务类型:invite_reward / invite_withdraw / invite_withdraw_refund
biz_type: Mapped[str] = mapped_column(String(32), nullable=False)
ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
remark: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<InviteCashTransaction id={self.id} user_id={self.user_id} cents={self.amount_cents}>"
+26 -5
View File
@@ -23,6 +23,7 @@ def create_ecpm_record(
ad_session_id: str | None = None,
adn: str | None = None,
slot_id: str | None = None,
feed_scene: str | None = None,
app_env: str | None = None,
our_code_id: str | None = None,
) -> AdEcpmRecord:
@@ -41,6 +42,7 @@ def create_ecpm_record(
ad_session_id=ad_session_id,
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
app_env=app_env,
our_code_id=our_code_id,
ecpm_raw=ecpm_raw,
@@ -51,11 +53,16 @@ def create_ecpm_record(
db.commit()
except IntegrityError:
db.rollback()
if ad_session_id:
existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
if existing is not None:
return existing
raise
# 撞唯一约束 uq_ad_ecpm_record_session(全局按 ad_session_id、不含 user_id):并发同会话重复上报,
# 或同一 ad_session_id 已被先到的上报占用。本接口 fire-and-forget、best-effort —— 丢一条不影响业务
# (穿山甲后台才是结算权威),绝不向客户端抛 500。兜底查找须与唯一约束**同口径**(只按 ad_session_id、
# 不带 user_id):否则不同 user 上报了同一 ad_session_id 时,带 user_id 的查找会漏掉那条别人的记录 →
# 旧逻辑在此 raise 成 500(本应静默吞掉)。
existing = _find_by_session_global(db, ad_session_id)
if existing is not None:
return existing
# 极少:rollback 后既存记录又查不到(并发删除 / 竞态)。吞掉、返回未入库的内存对象(调用方不读返回值)。
return rec
db.refresh(rec)
return rec
@@ -74,6 +81,20 @@ def find_by_session(
).scalar_one_or_none()
def _find_by_session_global(db: Session, ad_session_id: str | None) -> AdEcpmRecord | None:
"""按 ad_session_id **全局**查找(与唯一约束 uq_ad_ecpm_record_session 同口径,不含 user_id)。
create_ecpm_record 撞约束后兜底用:此时撞的是全局会话约束,既存记录可能属于**另一个 user**,
user_id find_by_session 会漏掉它导致误判查无 raise 500其它业务查 user 的某次
展示 eCPM仍用 find_by_session( user_id,语义更准),不走这里
"""
if not ad_session_id:
return None
return db.execute(
select(AdEcpmRecord).where(AdEcpmRecord.ad_session_id == ad_session_id)
).scalar_one_or_none()
def count_today(db: Session, user_id: int) -> int:
"""该用户今日(北京时间)上报的 eCPM 条数,排查/对账辅助用。"""
return db.execute(
+34 -18
View File
@@ -14,7 +14,6 @@ from app.core.rewards import cn_today
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.repositories import wallet as crud_wallet
FEED_REWARD_UNIT_SECONDS = 10
# 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数
# (每 10 秒 1 份)。真实单条信息流视频远小于此;取 120s=12 份封顶,挡刷量、不影响正规单。
@@ -65,6 +64,7 @@ def grant_feed_reward(
client_event_id: str,
ecpm: str,
duration_seconds: int,
ad_type: str = "feed",
ad_session_id: str | None = None,
adn: str | None = None,
slot_id: str | None = None,
@@ -73,17 +73,22 @@ def grant_feed_reward(
app_env: str | None = None,
our_code_id: str | None = None,
aborted: bool = False,
display_coin: int = 0,
) -> AdFeedRewardRecord:
"""**每条**信息流广告(客户端每条各上报一次)结算奖励。client_event_id 幂等,同号重试不重复发。
发奖规则:**一条广告 = 一个单次公式值**(rewards.calculate_ad_reward_coin),因子2(LT)按账号累计
****数递进;看满一份时长(unit_count>=1, 10 )才发,**不逐份累加**
发奖规则(所见即所得, 2026-06-27 用户拍板显示多少给多少):优先**直接发客户端小球显示的金币
display_coin**;防刷钳到本条1 份满额(eCPM 已钳 AD_ECPM_MAX_FEN, 因子2 按账号累计已发条数取档),
合法显示(实际因子2 × 进度 p 1 )不被砍, 只挡伪造天价值旧客户端不传 display_coin 时退回
看满 10 秒发整份(兼容不断币)因子2(LT)**客户端** granted COUNT(拉自 /feed-reward/units)
算进 display_coin, 后端只记 granted 行让该计数自增, 不再服务端重算份值
- aborted=True(用户中途 关闭这条):本条不发, status='closed_early'
- 时长不足 10 (unit_count==0): status='too_short' 不发
- display_coin 0 且时长不足一份: status='too_short' 不发(不计 LT / 当日上限)
- 命中当日条数上限: status='capped' 不发
duration_seconds **这一条**的观看秒数服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS
eCPM calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN;叠加每日 get_ad_daily_limit 条数上限
duration_seconds 落库留痕(unit_count 字段), 旧端兼容路径据它判是否满 1
feed_scene:点位场景(comparison/coupon/welfare),仅归类落库,不参与计算
ad_type:广告形态(feed 信息流 / draw Draw 信息流),仅归类落库;**每日上限与因子2(LT)仍按本表
全表 unit 累计(feed+draw 共享同一发奖池/上限),不按 ad_type 拆分**
"""
existing = _find_by_event(db, client_event_id)
if existing is not None:
@@ -106,6 +111,7 @@ def grant_feed_reward(
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
ad_type=ad_type,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
@@ -126,6 +132,7 @@ def grant_feed_reward(
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
ad_type=ad_type,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
@@ -135,18 +142,31 @@ def grant_feed_reward(
)
return _commit_record(db, rec, client_event_id)
# 整场总时长不足 10 秒,凑不满一份 → 不发,记 too_short 留痕。
if unit_count == 0:
# 所见即所得(用户 2026-06-27「显示多少给多少」): 优先发**客户端小球显示**的金币 display_coin,
# 钳到本条「1 份满额」防刷(eCPM 已钳 AD_ECPM_MAX_FEN; 合法显示=因子2×p≤1份, 不会被砍)。
# 因子2(LT)按账号累计已发条数(granted 行 COUNT), 第 existing_ads+1 条。
existing_ads = granted_unit_total(db, user_id)
unit_cap = rewards.calculate_ad_reward_coin(ecpm, existing_ads + 1)
if display_coin > 0:
coin = min(display_coin, unit_cap) # 新端: 所见即所得(直接发小球显示金币)
elif unit_count >= 1:
coin = unit_cap # 旧端没传 display_coin: 退回「看满 1 份发整份」(兼容)
else:
coin = 0
# 显示金币为 0 且没满一份 → 不发, 记 too_short 留痕(不写 granted 行 → 不计 LT / 当日上限)。
if coin <= 0:
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
user_id=user_id,
reward_date=today,
duration_seconds=safe_duration,
unit_count=0,
unit_count=unit_count,
ad_session_id=ad_session_id,
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
ad_type=ad_type,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
@@ -156,15 +176,11 @@ def grant_feed_reward(
)
return _commit_record(db, rec, client_event_id)
# 一条广告 = 一个「单次公式值」(因子2 按账号累计**条**数, 即第 existing_ads+1 条);看满一份(unit_count>=1)即发,不逐份累加。
existing_ads = granted_unit_total(db, user_id)
coin = rewards.calculate_ad_reward_coin(ecpm, existing_ads + 1)
if coin > 0:
crud_wallet.grant_coins(
db, user_id, coin,
biz_type="feed_ad_reward", ref_id=client_event_id,
remark="信息流广告奖励",
)
crud_wallet.grant_coins(
db, user_id, coin,
biz_type="feed_ad_reward", ref_id=client_event_id,
remark="信息流广告奖励",
)
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
user_id=user_id,
+109
View File
@@ -0,0 +1,109 @@
"""穿山甲 GroMore 天级收益 读写(`ad_pangle_daily_revenue` 表)。
`scripts/sync_pangle_revenue` 拉数后调 `upsert_daily_rows` 落库(同一(日期×应用×代码位×广告源)
幂等覆盖,T+1 订正可重跑);admin 广告收益报表调 `aggregate_by_date` 穿山甲后台收益
汇总/趋势级展示穿山甲无用户维度,故这里不涉及 user_id
"""
from __future__ import annotations
from typing import Any, TypedDict
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.ad_pangle_revenue import AdPangleDailyRevenue
# upsert 时可被覆盖更新的列(唯一键之外的业务列)。
_UPDATABLE = ("site_id", "revenue_yuan", "api_revenue_yuan", "ecpm", "impressions", "currency")
class PangleDateAgg(TypedDict):
date: str
revenue_yuan: float
api_revenue_yuan: float | None
impressions: int
def upsert_daily_rows(db: Session, rows: list[dict[str, Any]]) -> dict[str, int]:
"""按唯一键 (report_date, app_env, our_code_id, adn) 逐行 upsert。
每个 row 须含:report_date, app_env, our_code_id;可选 adn(默认"")site_id
revenue_yuanapi_revenue_yuanecpmimpressionscurrency返回 {inserted, updated}
规模很小(代码位数×天数),逐行 select-then-write 足够, SQLite/PG 通用
"""
inserted = updated = 0
for row in rows:
adn = row.get("adn") or ""
existing = db.execute(
select(AdPangleDailyRevenue).where(
AdPangleDailyRevenue.report_date == row["report_date"],
AdPangleDailyRevenue.app_env == row["app_env"],
AdPangleDailyRevenue.our_code_id == row["our_code_id"],
AdPangleDailyRevenue.adn == adn,
)
).scalar_one_or_none()
if existing is None:
db.add(AdPangleDailyRevenue(
report_date=row["report_date"],
app_env=row["app_env"],
site_id=row.get("site_id"),
our_code_id=row["our_code_id"],
adn=adn,
revenue_yuan=float(row.get("revenue_yuan") or 0.0),
api_revenue_yuan=row.get("api_revenue_yuan"),
ecpm=row.get("ecpm"),
impressions=int(row.get("impressions") or 0),
currency=row.get("currency") or "cny",
))
inserted += 1
else:
for col in _UPDATABLE:
if col in row:
setattr(existing, col, row[col])
updated += 1
db.commit()
return {"inserted": inserted, "updated": updated}
def aggregate_by_date(
db: Session,
*,
date_from: str,
date_to: str,
app_env: str | None = None,
our_code_id: str | None = None,
) -> list[PangleDateAgg]:
"""按日期汇总穿山甲收益(闭区间,北京时间),供报表趋势 + 合计。
revenue_yuan = Σrevenue;api_revenue_yuan = Σapi_revenue(SQL SUM 忽略 NULL,
全为空则返回 None,前端显示-)可选按应用 / 代码位过滤返回按日期升序
"""
stmt = (
select(
AdPangleDailyRevenue.report_date,
func.sum(AdPangleDailyRevenue.revenue_yuan),
func.sum(AdPangleDailyRevenue.api_revenue_yuan),
func.sum(AdPangleDailyRevenue.impressions),
)
.where(
AdPangleDailyRevenue.report_date >= date_from,
AdPangleDailyRevenue.report_date <= date_to,
)
.group_by(AdPangleDailyRevenue.report_date)
.order_by(AdPangleDailyRevenue.report_date)
)
if app_env is not None:
stmt = stmt.where(AdPangleDailyRevenue.app_env == app_env)
if our_code_id is not None:
stmt = stmt.where(AdPangleDailyRevenue.our_code_id == our_code_id)
out: list[PangleDateAgg] = []
for report_date, rev, api_rev, imp in db.execute(stmt).all():
out.append(PangleDateAgg(
date=report_date,
revenue_yuan=round(float(rev or 0.0), 6),
api_revenue_yuan=(round(float(api_rev), 6) if api_rev is not None else None),
impressions=int(imp or 0),
))
return out
+34
View File
@@ -0,0 +1,34 @@
"""埋点事件批量落库。append-only,一次 commit 提交整批。"""
from __future__ import annotations
from sqlalchemy.orm import Session
from app.models.analytics_event import AnalyticsEvent
from app.schemas.analytics import AnalyticsBatchIn
def record_batch(db: Session, batch: AnalyticsBatchIn, *, client_ip: str | None) -> int:
"""把一批上报事件展开成多行落库(公共维度复制到每行),返回写入条数。"""
rows = [
AnalyticsEvent(
event=e.event,
props=e.props or None,
device_id=batch.device_id,
user_id=batch.user_id,
session_id=e.session_id,
client_ts=e.client_ts,
sent_at=batch.sent_at,
page=e.page,
client_ip=client_ip or None,
oem=batch.oem,
os=batch.os,
model=batch.model,
app_ver=batch.app_ver,
network=e.network,
channel=batch.channel,
)
for e in batch.events
]
db.add_all(rows)
db.commit()
return len(rows)
+4 -4
View File
@@ -101,10 +101,10 @@ AD_CONFIG_KEY = "ad_config"
_AD_CONFIG_DEFAULTS: dict[str, Any] = {
"app_id": "5830519", # 穿山甲应用ID(正式)
"reward_code_id": "104099389", # 福利页激励视频位
# ⚠️ 2026-06-21 真机核对穿山甲后台:5830519 名下信息流真实位是 104142227「信息流 1」;
# 旧值 104090333 不在该应用名下(请求会报 44406/配置 null、出不了广告)。客户端接入下发后以本值为准
"compare_feed_code_id": "104142227", # 比价信息流位
"coupon_feed_code_id": "104142227", # 领券信息流位(初始同比价,运营可拆)
# 比价与领券共用同一穿山甲 Draw 代码位(默认 104098712 = 后台「Draw信息流」位),靠 feed_scene
# (comparison/coupon)区分收益;运营可在后台把两者拆成不同代码位。字段从旧 *_feed_code_id 改名为 *_draw_code_id
"compare_draw_code_id": "104098712", # 比价 Draw 代码位(104098712 = Draw 信息流位)
"coupon_draw_code_id": "104098712", # 领券 Draw 代码位(初始同比价,运营可拆)
"reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*)
"reward_enabled": True, # 福利激励视频开关
"compare_ad_enabled": True, # 比价广告开关
+90 -1
View File
@@ -6,7 +6,7 @@
from __future__ import annotations
import logging
from datetime import date, datetime
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
from sqlalchemy import delete, func, select
@@ -17,6 +17,7 @@ from app.models.coupon_state import (
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
CouponSession,
)
logger = logging.getLogger("shagua.coupon_state")
@@ -232,3 +233,91 @@ def sum_claimed_count(db: Session, user_id: int) -> int:
)
).scalar_one()
return int(total or 0)
# ===== 领券任务流水(coupon_session,admin「领券数据」看板数据源)=====
def upsert_coupon_session(
db: Session,
*,
trace_id: str,
device_id: str,
status: str,
started_at_ms: int,
user_id: int | None = None,
platforms: list[str] | None = None,
origin_package: str | None = None,
device_model: str | None = None,
rom: str | None = None,
app_env: str | None = None,
elapsed_ms: int | None = None,
platform_elapsed: dict | None = None,
claimed_count: int | None = None,
trace_url: str | None = None,
) -> None:
"""一条领券流水按 trace_id 幂等 upsert(发起 started 建行、收尾终态更新同一行)。
- 乱序/重复兜底:终态(completed/failed/abandoned)先到也建行;started 重复到不覆盖已有终态
(状态只前进,不降级)
- started_at 由客户端墙钟毫秒转;started_date 取其 Asia/Shanghai 自然日(admin 按天聚合/筛选)
- 终态帧补 finished_at=服务端 now;各字段非空才写(避免 started 帧的 None 抹掉收尾值,反之亦然)
并发 IntegrityError 回滚忽略(本就幂等)
"""
started_at = datetime.fromtimestamp(started_at_ms / 1000, tz=timezone.utc)
started_date = started_at.astimezone(_CN_TZ).date()
is_terminal = status in ("completed", "failed", "abandoned")
row = db.execute(
select(CouponSession).where(CouponSession.trace_id == trace_id)
).scalar_one_or_none()
if row is None:
db.add(CouponSession(
trace_id=trace_id,
device_id=device_id,
user_id=user_id,
status=status,
app_env=app_env,
platforms=platforms,
origin_package=origin_package,
device_model=device_model,
rom=rom,
started_at=started_at,
started_date=started_date,
finished_at=datetime.now(timezone.utc) if is_terminal else None,
elapsed_ms=elapsed_ms,
platform_elapsed=platform_elapsed,
claimed_count=claimed_count,
trace_url=trace_url,
))
else:
# 状态只前进:started 帧重复到(如 START_STICKY 重启)不把已有终态降级回 started。
if not (status == "started" and row.status in ("completed", "failed", "abandoned")):
row.status = status
if is_terminal:
row.finished_at = datetime.now(timezone.utc)
if user_id is not None:
row.user_id = user_id
if platforms is not None:
row.platforms = platforms
if origin_package is not None:
row.origin_package = origin_package
if device_model is not None:
row.device_model = device_model
if rom is not None:
row.rom = rom
if app_env is not None:
row.app_env = app_env
if elapsed_ms is not None:
row.elapsed_ms = elapsed_ms
if platform_elapsed is not None:
row.platform_elapsed = platform_elapsed
if claimed_count is not None:
row.claimed_count = claimed_count
if trace_url is not None:
row.trace_url = trace_url
try:
db.commit()
except IntegrityError:
# 并发下另一请求刚插了同 trace_id → 唯一约束撞,回滚忽略(本就幂等)。
db.rollback()
+8
View File
@@ -17,12 +17,20 @@ def create_feedback(
content: str,
contact: str,
images: list[str] | None,
app_version: str | None = None,
device_model: str | None = None,
rom_name: str | None = None,
android_version: str | None = None,
) -> Feedback:
fb = Feedback(
user_id=user_id,
content=content,
contact=contact,
images=images or None,
app_version=app_version or None,
device_model=device_model or None,
rom_name=rom_name or None,
android_version=android_version or None,
status="pending",
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
)
+120 -28
View File
@@ -1,13 +1,14 @@
"""好友邀请 CRUD(注册即生效,邀请人 + 被邀请人各发金币)。
"""好友邀请 CRUD(注册即生效,绑定只建归因关系、双方都不发钱)。
发奖规则(v3, 2026-06-26 拍板):
- 绑定时双方都不发钱(被邀请人无奖励邀请人不发金币)
- 邀请人的钱由 try_reward_on_compare 在好友"成功比价一次"后发 2 元邀请奖励金(防刷)
- v1 "绑定双方各发金币"已废;invite_relation inviter_coin/invitee_coin 列保留恒 0(待清)
防重复发奖三道(仿 ad_reward / 提现的资金安全思路):
1. invitee_user_id 唯一 一个被邀请人只能被绑定一次(幂等键)
2. 自邀屏蔽 inviter == invitee 直接拒
3. 现成的手机号唯一(每个被邀请人 = 一个真实手机号账号)= 天然限制刷量规模
发金币复用 wallet.grant_coins(grant flush commit),与建关系记录在**同一事务**
commit,保证"建关系 + 双方加金币"原子奖励额 = rewards.INVITE_INVITER_COINS /
INVITE_INVITEE_COINS
"""
from __future__ import annotations
@@ -34,6 +35,30 @@ def _gen_code() -> str:
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LEN))
# ===== v2 邀请倒计时(7 天 1 轮,锚点=注册日,自然日差,东八区)=====
# 中国不用夏令时,固定 +8 偏移即可(不依赖 tzdata,Windows 本地联调也稳)。
_CST = timezone(timedelta(hours=8))
def compute_invite_countdown(register_at: datetime) -> tuple[int, bool, str]:
"""按 7 天 1 轮算邀请页倒计时。
锚点 = 用户注册日(user.created_at),按东八区自然日差算
(跨自然日才减同日多次登录不减,天然满足)返回:
(本轮剩余天数 1..7, 是否刚进入新一轮[非首轮第1天], 展示文案)
"""
reg = register_at if register_at.tzinfo else register_at.replace(tzinfo=timezone.utc)
days_since = max(0, (datetime.now(_CST).date() - reg.astimezone(_CST).date()).days)
day_in_cycle = days_since % 7 # 0..6(本轮第几天,0-based)
days_left = 7 - day_in_cycle # 7..1(第1天=7、第7天=1)
is_fresh_round = days_since >= 7 and day_in_cycle == 0 # 非首轮的第1天
if is_fresh_round:
text = "恭喜您进入新一轮邀请!\n距离本轮结束还有7天"
else:
text = f"距本轮结束还有{days_left}"
return days_left, is_fresh_round, text
def ensure_code(db: Session, user: User) -> str:
"""保证 user 有邀请码(懒生成),返回它。唯一约束碰撞则换码重试。
@@ -90,15 +115,17 @@ def _is_new_user(user: User) -> bool:
class BindResult:
status: str # success / already_bound / invalid_code / self_invite / not_eligible
relation: InviteRelation | None = None
invitee_coin: int = 0 # 本次给被邀请人发的金币(success 时 >0)
invitee_coin: int = 0 # v3 起恒 0(绑定不再发金币);保留字段兼容响应
def bind(
db: Session, *, invitee: User, invite_code: str, channel: str = "clipboard"
) -> BindResult:
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效 + 双方发金币
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效。
幂等:invitee 已被绑过 already_bound(不重复发奖)
v3 发奖( 2026-06-26 拍板):绑定双方都不发钱被邀请人无奖励,邀请人改"好友成功比价一次
才发 2 元邀请奖励金"(见 try_reward_on_compare,防刷)。绑定只建归因关系 + 跑防刷闸。
幂等:已绑过 already_bound
"""
# 幂等:已绑过直接返回(不重复发奖)
existing = _relation_of_invitee(db, invitee.id)
@@ -114,27 +141,18 @@ def bind(
if not _is_new_user(invitee):
return BindResult("not_eligible")
inviter_coin = rewards.INVITE_INVITER_COINS
invitee_coin = rewards.INVITE_INVITEE_COINS
# v3(冰 2026-06-26 拍板):绑定双方都不发钱。被邀请人不再发新人金币(去掉拉新即时激励);
# 邀请人的钱由 try_reward_on_compare 在好友成功比价后发 2 元邀请奖励金(防刷)。
rel = InviteRelation(
inviter_user_id=inviter.id,
invitee_user_id=invitee.id,
channel=(channel or "clipboard")[:16],
status="effective",
inviter_coin=inviter_coin,
invitee_coin=invitee_coin,
inviter_coin=0, # v1 金币线已停用,列保留恒 0(待清)
invitee_coin=0, # v3:被邀请人绑定不再发金币
)
db.add(rel)
# 双方发金币(同事务,与建关系一起 commit)。ref_id 互指对方便于对账
crud_wallet.grant_coins(
db, inviter.id, inviter_coin,
biz_type="invite_inviter", ref_id=str(invitee.id), remark="邀请好友奖励",
)
crud_wallet.grant_coins(
db, invitee.id, invitee_coin,
biz_type="invite_invitee", ref_id=str(inviter.id), remark="新人受邀奖励",
)
# 绑定不发任何金币(邀请人改比价发现金、被邀请人无奖励),仅建归因关系
try:
db.commit()
except IntegrityError:
@@ -145,20 +163,74 @@ def bind(
return BindResult("already_bound", existing)
raise
except Exception:
# 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证"建关系 + 双方发币"
# 原子(要么全成要么全无),不依赖 get_db 关闭时的隐式回滚,语义更硬。
# 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证建关系原子,
# 不依赖 get_db 关闭时的隐式回滚,语义更硬。
db.rollback()
raise
db.refresh(rel)
return BindResult("success", rel, invitee_coin)
return BindResult("success", rel)
@dataclass
class CompareRewardResult:
status: str # granted / no_relation / already_granted / inviter_inactive
inviter_user_id: int | None = None
reward_cents: int = 0
def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardResult:
"""被邀请人完成一次成功比价时调用:若其有邀请关系且尚未发过比价奖,给【邀请人】发邀请奖励金。
v2 发奖规则核心(替代 v1 "注册即发金币"):好友"下载+登录+比价一次" 邀请人得 2 元现金
幂等:compare_reward_granted 标记保证好友比价多次只发一次无邀请关系 / 已发过 / 邀请人失效
空操作发奖(grant_invite_cash 入账独立账户)+ 置标记同事务 commit,保证原子
"""
rel = _relation_of_invitee(db, invitee_user_id)
if rel is None:
return CompareRewardResult("no_relation")
if rel.compare_reward_granted:
return CompareRewardResult("already_granted", rel.inviter_user_id)
inviter = db.get(User, rel.inviter_user_id)
if inviter is None or inviter.status != "active":
# 邀请人注销 / 封禁:本次不发、不置标记,待其恢复后下次比价再试(保守,不吞奖励)
return CompareRewardResult("inviter_inactive", rel.inviter_user_id)
reward = rewards.INVITE_COMPARE_REWARD_CENTS
rel.compare_reward_granted = True
rel.compare_reward_cents = reward
rel.compare_rewarded_at = datetime.now(timezone.utc)
# 发邀请奖励金到邀请人的独立账户(与金币隔离),ref_id 指向被邀请人便于对账
crud_wallet.grant_invite_cash(
db, inviter.id, reward,
biz_type="invite_reward", ref_id=str(invitee_user_id), remark="好友比价奖励",
)
try:
db.commit()
except Exception:
db.rollback()
raise
return CompareRewardResult("granted", inviter.id, reward)
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。"""
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。
"已邀请好友数"口径 = 完成一次比价(已触发邀请奖励金)的被邀请人数, compare_reward_granted=True
不再数"仅绑定未比价"的关系否则会出现"已邀请 1、可提现余额 0"(好友下载登录但没比价),
与产品口径"已邀请好友数 × 2元 = 累计提现 + 可提现余额"对不上过滤后该恒等式天然成立
(每个计入的好友都恰好发过 1 2 ,钱要么在余额要么已提现)
金币口径(inviter_coin 之和) v3 起恒 0(邀请人收益改走邀请奖励金, get_reward_stats /
try_reward_on_compare);保留返回位兼容旧响应字段 coins_earned
"""
count = db.execute(
select(func.count())
.select_from(InviteRelation)
.where(InviteRelation.inviter_user_id == inviter_id)
.where(
InviteRelation.inviter_user_id == inviter_id,
InviteRelation.compare_reward_granted.is_(True),
)
).scalar_one()
coins = db.execute(
select(func.coalesce(func.sum(InviteRelation.inviter_coin), 0))
@@ -167,6 +239,26 @@ def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
return int(count), int(coins)
def get_reward_stats(db: Session, inviter_id: int) -> tuple[int, int]:
"""v2 邀请奖励金战绩:(可提现余额/分, 累计提现成功/分)。
余额 = coin_account.invite_cash_balance_cents;累计提现 = 该用户 source=invite_cash
status=success 的提现单金额之和(成功打款才算) /invite/me 提现板块展示
"""
from app.models.wallet import CoinAccount, WithdrawOrder
balance = db.execute(
select(CoinAccount.invite_cash_balance_cents).where(CoinAccount.user_id == inviter_id)
).scalar_one_or_none()
withdrawn = db.execute(
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
WithdrawOrder.user_id == inviter_id,
WithdrawOrder.source == "invite_cash",
WithdrawOrder.status == "success",
)
).scalar_one()
return int(balance or 0), int(withdrawn)
def _mask_phone(phone: str) -> str:
"""手机号脱敏:138****8888。前端拿不到完整号,展示被邀请人时在此兜底名字。
@@ -210,7 +302,7 @@ def get_invitees(
items.append({
"display_name": u.nickname or u.wechat_nickname or _mask_phone(u.phone),
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
"coins": rel.inviter_coin,
"coins": rel.inviter_coin, # v3 起恒 0(邀请人收益改走邀请奖励金)
"invited_at": rel.created_at,
})
has_more = offset + len(rows) < int(total)
+26
View File
@@ -2,8 +2,10 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.launch_confirm_sample import LaunchConfirmSample
@@ -33,3 +35,27 @@ def insert_sample(db: Session, payload: LaunchConfirmSampleIn) -> int:
db.commit()
db.refresh(row)
return row.id
def list_samples(
db: Session,
*,
exec_success: Optional[bool] = None,
host_package: Optional[str] = None,
since: Optional[datetime] = None,
limit: int = 1000,
) -> list[LaunchConfirmSample]:
"""按条件查样本(pricebot 的 distill_launch_confirm.py 沉淀工具读)。created_at 升序。
过滤项都可空:exec_success(只看放行成功的)/ host_package(只看某宿主包)/
since(>= created_at)limit 兜底防一次拉爆全表
"""
stmt = select(LaunchConfirmSample)
if exec_success is not None:
stmt = stmt.where(LaunchConfirmSample.exec_success == exec_success)
if host_package:
stmt = stmt.where(LaunchConfirmSample.host_package == host_package)
if since is not None:
stmt = stmt.where(LaunchConfirmSample.created_at >= since)
stmt = stmt.order_by(LaunchConfirmSample.created_at.asc()).limit(limit)
return list(db.execute(stmt).scalars().all())
+91 -33
View File
@@ -24,6 +24,7 @@ from app.models.wallet import (
CashTransaction,
CoinAccount,
CoinTransaction,
InviteCashTransaction,
WechatTransferAuthorization,
WithdrawOrder,
)
@@ -166,6 +167,36 @@ def grant_cash(
return acc, txn
def grant_invite_cash(
db: Session,
user_id: int,
amount_cents: int,
*,
biz_type: str,
ref_id: str | None = None,
remark: str | None = None,
) -> tuple[CoinAccount, InviteCashTransaction]:
"""邀请奖励金变动入口(正数入账 / 负数出账)。更新 invite_cash_balance_cents + 写
invite_cash_transaction, commit与金币兑换的 cash_balance_cents **物理隔离**
(产品红线:邀请奖励金 金币现金,两本账不可累加)返回 (account, transaction),
调用方负责 commit不在此校验扣成负由调用方按业务保护"""
acc = get_or_create_account(db, user_id, commit=False)
acc.invite_cash_balance_cents += amount_cents
txn = InviteCashTransaction(
user_id=user_id,
amount_cents=amount_cents,
balance_after_cents=acc.invite_cash_balance_cents,
biz_type=biz_type,
ref_id=ref_id,
remark=remark,
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
)
db.add(txn)
db.flush()
return acc, txn
def list_coin_transactions(
db: Session,
user_id: int,
@@ -411,33 +442,43 @@ def refund_reviewing_withdraws_on_unbind(db: Session, user_id: int) -> int:
_OUT_BILL_NO_RE = re.compile(r"^[0-9A-Za-z_-]{8,32}$")
def _try_deduct_cash(db: Session, user_id: int, amount_cents: int) -> bool:
"""原子扣减现金:仅当余额足够时扣,返回是否成功
def _balance_col(source: str):
"""提现账户来源 → CoinAccount 余额列。invite_cash=邀请奖励金;否则金币兑换的现金
两账户物理隔离,提现扣款/退款都按 source 走对应列,互不串"""
return (
CoinAccount.invite_cash_balance_cents
if source == "invite_cash"
else CoinAccount.cash_balance_cents
)
用带条件的 UPDATE(`WHERE cash_balance_cents >= amount`)避免"读-判断-写"竞态
并发/重试时不会两次都通过余额检查导致超额扣款(SQLite 串行写Postgres 行级,均安全)
def _try_deduct_cash(db: Session, user_id: int, amount_cents: int, source: str = "coin_cash") -> bool:
"""原子扣减指定账户余额:仅当余额足够时扣,返回是否成功。
用带条件的 UPDATE(`WHERE <col> >= amount`)避免"读-判断-写"竞态并发/重试时不会两次
都通过余额检查导致超额扣款(SQLite 串行写Postgres 行级,均安全)source 决定扣
cash_balance_cents(coin_cash) 还是 invite_cash_balance_cents(invite_cash)
"""
col = _balance_col(source)
res = db.execute(
update(CoinAccount)
.where(
CoinAccount.user_id == user_id,
CoinAccount.cash_balance_cents >= amount_cents,
)
.values(cash_balance_cents=CoinAccount.cash_balance_cents - amount_cents)
.where(CoinAccount.user_id == user_id, col >= amount_cents)
.values({col: col - amount_cents})
)
return res.rowcount == 1
def _add_cash(db: Session, user_id: int, amount_cents: int) -> int:
"""原子增加现金(退款用),返回加后余额。"""
def _add_cash(db: Session, user_id: int, amount_cents: int, source: str = "coin_cash") -> int:
"""原子增加指定账户余额(退款用),返回加后余额。source 决定退回哪个账户(两账户隔离)。"""
col = _balance_col(source)
db.execute(
update(CoinAccount)
.where(CoinAccount.user_id == user_id)
.values(cash_balance_cents=CoinAccount.cash_balance_cents + amount_cents)
.values({col: col + amount_cents})
)
db.flush()
bal = db.execute(
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
select(col).where(CoinAccount.user_id == user_id)
).scalar_one()
return bal
@@ -456,11 +497,15 @@ def _refund_withdraw(
"""
if order.status in ("failed", "rejected"):
return # 防重复退款(并发/对账与查单/重复拒绝同时触发)
# 账户隔离:按 order.source 退回对应账户 + 写对应退款流水表
is_invite = order.source == "invite_cash"
txn_model = InviteCashTransaction if is_invite else CashTransaction
refund_biz = "invite_withdraw_refund" if is_invite else "withdraw_refund"
refunded_txn_id = db.execute(
select(CashTransaction.id).where(
CashTransaction.user_id == order.user_id,
CashTransaction.biz_type == "withdraw_refund",
CashTransaction.ref_id == order.out_bill_no,
select(txn_model.id).where(
txn_model.user_id == order.user_id,
txn_model.biz_type == refund_biz,
txn_model.ref_id == order.out_bill_no,
).limit(1)
).scalar_one_or_none()
if refunded_txn_id is not None:
@@ -468,13 +513,13 @@ def _refund_withdraw(
order.fail_reason = reason[:256]
db.commit()
return
bal = _add_cash(db, order.user_id, order.amount_cents)
bal = _add_cash(db, order.user_id, order.amount_cents, order.source)
db.add(
CashTransaction(
txn_model(
user_id=order.user_id,
amount_cents=order.amount_cents,
balance_after_cents=bal,
biz_type="withdraw_refund",
biz_type=refund_biz,
ref_id=order.out_bill_no,
# 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason
remark=(
@@ -492,13 +537,13 @@ def _refund_withdraw(
db.commit()
except IntegrityError:
# 并发退款兜底:唯一退款流水已被另一事务写入时,回滚本事务的加钱和流水,
# 再把订单状态补到终态。这样无论拒绝/查单/对账怎么并发,金最多退一次。
# 再把订单状态补到终态。这样无论拒绝/查单/对账怎么并发,金最多退一次。
db.rollback()
refunded_txn_id = db.execute(
select(CashTransaction.id).where(
CashTransaction.user_id == user_id,
CashTransaction.biz_type == "withdraw_refund",
CashTransaction.ref_id == out_bill_no,
select(txn_model.id).where(
txn_model.user_id == user_id,
txn_model.biz_type == refund_biz,
txn_model.ref_id == out_bill_no,
).limit(1)
).scalar_one_or_none()
if refunded_txn_id is None:
@@ -562,8 +607,10 @@ def create_withdraw(
user_id: int,
amount_cents: int,
*,
source: str = "coin_cash",
user_name: str | None = None,
out_bill_no: str | None = None,
allow_sub_min: bool = False,
) -> WithdrawOrder:
"""发起提现:原子扣款 + 建单 reviewing(待人工审核),**不打款**。
@@ -573,8 +620,13 @@ def create_withdraw(
重复发起多笔提现(审核拒绝再退回)
#2 out_bill_no 客户端幂等键:同号重试返回该单现状(reviewing 等审核),不重复扣款建单。
实名 user_name 在此存下(WithdrawOrder.user_name),供异步审核打款时传给微信(达额需实名)
allow_sub_min:放行低于"提现最低额"的小额(用于 0.01 元调试提现)仅由 endpoint
`skip_review and not is_prod`(debug + 非生产双闸)时置 True;仍受 schema gt=0 max 上限约束
生产恒为 False 最低额校验照常,绝不可能提 0.01
"""
if amount_cents < rewards.get_withdraw_min_cents(db) or amount_cents > rewards.get_withdraw_max_cents(db):
min_c = 0 if allow_sub_min else rewards.get_withdraw_min_cents(db)
if amount_cents < min_c or amount_cents > rewards.get_withdraw_max_cents(db):
raise InvalidWithdrawAmountError
# 提现即要求已绑微信:否则审核通过也打不了款,提前拦更友好
@@ -608,20 +660,23 @@ def create_withdraw(
# 账户须存在(原子扣款的 UPDATE 不会建账户)
get_or_create_account(db, user_id, commit=True)
# #1 原子扣款:余额不足时影响行数为 0
if not _try_deduct_cash(db, user_id, amount_cents):
# #1 原子扣款:余额不足时影响行数为 0(按 source 扣对应账户)
if not _try_deduct_cash(db, user_id, amount_cents, source):
db.rollback()
raise InsufficientCashError
is_invite = source == "invite_cash"
txn_model = InviteCashTransaction if is_invite else CashTransaction
withdraw_biz = "invite_withdraw" if is_invite else "withdraw"
bal = db.execute(
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
select(_balance_col(source)).where(CoinAccount.user_id == user_id)
).scalar_one()
db.add(
CashTransaction(
txn_model(
user_id=user_id,
amount_cents=-amount_cents,
balance_after_cents=bal,
biz_type="withdraw",
biz_type=withdraw_biz,
ref_id=out_bill_no,
remark="提现到微信零钱(待审核)",
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
@@ -631,6 +686,7 @@ def create_withdraw(
user_id=user_id,
out_bill_no=out_bill_no,
amount_cents=amount_cents,
source=source,
user_name=user_name,
status="reviewing",
)
@@ -1007,10 +1063,12 @@ def reconcile_pending_withdraws(db: Session, *, older_than_minutes: int = 15) ->
def list_withdraw_orders(
db: Session, user_id: int, *, limit: int = 20, cursor: int | None = None
db: Session, user_id: int, *, source: str | None = None, limit: int = 20, cursor: int | None = None
) -> tuple[list[WithdrawOrder], int | None]:
"""提现单分页(按 id 倒序,游标式)。"""
"""提现单分页(按 id 倒序,游标式)。source 非空时只返回该账户来源的单(coin_cash / invite_cash)。"""
stmt = select(WithdrawOrder).where(WithdrawOrder.user_id == user_id)
if source is not None:
stmt = stmt.where(WithdrawOrder.source == source)
if cursor is not None:
stmt = stmt.where(WithdrawOrder.id < cursor)
stmt = stmt.order_by(WithdrawOrder.id.desc()).limit(limit)
+16
View File
@@ -57,6 +57,12 @@ class EcpmReportIn(BaseModel):
)
adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle")
slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)")
feed_scene: str | None = Field(
None,
max_length=16,
description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);"
"比价与领券共用同一 Draw 代码位,需客户端在各调用点显式标注,供收益报表区分比价/领券;激励视频为空",
)
app_env: str | None = Field(
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
)
@@ -128,6 +134,11 @@ class FeedRewardIn(BaseModel):
"""
client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id")
ad_type: str = Field(
"feed",
max_length=16,
description="广告类型:feed(信息流) / draw(Draw 信息流);默认 feed 兼容旧客户端",
)
ad_session_id: str | None = Field(
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
)
@@ -156,6 +167,11 @@ class FeedRewardIn(BaseModel):
aborted: bool = Field(
False, description="用户中途 ✕ 关闭广告(未走完比价):整场不发,记 closed_early"
)
display_coin: int = Field(
0, ge=0,
description="客户端金币小球**本条显示**的金币(所见即所得):后端直接发这个数,钳到本条最大 1 份"
"满额防刷。缺省 0 = 旧客户端不传,退回服务端「看满 10 秒发整份」",
)
class FeedRewardOut(BaseModel):
+42
View File
@@ -0,0 +1,42 @@
"""客户端埋点上报 schema(批量)。
客户端把设备固定维度(device_id / user_id / oem / os / model / app_ver / channel / sent_at)
放批次外层只传一次,events 列表里每条只带事件维度(event / client_ts / session_id / page /
network / props);服务端展开成多行 AnalyticsEvent 落库( app/repositories/analytics.py)
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class AnalyticsEventIn(BaseModel):
"""单条事件维度。"""
event: str = Field(max_length=64)
client_ts: int = Field(description="端事件发生时间 epoch ms")
session_id: str | None = Field(default=None, max_length=64)
page: str | None = Field(default=None, max_length=64)
network: str | None = Field(default=None, max_length=16)
props: dict[str, str] = Field(default_factory=dict)
class AnalyticsBatchIn(BaseModel):
"""一批上报:公共维度 + 事件列表。"""
# Who(整批共享)
device_id: str = Field(max_length=64)
user_id: int | None = None
# When(本批上报时刻 epoch ms)
sent_at: int | None = None
# How(设备固定维度,整批共享)
oem: str | None = Field(default=None, max_length=32)
os: str | None = Field(default=None, max_length=32)
model: str | None = Field(default=None, max_length=64)
app_ver: str | None = Field(default=None, max_length=32)
channel: str | None = Field(default=None, max_length=32)
events: list[AnalyticsEventIn] = Field(min_length=1, max_length=200)
class AnalyticsIngestOut(BaseModel):
ok: bool = True
received: int
+4
View File
@@ -71,6 +71,10 @@ class JverifyLoginRequest(BaseModel):
class SmsSendRequest(BaseModel):
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
device_id: str = Field(
"", max_length=64,
description="硬件级设备标识(Android ANDROID_ID),用于发码防刷按 设备+IP 限流;空=按 IP 聚一桶",
)
class SmsSendResponse(BaseModel):
+31 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
# ===== 上报请求 =====
@@ -21,8 +21,36 @@ class ComparisonItemIn(BaseModel):
name: str
qty: int = 1
# specs 仅供比价记录展示(admin-web 详情 / app 记录页都按字符串数组渲染并 join)。
# pricebot(2026-06-27 嵌套规格统一)起 calibration 的 specs 可能是规格对象
# [{name, qty, sub_specs}] 而非字符串 → 下面的 before-validator 统一拍平成可读字符串,
# 兼容新旧两种输入、保持 list[str] 契约不变(下游零改动)。
# ⚠️ 两个坑都踩过, 必须"拍平"而非别的: ① 直接声明 list[str] 不拍平 → 对象 specs 整条
# 422 被拒、不入库(同下方 platform_results list→dict 同类事故); ② 仅放宽成裸 list 又会让
# 下游 join 出 "[object Object]"/对象 toString 的乱码。
specs: list[str] | None = None
@field_validator("specs", mode="before")
@classmethod
def _flatten_specs(cls, v: object) -> object:
"""pricebot 规格对象 [{name, qty, sub_specs}] → 可读字符串数组; 字符串元素原样保留;
list 原样交还( pydantic 照常报类型错)嵌套规格拼成 '主项(子1,子2)'"""
if not isinstance(v, list):
return v
out: list[str] = []
for it in v:
if isinstance(it, str):
s = it
elif isinstance(it, dict):
name = str(it.get("name") or "").strip()
subs = [str(x).strip() for x in (it.get("sub_specs") or []) if str(x).strip()]
s = f"{name}({','.join(subs)})" if name and subs else (name or ",".join(subs))
else:
continue
if s:
out.append(s)
return out
class AppliedCouponIn(BaseModel):
"""单笔已用优惠(来自 comparison_results[].applied_coupons)。amount 单位:元、正数。"""
@@ -95,6 +123,7 @@ class ComparisonRecordIn(BaseModel):
# pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘
# 时分秒前端拼不出,必须由后端透传)。
trace_url: str | None = Field(None, description="本次比价公网调试链接")
total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)")
# ===== debug 维度(客户端采集上报;旧客户端不带 → None。仅 admin 比价记录页用)=====
# 必须显式声明,否则 model_dump() 落 raw_payload 时被 pydantic 静默丢弃(同上面 coupon_saved 的坑)。
@@ -144,6 +173,7 @@ class ComparisonRecordOut(BaseModel):
items: list = []
comparison_results: list = []
skipped_dish_names: list = []
total_ms: int | None = None
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
# 由 list_records 动态算出挂在 ORM 实例上(非 DB 列),from_attributes 读出;缺省 False。
ordered: bool = False
+25
View File
@@ -49,3 +49,28 @@ class CouponStatsOut(BaseModel):
"""
coupon_count: int
class CouponSessionIn(BaseModel):
"""客户端领券流水上报体(admin「领券数据」看板数据源,POST /api/v1/coupon/session)。
一次领券两段上报, trace_id upsert coupon_session:
- 发起(status=started):带勾选平台 + 机型/ROM/app_env + started_at_ms(发起墙钟毫秒)
- 收尾(completed/failed/abandoned): elapsed_ms(全程耗时)+ platform_elapsed(各平台耗时)+ claimed_count
不鉴权(同领券循环 MVP, device_id/trace_id),user_id 登录态带上做留痕(可空)
"""
trace_id: str
device_id: str
status: str # started / completed / failed / abandoned
started_at_ms: int # 发起墙钟毫秒(客户端 System.currentTimeMillis)
user_id: int | None = None
platforms: list[str] | None = None
origin_package: str | None = None
device_model: str | None = None
rom: str | None = None
app_env: str | None = None
elapsed_ms: int | None = None
platform_elapsed: dict[str, int] | None = None
claimed_count: int | None = None
trace_url: str | None = None
+6 -1
View File
@@ -10,7 +10,12 @@ class InviteInfoOut(BaseModel):
invite_code: str # 我的邀请码
share_url: str # 落地页链接(含 ?ref=),前端据此生成二维码 + 复制分享
invited_count: int # 已成功邀请人数
coins_earned: int # 累计从邀请获得的金币
coins_earned: int # 累计从邀请获得的金币(v1 口径;v2 邀请人改发奖励金)
reward_balance_cents: int = 0 # v2 可提现邀请奖励金(分)
reward_withdrawn_cents: int = 0 # v2 累计提现成功的邀请奖励金(分)
countdown_days_left: int = 7 # v2 本轮剩余天数(7 天 1 轮)
countdown_is_fresh_round: bool = False # 是否刚进入新一轮(非首轮第1天)
countdown_text: str = "" # 倒计时展示文案(前端直接显示,新轮含换行)
class LandingTrackIn(BaseModel):
+20 -1
View File
@@ -1,7 +1,9 @@
"""启动确认窗兜底样本的内部上报模型(pricebot → app-server)。"""
from __future__ import annotations
from pydantic import BaseModel
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class LaunchConfirmSampleIn(BaseModel):
@@ -24,3 +26,20 @@ class LaunchConfirmSampleOut(BaseModel):
"""落库结果。"""
id: int
class LaunchConfirmSampleRow(BaseModel):
"""单条样本(列表读出;沉淀脚本 distill 用,payload 原样带出)。"""
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
trace_id: str | None = None
device_id: str | None = None
host_package: str | None = None
target_app: str | None = None
system_locale: str | None = None
exec_success: bool = False
dialog_title: str | None = None
payload: dict | None = None
+2 -2
View File
@@ -38,8 +38,8 @@ class AdConfigPublicOut(BaseModel):
app_id: str # 穿山甲应用ID(改了客户端需冷启才生效,SDK init 一次性读)
reward_code_id: str # 福利页激励视频位
compare_feed_code_id: str # 比价信息流
coupon_feed_code_id: str # 领券信息流位
compare_draw_code_id: str # 比价 Draw 代码
coupon_draw_code_id: str # 领券 Draw 代码位(比价/领券共用同一位,靠 feed_scene 区分收益)
reward_enabled: bool # 福利激励视频开关
compare_ad_enabled: bool # 比价广告开关
coupon_ad_enabled: bool # 领券广告开关
+3
View File
@@ -16,6 +16,7 @@ class CoinAccountOut(BaseModel):
coin_balance: int = Field(..., description="当前金币余额")
cash_balance_cents: int = Field(..., description="当前现金余额(分)")
invite_cash_balance_cents: int = Field(0, description="邀请奖励金余额(分,与现金隔离)")
total_coin_earned: int = Field(..., description="累计赚取金币")
@@ -123,6 +124,7 @@ class UnbindWechatResultOut(BaseModel):
class WithdrawRequest(BaseModel):
amount_cents: int = Field(..., gt=0, description="提现金额(分)")
source: str = Field("coin_cash", description="提现账户:coin_cash(金币现金) / invite_cash(邀请奖励金)")
user_name: str | None = Field(None, description="实名(达额时微信要求,可空)")
out_bill_no: str | None = Field(
None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成"
@@ -166,6 +168,7 @@ class WithdrawOrderOut(BaseModel):
id: int
out_bill_no: str
amount_cents: int
source: str = Field("coin_cash", description="提现账户:coin_cash / invite_cash")
status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected")
wechat_state: str | None = None
fail_reason: str | None = None
Binary file not shown.

After

Width:  |  Height:  |  Size: 847 KiB

+233 -117
View File
@@ -8,122 +8,231 @@
<style>
* { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
html,body { height:100%; }
button { border:0; background:none; color:inherit; font:inherit; cursor:pointer; }
body {
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
background:linear-gradient(165deg,#FF7A3D 0%,#FF3B30 52%,#E0245E 100%);
color:#fff; min-height:100%; display:flex; flex-direction:column;
align-items:center; justify-content:center; padding:40px 26px; text-align:center;
overflow-x:hidden;
min-height:100vh;
display:flex; align-items:center; justify-content:center;
background:#000;
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",sans-serif;
}
.logo {
width:104px; height:104px; border-radius:26px; background:#fff;
display:flex; align-items:center; justify-content:center; font-size:52px;
box-shadow:0 14px 34px rgba(0,0,0,.22); margin-bottom:24px;
/* 设备框:桌面预览成 375×667 卡片;真机(≤430)铺满全屏 */
.device {
position:relative; flex:0 0 auto;
width:375px; height:667px;
overflow:hidden; border-radius:32px;
background:#FFF4CD;
box-shadow:0 20px 60px rgba(0,0,0,.5);
color:#fff;
}
h1 { font-size:30px; font-weight:800; letter-spacing:1px; }
.slogan { margin-top:12px; font-size:16px; line-height:1.7; opacity:.95; max-width:300px; }
.feats { margin-top:26px; display:flex; flex-direction:column; gap:12px; width:100%; max-width:320px; }
.feat { background:rgba(255,255,255,.16); border-radius:14px; padding:13px 16px; font-size:15px; display:flex; align-items:center; gap:10px; }
.feat b { font-weight:700; }
.btn {
margin-top:34px; width:100%; max-width:320px; border:none; cursor:pointer;
background:#fff; color:#FF3B30; font-size:19px; font-weight:800;
padding:17px 0; border-radius:999px; box-shadow:0 10px 26px rgba(0,0,0,.22);
display:flex; align-items:center; justify-content:center; gap:9px;
.download-landing {
position:absolute; inset:0; z-index:0; overflow:hidden;
background:
url('coupon-page-bg.png') center center / cover no-repeat,
#FFF4CD;
color:#1A1A1A; text-align:center;
}
.download-content {
position:relative; z-index:1; height:100%;
padding:82px 20px 0;
display:flex; flex-direction:column; align-items:center; overflow:hidden;
}
.brand-lockup {
display:flex; align-items:center; justify-content:center; gap:16px;
}
.ad-logo {
width:53px; height:53px; border-radius:13px; display:block;
box-shadow:0 10px 24px rgba(255,179,0,.24);
}
.ad-title {
color:#1A1A1A; font-size:32px; font-weight:800; line-height:1.15;
letter-spacing:0; white-space:nowrap;
}
.ad-subtitle {
margin-top:22px; max-width:100%;
color:#000; font-size:18px; font-weight:400; line-height:1.25;
display:flex; align-items:center; justify-content:center; gap:10px; white-space:nowrap;
}
.ad-subtitle::before, .ad-subtitle::after {
content:""; width:5px; height:5px; border-radius:50%; background:#000; flex:0 0 auto;
}
.bottom-area {
position:relative; z-index:2; width:100%; max-width:266px;
margin-top:11px;
display:grid; grid-template-columns:1fr; justify-items:stretch; align-content:center; gap:10px;
}
.download-btn {
width:100%; height:39px; border-radius:22px; color:#1A1A1A;
font-family:inherit; font-size:14px; line-height:1; font-weight:700; letter-spacing:0;
display:flex; align-items:center; justify-content:center;
}
.download-btn.primary {
background:linear-gradient(180deg,#FFE066 0%,#FFC400 100%);
box-shadow:inset 0 1px 0 rgba(255,255,255,.82), 0 7px 18px rgba(255,179,0,.24);
}
.download-btn.secondary {
background:#fff; border:1px solid #DDD; color:#1A1A1A; font-size:13px;
box-shadow:0 2px 8px rgba(122,79,0,.08);
}
.download-btn:active { transform:translateY(1px); }
/* 底部两条卖点文案,压在背景插画两张卡片下方 */
/* 卖点卡:CSS 实体卡片(白底+图标),不再靠底图死框,字自适应(对齐 WeChat.html PR siyi 改版)*/
.download-feature-card {
position:absolute; z-index:2; pointer-events:none;
height:62px; padding:0 10px; border-radius:22px;
background:#FFFAEE;
box-shadow:inset 0 1px 0 rgba(255,255,255,.86), 0 6px 14px rgba(122,79,0,.08);
display:flex; align-items:center; gap:8px; color:#1A1A1A;
}
.download-feature-card.left { left:24px; top:576px; width:146px; }
.download-feature-card.right { left:199px; top:576px; width:156px; }
.download-feature-icon {
width:27px; height:27px; flex:0 0 auto; display:block; color:#FFAE00;
}
.download-feature-icon svg {
display:block; width:100%; height:100%; filter:drop-shadow(0 1px 0 rgba(255,255,255,.7));
}
.download-feature-copy {
min-width:0; flex:1 1 auto; text-align:left; white-space:nowrap; letter-spacing:0;
}
.download-feature-title {
display:block; font-size:13px; font-weight:800; line-height:1.12; letter-spacing:0;
}
.download-feature-desc {
display:block; margin-top:5px; color:#5A3A00; font-size:10px; font-weight:400; line-height:1.1;
}
.btn:active { transform:translateY(1px); opacity:.92; }
.hint { margin-top:16px; font-size:13px; opacity:.85; }
.foot { margin-top:30px; font-size:12px; opacity:.6; line-height:1.6; max-width:320px; }
/* 微信内"去浏览器打开"引导蒙层 */
#wxmask {
display:none; position:fixed; inset:0; z-index:9999;
background:rgba(0,0,0,.86); padding:18px;
.download-guide-layer {
position:absolute; inset:0; z-index:30; display:none;
background:rgba(0,0,0,.85); color:#fff; /* 半透明黑:透出底层(新版)下载页,隐约可见 */
}
.download-guide-layer.show { display:block; }
.download-guide-arrow {
position:absolute; top:17px; right:9px; width:80px; height:60px;
}
.download-guide-arrow-svg {
display:block; width:100%; height:100%; overflow:hidden; shape-rendering:geometricPrecision;
}
.download-guide-title {
position:absolute; top:129px; right:16px; width:218px; margin:0;
color:#fff; font-size:17px; font-weight:800; line-height:1.32; letter-spacing:0;
text-align:right; text-shadow:0 2px 8px rgba(0,0,0,.36);
}
.download-guide-title .guide-dots { color:#FFD95A; letter-spacing:4px; }
.download-guide-title .guide-highlight { color:#FFD95A; white-space:nowrap; }
.download-guide-title .guide-final {
display:block; margin-top:10px; color:rgba(255,255,255,.94);
font-size:13px; font-weight:700; line-height:1.38;
}
.download-guide-title .guide-target { color:#FFD95A; white-space:nowrap; }
.guide-dismiss {
position:absolute; bottom:30px; left:0; right:0; text-align:center;
font-size:14px; color:#fff; opacity:.75;
}
.toast {
position:absolute; left:50%; bottom:118px; z-index:20;
transform:translateX(-50%) translateY(12px);
padding:9px 14px; border-radius:12px; background:rgba(0,0,0,.78);
color:#fff; font-size:14px; opacity:0; pointer-events:none;
transition:opacity .2s ease, transform .2s ease;
}
.toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
@media (max-width:430px) {
/* 真机:宽满屏、高按 375:667 锁比例(不拉伸变形),顶对齐、底部留白用底色填。
这样底图(含价格卡)与卖点卡片同处 667 坐标基准,卡片用回 top:576,不再相互错位/遮挡。 */
body { align-items:flex-start; background:#FFF4CD; }
.device { width:100vw; height:calc(100vw * 667 / 375); border-radius:0; box-shadow:none; }
}
#wxmask.show { display:block; }
.arrow { position:absolute; top:8px; right:14px; width:120px; }
.wxtip { position:absolute; top:150px; right:18px; left:18px; text-align:right; }
.wxtip .big { font-size:21px; font-weight:800; line-height:1.5; }
.wxtip .big em { color:#FFD24D; font-style:normal; }
.wxtip .sub { margin-top:14px; font-size:15px; line-height:1.8; opacity:.9; }
.wxsteps { margin-top:26px; text-align:left; background:rgba(255,255,255,.1); border-radius:14px; padding:18px 18px; font-size:15px; line-height:2; }
.wxsteps .n { display:inline-block; width:22px; height:22px; line-height:22px; text-align:center; border-radius:50%; background:#FFD24D; color:#333; font-weight:800; font-size:13px; margin-right:8px; }
.closebar { position:absolute; bottom:30px; left:0; right:0; text-align:center; font-size:14px; opacity:.7; }
</style>
</head>
<body>
<div class="logo">🛒</div>
<h1>傻瓜比价</h1>
<div class="slogan">买什么都先比一比<br>自动帮你找全网最低价</div>
<div class="feats">
<div class="feat">🍔 <span>点外卖前一键比价,<b>美团/京东/淘宝</b>到手价一目了然</span></div>
<div class="feat">🎟️ <span>自动领遍各平台<b>红包券</b>,能省的一分不漏</span></div>
<div class="feat">💰 <span>省下的钱看得见,还能<b>赚金币提现</b></span></div>
</div>
<button class="btn" id="dlbtn">🏪 打开应用商店下载</button>
<div class="hint" id="hint">Android 安卓版 · 应用商店安全下载</div>
<div class="foot">
将前往应用商店下载,安全放心。<br>
本页为内部测试页。
</div>
<!-- 微信内引导:跳出微信去浏览器 -->
<div id="wxmask">
<svg class="arrow" viewBox="0 0 120 130" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M30 120 C 30 70, 55 40, 95 28" stroke="#FFD24D" stroke-width="6" stroke-linecap="round" fill="none" stroke-dasharray="2 13"/>
<path d="M95 28 L 78 30 M95 28 L 92 46" stroke="#FFD24D" stroke-width="6" stroke-linecap="round"/>
</svg>
<div class="wxtip">
<div class="big">点击右上角 <em>···</em><br>选择「<em>在浏览器打开</em></div>
<div class="sub">微信里无法直接下载安装包<br>需在系统浏览器中完成下载</div>
<div class="wxsteps">
<div><span class="n">1</span>点右上角的 ··· 菜单</div>
<div><span class="n">2</span>选择「在浏览器打开」</div>
<div><span class="n">3</span>在浏览器里按提示去应用商店下载</div>
<main class="device" aria-label="傻瓜比价下载页">
<section class="download-landing" aria-label="傻瓜比价下载页">
<div class="download-content">
<div class="brand-lockup">
<img class="ad-logo" src="sb-brand.png" alt="傻瓜比价">
<h1 class="ad-title">傻瓜比价</h1>
</div>
<p class="ad-subtitle">跨平台比价,用傻瓜</p>
<div class="bottom-area" aria-label="下载入口">
<button class="download-btn primary" id="dlbtn" type="button">应用商店下载</button>
<button class="download-btn secondary" id="dlbtn2" type="button">官网下载</button>
</div>
</div>
<div class="closebar" id="wxclose">我知道了 ✕</div>
<div class="download-feature-card left" aria-label="优惠券轻松领,羊毛全都不错过">
<span class="download-feature-icon" aria-hidden="true">
<svg viewBox="0 0 40 32" focusable="false">
<path d="M5 5h30a3 3 0 0 1 3 3v5.2a4.8 4.8 0 0 0 0 9.6V24a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3v-1.2a4.8 4.8 0 0 0 0-9.6V8a3 3 0 0 1 3-3Z" fill="currentColor"/>
<path d="M20 10v12" fill="none" stroke="#FFF7CF" stroke-width="3" stroke-linecap="round"/>
</svg>
</span>
<span class="download-feature-copy">
<span class="download-feature-title">优惠券轻松领</span>
<span class="download-feature-desc">羊毛全都不错过</span>
</span>
</div>
<div class="download-feature-card right" aria-label="一键全网找底价,再也不用费力切屏">
<span class="download-feature-icon" aria-hidden="true">
<svg viewBox="0 0 40 32" focusable="false">
<rect x="5" y="16" width="8" height="11" rx="2" fill="currentColor"/>
<rect x="16" y="9" width="8" height="18" rx="2" fill="currentColor"/>
<rect x="27" y="3" width="8" height="24" rx="2" fill="currentColor"/>
</svg>
</span>
<span class="download-feature-copy">
<span class="download-feature-title">一键全网找底价</span>
<span class="download-feature-desc">再也不用费力切屏</span>
</span>
</div>
</section>
<!-- 微信内引导:跳出微信去浏览器 -->
<div class="download-guide-layer" id="wxGuide" role="dialog" aria-modal="true" aria-labelledby="wxGuideTitle">
<div class="download-guide-arrow" aria-hidden="true">
<svg class="download-guide-arrow-svg" viewBox="0 0 80 60" focusable="false">
<path d="M0 60 C16 32 39 15 66 15" fill="none" stroke="#FFD95A" stroke-width="5.5" stroke-linecap="round"/>
<path d="M59 5 L77 14 L63 31" fill="none" stroke="#FFD95A" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h2 class="download-guide-title" id="wxGuideTitle">
点击右上角 <span class="guide-dots">···</span><br>
选择「<span class="guide-highlight">在浏览器打开</span>
<span class="guide-final">在浏览器里按提示<span class="guide-target">去应用商店下载</span></span>
</h2>
<!-- <div class="guide-dismiss" id="wxGuideDismiss">我知道了 ✕</div> -->
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
</main>
<script>
// 应用商店跳转:用包名(App 唯一标识 = 身份证号)定位到傻瓜比价的商店下载页
// 主:market:// 唤起手机自带应用市场(华为/小米/OV);兜底:应用宝网页(任何浏览器都能开)
// ===== 应用商店跳转用包名App 唯一标识定位到傻瓜比价的商店下载页 =====
// 主market:// 唤起手机自带应用市场华为/小米/OV);兜底应用宝网页任何浏览器都能开
var PKG = "com.jishisongfu.shaguabijia";
var MARKET_URL = "market://details?id=" + PKG;
var YYB_URL = "https://a.app.qq.com/o/simple.jsp?pkgname=" + PKG;
var ua = navigator.userAgent || "";
var isWeChat = /MicroMessenger/i.test(ua);
var isIOS = /iPhone|iPad|iPod/i.test(ua);
var isAndroid = /Android/i.test(ua);
var ref = new URLSearchParams(location.search).get("ref"); // 邀请码(来自二维码 URL ?ref=)
var ref = new URLSearchParams(location.search).get("ref"); // 邀请码(来自二维码 URL ?ref=)
// 【任务 3】指纹归因兜底:页面加载即上报访问者指纹,后端存 invite_fingerprint 表
// 当 APK 首启读剪贴板失败(被覆盖)时,客户端用 (IP+屏幕+UA 解析的手机型) 反查
// 本表 7 天内最近一条匹配 → 撞库出原邀请人 → 走原 bind 流程。
// 任何失败都 silent(不影响下载主流程);后端 invalid_code/no_ip 也只返 200。
// ===== 指纹归因兜底页面加载即上报访问者指纹后端存 invite_fingerprint 表 =====
// 当 APK 首启读剪贴板失败被覆盖)时,客户端用 (IP+屏幕+UA 机型) 反查 7 天内最近一条 → 撞出原邀请人。
// 任何失败都 silent,不影响下载主流程。
if (ref) {
// screen 报【物理像素】= CSS 像素 × devicePixelRatio,跟 Android dm.widthPixels(物理像素)对齐
// 不同设备 DPR 不同(常见 2/2.5/3/3.5),CSS 像素直接报会跟客户端不对齐 → 撞不上库。
// screen 报【物理像素】= CSS 像素 × devicePixelRatio跟 Android dm.widthPixels 对齐,否则撞不上库
var _dpr = window.devicePixelRatio || 1;
var _sw = Math.round(screen.width * _dpr);
var _sh = Math.round(screen.height * _dpr);
fetch("/api/v1/invite/landing-track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ref: ref,
screen: _sw + "x" + _sh,
// IP / UA 服务端从 HTTP 头自动拿,无需 JS 上报
}),
}).catch(function () {}); // silent,绝不阻断下载
body: JSON.stringify({ ref: ref, screen: _sw + "x" + _sh }),
}).catch(function () {}); // silent,绝不阻断下载
}
// 把邀请码写进剪贴板,APK 首启读出完成归因(deferred deeplink 的关键一步)。
// 浏览器要求:必须在用户点击手势里调用 + HTTPS 下才允许写。
function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底
// ===== 把邀请码写进剪贴板APK 首启读出完成归因deferred deeplink=====
// 浏览器要求必须在用户点击手势里调用 + HTTPS 下才允许写。
function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底
try {
var ta = document.createElement("textarea");
ta.value = payload; ta.style.position = "fixed"; ta.style.top = "-1000px"; ta.style.opacity = "0";
@@ -131,50 +240,57 @@
document.execCommand("copy"); document.body.removeChild(ta);
} catch (e) {}
}
function copyInviteCode() { // 返回 Promise(完成后才下载,避免异步写入被打断)
function copyInviteCode() { // 返回 Promise完成后才下载避免异步写入被打断
if (!ref) return Promise.resolve();
var payload = "SGBJ_INVITE:" + ref;
legacyCopy(payload); // 同步兜底:在用户手势内立刻 execCommand 写一次(最可靠)
legacyCopy(payload); // 同步兜底在用户手势内立刻 execCommand 写一次最可靠
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(payload).catch(function () {}); // 现代 API 锦上添花,失败无妨
return navigator.clipboard.writeText(payload).catch(function () {});
}
return Promise.resolve();
}
// 跳应用商店:先尝试 market:// 唤起手机自带商店;若 2.5s 内页面没切后台
//(= 没有商店接管 market://),兜底跳应用宝网页下载页,不让用户卡死。
// 跳应用商店:先 market:// 唤起自带商店2.5s 内页面没切后台 → 兜底跳应用宝网页,不让用户卡死。
function openStore() {
var jumped = false;
function markJumped() { jumped = true; } // 页面切到后台 = 商店已唤起
document.addEventListener("visibilitychange", function () {
if (document.hidden) jumped = true;
});
window.addEventListener("pagehide", markJumped);
window.addEventListener("blur", markJumped);
window.location.href = MARKET_URL; // 唤起自带应用市场
setTimeout(function () {
if (!jumped) window.location.href = YYB_URL; // 没唤起 → 应用宝网页兜底
}, 2500);
document.addEventListener("visibilitychange", function () { if (document.hidden) jumped = true; });
window.addEventListener("pagehide", function () { jumped = true; });
window.addEventListener("blur", function () { jumped = true; });
window.location.href = MARKET_URL;
setTimeout(function () { if (!jumped) window.location.href = YYB_URL; }, 2500);
}
var hint = document.getElementById("hint");
if (isIOS) hint.textContent = "检测到 iPhone · iOS 版请前往 App Store";
// ===== 微信内引导蒙层 =====
var wxGuide = document.getElementById("wxGuide");
function showWxGuide() { wxGuide.classList.add("show"); }
function hideWxGuide() { wxGuide.classList.remove("show"); }
// document.getElementById("wxGuideDismiss").addEventListener("click", hideWxGuide); // 「我知道了 ✕」已注释隐藏
if (isWeChat) showWxGuide(); // 微信里一进页面就提示去浏览器(微信内下载必被拦)
var mask = document.getElementById("wxmask");
function showMask(){ mask.classList.add("show"); }
function hideMask(){ mask.classList.remove("show"); }
document.getElementById("wxclose").addEventListener("click", hideMask);
function showToast(text) {
var toast = document.getElementById("toast");
toast.textContent = text; toast.classList.add("show");
clearTimeout(showToast.timer);
showToast.timer = setTimeout(function () { toast.classList.remove("show"); }, 1400);
}
// 微信里一进页面就提示去浏览器打开(下载在微信内必被拦)
if (isWeChat) showMask();
document.getElementById("dlbtn").addEventListener("click", function(){
if (isWeChat) { showMask(); return; } // 微信内:引导去浏览器
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
// 安卓浏览器:先把邀请码写进剪贴板(legacyCopy 同步写、最可靠),再跳应用商店。
// 剪贴板供 App 首启归因;链路长易丢时由指纹兜底(已上报 landing-track)接住。
copyInviteCode();
// ===== 应用商店下载:微信内引导去浏览器;iOS 提示;安卓写邀请码 + 跳应用商店 =====
function handleDownload() {
if (isWeChat) { showWxGuide(); return; }
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
copyInviteCode(); // 先把邀请码写进剪贴板(供 App 首启归因),链路丢了还有 landing-track 指纹兜底
openStore();
});
}
// ===== 官网下载:微信/iOS 同上引导;安卓直接跳 APK 直链 → 弹系统下载弹窗 =====
// APK_URL 跟随页面 host:本地走 LAN、生产走 app-api.shaguabijia.com(见邀请功能文档约定)。
var APK_URL = location.origin + "/media/shaguabijia.apk";
function handleWebsiteDownload() {
if (isWeChat) { showWxGuide(); return; }
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
copyInviteCode(); // 同样先写邀请码进剪贴板(供 App 首启归因)
window.location.href = APK_URL;
}
document.getElementById("dlbtn").addEventListener("click", handleDownload);
document.getElementById("dlbtn2").addEventListener("click", handleWebsiteDownload);
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+67
View File
@@ -0,0 +1,67 @@
# 穿山甲 GroMore 收益拉取 定时任务 — 运维手册
> 对象:维护「每天拉穿山甲后台收益入库」这套定时任务的同事。
> 🔒 服务器登录信息见**私密交接清单**,不入库。
## 它是什么
admin「广告收益报表」里的「穿山甲后台收益(T+1)」读的是**本地表 `ad_pangle_daily_revenue` 的快照,不是实时查穿山甲**。穿山甲只通过 GroMore 数据 API 给数、且 **T+1**(次日约 10:00 出昨天的数),所以每天得拉一次入库,报表才会往前走。
- 每天 10:30 跑一轮 `scripts/sync_pangle_revenue.py`,默认 `--days 3` 回补近 3 天。
- 维度 = 日期 × 应用(site_id)× 广告位(ad_unit_id);指标 = `revenue`(预估)+ `api_revenue`(结算口径)。
- **幂等 upsert**:同一(日期×应用×代码位)重跑只覆盖、不重复,故回补 / 重跑 / catch-up 都安全。
- 穿山甲无用户/设备维度 → 只能落「汇总/趋势级」,报表带 user_id 过滤时这块收益置空(显示「-」)。
## 文件
| 项 | 路径 |
|---|---|
| 脚本入口 | `scripts/sync_pangle_revenue.py` |
| 拉取 / 签名 | `app/integrations/pangle_report.py`(签名=参数字典序拼接+secure_key 后 MD5) |
| 入库表 | `ad_pangle_daily_revenue`(读写在 `app/repositories/ad_pangle_revenue.py`) |
| 凭证 | `.env``PANGLE_REPORT_USER_ID` / `PANGLE_REPORT_ROLE_ID` / `PANGLE_REPORT_SECURITY_KEY` |
| 应用映射 | `.env``PANGLE_REPORT_SITE_ID_PROD`(5830519)/ `PANGLE_REPORT_SITE_ID_TEST`(5832303) |
| systemd 单元 | `deploy/pangle-revenue.{service,timer}` |
## 上线前置(只做一次)
1. **填凭证**:后台「接入中心 → GroMore-API」领 user_id / role_id / Security Key(`secure_key`,**≠ 发奖 m-key**),填进线上 `.env``PANGLE_REPORT_*`。三项填齐脚本才工作,缺任一 → no-op 退出。
2. **子账号要授权**:线上若用子账号(role_id ≠ user_id),需主账号给它授「查看全部数据」,否则接口报 **118**(无权限)。本人自查自己(user_id=role_id)无此问题。
3. 凭证泄露:后台「重置 key 值」即可,改完同步线上 `.env` 重跑。
## 部署(Linux 服务器,需 root)
```bash
sudo cp deploy/pangle-revenue.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now pangle-revenue.timer
systemctl list-timers pangle-revenue.timer # 确认下次触发时间(应是次日 10:30)
```
## 怎么看健康 / 手动跑一次
```bash
sudo systemctl start pangle-revenue.service # 立即手动跑一轮(不等 10:30)
journalctl -u pangle-revenue -n 30 --no-pager # 看日志:拉取区间 / 入库行数 / 新增更新 / 预估收益合计
```
成功日志形如:`✅ 完成:接口 N 行 → 入库 M 行(跳过 x),新增 a / 更新 b;预估收益合计 ¥19.42`
> 看不到收益、提示 `PANGLE_REPORT_* 未配置`→ 回「上线前置」补 `.env`;报 118 → 子账号没授「查看全部数据」。
## 本机 Windows 开发(无 systemd)
直接手动跑:
```
.venv\Scripts\python -m scripts.sync_pangle_revenue # 拉昨天
.venv\Scripts\python -m scripts.sync_pangle_revenue --days 3 # 回补近 3 天
.venv\Scripts\python -m scripts.sync_pangle_revenue --date 2026-06-27 # 指定单天
.venv\Scripts\python -m scripts.sync_pangle_revenue --start 2026-06-01 --end 2026-06-27 # 区间回补
```
## 脚本参数
- 无参:拉**昨天**(北京时间)。timer 用的是 `--days 3`
- `--days N`:从昨天起往前回补 N 天(含昨天)。
- `--date YYYY-MM-DD`:指定单天。
- `--start / --end`:指定闭区间(跨度 ≤ 31 天,接口上限 1 个月,超了报 114)。
## 注意事项
- **触发时间**:`OnCalendar=*-*-* 10:30:00`。穿山甲 ~10:00 出数,故别早于 10:00 跑(会拉到空/不全)。
- **catch-up**:`Persistent=true` 补跑错过的那一轮;叠加 `--days 3`,漏一两天重新触发即自愈。
- **今天 / 今天以前要分开查**:脚本默认只拉昨天及更早,不混查今天(接口约束),无需关心。
- **join key 是 `ad_unit_id`(我们配的 104xxx)不是 `code_id`**:`code_id` 是底层各 ADN 代码位,对不上口径;`ad_unit_id='-1'` 是未归因桶。改维度时务必注意(详见脚本头注释)。
- **`api_revenue` 很稀疏**:测试应用 ADN 没配 Reporting → 全 0,仅 prod 个别位有;`revenue`(预估)才是稳的主力。
- **DB 无关**:sqlite / postgres 均可(upsert 逐行 select-then-write,不像美团 ETL 需要 PG)。
- **别和别的触发方式双跑**:本 systemd timer 与「手动 cron / 进程内任务」二选一,虽幂等不会重复入库,纯属多余。
- **改脚本 / 改部署**:走 git + PR,由有 root 的人部署。
+38
View File
@@ -0,0 +1,38 @@
# 每天拉穿山甲 GroMore T+1 天级收益入库 —— 单轮跑,由 pangle-revenue.timer 每天 10:30 触发。
# 落 ad_pangle_daily_revenue 表,供 admin 广告收益报表的「穿山甲后台收益(T+1)」区块。
#
# 仅用于 Linux 服务器;本机 Windows 开发无 systemd,直接手动跑脚本即可:
# .venv\Scripts\python -m scripts.sync_pangle_revenue # 拉昨天(北京时间)
# .venv\Scripts\python -m scripts.sync_pangle_revenue --days 3 # 回补近 3 天
#
# 部署(服务器):
# sudo cp deploy/pangle-revenue.{service,timer} /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now pangle-revenue.timer
# # 手动跑一次验证: sudo systemctl start pangle-revenue.service && journalctl -u pangle-revenue -n 30
#
# 前置:.env 配好 PANGLE_REPORT_USER_ID / PANGLE_REPORT_ROLE_ID / PANGLE_REPORT_SECURITY_KEY
# (后台「接入中心 → GroMore-API」领;子账号需主账号授「查看全部数据」否则接口 118)。
# 未配齐这三项 → 脚本自动 no-op 退出,免动 timer。
[Unit]
Description=Sync Pangle GroMore daily revenue (T+1, one-shot, driven by timer)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
WorkingDirectory=/opt/shaguabijia-app-server
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
EnvironmentFile=/opt/shaguabijia-app-server/.env
# 默认拉昨天;--days 3 回补近 3 天(幂等 upsert,应对偶发漏跑 + 穿山甲对历史数据订正,重跑无害)。
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.sync_pangle_revenue --days 3
SyslogIdentifier=pangle-revenue
# 仅几个 HTTP 请求 + 小批量入库,通常数秒;给 10min 硬超时防穿山甲接口卡死。
TimeoutStartSec=600
# 与主服务 shaguabijia-app-server.service 同款加固。
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/shaguabijia-app-server
ProtectHome=true
+14
View File
@@ -0,0 +1,14 @@
# 每天 10:30 触发一次穿山甲 GroMore T+1 收益拉取入库(Linux 服务器用)。
# 见 pangle-revenue.service 顶部注释的部署步骤。
[Unit]
Description=Run Pangle GroMore daily revenue sync at 10:30
[Timer]
# 穿山甲 T+1、次日约 10:00 出数;10:30 触发留 30min 余量。要错开整点扎堆可微调到 10:35。
OnCalendar=*-*-* 10:30:00
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日);叠加 --days 3 回补,漏一两天能自愈。
Persistent=true
AccuracySec=1min
[Install]
WantedBy=timers.target
+35 -8
View File
@@ -27,8 +27,11 @@
| `date_to` | string | =`date_from` | 结束日 北京时间 `YYYY-MM-DD`,**闭区间**;单日时与 `date_from` 相同 |
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
| `ad_type` | string | 全部 | `reward_video` / `feed` / `draw`;不传=全部类型 |
| `feed_scene` | string | 全部 | `comparison`(比价)/ `coupon`(领券)/ `welfare`(福利);**全局筛选**,同时作用于明细 / 合计 / `daily`·`hourly` 趋势;不传=全部场景 |
| `granularity` | string | `day` | `day`=按天 / `hour`=按小时(聚合键再加北京时间小时 0–23);**区间>1 天建议用 day** |
| `limit` | int(1~1000) | 500 | **展示**明细组数(截断;`total`/`total_*`/`daily` 按全量统计不受影响) |
| `limit` | int(1~1000) | 500 | **每页条数**(分页大小);`total`/`total_*`/`daily`/`hourly` 按全量统计不受分页影响 |
| `offset` | int(≥0) | 0 | 分页偏移(已跳过条数)=(页码−1)×`limit` |
| `sort` | string | `time` | 明细排序:`time`=按时间倒序(新→旧) / `ecpm`=按 eCPM 数值倒序 |
约束:`date_to` 不早于 `date_from`、区间最长 **92 天**、日期须 `YYYY-MM-DD`,否则 `422`
@@ -36,25 +39,48 @@
| 字段 | 类型 | 说明 |
|---|---|---|
| `date_from` / `date_to` | string | 报表起止日期(闭区间) |
| `daily` | `AdRevenueDaily[]` | 按日期汇总序列(全量,供按天趋势图;不受 `limit` 影响) |
| `total` | int | 聚合组**总数**(全量,不受 `limit` 影响) |
| `truncated` | bool | 明细是否被 `limit` 截断 |
| `daily` | `AdRevenueDaily[]` | 按日期汇总序列(全量,供按天趋势图;不受分页影响) |
| `hourly` | `AdRevenueHourly[]` | 按小时汇总序列(全量,供按小时趋势图;**仅 `granularity=hour` 时非空**;不受分页影响) |
| `type_stats` | `{[ad_type]: AdRevenueTypeStat}` | 按广告类型(`ad_type`)小计(全量);前端取 `draw` / `reward_video` 做分类大盘 |
| `dau` | int \| null | 今日活跃用户数(复用大盘口径 `last_login_at`,今日登录过);**仅查询=今日单天时有值**,历史/多天为 `null` |
| `total` | int | 当前筛选下的**分页总条数**(全量,不受分页影响;= 前端分页器 total) |
| `truncated` | bool | 当前页之后是否还有更多事件(`len(events) > offset + limit`) |
| `total_impressions` | int | 全量展示条数合计 |
| `total_revenue_yuan` | float | 全量收益合计(元) |
| `total_revenue_yuan` | float | 全量**客户端预估**收益合计(元;eCPM 折算) |
| `total_pangle_revenue_yuan` | float \| null | 全量**穿山甲后台预估**收益合计(元;GroMore `revenue`)。穿山甲无用户/类型/场景维度,**仅全量视图**(未按 `user_id`/`ad_type`/`feed_scene` 过滤)有值,否则 `null` |
| `total_pangle_api_revenue_yuan` | float \| null | 全量**穿山甲收益Api**合计(元;GroMore `api_revenue`,各 ADN 回传更接近结算);未配 Reporting / 查当天 / 非全量视图为 `null` |
| `pangle_revenue_available` | bool | 本次是否带穿山甲后台收益(=全量视图且对应日期已同步到 `ad_pangle_daily_revenue`) |
| `total_expected_coin` | int | 全量应发金币合计 |
| `total_actual_coin` | int | 全量实发金币合计 |
| `mismatch_count` | int | 应发≠实发的组数(=0 说明全部按公式发放) |
| `items` | `AdRevenueRow[]` | 聚合明细(按 日期→用户→类型→代码位 排序) |
| `items` | `AdRevenueRow[]` | 逐条广告事件(**按时间倒序:新→旧**);`limit`/`offset` 对全量做分页切片,返回当前页 |
### AdRevenueDaily(`daily[]` — 按天趋势)
| 字段 | 类型 | 说明 |
|---|---|---|
| `date` | string | 北京时间 `YYYY-MM-DD` |
| `impressions` | int | 当天展示条数合计 |
| `revenue_yuan` | float | 当天预估收益合计(元) |
| `revenue_yuan` | float | 当天客户端预估收益合计(元;eCPM 折算) |
| `pangle_revenue_yuan` | float \| null | 当天穿山甲后台预估收益(元;GroMore `revenue`);非全量视图 / 无数据为 `null` |
| `pangle_api_revenue_yuan` | float \| null | 当天穿山甲收益Api(元;GroMore `api_revenue`);未配 / 当天 / 无数据为 `null` |
| `expected_coin` | int | 当天应发金币合计 |
| `actual_coin` | int | 当天实发金币合计 |
### AdRevenueHourly(`hourly[]` — 按小时趋势,仅 `granularity=hour` 时非空)
| 字段 | 类型 | 说明 |
|---|---|---|
| `hour` | int | 北京时间小时 023 |
| `impressions` | int | 该小时展示条数合计 |
| `revenue_yuan` | float | 该小时预估收益合计(元) |
| `expected_coin` | int | 该小时应发金币合计 |
| `actual_coin` | int | 该小时实发金币合计 |
### AdRevenueTypeStat(`type_stats[ad_type]` — 分广告类型小计,供大盘第二行)
| 字段 | 类型 | 说明 |
|---|---|---|
| `impressions` | int | 该类型展示条数合计 |
| `revenue_yuan` | float | 该类型预估收益合计(元);eCPM 由前端用 收益÷展示×1000 算 |
### AdRevenueRow(`items[]`)
| 字段 | 类型 | 说明 |
|---|---|---|
@@ -104,5 +130,6 @@
- **展示 vs 发奖分离**:信息流轮播一会话可展示多条(都计入 `impressions`),但发奖仍按现规则(一会话发一次),`coin` 不因展示条数变化——这是有意设计(用户中途关只记展示不发奖)。
- **历史 Draw 不可拆**:迁移(Draw→普通信息流)前,Draw 发奖混在 `ad_feed_reward_record` 且无类型标记,金币侧统一记 `feed`;迁移后 Draw 不再产生新数据。展示侧 `ad_type` 由客户端上报区分,故 `draw` 桶基本为空。
- **来源字段从上线起齐全**:`app_env`/`our_code_id` 是本期新增列,历史记录为 NULL(报表来源列留空)。
- **收益是预估**:基于客户端上报的 eCPM,非穿山甲后台结算值;以后台报表为结算权威
- **逐条/明细的收益是预估**:`items[].revenue_yuan` 基于客户端上报的 eCPM 折算,非穿山甲后台结算值。
- **穿山甲后台收益(汇总/趋势级)**:`total_pangle_revenue_yuan`(预估 `revenue`)与 `total_pangle_api_revenue_yuan`(收益Api `api_revenue`,更接近结算)来自穿山甲 **GroMore 数据 API**(`integrations/pangle_report` + `scripts/sync_pangle_revenue` 按天 T+1 拉取入 [ad_pangle_daily_revenue](../database/ad_pangle_daily_revenue.md))。穿山甲**不提供分用户/设备/类型/场景维度**(官方明确),最细到 日期×应用×代码位,故只用于汇总与按天趋势的对照,**不挂到逐条事件行**;且仅在全量视图(未按 user/类型/场景过滤)展示。配置见 `.env``PANGLE_REPORT_*`
- **对账聚合级 + 逐条下钻**:行级 `matched` 给出该组(用户×类型×应用×代码位)应发是否==实发;**展开 `records` 即可看该组逐条明细**(eCPM/因子1/份数/LT/因子2/应发/实发/一致)定位到具体记录。独立逐条审计接口 [admin-ad-coin-audit](./admin-ad-coin-audit.md) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。
+45
View File
@@ -0,0 +1,45 @@
# ad_pangle_daily_revenue — 穿山甲 GroMore 天级收益
> 模型:[app/models/ad_pangle_revenue.py](../../app/models/ad_pangle_revenue.py) | 读写:[app/repositories/ad_pangle_revenue.py](../../app/repositories/ad_pangle_revenue.py) | 拉取:[app/integrations/pangle_report.py](../../app/integrations/pangle_report.py) + [scripts/sync_pangle_revenue.py](../../scripts/sync_pangle_revenue.py)
存放从穿山甲 **GroMore 数据 API**(聚合数据报告 API,天级、T+1)按天拉取的收益,供 admin
[广告收益报表](../api/admin-ad-revenue-report.md) 的「穿山甲后台收益」做汇总/趋势级展示,
与客户端自报 eCPM 折算的预估互为对照(看 gap)。
**粒度 = 日期 × 应用(app_env) × 代码位(our_code_id) × 广告源(adn)**。穿山甲**不提供分用户/设备维度**
(官方明确),故本表无 `user_id`,也无法挂到逐条广告事件;报表逐条行仍用客户端预估。
## 数据流
- **写**:`scripts/sync_pangle_revenue`(线上每天 ~10:30 由 systemd timer 跑)调
`pangle_report.fetch_daily_report` 拉昨天(可 `--days N` 回补),维度 `date,site_id,ad_unit_id`,
映射 `site_id→app_env``ad_unit_id→our_code_id``upsert_daily_rows` 落库。按唯一键幂等,T+1 订正可重跑。
⚠️ 用 `ad_unit_id`(广告位ID=104xxx)而非 `code_id`:实测 `code_id` 返回底层各 ADN 代码位(如 `983674557`),对不上我们的口径。
- **读**:`ad_pangle_revenue.aggregate_by_date` 按日期汇总 → admin 报表
`total_pangle_revenue_yuan` / `total_pangle_api_revenue_yuan` / `daily[].pangle_*`
## 字段
| 列 | 类型 | 说明 |
|---|---|---|
| `id` | int PK | |
| `report_date` | str(10) idx | 北京时间 `YYYY-MM-DD`(拉取 `time_zone=8`),与 `ad_ecpm_record.report_date` 同口径可 join |
| `app_env` | str(16) | `prod`(傻瓜比价正式)/ `test`(测试);由 `site_id``settings.pangle_report_site_id_to_env` 映射;未映射记 `site:<id>` |
| `site_id` | str(32) \| null | 原始 GroMore AppId(留痕) |
| `our_code_id` | str(64) idx | 广告位 ID(= 接口 `ad_unit_id` = 客户端配的 104xxx = `ad_ecpm_record.our_code_id`),join key。⚠️ **非** `code_id`(那是底层各 ADN 代码位,对不上);`-1` 为 GroMore 未归因桶 |
| `adn` | str(16) | 广告源(接口 `network`);当前默认口径不分广告源(按 `ad_unit_id` 汇总),统一 `""` |
| `revenue_yuan` | float | 预估收益(元)← 接口 `revenue`(排序价×展示/1000,所有 ADN) |
| `api_revenue_yuan` | float \| null | 收益Api(元)← 接口 `api_revenue`(各 ADN 经 Reporting 回传、更接近结算);未配 / 当天为 `null` |
| `ecpm` | str(32) \| null | 预估 eCPM 原值(接口 `ecpm`,单位**元/千次**,与客户端 getEcpm 的「分」不同) |
| `impressions` | int | 展示次数 ← 接口 `imp_cnt` |
| `currency` | str(8) | 货币(接口 `currency`,正常 `cny`) |
| `synced_at` | datetime | 最近同步写入时间(可被回补覆盖) |
唯一约束 `uq_ad_pangle_daily (report_date, app_env, our_code_id, adn)`;`adn``""` 而非
NULL,避免 NULL 在唯一约束里互不相同导致 upsert 重复。
## 局限
- **T+1**:当天数据穿山甲不出 `api_revenue`,`revenue` 也次日才稳;报表「今天」此两列多为空。
- **`revenue` vs `api_revenue`**:前者所有 ADN 都有(预估口径);后者需后台为该 ADN 配置 Reporting
才回传,部分 ADN 可能长期为空。结算仍以各 ADN 结算单 / 穿山甲后台为准。
@@ -0,0 +1,820 @@
# 傻瓜比价 数据表字典(产品参考)
> 面向产品/运营的全量数据表说明,按业务域分组。来源:`app/models/` 下全部 ORM 模型(共 **40 张表**)。
> 字段名为数据库真实列名;本文档由模型源码整理,后端改表后以模型为准。
## 通用约定(先读)
1. **金额单位**:所有 `*_cents` 字段单位是「**分**」,展示时 ÷100 为「元」。金币(coin)是「个数」,另有自己的兑换比例。
2. **时间**:存储统一为带时区的 UTC;展示按北京时间。部分「按天聚合」字段直接存北京日期串 `'YYYY-MM-DD'` 或日期类型,避免跨时区比较。
3. **JSON 字段**:线上 PostgreSQL 用 JSONB、本地/测试 SQLite 用普通 JSON,业务无差别。
4. **device_id 有两种含义**(重要):
- **per-install**(存手机本地,卸载重装会变):领券、比价、设备存活用。
- **硬件级稳定**(ANDROID_ID,卸载重装不变):新手引导用。
5. **审计字段**:多数表有 `created_at`(创建时间)/ `updated_at`(更新时间),下文仅在有特殊含义时展开。
6. **约束标记**:说明里 **主键** / **唯一** / **外键→表** / **可空** 表示该列的关键约束。
---
## 表总览
| 业务域 | 表名 | 中文名 | 一句话用途 |
|---|---|---|---|
| 用户 | `user` | 用户 | App 用户主表 |
| 钱包 | `coin_account` | 金币账户 | 每用户余额快照(金币/现金) |
| 钱包 | `coin_transaction` | 金币流水 | 金币每笔变动账本 |
| 钱包 | `cash_transaction` | 现金流水 | 现金(分)每笔变动账本 |
| 钱包 | `withdraw_order` | 提现单 | 现金→微信零钱提现 |
| 钱包 | `wechat_transfer_authorization` | 微信转账免确认授权 | 用户授权后转账免逐笔确认 |
| 激励 | `signin_record` | 签到记录 | 每日签到 |
| 激励 | `signin_boost_record` | 签到膨胀记录 | 签到后看广告翻倍补发 |
| 激励 | `user_task` | 一次性任务完成 | 只能领一次的任务 |
| 激励 | `comparison_milestone_claim` | 比价战绩领取 | 比价次数里程碑奖励 |
| 比价 | `comparison_record` | 比价记录 | 用户视角「我的比价记录」 |
| 比价 | `savings_record` | 省钱记录 | 「累计帮你省了」数据源 |
| 比价 | `price_observation` | 价格观测 | 平台/门店价格事实沉淀(后端) |
| 比价 | `store_mapping` | 平台店铺映射 | 跨平台同店身份映射(后端) |
| 比价 | `price_report` | 上报更低价 | 用户举报更低价、审核奖励 |
| 广告 | `ad_reward_record` | 激励视频发奖 | 穿山甲 S2S 回调发金币 |
| 广告 | `ad_ecpm_record` | 广告 eCPM 上报 | 展示收益统计/对账 |
| 广告 | `ad_feed_reward_record` | 信息流广告奖励 | 比价等待/领券信息流广告金币 |
| 广告 | `ad_watch_log` | 看广告时长 | 观看秒数记录(旧版/排查) |
| 领券 | `coupon_claim_record` | 领券记录 | App 内自动领券每张券结果 |
| 领券 | `coupon_daily_completion` | 领券每日完成 | 今日是否跑完整轮领券(置灰源) |
| 领券 | `coupon_prompt_engagement` | 领券弹窗频控 | 控制领券引导窗弹不弹 |
| CPS | `cps_group` | CPS 推广群 | 微信群(渠道追踪位 sid) |
| CPS | `cps_activity` | CPS 活动池 | 可推广的券(美团/淘宝/京东) |
| CPS | `cps_link` | CPS 群发短链 | `/c/{code}` 短链 |
| CPS | `cps_click` | CPS 点击事件 | 落地页点击/复制 |
| CPS | `cps_order` | CPS 对账订单 | 美团订单/佣金 |
| CPS | `cps_wx_user` | CPS 落地页微信用户 | 群发落地页授权用户 |
| CPS | `meituan_coupon` | 美团 CPS 券缓存 | 定时抓券本地缓存排序 |
| 邀请 | `invite_relation` | 邀请关系 | 邀请绑定与发奖 |
| 邀请 | `invite_fingerprint` | 邀请指纹归因 | 剪贴板失效时的兜底归因 |
| 设备 | `device_liveness` | 设备无障碍存活 | 无障碍掉线检测+推送提醒 |
| 设备 | `onboarding_completion` | 新手引导完成 | 引导只跑一次 |
| 反馈 | `feedback` | 用户反馈 | 帮助与反馈 |
| 后台 | `admin_user` | 管理员账号 | 后台账号(角色) |
| 后台 | `admin_audit_log` | 管理员操作审计 | 后台写操作留痕 |
| 配置 | `app_config` | 运营可配置项 | 把硬编码规则挪到 DB |
| 配置 | `ops_marquee_seed` | 首页轮播种子 | 滚动条兜底假数据规则 |
| 配置 | `ops_stat_config` | 首页三统计配置 | 门面数字展示模式 |
| 技术 | `launch_confirm_sample` | 启动确认窗样本 | 比价 agent 兜底样本(内部) |
---
# 一、用户
## `user` — 用户表
App 用户主表。两种登录(极光一键 / 短信验证码)都映射到同一行,按 `phone` 唯一;注册即登录。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| phone | 字符串 | 手机号,**唯一**,登录主键 |
| username | 字符串 | 对外展示账号 ID:11 位纯数字、首位非 1、**唯一**、创建时随机生成、不可变、不参与登录 |
| register_channel | 字符串 | 注册渠道:jverify(极光)/ sms |
| nickname | 字符串 | 昵称,可空 |
| avatar_url | 字符串 | 头像 URL,可空 |
| invite_code | 字符串 | 邀请码,每用户一个稳定短码(懒生成),**唯一**、可空 |
| wechat_openid | 字符串 | 微信 openid(提现到零钱用),**唯一**、可空 |
| wechat_nickname | 字符串 | 微信昵称(提现绑定卡展示),与通用 nickname 分开 |
| wechat_avatar_url | 字符串 | 微信头像,同上 |
| status | 字符串 | 账号状态:active / disabled / deleted |
| debug_trace_enabled | 布尔 | 调试链接权限:开了的用户能看到「复制调试链接」按钮,默认 false |
| created_at | 时间 | 注册时间 |
| last_login_at | 时间 | 最后登录时间 |
---
# 二、钱包
## `coin_account` — 金币账户
一个用户一行,余额快照(读取用)。
| 字段 | 类型 | 说明 |
|---|---|---|
| user_id | 整数 | **主键 + 外键→user** |
| coin_balance | 整数 | 当前金币余额 |
| cash_balance_cents | 金额(分) | 当前现金余额 |
| total_coin_earned | 整数 | 累计赚取金币(只增不减),用于「历史总收益」展示 |
| updated_at | 时间 | 更新时间 |
## `coin_transaction` — 金币流水
金币每笔变动账本,每次余额变动写一笔并记变动后余额,可逐笔对账。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| amount | 整数 | 变动额:正=入账(赚),负=出账(花/兑换) |
| balance_after | 整数 | 本笔变动后金币余额(对账用) |
| biz_type | 字符串 | 业务类型:signin / task_<key> / exchange_out / … |
| ref_id | 字符串 | 关联业务 id(签到日期、任务 key 等),可空;可重复任务按它去重防双发 |
| remark | 字符串 | 备注,可空 |
| created_at | 时间 | 时间 |
## `cash_transaction` — 现金流水
现金(分)每笔变动账本。金币兑现金、提现记这里。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| amount_cents | 金额(分) | 正=入账(兑入),负=出账(提现) |
| balance_after_cents | 金额(分) | 本笔后现金余额 |
| biz_type | 字符串 | 业务类型:exchange_in(兑入)/ withdraw(提现)/ withdraw_refund(提现退回) |
| ref_id | 字符串 | 关联业务 id,可空 |
| remark | 字符串 | 备注,可空 |
| created_at | 时间 | 时间 |
## `withdraw_order` — 提现单
现金 → 微信零钱。状态机:reviewing(待审核)→ pending(打款在途)→ success / failedreviewing →(拒绝)→ rejected(已退款)。同一用户同时只能有一笔进行中。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| out_bill_no | 字符串 | 商户单号(我方生成,微信查单用),**唯一** |
| amount_cents | 金额(分) | 提现金额 |
| user_name | 字符串 | 提现实名(微信达额转账要求),可空 |
| status | 字符串 | reviewing / pending / success / failed / rejected |
| wechat_state | 字符串 | 微信侧原始状态(WAIT_USER_CONFIRM / SUCCESS / FAIL / CANCELLED…),可空 |
| transfer_bill_no | 字符串 | 微信转账单号,可空 |
| package_info | 字符串 | 待用户确认时给 App 拉起确认页的参数,可空 |
| fail_reason | 字符串 | 失败原因,可空 |
| created_at / updated_at | 时间 | 创建 / 更新时间 |
## `wechat_transfer_authorization` — 微信转账免确认授权
用户授权「免确认收款」后,后续转账免逐笔确认。一个用户一条。状态机:pending(待用户确认)→ active(已生效可免确认)→ closed(已关闭需重开)。
| 字段 | 类型 | 说明 |
|---|---|---|
| user_id | 整数 | **主键 + 外键→user** |
| openid | 字符串 | 用户微信 openid |
| out_authorization_no | 字符串 | 商户侧授权单号(我方生成),**唯一** |
| authorization_id | 字符串 | 微信侧授权单号(生效后返回,免确认转账要用),可空 |
| state | 字符串 | pending / active / closed |
| created_at / updated_at | 时间 | 创建 / 更新时间 |
---
# 三、激励(签到 · 任务 · 比价奖励)
## `signin_record` — 签到记录
每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| signin_date | 日期 | 签到日期(北京时间),与 user_id 组成**唯一** |
| cycle_day | 整数 | 1..7,7 天循环里今天第几档(决定发多少金币),断签重置回 1 |
| streak | 整数 | 连续签到天数(不封顶),断签重置回 1 |
| coin_awarded | 整数 | 本次发放金币 |
| created_at | 时间 | 时间 |
## `signin_boost_record` — 签到膨胀记录
签到后看广告「膨胀」翻倍,一天最多一次,补发金额=当天签到原始奖励。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| signin_date | 日期 | 与 user_id 组成**唯一**(防并发重复补发) |
| coin_awarded | 整数 | 补发金币 |
| ad_ref_id | 字符串 | 广告会话/交易号,可空 |
| created_at | 时间 | 时间 |
## `user_task` — 一次性任务完成记录
「打开消息提醒」这类只能领一次的任务,完成写一行,(user_id, task_key) 唯一。可循环任务(签到)走专表。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| task_key | 字符串 | 任务标识,与 user_id 组成**唯一** |
| status | 字符串 | 状态,默认 completed |
| coin_awarded | 整数 | 发放金币 |
| completed_at | 时间 | 完成时间 |
## `comparison_milestone_claim` — 比价战绩里程碑领取
「记录比价战绩」每档(第 1~6 次)只能领一次。解锁进度由成功比价条数决定,本表只记「哪几档已领」。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| milestone | 整数 | 档位序号(1-based),与 user_id 组成**唯一** |
| coin_awarded | 整数 | 发放金币 |
| claimed_at | 时间 | 领取时间 |
---
# 四、比价 · 省钱
## `comparison_record` — 比价记录
用户视角的「我的比价记录」,每完成一次比价客户端登录后上报一条。(user_id, trace_id) 唯一(重试幂等)。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| device_id | 字符串 | 设备号,可空 |
| business_type | 字符串 | food(外卖,当前唯一)/ ecom(电商)/ coupon(领券) |
| trace_id | 字符串 | 比价追踪号,与 user_id 组成**唯一** |
| trace_url | 字符串 | 公网调试链接(按用户 debug 权限决定是否返回),可空 |
| source_platform_id / _name | 字符串 | 源平台(发起比价那家)id / 名 |
| source_package | 字符串 | 源平台 App 包名 |
| source_price_cents | 金额(分) | 源平台价 |
| best_platform_id / _name | 字符串 | 最优(最便宜)平台 id / 名 |
| best_price_cents | 金额(分) | 最优价 |
| best_deeplink | 字符串 | 最优平台深链(「再次比价」直达),可空 |
| saved_amount_cents | 金额(分) | 省下金额=源价−最优价(可为 0/负) |
| is_source_best | 布尔 | 源平台就是最便宜(这次没省到),可空 |
| store_name | 字符串 | 店名 |
| total_dish_count / skipped_dish_count | 整数 | 下单菜数 / 跳过菜数 |
| status | 字符串 | success(有效对比)/ failed |
| information | 字符串 | done 文案:成功的到手价说明 / 失败原因 |
| items | JSON | 下单菜品明细 |
| comparison_results | JSON | 逐平台对比明细(含优惠/名次) |
| skipped_dish_names | JSON | 未找到跳过的菜名 |
| raw_payload | JSON | 客户端原始上报全量(取数兜底),可空 |
| device_model / device_manufacturer | 字符串 | 机型 / 厂商(排障),可空 |
| rom_vendor / rom_name / rom_version | 字符串/整数 | ROM 厂商/名/版本(排障),可空 |
| android_version / android_sdk | 字符串/整数 | 安卓版本,可空 |
| app_version / app_version_code | 字符串/整数 | 我方 App 版本,可空 |
| source_app_version | 字符串 | 源平台 App 版本(排适配失效),可空 |
| longitude / latitude | 小数 | 比价时定位,可空 |
| total_ms / step_count | 整数 | 整场耗时 / agent 步数(排障),可空 |
| llm_call_count / retry_count | 整数 | LLM 调用 / 重试次数,可空 |
| input_tokens / output_tokens | 整数 | LLM 累计 token,可空 |
| llm_calls | JSON | 每次 LLM 调用明细,可空 |
| created_at | 时间 | 时间 |
## `savings_record` — 省钱记录
profile「累计帮你省了」「省钱战绩」的唯一数据源。(user_id, client_event_id) 唯一(真实上报幂等)。当前由 demo seeder 灌入,比价真接入后改上报写入。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| order_amount_cents | 金额(分) | 订单实付金额 |
| saved_amount_cents | 金额(分) | 省下金额 |
| platform | 字符串 | 平台,可空 |
| title | 字符串 | 标题,可空 |
| shop_name | 字符串 | 店铺名(订单卡标题),可空 |
| dishes | JSON | 菜品名列表(前 2 道直接展示) |
| source | 字符串 | demo(演示)/ compare(真实比价上报) |
| original_price_cents | 金额(分) | 源平台原价,可空(真实上报才有) |
| compared_price_cents | 金额(分) | 我们给出的比价价,可空 |
| pay_channel | 字符串 | 支付渠道:wechat / alipay,可空 |
| platform_package | 字符串 | 实际下单平台包名,可空 |
| source_platform_name | 字符串 | 源平台展示名(如「美团」),可空 |
| source_deeplink | 字符串 | 源平台重进链接(预留),可空 |
| client_event_id | 字符串 | 客户端幂等键(UUID),demo 行为空 |
| device_id | 字符串 | 设备号,可空 |
| created_at | 时间 | 时间 |
## `price_observation` — 价格观测(后端数据资产)
平台/门店视角的「价格事实」。比价跑到 done 后由服务端无条件沉淀(与登录无关、匿名也记),是未来「查过同店秒回价」的源头。(trace_id, platform, scope) 唯一。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| observed_at | 时间 | 观测时刻(≈比价 done 时) |
| trace_id | 字符串 | 比价追踪号(溯源+幂等) |
| business_type | 字符串 | food / ecom |
| platform | 字符串 | 平台 |
| platform_store_id | 字符串 | 平台内门店 ID,可空 |
| store_name / city | 字符串 | 门店名 / 城市,可空 |
| geohash / lng / lat | 字符串/小数 | 地理位置,可空 |
| is_source | 布尔 | 是否源平台 |
| scope | 字符串 | order(整单到手价,当前唯一)/ dish(单菜价) |
| price_cents | 金额(分) | 该口径到手价;空=采集失败/打烊 |
| coupon_saved_cents | 金额(分) | 优惠额,可空 |
| coupon_name | 字符串 | 优惠名,可空 |
| store_closed | 字符串 | 打烊原因(非空时无有效价),可空 |
| rank | 整数 | 本次比价全局名次(1=最便宜),可空 |
| dishes | JSON | 菜篮明细,可空 |
| attrs | JSON | 灵活字段兜底,可空 |
| source_device_id / source_user_id | 字符串/整数 | 来源设备 / 用户(不鉴权时为空) |
| confidence | 小数 | 置信度,默认 1.0 |
| created_at | 时间 | 时间 |
## `store_mapping` — 平台店铺映射(后端数据资产)
跨平台「同一家店」在淘宝/美团/京东各自的店铺 id 与店名映射。每次淘宝比价沉淀一行。trace_id 唯一。⚠️ 数据来自 LLM 店铺匹配,可能匹配错。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| id_taobao / name_taobao | 字符串 | 淘宝店铺 idshopId)/ 名,可空 |
| id_meituan / name_meituan | 字符串 | 美团店铺 id(预留稳定 poi)/ 名,可空 |
| id_jd / name_jd | 字符串 | 京东店铺 idstoreId)/ 名,可空 |
| city / geohash / lng / lat | 字符串/小数 | 地理位置,可空 |
| taobao_address | 字符串 | 淘宝门店地址,可空 |
| source_platform | 字符串 | 源平台,可空 |
| business_type | 字符串 | 业务类型,默认 food |
| trace_id | 字符串 | 比价追踪号(溯源+**唯一**) |
| source_device_id / source_user_id | 字符串/整数 | 来源设备 / 用户,可空 |
| taobao_share_url / taobao_resolved_url / taobao_deeplink | 字符串/文本 | 淘宝短链 / 解析 URL / 深链 |
| taobao_deeplink_invalid_at | 时间 | 淘宝深链失效标记(空=有效) |
| meituan_poi_id_str | 字符串 | 美团一次性票据(非稳定主键),可空 |
| meituan_share_url / meituan_resolved_url / meituan_deeplink | 字符串/文本 | 美团短链 / 解析 URL / 深链 |
| jd_vender_id | 字符串 | 京东商家 id(深链要用),可空 |
| jd_share_url / jd_resolved_url / jd_deeplink | 字符串/文本 | 京东短链 / 解析 URL / 深链 |
| jd_deeplink_invalid_at | 时间 | 京东深链失效标记(空=有效) |
| attrs | JSON | 灵活字段兜底,可空 |
| created_at | 时间 | 时间 |
## `price_report` — 上报更低价
用户在比价记录里举报「某平台有更低价」+ 截图,人工审核通过奖励金币。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| comparison_record_id | 整数 | **外键→comparison_record**,关联的比价记录,可空(记录删后保留上报) |
| store_name / dish_summary | 字符串 | 比价记录快照:店名 / 菜品摘要,可空 |
| original_platform_id / _name | 字符串 | 原最低价平台快照 |
| original_price_cents | 金额(分) | 原最低价快照 |
| reported_platform_id / _name | 字符串 | 用户上报的平台 |
| reported_price_cents | 金额(分) | 用户上报的更低价 |
| images | JSON | 截图 URL 列表 |
| status | 字符串 | pending(审核中)/ approved / rejected |
| reject_reason | 字符串 | 驳回原因,可空 |
| reward_coins | 整数 | 通过奖励金币,可空 |
| reviewed_at | 时间 | 审核时间,可空 |
| created_at | 时间 | 提交时间 |
---
# 五、广告变现
> 看广告有三条并列数据流:`ad_reward_record`(后端 S2S 发金币)、`ad_ecpm_record`(前端报展示收益)、`ad_watch_log`(前端报观看时长);外加点位 2 的 `ad_feed_reward_record`(信息流)。
## `ad_reward_record` — 激励视频发奖记录
每条=穿山甲/GroMore 一次服务端激励回调。`trans_id` 唯一做幂等键。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| trans_id | 字符串 | 穿山甲交易号,**唯一**(幂等键) |
| user_id | 整数 | **外键→user** |
| coin | 整数 | 实发金币(超限为 0) |
| status | 字符串 | granted(已发)/ capped(当日超限)/ ecpm_missing(缺 eCPM |
| reward_scene | 字符串 | reward_video(福利页看视频)/ signin_boost(签到膨胀) |
| ad_session_id | 字符串 | 广告会话 id,可空 |
| ecpm_raw | 字符串 | 本次发奖采用的 eCPM 原始值,可空 |
| app_env | 字符串 | 应用环境 prod/test(回填),可空 |
| our_code_id | 字符串 | 我方配置代码位 104xxx(回填),可空 |
| reward_date | 字符串 | 北京日期串,按它统计当日发奖次数 |
| reward_name | 字符串 | 穿山甲上报奖励名(参考),可空 |
| raw | 字符串 | 回调原始参数(审计),可空 |
| created_at | 时间 | 时间 |
## `ad_ecpm_record` — 广告 eCPM 上报
每条=客户端一次广告展示后读到的 eCPM。`ad_session_id` 唯一,把展示收益与奖励完成绑定。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| ad_type | 字符串 | reward_video / draw 等 |
| ad_session_id | 字符串 | 广告会话 id,**唯一**、可空 |
| adn | 字符串 | 实际 ADNpangle / gdt…),可空 |
| slot_id | 字符串 | 底层代码位 rit,可空 |
| app_env | 字符串 | 应用环境 prod/test,可空 |
| our_code_id | 字符串 | 我方配置代码位 104xxx,可空 |
| ecpm_raw | 字符串 | eCPM 原始字符串(单位:分/千次展示) |
| report_date | 字符串 | 北京日期串(按天聚合) |
| created_at | 时间 | 时间 |
## `ad_feed_reward_record` — 信息流广告奖励
点位 2:比价等待 / 领券信息流广告,每满 10 秒累计一份,视频完成一次性入账。`client_event_id` 唯一。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| client_event_id | 字符串 | 客户端事件 id,**唯一**(幂等) |
| ad_session_id | 字符串 | 广告会话 id,可空 |
| user_id | 整数 | **外键→user** |
| reward_date | 字符串 | 北京日期串 |
| duration_seconds | 整数 | 本次展示秒数 |
| unit_count | 整数 | 累计份数 |
| ecpm_raw | 字符串 | eCPM 原始值 |
| adn / slot_id | 字符串 | ADN / 代码位,可空 |
| feed_scene | 字符串 | comparison(比价等待)/ coupon(领券)/ welfare(福利页),可空 |
| trace_id | 字符串 | 比价场景的 trace_id(归属本场比价金币),可空 |
| app_env / our_code_id | 字符串 | 应用环境 / 代码位,可空 |
| coin | 整数 | 实发金币 |
| status | 字符串 | 状态,默认 granted |
| created_at | 时间 | 时间 |
## `ad_watch_log` — 看广告时长记录
每条=客户端上报的一次激励视频观看秒数。当前发奖以次数上限为准,本表保留作旧客户端兼容/排查。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| watch_seconds | 整数 | 本次观看秒数(服务端已夹区间) |
| watch_date | 字符串 | 北京日期串(按天聚合总时长) |
| created_at | 时间 | 时间 |
---
# 六、领券(App 内自动领券)
> 判断维度是 **device_id**per-install,重装会变);日期为北京自然日。
## `coupon_claim_record` — 领券记录
按 (设备, 券, 自然日) 记每张券领取结果,纯沉淀(资产/画像/排查),当前不参与「要不要领」判断。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| device_id | 字符串 | 设备号(与 coupon_id、claim_date 组成**唯一** |
| user_id | 整数 | 登录态用户,可空(不进唯一键) |
| coupon_id | 字符串 | 券 id |
| claim_date | 日期 | 北京自然日 |
| status | 字符串 | success / already_claimed / failed / skipped |
| vendor | 字符串 | 券来源,可空 |
| coupon_name | 字符串 | 券名,可空 |
| claimed_count | 整数 | 领到几张,可空 |
| trace_id | 字符串 | 哪次任务领的(排查),可空 |
| reason | 字符串 | 失败/跳过原因,可空 |
| extra | JSON | 券结构化信息兜底,可空 |
| created_at / updated_at | 时间 | 创建 / 更新时间 |
## `coupon_daily_completion` — 领券每日完成
按 (设备, 自然日) 记「今天是否跑完整轮领券(到 done)」——首页「去领取」卡置灰源。到 done 即算(不管单券成败)。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| device_id | 字符串 | 设备号(与 complete_date 组成**唯一** |
| user_id | 整数 | 登录态用户,可空 |
| complete_date | 日期 | 北京自然日 |
| trace_id | 字符串 | 哪次任务跑到 done(排查),可空 |
| created_at / updated_at | 时间 | 创建 / 更新时间 |
## `coupon_prompt_engagement` — 领券弹窗频控
按 (设备, App, 自然日) 记「今天这个 App 是否对领券引导窗表达过意向」——弹窗频控源。美团/淘宝/京东各自独立。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| device_id | 字符串 | 设备号(与 package、engage_date 组成**唯一** |
| package | 字符串 | 目标 App 包名(频控维度,各 App 独立) |
| user_id | 整数 | 登录态用户,可空 |
| engage_date | 日期 | 北京自然日 |
| engage_type | 字符串 | claim_started(点一键领取)/ dismissed(拒绝/关闭)/ shown(自动弹出) |
| created_at / updated_at | 时间 | 创建 / 更新时间 |
---
# 七、CPS 群发分发(微信群发券)
## `cps_group` — CPS 推广群
每个微信群一条;`sid` 是美团联盟渠道追踪位(仅美团需要)。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| sid | 字符串 | 渠道追踪位,**唯一**、可空(纯淘宝/京东群无) |
| name | 字符串 | 群名 |
| platforms | JSON | 该群发哪些平台:["meituan","taobao","jd"] |
| member_count | 整数 | 群人数(转化率分母),可空 |
| status | 字符串 | active / archived |
| remark | 字符串 | 备注,可空 |
| created_at | 时间 | 时间 |
## `cps_activity` — CPS 活动池
可推广的券(运营预存)。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| platform | 字符串 | meituan / taobao / jd |
| name | 字符串 | 活动名 |
| act_id | 字符串 | 美团活动物料 ID,可空 |
| product_view_sign | 字符串 | 美团商品券标识(与 act_id 二选一),可空 |
| payload | 文本 | 淘宝整段淘口令 / 京东推广链接,可空 |
| image_url | 字符串 | 淘宝落地页主视觉图(绝对 URL),可空 |
| status | 字符串 | active / archived |
| remark | 字符串 | 备注,可空 |
| created_at | 时间 | 时间 |
## `cps_link` — CPS 群发短链
群发的 `/c/{code}` 短链,每个活动每次生成一条。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| code | 字符串 | 短码(`/c/{code}`),**唯一** |
| group_id | 整数 | 所属群 id |
| activity_id | 整数 | 所属活动 id |
| sid | 字符串 | 渠道追踪位(仅美团),可空 |
| platform | 字符串 | meituan / taobao / jd |
| target_url | 字符串 | 跳转目标:美团短链 / 京东链接 / 淘宝淘口令 |
| created_at | 时间 | 时间 |
## `cps_click` — CPS 点击事件
落地页点击/复制事件,统计 PV/UV 及用户级领券。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| link_id | 整数 | 所属短链 id |
| group_id | 整数 | 所属群 id(冗余,按群聚合快) |
| sid | 字符串 | 渠道追踪位(冗余),可空 |
| event_type | 字符串 | visit(进页/被跳转)/ copy(淘宝点复制口令) |
| ip / ua | 字符串 | 客户端 IP / UA(UV 近似去重),可空 |
| openid | 字符串 | 微信授权用户标识(非微信/未授权为空) |
| clicked_at | 时间 | 点击时间 |
## `cps_order` — CPS 对账订单
从美团联盟拉回、按 sid 归群的订单明细。`order_id` 唯一(upsert 幂等)。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| order_id | 字符串 | 美团订单号,**唯一** |
| sid | 字符串 | 渠道追踪位(按它归群),可空 |
| act_id | 字符串 | 活动物料 id,可空 |
| biz_line | 整数 | 业务线(1=外卖),可空 |
| trade_type | 整数 | 1=cps 2=cpa,可空 |
| pay_price_cents | 金额(分) | 付款金额,可空 |
| commission_cents | 金额(分) | 预估佣金,可空 |
| commission_rate | 字符串 | 佣金率("300"=3%),可空 |
| refund_price_cents / refund_profit_cents | 金额(分) | 退款金额 / 退款佣金,可空 |
| mt_status | 字符串 | 美团状态:2 付款 3 完成 4 取消 5 风控 6 结算 |
| invalid_reason | 字符串 | 失效原因,可空 |
| product_name | 字符串 | 商品名,可空 |
| pay_time / mt_update_time | 时间 | 付款 / 美团更新时间,可空 |
| raw | JSON | 原始订单返回 |
| first_seen / updated_at | 时间 | 首次拉到 / 更新时间 |
## `cps_wx_user` — CPS 落地页微信用户
群发落地页经服务号网页授权拿到的用户。`openid` 唯一。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| openid | 字符串 | 服务号下用户唯一标识,**唯一** |
| unionid | 字符串 | 开放平台 unionid,可空 |
| nickname / headimgurl | 字符串 | 昵称 / 头像(userinfo 授权后才有),可空 |
| first_code | 字符串 | 首次进入来源短码,可空 |
| first_group_id | 整数 | 首次授权来源群,可空 |
| first_seen / last_seen | 时间 | 首次 / 最近出现时间 |
## `meituan_coupon` — 美团 CPS 券本地缓存
定时把美团联盟券抓进本地库,供本地排序(销量/佣金)。(source, product_view_sign) 唯一;查询用 `dedup_key` 跨源去重。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| source | 字符串 | search_waimai / search_meishi / store_supply(与 product_view_sign 组成**唯一** |
| platform | 整数 | 1 到家/外卖,2 到店 |
| biz_line | 整数 | 到店细分:1 到餐 2 到综 3 酒店 4 门票,可空 |
| city_id | 字符串 | 城市 id |
| product_view_sign | 字符串 | 召回标识(换推广链用) |
| sku_view_id | 字符串 | sku 标识,可空 |
| name / brand_name | 字符串 | 商品名 / 品牌名,可空 |
| sell_price_cents / original_price_cents | 金额(分) | 售价 / 原价,可空 |
| head_url | 字符串 | 头图 URL,可空 |
| image_size / image_type | 整数/字符串 | 头图字节大小 / MIME(分析用),可空 |
| sale_volume | 字符串 | 销量档位文本(如「热销 1w+」),可空 |
| sale_volume_num | 整数 | 销量排序用数值,可空 |
| commission_percent | 小数 | 佣金率(1.4=1.4%),可空 |
| commission_amount_cents | 金额(分) | 佣金额,可空 |
| poi_name | 字符串 | 门店名,可空 |
| available_poi_num | 整数 | 可用门店数,可空 |
| delivery_distance_m | 小数 | 配送距离(米),可空 |
| dedup_key | 字符串 | 跨源去重键 md5(brand\|name\|price) |
| raw | JSON | 原始返回 |
| first_seen / last_seen / updated_at | 时间 | 首次 / 最近 / 更新时间 |
---
# 八、邀请裂变
## `invite_relation` — 邀请关系
一行=一次成功邀请绑定。`invitee_user_id` 唯一(一人只能被邀一次,防重复发奖)。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| inviter_user_id | 整数 | **外键→user**,邀请人 |
| invitee_user_id | 整数 | **外键→user**,被邀请人,**唯一** |
| channel | 字符串 | clipboard(剪贴板自动)/ manual(手动填码)/ fingerprint(指纹兜底) |
| status | 字符串 | effective(注册即生效),预留 pending |
| inviter_coin / invitee_coin | 整数 | 各自发放金币 |
| created_at | 时间 | 时间 |
## `invite_fingerprint` — 邀请指纹归因
剪贴板归因失效时的兜底:落地页访问留指纹,装机后用 (IP, 屏幕, 机型) 7 天内反查。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| inviter_user_id | 整数 | **外键→user**,邀请人 |
| ip | 字符串 | 落地页访问者 IP |
| device_model | 字符串 | 机型(Build.MODEL / UA 解析) |
| screen | 字符串 | 屏幕分辨率(如 1080x2400 |
| user_agent | 文本 | 完整 UAdebug 用) |
| created_at | 时间 | 时间 |
---
# 九、设备 · 新手引导
## `device_liveness` — 设备无障碍存活监控
每条=一个用户的一台设备。无障碍存活时上报心跳,掉线超时则极光推送提醒。(user_id, device_id) 唯一。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| device_id | 字符串 | per-install 设备号(与 user_id 组成**唯一** |
| registration_id | 字符串 | 极光推送目标 id,可空 |
| platform | 字符串 | 平台,默认 android |
| app_version | 字符串 | App 版本,可空 |
| ever_protected | 布尔 | 是否开过无障碍(功能对它有意义) |
| last_heartbeat_at | 时间 | 最近心跳时间(超时=掉线),可空 |
| last_report_protection_on | 布尔 | 最近上报的无障碍开关状态 |
| liveness_state | 字符串 | unknown / alive / silent / notified |
| notified_at | 时间 | 最近推送告警时间,可空 |
| kill_alert_pending | 布尔 | 掉线告警待客户端提醒标记 |
| created_at / updated_at | 时间 | 创建 / 更新时间 |
## `onboarding_completion` — 新手引导完成
同一台设备+同一账号,新手引导只跑一次。device_id 用**硬件级稳定标识**(重装不变)。(user_id, device_id) 唯一。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | 登录态用户(与 device_id 组成**唯一** |
| device_id | 字符串 | 硬件级稳定设备号(ANDROID_ID |
| completed_at | 时间 | 完成时间 |
---
# 十、反馈
## `feedback` — 用户反馈
帮助与反馈,人工审核可奖励金币。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| user_id | 整数 | **外键→user** |
| content | 文本 | 反馈内容(必填) |
| contact | 字符串 | 联系方式(改版后客户端不再采集,新数据存空串) |
| images | JSON | 截图 URL 列表,可空 |
| status | 字符串 | pending(审核中)/ adopted(已采纳)/ rejected |
| reject_reason | 字符串 | 驳回原因,可空 |
| reward_coins | 整数 | 采纳奖励金币,可空 |
| review_note | 字符串 | 审核批注,可空 |
| reviewed_by_admin_id | 整数 | **外键→admin_user**,审核人,可空 |
| reviewed_at | 时间 | 审核时间,可空 |
| created_at | 时间 | 提交时间 |
---
# 十一、运营后台 · 配置
## `admin_user` — 管理员账号
后台账号,与 App 用户完全隔离(独立 JWT)。账号密码(bcrypt)登录。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| username | 字符串 | 用户名,**唯一** |
| password_hash | 字符串 | bcrypt 密码哈希 |
| role | 字符串 | super_admin(全权)/ finance(钱)/ operator(用户+反馈+大盘) |
| status | 字符串 | active / disabled |
| created_at | 时间 | 创建时间 |
| last_login_at | 时间 | 最后登录时间,可空 |
## `admin_audit_log` — 管理员操作审计
每个后台写操作落一条,记前后值,不可删,用于追溯「谁在何时改了谁的钱/状态」。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| admin_id | 整数 | **外键→admin_user** |
| admin_username | 字符串 | 操作者用户名(冗余存,改名后仍可追溯) |
| action | 字符串 | 操作类型(如 user.coins.grant / withdraw.refresh |
| target_type | 字符串 | 被操作对象类型 |
| target_id | 字符串 | 被操作对象 id(字符串以兼容非整型主键),可空 |
| detail | JSON | 上下文 + 前后值,可空 |
| ip | 字符串 | 操作 IP,可空 |
| created_at | 时间 | 时间 |
## `app_config` — 运营可配置项
把硬编码规则(奖励数值等)挪到 DB,运营后台可改。空表=现有行为完全不变(fallback 到代码默认)。
| 字段 | 类型 | 说明 |
|---|---|---|
| key | 字符串 | **主键**,配置标识 |
| value | JSON | 配置值(任意 int/list/dict |
| updated_by_admin_id | 整数 | 最后修改管理员,可空 |
| updated_at | 时间 | 更新时间 |
## `ops_marquee_seed` — 首页轮播种子
首页「用户 xxx 比价省了 xx 元」滚动条的兜底假数据**规则**:真实记录不够时用它补齐混播。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| masked_user | 字符串 | 脱敏用户名;留空则展示时随机合成,可空 |
| min_cents / max_cents | 金额(分) | 节省金额区间(展示时区间内随机取值) |
| enabled | 布尔 | 是否启用(停用不参与混播) |
| sort_order | 整数 | 仅后台列表排序(feed 公平随机,不看此字段) |
| created_at | 时间 | 时间 |
## `ops_stat_config` — 首页三统计配置
首页门面三数字(帮助用户/完成比价/累计节省),每指标独立选展示模式。一行一指标。
| 字段 | 类型 | 说明 |
|---|---|---|
| metric | 字符串 | **主键**help_users / total_compares / total_saved |
| mode | 字符串 | real(真实)/ manual(手填固定值)/ random(只增不减伪增长) |
| manual_value | 整数 | manual 模式固定值(total_saved 为分),可空 |
| random_mult_min / random_mult_max | 整数 | random 倍率区间(千分比,≥1000 只增不减) |
| random_tick_seconds | 整数 | random 刷新周期(秒) |
| random_anchor_minutes | 整数 | 刷新触发时刻对齐偏移(距北京 0 点分钟数) |
| random_current | 整数 | random 当前值,可空 |
| random_last_tick_at | 时间 | 上次 tick 时刻,可空 |
| random_kind | 字符串 | mult(×倍率)/ add(+绝对增量) |
| random_step_min / random_step_max | 整数 | add 模式增量区间 |
| real_offset | 整数 | real 模式保底值(展示=max(真实,保底)) |
| allow_decrease | 布尔 | 是否允许下调(默认只增不减) |
| updated_by_admin_id | 整数 | 最后修改管理员,可空 |
| updated_at | 时间 | 更新时间 |
---
# 十二、技术沉淀(内部)
## `launch_confirm_sample` — 启动确认窗样本
比价 agent 用 LLM 兜底放行跨 App 启动确认窗时落的样本,研发人工沉淀进静态规则。内部排障用,不去重。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | 整数 | **主键** |
| created_at | 时间 | 时间 |
| trace_id | 字符串 | 比价追踪号(溯源),可空 |
| device_id | 字符串 | 设备号,可空 |
| host_package | 字符串 | 弹窗宿主包(聚合键),可空 |
| target_app | 字符串 | 被打开的目标 App 包名,可空 |
| system_locale | 字符串 | 系统语言地区(zh-TW / en-US…),可空 |
| exec_success | 布尔 | 本次兜底是否成功放行(研发只沉淀 true 的) |
| dialog_title | 字符串 | 弹窗标题全文,可空 |
| payload | JSON | 完整样本(弹窗树 + LLM plan + 设备信息),可空 |
---
*文档生成基于 `app/models/` 全量模型。如需单表更详细的索引/迁移信息,见 `docs/database/` 下对应同名文件。*
@@ -37,7 +37,7 @@ B 安装并首启 App
└─ POST /api/v1/invite/bind { invite_code, channel="clipboard" }
后端 repositories/invite.py bind()
└─ 过四道防线 → 建 invite_relation + 给 A、B 各发金币(同事务原子提交)
└─ 过四道防线 → 建 invite_relation(邀请金币已下线,不写金币流水)
```
手动填码这条:B 在邀请页输码 → `InviteRepository.bindManual()``POST /bind { channel="manual" }` → 同一个 `bind()`
@@ -52,20 +52,20 @@ B 安装并首启 App
|---|---|
| 端点 | `app/api/v1/invite.py`:`GET /api/v1/invite/me`(返回 `invite_code` + `share_url` + 战绩)、`POST /api/v1/invite/bind`(绑定,`channel` = `clipboard` / `manual`)。**均需 Bearer 鉴权**。 |
| share_url 构造 | `invite.py``my_invite`:`settings.INVITE_LANDING_URL + "?ref=" + code``INVITE_LANDING_URL``app/core/config.py`(默认 `https://app-api.shaguabijia.com/media/dl.html`)。 |
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 累计金币)。 |
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 兼容累计金币字段,当前恒为 0)。 |
| 数据模型 | `app/models/invite.py``InviteRelation`(`inviter_user_id` / `invitee_user_id` / `channel` / `status` / `inviter_coin` / `invitee_coin` / `created_at`)+ `app/models/user.py``User.invite_code` 列。 |
| 迁移 | `alembic/versions/invite_code_and_relation.py`:给 `user``invite_code`(唯一索引)+ 建 `invite_relation` 表。`down_revision = 11a1d08c6f55`。 |
| 收发模型 | `app/schemas/invite.py`:`InviteInfoOut` / `BindInviteIn` / `BindInviteOut`。 |
| 奖励常量 | `app/core/rewards.py`:`INVITE_INVITER_COINS` / `INVITE_INVITEE_COINS`(各 10000 = 1 元)、`INVITE_NEW_USER_WINDOW_HOURS`(72)。 |
| 新人窗口 | `app/core/rewards.py`:`INVITE_NEW_USER_WINDOW_HOURS`(72)。邀请金币已下线,不再配置邀请金币常量。 |
**`bind()` 的四道防线(防重复 / 防刷,看 `repositories/invite.py`):**
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复发奖)。
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复绑定)。
2. **自邀屏蔽**:`inviter == invitee``self_invite`
3. **新人闸**:`_is_new_user`(B 的 `created_at``INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才发奖,挡存量老用户互相填码薅羊毛 → 否则 `not_eligible`
3. **新人闸**:`_is_new_user`(B 的 `created_at``INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才生效,挡存量老用户互相填码刷关系 → 否则 `not_eligible`
4. **手机号唯一**(天然限量):每个 B = 一个真实手机号账号。
发金币复用 `repositories/wallet.py``grant_coins`,与建关系记录在**同一事务**提交,保证"建关系 + 双方加金币"原子
邀请金币已下线:`bind()` 只记录绑定关系,不再写 `coin_transaction`;响应里的金币字段保留兼容旧客户端,当前恒为 0
### 3.2 前端(shaguabijia-app-android)
@@ -120,7 +120,7 @@ B 安装并首启 App
### 4.4 测试硬约束 / 坑(都是机制,不是 bug)
- **B 必须用新手机号**:`invitee_user_id` 唯一,一个 B 只能绑一次;反复测要换号(或手删 `invite_relation` 那行 + 回滚金币)。
- **72h 新人闸**:B 注册后 72 小时内绑才发奖(刚注册肯定满足)。
- **72h 新人闸**:B 注册后 72 小时内绑定才生效(刚注册肯定满足)。
- **A ≠ B**:自邀被屏蔽。
- **B 从点下载到首启 App 之间别复制别的东西**:剪贴板会被覆盖 → 归因丢(剪贴板 deferred deeplink 的固有脆弱性)。
- **笔记本 IP 别变**:debug 包把 `BASE_URL` 的 IP 烧死在编译期,DHCP 一换就连不上 → 给笔记本固定个 LAN IP。
+7771
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More