Compare commits

...

24 Commits

Author SHA1 Message Date
no_gen_mu 988576e14b docs(db): 补领券今日状态三张表文档 + 更新索引
新增 coupon_state.md 合并文档,覆盖 coupon_home_ui 分支新建的三张表:
coupon_prompt_engagement(弹窗频控)/ coupon_daily_completion(首页置灰)/
coupon_claim_record(领券记录沉淀)。逐列对齐 app/models/coupon_state.py,
接口取自 app/api/v1/coupon.py(/api/v1/coupon/*)。

同步更新 README.md(22→25 张业务表 + 新增「领券」总览段)与
OVERVIEW.md(功能域 / 写入路径 / 表间软关联),保持文档与代码一致。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 16:29:11 +08:00
no_gen_mu d94065c5af Merge remote-tracking branch 'origin/main' into coupon_home_ui
# Conflicts:
#	app/api/v1/coupon.py
#	app/repositories/coupon_state.py
2026-06-10 16:01:35 +08:00
ouzhou 0bddce516f feat(ad): eCPM 单位统一为分 + 新增看广告金币审计接口 (#32)
- eCPM 口径修正:getEcpm 原值是分/千次,新增 parse_ecpm_fen;parse_ecpm_yuan
  改为 ÷100 转元,因子1 阈值按元判档(100/200/400),收益换算用元
- test-grant 用客户端按 ad_session_id 上报的真实 eCPM 发奖(取不到/≤0 兜底 200)
- 新增 GET /admin/api/ad-coin-audit 只读复算对账(router/repo/schema),复用发奖公式
- 同步更新 docs/api、docs/database 的分/元口径

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

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #32
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-10 15:53:52 +08:00
xiebing 90fe6d6aa7 feat(invite): 邀请落地页改跳应用商店(替代手点右上角下载) (#36)
- 浏览器内:点按钮→写剪贴板归因→跳 market:// 唤起手机自带商店
- market:// 唤不起(2.5s 无响应)→ 兜底跳应用宝网页下载页
- 微信内保留引导去浏览器;iOS 维持"即将上线"
- 仅改 dl.html;App/后端不动,归因+指纹兜底机制不变
- market:// 各厂商兼容性真机待验

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

Reviewed-on: #36
Co-authored-by: xiebing <xiebing@wonderable.ai>
Co-committed-by: xiebing <xiebing@wonderable.ai>
2026-06-10 15:53:14 +08:00
marco be5e94ed6d fix(alembic): 线性化 f8d3b1e60a27 接 a8c47fc4dc39,消除与 #33 mergepoint 平行的双 head
f8d3b1e60a27 最初基于旧 main(#31 双 head)写、down_revision 指向那对父;但 #33 已用 a8c47fc4dc39 合并那对 head,合入 main 后 a8c47 与 f8d3 平行 → 又成双 head,alembic upgrade head 报 Multiple heads、deploy 迁移会失败。
改 f8d3 的 down_revision 为 a8c47fc4dc39(线性接其后、只加两列),收口单 head。已 SQLite upgrade head 验证整链可 apply。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:43:54 +08:00
marco 953a05a5e6 feat(debug-trace): 调试链接权限 + trace_url 落库与按权限下发 (#35)
- user 加 debug_trace_enabled、comparison_record 加 trace_url
- 迁移 f8d3b1e60a27:加两列,down_revision 用 tuple 顺带合并既存双 head(invite_fingerprint_table + 044dce6e9b1f)
- UserOut 加 debug_trace_enabled → /me 与登录响应带出
- ComparisonRecordIn/Out 加 trace_url;upsert 落库
- /compare/records 列表与详情按 user.debug_trace_enabled 下发;详情连 raw_payload 里那份一并抹掉,防权限绕过
- admin 加 POST /users/{id}/debug-trace(operator + 审计),列表带该字段

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

Reviewed-on: #35
2026-06-10 15:35:08 +08:00
zhangxianze e69788eb41 feat(admin): 上报更低价人工审核(通过发1000金币/拒绝填理由) (#33)
运营后台审核用户上报的「某平台比我们算的最低价更便宜」+截图。

- routers/price_report.py:列表 + /summary、approve|reject(require operator)
- repositories:list_price_reports + price_report_summary(queries)、review_price_report(mutations)
- core/rewards.py:PRICE_REPORT_REWARD_COINS=1000(通过即发,grant_coins 由 router 同事务调)
- admin/main.py:注册 price_report_router

跨端配套:前端 admin-web feat/price-report-review;端上金币刷新 android feat/price-report-review。

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

---------

Co-authored-by: xianze <ze@192.168.0.128>
Reviewed-on: #33
Co-authored-by: zhangxianze <zhangxianze@wonderable.ai>
Co-committed-by: zhangxianze <zhangxianze@wonderable.ai>
2026-06-10 14:44:28 +08:00
no_gen_mu 47d74273c9 feat(coupon): 今日跑完整轮领券后端记录 + 查询(首页置灰源)
新增 coupon_daily_completion 表:按 (device_id, 自然日) 记"今天是否已跑完
整轮领券(到 done 帧)"。客户端据此把首页「去领取」卡置灰、不可点。

口径(用户决策 A 方案):到 done 即算,不管单券成败 —— 失败/跳过常是无障碍/
环境问题,重复点也补不回来。判断维度 device_id,与 engagement/claim 一致。

- model CouponDailyCompletion(uq device+complete_date,仿 CouponPromptEngagement)
- repo has_completed_today / mark_completed_today(幂等 upsert + IntegrityError 兜底)
- schema CouponCompletedTodayOut(completed: bool)
- coupon_step 透传链路在 action.command=="done" 那帧 best-effort 写完成记录
  (pricebot 只在整轮全跑完才保留 command=="done",故无需再判 continue)
- GET /api/v1/coupon/completed-today?device_id= 供首页查询
- alembic 迁移 coupon_daily_completion(down_revision=0cf18d590b1d 当前 head)

合并后需 alembic upgrade head,否则 /completed-today 500。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:43:31 +08:00
marco cfc54ac2be fix(alembic): 合并 invite_fingerprint 与 044 双 head 修复 Multiple heads 部署失败
#31 的 invite_fingerprint_table 迁移 down_revision 挂在 0cf18d590b1d,与 #30
的 044dce6e9b1f(ad_reward+withdraw 合并) 形成并列双 head,alembic upgrade head
报 "Multiple head revisions are present",ecs1 部署在迁移步失败未重启。

加纯合并迁移 a8c47fc4dc39 (Revises: invite_fingerprint_table, 044dce6e9b1f)
收敛为单 head,upgrade/downgrade 为 no-op 不改 schema。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 02:28:56 +08:00
xiebing b2f5a53dd8 feat(invite): 被邀请人列表接口 + 指纹归因(任务3) (#31)
- GET /invitees: 分页 + 名字降级兜底(昵称→微信昵称→脱敏手机号)
- 指纹归因: 落地页采集 + (IP,设备型号)反查撞库 + 时间窗口闸
- test_invite: +5 个列表测试(脱敏/倒序/昵称优先/分页/空), 修指纹测试跨用例串味

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

Reviewed-on: #31
Co-authored-by: xiebing <xiebing@wonderable.ai>
Co-committed-by: xiebing <xiebing@wonderable.ai>
2026-06-09 21:48:48 +08:00
wuqi a828b51d9f feat(alembic): 添加合并广告奖励会话与提现安全的迁移脚本 (#30)
Reviewed-on: #30
Co-authored-by: wuqi <wuqi@wonderable.ai>
Co-committed-by: wuqi <wuqi@wonderable.ai>
2026-06-09 18:10:58 +08:00
marco 25b2b6850b feat(coupon): 领券弹窗频控加重置端点 + reset_today_engagement (#29)
- 加 POST /api/v1/coupon/prompt/reset:删该设备今日 engagement → has_engaged_today 变 false、
  今天又能弹。开发设置「重置今日领券弹窗状态」按钮调它(客户端改纯后台频控后前台清缓存已失效)
- coupon_state 加 reset_today_engagement(按 device_id + 今天删);should-show 文档改为"纯后台判据"

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

Reviewed-on: #29
2026-06-09 11:18:07 +08:00
ouzhou d7c29c0883 feat(coin): 金币数值体系二期——看广告按 eCPM 发奖 + ad_session_id 幂等 + 签到膨胀固定金币 (#28)
- rewards: 激励视频实发改按 calculate_ad_reward_coin(eCPM, 当日第N次) 公式;AD_REWARD_COIN/MAX_AD_REWARD_COIN 降为历史兼容口径;新增 SIGNIN_BOOST_COIN=2000

- 签到膨胀: Day1-13 看完激励视频额外发固定金币、Day14 不允许 (signin.py / signin-boost)

- ad_session_id 幂等: ad_ecpm/ad_reward/ad_feed_reward 记录加 ad_session_id 列 + 唯一索引(新迁移 coin_reward_phase2 / ad_feed_reward_session)

- ad.py + schemas + repositories: ecpm-report / feed-reward / reward-status / test-grant 改造;config_schema 增 signin_boost_coin;admin stats overview 补充

- 同步 docs/api + docs/database + docs/integrations/pangle 及 tests(test_ad_reward / test_welfare)

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

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #28
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-09 01:56:47 +08:00
ouzhou 6432497af1 feat: 后端接入微信提现人工审核与资金安全校验 (#27)
提现申请改为先扣款并进入待审核,审核通过后才发起微信打款,审核拒绝或微信失败时自动退款。

新增运营后台提现列表的关键词搜索、日期筛选、快捷筛选和排序参数,并支持批量通过、批量拒绝、批量刷新查单。

新增微信支付配置健康检查、现金账本校验、自动对账 worker 和单实例保护。

新增数据库唯一索引,约束同一用户未完成提现和同一提现单重复退款,提升并发安全性。

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #27
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-09 01:56:37 +08:00
zhangxianze 94b7c027be fix(time): created_at 统一存北京时间,修面向用户展示慢 8h (#25)
savings/comparison/wallet(金币·现金流水)/report 写入显式 created_at=datetime.now(CN_TZ).replace(tzinfo=None),替代 SQLite 下返回 UTC 的 server_default=func.now()。后台统计 admin/ops 依赖 created_at 是 UTC,不动。

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

---------

Co-authored-by: xianze <ze@192.168.0.128>
Reviewed-on: #25
Co-authored-by: zhangxianze <zhangxianze@wonderable.ai>
Co-committed-by: zhangxianze <zhangxianze@wonderable.ai>
2026-06-08 16:23:26 +08:00
marco 97f0fb210f fix(alembic): merge coupon_state + invite 双 head 为单一 head
coupon_state_tables 与 invite_code_and_relation 都挂在 11a1d08c6f55 下、是兄弟迁移,
合 main 后形成两个 head,alembic upgrade head 会报 Multiple head revisions 致部署失败。
本 merge 迁移(无 schema 变更)把两条链并起来,恢复单一 head。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 05:54:55 +08:00
marco 4d392f44f5 feat(invite): 好友邀请后端(注册即生效,邀请人+被邀请人各发1万金币) (#24)
- user.invite_code 列 + invite_relation 表(invitee_user_id 唯一 = 幂等防重复发奖)
- GET /api/v1/invite/me(我的码+分享链接+战绩)、POST /api/v1/invite/bind
- 复用 wallet.grant_coins 同事务发币;自邀屏蔽 / 无效码 / 新用户闸(注册72h内才发)
- alembic 迁移(11a1d08c6f55 -> invite_code_and_relation)+ 8 个测试(钱路/幂等/并发兜底全覆盖)

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

Reviewed-on: #24
2026-06-08 04:57:55 +08:00
marco 357ba27499 feat(coupon): 领券今日状态后端(弹窗频控 + 领券记录) (#23)
- 两张表 + Alembic 迁移:coupon_prompt_engagement(device×日,弹窗频控源)/ coupon_claim_record(device×券×日,领券记录·资产)
- coupon/step 透传顺手写库:step0 写 engagement(claim_started);响应解析 last_coupon_result/coupon_results 幂等写 claim record(按 coupon_id 去重,避免 done 帧 last 与 coupon_results 重复在 autoflush=False 下撞唯一约束回滚整批)
- 新增 GET /coupon/prompt/should-show(弹窗判据)+ POST /coupon/prompt/dismiss(拒绝通知,透传链路看不到拒绝)
- 判断按 device_id(券发到设备登录的外卖账号,比 user 更贴近登录环境);user_id 仅资产留痕、可空
- MVP 先不去重(队列照旧全发);pricebot 未改

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

Reviewed-on: #23
2026-06-08 03:20:51 +08:00
marco ece41086cd 新增美团 CPS 券定时抓取入库(北京试点),作为"销量/佣金排序"的本地数据源 (#18)
背景:美团搜索/供给对销量排序支持差(实测乱序)、且有 402 限流和单次召回上限,不适合
每次实时打接口排序。改为定时把券抓进本地 meituan_coupon 表,查询时从库里捞、自己按
销量/佣金排。

本次内容:
- app/models/meituan_coupon.py:新表模型。字段尽量全(售价/原价/销量档/佣金率/佣金额/
  门店/距离/品牌/图 等),raw 列存整条原始返回避免漏字段;(source, product_view_sign) 唯一。
- alembic/versions/meituan_coupon_table.py:建表迁移(挂在 withdraw_review_ad_watch 之后)。
- scripts/pull_meituan_coupons.py:ETL,抓 3 路并按 (source, product_view_sign) upsert:
  1) 外卖·搜"外卖"(platform=1)翻到尽头
  2) 外卖·搜"美食"(platform=1)翻到尽头
  3) 到店·多业务线供给(到餐+到综+酒店+门票)翻到尽头
  支持 --once(单轮,给 cron;线上每 1h)/ --loop --interval(本地循环,默认 10min)
  + 运行锁防重叠 + --prune-hours 清理陈旧券(默认 24h)。
- app/models/__init__.py:注册新模型。

实测:北京一轮约 2.5 分钟、入库约 2700 条;销量/佣金可直接从库里排序。

给接手同事:
- 仅北京(city_id 固定);v2 再加 per商圈 union 突破全城单次召回上限。
- 销量仅粗档位且约 51% 的券才有,查询需 WHERE sale_volume_num IS NOT NULL
  (PG 的 DESC 默认把 NULL 排最前);佣金、价格 100% 有值。
- productViewSign/skuViewId 跨渠道会变,不能当全局 id;跨源去重用 dedup_key=md5(品牌|名|价)。
- 待做:查询接口(从库捞 + dedup_key 去重 + 按销量/佣金排,返回前端)。

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

---------

Co-authored-by: chenshuobo <1119780489@qq.com>
Reviewed-on: #18
2026-06-08 00:16:25 +08:00
ouzhou 766666601e feat(platform): 首页门面三统计 + 运营后台展示模式配置 (#22)
Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #22
Reviewed-by: marco <marco@wonderable.ai>
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-07 23:17:27 +08:00
marco f5d1a1a20d feat(price-observation): 价格观测资产表 + pricebot 内部上报端点 (#21)
比价沉淀的价格事实(平台/门店/菜篮/到手价/时间/来源)无条件落库,server 侧存储、
与登录无关,独立于用户视角的 comparison_record。是未来"别人查过同店→秒回价格"
价格大数据的源头(先存数据,用法后续)。

- models/price_observation.py: 新增 price_observation 表。结构化列(平台/门店/价格/
  红包/地理/来源)+ dishes/attrs 两个 JSONB 兜底列;(trace_id,platform,scope) 幂等
  唯一约束;observed_at/store_name/geohash/source_device_id/source_user_id 等索引
- alembic/versions/price_observation_table.py: 建表迁移,down_revision=wx_transfer_auth
  (已核为当前唯一 head,无多 head 风险)
- schemas/ + repositories/price_observation.py: 批量收发模型 + 跨方言(PG/SQLite)幂等
  批量插入(先查 trace 已有 (platform,scope) 只插新的,并发撞唯一约束则回滚跳过)
- api/internal/price.py: 新增 POST /internal/price-observation,X-Internal-Secret 头校验
  (常量时间比较,未配密钥→503),server→server 专用,不走用户 JWT
- core/config.py + main.py: 新增 INTERNAL_API_SECRET 配置 + 注册内部路由
- .env.example: INTERNAL_API_SECRET(须与 pricebot 侧同值,留空=内部写端点关闭)

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

---------

Co-authored-by: pure <pure@192.168.0.104>
Reviewed-on: #21
2026-06-07 03:31:35 +08:00
marco 7126fb3ba3 feat(concurrency): trace 一致性 hash 亲和 + 透传 stream 优化 (#20)
配合 pricebot 单机多进程:透传层按 trace_id 把同一次比价/领券的所有帧
亲和到同一 pricebot 实例,使进程内内存维护 state、无需 Redis。本仓改动:

- 一致性 hash: 新增 app/core/pricebot_router.py(ketama 环,1000 虚拟节点)
  + pick_pricebot(trace_id),md5 纯函数、跨 worker 一致;实测负载偏差 4%、
  6→7 扩容只重映射 ~13%(取模会 ~85%)。
- 实例列表: app/core/config.py 加 PRICEBOT_INSTANCES(逗号分隔)+
  pricebot_instances property;留空 fallback 单实例,向后兼容。
- 透传亲和 + stream: api/v1/compare.py + coupon.py 改为读原始字节、只
  loads 一次拿 trace_id 选实例、转发原始 bytes 不重序列化(省 ~半透传 CPU,
  单 worker 即可扛高并发,避开多 worker 破坏短信内存表的坑);覆盖
  intent/recognize、intent/step、price/step、coupon/step 四端点。
- .env.example 加 PRICEBOT_INSTANCES + PRICEBOT_COMPARE_TIMEOUT_SEC。

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

---------

Co-authored-by: pure <pure@192.168.0.104>
Reviewed-on: #20
2026-06-07 02:33:50 +08:00
marco f098ba3816 日志: app-server 增加 JSON 文件输出供阿里云 SLS 采集 (#19)
app/core/logging.py:
- 新增 RotatingFileHandler 落 logs/app-server.log (此前仅 stdout), 用 JsonFormatter 输出
  单行 JSON (time/level/service/logger/func/line/message, 异常栈内嵌不换行)。
- 控制台保持文本 (stdout 行为不变, systemd/run.log 不受影响)。
- setup_logging 加幂等保护 + 清理预置 root handler 防重复输出。
- 支持环境变量 LOG_FILE/LOG_DIR/LOG_JSON_CONSOLE/LOG_SERVICE_NAME
  (admin 子进程应设独立 LOG_FILE 避免与主进程争抢同一轮转文件)。

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

---------

Co-authored-by: pure <pure@192.168.0.104>
Reviewed-on: #19
2026-06-07 02:26:45 +08:00
marco b4780e256e docs(database): 补全 19 表文档 + 新增 OVERVIEW 总览
- 重写 15 张已有表文档:每张补「用在 App 哪/增删改查时机/字段取值/join key 指向」
- 新建 4 张缺失表文档:wechat_transfer_authorization / ad_watch_log / price_report / app_config
- 新增 OVERVIEW.md:功能↔表映射 + 写入路径 + 表间关系(含语义 join key)+ ER + 资金模型
- README 索引补齐到 19 表 + 置顶 OVERVIEW + 通用约定补全
- 纠正过时:withdraw_order(reviewing/rejected/user_name)、savings_record(/order/report 真实写入)、coin_transaction(biz_type)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:25:57 +08:00
159 changed files with 9344 additions and 655 deletions
+22 -2
View File
@@ -40,13 +40,29 @@ MT_CPS_APP_KEY=
MT_CPS_APP_SECRET=
# 默认渠道追踪标识(sid),用于区分不同 app 的 CPS 数据
MT_CPS_DEFAULT_SID=sgbjia
# 美团调用走的代理。⚠️ 本机/内网开发直连美团会 SSL EOF,必须填本地代理(如 http://127.0.0.1:7897);
# 线上国内服务器留空(=直连)。留空且本机直连失败时 /feed、/coupons、/top-sales 会返回空。
MT_CPS_PROXY=
# ===== Pricebot 上游 (领券业务透传目标) =====
# 客户端调本服务的 /api/v1/coupon/step,我们透传到 pricebot-backend 的 /api/coupon/step
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
# 客户端调本服务的 /api/v1/coupon/step,我们透传到 pricebot-backend。
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
PRICEBOT_BASE_URL=http://localhost:8000
# 【多实例】单机多进程部署时,填逗号分隔的实例列表(端口与 pricebot 集群对齐),透传层按
# trace_id 一致性 hash 选实例 → 同一比价所有帧落同一进程(进程内维护 state,无需 Redis)。
# 留空 = 单实例(用上面的 PRICEBOT_BASE_URL)。详见 pricebot-backend/docs/并发部署设计.md
# PRICEBOT_INSTANCES=http://127.0.0.1:8001,http://127.0.0.1:8002,http://127.0.0.1:8003,http://127.0.0.1:8004,http://127.0.0.1:8005,http://127.0.0.1:8006
# 领券单帧最多 wait 6s,加网络往返,30s 兜底
PRICEBOT_REQUEST_TIMEOUT_SEC=30
# 比价(intent/recognize + price/step)透传超时:大上下文 LLM + 逐帧 LLM,给 60s
PRICEBOT_COMPARE_TIMEOUT_SEC=60
# ===== 内部端点 (server→server, pricebot 上报价格观测) =====
# pricebot 比价 done 后把各平台到手价 POST 到本服务 /internal/price-observation 落库
# (price_observation 表,比价资产沉淀层),靠共享密钥头 X-Internal-Secret 校验。
# 必须与 pricebot 侧的 INTERNAL_API_SECRET **同值**;留空 = 内部写端点关闭(返 503)。
# 启用前两边都填同一高熵串:python -c "import secrets; print(secrets.token_urlsafe(48))"
INTERNAL_API_SECRET=
# ===== CORS =====
# 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类
@@ -63,6 +79,10 @@ WXPAY_MCH_PRIVATE_KEY_PATH=./secrets/apiclient_key.pem
WXPAY_PUBLIC_KEY_ID=PUB_KEY_ID_xxxxxxxx
WXPAY_PUBLIC_KEY_PATH=./secrets/pub_key.pem
WXPAY_TRANSFER_SCENE_ID=1000
WXPAY_AUTH_NOTIFY_URL=
WITHDRAW_AUTO_RECONCILE_ENABLED=false
WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC=300
WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES=15
# ===== 穿山甲激励视频(服务端发奖回调)=====
# 看完激励视频后穿山甲服务器 S2S 回调本服务发金币(客户端不参与发奖)。
+8 -1
View File
@@ -23,7 +23,13 @@ dist/
*.db-journal
*.db-shm
*.db-wal
data/
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html——
# 它既是生产落地页又是本地测试资产,纳入 git 便于同事一致测试
# (见 docs/邀请功能-实现原理与本地测试.md)。其余(avatars/ / *.apk / app.db 等)仍忽略。
data/*
!data/media/
data/media/*
!data/media/dl.html
secrets/*
!secrets/.gitkeep
@@ -37,3 +43,4 @@ secrets/*
# 运行日志(run.sh 输出, 不入库)
*.log
logs/
@@ -0,0 +1,26 @@
"""merge coupon_state and invite heads
Revision ID: 0cf18d590b1d
Revises: coupon_state_tables, invite_code_and_relation
Create Date: 2026-06-08 05:49:29.604571
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0cf18d590b1d'
down_revision: Union[str, Sequence[str], None] = ('coupon_state_tables', 'invite_code_and_relation')
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,26 @@
"""merge meituan_coupon 与 ops/price_observation 迁移头
Revision ID: 11a1d08c6f55
Revises: meituan_coupon_table, opsrename01
Create Date: 2026-06-08 00:06:01.633796
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '11a1d08c6f55'
down_revision: Union[str, Sequence[str], None] = ('meituan_coupon_table', 'opsrename01')
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,26 @@
"""merge invite_fingerprint and ad_reward heads
Revision ID: a8c47fc4dc39
Revises: invite_fingerprint_table, 044dce6e9b1f
Create Date: 2026-06-10 01:03:03.699443
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a8c47fc4dc39'
down_revision: Union[str, Sequence[str], None] = ('invite_fingerprint_table', '044dce6e9b1f')
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,38 @@
"""add ad_session_id to ad feed reward record
Revision ID: ad_feed_reward_session
Revises: coin_reward_phase2
Create Date: 2026-06-09
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "ad_feed_reward_session"
down_revision = "coin_reward_phase2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"ad_feed_reward_record",
sa.Column("ad_session_id", sa.String(length=64), nullable=True),
)
op.create_index(
op.f("ix_ad_feed_reward_record_ad_session_id"),
"ad_feed_reward_record",
["ad_session_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f("ix_ad_feed_reward_record_ad_session_id"),
table_name="ad_feed_reward_record",
)
op.drop_column("ad_feed_reward_record", "ad_session_id")
@@ -0,0 +1,76 @@
"""coin reward points phase 1 tables
Revision ID: coin_reward_phase1
Revises: pstat3growth
Create Date: 2026-06-07 15:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'coin_reward_phase1'
down_revision: Union[str, Sequence[str], None] = 'pstat3growth'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'signin_boost_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('signin_date', sa.Date(), nullable=False),
sa.Column('coin_awarded', sa.Integer(), nullable=False),
sa.Column('ad_ref_id', sa.String(length=64), nullable=True),
sa.Column(
'created_at', sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'signin_date', name='uq_signin_boost_user_date'),
)
with op.batch_alter_table('signin_boost_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_signin_boost_record_user_id'), ['user_id'], unique=False)
op.create_table(
'ad_feed_reward_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('client_event_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('reward_date', sa.String(length=10), nullable=False),
sa.Column('duration_seconds', sa.Integer(), nullable=False),
sa.Column('unit_count', sa.Integer(), nullable=False),
sa.Column('ecpm_raw', sa.String(length=32), nullable=False),
sa.Column('adn', sa.String(length=32), nullable=True),
sa.Column('slot_id', sa.String(length=64), nullable=True),
sa.Column('coin', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column(
'created_at', sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('client_event_id', name='uq_ad_feed_reward_client_event'),
)
with op.batch_alter_table('ad_feed_reward_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_reward_date'), ['reward_date'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('ad_feed_reward_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_created_at'))
batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_reward_date'))
batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_user_id'))
op.drop_table('ad_feed_reward_record')
with op.batch_alter_table('signin_boost_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_signin_boost_record_user_id'))
op.drop_table('signin_boost_record')
@@ -0,0 +1,71 @@
"""coin reward points phase2
Revision ID: coin_reward_phase2
Revises: 0cf18d590b1d
Create Date: 2026-06-08
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "coin_reward_phase2"
down_revision = "0cf18d590b1d"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"ad_ecpm_record",
sa.Column("ad_session_id", sa.String(length=64), nullable=True),
)
op.create_index(
op.f("ix_ad_ecpm_record_ad_session_id"),
"ad_ecpm_record",
["ad_session_id"],
unique=False,
)
op.create_index(
"uq_ad_ecpm_record_session",
"ad_ecpm_record",
["ad_session_id"],
unique=True,
)
op.add_column(
"ad_reward_record",
sa.Column(
"reward_scene",
sa.String(length=32),
nullable=False,
server_default="reward_video",
),
)
op.add_column(
"ad_reward_record",
sa.Column("ad_session_id", sa.String(length=64), nullable=True),
)
op.add_column(
"ad_reward_record",
sa.Column("ecpm_raw", sa.String(length=32), nullable=True),
)
op.create_index(
op.f("ix_ad_reward_record_ad_session_id"),
"ad_reward_record",
["ad_session_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(op.f("ix_ad_reward_record_ad_session_id"), table_name="ad_reward_record")
op.drop_column("ad_reward_record", "ecpm_raw")
op.drop_column("ad_reward_record", "ad_session_id")
op.drop_column("ad_reward_record", "reward_scene")
op.drop_index("uq_ad_ecpm_record_session", table_name="ad_ecpm_record")
op.drop_index(op.f("ix_ad_ecpm_record_ad_session_id"), table_name="ad_ecpm_record")
op.drop_column("ad_ecpm_record", "ad_session_id")
@@ -0,0 +1,58 @@
"""coupon daily completion table(今日跑完整轮领券 → 首页置灰源)
Revision ID: coupon_daily_completion
Revises: f8d3b1e60a27
Create Date: 2026-06-10 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "coupon_daily_completion"
down_revision: str | Sequence[str] | None = "f8d3b1e60a27"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"coupon_daily_completion",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("device_id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("complete_date", sa.Date(), nullable=False),
sa.Column("trace_id", sa.String(length=64), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("device_id", "complete_date", name="uq_coupon_completion_device_date"),
)
with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op:
batch_op.create_index(
batch_op.f("ix_coupon_daily_completion_user_id"), ["user_id"], unique=False
)
batch_op.create_index(
batch_op.f("ix_coupon_daily_completion_trace_id"), ["trace_id"], unique=False
)
def downgrade() -> None:
with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_trace_id"))
batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_user_id"))
op.drop_table("coupon_daily_completion")
+77
View File
@@ -0,0 +1,77 @@
"""coupon state tables (领券今日状态:弹窗频控 engagement + 领券记录 claim)
Revision ID: coupon_state_tables
Revises: 11a1d08c6f55
Create Date: 2026-06-08 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'coupon_state_tables'
down_revision: Union[str, Sequence[str], None] = '11a1d08c6f55'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# PG 上为 JSONB,其它(SQLite dev)为 JSON——与模型层 with_variant 对齐
_JSON = sa.JSON().with_variant(postgresql.JSONB(), 'postgresql')
def upgrade() -> None:
# 领券记录(资产层,当前不参与去重判断)
op.create_table(
'coupon_claim_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('device_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('coupon_id', sa.String(length=64), nullable=False),
sa.Column('claim_date', sa.Date(), nullable=False),
sa.Column('status', sa.String(length=24), nullable=False),
sa.Column('vendor', sa.String(length=48), nullable=True),
sa.Column('coupon_name', sa.String(length=128), nullable=True),
sa.Column('claimed_count', sa.Integer(), nullable=True),
sa.Column('trace_id', sa.String(length=64), nullable=True),
sa.Column('reason', sa.String(length=255), nullable=True),
sa.Column('extra', _JSON, nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('device_id', 'coupon_id', 'claim_date', name='uq_coupon_claim_device_coupon_date'),
)
with op.batch_alter_table('coupon_claim_record', schema=None) as batch_op:
batch_op.create_index('ix_coupon_claim_device_date', ['device_id', 'claim_date'], unique=False)
batch_op.create_index(batch_op.f('ix_coupon_claim_record_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_coupon_claim_record_trace_id'), ['trace_id'], unique=False)
# 弹窗频控(今天 engage 过就不再弹)
op.create_table(
'coupon_prompt_engagement',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('device_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('engage_date', sa.Date(), nullable=False),
sa.Column('engage_type', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('device_id', 'engage_date', name='uq_coupon_engage_device_date'),
)
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_coupon_prompt_engagement_user_id'), ['user_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_coupon_prompt_engagement_user_id'))
op.drop_table('coupon_prompt_engagement')
with op.batch_alter_table('coupon_claim_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_coupon_claim_record_trace_id'))
batch_op.drop_index(batch_op.f('ix_coupon_claim_record_user_id'))
batch_op.drop_index('ix_coupon_claim_device_date')
op.drop_table('coupon_claim_record')
@@ -0,0 +1,49 @@
"""add user.debug_trace_enabled + comparison_record.trace_url
调试链接权限功能:
- user.debug_trace_enabled:运营后台给指定用户开「复制调试链接」权限
- comparison_record.trace_url:比价记录页「复制调试链接」的数据(pricebot done 帧给,
dir 名含落盘时分秒、前端/server 拼不出,必须落库)
注:本迁移最初基于旧 main#31,双 head)写、down_revision 曾指向那对父;但 #33 已用
a8c47fc4dc39 合并了那对双 head,故改为线性接在 a8c47fc4dc39 之后、只负责加两列
(避免与 a8c47fc4dc39 平行再造一个双 head)。
Revision ID: f8d3b1e60a27
Revises: a8c47fc4dc39
Create Date: 2026-06-10 02:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f8d3b1e60a27"
down_revision: Union[str, Sequence[str], None] = "a8c47fc4dc39"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# sa.false() 渲染成 PG 的 false / SQLite 的 0,两端兼容(避免字符串 "false" 在 SQLite 上存歪)
op.add_column(
"user",
sa.Column(
"debug_trace_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
op.add_column(
"comparison_record",
sa.Column("trace_url", sa.String(length=512), nullable=True),
)
def downgrade() -> None:
op.drop_column("comparison_record", "trace_url")
op.drop_column("user", "debug_trace_enabled")
@@ -0,0 +1,54 @@
"""user.invite_code + invite_relation table
Revision ID: invite_code_and_relation
Revises: 11a1d08c6f55
Create Date: 2026-06-08 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'invite_code_and_relation'
down_revision: Union[str, Sequence[str], None] = '11a1d08c6f55'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# user 加邀请码列(唯一索引;nullable 允许多个 NULL=未生成的用户)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('invite_code', sa.String(length=16), nullable=True))
batch_op.create_index('ix_user_invite_code', ['invite_code'], unique=True)
# 邀请关系表
op.create_table(
'invite_relation',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('inviter_user_id', sa.Integer(), nullable=False),
sa.Column('invitee_user_id', sa.Integer(), nullable=False),
sa.Column('channel', sa.String(length=16), nullable=False, server_default='clipboard'),
sa.Column('status', sa.String(length=16), nullable=False, server_default='effective'),
sa.Column('inviter_coin', sa.Integer(), nullable=False, server_default='0'),
sa.Column('invitee_coin', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['inviter_user_id'], ['user.id']),
sa.ForeignKeyConstraint(['invitee_user_id'], ['user.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_invite_relation_inviter_user_id', 'invite_relation', ['inviter_user_id'])
op.create_index('ix_invite_relation_invitee_user_id', 'invite_relation', ['invitee_user_id'], unique=True)
op.create_index('ix_invite_relation_created_at', 'invite_relation', ['created_at'])
def downgrade() -> None:
op.drop_index('ix_invite_relation_created_at', table_name='invite_relation')
op.drop_index('ix_invite_relation_invitee_user_id', table_name='invite_relation')
op.drop_index('ix_invite_relation_inviter_user_id', table_name='invite_relation')
op.drop_table('invite_relation')
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_index('ix_user_invite_code')
batch_op.drop_column('invite_code')
@@ -0,0 +1,50 @@
"""invite_fingerprint table (任务 3 指纹归因兜底)
Revision ID: invite_fingerprint_table
Revises: 0cf18d590b1d
Create Date: 2026-06-08 14:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'invite_fingerprint_table'
down_revision: Union[str, Sequence[str], None] = '0cf18d590b1d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 邀请指纹归因表:落地页访问时记一行,登录后剪贴板没拿到邀请码时反查
op.create_table(
'invite_fingerprint',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('inviter_user_id', sa.Integer(), nullable=False),
sa.Column('ip', sa.String(length=64), nullable=False),
sa.Column('device_model', sa.String(length=64), nullable=False, server_default=''),
sa.Column('screen', sa.String(length=32), nullable=False, server_default=''),
sa.Column('user_agent', sa.Text(), nullable=False, server_default=''),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['inviter_user_id'], ['user.id']),
sa.PrimaryKeyConstraint('id'),
)
# 反查主索引:(ip, screen, device_model, created_at)
# /bind 兜底分支 WHERE ip=? AND screen=? AND device_model=? AND created_at > now - 7d
# ORDER BY created_at DESC LIMIT 1 — SQLite/PG 优化器都能反向扫该索引
op.create_index(
'ix_invite_fp_match',
'invite_fingerprint',
['ip', 'screen', 'device_model', 'created_at'],
)
# 兜底/统计索引:按 inviter 看"这个邀请人的落地页被谁访问过"
op.create_index('ix_invite_fp_inviter', 'invite_fingerprint', ['inviter_user_id'])
def downgrade() -> None:
op.drop_index('ix_invite_fp_inviter', table_name='invite_fingerprint')
op.drop_index('ix_invite_fp_match', table_name='invite_fingerprint')
op.drop_table('invite_fingerprint')
+50
View File
@@ -0,0 +1,50 @@
"""marquee_seed 升级为「生成规则」:用户名可空 + 金额拆 min/max 区间
把种子从「死记录」升级:
- masked_user 改 nullable(留空 → feed 随机合成脱敏用户名)
- saved_amount_cents(单值)拆成 min_cents / max_cents(feed 在区间内随机取值;固定金额则相等)
现有 6 条种子:min_cents = max_cents = 原 saved_amount_cents,masked_user 原样保留。
Revision ID: marquee_seed02
Revises: marquee_seed01
Create Date: 2026-06-07 11:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'marquee_seed02'
down_revision: Union[str, Sequence[str], None] = 'marquee_seed01'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1) 先加可空新列,使现有行合法
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
batch_op.add_column(sa.Column('min_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('max_cents', sa.Integer(), nullable=True))
# 2) 用旧单值回填区间(固定金额)
op.execute('UPDATE marquee_seed SET min_cents = saved_amount_cents, max_cents = saved_amount_cents')
# 3) 收紧:区间非空、用户名可空、删旧列
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
batch_op.alter_column('min_cents', existing_type=sa.Integer(), nullable=False)
batch_op.alter_column('max_cents', existing_type=sa.Integer(), nullable=False)
batch_op.alter_column('masked_user', existing_type=sa.String(length=64), nullable=True)
batch_op.drop_column('saved_amount_cents')
def downgrade() -> None:
# 反向:恢复单值列(取下限),用户名回非空
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
batch_op.add_column(sa.Column('saved_amount_cents', sa.Integer(), nullable=True))
op.execute('UPDATE marquee_seed SET saved_amount_cents = min_cents')
# 自动合成名的种子 masked_user 为 NULL,降级前补占位符以满足非空
op.execute("UPDATE marquee_seed SET masked_user = '用户********000' WHERE masked_user IS NULL")
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
batch_op.alter_column('saved_amount_cents', existing_type=sa.Integer(), nullable=False)
batch_op.alter_column('masked_user', existing_type=sa.String(length=64), nullable=False)
batch_op.drop_column('max_cents')
batch_op.drop_column('min_cents')
+52
View File
@@ -0,0 +1,52 @@
"""marquee_seed table (首页轮播种子) + 合并 platform_stat / price_observation 两 head
Revision ID: marquee_seed01
Revises: pstat2anchor01, price_observation_table
Create Date: 2026-06-07 10:00:00.000000
兼并当前两个 head 为单 head,同时建 marquee_seed 表并播种客户端原写死的 6 条轮播
(作为初始种子,保证上线即有内容)。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'marquee_seed01'
down_revision: Union[str, Sequence[str], None] = ('pstat2anchor01', 'price_observation_table')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
table = op.create_table(
'marquee_seed',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('masked_user', sa.String(length=64), nullable=False),
sa.Column('saved_amount_cents', sa.Integer(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
sa.Column(
'created_at', sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
),
sa.PrimaryKeyConstraint('id'),
)
# 初始种子 = 客户端原写死的 6 条(金额转成分)
op.bulk_insert(
table,
[
{'masked_user': '用户********a52', 'saved_amount_cents': 1860, 'enabled': True, 'sort_order': 1},
{'masked_user': '用户********k89', 'saved_amount_cents': 4240, 'enabled': True, 'sort_order': 2},
{'masked_user': '用户********m71', 'saved_amount_cents': 790, 'enabled': True, 'sort_order': 3},
{'masked_user': '用户********p46', 'saved_amount_cents': 15630, 'enabled': True, 'sort_order': 4},
{'masked_user': '用户********r93', 'saved_amount_cents': 2350, 'enabled': True, 'sort_order': 5},
{'masked_user': '用户********c28', 'saved_amount_cents': 8940, 'enabled': True, 'sort_order': 6},
],
)
def downgrade() -> None:
op.drop_table('marquee_seed')
+62
View File
@@ -0,0 +1,62 @@
"""meituan_coupon table(美团 CPS 券本地缓存,供销量/佣金排序从库里捞、本地排序)
Revision ID: meituan_coupon_table
Revises: withdraw_review_ad_watch
Create Date: 2026-06-06 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'meituan_coupon_table'
down_revision: Union[str, Sequence[str], None] = 'withdraw_review_ad_watch'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'meituan_coupon',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('source', sa.String(length=16), nullable=False),
sa.Column('platform', sa.Integer(), nullable=False),
sa.Column('biz_line', sa.Integer(), nullable=True),
sa.Column('city_id', sa.String(length=32), nullable=False),
sa.Column('product_view_sign', sa.String(length=128), nullable=False),
sa.Column('sku_view_id', sa.String(length=128), nullable=True),
sa.Column('name', sa.String(length=256), nullable=True),
sa.Column('brand_name', sa.String(length=128), nullable=True),
sa.Column('sell_price_cents', sa.Integer(), nullable=True),
sa.Column('original_price_cents', sa.Integer(), nullable=True),
sa.Column('head_url', sa.String(length=512), nullable=True),
sa.Column('sale_volume', sa.String(length=32), nullable=True),
sa.Column('sale_volume_num', sa.Integer(), nullable=True),
sa.Column('commission_percent', sa.Float(), nullable=True),
sa.Column('commission_amount_cents', sa.Integer(), nullable=True),
sa.Column('poi_name', sa.String(length=128), nullable=True),
sa.Column('available_poi_num', sa.Integer(), nullable=True),
sa.Column('delivery_distance_m', sa.Float(), nullable=True),
sa.Column('dedup_key', sa.String(length=64), nullable=False),
sa.Column('raw', sa.JSON().with_variant(postgresql.JSONB(), 'postgresql'), nullable=False),
sa.Column('first_seen', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('last_seen', 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('source', 'product_view_sign', name='uq_meituan_coupon_source_sign'),
)
op.create_index('ix_meituan_coupon_source', 'meituan_coupon', ['source'])
op.create_index('ix_meituan_coupon_city_id', 'meituan_coupon', ['city_id'])
op.create_index('ix_meituan_coupon_brand_name', 'meituan_coupon', ['brand_name'])
op.create_index('ix_meituan_coupon_sale_volume_num', 'meituan_coupon', ['sale_volume_num'])
op.create_index('ix_meituan_coupon_commission_percent', 'meituan_coupon', ['commission_percent'])
op.create_index('ix_meituan_coupon_dedup_key', 'meituan_coupon', ['dedup_key'])
op.create_index('ix_meituan_coupon_last_seen', 'meituan_coupon', ['last_seen'])
def downgrade() -> None:
op.drop_table('meituan_coupon')
@@ -0,0 +1,26 @@
"""merge ad feed reward session and withdraw safety heads
Revision ID: 044dce6e9b1f
Revises: ad_feed_reward_session, withdraw_safety_indexes
Create Date: 2026-06-09 09:52:08.276767
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '044dce6e9b1f'
down_revision: Union[str, Sequence[str], None] = ('ad_feed_reward_session', 'withdraw_safety_indexes')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+28
View File
@@ -0,0 +1,28 @@
"""运营可配表加 ops_ 前缀:platform_stat_display→ops_stat_config / marquee_seed→ops_marquee_seed
把首页门面数据的两张「运营可配」表重命名,与代码侧 ops_ 前缀(模型 OpsStatConfig/OpsMarqueeSeed、
repo ops_stat/ops_marquee、admin ops_stat_config/ops_marquee_seed)对齐。仅改表名,不动列与数据。
Revision ID: opsrename01
Revises: coin_reward_phase1
Create Date: 2026-06-07 16:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = 'opsrename01'
down_revision: Union[str, Sequence[str], None] = 'coin_reward_phase1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.rename_table('platform_stat_display', 'ops_stat_config')
op.rename_table('marquee_seed', 'ops_marquee_seed')
def downgrade() -> None:
op.rename_table('ops_stat_config', 'platform_stat_display')
op.rename_table('ops_marquee_seed', 'marquee_seed')
@@ -0,0 +1,30 @@
"""platform_stat_display 增加 random_anchor_minutes(自增长触发时刻对齐偏移)
Revision ID: pstat2anchor01
Revises: pstat1d2e3f4a
Create Date: 2026-06-06 13:00:00.000000
自增长 tick 从「启动时刻 + 间隔」改为「北京时间钟点对齐(anchor + k*interval)」,
新增列存对齐偏移(距 0 点分钟数)。已有行默认 0(= 对齐到自然边界:天→0点、小时→整点)。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'pstat2anchor01'
down_revision: Union[str, Sequence[str], None] = 'pstat1d2e3f4a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'platform_stat_display',
sa.Column('random_anchor_minutes', sa.Integer(), nullable=False, server_default='0'),
)
def downgrade() -> None:
op.drop_column('platform_stat_display', 'random_anchor_minutes')
@@ -0,0 +1,59 @@
"""platform_stat_display table (首页三统计展示配置:每指标独立 real/manual/random)
Revision ID: pstat1d2e3f4a
Revises: withdraw_review_ad_watch
Create Date: 2026-06-06 12:00:00.000000
播种 3 行:默认 manual + 客户端原写死门面值(12847 人 / 86532 次 / 37621.4 元),
保证上线前后展示一致,运营再按需切模式。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'pstat1d2e3f4a'
down_revision: Union[str, Sequence[str], None] = 'withdraw_review_ad_watch'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
table = op.create_table(
'platform_stat_display',
sa.Column('metric', sa.String(length=32), nullable=False),
sa.Column('mode', sa.String(length=16), nullable=False),
sa.Column('manual_value', sa.Integer(), nullable=True),
sa.Column('random_mult_min', sa.Integer(), nullable=False),
sa.Column('random_mult_max', sa.Integer(), nullable=False),
sa.Column('random_tick_seconds', sa.Integer(), nullable=False),
sa.Column('random_current', sa.Integer(), nullable=True),
sa.Column('random_last_tick_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_by_admin_id', sa.Integer(), nullable=True),
sa.Column(
'updated_at', sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
),
sa.PrimaryKeyConstraint('metric'),
)
_common = {
'mode': 'manual',
'random_mult_min': 1000, # 1.000(只增不减下限)
'random_mult_max': 1100, # 1.100
'random_tick_seconds': 86400, # 1 天
}
op.bulk_insert(
table,
[
{'metric': 'help_users', 'manual_value': 12847, **_common},
{'metric': 'total_compares', 'manual_value': 86532, **_common},
{'metric': 'total_saved', 'manual_value': 3762140, **_common}, # 37621.40 元
],
)
def downgrade() -> None:
op.drop_table('platform_stat_display')
@@ -0,0 +1,39 @@
"""platform_stat_display 增强:绝对增量增长 / 真实值偏移 / 只增不减护栏
给首页三统计配置加 5 列:
- random_kind ('mult'/'add') + random_step_min/max:自增长支持「绝对增量」(每周期 +[a,b]),不只是 ×倍率
- real_offset:真实值模式基数偏移(展示=真实+偏移,冷启动既真实又体面)
- allow_decrease:门面数字默认「只增不减」,开此开关才允许展示值下降
Revision ID: pstat3growth
Revises: marquee_seed02
Create Date: 2026-06-07 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'pstat3growth'
down_revision: Union[str, Sequence[str], None] = 'marquee_seed02'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('platform_stat_display', schema=None) as batch_op:
batch_op.add_column(sa.Column('random_kind', sa.String(length=8), nullable=False, server_default='mult'))
batch_op.add_column(sa.Column('random_step_min', sa.Integer(), nullable=False, server_default='0'))
batch_op.add_column(sa.Column('random_step_max', sa.Integer(), nullable=False, server_default='0'))
batch_op.add_column(sa.Column('real_offset', sa.Integer(), nullable=False, server_default='0'))
batch_op.add_column(sa.Column('allow_decrease', sa.Boolean(), nullable=False, server_default=sa.false()))
def downgrade() -> None:
with op.batch_alter_table('platform_stat_display', schema=None) as batch_op:
batch_op.drop_column('allow_decrease')
batch_op.drop_column('real_offset')
batch_op.drop_column('random_step_max')
batch_op.drop_column('random_step_min')
batch_op.drop_column('random_kind')
@@ -0,0 +1,74 @@
"""price_observation table (价格观测:比价沉淀的价格资产层,server 侧无条件落库)
Revision ID: price_observation_table
Revises: wx_transfer_auth
Create Date: 2026-06-07 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'price_observation_table'
down_revision: Union[str, Sequence[str], None] = 'wx_transfer_auth'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'price_observation',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('observed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('trace_id', sa.String(length=64), nullable=False),
sa.Column('business_type', sa.String(length=16), nullable=False),
sa.Column('platform', sa.String(length=32), nullable=False),
sa.Column('platform_store_id', sa.String(length=64), nullable=True),
sa.Column('store_name', sa.String(length=128), nullable=True),
sa.Column('city', sa.String(length=64), nullable=True),
sa.Column('geohash', sa.String(length=16), nullable=True),
sa.Column('lng', sa.Float(), nullable=True),
sa.Column('lat', sa.Float(), nullable=True),
sa.Column('is_source', sa.Boolean(), nullable=False),
sa.Column('scope', sa.String(length=16), nullable=False),
sa.Column('price_cents', sa.Integer(), nullable=True),
sa.Column('coupon_saved_cents', sa.Integer(), nullable=True),
sa.Column('coupon_name', sa.String(length=64), nullable=True),
sa.Column('store_closed', sa.String(length=32), nullable=True),
sa.Column('rank', sa.Integer(), nullable=True),
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
sa.Column('dishes', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True),
sa.Column('attrs', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True),
sa.Column('source_device_id', sa.String(length=64), nullable=True),
sa.Column('source_user_id', sa.Integer(), nullable=True),
sa.Column('confidence', sa.Float(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('trace_id', 'platform', 'scope', name='uq_price_obs_trace_platform_scope'),
)
with op.batch_alter_table('price_observation', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_price_observation_observed_at'), ['observed_at'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_trace_id'), ['trace_id'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_business_type'), ['business_type'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_platform'), ['platform'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_store_name'), ['store_name'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_geohash'), ['geohash'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_source_device_id'), ['source_device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_source_user_id'), ['source_user_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('price_observation', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_price_observation_source_user_id'))
batch_op.drop_index(batch_op.f('ix_price_observation_source_device_id'))
batch_op.drop_index(batch_op.f('ix_price_observation_geohash'))
batch_op.drop_index(batch_op.f('ix_price_observation_store_name'))
batch_op.drop_index(batch_op.f('ix_price_observation_platform'))
batch_op.drop_index(batch_op.f('ix_price_observation_business_type'))
batch_op.drop_index(batch_op.f('ix_price_observation_trace_id'))
batch_op.drop_index(batch_op.f('ix_price_observation_observed_at'))
op.drop_table('price_observation')
@@ -0,0 +1,42 @@
"""add withdraw concurrency safety indexes
Revision ID: withdraw_safety_indexes
Revises: 0cf18d590b1d
Create Date: 2026-06-08 16:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "withdraw_safety_indexes"
down_revision: Union[str, Sequence[str], None] = "0cf18d590b1d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
"ux_withdraw_order_user_active",
"withdraw_order",
["user_id"],
unique=True,
sqlite_where=sa.text("status IN ('reviewing', 'pending')"),
postgresql_where=sa.text("status IN ('reviewing', 'pending')"),
)
op.create_index(
"ux_cash_transaction_withdraw_refund_ref",
"cash_transaction",
["ref_id"],
unique=True,
sqlite_where=sa.text("biz_type = 'withdraw_refund' AND ref_id IS NOT NULL"),
postgresql_where=sa.text("biz_type = 'withdraw_refund' AND ref_id IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("ux_cash_transaction_withdraw_refund_ref", table_name="cash_transaction")
op.drop_index("ux_withdraw_order_user_active", table_name="withdraw_order")
+8
View File
@@ -13,12 +13,16 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.admin.routers.ad_audit import router as ad_audit_router
from app.admin.routers.admins import router as admins_router
from app.admin.routers.audit import router as audit_router
from app.admin.routers.auth import router as auth_router
from app.admin.routers.config import router as config_router
from app.admin.routers.dashboard import router as dashboard_router
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
from app.admin.routers.feedback import router as feedback_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.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
@@ -73,10 +77,14 @@ def health() -> dict[str, str]:
admin_app.include_router(auth_router)
admin_app.include_router(dashboard_router)
admin_app.include_router(ops_stat_config_router)
admin_app.include_router(ops_marquee_seed_router)
admin_app.include_router(users_router)
admin_app.include_router(wallet_router)
admin_app.include_router(withdraw_router)
admin_app.include_router(price_report_router)
admin_app.include_router(feedback_router)
admin_app.include_router(admins_router)
admin_app.include_router(audit_router)
admin_app.include_router(config_router)
admin_app.include_router(ad_audit_router)
+168
View File
@@ -0,0 +1,168 @@
"""看广告金币审计:复算 expected_coin 并与实发对比。
只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移):
- 看视频:每条 granted = 1 份,第 N 份 = 当日该用户 granted 的 reward_video 顺序号
(与 ad_reward.grant_ad_reward 里 `_granted_today + 1` 一致)。
- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 当日该用户已 granted 份数累计
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致)。
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS
def _reward_video_rows(
db: Session, *, date: str, user_id: int | None
) -> list[dict]:
"""看视频记录复算。按 (user_id, created_at) 升序还原当日第 N 份。"""
stmt = (
select(AdRewardRecord)
.where(
AdRewardRecord.reward_date == date,
AdRewardRecord.reward_scene == "reward_video",
)
.order_by(AdRewardRecord.user_id, AdRewardRecord.created_at)
)
if user_id is not None:
stmt = stmt.where(AdRewardRecord.user_id == user_id)
granted_n: dict[int, int] = {} # user_id -> 已 granted 份数
rows: list[dict] = []
for rec in db.execute(stmt).scalars():
if rec.status == "granted":
nth = granted_n.get(rec.user_id, 0) + 1
granted_n[rec.user_id] = nth
expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth)
rows.append({
"scene": "reward_video",
"record_id": rec.id,
"user_id": rec.user_id,
"created_at": rec.created_at,
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
"units": 1,
"lt_index_start": nth,
"lt_index_end": nth,
"lt_factor_start": rewards.ad_lt_factor(nth),
"lt_factor_end": rewards.ad_lt_factor(nth),
"expected_coin": expected,
"actual_coin": rec.coin,
"matched": expected == rec.coin,
})
else:
# capped / ecpm_missing:不发金币,校验实发确为 0
rows.append({
"scene": "reward_video",
"record_id": rec.id,
"user_id": rec.user_id,
"created_at": rec.created_at,
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": None,
"units": 1,
"lt_index_start": None,
"lt_index_end": None,
"lt_factor_start": None,
"lt_factor_end": None,
"expected_coin": 0,
"actual_coin": rec.coin,
"matched": rec.coin == 0,
})
return rows
def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用当日累计份数。"""
stmt = (
select(AdFeedRewardRecord)
.where(AdFeedRewardRecord.reward_date == date)
.order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at)
)
if user_id is not None:
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
granted_units: dict[int, int] = {} # user_id -> 已 granted 份数累计
rows: list[dict] = []
for rec in db.execute(stmt).scalars():
if rec.status == "granted":
existing = granted_units.get(rec.user_id, 0)
units = rec.unit_count
expected = sum(
rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset)
for offset in range(1, units + 1)
)
granted_units[rec.user_id] = existing + units
start = existing + 1 if units > 0 else None
end = existing + units if units > 0 else None
rows.append({
"scene": "feed",
"record_id": rec.id,
"user_id": rec.user_id,
"created_at": rec.created_at,
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
"units": units,
"lt_index_start": start,
"lt_index_end": end,
"lt_factor_start": rewards.ad_lt_factor(start) if start else None,
"lt_factor_end": rewards.ad_lt_factor(end) if end else None,
"expected_coin": expected,
"actual_coin": rec.coin,
"matched": expected == rec.coin,
})
else:
rows.append({
"scene": "feed",
"record_id": rec.id,
"user_id": rec.user_id,
"created_at": rec.created_at,
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": None,
"units": rec.unit_count,
"lt_index_start": None,
"lt_index_end": None,
"lt_factor_start": None,
"lt_factor_end": None,
"expected_coin": 0,
"actual_coin": rec.coin,
"matched": rec.coin == 0,
})
return rows
def ad_coin_audit(
db: Session, *, date: str, user_id: int | None, scene: str | None, limit: int
) -> list[dict]:
"""返回当日发奖复算明细,按 created_at 倒序(最新在前)截断到 limit。
scene: None=两类都要 / "reward_video" / "feed"
份序号在截断前已基于全天数据算好,故 limit 只影响展示条数、不影响 expected 复算正确性。
"""
rows: list[dict] = []
if scene in (None, "reward_video"):
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
if scene in (None, "feed"):
rows.extend(_feed_rows(db, date=date, user_id=user_id))
rows.sort(key=lambda r: r["created_at"], reverse=True)
return rows[:limit]
def formula_snapshot() -> dict:
"""当前公式参数快照(给前端展示规则参照)。直接读 rewards 常量,与发奖同源。"""
return {
"coin_per_yuan": rewards.COIN_PER_YUAN,
"feed_unit_seconds": FEED_REWARD_UNIT_SECONDS,
"ecpm_factor_tiers": [list(t) for t in rewards.AD_ECPM_FACTOR_TABLE],
"lt_factor_tiers": [list(t) for t in rewards.AD_LT_FACTOR_TABLE],
}
+45
View File
@@ -8,9 +8,13 @@ set_user_status / update_feedback_status 支持 commit=False,让 router 把"业
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.feedback import Feedback
from app.models.price_report import PriceReport
from app.models.user import User
@@ -24,6 +28,20 @@ def set_user_status(db: Session, user: User, *, status: str, commit: bool = True
return user
def set_user_debug_trace(
db: Session, user: User, *, enabled: bool, commit: bool = True
) -> User:
"""开关用户「复制调试链接」权限。同 set_user_status:支持 commit=False 让 router 把
业务写 + 审计写放进同一事务。"""
user.debug_trace_enabled = enabled
if commit:
db.commit()
db.refresh(user)
else:
db.flush()
return user
def update_feedback_status(
db: Session, feedback: Feedback, *, status: str, commit: bool = True
) -> Feedback:
@@ -34,3 +52,30 @@ def update_feedback_status(
else:
db.flush()
return feedback
def review_price_report(
db: Session,
report: PriceReport,
*,
status: str,
reward_coins: int | None = None,
reject_reason: str | None = None,
commit: bool = True,
) -> PriceReport:
"""审核上报:置 approved/rejected + 记审核时间;通过填 reward_coins、拒绝填 reject_reason。
发金币(wallet.grant_coins)不在这里——由 router 在同一事务里调,涉钱逻辑不重写(见模块头注释)。
"""
report.status = status
if reward_coins is not None:
report.reward_coins = reward_coins
if reject_reason is not None:
report.reject_reason = reject_reason
report.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None) # 北京 wall-clock,同 created_at
if commit:
db.commit()
db.refresh(report)
else:
db.flush()
return report
+278 -2
View File
@@ -5,11 +5,16 @@
"""
from __future__ import annotations
from sqlalchemy import Select, func, select
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from sqlalchemy import Select, asc, desc, func, or_, select
from sqlalchemy.orm import Session
from app.models.admin import AdminAuditLog
from app.models.comparison import ComparisonRecord
from app.models.feedback import Feedback
from app.models.price_report import PriceReport
from app.models.user import User
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
@@ -88,15 +93,114 @@ def list_all_withdraw_orders(
*,
user_id: int | None = None,
status: str | None = None,
keyword: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
date_field: str = "created_at",
sort_by: str = "created_at",
sort_order: str = "desc",
quick_filter: str | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[WithdrawOrder], int | None]:
stmt = select(WithdrawOrder)
needs_user_join = bool(keyword and keyword.strip()) or quick_filter == "high_risk"
if needs_user_join:
stmt = stmt.outerjoin(User, User.id == WithdrawOrder.user_id)
if user_id is not None:
stmt = stmt.where(WithdrawOrder.user_id == user_id)
if status:
stmt = stmt.where(WithdrawOrder.status == status)
return cursor_paginate(db, stmt, WithdrawOrder.id, limit=limit, cursor=cursor)
kw = (keyword or "").strip()
if kw:
pattern = f"%{kw}%"
conditions = [
WithdrawOrder.out_bill_no.ilike(pattern),
WithdrawOrder.transfer_bill_no.ilike(pattern),
WithdrawOrder.user_name.ilike(pattern),
WithdrawOrder.wechat_state.ilike(pattern),
WithdrawOrder.fail_reason.ilike(pattern),
User.phone.ilike(pattern),
User.nickname.ilike(pattern),
User.wechat_nickname.ilike(pattern),
]
if kw.isdigit():
conditions.append(WithdrawOrder.user_id == int(kw))
stmt = stmt.where(or_(*conditions))
date_col = WithdrawOrder.updated_at if date_field == "updated_at" else WithdrawOrder.created_at
if date_from is not None:
stmt = stmt.where(date_col >= _as_utc_naive(date_from))
if date_to is not None:
stmt = stmt.where(date_col <= _as_utc_naive(date_to))
now = datetime.now(timezone.utc).replace(tzinfo=None)
today_start = (
datetime.now(ZoneInfo("Asia/Shanghai"))
.replace(hour=0, minute=0, second=0, microsecond=0)
.astimezone(timezone.utc)
.replace(tzinfo=None)
)
if quick_filter == "abnormal":
stmt = stmt.where(
or_(
WithdrawOrder.status.in_(("failed", "rejected")),
WithdrawOrder.fail_reason.is_not(None),
WithdrawOrder.wechat_state.in_(("FAIL", "CANCELLED", "CLOSED")),
)
)
elif quick_filter == "failed":
stmt = stmt.where(WithdrawOrder.status == "failed")
elif quick_filter == "overdue_reviewing":
stmt = stmt.where(
WithdrawOrder.status == "reviewing",
WithdrawOrder.created_at <= now - timedelta(minutes=30),
)
elif quick_filter == "pending_overdue":
stmt = stmt.where(
WithdrawOrder.status == "pending",
WithdrawOrder.updated_at <= now - timedelta(minutes=15),
)
elif quick_filter == "today":
stmt = stmt.where(WithdrawOrder.created_at >= today_start)
elif quick_filter == "high_risk":
stmt = stmt.where(
or_(
WithdrawOrder.user_name.is_(None),
WithdrawOrder.user_name == "",
User.status != "active",
User.created_at >= now - timedelta(hours=24),
WithdrawOrder.status.in_(("failed", "rejected")),
WithdrawOrder.fail_reason.is_not(None),
)
)
sort_cols = {
"id": WithdrawOrder.id,
"created_at": WithdrawOrder.created_at,
"updated_at": WithdrawOrder.updated_at,
"amount_cents": WithdrawOrder.amount_cents,
}
sort_col = sort_cols.get(sort_by, WithdrawOrder.created_at)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(WithdrawOrder.id) if sort_order == "asc" else desc(WithdrawOrder.id)
stmt = stmt.order_by(order_fn(sort_col), id_order)
offset = max(cursor or 0, 0)
rows = list(db.execute(stmt.offset(offset).limit(limit + 1)).scalars().all())
has_more = len(rows) > limit
items = rows[:limit]
next_cursor = offset + limit if has_more else None
return items, next_cursor
def _as_utc_naive(value: datetime) -> datetime:
"""前端传 ISO 时间;DB 当前按 UTC naive 比较最稳(SQLite/本地开发一致)。"""
if value.tzinfo is None:
return value
return value.astimezone(timezone.utc).replace(tzinfo=None)
def list_feedbacks(
@@ -122,6 +226,147 @@ def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder
).scalar_one_or_none()
def withdraw_summary(db: Session) -> dict:
"""提现审核台顶部统计。金额单位:分。"""
rows = db.execute(
select(
WithdrawOrder.status,
func.count(WithdrawOrder.id),
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0),
).group_by(WithdrawOrder.status)
).all()
by_status = {
status: {"count": int(count), "amount_cents": int(amount_cents)}
for status, count, amount_cents in rows
}
today_start = (
datetime.now(ZoneInfo("Asia/Shanghai"))
.replace(hour=0, minute=0, second=0, microsecond=0)
.astimezone(timezone.utc)
)
def _today_count(status: str) -> int:
return db.execute(
select(func.count(WithdrawOrder.id)).where(
WithdrawOrder.status == status,
WithdrawOrder.updated_at >= today_start,
)
).scalar_one()
today_success_amount = db.execute(
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
WithdrawOrder.status == "success",
WithdrawOrder.updated_at >= today_start,
)
).scalar_one()
return {
"reviewing_count": by_status.get("reviewing", {}).get("count", 0),
"reviewing_amount_cents": by_status.get("reviewing", {}).get("amount_cents", 0),
"pending_count": by_status.get("pending", {}).get("count", 0),
"failed_count": by_status.get("failed", {}).get("count", 0),
"today_success_count": _today_count("success"),
"today_success_amount_cents": int(today_success_amount),
"today_rejected_count": _today_count("rejected"),
}
def list_withdraw_audit_logs(
db: Session, out_bill_no: str, *, limit: int = 20
) -> list[AdminAuditLog]:
return list(
db.execute(
select(AdminAuditLog)
.where(AdminAuditLog.target_type == "withdraw", AdminAuditLog.target_id == out_bill_no)
.order_by(AdminAuditLog.id.desc())
.limit(limit)
).scalars().all()
)
def withdraw_risk_flags(
order: WithdrawOrder,
user: User | None,
recent_withdraws: list[WithdrawOrder],
cash_balance_cents: int,
) -> tuple[list[str], int]:
flags: list[str] = []
if not order.user_name:
flags.append("缺少提现实名")
if user and user.status != "active":
flags.append(f"账号状态:{user.status}")
if user and user.created_at:
created_at = user.created_at.replace(tzinfo=timezone.utc) if user.created_at.tzinfo is None else user.created_at
if datetime.now(timezone.utc) - created_at < timedelta(hours=24):
flags.append("新注册用户")
failed_or_rejected = sum(1 for item in recent_withdraws if item.status in {"failed", "rejected"})
if failed_or_rejected:
flags.append(f"历史异常提现{failed_or_rejected}")
recent_reviewing = sum(1 for item in recent_withdraws if item.status == "reviewing")
if recent_reviewing >= 3:
flags.append(f"待审核提现偏多:{recent_reviewing}")
if cash_balance_cents < 0:
flags.append("现金余额为负")
score = min(100, len(flags) * 20 + failed_or_rejected * 10)
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()
)
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"}
refund_counts: dict[str, int] = {}
for txn in cash_txns:
if txn.biz_type == "withdraw_refund" and txn.ref_id:
refund_counts[txn.ref_id] = refund_counts.get(txn.ref_id, 0) + 1
missing_withdraw = 0
missing_refund = 0
refund_on_non_terminal = 0
for order in orders:
if order.out_bill_no not in withdraw_refs:
missing_withdraw += 1
has_refund = refund_counts.get(order.out_bill_no, 0) > 0
if order.status in {"failed", "rejected"} and not has_refund:
missing_refund += 1
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
ok = (
diff == 0
and missing_withdraw == 0
and missing_refund == 0
and duplicate_refund == 0
and refund_on_non_terminal == 0
)
return {
"ok": ok,
"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,
}
def get_user_overview(db: Session, user_id: int) -> dict | None:
"""用户 360 概览:基础资料 + 钱包余额 + 各项 count。历史明细走各自分页接口(带 user_id 过滤)。"""
user = db.get(User, user_id)
@@ -151,3 +396,34 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
).scalar_one(),
"feedback_total": _count(Feedback, Feedback.user_id == user_id),
}
def list_price_reports(
db: Session,
*,
status: str | None = None,
user_id: int | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[PriceReport], int | None]:
"""上报更低价列表(admin 全量,可按状态/用户筛)。游标同 feedback:id 倒序。"""
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 cursor_paginate(db, stmt, PriceReport.id, limit=limit, cursor=cursor)
def price_report_summary(db: Session) -> dict:
"""上报审核台各状态计数(待审核/已通过/已拒绝/合计)。"""
rows = db.execute(
select(PriceReport.status, func.count(PriceReport.id)).group_by(PriceReport.status)
).all()
by_status = {status: int(count) for status, count in rows}
return {
"pending": by_status.get("pending", 0),
"approved": by_status.get("approved", 0),
"rejected": by_status.get("rejected", 0),
"total": sum(by_status.values()),
}
+34
View File
@@ -10,8 +10,11 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.comparison import ComparisonRecord
from app.models.feedback import Feedback
from app.models.signin import SigninBoostRecord, SigninRecord
from app.models.user import User
from app.models.wallet import CoinTransaction, WithdrawOrder
@@ -71,6 +74,37 @@ def dashboard_overview(db: Session) -> dict:
"coins": {
# 累计发放金币(coin_transaction 里所有 amount>0 之和;负数是兑换/扣减不计)
"granted_total": _sum(CoinTransaction.amount, CoinTransaction.amount > 0),
"reward_video_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
),
"reward_video_watch_count": _count(
AdRewardRecord,
AdRewardRecord.reward_scene == "reward_video",
AdRewardRecord.status == "granted",
),
"feed_ad_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type == "feed_ad_reward",
),
"feed_ad_watch_count": _count(
AdFeedRewardRecord,
AdFeedRewardRecord.status == "granted",
),
"signin_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type == "signin",
),
"signin_count": _count(SigninRecord),
"signin_boost_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type == "signin_boost",
),
"signin_boost_watch_count": _count(SigninBoostRecord),
},
"cash": {
"withdraw_success_cents": _sum(
+44
View File
@@ -0,0 +1,44 @@
"""admin 看广告金币审计:只读对账,核对发奖金币是否按公式计算。
任意已登录 admin 可看(只读,不涉及资金操作)。复算逻辑在 app/admin/repositories/ad_audit.py。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import ad_audit
from app.admin.schemas.ad_audit import AdCoinAuditOut, AdCoinAuditRow, AdCoinFormulaOut
from app.core.rewards import cn_today
router = APIRouter(
prefix="/admin/api/ad-coin-audit",
tags=["admin-ad-coin-audit"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=AdCoinAuditOut, summary="看广告金币公式审计(复算对比)")
def get_ad_coin_audit(
db: AdminDb,
date: Annotated[str | None, Query(description="北京时间 YYYY-MM-DD,默认今天")] = None,
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
scene: Annotated[
str | None, Query(description="reward_video / feed;不传=两类都要")
] = None,
limit: Annotated[int, Query(ge=1, le=500)] = 100,
) -> AdCoinAuditOut:
audit_date = date or cn_today().isoformat()
rows = ad_audit.ad_coin_audit(
db, date=audit_date, user_id=user_id, scene=scene, limit=limit,
)
items = [AdCoinAuditRow(**r) for r in rows]
return AdCoinAuditOut(
date=audit_date,
formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()),
total=len(items),
mismatch_count=sum(1 for it in items if not it.matched),
items=items,
)
+2 -2
View File
@@ -34,8 +34,8 @@ def _validate(key: str, value: Any) -> None:
raise ValueError("需为非空整数列表")
if not all(isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in value):
raise ValueError("列表元素需为非负整数")
if key == "signin_rewards" and len(value) != 7:
raise ValueError("签到档位必须正好 7 个(对应 7 天循环)")
if key == "signin_rewards" and len(value) != 14:
raise ValueError("签到档位必须正好 14 个(对应 14 天循环)")
elif t == "dict_str_int":
if not isinstance(value, dict) or not all(
isinstance(k, str) and isinstance(v, int) and not isinstance(v, bool)
+160
View File
@@ -0,0 +1,160 @@
"""admin 首页轮播种子管理:列出 / 新增 / 改 / 删 / 批量生成 / 预览(带审计)。
种子用于首页轮播「真实+种子混播」的兜底(见 app/repositories/ops_marquee.py)。
权限:operator 可改,super 恒可;预览为只读(任意已登录 admin)。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.ops_marquee_seed import (
OpsMarqueeSeedBulkCreate,
OpsMarqueeSeedCreate,
OpsMarqueeSeedOut,
OpsMarqueeSeedUpdate,
OpsSavingsFeedPreviewOut,
)
from app.models.admin import AdminUser
from app.models.ops_marquee_seed import OpsMarqueeSeed
from app.repositories import ops_marquee
router = APIRouter(
prefix="/admin/api/marquee-seeds",
tags=["admin-marquee-seeds"],
dependencies=[Depends(get_current_admin)],
)
def _out(seed: OpsMarqueeSeed) -> OpsMarqueeSeedOut:
return OpsMarqueeSeedOut(
id=seed.id,
masked_user=seed.masked_user,
min_cents=seed.min_cents,
max_cents=seed.max_cents,
enabled=seed.enabled,
sort_order=seed.sort_order,
created_at=seed.created_at.isoformat() if seed.created_at else None,
)
@router.get("", response_model=list[OpsMarqueeSeedOut], summary="轮播种子列表")
def list_seeds(db: AdminDb) -> list[OpsMarqueeSeedOut]:
return [_out(s) for s in ops_marquee.list_seeds(db)]
@router.get("/preview", response_model=OpsSavingsFeedPreviewOut, summary="预览实际混播 feed")
def preview_feed(
db: AdminDb,
limit: Annotated[int, Query(ge=1, le=30)] = 8,
) -> OpsSavingsFeedPreviewOut:
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。"""
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit))
@router.post("", response_model=OpsMarqueeSeedOut, summary="新增轮播种子(带审计)")
def create_seed(
body: OpsMarqueeSeedCreate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OpsMarqueeSeedOut:
try:
seed = ops_marquee.create_seed(
db,
masked_user=body.masked_user,
min_cents=body.min_cents,
max_cents=body.max_cents,
enabled=body.enabled,
sort_order=body.sort_order,
commit=False,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
write_audit(
db, admin, action="ops_marquee_seed.create", target_type="ops_marquee_seed",
target_id=None, detail=body.model_dump(), ip=get_client_ip(request), commit=False,
)
db.commit()
db.refresh(seed)
return _out(seed)
@router.post("/bulk", response_model=list[OpsMarqueeSeedOut], summary="批量生成种子(带审计)")
def bulk_generate(
body: OpsMarqueeSeedBulkCreate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> list[OpsMarqueeSeedOut]:
try:
seeds = ops_marquee.bulk_generate(
db,
count=body.count,
min_cents=body.min_cents,
max_cents=body.max_cents,
enabled=body.enabled,
commit=False,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
write_audit(
db, admin, action="ops_marquee_seed.bulk_generate", target_type="ops_marquee_seed",
target_id=None, detail=body.model_dump(), ip=get_client_ip(request), commit=False,
)
db.commit()
for s in seeds:
db.refresh(s)
return [_out(s) for s in seeds]
@router.patch("/{seed_id}", response_model=OpsMarqueeSeedOut, summary="改轮播种子(带审计)")
def update_seed(
seed_id: int,
body: OpsMarqueeSeedUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OpsMarqueeSeedOut:
try:
seed = ops_marquee.update_seed(
db,
seed_id,
masked_user=body.masked_user,
min_cents=body.min_cents,
max_cents=body.max_cents,
enabled=body.enabled,
sort_order=body.sort_order,
commit=False,
)
except ValueError as e:
raise HTTPException(status_code=404 if "不存在" in str(e) else 400, detail=str(e)) from e
write_audit(
db, admin, action="ops_marquee_seed.update", target_type="ops_marquee_seed",
target_id=seed_id, detail=body.model_dump(exclude_none=True),
ip=get_client_ip(request), commit=False,
)
db.commit()
db.refresh(seed)
return _out(seed)
@router.delete("/{seed_id}", summary="删轮播种子(带审计)")
def delete_seed(
seed_id: int,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> dict:
ok = ops_marquee.delete_seed(db, seed_id, commit=False)
if not ok:
raise HTTPException(status_code=404, detail="种子不存在")
write_audit(
db, admin, action="ops_marquee_seed.delete", target_type="ops_marquee_seed",
target_id=seed_id, detail=None, ip=get_client_ip(request), commit=False,
)
db.commit()
return {"deleted": True}
+74
View File
@@ -0,0 +1,74 @@
"""admin 首页三统计展示配置:列出三指标配置(+ 真实值预览)+ 改某指标(带审计 + 校验)。
每指标可独立选 real/manual/random。计算逻辑见 app/repositories/ops_stat.py。
权限:operator 可改(运营调门面数字),super 恒可。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.ops_stat_config import (
OpsStatItemOut,
OpsStatUpdateRequest,
)
from app.models.admin import AdminUser
from app.repositories import ops_stat
router = APIRouter(
prefix="/admin/api/dashboard-display",
tags=["admin-dashboard-display"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=list[OpsStatItemOut], summary="首页三统计配置 + 真实值预览")
def list_display(db: AdminDb) -> list[OpsStatItemOut]:
return [OpsStatItemOut(**item) for item in ops_stat.get_config(db)]
@router.patch(
"/{metric}", response_model=OpsStatItemOut, summary="改某指标展示配置(带审计)"
)
def update_display(
metric: str,
body: OpsStatUpdateRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OpsStatItemOut:
try:
before, after = ops_stat.update_config(
db,
metric,
mode=body.mode,
manual_value=body.manual_value,
random_mult_min=body.random_mult_min,
random_mult_max=body.random_mult_max,
random_tick_seconds=body.random_tick_seconds,
random_anchor_minutes=body.random_anchor_minutes,
random_kind=body.random_kind,
random_step_min=body.random_step_min,
random_step_max=body.random_step_max,
real_offset=body.real_offset,
allow_decrease=body.allow_decrease,
random_initial=body.random_initial,
apply_now=body.apply_now,
admin_id=admin.id,
commit=False,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
write_audit(
db, admin, action="ops_stat_config.set", target_type="ops_stat_config",
target_id=metric, detail={"before": before, "after": after},
ip=get_client_ip(request), commit=False,
)
db.commit()
item = next(it for it in ops_stat.get_config(db) if it["metric"] == metric)
return OpsStatItemOut(**item)
+103
View File
@@ -0,0 +1,103 @@
"""admin 上报更低价审核:列表 + 统计(读)+ 通过(发金币)/拒绝(写,带审计)。
数据由客户端 POST /api/v1/report 写入 price_report 表(提交即 pending);本路由是运营后台
对它的人工审核窗口。**通过** → 给上报用户钱包发固定金币(PRICE_REPORT_REWARD_COINS):
改状态 + 发金币(wallet.grant_coins)+ 审计同一事务一起 commit(原子,仿 users.grant_user_coins),
绝不只改状态不发钱或反之。客户端轮询 GET /api/v1/report/records 自动看到结果(无需推送)。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
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.price_report import (
PriceReportOut,
PriceReportRejectRequest,
PriceReportSummary,
)
from app.core.rewards import PRICE_REPORT_REWARD_COINS
from app.models.admin import AdminUser
from app.models.price_report import PriceReport
from app.repositories import wallet as wallet_repo
router = APIRouter(
prefix="/admin/api/price-reports",
tags=["admin-price-report"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=CursorPage[PriceReportOut], summary="上报更低价列表(筛选+分页)")
def list_price_reports(
db: AdminDb,
status: Annotated[str | None, Query()] = None,
user_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[PriceReportOut]:
items, next_cursor = queries.list_price_reports(
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
)
return CursorPage(
items=[PriceReportOut.model_validate(r) for r in items], next_cursor=next_cursor,
)
@router.get("/summary", response_model=PriceReportSummary, summary="上报审核统计(各状态计数)")
def price_report_summary(db: AdminDb) -> PriceReportSummary:
return PriceReportSummary.model_validate(queries.price_report_summary(db))
@router.post("/{report_id}/approve", response_model=OkResponse, summary="通过上报(发固定金币)")
def approve_price_report(
report_id: int,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
rep = db.get(PriceReport, report_id)
if rep is None:
raise HTTPException(status_code=404, detail="上报记录不存在")
if rep.status != "pending":
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
coins = PRICE_REPORT_REWARD_COINS
# 改状态 + 发金币 + 审计同一事务(commit=False),最后一起 commit:改了就有痕、发了就留账
mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False)
wallet_repo.grant_coins(
db, rep.user_id, coins,
biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过",
)
write_audit(
db, admin, action="price_report.approve", target_type="price_report", target_id=report_id,
detail={"reward_coins": coins, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
@router.post("/{report_id}/reject", response_model=OkResponse, summary="拒绝上报")
def reject_price_report(
report_id: int,
body: PriceReportRejectRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
rep = db.get(PriceReport, report_id)
if rep is None:
raise HTTPException(status_code=404, detail="上报记录不存在")
if rep.status != "pending":
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
reason = body.reason.strip()
mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False)
write_audit(
db, admin, action="price_report.reject", target_type="price_report", target_id=report_id,
detail={"reason": reason, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
+23
View File
@@ -13,6 +13,7 @@ from app.admin.schemas.user import (
AdminUserListItem,
AdminUserOverview,
GrantCoinsRequest,
SetDebugTraceRequest,
SetUserStatusRequest,
)
from app.models.admin import AdminUser
@@ -77,6 +78,28 @@ def set_user_status(
return OkResponse()
@router.post("/{user_id}/debug-trace", response_model=OkResponse, summary="开关调试链接权限")
def set_user_debug_trace(
user_id: int,
body: SetDebugTraceRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
user = user_repo.get_user_by_id(db, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
before = user.debug_trace_enabled
# 业务写 + 审计写同一事务(commit=False),最后一起 commit(同 set_user_status)
mutations.set_user_debug_trace(db, user, enabled=body.enabled, commit=False)
write_audit(
db, admin, action="user.debug_trace.set", target_type="user", target_id=user_id,
detail={"before": before, "after": body.enabled}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)")
def grant_user_coins(
user_id: int,
+303 -3
View File
@@ -6,6 +6,7 @@
"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
@@ -14,7 +15,23 @@ 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 queries
from app.admin.schemas.common import CursorPage
from app.admin.schemas.wallet import ReconcileResult, WithdrawOrderOut, WithdrawRejectRequest
from app.admin.schemas.admin import AdminAuditLogOut
from app.admin.schemas.wallet import (
CashTxnOut,
ReconcileResult,
WithdrawBulkRejectRequest,
WithdrawBulkRequest,
WithdrawBulkResult,
WithdrawBulkItemResult,
WithdrawDetailOut,
WithdrawLedgerCheckOut,
WithdrawOrderOut,
WithdrawRejectRequest,
WithdrawSummaryOut,
WithdrawUserSnapshot,
WxpayHealthCheckOut,
)
from app.core.config import settings
from app.integrations import wxpay
from app.models.admin import AdminUser
from app.repositories import wallet as wallet_repo
@@ -31,17 +48,143 @@ def list_withdraws(
db: AdminDb,
user_id: Annotated[int | None, Query()] = None,
status: Annotated[str | None, Query()] = None,
keyword: Annotated[str | None, Query(max_length=100)] = None,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
date_field: Annotated[str, Query(pattern="^(created_at|updated_at)$")] = "created_at",
sort_by: Annotated[
str, Query(pattern="^(id|created_at|updated_at|amount_cents)$")
] = "created_at",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
quick_filter: Annotated[
str | None,
Query(pattern="^(abnormal|failed|overdue_reviewing|pending_overdue|today|high_risk)$"),
] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[WithdrawOrderOut]:
items, next_cursor = queries.list_all_withdraw_orders(
db, user_id=user_id, status=status, limit=limit, cursor=cursor,
db,
user_id=user_id,
status=status,
keyword=keyword,
date_from=date_from,
date_to=date_to,
date_field=date_field,
sort_by=sort_by,
sort_order=sort_order,
quick_filter=quick_filter,
limit=limit,
cursor=cursor,
)
return CursorPage(
items=[WithdrawOrderOut.model_validate(o) for o in items], next_cursor=next_cursor,
)
@router.get("/summary", response_model=WithdrawSummaryOut, summary="提现审核台统计")
def withdraws_summary(db: AdminDb) -> WithdrawSummaryOut:
return WithdrawSummaryOut(**queries.withdraw_summary(db))
@router.get("/health-check", response_model=WxpayHealthCheckOut, summary="提现配置健康检查")
def withdraw_health_check() -> WxpayHealthCheckOut:
private_path = wxpay._resolve_config_path(settings.WXPAY_MCH_PRIVATE_KEY_PATH) # noqa: SLF001
public_path = wxpay._resolve_config_path(settings.WXPAY_PUBLIC_KEY_PATH) # noqa: SLF001
issues: list[str] = []
private_loadable = False
public_loadable = False
try:
wxpay._load_private_key() # noqa: SLF001
private_loadable = True
except Exception as e: # noqa: BLE001
issues.append(f"商户私钥不可用:{e}")
try:
wxpay._load_public_key() # noqa: SLF001
public_loadable = True
except Exception as e: # noqa: BLE001
issues.append(f"微信支付平台公钥不可用:{e}")
if not settings.wxpay_configured:
issues.append("微信支付基础配置不完整")
if not settings.WXPAY_AUTH_NOTIFY_URL:
issues.append("免确认授权回调地址未配置")
if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED:
issues.append("自动对账未开启")
return WxpayHealthCheckOut(
ok=not issues,
wxpay_configured=settings.wxpay_configured,
wxpay_auth_configured=settings.wxpay_auth_configured,
private_key_path=str(private_path),
private_key_exists=private_path.exists(),
private_key_loadable=private_loadable,
public_key_path=str(public_path),
public_key_exists=public_path.exists(),
public_key_loadable=public_loadable,
auth_notify_url_configured=bool(settings.WXPAY_AUTH_NOTIFY_URL),
auto_reconcile_enabled=settings.WITHDRAW_AUTO_RECONCILE_ENABLED,
auto_reconcile_interval_sec=settings.WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC,
auto_reconcile_older_than_minutes=settings.WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES,
issues=issues,
)
@router.get("/ledger-check", response_model=WithdrawLedgerCheckOut, summary="提现资金账本校验")
def withdraw_ledger_check(db: AdminDb) -> WithdrawLedgerCheckOut:
return WithdrawLedgerCheckOut(**queries.withdraw_ledger_check(db))
@router.get("/{out_bill_no}", response_model=WithdrawDetailOut, summary="提现单详情")
def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut:
order = queries.get_withdraw_by_out_bill_no(db, out_bill_no)
if order is None:
raise HTTPException(status_code=404, detail="提现单不存在")
overview = queries.get_user_overview(db, order.user_id)
user_snapshot = None
if overview is not None:
user = overview["user"]
user_snapshot = WithdrawUserSnapshot(
id=user.id,
phone=user.phone,
nickname=user.nickname,
status=user.status,
wechat_nickname=user.wechat_nickname,
wechat_avatar_url=user.wechat_avatar_url,
created_at=user.created_at,
last_login_at=user.last_login_at,
cash_balance_cents=overview["cash_balance_cents"],
withdraw_total=overview["withdraw_total"],
withdraw_success_cents=overview["withdraw_success_cents"],
)
recent_withdraws, _ = queries.list_all_withdraw_orders(
db, user_id=order.user_id, limit=5, cursor=None,
)
recent_cash_transactions, _ = queries.list_all_cash_transactions(
db, user_id=order.user_id, limit=8, cursor=None,
)
audit_logs = queries.list_withdraw_audit_logs(db, out_bill_no, limit=10)
risk_flags, risk_score = queries.withdraw_risk_flags(
order,
overview["user"] if overview else None,
recent_withdraws,
overview["cash_balance_cents"] if overview else 0,
)
return WithdrawDetailOut(
order=WithdrawOrderOut.model_validate(order),
user=user_snapshot,
risk_flags=risk_flags,
risk_score=risk_score,
recent_withdraws=[WithdrawOrderOut.model_validate(o) for o in recent_withdraws],
recent_cash_transactions=[CashTxnOut.model_validate(t) for t in recent_cash_transactions],
audit_logs=[AdminAuditLogOut.model_validate(log) for log in audit_logs],
)
# 注意:/reconcile 必须在 /{out_bill_no}/refresh 之前声明(静态路径优先于路径参数)
@router.post("/reconcile", response_model=ReconcileResult, summary="批量对账(扫超时 pending 单)")
def reconcile(
@@ -61,6 +204,159 @@ def reconcile(
return ReconcileResult(**result)
def _bulk_result(items: list[WithdrawBulkItemResult]) -> WithdrawBulkResult:
success = sum(1 for item in items if item.ok)
return WithdrawBulkResult(
total=len(items),
success=success,
failed=len(items) - success,
items=items,
)
@router.post("/bulk/refresh", response_model=WithdrawBulkResult, summary="批量刷新查单")
def bulk_refresh_withdraws(
body: WithdrawBulkRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
) -> WithdrawBulkResult:
results: list[WithdrawBulkItemResult] = []
ip = get_client_ip(request)
for out_bill_no in body.out_bill_nos:
try:
order = queries.get_withdraw_by_out_bill_no(db, out_bill_no)
if order is None:
raise wallet_repo.WithdrawOrderNotFound
refreshed = wallet_repo.refresh_withdraw_status(
db, order.user_id, out_bill_no, cancel_if_unconfirmed=True,
)
write_audit(
db, admin, action="withdraw.refresh", target_type="withdraw",
target_id=out_bill_no,
detail={
"status": refreshed.status,
"wechat_state": refreshed.wechat_state,
"fail_reason": refreshed.fail_reason,
"bulk": True,
},
ip=ip, commit=True,
)
results.append(
WithdrawBulkItemResult(
out_bill_no=out_bill_no, ok=True, status=refreshed.status
)
)
except wallet_repo.WithdrawOrderNotFound:
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="提现单不存在")
)
except wxpay.WxPayNotConfiguredError:
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="微信支付未配置")
)
except Exception as e: # noqa: BLE001 - 批量操作单笔失败不打断整批
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=f"系统异常: {e}")
)
return _bulk_result(results)
@router.post("/bulk/approve", response_model=WithdrawBulkResult, summary="批量审核通过并打款")
def bulk_approve_withdraws(
body: WithdrawBulkRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
) -> WithdrawBulkResult:
results: list[WithdrawBulkItemResult] = []
ip = get_client_ip(request)
for out_bill_no in body.out_bill_nos:
try:
order = wallet_repo.approve_withdraw(db, out_bill_no)
write_audit(
db, admin, action="withdraw.approve", target_type="withdraw",
target_id=out_bill_no,
detail={
"status": order.status,
"wechat_state": order.wechat_state,
"amount_cents": order.amount_cents,
"bulk": True,
},
ip=ip, commit=True,
)
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=True, status=order.status)
)
except wallet_repo.WithdrawOrderNotFound:
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="提现单不存在")
)
except wallet_repo.WithdrawNotReviewable as e:
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=str(e))
)
except wxpay.WxPayNotConfiguredError:
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="微信支付未配置")
)
except Exception as e: # noqa: BLE001 - 批量操作单笔失败不打断整批
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=f"系统异常: {e}")
)
return _bulk_result(results)
@router.post("/bulk/reject", response_model=WithdrawBulkResult, summary="批量审核拒绝并退款")
def bulk_reject_withdraws(
body: WithdrawBulkRejectRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
) -> WithdrawBulkResult:
results: list[WithdrawBulkItemResult] = []
ip = get_client_ip(request)
for out_bill_no in body.out_bill_nos:
try:
order = wallet_repo.reject_withdraw(db, out_bill_no, body.reason)
write_audit(
db, admin, action="withdraw.reject", target_type="withdraw",
target_id=out_bill_no,
detail={
"reason": body.reason,
"amount_cents": order.amount_cents,
"bulk": True,
},
ip=ip, commit=True,
)
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=True, status=order.status)
)
except wallet_repo.WithdrawOrderNotFound:
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="提现单不存在")
)
except wallet_repo.WithdrawNotReviewable as e:
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=str(e))
)
except Exception as e: # noqa: BLE001 - 批量操作单笔失败不打断整批
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=f"系统异常: {e}")
)
return _bulk_result(results)
@router.post("/{out_bill_no}/refresh", response_model=WithdrawOrderOut, summary="单笔重试查单")
def refresh_withdraw(
out_bill_no: str,
@@ -79,7 +375,11 @@ def refresh_withdraw(
raise HTTPException(status_code=503, detail="微信支付未配置") from e
write_audit(
db, admin, action="withdraw.refresh", target_type="withdraw", target_id=out_bill_no,
detail={"status": refreshed.status, "wechat_state": refreshed.wechat_state},
detail={
"status": refreshed.status,
"wechat_state": refreshed.wechat_state,
"fail_reason": refreshed.fail_reason,
},
ip=get_client_ip(request), commit=True,
)
return WithdrawOrderOut.model_validate(refreshed)
+57
View File
@@ -0,0 +1,57 @@
"""看广告金币审计 schemas。
只读对账视图:把"看视频"(ad_reward_record)和"信息流"(ad_feed_reward_record)两类发奖记录,
用与正式发奖相同的公式 [app.core.rewards.calculate_ad_reward_coin] 复算一遍 expected_coin,
和实际入账的 actual_coin 对比,核对金币公式是否生效。字段 snake_case、金额按金币整数。
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class AdCoinAuditRow(BaseModel):
"""单条发奖记录的复算明细。"""
scene: str = Field(..., description="reward_video(看视频) / feed(信息流)")
record_id: int = Field(..., description="对应记录表主键")
user_id: int
created_at: datetime
status: str = Field(..., description="granted / capped / ecpm_missing")
ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示,SDK getEcpm 原值)")
ecpm_factor: float | None = Field(None, description="因子1(eCPM 档)")
units: int = Field(..., description="折算份数:看视频恒为 1;信息流 = 满 10 秒的份数")
lt_index_start: int | None = Field(None, description="本条占用的当日第几份(起)")
lt_index_end: int | None = Field(None, description="本条占用的当日第几份(止);看视频 = 起")
lt_factor_start: float | None = Field(None, description="因子2(LT)起值")
lt_factor_end: float | None = Field(None, description="因子2(LT)止值;看视频 = 起值")
expected_coin: int = Field(..., description="按公式复算应发金币")
actual_coin: int = Field(..., description="实际入账金币")
matched: bool = Field(..., description="复算与实发是否一致(capped/ecpm_missing 校验是否确为 0)")
class AdCoinFormulaOut(BaseModel):
"""当前金币公式参数(给前端展示规则参照)。"""
description: str = Field(
"eCPM元 = getEcpm分 ÷ 100;单份金币 = round(eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan);"
"因子1 按 eCPM元 判档(阈值 100/200/400 元)",
description="公式说明",
)
coin_per_yuan: int = Field(..., description="金币:元 汇率")
ecpm_unit: str = Field("分/千次展示(SDK getEcpm 原值)", description="eCPM 口径")
feed_unit_seconds: int = Field(..., description="信息流每多少秒折 1 份")
# [(因子值, 区间下限, 区间上限或 null)]
ecpm_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子1 档位表")
lt_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子2 LT 档位表")
class AdCoinAuditOut(BaseModel):
"""审计响应:公式参照 + 命中条数 + 明细。"""
date: str = Field(..., description="审计日期(北京时间 YYYY-MM-DD)")
formula: AdCoinFormulaOut
total: int = Field(..., description="返回的明细条数")
mismatch_count: int = Field(..., description="其中 matched=false 的条数(=0 说明公式全部生效)")
items: list[AdCoinAuditRow]
+8
View File
@@ -14,6 +14,14 @@ class DashboardUsers(BaseModel):
class DashboardCoins(BaseModel):
reward_video_coin_total: int = 0
reward_video_watch_count: int = 0
feed_ad_coin_total: int = 0
feed_ad_watch_count: int = 0
signin_coin_total: int = 0
signin_count: int = 0
signin_boost_coin_total: int = 0
signin_boost_watch_count: int = 0
granted_total: int
+53
View File
@@ -0,0 +1,53 @@
"""admin 首页轮播种子 schemas。金额单位:分(前端展示 ÷100 取元)。
种子 = 生成规则:masked_user 可空(空→feed 随机合成名)、金额是 [min_cents, max_cents] 区间。
"""
from __future__ import annotations
from pydantic import BaseModel
class OpsMarqueeSeedOut(BaseModel):
id: int
masked_user: str | None = None
min_cents: int
max_cents: int
enabled: bool
sort_order: int
created_at: str | None = None
class OpsMarqueeSeedCreate(BaseModel):
masked_user: str | None = None # 空 / 不传 → feed 随机合成脱敏名
min_cents: int
max_cents: int
enabled: bool = True
sort_order: int = 0
class OpsMarqueeSeedUpdate(BaseModel):
# masked_user:不传=不改;传空串=清空(改回随机合成);传值=固定该名
masked_user: str | None = None
min_cents: int | None = None
max_cents: int | None = None
enabled: bool | None = None
sort_order: int | None = None
class OpsMarqueeSeedBulkCreate(BaseModel):
"""批量生成 count 条自动合成名种子(冷启动铺量)。"""
count: int
min_cents: int
max_cents: int
enabled: bool = True
class OpsSavingsFeedPreviewItem(BaseModel):
masked_user: str
saved_amount_cents: int
time: str
class OpsSavingsFeedPreviewOut(BaseModel):
"""运营预览:实际混播出来的 feed(真实记录会插队,与客户端一致)。"""
items: list[OpsSavingsFeedPreviewItem]
+48
View File
@@ -0,0 +1,48 @@
"""admin 首页三统计展示配置 schemas。
倍率用千分比整数(1.000→1000);total_saved 的 manual_value / random_current 单位是分。
"""
from __future__ import annotations
from pydantic import BaseModel
class OpsStatItemOut(BaseModel):
metric: str # help_users / total_compares / total_saved
label: str # 帮助用户 / 完成比价 / 累计节省
unit: str # 人 / 次 / 分
mode: str # real / manual / random
manual_value: int | None = None
random_mult_min: int
random_mult_max: int
random_tick_seconds: int
random_anchor_minutes: int
random_kind: str # mult(×倍率) / add(+绝对增量)
random_step_min: int # 绝对增量区间(基础单位;total_saved 为分)
random_step_max: int
real_offset: int # 真实值模式基数偏移(基础单位)
allow_decrease: bool # 是否允许展示值下降(默认 false = 只增不减)
random_current: int | None = None
random_last_tick_at: str | None = None
real_value: int # 当前真实值(纯真实、不含偏移;给运营对比参考)
updated_at: str | None = None
class OpsStatUpdateRequest(BaseModel):
"""改某指标配置(均可选,只改传了的字段)。"""
mode: str | None = None
manual_value: int | None = None
random_mult_min: int | None = None
random_mult_max: int | None = None
random_tick_seconds: int | None = None
random_anchor_minutes: int | None = None
random_kind: str | None = None # mult / add
random_step_min: int | None = None
random_step_max: int | None = None
real_offset: int | None = None
allow_decrease: bool | None = None
# 切 random 时的初始基数;不传则用当前真实值播种
random_initial: int | None = None
# 立即更新:不等更新钟点,保存后马上把展示值刷新一次(real 快照/manual 生效/random 走一档)
apply_now: bool = False
+46
View File
@@ -0,0 +1,46 @@
"""admin 上报更低价审核 schemas。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class PriceReportOut(BaseModel):
"""admin 列表项:price_report 全字段(审核要看的快照 + 截图 + 状态 + 奖励)。"""
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
comparison_record_id: int | None = None
# 选中比价记录的快照
store_name: str | None = None
dish_summary: str | None = None
original_platform_id: str | None = None
original_platform_name: str | None = None
original_price_cents: int | None = None
# 用户上报的更低价
reported_platform_id: str
reported_platform_name: str
reported_price_cents: int
images: list[str] = []
# 审核
status: str
reject_reason: str | None = None
reward_coins: int | None = None
reviewed_at: datetime | None = None
created_at: datetime
class PriceReportRejectRequest(BaseModel):
reason: str = Field(min_length=1, max_length=256, description="拒绝理由,用户端记录页会看到")
class PriceReportSummary(BaseModel):
"""审核台顶部各状态计数。"""
pending: int
approved: int
rejected: int
total: int
+5
View File
@@ -15,6 +15,7 @@ class AdminUserListItem(BaseModel):
nickname: str | None = None
register_channel: str
status: str
debug_trace_enabled: bool = False
wechat_openid: str | None = None
created_at: datetime
last_login_at: datetime
@@ -45,3 +46,7 @@ class SetUserStatusRequest(BaseModel):
status: Literal["active", "disabled"] = Field(
..., description="active=解封 / disabled=封禁(注销 deleted 不走此接口)"
)
class SetDebugTraceRequest(BaseModel):
enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限")
+90
View File
@@ -5,6 +5,8 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.admin.schemas.admin import AdminAuditLogOut
class CoinTxnOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -48,11 +50,99 @@ class WithdrawOrderOut(BaseModel):
updated_at: datetime
class WithdrawSummaryOut(BaseModel):
reviewing_count: int
reviewing_amount_cents: int
pending_count: int
failed_count: int
today_success_count: int
today_success_amount_cents: int
today_rejected_count: int
class WithdrawUserSnapshot(BaseModel):
id: int
phone: str
nickname: str | None = None
status: str
wechat_nickname: str | None = None
wechat_avatar_url: str | None = None
created_at: datetime
last_login_at: datetime
cash_balance_cents: int
withdraw_total: int
withdraw_success_cents: int
class WithdrawDetailOut(BaseModel):
order: WithdrawOrderOut
user: WithdrawUserSnapshot | None = None
risk_flags: list[str]
risk_score: int
recent_withdraws: list[WithdrawOrderOut]
recent_cash_transactions: list[CashTxnOut]
audit_logs: list[AdminAuditLogOut]
class ReconcileResult(BaseModel):
checked: int
resolved: int
class WithdrawBulkRequest(BaseModel):
out_bill_nos: list[str] = Field(
..., min_length=1, max_length=50, description="提现商户单号列表"
)
class WithdrawBulkRejectRequest(WithdrawBulkRequest):
reason: str = Field(
..., min_length=1, max_length=200, description="批量拒绝理由(用户可见)"
)
class WithdrawBulkItemResult(BaseModel):
out_bill_no: str
ok: bool
status: str | None = None
error: str | None = None
class WithdrawBulkResult(BaseModel):
total: int
success: int
failed: int
items: list[WithdrawBulkItemResult]
class WithdrawLedgerCheckOut(BaseModel):
ok: bool
cash_balance_total_cents: int
cash_transaction_total_cents: int
balance_diff_cents: int
missing_withdraw_txn_count: int
missing_refund_txn_count: int
duplicate_refund_txn_count: int
refund_txn_on_non_terminal_count: int
class WxpayHealthCheckOut(BaseModel):
ok: bool
wxpay_configured: bool
wxpay_auth_configured: bool
private_key_path: str
private_key_exists: bool
private_key_loadable: bool
public_key_path: str
public_key_exists: bool
public_key_loadable: bool
auth_notify_url_configured: bool
auto_reconcile_enabled: bool
auto_reconcile_interval_sec: int
auto_reconcile_older_than_minutes: int
issues: list[str]
class WithdrawRejectRequest(BaseModel):
reason: str = Field(
..., min_length=1, max_length=200, description="拒绝理由(写入 fail_reason,用户可见)"
+1
View File
@@ -0,0 +1 @@
"""内部(server→server)端点。不对客户端开放,靠共享密钥头校验,不走用户 JWT。"""
+63
View File
@@ -0,0 +1,63 @@
"""价格观测内部上报端点(pricebot → app-server)。
pricebot 在比价 done 帧把整批价格事实 POST 到这里落库。**不是给客户端的接口**:
不走用户 JWT,靠 server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。
密钥未配置(默认空)时直接 503,避免裸奔的写端点。
与不鉴权的比价透传(compare.py / coupon.py)无关:那两个是客户端→server 的透传壳,
这个是 server→server 的内部写库。
"""
from __future__ import annotations
import hmac
import logging
from typing import Annotated
from fastapi import APIRouter, Header, HTTPException, status
from app.api.deps import DbSession
from app.core.config import settings
from app.repositories import price_observation as repo
from app.schemas.price_observation import (
PriceObservationBatchIn,
PriceObservationBatchOut,
)
logger = logging.getLogger("shagua.internal.price")
router = APIRouter(prefix="/internal", tags=["internal"])
def _check_secret(x_internal_secret: str | None) -> None:
"""共享密钥校验。未配置 → 503(挡住裸奔写端点);不匹配 → 401(常量时间比较)。"""
configured = settings.INTERNAL_API_SECRET
if not configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="internal api not configured",
)
if not x_internal_secret or not hmac.compare_digest(x_internal_secret, configured):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid internal secret",
)
@router.post(
"/price-observation",
response_model=PriceObservationBatchOut,
summary="价格观测内部上报(pricebot→app-server,落 price_observation)",
)
def report_price_observation(
payload: PriceObservationBatchIn,
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> PriceObservationBatchOut:
_check_secret(x_internal_secret)
inserted, skipped = repo.insert_batch(db, payload)
logger.info(
"price_observation trace=%s inserted=%d skipped=%d device=%s user=%s",
payload.trace_id, inserted, skipped,
payload.source_device_id, payload.source_user_id,
)
return PriceObservationBatchOut(inserted=inserted, skipped=skipped)
+195 -22
View File
@@ -10,6 +10,7 @@
from __future__ import annotations
import logging
import json
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -20,13 +21,18 @@ from app.core.config import settings
from app.integrations import pangle
from app.core.ratelimit import rate_limit
from app.repositories import ad_ecpm as crud_ecpm
from app.repositories import ad_feed_reward as crud_feed
from app.repositories import ad_reward as crud_ad
from app.repositories import ad_watch as crud_watch
from app.repositories import signin as crud_signin
from app.schemas.ad import (
AdRewardStatusOut,
EcpmReportIn,
EcpmReportOut,
FeedRewardIn,
FeedRewardOut,
PangleCallbackOut,
TestGrantIn,
TestGrantOut,
WatchReportIn,
WatchReportOut,
@@ -41,6 +47,23 @@ REASON_OK = 0
REASON_BAD_PARAMS = 1 # 验签过但缺 trans_id / user_id 非数字
REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
REWARD_SCENE_REWARD_VIDEO = "reward_video"
REWARD_SCENE_SIGNIN_BOOST = "signin_boost"
SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO, REWARD_SCENE_SIGNIN_BOOST}
def _parse_extra(raw_extra: str | None) -> dict[str, str]:
"""解析客户端 setMediaExtra 透传的 JSON;旧格式/异常返回空 dict。"""
if not raw_extra:
return {}
try:
data = json.loads(raw_extra)
except (TypeError, ValueError):
return {}
if not isinstance(data, dict):
return {}
return {str(k): str(v) for k, v in data.items() if v is not None}
@router.get(
"/pangle-callback",
@@ -49,9 +72,9 @@ REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
dependencies=[Depends(rate_limit(300, 60, "pangle-callback"))],
)
def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
"""穿山甲 GroMore 在激励视频播完后回调,带 user_id / trans_id / reward_amount / sign 等 query 参数。
"""穿山甲 GroMore 在激励视频播完后回调,带 user_id / trans_id / ecpm / sign 等 query 参数。
流程:开关/密钥就绪 → 验签(SHA256(m-key:trans_id)) → 解析 user_id/reward_amount → 幂等发金币。
流程:开关/密钥就绪 → 验签(SHA256(m-key:trans_id)) → 解析 user_id/场景/eCPM → 幂等发金币。
验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。
granted / capped → is_verify=true + reason=0。
"""
@@ -74,19 +97,86 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
user_id = int(raw_user_id)
coin = rewards.resolve_ad_reward_coin(db, params.get("reward_amount"))
raw = "&".join(f"{k}={v}" for k, v in sorted(params.items()) if k != "sign")
try:
rec = crud_ad.grant_ad_reward(
db, user_id, trans_id, coin=coin,
reward_name=params.get("reward_name"), raw=raw[:1024],
extra = {}
for extra_key in ("extra", "gromoreExtra", "gromore_extra"):
extra.update(_parse_extra(params.get(extra_key)))
reward_scene = extra.get("reward_scene") or REWARD_SCENE_REWARD_VIDEO
ad_session_id = extra.get("ad_session_id")
ecpm = params.get("ecpm")
existing = crud_ad.find_by_trans(db, trans_id)
if existing is not None:
logger.info(
"pangle callback idempotent user_id=%d trans_id=%s status=%s scene=%s",
user_id, trans_id, existing.status, existing.reward_scene,
)
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
try:
if reward_scene not in SUPPORTED_REWARD_SCENES:
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene[:32],
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="unknown_scene",
)
logger.warning(
"pangle callback unknown scene user_id=%d trans_id=%s scene=%s",
user_id, trans_id, reward_scene,
)
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
if reward_scene == REWARD_SCENE_SIGNIN_BOOST:
try:
boost, _balance = crud_signin.boost_today_signin(
db, user_id, ad_ref_id=trans_id, commit=False
)
except crud_signin.NotSignedTodayError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="not_signed",
)
except crud_signin.AlreadyBoostedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="already_boosted",
)
except crud_signin.LastCycleDayBoostBlockedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="last_day",
)
else:
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=boost.coin_awarded,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
commit=False,
)
db.commit()
db.refresh(rec)
else:
rec = crud_ad.grant_ad_reward(
db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id,
reward_scene=REWARD_SCENE_REWARD_VIDEO,
reward_name=params.get("reward_name"), raw=raw[:1024],
)
except crud_ad.UnknownUserError:
logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id)
return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER)
logger.info(
"ad reward user_id=%d trans_id=%s status=%s coin=%d", user_id, trans_id, rec.status, rec.coin
"ad reward user_id=%d trans_id=%s scene=%s status=%s coin=%d",
user_id, trans_id, rec.reward_scene, rec.status, rec.coin,
)
# granted / capped 均算"已处理":is_verify=true 不让穿山甲重试(capped 只是没加币)
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
@@ -112,15 +202,15 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
@router.post(
"/watch-report",
response_model=WatchReportOut,
summary="上报激励视频观看时长(每日 50 分钟防刷计时)",
summary="上报激励视频观看时长(旧客户端兼容字段)",
dependencies=[Depends(rate_limit(120, 60, "ad-watch-report"))],
)
def watch_report(payload: WatchReportIn, user: CurrentUser, db: DbSession) -> WatchReportOut:
"""客户端在激励视频关闭(onAdClose)后上报本次实际观看秒数,服务端累计到当日总时长。
Bearer 鉴权,user_id 取自 JWT(不信 body)。seconds 服务端夹 [0, MAX_SINGLE_WATCH_SECONDS]。
日累计达 DAILY_AD_WATCH_SECONDS_LIMIT(50 分钟)后:① 本接口 / reward-status 返回 remaining=0,
客户端不再展示广告;② 后端发奖(S2S 回调)也会因时长闸记 capped 不发金币(双闸,前端绕不过)
前产品只保留每日 500 次上限,DAILY_AD_WATCH_SECONDS_LIMIT=0 表示时长闸不启用;该接口
仍保留用于旧客户端兼容和排查观看时长
"""
total = crud_watch.add_watch_seconds(db, user.id, payload.seconds)
limit = rewards.DAILY_AD_WATCH_SECONDS_LIMIT
@@ -150,11 +240,12 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
crud_ecpm.create_ecpm_record(
db, user.id,
ad_type=payload.ad_type, ecpm_raw=payload.ecpm,
ad_session_id=payload.ad_session_id,
adn=payload.adn, slot_id=payload.slot_id,
)
logger.info(
"ad ecpm report user_id=%d type=%s ecpm=%s adn=%s slot=%s",
user.id, payload.ad_type, payload.ecpm, payload.adn, payload.slot_id,
"ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s",
user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id,
)
return EcpmReportOut(ok=True)
@@ -165,7 +256,7 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
summary="[仅本地联调]模拟穿山甲回调发奖",
dependencies=[Depends(rate_limit(60, 60, "ad-test-grant"))],
)
def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = None) -> TestGrantOut:
"""⚠️ 仅本地联调用:没部署公网、穿山甲 S2S 回调打不到本地时,客户端(debug 包)看完广告后
调这个接口,直接走与回调相同的发奖逻辑(幂等 + 每日上限),验证"看广告→金币到账"全链路。
@@ -175,18 +266,66 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
if not settings.AD_REWARD_TEST_GRANT_ENABLED:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found")
# 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限)
reward_scene = (payload.reward_scene if payload is not None else REWARD_SCENE_REWARD_VIDEO)
if reward_scene not in SUPPORTED_REWARD_SCENES:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="bad reward_scene")
# 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限/今日膨胀一次)
trans_id = f"test-{user.id}-{uuid.uuid4().hex}"
try:
rec = crud_ad.grant_ad_reward(
db, user.id, trans_id, reward_name="测试发奖", raw="client debug test-grant"
)
except crud_ad.UnknownUserError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
if reward_scene == REWARD_SCENE_SIGNIN_BOOST:
try:
boost, _balance = crud_signin.boost_today_signin(
db, user.id, ad_ref_id=trans_id, commit=False
)
except crud_signin.NotSignedTodayError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
raw="client debug test-grant signin_boost", status="not_signed",
)
except crud_signin.AlreadyBoostedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
raw="client debug test-grant signin_boost", status="already_boosted",
)
except crud_signin.LastCycleDayBoostBlockedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
raw="client debug test-grant signin_boost", status="last_day",
)
else:
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=boost.coin_awarded,
reward_scene=reward_scene, reward_name="测试签到膨胀",
raw="client debug test-grant signin_boost", commit=False,
)
db.commit()
db.refresh(rec)
else:
# 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式);
# 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。
ad_session_id = payload.ad_session_id if payload is not None else None
ecpm_val = "200"
if ad_session_id:
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id)
if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0:
ecpm_val = ecpm_rec.ecpm_raw
try:
rec = crud_ad.grant_ad_reward(
db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id,
reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}",
)
except crud_ad.UnknownUserError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
(used, limit, coin_per, round_count, cooldown_until,
_watched, _watch_limit) = crud_ad.today_status(db, user.id)
logger.info("ad TEST grant user_id=%d status=%s coin=%d", user.id, rec.status, rec.coin)
logger.info(
"ad TEST grant user_id=%d scene=%s status=%s coin=%d",
user.id, reward_scene, rec.status, rec.coin,
)
return TestGrantOut(
granted=(rec.status == "granted"),
status=rec.status,
@@ -198,3 +337,37 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
round_count=round_count,
cooldown_until=cooldown_until,
)
@router.post(
"/feed-reward",
response_model=FeedRewardOut,
summary="信息流广告完成后结算金币",
dependencies=[Depends(rate_limit(120, 60, "ad-feed-reward"))],
)
def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> FeedRewardOut:
"""点位 2:信息流广告每展示满 10 秒累计一份奖励,视频完成后一次性入账。
当前一期由客户端完成回调携带 eCPM / 展示秒数上报;client_event_id 做幂等键,避免重试重复发。
"""
rec = crud_feed.grant_feed_reward(
db,
user.id,
client_event_id=payload.client_event_id,
ecpm=payload.ecpm,
duration_seconds=payload.duration_seconds,
ad_session_id=payload.ad_session_id,
adn=payload.adn,
slot_id=payload.slot_id,
)
logger.info(
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
user.id, rec.client_event_id, rec.status, rec.unit_count, rec.coin,
)
return FeedRewardOut(
granted=(rec.status == "granted"),
status=rec.status,
coin=rec.coin,
unit_count=rec.unit_count,
daily_limit=rewards.get_ad_daily_limit(db),
)
+18 -6
View File
@@ -18,6 +18,7 @@ pricebot 协议文档:
"""
from __future__ import annotations
import json
import logging
from typing import Any
@@ -25,6 +26,7 @@ import httpx
from fastapi import APIRouter, HTTPException, Request, status
from app.core.config import settings
from app.core.pricebot_router import pick_pricebot
logger = logging.getLogger("shagua.compare")
@@ -38,25 +40,35 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
打日志。比价单帧是大上下文 / 逐帧 LLM,超时用 PRICEBOT_COMPARE_TIMEOUT_SEC(60s,
比领券的 30s 长)。
"""
# 读原始字节,避免"反序列化→再序列化"的双重 JSON(省 ~一半透传 CPU,让 app-server
# 单 worker 也扛得住高并发)。只 json.loads 一次拿 trace_id 做亲和 + 打日志,转发时
# 直接发原始 bytes(content=raw),不重新 dumps。
raw = await request.body()
try:
body = await request.json()
meta = json.loads(raw)
except Exception as e:
raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e
if not isinstance(meta, dict):
meta = {}
url = f"{settings.PRICEBOT_BASE_URL.rstrip('/')}{upstream_path}"
# 按 trace_id 一致性 hash 选 pricebot 实例(同一比价的所有帧落同一进程,内存维护 state)
base = pick_pricebot(meta.get("trace_id"))
url = f"{base.rstrip('/')}{upstream_path}"
timeout = settings.PRICEBOT_COMPARE_TIMEOUT_SEC
logger.info(
"compare %s device_id=%s trace_id=%s step=%s",
upstream_path,
body.get("device_id"),
body.get("trace_id"),
body.get("step"),
meta.get("device_id"),
meta.get("trace_id"),
meta.get("step"),
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, json=body)
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
+26 -5
View File
@@ -20,6 +20,7 @@ from fastapi import APIRouter, HTTPException, Query, status
from app.api.deps import CurrentUser, DbSession
from app.repositories import comparison as crud_compare
from app.schemas.compare_record import (
CompareStatsOut,
ComparisonRecordCreatedOut,
ComparisonRecordDetailOut,
ComparisonRecordIn,
@@ -52,6 +53,16 @@ def report_record(
return ComparisonRecordCreatedOut(id=rec.id)
@router.get(
"/stats",
response_model=CompareStatsOut,
summary="比价口径战绩(完成比价数 + 累计发现可省)",
)
def stats(user: CurrentUser, db: DbSession) -> CompareStatsOut:
count, saved = crud_compare.get_stats(db, user.id)
return CompareStatsOut(compare_count=count, discovered_saved_cents=saved)
@router.get(
"/records",
response_model=ComparisonRecordPage,
@@ -66,10 +77,12 @@ def list_records(
items, next_cursor = crud_compare.list_records(
db, user.id, limit=limit, cursor=cursor
)
return ComparisonRecordPage(
items=[ComparisonRecordOut.model_validate(it) for it in items],
next_cursor=next_cursor,
)
outs = [ComparisonRecordOut.model_validate(it) for it in items]
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)
if not user.debug_trace_enabled:
for o in outs:
o.trace_url = None
return ComparisonRecordPage(items=outs, next_cursor=next_cursor)
@router.get(
@@ -83,4 +96,12 @@ def get_record(
rec = crud_compare.get_record(db, user.id, record_id)
if rec is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="record not found")
return ComparisonRecordDetailOut.model_validate(rec)
out = ComparisonRecordDetailOut.model_validate(rec)
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url。
# ⚠️ raw_payload 是上报体全量(model_dump),里面也藏着一份 trace_url,必须一并抹掉——
# 否则无权限用户从详情接口的 raw_payload 绕过权限闸拿到 trace_url。
if not user.debug_trace_enabled:
out.trace_url = None
if isinstance(out.raw_payload, dict):
out.raw_payload.pop("trace_url", None)
return out
+173 -7
View File
@@ -10,19 +10,84 @@ pricebot 协议文档:
"""
from __future__ import annotations
import json
import logging
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 DbSession
from app.core.config import settings
from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal
from app.repositories import coupon_state as coupon_repo
from app.schemas.coupon_state import (
CouponCompletedTodayOut,
CouponPromptDismissIn,
CouponPromptShouldShowOut,
)
logger = logging.getLogger("shagua.coupon")
router = APIRouter(prefix="/api/v1/coupon", tags=["coupon"])
def _to_int(v: object) -> int | None:
"""user_id 协议是字符串(登录态才带),转 int 存库;缺失/非法 → None。"""
if v is None:
return None
try:
return int(v) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def _extract_coupon_results(resp_json: dict) -> list[dict]:
"""从 pricebot 响应抽本帧券结果,并**按 coupon_id 去重**。
⚠️ done/单券帧同时带 last_coupon_result(最后一张)+ action.params.coupon_results
(全量含最后一张)→ 最后一张出现两次。必须去重:同批里同 coupon_id 两次,
record_claims 在 autoflush=False 下两次 select 都查不到刚 add 的行 → 重复 add →
commit 撞唯一约束 IntegrityError 回滚整批(done/单券记录全丢)。
coupon_results(全量权威)覆盖 last_coupon_result。"""
by_id: dict[str, dict] = {}
last = resp_json.get("last_coupon_result")
if isinstance(last, dict) and last.get("coupon_id"):
by_id[last["coupon_id"]] = last
params = (resp_json.get("action") or {}).get("params") or {}
cr = params.get("coupon_results")
if isinstance(cr, list):
for x in cr:
if isinstance(x, dict) and x.get("coupon_id"):
by_id[x["coupon_id"]] = x
return list(by_id.values())
def _mark_engagement_blocking(
device_id: str, user_id: int | None, engage_type: str
) -> None:
"""独立 session 写 engagement(async 端点经 run_in_threadpool 调,不阻塞事件循环)。"""
with SessionLocal() as db:
coupon_repo.mark_engagement(db, device_id, user_id, engage_type)
def _record_claims_blocking(
device_id: str, user_id: int | None, trace_id: str | None, results: list[dict]
) -> None:
with SessionLocal() as db:
coupon_repo.record_claims(db, device_id, user_id, trace_id, results)
def _mark_completed_blocking(
device_id: str, user_id: int | None, trace_id: str | None
) -> None:
"""独立 session 写"今日已跑完整轮"(到 done 帧那刻调,best-effort 不阻塞领券)。"""
with SessionLocal() as db:
coupon_repo.mark_completed_today(db, device_id, user_id, trace_id)
@router.post("/step", summary="领券任务步进 (透传到 pricebot)")
async def coupon_step(
request: Request,
@@ -33,24 +98,48 @@ async def coupon_step(
- 透传: 不做 schema 校验,pricebot 自己校验
- 失败: 网络不可达 / pricebot 5xx → 502 + 友好 message
"""
# 读原始字节,避免"反序列化→再序列化"的双重 JSON(省透传 CPU)。只 loads 一次拿
# trace_id 做亲和 + 打日志,转发时直接发原始 bytes,不重新 dumps。
raw = await request.body()
try:
body = await request.json()
meta = json.loads(raw)
except Exception as e:
raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e
if not isinstance(meta, dict):
meta = {}
url = f"{settings.PRICEBOT_BASE_URL.rstrip('/')}/api/coupon/step"
device_id = meta.get("device_id")
user_id = _to_int(meta.get("user_id")) # 登录态才带;判断不靠它,资产留痕用
trace_id = meta.get("trace_id")
# 领券任务首帧(step=0)= 用户已发起领券 → 记一条今日 engagement(claim_started),
# 今天这台设备不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能
# 连累领券主流程,整段吞掉。
if device_id and meta.get("step") == 0:
try:
await run_in_threadpool(
_mark_engagement_blocking, device_id, user_id, "claim_started"
)
except Exception as e: # noqa: BLE001
logger.warning("coupon engagement write failed: %s", e)
# 按 trace_id 一致性 hash 选 pricebot 实例(同一领券任务的所有帧落同一进程)
base = pick_pricebot(meta.get("trace_id"))
url = f"{base.rstrip('/')}/api/coupon/step"
timeout = settings.PRICEBOT_REQUEST_TIMEOUT_SEC
logger.info(
"coupon_step device_id=%s trace_id=%s step=%s",
body.get("device_id"),
body.get("trace_id"),
body.get("step"),
meta.get("device_id"),
meta.get("trace_id"),
meta.get("step"),
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, json=body)
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
@@ -69,4 +158,81 @@ async def coupon_step(
detail=f"pricebot upstream returned {resp.status_code}",
)
return resp.json()
resp_json = resp.json()
# 领券结果沉淀(资产):中间帧 last_coupon_result + done 帧 coupon_results 幂等写库。
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
if device_id:
results = _extract_coupon_results(resp_json)
if results:
try:
await run_in_threadpool(
_record_claims_blocking, device_id, user_id, trace_id, results
)
except Exception as e: # noqa: BLE001
logger.warning("coupon claim write failed: %s", e)
# 整轮跑完(done 帧)→ 记一条"今日已完成",首页「去领取」卡据此置灰。
# pricebot 把中途单券 done 改写成 wait+continue=true,只有整套全跑完那帧才保留
# command=="done"(见 pricebot main.py),故 done 已等价"整轮完成"(用户决策 A 方案:
# 到 done 即算,不管单券成败),无需再判 continue。写库失败绝不连累领券返回。
action = resp_json.get("action") or {}
if device_id and action.get("command") == "done":
try:
await run_in_threadpool(
_mark_completed_blocking, device_id, user_id, trace_id
)
except Exception as e: # noqa: BLE001
logger.warning("coupon completion write failed: %s", e)
return resp_json
@router.post("/prompt/dismiss", summary="用户拒绝/关闭领券引导窗(记今日已 engage)")
def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]:
"""客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天不再弹。
server 在透传链路里看不到"用户拒绝"(拒绝不发起领券),故必须客户端通知。
MVP 不鉴权,按 device_id 记。
"""
coupon_repo.mark_engagement(db, payload.device_id, payload.user_id, "dismissed")
return {"ok": True}
@router.get(
"/prompt/should-show",
response_model=CouponPromptShouldShowOut,
summary="切到外卖 App 时是否还应弹领券引导窗",
)
def coupon_prompt_should_show(
device_id: str, db: DbSession
) -> CouponPromptShouldShowOut:
"""今天这台设备已 engage(领或拒)过 → should_show=false。客户端据此决定弹不弹
(纯后台判据,客户端不再做前台 SP 缓存判断)。"""
return CouponPromptShouldShowOut(
should_show=not coupon_repo.has_engaged_today(db, device_id)
)
@router.post("/prompt/reset", summary="重置今日领券引导窗 engagement(开发测频控用)")
def coupon_prompt_reset(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]:
"""删这台设备今天的 engagement → has_engaged_today 变 false,今天又能弹。
开发设置「重置今日领券弹窗状态」按钮调。MVP 不鉴权,按 device_id。"""
coupon_repo.reset_today_engagement(db, payload.device_id)
return {"ok": True}
@router.get(
"/completed-today",
response_model=CouponCompletedTodayOut,
summary="这台设备今天是否已跑完整轮领券(到 done 帧)",
)
def coupon_completed_today(
device_id: str, db: DbSession
) -> CouponCompletedTodayOut:
"""今天这台设备已跑完整轮(到 done)→ completed=true。客户端据此把首页「去领取」
卡置灰、不可点(用户决策 A 方案:到 done 即算,不管单券成败)。判断维度 device_id
必须与领券循环上报的一致(客户端两端都用 ANDROID_ID)。"""
return CouponCompletedTodayOut(
completed=coupon_repo.has_completed_today(db, device_id)
)
+217
View File
@@ -0,0 +1,217 @@
"""好友邀请 endpoint。
路由前缀 /api/v1/invite,需 Bearer 鉴权(用户级数据);唯一例外是 /landing-track
(落地页 dl.html 浏览器访问、无 token,详见任务 3 [[invite-three-tasks]]):
GET /me 我的邀请码 + 分享链接 + 已邀人数/已得金币(邀请页展示)
POST /bind 把当前登录用户(被邀请人)绑定到某邀请码;支持三种归因:
① clipboard:首启读剪贴板拿邀请码 → 上报码
② manual:用户在邀请页输入邀请码 → 上报码
③ fingerprint:剪贴板没拿到时上报指纹,后端反查
POST /landing-track 落地页 dl.html 访问时上报指纹(剪贴板兜底的服务端一端,无需鉴权)
绑定的真正逻辑(邀请码生成/反查、幂等、自邀屏蔽、发金币、指纹反查)在
repositories/invite.py。
"""
from __future__ import annotations
import logging
import re
from fastapi import APIRouter, Request
from app.api.deps import CurrentUser, DbSession
from app.core import rewards
from app.core.config import settings
from app.repositories import invite as invite_repo
from app.schemas.invite import (
BindInviteIn,
BindInviteOut,
InviteeItem,
InviteeListOut,
InviteInfoOut,
LandingTrackIn,
LandingTrackOut,
)
logger = logging.getLogger("shagua.invite")
router = APIRouter(prefix="/api/v1/invite", tags=["invite"])
_BIND_MESSAGES = {
"success": "邀请绑定成功,金币已到账",
"already_bound": "你已绑定过邀请人",
"invalid_code": "邀请码无效",
"self_invite": "不能填写自己的邀请码",
"not_eligible": "邀请仅对新注册用户生效",
"fp_not_found": "未匹配到邀请关系",
}
def _client_ip(request: Request) -> str:
"""从 HTTP 头拿真实 IP。nginx 反代时走 X-Forwarded-For;裸跑 uvicorn 走 request.client。"""
xff = request.headers.get("x-forwarded-for", "")
if xff:
# X-Forwarded-For 可能是 "client, proxy1, proxy2" 链;取第一个 = 真实客户端
return xff.split(",")[0].strip()
return request.client.host if request.client else ""
# Android UA 形如 '... ; <Model> Build/<id>) AppleWebKit/...';抓 ';' 后到 ' Build/' 前
# 的 token = Build.MODEL(跨端跟客户端 android.os.Build.MODEL 对齐)。user-agents 库
# 把 Android 设备归为 'Smartphone' 通用名,抓不到真实型号,只能正则。
_ANDROID_BUILD_MODEL_RE = re.compile(r";\s*([^;]+?)\s+Build/", re.IGNORECASE)
def _parse_device_model(ua: str) -> str:
"""解析浏览器 UA 拿手机型号(如 '24115RA8EC''PJF110')。
Android:正则抓 UA 里 ';...Build/' 前的 token(== Build.MODEL),跨端可严格匹配。
iOS / 解析不到:退到 user-agents 库的通用解析,失败/UA 空 → 返空字符串。
"""
if not ua:
return ""
m = _ANDROID_BUILD_MODEL_RE.search(ua)
if m:
return m.group(1).strip()
try:
from user_agents import parse
return (parse(ua).device.model or "").strip()
except Exception: # noqa: BLE001
return ""
@router.get("/me", response_model=InviteInfoOut, summary="我的邀请码 + 分享链接 + 战绩")
def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
code = invite_repo.ensure_code(db, user)
invited, coins = invite_repo.get_stats(db, user.id)
sep = "&" if "?" in settings.INVITE_LANDING_URL else "?"
share_url = f"{settings.INVITE_LANDING_URL}{sep}ref={code}"
return InviteInfoOut(
invite_code=code,
share_url=share_url,
invited_count=invited,
coins_earned=coins,
)
@router.get("/invitees", response_model=InviteeListOut, summary="我邀请的人列表(分页)")
def my_invitees(
user: CurrentUser, db: DbSession, limit: int = 20, offset: int = 0
) -> InviteeListOut:
"""邀请页小窗(取前几条) + 完整列表页(分页加载)共用。
名字/头像的降级兜底在 repositories/invite.get_invitees 算好,这里只组装响应。
limit 夹到 [1,50] 防滥用;offset 不小于 0。
"""
limit = max(1, min(limit, 50))
offset = max(0, offset)
items, total, has_more = invite_repo.get_invitees(
db, user.id, limit=limit, offset=offset,
)
return InviteeListOut(
items=[InviteeItem(**it) for it in items],
total=total,
has_more=has_more,
)
@router.post(
"/landing-track",
response_model=LandingTrackOut,
summary="落地页指纹采集(无需鉴权)",
)
def landing_track(
req: LandingTrackIn, request: Request, db: DbSession
) -> LandingTrackOut:
"""B 浏览器打开 dl.html?ref=xxx 时上报指纹。
服务端从 HTTP 头拿 IP/UA、解析 UA 拿 device_model,跟 req.screen 一起入库。
无需鉴权(浏览器没 token)。invalid_code / no_ip 走 silent 返回(不抛 5xx 影响下载流程)。
"""
inviter = invite_repo.resolve_inviter(db, req.ref)
if inviter is None or inviter.status != "active":
return LandingTrackOut(status="invalid_code")
ip = _client_ip(request)
if not ip:
return LandingTrackOut(status="no_ip")
ua_str = request.headers.get("user-agent", "")
device_model = _parse_device_model(ua_str)
invite_repo.record_fingerprint(
db,
inviter_user_id=inviter.id,
ip=ip,
screen=req.screen,
device_model=device_model,
user_agent=ua_str,
)
logger.info(
"invite landing-track inviter=%d ip=%s screen=%s model=%s",
inviter.id, ip, req.screen, device_model,
)
return LandingTrackOut(status="ok")
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
def bind_invite(
req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request
) -> BindInviteOut:
code = (req.invite_code or "").strip()
# 路径 1:有邀请码 → 走原路径(clipboard / manual)
if code:
result = invite_repo.bind(
db, invitee=user, invite_code=code, channel=req.channel,
)
logger.info(
"invite bind invitee=%d code=%s channel=%s -> %s",
user.id, code, req.channel, result.status,
)
return BindInviteOut(
status=result.status,
coins_awarded=result.invitee_coin,
message=_BIND_MESSAGES.get(result.status, result.status),
)
# 路径 2:无邀请码 + 有指纹 → 指纹兜底反查
if req.fingerprint is not None:
ip = _client_ip(request)
inviter = invite_repo.resolve_inviter_by_fingerprint(
db,
ip=ip,
screen=req.fingerprint.screen,
device_model=req.fingerprint.device_model,
window_days=rewards.INVITE_FP_WINDOW_DAYS,
)
if inviter is None:
logger.info(
"invite bind invitee=%d fingerprint not_found ip=%s screen=%s model=%s",
user.id, ip, req.fingerprint.screen, req.fingerprint.device_model,
)
return BindInviteOut(
status="fp_not_found",
coins_awarded=0,
message=_BIND_MESSAGES["fp_not_found"],
)
# 用反查到的 inviter.invite_code 走原 bind 流程(走原幂等/自邀/新人闸/发币逻辑)
result = invite_repo.bind(
db, invitee=user, invite_code=inviter.invite_code, channel="fingerprint",
)
logger.info(
"invite bind invitee=%d fingerprint -> inviter=%d code=%s -> %s",
user.id, inviter.id, inviter.invite_code, result.status,
)
return BindInviteOut(
status=result.status,
coins_awarded=result.invitee_coin,
message=_BIND_MESSAGES.get(result.status, result.status),
)
# 路径 3:啥都没传 → 无效请求
return BindInviteOut(
status="invalid_code",
coins_awarded=0,
message=_BIND_MESSAGES["invalid_code"],
)
+75 -4
View File
@@ -7,10 +7,14 @@ from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import get_db
from app.integrations.meituan import MeituanCpsError, get_referral_link, query_coupon
from app.models.meituan_coupon import MeituanCoupon
from app.schemas.meituan import (
CouponCard,
CouponListRequest,
@@ -19,6 +23,7 @@ from app.schemas.meituan import (
FeedResponse,
ReferralLinkRequest,
ReferralLinkResponse,
TopSalesRequest,
)
logger = logging.getLogger("shagua.meituan")
@@ -79,13 +84,21 @@ def _interleave(waimai: list[dict], daodian: list[dict]) -> list[CouponCard]:
return items
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉, 无限流)")
def _commission_pct(card: CouponCard) -> float:
"""'1.4%' → 1.4;解析失败按 0(会被智能推荐过滤掉)。"""
try:
return float(card.commission_rate.rstrip("%"))
except (ValueError, AttributeError):
return 0.0
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉);tab=rec智能推荐/distance距离最近")
def feed(req: FeedRequest) -> FeedResponse:
if not settings.mt_cps_configured:
return FeedResponse(items=[], has_next=False, page=req.page)
page_idx = req.page - 1
lon, lat = req.longitude, req.latitude
logger.info("[feed] page=%s lon=%.6f lat=%.6f", req.page, lon, lat)
tab = (req.tab or "").strip()
logger.info("[feed] tab=%s page=%s lon=%.6f lat=%.6f", tab or "(default)", req.page, lon, lat)
def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> list[dict]:
try:
@@ -97,6 +110,28 @@ def feed(req: FeedRequest) -> FeedResponse:
except MeituanCpsError:
return []
# 距离最近:拉齐全部榜单轮次,合并去重,后端在【完整池子】上全局按距离由近及远排,一次性返回。
# (距离排序必须在完整池上做、不能逐页排——这正是之前放前端不合理的根因。)
if tab == "distance":
with ThreadPoolExecutor(max_workers=len(_TOPIC_ROUNDS) * 2) as pool:
futs = []
for wm_topic, dd_topic in _TOPIC_ROUNDS:
futs.append(pool.submit(_fetch_topic, 1, None, wm_topic))
futs.append(pool.submit(_fetch_topic, 2, 1, dd_topic))
raws = [f.result() for f in futs]
seen: set[str] = set()
cards: list[CouponCard] = []
for raw_list in raws:
for it in raw_list:
card = CouponCard.from_raw(it)
if card.product_view_sign and card.product_view_sign not in seen:
seen.add(card.product_view_sign)
cards.append(card)
cards.sort(key=lambda c: c.distance_meters if c.distance_meters is not None else float("inf"))
return FeedResponse(items=cards, has_next=False, page=1)
# 智能推荐(rec,默认):沿用逐轮分页的混合 feed,后端过滤掉佣金率 < 3%。
page_idx = req.page - 1
if page_idx >= len(_TOPIC_ROUNDS):
return FeedResponse(items=[], has_next=False, page=req.page)
@@ -107,6 +142,8 @@ def feed(req: FeedRequest) -> FeedResponse:
waimai, daodian = f_wm.result(), f_dd.result()
items = _interleave(waimai, daodian)
if tab == "rec":
items = [c for c in items if _commission_pct(c) >= 3.0]
has_next = page_idx + 1 < len(_TOPIC_ROUNDS)
return FeedResponse(items=items, has_next=has_next, page=req.page)
@@ -130,3 +167,37 @@ def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
link_map = raw.get("referralLinkMap") or {}
link = raw.get("data") or link_map.get("1") or link_map.get("3") or next(iter(link_map.values()), "")
return ReferralLinkResponse(link=link, link_map=link_map)
@router.post("/top-sales", response_model=CouponListResponse,
summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,不实时打美团)")
def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponListResponse:
# 只取有销量档的(美团很多券没销量,排不了);按销量降序、同销量再按佣金降序
stmt = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None))
if req.platform is not None:
stmt = stmt.where(MeituanCoupon.platform == req.platform)
stmt = stmt.order_by(
MeituanCoupon.sale_volume_num.desc(),
MeituanCoupon.commission_percent.desc(),
)
rows = db.execute(stmt).scalars().all()
# 跨源去重(dedup_key = 品牌|名|价):按销量降序遍历,每个 dedup_key 只留第一条(=销量最高那条)。
# 用 raw(整条原始返回)重建 CouponCard,字段与实时接口完全一致,前端无需改渲染。
seen: set[str] = set()
cards: list[CouponCard] = []
for row in rows:
if row.dedup_key in seen:
continue
seen.add(row.dedup_key)
try:
card = CouponCard.from_raw(row.raw or {})
except Exception: # noqa: BLE001
continue
if card.product_view_sign:
cards.append(card)
start = (req.page - 1) * req.page_size
page_items = cards[start:start + req.page_size]
has_next = start + req.page_size < len(cards)
return CouponListResponse(items=page_items, has_next=has_next, search_id=None)
+37
View File
@@ -0,0 +1,37 @@
"""首页平台级展示数据 endpoint(全平台门面数字,**不鉴权**——登录前首页也要展示)。
路由前缀 `/api/v1/platform`:
GET /stats 首页三统计(帮助用户 / 完成比价 / 累计节省),按运营后台配的模式算。
展示模式(real/manual/random,每指标独立)与计算逻辑见 app/repositories/ops_stat.py。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Query
from app.api.deps import DbSession
from app.repositories import ops_marquee as marquee_crud
from app.repositories import ops_stat as crud
from app.schemas.platform import PlatformStatsOut, SavingsFeedItem, SavingsFeedOut
logger = logging.getLogger("shagua.platform")
router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
@router.get("/stats", response_model=PlatformStatsOut, summary="首页三统计(全平台门面数字)")
def stats(db: DbSession) -> PlatformStatsOut:
v = crud.get_display_values(db)
return PlatformStatsOut(
help_users=v["help_users"],
total_compares=v["total_compares"],
total_saved_cents=v["total_saved"],
)
@router.get("/savings-feed", response_model=SavingsFeedOut, summary="首页轮播 feed(真实+种子混播)")
def savings_feed(db: DbSession, limit: int = Query(8, ge=1, le=30)) -> SavingsFeedOut:
items = marquee_crud.get_feed(db, limit=limit)
return SavingsFeedOut(items=[SavingsFeedItem(**it) for it in items])
+41 -2
View File
@@ -1,8 +1,9 @@
"""签到 endpoint。
路由前缀 `/api/v1/signin`:
GET /status 今日签到状态 + 7 天档位
GET /status 今日签到状态 + 14 天档位
POST / 执行今日签到
POST /boost 签到后看广告膨胀金币
"""
from __future__ import annotations
@@ -11,8 +12,15 @@ import logging
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.repositories import ad_reward as crud_ad
from app.repositories import signin as crud_signin
from app.schemas.welfare import SigninResultOut, SigninStatusOut
from app.repositories import wallet as crud_wallet
from app.schemas.welfare import (
SigninBoostRequest,
SigninBoostResultOut,
SigninResultOut,
SigninStatusOut,
)
logger = logging.getLogger("shagua.signin")
@@ -42,3 +50,34 @@ def do_signin(user: CurrentUser, db: DbSession) -> SigninResultOut:
streak=record.streak,
coin_balance=balance,
)
@router.post("/boost", response_model=SigninBoostResultOut, summary="签到后看广告膨胀金币")
def boost_signin(
payload: SigninBoostRequest, user: CurrentUser, db: DbSession
) -> SigninBoostResultOut:
if not payload.ad_ref_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward required")
ad_rec = crud_ad.find_by_trans(db, payload.ad_ref_id)
if (
ad_rec is None
or ad_rec.user_id != user.id
or ad_rec.reward_scene != "signin_boost"
or ad_rec.status != "granted"
):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward not verified")
record = crud_signin.boost_by_ad_ref(db, user.id, payload.ad_ref_id)
if record is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="signin boost not granted")
acc = crud_wallet.get_or_create_account(db, user.id)
balance = acc.coin_balance
logger.info(
"signin boost ok user_id=%d date=%s coin=%d",
user.id, record.signin_date, record.coin_awarded,
)
return SigninBoostResultOut(
coin_awarded=record.coin_awarded,
coin_balance=balance,
signin_date=record.signin_date.isoformat(),
)
+9 -4
View File
@@ -176,7 +176,7 @@ def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
"/withdraw",
response_model=WithdrawResultOut,
summary="发起提现(扣款建单,待人工审核;审核通过后才打款)",
dependencies=[Depends(rate_limit(20, 60, "withdraw"))], # #6 同 IP 每分钟≤20 次
dependencies=[Depends(rate_limit(5, 60, "withdraw"))], # IP 级粗限流;用户级未完成单限制在仓库层
)
def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> WithdrawResultOut:
# 提现发起本身不调微信(打款在审核通过后),但仍要求微信支付已配置——否则审核通过也打不了款,提前拦
@@ -189,12 +189,17 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
except crud_wallet.InvalidWithdrawAmountError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"amount_cents must be within [{WITHDRAW_MIN_CENTS}, {WITHDRAW_MAX_CENTS}]",
detail="提现金额不符合要求",
) from e
except crud_wallet.WechatNotBoundError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="wechat not bound") from e
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先绑定微信") from e
except crud_wallet.WithdrawTooFrequentError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="已有提现申请正在审核或打款中,请处理完成后再申请",
) from e
except crud_wallet.InsufficientCashError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="insufficient cash balance") from e
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="现金余额不足") from e
# 调试直发(非生产):skip_review=true 时跳过人工审核,立即发起微信转账(等价 admin approve)。
# 双闸保护——客户端仅 debug 包下发此 flag,服务端仅非 prod 才认;任一道闸拦住即恢复正常审核,
+31 -1
View File
@@ -81,6 +81,9 @@ class Settings(BaseSettings):
MT_CPS_HOST: str = "https://media.meituan.com"
MT_CPS_TIMEOUT_SEC: int = 15
MT_CPS_DEFAULT_SID: str = "sgbjia"
# 美团调用走的代理。本机开发直连美团会 SSL EOF,需填 http://127.0.0.1:7897;
# 线上国内服务器留空(=直连)。见 .env.example 与 integrations/meituan.py。
MT_CPS_PROXY: str = ""
@property
def mt_cps_configured(self) -> bool:
@@ -99,6 +102,9 @@ class Settings(BaseSettings):
WXPAY_PUBLIC_KEY_PATH: str = "./secrets/pub_key.pem" # 微信支付平台公钥
WXPAY_TRANSFER_SCENE_ID: str = "1000" # 转账场景 ID(1000=现金营销)
WXPAY_REQUEST_TIMEOUT_SEC: int = 10
WITHDRAW_AUTO_RECONCILE_ENABLED: bool = False
WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC: int = 300
WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES: int = 15
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
@@ -135,15 +141,34 @@ class Settings(BaseSettings):
"""回调开关打开且验签密钥已配,才接受发奖回调。"""
return bool(self.PANGLE_CALLBACK_ENABLED and self.PANGLE_REWARD_SECRET)
# ===== Pricebot 上游 (领券业务透传目标) =====
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
# pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step
PRICEBOT_BASE_URL: str = "http://localhost:8000"
# 多实例(单机多进程)时填逗号分隔的实例列表,例如:
# PRICEBOT_INSTANCES=http://127.0.0.1:8001,http://127.0.0.1:8002,http://127.0.0.1:8003
# 透传层按 trace_id 一致性 hash 选实例(见 app/core/pricebot_router.py),保证同一次
# 比价/领券的所有帧落同一 pricebot 进程,从而用进程内内存维护 session/coordinator,
# 无需 Redis。留空 → 退回单实例 [PRICEBOT_BASE_URL],零改动兼容。
PRICEBOT_INSTANCES: str = ""
# 领券一帧最多 wait 6s,加网络往返,timeout 给 30s 比较稳
PRICEBOT_REQUEST_TIMEOUT_SEC: int = 30
# 比价(intent/recognize + price/step)透传超时:意图识别是大上下文 LLM、
# price/step 每帧也是 LLM,可能 >30s;对齐客户端 agent ApiClient 的 60s 读超时。
PRICEBOT_COMPARE_TIMEOUT_SEC: int = 60
@property
def pricebot_instances(self) -> list[str]:
"""pricebot 上游实例列表。空 → 单实例兜底 [PRICEBOT_BASE_URL]。"""
if not self.PRICEBOT_INSTANCES.strip():
return [self.PRICEBOT_BASE_URL]
return [u.strip() for u in self.PRICEBOT_INSTANCES.split(",") if u.strip()]
# ===== 内部(server→server)端点密钥 =====
# pricebot 比价 done 后把价格观测 POST 到 /internal/price-observation 落库,
# 靠这个共享密钥头(X-Internal-Secret)校验,与 pricebot 侧 INTERNAL_API_SECRET 同值。
# 默认空 = 内部写端点关闭(返 503),启用前两边都要配上同一高熵串。
INTERNAL_API_SECRET: str = ""
# ===== 媒体文件(用户头像上传)=====
# 落盘根目录(data/ 已 gitignore,上传不进库);对外经 StaticFiles 挂在 MEDIA_URL_PREFIX。
# 生产可改由 nginx 直接 serve MEDIA_ROOT,绕过应用进程。
@@ -151,6 +176,11 @@ class Settings(BaseSettings):
MEDIA_URL_PREFIX: str = "/media"
AVATAR_MAX_BYTES: int = 5 * 1024 * 1024 # 头像最大 5MB
# ===== 邀请好友 =====
# 分享落地页(二维码 / 分享链接指向这里;扫码 → 落地页 → 引导浏览器下载 APK)。
# MVP 落地页就放 app-server 的 /media 静态目录下(dl.html)。
INVITE_LANDING_URL: str = "https://app-api.shaguabijia.com/media/dl.html"
# ===== CORS =====
CORS_ALLOW_ORIGINS: str = ""
+13 -8
View File
@@ -14,9 +14,9 @@ from app.core import rewards as r
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int
CONFIG_DEFS: dict[str, dict[str, Any]] = {
"signin_rewards": {
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
"default": list(r.SIGNIN_REWARDS), "label": "签到 14 天金币档位",
"group": "签到", "type": "int_list",
"help": "第 1~7 天每天签到发的金币;断签重置回第 1 天。长度需为 7",
"help": "第 1~14 天每天签到发的金币;断签重置回第 1 天。长度需为 14",
},
"min_exchange_coin": {
"default": r.MIN_EXCHANGE_COIN, "label": "最低兑换金币",
@@ -42,22 +42,27 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"ad_reward_coin": {
"default": r.AD_REWARD_COIN, "label": "看广告单次金币",
"group": "看广告", "type": "int",
"help": "看完一个激励视频发的金币;须与穿山甲后台代码位'奖励数量'保持一致",
"help": "历史兼容/测试展示值;正式发放按 eCPM 公式计算",
},
"ad_daily_limit": {
"default": r.DAILY_AD_REWARD_LIMIT, "label": "看广告每日上限(次)",
"group": "看广告", "type": "int",
"group": "看广告", "type": "int", "help": "福利页激励视频每日可发奖次数上限,默认 500。",
},
"ad_max_coin": {
"default": r.MAX_AD_REWARD_COIN, "label": "看广告单次金币上限",
"group": "看广告", "type": "int", "help": "夹紧穿山甲回调里异常的 reward_amount,防刷爆",
"group": "看广告", "type": "int", "help": "历史 reward_amount 口径保留项;正式发放按 eCPM 公式计算",
},
"ad_round_count": {
"default": r.VIDEO_ROUND_REQUIRED_COUNT, "label": "每轮看广告次数",
"group": "看广告", "type": "int", "help": "看完一轮触发冷却。",
"group": "看广告", "type": "int", "help": "当前为 1,表示每次广告关闭后触发冷却。",
},
"ad_cooldown_sec": {
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "每轮冷却(秒)",
"group": "看广告", "type": "int",
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)",
"group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。",
},
"signin_boost_coin": {
"default": r.SIGNIN_BOOST_COIN, "label": "签到膨胀固定金币",
"group": "签到", "type": "int",
"help": "Day1-Day13 签到后看完激励视频额外发放的固定金币;Day14 不展示也不允许膨胀。",
},
}
+76 -5
View File
@@ -1,20 +1,91 @@
"""统一日志配置。
业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可,本模块在 main.py 启动时调一次。
业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可, 本模块在 main.py 启动时调一次。
- 控制台(stdout): 人类可读文本, 给 systemd / 本地看。
- 文件 `logs/app-server.log`: 单行 JSON, 供阿里云 SLS/Logtail 采集(JSON 模式零正则);
异常栈作为字段内嵌不换行 → 每条日志一行。
- 环境变量:
- LOG_JSON_CONSOLE=1 控制台也输出 JSON
- LOG_DIR / LOG_FILE 改落盘路径(默认 logs/app-server.log)
⚠️ admin 子进程(app.admin.main, 独立进程)应设不同 LOG_FILE, 避免与主进程争抢同一轮转文件
- LOG_SERVICE_NAME JSON 里的 service 字段(默认 app-server)
"""
from __future__ import annotations
import json
import logging
import os
import sys
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
class JsonFormatter(logging.Formatter):
"""把 LogRecord 序列化成单行 JSON(SLS/Logtail 友好)。异常栈内嵌为字段, 整条仍是一行。"""
def __init__(self, service: str = "app-server"):
super().__init__()
self.service = service
def format(self, record: logging.LogRecord) -> str:
dt = datetime.fromtimestamp(record.created)
data = {
"time": dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(record.msecs):03d}",
"level": record.levelname,
"service": self.service,
"logger": record.name,
"func": record.funcName,
"line": record.lineno,
"message": record.getMessage(),
}
if record.exc_info:
data["exception"] = self.formatException(record.exc_info)
if record.stack_info:
data["stack"] = self.formatStack(record.stack_info)
return json.dumps(data, ensure_ascii=False, default=str)
_CONFIGURED = False
def setup_logging(debug: bool = False) -> None:
global _CONFIGURED
if _CONFIGURED:
return
level = logging.DEBUG if debug else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stdout,
service = os.getenv("LOG_SERVICE_NAME", "app-server")
root = logging.getLogger()
root.setLevel(level)
# 清掉 basicConfig/uvicorn 可能预置的 root handler, 避免重复输出
for h in list(root.handlers):
root.removeHandler(h)
# 控制台: 默认文本(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")
)
root.addHandler(console)
# 文件: 单行 JSON, 供 Logtail 采集(自动轮转, 单文件 10MB, 保留 5 个)
log_file = os.getenv("LOG_FILE") or str(
Path(os.getenv("LOG_DIR", "logs")) / "app-server.log"
)
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
file_handler = RotatingFileHandler(
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8",
)
file_handler.setFormatter(JsonFormatter(service))
root.addHandler(file_handler)
# 第三方库降噪
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
_CONFIGURED = True
+82
View File
@@ -0,0 +1,82 @@
"""pricebot 上游实例选择:按 trace_id 一致性 hash(ketama 风格)选实例。
单机多进程下,保证同一 trace_id(一次比价/领券的所有帧)落同一 pricebot 进程,
从而用进程内内存维护 session/coordinator,不需要 Redis。
为什么用一致性 hash 而非简单取模(crc32 % N):
加/减实例时,取模会让几乎所有 trace 重新映射(扩缩容全量中断进行中的比价);
一致性 hash(虚拟节点)只重映射约 1/N 的 trace,扩缩容对存量冲击最小。
⚠️ 但内存态下,被重映射的那 1/N trace 仍会丢失 session(状态没外置),
所以扩缩容仍建议挑低峰。
hash 用 md5(纯函数),app-server 多 worker / 多实例算出的环一致,亲和不会因
app-server 自身水平扩展而被破坏。
"""
from __future__ import annotations
import bisect
import hashlib
from typing import Dict, List, Optional
from app.core.config import settings
# 每个真实实例在 hash 环上的虚拟节点数。越多分布越均匀。实例数少时(6~10)需要更多
# 虚拟节点才均匀:实测 150→负载偏差~16%、1000→~6%。环只在启动/实例变更时构建一次,
# 1000×N 个点的构建与 bisect 查找开销都可忽略。
_VNODES_PER_NODE = 1000
def _hash(s: str) -> int:
"""取 md5 前 8 hex(32-bit)做环坐标。确定性、跨进程一致。"""
return int(hashlib.md5(s.encode("utf-8")).hexdigest()[:8], 16)
class _HashRing:
"""ketama 风格一致性 hash 环。不可变,实例列表变化时整体重建(见 _get_ring)。"""
def __init__(self, nodes: List[str]):
self._keys: List[int] = [] # 升序的环坐标
self._key_to_node: Dict[int, str] = {}
for node in nodes:
for v in range(_VNODES_PER_NODE):
h = _hash(f"{node}#{v}")
# 极小概率撞坐标,撞了跳过该虚拟节点(不影响正确性,仅少一个 vnode)
if h not in self._key_to_node:
self._key_to_node[h] = node
self._keys.append(h)
self._keys.sort()
def pick(self, key: str) -> str:
h = _hash(key)
idx = bisect.bisect(self._keys, h)
if idx == len(self._keys):
idx = 0 # 环回绕
return self._key_to_node[self._keys[idx]]
# 按实例列表缓存环,列表变了(改 PRICEBOT_INSTANCES + 重启)才重建。
_ring: Optional[_HashRing] = None
_ring_nodes: tuple = ()
def _get_ring(nodes: List[str]) -> _HashRing:
global _ring, _ring_nodes
key = tuple(nodes)
if _ring is None or key != _ring_nodes:
_ring = _HashRing(nodes)
_ring_nodes = key
return _ring
def pick_pricebot(trace_id: Optional[str]) -> str:
"""按 trace_id 选 pricebot 实例 base url。
- 实例列表来自 settings.pricebot_instances(空则单实例兜底 [PRICEBOT_BASE_URL])
- trace_id 缺失/空 或 单实例 → 直接返回第一个,不进环
"""
nodes = settings.pricebot_instances
if len(nodes) == 1 or not trace_id:
return nodes[0]
# str() 防御:协议保证 trace_id 是 str,但万一传入非 str(如 int)也不至于在
# _hash 的 .encode() 处炸,确定性地选到实例。
return _get_ring(nodes).pick(str(trace_id))
+120 -29
View File
@@ -17,9 +17,12 @@ def cn_today() -> date:
return datetime.now(CN_TZ).date()
# ===== 签到:7 天循环,断签重置回第 1 天 =====
# 第 1→7 天的金币奖励,签到逻辑用 cycle_day(1..7)索引。
SIGNIN_REWARDS: tuple[int, ...] = (10, 20, 30, 50, 80, 120, 200)
# ===== 签到:14 天循环,断签重置回第 1 天 =====
# 第 1→14 天的金币奖励,签到逻辑用 cycle_day(1..14)索引。
SIGNIN_REWARDS: tuple[int, ...] = (
200, 120, 150, 180, 200, 250, 500,
200, 250, 300, 350, 400, 500, 3000,
)
SIGNIN_CYCLE_LEN: int = len(SIGNIN_REWARDS)
@@ -74,38 +77,122 @@ def record_milestone_reward(milestone: int) -> int:
return RECORD_MILESTONES[milestone - 1]
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
# 看完一个激励视频发的金币(666 金币 ≈¥0.0666,汇率 10000 金币=1 元)
# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币";
# ③ test-grant 本地联调的发奖额。
# 真实发放以穿山甲回调带回的 reward_amount 为准(见 resolve_ad_reward_coin),后台应把
# 代码位"奖励数量"配成与本值一致(=666),保证"广告内展示 / 进度预告 / 实际到账"三者一致。
AD_REWARD_COIN: int = 666
# 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。
MAX_AD_REWARD_COIN: int = 1000
# 每用户每日发奖次数上限——现作为"看广告时长上限"的**次数兜底**(防前端少报观看时长绕过时长闸)。
# 主闸是 DAILY_AD_WATCH_SECONDS_LIMIT(50 分钟);次数兜底要 ≥ 时长闸内的"合理最大次数",
# 否则会比主闸先掐、误伤看大量短广告的正常用户:50 分钟里若广告各 ~15s,理论可看 ~200 次,
# 故取 200(≈时长闸的次数等价),正常用户先撞 50 分钟时长闸,仅前端时长全报 0 等异常时本闸才兜住。
# 不要为"更严"而下调到 120 之类(会在 ~30 分钟就掐短广告用户);要更严应在客户端如实上报时长。
DAILY_AD_REWARD_LIMIT: int = 200
# ===== 上报更低价(人工审核通过发固定金币)=====
# 用户上报"某平台比我们算的最低价更便宜"+ 截图,经运营后台人工审核通过后发放的固定金币奖励
# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。
PRICE_REPORT_REWARD_COINS: int = 1000
# ===== 看激励视频每日总时长上限(防刷主闸)=====
# 用户每天最多看 50 分钟激励视频,超了不发奖 / 不给看。时长由前端 onAdClose 上报真实观看
# 秒数累计(POST /api/v1/ad/watch-report → ad_watch_log 表 SUM)。前端不可信,故配合上面
# DAILY_AD_REWARD_LIMIT 次数兜底一起防刷。要调上限直接改这里。
DAILY_AD_WATCH_SECONDS_LIMIT: int = 50 * 60 # 3000 秒 = 50 分钟
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
INVITE_INVITER_COINS: int = 10000
INVITE_INVITEE_COINS: int = 10000
# "新用户闸":被邀请人必须在注册后此窗口内绑定才发奖(挡存量老用户互相填码薅羊毛)。
# 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。
INVITE_NEW_USER_WINDOW_HOURS: int = 72
# 指纹归因兜底(剪贴板被覆盖时):落地页 POST /landing-track 存的指纹记录,在此窗口内可被
# /bind 反查撞库。窗口取舍:太短(24h)覆盖不到"晚上看链接、第二天装"的常见场景;太宽
# (30d)IP/设备会漂、匹配错率上升。7 天兼顾"看广告→使用"周期与匹配精度。
INVITE_FP_WINDOW_DAYS: int = 7
# ===== 看激励视频 / 信息流广告发金币 =====
# eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元!
# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元;
# 因子1 档位阈值按【元/千次】定(100/200/400 元 = ¥100/¥200/¥400 CPM,产品口径 2026-06-09)。
# 注:真实 eCPM 一般 <¥100 CPM,故多落最低档 0.1,高档基本不触发——这是产品有意的取舍。
# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次)。
AD_ECPM_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
(0.1, 0, 100),
(0.3, 101, 200),
(0.4, 201, 400),
(0.6, 401, None),
)
AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
(2.0, 1, 1),
(1.5, 2, 2),
(1.3, 3, 3),
(1.1, 4, 10),
(1.0, 11, None),
)
def parse_ecpm_fen(ecpm: str | int | float | None) -> float:
"""解析 eCPM 原始值(穿山甲 getEcpm 原值,单位=分/千次展示)。非法/缺失→0。"""
if ecpm is None:
return 0.0
try:
value = float(str(ecpm).strip())
except (TypeError, ValueError):
return 0.0
return max(0.0, value)
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
"""eCPM 转成元(getEcpm 原值是分,÷100)。因子档位判定与收益换算都用元。"""
return parse_ecpm_fen(ecpm) / 100.0
def ad_ecpm_factor(ecpm_yuan: float) -> float:
"""eCPM 档位因子(阈值单位=元/千次):≤100=0.1,101-200=0.3,201-400=0.4,>400=0.6。
产品口径(2026-06-09):阈值按元判档;真实 eCPM(<¥100 CPM)多落最低档 0.1。"""
if ecpm_yuan > 400:
return 0.6
if ecpm_yuan > 200:
return 0.4
if ecpm_yuan > 100:
return 0.3
return 0.1
def ad_lt_factor(today_count_after_this: int) -> float:
"""LT 因子。today_count_after_this 是当天累计第 N 条/份广告奖励。"""
count = max(1, today_count_after_this)
for factor, lo, hi in AD_LT_FACTOR_TABLE:
if count >= lo and (hi is None or count <= hi):
return factor
return 1.0
def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_this: int) -> int:
"""按金币数值体系计算单份广告奖励金币。
eCPM 是穿山甲 getEcpm 原值,单位【分/千次展示】;先 ÷100 转成元(因子判档 + 收益换算都用元)。
单次收益(元)= eCPM元 ÷ 1000(每千次→单次) × 因子1(eCPM 元档) × 因子2(LT);
再按 1 元=10000 金币取整。
"""
ecpm_yuan = parse_ecpm_yuan(ecpm)
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this)
return max(0, round(yuan * COIN_PER_YUAN))
SIGNIN_BOOST_COIN: int = 2000
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
# 历史固定金币口径保留项。正式激励视频实发按 calculate_ad_reward_coin(eCPM, 当日第 N 次)
# 计算;该值只用于旧接口兼容、配置页展示和本地联调兜底。
AD_REWARD_COIN: int = 666
# 历史 reward_amount 口径保留项。正式激励视频实发按 eCPM 公式计算。
MAX_AD_REWARD_COIN: int = 1000
# 每用户每日发奖次数上限。产品口径:一天最多看 500 次广告。
DAILY_AD_REWARD_LIMIT: int = 500
# ===== 看激励视频每日总时长上限(已停用)=====
# 旧版本用 50 分钟时长闸;当前产品只保留每日 500 次上限。保留字段返回给旧客户端,
# 但值为 0,客户端按 limit<=0 视为未启用。
DAILY_AD_WATCH_SECONDS_LIMIT: int = 0
# 单次上报观看时长的合理上限(夹紧异常值):激励视频一般 15~60 秒,超 120 秒按 120 记,防刷大值。
MAX_SINGLE_WATCH_SECONDS: int = 120
# 一轮看 N 次激励视频,看完一轮强制 10 分钟冷却(reward-status 派生 cooldown_until 给客户端,
# 客户端任务行 CTA 在冷却期间显示"MM:SS"且不可点)。冷却是 UX 约束,后端发奖只看 daily_limit,
# 冷却期间穿山甲回调仍照常发奖,不另设拒发分支。
VIDEO_ROUND_REQUIRED_COUNT: int = 3
VIDEO_ROUND_COOLDOWN_SECONDS: int = 600
# 每次广告关闭后 3 秒内不允许再次点击观看。沿用 round_count/cooldown_until 字段表达:
# round_size=1 表示每次成功发奖后都会派生一个 3 秒 cooldown_until。
VIDEO_ROUND_REQUIRED_COUNT: int = 1
VIDEO_ROUND_COOLDOWN_SECONDS: int = 3
def resolve_ad_reward_coin(db, reward_amount: str | int | None) -> int: # noqa: ANN001
"""把穿山甲回调的 reward_amount(后台代码位配的"奖励数量")解析成本次发放金币
"""历史 reward_amount 口径解析函数,保留给旧脚本/旧配置兼容
缺失 / 非数字 / ≤0 → 回退配置单次金币;超过配置单次上限 → 夹紧(均从 app_config 读)。
注:reward_amount 不参与穿山甲 sign(只签 trans_id),但伪造回调需先伪造合法 sign
@@ -172,3 +259,7 @@ def get_ad_round_count(db) -> int: # noqa: ANN001
def get_ad_cooldown_sec(db) -> int: # noqa: ANN001
return int(_cfg(db, "ad_cooldown_sec"))
def get_signin_boost_coin(db) -> int: # noqa: ANN001
return int(_cfg(db, "signin_boost_coin"))
+118
View File
@@ -0,0 +1,118 @@
"""提现 pending 单自动对账后台任务。"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from collections.abc import Iterator
from pathlib import Path
from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings
from app.db.session import SessionLocal
from app.integrations.wxpay import WxPayNotConfiguredError
from app.repositories import wallet as wallet_repo
logger = logging.getLogger("shagua.withdraw_reconcile")
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "withdraw_reconcile.lock"
def _touch_lock() -> None:
with contextlib.suppress(FileNotFoundError):
os.utime(_LOCK_PATH, None)
@contextlib.contextmanager
def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
"""同机多进程保护:同一时间只允许一个自动查单 worker 运行。"""
_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
fd: int | None = None
try:
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
try:
age = time.time() - _LOCK_PATH.stat().st_mtime
except FileNotFoundError:
age = stale_after_sec + 1
if age > stale_after_sec:
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
fd = None
if fd is None:
yield False
return
os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii"))
yield True
finally:
if fd is not None:
os.close(fd)
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
def _reconcile_once(older_than_minutes: int) -> dict:
with SessionLocal() as db:
return wallet_repo.reconcile_pending_withdraws(db, older_than_minutes=older_than_minutes)
async def _run_loop() -> None:
interval = max(30, int(settings.WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC))
older_than = max(1, int(settings.WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES))
lock_stale_after = max(interval * 3, 600)
with _single_instance_lock(lock_stale_after) as lock_acquired:
if not lock_acquired:
logger.warning("withdraw auto reconcile skipped: another worker owns lock")
return
await _run_locked_loop(interval, older_than)
async def _run_locked_loop(interval: int, older_than: int) -> None:
logger.info(
"withdraw auto reconcile started interval=%ss older_than=%sm",
interval,
older_than,
)
try:
while True:
try:
_touch_lock()
result = await asyncio.to_thread(_reconcile_once, older_than)
if result["checked"] or result["resolved"]:
logger.info("withdraw auto reconcile result=%s", result)
except WxPayNotConfiguredError:
logger.warning("withdraw auto reconcile skipped: wxpay not configured")
except SQLAlchemyError:
logger.exception("withdraw auto reconcile db error")
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
logger.exception("withdraw auto reconcile unexpected error")
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.info("withdraw auto reconcile stopped")
raise
def start_withdraw_reconcile_worker() -> asyncio.Task | None:
if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED:
logger.info("withdraw auto reconcile disabled")
return None
if not settings.wxpay_configured:
logger.warning("withdraw auto reconcile enabled but wxpay not configured")
return asyncio.create_task(_run_loop(), name="withdraw-auto-reconcile")
async def stop_withdraw_reconcile_worker(task: asyncio.Task | None) -> None:
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
+5 -1
View File
@@ -62,8 +62,12 @@ def _call(path: str, body_obj: dict[str, Any]) -> dict[str, Any]:
}
url = f"{settings.MT_CPS_HOST}{path}"
# 美团调用走 MT_CPS_PROXY(本机开发直连会 SSL EOF,必须走代理;线上留空=直连)。
# trust_env=False:不读进程环境的 HTTP_PROXY,只认配置,避免被错误/失效代理误导。
proxy = settings.MT_CPS_PROXY or None
try:
resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC)
with httpx.Client(proxy=proxy, trust_env=False, timeout=settings.MT_CPS_TIMEOUT_SEC) as client:
resp = client.post(url, content=body, headers=headers)
except httpx.HTTPError as e:
logger.exception("[MT] http error calling %s", url)
raise MeituanCpsError(f"meituan http error: {e}") from e
+2 -2
View File
@@ -8,9 +8,9 @@
我们客户端是 useMediation(true) 融合,回调走 GroMore 广告位层级,**不是**联盟代码位层级):
**sign = SHA256("{m-key}:{trans_id}")** 的十六进制串。注意:
- 是**普通 SHA256**,不是 HMAC,也不是 RSA;
- **只对 `m-key:trans_id` 这一个字符串**签名,user_id / reward_amount 等其余参数**不参与**签名
- **只对 `m-key:trans_id` 这一个字符串**签名,user_id / ecpm / extra 等其余参数**不参与**签名
(它们的可信度由"能算出正确 sign = 知道 m-key"间接保证——伪造者没有 m-key 就连合法 sign
都造不出,自然也无法注入假 user_id/reward_amount)。
都造不出,自然也无法注入假 user_id/ecpm/extra)。
m-key(安全密钥)在穿山甲后台「GroMore 聚合管理 → 搜广告位ID → 编辑」处获取,配到
`settings.PANGLE_REWARD_SECRET`。(联盟代码位层级用的是另一套 Security Key + isValid 响应,我们不走那条。)
"""
+48 -27
View File
@@ -14,7 +14,9 @@ import base64
import json
import time
import uuid
from pathlib import Path
import certifi
import httpx
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
@@ -23,6 +25,7 @@ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPubl
from app.core.config import settings
_API_HOST = "https://api.mch.weixin.qq.com"
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
_TRANSFER_PATH = "/v3/fund-app/mch-transfer/transfer-bills"
# 免确认收款授权(用户授权免确认模式)
_AUTH_PATH = "/v3/fund-app/mch-transfer/user-confirm-authorization"
@@ -38,15 +41,30 @@ class WxPayNotConfiguredError(Exception):
"""微信支付凭证 / 证书缺失,无法调用。"""
def _http_client() -> httpx.Client:
"""微信相关 HTTP 客户端。
显式使用当前 Python 环境的 certifi 证书,避免被外部 SSL_CERT_FILE 指到不存在文件。
代理仍允许从环境变量读取,方便本地开发。
"""
return httpx.Client(verify=certifi.where())
def _resolve_config_path(value: str) -> Path:
path = Path(value)
return path if path.is_absolute() else _PROJECT_ROOT / path
def _load_private_key() -> RSAPrivateKey:
global _private_key
if _private_key is None:
key_path = _resolve_config_path(settings.WXPAY_MCH_PRIVATE_KEY_PATH)
try:
with open(settings.WXPAY_MCH_PRIVATE_KEY_PATH, "rb") as f:
with key_path.open("rb") as f:
_private_key = serialization.load_pem_private_key(f.read(), password=None)
except FileNotFoundError as e:
raise WxPayNotConfiguredError(
f"商户私钥不存在: {settings.WXPAY_MCH_PRIVATE_KEY_PATH}"
f"商户私钥不存在: {key_path}"
) from e
return _private_key
@@ -54,12 +72,13 @@ def _load_private_key() -> RSAPrivateKey:
def _load_public_key() -> RSAPublicKey:
global _public_key
if _public_key is None:
key_path = _resolve_config_path(settings.WXPAY_PUBLIC_KEY_PATH)
try:
with open(settings.WXPAY_PUBLIC_KEY_PATH, "rb") as f:
with key_path.open("rb") as f:
_public_key = serialization.load_pem_public_key(f.read())
except FileNotFoundError as e:
raise WxPayNotConfiguredError(
f"微信支付平台公钥不存在: {settings.WXPAY_PUBLIC_KEY_PATH}"
f"微信支付平台公钥不存在: {key_path}"
) from e
return _public_key
@@ -126,7 +145,7 @@ def create_transfer(
"Content-Type": "application/json",
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
}
with httpx.Client() as client:
with _http_client() as client:
resp = client.post(
f"{_API_HOST}{_TRANSFER_PATH}",
content=body_str,
@@ -143,7 +162,7 @@ def query_transfer(out_bill_no: str) -> dict:
"Authorization": _build_authorization("GET", path, ""),
"Accept": "application/json",
}
with httpx.Client() as client:
with _http_client() as client:
resp = client.get(
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
)
@@ -159,7 +178,7 @@ def cancel_transfer(out_bill_no: str) -> dict:
"Accept": "application/json",
"Content-Type": "application/json",
}
with httpx.Client() as client:
with _http_client() as client:
resp = client.post(
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
)
@@ -171,16 +190,17 @@ def code_to_userinfo(code: str) -> dict:
返回 {openid, nickname, avatar_url, raw}。失败抛 ValueError。
注意:微信隐私新政下,部分 app 的 sns/userinfo 可能返回脱敏值(昵称"微信用户"/灰头像);
nickname/avatar_url 可能为空,调用方需兜底。"""
r1 = httpx.get(
"https://api.weixin.qq.com/sns/oauth2/access_token",
params={
"appid": settings.WECHAT_APP_ID,
"secret": settings.WECHAT_APP_SECRET,
"code": code,
"grant_type": "authorization_code",
},
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
with _http_client() as client:
r1 = client.get(
"https://api.weixin.qq.com/sns/oauth2/access_token",
params={
"appid": settings.WECHAT_APP_ID,
"secret": settings.WECHAT_APP_SECRET,
"code": code,
"grant_type": "authorization_code",
},
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
d1 = r1.json()
if "openid" not in d1 or "access_token" not in d1:
raise ValueError(f"微信授权失败: {d1.get('errmsg', d1)}")
@@ -191,11 +211,12 @@ def code_to_userinfo(code: str) -> dict:
raw: dict = {}
# userinfo 拉取失败不应让绑定失败(openid 已拿到),吞掉异常只是没昵称头像
try:
r2 = httpx.get(
"https://api.weixin.qq.com/sns/userinfo",
params={"access_token": d1["access_token"], "openid": openid, "lang": "zh_CN"},
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
with _http_client() as client:
r2 = client.get(
"https://api.weixin.qq.com/sns/userinfo",
params={"access_token": d1["access_token"], "openid": openid, "lang": "zh_CN"},
timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC,
)
raw = r2.json()
nickname = raw.get("nickname") or None
avatar_url = raw.get("headimgurl") or None
@@ -244,7 +265,7 @@ def apply_transfer_authorization(
"Accept": "application/json",
"Content-Type": "application/json",
}
with httpx.Client() as client:
with _http_client() as client:
resp = client.post(
f"{_API_HOST}{_AUTH_PATH}",
content=body_str,
@@ -262,7 +283,7 @@ def query_transfer_authorization(out_authorization_no: str) -> dict:
"Authorization": _build_authorization("GET", path, ""),
"Accept": "application/json",
}
with httpx.Client() as client:
with _http_client() as client:
resp = client.get(
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
)
@@ -277,7 +298,7 @@ def close_transfer_authorization(out_authorization_no: str) -> dict:
"Accept": "application/json",
"Content-Type": "application/json",
}
with httpx.Client() as client:
with _http_client() as client:
resp = client.post(
f"{_API_HOST}{path}", headers=headers, timeout=settings.WXPAY_REQUEST_TIMEOUT_SEC
)
@@ -319,7 +340,7 @@ def pre_transfer_with_authorization(
"Content-Type": "application/json",
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
}
with httpx.Client() as client:
with _http_client() as client:
resp = client.post(
f"{_API_HOST}{_PRE_TRANSFER_AUTH_PATH}",
content=body_str,
@@ -356,7 +377,7 @@ def transfer_with_authorization(
"Content-Type": "application/json",
"Wechatpay-Serial": settings.WXPAY_PUBLIC_KEY_ID,
}
with httpx.Client() as client:
with _http_client() as client:
resp = client.post(
f"{_API_HOST}{_TRANSFER_WITH_AUTH_PATH}",
content=body_str,
+17 -2
View File
@@ -20,9 +20,12 @@ 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.internal.price import router as internal_price_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
from app.api.v1.order import router as order_router
from app.api.v1.platform import router as platform_router
from app.api.v1.report import router as report_router
from app.api.v1.savings import router as savings_router
from app.api.v1.signin import router as signin_router
@@ -32,6 +35,10 @@ 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.logging import setup_logging
from app.core.withdraw_reconcile_worker import (
start_withdraw_reconcile_worker,
stop_withdraw_reconcile_worker,
)
setup_logging(debug=settings.APP_DEBUG)
logger = logging.getLogger("shagua.main")
@@ -47,8 +54,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.APP_DEBUG,
settings.DATABASE_URL.split("://", 1)[0],
)
yield
logger.info("shutting down")
reconcile_task = start_withdraw_reconcile_worker()
try:
yield
finally:
await stop_withdraw_reconcile_worker(reconcile_task)
logger.info("shutting down")
app = FastAPI(
@@ -77,6 +88,7 @@ def health() -> dict[str, str]:
app.include_router(auth_router)
app.include_router(user_router)
app.include_router(feedback_router)
app.include_router(invite_router)
app.include_router(coupon_router)
app.include_router(compare_router)
app.include_router(compare_record_router)
@@ -90,6 +102,9 @@ app.include_router(savings_router)
app.include_router(ad_router)
app.include_router(order_router)
app.include_router(report_router)
# 内部(server→server)端点:pricebot 上报价格观测,靠共享密钥头校验,不对客户端开放。
app.include_router(internal_price_router)
app.include_router(platform_router)
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
_media_root = Path(settings.MEDIA_ROOT)
+13 -1
View File
@@ -1,15 +1,27 @@
"""所有 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_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.app_config import AppConfig # noqa: F401
from app.models.comparison import ComparisonRecord # noqa: F401
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
from app.models.coupon_state import ( # noqa: F401
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
)
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
from app.models.ops_stat_config import OpsStatConfig # noqa: F401
from app.models.price_observation import PriceObservation # noqa: F401
from app.models.price_report import PriceReport # noqa: F401
from app.models.savings import SavingsRecord # noqa: F401
from app.models.signin import SigninRecord # noqa: F401
from app.models.signin import SigninBoostRecord, SigninRecord # noqa: F401
from app.models.task import UserTask # noqa: F401
from app.models.user import User # noqa: F401
from app.models.wallet import ( # noqa: F401
+10 -8
View File
@@ -1,11 +1,8 @@
"""广告展示 eCPM 上报记录(内部收益统计/对账)。
每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM 信息和发奖记录
[ad_reward.AdRewardRecord] **两条独立的数据流**:
- 发奖走穿山甲 S2S 回调(后端 trans_id ecpm);
- eCPM 走客户端上报(客户端 ecpm trans_id)
两者没有公共键,无法逐条一一对应,所以本表用于**按用户/按天聚合收益**口径的对账,
不做"这条发奖 = 这条 ecpm"的精确关联穿山甲后台报表才是结算权威,本表是细粒度补充
每条 = 客户端一次广告展示(`onAdShow`)后读到的 eCPM 信息`ad_session_id`
由客户端生成,并通过激励视频 extra 透传给 S2S 回调,用于把"展示 eCPM"
"奖励完成"绑定没有 session id 的旧上报仍可作为按天对账补充
`ecpm_raw` 原样存客户端上报的字符串eCPM 单位( / )截至 2026-05-31 尚未最终确认,
确认后再加一列解析好的数值;在此之前对账按"待定单位"处理
@@ -14,7 +11,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -22,6 +19,9 @@ from app.db.base import Base
class AdEcpmRecord(Base):
__tablename__ = "ad_ecpm_record"
__table_args__ = (
UniqueConstraint("ad_session_id", name="uq_ad_ecpm_record_session"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
@@ -29,11 +29,13 @@ class AdEcpmRecord(Base):
)
# 广告类型:reward_video(激励视频) / draw(Draw 信息流) 等;不强行统一代码位,各类型各自上报
ad_type: Mapped[str] = mapped_column(String(32), nullable=False)
# 客户端生成的一次广告会话 id;激励视频 S2S 回调 extra 会透传同值
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt)
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 实际展示用的代码位(底层 mediation rit,非客户端配置位)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 客户端上报的 eCPM 原始字符串(单位待确认,原样存)
# 客户端上报的 eCPM 原始字符串(单位:分/千次展示,SDK getEcpm 原值,原样存)
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
# 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较)
report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
+44
View File
@@ -0,0 +1,44 @@
"""信息流广告奖励记录。
点位 2:比价等待 / 领券信息流广告每展示满 10 秒累计一份奖励,视频完成后一次性入账
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AdFeedRewardRecord(Base):
__tablename__ = "ad_feed_reward_record"
__table_args__ = (
UniqueConstraint("client_event_id", name="uq_ad_feed_reward_client_event"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
client_event_id: Mapped[str] = mapped_column(String(64), nullable=False)
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
duration_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
unit_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<AdFeedRewardRecord user_id={self.user_id} event={self.client_event_id} "
f"{self.status} coin={self.coin}>"
)
+14 -5
View File
@@ -1,8 +1,8 @@
"""看激励视频发奖记录(穿山甲 S2S 回调)。
每条 = 穿山甲一次发奖回调`trans_id`(穿山甲交易号)唯一,做幂等键:穿山甲会重试回调,
同号只发一次金币`reward_date`(北京时间日期串)"每日上限"计数用按日期串等值查,
SQL 里做跨时区 date 比较(SQLite 上不可靠)审计 / 对账时整张表可逐笔回溯
每条 = 穿山甲/GroMore 一次服务端激励回调`trans_id`(交易号)唯一,做幂等键:
穿山甲会重试回调,同号只处理一次`reward_scene` 区分福利页激励视频签到膨胀等
同奖励场景,避免统计和每日上限互相污染
"""
from __future__ import annotations
@@ -25,8 +25,14 @@ class AdRewardRecord(Base):
)
# 实发金币(capped 时为 0)
coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# granted(已发) / capped(当日超限未发)
# granted(已发) / capped(当日超限未发) / ecpm_missing(缺 eCPM 未发)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted")
# reward_video(福利页看视频) / signin_boost(签到膨胀)
reward_scene: Mapped[str] = mapped_column(String(32), nullable=False, default="reward_video")
# 客户端生成并通过 extra 透传的广告会话 id
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 本次发奖采用的 eCPM 原始值(回调自带或按 ad_session_id 匹配的客户端上报)
ecpm_raw: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 北京时间日期串 'YYYY-MM-DD',按它等值统计当日发奖次数
reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
# 穿山甲上报的奖励名(参考,不作发奖依据)
@@ -39,4 +45,7 @@ class AdRewardRecord(Base):
)
def __repr__(self) -> str: # pragma: no cover
return f"<AdRewardRecord trans_id={self.trans_id} user_id={self.user_id} {self.status} coin={self.coin}>"
return (
f"<AdRewardRecord trans_id={self.trans_id} user_id={self.user_id} "
f"scene={self.reward_scene} {self.status} coin={self.coin}>"
)
+4 -5
View File
@@ -1,15 +1,14 @@
"""看激励视频观看时长记录(每日总时长防刷)。
"""看激励视频观看时长记录(旧版每日总时长字段)。
每条 = 客户端 onAdClose 上报的一次激励视频实际观看秒数 (user_id, watch_date) 聚合
SUM(watch_seconds) 得当日总观看时长,用于"每天最多看 50 分钟激励视频"硬上限(超了不再发奖 /
不给看, core.rewards.DAILY_AD_WATCH_SECONDS_LIMIT)
SUM(watch_seconds) 得当日总观看时长当前产品只保留每日 500 次上限,
core.rewards.DAILY_AD_WATCH_SECONDS_LIMIT=0 表示不启用时长闸;本表保留用于旧客户端兼容和排查
这是看广告的第三条数据流,与另两条并列互不关联:
- ad_reward(AdRewardRecord):穿山甲 S2S 回调发金币(后端, trans_id);
- ad_ecpm(AdEcpmRecord):客户端上报展示收益(前端, ecpm);
- ad_watch_log(本表):客户端上报观看时长(前端, watch_seconds)
前端上报的时长不可信 时长是防刷"主闸",配合 ad_reward 的每日次数上限(DAILY_AD_REWARD_LIMIT)
"次数兜底",前端少报时长也刷不过次数闸
前端上报的时长不可信,不能作为唯一发奖依据;当前发奖闸口以 ad_reward 的每日次数上限为准
"""
from __future__ import annotations
+4
View File
@@ -53,6 +53,10 @@ class ComparisonRecord(Base):
)
# pricebot 侧 trace_id:关联调试落盘 + 幂等去重键
trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
# 本次比价的公网调试链接(price.shaguabijia.com/traces/{dir}/)。pricebot done 帧给、
# 客户端上报带上——dir 名含 pricebot 落盘的时分秒,前端/server 都拼不出,必须存。
# 查看接口按 user.debug_trace_enabled 决定返不返回。旧记录 / 未开上云为 None。
trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
# ===== 源平台(发起比价的那家)=====
source_platform_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
+173
View File
@@ -0,0 +1,173 @@
"""领券今日状态(弹窗频控 + 领券记录)两张表。
- `coupon_prompt_engagement`: (device, 自然日) "今天是否对领券引导窗表达过意向"
一键领取(claim_started) 点拒绝/关闭 (dismissed) 都算切到外卖 App
据此决定弹不弹:今天 engage 过就不再弹判断维度是 **device_id**券发到的是设备上
登录的那个外卖账号,device user 更贴近"哪个登录环境", device_id 全链路现成
不依赖领券鉴权
- `coupon_claim_record`: (device, , 自然日) 记每张券的领取结果,纯沉淀(资产/画像/
排查)当前**不参与**"要不要领"的判断(MVP 先不去重:今天 engage 过就不弹,A 路径
主动领则全跑)留作以后做按券去重 / CPS 归因 / 用户画像的数据源
口径:
- 日期 = Asia/Shanghai 的自然日(claim_date / engage_date)
- user_id 领券登录态有就记(资产),可空不进唯一键不阻塞判断
- device_id 客户端生成存 SP,卸载重装会变 重装当新设备重新弹一次(产品预期)
"""
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import (
JSON,
Date,
DateTime,
Index,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
# PG 上用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 price_observation / comparison)。
_JSON = JSON().with_variant(JSONB(), "postgresql")
class CouponClaimRecord(Base):
"""单张券一天一条领取记录(资产层,当前不做去重判断)。"""
__tablename__ = "coupon_claim_record"
__table_args__ = (
# 同设备、同券、同一天只一条:领券每帧 last_coupon_result + done 帧 coupon_results
# 会重复上报同一张券,靠它幂等 upsert。
UniqueConstraint(
"device_id", "coupon_id", "claim_date",
name="uq_coupon_claim_device_coupon_date",
),
# 去重/统计查询按 (device, 日) 取一天所有券。
Index("ix_coupon_claim_device_date", "device_id", "claim_date"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 判断/聚合维度。client getOrCreateDeviceId 生成,重装会变。
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
# 登录态有就记(资产/画像),可空、不进唯一键。
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
coupon_id: Mapped[str] = mapped_column(String(64), nullable=False)
# Asia/Shanghai 自然日。每日可领的券(签到/天天红包)靠这天然每天一条。
claim_date: Mapped[date] = mapped_column(Date, nullable=False)
# success / already_claimed / failed / skipped(原样取 pricebot coupon 结果)
status: Mapped[str] = mapped_column(String(24), nullable=False)
vendor: Mapped[str | None] = mapped_column(String(48), nullable=True)
coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 这张领到几张(pricebot display_count;给不出时为 None)
claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 哪次任务领的,回指 pricebot work_logs / 排查
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# failed / skipped 原因
reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
# 杂项兜底:券的结构化信息(面额/入口/关键节点摘要等),免得加字段就迁移。
# ⚠️ 别塞原始无障碍树(几十 KB → 行膨胀);原始大树看 trace_id 指过去的 work_logs。
extra: Mapped[dict | None] = mapped_column(_JSON, 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"<CouponClaimRecord device={self.device_id} coupon={self.coupon_id} "
f"date={self.claim_date} status={self.status}>"
)
class CouponDailyCompletion(Base):
"""按 (device, 自然日) 记"今天是否已跑完整轮领券(到 done 帧)"——首页置灰源。
engagement 区别:engagement = 用户表达过意向(点了一键领取/拒绝即记,不管跑没跑完);
completion = 这一轮真跑到了 done(整套流程走完)首页去领取卡据此置灰:跑完了
今天就不能再领判断口径(用户决策 2026-06-10 A 方案):** done 即算**,不管单券
成败(失败/跳过常是无障碍/环境问题,重复点也补不回来)判断维度 device_id,
engagement/claim 一致
"""
__tablename__ = "coupon_daily_completion"
__table_args__ = (
# 一台设备一天一条:今天跑完过就置灰。
UniqueConstraint(
"device_id", "complete_date",
name="uq_coupon_completion_device_date",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# Asia/Shanghai 自然日。
complete_date: Mapped[date] = mapped_column(Date, nullable=False)
# 哪次任务跑到 done,回指 pricebot work_logs / 排查。
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<CouponDailyCompletion device={self.device_id} "
f"date={self.complete_date}>"
)
class CouponPromptEngagement(Base):
"""按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。"""
__tablename__ = "coupon_prompt_engagement"
__table_args__ = (
# 一台设备一天一条:今天 engage 过(领或拒)就不再弹。
UniqueConstraint(
"device_id", "engage_date",
name="uq_coupon_engage_device_date",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# Asia/Shanghai 自然日。
engage_date: Mapped[date] = mapped_column(Date, nullable=False)
# claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)。仅记录区分,
# 判断只看"今天有没有这条",type 不影响弹不弹。
engage_type: Mapped[str] = mapped_column(String(16), nullable=False)
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"<CouponPromptEngagement device={self.device_id} "
f"date={self.engage_date} type={self.engage_type}>"
)
+46
View File
@@ -0,0 +1,46 @@
"""好友邀请关系表(注册即生效)。
一行 = 一次成功的邀请绑定`invitee_user_id` 唯一 = 一个被邀请人只能被归因一次
(天然幂等键,防重复发奖,仿 ad_reward.trans_id 思路)`channel` 记归因来源
(clipboard 自动 / manual 手动填),便于后续分析"剪贴板自动捕获率"
邀请码本身是 `user.invite_code`(一人一稳定码,懒生成),不在这张表里
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class InviteRelation(Base):
__tablename__ = "invite_relation"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
inviter_user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 被邀请人唯一:一个人只能被邀一次(幂等键,防重复发奖)
invitee_user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), unique=True, index=True, nullable=False
)
# 归因来源:clipboard(剪贴板自动) / manual(手动填码)
channel: Mapped[str] = mapped_column(String(16), nullable=False, default="clipboard")
# 状态:effective(注册即生效)。预留:以后改"完成首单才生效"时用 pending/effective
status: Mapped[str] = mapped_column(String(16), nullable=False, default="effective")
# 本次给邀请人 / 被邀请人各发的金币(记账留痕,便于对账)
inviter_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
invitee_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<InviteRelation inviter={self.inviter_user_id} "
f"invitee={self.invitee_user_id} {self.status}>"
)
+46
View File
@@ -0,0 +1,46 @@
"""邀请指纹归因表(剪贴板归因兜底)。
数据流(对应 [[invite-three-tasks]] 任务 3):
1. B 浏览器打开落地页 dl.html?ref=邀请码 JS POST /api/v1/invite/landing-track
2. 后端从 HTTP 头拿 IP/UA,解析 UA device_model, JS 上报的 screen 一起入库
3. B 装包首启 登录后,若剪贴板没拿到邀请码(被覆盖),客户端再算一次 screen+Build.MODEL
上报 后端用 (ip, screen, device_model) 反查本表 7 天内最近匹配 拿出 inviter_user_id
走原 bind 路径(channel=fingerprint)
不要索引(IP, created_at) 单独,组合索引 (ip, screen, device_model, created_at desc) 反查更省
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class InviteFingerprint(Base):
__tablename__ = "invite_fingerprint"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
inviter_user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 落地页访问者 IP(服务端从 HTTP 头拿,X-Forwarded-For 由部署侧 nginx 透传)
ip: Mapped[str] = mapped_column(String(64), nullable=False)
# 客户端 Build.MODEL / 浏览器 UA 解析出来的型号(如 "PJF110"、"23046PNC9C")
device_model: Mapped[str] = mapped_column(String(64), nullable=False, default="")
# 屏幕分辨率 "1080x2400"(浏览器 screen.width × height,客户端 DisplayMetrics)
screen: Mapped[str] = mapped_column(String(32), nullable=False, default="")
# 完整 UA 字符串(留 debug / 排查"匹配不上"用,不直接参与匹配)
user_agent: Mapped[str] = mapped_column(Text, nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<InviteFingerprint inviter={self.inviter_user_id} "
f"ip={self.ip} model={self.device_model} screen={self.screen}>"
)
+93
View File
@@ -0,0 +1,93 @@
"""美团 CPS 券本地缓存表(meituan_coupon)。
把美团联盟 CPS 的券定时抓进本地库,销量 / 佣金排序等查询**直接从库里捞本地排序**,
不再每次实时打美团接口美团搜索/供给对销量排序支持差(实测乱序)且有 402 限流和召回上限
数据来源(source,北京试点):
- search_waimai : 到家/外卖, 搜索关键词外卖, 翻到尽头
- search_meishi : 到家/外卖, 搜索关键词美食, 翻到尽头
- store_supply : 到店, 多业务线供给(到餐+到综+酒店+门票), 翻到尽头
5min(本地测试)/ 1h(线上)全量抓一次, (source, product_view_sign) upsert 存最新态,
last_seen 每轮刷新(可据此清理长期未再出现的陈旧券)
去重说明:`product_view_sign` / `sku_view_id` 都是按召回渠道生成,跨渠道(搜索 vs 供给)
会变,**不能当商品全局唯一 id**因此:
- 存储: (source, product_view_sign) upsert(同源短周期内 sign 稳定)
- 查询: `dedup_key = md5(brand|name|price)` 跨源去重,再按 `sale_volume_num` / `commission_percent` 排序
价格统一存(cents), price_report / comparison_record 一致`raw` 保留整条原始返回
(字段越详细越好,避免后续漏字段还要重抓)
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, DateTime, Float, Integer, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class MeituanCoupon(Base):
__tablename__ = "meituan_coupon"
__table_args__ = (
UniqueConstraint("source", "product_view_sign", name="uq_meituan_coupon_source_sign"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# ===== 来源 / 分类 =====
# source: search_waimai | search_meishi | store_supply
source: Mapped[str] = mapped_column(String(16), index=True, nullable=False)
platform: Mapped[int] = mapped_column(Integer, nullable=False) # 1 到家/外卖, 2 到店
biz_line: Mapped[int | None] = mapped_column(Integer, nullable=True) # 到店: 1到餐 2到综 3酒店 4门票
city_id: Mapped[str] = mapped_column(String(32), index=True, nullable=False)
# ===== 召回 id(按渠道生成,跨渠道会变;product_view_sign 用于换推广链) =====
product_view_sign: Mapped[str] = mapped_column(String(128), nullable=False)
sku_view_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
# ===== 商品本体 =====
name: Mapped[str | None] = mapped_column(String(256), nullable=True)
brand_name: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
sell_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
original_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
head_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
# ===== 销量(美团只给粗档位:热销1w+;num=排序用的下界数值,如 1w+ → 10000) =====
sale_volume: Mapped[str | None] = mapped_column(String(32), nullable=True)
sale_volume_num: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# ===== 佣金(percent 如 1.4 表示 1.4%;amount 为分) =====
commission_percent: Mapped[float | None] = mapped_column(Float, index=True, nullable=True)
commission_amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# ===== 门店 / 距离 =====
poi_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
available_poi_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
delivery_distance_m: Mapped[float | None] = mapped_column(Float, nullable=True)
# ===== 跨源去重键 + 原始返回 =====
dedup_key: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
raw: Mapped[dict] = mapped_column(
JSON().with_variant(JSONB(), "postgresql"), nullable=False, default=dict
)
# ===== 抓取时间窗 =====
first_seen: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
last_seen: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, 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"<MeituanCoupon id={self.id} source={self.source} "
f"name={self.name!r} sale={self.sale_volume} comm={self.commission_percent}>"
)
+42
View File
@@ -0,0 +1,42 @@
"""首页轮播「种子」条目(运营可配的兜底假数据 = 假数据生成规则)。
客户端首页顶部用户****xxx 比价后节省 xx 滚动条:数据源是全平台真实比价记录,
但冷启动 / 低谷期真实记录不够 N 条时,用本表的种子条目补齐到 N 混播,保证轮播永不空
运营后台可增删改本表真实够时种子不出现
种子是规则而非死记录:
- masked_user 可空留空则 feed 展示时按脱敏格式随机合成用户名(并避开同屏撞名),
风格与真实条一致运营零心智永不穿帮;填了则固定用该名
- 金额是 [min_cents, max_cents] 区间,feed 每次在区间内随机取值(固定金额则 min==max),
让同一种子每次展示的数字有浮动,更像真的
- sort_order 仅供后台列表排序;feed 是公平随机抽取(不看 sort_order),保证所有启用种子都有机会露出
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class OpsMarqueeSeed(Base):
__tablename__ = "ops_marquee_seed"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 脱敏用户名,如「用户********a52」;留空(NULL)→ feed 展示时按脱敏格式随机合成(避开撞名)
masked_user: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 节省金额区间(分);feed 每次在 [min,max] 随机取一个值。固定金额则 min==max。客户端 ÷100 显示「x.xx 元」
min_cents: Mapped[int] = mapped_column(Integer, nullable=False)
max_cents: Mapped[int] = mapped_column(Integer, nullable=False)
# 停用的种子不参与混播
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# 仅供后台列表排序(小在前);feed 是公平随机抽取,不看此字段
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<OpsMarqueeSeed id={self.id} user={self.masked_user}>"
+70
View File
@@ -0,0 +1,70 @@
"""首页三统计展示配置表(帮助用户 / 完成比价 / 累计节省)。
客户端首页顶部三个门面数字,每个指标可独立选 3 种展示模式(运营后台配):
- real : 实时查真实数据(help_users=去重成功比价用户数 / total_compares=成功比价数 /
total_saved=成功比价省额求和)
- manual : 直接用运营手填的固定值
- random : 只增不减的伪增长读取时惰性 tick:距上次 tick 超过 random_tick_seconds
就把 random_current 乘以一个 [min,max] 内的随机倍率(倍率恒 1.0 只增不减)
数值单位约定(三指标都用各自基础单位的整数存,避免浮点):
- help_users / total_compares : 个数( / )
- total_saved : (cents);客户端 ÷100 显示
倍率用千分比整数存:1.00010001.1001100,读时 factor = randint(min,max)/1000
一行一指标,主键 = metric初始由 migration 播种 3 (默认 manual + 当前写死门面值,
保证上线无视觉变化),运营再按需切模式
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class OpsStatConfig(Base):
__tablename__ = "ops_stat_config"
# help_users / total_compares / total_saved
metric: Mapped[str] = mapped_column(String(32), primary_key=True)
# real / manual / random(每指标独立)
mode: Mapped[str] = mapped_column(String(16), nullable=False, default="real")
# manual 模式固定值(基础单位整数;total_saved 为分)
manual_value: Mapped[int | None] = mapped_column(Integer, nullable=True)
# random 模式:倍率区间(千分比,≥1000 保证只增不减)+ tick 周期 + 当前值 + 上次 tick 时刻
random_mult_min: Mapped[int] = mapped_column(Integer, nullable=False, default=1000)
random_mult_max: Mapped[int] = mapped_column(Integer, nullable=False, default=1100)
random_tick_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=86400)
# 触发时刻对齐偏移:距北京时间 0 点的分钟数。按单位:天=每日时刻(h*60+m)、小时=每小时第几分、
# 分钟=0。刷新在北京时间 (anchor + k*interval) 的钟点边界触发(见 repo _refresh)。
random_anchor_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
random_current: Mapped[int | None] = mapped_column(Integer, nullable=True)
random_last_tick_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# random 增长方式:mult=×倍率(用 random_mult_*) / add=+绝对增量(用 random_step_*,基础单位整数)
random_kind: Mapped[str] = mapped_column(String(8), nullable=False, default="mult")
random_step_min: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
random_step_max: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# real 模式基数偏移(基础单位;total_saved 为分):展示值 = 真实值 + 偏移(冷启动期既真实又体面)
real_offset: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 门面数字默认「只增不减」;运营明确要下调时才开此开关(real/manual 刷新都受护栏约束)
allow_decrease: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
updated_by_admin_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
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"<OpsStatConfig metric={self.metric} mode={self.mode}>"
+111
View File
@@ -0,0 +1,111 @@
"""价格观测表(price_observation)—— 比价沉淀的价格资产层。
每完成一次比价,pricebot done 帧把"在某平台某店,这一篮菜/这单到手价多少钱"
按平台逐条算出来,serverserver 内部上报落这里**与登录无关不依赖客户端上报**
比价透传链路当前不鉴权,只要比价跑到 done 就记,匿名用户也记(来源记 device_id,
user_id 客户端带上时一并记)
comparison_record 的区别:
- comparison_record:用户视角的我的比价记录,客户端登录后主动上报, user_id
- price_observation:**平台/门店视角的价格事实**,server 侧无条件沉淀, (平台,门店,,时间)
组织,是未来"别人查过同店→直接秒回价格"大数据的源头两表独立,互不影响
先存下来用法后说:结构化列给将来按 平台/门店//时间/地理 查询聚合用;dishes / attrs
(JSONB)兜底存灵活明细,免得每多记一个字段就迁移 schemaraw trace( step 全过程)
另存 pricebot 本地 jsonl.gz,本表只存提炼后的价格事实(本表可从 raw 重算)
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
JSON,
Boolean,
DateTime,
Float,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
# PG 上用 JSONB(可建 GIN 索引),SQLite(本地/测试)退化为通用 JSON(同 comparison_record)。
_JSON = JSON().with_variant(JSONB(), "postgresql")
class PriceObservation(Base):
__tablename__ = "price_observation"
__table_args__ = (
# 一次比价(trace)里,同一平台、同一口径(scope)只记一条:pricebot 重试 / 客户端
# replay 重复上报时幂等去重(insert-or-ignore)。一次外卖比价 = 源 + N 个目标平台
# 各一条 scope='order'。
UniqueConstraint(
"trace_id", "platform", "scope",
name="uq_price_obs_trace_platform_scope",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 观测时刻(≈ 比价 done 时;服务端落库时取 now)。按时间查"最近有效价"用,建索引。
observed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
# pricebot 侧 trace_id:回指 A 层原始 trace(能溯源 / 重算)+ 幂等去重键
trace_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
# 业务类型:food(外卖,当前唯一接通)/ ecom(电商,二期)。预留扩展。
business_type: Mapped[str] = mapped_column(
String(16), nullable=False, default="food", index=True
)
# ===== 门店维度(先 denormalize 成文本,不建维度表;实体归一二期再做)=====
platform: Mapped[str] = mapped_column(String(32), index=True, nullable=False)
# 平台内门店 ID(抓得到就存,将来归一最稳的 key;现在多为空)
platform_store_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
store_name: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
city: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 地理(现在拿不到 → 留空;二期把 device 经纬度透传进比价请求后回填)
geohash: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
lng: Mapped[float | None] = mapped_column(Float, nullable=True)
lat: Mapped[float | None] = mapped_column(Float, nullable=True)
# ===== 价格事实 =====
# 是否源平台(发起比价那家)。源平台价也是真实观测,照记。
is_source: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# 口径:order(整单到手价,外卖当前唯一)/ dish(单菜单价,引擎暂不逐菜读,二期)
scope: Mapped[str] = mapped_column(String(16), nullable=False, default="order")
# 该口径的到手价(分);None = 该平台采集失败 / 门店打烊,无有效价
price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
coupon_saved_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
coupon_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 该平台目标店打烊原因(非空时 price_cents 为 None)
store_closed: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 本次比价里的全局名次(1=最便宜;按到手价升序,源/目标统一排)
rank: Mapped[int | None] = mapped_column(Integer, nullable=True)
# ===== 明细(JSON)=====
# 菜篮 [{name, qty, subtotal?}](外卖)。dish 级单价二期从这里 + 引擎增强推。
dishes: Mapped[list | None] = mapped_column(_JSON, nullable=True)
# 灵活字段兜底(配送费 / 起送 / 活动名 / 跳过菜数 等),免得加字段就迁移。
attrs: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
# ===== 来源(溯源 / 去重 / 置信)=====
source_device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# user_id 当前比价链路不鉴权拿不到,客户端带上时才有;先可空。
source_user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<PriceObservation id={self.id} platform={self.platform} "
f"store={self.store_name!r} price_cents={self.price_cents} "
f"trace_id={self.trace_id}>"
)
+30 -2
View File
@@ -1,14 +1,14 @@
"""签到记录表。
每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次
- cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1
- cycle_day: 1..14,14 天循环里今天落在第几档,决定发多少金币;断签后重置回 1
- streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1
"""
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Integer, UniqueConstraint, func
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -39,3 +39,31 @@ class SigninRecord(Base):
f"<SigninRecord user_id={self.user_id} date={self.signin_date} "
f"day={self.cycle_day} streak={self.streak}>"
)
class SigninBoostRecord(Base):
"""签到后看广告膨胀记录。
一天最多膨胀一次,补发金额等于当天签到原始奖励独立表用于防并发重复补发,
后续接入真实 S2S 广告 session 时可把 ad_ref_id 回填为广告会话/交易号
"""
__tablename__ = "signin_boost_record"
__table_args__ = (
UniqueConstraint("user_id", "signin_date", name="uq_signin_boost_user_date"),
)
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
)
signin_date: Mapped[date] = mapped_column(Date, nullable=False)
coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False)
ad_ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<SigninBoostRecord user_id={self.user_id} date={self.signin_date} coin={self.coin_awarded}>"
+14 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy import Boolean, DateTime, Integer, String, false, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -33,6 +33,12 @@ class User(Base):
nickname: Mapped[str | None] = mapped_column(String(64), nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 邀请码:每个用户一个稳定短码(懒生成,见 repositories/invite.ensure_code),
# 分享链接 / 二维码里带它。unique 允许多个 NULL(未生成的用户)。
invite_code: Mapped[str | None] = mapped_column(
String(16), unique=True, index=True, nullable=True
)
# 微信开放平台 openid(微信登录绑定后存,提现转账到零钱用)。同一 appid 下唯一;
# unique 索引保证一个微信只能绑一个账号(nullable,允许多个 NULL=未绑定)。
wechat_openid: Mapped[str | None] = mapped_column(
@@ -45,6 +51,13 @@ class User(Base):
# 账号状态:active / disabled / deleted
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
# 调试链接权限:开了的用户在比价完成弹窗 + 比价记录页能看到「复制调试链接」按钮
# (复制 price.shaguabijia.com 的 trace 链接发给开发排障)。运营后台按用户配置;
# /me 与登录响应里带出给前端做条件渲染。默认 false。
debug_trace_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=false()
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
+19 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func, text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -76,6 +76,15 @@ class WithdrawOrder(Base):
"""
__tablename__ = "withdraw_order"
__table_args__ = (
Index(
"ux_withdraw_order_user_active",
"user_id",
unique=True,
sqlite_where=text("status IN ('reviewing', 'pending')"),
postgresql_where=text("status IN ('reviewing', 'pending')"),
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
@@ -148,6 +157,15 @@ class CashTransaction(Base):
"""现金流水(单位:分)。金币兑现金、提现都记在这里。"""
__tablename__ = "cash_transaction"
__table_args__ = (
Index(
"ux_cash_transaction_withdraw_refund_ref",
"ref_id",
unique=True,
sqlite_where=text("biz_type = 'withdraw_refund' AND ref_id IS NOT NULL"),
postgresql_where=text("biz_type = 'withdraw_refund' AND ref_id IS NOT NULL"),
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
+30 -1
View File
@@ -7,6 +7,7 @@ user 存在(JWT),故不做 UnknownUser 校验。best-effort 上报:丢一两条
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.rewards import cn_today
@@ -19,24 +20,52 @@ def create_ecpm_record(
*,
ad_type: str,
ecpm_raw: str,
ad_session_id: str | None = None,
adn: str | None = None,
slot_id: str | None = None,
) -> AdEcpmRecord:
"""落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。"""
if ad_session_id:
existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
if existing is not None:
return existing
rec = AdEcpmRecord(
user_id=user_id,
ad_type=ad_type,
ad_session_id=ad_session_id,
adn=adn,
slot_id=slot_id,
ecpm_raw=ecpm_raw,
report_date=cn_today().isoformat(),
)
db.add(rec)
db.commit()
try:
db.commit()
except IntegrityError:
db.rollback()
if ad_session_id:
existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
if existing is not None:
return existing
raise
db.refresh(rec)
return rec
def find_by_session(
db: Session, *, user_id: int, ad_session_id: str | None
) -> AdEcpmRecord | None:
"""按广告会话找 eCPM。旧客户端无 ad_session_id 时返回 None。"""
if not ad_session_id:
return None
return db.execute(
select(AdEcpmRecord).where(
AdEcpmRecord.user_id == user_id,
AdEcpmRecord.ad_session_id == ad_session_id,
)
).scalar_one_or_none()
def count_today(db: Session, user_id: int) -> int:
"""该用户今日(北京时间)上报的 eCPM 条数,排查/对账辅助用。"""
return db.execute(
+129
View File
@@ -0,0 +1,129 @@
"""信息流广告奖励 CRUD。
点位 2:每展示满 10 秒累计一份奖励,视频完成后一次性入账当前一期由客户端在
完成回调后上报;后续若 SDK/S2S 能提供更强确认信号,可继续复用本表的幂等键
"""
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import rewards
from app.core.rewards import cn_today
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.repositories import wallet as crud_wallet
FEED_REWARD_UNIT_SECONDS = 10
def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | None:
return db.execute(
select(AdFeedRewardRecord).where(
AdFeedRewardRecord.client_event_id == client_event_id
)
).scalar_one_or_none()
def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
return db.execute(
select(func.count())
.select_from(AdFeedRewardRecord)
.where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.reward_date == reward_date,
AdFeedRewardRecord.status == "granted",
)
).scalar_one()
def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int, today: str) -> int:
"""按每个 10 秒单位逐份计算奖励,LT 使用当天累计奖励份序号。"""
if unit_count <= 0:
return 0
existing_units = db.execute(
select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0))
.where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.reward_date == today,
AdFeedRewardRecord.status == "granted",
)
).scalar_one()
total = 0
for offset in range(1, unit_count + 1):
total += rewards.calculate_ad_reward_coin(ecpm, int(existing_units) + offset)
return total
def grant_feed_reward(
db: Session,
user_id: int,
*,
client_event_id: str,
ecpm: str,
duration_seconds: int,
ad_session_id: str | None = None,
adn: str | None = None,
slot_id: str | None = None,
) -> AdFeedRewardRecord:
"""完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。"""
existing = _find_by_event(db, client_event_id)
if existing is not None:
return existing
today = cn_today().isoformat()
safe_duration = max(0, min(duration_seconds, 24 * 60 * 60))
unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS
if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
user_id=user_id,
reward_date=today,
duration_seconds=safe_duration,
unit_count=unit_count,
ad_session_id=ad_session_id,
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
coin=0,
status="capped",
)
return _commit_record(db, rec, client_event_id)
coin = _unit_reward_total(db, user_id, ecpm, unit_count, today)
if coin > 0:
crud_wallet.grant_coins(
db, user_id, coin,
biz_type="feed_ad_reward", ref_id=client_event_id,
remark=f"信息流广告奖励 {unit_count}",
)
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
user_id=user_id,
reward_date=today,
duration_seconds=safe_duration,
unit_count=unit_count,
ad_session_id=ad_session_id,
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
coin=coin,
status="granted",
)
return _commit_record(db, rec, client_event_id)
def _commit_record(db: Session, rec: AdFeedRewardRecord, client_event_id: str) -> AdFeedRewardRecord:
db.add(rec)
try:
db.commit()
except IntegrityError:
db.rollback()
existing = _find_by_event(db, client_event_id)
if existing is not None:
return existing
raise
db.refresh(rec)
return rec
+88 -19
View File
@@ -5,8 +5,8 @@
2. trans_id 唯一 同一交易号二次回调不重复发(穿山甲会重试)
3. 当日发奖次数 上限 记一条 status='capped' 但不发金币
发金币复用 `wallet.grant_coins`(biz_type='ad_reward', ref_id=trans_id),与发奖记录同事务,
保证"记一笔 + 加金币"原子化单次金币 / 每日上限 / 每轮冷却 都从 app_config (运营后台可改)
普通福利页激励视频按 eCPM 公式发金币; eCPM 时记 status='ecpm_missing' 但不发金币
签到膨胀等其他激励视频场景复用本表记录 S2S 幂等,实际发币由各自业务仓储完成
"""
from __future__ import annotations
@@ -21,6 +21,7 @@ from app.core.ad_cooldown import compute_cooldown
from app.core.rewards import DAILY_AD_WATCH_SECONDS_LIMIT, cn_today
from app.models.ad_reward import AdRewardRecord
from app.models.user import User
from app.repositories import ad_ecpm as crud_ecpm
from app.repositories import wallet as crud_wallet
from app.repositories.ad_watch import watched_seconds_today
@@ -35,6 +36,11 @@ def _find_by_trans(db: Session, trans_id: str) -> AdRewardRecord | None:
).scalar_one_or_none()
def find_by_trans(db: Session, trans_id: str) -> AdRewardRecord | None:
"""按 S2S 交易号查询处理记录,供路由在分场景前做幂等短路。"""
return _find_by_trans(db, trans_id)
def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
return db.execute(
select(func.count())
@@ -43,6 +49,7 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
AdRewardRecord.user_id == user_id,
AdRewardRecord.reward_date == reward_date,
AdRewardRecord.status == "granted",
AdRewardRecord.reward_scene == "reward_video",
)
).scalar_one()
@@ -52,16 +59,13 @@ def grant_ad_reward(
user_id: int,
trans_id: str,
*,
coin: int | None = None,
ecpm: str | None = None,
ad_session_id: str | None = None,
reward_scene: str = "reward_video",
reward_name: str | None = None,
raw: str | None = None,
) -> AdRewardRecord:
"""看广告发奖(幂等 + 每日限额)。返回发奖记录(status=granted/capped)。
coin 为本次发放金币(由调用方按穿山甲回调 reward_amount 解析,
rewards.resolve_ad_reward_coin);None 读配置 get_ad_reward_coin(test-grant / 缺省场景)
user_id 不存在抛 UnknownUserError(不建账户,防伪造 user_id 刷出脏数据)
"""
"""福利页激励视频发奖(幂等 + 每日限额 + eCPM 公式)。"""
# #2 幂等:同 trans_id 已处理过 → 原样返回,不重复发
existing = _find_by_trans(db, trans_id)
if existing is not None:
@@ -72,33 +76,97 @@ def grant_ad_reward(
today = cn_today().isoformat()
# #3 每日上限:观看时长(主闸,50 分钟) 或 发奖次数(兜底) 任一到顶 → 记 capped 不发金币,
# 让审计能看到"今天到顶了"。时长由前端 watch-report 累计(防刷主闸),次数防前端少报时长
# 绕过;次数上限走 app_config(运营后台可改,默认 rewards.DAILY_AD_REWARD_LIMIT)。
over_time = watched_seconds_today(db, user_id, today=today) >= DAILY_AD_WATCH_SECONDS_LIMIT
# #3 每日上限:当前产品只保留发奖次数上限(默认 500 次)。旧的观看时长闸保留字段,
# 但 DAILY_AD_WATCH_SECONDS_LIMIT=0 时视为停用,不能命中 capped。
over_time = (
DAILY_AD_WATCH_SECONDS_LIMIT > 0
and watched_seconds_today(db, user_id, today=today) >= DAILY_AD_WATCH_SECONDS_LIMIT
)
over_count = _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db)
if over_time or over_count:
rec = AdRewardRecord(
trans_id=trans_id, user_id=user_id, coin=0, status="capped",
reward_date=today, reward_name=reward_name, raw=raw,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm,
)
return _commit_record(db, rec, trans_id)
if coin is None:
coin = rewards.get_ad_reward_coin(db)
ecpm_raw = ecpm
if not ecpm_raw and ad_session_id:
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
ecpm_raw = ecpm_rec.ecpm_raw if ecpm_rec is not None else None
if not ecpm_raw:
rec = AdRewardRecord(
trans_id=trans_id, user_id=user_id, coin=0, status="ecpm_missing",
reward_date=today, reward_name=reward_name, raw=raw,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=None,
)
return _commit_record(db, rec, trans_id)
coin = rewards.calculate_ad_reward_coin(ecpm_raw, _granted_today(db, user_id, today) + 1)
# 发金币 + 记一笔,同事务
crud_wallet.grant_coins(
db, user_id, coin,
biz_type="ad_reward", ref_id=trans_id, remark="看视频奖励金币",
biz_type="reward_video", ref_id=trans_id, remark="看视频奖励金币",
)
rec = AdRewardRecord(
trans_id=trans_id, user_id=user_id, coin=coin, status="granted",
reward_date=today, reward_name=reward_name, raw=raw,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm_raw,
)
return _commit_record(db, rec, trans_id)
def record_external_reward(
db: Session,
user_id: int,
trans_id: str,
*,
coin: int,
reward_scene: str,
ad_session_id: str | None = None,
ecpm: str | None = None,
reward_name: str | None = None,
raw: str | None = None,
status: str = "granted",
commit: bool = True,
) -> AdRewardRecord:
"""记录非普通看视频场景的 S2S 回调幂等,发币由调用方业务仓储完成。"""
existing = _find_by_trans(db, trans_id)
if existing is not None:
return existing
if db.get(User, user_id) is None:
raise UnknownUserError
rec = AdRewardRecord(
trans_id=trans_id,
user_id=user_id,
coin=coin,
status=status,
reward_date=cn_today().isoformat(),
reward_name=reward_name,
raw=raw,
reward_scene=reward_scene,
ad_session_id=ad_session_id,
ecpm_raw=ecpm,
)
db.add(rec)
if commit:
try:
db.commit()
except IntegrityError:
db.rollback()
existing = _find_by_trans(db, trans_id)
if existing is not None:
return existing
raise
db.refresh(rec)
else:
db.flush()
return rec
def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardRecord:
"""提交发奖记录;并发下同 trans_id 撞唯一约束时回滚并返回已存在的那条(幂等兜底)。"""
db.add(rec)
@@ -123,6 +191,7 @@ def _granted_times_today_desc(db: Session, user_id: int, reward_date: str) -> li
AdRewardRecord.user_id == user_id,
AdRewardRecord.reward_date == reward_date,
AdRewardRecord.status == "granted",
AdRewardRecord.reward_scene == "reward_video",
)
.order_by(AdRewardRecord.created_at.desc())
).scalars()
@@ -137,8 +206,8 @@ def today_status(
返回 (今日已发次数, 每日次数上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC),
今日已观看总秒数, 每日观看总时长上限())
次数上限/单次金币/每轮次数/冷却秒 均从 app_config (运营后台可改);次数维度的"本轮已看
几次 + 冷却到几点"委托 [app.core.ad_cooldown.compute_cooldown](纯函数);时长维度(50 分钟
主闸) ad_watch_log 当日累计前端据此展示"今日已看 X/50 分钟"+ 满则不给看
几次 + 冷却到几点"委托 [app.core.ad_cooldown.compute_cooldown](纯函数);观看时长字段保留给
旧客户端兼容,当前 DAILY_AD_WATCH_SECONDS_LIMIT=0 表示不启用时长闸
"""
today = cn_today().isoformat()
granted_desc = _granted_times_today_desc(db, user_id, today)
@@ -151,7 +220,7 @@ def today_status(
return (
len(granted_desc),
rewards.get_ad_daily_limit(db),
rewards.get_ad_reward_coin(db),
0,
state.round_count,
state.cooldown_until,
watched_seconds_today(db, user_id, today=today),
+2 -2
View File
@@ -1,7 +1,7 @@
"""看激励视频观看时长 CRUD(每日总时长防刷)。
"""看激励视频观看时长 CRUD(旧版每日总时长字段)。
前端 onAdClose 上报本次观看秒数 add_watch_seconds 落库 + 返回当日累计;
watched_seconds_today "时长闸"(发奖 / 展示前)判断当天是否已达 50 分钟上限
当前产品只保留每日 500 次上限,DAILY_AD_WATCH_SECONDS_LIMIT=0 时不再用本累计做发奖拦截
鉴权接口已确保 user 存在(JWT),故不做 UnknownUser 校验
"""
from __future__ import annotations
+32 -1
View File
@@ -5,9 +5,12 @@
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.comparison import ComparisonRecord
from app.models.savings import SavingsRecord
from app.schemas.compare_record import ComparisonRecordIn
@@ -81,6 +84,7 @@ def upsert_record(
source_package=payload.source_package,
information=payload.information,
best_deeplink=payload.best_deeplink,
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],
@@ -104,7 +108,15 @@ def upsert_record(
db.refresh(existing)
return existing
rec = ComparisonRecord(user_id=user_id, trace_id=payload.trace_id, **fields)
# created_at 显式存 naive 北京 wall-clock(同 savings_record):客户端「比价记录」页
# formatRecordTime 原样切片 created_at 字符串、不转时区,而默认 server_default=func.now()
# 在 SQLite 下返回 UTC → 直接慢 8h。覆盖分支不动 created_at(保留首次创建时间)。
rec = ComparisonRecord(
user_id=user_id,
trace_id=payload.trace_id,
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
**fields,
)
db.add(rec)
db.commit()
db.refresh(rec)
@@ -163,6 +175,25 @@ def count_success(db: Session, user_id: int) -> int:
).scalar_one()
def get_stats(db: Session, user_id: int) -> tuple[int, int]:
"""「我的」页省钱战绩卡的**比价口径**聚合:(成功比价数, 累计发现可省额/分)。
- 完成比价 = status='success' 的比价记录数( count_success)
- 累计发现可省 = 各成功比价 saved_amount_cents(源价最低价)之和;null(没省/未识别)不计
下单成交记账savings_record 解耦:比过价就计,不要求下单( savings.py)
"""
row = db.execute(
select(
func.count(ComparisonRecord.id),
func.coalesce(func.sum(ComparisonRecord.saved_amount_cents), 0),
).where(
ComparisonRecord.user_id == user_id,
ComparisonRecord.status == "success",
)
).one()
return int(row[0]), int(row[1])
def get_record(db: Session, user_id: int, record_id: int) -> ComparisonRecord | None:
"""取单条(限本人,避免越权读他人记录)。"""
return db.execute(
+187
View File
@@ -0,0 +1,187 @@
"""领券今日状态读写:弹窗频控(engagement)+ 领券记录(claim)。
写操作按唯一键幂等 upsert,自带 commit + 并发 IntegrityError 兜底(对齐 price_observation)
日期口径 = Asia/Shanghai 的自然日
"""
from __future__ import annotations
import logging
from datetime import date, datetime
from zoneinfo import ZoneInfo
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.coupon_state import (
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
)
logger = logging.getLogger("shagua.coupon_state")
_CN_TZ = ZoneInfo("Asia/Shanghai")
def today_cn() -> date:
"""Asia/Shanghai 的自然日(领券判断的"今天")。"""
return datetime.now(_CN_TZ).date()
# ===== 弹窗频控(coupon_prompt_engagement)=====
def has_engaged_today(db: Session, device_id: str) -> bool:
"""这台设备今天是否已对领券引导窗表达过意向(领或拒)。有 = 不再弹。"""
row = db.execute(
select(CouponPromptEngagement.id).where(
CouponPromptEngagement.device_id == device_id,
CouponPromptEngagement.engage_date == today_cn(),
)
).first()
return row is not None
def mark_engagement(
db: Session, device_id: str, user_id: int | None, engage_type: str
) -> None:
"""记今日意向(claim_started / dismissed)。(device, 今天) 唯一,幂等 upsert。"""
today = today_cn()
row = db.execute(
select(CouponPromptEngagement).where(
CouponPromptEngagement.device_id == device_id,
CouponPromptEngagement.engage_date == today,
)
).scalar_one_or_none()
if row is not None:
row.engage_type = engage_type
if user_id is not None:
row.user_id = user_id
else:
db.add(CouponPromptEngagement(
device_id=device_id, user_id=user_id,
engage_date=today, engage_type=engage_type,
))
try:
db.commit()
except IntegrityError:
# 并发下另一请求刚插了同 (device, 日) → 唯一约束撞,回滚忽略(本就幂等)。
db.rollback()
def reset_today_engagement(db: Session, device_id: str) -> int:
"""删这台设备今天的 engagement(开发设置「重置今日领券弹窗状态」调,测频控用)。
删后 has_engaged_today false,今天又能弹返回删除行数"""
result = db.execute(
delete(CouponPromptEngagement).where(
CouponPromptEngagement.device_id == device_id,
CouponPromptEngagement.engage_date == today_cn(),
)
)
db.commit()
return result.rowcount or 0
# ===== 今日跑完整轮(coupon_daily_completion)=====
def has_completed_today(db: Session, device_id: str) -> bool:
"""这台设备今天是否已跑完整轮领券(到 done 帧)。有 = 首页置灰、不能再领。"""
row = db.execute(
select(CouponDailyCompletion.id).where(
CouponDailyCompletion.device_id == device_id,
CouponDailyCompletion.complete_date == today_cn(),
)
).first()
return row is not None
def mark_completed_today(
db: Session, device_id: str, user_id: int | None, trace_id: str | None = None
) -> None:
"""记今日已跑完整轮。(device, 今天) 唯一,幂等 upsert。到 done 即记,不管单券成败。"""
today = today_cn()
row = db.execute(
select(CouponDailyCompletion).where(
CouponDailyCompletion.device_id == device_id,
CouponDailyCompletion.complete_date == today,
)
).scalar_one_or_none()
if row is not None:
if user_id is not None:
row.user_id = user_id
if trace_id is not None:
row.trace_id = trace_id
else:
db.add(CouponDailyCompletion(
device_id=device_id, user_id=user_id,
complete_date=today, trace_id=trace_id,
))
try:
db.commit()
except IntegrityError:
# 并发下另一请求刚插了同 (device, 日) → 唯一约束撞,回滚忽略(本就幂等)。
db.rollback()
# ===== 领券记录(coupon_claim_record)=====
def record_claims(
db: Session,
device_id: str,
user_id: int | None,
trace_id: str | None,
results: list[dict],
) -> int:
"""一批券领取结果幂等写入,返回写入(新增 + 更新)条数。
results 单项取自 pricebot last_coupon_result / done.coupon_results,识别字段:
coupon_id(必需)/ status(必需)/ name / vendor / reason /(display_count)
(device, coupon_id, 今天) 唯一:重复上报同张券走更新(status 以最后一次为准)
"""
today = today_cn()
written = 0
seen: set[str] = set() # 同批去重防御:autoflush=False 下同 coupon_id 重复会两次 add → 撞唯一约束回滚整批
for r in results:
coupon_id = r.get("coupon_id")
status = r.get("status")
if not coupon_id or not status or coupon_id in seen:
continue # 脏数据 / 同批重复跳过
seen.add(coupon_id)
count = r.get("display_count")
if count is None:
count = r.get("claimed_count")
row = db.execute(
select(CouponClaimRecord).where(
CouponClaimRecord.device_id == device_id,
CouponClaimRecord.coupon_id == coupon_id,
CouponClaimRecord.claim_date == today,
)
).scalar_one_or_none()
if row is not None:
row.status = status
row.reason = r.get("reason")
if user_id is not None:
row.user_id = user_id
if count is not None:
row.claimed_count = count
row.extra = r
else:
db.add(CouponClaimRecord(
device_id=device_id, user_id=user_id,
coupon_id=coupon_id, claim_date=today,
status=status, vendor=r.get("vendor"), coupon_name=r.get("name"),
claimed_count=count, trace_id=trace_id, reason=r.get("reason"),
extra=r,
))
written += 1
if written == 0:
return 0
try:
db.commit()
except IntegrityError:
db.rollback()
logger.warning(
"coupon_claim 并发幂等冲突 device=%s trace=%s,回滚", device_id, trace_id
)
return 0
return written
+285
View File
@@ -0,0 +1,285 @@
"""好友邀请 CRUD(注册即生效,邀请人 + 被邀请人各发金币)。
防重复发奖三道(仿 ad_reward / 提现的资金安全思路):
1. invitee_user_id 唯一 一个被邀请人只能被绑定一次(幂等键)
2. 自邀屏蔽 inviter == invitee 直接拒
3. 现成的手机号唯一(每个被邀请人 = 一个真实手机号账号)= 天然限制刷量规模
发金币复用 wallet.grant_coins(grant flush commit),与建关系记录在**同一事务**
commit,保证"建关系 + 双方加金币"原子奖励额 = rewards.INVITE_INVITER_COINS /
INVITE_INVITEE_COINS
"""
from __future__ import annotations
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.invite import InviteRelation
from app.models.invite_fingerprint import InviteFingerprint
from app.models.user import User
from app.repositories import wallet as crud_wallet
# 邀请码字符集:去掉易混字符(0/O/1/I/L/B/8/S/5/Z/2),用户口述/手输不易错
_CODE_ALPHABET = "ACDEFGHJKMNPQRTUVWXY34679"
_CODE_LEN = 6
def _gen_code() -> str:
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LEN))
def ensure_code(db: Session, user: User) -> str:
"""保证 user 有邀请码(懒生成),返回它。唯一约束碰撞则换码重试。
本函数会 db.commit() 整个 session( grant_coins"只 flush 不 commit"约定相反)
当前只在 GET /invite/me 里调(该请求此前无其它写,提交范围干净)若将来在带其它未提交
写的请求里复用本函数,会被它提前 commit届时应改成 flush + 由调用方 commit
"""
if user.invite_code:
return user.invite_code
for _ in range(8):
user.invite_code = _gen_code()
try:
db.commit()
db.refresh(user)
return user.invite_code
except IntegrityError:
db.rollback()
user = db.get(User, user.id) # 重取(rollback 后实例已过期),继续换码
raise RuntimeError("生成邀请码连续碰撞,请重试")
def resolve_inviter(db: Session, invite_code: str) -> User | None:
"""邀请码 → 邀请人(大小写不敏感)。"""
code = (invite_code or "").strip().upper()
if not code:
return None
return db.execute(
select(User).where(User.invite_code == code)
).scalar_one_or_none()
def _relation_of_invitee(db: Session, invitee_id: int) -> InviteRelation | None:
return db.execute(
select(InviteRelation).where(InviteRelation.invitee_user_id == invitee_id)
).scalar_one_or_none()
def _is_new_user(user: User) -> bool:
"""被邀请人是否为"新注册"(created_at 在 INVITE_NEW_USER_WINDOW_HOURS 窗口内)。
"新用户闸":只奖刚注册的人,挡存量老用户互相填码薅羊毛兼容 PG(tz-aware)
SQLite(naive, UTC 解释)
"""
created = user.created_at
if created is None:
return False
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - created
return age <= timedelta(hours=rewards.INVITE_NEW_USER_WINDOW_HOURS)
@dataclass
class BindResult:
status: str # success / already_bound / invalid_code / self_invite / not_eligible
relation: InviteRelation | None = None
invitee_coin: int = 0 # 本次给被邀请人发的金币(success 时 >0)
def bind(
db: Session, *, invitee: User, invite_code: str, channel: str = "clipboard"
) -> BindResult:
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效 + 双方发金币。
幂等:invitee 已被绑过 already_bound(不重复发奖)
"""
# 幂等:已绑过直接返回(不重复发奖)
existing = _relation_of_invitee(db, invitee.id)
if existing is not None:
return BindResult("already_bound", existing)
inviter = resolve_inviter(db, invite_code)
if inviter is None or inviter.status != "active":
return BindResult("invalid_code")
if inviter.id == invitee.id:
return BindResult("self_invite")
# 新用户闸:被邀请人必须是"新注册"(窗口内)才发奖,挡存量老用户互相填码薅羊毛
if not _is_new_user(invitee):
return BindResult("not_eligible")
inviter_coin = rewards.INVITE_INVITER_COINS
invitee_coin = rewards.INVITE_INVITEE_COINS
rel = InviteRelation(
inviter_user_id=inviter.id,
invitee_user_id=invitee.id,
channel=(channel or "clipboard")[:16],
status="effective",
inviter_coin=inviter_coin,
invitee_coin=invitee_coin,
)
db.add(rel)
# 双方发金币(同事务,与建关系一起 commit)。ref_id 互指对方便于对账。
crud_wallet.grant_coins(
db, inviter.id, inviter_coin,
biz_type="invite_inviter", ref_id=str(invitee.id), remark="邀请好友奖励",
)
crud_wallet.grant_coins(
db, invitee.id, invitee_coin,
biz_type="invite_invitee", ref_id=str(inviter.id), remark="新人受邀奖励",
)
try:
db.commit()
except IntegrityError:
# 并发:同一 invitee 另一个请求先建了关系 → 回滚返回已存在(幂等兜底)
db.rollback()
existing = _relation_of_invitee(db, invitee.id)
if existing is not None:
return BindResult("already_bound", existing)
raise
except Exception:
# 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证"建关系 + 双方发币"
# 原子(要么全成要么全无),不依赖 get_db 关闭时的隐式回滚,语义更硬。
db.rollback()
raise
db.refresh(rel)
return BindResult("success", rel, invitee_coin)
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。"""
count = db.execute(
select(func.count())
.select_from(InviteRelation)
.where(InviteRelation.inviter_user_id == inviter_id)
).scalar_one()
coins = db.execute(
select(func.coalesce(func.sum(InviteRelation.inviter_coin), 0))
.where(InviteRelation.inviter_user_id == inviter_id)
).scalar_one()
return int(count), int(coins)
def _mask_phone(phone: str) -> str:
"""手机号脱敏:138****8888。前端拿不到完整号,展示被邀请人时在此兜底名字。
11 位标准手机号 3 + **** + 4;非标准(异常/截断) 只露后 4 ;太短 "新用户"
"""
p = (phone or "").strip()
if len(p) == 11:
return f"{p[:3]}****{p[-4:]}"
if len(p) >= 4:
return f"****{p[-4:]}"
return "新用户"
def get_invitees(
db: Session, inviter_id: int, *, limit: int = 20, offset: int = 0
) -> tuple[list[dict], int, bool]:
"""查某邀请人的被邀请人列表(按邀请时间倒序, 分页),返回 (items, total, has_more)。
每条 item = {display_name, avatar_url, coins, invited_at}降级兜底:
名字 = 昵称 微信昵称 脱敏手机号(很多被邀请人是刚注册新用户没设资料);
头像 = 用户头像 微信头像 None(前端画默认色块)
邀请关系表 join 用户表;total 单独 count(分页时算 has_more)
"""
total = db.execute(
select(func.count())
.select_from(InviteRelation)
.where(InviteRelation.inviter_user_id == inviter_id)
).scalar_one()
rows = db.execute(
select(InviteRelation, User)
.join(User, User.id == InviteRelation.invitee_user_id)
.where(InviteRelation.inviter_user_id == inviter_id)
.order_by(InviteRelation.created_at.desc())
.limit(limit)
.offset(offset)
).all()
items: list[dict] = []
for rel, u in rows:
items.append({
"display_name": u.nickname or u.wechat_nickname or _mask_phone(u.phone),
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
"coins": rel.inviter_coin,
"invited_at": rel.created_at,
})
has_more = offset + len(rows) < int(total)
return items, int(total), has_more
# ===== 指纹归因(剪贴板兜底,见 [[invite-three-tasks]] 任务 3)=====
def record_fingerprint(
db: Session,
*,
inviter_user_id: int,
ip: str,
screen: str,
device_model: str,
user_agent: str,
) -> InviteFingerprint:
"""落地页 dl.html 访问时调用,写一行指纹记录。
后续被邀请人登录剪贴板没拿到邀请码时,/bind 会按 (ip, screen, device_model) 反查本表
7 天内最近一条匹配 拿出 inviter_user_id 撞库
本函数会 commit;调用方(/landing-track endpoint)在此之前无其它写操作
"""
fp = InviteFingerprint(
inviter_user_id=inviter_user_id,
ip=ip[:64],
screen=screen[:32],
device_model=device_model[:64],
user_agent=user_agent[:2000] if user_agent else "", # 截一下防异常长 UA
)
db.add(fp)
db.commit()
db.refresh(fp)
return fp
def resolve_inviter_by_fingerprint(
db: Session,
*,
ip: str,
screen: str,
device_model: str,
window_days: int,
) -> User | None:
"""根据指纹反查邀请人(time window 内最近一条匹配)。
匹配规则:(ip, device_model) 相等 + created_at > now - window_days
screen 字段**只入库不反查**:浏览器算物理像素走 `CSS × devicePixelRatio` 路径
Android `DisplayMetrics.widthPixels` 真实硬件值,两端浮点 round 必然 ±1 像素漂移
(DPR 不严格是整数) 严格匹配注定撞不上 device_model 必同 screen(同型号同硬件)
screen 是冗余字段,删它不损精度撞错只有"同 IP 下同型号手机同时被邀请"才会
发生,中国家庭/公司 WiFi 场景概率极低
"""
if not ip:
return None
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(days=window_days)
fp = db.execute(
select(InviteFingerprint)
.where(
InviteFingerprint.ip == ip,
InviteFingerprint.device_model == device_model,
InviteFingerprint.created_at > cutoff,
)
.order_by(InviteFingerprint.created_at.desc())
.limit(1)
).scalar_one_or_none()
if fp is None:
return None
return db.get(User, fp.inviter_user_id)
+246
View File
@@ -0,0 +1,246 @@
"""首页轮播 feed:全平台真实比价记录 + 种子兜底「混播」,以及种子表 CRUD。
feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条时用 ops_marquee_seed 的种子补齐,
保证轮播永不空真实够时种子不出现
种子是生成规则:用户名可空(随机合成脱敏名,避开同屏撞名)金额是区间(每次随机取值)
feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出
"""
from __future__ import annotations
import hashlib
import random
from datetime import datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.comparison import ComparisonRecord
from app.models.ops_marquee_seed import OpsMarqueeSeed
# feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」)
_rng = random.Random()
_SUFFIX_HEX = 3 # 脱敏用户名后缀位数,与真实条 md5[:3] 对齐
_HEX = "0123456789abcdef"
# 真实条金额上限(300 元):超过视为异常 / bug 值,不进轮播(防「节省 999 元」穿帮)
_REAL_MAX_CENTS = 30000
# 种子金额防呆上限(1000 元):运营手滑填超大值时拦下
_SEED_MAX_CENTS = 100000
def _mask_user(user_id: int) -> str:
"""真实用户脱敏:用户********+3 位哈希后缀(稳定、不暴露真实信息)。"""
suffix = hashlib.md5(str(user_id).encode()).hexdigest()[:_SUFFIX_HEX]
return f"用户********{suffix}"
def _random_suffix(used: set[str]) -> str:
"""随机 3 位 hex 后缀,避开 used(真实条 + 已生成种子),防同屏撞名。"""
for _ in range(60):
s = "".join(_rng.choice(_HEX) for _ in range(_SUFFIX_HEX))
if s not in used:
used.add(s)
return s
# 兜底(几乎不会到):线性扫一个空位
for n in range(16 ** _SUFFIX_HEX):
s = format(n, f"0{_SUFFIX_HEX}x")
if s not in used:
used.add(s)
return s
return "000"
def _validate_amount(min_cents: int, max_cents: int) -> None:
if min_cents < 0 or max_cents < 0:
raise ValueError("金额需为非负整数(分)")
if max_cents < min_cents:
raise ValueError("金额上限不得小于下限")
if max_cents > _SEED_MAX_CENTS:
raise ValueError(f"金额上限过大(≤ {_SEED_MAX_CENTS // 100} 元)")
# ===== 用户侧:轮播 feed(真实 + 种子混播) =====
def get_feed(db: Session, limit: int = 8) -> list[dict]:
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
真实条:success 0 < saved 上限, user 去重(同一用户只取最新一条,避免单人刷屏)
不足用启用的种子补齐**公平随机抽取** need (而非固定取前 N),让所有种子都有机会露出;
种子用户名留空则随机合成(避开撞名),金额在 [min,max] 区间随机取值
展示时间统一刷新成相对现在的最近时刻( now 往前递减),保证轮播永远像刚发生
(真实用户/金额不变,只换展示时间避免旧测试数据 / 低谷期记录显示成过时/未来时间)
"""
# 真实条:取较多近期记录后按 user 去重;金额超上限的异常值剔除(防穿帮)。
rows = db.execute(
select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents)
.where(
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0,
ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS,
)
.order_by(ComparisonRecord.created_at.desc())
.limit(max(limit * 20, 100))
).all()
items: list[dict] = []
used_suffixes: set[str] = set()
seen_users: set[int] = set()
for uid, sc in rows:
if uid in seen_users:
continue
seen_users.add(uid)
name = _mask_user(uid)
used_suffixes.add(name[-_SUFFIX_HEX:])
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:
# 用户名:填了固定名优先;留空则按脱敏格式随机合成(避开撞名)。
if s.masked_user and s.masked_user.strip():
name = s.masked_user.strip()
used_suffixes.add(name[-_SUFFIX_HEX:])
else:
name = f"用户********{_random_suffix(used_suffixes)}"
# 金额:在 [min,max] 随机一个(固定金额则 min==max)。
lo = max(0, int(s.min_cents))
hi = max(lo, int(s.max_cents))
cents = lo if lo >= hi else _rng.randint(lo, hi)
items.append({"masked_user": name, "saved_amount_cents": cents})
# 统一赋「最近」时间:首条约 20 秒前,其余每条往前 2~6 分钟,降序、节奏自然。
cursor = datetime.now(CN_TZ)
for i, it in enumerate(items):
cursor = cursor - timedelta(seconds=20 if i == 0 else 60 * (2 + (i * 3) % 5))
it["time"] = cursor.strftime("%H:%M:%S")
return items
# ===== 运营侧:种子 CRUD =====
def list_seeds(db: Session) -> list[OpsMarqueeSeed]:
return list(
db.execute(
select(OpsMarqueeSeed).order_by(OpsMarqueeSeed.sort_order, OpsMarqueeSeed.id)
).scalars().all()
)
def create_seed(
db: Session,
*,
masked_user: str | None = None,
min_cents: int,
max_cents: int,
enabled: bool = True,
sort_order: int = 0,
commit: bool = True,
) -> OpsMarqueeSeed:
_validate_amount(min_cents, max_cents)
seed = OpsMarqueeSeed(
masked_user=(masked_user.strip() or None) if masked_user else None,
min_cents=min_cents,
max_cents=max_cents,
enabled=enabled,
sort_order=sort_order,
)
db.add(seed)
if commit:
db.commit()
db.refresh(seed)
else:
db.flush()
return seed
def update_seed(
db: Session,
seed_id: int,
*,
masked_user: str | None = None,
min_cents: int | None = None,
max_cents: int | None = None,
enabled: bool | None = None,
sort_order: int | None = None,
commit: bool = True,
) -> OpsMarqueeSeed:
"""改种子。masked_user 约定:不传(None)=不改;传空串=清空(改回随机合成);传值=固定该名。"""
seed = db.get(OpsMarqueeSeed, seed_id)
if seed is None:
raise ValueError("种子不存在")
if masked_user is not None:
seed.masked_user = masked_user.strip() or None
if min_cents is not None or max_cents is not None:
new_min = min_cents if min_cents is not None else seed.min_cents
new_max = max_cents if max_cents is not None else seed.max_cents
_validate_amount(new_min, new_max)
seed.min_cents = new_min
seed.max_cents = new_max
if enabled is not None:
seed.enabled = enabled
if sort_order is not None:
seed.sort_order = sort_order
if commit:
db.commit()
db.refresh(seed)
else:
db.flush()
return seed
def bulk_generate(
db: Session,
*,
count: int,
min_cents: int,
max_cents: int,
enabled: bool = True,
commit: bool = True,
) -> list[OpsMarqueeSeed]:
"""批量生成 count 条「自动合成名」种子(masked_user=NULL),金额区间 [min,max]。
冷启动铺量用;sort_order 续在当前最大值之后
"""
if not (1 <= count <= 200):
raise ValueError("数量需在 1~200 之间")
_validate_amount(min_cents, max_cents)
base = db.execute(
select(func.coalesce(func.max(OpsMarqueeSeed.sort_order), 0))
).scalar_one()
created: list[OpsMarqueeSeed] = []
for i in range(1, count + 1):
seed = OpsMarqueeSeed(
masked_user=None,
min_cents=min_cents,
max_cents=max_cents,
enabled=enabled,
sort_order=base + i,
)
db.add(seed)
created.append(seed)
if commit:
db.commit()
for s in created:
db.refresh(s)
else:
db.flush()
return created
def delete_seed(db: Session, seed_id: int, *, commit: bool = True) -> bool:
seed = db.get(OpsMarqueeSeed, seed_id)
if seed is None:
return False
db.delete(seed)
if commit:
db.commit()
else:
db.flush()
return True
+334
View File
@@ -0,0 +1,334 @@
"""首页三统计(帮助用户 / 完成比价 / 累计节省)展示值计算 + 运营配置读写。
三种模式见 app/models/ops_stat_config.py统一定时刷新:展示值 random_current 只在跨过
北京钟点边界时刷新一次(real 快照查库 / manual 取固定值 / random 增长)
random 自增长支持两种方式(random_kind):
- mult:×随机倍率([min,max]/1000, 1.0)复利指数增长
- add :+随机绝对增量([step_min,step_max])线性可控,更像真实平台日增
真实值(real)模式展示 = 真实统计 + real_offset(基数偏移),冷启动期既真实又体面
只增不减护栏(allow_decrease=False,默认):real/manual 刷新时若新目标值低于当前展示值,
保持当前值不回退门面数字最忌当众缩水random 天然只增(倍率1 / 增量0)
纯门面数字,**接受 tick 时刻的微小并发竞态**(不加行锁):极端情况某次多走一档,无业务后果
"""
from __future__ import annotations
import random
from datetime import datetime, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.comparison import ComparisonRecord
from app.models.ops_stat_config import OpsStatConfig
# 指标顺序 + 展示元信息(label/unit 给运营后台用;total_saved 单位是分,客户端 ÷100 显示元)
METRICS = ("help_users", "total_compares", "total_saved")
_META = {
"help_users": ("帮助用户", ""),
"total_compares": ("完成比价", ""),
"total_saved": ("累计节省", ""),
}
# 初始播种值(= 客户端原写死门面数:12847 人 / 86532 次 / 37621.4 元)。
_SEED_VALUE = {
"help_users": 12847,
"total_compares": 86532,
"total_saved": 3762140, # 37621.40 元 = 3762140 分
}
# 防呆边界
_MULT_FLOOR = 1000 # 倍率千分比下限 = 1.000(只增不减)
_MULT_CAP = 1500 # 倍率千分比上限 = 1.500(收紧:单次 ×1.5 已不小,>此一跳就假)
_STEP_CAP = 10**12 # 绝对增量单次上限(基础单位防呆)
_TICK_MIN_SECONDS = 60 # tick 周期下限
_MAX_CATCHUP_PERIODS = 3650 # 单次最多补走的周期数(防 last 远古时一次走爆)
_BEIJING_OFFSET = 8 * 3600 # 北京时间 = UTC + 8h(钟点对齐按北京时间算)
# 真实 total_saved 求和:单条 saved 超此值视为异常 / bug 值,不计入
# (与轮播口径一致,防一条 bug 大额配合「只增不减」永久撑高门面)
_REAL_SAVED_CAP_CENTS = 30000 # 300 元
def _as_utc(dt: datetime | None) -> datetime | None:
"""把(可能朴素的 SQLite)datetime 归一成带时区 UTC;朴素值按 UTC 解释。"""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
# ===== 真实数据(real 模式)=====
def _real_value(db: Session, metric: str) -> int:
"""real 口径:仅统计 status='success' 的比价记录。
help_users=去重用户数 / total_compares=记录数 / total_saved=省额()求和(剔除单条异常大值)
"""
success = ComparisonRecord.status == "success"
if metric == "help_users":
return db.execute(
select(func.count(func.distinct(ComparisonRecord.user_id))).where(success)
).scalar_one()
if metric == "total_compares":
return db.execute(
select(func.count(ComparisonRecord.id)).where(success)
).scalar_one()
if metric == "total_saved":
# 只累加 0 < saved ≤ 上限 的条目,异常大值不计入(防 bug 值撑高门面)
return db.execute(
select(func.coalesce(func.sum(ComparisonRecord.saved_amount_cents), 0)).where(
success,
ComparisonRecord.saved_amount_cents > 0,
ComparisonRecord.saved_amount_cents <= _REAL_SAVED_CAP_CENTS,
)
).scalar_one()
raise KeyError(f"unknown metric: {metric}")
# ===== 行管理 =====
def _ensure_rows(db: Session) -> dict[str, OpsStatConfig]:
"""保证三指标行都存在(migration 已播种;这里是缺行兜底,默认 manual + 种子值)。"""
rows = {r.metric: r for r in db.execute(select(OpsStatConfig)).scalars().all()}
created = False
for metric in METRICS:
if metric not in rows:
row = OpsStatConfig(
metric=metric, mode="manual", manual_value=_SEED_VALUE[metric]
)
db.add(row)
rows[metric] = row
created = True
if created:
db.commit()
return rows
# ===== random 惰性 tick(北京钟点对齐)=====
def _boundary_index(epoch_utc: int, interval: int, anchor_sec: int) -> int:
"""该 UTC 时刻落在第几个「北京钟点边界」。边界 = 北京时间 (anchor + k*interval)。"""
bj = epoch_utc + _BEIJING_OFFSET
return (bj - anchor_sec) // interval
def _current_target(db: Session, row: OpsStatConfig) -> int:
"""某模式此刻「应有的展示值」(用于播种 / real·manual 的定时快照)。"""
if row.mode == "manual":
return max(0, int(row.manual_value or 0))
if row.mode == "real":
# 真实值 + 基数偏移
return max(0, int(_real_value(db, row.metric)) + int(row.real_offset or 0))
# random:无显式初始基数时用真实值(纯真实,不含偏移)播种
return max(0, int(_real_value(db, row.metric)))
def _monotonic(row: OpsStatConfig, new_val: int) -> int:
"""只增不减护栏:未开 allow_decrease 且已有展示值时,新值不得低于当前值(否则保持当前)。"""
if not row.allow_decrease and row.random_current is not None and new_val < row.random_current:
return row.random_current
return new_val
def _grow(row: OpsStatConfig, val: int, periods: int) -> int:
"""random 自增长:按 random_kind 走 periods 个周期(add=逐周期加随机增量 / mult=逐周期乘随机倍率)。"""
if row.random_kind == "add":
lo = max(0, row.random_step_min)
hi = max(lo, row.random_step_max)
for _ in range(periods):
val += random.randint(lo, hi)
else:
lo = max(_MULT_FLOOR, row.random_mult_min)
hi = max(lo, row.random_mult_max)
for _ in range(periods):
val = val * random.randint(lo, hi) // 1000
return val
def _refresh(db: Session, row: OpsStatConfig) -> int:
"""统一「定时刷新」:展示值 random_current 只在跨过「北京钟点边界」时刷新一次。
real重新快照(真实+偏移,受只增不减护栏)/ manual取固定值(受护栏)/ random按方式增长
无初值则按模式播种返回当前展示值(基础单位)
"""
now = datetime.now(timezone.utc)
if row.random_current is None or row.random_last_tick_at is None:
row.random_current = _current_target(db, row)
row.random_last_tick_at = now
db.commit()
return row.random_current
interval = max(_TICK_MIN_SECONDS, row.random_tick_seconds)
anchor_sec = (max(0, row.random_anchor_minutes or 0) * 60) % interval
last = _as_utc(row.random_last_tick_at)
periods = _boundary_index(int(now.timestamp()), interval, anchor_sec) - _boundary_index(
int(last.timestamp()), interval, anchor_sec
)
if periods <= 0:
return row.random_current
if row.mode == "random":
row.random_current = _grow(row, row.random_current, min(periods, _MAX_CATCHUP_PERIODS))
else:
# real / manual:跨多少边界都只取「此刻应有的值」快照一次,经只增不减护栏
row.random_current = _monotonic(row, _current_target(db, row))
row.random_last_tick_at = now # 用边界索引比较,直接记 now 不会重复计
db.commit()
return row.random_current
# ===== 用户侧:展示值 =====
def get_display_values(db: Session) -> dict[str, int]:
"""返回 {metric: 展示整数值}(total_saved 为分)。三模式统一走定时刷新。"""
rows = _ensure_rows(db)
return {metric: int(_refresh(db, rows[metric])) for metric in METRICS}
# ===== 运营侧:配置读写 =====
def _snapshot(row: OpsStatConfig) -> dict:
"""审计 / 返回用的行快照。"""
return {
"mode": row.mode,
"manual_value": row.manual_value,
"random_mult_min": row.random_mult_min,
"random_mult_max": row.random_mult_max,
"random_tick_seconds": row.random_tick_seconds,
"random_anchor_minutes": row.random_anchor_minutes,
"random_kind": row.random_kind,
"random_step_min": row.random_step_min,
"random_step_max": row.random_step_max,
"real_offset": row.real_offset,
"allow_decrease": row.allow_decrease,
"random_current": row.random_current,
"random_last_tick_at": (
row.random_last_tick_at.isoformat() if row.random_last_tick_at else None
),
}
def get_config(db: Session) -> list[dict]:
"""三指标当前配置 + 真实值预览(给运营对比),按 METRICS 顺序。
先跑一次定时刷新,使当前展示值在后台轮询时也随钟点实时推进
"""
rows = _ensure_rows(db)
out: list[dict] = []
for metric in METRICS:
row = rows[metric]
_refresh(db, row)
label, unit = _META[metric]
out.append(
{
"metric": metric,
"label": label,
"unit": unit,
"real_value": _real_value(db, metric), # 纯真实(不含偏移),供运营对比
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
**_snapshot(row),
}
)
return out
def update_config(
db: Session,
metric: str,
*,
mode: str | None = None,
manual_value: int | None = None,
random_mult_min: int | None = None,
random_mult_max: int | None = None,
random_tick_seconds: int | None = None,
random_anchor_minutes: int | None = None,
random_kind: str | None = None,
random_step_min: int | None = None,
random_step_max: int | None = None,
real_offset: int | None = None,
allow_decrease: bool | None = None,
random_initial: int | None = None,
apply_now: bool = False,
admin_id: int,
commit: bool = True,
) -> tuple[dict, dict]:
"""改某指标配置。返回 (before, after) 快照供审计。非法入参抛 ValueError(router 转 400)。
展示值 random_current 采用统一定时刷新:平时不动,到更新钟点才按模式刷新
本函数只在首次无值时按模式播种;给了 random_initial 则立即设为该值并重置;
apply_now=True 则立即刷新一次(random 走一档增长,real/manual 取应有值,均经只增不减护栏)
"""
if metric not in METRICS:
raise ValueError(f"未知指标: {metric}")
rows = _ensure_rows(db)
row = rows[metric]
before = _snapshot(row)
if mode is not None:
if mode not in ("real", "manual", "random"):
raise ValueError("mode 需为 real/manual/random")
row.mode = mode
if manual_value is not None:
if manual_value < 0:
raise ValueError("manual_value 需为非负整数")
row.manual_value = manual_value
if random_mult_min is not None:
if not (_MULT_FLOOR <= random_mult_min <= _MULT_CAP):
raise ValueError(f"倍率下限(千分比)需在 {_MULT_FLOOR}~{_MULT_CAP}")
row.random_mult_min = random_mult_min
if random_mult_max is not None:
if not (_MULT_FLOOR <= random_mult_max <= _MULT_CAP):
raise ValueError(f"倍率上限(千分比)需在 {_MULT_FLOOR}~{_MULT_CAP}")
row.random_mult_max = random_mult_max
if row.random_mult_max < row.random_mult_min:
raise ValueError("倍率上限不得小于下限")
if random_tick_seconds is not None:
if random_tick_seconds < _TICK_MIN_SECONDS:
raise ValueError(f"更新间隔不得小于 {_TICK_MIN_SECONDS}")
row.random_tick_seconds = random_tick_seconds
if random_anchor_minutes is not None:
if not (0 <= random_anchor_minutes < 1440):
raise ValueError("更新时间需在 00:00~23:59 之间")
row.random_anchor_minutes = random_anchor_minutes
if random_kind is not None:
if random_kind not in ("mult", "add"):
raise ValueError("增长方式需为 mult/add")
row.random_kind = random_kind
if random_step_min is not None:
if not (0 <= random_step_min <= _STEP_CAP):
raise ValueError("增量下限需为非负整数")
row.random_step_min = random_step_min
if random_step_max is not None:
if not (0 <= random_step_max <= _STEP_CAP):
raise ValueError("增量上限需为非负整数")
row.random_step_max = random_step_max
if row.random_step_max < row.random_step_min:
raise ValueError("增量上限不得小于下限")
if real_offset is not None:
if real_offset < 0:
raise ValueError("基数偏移需为非负整数")
row.real_offset = real_offset
if allow_decrease is not None:
row.allow_decrease = allow_decrease
now = datetime.now(timezone.utc)
if random_initial is not None:
if random_initial < 0:
raise ValueError("初始基数需为非负整数")
row.random_current = random_initial # 设起点是运营明确意图,不受护栏约束
row.random_last_tick_at = now
elif apply_now:
# 立即更新:不等钟点,马上刷新一次
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_last_tick_at = now
elif row.random_current is None:
# 首次无值:按当前模式播种,使配置后立即有合理展示值
row.random_current = _current_target(db, row)
row.random_last_tick_at = now
row.updated_by_admin_id = admin_id
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
return before, _snapshot(row)
+86
View File
@@ -0,0 +1,86 @@
"""价格观测落库:一次比价的整批观测幂等写入。
幂等键 = (trace_id, platform, scope)pricebot 重试 / replay 会重复上报同一 trace,
这里先查该 trace 已存在的 (platform, scope),只插新的跨方言(PG / SQLite dev)都安全,
不依赖 ON CONFLICT极端并发下若仍撞唯一约束,IntegrityError 回滚后忽略(本批本就幂等)
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.price_observation import PriceObservation
from app.schemas.price_observation import PriceObservationBatchIn
logger = logging.getLogger("shagua.price_obs")
def insert_batch(db: Session, payload: PriceObservationBatchIn) -> tuple[int, int]:
"""写入一次比价的整批观测,返回 (inserted, skipped)。
skipped = (trace_id, platform, scope) 已存在而幂等跳过的条数
"""
if not payload.observations:
return 0, 0
# 该 trace 已有的 (platform, scope),用于幂等过滤
existing: set[tuple[str, str]] = set(
db.execute(
select(PriceObservation.platform, PriceObservation.scope).where(
PriceObservation.trace_id == payload.trace_id
)
).all()
)
inserted = 0
skipped = 0
for obs in payload.observations:
key = (obs.platform, obs.scope)
if key in existing:
skipped += 1
continue
existing.add(key) # 同批内去重(防 pricebot 同帧给重复平台)
db.add(
PriceObservation(
trace_id=payload.trace_id,
business_type=payload.business_type,
platform=obs.platform,
platform_store_id=obs.platform_store_id,
store_name=payload.store_name,
city=payload.city,
geohash=payload.geohash,
lng=payload.lng,
lat=payload.lat,
is_source=obs.is_source,
scope=obs.scope,
price_cents=obs.price_cents,
coupon_saved_cents=obs.coupon_saved_cents,
coupon_name=obs.coupon_name,
store_closed=obs.store_closed,
rank=obs.rank,
dishes=payload.dishes,
attrs=obs.attrs,
source_device_id=payload.source_device_id,
source_user_id=payload.source_user_id,
)
)
inserted += 1
if inserted == 0:
return 0, skipped
try:
db.commit()
except IntegrityError:
# 并发下另一个请求刚插了同 (trace,platform,scope) → 唯一约束撞了。
# 本批本就幂等,回滚后当作全跳过(不重试,失败就失败,见 P0 约定)。
db.rollback()
logger.warning(
"price_observation 并发幂等冲突 trace=%s,本批回滚跳过", payload.trace_id
)
return 0, skipped + inserted
return inserted, skipped
+4
View File
@@ -1,9 +1,12 @@
"""price_report 表读写。"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.price_report import PriceReport
@@ -35,6 +38,7 @@ def create_report(
reported_price_cents=reported_price_cents,
images=images,
status="pending",
created_at=datetime.now(CN_TZ).replace(tzinfo=None), # 存北京 wall-clock(同 savings/comparison)
)
db.add(rep)
db.commit()
+32 -108
View File
@@ -1,14 +1,17 @@
"""省钱 CRUD:演示数据 seeder + 累计/战绩聚合 + 明细分页。
"""省钱 CRUD:累计/战绩聚合 + 明细分页(纯真实比价上报数据)
设计:聚合逻辑都是"生产级"真实计算(SUM按日分组连续天数),只是数据本期来自
demo seeder等比价真接入后停掉 seeder改成上报写入,这些聚合无需改
设计:聚合逻辑都是"生产级"真实计算(SUM按日分组连续天数),数据只来自真实比价
下单上报(`source='compare'`, create_from_report)
2026-06 **停用 demo 兜底**:比价下单上报链路已通(Android `PriceBotService`
`POST /api/v1/order/report`),新用户无真实记录时直接显示空/0(配合我的页省钱战绩
卡的未比价锁定态)历史遗留的 `source='demo'` 行不再被任何统计口径计入
聚合在 Python 里算(每用户记录量很小),避免 SQLite 跨时区 date 比较的坑
created_at 带时区,统一转成北京时间的 date 再分组
"""
from __future__ import annotations
import random
from dataclasses import dataclass
from datetime import datetime, timedelta
@@ -19,26 +22,6 @@ from app.core.rewards import CN_TZ, cn_today
from app.models.savings import SavingsRecord
from app.schemas.order import OrderReportRequest
# 演示数据规模:最近 N 天每天 1 单(撑起"连续省钱"和"本周"),再补若干历史单
_DEMO_STREAK_DAYS = 9
_DEMO_EXTRA_ORDERS = 14
# 外卖订单演示模板:(平台, 店铺名, 菜品列表)。
# 平台只用有 logo 的三家(美团外卖/淘宝闪购/京东外卖),保证客户端 logo 命中;
# 菜品前 2 道直接展示,其余进「还有 N 道菜」展开。
_DEMO_ORDERS: list[tuple[str, str, list[str]]] = [
("美团外卖", "窑鸡王(王府井店)", ["招牌窑鸡(整只)", "凉拌黄瓜", "蒜蓉花甲", "紫菜蛋花汤"]),
("淘宝闪购", "海底捞外卖(2人餐)", ["川辣牛肉锅", "嫩牛肉", "海底捞自制饮料", "鸭血", "菌菇拼盘", "鲜虾滑", "麻辣牛百叶"]),
("京东外卖", "喜茶(国贸店)", ["多肉葡萄", "烤黑糖波波牛乳"]),
("美团外卖", "麦当劳(朝阳大悦城店)", ["巨无霸套餐", "麦麦脆汁鸡", "薯条(大)"]),
("淘宝闪购", "7-ELEVEn(建外SOHO店)", ["关东煮", "饭团", "北海道牛乳"]),
("美团外卖", "蜜雪冰城(三里屯店)", ["冰鲜柠檬水", "摩天脆脆筒"]),
("京东外卖", "西贝莜面村(凯德MALL店)", ["西贝莜面", "黄馍馍", "牛大骨", "沙棘汁"]),
("美团外卖", "瑞幸咖啡(CBD店)", ["生椰拿铁", "厚乳拿铁"]),
("淘宝闪购", "肯德基(西单店)", ["香辣鸡腿堡", "黄金鸡块", "可乐(中)"]),
("京东外卖", "星巴克(华贸店)", ["燕麦拿铁", "提拉米苏"]),
]
@dataclass
class SavingsSummary:
@@ -52,7 +35,7 @@ class SavingsBattle:
week_saved_cents: int # 本周(周一起)已省
beat_percent: int # 超过百分之多少用户
streak_days: int # 连续省钱天数
compare_count: int # 累计完成比价次数(= 有效记录数:真实 compare 记录、否则 demo 兜底)
compare_count: int # 累计完成比价次数(= 真实 compare 上报记录数)
def _local_date(dt: datetime):
@@ -62,75 +45,20 @@ def _local_date(dt: datetime):
return dt.astimezone(CN_TZ).date()
def _all_records(db: Session, user_id: int) -> list[SavingsRecord]:
stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id)
def _compare_records(db: Session, user_id: int) -> list[SavingsRecord]:
"""统计/明细口径:只用真实比价上报(`source='compare'`)记录。
2026-06 起停用 demo 兜底:无真实记录直接返回空(新用户显示 0/锁定态)
历史 `source='demo'` 行不计入
"""
stmt = select(SavingsRecord).where(
SavingsRecord.user_id == user_id, SavingsRecord.source == "compare"
)
return list(db.execute(stmt).scalars().all())
def _has_real(db: Session, user_id: int) -> bool:
"""该用户是否已有真实比价上报(source='compare')记录。"""
return db.execute(
select(SavingsRecord.id)
.where(SavingsRecord.user_id == user_id, SavingsRecord.source == "compare")
.limit(1)
).first() is not None
def _effective_records(db: Session, user_id: int) -> list[SavingsRecord]:
"""统计口径:有真实(compare)记录就只用真实的;否则 seed 一批 demo 并用 demo 兜底。"""
if _has_real(db, user_id):
stmt = select(SavingsRecord).where(
SavingsRecord.user_id == user_id, SavingsRecord.source == "compare"
)
return list(db.execute(stmt).scalars().all())
ensure_seeded(db, user_id)
return _all_records(db, user_id)
def ensure_seeded(db: Session, user_id: int) -> None:
"""该用户没有任何省钱记录时,幂等灌一批 demo 数据。"""
exists = db.execute(
select(SavingsRecord.id).where(SavingsRecord.user_id == user_id).limit(1)
).first()
if exists is not None:
return
rng = random.Random(user_id) # 按 user_id 播种,保证同一用户每次结果一致
today = cn_today()
records: list[SavingsRecord] = []
def _mk(day) -> SavingsRecord:
hour = rng.randint(8, 21)
minute = rng.randint(0, 59)
ts = datetime(day.year, day.month, day.day, hour, minute, tzinfo=CN_TZ)
platform, shop, dishes = rng.choice(_DEMO_ORDERS)
# 三成订单设为"未省"(saved=0),对齐原型里有省/没省两种卡
saved = 0 if rng.random() < 0.3 else rng.randint(300, 4000) # 0 或 3~40 元
return SavingsRecord(
user_id=user_id,
order_amount_cents=rng.randint(1500, 12000), # 15~120 元(到手价)
saved_amount_cents=saved,
platform=platform,
title=shop,
shop_name=shop,
dishes=dishes,
source="demo",
created_at=ts,
)
# 最近 N 天每天 1 单 → 连续省钱 N 天
for d in range(_DEMO_STREAK_DAYS):
records.append(_mk(today - timedelta(days=d)))
# 历史散单(第 10~40 天)
for _ in range(_DEMO_EXTRA_ORDERS):
records.append(_mk(today - timedelta(days=rng.randint(10, 40))))
db.add_all(records)
db.commit()
def get_summary(db: Session, user_id: int) -> SavingsSummary:
records = _effective_records(db, user_id)
records = _compare_records(db, user_id)
total = sum(r.saved_amount_cents for r in records)
count = len(records)
avg = total // count if count else 0
@@ -150,7 +78,7 @@ def _streak_days(dates: set) -> int:
def get_battle(db: Session, user_id: int) -> SavingsBattle:
records = _effective_records(db, user_id)
records = _compare_records(db, user_id)
today = cn_today()
week_start = today - timedelta(days=today.weekday()) # 本周一
@@ -172,7 +100,7 @@ def get_battle(db: Session, user_id: int) -> SavingsBattle:
def _compute_beat_percent(db: Session, user_id: int) -> int:
"""超过百分之多少用户:按"累计省下金额"在所有有省钱记录的用户中做真实分位。
"""超过百分之多少用户:按"累计省下金额"在所有有真实(compare)记录的用户中做真实分位。
= 严格少于我的其他用户数 / 其他用户总数 * 100
全部省得比我少 100;只有自己一个用户 0(无可比)
@@ -180,18 +108,12 @@ def _compute_beat_percent(db: Session, user_id: int) -> int:
rows = db.execute(
select(
SavingsRecord.user_id,
SavingsRecord.source,
func.sum(SavingsRecord.saved_amount_cents),
).group_by(SavingsRecord.user_id, SavingsRecord.source)
)
.where(SavingsRecord.source == "compare")
.group_by(SavingsRecord.user_id)
).all()
# 每用户口径与展示一致:有 compare 用 compare 之和,否则退回 demo 之和
by_user: dict[int, dict[str, int]] = {}
for uid, source, total in rows:
by_user.setdefault(uid, {})[source] = total or 0
totals = {
uid: (d["compare"] if "compare" in d else d.get("demo", 0))
for uid, d in by_user.items()
}
totals = {uid: (total or 0) for uid, total in rows}
others = {uid: t for uid, t in totals.items() if uid != user_id}
if not others:
return 0
@@ -207,12 +129,10 @@ def list_records(
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[SavingsRecord], int | None]:
"""省钱明细分页(按 id 倒序,游标式)。有真实(compare)记录只列真实,否则 demo 兜底"""
stmt = select(SavingsRecord).where(SavingsRecord.user_id == user_id)
if _has_real(db, user_id):
stmt = stmt.where(SavingsRecord.source == "compare")
else:
ensure_seeded(db, user_id)
"""省钱明细分页(按 id 倒序,游标式)。只列真实比价上报(`source='compare'`)记录"""
stmt = select(SavingsRecord).where(
SavingsRecord.user_id == user_id, SavingsRecord.source == "compare"
)
if cursor is not None:
stmt = stmt.where(SavingsRecord.id < cursor)
stmt = stmt.order_by(SavingsRecord.id.desc()).limit(limit)
@@ -259,6 +179,10 @@ def create_from_report(
client_event_id=req.client_event_id,
device_id=req.device_id,
source="compare",
# created_at 显式存 naive 北京 wall-clock(与 demo 行、聚合 _local_date 的 naive 分支一致)。
# 不用列默认 server_default=func.now()——SQLite 下它返回 UTC,会让明细页时间早 8 小时。
# ⚠️ 生产 PG(timestamptz)对 naive 的解释与 SQLite 不同,迁前端到 PG 时需确认时区一致。
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
)
db.add(rec)
db.commit()
+85 -6
View File
@@ -1,7 +1,7 @@
"""签到 CRUD:状态查询 + 执行签到。
7 天循环规则:
- 昨天签过 今天 cycle_day = 昨天 % 7 + 1,streak += 1(连续)
14 天循环规则:
- 昨天签过 今天 cycle_day = 昨天 % 14 + 1,streak += 1(连续)
- 否则(首签 / 断签) cycle_day = 1,streak = 1(重置)
今天的 cycle_day 决定发多少金币(档位来自 rewards.get_signin_rewards,运营后台可改)
"""
@@ -11,11 +11,12 @@ from dataclasses import dataclass
from datetime import timedelta
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import rewards
from app.core.rewards import SIGNIN_CYCLE_LEN, cn_today
from app.models.signin import SigninRecord
from app.models.signin import SigninBoostRecord, SigninRecord
from app.repositories import wallet as crud_wallet
@@ -23,9 +24,21 @@ class AlreadySignedError(Exception):
"""今天已经签过了。"""
class NotSignedTodayError(Exception):
"""今天尚未签到,不能膨胀。"""
class AlreadyBoostedError(Exception):
"""今天签到奖励已经膨胀过。"""
class LastCycleDayBoostBlockedError(Exception):
"""14 天循环最后一天不允许签到膨胀。"""
@dataclass
class SigninStep:
day: int # 1..7
day: int # 1..14
coin: int
status: str # claimed / today / locked
@@ -34,7 +47,7 @@ class SigninStep:
class SigninStatus:
today_signed: bool
consecutive_days: int # 当前已确认的连续签到天数
today_cycle_day: int # 今天落在循环的第几档(1..7)
today_cycle_day: int # 今天落在循环的第几档(1..14)
today_coin: int # 今天这一档的金币
can_claim: bool # 现在能否签到(= not today_signed)
steps: list[SigninStep]
@@ -51,7 +64,7 @@ def _latest_record(db: Session, user_id: int) -> SigninRecord | None:
def _next_cycle_day(last: SigninRecord | None, today) -> int:
"""若现在签到,今天会落在第几档(1..7)。"""
"""若现在签到,今天会落在第几档(1..14)。"""
if last is not None and last.signin_date == today - timedelta(days=1):
return last.cycle_day % SIGNIN_CYCLE_LEN + 1
return 1
@@ -127,3 +140,69 @@ def do_signin(db: Session, user_id: int) -> tuple[SigninRecord, int]:
db.commit()
db.refresh(record)
return record, acc.coin_balance
def _today_record(db: Session, user_id: int) -> SigninRecord | None:
today = cn_today()
return db.execute(
select(SigninRecord).where(
SigninRecord.user_id == user_id,
SigninRecord.signin_date == today,
)
).scalar_one_or_none()
def boost_by_ad_ref(
db: Session, user_id: int, ad_ref_id: str
) -> SigninBoostRecord | None:
"""按广告交易号查签到膨胀记录。S2S 发奖后客户端确认用。"""
return db.execute(
select(SigninBoostRecord).where(
SigninBoostRecord.user_id == user_id,
SigninBoostRecord.ad_ref_id == ad_ref_id,
)
).scalar_one_or_none()
def boost_today_signin(
db: Session, user_id: int, *, ad_ref_id: str | None = None, commit: bool = True
) -> tuple[SigninBoostRecord, int]:
"""签到后看广告膨胀:固定补发配置金币。返回 (膨胀记录, 补发后余额)。"""
record = _today_record(db, user_id)
if record is None:
raise NotSignedTodayError
if record.cycle_day == SIGNIN_CYCLE_LEN:
raise LastCycleDayBoostBlockedError
today = record.signin_date
existing = db.execute(
select(SigninBoostRecord).where(
SigninBoostRecord.user_id == user_id,
SigninBoostRecord.signin_date == today,
)
).scalar_one_or_none()
if existing is not None:
raise AlreadyBoostedError
boost = SigninBoostRecord(
user_id=user_id,
signin_date=today,
coin_awarded=rewards.get_signin_boost_coin(db),
ad_ref_id=ad_ref_id,
)
db.add(boost)
try:
acc, _ = crud_wallet.grant_coins(
db, user_id, boost.coin_awarded,
biz_type="signin_boost", ref_id=ad_ref_id or today.isoformat(),
remark=f"签到膨胀 第{record.cycle_day}",
)
if commit:
db.commit()
else:
db.flush()
except IntegrityError as e:
db.rollback()
raise AlreadyBoostedError from e
db.refresh(boost)
return boost, acc.coin_balance
+84 -3
View File
@@ -12,6 +12,7 @@ import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import rewards
@@ -31,6 +32,7 @@ from app.models.wallet import (
_WX_STATE_SUCCESS = "SUCCESS"
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认
_WITHDRAW_ACTIVE_STATUSES = {"reviewing", "pending"}
# 免确认收款授权状态
_WX_AUTH_ACTIVE = "TAKING_EFFECT" # 已生效,可免确认转账
_WX_AUTH_CLOSED = "CLOSED" # 已关闭(用户/商户/风控),需重新开启
@@ -60,6 +62,10 @@ class InsufficientCashError(Exception):
"""现金余额不足。"""
class WithdrawTooFrequentError(Exception):
"""提现申请过于频繁,或已有未完成提现单。"""
class WithdrawTransferError(Exception):
"""调用微信转账失败(已退回余额)。"""
@@ -116,6 +122,7 @@ def grant_coins(
biz_type=biz_type,
ref_id=ref_id,
remark=remark,
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), # 存北京 wall-clock(客户端原样切片显示)
)
db.add(txn)
db.flush()
@@ -173,6 +180,7 @@ def exchange_coins_to_cash(
balance_after_cents=acc.cash_balance_cents,
biz_type="exchange_in",
remark=remark,
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
)
)
db.commit()
@@ -281,6 +289,18 @@ def _refund_withdraw(
"""
if order.status in ("failed", "rejected"):
return # 防重复退款(并发/对账与查单/重复拒绝同时触发)
refunded_txn_id = db.execute(
select(CashTransaction.id).where(
CashTransaction.user_id == order.user_id,
CashTransaction.biz_type == "withdraw_refund",
CashTransaction.ref_id == order.out_bill_no,
).limit(1)
).scalar_one_or_none()
if refunded_txn_id is not None:
order.status = final_status
order.fail_reason = reason[:256]
db.commit()
return
bal = _add_cash(db, order.user_id, order.amount_cents)
db.add(
CashTransaction(
@@ -291,11 +311,35 @@ def _refund_withdraw(
ref_id=order.out_bill_no,
# 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason
remark="提现审核未通过,金额已退回" if final_status == "rejected" else "提现未成功,金额已退回",
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
)
)
order.status = final_status
order.fail_reason = reason[:256]
db.commit()
out_bill_no = order.out_bill_no
user_id = order.user_id
try:
db.commit()
except IntegrityError:
# 并发退款兜底:唯一退款流水已被另一事务写入时,回滚本事务的加钱和流水,
# 再把订单状态补到终态。这样无论拒绝/查单/对账怎么并发,现金最多退一次。
db.rollback()
refunded_txn_id = db.execute(
select(CashTransaction.id).where(
CashTransaction.user_id == user_id,
CashTransaction.biz_type == "withdraw_refund",
CashTransaction.ref_id == out_bill_no,
).limit(1)
).scalar_one_or_none()
if refunded_txn_id is None:
raise
fresh_order = db.execute(
select(WithdrawOrder).where(WithdrawOrder.out_bill_no == out_bill_no)
).scalar_one_or_none()
if fresh_order is not None and fresh_order.status not in ("failed", "rejected"):
fresh_order.status = final_status
fresh_order.fail_reason = reason[:256]
db.commit()
def _wx_not_found(result: dict) -> bool:
@@ -382,6 +426,15 @@ def create_withdraw(
else:
out_bill_no = uuid.uuid4().hex
active_order_id = db.execute(
select(WithdrawOrder.id).where(
WithdrawOrder.user_id == user_id,
WithdrawOrder.status.in_(_WITHDRAW_ACTIVE_STATUSES),
).limit(1)
).scalar_one_or_none()
if active_order_id is not None:
raise WithdrawTooFrequentError
# 账户须存在(原子扣款的 UPDATE 不会建账户)
get_or_create_account(db, user_id, commit=True)
@@ -401,6 +454,7 @@ def create_withdraw(
biz_type="withdraw",
ref_id=out_bill_no,
remark="提现到微信零钱(待审核)",
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
)
)
order = WithdrawOrder(
@@ -411,7 +465,26 @@ def create_withdraw(
status="reviewing",
)
db.add(order)
db.commit()
try:
db.commit()
except IntegrityError:
db.rollback()
existing = db.execute(
select(WithdrawOrder).where(
WithdrawOrder.out_bill_no == out_bill_no, WithdrawOrder.user_id == user_id
)
).scalar_one_or_none()
if existing is not None:
return existing
active_order_id = db.execute(
select(WithdrawOrder.id).where(
WithdrawOrder.user_id == user_id,
WithdrawOrder.status.in_(_WITHDRAW_ACTIVE_STATUSES),
).limit(1)
).scalar_one_or_none()
if active_order_id is not None:
raise WithdrawTooFrequentError from None
raise
db.refresh(order)
return order # 待管理员审核;**不在此处打款**
@@ -699,7 +772,15 @@ def refresh_withdraw_status(
if order.status != "pending":
return order # 已终态,不再查
result = wxpay.query_transfer(out_bill_no)
try:
result = wxpay.query_transfer(out_bill_no)
except wxpay.WxPayNotConfiguredError:
raise
except Exception as exc: # noqa: BLE001 - 查单失败不能把运营后台打成 500
order.fail_reason = f"微信查单异常,保持pending: {exc}"[:256]
db.commit()
db.refresh(order)
return order
if result["status_code"] != 200:
if _wx_not_found(result):
# 微信明确无此单 → 转账从未创建(如崩溃在扣款后/调用前),退款安全
+54 -10
View File
@@ -28,29 +28,33 @@ class AdRewardStatusOut(BaseModel):
used_today: int = Field(..., description="今日已成功发奖次数")
daily_limit: int = Field(..., description="每日发奖次数上限")
remaining: int = Field(..., description="今日剩余可领次数")
coin_per_ad: int = Field(..., description="看完一个激励视频发的金币")
coin_per_ad: int = Field(..., description="历史兼容字段;正式发放按 eCPM 动态计算,当前返回 0")
round_count: int = Field(
..., description="本轮(3 次一组)已看次数,0..N-1。客户端可由它判断'刚刚看完一轮'"
..., description="本轮已看次数,当前 round_size=1,客户端可由 cooldown_until 判断短冷却"
)
cooldown_until: datetime | None = Field(
None,
description="本轮看完后 10 分钟冷却结束时间(UTC ISO),null 表示不在冷却。"
description="广告关闭后 3 秒短冷却结束时间(UTC ISO),null 表示不在冷却。"
"冷却是 UX 约束,后端发奖不受影响,客户端 CTA 据此显示 MM:SS 倒计时且不可点",
)
# ===== 看广告每日总时长(50 分钟防刷主闸):客户端据此展示"今日已看 X/50 分钟"、满则不给看 =====
# ===== 旧版每日观看总时长字段:当前产品仅保留每日 500 次上限,limit=0 表示未启用时长闸 =====
watched_seconds_today: int = Field(0, description="今日已观看激励视频总秒数(前端累计上报)")
watch_seconds_limit: int = Field(0, description="每日观看总时长上限(秒,默认 3000=50 分钟)")
watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数(<=0 时客户端不应再展示广告)")
watch_seconds_limit: int = Field(0, description="每日观看总时长上限(秒);0 表示当前未启用")
watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数;limit=0 时客户端不据此拦截")
class EcpmReportIn(BaseModel):
"""客户端上报一次广告展示的 eCPM(内部收益统计/对账)。
user_id 不在 body JWT (Bearer),防伪造ecpm 原样上报字符串(单位待确认)
user_id 不在 body JWT (Bearer),防伪造ecpm 原样上报字符串,后端按分/千次展示处理(SDK getEcpm 原值,非元)
"""
ad_type: str = Field(..., description="广告类型:reward_video(激励视频) / draw(Draw 信息流) 等")
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,单位待确认,原样上报")
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,按分/千次展示处理(SDK getEcpm 原值,非元)")
ad_session_id: str | None = Field(
None, min_length=8, max_length=64,
description="客户端生成的一次广告会话 id;激励视频 S2S extra 会透传同值",
)
adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle")
slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)")
@@ -78,17 +82,57 @@ class WatchReportOut(BaseModel):
watch_seconds_remaining: int = Field(..., description="今日剩余可观看秒数")
class TestGrantIn(BaseModel):
"""[仅本地联调]模拟发奖入参。"""
reward_scene: str = Field(
"reward_video",
description="模拟发奖场景:reward_video(普通激励视频) / signin_boost(签到膨胀)",
)
ad_session_id: str | None = Field(
None, min_length=8, max_length=64,
description="本次广告会话 id(与 ecpm-report 同值)。reward_video 场景下据此查回客户端"
"已上报的真实 eCPM 来按公式发奖;查不到或 eCPM≤0 时兜底 200,保证本地联调仍出非零金币",
)
class TestGrantOut(BaseModel):
"""[仅本地联调]模拟发奖结果。带上今日进度,客户端可直接据此刷新展示。"""
granted: bool = Field(..., description="本次是否真的发了金币(达每日上限则 False)")
status: str = Field(..., description="granted / capped")
status: str = Field(
..., description="granted / capped / not_signed / already_boosted / last_day / unknown_scene"
)
coin: int = Field(..., description="本次发放金币(capped 时为 0)")
used_today: int = Field(..., description="今日已成功发奖次数")
daily_limit: int = Field(..., description="每日发奖次数上限")
remaining: int = Field(..., description="今日剩余可领次数")
coin_per_ad: int = Field(..., description="看完一个激励视频发的金币")
coin_per_ad: int = Field(..., description="历史兼容字段;正式发放按 eCPM 动态计算,当前返回 0")
round_count: int = Field(..., description="本轮已看次数,详见 AdRewardStatusOut")
cooldown_until: datetime | None = Field(
None, description="本轮冷却结束时间(UTC),详见 AdRewardStatusOut"
)
class FeedRewardIn(BaseModel):
"""信息流广告完成后结算奖励。
每展示满 10 秒累计一份奖励,视频完成后一次性入账client_event_id 用于客户端超时重试幂等
"""
client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id")
ad_session_id: str | None = Field(
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
)
ecpm: str = Field(..., description="本条信息流广告 eCPM,按分/千次展示处理(SDK getEcpm 原值,非元)")
duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数")
adn: str | None = Field(None, description="实际投放 ADN")
slot_id: str | None = Field(None, description="实际展示代码位")
class FeedRewardOut(BaseModel):
granted: bool = Field(..., description="本次是否入账。达上限时 false")
status: str = Field(..., description="granted / capped")
coin: int = Field(..., description="本次发放金币")
unit_count: int = Field(..., description="按 10 秒折算出的奖励份数")
daily_limit: int = Field(..., description="每日信息流展示次数上限")
+2
View File
@@ -25,6 +25,8 @@ class UserOut(BaseModel):
status: str
created_at: datetime
last_login_at: datetime
# 调试链接权限:前端据此在比价结果弹窗/记录页显示「复制调试链接」按钮。默认 false。
debug_trace_enabled: bool = False
# ===== Token 通用结构 =====
+12
View File
@@ -67,6 +67,9 @@ class ComparisonRecordIn(BaseModel):
status: str | None = Field(None, description="success / failed,可不传由服务端派生")
# 最优平台商家/商品深链(客户端从 collectedLinks[best_index] 取);「再次比价」直达用
best_deeplink: str | None = Field(None, description="最优平台深链,再次比价直达")
# pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘
# 时分秒前端拼不出,必须由后端透传)。
trace_url: str | None = Field(None, description="本次比价公网调试链接")
# ===== 读取出参 =====
@@ -79,6 +82,8 @@ class ComparisonRecordOut(BaseModel):
id: int
business_type: str
trace_id: str
# 公网调试链接;仅当 user.debug_trace_enabled 时由端点填充,否则端点层置 None(权限闸)。
trace_url: str | None = None
source_platform_id: str | None = None
source_platform_name: str | None = None
source_package: str | None = None
@@ -118,6 +123,13 @@ class ComparisonRecordCreatedOut(BaseModel):
id: int = Field(..., description="写入(或已存在)的记录 id")
class CompareStatsOut(BaseModel):
"""「我的」页省钱战绩卡(比价口径)聚合。"""
compare_count: int = Field(..., description="累计完成比价次数(status='success' 记录数)")
discovered_saved_cents: int = Field(..., description="累计发现可省额(分;各成功比价 源价−最低价 之和)")
# ===== 比价战绩里程碑(福利页「记录比价战绩」)=====
class MilestoneStateOut(BaseModel):
+27
View File
@@ -0,0 +1,27 @@
"""领券今日状态端点的收发模型。"""
from __future__ import annotations
from pydantic import BaseModel
class CouponPromptDismissIn(BaseModel):
"""客户端拒绝/关闭领券引导窗的通知体。
server 据此记一条今日 engagement(dismissed) 今天这台设备不再弹引导窗
MVP 不鉴权, device_id 判断;user_id 登录态带上就一并记(资产),可空
"""
device_id: str
user_id: int | None = None
class CouponPromptShouldShowOut(BaseModel):
"""切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。"""
should_show: bool
class CouponCompletedTodayOut(BaseModel):
"""这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。"""
completed: bool
+73
View File
@@ -0,0 +1,73 @@
"""邀请相关 API 收发模型。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class InviteInfoOut(BaseModel):
invite_code: str # 我的邀请码
share_url: str # 落地页链接(含 ?ref=),前端据此生成二维码 + 复制分享
invited_count: int # 已成功邀请人数
coins_earned: int # 累计从邀请获得的金币
class LandingTrackIn(BaseModel):
"""落地页 dl.html 访问时上报的指纹(用于剪贴板归因失败时兜底)。"""
ref: str = Field(..., min_length=4, max_length=16, description="邀请码(落地页 ?ref=)")
screen: str = Field("", max_length=32, description="屏幕分辨率,如 1080x2400")
# IP / UA 服务端从 HTTP 头自动拿,无需 JS 上报
class LandingTrackOut(BaseModel):
status: str # ok / invalid_code / no_ip
class FingerprintIn(BaseModel):
"""客户端登录后剪贴板归因失败时上报的设备指纹。
跨端匹配字段:浏览器落地页存的同名字段 == 客户端 Build.MODEL / DisplayMetrics 算的值
IP 服务端从 HTTP 头拿,不在这里
"""
screen: str = Field("", max_length=32)
device_model: str = Field("", max_length=64)
class BindInviteIn(BaseModel):
# invite_code: 可选 — 走指纹兜底时为空(channel=fingerprint)
invite_code: str | None = Field(
None, max_length=16, description="邀请码;走指纹兜底时为空"
)
channel: str = Field(
"clipboard", max_length=16,
description="归因来源:clipboard(首启读剪贴板自动) / manual(手动填) / fingerprint(指纹兜底)",
)
# 当 invite_code 为空、channel=fingerprint 时,后端用这组指纹反查
fingerprint: FingerprintIn | None = None
class BindInviteOut(BaseModel):
status: str # success / already_bound / invalid_code / self_invite / not_eligible / fp_not_found
coins_awarded: int # 本次给当前用户(被邀请人)发的金币
message: str # 给前端直接展示的文案
class InviteeItem(BaseModel):
"""被邀请人列表的一条(邀请页小窗 / 完整列表页共用)。
display_name / avatar_url "降级兜底"在后端算好( repositories/invite.get_invitees):
名字 = 昵称 微信昵称 脱敏手机号(前端拿不到完整号,必须后端脱敏);
头像 = 用户头像 微信头像 null(前端拿到 null 画默认色块)
"""
display_name: str # 已兜底好的显示名(真名/脱敏号)
avatar_url: str | None = None # 头像 URL;null = 前端画默认色块
coins: int # 这次邀请给我(邀请人)发的金币
invited_at: datetime # 邀请绑定时间(前端转"今天/3天前")
class InviteeListOut(BaseModel):
"""GET /invite/invitees 响应:被邀请人列表 + 分页。"""
items: list[InviteeItem]
total: int # 我邀请的总人数(用于"共 N 人")
has_more: bool # 还有下一页吗(完整列表页滚到底加载更多)
+13
View File
@@ -134,6 +134,12 @@ class FeedRequest(BaseModel):
latitude: float = Field(..., description="纬度")
page: int = Field(1, ge=1)
page_size: int = Field(20, ge=1, le=20)
# 筛选/排序口径,后端据此处理后返回(前端不再自己筛/排):
# rec = 智能推荐:榜单混合 feed 去掉佣金率 < 3%(分页)
# distance = 距离最近:拉齐全部轮次后全局按距离由近及远(一次性返回, has_next=False)
# 留空/其它 = 原混合 feed 不筛(老客户端兼容,新 app 会显式传 tab)
# (销量最高 sales 走 /coupons 同城热销,不在本接口)
tab: str = Field("", description="rec 智能推荐 / distance 距离最近 / 空=不筛(兼容)")
class FeedResponse(BaseModel):
@@ -142,6 +148,13 @@ class FeedResponse(BaseModel):
page: int = 1
class TopSalesRequest(BaseModel):
"""销量最高 tab:从离线库 meituan_coupon 按销量降序取(不实时打美团)。"""
page: int = Field(1, ge=1)
page_size: int = Field(20, ge=1, le=50)
platform: int | None = Field(None, description="可选: 1只外卖 / 2只到店; 不填=全部(全城销量)")
# ───────────────── 换链 请求 / 响应 ─────────────────
class ReferralLinkRequest(BaseModel):
+24
View File
@@ -0,0 +1,24 @@
"""首页平台级展示数据 schemas(客户端首页门面数字)。"""
from __future__ import annotations
from pydantic import BaseModel
class PlatformStatsOut(BaseModel):
"""首页三统计。total_saved 为分,客户端 ÷100 显示「元」。"""
help_users: int # 帮助用户(人)
total_compares: int # 完成比价(次)
total_saved_cents: int # 累计节省(分)
class SavingsFeedItem(BaseModel):
"""首页轮播一条。time 为北京时间 HH:MM:SS。"""
masked_user: str # 脱敏用户名,如「用户********a52」
saved_amount_cents: int # 节省(分),客户端 ÷100 显示「x.xx 元」
time: str
class SavingsFeedOut(BaseModel):
items: list[SavingsFeedItem]

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