Compare commits

..

1 Commits

Author SHA1 Message Date
no_gen_mu 35f24084ae feat(user): 注销账号数据级联清理 + 后台删除端点
C 端与后台复用同一套 soft_delete_account:物理删除个人内容/行为/PII 表(比价/省钱/
签到/领券/任务/设备/价格上报/反馈/引导/埋点/看广告时长/邀请指纹)+ 清零余额 + 匿名化
user 行释放唯一约束;保留资金流水/邀请关系/广告幂等+对账表(对账/幂等/邀请人侧归属)。

- api/v1/user.py: DELETE /api/v1/user 加资金前置闸(未提现现金/邀请金 / 在审提现单 → 409)
- repositories/user.py: soft_delete_account 落地分类删除(补 ad_watch_log 埋点删除)
- repositories/wallet.py: 新增 get_invite_cash_balance_cents(邀请金 409 校验用)
- admin/routers/users.py: DELETE /admin/api/users/{id} 复用清理 + 审计,仅 super_admin
- admin/schemas/user.py: DeleteUserRequest(必填 reason 入审计)
- tests: 级联删除/余额归零/保留表存活 6 测 + admin 删除/审计/403/400/409 4 测

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:00:40 +08:00
51 changed files with 509 additions and 2530 deletions
-9
View File
@@ -48,12 +48,3 @@ 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
@@ -1,69 +0,0 @@
"""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")
@@ -1,29 +0,0 @@
"""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")
@@ -1,70 +0,0 @@
"""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")
+12 -3
View File
@@ -4,6 +4,7 @@
复用 App 的 DB/models/repositories/integrations;鉴权独立(admin JWT,见 app/admin/security.py)。
现有 app.main:app 不 import 本模块,两进程互不影响。
"""
from __future__ import annotations
import logging
@@ -12,6 +13,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import PlainTextResponse, RedirectResponse
from app.admin.routers.ad_audit import router as ad_audit_router
from app.admin.routers.ad_config import router as ad_config_router
@@ -25,14 +27,13 @@ from app.admin.routers.coupon_data import router as coupon_data_router
from app.admin.routers.cps import router as cps_router
from app.admin.routers.dashboard import router as dashboard_router
from app.admin.routers.device_liveness import router as device_liveness_router
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
from app.admin.routers.event_logs import router as event_logs_router
from app.admin.routers.feedback import router as feedback_router
from app.admin.routers.feedback_qr import router as feedback_qr_router
from app.admin.routers.onboarding import router as onboarding_router
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
from app.admin.routers.ops_stat_config import router as ops_stat_config_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
@@ -85,6 +86,15 @@ def health() -> dict[str, str]:
return {"status": "ok", "service": "admin"}
@admin_app.get("/", include_in_schema=False)
def root():
"""根路径落脚点:直接访问 :8771 时,非 prod 跳 API 文档,prod 下给一句纯文本说明
(prod 关了 docs_url,不能跳一个不存在的页面,也不暴露文档)。避免浏览器打开根路径吃 404。"""
if not settings.is_prod:
return RedirectResponse(url="/admin/docs")
return PlainTextResponse("shaguabijia admin API. See /admin/api/*.")
admin_app.include_router(auth_router)
admin_app.include_router(dashboard_router)
admin_app.include_router(device_liveness_router)
@@ -99,7 +109,6 @@ 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)
-80
View File
@@ -1,80 +0,0 @@
"""admin 后台「页面权限目录」—— RBAC 的权限侧单一真源。
一个权限 = 一个页面(= 左侧导航项)。角色持有一组 page key,登录后左侧只展示这些页(见前端
layout.tsx 按 pages 过滤导航)。key 必须与前端路由一级对齐(/dashboard → "dashboard")。
super_admin 为内建全权角色,恒可见全部页(effective_pages 特判)。新增页面 = 这里加一条 +
前端导航加对应项(key 一致即可被各角色勾选可见)。
"""
from __future__ import annotations
SUPER_ADMIN_ROLE = "super_admin"
# 分组镜像前端导航(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)
+4 -6
View File
@@ -385,12 +385,10 @@ def ad_revenue_report(
for k, v in type_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)
)
# 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
# 主表「逐行」= 单次广告行为(2026-07 按「一次比价/领券放一块」聚合):激励视频 = 一次观看一行(展示+发奖
# 按 ad_session_id 合并);一次比价 / 一次领券 = 该次整场多条广告按 ad_session_id 聚成一行(展开看逐条)。
-81
View File
@@ -1,81 +0,0 @@
"""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)
+1 -9
View File
@@ -20,20 +20,12 @@ def get_by_username(db: Session, username: str) -> AdminUser | None:
def create_admin(
db: Session,
*,
username: str,
password: str,
role: str = "operator",
plain_password: str | None = None,
db: Session, *, username: str, password: str, role: str = "operator"
) -> AdminUser:
"""建管理员。plain_password 非空则额外留存明文(后台 UI 建的账号传,供权限管理页复看);
脚本/起后台建账号不传(留 None → 前端不显示密码)。"""
admin = AdminUser(
username=username,
password_hash=hash_password(password),
role=role,
plain_password=plain_password,
)
db.add(admin)
db.commit()
+1 -9
View File
@@ -148,14 +148,11 @@ 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/业务类型筛,
store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。
join User 取 phone/nickname 瞬态挂记录上。"""
offset 分页(创建时间倒序、id 兜底)。join User 取 phone/nickname 瞬态挂记录上。"""
stmt = select(ComparisonRecord)
if user_id is not None:
stmt = stmt.where(ComparisonRecord.user_id == user_id)
@@ -169,11 +166,6 @@ 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)),
+61 -65
View File
@@ -55,10 +55,29 @@ def _beijing_today_start_utc() -> datetime:
def today_dau(db: Session) -> int:
"""今日活跃用户数(DAU):登录 + 开始比价 + 开始领券,按用户去重。
= period_active_dau(今天, 今天);历史 / 多天窗口用 period_active_dau 传区间(广告收益报表复用)
广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算
"""
today_bj = datetime.now(_BEIJING).date()
return period_active_dau(db, today_bj, today_bj)
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)
def _default_period_end() -> date:
@@ -115,56 +134,6 @@ 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:
@@ -292,12 +261,24 @@ def dashboard_overview(
period_new_user_ids = _user_id_set(
select(User.id).where(User.created_at >= start_utc, User.created_at < end_utc)
)
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,
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 = (
@@ -314,17 +295,32 @@ def dashboard_overview(
ComparisonRecord.created_at >= day_start_local,
ComparisonRecord.created_at < day_end_local,
)
daily_login_user_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",
)
)
trend_points.append(
{
"date": cur_date,
"active_users": len(
_period_active_user_ids(
db,
start_utc=day_start_utc,
end_utc=day_end_utc,
period_from=cur_date,
period_to=cur_date,
)
daily_login_user_ids
| daily_compare_start_user_ids
| daily_coupon_event_user_ids
| daily_coupon_claim_user_ids
),
"new_users": _count(
User,
+3 -49
View File
@@ -5,8 +5,6 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
from app.admin.permissions import SUPER_ADMIN_ROLE
from app.admin.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
@@ -25,23 +23,9 @@ def _active_super_count(db: AdminDb) -> int:
)
def _validate_role(db: AdminDb, role: str) -> None:
"""角色必须是 super_admin 或 admin_role 表里已存在的角色,否则 400。"""
if role == SUPER_ADMIN_ROLE:
return
role_repo.ensure_builtin_roles(db) # 空表(测试/全新库)兜底播种,再校验
if role_repo.get_role(db, role) is None:
raise HTTPException(status_code=400, detail=f"角色不存在: {role}")
@router.get("", response_model=list[AdminOut], summary="管理员列表(含明文密码,super_admin 专属)")
@router.get("", response_model=list[AdminOut], summary="管理员列表")
def list_admins(db: AdminDb) -> list[AdminOut]:
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
return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)]
@router.post("", response_model=AdminOut, summary="创建管理员")
@@ -50,10 +34,8 @@ 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)
new = admin_repo.create_admin(
db, username=body.username, password=body.password, role=body.role,
plain_password=body.password, # UI 建的账号留存明文,供权限管理页复看
db, username=body.username, password=body.password, role=body.role
)
write_audit(
db, admin, action="admin.create", target_type="admin", target_id=new.id,
@@ -85,9 +67,6 @@ 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:
changes["role"] = {"before": target.role, "after": body.role}
@@ -98,7 +77,6 @@ def update_admin(
if body.password is not None:
changes["password"] = "reset"
target.password_hash = hash_password(body.password)
target.plain_password = body.password # 同步留存明文,权限管理页复看保持一致
if not changes:
raise HTTPException(status_code=400, detail="无任何变更字段")
db.commit()
@@ -108,27 +86,3 @@ 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}
+4 -12
View File
@@ -6,7 +6,6 @@ import logging
from fastapi import APIRouter, Depends, HTTPException
from app.admin.deps import AdminDb, CurrentAdmin
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
@@ -18,13 +17,6 @@ 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 + 当前角色有效可见页(前端左侧导航按此过滤)。"""
out = AdminOut.model_validate(admin)
out.pages = role_repo.effective_pages_of(db, admin.role)
return out
@router.post(
"/login",
response_model=AdminLoginResponse,
@@ -47,10 +39,10 @@ def login(req: AdminLoginRequest, db: AdminDb) -> AdminLoginResponse:
return AdminLoginResponse(
access_token=token,
expires_in=expires_in,
admin=_admin_out_with_pages(admin, db),
admin=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)
@router.get("/me", response_model=AdminOut, summary="当前管理员")
def me(admin: CurrentAdmin) -> AdminOut:
return AdminOut.model_validate(admin)
+1 -4
View File
@@ -32,15 +32,12 @@ 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, store=store, product=product,
limit=limit, cursor=cursor,
business_type=business_type, limit=limit, cursor=cursor,
)
return CursorPage(
items=[AdminComparisonListItem.model_validate(r) for r in items],
+2 -7
View File
@@ -55,14 +55,9 @@ def _item(db, key: str) -> ConfigItemOut:
raise HTTPException(status_code=404, detail="未知配置项")
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值(不含 hidden)")
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值")
def list_config(db: AdminDb) -> list[ConfigItemOut]:
# hidden 项(已下线/由专用页管理,如福利页任务·里程碑·看广告调参、首页轮播数据源)不在本页渲染。
return [
ConfigItemOut(**item)
for item in app_config.list_all(db)
if not CONFIG_DEFS[item["key"]].get("hidden")
]
return [ConfigItemOut(**item) for item in app_config.list_all(db)]
@router.patch("/{key}", response_model=ConfigItemOut, summary="改某项配置(带审计)")
+1 -33
View File
@@ -8,7 +8,6 @@ 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
@@ -23,11 +22,7 @@ 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 app_config, ops_marquee
class MarqueeModeUpdate(BaseModel):
mode: str # mixed / real / seed
from app.repositories import ops_marquee
router = APIRouter(
prefix="/admin/api/marquee-seeds",
@@ -62,33 +57,6 @@ def preview_feed(
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit))
# 注:/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="新增轮播种子(带审计)")
def create_seed(
body: OpsMarqueeSeedCreate,
-129
View File
@@ -1,129 +0,0 @@
"""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}
+35
View File
@@ -13,6 +13,7 @@ from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.user import (
AdminUserListItem,
AdminUserOverview,
DeleteUserRequest,
GrantCashRequest,
GrantCoinsRequest,
SetDebugTraceRequest,
@@ -132,6 +133,40 @@ def set_user_status(
return OkResponse()
@router.delete("/{user_id}", response_model=OkResponse, summary="注销/删除账号(清理个人数据 + 匿名化,带审计)")
def delete_user(
user_id: int,
body: DeleteUserRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("super_admin"))],
db: AdminDb,
) -> OkResponse:
"""后台删除账号:复用 C 端自助注销同一套清理逻辑(soft_delete_account)——物理删除个人内容/
行为/PII 表 + 清零余额 + 匿名化 user 行释放唯一约束,保留资金流水/邀请关系/广告幂等+对账表。
仅 super_admin 可操作(最高危、不可逆)。资金安全同 C 端:有未提现现金/邀请金 / 在审提现单 → 409 拒绝。
"""
user = user_repo.get_user_by_id(db, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
if user.status == "deleted":
raise HTTPException(status_code=400, detail="账号已注销,请勿重复操作")
# 资金前置校验(与 C 端 delete_account 端点一致):余额一旦清零拿不回,有活钱先拦下。
if wallet_repo.get_cash_balance_cents(db, user_id) > 0:
raise HTTPException(status_code=409, detail="该账户还有未提现的现金余额,请先处理后再删除")
if wallet_repo.get_invite_cash_balance_cents(db, user_id) > 0:
raise HTTPException(status_code=409, detail="该账户还有未提现的邀请奖励金,请先处理后再删除")
if wallet_repo.has_reviewing_withdraw(db, user_id):
raise HTTPException(status_code=409, detail="该账户有提现正在审核中,请等审核完成后再删除")
# 先写审计(commit=False,挂进 session),再由 soft_delete_account 做清理 + 最终 commit —— 一次 commit
# 同时落审计行与清理结果,保持"改了就有痕、有痕就改了"原子(soft_delete_account 内部 commit 会一并 flush)。
write_audit(
db, admin, action="user.delete", target_type="user", target_id=user_id,
detail={"reason": body.reason, "phone": user.phone}, ip=get_client_ip(request), commit=False,
)
user_repo.soft_delete_account(db, user)
return OkResponse()
@router.post("/{user_id}/debug-trace", response_model=OkResponse, summary="开关调试链接权限")
def set_user_debug_trace(
user_id: int,
+1 -3
View File
@@ -138,9 +138,7 @@ class AdRevenueReportOut(BaseModel):
)
dau: int | None = Field(
None,
description="所选日期区间的去重活跃用户数(口径同数据大盘 period.users.active:登录 + 开始比价 + "
"开始领券)。按 date_from~date_to 区间统计,含今日、近 7 天、近 30 天等任意区间;全局口径,"
"不随 user_id / ad_type / feed_scene / app_env 筛选变化",
description="今日活跃用户数(复用大盘口径,last_login_at);**仅查询=今日单天时有值**,历史/多天为 null",
)
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
+3 -3
View File
@@ -6,19 +6,19 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
# 角色不再硬编码枚举:改为任意角色名(自定义角色由 admin_role 表管理),存在性在路由层校验。
_Role = Literal["super_admin", "finance", "operator"]
class AdminCreateRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=64)
password: str = Field(..., min_length=8, max_length=72) # bcrypt ≤72 字节
role: str = Field("operator", min_length=1, max_length=32)
role: _Role = "operator"
class AdminUpdateRequest(BaseModel):
"""改角色 / 启用禁用 / 重置密码,字段都可选(只改传了的)。"""
role: str | None = Field(None, min_length=1, max_length=32)
role: _Role | None = None
status: Literal["active", "disabled"] | None = None
password: str | None = Field(None, min_length=8, max_length=72)
-6
View File
@@ -20,12 +20,6 @@ class AdminOut(BaseModel):
status: str
created_at: datetime
last_login_at: datetime | None = None
# 该管理员当前角色的有效可见页(= 左侧导航项 key);仅登录 / /me 填充,列表接口默认空。
# 前端据此过滤左侧导航(super_admin = 全部页)。见 app/admin/permissions.py。
pages: list[str] = []
# 明文登录密码:仅「管理员账号列表」(super_admin 专属路由)填充,供权限管理页编辑时复看;
# 无留存(脚本建的超管 / 旧账号)为 None → 前端不显示。登录 / /me 不下发(保持 None)。
password: str | None = None
class AdminLoginResponse(BaseModel):
-1
View File
@@ -23,7 +23,6 @@ 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
-35
View File
@@ -1,35 +0,0 @@
"""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
+6
View File
@@ -115,3 +115,9 @@ class SetUserStatusRequest(BaseModel):
class SetDebugTraceRequest(BaseModel):
enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限")
class DeleteUserRequest(BaseModel):
reason: str = Field(..., min_length=1, max_length=128, description="删除原因(必填,入审计)")
_v_reason = field_validator("reason")(_strip_reason)
+4 -41
View File
@@ -25,19 +25,9 @@ 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"])
@@ -185,19 +175,13 @@ 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,
MeituanCoupon.city_id == city_id,
)
base = select(MeituanCoupon).where(MeituanCoupon.commission_percent >= 3.0)
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
MeituanCoupon.dedup_key,
MeituanCoupon.commission_percent.desc(),
@@ -227,9 +211,6 @@ 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")
@@ -273,24 +254,14 @@ 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),
MeituanCoupon.city_id == city_id,
)
base = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None))
if req.platform is not None:
base = base.where(MeituanCoupon.platform == req.platform)
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
@@ -321,14 +292,6 @@ 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")
+7 -3
View File
@@ -98,15 +98,19 @@ def onboarding_status(
return OnboardingStatusResponse(completed=completed)
@router.delete("", response_model=OkResponse, summary="注销账号(软删除)")
@router.delete("", response_model=OkResponse, summary="注销账号(清理个人数据 + 匿名化)")
def delete_account(user: CurrentUser, db: DbSession) -> OkResponse:
# 资金前置校验:注销是软删 + 匿名化,余额一旦留在 deleted 行就锁死(既退不出、新号也
# 拿不回)。有可提现现金 / 在审提现单 → 拒绝注销,引导用户先把钱处理掉。
# 资金前置校验:注销会物理清理个人数据 + 清零余额 + 匿名化,余额一旦清零就再也拿不回。
# 有可提现现金 / 邀请奖励金 / 在审提现单 → 拒绝注销,引导用户先把钱处理掉。
# (pending 提现转账已在途、对账 worker 不依赖 user.status 照常到账,故不拦 pending。)
if wallet_repo.get_cash_balance_cents(db, user.id) > 0:
raise HTTPException(
status_code=409, detail="账户还有未提现的现金余额,请先提现后再注销"
)
if wallet_repo.get_invite_cash_balance_cents(db, user.id) > 0:
raise HTTPException(
status_code=409, detail="账户还有未提现的邀请奖励金,请先提现后再注销"
)
if wallet_repo.has_reviewing_withdraw(db, user.id):
raise HTTPException(
status_code=409, detail="有提现正在审核中,请等审核完成后再注销"
+6 -18
View File
@@ -11,10 +11,7 @@ from typing import Any
from app.core import rewards as r
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool / enum
# hidden=True:仍是合法可配项(业务照常 get_value / admin 可经专用端点读写),但**不在通用
# 「系统配置」页渲染**(admin/routers/config.py:list_config 按此过滤)。用于把已下线/已改由
# 专用页管理的项从福利页 Tab 收起,同时保留后端默认值与写入能力。
# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool
CONFIG_DEFS: dict[str, dict[str, Any]] = {
"signin_rewards": {
"default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位",
@@ -33,21 +30,18 @@ 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 → 金币。", "hidden": True,
"group": "任务", "type": "dict_str_int", "help": "task_key → 金币。",
},
"record_milestones": {
"default": list(r.RECORD_MILESTONES), "label": "比价里程碑金币档位",
"group": "里程碑", "type": "int_list", "hidden": True,
"group": "里程碑", "type": "int_list",
"help": "累计成功比价第 1~N 档解锁发的金币。",
},
"ad_reward_coin": {
"default": r.AD_REWARD_COIN, "label": "看广告单次金币",
"group": "看广告", "type": "int", "hidden": True,
"group": "看广告", "type": "int",
"help": "历史兼容/测试展示值;正式发放按 eCPM 公式计算。",
},
"ad_daily_limit": {
@@ -60,7 +54,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
},
"ad_round_count": {
"default": r.VIDEO_ROUND_REQUIRED_COUNT, "label": "每轮看广告次数",
"group": "看广告", "type": "int", "hidden": True, "help": "当前为 1,表示每次广告关闭后触发短冷却。",
"group": "看广告", "type": "int", "help": "当前为 1,表示每次广告关闭后触发短冷却。",
},
"ad_cooldown_sec": {
"default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)",
@@ -73,7 +67,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
},
"comparing_ad_enabled": {
"default": True, "label": "比价/领券期信息流广告",
"group": "看广告", "type": "bool", "hidden": True,
"group": "看广告", "type": "bool",
"help": (
"开启后,比价进行中 + 领券等候期会在悬浮窗展示穿山甲信息流广告(变现行为);"
"关闭则全程不出广告。客户端按 app 启动 / 每场比价开始时拉取并缓存,故为「最终一致」的"
@@ -90,10 +84,4 @@ 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,12 +71,6 @@ 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,7 +5,6 @@ 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
-3
View File
@@ -25,9 +25,6 @@ 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)
# 明文登录密码:仅「后台 UI 创建/重置」的管理员留存,供超管在权限管理页复看转交。
# 脚本/起后台时建的超管账号不写(为 None → 前端「不显示密码」)。⚠️ 内部工具便利取舍,见 create/list。
plain_password: Mapped[str | None] = mapped_column(String(128), nullable=True)
# super_admin(全权+管账号)/ finance(钱:提现+金币)/ operator(用户+反馈+大盘)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="operator")
# active / disabled
-42
View File
@@ -1,42 +0,0 @@
"""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,10 +86,6 @@ 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)
+3 -24
View File
@@ -24,23 +24,6 @@ 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
@@ -98,12 +81,10 @@ 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,
@@ -112,7 +93,7 @@ def upsert_record(
trace_url=payload.trace_url,
total_dish_count=payload.total_dish_count,
skipped_dish_count=payload.skipped_dish_count,
items=items,
items=[it.model_dump(exclude_none=True) for it in payload.items],
comparison_results=[r.model_dump() for r in payload.comparison_results],
skipped_dish_names=list(payload.skipped_dish_names),
# 客户端环境 / 性能(debug,客户端上报;旧客户端为 None)
@@ -307,8 +288,6 @@ 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,
@@ -319,8 +298,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,
items=items,
product_names=_product_names_from_items(items),
# 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items
items=next((r.get("items") or [] for r in results if r.get("is_source")), []),
raw_payload=done_params,
**derived,
**_device_cols_from_info(device_info),
+39 -56
View File
@@ -29,17 +29,6 @@ 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
# 首页轮播数据源模式(存 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()
@@ -223,56 +212,50 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]:
展示时间统一「刷新」成相对现在的最近时刻(从 now 往前**随机抖动**递减),保证轮播永远像刚发生、
节奏自然不机械(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时时间)。
"""
mode = get_feed_mode(db) # mixed / real / seed(运营可在「首页轮播种子」页切换)
# 真实条:取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。
rows = _recent_real_rows(db)
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
# 真实条(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)
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)
items.append({
"masked_user": name,
"saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS),
})
# 金额:在 [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),
})
# 统一赋「最近」时间:从 now 往前**随机抖动**递减,避免固定节奏被看出规律。
# 首条几十秒前;其余多数 1~5 分钟,偶尔扎堆(20~55s)或较长(5~9 分钟),降序、像真实流水。
+2 -5
View File
@@ -318,14 +318,11 @@ 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 = _current_target(db, row)
row.random_current = _monotonic(row, _current_target(db, row))
row.random_last_tick_at = now
elif row.random_current is None:
# 首次无值:按当前模式播种,使配置后立即有合理展示值
+71 -8
View File
@@ -8,10 +8,29 @@ import secrets
import string
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.models.ad_watch_log import AdWatchLog
from app.models.analytics_event import AnalyticsEvent
from app.models.comparison import ComparisonRecord
from app.models.comparison_milestone import ComparisonMilestoneClaim
from app.models.coupon_state import (
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
CouponSession,
)
from app.models.device import DeviceLiveness
from app.models.feedback import Feedback
from app.models.invite_fingerprint import InviteFingerprint
from app.models.onboarding import OnboardingCompletion
from app.models.price_report import PriceReport
from app.models.savings import SavingsRecord
from app.models.signin import SigninBoostRecord, SigninRecord
from app.models.task import UserTask
from app.models.user import User
from app.models.wallet import CoinAccount
# ===== 创建时分配的标识:用户名(对外展示账号 ID)+ 默认昵称 =====
@@ -114,19 +133,63 @@ def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User:
def soft_delete_account(db: Session, user: User) -> None:
"""注销账号:软删除 + 匿名化 + 释放唯一约束/外部绑定
"""注销账号:物理删除个人数据 + 清零余额 + 匿名化保留财务/关系 + 释放唯一约束。
把 phone 改成 `deleted_<id>` 释放手机号唯一约束,允许同号码重新注册成全新账号;
清空 PII(昵称/头像),保留行用于审计。status=deleted 后将无法再登录该行
(登录接口校验 status==active)。
删除策略(按"删了是否破坏对账 / 影响别人 / 是否 PII"分类):
物理删除(个人内容/成绩/行为/PII,只涉本人、删了不碰账目也不影响别人):比价/
省钱/签到/领券/任务/里程碑领取/埋点/引导标记/设备活跃/价格上报/反馈/看广告时长日志
(ad_watch_log:前端上报时长、不可信、非发奖依据,纯行为埋点),以及
邀请指纹(含 IP/机型/UA 的 PII,按 inviter_user_id 存 = 本人作为邀请人留的)。
清零保留:coin_account 的金币/现金/邀请金/累计收益归零(现金/邀请金已由端点
前置校验=0),行保留 —— 避免碰资金流水外键,也留作审计。
保留 + 随本行匿名化与身份解绑(动了会破坏对账/在途到账/发奖幂等/误伤邀请人):
三本资金流水(coin/cash/invite_cash_transaction)、提现单(pending 在途照常到账)、
微信转账授权(worker 对账 pending 要用)、广告发奖幂等记录(ad_reward 有 trans_id、
ad_feed_reward_record 有 client_event_id 唯一键,删了幂等键会致回调/重放重复发奖)、
广告 eCPM 收益记录(ad_ecpm_record 是内部收益统计/对账口径,同资金流水按对账保留)、
邀请关系(inviter/invitee 双向、不含 PII,删了会破坏邀请人侧归属)。这些表的 user_id
指向已匿名化的本行,身份已解绑。
⚠️ 注销必须连同释放 user 其余唯一约束/外部身份,否则 deleted 行永久占着槽位,
对应资源再也无法被复用:
⚠️ 匿名化必须连同释放 user 其余唯一约束/外部身份,否则 deleted 行永久占着槽位:
- wechat_openid: 不清 → 这个微信再也无法被任何账号绑定(提现通道彻底锁死);
- invite_code: 不清 → 邀请码槽位被占 + 裸查仍命中死号(发奖已被 bind 的
status==active 校验兜住,清掉更干净)。
资金安全(未提现现金 / 在审提现单)由调用方 delete_account 端点前置校验,这里只匿名化
资金安全(未提现现金/邀请金 / 在审提现单)由调用方 delete_account 端点前置校验。
"""
uid = user.id
# 1) 物理删除个人内容/行为/成绩/PII 数据(不碰资金流水/账户/邀请关系/广告幂等)
personal_deletions = [
delete(ComparisonRecord).where(ComparisonRecord.user_id == uid),
delete(SavingsRecord).where(SavingsRecord.user_id == uid),
delete(SigninRecord).where(SigninRecord.user_id == uid),
delete(SigninBoostRecord).where(SigninBoostRecord.user_id == uid),
delete(CouponClaimRecord).where(CouponClaimRecord.user_id == uid),
delete(CouponDailyCompletion).where(CouponDailyCompletion.user_id == uid),
delete(CouponPromptEngagement).where(CouponPromptEngagement.user_id == uid),
delete(CouponSession).where(CouponSession.user_id == uid),
delete(UserTask).where(UserTask.user_id == uid),
delete(ComparisonMilestoneClaim).where(ComparisonMilestoneClaim.user_id == uid),
delete(DeviceLiveness).where(DeviceLiveness.user_id == uid),
delete(PriceReport).where(PriceReport.user_id == uid),
delete(Feedback).where(Feedback.user_id == uid),
delete(OnboardingCompletion).where(OnboardingCompletion.user_id == uid),
delete(AnalyticsEvent).where(AnalyticsEvent.user_id == uid),
delete(AdWatchLog).where(AdWatchLog.user_id == uid),
delete(InviteFingerprint).where(InviteFingerprint.inviter_user_id == uid),
]
for stmt in personal_deletions:
db.execute(stmt)
# 2) 清零余额账户(现金/邀请金已前置=0;金币/累计收益一并归零,行保留不碰流水外键)
acc = db.get(CoinAccount, uid)
if acc is not None:
acc.coin_balance = 0
acc.cash_balance_cents = 0
acc.invite_cash_balance_cents = 0
acc.total_coin_earned = 0
# 3) 匿名化本行 + 释放唯一约束/外部身份(保留行:审计锚 + 资金流水/邀请关系外键不断)
user.status = "deleted"
user.phone = f"deleted_{user.id}"
user.nickname = None
+10
View File
@@ -383,6 +383,16 @@ def get_cash_balance_cents(db: Session, user_id: int) -> int:
return val or 0
def get_invite_cash_balance_cents(db: Session, user_id: int) -> int:
"""只读查邀请奖励金余额(分)。账户不存在返回 0,**不创建账户**(同 get_cash_balance_cents)。
注销前置校验用:邀请奖励金与金币兑换现金物理隔离,注销会把账户余额一并清零,若不单独
校验,用户没提现的返佣金会被直接清零吞掉。"""
val = db.execute(
select(CoinAccount.invite_cash_balance_cents).where(CoinAccount.user_id == user_id)
).scalar_one_or_none()
return val or 0
def has_reviewing_withdraw(db: Session, user_id: int) -> bool:
"""用户是否有「待审核(reviewing)」的提现单。
+1 -5
View File
@@ -174,13 +174,9 @@ 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
@@ -1,360 +0,0 @@
城市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
@@ -1,65 +0,0 @@
"""通过经纬度反查城市(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
@@ -1,448 +0,0 @@
"""美团城市词典 + 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,9 +37,6 @@ dependencies = [
# 邀请指纹归因:解析浏览器 UA 拿手机型号(Build.MODEL),跨端匹配用
"user-agents>=2.2.0",
# 离线经纬度→城市反查(美团 CPS 按城市过滤);会带入 numpy/scipy 依赖
"reverse_geocoder>=1.5.1",
]
[project.optional-dependencies]
@@ -53,10 +50,6 @@ 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
@@ -1,170 +0,0 @@
"""把 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
@@ -1,189 +0,0 @@
"""给指定用户灌一批 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()
+1 -8
View File
@@ -68,14 +68,7 @@ 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()}
# 非 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 "signin_rewards" in items and "ad_daily_limit" in items
assert items["signin_rewards"]["value"] == [
200, 200, 300, 200, 400, 400, 800,
]
-152
View File
@@ -1,152 +0,0 @@
"""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
+97
View File
@@ -476,3 +476,100 @@ def test_withdraw_reconcile(admin_client: TestClient, finance_token: str, monkey
)
assert r.status_code == 200, r.text
assert "checked" in r.json() and "resolved" in r.json()
# ===== 删除账号(super_admin,复用 soft_delete_account + 审计) =====
def test_admin_delete_user_purges_and_audits(
admin_client: TestClient, super_token: str
) -> None:
"""super_admin 删除:user 行匿名化 + 个人数据被清 + 落审计(action=user.delete,含 reason)。"""
uid = _seed_user("13900000031")
db = SessionLocal()
try:
db.add(Feedback(user_id=uid, content="待清理", contact="wx", status="pending"))
db.commit()
finally:
db.close()
r = admin_client.request(
"DELETE", f"/admin/api/users/{uid}",
json={"reason": "违规账号"}, headers=_auth(super_token),
)
assert r.status_code == 200, r.text
db = SessionLocal()
try:
u = db.get(User, uid)
assert u is not None and u.status == "deleted"
assert u.phone == f"deleted_{uid}"
# 个人表被清(复用 C 端 soft_delete_account 同一套级联删除)
fbs = db.execute(
select(Feedback).where(Feedback.user_id == uid)
).scalars().all()
assert fbs == []
# 审计:业务 + 审计同一 commit 一起落(改了就有痕)
logs = db.execute(
select(AdminAuditLog).where(
AdminAuditLog.action == "user.delete", AdminAuditLog.target_id == str(uid)
)
).scalars().all()
assert len(logs) == 1 and logs[0].detail["reason"] == "违规账号"
finally:
db.close()
def test_admin_delete_user_forbidden_for_operator(
admin_client: TestClient, operator_token: str
) -> None:
"""删除是最高危操作:operator/finance 都不可,仅 super_admin。且拒绝后账号不被删。"""
uid = _seed_user("13900000032")
r = admin_client.request(
"DELETE", f"/admin/api/users/{uid}",
json={"reason": "x"}, headers=_auth(operator_token),
)
assert r.status_code == 403
db = SessionLocal()
try:
assert db.get(User, uid).status == "active" # 未被删
finally:
db.close()
def test_admin_delete_user_blocked_when_cash_positive(
admin_client: TestClient, super_token: str, finance_token: str
) -> None:
"""有未提现现金 → 409 拒绝(同 C 端资金前置闸),账号不被删。"""
uid = _seed_user("13900000033")
# 用 finance 给该用户灌现金
admin_client.post(
f"/admin/api/users/{uid}/cash", json={"amount_cents": 100, "reason": ""},
headers=_auth(finance_token),
)
r = admin_client.request(
"DELETE", f"/admin/api/users/{uid}",
json={"reason": "x"}, headers=_auth(super_token),
)
assert r.status_code == 409, r.text
assert "现金" in r.json()["detail"]
db = SessionLocal()
try:
assert db.get(User, uid).status == "active"
finally:
db.close()
def test_admin_delete_user_rejects_already_deleted(
admin_client: TestClient, super_token: str
) -> None:
uid = _seed_user("13900000034")
r = admin_client.request(
"DELETE", f"/admin/api/users/{uid}",
json={"reason": "首删"}, headers=_auth(super_token),
)
assert r.status_code == 200, r.text
# 再删已注销账号 → 400
r = admin_client.request(
"DELETE", f"/admin/api/users/{uid}",
json={"reason": "重复"}, headers=_auth(super_token),
)
assert r.status_code == 400
+133 -2
View File
@@ -1,14 +1,18 @@
"""注销账号(DELETE /api/v1/user):软删 + 释放唯一约束 + 资金前置校验。
"""注销账号(DELETE /api/v1/user):数据级联清理 + 软删 + 释放唯一约束 + 资金前置校验。
覆盖:
- 干净注销:status=deleted + phone 匿名化 + wechat_openid/invite_code 等唯一槽全部释放
- 回归原始 bug:注销释放 openid ,别的账号能绑同一个微信
- 资金前置闸:有可提现现金 / 在审提现单 409 拒绝注销,账号不被删
- 注销后旧 token 即时失效(status==active )
- 级联清理:个人内容/成绩/行为/PII 表被物理删空余额清零;财务流水/邀请关系/广告幂等
+对账表按策略保留(soft_delete_account 的分类落地断言)
"""
from __future__ import annotations
from sqlalchemy import select
from datetime import date, datetime, timezone
from sqlalchemy import func, select
from app.db.session import SessionLocal
from app.models.user import User
@@ -139,3 +143,130 @@ def test_deleted_user_old_token_is_rejected(client) -> None:
# 注销后旧 token 立即失效(get_current_user 校验 status==active)
r = client.get("/api/v1/user/onboarding/status", params={"device_id": "d"}, headers=_auth(token))
assert r.status_code == 401, r.text
# ===== 级联清理:个人表删空 / 余额清零 / 保留表存活 =====
def _count(model, col, uid: int) -> int:
db = SessionLocal()
try:
return db.execute(
select(func.count()).select_from(model).where(col == uid)
).scalar_one()
finally:
db.close()
def test_delete_purges_personal_data_zeroes_balance_keeps_ledgers(client) -> None:
"""注销后:① 个人内容/行为/PII 表按 user_id 被物理删空;② 金币等余额清零;
资金流水 / 邀请关系 / 广告幂等+对账表保留(soft_delete_account 分类策略的端到端断言)"""
from app.models.ad_ecpm import AdEcpmRecord
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_watch_log import AdWatchLog
from app.models.analytics_event import AnalyticsEvent
from app.models.comparison import ComparisonRecord
from app.models.comparison_milestone import ComparisonMilestoneClaim
from app.models.coupon_state import (
CouponClaimRecord,
CouponDailyCompletion,
CouponPromptEngagement,
CouponSession,
)
from app.models.device import DeviceLiveness
from app.models.feedback import Feedback
from app.models.invite import InviteRelation
from app.models.invite_fingerprint import InviteFingerprint
from app.models.onboarding import OnboardingCompletion
from app.models.price_report import PriceReport
from app.models.savings import SavingsRecord
from app.models.signin import SigninBoostRecord, SigninRecord
from app.models.task import UserTask
from app.models.wallet import CoinTransaction
phone = "13900000007"
token = _login(client, phone)
client.get("/api/v1/wallet/account", headers=_auth(token)) # 建 coin_account
uid = _get_user(phone).id
db = SessionLocal()
try:
# 余额:给金币 + 累计收益(现金/邀请金保持 0,否则命中 409 前置闸)
acc = db.get(CoinAccount, uid)
acc.coin_balance = 500
acc.total_coin_earned = 500
# 每张"应删"表各灌 1 行(user_id 维度;指纹按 inviter_user_id)
db.add_all([
ComparisonRecord(user_id=uid, trace_id="del_test_comparison"),
SavingsRecord(user_id=uid, order_amount_cents=1, saved_amount_cents=1),
SigninRecord(user_id=uid, signin_date=date(2026, 7, 4), cycle_day=1, streak=1, coin_awarded=1),
SigninBoostRecord(user_id=uid, signin_date=date(2026, 7, 4), coin_awarded=1),
CouponClaimRecord(user_id=uid, device_id="del_cc", coupon_id="c1", claim_date=date(2026, 7, 4), status="success"),
CouponDailyCompletion(user_id=uid, device_id="del_cd", complete_date=date(2026, 7, 4)),
CouponPromptEngagement(user_id=uid, device_id="del_ce", engage_date=date(2026, 7, 4), engage_type="shown"),
CouponSession(user_id=uid, trace_id="del_test_couponsession", device_id="d1", status="started",
started_at=datetime(2026, 7, 4, tzinfo=timezone.utc), started_date=date(2026, 7, 4)),
UserTask(user_id=uid, task_key="del_test_usertask"),
ComparisonMilestoneClaim(user_id=uid, milestone=1),
DeviceLiveness(user_id=uid, device_id="del_test_dl"),
PriceReport(user_id=uid, reported_platform_id="p1", reported_platform_name="x", reported_price_cents=1),
Feedback(user_id=uid, content="x", contact="x"),
OnboardingCompletion(user_id=uid, device_id="del_test_ob"),
AnalyticsEvent(user_id=uid, event="x", device_id="del_test_an", client_ts=1),
AdWatchLog(user_id=uid, watch_date="2026-07-04"),
InviteFingerprint(inviter_user_id=uid, ip="1.2.3.4"),
])
# 每张"应保留"表各灌 1 行
db.add_all([
CoinTransaction(user_id=uid, amount=1, balance_after=1, biz_type="signin"),
InviteRelation(inviter_user_id=uid, invitee_user_id=999999),
AdEcpmRecord(user_id=uid, ad_type="reward_video", ecpm_raw="100", report_date="2026-07-04"),
AdFeedRewardRecord(user_id=uid, client_event_id="del_test_afr", ecpm_raw="100", reward_date="2026-07-04"),
])
db.commit()
finally:
db.close()
# 灌完先确认确实各有 1 行(否则"删空"断言可能因根本没灌进去而假过)
deleted_specs = [
(ComparisonRecord, ComparisonRecord.user_id),
(SavingsRecord, SavingsRecord.user_id),
(SigninRecord, SigninRecord.user_id),
(SigninBoostRecord, SigninBoostRecord.user_id),
(CouponClaimRecord, CouponClaimRecord.user_id),
(CouponDailyCompletion, CouponDailyCompletion.user_id),
(CouponPromptEngagement, CouponPromptEngagement.user_id),
(CouponSession, CouponSession.user_id),
(UserTask, UserTask.user_id),
(ComparisonMilestoneClaim, ComparisonMilestoneClaim.user_id),
(DeviceLiveness, DeviceLiveness.user_id),
(PriceReport, PriceReport.user_id),
(Feedback, Feedback.user_id),
(OnboardingCompletion, OnboardingCompletion.user_id),
(AnalyticsEvent, AnalyticsEvent.user_id),
(AdWatchLog, AdWatchLog.user_id),
(InviteFingerprint, InviteFingerprint.inviter_user_id),
]
for model, col in deleted_specs:
assert _count(model, col, uid) == 1, f"seed missing for {model.__name__}"
r = client.delete("/api/v1/user", headers=_auth(token))
assert r.status_code == 200, r.text
# ① 应删表全部删空
for model, col in deleted_specs:
assert _count(model, col, uid) == 0, f"{model.__name__} 未被删空(残留 PII/行为)"
# ② 余额清零(行保留,不碰流水外键)
acc = SessionLocal().get(CoinAccount, uid)
assert acc is not None
assert acc.coin_balance == 0
assert acc.total_coin_earned == 0
assert acc.cash_balance_cents == 0
assert acc.invite_cash_balance_cents == 0
# ③ 保留表存活(资金流水 / 邀请关系 / 广告幂等+对账)
assert _count(CoinTransaction, CoinTransaction.user_id, uid) == 1
assert _count(InviteRelation, InviteRelation.inviter_user_id, uid) == 1
assert _count(AdEcpmRecord, AdEcpmRecord.user_id, uid) == 1
assert _count(AdFeedRewardRecord, AdFeedRewardRecord.user_id, uid) == 1
-137
View File
@@ -1,137 +0,0 @@
"""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
@@ -1,64 +0,0 @@
"""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"