Compare commits

..

31 Commits

Author SHA1 Message Date
liujiahui 9e082e6376 后台权限管理支持「自定义」角色(后端) (#126)
admin_user 加 pages_override(可空 JSON)+ Alembic 迁移;role==custom 时可见页由逐页勾选决定,否则跟随角色。
create/update 收 pages_override(切回普通角色自动清空),_validate_role 放行 custom 哨兵角色。
登录/me 下发有效页时优先用 override → 左侧导航按勾选即时生效。补 4 个用例,test_admin_roles 全绿。

配套前端 PR:shaguabijia-admin-web#admin-custom-perms。

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #126
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-07-09 01:09:03 +08:00
zhuzihao 93cb283c1b feat(marquee): 真实条不按用户去重(打乱+去连簇)+ 后台按模式分页浏览可展示记录 (#123)
- get_feed 真实条不再按 user 去重:改「洗牌 + 去连簇」(相邻尽量不同用户、减少单人连刷),
  同一用户可多条露出但被打散、尽量不连续。_shuffle_declustered 支持传入固定种子 rng。
- 新增 GET /admin/api/marquee-seeds/real-records:按**当前模式**分页浏览全部可展示记录(不去重),
  供运营逐页审核——只真实=真实记录;只种子=各启用种子按生成逻辑各出一行;混播=真实+种子。
  固定种子洗牌+去连簇 → 排列恒定、翻页稳定、能翻遍全部;含 OpsRealRecord* schema。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #123
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-08 00:57:00 +08:00
guke a43e082391 fix(dashboard): 比价/领券奖励金币按 feed_scene 汇总(修复恒 0 + 激励视频双计) (#125)
世睿之前mr未合并的逻辑
比价/领券奖励金币此前查 coin_transaction.biz_type in (comparison/coupon...),
但这些 biz_type 全站从未写入——比价/领券信息流广告金币实际记为 feed_ad_reward、
场景区分在 ad_feed_reward_record.feed_scene——故两卡恒 0;领券桶还误含
reward_video/ad_reward,把激励视频金币双计进领券。

改为:comparison/coupon 奖励金币 = biz_type 桶(历史空、留作兜底)+ 按
ad_feed_reward_record.feed_scene 的 granted 实发金币(reward_date 北京自然日窗口);
reward_video/ad_reward 拆成独立 REWARD_VIDEO_BIZ_TYPES,不再混入领券,
REGULAR_TASK_EXCLUDED_BIZ_TYPES 保持不变。

测试:tests/test_admin_read.py 加 3 个用例(比价/领券按 feed_scene 汇总、
too_short 不计、领券排除激励视频);全量 pytest 除 5 个既有失败外全绿。

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

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #125
2026-07-07 18:29:23 +08:00
guke e11ad9449c revert 6ec4cfb4be
revert fix(dashboard): 比价/领券奖励金币按 feed_scene 汇总(修复恒 0 + 激励视频双计)

比价/领券奖励金币此前查 coin_transaction.biz_type in (comparison/coupon...),
但这些 biz_type 全站从未写入——比价/领券信息流广告金币实际记为 feed_ad_reward、
场景区分在 ad_feed_reward_record.feed_scene——故两卡恒 0;领券桶还误含
reward_video/ad_reward,把激励视频金币双计进领券。

改为:comparison/coupon 奖励金币 = biz_type 桶(历史空、留作兜底)+ 按
ad_feed_reward_record.feed_scene 的 granted 实发金币(reward_date 北京自然日窗口);
reward_video/ad_reward 拆成独立 REWARD_VIDEO_BIZ_TYPES,不再混入领券,
REGULAR_TASK_EXCLUDED_BIZ_TYPES 保持不变。

测试:tests/test_admin_read.py 加 3 个用例(比价/领券按 feed_scene 汇总、
too_short 不计、领券排除激励视频);全量 pytest 除 5 个既有失败外全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:05:33 +08:00
guke 6ec4cfb4be fix(dashboard): 比价/领券奖励金币按 feed_scene 汇总(修复恒 0 + 激励视频双计)
比价/领券奖励金币此前查 coin_transaction.biz_type in (comparison/coupon...),
但这些 biz_type 全站从未写入——比价/领券信息流广告金币实际记为 feed_ad_reward、
场景区分在 ad_feed_reward_record.feed_scene——故两卡恒 0;领券桶还误含
reward_video/ad_reward,把激励视频金币双计进领券。

改为:comparison/coupon 奖励金币 = biz_type 桶(历史空、留作兜底)+ 按
ad_feed_reward_record.feed_scene 的 granted 实发金币(reward_date 北京自然日窗口);
reward_video/ad_reward 拆成独立 REWARD_VIDEO_BIZ_TYPES,不再混入领券,
REGULAR_TASK_EXCLUDED_BIZ_TYPES 保持不变。

测试:tests/test_admin_read.py 加 3 个用例(比价/领券按 feed_scene 汇总、
too_short 不计、领券排除激励视频);全量 pytest 除 5 个既有失败外全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:02:21 +08:00
guke a52fc1973f revert 58b59c264d
revert fix(dashboard): 比价/领券奖励金币按 feed_scene 汇总(修复恒 0 + 激励视频双计)

比价/领券奖励金币此前查 coin_transaction.biz_type in (comparison/coupon...),
但这些 biz_type 全站从未写入——比价/领券信息流广告金币实际记为 feed_ad_reward、
场景区分在 ad_feed_reward_record.feed_scene——故两卡恒 0;领券桶还误含
reward_video/ad_reward,把激励视频金币双计进领券。

改为:comparison/coupon 奖励金币 = biz_type 桶(历史空、留作兜底)+ 按
ad_feed_reward_record.feed_scene 的 granted 实发金币(reward_date 北京自然日窗口);
reward_video/ad_reward 拆成独立 REWARD_VIDEO_BIZ_TYPES,不再混入领券,
REGULAR_TASK_EXCLUDED_BIZ_TYPES 保持不变。

测试:tests/test_admin_read.py 加 3 个用例(比价/领券按 feed_scene 汇总、
too_short 不计、领券排除激励视频);全量 pytest 除 5 个既有失败外全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:52:57 +08:00
guke 58b59c264d fix(dashboard): 比价/领券奖励金币按 feed_scene 汇总(修复恒 0 + 激励视频双计)
比价/领券奖励金币此前查 coin_transaction.biz_type in (comparison/coupon...),
但这些 biz_type 全站从未写入——比价/领券信息流广告金币实际记为 feed_ad_reward、
场景区分在 ad_feed_reward_record.feed_scene——故两卡恒 0;领券桶还误含
reward_video/ad_reward,把激励视频金币双计进领券。

改为:comparison/coupon 奖励金币 = biz_type 桶(历史空、留作兜底)+ 按
ad_feed_reward_record.feed_scene 的 granted 实发金币(reward_date 北京自然日窗口);
reward_video/ad_reward 拆成独立 REWARD_VIDEO_BIZ_TYPES,不再混入领券,
REGULAR_TASK_EXCLUDED_BIZ_TYPES 保持不变。

测试:tests/test_admin_read.py 加 3 个用例(比价/领券按 feed_scene 汇总、
too_short 不计、领券排除激励视频);全量 pytest 除 5 个既有失败外全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:46:09 +08:00
Ghost 0e149c83e7 修复美团 CPS 订单 pay_time 入库为空导致大盘美团收益漏算 (#119)
Co-authored-by: guke <guke@autohome.com.cn>
Co-authored-by: 陈世睿 <2839904623@qq.com>
Reviewed-on: #119
Co-authored-by: Ghost <>
Co-committed-by: Ghost <>
2026-07-07 17:11:00 +08:00
zhuzihao 9e011f699a feat(marquee): 预览可按数据源模式实时 + 默认昵称归入「无昵称」脱敏档 (#122)
后台「首页轮播种子」预览与用户名脱敏两处改动,对齐《省钱log&平台数据 显示策略》:

- 预览按模式实时:get_feed 增加可选 mode 入参(不落库),admin 预览接口
  /marquee-seeds/preview 透传 ?mode=,后台切换「混播/只真实/只种子」时预览即时对应;
  不传 mode 时读持久化配置,客户端 /savings-feed 行为不变。
- 默认昵称归「无昵称」档:创建时自动分配的默认昵称(「用户」+9 位随机)不算用户主动设的
  昵称,脱敏走 id 规则「用户*****+id后2位」而非昵称规则。新增 user.is_default_nickname
  精确匹配生成格式,不误伤真人以「用户」开头的昵称(如「用户体验师」)。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #122
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-07 00:16:20 +08:00
zhuzihao 66c1e3ad8f fix(withdraw): 提现账本校验按 source 分账,邀请奖励金提现纳入对账 (#121)
线上后台「提现审核」页现金账本报红(缺扣款/缺退款流水),根因是
withdraw_ledger_check 用全部提现单去比对普通现金流水(cash_transaction),
而 source=invite_cash 的提现,其扣款/退款流水写在独立的 invite_cash_transaction
表,于是每笔邀请提现单都在普通现金流水里找不到、被误报为「缺流水」。

改动:
- 抽出 _check_withdraw_ledger_side 复用单账核对逻辑;withdraw_ledger_check
  改为按 order.source 分两本账各自对账:
  coin_cash ↔ cash_transaction(withdraw/withdraw_refund),
  invite_cash ↔ invite_cash_transaction(invite_withdraw/invite_withdraw_refund)。
- 邀请奖励金账户的余额差额也纳入校验(此前完全未对账)。
- 纯只读校验,不改任何资金/流水写入;不掩盖真实缺流水
  (coin_cash 单若真缺流水仍照报)。
- WithdrawLedgerCheckOut 新增 7 个 invite_* 字段(默认 0,向后兼容)。
- 补 tests/test_withdraw_ledger_check.py(此前无 ledger-check 测试):
  覆盖邀请提现不再误报、邀请账缺流水能被抓、普通现金账未回归。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #121
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-07 00:16:12 +08:00
zhuzihao 982e92bc24 feat(ad-revenue): 收益报表 DAU/ARPU 按所选区间统计(复用大盘活跃口径) (#120)
- 抽出 stats.period_active_dau(date_from, date_to):任意北京自然日区间的去重活跃用户
  (登录 + 开始比价 + 开始领券),口径与数据大盘 period.users.active 一致。today_dau 与
  dashboard_overview 的 period 活跃、逐日趋势统一复用它,消除三处重复的并集计算,行为不变。
- ad_revenue_report 去掉「仅今日单天才给 dau」的 is_today 限制,dau 改为按 date_from~date_to
  区间统计(含今日、近 7 天、近 30 天);ARPU = 区间预估收益 ÷ 区间活跃用户 对三个时段均可算,
  dau 与大盘同区间同值。
- dau 为全局口径,不随 user_id/ad_type/feed_scene/app_env 筛选变化;更新 schema 字段说明。
- 验证:admin 读接口测试(含 dashboard_overview)全过;实测多天区间 dau 非空且 == 大盘
  period.users.active。前端展示改动见 admin-web 对应 PR。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #120
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-06 00:23:36 +08:00
zhuzihao 6241543119 运营后台后端:RBAC 权限管理 + 系统配置下发修复 + 比价记录店/商品搜索 (#117)
一、RBAC 权限管理(角色 → 可见页面)
- 新增 admin_role 表 + 权限目录 permissions.py:角色持有一组页面 key,登录后左侧只展示这些页
- 内建角色 管理员(super_admin 全权锁定)/运营/财务/技术,页集对齐原型;key 承重(require_role 用),另加中文 label 展示
- 角色 CRUD 端点(仅 super_admin)+ 目录端点;登录/me 下发当前角色有效可见页
- admins 加删除、角色存在性校验;可复看已确定登录密码:UI 建的账号留存明文 plain_password,脚本建的超管不留存

二、系统配置下发修复
- 首页数据「保存即生效」:显式保存(apply_now)对 real/manual 直接落配置目标值、绕过只增不减护栏(修「改了 app 端不变」),护栏仍管自动 tick
- 首页轮播新增数据源三选一:mixed(真实优先+种子)/real(只真实)/seed(只种子),get_feed 分支 + /marquee-seeds/mode 端点
- 福利页 Tab 隐藏 任务/里程碑,及看广告的单次金币/每轮次数/信息流开关:CONFIG_DEFS 加 hidden + list_config 过滤,业务读取默认值不受影响

三、比价记录店/商品搜索
- comparison_record 加 product_names 派生列(从下单 items 拼商品名),迁移建列并回填历史行
- admin 比价记录列表店/商品分列可搜:走 product_names 普通列 LIKE,规避 SQLite JSON 中文 ensure_ascii 转义搜不到的坑

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #117
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-05 09:46:19 +08:00
guke 7b6756f936 feat(meituan-cps): 经纬度→城市离线反查 + rec/销量最高按城市过滤 (#116)
## 主要功能
新增离线「经纬度 → 美团 cityId」反查,让 `rec`(智能推荐)与 `top-sales`(销量最高)从离线库只返**同城券**(此前会混返异地券)。

- `app/utils/geo.py` + `app/utils/meituan_city.py`:坐标 → 美团 `city_id`(reverse_geocoder 离线反查,零网络)。
- `feed?tab=rec` / `/top-sales`:按 `city_id` 过滤;解析不出 / 老客户端不带坐标 → 降级返空。
- `top-sales` 与 `rec` 一致置空库内距离(相对城市默认点、对用户无意义)。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #116
Co-authored-by: guke <guke@wonderable.ai>
Co-committed-by: guke <guke@wonderable.ai>
2026-07-05 09:31:53 +08:00
marco 3eb439b5ae feat(onboarding): 新增重置新手引导端点 /api/v1/user/onboarding/reset (#114)
Reviewed-on: #114
2026-07-04 02:39:12 +08:00
liujiahui 6b10e866e9 7-3 尾声帧: /api/v1/trace/epilogue 透传壳 (#112)
比价结果页"尾声帧"配套改动(透传壳一条)。

App 收到 done、渲染完比价结果页后, 把自己页面的截图(base64)经本壳透传给 pricebot
`/api/trace/epilogue`, 存进 trace 目录并触发重传 —— trace 里补上"用户实际看到的
汇总页"(trace 步骤帧只有目标 App 画面, App 自渲染的汇总页天然在镜头外)。

壳同 `/trace/finalize`: 不鉴权、按 trace_id 一致性 hash 亲和。

配套分支(同名 7-3-ljh-testlab): pricebot-backend(端点+viewer 实拍块, PR #160) /
shaguabijia-app-android(结果页拍屏上传)。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #112
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-07-04 01:42:01 +08:00
liujiahui 13b143cd97 feat(invite): 发奖改「比价并下单」+ 好友 is_compared 状态 + DEV 造数/重置接口 (#113)
重开分支重提 PR #110 的邀请发奖改动,**不含任何测试文件**(`tests/` 3 个 + `scripts/invite_reward_e2e.*` 2 个 E2E 脚本本地自用,未入库)。

只含 8 个产品/部署文件:

- 发奖口径从「上报比价」改为「实际下单」:`order.py` / `compare_record.py` / `repositories/invite.py`
- 好友列表返回 `is_compared` 已比价状态:`schemas/invite.py` / `repositories/invite.py`
- 新增 DEV 内部接口(生产 `is_prod` 一律 404):`POST/DELETE /invite/seed-fake` 造/清虚拟好友;`POST /invite/dev-reset` 固定测试号打回出厂态
- fix(deploy):每日兑换定时器锁北京时区 `Asia/Shanghai`(`daily-exchange.service` / `.timer`)

对应 PR #110,源分支 `7-1ljh`。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #113
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-07-03 19:01:37 +08:00
zhuzihao 93f052535e 精简短信登录风控:保留单号冷却 + 单设备频控两道 (#109)
按 mentor 要求精简 SMS 登录链路的防刷策略,只保留两道主策略:
- 单号 60s 冷却(发码,SMS_SEND_INTERVAL_SEC)
- 单设备(device_id + IP)每小时 5 次频控(发码 + 登录,enforce_rate_limit)

删除两道:
- 单号每日发送上限(SMS_DAILY_LIMIT_PER_PHONE)
- 登录接口纯 IP 20 次/分钟限流(rate_limit 依赖)

验证码安全底线保留(一次性使用 + 输错 SMS_MAX_VERIFY_ATTEMPTS 次即作废),
属验证码正确性、不算限流策略,不在精简范围。

顺带清理:sms.py 移除 _daily_count/_today 及 datetime 导入;config.py 删常量;
auth.py 清理变为无用的 Depends/rate_limit 导入;同步 docs/integrations/sms.md 与
docs/后端技术实现.md 的防刷说明(并修正 sms.md 里已过时的 /sms/send rate_limit 描述)。

影响:单号发码上限由 10 条/天放宽为仅受 60s 冷却约束;登录撞库防护改由设备频控
+ 验证码错误次数上限兜底。test_auth.py 相应用例已删除/更新,auth 测试全绿。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #109
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-03 15:28:32 +08:00
guke a563c1ca4b @ (#111)
docs/api目录文档分类和补全

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #111
2026-07-03 15:00:37 +08:00
chenshirui ee132aa93b 心跳掉线判定超时从10分钟调成1小时 (#107)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------

Co-authored-by: 陈世睿 <2839904623@qq.com>
Reviewed-on: #107
Co-authored-by: chenshirui <chenshirui@wonderable.ai>
Co-committed-by: chenshirui <chenshirui@wonderable.ai>
2026-07-02 22:09:54 +08:00
zhuzihao 4512b6ecac feat: 反馈加来源/场景/运营回复 + 提现类型筛选 (#105)
用户反馈后台:区分反馈类型(比价反馈/普通反馈)+ 审核可给用户留言;提现后台:按提现类型筛选。

- feedback 表加 source(profile/comparison)/scene/admin_reply + 迁移;提交接口 /api/v1/feedback
  接收 source/scene,来源判定显式 source 优先、否则据 scene 有无派生(有=comparison);
  /records 带回 scene + admin_reply。
- admin 反馈:列表加「反馈类型」筛选;采纳/拒绝支持存 admin_reply(给用户的回复,用户端可见);
  FeedbackOut 带出 source/scene/admin_reply。
- admin 提现:WithdrawOut 暴露 source,列表加「提现类型」筛选(coin_cash=福利页提现 / invite_cash=邀请提现)。
- 补 4 项测试:来源派生、回复存取、反馈按类型筛选、提现按类型筛选。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #105
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-02 19:50:14 +08:00
zhuzihao 4630fd017b feat(ad-revenue): 收益报表按一次比价/领券聚合(trace 合并)+ 微信昵称/签到时间修复 (#103)
- 报表主表改为「单次广告行为」:激励视频=一次观看一行;一次比价/领券=同一 trace_id(整场,
  无 trace 时兜底 ad_session_id)的多条发奖聚成一行,点开看逐条金币复算。中途跳转广告致浮层重弹、
  ad_session_id 变化也能按 trace 归一行。
- 信息流统一 Draw(业务已全切):新增 audit 的 feed_all scene,让「Draw 信息流」筛选覆盖历史误标的
  feed/NULL;聚合行 ad_type 统一 draw;_feed_rows 带出 trace_id 供聚合。
- 父行补 sub_rewards(组内逐条复算明细)、sub_count;row_revenue_yuan=该次发奖广告 eCPM 折算收益之和,
  仅供主表逐行展示、不进合计/趋势(避免与展示侧 total 重复计)。
- 用户 360 概览(AdminUserListItem)返回 wechat_nickname,供收益详情抽屉显示微信昵称。
- 金币记录:签到来自 coin_transaction(存北京 wall-clock),组装时转 UTC 与广告记录统一,修抽屉签到
  时间多 8 小时;签到窗口边界 +8h 对齐;顺带补 _window_conds 引用却从未定义的 _as_utc_naive
  (自定义区间会 NameError)。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #103
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-02 08:57:15 +08:00
wuqi 70c2349950 feat(migration): 添加合并 jd_cps_order_fields 和 coupon_session_origin_package 的迁移文件 (#102)
Reviewed-on: #102
Co-authored-by: wuqi <wuqi@wonderable.ai>
Co-committed-by: wuqi <wuqi@wonderable.ai>
2026-07-01 20:21:34 +08:00
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
232 changed files with 10093 additions and 691 deletions
+21 -1
View File
@@ -29,7 +29,7 @@ JG_REQUEST_TIMEOUT_SEC=15
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
HEARTBEAT_MONITOR_ENABLED=true
HEARTBEAT_TIMEOUT_MINUTES=10
HEARTBEAT_TIMEOUT_MINUTES=60
HEARTBEAT_SCAN_INTERVAL_SEC=60
# ===== 短信 (mock 模式) =====
@@ -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
+14 -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
@@ -45,3 +48,12 @@ secrets/*
# 运行日志(run.sh 输出, 不入库)
*.log
logs/
# Claude Code 自动持久化的权限 allowlist / 个人本地设置(会话专属,不入库)。
# 需要团队共享的 Claude 配置(commands/ 等)可单独 git add -f,不受此忽略影响。
.claude/settings.json
.claude/settings.local.json
tests/meituan_coupon_bj.tsv
tests/meituan_coupon_data.tsv
tests/meituan_coupon_fz.tsv
tests/meituan_coupon_xm.tsv
@@ -0,0 +1,26 @@
"""merge jd_cps_order_fields and coupon_session_origin_package heads
Revision ID: 761ef181ce7c
Revises: coupon_session_origin_package, jd_cps_order_fields
Create Date: 2026-07-01 13:52:16.068808
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '761ef181ce7c'
down_revision: Union[str, Sequence[str], None] = ('coupon_session_origin_package', 'jd_cps_order_fields')
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")
+69
View File
@@ -0,0 +1,69 @@
"""admin_role 表(RBAC 角色 → 可见页面)+ 种子内建角色
新增后台 RBAC:角色持有一组页面 key,登录后左侧只展示这些页。super_admin 内建全权(pages 存空、
effective_pages 特判为全部)、不可编辑删除;operator/finance 播默认页集,可由 super_admin 增删改。
admin_user.role 弱引用本表 name。全新环境顺序应用即得三条内建角色。
Revision ID: admin_role_table
Revises: comparison_product_names
Create Date: 2026-07-04 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "admin_role_table"
down_revision: str | Sequence[str] | None = "comparison_product_names"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
# 与 app/admin/permissions.py 的 BUILTIN_ROLES 同口径(迁移内联一份,不耦合 app 常量)。
# 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES。key 承重(require_role),label 为展示名。
_BUILTIN = [
{"name": "super_admin", "label": "管理员", "pages": [], "is_builtin": True},
{"name": "operator", "label": "运营", "pages": [
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
"cps", "device-liveness", "price-reports", "feedbacks",
], "is_builtin": False},
{"name": "finance", "label": "财务", "pages": [
"dashboard", "ad-revenue-report", "cps", "withdraws",
], "is_builtin": False},
{"name": "tech", "label": "技术", "pages": [
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
], "is_builtin": False},
]
def upgrade() -> None:
op.create_table(
"admin_role",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("name", sa.String(length=32), nullable=False),
sa.Column("label", sa.String(length=32), nullable=False, server_default=""),
sa.Column("pages", _JSON, nullable=False),
sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
),
)
op.create_index("ix_admin_role_name", "admin_role", ["name"], unique=True)
tbl = sa.table(
"admin_role",
sa.column("name", sa.String),
sa.column("label", sa.String),
sa.column("pages", _JSON),
sa.column("is_builtin", sa.Boolean),
)
op.bulk_insert(tbl, _BUILTIN)
def downgrade() -> None:
op.drop_index("ix_admin_role_name", table_name="admin_role")
op.drop_table("admin_role")
@@ -0,0 +1,35 @@
"""admin_user 加 pages_override 列(「自定义」权限:按人存专属可见页 key 列表)
权限管理页新增「自定义」角色:选它时该成员的可见页不跟随任何共享角色,而由逐页勾选决定,
存这个人专属的一份页 key 列表。仅 role == "custom" 时有效;普通角色为 None(可见页跟随角色)。
PG 用 JSONB,SQLite 退化为通用 JSON(同 admin_audit_log.detail / comparison_record.raw_payload)。
Revision ID: admin_user_pages_override
Revises: admin_user_plain_password
Create Date: 2026-07-08 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
from alembic import op
revision: str = "admin_user_pages_override"
down_revision: str | Sequence[str] | None = "admin_user_plain_password"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# 与 app/models/admin.py 的 _JSON 一致:PG JSONB / 其它 JSON
_JSON = sa.JSON().with_variant(JSONB(), "postgresql")
def upgrade() -> None:
with op.batch_alter_table("admin_user", schema=None) as batch_op:
batch_op.add_column(sa.Column("pages_override", _JSON, nullable=True))
def downgrade() -> None:
with op.batch_alter_table("admin_user", schema=None) as batch_op:
batch_op.drop_column("pages_override")
@@ -0,0 +1,29 @@
"""admin_user 加 plain_password 列(明文登录密码,仅后台 UI 建/重置的账号留存)
权限管理页需能复看某成员已确定的登录密码转交本人。系统只存哈希无法反推,故对「后台 UI 创建/重置」
的管理员额外留存一份明文;脚本/起后台建的超管为 None(前端不显示密码)。旧账号无此列值 → 同样不显示。
Revision ID: admin_user_plain_password
Revises: admin_role_table
Create Date: 2026-07-04 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "admin_user_plain_password"
down_revision: str | Sequence[str] | None = "admin_role_table"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("admin_user", schema=None) as batch_op:
batch_op.add_column(sa.Column("plain_password", sa.String(length=128), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("admin_user", schema=None) as batch_op:
batch_op.drop_column("plain_password")
@@ -0,0 +1,70 @@
"""comparison_record 加 product_names 列(下单商品名派生串)+ 回填历史行
admin 比价记录页要把原「店/商品」一列拆成「店」+「商品」两列、并支持按商品名搜索。
items 是 JSON(SQLite 下 json.dumps ensure_ascii 会把中文转义存成 \\uXXXX,无法直接 CAST+LIKE
命中中文),故把商品名派生成普通文本列,跨库 LIKE 一致、可索引。
写路径(upsert_record / harvest_done)已同步派生;本迁移建列并从已存 items 回填历史行。
全新环境顺序应用即得空列 + 回填(表本为空,回填 no-op)。
Revision ID: comparison_product_names
Revises: comparison_record_trace_unique
Create Date: 2026-07-04 00:00:00.000000
"""
import json
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "comparison_product_names"
down_revision: str | Sequence[str] | None = "comparison_record_trace_unique"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _product_names(items) -> str | None:
"""items([{name,...}]) → 顿号分隔的商品名串(去重保序)。与 repositories.comparison
._product_names_from_items 同口径,迁移内联一份避免耦合 app 代码。"""
if isinstance(items, str): # SQLite: items 以 JSON 文本存,取回是 str
try:
items = json.loads(items)
except (ValueError, TypeError):
return None
if not isinstance(items, list) or not items:
return None
names: list[str] = []
for it in items:
name = it.get("name") if isinstance(it, dict) else None
if not name:
continue
s = str(name).strip()
if s and s not in names:
names.append(s)
joined = "".join(names)
return joined[:500] or None
def upgrade() -> None:
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
batch_op.add_column(sa.Column("product_names", sa.String(length=512), nullable=True))
# 回填:从已存 items 派生商品名。items 小(菜名列表),一次取回即可;只更新有商品名的行。
bind = op.get_bind()
rows = bind.execute(
sa.text("SELECT id, items FROM comparison_record")
).fetchall()
for rid, items in rows:
pn = _product_names(items)
if pn:
bind.execute(
sa.text("UPDATE comparison_record SET product_names = :pn WHERE id = :id"),
{"pn": pn, "id": rid},
)
def downgrade() -> None:
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
batch_op.drop_column("product_names")
@@ -0,0 +1,49 @@
"""comparison_record: 唯一键 (user_id,trace_id) → trace_id 单列 + user_id 可空
Revision ID: comparison_record_trace_unique
Revises: feedback_type_reply
Create Date: 2026-07-03 12:00:00.000000
比价记录改「后端 harvest」:app-server 在帧0(pricebot 出 trace_id)即建行,随 done/finalize
逐步补全,客户端不再 POST 记录。故本表要两处调整:
1. user_id 改**可空**——harvest 建行那刻(软鉴权 / 老客户端匿名)可能还没有 user_id。
2. 唯一键 (user_id, trace_id) → **trace_id 单列**——trace_id 由 app-server 签发、全局唯一,
一次比价一行;harvest 建行时 user_id 还没有,不能再用复合键去重。
⚠️ 上线前(QA)务必确认 comparison_record 无重复 trace_id:旧复合唯一键**允许**同 trace_id
跨不同 user_id(实际上客户端 UUID 从不重复,但约束没拦),若真有重复,建 trace 单列唯一会失败。
查: SELECT trace_id, COUNT(*) c FROM comparison_record GROUP BY trace_id HAVING c > 1;
SQLite 不支持直接 drop/alter 约束/列,用 batch_alter_table(建临时表+拷数据+换名),
与 coupon_engage_per_package / store_mapping_* 同款;Postgres 直接执行原生 ALTER。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'comparison_record_trace_unique'
down_revision: Union[str, Sequence[str], None] = 'feedback_type_reply'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
# harvest 帧0 建行时 user_id 可能暂缺 → 放开非空。
batch_op.alter_column('user_id', existing_type=sa.Integer(), nullable=True)
# 复合唯一 → trace_id 单列唯一。
batch_op.drop_constraint('uq_comparison_user_trace', type_='unique')
batch_op.create_unique_constraint('uq_comparison_trace', ['trace_id'])
def downgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.drop_constraint('uq_comparison_trace', type_='unique')
batch_op.create_unique_constraint(
'uq_comparison_user_trace', ['user_id', 'trace_id']
)
# 回退非空前提是当时无 null user_id 行(harvest 期可能有孤儿行,回滚需先清理)。
batch_op.alter_column('user_id', existing_type=sa.Integer(), nullable=False)
@@ -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")
+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")
+48
View File
@@ -0,0 +1,48 @@
"""feedback 加反馈来源/场景/运营回复(source / scene / admin_reply)
admin「用户反馈」页要展示并筛选「反馈类型」(比价反馈 / 普通反馈),并支持审核时给用户留言:
- source: 反馈来源入口(profile=「我的」页 / comparison=比价结果页)。NOT NULL,
旧数据 + 普通反馈默认 profile(server_default)。加索引供 admin 按类型筛选。
- scene: 比价反馈的问题场景(找错商品/优惠不对…),普通反馈为 NULL。
- admin_reply: 运营给用户的回复留言(用户端可见),随「我的反馈」历史下发。
均为新增列(SQLite 原生支持 add_column);downgrade 的 drop_column 在 SQLite 走 batch 兜底。
Revision ID: feedback_type_reply
Revises: 761ef181ce7c
Create Date: 2026-07-02 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "feedback_type_reply"
down_revision: str | Sequence[str] | None = "761ef181ce7c"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"feedback",
sa.Column(
"source",
sa.String(length=16),
nullable=False,
server_default="profile",
),
)
op.add_column("feedback", sa.Column("scene", sa.String(length=32), nullable=True))
op.add_column("feedback", sa.Column("admin_reply", sa.String(length=256), nullable=True))
op.create_index("ix_feedback_source", "feedback", ["source"])
def downgrade() -> None:
with op.batch_alter_table("feedback") as batch_op:
batch_op.drop_index("ix_feedback_source")
batch_op.drop_column("admin_reply")
batch_op.drop_column("scene")
batch_op.drop_column("source")
+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,6 +21,7 @@ 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
@@ -31,6 +32,7 @@ from app.admin.routers.feedback_qr import router as feedback_qr_router
from app.admin.routers.onboarding import router as onboarding_router
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
from app.admin.routers.price_report import router as price_report_router
from app.admin.routers.roles import router as roles_router
from app.admin.routers.users import router as users_router
from app.admin.routers.wallet import router as wallet_router
from app.admin.routers.withdraw import router as withdraw_router
@@ -97,10 +99,12 @@ 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(roles_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)
+83
View File
@@ -0,0 +1,83 @@
"""admin 后台「页面权限目录」—— RBAC 的权限侧单一真源。
一个权限 = 一个页面(= 左侧导航项)。角色持有一组 page key,登录后左侧只展示这些页(见前端
layout.tsx 按 pages 过滤导航)。key 必须与前端路由一级对齐(/dashboard → "dashboard")。
super_admin 为内建全权角色,恒可见全部页(effective_pages 特判)。新增页面 = 这里加一条 +
前端导航加对应项(key 一致即可被各角色勾选可见)。
"""
from __future__ import annotations
SUPER_ADMIN_ROLE = "super_admin"
# 「自定义」哨兵角色:不是 admin_role 表里的行,而是标记「这个人的可见页由 pages_override 决定」。
# admin_user.role == CUSTOM_ROLE 时,有效可见页取 admin_user.pages_override(见 auth._admin_out_with_pages)。
CUSTOM_ROLE = "custom"
# 分组镜像前端导航(app/(main)/layout.tsx 的 NAV_GROUPS);key = 路由一级
PERMISSION_CATALOG: list[dict] = [
{"group": "看板", "pages": [
{"key": "dashboard", "label": "数据大盘"},
{"key": "coupon-data", "label": "领券数据"},
{"key": "ad-revenue-report", "label": "广告收益"},
{"key": "comparison-records", "label": "比价记录"},
{"key": "cps", "label": "CPS收益"},
{"key": "device-liveness", "label": "设备存活"},
]},
{"group": "奖励审核", "pages": [
{"key": "withdraws", "label": "提现审核"},
{"key": "price-reports", "label": "低价审核"},
{"key": "feedbacks", "label": "用户反馈"},
]},
{"group": "数据配置", "pages": [
{"key": "config", "label": "系统配置"},
{"key": "ad-revenue", "label": "广告配置"},
{"key": "users", "label": "用户管理"},
]},
{"group": "其他", "pages": [
{"key": "admins", "label": "权限管理"},
{"key": "event-logs", "label": "埋点日志"},
{"key": "audit-logs", "label": "审计日志"},
]},
]
# 全部页面 key(super_admin 有效可见 = 此全集;也用于校验角色 pages 合法性)
ALL_PAGE_KEYS: list[str] = [p["key"] for g in PERMISSION_CATALOG for p in g["pages"]]
_ALL_SET = set(ALL_PAGE_KEYS)
# 内建角色定义(种子 / ensure 兜底的单一真源):(key, 中文展示名, 默认可见页)。
# 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES;super_admin 恒全权(pages 空、bypass)。
# key 承重(require_role / admin_user.role),勿改;label 为 UI 展示名。
BUILTIN_ROLES: list[dict] = [
{"name": SUPER_ADMIN_ROLE, "label": "管理员", "pages": []},
{"name": "operator", "label": "运营", "pages": [
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
"cps", "device-liveness", "price-reports", "feedbacks",
]},
{"name": "finance", "label": "财务", "pages": [
"dashboard", "ad-revenue-report", "cps", "withdraws",
]},
{"name": "tech", "label": "技术", "pages": [
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
]},
]
# 内建 key → 中文展示名(展示 / 自愈用)
BUILTIN_LABELS: dict[str, str] = {r["name"]: r["label"] for r in BUILTIN_ROLES}
def sanitize_pages(pages: list[str] | None) -> list[str]:
"""过滤掉不在目录里的 key(去重、保序),防脏数据/目录收缩后的悬空 key。"""
seen: set[str] = set()
out: list[str] = []
for k in pages or []:
if k in _ALL_SET and k not in seen:
seen.add(k)
out.append(k)
return out
def effective_pages(role_name: str, role_pages: list[str] | None) -> list[str]:
"""某角色的有效可见页:super_admin → 全部;其余 → 其 pages 与目录取交(自愈悬空 key)。"""
if role_name == SUPER_ADMIN_ROLE:
return list(ALL_PAGE_KEYS)
return sanitize_pages(role_pages)
+7 -1
View File
@@ -136,12 +136,16 @@ 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=="feed_all":所有信息流(feed/draw/NULL 都要)——业务已全切 Draw 信息流,收益报表把「Draw 信息流」
当作整个信息流口径(含历史误标 feed/NULL),用它避免筛选漏历史。
- scene 为 None:不筛(两类都要)。
"""
if scene == "feed":
return rec.ad_type in (None, "feed")
if scene == "draw":
return rec.ad_type == "draw"
if scene == "feed_all":
return True
return True
@@ -184,6 +188,7 @@ def _feed_rows(
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
"trace_id": rec.trace_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
@@ -209,6 +214,7 @@ def _feed_rows(
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
"trace_id": rec.trace_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
@@ -241,7 +247,7 @@ def audit_rows(
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", "draw"):
if scene in (None, "feed", "draw", "feed_all"):
rows.extend(_feed_rows(db, date=date, user_id=user_id, scene=scene))
return rows
+172 -41
View File
@@ -3,9 +3,11 @@
只读。每行 = 一次广告事件(不再按用户聚合):
- **激励视频**:一次观看 = 1 条展示(ad_ecpm)+ 1 条发奖(ad_reward),按 ad_session_id 合并成一行,
直接给出 eCPM / 收益 + 状态 / 应发 / 实发 / 一致;点开看该条金币复算因子。
- **信息流**:轮播每条展示各一行(impressionId 各自独立);整场发奖(ad_feed_reward,client_event_id)
与逐条展示无法对应,单独成「纯发奖」行。
- 兜底:有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)都各自成行。
- **信息流(比价/领券)**:一次比价 / 一次领券 = 一条整场发奖(ad_feed_reward)一行,给出 eCPM /
发奖金币 + 应发 / 实发 / 一致;点开看金币复算因子。⚠️ draw 的逐条展示(ad_ecpm,impressionId 各自
独立、与整场发奖无公共键、无法归到「哪一次」)**不再单独占行**(2026-07 按「一次比价/领券放一块」调整)——
其展示数 / eCPM / 预估收益仍进全量统计(合计 / 趋势 / 分类大盘 / 穿山甲对照),只是主表不逐条铺开。
- 兜底:激励视频有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)仍各自成行。
展示与收益来自 ad_ecpm_record(收益 = eCPM元 ÷ 1000);应发 / 实发金币复用金币审计逐条复算
(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计,
@@ -31,6 +33,7 @@ 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:
@@ -57,14 +60,6 @@ def _date_range(date_from: str, date_to: str) -> list[str]:
_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 = (
"record_id", "created_at", "status", "ecpm", "ecpm_factor", "units",
@@ -86,6 +81,7 @@ def ad_revenue_report(
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,
@@ -106,9 +102,14 @@ def ad_revenue_report(
# 同时保留全量列表,未被展示合并的成「纯发奖」事件。
reward_by_session: dict[tuple[int, str], list[dict]] = {}
all_reward_rows: list[dict] = []
# 报表 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
# 报表 ad_type audit scene:reward_video/feed 直传;**draw(前端「Draw 信息流」)映射成 feed_all**
# ——业务已全切 Draw,把「Draw 信息流」当作整个信息流口径(含历史误标 feed/NULL),否则筛选会漏历史
if ad_type == "draw":
audit_scene = "feed_all"
elif ad_type in _AUDIT_SCENES:
audit_scene = ad_type
else:
audit_scene = 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):
@@ -138,7 +139,10 @@ def ad_revenue_report(
)
if user_id is not None:
stmt = stmt.where(AdEcpmRecord.user_id == user_id)
if ad_type is not None:
if ad_type == "draw":
# draw = 所有信息流展示(业务已全 Draw,含历史误标 feed);展示行只进统计,不占主表行
stmt = stmt.where(AdEcpmRecord.ad_type.in_(["draw", "feed"]))
elif ad_type is not None:
stmt = stmt.where(AdEcpmRecord.ad_type == ad_type)
for rec in db.execute(stmt).scalars():
rwd = _pop_reward(rec.user_id, rec.ad_session_id)
@@ -155,10 +159,16 @@ 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,
"sub_rewards": [],
"sub_count": 1,
}
if rwd is not None:
ev.update({
@@ -178,33 +188,88 @@ def ad_revenue_report(
})
events.append(ev)
# 3) 未被展示合并的发奖行 → 「纯发奖」事件(信息流整场发奖 / 有发奖无展示)。
# 收益恒 0(收益只算展示侧,避免与展示行重复计)。
# 3) 未被展示合并的发奖行 → 事件:
# - 激励视频(reward_video):逐条成「纯发奖」事件(每次一个 ad_session_id;有发奖无展示等)。
# - 信息流(feed/draw):同一次比价/领券的多条广告共享**整场 ad_session_id**(客户端整场复用),
# 按 (user_id, ad_session_id) 聚成**一次比价 / 一次领券**父事件;sub_rewards 为组内逐条明细,
# 应发/实发取组内合计;业务已全 Draw → 类型统一 "draw"。session 缺失(极少旧数据)各自单独成组。
feed_groups: dict[tuple[int, str], list[dict]] = {}
for row in all_reward_rows:
if row["record_id"] in used_reward_ids:
continue
if row["scene"] == "reward_video":
events.append({
"event_key": f"rwd-{row['record_id']}",
"report_date": row["_report_date"],
"user_id": row["user_id"],
"ad_type": "reward_video",
"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"],
"hour": _cn_hour(row["created_at"]) if by_hour else None,
"has_impression": False,
"impressions": 0,
"ecpm": row["ecpm"],
"revenue_yuan": 0.0,
"adn": None,
"slot_id": None,
"has_reward": True,
"status": row["status"],
"expected_coin": int(row["expected_coin"]),
"actual_coin": int(row["actual_coin"]),
"matched": bool(row["matched"]),
"reward_detail": _reward_detail(row),
"sub_rewards": [],
"sub_count": 1,
})
else:
# 聚合单位 = 一次完整比价/领券流程:优先用 trace_id(比价带 comparisonTraceId、领券带 sessionTraceId,
# 整个流程不变;即使中途点广告致浮层关闭重弹、ad_session_id 变了,trace_id 仍不变 → 全流程聚成一行)。
# 无 trace_id(历史领券未上报 / 旧数据)回退整场 ad_session_id;再无则 record_id 各自成组、不误并。
grp_key = row.get("trace_id") or row.get("ad_session_id") or f"_rid-{row['record_id']}"
feed_groups.setdefault((row["user_id"], grp_key), []).append(row)
# 信息流分组 → 「一次比价 / 一次领券」父事件(收益恒 0:收益只算展示侧,避免与展示行重复计)。
for (uid, grp_key), group in feed_groups.items():
group.sort(key=lambda r: (r["created_at"], r["record_id"]))
rep = group[-1] # 代表条(最新一条):时间/场景/应用/代码位取它
expected_sum = sum(int(g["expected_coin"]) for g in group)
actual_sum = sum(int(g["actual_coin"]) for g in group)
# 父行 eCPM:组内各条 eCPM(分)均值(展示用,各条不同);无有效值则取代表条
ecpm_fens = [rewards.parse_ecpm_fen(g["ecpm"]) for g in group if g.get("ecpm")]
avg_ecpm = str(round(sum(ecpm_fens) / len(ecpm_fens))) if ecpm_fens else rep.get("ecpm")
# 主表逐行显示用:这次发奖广告的预估收益之和(发奖侧 eCPM 折算,钳顶同展示侧)。只放进
# row_revenue_yuan 给主表逐行展示,不进 revenue_yuan/合计/趋势——避免与展示侧 total 重复计。
row_revenue = round(sum(
min(rewards.parse_ecpm_yuan(g["ecpm"]), rewards.AD_ECPM_MAX_FEN / 100.0) / 1000.0
for g in group if g.get("ecpm")
), 6)
events.append({
"event_key": f"rwd-{row['record_id']}",
"report_date": row["_report_date"],
"user_id": row["user_id"],
"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"],
"hour": _cn_hour(row["created_at"]) if by_hour else None,
"event_key": f"feedgrp-{uid}-{grp_key}",
"report_date": rep["_report_date"],
"user_id": uid,
"ad_type": "draw", # 业务已全切 Draw 信息流,聚合行统一 draw
"feed_scene": rep.get("feed_scene"),
"app_env": rep.get("app_env"),
"our_code_id": rep.get("our_code_id"),
"created_at": rep["created_at"],
"hour": _cn_hour(rep["created_at"]) if by_hour else None,
"has_impression": False,
"impressions": 0,
"ecpm": row["ecpm"],
"ecpm": avg_ecpm,
"revenue_yuan": 0.0,
"row_revenue_yuan": row_revenue,
"adn": None,
"slot_id": None,
"has_reward": True,
"status": row["status"],
"expected_coin": int(row["expected_coin"]),
"actual_coin": int(row["actual_coin"]),
"matched": bool(row["matched"]),
"reward_detail": _reward_detail(row),
"status": rep["status"], # 代表状态(逐条见展开)
"expected_coin": expected_sum,
"actual_coin": actual_sum,
"matched": all(bool(g["matched"]) for g in group),
"reward_detail": None,
"sub_rewards": [_reward_detail(g) for g in group],
"sub_count": len(group),
})
# 「场景」作为全局筛选(与 user_id/ad_type 一致):同时作用于明细、合计与 daily/hourly 趋势。
@@ -212,6 +277,14 @@ def ad_revenue_report(
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":
@@ -255,6 +328,25 @@ 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] = []
@@ -293,22 +385,61 @@ def ad_revenue_report(
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
# 分场景小计(按 feed_scene:展示条数 + 预估收益),同 type_stats 基于全量 events——
# 供数据大盘「领券广告 / 比价广告」卡用。此前大盘是在分页 items 里按 feed_scene 现算,
# 2026-07-02 起信息流逐条展示行(唯一带收益 + 场景的行)不再进主表 items,现算恒为 0;
# 改为服务端在全量上聚合下发(也顺带不受 limit 分页截断影响)。feed_scene 为空(激励视频 /
# 旧数据)不计入任何场景桶。
scene_map: dict[str, dict] = {}
for e in events:
sc = e.get("feed_scene")
if not sc:
continue
s = scene_map.get(sc)
if s is None:
s = {"impressions": 0, "revenue_yuan": 0.0}
scene_map[sc] = s
s["impressions"] += e["impressions"]
s["revenue_yuan"] += e["revenue_yuan"]
scene_stats = {
k: {"impressions": v["impressions"], "revenue_yuan": round(v["revenue_yuan"], 6)}
for k, v in scene_map.items()
}
# DAU:复用数据大盘活跃用户口径(登录 + 开始比价 + 开始领券,按用户去重),按所选日期区间
# 统计(含今日),历史 / 多天区间同样有值。ARPU = 区间预估收益 ÷ 区间活跃用户。全局口径,
# 不随 user / ad_type / feed_scene / app_env 筛选变化(活跃用户口径无这些维度)。
dau = admin_stats.period_active_dau(
db, _date.fromisoformat(date_from), _date.fromisoformat(date_to)
)
# 主表「逐行」= 单次广告行为(2026-07 按「一次比价/领券放一块」聚合):激励视频 = 一次观看一行(展示+发奖
# 按 ad_session_id 合并);一次比价 / 一次领券 = 该次整场多条广告按 ad_session_id 聚成一行(展开看逐条)。
# 信息流(draw/feed)的逐条展示(ad_ecpm,impressionId 各自独立、与整场发奖无公共键)不再单独占行
# ——其展示数 / eCPM / 预估收益已计入上面的全量统计(total_*、daily / hourly、type_stats、穿山甲对照),
# 只是主表不逐条铺开;逐条明细在父行展开里看(sub_rewards)。合计 / 趋势 / 分类大盘均基于全量 events,
# 不受此过滤影响;total / 分页只作用于主表行。
main_rows = [
e for e in events
if not (e["ad_type"] in ("draw", "feed") and e["has_impression"] and not e["has_reward"])
]
return {
"total": len(events),
"truncated": len(events) > offset + limit,
"total": len(main_rows),
"truncated": len(main_rows) > 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,
"hourly": hourly,
"type_stats": type_stats,
"scene_stats": scene_stats,
"dau": dau,
"items": events[offset:offset + limit],
"items": main_rows[offset:offset + limit],
}
+81
View File
@@ -0,0 +1,81 @@
"""admin_role 表 CRUD + 「角色 → 有效可见页」解析。"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.admin.permissions import (
ALL_PAGE_KEYS,
BUILTIN_ROLES,
SUPER_ADMIN_ROLE,
sanitize_pages,
)
from app.models.admin_role import AdminRole
def ensure_builtin_roles(db: Session) -> None:
"""空表时播种内建角色(管理员/运营/财务/技术)。幂等:表非空即 no-op。
给 create_all 环境(测试 / 未跑迁移的全新库)兜底,迁移已播种则不重复。
仅 super_admin 为 is_builtin(锁定不可改删);运营/财务/技术可编辑页/删除。"""
if db.execute(select(AdminRole.id).limit(1)).first() is not None:
return
db.add_all([
AdminRole(
name=r["name"], label=r["label"], pages=list(r["pages"]),
is_builtin=(r["name"] == SUPER_ADMIN_ROLE),
)
for r in BUILTIN_ROLES
])
db.commit()
def list_roles(db: Session) -> list[AdminRole]:
ensure_builtin_roles(db)
# super_admin(内建)恒排最前,其余按创建序
return list(
db.execute(
select(AdminRole).order_by(AdminRole.is_builtin.desc(), AdminRole.id)
).scalars().all()
)
def get_role(db: Session, name: str) -> AdminRole | None:
return db.execute(
select(AdminRole).where(AdminRole.name == name)
).scalar_one_or_none()
def create_role(db: Session, *, name: str, label: str, pages: list[str]) -> AdminRole:
# 自定义角色:name(key)= label = 用户输入的名称
role = AdminRole(name=name, label=label, pages=sanitize_pages(pages), is_builtin=False)
db.add(role)
db.commit()
db.refresh(role)
return role
def update_role(
db: Session, role: AdminRole, *, label: str | None = None, pages: list[str] | None = None
) -> AdminRole:
# 只改展示名 label + 可见页;name(key)不可变(admin_user.role / require_role 承重)
if label is not None:
role.label = label
if pages is not None:
role.pages = sanitize_pages(pages)
db.commit()
db.refresh(role)
return role
def delete_role(db: Session, role: AdminRole) -> None:
db.delete(role)
db.commit()
def effective_pages_of(db: Session, role_name: str) -> list[str]:
"""某角色名的有效可见页:super_admin → 全部;其余 → 查表 pages 与目录取交。"""
if role_name == SUPER_ADMIN_ROLE:
return list(ALL_PAGE_KEYS)
ensure_builtin_roles(db)
role = get_role(db, role_name)
return sanitize_pages(role.pages if role else None)
+12 -1
View File
@@ -20,12 +20,23 @@ def get_by_username(db: Session, username: str) -> AdminUser | None:
def create_admin(
db: Session, *, username: str, password: str, role: str = "operator"
db: Session,
*,
username: str,
password: str,
role: str = "operator",
plain_password: str | None = None,
pages_override: list[str] | None = None,
) -> AdminUser:
"""建管理员。plain_password 非空则额外留存明文(后台 UI 建的账号传,供权限管理页复看);
脚本/起后台建账号不传(留 None → 前端不显示密码)。
pages_override:role=="custom" 时传专属可见页 key 列表;普通角色为 None。"""
admin = AdminUser(
username=username,
password_hash=hash_password(password),
role=role,
plain_password=plain_password,
pages_override=pages_override,
)
db.add(admin)
db.commit()
+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)}
+185 -4
View File
@@ -7,13 +7,14 @@ 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
@@ -24,6 +25,11 @@ 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))
@@ -45,12 +51,58 @@ def _yuan_to_cents(v: object) -> int | None:
def _ts_to_dt(ts: object) -> datetime | None:
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
if not ts:
if ts is None:
return None
if isinstance(ts, datetime):
return ts if ts.tzinfo else ts.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc)
s = str(ts).strip()
if not s or s.lower() == "null":
return None
try:
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
except (ValueError, OSError, TypeError):
seconds = float(Decimal(s))
except (InvalidOperation, ValueError):
return None
if seconds == 0:
return None
# 美团文档是秒级时间戳,这里顺手兼容毫秒/微秒,避免上游格式变化导致时间再次落空。
if abs(seconds) > 10_000_000_000_000:
seconds /= 1_000_000
elif abs(seconds) > 10_000_000_000:
seconds /= 1_000
try:
return datetime.fromtimestamp(seconds, tz=timezone.utc)
except (OverflowError, OSError, ValueError):
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
# ───────────── 群 ─────────────
@@ -249,6 +301,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 +400,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 +422,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,
+4 -1
View File
@@ -65,16 +65,19 @@ def review_feedback(
reward_coins: int | None = None,
reject_reason: str | None = None,
review_note: str | None = None,
admin_reply: str | None = None,
commit: bool = True,
) -> Feedback:
"""审核反馈:置 adopted/rejected + 记录奖励/原因/审核人/审核时间。
"""审核反馈:置 adopted/rejected + 记录奖励/原因/回复留言/审核人/审核时间。
发金币(wallet.grant_coins)由 router 在同一事务里调,确保状态、金币流水、审计一起提交。
admin_reply=给用户的回复留言(用户端可见),与 review_note(内部备注)区分。
"""
feedback.status = status
feedback.reward_coins = reward_coins
feedback.reject_reason = reject_reason
feedback.review_note = review_note
feedback.admin_reply = admin_reply
feedback.reviewed_by_admin_id = reviewed_by_admin_id
feedback.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None)
if commit:
+327 -48
View File
@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo
from sqlalchemy import Select, asc, case, desc, func, or_, select
from sqlalchemy.orm import Session
from app.admin.repositories.stats import COMPARE_START_EVENT, COUPON_START_EVENT
from app.core import rewards
from app.core.config import settings
from app.models.ad_feed_reward import AdFeedRewardRecord
@@ -18,12 +19,22 @@ 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.coupon_state import CouponPromptEngagement
from app.models.device import DeviceLiveness
from app.models.feedback import Feedback
from app.models.onboarding import OnboardingCompletion
from app.models.price_report import PriceReport
from app.models.user import User
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
from app.models.wallet import (
CashTransaction,
CoinAccount,
CoinTransaction,
InviteCashTransaction,
WithdrawOrder,
)
# 「最近活跃」计入的行为事件(与大盘 DAU/留存活跃口径一致:开始比价 + 开始领券)
_ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT)
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
@@ -76,6 +87,86 @@ def offset_paginate(
return items, next_cursor, total
def _last_active_parts():
"""「最近活跃」的两个按 user_id 预聚合派生表(最近开始比价/领券事件、最近领券发起)。
活跃口径与大盘 DAU/留存一致(2026-07-05 产品定:进入 App≈登录 last_login_at +
发起比价 real_compare_start + 发起领券 real_coupon_start/claim_started)。
用 LEFT JOIN 预聚合而非相关标量子查询:后者在 PG 上对 users 每行各跑一个 SubPlan
(排序键、range 筛选、offset_paginate 的 count 三处叠加),埋点表大了会拖垮列表接口;
预聚合借 analytics_event.event 索引只扫两类 start 事件,每次查询聚合一次。
"""
ev_agg = (
select(
AnalyticsEvent.user_id.label("user_id"),
func.max(AnalyticsEvent.created_at).label("last_at"),
)
.where(
AnalyticsEvent.user_id.is_not(None),
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
)
.group_by(AnalyticsEvent.user_id)
.subquery()
)
eng_agg = (
select(
CouponPromptEngagement.user_id.label("user_id"),
func.max(CouponPromptEngagement.created_at).label("last_at"),
)
.where(
CouponPromptEngagement.user_id.is_not(None),
CouponPromptEngagement.engage_type == "claim_started",
)
.group_by(CouponPromptEngagement.user_id)
.subquery()
)
return ev_agg, eng_agg
def _norm_utc(dt: datetime | None) -> datetime | None:
"""naive 视为 UTC 补 tzinfo(SQLite 读回 naive、PG 读回 aware,混着 max() 会 TypeError)。"""
if dt is None:
return None
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
def _attach_last_active(db: Session, users: list[User]) -> None:
"""给本页用户瞬态挂 last_active_at(非 DB 列,供 AdminUserListItem from_attributes 读)。
口径同 [_last_active_expr];按本页 user_id 批量两次 GROUP BY,防 N+1。
"""
uids = [u.id for u in users]
if not uids:
return
ev_map = dict(
db.execute(
select(AnalyticsEvent.user_id, func.max(AnalyticsEvent.created_at))
.where(
AnalyticsEvent.user_id.in_(uids),
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
)
.group_by(AnalyticsEvent.user_id)
).all()
)
eng_map = dict(
db.execute(
select(CouponPromptEngagement.user_id, func.max(CouponPromptEngagement.created_at))
.where(
CouponPromptEngagement.user_id.in_(uids),
CouponPromptEngagement.engage_type == "claim_started",
)
.group_by(CouponPromptEngagement.user_id)
).all()
)
for u in users:
candidates = [
_norm_utc(u.last_login_at),
_norm_utc(ev_map.get(u.id)),
_norm_utc(eng_map.get(u.id)),
]
u.last_active_at = max((c for c in candidates if c is not None), default=None)
def list_users(
db: Session,
*,
@@ -87,16 +178,34 @@ def list_users(
created_to: datetime | None = None,
last_login_from: datetime | None = None,
last_login_to: datetime | None = None,
last_active_from: datetime | None = None,
last_active_to: datetime | None = None,
sort_by: str = "id",
sort_order: str = "desc",
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[User], int | None, int]:
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选,
按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录·最近活跃
时间范围筛选,按 id·注册时间·最近登录·最近活跃排序;每页附带计算列 last_active_at
(口径见 [_last_active_expr])。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
stmt = select(User)
# 最近活跃 = max(最近登录, 最近行为事件, 最近领券发起)。PG 用 GREATEST;SQLite 标量 max()
# 任一参数 NULL 即返回 NULL,故 LEFT JOIN 未命中侧 coalesce 到 last_login_at 兜底
# (注册即登录,该列恒非空)。派生表 1:1(按 user_id 聚合),outerjoin 不会放大行数,
# offset_paginate 的 count 不受影响。
ev_agg, eng_agg = _last_active_parts()
greatest = func.greatest if db.get_bind().dialect.name == "postgresql" else func.max
last_active = greatest(
User.last_login_at,
func.coalesce(ev_agg.c.last_at, User.last_login_at),
func.coalesce(eng_agg.c.last_at, User.last_login_at),
)
stmt = (
select(User)
.outerjoin(ev_agg, ev_agg.c.user_id == User.id)
.outerjoin(eng_agg, eng_agg.c.user_id == User.id)
)
if phone:
stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配
if register_channel:
@@ -113,20 +222,30 @@ def list_users(
stmt = stmt.where(User.last_login_at >= _as_utc(last_login_from))
if last_login_to is not None:
stmt = stmt.where(User.last_login_at <= _as_utc(last_login_to))
if last_active_from is not None:
stmt = stmt.where(last_active >= _as_utc(last_active_from))
if last_active_to is not None:
stmt = stmt.where(last_active <= _as_utc(last_active_to))
sort_cols = {
"id": User.id,
"created_at": User.created_at,
"last_login_at": User.last_login_at,
"last_active_at": last_active,
}
sort_col = sort_cols.get(sort_by, User.id)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(User.id) if sort_order == "asc" else desc(User.id)
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
items, next_cursor, total = offset_paginate(
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
)
_attach_last_active(db, items)
return items, next_cursor, total
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
@@ -147,11 +266,14 @@ def list_comparison_records(
phone: str | None = None,
status: str | None = None,
business_type: str | None = None,
store: str | None = None,
product: str | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[ComparisonRecord], int | None, int]:
"""admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛,
offset 分页(创建时间倒序、id 兜底)。join User 取 phone/nickname 瞬态挂记录上。"""
store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。
join User 取 phone/nickname 瞬态挂记录上。"""
stmt = select(ComparisonRecord)
if user_id is not None:
stmt = stmt.where(ComparisonRecord.user_id == user_id)
@@ -165,6 +287,11 @@ def list_comparison_records(
stmt = stmt.where(ComparisonRecord.status == status)
if business_type:
stmt = stmt.where(ComparisonRecord.business_type == business_type)
if store:
stmt = stmt.where(ComparisonRecord.store_name.like(f"%{store}%"))
if product:
# 商品名搜 product_names 派生文本列(非 items JSON:SQLite 下 JSON 中文被转义无法直接 LIKE)。
stmt = stmt.where(ComparisonRecord.product_names.like(f"%{product}%"))
items, next_cursor, total = offset_paginate(
db, stmt,
(desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)),
@@ -389,6 +516,7 @@ def list_all_withdraw_orders(
*,
user_id: int | None = None,
status: str | None = None,
source: str | None = None,
keyword: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
@@ -408,6 +536,9 @@ def list_all_withdraw_orders(
stmt = stmt.where(WithdrawOrder.user_id == user_id)
if status:
stmt = stmt.where(WithdrawOrder.status == status)
# 提现类型:coin_cash(福利页提现)/ invite_cash(邀请提现);None=全部
if source:
stmt = stmt.where(WithdrawOrder.source == source)
kw = (keyword or "").strip()
if kw:
@@ -533,6 +664,7 @@ def list_feedbacks(
db: Session,
*,
status: str | None = None,
source: str | None = None,
user_id: int | None = None,
content: str | None = None,
created_from: datetime | None = None,
@@ -542,13 +674,15 @@ def list_feedbacks(
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[Feedback], int | None, int]:
"""反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。
**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间
数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),total 供页码分页。
created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
"""反馈工单列表。支持 状态 / 反馈类型(source) / 用户ID / 内容模糊 / 提交时间范围 筛选,
按 id·提交时间排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),
total 供页码分页。created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
stmt = select(Feedback)
if status:
stmt = stmt.where(Feedback.status == status)
if source:
stmt = stmt.where(Feedback.source == source)
if user_id is not None:
stmt = stmt.where(Feedback.user_id == user_id)
if content and content.strip():
@@ -562,7 +696,25 @@ 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)
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
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(
@@ -700,26 +852,19 @@ def withdraw_risk_flags(
return flags, score
def withdraw_ledger_check(db: Session) -> dict:
cash_balance_total = int(
db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one()
)
cash_txn_total = int(
db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one()
)
def _check_withdraw_ledger_side(
orders: list[WithdrawOrder], txns: list, *, withdraw_biz: str, refund_biz: str
) -> dict:
"""对某一本账(普通现金 / 邀请奖励金)做提现单 ↔ 流水的交叉校验。
orders = list(db.execute(select(WithdrawOrder)).scalars().all())
cash_txns = list(
db.execute(
select(CashTransaction).where(
CashTransaction.biz_type.in_(("withdraw", "withdraw_refund"))
)
).scalars().all()
)
withdraw_refs = {txn.ref_id for txn in cash_txns if txn.biz_type == "withdraw"}
orders 已按 source 过滤到本账;txns 是本账流水表里 withdraw_biz/refund_biz 两类流水。
规则:每单发起应有一条扣款流水(ref_id=out_bill_no);失败/拒绝单应有且仅一条退款流水;
非退款终态不应出现退款流水。四个计数全为 0 即本账自洽。
"""
withdraw_refs = {txn.ref_id for txn in txns if txn.biz_type == withdraw_biz}
refund_counts: dict[str, int] = {}
for txn in cash_txns:
if txn.biz_type == "withdraw_refund" and txn.ref_id:
for txn in txns:
if txn.biz_type == refund_biz and txn.ref_id:
refund_counts[txn.ref_id] = refund_counts.get(txn.ref_id, 0) + 1
missing_withdraw = 0
@@ -734,24 +879,95 @@ def withdraw_ledger_check(db: Session) -> dict:
if has_refund and order.status not in {"failed", "rejected"}:
refund_on_non_terminal += 1
duplicate_refund = sum(1 for count in refund_counts.values() if count > 1)
diff = cash_balance_total - cash_txn_total
return {
"missing_withdraw": missing_withdraw,
"missing_refund": missing_refund,
"duplicate_refund": sum(1 for count in refund_counts.values() if count > 1),
"refund_on_non_terminal": refund_on_non_terminal,
}
def withdraw_ledger_check(db: Session) -> dict:
"""现金账本校验:两本物理隔离的账各自对账(产品红线:coin_cash / invite_cash 不串)。
普通现金:CoinAccount.cash_balance_cents ↔ cash_transaction(withdraw/withdraw_refund);
邀请奖励金:CoinAccount.invite_cash_balance_cents ↔ invite_cash_transaction
(invite_withdraw/invite_withdraw_refund)。
提现单按 source 分流到对应账核对——邀请提现的流水写在 invite_cash_transaction 表,
绝不能拿去和普通现金流水比(否则每笔邀请提现单都会被误报「缺扣款/缺退款流水」)。
分流口径与 create_withdraw 一致:仅 source==invite_cash 走邀请账,其余(含历史空值)归普通现金。
"""
orders = list(db.execute(select(WithdrawOrder)).scalars().all())
coin_orders = [o for o in orders if o.source != "invite_cash"]
invite_orders = [o for o in orders if o.source == "invite_cash"]
# —— 普通现金账(coin_cash) ——
cash_balance_total = int(
db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one()
)
cash_txn_total = int(
db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one()
)
cash_txns = list(
db.execute(
select(CashTransaction).where(
CashTransaction.biz_type.in_(("withdraw", "withdraw_refund"))
)
).scalars().all()
)
coin = _check_withdraw_ledger_side(
coin_orders, cash_txns, withdraw_biz="withdraw", refund_biz="withdraw_refund"
)
cash_diff = cash_balance_total - cash_txn_total
# —— 邀请奖励金账(invite_cash,独立账户 + 独立流水表) ——
invite_balance_total = int(
db.execute(
select(func.coalesce(func.sum(CoinAccount.invite_cash_balance_cents), 0))
).scalar_one()
)
invite_txn_total = int(
db.execute(
select(func.coalesce(func.sum(InviteCashTransaction.amount_cents), 0))
).scalar_one()
)
invite_txns = list(
db.execute(
select(InviteCashTransaction).where(
InviteCashTransaction.biz_type.in_(("invite_withdraw", "invite_withdraw_refund"))
)
).scalars().all()
)
invite = _check_withdraw_ledger_side(
invite_orders, invite_txns,
withdraw_biz="invite_withdraw", refund_biz="invite_withdraw_refund",
)
invite_diff = invite_balance_total - invite_txn_total
ok = (
diff == 0
and missing_withdraw == 0
and missing_refund == 0
and duplicate_refund == 0
and refund_on_non_terminal == 0
cash_diff == 0
and invite_diff == 0
and all(v == 0 for v in coin.values())
and all(v == 0 for v in invite.values())
)
return {
"ok": ok,
# 普通现金账(coin_cash:金币兑换的现金)
"cash_balance_total_cents": cash_balance_total,
"cash_transaction_total_cents": cash_txn_total,
"balance_diff_cents": diff,
"missing_withdraw_txn_count": missing_withdraw,
"missing_refund_txn_count": missing_refund,
"duplicate_refund_txn_count": duplicate_refund,
"refund_txn_on_non_terminal_count": refund_on_non_terminal,
"balance_diff_cents": cash_diff,
"missing_withdraw_txn_count": coin["missing_withdraw"],
"missing_refund_txn_count": coin["missing_refund"],
"duplicate_refund_txn_count": coin["duplicate_refund"],
"refund_txn_on_non_terminal_count": coin["refund_on_non_terminal"],
# 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)
"invite_cash_balance_total_cents": invite_balance_total,
"invite_cash_transaction_total_cents": invite_txn_total,
"invite_balance_diff_cents": invite_diff,
"invite_missing_withdraw_txn_count": invite["missing_withdraw"],
"invite_missing_refund_txn_count": invite["missing_refund"],
"invite_duplicate_refund_txn_count": invite["duplicate_refund"],
"invite_refund_txn_on_non_terminal_count": invite["refund_on_non_terminal"],
}
@@ -769,6 +985,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(
@@ -786,6 +1003,12 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
}
def _as_utc_naive(value: datetime) -> datetime:
"""窗口入参 → UTC naive(= _as_utc 去时区),与库里按 naive UTC 存取的 created_at 同口径比较。
历史遗留:_window_conds 一直引用本函数却未定义(自定义区间会 NameError),此处补上。"""
return _as_utc(value).replace(tzinfo=None)
def _window_conds(col, date_from: datetime | None, date_to: datetime | None) -> list:
"""把 [date_from, date_to] 转成对 col(created_at)的过滤条件;都为 None = 全量(注册至今)。"""
conds = []
@@ -875,6 +1098,13 @@ def user_reward_stats(
}
def _cn_wall_to_utc(dt: datetime) -> datetime:
"""coin_transaction 存的是北京 wall-clock(naive,见 wallet.grant_coins「存北京 wall-clock」),转成 UTC naive,
与广告表(func.now() UTC)统一 —— 让本函数按同一绝对时刻排序、且前端 apiTime(把无时区时间当 UTC 再 +8 展示)
口径一致;否则签到会比实际多显示 8 小时(北京时间又被 +8)。"""
return dt.replace(tzinfo=rewards.CN_TZ).astimezone(timezone.utc).replace(tzinfo=None)
def user_coin_records(
db: Session,
user_id: int,
@@ -895,6 +1125,9 @@ def user_coin_records(
offset = max(cursor or 0, 0)
fetch = offset + limit + 1
rows: list[dict] = []
# coin_transaction 存北京 wall-clock(其余表存 UTC);签到窗口边界 +8h 对齐北京,过滤/计数才不偏移 8 小时
signin_from = date_from + timedelta(hours=8) if date_from is not None else None
signin_to = date_to + timedelta(hours=8) if date_to is not None else None
for rec in db.execute(
select(AdRewardRecord)
@@ -938,7 +1171,7 @@ def user_coin_records(
.where(
CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == "signin",
*_window_conds(CoinTransaction.created_at, date_from, date_to),
*_window_conds(CoinTransaction.created_at, signin_from, signin_to),
)
.order_by(CoinTransaction.created_at.desc())
.limit(fetch)
@@ -946,7 +1179,8 @@ def user_coin_records(
rows.append({
"source": "signin",
"source_label": "签到",
"created_at": rec.created_at,
# 北京 wall-clock → UTC,与广告记录统一(前端 apiTime 会 +8 回北京展示,不然签到会多 8 小时)
"created_at": _cn_wall_to_utc(rec.created_at),
"ecpm": None,
"coin": rec.amount,
})
@@ -972,27 +1206,72 @@ def user_coin_records(
+ _count(
CoinTransaction, CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == "signin",
*_window_conds(CoinTransaction.created_at, date_from, date_to),
*_window_conds(CoinTransaction.created_at, signin_from, signin_to),
)
)
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:
+277 -50
View File
@@ -5,16 +5,23 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
"""
from __future__ import annotations
from collections import Counter
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal, InvalidOperation
from sqlalchemy import func, select
from sqlalchemy import case, func, select
from sqlalchemy.orm import Session
from app.admin.repositories.coupon_data import _percentile
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.coupon_state import (
CouponClaimRecord,
CouponPromptEngagement,
CouponSession,
)
from app.models.cps_order import CpsOrder
from app.models.feedback import Feedback
from app.models.savings import SavingsRecord
@@ -23,11 +30,17 @@ 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")
REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward")
# 领券/比价奖励金币的真实来源是信息流广告发奖(ad_feed_reward_record,按 feed_scene 分场景);
# coin_transaction 里只有扁平的 feed_ad_reward、biz_type 不分 coupon/comparison,故这俩桶历史从未
# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。reward_video/
# ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)。
COUPON_REWARD_BIZ_TYPES = ("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 = (
*REWARD_VIDEO_BIZ_TYPES,
*COUPON_REWARD_BIZ_TYPES,
*COMPARISON_REWARD_BIZ_TYPES,
*EXCLUDED_REWARD_BIZ_TYPES,
@@ -35,6 +48,13 @@ REGULAR_TASK_EXCLUDED_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:
@@ -45,14 +65,12 @@ def _beijing_today_start_utc() -> datetime:
def today_dau(db: Session) -> int:
"""今日活跃用户数(DAU):北京时区今天 0 点后登录过(last_login_at)
"""今日活跃用户数(DAU):登录 + 开始比价 + 开始领券,按用户去重
广告收益报表复用这个函数;历史窗口 DAU dashboard_overview period 口径另算
= period_active_dau(今天, 今天);历史 / 多天窗口用 period_active_dau 传区间(广告收益报表复用)
"""
today_start = _beijing_today_start_utc()
return int(
db.execute(select(func.count(User.id)).where(User.last_login_at >= today_start)).scalar_one()
)
today_bj = datetime.now(_BEIJING).date()
return period_active_dau(db, today_bj, today_bj)
def _default_period_end() -> date:
@@ -91,6 +109,74 @@ def _date_range(date_from: date, date_to: date) -> list[date]:
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 _period_active_user_ids(
db: Session,
*,
start_utc: datetime,
end_utc: datetime,
period_from: date,
period_to: date,
) -> set[int]:
"""区间去重活跃用户 id 集合:登录(last_login_at)+ 开始比价(real_compare_start)+
开始领券(real_coupon_start / claim_started)
user / analytics_event UTC aware 边界 [start_utc, end_utc);coupon_prompt_engagement
engage_date 是北京自然日 date , [period_from, period_to] 闭区间今日 / 历史 / 多天通用
"""
login_user_ids = _id_set(
db,
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 = _id_set(
db,
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date >= period_from,
CouponPromptEngagement.engage_date <= period_to,
CouponPromptEngagement.engage_type == "claim_started",
),
)
return login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids
def period_active_dau(db: Session, date_from: date, date_to: date) -> int:
"""任意北京自然日区间 [date_from, date_to] 的去重活跃用户数。
与数据大盘 period.users.active 同口径(登录 + 开始比价 + 开始领券);广告收益报表按所选
日期区间(含今日)复用,ARPU = 区间预估收益 ÷ 本数全局口径,不随用户 / 类型 / 场景筛选变化
"""
period_from, period_to = _normalize_period(date_from, date_to)
start_utc, end_utc, _start_local, _end_local = _period_bounds(period_from, period_to)
return len(
_period_active_user_ids(
db,
start_utc=start_utc,
end_utc=end_utc,
period_from=period_from,
period_to=period_to,
)
)
def _commission_rate_percent(raw: str | None) -> Decimal | None:
"""美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%""""
if raw is None:
@@ -107,6 +193,11 @@ def _commission_rate_percent(raw: str | None) -> Decimal | 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:
@@ -213,26 +304,19 @@ def dashboard_overview(
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_user_ids = _user_id_set(
select(ComparisonRecord.user_id).where(*period_comparison_conds)
)
coupon_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_user_ids | coupon_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
period_active_user_ids = _period_active_user_ids(
db,
start_utc=start_utc,
end_utc=end_utc,
period_from=period_from,
period_to=period_to,
)
# 留存口径(2026-07-05 产品改):次日留存——窗口内每天 D,取 **D-1 日(前日)新增**用户,
# 统计其 D 日活跃(登录/开始比价/开始领券)比例,逐日累加。默认窗口=昨日单天,即
# 「前日新增用户的昨日留存」。原口径(窗口内新增∩窗口内活跃)在单日窗口下≈100% 无意义
# (注册即登录,当天新增必然当天活跃)。逐日 cohort 在下方 trend 循环内顺带累计。
retention_cohort_total = 0
retention_retained_total = 0
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(
@@ -242,27 +326,26 @@ def dashboard_overview(
ComparisonRecord.created_at >= day_start_local,
ComparisonRecord.created_at < day_end_local,
)
daily_login_user_ids = _user_id_set(
daily_active_user_ids = _period_active_user_ids(
db,
start_utc=day_start_utc,
end_utc=day_end_utc,
period_from=cur_date,
period_to=cur_date,
)
# 次日留存:cohort = 前一日(D-1)新增用户,留存 = 其中当日(D)活跃者(口径见上)。
cohort_ids = _user_id_set(
select(User.id).where(
User.last_login_at >= day_start_utc,
User.last_login_at < day_end_utc,
)
)
daily_compare_user_ids = _user_id_set(
select(ComparisonRecord.user_id).where(*daily_comparison_conds)
)
daily_coupon_user_ids = _user_id_set(
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date == cur_date,
CouponPromptEngagement.engage_type == "claim_started",
User.created_at >= day_start_utc - timedelta(days=1),
User.created_at < day_end_utc - timedelta(days=1),
)
)
retention_cohort_total += len(cohort_ids)
retention_retained_total += len(cohort_ids & daily_active_user_ids)
trend_points.append(
{
"date": cur_date,
"active_users": len(
daily_login_user_ids | daily_compare_user_ids | daily_coupon_user_ids
),
"active_users": len(daily_active_user_ids),
"new_users": _count(
User,
User.created_at >= day_start_utc,
@@ -271,6 +354,11 @@ def dashboard_overview(
"comparisons": _count(ComparisonRecord, *daily_comparison_conds),
}
)
period_retention_rate = (
round(retention_retained_total / retention_cohort_total, 4)
if retention_cohort_total
else None
)
period_coin_conds = (
CoinTransaction.created_at >= start_local,
@@ -280,7 +368,7 @@ def dashboard_overview(
period_reward_video_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
)
period_feed_ad_coin_total = _sum(
CoinTransaction.amount,
@@ -302,22 +390,36 @@ def dashboard_overview(
*period_coin_conds,
CoinTransaction.biz_type.like("task_%"),
)
# 领券/比价奖励金币 = biz_type 桶(历史空,兜底)+ 该场景信息流广告实发金币
# (ad_feed_reward_record.feed_scene,granted;reward_date 是北京日期串,与 period 同自然日窗口)。
period_coupon_reward_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
) + _sum(
AdFeedRewardRecord.coin,
AdFeedRewardRecord.status == "granted",
AdFeedRewardRecord.feed_scene == "coupon",
AdFeedRewardRecord.reward_date >= period_from.isoformat(),
AdFeedRewardRecord.reward_date <= period_to.isoformat(),
)
period_comparison_reward_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
) + _sum(
AdFeedRewardRecord.coin,
AdFeedRewardRecord.status == "granted",
AdFeedRewardRecord.feed_scene == "comparison",
AdFeedRewardRecord.reward_date >= period_from.isoformat(),
AdFeedRewardRecord.reward_date <= period_to.isoformat(),
)
period_regular_task_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
)
period_meituan_orders = list(
period_cps_orders = list(
db.execute(
select(CpsOrder).where(
CpsOrder.pay_time >= start_utc,
@@ -325,9 +427,17 @@ def dashboard_overview(
)
).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
@@ -346,6 +456,98 @@ def dashboard_overview(
else None
)
# ===== 领券核心数据(2026-07-05 产品新增)=====
# 数据源:coupon_session(一次领券一行,started_date 北京自然日)+ coupon_claim_record
# (一券/点位一天一条终态,claim_date 北京自然日)。点位与 session 不按 trace_id 关联——
# record_claims 更新路径不覆盖 trace_id(同设备同券同日重跑归第一次的 trace),按
# (device_id, 自然日) 桶关联才可靠;同桶多次发起共享同一份点位终态。
period_coupon_sessions = db.execute(
select(
CouponSession.device_id,
CouponSession.started_date,
CouponSession.status,
CouponSession.elapsed_ms,
).where(
CouponSession.started_date >= period_from,
CouponSession.started_date <= period_to,
# 只统计正式环境,同「领券数据」页默认口径(防 debug 包调试数据串台;
# 命中 ix_coupon_session_date_env)。点位表无 app_env 列,但点位指标只经
# 下方 prod session 触达的 (device, 日) 桶进入统计,随之收敛到 prod。
CouponSession.app_env == "prod",
)
).all()
coupon_started = len(period_coupon_sessions)
coupon_completed_elapsed = sorted(
s.elapsed_ms
for s in period_coupon_sessions
if s.status == "completed" and s.elapsed_ms is not None
)
# 点位桶:(device, 日) → (点位总数, 成功点位数)。成功口径与「我的」页累计领券一致
# (sum_claimed_count,2026-06-15 产品定):success + already_claimed(已领过=持有券)都算成功。
point_buckets: dict[tuple[str, date], tuple[int, int]] = {
(dev, d): (int(total), int(succ or 0))
for dev, d, total, succ in db.execute(
select(
CouponClaimRecord.device_id,
CouponClaimRecord.claim_date,
func.count(),
func.sum(
case(
(CouponClaimRecord.status.in_(("success", "already_claimed")), 1),
else_=0,
)
),
)
.where(
CouponClaimRecord.claim_date >= period_from,
# 上界放宽一天:跨零点场次(23:5x 发起)的点位 claim_date 落在发起日+1,
# 桶只经下方 session 触达的键参与计数,放宽不会引入无关数据。
CouponClaimRecord.claim_date <= period_to + timedelta(days=1),
)
.group_by(CouponClaimRecord.device_id, CouponClaimRecord.claim_date)
).all()
}
# 全部领成功的次数:completed 且其 (device, 日) 桶内点位全部成功(桶为空不算)。
coupon_all_success = 0
completed_bucket_totals: list[int] = []
session_bucket_keys: set[tuple[str, date]] = set()
for s in period_coupon_sessions:
key = (s.device_id, s.started_date)
if key not in point_buckets:
# 跨零点回退:发起日桶不存在(点位终态全部落在次日)时取 (device, 发起日+1)。
# 仅在发起日桶完全缺失时回退,避免抢占该设备次日 session 自己的桶。
next_key = (s.device_id, s.started_date + timedelta(days=1))
if next_key in point_buckets:
key = next_key
bucket = point_buckets.get(key)
if bucket is not None:
session_bucket_keys.add(key)
if s.status != "completed" or bucket is None:
continue
total, succ = bucket
completed_bucket_totals.append(total)
if total > 0 and succ == total:
coupon_all_success += 1
# 每次发起的应领点位数:取「完成过的领券」实际点位数的众数(done 帧会给所有点位终态,
# 完成场的点位数=当前配置的全量点位数;数据自校准,配置改点位数无需改代码)。本期无完成场
# 时给不出,点位成功率置空。
coupon_points_per_session = (
Counter(completed_bucket_totals).most_common(1)[0][0]
if completed_bucket_totals
else None
)
# 成功点位数:本期 session 触达过的 (device, 日) 桶内成功点位之和(桶级去重,同桶重试不重复计)。
coupon_point_success = sum(point_buckets[k][1] for k in session_bucket_keys)
# 点位成功率 = 成功点位数 / (发起数 × 应领点位数):中途退出未跑到的点位不产生记录,
# 但发起数×点位数把它们计入分母 → 视为失败,符合产品口径;重试会拉低该率(分母按次数计)。
coupon_point_success_rate = (
round(
min(1.0, coupon_point_success / (coupon_started * coupon_points_per_session)), 4
)
if coupon_started and coupon_points_per_session
else None
)
return {
"users": {
"total": _count(User),
@@ -361,7 +563,7 @@ def dashboard_overview(
"reward_video_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
),
"reward_video_watch_count": _count(
AdRewardRecord,
@@ -409,11 +611,13 @@ def dashboard_overview(
"users": {
"new": len(period_new_user_ids),
"active": len(period_active_user_ids),
"retained_new_users": len(period_retained_new_user_ids),
"retained_new_users": retention_retained_total,
"retention_cohort": retention_cohort_total,
"retention_rate": period_retention_rate,
"retention_note": (
"近似口径:登录(last_login_at)+已上报比价记录+领券claim_started;"
"尚不包含未完成上报的比价开始事件"
"次日留存:窗口内每天取前一日新增用户,统计其当日活跃"
"(登录/开始比价/开始领券,按用户去重)比例,逐日累加;"
"默认窗口=昨日,即前日新增用户的昨日留存"
),
},
"comparison": {
@@ -424,6 +628,17 @@ def dashboard_overview(
"average_duration_ms": period_avg_duration_ms,
"average_saved_cents": period_avg_saved_cents,
},
"coupon": {
"started": coupon_started,
"all_success": coupon_all_success,
"success_rate": (
round(coupon_all_success / coupon_started, 4) if coupon_started else None
),
"point_success": coupon_point_success,
"points_per_session": coupon_points_per_session,
"point_success_rate": coupon_point_success_rate,
"median_elapsed_ms": _percentile(coupon_completed_elapsed, 50),
},
"coins": {
"granted_total": _sum(CoinTransaction.amount, *period_coin_conds),
"reward_video_coin_total": period_reward_video_coin_total,
@@ -450,7 +665,7 @@ def dashboard_overview(
},
"cps": {
"available": True,
"note": "美团 CPS 读 cps_order 对账订单;淘宝/京东佣金暂空",
"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
@@ -459,5 +674,17 @@ def dashboard_overview(
"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),
},
}
+12 -1
View File
@@ -56,6 +56,13 @@ def get_ad_revenue_report(
"全局筛选,同时影响明细 / 合计 / 趋势"
),
] = None,
app_env: Annotated[
str | None,
Query(
description="prod(正式应用) / test(测试应用);不传=全部。"
"建议正式收益报表选 prod,避免测试应用的假 eCPM 污染收益合计/平均"
),
] = None,
granularity: Annotated[
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
] = "day",
@@ -75,7 +82,7 @@ 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, feed_scene=feed_scene,
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(
@@ -84,11 +91,15 @@ def get_ad_revenue_report(
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()},
scene_stats={k: AdRevenueTypeStat(**v) for k, v in result["scene_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"],
+74 -5
View File
@@ -5,6 +5,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
from app.admin.permissions import CUSTOM_ROLE, SUPER_ADMIN_ROLE, sanitize_pages
from app.admin.repositories import admin_role as role_repo
from app.admin.repositories import admin_user as admin_repo
from app.admin.schemas.admin import AdminCreateRequest, AdminUpdateRequest
from app.admin.schemas.auth import AdminOut
@@ -23,9 +25,31 @@ def _active_super_count(db: AdminDb) -> int:
)
@router.get("", response_model=list[AdminOut], summary="管理员列表")
def _validate_role(db: AdminDb, role: str) -> None:
"""角色必须是 super_admin / custom(自定义) / admin_role 表里已存在的角色,否则 400。"""
if role in (SUPER_ADMIN_ROLE, CUSTOM_ROLE):
return
role_repo.ensure_builtin_roles(db) # 空表(测试/全新库)兜底播种,再校验
if role_repo.get_role(db, role) is None:
raise HTTPException(status_code=400, detail=f"角色不存在: {role}")
def _clean_override(role: str, pages_override: list[str] | None) -> list[str] | None:
"""按最终角色算出该存的 pages_override:custom → 勾选集(过滤非法 key,可空列表);
custom None(切回普通角色即清空自定义页)"""
if role == CUSTOM_ROLE:
return sanitize_pages(pages_override)
return None
@router.get("", response_model=list[AdminOut], summary="管理员列表(含明文密码,super_admin 专属)")
def list_admins(db: AdminDb) -> list[AdminOut]:
return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)]
out: list[AdminOut] = []
for a in admin_repo.list_admins(db):
item = AdminOut.model_validate(a)
item.password = a.plain_password # 明文:仅本 super_admin 专属路由下发,供权限管理页复看
out.append(item)
return out
@router.post("", response_model=AdminOut, summary="创建管理员")
@@ -34,12 +58,17 @@ def create_admin(
) -> AdminOut:
if admin_repo.get_by_username(db, body.username) is not None:
raise HTTPException(status_code=409, detail="用户名已存在")
_validate_role(db, body.role)
override = _clean_override(body.role, body.pages_override)
new = admin_repo.create_admin(
db, username=body.username, password=body.password, role=body.role
db, username=body.username, password=body.password, role=body.role,
plain_password=body.password, # UI 建的账号留存明文,供权限管理页复看
pages_override=override,
)
write_audit(
db, admin, action="admin.create", target_type="admin", target_id=new.id,
detail={"username": new.username, "role": new.role}, ip=get_client_ip(request), commit=True,
detail={"username": new.username, "role": new.role, "pages_override": override},
ip=get_client_ip(request), commit=True,
)
return AdminOut.model_validate(new)
@@ -67,8 +96,12 @@ def update_admin(
if demotes_super and _active_super_count(db) <= 1:
raise HTTPException(status_code=400, detail="不能降级/禁用最后一个超级管理员")
if body.role is not None:
_validate_role(db, body.role)
changes: dict = {}
if body.role is not None and body.role != target.role:
role_changed = body.role is not None and body.role != target.role
if role_changed:
changes["role"] = {"before": target.role, "after": body.role}
target.role = body.role
if body.status is not None and body.status != target.status:
@@ -77,6 +110,18 @@ def update_admin(
if body.password is not None:
changes["password"] = "reset"
target.password_hash = hash_password(body.password)
target.plain_password = body.password # 同步留存明文,权限管理页复看保持一致
# 自定义可见页:按「最终角色」(target.role,已应用完角色变更)决定该存什么。
# - 切到/维持 custom 且传了 pages_override → 用勾选集;切到 custom 没传 → 清成空列表。
# - 切回普通角色 → 清空 override(_clean_override 返回 None)。
# - 角色没变、只传 pages_override(编辑现有 custom 用户的勾选)→ 也更新。
if role_changed or body.pages_override is not None:
new_override = _clean_override(target.role, body.pages_override)
if new_override != target.pages_override:
changes["pages_override"] = {"before": target.pages_override, "after": new_override}
target.pages_override = new_override
if not changes:
raise HTTPException(status_code=400, detail="无任何变更字段")
db.commit()
@@ -86,3 +131,27 @@ def update_admin(
detail=changes, ip=get_client_ip(request), commit=True,
)
return AdminOut.model_validate(target)
@router.delete("/{admin_id}", summary="删除管理员(带审计)")
def delete_admin(admin_id: int, request: Request, admin: CurrentAdmin, db: AdminDb) -> dict:
target = admin_repo.get_by_id(db, admin_id)
if target is None:
raise HTTPException(status_code=404, detail="管理员不存在")
if admin_id == admin.id:
raise HTTPException(status_code=400, detail="不能删除自己")
# 防自锁:删掉某个 active super_admin 前,确认后仍至少剩 1 个,否则进「零可用超管」死局。
if (
target.role == "super_admin"
and target.status == "active"
and _active_super_count(db) <= 1
):
raise HTTPException(status_code=400, detail="不能删除最后一个超级管理员")
username = target.username
db.delete(target)
db.commit()
write_audit(
db, admin, action="admin.delete", target_type="admin", target_id=admin_id,
detail={"username": username, "role": target.role}, ip=get_client_ip(request), commit=True,
)
return {"deleted": True}
+17 -4
View File
@@ -6,6 +6,8 @@ import logging
from fastapi import APIRouter, Depends, HTTPException
from app.admin.deps import AdminDb, CurrentAdmin
from app.admin.permissions import CUSTOM_ROLE, sanitize_pages
from app.admin.repositories import admin_role as role_repo
from app.admin.repositories import admin_user as admin_repo
from app.admin.schemas.auth import AdminLoginRequest, AdminLoginResponse, AdminOut
from app.admin.security import create_admin_token
@@ -17,6 +19,17 @@ logger = logging.getLogger("shagua.admin.auth")
router = APIRouter(prefix="/admin/api/auth", tags=["admin-auth"])
def _admin_out_with_pages(admin, db: AdminDb) -> AdminOut: # noqa: ANN001
"""AdminOut + 有效可见页(前端左侧导航按此过滤)。
role=="custom" 用这个人的 pages_override(按人自定义,过滤悬空 key);其余走角色解析"""
out = AdminOut.model_validate(admin)
if admin.role == CUSTOM_ROLE:
out.pages = sanitize_pages(admin.pages_override)
else:
out.pages = role_repo.effective_pages_of(db, admin.role)
return out
@router.post(
"/login",
response_model=AdminLoginResponse,
@@ -39,10 +52,10 @@ def login(req: AdminLoginRequest, db: AdminDb) -> AdminLoginResponse:
return AdminLoginResponse(
access_token=token,
expires_in=expires_in,
admin=AdminOut.model_validate(admin),
admin=_admin_out_with_pages(admin, db),
)
@router.get("/me", response_model=AdminOut, summary="当前管理员")
def me(admin: CurrentAdmin) -> AdminOut:
return AdminOut.model_validate(admin)
@router.get("/me", response_model=AdminOut, summary="当前管理员(含有效可见页)")
def me(admin: CurrentAdmin, db: AdminDb) -> AdminOut:
return _admin_out_with_pages(admin, db)
+4 -1
View File
@@ -32,12 +32,15 @@ def list_comparison_records(
phone: Annotated[str | None, Query(description="手机号前缀")] = None,
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
business_type: Annotated[str | None, Query()] = None,
store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None,
product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AdminComparisonListItem]:
items, next_cursor, total = queries.list_comparison_records(
db, user_id=user_id, phone=phone, status=status,
business_type=business_type, limit=limit, cursor=cursor,
business_type=business_type, store=store, product=product,
limit=limit, cursor=cursor,
)
return CursorPage(
items=[AdminComparisonListItem.model_validate(r) for r in items],
+7 -2
View File
@@ -55,9 +55,14 @@ def _item(db, key: str) -> ConfigItemOut:
raise HTTPException(status_code=404, detail="未知配置项")
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值")
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值(不含 hidden)")
def list_config(db: AdminDb) -> list[ConfigItemOut]:
return [ConfigItemOut(**item) for item in app_config.list_all(db)]
# hidden 项(已下线/由专用页管理,如福利页任务·里程碑·看广告调参、首页轮播数据源)不在本页渲染。
return [
ConfigItemOut(**item)
for item in app_config.list_all(db)
if not CONFIG_DEFS[item["key"]].get("hidden")
]
@router.patch("/{key}", response_model=ConfigItemOut, summary="改某项配置(带审计)")
+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"],
)
+48 -16
View File
@@ -1,7 +1,7 @@
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 联盟订单对账 + 统计。
平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)
淘宝/京东无 API 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"
淘宝暂未接 API 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"
/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可
"""
from __future__ import annotations
@@ -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
@@ -373,7 +374,22 @@ def _reconcile_range_to_ts(
return int(start_dt.timestamp()), int(end_dt.timestamp())
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账")
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"))],
@@ -382,26 +398,42 @@ def reconcile_orders(
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=2)] = 1,
query_time_type: Annotated[int, Query(ge=1, le=3)] = 1,
platform: Annotated[str, Query(pattern="^(all|meituan|jd)$")] = "all",
) -> CpsReconcileResult:
start_ts, end_ts = _reconcile_range_to_ts(
_parse_day(date_from, field="date_from"),
_parse_day(date_to, field="date_to"),
days,
)
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=start_ts,
end_time=end_ts,
query_time_type=query_time_type,
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={
"platform": platform,
"date_from": date_from,
"date_to": date_to,
"days": days,
+18 -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
@@ -31,6 +36,8 @@ def _ensure_pending(fb: Feedback) -> None:
def list_feedbacks(
db: AdminDb,
status: Annotated[str | None, Query()] = None,
# 反馈类型筛选:profile(普通反馈)/ comparison(比价反馈);None=全部
source: Annotated[str | None, Query(pattern="^(profile|comparison)$")] = None,
user_id: Annotated[int | None, Query()] = None,
content: Annotated[str | None, Query(max_length=100)] = None,
created_from: Annotated[datetime | None, Query()] = None,
@@ -43,6 +50,7 @@ def list_feedbacks(
items, next_cursor, total = queries.list_feedbacks(
db,
status=status,
source=source,
user_id=user_id,
content=content,
created_from=created_from,
@@ -59,6 +67,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,
@@ -91,6 +104,7 @@ def approve_feedback(
status="adopted",
reward_coins=payload.reward_coins,
review_note=payload.note,
admin_reply=payload.reply,
reviewed_by_admin_id=admin.id,
commit=False,
)
@@ -113,6 +127,7 @@ def approve_feedback(
"after": "adopted",
"reward_coins": payload.reward_coins,
"note": payload.note,
"reply": payload.reply,
},
ip=get_client_ip(request),
commit=False,
@@ -142,6 +157,7 @@ def reject_feedback(
status="rejected",
reject_reason=payload.reason,
review_note=payload.note,
admin_reply=payload.reply,
reviewed_by_admin_id=admin.id,
commit=False,
)
@@ -156,6 +172,7 @@ def reject_feedback(
"after": "rejected",
"reason": payload.reason,
"note": payload.note,
"reply": payload.reply,
},
ip=get_client_ip(request),
commit=False,
+59 -3
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
@@ -18,11 +19,17 @@ from app.admin.schemas.ops_marquee_seed import (
OpsMarqueeSeedCreate,
OpsMarqueeSeedOut,
OpsMarqueeSeedUpdate,
OpsRealRecordItem,
OpsRealRecordsOut,
OpsSavingsFeedPreviewOut,
)
from app.models.admin import AdminUser
from app.models.ops_marquee_seed import OpsMarqueeSeed
from app.repositories import ops_marquee
from app.repositories import app_config, ops_marquee
class MarqueeModeUpdate(BaseModel):
mode: str # mixed / real / seed
router = APIRouter(
prefix="/admin/api/marquee-seeds",
@@ -52,9 +59,58 @@ def list_seeds(db: AdminDb) -> list[OpsMarqueeSeedOut]:
def preview_feed(
db: AdminDb,
limit: Annotated[int, Query(ge=1, le=30)] = 8,
mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None,
) -> OpsSavingsFeedPreviewOut:
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。"""
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit))
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。
mode 显式指定则预览该模式(**不改持久化配置**,供前端切换开关时实时预览);不传则用当前持久化模式
"""
if mode is not None and mode not in ops_marquee.FEED_MODES:
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit, mode=mode))
@router.get("/real-records", response_model=OpsRealRecordsOut, summary="分页浏览当前模式下可展示的记录(审核用)")
def list_real_records(
db: AdminDb,
mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None,
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=50)] = 8,
) -> OpsRealRecordsOut:
"""分页列出**当前模式**下可在 app 轮播展示的全部记录(**不去重**):只真实=真实记录;只种子=各启用
种子按生成逻辑各出一行;混播=真实+种子 app 同口径洗牌+去连簇,固定种子翻页稳定能翻遍全部
item.user_id=0 表示种子行"""
if mode is not None and mode not in ops_marquee.FEED_MODES:
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
items, total = ops_marquee.list_real_records(db, mode=mode, offset=offset, limit=limit)
return OpsRealRecordsOut(items=[OpsRealRecordItem(**it) for it in items], total=total)
# 注:/mode 两个端点须在 /{seed_id} 之前注册,否则 PATCH /mode 会被 /{seed_id} 抢先按 id 解析。
@router.get("/mode", summary="首页轮播数据源模式(mixed/real/seed)")
def get_feed_mode(db: AdminDb) -> dict:
"""当前轮播取数模式:mixed=真实优先+种子补位(默认)/ real=只真实 / seed=只种子·合成。"""
return {"mode": ops_marquee.get_feed_mode(db)}
@router.patch("/mode", summary="改首页轮播数据源模式(带审计)")
def set_feed_mode(
body: MarqueeModeUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> dict:
if body.mode not in ops_marquee.FEED_MODES:
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
before = ops_marquee.get_feed_mode(db)
app_config.set_value(db, "marquee_feed_mode", body.mode, admin_id=admin.id, commit=False)
write_audit(
db, admin, action="ops_marquee_seed.set_mode", target_type="config",
target_id="marquee_feed_mode", detail={"before": before, "after": body.mode},
ip=get_client_ip(request), commit=False,
)
db.commit()
return {"mode": body.mode}
@router.post("", response_model=OpsMarqueeSeedOut, summary="新增轮播种子(带审计)")
+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],
+129
View File
@@ -0,0 +1,129 @@
"""admin RBAC 角色管理(仅 super_admin):列角色 / 权限目录 / 增删改角色。均写审计。
角色 = 一组可见页面( app/admin/permissions.py)角色登录后台后左侧只展示其 pages 对应导航项
只有 super_admin(内建全权角色)能进本组端点(dependencies=require_role() 无参 = super_admin)
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import func, select
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
from app.admin.permissions import (
PERMISSION_CATALOG,
SUPER_ADMIN_ROLE,
effective_pages,
sanitize_pages,
)
from app.admin.repositories import admin_role as role_repo
from app.admin.schemas.role import (
PermissionGroup,
RoleCreateRequest,
RoleOut,
RoleUpdateRequest,
)
from app.models.admin import AdminUser
from app.models.admin_role import AdminRole
router = APIRouter(
prefix="/admin/api/roles",
tags=["admin-roles"],
dependencies=[Depends(require_role())], # 无参 = 仅 super_admin
)
def _usage_counts(db: AdminDb) -> dict[str, int]:
rows = db.execute(
select(AdminUser.role, func.count(AdminUser.id)).group_by(AdminUser.role)
).all()
return {r: c for r, c in rows}
def _to_out(role: AdminRole, usage: dict[str, int]) -> RoleOut:
return RoleOut(
id=role.id,
name=role.name,
label=role.label or role.name,
pages=effective_pages(role.name, role.pages),
is_builtin=role.is_builtin,
in_use=usage.get(role.name, 0),
)
@router.get("", response_model=list[RoleOut], summary="角色列表(含可见页 + 使用数)")
def list_roles(db: AdminDb) -> list[RoleOut]:
usage = _usage_counts(db)
return [_to_out(r, usage) for r in role_repo.list_roles(db)]
@router.get("/catalog", response_model=list[PermissionGroup], summary="页面权限目录(分组)")
def get_catalog() -> list[PermissionGroup]:
return [PermissionGroup(**g) for g in PERMISSION_CATALOG]
@router.post("", response_model=RoleOut, summary="新增角色(带审计)")
def create_role(
body: RoleCreateRequest, request: Request, admin: CurrentAdmin, db: AdminDb
) -> RoleOut:
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="角色名称不能为空")
if name == SUPER_ADMIN_ROLE:
raise HTTPException(status_code=400, detail="super_admin 为内建角色,不能新建")
if role_repo.get_role(db, name) is not None:
raise HTTPException(status_code=409, detail="角色名称已存在")
# 自定义角色:name(key)= label = 输入名称
role = role_repo.create_role(db, name=name, label=name, pages=body.pages)
write_audit(
db, admin, action="role.create", target_type="role", target_id=str(role.id),
detail={"name": name, "pages": role.pages}, ip=get_client_ip(request), commit=True,
)
return _to_out(role, _usage_counts(db))
@router.patch("/{role_id}", response_model=RoleOut, summary="改角色(展示名/可见页,带审计)")
def update_role(
role_id: int, body: RoleUpdateRequest, request: Request, admin: CurrentAdmin, db: AdminDb
) -> RoleOut:
role = db.get(AdminRole, role_id)
if role is None:
raise HTTPException(status_code=404, detail="角色不存在")
if role.is_builtin:
raise HTTPException(status_code=400, detail="内建角色「管理员」不可编辑")
new_label = body.label.strip() if body.label is not None else None
changes: dict = {}
if new_label and new_label != role.label:
changes["label"] = {"before": role.label, "after": new_label}
if body.pages is not None:
changes["pages"] = {"before": role.pages, "after": sanitize_pages(body.pages)}
if not changes:
raise HTTPException(status_code=400, detail="无任何变更字段")
# 只改展示名 + 可见页;key(name)不可变,故无需级联 admin_user.role
role_repo.update_role(db, role, label=new_label, pages=body.pages)
write_audit(
db, admin, action="role.update", target_type="role", target_id=str(role_id),
detail=changes, ip=get_client_ip(request), commit=True,
)
return _to_out(role, _usage_counts(db))
@router.delete("/{role_id}", summary="删角色(带审计;内建/在用不可删)")
def delete_role(role_id: int, request: Request, admin: CurrentAdmin, db: AdminDb) -> dict:
role = db.get(AdminRole, role_id)
if role is None:
raise HTTPException(status_code=404, detail="角色不存在")
if role.is_builtin:
raise HTTPException(status_code=400, detail="内建角色不可删除")
used = _usage_counts(db).get(role.name, 0)
if used > 0:
raise HTTPException(status_code=400, detail=f"该角色仍有 {used} 名管理员在用,请先改派再删")
name = role.name
role_repo.delete_role(db, role)
write_audit(
db, admin, action="role.delete", target_type="role", target_id=str(role_id),
detail={"name": name}, ip=get_client_ip(request), commit=True,
)
return {"deleted": True}
+26 -12
View File
@@ -42,7 +42,12 @@ def list_users(
created_to: Annotated[datetime | None, Query()] = None,
last_login_from: Annotated[datetime | None, Query()] = None,
last_login_to: Annotated[datetime | None, Query()] = None,
sort_by: Annotated[str, Query(pattern="^(id|created_at|last_login_at)$")] = "id",
# 最近活跃(登录/发起比价/发起领券取最大,见 queries._last_active_expr)筛选与排序
last_active_from: Annotated[datetime | None, Query()] = None,
last_active_to: Annotated[datetime | None, Query()] = None,
sort_by: Annotated[
str, Query(pattern="^(id|created_at|last_login_at|last_active_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,
@@ -51,6 +56,7 @@ def list_users(
db, phone=phone, register_channel=register_channel, status=status,
nickname=nickname, created_from=created_from, created_to=created_to,
last_login_from=last_login_from, last_login_to=last_login_to,
last_active_from=last_active_from, last_active_to=last_active_to,
sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor,
)
return CursorPage(
@@ -210,22 +216,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 +246,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":
+3
View File
@@ -50,6 +50,8 @@ def list_withdraws(
db: AdminDb,
user_id: Annotated[int | None, Query()] = None,
status: Annotated[str | None, Query()] = None,
# 提现类型筛选:coin_cash(福利页提现)/ invite_cash(邀请提现);None=全部
source: Annotated[str | None, Query(pattern="^(coin_cash|invite_cash)$")] = None,
keyword: Annotated[str | None, Query(max_length=100)] = None,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
@@ -69,6 +71,7 @@ def list_withdraws(
db,
user_id=user_id,
status=status,
source=source,
keyword=keyword,
date_from=date_from,
date_to=date_to,
+44 -3
View File
@@ -44,7 +44,13 @@ class AdRevenueDaily(BaseModel):
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="当天实发金币合计")
@@ -88,6 +94,11 @@ class AdRevenueRow(BaseModel):
impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)")
ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值")
revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000;纯发奖行=0")
row_revenue_yuan: float | None = Field(
None,
description="主表逐行展示用的预估收益(元):一次比价/领券聚合行=该次发奖广告 eCPM 折算之和;"
"其它行为空(前端回退取 revenue_yuan)。不进合计/趋势,避免与展示侧重复计",
)
adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…);纯发奖行为空")
slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID);纯发奖行为空")
# ── 发奖侧 ──
@@ -100,6 +111,15 @@ class AdRevenueRow(BaseModel):
None,
description="发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);点行展开下钻用,纯展示为空",
)
sub_rewards: list[AdRevenueRecord] = Field(
default_factory=list,
description="一次比价/领券聚合行的组内逐条发奖明细(同一整场 ad_session_id 的多条广告);"
"点行展开渲染多行。激励视频/纯展示行为空(单条看 reward_detail)",
)
sub_count: int = Field(
1,
description="本行聚合的发奖条数:一次比价/领券=该次广告条数(≥1);激励视频/纯展示=1",
)
class AdRevenueReportOut(BaseModel):
@@ -116,14 +136,35 @@ class AdRevenueReportOut(BaseModel):
default_factory=dict,
description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘",
)
scene_stats: dict[str, AdRevenueTypeStat] = Field(
default_factory=dict,
description="按信息流场景(feed_scene)小计 {comparison/coupon/welfare: {impressions, revenue_yuan}};"
"全量统计(不受分页截断),供数据大盘「领券广告 / 比价广告」卡;feed_scene 为空的事件不计入",
)
dau: int | None = Field(
None,
description="今日活跃用户数(复用大盘口径,last_login_at);**仅查询=今日单天时有值**,历史/多天为 null",
description="所选日期区间的去重活跃用户数(口径同数据大盘 period.users.active:登录 + 开始比价 + "
"开始领券)。按 date_from~date_to 区间统计,含今日、近 7 天、近 30 天等任意区间;全局口径,"
"不随 user_id / ad_type / feed_scene / app_env 筛选变化",
)
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 说明全部按公式发放)")
+8 -4
View File
@@ -6,21 +6,25 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
_Role = Literal["super_admin", "finance", "operator"]
# 角色不再硬编码枚举:改为任意角色名(自定义角色由 admin_role 表管理),存在性在路由层校验。
class AdminCreateRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=64)
password: str = Field(..., min_length=8, max_length=72) # bcrypt ≤72 字节
role: _Role = "operator"
role: str = Field("operator", min_length=1, max_length=32)
# 仅当 role == "custom":这个人专属可见页 key 列表(逐页勾选结果)。其余角色不传/忽略。
pages_override: list[str] | None = None
class AdminUpdateRequest(BaseModel):
"""改角色 / 启用禁用 / 重置密码,字段都可选(只改传了的)。"""
"""改角色 / 启用禁用 / 重置密码 / 自定义可见页,字段都可选(只改传了的)。"""
role: _Role | None = None
role: str | None = Field(None, min_length=1, max_length=32)
status: Literal["active", "disabled"] | None = None
password: str | None = Field(None, min_length=8, max_length=72)
# 改成/更新「自定义」可见页;role 切回普通角色时后端会清空 override(见路由)。
pages_override: list[str] | None = None
class AdminAuditLogOut(BaseModel):
+9
View File
@@ -20,6 +20,15 @@ class AdminOut(BaseModel):
status: str
created_at: datetime
last_login_at: datetime | None = None
# 该管理员当前角色的有效可见页(= 左侧导航项 key);仅登录 / /me 填充,列表接口默认空。
# 前端据此过滤左侧导航(super_admin = 全部页;role=="custom" = pages_override)。见 permissions.py。
pages: list[str] = []
# 「自定义」可见页原始勾选集(role=="custom" 时非空);列表接口下发,供权限管理页编辑回填勾选。
# 普通角色为 None。from_attributes 自动从 ORM 列取。
pages_override: list[str] | None = None
# 明文登录密码:仅「管理员账号列表」(super_admin 专属路由)填充,供权限管理页编辑时复看;
# 无留存(脚本建的超管 / 旧账号)为 None → 前端不显示。登录 / /me 不下发(保持 None)。
password: str | None = None
class AdminLoginResponse(BaseModel):
+1
View File
@@ -23,6 +23,7 @@ class AdminComparisonListItem(BaseModel):
status: str
information: str | None = None
store_name: str | None = None
product_names: str | None = None # 下单商品名派生串(顿号分隔;「商品」列展示 + 商品搜索)
source_platform_name: str | None = None
best_platform_name: str | None = None
source_price_cents: int | None = 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
+27
View File
@@ -43,7 +43,10 @@ class DashboardComparison(BaseModel):
class DashboardPeriodUsers(BaseModel):
new: int
active: int
# 次日留存(2026-07-05 起):retained_new_users = 窗口内逐日「前一日新增且当日活跃」用户数之和,
# retention_cohort = 对应的前一日新增基数之和,retention_rate = 两者之比。
retained_new_users: int
retention_cohort: int = 0
retention_rate: float | None = None
retention_note: str
@@ -57,6 +60,24 @@ class DashboardPeriodComparison(BaseModel):
average_saved_cents: int | None = None
class DashboardPeriodCoupon(BaseModel):
"""领券核心数据(2026-07-05 产品新增)。点位=一张券(coupon_claim_record 一天一条终态);
成功口径 success+already_claimed(我的页累计领券一致)"""
started: int = 0
# 全部领成功的次数:completed 且当日该设备全部点位成功
all_success: int = 0
success_rate: float | None = None
# 本期 session 触达的点位中成功的条数(同设备同日去重)
point_success: int = 0
# 每次发起的应领点位数(本期完成场实际点位数的众数;无完成场为空)
points_per_session: int | None = None
# 点位成功率 = point_success / (started × points_per_session);未跑到的点位计入分母视为失败
point_success_rate: float | None = None
# 耗时中位数(仅 completed 的 elapsed_ms,同「领券数据」页口径)
median_elapsed_ms: int | None = None
class DashboardPeriodCoins(BaseModel):
granted_total: int
reward_video_coin_total: int = 0
@@ -85,6 +106,7 @@ class DashboardPeriod(BaseModel):
date_to: date
users: DashboardPeriodUsers
comparison: DashboardPeriodComparison
coupon: DashboardPeriodCoupon = DashboardPeriodCoupon()
coins: DashboardPeriodCoins
cash: DashboardPeriodCash
trend: list[DashboardTrendPoint] = []
@@ -103,6 +125,11 @@ class DashboardCps(BaseModel):
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):
+26 -1
View File
@@ -15,14 +15,28 @@ class FeedbackOut(BaseModel):
user_id: int
content: str
contact: str
# 反馈来源入口:profile(「我的」页)/ comparison(比价结果页)——admin 展示/筛选「反馈类型」
source: str = "profile"
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
scene: str | None = None
images: list[str] | None = None
status: str
reject_reason: str | None = None
reward_coins: int | None = None
review_note: str | None = None
# 运营给用户的回复留言(用户端可见,采纳/未采纳都可填)
admin_reply: str | None = None
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):
@@ -31,9 +45,20 @@ class FeedbackApproveRequest(BaseModel):
le=FEEDBACK_REWARD_MAX_COINS,
description="采纳后发放金币数",
)
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注")
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注(内部)")
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
class FeedbackRejectRequest(BaseModel):
reason: str = Field(min_length=1, max_length=256, description="未采纳原因,用户端可见")
note: str | None = Field(default=None, max_length=256, description="运营内部审核备注")
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
class FeedbackSummary(BaseModel):
"""审核台顶部各状态计数(pending 含历史 new 态)。"""
pending: int
adopted: int
rejected: int
total: int
+13
View File
@@ -62,3 +62,16 @@ class OpsSavingsFeedPreviewItem(BaseModel):
class OpsSavingsFeedPreviewOut(BaseModel):
"""运营预览:实际混播出来的 feed(真实记录会插队,与客户端一致)。"""
items: list[OpsSavingsFeedPreviewItem]
class OpsRealRecordItem(BaseModel):
masked_user: str # 脱敏后展示名(与 app 一致)
saved_amount_cents: int # 节省金额(分)
created_at: str # 比价记录时间(YYYY-MM-DD HH:MM)
user_id: int # 真实 user_id(供运营核对,不下发客户端)
class OpsRealRecordsOut(BaseModel):
"""分页浏览「全部可展示的真实记录」(success+省>0,不去重、稳定顺序)。"""
items: list[OpsRealRecordItem]
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):
+35
View File
@@ -0,0 +1,35 @@
"""admin RBAC 角色 + 权限目录 schemas。"""
from __future__ import annotations
from pydantic import BaseModel, Field
class PermissionPage(BaseModel):
key: str
label: str
class PermissionGroup(BaseModel):
group: str
pages: list[PermissionPage]
class RoleOut(BaseModel):
id: int
name: str # 角色 key(承重,不可变)
label: str # 展示名(UI 显示)
pages: list[str] # 有效可见页 key(super_admin = 全部页)
is_builtin: bool # 内建角色(super_admin):不可编辑/删除
in_use: int # 使用该角色的管理员数(删除前端提示 / 拦截用)
class RoleCreateRequest(BaseModel):
# 新增自定义角色:name 即用户输入的名称(同时作 key 与展示名)
name: str = Field(..., min_length=1, max_length=32)
pages: list[str] = Field(default_factory=list)
class RoleUpdateRequest(BaseModel):
# 只改展示名 + 可见页;key 不可变
label: str | None = Field(None, min_length=1, max_length=32)
pages: list[str] | None = None
+10
View File
@@ -17,8 +17,12 @@ class AdminUserListItem(BaseModel):
status: str
debug_trace_enabled: bool = False
wechat_openid: str | None = None
wechat_nickname: str | None = None
created_at: datetime
last_login_at: datetime
# 最近活跃 = max(最近登录, 最近发起比价, 最近发起领券);列表页由 queries._attach_last_active
# 瞬态挂上。其他复用本 schema 的入口(用户 360 等)没挂该属性 → None(前端显示 '-')。
last_active_at: datetime | None = None
class AdminUserOverview(BaseModel):
@@ -29,6 +33,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 +93,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)"
)
+11
View File
@@ -41,6 +41,8 @@ class WithdrawOrderOut(BaseModel):
user_id: int
out_bill_no: str
amount_cents: int
# 提现类型:coin_cash(福利页提现,金币兑换的现金)/ invite_cash(邀请提现,邀请奖励金)
source: str = "coin_cash"
user_name: str | None = None # 提现实名(审核核对 + 打款用)
status: str
wechat_state: str | None = None
@@ -128,6 +130,7 @@ class WithdrawBulkResult(BaseModel):
class WithdrawLedgerCheckOut(BaseModel):
ok: bool
# 普通现金账(coin_cash:金币兑换的现金)
cash_balance_total_cents: int
cash_transaction_total_cents: int
balance_diff_cents: int
@@ -135,6 +138,14 @@ class WithdrawLedgerCheckOut(BaseModel):
missing_refund_txn_count: int
duplicate_refund_txn_count: int
refund_txn_on_non_terminal_count: int
# 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)。默认 0 向后兼容。
invite_cash_balance_total_cents: int = 0
invite_cash_transaction_total_cents: int = 0
invite_balance_diff_cents: int = 0
invite_missing_withdraw_txn_count: int = 0
invite_missing_refund_txn_count: int = 0
invite_duplicate_refund_txn_count: int = 0
invite_refund_txn_on_non_terminal_count: int = 0
class WxpayHealthCheckOut(BaseModel):
+24
View File
@@ -54,5 +54,29 @@ def get_current_user(
return user
def get_current_user_optional(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
db: Annotated[Session, Depends(get_db)],
) -> User | None:
"""软鉴权:有合法 Bearer 就返回 user,否则(无 header / 无效 token / 用户禁用)一律返回
None,** raise**
比价 step / finalize 灰度期用:新客户端带 JWT 拿到 user_id 绑定 harvest ;
老客户端(不带 JWT) None,harvest user_id 暂空,由其后续 /compare/record 上报补齐
等新版覆盖率够高,再把这几条端点从软鉴权收紧成硬 get_current_user
"""
if credentials is None or credentials.scheme.lower() != "bearer":
return None
try:
payload = decode_token(credentials.credentials, expected_type="access")
user = db.get(User, int(payload["sub"]))
except (TokenError, KeyError, ValueError, TypeError):
return None
if user is None or user.status != "active":
return None
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
OptionalUser = Annotated[User | None, Depends(get_current_user_optional)]
DbSession = Annotated[Session, Depends(get_db)]
+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]
+6 -7
View File
@@ -12,11 +12,11 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, HTTPException, Request, status
from app.api.deps import CurrentUser, DbSession
from app.core import test_account
from app.core.ratelimit import enforce_rate_limit, rate_limit
from app.core.ratelimit import enforce_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
@@ -41,7 +41,7 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
SMS_LOGIN_MAX_PER_HOUR = 5
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
# 堵「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞 —— 那两道是单号维度,一机换号能绕开。
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5
@@ -100,8 +100,8 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。
# 补「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞(那两道是单号维度,一机换号能绕);设备维度按机器封顶,
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。与路由上 IP 维度(10次/分钟)互补。
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。
enforce_rate_limit(
request,
scope="sms-send-device",
@@ -125,7 +125,6 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
"/sms/login",
response_model=TokenWithUser,
summary="手机号+验证码登录",
dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限)
)
def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser:
# 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。
@@ -143,7 +142,7 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
return _login_response(user, onboarding_completed=False, force_onboarding=True)
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试。放在验证码校验
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破。与路由上 IP 维度的 sms-login 限流(同 IP)互补
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破(另有单码失败 SMS_MAX_VERIFY_ATTEMPTS 次即作废兜底)
# ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时
# 退化为该 IP 下所有空设备聚一桶,仍受限。
enforce_rate_limit(
+203 -60
View File
@@ -1,49 +1,114 @@
"""外卖比价业务透传端点。
"""外卖比价业务透传端点 + 后端 harvest 落库
把客户端 POST /api/v1/intent/recognize /api/v1/price/step 请求原样转发给
pricebot-backend /api/intent/recognize /api/price/stepMVP 阶段**不鉴权**
( coupon/step, docs/待办与技术债.md P1:device_id 透传区分设备,待补 JWT +
device_iduser_id 绑定后才能做用户级画像)
把客户端 POST /api/v1/intent/* /api/v1/price/step 转发给 pricebot,同时** app-server
在透传里直接落库比价记录**(不再靠客户端 POST /compare/record):
- 帧0(客户端首帧不带 trace_id) app-server uuid 签发 trace_id注入转发 body回给
客户端;并按 trace_id running (harvest_running)
- 最终 done 更新成 success/failed + 结果(harvest_done)发奖不在这里:#113 已把
邀请奖口径从比价移到实际下单(order.py)
- /trace/finalize(用户终止/Phase1 未识别, done) 更新成 cancelled/failed
(harvest_abort,**不降级 success**)
trace_url pricebot 响应**顶层 trace_url** (pricebot 每帧都带,见其 goal_engine.process_step)
- Phase 1 /intent/recognize:从源平台(淘宝闪购/美团/京东外卖)购物车页识别店名+菜品+
价格,一次性
- Phase 2 /price/step:多轮循环,在目标平台复现订单读到手价,直到 done
鉴权:软鉴权(OptionalUser)新客户端带 JWT user_id;老客户端不带 user_id 暂空,
由其后续 /compare/record 上报补(灰度期两条写路径按 trace_id reconcile,success 不被降级)
真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口是透传壳
电商(ecom)那两个端点 food MVP 暂不需要,以后放开 scene 时再加 /ecom/intent/recognize
/ecom/step 两行即可
pricebot 协议文档:
pricebot-backend/docs/main/02_api_protocol.md
真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口是透传壳 + 落库
pricebot 协议文档: pricebot-backend/docs/main/02_api_protocol.md
"""
from __future__ import annotations
import json
import logging
import time
import uuid
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.concurrency import run_in_threadpool
from app.api.deps import OptionalUser
from app.core.config import settings
from app.core.logging import trace_id_ctx
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 comparison as crud_compare
logger = logging.getLogger("shagua.compare")
router = APIRouter(prefix="/api/v1", tags=["compare"])
async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
"""把请求体原样转发给 pricebot-backend 的 {upstream_path}
# ============================================================
# harvest 落库(阻塞 SQLAlchemy → run_in_threadpool,独立 SessionLocal,不阻塞事件循环;
# 任何写库异常都吞掉、绝不连累比价透传返回 —— 同 coupon.py 现有 best-effort 写法)。
# ============================================================
coupon_step 同款透传壳:不鉴权不做 schema 校验,仅读 device_id/trace_id/step
打日志比价单帧是大上下文 / 逐帧 LLM,超时用 PRICEBOT_COMPARE_TIMEOUT_SEC(60s,
比领券的 30s )
def _harvest_running_blocking(
trace_id: str, user_id: int | None, business_type: str,
device_id: str | None, device_info: dict | None, trace_url: str | None,
) -> None:
with SessionLocal() as db:
crud_compare.harvest_running(
db, trace_id=trace_id, user_id=user_id, business_type=business_type,
device_id=device_id, device_info=device_info, trace_url=trace_url,
)
logger.info(
"harvest running row (user=%s)", user_id,
extra={"phase": "harvest_running", "status": "running", "user_id": user_id},
)
def _harvest_done_blocking(
trace_id: str, user_id: int | None, done_params: dict, business_type: str,
device_id: str | None, device_info: dict | None, trace_url: str | None,
) -> None:
with SessionLocal() as db:
rec, newly_success = crud_compare.harvest_done(
db, trace_id=trace_id, user_id=user_id, done_params=done_params,
business_type=business_type, device_id=device_id,
device_info=device_info, trace_url=trace_url,
)
logger.info(
"harvest done → %s saved=%s newly=%s", rec.status,
rec.saved_amount_cents, newly_success,
extra={"phase": "harvest_done", "status": rec.status,
"saved_cents": rec.saved_amount_cents,
"best_platform": rec.best_platform_id, "newly_success": newly_success,
"user_id": user_id},
)
# 不在此处发邀请奖:#113 已把发奖口径从「比价」移到「实际下单」(order.py),harvest
# 只记录比价、不发奖。否则比价先于下单 + try_reward 幂等闸会让奖落在「比价」这步,
# 架空 #113 的「下单才发奖」防刷意图(newly_success 仅留作日志观测)。
def _harvest_abort_blocking(
trace_id: str, status_hint: str, reason: str | None, trace_url: str | None,
) -> None:
with SessionLocal() as db:
rec = crud_compare.harvest_abort(
db, trace_id=trace_id, status=status_hint, reason=reason, trace_url=trace_url,
)
logger.info(
"harvest abort → %s", (rec.status if rec else "no-row"),
extra={"phase": "harvest_abort",
"status": (rec.status if rec else None), "reason": reason},
)
async def _forward(
request: Request, upstream_path: str, user, *, harvest_first_frame: bool = True,
) -> tuple[dict[str, Any], str, dict[str, Any]]:
"""透传给 pricebot 的 {upstream_path},并 mint trace_id + 首帧建 running 行。
返回 (resp_json, trace_id, meta)
- 客户端首帧不带 trace_id app-server uuid 签发写进转发 body回填进响应顶层
`trace_id`(客户端据此拿到后续帧都带上)带了(老客户端 / 后续帧) 原样用走原始 bytes 快路
- **本次 mint(=首帧)** running (idempotent);后续帧不再写库
"""
# 读原始字节,避免"反序列化→再序列化"的双重 JSON(省 ~一半透传 CPU,让 app-server
# 单 worker 也扛得住高并发)。只 json.loads 一次拿 trace_id 做亲和 + 打日志,转发时
# 直接发原始 bytes(content=raw),不重新 dumps。
raw = await request.body()
try:
meta = json.loads(raw)
@@ -52,26 +117,41 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
if not isinstance(meta, dict):
meta = {}
# 按 trace_id 一致性 hash 选 pricebot 实例(同一比价的所有帧落同一进程,内存维护 state)
base = pick_pricebot(meta.get("trace_id"))
trace_id = meta.get("trace_id")
minted = False
if not trace_id:
trace_id = str(uuid.uuid4())
meta["trace_id"] = trace_id
raw = json.dumps(meta).encode() # 仅首帧重新序列化(注入 trace_id);后续帧走原始 bytes
minted = True
# 请求级 trace_id 贯穿:此后本请求(含 run_in_threadpool 里的 harvest)每行日志自动带 trace_id
trace_id_ctx.set(trace_id)
base = pick_pricebot(trace_id)
url = f"{base.rstrip('/')}{upstream_path}"
timeout = settings.PRICEBOT_COMPARE_TIMEOUT_SEC
step = meta.get("step")
logger.info(
"compare %s device_id=%s trace_id=%s step=%s",
upstream_path,
meta.get("device_id"),
meta.get("trace_id"),
meta.get("step"),
"→ pricebot %s step=%s%s", upstream_path, step, " [mint]" if minted else "",
extra={"phase": "forward", "endpoint": upstream_path, "step": step,
"device_id": meta.get("device_id"), "minted": minted,
"query": meta.get("query"), "user_id": (user.id if user else None)},
)
t0 = time.monotonic()
try:
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)
logger.error(
"← pricebot %s 不可达: %s", upstream_path, e,
extra={"phase": "resp", "endpoint": upstream_path, "step": step,
"error": "upstream_unreachable"},
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"pricebot upstream unreachable: {e}",
@@ -79,49 +159,112 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
if resp.status_code >= 500:
logger.error(
"[pricebot] 5xx status=%d body=%s",
resp.status_code,
resp.text[:500],
"pricebot %s 5xx=%d", upstream_path, resp.status_code,
extra={"phase": "resp", "endpoint": upstream_path, "step": step,
"http_status": resp.status_code, "error": "upstream_5xx"},
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"pricebot upstream returned {resp.status_code}",
)
return resp.json()
resp_json = resp.json()
cost_ms = int((time.monotonic() - t0) * 1000)
if not isinstance(resp_json, dict):
return {}, trace_id, meta
# 回传 app-server 签发的 trace_id(新客户端首帧据此拿到,后续帧都带上它)
resp_json.setdefault("trace_id", trace_id)
_action = resp_json.get("action") or {}
logger.info(
"← pricebot %s cmd=%s cont=%s %dms", upstream_path,
_action.get("command"), resp_json.get("continue"), cost_ms,
extra={"phase": "resp", "endpoint": upstream_path, "step": step,
"command": _action.get("command"), "continue": resp_json.get("continue"),
"cost_ms": cost_ms},
)
# 首帧建 running 行(仅本次 mint;老客户端自带 trace_id → 不 mint → 由 done / 其 POST 建行)
if minted and harvest_first_frame:
try:
await run_in_threadpool(
_harvest_running_blocking, trace_id,
(user.id if user else None), "food",
meta.get("device_id"), meta.get("device_info"),
resp_json.get("trace_url"),
)
except Exception as e: # noqa: BLE001
logger.warning("harvest_running failed trace=%s: %s", trace_id, e)
return resp_json, trace_id, meta
@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传到 pricebot)")
async def intent_recognize(request: Request) -> dict[str, Any]:
return await _passthrough(request, "/api/intent/recognize")
@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传 + 建 running 行)")
async def intent_recognize(request: Request, user: OptionalUser) -> dict[str, Any]:
resp, _, _ = await _forward(request, "/api/intent/recognize", user)
return resp
@router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传到 pricebot, 仅淘宝源)")
async def intent_step(request: Request) -> dict[str, Any]:
# 多帧版意图识别(展开+滚动采集→提取): 循环调用直到 done(done 帧顶层带
# result+calibration)。目前仅淘宝源走这条, 其它源走上面单次 /intent/recognize。
return await _passthrough(request, "/api/intent/step")
@router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传, 仅淘宝源)")
async def intent_step(request: Request, user: OptionalUser) -> dict[str, Any]:
resp, _, _ = await _forward(request, "/api/intent/step", user)
return resp
@router.post(
"/intent/precoupon/step",
summary="外卖比价 Phase 0 意图识别前先用券 (透传到 pricebot, 仅美团源)",
)
async def intent_precoupon_step(request: Request) -> dict[str, Any]:
# 美团源平台『意图识别前先用券』多帧循环: 客户端在调 /intent/recognize 之前先循环
# 调本端点到 done(continue=false)。订单页底部有『点击使用X红包』就自动选最大免费券
# 用上, 已用券/无券则首帧秒过。与 /intent/step 同属 intent 域, 复用同一透传壳。
return await _passthrough(request, "/api/intent/precoupon/step")
@router.post("/intent/precoupon/step", summary="外卖比价 Phase 0 识别前先用券 (透传, 仅美团源)")
async def intent_precoupon_step(request: Request, user: OptionalUser) -> dict[str, Any]:
resp, _, _ = await _forward(request, "/api/intent/precoupon/step", user)
return resp
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)")
async def price_step(request: Request) -> dict[str, Any]:
return await _passthrough(request, "/api/price/step")
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传 + done 落库)")
async def price_step(request: Request, user: OptionalUser) -> dict[str, Any]:
resp, trace_id, meta = await _forward(request, "/api/price/step", user)
# 最终 done 帧(command=done 且 continue=false)→ harvest 更新成终态。
# (单平台中途 done 被 pricebot 改写成 wait+continue=true,不会命中这里,同 coupon 语义。)
action = resp.get("action") or {}
if action.get("command") == "done" and not resp.get("continue", True):
done_params = action.get("params") or {}
try:
await run_in_threadpool(
_harvest_done_blocking, trace_id, (user.id if user else None),
done_params, "food",
meta.get("device_id"), meta.get("device_info"),
resp.get("trace_url") or done_params.get("trace_url"),
)
except Exception as e: # noqa: BLE001
logger.warning("harvest_done failed trace=%s: %s", trace_id, e)
return resp
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传到 pricebot, 终止/未识别拿 trace_url)")
async def trace_finalize(request: Request) -> dict[str, Any]:
# 用户终止 / Phase1 未识别没走到 done 帧, pricebot 没上云也没回传 trace_url。客户端收尾时
# 打这个, _passthrough 按 trace_id 一致性 hash 落到处理这条 trace 的同一 pricebot 进程
# (dir_cache 在那, 才能算对 trace 目录), 由后者打包上云返回 {trace_url}。
return await _passthrough(request, "/api/trace/finalize")
@router.post("/trace/epilogue", summary="比价结果页尾声帧 (透传到 pricebot, 用户视角截图入 trace)")
async def trace_epilogue(request: Request, user: OptionalUser) -> dict[str, Any]:
# App 收到 done、渲染完结果页后,把自己页面的截图(base64)传给 pricebot 存进 trace
# 目录并触发重传 —— trace 里补上"用户实际看到的汇总页"(步骤帧只有目标 App 画面)。
# body ~几百 KB(截图 base64), 纯透传壳(harvest_first_frame=False:不建行/不落库,
# 该 trace 的记录已由 done/finalize 落终态)。#112 原走 _passthrough,合并到 harvest
# 分支后统一走 _forward(_passthrough 已并入它)。
resp, _, _ = await _forward(
request, "/api/trace/epilogue", user, harvest_first_frame=False,
)
return resp
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)")
async def trace_finalize(request: Request, user: OptionalUser) -> dict[str, Any]:
# 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url};
# app-server 顺手把该 trace 的 running 行更新成夭折终态(**不降级 success**)。
# 客户端 finalize body 带 status(cancelled/failed)+ reason;老客户端只带 trace_id →
# 默认 cancelled,其后续 /compare/record 上报再补精确态。
resp, trace_id, meta = await _forward(
request, "/api/trace/finalize", user, harvest_first_frame=False,
)
try:
await run_in_threadpool(
_harvest_abort_blocking, trace_id,
(meta.get("status") or "cancelled"),
(meta.get("reason") or meta.get("information")),
(resp.get("trace_url") if isinstance(resp, dict) else None),
)
except Exception as e: # noqa: BLE001
logger.warning("harvest_abort failed trace=%s: %s", trace_id, e)
return resp
+9 -22
View File
@@ -1,15 +1,13 @@
"""比价记录 endpoint(「我的比价记录」数据源)。
"""比价记录 endpoint(「我的比价记录」+ admin 数据源)。
路由前缀 `/api/v1/compare`:
POST /record 上报一次比价结果(幂等: user+trace_id 覆盖)
POST /record (灰度期兼容)老客户端上报, **trace_id** 幂等 + 不降级 success
GET /records 比价记录列表(游标分页)
GET /records/{id} 单条详情( raw_payload 全量)
**均需鉴权**(CurrentUser)与同文件无关的不鉴权透传 `compare.py` 分开:那个是
转发壳(MVP 不鉴权),这里是按用户维度落库的业务接口,必须有 user_id
:本轮只做 server ,客户端(android ) done 帧后调 POST /record 上报的改动
另起一轮( app-server docs/待办与技术债.md P1)
**均需鉴权**(CurrentUser) 写路径现以 `compare.py` 透传壳的**后端 harvest** 为主
(帧0 running done/finalize 落终态,新客户端不再 POST); POST /record 仅灰度期
给老客户端用, harvest trace_id reconcile新版覆盖够高后可下线本 POST(阶段3)
"""
from __future__ import annotations
@@ -21,16 +19,15 @@ 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,
ComparisonRecordCreatedOut,
ComparisonRecordDetailOut,
ComparisonRecordIn,
ComparisonRecordPage,
ComparisonRecordOut,
ComparisonRecordPage,
)
from app.services.pricebot_llm_calls import fetch_llm_calls
logger = logging.getLogger("shagua.compare_record")
@@ -53,18 +50,8 @@ 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)
# 注:邀请发奖已从"比价成功"挪到"实际下单"(见 api/v1/order.py report_order)——
# 冰拍板:被邀请人完成比价并实际下单才算邀请成功,仅完成比价不再发奖
logger.info(
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
user.id,
+29
View File
@@ -29,6 +29,7 @@ from app.schemas.coupon_state import (
CouponPromptDismissIn,
CouponPromptShouldShowOut,
CouponPromptShownIn,
CouponSessionIn,
CouponStatsOut,
)
@@ -194,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** 不再自动弹。
+31 -1
View File
@@ -31,7 +31,18 @@ router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 6
_CONTENT_MAX = 200
_CONTACT_MAX = 128
_SCENE_MAX = 32
_VALID_RECORD_STATUS = {"pending", "adopted", "rejected"}
_VALID_SOURCES = {"profile", "comparison"}
def _resolve_source(source: str, scene: str) -> str:
"""反馈来源:客户端显式传的 source 优先(profile / comparison);未传或非法时,
scene 有无派生比价结果页反馈才带 scene, scene 非空即视作 comparison"""
src = source.strip().lower()
if src in _VALID_SOURCES:
return src
return "comparison" if scene.strip() else "profile"
def _app_status(db_status: str) -> str:
@@ -46,10 +57,12 @@ def _record_out(fb) -> FeedbackRecordOut:
return FeedbackRecordOut(
id=fb.id,
content=fb.content,
scene=getattr(fb, "scene", None),
images=fb.images or [],
status=_app_status(fb.status),
reject_reason=getattr(fb, "reject_reason", None),
reward_coins=getattr(fb, "reward_coins", None),
admin_reply=getattr(fb, "admin_reply", None),
created_at=fb.created_at,
)
@@ -61,6 +74,16 @@ async def submit_feedback(
content: str = Form(...),
# 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
contact: str = Form(default=""),
# 反馈来源入口:profile(「我的」页)/ comparison(比价结果页)。新端显式传;旧端不带 →
# 空串 → 按 scene 有无派生。admin 据此展示/筛选「反馈类型」。
source: str = Form(default=""),
# 比价结果页反馈的「问题场景」(找错商品/优惠不对…);普通反馈不带 → 空串 → 存 NULL。
scene: 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()
@@ -71,6 +94,8 @@ async def submit_feedback(
raise HTTPException(status_code=400, detail="反馈内容过长")
if len(contact) > _CONTACT_MAX:
raise HTTPException(status_code=400, detail="联系方式过长")
scene = scene.strip()[:_SCENE_MAX]
resolved_source = _resolve_source(source, scene)
files = [f for f in (images or []) if f is not None and f.filename]
if len(files) > _MAX_IMAGES:
@@ -86,8 +111,13 @@ async def submit_feedback(
fb = feedback_repo.create_feedback(
db, user_id=user.id, content=content, contact=contact, images=urls,
source=resolved_source, scene=scene or None,
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 source=%s images=%d", fb.id, user.id, resolved_source, len(urls)
)
logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls))
return FeedbackOut.model_validate(fb)
+41 -4
View File
@@ -25,9 +25,19 @@ from app.schemas.meituan import (
ReferralLinkResponse,
TopSalesRequest,
)
from app.utils.meituan_city import get_meituan_city
logger = logging.getLogger("shagua.meituan")
def _resolve_city_id(latitude: float, longitude: float) -> str:
"""经纬度 → 美团城市 ID;解析失败返 ""(调用方应降级返空)。"""
try:
return get_meituan_city(latitude, longitude).get("city_id", "")
except Exception:
logger.exception("get_meituan_city 失败")
return ""
router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"])
@@ -175,13 +185,19 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
status = "degraded" if (not cards and wm_fail and dd_fail) else ("ok" if cards else "empty")
return FeedResponse(items=cards, has_next=wm_hn or dd_hn, page=req.page, status=status)
# 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。
# 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,按城市过滤,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。
# 实测库里佣金≥3% 去重后仅 ~578 条(几乎全是外卖;到店团购佣金普遍 <3%):实时按"同城热销榜单"
# 拉既撞限流、又填不满(该榜单中位佣金 ~0.8%,筛完每页剩 0-1 条),故从库出。佣金阈值逻辑不变。
if tab == "rec":
city_id = _resolve_city_id(lat, lon)
if not city_id:
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
PAGE = 20
try:
base = select(MeituanCoupon).where(MeituanCoupon.commission_percent >= 3.0)
base = select(MeituanCoupon).where(
MeituanCoupon.commission_percent >= 3.0,
MeituanCoupon.city_id == city_id,
)
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
MeituanCoupon.dedup_key,
MeituanCoupon.commission_percent.desc(),
@@ -211,6 +227,9 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
card.distance_text = None
card.distance_meters = None
cards.append(card)
if not cards and req.page == 1:
# 命中城市却 0 券:该城确无 ≥3% 券,或 ETL 灌的 city_id 与 city_dict 口径不一致。
logger.info("[feed] rec city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id)
return FeedResponse(items=cards, has_next=has_next, page=req.page,
status="ok" if cards else "empty")
@@ -254,14 +273,24 @@ def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
@router.post("/top-sales", response_model=CouponListResponse,
summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,不实时打美团)")
summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,按城市过滤,不实时打美团)")
def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponListResponse:
# 按设备经纬度定位城市,只查同城券;老客户端不带坐标 → 降级返空(不 422、不误返全城)。
if req.latitude is None or req.longitude is None:
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
city_id = _resolve_city_id(req.latitude, req.longitude)
if not city_id:
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
# 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。
# (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。)
# 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。
try:
# 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
base = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None))
base = select(MeituanCoupon).where(
MeituanCoupon.sale_volume_num.isnot(None),
MeituanCoupon.city_id == city_id,
)
if req.platform is not None:
base = base.where(MeituanCoupon.platform == req.platform)
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
@@ -292,6 +321,14 @@ def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponList
except Exception: # noqa: BLE001
continue
if card.product_view_sign:
# 不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。
# 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。
# 逻辑与推荐流保持一致
card.distance_text = None
card.distance_meters = None
cards.append(card)
if not cards and req.page == 1:
# 命中城市却 0 券:可能该城确无券,也可能 ETL 灌的 city_id 与 city_dict 口径不一致(静默降级的隐患)。
logger.info("[top-sales] city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id)
return CouponListResponse(items=cards, has_next=has_next, search_id=None,
status="ok" if cards else "empty")
+19
View File
@@ -1,9 +1,14 @@
import logging
from fastapi import APIRouter
from app.api.deps import CurrentUser, DbSession
from app.repositories import invite as crud_invite
from app.repositories import savings as crud_savings
from app.schemas.order import OrderReportOut, OrderReportRequest
logger = logging.getLogger("shagua.order")
router = APIRouter(prefix="/api/v1/order", tags=["order"])
@@ -15,6 +20,20 @@ router = APIRouter(prefix="/api/v1/order", tags=["order"])
def report_order(req: OrderReportRequest, user: CurrentUser, db: DbSession) -> OrderReportOut:
# 记账唯一真相表是 savings_record(source='compare')。
rec, duplicated = crud_savings.create_from_report(db, user.id, req)
# 邀请 v2 发奖:被邀请人【实际下单】(而非仅完成比价)才给邀请人发邀请奖励金(幂等,只发一次)。
# 触发点从"比价成功上报"挪到这里(冰:完成比价并实际下单才算成功)。仅首次真实上报触发,
# 重复上报(duplicated)跳过。best-effort:发奖异常不影响订单上报本身(rec 已 commit),只 log;
# try_reward_on_compare 自带 compare_reward_granted 幂等闸,漏发靠后续对账补。
if not duplicated:
try:
reward = crud_invite.try_reward_on_compare(db, user.id)
if reward.status == "granted":
logger.info(
"invite order 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 order reward failed invitee=%s: %s", user.id, e)
return OrderReportOut(
id=rec.id,
platform=rec.platform or req.platform,
+12
View File
@@ -69,6 +69,18 @@ def complete_onboarding(
return OkResponse()
@router.post("/onboarding/reset", response_model=OkResponse, summary="重置新手引导(删该 设备+账号 完成标记,下次登录重走)")
def reset_onboarding(
req: OnboardingCompleteRequest, user: CurrentUser, db: DbSession
) -> OkResponse:
"""删该 (当前账号, device_id) 的引导完成标记 → 下次登录 onboarding_completed=false,客户端重走。
/onboarding/complete 互逆device_id 取客户端硬件级 ANDROID_ID(与登录/complete 一致)
幂等:无记录也返回 ok仅删自己(当前 JWT 用户)这台设备的记录,不影响别的账号/设备"""
deleted = onboarding_repo.delete_completion(db, user_id=user.id, device_id=req.device_id)
logger.info("onboarding reset user_id=%d device_len=%d deleted=%d", user.id, len(req.device_id), deleted)
return OkResponse()
@router.get(
"/onboarding/status",
response_model=OnboardingStatusResponse,
+3
View File
@@ -202,6 +202,9 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
order = crud_wallet.create_withdraw(
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(
+51 -2
View File
@@ -68,7 +68,7 @@ class Settings(BaseSettings):
# 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送)
HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关
HEARTBEAT_TIMEOUT_MINUTES: int = 10 # 多久没心跳算掉线(≈3 个客户端心跳周期)
HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀)
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
# ===== 短信 =====
@@ -81,7 +81,6 @@ class Settings(BaseSettings):
SMS_SIGN_ID: int = 31729 # 极光短信签名 ID(非机密,可被 .env 覆盖)
SMS_TEMPLATE_ID: int = 1 # 极光短信模板 ID(变量名 code,有效期 5 分钟)
SMS_CODE_LENGTH: int = 6 # 验证码位数(本服务生成;前端 code 字段 4-8 位兼容)
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
# ===== 测试账号(release 包全流程联调用)=====
@@ -113,6 +112,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 +225,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"
+18 -6
View File
@@ -11,7 +11,10 @@ from typing import Any
from app.core import rewards as r
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool / enum
# hidden=True:仍是合法可配项(业务照常 get_value / admin 可经专用端点读写),但**不在通用
# 「系统配置」页渲染**(admin/routers/config.py:list_config 按此过滤)。用于把已下线/已改由
# 专用页管理的项从福利页 Tab 收起,同时保留后端默认值与写入能力。
CONFIG_DEFS: dict[str, dict[str, Any]] = {
"signin_rewards": {
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
@@ -30,18 +33,21 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"default": r.WITHDRAW_MAX_CENTS, "label": "提现最高额(分)",
"group": "钱包", "type": "int",
},
# 从福利页 Tab 收起(hidden)的项:任务 / 里程碑 整组 + 看广告组里的「单次金币(遗留展示值)/
# 每轮次数 / 比价领券信息流广告开关 comparing_ad_enabled」。key 与默认值保留、后端业务照常读取,
# 仅不在配置页渲染。看广告组保留可见的:每日上限 / 单次金币上限 / 关闭后冷却。
"task_rewards": {
"default": dict(r.TASK_REWARDS), "label": "一次性任务奖励",
"group": "任务", "type": "dict_str_int", "help": "task_key → 金币。",
"group": "任务", "type": "dict_str_int", "help": "task_key → 金币。", "hidden": True,
},
"record_milestones": {
"default": list(r.RECORD_MILESTONES), "label": "比价里程碑金币档位",
"group": "里程碑", "type": "int_list",
"group": "里程碑", "type": "int_list", "hidden": True,
"help": "累计成功比价第 1~N 档解锁发的金币。",
},
"ad_reward_coin": {
"default": r.AD_REWARD_COIN, "label": "看广告单次金币",
"group": "看广告", "type": "int",
"group": "看广告", "type": "int", "hidden": True,
"help": "历史兼容/测试展示值;正式发放按 eCPM 公式计算。",
},
"ad_daily_limit": {
@@ -54,7 +60,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
},
"ad_round_count": {
"default": r.VIDEO_ROUND_REQUIRED_COUNT, "label": "每轮看广告次数",
"group": "看广告", "type": "int", "help": "当前为 1,表示每次广告关闭后触发短冷却。",
"group": "看广告", "type": "int", "hidden": True, "help": "当前为 1,表示每次广告关闭后触发短冷却。",
},
"ad_cooldown_sec": {
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)",
@@ -67,7 +73,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
},
"comparing_ad_enabled": {
"default": True, "label": "比价/领券期信息流广告",
"group": "看广告", "type": "bool",
"group": "看广告", "type": "bool", "hidden": True,
"help": (
"开启后,比价进行中 + 领券等候期会在悬浮窗展示穿山甲信息流广告(变现行为);"
"关闭则全程不出广告。客户端按 app 启动 / 每场比价开始时拉取并缓存,故为「最终一致」的"
@@ -84,4 +90,10 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"提现页「批量对账」手动按钮不受影响。"
),
},
# 首页轮播数据源(hidden:不进通用配置 Tab,由「首页轮播种子」页的专用端点 /marquee-seeds/mode 读写)。
"marquee_feed_mode": {
"default": "mixed", "label": "首页轮播数据源",
"group": "首页轮播", "type": "enum", "hidden": True,
"help": "mixed=真实优先+种子补位(默认);real=只用真实比价记录;seed=只用种子/合成(演示)。",
},
}
+57 -8
View File
@@ -2,9 +2,15 @@
业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可, 本模块在 main.py 启动时调一次
- 控制台(stdout): 人类可读文本, systemd / 本地看
- 文件 `logs/app-server.log`: 单行 JSON, 供阿里云 SLS/Logtail 采集(JSON 模式零正则);
异常栈为字段内嵌不换行 每条日志一行
- 控制台(stdout): 人类可读文本, systemd / 本地看; trace 时行尾附 `trace=xxx`
- 文件 `logs/app-server.log`: 单行 JSON, **阿里云 SLS / Logtail** 采集(JSON 模式零正则);
异常栈内嵌为字段不换行 每条日志一行
- **结构化字段(SLS 可直接查/聚合)**:
- `trace_id`: 请求级贯穿在入口 `trace_id_ctx.set(...)` , 本请求内**每一行日志**(
run_in_threadpool 里的 harvest, contextvars 自动拷进线程)都自动带上, 无需手写
SLS `trace_id: "xxx"` 一查即得整条比价链路, time 升序即请求顺序
- 任意 `logger.info(msg, extra={"phase": ..., "step": ..., "command": ...})` extra
键都会平铺进 JSON 顶层 SLS 可按 phase/step/command/cost_ms 等过滤聚合
- 环境变量:
- LOG_JSON_CONSOLE=1 控制台也输出 JSON
- LOG_DIR / LOG_FILE 改落盘路径(默认 logs/app-server.log)
@@ -17,13 +23,36 @@ import json
import logging
import os
import sys
from contextvars import ContextVar
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
# 请求级 trace_id:入口(如 compare.py 透传壳)set 之后, 本请求上下文(含 run_in_threadpool
# 拷贝出去的线程)内所有日志自动带上。默认空串 = 非请求上下文(启动/后台 worker)。
trace_id_ctx: ContextVar[str] = ContextVar("trace_id", default="")
# 标准 LogRecord 属性 + 格式化期附加项:凡不在此集合的 record 属性都视为业务 extra, 平铺进 JSON。
_RESERVED = set(
logging.LogRecord("", 0, "", 0, "", (), None).__dict__
) | {"message", "asctime", "trace_id", "taskName"}
class _ContextFilter(logging.Filter):
"""把 trace_id_ctx 注入每条 record(供两个 formatter 取用)。挂在 handler 上,
命中每条( propagate 上来的)记录, format 之前置好 record.trace_id"""
def filter(self, record: logging.LogRecord) -> bool:
if not hasattr(record, "trace_id"):
record.trace_id = trace_id_ctx.get()
return True
class JsonFormatter(logging.Formatter):
"""LogRecord 序列化成单行 JSON(SLS/Logtail 友好)。异常栈内嵌为字段, 整条仍是一行"""
"""LogRecord 单行 JSON(SLS/Logtail 友好)。trace_id 提到顶层、extra 键平铺, 异常栈内嵌。"""
def __init__(self, service: str = "app-server"):
super().__init__()
self.service = service
@@ -35,10 +64,17 @@ class JsonFormatter(logging.Formatter):
"level": record.levelname,
"service": self.service,
"logger": record.name,
"func": record.funcName,
"line": record.lineno,
"message": record.getMessage(),
}
tid = getattr(record, "trace_id", "") or trace_id_ctx.get()
if tid:
data["trace_id"] = tid
# 业务 extra 字段(phase / step / endpoint / command / cost_ms / status ...)平铺进顶层
for k, v in record.__dict__.items():
if k not in _RESERVED and not k.startswith("_"):
data[k] = v
data["func"] = record.funcName
data["line"] = record.lineno
data["message"] = record.getMessage()
if record.exc_info:
data["exception"] = self.formatException(record.exc_info)
if record.stack_info:
@@ -46,6 +82,15 @@ class JsonFormatter(logging.Formatter):
return json.dumps(data, ensure_ascii=False, default=str)
class TextFormatter(logging.Formatter):
"""控制台文本:标准行 + 有 trace_id 时行尾附 `trace=xxx`(无 trace 的启动/后台日志不加噪)。"""
def format(self, record: logging.LogRecord) -> str:
base = super().format(record)
tid = getattr(record, "trace_id", "") or trace_id_ctx.get()
return f"{base} trace={tid}" if tid else base
_CONFIGURED = False
@@ -63,14 +108,17 @@ def setup_logging(debug: bool = False) -> None:
for h in list(root.handlers):
root.removeHandler(h)
ctx_filter = _ContextFilter()
# 控制台: 默认文本(systemd/本地看); LOG_JSON_CONSOLE=1 时输出 JSON
console = logging.StreamHandler(sys.stdout)
if os.getenv("LOG_JSON_CONSOLE") == "1":
console.setFormatter(JsonFormatter(service))
else:
console.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
TextFormatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
)
console.addFilter(ctx_filter)
root.addHandler(console)
# 文件: 单行 JSON, 供 Logtail 采集(自动轮转, 单文件 10MB, 保留 5 个)
@@ -82,6 +130,7 @@ def setup_logging(debug: bool = False) -> None:
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8",
)
file_handler.setFormatter(JsonFormatter(service))
file_handler.addFilter(ctx_filter)
root.addHandler(file_handler)
# 第三方库降噪
+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
+8 -25
View File
@@ -8,15 +8,16 @@
校验) 鉴权复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`(同一极光应用)
验证码存储:**进程内存**( worker uvicorn 够用)重启丢失(用户重发即可)
worker / 多机时内存不共享 冷却每日上限校验都会失效,届时迁移到 DB/Redis
worker / 多机时内存不共享 冷却校验都会失效,届时迁移到 DB/Redis
docs/待办与技术债.md
防刷(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
防刷(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
2. 号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限(本文件)
3. 单设备(device_id)每小时频控(api auth.sms_send enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)
2. 设备(device_id)每小时频控(api auth.sms_send enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)
IP 频控(rate_limit 依赖)2026-06-26 按产品要求删除改设备维度; device_id 客户端可伪造/轮换,
脚本轮换 id 能绕过本层 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)
单号每日上限2026-07-03 按精简要求删除(mentor :登录风控只留单号冷却 + 单设备频控);
单号维度现仅剩 60s 冷却,换号轰炸由单设备频控封顶
:单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废(防爆破),验过即作废(一次性)
"""
from __future__ import annotations
@@ -26,7 +27,6 @@ import logging
import secrets
import time
from dataclasses import dataclass
from datetime import datetime
from threading import Lock
import httpx
@@ -56,22 +56,17 @@ class _CodeRecord:
# 进程内存(单 worker 有效;多 worker 不共享,见模块 docstring)
_codes: dict[str, _CodeRecord] = {} # phone -> 当前有效验证码
_last_sent: dict[str, float] = {} # phone -> 上次发送 epoch(冷却)
_daily_count: dict[str, tuple[str, int]] = {} # phone -> (date_str, 当日发送数)
_lock = Lock()
_GC_THRESHOLD = 10000 # 任一内存 dict 超此阈值,send 时顺手清过期项(防无限增长,仿 ratelimit)
def _today() -> str:
return datetime.now().strftime("%Y-%m-%d")
def _gen_code() -> str:
"""生成 N 位数字验证码(用 secrets 而非 random;允许前导 0)。"""
return "".join(secrets.choice("0123456789") for _ in range(settings.SMS_CODE_LENGTH))
def _gc(now: float) -> None:
"""顺手清理过期内存项,防个 dict 无限增长。仅在持锁时调用,且某 dict 超
"""顺手清理过期内存项,防个 dict 无限增长。仅在持锁时调用,且某 dict 超
_GC_THRESHOLD 才扫它(低频,开销可忽略)"""
if len(_codes) > _GC_THRESHOLD:
for p in [p for p, r in _codes.items() if now > r.expires_at]:
@@ -80,10 +75,6 @@ def _gc(now: float) -> None:
cutoff = now - settings.SMS_SEND_INTERVAL_SEC
for p in [p for p, ts in _last_sent.items() if ts < cutoff]:
_last_sent.pop(p, None)
if len(_daily_count) > _GC_THRESHOLD:
today = _today()
for p in [p for p, (d, _c) in _daily_count.items() if d != today]:
_daily_count.pop(p, None)
def send_code(phone: str) -> int:
@@ -102,17 +93,9 @@ def send_code(phone: str) -> int:
remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed)
raise SmsError(f"发送过于频繁,请 {remain}s 后再试")
today = _today()
day, cnt = _daily_count.get(phone, ("", 0))
if day != today:
cnt = 0
if cnt >= settings.SMS_DAILY_LIMIT_PER_PHONE:
raise SmsError("今日验证码发送次数已达上限,请明天再试")
code = _gen_code()
# 预占:先记冷却/计数/存码,释放锁后再发网络(发失败保留冷却+计数,见下)
# 预占:先记冷却/存码,释放锁后再发网络(发失败保留冷却,见下)
_last_sent[phone] = now
_daily_count[phone] = (today, cnt + 1)
_codes[phone] = _CodeRecord(code=code, expires_at=now + settings.SMS_CODE_TTL_SEC)
# --- lock 外:真正发送(网络 IO 不持锁)---
@@ -123,7 +106,7 @@ def send_code(phone: str) -> int:
_send_via_jiguang(phone, code)
logger.info("[SMS] sent to %s****", phone[:3])
except Exception as e:
# 发送失败:**保留冷却 + 每日计数**(失败也限速,挡住余额不足/签名失效时
# 发送失败:**保留冷却**(失败也限速,挡住余额不足/签名失效时
# 前端重试狂打极光),只清掉没发出去的码(用户收不到,留着无意义且占内存)。
with _lock:
_codes.pop(phone, None)
+41 -10
View File
@@ -2,18 +2,23 @@
通过 `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
@@ -21,12 +26,8 @@ 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
@@ -40,14 +41,14 @@ 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 (
@@ -70,6 +71,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.DATABASE_URL.split("://", 1)[0],
)
get_pricebot_client() # 预热透传 client:把建 SSL 上下文的一次性成本付在启动,首个领券请求即热
try:
# 预热离线地理库:首次加载 ~2.5M 行 CSV + 建 KDTree,摊到启动、不砸首个按城市过滤的请求
from app.utils import geo
geo.ensure_loaded()
except Exception: # noqa: BLE001
logger.exception("reverse_geocoder 预热失败(城市反查将在首个请求时懒加载)")
reconcile_task = start_withdraw_reconcile_worker()
heartbeat_task = start_heartbeat_monitor()
daily_exchange_task = start_daily_exchange_worker()
@@ -137,6 +144,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)),
+3 -1
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.admin_role import AdminRole # 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
@@ -18,8 +20,8 @@ from app.models.coupon_state import ( # noqa: F401
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
CouponSession,
)
from app.models.cps_order import CpsOrder # noqa: F401
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
+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}>"
)
+7 -1
View File
@@ -25,8 +25,14 @@ class AdminUser(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
# super_admin(全权+管账号)/ finance(钱:提现+金币)/ operator(用户+反馈+大盘)
# 明文登录密码:仅「后台 UI 创建/重置」的管理员留存,供超管在权限管理页复看转交。
# 脚本/起后台时建的超管账号不写(为 None → 前端「不显示密码」)。⚠️ 内部工具便利取舍,见 create/list。
plain_password: Mapped[str | None] = mapped_column(String(128), nullable=True)
# super_admin(全权+管账号)/ finance(钱:提现+金币)/ operator(用户+反馈+大盘)/ custom(按人自定义)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="operator")
# 「自定义」权限:仅当 role == "custom" 时有效,存这个人专属的可见页 key 列表(不共享给他人)。
# 非 custom 用户为 None → 可见页跟随角色。登录/`/me` 下发有效页时,非空即优先用它(见 auth._admin_out_with_pages)。
pages_override: Mapped[list | None] = mapped_column(_JSON, nullable=True)
# active / disabled
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
+42
View File
@@ -0,0 +1,42 @@
"""admin 角色 → 可见页面(权限)映射表。
RBAC 角色:每个角色持有一组页面 key(= 左侧导航项),决定该角色登录后台后左边能看到
哪些页super_admin 是内建全权角色(is_builtin=True),恒可见全部页(effective_pages 里特判,
不依赖本表存的 pages)不可编辑/删除其余角色( operator/finance)可由 super_admin 增删改
admin_user.role 存的是本表的 name(字符串弱引用,不建外键与既有 require_role 字符串口径一致;
删除在用角色由业务层拦截, routers/roles.py)页面 key 清单见 app/admin/permissions.py
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
_JSON = JSON().with_variant(JSONB(), "postgresql")
class AdminRole(Base):
__tablename__ = "admin_role"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 角色标识 key(不可变,承重):admin_user.role 引用它、require_role 按它鉴权。
# 内建 = super_admin/operator/finance/tech(英文,勿改);自定义 = 创建时的名称。
name: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False)
# 展示名(可改):内建 = 管理员/运营/财务/技术;自定义默认 = name。UI 一律展示 label。
label: Mapped[str] = mapped_column(String(32), nullable=False, default="")
# 该角色可见页面 key 列表(= 左侧导航项),见 app/admin/permissions.py 的 PERMISSION_CATALOG
pages: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 内建角色(当前仅 super_admin):不可编辑/删除,恒全权
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<AdminRole name={self.name} pages={len(self.pages)} builtin={self.is_builtin}>"
+11 -4
View File
@@ -39,16 +39,19 @@ _JSON = JSON().with_variant(JSONB(), "postgresql")
class ComparisonRecord(Base):
__tablename__ = "comparison_record"
__table_args__ = (
# 同一用户同一次比价(trace_id)只存一条:客户端重试/误点重复上报时幂等覆盖
UniqueConstraint("user_id", "trace_id", name="uq_comparison_user_trace"),
# trace_id 由 app-server 签发、全局唯一 → 一次比价一行,后端 harvest 按它 upsert
# (原 (user_id,trace_id) 复合唯一改为 trace_id 单列:harvest 帧0 建行时 user_id 可能暂缺。)
UniqueConstraint("trace_id", name="uq_comparison_trace"),
# 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序;
# 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。
Index("ix_comparison_status_created", "status", "created_at"),
)
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
# 后端 harvest 在帧0(pricebot 出 trace_id)即建行,软鉴权下 user_id 可能暂缺(老客户端/匿名)→ 可空。
# C 端「我的比价记录」按 user_id 过滤天然排除 null-user 行;admin 全看(含孤儿行)。
user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=True
)
# 仍记录设备号(同一用户多设备的行为区分 / 与不鉴权期 device_id 数据对账)
device_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
@@ -83,6 +86,10 @@ class ComparisonRecord(Base):
# ===== 订单概要 =====
store_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 下单商品名拼接串(顿号分隔,从 items[].name 去重派生),供 admin 列表「商品」列展示 + 商品名搜索。
# items 是 JSON(SQLite 下 json.dumps ensure_ascii 把中文转义,无法直接 CAST+LIKE),故另派生成普通
# 文本列——跨库 LIKE 一致、可加索引。写路径(upsert_record / harvest_done)落库时同步派生。
product_names: Mapped[str | None] = mapped_column(String(512), nullable=True)
total_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
skipped_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
+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}>"
)
+16
View File
@@ -23,14 +23,30 @@ class Feedback(Base):
)
content: Mapped[str] = mapped_column(Text, nullable=False)
contact: Mapped[str] = mapped_column(String(128), nullable=False)
# 反馈来源入口:profile(「我的」页反馈入口)/ comparison(比价结果页反馈入口)。
# admin 据此展示/筛选「反馈类型」。旧数据与「我的」页反馈均为 profile(server_default)。
# 客户端显式传 source 优先;未传时由 scene 有无派生(见 api/v1/feedback.submit_feedback)。
source: Mapped[str] = mapped_column(
String(16), nullable=False, default="profile", server_default="profile", index=True
)
# 比价反馈的「问题场景」(找错商品/优惠不对/比价太慢…),比价结果页反馈才有;普通反馈为 NULL。
scene: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 截图 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)
reward_coins: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 审核批注:采纳时可写采纳要点,未采纳时也可保留运营侧备注
review_note: Mapped[str | None] = mapped_column(String(256), nullable=True)
# 运营给用户的回复留言(**用户端可见**,采纳/未采纳都可填,随「我的反馈」历史下发)。
# 与 review_note(内部备注,用户不可见)、reject_reason(未采纳原因)区分开。
admin_reply: Mapped[str | None] = mapped_column(String(256), nullable=True)
reviewed_by_admin_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("admin_user.id"), nullable=True
)
+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
+239 -3
View File
@@ -24,6 +24,23 @@ def _yuan_to_cents(yuan: float | None) -> int | None:
return round(yuan * 100)
def _product_names_from_items(items: list | None) -> str | None:
"""下单商品 items([{name, qty, specs?}])→ 顿号分隔的商品名串(去重保序),
admin商品列展示 + 商品名 LIKE 搜索 / 无名 None;超列宽(512)截断留余量"""
if not items:
return None
names: list[str] = []
for it in items:
name = it.get("name") if isinstance(it, dict) else None
if not name:
continue
s = str(name).strip()
if s and s not in names:
names.append(s)
joined = "".join(names)
return joined[:500] or None
def _derive(payload: ComparisonRecordIn) -> dict:
"""从上报 payload 派生结构化列(best/saved/is_source_best/status)。"""
results = payload.comparison_results
@@ -74,12 +91,19 @@ def _derive(payload: ComparisonRecordIn) -> dict:
def upsert_record(
db: Session, *, user_id: int, payload: ComparisonRecordIn
) -> ComparisonRecord:
"""(user_id, trace_id) 幂等写入:已存在则覆盖(更完整的重试上报胜出),否则新建。"""
"""**trace_id** 幂等写入(唯一键已从 user_id+trace_id 改为 trace_id):已存在则合并
(回填 null user_id + 覆盖字段,**不降级 success**),否则新建
灰度期老客户端 POST /compare/record 走这条,与后端 harvest trace_id reconcile;
新客户端不再 POST(改由 compare.py 透传壳 harvest 落库)
"""
derived = _derive(payload)
items = [it.model_dump(exclude_none=True) for it in payload.items]
fields = dict(
device_id=payload.device_id,
business_type=payload.business_type,
store_name=payload.store_name,
product_names=_product_names_from_items(items),
source_platform_id=payload.source_platform_id,
source_platform_name=payload.source_platform_name,
source_package=payload.source_package,
@@ -88,7 +112,7 @@ def upsert_record(
trace_url=payload.trace_url,
total_dish_count=payload.total_dish_count,
skipped_dish_count=payload.skipped_dish_count,
items=[it.model_dump(exclude_none=True) for it in payload.items],
items=items,
comparison_results=[r.model_dump() for r in payload.comparison_results],
skipped_dish_names=list(payload.skipped_dish_names),
# 客户端环境 / 性能(debug,客户端上报;旧客户端为 None)
@@ -110,14 +134,29 @@ def upsert_record(
**derived,
)
# 按 trace_id 定位(唯一键已改 trace_id):后端 harvest 可能已建行,这里的客户端上报
# (灰度期老客户端 / 新客户端不再走这条)与之 reconcile。
existing = db.execute(
select(ComparisonRecord).where(
ComparisonRecord.user_id == user_id,
ComparisonRecord.trace_id == payload.trace_id,
)
).scalar_one_or_none()
if existing is not None:
# 不降级:harvest 或更早上报已落成 success,收尾期 fromFailure 的 cancelled/failed
# 不许把它盖回去(老 comparisonReported bug 的服务端兜底)——只补 user_id / trace_url。
if existing.status == "success" and fields.get("status") != "success":
if existing.user_id is None and user_id is not None:
existing.user_id = user_id
if fields.get("trace_url"):
existing.trace_url = fields["trace_url"]
db.commit()
db.refresh(existing)
return existing
# 只**填**空缺 user_id、不 reassign:trace_id 全局唯一,一条 trace 只属一个用户;
# 绝不把已有归属的记录改判给另一个上报者(仅 harvest 建的 null-user 行在此绑上)。
if existing.user_id is None:
existing.user_id = user_id
for k, v in fields.items():
setattr(existing, k, v)
db.commit()
@@ -139,6 +178,203 @@ def upsert_record(
return rec
# ============================================================
# 后端 harvestapp-server 从 pricebot 透传响应里直接落库(不靠客户端上报)。
# 帧0 建行(running) → 最终 done 更新(success/failed) → finalize 更新(aborted)。
# 全按 trace_id upsertsuccess 行永不被后到的 failed/aborted 降级。
# ============================================================
def _derive_from_results(results: list[dict]) -> dict:
"""从 done 帧 comparison_results(pricebot 原始 dict 列表)派生结构化列。
等价 _derive,但吃原始字段(is_source/price/rank/platform_id/store_name...)而非 pydantic 对象"""
priced = [r for r in results if r.get("price") is not None]
best = None
if priced:
best = min(
priced,
key=lambda r: (r.get("rank") if r.get("rank") is not None else 10**9, r["price"]),
)
src_row = next((r for r in results if r.get("is_source")), None)
source_price_cents = None
if src_row is not None and src_row.get("price") is not None:
source_price_cents = _yuan_to_cents(src_row["price"])
best_price_cents = _yuan_to_cents(best["price"]) if best else None
saved_amount_cents = None
if source_price_cents is not None and best_price_cents is not None:
saved_amount_cents = source_price_cents - best_price_cents
has_valid_target = any(
(not r.get("is_source")) and r.get("price") is not None for r in results
)
return {
"source_platform_id": (src_row or {}).get("platform_id"),
"source_platform_name": (src_row or {}).get("platform_name"),
"source_package": (src_row or {}).get("package"),
"source_price_cents": source_price_cents,
"best_platform_id": best.get("platform_id") if best else None,
"best_platform_name": best.get("platform_name") if best else None,
"best_price_cents": best_price_cents,
"saved_amount_cents": saved_amount_cents,
"is_source_best": best.get("is_source") if best else None,
"store_name": (src_row or {}).get("store_name") or None,
"status": "success" if has_valid_target else "failed",
}
def _device_cols_from_info(device_info: dict | None) -> dict:
"""step 帧 device_info({locale,brand,model,android_version,rom_version})→ 表列。
step 帧只带这几项;rom_name / android_sdk / app_version / 耗时步数 harvest 拿不到 None
(灰度期老客户端 fromComparison 会补齐;要新客户端也全带需扩 collectDeviceInfo,后续)"""
d = device_info or {}
rv = str(d.get("rom_version") or "")
return {
"device_model": d.get("model") or None,
"device_manufacturer": d.get("brand") or None,
"android_version": d.get("android_version") or None,
"rom_version": int(rv) if rv.isdigit() else None,
}
def _get_by_trace(db: Session, trace_id: str) -> ComparisonRecord | None:
return db.execute(
select(ComparisonRecord).where(ComparisonRecord.trace_id == trace_id)
).scalar_one_or_none()
def harvest_running(
db: Session,
*,
trace_id: str,
user_id: int | None,
business_type: str = "food",
device_id: str | None = None,
device_info: dict | None = None,
trace_url: str | None = None,
) -> ComparisonRecord:
"""帧0(或任一尚未建行的帧)建/补 running 行。幂等:已存在只补空缺(user_id/trace_url/
device_id/机型),绝不动已落定的 status / 结果"""
rec = _get_by_trace(db, trace_id)
if rec is None:
rec = ComparisonRecord(
trace_id=trace_id,
user_id=user_id,
business_type=business_type or "food",
device_id=device_id,
status="running",
trace_url=trace_url,
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
**_device_cols_from_info(device_info),
)
db.add(rec)
db.commit()
db.refresh(rec)
return rec
changed = False
if rec.user_id is None and user_id is not None:
rec.user_id = user_id
changed = True
if not rec.trace_url and trace_url:
rec.trace_url = trace_url
changed = True
if rec.device_id is None and device_id:
rec.device_id = device_id
changed = True
for k, v in _device_cols_from_info(device_info).items():
if getattr(rec, k) is None and v is not None:
setattr(rec, k, v)
changed = True
if changed:
db.commit()
db.refresh(rec)
return rec
def harvest_done(
db: Session,
*,
trace_id: str,
user_id: int | None,
done_params: dict,
business_type: str = "food",
device_id: str | None = None,
device_info: dict | None = None,
trace_url: str | None = None,
) -> tuple[ComparisonRecord, bool]:
"""最终 done 帧:running 行 → 终态(success/failed)+结果+trace_url。
返回 (记录, 是否本次****落成 success)供调用方据此幂等发一次邀请奖
行不存在(理论上帧0已建;防御)则新建"""
results = done_params.get("comparison_results") or []
derived = _derive_from_results(results)
# 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items
items = next((r.get("items") or [] for r in results if r.get("is_source")), [])
fields = dict(
business_type=business_type or "food",
information=done_params.get("information") or None,
# best_deeplink 来自客户端剪贴板采集,harvest 拿不到 → 留空(灰度期 fromComparison 会补;
# 纯 harvest 行「再次比价」退化为按 package 拉起 App。要精确深链需客户端另传,后续)。
trace_url=trace_url or done_params.get("trace_url"),
total_dish_count=done_params.get("total_dish_count"),
skipped_dish_count=done_params.get("skipped_dish_count"),
skipped_dish_names=list(done_params.get("skipped_dish_names") or []),
comparison_results=results,
items=items,
product_names=_product_names_from_items(items),
raw_payload=done_params,
**derived,
**_device_cols_from_info(device_info),
)
rec = _get_by_trace(db, trace_id)
was_success = rec is not None and rec.status == "success"
if rec is None:
rec = ComparisonRecord(
trace_id=trace_id,
user_id=user_id,
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
device_id=device_id,
**fields,
)
db.add(rec)
else:
if user_id is not None and rec.user_id is None:
rec.user_id = user_id
if device_id and rec.device_id is None:
rec.device_id = device_id
for k, v in fields.items():
setattr(rec, k, v)
db.commit()
db.refresh(rec)
newly_success = (rec.status == "success") and not was_success
return rec, newly_success
def harvest_abort(
db: Session,
*,
trace_id: str,
status: str,
reason: str | None,
trace_url: str | None = None,
) -> ComparisonRecord | None:
"""finalize(用户终止/超时/异常,无 done 帧):running 行 → aborted/failed + trace_url。
**不降级 success**:行已 success(收尾取消那种 finalize 后到) refresh trace_url
行不存在(极少:帧0没建成) 返回 None,不凭空造"""
rec = _get_by_trace(db, trace_id)
if rec is None:
return None
if trace_url and not rec.trace_url:
rec.trace_url = trace_url
if rec.status != "success":
rec.status = status or "cancelled"
if reason:
rec.information = reason
db.commit()
db.refresh(rec)
return rec
def _ordered_shop_names(db: Session, user_id: int) -> set[str]:
"""该用户「真实下单」(source='compare')覆盖到的店名集合,用来给比价记录打「已下单」。
+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()
+12
View File
@@ -17,12 +17,24 @@ def create_feedback(
content: str,
contact: str,
images: list[str] | None,
source: str = "profile",
scene: str | None = 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,
source=source,
scene=scene or None,
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),
)
+12 -1
View File
@@ -216,13 +216,21 @@ def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardRes
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))
@@ -296,6 +304,9 @@ def get_invitees(
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
"coins": rel.inviter_coin, # v3 起恒 0(邀请人收益改走邀请奖励金)
"invited_at": rel.created_at,
# 是否已完成过一次比价(= 已发过邀请奖励金)。客户端据此:好友列表分"去提醒/邀请成功"、
# 在途列表只取未比价(is_compared=False)、算在途好友数与在途收益(未比价数×2元)。
"is_compared": bool(rel.compare_reward_granted),
})
has_more = offset + len(rows) < int(total)
return items, int(total), has_more
+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())
+16 -1
View File
@@ -5,7 +5,7 @@ device_id 为空(老客户端 / 取不到 ANDROID_ID)一律按"未完成"处理,
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -33,3 +33,18 @@ def mark_completed(db: Session, *, user_id: int, device_id: str) -> None:
except IntegrityError:
# 并发 / 重复提交撞唯一约束:已有行即视为成功。
db.rollback()
def delete_completion(db: Session, *, user_id: int, device_id: str) -> int:
"""删该 (账号, 设备) 的引导完成标记 → 下次登录 is_completed=False → 客户端重走。
[mark_completed] 互逆device_id 为空忽略( 0);无记录也幂等( 0)返回删除行数"""
if not device_id:
return 0
result = db.execute(
delete(OnboardingCompletion).where(
OnboardingCompletion.user_id == user_id,
OnboardingCompletion.device_id == device_id,
)
)
db.commit()
return result.rowcount
+149 -43
View File
@@ -29,6 +29,18 @@ from app.core.rewards import CN_TZ
from app.models.comparison import ComparisonRecord
from app.models.ops_marquee_seed import OpsMarqueeSeed
from app.models.user import User
from app.repositories import app_config
from app.repositories.user import is_default_nickname
# 首页轮播数据源模式(存 app_config.marquee_feed_mode):
# mixed=真实优先+种子补位+合成兜底(默认,原行为);real=只真实(不足则少/空);seed=只种子+合成兜底。
FEED_MODES = ("mixed", "real", "seed")
def get_feed_mode(db: Session) -> str:
"""读首页轮播数据源模式;非法/未配置回退 mixed(= 原行为)。"""
mode = app_config.get_value(db, "marquee_feed_mode")
return mode if mode in FEED_MODES else "mixed"
# feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」)
_rng = random.Random()
@@ -129,9 +141,12 @@ def _synth_masked_name(rng: random.Random) -> str:
def _mask_real(nickname: str | None, user_id: int) -> str:
"""真实用户脱敏(对齐 PRD「用户标识打码规则」):设过昵称→昵称脱敏(中英文皆可);
没昵称用户+5+id 2 (用户*****08), user_id 稳定刷新不变脸"""
没昵称用户+5+id 2 (用户*****08), user_id 稳定刷新不变脸
创建时自动分配的默认昵称(用户+9 位随机, user.is_default_nickname)不算用户主动设的昵称,
无昵称处理走 id 规则(产品决策 2026-07:默认昵称归入没昵称)"""
nick = (nickname or "").strip()
if nick:
if nick and not is_default_nickname(nick):
return _mask_nickname(nick)
return _mask_anon(user_id)
@@ -202,60 +217,79 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
return out
def get_feed(db: Session, limit: int = 8) -> list[dict]:
def _shuffle_declustered(rows: list, rng: random.Random | None = None) -> list:
"""洗牌 + 「去连簇」:先洗牌,再贪心重排让相邻两条尽量不是同一 user_id(元素 [0] 即 user_id)。
rng=None 用全局 _rng(feed 每次新随机);传入 rng(如固定种子 Random) 排列确定(admin 稳定分页)
减少同一用户连续出现;只有少数几个用户时 best-effort"""
rng = rng or _rng
pool = list(rows)
rng.shuffle(pool)
result: list = []
while pool:
prev_uid = result[-1][0] if result else None
# 优先挑与上一条不同 user 的;挑不到(只剩同 user)才取第一个
idx = next((i for i, r in enumerate(pool) if r[0] != prev_uid), 0)
result.append(pool.pop(idx))
return result
def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]:
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
真实条:success 0 < saved 上限, user 去重(同一用户只取最新一条,避免单人刷屏)
mode:显式传入(admin 预览指定模式)则用它**不改持久化配置**;不传(客户端 /savings-feed)
持久化的 marquee_feed_mode;非法值一律回退到持久化模式
真实条:success 0 < saved 上限,**不按 user 去重**(打乱 + 去连簇:相邻尽量不同用户减少单人连刷)后取前 limit
不足用启用的种子补齐**公平随机抽取** need (而非固定取前 N),让所有种子都有机会露出;
种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多偶尔大额,更像真实分布)
真实 + 种子仍不满 limit 内置合成条**补满**,保证轮播既不空也不稀疏
展示时间统一刷新成相对现在的最近时刻( now 往前**随机抖动**递减),保证轮播永远像刚发生
节奏自然不机械(真实用户/金额不变,只换展示时间避免旧测试数据 / 低谷期记录显示成过时时间)
"""
# 真实条:取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除
rows = _recent_real_rows(db)
# 预览可显式指定模式(所见=选中模式,不依赖 PATCH 落库时序);None/非法 → 读持久化配置
mode = mode if mode in FEED_MODES else get_feed_mode(db) # mixed / real / seed
items: list[dict] = []
used_names: set[str] = set()
seen_users: set[int] = set()
for uid, sc, nick in rows:
if uid in seen_users:
continue
seen_users.add(uid)
name = _mask_real(nick, uid)
used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
if len(items) >= limit:
break
need = limit - len(items)
if need > 0:
seeds = db.execute(
select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True))
).scalars().all()
# 公平随机抽取 need 个(池子够大则不放回抽样;否则全用并打散),让所有启用种子都有机会露出。
chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds)
_rng.shuffle(chosen)
for s in chosen:
# 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子);
# 仅运营手动设的非模板真名才原样用。
fixed = (s.masked_user or "").strip()
name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names)
# 真实条(mixed / real):**不按 user 去重**——打乱 + 去连簇(相邻尽量不同用户、减少单人连刷)后取前
# limit;金额超上限的异常值已在查询剔除。同一用户可多条露出,但被打散、尽量不连续。
if mode != "seed":
for uid, sc, nick in _shuffle_declustered(_recent_real_rows(db))[:limit]:
name = _mask_real(nick, uid)
used_names.add(name) # 同一用户可多条,名字重复无害(used_names 仅供种子避重)
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
# 种子补位 + 合成兜底(mixed / seed):mixed 下补真实不足的部分,seed 下全量用种子/合成。
# real 模式**跳过**——只出真实,不掺任何假数据(真实不足则少于 limit,为 0 时返回空)。
if mode != "real":
need = limit - len(items)
if need > 0:
seeds = db.execute(
select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True))
).scalars().all()
# 公平随机抽取 need 个(池子够大则不放回抽样;否则全用并打散),让所有启用种子都有机会露出。
chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds)
_rng.shuffle(chosen)
for s in chosen:
# 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子);
# 仅运营手动设的非模板真名才原样用。
fixed = (s.masked_user or "").strip()
name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names)
used_names.add(name)
# 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。
lo = max(0, int(s.min_cents))
hi = max(lo, int(s.max_cents))
items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)})
# 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满,
# 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。
while len(items) < limit:
name = _unique_name(used_names)
used_names.add(name)
# 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。
lo = max(0, int(s.min_cents))
hi = max(lo, int(s.max_cents))
items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)})
# 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满,
# 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。
while len(items) < limit:
name = _unique_name(used_names)
used_names.add(name)
items.append({
"masked_user": name,
"saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS),
})
items.append({
"masked_user": name,
"saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS),
})
# 统一赋「最近」时间:从 now 往前**随机抖动**递减,避免固定节奏被看出规律。
# 首条几十秒前;其余多数 1~5 分钟,偶尔扎堆(20~55s)或较长(5~9 分钟),降序、像真实流水。
@@ -276,6 +310,78 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]:
return items
# ===== admin 侧:分页浏览「当前模式下可展示的记录」(审核用,不去重) =====
_REAL_BROWSE_CAP = 1000 # 真实记录一次最多纳入这么多去洗牌+分页(足够审核;防超大库全量洗牌)
_BROWSE_SEED = 20260707 # 固定洗牌种子:同一批数据下排列恒定 → 翻页稳定、能翻遍全部
def _seed_browse_row(seed: OpsMarqueeSeed) -> dict:
"""把一条种子按其「生成逻辑」**确定性**生成一行浏览项(名字/金额按 seed.id 派生固定种子 → 翻页稳定)。
名字:运营手填的非模板名原样用,否则本地合成;金额:区间内确定性长尾取值user_id=0(种子无真实用户)"""
r = random.Random(_BROWSE_SEED * 1_000_003 + int(seed.id))
fixed = (seed.masked_user or "").strip()
name = fixed if (fixed and not fixed.startswith("用户****")) else _synth_name(r)
lo = max(0, int(seed.min_cents))
hi = max(lo, int(seed.max_cents))
amt = lo if hi <= lo else lo + int(round((hi - lo) * (r.random() ** 2.2)))
return {"masked_user": name, "saved_amount_cents": amt, "created_at": "", "user_id": 0}
def list_real_records(
db: Session, mode: str | None = None, offset: int = 0, limit: int = 8
) -> tuple[list[dict], int]:
"""分页浏览「**当前模式**下可在 app 轮播展示的全部记录」,供 admin 逐页审核(**不去重**):
- 只真实:全部 success+>0 的真实记录;
- 只种子:每条启用种子按生成逻辑各出一行;
- 混播:真实 + 种子 全部合在一起
app 轮播同口径:先洗牌 + 去连簇(相邻尽量不同 user;种子各自独立不算同 user);**固定种子**
同批数据下排列恒定,翻页不跳能翻遍全部返回 (items, total);item.user_id=0 表示种子"""
mode = mode if mode in FEED_MODES else get_feed_mode(db)
# pool: [(cluster_key, item)];cluster_key 供去连簇——真实=user_id、种子=各自唯一负数(互不聚簇)
pool: list[tuple[int, dict]] = []
if mode != "seed":
rows = db.execute(
select(
ComparisonRecord.user_id,
ComparisonRecord.saved_amount_cents,
User.nickname,
ComparisonRecord.created_at,
)
.join(User, User.id == ComparisonRecord.user_id)
.where(
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0,
ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS,
)
.order_by(ComparisonRecord.created_at.desc())
.limit(_REAL_BROWSE_CAP)
).all()
for uid, sc, nick, ca in rows:
pool.append((
int(uid),
{
"masked_user": _mask_real(nick, int(uid)),
"saved_amount_cents": int(sc),
"created_at": str(ca)[:16] if ca is not None else "",
"user_id": int(uid),
},
))
if mode != "real":
seeds = (
db.execute(select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True)))
.scalars()
.all()
)
for i, s in enumerate(seeds):
pool.append((-(i + 1), _seed_browse_row(s))) # 每个种子唯一 key → 互不聚簇
total = len(pool)
# 每次用同一固定种子新建 Random → 同批数据排列恒定(翻页稳定);同时相邻尽量不同 user。
ordered = _shuffle_declustered(pool, random.Random(_BROWSE_SEED))
off = max(0, offset)
items = [item for _key, item in ordered[off : off + limit]]
return items, total
# ===== 运营侧:种子 CRUD =====
def list_seeds(db: Session) -> list[OpsMarqueeSeed]:
return list(
+5 -2
View File
@@ -318,11 +318,14 @@ def update_config(
row.random_current = _monotonic(row, random_initial)
row.random_last_tick_at = now
elif apply_now:
# 立即更新:不等钟点,马上刷新一次
# 立即更新:不等钟点,马上刷新一次
# random:走一档增长;real/manual:直接落到「配置目标值」(manual_value / max(真实,保底)),
# **显式保存即所见即所得,绕过「只增不减」护栏**——运营手动改值就是要按配置显示(含调小)。
# (护栏仍作用于自动 tick 的 _refresh:防门面在两次保存之间被真实刷新/自增长悄悄缩水。)
if row.mode == "random" and row.random_current is not None:
row.random_current = _grow(row, row.random_current, 1)
else:
row.random_current = _monotonic(row, _current_target(db, row))
row.random_current = _current_target(db, row)
row.random_last_tick_at = now
elif row.random_current is None:
# 首次无值:按当前模式播种,使配置后立即有合理展示值
+16
View File
@@ -42,6 +42,22 @@ def _gen_nickname() -> str:
)
def is_default_nickname(nickname: str | None) -> bool:
"""是否为创建时自动分配的默认昵称(= "用户" + 9 位字母数字,见 [_gen_nickname])。
这类不是用户主动设置的昵称,展示脱敏时按无昵称处理( id 规则, ops_marquee._mask_real)
精确匹配生成格式(前缀 + 定长字母数字集),不误伤真人以用户开头的昵称(用户体验师含汉字
长度也不符)用户改过昵称即不再匹配"""
if not nickname:
return False
s = nickname.strip()
return (
len(s) == len(_NICKNAME_PREFIX) + _NICKNAME_LEN
and s.startswith(_NICKNAME_PREFIX)
and all(c in _NICKNAME_ALPHABET for c in s[len(_NICKNAME_PREFIX):])
)
def get_user_by_username(db: Session, username: str) -> User | None:
return db.execute(
select(User).where(User.username == username)
+10 -1
View File
@@ -296,6 +296,9 @@ def daily_auto_exchange(db: Session) -> dict:
- **逐用户独立事务**:单个用户异常 rollback 不影响其他人;exchange_coins_to_cash 内部按用户 commit
返回统计 dict(scanned/converted/skipped_done/skipped_dust/failed/total_cents)
"""
# ⚠️「今天」写死北京时(rewards.cn_today() = datetime.now(CN_TZ).date(), CN_TZ=+8),与服务器/用户
# 时区无关(用户 2026-07-01 硬约束:兑换一律北京 0 点日切)。**禁止**改成 date.today() / 裸
# datetime.now().date()——那会跟随进程本地时区,服务器非 CST 时会在错误的"天"兑/重复兑。
today = rewards.cn_today()
stats = {
"scanned": 0, "converted": 0, "skipped_done": 0,
@@ -610,6 +613,7 @@ def create_withdraw(
source: str = "coin_cash",
user_name: str | None = None,
out_bill_no: str | None = None,
allow_sub_min: bool = False,
) -> WithdrawOrder:
"""发起提现:原子扣款 + 建单 reviewing(待人工审核),**不打款**。
@@ -619,8 +623,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
# 提现即要求已绑微信:否则审核通过也打不了款,提前拦更友好
+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
+4
View File
@@ -30,10 +30,14 @@ class FeedbackQrConfigOut(BaseModel):
class FeedbackRecordOut(BaseModel):
id: int
content: str
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
scene: str | None = None
images: list[str] = Field(default_factory=list)
status: str
reject_reason: str | None = None
reward_coins: int | None = None
# 运营给用户的回复留言(用户端可见,采纳/未采纳都可能有)
admin_reply: str | None = None
created_at: datetime
+1
View File
@@ -69,6 +69,7 @@ class InviteeItem(BaseModel):
avatar_url: str | None = None # 头像 URL;null = 前端画默认色块
coins: int # 这次邀请给我(邀请人)发的金币
invited_at: datetime # 邀请绑定时间(前端转"今天/3天前")
is_compared: bool = False # 该好友是否已完成过一次比价(好友列表分"去提醒/邀请成功";在途列表只取 False)
class InviteeListOut(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
+5 -1
View File
@@ -174,9 +174,13 @@ class FeedResponse(BaseModel):
class TopSalesRequest(BaseModel):
"""销量最高 tab:从离线库 meituan_coupon 按销量降序取(不实时打美团)。"""
# 可选:老客户端(本次改动前发版)不带经纬度。缺省时后端降级返空(status=degraded),
# 不做 422 硬拒,也不误返"全城"结果。新客户端会传坐标 → 按城市过滤。
longitude: float | None = Field(None, description="经度(用于定位城市;缺省=老客户端,降级返空)")
latitude: float | None = Field(None, description="纬度(用于定位城市;缺省=老客户端,降级返空)")
page: int = Field(1, ge=1)
page_size: int = Field(20, ge=1, le=50)
platform: int | None = Field(None, description="可选: 1只外卖 / 2只到店; 不填=全部(城销量)")
platform: int | None = Field(None, description="可选: 1只外卖 / 2只到店; 不填=全部(城销量)")
# ───────────────── 换链 请求 / 响应 ─────────────────
View File
+360
View File
@@ -0,0 +1,360 @@
城市ID 城市名称 省份名称
3NUYJKKJXPHVNZUHFK3HWUDHNM 宣城市 安徽省
LXXSHOY7LNK74ZK2SKVUXFY72Q 阜阳市 安徽省
ZEBF2LBJOEHGM4XGFPNW4IHBIA 合肥市 安徽省
WADWEY3GR6IARJLSNGQWG2KI4E 滁州市 安徽省
Z62QL3X66AOT6MSY7LB6LVO4CI 芜湖市 安徽省
M6QCWRCECRT6ZEJ6RERR6IWHFI 淮南市 安徽省
UP2NBJACSAO7FUH4XQQDMBOUE4 马鞍山市 安徽省
WLL7BSHUBPTLTMITFITJX2GKWY 蚌埠市 安徽省
ECTBNJ4KNNHPGXEVI4TBJNU7BY 亳州市 安徽省
KNEGDNOSRGNQPAMOMH54ATLIUU 六安市 安徽省
YH53FRR55H6VA4KXW36OJ7RNSE 宿州市 安徽省
LH6LX5DUVFPOFCAQ6NEGPWCLBI 淮北市 安徽省
RIFXWJ46SEJXE7EP6HZXEUIALU 铜陵市 安徽省
FK6F7KMO4WNARZKUXMMSBWGATM 安庆市 安徽省
M3CHGNNUSFSPS5HRV3W7MCBWFI 黄山市 安徽省
XFI6PM7SRO6NHBGYQUZEEA2WT4 池州市 安徽省
D2ZILSASTTGEFQWZIDTHA7PWU4 澳门 澳门特别行政区
WKV2HMXUEK634WP64CUCUQGM64 北京市 北京市
HPMKHLM3QR6EZGMY7GEI4H3QYQ 泉州市 福建省
6V523YRU54Y3PEAM2XPADNJM2U 福州市 福建省
HH3ZZCERPVQIYUZPW4A2U4JKZI 莆田市 福建省
BQ5RWEJS4O7W27SQMLPMRIRJDU 宁德市 福建省
TDN7XDQMZEP6ZCK6UO3VVMCSMM 三明市 福建省
ZKNTORN6YTZL2BXRYUSRGV3CHU 厦门市 福建省
Y2DI2QLZN5NNACMD3KI2DBR4IA 龙岩市 福建省
C4RS32I6QAHWLP55UQWI3N5LLY 南平市 福建省
5T23WDEOAP7RYNU4JNL2ZG7PDQ 漳州市 福建省
UHZBROATFB2KWNMNLS23DPRJBY 定西市 甘肃省
WLPAHIUOIVKS644QSN4V5ZY5XQ 金昌市 甘肃省
J7TO3UHZ57ABNUKIQUBCIJZQFA 白银市 甘肃省
GX277SS75375VEFYVCEHBL6ISA 临夏回族自治州 甘肃省
ORA3R7F2LSJHUOCIDTZCP54G7Q 张掖市 甘肃省
L6VQYYOJNTHSLW5JCR5SXBITDM 武威市 甘肃省
65OXQPQVFXNOYHXTMCE2RNFRL4 兰州市 甘肃省
IUIZYQ7E2SPMEAIGOUUNZWBQOA 天水市 甘肃省
BYBRGRDRV4NKAWCU6GWBIVLX3Q 酒泉市 甘肃省
KDST2VRETG6WK5SMJO2G2FN2RU 嘉峪关市 甘肃省
R3Q2XWVFVF4T2ADZZZMPZJRWBA 庆阳市 甘肃省
7FWNT2TP66SU4QEP6IBBMZWFNE 陇南市 甘肃省
T33S2GYGPVHAL5SR2FLSPTJSBE 平凉市 甘肃省
RR6KAWBLOKD4H2UINKYPFCPXO4 甘南藏族自治州 甘肃省
QKX4DS3CTJJG7SFW5SBHPLD42I 茂名市 广东省
JJZ75A32XCQNZU4IN2ZCEUGN3M 梅州市 广东省
SJSOXOSJASLUT6LBH4E32SUKKQ 清远市 广东省
SQQWAN5BQOVSX55S7EPF7QHMAU 珠海市 广东省
NGRJMW6JMRS6U2KUJEONSORAEY 韶关市 广东省
FAUMIGOSOET4E5WR5BL6P3OZHA 佛山市 广东省
JSBIH55ICFZQ2D3LEV47YMZ2NI 河源市 广东省
KOYAYPD2DBLDCF2ZI5GCW4LF6Y 中山市 广东省
647JGFPUYM4VWVLZCPSHT63XKQ 汕头市 广东省
AMOIPZW3Q2NMTDSEREFVM4SV74 深圳市 广东省
XIHEJY4H2CDZJCLIXDIN36BKXQ 广州市 广东省
UZ6OT4CYUR42KCTTED2KJW6EGA 东莞市 广东省
RM3HLOIUEYKTQ5O2JSVEKDMFRY 阳江市 广东省
C6XNJAZA6N3NNUDBUU3JIZPTDI 潮州市 广东省
AUPF3G2ULSV4TDT4L3NMHRTY6Y 揭阳市 广东省
7HIITKBPRXTVBA2FBBN443XITQ 云浮市 广东省
TO6ILZ7MPJMJN3S7W2SXMIFQQY 江门市 广东省
DTTBMGCIOMPCZY5NETUEKWJ6PY 汕尾市 广东省
HJY7JYWBA6FQY42RYSKX3RZTRI 湛江市 广东省
SLICHB4FBDVDLI53MR74WVUUNI 肇庆市 广东省
ZPX4JXJVBBYSSD2KTWHAPXO6NE 惠州市 广东省
JH4Q44RQA4EZ3Q6MHQVEVE7KZQ 百色市 广西壮族自治区
H2JXFEJFIL4PPFMYOS4MHZ5IBM 崇左市 广西壮族自治区
SXIRRISUOEGBU335AWT2ZFL6A4 贵港市 广西壮族自治区
HEQHKC4KP7YGGYVBZM5JEUI5AQ 北海市 广西壮族自治区
SKGG7KMFKVDIDKRVQEPTS7SIE4 贺州市 广西壮族自治区
N4WR7CWCULNA5Z35OTDJSZYDCU 钦州市 广西壮族自治区
T4RXX2WY6WPQYZEUXVJNZBXQZU 梧州市 广西壮族自治区
3R23AS3EIY7EYE2D5MWWORZODI 河池市 广西壮族自治区
57SMWWCV7X44E256P4I23OQ3AA 防城港市 广西壮族自治区
YHGHVIQ37UCTNQ4JKPQEAUWIQA 桂林市 广西壮族自治区
MQJZTM455OKZLAN5WQYUTA5TDE 柳州市 广西壮族自治区
C6FZPLB4NJQ6VUPSKDJH3EWQDM 玉林市 广西壮族自治区
BFSU5W6E5XBIDFPQLVPGRDSATY 南宁市 广西壮族自治区
CKXOQUZDNOVNME3PEBOY2CULQQ 来宾市 广西壮族自治区
LV32FV6IQTKFR7JIBEQMHRUVCA 贵阳市 贵州省
F26RCNKMFTZONJCSJ5C6FHVY74 毕节市 贵州省
G2LMYRWVRCK7BTD2WM4NWX4SYM 黔南布依族苗族自治州 贵州省
MFSOO3NBMB2PVLIVSI5EJK7MWY 黔西南布依族苗族自治州 贵州省
KWUL44L7SEMJGIMXCWSSEB3OOA 遵义市 贵州省
T2P3OFGQZUGR7D6TGRCMDF22GI 铜仁市 贵州省
AGUFUANSZNGC4TMOPZO65IRSPI 六盘水市 贵州省
2XOCOSNUAK3J5QDGTKIBWKK7KU 安顺市 贵州省
6XRTSAEYJTA2UBKXO4XEPQE5ZY 黔东南苗族侗族自治州 贵州省
YRMKRP2GOE2VMRS73N4YRIZUHY 三亚市 海南省
CJRGVLBNLJAVBJ4ZKKIZ3FZ2LY 白沙黎族自治县 海南省
5XOUAJ5Z4J4K7SVIQGXL2OM2JQ 保亭黎族苗族自治县 海南省
2UFQ6A2QRJPXPH3VOYEAQHMVSQ 海口市 海南省
TGCVXVS4M7NDQM4ROUDCVI6I3A 承德市 河北省
JEUP6QWCOXPSM3SQTINQCJKIGM 衡水市 河北省
Z442MNCW6BO2BBHIRUPRBACXPI 唐山市 河北省
RFE6R34GD4FY3LUKC2ICSF6AFY 张家口市 河北省
CWJN55M73VZDCYJEQ7AHDBWGGY 沧州市 河北省
DFL4ES776ECRGBYNOPLWKB247I 雄安新区 河北省
ZLSXYY34IHBHIC2NOVPQQBFTBE 保定市 河北省
3DO6Z2QRJQFMPLLDS55PG7DSBU 石家庄市 河北省
PR57XT25LI3246VGASEPSHP63E 邢台市 河北省
SKYLNH737BS56TD452FOKYL36U 邯郸市 河北省
5PWPERL7GQKJD6QPLR2TWUUM7E 秦皇岛市 河北省
5T2TGV6SJFVL3MO7HIMN2KTQTA 廊坊市 河北省
ECSTLZ7GP7IX3MB5EVNKS47MLE 焦作市 河南省
VTWW34QB2F5Q4LW7ISNUMWX7GY 开封市 河南省
D2NUN47NY4Q55X3UED4JMSI6CM 周口市 河南省
TR3XJFQR4EFYRRIX7TUQF3B26Y 郑州市 河南省
CKJGF5S6XMHW5ZJEBU7MJC47QA 新乡市 河南省
IFASZ625MCFJQKPLJ7EA2SMJUU 商丘市 河南省
SKXPYKTTRG4YAUHE2HZXWRWXGM 鹤壁市 河南省
G5LXE74CUHO2K6BBRN7Q5DSRJY 漯河市 河南省
SLNOAFJV2LTBSH7SJCRXJA36K4 驻马店市 河南省
65WO7LH7CFDUGKYMXQLRAYKKWM 安阳市 河南省
RIX2X7FAVTZCAQ5RT2C2CWK22Q 南阳市 河南省
SZOW5OY3U54SSY4WRC65VJUNTI 平顶山市 河南省
7VPIDDUS4P2LSZ6Q5S57MAQDEM 信阳市 河南省
4VYCRORUOZ4DC2U6S3CT6H6KWE 洛阳市 河南省
M5WNO2BQ3UGLLHBCG4NCEFPP5U 濮阳市 河南省
LY3O6PBPIWETMA3ZOL6ETY5UB4 三门峡市 河南省
FXXJLIRE72LS2W4OWWQVJMRJHA 许昌市 河南省
5XH353QTY3VWF2KYOCZCL3TOXY 牡丹江市 黑龙江省
2KGRZKF6IECV2W7K5J64Y2LY4M 齐齐哈尔市 黑龙江省
FASGWS5ADVSTFGJG6TGBZPZP6Q 鹤岗市 黑龙江省
2O6CDIXSWIKBXILZEEPKCS7MVI 双鸭山市 黑龙江省
OZ2PTOBYTBG57XJZMIC23QFJKM 佳木斯市 黑龙江省
FO24MQMULT3J5JW64APNXSQEPU 伊春市 黑龙江省
ETZ2HYWVU6U7SKU6G4JAO64RUQ 黑河市 黑龙江省
PR7EJNBY2VZBEUT36JAWE3TM7I 七台河市 黑龙江省
HADAAVLERKIW4SQGCTQYGX4AL4 哈尔滨市 黑龙江省
CGTU45YC5C3JYLHMA47USDPA7Y 大庆市 黑龙江省
T4W7SQIPOM4EYMEFFRAB5BSTII 鸡西市 黑龙江省
TYGZHNQL6YT7CX6EEG5DJQQHMA 绥化市 黑龙江省
OOSJTSN2CVUUCKD6XAB7EYYIPY 大兴安岭地区 黑龙江省
I3YF3EKZHIZTN6TZOTYTGZ2UXQ 随州市 湖北省
ESGVBOSTHW7JWEVCGYJUTEHEBQ 宜昌市 湖北省
MTJRWJ53XBW5SBTWHKNNZDLM7U 十堰市 湖北省
PXZLF2ISKQL5ACM67ZCBNOGDT4 黄石市 湖北省
44RMTOEHPUFXBHZXX4IQ4IRZVQ 荆州市 湖北省
OTKZGG743NFC474ADMMRX4ZOOA 鄂州市 湖北省
EXOUAZAQ73OEFAK72CHQ32GQHQ 恩施土家族苗族自治州 湖北省
SUCY7I72QJDZD7EBFXREIQ67SI 咸宁市 湖北省
QENSGB5R7HGYDXCG2LQZQTO3TU 荆门市 湖北省
OHIWL6SAE2PR4EJR4BOMLAE6FU 武汉市 湖北省
ZXCE4WV2CDVPQTA4HAOVELQMNE 襄阳市 湖北省
KEFN5OPSS4ZZF6NU2TTL72S6HE 孝感市 湖北省
ROAHLMQ67H6M5NDFVXROJG723E 黄冈市 湖北省
YBEBX2YYN4WPBNH6Z6C73DNE7I 张家界市 湖南省
45XGRKYGSCPE5VNRYF4FVJGFMM 株洲市 湖南省
LK3SEIBRU7GTDT4J2EPTLIO33U 永州市 湖南省
R4YXFIK53W5E556BSGSBJWS4DM 郴州市 湖南省
PQDO3RNADWXX75OWZW2GSXJ4SE 怀化市 湖南省
RRRT6QOJYEJ432L3F76ZN5NHCA 长沙市 湖南省
B6WPNMCZ3ENQSV4NFY5MSTPDAM 岳阳市 湖南省
KNDZW5EHDPKP2DX7HBLKP4DYLM 益阳市 湖南省
PA2GHG3XZ7I47HTKZ4YAFH3OYY 湘西土家族苗族自治州 湖南省
I7CNIUA5PYV2EHDEW3RGYT2R4U 邵阳市 湖南省
SRI2SU4FN66FMJJCKQOCZD72ZY 常德市 湖南省
H7UHHJAMQUL7UA5QEUTGKNSL3A 湘潭市 湖南省
EFB255OBTB2BUDZENR5UVIC7ZQ 衡阳市 湖南省
RDMANB4KCM3OJSNVGZWVYVME6E 娄底市 湖南省
LD37PDU5OB4UAV5QDOBMKG5YTY 吉林市 吉林省
EO3GF4XNF5RXWRPUVAT3KTQO4U 四平市 吉林省
4GD7OS4CAQABH5YIWVK5SKGHMY 通化市 吉林省
6DRI2R5VAWMYJHJPCJKUNMDYEQ 延边朝鲜族自治州 吉林省
TVBCNVGUND4MOUOOUXFGX7DIUA 松原市 吉林省
JYY62HSKBUVK5OU7KGJDKQ4RTA 白城市 吉林省
QEDUHKMZ36CHJTKRD6O2ZPLNBU 长春市 吉林省
4EADVCBJMZ5UBH2FVRT6QCLS2U 辽源市 吉林省
EUQD5EGS2LR5KJSFNG6PPSIHHI 白山市 吉林省
YLTIISPCLBEGTZZX3WUWAD7WDE 淮安市 江苏省
L6U5DZP6MESXPMHOHCDMJS55O4 宿迁市 江苏省
HQMLYA7TDGMYQAXFCDUXBZPYHI 镇江市 江苏省
K6XJ4UN65ZD6XQKYEG5YN7HCRI 盐城市 江苏省
36I4X3EZZU4EHOSCLQI5OAKKBE 南通市 江苏省
S3GWFQU6QAVRDKLJT77LD6OFLE 泰州市 江苏省
IO6F4AFGAVIFRGYTZEC4TXM7W4 无锡市 江苏省
NUXNK2VOFSD2JFTEO2AMWX6NSU 扬州市 江苏省
TEVZU6CU6SK57HFW7DFNGMQ44A 南京市 江苏省
UTYSRBQ4FSB7XLWCF3Z2HTKNUA 常州市 江苏省
OCZOBCJDEXKE7KBN3BD7AYQG2Q 徐州市 江苏省
6LIBPJGZROLXE3CLZGJRYMYBOU 连云港市 江苏省
FS4PIU74F7QKYARDWR5ZMOLICI 苏州市 江苏省
R2F4OWUO65HYZW2IQIKINORZ7Y 赣州市 江西省
YW346BTN3VFYNRC3744UR5MZXY 抚州市 江西省
QR3FDR26U2EJIXOMBHL7IJLQSA 南昌市 江西省
OAJHJL7L7VNW2Q5UXRE7F4CUJQ 九江市 江西省
OMH7D45R4DX2KNHLV3G2UP56OY 景德镇市 江西省
YSB2PAEROB2IZSJZFVFH7KJPEI 鹰潭市 江西省
5OYAMNORCXKYA6UF7DW6KFFBIU 上饶市 江西省
SMHZOYKE7BXQJ2NT6Q24TFMLEQ 吉安市 江西省
2RZV26OUPKUHUJ5ZPB673VDGZU 萍乡市 江西省
232VHZEEZ6SXACE4AC5HQ4ZTFQ 新余市 江西省
QRLM74YXNDW2QDBWLTFGEMXK2I 宜春市 江西省
S6OHUVUKIIWPVMQD44RREUMNT4 葫芦岛市 辽宁省
DQQ4OIFUGFYJY3XZRK5VDWMLCA 辽阳市 辽宁省
S4YXGFEYXEUG6ISZ6O337OPVSI 阜新市 辽宁省
D3JHM7A4CG6RJMBD7YRDS5JOYU 盘锦市 辽宁省
VTRWMOSS6PCUYUAIPG6VPBKUUQ 营口市 辽宁省
ZPHFGWBIEVLKP5CVZNZUB3CRT4 朝阳市 辽宁省
NGYYULZ4UAGD3Q2PG726FFXSHU 抚顺市 辽宁省
Q5BRTSW752VSHIAKLLL7KL5TNA 锦州市 辽宁省
ZGV3WNPOSS7J4ZWBP6ZQG46BNM 沈阳市 辽宁省
XEX676YYMTYIV5QPIUZB4TA7IY 本溪市 辽宁省
PRTEQZMLNLQNZXJHRCYYLWZB4E 丹东市 辽宁省
4GN4WF6UQRFU64T4FVZPRDXRWQ 鞍山市 辽宁省
3QTZDFLJFSLLOOVCZ65PSDAVOU 铁岭市 辽宁省
CC4ZTMKKXI73ZEVT5QQTJN5SMM 大连市 辽宁省
3MBJEFDLAVOMQZ7L7CM5MNSYKA 鄂尔多斯市 内蒙古自治区
5NOS4YC5WO2IZCQPVB6MCBYDJ4 呼和浩特市 内蒙古自治区
OQNIP675H7L5R64652BH7KHUOQ 通辽市 内蒙古自治区
4WA6I63MGVINV5DNLNWRRHCDDM 阿拉善盟 内蒙古自治区
ELI6BDJBAN6RCYTETMK2EX2UKU 乌兰察布市 内蒙古自治区
PV5ZAAXFW2DZCVZKCF4I4KK7BQ 巴彦淖尔市 内蒙古自治区
YU6UUT6G6T6AMWTJFECDIUQFEQ 乌海市 内蒙古自治区
V4MYANW5QFZCXG3FIDPXA3HOTE 呼伦贝尔市 内蒙古自治区
S5DCFJWJ7J3MJSLY2PHKWLNPOQ 包头市 内蒙古自治区
LY7SAZRFSJJMRU3JEO5SKNKIVM 兴安盟 内蒙古自治区
NM2XP54CNQCFOILKACYEQWUSGM 锡林郭勒盟 内蒙古自治区
S5G3IO75IDEJPZQA6VFM3OYPDI 赤峰市 内蒙古自治区
UUFUUPM5RT6ZU5UKILQC5YQV54 吴忠市 宁夏回族自治区
VMSRLIATK44WQXQEWAL63AXJ3M 固原市 宁夏回族自治区
4GWWCAAKGNJV2SMQPSWWZNCGYY 中卫市 宁夏回族自治区
6IE7GEETBQEF7GUSGU2FLIUEEM 石嘴山市 宁夏回族自治区
VI4YIH3URSON4Q4MWOEESXJ56Q 银川市 宁夏回族自治区
SIE4ED6QWVRT727GEHWBFH3DAA 海东市 青海省
JNJH6OJZIOQKXDXWW5ZGEHG5MA 海西蒙古族藏族自治州 青海省
MJADYNCKQNDJU2TXACTDP5I52M 海北藏族自治州 青海省
LRGFXIVB6RJWQWYAFH7EIUHCPE 黄南藏族自治州 青海省
J4TG3PCK2ZEMNEUMIPZF32UNQY 果洛藏族自治州 青海省
2YS5POGG53LKZGFBIUMDWP57SM 玉树藏族自治州 青海省
GRZMJEZCA2DNCZK3O6ZSUHMRPM 西宁市 青海省
NBFQIACRBBCAH5AZWJ5LVT7AU4 海南藏族自治州 青海省
MQUKCLQ76P4FRRECDBA3HBKT7Q 滨州市 山东省
633FVSBDDBM5WSMXSKOCX6QC5I 潍坊市 山东省
4434FVT3PXLMV6UAWLEW6O3M5A 菏泽市 山东省
P7PK4UBVCOHW3PI6IPEIA54DLY 济南市 山东省
I5M6JGTGSQEWX6HL7E5I6GRBAY 德州市 山东省
V562AOMBVU5NG5GB3EPK6U42XY 烟台市 山东省
4OSPHTE5TD24J6DYGR6DXMEDKY 淄博市 山东省
227TLAVTUJABWPJD4S4ZECJ3FY 临沂市 山东省
DAEZKZU32ZAPJGUTA6LLGO3WTY 聊城市 山东省
LBRRK2EOYJN5MLYJWT4R3QBSXM 东营市 山东省
AB6PBGCDBTNTG4KUQROY2FJ4GY 枣庄市 山东省
EVANGU7WZCVRAAM6NWTDJVP7SU 济宁市 山东省
GNUEGWZ3OKRWAKKVJ5THHHX6YY 泰安市 山东省
F3VWSF4ART2FYYBBOZYWKRXTUI 青岛市 山东省
KD6MNWWLVKB4E655XMV6MMA3KE 日照市 山东省
LHYVF4LBCOZ34G3WNZYUVEIQGA 威海市 山东省
ENVYDMYGDO3BMDXSAVQYZXLX74 阳泉市 山西省
UXOUG4UIF7ZRJYCNMQJ3LDN5FY 临汾市 山西省
4NZPT6Z35BMYACJ2HZGHUWRJ6E 吕梁市 山西省
KSNXQME2A3VFHCE3DM3SFZKIJQ 晋城市 山西省
HDOX7WKYSHJKEHET6TUYMVCTMQ 太原市 山西省
DFJIZVXJGBGBIABPSL3DGMIDIE 长治市 山西省
5KQYYTJR2EMP653QIALMA6LXXI 忻州市 山西省
T76EOJA332RIHML7B6LYS5LF4U 朔州市 山西省
HVX67CKT5TS6GPRDFDYOOLK4PE 大同市 山西省
S4NGXQJDOH7E4IHDWOH3EK6IIE 晋中市 山西省
GWDLZXLAWU54FKQ6G3HRQRR7E4 运城市 山西省
3FFTTN5PPV7MBCE5AGY2NGYOOI 安康市 陕西省
GACVPL3SWO3ZKH73JMJV6YI4NY 延安市 陕西省
6KPS7VRMW57P2DAC6OPR4ISHQQ 商洛市 陕西省
OMMF6XLNDNYWG5TBSNTWO2ZJZ4 渭南市 陕西省
EALFXGMWYRS6E6TWXQ2K3YHV4M 咸阳市 陕西省
WFG7U6JNUWDIS5ZZYM4FSM5C64 榆林市 陕西省
R3VBMYTCF5LVHO35X3MYJQFOOE 宝鸡市 陕西省
RQOWP7C234IS4RKSHB26IYZ5IU 西安市 陕西省
K4YU6B4T5GZLRWVHGVCR3576HI 铜川市 陕西省
VVFVAPLKSCN5KN4Q6RK2GPGUUA 汉中市 陕西省
2QSF6IG3KMDXWO5VP7FXHMMKXA 上海市 上海市
X3JCRNIPTCUU6DGOFFJ4MUK37M 眉山市 四川省
GQ24IZNTZJ3PUB5FDAMDA4W7UI 攀枝花市 四川省
6B6WT62WHBZRHPQUT7BAD2N6ZI 泸州市 四川省
HJ35P4KXL442MLIII7PFFWUNAE 雅安市 四川省
K4A6VSJH2AJYT46LMUSVQZPCCU 资阳市 四川省
646ZNPATOOM3MHI3LDU6HI4KFI 阿坝藏族羌族自治州 四川省
MU735ZDBFPXRQDUZ3I35JK3XEU 内江市 四川省
4WPGGJ63USY77GSRN2PPFCYKPQ 广安市 四川省
O4FFS4DALDAAKIFAUH4F5V5VS4 宜宾市 四川省
IRFJVK2KXBE6BZ7CSN4UFCI624 绵阳市 四川省
NELFD7FEKKUNDJ46VLD55SMDCE 甘孜藏族自治州 四川省
TKMVEUPZSQCXNRZPBEIK3F45AI 遂宁市 四川省
VQW7DPB4KTUI65COJBO3NODU24 巴中市 四川省
STP4ELXTVGQSB572LFRFJRIUUY 南充市 四川省
6ST5EX2JVXUCLR5GP5VEFSKN5M 成都市 四川省
UWNFCMW3HYJRALQI2MJH6EM2O4 德阳市 四川省
J5ZYU7XRV6CHSJAGOPQKS5YXNA 达州市 四川省
KYJTF5S746T35RFBMR65BLGM6U 凉山彝族自治州 四川省
RDUXR23XROLB4NGRVDDLXFXDSE 乐山市 四川省
4TUBIBHMVESJUCGMTUSLJPHXWI 广元市 四川省
AXQL57AO27NCHYMEOLRHAAKMTA 自贡市 四川省
4RXX566RZORCXS6HLEX3DIICSM 花莲县 台湾
BD5Y7SISWSSQGP3HTVPU6TXAH4 台东县 台湾
I4DNWLECRYOJAZLGYQB7PBBJXQ 台中市 台湾
GIZQIESFOMAEQSDQKOEQ5RTTPA 南投县 台湾
MPM6M2C634FAW7KYG3KIHERDTU 彰化县 台湾
NH2NK6JVOBBYYTK2G53ADWLX4Y 苗栗县 台湾
DW2Q2R2UEEDNQA7IHBGYV423K4 新竹市 台湾
FX5AOPFRPHGHHYZB4XNIPXLNNM 新北市 台湾
UVNZNB6G4M35RV3IGRUN6OXMXE 屏东县 台湾
MPB3M2YK24ZORO3EDCJ6UDWIGQ 基隆市 台湾
EBHVITJPDHJEEMMZTM4TD4UVRU 台北市 台湾
FQS4PZNCTQEX6I5F34Z2AGJUZM 高雄市 台湾
WVOJ636Q7MGT6RMN6QYQ4SZWIE 嘉义市 台湾
QRLER4EEMMKYGQLER2RWEQA74E 台南市 台湾
K2LHF64R2P4OJ7MDHJ6J2NSTPE 桃园市 台湾
BILG6LJIWUCTXZ6CDPXYAVM6XI 澎湖县 台湾
3FYRA3O2HUMLIQAAJQCPX2TETE 宜兰县 台湾
4MW6X22PAPVMHB6SBGF3RYS324 天津市 天津市
YEYPP4SQOBXU5UCNDN7ORSR6DI 拉萨市 西藏自治区
UNE6UPENGWQDOWGABDJEAQ2FEY 山南市 西藏自治区
VDXKN2YCIUPOHBZKKQHPN6HJWE 林芝市 西藏自治区
EAWIMNI77H72EYSOAYV3M76CB4 阿里地区 西藏自治区
HCF6UHTXOOKIOA43AIJH3ARKXA 昌都市 西藏自治区
Y47QI3KJY352QV3VOPXHM2IDWU 日喀则市 西藏自治区
R4UWJX44GVAA54NFKHT4Y4ZC5A 那曲市 西藏自治区
2D37GB5XUALJDXONWJIGXV3QXU 香港 香港特别行政区
PA5W7Z255K3EGYA4LTA7BGLHRA 巴音郭楞蒙古自治州 新疆维吾尔自治区
RJZOCU5ECQCOLCOCHJH2UNLQJM 哈密市 新疆维吾尔自治区
RYK6AR3VDQJFXZLX3MHYFV5VAI 塔城地区 新疆维吾尔自治区
MXUVGU5NPVTNINAKKPNLWUQ54Q 博尔塔拉蒙古自治州 新疆维吾尔自治区
T5RKMSH5VIGRTHDZOKV2EIKPIM 伊犁哈萨克自治州 新疆维吾尔自治区
NABMKFZPOUCZMS4TUVJSZ24DNA 克孜勒苏柯尔克孜自治州 新疆维吾尔自治区
2YQBXWNFYVJX4NU6WXB5II6X34 昌吉回族自治州 新疆维吾尔自治区
DBKCQCCQU2URQG2EP5ZNPKBETY 乌鲁木齐市 新疆维吾尔自治区
DON6KYBCR2XJQOJQGOZTYV4RMM 阿克苏地区 新疆维吾尔自治区
WKG47NEVYII5JISZJN7QSI2BDQ 克拉玛依市 新疆维吾尔自治区
DWLK6D3OUOXOVQHSEJVTRIDRAI 喀什地区 新疆维吾尔自治区
SAPKF2PQGJD4UMVJZTC3IZKI64 阿勒泰地区 新疆维吾尔自治区
ARWSLGG54LGUGN3XMIWW76NW34 吐鲁番市 新疆维吾尔自治区
VQCWAKL6ADHYFTSVPBGPDFSB2I 北屯市 新疆维吾尔自治区
ES5A6MOROAG6F2XAQ25TYYPWUE 铁门关市 新疆维吾尔自治区
36IUY52AESIPF4QEQAR2RTNQYA 和田地区 新疆维吾尔自治区
Z26KSUL6ULS65ITZNUCRRWBYJM 文山壮族苗族自治州 云南省
XBBUUATPBD2IUJR47FCKVUXB2I 昭通市 云南省
TK2LP3JCYYMPTV4OA3WJCH7UQ4 怒江傈僳族自治州 云南省
ISJ4FESOYKCQ5LXOQO6NL7TQHA 曲靖市 云南省
ELBUBVI5UMUIEQGIOAHPMESXFA 西双版纳傣族自治州 云南省
QHOYHEGZM4WSJZPFEU6FCBYIWY 玉溪市 云南省
4G4SPJ7MVHMYZAWMQ4642PMLVI 保山市 云南省
HCHRV2LGJ2TJ4X6BWNWI2IMID4 普洱市 云南省
IS4Q6NASBWHO3RFO7UCIWOXVI4 昆明市 云南省
TDJZOAZFQUQPYRR5BHOJTG6RWM 红河哈尼族彝族自治州 云南省
EIYC62RNU4SHQW3RLAMDPTQYTI 大理白族自治州 云南省
BA3XFPITAYKBUWDKU3QONRHBC4 德宏傣族景颇族自治州 云南省
6P6FFFO6C5MLNCVRJAJUICSVQI 临沧市 云南省
OXHMWH2TSIDI7BQ43EHAMXJ6N4 丽江市 云南省
QQPDT4LBI2K2KMBNXZ6YH7X2FI 楚雄彝族自治州 云南省
XBG4EJAWCRJL2TDPNJ23PTFYHQ 迪庆藏族自治州 云南省
DINNCH54AP74TJ62MICEYAZP74 宁波市 浙江省
XYTSLYGB2ETU6HG7GIXA7X5SOE 嘉兴市 浙江省
NNAALJZXGAWALR3LGE2V4UZT6U 丽水市 浙江省
H5UOJ5MQYJ737GS3TXYN2OJHUU 杭州市 浙江省
HG5VQGOMSCEGNXJXKO6XCNCHMY 湖州市 浙江省
LJ2SWEPRINTYDH5A2QHRMI5US4 衢州市 浙江省
TW4RRM62TDA7WWU77FDSLSAXGY 台州市 浙江省
HBBN247QZ6YUW5ZYSS6D7RVJCA 绍兴市 浙江省
GVFB23SJZGRPRXXTIKDCAOCDPI 金华市 浙江省
UEW4ENX7N7IFGFM7FD5SZ5GI7Y 舟山市 浙江省
HCCXS5DGRJQMYZMRLGVAMUIQEA 温州市 浙江省
FDGY55I6IHKY76E3MWDBOT2R6Y 重庆市 重庆市
+65
View File
@@ -0,0 +1,65 @@
"""通过经纬度反查城市(reverse_geocoder 离线库,零网络调用)。
reverse_geocoder 内置 ~2.5M 条全球城市/聚居点的经纬度地名映射表
构建一次 KDTree~几十MB 内存查询为纯内存搜索不作任何外部网络调用
必须持有单例且用 mode=1单进程
- reverse_geocoder 的模块级 rg.search()/rg.get() **每次调用都会 new 一个 RGeocoder**
即每次都重新解析 ~2.5M CSV + 重建 KDTree数秒/绝不能在服务端按请求调用
- 默认 mode=2 multiprocessing CPU spawn 子进程做并行查询在服务端 / Windows
spawn 下会重复 import 主模块 __main__ guard 时直接报错既慢又危险
故本模块持有一个 mode=1 RGeocoder 单例建一次树复用查询走单进程内存搜索
精度说明gazetteer 里的中国数据粒度不一致直辖市/省会通常直接命中城市名
但部分城市会命中到区/街道级如天津Erwangzhuang西安Zhangjiabao
此时 admin1省级行政区可作为回退业务侧建议优先用 admin1 做城市级判定
"""
from __future__ import annotations
from typing import Any
import reverse_geocoder as rg # type: ignore[import-untyped]
# mode=1 单进程 KDTree 的单例;None 表示尚未构建(见 ensure_loaded)。
_geocoder: rg.RGeocoder | None = None
def ensure_loaded() -> None:
"""构建(或复用)RGeocoder 单例(幂等)。
首次调用解析 ~2.5M CSV + 构建 KDTree~秒级数十 MB生产应在 main.py
lifespan 启动阶段主动调用一次把这份一次性成本摊到启动避免砸在第一个
/feed?tab=rec / /top-sales 请求上mode=1 = 单进程 spawn 子进程
"""
global _geocoder
if _geocoder is None:
_geocoder = rg.RGeocoder(mode=1, verbose=False)
def get_city(latitude: float, longitude: float) -> dict[str, str]:
"""根据经纬度反查最近聚居点。
返回 dict:
- name: 最近聚居点名称英文 "Beijing" / "Fengsheng"
中国境内可能是区/街道级海洋/无人区返 ""
- admin1: 省级行政区英文 "Beijing" / "Hubei" / "Chongqing Shi"
直辖市 admin1 即为城市名
- country: ISO 3166-1 alpha-2 "CN"
- latitude: 匹配到的参考点纬度字符串
- longitude: 匹配到的参考点经度字符串
未匹配到海洋/远洋时返回空字符串字段
"""
ensure_loaded()
assert _geocoder is not None # ensure_loaded 保证已构建
results: list[dict[str, Any]] = _geocoder.query([(latitude, longitude)])
if not results:
return {"name": "", "admin1": "", "country": "", "latitude": "", "longitude": ""}
r = results[0]
return {
"name": str(r.get("name", "")),
"admin1": str(r.get("admin1", "")),
"country": str(r.get("cc", "")),
"latitude": str(r.get("lat", "")),
"longitude": str(r.get("lon", "")),
}
+448
View File
@@ -0,0 +1,448 @@
"""美团城市词典 + reverse_geocoder 离线反查。
feed 入参的 latitude/longitude 计算出美团城市 ID
用于后续美团 CPS 接口的 cityId 参数
跨系统耦合:本模块返回的 city_id 取自 data/city_dict.txt,而离线库
`meituan_coupon.city_id` ETL(另一套系统)灌入二者必须用同一份城市 ID 口径,
否则 `WHERE city_id == <本模块结果>` 会静默查到 0 接口永久降级返空
改动 city_dict.txt ETL 的城市 ID 来源时,务必同步两侧
"""
from __future__ import annotations
import logging
import re
from functools import lru_cache
from pathlib import Path
from app.utils.geo import get_city as _get_geo_city
logger = logging.getLogger("shagua.meituan_city")
# city_dict.txt 作为运行时数据随包分发(见 pyproject [tool.setuptools.package-data])
_CITY_DICT_PATH = Path(__file__).resolve().parent / "data" / "city_dict.txt"
# ─────────── 反向地理编码 admin1 → 中文省份名 ───────────
# reverse_geocoder 的 admin1 格式不统一:
# 直辖市: "Beijing" / "Shanghai Shi" / "Tianjin Shi" / "Chongqing Shi"
# 省份: "Guangdong" / "Jiangsu Sheng" / "Hubei" ...
# 自治区: "Xinjiang Uygur Zizhiqu" / "Tibet Autonomous Region" ...
# 下面用前缀匹配,去掉了 Sheng/Shi/Zizhiqu/Autonomous Region 等后缀。
_PROVINCE_EN_PREFIX: list[tuple[str, str]] = [
# 直辖市 — admin1 即城市名
("Beijing", "北京市"),
("Shanghai", "上海市"),
("Tianjin", "天津市"),
("Chongqing", "重庆市"),
# 省
("Hebei", "河北省"),
("Shanxi", "山西省"), # 注意: 指山西省,不是陕西
("Liaoning", "辽宁省"),
("Jilin", "吉林省"),
("Heilongjiang", "黑龙江省"),
("Jiangsu", "江苏省"),
("Zhejiang", "浙江省"),
("Anhui", "安徽省"),
("Fujian", "福建省"),
("Jiangxi", "江西省"),
("Shandong", "山东省"),
("Henan", "河南省"),
("Hubei", "湖北省"),
("Hunan", "湖南省"),
("Guangdong", "广东省"),
("Hainan", "海南省"),
("Sichuan", "四川省"),
("Guizhou", "贵州省"),
("Yunnan", "云南省"),
("Shaanxi", "陕西省"), # 双写 a 是官方拼音
("Gansu", "甘肃省"),
("Qinghai", "青海省"),
# 自治区 — 注意匹配顺序, Xinjiang 要在 Guangxi 前面(Guangxi 也是 Xi 开头但先匹配 Xin 不会误判)
("Guangxi", "广西壮族自治区"),
("Inner Mongolia", "内蒙古自治区"),
("Nei Mongol", "内蒙古自治区"),
("Tibet", "西藏自治区"),
("Xizang", "西藏自治区"),
("Ningxia", "宁夏回族自治区"),
("Xinjiang", "新疆维吾尔自治区"),
# 特别行政区
("Hong Kong", "香港特别行政区"),
("Macau", "澳门特别行政区"),
("Macao", "澳门特别行政区"),
# 台湾(city_dict 里省份名为 "台湾",没有省/自治区后缀)
("Taiwan", "台湾"),
]
# ─────────── 常见城市名 英文→中文 映射 ───────────
# 覆盖所有直辖市 + 省会 + 一线城市 + 部分 reverse_geocoder 只能命中到区/县的城市。
# key 全小写,匹配时做小写比较。
_CITY_EN_TO_CN: dict[str, str] = {
# 直辖市
"beijing": "北京市",
"shanghai": "上海市",
"tianjin": "天津市",
"chongqing": "重庆市",
# 省会 / 副省级
"guangzhou": "广州市",
"shenzhen": "深圳市",
"chengdu": "成都市",
"hangzhou": "杭州市",
"wuhan": "武汉市",
"xi'an": "西安市",
"nanjing": "南京市",
"changsha": "长沙市",
"zhengzhou": "郑州市",
"jinan": "济南市",
"kunming": "昆明市",
"fuzhou": "福州市",
"harbin": "哈尔滨市",
"lanzhou": "兰州市",
"guiyang": "贵阳市",
"nanning": "南宁市",
"shijiazhuang": "石家庄市",
"taiyuan": "太原市",
"shenyang": "沈阳市",
"changchun": "长春市",
"hefei": "合肥市",
"nanchang": "南昌市",
"haikou": "海口市",
"hohhot": "呼和浩特市",
"huhehaote": "呼和浩特市",
"urumqi": "乌鲁木齐市",
"wulumuqi": "乌鲁木齐市",
"lhasa": "拉萨市",
"yinchuan": "银川市",
"xining": "西宁市",
# 其他常见城市
"xiamen": "厦门市",
"suzhou": "苏州市",
"qingdao": "青岛市",
"dalian": "大连市",
"ningbo": "宁波市",
"wuxi": "无锡市",
"foshan": "佛山市",
"dongguan": "东莞市",
"zhuhai": "珠海市",
"zhongshan": "中山市",
"wenzhou": "温州市",
"shaoxing": "绍兴市",
"jiaxing": "嘉兴市",
"jinhua": "金华市",
"taizhou": "台州市",
"yangzhou": "扬州市",
"nantong": "南通市",
"changzhou": "常州市",
"xuzhou": "徐州市",
"zhengjiang": "镇江市",
"yantai": "烟台市",
"weifang": "潍坊市",
"zibo": "淄博市",
"linyi": "临沂市",
"weihai": "威海市",
"rizhao": "日照市",
"luoyang": "洛阳市",
"kaifeng": "开封市",
"xinxiang": "新乡市",
"nanyang": "南阳市",
"yichang": "宜昌市",
"xiangyang": "襄阳市",
"huangshi": "黄石市",
"zhuzhou": "株洲市",
"xiangtan": "湘潭市",
"yueyang": "岳阳市",
"hengyang": "衡阳市",
"mianyang": "绵阳市",
"luzhou": "泸州市",
"yibin": "宜宾市",
"nanchong": "南充市",
"zigong": "自贡市",
"qujing": "曲靖市",
"yuxi": "玉溪市",
"zunyi": "遵义市",
"guilin": "桂林市",
"liuzhou": "柳州市",
"sanya": "三亚市",
"tangshan": "唐山市",
"baoding": "保定市",
"handan": "邯郸市",
"qinhuangdao": "秦皇岛市",
"langfang": "廊坊市",
"datong": "大同市",
"changzhi": "长治市",
"linfen": "临汾市",
"baotou": "包头市",
"ordos": "鄂尔多斯市",
"eerduosi": "鄂尔多斯市",
"daqing": "大庆市",
"qiqihar": "齐齐哈尔市",
"jilin_city": "吉林市",
"anshan": "鞍山市",
"fushun": "抚顺市",
"benxi": "本溪市",
"jinzhou": "锦州市",
"yingkou": "营口市",
"dandong": "丹东市",
"huizhou": "惠州市",
"jiangmen": "江门市",
"zhanjiang": "湛江市",
"maoming": "茂名市",
"zhaoqing": "肇庆市",
"chaozhou": "潮州市",
"shantou": "汕头市",
"shaoguan": "韶关市",
"meizhou": "梅州市",
"jieyang": "揭阳市",
"qingyuan": "清远市",
"heyuan": "河源市",
"yangjiang": "阳江市",
"shanwei": "汕尾市",
"yunfu": "云浮市",
}
# ─────────── 省会映射(城市匹配失败时回退) ───────────
# city_dict.txt 内省份的第一个城市不一定是省会,故显式维护。
_PROVINCE_CAPITAL: dict[str, str] = {
"安徽省": "合肥市",
"澳门特别行政区": "澳门",
"北京市": "北京市",
"福建省": "福州市",
"甘肃省": "兰州市",
"广东省": "广州市",
"广西壮族自治区": "南宁市",
"贵州省": "贵阳市",
"海南省": "海口市",
"河北省": "石家庄市",
"河南省": "郑州市",
"黑龙江省": "哈尔滨市",
"湖北省": "武汉市",
"湖南省": "长沙市",
"吉林省": "长春市",
"江苏省": "南京市",
"江西省": "南昌市",
"辽宁省": "沈阳市",
"内蒙古自治区": "呼和浩特市",
"宁夏回族自治区": "银川市",
"青海省": "西宁市",
"山东省": "济南市",
"山西省": "太原市",
"陕西省": "西安市",
"上海市": "上海市",
"四川省": "成都市",
"台湾": "台北市",
"天津市": "天津市",
"西藏自治区": "拉萨市",
"香港特别行政区": "香港",
"新疆维吾尔自治区": "乌鲁木齐市",
"云南省": "昆明市",
"浙江省": "杭州市",
"重庆市": "重庆市",
}
# ─────────── 城市字典加载 ───────────
def _parse_city_dict(path: str | Path) -> list[dict[str, str]]:
"""解析 city_dict.txt,返回 [{city_id, city_name, province_name}, ...]。
city_dict.txt 格式TSV:
城市ID\t城市名称\t省份名称
示例行:
3NUYJKKJXPHVNZUHFK3HWUDHNM\t宣城市\t安徽省
"""
data: list[dict[str, str]] = []
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
parts = line.split("\t")
if len(parts) < 3:
continue
city_id, city_name, province_name = parts[0], parts[1], parts[2]
if city_id == "城市ID":
continue # 跳过表头
if city_id and city_name and province_name:
data.append({
"city_id": city_id,
"city_name": city_name,
"province_name": province_name,
})
return data
# 模块加载时一次解析
try:
_CITY_DICT: list[dict[str, str]] = _parse_city_dict(_CITY_DICT_PATH)
except Exception:
logger.exception("加载 city_dict.txt 失败,美团城市反查将不可用")
_CITY_DICT = []
def _build_province_index() -> dict[str, list[dict[str, str]]]:
"""构建 省份名 → 该省全部城市列表 的索引。"""
idx: dict[str, list[dict[str, str]]] = {}
for entry in _CITY_DICT:
idx.setdefault(entry["province_name"], []).append(entry)
return idx
_PROVINCE_INDEX: dict[str, list[dict[str, str]]] | None = None
def _get_province_index() -> dict[str, list[dict[str, str]]]:
global _PROVINCE_INDEX
if _PROVINCE_INDEX is None:
_PROVINCE_INDEX = _build_province_index()
return _PROVINCE_INDEX
# ─────────── 查询 ───────────
def _map_admin1_to_cn_province(admin1: str) -> str:
"""将 reverse_geocoder 的 admin1 映射到 city_dict 中的中文省份名。"""
if not admin1:
return ""
normalized = admin1.strip()
# 多级匹配:先精确、再前缀
for en_prefix, cn_name in _PROVINCE_EN_PREFIX:
if normalized == en_prefix or normalized.startswith(en_prefix):
return cn_name
return ""
def _lookup_city_in_province(city_en_lower: str, province_cn: str) -> str:
"""在指定省份内查找匹配的城市名(EN→CN 映射)。"""
if not province_cn:
return ""
index = _get_province_index()
candidates = index.get(province_cn, [])
if not candidates:
return ""
# 1) 精确映射
if city_en_lower in _CITY_EN_TO_CN:
cn_city = _CITY_EN_TO_CN[city_en_lower]
for c in candidates:
if c["city_name"] == cn_city:
return cn_city
# 2) 前缀/包含匹配(处理 admin1 直辖市场景:行政区 → 直辖市本身)
for c in candidates:
# 去掉"市"后缀比较
city_core = c["city_name"].rstrip("")
if city_en_lower.startswith(city_core.lower()) or city_core.lower().startswith(city_en_lower):
return c["city_name"]
# city_en_lower 可能是拼音,city_core 是中文,尝试从 EN→CN 映射反向匹配
for en_k, cn_v in _CITY_EN_TO_CN.items():
if cn_v == c["city_name"] and (city_en_lower in en_k or en_k in city_en_lower):
return cn_v
# 3) 匹配不到 → 返回省会
capital = _PROVINCE_CAPITAL.get(province_cn, "")
if capital:
for c in candidates:
if c["city_name"] == capital:
return capital
return candidates[0]["city_name"] # 终极兜底
def _sanitize_city_name(name: str) -> str:
"""去除 reverse_geocoder name 中常见的行政后缀使匹配更鲁棒。"""
# 去掉 " District" / " Qu" / " Shi" 等英文后缀
for suffix in ("District", "Qu", "Shi", "Sheng", "Xian", "Cun", "Zhen", "Xiang",
"Zizhiqu", "Autonomous Region", "Special Administrative Region"):
name = re.sub(rf"\s+{suffix}$", "", name, flags=re.IGNORECASE)
return name.strip()
@lru_cache(maxsize=512)
def _resolve_meituan_city(latitude: float, longitude: float) -> dict[str, str]:
"""反查实现;入参已量化(见 get_meituan_city),故 lru_cache 命中率高。
返回的 dict 被缓存复用 调用方勿原地修改(get_meituan_city 已返回副本)
"""
if not _CITY_DICT:
return {"city_id": "", "city_name": "", "province_name": ""}
logger.debug("resolve_meituan_city: lat=%.2f lon=%.2f", latitude, longitude)
geo = _get_geo_city(latitude, longitude)
name_en = _sanitize_city_name(geo.get("name", ""))
admin1 = geo.get("admin1", "")
country = geo.get("country", "")
if country != "CN":
logger.debug("resolve_meituan_city: 坐标(%.2f,%.2f)不在中国境内(country=%s)", latitude, longitude, country)
return {"city_id": "", "city_name": "", "province_name": ""}
# 1) 映射省份
province_cn = _map_admin1_to_cn_province(admin1)
if not province_cn:
logger.warning("get_meituan_city: admin1=%r 无法映射到中文省份", admin1)
return {"city_id": "", "city_name": "", "province_name": ""}
# 2) 查找城市
name_lower = name_en.lower()
city_cn = _lookup_city_in_province(name_lower, province_cn)
# 3) 按省份+城市匹配 city_dict 中的城市 ID
index = _get_province_index()
candidates = index.get(province_cn, [])
for c in candidates:
if city_cn and c["city_name"] == city_cn:
return {
"city_id": c["city_id"],
"city_name": c["city_name"],
"province_name": province_cn,
}
# 4) 最终回退:返回该省省会
if candidates:
capital = _PROVINCE_CAPITAL.get(province_cn, "")
if capital:
for c in candidates:
if c["city_name"] == capital:
logger.info("get_meituan_city: 城市匹配失败 name_en=%r, 回退到省会 %s", name_en, capital)
return {
"city_id": c["city_id"],
"city_name": capital,
"province_name": province_cn,
}
# 终极兜底:第一个城市
fallback = candidates[0]
logger.info("get_meituan_city: 城市匹配失败 name_en=%r, 回退到 %s", name_en, fallback["city_name"])
return {
"city_id": fallback["city_id"],
"city_name": fallback["city_name"],
"province_name": province_cn,
}
return {"city_id": "", "city_name": "", "province_name": ""}
def get_meituan_city(latitude: float, longitude: float) -> dict[str, str]:
"""根据经纬度反查美团城市 ID + 城市名 + 省份名(对外入口)。
返回:
- city_id: 美团城市 ID( 3NUYJKKJXPHVNZUHFK3HWUDHNM);
匹配失败时返回 ""
- city_name: 中文城市名( "北京市")
- province_name: 中文省份名( "北京市")
原理:
1. reverse_geocoder 根据经纬度查出英文地名 + 省份
2. 英文省份中文省份映射(前缀匹配)
3. 英文地名中文城市名映射(精确映射 + 省内候选回退)
4. city_dict.txt 中按省份+城市名匹配城市 ID
城市名匹配失败的策略:
- 直辖市(京沪津渝): admin1 本身即城市名,直接取
- 省会: 回退到该省第一个城市(city_dict.txt 中每个省的省会通常排第一位)
实现说明:先把坐标量化到 ~1km(round 2 位小数)再进 lru_cache 原始 GPS 坐标
每次抖动到小数点后 5~6 ,直接做缓存 key 几乎不命中;城市级解析对 1km 误差不敏感,
量化后"同一地点反复请求"可命中缓存返回缓存 dict 的副本,调用方可安全读写
"""
return dict(_resolve_meituan_city(round(latitude, 2), round(longitude, 2)))
Binary file not shown.

After

Width:  |  Height:  |  Size: 847 KiB

+13 -4
View File
@@ -198,7 +198,7 @@
选择「<span class="guide-highlight">在浏览器打开</span>
<span class="guide-final">在浏览器里按提示<span class="guide-target">去应用商店下载</span></span>
</h2>
<div class="guide-dismiss" id="wxGuideDismiss">我知道了 ✕</div>
<!-- <div class="guide-dismiss" id="wxGuideDismiss">我知道了 ✕</div> -->
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
@@ -263,7 +263,7 @@
var wxGuide = document.getElementById("wxGuide");
function showWxGuide() { wxGuide.classList.add("show"); }
function hideWxGuide() { wxGuide.classList.remove("show"); }
document.getElementById("wxGuideDismiss").addEventListener("click", hideWxGuide);
// document.getElementById("wxGuideDismiss").addEventListener("click", hideWxGuide); // 「我知道了 ✕」已注释隐藏
if (isWeChat) showWxGuide(); // 微信里一进页面就提示去浏览器(微信内下载必被拦)
function showToast(text) {
@@ -273,15 +273,24 @@
showToast.timer = setTimeout(function () { toast.classList.remove("show"); }, 1400);
}
// ===== 下载按钮:微信内引导去浏览器;iOS 提示;安卓写邀请码 + 跳应用商店 =====
// ===== 应用商店下载:微信内引导去浏览器;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", handleDownload);
document.getElementById("dlbtn2").addEventListener("click", handleWebsiteDownload);
</script>
</body>
</html>

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