Compare commits

...

36 Commits

Author SHA1 Message Date
marco 43f28d358b docs: 补全 API 与数据库文档
- 新增 device-liveness(无障碍存活监控)、internal(内部回写端点)、cps-redirect(CPS 短链落地)三族单文件文档
- API 总览补录此前缺失端点:coupon prompt 频控、report/invite、wxpay 回调、platform 客户端配置、feedback/user/ad 零散读端点
- database 新增 cps_wx_user / device_liveness / launch_confirm_sample 三表文档,更新 OVERVIEW/README/comparison_record/数据库迁移/后端技术实现

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 04:07:54 +08:00
zhuzihao 128db7fb86 feat(admin): 广告收益报表改为逐条广告事件 + 用户列显示手机号 (#72)
报表主表从「按 用户×类型×应用×代码位 聚合」改成「逐条广告事件」(每次广告一行):
- 激励视频:展示(ad_ecpm)与发奖(ad_reward)按 ad_session_id 合并成一行,直接给出
  eCPM/收益 + 状态/应发/实发/一致;展开看该条金币复算因子
- 信息流:轮播每条展示各一行;整场发奖(client_event_id 与展示 impressionId 对不上)单独成行
- 纯展示行不计对账(matched 恒 true);有展示无发奖 / 有发奖无展示各自成行
- 每行补 user_phone(批量查 User.phone,完整不脱敏,与用户/钱包/比价记录页一致)
- 合计与对账在全量上统计、不受 limit 影响;event_key 作前端稳定 rowKey

ad_audit.audit_rows 顺带补返回 ad_session_id(供展示↔发奖按会话合并)。
真实库验证:逐条输出正确、合计交叉核对一致(展示条数=ecpm行数、实发=库实发)、schema 校验通过。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #72
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-24 03:54:01 +08:00
zhuzihao c222137b2a feat(auth): 默认昵称加“用户”前缀(用户+9 位随机串) (#70)
新用户注册即分配的默认昵称由 9 位字母数字随机串改为“用户”+9 位随机串(如 用户aB3xK9mP2),更友好可读;长度 11 仍在 nickname(64)与改名上限(20)内。同步更新 test_login_assigns_username_and_nickname 断言。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #70
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-24 03:53:58 +08:00
zhuzihao d8c7a6d1ef feat(auth): 测试账号免验证码登录 + 每次走引导 + 每日上限 (#69)
Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #69
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-24 03:53:52 +08:00
chenshuobo 4a9d3f1d1a 调整首页滚动条用户名称打码规则 (#68)
改了什么:将 savings-feed 用户名脱敏改为 PRD 规则;有昵称按长度打码,无昵称真实用户显示 用户*****后两位;种子和兜底继续合成多样化脱敏名。
为什么改:避免旧规则出现 用户********xxx,与产品要求的用户标识打码格式保持一致。
验证方式:本地请求 /api/v1/platform/savings-feed?limit=8,确认返回 用户*****xx、首字+**、首字+***+末 等新格式。

---------

Co-authored-by: lowmaster-chen <1119780489@qq.com>
Reviewed-on: #68
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-23 21:09:23 +08:00
chenshuobo b07ccb4bf5 feat(meituan-coupon): 采集记录头图大小/类型 + 读取侧缩放口子 + 回填脚本 (#71)
Reviewed-on: #71
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-23 21:09:10 +08:00
marco 2164155a23 docs(cps): 加 CPS 发券分发 + 微信授权 交接文档
设群/建活动/生成落地页短链/美团对账/统计 + 微信网页授权拿 openid 做用户级统计的
完整说明,含数据流/数据表/平台差异/端点清单/配置项/代码地图/部署/排障/已知技术债。
挂入 docs/README 的 guides 索引。供他人接手。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:02:08 +08:00
marco e81fd0a176 fix(alembic): 合并多 head(#65 device 迁移 + comparison_token_cols)
#65 的 device 迁移链与 comparison_token_cols 都从 ceb286289426 分叉成双 head,alembic upgrade head 报 Multiple head revisions;加 merge 迁移 4dc2af7ebe74 合并(空 DDL 仅接链)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:58:30 +08:00
chenshirui a6f55a00b8 feat(device): 无障碍保护存活心跳检测 + 掉线后置检测(pull) (#65)
Co-authored-by: 陈世睿 <2839904623@qq.com>
Reviewed-on: #65
Co-authored-by: chenshirui <chenshirui@wonderable.ai>
Co-committed-by: chenshirui <chenshirui@wonderable.ai>
2026-06-23 17:33:38 +08:00
marco a584e1f59f feat(comparison): 比价记录累计 LLM token(入/出)并在 admin 展示
server 从 llm_calls[].usage 累加 prompt/completion_tokens 落库,admin 比价记录列表/详情据此展示 token 数与估算成本;配套迁移 comparison_token_cols。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:24:54 +08:00
marco e13f0f7658 fix(cps): 群内微信用户按"本群点击"算,不用 first_group_id
first_group_id 只记首次授权群;同一用户(cookie 已存)再去别的群不再走授权回调,
会被漏算(实测:用户首次在群47授权,再去群52点击,群52详情显示0)。
改为:在本群有带 openid 点击的 distinct openid 即本群用户,关联 cps_wx_user 画像。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:36:57 +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
148 changed files with 6922 additions and 605 deletions
+14
View File
@@ -27,6 +27,11 @@ JG_PRIVATE_KEY_PATH=./secrets/jverify_rsa_private.pem
JG_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify
JG_REQUEST_TIMEOUT_SEC=15
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
HEARTBEAT_MONITOR_ENABLED=true
HEARTBEAT_TIMEOUT_MINUTES=10
HEARTBEAT_SCAN_INTERVAL_SEC=60
# ===== 短信 (mock 模式) =====
# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。
# 后续接阿里云/腾讯云短信时,改成 false 并填供应商相关 key。
@@ -34,6 +39,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=
+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 device#65 and comparison_token_cols heads
Revision ID: 4dc2af7ebe74
Revises: comparison_token_cols, ad_feed_reward_trace_id
Create Date: 2026-06-23 17:57:54.083531
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4dc2af7ebe74'
down_revision: Union[str, Sequence[str], None] = ('comparison_token_cols', 'ad_feed_reward_trace_id')
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,42 @@
"""ad_feed_reward_record.trace_id (比价看广告金币归属到比价记录)
Revision ID: ad_feed_reward_trace_id
Revises: device_kill_alert_pending
Create Date: 2026-06-21
比价等候期信息流广告(feed_ad_reward)结算时,客户端带上本场比价 trace_id 落此列;
比价记录页按 trace_id 聚合本场实发金币,显示「比价赚 N 金币」。领券/福利/旧客户端 = NULL。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ad_feed_reward_trace_id'
down_revision: Union[str, Sequence[str], None] = 'device_kill_alert_pending'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# SQLite 下 ADD COLUMN(可空) 与 CREATE INDEX 均原生支持,无需 batch_alter_table。
op.add_column(
'ad_feed_reward_record',
sa.Column('trace_id', sa.String(length=64), nullable=True),
)
op.create_index(
op.f('ix_ad_feed_reward_record_trace_id'),
'ad_feed_reward_record',
['trace_id'],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f('ix_ad_feed_reward_record_trace_id'),
table_name='ad_feed_reward_record',
)
op.drop_column('ad_feed_reward_record', 'trace_id')
+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,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,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)
+37
View File
@@ -0,0 +1,37 @@
"""add input_tokens / output_tokens to comparison_record
比价记录增加本次 LLM 累计 token(server 从 llm_calls[].usage 累加),
供 admin 比价记录页展示 token 数与估算成本。
Revision ID: comparison_token_cols
Revises: feedback_review_fields
Create Date: 2026-06-23 00:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "comparison_token_cols"
down_revision: Union[str, Sequence[str], None] = "feedback_review_fields"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"comparison_record",
sa.Column("input_tokens", sa.Integer(), nullable=True),
)
op.add_column(
"comparison_record",
sa.Column("output_tokens", sa.Integer(), nullable=True),
)
def downgrade() -> None:
op.drop_column("comparison_record", "output_tokens")
op.drop_column("comparison_record", "input_tokens")
+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 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,39 @@
"""device.kill_alert_pending (无障碍掉线「后置检测」待提醒标记)
Revision ID: device_kill_alert_pending
Revises: device_liveness_table
Create Date: 2026-06-19
后置检测 pull 版(spec/accessibility-liveness-pull-prompt.md):worker 检出心跳掉线即置此标记,
客户端下次进 App 拉取到 → 弹「开启自启动」引导,交互后 ack 清回。与 liveness_state 解耦,故单列。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'device_kill_alert_pending'
down_revision: Union[str, Sequence[str], None] = 'device_liveness_table'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# server_default=sa.false() 跨方言: SQLite 渲染 0 / PG 渲染 false(别用 sa.text("0"),PG 布尔严格会报错),
# 给存量行回填 False。
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
batch_op.add_column(
sa.Column(
'kill_alert_pending',
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
def downgrade() -> None:
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
batch_op.drop_column('kill_alert_pending')
+53
View File
@@ -0,0 +1,53 @@
"""device table (无障碍保护存活检测 + 极光推送)
Revision ID: device_liveness_table
Revises: ceb286289426
Create Date: 2026-06-15 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'device_liveness_table'
down_revision: Union[str, Sequence[str], None] = 'ceb286289426'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'device_liveness',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('device_id', sa.String(length=128), nullable=False),
sa.Column('registration_id', sa.String(length=64), nullable=True),
sa.Column('platform', sa.String(length=16), nullable=False),
sa.Column('app_version', sa.String(length=32), nullable=True),
sa.Column('ever_protected', sa.Boolean(), nullable=False),
sa.Column('last_heartbeat_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_report_protection_on', sa.Boolean(), nullable=False),
sa.Column('liveness_state', sa.String(length=16), nullable=False),
sa.Column('notified_at', sa.DateTime(timezone=True), 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.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'device_id', name='uq_device_liveness_user_device'),
)
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_device_liveness_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_device_liveness_device_id'), ['device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_device_liveness_last_heartbeat_at'), ['last_heartbeat_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_device_liveness_last_heartbeat_at'))
batch_op.drop_index(batch_op.f('ix_device_liveness_device_id'))
batch_op.drop_index(batch_op.f('ix_device_liveness_user_id'))
op.drop_table('device_liveness')
@@ -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,34 @@
"""meituan_coupon image size & type
给 meituan_coupon 增加 image_size(字节)/ image_type(MIME)两列,采集时 HEAD 头图得到,
供读取侧按图片大小决定是否拼缩放参数提速。两列均 nullable,不动存量行;下一轮全量 ETL
upsert 自动回填,无需手动 backfill。
Revision ID: meituan_coupon_image_meta
Revises: feedback_review_fields
Create Date: 2026-06-23 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "meituan_coupon_image_meta"
down_revision: str | Sequence[str] | None = "feedback_review_fields"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("meituan_coupon", schema=None) as batch_op:
batch_op.add_column(sa.Column("image_size", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("image_type", sa.String(length=32), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("meituan_coupon", schema=None) as batch_op:
batch_op.drop_column("image_type")
batch_op.drop_column("image_size")
+6
View File
@@ -14,15 +14,18 @@ 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
@@ -88,9 +91,12 @@ 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)
+4
View File
@@ -67,6 +67,7 @@ def _reward_video_rows(
"scene": "reward_video",
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
@@ -88,6 +89,7 @@ def _reward_video_rows(
"scene": "reward_video",
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
@@ -154,6 +156,7 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"scene": "feed",
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
@@ -174,6 +177,7 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"scene": "feed",
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
+145 -152
View File
@@ -1,17 +1,18 @@
"""admin 广告收益报表:按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合(单表含发奖对账)。
"""admin 广告收益报表:**逐条广告事件**列表(每行一次广告,含展示 + 发奖对账)。
只读。聚合键 = user_id × ad_type × app_env × our_code_id;每组一行同时给出:
- 展示条数 + 收益:`ad_ecpm_record`(每行 = 客户端一次广告展示;收益 = Σ eCPM元 ÷ 1000)。
激励视频每次展示上报一行;信息流轮播每条展示各上报一行(每条独立 id,不复用会话)
- 应发金币 / 实发金币:复用金币审计的**逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径,
不另写公式),把每条发奖记录的 expected/actual 按同维度求和;`matched` = 组内**逐条**全部一致
(任一条不符该组即不符,不用「应发和==实发和」以免互相抵消掩盖错误)。**不改发奖逻辑**,只读复算
只读。每行 = 一次广告事件(不再按用户聚合):
- **激励视频**:一次观看 = 1 条展示(ad_ecpm)+ 1 条发奖(ad_reward),按 ad_session_id 合并成一行,
直接给出 eCPM / 收益 + 状态 / 应发 / 实发 / 一致;点开看该条金币复算因子
- **信息流**:轮播每条展示各一行(impressionId 各自独立);整场发奖(ad_feed_reward,client_event_id)
与逐条展示无法对应,单独成「纯发奖」行。
- 兜底:有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)都各自成行
展示与发奖来自不同表,做并集:有展示无发奖(用户中途关 / 未达发奖)、有发奖无展示
(未上报 eCPM)都各自成行。app_env/our_code_id 旧数据为 NULL → 归到「来源未知」组。
展示与收益来自 ad_ecpm_record(收益 = eCPM元 ÷ 1000);应发 / 实发金币复用金币审计逐条复算
(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计,
不受 limit(只截断 items)影响。
⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 `feed`(迁移后 Draw
不再产生新数据)。② 聚合级只能看出「某组应发≠实发」,定位到具体哪条仍需逐条审计接口(ad-coin-audit)
⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 feed
② 跨天 S2S 回调:同一次广告的展示与发奖偶尔落相邻日,各自按 report_date / reward_date 归日
"""
from __future__ import annotations
@@ -23,6 +24,7 @@ from sqlalchemy.orm import Session
from app.admin.repositories import ad_audit
from app.core import rewards
from app.models.ad_ecpm import AdEcpmRecord
from app.models.user import User
def _cn_hour(dt: datetime) -> int:
@@ -32,17 +34,6 @@ def _cn_hour(dt: datetime) -> int:
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)
@@ -58,6 +49,18 @@ def _date_range(date_from: str, date_to: str) -> list[str]:
# 审计行的 scene 与报表 ad_type 一一对应
_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"}
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
_REWARD_DETAIL_KEYS = (
"record_id", "created_at", "status", "ecpm", "ecpm_factor", "units",
"lt_index_start", "lt_index_end", "lt_factor_start", "lt_factor_end",
"expected_coin", "actual_coin", "matched",
)
def _reward_detail(row: dict) -> dict:
"""从 audit 行抽出发奖复算明细(给前端展开行渲染因子1/因子2/份数/LT/应发实发)。"""
return {k: row[k] for k in _REWARD_DETAIL_KEYS}
def ad_revenue_report(
db: Session,
@@ -69,44 +72,42 @@ def ad_revenue_report(
granularity: str = "day",
limit: int = 500,
) -> dict:
"""日期区间(北京时间,闭区间)广告收益聚合 + 发奖对账。单日时 date_from==date_to。
"""日期区间(北京时间,闭区间)**逐条广告事件**列表 + 发奖对账。单日时 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 回调可能比展示晚
一会儿,故同一次广告的展示与金币偶尔落相邻小时(按天则一致)。
每个 item = 一次广告事件(展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行)。
ad_type: None=全部 / reward_video / feed / draw。granularity=hour 时每行带北京小时(由各自时间算)。
limit 只截断 items(事件明细),total 与 total_* / daily 在全量上统计,数字始终可信
"""
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) 发奖行(逐日 audit 复算):建 (user_id, ad_session_id) → [行] 映射用于和展示合并;
# 同时保留全量列表,未被展示合并的成「纯发奖」事件。
reward_by_session: dict[tuple[int, str], list[dict]] = {}
all_reward_rows: list[dict] = []
audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None
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):
row["_report_date"] = d
all_reward_rows.append(row)
sid = row.get("ad_session_id")
if sid:
reward_by_session.setdefault((row["user_id"], sid), []).append(row)
# 1) 展示条数 + 收益 ← ad_ecpm_record(report_date 闭区间;字符串 YYYY-MM-DD 字典序即日期序)
used_reward_ids: set[int] = set()
events: list[dict] = []
def _pop_reward(uid: int, sid: str | None) -> dict | None:
"""取一条与 (uid, sid) 匹配且未被用过的发奖行(激励视频展示↔发奖按会话 1:1 合并)。"""
if not sid:
return None
for r in reward_by_session.get((uid, sid), ()):
if r["record_id"] not in used_reward_ids:
used_reward_ids.add(r["record_id"])
return r
return None
# 2) 展示记录(ad_ecpm):每条一个事件;能匹配到发奖则合并成「展示 + 发奖」一行。
stmt = select(AdEcpmRecord).where(
AdEcpmRecord.report_date >= date_from,
AdEcpmRecord.report_date <= date_to,
@@ -116,124 +117,116 @@ def ad_revenue_report(
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,
rwd = _pop_reward(rec.user_id, rec.ad_session_id)
ev = {
"event_key": f"imp-{rec.id}",
"report_date": rec.report_date,
"user_id": rec.user_id,
"ad_type": rec.ad_type,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
"hour": _cn_hour(rec.created_at) if by_hour else None,
"has_impression": True,
"impressions": 1,
"ecpm": rec.ecpm_raw,
"revenue_yuan": round(rev, 6),
# 单次展示收益(元)= eCPM元 ÷ 1000(每千次→单次);与发奖同源解析,口径一致。
"revenue_yuan": round(rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0, 6),
"adn": rec.adn,
"slot_id": rec.slot_id,
}
if rwd is not None:
ev.update({
"has_reward": True,
"status": rwd["status"],
"expected_coin": int(rwd["expected_coin"]),
"actual_coin": int(rwd["actual_coin"]),
"matched": bool(rwd["matched"]),
"reward_detail": _reward_detail(rwd),
})
else:
# 纯展示(信息流逐条展示、激励视频缺发奖记录):不计对账,matched=True。
ev.update({
"has_reward": False, "status": None,
"expected_coin": 0, "actual_coin": 0, "matched": True,
"reward_detail": None,
})
events.append(ev)
# 3) 未被展示合并的发奖行 → 「纯发奖」事件(信息流整场发奖 / 有发奖无展示)。
# 收益恒 0(收益只算展示侧,避免与展示行重复计)。
for row in all_reward_rows:
if row["record_id"] in used_reward_ids:
continue
events.append({
"event_key": f"rwd-{row['record_id']}",
"report_date": row["_report_date"],
"user_id": row["user_id"],
"ad_type": _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"]),
"app_env": row.get("app_env"),
"our_code_id": row.get("our_code_id"),
"created_at": row["created_at"],
"hour": _cn_hour(row["created_at"]) if by_hour else None,
"has_impression": False,
"impressions": 0,
"ecpm": row["ecpm"],
"revenue_yuan": 0.0,
"adn": None,
"slot_id": None,
"has_reward": True,
"status": row["status"],
"expected_coin": int(row["expected_coin"]),
"actual_coin": int(row["actual_coin"]),
"matched": bool(row["matched"]),
"reward_detail": _reward_detail(row),
})
# 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"],
})
events.sort(key=lambda e: (e["report_date"], e["user_id"], e["created_at"]))
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 "",
)
)
# 补手机号(admin 展示用,完整不脱敏,与用户 / 钱包 / 比价记录页一致):批量一次查,避免 N+1。
uids = {e["user_id"] for e in events}
phone_map: dict[int, str] = {}
if uids:
phone_map = {
uid: phone
for uid, phone in db.execute(
select(User.id, User.phone).where(User.id.in_(uids))
).all()
}
for e in events:
e["user_phone"] = phone_map.get(e["user_id"])
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)
total_impressions = sum(e["impressions"] for e in events)
total_revenue_yuan = round(sum(e["revenue_yuan"] for e in events), 6)
total_expected_coin = sum(e["expected_coin"] for e in events)
total_actual_coin = sum(e["actual_coin"] for e in events)
mismatch_count = sum(1 for e in events if e["has_reward"] and not e["matched"])
# 按日期汇总(全量,不受 limit):供前端按天趋势图。
daily_map: dict[str, dict] = {}
for r in rows:
d = daily_map.get(r["report_date"])
for e in events:
d = daily_map.get(e["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"]
d = {"date": e["report_date"], "impressions": 0, "revenue_yuan": 0.0,
"expected_coin": 0, "actual_coin": 0}
daily_map[e["report_date"]] = d
d["impressions"] += e["impressions"]
d["revenue_yuan"] += e["revenue_yuan"]
d["expected_coin"] += e["expected_coin"]
d["actual_coin"] += e["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": len(events),
"truncated": len(events) > 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"])
),
"mismatch_count": mismatch_count,
"daily": daily,
"items": items,
"items": events[:limit],
}
+282 -43
View File
@@ -5,11 +5,11 @@
"""
from __future__ import annotations
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from uuid import uuid4
from sqlalchemy import desc, select
from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session
from app.admin.repositories.queries import _as_utc, offset_paginate
@@ -17,12 +17,17 @@ 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:
@@ -71,19 +76,23 @@ def list_groups(
def create_group(
db: Session, *, name: str, sid: str | None = None,
db: Session, *, name: str, platforms: list[str], sid: str | None = None,
member_count: int | None = None, remark: str | None = None, commit: bool = True,
) -> CpsGroup:
"""建群。sid 留空自动 g<id>(先 flush 拿 id 再回填)。"""
"""建群。含 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,
sid=sid or f"tmp{uuid4().hex[:20]}", # 临时唯一占位,留空时下面回填 g<id>
platforms=list(platforms or []),
sid=seed_sid,
member_count=member_count,
remark=remark,
)
db.add(group)
db.flush()
if not sid:
if has_meituan and not sid:
group.sid = f"g{group.id}"
db.flush()
if commit:
@@ -94,11 +103,14 @@ def create_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:
@@ -113,6 +125,15 @@ def update_group(
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)
@@ -130,13 +151,29 @@ def list_activities(
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, remark: str | None = None, commit: bool = True,
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, remark=remark,
product_view_sign=product_view_sign, payload=payload,
image_url=image_url, remark=remark,
)
db.add(activity)
if commit:
@@ -147,6 +184,46 @@ def create_activity(
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)。"""
@@ -233,55 +310,217 @@ def group_stats(
"""按 sid 聚合订单 + join 群信息。已建但本期无单的活跃群也列出(全 0)。
未归群的 sid(历史/其它来源)单独成行 group_id=None。按预估佣金降序。
"""
stmt = select(CpsOrder)
ostmt = select(CpsOrder)
if date_from is not None:
stmt = stmt.where(CpsOrder.pay_time >= _as_utc(date_from))
ostmt = ostmt.where(CpsOrder.pay_time >= _as_utc(date_from))
if date_to is not None:
stmt = stmt.where(CpsOrder.pay_time <= _as_utc(date_to))
orders = list(db.execute(stmt).scalars().all())
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)
groups = {g.sid: g for g in db.execute(select(CpsGroup)).scalars().all()}
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())
buckets: dict[str | None, list[CpsOrder]] = {}
for o in orders:
buckets.setdefault(o.sid, []).append(o)
rows: list[dict] = []
for sid, items in buckets.items():
g = groups.get(sid) if sid else None
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]
rows.append({
"group_id": g.id if g else None,
"sid": sid,
"name": g.name if g else (sid or "(无 sid 归属)"),
"member_count": g.member_count if g else None,
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),
})
}
# 已建的活跃群但本期无单 → 补 0 行,让运营看到全部群
seen = set(buckets.keys())
for sid, g in groups.items():
if sid not in seen and g.status == "active":
rows.append({
"group_id": g.id, "sid": sid, "name": g.name,
"member_count": g.member_count, "order_count": 0,
"settled_count": 0, "canceled_count": 0, "gmv_cents": 0,
"est_commission_cents": 0, "settled_commission_cents": 0,
})
# 淘宝/京东无法对账 → 对账字段全 None(前端显示 "-")
_no_recon = {
"order_count": None, "settled_count": None, "canceled_count": None,
"gmv_cents": None, "est_commission_cents": None, "settled_commission_cents": None,
}
# 合并点击数据(按 group_id):点击只对生成过我们短链的群有,未归群行(group_id=None)恒 0
clicks = cps_link_repo.click_stats_by_group(db, date_from=date_from, date_to=date_to)
for r in rows:
c = clicks.get(r["group_id"]) if r["group_id"] is not None else None
r["click_pv"] = c["pv"] if c else 0
r["click_uv"] = c["uv"] if c else 0
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)
rows.sort(key=lambda x: x["est_commission_cents"], reverse=True)
# 未归群的历史美团 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]:
"""该群来过的微信用户:在本群有带 openid 点击的所有用户 + 本群 visit/copy 次数 + 画像。
用「本群点击」判定归属,而非 cps_wx_user.first_group_id —— 同一用户会在多个群活动,
first_group_id 只记首次授权群,会漏掉"先在别处授权、cookie 已存、又来本群"的用户。
copy=本群点「复制口令」次数(领券意向),visit=进落地页次数。按本群首次点击倒序。
昵称/头像来自授权画像;美团/京东群只 302 跳转、无 userinfo 触发点,故多为空。
"""
rows = db.execute(
select(CpsClick.openid, CpsClick.event_type, func.count())
.where(CpsClick.group_id == group_id)
.where(CpsClick.openid.is_not(None))
.group_by(CpsClick.openid, CpsClick.event_type)
).all()
if not rows:
return []
stat: dict[str, dict] = {}
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
openids = list(stat.keys())
# 本群每个 openid 的首次点击时间(排序 + 展示"首次进入")
first_seen = dict(
db.execute(
select(CpsClick.openid, func.min(CpsClick.clicked_at))
.where(CpsClick.group_id == group_id)
.where(CpsClick.openid.is_not(None))
.group_by(CpsClick.openid)
).all()
)
# 关联画像(未授权 userinfo 的昵称/头像为空)
users = {
u.openid: u
for u in db.execute(select(CpsWxUser).where(CpsWxUser.openid.in_(openids))).scalars().all()
}
result = [
{
"openid": openid,
"nickname": users[openid].nickname if openid in users else None,
"headimgurl": users[openid].headimgurl if openid in users else None,
"first_seen": first_seen.get(openid),
"visit_count": stat[openid]["visit"],
"copy_count": stat[openid]["copy"],
}
for openid in openids
]
result.sort(key=lambda x: x["first_seen"], reverse=True)
return result[:limit]
+29
View File
@@ -56,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,
+302 -4
View File
@@ -11,6 +11,9 @@ 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
@@ -19,6 +22,16 @@ 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
@@ -109,6 +122,63 @@ def list_users(
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
def _attach_user_info(db: Session, records: list[ComparisonRecord]) -> None:
"""给每条比价记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。"""
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:每台设备走过引导的账号数 +
最近完成时间,按最近完成倒序。设备维度新手引导管理用。没走过引导的设备不在表里(本就会
@@ -274,6 +344,38 @@ def _as_utc(value: datetime) -> datetime:
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(
db: Session,
*,
@@ -391,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
@@ -489,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,
*,
+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)"},
}
+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))
+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)
+300 -70
View File
@@ -1,6 +1,7 @@
"""admin CPS 分发与对账:群(sid)管理 + 活动池 + 生成带 sid 券链接 + 美团订单对账 + 统计。
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。
当前仅接美团(get_referral_link / query_order)淘宝/京东待各自联盟凭证
平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)
淘宝/京东无 API 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"
/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可
"""
from __future__ import annotations
@@ -9,30 +10,35 @@ import time
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
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.core.config import settings
from app.repositories import cps_link as cps_link_repo
from app.admin.schemas.cps import (
CpsActivityCreate,
CpsActivityOut,
CpsActivityUpdate,
CpsGroupCreate,
CpsGroupOut,
CpsGroupStat,
CpsGroupUpdate,
CpsOrderOut,
CpsReconcileResult,
CpsReferralLinkOut,
CpsReferralLinkRequest,
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",
@@ -40,9 +46,11 @@ router = APIRouter(
dependencies=[Depends(get_current_admin)],
)
_VALID_PLATFORMS = {"meituan", "taobao", "jd"}
# ───────────── 群(sid) ─────────────
@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群(sid)列表")
# ───────────── 群 ─────────────
@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群列表")
def list_groups(
db: AdminDb,
keyword: Annotated[str | None, Query(max_length=100)] = None,
@@ -60,22 +68,27 @@ def list_groups(
)
@router.post("/groups", response_model=CpsGroupOut, summary="新建群(分配 sid)")
@router.post("/groups", response_model=CpsGroupOut, summary="新建群")
def create_group(
body: CpsGroupCreate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> CpsGroupOut:
if body.sid and cps_repo.get_group_by_sid(db, body.sid) 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}")
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, sid=body.sid, member_count=body.member_count,
remark=body.remark, commit=False,
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={"sid": group.sid, "name": group.name}, ip=get_client_ip(request), commit=False,
detail={"name": group.name, "platforms": group.platforms, "sid": group.sid},
ip=get_client_ip(request), commit=False,
)
db.commit()
db.refresh(group)
@@ -93,9 +106,13 @@ def update_group(
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, member_count=body.member_count,
status=body.status, remark=body.remark, commit=False,
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,
@@ -106,6 +123,26 @@ def update_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(
@@ -132,15 +169,23 @@ def create_activity(
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> CpsActivityOut:
if not body.act_id and not body.product_view_sign:
raise HTTPException(status_code=400, detail="act_id 或 product_view_sign 必填其一")
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, remark=body.remark, commit=False,
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, "act_id": activity.act_id, "platform": activity.platform},
detail={"name": activity.name, "platform": activity.platform},
ip=get_client_ip(request), commit=False,
)
db.commit()
@@ -148,65 +193,150 @@ def create_activity(
return CpsActivityOut.model_validate(activity)
# ───────────── 转链 ─────────────
@router.post("/referral-link", response_model=CpsReferralLinkOut, summary="生成群发短链(带 sid + 点击统计)")
def generate_referral_link(
body: CpsReferralLinkRequest,
@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,
) -> CpsReferralLinkOut:
) -> 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="群不存在")
activity = cps_repo.get_activity(db, body.activity_id)
if activity is None:
raise HTTPException(status_code=404, detail="活动不存在")
if activity.platform != "meituan":
raise HTTPException(status_code=400, detail=f"当前仅支持美团转链(活动平台={activity.platform})")
try:
# 全要:短链作跳转目标(微信可打开),长链/deeplink 作参考/备用
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
group_platforms = set(group.platforms or [])
base = settings.CPS_REDIRECT_BASE.rstrip("/")
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"]}
# 跳转目标优先短链(2,微信可打开)→ 长链(1)→ deeplink(3)
target_url = link_map.get("2") or link_map.get("1") or link_map.get("3")
if not target_url:
raise HTTPException(status_code=502, detail="美团未返回有效链接(检查 actId 是否在推广有效期)")
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,
))
# 存我们的短链:发群用 /c/{code},用户点 → 我们记点击 → 302 跳 target_url
link = cps_link_repo.create_link(
db, group_id=group.id, activity_id=activity.id, sid=group.sid,
target_url=target_url, platform=activity.platform, commit=False,
)
write_audit(
db, admin, action="cps.referral_link.generate", target_type="cps_link", target_id=link.id,
detail={"sid": group.sid, "activity_id": activity.id, "code": link.code},
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()
db.refresh(link)
base = settings.CPS_REDIRECT_BASE.rstrip("/")
redirect_url = f"{base}/c/{link.code}" if base else f"/c/{link.code}"
return CpsReferralLinkOut(
redirect_url=redirect_url,
code=link.code,
sid=group.sid,
group_name=group.name,
activity_name=activity.name,
target_url=target_url,
link_map=link_map,
)
return CpsReferralLinksOut(group_name=group.name, results=items)
# ───────────── 订单对账 ─────────────
@@ -262,7 +392,107 @@ def get_stats(
stats = [CpsGroupStat(**r) for r in rows]
return CpsStatsOut(
groups=stats,
total_order_count=sum(s.order_count for s in stats),
total_est_commission_cents=sum(s.est_commission_cents for s in stats),
total_settled_commission_cents=sum(s.settled_commission_cents for s in 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)}
+100 -8
View File
@@ -1,4 +1,4 @@
"""admin 反馈工单:列表(读,支持 状态/用户ID/内容/时间 筛选 + 排序)+ 标记已处理(写,带审计)。"""
"""admin 反馈工单:列表筛选 + 审核采纳/拒绝(带金币发放与审计)。"""
from __future__ import annotations
from datetime import datetime
@@ -10,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",
@@ -21,6 +22,11 @@ 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,
@@ -56,18 +62,104 @@ def list_feedbacks(
@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)
+42
View File
@@ -17,6 +17,8 @@ from app.admin.schemas.user import (
GrantCoinsRequest,
SetDebugTraceRequest,
SetUserStatusRequest,
UserCoinRecord,
UserRewardStats,
)
from app.models.admin import AdminUser
from app.repositories import user as user_repo
@@ -66,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,
+13 -7
View File
@@ -25,6 +25,7 @@ from app.admin.schemas.wallet import (
WithdrawBulkItemResult,
WithdrawDetailOut,
WithdrawLedgerCheckOut,
WithdrawListItemOut,
WithdrawOrderOut,
WithdrawRejectRequest,
WithdrawSummaryOut,
@@ -44,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,
@@ -63,7 +64,7 @@ def list_withdraws(
] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[WithdrawOrderOut]:
) -> CursorPage[WithdrawListItemOut]:
items, next_cursor, total = queries.list_all_withdraw_orders(
db,
user_id=user_id,
@@ -78,11 +79,16 @@ def list_withdraws(
limit=limit,
cursor=cursor,
)
return CursorPage(
items=[WithdrawOrderOut.model_validate(o) for o in items],
next_cursor=next_cursor,
total=total,
)
# 联表带出手机号/昵称 + 累计成功提现(本页 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="提现审核台统计")
+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
+26 -20
View File
@@ -1,7 +1,7 @@
"""广告收益报表 schemas。
用户 / 日期 / 广告类型 / 应用 / 代码位 聚合的只读报表:展示条数收益()金币来源
字段 snake_case;收益按元(float),金币按整数
**逐条广告事件**只读报表:每行一次广告(激励视频展示+发奖按会话合并;信息流展示/发奖各自成行),
展示条数收益()应发/实发金币对账字段 snake_case;收益按元(float),金币按整数
"""
from __future__ import annotations
@@ -50,27 +50,33 @@ class AdRevenueDaily(BaseModel):
class AdRevenueRow(BaseModel):
"""个聚合组(report_date × user × ad_type × app_env × our_code_id)的汇总"""
"""次广告事件(逐条一行):激励视频展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行"""
report_date: str = Field(..., description="组所属日期(北京时间 YYYY-MM-DD)")
event_key: str = Field(..., description="事件稳定唯一键(imp-{ecpm_id} / rwd-{reward_id});前端 rowKey 用")
report_date: str = Field(..., description="该事件所属日期(北京时间 YYYY-MM-DD)")
user_id: int
user_phone: str | None = Field(None, description="用户手机号(admin 展示用,完整;用户已删 / 查不到为空)")
ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(历史 Draw 信息流)")
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/应发/实发/一致);展开下钻用,纯展示无发奖记录的组为空",
created_at: datetime = Field(..., description="事件时间(有展示=展示时间,纯发奖=发奖时间)")
# ── 展示侧 ──
has_impression: bool = Field(..., description="是否有广告展示(信息流逐条展示=True,纯发奖行=False)")
impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)")
ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值")
revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000;纯发奖行=0")
adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…);纯发奖行为空")
slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID);纯发奖行为空")
# ── 发奖侧 ──
has_reward: bool = Field(..., description="是否有发奖记录(激励视频合并行 / 信息流整场发奖行=True;纯展示=False)")
status: str | None = Field(None, description="发奖状态 granted/closed_early/too_short/…;纯展示为空")
expected_coin: int = Field(..., description="应发金币(公式复算,与金币审计同源);纯展示=0")
actual_coin: int = Field(..., description="实发金币(实际入账);纯展示=0")
matched: bool = Field(..., description="本条应发==实发;纯展示恒 True(不计对账)")
reward_detail: AdRevenueRecord | None = Field(
None,
description="发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);点行展开下钻用,纯展示为空",
)
@@ -80,11 +86,11 @@ 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 影响)")
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="聚合明细(按 用户→类型→代码位 排序)")
mismatch_count: int = Field(..., description="应发≠实发的发奖条数(=0 说明全部按公式发放)")
items: list[AdRevenueRow] = Field(..., description="逐条广告事件(按 日期→用户→时间 排序)")
+72
View File
@@ -0,0 +1,72 @@
"""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
input_tokens: int | None = None # Σ usage.prompt_tokens(server 派生)
output_tokens: int | None = None # Σ usage.completion_tokens(server 派生)
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
+65 -32
View File
@@ -1,4 +1,8 @@
"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。"""
"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)
对账类字段对淘宝/京东为 None 前端显示 "-"(无法对账)
"""
from __future__ import annotations
from datetime import datetime
@@ -6,13 +10,14 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
# ───────────── 群(sid) ─────────────
# ───────────── 群 ─────────────
class CpsGroupOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
sid: str
sid: str | None = None # 仅美团群有
name: str
platforms: list[str] = Field(default_factory=list)
member_count: int | None = None
status: str
remark: str | None = None
@@ -21,7 +26,9 @@ class CpsGroupOut(BaseModel):
class CpsGroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=128)
# 留空 → 后端自动生成 g<id>;填写则必须字母+数字(美团 sid 限制)。
# 该群发哪些平台,至少选一个
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)
@@ -29,12 +36,13 @@ class CpsGroupCreate(BaseModel):
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)
# ───────────── 活动(actId) ─────────────
# ───────────── 活动 ─────────────
class CpsActivityOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -43,6 +51,8 @@ class CpsActivityOut(BaseModel):
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
@@ -50,36 +60,55 @@ class CpsActivityOut(BaseModel):
class CpsActivityCreate(BaseModel):
name: str = Field(min_length=1, max_length=128)
platform: str = Field(default="meituan", pattern=r"^(meituan|taobao|jd)$")
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 CpsReferralLinkRequest(BaseModel):
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
# 链接类型:1长链 2短链 3deeplink。默认短链(微信可打开)+deeplink。
link_types: list[int] = Field(default_factory=lambda: [2, 3])
class CpsReferralLinkOut(BaseModel):
redirect_url: str # ★ 我们的群发短链(/c/{code}),发群就用这个,点击经我们统计
code: str
sid: str
group_name: str
activity_name: str
target_url: str # 实际 302 跳转的美团短链
link_map: dict[str, str] # 美团原始 1长链/2短链/3deeplink(参考/备用)
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 # 翻了几页
fetched: int
inserted: int
updated: int
pages: int
# ───────────── 订单明细 ─────────────
@@ -101,18 +130,22 @@ class CpsOrderOut(BaseModel):
# ───────────── 统计 ─────────────
class CpsGroupStat(BaseModel):
group_id: int | None = None # None = 未归群的 sid(历史/其它来源)
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 # 独立点击(按 ip+ua 近似去重)
order_count: int # 有效订单数(不含取消/风控)
settled_count: int # 已结算单数
canceled_count: int # 取消+风控单数
gmv_cents: int # 成交额(有效单 payPrice 合计)
est_commission_cents: int # 预估佣金(有效单 profit 合计)
settled_commission_cents: int # 已结算佣金
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):
+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)
+30
View File
@@ -37,6 +37,36 @@ 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():
+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)
+29 -2
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,
@@ -31,6 +32,7 @@ from app.schemas.ad import (
EcpmReportOut,
FeedRewardIn,
FeedRewardOut,
FeedRewardUnitsOut,
PangleCallbackOut,
RewardNoShowIn,
RewardNoShowOut,
@@ -80,14 +82,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_any(params, settings.pangle_reward_secrets):
# 验签密钥: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")
@@ -363,6 +373,8 @@ 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,
trace_id=payload.trace_id,
app_env=payload.app_env,
our_code_id=payload.our_code_id,
aborted=payload.aborted,
@@ -380,6 +392,21 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
)
@router.get(
"/feed-reward/units",
response_model=FeedRewardUnitsOut,
summary="查账号累计信息流发奖份数(前端算因子2 LT、做实时金币进度条用)",
dependencies=[Depends(rate_limit(120, 60, "ad-feed-units"))],
)
def feed_reward_units(user: CurrentUser, db: DbSession) -> FeedRewardUnitsOut:
"""返回账号累计已发奖份数(因子2 LT 基线)。
信息流金币进度条:前端 show 时拉一次,之后每满 10 秒一份逐份用 (granted_units + offset)
LT 因子,精确复刻发奖公式 进度条数值 实际到账
"""
return FeedRewardUnitsOut(granted_units=crud_feed.granted_unit_total(db, user.id))
@router.post(
"/reward-noshow",
response_model=RewardNoShowOut,
+26 -2
View File
@@ -15,6 +15,7 @@ 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
@@ -38,13 +39,17 @@ logger = logging.getLogger("shagua.auth")
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
def _login_response(user, *, onboarding_completed: bool) -> TokenWithUser:
"""登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入。"""
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,
)
@@ -82,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:
@@ -99,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")
+8
View File
@@ -116,3 +116,11 @@ async def intent_precoupon_step(request: Request) -> dict[str, Any]:
@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")
+51 -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,35 @@ 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"))
# token 累加(usage 已被 pricebot llm_client 归一为 prompt/completion_tokens;
# error 的调用 usage 可能为 None,or {} 兜底)
rec.input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls)
rec.output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls)
db.commit()
logger.info(
"backfill llm_calls trace=%s n=%d in_tok=%d out_tok=%d",
trace_id, len(calls), rec.input_tokens, rec.output_tokens,
)
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 +112,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)
+180 -11
View File
@@ -1,21 +1,48 @@
"""CPS 群发短链跳转:用户点 /c/{code} → 记一条点击 → 302 跳美团
"""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 RedirectResponse
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 不存在/失效时的兜底落地(避免用户看到报错)
# 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")
@@ -24,16 +51,158 @@ def _client_ip(request: Request) -> str | None:
return request.client.host if request.client else None
@router.get("/c/{code}", summary="群发短链跳转(记点击 + 302)")
def cps_redirect(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)
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"),
ua=request.headers.get("user-agent"), event_type=event_type, openid=openid,
)
except Exception:
pass # 记点击失败不阻断跳转
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>"""
+96
View File
@@ -0,0 +1,96 @@
"""设备注册 / 心跳 endpoint(无障碍保护存活检测)。
路由前缀 /api/v1/device, Bearer 鉴权(设备绑登录用户)
POST /register 注册设备 / 更新 registration_id(App 前台拿到 push token 时调)
POST /heartbeat 上报心跳(无障碍服务存活时周期调,刷新存活)
后端 heartbeat_monitor_worker 据此发现心跳超时的设备并极光推送告警
spec: spec/accessibility-liveness-push.md
"""
from __future__ import annotations
import logging
from fastapi import APIRouter
from app.api.deps import CurrentUser, DbSession
from app.repositories import device as device_repo
from app.schemas.device import (
DeviceOut,
DeviceRegisterRequest,
HeartbeatRequest,
LivenessAckRequest,
LivenessOut,
OkResponse,
)
logger = logging.getLogger("shagua.device")
router = APIRouter(prefix="/api/v1/device", tags=["device"])
@router.post("/register", response_model=DeviceOut, summary="注册设备/更新推送token")
def register_device(
req: DeviceRegisterRequest,
user: CurrentUser,
db: DbSession,
) -> DeviceOut:
device = device_repo.register_or_update(
db,
user_id=user.id,
device_id=req.device_id,
registration_id=req.registration_id,
platform=req.platform,
app_version=req.app_version,
)
logger.info(
"device register user_id=%d device_id=%s reg=%s",
user.id,
req.device_id,
bool(req.registration_id),
)
return DeviceOut.model_validate(device)
@router.post("/heartbeat", response_model=OkResponse, summary="上报心跳")
def report_heartbeat(
req: HeartbeatRequest,
user: CurrentUser,
db: DbSession,
) -> OkResponse:
device_repo.touch_heartbeat(
db,
user_id=user.id,
device_id=req.device_id,
accessibility_enabled=req.accessibility_enabled,
registration_id=req.registration_id,
)
return OkResponse()
@router.get("/liveness", response_model=LivenessOut, summary="查询本机掉线告警(后置检测)")
def get_liveness(
device_id: str,
user: CurrentUser,
db: DbSession,
) -> LivenessOut:
"""客户端进 App 时拉取: 本机是否被判定掉线过(kill_alert_pending)。
设备从未注册过 返回默认(无告警)命中 客户端弹开启自启动引导, 之后调 /liveness/ack
spec: spec/accessibility-liveness-pull-prompt.md
"""
device = device_repo.get_device(db, user_id=user.id, device_id=device_id)
if device is None:
return LivenessOut()
return LivenessOut.model_validate(device)
@router.post("/liveness/ack", response_model=OkResponse, summary="确认已提醒(清掉线告警)")
def ack_liveness(
req: LivenessAckRequest,
user: CurrentUser,
db: DbSession,
) -> OkResponse:
"""客户端已弹过「开启自启动」引导 → 清掉「待提醒」标记(下次真掉线 worker 再置)。"""
device_repo.ack_kill_alert(db, user_id=user.id, device_id=req.device_id)
return OkResponse()
+57 -2
View File
@@ -2,6 +2,7 @@
路由前缀 `/api/v1/feedback`, Bearer 鉴权(反馈绑到登录用户,便于回访)
POST / 提交反馈(multipart:content 必填;contact 可选(原型改版后客户端已不再采集);images 可选 6 )
GET /records 我的反馈历史(pending/adopted/rejected)
截图复用 [app.core.media] 落盘到 /media/feedback/
"""
@@ -9,12 +10,19 @@ 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")
@@ -23,6 +31,27 @@ router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_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="提交反馈")
@@ -60,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)
+28
View File
@@ -17,7 +17,9 @@ 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 (
AdConfigPublicOut,
AppFlagsOut,
AppVersionOut,
PlatformStatsOut,
SavingsFeedItem,
SavingsFeedOut,
@@ -51,3 +53,29 @@ def flags(db: DbSession) -> AppFlagsOut:
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)
+12
View File
@@ -18,6 +18,7 @@ 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,
@@ -87,6 +88,17 @@ def onboarding_status(
@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)
+44 -3
View File
@@ -66,6 +66,11 @@ class Settings(BaseSettings):
JG_VERIFY_ENDPOINT: str = "https://api.verification.jpush.cn/v1/web/loginTokenVerify"
JG_REQUEST_TIMEOUT_SEC: int = 15
# 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送)
HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关
HEARTBEAT_TIMEOUT_MINUTES: int = 10 # 多久没心跳算掉线(≈3 个客户端心跳周期)
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
# ===== 短信 =====
SMS_MOCK: bool = True
SMS_CODE_TTL_SEC: int = 300
@@ -79,6 +84,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 = ""
@@ -95,6 +113,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 一致。
@@ -110,10 +147,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 点自动兑金币(deploy/daily-exchange.timer 触发 scripts.daily_auto_exchange)。
# 客户端已删手动兑入口、改为「0 点自动兑现金」,故默认开;运营要临时停可在 .env 置 false,
# 无需动 systemd timer——脚本读此开关,false 时直接 no-op 退出。
# 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 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
+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
+148
View File
@@ -0,0 +1,148 @@
"""无障碍保护存活监控后台任务。
周期扫描曾经保护过当前 alive心跳超时的设备 = App 被彻底杀掉/无障碍已停(心跳断了),
**命中即在服务器终端打印告警**(本期先不接推送,工程量大,用终端打印代替真实通知);并把状态机
推进到 notified 防每轮重复打印(心跳恢复时由 repositories.device.touch_heartbeat 重置回 alive)
结构仿 withdraw_reconcile_worker(单实例锁 + asyncio 轮询 + 优雅退出)
spec: spec/accessibility-liveness-push.md
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from collections.abc import Iterator
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings
from app.db.session import SessionLocal
from app.repositories import device as device_repo
logger = logging.getLogger("shagua.heartbeat_monitor")
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "heartbeat_monitor.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 _silent_seconds(last: datetime | None) -> int | None:
"""距上次心跳的秒数(兼容 sqlite 取回的 naive datetime)。"""
if last is None:
return None
ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow()
return int((ref - last).total_seconds())
def _scan_once(timeout_minutes: int) -> dict:
"""扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备,在**服务器终端打印**告警代替真实推送。
本期不接推送(极光/厂商通道工程量大),只做服务端掉线检测:命中即 logger.warning 打印到终端,
并把状态机推进到 notified 防每轮重复打印(心跳恢复时 touch_heartbeat 会重置回 alive)
"""
notified = 0
with SessionLocal() as db:
overdue = device_repo.list_overdue(db, timeout_minutes=timeout_minutes)
for device in overdue:
silent = _silent_seconds(device.last_heartbeat_at)
logger.warning(
"[掉线检测] user_id=%s device_id=%s%s 秒无心跳(阈值 %d 分钟)"
" → 判定 App 已被杀/无障碍已停。【已置 kill_alert_pending: 用户下次进 App 将弹「开启自启动」引导(后置检测);推送本期未接】",
device.user_id,
device.device_id,
silent if silent is not None else "?",
timeout_minutes,
)
device_repo.mark_notified(db, device_id_pk=device.id)
notified += 1
return {"checked": len(overdue), "notified": notified}
async def _run_loop() -> None:
interval = max(10, int(settings.HEARTBEAT_SCAN_INTERVAL_SEC))
timeout_minutes = max(1, int(settings.HEARTBEAT_TIMEOUT_MINUTES))
lock_stale_after = max(interval * 3, 600)
with _single_instance_lock(lock_stale_after) as lock_acquired:
if not lock_acquired:
logger.warning("heartbeat monitor skipped: another worker owns lock")
return
await _run_locked_loop(interval, timeout_minutes)
async def _run_locked_loop(interval: int, timeout_minutes: int) -> None:
logger.info(
"heartbeat monitor started interval=%ss timeout=%sm",
interval,
timeout_minutes,
)
try:
while True:
try:
_touch_lock()
result = await asyncio.to_thread(_scan_once, timeout_minutes)
if result["notified"]:
logger.info("heartbeat monitor result=%s", result)
except SQLAlchemyError:
logger.exception("heartbeat monitor db error")
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
logger.exception("heartbeat monitor unexpected error")
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.info("heartbeat monitor stopped")
raise
def start_heartbeat_monitor() -> asyncio.Task | None:
if not settings.HEARTBEAT_MONITOR_ENABLED:
logger.info("heartbeat monitor disabled")
return None
return asyncio.create_task(_run_loop(), name="heartbeat-monitor")
async def stop_heartbeat_monitor(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)
+4
View File
@@ -108,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)。
+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)
+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
+18
View File
@@ -20,7 +20,10 @@ from app.api.v1.compare import router as compare_router
from app.api.v1.compare_milestone import router as compare_milestone_router
from app.api.v1.compare_record import router as compare_record_router
from app.api.v1.coupon import router as coupon_router
from app.api.v1.device import router as device_router
from app.api.v1.cps_redirect import router as cps_redirect_router
from app.api.internal.app_version import router as internal_app_version_router
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
from app.api.internal.price import router as internal_price_router
from app.api.internal.store import router as internal_store_router
from app.api.v1.feedback import router as feedback_router
@@ -36,6 +39,14 @@ from app.api.v1.user import router as user_router
from app.api.v1.wallet import router as wallet_router
from app.api.v1.wxpay import router as wxpay_router
from app.core.config import settings
from app.core.heartbeat_monitor_worker import (
start_heartbeat_monitor,
stop_heartbeat_monitor,
)
from app.core.daily_exchange_worker import (
start_daily_exchange_worker,
stop_daily_exchange_worker,
)
from app.core.logging import setup_logging
from app.core.withdraw_reconcile_worker import (
start_withdraw_reconcile_worker,
@@ -57,10 +68,14 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.DATABASE_URL.split("://", 1)[0],
)
reconcile_task = start_withdraw_reconcile_worker()
heartbeat_task = start_heartbeat_monitor()
daily_exchange_task = start_daily_exchange_worker()
try:
yield
finally:
await stop_heartbeat_monitor(heartbeat_task)
await stop_withdraw_reconcile_worker(reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
logger.info("shutting down")
@@ -92,6 +107,7 @@ app.include_router(user_router)
app.include_router(feedback_router)
app.include_router(invite_router)
app.include_router(coupon_router)
app.include_router(device_router)
app.include_router(compare_router)
app.include_router(compare_record_router)
app.include_router(compare_milestone_router)
@@ -107,6 +123,8 @@ app.include_router(report_router)
# 内部(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)
+3
View File
@@ -10,7 +10,9 @@ 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.device import DeviceLiveness # noqa: F401
from app.models.coupon_state import ( # noqa: F401
CouponClaimRecord,
CouponDailyCompletion,
@@ -19,6 +21,7 @@ from app.models.coupon_state import ( # noqa: F401
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
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
+2 -2
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
+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)
# 本次比价 trace_id(仅 comparison 场景由客户端带上):把这场广告金币归属到对应比价记录,
# 比价记录页按 trace_id 聚合本场实发金币显示「比价赚 N 金币」。领券/福利/旧客户端 = NULL。
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, 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)
+30
View File
@@ -18,6 +18,7 @@ from sqlalchemy import (
JSON,
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
@@ -101,6 +102,35 @@ class ComparisonRecord(Base):
# 客户端上报的原始 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 累计 token(server 从 llm_calls[].usage 累加;旧记录/未采集为 None)
input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) # Σ usage.prompt_tokens
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) # Σ usage.completion_tokens
# 每次 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
)
+5 -1
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy import DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -24,6 +24,10 @@ class CpsActivity(Base):
act_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 美团商品券 productViewSign(与 act_id 二选一转链;多数活动用 act_id)。
product_view_sign: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 淘宝:整段淘口令文本(落地页原样复制);京东:推广链接(落地页 302 跳)。美团不用这个(用 act_id)。
payload: Mapped[str | None] = mapped_column(Text, nullable=True)
# 淘宝落地页主视觉图(绝对 URL,基于 CPS_REDIRECT_BASE)。运营建活动时上传/选已有,落地页按活动展示。
image_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") # active | archived
remark: Mapped[str | None] = mapped_column(String(256), nullable=True)
created_at: Mapped[datetime] = mapped_column(
+8 -3
View File
@@ -7,7 +7,8 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy import JSON, DateTime, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -17,9 +18,13 @@ class CpsGroup(Base):
__tablename__ = "cps_group"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 每群唯一的渠道标识,建群时分配(留空则自动生成 g<id>)。字母+数字,≤64
sid: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
# 渠道标识。仅美团需要(sid 追踪);纯淘宝/京东群无 sid(那俩不支持)。含美团时建群分配
sid: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
# 该群发哪些平台的券(多选): ["meituan","taobao","jd"]。含 meituan 才需要 sid。
platforms: Mapped[list] = mapped_column(
JSON().with_variant(JSONB(), "postgresql"), nullable=False, default=list
)
# 群人数,运营填,作转化率分母(可空)。
member_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") # active | archived
+11 -4
View File
@@ -23,10 +23,11 @@ class CpsLink(Base):
code: Mapped[str] = mapped_column(String(16), unique=True, index=True, nullable=False)
group_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
activity_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
sid: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
# 仅美团 link 有 sid(渠道追踪);淘宝/京东无 sid。
sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
platform: Mapped[str] = mapped_column(String(20), nullable=False, default="meituan")
# 302 跳转目标(美团短链,微信内可打开并唤起 App)。
target_url: Mapped[str] = mapped_column(String(1024), nullable=False)
# 跳转/落地目标: 美团短链 / 京东链接(302 跳) / 淘宝整段淘口令文本(H5 落地页复制)。
target_url: Mapped[str] = mapped_column(String(2048), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
@@ -41,9 +42,15 @@ class CpsClick(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
link_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
group_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) # 冗余,按群聚合快
sid: Mapped[str] = mapped_column(String(64), index=True, nullable=False) # 冗余
sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # 冗余(淘宝/京东无)
# 事件类型: visit(进落地页/被跳转) | copy(淘宝落地页点了"复制口令")
event_type: Mapped[str] = mapped_column(
String(16), nullable=False, default="visit", server_default="visit"
)
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
ua: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计
openid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
clicked_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
+42
View File
@@ -0,0 +1,42 @@
"""CPS 落地页微信用户(cps_wx_user)。
用户在微信内打开群发短链落地页 /c/{code},经服务号网页授权:
- base 静默: openid(唯一标识,统计主力)
- userinfo(点领券触发): nickname/headimgurl/unionid
openid 唯一,记录首次来源群(first_group_id),用于群内用户级统计(谁领了券)
下单归因到人需 user-level sid(另一期),本表只承载身份 + 领券/点击侧
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class CpsWxUser(Base):
__tablename__ = "cps_wx_user"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 服务号下用户唯一标识(网页授权拿到)
openid: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
# 开放平台 unionid(服务号绑开放平台 + scope=userinfo 才有);跨 App/服务号统一用户
unionid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 昵称/头像:userinfo 授权后才有(base 阶段为空)
nickname: Mapped[str | None] = mapped_column(String(128), nullable=True)
headimgurl: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 首次进入来源(从哪个群的链接授权进来)
first_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
first_group_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
first_seen: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
last_seen: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<CpsWxUser id={self.id} openid={self.openid!r} nickname={self.nickname!r}>"
+91
View File
@@ -0,0 +1,91 @@
"""设备表(无障碍保护存活检测 + 极光推送)。
每条 = 一个用户的一台设备(per-install,device_id 由客户端 DeviceId.get() 生成)
客户端的无障碍服务存活时周期上报心跳刷新 last_heartbeat_at;App 前台/登录时上报
registration_id(极光推送目标)后端 heartbeat_monitor_worker 扫描曾经保护过
现在心跳超时的设备,通过极光推送提醒用户重开无障碍
liveness_state 状态机(防刷屏,一次掉线只推一条):
unknown alive(收到 service 心跳) silent/notified(扫描发现超时并已推送)
心跳恢复时 handler 重置回 alive
spec: spec/accessibility-liveness-push.md
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class DeviceLiveness(Base):
# 表名不叫 device:device 易被当成「设备信息(品牌/型号/系统)」表;本表实为**无障碍存活监控状态**
# (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 推送目标 registration_id),故名 device_liveness。
__tablename__ = "device_liveness"
__table_args__ = (
UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 客户端 DeviceId.get() 生成的 per-install id(如 device_Pixel_ab12cd34)
device_id: Mapped[str] = mapped_column(String(128), index=True, nullable=False)
# 极光推送 registration id;拿到才填(JCollectionAuth 同意后才下发)
registration_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android")
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 收到过 service 心跳即 true(=该设备开过无障碍,功能对它有意义)
ever_protected: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
# 最近一次 service 心跳时间(存活证明);超时即视为保护掉线
last_heartbeat_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True, nullable=True
)
# 最近一次上报的无障碍开关状态(观测用)
last_report_protection_on: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
# unknown / alive / silent / notified
liveness_state: Mapped[str] = mapped_column(
String(16), nullable=False, default="unknown"
)
# 最近一次推送告警时间
notified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# 掉线告警「待客户端提醒」标记(后置检测 pull 版, 见 spec accessibility-liveness-pull-prompt.md)。
# 与 liveness_state 解耦: worker 检出掉线即置 True; touch_heartbeat(心跳恢复)不动它
# → 规避「服务随 App 重启先发心跳、state 被重置回 alive → 客户端进 App 漏看」竞态; 只由客户端 ack 清。
kill_alert_pending: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<DeviceLiveness id={self.id} user_id={self.user_id} "
f"device_id={self.device_id} state={self.liveness_state}>"
)
+13 -3
View File
@@ -2,7 +2,7 @@
每条 = 用户一次提交content 必填;contact 原为必填(微信/QQ/手机),原型改版后客户端不再采集,
新数据存空串(列保持 NOT NULL,免迁移;历史数据仍有值);images 为可选的截图 URL 列表
(/media/feedback/...,JSON )status: new(待处理)/ handled(处理)
(/media/feedback/...,JSON )status: pending(审核中)/adopted(采纳)/rejected(未采纳)
"""
from __future__ import annotations
@@ -25,8 +25,18 @@ class Feedback(Base):
contact: Mapped[str] = mapped_column(String(128), nullable=False)
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
# new(待处理) / handled(已处理)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="new")
# pending(审核中) / adopted(已采纳) / rejected(未采纳)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
reject_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
reward_coins: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 审核批注:采纳时可写采纳要点,未采纳时也可保留运营侧备注
review_note: Mapped[str | None] = mapped_column(String(256), nullable=True)
reviewed_by_admin_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("admin_user.id"), nullable=True
)
reviewed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
+61
View File
@@ -0,0 +1,61 @@
"""启动确认窗 agent 兜底样本表(launch_confirm_sample)。
pricebot launch_confirm_agent 每次"静态 PROFILES 没认出、靠 LLM 兜底放行"一个跨 App
启动确认窗(繁体 / 英文 / 小语种 / ROM 改版),把完整样本 serverserver 落这里研发定期
**人工** exec_success=true 的样本沉淀进 pricebot launch_confirm.PROFILES(静态快路径),
让以后同款窗不必再调 LLM
设计:
- 与登录无关不鉴权(serverserver 共享密钥头 X-Internal-Secret)
- **都上报不去重**(by design):同一种窗多台机器上报多条,研发按 host_package / locale
聚合后人工筛
- 大字段(弹窗树快照 + LLM plan + 设备信息) payload(JSONB)兜底,免得加字段就迁移 schema
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
# PG 上用 JSONB(可建 GIN 索引),SQLite(本地/测试)退化为通用 JSON(同 price_observation)。
_JSON = JSON().with_variant(JSONB(), "postgresql")
class LaunchConfirmSample(Base):
__tablename__ = "launch_confirm_sample"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
# pricebot 侧 trace_id:回指原始 trace,能去 price.shaguabijia.com 查完整上下文。
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 弹窗宿主包(= 触发判据, 也是研发沉淀进 PROFILES.host_packages 的键)。按它聚合人工筛。
host_package: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
# 被打开的目标 App 包名(标题里"想要打开 X"的 X 是变量, 据此把它从 sign 里挖空)。
target_app: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 系统语言地区(zh-TW / en-US…)—— 多语言问题的核心维度。
# ⚠️ 前端 AgentStepRequest 暂未上报设备信息 → 现恒 None,待前端加 device_info 字段后回填。
system_locale: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True)
# 这次兜底是否成功放行(弹窗消失=True;放弃 / LLM 没认出=False)。研发只沉淀 True 的。
exec_success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# 弹窗标题全文(研发据此提 launch_sign),如"「傻瓜比价」想要開啟「京東」"。
dialog_title: Mapped[str | None] = mapped_column(String(256), nullable=True)
# 大 JSON:完整样本。{is_launch_confirm, llm_model, llm_reason, plan, dialog_tree,
# device_info: {screen, locale, brand, model, android_version, rom_version}}
payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
def __repr__(self) -> str: # pragma: no cover
return (
f"<LaunchConfirmSample id={self.id} host={self.host_package!r} "
f"locale={self.system_locale!r} success={self.exec_success}>"
)
+4
View File
@@ -55,6 +55,10 @@ class MeituanCoupon(Base):
sell_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
original_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
head_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 头图字节大小 / MIME 类型:采集时 HEAD head_url 得到(Content-Length / Content-Type)。
# 读取侧已统一压缩(不再按大小判定),这两列保留用于分析/监控(GIF 占比、体积分布等)。
image_size: Mapped[int | None] = mapped_column(Integer, nullable=True) # 字节
image_type: Mapped[str | None] = mapped_column(String(32), nullable=True) # 如 image/jpeg
# ===== 销量(美团只给粗档位:热销1w+;num=排序用的下界数值,如 1w+ → 10000) =====
sale_volume: Mapped[str | None] = mapped_column(String(32), nullable=True)
+2 -1
View File
@@ -1,7 +1,8 @@
"""签到记录表。
每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次
- cycle_day: 1..14,14 天循环里今天落在第几档,决定发多少金币;断签后重置回 1
- cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1
(周期长度 = rewards.SIGNIN_CYCLE_LEN,2026-06 14 天改 7 天一轮)
- streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1
"""
from __future__ import annotations
+36 -24
View File
@@ -42,21 +42,20 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
).scalar_one()
def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int) -> int:
"""按每个 10 秒单位逐份计算奖励,LT 使用**账号累计**奖励份序号(不按天重置)。"""
if unit_count <= 0:
return 0
existing_units = db.execute(
select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0))
.where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
)
).scalar_one()
total = 0
for offset in range(1, unit_count + 1):
total += rewards.calculate_ad_reward_coin(ecpm, int(existing_units) + offset)
return total
def granted_unit_total(db: Session, user_id: int) -> int:
"""账号累计已发奖**条**数(COUNT status=granted),不按天重置 = 因子2(LT)基线。
口径:**一条广告 = 一份额度(一个单次公式值)**,"已发条数""已发份数"供前端拉取后精确复刻
金币公式做实时金币进度条(前端因子2 granted_unit_total + 本场已结算条数 + 1 取档,与本表发奖一致)
"""
return int(
db.execute(
select(func.count()).select_from(AdFeedRewardRecord).where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
)
).scalar_one()
)
def grant_feed_reward(
@@ -69,19 +68,22 @@ def grant_feed_reward(
ad_session_id: str | None = None,
adn: str | None = None,
slot_id: str | None = None,
feed_scene: str | None = None,
trace_id: str | None = None,
app_env: str | None = None,
our_code_id: str | None = None,
aborted: bool = False,
) -> AdFeedRewardRecord:
"""比价/领券一整场信息流(轮播多条)结束后结算奖励。client_event_id 幂等,同号重试不重复发。
"""**每条**信息流广告(客户端每条各上报一次)结算奖励。client_event_id 幂等,同号重试不重复发。
发奖规则:**比价全程不关广告才发**,金额按整场**总观看时长**折份( 10 1 )
- aborted=True(用户中途 关闭):整场不发, status='closed_early' 留痕(原因可查)
- 总时长不足 10 (unit_count==0): status='too_short' 不发
发奖规则:**一条广告 = 一个单次公式值**(rewards.calculate_ad_reward_coin),因子2(LT)按账号累计
****数递进;看满一份时长(unit_count>=1, 10 )才发,**不逐份累加**
- aborted=True(用户中途 关闭这条):本条不发, status='closed_early'
- 时长不足 10 (unit_count==0): status='too_short' 不发
- 命中当日条数上限: status='capped' 不发
duration_seconds 整场累计秒数一期 eCPM/时长均由客户端上报,服务端两道硬闸防刷:时长钳到
FEED_MAX_DURATION_SECONDS 限单场份数,eCPM rewards.calculate_ad_reward_coin 内钳到
AD_ECPM_MAX_FEN 限单份金额;叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间
duration_seconds **这一条**的观看秒数服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS
eCPM calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN;叠加每日 get_ad_daily_limit 条数上限
feed_scene:点位场景(comparison/coupon/welfare),仅归类落库,不参与计算
"""
existing = _find_by_event(db, client_event_id)
if existing is not None:
@@ -104,6 +106,8 @@ def grant_feed_reward(
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
our_code_id=our_code_id,
coin=0,
@@ -122,6 +126,8 @@ def grant_feed_reward(
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
our_code_id=our_code_id,
coin=0,
@@ -141,6 +147,8 @@ def grant_feed_reward(
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
our_code_id=our_code_id,
coin=0,
@@ -148,12 +156,14 @@ def grant_feed_reward(
)
return _commit_record(db, rec, client_event_id)
coin = _unit_reward_total(db, user_id, ecpm, unit_count)
# 一条广告 = 一个「单次公式值」(因子2 按账号累计**条**数, 即第 existing_ads+1 条);看满一份(unit_count>=1)即发,不逐份累加。
existing_ads = granted_unit_total(db, user_id)
coin = rewards.calculate_ad_reward_coin(ecpm, existing_ads + 1)
if coin > 0:
crud_wallet.grant_coins(
db, user_id, coin,
biz_type="feed_ad_reward", ref_id=client_event_id,
remark=f"信息流广告奖励 {unit_count}",
remark="信息流广告奖励",
)
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
@@ -165,6 +175,8 @@ def grant_feed_reward(
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
our_code_id=our_code_id,
coin=coin,
+78
View File
@@ -63,3 +63,81 @@ def list_all(db: Session) -> list[dict]:
}
)
return out
# ── 最新 App 版本(OTA 检查更新)──────────────────────────────────────────────
# 版本信息不是"运营可配置项",不进 CONFIG_DEFS:它由发布流程(发车)/应急整组写入、客户端读取做
# OTA,语义是"发布系统状态"而非"运营调参"。故走下面专用读写,直接操作 AppConfig 表(复用表不复用 repo)。
APP_VERSION_KEY = "latest_app_version"
def get_app_version(db: Session) -> dict | None:
"""读最新 App 版本信息(OTA)。无记录返回 None(客户端视为无更新)。"""
row = db.get(AppConfig, APP_VERSION_KEY)
return row.value if row is not None else None
def set_app_version(db: Session, data: dict, *, commit: bool = True) -> AppConfig:
"""整组原子覆盖最新版本信息(发布流程/应急写,非 admin,故 updated_by_admin_id 留空)。"""
row = db.get(AppConfig, APP_VERSION_KEY)
if row is None:
row = AppConfig(key=APP_VERSION_KEY, value=data, updated_by_admin_id=None)
db.add(row)
else:
row.value = data
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
return row
# ── 穿山甲广告配置(admin 可配,客户端动态拉)──────────────────────────────────
# 同 app_version:结构化 dict,复用 AppConfig 表不进 CONFIG_DEFS(它是一组关联配置,不是散项)。
# 默认值 = 客户端当前内置的硬编码 ID(AdConfig.kt),空库时下发默认 = 维持现状。
# reward_mkey 是 GroMore S2S 验签密钥(机密),只后端验签用、绝不下发客户端(见 platform/ad-config)。
AD_CONFIG_KEY = "ad_config"
_AD_CONFIG_DEFAULTS: dict[str, Any] = {
"app_id": "5830519", # 穿山甲应用ID(正式)
"reward_code_id": "104099389", # 福利页激励视频位
# ⚠️ 2026-06-21 真机核对穿山甲后台:5830519 名下信息流真实位是 104142227「信息流 1」;
# 旧值 104090333 不在该应用名下(请求会报 44406/配置 null、出不了广告)。客户端接入下发后以本值为准。
"compare_feed_code_id": "104142227", # 比价信息流位
"coupon_feed_code_id": "104142227", # 领券信息流位(初始同比价,运营可拆)
"reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*)
"reward_enabled": True, # 福利激励视频开关
"compare_ad_enabled": True, # 比价广告开关
"coupon_ad_enabled": True, # 领券广告开关
}
def get_ad_config(db: Session) -> dict:
"""读广告配置。DB 无则返回默认(=客户端内置值,维持现状);DB 有则与默认 merge
(新增字段向后兼容,老记录缺的字段用默认补)"""
row = db.get(AppConfig, AD_CONFIG_KEY)
merged = dict(_AD_CONFIG_DEFAULTS)
if row is not None and isinstance(row.value, dict):
merged.update(row.value)
return merged
def set_ad_config(db: Session, data: dict, *, admin_id: int, commit: bool = True) -> AppConfig:
"""admin 写广告配置。与现有值 merge(部分更新),只接受已知字段(防脏键)。"""
row = db.get(AppConfig, AD_CONFIG_KEY)
merged = dict(_AD_CONFIG_DEFAULTS)
if row is not None and isinstance(row.value, dict):
merged.update(row.value)
merged.update({k: v for k, v in data.items() if k in _AD_CONFIG_DEFAULTS})
if row is None:
row = AppConfig(key=AD_CONFIG_KEY, value=merged, updated_by_admin_id=admin_id)
db.add(row)
else:
row.value = merged
row.updated_by_admin_id = admin_id
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
return row
+49 -4
View File
@@ -11,6 +11,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.comparison import ComparisonRecord
from app.models.savings import SavingsRecord
from app.schemas.compare_record import ComparisonRecordIn
@@ -90,6 +91,21 @@ def upsert_record(
items=[it.model_dump(exclude_none=True) for it in payload.items],
comparison_results=[r.model_dump() for r in payload.comparison_results],
skipped_dish_names=list(payload.skipped_dish_names),
# 客户端环境 / 性能(debug,客户端上报;旧客户端为 None)
device_model=payload.device_model,
device_manufacturer=payload.device_manufacturer,
rom_vendor=payload.rom_vendor,
rom_name=payload.rom_name,
rom_version=payload.rom_version,
android_version=payload.android_version,
android_sdk=payload.android_sdk,
app_version=payload.app_version,
app_version_code=payload.app_version_code,
source_app_version=payload.source_app_version,
longitude=payload.longitude,
latitude=payload.latitude,
total_ms=payload.total_ms,
step_count=payload.step_count,
raw_payload=payload.model_dump(),
**derived,
)
@@ -140,6 +156,29 @@ def _ordered_shop_names(db: Session, user_id: int) -> set[str]:
return {s for s in rows if s}
def _ad_coins_by_trace(db: Session, user_id: int, trace_ids: list[str]) -> dict[str, int]:
"""各 trace_id「看广告赚的金币」:按 trace_id 聚合该用户已发放(granted)的信息流广告金币。
广告金币来自比价等候期信息流(biz_type=feed_ad_reward);客户端结算时把本场 trace_id 带上,
落到 ad_feed_reward_record.trace_id此处按 trace_id 求和 = 这次比价看广告实发的金币
trace_id 仅比价场景客户端带(领券/福利/旧客户端为 NULL),故按 trace_id 过滤天然只算比价广告
空集合直接返回(避免 IN () 非法)
"""
if not trace_ids:
return {}
rows = db.execute(
select(
AdFeedRewardRecord.trace_id,
func.coalesce(func.sum(AdFeedRewardRecord.coin), 0),
).where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
AdFeedRewardRecord.trace_id.in_(trace_ids),
).group_by(AdFeedRewardRecord.trace_id)
).all()
return {tid: int(coin) for tid, coin in rows if tid}
def list_records(
db: Session,
user_id: int,
@@ -147,7 +186,7 @@ def list_records(
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[ComparisonRecord], int | None]:
"""比价记录分页(按创建时间倒序、id 兜底,游标式)。附「已下单」店级标记(瞬态,不写库)。"""
"""比价记录分页(按创建时间倒序、id 兜底,游标式)。附「已下单」店级标记 + 「看广告赚的金币」(瞬态,不写库)。"""
stmt = select(ComparisonRecord).where(ComparisonRecord.user_id == user_id)
if cursor is not None:
stmt = stmt.where(ComparisonRecord.id < cursor)
@@ -157,10 +196,13 @@ def list_records(
next_cursor = items[-1].id if len(items) == limit else None
# 「已下单」标记:本页记录的 store_name 若落在该用户真实下单的店名集合里即 True。
# ordered 非 ORM 列,仅挂实例上供 ComparisonRecordOut(from_attributes) 读出,不持久化。
# ordered / ad_coins_earned 均非 ORM 列,仅挂实例上供 ComparisonRecordOut(from_attributes) 读出,不持久化。
ordered_shops = _ordered_shop_names(db, user_id)
# 「本次比价看广告赚的金币」:按本页 trace_id 一次性聚合(同 ordered 范式)。
ad_coins = _ad_coins_by_trace(db, user_id, [it.trace_id for it in items])
for it in items:
it.ordered = bool(it.store_name and it.store_name in ordered_shops)
it.ad_coins_earned = ad_coins.get(it.trace_id, 0)
return items, next_cursor
@@ -195,10 +237,13 @@ def get_stats(db: Session, user_id: int) -> tuple[int, int]:
def get_record(db: Session, user_id: int, record_id: int) -> ComparisonRecord | None:
"""取单条(限本人,避免越权读他人记录)。"""
return db.execute(
"""取单条(限本人,避免越权读他人记录)。附「看广告赚的金币」瞬态属性(同 list_records)。"""
rec = db.execute(
select(ComparisonRecord).where(
ComparisonRecord.id == record_id,
ComparisonRecord.user_id == user_id,
)
).scalar_one_or_none()
if rec is not None:
rec.ad_coins_earned = _ad_coins_by_trace(db, user_id, [rec.trace_id]).get(rec.trace_id, 0)
return rec
+26 -12
View File
@@ -26,7 +26,7 @@ def get_by_code(db: Session, code: str) -> CpsLink | None:
def create_link(
db: Session, *, group_id: int, activity_id: int, sid: str, target_url: str,
db: Session, *, group_id: int, activity_id: int, sid: str | None, target_url: str,
platform: str = "meituan", commit: bool = True,
) -> CpsLink:
"""生成唯一短码并存库。冲突重试 5 次,仍冲突用 12 位兜底(概率近 0)。"""
@@ -50,10 +50,15 @@ def create_link(
return link
def record_click(db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None) -> None:
def record_click(
db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None,
event_type: str = "visit", openid: str | None = None,
) -> None:
"""记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。
openid: 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计"""
db.add(CpsClick(
link_id=link.id, group_id=link.group_id, sid=link.sid,
ip=ip, ua=(ua[:500] if ua else None),
link_id=link.id, group_id=link.group_id, sid=link.sid, event_type=event_type,
ip=ip, ua=(ua[:500] if ua else None), openid=openid,
))
db.commit()
@@ -61,7 +66,8 @@ def record_click(db: Session, *, link: CpsLink, ip: str | None = None, ua: str |
def click_stats_by_group(
db: Session, *, date_from: datetime | None = None, date_to: datetime | None = None,
) -> dict[int, dict]:
"""按 group_id 聚合点击 → {group_id: {"pv": N, "uv": M}}。uv 按 (ip, ua) 近似去重。"""
"""按 group_id 聚合点击 → {group_id: {pv, uv, copy_pv, copy_uv}}。
visit 计入 pv/uv,copy(淘宝复制口令) 计入 copy_pv/copy_uv;uv (ip, ua) 近似去重"""
stmt = select(CpsClick)
if date_from is not None:
stmt = stmt.where(CpsClick.clicked_at >= date_from)
@@ -70,13 +76,21 @@ def click_stats_by_group(
clicks = list(db.execute(stmt).scalars().all())
agg: dict[int, dict] = {}
seen: dict[int, set] = {}
seen_visit: dict[int, set] = {}
seen_copy: dict[int, set] = {}
for c in clicks:
a = agg.setdefault(c.group_id, {"pv": 0, "uv": 0})
a["pv"] += 1
s = seen.setdefault(c.group_id, set())
a = agg.setdefault(c.group_id, {"pv": 0, "uv": 0, "copy_pv": 0, "copy_uv": 0})
key = (c.ip, c.ua)
if key not in s:
s.add(key)
a["uv"] += 1
if c.event_type == "copy":
a["copy_pv"] += 1
s = seen_copy.setdefault(c.group_id, set())
if key not in s:
s.add(key)
a["copy_uv"] += 1
else:
a["pv"] += 1
s = seen_visit.setdefault(c.group_id, set())
if key not in s:
s.add(key)
a["uv"] += 1
return agg
+43
View File
@@ -0,0 +1,43 @@
"""cps_wx_user 数据访问:按 openid upsert。"""
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.cps_wx_user import CpsWxUser
def get_by_openid(db: Session, openid: str) -> CpsWxUser | None:
return db.execute(
select(CpsWxUser).where(CpsWxUser.openid == openid)
).scalar_one_or_none()
def upsert(
db: Session, *, openid: str, code: str | None = None, group_id: int | None = None,
nickname: str | None = None, headimgurl: str | None = None, unionid: str | None = None,
) -> CpsWxUser:
"""按 openid upsert。
- 首次:建行,记来源 code/group
- 已存在:仅在传入非 None 时更新昵称/头像/unionid(base 阶段为 None,不覆盖已有画像);
并刷新 last_seen(显式 set, onupdate 仅在字段有变更时触发)
"""
u = get_by_openid(db, openid)
if u is None:
u = CpsWxUser(
openid=openid, first_code=code, first_group_id=group_id,
nickname=nickname, headimgurl=headimgurl, unionid=unionid,
)
db.add(u)
else:
if nickname is not None:
u.nickname = nickname
if headimgurl is not None:
u.headimgurl = headimgurl
if unionid is not None:
u.unionid = unionid
u.last_seen = func.now()
db.commit()
db.refresh(u)
return u
+124
View File
@@ -0,0 +1,124 @@
"""device 表读写(设备注册 / 心跳 / 超时扫描)。"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.device import DeviceLiveness
def _get(db: Session, *, user_id: int, device_id: str) -> DeviceLiveness | None:
stmt = select(DeviceLiveness).where(
DeviceLiveness.user_id == user_id, DeviceLiveness.device_id == device_id
)
return db.execute(stmt).scalar_one_or_none()
def register_or_update(
db: Session,
*,
user_id: int,
device_id: str,
registration_id: str | None,
platform: str = "android",
app_version: str | None = None,
) -> DeviceLiveness:
"""注册设备或更新其 registration_id / 元信息。upsert by (user_id, device_id)。"""
device = _get(db, user_id=user_id, device_id=device_id)
if device is None:
device = DeviceLiveness(
user_id=user_id,
device_id=device_id,
registration_id=registration_id,
platform=platform or "android",
app_version=app_version,
)
db.add(device)
else:
if registration_id:
device.registration_id = registration_id
if platform:
device.platform = platform
if app_version:
device.app_version = app_version
db.commit()
db.refresh(device)
return device
def touch_heartbeat(
db: Session,
*,
user_id: int,
device_id: str,
accessibility_enabled: bool,
registration_id: str | None,
) -> DeviceLiveness:
"""处理一次心跳(心跳也能自注册)。
service 心跳或 accessibility_enabled=true ,刷新存活并把状态机重置回 alive
清掉 notified_at(掉线恢复 下次再断才会再推一条)
"""
now = datetime.now(timezone.utc)
device = _get(db, user_id=user_id, device_id=device_id)
if device is None:
device = DeviceLiveness(user_id=user_id, device_id=device_id)
db.add(device)
if registration_id:
device.registration_id = registration_id
device.last_report_protection_on = accessibility_enabled
if accessibility_enabled:
device.last_heartbeat_at = now
device.ever_protected = True
device.liveness_state = "alive"
device.notified_at = None
db.commit()
db.refresh(device)
return device
def list_overdue(db: Session, *, timeout_minutes: int) -> list[DeviceLiveness]:
"""掉线设备:曾经保护过、当前 alive、心跳超时。
本期只做终端打印检测不推送 不再要求有 registration_id(没接极光 token 的设备也要检出)
"""
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
stmt = select(DeviceLiveness).where(
DeviceLiveness.ever_protected.is_(True),
DeviceLiveness.liveness_state == "alive",
DeviceLiveness.last_heartbeat_at.is_not(None),
DeviceLiveness.last_heartbeat_at < cutoff,
)
return list(db.execute(stmt).scalars().all())
def mark_notified(db: Session, *, device_id_pk: int) -> None:
"""标记掉线已检出(状态机进入 notified,避免每轮重复处理)+ 置「待客户端提醒」标记。
kill_alert_pending state 解耦( spec accessibility-liveness-pull-prompt.md):
心跳恢复(touch_heartbeat)只重置 state不动此标记,故客户端下次进 App 必能拉到这次掉线
"""
device = db.get(DeviceLiveness, device_id_pk)
if device is not None:
device.liveness_state = "notified"
device.notified_at = datetime.now(timezone.utc)
device.kill_alert_pending = True
db.commit()
def get_device(db: Session, *, user_id: int, device_id: str) -> DeviceLiveness | None:
"""按 (user_id, device_id) 取设备(liveness 查询用)。"""
return _get(db, user_id=user_id, device_id=device_id)
def ack_kill_alert(db: Session, *, user_id: int, device_id: str) -> None:
"""客户端已弹过「开启自启动」引导 → 清「待提醒」标记(幂等; 下次真掉线 worker 再置)。"""
device = _get(db, user_id=user_id, device_id=device_id)
if device is not None and device.kill_alert_pending:
device.kill_alert_pending = False
db.commit()
+16 -2
View File
@@ -1,8 +1,12 @@
"""feedback 表写"""
"""feedback 表写。"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.feedback import Feedback
@@ -19,9 +23,19 @@ def create_feedback(
content=content,
contact=contact,
images=images or None,
status="new",
status="pending",
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
)
db.add(fb)
db.commit()
db.refresh(fb)
return fb
def list_feedback(db: Session, *, user_id: int) -> list[Feedback]:
stmt = (
select(Feedback)
.where(Feedback.user_id == user_id)
.order_by(Feedback.created_at.desc(), Feedback.id.desc())
)
return list(db.execute(stmt).scalars().all())
+106
View File
@@ -0,0 +1,106 @@
"""反馈页二维码卡配置读写(运营后台可配 → App 反馈页同步)。
整张卡(开关 + 二维码图 + 三行文案)作为一个 JSON 对象,复用通用 app_config
(key=feedback_qr_card,value 存这个 dict)表里没这行 返回内置默认(= App 反馈页原
硬编码文案,空配置 = 改造前现状,图片走 App 本地兜底)admin 写一行 App 下次拉
GET /api/v1/feedback/config 即生效(跨进程一致)
不经 app.repositories.app_config(那层按 CONFIG_DEFS 白名单限定 key,且会出现在系统配置页);
key 由本模块独占维护,不进 CONFIG_DEFS,所以不会污染系统配置页
"""
from __future__ import annotations
from typing import Any
from sqlalchemy.orm import Session
from app.models.app_config import AppConfig
_KEY = "feedback_qr_card"
# 默认值镜像 App 反馈页原硬编码:空配置时整体行为与改造前一致。
_DEFAULTS: dict[str, Any] = {
"enabled": True,
"image_url": None, # None = App 用本地 asset 兜底(group_qr.png),再不行画占位伪码
"title": "长按图片保存二维码",
"group_name": "傻瓜比价官方群",
"subtitle": "一起唠嗑共创、解锁新玩法",
}
_FIELDS = tuple(_DEFAULTS.keys())
def _merge(raw: Any) -> dict[str, Any]:
"""把 DB 存的(可能不全的)dict 叠加到默认上,得到完整配置(5 个字段,无 updated_at)。"""
out = dict(_DEFAULTS)
if isinstance(raw, dict):
for k in _FIELDS:
v = raw.get(k)
if v is not None: # image_url 的 None 与默认 None 等价,无需特判
out[k] = v
return out
def get_config(db: Session) -> dict[str, Any]:
"""完整配置 + updated_at(admin 读 / App 读共用;App schema 会忽略多余的 updated_at)。"""
row = db.get(AppConfig, _KEY)
cfg = _merge(row.value if row is not None else None)
cfg["updated_at"] = row.updated_at.isoformat() if row is not None and row.updated_at else None
return cfg
def _write(db: Session, value: dict[str, Any], *, admin_id: int, commit: bool) -> dict[str, Any]:
"""整体覆写该行(value 须为完整 5 字段 dict),返回合并后的完整配置(含 updated_at)。"""
row = db.get(AppConfig, _KEY)
if row is None:
row = AppConfig(key=_KEY, value=value, updated_by_admin_id=admin_id)
db.add(row)
else:
row.value = value # 重新赋整个 dict,SQLAlchemy 能侦测到变更(in-place 改才侦测不到)
row.updated_by_admin_id = admin_id
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
out = _merge(row.value)
out["updated_at"] = row.updated_at.isoformat() if row.updated_at else None
return out
def update_config(
db: Session,
*,
enabled: bool | None = None,
title: str | None = None,
group_name: str | None = None,
subtitle: str | None = None,
admin_id: int,
commit: bool = True,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""改文案/开关(只改传了的字段;图片走 set_image)。返回 (before, after) 供审计。"""
row = db.get(AppConfig, _KEY)
before = _merge(row.value if row is not None else None)
new_value = {k: before[k] for k in _FIELDS}
if enabled is not None:
new_value["enabled"] = enabled
if title is not None:
new_value["title"] = title.strip()
if group_name is not None:
new_value["group_name"] = group_name.strip()
if subtitle is not None:
new_value["subtitle"] = subtitle.strip()
after = _write(db, new_value, admin_id=admin_id, commit=commit)
return before, after
def set_image(
db: Session, image_url: str | None, *, admin_id: int, commit: bool = True
) -> tuple[dict[str, Any], dict[str, Any]]:
"""设置/清空二维码图 URL。返回 (before, after);before['image_url'] 供调用方删旧文件。"""
row = db.get(AppConfig, _KEY)
before = _merge(row.value if row is not None else None)
new_value = {k: before[k] for k in _FIELDS}
new_value["image_url"] = image_url
after = _write(db, new_value, admin_id=admin_id, commit=commit)
return before, after
+35
View File
@@ -0,0 +1,35 @@
"""启动确认窗兜底样本落库:单条 insert(都上报、不去重,by design)。"""
from __future__ import annotations
import logging
from typing import Optional
from sqlalchemy.orm import Session
from app.models.launch_confirm_sample import LaunchConfirmSample
from app.schemas.launch_confirm_sample import LaunchConfirmSampleIn
logger = logging.getLogger("shagua.launch_confirm")
def _trunc(s: Optional[str], n: int) -> Optional[str]:
"""截断到列长上限(PG varchar 超长会报错;繁体长标题等需要)。空 → None。"""
return s[:n] if s else None
def insert_sample(db: Session, payload: LaunchConfirmSampleIn) -> int:
"""写入一条样本,返回新行 id。"""
row = LaunchConfirmSample(
trace_id=_trunc(payload.trace_id, 64),
device_id=_trunc(payload.device_id, 64),
host_package=_trunc(payload.host_package, 128),
target_app=_trunc(payload.target_app, 128),
system_locale=_trunc(payload.system_locale, 32),
exec_success=bool(payload.exec_success),
dialog_title=_trunc(payload.dialog_title, 256),
payload=payload.payload,
)
db.add(row)
db.commit()
db.refresh(row)
return row.id
+35 -34
View File
@@ -6,13 +6,13 @@ feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条
种子是生成规则:用户名可空(随机合成脱敏名,避开同屏撞名)金额是区间(每次随机取值)
feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出
脱敏名规则( feed 统一,按字符算中英文皆适用):
- 有昵称 昵称脱敏:3 +隐藏字数个星+(省钱小能手*** / SaveKingS******g),
2 +(阿强*),1 用户+该字(用户喵);
- 没昵称 合成一个假名(见下)再脱敏,少数露用户+id 3 (用户********618)
真实条:设过昵称的按真实昵称脱敏;当前多数用户没设昵称,直接展示用户****会清一色,故给它们合成
假名后期真实昵称多了,真实条会直接用真实昵称,合成占比自然下降种子 / 兜底同样合成避开同屏撞名;
种子留空 / 用户****xxx模板名一律重新合成(自愈历史种子,无需迁移)
脱敏名规则(对齐 PRD用户标识打码规则,按字符中英文皆适用):
- 有昵称 昵称脱敏:n5+***+(AAA省钱小能手A*** / Micky_ M***),
n=4+**+(你好不好**),n3+**(小确呀** / 阿强** / **);
- 没昵称(真实用户)用户+5+id 2 (用户*****08), user_id 稳定
真实条按上述 PRD 规则脱敏种子 / 兜底无真实用户(无昵称/ id,套不上用户*****+id后2位),
仍本地合成多样假名再走同一套昵称脱敏(避开同屏撞名),让横条不至清一色;种子留空 / 用户****xxx
模板名一律重新合成(自愈历史种子,无需迁移)
假名合成:纯本地语料**组合生成**(中文姓池×名字池 拼真名 / 中文网络昵称 / 英文昵称 / 英文名带数字尾),
组合空间上万中文为主英文为辅,脱敏后像真实异质用户群**真实条按 `user_id` 播种确定性合成**(同一用户
每次展示恒定不同用户各异),刷新/翻页不变脸;种子 / 兜底用运行时随机源出多样填充不依赖外部库
@@ -44,12 +44,12 @@ _REAL_ROWS_TTL_SECONDS = 30
_REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用
_real_rows_cache: dict = {"at": None, "rows": None}
# ===== 用户标识脱敏 + 没真实昵称时的假名合成 =====
# 脱敏规则(中英文皆适用,按字符算):有昵称→≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 /
# SaveKing→S******g)、2 字「首+星」(阿强→阿*)、1 字「用户+该字」(喵→用户喵);没昵称→用户+8星+id 后 3 位。
# 没真实昵称时(当前多数用户 / 种子 / 兜底)合成假名:本地姓/名字池组合真名 + 网络昵称语料(中/英)
# 混播再走同一套脱敏,像真实异质用户群。后期真实昵称多了,真实条直接用真实昵称,这里占比自然下降
_ANON_STARS = "*" * 8 # 无昵称匿名串固定 8 星(用户********xxx)
# ===== 用户标识脱敏(对齐 PRD) + 种子无真实昵称时的假名合成 =====
# 脱敏规则(按字符数,中英文皆适用):有昵称→n≥5「首+***+末」、n=4「首+**+末」、n≤3「首+**」;
# 没昵称(真实用户)→「用户」+5星+id 后 2(用户*****08)
# 种子/兜底没有真实用户(无昵称/无 id),本地姓/名字池组合真名 + 网络昵称语料(中/英)合成假名,
# 再走同一套昵称脱敏,让横条像真实异质用户群、不清一色(PRD 只约束真实用户,种子是运营填充)
_ANON_STARS = "*" * 5 # 「用户」匿名串固定 5 星(用户*****xx,对齐 PRD)
# 中文真名组合池:姓(百家姓常见 ~100)× 名字单字(~120)拼「姓 + 1~2 字」,组合空间上万。
# 脱敏后只露「姓*」或「姓*末」,故无需真实姓名库——组合够多即看着各异(替代原 Faker zh_CN)。
@@ -86,19 +86,23 @@ _NICK_EN = [
def _mask_nickname(nick: str) -> str:
"""昵称脱敏(按字符,中英文皆可):≥3 字→首+(隐藏字数个)星+末(省钱小能手→省***手 / SaveKing→S******g);
2 +(阿强*);1 用户+该字(用户喵)调用方需保证 nick strip 且非空"""
"""昵称脱敏(按字符,中英文皆可,对齐 PRD「用户标识打码规则」):
n5 +***+(AAA省钱小能手A*** / Micky_ M***);
n=4 +**+(你好不好** / LadyL**y);
n3 +**(小确呀** / 阿强** / ** / LayL**)
调用方需保证 nick strip 且非空"""
n = len(nick)
if n >= 3:
return nick[0] + "*" * (n - 2) + nick[-1]
if n == 2:
return nick[0] + "*"
return "用户" + nick
if n >= 5:
return nick[0] + "***" + nick[-1]
if n == 4:
return nick[0] + "**" + nick[-1]
return nick[0] + "**"
def _anon_by_id(user_id: int) -> str:
"""没昵称匿名:固定「用户」+ 8 星 + id 后 3 位(不足 3 位前补 0):用户********618。"""
return "用户" + _ANON_STARS + f"{user_id % 1000:03d}"
def _mask_anon(user_id: int) -> str:
"""没昵称(真实用户,对齐 PRD):「用户」+ 5 星 + id 后 2 位(不足 2 位前补 0):用户*****08。
:user_id 为整数,id 2 取末两位数字(PRD 示例用户*****x8 x 为占位符)"""
return "用户" + _ANON_STARS + f"{user_id % 100:02d}"
def _synth_full_name(rng: random.Random) -> str:
@@ -118,27 +122,24 @@ def _synth_full_name(rng: random.Random) -> str:
def _synth_masked_name(rng: random.Random) -> str:
"""合成一条「已脱敏」假名(供没真实昵称的真实用户 / 种子 / 兜底用)。用传入 rng 决定一切随机。"""
"""合成一条「已脱敏」假名(供种子 / 兜底用,真实用户已改走 PRD 规则)。用传入 rng 决定一切随机。"""
full = _synth_full_name(rng).strip()
return _mask_nickname(full) if full else "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}"
return _mask_nickname(full) if full else "用户" + _ANON_STARS + f"{rng.randint(0, 99):02d}"
def _mask_real(nickname: str | None, user_id: int) -> str:
"""真实用户脱敏:设过昵称→昵称脱敏(中英文皆可);没昵称→**按 user_id 播种确定性合成假名**再脱敏
(同一用户每次展示恒定刷新不变脸,不同用户各异),避免无昵称用户清一色用户****xxx;
少数(~15%)用户+真实 id 3 当前多数用户没昵称,后期真实昵称多了占比下降"""
"""真实用户脱敏(对齐 PRD「用户标识打码规则」):设过昵称→昵称脱敏(中英文皆可);
没昵称用户+5+id 2 (用户*****08), user_id 稳定刷新不变脸"""
nick = (nickname or "").strip()
if nick:
return _mask_nickname(nick)
# 关键:按 user_id 播种本地 rng → 同一用户的合成名跨请求/刷新恒定(0.15 抽签也用它)。
seeded = random.Random(user_id)
return _anon_by_id(user_id) if seeded.random() < 0.15 else _synth_masked_name(seeded)
return _mask_anon(user_id)
def _synth_name(rng: random.Random) -> str:
"""合成一条脱敏名(种子留空 / 旧模板名 / 兜底用):绝大多数=假名脱敏,少数=用户+随机 3 位(用户********618)。"""
"""合成一条脱敏名(种子留空 / 旧模板名 / 兜底用):绝大多数=假名脱敏,少数=用户+随机 2 位(用户*****08)。"""
if rng.random() < 0.15:
return "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}"
return "用户" + _ANON_STARS + f"{rng.randint(0, 99):02d}"
return _synth_masked_name(rng)
@@ -148,7 +149,7 @@ def _unique_name(used: set[str]) -> str:
n = _synth_name(_rng)
if n not in used:
return n
return "用户" + _ANON_STARS + f"{_rng.randint(0, 999):03d}" # 兜底:数字尾号天然好去重
return "用户" + _ANON_STARS + f"{_rng.randint(0, 99):02d}" # 兜底:数字尾号天然好去重
def _validate_amount(min_cents: int, max_cents: int) -> None:
+21 -5
View File
@@ -22,7 +22,8 @@ _USERNAME_FIRST = "23456789"
_USERNAME_DIGITS = "0123456789"
_USERNAME_LEN = 11
# 默认昵称 = 9 位大小写字母 + 数字随机串(创建时给,用户可后续改;不要求唯一)。
# 默认昵称 = "用户" + 9 位大小写字母 + 数字随机串(创建时给,用户可后续改;不要求唯一)。
_NICKNAME_PREFIX = "用户"
_NICKNAME_ALPHABET = string.ascii_letters + string.digits
_NICKNAME_LEN = 9
@@ -35,8 +36,10 @@ def _gen_username() -> str:
def _gen_nickname() -> str:
"""随机一个 9 位字母+数字的默认昵称"""
return "".join(secrets.choice(_NICKNAME_ALPHABET) for _ in range(_NICKNAME_LEN))
"""默认昵称 = "用户" + 9 位字母+数字随机串"""
return _NICKNAME_PREFIX + "".join(
secrets.choice(_NICKNAME_ALPHABET) for _ in range(_NICKNAME_LEN)
)
def get_user_by_username(db: Session, username: str) -> User | None:
@@ -111,14 +114,27 @@ def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User:
def soft_delete_account(db: Session, user: User) -> None:
"""注销账号:软删除 + 匿名化。
"""注销账号:软删除 + 匿名化 + 释放唯一约束/外部绑定
phone 改成 `deleted_<id>` 释放唯一约束,允许同号码重新注册成全新账号;
phone 改成 `deleted_<id>` 释放手机号唯一约束,允许同号码重新注册成全新账号;
清空 PII(昵称/头像),保留行用于审计status=deleted 后将无法再登录该行
(登录接口校验 status==active)
注销必须连同释放 user 上其余唯一约束/外部身份,否则 deleted 行永久占着槽位,
对应资源再也无法被复用:
- wechat_openid: 不清 这个微信再也无法被任何账号绑定(提现通道彻底锁死);
- invite_code: 不清 邀请码槽位被占 + 裸查仍命中死号(发奖已被 bind
status==active 校验兜住,清掉更干净)
资金安全(未提现现金 / 在审提现单)由调用方 delete_account 端点前置校验,这里只匿名化
"""
user.status = "deleted"
user.phone = f"deleted_{user.id}"
user.nickname = None
user.avatar_url = None
# 等价"解绑微信":释放 openid 唯一槽 + 清微信昵称头像
user.wechat_openid = None
user.wechat_nickname = None
user.wechat_avatar_url = None
# 释放邀请码唯一槽
user.invite_code = None
db.commit()
+9
View File
@@ -340,6 +340,15 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict:
return info
def get_cash_balance_cents(db: Session, user_id: int) -> int:
"""只读查现金余额(分)。账户不存在返回 0,**不创建账户**——注销前置校验用,
不该给一个即将被删除的用户凭空建一行空账户(get_or_create_account 会建)"""
val = db.execute(
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
).scalar_one_or_none()
return val or 0
def has_reviewing_withdraw(db: Session, user_id: int) -> bool:
"""用户是否有「待审核(reviewing)」的提现单。
+20
View File
@@ -135,6 +135,18 @@ class FeedRewardIn(BaseModel):
duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)")
adn: str | None = Field(None, description="实际投放 ADN")
slot_id: str | None = Field(None, description="实际展示代码位")
feed_scene: str | None = Field(
None,
max_length=16,
description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);"
"比价与领券共用同一信息流代码位,需客户端在各调用点显式标注,缺省=未分类",
)
trace_id: str | None = Field(
None,
max_length=64,
description="本次比价 trace_id(比价场景必带):把这场广告金币归属到对应比价记录,"
"比价记录页据此显示「比价赚 N 金币」。领券/福利场景为空",
)
app_env: str | None = Field(
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
)
@@ -154,6 +166,14 @@ class FeedRewardOut(BaseModel):
daily_limit: int = Field(..., description="每日信息流展示次数上限")
class FeedRewardUnitsOut(BaseModel):
granted_units: int = Field(
...,
description="账号累计已发奖的信息流**条**数(一条广告=一份额度),不按天重置;"
"前端据此算因子2(LT)做实时金币进度条,与后端发奖公式对齐",
)
class RewardNoShowIn(BaseModel):
"""激励视频展示了但用户提前关闭/跳过、未触发发奖——上报留痕(不发金币)。
+6
View File
@@ -48,6 +48,12 @@ class TokenWithUser(TokenPair):
description="该 设备+账号 是否已走完新手引导(据登录请求里的 device_id 计算)。"
"true → 客户端登录后直接进首页,跳过引导。",
)
force_onboarding: bool = Field(
False,
description="是否强制本次登录走新手引导(仅测试号置 true)。客户端据此让【应用内重新登录】"
"(退出后再登)也重走引导,普通号为 false、按原逻辑回原页/首页。与 onboarding_completed "
"分工:后者管 App 启动路由,本字段管应用内登录后的去向。",
)
# ===== 极光一键登录 =====
+34
View File
@@ -51,6 +51,13 @@ class ComparisonResultIn(BaseModel):
# 跟 coupon_saved 并存, 是更丰富的明细; 空=没抠到 → 前端回退单券路径。
# 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。
applied_coupons: list[AppliedCouponIn] = Field(default_factory=list)
# 逐平台状态(客户端上报前把 done.params 的 comparison_results + platform_results.status 合并写入):
# success / below_minimum / store_closed / store_not_found / items_not_found / unsupported / failed。
# 「比价记录」页据此对无价平台显示对应文案(未满起送价/门店打烊/店家未入驻/比价失败)。
# 必须显式声明: 落库走 model_dump(), 不声明会被 pydantic 静默丢弃 → 记录页拿不到 status。
status: str | None = None
# 门店打烊原因(price 为 None 时带): 与 status="store_closed" 等价的更早信号, 一并落库供前端兜底判打烊。
store_closed: str | None = None
class ComparisonRecordIn(BaseModel):
@@ -69,6 +76,13 @@ class ComparisonRecordIn(BaseModel):
# 明细
items: list[ComparisonItemIn] = Field(default_factory=list)
comparison_results: list[ComparisonResultIn] = Field(default_factory=list)
# 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/
# unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列),
# admin「卡在哪一步」从这里读。dict{platform_id: {...}} 宽松存(结构由 pricebot 定——是
# 按平台 id 索引的 dict 而非 list,只作 debug 展示)。
# ⚠️ 早先误声明成 list → 每条成功记录 422 被拒、记录页只剩失败/终止
# (2026-06-17 引入 platform_results 上报后复现);改 dict 后修复。
platform_results: dict = Field(default_factory=dict)
skipped_dish_count: int | None = None
skipped_dish_names: list[str] = Field(default_factory=list)
total_dish_count: int | None = None
@@ -82,6 +96,23 @@ class ComparisonRecordIn(BaseModel):
# 时分秒前端拼不出,必须由后端透传)。
trace_url: str | None = Field(None, description="本次比价公网调试链接")
# ===== debug 维度(客户端采集上报;旧客户端不带 → None。仅 admin 比价记录页用)=====
# 必须显式声明,否则 model_dump() 落 raw_payload 时被 pydantic 静默丢弃(同上面 coupon_saved 的坑)。
device_model: str | None = None
device_manufacturer: str | None = None
rom_vendor: str | None = Field(None, description="vivo/OPPO/Xiaomi/HUAWEI/HONOR/samsung")
rom_name: str | None = Field(None, description="OriginOS/ColorOS/HyperOS/MIUI/HarmonyOS...")
rom_version: int | None = None
android_version: str | None = None
android_sdk: int | None = None
app_version: str | None = None
app_version_code: int | None = None
source_app_version: str | None = Field(None, description="被操控的源平台 App 版本")
longitude: float | None = None
latitude: float | None = None
total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)")
step_count: int | None = Field(None, description="agent loop 总步数")
# ===== 读取出参 =====
@@ -116,6 +147,9 @@ class ComparisonRecordOut(BaseModel):
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
# 由 list_records 动态算出挂在 ORM 实例上(非 DB 列),from_attributes 读出;缺省 False。
ordered: bool = False
# 「本次比价看广告赚的金币」(feed_ad_reward,按 trace_id 聚合 granted 金币;非 DB 列,
# list_records/get_record 动态挂在 ORM 实例上,from_attributes 读出)。0=没赚到 → 记录页不显示金币标。
ad_coins_earned: int = 0
created_at: datetime
+48
View File
@@ -0,0 +1,48 @@
"""设备注册 / 心跳相关 schema(无障碍保护存活检测)。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class DeviceRegisterRequest(BaseModel):
device_id: str
registration_id: str | None = None
platform: str = "android"
app_version: str | None = None
class HeartbeatRequest(BaseModel):
device_id: str
source: str = "service" # service | app
accessibility_enabled: bool = True
registration_id: str | None = None
class DeviceOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
device_id: str
registration_id: str | None
ever_protected: bool
liveness_state: str
last_heartbeat_at: datetime | None
updated_at: datetime | None
class OkResponse(BaseModel):
ok: bool = True
class LivenessOut(BaseModel):
"""本机掉线告警状态(后置检测 pull 版)。客户端只需这一个布尔判断要不要弹「开启自启动」引导,
故只返回 kill_alert_pending(不暴露设备详情 / 内部 liveness_state )从未注册过 默认 False(无告警)"""
model_config = ConfigDict(from_attributes=True)
kill_alert_pending: bool = False
class LivenessAckRequest(BaseModel):
device_id: str
+37 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
class FeedbackOut(BaseModel):
@@ -12,3 +12,39 @@ class FeedbackOut(BaseModel):
id: int
status: str
created_at: datetime
class FeedbackQrConfigOut(BaseModel):
"""反馈页二维码卡配置(运营后台可配)。App 反馈页据此渲染整张「加群二维码」卡。
image_url 是相对 /media 路径(客户端按自己的 BASE_URL 拼绝对地址),为空 客户端走本地兜底
"""
enabled: bool
image_url: str | None = None
title: str
group_name: str
subtitle: str
class FeedbackRecordOut(BaseModel):
id: int
content: str
images: list[str] = Field(default_factory=list)
status: str
reject_reason: str | None = None
reward_coins: int | None = None
created_at: datetime
class FeedbackRecordCountsOut(BaseModel):
all: int = 0
pending: int = 0
adopted: int = 0
rejected: int = 0
class FeedbackRecordsOut(BaseModel):
records: list[FeedbackRecordOut]
counts: FeedbackRecordCountsOut
+26
View File
@@ -0,0 +1,26 @@
"""启动确认窗兜底样本的内部上报模型(pricebot → app-server)。"""
from __future__ import annotations
from pydantic import BaseModel
class LaunchConfirmSampleIn(BaseModel):
"""一次 agent 兜底的完整样本。
字段一律宽松可空:落盘是辅助数据,绝不能因个别字段缺失就拒收(否则连累 pricebot 兜底)
"""
trace_id: str | None = None
device_id: str | None = None
host_package: str | None = None
target_app: str | None = None
system_locale: str | None = None
exec_success: bool = False
dialog_title: str | None = None
payload: dict | None = None
class LaunchConfirmSampleOut(BaseModel):
"""落库结果。"""
id: int
+16 -1
View File
@@ -13,6 +13,21 @@ from pydantic import BaseModel, Field
FeedStatus = Literal["ok", "empty", "degraded"]
# ───────────────── 头图传输优化(统一缩放 + 转 WebP) ─────────────────
# 美团图片 CDN 支持在 URL 后拼 @<w>w_<h>h_1e_1c[.webp] 让服务端缩放/转码。feed 卡片只占
# 124dp(≈372px@3x),而库里 head_url 是原图(实测中位 137KB、长尾到 17MB、GIF 均 4.8MB),
# 是首页 feed 图片加载偏慢的来源。按产品要求,头图**统一**缩到 124dp + 转 WebP 再下发
# (全部压缩、不设大小阈值),由美团 CDN 服务端缩放/转码,实测省 76–99% 体积。
FEED_THUMB_PARAM = "@375w_375h_1e_1c.webp" # 124dp@3x 缩略 + 转 WebP
def feed_image_url(head_url: str) -> str:
"""统一给头图 URL 拼缩放参数(缩到 124dp + 转 WebP),让美团 CDN 出小图;空 URL 原样返回。"""
if not head_url:
return head_url
return head_url.split("@")[0] + FEED_THUMB_PARAM
# ───────────────── 券卡片(归一化后给客户端) ─────────────────
class CouponCard(BaseModel):
@@ -94,7 +109,7 @@ class CouponCard(BaseModel):
platform=platform,
biz_line=biz_line,
name=cpd.get("name") or "",
head_image_url=head_url,
head_image_url=feed_image_url(head_url),
brand_name=brand.get("brandName"),
brand_logo_url=brand.get("brandLogoUrl"),
sell_price=sell,
+44 -1
View File
@@ -1,7 +1,7 @@
"""首页平台级展示数据 schemas(客户端首页门面数字)。"""
from __future__ import annotations
from pydantic import BaseModel
from pydantic import BaseModel, Field
class PlatformStatsOut(BaseModel):
@@ -28,3 +28,46 @@ class AppFlagsOut(BaseModel):
"""客户端拉取的运营 feature flag(不鉴权,登录前也能拉)。客户端缓存后按需读。"""
comparing_ad_enabled: bool # 比价/领券期是否展示信息流广告(远程 kill-switch)
class AdConfigPublicOut(BaseModel):
"""下发给客户端的穿山甲广告配置(不鉴权,客户端缓存后用)。
不含 reward_mkey(GroMore 验签密钥,机密,只后端验签用,绝不下发)
"""
app_id: str # 穿山甲应用ID(改了客户端需冷启才生效,SDK init 一次性读)
reward_code_id: str # 福利页激励视频位
compare_feed_code_id: str # 比价信息流位
coupon_feed_code_id: str # 领券信息流位
reward_enabled: bool # 福利激励视频开关
compare_ad_enabled: bool # 比价广告开关
coupon_ad_enabled: bool # 领券广告开关
class AppVersionOut(BaseModel):
"""最新 App 版本信息(OTA 检查更新,不鉴权)。
客户端用 latest_version_code(整数,单调递增)与本机 versionCode 比较,**不要** version_name 字符串比
latest_version_code=0 表示后台尚未配置任何版本 客户端一律视为"已是最新"
"""
latest_version_code: int = 0 # 最新版 versionCode;0=未配置(无更新)
latest_version_name: str = "" # 展示用,如 "0.1.4"
apk_url: str = "" # 下载链接(发车产出的永久版本化链接)
update_note: str = "" # 更新说明,弹窗展示
min_supported_version_code: int = 0 # 本机低于此版本=强制更新;0=不强更(全可选)
apk_size_bytes: int = 0 # 包大小(字节),展示"约 xMB"
apk_sha256: str = "" # APK 完整性校验(客户端下载后比对,防损坏/劫持)
class AppVersionWriteIn(BaseModel):
"""内部写入最新版本(发布流程/应急,X-Internal-Secret 校验)。整组原子覆盖。"""
latest_version_code: int = Field(gt=0)
latest_version_name: str = Field(min_length=1)
apk_url: str = Field(min_length=1)
update_note: str = ""
min_supported_version_code: int = 0
apk_size_bytes: int = 0
apk_sha256: str = ""
+38
View File
@@ -0,0 +1,38 @@
"""按 trace_id 同机拉 pricebot 的本次比价 LLM 调用明细(供 admin 比价记录 debug)。
pricebot chat() 收口处把每次 LLM input/output 落盘,暴露内部接口
GET /api/internal/llm_calls/{trace_id}(X-Internal-Secret 校验,与本服务 INTERNAL_API_SECRET 同值)
app-server 收到比价记录上报后同机拉一次, comparison_record.llm_calls
best-effort:拉不到(pricebot 未部署该版本 / trace 无明细 / 网络)一律返 [],绝不阻断
比价记录上报多实例下用 pick_pricebot(trace_id) 选对实例( trace 落同进程,明细在
该进程磁盘),与比价透传同一致性 hash 口径
"""
from __future__ import annotations
import logging
import httpx
from app.core.config import settings
from app.core.pricebot_router import pick_pricebot
logger = logging.getLogger("shagua.pricebot_llm")
def fetch_llm_calls(trace_id: str) -> list[dict]:
"""返回该次比价的 LLM 调用明细列表(每条 {scene,model,input_messages,output,usage,latency_ms,error});
未配密钥 / trace_id / 拉取失败 []"""
secret = settings.INTERNAL_API_SECRET
if not secret or not trace_id:
return []
base = pick_pricebot(trace_id).rstrip("/")
url = f"{base}/api/internal/llm_calls/{trace_id}"
try:
resp = httpx.get(url, headers={"X-Internal-Secret": secret}, timeout=5.0)
if resp.status_code == 200:
return resp.json().get("calls", []) or []
logger.warning("fetch_llm_calls trace=%s status=%s", trace_id, resp.status_code)
except Exception as e: # noqa: BLE001 — best-effort,任何异常都不该影响上报
logger.warning("fetch_llm_calls trace=%s failed: %s", trace_id, e)
return []
Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# 美团券头图大小/类型回填 —— 全量重跑一轮 ETL,把 meituan_coupon.image_size / image_type 灌满。
# 上线本 PR 后在服务器手动跑一次,立即回填存量券(不必等定时器逐轮 upsert 自然补齐)。
#
# 用法(服务器, root):
# cd /opt/shaguabijia-app-server
# sudo bash deploy/backfill_image_meta.sh
# # 全量一轮 ~70min,建议在 tmux/screen 里跑,防 SSH 断开中断
#
# 前提:已拉到含本 PR 的 main、已跑 `alembic upgrade head`(脚本会自检列是否存在)、
# 已 restart shaguabijia-app-server.service。服务器 MT_CPS_PROXY 留空(直连 CDN)。
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/shaguabijia-app-server}"
PY="$APP_DIR/.venv/bin/python"
cd "$APP_DIR"
mkdir -p data
LOG="data/etl_backfill_$(date +%Y%m%d_%H%M%S).log"
# 退出时务必恢复定时器(即使 ETL 中途失败,也不把 timer 留在停用态)
restore_timer() { systemctl start meituan-etl.timer 2>/dev/null || true; }
trap restore_timer EXIT
echo "[0/3] 自检 image_size/image_type 列(缺列说明没迁移,先跑 alembic upgrade head)..."
"$PY" -c "from sqlalchemy import inspect; from app.db.session import engine; \
cols=[c['name'] for c in inspect(engine).get_columns('meituan_coupon')]; \
import sys; (('image_size' in cols) and ('image_type' in cols)) or sys.exit(' ❌ 缺列,请先: sudo .venv/bin/python -m alembic upgrade head'); \
print(' ✓ 列已就绪')"
echo "[1/3] 停定时器,防与定时轮次并发占锁..."
systemctl stop meituan-etl.timer 2>/dev/null || true
echo "[2/3] 全量重跑 ETL(~70min;日志 $LOG)..."
# 不走 systemctl:meituan-etl.service 有 20min 硬超时,全量一轮跑不完会被掐断。
"$PY" -m scripts.pull_meituan_coupons --once --prune-hours 24 2>&1 | tee "$LOG"
echo "[3/3] 验证填充率..."
"$PY" - <<'PYEOF'
from sqlalchemy import text
from app.db.session import engine
engine.echo = False
with engine.connect() as c:
tot = c.execute(text("SELECT count(*) FROM meituan_coupon")).scalar()
enr = c.execute(text("SELECT count(*) FROM meituan_coupon WHERE image_size IS NOT NULL")).scalar()
gif = c.execute(text("SELECT count(*) FROM meituan_coupon WHERE image_type='image/gif'")).scalar()
print(f" 总 {tot} 行 | 有 image_size {enr} ({enr*100//max(tot,1)}%) | GIF {gif} 张")
PYEOF
echo "✅ 回填完成(定时器已恢复)。"
+11 -1
View File
@@ -3,6 +3,15 @@
> 对象:维护「每天 0 点把用户金币自动兑成现金」这套定时任务的同事。
> 🔒 服务器登录信息见**私密交接清单**,不入库。
## 现状(2026-06 起):已改为 App 进程内任务,不再需要 systemd timer
「0 点自动兑换」现由 **App 进程内后台任务** `app.core.daily_exchange_worker` 负责:App 一起来就
每 10 分钟检查、跨过北京 0 点自动跑一轮(逐用户幂等、文件锁互斥、重启补跑当天遗漏)。**无需再装 / 启用
`daily-exchange.timer`**。
- 开关仍是 `.env``AUTO_EXCHANGE_ENABLED`(false → worker 不启动);间隔由 `AUTO_EXCHANGE_CHECK_INTERVAL_SEC`(默认 600s=10min)控。
- 下方 systemd timer / 脚本属**遗留 + 手动应急**:逻辑同一套且幂等,可手动 `--once` 补跑;
但**不要再 `enable` timer 与进程内 worker 并存**(虽幂等不会双兑,纯属多余)。
## 它是什么
客户端已删手动「兑金币」入口、改为「0 点自动兑现金(可能存在延迟)」,故服务端每天 0 点跑一轮:
把每个用户的金币按汇率 **100 金币 = 1 分** 兑成现金。
@@ -21,8 +30,9 @@
| systemd 单元 | `deploy/daily-exchange.{service,timer}` |
| 运行锁 | `/tmp/daily_auto_exchange.lock`(可用环境变量 `DAILY_EXCHANGE_LOCK` 覆盖) |
## 部署(Linux 服务器)
## (遗留 / 可选)systemd timer 部署 — 现已被进程内 worker 取代,正常无需执行
```bash
# ⚠️ 仅在你明确想用外部 timer 而非进程内 worker 时才做(并把 worker 关掉),否则跳过。
sudo cp deploy/daily-exchange.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now daily-exchange.timer
systemctl list-timers daily-exchange.timer # 确认下次触发时间
+15 -9
View File
@@ -1,16 +1,22 @@
# 后端文档库(docs/)
`shaguabijia-app-server` 的文档都在这里。各文档作用:
`shaguabijia-app-server` 的文档都在这里。结构:**根目录放总览,其余按领域/用途分目录**。
| 文档 / 目录 | 作用 |
## 根目录
| 文档 | 作用 |
|---|---|
| [后端技术实现.md](./后端技术实现.md) | **后端技术方案**:业务概览、分层架构与目录、登录链路、美团 CPS、领券透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"看这份。 |
| [api/](./api/) | **API 接口文档**(目录),采用"索引 + 一接口一文件",结构见下。想查"某个接口的协议"看这里。 |
| [integrations/](./integrations/) | **集成层实现文档**(目录):穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 |
| [数据库迁移.md](./数据库迁移.md) | **Alembic 迁移指南**:clone 后如何建表、日常升级、新增迁移、迁移文件命名约定。想"把数据库跑起来 / 改表结构"看这份。 |
| [待办与技术债.md](./待办与技术债.md) | **待办与技术债账本**:记"现在先简化、以后要补"的事 + 跨前后端技术债(P1 鉴权/用户绑定、引擎移植待办等),免遗忘。想知道"还欠什么、以后要补什么"看这份。 |
| [看广告赚金币上线清单.md](./看广告赚金币上线清单.md) | **看广告发奖上线 checklist**:跨前后端,记上线前必做(GroMore 回调配置、清理调试脚手架、端到端验收)。上线"看广告赚金币"前对照这份。 |
| [邀请功能-实现原理与本地测试.md](./邀请功能-实现原理与本地测试.md) | **好友邀请(invite-mvp)实现原理 + 跨前后端具体实现 + 本地内网测试方法**:剪贴板 deferred-deeplink 归因、双方各发 1 万金币、落地页 dl.html、起本地后端 + 编 debug 包 + 两台真机走全链路 + 上线前还差什么。要本地测 / 接手邀请功能看这份。 |
| [后端技术实现.md](./后端技术实现.md) | **后端技术方案总览**:业务概览、分层架构与目录、登录链路、美团 CPS、领券/比价透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"看这份。 |
| README.md | 本文件:文档库索引/传送门。 |
## 子目录
| 目录 | 作用 |
|---|---|
| [api/](./api/) | **API 接口文档**:"索引 + 一接口一文件"(组织方式见下)。想查"某个接口的协议"看这里。 |
| [database/](./database/) | **数据库文档**:每张表一个文件(表结构/字段/索引/约定) + 两份迁移指南——[数据库迁移.md](./database/数据库迁移.md)(Alembic 操作:clone 后建表/日常升级/新增迁移/多 head 排查) + [postgres-migration.md](./database/postgres-migration.md)(SQLite → PostgreSQL 切换步骤,配套 `scripts/init_postgres.py`)。想"把库跑起来 / 改表结构 / 查某张表"看这里。 |
| [integrations/](./integrations/) | **集成层实现文档**:穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 |
| [guides/](./guides/) | **开发 / 上线 / 功能 指南**:[待办与技术债.md](./guides/待办与技术债.md)(跨前后端 backlog,P1 鉴权/用户绑定、引擎移植待办等,"还欠什么、以后要补什么"看这份) + [看广告赚金币上线清单.md](./guides/看广告赚金币上线清单.md)(看广告发奖上线 checklist) + [邀请功能-实现原理与本地测试.md](./guides/邀请功能-实现原理与本地测试.md)(invite-mvp 实现原理 + 本地内网全链路测试) + [CPS发券分发与微信授权.md](./guides/CPS发券分发与微信授权.md)(**CPS 发券系统交接文档**:设群/建活动/生成落地页短链/美团对账/统计 + 微信网页授权拿 openid 做用户级统计;含数据流/表/平台差异/端点/配置/代码地图/排障/技术债)。 |
## api/ 目录是怎么组织的(传送门式)
+54 -8
View File
@@ -3,7 +3,7 @@
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
> 最后更新:2026-06-07(金币数值体系一期:签到膨胀 + 信息流广告结算
> 最后更新:2026-06-23(补全此前缺整族的端点:device 无障碍存活监控、internal 内部回写、CPS 短链落地、report/invite/wxpay,及 platform/coupon/feedback/user 的零散读端点;device/internal/cps-redirect 三族新建单文件文档。上一次 2026-06-18 补录比价透传端点与 `meituan/top-sales`
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
---
@@ -19,13 +19,24 @@
| 5 | `POST /api/v1/auth/refresh` | 无 | [详情](./auth-refresh.md) |
| 6 | `GET /api/v1/auth/me` | Bearer | [详情](./auth-me.md) |
| 7 | `POST /api/v1/auth/logout` | Bearer | [详情](./auth-logout.md) |
| 8 | `POST /api/v1/coupon/step` | 无 | [详情](./coupon-step.md) |
| 8 | `POST /api/v1/coupon/step` | 无 | [详情](./coupon-step.md)(透传 pricebot + best-effort 写 `coupon_*` 三表) |
| 8a | `POST /api/v1/coupon/prompt/shown` | 无 | 引导窗弹出即上报(按 device+package+日记 `shown`,今天这个 App 不再自动弹)(无单独文档) |
| 8b | `POST /api/v1/coupon/prompt/dismiss` | 无 | 用户拒绝/关闭引导窗(透传链路看不到拒绝,客户端通知)(无单独文档) |
| 8c | `GET /api/v1/coupon/prompt/should-show` | 无 | 切到外卖 App 时是否还应弹引导窗(`device_id`+`package`)(无单独文档) |
| 8d | `POST /api/v1/coupon/prompt/reset` | 无 | 重置今日引导窗 engagement(开发测频控用)(无单独文档) |
| 8e | `GET /api/v1/coupon/completed-today` | 无 | 这台设备今天是否已跑完整轮领券(首页「去领取」卡置灰源)(无单独文档) |
| 8f | `POST /api/v1/coupon/completed-today/reset` | 无 | 重置今日已完成(开发用)(无单独文档) |
| 8g | `GET /api/v1/coupon/stats` | Bearer | 累计领券数(「我的」页战绩卡;按 user_id 聚合,**鉴权**)(无单独文档) |
| 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan-coupons.md) |
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan-feed.md) |
| 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan-referral-link.md) |
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend |||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md) |
| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md) |
| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan-top-sales.md)(销量榜:离线库 `meituan_coupon` 按销量降序 + 跨源去重,不实时打美团 |
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend;下按 Phase 流程列,均不鉴权) |||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源) |
| 12a | `POST /api/v1/intent/precoupon/step` | 无 | Phase 0 意图识别前先用券,仅美团源(透传,无单独文档) |
| 12b | `POST /api/v1/intent/step` | 无 | Phase 1 多帧意图识别,仅淘宝源,循环到 done(透传,无单独文档) |
| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md)Phase 2 步进) |
| 13a | `POST /api/v1/trace/finalize` | 无 | 比价 trace 收尾上云,终止/未识别拿 trace_url(透传,无单独文档) |
| **比价记录**(前缀 `/api/v1/compare`;按用户落库,**鉴权**,区别于上面不鉴权的透传) |||
| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare-record-report.md) |
| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare-records.md) |
@@ -34,6 +45,19 @@
| **比价战绩里程碑**(前缀 `/api/v1/compare`;福利页「记录比价战绩」,按成功比价数解锁逐档发金币) |||
| 12d | `GET /api/v1/compare/milestones` | Bearer | [详情](./compare-milestones.md) |
| 12e | `POST /api/v1/compare/milestones/{milestone}/claim` | Bearer | [详情](./compare-milestone-claim.md) |
| **设备 / 无障碍存活监控**(前缀 `/api/v1/device`;心跳超时检出 + 掉线召回,#65 |||
| D1 | `POST /api/v1/device/register` | Bearer | [详情](./device-liveness.md)(注册设备/更新极光 push token |
| D2 | `POST /api/v1/device/heartbeat` | Bearer | [详情](./device-liveness.md)(无障碍服务存活心跳,心跳也能自注册) |
| D3 | `GET /api/v1/device/liveness` | Bearer | [详情](./device-liveness.md)(进 App 查本机是否被判掉线过) |
| D4 | `POST /api/v1/device/liveness/ack` | Bearer | [详情](./device-liveness.md)(确认已弹引导,清掉线告警) |
| **上报更低价**(前缀 `/api/v1/report`;众包纠偏,人工审核发奖) |||
| R1 | `POST /api/v1/report` | Bearer | 提交上报(multipart:`comparison_record_id`/`reported_platform_id`/`reported_price`(元) + 1~4 张截图;原最低价反查 `comparison_record.best_*` 校验须更低)(无单独文档) |
| R2 | `GET /api/v1/report/records` | Bearer | 上报记录列表(`?status=` pending/approved/rejected 可选筛选)(无单独文档) |
| **好友邀请**(前缀 `/api/v1/invite`;注册即生效,双方各发 1 万金币) |||
| I1 | `GET /api/v1/invite/me` | Bearer | 我的邀请码 + 分享链接 + 已邀人数/已得金币(无单独文档) |
| I2 | `GET /api/v1/invite/invitees` | Bearer | 我邀请的人列表(`limit`/`offset` 分页)(无单独文档) |
| I3 | `POST /api/v1/invite/landing-track` | 无 | 落地页 `dl.html` 访问上报指纹(剪贴板归因兜底;浏览器无 token)(无单独文档) |
| I4 | `POST /api/v1/invite/bind` | Bearer | 绑定邀请人;支持 clipboard/manual 邀请码 + fingerprint 指纹反查三种归因(无单独文档) |
| **钱包 / 我的资产**(前缀 `/api/v1/wallet` |||
| 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) |
| 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) |
@@ -64,15 +88,37 @@
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) |
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) |
| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) |
| 35c | `GET /api/v1/ad/feed-reward/units` | Bearer | 信息流广告今日已发份数/上限(配合 `feed-reward` 看进度)(无单独文档) |
| **用户资料**(前缀 `/api/v1/user` |||
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) |
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) |
| 36a | `POST /api/v1/user/onboarding/complete` | Bearer | 标记新手引导完成(按 账号+device_id 幂等,跨卸载重装持久)(无单独文档) |
| 36b | `GET /api/v1/user/onboarding/status` | Bearer | 查该 (账号,设备) 是否走过引导(运营在 admin 删记录即触发重走)(无单独文档) |
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user-delete.md) |
| **帮助与反馈**(前缀 `/api/v1/feedback` |||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) |
| **首页门面数据**(前缀 `/api/v1/platform`;全平台展示数字,登录前可读) |||
| 38a | `GET /api/v1/feedback/config` | Bearer | 反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)(无单独文档) |
| 38b | `GET /api/v1/feedback/records` | Bearer | 我的反馈历史(pending/adopted/rejected(无单独文档) |
| **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) |||
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform-stats.md) |
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./platform-savings-feed.md) |
| 40a | `GET /api/v1/platform/flags` | 无 | 客户端运营 feature flag(比价/领券期广告开关等),拉取后缓存(无单独文档) |
| 40b | `GET /api/v1/platform/ad-config` | 无 | 客户端拉广告配置(穿山甲 app_id + 各位 ID + 各场景开关;不含验签密钥)(无单独文档) |
| 40c | `GET /api/v1/platform/app-version` | 无 | 最新 App 版本(OTA 检查更新;与本机 versionCode 比)(无单独文档) |
| **微信支付回调**(前缀 `/api/v1/wxpay` |||
| W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) |
| **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) |||
| C1 | `GET /c/{code}` | 无 | [详情](./cps-redirect.md)(短链落地:微信授权拿 openid + 记点击 + 302 跳/淘宝 H5 落地页) |
| C2 | `POST /c/{code}/copy` | 无 | [详情](./cps-redirect.md)(淘宝落地页点「复制口令」记 `copy` |
| C3 | `GET /wx/oauth/cb` | 无 | [详情](./cps-redirect.md)(微信网页授权回调;upsert `cps_wx_user` + 种 cookie,`include_in_schema=False` |
| C4 | `GET /MP_verify_*.txt` | 无 | [详情](./cps-redirect.md)(微信「网页授权域名」归属校验文件,`include_in_schema=False` |
| **内部回写端点**(前缀 `/internal`;pricebot/发布流程→app-server,**`X-Internal-Secret` 头**,非客户端接口) |||
| N1 | `POST /internal/price-observation` | 内部密钥 | [详情](./internal.md)(比价价格事实批量落 `price_observation` |
| N2 | `GET /internal/store-mapping/lookup` | 内部密钥 | [详情](./internal.md)(按源平台店名反查目标平台已沉淀店铺 id/deeplink |
| N3 | `POST /internal/store-mapping` | 内部密钥 | [详情](./internal.md)(跨平台店铺身份映射落 `store_mapping` |
| N4 | `POST /internal/store-mapping/invalidate` | 内部密钥 | [详情](./internal.md)(标记某平台 shopId 缓存 deeplink 失效) |
| N5 | `POST /internal/launch-confirm-sample` | 内部密钥 | [详情](./internal.md)(启动确认窗兜底样本落 `launch_confirm_sample` |
| N6 | `POST /internal/app-version` | 内部密钥 | [详情](./internal.md)(发布流程写最新 App 版本,落 `app_config` |
| **静态资源**StaticFiles 挂载,见下方 `/media` 静态服务) |||
| - | `GET /media/avatars/<file>` | 无 | 用户头像;返回二进制图片 |
| - | `GET /media/feedback/<file>` | 无 | 反馈截图;返回二进制图片 |
@@ -109,7 +155,7 @@
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link``sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
> `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。
> `coupon/step` 及外卖比价的 `intent/recognize``intent/precoupon/step``intent/step``price/step``trace/finalize` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见 `app/api/v1/compare.py`)。
> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status``ad/feed-reward`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。
> 金额字段一律以**分**为单位(`*_cents`)。
@@ -169,7 +215,7 @@
| `platform` | int | `1`=外卖/到家, `2`=到店 |
| `biz_line` | int \| null | 到店子类:1到餐 2到综 3酒店 4门票 |
| `name` | string | 商品名 |
| `head_image_url` | string | 头图 |
| `head_image_url` | string | 头图;后端已**统一**拼缩放参数 `@375w_375h_1e_1c.webp`(美团 CDN 服务端缩到 124dp + 转 WebP,实测省 7699%),客户端直接用 |
| `brand_name` | string \| null | 品牌名 |
| `brand_logo_url` | string \| null | 品牌 logo |
| `sell_price` | string | 现价 |
+1 -1
View File
@@ -18,7 +18,7 @@ pricebot-backend 的响应**原样返回**JSON object)。典型含 `result`
外卖比价由客户端无障碍引擎在源平台(淘宝闪购 / 美团 / 京东外卖)购物车页点悬浮球触发 → 调本接口拿 `query` + `calibration` → 进入 `/price/step` 循环。
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`:`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../待办与技术债.md) P1。
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`:`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../guides/待办与技术债.md) P1。
**相关配置**:
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`
+1 -1
View File
@@ -5,7 +5,7 @@
比价 `done` 帧后,客户端用**带 JWT 的通道**上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。
> ⚠️ 与不鉴权的透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳,本接口按用户维度落库,**必须鉴权**。
> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../待办与技术债.md) P1)。
> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../guides/待办与技术债.md) P1)。
## 入参(JSON body
+1 -1
View File
@@ -17,7 +17,7 @@ pricebot-backend 的响应**原样返回**(JSON object)。
是产品"一键领券"的接入点,**前端已接通**:首页「去领取」→ `CouponPromptDialog` 确认 → 权限检查 → 无障碍引擎 `PriceBotService.startCouponClaim()` → 循环调本接口,后端逐张券下发 launch/wait/done。
⚠️ **客户端契约 vs Server 实现**:Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer <access_token>` 头(契约层 OK);Server MVP 阶段**暂未启用强校验**——[coupon.py](../../app/api/v1/coupon.py) 没接 `Depends(get_current_user)`,只读 `device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补强校验 + `device_id↔user_id` 绑定,详见 [待办与技术债.md](../待办与技术债.md) P1。
⚠️ **客户端契约 vs Server 实现**:Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer <access_token>` 头(契约层 OK);Server MVP 阶段**暂未启用强校验**——[coupon.py](../../app/api/v1/coupon.py) 没接 `Depends(get_current_user)`,只读 `device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补强校验 + `device_id↔user_id` 绑定,详见 [待办与技术债.md](../guides/待办与技术债.md) P1。
**相关配置**:
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`
+30
View File
@@ -0,0 +1,30 @@
# CPS 群发短链落地(cps-redirect 族)
> 所属:cps-redirect 组(**无前缀**,挂域名根;源 `app/api/v1/cps_redirect.py`) | 鉴权:**公网无鉴权**(群里任何人点都要能跳/能领) | [← 返回 API 索引](./README.md)
>
> 落库:点击落 [`cps_click`](../database/cps_click.md)、微信落地页用户落 [`cps_wx_user`](../database/cps_wx_user.md);短链由 [`cps_link`](../database/cps_link.md) 解析。
运营把 `/c/{code}` 短链发到微信群,用户点击 → 记点击 → 按平台 302 跳转(美团/京东)或返回 H5 淘宝落地页。**记点击/写用户失败绝不阻断用户跳转/领券**(try/except 吞掉)。
## GET /c/{code} — 短链落地
- 微信内 + 还没拿到 openid + 配了服务号 → 先 `snsapi_base` 静默授权拿 openid,再 302 回本页。
- 非微信 / 已有 openid cookie / 未配服务号 → 直接走原逻辑(openid 可能为 None,**绝不阻断**)。
- 记 `visit`(写 `cps_click`)后:**美团/京东** 302 跳 `target_url`;**淘宝** 返回 H5 落地页(主视觉图 + 「复制口令」按钮)。
- `code` 不存在/失效 → 302 跳兜底(`https://www.meituan.com/`)。
**入参**:路径 `code`(短链码)。**出参**:`302 Redirect``200 HTML`(淘宝)。
## POST /c/{code}/copy — 淘宝落地页点了「复制口令」
淘宝落地页是 H5,用户点「复制口令」才算转化 → 记一条 `copy` 事件(带 openid cookie)。
**入参**:路径 `code`;body 空。**出参**:`{ "ok": true }`
## GET /wx/oauth/cb — 微信网页授权回调(`include_in_schema=False`)
`state='base:{code}'`(静默拿 openid)或 `'uinfo:{code}'`(补昵称头像)。换 openid(+userinfo)→ upsert [`cps_wx_user`](../database/cps_wx_user.md) → 种 cookie(`wx_openid` / `wx_uinfo`,30 天)→ 302 回落地页。**任何失败兜底回落地页,绝不阻断领券**。`uinfo` 回流带 `?authed=1`,落地页据此自动触发复制(把「点领券→授权→复制」衔接成一步)。
## GET /MP_verify_*.txt — 微信「网页授权域名」归属校验(`include_in_schema=False`)
微信请求 `coupon.shaguabijia.com/MP_verify_xxx.txt` 验证域名归属;端点放域名根、返回文件名核心串(纯文本)。为微信网页授权(拿 openid)接入做准备。
## 说明
- **两阶授权**:`base` 静默拿 openid(统计主力、不打扰);`userinfo` 在用户点「领券」时触发(交互触发避免微信快照页),拿昵称/头像/unionid。
- **`cps_click` 不阻断**:落地/跳转优先级高于统计——丢几条点击可接受,挡住用户领券不可接受。

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