Compare commits

...

26 Commits

Author SHA1 Message Date
marco 9ec9d2389d feat(store_mapping): 淘宝缓存 deeplink 失效检测→标记→回退(app-server 侧) (#53)
加 taobao_deeplink_invalid_at 列(迁移+model);mark_taobao_deeplink_invalid 按 shopId 标记所有行;lookup_nearest 过滤失效淘宝候选;新增 POST /internal/store-mapping/invalidate。+ 7 单测。

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

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

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

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

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

---------

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

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

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

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

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

---------

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

---------

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

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

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

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

---------

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

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

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

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

---------

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

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

---------

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

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

---------

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

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

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

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

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

---------

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:32:51 +08:00
liujiahui 0890e693d7 feat(coupon): 今日跑完整轮领券后端记录 + 查询(首页置灰源) (#34)
新增 coupon_daily_completion 表:按 (device_id, 自然日) 记"今天是否已跑完
整轮领券(到 done 帧)"。客户端据此把首页「去领取」卡置灰、不可点。

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

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

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

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

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #34
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-06-10 16:07:33 +08:00
ouzhou 0bddce516f feat(ad): eCPM 单位统一为分 + 新增看广告金币审计接口 (#32)
- eCPM 口径修正:getEcpm 原值是分/千次,新增 parse_ecpm_fen;parse_ecpm_yuan
  改为 ÷100 转元,因子1 阈值按元判档(100/200/400),收益换算用元
- test-grant 用客户端按 ad_session_id 上报的真实 eCPM 发奖(取不到/≤0 兜底 200)
- 新增 GET /admin/api/ad-coin-audit 只读复算对账(router/repo/schema),复用发奖公式
- 同步更新 docs/api、docs/database 的分/元口径

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

---------

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: xianze <ze@192.168.0.128>
Reviewed-on: #33
Co-authored-by: zhangxianze <zhangxianze@wonderable.ai>
Co-committed-by: zhangxianze <zhangxianze@wonderable.ai>
2026-06-10 14:44:28 +08:00
marco cfc54ac2be fix(alembic): 合并 invite_fingerprint 与 044 双 head 修复 Multiple heads 部署失败
#31 的 invite_fingerprint_table 迁移 down_revision 挂在 0cf18d590b1d,与 #30
的 044dce6e9b1f(ad_reward+withdraw 合并) 形成并列双 head,alembic upgrade head
报 "Multiple head revisions are present",ecs1 部署在迁移步失败未重启。

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 02:28:56 +08:00
131 changed files with 4998 additions and 435 deletions
+2
View File
@@ -83,6 +83,8 @@ WXPAY_AUTH_NOTIFY_URL=
WITHDRAW_AUTO_RECONCILE_ENABLED=false
WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC=300
WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES=15
# 0 点自动兑金币开关(deploy/daily-exchange.timer 触发的脚本读它;false=脚本 no-op)。默认 true。
AUTO_EXCHANGE_ENABLED=true
# ===== 穿山甲激励视频(服务端发奖回调)=====
# 看完激励视频后穿山甲服务器 S2S 回调本服务发金币(客户端不参与发奖)。
@@ -0,0 +1,26 @@
"""merge user_force_onboarding and comparison_idx_coupon_daily heads
Revision ID: 9b894f5fff05
Revises: d4b87c5847de, user_force_onboarding
Create Date: 2026-06-10 22:49:02.248506
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9b894f5fff05'
down_revision: Union[str, Sequence[str], None] = ('d4b87c5847de', 'user_force_onboarding')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,26 @@
"""merge invite_fingerprint and ad_reward heads
Revision ID: a8c47fc4dc39
Revises: invite_fingerprint_table, 044dce6e9b1f
Create Date: 2026-06-10 01:03:03.699443
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a8c47fc4dc39'
down_revision: Union[str, Sequence[str], None] = ('invite_fingerprint_table', '044dce6e9b1f')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,40 @@
"""add coin_transaction task ref unique index
可重复任务(task_enable_notification「打开消息提醒」逐次减半领取)的发放路径是无锁的
读-算-写:并发/连点的两个 claim 会都读到同一已领次数、算出同一 ref_id(task_key:N)、
各发一笔,导致双倍发钱。给 coin_transaction 加 (user_id, biz_type, ref_id) 部分唯一索引
(仅 biz_type LIKE 'task%' 且 ref_id 非空),第二笔撞唯一约束 → IntegrityError,被
task.claim_task 兜底成 AlreadyClaimedError(409)。与 cash_transaction 提现退款、
withdraw_order 在途单的「唯一约束 + IntegrityError 兜底」同模式。
Revision ID: coin_txn_task_ref_uq
Revises: drop_force_onboarding
Create Date: 2026-06-12 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "coin_txn_task_ref_uq"
down_revision: Union[str, Sequence[str], None] = "drop_force_onboarding"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
"ux_coin_transaction_task_ref",
"coin_transaction",
["user_id", "biz_type", "ref_id"],
unique=True,
sqlite_where=sa.text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
postgresql_where=sa.text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("ux_coin_transaction_task_ref", table_name="coin_transaction")
@@ -0,0 +1,48 @@
"""add composite index (status, created_at) on comparison_record
首页轮播 feed(按 status='success' 过滤 + created_at 近期排序)与省钱战绩聚合
都吃这个过滤+排序;复合索引避免随数据量增大退化成全表扫。
Revision ID: comparison_status_created_idx
Revises: a8c47fc4dc39
Create Date: 2026-06-10
"""
from __future__ import annotations
from alembic import op
revision = "comparison_status_created_idx"
down_revision = "a8c47fc4dc39"
branch_labels = None
depends_on = None
INDEX_NAME = "ix_comparison_status_created"
def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == "postgresql":
# PG 上 comparison_record 已有数据量, 普通 CREATE INDEX 持表写锁会阻塞线上写入;
# 用 CONCURRENTLY 不锁表(须脱离事务, autocommit_block 切到自动提交)。
with op.get_context().autocommit_block():
op.create_index(
INDEX_NAME, "comparison_record", ["status", "created_at"],
unique=False, postgresql_concurrently=True,
)
else:
op.create_index(
INDEX_NAME, "comparison_record", ["status", "created_at"], unique=False
)
def downgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == "postgresql":
with op.get_context().autocommit_block():
op.drop_index(
INDEX_NAME, table_name="comparison_record",
postgresql_concurrently=True,
)
else:
op.drop_index(INDEX_NAME, table_name="comparison_record")
@@ -0,0 +1,58 @@
"""coupon daily completion table(今日跑完整轮领券 → 首页置灰源)
Revision ID: coupon_daily_completion
Revises: f8d3b1e60a27
Create Date: 2026-06-10 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "coupon_daily_completion"
down_revision: str | Sequence[str] | None = "f8d3b1e60a27"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"coupon_daily_completion",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("device_id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("complete_date", sa.Date(), nullable=False),
sa.Column("trace_id", sa.String(length=64), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("device_id", "complete_date", name="uq_coupon_completion_device_date"),
)
with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op:
batch_op.create_index(
batch_op.f("ix_coupon_daily_completion_user_id"), ["user_id"], unique=False
)
batch_op.create_index(
batch_op.f("ix_coupon_daily_completion_trace_id"), ["trace_id"], unique=False
)
def downgrade() -> None:
with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_trace_id"))
batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_user_id"))
op.drop_table("coupon_daily_completion")
@@ -0,0 +1,53 @@
"""coupon_prompt_engagement 频控加 package 维度(美团/淘宝/京东各自独立弹)
Revision ID: coupon_engage_per_package
Revises: store_mapping_jd_cols
Create Date: 2026-06-14 11:00:00.000000
需求:领券引导窗按 (device, App, 自然日) 频控——在美团弹过/领过,不影响淘宝、京东今天
仍各弹一次。原表唯一键是 (device_id, engage_date),缺 package → 任一 App 弹过就把整台
设备当天标记 engage,其余 App 被压住不弹(bug)。
本迁移:
1. 加 package 列(NOT NULL,旧行用 server_default "" 填占位,不影响新逻辑判断)。
2. 旧唯一约束 (device_id, engage_date) → 新 (device_id, package, engage_date)。
SQLite 不支持直接 drop/add 约束,用 batch_alter_table(建临时表 + 拷数据 + 换名,
与 store_mapping_* 同款)。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'coupon_engage_per_package'
down_revision: Union[str, Sequence[str], None] = 'store_mapping_jd_cols'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
# 加 package 列。旧行(改造前的全局记录)填 "" 占位:它们对应的是"老的全局态",
# 新逻辑按 (device, package, 日) 判,占位 "" 不会与真实包名(com.xxx)碰撞。
batch_op.add_column(
sa.Column('package', sa.String(length=64), nullable=False, server_default='')
)
# 旧唯一约束 (device_id, engage_date) → 新三元组 (device_id, package, engage_date)。
batch_op.drop_constraint('uq_coupon_engage_device_date', type_='unique')
batch_op.create_unique_constraint(
'uq_coupon_engage_device_pkg_date',
['device_id', 'package', 'engage_date'],
)
def downgrade() -> None:
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
batch_op.drop_constraint('uq_coupon_engage_device_pkg_date', type_='unique')
batch_op.create_unique_constraint(
'uq_coupon_engage_device_date',
['device_id', 'engage_date'],
)
batch_op.drop_column('package')
@@ -0,0 +1,26 @@
"""merge comparison idx and coupon daily completion heads
Revision ID: d4b87c5847de
Revises: comparison_status_created_idx, onboarding_completion
Create Date: 2026-06-10 16:45:50.285917
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd4b87c5847de'
down_revision: Union[str, Sequence[str], None] = ('comparison_status_created_idx', 'onboarding_completion')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,40 @@
"""drop user.force_onboarding column
force_onboarding(运营「按用户」强制新手引导)机制废弃:客户端实际按 onboarding_completion
(设备+账号 维度)判断是否走引导,该 force_onboarding 列从未被客户端正确响应(被
onboarding_completed 压过)。改用 admin「设备维度」重置(删 onboarding_completion 记录)替代,
见 app/admin/routers/onboarding.py。本迁移删列。
Revision ID: drop_force_onboarding
Revises: 9b894f5fff05
Create Date: 2026-06-12 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "drop_force_onboarding"
down_revision: Union[str, Sequence[str], None] = "9b894f5fff05"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_column("user", "force_onboarding")
def downgrade() -> None:
# 回滚:重建列(同 user_force_onboarding 迁移的 upgrade)。sa.false() 两端兼容。
op.add_column(
"user",
sa.Column(
"force_onboarding",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
@@ -0,0 +1,49 @@
"""add user.debug_trace_enabled + comparison_record.trace_url
调试链接权限功能:
- user.debug_trace_enabled:运营后台给指定用户开「复制调试链接」权限
- comparison_record.trace_url:比价记录页「复制调试链接」的数据(pricebot done 帧给,
dir 名含落盘时分秒、前端/server 拼不出,必须落库)
注:本迁移最初基于旧 main#31,双 head)写、down_revision 曾指向那对父;但 #33 已用
a8c47fc4dc39 合并了那对双 head,故改为线性接在 a8c47fc4dc39 之后、只负责加两列
(避免与 a8c47fc4dc39 平行再造一个双 head)。
Revision ID: f8d3b1e60a27
Revises: a8c47fc4dc39
Create Date: 2026-06-10 02:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f8d3b1e60a27"
down_revision: Union[str, Sequence[str], None] = "a8c47fc4dc39"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# sa.false() 渲染成 PG 的 false / SQLite 的 0,两端兼容(避免字符串 "false" 在 SQLite 上存歪)
op.add_column(
"user",
sa.Column(
"debug_trace_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
op.add_column(
"comparison_record",
sa.Column("trace_url", sa.String(length=512), nullable=True),
)
def downgrade() -> None:
op.drop_column("comparison_record", "trace_url")
op.drop_column("user", "debug_trace_enabled")
@@ -0,0 +1,39 @@
"""onboarding_completion table (新手引导完成标记,按 user+device 去重)
Revision ID: onboarding_completion
Revises: coupon_daily_completion
Create Date: 2026-06-10 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'onboarding_completion'
down_revision: Union[str, Sequence[str], None] = 'coupon_daily_completion'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# (账号, 设备) 一条完成标记;device_id = 客户端硬件级 ANDROID_ID(跨卸载重装稳定)。
op.create_table(
'onboarding_completion',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('device_id', sa.String(length=64), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'device_id', name='uq_onboarding_user_device'),
)
with op.batch_alter_table('onboarding_completion', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_onboarding_completion_user_id'), ['user_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('onboarding_completion', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_onboarding_completion_user_id'))
op.drop_table('onboarding_completion')
+40
View File
@@ -0,0 +1,40 @@
"""store_mapping 加京东原料列(jd_vender_id + 分享链/反查 URL/deeplink)
Revision ID: store_mapping_jd_cols
Revises: store_mapping_meituan_cols
Create Date: 2026-06-13 00:00:00.000000
京东秒送接入店内搜索 deeplink 链路: 3.cn 短链 → 跟随重定向反查 venderId+storeId →
openapp.jdmobile:// deeplink。京东店铺身份是**两个**稳定数字 id: storeId 进 id_jd(稳定
店主键, 同 taobao 的 shopId→id_taobao), venderId 进单列 jd_vender_id(deeplink 还需它)。
其余三列与 taobao_*/meituan_* 原料列平行。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_jd_cols'
down_revision: Union[str, Sequence[str], None] = 'store_mapping_meituan_cols'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.add_column(sa.Column('jd_vender_id', sa.String(length=64), nullable=True))
batch_op.add_column(sa.Column('jd_share_url', sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column('jd_resolved_url', sa.Text(), nullable=True))
batch_op.add_column(sa.Column('jd_deeplink', sa.Text(), nullable=True))
batch_op.create_index(batch_op.f('ix_store_mapping_jd_vender_id'), ['jd_vender_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_store_mapping_jd_vender_id'))
batch_op.drop_column('jd_deeplink')
batch_op.drop_column('jd_resolved_url')
batch_op.drop_column('jd_share_url')
batch_op.drop_column('jd_vender_id')
@@ -0,0 +1,39 @@
"""store_mapping 加美团原料列(poi_id_str + 分享链/反查 URL/deeplink)
Revision ID: store_mapping_meituan_cols
Revises: store_mapping_table
Create Date: 2026-06-13 00:00:00.000000
美团接入店内搜索 deeplink 链路: dpurl.cn 短链 → 302 反查 poi_id_str → imeituan:// deeplink。
poi_id_str 每次分享重新加密、非稳定主键, 单列 meituan_poi_id_str 存(不占 id_meituan,
后者留给将来 CPS API 的稳定数字 poi_id)。其余三列与 taobao_* 原料列平行。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_meituan_cols'
down_revision: Union[str, Sequence[str], None] = 'store_mapping_table'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.add_column(sa.Column('meituan_poi_id_str', sa.String(length=64), nullable=True))
batch_op.add_column(sa.Column('meituan_share_url', sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column('meituan_resolved_url', sa.Text(), nullable=True))
batch_op.add_column(sa.Column('meituan_deeplink', sa.Text(), nullable=True))
batch_op.create_index(batch_op.f('ix_store_mapping_meituan_poi_id_str'), ['meituan_poi_id_str'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_store_mapping_meituan_poi_id_str'))
batch_op.drop_column('meituan_deeplink')
batch_op.drop_column('meituan_resolved_url')
batch_op.drop_column('meituan_share_url')
batch_op.drop_column('meituan_poi_id_str')
+77
View File
@@ -0,0 +1,77 @@
"""store_mapping table (平台店铺表:跨平台同店 id/名 映射,server 侧无条件落库)
Revision ID: store_mapping_table
Revises: coin_txn_task_ref_uq
Create Date: 2026-06-13 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_table'
down_revision: Union[str, Sequence[str], None] = 'coin_txn_task_ref_uq'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'store_mapping',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
# 跨平台身份
sa.Column('id_taobao', sa.String(length=64), nullable=True),
sa.Column('name_taobao', sa.String(length=128), nullable=True),
sa.Column('id_meituan', sa.String(length=64), nullable=True),
sa.Column('name_meituan', sa.String(length=128), nullable=True),
sa.Column('id_jd', sa.String(length=64), nullable=True),
sa.Column('name_jd', sa.String(length=128), nullable=True),
# 地理
sa.Column('city', sa.String(length=64), nullable=True),
sa.Column('geohash', sa.String(length=16), nullable=True),
sa.Column('lng', sa.Float(), nullable=True),
sa.Column('lat', sa.Float(), nullable=True),
sa.Column('taobao_address', sa.String(length=256), nullable=True),
# 溯源
sa.Column('source_platform', sa.String(length=32), nullable=True),
sa.Column('business_type', sa.String(length=16), nullable=False),
sa.Column('trace_id', sa.String(length=64), nullable=False),
sa.Column('source_device_id', sa.String(length=64), nullable=True),
sa.Column('source_user_id', sa.Integer(), nullable=True),
# 淘宝原料(URL 可能很长 → Text)
sa.Column('taobao_share_url', sa.String(length=256), nullable=True),
sa.Column('taobao_resolved_url', sa.Text(), nullable=True),
sa.Column('taobao_deeplink', sa.Text(), nullable=True),
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
sa.Column('attrs', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
# 一次比价一行,trace_id 幂等去重(防 pricebot 重试 / replay 重复写)
sa.UniqueConstraint('trace_id', name='uq_store_mapping_trace'),
)
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_store_mapping_id_taobao'), ['id_taobao'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_id_meituan'), ['id_meituan'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_id_jd'), ['id_jd'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_geohash'), ['geohash'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_source_platform'), ['source_platform'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_source_device_id'), ['source_device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_source_user_id'), ['source_user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_store_mapping_created_at'))
batch_op.drop_index(batch_op.f('ix_store_mapping_source_user_id'))
batch_op.drop_index(batch_op.f('ix_store_mapping_source_device_id'))
batch_op.drop_index(batch_op.f('ix_store_mapping_source_platform'))
batch_op.drop_index(batch_op.f('ix_store_mapping_geohash'))
batch_op.drop_index(batch_op.f('ix_store_mapping_id_jd'))
batch_op.drop_index(batch_op.f('ix_store_mapping_id_meituan'))
batch_op.drop_index(batch_op.f('ix_store_mapping_id_taobao'))
op.drop_table('store_mapping')
@@ -0,0 +1,33 @@
"""store_mapping 加淘宝 deeplink 失效标记列 taobao_deeplink_invalid_at
Revision ID: store_mapping_tb_dl_invalid
Revises: coupon_engage_per_package
Create Date: 2026-06-15 00:00:00.000000
缓存的淘宝店内搜索 deeplink 会失效(打开是"页面出错了"降级页)。pricebot 比价撞到错误页时
回退正常搜店, 并 server→server 通知把该 shopId 的 deeplink 标记失效。本列记失效时刻
(NULL=有效); lookup 反查时过滤掉已失效的淘宝候选, 不再返回坏 deeplink。
只加淘宝一列(当前只接淘宝); 用时间戳而非布尔: 留痕可审计、可统计失效率, 且不销毁原 deeplink。
无需索引: 过滤总叠在 name_taobao== 之后, 候选集已小。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_tb_dl_invalid'
down_revision: Union[str, Sequence[str], None] = 'coupon_engage_per_package'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.add_column(sa.Column('taobao_deeplink_invalid_at', sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_column('taobao_deeplink_invalid_at')
+39
View File
@@ -0,0 +1,39 @@
"""add user.force_onboarding
运营后台「一键开启新手引导」:置 true → 用户下次启动 App 被强制重走引导教程(即便本地已完成),
走完引导(/api/v1/user/onboarding/complete)后端自动清回 false,不无限循环。
见 app/models/user.py force_onboarding 列、admin POST /admin/api/users/{id}/force-onboarding。
Revision ID: user_force_onboarding
Revises: onboarding_completion
Create Date: 2026-06-10 21:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "user_force_onboarding"
down_revision: Union[str, Sequence[str], None] = "onboarding_completion"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# sa.false() 渲染成 PG 的 false / SQLite 的 0,两端兼容(同 debug_trace_enabled)。
op.add_column(
"user",
sa.Column(
"force_onboarding",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
def downgrade() -> None:
op.drop_column("user", "force_onboarding")
+6
View File
@@ -13,6 +13,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.admin.routers.ad_audit import router as ad_audit_router
from app.admin.routers.admins import router as admins_router
from app.admin.routers.audit import router as audit_router
from app.admin.routers.auth import router as auth_router
@@ -20,7 +21,9 @@ from app.admin.routers.config import router as config_router
from app.admin.routers.dashboard import router as dashboard_router
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
from app.admin.routers.feedback import router as feedback_router
from app.admin.routers.onboarding import router as onboarding_router
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
from app.admin.routers.price_report import router as price_report_router
from app.admin.routers.users import router as users_router
from app.admin.routers.wallet import router as wallet_router
from app.admin.routers.withdraw import router as withdraw_router
@@ -78,9 +81,12 @@ admin_app.include_router(dashboard_router)
admin_app.include_router(ops_stat_config_router)
admin_app.include_router(ops_marquee_seed_router)
admin_app.include_router(users_router)
admin_app.include_router(onboarding_router)
admin_app.include_router(wallet_router)
admin_app.include_router(withdraw_router)
admin_app.include_router(price_report_router)
admin_app.include_router(feedback_router)
admin_app.include_router(admins_router)
admin_app.include_router(audit_router)
admin_app.include_router(config_router)
admin_app.include_router(ad_audit_router)
+228
View File
@@ -0,0 +1,228 @@
"""看广告金币审计:复算 expected_coin 并与实发对比。
只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移):
- 看视频:每条 granted = 1 份,第 N 份 = 该用户 granted 的 reward_video **账号累计**顺序号
(与 ad_reward.grant_ad_reward 里 `_granted_cumulative + 1` 一致;LT 因子不按天重置,
故复算时要把当日序号叠加上该用户在本日**之前**的累计已发份数)。
- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 该用户 granted 份数**账号累计**
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致;同样不按天重置,
复算需叠加本日之前的累计份数)。
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
"""
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS
def _prior_granted_counts(
db: Session, *, date: str, user_id: int | None
) -> dict[int, int]:
"""各用户在 date **之前**已发奖的 reward_video 累计份数,作为当日复算的 LT 序号起点。
LT 因子改账号累计后,当日第 1 份并非全局第 1 份,需叠加历史累计。"""
stmt = (
select(AdRewardRecord.user_id, func.count())
.where(
AdRewardRecord.reward_date < date,
AdRewardRecord.reward_scene == "reward_video",
AdRewardRecord.status == "granted",
)
.group_by(AdRewardRecord.user_id)
)
if user_id is not None:
stmt = stmt.where(AdRewardRecord.user_id == user_id)
return {uid: n for uid, n in db.execute(stmt).all()}
def _reward_video_rows(
db: Session, *, date: str, user_id: int | None
) -> list[dict]:
"""看视频记录复算。按 (user_id, created_at) 升序还原账号累计第 N 份(含本日之前的累计)。"""
stmt = (
select(AdRewardRecord)
.where(
AdRewardRecord.reward_date == date,
AdRewardRecord.reward_scene == "reward_video",
)
.order_by(AdRewardRecord.user_id, AdRewardRecord.created_at, AdRewardRecord.id)
)
if user_id is not None:
stmt = stmt.where(AdRewardRecord.user_id == user_id)
# 用本日之前的累计份数做起点,当日 granted 在其上继续递增 → 与 _granted_cumulative+1 对齐
granted_n: dict[int, int] = _prior_granted_counts(db, date=date, user_id=user_id)
rows: list[dict] = []
for rec in db.execute(stmt).scalars():
if rec.status == "granted":
nth = granted_n.get(rec.user_id, 0) + 1
granted_n[rec.user_id] = nth
expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth)
rows.append({
"scene": "reward_video",
"record_id": rec.id,
"user_id": rec.user_id,
"created_at": rec.created_at,
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
"units": 1,
"lt_index_start": nth,
"lt_index_end": nth,
"lt_factor_start": rewards.ad_lt_factor(nth),
"lt_factor_end": rewards.ad_lt_factor(nth),
"expected_coin": expected,
"actual_coin": rec.coin,
"matched": expected == rec.coin,
})
else:
# capped / ecpm_missing:不发金币,校验实发确为 0
rows.append({
"scene": "reward_video",
"record_id": rec.id,
"user_id": rec.user_id,
"created_at": rec.created_at,
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": None,
"units": 1,
"lt_index_start": None,
"lt_index_end": None,
"lt_factor_start": None,
"lt_factor_end": None,
"expected_coin": 0,
"actual_coin": rec.coin,
"matched": rec.coin == 0,
})
return rows
def _feed_prior_granted_units(
db: Session, *, date: str, user_id: int | None
) -> dict[int, int]:
"""各用户在 date **之前** granted 的信息流份数累计,作为当日复算的 LT 序号起点。"""
stmt = (
select(
AdFeedRewardRecord.user_id,
func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0),
)
.where(
AdFeedRewardRecord.reward_date < date,
AdFeedRewardRecord.status == "granted",
)
.group_by(AdFeedRewardRecord.user_id)
)
if user_id is not None:
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
return {uid: int(n) for uid, n in db.execute(stmt).all()}
def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)。"""
stmt = (
select(AdFeedRewardRecord)
.where(AdFeedRewardRecord.reward_date == date)
.order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at, AdFeedRewardRecord.id)
)
if user_id is not None:
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
# 本日之前的累计份数做起点,与 _unit_reward_total 的 existing_units(累计)对齐
granted_units: dict[int, int] = _feed_prior_granted_units(db, date=date, user_id=user_id)
rows: list[dict] = []
for rec in db.execute(stmt).scalars():
if rec.status == "granted":
existing = granted_units.get(rec.user_id, 0)
units = rec.unit_count
expected = sum(
rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset)
for offset in range(1, units + 1)
)
granted_units[rec.user_id] = existing + units
start = existing + 1 if units > 0 else None
end = existing + units if units > 0 else None
rows.append({
"scene": "feed",
"record_id": rec.id,
"user_id": rec.user_id,
"created_at": rec.created_at,
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
"units": units,
"lt_index_start": start,
"lt_index_end": end,
"lt_factor_start": rewards.ad_lt_factor(start) if start else None,
"lt_factor_end": rewards.ad_lt_factor(end) if end else None,
"expected_coin": expected,
"actual_coin": rec.coin,
"matched": expected == rec.coin,
})
else:
rows.append({
"scene": "feed",
"record_id": rec.id,
"user_id": rec.user_id,
"created_at": rec.created_at,
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": None,
"units": rec.unit_count,
"lt_index_start": None,
"lt_index_end": None,
"lt_factor_start": None,
"lt_factor_end": None,
"expected_coin": 0,
"actual_coin": rec.coin,
"matched": rec.coin == 0,
})
return rows
def ad_coin_audit(
db: Session,
*,
date: str,
user_id: int | None,
scene: str | None,
limit: int,
only_mismatch: bool = False,
) -> dict:
"""当日发奖复算。返回 {total, mismatch_count, truncated, items}。
scene: None=两类都要 / "reward_video" / "feed";only_mismatch=True 只展示不一致(✗)行。
关键:`total` 与 `mismatch_count` 在**全量**(截断前)上统计,故对账数字始终可信,不受 limit
影响;`items` 才是展示集(only_mismatch 时只取 ✗ 行)按 created_at 倒序截断到 limit。
份序号在全天数据上已算好,limit 只影响展示条数、不影响 expected 复算正确性。
"""
rows: list[dict] = []
if scene in (None, "reward_video"):
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
if scene in (None, "feed"):
rows.extend(_feed_rows(db, date=date, user_id=user_id))
rows.sort(key=lambda r: (r["created_at"], r["record_id"]), reverse=True)
total = len(rows)
mismatch_count = sum(1 for r in rows if not r["matched"])
display = [r for r in rows if not r["matched"]] if only_mismatch else rows
return {
"total": total,
"mismatch_count": mismatch_count,
"truncated": len(display) > limit,
"items": display[:limit],
}
def formula_snapshot() -> dict:
"""当前公式参数快照(给前端展示规则参照)。直接读 rewards 常量,与发奖同源。"""
return {
"coin_per_yuan": rewards.COIN_PER_YUAN,
"feed_unit_seconds": FEED_REWARD_UNIT_SECONDS,
"ecpm_factor_tiers": [list(t) for t in rewards.AD_ECPM_FACTOR_TABLE],
"lt_factor_tiers": [list(t) for t in rewards.AD_LT_FACTOR_TABLE],
}
+14 -11
View File
@@ -4,7 +4,7 @@
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.admin import AdminAuditLog
@@ -49,8 +49,9 @@ def list_audit_logs(
admin_id: int | None = None,
limit: int = 50,
cursor: int | None = None,
) -> tuple[list[AdminAuditLog], int | None]:
"""游标分页(id 倒序),与现有 list_* 约定一致。返回 (rows, next_cursor)。"""
) -> tuple[list[AdminAuditLog], int | None, int]:
"""offset 分页(id 倒序)+ total。cursor 即 offset((page-1)*pageSize),支持页码跳页。
返回 (rows, next_cursor, total)。"""
stmt = select(AdminAuditLog)
if action:
stmt = stmt.where(AdminAuditLog.action == action)
@@ -58,13 +59,15 @@ def list_audit_logs(
stmt = stmt.where(AdminAuditLog.target_type == target_type)
if admin_id is not None:
stmt = stmt.where(AdminAuditLog.admin_id == admin_id)
if cursor is not None:
stmt = stmt.where(AdminAuditLog.id < cursor)
stmt = stmt.order_by(AdminAuditLog.id.desc())
rows = list(db.execute(stmt.limit(limit + 1)).scalars().all())
total = int(db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one())
offset = max(cursor or 0, 0)
rows = list(
db.execute(
stmt.order_by(AdminAuditLog.id.desc()).offset(offset).limit(limit + 1)
).scalars().all()
)
has_more = len(rows) > limit
items = rows[:limit]
# next_cursor 必须是"本页返回的最后一条"的 id(下一页查 id < 它),不能用 rows[limit]——
# rows[limit] 是探测下一页用的第 limit+1 条,它既不在本页也不在下页 → 每页边界丢一条。
next_cursor = items[-1].id if has_more else None
return items, next_cursor
next_cursor = offset + limit if has_more else None
return items, next_cursor, total
+71
View File
@@ -8,9 +8,15 @@ set_user_status / update_feedback_status 支持 commit=False,让 router 把"业
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import delete
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.feedback import Feedback
from app.models.onboarding import OnboardingCompletion
from app.models.price_report import PriceReport
from app.models.user import User
@@ -24,6 +30,20 @@ def set_user_status(db: Session, user: User, *, status: str, commit: bool = True
return user
def set_user_debug_trace(
db: Session, user: User, *, enabled: bool, commit: bool = True
) -> User:
"""开关用户「复制调试链接」权限。同 set_user_status:支持 commit=False 让 router 把
业务写 + 审计写放进同一事务。"""
user.debug_trace_enabled = enabled
if commit:
db.commit()
db.refresh(user)
else:
db.flush()
return user
def update_feedback_status(
db: Session, feedback: Feedback, *, status: str, commit: bool = True
) -> Feedback:
@@ -34,3 +54,54 @@ def update_feedback_status(
else:
db.flush()
return feedback
def review_price_report(
db: Session,
report: PriceReport,
*,
status: str,
reward_coins: int | None = None,
reject_reason: str | None = None,
commit: bool = True,
) -> PriceReport:
"""审核上报:置 approved/rejected + 记审核时间;通过填 reward_coins、拒绝填 reject_reason。
发金币(wallet.grant_coins)不在这里——由 router 在同一事务里调,涉钱逻辑不重写(见模块头注释)。
"""
report.status = status
if reward_coins is not None:
report.reward_coins = reward_coins
if reject_reason is not None:
report.reject_reason = reject_reason
report.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None) # 北京 wall-clock,同 created_at
if commit:
db.commit()
db.refresh(report)
else:
db.flush()
return report
def delete_device_onboarding(db: Session, device_id: str, *, commit: bool = True) -> int:
"""删某设备(device_id)的全部 onboarding 完成记录 → 该设备上所有账号下次登录重走引导。
返回删除行数。支持 commit=False 让 router 把业务写 + 审计写放进同一事务。"""
n = db.execute(
delete(OnboardingCompletion).where(OnboardingCompletion.device_id == device_id)
).rowcount
if commit:
db.commit()
else:
db.flush()
return n
def delete_all_onboarding(db: Session, *, commit: bool = True) -> int:
"""清空 onboarding_completion 表 → 所有用户在所有设备下次登录都重走引导。返回删除行数。
支持 commit=False(同 delete_device_onboarding)。"""
n = db.execute(delete(OnboardingCompletion)).rowcount
if commit:
db.commit()
else:
db.flush()
return n
+159 -30
View File
@@ -14,6 +14,8 @@ from sqlalchemy.orm import Session
from app.models.admin import AdminAuditLog
from app.models.comparison import ComparisonRecord
from app.models.feedback import Feedback
from app.models.onboarding import OnboardingCompletion
from app.models.price_report import PriceReport
from app.models.user import User
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
@@ -36,15 +38,48 @@ def cursor_paginate(
return items, next_cursor
def offset_paginate(
db: Session, stmt: Select, sort_clause: tuple, *, limit: int, cursor: int | None
) -> tuple[list, int | None, int]:
"""offset 分页 + 总数。stmt 只含 where/join,不要预先带 order_by/offset/limit。
cursor 即 offset(页码分页:offset=(page-1)*pageSize)。返回 (items, next_cursor, total):
- total:符合筛选条件的总条数(供 antd pagination 渲染页码/共 N 条),count 在 P0 量级开销可忽略;
- next_cursor:下一页 offset(兼容「加载更多」),末页为 None。
多取 1 条探测下一页。sort_clause 为 order_by 表达式元组(末位应含 id 保证稳定排序)。"""
total = int(
db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one()
)
offset = max(cursor or 0, 0)
rows = list(
db.execute(stmt.order_by(*sort_clause).offset(offset).limit(limit + 1)).scalars().all()
)
has_more = len(rows) > limit
items = rows[:limit]
next_cursor = offset + limit if has_more else None
return items, next_cursor, total
def list_users(
db: Session,
*,
phone: str | None = None,
register_channel: str | None = None,
status: str | None = None,
nickname: str | None = None,
created_from: datetime | None = None,
created_to: datetime | None = None,
last_login_from: datetime | None = None,
last_login_to: datetime | None = None,
sort_by: str = "id",
sort_order: str = "desc",
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[User], int | None]:
) -> tuple[list[User], int | None, int]:
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选,
按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
stmt = select(User)
if phone:
stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配
@@ -52,7 +87,46 @@ def list_users(
stmt = stmt.where(User.register_channel == register_channel)
if status:
stmt = stmt.where(User.status == status)
return cursor_paginate(db, stmt, User.id, limit=limit, cursor=cursor)
if nickname and nickname.strip():
stmt = stmt.where(User.nickname.ilike(f"%{nickname.strip()}%"))
if created_from is not None:
stmt = stmt.where(User.created_at >= _as_utc(created_from))
if created_to is not None:
stmt = stmt.where(User.created_at <= _as_utc(created_to))
if last_login_from is not None:
stmt = stmt.where(User.last_login_at >= _as_utc(last_login_from))
if last_login_to is not None:
stmt = stmt.where(User.last_login_at <= _as_utc(last_login_to))
sort_cols = {
"id": User.id,
"created_at": User.created_at,
"last_login_at": User.last_login_at,
}
sort_col = sort_cols.get(sort_by, User.id)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(User.id) if sort_order == "asc" else desc(User.id)
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]:
"""按设备(device_id, ANDROID_ID)聚合 onboarding_completion:每台设备走过引导的账号数 +
最近完成时间,按最近完成倒序。设备维度新手引导管理用。没走过引导的设备不在表里(本就会
引导、无需管理)。当前全量返回(上限 limit;调试期设备少,量大再加分页/搜索)。"""
rows = db.execute(
select(
OnboardingCompletion.device_id,
func.count(OnboardingCompletion.id),
func.max(OnboardingCompletion.completed_at),
)
.group_by(OnboardingCompletion.device_id)
.order_by(func.max(OnboardingCompletion.completed_at).desc())
.limit(limit)
).all()
return [
{"device_id": did, "account_count": int(cnt), "last_completed_at": last}
for did, cnt, last in rows
]
def list_all_coin_transactions(
@@ -101,7 +175,7 @@ def list_all_withdraw_orders(
quick_filter: str | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[WithdrawOrder], int | None]:
) -> tuple[list[WithdrawOrder], int | None, int]:
stmt = select(WithdrawOrder)
needs_user_join = bool(keyword and keyword.strip()) or quick_filter == "high_risk"
if needs_user_join:
@@ -131,16 +205,16 @@ def list_all_withdraw_orders(
date_col = WithdrawOrder.updated_at if date_field == "updated_at" else WithdrawOrder.created_at
if date_from is not None:
stmt = stmt.where(date_col >= _as_utc_naive(date_from))
stmt = stmt.where(date_col >= _as_utc(date_from))
if date_to is not None:
stmt = stmt.where(date_col <= _as_utc_naive(date_to))
stmt = stmt.where(date_col <= _as_utc(date_to))
now = datetime.now(timezone.utc).replace(tzinfo=None)
# tz-aware:列为 timestamptz,比较绝对时刻、与 DB 会话时区无关(同 _as_utc / stats.py)
now = datetime.now(timezone.utc)
today_start = (
datetime.now(ZoneInfo("Asia/Shanghai"))
.replace(hour=0, minute=0, second=0, microsecond=0)
.astimezone(timezone.utc)
.replace(tzinfo=None)
)
if quick_filter == "abnormal":
stmt = stmt.where(
@@ -185,6 +259,53 @@ def list_all_withdraw_orders(
sort_col = sort_cols.get(sort_by, WithdrawOrder.created_at)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(WithdrawOrder.id) if sort_order == "asc" else desc(WithdrawOrder.id)
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
def _as_utc(value: datetime) -> datetime:
"""前端传 ISO 时间 → 统一成 tz-aware UTC 再比较。
所有时间列均为 `DateTime(timezone=True)`(Postgres timestamptz);用 tz-aware 绑定参数
比较的是绝对时刻,与 DB 会话时区无关、恒正确。曾用 naive UTC,正确性依赖会话 TimeZone=UTC,
生产会话非 UTC 时筛选边界会整体偏移——故统一 tz-aware(与 stats.py / withdraw_summary 一致)。
无时区入参按 UTC 解释。"""
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def list_feedbacks(
db: Session,
*,
status: str | None = None,
user_id: int | None = None,
content: str | None = None,
created_from: datetime | None = None,
created_to: datetime | None = None,
sort_by: str = "id",
sort_order: str = "desc",
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[Feedback], int | None]:
"""反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。
**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间
数据变动可能错位一条——admin 低频场景可接受。created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
stmt = select(Feedback)
if status:
stmt = stmt.where(Feedback.status == status)
if user_id is not None:
stmt = stmt.where(Feedback.user_id == user_id)
if content and content.strip():
stmt = stmt.where(Feedback.content.ilike(f"%{content.strip()}%"))
if created_from is not None:
stmt = stmt.where(Feedback.created_at >= _as_utc(created_from))
if created_to is not None:
stmt = stmt.where(Feedback.created_at <= _as_utc(created_to))
sort_cols = {"id": Feedback.id, "created_at": Feedback.created_at}
sort_col = sort_cols.get(sort_by, Feedback.id)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id)
stmt = stmt.order_by(order_fn(sort_col), id_order)
offset = max(cursor or 0, 0)
@@ -195,29 +316,6 @@ def list_all_withdraw_orders(
return items, next_cursor
def _as_utc_naive(value: datetime) -> datetime:
"""前端传 ISO 时间;DB 当前按 UTC naive 比较最稳(SQLite/本地开发一致)。"""
if value.tzinfo is None:
return value
return value.astimezone(timezone.utc).replace(tzinfo=None)
def list_feedbacks(
db: Session,
*,
status: str | None = None,
user_id: int | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[Feedback], int | None]:
stmt = select(Feedback)
if status:
stmt = stmt.where(Feedback.status == status)
if user_id is not None:
stmt = stmt.where(Feedback.user_id == user_id)
return cursor_paginate(db, stmt, Feedback.id, limit=limit, cursor=cursor)
def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None:
"""按商户单号查提现单(admin 重试打款先拿 user_id 用,M3)。"""
return db.execute(
@@ -395,3 +493,34 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
).scalar_one(),
"feedback_total": _count(Feedback, Feedback.user_id == user_id),
}
def list_price_reports(
db: Session,
*,
status: str | None = None,
user_id: int | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[PriceReport], int | None, int]:
"""上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,id 倒序。"""
stmt = select(PriceReport)
if status:
stmt = stmt.where(PriceReport.status == status)
if user_id is not None:
stmt = stmt.where(PriceReport.user_id == user_id)
return offset_paginate(db, stmt, (PriceReport.id.desc(),), limit=limit, cursor=cursor)
def price_report_summary(db: Session) -> dict:
"""上报审核台各状态计数(待审核/已通过/已拒绝/合计)。"""
rows = db.execute(
select(PriceReport.status, func.count(PriceReport.id)).group_by(PriceReport.status)
).all()
by_status = {status: int(count) for status, count in rows}
return {
"pending": by_status.get("pending", 0),
"approved": by_status.get("approved", 0),
"rejected": by_status.get("rejected", 0),
"total": sum(by_status.values()),
}
+48
View File
@@ -0,0 +1,48 @@
"""admin 看广告金币审计:只读对账,核对发奖金币是否按公式计算。
任意已登录 admin 可看(只读,不涉及资金操作)。复算逻辑在 app/admin/repositories/ad_audit.py。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import ad_audit
from app.admin.schemas.ad_audit import AdCoinAuditOut, AdCoinAuditRow, AdCoinFormulaOut
from app.core.rewards import cn_today
router = APIRouter(
prefix="/admin/api/ad-coin-audit",
tags=["admin-ad-coin-audit"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=AdCoinAuditOut, summary="看广告金币公式审计(复算对比)")
def get_ad_coin_audit(
db: AdminDb,
date: Annotated[str | None, Query(description="北京时间 YYYY-MM-DD,默认今天")] = None,
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
scene: Annotated[
str | None, Query(description="reward_video / feed;不传=两类都要")
] = None,
limit: Annotated[int, Query(ge=1, le=500)] = 100,
only_mismatch: Annotated[
bool, Query(description="只看不一致(✗)行;统计数仍按全量,不受影响")
] = False,
) -> AdCoinAuditOut:
audit_date = date or cn_today().isoformat()
result = ad_audit.ad_coin_audit(
db, date=audit_date, user_id=user_id, scene=scene, limit=limit,
only_mismatch=only_mismatch,
)
return AdCoinAuditOut(
date=audit_date,
formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()),
total=result["total"],
mismatch_count=result["mismatch_count"],
truncated=result["truncated"],
items=[AdCoinAuditRow(**r) for r in result["items"]],
)
+24 -5
View File
@@ -17,6 +17,12 @@ router = APIRouter(
)
def _active_super_count(db: AdminDb) -> int:
return sum(
1 for a in admin_repo.list_admins(db) if a.role == "super_admin" and a.status == "active"
)
@router.get("", response_model=list[AdminOut], summary="管理员列表")
def list_admins(db: AdminDb) -> list[AdminOut]:
return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)]
@@ -48,16 +54,29 @@ def update_admin(
if admin_id == admin.id and body.status == "disabled":
raise HTTPException(status_code=400, detail="不能禁用自己")
# 防自锁:降级 / 禁用某个 super_admin 前,确认操作后仍至少剩 1 个 active super_admin,
# 否则会进入「零可用超管」死局——本路由仅 super 可进,只能改库恢复。
demotes_super = (
target.role == "super_admin"
and target.status == "active"
and (
(body.role is not None and body.role != "super_admin")
or body.status == "disabled"
)
)
if demotes_super and _active_super_count(db) <= 1:
raise HTTPException(status_code=400, detail="不能降级/禁用最后一个超级管理员")
changes: dict = {}
if body.role is not None:
if body.role is not None and body.role != target.role:
changes["role"] = {"before": target.role, "after": body.role}
target.role = body.role
changes["role"] = body.role
if body.status is not None:
if body.status is not None and body.status != target.status:
changes["status"] = {"before": target.status, "after": body.status}
target.status = body.status
changes["status"] = body.status
if body.password is not None:
target.password_hash = hash_password(body.password)
changes["password"] = "reset"
target.password_hash = hash_password(body.password)
if not changes:
raise HTTPException(status_code=400, detail="无任何变更字段")
db.commit()
+4 -2
View File
@@ -26,9 +26,11 @@ def list_audit_logs(
limit: Annotated[int, Query(ge=1, le=100)] = 50,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AdminAuditLogOut]:
items, next_cursor = audit_repo.list_audit_logs(
items, next_cursor, total = audit_repo.list_audit_logs(
db, action=action, target_type=target_type, admin_id=admin_id, limit=limit, cursor=cursor,
)
return CursorPage(
items=[AdminAuditLogOut.model_validate(x) for x in items], next_cursor=next_cursor,
items=[AdminAuditLogOut.model_validate(x) for x in items],
next_cursor=next_cursor,
total=total,
)
+3 -2
View File
@@ -13,6 +13,7 @@ from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.config import ConfigItemOut, ConfigUpdateRequest
from app.core.config_schema import CONFIG_DEFS
from app.core.rewards import SIGNIN_CYCLE_LEN
from app.models.admin import AdminUser
from app.repositories import app_config
@@ -34,8 +35,8 @@ def _validate(key: str, value: Any) -> None:
raise ValueError("需为非空整数列表")
if not all(isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in value):
raise ValueError("列表元素需为非负整数")
if key == "signin_rewards" and len(value) != 14:
raise ValueError("签到档位必须正好 14 个(对应 14 天循环)")
if key == "signin_rewards" and len(value) != SIGNIN_CYCLE_LEN:
raise ValueError(f"签到档位必须正好 {SIGNIN_CYCLE_LEN} 个(对应 {SIGNIN_CYCLE_LEN} 天循环)")
elif t == "dict_str_int":
if not isinstance(value, dict) or not all(
isinstance(k, str) and isinstance(v, int) and not isinstance(v, bool)
+17 -2
View File
@@ -1,6 +1,7 @@
"""admin 反馈工单:列表(读)+ 标记已处理(写,带审计)。"""
"""admin 反馈工单:列表(读,支持 状态/用户ID/内容/时间 筛选 + 排序)+ 标记已处理(写,带审计)。"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
@@ -25,11 +26,25 @@ def list_feedbacks(
db: AdminDb,
status: Annotated[str | None, Query()] = None,
user_id: Annotated[int | None, Query()] = None,
content: Annotated[str | None, Query(max_length=100)] = None,
created_from: Annotated[datetime | None, Query()] = None,
created_to: Annotated[datetime | None, Query()] = None,
sort_by: Annotated[str, Query(pattern="^(id|created_at)$")] = "id",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[FeedbackOut]:
items, next_cursor = queries.list_feedbacks(
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
db,
status=status,
user_id=user_id,
content=content,
created_from=created_from,
created_to=created_to,
sort_by=sort_by,
sort_order=sort_order,
limit=limit,
cursor=cursor,
)
return CursorPage(
items=[FeedbackOut.model_validate(f) for f in items], next_cursor=next_cursor,
+86
View File
@@ -0,0 +1,86 @@
"""admin 设备维度新手引导管理:列设备 / 重置单设备 / 全部重置。
背景:原 force_onboarding(运营「按用户」强制引导)客户端不认(被 onboarding_completed 压过),
已废弃。改为直接操作 onboarding_completion(设备+账号 维度):删记录 → 该(设备,账号)的
onboarding_completed 变 false → 客户端任何版本下次登录都重走引导。可靠、不依赖客户端实现。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.repositories import mutations, queries
from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.onboarding import DeviceOnboardingItem, ResetAllRequest
from app.models.admin import AdminUser
router = APIRouter(
prefix="/admin/api/onboarding",
tags=["admin-onboarding"],
dependencies=[Depends(get_current_admin)],
)
@router.get(
"/devices",
response_model=CursorPage[DeviceOnboardingItem],
summary="设备列表(按设备聚合走过引导的账号)",
)
def list_devices(db: AdminDb) -> CursorPage[DeviceOnboardingItem]:
"""列出走过新手引导的设备(ANDROID_ID),按最近完成时间倒序。没走过引导的设备不在表里、
本就会引导,无需管理,故不列。当前全量返回(上限 500;设备量大再加分页/搜索),
next_cursor 恒 None。"""
rows = queries.list_onboarding_devices(db)
return CursorPage(
items=[DeviceOnboardingItem(**r) for r in rows],
next_cursor=None,
)
@router.post(
"/devices/{device_id}/reset",
response_model=OkResponse,
summary="重置单个设备(该设备所有账号下次重走引导)",
)
def reset_device(
device_id: str,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
"""删该设备的全部 onboarding 完成记录 → 该设备上所有账号下次登录重走引导。"""
# 业务写 + 审计写同一事务(commit=False),最后一起 commit(同 users 各写接口)
deleted = mutations.delete_device_onboarding(db, device_id, commit=False)
write_audit(
db, admin, action="device_onboarding.reset", target_type="device", target_id=device_id,
detail={"deleted_rows": deleted}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
@router.post(
"/reset-all",
response_model=OkResponse,
summary="全部重设(清空所有设备引导记录)",
)
def reset_all(
body: ResetAllRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
"""清空 onboarding_completion 表 → 所有用户在所有设备下次登录都重走引导。
需 confirm=true 防误触(前端另有二次确认弹窗)。"""
if not body.confirm:
raise HTTPException(status_code=400, detail="需 confirm=true 确认")
deleted = mutations.delete_all_onboarding(db, commit=False)
write_audit(
db, admin, action="device_onboarding.reset_all", target_type="onboarding", target_id=None,
detail={"deleted_rows": deleted}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
+36
View File
@@ -12,6 +12,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.ops_marquee_seed import (
OpsMarqueeSeedBatchDelete,
OpsMarqueeSeedBatchEnable,
OpsMarqueeSeedBulkCreate,
OpsMarqueeSeedCreate,
OpsMarqueeSeedOut,
@@ -111,6 +113,40 @@ def bulk_generate(
return [_out(s) for s in seeds]
@router.post("/batch-delete", summary="批量删除种子(带审计)")
def batch_delete_seeds(
body: OpsMarqueeSeedBatchDelete,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> dict:
n = ops_marquee.batch_delete(db, body.ids, commit=False)
write_audit(
db, admin, action="ops_marquee_seed.batch_delete", target_type="ops_marquee_seed",
target_id=None, detail={"ids": body.ids, "deleted": n},
ip=get_client_ip(request), commit=False,
)
db.commit()
return {"deleted": n}
@router.post("/batch-enable", summary="批量启用 / 停用种子(带审计)")
def batch_enable_seeds(
body: OpsMarqueeSeedBatchEnable,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> dict:
n = ops_marquee.batch_set_enabled(db, body.ids, body.enabled, commit=False)
write_audit(
db, admin, action="ops_marquee_seed.batch_enable", target_type="ops_marquee_seed",
target_id=None, detail={"ids": body.ids, "enabled": body.enabled, "updated": n},
ip=get_client_ip(request), commit=False,
)
db.commit()
return {"updated": n}
@router.patch("/{seed_id}", response_model=OpsMarqueeSeedOut, summary="改轮播种子(带审计)")
def update_seed(
seed_id: int,
+107
View File
@@ -0,0 +1,107 @@
"""admin 上报更低价审核:列表 + 统计(读)+ 通过(发金币)/拒绝(写,带审计)。
数据由客户端 POST /api/v1/report 写入 price_report 表(提交即 pending);本路由是运营后台
对它的人工审核窗口。**通过** → 给上报用户钱包发固定金币(PRICE_REPORT_REWARD_COINS):
改状态 + 发金币(wallet.grant_coins)+ 审计同一事务一起 commit(原子,仿 users.grant_user_coins),
绝不只改状态不发钱或反之。客户端轮询 GET /api/v1/report/records 自动看到结果(无需推送)。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.repositories import mutations, queries
from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.price_report import (
PriceReportOut,
PriceReportRejectRequest,
PriceReportSummary,
)
from app.core.rewards import PRICE_REPORT_REWARD_COINS
from app.models.admin import AdminUser
from app.models.price_report import PriceReport
from app.repositories import wallet as wallet_repo
router = APIRouter(
prefix="/admin/api/price-reports",
tags=["admin-price-report"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=CursorPage[PriceReportOut], summary="上报更低价列表(筛选+分页)")
def list_price_reports(
db: AdminDb,
status: Annotated[str | None, Query()] = None,
user_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[PriceReportOut]:
items, next_cursor, total = queries.list_price_reports(
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
)
return CursorPage(
items=[PriceReportOut.model_validate(r) for r in items],
next_cursor=next_cursor,
total=total,
)
@router.get("/summary", response_model=PriceReportSummary, summary="上报审核统计(各状态计数)")
def price_report_summary(db: AdminDb) -> PriceReportSummary:
return PriceReportSummary.model_validate(queries.price_report_summary(db))
@router.post("/{report_id}/approve", response_model=OkResponse, summary="通过上报(发固定金币)")
def approve_price_report(
report_id: int,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
# 行锁(SELECT FOR UPDATE):并发/连点双请求会都读到 pending → 各发一次金币双倍发奖,
# 锁住该行串行化,第二个请求拿锁后看到 approved → 走 400。SQLite 下 FOR UPDATE 为 no-op。
rep = db.get(PriceReport, report_id, with_for_update=True)
if rep is None:
raise HTTPException(status_code=404, detail="上报记录不存在")
if rep.status != "pending":
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
coins = PRICE_REPORT_REWARD_COINS
# 改状态 + 发金币 + 审计同一事务(commit=False),最后一起 commit:改了就有痕、发了就留账
mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False)
wallet_repo.grant_coins(
db, rep.user_id, coins,
biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过",
)
write_audit(
db, admin, action="price_report.approve", target_type="price_report", target_id=report_id,
detail={"reward_coins": coins, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
@router.post("/{report_id}/reject", response_model=OkResponse, summary="拒绝上报")
def reject_price_report(
report_id: int,
body: PriceReportRejectRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
rep = db.get(PriceReport, report_id, with_for_update=True) # 行锁,同 approve(防并发重复审核)
if rep is None:
raise HTTPException(status_code=404, detail="上报记录不存在")
if rep.status != "pending":
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
reason = body.reason.strip()
mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False)
write_audit(
db, admin, action="price_report.reject", target_type="price_report", target_id=report_id,
detail={"reason": reason, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
+122 -17
View File
@@ -1,6 +1,7 @@
"""admin 用户管理:列表 + 360 详情(读)+ 封禁/解封 + 手动调金币(写,带审计)。"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
@@ -12,7 +13,9 @@ from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.user import (
AdminUserListItem,
AdminUserOverview,
GrantCashRequest,
GrantCoinsRequest,
SetDebugTraceRequest,
SetUserStatusRequest,
)
from app.models.admin import AdminUser
@@ -26,22 +29,32 @@ router = APIRouter(
)
@router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+分页)")
@router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+排序+分页)")
def list_users(
db: AdminDb,
phone: Annotated[str | None, Query()] = None,
register_channel: Annotated[str | None, Query()] = None,
status: Annotated[str | None, Query()] = None,
nickname: Annotated[str | None, Query(max_length=100)] = None,
created_from: Annotated[datetime | None, Query()] = None,
created_to: Annotated[datetime | None, Query()] = None,
last_login_from: Annotated[datetime | None, Query()] = None,
last_login_to: Annotated[datetime | None, Query()] = None,
sort_by: Annotated[str, Query(pattern="^(id|created_at|last_login_at)$")] = "id",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AdminUserListItem]:
items, next_cursor = queries.list_users(
items, next_cursor, total = queries.list_users(
db, phone=phone, register_channel=register_channel, status=status,
limit=limit, cursor=cursor,
nickname=nickname, created_from=created_from, created_to=created_to,
last_login_from=last_login_from, last_login_to=last_login_to,
sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor,
)
return CursorPage(
items=[AdminUserListItem.model_validate(u) for u in items],
next_cursor=next_cursor,
total=total,
)
@@ -77,7 +90,29 @@ def set_user_status(
return OkResponse()
@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)")
@router.post("/{user_id}/debug-trace", response_model=OkResponse, summary="开关调试链接权限")
def set_user_debug_trace(
user_id: int,
body: SetDebugTraceRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
user = user_repo.get_user_by_id(db, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
before = user.debug_trace_enabled
# 业务写 + 审计写同一事务(commit=False),最后一起 commit(同 set_user_status)
mutations.set_user_debug_trace(db, user, enabled=body.enabled, commit=False)
write_audit(
db, admin, action="user.debug_trace.set", target_type="user", target_id=user_id,
detail={"before": before, "after": body.enabled}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减/设值金币(带审计)")
def grant_user_coins(
user_id: int,
body: GrantCoinsRequest,
@@ -85,27 +120,97 @@ def grant_user_coins(
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
) -> OkResponse:
if body.amount == 0:
raise HTTPException(status_code=400, detail="amount 不能为 0")
user = user_repo.get_user_by_id(db, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
# 负数扣减时不允许扣成负余额(运营误操作保护)
if body.amount < 0:
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False)
if acc_now.coin_balance + body.amount < 0:
raise HTTPException(
status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})"
)
biz_type = "admin_grant" if body.amount > 0 else "admin_deduct"
# set=设为目标值:读当前余额算出要写的差值,仍复用 grant_coins 写一笔流水(沿用原子/审计/扣负保护)
if body.mode == "set":
if body.amount < 0:
raise HTTPException(status_code=400, detail="目标金币值不能为负")
# lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
before = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True).coin_balance
delta = body.amount - before
if delta == 0:
raise HTTPException(status_code=400, detail=f"当前金币已为 {body.amount},无需调整")
else:
if body.amount == 0:
raise HTTPException(status_code=400, detail="amount 不能为 0")
delta = body.amount
# 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿
if delta < 0:
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
if acc_now.coin_balance + delta < 0:
raise HTTPException(
status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})"
)
biz_type = "admin_grant" if delta > 0 else "admin_deduct"
# grant_coins 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
acc, _ = wallet_repo.grant_coins(
db, user_id, body.amount, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
)
detail = {"amount": delta, "balance_after": acc.coin_balance, "reason": body.reason}
if body.mode == "set":
detail.update({"mode": "set", "target": body.amount, "before": before})
write_audit(
db, admin, action="user.coins.grant", target_type="user", target_id=user_id,
detail={"amount": body.amount, "balance_after": acc.coin_balance, "reason": body.reason},
ip=get_client_ip(request), commit=False,
detail=detail, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
@router.post("/{user_id}/cash", response_model=OkResponse, summary="手动增减/设值现金(带审计)")
def grant_user_cash(
user_id: int,
body: GrantCashRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
) -> OkResponse:
"""给指定用户增/减或设值现金(分)。delta:正=发放、负=扣减;set:直接设为目标值。
主要用于让无现金用户直接测试提现。"""
user = user_repo.get_user_by_id(db, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
# set=设为目标值:读当前余额算差值,仍复用 grant_cash 写一笔流水(沿用原子/审计/扣负保护)
if body.mode == "set":
if body.amount_cents < 0:
raise HTTPException(status_code=400, detail="目标现金值不能为负")
# lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
before = wallet_repo.get_or_create_account(
db, user_id, commit=False, lock=True
).cash_balance_cents
delta = body.amount_cents - before
if delta == 0:
raise HTTPException(
status_code=400, detail=f"当前现金已为 {body.amount_cents} 分,无需调整"
)
else:
if body.amount_cents == 0:
raise HTTPException(status_code=400, detail="amount_cents 不能为 0")
delta = body.amount_cents
# 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿
if delta < 0:
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
if acc_now.cash_balance_cents + delta < 0:
raise HTTPException(
status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)"
)
biz_type = "admin_grant" if delta > 0 else "admin_deduct"
# grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
acc, _ = wallet_repo.grant_cash(
db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
)
detail = {
"amount_cents": delta,
"balance_after_cents": acc.cash_balance_cents,
"reason": body.reason,
}
if body.mode == "set":
detail.update({"mode": "set", "target_cents": body.amount_cents, "before_cents": before})
write_audit(
db, admin, action="user.cash.grant", target_type="user", target_id=user_id,
detail=detail, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
+11 -4
View File
@@ -63,7 +63,7 @@ def list_withdraws(
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[WithdrawOrderOut]:
items, next_cursor = queries.list_all_withdraw_orders(
items, next_cursor, total = queries.list_all_withdraw_orders(
db,
user_id=user_id,
status=status,
@@ -78,7 +78,9 @@ def list_withdraws(
cursor=cursor,
)
return CursorPage(
items=[WithdrawOrderOut.model_validate(o) for o in items], next_cursor=next_cursor,
items=[WithdrawOrderOut.model_validate(o) for o in items],
next_cursor=next_cursor,
total=total,
)
@@ -87,7 +89,12 @@ def withdraws_summary(db: AdminDb) -> WithdrawSummaryOut:
return WithdrawSummaryOut(**queries.withdraw_summary(db))
@router.get("/health-check", response_model=WxpayHealthCheckOut, summary="提现配置健康检查")
@router.get(
"/health-check",
response_model=WxpayHealthCheckOut,
summary="提现配置健康检查",
dependencies=[Depends(require_role("finance"))], # 暴露密钥路径/配置,限财务+super
)
def withdraw_health_check() -> WxpayHealthCheckOut:
private_path = wxpay._resolve_config_path(settings.WXPAY_MCH_PRIVATE_KEY_PATH) # noqa: SLF001
public_path = wxpay._resolve_config_path(settings.WXPAY_PUBLIC_KEY_PATH) # noqa: SLF001
@@ -160,7 +167,7 @@ def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut:
withdraw_success_cents=overview["withdraw_success_cents"],
)
recent_withdraws, _ = queries.list_all_withdraw_orders(
recent_withdraws, _, _ = queries.list_all_withdraw_orders(
db, user_id=order.user_id, limit=5, cursor=None,
)
recent_cash_transactions, _ = queries.list_all_cash_transactions(
+62
View File
@@ -0,0 +1,62 @@
"""看广告金币审计 schemas。
只读对账视图:把"看视频"(ad_reward_record)和"信息流"(ad_feed_reward_record)两类发奖记录,
用与正式发奖相同的公式 [app.core.rewards.calculate_ad_reward_coin] 复算一遍 expected_coin,
和实际入账的 actual_coin 对比,核对金币公式是否生效。字段 snake_case、金额按金币整数。
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class AdCoinAuditRow(BaseModel):
"""单条发奖记录的复算明细。"""
scene: str = Field(..., description="reward_video(看视频) / feed(信息流)")
record_id: int = Field(..., description="对应记录表主键")
user_id: int
created_at: datetime
status: str = Field(..., description="granted / capped / ecpm_missing")
ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示,SDK getEcpm 原值)")
ecpm_factor: float | None = Field(None, description="因子1(eCPM 档)")
units: int = Field(..., description="折算份数:看视频恒为 1;信息流 = 满 10 秒的份数")
lt_index_start: int | None = Field(None, description="本条占用的当日第几份(起)")
lt_index_end: int | None = Field(None, description="本条占用的当日第几份(止);看视频 = 起")
lt_factor_start: float | None = Field(None, description="因子2(LT)起值")
lt_factor_end: float | None = Field(None, description="因子2(LT)止值;看视频 = 起值")
expected_coin: int = Field(..., description="按公式复算应发金币")
actual_coin: int = Field(..., description="实际入账金币")
matched: bool = Field(..., description="复算与实发是否一致(capped/ecpm_missing 校验是否确为 0)")
class AdCoinFormulaOut(BaseModel):
"""当前金币公式参数(给前端展示规则参照)。"""
description: str = Field(
"eCPM元 = getEcpm分 ÷ 100;单份金币 = round(eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan);"
"因子1 按 eCPM元 判档(阈值 100/200/400 元)",
description="公式说明",
)
coin_per_yuan: int = Field(..., description="金币:元 汇率")
ecpm_unit: str = Field("分/千次展示(SDK getEcpm 原值)", description="eCPM 口径")
feed_unit_seconds: int = Field(..., description="信息流每多少秒折 1 份")
# [(因子值, 区间下限, 区间上限或 null)]
ecpm_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子1 档位表")
lt_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子2 LT 档位表")
class AdCoinAuditOut(BaseModel):
"""审计响应:公式参照 + 全量统计 + 明细。"""
date: str = Field(..., description="审计日期(北京时间 YYYY-MM-DD)")
formula: AdCoinFormulaOut
total: int = Field(..., description="该筛选下复算总条数(全量,不受 limit/only_mismatch 影响)")
mismatch_count: int = Field(
..., description="全量不一致条数(=0 说明公式全部生效;在截断前统计,可信)"
)
truncated: bool = Field(
..., description="展示集是否被 limit 截断(true=还有未返回的明细,请缩小范围或调大 limit)"
)
items: list[AdCoinAuditRow] = Field(..., description="展示明细;only_mismatch=true 时只含 ✗ 行")
+6 -1
View File
@@ -9,10 +9,15 @@ T = TypeVar("T")
class CursorPage(BaseModel, Generic[T]):
"""游标分页响应:items + 下一页游标(next_cursor=None 表示末页)"""
"""分页响应:items + 下一页游标(next_cursor=None 表示末页)+ 可选 total。
next_cursor:offset 分页时即下一页 offset,「加载更多」用;末页为 None。
total:符合筛选条件的总条数,页码分页(antd pagination)用;不需要总数的接口可不传(None)。
"""
items: list[T]
next_cursor: int | None = None
total: int | None = None
class OkResponse(BaseModel):
+24
View File
@@ -0,0 +1,24 @@
"""admin 设备维度新手引导管理 schemas。
force_onboarding(按用户)废弃后改用「按设备」:直接操作 onboarding_completion(设备+账号 维度),
客户端任何版本都按它判断是否走引导,故可靠。
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class DeviceOnboardingItem(BaseModel):
"""一台设备(ANDROID_ID)的引导聚合:在该设备走过引导的账号数 + 最近完成时间。"""
device_id: str
account_count: int
last_completed_at: datetime
class ResetAllRequest(BaseModel):
confirm: bool = Field(
False, description="必须 true 才清空全部设备的引导记录(防误调接口;前端另有二次确认弹窗)"
)
+12 -1
View File
@@ -4,7 +4,7 @@
"""
from __future__ import annotations
from pydantic import BaseModel
from pydantic import BaseModel, Field
class OpsMarqueeSeedOut(BaseModel):
@@ -42,6 +42,17 @@ class OpsMarqueeSeedBulkCreate(BaseModel):
enabled: bool = True
class OpsMarqueeSeedBatchDelete(BaseModel):
"""批量删除选中的种子。"""
ids: list[int] = Field(..., max_length=500) # 上限防超大列表(审计行/IN 子句过大)
class OpsMarqueeSeedBatchEnable(BaseModel):
"""批量启用 / 停用选中的种子。"""
ids: list[int] = Field(..., max_length=500)
enabled: bool
class OpsSavingsFeedPreviewItem(BaseModel):
masked_user: str
saved_amount_cents: int
+1 -1
View File
@@ -20,7 +20,7 @@ class OpsStatItemOut(BaseModel):
random_kind: str # mult(×倍率) / add(+绝对增量)
random_step_min: int # 绝对增量区间(基础单位;total_saved 为分)
random_step_max: int
real_offset: int # 真实值模式基数偏移(基础单位)
real_offset: int # 真实值模式保底值(基础单位):展示=max(真实,保底)。列名沿用 real_offset
allow_decrease: bool # 是否允许展示值下降(默认 false = 只增不减)
random_current: int | None = None
random_last_tick_at: str | None = None
+54
View File
@@ -0,0 +1,54 @@
"""admin 上报更低价审核 schemas。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
class PriceReportOut(BaseModel):
"""admin 列表项:price_report 全字段(审核要看的快照 + 截图 + 状态 + 奖励)。"""
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
comparison_record_id: int | None = None
# 选中比价记录的快照
store_name: str | None = None
dish_summary: str | None = None
original_platform_id: str | None = None
original_platform_name: str | None = None
original_price_cents: int | None = None
# 用户上报的更低价
reported_platform_id: str
reported_platform_name: str
reported_price_cents: int
images: list[str] = []
# 审核
status: str
reject_reason: str | None = None
reward_coins: int | None = None
reviewed_at: datetime | None = None
created_at: datetime
class PriceReportRejectRequest(BaseModel):
reason: str = Field(min_length=1, max_length=256, description="拒绝理由,用户端记录页会看到")
@field_validator("reason")
@classmethod
def _reason_not_blank(cls, v: str) -> str:
# min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计/用户端记录到空理由
if not v.strip():
raise ValueError("拒绝理由不能为空")
return v.strip()
class PriceReportSummary(BaseModel):
"""审核台顶部各状态计数。"""
pending: int
approved: int
rejected: int
total: int
+35 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
class AdminUserListItem(BaseModel):
@@ -15,6 +15,7 @@ class AdminUserListItem(BaseModel):
nickname: str | None = None
register_channel: str
status: str
debug_trace_enabled: bool = False
wechat_openid: str | None = None
created_at: datetime
last_login_at: datetime
@@ -36,12 +37,44 @@ class AdminUserOverview(BaseModel):
feedback_total: int
def _strip_reason(v: str) -> str:
# min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计记到空原因
if not v.strip():
raise ValueError("操作原因不能为空")
return v.strip()
class GrantCoinsRequest(BaseModel):
amount: int = Field(..., description="金币变动:正=增加,负=扣减(不可为 0)")
mode: Literal["delta", "set"] = Field(
"delta", description="delta=增减(amount 为变动量) / set=设为(amount 为目标值,须≥0)"
)
amount: int = Field(
...,
description="delta 模式:金币变动(正=增加,负=扣减,不可为 0);set 模式:目标金币值(须≥0)",
)
reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
_v_reason = field_validator("reason")(_strip_reason)
class GrantCashRequest(BaseModel):
mode: Literal["delta", "set"] = Field(
"delta", description="delta=增减(amount_cents 为变动量) / set=设为(amount_cents 为目标值,须≥0)"
)
amount_cents: int = Field(
...,
description="delta 模式:现金变动(分,正=增加,负=扣减,不可为 0);set 模式:目标现金值(分,须≥0)",
)
reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
_v_reason = field_validator("reason")(_strip_reason)
class SetUserStatusRequest(BaseModel):
status: Literal["active", "disabled"] = Field(
..., description="active=解封 / disabled=封禁(注销 deleted 不走此接口)"
)
class SetDebugTraceRequest(BaseModel):
enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限")
+107
View File
@@ -0,0 +1,107 @@
"""平台店铺映射内部上报端点(pricebot → app-server)。
pricebot 在淘宝比价拿到 shopId 后,把这一行跨平台店铺映射 POST 到这里落库。
**不是给客户端的接口**:不走用户 JWT,靠 server 间共享密钥头 `X-Internal-Secret` 校验
(复用 price.py 的 _check_secret,与 price-observation 同一密钥)。
与 price.py 的 /internal/price-observation 平行:那个落价格事实,这个落店铺身份映射。
"""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Header
from app.api.deps import DbSession
from app.api.internal.price import _check_secret
from app.repositories import store_mapping as repo
from app.schemas.store_mapping import (
StoreMappingIn,
StoreMappingInvalidateIn,
StoreMappingInvalidateOut,
StoreMappingOut,
)
logger = logging.getLogger("shagua.internal.store")
router = APIRouter(prefix="/internal", tags=["internal"])
@router.get(
"/store-mapping/lookup",
summary="比价前按源平台店名反查各目标平台已沉淀的店铺 id(命中→pricebot 直接 deeplink)",
)
def lookup_store_mapping(
source_platform: str,
name: str,
db: DbSession,
lat: float | None = None,
lng: float | None = None,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> dict:
_check_secret(x_internal_secret)
result = repo.lookup_nearest(db, source_platform, name, lat, lng)
if result:
hits = ", ".join(
f"{t}:row{v['row_id']}"
f"{'(' + str(v['dist_km']) + 'km)' if 'dist_km' in v else ''}"
f"{v.get('deeplink') or '(无deeplink)'}"
for t, v in result.items()
)
logger.info(
"store_mapping lookup source=%s name=%r geo=(%s,%s) → 命中 %s",
source_platform, name, lat, lng, hits,
)
else:
logger.info(
"store_mapping lookup source=%s name=%r geo=(%s,%s) → MISS",
source_platform, name, lat, lng,
)
return result
@router.post(
"/store-mapping",
response_model=StoreMappingOut,
summary="平台店铺映射内部上报(pricebot→app-server,落 store_mapping)",
)
def report_store_mapping(
payload: StoreMappingIn,
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> StoreMappingOut:
_check_secret(x_internal_secret)
created, row_id = repo.upsert(db, payload)
logger.info(
"store_mapping trace=%s %s row_id=%s source=%s "
"taobao=(%s,%s) jd=(%s,%s) device=%s user=%s",
payload.trace_id, "新建" if created else "合并", row_id, payload.source_platform,
payload.id_taobao, payload.name_taobao, payload.id_jd, payload.name_jd,
payload.source_device_id, payload.source_user_id,
)
return StoreMappingOut(inserted=created, row_id=row_id)
@router.post(
"/store-mapping/invalidate",
response_model=StoreMappingInvalidateOut,
summary="标记某平台 shopId 的缓存 deeplink 失效(pricebot 撞错误页回退时上报,lookup 不再返回)",
)
def invalidate_store_mapping(
payload: StoreMappingInvalidateIn,
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> StoreMappingInvalidateOut:
_check_secret(x_internal_secret)
if payload.platform != "taobao":
# 当前只接淘宝; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。
logger.info("store_mapping invalidate 跳过: platform=%s 暂不支持", payload.platform)
return StoreMappingInvalidateOut(ok=True, affected=0)
affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id)
logger.info(
"store_mapping invalidate platform=%s shop_id=%s → 标记失效 %d",
payload.platform, payload.shop_id, affected,
)
return StoreMappingInvalidateOut(ok=True, affected=affected)
+10 -2
View File
@@ -304,10 +304,18 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N
db.commit()
db.refresh(rec)
else:
# 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式);
# 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。
ad_session_id = payload.ad_session_id if payload is not None else None
ecpm_val = "200"
if ad_session_id:
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id)
if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0:
ecpm_val = ecpm_rec.ecpm_raw
try:
rec = crud_ad.grant_ad_reward(
db, user.id, trans_id, ecpm="200", reward_name="测试发奖",
raw="client debug test-grant ecpm=200",
db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id,
reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}",
)
except crud_ad.UnknownUserError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
+10 -6
View File
@@ -19,6 +19,7 @@ from app.core.ratelimit import rate_limit
from app.core.security import TokenError, decode_token, issue_token_pair
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.integrations.sms import SmsError, send_code, verify_code
from app.repositories import onboarding as onboarding_repo
from app.repositories import user as user_repo
from app.schemas.auth import (
JverifyLoginRequest,
@@ -37,12 +38,13 @@ logger = logging.getLogger("shagua.auth")
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
def _login_response(db, user) -> TokenWithUser:
"""登录类接口共用的响应组装。"""
def _login_response(user, *, onboarding_completed: bool) -> TokenWithUser:
"""登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入。"""
tokens = issue_token_pair(user.id)
return TokenWithUser(
**tokens,
user=UserOut.model_validate(user),
onboarding_completed=onboarding_completed,
)
@@ -66,8 +68,9 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
logger.info("jverify_login ok user_id=%d phone=%s", user.id, mask_phone(phone))
return _login_response(db, user)
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
logger.info("jverify_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(phone), completed)
return _login_response(user, onboarding_completed=completed)
# ===================== 短信登录 =====================
@@ -103,8 +106,9 @@ def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
logger.info("sms_login ok user_id=%d phone=%s", user.id, mask_phone(req.phone))
return _login_response(db, user)
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
logger.info("sms_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(req.phone), completed)
return _login_response(user, onboarding_completed=completed)
# ===================== Refresh =====================
+11
View File
@@ -102,6 +102,17 @@ async def intent_step(request: Request) -> dict[str, Any]:
return await _passthrough(request, "/api/intent/step")
@router.post(
"/intent/precoupon/step",
summary="外卖比价 Phase 0 意图识别前先用券 (透传到 pricebot, 仅美团源)",
)
async def intent_precoupon_step(request: Request) -> dict[str, Any]:
# 美团源平台『意图识别前先用券』多帧循环: 客户端在调 /intent/recognize 之前先循环
# 调本端点到 done(continue=false)。订单页底部有『点击使用X红包』就自动选最大免费券
# 用上, 已用券/无券则首帧秒过。与 /intent/step 同属 intent 域, 复用同一透传壳。
return await _passthrough(request, "/api/intent/precoupon/step")
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)")
async def price_step(request: Request) -> dict[str, Any]:
return await _passthrough(request, "/api/price/step")
+15 -5
View File
@@ -77,10 +77,12 @@ def list_records(
items, next_cursor = crud_compare.list_records(
db, user.id, limit=limit, cursor=cursor
)
return ComparisonRecordPage(
items=[ComparisonRecordOut.model_validate(it) for it in items],
next_cursor=next_cursor,
)
outs = [ComparisonRecordOut.model_validate(it) for it in items]
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)
if not user.debug_trace_enabled:
for o in outs:
o.trace_url = None
return ComparisonRecordPage(items=outs, next_cursor=next_cursor)
@router.get(
@@ -94,4 +96,12 @@ def get_record(
rec = crud_compare.get_record(db, user.id, record_id)
if rec is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="record not found")
return ComparisonRecordDetailOut.model_validate(rec)
out = ComparisonRecordDetailOut.model_validate(rec)
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url。
# ⚠️ raw_payload 是上报体全量(model_dump),里面也藏着一份 trace_url,必须一并抹掉——
# 否则无权限用户从详情接口的 raw_payload 绕过权限闸拿到 trace_url。
if not user.debug_trace_enabled:
out.trace_url = None
if isinstance(out.raw_payload, dict):
out.raw_payload.pop("trace_url", None)
return out
+85 -12
View File
@@ -23,7 +23,12 @@ from app.core.config import settings
from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal
from app.repositories import coupon_state as coupon_repo
from app.schemas.coupon_state import CouponPromptDismissIn, CouponPromptShouldShowOut
from app.schemas.coupon_state import (
CouponCompletedTodayOut,
CouponPromptDismissIn,
CouponPromptShouldShowOut,
CouponPromptShownIn,
)
logger = logging.getLogger("shagua.coupon")
@@ -62,11 +67,11 @@ def _extract_coupon_results(resp_json: dict) -> list[dict]:
def _mark_engagement_blocking(
device_id: str, user_id: int | None, engage_type: str
device_id: str, package: str, user_id: int | None, engage_type: str
) -> None:
"""独立 session 写 engagement(async 端点经 run_in_threadpool 调,不阻塞事件循环)。"""
with SessionLocal() as db:
coupon_repo.mark_engagement(db, device_id, user_id, engage_type)
coupon_repo.mark_engagement(db, device_id, package, user_id, engage_type)
def _record_claims_blocking(
@@ -76,6 +81,14 @@ def _record_claims_blocking(
coupon_repo.record_claims(db, device_id, user_id, trace_id, results)
def _mark_completed_blocking(
device_id: str, user_id: int | None, trace_id: str | None
) -> None:
"""独立 session 写"今日已跑完整轮"(到 done 帧那刻调,best-effort 不阻塞领券)。"""
with SessionLocal() as db:
coupon_repo.mark_completed_today(db, device_id, user_id, trace_id)
@router.post("/step", summary="领券任务步进 (透传到 pricebot)")
async def coupon_step(
request: Request,
@@ -99,14 +112,17 @@ async def coupon_step(
device_id = meta.get("device_id")
user_id = _to_int(meta.get("user_id")) # 登录态才带;判断不靠它,资产留痕用
trace_id = meta.get("trace_id")
# 发起领券时前台 App 包名(step body 带 "package")。频控按 App,这条 engagement 要记到
# 对应 App 上。App 内「去领取」发起时 package 可能缺/为空 → 退化为 "" 占位(全局态)。
pkg = meta.get("package") or ""
# 领券任务首帧(step=0)= 用户已发起领券 → 记一条今日 engagement(claim_started),
# 今天这台设备不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能
# 今天**这个 App** 不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能
# 连累领券主流程,整段吞掉。
if device_id and meta.get("step") == 0:
try:
await run_in_threadpool(
_mark_engagement_blocking, device_id, user_id, "claim_started"
_mark_engagement_blocking, device_id, pkg, user_id, "claim_started"
)
except Exception as e: # noqa: BLE001
logger.warning("coupon engagement write failed: %s", e)
@@ -160,17 +176,45 @@ async def coupon_step(
except Exception as e: # noqa: BLE001
logger.warning("coupon claim write failed: %s", e)
# 整轮跑完(done 帧)→ 记一条"今日已完成",首页「去领取」卡据此置灰。
# pricebot 把中途单券 done 改写成 wait+continue=true,只有整套全跑完那帧才保留
# command=="done"(见 pricebot main.py),故 done 已等价"整轮完成"(用户决策 A 方案:
# 到 done 即算,不管单券成败),无需再判 continue。写库失败绝不连累领券返回。
action = resp_json.get("action") or {}
if device_id and action.get("command") == "done":
try:
await run_in_threadpool(
_mark_completed_blocking, device_id, user_id, trace_id
)
except Exception as e: # noqa: BLE001
logger.warning("coupon completion write failed: %s", e)
return resp_json
@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]:
"""客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。
频控主判据(管跨重装):弹出即占用今天这个 App 的"一次"。用户领/拒/无视都算用掉。
后续点领取/拒绝再由 step/dismiss 把 type 升级。按 (device, package, 日) 记。
"""
coupon_repo.mark_engagement(
db, payload.device_id, payload.package, payload.user_id, "shown"
)
return {"ok": True}
@router.post("/prompt/dismiss", summary="用户拒绝/关闭领券引导窗(记今日已 engage)")
def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]:
"""客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天不再弹。
"""客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天**这个 App** 不再弹。
server 在透传链路里看不到"用户拒绝"(拒绝不发起领券),故必须客户端通知。
MVP 不鉴权,按 device_id 记。
频控按 (device, package, 日),各 App 独立。MVP 不鉴权,按 device_id 记。
"""
coupon_repo.mark_engagement(db, payload.device_id, payload.user_id, "dismissed")
coupon_repo.mark_engagement(
db, payload.device_id, payload.package, payload.user_id, "dismissed"
)
return {"ok": True}
@@ -180,12 +224,12 @@ def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict
summary="切到外卖 App 时是否还应弹领券引导窗",
)
def coupon_prompt_should_show(
device_id: str, db: DbSession
device_id: str, db: DbSession, package: str = ""
) -> CouponPromptShouldShowOut:
"""今天这台设备已 engage(领或拒)过 → should_show=false。客户端据此决定弹不弹
(纯后台判据,客户端不再做前台 SP 缓存判断)。"""
"""今天这台设备**这个 App** 已 engage(弹/领/拒)过 → should_show=false。各 App 独立:
美团弹过不压淘宝/京东。客户端切到目标 App 时带 package 查(老客户端不带 → "" 全局态)。"""
return CouponPromptShouldShowOut(
should_show=not coupon_repo.has_engaged_today(db, device_id)
should_show=not coupon_repo.has_engaged_today(db, device_id, package)
)
@@ -195,3 +239,32 @@ def coupon_prompt_reset(payload: CouponPromptDismissIn, db: DbSession) -> dict[s
开发设置「重置今日领券弹窗状态」按钮调。MVP 不鉴权,按 device_id。"""
coupon_repo.reset_today_engagement(db, payload.device_id)
return {"ok": True}
@router.get(
"/completed-today",
response_model=CouponCompletedTodayOut,
summary="这台设备今天是否已跑完整轮领券(到 done 帧)",
)
def coupon_completed_today(
device_id: str, db: DbSession
) -> CouponCompletedTodayOut:
"""今天这台设备已跑完整轮(到 done)→ completed=true。客户端据此把首页「去领取」
卡置灰、不可点(用户决策 A 方案:到 done 即算,不管单券成败)。判断维度 device_id
必须与领券循环上报的一致(客户端两端都用 ANDROID_ID)。"""
return CouponCompletedTodayOut(
completed=coupon_repo.has_completed_today(db, device_id)
)
@router.post(
"/completed-today/reset",
summary="重置今日已完成(开发设置全重置用,恢复首页「去领取」卡可点)",
)
def coupon_completed_today_reset(
payload: CouponPromptDismissIn, db: DbSession
) -> dict[str, bool]:
"""删这台设备今天的 completion → has_completed_today 变 false,首页「去领取」卡恢复可点。
与 /prompt/reset 配套:开发设置「重置今日领券弹窗状态」一键把今日状态全清。MVP 不鉴权。"""
coupon_repo.reset_today_completion(db, payload.device_id)
return {"ok": True}
+4 -5
View File
@@ -1,7 +1,7 @@
"""帮助与反馈 endpoint。
路由前缀 `/api/v1/feedback`,需 Bearer 鉴权(反馈绑到登录用户,便于回访)。
POST / 提交反馈(multipart:content / contact 必填,images 可选 ≤4 张)
POST / 提交反馈(multipart:content 必填;contact 可选(原型改版后客户端已不再采集);images 可选 ≤6 张)
截图复用 [app.core.media] 落盘到 /media/feedback/。
"""
@@ -20,7 +20,7 @@ logger = logging.getLogger("shagua.feedback")
router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 4
_MAX_IMAGES = 6
_CONTENT_MAX = 2000
_CONTACT_MAX = 128
@@ -30,7 +30,8 @@ async def submit_feedback(
user: CurrentUser,
db: DbSession,
content: str = Form(...),
contact: str = Form(...),
# 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
contact: str = Form(default=""),
images: list[UploadFile] = File(default=[]),
) -> FeedbackOut:
content = content.strip()
@@ -39,8 +40,6 @@ async def submit_feedback(
raise HTTPException(status_code=400, detail="反馈内容不能为空")
if len(content) > _CONTENT_MAX:
raise HTTPException(status_code=400, detail="反馈内容过长")
if not contact:
raise HTTPException(status_code=400, detail="联系方式不能为空")
if len(contact) > _CONTACT_MAX:
raise HTTPException(status_code=400, detail="联系方式过长")
+148 -54
View File
@@ -8,12 +8,12 @@ import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy import nullslast, select
from sqlalchemy.orm import Session, aliased
from app.core.config import settings
from app.db.session import get_db
from app.integrations.meituan import MeituanCpsError, get_referral_link, query_coupon
from app.integrations.meituan import MeituanCpsError, _call, get_referral_link, query_coupon
from app.models.meituan_coupon import MeituanCoupon
from app.schemas.meituan import (
CouponCard,
@@ -34,7 +34,7 @@ router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"])
@router.post("/coupons", response_model=CouponListResponse, summary="券列表(为您推荐)")
def list_coupons(req: CouponListRequest) -> CouponListResponse:
if not settings.mt_cps_configured:
return CouponListResponse(items=[], has_next=False, search_id=None)
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
logger.info("[coupons] lon=%.6f lat=%.6f topic=%s", req.longitude, req.latitude, req.list_topic_id)
try:
raw = query_coupon(
@@ -50,14 +50,21 @@ def list_coupons(req: CouponListRequest) -> CouponListResponse:
page_size=req.page_size,
)
except MeituanCpsError as e:
logger.error("query_coupon failed: %s", e)
raise HTTPException(status_code=502, detail=f"meituan: {e}") from e
# 软降级:不再抛 502(避免前端弹报错 toast),返空 + degraded,前端显示「服务繁忙」。
logger.warning("[coupons] query_coupon 失败,降级返空: %s", e)
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
items = [CouponCard.from_raw(it) for it in (raw.get("data") or [])]
items: list[CouponCard] = []
for it in (raw.get("data") or []):
try:
items.append(CouponCard.from_raw(it))
except Exception: # noqa: BLE001
continue
return CouponListResponse(
items=items,
has_next=raw.get("hasNext", False),
search_id=raw.get("searchId"),
status="ok" if items else "empty",
)
@@ -93,59 +100,136 @@ def _commission_pct(card: CouponCard) -> float:
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉);tab=rec智能推荐/distance距离最近")
def feed(req: FeedRequest) -> FeedResponse:
if not settings.mt_cps_configured:
return FeedResponse(items=[], has_next=False, page=req.page)
def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
lon, lat = req.longitude, req.latitude
tab = (req.tab or "").strip()
logger.info("[feed] tab=%s page=%s lon=%.6f lat=%.6f", tab or "(default)", req.page, lon, lat)
def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> list[dict]:
# rec 是纯离线库查询,不依赖 MT 凭证、不实时打美团;其余 tab(distance / 默认)要实时打美团,
# 未配凭证直接降级返空(degraded),让前端区分「服务暂不可用」而非「暂无」。
if tab != "rec" and not settings.mt_cps_configured:
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> tuple[list[dict], bool]:
"""返回(items, 是否调用失败)。失败标志用于把默认 feed 整页标 degraded。"""
try:
return (query_coupon(
data = (query_coupon(
longitude=lon, latitude=lat,
platform=platform, biz_line=biz_line,
list_topic_id=topic, page_size=20,
).get("data") or [])
return data, False
except MeituanCpsError:
return []
return [], True
# 距离最近:拉齐全部榜单轮次,合并去重,后端在【完整池子】上全局按距离由近及远排,一次性返回
# (距离排序必须在完整池上做、不能逐页排——这正是之前放前端不合理的根因。)
# 距离最近:搜索召回(外卖搜"外卖" + 到店搜"美食",都 sortField=6 离我最近)一页页拉
# 搜索翻页必须用 searchId(pageNo 翻不动),所以每个 feed 页顺序翻到第 N 页;两路并行、page 1 最快。
# 无状态、不改 APP(传页码即可);按你位置实时算距离(库里没存 POI 经纬度,只能实时)。
if tab == "distance":
with ThreadPoolExecutor(max_workers=len(_TOPIC_ROUNDS) * 2) as pool:
futs = []
for wm_topic, dd_topic in _TOPIC_ROUNDS:
futs.append(pool.submit(_fetch_topic, 1, None, wm_topic))
futs.append(pool.submit(_fetch_topic, 2, 1, dd_topic))
raws = [f.result() for f in futs]
lon_i, lat_i = int(lon * 1_000_000), int(lat * 1_000_000)
def _search_page_n(platform: int, biz_line: int | None, keyword: str, n: int) -> tuple[list[dict], bool, bool]:
"""顺序翻到第 n 页(搜索须 searchId 续页),返回(第 n 页 items, 是否还有下一页, 是否调用失败)。"""
sid: str | None = None
data: list[dict] = []
has_next = False
for pg in range(1, n + 1):
body: dict = {
"platform": platform, "searchText": keyword, "sortField": 6,
"longitude": lon_i, "latitude": lat_i, "pageSize": 20,
}
if biz_line is not None:
body["bizLine"] = biz_line
if sid:
body["searchId"] = sid
else:
body["pageNo"] = 1
try:
r = _call("/cps_open/common/api/v1/query_coupon", body)
except MeituanCpsError:
return [], False, True # 调用失败(用于标 degraded)
data = r.get("data") or []
sid = r.get("searchId")
has_next = bool(r.get("hasNext")) and bool(data)
if not data or (not has_next and pg < n):
return [], False, False # 没那么多页了(非错误)
return data, has_next, False
with ThreadPoolExecutor(max_workers=2) as pool:
f_wm = pool.submit(_search_page_n, 1, None, "外卖", req.page)
f_dd = pool.submit(_search_page_n, 2, 1, "美食", req.page)
(wm_data, wm_hn, wm_fail), (dd_data, dd_hn, dd_fail) = f_wm.result(), f_dd.result()
seen: set[str] = set()
cards: list[CouponCard] = []
for raw_list in raws:
for it in raw_list:
for it in wm_data + dd_data:
try:
card = CouponCard.from_raw(it)
if card.product_view_sign and card.product_view_sign not in seen:
seen.add(card.product_view_sign)
cards.append(card)
except Exception: # noqa: BLE001
continue
if card.product_view_sign and card.product_view_sign not in seen:
seen.add(card.product_view_sign)
cards.append(card)
cards.sort(key=lambda c: c.distance_meters if c.distance_meters is not None else float("inf"))
return FeedResponse(items=cards, has_next=False, page=1)
# 两路都失败且无数据 → degraded;否则有数据 ok / 无数据 empty
status = "degraded" if (not cards and wm_fail and dd_fail) else ("ok" if cards else "empty")
return FeedResponse(items=cards, has_next=wm_hn or dd_hn, page=req.page, status=status)
# 智能推荐(rec,默认):沿用逐轮分页的混合 feed,后端过滤掉佣金率 < 3%。
# 智能推荐(rec):走【离线库】筛佣金率 3%,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)
# 实测库里佣金≥3% 去重后仅 ~578 条(几乎全是外卖;到店团购佣金普遍 <3%):实时按"同城热销榜单"
# 拉既撞限流、又填不满(该榜单中位佣金 ~0.8%,筛完每页剩 0-1 条),故从库出。佣金阈值逻辑不变。
if tab == "rec":
PAGE = 20
try:
base = select(MeituanCoupon).where(MeituanCoupon.commission_percent >= 3.0)
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
MeituanCoupon.dedup_key,
MeituanCoupon.commission_percent.desc(),
).subquery()
m = aliased(MeituanCoupon, deduped)
start = (req.page - 1) * PAGE
rows = db.execute(
select(m)
# 销量高的优先(无销量档排后),同档佣金高优先,id 兜底稳定分页
.order_by(nullslast(m.sale_volume_num.desc()), m.commission_percent.desc(), m.id)
.offset(start)
.limit(PAGE + 1)
).scalars().all()
except Exception: # noqa: BLE001
logger.exception("[feed] rec 库查询失败,降级返空")
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
has_next = len(rows) > PAGE
cards: list[CouponCard] = []
for row in rows[:PAGE]:
try:
card = CouponCard.from_raw(row.raw or {})
except Exception: # noqa: BLE001
continue
if card.product_view_sign:
# 智能推荐不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。
# 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。
card.distance_text = None
card.distance_meters = None
cards.append(card)
return FeedResponse(items=cards, has_next=has_next, page=req.page,
status="ok" if cards else "empty")
# 默认(老客户端不传 tab):沿用逐轮分页的混合 feed,不筛。
page_idx = req.page - 1
if page_idx >= len(_TOPIC_ROUNDS):
return FeedResponse(items=[], has_next=False, page=req.page)
return FeedResponse(items=[], has_next=False, page=req.page, status="empty")
wm_topic, dd_topic = _TOPIC_ROUNDS[page_idx]
with ThreadPoolExecutor(max_workers=2) as pool:
f_wm = pool.submit(_fetch_topic, 1, None, wm_topic)
f_dd = pool.submit(_fetch_topic, 2, 1, dd_topic)
waimai, daodian = f_wm.result(), f_dd.result()
(waimai, wm_fail), (daodian, dd_fail) = f_wm.result(), f_dd.result()
items = _interleave(waimai, daodian)
if tab == "rec":
items = [c for c in items if _commission_pct(c) >= 3.0]
has_next = page_idx + 1 < len(_TOPIC_ROUNDS)
return FeedResponse(items=items, has_next=has_next, page=req.page)
# 两路都失败且无数据 → degraded;否则有数据 ok / 无数据 empty
status = "degraded" if (not items and wm_fail and dd_fail) else ("ok" if items else "empty")
return FeedResponse(items=items, has_next=has_next, page=req.page, status=status)
@router.post("/referral-link", response_model=ReferralLinkResponse, summary="换取推广链接(点抢时调)")
@@ -172,32 +256,42 @@ def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
@router.post("/top-sales", response_model=CouponListResponse,
summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,不实时打美团)")
def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponListResponse:
# 只取有销量档的(美团很多券没销量,排不了);按销量降序、同销量再按佣金降序
stmt = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None))
if req.platform is not None:
stmt = stmt.where(MeituanCoupon.platform == req.platform)
stmt = stmt.order_by(
MeituanCoupon.sale_volume_num.desc(),
MeituanCoupon.commission_percent.desc(),
)
rows = db.execute(stmt).scalars().all()
# 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。
# (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。)
# 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。
try:
# 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
base = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None))
if req.platform is not None:
base = base.where(MeituanCoupon.platform == req.platform)
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
MeituanCoupon.dedup_key,
MeituanCoupon.sale_volume_num.desc(),
MeituanCoupon.commission_percent.desc(),
).subquery()
# 跨源去重(dedup_key = 品牌|名|价):按销量降序遍历,每个 dedup_key 只留第一条(=销量最高那条)。
# 用 raw(整条原始返回)重建 CouponCard,字段与实时接口完全一致,前端无需改渲染。
seen: set[str] = set()
# 2) 对去重结果按销量降序分页;多取 1 条判断 has_next,只对本页做 from_raw
m = aliased(MeituanCoupon, deduped)
start = (req.page - 1) * req.page_size
rows = db.execute(
select(m)
# 加 id 作稳定 tiebreaker:同销量同佣金的并列项排序确定,避免跨页重复/漏项
.order_by(m.sale_volume_num.desc(), m.commission_percent.desc(), m.id)
.offset(start)
.limit(req.page_size + 1)
).scalars().all()
except Exception: # noqa: BLE001
logger.exception("[top-sales] 库查询失败,降级返空")
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
has_next = len(rows) > req.page_size
cards: list[CouponCard] = []
for row in rows:
if row.dedup_key in seen:
continue
seen.add(row.dedup_key)
for row in rows[:req.page_size]:
try:
card = CouponCard.from_raw(row.raw or {})
except Exception: # noqa: BLE001
continue
if card.product_view_sign:
cards.append(card)
start = (req.page - 1) * req.page_size
page_items = cards[start:start + req.page_size]
has_next = start + req.page_size < len(cards)
return CouponListResponse(items=page_items, has_next=has_next, search_id=None)
return CouponListResponse(items=cards, has_next=has_next, search_id=None,
status="ok" if cards else "empty")
+36 -2
View File
@@ -12,13 +12,19 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, File, HTTPException, UploadFile
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from app.api.deps import CurrentUser, DbSession
from app.core import media
from app.repositories import onboarding as onboarding_repo
from app.repositories import user as user_repo
from app.schemas.auth import UserOut
from app.schemas.user import OkResponse, ProfileUpdateRequest
from app.schemas.user import (
OkResponse,
OnboardingCompleteRequest,
OnboardingStatusResponse,
ProfileUpdateRequest,
)
logger = logging.getLogger("shagua.user")
@@ -51,6 +57,34 @@ async def upload_avatar(
return UserOut.model_validate(updated)
@router.post("/onboarding/complete", response_model=OkResponse, summary="标记新手引导完成(按 设备+账号)")
def complete_onboarding(
req: OnboardingCompleteRequest, user: CurrentUser, db: DbSession
) -> OkResponse:
"""走完新手引导时调一次。按 (当前账号, device_id) 落一条完成标记,跨卸载重装持久。
幂等:重复调用不报错。device_id 取客户端硬件级 ANDROID_ID,与登录请求一致。"""
onboarding_repo.mark_completed(db, user_id=user.id, device_id=req.device_id)
logger.info("onboarding complete user_id=%d device_len=%d", user.id, len(req.device_id))
return OkResponse()
@router.get(
"/onboarding/status",
response_model=OnboardingStatusResponse,
summary="查新手引导是否已完成(按 设备+账号)",
)
def onboarding_status(
user: CurrentUser,
db: DbSession,
device_id: str = Query("", max_length=64, description="硬件级 ANDROID_ID;空=按未完成处理"),
) -> OnboardingStatusResponse:
"""已登录用户启动时查:该 (账号, 设备) 走过引导没。false → 客户端重走(运营在 admin 删了该设备
记录即触发)。替代原 force_onboarding(按用户)——改设备维度后这是"运营触发重走"的查询入口。
device_id 为空一律按未完成(同登录:不误跳过)。"""
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=device_id)
return OnboardingStatusResponse(completed=completed)
@router.delete("", response_model=OkResponse, summary="注销账号(软删除)")
def delete_account(user: CurrentUser, db: DbSession) -> OkResponse:
media.delete_avatar(user.avatar_url)
+18 -2
View File
@@ -35,6 +35,7 @@ from app.schemas.welfare import (
ExchangeResultOut,
TransferAuthResultOut,
TransferAuthStatusOut,
UnbindWechatRequest,
UnbindWechatResultOut,
WithdrawInfoOut,
WithdrawOrderOut,
@@ -151,9 +152,24 @@ def bind_wechat(req: BindWechatRequest, user: CurrentUser, db: DbSession) -> Bin
@router.post("/unbind-wechat", response_model=UnbindWechatResultOut, summary="解绑微信(清空 openid)")
def unbind_wechat(user: CurrentUser, db: DbSession) -> UnbindWechatResultOut:
def unbind_wechat(
user: CurrentUser, db: DbSession, req: UnbindWechatRequest | None = None
) -> UnbindWechatResultOut:
# 有「待审核(reviewing)」提现单时,首次解绑拦下要求确认:这类单还没打款,确认解绑(force)
# 时会被立即退回现金余额(refund_reviewing_withdraws_on_unbind),后台不再挂单,用户需先知情。
# (pending 单转账已在途、解绑影响不到,不拦;见 has_reviewing_withdraw。)
force = req.force if req is not None else False
if not force and crud_wallet.has_reviewing_withdraw(db, user.id):
logger.info("unbind wechat needs_confirm(有待审核提现) user_id=%d", user.id)
return UnbindWechatResultOut(
bound=bool(user.wechat_openid),
needs_confirm=True,
message="你有提现正在审核中,解绑微信后这笔会自动退回到现金余额。确定解绑吗?",
)
# 先把待审核提现立即退回现金(无则 no-op),再清 openid —— 让"解绑即退"名副其实
refunded = crud_wallet.refund_reviewing_withdraws_on_unbind(db, user.id)
crud_wallet.unbind_wechat_openid(db, user.id)
logger.info("unbind wechat ok user_id=%d", user.id)
logger.info("unbind wechat ok user_id=%d force=%s refunded_withdraws=%d", user.id, force, refunded)
return UnbindWechatResultOut(bound=False)
+33 -1
View File
@@ -9,11 +9,16 @@ from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
# 生产环境 JWT secret 的最小可接受长度(字节)。HS256 推荐高熵随机串;<16 视为弱密钥。
_MIN_PROD_SECRET_LEN = 16
# 已知的占位默认值(代码里写死的 default),prod 下绝不能沿用。
_INSECURE_SECRET_DEFAULTS = frozenset({"change-me", "change-me-admin", ""})
class Settings(BaseSettings):
model_config = SettingsConfigDict(
@@ -105,6 +110,10 @@ 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 退出。
AUTO_EXCHANGE_ENABLED: bool = True
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
@@ -194,6 +203,29 @@ class Settings(BaseSettings):
def is_prod(self) -> bool:
return self.APP_ENV == "prod"
@model_validator(mode="after")
def _enforce_prod_secrets(self) -> "Settings":
"""prod 下强校验 JWT secret,弱/默认/空即启动报错(fail-fast,挡住 token 被伪造)。
只校验两个签发凭证:App 用户的 JWT_SECRET_KEY、后台的 ADMIN_JWT_SECRET——它们沿用默认值
时任何人都能伪造 access/admin token → 账号与后台失陷。INTERNAL_API_SECRET 默认空 = 内部端点
关闭(返 503),是安全的默认态,故不在此强制。dev 不触发,便于本地直接起。
"""
if not self.is_prod:
return self
weak: list[str] = []
for name in ("JWT_SECRET_KEY", "ADMIN_JWT_SECRET"):
value = getattr(self, name)
if value in _INSECURE_SECRET_DEFAULTS or len(value) < _MIN_PROD_SECRET_LEN:
weak.append(name)
if weak:
raise ValueError(
f"APP_ENV=prod 但检测到弱/默认密钥: {', '.join(weak)} —— 必须改成 "
f"{_MIN_PROD_SECRET_LEN} 位高熵随机串(否则 JWT 可被伪造 → 用户/后台账号失陷)。"
f"生成示例: python -c \"import secrets; print(secrets.token_urlsafe(48))\""
)
return self
@lru_cache(maxsize=1)
def get_settings() -> Settings:
+3 -3
View File
@@ -14,9 +14,9 @@ from app.core import rewards as r
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int
CONFIG_DEFS: dict[str, dict[str, Any]] = {
"signin_rewards": {
"default": list(r.SIGNIN_REWARDS), "label": "签到 14 天金币档位",
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
"group": "签到", "type": "int_list",
"help": "第 1~14 天每天签到发的金币;断签重置回第 1 天。长度需为 14",
"help": "第 1~7 天每天签到发的金币;断签重置回第 1 天。长度需为 7",
},
"min_exchange_coin": {
"default": r.MIN_EXCHANGE_COIN, "label": "最低兑换金币",
@@ -63,6 +63,6 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"signin_boost_coin": {
"default": r.SIGNIN_BOOST_COIN, "label": "签到膨胀固定金币",
"group": "签到", "type": "int",
"help": "Day1-Day13 签到后看完激励视频额外发放的固定金币;Day14 不展示也不允许膨胀。",
"help": "Day1-Day6 签到后看完激励视频额外发放的固定金币;Day7 不展示也不允许膨胀。",
},
}
+74 -18
View File
@@ -17,11 +17,10 @@ def cn_today() -> date:
return datetime.now(CN_TZ).date()
# ===== 签到:14 天循环,断签重置回第 1 天 =====
# 第 1→14 天的金币奖励,签到逻辑用 cycle_day(1..14)索引。
# ===== 签到:7 天循环,断签重置回第 1 天(原型 2026-06 由 14 天改 7 天一轮)=====
# 第 1→7 天的金币奖励,签到逻辑用 cycle_day(1..7)索引;一轮 7 天累计 2500 金币(对原型 banner)
SIGNIN_REWARDS: tuple[int, ...] = (
200, 120, 150, 180, 200, 250, 500,
200, 250, 300, 350, 400, 500, 3000,
200, 200, 300, 200, 400, 400, 800,
)
SIGNIN_CYCLE_LEN: int = len(SIGNIN_REWARDS)
@@ -57,13 +56,40 @@ WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
TASK_ENABLE_NOTIFICATION = "enable_notification"
# task_key -> 奖励金币
# 打开消息提醒: 1000 金币(=¥0.1, 客户端原型展示口径; 量级与签到/里程碑相称)。
# 打开消息提醒: 750 金币(客户端原型展示口径 2026-06 调 1000→750; 量级与签到/里程碑相称)。
# 注意: 不再 = 兑换下限(下限已降到 MIN_EXCHANGE_COIN=COIN_PER_CENT=100), test_exchange_flow 改走 grant_coins 直接供款。
# ⚠️打开消息提醒是「可重复领取」任务(2026-06):每次开启通知发一次,金额逐次减半(见 notification_reward),
# 这里的 750 是首次(基数)。客户端只在「通知权限关闭」时展示该任务,开着就隐藏。
TASK_REWARDS: dict[str, int] = {
TASK_ENABLE_NOTIFICATION: 1000,
TASK_ENABLE_NOTIFICATION: 750,
}
def notification_reward(base: int, times_claimed: int) -> int:
"""打开消息提醒第 (times_claimed+1) 次领取的金币。
基数 base(=750)起,每次减半、四舍五入到整数、最低 1:
750 → 375 → 188 → 94 → 47 → 24 → 12 → 6 → 3 → 2 → 1 → 1 …
(四舍五入用 (x+1)//2,匹配产品给的 750/375/188/94 序列。)
"""
r = max(1, base)
for _ in range(max(0, times_claimed)):
r = max(1, (r + 1) // 2)
return r
def notification_max_claims(base: int) -> int:
"""打开消息提醒最多可领取的次数:减半到最低 1 那次为止(含),之后不再可领。
防止「通知一直关着」时无限刷最低档 1 金币(发放上限闸,服务端硬限,不依赖客户端只在
通知关闭时才展示的约定)。base=750 时序列 750→…→1 在第 11 次落到 1,故上限 11 次。
"""
n = 0
while notification_reward(base, n) > 1:
n += 1
return n + 1 # 含第一次减半到 1 的那一次
# ===== 比价战绩里程碑(累计成功比价 N 次,逐档解锁领金币)=====
# 第 1→6 次的金币奖励(1-based:第 N 次比价解锁第 N 档)。值沿用客户端原型档位。
# 数据源是 comparison_record 里 status='success' 的条数;每档领一次,
@@ -77,6 +103,12 @@ def record_milestone_reward(milestone: int) -> int:
return RECORD_MILESTONES[milestone - 1]
# ===== 上报更低价(人工审核通过发固定金币)=====
# 用户上报"某平台比我们算的最低价更便宜"+ 截图,经运营后台人工审核通过后发放的固定金币奖励。
# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。
PRICE_REPORT_REWARD_COINS: int = 1000
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
INVITE_INVITER_COINS: int = 10000
@@ -92,7 +124,11 @@ INVITE_FP_WINDOW_DAYS: int = 7
# ===== 看激励视频 / 信息流广告发金币 =====
# 金币数值体系约定:eCPM 单位按"元/千次展示"处理,单次收入 = eCPM / 1000 元。
# eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元!
# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元;
# 因子1 档位阈值按【元/千次】定(100/200/400 元 = ¥100/¥200/¥400 CPM,产品口径 2026-06-09)。
# 注:真实 eCPM 一般 <¥100 CPM,故多落最低档 0.1,高档基本不触发——这是产品有意的取舍。
# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次)。
AD_ECPM_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
(0.1, 0, 100),
(0.3, 101, 200),
@@ -107,9 +143,16 @@ AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
(1.0, 11, None),
)
# 客户端可影响的 eCPM 可信上限(分/千次展示):信息流广告一期由客户端上报 eCPM,伪造天价 eCPM
# 可铸出天量金币(见 calculate_ad_reward_coin)。真实 eCPM 一般 <¥100 CPM(=10000 分),档位表顶档
# 为 >¥400(=40000 分);取 ¥500 CPM=50000 分,留足真实头部余量又封死伪造值。钳在唯一计算口
# calculate_ad_reward_coin,故 feed 与 reward_video(回退客户端上报 eCPM 时)一并护住;阈值设在所有
# 真实值之上,不会少发正规奖励。
AD_ECPM_MAX_FEN: int = 50_000
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
"""解析 eCPM 原始值。当前产品口径:SDK 返回值按"元/千次展示"处理。"""
def parse_ecpm_fen(ecpm: str | int | float | None) -> float:
"""解析 eCPM 原始值(穿山甲 getEcpm 原值,单位=分/千次展示)。非法/缺失→0。"""
if ecpm is None:
return 0.0
try:
@@ -119,8 +162,14 @@ def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
return max(0.0, value)
def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
"""eCPM 转成元(getEcpm 原值是分,÷100)。因子档位判定与收益换算都用元。"""
return parse_ecpm_fen(ecpm) / 100.0
def ad_ecpm_factor(ecpm_yuan: float) -> float:
"""eCPM 档位因子:0-100=0.1,101-200=0.3,201-400=0.4,>400=0.6。"""
"""eCPM 档位因子(阈值单位=元/千次):≤100=0.1,101-200=0.3,201-400=0.4,>400=0.6。
产品口径(2026-06-09):阈值按元判档;真实 eCPM(<¥100 CPM)多落最低档 0.1。"""
if ecpm_yuan > 400:
return 0.6
if ecpm_yuan > 200:
@@ -130,26 +179,33 @@ def ad_ecpm_factor(ecpm_yuan: float) -> float:
return 0.1
def ad_lt_factor(today_count_after_this: int) -> float:
"""LT 因子。today_count_after_this 是当天累计第 N 条/份广告奖励"""
count = max(1, today_count_after_this)
def ad_lt_factor(count_after_this: int) -> float:
"""LT 因子。count_after_this 是该账号累计第 N 条/份看视频奖励(不按天重置)"""
count = max(1, count_after_this)
for factor, lo, hi in AD_LT_FACTOR_TABLE:
if count >= lo and (hi is None or count <= hi):
return factor
return 1.0
def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_this: int) -> int:
def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: int) -> int:
"""按金币数值体系计算单份广告奖励金币。
单次奖励(元)=eCPM/1000 × 因子1(eCPM 档) × 因子2(LT);再按 1 元=10000 金币取整
eCPM 是穿山甲 getEcpm 原值,单位【分/千次展示】;先 ÷100 转成元(因子判档 + 收益换算都用元)
单次收益(元)= eCPM元 ÷ 1000(每千次→单次) × 因子1(eCPM 元档) × 因子2(LT);
再按 1 元=10000 金币取整。count_after_this 为账号累计第 N 次看视频(LT 因子用,不按天重置)。
eCPM 在此先钳到 AD_ECPM_MAX_FEN(¥500 CPM):信息流广告一期 eCPM 由客户端上报,伪造天价值
会铸天量金币;钳在这唯一入口,feed 与 reward_video 回退客户端 eCPM 的路径都护住,且阈值高于
所有真实值,不影响正规发奖。
"""
ecpm_yuan = parse_ecpm_yuan(ecpm)
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this)
ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0)
yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this)
return max(0, round(yuan * COIN_PER_YUAN))
SIGNIN_BOOST_COIN: int = 2000
# 签到看广告膨胀:S2S 固定补发(原型 2026-06 由 2000 提到 3000,对应 CTA「看广告最高膨胀至3000金币」)。
SIGNIN_BOOST_COIN: int = 3000
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
+3 -1
View File
@@ -21,6 +21,7 @@ from app.api.v1.compare_milestone import router as compare_milestone_router
from app.api.v1.compare_record import router as compare_record_router
from app.api.v1.coupon import router as coupon_router
from app.api.internal.price import router as internal_price_router
from app.api.internal.store import router as internal_store_router
from app.api.v1.feedback import router as feedback_router
from app.api.v1.invite import router as invite_router
from app.api.v1.meituan import router as meituan_router
@@ -102,8 +103,9 @@ app.include_router(savings_router)
app.include_router(ad_router)
app.include_router(order_router)
app.include_router(report_router)
# 内部(server→server)端点:pricebot 上报价格观测,靠共享密钥头校验,不对客户端开放。
# 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。
app.include_router(internal_price_router)
app.include_router(internal_store_router)
app.include_router(platform_router)
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
+3
View File
@@ -9,18 +9,21 @@ from app.models.comparison import ComparisonRecord # noqa: F401
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
from app.models.coupon_state import ( # noqa: F401
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
)
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
from app.models.onboarding import OnboardingCompletion # noqa: F401
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
from app.models.ops_stat_config import OpsStatConfig # noqa: F401
from app.models.price_observation import PriceObservation # noqa: F401
from app.models.price_report import PriceReport # noqa: F401
from app.models.savings import SavingsRecord # noqa: F401
from app.models.signin import SigninBoostRecord, SigninRecord # noqa: F401
from app.models.store_mapping import StoreMapping # noqa: F401
from app.models.task import UserTask # noqa: F401
from app.models.user import User # noqa: F401
from app.models.wallet import ( # noqa: F401
+1 -1
View File
@@ -35,7 +35,7 @@ class AdEcpmRecord(Base):
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 实际展示用的代码位(底层 mediation rit,非客户端配置位)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 客户端上报的 eCPM 原始字符串(单位:/千次展示,原样存)
# 客户端上报的 eCPM 原始字符串(单位:/千次展示,SDK getEcpm 原值,原样存)
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
# 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较)
report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
+9 -1
View File
@@ -19,6 +19,7 @@ from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
@@ -39,6 +40,9 @@ class ComparisonRecord(Base):
__table_args__ = (
# 同一用户同一次比价(trace_id)只存一条:客户端重试/误点重复上报时幂等覆盖。
UniqueConstraint("user_id", "trace_id", name="uq_comparison_user_trace"),
# 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序;
# 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。
Index("ix_comparison_status_created", "status", "created_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -53,6 +57,10 @@ class ComparisonRecord(Base):
)
# pricebot 侧 trace_id:关联调试落盘 + 幂等去重键
trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
# 本次比价的公网调试链接(price.shaguabijia.com/traces/{dir}/)。pricebot done 帧给、
# 客户端上报带上——dir 名含 pricebot 落盘的时分秒,前端/server 都拼不出,必须存。
# 查看接口按 user.debug_trace_enabled 决定返不返回。旧记录 / 未开上云为 None。
trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
# ===== 源平台(发起比价的那家)=====
source_platform_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
@@ -86,7 +94,7 @@ class ComparisonRecord(Base):
# ===== 明细(JSON,越详细越好)=====
# 下单菜品 [{name, qty, specs?}]
items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名)
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name, applied_coupons}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名;applied_coupons=[{name,amount}] 多券明细)
comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 目标平台未找到、跳过的菜名
skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
+61 -8
View File
@@ -93,15 +93,22 @@ class CouponClaimRecord(Base):
)
class CouponPromptEngagement(Base):
"""按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。"""
class CouponDailyCompletion(Base):
"""按 (device, 自然日) 记"今天是否已跑完整轮领券(到 done 帧)"——首页置灰源。
__tablename__ = "coupon_prompt_engagement"
与 engagement 区别:engagement = 用户表达过意向(点了一键领取/拒绝即记,不管跑没跑完);
completion = 这一轮真跑到了 done(整套流程走完)。首页「去领取」卡据此置灰:跑完了
今天就不能再领。判断口径(用户决策 2026-06-10 A 方案):**到 done 即算**,不管单券
成败(失败/跳过常是无障碍/环境问题,重复点也补不回来)。判断维度 device_id,与
engagement/claim 一致。
"""
__tablename__ = "coupon_daily_completion"
__table_args__ = (
# 一台设备一天一条:今天 engage 过(领或拒)就不再弹
# 一台设备一天一条:今天跑完过就置灰
UniqueConstraint(
"device_id", "engage_date",
name="uq_coupon_engage_device_date",
"device_id", "complete_date",
name="uq_coupon_completion_device_date",
),
)
@@ -110,9 +117,55 @@ class CouponPromptEngagement(Base):
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# Asia/Shanghai 自然日。
complete_date: Mapped[date] = mapped_column(Date, nullable=False)
# 哪次任务跑到 done,回指 pricebot work_logs / 排查。
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<CouponDailyCompletion device={self.device_id} "
f"date={self.complete_date}>"
)
class CouponPromptEngagement(Base):
"""按 (device, **App**, 自然日) 记"今天这个 App 是否对领券引导窗表达过意向"——弹窗频控源。
2026-06-14:频控维度从 (device, 日) 改为 (device, package, 日)。需求是美团/淘宝/京东
各自独立——在美团弹过/领过,不影响淘宝、京东今天仍各弹一次。原来缺 package → 任一 App
弹过就把整台设备当天标记 engage,其余 App 被压住不弹(bug)。
"""
__tablename__ = "coupon_prompt_engagement"
__table_args__ = (
# 一台设备、一个 App、一天一条:今天**这个 App** engage 过(领或拒)才不再弹该 App。
UniqueConstraint(
"device_id", "package", "engage_date",
name="uq_coupon_engage_device_pkg_date",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
# 触发弹窗的目标 App 包名(com.sankuai.meituan / com.taobao.taobao / com.jingdong.app.mall)。
# 频控维度,各 App 独立。旧行(改造前)无此值 → 迁移用占位 "" 填,不影响新逻辑判断。
package: Mapped[str] = mapped_column(
String(64), nullable=False, server_default=""
)
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# Asia/Shanghai 自然日。
engage_date: Mapped[date] = mapped_column(Date, nullable=False)
# claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)。仅记录区分,
# 判断只看"今天有没有这条",type 不影响弹不弹。
# claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)/ shown(自动弹出即记)。
# 仅记录区分,判断只看"今天这个 App 有没有这条",type 不影响弹不弹。
engage_type: Mapped[str] = mapped_column(String(16), nullable=False)
created_at: Mapped[datetime] = mapped_column(
+3 -2
View File
@@ -1,7 +1,8 @@
"""用户反馈表(帮助与反馈)。
每条 = 用户一次提交。content 必填,contact 必填(微信/QQ/手机,便于回访),images 为可选的
截图 URL 列表(/media/feedback/...,JSON 存)。status: new(待处理)/ handled(已处理)。
每条 = 用户一次提交。content 必填;contact 原为必填(微信/QQ/手机),原型改版后客户端不再采集,
新数据存空串(列保持 NOT NULL,免迁移;历史数据仍有值);images 为可选的截图 URL 列表
(/media/feedback/...,JSON 存)。status: new(待处理)/ handled(已处理)。
"""
from __future__ import annotations
+40
View File
@@ -0,0 +1,40 @@
"""新手引导完成标记(按 设备 + 账号 去重)。
产品规则:同一台设备 + 同一个账号,新手引导只跑一次;卸载重装(同设备同账号)
不再触发。本地标记(SharedPreferences)卸载即丢,所以"完成"必须落后端,按
(user_id, device_id) 唯一一条。
⚠️ device_id 这里用客户端的**硬件级稳定标识**(Android `Settings.Secure.ANDROID_ID`),
同签名 app 卸载重装不变、仅恢复出厂才重置——区别于领券/比价用的 per-install device_id
(见 coupon_state,存 SP、重装会变)。换新设备 → 无此行 → 重新走引导。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class OnboardingCompletion(Base):
"""一台设备 + 一个账号 一条:走完新手引导即写入,登录时据此跳过引导。"""
__tablename__ = "onboarding_completion"
__table_args__ = (
# 同账号、同设备只一条:重复 mark / 并发提交靠它幂等。
UniqueConstraint("user_id", "device_id", name="uq_onboarding_user_device"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 登录态用户。引导是登录后才走的,故必填(非空)、进唯一键。
user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
# 硬件级稳定设备标识(ANDROID_ID),卸载重装不变。
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
completed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<OnboardingCompletion user={self.user_id} device={self.device_id}>"
+2 -1
View File
@@ -25,7 +25,8 @@ class OpsMarqueeSeed(Base):
__tablename__ = "ops_marquee_seed"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 脱敏用户名,如「用户********a52」;留空(NULL)→ feed 展示时按脱敏格式随机合成(避开撞名)
# 脱敏用户名(手机尾号 / 中文昵称风格,如「138****5678」「省钱**」);留空(NULL)或旧「用户****xxx」
# 模板名 → feed 展示时随机合成混合风格名(避开撞名、自愈历史种子)
masked_user: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 节省金额区间(分);feed 每次在 [min,max] 随机取一个值。固定金额则 min==max。客户端 ÷100 显示「x.xx 元」
min_cents: Mapped[int] = mapped_column(Integer, nullable=False)
+2 -1
View File
@@ -53,7 +53,8 @@ class OpsStatConfig(Base):
random_kind: Mapped[str] = mapped_column(String(8), nullable=False, default="mult")
random_step_min: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
random_step_max: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# real 模式基数偏移(基础单位;total_saved 为分):展示值 = 真实值 + 偏移(冷启动期既真实又体面)
# real 模式保底值(基础单位;total_saved 为分):展示值 = max(真实值, 保底值)。冷启动显示保底撑
# 场面,真实值涨过保底后显示纯真实、零差距。列名沿用 real_offset(语义已从"偏移"改为"保底")
real_offset: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 门面数字默认「只增不减」;运营明确要下调时才开此开关(real/manual 刷新都受护栏约束)
allow_decrease: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+120
View File
@@ -0,0 +1,120 @@
"""平台店铺表(store_mapping)—— 跨平台"同一家店"的 id/名 映射资产层。
每完成一次淘宝比价(在目标淘宝店通过 更多操作→分享→复制链接 拿到分享短链、
HTTP 解析出 shopId 后),pricebot server→server 内部上报落这里一行。**与登录无关、
不依赖客户端鉴权**(比价透传链路当前不鉴权,user_id 客户端带上时一并记)。
与 price_observation 的区别:
- price_observation:平台/门店视角的**价格事实**(某店这单多少钱)。
- store_mapping:平台/门店视角的**身份映射**(同一家物理店在 淘宝/美团/京东 各自的
店铺 id 与店名)。是未来"我见过这家店→跳过重新搜索/匹配"的源头。两表独立。
「先存下来、用法后说」:列尽量铺全(各平台 id/名 + 地理 + 溯源 + 淘宝/美团原料 URL),
attrs(JSONB)兜底存灵活明细,免得每多记一个字段就迁移 schema。
⚠️ 数据质量:跨平台"同一家店"的连接来自 agent 的 LLM 店铺匹配,匹配错则一行里连错店。
本表是 append-only 原始记录(每比价一行、trace_id 幂等防重试重复),清洗/归一二期再做。
已接通**淘宝**(id_taobao=shopId)、**美团**(meituan_poi_id_str,非稳定主键、单列存)、
**京东**(id_jd=storeId + jd_vender_id,均稳定数字主键;3.cn 短链反查)。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
JSON,
DateTime,
Float,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
# PG 上用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 price_observation / comparison_record)。
_JSON = JSON().with_variant(JSONB(), "postgresql")
class StoreMapping(Base):
__tablename__ = "store_mapping"
__table_args__ = (
# 一次比价(trace)只记一条:pricebot 重试 / 客户端 replay 重复上报时幂等去重。
# 一次淘宝比价 = 一个目标淘宝店 → 一行映射(源 + 各平台身份压在同一行)。
UniqueConstraint("trace_id", name="uq_store_mapping_trace"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# ===== 跨平台店铺身份(同一家物理店在各平台的 id/名;按比价角色稀疏填充)=====
# id_taobao = 分享短链解析出的 shopId(淘宝当目标、走完取 id 流程才有);
# name_taobao = 店铺页 a11y content_desc "店铺标题:xxx" 剥前缀。
id_taobao: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
name_taobao: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 美团:无同款 share→稳定id 机制(poi_id_str 每次变,见下方 meituan_poi_id_str),id_meituan
# 留给将来 CPS API 的稳定 poi_id;name-only 时仅 name_meituan(源平台店名来自 intent/agent 匹配名)。
id_meituan: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
name_meituan: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 京东:已接通 share→id(3.cn 短链反查)。id_jd = storeId(门店稳定数字主键,同 taobao shopId→
# id_taobao);venderId(deeplink 还需)单列存 jd_vender_id。name_jd = 店铺页店名。
id_jd: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
name_jd: Mapped[str | None] = mapped_column(String(128), nullable=True)
# ===== 地理(同名店异地区分 / 地理分桶匹配的主要燃料)=====
city: Mapped[str | None] = mapped_column(String(64), nullable=True)
geohash: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
lng: Mapped[float | None] = mapped_column(Float, nullable=True)
lat: Mapped[float | None] = mapped_column(Float, nullable=True)
# 淘宝门店地址(店铺页 a11y 抓到才有;比经纬度更利于人工/LLM 匹配)
taobao_address: Mapped[str | None] = mapped_column(String(256), nullable=True)
# ===== 溯源 / 用户画像 =====
# 源平台(发起比价那家:meituan / taobao_flash / jd_waimai ...)
source_platform: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True)
business_type: Mapped[str] = mapped_column(String(16), nullable=False, default="food")
# pricebot 侧 trace_id:回指原始 trace(溯源)+ 幂等去重键(uq_store_mapping_trace)
trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
source_device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# user_id 当前比价链路不鉴权拿不到,客户端带上时才有;先可空。
source_user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# ===== 淘宝原料(可复跳 / 可重解析 / 调试;URL 可能很长 → Text)=====
taobao_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # m.tb.cn 短链
taobao_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 解析出的目标 URL(含 shopId)
taobao_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 et-store/search deeplink
# 淘宝 deeplink 失效标记:比价撞"页面出错了"降级页时被置(pricebot server→server invalidate),
# NULL=有效。lookup 反查过滤掉非 NULL 的淘宝候选,不再返回坏 deeplink(重搜会写新行覆盖)。
taobao_deeplink_invalid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# ===== 美团原料(同淘宝;dpurl.cn 短链 → 302 反查 poi_id_str → imeituan:// deeplink)=====
# ⚠️ poi_id_str 每次分享重新加密、非稳定主键(调研文档 §八), 故单列存"可复跳的一次性票据",
# 不进 id_meituan —— 后者留给将来 CPS API 拿到的稳定数字 poi_id。
meituan_poi_id_str: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
meituan_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # dpurl.cn 短链
meituan_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 302 落地 menu URL(含 poi_id_str)
meituan_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 imeituan:// 店内搜索 deeplink
# ===== 京东原料(秒送;3.cn 短链 → 跟随重定向反查 venderId+storeId → openapp.jdmobile:// deeplink)=====
# storeId 进 id_jd(稳定店主键);venderId 单列存(deeplink 模板 venderId+storeId 都要,且 venderId
# 是商家维度、可跨门店,与门店 storeId 分开记)。其余三列与 taobao_*/meituan_* 平行。
jd_vender_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
jd_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # 3.cn 短链
jd_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 反查出的目标 openapp.jdmobile:// deeplink
jd_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 pages/search 店内搜索 deeplink
# 灵活字段兜底(免得加字段就迁移)
attrs: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<StoreMapping id={self.id} taobao=({self.id_taobao!r},{self.name_taobao!r}) "
f"source={self.source_platform} trace_id={self.trace_id}>"
)
+8 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy import Boolean, DateTime, Integer, String, false, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
@@ -51,6 +51,13 @@ class User(Base):
# 账号状态:active / disabled / deleted
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
# 调试链接权限:开了的用户在比价完成弹窗 + 比价记录页能看到「复制调试链接」按钮
# (复制 price.shaguabijia.com 的 trace 链接发给开发排障)。运营后台按用户配置;
# /me 与登录响应里带出给前端做条件渲染。默认 false。
debug_trace_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=false()
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
+15
View File
@@ -41,6 +41,21 @@ class CoinAccount(Base):
class CoinTransaction(Base):
__tablename__ = "coin_transaction"
__table_args__ = (
# 可重复任务(如 task_enable_notification)按 ref_id 序号(task_key:N)去重,挡并发/连点
# 重复发放——读-算-写无锁,两个并发 claim 会都算出同一序号双倍发钱(见 task.claim_task)。
# 仅 task_* 且 ref_id 非空生效;一次性任务 ref_id=task_key 本就每人一条,纳入无害;
# 不影响 signin / exchange / withdraw 等其它 biz_type。
Index(
"ux_coin_transaction_task_ref",
"user_id",
"biz_type",
"ref_id",
unique=True,
sqlite_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
postgresql_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
+15 -6
View File
@@ -16,6 +16,10 @@ from app.repositories import wallet as crud_wallet
FEED_REWARD_UNIT_SECONDS = 10
# 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数
# (每 10 秒 1 份)。真实单条信息流视频远小于此;取 120s=12 份封顶,挡刷量、不影响正规单。
# 与 rewards.AD_ECPM_MAX_FEN(eCPM 钳顶)合起来,把单事件可铸金币锁进有限区间。
FEED_MAX_DURATION_SECONDS = 120
def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | None:
@@ -38,15 +42,14 @@ 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, today: str) -> int:
"""按每个 10 秒单位逐份计算奖励,LT 使用当天累计奖励份序号"""
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.reward_date == today,
AdFeedRewardRecord.status == "granted",
)
).scalar_one()
@@ -67,13 +70,19 @@ def grant_feed_reward(
adn: str | None = None,
slot_id: str | None = None,
) -> AdFeedRewardRecord:
"""完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。"""
"""完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。
一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS
限单事件份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN 限单份金额;
叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。
"""
existing = _find_by_event(db, client_event_id)
if existing is not None:
return existing
today = cn_today().isoformat()
safe_duration = max(0, min(duration_seconds, 24 * 60 * 60))
# 客户端上报时长先钳到 FEED_MAX_DURATION_SECONDS,防伪造超长时长刷份数(见常量注释)。
safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS))
unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS
if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
@@ -92,7 +101,7 @@ def grant_feed_reward(
)
return _commit_record(db, rec, client_event_id)
coin = _unit_reward_total(db, user_id, ecpm, unit_count, today)
coin = _unit_reward_total(db, user_id, ecpm, unit_count)
if coin > 0:
crud_wallet.grant_coins(
db, user_id, coin,
+17 -1
View File
@@ -54,6 +54,20 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
).scalar_one()
def _granted_cumulative(db: Session, user_id: int) -> int:
"""账号累计已发奖的看视频次数(不按天重置)——LT 因子(因子2)用它定"第 N 次广告"
与 _granted_today 区别仅在去掉 reward_date 过滤;每日次数上限/冷却仍按当日统计。"""
return db.execute(
select(func.count())
.select_from(AdRewardRecord)
.where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.status == "granted",
AdRewardRecord.reward_scene == "reward_video",
)
).scalar_one()
def grant_ad_reward(
db: Session,
user_id: int,
@@ -104,7 +118,9 @@ def grant_ad_reward(
)
return _commit_record(db, rec, trans_id)
coin = rewards.calculate_ad_reward_coin(ecpm_raw, _granted_today(db, user_id, today) + 1)
# LT 因子(因子2)按"账号累计第 N 次看广告"递减(2.0→1.0),不再按天重置;
# 每日次数上限/冷却仍按当日统计(over_count 用 _granted_today)。
coin = rewards.calculate_ad_reward_coin(ecpm_raw, _granted_cumulative(db, user_id) + 1)
# 发金币 + 记一笔,同事务
crud_wallet.grant_coins(
+1
View File
@@ -84,6 +84,7 @@ def upsert_record(
source_package=payload.source_package,
information=payload.information,
best_deeplink=payload.best_deeplink,
trace_url=payload.trace_url,
total_dish_count=payload.total_dish_count,
skipped_dish_count=payload.skipped_dish_count,
items=[it.model_dump(exclude_none=True) for it in payload.items],
+78 -12
View File
@@ -13,7 +13,11 @@ from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.coupon_state import CouponClaimRecord, CouponPromptEngagement
from app.models.coupon_state import (
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
)
logger = logging.getLogger("shagua.coupon_state")
@@ -27,11 +31,13 @@ def today_cn() -> date:
# ===== 弹窗频控(coupon_prompt_engagement)=====
def has_engaged_today(db: Session, device_id: str) -> bool:
"""这台设备今天是否已对领券引导窗表达过意向(领或拒)。有 = 不再弹。"""
def has_engaged_today(db: Session, device_id: str, package: str) -> bool:
"""这台设备今天**这个 App** 是否已对领券引导窗表达过意向(领/拒/弹出)。有 = 该 App 不再弹。
频控按 (device, package, 日):美团弹过不影响淘宝/京东今天各自仍弹一次。"""
row = db.execute(
select(CouponPromptEngagement.id).where(
CouponPromptEngagement.device_id == device_id,
CouponPromptEngagement.package == package,
CouponPromptEngagement.engage_date == today_cn(),
)
).first()
@@ -39,13 +45,18 @@ def has_engaged_today(db: Session, device_id: str) -> bool:
def mark_engagement(
db: Session, device_id: str, user_id: int | None, engage_type: str
db: Session, device_id: str, package: str, user_id: int | None, engage_type: str
) -> None:
"""记今日意向(claim_started / dismissed)。(device, 今天) 唯一,幂等 upsert。"""
"""记今日意向(shown / claim_started / dismissed)。(device, package, 今天) 唯一,幂等 upsert。
engage_type 升级口径(同一 (device,package,日) 多次调,只覆盖 type,不新增行):
shown(自动弹出)→ claim_started(点一键领取)/ dismissed(点关闭)。判断只看"有没有这条"
"""
today = today_cn()
row = db.execute(
select(CouponPromptEngagement).where(
CouponPromptEngagement.device_id == device_id,
CouponPromptEngagement.package == package,
CouponPromptEngagement.engage_date == today,
)
).scalar_one_or_none()
@@ -55,23 +66,78 @@ def mark_engagement(
row.user_id = user_id
else:
db.add(CouponPromptEngagement(
device_id=device_id, user_id=user_id,
device_id=device_id, package=package, user_id=user_id,
engage_date=today, engage_type=engage_type,
))
try:
db.commit()
except IntegrityError:
# 并发下另一请求刚插了同 (device, package, 日) → 唯一约束撞,回滚忽略(本就幂等)。
db.rollback()
def reset_today_engagement(db: Session, device_id: str) -> int:
"""删这台设备今天**所有 App** 的 engagement(开发设置「重置今日领券弹窗状态」调,测频控用)。
删后各 App has_engaged_today → false,今天又都能弹。返回删除行数。
(不按 package 过滤:重置是"把今天清干净从头测",清全部 App 最符合预期。)"""
result = db.execute(
delete(CouponPromptEngagement).where(
CouponPromptEngagement.device_id == device_id,
CouponPromptEngagement.engage_date == today_cn(),
)
)
db.commit()
return result.rowcount or 0
# ===== 今日跑完整轮(coupon_daily_completion)=====
def has_completed_today(db: Session, device_id: str) -> bool:
"""这台设备今天是否已跑完整轮领券(到 done 帧)。有 = 首页置灰、不能再领。"""
row = db.execute(
select(CouponDailyCompletion.id).where(
CouponDailyCompletion.device_id == device_id,
CouponDailyCompletion.complete_date == today_cn(),
)
).first()
return row is not None
def mark_completed_today(
db: Session, device_id: str, user_id: int | None, trace_id: str | None = None
) -> None:
"""记今日已跑完整轮。(device, 今天) 唯一,幂等 upsert。到 done 即记,不管单券成败。"""
today = today_cn()
row = db.execute(
select(CouponDailyCompletion).where(
CouponDailyCompletion.device_id == device_id,
CouponDailyCompletion.complete_date == today,
)
).scalar_one_or_none()
if row is not None:
if user_id is not None:
row.user_id = user_id
if trace_id is not None:
row.trace_id = trace_id
else:
db.add(CouponDailyCompletion(
device_id=device_id, user_id=user_id,
complete_date=today, trace_id=trace_id,
))
try:
db.commit()
except IntegrityError:
# 并发下另一请求刚插了同 (device, 日) → 唯一约束撞,回滚忽略(本就幂等)。
db.rollback()
def reset_today_engagement(db: Session, device_id: str) -> int:
"""删这台设备今天的 engagement(开发设置「重置今日领券弹窗状态」调,测频控用)。
删后 has_engaged_today → false,今天又能弹。返回删除行数。"""
def reset_today_completion(db: Session, device_id: str) -> int:
"""删这台设备今天的"已完成"记录(开发设置「重置今日领券弹窗状态」全重置时调)。
删后 has_completed_today → false,首页「去领取」卡恢复可点。返回删除行数。"""
result = db.execute(
delete(CouponPromptEngagement).where(
CouponPromptEngagement.device_id == device_id,
CouponPromptEngagement.engage_date == today_cn(),
delete(CouponDailyCompletion).where(
CouponDailyCompletion.device_id == device_id,
CouponDailyCompletion.complete_date == today_cn(),
)
)
db.commit()
+35
View File
@@ -0,0 +1,35 @@
"""新手引导完成标记的读写(按 user_id + device_id 去重)。
登录时 [is_completed] 判断该 (账号, 设备) 是否走过引导;走完引导时 [mark_completed] 写一条。
device_id 为空(老客户端 / 取不到 ANDROID_ID)一律按"未完成"处理,即照常走引导,不误跳过。
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.onboarding import OnboardingCompletion
def is_completed(db: Session, *, user_id: int, device_id: str) -> bool:
"""该 (账号, 设备) 是否已完成新手引导。device_id 为空 → 一律 False。"""
if not device_id:
return False
stmt = select(OnboardingCompletion.id).where(
OnboardingCompletion.user_id == user_id,
OnboardingCompletion.device_id == device_id,
)
return db.execute(stmt).first() is not None
def mark_completed(db: Session, *, user_id: int, device_id: str) -> None:
"""走完引导时写入。device_id 为空忽略;同 (账号, 设备) 已存在则幂等(靠唯一约束兜并发)。"""
if not device_id:
return
db.add(OnboardingCompletion(user_id=user_id, device_id=device_id))
try:
db.commit()
except IntegrityError:
# 并发 / 重复提交撞唯一约束:已有行即视为成功。
db.rollback()
+224 -48
View File
@@ -5,51 +5,150 @@ feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条
种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、
feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。
脱敏名规则(全 feed 统一,按字符算、中英文皆适用):
- 有昵称 → 昵称脱敏:≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 / SaveKing→S******g),
2 字「首+星」(阿强→阿*),1 字「用户+该字」(喵→用户喵);
- 没昵称 → 合成一个假名(见下)再脱敏,少数露「用户+id 后 3 位」(用户********618)。
真实条:设过昵称的按真实昵称脱敏;当前多数用户没设昵称,直接展示「用户****」会清一色,故给它们合成
假名——后期真实昵称多了,真实条会直接用真实昵称,合成占比自然下降。种子 / 兜底同样合成、避开同屏撞名;
种子留空 / 旧「用户****xxx」模板名一律重新合成(自愈历史种子,无需迁移)。
假名合成:纯本地语料**组合生成**(中文姓池×名字池 拼真名 / 中文网络昵称 / 英文昵称 / 英文名带数字尾),
组合空间上万、中文为主英文为辅,脱敏后像真实异质用户群。**真实条按 `user_id` 播种确定性合成**(同一用户
每次展示恒定、不同用户各异),刷新/翻页不变脸;种子 / 兜底用运行时随机源出多样填充。不依赖外部库。
"""
from __future__ import annotations
import hashlib
import random
from datetime import datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy import delete, func, select, update
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.comparison import ComparisonRecord
from app.models.ops_marquee_seed import OpsMarqueeSeed
from app.models.user import User
# feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」)
_rng = random.Random()
_SUFFIX_HEX = 3 # 脱敏用户名后缀位数,与真实条 md5[:3] 对齐
_HEX = "0123456789abcdef"
# 真实条金额上限(300 元):超过视为异常 / bug 值,不进轮播(防「节省 999 元」穿帮)
_REAL_MAX_CENTS = 30000
# 种子金额防呆上限(1000 元):运营手滑填超大值时拦下
_SEED_MAX_CENTS = 100000
# 真实记录查询缓存:首页人人都看、真实数据变化慢 → 缓存原始行 ~30s,省掉高频 DB 往返。
# 展示层随机(抽样/金额/时间/名字合成)仍每次重算,缓存只省查询;新记录最多晚 30s 进轮播,可接受。
_REAL_ROWS_TTL_SECONDS = 30
_REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用
_real_rows_cache: dict = {"at": None, "rows": None}
def _mask_user(user_id: int) -> str:
"""真实用户脱敏:用户********+3 位哈希后缀(稳定、不暴露真实信息)。"""
suffix = hashlib.md5(str(user_id).encode()).hexdigest()[:_SUFFIX_HEX]
return f"用户********{suffix}"
# ===== 用户标识脱敏 + 没真实昵称时的假名合成 =====
# 脱敏规则(中英文皆适用,按字符算):有昵称→≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 /
# SaveKing→S******g)、2 字「首+星」(阿强→阿*)、1 字「用户+该字」(喵→用户喵);没昵称→用户+8星+id 后 3 位。
# 没真实昵称时(当前多数用户 / 种子 / 兜底)合成假名:本地姓/名字池组合出真名 + 网络昵称语料(中/英)
# 混播再走同一套脱敏,像真实异质用户群。后期真实昵称多了,真实条直接用真实昵称,这里占比自然下降。
_ANON_STARS = "*" * 8 # 无昵称匿名串固定 8 星(用户********xxx)
# 中文真名组合池:姓(百家姓常见 ~100)× 名字单字(~120)拼「姓 + 1~2 字」,组合空间上万。
# 脱敏后只露「姓*」或「姓*末」,故无需真实姓名库——组合够多即看着各异(替代原 Faker zh_CN)。
_SURNAME_ZH = list(
"王李张刘陈杨赵黄周吴徐孙胡朱高林何郭马罗梁宋郑谢韩唐冯于董萧程曹袁邓许傅沈曾彭吕苏卢蒋蔡贾丁"
"魏薛叶阎余潘杜戴夏钟汪田任姜范方石姚谭廖邹熊金陆郝孔白崔康毛邱秦江史顾侯邵孟龙万段钱汤尹黎"
"易常武乔贺赖龚文施洪丰房邢"
)
_GIVEN_ZH = list(
"伟芳娜秀英敏静丽强磊军洋勇艳杰娟涛明超霞平刚桂兰华健世广义兴良海山仁波宁贵福生龙元全国胜学祥"
"才发新利清飞彬富顺信子昌成康星光天达安岩中茂进有坚和彪博诚先敬震振壮会思群豪心邦承乐绍功松善"
"厚庆民友裕河哲亮政谦亨奇之轮翰朗伯宏言若鸣朋斌栋维启克伦翔旭鹏泽晨辰士以建家致树炎德行时泰盛雄"
)
# 英文名池:与英文昵称互补,偶尔带数字尾(jay87)更像注册用户名(替代原 Faker en_US)。
_FIRST_EN = [
"Alex", "Chris", "Sam", "Jamie", "Taylor", "Jordan", "Casey", "Morgan", "Riley", "Jessie",
"Robin", "Drew", "Lee", "Max", "Kim", "Ray", "Dana", "Pat", "Terry", "Jay",
"Kelly", "Sky", "Ash", "Nico", "Remy", "Quinn", "Reese", "Sage", "Toni", "Val",
]
# 网络昵称语料:补一份「省钱小能手 / SaveKing」式网络昵称更像真实 app 用户。想更丰富往里加即可。
_NICK_ZH = [
"省钱小能手", "薅羊毛专业户", "吃货一枚", "干饭人", "奶茶续命", "精打细算过日子",
"会过日子的人", "捡漏王", "外卖救星", "养生少年", "持家有道", "比价狂魔",
"月光族逆袭", "摸鱼达人", "暴富锦鲤", "美食猎人", "折扣猎手", "隐形贫困人口",
"打工不打烊", "佛系青年", "元气满满", "一只小馋猫", "爱吃的小朋友", "省钱才是硬道理",
]
_NICK_EN = [
"SaveKing", "DealHunter", "FoodieLife", "BudgetBoss", "CouponQueen", "ThriftyOne",
"SnackLover", "BargainHero", "PennyWise", "SmartShopper", "DiscountNinja", "HungryPanda",
"MoneyMaster", "CheapEats", "SaverPro", "FrugalFox", "NoodleKing", "BubbleTeaFan",
"GoldenSaver", "ValueHunter", "SnackAttack", "LazyFoodie",
]
def _random_suffix(used: set[str]) -> str:
"""随机 3 位 hex 后缀,避开 used(真实条 + 已生成种子),防同屏撞名。"""
def _mask_nickname(nick: str) -> str:
"""昵称脱敏(按字符,中英文皆可):≥3 字→首+(隐藏字数个)星+末(省钱小能手→省***手 / SaveKing→S******g);
2 字→首+星(阿强→阿*);1 字→「用户」+该字(喵→用户喵)。调用方需保证 nick 已 strip 且非空。"""
n = len(nick)
if n >= 3:
return nick[0] + "*" * (n - 2) + nick[-1]
if n == 2:
return nick[0] + "*"
return "用户" + nick
def _anon_by_id(user_id: int) -> str:
"""没昵称匿名:固定「用户」+ 8 星 + id 后 3 位(不足 3 位前补 0):用户********618。"""
return "用户" + _ANON_STARS + f"{user_id % 1000:03d}"
def _synth_full_name(rng: random.Random) -> str:
"""合成一个完整(未脱敏)假名:中文真名(姓+1~2字) / 中文网络昵称 / 英文昵称 / 英文名(可带数字尾) 混播,
中文为主(~70%)英文为辅(~30%)。**全程用传入 rng** → 同种子复现、不同种子各异、组合空间上万。"""
r = rng.random()
if r < 0.35:
# 中文真名:姓 + 1~2 个名字字(60% 概率两字),脱敏后露「姓*」或「姓*末」
given = rng.choice(_GIVEN_ZH) + (rng.choice(_GIVEN_ZH) if rng.random() < 0.6 else "")
return rng.choice(_SURNAME_ZH) + given # 王伟 / 李秀兰
if r < 0.70:
return rng.choice(_NICK_ZH) # 中文网络昵称:省钱小能手
if r < 0.85:
return rng.choice(_NICK_EN) # 英文昵称:SaveKing
base = rng.choice(_FIRST_EN) # 英文名,半数带数字尾:Jay87 / Alex
return base + (str(rng.randint(1, 99)) if rng.random() < 0.5 else "")
def _synth_masked_name(rng: random.Random) -> str:
"""合成一条「已脱敏」假名(供没真实昵称的真实用户 / 种子 / 兜底用)。用传入 rng 决定一切随机。"""
full = _synth_full_name(rng).strip()
return _mask_nickname(full) if full else "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}"
def _mask_real(nickname: str | None, user_id: int) -> str:
"""真实用户脱敏:设过昵称→昵称脱敏(中英文皆可);没昵称→**按 user_id 播种确定性合成假名**再脱敏
(同一用户每次展示恒定、刷新不变脸,不同用户各异),避免无昵称用户清一色「用户****xxx」;
少数(~15%)露「用户+真实 id 后 3 位」。当前多数用户没昵称,后期真实昵称多了占比下降。"""
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)
def _synth_name(rng: random.Random) -> str:
"""合成一条脱敏名(种子留空 / 旧模板名 / 兜底用):绝大多数=假名脱敏,少数=用户+随机 3 位(用户********618)。"""
if rng.random() < 0.15:
return "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}"
return _synth_masked_name(rng)
def _unique_name(used: set[str]) -> str:
"""随机合成一个不与 used 撞的脱敏名(种子留空 / 旧模板名 / 兜底用),防同屏撞名。"""
for _ in range(60):
s = "".join(_rng.choice(_HEX) for _ in range(_SUFFIX_HEX))
if s not in used:
used.add(s)
return s
# 兜底(几乎不会到):线性扫一个空位
for n in range(16 ** _SUFFIX_HEX):
s = format(n, f"0{_SUFFIX_HEX}x")
if s not in used:
used.add(s)
return s
return "000"
n = _synth_name(_rng)
if n not in used:
return n
return "用户" + _ANON_STARS + f"{_rng.randint(0, 999):03d}" # 兜底:数字尾号天然好去重
def _validate_amount(min_cents: int, max_cents: int) -> None:
@@ -61,37 +160,69 @@ def _validate_amount(min_cents: int, max_cents: int) -> None:
raise ValueError(f"金额上限过大(≤ {_SEED_MAX_CENTS // 100} 元)")
# ===== 用户侧:轮播 feed(真实 + 种子混播) =====
def get_feed(db: Session, limit: int = 8) -> list[dict]:
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}
真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。
不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出;
种子用户名留空则随机合成(避开撞名),金额在 [min,max] 区间随机取值。
展示时间统一「刷新」成相对现在的最近时刻(从 now 往前递减),保证轮播永远像刚发生
(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时/未来时间)。
def _skewed_amount(lo: int, hi: int) -> int:
"""长尾金额:多数贴近下限、偶尔接近上限(比均匀随机更像真实省钱分布)。
用 u**2.2 把 [0,1) 均匀值压向 0 → 小额居多;偶尔 u 接近 1 给出大额
"""
# 真实条:取较多近期记录后按 user 去重;金额超上限的异常值剔除(防穿帮)。
if hi <= lo:
return max(0, lo)
u = _rng.random() ** 2.2
return lo + int(round((hi - lo) * u))
# 兜底金额区间(真实 + 种子都凑不满 limit 时,内置合成条用):3~35 元
_FALLBACK_MIN_CENTS = 300
_FALLBACK_MAX_CENTS = 3500
# ===== 用户侧:轮播 feed(真实 + 种子混播) =====
def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
"""近期 success 且省到钱的 (user_id, saved_cents, nickname),按 created_at desc;带 ~30s 进程内缓存。
join user 取真实昵称用于脱敏(无昵称则展示层走「用户+id 后 3 位」)。
返回纯元组(脱离 session),可安全跨请求复用。极端并发下偶尔多查一次(无锁、幂等),纯门面无副作用。
"""
now = datetime.now(CN_TZ)
cached, at = _real_rows_cache["rows"], _real_rows_cache["at"]
if cached is not None and at is not None and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS:
return cached
rows = db.execute(
select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents)
select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents, User.nickname)
.join(User, User.id == ComparisonRecord.user_id)
.where(
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0,
ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS,
)
.order_by(ComparisonRecord.created_at.desc())
.limit(max(limit * 20, 100))
.limit(_REAL_ROWS_FETCH_CAP)
).all()
out = [(int(uid), int(sc), nick) for uid, sc, nick in rows]
_real_rows_cache["rows"], _real_rows_cache["at"] = out, now
return out
def get_feed(db: Session, limit: int = 8) -> list[dict]:
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。
不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出;
种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多、偶尔大额,更像真实分布)。
真实 + 种子仍不满 limit → 内置合成条**补满**,保证轮播既不空也不稀疏。
展示时间统一「刷新」成相对现在的最近时刻(从 now 往前**随机抖动**递减),保证轮播永远像刚发生、
节奏自然不机械(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时时间)。
"""
# 真实条:取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。
rows = _recent_real_rows(db)
items: list[dict] = []
used_suffixes: set[str] = set()
used_names: set[str] = set()
seen_users: set[int] = set()
for uid, sc in rows:
for uid, sc, nick in rows:
if uid in seen_users:
continue
seen_users.add(uid)
name = _mask_user(uid)
used_suffixes.add(name[-_SUFFIX_HEX:])
name = _mask_real(nick, uid)
used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
if len(items) >= limit:
break
@@ -105,22 +236,41 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]:
chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds)
_rng.shuffle(chosen)
for s in chosen:
# 用户名:填了固定名优先;留空则按脱敏格式随机合成(避开撞名)。
if s.masked_user and s.masked_user.strip():
name = s.masked_user.strip()
used_suffixes.add(name[-_SUFFIX_HEX:])
else:
name = f"用户********{_random_suffix(used_suffixes)}"
# 金额:在 [min,max] 随机一个(固定金额则 min==max)。
# 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子);
# 仅运营手动设的非模板真名才原样用。
fixed = (s.masked_user or "").strip()
name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names)
used_names.add(name)
# 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。
lo = max(0, int(s.min_cents))
hi = max(lo, int(s.max_cents))
cents = lo if lo >= hi else _rng.randint(lo, hi)
items.append({"masked_user": name, "saved_amount_cents": cents})
items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)})
# 统一赋「最近」时间:首条约 20 秒前,其余每条往前 2~6 分钟,降序、节奏自然。
# 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满,
# 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。
while len(items) < limit:
name = _unique_name(used_names)
used_names.add(name)
items.append({
"masked_user": name,
"saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS),
})
# 统一赋「最近」时间:从 now 往前**随机抖动**递减,避免固定节奏被看出规律。
# 首条几十秒前;其余多数 1~5 分钟,偶尔扎堆(20~55s)或较长(5~9 分钟),降序、像真实流水。
cursor = datetime.now(CN_TZ)
for i, it in enumerate(items):
cursor = cursor - timedelta(seconds=20 if i == 0 else 60 * (2 + (i * 3) % 5))
if i == 0:
gap = _rng.randint(8, 40)
else:
r = _rng.random()
if r < 0.2:
gap = _rng.randint(20, 55) # 偶尔扎堆
elif r < 0.85:
gap = _rng.randint(60, 300) # 常规 1~5 分钟
else:
gap = _rng.randint(300, 540) # 偶尔较长 5~9 分钟
cursor = cursor - timedelta(seconds=gap)
it["time"] = cursor.strftime("%H:%M:%S")
return items
@@ -244,3 +394,29 @@ def delete_seed(db: Session, seed_id: int, *, commit: bool = True) -> bool:
else:
db.flush()
return True
def batch_delete(db: Session, ids: list[int], *, commit: bool = True) -> int:
"""批量删除选中的种子,返回实际删除条数(不存在的 id 自动跳过)。"""
if not ids:
return 0
n = db.execute(delete(OpsMarqueeSeed).where(OpsMarqueeSeed.id.in_(ids))).rowcount
if commit:
db.commit()
else:
db.flush()
return int(n or 0)
def batch_set_enabled(db: Session, ids: list[int], enabled: bool, *, commit: bool = True) -> int:
"""批量启用 / 停用选中的种子,返回实际更新条数。"""
if not ids:
return 0
n = db.execute(
update(OpsMarqueeSeed).where(OpsMarqueeSeed.id.in_(ids)).values(enabled=enabled)
).rowcount
if commit:
db.commit()
else:
db.flush()
return int(n or 0)
+9 -5
View File
@@ -7,7 +7,8 @@ random 自增长支持两种方式(random_kind):
- mult:×随机倍率([min,max]/1000,恒 ≥1.0)——复利、指数增长
- add :+随机绝对增量([step_min,step_max])——线性、可控,更像真实平台日增
真实值(real)模式展示 = 真实统计 + real_offset(基数偏移),冷启动期既真实又体面。
真实值(real)模式展示 = max(真实统计, real_offset 保底值):冷启动显示保底撑场面,真实值涨过
保底后显示纯真实、零差距(real_offset 列名沿用,语义从"偏移"改为"保底")。
「只增不减」护栏(allow_decrease=False,默认):real/manual 刷新时若新目标值低于当前展示值,
保持当前值不回退——门面数字最忌当众缩水。random 天然只增(倍率≥1 / 增量≥0)。
@@ -116,8 +117,9 @@ def _current_target(db: Session, row: OpsStatConfig) -> int:
if row.mode == "manual":
return max(0, int(row.manual_value or 0))
if row.mode == "real":
# 真实值 + 基数偏移
return max(0, int(_real_value(db, row.metric)) + int(row.real_offset or 0))
# 真实值保底:展示 = max(真实值, 保底值)。冷启动真实值小→显示保底撑场面;
# 真实值涨过保底后显示纯真实、零差距(real_offset 列名沿用,语义已从"偏移"改为"保底")。
return max(0, int(_real_value(db, row.metric)), int(row.real_offset or 0))
# random:无显式初始基数时用真实值(纯真实,不含偏移)播种
return max(0, int(_real_value(db, row.metric)))
@@ -302,7 +304,7 @@ def update_config(
raise ValueError("增量上限不得小于下限")
if real_offset is not None:
if real_offset < 0:
raise ValueError("基数偏移需为非负整数")
raise ValueError("保底值需为非负整数")
row.real_offset = real_offset
if allow_decrease is not None:
row.allow_decrease = allow_decrease
@@ -311,7 +313,9 @@ def update_config(
if random_initial is not None:
if random_initial < 0:
raise ValueError("初始基数需为非负整数")
row.random_current = random_initial # 设起点是运营明确意图,不受护栏约束
# 初始基数受「只增不减」护栏(与 manual/real 一致):未勾 allow_decrease 且低于
# 当前展示值时保持现值。首次无值(random_current is None)则护栏放行、直接设。
row.random_current = _monotonic(row, random_initial)
row.random_last_tick_at = now
elif apply_now:
# 立即更新:不等钟点,马上刷新一次
+5 -4
View File
@@ -1,7 +1,7 @@
"""签到 CRUD:状态查询 + 执行签到。
14 天循环规则:
- 昨天签过 今天 cycle_day = 昨天 % 14 + 1,streak += 1(连续)
7 天循环规则(2026-06 14 天改 7 天一轮,周期长度统一取 SIGNIN_CYCLE_LEN):
- 昨天签过 今天 cycle_day = 昨天 % SIGNIN_CYCLE_LEN + 1,streak += 1(连续)
- 否则(首签 / 断签) cycle_day = 1,streak = 1(重置)
今天的 cycle_day 决定发多少金币(档位来自 rewards.get_signin_rewards,运营后台可改)
"""
@@ -33,7 +33,7 @@ class AlreadyBoostedError(Exception):
class LastCycleDayBoostBlockedError(Exception):
"""14 天循环最后一天不允许签到膨胀。"""
"""循环最后一天(第 SIGNIN_CYCLE_LEN 天)不允许签到膨胀。"""
@dataclass
@@ -77,7 +77,8 @@ def get_status(db: Session, user_id: int) -> SigninStatus:
today_signed = last is not None and last.signin_date == today
if today_signed:
today_cycle_day = last.cycle_day
# 14→7 改制遗留:老记录 cycle_day 可能 >SIGNIN_CYCLE_LEN,归一化到 1..LEN 防 rewards_list 越界
today_cycle_day = (last.cycle_day - 1) % SIGNIN_CYCLE_LEN + 1
consecutive_days = last.streak
else:
today_cycle_day = _next_cycle_day(last, today)
+192
View File
@@ -0,0 +1,192 @@
"""平台店铺映射落库:一次比价(trace)一行,各目标平台解析出 id 就 upsert 进同一行。
合并键 = trace_id(唯一约束)一次跨平台比价 = 一个 trace = 一个真实店铺:淘宝腿解析出
shopId 先建行(填淘宝列),京东腿(将来)解析出 id upsert **同一行**(填京东列)
合并策略 = 填空(fill-the-blanks):只写该行当前为 NULL 的列,绝不覆盖已有非空值保证后到
的平台只填自己那几列动不了先到平台的数据;共享列(geo / source / 溯源)先到先得
不依赖 ON CONFLICT,跨方言(PG / SQLite dev)都安全;并发撞唯一约束则回滚后转走合并路径
一行里 id_taobao id_jd 共存只表示"两条腿搜同一个源店名各自匹配到了某家店",
name-match 置信度非已核实同一实体作为 append-only 原始资产留存,跨平台精确匹配由下游做
"""
from __future__ import annotations
import logging
import math
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.store_mapping import StoreMapping
from app.schemas.store_mapping import StoreMappingIn
logger = logging.getLogger("shagua.store_mapping")
# 源平台 → 该平台店名所在列(缓存查询的匹配键)。镜像 pricebot reporter 的同名表。
_SOURCE_NAME_COLUMN = {
"meituan": "name_meituan",
"meituan_waimai": "name_meituan",
"jd_waimai": "name_jd",
"jd_waimai_standalone": "name_jd",
"taobao_flash": "name_taobao",
}
# 源平台 → 它自己对应的目标 key(查缓存时排除"源平台自己", 不会复用源平台的店铺 id)。
_SOURCE_TARGET_KEY = {
"meituan": "meituan", "meituan_waimai": "meituan",
"jd_waimai": "jd", "jd_waimai_standalone": "jd",
"taobao_flash": "taobao",
}
# upsert 填空时可写的列。不含: trace_id(合并键)/ business_type(非空默认)/
# id(主键)/ created_at(server_default)。各平台只会带自己那几列非空, 其余为 None 不动。
_MERGE_COLUMNS = (
"source_platform",
"id_taobao", "name_taobao", "id_meituan", "name_meituan", "id_jd", "name_jd",
"city", "geohash", "lng", "lat", "taobao_address",
"source_device_id", "source_user_id",
"taobao_share_url", "taobao_resolved_url", "taobao_deeplink",
"meituan_poi_id_str", "meituan_share_url", "meituan_resolved_url", "meituan_deeplink",
"jd_vender_id", "jd_share_url", "jd_resolved_url", "jd_deeplink",
"attrs",
)
def _find(db: Session, trace_id: str) -> StoreMapping | None:
return db.execute(
select(StoreMapping).where(StoreMapping.trace_id == trace_id)
).scalar_one_or_none()
def _merge_fill_blanks(existing: StoreMapping, payload: StoreMappingIn) -> list[str]:
"""把 payload 里非空、且 existing 当前为 NULL 的列填进去。返回被填的列名(空=无变化)。"""
filled: list[str] = []
for col in _MERGE_COLUMNS:
new = getattr(payload, col)
if new is not None and getattr(existing, col) is None:
setattr(existing, col, new)
filled.append(col)
return filled
def upsert(db: Session, payload: StoreMappingIn) -> tuple[int, int | None]:
"""落一行跨平台店铺映射,返回 (created, row_id)。
created=1 新建该 trace 的行 / 0 合并进已存在行(填空,不覆盖)"""
existing = _find(db, payload.trace_id)
if existing is None:
# 首写: 把 payload 全部可合并列灌进去(逐列 setattr 而非硬编码构造器, 否则首写的是
# 美团/京东腿时它们的列会漏 —— 不在硬编码列表里就丢)。trace_id/business_type 是键/默认, 显式给。
row = StoreMapping(
trace_id=payload.trace_id,
business_type=payload.business_type,
)
for col in _MERGE_COLUMNS:
setattr(row, col, getattr(payload, col))
db.add(row)
try:
db.commit()
except IntegrityError:
# 并发: 另一个请求刚插了同 trace → 撞唯一约束。回滚后转合并路径填空。
db.rollback()
existing = _find(db, payload.trace_id)
if existing is None:
raise
logger.warning("store_mapping 并发冲突 trace=%s, 转填空合并", payload.trace_id)
else:
db.refresh(row)
return 1, row.id
# 已存在(或并发回退到此): 填空合并, 只写当前 NULL 的列
filled = _merge_fill_blanks(existing, payload)
if filled:
db.commit()
db.refresh(existing)
logger.info("store_mapping 合并 trace=%s 填列=%s", payload.trace_id, filled)
return 0, existing.id
def mark_taobao_deeplink_invalid(db: Session, shop_id: str) -> int:
"""把所有 id_taobao=shop_id 的行标记淘宝 deeplink 失效(置 invalid_at=now)。返回本次新标记的行数。
shopId 标记**所有** 同一个坏 shopId(撞淘宝"页面出错了"降级页)可能散在多次比价的
多行里, 全标掉才能让后续 lookup 不再返回它幂等: 已标记的行(invalid_at NULL)跳过"""
result = db.execute(
update(StoreMapping)
.where(
StoreMapping.id_taobao == shop_id,
StoreMapping.taobao_deeplink_invalid_at.is_(None),
)
.values(taobao_deeplink_invalid_at=func.now())
)
db.commit()
return result.rowcount or 0
# ============================================================
# 缓存查询: 比价前按"源平台店名"反查已沉淀的各目标平台店铺 id, 命中就让 pricebot 直接
# deeplink 跳店内搜索, 省掉"开平台→进店→分享反查"整段。
# ============================================================
def _haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
"""两点球面距离(km)。仅用于同名候选里挑最近, 精度够用。"""
r = 6371.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lng2 - lng1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
def _pick_best(rows: list[StoreMapping], lat: float | None, lng: float | None) -> StoreMapping:
"""同名 + 含目标 id 的候选里挑一条:有入参 geo 且有候选带 geo → 取最近;否则取 created_at 最新。"""
if lat is not None and lng is not None:
geod = [r for r in rows if r.lat is not None and r.lng is not None]
if geod:
return min(geod, key=lambda r: _haversine_km(lat, lng, r.lat, r.lng))
return max(rows, key=lambda r: r.created_at)
# 目标 key → (该平台店铺 id 列, 组装返回 payload 的函数)。pricebot 拿 id 现拼 deeplink。
_TARGETS = {
"taobao": ("id_taobao", lambda r: {"shop_id": r.id_taobao, "deeplink": r.taobao_deeplink}),
"jd": ("id_jd", lambda r: {"store_id": r.id_jd, "vender_id": r.jd_vender_id, "deeplink": r.jd_deeplink}),
"meituan": ("meituan_poi_id_str", lambda r: {"poi_id_str": r.meituan_poi_id_str, "deeplink": r.meituan_deeplink}),
}
def lookup_nearest(
db: Session, source_platform: str, store_name: str,
lat: float | None = None, lng: float | None = None,
) -> dict:
""""源平台店名"反查各目标平台已沉淀的店铺 id。返回 {target_key: {id..., deeplink, row_id, ...}}。
- 匹配键 = 源平台对应的 name == store_name(精确)
- 每个目标**分别**"含该目标 id 的同名候选里最近一条"(淘宝 id / 京东 id 可能在不同行)
- 排除源平台自己(不复用源平台的店铺 id)命中为空 = 没缓存, pricebot 走现场反查老路"""
name_col = _SOURCE_NAME_COLUMN.get(source_platform)
if not name_col or not store_name:
return {}
rows = db.execute(
select(StoreMapping).where(getattr(StoreMapping, name_col) == store_name)
).scalars().all()
if not rows:
return {}
src_key = _SOURCE_TARGET_KEY.get(source_platform)
out: dict = {}
for tgt, (id_attr, make_payload) in _TARGETS.items():
if tgt == src_key:
continue # 不返回源平台自己
cands = [r for r in rows if getattr(r, id_attr)]
if tgt == "taobao":
# 失效的淘宝 deeplink(撞过错误页被 invalidate)整条排除 = 当没缓存, pricebot 走正常搜店。
cands = [r for r in cands if r.taobao_deeplink_invalid_at is None]
if not cands:
continue
best = _pick_best(cands, lat, lng)
payload = make_payload(best)
payload["row_id"] = best.id
if best.lat is not None and best.lng is not None and lat is not None and lng is not None:
payload["dist_km"] = round(_haversine_km(lat, lng, best.lat, best.lng), 3)
out[tgt] = payload
return out
+60 -6
View File
@@ -7,14 +7,31 @@ from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.task import UserTask
from app.models.wallet import CoinTransaction
from app.repositories import wallet as crud_wallet
def _notification_grant_times(db: Session, user_id: int) -> int:
"""打开消息提醒已领取次数 = 该 biz_type 的【正向】金币流水条数(可重复任务不写 user_task)。
amount>0 只数发放笔,避免日后出现该 biz_type 的回滚/冲正负向流水时把次数算大减半档位错
"""
biz = f"task_{rewards.TASK_ENABLE_NOTIFICATION}"
return db.execute(
select(func.count()).select_from(CoinTransaction).where(
CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == biz,
CoinTransaction.amount > 0,
)
).scalar_one() or 0
class UnknownTaskError(Exception):
"""task_key 不在已知任务表里。"""
@@ -31,14 +48,30 @@ class TaskState:
def list_tasks(db: Session, user_id: int) -> list[TaskState]:
"""返回所有已知一次性任务及其领取状态。"""
"""返回所有已知任务及其领取状态。
打开消息提醒是可重复任务:claimed False,coin = 下一次(减半后)的金额;
客户端按通知权限是否开启决定展不展示(开着就隐藏,不在这里判断)
其余为一次性任务,user_task 唯一约束去重
"""
task_rewards = rewards.get_task_rewards(db)
stmt = select(UserTask.task_key).where(UserTask.user_id == user_id)
claimed_keys = set(db.execute(stmt).scalars().all())
return [
TaskState(task_key=key, coin=coin, claimed=key in claimed_keys)
for key, coin in task_rewards.items()
]
result: list[TaskState] = []
for key, coin in task_rewards.items():
if key == rewards.TASK_ENABLE_NOTIFICATION:
times = _notification_grant_times(db, user_id)
exhausted = times >= rewards.notification_max_claims(coin)
result.append(
TaskState(
task_key=key,
coin=rewards.notification_reward(coin, times),
claimed=exhausted, # 领满(减半到底)后置 True,客户端据此可隐藏
)
)
else:
result.append(TaskState(task_key=key, coin=coin, claimed=key in claimed_keys))
return result
def claim_task(db: Session, user_id: int, task_key: str) -> tuple[int, int]:
@@ -50,6 +83,27 @@ def claim_task(db: Session, user_id: int, task_key: str) -> tuple[int, int]:
if task_key not in task_rewards:
raise UnknownTaskError
# 打开消息提醒:可重复领取,每次减半。不写 user_task(不去重),已领次数用金币流水条数算;
# ref_id 带序号(task_key:N)让流水可读、且 (user_id,biz_type,ref_id) 唯一索引能挡并发/连点
# 重复发放。减半到底(notification_max_claims)后不再可领。客户端只在通知权限关闭时展示+触发。
if task_key == rewards.TASK_ENABLE_NOTIFICATION:
base = task_rewards[task_key]
times = _notification_grant_times(db, user_id)
if times >= rewards.notification_max_claims(base):
raise AlreadyClaimedError
coin = rewards.notification_reward(base, times)
try:
acc, _ = crud_wallet.grant_coins(
db, user_id, coin,
biz_type=f"task_{task_key}", ref_id=f"{task_key}:{times + 1}",
)
db.commit()
except IntegrityError as e:
# 并发/连点:另一个请求已抢先发了同一序号(撞唯一索引)。回滚,按已领处理,不双发。
db.rollback()
raise AlreadyClaimedError from e
return coin, acc.coin_balance
existing = db.execute(
select(UserTask).where(
UserTask.user_id == user_id, UserTask.task_key == task_key
+173 -12
View File
@@ -78,9 +78,15 @@ class WithdrawNotReviewable(Exception):
"""提现单当前状态不可审核(非 reviewing,可能已被处理过)。"""
def get_or_create_account(db: Session, user_id: int, *, commit: bool = True) -> CoinAccount:
"""取用户金币账户,不存在则建一个空账户。"""
acc = db.get(CoinAccount, user_id)
def get_or_create_account(
db: Session, user_id: int, *, commit: bool = True, lock: bool = False
) -> CoinAccount:
"""取用户金币账户,不存在则建一个空账户。
lock=True 时对已存在的账户行加 SELECT FOR UPDATE(--写余额的调用方串行化,防并发
双写余额错位, admin set 模式连点);默认 False 不改 C 端发奖行为SQLite 下为 no-op
"""
acc = db.get(CoinAccount, user_id, with_for_update=True) if lock else db.get(CoinAccount, user_id)
if acc is None:
acc = CoinAccount(
user_id=user_id,
@@ -129,6 +135,37 @@ def grant_coins(
return acc, txn
def grant_cash(
db: Session,
user_id: int,
amount_cents: int,
*,
biz_type: str,
ref_id: str | None = None,
remark: str | None = None,
) -> tuple[CoinAccount, CashTransaction]:
"""现金变动入口(正数入账 / 负数出账)。更新现金余额 + 写流水,不 commit。
[grant_coins] 同模式(运营手动调现金 / 测试发现金用)返回 (account, transaction),
调用方负责 commit不在此校验扣成负由调用方(admin router)按业务保护
"""
acc = get_or_create_account(db, user_id, commit=False)
acc.cash_balance_cents += amount_cents
txn = CashTransaction(
user_id=user_id,
amount_cents=amount_cents,
balance_after_cents=acc.cash_balance_cents,
biz_type=biz_type,
ref_id=ref_id,
remark=remark,
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), # 存北京 wall-clock
)
db.add(txn)
db.flush()
return acc, txn
def list_coin_transactions(
db: Session,
user_id: int,
@@ -152,14 +189,22 @@ def list_coin_transactions(
def exchange_coins_to_cash(
db: Session, user_id: int, coin_amount: int
db: Session,
user_id: int,
coin_amount: int,
*,
remark: str | None = None,
enforce_min: bool = True,
) -> tuple[CoinAccount, int]:
"""金币兑现金。返回 (account, 本次兑入的分)。
校验:金额 >= MIN_EXCHANGE_COIN 且为整分倍数(InvalidExchangeAmountError),
余额充足(InsufficientCoinError)扣金币 + 加现金,两条流水同事务 commit
校验:金额为整分倍数(InvalidExchangeAmountError)余额充足(InsufficientCoinError);
`enforce_min=True`(手动兑默认)时还要 >= 后台配置的兑换下限,`enforce_min=False`
(0 点自动兑到分全额)只要够 1 (COIN_PER_CENT)即可,不受手动下限约束
`remark` 不传则用默认N金币兑M分扣金币 + 加现金,两条流水同事务 commit
"""
if coin_amount < rewards.get_min_exchange_coin(db) or coin_amount % COIN_PER_CENT != 0:
floor_min = rewards.get_min_exchange_coin(db) if enforce_min else COIN_PER_CENT
if coin_amount < floor_min or coin_amount % COIN_PER_CENT != 0:
raise InvalidExchangeAmountError
acc = get_or_create_account(db, user_id, commit=False)
@@ -167,10 +212,10 @@ def exchange_coins_to_cash(
raise InsufficientCoinError
cents = coins_to_cents(coin_amount)
remark = f"{coin_amount}金币兑{cents}"
tx_remark = remark or f"{coin_amount}金币兑{cents}"
# 1. 扣金币(写 coin_transaction)
grant_coins(db, user_id, -coin_amount, biz_type="exchange_out", remark=remark)
grant_coins(db, user_id, -coin_amount, biz_type="exchange_out", remark=tx_remark)
# 2. 加现金(写 cash_transaction)
acc.cash_balance_cents += cents
db.add(
@@ -179,7 +224,7 @@ def exchange_coins_to_cash(
amount_cents=cents,
balance_after_cents=acc.cash_balance_cents,
biz_type="exchange_in",
remark=remark,
remark=tx_remark,
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
)
)
@@ -188,6 +233,70 @@ def exchange_coins_to_cash(
return acc, cents
def _has_exchange_in_on(db: Session, user_id: int, day) -> bool:
"""该用户在指定日期(北京日)是否已有 exchange_in 现金流水。
自动兑复用 exchange_in 类型,故用当天是否已有 exchange_in做幂等键:timer 重触发 /
手动重跑当天不会重复兑手动兑入口已下线,不会与之误判混淆(若日后恢复手动兑需改用独立类型)
created_at 落库为北京 naive 时间( exchange_coins_to_cash), [当天 0 , 次日 0 ) 比较
"""
day_start = datetime.combine(day, datetime.min.time())
next_day = day_start + timedelta(days=1)
return (
db.execute(
select(CashTransaction.id)
.where(
CashTransaction.user_id == user_id,
CashTransaction.biz_type == "exchange_in",
CashTransaction.created_at >= day_start,
CashTransaction.created_at < next_day,
)
.limit(1)
).first()
is not None
)
def daily_auto_exchange(db: Session) -> dict:
"""0 点定时任务:把每个用户金币「到分全额」兑成现金(复用 exchange_in 流水类型)。
- **到分全额**:coin_amount = floor(余额 / 100) * 100;余额不足 1 (<100)的零头留到下次
- **幂等**:当天已有 exchange_in 的用户跳过( [_has_exchange_in_on])
- **逐用户独立事务**:单个用户异常 rollback 不影响其他人;exchange_coins_to_cash 内部按用户 commit
返回统计 dict(scanned/converted/skipped_done/skipped_dust/failed/total_cents)
"""
today = rewards.cn_today()
stats = {
"scanned": 0, "converted": 0, "skipped_done": 0,
"skipped_dust": 0, "failed": 0, "total_cents": 0,
}
user_ids = db.execute(
select(CoinAccount.user_id).where(CoinAccount.coin_balance >= COIN_PER_CENT)
).scalars().all()
for user_id in user_ids:
stats["scanned"] += 1
try:
if _has_exchange_in_on(db, user_id, today):
stats["skipped_done"] += 1
continue
acc = get_or_create_account(db, user_id, commit=False)
coin_amount = (acc.coin_balance // COIN_PER_CENT) * COIN_PER_CENT
if coin_amount < COIN_PER_CENT:
stats["skipped_dust"] += 1
continue
_, cents = exchange_coins_to_cash(
db, user_id, coin_amount, remark="0 点自动兑现金", enforce_min=False
)
stats["converted"] += 1
stats["total_cents"] += cents
except Exception: # noqa: BLE001 - 批处理不因单用户异常中断
db.rollback()
stats["failed"] += 1
return stats
def list_cash_transactions(
db: Session,
user_id: int,
@@ -231,6 +340,26 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict:
return info
def has_reviewing_withdraw(db: Session, user_id: int) -> bool:
"""用户是否有「待审核(reviewing)」的提现单。
只看 reviewing:这类单还没打款,用户确认解绑时会被立即退回现金
(refund_reviewing_withdraws_on_unbind),解绑前据此提示用户知情确认
pending 单转账已在途(execute 已过 openid ),解绑影响不到照常打款,故不计入
"""
return (
db.execute(
select(WithdrawOrder.id)
.where(
WithdrawOrder.user_id == user_id,
WithdrawOrder.status == "reviewing",
)
.limit(1)
).first()
is not None
)
def unbind_wechat_openid(db: Session, user_id: int) -> None:
"""解绑微信:清空 openid。已绑定才清,未绑定为幂等空操作。"""
user = db.get(User, user_id)
@@ -243,6 +372,33 @@ def unbind_wechat_openid(db: Session, user_id: int) -> None:
db.commit()
def refund_reviewing_withdraws_on_unbind(db: Session, user_id: int) -> int:
"""解绑微信时,把该用户所有「待审核(reviewing)」提现单立即退回现金并置终态。
reviewing 单还没打款(钱在 create_withdraw 时已从现金扣进待审核单),解绑后这笔无法
打款到微信,直接退回现金最干净:用户即时拿回钱后台不再挂死单(不必等管理员审核)
复用 _refund_withdraw(退现金 + 流水 + 幂等)返回退掉的单数
"""
orders = (
db.execute(
select(WithdrawOrder).where(
WithdrawOrder.user_id == user_id,
WithdrawOrder.status == "reviewing",
)
)
.scalars()
.all()
)
for order in orders:
_refund_withdraw(
db,
order,
reason="用户解绑微信,待审核提现自动退回",
remark="解绑微信,提现已退回现金余额",
)
return len(orders)
_OUT_BILL_NO_RE = re.compile(r"^[0-9A-Za-z_-]{8,32}$")
@@ -278,7 +434,8 @@ def _add_cash(db: Session, user_id: int, amount_cents: int) -> int:
def _refund_withdraw(
db: Session, order: WithdrawOrder, reason: str, *, final_status: str = "failed"
db: Session, order: WithdrawOrder, reason: str, *, final_status: str = "failed",
remark: str | None = None,
) -> None:
"""退回现金 + 写 withdraw_refund 流水 + 单子置终态。幂等:已是退款终态不重复退。
@@ -286,6 +443,7 @@ def _refund_withdraw(
- "failed" (默认):微信转账失败/取消 自动退款
- "rejected":管理员审核拒绝 退款( reject_withdraw)
两种都已退款, in 判定防重复退(并发/对账/拒绝重复点)
remark:给用户可见的现金流水文案;省略则按 final_status 取默认(解绑退回等场景可自定义)
"""
if order.status in ("failed", "rejected"):
return # 防重复退款(并发/对账与查单/重复拒绝同时触发)
@@ -310,7 +468,10 @@ def _refund_withdraw(
biz_type="withdraw_refund",
ref_id=order.out_bill_no,
# 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason
remark="提现审核未通过,金额已退回" if final_status == "rejected" else "提现未成功,金额已退回",
remark=(
remark
or ("提现审核未通过,金额已退回" if final_status == "rejected" else "提现未成功,金额已退回")
),
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
)
)
+8 -3
View File
@@ -46,11 +46,11 @@ class AdRewardStatusOut(BaseModel):
class EcpmReportIn(BaseModel):
"""客户端上报一次广告展示的 eCPM(内部收益统计/对账)。
user_id 不在 body JWT (Bearer),防伪造ecpm 原样上报字符串,后端按/千次展示处理
user_id 不在 body JWT (Bearer),防伪造ecpm 原样上报字符串,后端按/千次展示处理(SDK getEcpm 原值,非元)
"""
ad_type: str = Field(..., description="广告类型:reward_video(激励视频) / draw(Draw 信息流) 等")
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,按/千次展示处理")
ecpm: str = Field(..., description="穿山甲 getShowEcpm().getEcpm() 原始字符串,按/千次展示处理(SDK getEcpm 原值,非元)")
ad_session_id: str | None = Field(
None, min_length=8, max_length=64,
description="客户端生成的一次广告会话 id;激励视频 S2S extra 会透传同值",
@@ -89,6 +89,11 @@ class TestGrantIn(BaseModel):
"reward_video",
description="模拟发奖场景:reward_video(普通激励视频) / signin_boost(签到膨胀)",
)
ad_session_id: str | None = Field(
None, min_length=8, max_length=64,
description="本次广告会话 id(与 ecpm-report 同值)。reward_video 场景下据此查回客户端"
"已上报的真实 eCPM 来按公式发奖;查不到或 eCPM≤0 时兜底 200,保证本地联调仍出非零金币",
)
class TestGrantOut(BaseModel):
@@ -119,7 +124,7 @@ class FeedRewardIn(BaseModel):
ad_session_id: str | None = Field(
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
)
ecpm: str = Field(..., description="本条信息流广告 eCPM,按/千次展示处理")
ecpm: str = Field(..., description="本条信息流广告 eCPM,按/千次展示处理(SDK getEcpm 原值,非元)")
duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数")
adn: str | None = Field(None, description="实际投放 ADN")
slot_id: str | None = Field(None, description="实际展示代码位")
+15
View File
@@ -25,6 +25,8 @@ class UserOut(BaseModel):
status: str
created_at: datetime
last_login_at: datetime
# 调试链接权限:前端据此在比价结果弹窗/记录页显示「复制调试链接」按钮。默认 false。
debug_trace_enabled: bool = False
# ===== Token 通用结构 =====
@@ -39,6 +41,11 @@ class TokenPair(BaseModel):
class TokenWithUser(TokenPair):
user: UserOut
onboarding_completed: bool = Field(
False,
description="该 设备+账号 是否已走完新手引导(据登录请求里的 device_id 计算)。"
"true → 客户端登录后直接进首页,跳过引导。",
)
# ===== 极光一键登录 =====
@@ -46,6 +53,10 @@ class TokenWithUser(TokenPair):
class JverifyLoginRequest(BaseModel):
login_token: str = Field(..., description="客户端 loginAuth 拿到的 loginToken", min_length=1)
operator: str = Field("", description="CM/CU/CT,用于日志,可选")
device_id: str = Field(
"", max_length=64,
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
)
# ===== 短信验证码 =====
@@ -63,6 +74,10 @@ class SmsSendResponse(BaseModel):
class SmsLoginRequest(BaseModel):
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
code: str = Field(..., min_length=4, max_length=8)
device_id: str = Field(
"", max_length=64,
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
)
# ===== Refresh =====
+16
View File
@@ -24,6 +24,13 @@ class ComparisonItemIn(BaseModel):
specs: list[str] | None = None
class AppliedCouponIn(BaseModel):
"""单笔已用优惠(来自 comparison_results[].applied_coupons)。amount 单位:元、正数。"""
name: str
amount: float
class ComparisonResultIn(BaseModel):
"""逐平台对比项(来自 done.params.comparison_results)。price 单位:元。"""
@@ -40,6 +47,10 @@ class ComparisonResultIn(BaseModel):
# 优惠**来源名**(展示用, best-effort): 美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"。
# None=没抠到 → 前端走通用"红包"。同样必须显式声明否则上报边界被 pydantic 静默丢弃(pricebot#38 引入)。
coupon_name: str | None = None
# 多券明细 [{name, amount}](全口径: 平台红包+商家券+满减+配送减免, amount 单位元正数)。
# 跟 coupon_saved 并存, 是更丰富的明细; 空=没抠到 → 前端回退单券路径。
# 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。
applied_coupons: list[AppliedCouponIn] = Field(default_factory=list)
class ComparisonRecordIn(BaseModel):
@@ -67,6 +78,9 @@ class ComparisonRecordIn(BaseModel):
status: str | None = Field(None, description="success / failed,可不传由服务端派生")
# 最优平台商家/商品深链(客户端从 collectedLinks[best_index] 取);「再次比价」直达用
best_deeplink: str | None = Field(None, description="最优平台深链,再次比价直达")
# pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘
# 时分秒前端拼不出,必须由后端透传)。
trace_url: str | None = Field(None, description="本次比价公网调试链接")
# ===== 读取出参 =====
@@ -79,6 +93,8 @@ class ComparisonRecordOut(BaseModel):
id: int
business_type: str
trace_id: str
# 公网调试链接;仅当 user.debug_trace_enabled 时由端点填充,否则端点层置 None(权限闸)。
trace_url: str | None = None
source_platform_id: str | None = None
source_platform_name: str | None = None
source_package: str | None = None
+22 -2
View File
@@ -7,15 +7,35 @@ from pydantic import BaseModel
class CouponPromptDismissIn(BaseModel):
"""客户端拒绝/关闭领券引导窗的通知体。
server 据此记一条今日 engagement(dismissed) 今天这台设备不再弹引导窗
server 据此记一条今日 engagement(dismissed) 今天**这个 App** 不再弹引导窗
频控按 (device, package, ): App 独立package 缺省 ""(老客户端兼容,退化为全局态)
MVP 不鉴权, device_id 判断;user_id 登录态带上就一并记(资产),可空
"""
device_id: str
package: str = ""
user_id: int | None = None
class CouponPromptShownIn(BaseModel):
"""客户端弹出领券引导窗即上报(记 shown)。
弹出那刻就记一条今日 engagement(shown) 今天**这个 App** 不再自动弹(频控主判据,
管跨重装;本地 SP 兜后台抖动)后续用户点领取/拒绝再把 type 升级成 claim_started/dismissed
"""
device_id: str
package: str
user_id: int | None = None
class CouponPromptShouldShowOut(BaseModel):
"""切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。"""
"""切到外卖 App 时是否还应弹领券引导窗。今天**这个 App** 已 engage(弹/领/拒)过 → false。"""
should_show: bool
class CouponCompletedTodayOut(BaseModel):
"""这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。"""
completed: bool
+10 -1
View File
@@ -1,10 +1,17 @@
"""美团 CPS 券列表 / 换链相关 schemas。"""
from __future__ import annotations
from typing import Any
from typing import Any, Literal
from pydantic import BaseModel, Field
# feed / 列表类接口的结果状态,供前端区分占位文案:
# ok = 正常有数据
# empty = 后端正常、查询成功但确实没有数据(显示「暂无优惠券」)
# degraded = 上游(美团)或库查询失败,已降级返空(显示「服务繁忙,下拉重试」)
# 老客户端不读该字段,默认 ok,向后兼容。
FeedStatus = Literal["ok", "empty", "degraded"]
# ───────────────── 券卡片(归一化后给客户端) ─────────────────
@@ -127,6 +134,7 @@ class CouponListResponse(BaseModel):
items: list[CouponCard]
has_next: bool = False
search_id: str | None = None
status: FeedStatus = Field("ok", description="ok 有数据 / empty 暂无 / degraded 上游失败已降级")
class FeedRequest(BaseModel):
@@ -146,6 +154,7 @@ class FeedResponse(BaseModel):
items: list[CouponCard]
has_next: bool = False
page: int = 1
status: FeedStatus = Field("ok", description="ok 有数据 / empty 暂无 / degraded 上游失败已降级")
class TopSalesRequest(BaseModel):
+1 -1
View File
@@ -15,7 +15,7 @@ class PlatformStatsOut(BaseModel):
class SavingsFeedItem(BaseModel):
"""首页轮播一条。time 为北京时间 HH:MM:SS。"""
masked_user: str # 脱敏用户名,如「用户********a52」
masked_user: str # 脱敏用户名,手机尾号(138****5678)或中文昵称(省钱**)混合风格
saved_amount_cents: int # 节省(分),客户端 ÷100 显示「x.xx 元」
time: str
+76
View File
@@ -0,0 +1,76 @@
"""平台店铺映射内部上报的收发模型。
pricebot 在淘宝比价拿到 shopId serverserver POST 一行映射所有字段可空(按比价
角色稀疏填充),server 端只校验共享密钥 + 幂等(trace_id)落库
"""
from __future__ import annotations
from pydantic import BaseModel
class StoreMappingIn(BaseModel):
"""一次比价的跨平台店铺映射上报体(一行)。"""
trace_id: str
business_type: str = "food"
source_platform: str | None = None
# 跨平台身份(按角色稀疏填充)
id_taobao: str | None = None
name_taobao: str | None = None
id_meituan: str | None = None
name_meituan: str | None = None
id_jd: str | None = None
name_jd: str | None = None
# 地理
city: str | None = None
geohash: str | None = None
lng: float | None = None
lat: float | None = None
taobao_address: str | None = None
# 溯源
source_device_id: str | None = None
source_user_id: int | None = None
# 淘宝原料(可复跳 / 可重解析 / 调试)
taobao_share_url: str | None = None
taobao_resolved_url: str | None = None
taobao_deeplink: str | None = None
# 美团原料(poi_id_str 非稳定主键, 单列存; 见 model 注释)
meituan_poi_id_str: str | None = None
meituan_share_url: str | None = None
meituan_resolved_url: str | None = None
meituan_deeplink: str | None = None
# 京东原料(venderId 单列存, storeId 进 id_jd; 见 model 注释)
jd_vender_id: str | None = None
jd_share_url: str | None = None
jd_resolved_url: str | None = None
jd_deeplink: str | None = None
attrs: dict | None = None
class StoreMappingOut(BaseModel):
"""上报结果。inserted=1 为新建该 trace 行,0 为合并进已存在行(填空,不覆盖);
row_id 为该 trace 对应行 id"""
inserted: int
row_id: int | None = None
class StoreMappingInvalidateIn(BaseModel):
"""标记某平台某 shopId 的缓存 deeplink 失效(pricebot 撞错误页回退时上报)。"""
platform: str # 目前只支持 "taobao"
shop_id: str # 失效的店铺 id(淘宝 = shopId = id_taobao)
class StoreMappingInvalidateOut(BaseModel):
"""标记结果。affected = 本次新标记失效的行数(按 shopId 标记所有匹配行)。"""
ok: bool
affected: int
+12 -1
View File
@@ -5,7 +5,7 @@ from pydantic import BaseModel, Field, field_validator
class ProfileUpdateRequest(BaseModel):
nickname: str = Field(..., min_length=1, max_length=16, description="昵称,1-16")
nickname: str = Field(..., min_length=1, max_length=20, description="昵称,1-20")
@field_validator("nickname")
@classmethod
@@ -16,5 +16,16 @@ class ProfileUpdateRequest(BaseModel):
return v
class OnboardingCompleteRequest(BaseModel):
device_id: str = Field(
..., min_length=1, max_length=64,
description="硬件级设备标识(Android ANDROID_ID),与登录时一致,用于按 设备+账号 标记引导完成",
)
class OnboardingStatusResponse(BaseModel):
completed: bool = Field(..., description="该 设备+账号 是否已走过新手引导(false → 客户端应重走)")
class OkResponse(BaseModel):
ok: bool = True
+8
View File
@@ -109,8 +109,16 @@ class BindWechatResultOut(BaseModel):
wechat_avatar_url: str | None = None
class UnbindWechatRequest(BaseModel):
# 有进行中提现单时,首次解绑会被拦(needs_confirm),用户确认后带 force=true 再调一次才真解绑。
force: bool = Field(False, description="跳过「进行中提现」二次确认,强制解绑")
class UnbindWechatResultOut(BaseModel):
bound: bool = False
# 有进行中提现单且未 force → True:本次未解绑(bound 仍 True),客户端弹确认弹窗。
needs_confirm: bool = False
message: str | None = None
class WithdrawRequest(BaseModel):
+27 -15
View File
@@ -62,11 +62,11 @@
<div class="feat">💰 <span>省下的钱看得见,还能<b>赚金币提现</b></span></div>
</div>
<button class="btn" id="dlbtn">⬇️ 下载安装包</button>
<div class="hint" id="hint">Android 安卓版 · 安装包约 24 MB</div>
<button class="btn" id="dlbtn">🏪 打开应用商店下载</button>
<div class="hint" id="hint">Android 安卓版 · 应用商店安全下载</div>
<div class="foot">
安装时如提示「未知来源」,请允许后继续安装<br>
将前往应用商店下载,安全放心<br>
本页为内部测试页。
</div>
@@ -82,16 +82,18 @@
<div class="wxsteps">
<div><span class="n">1</span>点右上角的 ··· 菜单</div>
<div><span class="n">2</span>选择「在浏览器打开」</div>
<div><span class="n">3</span>在浏览器里点「下载安装包」</div>
<div><span class="n">3</span>在浏览器里按提示去应用商店下载</div>
</div>
</div>
<div class="closebar" id="wxclose">我知道了 ✕</div>
</div>
<script>
// APK 下载地址跟随当前页面 origin:本地测试时页面由笔记本 app-server(LAN:8770)托管 →
// 下载也走 LAN;生产由 app-api.shaguabijia.com 托管 → 下载走生产域名。无需按机器改 IP
var APK_URL = location.origin + "/media/shaguabijia.apk";
// 应用商店跳转:用包名(App 唯一标识 = 身份证号)定位到傻瓜比价的商店下载页。
// 主:market:// 唤起手机自带应用市场(华为/小米/OV);兜底:应用宝网页(任何浏览器都能开)
var PKG = "com.jishisongfu.shaguabijia";
var MARKET_URL = "market://details?id=" + PKG;
var YYB_URL = "https://a.app.qq.com/o/simple.jsp?pkgname=" + PKG;
var ua = navigator.userAgent || "";
var isWeChat = /MicroMessenger/i.test(ua);
var isIOS = /iPhone|iPad|iPod/i.test(ua);
@@ -138,12 +140,20 @@
}
return Promise.resolve();
}
// 确保剪贴板写完(或最多等 400ms 兜底,防写入卡住)再触发下载。
function startDownload() {
var fired = false;
function go() { if (!fired) { fired = true; window.location.href = APK_URL; } }
copyInviteCode().then(go);
setTimeout(go, 400);
// 跳应用商店:先尝试 market:// 唤起手机自带商店;若 2.5s 内页面没切到后台
//(= 没有商店接管 market://),兜底跳应用宝网页下载页,不让用户卡死。
function openStore() {
var jumped = false;
function markJumped() { jumped = true; } // 页面切到后台 = 商店已唤起
document.addEventListener("visibilitychange", function () {
if (document.hidden) jumped = true;
});
window.addEventListener("pagehide", markJumped);
window.addEventListener("blur", markJumped);
window.location.href = MARKET_URL; // 唤起自带应用市场
setTimeout(function () {
if (!jumped) window.location.href = YYB_URL; // 没唤起 → 应用宝网页兜底
}, 2500);
}
var hint = document.getElementById("hint");
@@ -160,8 +170,10 @@
document.getElementById("dlbtn").addEventListener("click", function(){
if (isWeChat) { showMask(); return; } // 微信内:引导去浏览器
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
// 普通浏览器:先把邀请码写进剪贴板(写完或 400ms 兜底),再下载 APK(首启读回完成归因)
startDownload();
// 安卓浏览器:先把邀请码写进剪贴板(legacyCopy 同步写、最可靠),再跳应用商店。
// 剪贴板供 App 首启归因;链路长易丢时由指纹兜底(已上报 landing-track)接住。
copyInviteCode();
openStore();
});
</script>
</body>
+59
View File
@@ -0,0 +1,59 @@
# 0 点自动兑金币 定时任务 — 运维手册
> 对象:维护「每天 0 点把用户金币自动兑成现金」这套定时任务的同事。
> 🔒 服务器登录信息见**私密交接清单**,不入库。
## 它是什么
客户端已删手动「兑金币」入口、改为「0 点自动兑现金(可能存在延迟)」,故服务端每天 0 点跑一轮:
把每个用户的金币按汇率 **100 金币 = 1 分** 兑成现金。
- **到分全额**:`coin_amount = floor(余额 / 100) × 100`,不足 1 分(<100 金币)的零头留到下次。
- **复用 `exchange_in` 流水类型**(手动兑入口已下线,不会混淆),用户在「现金明细」看到「金币兑换现金」。
- **幂等**:同一用户当天已有 `exchange_in` 流水则跳过 → timer 重触发 / 手动重跑当天不会重复兑。
- 逻辑见 `app/repositories/wallet.py::daily_auto_exchange`,入口 `scripts/daily_auto_exchange.py`
## 文件
| 项 | 路径 |
|---|---|
| 脚本入口 | `scripts/daily_auto_exchange.py` |
| 核心逻辑 | `app/repositories/wallet.py::daily_auto_exchange` |
| 开关 | `.env``AUTO_EXCHANGE_ENABLED`(默认 true;false=脚本 no-op) |
| systemd 单元 | `deploy/daily-exchange.{service,timer}` |
| 运行锁 | `/tmp/daily_auto_exchange.lock`(可用环境变量 `DAILY_EXCHANGE_LOCK` 覆盖) |
## 部署(Linux 服务器)
```bash
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 # 确认下次触发时间
```
## 本机 Windows 开发(无 systemd)
直接手动跑:
```
.venv\Scripts\python -m scripts.daily_auto_exchange --once
# 本地循环调试(每小时一轮,幂等空跑无害):
.venv\Scripts\python -m scripts.daily_auto_exchange --loop --interval 3600
```
## 怎么看健康 / 手动跑一次
```bash
sudo systemctl start daily-exchange.service # 立即手动跑一轮(不等 0 点)
journalctl -u daily-exchange -n 30 --no-pager # 看日志:扫描/兑换/跳过/失败/累计兑入分
```
日志一行形如:`自动兑完成 用时1.2s 扫描120 兑换88 已兑过跳过30 零头跳过2 失败0 累计兑入15600分`
## 怎么关 / 临时停
- **临时停(推荐)**:`.env``AUTO_EXCHANGE_ENABLED=false` → 脚本 no-op,无需动 timer。
- **停 timer**:`sudo systemctl disable --now daily-exchange.timer`
## 脚本参数
- `--once`:跑一轮(timer 用这个,默认)
- `--loop --interval N`:常驻循环,每 N 秒一轮(默认 3600,调试用)
- 锁位置可用环境变量 `DAILY_EXCHANGE_LOCK=/path/to.lock` 覆盖
## 注意事项
- **触发时间**:`OnCalendar=*-*-* 00:00:00`。客户端文案已注明「可能存在延迟」,要错开整点扎堆可改 `00:05:00`
- **catch-up**:`Persistent=true`,服务器宕机错过当天会在重启后补跑一轮。
- **DB 无关**:sqlite / postgres 均可(不像美团 ETL 需要 PG)。
- **改脚本 / 改部署**:走 git + PR,由有 root 的人部署。
+33
View File
@@ -0,0 +1,33 @@
# 0 点自动兑金币 —— 单轮把每个用户金币「到分全额」兑成现金,由 daily-exchange.timer 每天 0 点触发。
#
# 仅用于 Linux 服务器;本机 Windows 开发无 systemd,直接手动跑脚本即可:
# .venv\Scripts\python -m scripts.daily_auto_exchange --once
#
# 部署(服务器):
# sudo cp deploy/daily-exchange.{service,timer} /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now daily-exchange.timer
# # 手动跑一次验证: sudo systemctl start daily-exchange.service && journalctl -u daily-exchange -n 30
#
# 前置:.env 的 AUTO_EXCHANGE_ENABLED=true(默认);置 false 则脚本 no-op,免动 timer。
[Unit]
Description=Daily auto-exchange coins to cash (one-shot, driven by timer)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
WorkingDirectory=/opt/shaguabijia-app-server
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
EnvironmentFile=/opt/shaguabijia-app-server/.env
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.daily_auto_exchange --once
SyslogIdentifier=daily-exchange
# 脚本自带 30min 文件锁;给 20min 硬超时,防卡死轮次长期占锁。
TimeoutStartSec=1200
# 与主服务 shaguabijia-app-server.service 同款加固。
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/shaguabijia-app-server
ProtectHome=true
+14
View File
@@ -0,0 +1,14 @@
# 每天 0 点触发一次「自动兑金币」(Linux 服务器用)。
# 见 daily-exchange.service 顶部注释的部署步骤。
[Unit]
Description=Run daily auto-exchange coins->cash at midnight
[Timer]
# 每天 0 点跑。客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆。
OnCalendar=*-*-* 00:00:00
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日)。
Persistent=true
AccuracySec=1min
[Install]
WantedBy=timers.target
+90
View File
@@ -0,0 +1,90 @@
# 美团券 ETL 定时任务 — 运维手册
> 对象:维护「美团 CPS 券抓取入库」这套定时任务的同事。
> 🔒 登录信息(服务器 IP / 账号密码 / DB 密码)见**私密交接清单**,**不入库**。
> 运行账号 `cps` **无 sudo**,本手册所有操作都不需要 root。
## 它是什么
每小时全量抓美团 CPS 券(北京)→ upsert 进 PostgreSQL 的 `meituan_coupon` 表,作为 App「销量最高 / 智能推荐」两个 tab 的数据源。一轮约 140 秒、~2700 条;靠 `cps` 用户的 crontab 常驻,关终端也照跑。
## 代码 / 文件在哪(都在生产服务器上)
| 项 | 路径 |
|---|---|
| 代码目录 | `/opt/shaguabijia-app-server` |
| 抓取脚本 | `scripts/pull_meituan_coupons.py` |
| Python(venv) | `/opt/shaguabijia-app-server/.venv/bin/python` |
| 配置(美团凭证 / DB 连接串) | `/opt/shaguabijia-app-server/.env`(`MT_CPS_*` / `DATABASE_URL`)|
| 日志 | `/home/cps/meituan_etl.log` |
| 运行锁 | `/tmp/meituan_etl.lock`(根治版)或 `~/data/.meituan_etl.lock`(过渡版,见文末)|
| 定时配置 | `cps` 用户 crontab(`crontab -l`)|
| 入库表 | PostgreSQL `shaguabijia` 库的 `meituan_coupon` |
## 怎么看在不在跑 / 健康
```bash
crontab -l # 看定时任务(应有每小时第 17 分那条)
tail -n 20 ~/meituan_etl.log # 看最近几轮:本轮完成 / 入库条数 / 有无报错
# 查库存量与新鲜度(走 .env 连接串,不用输 DB 密码):
cd /opt/shaguabijia-app-server && .venv/bin/python -c "from sqlalchemy import text; from app.db.session import engine; print(engine.connect().execute(text('select count(*) total, count(*) filter (where sale_volume_num is not null) sales, max(last_seen) last_run from meituan_coupon')).one())"
```
**健康标准**:`last_run` 在最近 12 小时内、`total` 两三千、日志最近一轮是 `本轮完成`(不是 `Permission denied` / Traceback)。
## 怎么启动
定时任务已在 `cps` 的 crontab 里常驻,**随系统 cron 服务自动运行,无需手动启动**。若 crontab 被清空、要重新装回:`crontab -e`,粘下面这行存盘。
```cron
# 根治版(锁文件已改到 /tmp 后用这条,推荐):
17 * * * * cd /opt/shaguabijia-app-server && .venv/bin/python -m scripts.pull_meituan_coupons --once --prune-hours 24 >> /home/cps/meituan_etl.log 2>&1
```
**手动立即跑一轮**(不等下一个整点,比如刚发现库空):
```bash
cd /opt/shaguabijia-app-server && .venv/bin/python -m scripts.pull_meituan_coupons --once --prune-hours 24
```
前台跑、约 140 秒,出现 `本轮完成` 即成功。
## 怎么关闭(停用定时任务)
```bash
crontab -e
```
在那一行最前面加 `#` 注释掉(**推荐**,保留备查),存盘即停;要彻底删就删那一行。
> ⚠️ 别用 `crontab -r`——它会**清空你所有 cron**,不只这一条。
## 怎么终止(杀掉正在跑的那一轮)
```bash
pgrep -af pull_meituan_coupons # 查在跑的进程和 PID
pkill -f pull_meituan_coupons # 杀掉它(或 kill <PID>)
rm -f /tmp/meituan_etl.lock ~/data/.meituan_etl.lock # 清掉残留锁,否则下一轮可能"跳过本轮"
```
> 脚本有 **30 分钟陈旧锁自动接管**机制:即使没手动清锁,30 分钟后下一轮也会自愈;急着立刻重跑才需手动 `rm` 锁。
## 调整频率
`crontab -e` 改那行最前面的 cron 表达式:
| 频率 | 表达式 |
|---|---|
| 每小时第 17 分(当前) | `17 * * * *` |
| 每 30 分钟 | `*/30 * * * *` |
| 每 2 小时 | `17 */2 * * *` |
> ⚠️ 别调太密:一轮 ~140 秒,且要守美团限流(脚本已配速到 ~2–3 req/s,远低于 ~15 req/s 红线;402 内置退避重试)。**每小时一轮足够**,勿并行、勿调小页间 sleep。
## 脚本参数
- `--once`:跑一轮(定时任务用这个)
- `--loop --interval 600`:常驻循环,每 600 秒一轮(调试用)
- `--prune-hours N`:清理超过 N 小时没再出现的旧券(默认 24;`0`=不清)
- 锁文件位置可用环境变量 `MEITUAN_ETL_LOCK=/path/to.lock` 覆盖
> 详见脚本头部注释:`head -30 scripts/pull_meituan_coupons.py`
## 已知问题与根治(运行锁权限)
- **现象**:cron 报 `PermissionError: [Errno 13] ... 'data/.meituan_etl.lock'`,每轮抓不进数据、表为空。
- **根因**:旧版脚本把运行锁建在代码目录的 `data/` 下,而 `data/` 属 root、`cps` 不可写(交接清单里"已给写权限"实际不成立)。
- **根治(已在代码层修复)**:脚本把锁默认改到 `/tmp/meituan_etl.lock`(任何账号可写),并支持 `MEITUAN_ETL_LOCK` 覆盖。**部署该版本后,用上面「根治版」那条干净 crontab 即可。**
- **过渡写法(根治版部署前的临时方案)**:crontab 里 `cd /home/cps` 把锁落到家目录、再用 `PYTHONPATH` 让代码仍能 import:
```cron
17 * * * * cd /home/cps && PYTHONPATH=/opt/shaguabijia-app-server /opt/shaguabijia-app-server/.venv/bin/python -m scripts.pull_meituan_coupons --once --prune-hours 24 >> /home/cps/meituan_etl.log 2>&1
```
根治版上线后,把这条换回上面的「根治版」标准写法即可。
## 注意事项
- **只抓北京**:脚本里 `city_id` 硬编码,扩城市需改脚本。
- **表行数 > 单轮抓取数**属正常:美团 `product_view_sign` 会轮换,跨轮累积;查询侧有 `DISTINCT ON(dedup_key)` 按「品牌\|名\|价」去重,用户看到的不重复;旧券 24h 后被 prune 清掉。
- **别和 systemd 双跑**:仓库 `deploy/meituan-etl.{service,timer}` 是**未来由 root 纳入 systemd 正规部署**的备选,和本 user cron **二选一、别同时开**(否则重复抓、浪费限流额度)。当前生产用 user cron。
- **改脚本 / 改部署**:走 git + PR,让有 root 的人 `/opt/deploy.sh` 部署(`git reset` + `alembic upgrade` + 重启);`cps` 账号改不了 `/opt` 下的代码。
+33
View File
@@ -0,0 +1,33 @@
# 美团 CPS 券 ETL —— 单轮抓取入库,由 meituan-etl.timer 每小时触发(Linux 服务器用)。
#
# 仅用于 mentor 的 Linux 服务器;本机 Windows 开发无 systemd,直接手动跑脚本即可:
# .venv\Scripts\python -m scripts.pull_meituan_coupons --once --prune-hours 24
#
# 部署(服务器):
# sudo cp deploy/meituan-etl.{service,timer} /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now meituan-etl.timer
#
# 前置:DATABASE_URL 必须是 postgresql+psycopg://(ETL 的 upsert 是 PG 专用);
# MT_CPS_APP_KEY/SECRET 已配;服务器上 MT_CPS_PROXY 留空(直连)。
[Unit]
Description=Meituan CPS coupon ETL (one-shot, driven by timer)
After=network-online.target postgresql.service
Wants=network-online.target postgresql.service
[Service]
Type=oneshot
User=root
WorkingDirectory=/opt/shaguabijia-app-server
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
EnvironmentFile=/opt/shaguabijia-app-server/.env
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.pull_meituan_coupons --once --prune-hours 24
SyslogIdentifier=meituan-etl
# 脚本自带 30min 文件锁;给 20min 硬超时,防卡死轮次长期占锁。
TimeoutStartSec=1200
# 与主服务 shaguabijia-app-server.service 同款加固。
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/shaguabijia-app-server
ProtectHome=true
+14
View File
@@ -0,0 +1,14 @@
# 每小时触发一次美团 CPS 券 ETL(Linux 服务器用)。
# 见 meituan-etl.service 顶部注释的部署步骤。
[Unit]
Description=Run Meituan CPS coupon ETL hourly
[Timer]
# 每小时第 5 分钟跑,避开整点其它定时任务扎堆。
OnCalendar=*-*-* *:05:00
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等下一个整点)。
Persistent=true
AccuracySec=1min
[Install]
WantedBy=timers.target
+21 -19
View File
@@ -83,25 +83,27 @@
| A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin-user-detail.md) |
| A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin-user-status.md) |
| A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin-user-coins.md) |
| A8 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) |
| A9 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) |
| A10 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) |
| A11 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) |
| A12 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) |
| A13 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) |
| A14 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) |
| A15 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) |
| A16 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) |
| A17 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) |
| A18 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) |
| A19 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) |
| A20 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) |
| A21 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin-marquee-seeds.md) |
| A22 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin-marquee-seeds.md) |
| A23 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
| A24 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
| A25 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) |
| A26 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) |
| A8 | `POST /admin/api/users/{user_id}/cash` | finance | [详情](./admin-user-cash.md) |
| A9 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) |
| A10 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) |
| A11 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) |
| A12 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) |
| A13 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) |
| A14 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) |
| A15 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) |
| A16 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) |
| A17 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) |
| A18 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) |
| A19 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) |
| A20 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) |
| A21 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) |
| A22 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin-marquee-seeds.md) |
| A23 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin-marquee-seeds.md) |
| A24 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
| A25 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
| A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) |
| A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) |
| A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link``sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
+1 -1
View File
@@ -9,7 +9,7 @@
|---|---|---|---|
| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 |
| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;需和穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配 |
| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,后端按“元/千次展示”参与金币公式 |
| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,单位是**分/千次展示**(非元),后端 ÷100 转元参与金币公式 |
| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` |
| `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) |
+1 -1
View File
@@ -12,7 +12,7 @@
|---|---|---:|---|
| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 |
| `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 |
| `ecpm` | string | 是 | 本条信息流广告 eCPM,按“/千次展示”处理 |
| `ecpm` | string | 是 | 本条信息流广告 eCPM(穿山甲 getEcpm 原值),按“/千次展示”处理(非元) |
| `duration_seconds` | int | 是 | 实际展示/播放秒数 |
| `adn` | string\|null | 否 | 实际投放 ADN |
| `slot_id` | string\|null | 否 | 实际展示代码位 |
+3
View File
@@ -10,6 +10,7 @@
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。`reward_video`=普通激励视频;`signin_boost`=签到膨胀 |
| `ad_session_id` | string(8~64) \| null | 否 | null | 本次广告会话 id(与 [ecpm-report](./ad-ecpm-report.md) 同值)。**仅 `reward_video` 场景生效**:据此查回客户端已上报的真实 eCPM,走与正式发奖相同的公式发奖;查不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍出非零金币 |
## 出参
响应 `200`:`TestGrantOut`
@@ -30,4 +31,6 @@
## 说明
没公网、穿山甲 S2S 回调打不到本地时,debug 客户端看完广告后调它,直接走与 [ad-pangle-callback](./ad-pangle-callback.md) 相同的发奖逻辑(每次新 `trans_id`,幂等 + 每日上限/今日膨胀一次)。
`reward_scene=reward_video` 时按上面 `ad_session_id` 查回的真实 eCPM 走金币公式发奖(取不到兜底 200)——便于本地用 [admin 金币审计](./admin-ad-coin-audit.md) 核对「看广告→金币」是否按公式计算。
`reward_scene=signin_boost` 时复用签到膨胀业务规则:必须当天已签到、非第 14 天、当天未膨胀过,成功后写入 `signin_boost` 金币流水。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。
+71
View File
@@ -0,0 +1,71 @@
# Admin 看广告金币审计
> 所属:Admin 组(前缀 `/admin/api/ad-coin-audit`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md)
把「看视频赚金币」(`ad_reward_record`)和「比价信息流广告」(`ad_feed_reward_record`)两类发奖记录,用与**正式发奖完全相同**的公式 [`app/core/rewards.py` `calculate_ad_reward_coin`](../../app/core/rewards.py) 复算一遍 `expected_coin`,与实际入账的 `actual_coin` 对比,核对金币公式是否生效。**纯只读对账**,不发币、不改任何数据。
相关表:[ad_reward_record](../database/ad_reward_record.md)、[ad_feed_reward_record](../database/ad_feed_reward_record.md)。
## 金币公式(展示参照)
```
eCPM元 = getEcpm分 ÷ 100
单份金币 = round( eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan ) # coin_per_yuan=10000
```
- **eCPM 口径**:`getEcpm()` 原值单位是分/千次展示;先 ÷100 转元,**因子判档与收益换算都用元**。
- **因子1(eCPM 档,阈值单位=元/千次)**:≤100→0.1,101200→0.3,201400→0.4,>400→0.6(即 ¥100/¥200/¥400 CPM 分界。**真实 eCPM 多 <¥100 CPM,故常落最低档 0.1,高档基本不触发——产品有意取舍**)。
- **因子2(LT,当日第 N 份)**:第1→2.0,第2→1.5,第3→1.3,第410→1.1,≥11→1.0。
- **第 N 份的计数**:看视频每条 granted 记 1 份;信息流每满 10 秒记 1 份。**两个点位各自独立计数**(看视频的份数不影响信息流的份数,反之亦然)。
## GET /admin/api/ad-coin-audit — 复算对比
- 入参(均 query,可选):
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `date` | string | 今天 | 北京时间 `YYYY-MM-DD`,审计某天 |
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
| `scene` | string | 两类 | `reward_video` / `feed`;不传=两类都返回 |
| `limit` | int(1~500) | 100 | **展示**明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) |
| `only_mismatch` | bool | false | 只展示不一致(✗)行;统计数仍按全量,不受影响 |
- 出参 `200`:`AdCoinAuditOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `date` | string | 审计日期 |
| `formula` | object | 当前公式参数快照(见下) |
| `total` | int | 该筛选下复算**总条数**(全量,不受 `limit`/`only_mismatch` 影响) |
| `mismatch_count` | int | **全量**不一致条数(截断前统计,可信);**=0 说明全部按公式发放** |
| `truncated` | bool | 展示集是否被 `limit` 截断(true=还有未返回的明细,请缩小范围或调大 `limit`) |
| `items` | `AdCoinAuditRow[]` | 展示明细(见下);`only_mismatch=true` 时只含 ✗ 行 |
### AdCoinFormulaOut(`formula`)
| 字段 | 类型 | 说明 |
|---|---|---|
| `description` | string | 公式文字说明 |
| `coin_per_yuan` | int | 金币:元 汇率(10000) |
| `ecpm_unit` | string | eCPM 口径(分/千次展示,SDK getEcpm 原值) |
| `feed_unit_seconds` | int | 信息流每多少秒折 1 份(10) |
| `ecpm_factor_tiers` | `[因子, 下限, 上限\|null][]` | 因子1 档位表(直接读发奖常量,与发奖同源) |
| `lt_factor_tiers` | `[因子, 下限, 上限\|null][]` | 因子2 LT 档位表 |
### AdCoinAuditRow(`items[]`)
| 字段 | 类型 | 说明 |
|---|---|---|
| `scene` | string | `reward_video` / `feed` |
| `record_id` | int | 对应记录表主键 |
| `user_id` | int | |
| `created_at` | datetime | |
| `status` | string | `granted` / `capped`(超每日上限未发) / `ecpm_missing`(缺 eCPM 未发) |
| `ecpm` | string \| null | 本次采用的 eCPM 原始值 |
| `ecpm_factor` | float \| null | 因子1;非 granted 为 null |
| `units` | int | 折算份数:看视频恒 1;信息流 = 满 10 秒份数 |
| `lt_index_start` / `lt_index_end` | int \| null | 本条占用「当日第几份」的起止(看视频起=止;信息流一条可跨多份) |
| `lt_factor_start` / `lt_factor_end` | float \| null | 因子2 的起止值(信息流跨份时会衰减,故给区间) |
| `expected_coin` | int | 按公式复算应发金币(非 granted 恒 0) |
| `actual_coin` | int | 实际入账金币 |
| `matched` | bool | 复算与实发是否一致;非 granted 校验实发是否确为 0 |
## 说明
- **非 granted 行**(capped/ecpm_missing)不占用份序号、应发恒 0,`matched` 用于校验「该不发的确实没发」。
- 用法建议:**按 `user_id`+`date` 定位某次具体核对**最直观;不带 `user_id` 是全用户当天概览。
- 本地联调造数:debug 包看完激励视频会调 [`/api/v1/ad/test-grant`](./ad-test-grant.md),它会用客户端按 `ad_session_id` 上报的真实 eCPM 发奖(取不到兜底 200),所以本审计能看到真实 eCPM 对应的金币。
+2 -2
View File
@@ -27,7 +27,7 @@
| `random_kind` | string | 自增长方式:`mult` ×倍率 / `add` +绝对增量 |
| `random_step_min` | int | add 模式增量下限(基础单位) |
| `random_step_max` | int | add 模式增量上限(基础单位) |
| `real_offset` | int | real 模式基数偏移(基础单位):展示 = 真实值 + 偏移 |
| `real_offset` | int | real 模式保底值(基础单位):展示 = max(真实值, 保底值)。字段名沿用 real_offset(语义已改为"保底" |
| `allow_decrease` | bool | 是否允许展示值下降(默认 false = 只增不减) |
| `random_current` | int \| null | 当前展示值(所有模式,基础单位) |
| `random_last_tick_at` | string \| null | 上次 tick 时刻(ISO |
@@ -52,7 +52,7 @@
| `random_kind` | string | 自增长方式:`mult` / `add` |
| `random_step_min` | int | add 增量下限(基础单位,≥0) |
| `random_step_max` | int | add 增量上限(基础单位,≥下限) |
| `real_offset` | int | real 基数偏移(基础单位,≥0 |
| `real_offset` | int | real 保底值(基础单位,≥0:展示 = max(真实值, 保底值) |
| `allow_decrease` | bool | 允许展示值下降(默认 false=只增不减) |
| `random_initial` | int | 切 random 的初始基数(基础单位);**留空则用当前真实值播种** |
| `apply_now` | bool | 立即更新:不等更新钟点,保存后马上刷新一次(random 走一档,real/manual 取应有值,均经护栏) |
+11 -5
View File
@@ -1,4 +1,4 @@
# GET /admin/api/feedbacks — 反馈工单列表(游标分页)
# GET /admin/api/feedbacks — 反馈工单列表(offset 分页 + 筛选/排序)
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 `require_role`,仅 `get_current_admin` | [← 返回 API 索引](./README.md)
@@ -7,8 +7,13 @@
|---|---|---|---|---|
| `status` | string | ❌ | null | 反馈状态,精确匹配:`new`(待处理) / `handled`(已处理);传空/不传则不筛 |
| `user_id` | int | ❌ | null | 按提交用户 id 精确筛 |
| `content` | string | ❌ | null | 反馈内容模糊匹配(ilike,≤100 字) |
| `created_from` | datetime | ❌ | null | 提交时间 ≥(ISO,统一按 UTC 比较) |
| `created_to` | datetime | ❌ | null | 提交时间 ≤(ISO,统一按 UTC 比较) |
| `sort_by` | string | ❌ | `id` | 排序列:`id` / `created_at` |
| `sort_order` | string | ❌ | `desc` | `asc` / `desc` |
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页 next_cursor(按 feedback id 倒序,查 `id < cursor`) |
| `cursor` | int | ❌ | null | 上一页 next_cursor(**offset 分页**,cursor=offset) |
## 出参
响应 `200`:`{ items: FeedbackOut[], next_cursor: int|null }`(`next_cursor=null` 表示末页)
@@ -26,9 +31,10 @@
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `422` `limit` 超出 1–100 范围 / 字段类型不合法
- `422` `limit` 超出 1100 范围 / `sort_by`·`sort_order` 不在允许集 / 字段类型不合法
## 说明
- 游标分页约定:结果按 feedback `id` 倒序;`cursor` 传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。
- `status` / `user_id` 均为精确匹配,可叠加
- **offset 分页**(同 [admin-users-list](./admin-users-list.md)):`cursor` 即 offset,传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。改用 offset 是为了在任意列排序下游标语义统一,代价是翻页期间数据变动可能错位一条(admin 低频可接受)。
- 排序:`sort_by`(id/created_at)× `sort_order`(asc/desc),恒以 `id` 同向兜底次序
- `status` / `user_id` 精确匹配、`content` 模糊、`created_from`/`created_to` 时间范围,均可叠加。
- 关联表 [feedback](../database/feedback.md);截图为相对路径,经 `GET /media/feedback/<file>` 静态读。
+11 -1
View File
@@ -31,6 +31,16 @@
- 入参 `OpsMarqueeSeedBulkCreate`:`count`(1~200)、`min_cents``max_cents``enabled`(默认 true)
- 出参 `200`:`list[OpsMarqueeSeedOut]`(新建的 count 条)。`400`=数量 / 金额非法。
## POST /admin/api/marquee-seeds/batch-delete — 批量删除(带审计)
对选中的多条种子一次删除(一个事务 + 一条审计)。
- 入参 `OpsMarqueeSeedBatchDelete`:`ids`(int 数组;空数组 = no-op;不存在的 id 自动跳过)
- 出参 `200`:`{"deleted": <实际删除条数>}`
## POST /admin/api/marquee-seeds/batch-enable — 批量启用 / 停用(带审计)
对选中的多条种子一次置启用态。
- 入参 `OpsMarqueeSeedBatchEnable`:`ids`(int 数组)、`enabled`(bool)
- 出参 `200`:`{"updated": <实际更新条数>}`
## PATCH /admin/api/marquee-seeds/{seed_id} — 改(带审计)
入参 `OpsMarqueeSeedUpdate`(均可选):`masked_user` / `min_cents` / `max_cents` / `enabled` / `sort_order`
- `masked_user` 约定:**不传**=不改;**传空串**=清空(改回随机合成);**传值**=固定该名。
@@ -41,5 +51,5 @@
出参 `200`:`{"deleted": true}``404`=种子不存在。
## 说明
- 写操作需 operator(super 恒可),均写 `admin_audit_log`(action=`ops_marquee_seed.create` / `update` / `delete` / `bulk_generate`)。预览为只读,任意已登录 admin 可用。
- 写操作需 operator(super 恒可),均写 `admin_audit_log`(action=`ops_marquee_seed.create` / `update` / `delete` / `bulk_generate` / `batch_delete` / `batch_enable`)。预览为只读,任意已登录 admin 可用。
- 改动客户端**进首页 / 回前台**重拉 feed 时生效。
+34
View File
@@ -0,0 +1,34 @@
# POST /admin/api/users/{user_id}/cash — 手动增减/设值现金(带审计)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
主要用于给无现金用户直接发钱、好让其测试提现链路。
## 入参
- 路径:`user_id`(int)
- **application/json**:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `mode` | string | ✗ | `delta`(默认)=增减 / `set`=设为指定值 |
| `amount_cents` | int | ✓ | `delta` 模式:现金变动(分,正=发放,负=扣减,不可为 0);`set` 模式:目标现金值(分,须 ≥ 0) |
| `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) |
## 出参
响应 `200`:`OkResponse` = `{ "ok": true }`
## 错误码
- `400`(`delta`)`amount_cents == 0`(`detail: "amount_cents 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后现金为负(当前余额 N 分)"`)
- `400`(`set`)目标值为负(`detail: "目标现金值不能为负"`);或目标值等于当前余额(`detail: "当前现金已为 N 分,无需调整"`)
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `403` 角色不足(需 `finance``super_admin`)
- `404` 用户不存在(`detail: "用户不存在"`)
- `422``amount_cents`/`reason``reason` 长度不在 1128、`mode``delta`/`set`、字段类型不合法 / `user_id` 非整数
## 说明
- 金额单位一律为**分**(`*_cents`);本接口只动现金余额,不涉及金币。
- **set 模式**:读当前余额算出差值 `delta = target - 当前余额`,再复用同一套写入逻辑(故只写一笔差值流水)。目标值须 ≥ 0;差值为 0(已等于目标)直接拒绝。
- 扣减保护:实际写入的 `delta < 0` 时若扣减后现金余额 < 0 直接拒绝(运营误操作保护);set 模式目标值 ≥ 0 天然不会扣成负。
- 现金变动写流水 [cash_transaction](../database/cash_transaction.md):`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:<reason>`(截断至 128 字)。
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.cash.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount_cents(=实际差值), balance_after_cents, reason}`;set 模式额外带 `{mode:"set", target_cents, before_cents}`。并记录操作 IP。
- 现金变动 + 审计在同一事务原子提交(改钱必留痕)。
- 关联用户表 [user](../database/user.md);现金账户 [coin_account](../database/coin_account.md);现金流水 [cash_transaction](../database/cash_transaction.md)。
+10 -7
View File
@@ -1,4 +1,4 @@
# POST /admin/api/users/{user_id}/coins — 手动增减金币(带审计)
# POST /admin/api/users/{user_id}/coins — 手动增减/设值金币(带审计)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
@@ -7,23 +7,26 @@
- **application/json**:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `amount` | int | | 金币变动(个数):正=增加,负=扣减;不可为 0 |
| `mode` | string | | `delta`(默认)=增减 / `set`=设为指定值 |
| `amount` | int | ✓ | `delta` 模式:金币变动(正=增加,负=扣减,不可为 0);`set` 模式:目标金币值(须 ≥ 0) |
| `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) |
## 出参
响应 `200`:`OkResponse` = `{ "ok": true }`
## 错误码
- `400` `amount == 0`(`detail: "amount 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后金币为负(当前余额 N)"`)
- `400`(`delta`)`amount == 0`(`detail: "amount 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后金币为负(当前余额 N)"`)
- `400`(`set`)目标值为负(`detail: "目标金币值不能为负"`);或目标值等于当前余额(`detail: "当前金币已为 N,无需调整"`)
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `403` 角色不足(需 `finance``super_admin`)
- `404` 用户不存在(`detail: "用户不存在"`)
- `422``amount`/`reason``reason` 长度不在 1–128、字段类型不合法 / `user_id` 非整数
- `422``amount`/`reason``reason` 长度不在 1128、`mode``delta`/`set`字段类型不合法 / `user_id` 非整数
## 说明
- 金币 = 个数(非现金);本接口只动金币余额,不涉及现金(`*_cents`)。
- 扣减保护:`amount < 0` 时若扣减后金币余额 < 0 直接拒绝(运营误操作保护)
- 金币变动写流水 [coin_transaction](../database/coin_transaction.md):`biz_type` 增加为 `admin_grant`、扣减为 `admin_deduct`,`remark = admin:<reason>`(截断至 128 字)
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.coins.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount, balance_after, reason}`,并记录操作 IP
- **set 模式**:读当前余额算出差值 `delta = target - 当前余额`,再复用同一套写入逻辑(故只写一笔差值流水)。目标值须 ≥ 0;差值为 0(已等于目标)直接拒绝
- 扣减保护:实际写入的 `delta < 0` 时若扣减后金币余额 < 0 直接拒绝(运营误操作保护);set 模式目标值 ≥ 0 天然不会扣成负
- 金币变动写流水 [coin_transaction](../database/coin_transaction.md):`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:<reason>`(截断至 128 字)
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.coins.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount(=实际差值), balance_after, reason}`;set 模式额外带 `{mode:"set", target, before}`。并记录操作 IP。
- 金币变动 + 审计在同一事务原子提交(改钱必留痕)。
- 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);金币流水 [coin_transaction](../database/coin_transaction.md)。

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