Compare commits

..

58 Commits

Author SHA1 Message Date
zzhyyyyy 7250682dc5 feat(auth): 测试号下发 force_onboarding,驱动应用内重登也重走引导
新增 TokenWithUser.force_onboarding(仅测试号置 true)。配合客户端:让测试号在
「退出后应用内重新登录」也重走新手引导;普通号 force_onboarding=false、按原逻辑回原页。
与 onboarding_completed 分工:后者管 App 启动路由,本字段管应用内登录后的去向。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:07:23 +08:00
zzhyyyyy cc37905509 feat(auth): 测试账号免验证码登录 + 每次走引导 + 每日上限
为打通 release 包全流程(无 SIM 卡 / 不走极光一键登录)新增一个固定测试手机号:
- 该号 /sms/send 不真发、/sms/login 跳过验证码校验(real 模式同样生效),填任意码即可登入;
- 登录响应 onboarding_completed 恒为 false,每次都重走新手引导(不读/不写完成表);
- 每日登录次数上限(默认 500),超过当天拒绝(429),防被人猜到号后脚本刷。

手机号与上限都在 .env 配(TEST_ACCOUNT_PHONE / TEST_ACCOUNT_DAILY_LIMIT),
留空 = 整功能关闭(生产默认安全)。逻辑集中在 app/core/test_account.py,与其他业务解耦。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:03:32 +08:00
chenshuobo f868414966 feat(feedback): 补齐反馈历史审核发奖闭环 (#66)
- 新增反馈审核字段和迁移,支持 pending/adopted/rejected、未采纳原因和采纳金币

- 增加用户端反馈历史 records 接口和 admin 采纳/拒绝接口

- 采纳时同事务写状态、金币流水和审计日志,拦截重复审核

- 验证:pytest tests/test_feedback.py tests/test_admin_write.py tests/test_admin_read.py

---------

Co-authored-by: lowmaster-chen <1119780489@qq.com>
Reviewed-on: #66
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-22 23:18:42 +08:00
zhuzihao 900cc83d38 fix(ad): 广告配置默认信息流位 104090333 → 104142227 (#67)
_AD_CONFIG_DEFAULTS 里 compare/coupon_feed_code_id 的旧默认 104090333
不在穿山甲应用 5830519 名下(请求报 44406「配置为 null」、出不了广告);
2026-06-21 真机核对后台,5830519 名下真实信息流位是 104142227「信息流 1」。

客户端接入 /api/v1/platform/ad-config 下发后会以此默认为准(空库回退),
故更正默认值。注:若 DB 里已存过 104090333,仍需在 admin 广告管理页改存为 104142227。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #67
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-22 23:00:27 +08:00
zhuzihao 3cab75b6ac feat(admin-withdraw): 提现详情金币记录分页 + 风险标签拆分 (#64)
- user_coin_records 增返 total(三源 granted 计数和),支持前端页码分页
- 风险标签「历史异常提现」拆成「提现拒绝N笔」「提现失败N笔」(拒绝=人工驳回退款,失败=打款失败退款)

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #64
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-21 23:41:40 +08:00
marco 08bbb5775e fix(compare-record): platform_results 类型 list→dict,修部分失败比价上报 422 丢记录
ComparisonRecordIn.platform_results 误声明为 list,但 pricebot/客户端透传的是 dict
({platform_id: {status,reason,...}})。该字段仅「失败/部分失败」比价 done 帧才带,故
成功比价一直正常入库、部分失败比价上报被 pydantic 422 拒导致不入库、记录页查不到。
改 dict 后用线上真实 body 验证通过;字段仅随 raw_payload 落库、无结构化列、零副作用。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:11:29 +08:00
zhuzihao 8a2f72d366 feat(user): 昵称上限放宽到 20 字(与客户端/原型一致) (#63)
Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #63
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-21 00:16:40 +08:00
marco f7a3ef2e0b feat(internal): 启动确认窗 agent 兜底样本落盘(pricebot 回写)
- 新增内部端点 POST /internal/launch-confirm-sample(X-Internal-Secret 鉴权):
  pricebot 对没见过文案的启动确认窗用 LLM 兜底放行后,把样本(host 包 / 弹窗树 /
  LLM plan / 设备 locale 机型)回写落 launch_confirm_sample 表,供研发人工沉淀回
  pricebot 规则 yaml
- 配套: model LaunchConfirmSample + schema + repo(都上报不去重) + alembic 迁移
  launch_confirm_sample_table(down_revision=cps_wx_user) + main 注册 router +
  models/__init__ 登记
- docs: 后端技术实现.md 加 §6.5 pricebot 回写内部端点说明

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:39:45 +08:00
marco d8358a6816 fix(user): 注销账号前置校验资金余额 + 释放微信/邀请码唯一约束
- delete_account 端点: 有可提现现金余额 / 在审提现单(reviewing)时拒绝注销(409), 防余额锁死在 deleted 行无法退出; pending 提现已在途不拦(对账 worker 不依赖 user.status)
- soft_delete_account: 注销连带清 wechat_openid/nickname/avatar(释放微信绑定槽, 否则该微信永久无法再被任何账号绑定→提现通道锁死) + invite_code(释放邀请码唯一槽)
- wallet 新增 get_cash_balance_cents(只读, 不建账户) 供注销前置校验; 复用 has_reviewing_withdraw
- 新增 tests/test_delete_account.py

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 02:03:33 +08:00
marco 95a07f75b7 feat(ad): 穿山甲广告配置后台化(app_id/各位ID/验签密钥/各场景开关)
- app_config 存 ad_config(默认=客户端当前内置 ID,空库维持现状)
- GET /platform/ad-config 下发客户端(不含验签密钥 reward_mkey)
- admin GET/PATCH /admin/api/ad-config 读写(operator/finance + 审计,mkey 不记明文)
- pangle 回调验签 m-key 优先读后台配置、.env 兜底兼容
- 比价/领券信息流位拆分(compare/coupon_feed_code_id)
- 客户端需发版接入 /platform/ad-config 才生效;本次后端就绪、不影响现状

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 01:35:17 +08:00
marco 733a51260f feat(cps): 微信网页授权加总开关 WX_MP_OAUTH_ENABLED(默认关)
服务号认证审核中,先关掉落地页授权(走原逻辑不跳授权/不拿 openid),整套微信代码保留。
审核通过后 .env 置 WX_MP_OAUTH_ENABLED=true + 重启即启用,无需改代码。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:03:58 +08:00
marco 7fd70a264d feat(cps): 微信网页授权接入——落地页拿 openid/昵称头像,用户级群统计
- 服务号网页授权(WX_MP_APPID/SECRET,区别于 App 的 WECHAT_APP_ID):
  落地页 base 静默拿 openid;点「领券」补 userinfo 拿昵称头像(交互触发避快照页)
- 新表 cps_wx_user + cps_click 加 openid + 迁移;integrations/wx_oauth
- /c/{code} 授权链路 + 回调 /wx/oauth/cb + cookie 免重复授权;click 带 openid
- admin 群详情加「群内微信用户」(领券画像)端点
- 下单归因到人需 user-level sid,本期只做身份+领券侧

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:44:11 +08:00
marco 4b98d405d7 feat: OTA 版本下发端点(检查更新)
- app_config 加 get/set_app_version(独立 key,复用表不进 CONFIG_DEFS)
- GET /api/v1/platform/app-version 公开读 + POST /internal/app-version 内部写(X-Internal-Secret)
- tests/test_app_version.py 5 端到端测试

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 08:31:31 +08:00
marco 63400c29cc docs(api): 补录比价透传端点到 API 总览
intent/precoupon/step、intent/step、trace/finalize、meituan/top-sales 补进总览表,
标注 Phase 流程与不鉴权; 更新最后更新日期到 2026-06-18。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:04:48 +08:00
marco 42be0f828f feat(cps): 加微信网页授权域名校验文件端点(MP_verify)
coupon.shaguabijia.com 全反代 app-server,微信请求该域名根目录 MP_verify_xxx.txt
验证域名归属(放根目录)。为微信网页授权(落地页拿 openid)接入做准备。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 05:53:45 +08:00
marco 5bafe5b71a feat(cps): 群每天明细端点(点击+订单按天合并,含退款/净佣金)
- repo group_order_daily:订单按 pay_time 北京分天聚合(口径同 _order_agg)+ 退款佣金合计
- GET /groups/{id}/daily?days=N:点击侧(补零连续天)与订单侧(仅美团群有 sid)按天合并
- 用生产 g46 真实数据验证按天口径无误

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:32:18 +08:00
marco 498da05995 docs(database): 补充数据库表文档(cps/invite/price_observation/store_mapping 等)
(工作区既有改动, 非本次会话产出)
2026-06-17 23:32:36 +08:00
marco 3cab3b9055 fix(compare-record): admin 放行 cancelled 筛选 + llm_calls 拉取改后台任务
- admin 列表 status pattern 加 cancelled(客户端会上报用户终止比价)
- 上报端点拉 pricebot llm_calls 改 BackgroundTasks 异步回填, 不阻塞上报响应(独立 session)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 23:32:36 +08:00
marco f790519765 fix(cps): 时序分桶对 naive datetime 按 UTC 兜底(本地 SQLite 错桶)
clicked_at.astimezone 对 naive(SQLite 读回无 tz)会按系统时区解释→分桶偏移;
naive 先按 UTC 兜底再转北京,与 queries._as_utc 约定一致。生产 PG(tz-aware)本就正确。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 23:06:05 +08:00
marco a2fd666203 feat(admin): 比价记录 debug 页后端——补全链路 + DB 迁移
model/schema 的 debug 字段已在 4ee6de2 起头,本次补完整条链路:
- repo upsert 存客户端环境/性能字段;端点同机拉 pricebot llm_calls 落库 + 算 llm次数/重试
- schema 加 platform_results 透传(admin「卡在哪一步」从 raw_payload 读)
- admin 比价记录查询接口(按 user_id/phone 列表分页 + 详情含 llm_calls)
- 迁移 comparison_debug_fields: 给 comparison_record 加环境/性能/llm_calls 列(全 nullable)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 22:33:33 +08:00
marco aa898fc09a feat(cps): 群点击时序聚合端点(天/小时级 PV/UV/复制,补零连续桶)
- repo group_click_timeseries:北京时区分桶,visit/copy 的 PV/UV,无数据补 0
- GET /cps/groups/{id}/timeseries?granularity=day&days=N | granularity=hour

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 22:31:34 +08:00
marco 4ee6de2548 feat(cps): 活动支持编辑(PATCH /activities/{id})
- CpsActivityUpdate schema + repo update_activity(部分更新,非 None 覆盖)
- router 按「合并后最终值」校验平台必填项(同新建口径),写审计 cps.activity.update
- 可改名/平台/对应字段/落地页图/备注/状态

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 22:13:35 +08:00
marco c78cfc838f feat(cps): 群/活动支持删除(operator,二次确认,不级联删已发链接)
- repo delete_group/delete_activity + DELETE /cps/groups/{id} 与 /cps/activities/{id}
- 硬删记录但保留 cps_link:已发短链继续可用(落地页不依赖 group/activity 存活)
- 写审计 cps.group.delete / cps.activity.delete

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:43:34 +08:00
marco d55f47fc59 feat(cps): 淘宝活动落地页图做活——每活动关联图,运营上传/选已有
- model: cps_activity.image_url + 迁移 cps_activity_image 回填存量淘宝活动为现有图
- admin: 新建淘宝活动必传图(上传 /upload-image 或选已有 /activity-images),存绝对 URL
- 落地页 cps_redirect: 按 link→activity 取图展示,缺图兜底默认图,img src 经 html.escape
- media: save_cps_image + to_abs_media_url(基于 CPS_REDIRECT_BASE,跨域 admin-web 可显示)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:32:10 +08:00
marco 7368a1ca8a feat(cps): 淘宝落地页改用设计图主视觉 + 复制按钮上提至 75% 屏高
- banner/card 手画区域整体换成设计图 /media/taobao_landing.jpg(~240KB jpg)
- 复制淘口令按钮从底部固定上提到 top:75vh, 上方留白
- 图入库(data/media 白名单, 同 dl.html 先例), 同域 /media serve
- 淘口令注入 / 复制 / toast 逻辑不变

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:53:34 +08:00
marco 3a40f617bd feat(cps): 后端接入淘宝/京东多平台(payload + 群多平台 + 复制统计 + 迁移)
- model: cps_group.platforms 多选 / cps_activity.payload(淘口令/京东链接)
  / cps_link.sid 可空 + 复制统计
- admin API: 群与活动平台多选、批量生成落地页短链 referral-links、对账字段可空
- 落地页 cps_redirect: 淘宝展示淘口令(记 copy 事件) / 京东 302 / 美团原逻辑
- 迁移 cps_v2_platforms: 加 platforms/payload/event_type 列, sid 放宽可空(含 downgrade)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 19:21:20 +08:00
marco 003fd9d986 feat(compare): 失败比价记录支持 trace 链接
compare.py 加 /trace/finalize 透传(按 trace_id 一致性 hash 落同一 pricebot 进程);compare_record.py list_records 加 include_trace,对本人记录在调试态(客户端 agent 调试模式)放行 trace_url。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:56:15 +08:00
ouzhou 04edb50acc feat(admin/ad): 提现自动对账后台开关 + 客户端广告 flags 端点 + 穿山甲多 m-key 验签 (#59)
- 自动对账接入 app_config 运行时配置(新增 bool 配置类型):env 部署总闸 + DB 运营日常开关
  双层;worker 每轮读 DB 即时生效,健康检查改报「env AND DB」实际生效态
- 新增 GET /api/v1/platform/flags(不鉴权)下发 comparing_ad_enabled 远程 kill-switch,
  客户端拉取后缓存;空库回退默认 True
- 穿山甲发奖回调支持多激励位 m-key(三命名项 PANGLE_REWARD_SECRET_TEST/_DEDICATED/_PROD
  + 旧逗号分隔合并去重),verify_callback_sign_any 逐个验签任一通过即受理;向后兼容旧单 key
- 补 test_platform / bool config 用例;app_config 文档同步

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

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #59
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-17 10:37:54 +08:00
marco 3483c1bba9 Merge origin/main (meituan-etl #57/#58) into CPS 分发
整合实习生 meituan-etl 全国扩城 + NUL 清洗,与本次 CPS 分发对账并线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:03:29 +08:00
marco 277f9b16a2 feat(cps): 群发券分发与对账 + 短链点击追踪
群(sid)/活动池/转链(带sid)/美团 query_order 按 sid 对账/按群统计;
短链 /c/{code} 记点击(PV/UV)→302 跳美团,点击→下单漏斗。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:00:29 +08:00
marco 9d93b70b9b feat(user): username 对外展示 + 默认昵称 + 存量回填
代提工作区既有的他人在制品(非本次 CPS);CPS 迁移链 cps_tables 依赖其
b3f1a2c4d5e6 迁移,需一并提交。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:00:29 +08:00
chenshuobo f853938095 fix(meituan-etl): 清洗文本 NUL 字节 + 逐城入库容错(修全量首灌崩在脏数据) (#58)
全国 359 城全量首灌崩在厦门:美团某券文本字段含 NUL(0x00),PostgreSQL
text/jsonb 拒绝该字节,整批 upsert 抛 DataError → --once 进程崩、后续 300+ 城
全不跑(本地 20 城没撞上、跑全国才暴露)。两处修复:
- _strip_nul 递归清洗入库 dict 所有字符串(含 raw JSON)的 NUL;
- 逐城 _upsert 包 try/except + rollback,单城入库失败记 fails 跳过,不再让
  一条脏数据 / 一次抖动拖垮整轮 359 城。

---------

Co-authored-by: chenshuobo <1119780489@qq.com>
Reviewed-on: #58
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-16 21:19:47 +08:00
chenshuobo 783dfd059d feat(meituan-etl): 离线库扩到全国 359 城(多城并发抓取入库) (#57)
智能推荐 / 销量最高两 tab 的离线库此前只有北京(ETL 写死 cityId),改为遍历
美团官方城市字典 359 个地级市全量抓取。实测一个地级市 cityId 已覆盖其下辖县级市
(徐州→邳州/新沂/睢宁等),按地级市抓即可,无需区县层级。

- 城市字典:tools/gen_meituan_cities.py 从美团 Excel 生成随仓库 JSON
  (app/integrations/data/meituan_cities.json,359 城),app/integrations/cities.py 读取;
- ETL:city_id 参数化 + 城市级并发(默认 12)+ worker 启动错峰削平瞬时峰值
  + 主线程逐城串行入库(Session 不跨线程);
- 配速实测:单城全量 ~110s/~2300 条;15 并发抓完一轮 ~50-60min,402 占 3% 退避全消化;
  每 3h 一轮全量(--interval 10800),窗口充裕;
- prune 双护栏:本轮 0 入库 或 失败城占比 >5% 时跳过,防上游故障/大面积限流误删全表;
- 仅写入侧;读取侧(rec/top-sales 按城过滤)待后续(依赖用户定位→cityId 映射,字典无经纬度)。

---------

Co-authored-by: chenshuobo <1119780489@qq.com>
Reviewed-on: #57
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-16 15:40:46 +08:00
marco f97048ff56 fix(alembic): 合并多 head (store_mapping_jd_dl_invalid + ad_revenue) 修复 0.1.2 部署
PR #56 的 jd_deeplink_invalid 迁移与 ad_revenue 报表迁移从同一 branchpoint
分叉成双 head, 部署时 alembic upgrade head 报 "Multiple head revisions"。
加一个 no-op merge 节点统一 DAG, 两支迁移列不冲突。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 01:39:05 +08:00
marco f15ca74a22 feat(store-mapping): JD 缓存 deeplink 失效标记(对标淘宝) (#56)
Reviewed-on: #56
2026-06-16 01:25:57 +08:00
ouzhou f7d86011c1 feat(ad-revenue): admin 广告收益报表(按 用户/日期/类型/应用/代码位 聚合) (#54)
- 新增 GET /admin/api/ad-revenue-report:展示条数/收益 + 复用金币审计逐条复算做发奖对账
- ad_ecpm/ad_reward/ad_feed_reward 各加 app_env + our_code_id 两列(alembic 迁移)
- ecpm-report / feed-reward 接收并落库 app_env/our_code_id;激励发奖按 ad_session_id 回填
- ad_audit 抽出 audit_rows,报表与逐条审计复用同一复算口径
- 组级 matched 改「组内逐条全一致」,避免应发和==实发和的互相抵消掩盖错误
- list_feedbacks 改 offset 分页并返回 total(配合 admin 页码分页)
- 反馈正文上限 _CONTENT_MAX 2000→200
- 文档:新增 admin-ad-revenue-report,更新 ecpm/feed-reward/feedback 及对应 db docs

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

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #54
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-15 23:13:14 +08:00
marco 9ec9d2389d feat(store_mapping): 淘宝缓存 deeplink 失效检测→标记→回退(app-server 侧) (#53)
加 taobao_deeplink_invalid_at 列(迁移+model);mark_taobao_deeplink_invalid 按 shopId 标记所有行;lookup_nearest 过滤失效淘宝候选;新增 POST /internal/store-mapping/invalidate。+ 7 单测。

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

Reviewed-on: #53
2026-06-15 18:07:32 +08:00
liujiahui 8fa55eec3e fix(coupon): 领券弹窗频控按 App 独立 + debug 全重置补全 (#52)
bug: 弹窗 engagement 表只按 (device, 日) 全局记一条,美团弹过/领过就把整台设备当天
标记 engage,淘宝/京东被压住不弹。需求是美团/淘宝/京东各自独立、每日各弹一次。

改动:
- model: CouponPromptEngagement 加 package 列,唯一约束 (device,日) → (device,package,日)
- alembic: 新增迁移 coupon_engage_per_package(加列 + 改唯一约束, batch_alter_table)
- repository: has_engaged_today / mark_engagement 加 package 维度;新增 reset_today_completion
- api: should-show / dismiss 接收 package;coupon_step(step=0) 按 App 记 engagement;
  补 /prompt/shown 接口(客户端一直在调但后端缺失, 原 404);
  补 /completed-today/reset(开发设置全重置用, 解首页卡置灰)

验证: curl 端到端 —— 美团弹过后 should-show 美团=false 淘宝/京东=true;
shown/reset/completion-reset 端点全 ok。

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

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #52
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-06-14 23:41:54 +08:00
ouzhou 27f76918b2 feat(admin): 运营后台跟进——review 加固 + 反馈改版 + 列表页码分页 (#51)
本 PR 汇合三块运营后台改动(原 #50 仅含其中「加固」一块,已并入本 PR 并关闭)。

## 1. review 加固 (436b2a3)
- 超管防自锁:降级/禁用最后一个 active super_admin 前校验,杜绝零超管死局
- 时间筛选统一 tz-aware(列为 timestamptz),比较绝对时刻、不依赖 DB 会话时区
- 上报审核 / 调余额(set·扣减)加行锁,防并发/连点重复发钱
- ad_audit 复算排序补 id 次级键;health-check 限 finance;调账/拒绝 reason 去空白校验

## 2. 反馈改版 (5a18dbb)
- contact 可选、截图≤6;admin 反馈列表筛选/排序;admin·wallet 接口调整 + docs

## 3. 列表页码分页 (1a7a624)
- CursorPage 加 total;新增 offset_paginate(count 与分页同源)
- 上报/审计日志从 id 游标改 offset 分页(支持跳页)
- 用户 / 提现 / 上报 / 审计日志 四页接入页码分页

测试:admin 套件 47 passed。前端配套改动见 shaguabijia-admin-web。

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #51
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-14 22:54:07 +08:00
liujiahui 47812f7fcc 比价结算记录 schema + comparison 模型 (#44)
## 改动
- 支撑客户端比价结算页:补 `compare_record` schema 字段 + `comparison` 模型调整
- docs/database 索引合并(领券三表 + onboarding_completion,表数对齐 28)

## 验证
- 模型/schema parse OK;已 merge origin/main 解 OVERVIEW/README 文档冲突(取并集)

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

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #44
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-06-14 04:16:52 +08:00
marco 8659a7ed2b feat(store_mapping): 跨平台店铺映射表 + pricebot 内部上报端点 (#48)
新增「平台店铺表」资产层: 淘宝比价拿到 shopId 后, pricebot server→server 把跨平台
店铺身份(各平台 id/名 + 地理 + 来源)落库, 作为未来"我见过这家店→跳过重搜/匹配"的源头。
与 price_observation(价格事实)平行、独立。

- models/store_mapping.py: store_mapping 表 22 列 —— 跨平台身份(id/name_taobao/meituan/jd)
  + 地理(city/geohash/lng/lat/taobao_address) + 溯源(source_platform/trace_id/device/user)
  + 淘宝原料(share_url/resolved_url/deeplink) + attrs(JSONB) + created_at。
- schemas/store_mapping.py + repositories/store_mapping.py: append-only, trace_id 幂等
  (pricebot 重试/replay 不重复写; 并发 IntegrityError 兜底返已存在行), 跨方言安全。
- api/internal/store.py: POST /internal/store-mapping(复用 price.py 共享密钥 X-Internal-Secret 校验)。
- 注册 model(__init__) + router(main.py); 迁移 store_mapping_table 接现 head coin_txn_task_ref_uq。

验证: alembic 单 head + 零模型/迁移漂移; TestClient 全链(无密钥401/有密钥inserted=1/幂等inserted=0/错密钥401)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Reviewed-on: #48
2026-06-14 01:24:29 +08:00
ouzhou cce3a01de1 fix(marquee): 首页轮播脱敏名改为按 user_id 恒定 + 去 Faker 依赖 (#49)
Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #49
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-13 23:44:57 +08:00
ouzhou 8d7b91219a feat(wallet): 解绑微信前对待审核提现二次确认 (#46)
## 改动
解绑微信前若有**待审核(reviewing)**提现单,首次解绑被拦下返回二次确认,用户确认后带 `force=true` 才真解绑。

- `schemas`: `UnbindWechatRequest{force}` + `UnbindWechatResultOut{needs_confirm, message}`
- `repositories`: `has_reviewing_withdraw`(只看 reviewing)
- `api`: `unbind-wechat` 接受可选 body(兼容旧无 body 客户端);`bound` 返回真实绑定态
- `docs/api`: 同步协议

## 为什么只拦 reviewing
reviewing 单解绑后审核打款会回读空 openid → 自动退回现金(钱不丢、只为让用户知情);pending 转账已在途、解绑影响不到,不拦。

配套客户端见 shaguabijia-app-android 同名分支。

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

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #46
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-13 03:53:43 +08:00
zhuzihao 25484aadb8 feat(user): 昵称上限放宽到 20 字(与客户端/原型一致) (#47)
ProfileUpdateRequest.nickname 的 max_length 16 → 20,与客户端 take(20) 和
原型 settings.html(maxlength=20)对齐。修复 17–20 字昵称保存时后端返回 422、
客户端弹「更新失败,请重试」的问题。DB 列为 String(64),放得下,无需迁移。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #47
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-12 23:27:35 +08:00
marco e8bd12cc1f feat(onboarding): force_onboarding 改设备维度 + admin 设备管理 + status 查询
废弃 force_onboarding(运营按用户强制引导,客户端不认——被 onboarding_completed 压过),
改用 onboarding_completion(设备+账号 维度),admin 直接增删该表记录控制重走。

- 删 force_onboarding: User 列 + auth/admin schema + admin 接口 + mutations + repo
  clear + /onboarding/complete 调用 + 3 测试;新增 alembic 迁移删列
- admin 设备管理: 列设备(按 device 聚合) / 重置单设备 / 全部重置(清 onboarding_completion)
- 新增 GET /api/v1/user/onboarding/status: 客户端已登录启动查"本设备是否要重走引导"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 00:51:47 +08:00
ouzhou 57ddcd356b feat(wallet): 0 点定时把用户金币「到分全额」自动兑成现金 (#45)
客户端已删手动兑入口、改「0 点自动兑现金」,服务端补这条批处理:
- wallet.exchange_coins_to_cash 加 remark / enforce_min 参数:enforce_min=False(自动兑)只要够
  1 分(COIN_PER_CENT)即可,不受手动兑下限约束;remark 可定制。
- 新增 wallet.daily_auto_exchange(db):扫 coin_balance>=100 的用户,到分全额(floor(余额/100)*100)
  兑现金,零头留下次;幂等键=当天是否已有 exchange_in 流水(_has_exchange_in_on);逐用户独立事务,
  单用户异常 rollback 不中断。
- scripts/daily_auto_exchange.py(--once / --loop):带 .env AUTO_EXCHANGE_ENABLED 开关(false→no-op)
  + 文件锁防重入;deploy/ 配 systemd service+timer(0 点触发)+ 说明。
- config.AUTO_EXCHANGE_ENABLED(默认 true)+ .env.example。

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

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #45
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-11 23:36:26 +08:00
marco 5f9af204ea fix(alembic): 合并 user_force_onboarding 与 comparison_idx/coupon_daily 双 head
#43 的 user_force_onboarding 迁移挂在 onboarding_completion 上,与
d4b87c5847de(comparison_status_created_idx + coupon_daily 合并、同样以
onboarding_completion 为父) 形成并列双 head,alembic upgrade head 报
Multiple head revisions,ecs1 部署会在迁移步失败未重启。

加纯合并迁移 9b894f5fff05 (Revises: d4b87c5847de, user_force_onboarding)
收敛为单 head,upgrade/downgrade no-op 不改 schema。本会话第 2 次、历史第 3
次此类问题(前为 a8c47fc4dc39 / be5e94e / f01db5d77dac)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:49:33 +08:00
zhuzihao 3d07461ef5 feat(force-onboarding): 运营一键开启新手引导 — User.force_onboarding + admin 接口 + /me 带出 + 走完自动清 (#43)
- User 新增 force_onboarding 列(仿 debug_trace_enabled)+ 迁移 user_force_onboarding。
- POST /admin/api/users/{id}/force-onboarding(operator 角色 + 审计 user.force_onboarding.set)。
- UserOut(/me 与登录响应)带出该字段;/api/v1/user/onboarding/complete 走完即自动清回 false,只触发一次。
- 测试:admin 设置+审计+角色守卫、走完引导自动清标记。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #43
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-10 22:27:06 +08:00
ouzhou db399a40fa feat(admin): 首页门面数字保底/护栏 + 轮播种子真实化与批量 + 比价记录索引 (#42)
- ops_stat: 真实值 offset→保底(max);初始基数过只增不减护栏
- ops_marquee: 脱敏名手机尾号+中文昵称混合;金额长尾;时间随机抖动;真实+种子不足兜底补满;真实记录查询 ~30s 缓存
- comparison_record: 复合索引 (status, created_at) + 迁移(PG CONCURRENTLY);修复 alembic 多 head(merge 改挂 onboarding_completion)
- 轮播种子批量删除/启用接口 + ids 上限;docs 同步
- 附带并行会话 WIP:admin users / wallet

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

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #42
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-10 22:26:51 +08:00
marco a753843804 美团 feed 改用离线库 + 三 tab 降级兜底(替换 main 的实时版 feed) (#40)
把 main 的实时 feed 换成离线库版本 + status 降级兜底:
- rec 智能推荐:改走离线库 meituan_coupon 筛佣金≥3%(SQL 去重+排序+分页),不再实时撞限流
- distance 距离最近:改实时搜索翻页(sortField=6),按用户真实位置由近及远排
- top_sales 销量最高:改 SQL DISTINCT ON 分页,修原全表拉取+全量解析的翻页卡顿
- 四路加 status(ok/empty/degraded):空库/上游失败均返 200 不再 5xx;/coupons 502 软化为降级
- FeedResponse/CouponListResponse 加 status 字段(默认 ok,向后兼容)

注意:替换 main #18 的实时 feed 实现,需 review 行为变更;依赖的离线库由 ETL 灌(见 meituan-etl PR)

---------

Co-authored-by: chenshuobo <1119780489@qq.com>
Reviewed-on: #40
2026-06-10 20:05:50 +08:00
zhangxianze bf82c68408 feat(compare): 美团比价前用券透传端点 /intent/precoupon/step (#41)
## 背景
客户端在美团源比价的**意图识别前**先循环调本端点用券。app-server 只做透传壳(决策在 pricebot-backend)。

## 改动
- 新增 `POST /api/v1/intent/precoupon/step` → 透传到 pricebot-backend `/api/intent/precoupon/step`
- 同 `/intent/step` 透传壳: 不鉴权、不做 schema 校验, device_id 透传区分设备

## 测试
- 链路自测(:8770 → :8000) + 真机实测用券成功

## 关联(跨三仓库, 同名分支 `feat/meituan-precoupon`)
- WonderableAI/pricebot-backend
- WonderableAI/shaguabijia-app-server
- WonderableAI/shaguabijia-app-android
**改协议字段需三仓库一起改。**

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

---------

Co-authored-by: xianze <ze@192.168.0.128>
Reviewed-on: #41
Co-authored-by: zhangxianze <zhangxianze@wonderable.ai>
Co-committed-by: zhangxianze <zhangxianze@wonderable.ai>
2026-06-10 19:35:58 +08:00
zzhyyyyy 0ae19dc49c 合并远程更改并解决冲突 2026-06-10 18:47:18 +08:00
zzhyyyyy 389ece9052 Merge branch 'feat/onboarding-completion' 2026-06-10 18:44:29 +08:00
marco 455884401f 新增美团 CPS 券定时抓取入库(北京试点),作为"销量/佣金排序"的本地数据源 (#38)
Co-authored-by: chenshuobo <1119780489@qq.com>
Reviewed-on: #38
2026-06-10 18:34:40 +08:00
zzhyyyyy dacb37e3e1 feat(onboarding): 新手引导完成按 设备+账号 去重(表/模型/仓储/端点/迁移/测试)
- onboarding_completion 表(user_id+device_id 唯一约束)+ model + repository
- POST /api/v1/user/onboarding/complete 标记完成;登录响应 TokenWithUser.onboarding_completed
  (按登录请求带的 device_id 判定是否已走过引导,跨卸载重装稳定)
- alembic 迁移 onboarding_completion(rebase 到最新 main 后已链在 coupon_daily_completion 之后,单 head)
- docs/database 文档 + tests/test_onboarding.py + run.bat(本地启动脚本)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:32:51 +08:00
liujiahui 0890e693d7 feat(coupon): 今日跑完整轮领券后端记录 + 查询(首页置灰源) (#34)
新增 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>

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #34
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-06-10 16:07:33 +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
242 changed files with 14292 additions and 656 deletions
+21 -2
View File
@@ -34,6 +34,15 @@ SMS_MOCK=true
SMS_CODE_TTL_SEC=300
SMS_SEND_INTERVAL_SEC=60
# ===== 测试账号(release 包全流程联调用)=====
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
# (real 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。
# 逻辑见 app/core/test_account.py,与其他业务解耦。
# ⚠️ 留空 = 关闭整功能(生产默认);要启用才填号(如 11111111111)。改完重启生效,随时可清空停用。
TEST_ACCOUNT_PHONE=
# 该测试号每日最多登录次数,当日超过即拒绝(429),次日归零。
TEST_ACCOUNT_DAILY_LIMIT=500
# ===== 美团联盟 CPS =====
# 美团联盟后台 → 媒体管理 拿到的 appkey 和密钥
MT_CPS_APP_KEY=
@@ -83,12 +92,22 @@ WXPAY_AUTH_NOTIFY_URL=
WITHDRAW_AUTO_RECONCILE_ENABLED=false
WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC=300
WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES=15
# 0 点自动兑金币开关(deploy/daily-exchange.timer 触发的脚本读它;false=脚本 no-op)。默认 true。
AUTO_EXCHANGE_ENABLED=true
# ===== 穿山甲激励视频(服务端发奖回调)=====
# 看完激励视频后穿山甲服务器 S2S 回调本服务发金币(客户端不参与发奖)。
# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",用于验签,从后台取到后填这里;
# 配齐并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。
# 穿山甲"奖励校验密钥"(m-key),验签,从 GroMore 后台各广告位取到后填这里
# 配齐(任一非空)并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。
# 每个激励位 m-key 不同但共用同一回调 URL → 各位分开一行配(留空的忽略);验签逐个试、任一通过即接受。
PANGLE_CALLBACK_ENABLED=false
# 测试应用 激励位 104099649
PANGLE_REWARD_SECRET_TEST=
# 测试应用 专属激励位 104127529
PANGLE_REWARD_SECRET_TEST_DEDICATED=
# 正式应用 激励位 104099389
PANGLE_REWARD_SECRET_PROD=
# (旧用法,仍兼容:单个或逗号分隔的多个 m-key,会与上面三个合并去重)
PANGLE_REWARD_SECRET=
# ⚠️ 仅本地联调:true 时开放 POST /api/v1/ad/test-grant,让 debug 客户端看完广告直接发奖,
# 验证"看广告→金币到账"全链路(未部署公网、穿山甲 S2S 打不到本地时用)。生产必须 false(绕过反作弊)。
+1
View File
@@ -30,6 +30,7 @@ data/*
!data/media/
data/media/*
!data/media/dl.html
!data/media/taobao_landing.jpg
secrets/*
!secrets/.gitkeep
@@ -0,0 +1,26 @@
"""merge store_mapping_jd_dl_invalid and ad_revenue heads (0.1.2)
Revision ID: 45047b5a884c
Revises: d4e68464761d, store_mapping_jd_dl_invalid
Create Date: 2026-06-16 01:38:26.273226
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '45047b5a884c'
down_revision: Union[str, Sequence[str], None] = ('d4e68464761d', 'store_mapping_jd_dl_invalid')
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 user_force_onboarding and comparison_idx_coupon_daily heads
Revision ID: 9b894f5fff05
Revises: d4b87c5847de, user_force_onboarding
Create Date: 2026-06-10 22:49:02.248506
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9b894f5fff05'
down_revision: Union[str, Sequence[str], None] = ('d4b87c5847de', 'user_force_onboarding')
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,39 @@
"""ad revenue report columns: app_env + our_code_id
给 ad_ecpm_record / ad_reward_record / ad_feed_reward_record 各加两列:
- app_env:我们的穿山甲应用环境(prod=傻瓜比价正式 / test=测试应用)
- our_code_id:我们在穿山甲后台配置的代码位 ID(104xxx,非底层 mediation rit)
供「广告收益报表」按 用户/日期/广告类型/应用/代码位 聚合 展示条数/收益/金币。
旧数据这两列为 NULL(报表里来源列留空),新数据由客户端上报/发奖时回填。
Revision ID: ad_revenue_report_cols
Revises: coupon_engage_per_package
Create Date: 2026-06-15
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "ad_revenue_report_cols"
down_revision = "coupon_engage_per_package"
branch_labels = None
depends_on = None
_TABLES = ("ad_ecpm_record", "ad_reward_record", "ad_feed_reward_record")
def upgrade() -> None:
for table in _TABLES:
op.add_column(table, sa.Column("app_env", sa.String(length=16), nullable=True))
op.add_column(table, sa.Column("our_code_id", sa.String(length=64), nullable=True))
def downgrade() -> None:
for table in _TABLES:
op.drop_column(table, "our_code_id")
op.drop_column(table, "app_env")
+34
View File
@@ -0,0 +1,34 @@
"""add feed_scene to ad_feed_reward_record
信息流广告点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。
比价与领券共用同一个信息流代码位(AdConfig.feedCodeId),slot_id / our_code_id 都分不出来,
只能由客户端在各调用点(PriceBotService / CouponForegroundService / WelfareScreen)显式打标。
可空:历史记录与未升级客户端为 NULL = 未分类。
Revision ID: add_feed_scene
Revises: drop_force_onboarding
Create Date: 2026-06-20 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "add_feed_scene"
down_revision: Union[str, Sequence[str], None] = "drop_force_onboarding"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"ad_feed_reward_record",
sa.Column("feed_scene", sa.String(length=16), nullable=True),
)
def downgrade() -> None:
op.drop_column("ad_feed_reward_record", "feed_scene")
@@ -0,0 +1,79 @@
"""user.username(对外展示账号 ID)+ 默认昵称,并回填存量用户
Revision ID: b3f1a2c4d5e6
Revises: 45047b5a884c
Create Date: 2026-06-16 12:00:00.000000
加 user.username(11 位纯数字、首位非 1 与手机号天然区分、全局唯一)。存量用户:
生成唯一 username,昵称为空的补 9 位字母数字随机昵称。
迁移自包含(不 import app 业务代码,逻辑冻结为当时快照)。
"""
import secrets
import string
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b3f1a2c4d5e6'
down_revision: Union[str, Sequence[str], None] = '45047b5a884c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_USERNAME_FIRST = "23456789" # 首位避前导 0、避 1(手机号都以 1 开头)
_USERNAME_DIGITS = "0123456789"
_NICKNAME_ALPHABET = string.ascii_letters + string.digits
def _gen_username() -> str:
return secrets.choice(_USERNAME_FIRST) + "".join(
secrets.choice(_USERNAME_DIGITS) for _ in range(10)
)
def _gen_nickname() -> str:
return "".join(secrets.choice(_NICKNAME_ALPHABET) for _ in range(9))
def upgrade() -> None:
# 1. 先加可空列(存量行此刻还没值)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('username', sa.String(length=11), nullable=True))
# 2. 回填存量:每人一个唯一 username;昵称为空的补随机昵称。
# 表里 username 原本全空,本批用 used 防互撞即可(无既有非空值需规避)。
# "user" 是 PostgreSQL 保留字,必须加双引号(SQLite 也接受双引号标识符)。
bind = op.get_bind()
rows = bind.execute(sa.text('SELECT id, nickname FROM "user"')).fetchall()
used = set()
for row in rows:
uid, nick = row[0], row[1]
while True:
uname = _gen_username()
if uname not in used:
used.add(uname)
break
if nick is None or not str(nick).strip():
bind.execute(
sa.text('UPDATE "user" SET username = :u, nickname = :n WHERE id = :i'),
{"u": uname, "n": _gen_nickname(), "i": uid},
)
else:
bind.execute(
sa.text('UPDATE "user" SET username = :u WHERE id = :i'),
{"u": uname, "i": uid},
)
# 3. 收紧:NOT NULL + 唯一索引(对齐模型 unique=True, index=True)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.alter_column('username', existing_type=sa.String(length=11), nullable=False)
batch_op.create_index('ix_user_username', ['username'], unique=True)
def downgrade() -> None:
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_index('ix_user_username')
batch_op.drop_column('username')
@@ -0,0 +1,26 @@
"""merge add_feed_scene and launch_confirm_sample heads
Revision ID: ceb286289426
Revises: add_feed_scene, launch_confirm_sample_table
Create Date: 2026-06-20 23:52:57.853400
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ceb286289426'
down_revision: Union[str, Sequence[str], None] = ('add_feed_scene', 'launch_confirm_sample_table')
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,40 @@
"""add coin_transaction task ref unique index
可重复任务(task_enable_notification「打开消息提醒」逐次减半领取)的发放路径是无锁的
读-算-写:并发/连点的两个 claim 会都读到同一已领次数、算出同一 ref_id(task_key:N)、
各发一笔,导致双倍发钱。给 coin_transaction 加 (user_id, biz_type, ref_id) 部分唯一索引
(仅 biz_type LIKE 'task%' 且 ref_id 非空),第二笔撞唯一约束 → IntegrityError,被
task.claim_task 兜底成 AlreadyClaimedError(409)。与 cash_transaction 提现退款、
withdraw_order 在途单的「唯一约束 + IntegrityError 兜底」同模式。
Revision ID: coin_txn_task_ref_uq
Revises: drop_force_onboarding
Create Date: 2026-06-12 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "coin_txn_task_ref_uq"
down_revision: Union[str, Sequence[str], None] = "drop_force_onboarding"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
"ux_coin_transaction_task_ref",
"coin_transaction",
["user_id", "biz_type", "ref_id"],
unique=True,
sqlite_where=sa.text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
postgresql_where=sa.text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("ux_coin_transaction_task_ref", table_name="coin_transaction")
@@ -0,0 +1,54 @@
"""comparison_record: 客户端环境 / 性能统计 / LLM 明细 debug 字段
Revision ID: comparison_debug_fields
Revises: cps_activity_image
Create Date: 2026-06-17 22:00:00.000000
给 admin 比价记录 debug 页新增采集列,全部 nullable(旧记录 / 未发版客户端为 NULL,向后兼容):
- 客户端环境: device_*/rom_*/android_*/app_version*/source_app_version/经纬度
- 过程统计: total_ms/step_count/llm_call_count/retry_count
- LLM 明细: llm_calls(JSONBserver 按 trace_id 同机拉 pricebot 落库)
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "comparison_debug_fields"
down_revision: Union[str, Sequence[str], None] = "cps_activity_image"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_JSONB = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
def upgrade() -> None:
op.add_column("comparison_record", sa.Column("device_model", sa.String(64), nullable=True))
op.add_column("comparison_record", sa.Column("device_manufacturer", sa.String(64), nullable=True))
op.add_column("comparison_record", sa.Column("rom_vendor", sa.String(32), nullable=True))
op.add_column("comparison_record", sa.Column("rom_name", sa.String(32), nullable=True))
op.add_column("comparison_record", sa.Column("rom_version", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("android_version", sa.String(16), nullable=True))
op.add_column("comparison_record", sa.Column("android_sdk", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("app_version", sa.String(32), nullable=True))
op.add_column("comparison_record", sa.Column("app_version_code", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("source_app_version", sa.String(32), nullable=True))
op.add_column("comparison_record", sa.Column("longitude", sa.Float(), nullable=True))
op.add_column("comparison_record", sa.Column("latitude", sa.Float(), nullable=True))
op.add_column("comparison_record", sa.Column("total_ms", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("step_count", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("llm_call_count", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("retry_count", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("llm_calls", _JSONB, nullable=True))
def downgrade() -> None:
for col in (
"llm_calls", "retry_count", "llm_call_count", "step_count", "total_ms",
"latitude", "longitude", "source_app_version", "app_version_code",
"app_version", "android_sdk", "android_version", "rom_version",
"rom_name", "rom_vendor", "device_manufacturer", "device_model",
):
op.drop_column("comparison_record", col)
@@ -0,0 +1,48 @@
"""add composite index (status, created_at) on comparison_record
首页轮播 feed(按 status='success' 过滤 + created_at 近期排序)与省钱战绩聚合
都吃这个过滤+排序;复合索引避免随数据量增大退化成全表扫。
Revision ID: comparison_status_created_idx
Revises: a8c47fc4dc39
Create Date: 2026-06-10
"""
from __future__ import annotations
from alembic import op
revision = "comparison_status_created_idx"
down_revision = "a8c47fc4dc39"
branch_labels = None
depends_on = None
INDEX_NAME = "ix_comparison_status_created"
def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == "postgresql":
# PG 上 comparison_record 已有数据量, 普通 CREATE INDEX 持表写锁会阻塞线上写入;
# 用 CONCURRENTLY 不锁表(须脱离事务, autocommit_block 切到自动提交)。
with op.get_context().autocommit_block():
op.create_index(
INDEX_NAME, "comparison_record", ["status", "created_at"],
unique=False, postgresql_concurrently=True,
)
else:
op.create_index(
INDEX_NAME, "comparison_record", ["status", "created_at"], unique=False
)
def downgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == "postgresql":
with op.get_context().autocommit_block():
op.drop_index(
INDEX_NAME, table_name="comparison_record",
postgresql_concurrently=True,
)
else:
op.drop_index(INDEX_NAME, table_name="comparison_record")
@@ -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")
@@ -0,0 +1,53 @@
"""coupon_prompt_engagement 频控加 package 维度(美团/淘宝/京东各自独立弹)
Revision ID: coupon_engage_per_package
Revises: store_mapping_jd_cols
Create Date: 2026-06-14 11:00:00.000000
需求:领券引导窗按 (device, App, 自然日) 频控——在美团弹过/领过,不影响淘宝、京东今天
仍各弹一次。原表唯一键是 (device_id, engage_date),缺 package → 任一 App 弹过就把整台
设备当天标记 engage,其余 App 被压住不弹(bug)。
本迁移:
1. 加 package 列(NOT NULL,旧行用 server_default "" 填占位,不影响新逻辑判断)。
2. 旧唯一约束 (device_id, engage_date) → 新 (device_id, package, engage_date)。
SQLite 不支持直接 drop/add 约束,用 batch_alter_table(建临时表 + 拷数据 + 换名,
与 store_mapping_* 同款)。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'coupon_engage_per_package'
down_revision: Union[str, Sequence[str], None] = 'store_mapping_jd_cols'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
# 加 package 列。旧行(改造前的全局记录)填 "" 占位:它们对应的是"老的全局态",
# 新逻辑按 (device, package, 日) 判,占位 "" 不会与真实包名(com.xxx)碰撞。
batch_op.add_column(
sa.Column('package', sa.String(length=64), nullable=False, server_default='')
)
# 旧唯一约束 (device_id, engage_date) → 新三元组 (device_id, package, engage_date)。
batch_op.drop_constraint('uq_coupon_engage_device_date', type_='unique')
batch_op.create_unique_constraint(
'uq_coupon_engage_device_pkg_date',
['device_id', 'package', 'engage_date'],
)
def downgrade() -> None:
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
batch_op.drop_constraint('uq_coupon_engage_device_pkg_date', type_='unique')
batch_op.create_unique_constraint(
'uq_coupon_engage_device_date',
['device_id', 'engage_date'],
)
batch_op.drop_column('package')
+38
View File
@@ -0,0 +1,38 @@
"""cps activity 落地页图: cps_activity.image_url + 回填存量淘宝活动
新建淘宝活动时运营提供落地页主视觉图(上传或选已有),落地页按活动展示对应图。
存量淘宝活动回填为此前写死的那张图,无缝衔接。
Revision ID: cps_activity_image
Revises: cps_v2_platforms
Create Date: 2026-06-17 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from app.core.config import settings
revision: str = "cps_activity_image"
down_revision: Union[str, Sequence[str], None] = "cps_v2_platforms"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("cps_activity", sa.Column("image_url", sa.String(512), nullable=True))
# 回填存量淘宝活动 = 此前写死的那张图(绝对 URL,基于落地页域名 CPS_REDIRECT_BASE;
# dev 未配则相对路径)。新活动由运营自传/选,不回填。
base = settings.CPS_REDIRECT_BASE.rstrip("/")
default_img = f"{base}/media/taobao_landing.jpg" if base else "/media/taobao_landing.jpg"
op.execute(
sa.text(
"UPDATE cps_activity SET image_url = :img WHERE platform = 'taobao'"
).bindparams(img=default_img)
)
def downgrade() -> None:
op.drop_column("cps_activity", "image_url")
+61
View File
@@ -0,0 +1,61 @@
"""cps_link / cps_click tables (群发短链 + 点击统计)
Revision ID: cps_link_tables
Revises: cps_tables
Create Date: 2026-06-16 13:20:00.000000
群发券链接套一层 /c/{code} 做点击统计:cps_link(短码→美团目标) + cps_click(点击事件)。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "cps_link_tables"
down_revision: Union[str, Sequence[str], None] = "cps_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_NOW = sa.text("CURRENT_TIMESTAMP")
def upgrade() -> None:
op.create_table(
"cps_link",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=16), nullable=False),
sa.Column("group_id", sa.Integer(), nullable=False),
sa.Column("activity_id", sa.Integer(), nullable=False),
sa.Column("sid", sa.String(length=64), nullable=False),
sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan"),
sa.Column("target_url", sa.String(length=1024), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("code", name="uq_cps_link_code"),
)
op.create_index("ix_cps_link_code", "cps_link", ["code"])
op.create_index("ix_cps_link_group_id", "cps_link", ["group_id"])
op.create_index("ix_cps_link_activity_id", "cps_link", ["activity_id"])
op.create_index("ix_cps_link_sid", "cps_link", ["sid"])
op.create_table(
"cps_click",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("link_id", sa.Integer(), nullable=False),
sa.Column("group_id", sa.Integer(), nullable=False),
sa.Column("sid", sa.String(length=64), nullable=False),
sa.Column("ip", sa.String(length=64), nullable=True),
sa.Column("ua", sa.String(length=512), nullable=True),
sa.Column("clicked_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_cps_click_link_id", "cps_click", ["link_id"])
op.create_index("ix_cps_click_group_id", "cps_click", ["group_id"])
op.create_index("ix_cps_click_sid", "cps_click", ["sid"])
op.create_index("ix_cps_click_clicked_at", "cps_click", ["clicked_at"])
def downgrade() -> None:
op.drop_table("cps_click")
op.drop_table("cps_link")
+92
View File
@@ -0,0 +1,92 @@
"""cps_group / cps_activity / cps_order tables (CPS 分发与对账)
Revision ID: cps_tables
Revises: b3f1a2c4d5e6
Create Date: 2026-06-16 12:40:00.000000
群发 CPS 优惠券的分发与对账:cps_group(群=sid) + cps_activity(可推广活动池) +
cps_order(美团 query_order 按 sid 拉回的订单明细)。金额统一存「分」。
order_id 全局唯一(reconcile 按它 upsert,订单状态会变则更新)。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "cps_tables"
down_revision: Union[str, Sequence[str], None] = "b3f1a2c4d5e6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_NOW = sa.text("CURRENT_TIMESTAMP")
def upgrade() -> None:
op.create_table(
"cps_group",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("sid", sa.String(length=64), nullable=False),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("member_count", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
sa.Column("remark", sa.String(length=256), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("sid", name="uq_cps_group_sid"),
)
op.create_index("ix_cps_group_sid", "cps_group", ["sid"])
op.create_table(
"cps_activity",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan"),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("act_id", sa.String(length=64), nullable=True),
sa.Column("product_view_sign", sa.String(length=128), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
sa.Column("remark", sa.String(length=256), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"cps_order",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("order_id", sa.String(length=64), nullable=False),
sa.Column("sid", sa.String(length=64), nullable=True),
sa.Column("act_id", sa.String(length=64), nullable=True),
sa.Column("biz_line", sa.Integer(), nullable=True),
sa.Column("trade_type", sa.Integer(), nullable=True),
sa.Column("pay_price_cents", sa.Integer(), nullable=True),
sa.Column("commission_cents", sa.Integer(), nullable=True),
sa.Column("commission_rate", sa.String(length=16), nullable=True),
sa.Column("refund_price_cents", sa.Integer(), nullable=True),
sa.Column("refund_profit_cents", sa.Integer(), nullable=True),
sa.Column("mt_status", sa.String(length=8), nullable=True),
sa.Column("invalid_reason", sa.String(length=128), nullable=True),
sa.Column("product_name", sa.String(length=512), nullable=True),
sa.Column("pay_time", sa.DateTime(timezone=True), nullable=True),
sa.Column("mt_update_time", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"raw",
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
nullable=False,
),
sa.Column("first_seen", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("order_id", name="uq_cps_order_order_id"),
)
op.create_index("ix_cps_order_order_id", "cps_order", ["order_id"])
op.create_index("ix_cps_order_sid", "cps_order", ["sid"])
op.create_index("ix_cps_order_act_id", "cps_order", ["act_id"])
op.create_index("ix_cps_order_mt_status", "cps_order", ["mt_status"])
op.create_index("ix_cps_order_pay_time", "cps_order", ["pay_time"])
def downgrade() -> None:
op.drop_table("cps_order")
op.drop_table("cps_activity")
op.drop_table("cps_group")
+61
View File
@@ -0,0 +1,61 @@
"""cps v2: 活动 payload(淘口令/京东链接) + 群多平台/sid 可空 + 点击 event_type
Revision ID: cps_v2_platforms
Revises: cps_link_tables
Create Date: 2026-06-17 11:00:00.000000
接入淘宝/京东:
- cps_activity.payload 淘宝整段淘口令文本 / 京东推广链接(美团仍用 act_id)
- cps_group.platforms 该群发哪些平台(多选);sid 改可空(纯淘宝/京东无 sid)
- cps_link.sid 可空 + target_url 加长(淘口令较长)
- cps_click.event_type visit / copy(淘宝落地页"复制口令")
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "cps_v2_platforms"
down_revision: Union[str, Sequence[str], None] = "cps_link_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 活动:淘宝淘口令 / 京东链接
op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True))
# 群:多平台(现有群都是美团,server_default 回填)+ sid 可空
op.add_column(
"cps_group",
sa.Column(
"platforms",
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
nullable=False,
server_default=sa.text("'[\"meituan\"]'::jsonb"),
),
)
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=True)
# link:sid 可空 + target 加长
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=True)
op.alter_column("cps_link", "target_url", existing_type=sa.String(1024), type_=sa.String(2048))
# click:sid 可空 + 事件类型
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=True)
op.add_column(
"cps_click",
sa.Column("event_type", sa.String(16), nullable=False, server_default="visit"),
)
def downgrade() -> None:
op.drop_column("cps_click", "event_type")
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=False)
op.alter_column("cps_link", "target_url", existing_type=sa.String(2048), type_=sa.String(1024))
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=False)
op.drop_column("cps_group", "platforms")
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=False)
op.drop_column("cps_activity", "payload")
+48
View File
@@ -0,0 +1,48 @@
"""cps 微信用户表 cps_wx_user + cps_click 加 openid
CPS 落地页微信网页授权拿 openid/昵称头像,做用户级群统计。
Revision ID: cps_wx_user
Revises: comparison_debug_fields
Create Date: 2026-06-19 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "cps_wx_user"
down_revision: Union[str, Sequence[str], None] = "comparison_debug_fields"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"cps_wx_user",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("openid", sa.String(64), nullable=False),
sa.Column("unionid", sa.String(64), nullable=True),
sa.Column("nickname", sa.String(128), nullable=True),
sa.Column("headimgurl", sa.String(512), nullable=True),
sa.Column("first_code", sa.String(16), nullable=True),
sa.Column("first_group_id", sa.Integer(), nullable=True),
sa.Column("first_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("last_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_cps_wx_user_openid", "cps_wx_user", ["openid"], unique=True)
op.create_index("ix_cps_wx_user_unionid", "cps_wx_user", ["unionid"])
op.create_index("ix_cps_wx_user_first_group_id", "cps_wx_user", ["first_group_id"])
op.add_column("cps_click", sa.Column("openid", sa.String(64), nullable=True))
op.create_index("ix_cps_click_openid", "cps_click", ["openid"])
def downgrade() -> None:
op.drop_index("ix_cps_click_openid", table_name="cps_click")
op.drop_column("cps_click", "openid")
op.drop_index("ix_cps_wx_user_first_group_id", table_name="cps_wx_user")
op.drop_index("ix_cps_wx_user_unionid", table_name="cps_wx_user")
op.drop_index("ix_cps_wx_user_openid", table_name="cps_wx_user")
op.drop_table("cps_wx_user")
@@ -0,0 +1,26 @@
"""merge comparison idx and coupon daily completion heads
Revision ID: d4b87c5847de
Revises: comparison_status_created_idx, onboarding_completion
Create Date: 2026-06-10 16:45:50.285917
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd4b87c5847de'
down_revision: Union[str, Sequence[str], None] = ('comparison_status_created_idx', 'onboarding_completion')
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 ad_revenue_report_cols and store_mapping_tb_dl_invalid heads
Revision ID: d4e68464761d
Revises: ad_revenue_report_cols, store_mapping_tb_dl_invalid
Create Date: 2026-06-15 21:55:58.115692
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd4e68464761d'
down_revision: Union[str, Sequence[str], None] = ('ad_revenue_report_cols', 'store_mapping_tb_dl_invalid')
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,40 @@
"""drop user.force_onboarding column
force_onboarding(运营「按用户」强制新手引导)机制废弃:客户端实际按 onboarding_completion
(设备+账号 维度)判断是否走引导,该 force_onboarding 列从未被客户端正确响应(被
onboarding_completed 压过)。改用 admin「设备维度」重置(删 onboarding_completion 记录)替代,
见 app/admin/routers/onboarding.py。本迁移删列。
Revision ID: drop_force_onboarding
Revises: 9b894f5fff05
Create Date: 2026-06-12 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "drop_force_onboarding"
down_revision: Union[str, Sequence[str], None] = "9b894f5fff05"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_column("user", "force_onboarding")
def downgrade() -> None:
# 回滚:重建列(同 user_force_onboarding 迁移的 upgrade)。sa.false() 两端兼容。
op.add_column(
"user",
sa.Column(
"force_onboarding",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
@@ -0,0 +1,78 @@
"""feedback review fields
Revision ID: feedback_review_fields
Revises: ceb286289426
Create Date: 2026-06-22 20:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "feedback_review_fields"
down_revision: str | Sequence[str] | None = "ceb286289426"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("feedback", schema=None) as batch_op:
batch_op.add_column(sa.Column("reject_reason", sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column("reward_coins", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("review_note", sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column("reviewed_by_admin_id", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True))
batch_op.create_index(batch_op.f("ix_feedback_status"), ["status"], unique=False)
batch_op.create_foreign_key(
batch_op.f("fk_feedback_reviewed_by_admin_id_admin_user"),
"admin_user",
["reviewed_by_admin_id"],
["id"],
)
feedback = sa.table(
"feedback",
sa.column("status", sa.String(length=16)),
)
op.execute(
feedback.update()
.where(feedback.c.status == "new")
.values(status="pending")
)
op.execute(
feedback.update()
.where(feedback.c.status == "handled")
.values(status="adopted")
)
def downgrade() -> None:
feedback = sa.table(
"feedback",
sa.column("status", sa.String(length=16)),
)
op.execute(
feedback.update()
.where(feedback.c.status == "pending")
.values(status="new")
)
op.execute(
feedback.update()
.where(feedback.c.status == "adopted")
.values(status="handled")
)
with op.batch_alter_table("feedback", schema=None) as batch_op:
batch_op.drop_constraint(
batch_op.f("fk_feedback_reviewed_by_admin_id_admin_user"),
type_="foreignkey",
)
batch_op.drop_index(batch_op.f("ix_feedback_status"))
batch_op.drop_column("reviewed_at")
batch_op.drop_column("reviewed_by_admin_id")
batch_op.drop_column("review_note")
batch_op.drop_column("reward_coins")
batch_op.drop_column("reject_reason")
@@ -0,0 +1,53 @@
"""launch_confirm_sample table (启动确认窗 agent 兜底样本: LLM 放行的弹窗样本沉淀)
Revision ID: launch_confirm_sample_table
Revises: cps_wx_user
Create Date: 2026-06-20 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "launch_confirm_sample_table"
down_revision: Union[str, Sequence[str], None] = "cps_wx_user"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"launch_confirm_sample",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
sa.Column("trace_id", sa.String(length=64), nullable=True),
sa.Column("device_id", sa.String(length=64), nullable=True),
sa.Column("host_package", sa.String(length=128), nullable=True),
sa.Column("target_app", sa.String(length=128), nullable=True),
sa.Column("system_locale", sa.String(length=32), nullable=True),
sa.Column("exec_success", sa.Boolean(), nullable=False),
sa.Column("dialog_title", sa.String(length=256), nullable=True),
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
sa.Column("payload", sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), "postgresql"), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_created_at"), ["created_at"], unique=False)
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_trace_id"), ["trace_id"], unique=False)
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_device_id"), ["device_id"], unique=False)
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_host_package"), ["host_package"], unique=False)
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_system_locale"), ["system_locale"], unique=False)
def downgrade() -> None:
with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_system_locale"))
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_host_package"))
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_device_id"))
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_trace_id"))
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_created_at"))
op.drop_table("launch_confirm_sample")
@@ -0,0 +1,39 @@
"""onboarding_completion table (新手引导完成标记,按 user+device 去重)
Revision ID: onboarding_completion
Revises: coupon_daily_completion
Create Date: 2026-06-10 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'onboarding_completion'
down_revision: Union[str, Sequence[str], None] = 'coupon_daily_completion'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# (账号, 设备) 一条完成标记;device_id = 客户端硬件级 ANDROID_ID(跨卸载重装稳定)。
op.create_table(
'onboarding_completion',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('device_id', sa.String(length=64), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'device_id', name='uq_onboarding_user_device'),
)
with op.batch_alter_table('onboarding_completion', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_onboarding_completion_user_id'), ['user_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('onboarding_completion', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_onboarding_completion_user_id'))
op.drop_table('onboarding_completion')
+40
View File
@@ -0,0 +1,40 @@
"""store_mapping 加京东原料列(jd_vender_id + 分享链/反查 URL/deeplink)
Revision ID: store_mapping_jd_cols
Revises: store_mapping_meituan_cols
Create Date: 2026-06-13 00:00:00.000000
京东秒送接入店内搜索 deeplink 链路: 3.cn 短链 → 跟随重定向反查 venderId+storeId →
openapp.jdmobile:// deeplink。京东店铺身份是**两个**稳定数字 id: storeId 进 id_jd(稳定
店主键, 同 taobao 的 shopId→id_taobao), venderId 进单列 jd_vender_id(deeplink 还需它)。
其余三列与 taobao_*/meituan_* 原料列平行。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_jd_cols'
down_revision: Union[str, Sequence[str], None] = 'store_mapping_meituan_cols'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.add_column(sa.Column('jd_vender_id', sa.String(length=64), nullable=True))
batch_op.add_column(sa.Column('jd_share_url', sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column('jd_resolved_url', sa.Text(), nullable=True))
batch_op.add_column(sa.Column('jd_deeplink', sa.Text(), nullable=True))
batch_op.create_index(batch_op.f('ix_store_mapping_jd_vender_id'), ['jd_vender_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_store_mapping_jd_vender_id'))
batch_op.drop_column('jd_deeplink')
batch_op.drop_column('jd_resolved_url')
batch_op.drop_column('jd_share_url')
batch_op.drop_column('jd_vender_id')
@@ -0,0 +1,32 @@
"""store_mapping 加京东 deeplink 失效标记列 jd_deeplink_invalid_at
Revision ID: store_mapping_jd_dl_invalid
Revises: store_mapping_tb_dl_invalid
Create Date: 2026-06-15 00:00:00.000000
京东同淘宝: 缓存的店内搜索 deeplink 会失效(打开是"当前门店超出配送范围"页)。pricebot 比价撞到
失效页时回退正常搜店, 并 server→server 通知把该 storeId 的 deeplink 标记失效。本列记失效时刻
(NULL=有效); lookup 反查时过滤掉已失效的京东候选, 不再返回坏 deeplink。
与淘宝 taobao_deeplink_invalid_at 对称; 用时间戳而非布尔: 留痕可审计、可统计失效率, 不销毁原 deeplink。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_jd_dl_invalid'
down_revision: Union[str, Sequence[str], None] = 'store_mapping_tb_dl_invalid'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.add_column(sa.Column('jd_deeplink_invalid_at', sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_column('jd_deeplink_invalid_at')
@@ -0,0 +1,39 @@
"""store_mapping 加美团原料列(poi_id_str + 分享链/反查 URL/deeplink)
Revision ID: store_mapping_meituan_cols
Revises: store_mapping_table
Create Date: 2026-06-13 00:00:00.000000
美团接入店内搜索 deeplink 链路: dpurl.cn 短链 → 302 反查 poi_id_str → imeituan:// deeplink。
poi_id_str 每次分享重新加密、非稳定主键, 单列 meituan_poi_id_str 存(不占 id_meituan,
后者留给将来 CPS API 的稳定数字 poi_id)。其余三列与 taobao_* 原料列平行。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_meituan_cols'
down_revision: Union[str, Sequence[str], None] = 'store_mapping_table'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.add_column(sa.Column('meituan_poi_id_str', sa.String(length=64), nullable=True))
batch_op.add_column(sa.Column('meituan_share_url', sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column('meituan_resolved_url', sa.Text(), nullable=True))
batch_op.add_column(sa.Column('meituan_deeplink', sa.Text(), nullable=True))
batch_op.create_index(batch_op.f('ix_store_mapping_meituan_poi_id_str'), ['meituan_poi_id_str'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_store_mapping_meituan_poi_id_str'))
batch_op.drop_column('meituan_deeplink')
batch_op.drop_column('meituan_resolved_url')
batch_op.drop_column('meituan_share_url')
batch_op.drop_column('meituan_poi_id_str')
+77
View File
@@ -0,0 +1,77 @@
"""store_mapping table (平台店铺表:跨平台同店 id/名 映射,server 侧无条件落库)
Revision ID: store_mapping_table
Revises: coin_txn_task_ref_uq
Create Date: 2026-06-13 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_table'
down_revision: Union[str, Sequence[str], None] = 'coin_txn_task_ref_uq'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'store_mapping',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
# 跨平台身份
sa.Column('id_taobao', sa.String(length=64), nullable=True),
sa.Column('name_taobao', sa.String(length=128), nullable=True),
sa.Column('id_meituan', sa.String(length=64), nullable=True),
sa.Column('name_meituan', sa.String(length=128), nullable=True),
sa.Column('id_jd', sa.String(length=64), nullable=True),
sa.Column('name_jd', 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('taobao_address', sa.String(length=256), nullable=True),
# 溯源
sa.Column('source_platform', sa.String(length=32), nullable=True),
sa.Column('business_type', sa.String(length=16), nullable=False),
sa.Column('trace_id', sa.String(length=64), nullable=False),
sa.Column('source_device_id', sa.String(length=64), nullable=True),
sa.Column('source_user_id', sa.Integer(), nullable=True),
# 淘宝原料(URL 可能很长 → Text)
sa.Column('taobao_share_url', sa.String(length=256), nullable=True),
sa.Column('taobao_resolved_url', sa.Text(), nullable=True),
sa.Column('taobao_deeplink', sa.Text(), nullable=True),
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
sa.Column('attrs', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
# 一次比价一行,trace_id 幂等去重(防 pricebot 重试 / replay 重复写)
sa.UniqueConstraint('trace_id', name='uq_store_mapping_trace'),
)
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_store_mapping_id_taobao'), ['id_taobao'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_id_meituan'), ['id_meituan'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_id_jd'), ['id_jd'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_geohash'), ['geohash'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_source_platform'), ['source_platform'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_source_device_id'), ['source_device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_source_user_id'), ['source_user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_store_mapping_created_at'))
batch_op.drop_index(batch_op.f('ix_store_mapping_source_user_id'))
batch_op.drop_index(batch_op.f('ix_store_mapping_source_device_id'))
batch_op.drop_index(batch_op.f('ix_store_mapping_source_platform'))
batch_op.drop_index(batch_op.f('ix_store_mapping_geohash'))
batch_op.drop_index(batch_op.f('ix_store_mapping_id_jd'))
batch_op.drop_index(batch_op.f('ix_store_mapping_id_meituan'))
batch_op.drop_index(batch_op.f('ix_store_mapping_id_taobao'))
op.drop_table('store_mapping')
@@ -0,0 +1,33 @@
"""store_mapping 加淘宝 deeplink 失效标记列 taobao_deeplink_invalid_at
Revision ID: store_mapping_tb_dl_invalid
Revises: coupon_engage_per_package
Create Date: 2026-06-15 00:00:00.000000
缓存的淘宝店内搜索 deeplink 会失效(打开是"页面出错了"降级页)。pricebot 比价撞到错误页时
回退正常搜店, 并 server→server 通知把该 shopId 的 deeplink 标记失效。本列记失效时刻
(NULL=有效); lookup 反查时过滤掉已失效的淘宝候选, 不再返回坏 deeplink。
只加淘宝一列(当前只接淘宝); 用时间戳而非布尔: 留痕可审计、可统计失效率, 且不销毁原 deeplink。
无需索引: 过滤总叠在 name_taobao== 之后, 候选集已小。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_tb_dl_invalid'
down_revision: Union[str, Sequence[str], None] = 'coupon_engage_per_package'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.add_column(sa.Column('taobao_deeplink_invalid_at', sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_column('taobao_deeplink_invalid_at')
+39
View File
@@ -0,0 +1,39 @@
"""add user.force_onboarding
运营后台「一键开启新手引导」:置 true → 用户下次启动 App 被强制重走引导教程(即便本地已完成),
走完引导(/api/v1/user/onboarding/complete)后端自动清回 false,不无限循环。
见 app/models/user.py force_onboarding 列、admin POST /admin/api/users/{id}/force-onboarding。
Revision ID: user_force_onboarding
Revises: onboarding_completion
Create Date: 2026-06-10 21:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "user_force_onboarding"
down_revision: Union[str, Sequence[str], None] = "onboarding_completion"
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,两端兼容(同 debug_trace_enabled)。
op.add_column(
"user",
sa.Column(
"force_onboarding",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
def downgrade() -> None:
op.drop_column("user", "force_onboarding")
+14
View File
@@ -13,13 +13,20 @@ 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.ad_config import router as ad_config_router
from app.admin.routers.ad_revenue import router as ad_revenue_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.comparison import router as comparison_router
from app.admin.routers.config import router as config_router
from app.admin.routers.cps import router as cps_router
from app.admin.routers.dashboard import router as dashboard_router
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
from app.admin.routers.feedback import router as feedback_router
from app.admin.routers.feedback_qr import router as feedback_qr_router
from app.admin.routers.onboarding import router as onboarding_router
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
@@ -79,10 +86,17 @@ 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(onboarding_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(feedback_qr_router)
admin_app.include_router(admins_router)
admin_app.include_router(audit_router)
admin_app.include_router(config_router)
admin_app.include_router(comparison_router)
admin_app.include_router(cps_router)
admin_app.include_router(ad_audit_router)
admin_app.include_router(ad_config_router)
admin_app.include_router(ad_revenue_router)
+248
View File
@@ -0,0 +1,248 @@
"""看广告金币审计:复算 expected_coin 并与实发对比。
只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移):
- 看视频:每条 granted = 1 份,第 N 份 = 该用户 granted 的 reward_video **账号累计**顺序号
(与 ad_reward.grant_ad_reward 里 `_granted_cumulative + 1` 一致;LT 因子不按天重置,
故复算时要把当日序号叠加上该用户在本日**之前**的累计已发份数)。
- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 该用户 granted 份数**账号累计**
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致;同样不按天重置,
复算需叠加本日之前的累计份数)。
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
"""
from __future__ import annotations
from sqlalchemy import func, 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 _prior_granted_counts(
db: Session, *, date: str, user_id: int | None
) -> dict[int, int]:
"""各用户在 date **之前**已发奖的 reward_video 累计份数,作为当日复算的 LT 序号起点。
LT 因子改账号累计后,当日第 1 份并非全局第 1 份,需叠加历史累计。"""
stmt = (
select(AdRewardRecord.user_id, func.count())
.where(
AdRewardRecord.reward_date < date,
AdRewardRecord.reward_scene == "reward_video",
AdRewardRecord.status == "granted",
)
.group_by(AdRewardRecord.user_id)
)
if user_id is not None:
stmt = stmt.where(AdRewardRecord.user_id == user_id)
return {uid: n for uid, n in db.execute(stmt).all()}
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, AdRewardRecord.id)
)
if user_id is not None:
stmt = stmt.where(AdRewardRecord.user_id == user_id)
# 用本日之前的累计份数做起点,当日 granted 在其上继续递增 → 与 _granted_cumulative+1 对齐
granted_n: dict[int, int] = _prior_granted_counts(db, date=date, user_id=user_id)
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,
"app_env": rec.app_env,
"our_code_id": rec.our_code_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,
"app_env": rec.app_env,
"our_code_id": rec.our_code_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_prior_granted_units(
db: Session, *, date: str, user_id: int | None
) -> dict[int, int]:
"""各用户在 date **之前** granted 的信息流份数累计,作为当日复算的 LT 序号起点。"""
stmt = (
select(
AdFeedRewardRecord.user_id,
func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0),
)
.where(
AdFeedRewardRecord.reward_date < date,
AdFeedRewardRecord.status == "granted",
)
.group_by(AdFeedRewardRecord.user_id)
)
if user_id is not None:
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
return {uid: int(n) for uid, n in db.execute(stmt).all()}
def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)。"""
stmt = (
select(AdFeedRewardRecord)
.where(AdFeedRewardRecord.reward_date == date)
.order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at, AdFeedRewardRecord.id)
)
if user_id is not None:
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
# 本日之前的累计份数做起点,与 _unit_reward_total 的 existing_units(累计)对齐
granted_units: dict[int, int] = _feed_prior_granted_units(db, date=date, user_id=user_id)
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,
"app_env": rec.app_env,
"our_code_id": rec.our_code_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,
"app_env": rec.app_env,
"our_code_id": rec.our_code_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 audit_rows(
db: Session, *, date: str, user_id: int | None, scene: str | None = None
) -> list[dict]:
"""当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed"
每行含 `app_env`/`our_code_id`/`expected_coin`/`actual_coin` 等,供金币审计逐条对账,
也供广告收益报表把「应发/实发」按 用户×类型×应用×代码位 聚合(见 ad_revenue,复用同一复算口径)。
"""
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))
return rows
def ad_coin_audit(
db: Session,
*,
date: str,
user_id: int | None,
scene: str | None,
limit: int,
only_mismatch: bool = False,
) -> dict:
"""当日发奖复算。返回 {total, mismatch_count, truncated, items}。
scene: None=两类都要 / "reward_video" / "feed";only_mismatch=True 只展示不一致(✗)行。
关键:`total` 与 `mismatch_count` 在**全量**(截断前)上统计,故对账数字始终可信,不受 limit
影响;`items` 才是展示集(only_mismatch 时只取 ✗ 行)按 created_at 倒序截断到 limit。
份序号在全天数据上已算好,limit 只影响展示条数、不影响 expected 复算正确性。
"""
rows = audit_rows(db, date=date, user_id=user_id, scene=scene)
rows.sort(key=lambda r: (r["created_at"], r["record_id"]), reverse=True)
total = len(rows)
mismatch_count = sum(1 for r in rows if not r["matched"])
display = [r for r in rows if not r["matched"]] if only_mismatch else rows
return {
"total": total,
"mismatch_count": mismatch_count,
"truncated": len(display) > limit,
"items": display[: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],
}
+239
View File
@@ -0,0 +1,239 @@
"""admin 广告收益报表:按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合(单表含发奖对账)。
只读。聚合键 = user_id × ad_type × app_env × our_code_id;每组一行同时给出:
- 展示条数 + 收益:`ad_ecpm_record`(每行 = 客户端一次广告展示;收益 = Σ eCPM元 ÷ 1000)。
激励视频每次展示上报一行;信息流轮播每条展示各上报一行(每条独立 id,不复用会话)。
- 应发金币 / 实发金币:复用金币审计的**逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径,
不另写公式),把每条发奖记录的 expected/actual 按同维度求和;`matched` = 组内**逐条**全部一致
(任一条不符该组即不符,不用「应发和==实发和」以免互相抵消掩盖错误)。**不改发奖逻辑**,只读复算。
展示与发奖来自不同表,做并集:有展示无发奖(用户中途关 / 未达发奖)、有发奖无展示
(未上报 eCPM)都各自成行。app_env/our_code_id 旧数据为 NULL → 归到「来源未知」组。
⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 `feed`(迁移后 Draw
不再产生新数据)。② 聚合级只能看出「某组应发≠实发」,定位到具体哪条仍需逐条审计接口(ad-coin-audit)。
"""
from __future__ import annotations
from datetime import date as _date, datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.admin.repositories import ad_audit
from app.core import rewards
from app.models.ad_ecpm import AdEcpmRecord
def _cn_hour(dt: datetime) -> int:
"""created_at(UTC 口径)→ 北京时间小时(023)。naive 当 UTC 处理(sqlite),tz-aware 直接换算(pg)。"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(rewards.CN_TZ).hour
def _key(
report_date: str,
user_id: int,
ad_type: str,
app_env: str | None,
our_code_id: str | None,
hour: int | None,
) -> tuple:
return (report_date, user_id, ad_type, app_env or None, our_code_id or None, hour)
def _date_range(date_from: str, date_to: str) -> list[str]:
"""闭区间内逐日 'YYYY-MM-DD' 串(含首尾)。date_from > date_to 时返回空。"""
d0 = _date.fromisoformat(date_from)
d1 = _date.fromisoformat(date_to)
out: list[str] = []
d = d0
while d <= d1:
out.append(d.isoformat())
d += timedelta(days=1)
return out
# 审计行的 scene 与报表 ad_type 一一对应
_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"}
def ad_revenue_report(
db: Session,
*,
date_from: str,
date_to: str,
user_id: int | None = None,
ad_type: str | None = None,
granularity: str = "day",
limit: int = 500,
) -> dict:
"""日期区间(北京时间,闭区间)广告收益聚合 + 发奖对账。单日时 date_from==date_to。
聚合键含**日期**:report_date × user × ad_type × app_env × our_code_id(× 北京小时,granularity=hour)。
ad_type: None=全部 / reward_video / feed / draw。
granularity: "day"=按天 / "hour"=按小时(聚合键再加北京小时 0–23,每组一行)。
limit 只截断展示明细,total 与 total_* / daily 在全量上统计(不受 limit 影响),数字始终可信。
返回额外含 `daily`(按日期汇总的展示/收益/应发/实发,供前端按天趋势图;不受 limit 影响)。
注:按小时下,展示按 ecpm 记录的小时、金币按发奖记录的小时各自归桶——S2S 回调可能比展示晚
一会儿,故同一次广告的展示与金币偶尔落相邻小时(按天则一致)。
"""
by_hour = granularity == "hour"
groups: dict[tuple, dict] = {}
def _grp(key: tuple) -> dict:
g = groups.get(key)
if g is None:
rdate, uid, atype, app_env, code_id, hour = key
g = {
"report_date": rdate,
"user_id": uid,
"ad_type": atype,
"app_env": app_env,
"our_code_id": code_id,
"hour": hour,
"impressions": 0,
"revenue_yuan": 0.0,
"expected_coin": 0,
"actual_coin": 0,
"adns": set(),
"impression_records": [], # 该组逐条展示明细(展开下钻用)
"records": [], # 该组逐条发奖复算明细(展开下钻用)
}
groups[key] = g
return g
# 1) 展示条数 + 收益 ← ad_ecpm_record(report_date 闭区间;字符串 YYYY-MM-DD 字典序即日期序)
stmt = select(AdEcpmRecord).where(
AdEcpmRecord.report_date >= date_from,
AdEcpmRecord.report_date <= date_to,
)
if user_id is not None:
stmt = stmt.where(AdEcpmRecord.user_id == user_id)
if ad_type is not None:
stmt = stmt.where(AdEcpmRecord.ad_type == ad_type)
for rec in db.execute(stmt).scalars():
hour = _cn_hour(rec.created_at) if by_hour else None
g = _grp(_key(rec.report_date, rec.user_id, rec.ad_type, rec.app_env, rec.our_code_id, hour))
g["impressions"] += 1
# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次);用与发奖同源的解析,口径一致。
rev = rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0
g["revenue_yuan"] += rev
if rec.adn:
g["adns"].add(rec.adn)
g["impression_records"].append({
"id": rec.id,
"created_at": rec.created_at,
"ecpm": rec.ecpm_raw,
"revenue_yuan": round(rev, 6),
"adn": rec.adn,
"slot_id": rec.slot_id,
})
# 2) 应发 / 实发金币 ← 复用金币审计逐条复算(同一公式口径),按同维度求和。
# audit_rows 是单日的,区间逐日调用,每天的行归到当天 report_date(语义与单日报表完全一致)。
# ad_type=draw 时审计无对应记录(scene 只有 reward_video/feed),金币侧自然为空。
audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None
if ad_type is None or audit_scene is not None:
for d in _date_range(date_from, date_to):
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
atype = _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"])
hour = _cn_hour(row["created_at"]) if by_hour else None
g = _grp(_key(d, row["user_id"], atype, row.get("app_env"), row.get("our_code_id"), hour))
g["expected_coin"] += int(row["expected_coin"])
g["actual_coin"] += int(row["actual_coin"])
# 逐条明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致)——前端展开该组时下钻展示。
g["records"].append({
"record_id": row["record_id"],
"created_at": row["created_at"],
"status": row["status"],
"ecpm": row["ecpm"],
"ecpm_factor": row["ecpm_factor"],
"units": row["units"],
"lt_index_start": row["lt_index_start"],
"lt_index_end": row["lt_index_end"],
"lt_factor_start": row["lt_factor_start"],
"lt_factor_end": row["lt_factor_end"],
"expected_coin": row["expected_coin"],
"actual_coin": row["actual_coin"],
"matched": row["matched"],
})
rows = list(groups.values())
rows.sort(
key=lambda r: (
r["report_date"],
r["user_id"],
r["hour"] if r["hour"] is not None else -1,
r["ad_type"] or "",
r["our_code_id"] or "",
)
)
total_impressions = sum(r["impressions"] for r in rows)
total_expected_coin = sum(r["expected_coin"] for r in rows)
total_actual_coin = sum(r["actual_coin"] for r in rows)
total_revenue_yuan = round(sum(r["revenue_yuan"] for r in rows), 6)
# 按日期汇总(全量,不受 limit):供前端按天趋势图。
daily_map: dict[str, dict] = {}
for r in rows:
d = daily_map.get(r["report_date"])
if d is None:
d = {
"date": r["report_date"],
"impressions": 0,
"revenue_yuan": 0.0,
"expected_coin": 0,
"actual_coin": 0,
}
daily_map[r["report_date"]] = d
d["impressions"] += r["impressions"]
d["revenue_yuan"] += r["revenue_yuan"]
d["expected_coin"] += r["expected_coin"]
d["actual_coin"] += r["actual_coin"]
daily = [
{**d, "revenue_yuan": round(d["revenue_yuan"], 6)}
for d in sorted(daily_map.values(), key=lambda x: x["date"])
]
items = [
{
"report_date": r["report_date"],
"user_id": r["user_id"],
"ad_type": r["ad_type"],
"app_env": r["app_env"],
"our_code_id": r["our_code_id"],
"hour": r["hour"],
"impressions": r["impressions"],
"revenue_yuan": round(r["revenue_yuan"], 6),
"expected_coin": r["expected_coin"],
"actual_coin": r["actual_coin"],
# 组内**逐条**全部一致才记一致——不能用「应发和==实发和」,否则一条多发+一条少发会互相
# 抵消、求和相等被误判为 ✓,掩盖真实发奖错误。纯展示无发奖记录的组 all([]) → True。
"matched": all(rec["matched"] for rec in r["records"]),
"adns": sorted(r["adns"]),
"impression_records": sorted(
r["impression_records"], key=lambda x: (x["created_at"], x["id"])
),
"records": sorted(r["records"], key=lambda x: (x["created_at"], x["record_id"])),
}
for r in rows[:limit]
]
return {
"total": len(rows),
"truncated": len(rows) > limit,
"total_impressions": total_impressions,
"total_revenue_yuan": total_revenue_yuan,
"total_expected_coin": total_expected_coin,
"total_actual_coin": total_actual_coin,
"mismatch_count": sum(
1 for r in rows if not all(rec["matched"] for rec in r["records"])
),
"daily": daily,
"items": items,
}
+14 -11
View File
@@ -4,7 +4,7 @@
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.admin import AdminAuditLog
@@ -49,8 +49,9 @@ def list_audit_logs(
admin_id: int | None = None,
limit: int = 50,
cursor: int | None = None,
) -> tuple[list[AdminAuditLog], int | None]:
"""游标分页(id 倒序),与现有 list_* 约定一致。返回 (rows, next_cursor)。"""
) -> tuple[list[AdminAuditLog], int | None, int]:
"""offset 分页(id 倒序)+ total。cursor 即 offset((page-1)*pageSize),支持页码跳页。
返回 (rows, next_cursor, total)。"""
stmt = select(AdminAuditLog)
if action:
stmt = stmt.where(AdminAuditLog.action == action)
@@ -58,13 +59,15 @@ def list_audit_logs(
stmt = stmt.where(AdminAuditLog.target_type == target_type)
if admin_id is not None:
stmt = stmt.where(AdminAuditLog.admin_id == admin_id)
if cursor is not None:
stmt = stmt.where(AdminAuditLog.id < cursor)
stmt = stmt.order_by(AdminAuditLog.id.desc())
rows = list(db.execute(stmt.limit(limit + 1)).scalars().all())
total = int(db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one())
offset = max(cursor or 0, 0)
rows = list(
db.execute(
stmt.order_by(AdminAuditLog.id.desc()).offset(offset).limit(limit + 1)
).scalars().all()
)
has_more = len(rows) > limit
items = rows[:limit]
# next_cursor 必须是"本页返回的最后一条"的 id(下一页查 id < 它),不能用 rows[limit]——
# rows[limit] 是探测下一页用的第 limit+1 条,它既不在本页也不在下页 → 每页边界丢一条。
next_cursor = items[-1].id if has_more else None
return items, next_cursor
next_cursor = offset + limit if has_more else None
return items, next_cursor, total
+517
View File
@@ -0,0 +1,517 @@
"""admin CPS 数据访问:群/活动 CRUD + 美团订单拉单入库(对账) + 按群统计聚合。
美团 query_order 返回的金额是「元」字符串、时间是秒级时间戳,这里统一转「分」+ tz-aware。
统计在 Python 侧聚合(订单量级小、admin 低频),逻辑清晰、跨 PG/SQLite 无 SQL 方言坑。
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from uuid import uuid4
from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session
from app.admin.repositories.queries import _as_utc, offset_paginate
from app.integrations import meituan
from app.repositories import cps_link as cps_link_repo
from app.models.cps_activity import CpsActivity
from app.models.cps_group import CpsGroup
from app.models.cps_link import CpsClick
from app.models.cps_order import CpsOrder
from app.models.cps_wx_user import CpsWxUser
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
_INVALID_STATUS = {"4", "5"}
_SETTLED_STATUS = "6"
# CPS 点击时序按北京时区分桶(运营看的是北京时间)
_BJ_TZ = timezone(timedelta(hours=8))
# ───────────── 单位换算 ─────────────
def _yuan_to_cents(v: object) -> int | None:
"""「元」字符串/数 → 分。None / 空 / 字面 "null" → None。"""
if v is None:
return None
s = str(v).strip()
if not s or s.lower() == "null":
return None
try:
return int((Decimal(s) * 100).to_integral_value())
except (InvalidOperation, ValueError):
return None
def _ts_to_dt(ts: object) -> datetime | None:
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
if not ts:
return None
try:
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
except (ValueError, OSError, TypeError):
return None
# ───────────── 群 ─────────────
def get_group(db: Session, group_id: int) -> CpsGroup | None:
return db.get(CpsGroup, group_id)
def get_group_by_sid(db: Session, sid: str) -> CpsGroup | None:
return db.execute(select(CpsGroup).where(CpsGroup.sid == sid)).scalar_one_or_none()
def list_groups(
db: Session, *, keyword: str | None = None, status: str | None = None,
limit: int = 20, cursor: int | None = None,
) -> tuple[list[CpsGroup], int | None, int]:
stmt = select(CpsGroup)
if keyword and keyword.strip():
kw = f"%{keyword.strip()}%"
stmt = stmt.where(CpsGroup.name.ilike(kw) | CpsGroup.sid.ilike(kw))
if status:
stmt = stmt.where(CpsGroup.status == status)
return offset_paginate(db, stmt, (desc(CpsGroup.id),), limit=limit, cursor=cursor)
def create_group(
db: Session, *, name: str, platforms: list[str], sid: str | None = None,
member_count: int | None = None, remark: str | None = None, commit: bool = True,
) -> CpsGroup:
"""建群。含 meituan 才有 sid(留空自动 g<id>);纯淘宝/京东 sid=None(那俩不支持 sid)。"""
has_meituan = "meituan" in (platforms or [])
# 含美团:用填的 sid,或临时占位待回填 g<id>;不含美团:sid 恒 None
seed_sid = (sid or f"tmp{uuid4().hex[:20]}") if has_meituan else None
group = CpsGroup(
name=name,
platforms=list(platforms or []),
sid=seed_sid,
member_count=member_count,
remark=remark,
)
db.add(group)
db.flush()
if has_meituan and not sid:
group.sid = f"g{group.id}"
db.flush()
if commit:
db.commit()
db.refresh(group)
return group
def update_group(
db: Session, group: CpsGroup, *, name: str | None = None,
platforms: list[str] | None = None,
member_count: int | None = None, status: str | None = None,
remark: str | None = None, commit: bool = True,
) -> CpsGroup:
if name is not None:
group.name = name
if platforms is not None:
group.platforms = list(platforms)
if member_count is not None:
group.member_count = member_count
if status is not None:
group.status = status
if remark is not None:
group.remark = remark
if commit:
db.commit()
db.refresh(group)
else:
db.flush()
return group
def delete_group(db: Session, group: CpsGroup, *, commit: bool = True) -> None:
"""硬删群。不级联删已生成的 cps_link(已发链接继续可用);该群历史点击/订单按 sid 仍在库。"""
db.delete(group)
if commit:
db.commit()
else:
db.flush()
# ───────────── 活动 ─────────────
def get_activity(db: Session, activity_id: int) -> CpsActivity | None:
return db.get(CpsActivity, activity_id)
def list_activities(
db: Session, *, platform: str | None = None, status: str | None = None,
limit: int = 20, cursor: int | None = None,
) -> tuple[list[CpsActivity], int | None, int]:
stmt = select(CpsActivity)
if platform:
stmt = stmt.where(CpsActivity.platform == platform)
if status:
stmt = stmt.where(CpsActivity.status == status)
return offset_paginate(db, stmt, (desc(CpsActivity.id),), limit=limit, cursor=cursor)
def list_activity_images(db: Session) -> list[str]:
"""已用过的活动落地页图(distinct 非空 image_url),供新建活动复用选择。"""
rows = (
db.execute(
select(CpsActivity.image_url)
.where(CpsActivity.image_url.is_not(None))
.distinct()
)
.scalars()
.all()
)
return [r for r in rows if r]
def create_activity(
db: Session, *, name: str, platform: str = "meituan", act_id: str | None = None,
product_view_sign: str | None = None, payload: str | None = None,
image_url: str | None = None, remark: str | None = None, commit: bool = True,
) -> CpsActivity:
activity = CpsActivity(
name=name, platform=platform, act_id=act_id,
product_view_sign=product_view_sign, payload=payload,
image_url=image_url, remark=remark,
)
db.add(activity)
if commit:
db.commit()
db.refresh(activity)
else:
db.flush()
return activity
def update_activity(
db: Session, activity: CpsActivity, *, name: str | None = None,
platform: str | None = None, act_id: str | None = None,
product_view_sign: str | None = None, payload: str | None = None,
image_url: str | None = None, remark: str | None = None,
status: str | None = None, commit: bool = True,
) -> CpsActivity:
if name is not None:
activity.name = name
if platform is not None:
activity.platform = platform
if act_id is not None:
activity.act_id = act_id
if product_view_sign is not None:
activity.product_view_sign = product_view_sign
if payload is not None:
activity.payload = payload
if image_url is not None:
activity.image_url = image_url
if remark is not None:
activity.remark = remark
if status is not None:
activity.status = status
if commit:
db.commit()
db.refresh(activity)
else:
db.flush()
return activity
def delete_activity(db: Session, activity: CpsActivity, *, commit: bool = True) -> None:
"""硬删活动。不级联删 cps_link(已发链接继续可用;淘宝落地页缺活动时取图走兜底默认图)。"""
db.delete(activity)
if commit:
db.commit()
else:
db.flush()
# ───────────── 订单(对账) ─────────────
def _map_order_fields(r: dict) -> dict:
"""美团 query_order 单条 dataList → CpsOrder 字段(金额转分、时间转 datetime)。"""
pn = r.get("productName")
if pn and len(pn) > 500:
pn = pn[:500]
return {
"sid": (r.get("sid") or None),
"act_id": str(r["actId"]) if r.get("actId") is not None else None,
"biz_line": r.get("businessLine"),
"trade_type": r.get("tradeType"),
"pay_price_cents": _yuan_to_cents(r.get("payPrice")),
"commission_cents": _yuan_to_cents(r.get("profit")),
"commission_rate": str(r["commissionRate"]) if r.get("commissionRate") is not None else None,
"refund_price_cents": _yuan_to_cents(r.get("refundPrice")),
"refund_profit_cents": _yuan_to_cents(r.get("refundProfit")),
"mt_status": str(r["status"]) if r.get("status") is not None else None,
"invalid_reason": (r.get("invalidReason") or None),
"product_name": pn or None,
"pay_time": _ts_to_dt(r.get("payTime")),
"mt_update_time": _ts_to_dt(r.get("updateTime")),
"raw": r,
}
def reconcile_orders(
db: Session, *, start_time: int, end_time: int,
query_time_type: int = 1, sid: str | None = None, max_pages: int = 200,
) -> dict:
"""调美团 query_order 分页拉单 → 按 order_id upsert。返回 {fetched, inserted, updated, pages}。
订单状态会随时间变(付款→完成→结算/退款),重复拉同一单则更新。max_pages 防异常死循环。
"""
fetched = inserted = updated = pages = 0
page = 1
while page <= max_pages:
resp = meituan.query_order(
sid=sid, start_time=start_time, end_time=end_time,
query_time_type=query_time_type, page=page, limit=100,
)
rows = ((resp.get("data") or {}).get("dataList")) or []
if not rows:
break
pages += 1
for r in rows:
order_id = str(r.get("orderId") or "").strip()
if not order_id:
continue
fetched += 1
fields = _map_order_fields(r)
existing = db.execute(
select(CpsOrder).where(CpsOrder.order_id == order_id)
).scalar_one_or_none()
if existing is None:
db.add(CpsOrder(order_id=order_id, **fields))
inserted += 1
else:
for k, v in fields.items():
setattr(existing, k, v)
updated += 1
if len(rows) < 100:
break
page += 1
db.commit()
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
def list_orders(
db: Session, *, sid: str | None = None, mt_status: str | None = None,
limit: int = 20, cursor: int | None = None,
) -> tuple[list[CpsOrder], int | None, int]:
stmt = select(CpsOrder)
if sid:
stmt = stmt.where(CpsOrder.sid == sid)
if mt_status:
stmt = stmt.where(CpsOrder.mt_status == mt_status)
return offset_paginate(db, stmt, (desc(CpsOrder.id),), limit=limit, cursor=cursor)
# ───────────── 统计(按群聚合) ─────────────
def group_stats(
db: Session, *, date_from: datetime | None = None, date_to: datetime | None = None,
) -> list[dict]:
"""按 sid 聚合订单 + join 群信息。已建但本期无单的活跃群也列出(全 0)。
未归群的 sid(历史/其它来源)单独成行 group_id=None。按预估佣金降序。
"""
ostmt = select(CpsOrder)
if date_from is not None:
ostmt = ostmt.where(CpsOrder.pay_time >= _as_utc(date_from))
if date_to is not None:
ostmt = ostmt.where(CpsOrder.pay_time <= _as_utc(date_to))
orders_by_sid: dict[str | None, list[CpsOrder]] = {}
for o in db.execute(ostmt).scalars().all():
orders_by_sid.setdefault(o.sid, []).append(o)
clicks = cps_link_repo.click_stats_by_group(db, date_from=date_from, date_to=date_to)
groups = list(db.execute(select(CpsGroup)).scalars().all())
def _order_agg(items: list[CpsOrder]) -> dict:
valid = [o for o in items if o.mt_status not in _INVALID_STATUS]
settled = [o for o in items if o.mt_status == _SETTLED_STATUS]
canceled = [o for o in items if o.mt_status in _INVALID_STATUS]
return {
"order_count": len(valid),
"settled_count": len(settled),
"canceled_count": len(canceled),
"gmv_cents": sum(o.pay_price_cents or 0 for o in valid),
"est_commission_cents": sum(o.commission_cents or 0 for o in valid),
"settled_commission_cents": sum(o.commission_cents or 0 for o in settled),
}
# 淘宝/京东无法对账 → 对账字段全 None(前端显示 "-")
_no_recon = {
"order_count": None, "settled_count": None, "canceled_count": None,
"gmv_cents": None, "est_commission_cents": None, "settled_commission_cents": None,
}
rows: list[dict] = []
seen_sids: set[str] = set()
for g in groups:
if g.status != "active":
continue
c = clicks.get(g.id) or {}
row = {
"group_id": g.id, "sid": g.sid, "name": g.name,
"platforms": list(g.platforms or []), "member_count": g.member_count,
"click_pv": c.get("pv", 0), "click_uv": c.get("uv", 0),
"copy_pv": c.get("copy_pv", 0), "copy_uv": c.get("copy_uv", 0),
}
# 对账只对美团群(有 sid);淘宝/京东 → None
if "meituan" in (g.platforms or []) and g.sid:
row.update(_order_agg(orders_by_sid.get(g.sid, [])))
seen_sids.add(g.sid)
else:
row.update(_no_recon)
rows.append(row)
# 未归群的历史美团 sid(如 wonderableai):单列,有对账无点击
for sid, items in orders_by_sid.items():
if sid is None or sid in seen_sids:
continue
row = {
"group_id": None, "sid": sid, "name": sid, "platforms": ["meituan"],
"member_count": None, "click_pv": 0, "click_uv": 0, "copy_pv": 0, "copy_uv": 0,
}
row.update(_order_agg(items))
rows.append(row)
# 佣金降序(None 当 0),次按点击
rows.sort(key=lambda x: ((x["est_commission_cents"] or 0), x["click_pv"]), reverse=True)
return rows
def group_click_timeseries(
db: Session, *, group_id: int, granularity: str,
start: datetime, end: datetime,
) -> list[dict]:
"""该群点击时序(北京时区分桶,granularity=day|hour)。
每桶:click_pv/uv(visit)+copy_pv/uv;UV=distinct(ip,ua)。**生成连续桶(无数据补 0)**,
让折线图 x 轴按所选范围铺满、不断裂(CPS 数据稀疏,补零是关键)。
"""
rows = db.execute(
select(CpsClick.clicked_at, CpsClick.event_type, CpsClick.ip, CpsClick.ua)
.where(CpsClick.group_id == group_id)
.where(CpsClick.clicked_at >= _as_utc(start))
.where(CpsClick.clicked_at <= _as_utc(end))
).all()
agg: dict = {} # bucket_key -> {vp, vu(set), cp, cu(set)}
for clicked_at, event_type, ip, ua in rows:
# naive(本地 SQLite 读回无 tz)按 UTC 兜底,再转北京——与 queries._as_utc 约定一致,
# 否则 astimezone 会把 UTC 瞬刻当系统本地时区解释,分桶整体偏移。
ca = clicked_at if clicked_at.tzinfo else clicked_at.replace(tzinfo=timezone.utc)
bj = ca.astimezone(_BJ_TZ)
key = bj.strftime("%Y-%m-%d") if granularity == "day" else bj.hour
b = agg.setdefault(key, {"vp": 0, "vu": set(), "cp": 0, "cu": set()})
uid = f"{ip or ''}|{ua or ''}"
if event_type == "copy":
b["cp"] += 1
b["cu"].add(uid)
else:
b["vp"] += 1
b["vu"].add(uid)
def _point(label: str, b: dict | None) -> dict:
return {
"label": label,
"click_pv": b["vp"] if b else 0,
"click_uv": len(b["vu"]) if b else 0,
"copy_pv": b["cp"] if b else 0,
"copy_uv": len(b["cu"]) if b else 0,
}
points: list[dict] = []
if granularity == "day":
cur = start.astimezone(_BJ_TZ).date()
last = end.astimezone(_BJ_TZ).date()
while cur <= last:
points.append(_point(cur.strftime("%m-%d"), agg.get(cur.strftime("%Y-%m-%d"))))
cur += timedelta(days=1)
else:
for h in range(24):
points.append(_point(f"{h:02d}:00", agg.get(h)))
return points
def group_order_daily(
db: Session, *, sid: str, start: datetime, end: datetime,
) -> dict[str, dict]:
"""美团群订单按天(北京时区,key=YYYY-MM-DD)聚合。口径与 group_stats._order_agg 一致:
取消(4)/风控(5)不计有效;结算只数 status=6。退款佣金取当天全部单的 refund_profit_cents 之和。
返回 {date: {order_count, canceled_count, gmv_cents, est_commission_cents,
refund_profit_cents, settled_commission_cents}};无单的天不在 dict 里(端点侧补零)。
"""
rows = (
db.execute(
select(CpsOrder)
.where(CpsOrder.sid == sid)
.where(CpsOrder.pay_time >= _as_utc(start))
.where(CpsOrder.pay_time <= _as_utc(end))
)
.scalars()
.all()
)
by_day: dict[str, list] = {}
for o in rows:
if not o.pay_time:
continue
pt = o.pay_time if o.pay_time.tzinfo else o.pay_time.replace(tzinfo=timezone.utc)
d = pt.astimezone(_BJ_TZ).strftime("%Y-%m-%d")
by_day.setdefault(d, []).append(o)
out: dict[str, dict] = {}
for d, items in by_day.items():
valid = [o for o in items if o.mt_status not in _INVALID_STATUS]
settled = [o for o in items if o.mt_status == _SETTLED_STATUS]
canceled = [o for o in items if o.mt_status in _INVALID_STATUS]
out[d] = {
"order_count": len(valid),
"canceled_count": len(canceled),
"gmv_cents": sum(o.pay_price_cents or 0 for o in valid),
"est_commission_cents": sum(o.commission_cents or 0 for o in valid),
"refund_profit_cents": sum(o.refund_profit_cents or 0 for o in items),
"settled_commission_cents": sum(o.commission_cents or 0 for o in settled),
}
return out
def group_wx_users(db: Session, *, group_id: int, limit: int = 200) -> list[dict]:
"""群内微信用户(首次来源 first_group_id=group_id)+ 每人在该群的 visit/copy 次数(领券画像)。
按首次进入倒序。copy 次数 = 该用户在该群点了几次「复制口令」(领券意向),visit = 进落地页次数。
"""
users = (
db.execute(
select(CpsWxUser)
.where(CpsWxUser.first_group_id == group_id)
.order_by(desc(CpsWxUser.first_seen))
.limit(limit)
)
.scalars()
.all()
)
if not users:
return []
openids = [u.openid for u in users]
stat: dict[str, dict] = {}
rows = db.execute(
select(CpsClick.openid, CpsClick.event_type, func.count())
.where(CpsClick.group_id == group_id)
.where(CpsClick.openid.in_(openids))
.group_by(CpsClick.openid, CpsClick.event_type)
).all()
for openid, event_type, cnt in rows:
s = stat.setdefault(openid, {"visit": 0, "copy": 0})
if event_type == "copy":
s["copy"] = cnt
else:
s["visit"] = cnt
return [
{
"openid": u.openid,
"nickname": u.nickname,
"headimgurl": u.headimgurl,
"first_seen": u.first_seen,
"visit_count": stat.get(u.openid, {}).get("visit", 0),
"copy_count": stat.get(u.openid, {}).get("copy", 0),
}
for u in users
]
+55
View File
@@ -10,10 +10,12 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import delete
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.feedback import Feedback
from app.models.onboarding import OnboardingCompletion
from app.models.price_report import PriceReport
from app.models.user import User
@@ -54,6 +56,35 @@ def update_feedback_status(
return feedback
def review_feedback(
db: Session,
feedback: Feedback,
*,
status: str,
reviewed_by_admin_id: int,
reward_coins: int | None = None,
reject_reason: str | None = None,
review_note: str | None = None,
commit: bool = True,
) -> Feedback:
"""审核反馈:置 adopted/rejected + 记录奖励/原因/审核人/审核时间。
发金币(wallet.grant_coins)由 router 在同一事务里调,确保状态、金币流水、审计一起提交。
"""
feedback.status = status
feedback.reward_coins = reward_coins
feedback.reject_reason = reject_reason
feedback.review_note = review_note
feedback.reviewed_by_admin_id = reviewed_by_admin_id
feedback.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None)
if commit:
db.commit()
db.refresh(feedback)
else:
db.flush()
return feedback
def review_price_report(
db: Session,
report: PriceReport,
@@ -79,3 +110,27 @@ def review_price_report(
else:
db.flush()
return report
def delete_device_onboarding(db: Session, device_id: str, *, commit: bool = True) -> int:
"""删某设备(device_id)的全部 onboarding 完成记录 → 该设备上所有账号下次登录重走引导。
返回删除行数。支持 commit=False 让 router 把业务写 + 审计写放进同一事务。"""
n = db.execute(
delete(OnboardingCompletion).where(OnboardingCompletion.device_id == device_id)
).rowcount
if commit:
db.commit()
else:
db.flush()
return n
def delete_all_onboarding(db: Session, *, commit: bool = True) -> int:
"""清空 onboarding_completion 表 → 所有用户在所有设备下次登录都重走引导。返回删除行数。
支持 commit=False(同 delete_device_onboarding)。"""
n = db.execute(delete(OnboardingCompletion)).rowcount
if commit:
db.commit()
else:
db.flush()
return n
+417 -28
View File
@@ -11,13 +11,27 @@ from zoneinfo import ZoneInfo
from sqlalchemy import Select, asc, desc, func, or_, 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.models.admin import AdminAuditLog
from app.models.comparison import ComparisonRecord
from app.models.feedback import Feedback
from app.models.onboarding import OnboardingCompletion
from app.models.price_report import PriceReport
from app.models.user import User
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
# 信息流点位场景 → 金币记录「赚取途径」展示名;NULL/未知 = 历史未分类。
_FEED_SCENE_LABEL = {
"comparison": "比价信息流",
"coupon": "领券信息流",
"welfare": "福利信息流",
}
def cursor_paginate(
db: Session, stmt: Select, id_col, *, limit: int, cursor: int | None
@@ -37,15 +51,48 @@ def cursor_paginate(
return items, next_cursor
def offset_paginate(
db: Session, stmt: Select, sort_clause: tuple, *, limit: int, cursor: int | None
) -> tuple[list, int | None, int]:
"""offset 分页 + 总数。stmt 只含 where/join,不要预先带 order_by/offset/limit。
cursor 即 offset(页码分页:offset=(page-1)*pageSize)。返回 (items, next_cursor, total):
- total:符合筛选条件的总条数(供 antd pagination 渲染页码/共 N 条),count 在 P0 量级开销可忽略;
- next_cursor:下一页 offset(兼容「加载更多」),末页为 None。
多取 1 条探测下一页。sort_clause 为 order_by 表达式元组(末位应含 id 保证稳定排序)。"""
total = int(
db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one()
)
offset = max(cursor or 0, 0)
rows = list(
db.execute(stmt.order_by(*sort_clause).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, total
def list_users(
db: Session,
*,
phone: str | None = None,
register_channel: str | None = None,
status: str | None = None,
nickname: str | None = None,
created_from: datetime | None = None,
created_to: datetime | None = None,
last_login_from: datetime | None = None,
last_login_to: datetime | None = None,
sort_by: str = "id",
sort_order: str = "desc",
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[User], int | None]:
) -> tuple[list[User], int | None, int]:
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选,
按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
stmt = select(User)
if phone:
stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配
@@ -53,7 +100,103 @@ def list_users(
stmt = stmt.where(User.register_channel == register_channel)
if status:
stmt = stmt.where(User.status == status)
return cursor_paginate(db, stmt, User.id, limit=limit, cursor=cursor)
if nickname and nickname.strip():
stmt = stmt.where(User.nickname.ilike(f"%{nickname.strip()}%"))
if created_from is not None:
stmt = stmt.where(User.created_at >= _as_utc(created_from))
if created_to is not None:
stmt = stmt.where(User.created_at <= _as_utc(created_to))
if last_login_from is not None:
stmt = stmt.where(User.last_login_at >= _as_utc(last_login_from))
if last_login_to is not None:
stmt = stmt.where(User.last_login_at <= _as_utc(last_login_to))
sort_cols = {
"id": User.id,
"created_at": User.created_at,
"last_login_at": User.last_login_at,
}
sort_col = sort_cols.get(sort_by, User.id)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(User.id) if sort_order == "asc" else desc(User.id)
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
def _attach_user_info(db: Session, records: list[ComparisonRecord]) -> None:
"""给每条比价记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。"""
uids = {r.user_id for r in records}
if not uids:
return
rows = db.execute(
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
).all()
umap = {uid: (phone, nick) for uid, phone, nick in rows}
for r in records:
phone, nick = umap.get(r.user_id, (None, None))
r.phone = phone
r.nickname = nick
def list_comparison_records(
db: Session,
*,
user_id: int | None = None,
phone: str | None = None,
status: str | None = None,
business_type: str | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[ComparisonRecord], int | None, int]:
"""admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛,
offset 分页(创建时间倒序、id 兜底)。join User 取 phone/nickname 瞬态挂记录上。"""
stmt = select(ComparisonRecord)
if user_id is not None:
stmt = stmt.where(ComparisonRecord.user_id == user_id)
if phone:
stmt = stmt.where(
ComparisonRecord.user_id.in_(
select(User.id).where(User.phone.like(f"{phone}%"))
)
)
if status:
stmt = stmt.where(ComparisonRecord.status == status)
if business_type:
stmt = stmt.where(ComparisonRecord.business_type == business_type)
items, next_cursor, total = offset_paginate(
db, stmt,
(desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)),
limit=limit, cursor=cursor,
)
_attach_user_info(db, items)
return items, next_cursor, total
def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None:
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。"""
rec = db.get(ComparisonRecord, record_id)
if rec is not None:
_attach_user_info(db, [rec])
return rec
def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]:
"""按设备(device_id, ANDROID_ID)聚合 onboarding_completion:每台设备走过引导的账号数 +
最近完成时间,按最近完成倒序。设备维度新手引导管理用。没走过引导的设备不在表里(本就会
引导、无需管理)。当前全量返回(上限 limit;调试期设备少,量大再加分页/搜索)。"""
rows = db.execute(
select(
OnboardingCompletion.device_id,
func.count(OnboardingCompletion.id),
func.max(OnboardingCompletion.completed_at),
)
.group_by(OnboardingCompletion.device_id)
.order_by(func.max(OnboardingCompletion.completed_at).desc())
.limit(limit)
).all()
return [
{"device_id": did, "account_count": int(cnt), "last_completed_at": last}
for did, cnt, last in rows
]
def list_all_coin_transactions(
@@ -102,7 +245,7 @@ def list_all_withdraw_orders(
quick_filter: str | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[WithdrawOrder], int | None]:
) -> tuple[list[WithdrawOrder], int | None, int]:
stmt = select(WithdrawOrder)
needs_user_join = bool(keyword and keyword.strip()) or quick_filter == "high_risk"
if needs_user_join:
@@ -132,16 +275,16 @@ def list_all_withdraw_orders(
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))
stmt = stmt.where(date_col >= _as_utc(date_from))
if date_to is not None:
stmt = stmt.where(date_col <= _as_utc_naive(date_to))
stmt = stmt.where(date_col <= _as_utc(date_to))
now = datetime.now(timezone.utc).replace(tzinfo=None)
# tz-aware:列为 timestamptz,比较绝对时刻、与 DB 会话时区无关(同 _as_utc / stats.py)
now = datetime.now(timezone.utc)
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(
@@ -186,21 +329,51 @@ def list_all_withdraw_orders(
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
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
def _as_utc_naive(value: datetime) -> datetime:
"""前端传 ISO 时间;DB 当前按 UTC naive 比较最稳(SQLite/本地开发一致)。"""
def _as_utc(value: datetime) -> datetime:
"""前端传 ISO 时间 → 统一成 tz-aware UTC 再比较。
所有时间列均为 `DateTime(timezone=True)`(Postgres timestamptz);用 tz-aware 绑定参数
比较的是绝对时刻,与 DB 会话时区无关、恒正确。曾用 naive UTC,正确性依赖会话 TimeZone=UTC,
生产会话非 UTC 时筛选边界会整体偏移——故统一 tz-aware(与 stats.py / withdraw_summary 一致)。
无时区入参按 UTC 解释。"""
if value.tzinfo is None:
return value
return value.astimezone(timezone.utc).replace(tzinfo=None)
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def withdraw_list_enrichment(db: Session, user_ids: list[int]) -> dict[int, dict]:
"""批量富化提现单列表:按本页 user_id 取 手机号/昵称 + 各自累计成功提现金额(分)。
两条聚合查询搞定(避免逐行 N+1)。累计口径与 get_user_overview 的 withdraw_success_cents
完全一致:SUM(amount_cents) WHERE status='success'。返回 {user_id: {phone, nickname,
cumulative_success_cents}};某 user_id 查不到用户则不在 dict 里(路由侧回退到默认值)。
"""
uniq = list(set(user_ids))
if not uniq:
return {}
success_rows = db.execute(
select(
WithdrawOrder.user_id,
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0),
)
.where(WithdrawOrder.user_id.in_(uniq), WithdrawOrder.status == "success")
.group_by(WithdrawOrder.user_id)
).all()
success_map = {uid: int(total) for uid, total in success_rows}
users = db.execute(
select(User.id, User.phone, User.nickname).where(User.id.in_(uniq))
).all()
return {
uid: {
"phone": phone,
"nickname": nickname,
"cumulative_success_cents": success_map.get(uid, 0),
}
for uid, phone, nickname in users
}
def list_feedbacks(
@@ -208,15 +381,35 @@ def list_feedbacks(
*,
status: str | None = None,
user_id: int | None = None,
content: str | None = None,
created_from: datetime | None = None,
created_to: datetime | None = None,
sort_by: str = "id",
sort_order: str = "desc",
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[Feedback], int | None]:
) -> tuple[list[Feedback], int | None, int]:
"""反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。
**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间
数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),total 供页码分页。
created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
stmt = select(Feedback)
if status:
stmt = stmt.where(Feedback.status == status)
if user_id is not None:
stmt = stmt.where(Feedback.user_id == user_id)
return cursor_paginate(db, stmt, Feedback.id, limit=limit, cursor=cursor)
if content and content.strip():
stmt = stmt.where(Feedback.content.ilike(f"%{content.strip()}%"))
if created_from is not None:
stmt = stmt.where(Feedback.created_at >= _as_utc(created_from))
if created_to is not None:
stmt = stmt.where(Feedback.created_at <= _as_utc(created_to))
sort_cols = {"id": Feedback.id, "created_at": Feedback.created_at}
sort_col = sort_cols.get(sort_by, Feedback.id)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id)
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None:
@@ -300,15 +493,19 @@ def withdraw_risk_flags(
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}")
# 历史异常提现拆「拒绝」「失败」两类(口径不同:拒绝=人工驳回退款,失败=打款失败退款)
rejected_n = sum(1 for item in recent_withdraws if item.status == "rejected")
failed_n = sum(1 for item in recent_withdraws if item.status == "failed")
if rejected_n:
flags.append(f"提现拒绝{rejected_n}")
if failed_n:
flags.append(f"提现失败{failed_n}")
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)
score = min(100, len(flags) * 20 + (rejected_n + failed_n) * 10)
return flags, score
@@ -398,6 +595,198 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
}
def _window_conds(col, date_from: datetime | None, date_to: datetime | None) -> list:
"""把 [date_from, date_to] 转成对 col(created_at)的过滤条件;都为 None = 全量(注册至今)。"""
conds = []
if date_from is not None:
conds.append(col >= _as_utc_naive(date_from))
if date_to is not None:
conds.append(col <= _as_utc_naive(date_to))
return conds
def _coins_to_cents(coins: int) -> int:
"""累计金币折算成可提现现金(分)。COIN_PER_YUAN=10000 → 100 金币 = 1 分。"""
return round(coins / (rewards.COIN_PER_YUAN / 100))
def user_reward_stats(
db: Session,
user_id: int,
*,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> dict:
"""提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。
口径:激励视频/信息流只统计 granted;数量——视频按条数、信息流按份数(unit_count 累加);
平均 eCPM 用原始分值(分/千次)按记录取算术平均;各「提现」= 该来源累计金币折现。
传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。
"""
wd_success = db.execute(
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
WithdrawOrder.user_id == user_id,
WithdrawOrder.status == "success",
*_window_conds(WithdrawOrder.created_at, date_from, date_to),
)
).scalar_one()
wd_total = db.execute(
select(func.count(WithdrawOrder.id)).where(
WithdrawOrder.user_id == user_id,
*_window_conds(WithdrawOrder.created_at, date_from, date_to),
)
).scalar_one()
acc = db.get(CoinAccount, user_id) # 现金余额:当前快照,不随窗口
cash_balance = acc.cash_balance_cents if acc else 0
rv = list(db.execute(
select(AdRewardRecord).where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.reward_scene == "reward_video",
AdRewardRecord.status == "granted",
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
)
).scalars())
rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw]
rv_coins = sum(r.coin for r in rv)
feed = list(db.execute(
select(AdFeedRewardRecord).where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
)
).scalars())
feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw]
feed_coins = sum(f.coin for f in feed)
trad_coins = db.execute(
select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where(
CoinTransaction.user_id == user_id,
CoinTransaction.amount > 0,
CoinTransaction.biz_type.not_in(_NON_TASK_BIZ_TYPES),
*_window_conds(CoinTransaction.created_at, date_from, date_to),
)
).scalar_one()
return {
"withdraw_success_cents": int(wd_success),
"cash_balance_cents": int(cash_balance),
"withdraw_total": int(wd_total),
"traditional_task_cash_cents": _coins_to_cents(int(trad_coins)),
"reward_video_count": len(rv),
"reward_video_avg_ecpm": round(sum(rv_ecpms) / len(rv_ecpms), 2) if rv_ecpms else 0.0,
"reward_video_cash_cents": _coins_to_cents(rv_coins),
"feed_count": int(sum(f.unit_count for f in feed)),
"feed_avg_ecpm": round(sum(feed_ecpms) / len(feed_ecpms), 2) if feed_ecpms else 0.0,
"feed_cash_cents": _coins_to_cents(feed_coins),
}
def user_coin_records(
db: Session,
user_id: int,
*,
date_from: datetime | None = None,
date_to: datetime | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[dict], int | None, int]:
"""金币发放记录(提现详情底部表),按时间倒序、offset 游标分页 + 总数。
合并三类来源(Phase1 信息流不分比价/领券):
- 激励视频 / 签到膨胀 = ad_reward_record(granted)
- 信息流广告 = ad_feed_reward_record(granted)
- 签到 = coin_transaction(biz_type='signin')
每来源各取 offset+limit+1 条(倒序)再归并切片,避免 union 全量拉取。
"""
offset = max(cursor or 0, 0)
fetch = offset + limit + 1
rows: list[dict] = []
for rec in db.execute(
select(AdRewardRecord)
.where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.status == "granted",
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
)
.order_by(AdRewardRecord.created_at.desc())
.limit(fetch)
).scalars():
is_video = rec.reward_scene == "reward_video"
rows.append({
"source": rec.reward_scene,
"source_label": "激励视频" if is_video else "签到膨胀",
"created_at": rec.created_at,
"ecpm": rec.ecpm_raw,
"coin": rec.coin,
})
for rec in db.execute(
select(AdFeedRewardRecord)
.where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
)
.order_by(AdFeedRewardRecord.created_at.desc())
.limit(fetch)
).scalars():
rows.append({
"source": "feed",
"source_label": _FEED_SCENE_LABEL.get(rec.feed_scene, "信息流广告"),
"created_at": rec.created_at,
"ecpm": rec.ecpm_raw,
"coin": rec.coin,
})
for rec in db.execute(
select(CoinTransaction)
.where(
CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == "signin",
*_window_conds(CoinTransaction.created_at, date_from, date_to),
)
.order_by(CoinTransaction.created_at.desc())
.limit(fetch)
).scalars():
rows.append({
"source": "signin",
"source_label": "签到",
"created_at": rec.created_at,
"ecpm": None,
"coin": rec.amount,
})
rows.sort(key=lambda r: r["created_at"], reverse=True)
has_more = len(rows) > offset + limit
# 总数 = 三源在窗口内 granted 计数之和(供前端页码分页渲染页码/共 N 条)
def _count(model, *conds) -> int:
return int(db.execute(select(func.count()).select_from(model).where(*conds)).scalar_one())
total = (
_count(
AdRewardRecord, AdRewardRecord.user_id == user_id,
AdRewardRecord.status == "granted",
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
)
+ _count(
AdFeedRewardRecord, AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
)
+ _count(
CoinTransaction, CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == "signin",
*_window_conds(CoinTransaction.created_at, date_from, date_to),
)
)
return rows[offset:offset + limit], (offset + limit if has_more else None), total
def list_price_reports(
db: Session,
*,
@@ -405,14 +794,14 @@ def list_price_reports(
user_id: int | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[PriceReport], int | None]:
"""上报更低价列表(admin 全量,可按状态/用户筛)。游标同 feedback:id 倒序。"""
) -> tuple[list[PriceReport], int | None, int]:
"""上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,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)
return offset_paginate(db, stmt, (PriceReport.id.desc(),), limit=limit, cursor=cursor)
def price_report_summary(db: Session) -> dict:
+1 -1
View File
@@ -119,7 +119,7 @@ def dashboard_overview(db: Session) -> dict:
"success": comparison_success,
"success_rate": success_rate,
},
"feedback": {"new": _count(Feedback, Feedback.status == "new")},
"feedback": {"new": _count(Feedback, Feedback.status.in_(("pending", "new")))},
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
}
+48
View File
@@ -0,0 +1,48 @@
"""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,
only_mismatch: Annotated[
bool, Query(description="只看不一致(✗)行;统计数仍按全量,不受影响")
] = False,
) -> AdCoinAuditOut:
audit_date = date or cn_today().isoformat()
result = ad_audit.ad_coin_audit(
db, date=audit_date, user_id=user_id, scene=scene, limit=limit,
only_mismatch=only_mismatch,
)
return AdCoinAuditOut(
date=audit_date,
formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()),
total=result["total"],
mismatch_count=result["mismatch_count"],
truncated=result["truncated"],
items=[AdCoinAuditRow(**r) for r in result["items"]],
)
+45
View File
@@ -0,0 +1,45 @@
"""admin 广告配置:读/写穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。
存在 app_config 表的 ad_config dict(见 repositories/app_config.get_ad_config/set_ad_config)。
客户端经 /api/v1/platform/ad-config 拉取(不含 reward_mkey)。权限 operator/finance + 审计。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, 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.ad_config import AdConfigOut, AdConfigUpdate
from app.models.admin import AdminUser
from app.repositories import app_config
router = APIRouter(
prefix="/admin/api/ad-config",
tags=["admin-ad-config"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=AdConfigOut, summary="广告配置(穿山甲ID/验签密钥/各场景开关)")
def get_ad_config(db: AdminDb) -> AdConfigOut:
return AdConfigOut(**app_config.get_ad_config(db))
@router.patch("", response_model=AdConfigOut, summary="改广告配置(部分更新,带审计)")
def update_ad_config(
body: AdConfigUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator", "finance"))],
db: AdminDb,
) -> AdConfigOut:
data = body.model_dump(exclude_none=True)
app_config.set_ad_config(db, data, admin_id=admin.id, commit=False)
# 审计只记改了哪些字段,不记 mkey 明文(机密)
write_audit(
db, admin, action="ad_config.set", target_type="ad_config", target_id="ad_config",
detail={"changed": sorted(data.keys())}, ip=get_client_ip(request), commit=False,
)
db.commit()
return AdConfigOut(**app_config.get_ad_config(db))
+75
View File
@@ -0,0 +1,75 @@
"""admin 广告收益报表:按 用户/日期/广告类型/应用/代码位 聚合 展示条数 / 收益 / 金币。
任意已登录 admin 可看(只读,不涉及资金操作)。聚合逻辑在 app/admin/repositories/ad_revenue.py。
"""
from __future__ import annotations
from datetime import date as _date
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import ad_revenue
from app.admin.schemas.ad_revenue import AdRevenueDaily, AdRevenueReportOut, AdRevenueRow
from app.core.rewards import cn_today
router = APIRouter(
prefix="/admin/api/ad-revenue-report",
tags=["admin-ad-revenue-report"],
dependencies=[Depends(get_current_admin)],
)
# 区间最大跨度(天);超出拒绝,避免审计页一次拉过多天(逐日审计 + 大查询)拖垮接口。
_MAX_RANGE_DAYS = 92
def _parse_day(value: str | None, *, field: str, default: _date) -> _date:
if value is None:
return default
try:
return _date.fromisoformat(value)
except ValueError as e:
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
@router.get("", response_model=AdRevenueReportOut, summary="广告收益报表(按 日期区间/用户/类型/应用/代码位 聚合)")
def get_ad_revenue_report(
db: AdminDb,
date_from: Annotated[str | None, Query(description="起始日 北京时间 YYYY-MM-DD,默认今天")] = None,
date_to: Annotated[str | None, Query(description="结束日 北京时间 YYYY-MM-DD,闭区间,默认=date_from")] = None,
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
ad_type: Annotated[
str | None,
Query(description="reward_video / feed / draw;不传=全部类型"),
] = None,
granularity: Annotated[
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
] = "day",
limit: Annotated[int, Query(ge=1, le=1000)] = 500,
) -> AdRevenueReportOut:
today = cn_today()
d_from = _parse_day(date_from, field="date_from", default=today)
d_to = _parse_day(date_to, field="date_to", default=d_from)
if d_to < d_from:
raise HTTPException(status_code=422, detail="date_to 不能早于 date_from")
if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS:
raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS}")
result = ad_revenue.ad_revenue_report(
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
user_id=user_id, ad_type=ad_type, granularity=granularity, limit=limit,
)
return AdRevenueReportOut(
date_from=d_from.isoformat(),
date_to=d_to.isoformat(),
daily=[AdRevenueDaily(**d) for d in result["daily"]],
total=result["total"],
truncated=result["truncated"],
total_impressions=result["total_impressions"],
total_revenue_yuan=result["total_revenue_yuan"],
total_expected_coin=result["total_expected_coin"],
total_actual_coin=result["total_actual_coin"],
mismatch_count=result["mismatch_count"],
items=[AdRevenueRow(**r) for r in result["items"]],
)
+24 -5
View File
@@ -17,6 +17,12 @@ router = APIRouter(
)
def _active_super_count(db: AdminDb) -> int:
return sum(
1 for a in admin_repo.list_admins(db) if a.role == "super_admin" and a.status == "active"
)
@router.get("", response_model=list[AdminOut], summary="管理员列表")
def list_admins(db: AdminDb) -> list[AdminOut]:
return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)]
@@ -48,16 +54,29 @@ def update_admin(
if admin_id == admin.id and body.status == "disabled":
raise HTTPException(status_code=400, detail="不能禁用自己")
# 防自锁:降级 / 禁用某个 super_admin 前,确认操作后仍至少剩 1 个 active super_admin,
# 否则会进入「零可用超管」死局——本路由仅 super 可进,只能改库恢复。
demotes_super = (
target.role == "super_admin"
and target.status == "active"
and (
(body.role is not None and body.role != "super_admin")
or body.status == "disabled"
)
)
if demotes_super and _active_super_count(db) <= 1:
raise HTTPException(status_code=400, detail="不能降级/禁用最后一个超级管理员")
changes: dict = {}
if body.role is not None:
if body.role is not None and body.role != target.role:
changes["role"] = {"before": target.role, "after": body.role}
target.role = body.role
changes["role"] = body.role
if body.status is not None:
if body.status is not None and body.status != target.status:
changes["status"] = {"before": target.status, "after": body.status}
target.status = body.status
changes["status"] = body.status
if body.password is not None:
target.password_hash = hash_password(body.password)
changes["password"] = "reset"
target.password_hash = hash_password(body.password)
if not changes:
raise HTTPException(status_code=400, detail="无任何变更字段")
db.commit()
+4 -2
View File
@@ -26,9 +26,11 @@ def list_audit_logs(
limit: Annotated[int, Query(ge=1, le=100)] = 50,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AdminAuditLogOut]:
items, next_cursor = audit_repo.list_audit_logs(
items, next_cursor, total = audit_repo.list_audit_logs(
db, action=action, target_type=target_type, admin_id=admin_id, limit=limit, cursor=cursor,
)
return CursorPage(
items=[AdminAuditLogOut.model_validate(x) for x in items], next_cursor=next_cursor,
items=[AdminAuditLogOut.model_validate(x) for x in items],
next_cursor=next_cursor,
total=total,
)
+58
View File
@@ -0,0 +1,58 @@
"""admin 比价记录 debug:按 user_id / phone 查列表 + 单条详情(含 LLM 每次调用明细 / 原始 payload)。
只读。读权限默认即可(get_current_admin),不额外 require_role(查记录是基础 debug 能力)。
trace_url 无条件下发——admin 是内部 debug 工具,不走 C 端 user.debug_trace_enabled 权限闸。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import queries
from app.admin.schemas.common import CursorPage
from app.admin.schemas.comparison import AdminComparisonDetail, AdminComparisonListItem
router = APIRouter(
prefix="/admin/api/comparison-records",
tags=["admin-comparison"],
dependencies=[Depends(get_current_admin)],
)
@router.get(
"",
response_model=CursorPage[AdminComparisonListItem],
summary="比价记录列表(按 user_id/phone 筛 + 分页)",
)
def list_comparison_records(
db: AdminDb,
user_id: Annotated[int | None, Query()] = None,
phone: Annotated[str | None, Query(description="手机号前缀")] = None,
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
business_type: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AdminComparisonListItem]:
items, next_cursor, total = queries.list_comparison_records(
db, user_id=user_id, phone=phone, status=status,
business_type=business_type, limit=limit, cursor=cursor,
)
return CursorPage(
items=[AdminComparisonListItem.model_validate(r) for r in items],
next_cursor=next_cursor,
total=total,
)
@router.get(
"/{record_id}",
response_model=AdminComparisonDetail,
summary="比价记录详情(含 LLM 每次调用明细 + 原始 payload)",
)
def get_comparison_record(record_id: int, db: AdminDb) -> AdminComparisonDetail:
rec = queries.get_comparison_record(db, record_id)
if rec is None:
raise HTTPException(status_code=404, detail="记录不存在")
return AdminComparisonDetail.model_validate(rec)
+6 -2
View File
@@ -13,6 +13,7 @@ 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.config import ConfigItemOut, ConfigUpdateRequest
from app.core.config_schema import CONFIG_DEFS
from app.core.rewards import SIGNIN_CYCLE_LEN
from app.models.admin import AdminUser
from app.repositories import app_config
@@ -34,14 +35,17 @@ 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) != 14:
raise ValueError("签到档位必须正好 14 个(对应 14 天循环)")
if key == "signin_rewards" and len(value) != SIGNIN_CYCLE_LEN:
raise ValueError(f"签到档位必须正好 {SIGNIN_CYCLE_LEN} 个(对应 {SIGNIN_CYCLE_LEN} 天循环)")
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)
for k, v in value.items()
):
raise ValueError("需为 {字符串: 整数} 映射")
elif t == "bool":
if not isinstance(value, bool):
raise ValueError("需为布尔值")
def _item(db, key: str) -> ConfigItemOut:
+498
View File
@@ -0,0 +1,498 @@
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。
平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。
淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"
群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。
"""
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
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 cps as cps_repo
from app.admin.schemas.common import CursorPage
from app.admin.schemas.cps import (
CpsActivityCreate,
CpsActivityOut,
CpsActivityUpdate,
CpsGroupCreate,
CpsGroupOut,
CpsGroupStat,
CpsGroupUpdate,
CpsOrderOut,
CpsReconcileResult,
CpsReferralLinkItem,
CpsReferralLinksOut,
CpsReferralLinksRequest,
CpsStatsOut,
)
from app.core import media
from app.core.config import settings
from app.integrations import meituan
from app.integrations.meituan import MeituanCpsError
from app.models.admin import AdminUser
from app.models.cps_activity import CpsActivity
from app.models.cps_group import CpsGroup
from app.repositories import cps_link as cps_link_repo
router = APIRouter(
prefix="/admin/api/cps",
tags=["admin-cps"],
dependencies=[Depends(get_current_admin)],
)
_VALID_PLATFORMS = {"meituan", "taobao", "jd"}
# ───────────── 群 ─────────────
@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群列表")
def list_groups(
db: AdminDb,
keyword: Annotated[str | None, Query(max_length=100)] = None,
status: Annotated[str | None, Query(pattern="^(active|archived)$")] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[CpsGroupOut]:
items, next_cursor, total = cps_repo.list_groups(
db, keyword=keyword, status=status, limit=limit, cursor=cursor
)
return CursorPage(
items=[CpsGroupOut.model_validate(g) for g in items],
next_cursor=next_cursor,
total=total,
)
@router.post("/groups", response_model=CpsGroupOut, summary="新建群")
def create_group(
body: CpsGroupCreate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> CpsGroupOut:
bad = [p for p in body.platforms if p not in _VALID_PLATFORMS]
if bad:
raise HTTPException(status_code=400, detail=f"非法平台: {bad}")
has_meituan = "meituan" in body.platforms
if has_meituan and body.sid and cps_repo.get_group_by_sid(db, body.sid) is not None:
raise HTTPException(status_code=409, detail="sid 已存在")
group = cps_repo.create_group(
db, name=body.name, platforms=body.platforms, sid=body.sid,
member_count=body.member_count, remark=body.remark, commit=False,
)
write_audit(
db, admin, action="cps.group.create", target_type="cps_group", target_id=group.id,
detail={"name": group.name, "platforms": group.platforms, "sid": group.sid},
ip=get_client_ip(request), commit=False,
)
db.commit()
db.refresh(group)
return CpsGroupOut.model_validate(group)
@router.patch("/groups/{group_id}", response_model=CpsGroupOut, summary="编辑群")
def update_group(
group_id: int,
body: CpsGroupUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> CpsGroupOut:
group = cps_repo.get_group(db, group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
if body.platforms is not None:
bad = [p for p in body.platforms if p not in _VALID_PLATFORMS]
if bad:
raise HTTPException(status_code=400, detail=f"非法平台: {bad}")
cps_repo.update_group(
db, group, name=body.name, platforms=body.platforms,
member_count=body.member_count, status=body.status, remark=body.remark, commit=False,
)
write_audit(
db, admin, action="cps.group.update", target_type="cps_group", target_id=group_id,
detail=body.model_dump(exclude_none=True), ip=get_client_ip(request), commit=False,
)
db.commit()
db.refresh(group)
return CpsGroupOut.model_validate(group)
@router.delete("/groups/{group_id}", summary="删除群")
def delete_group(
group_id: int,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> dict:
group = cps_repo.get_group(db, group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
write_audit(
db, admin, action="cps.group.delete", target_type="cps_group", target_id=group_id,
detail={"name": group.name, "sid": group.sid, "platforms": list(group.platforms or [])},
ip=get_client_ip(request), commit=False,
)
cps_repo.delete_group(db, group, commit=False)
db.commit()
return {"ok": True}
# ───────────── 活动 ─────────────
@router.get("/activities", response_model=CursorPage[CpsActivityOut], summary="活动列表")
def list_activities(
db: AdminDb,
platform: Annotated[str | None, Query(max_length=20)] = None,
status: Annotated[str | None, Query(pattern="^(active|archived)$")] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[CpsActivityOut]:
items, next_cursor, total = cps_repo.list_activities(
db, platform=platform, status=status, limit=limit, cursor=cursor
)
return CursorPage(
items=[CpsActivityOut.model_validate(a) for a in items],
next_cursor=next_cursor,
total=total,
)
@router.post("/activities", response_model=CpsActivityOut, summary="新建活动")
def create_activity(
body: CpsActivityCreate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> CpsActivityOut:
if body.platform == "meituan":
if not body.act_id and not body.product_view_sign:
raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign")
else: # taobao / jd
if not body.payload:
label = "淘口令" if body.platform == "taobao" else "推广链接"
raise HTTPException(status_code=400, detail=f"{body.platform} 活动需填{label}")
if body.platform == "taobao" and not body.image_url:
raise HTTPException(status_code=400, detail="淘宝活动需提供落地页图(上传或选已有)")
activity = cps_repo.create_activity(
db, name=body.name, platform=body.platform, act_id=body.act_id,
product_view_sign=body.product_view_sign, payload=body.payload,
image_url=media.to_abs_media_url(body.image_url), remark=body.remark, commit=False,
)
write_audit(
db, admin, action="cps.activity.create", target_type="cps_activity", target_id=activity.id,
detail={"name": activity.name, "platform": activity.platform},
ip=get_client_ip(request), commit=False,
)
db.commit()
db.refresh(activity)
return CpsActivityOut.model_validate(activity)
@router.patch("/activities/{activity_id}", response_model=CpsActivityOut, summary="编辑活动")
def update_activity(
activity_id: int,
body: CpsActivityUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> CpsActivityOut:
activity = cps_repo.get_activity(db, activity_id)
if activity is None:
raise HTTPException(status_code=404, detail="活动不存在")
# 用「合并后最终值」校验平台必填项(同新建口径),允许只改部分字段
platform = body.platform or activity.platform
act_id = body.act_id if body.act_id is not None else activity.act_id
pvs = body.product_view_sign if body.product_view_sign is not None else activity.product_view_sign
payload = body.payload if body.payload is not None else activity.payload
image_url = body.image_url if body.image_url is not None else activity.image_url
if platform == "meituan":
if not act_id and not pvs:
raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign")
else: # taobao / jd
if not payload:
label = "淘口令" if platform == "taobao" else "推广链接"
raise HTTPException(status_code=400, detail=f"{platform} 活动需填{label}")
if platform == "taobao" and not image_url:
raise HTTPException(status_code=400, detail="淘宝活动需提供落地页图(上传或选已有)")
cps_repo.update_activity(
db, activity, name=body.name, platform=body.platform, act_id=body.act_id,
product_view_sign=body.product_view_sign, payload=body.payload,
image_url=media.to_abs_media_url(body.image_url) if body.image_url else None,
remark=body.remark, status=body.status, commit=False,
)
write_audit(
db, admin, action="cps.activity.update", target_type="cps_activity", target_id=activity_id,
detail=body.model_dump(exclude_none=True), ip=get_client_ip(request), commit=False,
)
db.commit()
db.refresh(activity)
return CpsActivityOut.model_validate(activity)
@router.post("/upload-image", summary="上传活动落地页图(返回绝对 URL)")
async def upload_activity_image(
admin: Annotated[AdminUser, Depends(require_role("operator"))],
file: UploadFile = File(..., description="落地页图(jpeg/png/webp,≤5MB)"),
) -> dict:
data = await file.read()
try:
url = media.save_cps_image(admin.id, data)
except media.MediaError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"url": media.to_abs_media_url(url)}
@router.get("/activity-images", summary="已有活动落地页图(供新建复用选择)")
def activity_images(db: AdminDb) -> dict:
return {"images": cps_repo.list_activity_images(db)}
@router.delete("/activities/{activity_id}", summary="删除活动")
def delete_activity(
activity_id: int,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> dict:
activity = cps_repo.get_activity(db, activity_id)
if activity is None:
raise HTTPException(status_code=404, detail="活动不存在")
write_audit(
db, admin, action="cps.activity.delete", target_type="cps_activity", target_id=activity_id,
detail={"name": activity.name, "platform": activity.platform},
ip=get_client_ip(request), commit=False,
)
cps_repo.delete_activity(db, activity, commit=False)
db.commit()
return {"ok": True}
# ───────────── 生成链接(批量,按平台分支) ─────────────
def _gen_one_link(db, group: CpsGroup, activity: CpsActivity):
"""给一个活动生成一条落地页 link。美团:转链拿短链(需群 sid);淘宝/京东:用 payload。"""
if activity.platform == "meituan":
if not group.sid:
raise HTTPException(status_code=400, detail=f"群「{group.name}」无 sid,无法生成美团链接")
try:
resp = meituan.get_referral_link(
act_id=activity.act_id, product_view_sign=activity.product_view_sign,
sid=group.sid, link_type_list=[1, 2, 3],
)
except MeituanCpsError as e:
raise HTTPException(status_code=502, detail=f"美团转链失败: {e}") from e
link_map = {str(k): v for k, v in (resp.get("referralLinkMap") or {}).items()}
if not link_map and resp.get("data"):
link_map = {"1": resp["data"]}
target = link_map.get("2") or link_map.get("1") or link_map.get("3")
if not target:
raise HTTPException(status_code=502, detail=f"美团未返回有效链接(活动「{activity.name}」)")
else: # taobao(淘口令) / jd(链接)
if not activity.payload:
raise HTTPException(status_code=400, detail=f"活动「{activity.name}」缺少内容")
target = activity.payload
return cps_link_repo.create_link(
db, group_id=group.id, activity_id=activity.id, sid=group.sid,
target_url=target, platform=activity.platform, commit=False,
)
@router.post("/referral-links", response_model=CpsReferralLinksOut, summary="批量生成落地页短链")
def generate_referral_links(
body: CpsReferralLinksRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> CpsReferralLinksOut:
group = cps_repo.get_group(db, body.group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
group_platforms = set(group.platforms or [])
base = settings.CPS_REDIRECT_BASE.rstrip("/")
items: list[CpsReferralLinkItem] = []
for aid in body.activity_ids:
activity = cps_repo.get_activity(db, aid)
if activity is None:
raise HTTPException(status_code=404, detail=f"活动 {aid} 不存在")
if activity.platform not in group_platforms:
raise HTTPException(
status_code=400,
detail=f"活动「{activity.name}」平台({activity.platform})不在群「{group.name}」范围内",
)
link = _gen_one_link(db, group, activity)
items.append(CpsReferralLinkItem(
activity_id=activity.id, activity_name=activity.name, platform=activity.platform,
redirect_url=f"{base}/c/{link.code}" if base else f"/c/{link.code}", code=link.code,
))
write_audit(
db, admin, action="cps.referral_link.generate", target_type="cps_group", target_id=group.id,
detail={"group": group.name, "activity_ids": body.activity_ids, "count": len(items)},
ip=get_client_ip(request), commit=False,
)
db.commit()
return CpsReferralLinksOut(group_name=group.name, results=items)
# ───────────── 订单对账 ─────────────
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账")
def reconcile_orders(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
days: Annotated[int, Query(ge=1, le=90)] = 7,
sid: Annotated[str | None, Query(max_length=64)] = None,
) -> CpsReconcileResult:
now = int(time.time())
try:
result = cps_repo.reconcile_orders(
db, start_time=now - days * 86400, end_time=now, sid=sid,
)
except MeituanCpsError as e:
raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e
write_audit(
db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None,
detail={"days": days, "sid": sid, **result}, ip=get_client_ip(request), commit=True,
)
return CpsReconcileResult(**result)
@router.get("/orders", response_model=CursorPage[CpsOrderOut], summary="订单明细")
def list_orders(
db: AdminDb,
sid: Annotated[str | None, Query(max_length=64)] = None,
mt_status: Annotated[str | None, Query(max_length=8)] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[CpsOrderOut]:
items, next_cursor, total = cps_repo.list_orders(
db, sid=sid, mt_status=mt_status, limit=limit, cursor=cursor
)
return CursorPage(
items=[CpsOrderOut.model_validate(o) for o in items],
next_cursor=next_cursor,
total=total,
)
# ───────────── 统计 ─────────────
@router.get("/stats", response_model=CpsStatsOut, summary="按群对账统计")
def get_stats(
db: AdminDb,
days: Annotated[int, Query(ge=1, le=90)] = 30,
) -> CpsStatsOut:
date_to = datetime.now(timezone.utc)
date_from = date_to - timedelta(days=days)
rows = cps_repo.group_stats(db, date_from=date_from, date_to=date_to)
stats = [CpsGroupStat(**r) for r in rows]
return CpsStatsOut(
groups=stats,
total_order_count=sum(s.order_count or 0 for s in stats),
total_est_commission_cents=sum(s.est_commission_cents or 0 for s in stats),
total_settled_commission_cents=sum(s.settled_commission_cents or 0 for s in stats),
)
@router.get("/groups/{group_id}/timeseries", summary="群点击时序(折线图:天/小时级 PV/UV/复制)")
def group_timeseries(
group_id: int,
db: AdminDb,
granularity: Annotated[str, Query(pattern="^(day|hour)$")] = "day",
days: Annotated[int, Query(ge=1, le=30)] = 7,
date: Annotated[str | None, Query()] = None, # hour 粒度看哪天(YYYY-MM-DD 北京),默认今天
) -> dict:
group = cps_repo.get_group(db, group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
bj = timezone(timedelta(hours=8))
now_bj = datetime.now(bj)
if granularity == "hour":
if date:
try:
day0 = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=bj)
except ValueError as e:
raise HTTPException(status_code=400, detail="date 格式应为 YYYY-MM-DD") from e
else:
day0 = now_bj
start = day0.replace(hour=0, minute=0, second=0, microsecond=0)
end = start + timedelta(days=1) - timedelta(microseconds=1)
else:
start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
end = now_bj
points = cps_repo.group_click_timeseries(
db, group_id=group_id, granularity=granularity, start=start, end=end
)
return {
"group_id": group.id,
"group_name": group.name,
"granularity": granularity,
"points": points,
}
@router.get("/groups/{group_id}/daily", summary="群每天明细大表格(点击+订单按天合并)")
def group_daily(
group_id: int,
db: AdminDb,
days: Annotated[int, Query(ge=1, le=90)] = 7,
) -> dict:
group = cps_repo.get_group(db, group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
bj = timezone(timedelta(hours=8))
now_bj = datetime.now(bj)
start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
end = now_bj
# 点击侧:连续 N 天补零,顺序 = start..end(与下面 while 同序,按 index 对齐避免跨年 MM-DD 碰撞)
click_points = cps_repo.group_click_timeseries(
db, group_id=group_id, granularity="day", start=start, end=end
)
# 订单侧:仅美团群(有 sid)有对账;淘宝/京东订单列返回 None(前端显示 "-")
is_meituan = "meituan" in (group.platforms or []) and bool(group.sid)
order_daily = (
cps_repo.group_order_daily(db, sid=group.sid, start=start, end=end) if is_meituan else {}
)
_ORDER_KEYS = (
"order_count", "canceled_count", "gmv_cents",
"est_commission_cents", "refund_profit_cents", "settled_commission_cents",
)
rows = []
cur = start.astimezone(bj).date()
last = end.astimezone(bj).date()
idx = 0
while cur <= last:
cp = click_points[idx] if idx < len(click_points) else None
row = {
"date": cur.strftime("%m-%d"),
"click_pv": cp["click_pv"] if cp else 0,
"click_uv": cp["click_uv"] if cp else 0,
"copy_pv": cp["copy_pv"] if cp else 0,
}
if is_meituan:
od = order_daily.get(cur.strftime("%Y-%m-%d"))
row.update({k: (od[k] if od else 0) for k in _ORDER_KEYS})
else:
row.update({k: None for k in _ORDER_KEYS})
rows.append(row)
idx += 1
cur += timedelta(days=1)
return {
"group_id": group.id,
"group_name": group.name,
"is_meituan": is_meituan,
"days": days,
"rows": rows,
}
@router.get("/groups/{group_id}/wx-users", summary="群内微信用户(领券画像:头像/昵称/领券次数)")
def group_wx_users(group_id: int, db: AdminDb) -> dict:
group = cps_repo.get_group(db, group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
return {"users": cps_repo.group_wx_users(db, group_id=group_id)}
+120 -11
View File
@@ -1,6 +1,7 @@
"""admin 反馈工单:列表(读)+ 标记已处理(写,带审计)。"""
"""admin 反馈工单:列表筛选 + 审核采纳/拒绝(带金币发放与审计)。"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
@@ -9,9 +10,10 @@ from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.repositories import mutations, queries
from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.feedback import FeedbackOut
from app.admin.schemas.feedback import FeedbackApproveRequest, FeedbackOut, FeedbackRejectRequest
from app.models.admin import AdminUser
from app.models.feedback import Feedback
from app.repositories import wallet as wallet_repo
router = APIRouter(
prefix="/admin/api/feedbacks",
@@ -20,37 +22,144 @@ router = APIRouter(
)
def _ensure_pending(fb: Feedback) -> None:
if fb.status not in {"pending", "new"}:
raise HTTPException(status_code=400, detail="反馈已审核")
@router.get("", response_model=CursorPage[FeedbackOut], summary="反馈工单列表")
def list_feedbacks(
db: AdminDb,
status: Annotated[str | None, Query()] = None,
user_id: Annotated[int | None, Query()] = None,
content: Annotated[str | None, Query(max_length=100)] = None,
created_from: Annotated[datetime | None, Query()] = None,
created_to: Annotated[datetime | None, Query()] = None,
sort_by: Annotated[str, Query(pattern="^(id|created_at)$")] = "id",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[FeedbackOut]:
items, next_cursor = queries.list_feedbacks(
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
items, next_cursor, total = queries.list_feedbacks(
db,
status=status,
user_id=user_id,
content=content,
created_from=created_from,
created_to=created_to,
sort_by=sort_by,
sort_order=sort_order,
limit=limit,
cursor=cursor,
)
return CursorPage(
items=[FeedbackOut.model_validate(f) for f in items], next_cursor=next_cursor,
items=[FeedbackOut.model_validate(f) for f in items],
next_cursor=next_cursor,
total=total,
)
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
def handle_feedback(
feedback_id: int,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
_admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
fb = db.get(Feedback, feedback_id)
if fb is None:
raise HTTPException(status_code=404, detail="反馈不存在")
raise HTTPException(status_code=400, detail="请使用采纳或拒绝接口审核反馈")
@router.post("/{feedback_id}/approve", response_model=FeedbackOut, summary="采纳反馈并发金币")
def approve_feedback(
feedback_id: int,
payload: FeedbackApproveRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackOut:
fb = db.get(Feedback, feedback_id)
if fb is None:
raise HTTPException(status_code=404, detail="反馈不存在")
_ensure_pending(fb)
before = fb.status
mutations.update_feedback_status(db, fb, status="handled", commit=False)
mutations.review_feedback(
db,
fb,
status="adopted",
reward_coins=payload.reward_coins,
review_note=payload.note,
reviewed_by_admin_id=admin.id,
commit=False,
)
wallet_repo.grant_coins(
db,
fb.user_id,
payload.reward_coins,
biz_type="feedback_reward",
ref_id=str(fb.id),
remark="意见反馈被采纳",
)
write_audit(
db, admin, action="feedback.handle", target_type="feedback", target_id=feedback_id,
detail={"before": before, "after": "handled"}, ip=get_client_ip(request), commit=False,
db,
admin,
action="feedback.approve",
target_type="feedback",
target_id=feedback_id,
detail={
"before": before,
"after": "adopted",
"reward_coins": payload.reward_coins,
"note": payload.note,
},
ip=get_client_ip(request),
commit=False,
)
db.commit()
return OkResponse()
db.refresh(fb)
return FeedbackOut.model_validate(fb)
@router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈")
def reject_feedback(
feedback_id: int,
payload: FeedbackRejectRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackOut:
fb = db.get(Feedback, feedback_id)
if fb is None:
raise HTTPException(status_code=404, detail="反馈不存在")
_ensure_pending(fb)
before = fb.status
mutations.review_feedback(
db,
fb,
status="rejected",
reject_reason=payload.reason,
review_note=payload.note,
reviewed_by_admin_id=admin.id,
commit=False,
)
write_audit(
db,
admin,
action="feedback.reject",
target_type="feedback",
target_id=feedback_id,
detail={
"before": before,
"after": "rejected",
"reason": payload.reason,
"note": payload.note,
},
ip=get_client_ip(request),
commit=False,
)
db.commit()
db.refresh(fb)
return FeedbackOut.model_validate(fb)
+92
View File
@@ -0,0 +1,92 @@
"""admin 反馈页二维码卡配置:读 / 改文案开关 / 上传二维码 / 删二维码(带审计)。
整张卡存通用 app_config 表(见 app/repositories/feedback_qr.py),App 反馈页拉
GET /api/v1/feedback/config 同步。权限:operator 可改(运营维护),super 恒可;
读为只读(任意已登录 admin)。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
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.feedback_qr import FeedbackQrOut, FeedbackQrUpdate
from app.core import media
from app.models.admin import AdminUser
from app.repositories import feedback_qr
router = APIRouter(
prefix="/admin/api/feedback-config",
tags=["admin-feedback-config"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=FeedbackQrOut, summary="反馈页二维码卡配置")
def get_config(db: AdminDb) -> FeedbackQrOut:
return FeedbackQrOut(**feedback_qr.get_config(db))
@router.patch("", response_model=FeedbackQrOut, summary="改反馈页文案/开关(带审计)")
def update_config(
body: FeedbackQrUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackQrOut:
before, after = feedback_qr.update_config(
db,
enabled=body.enabled,
title=body.title,
group_name=body.group_name,
subtitle=body.subtitle,
admin_id=admin.id,
commit=False,
)
write_audit(
db, admin, action="feedback_qr.update", target_type="feedback_qr", target_id=None,
detail={"before": before, "after": after}, ip=get_client_ip(request), commit=False,
)
db.commit()
return FeedbackQrOut(**after)
@router.post("/image", response_model=FeedbackQrOut, summary="上传反馈页二维码(带审计)")
async def upload_image(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
file: UploadFile = File(...),
) -> FeedbackQrOut:
data = await file.read()
try:
url = media.save_feedback_qr(data)
except media.MediaError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
before, after = feedback_qr.set_image(db, url, admin_id=admin.id, commit=False)
write_audit(
db, admin, action="feedback_qr.set_image", target_type="feedback_qr", target_id=None,
detail={"before": before.get("image_url"), "after": url},
ip=get_client_ip(request), commit=False,
)
db.commit()
media.delete_feedback_qr(before.get("image_url")) # 提交成功后再删旧图,避免新图未落库就丢旧图
return FeedbackQrOut(**after)
@router.delete("/image", response_model=FeedbackQrOut, summary="移除反馈页二维码(带审计)")
def delete_image(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackQrOut:
before, after = feedback_qr.set_image(db, None, admin_id=admin.id, commit=False)
write_audit(
db, admin, action="feedback_qr.delete_image", target_type="feedback_qr", target_id=None,
detail={"before": before.get("image_url")}, ip=get_client_ip(request), commit=False,
)
db.commit()
media.delete_feedback_qr(before.get("image_url"))
return FeedbackQrOut(**after)
+86
View File
@@ -0,0 +1,86 @@
"""admin 设备维度新手引导管理:列设备 / 重置单设备 / 全部重置。
背景:原 force_onboarding(运营「按用户」强制引导)客户端不认(被 onboarding_completed 压过),
已废弃。改为直接操作 onboarding_completion(设备+账号 维度):删记录 → 该(设备,账号)的
onboarding_completed 变 false → 客户端任何版本下次登录都重走引导。可靠、不依赖客户端实现。
"""
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.repositories import mutations, queries
from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.onboarding import DeviceOnboardingItem, ResetAllRequest
from app.models.admin import AdminUser
router = APIRouter(
prefix="/admin/api/onboarding",
tags=["admin-onboarding"],
dependencies=[Depends(get_current_admin)],
)
@router.get(
"/devices",
response_model=CursorPage[DeviceOnboardingItem],
summary="设备列表(按设备聚合走过引导的账号)",
)
def list_devices(db: AdminDb) -> CursorPage[DeviceOnboardingItem]:
"""列出走过新手引导的设备(ANDROID_ID),按最近完成时间倒序。没走过引导的设备不在表里、
本就会引导,无需管理,故不列。当前全量返回(上限 500;设备量大再加分页/搜索),
next_cursor 恒 None。"""
rows = queries.list_onboarding_devices(db)
return CursorPage(
items=[DeviceOnboardingItem(**r) for r in rows],
next_cursor=None,
)
@router.post(
"/devices/{device_id}/reset",
response_model=OkResponse,
summary="重置单个设备(该设备所有账号下次重走引导)",
)
def reset_device(
device_id: str,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
"""删该设备的全部 onboarding 完成记录 → 该设备上所有账号下次登录重走引导。"""
# 业务写 + 审计写同一事务(commit=False),最后一起 commit(同 users 各写接口)
deleted = mutations.delete_device_onboarding(db, device_id, commit=False)
write_audit(
db, admin, action="device_onboarding.reset", target_type="device", target_id=device_id,
detail={"deleted_rows": deleted}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
@router.post(
"/reset-all",
response_model=OkResponse,
summary="全部重设(清空所有设备引导记录)",
)
def reset_all(
body: ResetAllRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
"""清空 onboarding_completion 表 → 所有用户在所有设备下次登录都重走引导。
需 confirm=true 防误触(前端另有二次确认弹窗)。"""
if not body.confirm:
raise HTTPException(status_code=400, detail="需 confirm=true 确认")
deleted = mutations.delete_all_onboarding(db, commit=False)
write_audit(
db, admin, action="device_onboarding.reset_all", target_type="onboarding", target_id=None,
detail={"deleted_rows": deleted}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
+36
View File
@@ -12,6 +12,8 @@ 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 (
OpsMarqueeSeedBatchDelete,
OpsMarqueeSeedBatchEnable,
OpsMarqueeSeedBulkCreate,
OpsMarqueeSeedCreate,
OpsMarqueeSeedOut,
@@ -111,6 +113,40 @@ def bulk_generate(
return [_out(s) for s in seeds]
@router.post("/batch-delete", summary="批量删除种子(带审计)")
def batch_delete_seeds(
body: OpsMarqueeSeedBatchDelete,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> dict:
n = ops_marquee.batch_delete(db, body.ids, commit=False)
write_audit(
db, admin, action="ops_marquee_seed.batch_delete", target_type="ops_marquee_seed",
target_id=None, detail={"ids": body.ids, "deleted": n},
ip=get_client_ip(request), commit=False,
)
db.commit()
return {"deleted": n}
@router.post("/batch-enable", summary="批量启用 / 停用种子(带审计)")
def batch_enable_seeds(
body: OpsMarqueeSeedBatchEnable,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> dict:
n = ops_marquee.batch_set_enabled(db, body.ids, body.enabled, commit=False)
write_audit(
db, admin, action="ops_marquee_seed.batch_enable", target_type="ops_marquee_seed",
target_id=None, detail={"ids": body.ids, "enabled": body.enabled, "updated": n},
ip=get_client_ip(request), commit=False,
)
db.commit()
return {"updated": n}
@router.patch("/{seed_id}", response_model=OpsMarqueeSeedOut, summary="改轮播种子(带审计)")
def update_seed(
seed_id: int,
+8 -4
View File
@@ -40,11 +40,13 @@ def list_price_reports(
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[PriceReportOut]:
items, next_cursor = queries.list_price_reports(
items, next_cursor, total = 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,
items=[PriceReportOut.model_validate(r) for r in items],
next_cursor=next_cursor,
total=total,
)
@@ -60,7 +62,9 @@ def approve_price_report(
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
rep = db.get(PriceReport, report_id)
# 行锁(SELECT FOR UPDATE):并发/连点双请求会都读到 pending → 各发一次金币双倍发奖,
# 锁住该行串行化,第二个请求拿锁后看到 approved → 走 400。SQLite 下 FOR UPDATE 为 no-op。
rep = db.get(PriceReport, report_id, with_for_update=True)
if rep is None:
raise HTTPException(status_code=404, detail="上报记录不存在")
if rep.status != "pending":
@@ -88,7 +92,7 @@ def reject_price_report(
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
rep = db.get(PriceReport, report_id)
rep = db.get(PriceReport, report_id, with_for_update=True) # 行锁,同 approve(防并发重复审核)
if rep is None:
raise HTTPException(status_code=404, detail="上报记录不存在")
if rep.status != "pending":
+141 -17
View File
@@ -1,6 +1,7 @@
"""admin 用户管理:列表 + 360 详情(读)+ 封禁/解封 + 手动调金币(写,带审计)。"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
@@ -12,9 +13,12 @@ from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.user import (
AdminUserListItem,
AdminUserOverview,
GrantCashRequest,
GrantCoinsRequest,
SetDebugTraceRequest,
SetUserStatusRequest,
UserCoinRecord,
UserRewardStats,
)
from app.models.admin import AdminUser
from app.repositories import user as user_repo
@@ -27,22 +31,32 @@ router = APIRouter(
)
@router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+分页)")
@router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+排序+分页)")
def list_users(
db: AdminDb,
phone: Annotated[str | None, Query()] = None,
register_channel: Annotated[str | None, Query()] = None,
status: Annotated[str | None, Query()] = None,
nickname: Annotated[str | None, Query(max_length=100)] = None,
created_from: Annotated[datetime | None, Query()] = None,
created_to: Annotated[datetime | None, Query()] = None,
last_login_from: Annotated[datetime | None, Query()] = None,
last_login_to: Annotated[datetime | None, Query()] = None,
sort_by: Annotated[str, Query(pattern="^(id|created_at|last_login_at)$")] = "id",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AdminUserListItem]:
items, next_cursor = queries.list_users(
items, next_cursor, total = queries.list_users(
db, phone=phone, register_channel=register_channel, status=status,
limit=limit, cursor=cursor,
nickname=nickname, created_from=created_from, created_to=created_to,
last_login_from=last_login_from, last_login_to=last_login_to,
sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor,
)
return CursorPage(
items=[AdminUserListItem.model_validate(u) for u in items],
next_cursor=next_cursor,
total=total,
)
@@ -54,6 +68,46 @@ def get_user(user_id: int, db: AdminDb) -> AdminUserOverview:
return AdminUserOverview.model_validate(overview)
@router.get(
"/{user_id}/reward-stats",
response_model=UserRewardStats,
summary="用户提现/看广告统计(提现详情用,按时间窗口)",
)
def get_user_reward_stats(
user_id: int,
db: AdminDb,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
) -> UserRewardStats:
"""提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。"""
if user_repo.get_user_by_id(db, user_id) is None:
raise HTTPException(status_code=404, detail="用户不存在")
return UserRewardStats(
**queries.user_reward_stats(db, user_id, date_from=date_from, date_to=date_to)
)
@router.get(
"/{user_id}/coin-records",
response_model=CursorPage[UserCoinRecord],
summary="用户金币发放记录(提现详情底部表,按时间窗口分页)",
)
def get_user_coin_records(
user_id: int,
db: AdminDb,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[UserCoinRecord]:
items, next_cursor, total = queries.user_coin_records(
db, user_id, date_from=date_from, date_to=date_to, limit=limit, cursor=cursor,
)
return CursorPage(
items=[UserCoinRecord(**r) for r in items], next_cursor=next_cursor, total=total,
)
@router.post("/{user_id}/status", response_model=OkResponse, summary="封禁/解封用户")
def set_user_status(
user_id: int,
@@ -100,7 +154,7 @@ def set_user_debug_trace(
return OkResponse()
@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)")
@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减/设值金币(带审计)")
def grant_user_coins(
user_id: int,
body: GrantCoinsRequest,
@@ -108,27 +162,97 @@ def grant_user_coins(
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
) -> OkResponse:
if body.amount == 0:
raise HTTPException(status_code=400, detail="amount 不能为 0")
user = user_repo.get_user_by_id(db, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
# 负数扣减时不允许扣成负余额(运营误操作保护)
if body.amount < 0:
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False)
if acc_now.coin_balance + body.amount < 0:
raise HTTPException(
status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})"
)
biz_type = "admin_grant" if body.amount > 0 else "admin_deduct"
# set=设为目标值:读当前余额算出要写的差值,仍复用 grant_coins 写一笔流水(沿用原子/审计/扣负保护)
if body.mode == "set":
if body.amount < 0:
raise HTTPException(status_code=400, detail="目标金币值不能为负")
# lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
before = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True).coin_balance
delta = body.amount - before
if delta == 0:
raise HTTPException(status_code=400, detail=f"当前金币已为 {body.amount},无需调整")
else:
if body.amount == 0:
raise HTTPException(status_code=400, detail="amount 不能为 0")
delta = body.amount
# 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿
if delta < 0:
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
if acc_now.coin_balance + delta < 0:
raise HTTPException(
status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})"
)
biz_type = "admin_grant" if delta > 0 else "admin_deduct"
# grant_coins 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
acc, _ = wallet_repo.grant_coins(
db, user_id, body.amount, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
)
detail = {"amount": delta, "balance_after": acc.coin_balance, "reason": body.reason}
if body.mode == "set":
detail.update({"mode": "set", "target": body.amount, "before": before})
write_audit(
db, admin, action="user.coins.grant", target_type="user", target_id=user_id,
detail={"amount": body.amount, "balance_after": acc.coin_balance, "reason": body.reason},
ip=get_client_ip(request), commit=False,
detail=detail, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
@router.post("/{user_id}/cash", response_model=OkResponse, summary="手动增减/设值现金(带审计)")
def grant_user_cash(
user_id: int,
body: GrantCashRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
) -> OkResponse:
"""给指定用户增/减或设值现金(分)。delta:正=发放、负=扣减;set:直接设为目标值。
主要用于让无现金用户直接测试提现。"""
user = user_repo.get_user_by_id(db, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
# set=设为目标值:读当前余额算差值,仍复用 grant_cash 写一笔流水(沿用原子/审计/扣负保护)
if body.mode == "set":
if body.amount_cents < 0:
raise HTTPException(status_code=400, detail="目标现金值不能为负")
# lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
before = wallet_repo.get_or_create_account(
db, user_id, commit=False, lock=True
).cash_balance_cents
delta = body.amount_cents - before
if delta == 0:
raise HTTPException(
status_code=400, detail=f"当前现金已为 {body.amount_cents} 分,无需调整"
)
else:
if body.amount_cents == 0:
raise HTTPException(status_code=400, detail="amount_cents 不能为 0")
delta = body.amount_cents
# 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿
if delta < 0:
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
if acc_now.cash_balance_cents + delta < 0:
raise HTTPException(
status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)"
)
biz_type = "admin_grant" if delta > 0 else "admin_deduct"
# grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
acc, _ = wallet_repo.grant_cash(
db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
)
detail = {
"amount_cents": delta,
"balance_after_cents": acc.cash_balance_cents,
"reason": body.reason,
}
if body.mode == "set":
detail.update({"mode": "set", "target_cents": body.amount_cents, "before_cents": before})
write_audit(
db, admin, action="user.cash.grant", target_type="user", target_id=user_id,
detail=detail, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
+33 -12
View File
@@ -25,6 +25,7 @@ from app.admin.schemas.wallet import (
WithdrawBulkItemResult,
WithdrawDetailOut,
WithdrawLedgerCheckOut,
WithdrawListItemOut,
WithdrawOrderOut,
WithdrawRejectRequest,
WithdrawSummaryOut,
@@ -34,6 +35,7 @@ from app.admin.schemas.wallet import (
from app.core.config import settings
from app.integrations import wxpay
from app.models.admin import AdminUser
from app.repositories import app_config
from app.repositories import wallet as wallet_repo
router = APIRouter(
@@ -43,7 +45,7 @@ router = APIRouter(
)
@router.get("", response_model=CursorPage[WithdrawOrderOut], summary="提现单列表")
@router.get("", response_model=CursorPage[WithdrawListItemOut], summary="提现单列表")
def list_withdraws(
db: AdminDb,
user_id: Annotated[int | None, Query()] = None,
@@ -62,8 +64,8 @@ def list_withdraws(
] = 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(
) -> CursorPage[WithdrawListItemOut]:
items, next_cursor, total = queries.list_all_withdraw_orders(
db,
user_id=user_id,
status=status,
@@ -77,9 +79,16 @@ def list_withdraws(
limit=limit,
cursor=cursor,
)
return CursorPage(
items=[WithdrawOrderOut.model_validate(o) for o in items], next_cursor=next_cursor,
)
# 联表带出手机号/昵称 + 累计成功提现(本页 user_id 批量富化,2 条聚合查询,无 N+1)。
enrichment = queries.withdraw_list_enrichment(db, [o.user_id for o in items])
_empty = {"phone": None, "nickname": None, "cumulative_success_cents": 0}
out_items = [
WithdrawListItemOut.model_validate(o).model_copy(
update=enrichment.get(o.user_id, _empty)
)
for o in items
]
return CursorPage(items=out_items, next_cursor=next_cursor, total=total)
@router.get("/summary", response_model=WithdrawSummaryOut, summary="提现审核台统计")
@@ -87,8 +96,13 @@ 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:
@router.get(
"/health-check",
response_model=WxpayHealthCheckOut,
summary="提现配置健康检查",
dependencies=[Depends(require_role("finance"))], # 暴露密钥路径/配置,限财务+super
)
def withdraw_health_check(db: AdminDb) -> 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] = []
@@ -110,8 +124,15 @@ def withdraw_health_check() -> WxpayHealthCheckOut:
issues.append("微信支付基础配置不完整")
if not settings.WXPAY_AUTH_NOTIFY_URL:
issues.append("免确认授权回调地址未配置")
if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED:
issues.append("自动对账未开启")
# 实际是否自动对账 = env 部署总闸(worker 起没起)AND 运营后台 DB 开关(本轮跑不跑)。
worker_running = settings.WITHDRAW_AUTO_RECONCILE_ENABLED
daily_on = bool(app_config.get_value(db, "withdraw_auto_reconcile_enabled"))
auto_reconcile_enabled = worker_running and daily_on
if not worker_running:
issues.append("自动对账 worker 未启动(部署侧 env WITHDRAW_AUTO_RECONCILE_ENABLED=false)")
elif not daily_on:
issues.append("自动对账运营开关已关闭(系统配置页可开)")
return WxpayHealthCheckOut(
ok=not issues,
@@ -124,7 +145,7 @@ def withdraw_health_check() -> WxpayHealthCheckOut:
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_enabled=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,
@@ -160,7 +181,7 @@ def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut:
withdraw_success_cents=overview["withdraw_success_cents"],
)
recent_withdraws, _ = queries.list_all_withdraw_orders(
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(
+62
View File
@@ -0,0 +1,62 @@
"""看广告金币审计 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="该筛选下复算总条数(全量,不受 limit/only_mismatch 影响)")
mismatch_count: int = Field(
..., description="全量不一致条数(=0 说明公式全部生效;在截断前统计,可信)"
)
truncated: bool = Field(
..., description="展示集是否被 limit 截断(true=还有未返回的明细,请缩小范围或调大 limit)"
)
items: list[AdCoinAuditRow] = Field(..., description="展示明细;only_mismatch=true 时只含 ✗ 行")
+30
View File
@@ -0,0 +1,30 @@
"""admin 广告配置 schemas(穿山甲 app_id/各位ID/验签密钥/各场景开关)。"""
from __future__ import annotations
from pydantic import BaseModel
class AdConfigOut(BaseModel):
"""admin 读到的完整广告配置(含 reward_mkey;admin 有权见,但绝不经 /platform 下发客户端)。"""
app_id: str
reward_code_id: str
compare_feed_code_id: str
coupon_feed_code_id: str
reward_mkey: str
reward_enabled: bool
compare_ad_enabled: bool
coupon_ad_enabled: bool
class AdConfigUpdate(BaseModel):
"""部分更新:只改传入(非 None)字段。空串是合法值(如清空 mkey 回退 .env)。"""
app_id: str | None = None
reward_code_id: str | None = None
compare_feed_code_id: str | None = None
coupon_feed_code_id: str | None = None
reward_mkey: str | None = None
reward_enabled: bool | None = None
compare_ad_enabled: bool | None = None
coupon_ad_enabled: bool | None = None
+90
View File
@@ -0,0 +1,90 @@
"""广告收益报表 schemas。
按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合的只读报表:展示条数、收益(元)、金币、来源。
字段 snake_case;收益按元(float),金币按整数。
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class AdRevenueImpression(BaseModel):
"""聚合行下钻的单条**展示**明细(每次广告展示一条,展开该组时展示)。"""
id: int = Field(..., description="ad_ecpm_record 主键")
created_at: datetime
ecpm: str = Field(..., description="本次展示 eCPM 原始值(分/千次展示)")
revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000")
adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…)")
slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID)")
class AdRevenueRecord(BaseModel):
"""聚合行下钻的单条发奖复算明细(与金币审计同源,展开该组时展示)。"""
record_id: int
created_at: datetime
status: str = Field(..., description="granted / capped / ecpm_missing")
ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示)")
ecpm_factor: float | None = Field(None, description="因子1(eCPM 档);非 granted 为空")
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="复算与实发是否一致")
class AdRevenueDaily(BaseModel):
"""按日期汇总的一天(供前端按天趋势图;全量,不受 limit 影响)。"""
date: str = Field(..., description="北京时间 YYYY-MM-DD")
impressions: int = Field(..., description="当天展示条数合计")
revenue_yuan: float = Field(..., description="当天预估收益合计(元)")
expected_coin: int = Field(..., description="当天应发金币合计")
actual_coin: int = Field(..., description="当天实发金币合计")
class AdRevenueRow(BaseModel):
"""一个聚合组(report_date × user × ad_type × app_env × our_code_id)的汇总。"""
report_date: str = Field(..., description="该组所属日期(北京时间 YYYY-MM-DD)")
user_id: int
ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(历史 Draw 信息流)")
app_env: str | None = Field(None, description="我们的应用:prod(傻瓜比价正式) / test(测试应用);旧数据为空")
our_code_id: str | None = Field(None, description="我们后台配置的代码位 ID(104xxx);旧数据为空")
hour: int | None = Field(None, description="北京时间小时 023(granularity=hour 时有值;按天为 null)")
impressions: int = Field(..., description="展示条数(每条广告展示一条;轮播每条各计一次)")
revenue_yuan: float = Field(..., description="收益(元)= Σ(eCPM元 ÷ 1000);测试应用多为 0")
expected_coin: int = Field(..., description="应发金币(按公式复算,与金币审计同源)")
actual_coin: int = Field(..., description="实发金币(实际入账,按现发奖算法)")
matched: bool = Field(..., description="该组应发==实发(组内任一条不符则 false)")
adns: list[str] = Field(default_factory=list, description="实际填充的底层 ADN 子渠道集合(如 pangle/gdt)")
impression_records: list[AdRevenueImpression] = Field(
default_factory=list,
description="该组逐条展示明细(时间/eCPM/收益/adn);展开下钻用,无发奖也有(只要有展示)",
)
records: list[AdRevenueRecord] = Field(
default_factory=list,
description="该组逐条发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);展开下钻用,纯展示无发奖记录的组为空",
)
class AdRevenueReportOut(BaseModel):
"""报表响应:全量统计 + 按天趋势 + 聚合明细。"""
date_from: str = Field(..., description="报表起始日期(北京时间 YYYY-MM-DD)")
date_to: str = Field(..., description="报表结束日期(北京时间 YYYY-MM-DD,闭区间;单日时与 date_from 相同)")
daily: list[AdRevenueDaily] = Field(..., description="按日期汇总序列(全量,供按天趋势图)")
total: int = Field(..., description="聚合组总数(全量,不受 limit 影响)")
truncated: bool = Field(..., description="明细是否被 limit 截断")
total_impressions: int = Field(..., description="全量展示条数合计")
total_revenue_yuan: float = Field(..., description="全量收益合计(元)")
total_expected_coin: int = Field(..., description="全量应发金币合计")
total_actual_coin: int = Field(..., description="全量实发金币合计")
mismatch_count: int = Field(..., description="应发≠实发的组数(=0 说明全部按公式发放)")
items: list[AdRevenueRow] = Field(..., description="聚合明细(按 用户→类型→代码位 排序)")
+6 -1
View File
@@ -9,10 +9,15 @@ T = TypeVar("T")
class CursorPage(BaseModel, Generic[T]):
"""游标分页响应:items + 下一页游标(next_cursor=None 表示末页)"""
"""分页响应:items + 下一页游标(next_cursor=None 表示末页)+ 可选 total。
next_cursor:offset 分页时即下一页 offset,「加载更多」用;末页为 None。
total:符合筛选条件的总条数,页码分页(antd pagination)用;不需要总数的接口可不传(None)。
"""
items: list[T]
next_cursor: int | None = None
total: int | None = None
class OkResponse(BaseModel):
+70
View File
@@ -0,0 +1,70 @@
"""admin 比价记录 debug 页 schema(独立于 C 端 app/schemas/compare_record:admin 看任意用户、
无 debug_trace 权限闸、展示全量 debug 字段)。phone/nickname 由 queries 瞬态挂在 ORM 实例上。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class AdminComparisonListItem(BaseModel):
"""列表项:概要 + debug 维度概览(不含大字段 raw_payload/llm_calls/明细数组)。"""
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
phone: str | None = None # join User 瞬态(非 DB 列)
nickname: str | None = None # join User 瞬态
business_type: str
trace_id: str
# admin 是 debug 工具,无条件下发 trace_url(不看 user.debug_trace_enabled)
trace_url: str | None = None
status: str
information: str | None = None
store_name: str | None = None
source_platform_name: str | None = None
best_platform_name: str | None = None
source_price_cents: int | None = None
best_price_cents: int | None = None
saved_amount_cents: int | None = None
# debug 概览
total_ms: int | None = None
step_count: int | None = None
llm_call_count: int | None = None
retry_count: int | None = None
device_model: str | None = None
rom_vendor: str | None = None
rom_name: str | None = None
android_version: str | None = None
app_version: str | None = None
created_at: datetime
class AdminComparisonDetail(AdminComparisonListItem):
"""详情:概要 + 全量明细(逐平台对比 / LLM 每次调用 / 原始 payload)。"""
source_platform_id: str | None = None
source_package: str | None = None
best_platform_id: str | None = None
best_deeplink: str | None = None
is_source_best: bool | None = None
total_dish_count: int | None = None
skipped_dish_count: int | None = None
device_id: str | None = None
items: list = []
comparison_results: list = [] # 逐平台对比(价格/rank/coupon/打烊...)
skipped_dish_names: list = []
# 全量环境
device_manufacturer: str | None = None
rom_version: int | None = None
android_sdk: int | None = None
app_version_code: int | None = None
source_app_version: str | None = None
longitude: float | None = None
latitude: float | None = None
# 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}]
llm_calls: list | None = None
# 原始上报全量;「卡在哪一步」从 raw_payload.platform_results[*].status 读
# (store_not_found/items_not_found/below_minimum/unsupported = 卡在 找店/加菜/起送/读价)。
raw_payload: dict | None = None
+1 -1
View File
@@ -10,7 +10,7 @@ class ConfigItemOut(BaseModel):
key: str
label: str
group: str
type: str # int / int_list / dict_str_int
type: str # int / int_list / dict_str_int / bool
help: str | None = None
default: Any
value: Any
+155
View File
@@ -0,0 +1,155 @@
"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)。
对账类字段对淘宝/京东为 None → 前端显示 "-"(无法对账)。
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
# ───────────── 群 ─────────────
class CpsGroupOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
sid: str | None = None # 仅美团群有
name: str
platforms: list[str] = Field(default_factory=list)
member_count: int | None = None
status: str
remark: str | None = None
created_at: datetime
class CpsGroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=128)
# 该群发哪些平台,至少选一个
platforms: list[str] = Field(min_length=1)
# 仅当含 meituan 时需要(留空自动生成);纯淘宝/京东不填,后端置 None。
sid: str | None = Field(default=None, max_length=64, pattern=r"^[A-Za-z0-9]+$")
member_count: int | None = Field(default=None, ge=0)
remark: str | None = Field(default=None, max_length=256)
class CpsGroupUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=128)
platforms: list[str] | None = None
member_count: int | None = Field(default=None, ge=0)
status: str | None = Field(default=None, pattern=r"^(active|archived)$")
remark: str | None = Field(default=None, max_length=256)
# ───────────── 活动 ─────────────
class CpsActivityOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
platform: str
name: str
act_id: str | None = None
product_view_sign: str | None = None
payload: str | None = None # 淘宝淘口令 / 京东链接
image_url: str | None = None # 淘宝落地页图(绝对 URL)
status: str
remark: str | None = None
created_at: datetime
class CpsActivityCreate(BaseModel):
name: str = Field(min_length=1, max_length=128)
platform: str = Field(pattern=r"^(meituan|taobao|jd)$")
# 美团:act_id 或 product_view_sign 二选一
act_id: str | None = Field(default=None, max_length=64)
product_view_sign: str | None = Field(default=None, max_length=128)
# 淘宝:整段淘口令文本 / 京东:推广链接
payload: str | None = Field(default=None, max_length=4096)
# 淘宝落地页图(绝对/相对 URL;淘宝必填,由 router 校验)
image_url: str | None = Field(default=None, max_length=512)
remark: str | None = Field(default=None, max_length=256)
class CpsActivityUpdate(BaseModel):
"""编辑活动(PATCH):全字段可选,只更新传入的非 None 项;最终平台对应字段由 router 校验。"""
name: str | None = Field(default=None, min_length=1, max_length=128)
platform: str | None = Field(default=None, pattern=r"^(meituan|taobao|jd)$")
act_id: str | None = Field(default=None, max_length=64)
product_view_sign: str | None = Field(default=None, max_length=128)
payload: str | None = Field(default=None, max_length=4096)
image_url: str | None = Field(default=None, max_length=512)
remark: str | None = Field(default=None, max_length=256)
status: str | None = Field(default=None, pattern=r"^(active|archived)$")
# ───────────── 生成链接(批量) ─────────────
class CpsReferralLinksRequest(BaseModel):
group_id: int
activity_ids: list[int] = Field(min_length=1)
class CpsReferralLinkItem(BaseModel):
activity_id: int
activity_name: str
platform: str
redirect_url: str # 我们的落地页 /c/{code}(发群用)
code: str
class CpsReferralLinksOut(BaseModel):
group_name: str
results: list[CpsReferralLinkItem]
# ───────────── 对账拉单 ─────────────
class CpsReconcileResult(BaseModel):
fetched: int
inserted: int
updated: int
pages: int
# ───────────── 订单明细 ─────────────
class CpsOrderOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
order_id: str
sid: str | None = None
act_id: str | None = None
pay_price_cents: int | None = None
commission_cents: int | None = None
commission_rate: str | None = None
mt_status: str | None = None
invalid_reason: str | None = None
product_name: str | None = None
pay_time: datetime | None = None
# ───────────── 统计 ─────────────
class CpsGroupStat(BaseModel):
group_id: int | None = None
sid: str | None = None
name: str
platforms: list[str] = Field(default_factory=list)
member_count: int | None = None
click_pv: int = 0
click_uv: int = 0
copy_pv: int = 0 # 淘宝"复制口令"次数
copy_uv: int = 0
# 对账类:淘宝/京东无法对账 → None(前端显示 "-");美团群为数值
order_count: int | None = None
settled_count: int | None = None
canceled_count: int | None = None
gmv_cents: int | None = None
est_commission_cents: int | None = None
settled_commission_cents: int | None = None
class CpsStatsOut(BaseModel):
groups: list[CpsGroupStat]
total_order_count: int
total_est_commission_cents: int
total_settled_commission_cents: int
+22 -1
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from app.core.rewards import FEEDBACK_REWARD_MAX_COINS
class FeedbackOut(BaseModel):
@@ -15,4 +17,23 @@ class FeedbackOut(BaseModel):
contact: str
images: list[str] | None = None
status: str
reject_reason: str | None = None
reward_coins: int | None = None
review_note: str | None = None
reviewed_by_admin_id: int | None = None
reviewed_at: datetime | None = None
created_at: datetime
class FeedbackApproveRequest(BaseModel):
reward_coins: int = Field(
ge=1,
le=FEEDBACK_REWARD_MAX_COINS,
description="采纳后发放金币数",
)
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注")
class FeedbackRejectRequest(BaseModel):
reason: str = Field(min_length=1, max_length=256, description="未采纳原因,用户端可见")
note: str | None = Field(default=None, max_length=256, description="运营内部审核备注")
+22
View File
@@ -0,0 +1,22 @@
"""admin 反馈页二维码卡配置 schemas。"""
from __future__ import annotations
from pydantic import BaseModel, Field
class FeedbackQrOut(BaseModel):
enabled: bool
image_url: str | None = None
title: str
group_name: str
subtitle: str
updated_at: str | None = None
class FeedbackQrUpdate(BaseModel):
"""改文案/开关(均可选,只改传了的字段;图片走单独的上传/删除接口)。"""
enabled: bool | None = None
title: str | None = Field(default=None, max_length=40)
group_name: str | None = Field(default=None, max_length=40)
subtitle: str | None = Field(default=None, max_length=60)
+24
View File
@@ -0,0 +1,24 @@
"""admin 设备维度新手引导管理 schemas。
force_onboarding(按用户)废弃后改用「按设备」:直接操作 onboarding_completion(设备+账号 维度),
客户端任何版本都按它判断是否走引导,故可靠。
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class DeviceOnboardingItem(BaseModel):
"""一台设备(ANDROID_ID)的引导聚合:在该设备走过引导的账号数 + 最近完成时间。"""
device_id: str
account_count: int
last_completed_at: datetime
class ResetAllRequest(BaseModel):
confirm: bool = Field(
False, description="必须 true 才清空全部设备的引导记录(防误调接口;前端另有二次确认弹窗)"
)
+12 -1
View File
@@ -4,7 +4,7 @@
"""
from __future__ import annotations
from pydantic import BaseModel
from pydantic import BaseModel, Field
class OpsMarqueeSeedOut(BaseModel):
@@ -42,6 +42,17 @@ class OpsMarqueeSeedBulkCreate(BaseModel):
enabled: bool = True
class OpsMarqueeSeedBatchDelete(BaseModel):
"""批量删除选中的种子。"""
ids: list[int] = Field(..., max_length=500) # 上限防超大列表(审计行/IN 子句过大)
class OpsMarqueeSeedBatchEnable(BaseModel):
"""批量启用 / 停用选中的种子。"""
ids: list[int] = Field(..., max_length=500)
enabled: bool
class OpsSavingsFeedPreviewItem(BaseModel):
masked_user: str
saved_amount_cents: int
+1 -1
View File
@@ -20,7 +20,7 @@ class OpsStatItemOut(BaseModel):
random_kind: str # mult(×倍率) / add(+绝对增量)
random_step_min: int # 绝对增量区间(基础单位;total_saved 为分)
random_step_max: int
real_offset: int # 真实值模式基数偏移(基础单位)
real_offset: int # 真实值模式保底值(基础单位):展示=max(真实,保底)。列名沿用 real_offset
allow_decrease: bool # 是否允许展示值下降(默认 false = 只增不减)
random_current: int | None = None
random_last_tick_at: str | None = None
+9 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
class PriceReportOut(BaseModel):
@@ -36,6 +36,14 @@ class PriceReportOut(BaseModel):
class PriceReportRejectRequest(BaseModel):
reason: str = Field(min_length=1, max_length=256, description="拒绝理由,用户端记录页会看到")
@field_validator("reason")
@classmethod
def _reason_not_blank(cls, v: str) -> str:
# min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计/用户端记录到空理由
if not v.strip():
raise ValueError("拒绝理由不能为空")
return v.strip()
class PriceReportSummary(BaseModel):
"""审核台顶部各状态计数。"""
+60 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
class AdminUserListItem(BaseModel):
@@ -37,10 +37,68 @@ class AdminUserOverview(BaseModel):
feedback_total: int
class UserRewardStats(BaseModel):
"""提现详情抽屉「用户统计区」:按时间窗口算的提现 + 看广告行为统计。
窗口由 date_from/date_to 决定(都不传 = 注册至今 = 全量);现金余额除外——它是当前快照。
各「*_cash_cents」是把该来源累计发放金币折算成可提现现金(分):100 金币 = 1 分(COIN_PER_YUAN=10000)。
eCPM 单位沿用穿山甲原值「分/千次」。
"""
withdraw_success_cents: int # 累计提现(窗口内 success 金额)
cash_balance_cents: int # 现金余额(当前快照,不随窗口)
withdraw_total: int # 提现总次数(窗口内全部状态)
traditional_task_cash_cents: int # 传统任务提现(非广告非人工的金币折现)
reward_video_count: int # 累计激励视频数(granted 条数)
reward_video_avg_ecpm: float # 平均激励视频 eCPM(分/千次)
reward_video_cash_cents: int # 激励视频提现(金币折现)
feed_count: int # 累计信息流广告数(granted 份数,unit_count 累加)
feed_avg_ecpm: float # 平均信息流广告 eCPM(分/千次)
feed_cash_cents: int # 信息流广告提现(金币折现)
class UserCoinRecord(BaseModel):
"""金币发放记录(提现详情底部表)。source 见 source_label;非广告来源 ecpm 为 None。"""
source: str # reward_video / signin_boost / feed / signin
source_label: str # 激励视频 / 签到膨胀 / 信息流广告 / 签到
created_at: datetime
ecpm: str | None = None # 原始 eCPM(分/千次),非广告为 None
coin: int # 发放金币数
def _strip_reason(v: str) -> str:
# min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计记到空原因
if not v.strip():
raise ValueError("操作原因不能为空")
return v.strip()
class GrantCoinsRequest(BaseModel):
amount: int = Field(..., description="金币变动:正=增加,负=扣减(不可为 0)")
mode: Literal["delta", "set"] = Field(
"delta", description="delta=增减(amount 为变动量) / set=设为(amount 为目标值,须≥0)"
)
amount: int = Field(
...,
description="delta 模式:金币变动(正=增加,负=扣减,不可为 0);set 模式:目标金币值(须≥0)",
)
reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
_v_reason = field_validator("reason")(_strip_reason)
class GrantCashRequest(BaseModel):
mode: Literal["delta", "set"] = Field(
"delta", description="delta=增减(amount_cents 为变动量) / set=设为(amount_cents 为目标值,须≥0)"
)
amount_cents: int = Field(
...,
description="delta 模式:现金变动(分,正=增加,负=扣减,不可为 0);set 模式:目标现金值(分,须≥0)",
)
reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
_v_reason = field_validator("reason")(_strip_reason)
class SetUserStatusRequest(BaseModel):
status: Literal["active", "disabled"] = Field(
+11
View File
@@ -50,6 +50,17 @@ class WithdrawOrderOut(BaseModel):
updated_at: datetime
class WithdrawListItemOut(WithdrawOrderOut):
"""提现单列表项:在提现单字段基础上,补本页用户的手机号/昵称 + 累计成功提现金额(分)。
仅列表接口用(需联表 User + 聚合);详情/批量仍用 WithdrawOrderOut(不带这些字段)。
"""
phone: str | None = None
nickname: str | None = None
cumulative_success_cents: int = 0 # 累计成功提现 = SUM(amount_cents) WHERE status='success'
class WithdrawSummaryOut(BaseModel):
reviewing_count: int
reviewing_amount_cents: int
+58
View File
@@ -0,0 +1,58 @@
"""最新 App 版本写入端点(发布流程 → app-server)。
发车流程出 APK ,把版本号 / 下载链接 / sha256 POST 到这里, app_config(key=latest_app_version)
客户端再 GET /api/v1/platform/app-version 读取做 OTA 检查更新**不是给客户端的接口**:
不走用户 JWT, server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)
密钥未配置(默认空)时直接 503,避免裸奔的写端点也是应急改版本信息(紧急下线/ apk_url)的入口
"""
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 app_config
from app.schemas.platform import AppVersionOut, AppVersionWriteIn
logger = logging.getLogger("shagua.internal.app_version")
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(
"/app-version",
response_model=AppVersionOut,
summary="写最新 App 版本(发布流程→app-server,落 app_config)",
)
def write_app_version(
payload: AppVersionWriteIn,
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> AppVersionOut:
_check_secret(x_internal_secret)
data = payload.model_dump()
app_config.set_app_version(db, data)
logger.info(
"app_version set code=%d name=%s url=%s",
payload.latest_version_code, payload.latest_version_name, payload.apk_url,
)
return AppVersionOut(**data)
+63
View File
@@ -0,0 +1,63 @@
"""启动确认窗兜底样本内部上报端点(pricebot → app-server)。
pricebot launch_confirm_agent 每次靠 LLM 兜底放行一个"静态 PROFILES 没认出"的跨 App
启动确认窗时,把完整样本 POST 到这里落库**不是给客户端的接口**:不走用户 JWT,
server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)密钥未配置
(默认空)时直接 503,避免裸奔写端点
研发定期人工把 exec_success=true 的样本沉淀进 pricebot launch_confirm.PROFILES
"""
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 launch_confirm_sample as repo
from app.schemas.launch_confirm_sample import (
LaunchConfirmSampleIn,
LaunchConfirmSampleOut,
)
logger = logging.getLogger("shagua.internal.launch_confirm")
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(
"/launch-confirm-sample",
response_model=LaunchConfirmSampleOut,
summary="启动确认窗兜底样本上报(pricebot→app-server,落 launch_confirm_sample)",
)
def report_launch_confirm_sample(
payload: LaunchConfirmSampleIn,
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> LaunchConfirmSampleOut:
_check_secret(x_internal_secret)
sid = repo.insert_sample(db, payload)
logger.info(
"launch_confirm_sample id=%d host=%s locale=%s success=%s trace=%s",
sid, payload.host_package, payload.system_locale,
payload.exec_success, payload.trace_id,
)
return LaunchConfirmSampleOut(id=sid)
+110
View File
@@ -0,0 +1,110 @@
"""平台店铺映射内部上报端点(pricebot → app-server)。
pricebot 在淘宝比价拿到 shopId ,把这一行跨平台店铺映射 POST 到这里落库
**不是给客户端的接口**:不走用户 JWT, server 间共享密钥头 `X-Internal-Secret` 校验
(复用 price.py _check_secret, price-observation 同一密钥)
price.py /internal/price-observation 平行:那个落价格事实,这个落店铺身份映射
"""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Header
from app.api.deps import DbSession
from app.api.internal.price import _check_secret
from app.repositories import store_mapping as repo
from app.schemas.store_mapping import (
StoreMappingIn,
StoreMappingInvalidateIn,
StoreMappingInvalidateOut,
StoreMappingOut,
)
logger = logging.getLogger("shagua.internal.store")
router = APIRouter(prefix="/internal", tags=["internal"])
@router.get(
"/store-mapping/lookup",
summary="比价前按源平台店名反查各目标平台已沉淀的店铺 id(命中→pricebot 直接 deeplink)",
)
def lookup_store_mapping(
source_platform: str,
name: str,
db: DbSession,
lat: float | None = None,
lng: float | None = None,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> dict:
_check_secret(x_internal_secret)
result = repo.lookup_nearest(db, source_platform, name, lat, lng)
if result:
hits = ", ".join(
f"{t}:row{v['row_id']}"
f"{'(' + str(v['dist_km']) + 'km)' if 'dist_km' in v else ''}"
f"{v.get('deeplink') or '(无deeplink)'}"
for t, v in result.items()
)
logger.info(
"store_mapping lookup source=%s name=%r geo=(%s,%s) → 命中 %s",
source_platform, name, lat, lng, hits,
)
else:
logger.info(
"store_mapping lookup source=%s name=%r geo=(%s,%s) → MISS",
source_platform, name, lat, lng,
)
return result
@router.post(
"/store-mapping",
response_model=StoreMappingOut,
summary="平台店铺映射内部上报(pricebot→app-server,落 store_mapping)",
)
def report_store_mapping(
payload: StoreMappingIn,
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> StoreMappingOut:
_check_secret(x_internal_secret)
created, row_id = repo.upsert(db, payload)
logger.info(
"store_mapping trace=%s %s row_id=%s source=%s "
"taobao=(%s,%s) jd=(%s,%s) device=%s user=%s",
payload.trace_id, "新建" if created else "合并", row_id, payload.source_platform,
payload.id_taobao, payload.name_taobao, payload.id_jd, payload.name_jd,
payload.source_device_id, payload.source_user_id,
)
return StoreMappingOut(inserted=created, row_id=row_id)
@router.post(
"/store-mapping/invalidate",
response_model=StoreMappingInvalidateOut,
summary="标记某平台 shopId 的缓存 deeplink 失效(pricebot 撞错误页回退时上报,lookup 不再返回)",
)
def invalidate_store_mapping(
payload: StoreMappingInvalidateIn,
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> StoreMappingInvalidateOut:
_check_secret(x_internal_secret)
if payload.platform == "taobao":
affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id)
elif payload.platform == "jd":
affected = repo.mark_jd_deeplink_invalid(db, payload.shop_id)
else:
# 当前只接淘宝/京东; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。
logger.info("store_mapping invalidate 跳过: platform=%s 暂不支持", payload.platform)
return StoreMappingInvalidateOut(ok=True, affected=0)
logger.info(
"store_mapping invalidate platform=%s shop_id=%s → 标记失效 %d",
payload.platform, payload.shop_id, affected,
)
return StoreMappingInvalidateOut(ok=True, affected=affected)
+57 -5
View File
@@ -24,6 +24,7 @@ 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 app_config
from app.repositories import signin as crud_signin
from app.schemas.ad import (
AdRewardStatusOut,
@@ -32,6 +33,8 @@ from app.schemas.ad import (
FeedRewardIn,
FeedRewardOut,
PangleCallbackOut,
RewardNoShowIn,
RewardNoShowOut,
TestGrantIn,
TestGrantOut,
WatchReportIn,
@@ -78,14 +81,22 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 is_verify=false + reason(不发,不重试)
granted / capped is_verify=true + reason=0
"""
if not settings.pangle_callback_configured:
if not settings.PANGLE_CALLBACK_ENABLED:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
)
params = dict(request.query_params)
if not pangle.verify_callback_sign(params, settings.PANGLE_REWARD_SECRET):
# 验签密钥:admin 后台配的 reward_mkey 优先,.env 的 PANGLE_REWARD_SECRET* 兜底(兼容/过渡)。
# 换激励位时后台同步换 mkey 即可,无需改 .env。两者都空才视为未配置。
ad_mkey = app_config.get_ad_config(db).get("reward_mkey") or ""
secrets = ([ad_mkey] if ad_mkey else []) + settings.pangle_reward_secrets
if not secrets:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
)
if not pangle.verify_callback_sign_any(params, secrets):
logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id"))
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign")
@@ -242,10 +253,12 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
ad_type=payload.ad_type, ecpm_raw=payload.ecpm,
ad_session_id=payload.ad_session_id,
adn=payload.adn, slot_id=payload.slot_id,
app_env=payload.app_env, our_code_id=payload.our_code_id,
)
logger.info(
"ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s",
"ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s",
user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id,
payload.app_env, payload.our_code_id,
)
return EcpmReportOut(ok=True)
@@ -304,10 +317,18 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N
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="200", reward_name="测试发奖",
raw="client debug test-grant ecpm=200",
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
@@ -351,6 +372,10 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
ad_session_id=payload.ad_session_id,
adn=payload.adn,
slot_id=payload.slot_id,
feed_scene=payload.feed_scene,
app_env=payload.app_env,
our_code_id=payload.our_code_id,
aborted=payload.aborted,
)
logger.info(
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
@@ -363,3 +388,30 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
unit_count=rec.unit_count,
daily_limit=rewards.get_ad_daily_limit(db),
)
@router.post(
"/reward-noshow",
response_model=RewardNoShowOut,
summary="激励视频提前关闭/未发奖留痕",
dependencies=[Depends(rate_limit(120, 60, "ad-reward-noshow"))],
)
def reward_noshow(payload: RewardNoShowIn, user: CurrentUser, db: DbSession) -> RewardNoShowOut:
"""激励视频展示了但用户提前关/跳过、未触发 S2S 发奖时,客户端 best-effort 上报一条留痕,
让广告收益报表能呈现有展示没发金币的原因不发金币;同一 session 已发奖则跳过
"""
rec = crud_ad.record_reward_noshow(
db,
user.id,
ad_session_id=payload.ad_session_id,
ecpm=payload.ecpm,
adn=payload.adn,
slot_id=payload.slot_id,
app_env=payload.app_env,
our_code_id=payload.our_code_id,
)
logger.info(
"ad reward noshow user_id=%d session=%s watched=%ds -> status=%s",
user.id, payload.ad_session_id, payload.watched_seconds, rec.status,
)
return RewardNoShowOut(ok=True, status=rec.status)
+34 -6
View File
@@ -15,10 +15,12 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.core import test_account
from app.core.ratelimit import rate_limit
from app.core.security import TokenError, decode_token, issue_token_pair
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.integrations.sms import SmsError, send_code, verify_code
from app.repositories import onboarding as onboarding_repo
from app.repositories import user as user_repo
from app.schemas.auth import (
JverifyLoginRequest,
@@ -37,12 +39,17 @@ logger = logging.getLogger("shagua.auth")
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
def _login_response(db, user) -> TokenWithUser:
"""登录类接口共用的响应组装。"""
def _login_response(
user, *, onboarding_completed: bool, force_onboarding: bool = False
) -> TokenWithUser:
"""登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入;
force_onboarding 仅测试号置 True(让应用内重新登录也重走引导)"""
tokens = issue_token_pair(user.id)
return TokenWithUser(
**tokens,
user=UserOut.model_validate(user),
onboarding_completed=onboarding_completed,
force_onboarding=force_onboarding,
)
@@ -66,8 +73,9 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
logger.info("jverify_login ok user_id=%d phone=%s", user.id, mask_phone(phone))
return _login_response(db, user)
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
logger.info("jverify_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(phone), completed)
return _login_response(user, onboarding_completed=completed)
# ===================== 短信登录 =====================
@@ -79,6 +87,12 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
dependencies=[Depends(rate_limit(10, 60, "sms-send"))], # 同 IP 每分钟≤10 次(防一 IP 刷不同号)
)
def sms_send(req: SmsSendRequest) -> SmsSendResponse:
# 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。
# 真正的"免验证码"在 /sms/login 跳过校验;每日上限也在 login 处算(send 不耗额度)。
if test_account.is_test_account(req.phone):
logger.info("test_account sms_send short-circuit (不真发)")
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
try:
cooldown = send_code(req.phone)
except SmsError as e:
@@ -96,6 +110,19 @@ def sms_send(req: SmsSendRequest) -> SmsSendResponse:
dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限)
)
def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
# 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。
# 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。
if test_account.is_test_account(req.phone):
if not test_account.try_consume_quota():
raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试")
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
logger.info("sms_login test_account ok user_id=%d phone=%s", user.id, mask_phone(req.phone))
# onboarding 恒 false + force_onboarding=true:每次登录都重走引导(含应用内退出后重登),
# 不读/不写 onboarding_completion 表。
return _login_response(user, onboarding_completed=False, force_onboarding=True)
if not verify_code(req.phone, req.code):
raise HTTPException(status_code=400, detail="invalid sms code")
@@ -103,8 +130,9 @@ def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
logger.info("sms_login ok user_id=%d phone=%s", user.id, mask_phone(req.phone))
return _login_response(db, user)
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
logger.info("sms_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(req.phone), completed)
return _login_response(user, onboarding_completed=completed)
# ===================== Refresh =====================
+19
View File
@@ -102,6 +102,25 @@ async def intent_step(request: Request) -> dict[str, Any]:
return await _passthrough(request, "/api/intent/step")
@router.post(
"/intent/precoupon/step",
summary="外卖比价 Phase 0 意图识别前先用券 (透传到 pricebot, 仅美团源)",
)
async def intent_precoupon_step(request: Request) -> dict[str, Any]:
# 美团源平台『意图识别前先用券』多帧循环: 客户端在调 /intent/recognize 之前先循环
# 调本端点到 done(continue=false)。订单页底部有『点击使用X红包』就自动选最大免费券
# 用上, 已用券/无券则首帧秒过。与 /intent/step 同属 intent 域, 复用同一透传壳。
return await _passthrough(request, "/api/intent/precoupon/step")
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)")
async def price_step(request: Request) -> dict[str, Any]:
return await _passthrough(request, "/api/price/step")
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传到 pricebot, 终止/未识别拿 trace_url)")
async def trace_finalize(request: Request) -> dict[str, Any]:
# 用户终止 / Phase1 未识别没走到 done 帧, pricebot 没上云也没回传 trace_url。客户端收尾时
# 打这个, _passthrough 按 trace_id 一致性 hash 落到处理这条 trace 的同一 pricebot 进程
# (dir_cache 在那, 才能算对 trace 目录), 由后者打包上云返回 {trace_url}。
return await _passthrough(request, "/api/trace/finalize")
+44 -5
View File
@@ -15,10 +15,13 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Query, status
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
from app.api.deps import CurrentUser, DbSession
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.repositories import comparison as crud_compare
from app.services.pricebot_llm_calls import fetch_llm_calls
from app.schemas.compare_record import (
CompareStatsOut,
ComparisonRecordCreatedOut,
@@ -39,11 +42,18 @@ router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
summary="上报一次比价结果(幂等)",
)
def report_record(
payload: ComparisonRecordIn, user: CurrentUser, db: DbSession
payload: ComparisonRecordIn,
user: CurrentUser,
db: DbSession,
background_tasks: BackgroundTasks,
) -> ComparisonRecordCreatedOut:
rec = crud_compare.upsert_record(db, user_id=user.id, payload=payload)
# LLM 调用明细异步回填:同机拉 pricebot llm_calls 最长 5s 且是 best-effort,放后台
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
# 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。
background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id)
logger.info(
"compare record user=%s trace=%s biz=%s status=%s saved=%s",
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
user.id,
rec.trace_id,
rec.business_type,
@@ -53,6 +63,28 @@ def report_record(
return ComparisonRecordCreatedOut(id=rec.id)
def _backfill_llm_calls(record_id: int, trace_id: str) -> None:
"""后台回填本次比价的 LLM 调用明细 + 派生 llm_call_count/retry_count。
独立 DB session(请求 session 此时已关);拉取/写库失败只 log,绝不影响已落库的上报"""
calls = fetch_llm_calls(trace_id)
if not calls:
return
db = SessionLocal()
try:
rec = db.get(ComparisonRecord, record_id)
if rec is None:
return
rec.llm_calls = calls
rec.llm_call_count = len(calls)
rec.retry_count = sum(1 for c in calls if c.get("error"))
db.commit()
logger.info("backfill llm_calls trace=%s n=%d", trace_id, len(calls))
except Exception as e: # noqa: BLE001 best-effort
logger.warning("backfill llm_calls failed trace=%s: %s", trace_id, e)
finally:
db.close()
@router.get(
"/stats",
response_model=CompareStatsOut,
@@ -73,13 +105,20 @@ def list_records(
db: DbSession,
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
include_trace: bool = Query(
False,
description="客户端开了本机 agent 调试模式时带 true,放行本人记录的 trace_url",
),
) -> ComparisonRecordPage:
items, next_cursor = crud_compare.list_records(
db, user.id, limit=limit, cursor=cursor
)
outs = [ComparisonRecordOut.model_validate(it) for it in items]
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)
if not user.debug_trace_enabled:
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)
# include_trace=true 例外:客户端开了本机 agent 调试模式时带上,放行**本人记录**的 trace_url
# ——list_records 只查 user.id 自己的记录,给本人看自己的调试链接无越权,与实时结果页
# CompResultScreen「debug 权限 OR 本机 agent 调试」同口径(领导 2026-06-12 拍板)。
if not (user.debug_trace_enabled or include_trace):
for o in outs:
o.trace_url = None
return ComparisonRecordPage(items=outs, next_cursor=next_cursor)
+99 -13
View File
@@ -18,12 +18,18 @@ import httpx
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.concurrency import run_in_threadpool
from app.api.deps import DbSession
from app.api.deps import CurrentUser, 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 CouponPromptDismissIn, CouponPromptShouldShowOut
from app.schemas.coupon_state import (
CouponCompletedTodayOut,
CouponPromptDismissIn,
CouponPromptShouldShowOut,
CouponPromptShownIn,
CouponStatsOut,
)
logger = logging.getLogger("shagua.coupon")
@@ -62,11 +68,11 @@ def _extract_coupon_results(resp_json: dict) -> list[dict]:
def _mark_engagement_blocking(
device_id: str, user_id: int | None, engage_type: str
device_id: str, package: 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)
coupon_repo.mark_engagement(db, device_id, package, user_id, engage_type)
def _record_claims_blocking(
@@ -76,6 +82,14 @@ def _record_claims_blocking(
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,
@@ -99,14 +113,17 @@ async def coupon_step(
device_id = meta.get("device_id")
user_id = _to_int(meta.get("user_id")) # 登录态才带;判断不靠它,资产留痕用
trace_id = meta.get("trace_id")
# 发起领券时前台 App 包名(step body 带 "package")。频控按 App,这条 engagement 要记到
# 对应 App 上。App 内「去领取」发起时 package 可能缺/为空 → 退化为 "" 占位(全局态)。
pkg = meta.get("package") or ""
# 领券任务首帧(step=0)= 用户已发起领券 → 记一条今日 engagement(claim_started),
# 今天这台设备不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能
# 今天**这个 App** 不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能
# 连累领券主流程,整段吞掉。
if device_id and meta.get("step") == 0:
try:
await run_in_threadpool(
_mark_engagement_blocking, device_id, user_id, "claim_started"
_mark_engagement_blocking, device_id, pkg, user_id, "claim_started"
)
except Exception as e: # noqa: BLE001
logger.warning("coupon engagement write failed: %s", e)
@@ -160,17 +177,45 @@ async def coupon_step(
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/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]:
"""客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。
频控主判据(管跨重装):弹出即占用今天这个 App "一次"用户领//无视都算用掉
后续点领取/拒绝再由 step/dismiss type 升级 (device, package, )
"""
coupon_repo.mark_engagement(
db, payload.device_id, payload.package, payload.user_id, "shown"
)
return {"ok": True}
@router.post("/prompt/dismiss", summary="用户拒绝/关闭领券引导窗(记今日已 engage)")
def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]:
"""客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天不再弹。
"""客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天**这个 App** 不再弹。
server 在透传链路里看不到"用户拒绝"(拒绝不发起领券),故必须客户端通知
MVP 不鉴权, device_id
频控按 (device, package, ), App 独立MVP 不鉴权, device_id
"""
coupon_repo.mark_engagement(db, payload.device_id, payload.user_id, "dismissed")
coupon_repo.mark_engagement(
db, payload.device_id, payload.package, payload.user_id, "dismissed"
)
return {"ok": True}
@@ -180,12 +225,12 @@ def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict
summary="切到外卖 App 时是否还应弹领券引导窗",
)
def coupon_prompt_should_show(
device_id: str, db: DbSession
device_id: str, db: DbSession, package: str = ""
) -> CouponPromptShouldShowOut:
"""今天这台设备已 engage(领或拒)过 → should_show=false。客户端据此决定弹不弹
(纯后台判据,客户端不再做前台 SP 缓存判断)"""
"""今天这台设备**这个 App** 已 engage(弹/领/拒)过 → should_show=false。各 App 独立:
美团弹过不压淘宝/京东客户端切到目标 App 时带 package (客户端不 "" 全局态)"""
return CouponPromptShouldShowOut(
should_show=not coupon_repo.has_engaged_today(db, device_id)
should_show=not coupon_repo.has_engaged_today(db, device_id, package)
)
@@ -195,3 +240,44 @@ def coupon_prompt_reset(payload: CouponPromptDismissIn, db: DbSession) -> dict[s
开发设置重置今日领券弹窗状态按钮调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)
)
@router.post(
"/completed-today/reset",
summary="重置今日已完成(开发设置全重置用,恢复首页「去领取」卡可点)",
)
def coupon_completed_today_reset(
payload: CouponPromptDismissIn, db: DbSession
) -> dict[str, bool]:
"""删这台设备今天的 completion → has_completed_today 变 false,首页「去领取」卡恢复可点。
/prompt/reset 配套:开发设置重置今日领券弹窗状态一键把今日状态全清MVP 不鉴权"""
coupon_repo.reset_today_completion(db, payload.device_id)
return {"ok": True}
@router.get(
"/stats",
response_model=CouponStatsOut,
summary="累计领券数(「我的」页战绩卡「领取优惠券 X 张」)",
)
def coupon_stats(user: CurrentUser, db: DbSession) -> CouponStatsOut:
"""该登录用户累计领到的券数(SUM(claimed_count),口径见 coupon_repo.sum_claimed_count)。
**鉴权(CurrentUser)**区别于同文件不鉴权的 /step 透传与 /prompt 频控(那些按 device_id):
个人战绩按 user_id 聚合,必须有登录态"""
return CouponStatsOut(coupon_count=coupon_repo.sum_claimed_count(db, user.id))
+208
View File
@@ -0,0 +1,208 @@
"""CPS 群发短链落地:用户点 /c/{code} → 记点击 → 按平台 302 跳 或 返回淘宝落地页。
公网无鉴权(群里任何人点都要能跳/能领)记点击失败绝不影响用户
- 美团/京东: visit + 302 target(美团短链 / 京东链接)
- 淘宝: visit + 返回 H5 落地页(整段淘口令复制按钮);"复制口令" POST /c/{code}/copy copy
"""
from __future__ import annotations
import html
import json
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.core import media
from app.core.config import settings
from app.db.session import get_db
from app.integrations import wx_oauth
from app.models.cps_activity import CpsActivity
from app.models.cps_link import CpsLink
from app.repositories import cps_link as cps_link_repo
from app.repositories import cps_wx_user as cps_wx_user_repo
logger = logging.getLogger("shagua.cps_redirect")
router = APIRouter(tags=["cps-redirect"])
# code 不存在/失效时的兜底落地
_FALLBACK_URL = "https://www.meituan.com/"
# 淘宝活动未设图时的兜底主视觉(存量已回填,基本只在老 link/异常时触发)
_DEFAULT_TAOBAO_IMAGE = "/media/taobao_landing.jpg"
# 微信网页授权拿到的用户标识 cookie(种在 coupon 域,30 天免重复授权)
_WX_OPENID_COOKIE = "wx_openid"
_WX_UINFO_COOKIE = "wx_uinfo" # "1" = 已拿过昵称头像(userinfo),点领券不再跳授权
_COOKIE_MAX_AGE = 30 * 86400
def _is_wechat(request: Request) -> bool:
"""是否微信内置浏览器(网页授权只在微信内有意义,外部浏览器不跳授权)。"""
return "micromessenger" in (request.headers.get("user-agent", "").lower())
def _client_ip(request: Request) -> str | None:
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else None
def _record(
db: Session, link: CpsLink, request: Request, event_type: str, openid: str | None = None
) -> None:
try:
cps_link_repo.record_click(
db, link=link, ip=_client_ip(request),
ua=request.headers.get("user-agent"), event_type=event_type, openid=openid,
)
except Exception:
pass # 记点击失败不阻断
@router.get("/MP_verify_F7wnRQ7xPbhVOWC8.txt", include_in_schema=False)
def wx_mp_domain_verify() -> PlainTextResponse:
"""微信「网页授权域名」归属校验文件。coupon.shaguabijia.com 全反代 app-server,
微信请求 coupon.shaguabijia.com/MP_verify_xxx.txt 验证域名归属;内容=文件名核心串
放域名根目录(本端点即根路径),为微信网页授权( openid)接入做准备"""
return PlainTextResponse("F7wnRQ7xPbhVOWC8")
@router.get("/c/{code}", summary="群发短链落地(微信授权拿 openid + 记点击 + 跳转/淘宝落地页)")
def cps_landing(code: str, request: Request, db: Session = Depends(get_db)):
link = cps_link_repo.get_by_code(db, code)
if link is None:
return RedirectResponse(_FALLBACK_URL, status_code=302)
openid = request.cookies.get(_WX_OPENID_COOKIE)
# 微信内 + 还没拿到 openid + 配了服务号 → 先 base 静默授权拿 openid,再 302 回本页。
# 非微信 / 已有 cookie / 未配服务号 → 直接走原逻辑(openid 可能为 None,绝不阻断领券)。
if openid is None and settings.wx_oauth_active and _is_wechat(request):
redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
auth_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_base", f"base:{code}")
return RedirectResponse(auth_url, status_code=302)
_record(db, link, request, "visit", openid=openid)
if link.platform == "taobao":
activity = db.get(CpsActivity, link.activity_id)
image_url = (activity.image_url if activity else None) or _DEFAULT_TAOBAO_IMAGE
has_uinfo = request.cookies.get(_WX_UINFO_COOKIE) == "1"
return HTMLResponse(
_taobao_landing_html(link.target_url, image_url, code, openid, has_uinfo)
)
# 美团短链 / 京东链接:直接 302 跳
return RedirectResponse(link.target_url, status_code=302)
@router.get("/wx/oauth/cb", include_in_schema=False)
def wx_oauth_cb(code: str, state: str, db: Session = Depends(get_db)):
"""微信网页授权回调。state='base:{原code}'(静默拿 openid) 或 'uinfo:{原code}'(补昵称头像)。
openid(+userinfo) upsert 用户 cookie 302 回落地页任何失败兜底回落地页,
绝不阻断用户领券"""
kind, _, orig_code = state.partition(":")
try:
token = wx_oauth.exchange_code(code) # {openid, access_token, scope, ...}
openid = token["openid"]
link = cps_link_repo.get_by_code(db, orig_code)
group_id = link.group_id if link else None
nickname = headimgurl = unionid = None
if kind == "uinfo" and "userinfo" in (token.get("scope") or ""):
info = wx_oauth.get_userinfo(token["access_token"], openid)
nickname, headimgurl, unionid = (
info.get("nickname"), info.get("headimgurl"), info.get("unionid"),
)
cps_wx_user_repo.upsert(
db, openid=openid, code=orig_code, group_id=group_id,
nickname=nickname, headimgurl=headimgurl, unionid=unionid,
)
except Exception:
logger.exception("[wx_oauth] callback failed state=%s", state)
return RedirectResponse(f"/c/{orig_code}" if orig_code else _FALLBACK_URL, status_code=302)
# userinfo 授权回来带 ?authed=1,落地页据此自动触发复制(把"点领券→授权→复制"衔接为一步)
target = f"/c/{orig_code}" + ("?authed=1" if kind == "uinfo" else "")
resp = RedirectResponse(target, status_code=302)
resp.set_cookie(_WX_OPENID_COOKIE, openid, max_age=_COOKIE_MAX_AGE, httponly=True, samesite="lax")
if nickname:
resp.set_cookie(_WX_UINFO_COOKIE, "1", max_age=_COOKIE_MAX_AGE, samesite="lax")
return resp
@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件,带 openid)")
def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict:
link = cps_link_repo.get_by_code(db, code)
if link is not None:
_record(db, link, request, "copy", openid=request.cookies.get(_WX_OPENID_COOKIE))
return {"ok": True}
def _taobao_landing_html(
token: str, image_url: str, code: str, openid: str | None, has_uinfo: bool
) -> str:
"""淘宝落地页 H5(主视觉图按活动传入,复制淘口令按钮在 75% 屏高)。
image_url html.escape 嵌入 <img src>(防注入);token json.dumps 安全嵌入 JS
uinfo_url:已有 openid 但还没拿过昵称头像时,生成 userinfo 授权链接 用户点领券
时先跳它(交互触发,避免微信快照页),授权回来自动复制已拿过 / openid 则为空,直接复制
"""
safe_img = html.escape(media.to_abs_media_url(image_url) or image_url, quote=True)
uinfo_url = ""
if openid and not has_uinfo and settings.wx_oauth_active:
redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
uinfo_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_userinfo", f"uinfo:{code}")
return (
_TAOBAO_HTML
.replace("__IMAGE_URL__", safe_img)
.replace("__UINFO_URL__", json.dumps(uinfo_url))
.replace("__TOKEN_JS__", json.dumps(token))
)
_TAOBAO_HTML = """<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>淘宝闪购 · 天天领红包</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
body{font-family:-apple-system,"PingFang SC",sans-serif;background:#fff0ef;color:#333;min-height:100vh}
.hero{display:block;width:100%}
.btn-wrap{position:fixed;left:0;right:0;top:75vh;padding:0 20px}
.btn{display:block;width:100%;background:linear-gradient(90deg,#ff5b5b,#ff3b3b);color:#fff;font-size:19px;font-weight:800;text-align:center;padding:16px;border:none;border-radius:30px;box-shadow:0 6px 16px rgba(255,59,59,.4)}
.btn:active{transform:scale(.98)}
.toast{position:fixed;left:50%;top:42%;transform:translate(-50%,-50%);background:rgba(0,0,0,.82);color:#fff;padding:12px 22px;border-radius:10px;font-size:15px;opacity:0;transition:opacity .25s;pointer-events:none;z-index:99;white-space:nowrap}
.toast.show{opacity:1}
</style>
</head>
<body>
<img class="hero" src="__IMAGE_URL__" alt="淘宝闪购 天天领红包">
<div class="btn-wrap"><button class="btn" onclick="copyToken()">复制口令去淘宝领红包</button></div>
<div class="toast" id="toast"></div>
<script>
var TOKEN = __TOKEN_JS__;
var UINFO_URL = __UINFO_URL__; // 非空=还没拿昵称头像,点领券先跳它补(授权回来自动复制)
function showToast(m){var t=document.getElementById('toast');t.textContent=m;t.classList.add('show');setTimeout(function(){t.classList.remove('show')},2200)}
function reportCopy(){try{fetch(location.pathname+'/copy',{method:'POST',keepalive:true})}catch(e){}}
function done(){showToast('复制成功!打开淘宝即可领取');reportCopy()}
function fallback(){
var ta=document.createElement('textarea');ta.value=TOKEN;ta.style.position='fixed';ta.style.opacity='0';
document.body.appendChild(ta);ta.focus();ta.select();
try{document.execCommand('copy');done()}catch(e){showToast('复制失败,请长按手动复制')}
document.body.removeChild(ta);
}
function doCopy(){
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(TOKEN).then(done).catch(fallback)}
else{fallback()}
}
function copyToken(){
// 第一次领券且还没授权过昵称头像:先跳 userinfo 授权(用户点击=交互触发,避免快照页),
// 授权回来 ?authed=1 自动复制;已授权过/ openid 则直接复制
if(UINFO_URL){location.href=UINFO_URL;return}
doCopy();
}
// userinfo 授权回流(?authed=1):自动复制,"点领券→授权→复制"衔接成一步无感
if(location.search.indexOf('authed=1')>=0){doCopy()}
</script>
</body>
</html>"""
+62 -8
View File
@@ -1,7 +1,8 @@
"""帮助与反馈 endpoint。
路由前缀 `/api/v1/feedback`, Bearer 鉴权(反馈绑到登录用户,便于回访)
POST / 提交反馈(multipart:content / contact 必填,images 可选 4 )
POST / 提交反馈(multipart:content 必填;contact 可选(原型改版后客户端已不再采集);images 可选 6 )
GET /records 我的反馈历史(pending/adopted/rejected)
截图复用 [app.core.media] 落盘到 /media/feedback/
"""
@@ -9,20 +10,48 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
from app.api.deps import CurrentUser, DbSession
from app.core import media
from app.repositories import feedback as feedback_repo
from app.schemas.feedback import FeedbackOut
from app.repositories import feedback_qr as feedback_qr_repo
from app.schemas.feedback import (
FeedbackOut,
FeedbackQrConfigOut,
FeedbackRecordCountsOut,
FeedbackRecordOut,
FeedbackRecordsOut,
)
logger = logging.getLogger("shagua.feedback")
router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 4
_CONTENT_MAX = 2000
_MAX_IMAGES = 6
_CONTENT_MAX = 200
_CONTACT_MAX = 128
_VALID_RECORD_STATUS = {"pending", "adopted", "rejected"}
def _app_status(db_status: str) -> str:
return {
"new": "pending",
"handled": "adopted",
"approved": "adopted",
}.get(db_status, db_status)
def _record_out(fb) -> FeedbackRecordOut:
return FeedbackRecordOut(
id=fb.id,
content=fb.content,
images=fb.images or [],
status=_app_status(fb.status),
reject_reason=getattr(fb, "reject_reason", None),
reward_coins=getattr(fb, "reward_coins", None),
created_at=fb.created_at,
)
@router.post("", response_model=FeedbackOut, summary="提交反馈")
@@ -30,7 +59,8 @@ async def submit_feedback(
user: CurrentUser,
db: DbSession,
content: str = Form(...),
contact: str = Form(...),
# 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
contact: str = Form(default=""),
images: list[UploadFile] = File(default=[]),
) -> FeedbackOut:
content = content.strip()
@@ -39,8 +69,6 @@ async def submit_feedback(
raise HTTPException(status_code=400, detail="反馈内容不能为空")
if len(content) > _CONTENT_MAX:
raise HTTPException(status_code=400, detail="反馈内容过长")
if not contact:
raise HTTPException(status_code=400, detail="联系方式不能为空")
if len(contact) > _CONTACT_MAX:
raise HTTPException(status_code=400, detail="联系方式过长")
@@ -61,3 +89,29 @@ async def submit_feedback(
)
logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls))
return FeedbackOut.model_validate(fb)
@router.get("/config", response_model=FeedbackQrConfigOut, summary="反馈页二维码卡配置")
def feedback_config(user: CurrentUser, db: DbSession) -> FeedbackQrConfigOut:
"""运营后台配的反馈页「加群二维码」卡(开关 + 二维码图 + 三行文案)。客户端进反馈页时拉取。"""
return FeedbackQrConfigOut(**feedback_qr_repo.get_config(db))
@router.get("/records", response_model=FeedbackRecordsOut, summary="我的反馈历史")
def feedback_records(
user: CurrentUser,
db: DbSession,
status: str | None = Query(default=None),
) -> FeedbackRecordsOut:
if status is not None and status not in _VALID_RECORD_STATUS:
raise HTTPException(status_code=400, detail="invalid status")
all_records = [_record_out(fb) for fb in feedback_repo.list_feedback(db, user_id=user.id)]
counts = FeedbackRecordCountsOut(
all=len(all_records),
pending=sum(1 for r in all_records if r.status == "pending"),
adopted=sum(1 for r in all_records if r.status == "adopted"),
rejected=sum(1 for r in all_records if r.status == "rejected"),
)
records = [r for r in all_records if r.status == status] if status else all_records
return FeedbackRecordsOut(records=records, counts=counts)
+148 -54
View File
@@ -8,12 +8,12 @@ import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy import nullslast, select
from sqlalchemy.orm import Session, aliased
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.integrations.meituan import MeituanCpsError, _call, get_referral_link, query_coupon
from app.models.meituan_coupon import MeituanCoupon
from app.schemas.meituan import (
CouponCard,
@@ -34,7 +34,7 @@ router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"])
@router.post("/coupons", response_model=CouponListResponse, summary="券列表(为您推荐)")
def list_coupons(req: CouponListRequest) -> CouponListResponse:
if not settings.mt_cps_configured:
return CouponListResponse(items=[], has_next=False, search_id=None)
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
logger.info("[coupons] lon=%.6f lat=%.6f topic=%s", req.longitude, req.latitude, req.list_topic_id)
try:
raw = query_coupon(
@@ -50,14 +50,21 @@ def list_coupons(req: CouponListRequest) -> CouponListResponse:
page_size=req.page_size,
)
except MeituanCpsError as e:
logger.error("query_coupon failed: %s", e)
raise HTTPException(status_code=502, detail=f"meituan: {e}") from e
# 软降级:不再抛 502(避免前端弹报错 toast),返空 + degraded,前端显示「服务繁忙」。
logger.warning("[coupons] query_coupon 失败,降级返空: %s", e)
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
items = [CouponCard.from_raw(it) for it in (raw.get("data") or [])]
items: list[CouponCard] = []
for it in (raw.get("data") or []):
try:
items.append(CouponCard.from_raw(it))
except Exception: # noqa: BLE001
continue
return CouponListResponse(
items=items,
has_next=raw.get("hasNext", False),
search_id=raw.get("searchId"),
status="ok" if items else "empty",
)
@@ -93,59 +100,136 @@ def _commission_pct(card: CouponCard) -> float:
@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)
def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
lon, lat = req.longitude, req.latitude
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]:
# rec 是纯离线库查询,不依赖 MT 凭证、不实时打美团;其余 tab(distance / 默认)要实时打美团,
# 未配凭证直接降级返空(degraded),让前端区分「服务暂不可用」而非「暂无」。
if tab != "rec" and not settings.mt_cps_configured:
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> tuple[list[dict], bool]:
"""返回(items, 是否调用失败)。失败标志用于把默认 feed 整页标 degraded。"""
try:
return (query_coupon(
data = (query_coupon(
longitude=lon, latitude=lat,
platform=platform, biz_line=biz_line,
list_topic_id=topic, page_size=20,
).get("data") or [])
return data, False
except MeituanCpsError:
return []
return [], True
# 距离最近:拉齐全部榜单轮次,合并去重,后端在【完整池子】上全局按距离由近及远排,一次性返回
# (距离排序必须在完整池上做、不能逐页排——这正是之前放前端不合理的根因。)
# 距离最近:搜索召回(外卖搜"外卖" + 到店搜"美食",都 sortField=6 离我最近)一页页拉
# 搜索翻页必须用 searchId(pageNo 翻不动),所以每个 feed 页顺序翻到第 N 页;两路并行、page 1 最快。
# 无状态、不改 APP(传页码即可);按你位置实时算距离(库里没存 POI 经纬度,只能实时)。
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]
lon_i, lat_i = int(lon * 1_000_000), int(lat * 1_000_000)
def _search_page_n(platform: int, biz_line: int | None, keyword: str, n: int) -> tuple[list[dict], bool, bool]:
"""顺序翻到第 n 页(搜索须 searchId 续页),返回(第 n 页 items, 是否还有下一页, 是否调用失败)。"""
sid: str | None = None
data: list[dict] = []
has_next = False
for pg in range(1, n + 1):
body: dict = {
"platform": platform, "searchText": keyword, "sortField": 6,
"longitude": lon_i, "latitude": lat_i, "pageSize": 20,
}
if biz_line is not None:
body["bizLine"] = biz_line
if sid:
body["searchId"] = sid
else:
body["pageNo"] = 1
try:
r = _call("/cps_open/common/api/v1/query_coupon", body)
except MeituanCpsError:
return [], False, True # 调用失败(用于标 degraded)
data = r.get("data") or []
sid = r.get("searchId")
has_next = bool(r.get("hasNext")) and bool(data)
if not data or (not has_next and pg < n):
return [], False, False # 没那么多页了(非错误)
return data, has_next, False
with ThreadPoolExecutor(max_workers=2) as pool:
f_wm = pool.submit(_search_page_n, 1, None, "外卖", req.page)
f_dd = pool.submit(_search_page_n, 2, 1, "美食", req.page)
(wm_data, wm_hn, wm_fail), (dd_data, dd_hn, dd_fail) = f_wm.result(), f_dd.result()
seen: set[str] = set()
cards: list[CouponCard] = []
for raw_list in raws:
for it in raw_list:
for it in wm_data + dd_data:
try:
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)
except Exception: # noqa: BLE001
continue
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)
# 两路都失败且无数据 → degraded;否则有数据 ok / 无数据 empty
status = "degraded" if (not cards and wm_fail and dd_fail) else ("ok" if cards else "empty")
return FeedResponse(items=cards, has_next=wm_hn or dd_hn, page=req.page, status=status)
# 智能推荐(rec,默认):沿用逐轮分页的混合 feed,后端过滤掉佣金率 < 3%。
# 智能推荐(rec):走【离线库】筛佣金率 3%,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)
# 实测库里佣金≥3% 去重后仅 ~578 条(几乎全是外卖;到店团购佣金普遍 <3%):实时按"同城热销榜单"
# 拉既撞限流、又填不满(该榜单中位佣金 ~0.8%,筛完每页剩 0-1 条),故从库出。佣金阈值逻辑不变。
if tab == "rec":
PAGE = 20
try:
base = select(MeituanCoupon).where(MeituanCoupon.commission_percent >= 3.0)
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
MeituanCoupon.dedup_key,
MeituanCoupon.commission_percent.desc(),
).subquery()
m = aliased(MeituanCoupon, deduped)
start = (req.page - 1) * PAGE
rows = db.execute(
select(m)
# 销量高的优先(无销量档排后),同档佣金高优先,id 兜底稳定分页
.order_by(nullslast(m.sale_volume_num.desc()), m.commission_percent.desc(), m.id)
.offset(start)
.limit(PAGE + 1)
).scalars().all()
except Exception: # noqa: BLE001
logger.exception("[feed] rec 库查询失败,降级返空")
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
has_next = len(rows) > PAGE
cards: list[CouponCard] = []
for row in rows[:PAGE]:
try:
card = CouponCard.from_raw(row.raw or {})
except Exception: # noqa: BLE001
continue
if card.product_view_sign:
# 智能推荐不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。
# 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。
card.distance_text = None
card.distance_meters = None
cards.append(card)
return FeedResponse(items=cards, has_next=has_next, page=req.page,
status="ok" if cards else "empty")
# 默认(老客户端不传 tab):沿用逐轮分页的混合 feed,不筛。
page_idx = req.page - 1
if page_idx >= len(_TOPIC_ROUNDS):
return FeedResponse(items=[], has_next=False, page=req.page)
return FeedResponse(items=[], has_next=False, page=req.page, status="empty")
wm_topic, dd_topic = _TOPIC_ROUNDS[page_idx]
with ThreadPoolExecutor(max_workers=2) as pool:
f_wm = pool.submit(_fetch_topic, 1, None, wm_topic)
f_dd = pool.submit(_fetch_topic, 2, 1, dd_topic)
waimai, daodian = f_wm.result(), f_dd.result()
(waimai, wm_fail), (daodian, dd_fail) = 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)
# 两路都失败且无数据 → degraded;否则有数据 ok / 无数据 empty
status = "degraded" if (not items and wm_fail and dd_fail) else ("ok" if items else "empty")
return FeedResponse(items=items, has_next=has_next, page=req.page, status=status)
@router.post("/referral-link", response_model=ReferralLinkResponse, summary="换取推广链接(点抢时调)")
@@ -172,32 +256,42 @@ def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
@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()
# 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。
# (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。)
# 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。
try:
# 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
base = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None))
if req.platform is not None:
base = base.where(MeituanCoupon.platform == req.platform)
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
MeituanCoupon.dedup_key,
MeituanCoupon.sale_volume_num.desc(),
MeituanCoupon.commission_percent.desc(),
).subquery()
# 跨源去重(dedup_key = 品牌|名|价):按销量降序遍历,每个 dedup_key 只留第一条(=销量最高那条)。
# 用 raw(整条原始返回)重建 CouponCard,字段与实时接口完全一致,前端无需改渲染。
seen: set[str] = set()
# 2) 对去重结果按销量降序分页;多取 1 条判断 has_next,只对本页做 from_raw
m = aliased(MeituanCoupon, deduped)
start = (req.page - 1) * req.page_size
rows = db.execute(
select(m)
# 加 id 作稳定 tiebreaker:同销量同佣金的并列项排序确定,避免跨页重复/漏项
.order_by(m.sale_volume_num.desc(), m.commission_percent.desc(), m.id)
.offset(start)
.limit(req.page_size + 1)
).scalars().all()
except Exception: # noqa: BLE001
logger.exception("[top-sales] 库查询失败,降级返空")
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
has_next = len(rows) > req.page_size
cards: list[CouponCard] = []
for row in rows:
if row.dedup_key in seen:
continue
seen.add(row.dedup_key)
for row in rows[:req.page_size]:
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)
return CouponListResponse(items=cards, has_next=has_next, search_id=None,
status="ok" if cards else "empty")
+45 -1
View File
@@ -2,6 +2,7 @@
路由前缀 `/api/v1/platform`:
GET /stats 首页三统计(帮助用户 / 完成比价 / 累计节省),按运营后台配的模式算
GET /flags 客户端运营 feature flag(比价/领券期广告开关等),客户端拉取后缓存
展示模式(real/manual/random,每指标独立)与计算逻辑见 app/repositories/ops_stat.py
"""
@@ -12,9 +13,17 @@ import logging
from fastapi import APIRouter, Query
from app.api.deps import DbSession
from app.repositories import app_config
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
from app.schemas.platform import (
AdConfigPublicOut,
AppFlagsOut,
AppVersionOut,
PlatformStatsOut,
SavingsFeedItem,
SavingsFeedOut,
)
logger = logging.getLogger("shagua.platform")
@@ -35,3 +44,38 @@ def stats(db: DbSession) -> PlatformStatsOut:
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])
@router.get("/flags", response_model=AppFlagsOut, summary="客户端运营 feature flag(不鉴权)")
def flags(db: DbSession) -> AppFlagsOut:
"""客户端拉取运营开关并缓存(app 启动 / 每场比价开始时刷新)。不鉴权:开关非敏感,
且比价无障碍服务取值时未必有登录态值来自 app_config(admin 可改),空库回退默认"""
return AppFlagsOut(
comparing_ad_enabled=bool(app_config.get_value(db, "comparing_ad_enabled")),
)
@router.get("/ad-config", response_model=AdConfigPublicOut, summary="客户端拉广告配置(穿山甲ID+场景开关,不鉴权)")
def ad_config(db: DbSession) -> AdConfigPublicOut:
"""客户端启动/每场广告前拉,缓存后用:app_id + 各位ID + 各场景开关。
不含验签密钥;空库回退默认(=客户端内置值,维持现状)"""
c = app_config.get_ad_config(db)
return AdConfigPublicOut(
app_id=c["app_id"],
reward_code_id=c["reward_code_id"],
compare_feed_code_id=c["compare_feed_code_id"],
coupon_feed_code_id=c["coupon_feed_code_id"],
reward_enabled=c["reward_enabled"],
compare_ad_enabled=c["compare_ad_enabled"],
coupon_ad_enabled=c["coupon_ad_enabled"],
)
@router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)")
def app_version(db: DbSession) -> AppVersionOut:
"""客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。
latest_version_code 与本机 versionCode ;未配置(返回默认 0)时客户端视为已是最新"""
data = app_config.get_app_version(db)
if not data:
return AppVersionOut()
return AppVersionOut(**data)
+48 -2
View File
@@ -12,13 +12,20 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, File, HTTPException, UploadFile
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from app.api.deps import CurrentUser, DbSession
from app.core import media
from app.repositories import onboarding as onboarding_repo
from app.repositories import user as user_repo
from app.repositories import wallet as wallet_repo
from app.schemas.auth import UserOut
from app.schemas.user import OkResponse, ProfileUpdateRequest
from app.schemas.user import (
OkResponse,
OnboardingCompleteRequest,
OnboardingStatusResponse,
ProfileUpdateRequest,
)
logger = logging.getLogger("shagua.user")
@@ -51,8 +58,47 @@ async def upload_avatar(
return UserOut.model_validate(updated)
@router.post("/onboarding/complete", response_model=OkResponse, summary="标记新手引导完成(按 设备+账号)")
def complete_onboarding(
req: OnboardingCompleteRequest, user: CurrentUser, db: DbSession
) -> OkResponse:
"""走完新手引导时调一次。按 (当前账号, device_id) 落一条完成标记,跨卸载重装持久。
幂等:重复调用不报错device_id 取客户端硬件级 ANDROID_ID,与登录请求一致"""
onboarding_repo.mark_completed(db, user_id=user.id, device_id=req.device_id)
logger.info("onboarding complete user_id=%d device_len=%d", user.id, len(req.device_id))
return OkResponse()
@router.get(
"/onboarding/status",
response_model=OnboardingStatusResponse,
summary="查新手引导是否已完成(按 设备+账号)",
)
def onboarding_status(
user: CurrentUser,
db: DbSession,
device_id: str = Query("", max_length=64, description="硬件级 ANDROID_ID;空=按未完成处理"),
) -> OnboardingStatusResponse:
"""已登录用户启动时查:该 (账号, 设备) 走过引导没。false → 客户端重走(运营在 admin 删了该设备
记录即触发)替代原 force_onboarding(按用户)改设备维度后这是"运营触发重走"的查询入口
device_id 为空一律按未完成(同登录:不误跳过)"""
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=device_id)
return OnboardingStatusResponse(completed=completed)
@router.delete("", response_model=OkResponse, summary="注销账号(软删除)")
def delete_account(user: CurrentUser, db: DbSession) -> OkResponse:
# 资金前置校验:注销是软删 + 匿名化,余额一旦留在 deleted 行就锁死(既退不出、新号也
# 拿不回)。有可提现现金 / 在审提现单 → 拒绝注销,引导用户先把钱处理掉。
# (pending 提现转账已在途、对账 worker 不依赖 user.status 照常到账,故不拦 pending。)
if wallet_repo.get_cash_balance_cents(db, user.id) > 0:
raise HTTPException(
status_code=409, detail="账户还有未提现的现金余额,请先提现后再注销"
)
if wallet_repo.has_reviewing_withdraw(db, user.id):
raise HTTPException(
status_code=409, detail="有提现正在审核中,请等审核完成后再注销"
)
media.delete_avatar(user.avatar_url)
user_repo.soft_delete_account(db, user)
logger.info("delete account user_id=%d", user.id)
+18 -2
View File
@@ -35,6 +35,7 @@ from app.schemas.welfare import (
ExchangeResultOut,
TransferAuthResultOut,
TransferAuthStatusOut,
UnbindWechatRequest,
UnbindWechatResultOut,
WithdrawInfoOut,
WithdrawOrderOut,
@@ -151,9 +152,24 @@ def bind_wechat(req: BindWechatRequest, user: CurrentUser, db: DbSession) -> Bin
@router.post("/unbind-wechat", response_model=UnbindWechatResultOut, summary="解绑微信(清空 openid)")
def unbind_wechat(user: CurrentUser, db: DbSession) -> UnbindWechatResultOut:
def unbind_wechat(
user: CurrentUser, db: DbSession, req: UnbindWechatRequest | None = None
) -> UnbindWechatResultOut:
# 有「待审核(reviewing)」提现单时,首次解绑拦下要求确认:这类单还没打款,确认解绑(force)
# 时会被立即退回现金余额(refund_reviewing_withdraws_on_unbind),后台不再挂单,用户需先知情。
# (pending 单转账已在途、解绑影响不到,不拦;见 has_reviewing_withdraw。)
force = req.force if req is not None else False
if not force and crud_wallet.has_reviewing_withdraw(db, user.id):
logger.info("unbind wechat needs_confirm(有待审核提现) user_id=%d", user.id)
return UnbindWechatResultOut(
bound=bool(user.wechat_openid),
needs_confirm=True,
message="你有提现正在审核中,解绑微信后这笔会自动退回到现金余额。确定解绑吗?",
)
# 先把待审核提现立即退回现金(无则 no-op),再清 openid —— 让"解绑即退"名副其实
refunded = crud_wallet.refund_reviewing_withdraws_on_unbind(db, user.id)
crud_wallet.unbind_wechat_openid(db, user.id)
logger.info("unbind wechat ok user_id=%d", user.id)
logger.info("unbind wechat ok user_id=%d force=%s refunded_withdraws=%d", user.id, force, refunded)
return UnbindWechatResultOut(bound=False)
+99 -4
View File
@@ -9,11 +9,16 @@ from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
# 生产环境 JWT secret 的最小可接受长度(字节)。HS256 推荐高熵随机串;<16 视为弱密钥。
_MIN_PROD_SECRET_LEN = 16
# 已知的占位默认值(代码里写死的 default),prod 下绝不能沿用。
_INSECURE_SECRET_DEFAULTS = frozenset({"change-me", "change-me-admin", ""})
class Settings(BaseSettings):
model_config = SettingsConfigDict(
@@ -74,6 +79,19 @@ class Settings(BaseSettings):
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
# ===== 测试账号(release 包全流程联调用)=====
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
# (real 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
# 猜到号后脚本滥用。两个值都能随时改 .env。逻辑全在 app/core/test_account.py,与其他业务解耦。
# ⚠️ TEST_ACCOUNT_PHONE 留空 = 整个功能关闭(生产默认安全;要启用才显式填号)。
TEST_ACCOUNT_PHONE: str = "" # 测试手机号(11 位,如 11111111111);空=关闭整功能
TEST_ACCOUNT_DAILY_LIMIT: int = 500 # 该测试号每日最多登录次数,当日超过即拒绝登录
@property
def test_account_phone(self) -> str:
"""规整后的测试手机号(去空白);空串=功能关闭。"""
return self.TEST_ACCOUNT_PHONE.strip()
# ===== 美团联盟 CPS =====
# 未配置时所有 /api/v1/meituan/* 接口 200 返空(优雅降级),不影响登录/领券等其他业务。
MT_CPS_APP_KEY: str = ""
@@ -90,6 +108,25 @@ class Settings(BaseSettings):
"""美团 CPS 凭证齐全(缺则接口返空,而非 502)。"""
return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET)
# ===== 微信服务号(网页授权) =====
# CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。
# ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。
WX_MP_APPID: str = ""
WX_MP_SECRET: str = ""
# 落地页微信网页授权【总开关】。默认关:服务号认证审核中/未就绪时,落地页走原逻辑
# (不跳授权、不拿 openid),整套微信代码保留。审核通过后 .env 置 true + 重启即启用,无需改代码。
WX_MP_OAUTH_ENABLED: bool = False
@property
def wx_mp_configured(self) -> bool:
"""服务号网页授权凭证齐全(缺则落地页不发起授权,降级为无 openid)。"""
return bool(self.WX_MP_APPID and self.WX_MP_SECRET)
@property
def wx_oauth_active(self) -> bool:
"""落地页是否真正发起微信授权 = 凭证齐全 且 总开关开。"""
return self.wx_mp_configured and self.WX_MP_OAUTH_ENABLED
# ===== 微信支付(商家转账到零钱 / 提现)=====
# 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于
# 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。
@@ -105,6 +142,14 @@ class Settings(BaseSettings):
WITHDRAW_AUTO_RECONCILE_ENABLED: bool = False
WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC: int = 300
WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES: int = 15
# 0 点自动兑金币:由 App 进程内任务 app.core.daily_exchange_worker 跨 0 点跑一轮
# wallet.daily_auto_exchange(原 deploy/daily-exchange.timer 已不再需要,见 deploy/daily-exchange.md)。
# 客户端已删手动兑入口、改为「0 点自动兑现金」,故默认开;运营要临时停在 .env 置 false
# (worker 不启动;脚本也 no-op)。
AUTO_EXCHANGE_ENABLED: bool = True
# 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。
# 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。
AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
@@ -127,8 +172,15 @@ class Settings(BaseSettings):
# ===== 穿山甲激励视频(服务端发奖回调)=====
# 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。
# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",验签用,从后台取到后填 .env。
# 穿山甲后台配置的"奖励校验密钥"(m-key),验签用。每个 GroMore 广告位 m-key 不同(后台各自
# 生成),但共用同一回调 URL → 多个位的 m-key 都要配上。验签时所有非空 m-key 逐个试、任一通过
# 即接受(见 pangle.verify_callback_sign_any / 下面的 pangle_reward_secrets)。
PANGLE_CALLBACK_ENABLED: bool = False
# 推荐:每个激励位的 m-key 分开一行配,清晰不混淆(留空的忽略)。
PANGLE_REWARD_SECRET_TEST: str = "" # 测试应用 激励位 104099649
PANGLE_REWARD_SECRET_TEST_DEDICATED: str = "" # 测试应用 专属激励位 104127529
PANGLE_REWARD_SECRET_PROD: str = "" # 正式应用 激励位 104099389
# 旧用法:单个或逗号分隔的多个 m-key,仍兼容(会与上面三个命名项合并去重)。
PANGLE_REWARD_SECRET: str = ""
# ⚠️ 仅本地联调:打开后开放 POST /api/v1/ad/test-grant,让(已登录的)客户端在没部署公网、
@@ -136,10 +188,23 @@ class Settings(BaseSettings):
# 它让客户端能自助发奖 = 绕过反作弊,**生产必须保持 False**(默认 False;只在本地 .env 设 true)。
AD_REWARD_TEST_GRANT_ENABLED: bool = False
@property
def pangle_reward_secrets(self) -> list[str]:
"""汇总所有 m-key 成列表(去空白、去空项、去重保序)。验签时逐个试、任一通过即接受
( pangle.verify_callback_sign_any)来源可混用:三个命名项 + 旧的逗号分隔 PANGLE_REWARD_SECRET"""
raw = [
*self.PANGLE_REWARD_SECRET.split(","),
self.PANGLE_REWARD_SECRET_TEST,
self.PANGLE_REWARD_SECRET_TEST_DEDICATED,
self.PANGLE_REWARD_SECRET_PROD,
]
cleaned = [s.strip() for s in raw if s and s.strip()]
return list(dict.fromkeys(cleaned)) # 去重保序
@property
def pangle_callback_configured(self) -> bool:
"""回调开关打开且验签密钥已配,才接受发奖回调。"""
return bool(self.PANGLE_CALLBACK_ENABLED and self.PANGLE_REWARD_SECRET)
"""回调开关打开且至少配了一个验签密钥,才接受发奖回调。"""
return bool(self.PANGLE_CALLBACK_ENABLED and self.pangle_reward_secrets)
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
# pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step
@@ -181,6 +246,13 @@ class Settings(BaseSettings):
# MVP 落地页就放 app-server 的 /media 静态目录下(dl.html)。
INVITE_LANDING_URL: str = "https://app-api.shaguabijia.com/media/dl.html"
# ===== CPS 群发短链跳转 =====
# 群发券链接套一层我们的短链 /c/{code} 做点击统计后 302 跳美团。这里是写进 admin
# 生成链接里的对外跳转域名前缀。本地填 http://localhost:8770;生产**强烈建议用独立
# 域名**(大量群发会被微信封,别用主 API 域名连累整个 App)。留空 → 跳转端点用请求
# 的 Host 兜底拼绝对地址。
CPS_REDIRECT_BASE: str = ""
# ===== CORS =====
CORS_ALLOW_ORIGINS: str = ""
@@ -194,6 +266,29 @@ class Settings(BaseSettings):
def is_prod(self) -> bool:
return self.APP_ENV == "prod"
@model_validator(mode="after")
def _enforce_prod_secrets(self) -> "Settings":
"""prod 下强校验 JWT secret,弱/默认/空即启动报错(fail-fast,挡住 token 被伪造)。
只校验两个签发凭证:App 用户的 JWT_SECRET_KEY后台的 ADMIN_JWT_SECRET它们沿用默认值
时任何人都能伪造 access/admin token 账号与后台失陷INTERNAL_API_SECRET 默认空 = 内部端点
关闭( 503),是安全的默认态,故不在此强制dev 不触发,便于本地直接起
"""
if not self.is_prod:
return self
weak: list[str] = []
for name in ("JWT_SECRET_KEY", "ADMIN_JWT_SECRET"):
value = getattr(self, name)
if value in _INSECURE_SECRET_DEFAULTS or len(value) < _MIN_PROD_SECRET_LEN:
weak.append(name)
if weak:
raise ValueError(
f"APP_ENV=prod 但检测到弱/默认密钥: {', '.join(weak)} —— 必须改成 "
f"{_MIN_PROD_SECRET_LEN} 位高熵随机串(否则 JWT 可被伪造 → 用户/后台账号失陷)。"
f"生成示例: python -c \"import secrets; print(secrets.token_urlsafe(48))\""
)
return self
@lru_cache(maxsize=1)
def get_settings() -> Settings:
+23 -4
View File
@@ -11,12 +11,12 @@ from typing import Any
from app.core import rewards as r
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool
CONFIG_DEFS: dict[str, dict[str, Any]] = {
"signin_rewards": {
"default": list(r.SIGNIN_REWARDS), "label": "签到 14 天金币档位",
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
"group": "签到", "type": "int_list",
"help": "第 1~14 天每天签到发的金币;断签重置回第 1 天。长度需为 14",
"help": "第 1~7 天每天签到发的金币;断签重置回第 1 天。长度需为 7",
},
"min_exchange_coin": {
"default": r.MIN_EXCHANGE_COIN, "label": "最低兑换金币",
@@ -63,6 +63,25 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"signin_boost_coin": {
"default": r.SIGNIN_BOOST_COIN, "label": "签到膨胀固定金币",
"group": "签到", "type": "int",
"help": "Day1-Day13 签到后看完激励视频额外发放的固定金币;Day14 不展示也不允许膨胀。",
"help": "Day1-Day6 签到后看完激励视频额外发放的固定金币;Day7 不展示也不允许膨胀。",
},
"comparing_ad_enabled": {
"default": True, "label": "比价/领券期信息流广告",
"group": "看广告", "type": "bool",
"help": (
"开启后,比价进行中 + 领券等候期会在悬浮窗展示穿山甲信息流广告(变现行为);"
"关闭则全程不出广告。客户端按 app 启动 / 每场比价开始时拉取并缓存,故为「最终一致」的"
"远程开关(下一场比价生效),用于出问题时无需发版即可快速止血。debug 包可用本地开关覆盖。"
),
},
"withdraw_auto_reconcile_enabled": {
"default": True, "label": "提现自动对账",
"group": "钱包", "type": "bool",
"help": (
"开启后后台 worker 每隔一段时间自动扫描超时仍「打款中」的提现单并归一化"
"(查微信/退款/撤单)。需部署侧 env WITHDRAW_AUTO_RECONCILE_ENABLED=true 启动 worker "
"进程后此开关才起效;扫描间隔/超时阈值仍由 env 控制。关掉只停自动扫描,"
"提现页「批量对账」手动按钮不受影响。"
),
},
}
+125
View File
@@ -0,0 +1,125 @@
"""0 点金币→现金自动兑换的进程内定时任务。
替代原 systemd timer(deploy/daily-exchange.*):App 启动即自带,
`AUTO_EXCHANGE_CHECK_INTERVAL_SEC` 醒一次,跨进北京新的一天(0 点后)就跑一轮
`wallet.daily_auto_exchange`
健壮性:
- **逐用户幂等**:当天已有 exchange_in 流水的用户跳过( wallet._has_exchange_in_on),
故启动补跑 / 多次唤醒 / 进程重启都安全,不会重复兑
- **当天首跑即补**:进程起来时若当天还没兑过,立即兑一轮(等价原 timer Persistent 补跑)
- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑(防跨进程并发导致 TOCTOU 双兑)
- **开关**:settings.AUTO_EXCHANGE_ENABLED=false 时不启动(与脚本/ timer 同一开关)
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from collections.abc import Iterator
from datetime import date
from pathlib import Path
from sqlalchemy.exc import SQLAlchemyError
from app.core import rewards
from app.core.config import settings
from app.db.session import SessionLocal
from app.repositories import wallet as wallet_repo
logger = logging.getLogger("shagua.daily_exchange")
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "daily_exchange.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 _exchange_once() -> dict:
with SessionLocal() as db:
return wallet_repo.daily_auto_exchange(db)
async def _run_loop() -> None:
interval = max(60, int(settings.AUTO_EXCHANGE_CHECK_INTERVAL_SEC))
lock_stale_after = max(interval * 3, 1800)
with _single_instance_lock(lock_stale_after) as lock_acquired:
if not lock_acquired:
logger.warning("daily auto-exchange skipped: another worker owns lock")
return
await _run_locked_loop(interval)
async def _run_locked_loop(interval: int) -> None:
logger.info("daily auto-exchange worker started interval=%ss", interval)
# 本进程上次跑过的北京日;None=尚未跑过本进程(启动即补当天)。
last_run: date | None = None
try:
while True:
try:
_touch_lock()
today = rewards.cn_today()
if last_run != today:
result = await asyncio.to_thread(_exchange_once)
last_run = today
logger.info("daily auto-exchange done date=%s result=%s", today, result)
except SQLAlchemyError:
logger.exception("daily auto-exchange db error")
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
logger.exception("daily auto-exchange unexpected error")
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.info("daily auto-exchange worker stopped")
raise
def start_daily_exchange_worker() -> asyncio.Task | None:
if not settings.AUTO_EXCHANGE_ENABLED:
logger.info("daily auto-exchange disabled (AUTO_EXCHANGE_ENABLED=false)")
return None
return asyncio.create_task(_run_loop(), name="daily-auto-exchange")
async def stop_daily_exchange_worker(task: asyncio.Task | None) -> None:
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
+43 -6
View File
@@ -37,7 +37,7 @@ def _sniff_ext(data: bytes) -> str | None:
return None
def _save_image(subdir: str, user_id: int, data: bytes) -> str:
def _save_named(subdir: str, name_prefix: str, data: bytes) -> str:
"""通用图片落盘:校验非空 / ≤上限 / 魔数为图片,随机文件名,返回相对 URL。"""
if not data:
raise MediaError("空文件")
@@ -47,11 +47,16 @@ def _save_image(subdir: str, user_id: int, data: bytes) -> str:
if ext is None:
raise MediaError("仅支持 JPEG / PNG / WebP 图片")
fname = f"u{user_id}_{secrets.token_hex(8)}{ext}"
fname = f"{name_prefix}_{secrets.token_hex(8)}{ext}"
(_media_dir(subdir) / fname).write_bytes(data)
return f"{settings.MEDIA_URL_PREFIX}/{subdir}/{fname}"
def _save_image(subdir: str, user_id: int, data: bytes) -> str:
"""用户上传图片落盘(文件名带 user_id 前缀)。"""
return _save_named(subdir, f"u{user_id}", data)
def save_avatar(user_id: int, data: bytes) -> str:
"""保存头像,返回相对 URL(`/media/avatars/<file>`)。"""
return _save_image("avatars", user_id, data)
@@ -67,15 +72,47 @@ def save_report_image(user_id: int, data: bytes) -> str:
return _save_image("price_report", user_id, data)
def delete_avatar(url: str | None) -> None:
"""删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理"""
prefix = f"{settings.MEDIA_URL_PREFIX}/avatars/"
def save_feedback_qr(data: bytes) -> str:
"""保存反馈页二维码(运营后台上传的运营素材,非用户文件),返回相对 URL(`/media/feedback_qr/<file>`)"""
return _save_named("feedback_qr", "qr", data)
def save_cps_image(admin_id: int, data: bytes) -> str:
"""保存 CPS 活动落地页图,返回相对 URL(`/media/cps/<file>`)。admin_id 入文件名便于追溯。"""
return _save_image("cps", admin_id, data)
def to_abs_media_url(rel_url: str | None) -> str | None:
"""相对 `/media/...` → 基于 CPS_REDIRECT_BASE 的绝对 URL(CPS 落地页域名,跨域可访问)。
已是绝对 URL / / base 未配(dev) 时原样返回CPS 活动图入库前转绝对,使落地页
(coupon ) admin-web(另一域, serve /media)都能直接 <img src> 加载
"""
if not rel_url or rel_url.startswith(("http://", "https://")):
return rel_url
base = settings.CPS_REDIRECT_BASE.rstrip("/")
return f"{base}{rel_url}" if base else rel_url
def _delete_managed(subdir: str, url: str | None) -> None:
"""删除本服务托管的某子目录下文件;外部 URL / 空值 / 非本子目录的不处理。"""
prefix = f"{settings.MEDIA_URL_PREFIX}/{subdir}/"
if not url or not url.startswith(prefix):
return
fname = url[len(prefix):]
if not fname or "/" in fname or "\\" in fname or ".." in fname:
return # 防路径穿越
try:
(_media_dir("avatars") / fname).unlink(missing_ok=True)
(_media_dir(subdir) / fname).unlink(missing_ok=True)
except OSError:
pass
def delete_avatar(url: str | None) -> None:
"""删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。"""
_delete_managed("avatars", url)
def delete_feedback_qr(url: str | None) -> None:
"""删除本服务托管的旧反馈页二维码文件;外部 URL 或空值不处理。"""
_delete_managed("feedback_qr", url)
+72 -18
View File
@@ -17,11 +17,10 @@ def cn_today() -> date:
return datetime.now(CN_TZ).date()
# ===== 签到:14 天循环,断签重置回第 1 天 =====
# 第 1→14 天的金币奖励,签到逻辑用 cycle_day(1..14)索引。
# ===== 签到:7 天循环,断签重置回第 1 天(原型 2026-06 由 14 天改 7 天一轮)=====
# 第 1→7 天的金币奖励,签到逻辑用 cycle_day(1..7)索引;一轮 7 天累计 2500 金币(对原型 banner)
SIGNIN_REWARDS: tuple[int, ...] = (
200, 120, 150, 180, 200, 250, 500,
200, 250, 300, 350, 400, 500, 3000,
200, 200, 300, 200, 400, 400, 800,
)
SIGNIN_CYCLE_LEN: int = len(SIGNIN_REWARDS)
@@ -57,13 +56,40 @@ WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
TASK_ENABLE_NOTIFICATION = "enable_notification"
# task_key -> 奖励金币
# 打开消息提醒: 1000 金币(=¥0.1, 客户端原型展示口径; 量级与签到/里程碑相称)。
# 打开消息提醒: 750 金币(客户端原型展示口径 2026-06 调 1000→750; 量级与签到/里程碑相称)。
# 注意: 不再 = 兑换下限(下限已降到 MIN_EXCHANGE_COIN=COIN_PER_CENT=100), test_exchange_flow 改走 grant_coins 直接供款。
# ⚠️打开消息提醒是「可重复领取」任务(2026-06):每次开启通知发一次,金额逐次减半(见 notification_reward),
# 这里的 750 是首次(基数)。客户端只在「通知权限关闭」时展示该任务,开着就隐藏。
TASK_REWARDS: dict[str, int] = {
TASK_ENABLE_NOTIFICATION: 1000,
TASK_ENABLE_NOTIFICATION: 750,
}
def notification_reward(base: int, times_claimed: int) -> int:
"""打开消息提醒第 (times_claimed+1) 次领取的金币。
基数 base(=750),每次减半四舍五入到整数最低 1:
750 375 188 94 47 24 12 6 3 2 1 1
(四舍五入用 (x+1)//2,匹配产品给的 750/375/188/94 序列)
"""
r = max(1, base)
for _ in range(max(0, times_claimed)):
r = max(1, (r + 1) // 2)
return r
def notification_max_claims(base: int) -> int:
"""打开消息提醒最多可领取的次数:减半到最低 1 那次为止(含),之后不再可领。
防止通知一直关着时无限刷最低档 1 金币(发放上限闸,服务端硬限,不依赖客户端只在
通知关闭时才展示的约定)base=750 时序列 7501 在第 11 次落到 1,故上限 11
"""
n = 0
while notification_reward(base, n) > 1:
n += 1
return n + 1 # 含第一次减半到 1 的那一次
# ===== 比价战绩里程碑(累计成功比价 N 次,逐档解锁领金币)=====
# 第 1→6 次的金币奖励(1-based:第 N 次比价解锁第 N 档)。值沿用客户端原型档位。
# 数据源是 comparison_record 里 status='success' 的条数;每档领一次,
@@ -82,6 +108,10 @@ def record_milestone_reward(milestone: int) -> int:
# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。
PRICE_REPORT_REWARD_COINS: int = 1000
# ===== 意见反馈采纳奖励(人工审核按质量发放)=====
# 后台审核反馈时允许发放的单条金币上限。只做后端硬保护,具体档位由 admin-web 呈现。
FEEDBACK_REWARD_MAX_COINS: int = 10000
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
@@ -98,7 +128,11 @@ INVITE_FP_WINDOW_DAYS: int = 7
# ===== 看激励视频 / 信息流广告发金币 =====
# 金币数值体系约定:eCPM 单位按"元/千次展示"处理,单次收入 = eCPM / 1000 元。
# 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),
@@ -113,9 +147,16 @@ AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
(1.0, 11, None),
)
# 客户端可影响的 eCPM 可信上限(分/千次展示):信息流广告一期由客户端上报 eCPM,伪造天价 eCPM
# 可铸出天量金币(见 calculate_ad_reward_coin)。真实 eCPM 一般 <¥100 CPM(=10000 分),档位表顶档
# 为 >¥400(=40000 分);取 ¥500 CPM=50000 分,留足真实头部余量又封死伪造值。钳在唯一计算口
# calculate_ad_reward_coin,故 feed 与 reward_video(回退客户端上报 eCPM 时)一并护住;阈值设在所有
# 真实值之上,不会少发正规奖励。
AD_ECPM_MAX_FEN: int = 50_000
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
"""解析 eCPM 原始值。当前产品口径:SDK 返回值按"元/千次展示"处理。"""
def parse_ecpm_fen(ecpm: str | int | float | None) -> float:
"""解析 eCPM 原始值(穿山甲 getEcpm 原值,单位=分/千次展示)。非法/缺失→0。"""
if ecpm is None:
return 0.0
try:
@@ -125,8 +166,14 @@ def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
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 档位因子:0-100=0.1,101-200=0.3,201-400=0.4,>400=0.6。"""
"""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:
@@ -136,26 +183,33 @@ def ad_ecpm_factor(ecpm_yuan: float) -> float:
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)
def ad_lt_factor(count_after_this: int) -> float:
"""LT 因子。count_after_this 是该账号累计第 N 条/份看视频奖励(不按天重置)"""
count = max(1, 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:
def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: int) -> int:
"""按金币数值体系计算单份广告奖励金币。
单次奖励()=eCPM/1000 × 因子1(eCPM ) × 因子2(LT);再按 1 =10000 金币取整
eCPM 是穿山甲 getEcpm 原值,单位/千次展示; ÷100 转成元(因子判档 + 收益换算都用元)
单次收益()= eCPM元 ÷ 1000(每千次单次) × 因子1(eCPM 元档) × 因子2(LT);
再按 1 =10000 金币取整count_after_this 为账号累计第 N 次看视频(LT 因子用,不按天重置)
eCPM 在此先钳到 AD_ECPM_MAX_FEN(¥500 CPM):信息流广告一期 eCPM 由客户端上报,伪造天价值
会铸天量金币;钳在这唯一入口,feed reward_video 回退客户端 eCPM 的路径都护住,且阈值高于
所有真实值,不影响正规发奖
"""
ecpm_yuan = parse_ecpm_yuan(ecpm)
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this)
ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0)
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this)
return max(0, round(yuan * COIN_PER_YUAN))
SIGNIN_BOOST_COIN: int = 2000
# 签到看广告膨胀:S2S 固定补发(原型 2026-06 由 2000 提到 3000,对应 CTA「看广告最高膨胀至3000金币」)。
SIGNIN_BOOST_COIN: int = 3000
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
+80
View File
@@ -0,0 +1,80 @@
"""测试账号:免验证码登录 + 每次重走新手引导 + 每日使用上限。
为打通 **release 包全流程**( SIM 不走极光一键登录)而设的一个固定测试手机号
对这个号(且仅这个号)放开三件事:
1. **免短信验证码**:`/sms/send` 不真发`/sms/login` 跳过 `verify_code`,real 模式下同样生效
测试时直接填任意验证码即可登入,不用等真短信
2. **每次都走新手引导**:登录响应 `onboarding_completed` 恒为 false,不读/不依赖
onboarding_completion ,所以反复登录都能体验引导
3. **每日使用次数上限**:防被人猜到这个号后写脚本一直刷当天登录数超过上限即拒绝(429),
次日自动归零
手机号与上限都在 .env (`TEST_ACCOUNT_PHONE` / `TEST_ACCOUNT_DAILY_LIMIT`),随时可改
`TEST_ACCOUNT_PHONE` 留空 = 整个功能关闭(生产默认态),`is_test_account()` 对任何号都返回
False,登录/短信回到原逻辑,零影响
计数存**进程内存**( worker 够用, sms.py 同款约定):重启清零 worker 不共享作为
一个测试号的粗粒度防滥用闸够用;且因所有登录都落同一个 phone 同一个 user,滥用面天然只
限于这一个账号要严格持久化/跨进程再迁 DB Redis( sms.py 的技术债)
"""
from __future__ import annotations
import logging
from datetime import datetime
from threading import Lock
from app.core.config import settings
logger = logging.getLogger("shagua.test_account")
# 进程内每日计数:(date_str, 当日已登录次数)。单 worker 有效,重启清零(见模块 docstring)。
_lock = Lock()
_usage: tuple[str, int] = ("", 0)
def _today() -> str:
return datetime.now().strftime("%Y-%m-%d")
def is_enabled() -> bool:
"""功能总开关:配了 TEST_ACCOUNT_PHONE 才启用(空=关闭)。"""
return bool(settings.test_account_phone)
def is_test_account(phone: str) -> bool:
"""该手机号是否为配置的测试账号。功能关闭时对任何号都返回 False。"""
return is_enabled() and phone == settings.test_account_phone
def try_consume_quota() -> bool:
"""测试账号登录时调:当日计数 +1。
Returns:
True 未超当日上限,本次放行(计数已 +1);
False 已达上限,本次拒绝(计数不变)
线程安全;跨天自动归零上限取自 settings.TEST_ACCOUNT_DAILY_LIMIT(可在 .env )
"""
global _usage
limit = settings.TEST_ACCOUNT_DAILY_LIMIT
with _lock:
today = _today()
day, cnt = _usage
if day != today: # 跨天归零
cnt = 0
if cnt >= limit:
_usage = (today, cnt) # 已满,保持不变
logger.warning(
"测试账号 %s 今日登录数已达上限 %d,拒绝", settings.test_account_phone, limit
)
return False
_usage = (today, cnt + 1)
logger.info("测试账号 %s%d/%d 次登录", settings.test_account_phone, cnt + 1, limit)
return True
def _reset_for_test() -> None:
"""仅供单测:清空进程内计数,隔离用例间状态。"""
global _usage
with _lock:
_usage = ("", 0)
+17 -4
View File
@@ -14,8 +14,11 @@ 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 app_config
from app.repositories import wallet as wallet_repo
_AUTO_RECONCILE_KEY = "withdraw_auto_reconcile_enabled"
logger = logging.getLogger("shagua.withdraw_reconcile")
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "withdraw_reconcile.lock"
@@ -59,8 +62,15 @@ def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
_LOCK_PATH.unlink()
def _reconcile_once(older_than_minutes: int) -> dict:
def _reconcile_once(older_than_minutes: int) -> dict | None:
"""读运营开关(app_config):关着返回 None(本轮跳过),开着才真扫单。
env WITHDRAW_AUTO_RECONCILE_ENABLED 是部署级总闸(决定 worker 起不起);
这里的 DB 开关是运营级日常开关,后台一改下一轮即生效跨进程一致无需重启
"""
with SessionLocal() as db:
if not app_config.get_value(db, _AUTO_RECONCILE_KEY):
return None
return wallet_repo.reconcile_pending_withdraws(db, older_than_minutes=older_than_minutes)
@@ -78,16 +88,18 @@ async def _run_loop() -> None:
async def _run_locked_loop(interval: int, older_than: int) -> None:
logger.info(
"withdraw auto reconcile started interval=%ss older_than=%sm",
"withdraw auto reconcile worker started interval=%ss older_than=%sm "
"(runtime on/off via app_config '%s')",
interval,
older_than,
_AUTO_RECONCILE_KEY,
)
try:
while True:
try:
_touch_lock()
result = await asyncio.to_thread(_reconcile_once, older_than)
if result["checked"] or result["resolved"]:
if result is not None and (result["checked"] or result["resolved"]):
logger.info("withdraw auto reconcile result=%s", result)
except WxPayNotConfiguredError:
logger.warning("withdraw auto reconcile skipped: wxpay not configured")
@@ -103,7 +115,8 @@ async def _run_locked_loop(interval: int, older_than: int) -> None:
def start_withdraw_reconcile_worker() -> asyncio.Task | None:
if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED:
logger.info("withdraw auto reconcile disabled")
# 部署级总闸关:worker 进程不启动(运营后台开关此时无效,需先在 env 打开)。
logger.info("withdraw auto reconcile worker not started (env master switch off)")
return None
if not settings.wxpay_configured:
logger.warning("withdraw auto reconcile enabled but wxpay not configured")
+34
View File
@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
"""美团城市字典(地级市)读取。
数据源:app/integrations/data/meituan_cities.json, tools/gen_meituan_cities.py
美团官方城市字典Excel 生成(359 个地级市:city_id / 城市名 / 省份)
实测一个地级市 cityId 已覆盖其下辖县级市供给(徐州 邳州 / 新沂 / 睢宁 ),故无区县层级
- ETL(scripts/pull_meituan_coupons.py)遍历 all_cities() 全量抓取入库
- 读取侧将来按城市过滤,也从这里取 city_id 城市名映射
"""
from __future__ import annotations
import functools
import json
from pathlib import Path
_JSON = Path(__file__).resolve().parent / "data" / "meituan_cities.json"
@functools.lru_cache(maxsize=1)
def all_cities() -> list[dict]:
"""全部城市 [{city_id, name, province}, ...](359 个地级市)。"""
return json.loads(_JSON.read_text(encoding="utf-8"))
@functools.lru_cache(maxsize=1)
def _by_id() -> dict[str, dict]:
return {c["city_id"]: c for c in all_cities()}
def city_name(city_id: str) -> str | None:
"""city_id → 城市名(查不到返回 None)。"""
c = _by_id().get(city_id)
return c["name"] if c else None
File diff suppressed because it is too large Load Diff
+39 -2
View File
@@ -126,13 +126,23 @@ def query_coupon(
def get_referral_link(
*,
product_view_sign: str,
product_view_sign: str | None = None,
act_id: str | None = None,
platform: int = 1,
biz_line: int | None = None,
sid: str | None = None,
link_type_list: list[int] | None = None,
) -> dict[str, Any]:
body: dict[str, Any] = {"productViewSign": product_view_sign}
"""换推广链接。actId(活动物料)与 productViewSign(商品券)二选一。
actId 入参时,出参 data null,链接全在 referralLinkMap(实测)
"""
if not act_id and not product_view_sign:
raise MeituanCpsError("act_id 或 product_view_sign 必填其一")
body: dict[str, Any] = {}
if act_id:
body["actId"] = act_id
else:
body["productViewSign"] = product_view_sign
if platform == 2:
body["platform"] = 2
if biz_line is not None:
@@ -142,3 +152,30 @@ def get_referral_link(
body["linkTypeList"] = link_type_list or [1, 3]
return _call("/cps_open/common/api/v1/get_referral_link", body)
def query_order(
*,
sid: str | None = None,
start_time: int,
end_time: int,
query_time_type: int = 1,
page: int = 1,
limit: int = 100,
) -> dict[str, Any]:
"""按时间窗(+可选 sid)拉 CPS 订单做对账。实测要点:
- 分页是 page / limit(不是 pageNo/pageSize);startTime/endTime 10 位秒级时间戳
- query_time_type: 1 按支付时间 2 按更新时间
- 响应 data.dataList[],每条含 orderId / sid / payPrice() / profit() /
commissionRate / status(2付款 3完成 4取消 5风控 6结算) / payTime() / actId
"""
body: dict[str, Any] = {
"queryTimeType": query_time_type,
"startTime": start_time,
"endTime": end_time,
"page": page,
"limit": limit,
}
if sid:
body["sid"] = sid
return _call("/cps_open/common/api/v1/query_order", body)
+10
View File
@@ -42,3 +42,13 @@ def verify_callback_sign(params: dict[str, str], secret: str) -> bool:
got = params.get("sign") or ""
expect = build_sign(trans_id, secret)
return hmac.compare_digest(got, expect)
def verify_callback_sign_any(params: dict[str, str], secrets: list[str]) -> bool:
"""对**多个** m-key 逐个验签,任一通过即接受。
多广告位场景:每个 GroMore 广告位的 m-key 由后台各自生成互不相同,但本服务用
同一个回调 URL 接所有位的回调配置里放多个密钥(逗号分隔),回调到达时挨个试
仍然安全:伪造者必须知道其中某个 m-key 才能算出合法 sign;空列表 一律失败
"""
return any(verify_callback_sign(params, s) for s in secrets if s)
+75
View File
@@ -0,0 +1,75 @@
"""微信服务号网页授权:构造授权链接 + code 换 openid + 拉 userinfo。
用于 CPS 落地页(coupon.shaguabijia.com)在微信内拿用户身份流程(见微信文档):
授权链接(snsapi_base|snsapi_userinfo) 回调带 code sns/oauth2/access_token openid
(userinfo )sns/userinfo 拿昵称头像
注意:
- 这里的 access_token 网页授权 token,与基础 access_token(cgi-bin/token)不同,
sns/* 接口,不需要 IP 白名单
- secret 只在服务器,不下发客户端
- WX_MP_APPID/SECRET(已认证服务号),区别于 WECHAT_APP_ID(App 移动应用)
"""
from __future__ import annotations
from urllib.parse import quote
import httpx
from app.core.config import settings
_API = "https://api.weixin.qq.com"
_AUTHORIZE = "https://open.weixin.qq.com/connect/oauth2/authorize"
_TIMEOUT = 10
class WxOauthError(Exception):
"""网页授权调用失败(凭证/网络/code 失效等)。调用方兜底为无 openid。"""
def build_authorize_url(redirect_uri: str, scope: str, state: str) -> str:
"""构造网页授权跳转链接。scope: 'snsapi_base'(静默,仅 openid) | 'snsapi_userinfo'(昵称头像)。
微信要求 redirect_uri urlEncode参数顺序固定结尾 #wechat_redirect。
"""
return (
f"{_AUTHORIZE}?appid={settings.WX_MP_APPID}"
f"&redirect_uri={quote(redirect_uri, safe='')}"
f"&response_type=code&scope={scope}&state={quote(state, safe='')}"
f"#wechat_redirect"
)
def exchange_code(code: str) -> dict:
"""code 换网页授权 access_token + openid。返回含 openid/access_token/scope(/unionid)。
code 5 分钟内一次性有效失败(errcode) WxOauthError
"""
resp = httpx.get(
f"{_API}/sns/oauth2/access_token",
params={
"appid": settings.WX_MP_APPID,
"secret": settings.WX_MP_SECRET,
"code": code,
"grant_type": "authorization_code",
},
timeout=_TIMEOUT,
)
data = resp.json()
if "openid" not in data:
raise WxOauthError(f"exchange_code failed: {data}")
return data
def get_userinfo(access_token: str, openid: str) -> dict:
"""拉用户信息(nickname/headimgurl/unionid)。需 scope=snsapi_userinfo 的 access_token。"""
resp = httpx.get(
f"{_API}/sns/userinfo",
params={"access_token": access_token, "openid": openid, "lang": "zh_CN"},
timeout=_TIMEOUT,
)
resp.encoding = "utf-8" # 昵称含中文/emoji,强制 utf-8 避免乱码
data = resp.json()
if "openid" not in data:
raise WxOauthError(f"get_userinfo failed: {data}")
return data
+16 -1
View File
@@ -20,7 +20,11 @@ from app.api.v1.compare import router as compare_router
from app.api.v1.compare_milestone import router as compare_milestone_router
from app.api.v1.compare_record import router as compare_record_router
from app.api.v1.coupon import router as coupon_router
from app.api.v1.cps_redirect import router as cps_redirect_router
from app.api.internal.app_version import router as internal_app_version_router
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
from app.api.internal.price import router as internal_price_router
from app.api.internal.store import router as internal_store_router
from app.api.v1.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
@@ -34,6 +38,10 @@ from app.api.v1.user import router as user_router
from app.api.v1.wallet import router as wallet_router
from app.api.v1.wxpay import router as wxpay_router
from app.core.config import settings
from app.core.daily_exchange_worker import (
start_daily_exchange_worker,
stop_daily_exchange_worker,
)
from app.core.logging import setup_logging
from app.core.withdraw_reconcile_worker import (
start_withdraw_reconcile_worker,
@@ -55,10 +63,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.DATABASE_URL.split("://", 1)[0],
)
reconcile_task = start_withdraw_reconcile_worker()
daily_exchange_task = start_daily_exchange_worker()
try:
yield
finally:
await stop_withdraw_reconcile_worker(reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
logger.info("shutting down")
@@ -102,9 +112,14 @@ app.include_router(savings_router)
app.include_router(ad_router)
app.include_router(order_router)
app.include_router(report_router)
# 内部(server→server)端点:pricebot 上报价格观测,靠共享密钥头校验,不对客户端开放。
# 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。
app.include_router(internal_price_router)
app.include_router(internal_store_router)
app.include_router(internal_app_version_router)
app.include_router(internal_launch_confirm_router)
app.include_router(platform_router)
# CPS 群发短链跳转 /c/{code}(公网无鉴权:记点击 → 302 跳美团)
app.include_router(cps_redirect_router)
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
_media_root = Path(settings.MEDIA_ROOT)
+9
View File
@@ -6,21 +6,30 @@ 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.cps_activity import CpsActivity # noqa: F401
from app.models.cps_group import CpsGroup # noqa: F401
from app.models.cps_link import CpsClick, CpsLink # noqa: F401
from app.models.cps_order import CpsOrder # noqa: F401
from app.models.cps_wx_user import CpsWxUser # 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.launch_confirm_sample import LaunchConfirmSample # noqa: F401
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
from app.models.onboarding import OnboardingCompletion # 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 SigninBoostRecord, SigninRecord # noqa: F401
from app.models.store_mapping import StoreMapping # 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
+8 -3
View File
@@ -4,8 +4,8 @@
由客户端生成,并通过激励视频 extra 透传给 S2S 回调,用于把"展示 eCPM"
"奖励完成"绑定没有 session id 的旧上报仍可作为按天对账补充
`ecpm_raw` 原样存客户端上报的字符串eCPM 单位( / )截至 2026-05-31 尚未最终确认,
确认后再加一列解析好的数值;在此之前对账按"待定单位"处理
`ecpm_raw` 原样存客户端上报的字符串单位是穿山甲 getEcpm 官方口径/千次展示
(非元!),对账由 `core/rewards.parse_ecpm_fen` 解析`parse_ecpm_yuan` ÷100 转元;本列只存原值
"""
from __future__ import annotations
@@ -35,7 +35,12 @@ class AdEcpmRecord(Base):
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 实际展示用的代码位(底层 mediation rit,非客户端配置位)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 客户端上报的 eCPM 原始字符串(单位:元/千次展示,原样存)
# 我们的穿山甲应用环境:prod(傻瓜比价正式应用) / test(测试应用)。客户端按 AdConfig.useProductionApp 上报。
# 与底层 adn 不同:这是「我们用的是哪个 App」,adn 是「聚合后实际填充的子渠道」。旧数据为 NULL。
app_env: Mapped[str | None] = mapped_column(String(16), nullable=True)
# 我们在穿山甲后台配置的代码位 ID(AdConfig.feedCodeId/rewardCodeId 返回的 104xxx,**非** slot_id 的底层 rit)。旧数据为 NULL。
our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 客户端上报的 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)
+6
View File
@@ -30,6 +30,12 @@ class AdFeedRewardRecord(Base):
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。比价与领券共用同一信息流
# 代码位,slot_id/our_code_id 分不出,只能客户端各调用点显式打标;NULL=历史/未升级客户端=未分类。
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
# 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx,由客户端 feed-reward 上报带上。旧数据为 NULL。
app_env: Mapped[str | None] = mapped_column(String(16), nullable=True)
our_code_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")
+4
View File
@@ -33,6 +33,10 @@ class AdRewardRecord(Base):
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)
# 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx。
# S2S 回调本身不带这俩,发奖时按 ad_session_id 匹配 ad_ecpm_record 回填(查不到为 NULL)。
app_env: Mapped[str | None] = mapped_column(String(16), nullable=True)
our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 北京时间日期串 'YYYY-MM-DD',按它等值统计当日发奖次数
reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
# 穿山甲上报的奖励名(参考,不作发奖依据)
+32 -1
View File
@@ -18,7 +18,9 @@ from sqlalchemy import (
JSON,
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
@@ -39,6 +41,9 @@ class ComparisonRecord(Base):
__table_args__ = (
# 同一用户同一次比价(trace_id)只存一条:客户端重试/误点重复上报时幂等覆盖。
UniqueConstraint("user_id", "trace_id", name="uq_comparison_user_trace"),
# 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序;
# 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。
Index("ix_comparison_status_created", "status", "created_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -90,13 +95,39 @@ class ComparisonRecord(Base):
# ===== 明细(JSON,越详细越好)=====
# 下单菜品 [{name, qty, specs?}]
items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名)
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name, applied_coupons}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名;applied_coupons=[{name,amount}] 多券明细)
comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 目标平台未找到、跳过的菜名
skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 客户端上报的原始 payload(calibration + done.params 全量),未来取数兜底
raw_payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
# ===== debug 维度(客户端上报;旧记录 / 未发版客户端为 None)=====
# 设备环境:无障碍比价高度依赖机型/ROM,这是排"某机型跑不通"类问题的头号线索
device_model: Mapped[str | None] = mapped_column(String(64), nullable=True)
device_manufacturer: Mapped[str | None] = mapped_column(String(64), nullable=True)
rom_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True) # vivo/OPPO/Xiaomi/HUAWEI/HONOR/samsung
rom_name: Mapped[str | None] = mapped_column(String(32), nullable=True) # OriginOS/ColorOS/HyperOS/MIUI/EMUI/HarmonyOS...
rom_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
android_version: Mapped[str | None] = mapped_column(String(16), nullable=True) # Build.VERSION.RELEASE
android_sdk: Mapped[int | None] = mapped_column(Integer, nullable=True) # Build.VERSION.SDK_INT
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) # 我们 app versionName
app_version_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 被操控的源平台 App 版本(美团/淘宝/京东),排"平台 App 改版导致 UI 适配失效"用
source_app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 比价时设备定位(影响淘宝 deeplink / 距离匹配)
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
# ===== 性能 / 过程统计(debug 用)=====
total_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) # 客户端整场比价墙钟耗时
step_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 客户端 agent loop 总步数
llm_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 本次 LLM 调用次数(server 从 llm_calls 算)
retry_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # LLM 失败重试次数(server 从 llm_calls error 算)
# 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}];
# server 收上报后按 trace_id 同机拉 pricebot 落库(见 compare_record 端点)。旧记录/未采集为 None。
llm_calls: Mapped[list | None] = mapped_column(_JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)

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