Compare commits

..

14 Commits

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

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

---------

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

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

---------

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

---------

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

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

---------

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

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

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

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

---------

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

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

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #116
Co-authored-by: guke <guke@wonderable.ai>
Co-committed-by: guke <guke@wonderable.ai>
2026-07-05 09:31:53 +08:00
81 changed files with 3360 additions and 432 deletions
+9
View File
@@ -48,3 +48,12 @@ secrets/*
# 运行日志(run.sh 输出, 不入库)
*.log
logs/
# Claude Code 自动持久化的权限 allowlist / 个人本地设置(会话专属,不入库)。
# 需要团队共享的 Claude 配置(commands/ 等)可单独 git add -f,不受此忽略影响。
.claude/settings.json
.claude/settings.local.json
tests/meituan_coupon_bj.tsv
tests/meituan_coupon_data.tsv
tests/meituan_coupon_fz.tsv
tests/meituan_coupon_xm.tsv
@@ -0,0 +1,35 @@
"""admin_user 加 pages_override 列(「自定义」权限:按人存专属可见页 key 列表)
权限管理页新增「自定义」角色:选它时该成员的可见页不跟随任何共享角色,而由逐页勾选决定,
存这个人专属的一份页 key 列表。仅 role == "custom" 时有效;普通角色为 None(可见页跟随角色)。
PG 用 JSONB,SQLite 退化为通用 JSON(同 admin_audit_log.detail / comparison_record.raw_payload)。
Revision ID: admin_user_pages_override
Revises: admin_user_plain_password
Create Date: 2026-07-08 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
from alembic import op
revision: str = "admin_user_pages_override"
down_revision: str | Sequence[str] | None = "admin_user_plain_password"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# 与 app/models/admin.py 的 _JSON 一致:PG JSONB / 其它 JSON
_JSON = sa.JSON().with_variant(JSONB(), "postgresql")
def upgrade() -> None:
with op.batch_alter_table("admin_user", schema=None) as batch_op:
batch_op.add_column(sa.Column("pages_override", _JSON, nullable=True))
def downgrade() -> None:
with op.batch_alter_table("admin_user", schema=None) as batch_op:
batch_op.drop_column("pages_override")
+3
View File
@@ -9,6 +9,9 @@ super_admin 为内建全权角色,恒可见全部页(effective_pages 特判)。
from __future__ import annotations
SUPER_ADMIN_ROLE = "super_admin"
# 「自定义」哨兵角色:不是 admin_role 表里的行,而是标记「这个人的可见页由 pages_override 决定」。
# admin_user.role == CUSTOM_ROLE 时,有效可见页取 admin_user.pages_override(见 auth._admin_out_with_pages)。
CUSTOM_ROLE = "custom"
# 分组镜像前端导航(app/(main)/layout.tsx 的 NAV_GROUPS);key = 路由一级
PERMISSION_CATALOG: list[dict] = [
+28 -4
View File
@@ -385,10 +385,33 @@ def ad_revenue_report(
for k, v in type_map.items()
}
# DAU:复用大盘「今日活跃」口径(stats.today_dau,last_login_at)。该口径只能算今日,
# 故仅当查询=今日单天时给值;历史 / 多天区间返回 None,前端显示「-」。
is_today = date_from == date_to == rewards.cn_today().isoformat()
dau = admin_stats.today_dau(db) if is_today else None
# 分场景小计(按 feed_scene:展示条数 + 预估收益),同 type_stats 基于全量 events——
# 供数据大盘「领券广告 / 比价广告」卡用。此前大盘是在分页 items 里按 feed_scene 现算,
# 2026-07-02 起信息流逐条展示行(唯一带收益 + 场景的行)不再进主表 items,现算恒为 0;
# 改为服务端在全量上聚合下发(也顺带不受 limit 分页截断影响)。feed_scene 为空(激励视频 /
# 旧数据)不计入任何场景桶。
scene_map: dict[str, dict] = {}
for e in events:
sc = e.get("feed_scene")
if not sc:
continue
s = scene_map.get(sc)
if s is None:
s = {"impressions": 0, "revenue_yuan": 0.0}
scene_map[sc] = s
s["impressions"] += e["impressions"]
s["revenue_yuan"] += e["revenue_yuan"]
scene_stats = {
k: {"impressions": v["impressions"], "revenue_yuan": round(v["revenue_yuan"], 6)}
for k, v in scene_map.items()
}
# DAU:复用数据大盘活跃用户口径(登录 + 开始比价 + 开始领券,按用户去重),按所选日期区间
# 统计(含今日),历史 / 多天区间同样有值。ARPU = 区间预估收益 ÷ 区间活跃用户。全局口径,
# 不随 user / ad_type / feed_scene / app_env 筛选变化(活跃用户口径无这些维度)。
dau = admin_stats.period_active_dau(
db, _date.fromisoformat(date_from), _date.fromisoformat(date_to)
)
# 主表「逐行」= 单次广告行为(2026-07 按「一次比价/领券放一块」聚合):激励视频 = 一次观看一行(展示+发奖
# 按 ad_session_id 合并);一次比价 / 一次领券 = 该次整场多条广告按 ad_session_id 聚成一行(展开看逐条)。
@@ -416,6 +439,7 @@ def ad_revenue_report(
"daily": daily,
"hourly": hourly,
"type_stats": type_stats,
"scene_stats": scene_stats,
"dau": dau,
"items": main_rows[offset:offset + limit],
}
+4 -1
View File
@@ -26,14 +26,17 @@ def create_admin(
password: str,
role: str = "operator",
plain_password: str | None = None,
pages_override: list[str] | None = None,
) -> AdminUser:
"""建管理员。plain_password 非空则额外留存明文(后台 UI 建的账号传,供权限管理页复看);
脚本/起后台建账号不传(留 None → 前端不显示密码)。"""
脚本/起后台建账号不传(留 None → 前端不显示密码)。
pages_override:role=="custom" 时传专属可见页 key 列表;普通角色为 None。"""
admin = AdminUser(
username=username,
password_hash=hash_password(password),
role=role,
plain_password=plain_password,
pages_override=pages_override,
)
db.add(admin)
db.commit()
+21 -5
View File
@@ -51,7 +51,27 @@ def _yuan_to_cents(v: object) -> int | None:
def _ts_to_dt(ts: object) -> datetime | None:
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
if not ts:
if ts is None:
return None
if isinstance(ts, datetime):
return ts if ts.tzinfo else ts.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc)
s = str(ts).strip()
if not s or s.lower() == "null":
return None
try:
seconds = float(Decimal(s))
except (InvalidOperation, ValueError):
return None
if seconds == 0:
return None
# 美团文档是秒级时间戳,这里顺手兼容毫秒/微秒,避免上游格式变化导致时间再次落空。
if abs(seconds) > 10_000_000_000_000:
seconds /= 1_000_000
elif abs(seconds) > 10_000_000_000:
seconds /= 1_000
try:
return datetime.fromtimestamp(seconds, tz=timezone.utc)
except (OverflowError, OSError, ValueError):
return None
@@ -83,10 +103,6 @@ def _pick(row: dict[str, Any], *keys: str) -> Any:
if key in row and row[key] is not None:
return row[key]
return None
try:
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
except (ValueError, OSError, TypeError):
return None
# ───────────── 群 ─────────────
+217 -35
View File
@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo
from sqlalchemy import Select, asc, case, desc, func, or_, select
from sqlalchemy.orm import Session
from app.admin.repositories.stats import COMPARE_START_EVENT, COUPON_START_EVENT
from app.core import rewards
from app.core.config import settings
from app.models.ad_feed_reward import AdFeedRewardRecord
@@ -18,12 +19,22 @@ from app.models.ad_reward import AdRewardRecord
from app.models.admin import AdminAuditLog
from app.models.analytics_event import AnalyticsEvent
from app.models.comparison import ComparisonRecord
from app.models.coupon_state import CouponPromptEngagement
from app.models.device import DeviceLiveness
from app.models.feedback import Feedback
from app.models.onboarding import OnboardingCompletion
from app.models.price_report import PriceReport
from app.models.user import User
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
from app.models.wallet import (
CashTransaction,
CoinAccount,
CoinTransaction,
InviteCashTransaction,
WithdrawOrder,
)
# 「最近活跃」计入的行为事件(与大盘 DAU/留存活跃口径一致:开始比价 + 开始领券)
_ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT)
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
@@ -76,6 +87,86 @@ def offset_paginate(
return items, next_cursor, total
def _last_active_parts():
"""「最近活跃」的两个按 user_id 预聚合派生表(最近开始比价/领券事件、最近领券发起)。
活跃口径与大盘 DAU/留存一致(2026-07-05 产品定:进入 App≈登录 last_login_at +
发起比价 real_compare_start + 发起领券 real_coupon_start/claim_started)。
用 LEFT JOIN 预聚合而非相关标量子查询:后者在 PG 上对 users 每行各跑一个 SubPlan
(排序键、range 筛选、offset_paginate 的 count 三处叠加),埋点表大了会拖垮列表接口;
预聚合借 analytics_event.event 索引只扫两类 start 事件,每次查询聚合一次。
"""
ev_agg = (
select(
AnalyticsEvent.user_id.label("user_id"),
func.max(AnalyticsEvent.created_at).label("last_at"),
)
.where(
AnalyticsEvent.user_id.is_not(None),
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
)
.group_by(AnalyticsEvent.user_id)
.subquery()
)
eng_agg = (
select(
CouponPromptEngagement.user_id.label("user_id"),
func.max(CouponPromptEngagement.created_at).label("last_at"),
)
.where(
CouponPromptEngagement.user_id.is_not(None),
CouponPromptEngagement.engage_type == "claim_started",
)
.group_by(CouponPromptEngagement.user_id)
.subquery()
)
return ev_agg, eng_agg
def _norm_utc(dt: datetime | None) -> datetime | None:
"""naive 视为 UTC 补 tzinfo(SQLite 读回 naive、PG 读回 aware,混着 max() 会 TypeError)。"""
if dt is None:
return None
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
def _attach_last_active(db: Session, users: list[User]) -> None:
"""给本页用户瞬态挂 last_active_at(非 DB 列,供 AdminUserListItem from_attributes 读)。
口径同 [_last_active_expr];按本页 user_id 批量两次 GROUP BY,防 N+1。
"""
uids = [u.id for u in users]
if not uids:
return
ev_map = dict(
db.execute(
select(AnalyticsEvent.user_id, func.max(AnalyticsEvent.created_at))
.where(
AnalyticsEvent.user_id.in_(uids),
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
)
.group_by(AnalyticsEvent.user_id)
).all()
)
eng_map = dict(
db.execute(
select(CouponPromptEngagement.user_id, func.max(CouponPromptEngagement.created_at))
.where(
CouponPromptEngagement.user_id.in_(uids),
CouponPromptEngagement.engage_type == "claim_started",
)
.group_by(CouponPromptEngagement.user_id)
).all()
)
for u in users:
candidates = [
_norm_utc(u.last_login_at),
_norm_utc(ev_map.get(u.id)),
_norm_utc(eng_map.get(u.id)),
]
u.last_active_at = max((c for c in candidates if c is not None), default=None)
def list_users(
db: Session,
*,
@@ -87,16 +178,34 @@ def list_users(
created_to: datetime | None = None,
last_login_from: datetime | None = None,
last_login_to: datetime | None = None,
last_active_from: datetime | None = None,
last_active_to: datetime | None = None,
sort_by: str = "id",
sort_order: str = "desc",
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[User], int | None, int]:
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选,
按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录·最近活跃
时间范围筛选,按 id·注册时间·最近登录·最近活跃排序;每页附带计算列 last_active_at
(口径见 [_last_active_expr])。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
stmt = select(User)
# 最近活跃 = max(最近登录, 最近行为事件, 最近领券发起)。PG 用 GREATEST;SQLite 标量 max()
# 任一参数 NULL 即返回 NULL,故 LEFT JOIN 未命中侧 coalesce 到 last_login_at 兜底
# (注册即登录,该列恒非空)。派生表 1:1(按 user_id 聚合),outerjoin 不会放大行数,
# offset_paginate 的 count 不受影响。
ev_agg, eng_agg = _last_active_parts()
greatest = func.greatest if db.get_bind().dialect.name == "postgresql" else func.max
last_active = greatest(
User.last_login_at,
func.coalesce(ev_agg.c.last_at, User.last_login_at),
func.coalesce(eng_agg.c.last_at, User.last_login_at),
)
stmt = (
select(User)
.outerjoin(ev_agg, ev_agg.c.user_id == User.id)
.outerjoin(eng_agg, eng_agg.c.user_id == User.id)
)
if phone:
stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配
if register_channel:
@@ -113,16 +222,25 @@ def list_users(
stmt = stmt.where(User.last_login_at >= _as_utc(last_login_from))
if last_login_to is not None:
stmt = stmt.where(User.last_login_at <= _as_utc(last_login_to))
if last_active_from is not None:
stmt = stmt.where(last_active >= _as_utc(last_active_from))
if last_active_to is not None:
stmt = stmt.where(last_active <= _as_utc(last_active_to))
sort_cols = {
"id": User.id,
"created_at": User.created_at,
"last_login_at": User.last_login_at,
"last_active_at": last_active,
}
sort_col = sort_cols.get(sort_by, User.id)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(User.id) if sort_order == "asc" else desc(User.id)
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
items, next_cursor, total = offset_paginate(
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
)
_attach_last_active(db, items)
return items, next_cursor, total
def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | PriceReport]) -> None:
@@ -734,26 +852,19 @@ def withdraw_risk_flags(
return flags, score
def withdraw_ledger_check(db: Session) -> dict:
cash_balance_total = int(
db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one()
)
cash_txn_total = int(
db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one()
)
def _check_withdraw_ledger_side(
orders: list[WithdrawOrder], txns: list, *, withdraw_biz: str, refund_biz: str
) -> dict:
"""对某一本账(普通现金 / 邀请奖励金)做提现单 ↔ 流水的交叉校验。
orders = list(db.execute(select(WithdrawOrder)).scalars().all())
cash_txns = list(
db.execute(
select(CashTransaction).where(
CashTransaction.biz_type.in_(("withdraw", "withdraw_refund"))
)
).scalars().all()
)
withdraw_refs = {txn.ref_id for txn in cash_txns if txn.biz_type == "withdraw"}
orders 已按 source 过滤到本账;txns 是本账流水表里 withdraw_biz/refund_biz 两类流水。
规则:每单发起应有一条扣款流水(ref_id=out_bill_no);失败/拒绝单应有且仅一条退款流水;
非退款终态不应出现退款流水。四个计数全为 0 即本账自洽。
"""
withdraw_refs = {txn.ref_id for txn in txns if txn.biz_type == withdraw_biz}
refund_counts: dict[str, int] = {}
for txn in cash_txns:
if txn.biz_type == "withdraw_refund" and txn.ref_id:
for txn in txns:
if txn.biz_type == refund_biz and txn.ref_id:
refund_counts[txn.ref_id] = refund_counts.get(txn.ref_id, 0) + 1
missing_withdraw = 0
@@ -768,24 +879,95 @@ def withdraw_ledger_check(db: Session) -> dict:
if has_refund and order.status not in {"failed", "rejected"}:
refund_on_non_terminal += 1
duplicate_refund = sum(1 for count in refund_counts.values() if count > 1)
diff = cash_balance_total - cash_txn_total
return {
"missing_withdraw": missing_withdraw,
"missing_refund": missing_refund,
"duplicate_refund": sum(1 for count in refund_counts.values() if count > 1),
"refund_on_non_terminal": refund_on_non_terminal,
}
def withdraw_ledger_check(db: Session) -> dict:
"""现金账本校验:两本物理隔离的账各自对账(产品红线:coin_cash / invite_cash 不串)。
普通现金:CoinAccount.cash_balance_cents ↔ cash_transaction(withdraw/withdraw_refund);
邀请奖励金:CoinAccount.invite_cash_balance_cents ↔ invite_cash_transaction
(invite_withdraw/invite_withdraw_refund)。
提现单按 source 分流到对应账核对——邀请提现的流水写在 invite_cash_transaction 表,
绝不能拿去和普通现金流水比(否则每笔邀请提现单都会被误报「缺扣款/缺退款流水」)。
分流口径与 create_withdraw 一致:仅 source==invite_cash 走邀请账,其余(含历史空值)归普通现金。
"""
orders = list(db.execute(select(WithdrawOrder)).scalars().all())
coin_orders = [o for o in orders if o.source != "invite_cash"]
invite_orders = [o for o in orders if o.source == "invite_cash"]
# —— 普通现金账(coin_cash) ——
cash_balance_total = int(
db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one()
)
cash_txn_total = int(
db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one()
)
cash_txns = list(
db.execute(
select(CashTransaction).where(
CashTransaction.biz_type.in_(("withdraw", "withdraw_refund"))
)
).scalars().all()
)
coin = _check_withdraw_ledger_side(
coin_orders, cash_txns, withdraw_biz="withdraw", refund_biz="withdraw_refund"
)
cash_diff = cash_balance_total - cash_txn_total
# —— 邀请奖励金账(invite_cash,独立账户 + 独立流水表) ——
invite_balance_total = int(
db.execute(
select(func.coalesce(func.sum(CoinAccount.invite_cash_balance_cents), 0))
).scalar_one()
)
invite_txn_total = int(
db.execute(
select(func.coalesce(func.sum(InviteCashTransaction.amount_cents), 0))
).scalar_one()
)
invite_txns = list(
db.execute(
select(InviteCashTransaction).where(
InviteCashTransaction.biz_type.in_(("invite_withdraw", "invite_withdraw_refund"))
)
).scalars().all()
)
invite = _check_withdraw_ledger_side(
invite_orders, invite_txns,
withdraw_biz="invite_withdraw", refund_biz="invite_withdraw_refund",
)
invite_diff = invite_balance_total - invite_txn_total
ok = (
diff == 0
and missing_withdraw == 0
and missing_refund == 0
and duplicate_refund == 0
and refund_on_non_terminal == 0
cash_diff == 0
and invite_diff == 0
and all(v == 0 for v in coin.values())
and all(v == 0 for v in invite.values())
)
return {
"ok": ok,
# 普通现金账(coin_cash:金币兑换的现金)
"cash_balance_total_cents": cash_balance_total,
"cash_transaction_total_cents": cash_txn_total,
"balance_diff_cents": diff,
"missing_withdraw_txn_count": missing_withdraw,
"missing_refund_txn_count": missing_refund,
"duplicate_refund_txn_count": duplicate_refund,
"refund_txn_on_non_terminal_count": refund_on_non_terminal,
"balance_diff_cents": cash_diff,
"missing_withdraw_txn_count": coin["missing_withdraw"],
"missing_refund_txn_count": coin["missing_refund"],
"duplicate_refund_txn_count": coin["duplicate_refund"],
"refund_txn_on_non_terminal_count": coin["refund_on_non_terminal"],
# 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)
"invite_cash_balance_total_cents": invite_balance_total,
"invite_cash_transaction_total_cents": invite_txn_total,
"invite_balance_diff_cents": invite_diff,
"invite_missing_withdraw_txn_count": invite["missing_withdraw"],
"invite_missing_refund_txn_count": invite["missing_refund"],
"invite_duplicate_refund_txn_count": invite["duplicate_refund"],
"invite_refund_txn_on_non_terminal_count": invite["refund_on_non_terminal"],
}
+222 -74
View File
@@ -5,17 +5,23 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
"""
from __future__ import annotations
from collections import Counter
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal, InvalidOperation
from sqlalchemy import func, select
from sqlalchemy import case, func, select
from sqlalchemy.orm import Session
from app.admin.repositories.coupon_data import _percentile
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.analytics_event import AnalyticsEvent
from app.models.comparison import ComparisonRecord
from app.models.coupon_state import CouponPromptEngagement
from app.models.coupon_state import (
CouponClaimRecord,
CouponPromptEngagement,
CouponSession,
)
from app.models.cps_order import CpsOrder
from app.models.feedback import Feedback
from app.models.savings import SavingsRecord
@@ -24,11 +30,17 @@ from app.models.user import User
from app.models.wallet import CoinTransaction, WithdrawOrder
_BEIJING = timezone(timedelta(hours=8))
COUPON_REWARD_BIZ_TYPES = ("reward_video", "ad_reward", "coupon", "coupon_reward")
REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward")
# 领券/比价奖励金币的真实来源是信息流广告发奖(ad_feed_reward_record,按 feed_scene 分场景);
# coin_transaction 里只有扁平的 feed_ad_reward、biz_type 不分 coupon/comparison,故这俩桶历史从未
# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。reward_video/
# ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)。
COUPON_REWARD_BIZ_TYPES = ("coupon", "coupon_reward")
COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward")
EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant")
UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",)
REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
*REWARD_VIDEO_BIZ_TYPES,
*COUPON_REWARD_BIZ_TYPES,
*COMPARISON_REWARD_BIZ_TYPES,
*EXCLUDED_REWARD_BIZ_TYPES,
@@ -55,29 +67,10 @@ def _beijing_today_start_utc() -> datetime:
def today_dau(db: Session) -> int:
"""今日活跃用户数(DAU):登录 + 开始比价 + 开始领券,按用户去重。
广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算
= period_active_dau(今天, 今天);历史 / 多天窗口用 period_active_dau 传区间(广告收益报表复用)
"""
today_bj = datetime.now(_BEIJING).date()
today_start = _beijing_today_start_utc()
tomorrow_start = today_start + timedelta(days=1)
login_user_ids = _id_set(
db,
select(User.id).where(User.last_login_at >= today_start, User.last_login_at < tomorrow_start),
)
compare_start_user_ids = _event_user_ids(
db, (COMPARE_START_EVENT,), today_start, tomorrow_start
)
coupon_event_user_ids = _event_user_ids(
db, (COUPON_START_EVENT,), today_start, tomorrow_start
)
coupon_claim_user_ids = _id_set(
db,
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date == today_bj,
CouponPromptEngagement.engage_type == "claim_started",
),
)
return len(login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids)
return period_active_dau(db, today_bj, today_bj)
def _default_period_end() -> date:
@@ -134,6 +127,56 @@ def _event_user_ids(
)
def _period_active_user_ids(
db: Session,
*,
start_utc: datetime,
end_utc: datetime,
period_from: date,
period_to: date,
) -> set[int]:
"""区间去重活跃用户 id 集合:登录(last_login_at)+ 开始比价(real_compare_start)+
开始领券(real_coupon_start / claim_started)。
user / analytics_event 用 UTC aware 边界 [start_utc, end_utc);coupon_prompt_engagement
的 engage_date 是北京自然日 date 列,用 [period_from, period_to] 闭区间。今日 / 历史 / 多天通用。
"""
login_user_ids = _id_set(
db,
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc),
)
compare_start_user_ids = _event_user_ids(db, (COMPARE_START_EVENT,), start_utc, end_utc)
coupon_event_user_ids = _event_user_ids(db, (COUPON_START_EVENT,), start_utc, end_utc)
coupon_claim_user_ids = _id_set(
db,
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date >= period_from,
CouponPromptEngagement.engage_date <= period_to,
CouponPromptEngagement.engage_type == "claim_started",
),
)
return login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids
def period_active_dau(db: Session, date_from: date, date_to: date) -> int:
"""任意北京自然日区间 [date_from, date_to] 的去重活跃用户数。
与数据大盘 period.users.active 同口径(登录 + 开始比价 + 开始领券);广告收益报表按所选
日期区间(含今日)复用,ARPU = 区间预估收益 ÷ 本数。全局口径,不随用户 / 类型 / 场景筛选变化。
"""
period_from, period_to = _normalize_period(date_from, date_to)
start_utc, end_utc, _start_local, _end_local = _period_bounds(period_from, period_to)
return len(
_period_active_user_ids(
db,
start_utc=start_utc,
end_utc=end_utc,
period_from=period_from,
period_to=period_to,
)
)
def _commission_rate_percent(raw: str | None) -> Decimal | None:
"""美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%""""
if raw is None:
@@ -261,31 +304,19 @@ def dashboard_overview(
period_new_user_ids = _user_id_set(
select(User.id).where(User.created_at >= start_utc, User.created_at < end_utc)
)
login_user_ids = _user_id_set(
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc)
)
compare_start_user_ids = _event_user_ids(
db, (COMPARE_START_EVENT,), start_utc, end_utc
)
coupon_event_user_ids = _event_user_ids(
db, (COUPON_START_EVENT,), start_utc, end_utc
)
coupon_claim_user_ids = _user_id_set(
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date >= period_from,
CouponPromptEngagement.engage_date <= period_to,
CouponPromptEngagement.engage_type == "claim_started",
)
)
period_active_user_ids = (
login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids
)
period_retained_new_user_ids = period_new_user_ids & period_active_user_ids
period_retention_rate = (
round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4)
if period_new_user_ids
else None
period_active_user_ids = _period_active_user_ids(
db,
start_utc=start_utc,
end_utc=end_utc,
period_from=period_from,
period_to=period_to,
)
# 留存口径(2026-07-05 产品改):次日留存——窗口内每天 D,取 **D-1 日(前日)新增**用户,
# 统计其 D 日活跃(登录/开始比价/开始领券)比例,逐日累加。默认窗口=昨日单天,即
# 「前日新增用户的昨日留存」。原口径(窗口内新增∩窗口内活跃)在单日窗口下≈100% 无意义
# (注册即登录,当天新增必然当天活跃)。逐日 cohort 在下方 trend 循环内顺带累计。
retention_cohort_total = 0
retention_retained_total = 0
trend_points: list[dict] = []
for cur_date in _date_range(period_from, period_to):
day_start_utc, day_end_utc, day_start_local, day_end_local = _period_bounds(
@@ -295,33 +326,26 @@ def dashboard_overview(
ComparisonRecord.created_at >= day_start_local,
ComparisonRecord.created_at < day_end_local,
)
daily_login_user_ids = _user_id_set(
daily_active_user_ids = _period_active_user_ids(
db,
start_utc=day_start_utc,
end_utc=day_end_utc,
period_from=cur_date,
period_to=cur_date,
)
# 次日留存:cohort = 前一日(D-1)新增用户,留存 = 其中当日(D)活跃者(口径见上)。
cohort_ids = _user_id_set(
select(User.id).where(
User.last_login_at >= day_start_utc,
User.last_login_at < day_end_utc,
)
)
daily_compare_start_user_ids = _event_user_ids(
db, (COMPARE_START_EVENT,), day_start_utc, day_end_utc
)
daily_coupon_event_user_ids = _event_user_ids(
db, (COUPON_START_EVENT,), day_start_utc, day_end_utc
)
daily_coupon_claim_user_ids = _user_id_set(
select(CouponPromptEngagement.user_id).where(
CouponPromptEngagement.engage_date == cur_date,
CouponPromptEngagement.engage_type == "claim_started",
User.created_at >= day_start_utc - timedelta(days=1),
User.created_at < day_end_utc - timedelta(days=1),
)
)
retention_cohort_total += len(cohort_ids)
retention_retained_total += len(cohort_ids & daily_active_user_ids)
trend_points.append(
{
"date": cur_date,
"active_users": len(
daily_login_user_ids
| daily_compare_start_user_ids
| daily_coupon_event_user_ids
| daily_coupon_claim_user_ids
),
"active_users": len(daily_active_user_ids),
"new_users": _count(
User,
User.created_at >= day_start_utc,
@@ -330,6 +354,11 @@ def dashboard_overview(
"comparisons": _count(ComparisonRecord, *daily_comparison_conds),
}
)
period_retention_rate = (
round(retention_retained_total / retention_cohort_total, 4)
if retention_cohort_total
else None
)
period_coin_conds = (
CoinTransaction.created_at >= start_local,
@@ -339,7 +368,7 @@ def dashboard_overview(
period_reward_video_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
)
period_feed_ad_coin_total = _sum(
CoinTransaction.amount,
@@ -361,15 +390,29 @@ def dashboard_overview(
*period_coin_conds,
CoinTransaction.biz_type.like("task_%"),
)
# 领券/比价奖励金币 = biz_type 桶(历史空,兜底)+ 该场景信息流广告实发金币
# (ad_feed_reward_record.feed_scene,granted;reward_date 是北京日期串,与 period 同自然日窗口)。
period_coupon_reward_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
) + _sum(
AdFeedRewardRecord.coin,
AdFeedRewardRecord.status == "granted",
AdFeedRewardRecord.feed_scene == "coupon",
AdFeedRewardRecord.reward_date >= period_from.isoformat(),
AdFeedRewardRecord.reward_date <= period_to.isoformat(),
)
period_comparison_reward_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
) + _sum(
AdFeedRewardRecord.coin,
AdFeedRewardRecord.status == "granted",
AdFeedRewardRecord.feed_scene == "comparison",
AdFeedRewardRecord.reward_date >= period_from.isoformat(),
AdFeedRewardRecord.reward_date <= period_to.isoformat(),
)
period_regular_task_coin_total = _sum(
CoinTransaction.amount,
@@ -413,6 +456,98 @@ def dashboard_overview(
else None
)
# ===== 领券核心数据(2026-07-05 产品新增)=====
# 数据源:coupon_session(一次领券一行,started_date 北京自然日)+ coupon_claim_record
# (一券/点位一天一条终态,claim_date 北京自然日)。点位与 session 不按 trace_id 关联——
# record_claims 更新路径不覆盖 trace_id(同设备同券同日重跑归第一次的 trace),按
# (device_id, 自然日) 桶关联才可靠;同桶多次发起共享同一份点位终态。
period_coupon_sessions = db.execute(
select(
CouponSession.device_id,
CouponSession.started_date,
CouponSession.status,
CouponSession.elapsed_ms,
).where(
CouponSession.started_date >= period_from,
CouponSession.started_date <= period_to,
# 只统计正式环境,同「领券数据」页默认口径(防 debug 包调试数据串台;
# 命中 ix_coupon_session_date_env)。点位表无 app_env 列,但点位指标只经
# 下方 prod session 触达的 (device, 日) 桶进入统计,随之收敛到 prod。
CouponSession.app_env == "prod",
)
).all()
coupon_started = len(period_coupon_sessions)
coupon_completed_elapsed = sorted(
s.elapsed_ms
for s in period_coupon_sessions
if s.status == "completed" and s.elapsed_ms is not None
)
# 点位桶:(device, 日) → (点位总数, 成功点位数)。成功口径与「我的」页累计领券一致
# (sum_claimed_count,2026-06-15 产品定):success + already_claimed(已领过=持有券)都算成功。
point_buckets: dict[tuple[str, date], tuple[int, int]] = {
(dev, d): (int(total), int(succ or 0))
for dev, d, total, succ in db.execute(
select(
CouponClaimRecord.device_id,
CouponClaimRecord.claim_date,
func.count(),
func.sum(
case(
(CouponClaimRecord.status.in_(("success", "already_claimed")), 1),
else_=0,
)
),
)
.where(
CouponClaimRecord.claim_date >= period_from,
# 上界放宽一天:跨零点场次(23:5x 发起)的点位 claim_date 落在发起日+1,
# 桶只经下方 session 触达的键参与计数,放宽不会引入无关数据。
CouponClaimRecord.claim_date <= period_to + timedelta(days=1),
)
.group_by(CouponClaimRecord.device_id, CouponClaimRecord.claim_date)
).all()
}
# 全部领成功的次数:completed 且其 (device, 日) 桶内点位全部成功(桶为空不算)。
coupon_all_success = 0
completed_bucket_totals: list[int] = []
session_bucket_keys: set[tuple[str, date]] = set()
for s in period_coupon_sessions:
key = (s.device_id, s.started_date)
if key not in point_buckets:
# 跨零点回退:发起日桶不存在(点位终态全部落在次日)时取 (device, 发起日+1)。
# 仅在发起日桶完全缺失时回退,避免抢占该设备次日 session 自己的桶。
next_key = (s.device_id, s.started_date + timedelta(days=1))
if next_key in point_buckets:
key = next_key
bucket = point_buckets.get(key)
if bucket is not None:
session_bucket_keys.add(key)
if s.status != "completed" or bucket is None:
continue
total, succ = bucket
completed_bucket_totals.append(total)
if total > 0 and succ == total:
coupon_all_success += 1
# 每次发起的应领点位数:取「完成过的领券」实际点位数的众数(done 帧会给所有点位终态,
# 完成场的点位数=当前配置的全量点位数;数据自校准,配置改点位数无需改代码)。本期无完成场
# 时给不出,点位成功率置空。
coupon_points_per_session = (
Counter(completed_bucket_totals).most_common(1)[0][0]
if completed_bucket_totals
else None
)
# 成功点位数:本期 session 触达过的 (device, 日) 桶内成功点位之和(桶级去重,同桶重试不重复计)。
coupon_point_success = sum(point_buckets[k][1] for k in session_bucket_keys)
# 点位成功率 = 成功点位数 / (发起数 × 应领点位数):中途退出未跑到的点位不产生记录,
# 但发起数×点位数把它们计入分母 → 视为失败,符合产品口径;重试会拉低该率(分母按次数计)。
coupon_point_success_rate = (
round(
min(1.0, coupon_point_success / (coupon_started * coupon_points_per_session)), 4
)
if coupon_started and coupon_points_per_session
else None
)
return {
"users": {
"total": _count(User),
@@ -428,7 +563,7 @@ def dashboard_overview(
"reward_video_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
),
"reward_video_watch_count": _count(
AdRewardRecord,
@@ -476,11 +611,13 @@ def dashboard_overview(
"users": {
"new": len(period_new_user_ids),
"active": len(period_active_user_ids),
"retained_new_users": len(period_retained_new_user_ids),
"retained_new_users": retention_retained_total,
"retention_cohort": retention_cohort_total,
"retention_rate": period_retention_rate,
"retention_note": (
"口径:登录(last_login_at)+开始比价(real_compare_start)+"
"开始领券(real_coupon_start/claim_started),按用户去重"
"次日留存:窗口内每天取前一日新增用户,统计其当日活跃"
"(登录/开始比价/开始领券,按用户去重)比例,逐日累加;"
"默认窗口=昨日,即前日新增用户的昨日留存"
),
},
"comparison": {
@@ -491,6 +628,17 @@ def dashboard_overview(
"average_duration_ms": period_avg_duration_ms,
"average_saved_cents": period_avg_saved_cents,
},
"coupon": {
"started": coupon_started,
"all_success": coupon_all_success,
"success_rate": (
round(coupon_all_success / coupon_started, 4) if coupon_started else None
),
"point_success": coupon_point_success,
"points_per_session": coupon_points_per_session,
"point_success_rate": coupon_point_success_rate,
"median_elapsed_ms": _percentile(coupon_completed_elapsed, 50),
},
"coins": {
"granted_total": _sum(CoinTransaction.amount, *period_coin_conds),
"reward_video_coin_total": period_reward_video_coin_total,
+1
View File
@@ -91,6 +91,7 @@ def get_ad_revenue_report(
daily=[AdRevenueDaily(**d) for d in result["daily"]],
hourly=[AdRevenueHourly(**h) for h in result["hourly"]],
type_stats={k: AdRevenueTypeStat(**v) for k, v in result["type_stats"].items()},
scene_stats={k: AdRevenueTypeStat(**v) for k, v in result["scene_stats"].items()},
dau=result["dau"],
total=result["total"],
truncated=result["truncated"],
+28 -5
View File
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
from app.admin.permissions import SUPER_ADMIN_ROLE
from app.admin.permissions import CUSTOM_ROLE, SUPER_ADMIN_ROLE, sanitize_pages
from app.admin.repositories import admin_role as role_repo
from app.admin.repositories import admin_user as admin_repo
from app.admin.schemas.admin import AdminCreateRequest, AdminUpdateRequest
@@ -26,14 +26,22 @@ def _active_super_count(db: AdminDb) -> int:
def _validate_role(db: AdminDb, role: str) -> None:
"""角色必须是 super_admin admin_role 表里已存在的角色,否则 400。"""
if role == SUPER_ADMIN_ROLE:
"""角色必须是 super_admin / custom(自定义) / admin_role 表里已存在的角色,否则 400。"""
if role in (SUPER_ADMIN_ROLE, CUSTOM_ROLE):
return
role_repo.ensure_builtin_roles(db) # 空表(测试/全新库)兜底播种,再校验
if role_repo.get_role(db, role) is None:
raise HTTPException(status_code=400, detail=f"角色不存在: {role}")
def _clean_override(role: str, pages_override: list[str] | None) -> list[str] | None:
"""按最终角色算出该存的 pages_override:custom → 勾选集(过滤非法 key,可空列表);
非 custom → None(切回普通角色即清空自定义页)。"""
if role == CUSTOM_ROLE:
return sanitize_pages(pages_override)
return None
@router.get("", response_model=list[AdminOut], summary="管理员列表(含明文密码,super_admin 专属)")
def list_admins(db: AdminDb) -> list[AdminOut]:
out: list[AdminOut] = []
@@ -51,13 +59,16 @@ def create_admin(
if admin_repo.get_by_username(db, body.username) is not None:
raise HTTPException(status_code=409, detail="用户名已存在")
_validate_role(db, body.role)
override = _clean_override(body.role, body.pages_override)
new = admin_repo.create_admin(
db, username=body.username, password=body.password, role=body.role,
plain_password=body.password, # UI 建的账号留存明文,供权限管理页复看
pages_override=override,
)
write_audit(
db, admin, action="admin.create", target_type="admin", target_id=new.id,
detail={"username": new.username, "role": new.role}, ip=get_client_ip(request), commit=True,
detail={"username": new.username, "role": new.role, "pages_override": override},
ip=get_client_ip(request), commit=True,
)
return AdminOut.model_validate(new)
@@ -89,7 +100,8 @@ def update_admin(
_validate_role(db, body.role)
changes: dict = {}
if body.role is not None and body.role != target.role:
role_changed = body.role is not None and body.role != target.role
if role_changed:
changes["role"] = {"before": target.role, "after": body.role}
target.role = body.role
if body.status is not None and body.status != target.status:
@@ -99,6 +111,17 @@ def update_admin(
changes["password"] = "reset"
target.password_hash = hash_password(body.password)
target.plain_password = body.password # 同步留存明文,权限管理页复看保持一致
# 自定义可见页:按「最终角色」(target.role,已应用完角色变更)决定该存什么。
# - 切到/维持 custom 且传了 pages_override → 用勾选集;切到 custom 没传 → 清成空列表。
# - 切回普通角色 → 清空 override(_clean_override 返回 None)。
# - 角色没变、只传 pages_override(编辑现有 custom 用户的勾选)→ 也更新。
if role_changed or body.pages_override is not None:
new_override = _clean_override(target.role, body.pages_override)
if new_override != target.pages_override:
changes["pages_override"] = {"before": target.pages_override, "after": new_override}
target.pages_override = new_override
if not changes:
raise HTTPException(status_code=400, detail="无任何变更字段")
db.commit()
+7 -2
View File
@@ -6,6 +6,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException
from app.admin.deps import AdminDb, CurrentAdmin
from app.admin.permissions import CUSTOM_ROLE, sanitize_pages
from app.admin.repositories import admin_role as role_repo
from app.admin.repositories import admin_user as admin_repo
from app.admin.schemas.auth import AdminLoginRequest, AdminLoginResponse, AdminOut
@@ -19,9 +20,13 @@ router = APIRouter(prefix="/admin/api/auth", tags=["admin-auth"])
def _admin_out_with_pages(admin, db: AdminDb) -> AdminOut: # noqa: ANN001
"""AdminOut + 当前角色有效可见页(前端左侧导航按此过滤)。"""
"""AdminOut + 有效可见页(前端左侧导航按此过滤)。
role=="custom" → 用这个人的 pages_override(按人自定义,过滤悬空 key);其余走角色解析。"""
out = AdminOut.model_validate(admin)
out.pages = role_repo.effective_pages_of(db, admin.role)
if admin.role == CUSTOM_ROLE:
out.pages = sanitize_pages(admin.pages_override)
else:
out.pages = role_repo.effective_pages_of(db, admin.role)
return out
+26 -2
View File
@@ -19,6 +19,8 @@ from app.admin.schemas.ops_marquee_seed import (
OpsMarqueeSeedCreate,
OpsMarqueeSeedOut,
OpsMarqueeSeedUpdate,
OpsRealRecordItem,
OpsRealRecordsOut,
OpsSavingsFeedPreviewOut,
)
from app.models.admin import AdminUser
@@ -57,9 +59,31 @@ def list_seeds(db: AdminDb) -> list[OpsMarqueeSeedOut]:
def preview_feed(
db: AdminDb,
limit: Annotated[int, Query(ge=1, le=30)] = 8,
mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None,
) -> OpsSavingsFeedPreviewOut:
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。"""
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit))
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。
mode 显式指定则预览该模式(**不改持久化配置**,供前端切换开关时实时预览);不传则用当前持久化模式。
"""
if mode is not None and mode not in ops_marquee.FEED_MODES:
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit, mode=mode))
@router.get("/real-records", response_model=OpsRealRecordsOut, summary="分页浏览当前模式下可展示的记录(审核用)")
def list_real_records(
db: AdminDb,
mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None,
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=50)] = 8,
) -> OpsRealRecordsOut:
"""分页列出**当前模式**下可在 app 轮播展示的全部记录(**不去重**):只真实=真实记录;只种子=各启用
种子按生成逻辑各出一行;混播=真实+种子。与 app 同口径洗牌+去连簇,固定种子→翻页稳定、能翻遍全部。
item.user_id=0 表示种子行。"""
if mode is not None and mode not in ops_marquee.FEED_MODES:
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
items, total = ops_marquee.list_real_records(db, mode=mode, offset=offset, limit=limit)
return OpsRealRecordsOut(items=[OpsRealRecordItem(**it) for it in items], total=total)
# 注:/mode 两个端点须在 /{seed_id} 之前注册,否则 PATCH /mode 会被 /{seed_id} 抢先按 id 解析。
+7 -1
View File
@@ -42,7 +42,12 @@ def list_users(
created_to: Annotated[datetime | None, Query()] = None,
last_login_from: Annotated[datetime | None, Query()] = None,
last_login_to: Annotated[datetime | None, Query()] = None,
sort_by: Annotated[str, Query(pattern="^(id|created_at|last_login_at)$")] = "id",
# 最近活跃(登录/发起比价/发起领券取最大,见 queries._last_active_expr)筛选与排序
last_active_from: Annotated[datetime | None, Query()] = None,
last_active_to: Annotated[datetime | None, Query()] = None,
sort_by: Annotated[
str, Query(pattern="^(id|created_at|last_login_at|last_active_at)$")
] = "id",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
@@ -51,6 +56,7 @@ def list_users(
db, phone=phone, register_channel=register_channel, status=status,
nickname=nickname, created_from=created_from, created_to=created_to,
last_login_from=last_login_from, last_login_to=last_login_to,
last_active_from=last_active_from, last_active_to=last_active_to,
sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor,
)
return CursorPage(
+8 -1
View File
@@ -136,9 +136,16 @@ class AdRevenueReportOut(BaseModel):
default_factory=dict,
description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘",
)
scene_stats: dict[str, AdRevenueTypeStat] = Field(
default_factory=dict,
description="按信息流场景(feed_scene)小计 {comparison/coupon/welfare: {impressions, revenue_yuan}};"
"全量统计(不受分页截断),供数据大盘「领券广告 / 比价广告」卡;feed_scene 为空的事件不计入",
)
dau: int | None = Field(
None,
description="今日活跃用户数(复用大盘口径,last_login_at);**仅查询=今日单天时有值**,历史/多天为 null",
description="所选日期区间的去重活跃用户数(口径同数据大盘 period.users.active:登录 + 开始比价 + "
"开始领券)。按 date_from~date_to 区间统计,含今日、近 7 天、近 30 天等任意区间;全局口径,"
"不随 user_id / ad_type / feed_scene / app_env 筛选变化",
)
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
+5 -1
View File
@@ -13,14 +13,18 @@ class AdminCreateRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=64)
password: str = Field(..., min_length=8, max_length=72) # bcrypt ≤72 字节
role: str = Field("operator", min_length=1, max_length=32)
# 仅当 role == "custom":这个人专属可见页 key 列表(逐页勾选结果)。其余角色不传/忽略。
pages_override: list[str] | None = None
class AdminUpdateRequest(BaseModel):
"""改角色 / 启用禁用 / 重置密码,字段都可选(只改传了的)。"""
"""改角色 / 启用禁用 / 重置密码 / 自定义可见页,字段都可选(只改传了的)。"""
role: str | None = Field(None, min_length=1, max_length=32)
status: Literal["active", "disabled"] | None = None
password: str | None = Field(None, min_length=8, max_length=72)
# 改成/更新「自定义」可见页;role 切回普通角色时后端会清空 override(见路由)。
pages_override: list[str] | None = None
class AdminAuditLogOut(BaseModel):
+4 -1
View File
@@ -21,8 +21,11 @@ class AdminOut(BaseModel):
created_at: datetime
last_login_at: datetime | None = None
# 该管理员当前角色的有效可见页(= 左侧导航项 key);仅登录 / /me 填充,列表接口默认空。
# 前端据此过滤左侧导航(super_admin = 全部页)。见 app/admin/permissions.py。
# 前端据此过滤左侧导航(super_admin = 全部页;role=="custom" = pages_override)。见 permissions.py。
pages: list[str] = []
# 「自定义」可见页原始勾选集(role=="custom" 时非空);列表接口下发,供权限管理页编辑回填勾选。
# 普通角色为 None。from_attributes 自动从 ORM 列取。
pages_override: list[str] | None = None
# 明文登录密码:仅「管理员账号列表」(super_admin 专属路由)填充,供权限管理页编辑时复看;
# 无留存(脚本建的超管 / 旧账号)为 None → 前端不显示。登录 / /me 不下发(保持 None)。
password: str | None = None
+22
View File
@@ -43,7 +43,10 @@ class DashboardComparison(BaseModel):
class DashboardPeriodUsers(BaseModel):
new: int
active: int
# 次日留存(2026-07-05 起):retained_new_users = 窗口内逐日「前一日新增且当日活跃」用户数之和,
# retention_cohort = 对应的前一日新增基数之和,retention_rate = 两者之比。
retained_new_users: int
retention_cohort: int = 0
retention_rate: float | None = None
retention_note: str
@@ -57,6 +60,24 @@ class DashboardPeriodComparison(BaseModel):
average_saved_cents: int | None = None
class DashboardPeriodCoupon(BaseModel):
"""领券核心数据(2026-07-05 产品新增)。点位=一张券(coupon_claim_record 一天一条终态);
成功口径 success+already_claimed(与「我的」页累计领券一致)。"""
started: int = 0
# 全部领成功的次数:completed 且当日该设备全部点位成功
all_success: int = 0
success_rate: float | None = None
# 本期 session 触达的点位中成功的条数(同设备同日去重)
point_success: int = 0
# 每次发起的应领点位数(本期完成场实际点位数的众数;无完成场为空)
points_per_session: int | None = None
# 点位成功率 = point_success / (started × points_per_session);未跑到的点位计入分母视为失败
point_success_rate: float | None = None
# 耗时中位数(仅 completed 的 elapsed_ms,同「领券数据」页口径)
median_elapsed_ms: int | None = None
class DashboardPeriodCoins(BaseModel):
granted_total: int
reward_video_coin_total: int = 0
@@ -85,6 +106,7 @@ class DashboardPeriod(BaseModel):
date_to: date
users: DashboardPeriodUsers
comparison: DashboardPeriodComparison
coupon: DashboardPeriodCoupon = DashboardPeriodCoupon()
coins: DashboardPeriodCoins
cash: DashboardPeriodCash
trend: list[DashboardTrendPoint] = []
+13
View File
@@ -62,3 +62,16 @@ class OpsSavingsFeedPreviewItem(BaseModel):
class OpsSavingsFeedPreviewOut(BaseModel):
"""运营预览:实际混播出来的 feed(真实记录会插队,与客户端一致)。"""
items: list[OpsSavingsFeedPreviewItem]
class OpsRealRecordItem(BaseModel):
masked_user: str # 脱敏后展示名(与 app 一致)
saved_amount_cents: int # 节省金额(分)
created_at: str # 比价记录时间(YYYY-MM-DD HH:MM)
user_id: int # 真实 user_id(供运营核对,不下发客户端)
class OpsRealRecordsOut(BaseModel):
"""分页浏览「全部可展示的真实记录」(success+省>0,不去重、稳定顺序)。"""
items: list[OpsRealRecordItem]
total: int # 满足条件的真实记录总数(算页数用)
+3
View File
@@ -20,6 +20,9 @@ class AdminUserListItem(BaseModel):
wechat_nickname: str | None = None
created_at: datetime
last_login_at: datetime
# 最近活跃 = max(最近登录, 最近发起比价, 最近发起领券);列表页由 queries._attach_last_active
# 瞬态挂上。其他复用本 schema 的入口(用户 360 等)没挂该属性 → None(前端显示 '-')。
last_active_at: datetime | None = None
class AdminUserOverview(BaseModel):
+9
View File
@@ -130,6 +130,7 @@ class WithdrawBulkResult(BaseModel):
class WithdrawLedgerCheckOut(BaseModel):
ok: bool
# 普通现金账(coin_cash:金币兑换的现金)
cash_balance_total_cents: int
cash_transaction_total_cents: int
balance_diff_cents: int
@@ -137,6 +138,14 @@ class WithdrawLedgerCheckOut(BaseModel):
missing_refund_txn_count: int
duplicate_refund_txn_count: int
refund_txn_on_non_terminal_count: int
# 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)。默认 0 向后兼容。
invite_cash_balance_total_cents: int = 0
invite_cash_transaction_total_cents: int = 0
invite_balance_diff_cents: int = 0
invite_missing_withdraw_txn_count: int = 0
invite_missing_refund_txn_count: int = 0
invite_duplicate_refund_txn_count: int = 0
invite_refund_txn_on_non_terminal_count: int = 0
class WxpayHealthCheckOut(BaseModel):
+41 -4
View File
@@ -25,9 +25,19 @@ from app.schemas.meituan import (
ReferralLinkResponse,
TopSalesRequest,
)
from app.utils.meituan_city import get_meituan_city
logger = logging.getLogger("shagua.meituan")
def _resolve_city_id(latitude: float, longitude: float) -> str:
"""经纬度 → 美团城市 ID;解析失败返 ""(调用方应降级返空)。"""
try:
return get_meituan_city(latitude, longitude).get("city_id", "")
except Exception:
logger.exception("get_meituan_city 失败")
return ""
router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"])
@@ -175,13 +185,19 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
status = "degraded" if (not cards and wm_fail and dd_fail) else ("ok" if cards else "empty")
return FeedResponse(items=cards, has_next=wm_hn or dd_hn, page=req.page, status=status)
# 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。
# 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,按城市过滤,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。
# 实测库里佣金≥3% 去重后仅 ~578 条(几乎全是外卖;到店团购佣金普遍 <3%):实时按"同城热销榜单"
# 拉既撞限流、又填不满(该榜单中位佣金 ~0.8%,筛完每页剩 0-1 条),故从库出。佣金阈值逻辑不变。
if tab == "rec":
city_id = _resolve_city_id(lat, lon)
if not city_id:
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
PAGE = 20
try:
base = select(MeituanCoupon).where(MeituanCoupon.commission_percent >= 3.0)
base = select(MeituanCoupon).where(
MeituanCoupon.commission_percent >= 3.0,
MeituanCoupon.city_id == city_id,
)
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
MeituanCoupon.dedup_key,
MeituanCoupon.commission_percent.desc(),
@@ -211,6 +227,9 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
card.distance_text = None
card.distance_meters = None
cards.append(card)
if not cards and req.page == 1:
# 命中城市却 0 券:该城确无 ≥3% 券,或 ETL 灌的 city_id 与 city_dict 口径不一致。
logger.info("[feed] rec city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id)
return FeedResponse(items=cards, has_next=has_next, page=req.page,
status="ok" if cards else "empty")
@@ -254,14 +273,24 @@ def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
@router.post("/top-sales", response_model=CouponListResponse,
summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,不实时打美团)")
summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,按城市过滤,不实时打美团)")
def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponListResponse:
# 按设备经纬度定位城市,只查同城券;老客户端不带坐标 → 降级返空(不 422、不误返全城)。
if req.latitude is None or req.longitude is None:
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
city_id = _resolve_city_id(req.latitude, req.longitude)
if not city_id:
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
# 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。
# (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。)
# 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。
try:
# 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
base = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None))
base = select(MeituanCoupon).where(
MeituanCoupon.sale_volume_num.isnot(None),
MeituanCoupon.city_id == city_id,
)
if req.platform is not None:
base = base.where(MeituanCoupon.platform == req.platform)
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
@@ -292,6 +321,14 @@ def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponList
except Exception: # noqa: BLE001
continue
if card.product_view_sign:
# 不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。
# 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。
# 逻辑与推荐流保持一致
card.distance_text = None
card.distance_meters = None
cards.append(card)
if not cards and req.page == 1:
# 命中城市却 0 券:可能该城确无券,也可能 ETL 灌的 city_id 与 city_dict 口径不一致(静默降级的隐患)。
logger.info("[top-sales] city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id)
return CouponListResponse(items=cards, has_next=has_next, search_id=None,
status="ok" if cards else "empty")
+6
View File
@@ -71,6 +71,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.DATABASE_URL.split("://", 1)[0],
)
get_pricebot_client() # 预热透传 client:把建 SSL 上下文的一次性成本付在启动,首个领券请求即热
try:
# 预热离线地理库:首次加载 ~2.5M 行 CSV + 建 KDTree,摊到启动、不砸首个按城市过滤的请求
from app.utils import geo
geo.ensure_loaded()
except Exception: # noqa: BLE001
logger.exception("reverse_geocoder 预热失败(城市反查将在首个请求时懒加载)")
reconcile_task = start_withdraw_reconcile_worker()
heartbeat_task = start_heartbeat_monitor()
daily_exchange_task = start_daily_exchange_worker()
+4 -1
View File
@@ -28,8 +28,11 @@ class AdminUser(Base):
# 明文登录密码:仅「后台 UI 创建/重置」的管理员留存,供超管在权限管理页复看转交。
# 脚本/起后台时建的超管账号不写(为 None → 前端「不显示密码」)。⚠️ 内部工具便利取舍,见 create/list。
plain_password: Mapped[str | None] = mapped_column(String(128), nullable=True)
# super_admin(全权+管账号)/ finance(钱:提现+金币)/ operator(用户+反馈+大盘)
# super_admin(全权+管账号)/ finance(钱:提现+金币)/ operator(用户+反馈+大盘)/ custom(按人自定义)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="operator")
# 「自定义」权限:仅当 role == "custom" 时有效,存这个人专属的可见页 key 列表(不共享给他人)。
# 非 custom 用户为 None → 可见页跟随角色。登录/`/me` 下发有效页时,非空即优先用它(见 auth._admin_out_with_pages)。
pages_override: Mapped[list | None] = mapped_column(_JSON, nullable=True)
# active / disabled
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
+104 -15
View File
@@ -30,6 +30,7 @@ from app.models.comparison import ComparisonRecord
from app.models.ops_marquee_seed import OpsMarqueeSeed
from app.models.user import User
from app.repositories import app_config
from app.repositories.user import is_default_nickname
# 首页轮播数据源模式(存 app_config.marquee_feed_mode):
# mixed=真实优先+种子补位+合成兜底(默认,原行为);real=只真实(不足则少/空);seed=只种子+合成兜底。
@@ -140,9 +141,12 @@ def _synth_masked_name(rng: random.Random) -> str:
def _mask_real(nickname: str | None, user_id: int) -> str:
"""真实用户脱敏(对齐 PRD「用户标识打码规则」):设过昵称→昵称脱敏(中英文皆可);
没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。"""
没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。
创建时自动分配的默认昵称(「用户」+9 位随机,见 user.is_default_nickname)不算用户主动设的昵称,
按「无昵称」处理走 id 规则(产品决策 2026-07:默认昵称归入「没昵称」档)。"""
nick = (nickname or "").strip()
if nick:
if nick and not is_default_nickname(nick):
return _mask_nickname(nick)
return _mask_anon(user_id)
@@ -213,34 +217,47 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
return out
def get_feed(db: Session, limit: int = 8) -> list[dict]:
def _shuffle_declustered(rows: list, rng: random.Random | None = None) -> list:
"""洗牌 + 「去连簇」:先洗牌,再贪心重排让相邻两条尽量不是同一 user_id(元素 [0] 即 user_id)。
rng=None → 用全局 _rng(feed 每次新随机);传入 rng(如固定种子 Random)→ 排列确定(admin 稳定分页)。
减少同一用户连续出现;只有少数几个用户时 best-effort。"""
rng = rng or _rng
pool = list(rows)
rng.shuffle(pool)
result: list = []
while pool:
prev_uid = result[-1][0] if result else None
# 优先挑与上一条不同 user 的;挑不到(只剩同 user)才取第一个
idx = next((i for i, r in enumerate(pool) if r[0] != prev_uid), 0)
result.append(pool.pop(idx))
return result
def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]:
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。
mode:显式传入(admin 预览指定模式)则用它、**不改持久化配置**;不传(客户端 /savings-feed)读
持久化的 marquee_feed_mode;非法值一律回退到持久化模式。
真实条:success 且 0 < saved ≤ 上限,**不按 user 去重**(打乱 + 去连簇:相邻尽量不同用户、减少单人连刷)后取前 limit。
不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出;
种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多、偶尔大额,更像真实分布)。
真实 + 种子仍不满 limit → 内置合成条**补满**,保证轮播既不空也不稀疏。
展示时间统一「刷新」成相对现在的最近时刻(从 now 往前**随机抖动**递减),保证轮播永远像刚发生、
节奏自然不机械(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时时间)。
"""
mode = get_feed_mode(db) # mixed / real / seed(运营可在「首页轮播种子」页切换)
# 预览可显式指定模式(所见=选中模式,不依赖 PATCH 落库时序);None/非法 → 读持久化配置。
mode = mode if mode in FEED_MODES else get_feed_mode(db) # mixed / real / seed
items: list[dict] = []
used_names: set[str] = set()
# 真实条(mixed / real):取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。
# 真实条(mixed / real):**不按 user 去重**——打乱 + 去连簇(相邻尽量不同用户、减少单人连刷)后取前
# limit;金额超上限的异常值已在查询剔除。同一用户可多条露出,但被打散、尽量不连续。
if mode != "seed":
rows = _recent_real_rows(db)
seen_users: set[int] = set()
for uid, sc, nick in rows:
if uid in seen_users:
continue
seen_users.add(uid)
for uid, sc, nick in _shuffle_declustered(_recent_real_rows(db))[:limit]:
name = _mask_real(nick, uid)
used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受
used_names.add(name) # 同一用户可多条,名字重复无害(used_names 仅供种子避重)
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
if len(items) >= limit:
break
# 种子补位 + 合成兜底(mixed / seed):mixed 下补真实不足的部分,seed 下全量用种子/合成。
# real 模式**跳过**——只出真实,不掺任何假数据(真实不足则少于 limit,为 0 时返回空)。
@@ -293,6 +310,78 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]:
return items
# ===== admin 侧:分页浏览「当前模式下可展示的记录」(审核用,不去重) =====
_REAL_BROWSE_CAP = 1000 # 真实记录一次最多纳入这么多去洗牌+分页(足够审核;防超大库全量洗牌)
_BROWSE_SEED = 20260707 # 固定洗牌种子:同一批数据下排列恒定 → 翻页稳定、能翻遍全部
def _seed_browse_row(seed: OpsMarqueeSeed) -> dict:
"""把一条种子按其「生成逻辑」**确定性**生成一行浏览项(名字/金额按 seed.id 派生固定种子 → 翻页稳定)。
名字:运营手填的非模板名原样用,否则本地合成;金额:区间内确定性长尾取值。user_id=0(种子无真实用户)。"""
r = random.Random(_BROWSE_SEED * 1_000_003 + int(seed.id))
fixed = (seed.masked_user or "").strip()
name = fixed if (fixed and not fixed.startswith("用户****")) else _synth_name(r)
lo = max(0, int(seed.min_cents))
hi = max(lo, int(seed.max_cents))
amt = lo if hi <= lo else lo + int(round((hi - lo) * (r.random() ** 2.2)))
return {"masked_user": name, "saved_amount_cents": amt, "created_at": "", "user_id": 0}
def list_real_records(
db: Session, mode: str | None = None, offset: int = 0, limit: int = 8
) -> tuple[list[dict], int]:
"""分页浏览「**当前模式**下可在 app 轮播展示的全部记录」,供 admin 逐页审核(**不去重**):
- 只真实:全部 success+省>0 的真实记录;
- 只种子:每条启用种子按生成逻辑各出一行;
- 混播:真实 + 种子 全部合在一起。
与 app 轮播同口径:先洗牌 + 去连簇(相邻尽量不同 user;种子各自独立、不算同 user);**固定种子** →
同批数据下排列恒定,翻页不跳、能翻遍全部。返回 (items, total);item.user_id=0 表示种子。"""
mode = mode if mode in FEED_MODES else get_feed_mode(db)
# pool: [(cluster_key, item)];cluster_key 供去连簇——真实=user_id、种子=各自唯一负数(互不聚簇)
pool: list[tuple[int, dict]] = []
if mode != "seed":
rows = db.execute(
select(
ComparisonRecord.user_id,
ComparisonRecord.saved_amount_cents,
User.nickname,
ComparisonRecord.created_at,
)
.join(User, User.id == ComparisonRecord.user_id)
.where(
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0,
ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS,
)
.order_by(ComparisonRecord.created_at.desc())
.limit(_REAL_BROWSE_CAP)
).all()
for uid, sc, nick, ca in rows:
pool.append((
int(uid),
{
"masked_user": _mask_real(nick, int(uid)),
"saved_amount_cents": int(sc),
"created_at": str(ca)[:16] if ca is not None else "",
"user_id": int(uid),
},
))
if mode != "real":
seeds = (
db.execute(select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True)))
.scalars()
.all()
)
for i, s in enumerate(seeds):
pool.append((-(i + 1), _seed_browse_row(s))) # 每个种子唯一 key → 互不聚簇
total = len(pool)
# 每次用同一固定种子新建 Random → 同批数据排列恒定(翻页稳定);同时相邻尽量不同 user。
ordered = _shuffle_declustered(pool, random.Random(_BROWSE_SEED))
off = max(0, offset)
items = [item for _key, item in ordered[off : off + limit]]
return items, total
# ===== 运营侧:种子 CRUD =====
def list_seeds(db: Session) -> list[OpsMarqueeSeed]:
return list(
+16
View File
@@ -42,6 +42,22 @@ def _gen_nickname() -> str:
)
def is_default_nickname(nickname: str | None) -> bool:
"""是否为创建时自动分配的默认昵称(= "用户" + 9 位字母数字,见 [_gen_nickname])。
这类不是用户主动设置的昵称,展示脱敏时按「无昵称」处理(走 id 规则,见 ops_marquee._mask_real)。
精确匹配生成格式(前缀 + 定长字母数字集),不误伤真人以「用户」开头的昵称(如「用户体验师」含汉字、
长度也不符)。用户改过昵称即不再匹配。"""
if not nickname:
return False
s = nickname.strip()
return (
len(s) == len(_NICKNAME_PREFIX) + _NICKNAME_LEN
and s.startswith(_NICKNAME_PREFIX)
and all(c in _NICKNAME_ALPHABET for c in s[len(_NICKNAME_PREFIX):])
)
def get_user_by_username(db: Session, username: str) -> User | None:
return db.execute(
select(User).where(User.username == username)
+5 -1
View File
@@ -174,9 +174,13 @@ class FeedResponse(BaseModel):
class TopSalesRequest(BaseModel):
"""销量最高 tab:从离线库 meituan_coupon 按销量降序取(不实时打美团)。"""
# 可选:老客户端(本次改动前发版)不带经纬度。缺省时后端降级返空(status=degraded),
# 不做 422 硬拒,也不误返"全城"结果。新客户端会传坐标 → 按城市过滤。
longitude: float | None = Field(None, description="经度(用于定位城市;缺省=老客户端,降级返空)")
latitude: float | None = Field(None, description="纬度(用于定位城市;缺省=老客户端,降级返空)")
page: int = Field(1, ge=1)
page_size: int = Field(20, ge=1, le=50)
platform: int | None = Field(None, description="可选: 1只外卖 / 2只到店; 不填=全部(城销量)")
platform: int | None = Field(None, description="可选: 1只外卖 / 2只到店; 不填=全部(城销量)")
# ───────────────── 换链 请求 / 响应 ─────────────────
View File
+360
View File
@@ -0,0 +1,360 @@
城市ID 城市名称 省份名称
3NUYJKKJXPHVNZUHFK3HWUDHNM 宣城市 安徽省
LXXSHOY7LNK74ZK2SKVUXFY72Q 阜阳市 安徽省
ZEBF2LBJOEHGM4XGFPNW4IHBIA 合肥市 安徽省
WADWEY3GR6IARJLSNGQWG2KI4E 滁州市 安徽省
Z62QL3X66AOT6MSY7LB6LVO4CI 芜湖市 安徽省
M6QCWRCECRT6ZEJ6RERR6IWHFI 淮南市 安徽省
UP2NBJACSAO7FUH4XQQDMBOUE4 马鞍山市 安徽省
WLL7BSHUBPTLTMITFITJX2GKWY 蚌埠市 安徽省
ECTBNJ4KNNHPGXEVI4TBJNU7BY 亳州市 安徽省
KNEGDNOSRGNQPAMOMH54ATLIUU 六安市 安徽省
YH53FRR55H6VA4KXW36OJ7RNSE 宿州市 安徽省
LH6LX5DUVFPOFCAQ6NEGPWCLBI 淮北市 安徽省
RIFXWJ46SEJXE7EP6HZXEUIALU 铜陵市 安徽省
FK6F7KMO4WNARZKUXMMSBWGATM 安庆市 安徽省
M3CHGNNUSFSPS5HRV3W7MCBWFI 黄山市 安徽省
XFI6PM7SRO6NHBGYQUZEEA2WT4 池州市 安徽省
D2ZILSASTTGEFQWZIDTHA7PWU4 澳门 澳门特别行政区
WKV2HMXUEK634WP64CUCUQGM64 北京市 北京市
HPMKHLM3QR6EZGMY7GEI4H3QYQ 泉州市 福建省
6V523YRU54Y3PEAM2XPADNJM2U 福州市 福建省
HH3ZZCERPVQIYUZPW4A2U4JKZI 莆田市 福建省
BQ5RWEJS4O7W27SQMLPMRIRJDU 宁德市 福建省
TDN7XDQMZEP6ZCK6UO3VVMCSMM 三明市 福建省
ZKNTORN6YTZL2BXRYUSRGV3CHU 厦门市 福建省
Y2DI2QLZN5NNACMD3KI2DBR4IA 龙岩市 福建省
C4RS32I6QAHWLP55UQWI3N5LLY 南平市 福建省
5T23WDEOAP7RYNU4JNL2ZG7PDQ 漳州市 福建省
UHZBROATFB2KWNMNLS23DPRJBY 定西市 甘肃省
WLPAHIUOIVKS644QSN4V5ZY5XQ 金昌市 甘肃省
J7TO3UHZ57ABNUKIQUBCIJZQFA 白银市 甘肃省
GX277SS75375VEFYVCEHBL6ISA 临夏回族自治州 甘肃省
ORA3R7F2LSJHUOCIDTZCP54G7Q 张掖市 甘肃省
L6VQYYOJNTHSLW5JCR5SXBITDM 武威市 甘肃省
65OXQPQVFXNOYHXTMCE2RNFRL4 兰州市 甘肃省
IUIZYQ7E2SPMEAIGOUUNZWBQOA 天水市 甘肃省
BYBRGRDRV4NKAWCU6GWBIVLX3Q 酒泉市 甘肃省
KDST2VRETG6WK5SMJO2G2FN2RU 嘉峪关市 甘肃省
R3Q2XWVFVF4T2ADZZZMPZJRWBA 庆阳市 甘肃省
7FWNT2TP66SU4QEP6IBBMZWFNE 陇南市 甘肃省
T33S2GYGPVHAL5SR2FLSPTJSBE 平凉市 甘肃省
RR6KAWBLOKD4H2UINKYPFCPXO4 甘南藏族自治州 甘肃省
QKX4DS3CTJJG7SFW5SBHPLD42I 茂名市 广东省
JJZ75A32XCQNZU4IN2ZCEUGN3M 梅州市 广东省
SJSOXOSJASLUT6LBH4E32SUKKQ 清远市 广东省
SQQWAN5BQOVSX55S7EPF7QHMAU 珠海市 广东省
NGRJMW6JMRS6U2KUJEONSORAEY 韶关市 广东省
FAUMIGOSOET4E5WR5BL6P3OZHA 佛山市 广东省
JSBIH55ICFZQ2D3LEV47YMZ2NI 河源市 广东省
KOYAYPD2DBLDCF2ZI5GCW4LF6Y 中山市 广东省
647JGFPUYM4VWVLZCPSHT63XKQ 汕头市 广东省
AMOIPZW3Q2NMTDSEREFVM4SV74 深圳市 广东省
XIHEJY4H2CDZJCLIXDIN36BKXQ 广州市 广东省
UZ6OT4CYUR42KCTTED2KJW6EGA 东莞市 广东省
RM3HLOIUEYKTQ5O2JSVEKDMFRY 阳江市 广东省
C6XNJAZA6N3NNUDBUU3JIZPTDI 潮州市 广东省
AUPF3G2ULSV4TDT4L3NMHRTY6Y 揭阳市 广东省
7HIITKBPRXTVBA2FBBN443XITQ 云浮市 广东省
TO6ILZ7MPJMJN3S7W2SXMIFQQY 江门市 广东省
DTTBMGCIOMPCZY5NETUEKWJ6PY 汕尾市 广东省
HJY7JYWBA6FQY42RYSKX3RZTRI 湛江市 广东省
SLICHB4FBDVDLI53MR74WVUUNI 肇庆市 广东省
ZPX4JXJVBBYSSD2KTWHAPXO6NE 惠州市 广东省
JH4Q44RQA4EZ3Q6MHQVEVE7KZQ 百色市 广西壮族自治区
H2JXFEJFIL4PPFMYOS4MHZ5IBM 崇左市 广西壮族自治区
SXIRRISUOEGBU335AWT2ZFL6A4 贵港市 广西壮族自治区
HEQHKC4KP7YGGYVBZM5JEUI5AQ 北海市 广西壮族自治区
SKGG7KMFKVDIDKRVQEPTS7SIE4 贺州市 广西壮族自治区
N4WR7CWCULNA5Z35OTDJSZYDCU 钦州市 广西壮族自治区
T4RXX2WY6WPQYZEUXVJNZBXQZU 梧州市 广西壮族自治区
3R23AS3EIY7EYE2D5MWWORZODI 河池市 广西壮族自治区
57SMWWCV7X44E256P4I23OQ3AA 防城港市 广西壮族自治区
YHGHVIQ37UCTNQ4JKPQEAUWIQA 桂林市 广西壮族自治区
MQJZTM455OKZLAN5WQYUTA5TDE 柳州市 广西壮族自治区
C6FZPLB4NJQ6VUPSKDJH3EWQDM 玉林市 广西壮族自治区
BFSU5W6E5XBIDFPQLVPGRDSATY 南宁市 广西壮族自治区
CKXOQUZDNOVNME3PEBOY2CULQQ 来宾市 广西壮族自治区
LV32FV6IQTKFR7JIBEQMHRUVCA 贵阳市 贵州省
F26RCNKMFTZONJCSJ5C6FHVY74 毕节市 贵州省
G2LMYRWVRCK7BTD2WM4NWX4SYM 黔南布依族苗族自治州 贵州省
MFSOO3NBMB2PVLIVSI5EJK7MWY 黔西南布依族苗族自治州 贵州省
KWUL44L7SEMJGIMXCWSSEB3OOA 遵义市 贵州省
T2P3OFGQZUGR7D6TGRCMDF22GI 铜仁市 贵州省
AGUFUANSZNGC4TMOPZO65IRSPI 六盘水市 贵州省
2XOCOSNUAK3J5QDGTKIBWKK7KU 安顺市 贵州省
6XRTSAEYJTA2UBKXO4XEPQE5ZY 黔东南苗族侗族自治州 贵州省
YRMKRP2GOE2VMRS73N4YRIZUHY 三亚市 海南省
CJRGVLBNLJAVBJ4ZKKIZ3FZ2LY 白沙黎族自治县 海南省
5XOUAJ5Z4J4K7SVIQGXL2OM2JQ 保亭黎族苗族自治县 海南省
2UFQ6A2QRJPXPH3VOYEAQHMVSQ 海口市 海南省
TGCVXVS4M7NDQM4ROUDCVI6I3A 承德市 河北省
JEUP6QWCOXPSM3SQTINQCJKIGM 衡水市 河北省
Z442MNCW6BO2BBHIRUPRBACXPI 唐山市 河北省
RFE6R34GD4FY3LUKC2ICSF6AFY 张家口市 河北省
CWJN55M73VZDCYJEQ7AHDBWGGY 沧州市 河北省
DFL4ES776ECRGBYNOPLWKB247I 雄安新区 河北省
ZLSXYY34IHBHIC2NOVPQQBFTBE 保定市 河北省
3DO6Z2QRJQFMPLLDS55PG7DSBU 石家庄市 河北省
PR57XT25LI3246VGASEPSHP63E 邢台市 河北省
SKYLNH737BS56TD452FOKYL36U 邯郸市 河北省
5PWPERL7GQKJD6QPLR2TWUUM7E 秦皇岛市 河北省
5T2TGV6SJFVL3MO7HIMN2KTQTA 廊坊市 河北省
ECSTLZ7GP7IX3MB5EVNKS47MLE 焦作市 河南省
VTWW34QB2F5Q4LW7ISNUMWX7GY 开封市 河南省
D2NUN47NY4Q55X3UED4JMSI6CM 周口市 河南省
TR3XJFQR4EFYRRIX7TUQF3B26Y 郑州市 河南省
CKJGF5S6XMHW5ZJEBU7MJC47QA 新乡市 河南省
IFASZ625MCFJQKPLJ7EA2SMJUU 商丘市 河南省
SKXPYKTTRG4YAUHE2HZXWRWXGM 鹤壁市 河南省
G5LXE74CUHO2K6BBRN7Q5DSRJY 漯河市 河南省
SLNOAFJV2LTBSH7SJCRXJA36K4 驻马店市 河南省
65WO7LH7CFDUGKYMXQLRAYKKWM 安阳市 河南省
RIX2X7FAVTZCAQ5RT2C2CWK22Q 南阳市 河南省
SZOW5OY3U54SSY4WRC65VJUNTI 平顶山市 河南省
7VPIDDUS4P2LSZ6Q5S57MAQDEM 信阳市 河南省
4VYCRORUOZ4DC2U6S3CT6H6KWE 洛阳市 河南省
M5WNO2BQ3UGLLHBCG4NCEFPP5U 濮阳市 河南省
LY3O6PBPIWETMA3ZOL6ETY5UB4 三门峡市 河南省
FXXJLIRE72LS2W4OWWQVJMRJHA 许昌市 河南省
5XH353QTY3VWF2KYOCZCL3TOXY 牡丹江市 黑龙江省
2KGRZKF6IECV2W7K5J64Y2LY4M 齐齐哈尔市 黑龙江省
FASGWS5ADVSTFGJG6TGBZPZP6Q 鹤岗市 黑龙江省
2O6CDIXSWIKBXILZEEPKCS7MVI 双鸭山市 黑龙江省
OZ2PTOBYTBG57XJZMIC23QFJKM 佳木斯市 黑龙江省
FO24MQMULT3J5JW64APNXSQEPU 伊春市 黑龙江省
ETZ2HYWVU6U7SKU6G4JAO64RUQ 黑河市 黑龙江省
PR7EJNBY2VZBEUT36JAWE3TM7I 七台河市 黑龙江省
HADAAVLERKIW4SQGCTQYGX4AL4 哈尔滨市 黑龙江省
CGTU45YC5C3JYLHMA47USDPA7Y 大庆市 黑龙江省
T4W7SQIPOM4EYMEFFRAB5BSTII 鸡西市 黑龙江省
TYGZHNQL6YT7CX6EEG5DJQQHMA 绥化市 黑龙江省
OOSJTSN2CVUUCKD6XAB7EYYIPY 大兴安岭地区 黑龙江省
I3YF3EKZHIZTN6TZOTYTGZ2UXQ 随州市 湖北省
ESGVBOSTHW7JWEVCGYJUTEHEBQ 宜昌市 湖北省
MTJRWJ53XBW5SBTWHKNNZDLM7U 十堰市 湖北省
PXZLF2ISKQL5ACM67ZCBNOGDT4 黄石市 湖北省
44RMTOEHPUFXBHZXX4IQ4IRZVQ 荆州市 湖北省
OTKZGG743NFC474ADMMRX4ZOOA 鄂州市 湖北省
EXOUAZAQ73OEFAK72CHQ32GQHQ 恩施土家族苗族自治州 湖北省
SUCY7I72QJDZD7EBFXREIQ67SI 咸宁市 湖北省
QENSGB5R7HGYDXCG2LQZQTO3TU 荆门市 湖北省
OHIWL6SAE2PR4EJR4BOMLAE6FU 武汉市 湖北省
ZXCE4WV2CDVPQTA4HAOVELQMNE 襄阳市 湖北省
KEFN5OPSS4ZZF6NU2TTL72S6HE 孝感市 湖北省
ROAHLMQ67H6M5NDFVXROJG723E 黄冈市 湖北省
YBEBX2YYN4WPBNH6Z6C73DNE7I 张家界市 湖南省
45XGRKYGSCPE5VNRYF4FVJGFMM 株洲市 湖南省
LK3SEIBRU7GTDT4J2EPTLIO33U 永州市 湖南省
R4YXFIK53W5E556BSGSBJWS4DM 郴州市 湖南省
PQDO3RNADWXX75OWZW2GSXJ4SE 怀化市 湖南省
RRRT6QOJYEJ432L3F76ZN5NHCA 长沙市 湖南省
B6WPNMCZ3ENQSV4NFY5MSTPDAM 岳阳市 湖南省
KNDZW5EHDPKP2DX7HBLKP4DYLM 益阳市 湖南省
PA2GHG3XZ7I47HTKZ4YAFH3OYY 湘西土家族苗族自治州 湖南省
I7CNIUA5PYV2EHDEW3RGYT2R4U 邵阳市 湖南省
SRI2SU4FN66FMJJCKQOCZD72ZY 常德市 湖南省
H7UHHJAMQUL7UA5QEUTGKNSL3A 湘潭市 湖南省
EFB255OBTB2BUDZENR5UVIC7ZQ 衡阳市 湖南省
RDMANB4KCM3OJSNVGZWVYVME6E 娄底市 湖南省
LD37PDU5OB4UAV5QDOBMKG5YTY 吉林市 吉林省
EO3GF4XNF5RXWRPUVAT3KTQO4U 四平市 吉林省
4GD7OS4CAQABH5YIWVK5SKGHMY 通化市 吉林省
6DRI2R5VAWMYJHJPCJKUNMDYEQ 延边朝鲜族自治州 吉林省
TVBCNVGUND4MOUOOUXFGX7DIUA 松原市 吉林省
JYY62HSKBUVK5OU7KGJDKQ4RTA 白城市 吉林省
QEDUHKMZ36CHJTKRD6O2ZPLNBU 长春市 吉林省
4EADVCBJMZ5UBH2FVRT6QCLS2U 辽源市 吉林省
EUQD5EGS2LR5KJSFNG6PPSIHHI 白山市 吉林省
YLTIISPCLBEGTZZX3WUWAD7WDE 淮安市 江苏省
L6U5DZP6MESXPMHOHCDMJS55O4 宿迁市 江苏省
HQMLYA7TDGMYQAXFCDUXBZPYHI 镇江市 江苏省
K6XJ4UN65ZD6XQKYEG5YN7HCRI 盐城市 江苏省
36I4X3EZZU4EHOSCLQI5OAKKBE 南通市 江苏省
S3GWFQU6QAVRDKLJT77LD6OFLE 泰州市 江苏省
IO6F4AFGAVIFRGYTZEC4TXM7W4 无锡市 江苏省
NUXNK2VOFSD2JFTEO2AMWX6NSU 扬州市 江苏省
TEVZU6CU6SK57HFW7DFNGMQ44A 南京市 江苏省
UTYSRBQ4FSB7XLWCF3Z2HTKNUA 常州市 江苏省
OCZOBCJDEXKE7KBN3BD7AYQG2Q 徐州市 江苏省
6LIBPJGZROLXE3CLZGJRYMYBOU 连云港市 江苏省
FS4PIU74F7QKYARDWR5ZMOLICI 苏州市 江苏省
R2F4OWUO65HYZW2IQIKINORZ7Y 赣州市 江西省
YW346BTN3VFYNRC3744UR5MZXY 抚州市 江西省
QR3FDR26U2EJIXOMBHL7IJLQSA 南昌市 江西省
OAJHJL7L7VNW2Q5UXRE7F4CUJQ 九江市 江西省
OMH7D45R4DX2KNHLV3G2UP56OY 景德镇市 江西省
YSB2PAEROB2IZSJZFVFH7KJPEI 鹰潭市 江西省
5OYAMNORCXKYA6UF7DW6KFFBIU 上饶市 江西省
SMHZOYKE7BXQJ2NT6Q24TFMLEQ 吉安市 江西省
2RZV26OUPKUHUJ5ZPB673VDGZU 萍乡市 江西省
232VHZEEZ6SXACE4AC5HQ4ZTFQ 新余市 江西省
QRLM74YXNDW2QDBWLTFGEMXK2I 宜春市 江西省
S6OHUVUKIIWPVMQD44RREUMNT4 葫芦岛市 辽宁省
DQQ4OIFUGFYJY3XZRK5VDWMLCA 辽阳市 辽宁省
S4YXGFEYXEUG6ISZ6O337OPVSI 阜新市 辽宁省
D3JHM7A4CG6RJMBD7YRDS5JOYU 盘锦市 辽宁省
VTRWMOSS6PCUYUAIPG6VPBKUUQ 营口市 辽宁省
ZPHFGWBIEVLKP5CVZNZUB3CRT4 朝阳市 辽宁省
NGYYULZ4UAGD3Q2PG726FFXSHU 抚顺市 辽宁省
Q5BRTSW752VSHIAKLLL7KL5TNA 锦州市 辽宁省
ZGV3WNPOSS7J4ZWBP6ZQG46BNM 沈阳市 辽宁省
XEX676YYMTYIV5QPIUZB4TA7IY 本溪市 辽宁省
PRTEQZMLNLQNZXJHRCYYLWZB4E 丹东市 辽宁省
4GN4WF6UQRFU64T4FVZPRDXRWQ 鞍山市 辽宁省
3QTZDFLJFSLLOOVCZ65PSDAVOU 铁岭市 辽宁省
CC4ZTMKKXI73ZEVT5QQTJN5SMM 大连市 辽宁省
3MBJEFDLAVOMQZ7L7CM5MNSYKA 鄂尔多斯市 内蒙古自治区
5NOS4YC5WO2IZCQPVB6MCBYDJ4 呼和浩特市 内蒙古自治区
OQNIP675H7L5R64652BH7KHUOQ 通辽市 内蒙古自治区
4WA6I63MGVINV5DNLNWRRHCDDM 阿拉善盟 内蒙古自治区
ELI6BDJBAN6RCYTETMK2EX2UKU 乌兰察布市 内蒙古自治区
PV5ZAAXFW2DZCVZKCF4I4KK7BQ 巴彦淖尔市 内蒙古自治区
YU6UUT6G6T6AMWTJFECDIUQFEQ 乌海市 内蒙古自治区
V4MYANW5QFZCXG3FIDPXA3HOTE 呼伦贝尔市 内蒙古自治区
S5DCFJWJ7J3MJSLY2PHKWLNPOQ 包头市 内蒙古自治区
LY7SAZRFSJJMRU3JEO5SKNKIVM 兴安盟 内蒙古自治区
NM2XP54CNQCFOILKACYEQWUSGM 锡林郭勒盟 内蒙古自治区
S5G3IO75IDEJPZQA6VFM3OYPDI 赤峰市 内蒙古自治区
UUFUUPM5RT6ZU5UKILQC5YQV54 吴忠市 宁夏回族自治区
VMSRLIATK44WQXQEWAL63AXJ3M 固原市 宁夏回族自治区
4GWWCAAKGNJV2SMQPSWWZNCGYY 中卫市 宁夏回族自治区
6IE7GEETBQEF7GUSGU2FLIUEEM 石嘴山市 宁夏回族自治区
VI4YIH3URSON4Q4MWOEESXJ56Q 银川市 宁夏回族自治区
SIE4ED6QWVRT727GEHWBFH3DAA 海东市 青海省
JNJH6OJZIOQKXDXWW5ZGEHG5MA 海西蒙古族藏族自治州 青海省
MJADYNCKQNDJU2TXACTDP5I52M 海北藏族自治州 青海省
LRGFXIVB6RJWQWYAFH7EIUHCPE 黄南藏族自治州 青海省
J4TG3PCK2ZEMNEUMIPZF32UNQY 果洛藏族自治州 青海省
2YS5POGG53LKZGFBIUMDWP57SM 玉树藏族自治州 青海省
GRZMJEZCA2DNCZK3O6ZSUHMRPM 西宁市 青海省
NBFQIACRBBCAH5AZWJ5LVT7AU4 海南藏族自治州 青海省
MQUKCLQ76P4FRRECDBA3HBKT7Q 滨州市 山东省
633FVSBDDBM5WSMXSKOCX6QC5I 潍坊市 山东省
4434FVT3PXLMV6UAWLEW6O3M5A 菏泽市 山东省
P7PK4UBVCOHW3PI6IPEIA54DLY 济南市 山东省
I5M6JGTGSQEWX6HL7E5I6GRBAY 德州市 山东省
V562AOMBVU5NG5GB3EPK6U42XY 烟台市 山东省
4OSPHTE5TD24J6DYGR6DXMEDKY 淄博市 山东省
227TLAVTUJABWPJD4S4ZECJ3FY 临沂市 山东省
DAEZKZU32ZAPJGUTA6LLGO3WTY 聊城市 山东省
LBRRK2EOYJN5MLYJWT4R3QBSXM 东营市 山东省
AB6PBGCDBTNTG4KUQROY2FJ4GY 枣庄市 山东省
EVANGU7WZCVRAAM6NWTDJVP7SU 济宁市 山东省
GNUEGWZ3OKRWAKKVJ5THHHX6YY 泰安市 山东省
F3VWSF4ART2FYYBBOZYWKRXTUI 青岛市 山东省
KD6MNWWLVKB4E655XMV6MMA3KE 日照市 山东省
LHYVF4LBCOZ34G3WNZYUVEIQGA 威海市 山东省
ENVYDMYGDO3BMDXSAVQYZXLX74 阳泉市 山西省
UXOUG4UIF7ZRJYCNMQJ3LDN5FY 临汾市 山西省
4NZPT6Z35BMYACJ2HZGHUWRJ6E 吕梁市 山西省
KSNXQME2A3VFHCE3DM3SFZKIJQ 晋城市 山西省
HDOX7WKYSHJKEHET6TUYMVCTMQ 太原市 山西省
DFJIZVXJGBGBIABPSL3DGMIDIE 长治市 山西省
5KQYYTJR2EMP653QIALMA6LXXI 忻州市 山西省
T76EOJA332RIHML7B6LYS5LF4U 朔州市 山西省
HVX67CKT5TS6GPRDFDYOOLK4PE 大同市 山西省
S4NGXQJDOH7E4IHDWOH3EK6IIE 晋中市 山西省
GWDLZXLAWU54FKQ6G3HRQRR7E4 运城市 山西省
3FFTTN5PPV7MBCE5AGY2NGYOOI 安康市 陕西省
GACVPL3SWO3ZKH73JMJV6YI4NY 延安市 陕西省
6KPS7VRMW57P2DAC6OPR4ISHQQ 商洛市 陕西省
OMMF6XLNDNYWG5TBSNTWO2ZJZ4 渭南市 陕西省
EALFXGMWYRS6E6TWXQ2K3YHV4M 咸阳市 陕西省
WFG7U6JNUWDIS5ZZYM4FSM5C64 榆林市 陕西省
R3VBMYTCF5LVHO35X3MYJQFOOE 宝鸡市 陕西省
RQOWP7C234IS4RKSHB26IYZ5IU 西安市 陕西省
K4YU6B4T5GZLRWVHGVCR3576HI 铜川市 陕西省
VVFVAPLKSCN5KN4Q6RK2GPGUUA 汉中市 陕西省
2QSF6IG3KMDXWO5VP7FXHMMKXA 上海市 上海市
X3JCRNIPTCUU6DGOFFJ4MUK37M 眉山市 四川省
GQ24IZNTZJ3PUB5FDAMDA4W7UI 攀枝花市 四川省
6B6WT62WHBZRHPQUT7BAD2N6ZI 泸州市 四川省
HJ35P4KXL442MLIII7PFFWUNAE 雅安市 四川省
K4A6VSJH2AJYT46LMUSVQZPCCU 资阳市 四川省
646ZNPATOOM3MHI3LDU6HI4KFI 阿坝藏族羌族自治州 四川省
MU735ZDBFPXRQDUZ3I35JK3XEU 内江市 四川省
4WPGGJ63USY77GSRN2PPFCYKPQ 广安市 四川省
O4FFS4DALDAAKIFAUH4F5V5VS4 宜宾市 四川省
IRFJVK2KXBE6BZ7CSN4UFCI624 绵阳市 四川省
NELFD7FEKKUNDJ46VLD55SMDCE 甘孜藏族自治州 四川省
TKMVEUPZSQCXNRZPBEIK3F45AI 遂宁市 四川省
VQW7DPB4KTUI65COJBO3NODU24 巴中市 四川省
STP4ELXTVGQSB572LFRFJRIUUY 南充市 四川省
6ST5EX2JVXUCLR5GP5VEFSKN5M 成都市 四川省
UWNFCMW3HYJRALQI2MJH6EM2O4 德阳市 四川省
J5ZYU7XRV6CHSJAGOPQKS5YXNA 达州市 四川省
KYJTF5S746T35RFBMR65BLGM6U 凉山彝族自治州 四川省
RDUXR23XROLB4NGRVDDLXFXDSE 乐山市 四川省
4TUBIBHMVESJUCGMTUSLJPHXWI 广元市 四川省
AXQL57AO27NCHYMEOLRHAAKMTA 自贡市 四川省
4RXX566RZORCXS6HLEX3DIICSM 花莲县 台湾
BD5Y7SISWSSQGP3HTVPU6TXAH4 台东县 台湾
I4DNWLECRYOJAZLGYQB7PBBJXQ 台中市 台湾
GIZQIESFOMAEQSDQKOEQ5RTTPA 南投县 台湾
MPM6M2C634FAW7KYG3KIHERDTU 彰化县 台湾
NH2NK6JVOBBYYTK2G53ADWLX4Y 苗栗县 台湾
DW2Q2R2UEEDNQA7IHBGYV423K4 新竹市 台湾
FX5AOPFRPHGHHYZB4XNIPXLNNM 新北市 台湾
UVNZNB6G4M35RV3IGRUN6OXMXE 屏东县 台湾
MPB3M2YK24ZORO3EDCJ6UDWIGQ 基隆市 台湾
EBHVITJPDHJEEMMZTM4TD4UVRU 台北市 台湾
FQS4PZNCTQEX6I5F34Z2AGJUZM 高雄市 台湾
WVOJ636Q7MGT6RMN6QYQ4SZWIE 嘉义市 台湾
QRLER4EEMMKYGQLER2RWEQA74E 台南市 台湾
K2LHF64R2P4OJ7MDHJ6J2NSTPE 桃园市 台湾
BILG6LJIWUCTXZ6CDPXYAVM6XI 澎湖县 台湾
3FYRA3O2HUMLIQAAJQCPX2TETE 宜兰县 台湾
4MW6X22PAPVMHB6SBGF3RYS324 天津市 天津市
YEYPP4SQOBXU5UCNDN7ORSR6DI 拉萨市 西藏自治区
UNE6UPENGWQDOWGABDJEAQ2FEY 山南市 西藏自治区
VDXKN2YCIUPOHBZKKQHPN6HJWE 林芝市 西藏自治区
EAWIMNI77H72EYSOAYV3M76CB4 阿里地区 西藏自治区
HCF6UHTXOOKIOA43AIJH3ARKXA 昌都市 西藏自治区
Y47QI3KJY352QV3VOPXHM2IDWU 日喀则市 西藏自治区
R4UWJX44GVAA54NFKHT4Y4ZC5A 那曲市 西藏自治区
2D37GB5XUALJDXONWJIGXV3QXU 香港 香港特别行政区
PA5W7Z255K3EGYA4LTA7BGLHRA 巴音郭楞蒙古自治州 新疆维吾尔自治区
RJZOCU5ECQCOLCOCHJH2UNLQJM 哈密市 新疆维吾尔自治区
RYK6AR3VDQJFXZLX3MHYFV5VAI 塔城地区 新疆维吾尔自治区
MXUVGU5NPVTNINAKKPNLWUQ54Q 博尔塔拉蒙古自治州 新疆维吾尔自治区
T5RKMSH5VIGRTHDZOKV2EIKPIM 伊犁哈萨克自治州 新疆维吾尔自治区
NABMKFZPOUCZMS4TUVJSZ24DNA 克孜勒苏柯尔克孜自治州 新疆维吾尔自治区
2YQBXWNFYVJX4NU6WXB5II6X34 昌吉回族自治州 新疆维吾尔自治区
DBKCQCCQU2URQG2EP5ZNPKBETY 乌鲁木齐市 新疆维吾尔自治区
DON6KYBCR2XJQOJQGOZTYV4RMM 阿克苏地区 新疆维吾尔自治区
WKG47NEVYII5JISZJN7QSI2BDQ 克拉玛依市 新疆维吾尔自治区
DWLK6D3OUOXOVQHSEJVTRIDRAI 喀什地区 新疆维吾尔自治区
SAPKF2PQGJD4UMVJZTC3IZKI64 阿勒泰地区 新疆维吾尔自治区
ARWSLGG54LGUGN3XMIWW76NW34 吐鲁番市 新疆维吾尔自治区
VQCWAKL6ADHYFTSVPBGPDFSB2I 北屯市 新疆维吾尔自治区
ES5A6MOROAG6F2XAQ25TYYPWUE 铁门关市 新疆维吾尔自治区
36IUY52AESIPF4QEQAR2RTNQYA 和田地区 新疆维吾尔自治区
Z26KSUL6ULS65ITZNUCRRWBYJM 文山壮族苗族自治州 云南省
XBBUUATPBD2IUJR47FCKVUXB2I 昭通市 云南省
TK2LP3JCYYMPTV4OA3WJCH7UQ4 怒江傈僳族自治州 云南省
ISJ4FESOYKCQ5LXOQO6NL7TQHA 曲靖市 云南省
ELBUBVI5UMUIEQGIOAHPMESXFA 西双版纳傣族自治州 云南省
QHOYHEGZM4WSJZPFEU6FCBYIWY 玉溪市 云南省
4G4SPJ7MVHMYZAWMQ4642PMLVI 保山市 云南省
HCHRV2LGJ2TJ4X6BWNWI2IMID4 普洱市 云南省
IS4Q6NASBWHO3RFO7UCIWOXVI4 昆明市 云南省
TDJZOAZFQUQPYRR5BHOJTG6RWM 红河哈尼族彝族自治州 云南省
EIYC62RNU4SHQW3RLAMDPTQYTI 大理白族自治州 云南省
BA3XFPITAYKBUWDKU3QONRHBC4 德宏傣族景颇族自治州 云南省
6P6FFFO6C5MLNCVRJAJUICSVQI 临沧市 云南省
OXHMWH2TSIDI7BQ43EHAMXJ6N4 丽江市 云南省
QQPDT4LBI2K2KMBNXZ6YH7X2FI 楚雄彝族自治州 云南省
XBG4EJAWCRJL2TDPNJ23PTFYHQ 迪庆藏族自治州 云南省
DINNCH54AP74TJ62MICEYAZP74 宁波市 浙江省
XYTSLYGB2ETU6HG7GIXA7X5SOE 嘉兴市 浙江省
NNAALJZXGAWALR3LGE2V4UZT6U 丽水市 浙江省
H5UOJ5MQYJ737GS3TXYN2OJHUU 杭州市 浙江省
HG5VQGOMSCEGNXJXKO6XCNCHMY 湖州市 浙江省
LJ2SWEPRINTYDH5A2QHRMI5US4 衢州市 浙江省
TW4RRM62TDA7WWU77FDSLSAXGY 台州市 浙江省
HBBN247QZ6YUW5ZYSS6D7RVJCA 绍兴市 浙江省
GVFB23SJZGRPRXXTIKDCAOCDPI 金华市 浙江省
UEW4ENX7N7IFGFM7FD5SZ5GI7Y 舟山市 浙江省
HCCXS5DGRJQMYZMRLGVAMUIQEA 温州市 浙江省
FDGY55I6IHKY76E3MWDBOT2R6Y 重庆市 重庆市
+65
View File
@@ -0,0 +1,65 @@
"""通过经纬度反查城市(reverse_geocoder 离线库,零网络调用)。
reverse_geocoder 内置 ~2.5M 条全球城市/聚居点的经纬度→地名映射表,
构建一次 KDTree(~几十MB 内存)后,查询为纯内存搜索,不作任何外部网络调用。
⚠️ 必须持有单例、且用 mode=1(单进程):
- reverse_geocoder 的模块级 rg.search()/rg.get() **每次调用都会 new 一个 RGeocoder**
即每次都重新解析 ~2.5M 行 CSV + 重建 KDTree(数秒/次)。绝不能在服务端按请求调用。
- 默认 mode=2 用 multiprocessing 按 CPU 数 spawn 子进程做并行查询;在服务端 / Windows
spawn 下会重复 import 主模块(无 __main__ guard 时直接报错),既慢又危险。
故本模块持有一个 mode=1 的 RGeocoder 单例,建一次树、复用;查询走单进程内存搜索。
⚠️ 精度说明:gazetteer 里的中国数据粒度不一致——直辖市/省会通常直接命中城市名,
但部分城市会命中到区/街道级(如天津→Erwangzhuang、西安→Zhangjiabao),
此时 admin1(省级行政区)可作为回退。业务侧建议优先用 admin1 做城市级判定。
"""
from __future__ import annotations
from typing import Any
import reverse_geocoder as rg # type: ignore[import-untyped]
# mode=1 单进程 KDTree 的单例;None 表示尚未构建(见 ensure_loaded)。
_geocoder: rg.RGeocoder | None = None
def ensure_loaded() -> None:
"""构建(或复用)RGeocoder 单例(幂等)。
首次调用解析 ~2.5M 行 CSV + 构建 KDTree~秒级、数十 MB)。生产应在 main.py 的
lifespan 启动阶段主动调用一次,把这份一次性成本摊到启动,避免砸在第一个
/feed?tab=rec / /top-sales 请求上。mode=1 = 单进程,不 spawn 子进程。
"""
global _geocoder
if _geocoder is None:
_geocoder = rg.RGeocoder(mode=1, verbose=False)
def get_city(latitude: float, longitude: float) -> dict[str, str]:
"""根据经纬度反查最近聚居点。
返回 dict:
- name: 最近聚居点名称(英文),如 "Beijing" / "Fengsheng"
中国境内可能是区/街道级;海洋/无人区返 ""
- admin1: 省级行政区(英文),如 "Beijing" / "Hubei" / "Chongqing Shi"
直辖市 admin1 即为城市名
- country: ISO 3166-1 alpha-2,如 "CN"
- latitude: 匹配到的参考点纬度(字符串)
- longitude: 匹配到的参考点经度(字符串)
未匹配到(海洋/远洋)时返回空字符串字段。
"""
ensure_loaded()
assert _geocoder is not None # ensure_loaded 保证已构建
results: list[dict[str, Any]] = _geocoder.query([(latitude, longitude)])
if not results:
return {"name": "", "admin1": "", "country": "", "latitude": "", "longitude": ""}
r = results[0]
return {
"name": str(r.get("name", "")),
"admin1": str(r.get("admin1", "")),
"country": str(r.get("cc", "")),
"latitude": str(r.get("lat", "")),
"longitude": str(r.get("lon", "")),
}
+448
View File
@@ -0,0 +1,448 @@
"""美团城市词典 + reverse_geocoder 离线反查。
从 feed 入参的 latitude/longitude 计算出美团城市 ID
用于后续美团 CPS 接口的 cityId 参数。
⚠️ 跨系统耦合:本模块返回的 city_id 取自 data/city_dict.txt,而离线库
`meituan_coupon.city_id` 由 ETL(另一套系统)灌入。二者必须用同一份城市 ID 口径,
否则 `WHERE city_id == <本模块结果>` 会静默查到 0 行 → 接口永久降级返空。
改动 city_dict.txt 或 ETL 的城市 ID 来源时,务必同步两侧。
"""
from __future__ import annotations
import logging
import re
from functools import lru_cache
from pathlib import Path
from app.utils.geo import get_city as _get_geo_city
logger = logging.getLogger("shagua.meituan_city")
# city_dict.txt 作为运行时数据随包分发(见 pyproject [tool.setuptools.package-data])
_CITY_DICT_PATH = Path(__file__).resolve().parent / "data" / "city_dict.txt"
# ─────────── 反向地理编码 admin1 → 中文省份名 ───────────
# reverse_geocoder 的 admin1 格式不统一:
# 直辖市: "Beijing" / "Shanghai Shi" / "Tianjin Shi" / "Chongqing Shi"
# 省份: "Guangdong" / "Jiangsu Sheng" / "Hubei" ...
# 自治区: "Xinjiang Uygur Zizhiqu" / "Tibet Autonomous Region" ...
# 下面用前缀匹配,去掉了 Sheng/Shi/Zizhiqu/Autonomous Region 等后缀。
_PROVINCE_EN_PREFIX: list[tuple[str, str]] = [
# 直辖市 — admin1 即城市名
("Beijing", "北京市"),
("Shanghai", "上海市"),
("Tianjin", "天津市"),
("Chongqing", "重庆市"),
# 省
("Hebei", "河北省"),
("Shanxi", "山西省"), # 注意: 指山西省,不是陕西
("Liaoning", "辽宁省"),
("Jilin", "吉林省"),
("Heilongjiang", "黑龙江省"),
("Jiangsu", "江苏省"),
("Zhejiang", "浙江省"),
("Anhui", "安徽省"),
("Fujian", "福建省"),
("Jiangxi", "江西省"),
("Shandong", "山东省"),
("Henan", "河南省"),
("Hubei", "湖北省"),
("Hunan", "湖南省"),
("Guangdong", "广东省"),
("Hainan", "海南省"),
("Sichuan", "四川省"),
("Guizhou", "贵州省"),
("Yunnan", "云南省"),
("Shaanxi", "陕西省"), # 双写 a 是官方拼音
("Gansu", "甘肃省"),
("Qinghai", "青海省"),
# 自治区 — 注意匹配顺序, Xinjiang 要在 Guangxi 前面(Guangxi 也是 Xi 开头但先匹配 Xin 不会误判)
("Guangxi", "广西壮族自治区"),
("Inner Mongolia", "内蒙古自治区"),
("Nei Mongol", "内蒙古自治区"),
("Tibet", "西藏自治区"),
("Xizang", "西藏自治区"),
("Ningxia", "宁夏回族自治区"),
("Xinjiang", "新疆维吾尔自治区"),
# 特别行政区
("Hong Kong", "香港特别行政区"),
("Macau", "澳门特别行政区"),
("Macao", "澳门特别行政区"),
# 台湾(city_dict 里省份名为 "台湾",没有省/自治区后缀)
("Taiwan", "台湾"),
]
# ─────────── 常见城市名 英文→中文 映射 ───────────
# 覆盖所有直辖市 + 省会 + 一线城市 + 部分 reverse_geocoder 只能命中到区/县的城市。
# key 全小写,匹配时做小写比较。
_CITY_EN_TO_CN: dict[str, str] = {
# 直辖市
"beijing": "北京市",
"shanghai": "上海市",
"tianjin": "天津市",
"chongqing": "重庆市",
# 省会 / 副省级
"guangzhou": "广州市",
"shenzhen": "深圳市",
"chengdu": "成都市",
"hangzhou": "杭州市",
"wuhan": "武汉市",
"xi'an": "西安市",
"nanjing": "南京市",
"changsha": "长沙市",
"zhengzhou": "郑州市",
"jinan": "济南市",
"kunming": "昆明市",
"fuzhou": "福州市",
"harbin": "哈尔滨市",
"lanzhou": "兰州市",
"guiyang": "贵阳市",
"nanning": "南宁市",
"shijiazhuang": "石家庄市",
"taiyuan": "太原市",
"shenyang": "沈阳市",
"changchun": "长春市",
"hefei": "合肥市",
"nanchang": "南昌市",
"haikou": "海口市",
"hohhot": "呼和浩特市",
"huhehaote": "呼和浩特市",
"urumqi": "乌鲁木齐市",
"wulumuqi": "乌鲁木齐市",
"lhasa": "拉萨市",
"yinchuan": "银川市",
"xining": "西宁市",
# 其他常见城市
"xiamen": "厦门市",
"suzhou": "苏州市",
"qingdao": "青岛市",
"dalian": "大连市",
"ningbo": "宁波市",
"wuxi": "无锡市",
"foshan": "佛山市",
"dongguan": "东莞市",
"zhuhai": "珠海市",
"zhongshan": "中山市",
"wenzhou": "温州市",
"shaoxing": "绍兴市",
"jiaxing": "嘉兴市",
"jinhua": "金华市",
"taizhou": "台州市",
"yangzhou": "扬州市",
"nantong": "南通市",
"changzhou": "常州市",
"xuzhou": "徐州市",
"zhengjiang": "镇江市",
"yantai": "烟台市",
"weifang": "潍坊市",
"zibo": "淄博市",
"linyi": "临沂市",
"weihai": "威海市",
"rizhao": "日照市",
"luoyang": "洛阳市",
"kaifeng": "开封市",
"xinxiang": "新乡市",
"nanyang": "南阳市",
"yichang": "宜昌市",
"xiangyang": "襄阳市",
"huangshi": "黄石市",
"zhuzhou": "株洲市",
"xiangtan": "湘潭市",
"yueyang": "岳阳市",
"hengyang": "衡阳市",
"mianyang": "绵阳市",
"luzhou": "泸州市",
"yibin": "宜宾市",
"nanchong": "南充市",
"zigong": "自贡市",
"qujing": "曲靖市",
"yuxi": "玉溪市",
"zunyi": "遵义市",
"guilin": "桂林市",
"liuzhou": "柳州市",
"sanya": "三亚市",
"tangshan": "唐山市",
"baoding": "保定市",
"handan": "邯郸市",
"qinhuangdao": "秦皇岛市",
"langfang": "廊坊市",
"datong": "大同市",
"changzhi": "长治市",
"linfen": "临汾市",
"baotou": "包头市",
"ordos": "鄂尔多斯市",
"eerduosi": "鄂尔多斯市",
"daqing": "大庆市",
"qiqihar": "齐齐哈尔市",
"jilin_city": "吉林市",
"anshan": "鞍山市",
"fushun": "抚顺市",
"benxi": "本溪市",
"jinzhou": "锦州市",
"yingkou": "营口市",
"dandong": "丹东市",
"huizhou": "惠州市",
"jiangmen": "江门市",
"zhanjiang": "湛江市",
"maoming": "茂名市",
"zhaoqing": "肇庆市",
"chaozhou": "潮州市",
"shantou": "汕头市",
"shaoguan": "韶关市",
"meizhou": "梅州市",
"jieyang": "揭阳市",
"qingyuan": "清远市",
"heyuan": "河源市",
"yangjiang": "阳江市",
"shanwei": "汕尾市",
"yunfu": "云浮市",
}
# ─────────── 省会映射(城市匹配失败时回退) ───────────
# city_dict.txt 内省份的第一个城市不一定是省会,故显式维护。
_PROVINCE_CAPITAL: dict[str, str] = {
"安徽省": "合肥市",
"澳门特别行政区": "澳门",
"北京市": "北京市",
"福建省": "福州市",
"甘肃省": "兰州市",
"广东省": "广州市",
"广西壮族自治区": "南宁市",
"贵州省": "贵阳市",
"海南省": "海口市",
"河北省": "石家庄市",
"河南省": "郑州市",
"黑龙江省": "哈尔滨市",
"湖北省": "武汉市",
"湖南省": "长沙市",
"吉林省": "长春市",
"江苏省": "南京市",
"江西省": "南昌市",
"辽宁省": "沈阳市",
"内蒙古自治区": "呼和浩特市",
"宁夏回族自治区": "银川市",
"青海省": "西宁市",
"山东省": "济南市",
"山西省": "太原市",
"陕西省": "西安市",
"上海市": "上海市",
"四川省": "成都市",
"台湾": "台北市",
"天津市": "天津市",
"西藏自治区": "拉萨市",
"香港特别行政区": "香港",
"新疆维吾尔自治区": "乌鲁木齐市",
"云南省": "昆明市",
"浙江省": "杭州市",
"重庆市": "重庆市",
}
# ─────────── 城市字典加载 ───────────
def _parse_city_dict(path: str | Path) -> list[dict[str, str]]:
"""解析 city_dict.txt,返回 [{city_id, city_name, province_name}, ...]。
city_dict.txt 格式(TSV:
城市ID\t城市名称\t省份名称
示例行:
3NUYJKKJXPHVNZUHFK3HWUDHNM\t宣城市\t安徽省
"""
data: list[dict[str, str]] = []
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
parts = line.split("\t")
if len(parts) < 3:
continue
city_id, city_name, province_name = parts[0], parts[1], parts[2]
if city_id == "城市ID":
continue # 跳过表头
if city_id and city_name and province_name:
data.append({
"city_id": city_id,
"city_name": city_name,
"province_name": province_name,
})
return data
# 模块加载时一次解析
try:
_CITY_DICT: list[dict[str, str]] = _parse_city_dict(_CITY_DICT_PATH)
except Exception:
logger.exception("加载 city_dict.txt 失败,美团城市反查将不可用")
_CITY_DICT = []
def _build_province_index() -> dict[str, list[dict[str, str]]]:
"""构建 省份名 → 该省全部城市列表 的索引。"""
idx: dict[str, list[dict[str, str]]] = {}
for entry in _CITY_DICT:
idx.setdefault(entry["province_name"], []).append(entry)
return idx
_PROVINCE_INDEX: dict[str, list[dict[str, str]]] | None = None
def _get_province_index() -> dict[str, list[dict[str, str]]]:
global _PROVINCE_INDEX
if _PROVINCE_INDEX is None:
_PROVINCE_INDEX = _build_province_index()
return _PROVINCE_INDEX
# ─────────── 查询 ───────────
def _map_admin1_to_cn_province(admin1: str) -> str:
"""将 reverse_geocoder 的 admin1 映射到 city_dict 中的中文省份名。"""
if not admin1:
return ""
normalized = admin1.strip()
# 多级匹配:先精确、再前缀
for en_prefix, cn_name in _PROVINCE_EN_PREFIX:
if normalized == en_prefix or normalized.startswith(en_prefix):
return cn_name
return ""
def _lookup_city_in_province(city_en_lower: str, province_cn: str) -> str:
"""在指定省份内查找匹配的城市名(EN→CN 映射)。"""
if not province_cn:
return ""
index = _get_province_index()
candidates = index.get(province_cn, [])
if not candidates:
return ""
# 1) 精确映射
if city_en_lower in _CITY_EN_TO_CN:
cn_city = _CITY_EN_TO_CN[city_en_lower]
for c in candidates:
if c["city_name"] == cn_city:
return cn_city
# 2) 前缀/包含匹配(处理 admin1 直辖市场景:行政区 → 直辖市本身)
for c in candidates:
# 去掉"市"后缀比较
city_core = c["city_name"].rstrip("")
if city_en_lower.startswith(city_core.lower()) or city_core.lower().startswith(city_en_lower):
return c["city_name"]
# city_en_lower 可能是拼音,city_core 是中文,尝试从 EN→CN 映射反向匹配
for en_k, cn_v in _CITY_EN_TO_CN.items():
if cn_v == c["city_name"] and (city_en_lower in en_k or en_k in city_en_lower):
return cn_v
# 3) 匹配不到 → 返回省会
capital = _PROVINCE_CAPITAL.get(province_cn, "")
if capital:
for c in candidates:
if c["city_name"] == capital:
return capital
return candidates[0]["city_name"] # 终极兜底
def _sanitize_city_name(name: str) -> str:
"""去除 reverse_geocoder name 中常见的行政后缀使匹配更鲁棒。"""
# 去掉 " District" / " Qu" / " Shi" 等英文后缀
for suffix in ("District", "Qu", "Shi", "Sheng", "Xian", "Cun", "Zhen", "Xiang",
"Zizhiqu", "Autonomous Region", "Special Administrative Region"):
name = re.sub(rf"\s+{suffix}$", "", name, flags=re.IGNORECASE)
return name.strip()
@lru_cache(maxsize=512)
def _resolve_meituan_city(latitude: float, longitude: float) -> dict[str, str]:
"""反查实现;入参已量化(见 get_meituan_city),故 lru_cache 命中率高。
返回的 dict 被缓存复用 —— 调用方勿原地修改(get_meituan_city 已返回副本)。
"""
if not _CITY_DICT:
return {"city_id": "", "city_name": "", "province_name": ""}
logger.debug("resolve_meituan_city: lat=%.2f lon=%.2f", latitude, longitude)
geo = _get_geo_city(latitude, longitude)
name_en = _sanitize_city_name(geo.get("name", ""))
admin1 = geo.get("admin1", "")
country = geo.get("country", "")
if country != "CN":
logger.debug("resolve_meituan_city: 坐标(%.2f,%.2f)不在中国境内(country=%s)", latitude, longitude, country)
return {"city_id": "", "city_name": "", "province_name": ""}
# 1) 映射省份
province_cn = _map_admin1_to_cn_province(admin1)
if not province_cn:
logger.warning("get_meituan_city: admin1=%r 无法映射到中文省份", admin1)
return {"city_id": "", "city_name": "", "province_name": ""}
# 2) 查找城市
name_lower = name_en.lower()
city_cn = _lookup_city_in_province(name_lower, province_cn)
# 3) 按省份+城市匹配 city_dict 中的城市 ID
index = _get_province_index()
candidates = index.get(province_cn, [])
for c in candidates:
if city_cn and c["city_name"] == city_cn:
return {
"city_id": c["city_id"],
"city_name": c["city_name"],
"province_name": province_cn,
}
# 4) 最终回退:返回该省省会
if candidates:
capital = _PROVINCE_CAPITAL.get(province_cn, "")
if capital:
for c in candidates:
if c["city_name"] == capital:
logger.info("get_meituan_city: 城市匹配失败 name_en=%r, 回退到省会 %s", name_en, capital)
return {
"city_id": c["city_id"],
"city_name": capital,
"province_name": province_cn,
}
# 终极兜底:第一个城市
fallback = candidates[0]
logger.info("get_meituan_city: 城市匹配失败 name_en=%r, 回退到 %s", name_en, fallback["city_name"])
return {
"city_id": fallback["city_id"],
"city_name": fallback["city_name"],
"province_name": province_cn,
}
return {"city_id": "", "city_name": "", "province_name": ""}
def get_meituan_city(latitude: float, longitude: float) -> dict[str, str]:
"""根据经纬度反查美团城市 ID + 城市名 + 省份名(对外入口)。
返回:
- city_id: 美团城市 ID(如 3NUYJKKJXPHVNZUHFK3HWUDHNM);
匹配失败时返回 ""
- city_name: 中文城市名(如 "北京市")
- province_name: 中文省份名(如 "北京市")
原理:
1. reverse_geocoder 根据经纬度查出英文地名 + 省份
2. 英文省份→中文省份映射(前缀匹配)
3. 英文地名→中文城市名映射(精确映射 + 省内候选回退)
4. 在 city_dict.txt 中按省份+城市名匹配城市 ID
城市名匹配失败的策略:
- 直辖市(京沪津渝): admin1 本身即城市名,直接取
- 省会: 回退到该省第一个城市(city_dict.txt 中每个省的省会通常排第一位)
实现说明:先把坐标量化到 ~1km(round 到 2 位小数)再进 lru_cache —— 原始 GPS 坐标
每次抖动到小数点后 5~6 位,直接做缓存 key 几乎不命中;城市级解析对 1km 误差不敏感,
量化后"同一地点反复请求"可命中缓存。返回缓存 dict 的副本,调用方可安全读写。
"""
return dict(_resolve_meituan_city(round(latitude, 2), round(longitude, 2)))
+40 -44
View File
@@ -3,7 +3,7 @@
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
> 最后更新:2026-07-03(补全缺失文档:ad/watch-report, wallet/transfer-auth 族, coupon/session+stats+completed-today+prompt 族, invite 族, user/onboarding, platform/flags+ad-config+app-version, intent/step+precoupon/step, analytics/events, order/report, report 族, feedback/config+records, trace/finalize。文档移至分类子目录,新增 mock 入参/出参示例
> 最后更新:2026-07-09(① 比价透传改「软鉴权 + trace_id 签发 + harvest 落库」(#112 尾声帧 `trace/epilogue` 一并补录);② 新端点:`user/onboarding/reset`(#114)、`GET /internal/launch-confirm-samples`(#91);③ 参数更新:提现族 `source` 分账(#82/#121)、`wallet/account` 邀请奖励金余额、美团 feed/top-sales 按城市过滤(#116)、admin 调现金 `account` 目标账户(#95);④ **Admin 索引补全到当前全量**:新家族 roles(#117/#126)/coupon-data(#99)/device-liveness(#80)/event-logs(#83)/price-reports(#94)/CPS 运营台/提现审核族, feedbacks 采纳拒绝(#94/#105)、marquee 模式与真实条浏览(#122/#123)等。上一次 2026-07-03
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
---
@@ -27,17 +27,18 @@
| 8e | `GET /api/v1/coupon/completed-today` | 无 | [详情](./coupon/coupon-completed-today.md)(这台设备今天是否已跑完整轮领券) |
| 8f | `POST /api/v1/coupon/completed-today/reset` | 无 | [详情](./coupon/coupon-completed-today.md)(重置今日已完成,开发用) |
| 8g | `GET /api/v1/coupon/stats` | Bearer | [详情](./coupon/coupon-stats.md)(累计领券数,「我的」页战绩卡) |
| 8h | `POST /api/v1/coupon/session` | 无 | [详情](./coupon/coupon/coupon-session.md)(领券流水上报,admin 看板数据源) |
| 8h | `POST /api/v1/coupon/session` | 无 | [详情](./coupon/coupon-session.md)(领券流水上报,admin 看板数据源) |
| 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan/meituan-coupons.md) |
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan/meituan-feed.md) |
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan/meituan-feed.md)`rec` tab 离线库 + **按城市过滤** #116 |
| 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan/meituan-referral-link.md) |
| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan/meituan-top-sales.md)(销量榜:离线库 `meituan_coupon` 按销量降序 + 跨源去重,不实时打美团) |
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend;下按 Phase 流程列,均不鉴权 |||
| 12 | `POST /api/v1/intent/recognize` | | [详情](./intent/compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源) |
| 12a | `POST /api/v1/intent/precoupon/step` | | [详情](./intent/intent-step.md)(Phase 0 意图识别前先用券,仅美团源) |
| 12b | `POST /api/v1/intent/step` | | [详情](./intent/intent-step.md)(Phase 1 多帧意图识别,仅淘宝源,循环到 done) |
| 13 | `POST /api/v1/price/step` | | [详情](./intent/compare-price-step.md)Phase 2 步进) |
| 13a | `POST /api/v1/trace/finalize` | | [详情](./other/trace-finalize.md)(比价 trace 收尾上云,终止/未识别拿 trace_url |
| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan/meituan-top-sales.md)同城销量榜:离线库按销量降序 + 跨源去重 + 城市过滤 #116,不实时打美团) |
| **比价透传**(前缀 `/api/v1`,透传 pricebot-backend;**软鉴权 OptionalUser** + 首帧签发 trace_id + harvest 落 `comparison_record`,2026-07 起不再是纯透传 |||
| 12 | `POST /api/v1/intent/recognize` | | [详情](./intent/compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源;mint 帧建 running 行 |
| 12a | `POST /api/v1/intent/precoupon/step` | | [详情](./intent/intent-step.md)(Phase 0 意图识别前先用券,仅美团源) |
| 12b | `POST /api/v1/intent/step` | | [详情](./intent/intent-step.md)(Phase 1 多帧意图识别,仅淘宝源,循环到 done) |
| 13 | `POST /api/v1/price/step` | | [详情](./intent/compare-price-step.md)Phase 2 步进;done 帧 harvest 写终态 |
| 13a | `POST /api/v1/trace/finalize` | | [详情](./other/trace-finalize.md)(比价 trace 收尾上云 + 夭折落库,终止/未识别拿 trace_url |
| 13b | `POST /api/v1/trace/epilogue` | 软 | [详情](./other/trace-finalize.md)(结果页尾声帧:App 结果页截图入 trace,纯透传不落库,#112 |
| **比价记录**(前缀 `/api/v1/compare`;按用户落库,**鉴权**,区别于上面不鉴权的透传) |||
| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare/compare-record-report.md) |
| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare/compare-records.md) |
@@ -54,7 +55,7 @@
| **上报更低价**(前缀 `/api/v1/report`;众包纠偏,人工审核发奖) |||
| R1 | `POST /api/v1/report` | Bearer | [详情](./other/report-submit.md)(提交上报更低价,multipart:比价记录ID+平台+价格+截图1-4张) |
| R2 | `GET /api/v1/report/records` | Bearer | [详情](./other/report-records.md)(上报记录列表,?status=pending/approved/rejected 可选筛选) |
| **好友邀请**(前缀 `/api/v1/invite`;注册即生效,双方各发 1 万金币 |||
| **好友邀请**(前缀 `/api/v1/invite`绑定注册即生效但**不发奖**,#113 起好友「比价并下单」才给邀请人发**邀请奖励金**,经 `POST /order/report` 触发 |||
| I1 | `GET /api/v1/invite/me` | Bearer | [详情](./invite/invite-me.md)(我的邀请码+分享链接+已邀人数/已得金币) |
| I2 | `GET /api/v1/invite/invitees` | Bearer | [详情](./invite/invite-invitees.md)(我邀请的人列表,limit/offset 分页) |
| I3 | `POST /api/v1/invite/landing-track` | 无 | [详情](./invite/invite-bind.md)(落地页 dl.html 访问上报指纹,剪贴板归因兜底;浏览器无 token) |
@@ -68,9 +69,9 @@
| 19 | `POST /api/v1/wallet/bind-wechat` | Bearer | [详情](./wallet/wallet-bind-wechat.md) |
| 20 | `POST /api/v1/wallet/unbind-wechat` | Bearer | [详情](./wallet/wallet-unbind-wechat.md) |
| 21 | `GET /api/v1/wallet/withdraw-info` | Bearer | [详情](./wallet/wallet-withdraw-info.md) |
| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet/wallet-withdraw.md) |
| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet/wallet-withdraw.md)`source` 分账:coin_cash / invite_cash,#121 |
| 23 | `GET /api/v1/wallet/withdraw/status` | Bearer | [详情](./wallet/wallet-withdraw-status.md) |
| 24 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet/wallet-withdraw-orders.md) |
| 24 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet/wallet-withdraw-orders.md)(可按 `source` 过滤) |
| 24a | `POST /api/v1/wallet/transfer-auth` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(开启免确认到账,申请授权,返回拉起微信授权页的 package) |
| 24b | `GET /api/v1/wallet/transfer-auth/status` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(查免确认授权状态,从微信授权页返回后轮询) |
| 24c | `POST /api/v1/wallet/transfer-auth/close` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(关闭免确认到账,解除授权) |
@@ -99,6 +100,7 @@
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user/user-avatar.md) |
| 36a | `POST /api/v1/user/onboarding/complete` | Bearer | [详情](./user/user-onboarding.md)(标记新手引导完成,按 账号+device_id 幂等,跨卸载重装持久) |
| 36b | `GET /api/v1/user/onboarding/status` | Bearer | [详情](./user/user-onboarding.md)(查该 账号+设备 是否走过引导,运营在 admin 删记录即触发重走) |
| 36c | `POST /api/v1/user/onboarding/reset` | Bearer | [详情](./user/user-onboarding.md)(重置本设备引导标记,下次登录重走,#114 |
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user/user-delete.md) |
| **帮助与反馈**(前缀 `/api/v1/feedback` |||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./other/feedback.md) |
@@ -127,43 +129,37 @@
| N4 | `POST /internal/store-mapping/invalidate` | 内部密钥 | [详情](./internal/internal.md)(标记某平台 shopId 缓存 deeplink 失效) |
| N5 | `POST /internal/launch-confirm-sample` | 内部密钥 | [详情](./internal/internal.md)(启动确认窗兜底样本落 `launch_confirm_sample` |
| N6 | `POST /internal/app-version` | 内部密钥 | [详情](./internal/internal.md)(发布流程写最新 App 版本,落 `app_config` |
| N7 | `GET /internal/launch-confirm-samples` | 内部密钥 | [详情](./internal/internal.md)(样本列表,供 pricebot distill 脚本聚合沉淀回静态规则,#91 |
| **静态资源**StaticFiles 挂载,见下方 `/media` 静态服务) |||
| - | `GET /media/avatars/<file>` | 无 | 用户头像;返回二进制图片 |
| - | `GET /media/feedback/<file>` | 无 | 反馈截图;返回二进制图片 |
| **运营后台 Admin**(独立子应用 `app/admin/`,前缀 `/admin/api`,独立进程 + 独立 admin JWT。鉴权列:`admin`=任意已登录管理员,`operator`/`finance`/`super_admin`=需对应角色`super_admin` 恒通过) |||
| A1 | `POST /admin/api/auth/login` | 无 | [详情](./admin/auth/admin-auth-login.md) |
| A2 | `GET /admin/api/auth/me` | admin | [详情](./admin/auth/admin-auth-me.md) |
| A3 | `GET /admin/api/stats/overview` | admin | [详情](./admin/admin-stats-overview.md) |
| A4 | `GET /admin/api/users` | admin | [详情](./admin/users/admin-users-list.md) |
| A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin/users/admin-user-detail.md) |
| A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin/users/admin-user-status.md) |
| A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin/users/admin-user-coins.md) |
| A8 | `POST /admin/api/users/{user_id}/cash` | finance | [详情](./admin/users/admin-user-cash.md) |
| A9 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin/wallet/admin-wallet-coin-transactions.md) |
| A10 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin/wallet/admin-wallet-cash-transactions.md) |
| A11 | `GET /admin/api/withdraws` | admin | [详情](./admin/withdraws/admin-withdraws-list.md) |
| A12 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin/withdraws/admin-withdraw-reconcile.md) |
| A13 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin/withdraws/admin-withdraw-refresh.md) |
| A14 | `GET /admin/api/feedbacks` | admin | [详情](./admin/feedbacks/admin-feedbacks-list.md) |
| A15 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin/feedbacks/admin-feedback-handle.md) |
| A16 | `GET /admin/api/admins` | super_admin | [详情](./admin/admins/admin-admins-list.md) |
| A17 | `POST /admin/api/admins` | super_admin | [详情](./admin/admins/admin-admin-create.md) |
| A18 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin/admins/admin-admin-update.md) |
| A19 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) |
| A20 | `GET /admin/api/dashboard-display` | admin | [详情](./admin/admin-dashboard-display.md) |
| A21 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin/admin-dashboard-display.md) |
| A22 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin/admin-marquee-seeds.md) |
| A23 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin/admin-marquee-seeds.md) |
| A24 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin/admin-marquee-seeds.md) |
| A25 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin/admin-marquee-seeds.md) |
| A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin/admin-marquee-seeds.md) |
| A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin/admin-marquee-seeds.md) |
| A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin/ad/admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
| A29 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:按用户/日期/类型/应用/代码位 聚合 条数/收益/金币,只读) |
| **运营后台 Admin**(独立子应用 `app/admin/`,前缀 `/admin/api`,独立进程 + 独立 admin JWT。鉴权列:`admin`=任意已登录管理员,`operator`/`finance`/`super_admin`=需对应角色;#117 起可见页由 [admin_role](../database/admin_role.md) 数据驱动,`super_admin` 恒通过) |||
| A1 | `POST /admin/api/auth/login` · `GET /auth/me` | 无 / admin | [详情](./admin/auth/admin-auth-login.md) / [me](./admin/auth/admin-auth-me.md)me 返回有效可见页 `pages` |
| A2 | `GET /admin/api/stats/overview` | admin | [详情](./admin/admin-stats-overview.md)(大盘核心指标;#103 按 trace 聚合 + 京东收益 #90 + feed_scene 口径 #125 |
| A3 | `GET /admin/api/event-logs` | admin | [详情](./admin/admin-event-logs.md)(埋点日志检索,#83 |
| **A·用户**:`GET /users`(筛选排序分页)、`GET /users/{id}`(360 详情)、`GET /{id}/reward-stats` + `GET /{id}/coin-records`(提现详情联查)、`POST /{id}/status`(封禁)、`POST /{id}/debug-trace`(调试链接权限)、`POST /{id}/coins``POST /{id}/cash`(#95 `account` 目标账户) ||| [列表](./admin/users/admin-users-list.md) / [详情](./admin/users/admin-user-detail.md) / [状态+debug-trace](./admin/users/admin-user-status.md) / [金币](./admin/users/admin-user-coins.md) / [现金](./admin/users/admin-user-cash.md) |
| A4 | `GET /admin/api/wallet/coin-transactions` / `cash-transactions` | admin | [金币](./admin/wallet/admin-wallet-coin-transactions.md) / [现金](./admin/wallet/admin-wallet-cash-transactions.md) |
| **A·提现审核台**:`GET /withdraws`(列表)、`/summary``/health-check`(finance)、`/ledger-check`(#121 分账对账)、`/{out_bill_no}`(详情)、`POST /reconcile`、单笔 `refresh`/`approve`/`reject`、批量 `bulk/refresh`/`bulk/approve`/`bulk/reject` ||| [列表](./admin/withdraws/admin-withdraws-list.md) / [审核族](./admin/withdraws/admin-withdraw-review.md) / [对账](./admin/withdraws/admin-withdraw-reconcile.md) / [查单](./admin/withdraws/admin-withdraw-refresh.md) |
| **A·反馈**:`GET /feedbacks``/summary``POST /{id}/approve`(采纳发币 #94)、`/{id}/reject``/{id}/handle` ||| [列表](./admin/feedbacks/admin-feedbacks-list.md) / [审核族](./admin/feedbacks/admin-feedback-handle.md) |
| A5 | `GET`/`PATCH` `/admin/api/feedback-config`,`POST`/`DELETE` `…/image` | operator | 反馈页「加群二维码」卡配置(admin 侧;C 端读见 38a)(无单独文档,见 `app/admin/routers/feedback_qr.py`) |
| **A·上报更低价**:`GET /price-reports``/summary``POST /{id}/approve|reject`(#94) ||| [审核族](./admin/admin-price-reports.md) |
| A6 | `GET /admin/api/comparison-records`(+`/{id}` 详情) | admin | 比价记录检索(按 user/phone/**店与商品名模糊搜** #117 筛;详情含 LLM 调用明细)(无单独文档,见 `app/admin/routers/comparison.py`) |
| A7 | `GET /admin/api/coupon-data`(+`/user-records`) | admin | [详情](./admin/admin-coupon-data.md)(领券数据看板,#99) |
| A8 | `GET /admin/api/device-liveness`(+`/stats`) | admin | [详情](./admin/admin-device-liveness.md)(设备存活监控,#80) |
| A9 | `GET /onboarding/devices``POST /devices/{id}/reset``POST /reset-all` | operator | 新手引导记录管理(按设备聚合/重置)(无单独文档,见 `app/admin/routers/onboarding.py`) |
| **A·轮播**:`GET /marquee-seeds``/preview``/real-records`(#123)、`GET`/`PATCH` `/mode`(#122,模式落 `app_config`)、`POST`(+`/bulk``/batch-delete``/batch-enable`)、`PATCH`/`DELETE` `/{seed_id}` ||| [详情](./admin/admin-marquee-seeds.md) |
| A10 | `GET / PATCH /admin/api/dashboard-display` | admin / operator | [详情](./admin/admin-dashboard-display.md)(首页三统计配置) |
| A11 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin/ad/admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
| A12 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:分页/场景/`app_env` 筛 + **DAU/ARPU** #120;真实收益侧接穿山甲日表 #92) |
| A13 | `GET / PATCH /admin/api/ad-config` | operator/finance | 广告配置(穿山甲 ID/验签密钥/各场景开关;C 端只读版见 40b)(无单独文档,见 `app/admin/routers/ad_config.py`) |
| A14 | `GET /admin/api/config``PATCH /config/{key}` | operator/finance | 运营可配置项([app_config](../database/app_config.md):奖励常量/提现地板价等;#117 修系统配置下发)(无单独文档,见 `app/admin/routers/config.py`) |
| **A·管理员与角色**(super_admin):`GET`/`POST` `/admins``PATCH`/`DELETE` `/admins/{id}`(#126 删除+`pages_override`)、`GET`/`POST` `/roles``GET /roles/catalog``PATCH`/`DELETE` `/roles/{id}`(#117/#126 自定义角色) ||| [列表](./admin/admins/admin-admins-list.md) / [](./admin/admins/admin-admin-create.md) / [改+删](./admin/admins/admin-admin-update.md) / [角色](./admin/admin-roles.md) |
| A15 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) |
| **A·CPS 运营台**:群/活动 CRUD、`POST /referral-links``POST /orders/reconcile`(美团+京东 #90)、`GET /orders``/stats`、群 `timeseries`/`daily`/`wx-users`/`day-users`(#79) ||| [详情](./admin/admin-cps.md) |
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
> `coupon/step` 及外卖比价的 `intent/recognize`、`intent/precoupon/step`、`intent/step`、`price/step`、`trace/finalize` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见 `app/api/v1/compare.py`)。
> `coupon/step` 透传到 pricebot-backend,**仍不鉴权**(device_id 区分设备,待补 JWT)。外卖比价透传族(`intent/*`、`price/step`、`trace/finalize|epilogue`)2026-07 起改**软鉴权 OptionalUser**:带 Bearer 则比价记录绑 `user_id`,不带也放行;并由 app-server 首帧签发 `trace_id` + harvest 落 `comparison_record`(见 `app/api/v1/compare.py` 模块注释)。
> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status`、`ad/feed-reward`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。
> 金额字段一律以**分**为单位(`*_cents`)。
+17
View File
@@ -0,0 +1,17 @@
# /admin/api/coupon-data — 领券数据看板(#99)
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin(任意已登录管理员) | 表 [coupon_session](../../database/coupon_session.md) | [← 返回 API 索引](../README.md)
数据源是客户端两段上报的 `coupon_session`(一次领券任务一行:发起建行/收尾更新)。看板量化:发起数、完成率、**中途流失**(started 无终态)、平均/分位耗时、各平台耗时、机型/ROM 维度。
## 端点
| 方法 + 路径 | 说明 |
|---|---|
| `GET /admin/api/coupon-data` | 看板聚合:发起/完成数 + 耗时分位 + 按天趋势 + 逐条明细(按 `started_date` 区间 + `app_env` 筛,默认只看 prod 防测试数据串台) |
| `GET /admin/api/coupon-data/user-records` | 某用户全部领券记录(用户列表点手机号抽屉:领券次数 + 记录列表) |
## 说明
- 明细行 LEFT JOIN `user` 出手机号/昵称(匿名领券行 user 列为空)。
- 「发起平台」列按 `origin_package` 区分:空=App 内首页发起,包名=从对应外卖 App 弹券引导发起。
- 耗时口径:`elapsed_ms` 客户端全程计时(只统计 completed)。
+28
View File
@@ -0,0 +1,28 @@
# /admin/api/cps — CPS 群发联盟运营台(群/活动/短链/对账)
> 所属:Admin 子应用(前缀 `/admin/api/cps`) | 鉴权:读=admin,写=operator/finance(对账) | 表 [cps_group](../../database/cps_group.md) / [cps_activity](../../database/cps_activity.md) / [cps_link](../../database/cps_link.md) / [cps_click](../../database/cps_click.md) / [cps_order](../../database/cps_order.md) / [cps_wx_user](../../database/cps_wx_user.md) | [← 返回 API 索引](../README.md)
>
> 业务与授权流程详见 [guides/CPS发券分发与微信授权](../../guides/CPS发券分发与微信授权.md);C 端落地短链见 [cps-redirect](../other/cps-redirect.md)。
私域社群 CPS 的完整运营链:建群(拿 `sid`)→ 建活动(券/物料)→ 生成群发短链 `/c/{code}` → 用户点击/复制口令 → 联盟订单按 `sid` 归群对账,汇成「点击→下单→佣金」漏斗。
## 端点
| 方法 + 路径 | 说明 |
|---|---|
| `GET / POST /admin/api/cps/groups`,`PATCH / DELETE /groups/{id}` | 推广群 CRUD;含美团平台的群自动分配 `sid` |
| `GET / POST /admin/api/cps/activities`,`PATCH / DELETE /activities/{id}` | 可推广活动 CRUD(美团 actId / 淘宝淘口令 / 京东链接) |
| `POST /admin/api/cps/upload-image``GET /activity-images` | 活动落地页图上传 / 已有图列表(新建复用) |
| `POST /admin/api/cps/referral-links` | 批量生成群×活动短链(美团经 sid 转链) |
| `POST /admin/api/cps/orders/reconcile` | 拉联盟订单对账(美团 `query_order` + 京东联盟 #90,`order_id` 幂等 upsert;finance) |
| `GET /admin/api/cps/orders` | 订单明细(游标分页,可按 sid / 状态筛) |
| `GET /admin/api/cps/stats` | 按群对账统计(点击/订单/GMV/预估与结算佣金) |
| `GET /admin/api/cps/groups/{id}/timeseries` | 群点击时序(天/小时级 PV/UV/复制,折线图) |
| `GET /admin/api/cps/groups/{id}/daily` | 群每天明细大表格(点击+订单按天合并;#79 起支持按天下钻) |
| `GET /admin/api/cps/groups/{id}/wx-users` | 群内微信用户(领券画像:头像/昵称/领券次数) |
| `GET /admin/api/cps/groups/{id}/day-users` | 某天该群按用户的领券/点击 + 每人点过的券(#79) |
## 说明
- 订单与点击**只能在群(sid)维度汇合**,无法对到单笔(联盟只回传 sid)。
- 京东单有效性按 `jd_valid_code`,美团按 `mt_status`(4 取消/5 风控不计佣,6 结算才到账);淘宝无对账 API,对账列显示 `-`
- #119 修美团 `pay_time` 入库为空导致大盘时间窗漏算。
+15
View File
@@ -0,0 +1,15 @@
# /admin/api/device-liveness — 设备存活监控(#80)
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin | 表 [device_liveness](../../database/device_liveness.md) | [← 返回 API 索引](../README.md)
无障碍保护存活的后台视角:哪些设备开过保护(`ever_protected`)、现在在线还是掉线(心跳超时,#107 起阈值 1 小时)、首次开启时间(`first_protected_at`)。
## 端点
| 方法 + 路径 | 说明 |
|---|---|
| `GET /admin/api/device-liveness/stats` | 顶部卡片统计:设备总数 / 开过保护 / 当前在线 / 掉线数 |
| `GET /admin/api/device-liveness` | 设备存活列表(游标分页):在线情况/设备 id/归属用户 筛选 + 排序,**默认掉线置顶**;行含最近心跳、首次开启、App 版本、push token 有无 |
## 说明
- 「在线」= `last_heartbeat_at` 距今 < 超时阈值;掉线召回链路(worker 置 `kill_alert_pending` → 客户端 pull)见表文档。
+15
View File
@@ -0,0 +1,15 @@
# /admin/api/event-logs — 埋点日志(#83)
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin | 表 [analytics_event](../../database/analytics_event.md) | [← 返回 API 索引](../README.md)
客户端埋点(`POST /api/v1/analytics/events` 批量上报)的后台检索页。
## 端点
| 方法 + 路径 | 说明 |
|---|---|
| `GET /admin/api/event-logs` | 埋点事件列表(游标分页):可按 `event` / `device_id` / `user_id` 筛;行含事件名、props、页面、机型/系统/网络、client_ts |
## 说明
- 纯只读;无聚合报表(要分析导出后自己算)。
- 时间轴用 `client_ts`(事件真实发生时刻),入库时间受客户端攒批影响。
+14 -3
View File
@@ -2,7 +2,7 @@
> 所属:Admin 组(前缀 `/admin/api/marquee-seeds` | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](../README.md)
管理首页轮播「真实+种子混播」的兜底种子。种子是「生成规则」:`masked_user` 可空(空→feed 随机合成名)、金额是 `[min_cents, max_cents]` 区间(feed 每次随机取值)。用户侧 feed 见 [platform-savings-feed](./platform-savings-feed.md);表见 [ops_marquee_seed](../database/ops_marquee_seed.md)。金额单位:分(前端 ÷100 显示元)。
管理首页轮播「真实+种子混播」的兜底种子。种子是「生成规则」:`masked_user` 可空(空→feed 随机合成名)、金额是 `[min_cents, max_cents]` 区间(feed 每次随机取值)。用户侧 feed 见 [platform-savings-feed](../savings/platform-savings-feed.md);表见 [ops_marquee_seed](../../database/ops_marquee_seed.md)。金额单位:分(前端 ÷100 显示元)。
## 复用结构 OpsMarqueeSeedOut
| 字段 | 类型 | 说明 |
@@ -19,9 +19,20 @@
出参 `200`:`list[OpsMarqueeSeedOut]`(按 `sort_order,id`)。
## GET /admin/api/marquee-seeds/preview — 预览实际混播 feed
预览客户端实际会看到的轮播(真实记录会插队、种子随机抽取 / 金额随机 / 名字合成),供运营对效果。**含随机,每次结果不同**。
预览客户端实际会看到的轮播(真实记录会插队、种子随机抽取 / 金额随机 / 名字合成),供运营对效果。**含随机,每次结果不同**;#122 起按**当前数据源模式**实时预览(mixed/real/seed 各自的真实产出)
- 入参:`limit`(query,1~30,默认 8)
- 出参 `200`:`{"items": [{masked_user, saved_amount_cents, time}]}`(条目同 [platform-savings-feed](./platform-savings-feed.md))
- 出参 `200`:`{"items": [{masked_user, saved_amount_cents, time}]}`(条目同 [platform-savings-feed](../savings/platform-savings-feed.md))
## GET /admin/api/marquee-seeds/real-records — 分页浏览当前模式下可展示的真实记录(#123)
审核用:看「真实条」到底会拿哪些 `comparison_record` 上轮播(真实条**不按用户去重**,打乱+去连簇后混播;默认昵称归「无昵称」脱敏档,#122)。
- 入参:`limit` / `cursor`(游标分页)
- 出参 `200`:`{"items": [...], "next_cursor": int|null}`
## GET /admin/api/marquee-seeds/mode — 首页轮播数据源模式
出参:`{"mode": "mixed" | "real" | "seed"}`(混播 / 只真实 / 只种子)。
## PATCH /admin/api/marquee-seeds/mode — 改数据源模式(带审计)
- 入参:`{"mode": "mixed" | "real" | "seed"}`;operator 起。改动客户端重拉 feed 生效。
## POST /admin/api/marquee-seeds — 新增(带审计)
入参 `OpsMarqueeSeedCreate`:`masked_user`(可选,空 / 不传 → 随机合成)、`min_cents`(必填,≥0)、`max_cents`(必填,≥min,≤1000 元)、`enabled`(默认 true)、`sort_order`(默认 0)。出参:新建的 `OpsMarqueeSeedOut``400`=金额非法。
+16
View File
@@ -0,0 +1,16 @@
# /admin/api/price-reports — 上报更低价审核(#94)
> 所属:Admin 子应用(前缀 `/admin/api/price-reports`) | 鉴权:读=admin,审=operator | 表 [price_report](../../database/price_report.md) | [← 返回 API 索引](../README.md)
>
> C 端提交/查询见 [report-submit](../other/report-submit.md) / [report-records](../other/report-records.md)。
用户众包「上报更低价」的人工审核台:审截图与价格,通过发固定金币。
## 端点
| 方法 + 路径 | 说明 |
|---|---|
| `GET /admin/api/price-reports` | 上报列表(状态筛选 + 游标分页,含截图、关联比价记录快照) |
| `GET /admin/api/price-reports/summary` | 审核统计(pending/approved/rejected 计数) |
| `POST /admin/api/price-reports/{report_id}/approve` | 通过 → 发固定金币(`grant_coins` 同事务)+ 带审计 |
| `POST /admin/api/price-reports/{report_id}/reject` | 拒绝(填原因,用户端可见)+ 带审计 |
+19
View File
@@ -0,0 +1,19 @@
# /admin/api/roles — 角色与可见页管理(RBAC,#117/#126)
> 所属:Admin 子应用(前缀 `/admin/api`,独立 admin JWT) | 鉴权:**super_admin** | 表 [admin_role](../../database/admin_role.md) | [← 返回 API 索引](../README.md)
后台 RBAC 的数据驱动层:内建三角色(`super_admin`/`finance`/`operator`,`is_builtin=true` 不可删)+ **自定义角色**(勾任意页面组合)。管理员的有效可见页 = `admin_user.pages_override`(个人覆盖,非空优先)∪ 否则取其角色 `pages`;`super_admin` 恒全通。
## 端点
| 方法 + 路径 | 说明 |
|---|---|
| `GET /admin/api/roles` | 角色列表:`[{id, name, label, pages, is_builtin, admin_count}]`(含每个角色的使用人数) |
| `GET /admin/api/roles/catalog` | 页面权限目录(分组):全部可勾选的页面 key(来自 `app/admin/permissions.py` 常量,不落库),前端渲染勾选面板用 |
| `POST /admin/api/roles` | 新增自定义角色 `{name, pages}`(**name(key)= label = 输入名称**;不能叫 `super_admin`,重名 `409`);带审计 |
| `PATCH /admin/api/roles/{role_id}` | 改展示名/可见页(内建角色的 `pages` 也可调);带审计 |
| `DELETE /admin/api/roles/{role_id}` | 删角色;**内建(`is_builtin`)与在用(有 `admin_user.role` 引用)不可删**(400);带审计 |
## 说明
- 管理员个人覆盖在 [`PATCH /admin/api/admins/{id}`](./admins/admin-admin-update.md) 的 `pages_override` 字段改,不在本组。
- 加新后台页面要同步登记 `permissions.py` 目录,表里只存勾选结果(目录变更无需迁移)。
+12 -5
View File
@@ -8,12 +8,13 @@
|---|---|---|---|
| `admin_id` | int | ✓ | 目标管理员 id |
**application/json**(字段都可选,只改传了的;至少传一个):
**application/json**(字段都可选,只改传了的;至少传一个):
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `role` | string | ✗ | 改角色,枚举:`super_admin` / `finance` / `operator` |
| `role` | string | ✗ | 改角色:内建 `super_admin` / `finance` / `operator` **或自定义角色 name**(#117/#126,见 [admin-roles](../admin-roles.md)) |
| `pages_override` | list[string] \| null | ✗ | 个人可见页覆盖(#126):非空优先于角色 pages;传 `null` 清覆盖回归角色 |
| `status` | string | ✗ | 启停,枚举:`active`(启用)/ `disabled`(禁用) |
| `password` | string | ✗ | 重置密码,872 字(传则覆盖原密码) |
| `password` | string | ✗ | 重置密码,8–72 字(传则覆盖原密码,同时更新 `plain_password` 明文副本) |
## 出参
响应 `200`:`AdminOut`(更新后的管理员)
@@ -24,9 +25,15 @@
| `username` | string | 账号 |
| `role` | string | 角色 |
| `status` | string | 状态 |
| `pages` | list[string] | **有效可见页**(pages_override 优先,否则角色 pages;super_admin 全量) |
| `created_at` | datetime | 创建时间(UTC) |
| `last_login_at` | datetime \| null | 上次登录时间 |
---
## DELETE /admin/api/admins/{admin_id} — 删除管理员(#126)
物理删除(区别于禁用);**不可删自己**(`400`);带审计(`action=admin.delete`)。鉴权同本组(super_admin)。出参 `{"deleted": true}`
## 错误码
- `400` 不能禁用自己(`admin_id == 当前 admin.id``status=disabled`) / 无任何变更字段(三字段全空)
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
@@ -35,5 +42,5 @@
- `422` `role`/`status` 非法枚举 / `password` 长度不在 872
## 说明
- 更新成功后写一条审计:`action=admin.update``target_type=admin``target_id=admin_id``detail` 为本次实际变更字段(如 `{"role": "...", "status": "...", "password": "reset"}`,密码只记 `reset` 不记明文)。见 [admin_audit_log](../database/admin_audit_log.md)。
- 数据表见 [admin_user](../database/admin_user.md)。
- 更新成功后写一条审计:`action=admin.update``target_type=admin``target_id=admin_id``detail` 为本次实际变更字段(如 `{"role": "...", "status": "...", "password": "reset"}`,密码只记 `reset` 不记明文)。见 [admin_audit_log](../../../database/admin_audit_log.md)。
- 数据表见 [admin_user](../../../database/admin_user.md)。
@@ -1,6 +1,24 @@
# POST /admin/api/feedbacks/{feedback_id}/handle — 标记反馈已处理
# /admin/api/feedbacks — 反馈审核族(采纳/拒绝/标记处理/统计)
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks` | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过,`require_role("operator")` | [← 返回 API 索引](../../README.md)
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks` | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过,`require_role("operator")`;summary 任意 admin | [← 返回 API 索引](../../README.md)
>
> 列表见 [admin-feedbacks-list](./admin-feedbacks-list.md)。#94 引入 采纳(发金币)/拒绝 审核语义,#105 加运营回复 `admin_reply`;旧「标记已处理」保留。
## POST /admin/api/feedbacks/{feedback_id}/approve — 采纳并发金币(#94)
- body:`{reward_coins?(int,可 0), admin_reply?(string,用户可见), review_note?(string,内部)}`
- 行为:`status → adopted`;`reward_coins > 0` 时走 `grant_coins` 同事务发币(流水 `coin_transaction`);写审计(`detail.after="adopted"`)。已终态(非 pending/new)→ `400`
- 出参:更新后的 FeedbackOut。
## POST /admin/api/feedbacks/{feedback_id}/reject — 拒绝采纳(#94)
- body:`{reject_reason(string,用户可见), admin_reply?, review_note?}`
- 行为:`status → rejected`;写审计(`detail.after="rejected"`)。已终态 → `400`
## GET /admin/api/feedbacks/summary — 审核统计
- 出参:各状态计数(`pending` / `adopted` / `rejected` / `handled`),审核台顶部卡片用;任意 admin 可读。
---
## POST /admin/api/feedbacks/{feedback_id}/handle — 标记反馈已处理(旧口径)
## 入参
- 路径:`feedback_id`(int)
@@ -18,6 +36,6 @@
- `422` `feedback_id` 非合法 int
## 说明
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action="feedback.handle"``target_type="feedback"``target_id=<feedback_id>``detail={"before": <原 status>, "after": "handled"}``ip=<客户端 IP>`
- 写操作记审计 [admin_audit_log](../../../database/admin_audit_log.md):`action="feedback.handle"``target_type="feedback"``target_id=<feedback_id>``detail={"before": <原 status>, "after": "handled"}``ip=<客户端 IP>`
- 状态变更与审计写入在同一事务(`commit=False` 后统一 `db.commit()`)。
- 关联表 [feedback](../database/feedback.md)。
- 关联表 [feedback](../../../database/feedback.md)。
+4 -3
View File
@@ -10,6 +10,7 @@
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `mode` | string | ✗ | `delta`(默认)=增减 / `set`=设为指定值 |
| `account` | string | ✗ | 目标账户(#95):`coin_cash`(默认,金币兑换的现金)/ `invite_cash`(邀请奖励金)。两本账物理隔离、各调各 |
| `amount_cents` | int | ✓ | `delta` 模式:现金变动(分,正=发放,负=扣减,不可为 0);`set` 模式:目标现金值(分,须 ≥ 0) |
| `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) |
@@ -28,7 +29,7 @@
- 金额单位一律为**分**(`*_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。
- 现金变动`account` 写对应账本流水:`coin_cash` → [cash_transaction](../../../database/cash_transaction.md),`invite_cash` → [invite_cash_transaction](../../../database/invite_cash_transaction.md)(#95);`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)。
- 关联用户表 [user](../../../database/user.md);现金账户 [coin_account](../../../database/coin_account.md);现金流水 [cash_transaction](../../../database/cash_transaction.md)。
+11 -2
View File
@@ -38,6 +38,15 @@
- `422` `user_id` 非整数
## 说明
- 金币三项(`coin_balance` / `cash_balance_cents` / `total_coin_earned`)读 [coin_account](../database/coin_account.md);从未发生金币动作(账户不存在)时统一返回 0。
- 金币三项(`coin_balance` / `cash_balance_cents` / `total_coin_earned`)读 [coin_account](../../../database/coin_account.md);从未发生金币动作(账户不存在)时统一返回 0。
- 各 count 为聚合数,明细历史走带 `user_id` 过滤的分页接口(金币流水 / 现金流水 / 提现 / 比价 / 反馈)。
- 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);提现单 [withdraw_order](../database/withdraw_order.md);比价记录 [comparison_record](../database/comparison_record.md);反馈 [feedback](../database/feedback.md)。
- 关联用户表 [user](../../../database/user.md);金币账户 [coin_account](../../../database/coin_account.md);提现单 [withdraw_order](../../../database/withdraw_order.md);比价记录 [comparison_record](../../../database/comparison_record.md);反馈 [feedback](../../../database/feedback.md)。
---
## 族内配套端点(提现详情页联查用)
| 方法 + 路径 | 说明 |
|---|---|
| `GET /admin/api/users/{user_id}/reward-stats` | 用户提现/看广告统计(按时间窗口):提现审核时评估该用户金币来源是否健康 |
| `GET /admin/api/users/{user_id}/coin-records` | 用户金币发放记录(按时间窗口分页):提现详情底部表,逐笔看发币来源 |
+8 -2
View File
@@ -21,5 +21,11 @@
## 说明
- 业务写(改用户状态)与审计写在同一事务原子提交:改了就有痕、有痕就真改了。
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.status.set`,`target_type = user`,`target_id = user_id`,`detail = {before, after}`,并记录操作 IP。
- 关联用户表 [user](../database/user.md)。
- 写操作记审计 [admin_audit_log](../../../database/admin_audit_log.md):`action = user.status.set`,`target_type = user`,`target_id = user_id`,`detail = {before, after}`,并记录操作 IP。
- 关联用户表 [user](../../../database/user.md)。
---
## POST /admin/api/users/{user_id}/debug-trace — 开关调试链接权限
- body:`{"enabled": true|false}` → 写 `user.debug_trace_enabled`(带审计 `action=user.debug_trace.set`)。
- 开了的用户在比价完成弹窗 + 比价记录页可见「复制调试链接」按钮(trace_url);运营按用户灰度排障用。鉴权同本组(operator)。
@@ -0,0 +1,25 @@
# /admin/api/withdraws — 提现审核台(审核/批量/对账族)
> 所属:Admin 子应用(前缀 `/admin/api/withdraws`) | 鉴权:读=admin,写=**finance** | 表 [withdraw_order](../../../database/withdraw_order.md) | [← 返回 API 索引](../../README.md)
>
> 列表见 [admin-withdraws-list](./admin-withdraws-list.md);单笔查单见 [admin-withdraw-refresh](./admin-withdraw-refresh.md);超时对账见 [admin-withdraw-reconcile](./admin-withdraw-reconcile.md)。本文覆盖其余审核台端点。
提现状态机:`reviewing`(发起即扣款待审)→ 通过 `pending`(微信转账在途)→ `success`/`failed`(失败退款);拒绝 `rejected`(退款)。**#121 起按 `withdraw_order.source` 分账**:退款/流水落 `cash_transaction`(coin_cash)或 `invite_cash_transaction`(invite_cash)。
## 端点
| 方法 + 路径 | 鉴权 | 说明 |
|---|---|---|
| `GET /admin/api/withdraws/summary` | admin | 审核台统计:各状态计数(待审/在途/成功/失败/拒绝) |
| `GET /admin/api/withdraws/health-check` | finance | 提现配置健康检查(证书/密钥路径/商户配置就位与否;暴露路径故限 finance+super) |
| `GET /admin/api/withdraws/ledger-check` | admin | **资金账本校验**(#121):按 `source` 分账核对「提现单 ↔ 流水」金额闭环,邀请奖励金提现纳入对账 |
| `GET /admin/api/withdraws/{out_bill_no}` | admin | 提现单详情(审核台抽屉;用户维度联查另走 `users/{id}/reward-stats` + `coin-records`) |
| `POST /admin/api/withdraws/{out_bill_no}/approve` | finance | 审核通过 → 发起微信打款(`reviewing``pending`→查单归一化);带审计 |
| `POST /admin/api/withdraws/{out_bill_no}/reject` | finance | 审核拒绝 → 按 source 退款 + `rejected`;带审计 |
| `POST /admin/api/withdraws/bulk/refresh` | finance | 批量刷新查单(勾选多笔) |
| `POST /admin/api/withdraws/bulk/approve` | finance | 批量审核通过并打款 |
| `POST /admin/api/withdraws/bulk/reject` | finance | 批量审核拒绝并退款 |
## 说明
- 批量接口逐单处理、逐单落审计,单笔失败不中断整批(返回逐单结果)。
- 结果不明时先查单再定夺、绝不盲目退款(防退款后又到账),同 C 端口径。
+5 -5
View File
@@ -1,13 +1,13 @@
# POST /api/v1/intent/recognize — 外卖比价 Phase 1 意图识别(透传到 pricebot
# POST /api/v1/intent/recognize — 外卖比价 Phase 1 意图识别(透传 + 首帧 harvest 建行
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](../README.md)
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**软鉴权 OptionalUser**(带 JWT 则绑 `user_id`,不带也放行) | [← 返回 API 索引](../README.md)
## 入参
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端从中读 `device_id``trace_id``step` 用于日志。
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端从中读 `device_id``trace_id``step``device_info` 用于日志与落库
客户端实际传源平台购物车页的无障碍树采集结果(pricebot 协议里的 `screens`:`cart_page_1` / `cart_page_2`)。
## 出参
pricebot-backend 的响应**原样返回**JSON object)。典型含 `result`(店名)、`calibration`(含 `source_platform_id` / `items` / `price`),客户端在 `step=0` 把它透传进 `/price/step`
pricebot-backend 的响应**原样返回**JSON object,并在顶层补 `trace_id`。典型含 `result`(店名)、`calibration`(含 `source_platform_id` / `items` / `price`),客户端在 `step=0` 把它透传进 `/price/step`
## 错误码
- `400` body 不是合法 JSON
@@ -18,7 +18,7 @@ pricebot-backend 的响应**原样返回**JSON object)。典型含 `result`
外卖比价由客户端无障碍引擎在源平台(淘宝闪购 / 美团 / 京东外卖)购物车页点悬浮球触发 → 调本接口拿 `query` + `calibration` → 进入 `/price/step` 循环。
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`:`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../guides/待办与技术债.md) P1
**trace_id 签发 + harvest 建行(2026-07 起,`compare.py`)**:客户端首帧可不带 `trace_id`——app-server 用 uuid 签发、注入转发 body、回填响应顶层;**仅签发那帧**按 `trace_id` 建 [comparison_record](../../database/comparison_record.md) 的 `running` 行(幂等,best-effort),done / finalize 帧再补终态。**软鉴权**:带 Bearer 则记录绑 `user_id`,匿名行 `user_id` 暂空、由后续 `/compare/record` 上报补
**相关配置**:
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`
+10 -6
View File
@@ -1,22 +1,26 @@
# POST /api/v1/price/step — 外卖比价 Phase 2 步进(透传到 pricebot
# POST /api/v1/price/step — 外卖比价 Phase 2 步进(透传 + done 帧 harvest 落库
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](../README.md)
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**软鉴权 OptionalUser**(带 JWT 则绑 `user_id`,不带也放行) | [← 返回 API 索引](../README.md)
## 入参
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端从中读 `device_id``trace_id``step` 用于日志。
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端从中读 `device_id``trace_id``step``device_info` 用于日志与落库
客户端逐帧上报 `screen_state` + 上一步 `action_result`;`step=0` 还带 `query` + `calibration`(来自 Phase 1)。
## 出参
pricebot-backend 的响应**原样返回**JSON object)。含 `action`tap / set_text / launch / wait / done…)、`continue``status`;最终 `done` 帧带 `comparison_results`(源 + 各目标平台到手价,按价升序)。
pricebot-backend 的响应**原样返回**JSON object,并在顶层补 `trace_id`(见下「trace_id 签发」)。含 `action`tap / set_text / launch / wait / done…)、`continue``status`、每帧顶层 `trace_url`;最终 `done` 帧带 `comparison_results`(源 + 各目标平台到手价,按价升序)。
## 错误码
- `400` body 不是合法 JSON
- `502` pricebot 上游不可达(网络错误)或返回 5xx
## 说明
把请求体原样转发到 `PRICEBOT_BASE_URL``/api/price/step`(去掉 `/v1`,async httpx)。**多轮循环**:客户端按返回的 `action` 操作手机、再上报下一帧,直到 `continue=false`。真正的目标驱动比价逻辑(多目标平台串行复现订单、读到手价、聚合排序)在 **pricebot-backend**,本接口只是"透传壳"
把请求体原样转发到 `PRICEBOT_BASE_URL``/api/price/step`(去掉 `/v1`,共享 httpx 单例)。**多轮循环**:客户端按返回的 `action` 操作手机、再上报下一帧,直到 `continue=false`。真正的目标驱动比价逻辑(多目标平台串行复现订单、读到手价、聚合排序)在 **pricebot-backend**
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`)。
**不再是纯透传壳(2026-07 起,`compare.py`)**:
- **trace_id 签发**:客户端首帧可不带 `trace_id`——app-server 用 uuid 签发、注入转发 body、回填进响应顶层 `trace_id`,客户端后续帧都带它(老客户端自带则原样用)。
- **harvest 落库**([comparison_record](../../database/comparison_record.md)):首帧(mint 时)建 `running` 行 → **done 帧** `harvest_done``success`/`failed` + 派生 `best_*`/`saved_amount_cents`。写库 best-effort(threadpool 独立 session),失败不连累透传。
- **软鉴权**:新客户端带 Bearer → 记录绑 `user_id`;老客户端/匿名 → `user_id` 暂空,由其后续 `POST /compare/record` 上报补(灰度期两条写路径按 `trace_id` reconcile,success 不降级)。
- 邀请发奖**不在这里**:#113 起口径为好友「比价并下单」,发放在 `POST /order/report`
**相关配置**:
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`;生产部署应与 pricebot-backend 同内网——比价一单 30~80 步、逐帧多一跳,走公网延迟会累积)
+5 -4
View File
@@ -14,12 +14,13 @@
| 方法 + 路径 | 落库 | 说明 |
|---|---|---|
| `POST /internal/price-observation` | [`price_observation`](../database/price_observation.md) | 比价 done 帧整批价格事实上报;`(trace_id, platform, scope)` 幂等,返回 `{inserted, skipped}` |
| `GET /internal/store-mapping/lookup` | (只读 [`store_mapping`](../database/store_mapping.md)) | 比价前按 `source_platform`+`name`(+`lat`/`lng`)反查各目标平台已沉淀的店铺 id/deeplink;命中→pricebot 直接 deeplink 省现场搜店 |
| `POST /internal/price-observation` | [`price_observation`](../../database/price_observation.md) | 比价 done 帧整批价格事实上报;`(trace_id, platform, scope)` 幂等,返回 `{inserted, skipped}` |
| `GET /internal/store-mapping/lookup` | (只读 [`store_mapping`](../../database/store_mapping.md)) | 比价前按 `source_platform`+`name`(+`lat`/`lng`)反查各目标平台已沉淀的店铺 id/deeplink;命中→pricebot 直接 deeplink 省现场搜店 |
| `POST /internal/store-mapping` | `store_mapping` | 跨平台「同一家店」身份映射上报;`trace_id` 幂等、填空合并,返回 `{inserted, row_id}` |
| `POST /internal/store-mapping/invalidate` | `store_mapping` | 标记某平台 `shop_id` 的缓存 deeplink 失效(pricebot 撞错误页回退时报);当前支持 `taobao`/`jd`,其它平台 no-op,返回 `{ok, affected}` |
| `POST /internal/launch-confirm-sample` | [`launch_confirm_sample`](../database/launch_confirm_sample.md) | 启动确认窗 LLM 兜底放行后回写样本(host 包 + 弹窗树 + plan + locale);**都上报、不去重**,返回 `{id}` |
| `POST /internal/app-version` | [`app_config`](../database/app_config.md)(key=`latest_app_version`) | **发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256;客户端再 `GET /api/v1/platform/app-version` 读做 OTA。也是应急改版本信息(紧急下线/改 `apk_url`)入口 |
| `POST /internal/launch-confirm-sample` | [`launch_confirm_sample`](../../database/launch_confirm_sample.md) | 启动确认窗 LLM 兜底放行后回写样本(host 包 + 弹窗树 + plan + locale);**都上报、不去重**,返回 `{id}` |
| `GET /internal/launch-confirm-samples` | (只读 `launch_confirm_sample`) | 样本列表(#91,供 pricebot `distill_launch_confirm.py` 聚合沉淀回静态规则);可选筛 `exec_success` / `host_package` / `since_days`,`limit` 默认 1000 |
| `POST /internal/app-version` | [`app_config`](../../database/app_config.md)(key=`latest_app_version`) | **发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256;客户端再 `GET /api/v1/platform/app-version` 读做 OTA。也是应急改版本信息(紧急下线/改 `apk_url`)入口 |
## 错误
- `401` 密钥不匹配 / 缺失。
+3 -3
View File
@@ -6,7 +6,7 @@
## POST /bind — 绑定邀请人
把当前登录用户(被邀请人)绑定到某邀请码。支持三种归因路径:clipboard(首启读剪贴板)、manual(手动输入邀请码)、fingerprint(指纹兜底反查)。绑定成功双方各发 1 万金币
把当前登录用户(被邀请人)绑定到某邀请码。支持三种归因路径:clipboard(首启读剪贴板)、manual(手动输入邀请码)、fingerprint(指纹兜底反查)。**#113 起绑定只建关系、不发奖**——发奖后置到被邀请人「比价并实际下单」(`POST /order/report` 触发,给邀请人发**邀请奖励金**`compare_reward_granted` 幂等闸一人一次)
### 入参
@@ -46,14 +46,14 @@ Mock 入参(指纹兜底):
| 字段 | 类型 | 说明 |
|---|---|---|
| `status` | string | `success` / `already_bound` / `invalid_code` / `self_invite` / `not_eligible` / `fp_not_found` |
| `coins_awarded` | int | 本次给当前用户(被邀请人)发的金币 |
| `coins_awarded` | int | 兼容保留字段(#113 前"绑定即发金币"口径)。**#113 起新绑定恒 0**,前端不应再据此展示发奖 |
| `message` | string | 给前端直接展示的文案 |
Mock 出参:
```json
{
"status": "success",
"coins_awarded": 10000,
"coins_awarded": 0,
"message": "邀请绑定成功"
}
```
+6 -3
View File
@@ -25,7 +25,8 @@ GET /api/v1/invite/invitees?limit=5&offset=0
| `items` | list[InviteeItem] | 被邀请人列表 |
| `items[].display_name` | string | 显示名(昵称 → 微信昵称 → 脱敏手机号,后端已兜底) |
| `items[].avatar_url` | string \| null | 头像 URLnull = 前端画默认色块 |
| `items[].coins` | int | 这次邀请给邀请人发的金币 |
| `items[].coins` | int | 这次邀请给邀请人发的金币**历史留痕**#113 前旧口径的发放额;新绑定恒 0 |
| `items[].is_compared` | bool | 该好友是否已完成过一次比价(#113:好友列表据此分「邀请成功 / 去提醒」,在途列表只取 `false` 的) |
| `items[].invited_at` | datetime | 邀请绑定时间(ISO 8601 UTC |
| `total` | int | 我邀请的总人数 |
| `has_more` | bool | 还有下一页吗 |
@@ -37,13 +38,15 @@ Mock 出参:
{
"display_name": "省钱小王",
"avatar_url": "/media/avatars/u2_f1e2d3c4b5a60708.jpg",
"coins": 10000,
"coins": 0,
"is_compared": true,
"invited_at": "2026-06-28T14:30:00Z"
},
{
"display_name": "138****1234",
"avatar_url": null,
"coins": 10000,
"coins": 0,
"is_compared": false,
"invited_at": "2026-07-01T09:15:00Z"
}
],
+5 -4
View File
@@ -2,20 +2,21 @@
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](../README.md)
>
> 数据来自离线库 [database/meituan_coupon](../database/meituan_coupon.md);**不实时打美团**(美团搜索对销量排序支持差、且有 402 限流)。
> 数据来自离线库 [database/meituan_coupon](../../database/meituan_coupon.md);**不实时打美团**(美团搜索对销量排序支持差、且有 402 限流)。
## 入参
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `page` | int | ❌ | 1 | ≥1 |
| `page_size` | int | ❌ | 20 | 150 |
| `platform` | int \| null | ❌ | null | 1 只外卖 / 2 只到店 / 不填=全部(全城销量) |
| `platform` | int \| null | ❌ | null | 1 只外卖 / 2 只到店 / 不填=全部 |
| `longitude` / `latitude` | float \| null | ❌(实际必带) | null | 设备坐标(#116):服务端离线反查城市(`utils/geo` + `meituan_city`)→ **只返回同城券**;老客户端不带坐标 → 返空 + `status=degraded`(不 422、不误返全城) |
## 出参
响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: null, status: "ok"|"empty"|"degraded" }``CouponCard` 见 [API 索引](./README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。
响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: null, status: "ok"|"empty"|"degraded" }``CouponCard` 见 [API 索引](../README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。
## 说明
- 从 `meituan_coupon``sale_volume_num` 非空的券,`DISTINCT ON(dedup_key)` 跨源去重(每个「品牌|名|价」只留销量最高一条,同销量再按佣金),按销量降序分页;每页只对当前 ~20 条做 `from_raw` 解析(翻页快,不全表拉取)。
- 从 `meituan_coupon``sale_volume_num` 非空 **且 `city_id` = 反查城市** 的券(#116,同城销量榜),`DISTINCT ON(dedup_key)` 跨源去重(每个「品牌|名|价」只留销量最高一条,同销量再按佣金),按销量降序分页;每页只对当前 ~20 条做 `from_raw` 解析(翻页快,不全表拉取)。
- **不依赖 MT 凭证**(纯库查询)。库为空(prod 刚部署 / ETL 未跑完)→ `status=empty`;库查询异常 → `status=degraded`。均返 `200`、不抛 5xx。
- **仅 PostgreSQL**(`DISTINCT ON` 为 PG 专用)。
+17 -3
View File
@@ -1,9 +1,13 @@
# POST /api/v1/trace/finalize — 比价 trace 收尾上云
# POST /api/v1/trace/finalize + /trace/epilogue — 比价 trace 收尾
> 所属:透传端点(前缀 `/api/v1`,外卖比价) | 鉴权:无(MVP 阶段不鉴权) | [← 返回 API 索引](../README.md)
> 所属:透传端点(前缀 `/api/v1`,外卖比价) | 鉴权:软鉴权 OptionalUser | [← 返回 API 索引](../README.md)
## POST /api/v1/trace/finalize — 收尾上云(+夭折落库)
透传到 pricebot-backend。用户终止 / Phase 1 未识别没走到 done 帧时,pricebot 没上云也没回传 `trace_url`。客户端收尾时打这个,pricebot 按 `trace_id` 一致性 hash 落到处理这条 trace 的同一进程(dir_cache 在那才能算对 trace 目录),打包上云返回 `{trace_url}`
**顺手夭折落库(2026-07 起)**:app-server 把该 trace 的 [comparison_record](../../database/comparison_record.md) `running` 行更新成夭折终态(`harvest_abort`,**不降级已 success**)。body 可带 `status`(`cancelled`/`failed`)+ `reason`;老客户端只带 `trace_id` → 默认 `cancelled`,其后续 `/compare/record` 上报再补精确态。
## 入参
透传 pricebot,客户端按 pricebot 协议组装。关键字段:
@@ -41,5 +45,15 @@ Mock 出参:
## 说明
- 一致性 hash 按 `trace_id` 路由到同一 pricebot 实例(确保 dir_cache 命中)
- MVP 阶段不鉴权
- 软鉴权(OptionalUser,同比价透传族)
- 与 `/intent/recognize``/price/step` 等同属外卖比价透传族
---
## POST /api/v1/trace/epilogue — 结果页尾声帧(#112)
App 收到 done、渲染完**结果页**后,把自己页面的截图(base64,body ~几百 KB)传给 pricebot 存进 trace 目录并触发重传——trace 里补上「用户实际看到的汇总页」(步骤帧只有目标 App 画面)。
- **纯透传壳**:不建行、不落库(该 trace 的比价记录已由 done / finalize 落终态)。
- 入参:`{device_id, trace_id, screenshot(base64), ...}`(pricebot 协议);出参:pricebot 原样响应。
- 错误码同 finalize(`400` / `502`)。
+19 -2
View File
@@ -61,7 +61,24 @@ Mock 入参:
---
## POST /api/v1/user/onboarding/reset — 重置新手引导(#114)
删除(当前账号, `device_id`)的完成标记 → 该设备下次登录/进 App 重走引导。给客户端「设置 → 重看新手引导」入口用(此前只能运营在 admin 删记录)。
### 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `device_id` | string | ✅ | 硬件级 ANDROID_ID(与 complete 一致) |
### 出参
```json
{"ok": true}
```
幂等:无标记时也返回 ok。
---
## 说明
- 替代原 `force_onboarding`(按用户)→ 改设备维度后,运营删记录即触发重走
- 幂等:重复标记不报错
- 替代原 `force_onboarding`(按用户)→ 改设备维度后,运营删记录(或用户自己 reset即触发重走
- 幂等:重复标记 / 重复重置都不报错
- `device_id` 为空时 `status` 一律返回未完成
+3 -2
View File
@@ -11,8 +11,9 @@
| 字段 | 类型 | 说明 |
|---|---|---|
| `coin_balance` | int | 当前金币余额 |
| `cash_balance_cents` | int | 当前现金余额(分) |
| `cash_balance_cents` | int | 当前现金余额(分,金币兑换账 |
| `invite_cash_balance_cents` | int | 邀请奖励金余额(分,与现金**物理隔离**的第二本账,#82;好友比价并下单发奖入账,提现走 `source=invite_cash` |
| `total_coin_earned` | int | 累计赚取金币 |
## 说明
账户不存在时自动创建(零余额)。福利页「我的资产」卡的数据源。
账户不存在时自动创建(零余额)。福利页「我的资产」卡的数据源;邀请页「奖励金」余额也读它
+3 -2
View File
@@ -8,9 +8,10 @@
|---|---|---|---|---|
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 |
| `source` | string | ❌ | null | 按账户来源过滤:`coin_cash` / `invite_cash`;不传=全部(#121) |
## 出参
响应 `200`:`{ items: WithdrawOrderOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](./README.md#游标分页约定)
响应 `200`:`{ items: WithdrawOrderOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](../README.md#游标分页约定)
**WithdrawOrderOut**
@@ -19,7 +20,7 @@
| `id` | int | 单 id(也是游标) |
| `out_bill_no` | string | 商户提现单号 |
| `amount_cents` | int | 提现额(分) |
| `status` | string | `pending` / `success` / `failed` |
| `status` | string | `reviewing`(待审核)/ `pending` / `success` / `failed` / `rejected` |
| `wechat_state` | string \| null | 微信侧原始状态 |
| `fail_reason` | string \| null | 失败原因 |
| `created_at` | datetime | 发起时间 |
+2 -1
View File
@@ -2,13 +2,14 @@
> 所属:Wallet 组(前缀 `/api/v1/wallet` | 鉴权:Bearer | 限流:同 IP ≤20 次/分 | [← 返回 API 索引](../README.md)
>
> 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)(微信 V3 商家转账、签名、实名加密)。
> 集成实现:见 [integrations/wxpay](../../integrations/wxpay.md)(微信 V3 商家转账、签名、实名加密)。
## 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `amount_cents` | int | ✅(>0) | 提现金额(分),须落在 `[min_cents, max_cents]` |
| `source` | string | ❌(默认 `coin_cash` | 提现账户来源(#121 分账):`coin_cash`(金币兑换的现金)/ `invite_cash`(邀请奖励金)。按它扣对应余额、流水落对应账本([cash_transaction](../../database/cash_transaction.md) / [invite_cash_transaction](../../database/invite_cash_transaction.md)) |
| `user_name` | string | ❌ | 实名(达额时微信商家转账要求,可空) |
| `out_bill_no` | string | ❌ | **客户端幂等键(商户单号)**:同号重试不重复转账;不传则服务端生成 |
+69 -28
View File
@@ -2,7 +2,7 @@
> 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。
> 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **40 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表;比价结果分两路——客户端带 JWT 上报「我的记录」落 `comparison_record`,pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)+ 启动确认窗兜底样本落 `launch_confirm_sample`。此外好友邀请(`invite_*` 2 张)与美团 CPS 群发联盟(`cps_*` 6 张,含落地页微信身份 `cps_wx_user`)是两个独立子系统。无障碍存活监控 `device_liveness`(#65)按用户设备维度记心跳、检掉线召回。
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **45 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表 + `coupon_session` 任务流水(#99);比价记录 2026-07 起**由 app-server 透传壳 harvest 直接落库**(`comparison_record`:帧0 建 running 行 → done/finalize 写终态,老客户端带 JWT 的 `POST /compare/record` 仅作兜底),pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)+ 启动确认窗兜底样本落 `launch_confirm_sample`。此外好友邀请(`invite_*` 2 张 + 独立账本 `invite_cash_transaction`)与 CPS 群发联盟(`cps_*` 6 张,含落地页微信身份 `cps_wx_user`;`cps_order` 已含京东联盟单)是两个独立子系统。无障碍存活监控 `device_liveness`(#65)按用户设备维度记心跳、检掉线召回;客户端埋点落 `analytics_event`(#83)
---
@@ -12,7 +12,7 @@
| App 位置 / 动作 | 表 | 说明 |
|---|---|---|
| 比价/领券**过程**(看屏→决策→操作) | (无) | 在 pricebot-backend 内存态跑,**过程不落库**;只有结果回到 app-server 才落库 |
| 「我的比价记录」列表 / 详情 | [`comparison_record`](./comparison_record.md) | 每次比价 done 后客户端带 JWT 上报一条完整明细 |
| 「我的比价记录」列表 / 详情 | [`comparison_record`](./comparison_record.md) | **app-server 透传壳 harvest 落库**(2026-07 起):帧0 建 `running` 行 → done 帧写 success/failed → `trace/finalize` 写 cancelled/failed;老客户端带 JWT 的 `POST /compare/record` 兜底 |
| 比价战绩里程碑(逐档领金币) | [`comparison_milestone_claim`](./comparison_milestone_claim.md) | 累计成功比价 N 次解锁;进度读 `comparison_record` 计数 |
| profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 |
| 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 |
@@ -27,6 +27,7 @@
| 切外卖 App 时是否弹领券引导窗 | [`coupon_prompt_engagement`](./coupon_state.md) | 今天 engage 过(点领/点拒)就不再弹;判断维度 device_id |
| 首页「去领取」卡是否置灰 | [`coupon_daily_completion`](./coupon_state.md) | 今天跑完整轮(到 done)就置灰;判断维度 device_id |
| 每张券领取结果留痕 | [`coupon_claim_record`](./coupon_state.md) | 资产/画像/排查/CPS;当前**不参与**判断 |
| (admin 领券看板)一次领券任务全程流水 | [`coupon_session`](./coupon_session.md) | 客户端 `POST /coupon/session` 两段上报(发起建行/收尾更新);发起数、完成率、中途流失、平均耗时都从这算(#99) |
### 钱包 / 福利(看广告赚钱闭环)
| App 位置 / 动作 | 表 | 说明 |
@@ -37,7 +38,9 @@
| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 7 天循环发币;签到后看广告可膨胀一次 |
| 一次性任务(开消息提醒等) | [`user_task`](./user_task.md) | 领一次发币 |
| 看激励视频赚金币 | [`ad_reward_record`](./ad_reward_record.md) + [`ad_watch_log`](./ad_watch_log.md) + [`ad_ecpm_record`](./ad_ecpm_record.md) | 独立数据流:发奖 / 旧版观看时长 / 收益对账 |
| 信息流广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账 |
| 信息流/Draw 广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账;`ad_type`(feed/draw)+`feed_scene`(compare/coupon)分形态/场景 |
| (无 App UI)穿山甲后台收益对账 | [`ad_pangle_daily_revenue`](./ad_pangle_daily_revenue.md) | 定时脚本拉 GroMore 数据 API 落日表(#92);admin 收益报表/大盘的「真实收益」侧 |
| 好友比价并下单发奖 / 邀请奖励金 | [`invite_cash_transaction`](./invite_cash_transaction.md) + `coin_account.invite_cash_balance_cents` | 与现金**物理隔离**的第二本账(#82/#113):`POST /order/report` 触发 `invite_reward` 入账;`source=invite_cash` 提现出账 |
| 金币兑现金 | `coin_account` + `coin_transaction` + `cash_transaction` | exchange_out + exchange_in 两笔流水 |
| 提现到微信零钱 | [`withdraw_order`](./withdraw_order.md) + [`wechat_transfer_authorization`](./wechat_transfer_authorization.md) + `cash_transaction` | 人工审核 + 微信商家转账 |
| 绑定微信(提现前置) | `user`.wechat_* | openid 唯一,一微信一账号 |
@@ -53,8 +56,9 @@
### 好友邀请(注册增长)
| App 位置 / 动作 | 表 | 说明 |
|---|---|---|
| 输入/剪贴板邀请码绑定 | [`invite_relation`](./invite_relation.md) | 注册即生效,邀请人+被邀请人各发 1 万金币;`invitee_user_id` 唯一=幂等防重复发奖 |
| 输入/剪贴板邀请码绑定 | [`invite_relation`](./invite_relation.md) | 注册即生效(**绑定不发奖**,#113);`invitee_user_id` 唯一;`compare_reward_granted` 幂等闸=好友**比价并下单**后才给邀请人发奖励金 |
| 落地页访问指纹(剪贴板归因兜底) | [`invite_fingerprint`](./invite_fingerprint.md) | 剪贴板没拿到码时,用 (ip+机型+屏幕) 7 天内反查邀请人 |
| 邀请奖励金入账/提现 | [`invite_cash_transaction`](./invite_cash_transaction.md) | 独立账本(见上钱包节);余额在 `coin_account.invite_cash_balance_cents` |
### 美团 CPS 群发联盟(私域社群比价,运营后台驱动 · 群发选品→点击→对账漏斗)
| 后台/用户动作 | 表 | 说明 |
@@ -66,10 +70,19 @@
| 微信内打开落地页授权 | [`cps_wx_user`](./cps_wx_user.md) | 服务号网页授权拿 openid(base 静默)/ 昵称头像 unionid(userinfo,点领券触发),记首次来源群 |
| 定时拉美团联盟订单对账 | [`cps_order`](./cps_order.md) | `query_order` 按 sid 归群,串成点击→下单→佣金漏斗 |
### 埋点 / 首页门面 / 选品缓存
| 位置 / 动作 | 表 | 说明 |
|---|---|---|
| 客户端行为埋点 | [`analytics_event`](./analytics_event.md) | `POST /analytics/events` 批量上报,一事件一行(#83);admin「埋点日志」检索 |
| 首页三统计数字 | [`ops_stat_config`](./ops_stat_config.md) | real/manual/random 三模式,admin 配 |
| 首页轮播(省钱动态) | [`ops_marquee_seed`](./ops_marquee_seed.md) | 种子条目,与真实 `comparison_record` 混播(数据源模式 mixed/real/seed 可切) |
| 首页推荐/销量榜离线选品 | [`meituan_coupon`](./meituan_coupon.md) | 美团 CPS 券缓存,定时 ETL 灌入;`feed?tab=rec``top-sales` 纯库出、不实时打美团 |
### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权)
| 后台模块 | 表 | 说明 |
|---|---|---|
| 管理员账号 / 登录 | [`admin_user`](./admin_user.md) | 与 C 端 `user` 完全隔离,独立 JWT + RBAC |
| 管理员账号 / 登录 | [`admin_user`](./admin_user.md) | 与 C 端 `user` 完全隔离,独立 JWT + RBAC;`pages_override` 个人可见页覆盖 |
| 角色 / 可见页配置 | [`admin_role`](./admin_role.md) | 内建三角色 + 自定义角色(#117/#126);`admin_user.role` 按名引用 |
| 操作审计 | [`admin_audit_log`](./admin_audit_log.md) | 每个写操作落一条,只增不改不删 |
| 运营可配置项(改奖励常量) | [`app_config`](./app_config.md) | 空表 = 用代码默认;后台改了即覆盖 |
| 用户/钱包/提现/反馈管理 | 跨读写上面的 C 端表 | 见下「写入路径」admin 段 |
@@ -92,8 +105,8 @@
| 签到膨胀 `POST /signin/boost` | `signin_boost_record`(C) + `coin_account`(U) + `coin_transaction`(C `signin_boost`) | 同事务;同日一次 |
| 领任务 `POST /tasks/claim` | `user_task`(C) + `coin_account`(U) + `coin_transaction`(C `task_<key>`) | 同事务 |
| 金币兑现金 `POST /wallet/exchange` | `coin_account`(U) + `coin_transaction`(C `exchange_out` ) + `cash_transaction`(C `exchange_in` +) | 同事务 |
| 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`) + `coin_account`(U 扣现金) + `cash_transaction`(C `withdraw` ) | 同事务,**不打款** |
| 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→`cash_transaction`(C `withdraw_refund` +) | |
| 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`,记 `source`) + `coin_account`(U 按 source 扣对应余额) + 流水(C :`cash_transaction.withdraw``invite_cash_transaction.invite_withdraw`) | 同事务,**不打款**;#121`source` 分账 |
| 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→对应账本退款流水(C `withdraw_refund` / `invite_withdraw_refund` +) | |
| 穿山甲发奖 S2S 回调 `POST /ad/pangle-callback` | `ad_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `reward_video`/`signin_boost`) | `trans_id` 幂等 |
| 看广告时长上报 `POST /ad/watch-report` | `ad_watch_log`(C) | |
| 广告 eCPM 上报 `POST /ad/ecpm-report` | `ad_ecpm_record`(C) | |
@@ -101,13 +114,17 @@
| 注册设备 / 更新 push token `POST /device/register` | `device_liveness`(C/U upsert) | `(user_id, device_id)` 幂等;只在传入非空时更新 `registration_id`/版本 |
| 无障碍服务心跳 `POST /device/heartbeat` | `device_liveness`(C/U) | 心跳也能自注册;`accessibility_enabled=true` 时刷 `last_heartbeat_at`、置 `alive`、清 `notified_at` |
| 客户端 ack 掉线提醒 `POST /device/liveness/ack` | `device_liveness`(U) | 清 `kill_alert_pending`(幂等;下次真掉线 worker 再置) |
| 比价 done 上报 `POST /compare/record` | `comparison_record`(C 或 U) | `(user_id, trace_id)` 幂等覆盖 |
| 比价透传(帧0 / done / finalize)`POST /intent/recognize``/price/step``/trace/finalize` | `comparison_record`(C `running` → U 终态) | **harvest 三段式**(2026-07):帧0 mint trace_id 建 running 行 → done 写 success/failed → finalize 写 cancelled/failed(不降级 success);best-effort,写失败不连累透传 |
| 比价 done 上报 `POST /compare/record`(老客户端兜底) | `comparison_record`(C 或 U) | `trace_id` 幂等覆盖(唯一键已从 `(user_id,trace_id)` 改单列) |
| 领里程碑 `POST /compare/milestone/claim` | `comparison_milestone_claim`(C) | **当前不发币**(coin_awarded=0) |
| 支付归因上报 `POST /order/report` | `savings_record`(C `source=compare`) | `(user_id, client_event_id)` 幂等 |
| 支付归因上报 `POST /order/report` | `savings_record`(C `source=compare`)+ 触发邀请发奖:`invite_relation`(U `compare_reward_granted`)+`coin_account`(U invite_cash)+`invite_cash_transaction`(C `invite_reward`) | `(user_id, client_event_id)` 幂等;#113 邀请发奖口径=好友**比价并下单**,`compare_reward_granted` 幂等闸,同事务 |
| 批量埋点 `POST /analytics/events` | `analytics_event`(C 批量) | 不强制登录;每批 ≤200 条 |
| 领券任务流水 `POST /coupon/session` | `coupon_session`(C/U upsert) | `trace_id` 幂等:发起建行、收尾更新同一行(#99) |
| 重置新手引导 `POST /user/onboarding/reset` | `onboarding_completion`(**D** 该 设备+账号 行) | #114,删完成标记 → 下次登录重走引导 |
| 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 |
| 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 |
| 提交反馈 `POST /feedback` | `feedback`(C) | |
| 绑定邀请 `POST /invite/bind` | `invite_relation`(C `effective`) + `coin_account`(U×2) + `coin_transaction`(C `invite_inviter` + `invite_invitee`) | 同事务;`invitee_user_id` 唯一幂等,双方各发 1 万金币 |
| 绑定邀请 `POST /invite/bind` | `invite_relation`(C `effective`) | `invitee_user_id` 唯一幂等。**#113 起绑定不发奖**——发奖延后到好友比价并下单(`POST /order/report` 行),发的是邀请奖励金非金币 |
| 落地页归因 `POST /invite/landing-track` | `invite_fingerprint`(C) | 剪贴板归因兜底线索,登录后用 (ip+机型+屏幕) 反查 |
| 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, package, 北京日)` 幂等;best-effort |
| 领券每帧结果 `POST /api/v1/coupon/step` | `coupon_claim_record`(C/U) | `(device_id, coupon_id, 北京日)` 幂等;best-effort |
@@ -121,11 +138,17 @@
| 后台操作 | 写入 | 操作 |
|---|---|---|
| 手动增减金币 | `coin_account`(U)+`coin_transaction`(C `admin_grant`/`admin_deduct`)+`admin_audit_log`(C) | 同事务 |
| 改用户状态(禁用/启用) | `user`(U)+`admin_audit_log`(C) | 同事务 |
| 审核通过提现 | `withdraw_order`(U→pending/success/failed)+`wechat_transfer_authorization`(C/U)+失败时`cash_transaction`(refund)+`admin_audit_log`(C) | |
| 审核拒绝提现 | `withdraw_order`(U→rejected)+`cash_transaction`(C `withdraw_refund`)+`admin_audit_log`(C) | |
| 处理反馈 | `feedback`(U)+`admin_audit_log`(C) | 同事务 |
| 改运营配置 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 |
| 手动增减现金(#95 支持目标账户) | `coin_account`(U 按 `account` 选列)+ 流水(C:`cash_transaction``invite_cash_transaction`,`admin_grant`/`admin_deduct`)+`admin_audit_log`(C) | 同事务;`account=coin_cash`/`invite_cash` 两本账各调各 |
| 改用户状态(禁用/启用)/ 调试链接权限 | `user`(U `status` / `debug_trace_enabled`)+`admin_audit_log`(C) | 同事务 |
| 审核通过提现(含批量) | `withdraw_order`(U→pending/success/failed)+`wechat_transfer_authorization`(C/U)+失败时按 source 退款流水+`admin_audit_log`(C) | |
| 审核拒绝提现(含批量) | `withdraw_order`(U→rejected)+按 source 退款流水(C `withdraw_refund`/`invite_withdraw_refund`)+`admin_audit_log`(C) | |
| 反馈采纳/拒绝/标记处理 | `feedback`(U→adopted/rejected/handled + `admin_reply`/`reject_reason`)+采纳发币时 `coin_account`(U)+`coin_transaction`(C)+`admin_audit_log`(C) | 同事务(#94/#105) |
| 上报更低价审核 | `price_report`(U→approved/rejected)+通过发币 `coin_account`(U)+`coin_transaction`(C)+`admin_audit_log`(C) | 同事务 |
| 改运营配置 / 广告配置 / 反馈页二维码 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 |
| 轮播种子/数据源模式 | `ops_marquee_seed`(C/U/D)+`app_config`(模式 mixed/real/seed)+`admin_audit_log`(C) | #122/#123 预览/浏览只读 |
| 角色管理(#117/#126) | `admin_role`(C/U/D)+`admin_audit_log`(C) | 内建/在用角色不可删 |
| 管理员管理 | `admin_user`(C/U/**D** #126)+`admin_audit_log`(C) | 删除为物理删,不可删自己 |
| CPS 建群/活动/生成短链/拉单对账 | `cps_group`/`cps_activity`(C/U/D)、`cps_link`(C)、`cps_order`(C/U upsert;美团 `query_order` + 京东联盟 #90) | 见 CPS 段 |
| 任意写操作 | `admin_audit_log`(C,**永不 U/D**) | |
> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U(领券 `/prompt/reset``/completed-today/reset` 是开发用删除,非业务流程)。
@@ -137,7 +160,9 @@
| 后台批量生成短链 `POST /admin/api/cps/referral-links` | `cps_link`(C) | 每 群×活动 一条;美团经 `sid` 转链拿 `target_url`(同群同活动重复生成产生多条) |
| 用户点群发短链 `GET /c/{code}` / `POST /c/{code}/copy` | `cps_click`(C `visit`/`copy`) | 公开端点不鉴权;`group_id`/`sid` 从 link 冗余进来免 join |
| 微信落地页授权回调 `GET /wx/oauth/cb` | `cps_wx_user`(C/U upsert) | 按 `openid` 幂等;base 拿 openid,userinfo 补昵称/unionid(非 None 才覆盖);任何失败兜底回落地页不阻断领券 |
| 定时拉美团联盟订单对账 | `cps_order`(C/U upsert) | `query_order``sid` 归群;`order_id` 幂等(状态会变,重复拉则更新) |
| 定时拉联盟订单对账(美团 `query_order` + 京东联盟 #90) | `cps_order`(C/U upsert) | 按 `sid` 归群;`order_id` 幂等(状态会变,重复拉则更新);京东单 `platform='jd'` + `jd_valid_code` 判有效 |
| 定时拉穿山甲 GroMore 后台收益(#92,systemd 每天 10:30) | `ad_pangle_daily_revenue`(C/U upsert) | 收益报表/大盘「真实收益」侧;与客户端上报的 eCPM 侧互为对照 |
| 定时 ETL 灌美团 CPS 选品库 | `meituan_coupon`(C/U) | `feed?tab=rec` / `top-sales` 纯库出的数据源 |
| pricebot 比价 done 内部上报 `POST /internal/price-observation` | `price_observation`(C 批量) | `(trace_id,platform,scope)` 幂等;**不走 JWT、靠 `X-Internal-Secret`**(未配→503) |
| pricebot 比价 done 内部上报 `POST /internal/store-mapping` | `store_mapping`(C/U 填空合并) | `trace_id` 幂等;另有 `lookup` 反查 + `invalidate` deeplink 失效标记(淘宝/京东) |
| pricebot LLM 兜底放行启动确认窗后上报 `POST /internal/launch-confirm-sample` | `launch_confirm_sample`(C) | **都上报、不去重**;靠 `X-Internal-Secret`(未配→503) |
@@ -150,7 +175,7 @@
## 三、表间关系 & Join Key
### 硬外键(数据库 FK 约束)
- **18 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction``cash_transaction``withdraw_order``wechat_transfer_authorization`(同时是 PK)、`signin_record``signin_boost_record``user_task``comparison_record``comparison_milestone_claim``savings_record``ad_reward_record``ad_watch_log``ad_ecpm_record``ad_feed_reward_record``price_report``feedback``device_liveness`
- **19 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction``cash_transaction``invite_cash_transaction``withdraw_order``wechat_transfer_authorization`(同时是 PK)、`signin_record``signin_boost_record``user_task``comparison_record`(2026-07 起 `user_id` **可空**——harvest 帧0 建行时软鉴权可能拿不到)`comparison_milestone_claim``savings_record``ad_reward_record``ad_watch_log``ad_ecpm_record``ad_feed_reward_record``price_report``feedback``device_liveness`
- `admin_audit_log.admin_id``admin_user.id`
- `price_report.comparison_record_id``comparison_record.id`(可空:关联记录被删后仍留上报历史)。
- **邀请两表**`user.id`:`invite_relation.inviter_user_id``invite_relation.invitee_user_id`(唯一)、`invite_fingerprint.inviter_user_id`——注意 FK 列名是 `inviter`/`invitee_user_id`,不是 `user_id`
@@ -173,13 +198,24 @@
| biz_type | ref_id 指向 | amount 符号 |
|---|---|---|
| `withdraw` / `withdraw_refund` | `withdraw_order.out_bill_no` | / + |
| `withdraw` / `withdraw_refund` | `withdraw_order.out_bill_no`(`source=coin_cash` 的单) | / + |
| `exchange_in` | null | + |
| `admin_grant` / `admin_deduct` | null(原因在 `remark`=`admin:<reason>`) | + / |
- **`invite_cash_transaction.ref_id`**(邀请奖励金账本,#82):
| biz_type | ref_id 指向 | amount 符号 |
|---|---|---|
| `invite_reward` | 被邀请人 `user.id`(字符串) | + |
| `invite_withdraw` / `invite_withdraw_refund` | `withdraw_order.out_bill_no`(`source=invite_cash` 的单) | / + |
| `admin_grant` / `admin_deduct` | null | + / |
- **`comparison_record.store_name``savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。
- **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖。
- **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record``status='success'``count`
- **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record``user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。
- **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record``user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。[`coupon_session`](./coupon_session.md)(#99)同口径:`trace_id` 唯一 upsert、`user_id` 软指(admin 明细 LEFT JOIN 出手机号)。
- **`analytics_event`**(#83)与 **`coupon_session`** 均无硬 FK:埋点/流水不鉴权也收,`device_id`/`user_id` 只作维度。
- **`admin_user.role` 按名语义引用 `admin_role.name`**(无硬 FK,#117):删除保护在应用层(在用/内建角色不可删);个人 `pages_override` 优先于角色 `pages`
- **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。
- **CPS 群发 6 表全靠 `sid` / id / `openid` 语义串联(无硬 FK)**:`cps_link.group_id``cps_group.id``cps_link.activity_id``cps_activity.id``cps_click.link_id``cps_link.id``cps_wx_user.first_group_id``cps_group.id`;**点击与订单无法对到单笔**,只在群维度(`sid`)汇合——`cps_order.sid``cps_group.sid``cps_link.sid``cps_click.sid`(仅美团有 sid,淘宝/京东无)。统计按群聚合,故 `group_id`/`sid` 冗余进 `cps_click` 免 join。`cps_wx_user``openid` 自成用户身份维度,与 `cps_click`/`cps_order` 无 id 级 join。
- **比价沉淀两表(`price_observation` / `store_mapping`)**:`trace_id` 软指 pricebot work_logs(与 `comparison_record.trace_id` 同源但不互 join,各存各视角);`source_user_id` / `source_device_id` 软指用户/设备(可空,匿名也记)。`store_mapping.lookup` 靠**店名字符串精确相等** + geo 取最近,非 id 级 join。
@@ -188,10 +224,10 @@
```
user ─1:1─ coin_account
user ─1:1─ wechat_transfer_authorization
user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_record,
signin_boost_record, user_task, comparison_record, comparison_milestone_claim,
savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record,
price_report, feedback, device_liveness }
user ─1:N─ { coin_transaction, cash_transaction, invite_cash_transaction, withdraw_order,
signin_record, signin_boost_record, user_task, comparison_record(user_id 可空),
comparison_milestone_claim, savings_record, ad_reward_record, ad_watch_log,
ad_ecpm_record, ad_feed_reward_record, price_report, feedback, device_liveness }
(device_liveness 硬 FK; (user_id,device_id) 唯一)
user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,device_id) 去重)
comparison_record ─1:N─ price_report (comparison_record_id, 可空)
@@ -199,6 +235,11 @@ admin_user ─1:N─ admin_audit_log
app_config (独立, 无外键, key 为主键)
coupon_prompt_engagement / coupon_daily_completion / coupon_claim_record
(独立, 无硬 FK; 维度=device_id+北京日, user_id/trace_id 仅软关联)
coupon_session (独立, 无硬 FK; trace_id 唯一=一次领券任务一行, #99)
analytics_event (独立, 无硬 FK; 埋点事件流, device/user 仅维度, #83)
admin_role ◀──语义(role 按 name 引用, 无FK)── admin_user (pages_override 个人覆盖, #117/#126)
ops_stat_config / ops_marquee_seed / meituan_coupon / ad_pangle_daily_revenue
(独立运营/缓存/对账表, 无外键)
user ─1:N─ invite_relation (inviter_user_id 硬 FK); invitee_user_id ─1:1─ user (唯一硬 FK)
user ─1:N─ invite_fingerprint (inviter_user_id 硬 FK)
cps_activity / cps_group ──语义(无FK)──▶ cps_link ─1:N─ cps_click
@@ -212,13 +253,13 @@ launch_confirm_sample (独立, 无硬 FK; 都上报不去
## 四、资金模型(金币 / 现金 / 提现,三层)
1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分),一用户一行,读取展示用。
2. **流水账本** `coin_transaction` / `cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。
3. **唯一发金币入口** `repositories/wallet.grant_coins`:更新快照 + 写流水,**不 commit**,由调用方同一事务 commit(保证"记录"和"加币"原子化)。signin / signin_boost / task / ad_reward / feed_ad_reward / exchange / admin 都走它,靠 `biz_type` 区分来源。
1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分)+ `invite_cash_balance_cents`(邀请奖励金分,#82),一用户一行,读取展示用。
2. **流水账本** `coin_transaction` / `cash_transaction` / `invite_cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。**现金与邀请奖励金是两本物理隔离的账**——发放口径与提现对账各自独立。
3. **唯一变动入口**:金币走 `repositories/wallet.grant_coins`,邀请奖励金走 `grant_invite_cash`——都是「更新快照 + 写流水,**不 commit**,由调用方同一事务 commit。signin / signin_boost / task / ad_reward / feed_ad_reward / exchange / admin `grant_coins`;`invite_reward` / admin 调整走 `grant_invite_cash`,靠 `biz_type` 区分来源。
- **汇率**:`10000 金币 = 1 元 = 100 分`(`rewards.COIN_PER_YUAN`);兑换额必须是整分倍数。
- **提现状态机**:`reviewing`(发起即原子扣现金、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。扣款/退款`cash_transaction`,`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底。
- **防超额**:扣现金用带条件 `UPDATE ... WHERE cash_balance_cents >= amount`,并发/重试不会双扣。
- **提现状态机**:`reviewing`(发起即原子扣、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。**按 `withdraw_order.source` 分账**(#121):`coin_cash` 单的扣款/退款写 `cash_transaction`,`invite_cash` 单写 `invite_cash_transaction`;`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底,admin `withdraws/ledger-check` 分账校验「单 ↔ 流水」
- **防超额**:扣用带条件 `UPDATE ... WHERE <对应余额列> >= amount`(按 source 选 `cash_balance_cents` / `invite_cash_balance_cents`),并发/重试不会双扣。
---
+16 -7
View File
@@ -3,13 +3,13 @@
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`
> 最后更新:2026-06-23(补 3 张表文档:`device_liveness`(#65 无障碍存活监控)、`cps_wx_user`(CPS 落地页微信身份)、`launch_confirm_sample`(启动确认窗兜底样本);`comparison_record``input_tokens`/`output_tokens`。上一次 2026-06-17 补全 CPS 群发 5 张 `cps_*` + 好友邀请 2 张 `invite_*` + 比价沉淀 `price_observation`/`store_mapping` 并全表 review 对齐 model;含 [OVERVIEW 总览](./OVERVIEW.md))
> 最后更新:2026-07-09(补 5表文档并入索引:`coupon_session`(#99 领券任务流水)、`analytics_event`(#83 埋点)、`invite_cash_transaction`(#82 邀请奖励金账本)、`admin_role`(#117/#126 RBAC 角色)、`ad_pangle_daily_revenue`(#92,文档已有、补进索引);同步改动列:`comparison_record.product_names``withdraw_order.source``coin_account.invite_cash_balance_cents``feedback` 审核/环境列、`cps_order` 京东列、`device_liveness.first_protected_at``ad_feed_reward_record.ad_type/feed_scene``admin_user.plain_password/pages_override`。上一次 2026-06-23)
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
---
## 表总览(40 张业务表 + `alembic_version` 框架表)
## 表总览(45 张业务表 + `alembic_version` 框架表)
### 账号 / 反馈
| 表 | 用途 | 模型 | 文档 |
@@ -22,7 +22,7 @@
### 好友邀请(注册增长)
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `invite_relation` | 邀请绑定关系(注册即生效,双方各发1万金币;`invitee_user_id` 唯一=幂等防重复发奖) | `models/invite.py` | [详情](./invite_relation.md) |
| `invite_relation` | 邀请绑定关系(注册即生效但**绑定不发奖** #113;好友比价并下单后发邀请奖励金,`compare_reward_granted` 幂等闸;`invitee_user_id` 唯一) | `models/invite.py` | [详情](./invite_relation.md) |
| `invite_fingerprint` | 剪贴板归因失败时的指纹兜底(落地页记 ip+屏幕+机型,登录后反查邀请人) | `models/invite_fingerprint.py` | [详情](./invite_fingerprint.md) |
### 钱包 / 福利(看广告赚钱闭环)
@@ -30,8 +30,9 @@
|---|---|---|---|
| `coin_account` | 金币+现金余额快照(一用户一行) | `models/wallet.py` | [详情](./coin_account.md) |
| `coin_transaction` | 金币流水账本 | `models/wallet.py` | [详情](./coin_transaction.md) |
| `cash_transaction` | 现金流水账本(分) | `models/wallet.py` | [详情](./cash_transaction.md) |
| `withdraw_order` | 提现单(现金→微信零钱,含人工审核态) | `models/wallet.py` | [详情](./withdraw_order.md) |
| `cash_transaction` | 现金流水账本(分,金币兑换账) | `models/wallet.py` | [详情](./cash_transaction.md) |
| `invite_cash_transaction` | 邀请奖励金流水账本(分,与现金物理隔离;好友比价并下单发奖 + `source=invite_cash` 提现) | `models/wallet.py` | [详情](./invite_cash_transaction.md) |
| `withdraw_order` | 提现单(现金→微信零钱,含人工审核态;`source` 分账 coin_cash/invite_cash) | `models/wallet.py` | [详情](./withdraw_order.md) |
| `wechat_transfer_authorization` | 微信免确认转账授权(一用户一行) | `models/wallet.py` | [详情](./wechat_transfer_authorization.md) |
| `signin_record` | 签到记录(7 天循环) | `models/signin.py` | [详情](./signin_record.md) |
| `signin_boost_record` | 签到后看广告膨胀记录 | `models/signin.py` | [详情](./signin_boost_record.md) |
@@ -39,7 +40,8 @@
| `ad_reward_record` | 看激励视频发奖记录(S2S 回调,trans_id 幂等) | `models/ad_reward.py` | [详情](./ad_reward_record.md) |
| `ad_watch_log` | 看广告观看时长(旧版兼容字段) | `models/ad_watch_log.py` | [详情](./ad_watch_log.md) |
| `ad_ecpm_record` | 广告展示 eCPM 上报(收益对账) | `models/ad_ecpm.py` | [详情](./ad_ecpm_record.md) |
| `ad_feed_reward_record` | 信息流广告结算记录(10 秒一份,client_event_id 幂等) | `models/ad_feed_reward.py` | [详情](./ad_feed_reward_record.md) |
| `ad_feed_reward_record` | 信息流/Draw 广告结算记录(10 秒一份,client_event_id 幂等;`ad_type`+`feed_scene` 分形态/场景) | `models/ad_feed_reward.py` | [详情](./ad_feed_reward_record.md) |
| `ad_pangle_daily_revenue` | 穿山甲 GroMore 后台收益日表(定时拉取,收益报表/大盘真实收益源,#92) | `models/ad_pangle_revenue.py` | [详情](./ad_pangle_daily_revenue.md) |
### 比价 / 省钱
| 表 | 用途 | 模型 | 文档 |
@@ -62,6 +64,7 @@
| `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+package+日) | `models/coupon_state.py` | [详情](./coupon_state.md) |
| `coupon_daily_completion` | 首页「去领取」置灰源(今日是否已跑完整轮) | `models/coupon_state.py` | [详情](./coupon_state.md) |
| `coupon_claim_record` | 每张券领取结果沉淀(资产/画像/排查,不参与判断) | `models/coupon_state.py` | [详情](./coupon_state.md) |
| `coupon_session` | 一次领券任务一行的全程流水(发起/终态/耗时/机型,admin 领券看板数据源,#99) | `models/coupon_state.py` | [详情](./coupon_session.md) |
### 美团 CPS 券缓存
| 表 | 用途 | 模型 | 文档 |
@@ -84,10 +87,16 @@
| `ops_stat_config` | 首页三统计展示配置(real/manual/random) | `models/ops_stat_config.py` | [详情](./ops_stat_config.md) |
| `ops_marquee_seed` | 首页轮播种子(真实不足时兜底混播) | `models/ops_marquee_seed.py` | [详情](./ops_marquee_seed.md) |
### 埋点
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `analytics_event` | 客户端埋点事件流(批量上报,一事件一行;admin 埋点日志检索,#83) | `models/analytics_event.py` | [详情](./analytics_event.md) |
### 运营后台 admin(独立子应用 `app/admin/`,独立鉴权)
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `admin_user` | 管理员账号(独立 JWT + RBAC) | `models/admin.py` | [详情](./admin_user.md) |
| `admin_user` | 管理员账号(独立 JWT + RBAC;`pages_override` 个人可见页覆盖) | `models/admin.py` | [详情](./admin_user.md) |
| `admin_role` | 后台角色→可见页配置(内建三角色 + 自定义角色,#117/#126) | `models/admin_role.py` | [详情](./admin_role.md) |
| `admin_audit_log` | 操作审计日志(只追加) | `models/admin.py` | [详情](./admin_audit_log.md) |
| `app_config` | 运营可配置项(覆盖 rewards 常量) | `models/app_config.py` | [详情](./app_config.md) |
+2
View File
@@ -11,6 +11,8 @@
| `id` | Integer | PK | 自增主键 |
| `client_event_id` | String(64) | UNIQUE, NOT NULL | 客户端幂等事件 id |
| `ad_session_id` | String(64) | index, nullable | 客户端生成的一次信息流广告会话 id |
| `ad_type` | String(16) | nullable, default `feed` | 广告形态:`feed`(信息流)/ `draw`(Draw 视频流)。旧数据 NULL 视为 feed(迁移随 #83 入库) |
| `feed_scene` | String(16) | nullable | 展示场景:`compare`(比价期)/ `coupon`(领券期)。比价与领券共用同一 Draw 代码位,收益/大盘按它分场景归集(#125 修比价/领券奖励金币恒 0 即改按本列汇总) |
| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 |
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期 `YYYY-MM-DD` |
| `duration_seconds` | Integer | NOT NULL | 整场比价累计观看秒数(轮播各条相加) |
+32
View File
@@ -0,0 +1,32 @@
# admin_role — 运营后台角色(RBAC 可见页配置)
> 模型 `app/models/admin_role.py` · 仓库 `app/admin/repositories/admin_role.py` · 权限目录 `app/admin/permissions.py` · 接口 admin `GET/POST /admin/api/roles``PATCH/DELETE /admin/api/roles/{id}``GET /admin/api/roles/catalog`(`app/admin/routers/roles.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
运营后台的**角色 → 可见页面**配置表。#117 把 RBAC 从「代码写死三角色」升级为数据驱动:内建角色(`super_admin`/`finance`/`operator`)种子进表(`is_builtin=true`,不可删),`super_admin` 可另建**自定义角色**(#126)勾选任意页面组合。`admin_user.role` 存角色 `name` 按名引用本表;单个管理员还可用 `admin_user.pages_override` 在角色之上覆盖个人可见页。
## 用在哪 / 增删改查
- **C(插入)**:内建三角色随迁移种子;`POST /admin/api/roles`(super_admin)新建自定义角色。
- **U(更新)**:`PATCH /admin/api/roles/{id}` 改展示名/可见页(内建角色的 `pages` 也可调)。
- **D(删除)**:`DELETE /admin/api/roles/{id}`——**内建角色与在用角色(有 admin_user.role 引用)不可删**。
- **R**:登录/每次 admin 鉴权时按 `admin_user.role` 查本表算有效可见页(`pages_override` 非空则以覆盖为准);`GET /admin/api/roles/catalog` 返回全部可配页面目录(分组,来自 `permissions.py` 常量,不落库)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `name` | String(32) | UNIQUE, index, NOT NULL | 角色标识(被 `admin_user.role` 按名引用):内建 `super_admin`/`finance`/`operator`;**自定义角色 name(key)= label = 创建时的输入名称**(不能叫 super_admin,重名 409) |
| `label` | String(32) | NOT NULL, default `""` | 展示名(后台下拉里显示) |
| `pages` | JSON | NOT NULL, default `[]` | 可见页面 key 列表(全集见 `permissions.py` 目录;前端按它渲染菜单,后端接口守卫同源校验)。**`super_admin` 行 pages 存空数组**,有效可见页特判为全部 |
| `is_builtin` | Boolean | NOT NULL, default false | 内建角色标记(不可删;`super_admin` 恒过所有守卫,不依赖 pages) |
| `created_at` | DateTime(tz) | server_default now() | |
## 关系 / Join Key
- ← `admin_user.role` **按 `name` 语义引用**(无硬 FK;删除保护在应用层:在用角色不可删)。
- 与 `admin_user.pages_override` 的关系:有效可见页 = `pages_override`(个人覆盖,非空优先) 否则取角色 `pages`;`super_admin` 无视两者恒全通。
## 索引与约束
- PK `id`;UNIQUE+index `name`
## 注意
- 页面 key 目录维护在代码 `app/admin/permissions.py`(加新后台页面要同步登记),表里只存勾选结果——目录变更不需要迁移。
- 角色守卫兼容旧语义:`require_role("finance")` 等旧代码路径仍工作,内建角色名不可改。
+8 -6
View File
@@ -1,13 +1,13 @@
# admin_user — 运营后台管理员账号
> 模型 `app/models/admin.py` · 仓库 `app/admin/repositories/admin_user.py` · 接口 [admin-auth-login](../api/admin-auth-login.md) / [admin-admins-list](../api/admin-admins-list.md) / [admin-admin-create](../api/admin-admin-create.md) / [admin-admin-update](../api/admin-admin-update.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/admin.py` · 仓库 `app/admin/repositories/admin_user.py` · 接口 [admin-auth-login](../api/admin/auth/admin-auth-login.md) / [admin-admins-list](../api/admin/admins/admin-admins-list.md) / [admin-admin-create](../api/admin/admins/admin-admin-create.md) / [admin-admin-update](../api/admin/admins/admin-admin-update.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
运营后台(`app/admin/` 子应用,端口 8771)的管理员账号,与 App 用户(`user` 表)**完全隔离**:独立 JWT secret、独立鉴权链。密码 bcrypt 存哈希,带角色做 RBAC 权限分级。
## 用在哪 / 增删改查
- **C(插入)**:① 首个管理员用 `scripts/create_admin.py` 命令行创建(无自助注册);② `super_admin` 在后台「管理员管理」`POST` 新建子管理员。
- **U(更新)**:登录成功刷 `last_login_at`;`super_admin` 改他人 `role`/`status`/重置密码(`admin-admin-update`)。
- **D**:无(禁用走 `status='disabled'`,token 立即失效)。
- **U(更新)**:登录成功刷 `last_login_at`;`super_admin` 改他人 `role`/`status`/重置密码/`pages_override`(`admin-admin-update`)。
- **D**:`DELETE /admin/api/admins/{id}`(#126,super_admin,带审计;不可删自己)。禁用`status='disabled'`(token 立即失效)。
- **R**:每个 admin 请求经 `admin/deps` 解 admin token 查本表(校验 `status=='active'` + 角色守卫);管理员列表。
## 字段
@@ -15,8 +15,10 @@
|---|---|---|---|
| `id` | Integer | PK, autoincrement | 被 `admin_audit_log.admin_id` 引用 |
| `username` | String(64) | UNIQUE, index, NOT NULL | 登录名 |
| `password_hash` | String(255) | NOT NULL | bcrypt 哈希(明文不落库;⚠️ bcrypt 72 字节截断) |
| `role` | String(20) | NOT NULL, default `operator` | 取值:`super_admin`(全权+管账号)/ `finance`(钱:提现+金币)/ `operator`(用户+反馈+大盘) |
| `password_hash` | String(255) | NOT NULL | bcrypt 哈希(⚠️ bcrypt 72 字节截断) |
| `plain_password` | String(128) | nullable | **明文密码副本**(#117,`super_admin` 在管理员列表可见,便于线下派发/找回;创建/重置密码时同步写)。安全上是有意取舍:后台仅内网+super_admin 可见 |
| `role` | String(20) | NOT NULL, default `operator` | 角色名,按 `name` 引用 [`admin_role`](./admin_role.md)(#117 起数据驱动):内建 `super_admin`(恒全权)/ `finance` / `operator`,或自定义角色(#126) |
| `pages_override` | JSON | nullable | **个人可见页覆盖**(#126):非空时优先于角色 `pages`;NULL=跟随角色。页面 key 目录见 `app/admin/permissions.py` |
| `status` | String(20) | NOT NULL, default `active` | 取值:`active` / `disabled`(禁用后 token 立即失效) |
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 |
| `last_login_at` | DateTime(tz) | nullable | 最近登录时间(登录成功时更新) |
@@ -30,4 +32,4 @@
## 注意
- **鉴权隔离**:admin token `typ=admin` + 独立 `ADMIN_JWT_SECRET`(≠ App 的 `JWT_SECRET_KEY`),App 用户 token 无法当 admin 用;admin 无 refresh,过期(默认 12h)重登。
- **RBAC**:`super_admin` 恒过所有角色守卫(`require_role`);`finance` 管钱、`operator` 管用户/反馈/大盘。具体守卫见各接口文档。
- **RBAC**:`super_admin` 恒过所有角色守卫(`require_role`);`finance` 管钱、`operator` 管用户/反馈/大盘;#117 起可见页由 [`admin_role`](./admin_role.md)`.pages` 数据驱动 + `pages_override` 个人覆盖,自定义角色见 `POST /admin/api/roles`。具体守卫见各接口文档。
+39
View File
@@ -0,0 +1,39 @@
# analytics_event — 客户端埋点事件流
> 模型 `app/models/analytics_event.py` · 仓库 `app/repositories/analytics.py` · 接口 C 端 `POST /api/v1/analytics/events`([analytics-events](../api/other/analytics-events.md),批量,不强制登录);admin `GET /admin/api/event-logs`(`app/admin/routers/event_logs.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
客户端行为埋点的落地表,**一事件一行**(append-only)。客户端攒批上报(每批 ≤200 条),服务端展开逐条插入;`props` 装事件自带的任意维度。当前主要供 admin「埋点日志」检索与手工分析,无自动聚合任务。#83 新增(2026-06-27)。
## 用在哪 / 增删改查
- **C(插入)**:`POST /api/v1/analytics/events`(不强制登录:带 JWT 则记 `user_id`,匿名只记 `device_id`)。服务端补 `client_ip`
- **U / D**:无。只追加。
- **R**:admin `GET /admin/api/event-logs`(游标分页,可按 `event`/`device_id`/`user_id` 筛)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `event` | String(64) | index, NOT NULL | 事件名(客户端约定,如页面曝光/点击类事件名) |
| `props` | JSON | nullable | 事件属性(任意维度,客户端原样传) |
| `device_id` | String(64) | index, NOT NULL | 客户端 per-install id(匿名也有) |
| `user_id` | Integer | index, nullable | 登录态才有。**无硬 FK** |
| `session_id` | String(64) | index, nullable | 客户端会话 id(一次冷启动一个) |
| `client_ts` | BigInteger | NOT NULL | 事件发生时间(客户端 epoch ms) |
| `sent_at` | BigInteger | nullable | 客户端上报时间(epoch ms);与 `client_ts` 差 = 攒批延迟 |
| `page` | String(64) | nullable | 事件所在页面 |
| `client_ip` | String(64) | nullable | 服务端从请求头取 |
| `oem` / `os` / `model` | String | nullable | 厂商 / 系统版本 / 机型 |
| `app_ver` | String(32) | nullable | App versionName |
| `network` | String(16) | nullable | 网络类型(wifi/cellular) |
| `channel` | String(32) | nullable | 分发渠道 |
| `created_at` | DateTime(tz) | server_default now() | 入库时间 |
## 关系 / Join Key
- `user_id` 软指 `user.id``device_id` 与其他表的 per-install id 同源——均无硬 FK(埋点不鉴权、匿名也收,不能被外键约束卡住)。
## 索引与约束
- PK `id`;index `event``device_id``user_id``session_id`
## 注意
- 每批最多 200 条,超出整批 422;单条字段超长按模型截断口径处理。
- 时间轴分析用 `client_ts`(事件真实发生时刻),`created_at` 只是入库时刻(受攒批影响)。
+6 -3
View File
@@ -1,17 +1,20 @@
# cash_transaction — 现金流水账本(分)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-cash-transactions](../api/wallet-cash-transactions.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-cash-transactions](../api/wallet/wallet-cash-transactions.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
现金每变动一笔就记一行(单位:**分**,记变动后余额)。金币兑现金、提现、提现退款都落这里。**只增不改不删**。
> **只管「金币兑换现金」这本账**(`coin_account.cash_balance_cents`)。**邀请奖励金**是另一本物理隔离的账 → [`invite_cash_transaction`](./invite_cash_transaction.md)(同构表);`source=invite_cash` 的提现流水**不落本表**。
## 用在哪 / 增删改查
- **C(插入)**:个来源,每次写一笔:
- **C(插入)**:个来源,每次写一笔:
| 动作 / endpoint | `biz_type` | `amount_cents` | `ref_id` 指向 |
|---|---|---|---|
| 金币兑现金 `POST /wallet/exchange` | `exchange_in` | + | null(配套 `coin_transaction.exchange_out`) |
| 发起提现 `POST /wallet/withdraw` | `withdraw` | (扣现金) | `withdraw_order.out_bill_no` |
| 发起提现 `POST /wallet/withdraw`(`source=coin_cash`) | `withdraw` | (扣现金) | `withdraw_order.out_bill_no` |
| 提现失败/取消/审核拒绝退款 | `withdraw_refund` | +(退回) | `withdraw_order.out_bill_no` |
| admin 手动调整 `POST /admin/api/users/{id}/cash`(`account=coin_cash`) | `admin_grant` / `admin_deduct` | + / | null(原因记 `remark`=`admin:<reason>`) |
- **U / D**:无。账本只追加。
- **R**:`GET /wallet/cash-transactions`(现金明细,`id` 倒序游标);admin 跨用户现金流水。
+5 -4
View File
@@ -1,6 +1,6 @@
# coin_account — 金币 + 现金余额快照
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-account](../api/wallet-account.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-account](../api/wallet/wallet-account.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
一用户一行的余额快照,App「资产卡 / 钱包」读它展示。每次余额变动都另写一笔流水(`coin_transaction` / `cash_transaction`)并记 `balance_after`,出问题逐笔回溯。`user_id` 既是主键也是外键(一对一)。详见 [总览 §四 资金模型](./OVERVIEW.md#四资金模型金币--现金--提现三层)。
@@ -15,7 +15,8 @@
|---|---|---|---|
| `user_id` | Integer | **PK + FK→user.id** | 用户(一用户一行);既是主键也是外键 |
| `coin_balance` | Integer | NOT NULL, default 0 | 当前金币余额(个数);= 历次 `coin_transaction.amount` 之和 |
| `cash_balance_cents` | Integer | NOT NULL, default 0 | 当前现金余额(分);= 历次 `cash_transaction.amount_cents` 之和 |
| `cash_balance_cents` | Integer | NOT NULL, default 0 | 当前现金余额(分,**金币兑换账本**);= 历次 `cash_transaction.amount_cents` 之和 |
| `invite_cash_balance_cents` | Integer | NOT NULL, default 0 | 当前**邀请奖励金**余额(分,与现金物理隔离的第二本现金账);= 历次 [`invite_cash_transaction`](./invite_cash_transaction.md)`.amount_cents` 之和。好友比价并下单发奖入账(#113),`source=invite_cash` 提现出账(#121) |
| `total_coin_earned` | Integer | NOT NULL, default 0 | 累计赚取金币(**只增不减**,仅正向 grant 累加),用于"历史总收益"展示 |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后更新时间 |
@@ -27,5 +28,5 @@
- PK `user_id`(同时是 FK→user.id)。
## 注意
- **唯一发金币入口** `wallet.grant_coins`:更新本表 + 写 `coin_transaction`,**不 commit**,由调用方同事务提交(发币与业务记录原子化)。
- 扣现金用 `UPDATE ... WHERE cash_balance_cents >= amount` 原子条件扣减,并发/重试不会超额。
- **唯一发金币入口** `wallet.grant_coins`:更新本表 + 写 `coin_transaction`,**不 commit**,由调用方同事务提交(发币与业务记录原子化)。邀请奖励金同款:**唯一变动入口 `wallet.grant_invite_cash`**(更新 `invite_cash_balance_cents` + 写 `invite_cash_transaction`)。
- 扣现金用 `UPDATE ... WHERE <对应余额列> >= amount` 原子条件扣减(按提现 `source``cash_balance_cents``invite_cash_balance_cents`),并发/重试不会超额。
+4 -3
View File
@@ -1,6 +1,6 @@
# comparison_record — 比价记录(每次比价完整明细)
> 模型 `app/models/comparison.py` · 仓库 `app/repositories/comparison.py` · 接口 [compare-record-report](../api/compare-record-report.md) / [compare-records](../api/compare-records.md) / [compare-record-detail](../api/compare-record-detail.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/comparison.py` · 仓库 `app/repositories/comparison.py` · 接口 [compare-record-report](../api/compare/compare-record-report.md) / [compare-records](../api/compare/compare-records.md) / [compare-record-detail](../api/compare/compare-record-detail.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
每完成一次比价(外卖/电商/领券)记一行。**写入以 app-server 后端 harvest 为主**(2026-07 起):比价透传壳 `compare.py` 在帧0(pricebot 出 trace_id)即建 `running` 行,随 done / `trace/finalize` 逐步补全成终态,客户端不再主动 POST 记录;老客户端仍可走**带 JWT** 的 `POST /compare/record` 兜底(灰度期两条写路径按 `trace_id` reconcile)。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数),还被「上报更低价」反查原最低价。
@@ -8,9 +8,9 @@
> 与 [`price_observation`](./price_observation.md) / `store_mapping` 的区别:本表是**用户视角**(登录后按 `user_id` 存「我的比价记录」);后两张是 server 侧无条件沉淀的**平台/门店视角客观事实**(价格事实 / 跨平台店铺身份映射),与本表 `trace_id` 同源但不互相 join,各存各的视角。
## 用在哪 / 增删改查
- **C / U(harvest 为主,按 `trace_id` 幂等)**:透传壳 `compare.py` 三段式落库(`app/repositories/comparison.py`)——`harvest_running`(帧0 建 `running` 行)→ `harvest_done`(done 帧转 `success`/`failed` + 派生 `best_*`/`saved_amount_cents` + 返 `newly_success` 供幂等发邀请奖)→ `harvest_abort`(`trace/finalize``cancelled`/`failed`,**不降级已 success**)。老客户端仍可 `POST /compare/record`(`upsert_record`,按 `trace_id` 查、整行覆盖)兜底。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 一律由 `_derive``comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。
- **C / U(harvest 为主,按 `trace_id` 幂等)**:透传壳 `compare.py` 三段式落库(`app/repositories/comparison.py`)——`harvest_running`(帧0 建 `running` 行)→ `harvest_done`(done 帧转 `success`/`failed` + 派生 `best_*`/`saved_amount_cents`;`newly_success` 仅留日志观测,**不在此发邀请奖**——#113 已把发奖口径移到「实际下单」`POST /order/report`)→ `harvest_abort`(`trace/finalize``cancelled`/`failed`,**不降级已 success**)。老客户端仍可 `POST /compare/record`(`upsert_record`,按 `trace_id` 查、整行覆盖)兜底。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 一律由 `_derive``comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。
- **D**:无(关联的 `price_report` 也只把 `comparison_record_id` 置空,不删本表)。
- **R**:`GET /compare/records`(列表,`created_at` 倒序游标 + 「已下单」标记)、`GET /compare/records/{id}`(详情,限本人);`count_success` 给里程碑;`get_stats`(`status='success'` 计数 + `saved_amount_cents` 求和)给 [`GET /compare/stats`](../api/compare-stats.md) 喂「我的」页省钱战绩卡(完成比价 + 累计发现可省,**比价口径**);`report.py` 反查 `best_*`;admin 大盘/明细。
- **R**:`GET /compare/records`(列表,`created_at` 倒序游标 + 「已下单」标记)、`GET /compare/records/{id}`(详情,限本人);`count_success` 给里程碑;`get_stats`(`status='success'` 计数 + `saved_amount_cents` 求和)给 [`GET /compare/stats`](../api/compare/compare-stats.md) 喂「我的」页省钱战绩卡(完成比价 + 累计发现可省,**比价口径**);`report.py` 反查 `best_*`;admin 大盘/明细。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
@@ -30,6 +30,7 @@
| `saved_amount_cents` | Integer | nullable | 源价 最优价(可 0/负:源平台本就最便宜) |
| `is_source_best` | Boolean | nullable | 源平台就是最便宜(= 这次没省到) |
| `store_name` | String(128) | nullable | 店铺名。**与 `savings_record.shop_name` 按字符串相等关联**,给本记录打「已下单」 |
| `product_names` | String(512) | nullable | 菜品/商品名拼接串(`items[].name` 顿号连接,超长截断),**专供 admin 比价记录按店/商品模糊搜索**(#117:对 JSON 列做 LIKE 不可移植,冗余成扁平列;写入时随 harvest/upsert 同步生成)。C 端不读它 |
| `total_dish_count` / `skipped_dish_count` | Integer | nullable | 菜品总数 / 目标平台没找到被跳过数 |
| `status` | String(16) | NOT NULL, default `success` | 取值:`running`(harvest 帧0 建行、比价进行中)/ `success`(有非源且有价的目标结果)/ `failed`(出错/没采到目标价)/ `cancelled`(用户终止 / Phase1 未识别,`harvest_abort` 写,**不降级已 success**)。**里程碑只数 success** |
| `information` | String(256) | nullable | done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) |
+44
View File
@@ -0,0 +1,44 @@
# coupon_session — 领券任务全程流水(admin「领券数据」看板数据源)
> 模型 `app/models/coupon_state.py`(`CouponSession`) · 仓库 `app/repositories/coupon_state.py`(`upsert_session`) · 接口 C 端 `POST /api/v1/coupon/session`([coupon-session](../api/coupon/coupon-session.md));admin `GET /admin/api/coupon-data/*`(聚合看板 + 明细,`app/admin/routers/coupon_data.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
**一次领券任务一行**(`trace_id` 唯一),记从发起(`started`)到收尾(`completed`/`failed`/`abandoned`)的全程:总耗时、各平台耗时、领到张数、机型/ROM、发起来源。与同文件的三张「今日状态」表([coupon_state](./coupon_state.md))分工不同:那三张按「设备×日」管**频控/置灰**,本表按「一次任务」管**漏斗与体验指标**——admin「领券数据」看板的发起数、完成率、中途流失(started 无终态)、平均耗时都从这算。#99 新增(2026-06-30)。
## 用在哪 / 增删改查
- **C / U(upsert,按 `trace_id`)**:客户端 `POST /api/v1/coupon/session` **两段上报**——发起时建行(`status='started'`,带 platforms/origin_package/机型),收尾时按同 `trace_id` 更新同一行(终态 + `elapsed_ms` + `platform_elapsed` + `claimed_count`)。发起即落库 → 收尾丢失(App 被杀/断网)的行永远停在 `started` = 中途流失,可量化。
- **D**:无。
- **R**:admin `GET /admin/api/coupon-data`(按 `started_date` × `app_env` 聚合趋势/漏斗 + 逐条明细,join `user` 出手机号)+ `GET /admin/api/coupon-data/user-records`(用户抽屉:某用户全部领券记录)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `trace_id` | String(64) | NOT NULL, **UNIQUE**(`uq_coupon_session_trace`) | 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 幂等键;与 pricebot work_logs / `coupon_claim_record.trace_id` 同源 |
| `device_id` | String(64) | NOT NULL | 客户端 per-install id |
| `user_id` | Integer | index, nullable | 登录态才带(admin join `user` 出手机号/昵称);匿名领券为空。**无硬 FK** |
| `status` | String(16) | NOT NULL | `started`(发起)/ `completed` / `failed` / `abandoned`(用户中止)。`started` 无终态 = 中途流失 |
| `app_env` | String(16) | index, nullable | `prod` / `dev`(客户端 BuildConfig.DEBUG)。admin 报表默认只看 prod,防测试数据串台 |
| `platforms` | JSON(PG: JSONB) | nullable | 发起勾选平台 `["meituan-waimai", ...]`;空=全领 |
| `origin_package` | String(64) | nullable | 发起来源外卖 App 包名;null=App 内(傻瓜比价首页)发起,非空=切到美团/淘宝/京东被弹券引导发起。admin「发起平台」列据此区分 |
| `device_model` | String(128) | nullable | 机型(Build.MANUFACTURER + MODEL) |
| `rom` | String(64) | nullable | ROM(OemDetector,如 `ColorOS 14`) |
| `started_at` | DateTime(tz) | NOT NULL | 发起时刻(客户端墙钟) |
| `started_date` | Date | NOT NULL | 发起的 Asia/Shanghai 自然日;admin 按天聚合/筛选(复合索引) |
| `finished_at` | DateTime(tz) | nullable | 收尾时刻(服务端 now);未收尾(流失)为空 |
| `elapsed_ms` | Integer | nullable | 全程耗时(客户端点发起→收尾计时,权威);平均/分位只统计 completed |
| `platform_elapsed` | JSON(PG: JSONB) | nullable | 各平台领券耗时 `{"meituan-waimai": 3200, ...}`(ms) |
| `claimed_count` | Integer | nullable | 本次领到张数(收尾上报) |
| `trace_url` | String(512) | nullable | pricebot 公网调试链接(admin 明细可点开复盘) |
| `created_at` / `updated_at` | DateTime(tz) | server_default now() / +onupdate | |
## 关系 / Join Key
- `user_id` 软指 `user.id`(无硬 FK,可空;admin 明细 LEFT JOIN 出手机号)。
- `trace_id` 与 pricebot work_logs、[`coupon_claim_record`](./coupon_state.md).`trace_id` 同源(一次任务),无 id 级 join。
- `device_id` 与领券三表同源(per-install id)。
## 索引与约束
- PK `id`;UNIQUE `trace_id`;index `user_id``app_env`;复合 index `ix_coupon_session_date_env`(`started_date`, `app_env`)= admin 看板主查询路径。
## 注意
- 写库 best-effort 口径与领券三表一致:上报失败不影响领券本身(且本表数据源是客户端**独立上报**,不是 `/coupon/step` 透传顺手写)。
- `elapsed_ms` 以客户端计时为准(墙钟差不影响);`finished_at - started_at` 只做时刻留痕,不用来算时长。
+1
View File
@@ -1,6 +1,7 @@
# coupon_state — 领券今日状态三张表(弹窗频控 / 首页置灰 / 领券记录)
> 模型 `app/models/coupon_state.py` · 仓库 `app/repositories/coupon_state.py` · 接口 `app/api/v1/coupon.py`(prefix `/api/v1/coupon`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 同模型文件里还有第四张表 [`coupon_session`](./coupon_session.md)(一次领券任务一行的全程流水,admin「领券数据」看板数据源,#99)——维度与本文三张「设备×日」状态表不同,单独成文。
领券(优惠券自动化)联动产生的三张「今日状态」表,都挂在领券透传端点 `POST /api/v1/coupon/step` 这条链路上(pricebot 跑领券,结果回 app-server 落库;**领券过程本身在 pricebot 内存态跑、不落库**)。三表各管一件事:
+13 -4
View File
@@ -2,7 +2,7 @@
> 模型 `app/models/cps_order.py` · 仓库 `app/admin/repositories/cps.py`(`reconcile_orders` / `_map_order_fields` / `list_orders`、统计 `group_stats`) · 接口 admin `POST /admin/api/cps/orders/reconcile``GET /admin/api/cps/orders``GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
美团联盟 `query_order` 按时间窗拉回、按 `sid` 归群的订单明细,是 CPS 群发漏斗的**最下游"赚了多少佣金"**:用户点 [`cps_link`](./cps_link.md)(携群 [`cps_group`](./cps_group.md) 的 `sid`)下单后,美团把订单连同 `sid` 回传,这里按 `sid` 归群做对账,与 [`cps_click`](./cps_click.md) 的点击量在 `group_stats` 汇成"点击→下单→佣金"漏斗。**仅美团有此表**——淘宝/京东无对账 API,统计里对账字段显示 `-`
从联盟 API 按时间窗拉回、按 `sid` 归群的订单明细,是 CPS 群发漏斗的**最下游"赚了多少佣金"**:用户点 [`cps_link`](./cps_link.md)(携群 [`cps_group`](./cps_group.md) 的 `sid`)下单后,联盟把订单连同 `sid` 回传,这里按 `sid` 归群做对账,与 [`cps_click`](./cps_click.md) 的点击量在 `group_stats` 汇成"点击→下单→佣金"漏斗。**平台覆盖**:初版仅美团(`query_order`);#90(2026-06-30)接入**京东联盟**订单(拉单进同一张表,`platform='jd'`,配 `jd_*`/`external_*` 列,喂数据大盘);淘宝仍无对账 API,统计里对账字段显示 `-`
## 用在哪 / 增删改查
- **C/U(upsert)**:`POST /admin/api/cps/orders/reconcile`(`reconcile_orders`,需 `finance` 角色)。调 `meituan.query_order(sid?, start_time, end_time, page, limit=100)` 分页拉单(`max_pages=200` 防死循环),每条经 `_map_order_fields` 转字段,按 `order_id` **upsert**:不存在则 insert,存在则逐字段覆盖(订单状态会随时间变 付款→完成→结算/退款,重复拉则更新)。返回 `{fetched, inserted, updated, pages}``order_id` 全局唯一即幂等键。
@@ -15,7 +15,10 @@
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join / 源字段) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `order_id` | String(64) | **UNIQUE, index, NOT NULL** | 美团订单号(加密串),`orderId`。upsert 幂等键 |
| `platform` | String(20) | index, NOT NULL, default `meituan` | 订单来源联盟:`meituan` / `jd`(#90)。统计/大盘按它分平台 |
| `order_id` | String(64) | **UNIQUE, index, NOT NULL** | 订单号(美团加密串 `orderId`;京东为联盟订单号)。upsert 幂等键 |
| `external_order_id` | String(128) | index, nullable | 平台原始订单号(#90,京东 `orderId`;美团 null) |
| `external_row_id` | String(128) | index, nullable | 平台订单行号(#90,京东一单多 sku 时区分行;美团 null) |
| `sid` | String(64) | index, **nullable** | 渠道追踪位 = 群 `sid`,源 `sid`。**按它归群聚合**;历史无 sid 订单为空 |
| `act_id` | String(64) | index, nullable | 活动物料 ID,源 `actId`(转字符串) |
| `biz_line` | Integer | nullable | 业务线,源 `businessLine``1=外卖` |
@@ -25,9 +28,14 @@
| `commission_rate` | String(16) | nullable | 佣金率,源 `commissionRate``"300"=3%``"10"=0.1%`(原样字符串,前端解释) |
| `refund_price_cents` | Integer | nullable | 退款金额(分),源 `refundPrice`(元) |
| `refund_profit_cents` | Integer | nullable | 退款佣金(分),源 `refundProfit`(元) |
| `mt_status` | String(8) | index, nullable | 美团订单状态,源 `status`:`2`付款 `3`完成 `4`取消 `5`风控 `6`结算 |
| `estimated_commission_cents` | Integer | nullable | 预估佣金(分,#90 京东口径;美团用 `commission_cents`) |
| `actual_commission_cents` | Integer | nullable | 实际/结算佣金(分,#90 京东口径) |
| `mt_status` | String(8) | index, nullable | 美团订单状态,源 `status`:`2`付款 `3`完成 `4`取消 `5`风控 `6`结算;京东订单为 null |
| `jd_valid_code` | String(16) | index, nullable | 京东订单有效码(#90,联盟 `validCode`,判有效/无效/风控);美团订单为 null |
| `invalid_reason` | String(128) | nullable | 失效原因,源 `invalidReason` |
| `product_name` | String(512) | nullable | 商品名,源 `productName`(超 500 截断) |
| `settle_month` | String(16) | nullable | 结算月份(#90 京东) |
| `site_id` / `position_id` / `pid` | String(128) | nullable | 京东推广位维度(#90):站点/推广位/联盟 pid,归因用 |
| `pay_time` | DateTime(tz) | index, nullable | 付款时间,源 `payTime`(秒级 ts)。统计按时间窗过滤的就是它 |
| `mt_update_time` | DateTime(tz) | nullable | 美团侧更新时间,源 `updateTime`(秒级 ts) |
| `raw` | JSON / JSONB | NOT NULL, default `{}` | `query_order` 单条原始 dataList,留底排查/补字段 |
@@ -48,4 +56,5 @@
- **"未归群"独立行**:`group_stats` 遍历完所有群后,对剩下的、有订单但 `sid` 不属任何现存群的 sid(历史遗留如 `wonderableai`、或别处来源、或群被删),单列一行 `group_id=None`、有对账无点击。所以本表 `sid` 可空/可孤立是设计内的,不是脏数据。
- **金额/时间统一转换的原因**:`query_order` 返回金额是「元」字符串、时间是秒级 ts;`_yuan_to_cents`(Decimal 防浮点,`"null"`/空 → None)与 `_ts_to_dt`(转 tz-aware UTC,前端按北京展示)在入库时归一,与全站"金额存分、时间存 tz-aware"口径对齐。`raw` 整条留底,字段不够时不用重拉。
- **upsert 而非 append**:同一订单会被多次拉到(状态变化),按 `order_id` 覆盖即可拿到最新状态;`reconcile` 可重复跑(幂等)。
- **本表自始至终只服务美团**:初版(`277f9b1`)就定型,`3a40f61` 接淘宝/京东时**没动本表**——那俩平台无对账 API,统计对账列直接`None`(前端显示 `-`)。这是"对账 = 美团专属"的边界。
- **平台边界的演变**:初版(`277f9b1`)只服务美团;#90(2026-06-30)接入**京东联盟**拉单进同一张表(`platform='jd'` + `external_*`/`jd_valid_code`/`estimated|actual_commission_cents` 等列,有效性按 `jd_valid_code` 判,喂 admin 数据大盘的京东收益)。**淘宝仍无对账 API**,统计对账列给 `None`(前端显示 `-`)。
- **#119`pay_time` 缺失**:早期美团拉单部分订单 `payTime` 空导致 `pay_time` 为 null → 大盘按时间窗过滤漏算美团收益;#119 起入库补齐/回填,时间窗统计以 `pay_time` 为准。
+3 -2
View File
@@ -9,7 +9,7 @@
## 用在哪 / 增删改查
- **C / U(upsert)**:`register_or_update`(`POST /device/register`,App 拿到 push token 时调)按 `(user_id, device_id)` upsert,只在传入非空时更新 `registration_id`/`platform`/`app_version`;`touch_heartbeat`(`POST /device/heartbeat`,无障碍服务存活时周期调,**心跳也能自注册**)在 `accessibility_enabled=true` 时刷 `last_heartbeat_at`、置 `ever_protected=true`、状态机重置回 `alive`、清 `notified_at`(掉线恢复→下次再断才再推一条)。
- **U(worker)**:`mark_notified`(`heartbeat_monitor_worker` 检出掉线后)置 `liveness_state='notified'` + `notified_at` + **`kill_alert_pending=True`**;`ack_kill_alert`(`POST /device/liveness/ack`,客户端弹过引导后)清 `kill_alert_pending`(幂等)。
- **R**:`list_overdue`(worker 扫描:`ever_protected=true` + `liveness_state='alive'` + `last_heartbeat_at` 早于 `now - timeout`)→ 掉线设备列表;`get_device`(`GET /device/liveness`,客户端进 App 拉本机是否被判掉线过)。
- **R**:`list_overdue`(worker 扫描:`ever_protected=true` + `liveness_state='alive'` + `last_heartbeat_at` 早于 `now - timeout`)→ 掉线设备列表;`get_device`(`GET /device/liveness`,客户端进 App 拉本机是否被判掉线过);admin `GET /admin/api/device-liveness/stats` + 列表(#80,后台「设备存活监控」页:总数/在线/掉线卡片 + 明细)
- **D**:无。
## 字段
@@ -22,6 +22,7 @@
| `platform` | String(16) | NOT NULL, default `android` | |
| `app_version` | String(32) | nullable | 上报时 App 版本 |
| `ever_protected` | Boolean | NOT NULL, default false | 收到过 service 心跳即 true(=该设备开过无障碍,功能对它有意义)。`list_overdue` 的过滤前提 |
| `first_protected_at` | DateTime(tz) | nullable | **首次**开启无障碍(首次收到心跳)的时刻(#80);置 `ever_protected=true` 时一并写、之后不再变。admin 设备存活监控用它算「开启保护耗时/转化」 |
| `last_heartbeat_at` | DateTime(tz) | index, nullable | 最近一次 service 心跳时间(存活证明);超时即视为保护掉线 |
| `last_report_protection_on` | Boolean | NOT NULL, default false | 最近一次上报的无障碍开关状态(观测用) |
| `liveness_state` | String(16) | NOT NULL, default `unknown` | 状态机:`unknown``alive`(收到 service 心跳)→ `silent`/`notified`(扫描发现超时并已推送);心跳恢复 handler 重置回 `alive` |
@@ -40,4 +41,4 @@
## 注意
- **`kill_alert_pending` 为什么和 `liveness_state` 解耦**:服务随 App 重启会先发心跳把 `state` 重置回 `alive`,若复用 `state` 判「待提醒」,客户端进 App 这一刻可能恰好已被重置 → 漏看这次掉线。故另设一个只由 worker 置、只由客户端 ack 清的标记,规避竞态。
- **`list_overdue` 本期不要求有 `registration_id`**:本期只做终端打印检测、未真推送,没接极光 token 的设备也要检出。
- 心跳超时阈值由 worker 的 `timeout_minutes` 决定(不在表里)。
- 心跳超时阈值由 worker 的 `timeout_minutes` 决定(不在表里);#107 起默认 **1 小时**(原 10 分钟误报率高:息屏/省电模式下心跳会正常停发)
+25 -11
View File
@@ -1,14 +1,14 @@
# feedback — 用户帮助与反馈
# feedback — 用户帮助与反馈(含审核发奖)
> 模型 `app/models/feedback.py` · 仓库 `app/repositories/feedback.py` · 接口 [feedback](../api/feedback.md) · admin [admin-feedbacks-list](../api/admin-feedbacks-list.md) / [admin-feedback-handle](../api/admin-feedback-handle.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/feedback.py` · 仓库 `app/repositories/feedback.py` · 接口 C 端 [feedback](../api/other/feedback.md) / [feedback-records](../api/other/feedback-records.md);admin `GET /admin/api/feedbacks``/summary``POST /{id}/approve|reject|handle`(`app/admin/routers/feedback.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
App「帮助与反馈」每次提交写一行。`content` 必填;`contact` 原必填,**原型改版后客户端不再采集,新数据存空串**(列保持 NOT NULL、免迁移,历史数据仍有值);`images` 为可选截图(≤6 张)。后台人工处理后置 `handled`。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈。
App「帮助与反馈」每次提交写一行。2026-06 起演进为**轻审核工单**:运营在后台采纳(`adopted`,可发金币)/拒绝(`rejected`,填原因)并可写**运营回复**(#105),C 端「我的反馈」列表把状态与回复展示给用户;提交侧自动采集**来源/场景 + 端环境**(App 版本/机型/ROM/Android 版本,#94)供排障。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈。
## 用在哪 / 增删改查
- **C(插入)**:`POST /api/v1/feedback`(multipart:`content` + 可选 `contact` + 可选 `images`;`create_feedback`)。截图`core.media``/media/feedback/` 拿相对路径,再随反馈写入,`status='new'`
- **U(更新)**:admin 处理反馈 `update_feedback_status``status='handled'`(同事务写 `admin_audit_log`)。
- **C(插入)**:`POST /api/v1/feedback`(multipart:`content` + 可选 `contact`/`images`/`source`/`scene`;客户端自动带 `app_version`/`device_model`/`rom_name`/`android_version`)→ `status='pending'`。截图经 `core.media``/media/feedback/`
- **U(更新,admin,均写 `admin_audit_log`)**:`approve`(→`adopted`,可选发金币 `reward_coins`,走 `grant_coins` 同事务)/ `reject`(→`rejected`,`reject_reason`)/ `handle`(→`handled`,旧口径「标记已处理」保留)。三者均可写 `admin_reply`(用户可见回复)与 `review_note`(内部备注)。
- **D**:无。
- **R**:admin 反馈列表(可按 `status` 筛)。C 端当前无"我的反馈列表"读接口
- **R**:C 端 `GET /api/v1/feedback/records`(我的反馈历史,展示状态/回复/奖励);admin 列表(状态/来源筛选)+ `summary`(各状态计数)
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
@@ -16,17 +16,31 @@ App「帮助与反馈」每次提交写一行。`content` 必填;`contact` 原
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 提交用户 |
| `content` | Text | NOT NULL | 反馈正文 |
| `contact` | String(128) | NOT NULL | 联系方式(微信/QQ/手机)。客户端改版后不再采集,新数据为空串;列仍 NOT NULL |
| `images` | JSON | nullable | 截图相对 URL 列表 `/media/feedback/...`;无图为 NULL |
| `status` | String(16) | NOT NULL, default `new` | 取值:`new`(待处理)/ `handled`(已处理) |
| `contact` | String(128) | NOT NULL | 联系方式。客户端改版后不再采集,新数据为空串;列仍 NOT NULL |
| `source` | String(16) | NOT NULL, default `profile`, index | 提交入口来源(#105):`profile`(设置/我的页普通反馈)/ 比价场景值;客户端显式传优先,未传由 `scene` 有无派生 |
| `scene` | String(32) | nullable | 比价反馈的「问题场景」(找错商品/优惠不对/比价太慢…,#105);比价结果页反馈才有,普通反馈为 NULL |
| `images` | JSON | nullable | 截图相对 URL 列表 `/media/feedback/...` |
| `app_version` | String(32) | nullable | 提交时 App versionName(#94) |
| `device_model` | String(64) | nullable | 机型 Build.MODEL(#94) |
| `rom_name` | String(32) | nullable | ROM(OemDetector:ColorOS/MIUI/…,#94) |
| `android_version` | String(16) | nullable | Android 版本(#94) |
| `status` | String(16) | NOT NULL, default `pending`, index | `pending`(待处理)/ `adopted`(已采纳,#94)/ `rejected`(已拒绝)/ `handled`(旧「已处理」口径,兼容保留;历史另有 `new`) |
| `reject_reason` | String(256) | nullable | 拒绝原因(用户可见) |
| `reward_coins` | Integer | nullable | 采纳发的金币数(未发为 null;发币走 `coin_transaction`) |
| `review_note` | String(256) | nullable | 运营内部备注(不外露) |
| `admin_reply` | String(256) | nullable | **运营回复**(用户可见,#105;C 端反馈历史展示) |
| `reviewed_by_admin_id` | Integer | nullable | 审核管理员 id(软指 `admin_user.id`) |
| `reviewed_at` | DateTime(tz) | nullable | 审核时间 |
| `created_at` | DateTime(tz) | server_default now(), index | 提交时间 |
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- admin 处理时`admin_audit_log` 记录(`target_type='feedback'``target_id`=本行 id)。
- admin 审核动作`admin_audit_log` 记录(`target_type='feedback'`);`reviewed_by_admin_id` 软指 `admin_user.id`(无硬 FK)。
- 采纳发奖时写 `coin_transaction`(发币入口 `grant_coins` 同事务)。
## 索引与约束
- PK `id`;index `user_id``created_at`
- PK `id`;index `user_id``status``created_at`
## 注意
- `images` 用通用 `JSON`(本表**未**用 JSONB variant,与 comparison/savings 不同)。
- 状态机是单向的:`pending → adopted/rejected/handled``approve`/`reject` 只接受 `pending`(或历史 `new`)态,重复审核报 400;旧口径 `handle` **幂等不校验原状态**(重复调用结果一致)。
+42
View File
@@ -0,0 +1,42 @@
# invite_cash_transaction — 邀请奖励金流水账本(分)
> 模型 `app/models/wallet.py`(`InviteCashTransaction`) · 仓库 `app/repositories/wallet.py`(`grant_invite_cash` / 提现路径按 `source` 分账) · 接口 [wallet-account](../api/wallet/wallet-account.md)(余额)/ [wallet-withdraw](../api/wallet/wallet-withdraw.md)(`source=invite_cash` 提现) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
**邀请奖励金**(现金,分)的流水账本,与金币兑换的 `cash_transaction` **物理隔离**、结构同构:`balance_after_cents` 记的是 `coin_account.invite_cash_balance_cents`。隔离原因:邀请奖励金有独立的发放口径(好友**比价并下单**才发,#113)与独立的提现对账(`withdraw_order.source='invite_cash'`,#121),混在现金账本里无法分账校验。#82 新增(2026-06-27)。
## 用在哪 / 增删改查
- **C(插入)**:每次奖励金变动一笔:
| 动作 / endpoint | `biz_type` | `amount_cents` | `ref_id` 指向 |
|---|---|---|---|
| 好友比价并下单发奖(`POST /order/report``invite.try_reward_on_compare``grant_invite_cash`) | `invite_reward` | + | 被邀请人 `user.id`(字符串) |
| 发起提现 `POST /wallet/withdraw`(`source=invite_cash`) | `invite_withdraw` | | `withdraw_order.out_bill_no` |
| 该提现失败/审核拒绝退款 | `invite_withdraw_refund` | + | `withdraw_order.out_bill_no` |
| admin 手动调整 `POST /admin/api/users/{id}/cash`(`account=invite_cash`,#95) | `admin_grant` / `admin_deduct` | + / | null(原因记 `remark`=`admin:<reason>`) |
- **U / D**:无。账本只追加。
- **R**:admin 用户 360 / 提现资金账本校验 `GET /admin/api/withdraws/ledger-check`(按 `source` 分账对账,#121);C 端邀请页战绩(`invite.get_reward_stats`:余额 + 累计已提)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(= 邀请人) |
| `amount_cents` | Integer | NOT NULL | 本笔变动(分);正=入账(发奖/退款),负=出账(提现) |
| `balance_after_cents` | Integer | NOT NULL | 本笔后奖励金余额(= 当时 `coin_account.invite_cash_balance_cents`) |
| `biz_type` | String(32) | NOT NULL | `invite_reward` / `invite_withdraw` / `invite_withdraw_refund` |
| `ref_id` | String(64) | nullable | 见上表:发奖=被邀请人 id;提现/退款=`withdraw_order.out_bill_no` |
| `remark` | String(128) | nullable | 用户可见备注(如「好友比价奖励」「提现到微信零钱(待审核)」) |
| `created_at` | DateTime(tz) | server_default now(), index | |
## 关系 / Join Key
- `user_id``user.id`(多对一,硬 FK)。
- `ref_id` →(withdraw 类)`withdraw_order.out_bill_no`(软关联;该单 `source='invite_cash'`)/(invite_reward)被邀请人 `user.id`
- 与 `invite_relation` 的联动:发奖同事务置 `invite_relation.compare_reward_granted=true`(幂等闸,一个被邀请人只发一次)。
## 索引与约束
- PK `id`;index `user_id``created_at`
## 注意
- **唯一变动入口 `wallet.grant_invite_cash`**:更新 `coin_account.invite_cash_balance_cents` + 写本表,**不 commit**,调用方同事务提交(与 `grant_coins` 同款约定)。
- 提现按 `source` 走不同账本:`coin_cash``cash_transaction`,`invite_cash` → 本表;`withdraw_order` 两种共用一张表靠 `source` 列区分。
+17 -13
View File
@@ -2,15 +2,16 @@
> 模型 `app/models/invite.py`(`InviteRelation`) · 仓库 `app/repositories/invite.py` · 接口 `app/api/v1/invite.py`:`POST /api/v1/invite/bind`(写)、`GET /api/v1/invite/me`(战绩)、`GET /api/v1/invite/invitees`(列表) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
一行 = 一次**成功的**邀请绑定。被邀请人(B)用邀请人(A)的邀请码完成绑定后写入,**注册即生效**:同一事务里给 A、B 各发 1 万金币(= 1 元,可提现)。数据来自 `bind()`,触发它的归因来源有三种(`channel`)。这是邀请功能的**结果表 / 账本**;邀请码本身存在 `user.invite_code`,不在这张表。
一行 = 一次**成功的**邀请绑定。被邀请人(B)用邀请人(A)的邀请码完成绑定后写入,**绑定关系注册即生效,但绑定本身不发奖**(#113 起):发奖后置到 B **完成比价并实际下单**(`POST /order/report``try_reward_on_compare`),给 A 发**邀请奖励金**(入独立账本 [invite_cash_transaction](./invite_cash_transaction.md),非金币),本表 `compare_reward_granted` 做幂等闸。数据来自 `bind()`,触发它的归因来源有三种(`channel`)。这是邀请功能的**结果表 / 账本**;邀请码本身存在 `user.invite_code`,不在这张表。
## 用在哪 / 增删改查
- **C(插入)**:`POST /api/v1/invite/bind`(`bind_invite`)→ `invite_repo.bind()`。过四道防线后建一行 `status='effective'`,**同事务**复用 `wallet.grant_coins` 给双方发金币(`grant_coins` 只 flush,由 `bind()` 统一 commit)→ "建关系 + 双方加金币"原子
- **幂等键 = `invitee_user_id` 唯一**:一个 B 只能被绑一次,重复请求返回 `already_bound`,不重复发奖(仿 `ad_reward_record.trans_id` 思路)。并发下两请求同时插同一 invitee → 后者撞唯一约束 `IntegrityError`,`bind()` rollback 后改判 `already_bound` 兜底。
- 四道防线(`bind()` 内):① invitee 已绑 → `already_bound`;② 无效码 / 邀请人非 `active``invalid_code`;③ 自邀(inviter==invitee)→ `self_invite`;④ 新人闸 `_is_new_user`(B 的 `created_at``rewards.INVITE_NEW_USER_WINDOW_HOURS`=72h 内)→ 否则 `not_eligible`只有全过才插行 + 发奖。
- **U / D**:无。这张表只增不改不删(纯账本)
- **C(插入)**:`POST /api/v1/invite/bind`(`bind_invite`)→ `invite_repo.bind()`。过四道防线后建一行 `status='effective'`**#113 起绑定只建关系、不发奖**(`inviter_coin`/`invitee_coin` 新行恒 0)
- **幂等键 = `invitee_user_id` 唯一**:一个 B 只能被绑一次,重复请求返回 `already_bound`(仿 `ad_reward_record.trans_id` 思路)。并发下两请求同时插同一 invitee → 后者撞唯一约束 `IntegrityError`,`bind()` rollback 后改判 `already_bound` 兜底。
- 四道防线(`bind()` 内):① invitee 已绑 → `already_bound`;② 无效码 / 邀请人非 `active``invalid_code`;③ 自邀(inviter==invitee)→ `self_invite`;④ 新人闸 `_is_new_user`(B 的 `created_at``rewards.INVITE_NEW_USER_WINDOW_HOURS`=72h 内)→ 否则 `not_eligible`
- **U(发奖,#113)**:B 比价后下单 `POST /order/report``try_reward_on_compare(invitee_user_id)`:`compare_reward_granted=False` 才发 → 置 `True` + 记 `compare_reward_cents`/`compare_rewarded_at`,**同事务** `wallet.grant_invite_cash` 给 A 入邀请奖励金 → "置闸 + 入账"原子,一个 B 只发一次
- **D**:无。
- **R**:
- `GET /api/v1/invite/me`(`my_invite`)→ `get_stats(inviter_id)`:`count(*)` 得已邀人数、`sum(inviter_coin)` 得累计金币。
- `GET /api/v1/invite/me`(`my_invite`)→ `get_stats(inviter_id)`:`count(*)` 得已邀人数、`sum(inviter_coin)` 得累计金币(历史口径);另调 `get_reward_stats` 出邀请奖励金余额/累计已提(读 `coin_account.invite_cash_balance_cents` + `withdraw_order(source=invite_cash)`)
- `GET /api/v1/invite/invitees`(`my_invitees`)→ `get_invitees(inviter_id, limit, offset)`:join `user` 出被邀请人列表(倒序分页),名字降级兜底 `nickname → wechat_nickname → 脱敏手机号`,`coins``inviter_coin`
## 字段
@@ -20,15 +21,18 @@
| `inviter_user_id` | Integer | FK→user.id, index, NOT NULL | 邀请人(A) |
| `invitee_user_id` | Integer | FK→user.id, **UNIQUE**, index, NOT NULL | 被邀请人(B)。**唯一 = 幂等键**,一个 B 只能被归因一次 |
| `channel` | String(16) | NOT NULL, default `clipboard` | 归因来源:`clipboard`(剪贴板自动)/ `manual`(手动填码)/ `fingerprint`(指纹反查兜底)。入库前 `[:16]` 截断 |
| `status` | String(16) | NOT NULL, default `effective` | 当前只有 `effective`(注册即生效)。**预留** `pending`/`effective`:将来若改"完成首单才生效"时启用 |
| `inviter_coin` | Integer | NOT NULL, default 0 | 本次给 A 发的金币(记账留痕,=`rewards.INVITE_INVITER_COINS`=10000) |
| `invitee_coin` | Integer | NOT NULL, default 0 | 本次给 B 发的金币(=`rewards.INVITE_INVITEE_COINS`=10000) |
| `status` | String(16) | NOT NULL, default `effective` | 当前只有 `effective`(绑定关系注册即生效)。「发奖后置」没有走 status,而是用下面的 `compare_reward_granted` 闸表达 |
| `inviter_coin` | Integer | NOT NULL, default 0 | **历史留痕**(#113 前"绑定即发金币"时代给 A 发的金币,当时=10000);#113 起新绑定恒 0 |
| `invitee_coin` | Integer | NOT NULL, default 0 | 同上,给 B 发的金币历史留痕;新绑定恒 0 |
| `compare_reward_granted` | Boolean | NOT NULL, default false | **发奖幂等闸**(#113):B 首次「比价并下单」后置 true,一个 B 只给 A 发一次邀请奖励金 |
| `compare_reward_cents` | Integer | NOT NULL, default 0 | 实发的邀请奖励金(分,留痕;改常量不影响历史行) |
| `compare_rewarded_at` | DateTime(tz) | nullable | 发奖时间 |
| `created_at` | DateTime(tz) | server_default now(), index | 绑定时间(= 列表倒序键) |
## 关系 / Join Key
- `inviter_user_id``user.id`(硬 FK,多对一):一个 A 可邀多个 B。
- `invitee_user_id``user.id`(硬 FK,**一对一**,唯一约束):一个 B 至多一行。
- 发金币`coin_transaction`:`biz_type='invite_inviter'`(给 A,`ref_id=invitee.id`)/ `biz_type='invite_invitee'`(给 B,`ref_id=inviter.id`),双方 `ref_id` 互指对方便于对账
- 流水关联:**#113 前**的绑定发金币落 `coin_transaction`(`biz_type='invite_inviter'`/`'invite_invitee'`,`ref_id` 互指对方)——现为历史类型,新绑定不再产生;**#113 起**发奖落 [`invite_cash_transaction`](./invite_cash_transaction.md)(`biz_type='invite_reward'`,`ref_id=被邀请人 id`)
- `get_invitees``InviteRelation JOIN user ON user.id = invitee_user_id` 取被邀请人资料;`total` 单独 `count``has_more`
## 索引与约束
@@ -39,8 +43,8 @@
## 注意
- **防重复发奖三道**(`repositories/invite.py` docstring):① `invitee_user_id` 唯一(应用层 `_relation_of_invitee` 先查 + DB 唯一约束并发兜底);② 自邀屏蔽;③ 手机号天然唯一(每个 B = 一个真实手机号账号)= 限制刷量规模。
- **`status` 当前恒为 `effective`**:产品取"注册即生效"而非"完成首单才生效",`pending` 取值是为后者预留、目前不写入
- **`inviter_coin`/`invitee_coin` 是留痕字段**:写死当时发的金币值,即便日后改奖励常量,历史行仍保留发奖时的额度,便于对账
- **`status` 当前恒为 `effective`**(绑定关系维度);「发奖后置」由 `compare_reward_granted` 闸表达,没有引入 `pending` 状态
- **金币留痕字段已冻结**:`inviter_coin`/`invitee_coin` 只反映 #113 前旧口径的历史发放额,便于对账;新奖励额看 `compare_reward_cents`
- **`channel` 三种取值**:`clipboard`(deferred-deeplink 主路径,落地页写剪贴板、首启读回)/ `manual`(用户在邀请页手输)/ `fingerprint`(剪贴板被覆盖时走指纹兜底,见 [invite_fingerprint](./invite_fingerprint.md));三者都汇入同一个 `bind()`,只 `channel` 不同。
- **风控缺口(#24 设计文档「上线前还差什么」标注)**:1 万金币可提现且**邀请人无总数上限**,接码平台批量注册新号绑同码即可刷;上线前需加 inviter 上限 + 基础风控。当前 MVP 仅靠"手机号唯一 + 72h 新人闸"挡
- **风控演进**:#24 时代的缺口是"注册即发 1 万可提现金币、接码批量刷"——**#113 把发奖后置到真实比价+下单**(且发的是邀请奖励金独立账本),批量注册空号不再直接得利,刷奖成本显著抬高;inviter 总数上限等进一步风控仍待补
- **alembic 多 head**:本表迁移 `invite_code_and_relation`(`down_revision=11a1d08c6f55`)与 `coupon_state_tables` 是同一父的兄弟迁移,合 main 前需建 merge 迁移,否则 prod `alembic upgrade head``Multiple head revisions`(#24 设计文档已警示)。
+4 -3
View File
@@ -1,6 +1,6 @@
# withdraw_order — 提现单(现金 → 微信零钱)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-withdraw](../api/wallet-withdraw.md) / [wallet-withdraw-status](../api/wallet-withdraw-status.md) / [wallet-withdraw-orders](../api/wallet-withdraw-orders.md) · admin [admin-withdraws-list](../api/admin-withdraws-list.md) / [admin-withdraw-refresh](../api/admin-withdraw-refresh.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-withdraw](../api/wallet/wallet-withdraw.md) / [wallet-withdraw-status](../api/wallet/wallet-withdraw-status.md) / [wallet-withdraw-orders](../api/wallet/wallet-withdraw-orders.md) · admin [admin-withdraws-list](../api/admin/withdraws/admin-withdraws-list.md) / [admin-withdraw-refresh](../api/admin/withdraws/admin-withdraw-refresh.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
用户把现金余额提到微信零钱的工单。**含人工审核**(2026-06 起):发起即扣现金、进 `reviewing` 待审核、**不打款**;管理员后台审核通过才真正发起微信商家转账,拒绝则退款。
@@ -26,7 +26,8 @@ reviewing ──admin 审核拒绝──▶ rejected(已退款)
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单键)。客户端可传(`[0-9A-Za-z_-]{8,32}`),不传则服务端 `uuid4().hex`。**被 `cash_transaction.ref_id` 引用** |
| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单键)。客户端可传(`[0-9A-Za-z_-]{8,32}`),不传则服务端 `uuid4().hex`。**被 `cash_transaction.ref_id` / `invite_cash_transaction.ref_id` 引用**(按 `source` 落对应账本) |
| `source` | String(16) | NOT NULL, default `coin_cash` | 提现账户来源(#121 分账):`coin_cash`(金币兑换的现金,扣 `coin_account.cash_balance_cents`、流水落 `cash_transaction`)/ `invite_cash`(邀请奖励金,扣 `invite_cash_balance_cents`、流水落 [`invite_cash_transaction`](./invite_cash_transaction.md)) |
| `amount_cents` | Integer | NOT NULL | 提现金额(分) |
| `user_name` | String(64) | nullable | 提现实名;微信**达额转账要求实名**,发起时存下、审核打款时传给微信 |
| `status` | String(16) | NOT NULL, default `reviewing` | 归一化状态:`reviewing`(待审核,已扣款未打款)/ `pending`(打款在途)/ `success` / `failed`(打款失败已退)/ `rejected`(审核拒绝已退) |
@@ -39,7 +40,7 @@ reviewing ──admin 审核拒绝──▶ rejected(已退款)
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- `out_bill_no` ← 被 `cash_transaction.ref_id` 引用(发起 `withdraw` 一笔 ,失败/拒绝 `withdraw_refund` 一笔 +)。
- `out_bill_no` ← 被流水 `ref_id` 引用,**账本按 `source` 分**:`coin_cash` 单 → `cash_transaction`(`withdraw` / `withdraw_refund` +);`invite_cash` 单 → `invite_cash_transaction`(`invite_withdraw` / `invite_withdraw_refund` +)。admin `GET /admin/api/withdraws/ledger-check` 按 source 分账做「单 ↔ 流水」对账(#121)。
- 打款方式依赖 `wechat_transfer_authorization`(用户有生效授权 → 免确认转账,否则确认模式)。
## 索引与约束
+102 -97
View File
@@ -3,7 +3,7 @@
> 域名:`app-api.shaguabijia.com`(HTTPS,nginx 反代)
> 仓库:`shaguabijia-app-server`
> 接口协议详见 [`docs/api/`](./api/)(索引 + 一接口一文件)
> 最后更新:2026-06-23(§7 数据模型改为指向 OVERVIEW、§5 美团 CPS 对齐多 tab feed/4 接口、§6 厘清 coupon/step 有写库副作用 ≠ 纯透传壳、§6.5 补 `/internal/app-version`、Alembic 迁移数更新)
> 最后更新:2026-07-09(§1/§6.2 比价透传改「软鉴权 + trace_id 签发 + harvest 落库」不再是纯透传;§3 目录树补全到当前 22 个 v1 路由 + core 三 worker + utils/geo;§1 补 邀请奖励金/埋点/领券看板/设备存活;§5 美团 feed/top-sales 按城市过滤 #116;§7 45 张表、98 迁移;§8 部署对齐 ecs1 git-clone + systemd 定时器;§10 刷新。上一次 2026-06-23)
---
@@ -13,17 +13,23 @@
| 能力 | 说明 |
|---|---|
| **账号与登录** | 极光一键登录 + 短信验证码登录(mock)→ 签发 JWT |
| **账号与登录** | 极光一键登录 + 短信验证码登录(SMS_MOCK 切换)→ 签发 JWT;新手引导标记(设备+账号,含用户自助重置 #114) |
| **用户资料** | 昵称 / 头像(上传图片含魔数嗅探) / 注销账号(软删除+匿名化) |
| **美团 CPS 选品** | 透传美团联盟优惠券(外卖/到店)、点击换推广链接(分佣)未配凭证降级返空 |
| **领券透传** | `/coupon/step` 透传到 pricebot-backend(一键领券核心,MVP 不鉴权,前端已接通) |
| **外卖比价透传** | `/intent/recognize` + `/price/step` 透传 pricebot-backend(food MVP,MVP 不鉴权) |
| **金币 / 现金钱包** | 金币账户/流水/兑换/微信绑定/提现单 11 端点 |
| **美团 CPS 选品** | feed 多 tab(rec 离线库 / distance 实时)+ 销量榜,**按设备坐标离线反查城市、只出同城券**(#116);点击换推广链接(分佣);未配凭证降级返空 |
| **领券透传** | `/coupon/step` 透传到 pricebot-backend(一键领券核心,不鉴权,前端已接通)+ best-effort 写领券三表;`/coupon/session` 领券任务流水(admin 看板,#99) |
| **外卖比价透传 + 落库** | `/intent/*` + `/price/step` + `/trace/finalize``/trace/epilogue` 透传 pricebot,**软鉴权 + 首帧签发 trace_id + harvest 三段式落 `comparison_record`**(2026-07 起,见 §6.2) |
| **金币 / 现金钱包** | 金币账户/流水/兑换/微信绑定/提现单;**邀请奖励金独立账本**(#82,与现金物理隔离,`source` 分账提现 #121) |
| **好友邀请** | 邀请码/落地页指纹归因;发奖口径=好友**比价并下单**(#113,发邀请奖励金) |
| **签到 + 任务 + 省钱战绩** | 福利模块 |
| **看广告发奖** | 穿山甲 GroMore 激励视频 S2S 回调(SHA256 验签)+ 4 态 CTA 冷却 |
| **帮助与反馈** | 用户提交反馈(含截图)+ 静态 `/media` 服务 |
| **看广告发奖** | 穿山甲 GroMore 激励视频 S2S 回调(SHA256 验签)+ 信息流/Draw 结算(ad_type/feed_scene 分场景)+ 穿山甲后台收益日表拉取(#92) |
| **帮助与反馈** | 反馈工单(来源/场景/端环境采集 + admin 采纳发币/拒绝/运营回复,#94/#105)+ 静态 `/media` 服务 |
| **埋点** | `/analytics/events` 批量上报落 `analytics_event`(#83) |
| **设备存活监控** | 无障碍心跳 + 掉线检出(worker,超时 1h #107)+ 召回 ack;admin 存活看板(#80) |
| **平台配置 / OTA** | `/platform/*`:门面统计、feature flags、广告配置下发、App 版本检查更新(全不鉴权) |
| **CPS 群发联盟** | `/c/{code}` 短链落地 + 微信授权 + 联盟对账(美团+京东 #90),运营台在 admin |
| **运营后台 admin** | 独立子应用(8771,独立 JWT):用户/钱包/提现审核/反馈/大盘/收益报表/领券看板/RBAC 自定义角色(#117/#126)等,全量端点见 [api/README](./api/README.md) Admin 段 |
**本服务自身没有"比价"实现**——比价/领券业务的核心在 pricebot-backend(另一个 repo),本服务对 step 类接口做"透传壳"。无爬虫、无 LLM,但**有钱包/福利等业务模型**(数据模型从早期 1 张 `user`已扩展到 **40 张业务表**,见 §7 / [database/OVERVIEW.md](./database/OVERVIEW.md))。
**"比价/领券"的执行核心在 pricebot-backend**(另一个 repo),本服务对 step 类接口做透传;但 2026-07 起比价透传**不再是纯壳**——app-server 直接负责比价记录落库(§6.2)。无爬虫、无 LLM,业务模型已扩展到 **45 张业务表**(见 §7 / [database/OVERVIEW.md](./database/OVERVIEW.md))。仓库根另有 `h5/`(mine 我的页 + shared bridge/api,#89,H5 化的我的页静态资源)。
---
@@ -52,81 +58,56 @@
```
app/
├── main.py # FastAPI 入口:注册全部 router、CORS、/health、lifespan
├── main.py # FastAPI 入口:注册 21 个 v1 router + 4 个 internal router、CORS、
│ # lifespan(预热 pricebot client + 离线地理库,启 3 个后台 worker)、
│ # /media 静态服务 + /media/shaguabijia.apk 官网直链(强制下载头)
├── api/
│ ├── deps.py # 共享依赖:get_current_user(鉴权)、get_db(注入 session)
── v1/ # 接口层(薄):解析请求 → 调 repositories/integration → 组装响应 + HTTP 错误码
├── auth.py # 登录 6 端点(极光一键登录 / 短信 send+login / refresh / me / logout)
│ ├── user.py # 用户资料 3 端点(改昵称 / 上传头像 / 注销账号)
│ ├── feedback.py # 帮助与反馈 1 端点(提交反馈含截图)
│ ├── coupon.py # 领券透传 /coupon/step(转发 pricebot,MVP 不鉴权)
│ ├── compare.py # 外卖比价透传 /intent/recognize + /price/step(转发 pricebot,MVP 不鉴权)
│ ├── compare_record.py# 比价记录 3 端点(上报 /compare/record + 列表 /compare/records + 详情;鉴权,区别于上面透传)
│ ├── meituan.py # 美团 3 端点 + feed 拼接(_interleave / _TOPIC_ROUNDS),未配 MT_CPS 凭证降级返空
│ ├── wallet.py # 钱包/提现 11 端点(余额/流水/兑换/绑微信/提现/查单)
│ ├── signin.py # 签到 2 端点(状态 / 执行签到)
│ ├── tasks.py # 一次性任务 2 端点(列表 / 领取)
│ ├── savings.py # 省钱 3 端点(汇总 / 战绩 / 明细)
│ └── ad.py # 看广告发奖 3 端点(穿山甲 S2S 回调 / 进度+本轮冷却 / 联调发奖)
├── schemas/ # Pydantic:API 收发的数据契约(与客户端对齐字段看这里)
│ ├── auth.py
── user.py # 改昵称请求 + OkResponse
├── feedback.py # 反馈出参(请求是 multipart,在 router 直接校验)
├── meituan.py
├── welfare.py # 钱包/签到/任务/省钱 收发模型
│ ├── compare_record.py # 比价记录上报/列表/详情 收发模型(字段对齐 pricebot calibration + done.params)
│ └── ad.py # 看广告发奖收发模型
├── integrations/ # 外部服务/SDK 客户端(重逻辑:签名/加解密/外部 HTTP)
├── jiguang.py # 极光 REST 验 token + RSA 解密(多 padding 试错)
│ ├── meituan.py # 美团 CPS 网关签名 + query_coupon / get_referral_link
│ ├── sms.py # 短信验证码(mock,进程内存冷却表)
│ ├── pangle.py # 穿山甲激励视频发奖回调验签(SHA256,2026-05 从 core 移入)
── wxpay.py # 微信支付 V3 商家转账(提现)+ code 换 openid(2026-05 从 core 移入)
├── core/ # 基础设施(无外部业务集成)
│ ├── config.py # pydantic-settings
├── security.py # JWT 签发/校验
├── ratelimit.py # 同 IP 滑动窗口限流依赖
│ ├── rewards.py # 发奖/兑换/提现额度等业务常量与换算(2026-05 加 VIDEO_ROUND_REQUIRED_COUNT / VIDEO_ROUND_COOLDOWN_SECONDS)
│ ├── media.py # 用户上传文件(头像/反馈截图)落盘 + 魔数嗅探 + 随机文件名
│ └── logging.py
├── repositories/ # 数据访问 + 事务(早期叫 crud,2026-05 统一并入此目录)
│ ├── user.py # get_user_by_id / by_phone / upsert_for_login / update_nickname / set_avatar_url / soft_delete_account
│ ├── feedback.py # 提交反馈写库
│ ├── wallet.py # 账户/流水/兑换/提现单(调 integrations/wxpay)
│ ├── signin.py # 签到记录 / 连续天数 / 档位
│ ├── task.py # 一次性任务领取
│ ├── savings.py # 省钱汇总 / 战绩 / 明细
│ ├── comparison.py # 比价记录 upsert(user_id+trace_id 幂等)+ best/saved/status 派生 + 分页
│ └── ad_reward.py # 看广告发奖(按 trans_id 幂等 + 每日上限 + 本轮冷却派生)
├── models/ # ORM 表结构
│ ├── user.py # user(含微信 openid/nickname/avatar)
│ ├── feedback.py # 用户反馈(content/contact/images JSON 列/status)
│ ├── wallet.py # 金币账户 / 金币流水 / 现金流水 / 提现单
│ ├── signin.py # 签到记录
│ ├── task.py # 任务领取记录
│ ├── savings.py # 省钱明细 / 店铺菜品 / dishes(PG 上 JSONB)
│ ├── comparison.py # 比价记录(完整明细;含 4 个 JSON(B) 列 + raw_payload;独立于 savings)
│ └── ad_reward.py # 看广告发奖记录
└── db/
├── base.py # DeclarativeBase
└── session.py # engine + get_db(非 SQLite 时启 pool: size=10/overflow=20/recycle=3600)
│ ├── deps.py # 共享依赖:get_current_user / OptionalUser(软鉴权) / get_db
── internal/ # server→server 内部端点(X-Internal-Secret):app_version / launch_confirm / price / store
└── v1/ # 接口层(薄),21 个路由文件:
│ ├── auth.py user.py feedback.py # 登录 / 资料+引导(含 reset #114) / 反馈工单
│ ├── coupon.py # 领券透传 step + session 流水 + prompt/completed 频控族
│ ├── compare.py # 比价透传 + trace_id 签发 + harvest 落库(§6.2)
│ ├── compare_record.py compare_milestone.py # 比价记录(鉴权兜底上报/列表/详情/stats)+ 里程碑
│ ├── meituan.py # CPS 选品 4 端点(feed 多 tab / top-sales 按城市 #116)
│ ├── wallet.py wxpay.py # 钱包/提现(source 分账 #121)/transfer-auth 族 + 微信回调 stub
│ ├── signin.py tasks.py savings.py # 福利
│ ├── ad.py # 激励视频 S2S + 信息流/Draw 结算 + eCPM/noshow/watch
│ ├── invite.py order.py report.py # 邀请 / 支付归因(触发邀请发奖 #113) / 上报更低价
│ ├── analytics.py device.py platform.py # 埋点 #83 / 无障碍存活 #65 / 门面+flags+ad-config+OTA
│ └── cps_redirect.py # /c/{code} 短链落地 + 微信 OAuth(挂域名根)
├── admin/ # 运营后台独立子应用(8771,独立 JWT;app.main 不 import 它)
│ ├── main.py deps.py security.py permissions.py # 入口 / 鉴权链 / RBAC 页面目录(#117)
── routers/ # 23 个路由:users wallet withdraw feedback(+qr) dashboard comparison
# coupon_data device_liveness event_logs price_report onboarding
# ops_marquee_seed ops_stat_config ad_audit ad_revenue ad_config
# config admins roles(#126) audit auth cps
├── schemas/ # Pydantic 契约(17 个文件,与客户端对齐字段看这里)
├── integrations/ # 外部 SDK(重逻辑):jiguang / meituan(S-Ca 签名) / sms / pangle / wxpay
├── core/ # 基础设施:config(+config_schema 运营可配项定义) / security / ratelimit /
│ # rewards / media / logging / ad_cooldown / test_account(测试号免验证码 #69)
│ ├── pricebot_router.py # pricebot 多实例一致性 hash(ketama 1000 虚节点,按 trace_id 亲和)
│ ├── pricebot_client.py # 共享 httpx AsyncClient 单例(#87:免每请求重建 SSL 上下文、绕进程代理)
│ ├── withdraw_reconcile_worker.py # 提现对账 worker(lifespan 启动)
── heartbeat_monitor_worker.py # 无障碍心跳掉线检出 worker(#65,超时 1h #107)
│ └── daily_exchange_worker.py # 金币自动兑换 worker
├── utils/ # geo.py(离线经纬度→城市反查,~2.5M 行 CSV+KDTree,启动预热)
# + meituan_city.py(城市→美团 city_id,#116)
├── repositories/ # 数据访问 + 事务(28 个文件;comparison.py 含 harvest_running/done/abort 三段)
├── models/ # ORM 表结构(34 个文件,45 张业务表,见 database/OVERVIEW.md)
└── db/ # DeclarativeBase + engine/get_db(非 SQLite 启 pool 10+20)
alembic/ # 数据库迁移(versions/ 11+ 个迁移含 feedback_table / convert_dishes_jsonb / merge 等)
deploy/ # systemd(.service) + nginx(.conf)
secrets/ # 极光 RSA 私钥 / 微信支付证书(不入 git,仅 .gitkeep 占位)
scripts/
├── init_postgres.py # 一键 PG 初始化:建用户 + 建库 + 写 .env + 跑迁移(2026-05 新增,见已知 bug §10)
├── migrate.sh # 单独跑 alembic upgrade head(部署/CI 用)
├── reset_signin.py # 重置今日签到
├── reset_welfare.py # 重置福利数据
├── reconcile_withdraws.py # 提现对账
└── sim_pangle_callback.py # 模拟穿山甲回调
tests/ # pytest(auth / health / welfare / withdraw / ad_reward / coupon_proxy / compare_proxy)
run.sh # 本地启动脚本(自动先跑迁移再起服务)
docs/api/ # API 接口文档(索引 README + 一接口一文件)
docs/integrations/ # 集成层实现文档(SDK 签名/加解密/协议细节)
docs/database/数据库迁移.md # Alembic 迁移指南(如何建表/升级/新增迁移)
docs/database/postgres-migration.md # SQLite → PostgreSQL 切换指南(配套 scripts/init_postgres.py)
h5/ # H5 静态页(#89):mine 我的页 + shared bridge/api
alembic/ # 数据库迁移(98 个,含 14+ merge;单 head,见 §7)
deploy/ # systemd:app-server + admin 两服务;定时器 meituan-etl(选品 ETL)/
│ # pangle-revenue(穿山甲收益,每天 10:30 #100)/daily-exchange;nginx 配置
secrets/ # 极光 RSA 私钥 / 微信支付证书(不入 git)
scripts/ # init_postgres / migrate.sh / create_admin / 美团券 ETL(pull_meituan_coupons +
│ # load_meituan_coupon_tsv) / sync_pangle_revenue(#92) / publish_apk /
│ # reconcile_withdraws / reset_* / sim_pangle_callback / seed_mock_*(造数)
tests/ # pytest(外部集成全 monkeypatch,不打真 HTTP)
run.sh # 本地启动(钉定 .venv 解释器 #75,先迁移再起服务)
docs/api/ docs/database/ docs/integrations/ docs/guides/ # 文档(各自带索引)
```
> **命名说明**:`api/v1/``v1` 用于 URL 版本化(移动端无法强制即时升级,需新旧版本并存能力);`integrations` 装外部 SDK 集成、`repositories` 装数据访问、`core` 装基础设施,三者分离。**数据访问层统一在 `repositories/`**(早期叫 `crud/`,2026-05 已整体并入,`crud/` 不再存在)。`coupon.py` 是领券透传,勿与 `meituan.py` 里的 `coupons`(券列表)混淆。
@@ -190,15 +171,15 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
### 5.2 对外四个接口
> 接口级入参/出参/各 tab 行为详见 [api/meituan-feed.md](./api/meituan-feed.md) 等,本节只讲后端形态。
> 接口级入参/出参/各 tab 行为详见 [api/meituan/meituan-feed.md](./api/meituan/meituan-feed.md) 等,本节只讲后端形态。
- `coupons`:对外的搜索/榜单接口(底层 `query_coupon`),**客户端暂未接入**。
- `feed`:首页推荐流,**已是多 tab**(入参 `tab`):
- `rec` 智能推荐:走**离线库 `meituan_coupon`**(筛佣金率≥3%、`DISTINCT ON` 去重、按销量降序分页),**纯库查询、不打美团、不依赖 MT 凭证**(实测同城热销中位佣金 ~0.8%,实时筛≥3% 每页剩 0–1 条又撞 402,故从库出)。不显示距离。
- `rec` 智能推荐:走**离线库 `meituan_coupon`**(筛佣金率≥3%、`DISTINCT ON` 去重、按销量降序分页),**纯库查询、不打美团、不依赖 MT 凭证**(实测同城热销中位佣金 ~0.8%,实时筛≥3% 每页剩 0–1 条又撞 402,故从库出)。不显示距离。**#116 起按城市过滤**:设备经纬度经 `utils/geo`(离线反查,启动预热)+ `meituan_city` 映射成美团 `city_id`,只出同城券;拿不到坐标/城市 → 返空 + `status=degraded`
- `distance` 距离最近:实时拉外卖+到店两路,按用户坐标由近及远。
- 默认(空 tab):旧的逐轮分页混合 feed(2 外卖 + 1 到店交叉,写死 3 页爆款/今日必推/精选+限时,第 4 页返空),**仅老客户端兼容**。
- 任何失败场景返 `200` + 空 `items` + `status=degraded`(不抛 5xx)。
- `top-sales`:独立的销量榜接口,离线库 `meituan_coupon` 按销量降序 + 跨源去重,**不实时打美团**。
- `top-sales`:独立的销量榜接口,离线库 `meituan_coupon` 按销量降序 + 跨源去重 + **同城过滤**(#116,同 rec 的城市反查),**不实时打美团**。
- `referral-link`:换推广链接,客户端取 `link_map["3"]`(deeplink)优先跳美团 App。
- `query_coupon`:底层取数(被 `coupons`/`distance` feed 共用),不直接对外。
@@ -212,7 +193,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
## 6. 领券透传(coupon/step)
产品"一键领券"的核心接入点,**真正的领券逻辑不在本服务**——在另一个 repo `pricebot-backend`(GoalEngine + 事件驱动)。但 `coupon/step` **不是纯透传壳**:它在转发 pricebot 之余,还**best-effort 写库**(纯透传壳是 `compare.py` `intent/recognize``price/step`,它们只转发不写库)。
产品"一键领券"的核心接入点,**真正的领券逻辑不在本服务**——在另一个 repo `pricebot-backend`(GoalEngine + 事件驱动)。但 `coupon/step` **不是纯透传壳**:它在转发 pricebot 之余,还**best-effort 写库**。(比价透传 `compare.py` 2026-07 起同样带落库副作用,见 §6.2——现在全站已没有"只转发不写库"的透传端点,只有 `trace/epilogue` 例外。)
```
客户端 → POST /api/v1/coupon/step (任意 JSON body,含 device_id/trace_id/step)
@@ -231,6 +212,29 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
- **错误**:body 非合法 JSON → 400;pricebot 不可达或返回 5xx → 502。
- **配置**:`PRICEBOT_BASE_URL`(默认 `http://localhost:8000`)、`PRICEBOT_REQUEST_TIMEOUT_SEC`(默认 30s,因领券单帧最多 wait 6s)。
- **现状**:**前端已接通**——首页「去领取」→ `CouponPromptDialog` → 权限检查 → 无障碍引擎 `startCouponClaim` → 循环调本接口,逐张券下发 launch/wait/done。pricebot-backend 不在本目录。
- **配套**:`POST /coupon/session`(#99)由客户端**独立两段上报**(发起建行/收尾更新)落 `coupon_session` 流水,供 admin「领券数据」看板算发起数/完成率/中途流失/耗时——与 step 透传链路解耦。
---
## 6.2 外卖比价透传:软鉴权 + trace_id 签发 + harvest 落库(2026-07)
`compare.py``/intent/recognize``/intent/step``/intent/precoupon/step``/price/step``/trace/finalize``/trace/epilogue` 透传 pricebot 之余,**由 app-server 直接落库比价记录**(不再依赖客户端 POST /compare/record):
```
客户端首帧(可不带 trace_id)
→ compare._forward:app-server 用 uuid 签发 trace_id、注入转发 body、回填响应顶层
→ [harvest_running] 仅 mint 那帧:按 trace_id 建 comparison_record 的 running 行
→ 后续帧原样透传(原始 bytes 快路,不再写库)
→ done 帧(price/step)→ [harvest_done] 写 success/failed + 派生 best_*/saved_amount_cents
→ 用户终止/未识别 → 客户端打 /trace/finalize → [harvest_abort] 写 cancelled/failed(不降级已 success)
→ /trace/epilogue(#112):App 结果页截图透传入 trace,纯透传、不落库
```
- **软鉴权(OptionalUser)**:新客户端带 JWT → 记录绑 `user_id`;老客户端不带 → `user_id` 暂空,由其后续带 JWT 的 `POST /compare/record` 上报补齐(灰度期两条写路径按 `trace_id` reconcile)。
- **写库全 best-effort**:`run_in_threadpool` + 独立 SessionLocal,失败只 warning、绝不连累比价返回(同 coupon.py 口径)。
- **trace_url** 从 pricebot 响应顶层取(pricebot 每帧都带);查看权限按 `user.debug_trace_enabled` 控制。
- **邀请发奖不在 harvest**:#113 口径 = 好友「比价并下单」,发放在 `POST /order/report`(`invite.try_reward_on_compare`,`compare_reward_granted` 幂等闸,发**邀请奖励金**入独立账本)。
- 透传基建:`core/pricebot_router.pick_pricebot`(按 trace_id 一致性 hash 选实例)+ `core/pricebot_client`(共享 httpx 单例,#87)。
---
@@ -240,13 +244,13 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
**启动确认窗兜底样本**(2026-06):国产 ROM 打开别的 App 时弹"想要打开 XX"确认窗,pricebot 对没见过文案(繁体/英文/ROM 改版)的窗用 LLM 兜底放行后,把样本 POST 到 [`/internal/launch-confirm-sample`](../app/api/internal/launch_confirm.py) 落 `launch_confirm_sample` 表(host 包 + 弹窗树 + LLM plan + 设备 locale/机型),供研发定期人工沉淀回 pricebot 的规则 yaml(回到快路径)。**需 pricebot 与 app-server 两边 `.env` 配同一 `INTERNAL_API_SECRET` 才生效**(未配则 pricebot 侧跳过上报、不影响比价/领券)。
> 同类内部回写端点(同走 `X-Internal-Secret`)还有:`/internal/price-observation`(价格观测)、`/internal/store-mapping` + `/store-mapping/lookup` + `/store-mapping/invalidate`(跨平台店铺映射沉淀/反查/失效)等 pricebot 比价资产沉淀;以及 **`/internal/app-version`(OTA)**——**发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256 落 `app_config`,客户端再 `GET /api/v1/platform/app-version` 读做检查更新。完整 6 个内部端点见 [api/internal.md](./api/internal.md)。
> 同类内部回写端点(同走 `X-Internal-Secret`)还有:`/internal/price-observation`(价格观测)、`/internal/store-mapping` + `/store-mapping/lookup` + `/store-mapping/invalidate`(跨平台店铺映射沉淀/反查/失效)等 pricebot 比价资产沉淀;`GET /internal/launch-confirm-samples`(#91,样本列表供 pricebot `distill_launch_confirm.py` 聚合沉淀回静态规则);以及 **`/internal/app-version`(OTA)**——**发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256 落 `app_config`,客户端再 `GET /api/v1/platform/app-version` 读做检查更新。完整 7 个内部端点见 [api/internal/internal.md](./api/internal/internal.md)。
---
## 7. 数据模型
**完整表清单不在本文维护**(避免双份漂移)——共 **40 张业务表** + `alembic_version` 框架表,逐表字段级说明 + 跨表关系/写入路径见 **[database/OVERVIEW.md](./database/OVERVIEW.md)**(总览)与 [database/README.md](./database/README.md)(一表一文件索引)。生产 PG / 开发可回退 SQLite。
**完整表清单不在本文维护**(避免双份漂移)——共 **45 张业务表** + `alembic_version` 框架表,逐表字段级说明 + 跨表关系/写入路径见 **[database/OVERVIEW.md](./database/OVERVIEW.md)**(总览)与 [database/README.md](./database/README.md)(一表一文件索引)。生产 PG / 开发可回退 SQLite。2026-06 下旬以来新增:`coupon_session`(#99)、`analytics_event`(#83)、`invite_cash_transaction`(#82)、`admin_role`(#117)、`ad_pangle_daily_revenue`(#92)。
本文只保留分层与高频维度的速查:数据访问统一在 `repositories/`,ORM 表结构在 `models/`(钱包/福利在 `wallet.py`/`signin.py`/`task.py`、比价在 `comparison.py`、领券今日状态在 `coupon_state.py`、CPS 群发在 `cps_*.py`、pricebot 内部沉淀在 `price_observation.py`/`store_mapping.py`/`launch_confirm_sample.py`、无障碍存活在 `device.py`)。下面单列最常用的 `user` 表字段。
@@ -265,15 +269,15 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
`upsert_user_for_login`:phone 存在则更新 `last_login_at`,不存在则注册(注册即登录)。
**Alembic 迁移**:`alembic/versions/` 当前 **78 个迁移文件**(含 14 个合并迁移),当前单一 head `4dc2af7ebe74`(2026-06-23 合并 #65 device 链 + comparison token 列两条并行链)。多人/多分支并行改表频繁产生多 head,靠 `alembic merge` 收敛回单 head。规模随改表增长,**别背具体链**,以 `alembic history`/`alembic heads` 实时输出为准。详见 [数据库迁移.md](./database/数据库迁移.md)。
**Alembic 迁移**:`alembic/versions/` 当前 **98 个迁移文件**(含 14+ 个合并迁移),当前单一 head `admin_user_pages_override`(2026-07-08,#126)。多人/多分支并行改表频繁产生多 head,靠 `alembic merge` 收敛回单 head。规模随改表增长,**别背具体链**,以 `alembic history`/`alembic heads` 实时输出为准。⚠️ revision id 长度 ≤32 字符(`alembic_version` 列宽,超长部署时截断报错——0.2.1 实踩)。详见 [数据库迁移.md](./database/数据库迁移.md)。
---
## 8. 配置与部署
配置见 `core/config.py`(pydantic-settings 读 `.env`)。分组:环境、`DATABASE_URL`(默认 SQLite,生产应切 PG)、JWT、极光(`JG_*`)、短信(`SMS_MOCK` 默认 true)、美团(`MT_CPS_*`)、**pricebot 上游(`PRICEBOT_BASE_URL` / `PRICEBOT_REQUEST_TIMEOUT_SEC=30` / `PRICEBOT_COMPARE_TIMEOUT_SEC=60`)**、**媒体存储(`MEDIA_ROOT=./data/media` / `MEDIA_URL_PREFIX=/media`)**、CORS
配置见 `core/config.py`(pydantic-settings 读 `.env`)。分组:环境、`DATABASE_URL`(默认 SQLite,生产应切 PG)、JWT(+独立 `ADMIN_JWT_SECRET`)、极光(`JG_*`)、短信(`SMS_MOCK` 默认 true)、美团(`MT_CPS_*`)、**pricebot 上游(`PRICEBOT_BASE_URL` / 多实例 `PRICEBOT_INSTANCES` / `PRICEBOT_REQUEST_TIMEOUT_SEC=30` / `PRICEBOT_COMPARE_TIMEOUT_SEC=60`)**、**内部密钥(`INTERNAL_API_SECRET`,internal 族)**、穿山甲(`PANGLE_*`,含收益 API)、微信支付(`WXPAY_*`)、**媒体存储(`MEDIA_ROOT=./data/media` / `MEDIA_URL_PREFIX=/media`)**、CORS;运营可覆盖的业务常量在 `core/config_schema.py` 定义、`app_config` 表落值(admin `GET/PATCH /config`)
**生产部署**:systemd `shaguabijia-app-server.service`(WorkingDirectory `/opt/shaguabijia-app-server`,`EnvironmentFile=.env`,uvicorn 监听 `127.0.0.1:8770`,`--workers 1`)+ nginx 443 反代 → 8770。**无 Docker**。**PostgreSQL 16** 同机部署
**生产部署(2026-06-06 起在 ecs1)**:systemd 服务 `shaguabijia-app-server.service`(uvicorn `127.0.0.1:8770`,`--workers 1`)+ `shaguabijia-admin.service`(8771),WorkingDirectory `/opt/shaguabijia-app-server`,`EnvironmentFile=.env`,nginx 443 反代 `app-api.shaguabijia.com` → 8770。**无 Docker**。**PostgreSQL 16** 同机部署。代码走 **git clone(main 分支)+ Gitea 只读部署密钥**,日常发布用服务器上的 `deploy` 一键命令 / `release.sh` 发车 / deploy.shaguabijia.com 发车台(不再 rsync 整目录;`secrets/` 私钥证书单独放置、不入 git)
```bash
# 首次 PG 初始化(新机器或新环境):
@@ -281,12 +285,13 @@ ssh server "sudo apt install -y postgresql-16 && sudo systemctl enable --now pos
ssh server "cd /opt/shaguabijia-app-server && .venv/bin/python scripts/init_postgres.py"
# 该脚本会建业务用户 + 建库 + 写 .env 的 DATABASE_URL + 跑 alembic upgrade head
# 后续日常部署:
rsync -avz --exclude='.venv' --exclude='__pycache__' --exclude='data' --exclude='secrets/*.pem' ./ server:/opt/shaguabijia-app-server/
scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/ # 私钥单独传,不入 git
ssh server "cd /opt/shaguabijia-app-server && .venv/bin/alembic upgrade head && systemctl restart shaguabijia-app-server"
# 日常部署(deploy 一键命令等价动作):
ssh server "cd /opt/shaguabijia-app-server && git pull && .venv/bin/alembic upgrade head \
&& systemctl restart shaguabijia-app-server shaguabijia-admin"
```
**systemd 定时器**(`deploy/`,各带 .md 运维手册):`meituan-etl`(美团 CPS 选品 ETL → `meituan_coupon`,rec/top-sales 数据源)、`pangle-revenue`(穿山甲 GroMore 后台收益拉取,每天 10:30,#100)、`daily-exchange`(金币自动兑换;lifespan 里另有 in-process worker,两套并存以部署材料为准)。
完整 PG 切换流程见 [docs/database/postgres-migration.md](./database/postgres-migration.md)。
**生产 checklist(均为上线必查)**:
@@ -325,8 +330,8 @@ conda activate price # 首次:pip install -e .
| logout 无服务端失效 | 靠客户端清 token;后续加 jti 黑名单表(注销账号也是同问题——软删后旧 token 仍能用到自然过期) |
| 美团接口无鉴权 + sid 可覆盖 | 评估加鉴权/锁定 sid(注意首页要求未登录可见) |
| 美团接口未配凭证降级 | 未配 `MT_CPS_APP_KEY` 时 3 端点返空(不报 502),`/feed` 跟"已配但调用失败"路径无法区分——见 [integrations/meituan](./integrations/meituan.md) |
| 领券/比价依赖 pricebot | `coupon/step` / `intent/recognize` / `price/step` 仅透传,真正逻辑在 pricebot-backend;前端已接通领券链路,比价 food MVP 也已接通 |
| agent 系列接口 MVP 不鉴权 | 拿不到 user_id → 无法采集"哪个用户领了/买了什么"用户级画像(商业模式核心资产)。见 [待办与技术债.md](./guides/待办与技术债.md) P1 |
| 领券/比价依赖 pricebot | 执行核心在 pricebot-backend;比价透传 2026-07 起带 harvest 落库(§6.2),领券 `coupon/step` 带三表副作用,均非纯壳 |
| 领券 step 仍不鉴权;比价已软鉴权 | 比价透传族改 OptionalUser:带 JWT 即绑 `user_id`(用户级画像已能落到比价记录);`coupon/step` 仍拿不到 user_id(device 维度),老客户端比价记录靠 `/compare/record` 兜底补绑。见 [待办与技术债.md](./guides/待办与技术债.md) |
| SMS 已接极光(2026-06-03) | real 模式自定义验证码,上线只需 `SMS_MOCK=false`(复用极光凭证)。详见 [integrations/sms](./integrations/sms.md) |
| 短信冷却存内存 | 扩 worker 前需迁移到 Redis |
| `MEDIA_ROOT` 进程内 serve | 头像/反馈截图当前用 FastAPI StaticFiles,生产建议 nginx 直 serve 该目录 |
+7
View File
@@ -37,6 +37,9 @@ dependencies = [
# 邀请指纹归因:解析浏览器 UA 拿手机型号(Build.MODEL),跨端匹配用
"user-agents>=2.2.0",
# 离线经纬度→城市反查(美团 CPS 按城市过滤);会带入 numpy/scipy 依赖
"reverse_geocoder>=1.5.1",
]
[project.optional-dependencies]
@@ -50,6 +53,10 @@ dev = [
[tool.setuptools.packages.find]
include = ["app*"]
# 随包分发的运行时数据文件(美团城市词典;被 app/utils/meituan_city.py 加载)
[tool.setuptools.package-data]
"app.utils" = ["data/*.txt"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
+170
View File
@@ -0,0 +1,170 @@
"""把 meituan_coupon 的线上采样 TSV 灌进本地 SQLite。
用途:本地开发/调试时,把线上 `meituan_coupon` 表的采样数据(tests/meituan_coupon_data.tsv)
灌进 dev (默认 `./data/app.db`),免得每次都实时打美团接口
TSV 说明:
- 制表符分隔,每行一条记录,列顺序与线上 PostgreSQL 物理列一致
(image_size / image_type 是后加的迁移,排在最后两列 与本地 SQLite 一致)
- 空字段 = NULL(文件里没有 `\\N` 标记)
- 个别记录的文本/JSON 字段内含换行,会把一条逻辑行拆成多物理行 累计到 26 重组
- 文件尾部可能有一条被导出截断的残行(列数不足 / raw JSON 不完整),直接跳过
datetime 三列(first_seen/last_seen/updated_at)去掉尾部时区偏移(`+08`),
存成 SQLAlchemy SQLite 上用的朴素格式 `YYYY-MM-DD HH:MM:SS.ffffff`,保证 ORM 能读回
用法:
python scripts/load_meituan_coupon_tsv.py # 默认 TSV + .env 里的库
python scripts/load_meituan_coupon_tsv.py path/to.tsv # 指定 TSV
DATABASE_URL=sqlite:///./data/app.db python scripts/load_meituan_coupon_tsv.py
"""
from __future__ import annotations
import json
import os
import re
import sqlite3
import sys
from pathlib import Path
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
# 列顺序 = TSV 字段顺序 = 本地 SQLite 物理列顺序
COLS = [
"id", "source", "platform", "biz_line", "city_id", "product_view_sign",
"sku_view_id", "name", "brand_name", "sell_price_cents", "original_price_cents",
"head_url", "sale_volume", "sale_volume_num", "commission_percent",
"commission_amount_cents", "poi_name", "available_poi_num", "delivery_distance_m",
"dedup_key", "raw", "first_seen", "last_seen", "updated_at", "image_size", "image_type",
]
NCOL = len(COLS)
# 按列做类型转换(空串 -> None)。未列出的列 = 原样字符串(source/city_id/... 等 NOT NULL 文本)。
_INT_COLS = {0, 2, 3, 9, 10, 13, 15, 17, 24} # id, platform, biz_line, prices, ...
_FLOAT_COLS = {14, 18} # commission_percent, delivery_distance_m
_NULLABLE_STR_COLS = {6, 7, 8, 11, 12, 16, 25} # sku_view_id, name, brand_name, ...
_DT_COLS = {21, 22, 23} # first_seen, last_seen, updated_at
_RAW_COL = 20
_TZ_SUFFIX = re.compile(r"[+-]\d{2}(:?\d{2})?$") # 尾部时区偏移 +08 / +08:00 / +0800
def _resolve_sqlite_path() -> Path:
"""从 DATABASE_URL(env 或 .env)解析出 SQLite 文件路径。只支持 sqlite://。"""
url = os.environ.get("DATABASE_URL", "")
if not url:
env = _PROJECT_ROOT / ".env"
if env.exists():
for line in env.read_text(encoding="utf-8").splitlines():
if line.strip().startswith("DATABASE_URL="):
url = line.split("=", 1)[1].strip()
break
if not url:
url = "sqlite:///./data/app.db"
if not url.startswith("sqlite:"):
sys.exit(f"仅支持 sqlite:// 库,当前 DATABASE_URL={url!r}")
rest = url.split("sqlite:///", 1)[1] if "sqlite:///" in url else url.split("sqlite://", 1)[1]
p = Path(rest)
if not p.is_absolute():
p = (_PROJECT_ROOT / rest).resolve()
return p
def _reconstruct_rows(text: str) -> tuple[list[list[str]], int]:
"""把文件文本重组成一条条 26 列的逻辑行。返回 (rows, skipped)。
单个字段内含换行 -> 一条逻辑行被拆成多物理行:累计字段,拆点用 \\n 重新拼回,
直到凑满 26 列数溢出(内嵌 TAB / 错位)或文件尾残行 -> 跳过并计数
"""
lines = text.split("\n")
while lines and lines[-1] == "":
lines.pop()
rows: list[list[str]] = []
skipped = 0
buf: list[str] = []
for raw_line in lines:
parts = raw_line.split("\t")
if not buf:
buf = parts
else:
buf[-1] += "\n" + parts[0] # 拼回被换行拆开的字段
buf.extend(parts[1:])
if len(buf) == NCOL:
rows.append(buf)
buf = []
elif len(buf) > NCOL: # 溢出:数据异常,丢弃这段重新开始
print(f" [skip] 列数溢出({len(buf)}>{NCOL}),field0={buf[0][:20]!r}")
skipped += 1
buf = []
if buf: # 文件尾被截断的残行
print(f" [skip] 尾部残行不足 {NCOL} 列(实 {len(buf)} 列),field0={buf[0][:20]!r}")
skipped += 1
return rows, skipped
def _convert(row: list[str]) -> tuple | None:
"""按列类型转换一行;非法(必填 int 为空 / raw 非 JSON)返回 None。"""
out: list = []
for i, v in enumerate(row):
if i in _DT_COLS:
out.append(_TZ_SUFFIX.sub("", v))
continue
if i == _RAW_COL:
try:
json.loads(v)
except Exception as e:
print(f" [skip] id={row[0]} raw 非法 JSON: {e}")
return None
out.append(v)
continue
if i in _INT_COLS:
out.append(int(v) if v != "" else None)
elif i in _FLOAT_COLS:
out.append(float(v) if v != "" else None)
elif i in _NULLABLE_STR_COLS:
out.append(v if v != "" else None)
else: # 必填文本列,原样
out.append(v)
return tuple(out)
def main() -> None:
tsv = Path(sys.argv[1]) if len(sys.argv) > 1 else _PROJECT_ROOT / "tests" / "meituan_coupon_data.tsv"
if not tsv.is_absolute():
tsv = (_PROJECT_ROOT / tsv).resolve()
db = _resolve_sqlite_path()
print(f"TSV: {tsv}")
print(f"DB : {db}")
if not tsv.exists():
sys.exit(f"TSV 不存在: {tsv}")
if not db.exists():
sys.exit(f"SQLite 库不存在: {db}(先跑 alembic upgrade head 建表)")
rows, skipped = _reconstruct_rows(tsv.read_text(encoding="utf-8"))
print(f"重组逻辑行: {len(rows)} 跳过(残/异常): {skipped}")
records = []
bad = 0
for r in rows:
rec = _convert(r)
if rec is None:
bad += 1
continue
records.append(rec)
print(f"可入库: {len(records)} 转换失败: {bad}")
placeholders = ",".join(["?"] * NCOL)
sql = f"INSERT OR REPLACE INTO meituan_coupon ({','.join(COLS)}) VALUES ({placeholders})"
con = sqlite3.connect(str(db))
try:
before = con.execute("SELECT count(*) FROM meituan_coupon").fetchone()[0]
con.executemany(sql, records)
con.commit()
after = con.execute("SELECT count(*) FROM meituan_coupon").fetchone()[0]
finally:
con.close()
print(f"入库前 {before} 行 -> 入库后 {after} 行(本次 {len(records)} 条)")
if __name__ == "__main__":
main()
+189
View File
@@ -0,0 +1,189 @@
"""给指定用户灌一批 mock 比价记录(开发 / 真机走查用)。
为什么:首页上次比价横幅(4 分钟新鲜窗口)比价记录页我的省钱战绩卡都读
`comparison_record`dev 库里这个用户没数据时这些 UI 是空的,本脚本灌一批真实感的外卖
比价记录,方便端上走查
用法(项目根 pip install -e . 的环境):
python scripts/mock_compare_records.py # 默认 user 昵称后缀 DlSKoc5S7, 10 条
python scripts/mock_compare_records.py --user DlSKoc5S7 # 按 昵称后缀/用户名/邀请码/手机号
python scripts/mock_compare_records.py --user 5 # 直接按 user.id
python scripts/mock_compare_records.py --user 5 --count 6
要点:
- 幂等:trace_id 固定为 mock-<标识>-NN,重跑覆盖同号记录(不会越灌越多)
- 1 created_at = 跑脚本当下(4 分钟内),其余铺在近 7 ;重跑会刷新时间戳
(所以重跑一次即可让上次比价横幅重新进入 4 分钟窗口)
- best_* / saved / status 由真实的 repositories.comparison.upsert_record comparison_results 派生
"""
from __future__ import annotations
import argparse
from datetime import datetime, timedelta
from sqlalchemy import or_, select
from app.core.rewards import CN_TZ
from app.db.session import SessionLocal
from app.models.user import User
from app.repositories.comparison import upsert_record
from app.schemas.compare_record import ComparisonRecordIn
# 平台名 → id(展示用;横幅按 platform_name 渲染,id 仅留痕)
_PLATFORM_ID = {"美团": "meituan", "京东": "jd", "淘宝闪购": "taobao", "饿了么": "ele"}
# 10 单真实感外卖订单:source=美团,targets=(平台, 到手价/元, 优惠来源名|None, 红包额/元)。
# failed=True 表示目标平台没采到价(targets 价置 None → 派生 status=failed)。
_CATALOG = [
dict(store="瑞幸咖啡(国贸店)", items=[("生椰拿铁(大杯)", 2)],
source=("美团", 39.8),
targets=[("淘宝闪购", 29.9, "平台红包", 6.0), ("京东", 33.0, None, 0)]),
dict(store="茶百道(大悦城店)", items=[("杨梅冰萃(大杯)", 1), ("脆波波奶茶", 1)],
source=("美团", 31.0),
targets=[("京东", 24.5, "百亿补贴", 4.5), ("饿了么", 27.9, None, 0)]),
dict(store="麦当劳(西单店)", items=[("板烧鸡腿堡套餐", 1)],
source=("美团", 32.0),
targets=[("京东", 25.9, "神券", 3.0), ("淘宝闪购", 28.0, None, 0)]),
dict(store="肯德基(朝阳大悦城店)", items=[("疯狂星期四全家桶", 1)],
source=("美团", 89.0),
targets=[("京东", 79.9, "百亿补贴", 9.0)]),
dict(store="张亮麻辣烫(双井店)", items=[("自选麻辣烫", 1)],
source=("美团", 35.0),
targets=[("京东", None, None, 0), ("淘宝闪购", None, None, 0)],
failed=True, fail_reason="京东外卖、淘宝闪购均未找到该店"),
dict(store="必胜客(王府井店)", items=[("超级至尊比萨(9寸)", 1), ("香辣鸡翅", 1)],
source=("美团", 78.0),
targets=[("淘宝闪购", 62.0, "跨店满减", 10.0), ("京东", 69.0, None, 0)]),
dict(store="蜜雪冰城(中关村店)", items=[("多肉葡萄", 2), ("冰鲜柠檬水", 1)],
source=("美团", 21.0),
targets=[("京东", 16.5, None, 0)]),
dict(store="海底捞外送(三里屯店)", items=[("番茄锅底", 1), ("鲜毛肚", 2), ("虾滑", 1)],
source=("美团", 168.0),
targets=[("京东", 155.0, "大额神券", 13.0), ("饿了么", 162.0, None, 0)]),
dict(store="华莱士(回龙观店)", items=[("全鸡汉堡套餐", 1)],
source=("美团", 26.0),
targets=[("京东", None, None, 0)],
failed=True, fail_reason="该商品在京东外卖未上架"),
dict(store="星巴克(国贸店)", items=[("燕麦拿铁(大杯)", 2)],
source=("美团", 66.0),
targets=[("饿了么", 58.0, "会员券", 8.0), ("京东", 61.0, None, 0)]),
]
# 第 2..N 条的 created_at 相对当下的回退量(都 > 4 分钟,确保只有第 1 条落在新鲜窗口)。
_OFFSETS = [
timedelta(minutes=25), timedelta(hours=2, minutes=10), timedelta(hours=6),
timedelta(days=1, hours=3), timedelta(days=1, hours=20), timedelta(days=2, hours=9),
timedelta(days=3, hours=14), timedelta(days=5, hours=7), timedelta(days=7, hours=2),
]
def _resolve_user(db, ident: str) -> User | None:
"""按 id(纯数字)/ 用户名 / 邀请码 / 手机号 / 昵称(或昵称去掉「用户」前缀的后缀)解析用户。"""
if ident.isdigit():
u = db.get(User, int(ident))
if u is not None:
return u
return db.execute(
select(User).where(
or_(
User.username == ident,
User.invite_code == ident,
User.phone == ident,
User.nickname == ident,
User.nickname == f"用户{ident}",
User.nickname.like(f"%{ident}"),
)
)
).scalars().first()
def _build_results(order: dict) -> tuple[list[dict], str]:
"""造 comparison_results + information 文案;返回 (results, information)。"""
src_name, src_price = order["source"]
failed = order.get("failed", False)
rows: list[dict] = [dict(
platform_id=_PLATFORM_ID.get(src_name), platform_name=src_name,
price=src_price, is_source=True, status="success",
)]
for pname, price, cname, csaved in order["targets"]:
row = dict(
platform_id=_PLATFORM_ID.get(pname), platform_name=pname,
price=(None if failed else price), is_source=False,
status=("store_not_found" if failed else "success"),
)
if not failed and csaved:
row["coupon_saved"] = csaved
row["coupon_name"] = cname
rows.append(row)
# rank:有价的按升序 1..N;无价的不排
priced = sorted((r for r in rows if r["price"] is not None), key=lambda r: r["price"])
for i, r in enumerate(priced, start=1):
r["rank"] = i
if failed:
return rows, order.get("fail_reason", "目标平台未找到该商品")
best = priced[0]
saved = src_price - best["price"]
if best["is_source"]:
info = f"{src_name}已是最低价,本单未比出更低"
else:
info = f"已在{best['platform_name']}比出最低价 ¥{best['price']:.2f}(比{src_name}省 ¥{saved:.2f})"
return rows, info
def main() -> None:
parser = argparse.ArgumentParser(description="给用户灌一批 mock 比价记录")
parser.add_argument("--user", default="DlSKoc5S7", help="user.id / 用户名 / 邀请码 / 手机号 / 昵称后缀")
parser.add_argument("--count", type=int, default=10, help="条数(1..10),默认 10")
args = parser.parse_args()
count = max(1, min(args.count, len(_CATALOG)))
now = datetime.now(CN_TZ).replace(tzinfo=None)
db = SessionLocal()
try:
user = _resolve_user(db, args.user)
if user is None:
raise SystemExit(f"❌ 找不到用户:{args.user}(试试 --user <id>)")
print(f"→ 用户 id={user.id} nickname={user.nickname} phone={user.phone}")
ok = failed = 0
for idx in range(count):
order = _CATALOG[idx]
src_name, src_price = order["source"]
results, info = _build_results(order)
payload = ComparisonRecordIn(
trace_id=f"mock-{args.user}-{idx:02d}",
business_type="food",
device_id="mock-seed",
store_name=order["store"],
source_platform_id=_PLATFORM_ID.get(src_name),
source_platform_name=src_name,
source_price=src_price,
items=[{"name": n, "qty": q} for n, q in order["items"]],
comparison_results=results,
total_dish_count=sum(q for _, q in order["items"]),
information=info,
app_version="mock",
)
rec = upsert_record(db, user_id=user.id, payload=payload)
# created_at:第 1 条 = 当下(4 分钟新鲜窗口);其余铺近 7 天。重跑刷新时间戳。
rec.created_at = now if idx == 0 else now - _OFFSETS[idx - 1]
db.commit()
if rec.status == "success":
ok += 1
else:
failed += 1
tag = "🆕now" if idx == 0 else f"-{_OFFSETS[idx - 1]}"
print(f" [{rec.status:7}] id={rec.id} {order['store']} best={rec.best_platform_name} "
f"¥{(rec.best_price_cents or 0)/100:.2f} saved=¥{(rec.saved_amount_cents or 0)/100:.2f} {tag}")
print(f"✅ 已为 user id={user.id} 写入 {count} 条(success {ok} / failed {failed});"
f"第 1 条 created_at=当下,4 分钟内进 app 可见「上次比价」横幅。")
finally:
db.close()
if __name__ == "__main__":
main()
+89
View File
@@ -320,3 +320,92 @@ def test_read_apis_require_auth(admin_client: TestClient) -> None:
"/admin/api/feedbacks",
]:
assert admin_client.get(path).status_code == 401, path
def test_period_comparison_reward_coin_from_feed_scene(
admin_client: TestClient, admin_token: str
) -> None:
"""比价奖励金币口径:按 ad_feed_reward_record.feed_scene='comparison' 的实发金币
(status=granted)汇总,而非查从不写入的 biz_type (修复大盘该卡恒 0)
too_short(未发奖)不计"""
from app.models.ad_feed_reward import AdFeedRewardRecord
d = "2021-06-15" # 独立历史日,隔离其它用例数据
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(db, phone="13800008801", register_channel="sms").id
db.add(AdFeedRewardRecord(
client_event_id="cmp-fs-granted", user_id=uid, reward_date=d,
ecpm_raw="0", coin=123, feed_scene="comparison", status="granted",
))
db.add(AdFeedRewardRecord(
client_event_id="cmp-fs-tooshort", user_id=uid, reward_date=d,
ecpm_raw="0", coin=99, feed_scene="comparison", status="too_short",
))
db.commit()
finally:
db.close()
r = admin_client.get(
"/admin/api/stats/overview", params={"date_from": d, "date_to": d},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
assert r.json()["period"]["coins"]["comparison_reward_coin_total"] == 123
def test_period_coupon_reward_coin_from_feed_scene(
admin_client: TestClient, admin_token: str
) -> None:
"""领券奖励金币口径:按 ad_feed_reward_record.feed_scene='coupon' 的实发金币汇总。"""
from app.models.ad_feed_reward import AdFeedRewardRecord
d = "2021-06-16"
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(db, phone="13800008802", register_channel="sms").id
db.add(AdFeedRewardRecord(
client_event_id="cpn-fs-granted", user_id=uid, reward_date=d,
ecpm_raw="0", coin=456, feed_scene="coupon", status="granted",
))
db.commit()
finally:
db.close()
r = admin_client.get(
"/admin/api/stats/overview", params={"date_from": d, "date_to": d},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
assert r.json()["period"]["coins"]["coupon_reward_coin_total"] == 456
def test_period_coupon_reward_excludes_reward_video(
admin_client: TestClient, admin_token: str
) -> None:
"""领券奖励金币不再把激励视频金币算进来(reward_video/ad_reward 从领券桶拆出);
激励视频仍单独计入 reward_video_coin_total"""
from datetime import datetime
from app.models.wallet import CoinTransaction
d = "2021-06-17"
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(db, phone="13800008803", register_channel="sms").id
db.add(CoinTransaction(
user_id=uid, amount=50, balance_after=50, biz_type="reward_video",
ref_id="rv-split-1", created_at=datetime(2021, 6, 17, 12, 0, 0),
))
db.commit()
finally:
db.close()
r = admin_client.get(
"/admin/api/stats/overview", params={"date_from": d, "date_to": d},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
coins = r.json()["period"]["coins"]
assert coins["coupon_reward_coin_total"] == 0 # 激励视频不计入领券奖励
assert coins["reward_video_coin_total"] == 50 # 仍计入激励视频卡
+77
View File
@@ -150,3 +150,80 @@ def test_create_admin_rejects_unknown_role(admin_client, super_token) -> None:
headers=_auth(super_token),
)
assert r.status_code == 400
def _login_pages(username: str, password: str = "pass1234") -> list[str]:
"""以某账号登录,取下发的有效可见页(左侧导航过滤依据)。"""
c = TestClient(admin_app)
return c.post(
"/admin/api/auth/login", json={"username": username, "password": password}
).json()["admin"]["pages"]
def test_custom_role_create_pages_take_effect(admin_client, super_token) -> None:
# 建「自定义」账号:role=custom + 勾选页(含一个非法 key,应被过滤)
r = admin_client.post(
"/admin/api/admins",
json={
"username": "cust_user", "password": "pass1234", "role": "custom",
"pages_override": ["dashboard", "withdraws", "xx-bad"],
},
headers=_auth(super_token),
)
assert r.status_code == 200, r.text
# 登录下发的 pages == 勾选集(非法 key 过滤),真正驱动左侧导航
assert set(_login_pages("cust_user")) == {"dashboard", "withdraws"}
# 账号列表回显原始勾选集(供编辑回填),同样已过滤
admins = {a["username"]: a for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()}
assert set(admins["cust_user"]["pages_override"]) == {"dashboard", "withdraws"}
assert admins["cust_user"]["role"] == "custom"
def test_custom_role_update_and_switch_back_clears_override(admin_client, super_token) -> None:
admin_client.post(
"/admin/api/admins",
json={
"username": "cust_sw", "password": "pass1234", "role": "custom",
"pages_override": ["dashboard"],
},
headers=_auth(super_token),
)
aid = next(
a["id"] for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()
if a["username"] == "cust_sw"
)
# 只改勾选集(角色不变)→ 生效
u = admin_client.patch(
f"/admin/api/admins/{aid}", json={"pages_override": ["dashboard", "feedbacks"]},
headers=_auth(super_token),
)
assert u.status_code == 200, u.text
assert set(_login_pages("cust_sw")) == {"dashboard", "feedbacks"}
# 切回普通角色 operator → override 清空,pages 跟随角色(运营页集,且不含 admins)
u2 = admin_client.patch(
f"/admin/api/admins/{aid}", json={"role": "operator"}, headers=_auth(super_token)
)
assert u2.status_code == 200, u2.text
admins = {a["username"]: a for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()}
assert admins["cust_sw"]["pages_override"] is None
pages = set(_login_pages("cust_sw"))
assert "feedbacks" in pages and "admins" not in pages # 运营口径,自定义页已不生效
def test_switch_operator_to_custom_sets_override(admin_client, super_token) -> None:
admin_client.post(
"/admin/api/admins",
json={"username": "op2cust", "password": "pass1234", "role": "operator"},
headers=_auth(super_token),
)
aid = next(
a["id"] for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()
if a["username"] == "op2cust"
)
u = admin_client.patch(
f"/admin/api/admins/{aid}",
json={"role": "custom", "pages_override": ["config"]},
headers=_auth(super_token),
)
assert u.status_code == 200, u.text
assert set(_login_pages("op2cust")) == {"config"}
+137
View File
@@ -0,0 +1,137 @@
"""reverse_geocoder 经纬度→城市 测试。
验证离线库对国内主要城市的匹配准确性注意gazetteer 的中国数据粒度不一致
直辖市/省会通常直接命中城市名部分城市可能命中到区/街道级如天津Erwangzhuang
西安Zhangjiabao此时 admin1 为省级行政区测试以 admin1省级匹配为主
"""
from __future__ import annotations
import pytest
from app.utils.geo import get_city
# ─────────────── 国内主要城市 ───────────────
# (城市, 纬度, 经度, 期望 admin1 包含字串)
_CITY_CASES = [
# 直辖市 — admin1 即城市名(可能带 Shi 后缀)
("北京", 39.9042, 116.4074, "Beijing"),
("上海", 31.2304, 121.4737, "Shanghai"),
("重庆", 29.4316, 106.9123, "Chongqing"),
("天津", 39.3434, 117.3616, "Tianjin"),
# 省会 / 一线 — admin1 为省份
("广州", 23.1291, 113.2644, "Guangdong"),
("深圳", 22.5431, 114.0579, "Guangdong"),
("成都", 30.5728, 104.0668, "Sichuan"),
("杭州", 30.2741, 120.1551, "Zhejiang"),
("武汉", 30.5928, 114.3055, "Hubei"),
("西安", 34.3416, 108.9398, "Shaanxi"),
("南京", 32.0603, 118.7969, "Jiangsu"),
("长沙", 28.2282, 112.9388, "Hunan"),
("郑州", 34.7466, 113.6253, "Henan"),
("济南", 36.6512, 116.9946, "Shandong"),
("昆明", 25.0389, 102.7183, "Yunnan"),
("福州", 26.0745, 119.2965, "Fujian"),
("哈尔滨", 45.8038, 126.5350, "Heilongjiang"),
("乌鲁木齐", 43.8256, 87.6168, "Xinjiang"),
("拉萨", 29.6500, 91.1000, "Tibet"),
# 非省会
("厦门", 24.4798, 118.0894, "Fujian"),
("苏州", 31.2990, 120.5853, "Jiangsu"),
("青岛", 36.0671, 120.3826, "Shandong"),
]
@pytest.mark.parametrize("label,lat,lon,expected_admin1", _CITY_CASES)
def test_city_admin1_match(label: str, lat: float, lon: float, expected_admin1: str) -> None:
"""所有城市经纬度应能匹配到正确的省级行政区 (admin1)。"""
r = get_city(lat, lon)
assert r["name"] != "", f"{label}: name should not be empty"
assert r["country"] == "CN", f"{label}: expected country=CN, got={r['country']}"
assert expected_admin1 in r["admin1"], \
f"{label}: expected admin1 to contain '{expected_admin1}', got={r['admin1']!r}"
# ─────────────── 直辖市 / 省会直接命中城市名 ───────────────
# 这些城市在 gazetteer 中的坐标恰好命中城市级条目(而非区/街道级),
# 验证 name 字段也正确。
_DIRECT_HIT_CASES = [
("北京", 39.9042, 116.4074, "Beijing"),
("上海", 31.2304, 121.4737, "Shanghai"),
("广州", 23.1291, 113.2644, "Guangzhou"),
("深圳", 22.5431, 114.0579, "Shenzhen"),
("成都", 30.5728, 104.0668, "Chengdu"),
("杭州", 30.2741, 120.1551, "Hangzhou"),
("郑州", 34.7466, 113.6253, "Zhengzhou"),
("济南", 36.6512, 116.9946, "Jinan"),
("昆明", 25.0389, 102.7183, "Kunming"),
("哈尔滨", 45.8038, 126.5350, "Harbin"),
("厦门", 24.4798, 118.0894, "Xiamen"),
("苏州", 31.2990, 120.5853, "Suzhou"),
("青岛", 36.0671, 120.3826, "Qingdao"),
("拉萨", 29.6500, 91.1000, "Lhasa"),
]
@pytest.mark.parametrize("label,lat,lon,expected_name", _DIRECT_HIT_CASES)
def test_city_name_direct_hit(label: str, lat: float, lon: float, expected_name: str) -> None:
"""直辖市/省会等主要城市坐标应直接命中城市名(而非区/街道级)。"""
r = get_city(lat, lon)
assert r["name"] == expected_name, \
f"{label}: expected name={expected_name}, got={r['name']!r}"
# ─────────────── 边界情况 ───────────────
def test_ocean_not_china() -> None:
"""远洋坐标不应误判为国内城市。"""
# 太平洋中部 → 可能匹配到最近有人岛(如法属波利尼西亚 Taiohae),但绝不应是 CN
r = get_city(0.0, -140.0)
assert r["country"] != "CN", f"mid-Pacific should not be CN, got {r}"
# 南大西洋
r2 = get_city(-30.0, -20.0)
assert r2["country"] != "CN", f"South Atlantic should not be CN, got {r2}"
def test_return_keys_and_types() -> None:
"""返回 dict 应包含全部五个字段且类型为 str。"""
r = get_city(39.9042, 116.4074)
for key in ("name", "admin1", "country", "latitude", "longitude"):
assert key in r, f"missing key: {key}"
assert isinstance(r[key], str), f"key {key} should be str, got {type(r[key])}"
def test_empty_result_keys() -> None:
"""结果始终应包含完整字段且全为 str 类型(即使匹配到偏远地)。"""
# reverse_geocoder KDTree 总找最近聚居点;业务侧如需判定"是否有效城市"
# 应自行按 country / admin1 做二次校验,而非依赖空字符串。
r = get_city(0.0, -140.0)
assert r["name"] != ""
assert isinstance(r["name"], str)
assert isinstance(r["admin1"], str)
assert isinstance(r["country"], str)
assert isinstance(r["latitude"], str)
assert isinstance(r["longitude"], str)
def test_same_coords_consistent() -> None:
"""同一坐标两次查询应返回相同结果(幂等)。"""
r1 = get_city(31.2304, 121.4737)
r2 = get_city(31.2304, 121.4737)
assert r1 == r2
def test_near_border_has_result() -> None:
"""省界附近的坐标应返回结果(非空 + 国内)。"""
# 苏鲁豫皖交界区域(徐州/商丘/宿州附近)
r = get_city(34.2, 116.8)
assert r["name"] != "", "border region should find a nearby populated place"
assert r["country"] == "CN"
def test_extreme_lat_lon_no_crash() -> None:
"""极值经纬度不应抛异常。"""
r1 = get_city(90.0, 0.0) # 北极
r2 = get_city(-90.0, 0.0) # 南极
assert isinstance(r1, dict)
assert isinstance(r2, dict)
+64
View File
@@ -0,0 +1,64 @@
"""app.utils.meituan_city.get_meituan_city 经纬度→美团城市 反查测试。
覆盖 get_meituan_city 的分支(test_geo.py 只覆盖底层 get_city):
- 主要城市 正确省份 + 非空 city_id(+ 直辖市/省会命中中文城市名)
- 非中国境内坐标 city_id 为空(接口据此降级返空)
- 返回 dict 结构 / 类型
- 坐标量化(~1km)后近点命中同一缓存结果
- 返回的是缓存副本(调用方原地修改不污染缓存)
"""
from __future__ import annotations
import pytest
from app.utils.meituan_city import get_meituan_city
# (标签, 纬度, 经度, 期望省份名, 期望城市名)
_CASES = [
("北京", 39.9042, 116.4074, "北京市", "北京市"),
("上海", 31.2304, 121.4737, "上海市", "上海市"),
("广州", 23.1291, 113.2644, "广东省", "广州市"),
("深圳", 22.5431, 114.0579, "广东省", "深圳市"),
("成都", 30.5728, 104.0668, "四川省", "成都市"),
("杭州", 30.2741, 120.1551, "浙江省", "杭州市"),
("武汉", 30.5928, 114.3055, "湖北省", "武汉市"),
("郑州", 34.7466, 113.6253, "河南省", "郑州市"),
("厦门", 24.4798, 118.0894, "福建省", "厦门市"),
("青岛", 36.0671, 120.3826, "山东省", "青岛市"),
]
@pytest.mark.parametrize("label,lat,lon,exp_province,exp_city", _CASES)
def test_major_city_resolves(label: str, lat: float, lon: float,
exp_province: str, exp_city: str) -> None:
r = get_meituan_city(lat, lon)
assert r["province_name"] == exp_province, f"{label}: province {r!r}"
assert r["city_name"] == exp_city, f"{label}: city {r!r}"
assert r["city_id"], f"{label}: city_id 不应为空 {r!r}"
def test_non_china_returns_empty_city_id() -> None:
"""境外/远洋坐标 → city_id 空(接口据此返回 degraded 空列表)。"""
r = get_meituan_city(0.0, -140.0) # 太平洋中部
assert r["city_id"] == "", f"境外不应给出 city_id: {r!r}"
def test_return_shape_and_types() -> None:
r = get_meituan_city(39.9042, 116.4074)
for key in ("city_id", "city_name", "province_name"):
assert key in r and isinstance(r[key], str)
def test_quantized_coords_hit_same_result() -> None:
"""相距 <1km(round 到 2 位小数后相同)的两点应解析出同一城市。"""
a = get_meituan_city(39.9042, 116.4074)
b = get_meituan_city(39.9031, 116.4066) # round 后同为 (39.90, 116.41)
assert a == b
def test_result_is_defensive_copy() -> None:
"""返回的是缓存副本:原地修改不应污染后续查询。"""
first = get_meituan_city(31.2304, 121.4737)
first["city_id"] = "TAMPERED"
second = get_meituan_city(31.2304, 121.4737)
assert second["city_id"] != "TAMPERED"
+150
View File
@@ -0,0 +1,150 @@
"""提现现金账本校验(admin ledger-check)测试:两本物理隔离的账各自对账。
历史盲区:`withdraw_ledger_check` 曾拿全部提现单去和**普通现金流水**(cash_transaction)比对,
source=invite_cash 的提现单流水其实在 invite_cash_transaction ,导致每笔邀请提现单都被
误报缺扣款/缺退款流水这里用真实提现 API 造单 + before/after 差值断言锁定修复:
1) 邀请提现单不再污染普通现金账的缺流水计数;
2) 邀请账户已被纳入对账(能抓到它自己的缺流水);
3) 普通现金账的原有对账未被改坏
conftest 的库是 session 级共享测试间不清,故一律用 before/after 差值,只反映本用例造的数据
"""
from __future__ import annotations
from sqlalchemy import delete, select
from app.admin.repositories.queries import withdraw_ledger_check
from app.db.session import SessionLocal
from app.models.user import User
from app.models.wallet import CoinAccount, InviteCashTransaction
from app.repositories import wallet as crud_wallet
def _login(client, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _patch_userinfo(monkeypatch, openid: str) -> None:
monkeypatch.setattr(
"app.integrations.wxpay.code_to_userinfo",
lambda code: {"openid": openid, "nickname": None, "avatar_url": None, "raw": {}},
)
def _seed_balances(client, token: str, phone: str, *, cash: int = 0, invite_cash: int = 0) -> None:
client.get("/api/v1/wallet/account", headers=_auth(token))
db = SessionLocal()
try:
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
acc = db.get(CoinAccount, user.id)
acc.cash_balance_cents = cash
acc.invite_cash_balance_cents = invite_cash
db.commit()
finally:
db.close()
def _reject(bill: str, reason: str = "测试拒绝") -> None:
db = SessionLocal()
try:
crud_wallet.reject_withdraw(db, bill, reason)
finally:
db.close()
def _ledger() -> dict:
db = SessionLocal()
try:
return withdraw_ledger_check(db)
finally:
db.close()
def test_rejected_invite_withdraw_not_flagged_missing(client, monkeypatch) -> None:
"""核心回归:一笔被拒绝的 invite_cash 提现单,扣款/退款流水都在 invite_cash_transaction,
不应让普通现金账的缺扣款/缺退款计数增加(修复前每笔会各 +1)"""
before = _ledger()
_patch_userinfo(monkeypatch, "openid_lc_1")
token = _login(client, "13800005001")
_seed_balances(client, token, "13800005001", cash=0, invite_cash=500)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 200, "source": "invite_cash"},
headers=_auth(token),
)
assert r.status_code == 200, r.text
_reject(r.json()["out_bill_no"]) # rejected + 退款流水落 invite_cash_transaction
after = _ledger()
# 普通现金账不该因这笔 invite 单产生缺流水(修复前会各 +1 → 就是页面上看到的误报)
assert after["missing_withdraw_txn_count"] == before["missing_withdraw_txn_count"]
assert after["missing_refund_txn_count"] == before["missing_refund_txn_count"]
# 邀请账扣款 + 退款流水齐全,邀请账自身也不该缺
assert after["invite_missing_withdraw_txn_count"] == before["invite_missing_withdraw_txn_count"]
assert after["invite_missing_refund_txn_count"] == before["invite_missing_refund_txn_count"]
def test_invite_ledger_detects_missing_withdraw_txn(client, monkeypatch) -> None:
"""删掉一笔 invite 提现单的扣款流水 → 邀请账缺扣款计数 +1、ok=False,
证明邀请账户已真正纳入对账(修复前邀请账完全不校验永远报不出问题)"""
_patch_userinfo(monkeypatch, "openid_lc_2")
token = _login(client, "13800005002")
_seed_balances(client, token, "13800005002", cash=0, invite_cash=500)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 200, "source": "invite_cash"},
headers=_auth(token),
)
bill = r.json()["out_bill_no"]
before = _ledger()
db = SessionLocal()
try:
db.execute(
delete(InviteCashTransaction).where(
InviteCashTransaction.ref_id == bill,
InviteCashTransaction.biz_type == "invite_withdraw",
)
)
db.commit()
finally:
db.close()
after = _ledger()
assert (
after["invite_missing_withdraw_txn_count"]
== before["invite_missing_withdraw_txn_count"] + 1
)
assert after["ok"] is False
def test_coin_cash_withdraw_still_reconciled(client, monkeypatch) -> None:
"""普通现金 coin_cash 提现单齐全时不新增缺流水(确保分账改造没弄坏原有普通现金对账)。"""
_patch_userinfo(monkeypatch, "openid_lc_3")
token = _login(client, "13800005003")
_seed_balances(client, token, "13800005003", cash=500, invite_cash=0)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
before = _ledger()
r = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 200, "source": "coin_cash"},
headers=_auth(token),
)
assert r.status_code == 200, r.text
after = _ledger()
# 普通现金提现扣款流水随单写入 cash_transaction,缺扣款计数不变;邀请账更不受影响
assert after["missing_withdraw_txn_count"] == before["missing_withdraw_txn_count"]
assert after["invite_missing_withdraw_txn_count"] == before["invite_missing_withdraw_txn_count"]