Compare commits

...

9 Commits

Author SHA1 Message Date
no_gen_mu c3a5dc2a41 后台权限管理支持「自定义」角色:按人存 pages_override,可见页逐页勾选并即时生效
admin_user 加 pages_override(可空 JSON)+ Alembic 迁移;role=="custom" 时可见页由这份逐页
勾选决定,否则跟随角色。create/update 收 pages_override(切回普通角色自动清空),_validate_role
放行 custom 哨兵角色。登录/me 下发有效页时优先用 override → 左侧导航直接按勾选生效。
补 4 个用例覆盖建/改/切回/切入,test_admin_roles 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:44:03 +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
53 changed files with 3301 additions and 198 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
+69
View File
@@ -0,0 +1,69 @@
"""admin_role 表(RBAC 角色 → 可见页面)+ 种子内建角色
新增后台 RBAC:角色持有一组页面 key,登录后左侧只展示这些页。super_admin 内建全权(pages 存空、
effective_pages 特判为全部)、不可编辑删除;operator/finance 播默认页集,可由 super_admin 增删改。
admin_user.role 弱引用本表 name。全新环境顺序应用即得三条内建角色。
Revision ID: admin_role_table
Revises: comparison_product_names
Create Date: 2026-07-04 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "admin_role_table"
down_revision: str | Sequence[str] | None = "comparison_product_names"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
# 与 app/admin/permissions.py 的 BUILTIN_ROLES 同口径(迁移内联一份,不耦合 app 常量)。
# 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES。key 承重(require_role),label 为展示名。
_BUILTIN = [
{"name": "super_admin", "label": "管理员", "pages": [], "is_builtin": True},
{"name": "operator", "label": "运营", "pages": [
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
"cps", "device-liveness", "price-reports", "feedbacks",
], "is_builtin": False},
{"name": "finance", "label": "财务", "pages": [
"dashboard", "ad-revenue-report", "cps", "withdraws",
], "is_builtin": False},
{"name": "tech", "label": "技术", "pages": [
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
], "is_builtin": False},
]
def upgrade() -> None:
op.create_table(
"admin_role",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("name", sa.String(length=32), nullable=False),
sa.Column("label", sa.String(length=32), nullable=False, server_default=""),
sa.Column("pages", _JSON, nullable=False),
sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
),
)
op.create_index("ix_admin_role_name", "admin_role", ["name"], unique=True)
tbl = sa.table(
"admin_role",
sa.column("name", sa.String),
sa.column("label", sa.String),
sa.column("pages", _JSON),
sa.column("is_builtin", sa.Boolean),
)
op.bulk_insert(tbl, _BUILTIN)
def downgrade() -> None:
op.drop_index("ix_admin_role_name", table_name="admin_role")
op.drop_table("admin_role")
@@ -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")
@@ -0,0 +1,29 @@
"""admin_user 加 plain_password 列(明文登录密码,仅后台 UI 建/重置的账号留存)
权限管理页需能复看某成员已确定的登录密码转交本人。系统只存哈希无法反推,故对「后台 UI 创建/重置」
的管理员额外留存一份明文;脚本/起后台建的超管为 None(前端不显示密码)。旧账号无此列值 → 同样不显示。
Revision ID: admin_user_plain_password
Revises: admin_role_table
Create Date: 2026-07-04 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "admin_user_plain_password"
down_revision: str | Sequence[str] | None = "admin_role_table"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("admin_user", schema=None) as batch_op:
batch_op.add_column(sa.Column("plain_password", sa.String(length=128), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("admin_user", schema=None) as batch_op:
batch_op.drop_column("plain_password")
@@ -0,0 +1,70 @@
"""comparison_record 加 product_names 列(下单商品名派生串)+ 回填历史行
admin 比价记录页要把原「店/商品」一列拆成「店」+「商品」两列、并支持按商品名搜索。
items 是 JSON(SQLite 下 json.dumps ensure_ascii 会把中文转义存成 \\uXXXX,无法直接 CAST+LIKE
命中中文),故把商品名派生成普通文本列,跨库 LIKE 一致、可索引。
写路径(upsert_record / harvest_done)已同步派生;本迁移建列并从已存 items 回填历史行。
全新环境顺序应用即得空列 + 回填(表本为空,回填 no-op)。
Revision ID: comparison_product_names
Revises: comparison_record_trace_unique
Create Date: 2026-07-04 00:00:00.000000
"""
import json
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "comparison_product_names"
down_revision: str | Sequence[str] | None = "comparison_record_trace_unique"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _product_names(items) -> str | None:
"""items([{name,...}]) → 顿号分隔的商品名串(去重保序)。与 repositories.comparison
._product_names_from_items 同口径,迁移内联一份避免耦合 app 代码。"""
if isinstance(items, str): # SQLite: items 以 JSON 文本存,取回是 str
try:
items = json.loads(items)
except (ValueError, TypeError):
return None
if not isinstance(items, list) or not items:
return None
names: list[str] = []
for it in items:
name = it.get("name") if isinstance(it, dict) else None
if not name:
continue
s = str(name).strip()
if s and s not in names:
names.append(s)
joined = "".join(names)
return joined[:500] or None
def upgrade() -> None:
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
batch_op.add_column(sa.Column("product_names", sa.String(length=512), nullable=True))
# 回填:从已存 items 派生商品名。items 小(菜名列表),一次取回即可;只更新有商品名的行。
bind = op.get_bind()
rows = bind.execute(
sa.text("SELECT id, items FROM comparison_record")
).fetchall()
for rid, items in rows:
pn = _product_names(items)
if pn:
bind.execute(
sa.text("UPDATE comparison_record SET product_names = :pn WHERE id = :id"),
{"pn": pn, "id": rid},
)
def downgrade() -> None:
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
batch_op.drop_column("product_names")
+2
View File
@@ -32,6 +32,7 @@ from app.admin.routers.feedback_qr import router as feedback_qr_router
from app.admin.routers.onboarding import router as onboarding_router
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
from app.admin.routers.price_report import router as price_report_router
from app.admin.routers.roles import router as roles_router
from app.admin.routers.users import router as users_router
from app.admin.routers.wallet import router as wallet_router
from app.admin.routers.withdraw import router as withdraw_router
@@ -98,6 +99,7 @@ admin_app.include_router(feedback_router)
admin_app.include_router(event_logs_router)
admin_app.include_router(feedback_qr_router)
admin_app.include_router(admins_router)
admin_app.include_router(roles_router)
admin_app.include_router(audit_router)
admin_app.include_router(config_router)
admin_app.include_router(comparison_router)
+83
View File
@@ -0,0 +1,83 @@
"""admin 后台「页面权限目录」—— RBAC 的权限侧单一真源。
一个权限 = 一个页面(= 左侧导航项)。角色持有一组 page key,登录后左侧只展示这些页(见前端
layout.tsx 按 pages 过滤导航)。key 必须与前端路由一级对齐(/dashboard → "dashboard")。
super_admin 为内建全权角色,恒可见全部页(effective_pages 特判)。新增页面 = 这里加一条 +
前端导航加对应项(key 一致即可被各角色勾选可见)。
"""
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] = [
{"group": "看板", "pages": [
{"key": "dashboard", "label": "数据大盘"},
{"key": "coupon-data", "label": "领券数据"},
{"key": "ad-revenue-report", "label": "广告收益"},
{"key": "comparison-records", "label": "比价记录"},
{"key": "cps", "label": "CPS收益"},
{"key": "device-liveness", "label": "设备存活"},
]},
{"group": "奖励审核", "pages": [
{"key": "withdraws", "label": "提现审核"},
{"key": "price-reports", "label": "低价审核"},
{"key": "feedbacks", "label": "用户反馈"},
]},
{"group": "数据配置", "pages": [
{"key": "config", "label": "系统配置"},
{"key": "ad-revenue", "label": "广告配置"},
{"key": "users", "label": "用户管理"},
]},
{"group": "其他", "pages": [
{"key": "admins", "label": "权限管理"},
{"key": "event-logs", "label": "埋点日志"},
{"key": "audit-logs", "label": "审计日志"},
]},
]
# 全部页面 key(super_admin 有效可见 = 此全集;也用于校验角色 pages 合法性)
ALL_PAGE_KEYS: list[str] = [p["key"] for g in PERMISSION_CATALOG for p in g["pages"]]
_ALL_SET = set(ALL_PAGE_KEYS)
# 内建角色定义(种子 / ensure 兜底的单一真源):(key, 中文展示名, 默认可见页)。
# 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES;super_admin 恒全权(pages 空、bypass)。
# key 承重(require_role / admin_user.role),勿改;label 为 UI 展示名。
BUILTIN_ROLES: list[dict] = [
{"name": SUPER_ADMIN_ROLE, "label": "管理员", "pages": []},
{"name": "operator", "label": "运营", "pages": [
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
"cps", "device-liveness", "price-reports", "feedbacks",
]},
{"name": "finance", "label": "财务", "pages": [
"dashboard", "ad-revenue-report", "cps", "withdraws",
]},
{"name": "tech", "label": "技术", "pages": [
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
]},
]
# 内建 key → 中文展示名(展示 / 自愈用)
BUILTIN_LABELS: dict[str, str] = {r["name"]: r["label"] for r in BUILTIN_ROLES}
def sanitize_pages(pages: list[str] | None) -> list[str]:
"""过滤掉不在目录里的 key(去重、保序),防脏数据/目录收缩后的悬空 key。"""
seen: set[str] = set()
out: list[str] = []
for k in pages or []:
if k in _ALL_SET and k not in seen:
seen.add(k)
out.append(k)
return out
def effective_pages(role_name: str, role_pages: list[str] | None) -> list[str]:
"""某角色的有效可见页:super_admin → 全部;其余 → 其 pages 与目录取交(自愈悬空 key)。"""
if role_name == SUPER_ADMIN_ROLE:
return list(ALL_PAGE_KEYS)
return sanitize_pages(role_pages)
+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],
}
+81
View File
@@ -0,0 +1,81 @@
"""admin_role 表 CRUD + 「角色 → 有效可见页」解析。"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.admin.permissions import (
ALL_PAGE_KEYS,
BUILTIN_ROLES,
SUPER_ADMIN_ROLE,
sanitize_pages,
)
from app.models.admin_role import AdminRole
def ensure_builtin_roles(db: Session) -> None:
"""空表时播种内建角色(管理员/运营/财务/技术)。幂等:表非空即 no-op。
给 create_all 环境(测试 / 未跑迁移的全新库)兜底,迁移已播种则不重复。
仅 super_admin 为 is_builtin(锁定不可改删);运营/财务/技术可编辑页/删除。"""
if db.execute(select(AdminRole.id).limit(1)).first() is not None:
return
db.add_all([
AdminRole(
name=r["name"], label=r["label"], pages=list(r["pages"]),
is_builtin=(r["name"] == SUPER_ADMIN_ROLE),
)
for r in BUILTIN_ROLES
])
db.commit()
def list_roles(db: Session) -> list[AdminRole]:
ensure_builtin_roles(db)
# super_admin(内建)恒排最前,其余按创建序
return list(
db.execute(
select(AdminRole).order_by(AdminRole.is_builtin.desc(), AdminRole.id)
).scalars().all()
)
def get_role(db: Session, name: str) -> AdminRole | None:
return db.execute(
select(AdminRole).where(AdminRole.name == name)
).scalar_one_or_none()
def create_role(db: Session, *, name: str, label: str, pages: list[str]) -> AdminRole:
# 自定义角色:name(key)= label = 用户输入的名称
role = AdminRole(name=name, label=label, pages=sanitize_pages(pages), is_builtin=False)
db.add(role)
db.commit()
db.refresh(role)
return role
def update_role(
db: Session, role: AdminRole, *, label: str | None = None, pages: list[str] | None = None
) -> AdminRole:
# 只改展示名 label + 可见页;name(key)不可变(admin_user.role / require_role 承重)
if label is not None:
role.label = label
if pages is not None:
role.pages = sanitize_pages(pages)
db.commit()
db.refresh(role)
return role
def delete_role(db: Session, role: AdminRole) -> None:
db.delete(role)
db.commit()
def effective_pages_of(db: Session, role_name: str) -> list[str]:
"""某角色名的有效可见页:super_admin → 全部;其余 → 查表 pages 与目录取交。"""
if role_name == SUPER_ADMIN_ROLE:
return list(ALL_PAGE_KEYS)
ensure_builtin_roles(db)
role = get_role(db, role_name)
return sanitize_pages(role.pages if role else None)
+12 -1
View File
@@ -20,12 +20,23 @@ def get_by_username(db: Session, username: str) -> AdminUser | None:
def create_admin(
db: Session, *, username: str, password: str, role: str = "operator"
db: Session,
*,
username: str,
password: str,
role: str = "operator",
plain_password: str | None = None,
pages_override: list[str] | None = None,
) -> AdminUser:
"""建管理员。plain_password 非空则额外留存明文(后台 UI 建的账号传,供权限管理页复看);
脚本/起后台建账号不传(留 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
# ───────────── 群 ─────────────
+226 -36
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:
@@ -148,11 +266,14 @@ def list_comparison_records(
phone: str | None = None,
status: str | None = None,
business_type: str | None = None,
store: str | None = None,
product: str | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[ComparisonRecord], int | None, int]:
"""admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛,
offset 分页(创建时间倒序、id 兜底)。join User 取 phone/nickname 瞬态挂记录上。"""
store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。
join User 取 phone/nickname 瞬态挂记录上。"""
stmt = select(ComparisonRecord)
if user_id is not None:
stmt = stmt.where(ComparisonRecord.user_id == user_id)
@@ -166,6 +287,11 @@ def list_comparison_records(
stmt = stmt.where(ComparisonRecord.status == status)
if business_type:
stmt = stmt.where(ComparisonRecord.business_type == business_type)
if store:
stmt = stmt.where(ComparisonRecord.store_name.like(f"%{store}%"))
if product:
# 商品名搜 product_names 派生文本列(非 items JSON:SQLite 下 JSON 中文被转义无法直接 LIKE)。
stmt = stmt.where(ComparisonRecord.product_names.like(f"%{product}%"))
items, next_cursor, total = offset_paginate(
db, stmt,
(desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)),
@@ -726,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
@@ -760,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"],
}
+199 -71
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
@@ -55,29 +61,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 +121,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 +298,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 +320,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 +348,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,
@@ -413,6 +436,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),
@@ -476,11 +591,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 +608,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"],
+74 -5
View File
@@ -5,6 +5,8 @@ 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 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
from app.admin.schemas.auth import AdminOut
@@ -23,9 +25,31 @@ def _active_super_count(db: AdminDb) -> int:
)
@router.get("", response_model=list[AdminOut], summary="管理员列表")
def _validate_role(db: AdminDb, role: str) -> None:
"""角色必须是 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]:
return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)]
out: list[AdminOut] = []
for a in admin_repo.list_admins(db):
item = AdminOut.model_validate(a)
item.password = a.plain_password # 明文:仅本 super_admin 专属路由下发,供权限管理页复看
out.append(item)
return out
@router.post("", response_model=AdminOut, summary="创建管理员")
@@ -34,12 +58,17 @@ def create_admin(
) -> AdminOut:
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
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)
@@ -67,8 +96,12 @@ def update_admin(
if demotes_super and _active_super_count(db) <= 1:
raise HTTPException(status_code=400, detail="不能降级/禁用最后一个超级管理员")
if body.role is not None:
_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:
@@ -77,6 +110,18 @@ def update_admin(
if body.password is not None:
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()
@@ -86,3 +131,27 @@ def update_admin(
detail=changes, ip=get_client_ip(request), commit=True,
)
return AdminOut.model_validate(target)
@router.delete("/{admin_id}", summary="删除管理员(带审计)")
def delete_admin(admin_id: int, request: Request, admin: CurrentAdmin, db: AdminDb) -> dict:
target = admin_repo.get_by_id(db, admin_id)
if target is None:
raise HTTPException(status_code=404, detail="管理员不存在")
if admin_id == admin.id:
raise HTTPException(status_code=400, detail="不能删除自己")
# 防自锁:删掉某个 active super_admin 前,确认后仍至少剩 1 个,否则进「零可用超管」死局。
if (
target.role == "super_admin"
and target.status == "active"
and _active_super_count(db) <= 1
):
raise HTTPException(status_code=400, detail="不能删除最后一个超级管理员")
username = target.username
db.delete(target)
db.commit()
write_audit(
db, admin, action="admin.delete", target_type="admin", target_id=admin_id,
detail={"username": username, "role": target.role}, ip=get_client_ip(request), commit=True,
)
return {"deleted": True}
+17 -4
View File
@@ -6,6 +6,8 @@ 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
from app.admin.security import create_admin_token
@@ -17,6 +19,17 @@ logger = logging.getLogger("shagua.admin.auth")
router = APIRouter(prefix="/admin/api/auth", tags=["admin-auth"])
def _admin_out_with_pages(admin, db: AdminDb) -> AdminOut: # noqa: ANN001
"""AdminOut + 有效可见页(前端左侧导航按此过滤)。
role=="custom" 用这个人的 pages_override(按人自定义,过滤悬空 key);其余走角色解析"""
out = AdminOut.model_validate(admin)
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
@router.post(
"/login",
response_model=AdminLoginResponse,
@@ -39,10 +52,10 @@ def login(req: AdminLoginRequest, db: AdminDb) -> AdminLoginResponse:
return AdminLoginResponse(
access_token=token,
expires_in=expires_in,
admin=AdminOut.model_validate(admin),
admin=_admin_out_with_pages(admin, db),
)
@router.get("/me", response_model=AdminOut, summary="当前管理员")
def me(admin: CurrentAdmin) -> AdminOut:
return AdminOut.model_validate(admin)
@router.get("/me", response_model=AdminOut, summary="当前管理员(含有效可见页)")
def me(admin: CurrentAdmin, db: AdminDb) -> AdminOut:
return _admin_out_with_pages(admin, db)
+4 -1
View File
@@ -32,12 +32,15 @@ def list_comparison_records(
phone: Annotated[str | None, Query(description="手机号前缀")] = None,
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
business_type: Annotated[str | None, Query()] = None,
store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None,
product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AdminComparisonListItem]:
items, next_cursor, total = queries.list_comparison_records(
db, user_id=user_id, phone=phone, status=status,
business_type=business_type, limit=limit, cursor=cursor,
business_type=business_type, store=store, product=product,
limit=limit, cursor=cursor,
)
return CursorPage(
items=[AdminComparisonListItem.model_validate(r) for r in items],
+7 -2
View File
@@ -55,9 +55,14 @@ def _item(db, key: str) -> ConfigItemOut:
raise HTTPException(status_code=404, detail="未知配置项")
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值")
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值(不含 hidden)")
def list_config(db: AdminDb) -> list[ConfigItemOut]:
return [ConfigItemOut(**item) for item in app_config.list_all(db)]
# hidden 项(已下线/由专用页管理,如福利页任务·里程碑·看广告调参、首页轮播数据源)不在本页渲染。
return [
ConfigItemOut(**item)
for item in app_config.list_all(db)
if not CONFIG_DEFS[item["key"]].get("hidden")
]
@router.patch("/{key}", response_model=ConfigItemOut, summary="改某项配置(带审计)")
+41 -3
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
@@ -22,7 +23,11 @@ from app.admin.schemas.ops_marquee_seed import (
)
from app.models.admin import AdminUser
from app.models.ops_marquee_seed import OpsMarqueeSeed
from app.repositories import ops_marquee
from app.repositories import app_config, ops_marquee
class MarqueeModeUpdate(BaseModel):
mode: str # mixed / real / seed
router = APIRouter(
prefix="/admin/api/marquee-seeds",
@@ -52,9 +57,42 @@ 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))
# 注:/mode 两个端点须在 /{seed_id} 之前注册,否则 PATCH /mode 会被 /{seed_id} 抢先按 id 解析。
@router.get("/mode", summary="首页轮播数据源模式(mixed/real/seed)")
def get_feed_mode(db: AdminDb) -> dict:
"""当前轮播取数模式:mixed=真实优先+种子补位(默认)/ real=只真实 / seed=只种子·合成。"""
return {"mode": ops_marquee.get_feed_mode(db)}
@router.patch("/mode", summary="改首页轮播数据源模式(带审计)")
def set_feed_mode(
body: MarqueeModeUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> dict:
if body.mode not in ops_marquee.FEED_MODES:
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
before = ops_marquee.get_feed_mode(db)
app_config.set_value(db, "marquee_feed_mode", body.mode, admin_id=admin.id, commit=False)
write_audit(
db, admin, action="ops_marquee_seed.set_mode", target_type="config",
target_id="marquee_feed_mode", detail={"before": before, "after": body.mode},
ip=get_client_ip(request), commit=False,
)
db.commit()
return {"mode": body.mode}
@router.post("", response_model=OpsMarqueeSeedOut, summary="新增轮播种子(带审计)")
+129
View File
@@ -0,0 +1,129 @@
"""admin RBAC 角色管理(仅 super_admin):列角色 / 权限目录 / 增删改角色。均写审计。
角色 = 一组可见页面( app/admin/permissions.py)角色登录后台后左侧只展示其 pages 对应导航项
只有 super_admin(内建全权角色)能进本组端点(dependencies=require_role() 无参 = super_admin)
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import func, select
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
from app.admin.permissions import (
PERMISSION_CATALOG,
SUPER_ADMIN_ROLE,
effective_pages,
sanitize_pages,
)
from app.admin.repositories import admin_role as role_repo
from app.admin.schemas.role import (
PermissionGroup,
RoleCreateRequest,
RoleOut,
RoleUpdateRequest,
)
from app.models.admin import AdminUser
from app.models.admin_role import AdminRole
router = APIRouter(
prefix="/admin/api/roles",
tags=["admin-roles"],
dependencies=[Depends(require_role())], # 无参 = 仅 super_admin
)
def _usage_counts(db: AdminDb) -> dict[str, int]:
rows = db.execute(
select(AdminUser.role, func.count(AdminUser.id)).group_by(AdminUser.role)
).all()
return {r: c for r, c in rows}
def _to_out(role: AdminRole, usage: dict[str, int]) -> RoleOut:
return RoleOut(
id=role.id,
name=role.name,
label=role.label or role.name,
pages=effective_pages(role.name, role.pages),
is_builtin=role.is_builtin,
in_use=usage.get(role.name, 0),
)
@router.get("", response_model=list[RoleOut], summary="角色列表(含可见页 + 使用数)")
def list_roles(db: AdminDb) -> list[RoleOut]:
usage = _usage_counts(db)
return [_to_out(r, usage) for r in role_repo.list_roles(db)]
@router.get("/catalog", response_model=list[PermissionGroup], summary="页面权限目录(分组)")
def get_catalog() -> list[PermissionGroup]:
return [PermissionGroup(**g) for g in PERMISSION_CATALOG]
@router.post("", response_model=RoleOut, summary="新增角色(带审计)")
def create_role(
body: RoleCreateRequest, request: Request, admin: CurrentAdmin, db: AdminDb
) -> RoleOut:
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="角色名称不能为空")
if name == SUPER_ADMIN_ROLE:
raise HTTPException(status_code=400, detail="super_admin 为内建角色,不能新建")
if role_repo.get_role(db, name) is not None:
raise HTTPException(status_code=409, detail="角色名称已存在")
# 自定义角色:name(key)= label = 输入名称
role = role_repo.create_role(db, name=name, label=name, pages=body.pages)
write_audit(
db, admin, action="role.create", target_type="role", target_id=str(role.id),
detail={"name": name, "pages": role.pages}, ip=get_client_ip(request), commit=True,
)
return _to_out(role, _usage_counts(db))
@router.patch("/{role_id}", response_model=RoleOut, summary="改角色(展示名/可见页,带审计)")
def update_role(
role_id: int, body: RoleUpdateRequest, request: Request, admin: CurrentAdmin, db: AdminDb
) -> RoleOut:
role = db.get(AdminRole, role_id)
if role is None:
raise HTTPException(status_code=404, detail="角色不存在")
if role.is_builtin:
raise HTTPException(status_code=400, detail="内建角色「管理员」不可编辑")
new_label = body.label.strip() if body.label is not None else None
changes: dict = {}
if new_label and new_label != role.label:
changes["label"] = {"before": role.label, "after": new_label}
if body.pages is not None:
changes["pages"] = {"before": role.pages, "after": sanitize_pages(body.pages)}
if not changes:
raise HTTPException(status_code=400, detail="无任何变更字段")
# 只改展示名 + 可见页;key(name)不可变,故无需级联 admin_user.role
role_repo.update_role(db, role, label=new_label, pages=body.pages)
write_audit(
db, admin, action="role.update", target_type="role", target_id=str(role_id),
detail=changes, ip=get_client_ip(request), commit=True,
)
return _to_out(role, _usage_counts(db))
@router.delete("/{role_id}", summary="删角色(带审计;内建/在用不可删)")
def delete_role(role_id: int, request: Request, admin: CurrentAdmin, db: AdminDb) -> dict:
role = db.get(AdminRole, role_id)
if role is None:
raise HTTPException(status_code=404, detail="角色不存在")
if role.is_builtin:
raise HTTPException(status_code=400, detail="内建角色不可删除")
used = _usage_counts(db).get(role.name, 0)
if used > 0:
raise HTTPException(status_code=400, detail=f"该角色仍有 {used} 名管理员在用,请先改派再删")
name = role.name
role_repo.delete_role(db, role)
write_audit(
db, admin, action="role.delete", target_type="role", target_id=str(role_id),
detail={"name": name}, ip=get_client_ip(request), commit=True,
)
return {"deleted": True}
+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)")
+8 -4
View File
@@ -6,21 +6,25 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
_Role = Literal["super_admin", "finance", "operator"]
# 角色不再硬编码枚举:改为任意角色名(自定义角色由 admin_role 表管理),存在性在路由层校验。
class AdminCreateRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=64)
password: str = Field(..., min_length=8, max_length=72) # bcrypt ≤72 字节
role: _Role = "operator"
role: str = Field("operator", min_length=1, max_length=32)
# 仅当 role == "custom":这个人专属可见页 key 列表(逐页勾选结果)。其余角色不传/忽略。
pages_override: list[str] | None = None
class AdminUpdateRequest(BaseModel):
"""改角色 / 启用禁用 / 重置密码,字段都可选(只改传了的)。"""
"""改角色 / 启用禁用 / 重置密码 / 自定义可见页,字段都可选(只改传了的)。"""
role: _Role | None = None
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):
+9
View File
@@ -20,6 +20,15 @@ class AdminOut(BaseModel):
status: str
created_at: datetime
last_login_at: datetime | None = None
# 该管理员当前角色的有效可见页(= 左侧导航项 key);仅登录 / /me 填充,列表接口默认空。
# 前端据此过滤左侧导航(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
class AdminLoginResponse(BaseModel):
+1
View File
@@ -23,6 +23,7 @@ class AdminComparisonListItem(BaseModel):
status: str
information: str | None = None
store_name: str | None = None
product_names: str | None = None # 下单商品名派生串(顿号分隔;「商品」列展示 + 商品搜索)
source_platform_name: str | None = None
best_platform_name: str | None = None
source_price_cents: int | 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] = []
+35
View File
@@ -0,0 +1,35 @@
"""admin RBAC 角色 + 权限目录 schemas。"""
from __future__ import annotations
from pydantic import BaseModel, Field
class PermissionPage(BaseModel):
key: str
label: str
class PermissionGroup(BaseModel):
group: str
pages: list[PermissionPage]
class RoleOut(BaseModel):
id: int
name: str # 角色 key(承重,不可变)
label: str # 展示名(UI 显示)
pages: list[str] # 有效可见页 key(super_admin = 全部页)
is_builtin: bool # 内建角色(super_admin):不可编辑/删除
in_use: int # 使用该角色的管理员数(删除前端提示 / 拦截用)
class RoleCreateRequest(BaseModel):
# 新增自定义角色:name 即用户输入的名称(同时作 key 与展示名)
name: str = Field(..., min_length=1, max_length=32)
pages: list[str] = Field(default_factory=list)
class RoleUpdateRequest(BaseModel):
# 只改展示名 + 可见页;key 不可变
label: str | None = Field(None, min_length=1, max_length=32)
pages: list[str] | None = None
+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")
+18 -6
View File
@@ -11,7 +11,10 @@ from typing import Any
from app.core import rewards as r
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool / enum
# hidden=True:仍是合法可配项(业务照常 get_value / admin 可经专用端点读写),但**不在通用
# 「系统配置」页渲染**(admin/routers/config.py:list_config 按此过滤)。用于把已下线/已改由
# 专用页管理的项从福利页 Tab 收起,同时保留后端默认值与写入能力。
CONFIG_DEFS: dict[str, dict[str, Any]] = {
"signin_rewards": {
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
@@ -30,18 +33,21 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"default": r.WITHDRAW_MAX_CENTS, "label": "提现最高额(分)",
"group": "钱包", "type": "int",
},
# 从福利页 Tab 收起(hidden)的项:任务 / 里程碑 整组 + 看广告组里的「单次金币(遗留展示值)/
# 每轮次数 / 比价领券信息流广告开关 comparing_ad_enabled」。key 与默认值保留、后端业务照常读取,
# 仅不在配置页渲染。看广告组保留可见的:每日上限 / 单次金币上限 / 关闭后冷却。
"task_rewards": {
"default": dict(r.TASK_REWARDS), "label": "一次性任务奖励",
"group": "任务", "type": "dict_str_int", "help": "task_key → 金币。",
"group": "任务", "type": "dict_str_int", "help": "task_key → 金币。", "hidden": True,
},
"record_milestones": {
"default": list(r.RECORD_MILESTONES), "label": "比价里程碑金币档位",
"group": "里程碑", "type": "int_list",
"group": "里程碑", "type": "int_list", "hidden": True,
"help": "累计成功比价第 1~N 档解锁发的金币。",
},
"ad_reward_coin": {
"default": r.AD_REWARD_COIN, "label": "看广告单次金币",
"group": "看广告", "type": "int",
"group": "看广告", "type": "int", "hidden": True,
"help": "历史兼容/测试展示值;正式发放按 eCPM 公式计算。",
},
"ad_daily_limit": {
@@ -54,7 +60,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
},
"ad_round_count": {
"default": r.VIDEO_ROUND_REQUIRED_COUNT, "label": "每轮看广告次数",
"group": "看广告", "type": "int", "help": "当前为 1,表示每次广告关闭后触发短冷却。",
"group": "看广告", "type": "int", "hidden": True, "help": "当前为 1,表示每次广告关闭后触发短冷却。",
},
"ad_cooldown_sec": {
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)",
@@ -67,7 +73,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
},
"comparing_ad_enabled": {
"default": True, "label": "比价/领券期信息流广告",
"group": "看广告", "type": "bool",
"group": "看广告", "type": "bool", "hidden": True,
"help": (
"开启后,比价进行中 + 领券等候期会在悬浮窗展示穿山甲信息流广告(变现行为);"
"关闭则全程不出广告。客户端按 app 启动 / 每场比价开始时拉取并缓存,故为「最终一致」的"
@@ -84,4 +90,10 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
"提现页「批量对账」手动按钮不受影响。"
),
},
# 首页轮播数据源(hidden:不进通用配置 Tab,由「首页轮播种子」页的专用端点 /marquee-seeds/mode 读写)。
"marquee_feed_mode": {
"default": "mixed", "label": "首页轮播数据源",
"group": "首页轮播", "type": "enum", "hidden": True,
"help": "mixed=真实优先+种子补位(默认);real=只用真实比价记录;seed=只用种子/合成(演示)。",
},
}
+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()
+1
View File
@@ -5,6 +5,7 @@ from app.models.ad_pangle_revenue import AdPangleDailyRevenue # noqa: F401
from app.models.ad_reward import AdRewardRecord # noqa: F401
from app.models.ad_watch_log import AdWatchLog # noqa: F401
from app.models.admin import AdminAuditLog, AdminUser # noqa: F401
from app.models.admin_role import AdminRole # noqa: F401
from app.models.analytics_event import AnalyticsEvent # noqa: F401
from app.models.app_config import AppConfig # noqa: F401
from app.models.comparison import ComparisonRecord # noqa: F401
+7 -1
View File
@@ -25,8 +25,14 @@ class AdminUser(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
# super_admin(全权+管账号)/ finance(钱:提现+金币)/ operator(用户+反馈+大盘)
# 明文登录密码:仅「后台 UI 创建/重置」的管理员留存,供超管在权限管理页复看转交。
# 脚本/起后台时建的超管账号不写(为 None → 前端「不显示密码」)。⚠️ 内部工具便利取舍,见 create/list。
plain_password: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 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")
+42
View File
@@ -0,0 +1,42 @@
"""admin 角色 → 可见页面(权限)映射表。
RBAC 角色:每个角色持有一组页面 key(= 左侧导航项),决定该角色登录后台后左边能看到
哪些页super_admin 是内建全权角色(is_builtin=True),恒可见全部页(effective_pages 里特判,
不依赖本表存的 pages)不可编辑/删除其余角色( operator/finance)可由 super_admin 增删改
admin_user.role 存的是本表的 name(字符串弱引用,不建外键与既有 require_role 字符串口径一致;
删除在用角色由业务层拦截, routers/roles.py)页面 key 清单见 app/admin/permissions.py
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
_JSON = JSON().with_variant(JSONB(), "postgresql")
class AdminRole(Base):
__tablename__ = "admin_role"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 角色标识 key(不可变,承重):admin_user.role 引用它、require_role 按它鉴权。
# 内建 = super_admin/operator/finance/tech(英文,勿改);自定义 = 创建时的名称。
name: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False)
# 展示名(可改):内建 = 管理员/运营/财务/技术;自定义默认 = name。UI 一律展示 label。
label: Mapped[str] = mapped_column(String(32), nullable=False, default="")
# 该角色可见页面 key 列表(= 左侧导航项),见 app/admin/permissions.py 的 PERMISSION_CATALOG
pages: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 内建角色(当前仅 super_admin):不可编辑/删除,恒全权
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<AdminRole name={self.name} pages={len(self.pages)} builtin={self.is_builtin}>"
+4
View File
@@ -86,6 +86,10 @@ class ComparisonRecord(Base):
# ===== 订单概要 =====
store_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 下单商品名拼接串(顿号分隔,从 items[].name 去重派生),供 admin 列表「商品」列展示 + 商品名搜索。
# items 是 JSON(SQLite 下 json.dumps ensure_ascii 把中文转义,无法直接 CAST+LIKE),故另派生成普通
# 文本列——跨库 LIKE 一致、可加索引。写路径(upsert_record / harvest_done)落库时同步派生。
product_names: Mapped[str | None] = mapped_column(String(512), nullable=True)
total_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
skipped_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
+24 -3
View File
@@ -24,6 +24,23 @@ def _yuan_to_cents(yuan: float | None) -> int | None:
return round(yuan * 100)
def _product_names_from_items(items: list | None) -> str | None:
"""下单商品 items([{name, qty, specs?}])→ 顿号分隔的商品名串(去重保序),
admin商品列展示 + 商品名 LIKE 搜索 / 无名 None;超列宽(512)截断留余量"""
if not items:
return None
names: list[str] = []
for it in items:
name = it.get("name") if isinstance(it, dict) else None
if not name:
continue
s = str(name).strip()
if s and s not in names:
names.append(s)
joined = "".join(names)
return joined[:500] or None
def _derive(payload: ComparisonRecordIn) -> dict:
"""从上报 payload 派生结构化列(best/saved/is_source_best/status)。"""
results = payload.comparison_results
@@ -81,10 +98,12 @@ def upsert_record(
新客户端不再 POST(改由 compare.py 透传壳 harvest 落库)
"""
derived = _derive(payload)
items = [it.model_dump(exclude_none=True) for it in payload.items]
fields = dict(
device_id=payload.device_id,
business_type=payload.business_type,
store_name=payload.store_name,
product_names=_product_names_from_items(items),
source_platform_id=payload.source_platform_id,
source_platform_name=payload.source_platform_name,
source_package=payload.source_package,
@@ -93,7 +112,7 @@ def upsert_record(
trace_url=payload.trace_url,
total_dish_count=payload.total_dish_count,
skipped_dish_count=payload.skipped_dish_count,
items=[it.model_dump(exclude_none=True) for it in payload.items],
items=items,
comparison_results=[r.model_dump() for r in payload.comparison_results],
skipped_dish_names=list(payload.skipped_dish_names),
# 客户端环境 / 性能(debug,客户端上报;旧客户端为 None)
@@ -288,6 +307,8 @@ def harvest_done(
行不存在(理论上帧0已建;防御)则新建"""
results = done_params.get("comparison_results") or []
derived = _derive_from_results(results)
# 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items
items = next((r.get("items") or [] for r in results if r.get("is_source")), [])
fields = dict(
business_type=business_type or "food",
information=done_params.get("information") or None,
@@ -298,8 +319,8 @@ def harvest_done(
skipped_dish_count=done_params.get("skipped_dish_count"),
skipped_dish_names=list(done_params.get("skipped_dish_names") or []),
comparison_results=results,
# 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items
items=next((r.get("items") or [] for r in results if r.get("is_source")), []),
items=items,
product_names=_product_names_from_items(items),
raw_payload=done_params,
**derived,
**_device_cols_from_info(device_info),
+66 -42
View File
@@ -29,6 +29,18 @@ from app.core.rewards import CN_TZ
from app.models.comparison import ComparisonRecord
from app.models.ops_marquee_seed import OpsMarqueeSeed
from app.models.user import User
from app.repositories import app_config
from app.repositories.user import is_default_nickname
# 首页轮播数据源模式(存 app_config.marquee_feed_mode):
# mixed=真实优先+种子补位+合成兜底(默认,原行为);real=只真实(不足则少/空);seed=只种子+合成兜底。
FEED_MODES = ("mixed", "real", "seed")
def get_feed_mode(db: Session) -> str:
"""读首页轮播数据源模式;非法/未配置回退 mixed(= 原行为)。"""
mode = app_config.get_value(db, "marquee_feed_mode")
return mode if mode in FEED_MODES else "mixed"
# feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」)
_rng = random.Random()
@@ -129,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)
@@ -202,9 +217,11 @@ 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 get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]:
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
mode:显式传入(admin 预览指定模式)则用它**不改持久化配置**;不传(客户端 /savings-feed)
持久化的 marquee_feed_mode;非法值一律回退到持久化模式
真实条:success 0 < saved 上限, user 去重(同一用户只取最新一条,避免单人刷屏)
不足用启用的种子补齐**公平随机抽取** need (而非固定取前 N),让所有种子都有机会露出;
种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多偶尔大额,更像真实分布)
@@ -212,50 +229,57 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]:
展示时间统一刷新成相对现在的最近时刻( now 往前**随机抖动**递减),保证轮播永远像刚发生
节奏自然不机械(真实用户/金额不变,只换展示时间避免旧测试数据 / 低谷期记录显示成过时时间)
"""
# 真实条:取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除
rows = _recent_real_rows(db)
# 预览可显式指定模式(所见=选中模式,不依赖 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()
seen_users: set[int] = set()
for uid, sc, nick in rows:
if uid in seen_users:
continue
seen_users.add(uid)
name = _mask_real(nick, uid)
used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
if len(items) >= limit:
break
need = limit - len(items)
if need > 0:
seeds = db.execute(
select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True))
).scalars().all()
# 公平随机抽取 need 个(池子够大则不放回抽样;否则全用并打散),让所有启用种子都有机会露出。
chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds)
_rng.shuffle(chosen)
for s in chosen:
# 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子);
# 仅运营手动设的非模板真名才原样用。
fixed = (s.masked_user or "").strip()
name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names)
# 真实条(mixed / real):取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。
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)
name = _mask_real(nick, uid)
used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
if len(items) >= limit:
break
# 种子补位 + 合成兜底(mixed / seed):mixed 下补真实不足的部分,seed 下全量用种子/合成。
# real 模式**跳过**——只出真实,不掺任何假数据(真实不足则少于 limit,为 0 时返回空)。
if mode != "real":
need = limit - len(items)
if need > 0:
seeds = db.execute(
select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True))
).scalars().all()
# 公平随机抽取 need 个(池子够大则不放回抽样;否则全用并打散),让所有启用种子都有机会露出。
chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds)
_rng.shuffle(chosen)
for s in chosen:
# 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子);
# 仅运营手动设的非模板真名才原样用。
fixed = (s.masked_user or "").strip()
name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names)
used_names.add(name)
# 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。
lo = max(0, int(s.min_cents))
hi = max(lo, int(s.max_cents))
items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)})
# 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满,
# 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。
while len(items) < limit:
name = _unique_name(used_names)
used_names.add(name)
# 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。
lo = max(0, int(s.min_cents))
hi = max(lo, int(s.max_cents))
items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)})
# 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满,
# 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。
while len(items) < limit:
name = _unique_name(used_names)
used_names.add(name)
items.append({
"masked_user": name,
"saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS),
})
items.append({
"masked_user": name,
"saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS),
})
# 统一赋「最近」时间:从 now 往前**随机抖动**递减,避免固定节奏被看出规律。
# 首条几十秒前;其余多数 1~5 分钟,偶尔扎堆(20~55s)或较长(5~9 分钟),降序、像真实流水。
+5 -2
View File
@@ -318,11 +318,14 @@ def update_config(
row.random_current = _monotonic(row, random_initial)
row.random_last_tick_at = now
elif apply_now:
# 立即更新:不等钟点,马上刷新一次
# 立即更新:不等钟点,马上刷新一次
# random:走一档增长;real/manual:直接落到「配置目标值」(manual_value / max(真实,保底)),
# **显式保存即所见即所得,绕过「只增不减」护栏**——运营手动改值就是要按配置显示(含调小)。
# (护栏仍作用于自动 tick 的 _refresh:防门面在两次保存之间被真实刷新/自增长悄悄缩水。)
if row.mode == "random" and row.random_current is not None:
row.random_current = _grow(row, row.random_current, 1)
else:
row.random_current = _monotonic(row, _current_target(db, row))
row.random_current = _current_target(db, row)
row.random_last_tick_at = now
elif row.random_current is None:
# 首次无值:按当前模式播种,使配置后立即有合理展示值
+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)))
+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()
+8 -1
View File
@@ -68,7 +68,14 @@ def test_list_config(admin_client: TestClient, token: str) -> None:
r = admin_client.get("/admin/api/config", headers=_auth(token))
assert r.status_code == 200, r.text
items = {i["key"]: i for i in r.json()}
assert "signin_rewards" in items and "ad_daily_limit" in items
# 非 hidden 项照常返回;看广告组保留可见的:每日上限 / 单次金币上限 / 关闭后冷却。
assert "signin_rewards" in items and "ad_daily_limit" in items and "ad_cooldown_sec" in items
# hidden 项(任务/里程碑、首页轮播数据源、看广告组的单次金币/每轮次数/信息流广告开关)不在配置页返回。
for hidden_key in (
"task_rewards", "record_milestones", "marquee_feed_mode",
"ad_reward_coin", "ad_round_count", "comparing_ad_enabled",
):
assert hidden_key not in items, f"{hidden_key} 应被 hidden 过滤"
assert items["signin_rewards"]["value"] == [
200, 200, 300, 200, 400, 400, 800,
]
+229
View File
@@ -0,0 +1,229 @@
"""Admin RBAC 角色/权限测试:可见页下发、角色 CRUD 仅 super_admin、内建/在用保护。"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.admin.main import admin_app
from app.admin.repositories import admin_user as admin_repo
from app.core.security import hash_password
from app.db.session import SessionLocal
@pytest.fixture()
def admin_client() -> TestClient:
return TestClient(admin_app)
def _token(username: str, role: str) -> str:
db = SessionLocal()
try:
a = admin_repo.get_by_username(db, username)
if a is None:
admin_repo.create_admin(db, username=username, password="pass1234", role=role)
else:
a.password_hash = hash_password("pass1234")
a.role = role
a.status = "active"
db.commit()
finally:
db.close()
c = TestClient(admin_app)
return c.post(
"/admin/api/auth/login", json={"username": username, "password": "pass1234"}
).json()["access_token"]
@pytest.fixture()
def super_token() -> str:
return _token("r_super", "super_admin")
@pytest.fixture()
def operator_token() -> str:
return _token("r_operator", "operator")
def _auth(t: str) -> dict:
return {"Authorization": f"Bearer {t}"}
def test_super_pages_all_operator_limited(admin_client, super_token, operator_token) -> None:
su = admin_client.get("/admin/api/auth/me", headers=_auth(super_token)).json()
assert "admins" in su["pages"] and "dashboard" in su["pages"] # 超管全页
op = admin_client.get("/admin/api/auth/me", headers=_auth(operator_token)).json()
assert "dashboard" in op["pages"] and "admins" not in op["pages"] # 运营看不到管理员页
def test_roles_endpoints_super_only(admin_client, super_token, operator_token) -> None:
assert admin_client.get("/admin/api/roles", headers=_auth(super_token)).status_code == 200
assert admin_client.get("/admin/api/roles", headers=_auth(operator_token)).status_code == 403
def test_role_crud(admin_client, super_token) -> None:
cat = admin_client.get("/admin/api/roles/catalog", headers=_auth(super_token)).json()
assert any(g["group"] == "看板" for g in cat)
r = admin_client.post(
"/admin/api/roles", json={"name": "审核", "pages": ["dashboard", "feedbacks", "xx-bad"]},
headers=_auth(super_token),
)
assert r.status_code == 200, r.text
rid = r.json()["id"]
assert set(r.json()["pages"]) == {"dashboard", "feedbacks"} # 非法 key 被过滤
# 重名 409
assert admin_client.post(
"/admin/api/roles", json={"name": "审核", "pages": []}, headers=_auth(super_token)
).status_code == 409
# 改可见页
u = admin_client.patch(
f"/admin/api/roles/{rid}", json={"pages": ["dashboard"]}, headers=_auth(super_token)
)
assert u.status_code == 200 and u.json()["pages"] == ["dashboard"]
# 删
assert admin_client.delete(f"/admin/api/roles/{rid}", headers=_auth(super_token)).status_code == 200
def test_builtin_super_admin_protected(admin_client, super_token) -> None:
roles = admin_client.get("/admin/api/roles", headers=_auth(super_token)).json()
sa = next(r for r in roles if r["name"] == "super_admin")
assert sa["is_builtin"] and len(sa["pages"]) >= 10 # 全部页
assert admin_client.patch(
f"/admin/api/roles/{sa['id']}", json={"pages": []}, headers=_auth(super_token)
).status_code == 400
assert admin_client.delete(
f"/admin/api/roles/{sa['id']}", headers=_auth(super_token)
).status_code == 400
def test_delete_role_in_use_blocked(admin_client, super_token) -> None:
admin_client.post(
"/admin/api/admins",
json={"username": "role_holder", "password": "pass1234", "role": "operator"},
headers=_auth(super_token),
)
roles = admin_client.get("/admin/api/roles", headers=_auth(super_token)).json()
op = next(r for r in roles if r["name"] == "operator")
assert op["in_use"] >= 1
assert admin_client.delete(
f"/admin/api/roles/{op['id']}", headers=_auth(super_token)
).status_code == 400
def test_builtin_roles_labels_and_pages(admin_client, super_token) -> None:
roles = {r["name"]: r for r in admin_client.get("/admin/api/roles", headers=_auth(super_token)).json()}
# 中文展示名(key 不变)
assert roles["super_admin"]["label"] == "管理员"
assert roles["operator"]["label"] == "运营"
assert roles["finance"]["label"] == "财务"
assert roles["tech"]["label"] == "技术"
# 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES
assert set(roles["finance"]["pages"]) == {"dashboard", "ad-revenue-report", "cps", "withdraws"}
assert set(roles["tech"]["pages"]) == {
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
}
def test_ui_created_admin_shows_password_script_admin_hidden(admin_client, super_token) -> None:
# UI 建号:账号列表回显明文登录密码(供权限管理页编辑复看)
admin_client.post(
"/admin/api/admins",
json={"username": "pw_show_user", "password": "pass1234", "role": "operator"},
headers=_auth(super_token),
)
admins = {a["username"]: a for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()}
assert admins["pw_show_user"]["password"] == "pass1234"
# 经 repo/脚本直建的账号(r_super,无留存明文)→ password 为 None,前端不显示
assert admins["r_super"]["password"] is None
# 登录 / me 不下发明文(保持 None)
me = admin_client.get("/admin/api/auth/me", headers=_auth(super_token)).json()
assert me.get("password") is None
def test_create_admin_rejects_unknown_role(admin_client, super_token) -> None:
r = admin_client.post(
"/admin/api/admins",
json={"username": "bad_role_user", "password": "pass1234", "role": "不存在的角色"},
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"]