Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35f24084ae |
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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 聚成一行(展开看逐条)。
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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="改某项配置(带审计)")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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="有提现正在审核中,请等审核完成后再注销"
|
||||
|
||||
@@ -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=只用种子/合成(演示)。",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 分钟),降序、像真实流水。
|
||||
|
||||
@@ -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:
|
||||
# 首次无值:按当前模式播种,使配置后立即有合理展示值
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)」的提现单。
|
||||
|
||||
|
||||
@@ -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只到店; 不填=全部(全城销量)")
|
||||
|
||||
|
||||
# ───────────────── 换链 请求 / 响应 ─────────────────
|
||||
|
||||
@@ -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 重庆市 重庆市
|
||||
@@ -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", "")),
|
||||
}
|
||||
@@ -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)))
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user