diff --git a/.gitignore b/.gitignore index 4c87d95..fa98e0e 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,12 @@ secrets/* # 运行日志(run.sh 输出, 不入库) *.log logs/ + +# Claude Code 自动持久化的权限 allowlist / 个人本地设置(会话专属,不入库)。 +# 需要团队共享的 Claude 配置(commands/ 等)可单独 git add -f,不受此忽略影响。 +.claude/settings.json +.claude/settings.local.json +tests/meituan_coupon_bj.tsv +tests/meituan_coupon_data.tsv +tests/meituan_coupon_fz.tsv +tests/meituan_coupon_xm.tsv diff --git a/alembic/versions/admin_role_table.py b/alembic/versions/admin_role_table.py new file mode 100644 index 0000000..cc20ec2 --- /dev/null +++ b/alembic/versions/admin_role_table.py @@ -0,0 +1,69 @@ +"""admin_role 表(RBAC 角色 → 可见页面)+ 种子内建角色 + +新增后台 RBAC:角色持有一组页面 key,登录后左侧只展示这些页。super_admin 内建全权(pages 存空、 +effective_pages 特判为全部)、不可编辑删除;operator/finance 播默认页集,可由 super_admin 增删改。 +admin_user.role 弱引用本表 name。全新环境顺序应用即得三条内建角色。 + +Revision ID: admin_role_table +Revises: comparison_product_names +Create Date: 2026-07-04 00:00:00.000000 +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "admin_role_table" +down_revision: str | Sequence[str] | None = "comparison_product_names" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql") + +# 与 app/admin/permissions.py 的 BUILTIN_ROLES 同口径(迁移内联一份,不耦合 app 常量)。 +# 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES。key 承重(require_role),label 为展示名。 +_BUILTIN = [ + {"name": "super_admin", "label": "管理员", "pages": [], "is_builtin": True}, + {"name": "operator", "label": "运营", "pages": [ + "dashboard", "coupon-data", "ad-revenue-report", "comparison-records", + "cps", "device-liveness", "price-reports", "feedbacks", + ], "is_builtin": False}, + {"name": "finance", "label": "财务", "pages": [ + "dashboard", "ad-revenue-report", "cps", "withdraws", + ], "is_builtin": False}, + {"name": "tech", "label": "技术", "pages": [ + "dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs", + ], "is_builtin": False}, +] + + +def upgrade() -> None: + op.create_table( + "admin_role", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("name", sa.String(length=32), nullable=False), + sa.Column("label", sa.String(length=32), nullable=False, server_default=""), + sa.Column("pages", _JSON, nullable=False), + sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column( + "created_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False, + ), + ) + op.create_index("ix_admin_role_name", "admin_role", ["name"], unique=True) + + tbl = sa.table( + "admin_role", + sa.column("name", sa.String), + sa.column("label", sa.String), + sa.column("pages", _JSON), + sa.column("is_builtin", sa.Boolean), + ) + op.bulk_insert(tbl, _BUILTIN) + + +def downgrade() -> None: + op.drop_index("ix_admin_role_name", table_name="admin_role") + op.drop_table("admin_role") diff --git a/alembic/versions/admin_user_plain_password.py b/alembic/versions/admin_user_plain_password.py new file mode 100644 index 0000000..aea8806 --- /dev/null +++ b/alembic/versions/admin_user_plain_password.py @@ -0,0 +1,29 @@ +"""admin_user 加 plain_password 列(明文登录密码,仅后台 UI 建/重置的账号留存) + +权限管理页需能复看某成员已确定的登录密码转交本人。系统只存哈希无法反推,故对「后台 UI 创建/重置」 +的管理员额外留存一份明文;脚本/起后台建的超管为 None(前端不显示密码)。旧账号无此列值 → 同样不显示。 + +Revision ID: admin_user_plain_password +Revises: admin_role_table +Create Date: 2026-07-04 00:00:00.000000 +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "admin_user_plain_password" +down_revision: str | Sequence[str] | None = "admin_role_table" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("admin_user", schema=None) as batch_op: + batch_op.add_column(sa.Column("plain_password", sa.String(length=128), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("admin_user", schema=None) as batch_op: + batch_op.drop_column("plain_password") diff --git a/alembic/versions/comparison_product_names.py b/alembic/versions/comparison_product_names.py new file mode 100644 index 0000000..305a9aa --- /dev/null +++ b/alembic/versions/comparison_product_names.py @@ -0,0 +1,70 @@ +"""comparison_record 加 product_names 列(下单商品名派生串)+ 回填历史行 + +admin 比价记录页要把原「店/商品」一列拆成「店」+「商品」两列、并支持按商品名搜索。 +items 是 JSON(SQLite 下 json.dumps ensure_ascii 会把中文转义存成 \\uXXXX,无法直接 CAST+LIKE +命中中文),故把商品名派生成普通文本列,跨库 LIKE 一致、可索引。 + +写路径(upsert_record / harvest_done)已同步派生;本迁移建列并从已存 items 回填历史行。 +全新环境顺序应用即得空列 + 回填(表本为空,回填 no-op)。 + +Revision ID: comparison_product_names +Revises: comparison_record_trace_unique +Create Date: 2026-07-04 00:00:00.000000 +""" + +import json +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "comparison_product_names" +down_revision: str | Sequence[str] | None = "comparison_record_trace_unique" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _product_names(items) -> str | None: + """items([{name,...}]) → 顿号分隔的商品名串(去重保序)。与 repositories.comparison + ._product_names_from_items 同口径,迁移内联一份避免耦合 app 代码。""" + if isinstance(items, str): # SQLite: items 以 JSON 文本存,取回是 str + try: + items = json.loads(items) + except (ValueError, TypeError): + return None + if not isinstance(items, list) or not items: + return None + names: list[str] = [] + for it in items: + name = it.get("name") if isinstance(it, dict) else None + if not name: + continue + s = str(name).strip() + if s and s not in names: + names.append(s) + joined = "、".join(names) + return joined[:500] or None + + +def upgrade() -> None: + with op.batch_alter_table("comparison_record", schema=None) as batch_op: + batch_op.add_column(sa.Column("product_names", sa.String(length=512), nullable=True)) + + # 回填:从已存 items 派生商品名。items 小(菜名列表),一次取回即可;只更新有商品名的行。 + bind = op.get_bind() + rows = bind.execute( + sa.text("SELECT id, items FROM comparison_record") + ).fetchall() + for rid, items in rows: + pn = _product_names(items) + if pn: + bind.execute( + sa.text("UPDATE comparison_record SET product_names = :pn WHERE id = :id"), + {"pn": pn, "id": rid}, + ) + + +def downgrade() -> None: + with op.batch_alter_table("comparison_record", schema=None) as batch_op: + batch_op.drop_column("product_names") diff --git a/alembic/versions/comparison_record_trace_unique.py b/alembic/versions/comparison_record_trace_unique.py new file mode 100644 index 0000000..a662991 --- /dev/null +++ b/alembic/versions/comparison_record_trace_unique.py @@ -0,0 +1,49 @@ +"""comparison_record: 唯一键 (user_id,trace_id) → trace_id 单列 + user_id 可空 + +Revision ID: comparison_record_trace_unique +Revises: feedback_type_reply +Create Date: 2026-07-03 12:00:00.000000 + +比价记录改「后端 harvest」:app-server 在帧0(pricebot 出 trace_id)即建行,随 done/finalize +逐步补全,客户端不再 POST 记录。故本表要两处调整: + 1. user_id 改**可空**——harvest 建行那刻(软鉴权 / 老客户端匿名)可能还没有 user_id。 + 2. 唯一键 (user_id, trace_id) → **trace_id 单列**——trace_id 由 app-server 签发、全局唯一, + 一次比价一行;harvest 建行时 user_id 还没有,不能再用复合键去重。 + +⚠️ 上线前(QA)务必确认 comparison_record 无重复 trace_id:旧复合唯一键**允许**同 trace_id + 跨不同 user_id(实际上客户端 UUID 从不重复,但约束没拦),若真有重复,建 trace 单列唯一会失败。 + 查: SELECT trace_id, COUNT(*) c FROM comparison_record GROUP BY trace_id HAVING c > 1; + +SQLite 不支持直接 drop/alter 约束/列,用 batch_alter_table(建临时表+拷数据+换名), +与 coupon_engage_per_package / store_mapping_* 同款;Postgres 直接执行原生 ALTER。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'comparison_record_trace_unique' +down_revision: Union[str, Sequence[str], None] = 'feedback_type_reply' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('comparison_record', schema=None) as batch_op: + # harvest 帧0 建行时 user_id 可能暂缺 → 放开非空。 + batch_op.alter_column('user_id', existing_type=sa.Integer(), nullable=True) + # 复合唯一 → trace_id 单列唯一。 + batch_op.drop_constraint('uq_comparison_user_trace', type_='unique') + batch_op.create_unique_constraint('uq_comparison_trace', ['trace_id']) + + +def downgrade() -> None: + with op.batch_alter_table('comparison_record', schema=None) as batch_op: + batch_op.drop_constraint('uq_comparison_trace', type_='unique') + batch_op.create_unique_constraint( + 'uq_comparison_user_trace', ['user_id', 'trace_id'] + ) + # 回退非空前提是当时无 null user_id 行(harvest 期可能有孤儿行,回滚需先清理)。 + batch_op.alter_column('user_id', existing_type=sa.Integer(), nullable=False) diff --git a/app/admin/main.py b/app/admin/main.py index ca3a374..89669fc 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -32,6 +32,7 @@ from app.admin.routers.feedback_qr import router as feedback_qr_router from app.admin.routers.onboarding import router as onboarding_router from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router from app.admin.routers.price_report import router as price_report_router +from app.admin.routers.roles import router as roles_router from app.admin.routers.users import router as users_router from app.admin.routers.wallet import router as wallet_router from app.admin.routers.withdraw import router as withdraw_router @@ -98,6 +99,7 @@ admin_app.include_router(feedback_router) admin_app.include_router(event_logs_router) admin_app.include_router(feedback_qr_router) admin_app.include_router(admins_router) +admin_app.include_router(roles_router) admin_app.include_router(audit_router) admin_app.include_router(config_router) admin_app.include_router(comparison_router) diff --git a/app/admin/permissions.py b/app/admin/permissions.py new file mode 100644 index 0000000..e17f7db --- /dev/null +++ b/app/admin/permissions.py @@ -0,0 +1,80 @@ +"""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) diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py index 8dfcf2d..c0d8c94 100644 --- a/app/admin/repositories/ad_revenue.py +++ b/app/admin/repositories/ad_revenue.py @@ -385,10 +385,12 @@ def ad_revenue_report( for k, v in type_map.items() } - # DAU:复用大盘「今日活跃」口径(stats.today_dau,last_login_at)。该口径只能算今日, - # 故仅当查询=今日单天时给值;历史 / 多天区间返回 None,前端显示「-」。 - is_today = date_from == date_to == rewards.cn_today().isoformat() - dau = admin_stats.today_dau(db) if is_today else None + # DAU:复用数据大盘活跃用户口径(登录 + 开始比价 + 开始领券,按用户去重),按所选日期区间 + # 统计(含今日),历史 / 多天区间同样有值。ARPU = 区间预估收益 ÷ 区间活跃用户。全局口径, + # 不随 user / ad_type / feed_scene / app_env 筛选变化(活跃用户口径无这些维度)。 + dau = admin_stats.period_active_dau( + db, _date.fromisoformat(date_from), _date.fromisoformat(date_to) + ) # 主表「逐行」= 单次广告行为(2026-07 按「一次比价/领券放一块」聚合):激励视频 = 一次观看一行(展示+发奖 # 按 ad_session_id 合并);一次比价 / 一次领券 = 该次整场多条广告按 ad_session_id 聚成一行(展开看逐条)。 diff --git a/app/admin/repositories/admin_role.py b/app/admin/repositories/admin_role.py new file mode 100644 index 0000000..7eda933 --- /dev/null +++ b/app/admin/repositories/admin_role.py @@ -0,0 +1,81 @@ +"""admin_role 表 CRUD + 「角色 → 有效可见页」解析。""" +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.admin.permissions import ( + ALL_PAGE_KEYS, + BUILTIN_ROLES, + SUPER_ADMIN_ROLE, + sanitize_pages, +) +from app.models.admin_role import AdminRole + + +def ensure_builtin_roles(db: Session) -> None: + """空表时播种内建角色(管理员/运营/财务/技术)。幂等:表非空即 no-op。 + 给 create_all 环境(测试 / 未跑迁移的全新库)兜底,迁移已播种则不重复。 + 仅 super_admin 为 is_builtin(锁定不可改删);运营/财务/技术可编辑页/删除。""" + if db.execute(select(AdminRole.id).limit(1)).first() is not None: + return + db.add_all([ + AdminRole( + name=r["name"], label=r["label"], pages=list(r["pages"]), + is_builtin=(r["name"] == SUPER_ADMIN_ROLE), + ) + for r in BUILTIN_ROLES + ]) + db.commit() + + +def list_roles(db: Session) -> list[AdminRole]: + ensure_builtin_roles(db) + # super_admin(内建)恒排最前,其余按创建序 + return list( + db.execute( + select(AdminRole).order_by(AdminRole.is_builtin.desc(), AdminRole.id) + ).scalars().all() + ) + + +def get_role(db: Session, name: str) -> AdminRole | None: + return db.execute( + select(AdminRole).where(AdminRole.name == name) + ).scalar_one_or_none() + + +def create_role(db: Session, *, name: str, label: str, pages: list[str]) -> AdminRole: + # 自定义角色:name(key)= label = 用户输入的名称 + role = AdminRole(name=name, label=label, pages=sanitize_pages(pages), is_builtin=False) + db.add(role) + db.commit() + db.refresh(role) + return role + + +def update_role( + db: Session, role: AdminRole, *, label: str | None = None, pages: list[str] | None = None +) -> AdminRole: + # 只改展示名 label + 可见页;name(key)不可变(admin_user.role / require_role 承重) + if label is not None: + role.label = label + if pages is not None: + role.pages = sanitize_pages(pages) + db.commit() + db.refresh(role) + return role + + +def delete_role(db: Session, role: AdminRole) -> None: + db.delete(role) + db.commit() + + +def effective_pages_of(db: Session, role_name: str) -> list[str]: + """某角色名的有效可见页:super_admin → 全部;其余 → 查表 pages 与目录取交。""" + if role_name == SUPER_ADMIN_ROLE: + return list(ALL_PAGE_KEYS) + ensure_builtin_roles(db) + role = get_role(db, role_name) + return sanitize_pages(role.pages if role else None) diff --git a/app/admin/repositories/admin_user.py b/app/admin/repositories/admin_user.py index 7d2d331..828efe7 100644 --- a/app/admin/repositories/admin_user.py +++ b/app/admin/repositories/admin_user.py @@ -20,12 +20,20 @@ def get_by_username(db: Session, username: str) -> AdminUser | None: def create_admin( - db: Session, *, username: str, password: str, role: str = "operator" + db: Session, + *, + username: str, + password: str, + role: str = "operator", + plain_password: str | None = None, ) -> 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() diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index f0de886..ac2be15 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -148,11 +148,14 @@ def list_comparison_records( phone: str | None = None, status: str | None = None, business_type: str | None = None, + store: str | None = None, + product: str | None = None, limit: int = 20, cursor: int | None = None, ) -> tuple[list[ComparisonRecord], int | None, int]: """admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛, - offset 分页(创建时间倒序、id 兜底)。join User 取 phone/nickname 瞬态挂记录上。""" + store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。 + join User 取 phone/nickname 瞬态挂记录上。""" stmt = select(ComparisonRecord) if user_id is not None: stmt = stmt.where(ComparisonRecord.user_id == user_id) @@ -166,6 +169,11 @@ def list_comparison_records( stmt = stmt.where(ComparisonRecord.status == status) if business_type: stmt = stmt.where(ComparisonRecord.business_type == business_type) + if store: + stmt = stmt.where(ComparisonRecord.store_name.like(f"%{store}%")) + if product: + # 商品名搜 product_names 派生文本列(非 items JSON:SQLite 下 JSON 中文被转义无法直接 LIKE)。 + stmt = stmt.where(ComparisonRecord.product_names.like(f"%{product}%")) items, next_cursor, total = offset_paginate( db, stmt, (desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)), diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index 202a5ba..f799826 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -55,29 +55,10 @@ def _beijing_today_start_utc() -> datetime: def today_dau(db: Session) -> int: """今日活跃用户数(DAU):登录 + 开始比价 + 开始领券,按用户去重。 - 广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算。 + = period_active_dau(今天, 今天);历史 / 多天窗口用 period_active_dau 传区间(广告收益报表复用)。 """ today_bj = datetime.now(_BEIJING).date() - today_start = _beijing_today_start_utc() - tomorrow_start = today_start + timedelta(days=1) - login_user_ids = _id_set( - db, - select(User.id).where(User.last_login_at >= today_start, User.last_login_at < tomorrow_start), - ) - compare_start_user_ids = _event_user_ids( - db, (COMPARE_START_EVENT,), today_start, tomorrow_start - ) - coupon_event_user_ids = _event_user_ids( - db, (COUPON_START_EVENT,), today_start, tomorrow_start - ) - coupon_claim_user_ids = _id_set( - db, - select(CouponPromptEngagement.user_id).where( - CouponPromptEngagement.engage_date == today_bj, - CouponPromptEngagement.engage_type == "claim_started", - ), - ) - return len(login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids) + return period_active_dau(db, today_bj, today_bj) def _default_period_end() -> date: @@ -134,6 +115,56 @@ def _event_user_ids( ) +def _period_active_user_ids( + db: Session, + *, + start_utc: datetime, + end_utc: datetime, + period_from: date, + period_to: date, +) -> set[int]: + """区间去重活跃用户 id 集合:登录(last_login_at)+ 开始比价(real_compare_start)+ + 开始领券(real_coupon_start / claim_started)。 + + user / analytics_event 用 UTC aware 边界 [start_utc, end_utc);coupon_prompt_engagement + 的 engage_date 是北京自然日 date 列,用 [period_from, period_to] 闭区间。今日 / 历史 / 多天通用。 + """ + login_user_ids = _id_set( + db, + select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc), + ) + compare_start_user_ids = _event_user_ids(db, (COMPARE_START_EVENT,), start_utc, end_utc) + coupon_event_user_ids = _event_user_ids(db, (COUPON_START_EVENT,), start_utc, end_utc) + coupon_claim_user_ids = _id_set( + db, + select(CouponPromptEngagement.user_id).where( + CouponPromptEngagement.engage_date >= period_from, + CouponPromptEngagement.engage_date <= period_to, + CouponPromptEngagement.engage_type == "claim_started", + ), + ) + return login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids + + +def period_active_dau(db: Session, date_from: date, date_to: date) -> int: + """任意北京自然日区间 [date_from, date_to] 的去重活跃用户数。 + + 与数据大盘 period.users.active 同口径(登录 + 开始比价 + 开始领券);广告收益报表按所选 + 日期区间(含今日)复用,ARPU = 区间预估收益 ÷ 本数。全局口径,不随用户 / 类型 / 场景筛选变化。 + """ + period_from, period_to = _normalize_period(date_from, date_to) + start_utc, end_utc, _start_local, _end_local = _period_bounds(period_from, period_to) + return len( + _period_active_user_ids( + db, + start_utc=start_utc, + end_utc=end_utc, + period_from=period_from, + period_to=period_to, + ) + ) + + def _commission_rate_percent(raw: str | None) -> Decimal | None: """美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%"。""" if raw is None: @@ -261,24 +292,12 @@ def dashboard_overview( period_new_user_ids = _user_id_set( select(User.id).where(User.created_at >= start_utc, User.created_at < end_utc) ) - login_user_ids = _user_id_set( - select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc) - ) - compare_start_user_ids = _event_user_ids( - db, (COMPARE_START_EVENT,), start_utc, end_utc - ) - coupon_event_user_ids = _event_user_ids( - db, (COUPON_START_EVENT,), start_utc, end_utc - ) - coupon_claim_user_ids = _user_id_set( - select(CouponPromptEngagement.user_id).where( - CouponPromptEngagement.engage_date >= period_from, - CouponPromptEngagement.engage_date <= period_to, - CouponPromptEngagement.engage_type == "claim_started", - ) - ) - period_active_user_ids = ( - login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids + period_active_user_ids = _period_active_user_ids( + db, + start_utc=start_utc, + end_utc=end_utc, + period_from=period_from, + period_to=period_to, ) period_retained_new_user_ids = period_new_user_ids & period_active_user_ids period_retention_rate = ( @@ -295,32 +314,17 @@ 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( - daily_login_user_ids - | daily_compare_start_user_ids - | daily_coupon_event_user_ids - | daily_coupon_claim_user_ids + _period_active_user_ids( + db, + start_utc=day_start_utc, + end_utc=day_end_utc, + period_from=cur_date, + period_to=cur_date, + ) ), "new_users": _count( User, diff --git a/app/admin/routers/admins.py b/app/admin/routers/admins.py index 9f7ac15..5273a45 100644 --- a/app/admin/routers/admins.py +++ b/app/admin/routers/admins.py @@ -5,6 +5,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request from app.admin.audit import write_audit from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role +from app.admin.permissions import 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 @@ -23,9 +25,23 @@ def _active_super_count(db: AdminDb) -> int: ) -@router.get("", response_model=list[AdminOut], summary="管理员列表") +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 专属)") def list_admins(db: AdminDb) -> list[AdminOut]: - return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)] + out: list[AdminOut] = [] + for a in admin_repo.list_admins(db): + item = AdminOut.model_validate(a) + item.password = a.plain_password # 明文:仅本 super_admin 专属路由下发,供权限管理页复看 + out.append(item) + return out @router.post("", response_model=AdminOut, summary="创建管理员") @@ -34,8 +50,10 @@ 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 + db, username=body.username, password=body.password, role=body.role, + plain_password=body.password, # UI 建的账号留存明文,供权限管理页复看 ) write_audit( db, admin, action="admin.create", target_type="admin", target_id=new.id, @@ -67,6 +85,9 @@ 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} @@ -77,6 +98,7 @@ 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() @@ -86,3 +108,27 @@ def update_admin( detail=changes, ip=get_client_ip(request), commit=True, ) return AdminOut.model_validate(target) + + +@router.delete("/{admin_id}", summary="删除管理员(带审计)") +def delete_admin(admin_id: int, request: Request, admin: CurrentAdmin, db: AdminDb) -> dict: + target = admin_repo.get_by_id(db, admin_id) + if target is None: + raise HTTPException(status_code=404, detail="管理员不存在") + if admin_id == admin.id: + raise HTTPException(status_code=400, detail="不能删除自己") + # 防自锁:删掉某个 active super_admin 前,确认后仍至少剩 1 个,否则进「零可用超管」死局。 + if ( + target.role == "super_admin" + and target.status == "active" + and _active_super_count(db) <= 1 + ): + raise HTTPException(status_code=400, detail="不能删除最后一个超级管理员") + username = target.username + db.delete(target) + db.commit() + write_audit( + db, admin, action="admin.delete", target_type="admin", target_id=admin_id, + detail={"username": username, "role": target.role}, ip=get_client_ip(request), commit=True, + ) + return {"deleted": True} diff --git a/app/admin/routers/auth.py b/app/admin/routers/auth.py index 4d80251..6aa8e35 100644 --- a/app/admin/routers/auth.py +++ b/app/admin/routers/auth.py @@ -6,6 +6,7 @@ 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 @@ -17,6 +18,13 @@ 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, @@ -39,10 +47,10 @@ def login(req: AdminLoginRequest, db: AdminDb) -> AdminLoginResponse: return AdminLoginResponse( access_token=token, expires_in=expires_in, - admin=AdminOut.model_validate(admin), + admin=_admin_out_with_pages(admin, db), ) -@router.get("/me", response_model=AdminOut, summary="当前管理员") -def me(admin: CurrentAdmin) -> AdminOut: - return AdminOut.model_validate(admin) +@router.get("/me", response_model=AdminOut, summary="当前管理员(含有效可见页)") +def me(admin: CurrentAdmin, db: AdminDb) -> AdminOut: + return _admin_out_with_pages(admin, db) diff --git a/app/admin/routers/comparison.py b/app/admin/routers/comparison.py index 9aeb859..38dc242 100644 --- a/app/admin/routers/comparison.py +++ b/app/admin/routers/comparison.py @@ -32,12 +32,15 @@ def list_comparison_records( phone: Annotated[str | None, Query(description="手机号前缀")] = None, status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None, business_type: Annotated[str | None, Query()] = None, + store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None, + product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None, limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[AdminComparisonListItem]: items, next_cursor, total = queries.list_comparison_records( db, user_id=user_id, phone=phone, status=status, - business_type=business_type, limit=limit, cursor=cursor, + business_type=business_type, store=store, product=product, + limit=limit, cursor=cursor, ) return CursorPage( items=[AdminComparisonListItem.model_validate(r) for r in items], diff --git a/app/admin/routers/config.py b/app/admin/routers/config.py index 8a65c61..6701c03 100644 --- a/app/admin/routers/config.py +++ b/app/admin/routers/config.py @@ -55,9 +55,14 @@ def _item(db, key: str) -> ConfigItemOut: raise HTTPException(status_code=404, detail="未知配置项") -@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值") +@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值(不含 hidden)") def list_config(db: AdminDb) -> list[ConfigItemOut]: - return [ConfigItemOut(**item) for item in app_config.list_all(db)] + # hidden 项(已下线/由专用页管理,如福利页任务·里程碑·看广告调参、首页轮播数据源)不在本页渲染。 + return [ + ConfigItemOut(**item) + for item in app_config.list_all(db) + if not CONFIG_DEFS[item["key"]].get("hidden") + ] @router.patch("/{key}", response_model=ConfigItemOut, summary="改某项配置(带审计)") diff --git a/app/admin/routers/ops_marquee_seed.py b/app/admin/routers/ops_marquee_seed.py index 4c7c153..592dd56 100644 --- a/app/admin/routers/ops_marquee_seed.py +++ b/app/admin/routers/ops_marquee_seed.py @@ -8,6 +8,7 @@ from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, Request +from pydantic import BaseModel from app.admin.audit import write_audit from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role @@ -22,7 +23,11 @@ from app.admin.schemas.ops_marquee_seed import ( ) from app.models.admin import AdminUser from app.models.ops_marquee_seed import OpsMarqueeSeed -from app.repositories import ops_marquee +from app.repositories import app_config, ops_marquee + + +class MarqueeModeUpdate(BaseModel): + mode: str # mixed / real / seed router = APIRouter( prefix="/admin/api/marquee-seeds", @@ -57,6 +62,33 @@ 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, diff --git a/app/admin/routers/roles.py b/app/admin/routers/roles.py new file mode 100644 index 0000000..15416a7 --- /dev/null +++ b/app/admin/routers/roles.py @@ -0,0 +1,129 @@ +"""admin RBAC 角色管理(仅 super_admin):列角色 / 权限目录 / 增删改角色。均写审计。 + +角色 = 一组「可见页面」(见 app/admin/permissions.py)。角色登录后台后左侧只展示其 pages 对应导航项。 +只有 super_admin(内建全权角色)能进本组端点(dependencies=require_role() 无参 = 仅 super_admin)。 +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy import func, select + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role +from app.admin.permissions import ( + PERMISSION_CATALOG, + SUPER_ADMIN_ROLE, + effective_pages, + sanitize_pages, +) +from app.admin.repositories import admin_role as role_repo +from app.admin.schemas.role import ( + PermissionGroup, + RoleCreateRequest, + RoleOut, + RoleUpdateRequest, +) +from app.models.admin import AdminUser +from app.models.admin_role import AdminRole + +router = APIRouter( + prefix="/admin/api/roles", + tags=["admin-roles"], + dependencies=[Depends(require_role())], # 无参 = 仅 super_admin +) + + +def _usage_counts(db: AdminDb) -> dict[str, int]: + rows = db.execute( + select(AdminUser.role, func.count(AdminUser.id)).group_by(AdminUser.role) + ).all() + return {r: c for r, c in rows} + + +def _to_out(role: AdminRole, usage: dict[str, int]) -> RoleOut: + return RoleOut( + id=role.id, + name=role.name, + label=role.label or role.name, + pages=effective_pages(role.name, role.pages), + is_builtin=role.is_builtin, + in_use=usage.get(role.name, 0), + ) + + +@router.get("", response_model=list[RoleOut], summary="角色列表(含可见页 + 使用数)") +def list_roles(db: AdminDb) -> list[RoleOut]: + usage = _usage_counts(db) + return [_to_out(r, usage) for r in role_repo.list_roles(db)] + + +@router.get("/catalog", response_model=list[PermissionGroup], summary="页面权限目录(分组)") +def get_catalog() -> list[PermissionGroup]: + return [PermissionGroup(**g) for g in PERMISSION_CATALOG] + + +@router.post("", response_model=RoleOut, summary="新增角色(带审计)") +def create_role( + body: RoleCreateRequest, request: Request, admin: CurrentAdmin, db: AdminDb +) -> RoleOut: + name = body.name.strip() + if not name: + raise HTTPException(status_code=400, detail="角色名称不能为空") + if name == SUPER_ADMIN_ROLE: + raise HTTPException(status_code=400, detail="super_admin 为内建角色,不能新建") + if role_repo.get_role(db, name) is not None: + raise HTTPException(status_code=409, detail="角色名称已存在") + # 自定义角色:name(key)= label = 输入名称 + role = role_repo.create_role(db, name=name, label=name, pages=body.pages) + write_audit( + db, admin, action="role.create", target_type="role", target_id=str(role.id), + detail={"name": name, "pages": role.pages}, ip=get_client_ip(request), commit=True, + ) + return _to_out(role, _usage_counts(db)) + + +@router.patch("/{role_id}", response_model=RoleOut, summary="改角色(展示名/可见页,带审计)") +def update_role( + role_id: int, body: RoleUpdateRequest, request: Request, admin: CurrentAdmin, db: AdminDb +) -> RoleOut: + role = db.get(AdminRole, role_id) + if role is None: + raise HTTPException(status_code=404, detail="角色不存在") + if role.is_builtin: + raise HTTPException(status_code=400, detail="内建角色「管理员」不可编辑") + + new_label = body.label.strip() if body.label is not None else None + changes: dict = {} + if new_label and new_label != role.label: + changes["label"] = {"before": role.label, "after": new_label} + if body.pages is not None: + changes["pages"] = {"before": role.pages, "after": sanitize_pages(body.pages)} + if not changes: + raise HTTPException(status_code=400, detail="无任何变更字段") + + # 只改展示名 + 可见页;key(name)不可变,故无需级联 admin_user.role + role_repo.update_role(db, role, label=new_label, pages=body.pages) + write_audit( + db, admin, action="role.update", target_type="role", target_id=str(role_id), + detail=changes, ip=get_client_ip(request), commit=True, + ) + return _to_out(role, _usage_counts(db)) + + +@router.delete("/{role_id}", summary="删角色(带审计;内建/在用不可删)") +def delete_role(role_id: int, request: Request, admin: CurrentAdmin, db: AdminDb) -> dict: + role = db.get(AdminRole, role_id) + if role is None: + raise HTTPException(status_code=404, detail="角色不存在") + if role.is_builtin: + raise HTTPException(status_code=400, detail="内建角色不可删除") + used = _usage_counts(db).get(role.name, 0) + if used > 0: + raise HTTPException(status_code=400, detail=f"该角色仍有 {used} 名管理员在用,请先改派再删") + name = role.name + role_repo.delete_role(db, role) + write_audit( + db, admin, action="role.delete", target_type="role", target_id=str(role_id), + detail={"name": name}, ip=get_client_ip(request), commit=True, + ) + return {"deleted": True} diff --git a/app/admin/schemas/ad_revenue.py b/app/admin/schemas/ad_revenue.py index cec29b2..cf875c3 100644 --- a/app/admin/schemas/ad_revenue.py +++ b/app/admin/schemas/ad_revenue.py @@ -138,7 +138,9 @@ class AdRevenueReportOut(BaseModel): ) dau: int | None = Field( None, - description="今日活跃用户数(复用大盘口径,last_login_at);**仅查询=今日单天时有值**,历史/多天为 null", + description="所选日期区间的去重活跃用户数(口径同数据大盘 period.users.active:登录 + 开始比价 + " + "开始领券)。按 date_from~date_to 区间统计,含今日、近 7 天、近 30 天等任意区间;全局口径," + "不随 user_id / ad_type / feed_scene / app_env 筛选变化", ) total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)") truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)") diff --git a/app/admin/schemas/admin.py b/app/admin/schemas/admin.py index c1f7949..fcde8bf 100644 --- a/app/admin/schemas/admin.py +++ b/app/admin/schemas/admin.py @@ -6,19 +6,19 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, Field -_Role = Literal["super_admin", "finance", "operator"] +# 角色不再硬编码枚举:改为任意角色名(自定义角色由 admin_role 表管理),存在性在路由层校验。 class AdminCreateRequest(BaseModel): username: str = Field(..., min_length=3, max_length=64) password: str = Field(..., min_length=8, max_length=72) # bcrypt ≤72 字节 - role: _Role = "operator" + role: str = Field("operator", min_length=1, max_length=32) class AdminUpdateRequest(BaseModel): """改角色 / 启用禁用 / 重置密码,字段都可选(只改传了的)。""" - role: _Role | None = None + role: str | None = Field(None, min_length=1, max_length=32) status: Literal["active", "disabled"] | None = None password: str | None = Field(None, min_length=8, max_length=72) diff --git a/app/admin/schemas/auth.py b/app/admin/schemas/auth.py index 3826af5..3972fed 100644 --- a/app/admin/schemas/auth.py +++ b/app/admin/schemas/auth.py @@ -20,6 +20,12 @@ 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): diff --git a/app/admin/schemas/comparison.py b/app/admin/schemas/comparison.py index 19b93bb..5d4155c 100644 --- a/app/admin/schemas/comparison.py +++ b/app/admin/schemas/comparison.py @@ -23,6 +23,7 @@ class AdminComparisonListItem(BaseModel): status: str information: str | None = None store_name: str | None = None + product_names: str | None = None # 下单商品名派生串(顿号分隔;「商品」列展示 + 商品搜索) source_platform_name: str | None = None best_platform_name: str | None = None source_price_cents: int | None = None diff --git a/app/admin/schemas/role.py b/app/admin/schemas/role.py new file mode 100644 index 0000000..f97ebfa --- /dev/null +++ b/app/admin/schemas/role.py @@ -0,0 +1,35 @@ +"""admin RBAC 角色 + 权限目录 schemas。""" +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class PermissionPage(BaseModel): + key: str + label: str + + +class PermissionGroup(BaseModel): + group: str + pages: list[PermissionPage] + + +class RoleOut(BaseModel): + id: int + name: str # 角色 key(承重,不可变) + label: str # 展示名(UI 显示) + pages: list[str] # 有效可见页 key(super_admin = 全部页) + is_builtin: bool # 内建角色(super_admin):不可编辑/删除 + in_use: int # 使用该角色的管理员数(删除前端提示 / 拦截用) + + +class RoleCreateRequest(BaseModel): + # 新增自定义角色:name 即用户输入的名称(同时作 key 与展示名) + name: str = Field(..., min_length=1, max_length=32) + pages: list[str] = Field(default_factory=list) + + +class RoleUpdateRequest(BaseModel): + # 只改展示名 + 可见页;key 不可变 + label: str | None = Field(None, min_length=1, max_length=32) + pages: list[str] | None = None diff --git a/app/api/deps.py b/app/api/deps.py index 20a663c..0b9db63 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -54,5 +54,29 @@ def get_current_user( return user +def get_current_user_optional( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)], + db: Annotated[Session, Depends(get_db)], +) -> User | None: + """软鉴权:有合法 Bearer 就返回 user,否则(无 header / 无效 token / 用户禁用)一律返回 + None,**不 raise**。 + + 比价 step / finalize 灰度期用:新客户端带 JWT → 拿到 user_id 绑定 harvest 行; + 老客户端(不带 JWT)→ None,harvest 行 user_id 暂空,由其后续 /compare/record 上报补齐。 + 等新版覆盖率够高,再把这几条端点从软鉴权收紧成硬 get_current_user。 + """ + if credentials is None or credentials.scheme.lower() != "bearer": + return None + try: + payload = decode_token(credentials.credentials, expected_type="access") + user = db.get(User, int(payload["sub"])) + except (TokenError, KeyError, ValueError, TypeError): + return None + if user is None or user.status != "active": + return None + return user + + CurrentUser = Annotated[User, Depends(get_current_user)] +OptionalUser = Annotated[User | None, Depends(get_current_user_optional)] DbSession = Annotated[Session, Depends(get_db)] diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 3c4f4bf..6cee047 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -12,11 +12,11 @@ from __future__ import annotations import logging -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, HTTPException, Request, status from app.api.deps import CurrentUser, DbSession from app.core import test_account -from app.core.ratelimit import enforce_rate_limit, rate_limit +from app.core.ratelimit import enforce_rate_limit from app.core.security import TokenError, decode_token, issue_token_pair from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone from app.integrations.sms import SmsError, send_code, verify_code @@ -41,7 +41,7 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) # 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。 SMS_LOGIN_MAX_PER_HOUR = 5 # 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。 -# 堵「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞 —— 那两道是单号维度,一机换号能绕开。 +# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。 SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 @@ -100,8 +100,8 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse: return SmsSendResponse(sent=True, mock=True, cooldown_sec=0) # 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。 - # 补「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞(那两道是单号维度,一机换号能绕);设备维度按机器封顶, - # 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。与路由上 IP 维度(10次/分钟)互补。 + # 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶, + # 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。 enforce_rate_limit( request, scope="sms-send-device", @@ -125,7 +125,6 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse: "/sms/login", response_model=TokenWithUser, summary="手机号+验证码登录", - dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限) ) def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser: # 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。 @@ -143,7 +142,7 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit return _login_response(user, onboarding_completed=False, force_onboarding=True) # 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试。放在验证码校验 - # **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破。与路由上 IP 维度的 sms-login 限流(同 IP)互补。 + # **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破(另有单码失败 SMS_MAX_VERIFY_ATTEMPTS 次即作废兜底)。 # ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时 # 退化为该 IP 下所有空设备聚一桶,仍受限。 enforce_rate_limit( diff --git a/app/api/v1/compare.py b/app/api/v1/compare.py index 29362c1..b1f56ae 100644 --- a/app/api/v1/compare.py +++ b/app/api/v1/compare.py @@ -1,49 +1,114 @@ -"""外卖比价业务透传端点。 +"""外卖比价业务透传端点 + 后端 harvest 落库。 -把客户端的 POST /api/v1/intent/recognize 和 /api/v1/price/step 请求原样转发给 -pricebot-backend 的 /api/intent/recognize 和 /api/price/step。MVP 阶段**不鉴权** -(同 coupon/step,见 docs/待办与技术债.md P1:device_id 透传区分设备,待补 JWT + -device_id↔user_id 绑定后才能做用户级画像)。 +把客户端 POST /api/v1/intent/* 和 /api/v1/price/step 转发给 pricebot,同时**由 app-server +在透传里直接落库比价记录**(不再靠客户端 POST /compare/record): + - 帧0(客户端首帧不带 trace_id)→ app-server 用 uuid 签发 trace_id、注入转发 body、回给 + 客户端;并按 trace_id 建 running 行(harvest_running)。 + - 最终 done 帧 → 更新成 success/failed + 结果(harvest_done)。发奖不在这里:#113 已把 + 邀请奖口径从「比价」移到「实际下单」(order.py)。 + - /trace/finalize(用户终止/Phase1 未识别,无 done)→ 更新成 cancelled/failed + (harvest_abort,**不降级 success**)。 +trace_url 从 pricebot 响应**顶层 trace_url** 取(pricebot 每帧都带,见其 goal_engine.process_step)。 -- Phase 1 /intent/recognize:从源平台(淘宝闪购/美团/京东外卖)购物车页识别店名+菜品+ - 价格,一次性。 -- Phase 2 /price/step:多轮循环,在目标平台复现订单读到手价,直到 done。 +鉴权:软鉴权(OptionalUser)——新客户端带 JWT → 绑 user_id;老客户端不带 → user_id 暂空, +由其后续 /compare/record 上报补(灰度期两条写路径按 trace_id reconcile,success 不被降级)。 -真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口只是透传壳。 -电商(ecom)那两个端点 food MVP 暂不需要,以后放开 scene 时再加 /ecom/intent/recognize -和 /ecom/step 两行即可。 - -pricebot 协议文档: - pricebot-backend/docs/main/02_api_protocol.md +真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口是透传壳 + 落库。 +pricebot 协议文档: pricebot-backend/docs/main/02_api_protocol.md """ from __future__ import annotations import json import logging +import time +import uuid from typing import Any import httpx from fastapi import APIRouter, HTTPException, Request, status +from fastapi.concurrency import run_in_threadpool +from app.api.deps import OptionalUser from app.core.config import settings +from app.core.logging import trace_id_ctx from app.core.pricebot_client import get_pricebot_client from app.core.pricebot_router import pick_pricebot +from app.db.session import SessionLocal +from app.repositories import comparison as crud_compare logger = logging.getLogger("shagua.compare") router = APIRouter(prefix="/api/v1", tags=["compare"]) -async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]: - """把请求体原样转发给 pricebot-backend 的 {upstream_path}。 +# ============================================================ +# harvest 落库(阻塞 SQLAlchemy → run_in_threadpool,独立 SessionLocal,不阻塞事件循环; +# 任何写库异常都吞掉、绝不连累比价透传返回 —— 同 coupon.py 现有 best-effort 写法)。 +# ============================================================ - 跟 coupon_step 同款透传壳:不鉴权、不做 schema 校验,仅读 device_id/trace_id/step - 打日志。比价单帧是大上下文 / 逐帧 LLM,超时用 PRICEBOT_COMPARE_TIMEOUT_SEC(60s, - 比领券的 30s 长)。 + +def _harvest_running_blocking( + trace_id: str, user_id: int | None, business_type: str, + device_id: str | None, device_info: dict | None, trace_url: str | None, +) -> None: + with SessionLocal() as db: + crud_compare.harvest_running( + db, trace_id=trace_id, user_id=user_id, business_type=business_type, + device_id=device_id, device_info=device_info, trace_url=trace_url, + ) + logger.info( + "harvest running row (user=%s)", user_id, + extra={"phase": "harvest_running", "status": "running", "user_id": user_id}, + ) + + +def _harvest_done_blocking( + trace_id: str, user_id: int | None, done_params: dict, business_type: str, + device_id: str | None, device_info: dict | None, trace_url: str | None, +) -> None: + with SessionLocal() as db: + rec, newly_success = crud_compare.harvest_done( + db, trace_id=trace_id, user_id=user_id, done_params=done_params, + business_type=business_type, device_id=device_id, + device_info=device_info, trace_url=trace_url, + ) + logger.info( + "harvest done → %s saved=%s newly=%s", rec.status, + rec.saved_amount_cents, newly_success, + extra={"phase": "harvest_done", "status": rec.status, + "saved_cents": rec.saved_amount_cents, + "best_platform": rec.best_platform_id, "newly_success": newly_success, + "user_id": user_id}, + ) + # 不在此处发邀请奖:#113 已把发奖口径从「比价」移到「实际下单」(order.py),harvest + # 只记录比价、不发奖。否则比价先于下单 + try_reward 幂等闸会让奖落在「比价」这步, + # 架空 #113 的「下单才发奖」防刷意图(newly_success 仅留作日志观测)。 + + +def _harvest_abort_blocking( + trace_id: str, status_hint: str, reason: str | None, trace_url: str | None, +) -> None: + with SessionLocal() as db: + rec = crud_compare.harvest_abort( + db, trace_id=trace_id, status=status_hint, reason=reason, trace_url=trace_url, + ) + logger.info( + "harvest abort → %s", (rec.status if rec else "no-row"), + extra={"phase": "harvest_abort", + "status": (rec.status if rec else None), "reason": reason}, + ) + + +async def _forward( + request: Request, upstream_path: str, user, *, harvest_first_frame: bool = True, +) -> tuple[dict[str, Any], str, dict[str, Any]]: + """透传给 pricebot 的 {upstream_path},并 mint trace_id + 首帧建 running 行。 + 返回 (resp_json, trace_id, meta)。 + + - 客户端首帧不带 trace_id → app-server 用 uuid 签发、写进转发 body、回填进响应顶层 + `trace_id`(客户端据此拿到后续帧都带上)。带了(老客户端 / 后续帧)→ 原样用、走原始 bytes 快路。 + - 仅**本次 mint(=首帧)**建 running 行(idempotent);后续帧不再写库。 """ - # 读原始字节,避免"反序列化→再序列化"的双重 JSON(省 ~一半透传 CPU,让 app-server - # 单 worker 也扛得住高并发)。只 json.loads 一次拿 trace_id 做亲和 + 打日志,转发时 - # 直接发原始 bytes(content=raw),不重新 dumps。 raw = await request.body() try: meta = json.loads(raw) @@ -52,26 +117,41 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]: if not isinstance(meta, dict): meta = {} - # 按 trace_id 一致性 hash 选 pricebot 实例(同一比价的所有帧落同一进程,内存维护 state) - base = pick_pricebot(meta.get("trace_id")) + trace_id = meta.get("trace_id") + minted = False + if not trace_id: + trace_id = str(uuid.uuid4()) + meta["trace_id"] = trace_id + raw = json.dumps(meta).encode() # 仅首帧重新序列化(注入 trace_id);后续帧走原始 bytes + minted = True + + # 请求级 trace_id 贯穿:此后本请求(含 run_in_threadpool 里的 harvest)每行日志自动带 trace_id + trace_id_ctx.set(trace_id) + + base = pick_pricebot(trace_id) url = f"{base.rstrip('/')}{upstream_path}" timeout = settings.PRICEBOT_COMPARE_TIMEOUT_SEC + step = meta.get("step") logger.info( - "compare %s device_id=%s trace_id=%s step=%s", - upstream_path, - meta.get("device_id"), - meta.get("trace_id"), - meta.get("step"), + "→ pricebot %s step=%s%s", upstream_path, step, " [mint]" if minted else "", + extra={"phase": "forward", "endpoint": upstream_path, "step": step, + "device_id": meta.get("device_id"), "minted": minted, + "query": meta.get("query"), "user_id": (user.id if user else None)}, ) + t0 = time.monotonic() try: client = get_pricebot_client() resp = await client.post( url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout ) except httpx.RequestError as e: - logger.error("[pricebot] request failed: %s", e) + logger.error( + "← pricebot %s 不可达: %s", upstream_path, e, + extra={"phase": "resp", "endpoint": upstream_path, "step": step, + "error": "upstream_unreachable"}, + ) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"pricebot upstream unreachable: {e}", @@ -79,49 +159,112 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]: if resp.status_code >= 500: logger.error( - "[pricebot] 5xx status=%d body=%s", - resp.status_code, - resp.text[:500], + "← pricebot %s 5xx=%d", upstream_path, resp.status_code, + extra={"phase": "resp", "endpoint": upstream_path, "step": step, + "http_status": resp.status_code, "error": "upstream_5xx"}, ) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"pricebot upstream returned {resp.status_code}", ) - return resp.json() + resp_json = resp.json() + cost_ms = int((time.monotonic() - t0) * 1000) + if not isinstance(resp_json, dict): + return {}, trace_id, meta + # 回传 app-server 签发的 trace_id(新客户端首帧据此拿到,后续帧都带上它) + resp_json.setdefault("trace_id", trace_id) + _action = resp_json.get("action") or {} + logger.info( + "← pricebot %s cmd=%s cont=%s %dms", upstream_path, + _action.get("command"), resp_json.get("continue"), cost_ms, + extra={"phase": "resp", "endpoint": upstream_path, "step": step, + "command": _action.get("command"), "continue": resp_json.get("continue"), + "cost_ms": cost_ms}, + ) + + # 首帧建 running 行(仅本次 mint;老客户端自带 trace_id → 不 mint → 由 done / 其 POST 建行) + if minted and harvest_first_frame: + try: + await run_in_threadpool( + _harvest_running_blocking, trace_id, + (user.id if user else None), "food", + meta.get("device_id"), meta.get("device_info"), + resp_json.get("trace_url"), + ) + except Exception as e: # noqa: BLE001 + logger.warning("harvest_running failed trace=%s: %s", trace_id, e) + + return resp_json, trace_id, meta -@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传到 pricebot)") -async def intent_recognize(request: Request) -> dict[str, Any]: - return await _passthrough(request, "/api/intent/recognize") +@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传 + 建 running 行)") +async def intent_recognize(request: Request, user: OptionalUser) -> dict[str, Any]: + resp, _, _ = await _forward(request, "/api/intent/recognize", user) + return resp -@router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传到 pricebot, 仅淘宝源)") -async def intent_step(request: Request) -> dict[str, Any]: - # 多帧版意图识别(展开+滚动采集→提取): 循环调用直到 done(done 帧顶层带 - # result+calibration)。目前仅淘宝源走这条, 其它源走上面单次 /intent/recognize。 - return await _passthrough(request, "/api/intent/step") +@router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传, 仅淘宝源)") +async def intent_step(request: Request, user: OptionalUser) -> dict[str, Any]: + resp, _, _ = await _forward(request, "/api/intent/step", user) + return resp -@router.post( - "/intent/precoupon/step", - summary="外卖比价 Phase 0 意图识别前先用券 (透传到 pricebot, 仅美团源)", -) -async def intent_precoupon_step(request: Request) -> dict[str, Any]: - # 美团源平台『意图识别前先用券』多帧循环: 客户端在调 /intent/recognize 之前先循环 - # 调本端点到 done(continue=false)。订单页底部有『点击使用X红包』就自动选最大免费券 - # 用上, 已用券/无券则首帧秒过。与 /intent/step 同属 intent 域, 复用同一透传壳。 - return await _passthrough(request, "/api/intent/precoupon/step") +@router.post("/intent/precoupon/step", summary="外卖比价 Phase 0 识别前先用券 (透传, 仅美团源)") +async def intent_precoupon_step(request: Request, user: OptionalUser) -> dict[str, Any]: + resp, _, _ = await _forward(request, "/api/intent/precoupon/step", user) + return resp -@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)") -async def price_step(request: Request) -> dict[str, Any]: - return await _passthrough(request, "/api/price/step") +@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传 + done 落库)") +async def price_step(request: Request, user: OptionalUser) -> dict[str, Any]: + resp, trace_id, meta = await _forward(request, "/api/price/step", user) + # 最终 done 帧(command=done 且 continue=false)→ harvest 更新成终态。 + # (单平台中途 done 被 pricebot 改写成 wait+continue=true,不会命中这里,同 coupon 语义。) + action = resp.get("action") or {} + if action.get("command") == "done" and not resp.get("continue", True): + done_params = action.get("params") or {} + try: + await run_in_threadpool( + _harvest_done_blocking, trace_id, (user.id if user else None), + done_params, "food", + meta.get("device_id"), meta.get("device_info"), + resp.get("trace_url") or done_params.get("trace_url"), + ) + except Exception as e: # noqa: BLE001 + logger.warning("harvest_done failed trace=%s: %s", trace_id, e) + return resp -@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传到 pricebot, 终止/未识别拿 trace_url)") -async def trace_finalize(request: Request) -> dict[str, Any]: - # 用户终止 / Phase1 未识别没走到 done 帧, pricebot 没上云也没回传 trace_url。客户端收尾时 - # 打这个, _passthrough 按 trace_id 一致性 hash 落到处理这条 trace 的同一 pricebot 进程 - # (dir_cache 在那, 才能算对 trace 目录), 由后者打包上云返回 {trace_url}。 - return await _passthrough(request, "/api/trace/finalize") +@router.post("/trace/epilogue", summary="比价结果页尾声帧 (透传到 pricebot, 用户视角截图入 trace)") +async def trace_epilogue(request: Request, user: OptionalUser) -> dict[str, Any]: + # App 收到 done、渲染完结果页后,把自己页面的截图(base64)传给 pricebot 存进 trace + # 目录并触发重传 —— trace 里补上"用户实际看到的汇总页"(步骤帧只有目标 App 画面)。 + # body ~几百 KB(截图 base64), 纯透传壳(harvest_first_frame=False:不建行/不落库, + # 该 trace 的记录已由 done/finalize 落终态)。#112 原走 _passthrough,合并到 harvest + # 分支后统一走 _forward(_passthrough 已并入它)。 + resp, _, _ = await _forward( + request, "/api/trace/epilogue", user, harvest_first_frame=False, + ) + return resp + + +@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)") +async def trace_finalize(request: Request, user: OptionalUser) -> dict[str, Any]: + # 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url}; + # app-server 顺手把该 trace 的 running 行更新成夭折终态(**不降级 success**)。 + # 客户端 finalize body 带 status(cancelled/failed)+ reason;老客户端只带 trace_id → + # 默认 cancelled,其后续 /compare/record 上报再补精确态。 + resp, trace_id, meta = await _forward( + request, "/api/trace/finalize", user, harvest_first_frame=False, + ) + try: + await run_in_threadpool( + _harvest_abort_blocking, trace_id, + (meta.get("status") or "cancelled"), + (meta.get("reason") or meta.get("information")), + (resp.get("trace_url") if isinstance(resp, dict) else None), + ) + except Exception as e: # noqa: BLE001 + logger.warning("harvest_abort failed trace=%s: %s", trace_id, e) + return resp diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 3e4fc7b..2677dad 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -1,15 +1,13 @@ -"""比价记录 endpoint(「我的比价记录」数据源)。 +"""比价记录 endpoint(「我的比价记录」+ admin 数据源)。 路由前缀 `/api/v1/compare`: - POST /record 上报一次比价结果(幂等:同 user+trace_id 覆盖) + POST /record (灰度期兼容)老客户端上报,按 **trace_id** 幂等 + 不降级 success GET /records 比价记录列表(游标分页) GET /records/{id} 单条详情(含 raw_payload 全量) -**均需鉴权**(CurrentUser)——与同文件无关的不鉴权透传 `compare.py` 分开:那个是 -转发壳(MVP 不鉴权),这里是按用户维度落库的业务接口,必须有 user_id。 - -注:本轮只做 server 端,客户端(android 仓)在 done 帧后调 POST /record 上报的改动 -另起一轮(见 app-server docs/待办与技术债.md P1)。 +**均需鉴权**(CurrentUser)。⚠️ 写路径现以 `compare.py` 透传壳的**后端 harvest** 为主 +(帧0 建 running 行 → done/finalize 落终态,新客户端不再 POST);本 POST /record 仅灰度期 +给老客户端用,与 harvest 按 trace_id reconcile。新版覆盖够高后可下线本 POST(阶段3)。 """ from __future__ import annotations @@ -21,16 +19,15 @@ from app.api.deps import CurrentUser, DbSession from app.db.session import SessionLocal from app.models.comparison import ComparisonRecord from app.repositories import comparison as crud_compare -from app.repositories import invite as crud_invite -from app.services.pricebot_llm_calls import fetch_llm_calls from app.schemas.compare_record import ( CompareStatsOut, ComparisonRecordCreatedOut, ComparisonRecordDetailOut, ComparisonRecordIn, - ComparisonRecordPage, ComparisonRecordOut, + ComparisonRecordPage, ) +from app.services.pricebot_llm_calls import fetch_llm_calls logger = logging.getLogger("shagua.compare_record") @@ -53,18 +50,8 @@ def report_record( # 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用 # 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。 background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id) - # 邀请 v2 发奖:被邀请人完成一次【成功】比价 → 给邀请人发邀请奖励金(幂等,只发一次)。 - # best-effort:发奖异常不影响比价上报本身(rec 已 commit),只 log;邀请人补偿靠后续对账。 - if rec.status == "success": - try: - reward = crud_invite.try_reward_on_compare(db, user.id) - if reward.status == "granted": - logger.info( - "invite compare reward granted inviter=%s invitee=%s cents=%s", - reward.inviter_user_id, user.id, reward.reward_cents, - ) - except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞上报 - logger.warning("invite compare reward failed invitee=%s: %s", user.id, e) + # 注:邀请发奖已从"比价成功"挪到"实际下单"(见 api/v1/order.py report_order)—— + # 冰拍板:被邀请人完成比价并实际下单才算邀请成功,仅完成比价不再发奖。 logger.info( "compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)", user.id, diff --git a/app/api/v1/meituan.py b/app/api/v1/meituan.py index 5bb0031..c1d07a1 100644 --- a/app/api/v1/meituan.py +++ b/app/api/v1/meituan.py @@ -25,9 +25,19 @@ from app.schemas.meituan import ( ReferralLinkResponse, TopSalesRequest, ) +from app.utils.meituan_city import get_meituan_city logger = logging.getLogger("shagua.meituan") + +def _resolve_city_id(latitude: float, longitude: float) -> str: + """经纬度 → 美团城市 ID;解析失败返 ""(调用方应降级返空)。""" + try: + return get_meituan_city(latitude, longitude).get("city_id", "") + except Exception: + logger.exception("get_meituan_city 失败") + return "" + router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"]) @@ -175,13 +185,19 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: status = "degraded" if (not cards and wm_fail and dd_fail) else ("ok" if cards else "empty") return FeedResponse(items=cards, has_next=wm_hn or dd_hn, page=req.page, status=status) - # 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。 + # 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,按城市过滤,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。 # 实测库里佣金≥3% 去重后仅 ~578 条(几乎全是外卖;到店团购佣金普遍 <3%):实时按"同城热销榜单" # 拉既撞限流、又填不满(该榜单中位佣金 ~0.8%,筛完每页剩 0-1 条),故从库出。佣金阈值逻辑不变。 if tab == "rec": + city_id = _resolve_city_id(lat, lon) + if not city_id: + return FeedResponse(items=[], has_next=False, page=req.page, status="degraded") PAGE = 20 try: - base = select(MeituanCoupon).where(MeituanCoupon.commission_percent >= 3.0) + base = select(MeituanCoupon).where( + MeituanCoupon.commission_percent >= 3.0, + MeituanCoupon.city_id == city_id, + ) deduped = base.distinct(MeituanCoupon.dedup_key).order_by( MeituanCoupon.dedup_key, MeituanCoupon.commission_percent.desc(), @@ -211,6 +227,9 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: card.distance_text = None card.distance_meters = None cards.append(card) + if not cards and req.page == 1: + # 命中城市却 0 券:该城确无 ≥3% 券,或 ETL 灌的 city_id 与 city_dict 口径不一致。 + logger.info("[feed] rec city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id) return FeedResponse(items=cards, has_next=has_next, page=req.page, status="ok" if cards else "empty") @@ -254,14 +273,24 @@ def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse: @router.post("/top-sales", response_model=CouponListResponse, - summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,不实时打美团)") + summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,按城市过滤,不实时打美团)") def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponListResponse: + # 按设备经纬度定位城市,只查同城券;老客户端不带坐标 → 降级返空(不 422、不误返全城)。 + if req.latitude is None or req.longitude is None: + return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded") + city_id = _resolve_city_id(req.latitude, req.longitude) + if not city_id: + return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded") + # 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。 # (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。) # 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。 try: # 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金) - base = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None)) + base = select(MeituanCoupon).where( + MeituanCoupon.sale_volume_num.isnot(None), + MeituanCoupon.city_id == city_id, + ) if req.platform is not None: base = base.where(MeituanCoupon.platform == req.platform) deduped = base.distinct(MeituanCoupon.dedup_key).order_by( @@ -292,6 +321,14 @@ def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponList except Exception: # noqa: BLE001 continue if card.product_view_sign: + # 不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。 + # 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。 + # 逻辑与推荐流保持一致 + card.distance_text = None + card.distance_meters = None cards.append(card) + if not cards and req.page == 1: + # 命中城市却 0 券:可能该城确无券,也可能 ETL 灌的 city_id 与 city_dict 口径不一致(静默降级的隐患)。 + logger.info("[top-sales] city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id) return CouponListResponse(items=cards, has_next=has_next, search_id=None, status="ok" if cards else "empty") diff --git a/app/api/v1/order.py b/app/api/v1/order.py index c2f9bb3..86340e3 100644 --- a/app/api/v1/order.py +++ b/app/api/v1/order.py @@ -1,9 +1,14 @@ +import logging + from fastapi import APIRouter from app.api.deps import CurrentUser, DbSession +from app.repositories import invite as crud_invite from app.repositories import savings as crud_savings from app.schemas.order import OrderReportOut, OrderReportRequest +logger = logging.getLogger("shagua.order") + router = APIRouter(prefix="/api/v1/order", tags=["order"]) @@ -15,6 +20,20 @@ router = APIRouter(prefix="/api/v1/order", tags=["order"]) def report_order(req: OrderReportRequest, user: CurrentUser, db: DbSession) -> OrderReportOut: # 记账唯一真相表是 savings_record(source='compare')。 rec, duplicated = crud_savings.create_from_report(db, user.id, req) + # 邀请 v2 发奖:被邀请人【实际下单】(而非仅完成比价)才给邀请人发邀请奖励金(幂等,只发一次)。 + # 触发点从"比价成功上报"挪到这里(冰:完成比价并实际下单才算成功)。仅首次真实上报触发, + # 重复上报(duplicated)跳过。best-effort:发奖异常不影响订单上报本身(rec 已 commit),只 log; + # try_reward_on_compare 自带 compare_reward_granted 幂等闸,漏发靠后续对账补。 + if not duplicated: + try: + reward = crud_invite.try_reward_on_compare(db, user.id) + if reward.status == "granted": + logger.info( + "invite order reward granted inviter=%s invitee=%s cents=%s", + reward.inviter_user_id, user.id, reward.reward_cents, + ) + except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞订单上报 + logger.warning("invite order reward failed invitee=%s: %s", user.id, e) return OrderReportOut( id=rec.id, platform=rec.platform or req.platform, diff --git a/app/api/v1/user.py b/app/api/v1/user.py index dc23388..f310b2b 100644 --- a/app/api/v1/user.py +++ b/app/api/v1/user.py @@ -69,6 +69,18 @@ def complete_onboarding( return OkResponse() +@router.post("/onboarding/reset", response_model=OkResponse, summary="重置新手引导(删该 设备+账号 完成标记,下次登录重走)") +def reset_onboarding( + req: OnboardingCompleteRequest, user: CurrentUser, db: DbSession +) -> OkResponse: + """删该 (当前账号, device_id) 的引导完成标记 → 下次登录 onboarding_completed=false,客户端重走。 + 与 /onboarding/complete 互逆。device_id 取客户端硬件级 ANDROID_ID(与登录/complete 一致)。 + 幂等:无记录也返回 ok。仅删自己(当前 JWT 用户)这台设备的记录,不影响别的账号/设备。""" + deleted = onboarding_repo.delete_completion(db, user_id=user.id, device_id=req.device_id) + logger.info("onboarding reset user_id=%d device_len=%d deleted=%d", user.id, len(req.device_id), deleted) + return OkResponse() + + @router.get( "/onboarding/status", response_model=OnboardingStatusResponse, diff --git a/app/core/config.py b/app/core/config.py index cfb7d04..44d6104 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -81,7 +81,6 @@ class Settings(BaseSettings): SMS_SIGN_ID: int = 31729 # 极光短信签名 ID(非机密,可被 .env 覆盖) SMS_TEMPLATE_ID: int = 1 # 极光短信模板 ID(变量名 code,有效期 5 分钟) SMS_CODE_LENGTH: int = 6 # 验证码位数(本服务生成;前端 code 字段 4-8 位兼容) - SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费) SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破) # ===== 测试账号(release 包全流程联调用)===== diff --git a/app/core/config_schema.py b/app/core/config_schema.py index 5a56c3a..afc5dd5 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -11,7 +11,10 @@ from typing import Any from app.core import rewards as r -# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool +# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool / enum +# hidden=True:仍是合法可配项(业务照常 get_value / admin 可经专用端点读写),但**不在通用 +# 「系统配置」页渲染**(admin/routers/config.py:list_config 按此过滤)。用于把已下线/已改由 +# 专用页管理的项从福利页 Tab 收起,同时保留后端默认值与写入能力。 CONFIG_DEFS: dict[str, dict[str, Any]] = { "signin_rewards": { "default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位", @@ -30,18 +33,21 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { "default": r.WITHDRAW_MAX_CENTS, "label": "提现最高额(分)", "group": "钱包", "type": "int", }, + # 从福利页 Tab 收起(hidden)的项:任务 / 里程碑 整组 + 看广告组里的「单次金币(遗留展示值)/ + # 每轮次数 / 比价领券信息流广告开关 comparing_ad_enabled」。key 与默认值保留、后端业务照常读取, + # 仅不在配置页渲染。看广告组保留可见的:每日上限 / 单次金币上限 / 关闭后冷却。 "task_rewards": { "default": dict(r.TASK_REWARDS), "label": "一次性任务奖励", - "group": "任务", "type": "dict_str_int", "help": "task_key → 金币。", + "group": "任务", "type": "dict_str_int", "help": "task_key → 金币。", "hidden": True, }, "record_milestones": { "default": list(r.RECORD_MILESTONES), "label": "比价里程碑金币档位", - "group": "里程碑", "type": "int_list", + "group": "里程碑", "type": "int_list", "hidden": True, "help": "累计成功比价第 1~N 档解锁发的金币。", }, "ad_reward_coin": { "default": r.AD_REWARD_COIN, "label": "看广告单次金币", - "group": "看广告", "type": "int", + "group": "看广告", "type": "int", "hidden": True, "help": "历史兼容/测试展示值;正式发放按 eCPM 公式计算。", }, "ad_daily_limit": { @@ -54,7 +60,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { }, "ad_round_count": { "default": r.VIDEO_ROUND_REQUIRED_COUNT, "label": "每轮看广告次数", - "group": "看广告", "type": "int", "help": "当前为 1,表示每次广告关闭后触发短冷却。", + "group": "看广告", "type": "int", "hidden": True, "help": "当前为 1,表示每次广告关闭后触发短冷却。", }, "ad_cooldown_sec": { "default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)", @@ -67,7 +73,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { }, "comparing_ad_enabled": { "default": True, "label": "比价/领券期信息流广告", - "group": "看广告", "type": "bool", + "group": "看广告", "type": "bool", "hidden": True, "help": ( "开启后,比价进行中 + 领券等候期会在悬浮窗展示穿山甲信息流广告(变现行为);" "关闭则全程不出广告。客户端按 app 启动 / 每场比价开始时拉取并缓存,故为「最终一致」的" @@ -84,4 +90,10 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { "提现页「批量对账」手动按钮不受影响。" ), }, + # 首页轮播数据源(hidden:不进通用配置 Tab,由「首页轮播种子」页的专用端点 /marquee-seeds/mode 读写)。 + "marquee_feed_mode": { + "default": "mixed", "label": "首页轮播数据源", + "group": "首页轮播", "type": "enum", "hidden": True, + "help": "mixed=真实优先+种子补位(默认);real=只用真实比价记录;seed=只用种子/合成(演示)。", + }, } diff --git a/app/core/logging.py b/app/core/logging.py index 3e63ea5..cb91b5c 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -2,9 +2,15 @@ 业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可, 本模块在 main.py 启动时调一次。 -- 控制台(stdout): 人类可读文本, 给 systemd / 本地看。 -- 文件 `logs/app-server.log`: 单行 JSON, 供阿里云 SLS/Logtail 采集(JSON 模式零正则); - 异常栈作为字段内嵌不换行 → 每条日志一行。 +- 控制台(stdout): 人类可读文本, 给 systemd / 本地看; 有 trace 时行尾附 `trace=xxx`。 +- 文件 `logs/app-server.log`: 单行 JSON, 供**阿里云 SLS / Logtail** 采集(JSON 模式零正则); + 异常栈内嵌为字段不换行 → 每条日志一行。 +- **结构化字段(SLS 可直接查/聚合)**: + - `trace_id`: 请求级贯穿——在入口 `trace_id_ctx.set(...)` 后, 本请求内**每一行日志**(含 + run_in_threadpool 里的 harvest, contextvars 自动拷进线程)都自动带上, 无需手写。 + SLS 里 `trace_id: "xxx"` 一查即得整条比价链路, 按 time 升序即请求顺序。 + - 任意 `logger.info(msg, extra={"phase": ..., "step": ..., "command": ...})` 的 extra + 键都会平铺进 JSON 顶层 → SLS 可按 phase/step/command/cost_ms 等过滤聚合。 - 环境变量: - LOG_JSON_CONSOLE=1 控制台也输出 JSON - LOG_DIR / LOG_FILE 改落盘路径(默认 logs/app-server.log) @@ -17,13 +23,36 @@ import json import logging import os import sys +from contextvars import ContextVar from datetime import datetime from logging.handlers import RotatingFileHandler from pathlib import Path +# 请求级 trace_id:入口(如 compare.py 透传壳)set 之后, 本请求上下文(含 run_in_threadpool +# 拷贝出去的线程)内所有日志自动带上。默认空串 = 非请求上下文(启动/后台 worker)。 +trace_id_ctx: ContextVar[str] = ContextVar("trace_id", default="") + + +# 标准 LogRecord 属性 + 格式化期附加项:凡不在此集合的 record 属性都视为业务 extra, 平铺进 JSON。 +_RESERVED = set( + logging.LogRecord("", 0, "", 0, "", (), None).__dict__ +) | {"message", "asctime", "trace_id", "taskName"} + + +class _ContextFilter(logging.Filter): + """把 trace_id_ctx 注入每条 record(供两个 formatter 取用)。挂在 handler 上, + 命中每条(含 propagate 上来的)记录, 在 format 之前置好 record.trace_id。""" + + def filter(self, record: logging.LogRecord) -> bool: + if not hasattr(record, "trace_id"): + record.trace_id = trace_id_ctx.get() + return True + + class JsonFormatter(logging.Formatter): - """把 LogRecord 序列化成单行 JSON(SLS/Logtail 友好)。异常栈内嵌为字段, 整条仍是一行。""" + """LogRecord → 单行 JSON(SLS/Logtail 友好)。trace_id 提到顶层、extra 键平铺, 异常栈内嵌。""" + def __init__(self, service: str = "app-server"): super().__init__() self.service = service @@ -35,10 +64,17 @@ class JsonFormatter(logging.Formatter): "level": record.levelname, "service": self.service, "logger": record.name, - "func": record.funcName, - "line": record.lineno, - "message": record.getMessage(), } + tid = getattr(record, "trace_id", "") or trace_id_ctx.get() + if tid: + data["trace_id"] = tid + # 业务 extra 字段(phase / step / endpoint / command / cost_ms / status ...)平铺进顶层 + for k, v in record.__dict__.items(): + if k not in _RESERVED and not k.startswith("_"): + data[k] = v + data["func"] = record.funcName + data["line"] = record.lineno + data["message"] = record.getMessage() if record.exc_info: data["exception"] = self.formatException(record.exc_info) if record.stack_info: @@ -46,6 +82,15 @@ class JsonFormatter(logging.Formatter): return json.dumps(data, ensure_ascii=False, default=str) +class TextFormatter(logging.Formatter): + """控制台文本:标准行 + 有 trace_id 时行尾附 `trace=xxx`(无 trace 的启动/后台日志不加噪)。""" + + def format(self, record: logging.LogRecord) -> str: + base = super().format(record) + tid = getattr(record, "trace_id", "") or trace_id_ctx.get() + return f"{base} trace={tid}" if tid else base + + _CONFIGURED = False @@ -63,14 +108,17 @@ def setup_logging(debug: bool = False) -> None: for h in list(root.handlers): root.removeHandler(h) + ctx_filter = _ContextFilter() + # 控制台: 默认文本(systemd/本地看); LOG_JSON_CONSOLE=1 时输出 JSON console = logging.StreamHandler(sys.stdout) if os.getenv("LOG_JSON_CONSOLE") == "1": console.setFormatter(JsonFormatter(service)) else: console.setFormatter( - logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") + TextFormatter("%(asctime)s %(levelname)s %(name)s: %(message)s") ) + console.addFilter(ctx_filter) root.addHandler(console) # 文件: 单行 JSON, 供 Logtail 采集(自动轮转, 单文件 10MB, 保留 5 个) @@ -82,6 +130,7 @@ def setup_logging(debug: bool = False) -> None: log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8", ) file_handler.setFormatter(JsonFormatter(service)) + file_handler.addFilter(ctx_filter) root.addHandler(file_handler) # 第三方库降噪 diff --git a/app/integrations/sms.py b/app/integrations/sms.py index 6b16aac..3841f96 100644 --- a/app/integrations/sms.py +++ b/app/integrations/sms.py @@ -8,15 +8,16 @@ 校验)→ 鉴权复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`(同一极光应用)。 验证码存储:**进程内存**(单 worker uvicorn 够用)。重启丢失(用户重发即可)。多 -worker / 多机时内存不共享 → 冷却、每日上限、校验都会失效,届时迁移到 DB/Redis。 +worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移到 DB/Redis。 见 docs/待办与技术债.md。 -防刷三层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权): +防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权): 1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件) - 2. 单号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限(本文件) - 3. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。 + 2. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。 ⚠️ 原「单 IP 频控(rate_limit 依赖)」2026-06-26 按产品要求删除、改设备维度;但 device_id 客户端可伪造/轮换, 脚本轮换 id 能绕过本层 → 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)。 + ⚠️ 原「单号每日上限」2026-07-03 按精简要求删除(mentor 定:登录风控只留单号冷却 + 单设备频控); + 单号维度现仅剩 60s 冷却,「换号轰炸」由单设备频控封顶。 另:单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废(防爆破),验过即作废(一次性)。 """ from __future__ import annotations @@ -26,7 +27,6 @@ import logging import secrets import time from dataclasses import dataclass -from datetime import datetime from threading import Lock import httpx @@ -56,22 +56,17 @@ class _CodeRecord: # 进程内存(单 worker 有效;多 worker 不共享,见模块 docstring) _codes: dict[str, _CodeRecord] = {} # phone -> 当前有效验证码 _last_sent: dict[str, float] = {} # phone -> 上次发送 epoch(冷却) -_daily_count: dict[str, tuple[str, int]] = {} # phone -> (date_str, 当日发送数) _lock = Lock() _GC_THRESHOLD = 10000 # 任一内存 dict 超此阈值,send 时顺手清过期项(防无限增长,仿 ratelimit) -def _today() -> str: - return datetime.now().strftime("%Y-%m-%d") - - def _gen_code() -> str: """生成 N 位数字验证码(用 secrets 而非 random;允许前导 0)。""" return "".join(secrets.choice("0123456789") for _ in range(settings.SMS_CODE_LENGTH)) def _gc(now: float) -> None: - """顺手清理过期内存项,防三个 dict 无限增长。仅在持锁时调用,且某 dict 超 + """顺手清理过期内存项,防两个 dict 无限增长。仅在持锁时调用,且某 dict 超 _GC_THRESHOLD 才扫它(低频,开销可忽略)。""" if len(_codes) > _GC_THRESHOLD: for p in [p for p, r in _codes.items() if now > r.expires_at]: @@ -80,10 +75,6 @@ def _gc(now: float) -> None: cutoff = now - settings.SMS_SEND_INTERVAL_SEC for p in [p for p, ts in _last_sent.items() if ts < cutoff]: _last_sent.pop(p, None) - if len(_daily_count) > _GC_THRESHOLD: - today = _today() - for p in [p for p, (d, _c) in _daily_count.items() if d != today]: - _daily_count.pop(p, None) def send_code(phone: str) -> int: @@ -102,17 +93,9 @@ def send_code(phone: str) -> int: remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed) raise SmsError(f"发送过于频繁,请 {remain}s 后再试") - today = _today() - day, cnt = _daily_count.get(phone, ("", 0)) - if day != today: - cnt = 0 - if cnt >= settings.SMS_DAILY_LIMIT_PER_PHONE: - raise SmsError("今日验证码发送次数已达上限,请明天再试") - code = _gen_code() - # 预占:先记冷却/计数/存码,释放锁后再发网络(发失败保留冷却+计数,见下) + # 预占:先记冷却/存码,释放锁后再发网络(发失败保留冷却,见下) _last_sent[phone] = now - _daily_count[phone] = (today, cnt + 1) _codes[phone] = _CodeRecord(code=code, expires_at=now + settings.SMS_CODE_TTL_SEC) # --- lock 外:真正发送(网络 IO 不持锁)--- @@ -123,7 +106,7 @@ def send_code(phone: str) -> int: _send_via_jiguang(phone, code) logger.info("[SMS] sent to %s****", phone[:3]) except Exception as e: - # 发送失败:**保留冷却 + 每日计数**(失败也限速,挡住余额不足/签名失效时 + # 发送失败:**保留冷却**(失败也限速,挡住余额不足/签名失效时 # 前端重试狂打极光),只清掉没发出去的码(用户收不到,留着无意义且占内存)。 with _lock: _codes.pop(phone, None) diff --git a/app/main.py b/app/main.py index 89ab42a..7d93e60 100644 --- a/app/main.py +++ b/app/main.py @@ -76,6 +76,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: settings.DATABASE_URL.split("://", 1)[0], ) get_pricebot_client() # 预热透传 client:把建 SSL 上下文的一次性成本付在启动,首个领券请求即热 + try: + # 预热离线地理库:首次加载 ~2.5M 行 CSV + 建 KDTree,摊到启动、不砸首个按城市过滤的请求 + from app.utils import geo + geo.ensure_loaded() + except Exception: # noqa: BLE001 + logger.exception("reverse_geocoder 预热失败(城市反查将在首个请求时懒加载)") reconcile_task = start_withdraw_reconcile_worker() heartbeat_task = start_heartbeat_monitor() daily_exchange_task = start_daily_exchange_worker() diff --git a/app/models/__init__.py b/app/models/__init__.py index 9e5446c..1f9a0d8 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -5,6 +5,7 @@ from app.models.ad_pangle_revenue import AdPangleDailyRevenue # noqa: F401 from app.models.ad_reward import AdRewardRecord # noqa: F401 from app.models.ad_watch_log import AdWatchLog # noqa: F401 from app.models.admin import AdminAuditLog, AdminUser # noqa: F401 +from app.models.admin_role import AdminRole # noqa: F401 from app.models.analytics_event import AnalyticsEvent # noqa: F401 from app.models.app_config import AppConfig # noqa: F401 from app.models.comparison import ComparisonRecord # noqa: F401 diff --git a/app/models/admin.py b/app/models/admin.py index 7046896..0c56963 100644 --- a/app/models/admin.py +++ b/app/models/admin.py @@ -25,6 +25,9 @@ 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 diff --git a/app/models/admin_role.py b/app/models/admin_role.py new file mode 100644 index 0000000..39c6cd8 --- /dev/null +++ b/app/models/admin_role.py @@ -0,0 +1,42 @@ +"""admin 角色 → 可见页面(权限)映射表。 + +RBAC 的「角色」侧:每个角色持有一组「页面 key」(= 左侧导航项),决定该角色登录后台后左边能看到 +哪些页。super_admin 是内建全权角色(is_builtin=True),恒可见全部页(effective_pages 里特判, +不依赖本表存的 pages)、不可编辑/删除。其余角色(含 operator/finance)可由 super_admin 增删改。 + +admin_user.role 存的是本表的 name(字符串弱引用,不建外键——与既有 require_role 字符串口径一致; +删除在用角色由业务层拦截,见 routers/roles.py)。页面 key 清单见 app/admin/permissions.py。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import JSON, Boolean, DateTime, Integer, String, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + +_JSON = JSON().with_variant(JSONB(), "postgresql") + + +class AdminRole(Base): + __tablename__ = "admin_role" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 角色标识 key(不可变,承重):admin_user.role 引用它、require_role 按它鉴权。 + # 内建 = super_admin/operator/finance/tech(英文,勿改);自定义 = 创建时的名称。 + name: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) + # 展示名(可改):内建 = 管理员/运营/财务/技术;自定义默认 = name。UI 一律展示 label。 + label: Mapped[str] = mapped_column(String(32), nullable=False, default="") + # 该角色可见页面 key 列表(= 左侧导航项),见 app/admin/permissions.py 的 PERMISSION_CATALOG + pages: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) + # 内建角色(当前仅 super_admin):不可编辑/删除,恒全权 + is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/models/comparison.py b/app/models/comparison.py index a8b24d9..d21a201 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -39,16 +39,19 @@ _JSON = JSON().with_variant(JSONB(), "postgresql") class ComparisonRecord(Base): __tablename__ = "comparison_record" __table_args__ = ( - # 同一用户同一次比价(trace_id)只存一条:客户端重试/误点重复上报时幂等覆盖。 - UniqueConstraint("user_id", "trace_id", name="uq_comparison_user_trace"), + # trace_id 由 app-server 签发、全局唯一 → 一次比价一行,后端 harvest 按它 upsert。 + # (原 (user_id,trace_id) 复合唯一改为 trace_id 单列:harvest 帧0 建行时 user_id 可能暂缺。) + UniqueConstraint("trace_id", name="uq_comparison_trace"), # 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序; # 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。 Index("ix_comparison_status_created", "status", "created_at"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("user.id"), index=True, nullable=False + # 后端 harvest 在帧0(pricebot 出 trace_id)即建行,软鉴权下 user_id 可能暂缺(老客户端/匿名)→ 可空。 + # C 端「我的比价记录」按 user_id 过滤天然排除 null-user 行;admin 全看(含孤儿行)。 + user_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=True ) # 仍记录设备号(同一用户多设备的行为区分 / 与不鉴权期 device_id 数据对账) device_id: Mapped[str | None] = mapped_column(String(64), nullable=True) @@ -83,6 +86,10 @@ class ComparisonRecord(Base): # ===== 订单概要 ===== store_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 下单商品名拼接串(顿号分隔,从 items[].name 去重派生),供 admin 列表「商品」列展示 + 商品名搜索。 + # items 是 JSON(SQLite 下 json.dumps ensure_ascii 把中文转义,无法直接 CAST+LIKE),故另派生成普通 + # 文本列——跨库 LIKE 一致、可加索引。写路径(upsert_record / harvest_done)落库时同步派生。 + product_names: Mapped[str | None] = mapped_column(String(512), nullable=True) total_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True) skipped_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True) diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index d675822..5f186aa 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -24,6 +24,23 @@ def _yuan_to_cents(yuan: float | None) -> int | None: return round(yuan * 100) +def _product_names_from_items(items: list | None) -> str | None: + """下单商品 items([{name, qty, specs?}])→ 顿号分隔的商品名串(去重保序), + 供 admin「商品」列展示 + 商品名 LIKE 搜索。空 / 无名 → None;超列宽(512)截断留余量。""" + if not items: + return None + names: list[str] = [] + for it in items: + name = it.get("name") if isinstance(it, dict) else None + if not name: + continue + s = str(name).strip() + if s and s not in names: + names.append(s) + joined = "、".join(names) + return joined[:500] or None + + def _derive(payload: ComparisonRecordIn) -> dict: """从上报 payload 派生结构化列(best/saved/is_source_best/status)。""" results = payload.comparison_results @@ -74,12 +91,19 @@ def _derive(payload: ComparisonRecordIn) -> dict: def upsert_record( db: Session, *, user_id: int, payload: ComparisonRecordIn ) -> ComparisonRecord: - """按 (user_id, trace_id) 幂等写入:已存在则覆盖(更完整的重试上报胜出),否则新建。""" + """按 **trace_id** 幂等写入(唯一键已从 user_id+trace_id 改为 trace_id):已存在则合并 + (回填 null user_id + 覆盖字段,但**不降级 success**),否则新建。 + + 灰度期老客户端 POST /compare/record 走这条,与后端 harvest 按 trace_id reconcile; + 新客户端不再 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, @@ -88,7 +112,7 @@ def upsert_record( trace_url=payload.trace_url, total_dish_count=payload.total_dish_count, skipped_dish_count=payload.skipped_dish_count, - items=[it.model_dump(exclude_none=True) for it in payload.items], + items=items, comparison_results=[r.model_dump() for r in payload.comparison_results], skipped_dish_names=list(payload.skipped_dish_names), # 客户端环境 / 性能(debug,客户端上报;旧客户端为 None) @@ -110,14 +134,29 @@ def upsert_record( **derived, ) + # 按 trace_id 定位(唯一键已改 trace_id):后端 harvest 可能已建行,这里的客户端上报 + # (灰度期老客户端 / 新客户端不再走这条)与之 reconcile。 existing = db.execute( select(ComparisonRecord).where( - ComparisonRecord.user_id == user_id, ComparisonRecord.trace_id == payload.trace_id, ) ).scalar_one_or_none() if existing is not None: + # 不降级:harvest 或更早上报已落成 success,收尾期 fromFailure 的 cancelled/failed + # 不许把它盖回去(老 comparisonReported bug 的服务端兜底)——只补 user_id / trace_url。 + if existing.status == "success" and fields.get("status") != "success": + if existing.user_id is None and user_id is not None: + existing.user_id = user_id + if fields.get("trace_url"): + existing.trace_url = fields["trace_url"] + db.commit() + db.refresh(existing) + return existing + # 只**填**空缺 user_id、不 reassign:trace_id 全局唯一,一条 trace 只属一个用户; + # 绝不把已有归属的记录改判给另一个上报者(仅 harvest 建的 null-user 行在此绑上)。 + if existing.user_id is None: + existing.user_id = user_id for k, v in fields.items(): setattr(existing, k, v) db.commit() @@ -139,6 +178,203 @@ def upsert_record( return rec +# ============================================================ +# 后端 harvest:app-server 从 pricebot 透传响应里直接落库(不靠客户端上报)。 +# 帧0 建行(running) → 最终 done 更新(success/failed) → finalize 更新(aborted)。 +# 全按 trace_id upsert;success 行永不被后到的 failed/aborted 降级。 +# ============================================================ + + +def _derive_from_results(results: list[dict]) -> dict: + """从 done 帧 comparison_results(pricebot 原始 dict 列表)派生结构化列。 + 等价 _derive,但吃原始字段(is_source/price/rank/platform_id/store_name...)而非 pydantic 对象。""" + priced = [r for r in results if r.get("price") is not None] + best = None + if priced: + best = min( + priced, + key=lambda r: (r.get("rank") if r.get("rank") is not None else 10**9, r["price"]), + ) + src_row = next((r for r in results if r.get("is_source")), None) + + source_price_cents = None + if src_row is not None and src_row.get("price") is not None: + source_price_cents = _yuan_to_cents(src_row["price"]) + best_price_cents = _yuan_to_cents(best["price"]) if best else None + saved_amount_cents = None + if source_price_cents is not None and best_price_cents is not None: + saved_amount_cents = source_price_cents - best_price_cents + + has_valid_target = any( + (not r.get("is_source")) and r.get("price") is not None for r in results + ) + return { + "source_platform_id": (src_row or {}).get("platform_id"), + "source_platform_name": (src_row or {}).get("platform_name"), + "source_package": (src_row or {}).get("package"), + "source_price_cents": source_price_cents, + "best_platform_id": best.get("platform_id") if best else None, + "best_platform_name": best.get("platform_name") if best else None, + "best_price_cents": best_price_cents, + "saved_amount_cents": saved_amount_cents, + "is_source_best": best.get("is_source") if best else None, + "store_name": (src_row or {}).get("store_name") or None, + "status": "success" if has_valid_target else "failed", + } + + +def _device_cols_from_info(device_info: dict | None) -> dict: + """step 帧 device_info({locale,brand,model,android_version,rom_version})→ 表列。 + step 帧只带这几项;rom_name / android_sdk / app_version / 耗时步数 harvest 拿不到 → 留 None + (灰度期老客户端 fromComparison 会补齐;要新客户端也全带需扩 collectDeviceInfo,后续)。""" + d = device_info or {} + rv = str(d.get("rom_version") or "") + return { + "device_model": d.get("model") or None, + "device_manufacturer": d.get("brand") or None, + "android_version": d.get("android_version") or None, + "rom_version": int(rv) if rv.isdigit() else None, + } + + +def _get_by_trace(db: Session, trace_id: str) -> ComparisonRecord | None: + return db.execute( + select(ComparisonRecord).where(ComparisonRecord.trace_id == trace_id) + ).scalar_one_or_none() + + +def harvest_running( + db: Session, + *, + trace_id: str, + user_id: int | None, + business_type: str = "food", + device_id: str | None = None, + device_info: dict | None = None, + trace_url: str | None = None, +) -> ComparisonRecord: + """帧0(或任一尚未建行的帧)建/补 running 行。幂等:已存在只补空缺(user_id/trace_url/ + device_id/机型),绝不动已落定的 status / 结果。""" + rec = _get_by_trace(db, trace_id) + if rec is None: + rec = ComparisonRecord( + trace_id=trace_id, + user_id=user_id, + business_type=business_type or "food", + device_id=device_id, + status="running", + trace_url=trace_url, + created_at=datetime.now(CN_TZ).replace(tzinfo=None), + **_device_cols_from_info(device_info), + ) + db.add(rec) + db.commit() + db.refresh(rec) + return rec + changed = False + if rec.user_id is None and user_id is not None: + rec.user_id = user_id + changed = True + if not rec.trace_url and trace_url: + rec.trace_url = trace_url + changed = True + if rec.device_id is None and device_id: + rec.device_id = device_id + changed = True + for k, v in _device_cols_from_info(device_info).items(): + if getattr(rec, k) is None and v is not None: + setattr(rec, k, v) + changed = True + if changed: + db.commit() + db.refresh(rec) + return rec + + +def harvest_done( + db: Session, + *, + trace_id: str, + user_id: int | None, + done_params: dict, + business_type: str = "food", + device_id: str | None = None, + device_info: dict | None = None, + trace_url: str | None = None, +) -> tuple[ComparisonRecord, bool]: + """最终 done 帧:running 行 → 终态(success/failed)+结果+trace_url。 + 返回 (记录, 是否本次**新**落成 success)——供调用方据此幂等发一次邀请奖。 + 行不存在(理论上帧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, + # best_deeplink 来自客户端剪贴板采集,harvest 拿不到 → 留空(灰度期 fromComparison 会补; + # 纯 harvest 行「再次比价」退化为按 package 拉起 App。要精确深链需客户端另传,后续)。 + trace_url=trace_url or done_params.get("trace_url"), + total_dish_count=done_params.get("total_dish_count"), + 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), + raw_payload=done_params, + **derived, + **_device_cols_from_info(device_info), + ) + + rec = _get_by_trace(db, trace_id) + was_success = rec is not None and rec.status == "success" + if rec is None: + rec = ComparisonRecord( + trace_id=trace_id, + user_id=user_id, + created_at=datetime.now(CN_TZ).replace(tzinfo=None), + device_id=device_id, + **fields, + ) + db.add(rec) + else: + if user_id is not None and rec.user_id is None: + rec.user_id = user_id + if device_id and rec.device_id is None: + rec.device_id = device_id + for k, v in fields.items(): + setattr(rec, k, v) + db.commit() + db.refresh(rec) + newly_success = (rec.status == "success") and not was_success + return rec, newly_success + + +def harvest_abort( + db: Session, + *, + trace_id: str, + status: str, + reason: str | None, + trace_url: str | None = None, +) -> ComparisonRecord | None: + """finalize(用户终止/超时/异常,无 done 帧):running 行 → aborted/failed + trace_url。 + **不降级 success**:行已 success(收尾取消那种 finalize 后到)只 refresh trace_url。 + 行不存在(极少:帧0没建成)→ 返回 None,不凭空造。""" + rec = _get_by_trace(db, trace_id) + if rec is None: + return None + if trace_url and not rec.trace_url: + rec.trace_url = trace_url + if rec.status != "success": + rec.status = status or "cancelled" + if reason: + rec.information = reason + db.commit() + db.refresh(rec) + return rec + + def _ordered_shop_names(db: Session, user_id: int) -> set[str]: """该用户「真实下单」(source='compare')覆盖到的店名集合,用来给比价记录打「已下单」。 diff --git a/app/repositories/invite.py b/app/repositories/invite.py index 8dbd290..5af08c5 100644 --- a/app/repositories/invite.py +++ b/app/repositories/invite.py @@ -304,6 +304,9 @@ def get_invitees( "avatar_url": u.avatar_url or u.wechat_avatar_url or None, "coins": rel.inviter_coin, # v3 起恒 0(邀请人收益改走邀请奖励金) "invited_at": rel.created_at, + # 是否已完成过一次比价(= 已发过邀请奖励金)。客户端据此:好友列表分"去提醒/邀请成功"、 + # 在途列表只取未比价(is_compared=False)、算在途好友数与在途收益(未比价数×2元)。 + "is_compared": bool(rel.compare_reward_granted), }) has_more = offset + len(rows) < int(total) return items, int(total), has_more diff --git a/app/repositories/onboarding.py b/app/repositories/onboarding.py index 03e51d3..0750dd1 100644 --- a/app/repositories/onboarding.py +++ b/app/repositories/onboarding.py @@ -5,7 +5,7 @@ device_id 为空(老客户端 / 取不到 ANDROID_ID)一律按"未完成"处理, """ from __future__ import annotations -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -33,3 +33,18 @@ def mark_completed(db: Session, *, user_id: int, device_id: str) -> None: except IntegrityError: # 并发 / 重复提交撞唯一约束:已有行即视为成功。 db.rollback() + + +def delete_completion(db: Session, *, user_id: int, device_id: str) -> int: + """删该 (账号, 设备) 的引导完成标记 → 下次登录 is_completed=False → 客户端重走。 + 与 [mark_completed] 互逆。device_id 为空忽略(返 0);无记录也幂等(返 0)。返回删除行数。""" + if not device_id: + return 0 + result = db.execute( + delete(OnboardingCompletion).where( + OnboardingCompletion.user_id == user_id, + OnboardingCompletion.device_id == device_id, + ) + ) + db.commit() + return result.rowcount diff --git a/app/repositories/ops_marquee.py b/app/repositories/ops_marquee.py index bb4c35f..2572b3d 100644 --- a/app/repositories/ops_marquee.py +++ b/app/repositories/ops_marquee.py @@ -29,6 +29,17 @@ 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() @@ -212,50 +223,56 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]: 展示时间统一「刷新」成相对现在的最近时刻(从 now 往前**随机抖动**递减),保证轮播永远像刚发生、 节奏自然不机械(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时时间)。 """ - # 真实条:取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。 - rows = _recent_real_rows(db) + mode = get_feed_mode(db) # mixed / real / seed(运营可在「首页轮播种子」页切换) items: list[dict] = [] used_names: set[str] = set() - seen_users: set[int] = set() - for uid, sc, nick in rows: - if uid in seen_users: - continue - seen_users.add(uid) - name = _mask_real(nick, uid) - used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受 - items.append({"masked_user": name, "saved_amount_cents": int(sc)}) - if len(items) >= limit: - break - need = limit - len(items) - if need > 0: - seeds = db.execute( - select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True)) - ).scalars().all() - # 公平随机抽取 need 个(池子够大则不放回抽样;否则全用并打散),让所有启用种子都有机会露出。 - chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds) - _rng.shuffle(chosen) - for s in chosen: - # 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子); - # 仅运营手动设的非模板真名才原样用。 - fixed = (s.masked_user or "").strip() - name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names) + # 真实条(mixed / real):取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。 + if mode != "seed": + rows = _recent_real_rows(db) + seen_users: set[int] = set() + for uid, sc, nick in rows: + if uid in seen_users: + continue + seen_users.add(uid) + name = _mask_real(nick, uid) + used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受 + items.append({"masked_user": name, "saved_amount_cents": int(sc)}) + if len(items) >= limit: + break + + # 种子补位 + 合成兜底(mixed / seed):mixed 下补真实不足的部分,seed 下全量用种子/合成。 + # real 模式**跳过**——只出真实,不掺任何假数据(真实不足则少于 limit,为 0 时返回空)。 + if mode != "real": + need = limit - len(items) + if need > 0: + seeds = db.execute( + select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True)) + ).scalars().all() + # 公平随机抽取 need 个(池子够大则不放回抽样;否则全用并打散),让所有启用种子都有机会露出。 + chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds) + _rng.shuffle(chosen) + for s in chosen: + # 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子); + # 仅运营手动设的非模板真名才原样用。 + fixed = (s.masked_user or "").strip() + name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names) + used_names.add(name) + # 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。 + lo = max(0, int(s.min_cents)) + hi = max(lo, int(s.max_cents)) + items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)}) + + # 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满, + # 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。 + while len(items) < limit: + name = _unique_name(used_names) used_names.add(name) - # 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。 - lo = max(0, int(s.min_cents)) - hi = max(lo, int(s.max_cents)) - items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)}) - - # 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满, - # 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。 - while len(items) < limit: - name = _unique_name(used_names) - used_names.add(name) - items.append({ - "masked_user": name, - "saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS), - }) + items.append({ + "masked_user": name, + "saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS), + }) # 统一赋「最近」时间:从 now 往前**随机抖动**递减,避免固定节奏被看出规律。 # 首条几十秒前;其余多数 1~5 分钟,偶尔扎堆(20~55s)或较长(5~9 分钟),降序、像真实流水。 diff --git a/app/repositories/ops_stat.py b/app/repositories/ops_stat.py index c4593b8..1152b0d 100644 --- a/app/repositories/ops_stat.py +++ b/app/repositories/ops_stat.py @@ -318,11 +318,14 @@ def update_config( row.random_current = _monotonic(row, random_initial) row.random_last_tick_at = now elif apply_now: - # 立即更新:不等钟点,马上刷新一次 + # 立即更新:不等钟点,马上刷新一次。 + # random:走一档增长;real/manual:直接落到「配置目标值」(manual_value / max(真实,保底)), + # **显式保存即所见即所得,绕过「只增不减」护栏**——运营手动改值就是要按配置显示(含调小)。 + # (护栏仍作用于自动 tick 的 _refresh:防门面在两次保存之间被真实刷新/自增长悄悄缩水。) if row.mode == "random" and row.random_current is not None: row.random_current = _grow(row, row.random_current, 1) else: - row.random_current = _monotonic(row, _current_target(db, row)) + row.random_current = _current_target(db, row) row.random_last_tick_at = now elif row.random_current is None: # 首次无值:按当前模式播种,使配置后立即有合理展示值 diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 1edd034..345cda7 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -296,6 +296,9 @@ def daily_auto_exchange(db: Session) -> dict: - **逐用户独立事务**:单个用户异常 rollback 不影响其他人;exchange_coins_to_cash 内部按用户 commit。 返回统计 dict(scanned/converted/skipped_done/skipped_dust/failed/total_cents)。 """ + # ⚠️「今天」写死北京时(rewards.cn_today() = datetime.now(CN_TZ).date(), CN_TZ=+8),与服务器/用户 + # 时区无关(用户 2026-07-01 硬约束:兑换一律北京 0 点日切)。**禁止**改成 date.today() / 裸 + # datetime.now().date()——那会跟随进程本地时区,服务器非 CST 时会在错误的"天"兑/重复兑。 today = rewards.cn_today() stats = { "scanned": 0, "converted": 0, "skipped_done": 0, diff --git a/app/schemas/invite.py b/app/schemas/invite.py index 24e56b4..25da870 100644 --- a/app/schemas/invite.py +++ b/app/schemas/invite.py @@ -69,6 +69,7 @@ class InviteeItem(BaseModel): avatar_url: str | None = None # 头像 URL;null = 前端画默认色块 coins: int # 这次邀请给我(邀请人)发的金币 invited_at: datetime # 邀请绑定时间(前端转"今天/3天前") + is_compared: bool = False # 该好友是否已完成过一次比价(好友列表分"去提醒/邀请成功";在途列表只取 False) class InviteeListOut(BaseModel): diff --git a/app/schemas/meituan.py b/app/schemas/meituan.py index b40f3ed..a15e4c8 100644 --- a/app/schemas/meituan.py +++ b/app/schemas/meituan.py @@ -174,9 +174,13 @@ class FeedResponse(BaseModel): class TopSalesRequest(BaseModel): """销量最高 tab:从离线库 meituan_coupon 按销量降序取(不实时打美团)。""" + # 可选:老客户端(本次改动前发版)不带经纬度。缺省时后端降级返空(status=degraded), + # 不做 422 硬拒,也不误返"全城"结果。新客户端会传坐标 → 按城市过滤。 + longitude: float | None = Field(None, description="经度(用于定位城市;缺省=老客户端,降级返空)") + latitude: float | None = Field(None, description="纬度(用于定位城市;缺省=老客户端,降级返空)") page: int = Field(1, ge=1) page_size: int = Field(20, ge=1, le=50) - platform: int | None = Field(None, description="可选: 1只外卖 / 2只到店; 不填=全部(全城销量)") + platform: int | None = Field(None, description="可选: 1只外卖 / 2只到店; 不填=全部(同城销量)") # ───────────────── 换链 请求 / 响应 ───────────────── diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/data/city_dict.txt b/app/utils/data/city_dict.txt new file mode 100644 index 0000000..e8b0958 --- /dev/null +++ b/app/utils/data/city_dict.txt @@ -0,0 +1,360 @@ +城市ID 城市名称 省份名称 +3NUYJKKJXPHVNZUHFK3HWUDHNM 宣城市 安徽省 +LXXSHOY7LNK74ZK2SKVUXFY72Q 阜阳市 安徽省 +ZEBF2LBJOEHGM4XGFPNW4IHBIA 合肥市 安徽省 +WADWEY3GR6IARJLSNGQWG2KI4E 滁州市 安徽省 +Z62QL3X66AOT6MSY7LB6LVO4CI 芜湖市 安徽省 +M6QCWRCECRT6ZEJ6RERR6IWHFI 淮南市 安徽省 +UP2NBJACSAO7FUH4XQQDMBOUE4 马鞍山市 安徽省 +WLL7BSHUBPTLTMITFITJX2GKWY 蚌埠市 安徽省 +ECTBNJ4KNNHPGXEVI4TBJNU7BY 亳州市 安徽省 +KNEGDNOSRGNQPAMOMH54ATLIUU 六安市 安徽省 +YH53FRR55H6VA4KXW36OJ7RNSE 宿州市 安徽省 +LH6LX5DUVFPOFCAQ6NEGPWCLBI 淮北市 安徽省 +RIFXWJ46SEJXE7EP6HZXEUIALU 铜陵市 安徽省 +FK6F7KMO4WNARZKUXMMSBWGATM 安庆市 安徽省 +M3CHGNNUSFSPS5HRV3W7MCBWFI 黄山市 安徽省 +XFI6PM7SRO6NHBGYQUZEEA2WT4 池州市 安徽省 +D2ZILSASTTGEFQWZIDTHA7PWU4 澳门 澳门特别行政区 +WKV2HMXUEK634WP64CUCUQGM64 北京市 北京市 +HPMKHLM3QR6EZGMY7GEI4H3QYQ 泉州市 福建省 +6V523YRU54Y3PEAM2XPADNJM2U 福州市 福建省 +HH3ZZCERPVQIYUZPW4A2U4JKZI 莆田市 福建省 +BQ5RWEJS4O7W27SQMLPMRIRJDU 宁德市 福建省 +TDN7XDQMZEP6ZCK6UO3VVMCSMM 三明市 福建省 +ZKNTORN6YTZL2BXRYUSRGV3CHU 厦门市 福建省 +Y2DI2QLZN5NNACMD3KI2DBR4IA 龙岩市 福建省 +C4RS32I6QAHWLP55UQWI3N5LLY 南平市 福建省 +5T23WDEOAP7RYNU4JNL2ZG7PDQ 漳州市 福建省 +UHZBROATFB2KWNMNLS23DPRJBY 定西市 甘肃省 +WLPAHIUOIVKS644QSN4V5ZY5XQ 金昌市 甘肃省 +J7TO3UHZ57ABNUKIQUBCIJZQFA 白银市 甘肃省 +GX277SS75375VEFYVCEHBL6ISA 临夏回族自治州 甘肃省 +ORA3R7F2LSJHUOCIDTZCP54G7Q 张掖市 甘肃省 +L6VQYYOJNTHSLW5JCR5SXBITDM 武威市 甘肃省 +65OXQPQVFXNOYHXTMCE2RNFRL4 兰州市 甘肃省 +IUIZYQ7E2SPMEAIGOUUNZWBQOA 天水市 甘肃省 +BYBRGRDRV4NKAWCU6GWBIVLX3Q 酒泉市 甘肃省 +KDST2VRETG6WK5SMJO2G2FN2RU 嘉峪关市 甘肃省 +R3Q2XWVFVF4T2ADZZZMPZJRWBA 庆阳市 甘肃省 +7FWNT2TP66SU4QEP6IBBMZWFNE 陇南市 甘肃省 +T33S2GYGPVHAL5SR2FLSPTJSBE 平凉市 甘肃省 +RR6KAWBLOKD4H2UINKYPFCPXO4 甘南藏族自治州 甘肃省 +QKX4DS3CTJJG7SFW5SBHPLD42I 茂名市 广东省 +JJZ75A32XCQNZU4IN2ZCEUGN3M 梅州市 广东省 +SJSOXOSJASLUT6LBH4E32SUKKQ 清远市 广东省 +SQQWAN5BQOVSX55S7EPF7QHMAU 珠海市 广东省 +NGRJMW6JMRS6U2KUJEONSORAEY 韶关市 广东省 +FAUMIGOSOET4E5WR5BL6P3OZHA 佛山市 广东省 +JSBIH55ICFZQ2D3LEV47YMZ2NI 河源市 广东省 +KOYAYPD2DBLDCF2ZI5GCW4LF6Y 中山市 广东省 +647JGFPUYM4VWVLZCPSHT63XKQ 汕头市 广东省 +AMOIPZW3Q2NMTDSEREFVM4SV74 深圳市 广东省 +XIHEJY4H2CDZJCLIXDIN36BKXQ 广州市 广东省 +UZ6OT4CYUR42KCTTED2KJW6EGA 东莞市 广东省 +RM3HLOIUEYKTQ5O2JSVEKDMFRY 阳江市 广东省 +C6XNJAZA6N3NNUDBUU3JIZPTDI 潮州市 广东省 +AUPF3G2ULSV4TDT4L3NMHRTY6Y 揭阳市 广东省 +7HIITKBPRXTVBA2FBBN443XITQ 云浮市 广东省 +TO6ILZ7MPJMJN3S7W2SXMIFQQY 江门市 广东省 +DTTBMGCIOMPCZY5NETUEKWJ6PY 汕尾市 广东省 +HJY7JYWBA6FQY42RYSKX3RZTRI 湛江市 广东省 +SLICHB4FBDVDLI53MR74WVUUNI 肇庆市 广东省 +ZPX4JXJVBBYSSD2KTWHAPXO6NE 惠州市 广东省 +JH4Q44RQA4EZ3Q6MHQVEVE7KZQ 百色市 广西壮族自治区 +H2JXFEJFIL4PPFMYOS4MHZ5IBM 崇左市 广西壮族自治区 +SXIRRISUOEGBU335AWT2ZFL6A4 贵港市 广西壮族自治区 +HEQHKC4KP7YGGYVBZM5JEUI5AQ 北海市 广西壮族自治区 +SKGG7KMFKVDIDKRVQEPTS7SIE4 贺州市 广西壮族自治区 +N4WR7CWCULNA5Z35OTDJSZYDCU 钦州市 广西壮族自治区 +T4RXX2WY6WPQYZEUXVJNZBXQZU 梧州市 广西壮族自治区 +3R23AS3EIY7EYE2D5MWWORZODI 河池市 广西壮族自治区 +57SMWWCV7X44E256P4I23OQ3AA 防城港市 广西壮族自治区 +YHGHVIQ37UCTNQ4JKPQEAUWIQA 桂林市 广西壮族自治区 +MQJZTM455OKZLAN5WQYUTA5TDE 柳州市 广西壮族自治区 +C6FZPLB4NJQ6VUPSKDJH3EWQDM 玉林市 广西壮族自治区 +BFSU5W6E5XBIDFPQLVPGRDSATY 南宁市 广西壮族自治区 +CKXOQUZDNOVNME3PEBOY2CULQQ 来宾市 广西壮族自治区 +LV32FV6IQTKFR7JIBEQMHRUVCA 贵阳市 贵州省 +F26RCNKMFTZONJCSJ5C6FHVY74 毕节市 贵州省 +G2LMYRWVRCK7BTD2WM4NWX4SYM 黔南布依族苗族自治州 贵州省 +MFSOO3NBMB2PVLIVSI5EJK7MWY 黔西南布依族苗族自治州 贵州省 +KWUL44L7SEMJGIMXCWSSEB3OOA 遵义市 贵州省 +T2P3OFGQZUGR7D6TGRCMDF22GI 铜仁市 贵州省 +AGUFUANSZNGC4TMOPZO65IRSPI 六盘水市 贵州省 +2XOCOSNUAK3J5QDGTKIBWKK7KU 安顺市 贵州省 +6XRTSAEYJTA2UBKXO4XEPQE5ZY 黔东南苗族侗族自治州 贵州省 +YRMKRP2GOE2VMRS73N4YRIZUHY 三亚市 海南省 +CJRGVLBNLJAVBJ4ZKKIZ3FZ2LY 白沙黎族自治县 海南省 +5XOUAJ5Z4J4K7SVIQGXL2OM2JQ 保亭黎族苗族自治县 海南省 +2UFQ6A2QRJPXPH3VOYEAQHMVSQ 海口市 海南省 +TGCVXVS4M7NDQM4ROUDCVI6I3A 承德市 河北省 +JEUP6QWCOXPSM3SQTINQCJKIGM 衡水市 河北省 +Z442MNCW6BO2BBHIRUPRBACXPI 唐山市 河北省 +RFE6R34GD4FY3LUKC2ICSF6AFY 张家口市 河北省 +CWJN55M73VZDCYJEQ7AHDBWGGY 沧州市 河北省 +DFL4ES776ECRGBYNOPLWKB247I 雄安新区 河北省 +ZLSXYY34IHBHIC2NOVPQQBFTBE 保定市 河北省 +3DO6Z2QRJQFMPLLDS55PG7DSBU 石家庄市 河北省 +PR57XT25LI3246VGASEPSHP63E 邢台市 河北省 +SKYLNH737BS56TD452FOKYL36U 邯郸市 河北省 +5PWPERL7GQKJD6QPLR2TWUUM7E 秦皇岛市 河北省 +5T2TGV6SJFVL3MO7HIMN2KTQTA 廊坊市 河北省 +ECSTLZ7GP7IX3MB5EVNKS47MLE 焦作市 河南省 +VTWW34QB2F5Q4LW7ISNUMWX7GY 开封市 河南省 +D2NUN47NY4Q55X3UED4JMSI6CM 周口市 河南省 +TR3XJFQR4EFYRRIX7TUQF3B26Y 郑州市 河南省 +CKJGF5S6XMHW5ZJEBU7MJC47QA 新乡市 河南省 +IFASZ625MCFJQKPLJ7EA2SMJUU 商丘市 河南省 +SKXPYKTTRG4YAUHE2HZXWRWXGM 鹤壁市 河南省 +G5LXE74CUHO2K6BBRN7Q5DSRJY 漯河市 河南省 +SLNOAFJV2LTBSH7SJCRXJA36K4 驻马店市 河南省 +65WO7LH7CFDUGKYMXQLRAYKKWM 安阳市 河南省 +RIX2X7FAVTZCAQ5RT2C2CWK22Q 南阳市 河南省 +SZOW5OY3U54SSY4WRC65VJUNTI 平顶山市 河南省 +7VPIDDUS4P2LSZ6Q5S57MAQDEM 信阳市 河南省 +4VYCRORUOZ4DC2U6S3CT6H6KWE 洛阳市 河南省 +M5WNO2BQ3UGLLHBCG4NCEFPP5U 濮阳市 河南省 +LY3O6PBPIWETMA3ZOL6ETY5UB4 三门峡市 河南省 +FXXJLIRE72LS2W4OWWQVJMRJHA 许昌市 河南省 +5XH353QTY3VWF2KYOCZCL3TOXY 牡丹江市 黑龙江省 +2KGRZKF6IECV2W7K5J64Y2LY4M 齐齐哈尔市 黑龙江省 +FASGWS5ADVSTFGJG6TGBZPZP6Q 鹤岗市 黑龙江省 +2O6CDIXSWIKBXILZEEPKCS7MVI 双鸭山市 黑龙江省 +OZ2PTOBYTBG57XJZMIC23QFJKM 佳木斯市 黑龙江省 +FO24MQMULT3J5JW64APNXSQEPU 伊春市 黑龙江省 +ETZ2HYWVU6U7SKU6G4JAO64RUQ 黑河市 黑龙江省 +PR7EJNBY2VZBEUT36JAWE3TM7I 七台河市 黑龙江省 +HADAAVLERKIW4SQGCTQYGX4AL4 哈尔滨市 黑龙江省 +CGTU45YC5C3JYLHMA47USDPA7Y 大庆市 黑龙江省 +T4W7SQIPOM4EYMEFFRAB5BSTII 鸡西市 黑龙江省 +TYGZHNQL6YT7CX6EEG5DJQQHMA 绥化市 黑龙江省 +OOSJTSN2CVUUCKD6XAB7EYYIPY 大兴安岭地区 黑龙江省 +I3YF3EKZHIZTN6TZOTYTGZ2UXQ 随州市 湖北省 +ESGVBOSTHW7JWEVCGYJUTEHEBQ 宜昌市 湖北省 +MTJRWJ53XBW5SBTWHKNNZDLM7U 十堰市 湖北省 +PXZLF2ISKQL5ACM67ZCBNOGDT4 黄石市 湖北省 +44RMTOEHPUFXBHZXX4IQ4IRZVQ 荆州市 湖北省 +OTKZGG743NFC474ADMMRX4ZOOA 鄂州市 湖北省 +EXOUAZAQ73OEFAK72CHQ32GQHQ 恩施土家族苗族自治州 湖北省 +SUCY7I72QJDZD7EBFXREIQ67SI 咸宁市 湖北省 +QENSGB5R7HGYDXCG2LQZQTO3TU 荆门市 湖北省 +OHIWL6SAE2PR4EJR4BOMLAE6FU 武汉市 湖北省 +ZXCE4WV2CDVPQTA4HAOVELQMNE 襄阳市 湖北省 +KEFN5OPSS4ZZF6NU2TTL72S6HE 孝感市 湖北省 +ROAHLMQ67H6M5NDFVXROJG723E 黄冈市 湖北省 +YBEBX2YYN4WPBNH6Z6C73DNE7I 张家界市 湖南省 +45XGRKYGSCPE5VNRYF4FVJGFMM 株洲市 湖南省 +LK3SEIBRU7GTDT4J2EPTLIO33U 永州市 湖南省 +R4YXFIK53W5E556BSGSBJWS4DM 郴州市 湖南省 +PQDO3RNADWXX75OWZW2GSXJ4SE 怀化市 湖南省 +RRRT6QOJYEJ432L3F76ZN5NHCA 长沙市 湖南省 +B6WPNMCZ3ENQSV4NFY5MSTPDAM 岳阳市 湖南省 +KNDZW5EHDPKP2DX7HBLKP4DYLM 益阳市 湖南省 +PA2GHG3XZ7I47HTKZ4YAFH3OYY 湘西土家族苗族自治州 湖南省 +I7CNIUA5PYV2EHDEW3RGYT2R4U 邵阳市 湖南省 +SRI2SU4FN66FMJJCKQOCZD72ZY 常德市 湖南省 +H7UHHJAMQUL7UA5QEUTGKNSL3A 湘潭市 湖南省 +EFB255OBTB2BUDZENR5UVIC7ZQ 衡阳市 湖南省 +RDMANB4KCM3OJSNVGZWVYVME6E 娄底市 湖南省 +LD37PDU5OB4UAV5QDOBMKG5YTY 吉林市 吉林省 +EO3GF4XNF5RXWRPUVAT3KTQO4U 四平市 吉林省 +4GD7OS4CAQABH5YIWVK5SKGHMY 通化市 吉林省 +6DRI2R5VAWMYJHJPCJKUNMDYEQ 延边朝鲜族自治州 吉林省 +TVBCNVGUND4MOUOOUXFGX7DIUA 松原市 吉林省 +JYY62HSKBUVK5OU7KGJDKQ4RTA 白城市 吉林省 +QEDUHKMZ36CHJTKRD6O2ZPLNBU 长春市 吉林省 +4EADVCBJMZ5UBH2FVRT6QCLS2U 辽源市 吉林省 +EUQD5EGS2LR5KJSFNG6PPSIHHI 白山市 吉林省 +YLTIISPCLBEGTZZX3WUWAD7WDE 淮安市 江苏省 +L6U5DZP6MESXPMHOHCDMJS55O4 宿迁市 江苏省 +HQMLYA7TDGMYQAXFCDUXBZPYHI 镇江市 江苏省 +K6XJ4UN65ZD6XQKYEG5YN7HCRI 盐城市 江苏省 +36I4X3EZZU4EHOSCLQI5OAKKBE 南通市 江苏省 +S3GWFQU6QAVRDKLJT77LD6OFLE 泰州市 江苏省 +IO6F4AFGAVIFRGYTZEC4TXM7W4 无锡市 江苏省 +NUXNK2VOFSD2JFTEO2AMWX6NSU 扬州市 江苏省 +TEVZU6CU6SK57HFW7DFNGMQ44A 南京市 江苏省 +UTYSRBQ4FSB7XLWCF3Z2HTKNUA 常州市 江苏省 +OCZOBCJDEXKE7KBN3BD7AYQG2Q 徐州市 江苏省 +6LIBPJGZROLXE3CLZGJRYMYBOU 连云港市 江苏省 +FS4PIU74F7QKYARDWR5ZMOLICI 苏州市 江苏省 +R2F4OWUO65HYZW2IQIKINORZ7Y 赣州市 江西省 +YW346BTN3VFYNRC3744UR5MZXY 抚州市 江西省 +QR3FDR26U2EJIXOMBHL7IJLQSA 南昌市 江西省 +OAJHJL7L7VNW2Q5UXRE7F4CUJQ 九江市 江西省 +OMH7D45R4DX2KNHLV3G2UP56OY 景德镇市 江西省 +YSB2PAEROB2IZSJZFVFH7KJPEI 鹰潭市 江西省 +5OYAMNORCXKYA6UF7DW6KFFBIU 上饶市 江西省 +SMHZOYKE7BXQJ2NT6Q24TFMLEQ 吉安市 江西省 +2RZV26OUPKUHUJ5ZPB673VDGZU 萍乡市 江西省 +232VHZEEZ6SXACE4AC5HQ4ZTFQ 新余市 江西省 +QRLM74YXNDW2QDBWLTFGEMXK2I 宜春市 江西省 +S6OHUVUKIIWPVMQD44RREUMNT4 葫芦岛市 辽宁省 +DQQ4OIFUGFYJY3XZRK5VDWMLCA 辽阳市 辽宁省 +S4YXGFEYXEUG6ISZ6O337OPVSI 阜新市 辽宁省 +D3JHM7A4CG6RJMBD7YRDS5JOYU 盘锦市 辽宁省 +VTRWMOSS6PCUYUAIPG6VPBKUUQ 营口市 辽宁省 +ZPHFGWBIEVLKP5CVZNZUB3CRT4 朝阳市 辽宁省 +NGYYULZ4UAGD3Q2PG726FFXSHU 抚顺市 辽宁省 +Q5BRTSW752VSHIAKLLL7KL5TNA 锦州市 辽宁省 +ZGV3WNPOSS7J4ZWBP6ZQG46BNM 沈阳市 辽宁省 +XEX676YYMTYIV5QPIUZB4TA7IY 本溪市 辽宁省 +PRTEQZMLNLQNZXJHRCYYLWZB4E 丹东市 辽宁省 +4GN4WF6UQRFU64T4FVZPRDXRWQ 鞍山市 辽宁省 +3QTZDFLJFSLLOOVCZ65PSDAVOU 铁岭市 辽宁省 +CC4ZTMKKXI73ZEVT5QQTJN5SMM 大连市 辽宁省 +3MBJEFDLAVOMQZ7L7CM5MNSYKA 鄂尔多斯市 内蒙古自治区 +5NOS4YC5WO2IZCQPVB6MCBYDJ4 呼和浩特市 内蒙古自治区 +OQNIP675H7L5R64652BH7KHUOQ 通辽市 内蒙古自治区 +4WA6I63MGVINV5DNLNWRRHCDDM 阿拉善盟 内蒙古自治区 +ELI6BDJBAN6RCYTETMK2EX2UKU 乌兰察布市 内蒙古自治区 +PV5ZAAXFW2DZCVZKCF4I4KK7BQ 巴彦淖尔市 内蒙古自治区 +YU6UUT6G6T6AMWTJFECDIUQFEQ 乌海市 内蒙古自治区 +V4MYANW5QFZCXG3FIDPXA3HOTE 呼伦贝尔市 内蒙古自治区 +S5DCFJWJ7J3MJSLY2PHKWLNPOQ 包头市 内蒙古自治区 +LY7SAZRFSJJMRU3JEO5SKNKIVM 兴安盟 内蒙古自治区 +NM2XP54CNQCFOILKACYEQWUSGM 锡林郭勒盟 内蒙古自治区 +S5G3IO75IDEJPZQA6VFM3OYPDI 赤峰市 内蒙古自治区 +UUFUUPM5RT6ZU5UKILQC5YQV54 吴忠市 宁夏回族自治区 +VMSRLIATK44WQXQEWAL63AXJ3M 固原市 宁夏回族自治区 +4GWWCAAKGNJV2SMQPSWWZNCGYY 中卫市 宁夏回族自治区 +6IE7GEETBQEF7GUSGU2FLIUEEM 石嘴山市 宁夏回族自治区 +VI4YIH3URSON4Q4MWOEESXJ56Q 银川市 宁夏回族自治区 +SIE4ED6QWVRT727GEHWBFH3DAA 海东市 青海省 +JNJH6OJZIOQKXDXWW5ZGEHG5MA 海西蒙古族藏族自治州 青海省 +MJADYNCKQNDJU2TXACTDP5I52M 海北藏族自治州 青海省 +LRGFXIVB6RJWQWYAFH7EIUHCPE 黄南藏族自治州 青海省 +J4TG3PCK2ZEMNEUMIPZF32UNQY 果洛藏族自治州 青海省 +2YS5POGG53LKZGFBIUMDWP57SM 玉树藏族自治州 青海省 +GRZMJEZCA2DNCZK3O6ZSUHMRPM 西宁市 青海省 +NBFQIACRBBCAH5AZWJ5LVT7AU4 海南藏族自治州 青海省 +MQUKCLQ76P4FRRECDBA3HBKT7Q 滨州市 山东省 +633FVSBDDBM5WSMXSKOCX6QC5I 潍坊市 山东省 +4434FVT3PXLMV6UAWLEW6O3M5A 菏泽市 山东省 +P7PK4UBVCOHW3PI6IPEIA54DLY 济南市 山东省 +I5M6JGTGSQEWX6HL7E5I6GRBAY 德州市 山东省 +V562AOMBVU5NG5GB3EPK6U42XY 烟台市 山东省 +4OSPHTE5TD24J6DYGR6DXMEDKY 淄博市 山东省 +227TLAVTUJABWPJD4S4ZECJ3FY 临沂市 山东省 +DAEZKZU32ZAPJGUTA6LLGO3WTY 聊城市 山东省 +LBRRK2EOYJN5MLYJWT4R3QBSXM 东营市 山东省 +AB6PBGCDBTNTG4KUQROY2FJ4GY 枣庄市 山东省 +EVANGU7WZCVRAAM6NWTDJVP7SU 济宁市 山东省 +GNUEGWZ3OKRWAKKVJ5THHHX6YY 泰安市 山东省 +F3VWSF4ART2FYYBBOZYWKRXTUI 青岛市 山东省 +KD6MNWWLVKB4E655XMV6MMA3KE 日照市 山东省 +LHYVF4LBCOZ34G3WNZYUVEIQGA 威海市 山东省 +ENVYDMYGDO3BMDXSAVQYZXLX74 阳泉市 山西省 +UXOUG4UIF7ZRJYCNMQJ3LDN5FY 临汾市 山西省 +4NZPT6Z35BMYACJ2HZGHUWRJ6E 吕梁市 山西省 +KSNXQME2A3VFHCE3DM3SFZKIJQ 晋城市 山西省 +HDOX7WKYSHJKEHET6TUYMVCTMQ 太原市 山西省 +DFJIZVXJGBGBIABPSL3DGMIDIE 长治市 山西省 +5KQYYTJR2EMP653QIALMA6LXXI 忻州市 山西省 +T76EOJA332RIHML7B6LYS5LF4U 朔州市 山西省 +HVX67CKT5TS6GPRDFDYOOLK4PE 大同市 山西省 +S4NGXQJDOH7E4IHDWOH3EK6IIE 晋中市 山西省 +GWDLZXLAWU54FKQ6G3HRQRR7E4 运城市 山西省 +3FFTTN5PPV7MBCE5AGY2NGYOOI 安康市 陕西省 +GACVPL3SWO3ZKH73JMJV6YI4NY 延安市 陕西省 +6KPS7VRMW57P2DAC6OPR4ISHQQ 商洛市 陕西省 +OMMF6XLNDNYWG5TBSNTWO2ZJZ4 渭南市 陕西省 +EALFXGMWYRS6E6TWXQ2K3YHV4M 咸阳市 陕西省 +WFG7U6JNUWDIS5ZZYM4FSM5C64 榆林市 陕西省 +R3VBMYTCF5LVHO35X3MYJQFOOE 宝鸡市 陕西省 +RQOWP7C234IS4RKSHB26IYZ5IU 西安市 陕西省 +K4YU6B4T5GZLRWVHGVCR3576HI 铜川市 陕西省 +VVFVAPLKSCN5KN4Q6RK2GPGUUA 汉中市 陕西省 +2QSF6IG3KMDXWO5VP7FXHMMKXA 上海市 上海市 +X3JCRNIPTCUU6DGOFFJ4MUK37M 眉山市 四川省 +GQ24IZNTZJ3PUB5FDAMDA4W7UI 攀枝花市 四川省 +6B6WT62WHBZRHPQUT7BAD2N6ZI 泸州市 四川省 +HJ35P4KXL442MLIII7PFFWUNAE 雅安市 四川省 +K4A6VSJH2AJYT46LMUSVQZPCCU 资阳市 四川省 +646ZNPATOOM3MHI3LDU6HI4KFI 阿坝藏族羌族自治州 四川省 +MU735ZDBFPXRQDUZ3I35JK3XEU 内江市 四川省 +4WPGGJ63USY77GSRN2PPFCYKPQ 广安市 四川省 +O4FFS4DALDAAKIFAUH4F5V5VS4 宜宾市 四川省 +IRFJVK2KXBE6BZ7CSN4UFCI624 绵阳市 四川省 +NELFD7FEKKUNDJ46VLD55SMDCE 甘孜藏族自治州 四川省 +TKMVEUPZSQCXNRZPBEIK3F45AI 遂宁市 四川省 +VQW7DPB4KTUI65COJBO3NODU24 巴中市 四川省 +STP4ELXTVGQSB572LFRFJRIUUY 南充市 四川省 +6ST5EX2JVXUCLR5GP5VEFSKN5M 成都市 四川省 +UWNFCMW3HYJRALQI2MJH6EM2O4 德阳市 四川省 +J5ZYU7XRV6CHSJAGOPQKS5YXNA 达州市 四川省 +KYJTF5S746T35RFBMR65BLGM6U 凉山彝族自治州 四川省 +RDUXR23XROLB4NGRVDDLXFXDSE 乐山市 四川省 +4TUBIBHMVESJUCGMTUSLJPHXWI 广元市 四川省 +AXQL57AO27NCHYMEOLRHAAKMTA 自贡市 四川省 +4RXX566RZORCXS6HLEX3DIICSM 花莲县 台湾 +BD5Y7SISWSSQGP3HTVPU6TXAH4 台东县 台湾 +I4DNWLECRYOJAZLGYQB7PBBJXQ 台中市 台湾 +GIZQIESFOMAEQSDQKOEQ5RTTPA 南投县 台湾 +MPM6M2C634FAW7KYG3KIHERDTU 彰化县 台湾 +NH2NK6JVOBBYYTK2G53ADWLX4Y 苗栗县 台湾 +DW2Q2R2UEEDNQA7IHBGYV423K4 新竹市 台湾 +FX5AOPFRPHGHHYZB4XNIPXLNNM 新北市 台湾 +UVNZNB6G4M35RV3IGRUN6OXMXE 屏东县 台湾 +MPB3M2YK24ZORO3EDCJ6UDWIGQ 基隆市 台湾 +EBHVITJPDHJEEMMZTM4TD4UVRU 台北市 台湾 +FQS4PZNCTQEX6I5F34Z2AGJUZM 高雄市 台湾 +WVOJ636Q7MGT6RMN6QYQ4SZWIE 嘉义市 台湾 +QRLER4EEMMKYGQLER2RWEQA74E 台南市 台湾 +K2LHF64R2P4OJ7MDHJ6J2NSTPE 桃园市 台湾 +BILG6LJIWUCTXZ6CDPXYAVM6XI 澎湖县 台湾 +3FYRA3O2HUMLIQAAJQCPX2TETE 宜兰县 台湾 +4MW6X22PAPVMHB6SBGF3RYS324 天津市 天津市 +YEYPP4SQOBXU5UCNDN7ORSR6DI 拉萨市 西藏自治区 +UNE6UPENGWQDOWGABDJEAQ2FEY 山南市 西藏自治区 +VDXKN2YCIUPOHBZKKQHPN6HJWE 林芝市 西藏自治区 +EAWIMNI77H72EYSOAYV3M76CB4 阿里地区 西藏自治区 +HCF6UHTXOOKIOA43AIJH3ARKXA 昌都市 西藏自治区 +Y47QI3KJY352QV3VOPXHM2IDWU 日喀则市 西藏自治区 +R4UWJX44GVAA54NFKHT4Y4ZC5A 那曲市 西藏自治区 +2D37GB5XUALJDXONWJIGXV3QXU 香港 香港特别行政区 +PA5W7Z255K3EGYA4LTA7BGLHRA 巴音郭楞蒙古自治州 新疆维吾尔自治区 +RJZOCU5ECQCOLCOCHJH2UNLQJM 哈密市 新疆维吾尔自治区 +RYK6AR3VDQJFXZLX3MHYFV5VAI 塔城地区 新疆维吾尔自治区 +MXUVGU5NPVTNINAKKPNLWUQ54Q 博尔塔拉蒙古自治州 新疆维吾尔自治区 +T5RKMSH5VIGRTHDZOKV2EIKPIM 伊犁哈萨克自治州 新疆维吾尔自治区 +NABMKFZPOUCZMS4TUVJSZ24DNA 克孜勒苏柯尔克孜自治州 新疆维吾尔自治区 +2YQBXWNFYVJX4NU6WXB5II6X34 昌吉回族自治州 新疆维吾尔自治区 +DBKCQCCQU2URQG2EP5ZNPKBETY 乌鲁木齐市 新疆维吾尔自治区 +DON6KYBCR2XJQOJQGOZTYV4RMM 阿克苏地区 新疆维吾尔自治区 +WKG47NEVYII5JISZJN7QSI2BDQ 克拉玛依市 新疆维吾尔自治区 +DWLK6D3OUOXOVQHSEJVTRIDRAI 喀什地区 新疆维吾尔自治区 +SAPKF2PQGJD4UMVJZTC3IZKI64 阿勒泰地区 新疆维吾尔自治区 +ARWSLGG54LGUGN3XMIWW76NW34 吐鲁番市 新疆维吾尔自治区 +VQCWAKL6ADHYFTSVPBGPDFSB2I 北屯市 新疆维吾尔自治区 +ES5A6MOROAG6F2XAQ25TYYPWUE 铁门关市 新疆维吾尔自治区 +36IUY52AESIPF4QEQAR2RTNQYA 和田地区 新疆维吾尔自治区 +Z26KSUL6ULS65ITZNUCRRWBYJM 文山壮族苗族自治州 云南省 +XBBUUATPBD2IUJR47FCKVUXB2I 昭通市 云南省 +TK2LP3JCYYMPTV4OA3WJCH7UQ4 怒江傈僳族自治州 云南省 +ISJ4FESOYKCQ5LXOQO6NL7TQHA 曲靖市 云南省 +ELBUBVI5UMUIEQGIOAHPMESXFA 西双版纳傣族自治州 云南省 +QHOYHEGZM4WSJZPFEU6FCBYIWY 玉溪市 云南省 +4G4SPJ7MVHMYZAWMQ4642PMLVI 保山市 云南省 +HCHRV2LGJ2TJ4X6BWNWI2IMID4 普洱市 云南省 +IS4Q6NASBWHO3RFO7UCIWOXVI4 昆明市 云南省 +TDJZOAZFQUQPYRR5BHOJTG6RWM 红河哈尼族彝族自治州 云南省 +EIYC62RNU4SHQW3RLAMDPTQYTI 大理白族自治州 云南省 +BA3XFPITAYKBUWDKU3QONRHBC4 德宏傣族景颇族自治州 云南省 +6P6FFFO6C5MLNCVRJAJUICSVQI 临沧市 云南省 +OXHMWH2TSIDI7BQ43EHAMXJ6N4 丽江市 云南省 +QQPDT4LBI2K2KMBNXZ6YH7X2FI 楚雄彝族自治州 云南省 +XBG4EJAWCRJL2TDPNJ23PTFYHQ 迪庆藏族自治州 云南省 +DINNCH54AP74TJ62MICEYAZP74 宁波市 浙江省 +XYTSLYGB2ETU6HG7GIXA7X5SOE 嘉兴市 浙江省 +NNAALJZXGAWALR3LGE2V4UZT6U 丽水市 浙江省 +H5UOJ5MQYJ737GS3TXYN2OJHUU 杭州市 浙江省 +HG5VQGOMSCEGNXJXKO6XCNCHMY 湖州市 浙江省 +LJ2SWEPRINTYDH5A2QHRMI5US4 衢州市 浙江省 +TW4RRM62TDA7WWU77FDSLSAXGY 台州市 浙江省 +HBBN247QZ6YUW5ZYSS6D7RVJCA 绍兴市 浙江省 +GVFB23SJZGRPRXXTIKDCAOCDPI 金华市 浙江省 +UEW4ENX7N7IFGFM7FD5SZ5GI7Y 舟山市 浙江省 +HCCXS5DGRJQMYZMRLGVAMUIQEA 温州市 浙江省 +FDGY55I6IHKY76E3MWDBOT2R6Y 重庆市 重庆市 \ No newline at end of file diff --git a/app/utils/geo.py b/app/utils/geo.py new file mode 100644 index 0000000..03e4577 --- /dev/null +++ b/app/utils/geo.py @@ -0,0 +1,65 @@ +"""通过经纬度反查城市(reverse_geocoder 离线库,零网络调用)。 + +reverse_geocoder 内置 ~2.5M 条全球城市/聚居点的经纬度→地名映射表, +构建一次 KDTree(~几十MB 内存)后,查询为纯内存搜索,不作任何外部网络调用。 + +⚠️ 必须持有单例、且用 mode=1(单进程): + - reverse_geocoder 的模块级 rg.search()/rg.get() **每次调用都会 new 一个 RGeocoder**, + 即每次都重新解析 ~2.5M 行 CSV + 重建 KDTree(数秒/次)。绝不能在服务端按请求调用。 + - 默认 mode=2 用 multiprocessing 按 CPU 数 spawn 子进程做并行查询;在服务端 / Windows + spawn 下会重复 import 主模块(无 __main__ guard 时直接报错),既慢又危险。 + 故本模块持有一个 mode=1 的 RGeocoder 单例,建一次树、复用;查询走单进程内存搜索。 + +⚠️ 精度说明:gazetteer 里的中国数据粒度不一致——直辖市/省会通常直接命中城市名, +但部分城市会命中到区/街道级(如天津→Erwangzhuang、西安→Zhangjiabao), +此时 admin1(省级行政区)可作为回退。业务侧建议优先用 admin1 做城市级判定。 +""" +from __future__ import annotations + +from typing import Any + +import reverse_geocoder as rg # type: ignore[import-untyped] + +# mode=1 单进程 KDTree 的单例;None 表示尚未构建(见 ensure_loaded)。 +_geocoder: rg.RGeocoder | None = None + + +def ensure_loaded() -> None: + """构建(或复用)RGeocoder 单例(幂等)。 + + 首次调用解析 ~2.5M 行 CSV + 构建 KDTree(~秒级、数十 MB)。生产应在 main.py 的 + lifespan 启动阶段主动调用一次,把这份一次性成本摊到启动,避免砸在第一个 + /feed?tab=rec / /top-sales 请求上。mode=1 = 单进程,不 spawn 子进程。 + """ + global _geocoder + if _geocoder is None: + _geocoder = rg.RGeocoder(mode=1, verbose=False) + + +def get_city(latitude: float, longitude: float) -> dict[str, str]: + """根据经纬度反查最近聚居点。 + + 返回 dict: + - name: 最近聚居点名称(英文),如 "Beijing" / "Fengsheng"; + 中国境内可能是区/街道级;海洋/无人区返 "" + - admin1: 省级行政区(英文),如 "Beijing" / "Hubei" / "Chongqing Shi"; + 直辖市 admin1 即为城市名 + - country: ISO 3166-1 alpha-2,如 "CN" + - latitude: 匹配到的参考点纬度(字符串) + - longitude: 匹配到的参考点经度(字符串) + + 未匹配到(海洋/远洋)时返回空字符串字段。 + """ + ensure_loaded() + assert _geocoder is not None # ensure_loaded 保证已构建 + results: list[dict[str, Any]] = _geocoder.query([(latitude, longitude)]) + if not results: + return {"name": "", "admin1": "", "country": "", "latitude": "", "longitude": ""} + r = results[0] + return { + "name": str(r.get("name", "")), + "admin1": str(r.get("admin1", "")), + "country": str(r.get("cc", "")), + "latitude": str(r.get("lat", "")), + "longitude": str(r.get("lon", "")), + } diff --git a/app/utils/meituan_city.py b/app/utils/meituan_city.py new file mode 100644 index 0000000..22d0e0f --- /dev/null +++ b/app/utils/meituan_city.py @@ -0,0 +1,448 @@ +"""美团城市词典 + reverse_geocoder 离线反查。 + +从 feed 入参的 latitude/longitude 计算出美团城市 ID, +用于后续美团 CPS 接口的 cityId 参数。 + +⚠️ 跨系统耦合:本模块返回的 city_id 取自 data/city_dict.txt,而离线库 +`meituan_coupon.city_id` 由 ETL(另一套系统)灌入。二者必须用同一份城市 ID 口径, +否则 `WHERE city_id == <本模块结果>` 会静默查到 0 行 → 接口永久降级返空。 +改动 city_dict.txt 或 ETL 的城市 ID 来源时,务必同步两侧。 +""" +from __future__ import annotations + +import logging +import re +from functools import lru_cache +from pathlib import Path + +from app.utils.geo import get_city as _get_geo_city + +logger = logging.getLogger("shagua.meituan_city") + +# city_dict.txt 作为运行时数据随包分发(见 pyproject [tool.setuptools.package-data]) +_CITY_DICT_PATH = Path(__file__).resolve().parent / "data" / "city_dict.txt" + +# ─────────── 反向地理编码 admin1 → 中文省份名 ─────────── +# reverse_geocoder 的 admin1 格式不统一: +# 直辖市: "Beijing" / "Shanghai Shi" / "Tianjin Shi" / "Chongqing Shi" +# 省份: "Guangdong" / "Jiangsu Sheng" / "Hubei" ... +# 自治区: "Xinjiang Uygur Zizhiqu" / "Tibet Autonomous Region" ... +# 下面用前缀匹配,去掉了 Sheng/Shi/Zizhiqu/Autonomous Region 等后缀。 + +_PROVINCE_EN_PREFIX: list[tuple[str, str]] = [ + # 直辖市 — admin1 即城市名 + ("Beijing", "北京市"), + ("Shanghai", "上海市"), + ("Tianjin", "天津市"), + ("Chongqing", "重庆市"), + # 省 + ("Hebei", "河北省"), + ("Shanxi", "山西省"), # 注意: 指山西省,不是陕西 + ("Liaoning", "辽宁省"), + ("Jilin", "吉林省"), + ("Heilongjiang", "黑龙江省"), + ("Jiangsu", "江苏省"), + ("Zhejiang", "浙江省"), + ("Anhui", "安徽省"), + ("Fujian", "福建省"), + ("Jiangxi", "江西省"), + ("Shandong", "山东省"), + ("Henan", "河南省"), + ("Hubei", "湖北省"), + ("Hunan", "湖南省"), + ("Guangdong", "广东省"), + ("Hainan", "海南省"), + ("Sichuan", "四川省"), + ("Guizhou", "贵州省"), + ("Yunnan", "云南省"), + ("Shaanxi", "陕西省"), # 双写 a 是官方拼音 + ("Gansu", "甘肃省"), + ("Qinghai", "青海省"), + # 自治区 — 注意匹配顺序, Xinjiang 要在 Guangxi 前面(Guangxi 也是 Xi 开头但先匹配 Xin 不会误判) + ("Guangxi", "广西壮族自治区"), + ("Inner Mongolia", "内蒙古自治区"), + ("Nei Mongol", "内蒙古自治区"), + ("Tibet", "西藏自治区"), + ("Xizang", "西藏自治区"), + ("Ningxia", "宁夏回族自治区"), + ("Xinjiang", "新疆维吾尔自治区"), + # 特别行政区 + ("Hong Kong", "香港特别行政区"), + ("Macau", "澳门特别行政区"), + ("Macao", "澳门特别行政区"), + # 台湾(city_dict 里省份名为 "台湾",没有省/自治区后缀) + ("Taiwan", "台湾"), +] + +# ─────────── 常见城市名 英文→中文 映射 ─────────── +# 覆盖所有直辖市 + 省会 + 一线城市 + 部分 reverse_geocoder 只能命中到区/县的城市。 +# key 全小写,匹配时做小写比较。 +_CITY_EN_TO_CN: dict[str, str] = { + # 直辖市 + "beijing": "北京市", + "shanghai": "上海市", + "tianjin": "天津市", + "chongqing": "重庆市", + # 省会 / 副省级 + "guangzhou": "广州市", + "shenzhen": "深圳市", + "chengdu": "成都市", + "hangzhou": "杭州市", + "wuhan": "武汉市", + "xi'an": "西安市", + "nanjing": "南京市", + "changsha": "长沙市", + "zhengzhou": "郑州市", + "jinan": "济南市", + "kunming": "昆明市", + "fuzhou": "福州市", + "harbin": "哈尔滨市", + "lanzhou": "兰州市", + "guiyang": "贵阳市", + "nanning": "南宁市", + "shijiazhuang": "石家庄市", + "taiyuan": "太原市", + "shenyang": "沈阳市", + "changchun": "长春市", + "hefei": "合肥市", + "nanchang": "南昌市", + "haikou": "海口市", + "hohhot": "呼和浩特市", + "huhehaote": "呼和浩特市", + "urumqi": "乌鲁木齐市", + "wulumuqi": "乌鲁木齐市", + "lhasa": "拉萨市", + "yinchuan": "银川市", + "xining": "西宁市", + # 其他常见城市 + "xiamen": "厦门市", + "suzhou": "苏州市", + "qingdao": "青岛市", + "dalian": "大连市", + "ningbo": "宁波市", + "wuxi": "无锡市", + "foshan": "佛山市", + "dongguan": "东莞市", + "zhuhai": "珠海市", + "zhongshan": "中山市", + "wenzhou": "温州市", + "shaoxing": "绍兴市", + "jiaxing": "嘉兴市", + "jinhua": "金华市", + "taizhou": "台州市", + "yangzhou": "扬州市", + "nantong": "南通市", + "changzhou": "常州市", + "xuzhou": "徐州市", + "zhengjiang": "镇江市", + "yantai": "烟台市", + "weifang": "潍坊市", + "zibo": "淄博市", + "linyi": "临沂市", + "weihai": "威海市", + "rizhao": "日照市", + "luoyang": "洛阳市", + "kaifeng": "开封市", + "xinxiang": "新乡市", + "nanyang": "南阳市", + "yichang": "宜昌市", + "xiangyang": "襄阳市", + "huangshi": "黄石市", + "zhuzhou": "株洲市", + "xiangtan": "湘潭市", + "yueyang": "岳阳市", + "hengyang": "衡阳市", + "mianyang": "绵阳市", + "luzhou": "泸州市", + "yibin": "宜宾市", + "nanchong": "南充市", + "zigong": "自贡市", + "qujing": "曲靖市", + "yuxi": "玉溪市", + "zunyi": "遵义市", + "guilin": "桂林市", + "liuzhou": "柳州市", + "sanya": "三亚市", + "tangshan": "唐山市", + "baoding": "保定市", + "handan": "邯郸市", + "qinhuangdao": "秦皇岛市", + "langfang": "廊坊市", + "datong": "大同市", + "changzhi": "长治市", + "linfen": "临汾市", + "baotou": "包头市", + "ordos": "鄂尔多斯市", + "eerduosi": "鄂尔多斯市", + "daqing": "大庆市", + "qiqihar": "齐齐哈尔市", + "jilin_city": "吉林市", + "anshan": "鞍山市", + "fushun": "抚顺市", + "benxi": "本溪市", + "jinzhou": "锦州市", + "yingkou": "营口市", + "dandong": "丹东市", + "huizhou": "惠州市", + "jiangmen": "江门市", + "zhanjiang": "湛江市", + "maoming": "茂名市", + "zhaoqing": "肇庆市", + "chaozhou": "潮州市", + "shantou": "汕头市", + "shaoguan": "韶关市", + "meizhou": "梅州市", + "jieyang": "揭阳市", + "qingyuan": "清远市", + "heyuan": "河源市", + "yangjiang": "阳江市", + "shanwei": "汕尾市", + "yunfu": "云浮市", +} + + +# ─────────── 省会映射(城市匹配失败时回退) ─────────── +# city_dict.txt 内省份的第一个城市不一定是省会,故显式维护。 +_PROVINCE_CAPITAL: dict[str, str] = { + "安徽省": "合肥市", + "澳门特别行政区": "澳门", + "北京市": "北京市", + "福建省": "福州市", + "甘肃省": "兰州市", + "广东省": "广州市", + "广西壮族自治区": "南宁市", + "贵州省": "贵阳市", + "海南省": "海口市", + "河北省": "石家庄市", + "河南省": "郑州市", + "黑龙江省": "哈尔滨市", + "湖北省": "武汉市", + "湖南省": "长沙市", + "吉林省": "长春市", + "江苏省": "南京市", + "江西省": "南昌市", + "辽宁省": "沈阳市", + "内蒙古自治区": "呼和浩特市", + "宁夏回族自治区": "银川市", + "青海省": "西宁市", + "山东省": "济南市", + "山西省": "太原市", + "陕西省": "西安市", + "上海市": "上海市", + "四川省": "成都市", + "台湾": "台北市", + "天津市": "天津市", + "西藏自治区": "拉萨市", + "香港特别行政区": "香港", + "新疆维吾尔自治区": "乌鲁木齐市", + "云南省": "昆明市", + "浙江省": "杭州市", + "重庆市": "重庆市", +} + + +# ─────────── 城市字典加载 ─────────── + +def _parse_city_dict(path: str | Path) -> list[dict[str, str]]: + """解析 city_dict.txt,返回 [{city_id, city_name, province_name}, ...]。 + + city_dict.txt 格式(TSV): + 城市ID\t城市名称\t省份名称 + + 示例行: + 3NUYJKKJXPHVNZUHFK3HWUDHNM\t宣城市\t安徽省 + """ + data: list[dict[str, str]] = [] + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + parts = line.split("\t") + if len(parts) < 3: + continue + city_id, city_name, province_name = parts[0], parts[1], parts[2] + if city_id == "城市ID": + continue # 跳过表头 + if city_id and city_name and province_name: + data.append({ + "city_id": city_id, + "city_name": city_name, + "province_name": province_name, + }) + return data + + +# 模块加载时一次解析 +try: + _CITY_DICT: list[dict[str, str]] = _parse_city_dict(_CITY_DICT_PATH) +except Exception: + logger.exception("加载 city_dict.txt 失败,美团城市反查将不可用") + _CITY_DICT = [] + + +def _build_province_index() -> dict[str, list[dict[str, str]]]: + """构建 省份名 → 该省全部城市列表 的索引。""" + idx: dict[str, list[dict[str, str]]] = {} + for entry in _CITY_DICT: + idx.setdefault(entry["province_name"], []).append(entry) + return idx + + +_PROVINCE_INDEX: dict[str, list[dict[str, str]]] | None = None + + +def _get_province_index() -> dict[str, list[dict[str, str]]]: + global _PROVINCE_INDEX + if _PROVINCE_INDEX is None: + _PROVINCE_INDEX = _build_province_index() + return _PROVINCE_INDEX + + +# ─────────── 查询 ─────────── + +def _map_admin1_to_cn_province(admin1: str) -> str: + """将 reverse_geocoder 的 admin1 映射到 city_dict 中的中文省份名。""" + if not admin1: + return "" + normalized = admin1.strip() + # 多级匹配:先精确、再前缀 + for en_prefix, cn_name in _PROVINCE_EN_PREFIX: + if normalized == en_prefix or normalized.startswith(en_prefix): + return cn_name + return "" + + +def _lookup_city_in_province(city_en_lower: str, province_cn: str) -> str: + """在指定省份内查找匹配的城市名(EN→CN 映射)。""" + if not province_cn: + return "" + index = _get_province_index() + candidates = index.get(province_cn, []) + if not candidates: + return "" + + # 1) 精确映射 + if city_en_lower in _CITY_EN_TO_CN: + cn_city = _CITY_EN_TO_CN[city_en_lower] + for c in candidates: + if c["city_name"] == cn_city: + return cn_city + + # 2) 前缀/包含匹配(处理 admin1 直辖市场景:行政区 → 直辖市本身) + for c in candidates: + # 去掉"市"后缀比较 + city_core = c["city_name"].rstrip("市") + if city_en_lower.startswith(city_core.lower()) or city_core.lower().startswith(city_en_lower): + return c["city_name"] + # city_en_lower 可能是拼音,city_core 是中文,尝试从 EN→CN 映射反向匹配 + for en_k, cn_v in _CITY_EN_TO_CN.items(): + if cn_v == c["city_name"] and (city_en_lower in en_k or en_k in city_en_lower): + return cn_v + + # 3) 匹配不到 → 返回省会 + capital = _PROVINCE_CAPITAL.get(province_cn, "") + if capital: + for c in candidates: + if c["city_name"] == capital: + return capital + return candidates[0]["city_name"] # 终极兜底 + + +def _sanitize_city_name(name: str) -> str: + """去除 reverse_geocoder name 中常见的行政后缀使匹配更鲁棒。""" + # 去掉 " District" / " Qu" / " Shi" 等英文后缀 + for suffix in ("District", "Qu", "Shi", "Sheng", "Xian", "Cun", "Zhen", "Xiang", + "Zizhiqu", "Autonomous Region", "Special Administrative Region"): + name = re.sub(rf"\s+{suffix}$", "", name, flags=re.IGNORECASE) + return name.strip() + + +@lru_cache(maxsize=512) +def _resolve_meituan_city(latitude: float, longitude: float) -> dict[str, str]: + """反查实现;入参已量化(见 get_meituan_city),故 lru_cache 命中率高。 + + 返回的 dict 被缓存复用 —— 调用方勿原地修改(get_meituan_city 已返回副本)。 + """ + if not _CITY_DICT: + return {"city_id": "", "city_name": "", "province_name": ""} + + logger.debug("resolve_meituan_city: lat=%.2f lon=%.2f", latitude, longitude) + geo = _get_geo_city(latitude, longitude) + name_en = _sanitize_city_name(geo.get("name", "")) + admin1 = geo.get("admin1", "") + country = geo.get("country", "") + + if country != "CN": + logger.debug("resolve_meituan_city: 坐标(%.2f,%.2f)不在中国境内(country=%s)", latitude, longitude, country) + return {"city_id": "", "city_name": "", "province_name": ""} + + # 1) 映射省份 + province_cn = _map_admin1_to_cn_province(admin1) + if not province_cn: + logger.warning("get_meituan_city: admin1=%r 无法映射到中文省份", admin1) + return {"city_id": "", "city_name": "", "province_name": ""} + + # 2) 查找城市 + name_lower = name_en.lower() + city_cn = _lookup_city_in_province(name_lower, province_cn) + + # 3) 按省份+城市匹配 city_dict 中的城市 ID + index = _get_province_index() + candidates = index.get(province_cn, []) + for c in candidates: + if city_cn and c["city_name"] == city_cn: + return { + "city_id": c["city_id"], + "city_name": c["city_name"], + "province_name": province_cn, + } + + # 4) 最终回退:返回该省省会 + if candidates: + capital = _PROVINCE_CAPITAL.get(province_cn, "") + if capital: + for c in candidates: + if c["city_name"] == capital: + logger.info("get_meituan_city: 城市匹配失败 name_en=%r, 回退到省会 %s", name_en, capital) + return { + "city_id": c["city_id"], + "city_name": capital, + "province_name": province_cn, + } + # 终极兜底:第一个城市 + fallback = candidates[0] + logger.info("get_meituan_city: 城市匹配失败 name_en=%r, 回退到 %s", name_en, fallback["city_name"]) + return { + "city_id": fallback["city_id"], + "city_name": fallback["city_name"], + "province_name": province_cn, + } + + return {"city_id": "", "city_name": "", "province_name": ""} + + +def get_meituan_city(latitude: float, longitude: float) -> dict[str, str]: + """根据经纬度反查美团城市 ID + 城市名 + 省份名(对外入口)。 + + 返回: + - city_id: 美团城市 ID(如 3NUYJKKJXPHVNZUHFK3HWUDHNM); + 匹配失败时返回 "" + - city_name: 中文城市名(如 "北京市") + - province_name: 中文省份名(如 "北京市") + + 原理: + 1. reverse_geocoder 根据经纬度查出英文地名 + 省份 + 2. 英文省份→中文省份映射(前缀匹配) + 3. 英文地名→中文城市名映射(精确映射 + 省内候选回退) + 4. 在 city_dict.txt 中按省份+城市名匹配城市 ID + + 城市名匹配失败的策略: + - 直辖市(京沪津渝): admin1 本身即城市名,直接取 + - 省会: 回退到该省第一个城市(city_dict.txt 中每个省的省会通常排第一位) + + 实现说明:先把坐标量化到 ~1km(round 到 2 位小数)再进 lru_cache —— 原始 GPS 坐标 + 每次抖动到小数点后 5~6 位,直接做缓存 key 几乎不命中;城市级解析对 1km 误差不敏感, + 量化后"同一地点反复请求"可命中缓存。返回缓存 dict 的副本,调用方可安全读写。 + """ + return dict(_resolve_meituan_city(round(latitude, 2), round(longitude, 2))) diff --git a/deploy/daily-exchange.service b/deploy/daily-exchange.service index aef841a..2a6d949 100644 --- a/deploy/daily-exchange.service +++ b/deploy/daily-exchange.service @@ -19,6 +19,9 @@ Type=oneshot User=root WorkingDirectory=/opt/shaguabijia-app-server Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin" +# 写死北京时:兑换的"当天/0 点"一律按北京时,不随服务器 OS 时区漂(用户 2026-07-01 硬约束)。 +# 脚本内的日期判断本就走 rewards.cn_today()(CN_TZ=+8),这里再把进程 TZ 也钉成北京,双保险。 +Environment="TZ=Asia/Shanghai" EnvironmentFile=/opt/shaguabijia-app-server/.env ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.daily_auto_exchange --once SyslogIdentifier=daily-exchange diff --git a/deploy/daily-exchange.timer b/deploy/daily-exchange.timer index d00520b..c6ea4a9 100644 --- a/deploy/daily-exchange.timer +++ b/deploy/daily-exchange.timer @@ -4,8 +4,10 @@ Description=Run daily auto-exchange coins->cash at midnight [Timer] -# 每天 0 点跑。客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆。 -OnCalendar=*-*-* 00:00:00 +# 每天【北京时】0 点跑,写死时区(用户 2026-07-01 硬约束:兑换一律北京 0 点,不随服务器本地时区漂)。 +# systemd OnCalendar 支持尾缀时区;不写时区会按服务器 OS 本地时区触发 → 服务器非 CST 时会在错误时刻兑。 +# 客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆。 +OnCalendar=*-*-* 00:00:00 Asia/Shanghai # 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日)。 Persistent=true AccuracySec=1min diff --git a/docs/api/compare/compare-record-report.md b/docs/api/compare/compare-record-report.md index cdd4523..1e7f7fa 100644 --- a/docs/api/compare/compare-record-report.md +++ b/docs/api/compare/compare-record-report.md @@ -2,10 +2,10 @@ > 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) -比价 `done` 帧后,客户端用**带 JWT 的通道**上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。 +比价 `done` 帧后上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。 -> ⚠️ 与不鉴权的透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳,本接口按用户维度落库,**必须鉴权**。 -> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../guides/待办与技术债.md) P1)。 +> ⚠️ **灰度定位(2026-07)**:写 `comparison_record` 现以透传壳 `compare.py` 的**后端 harvest** 为主(帧0 建 `running` 行 → done/finalize 落终态,新客户端不再 POST)。本接口降级为**老客户端兼容**通道,与 harvest 按 `trace_id` reconcile(**success 不被降级**);新版覆盖够高后可下线本 POST。 +> 与软鉴权透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳(顺带 harvest 落库),本接口按用户维度显式上报,**必须鉴权**。 ## 入参(JSON body) @@ -13,7 +13,7 @@ | 字段 | 类型 | 必填 | 默认 | 说明 | |---|---|---|---|---| -| `trace_id` | string | ✅ | — | pricebot 侧 trace_id。**幂等键**:同用户同 trace_id 重复上报覆盖、返回同一 id | +| `trace_id` | string | ✅ | — | 一次比价的唯一标识(app-server 帧0 签发)。**幂等键(trace_id 单列唯一)**:同 trace_id 重复上报覆盖、返回同一 id;与 harvest 行按它 reconcile | | `business_type` | string | ❌ | `food` | `food`(外卖,当前唯一接通) / `ecom`(电商) / `coupon`(领券) | | `device_id` | string \| null | ❌ | null | 设备号 | | `store_name` | string \| null | ❌ | null | 店铺名(外卖,来自 calibration.result) | diff --git a/docs/database/comparison_record.md b/docs/database/comparison_record.md index 4b637ce..225f29e 100644 --- a/docs/database/comparison_record.md +++ b/docs/database/comparison_record.md @@ -2,13 +2,13 @@ > 模型 `app/models/comparison.py` · 仓库 `app/repositories/comparison.py` · 接口 [compare-record-report](../api/compare-record-report.md) / [compare-records](../api/compare-records.md) / [compare-record-detail](../api/compare-record-detail.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -每完成一次比价(外卖/电商/领券),客户端在 done 帧后用**带 JWT** 的通道上报一条。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数),还被「上报更低价」反查原最低价。 +每完成一次比价(外卖/电商/领券)记一行。**写入以 app-server 后端 harvest 为主**(2026-07 起):比价透传壳 `compare.py` 在帧0(pricebot 出 trace_id)即建 `running` 行,随 done / `trace/finalize` 逐步补全成终态,客户端不再主动 POST 记录;老客户端仍可走**带 JWT** 的 `POST /compare/record` 兜底(灰度期两条写路径按 `trace_id` reconcile)。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数),还被「上报更低价」反查原最低价。 > 与 `savings_record` 的区别:本表是「每一次**比价行为**的完整明细」(不省钱、甚至失败也记);`savings_record` 是「真正**下单成交**省了多少」。两表独立、互不喂数据。 > 与 [`price_observation`](./price_observation.md) / `store_mapping` 的区别:本表是**用户视角**(登录后按 `user_id` 存「我的比价记录」);后两张是 server 侧无条件沉淀的**平台/门店视角客观事实**(价格事实 / 跨平台店铺身份映射),与本表 `trace_id` 同源但不互相 join,各存各的视角。 ## 用在哪 / 增删改查 -- **C / U(upsert,幂等)**:`POST /compare/record`(`upsert_record`)。按 `(user_id, trace_id)` 查:不存在→新建;已存在→整行覆盖(客户端重试/重复上报时,**更完整的那次胜出**)。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 由 `_derive` 从 `comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。 +- **C / U(harvest 为主,按 `trace_id` 幂等)**:透传壳 `compare.py` 三段式落库(`app/repositories/comparison.py`)——`harvest_running`(帧0 建 `running` 行)→ `harvest_done`(done 帧转 `success`/`failed` + 派生 `best_*`/`saved_amount_cents` + 返 `newly_success` 供幂等发邀请奖)→ `harvest_abort`(`trace/finalize` 转 `cancelled`/`failed`,**不降级已 success**)。老客户端仍可 `POST /compare/record`(`upsert_record`,按 `trace_id` 查、整行覆盖)兜底。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 一律由 `_derive` 从 `comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。 - **D**:无(关联的 `price_report` 也只把 `comparison_record_id` 置空,不删本表)。 - **R**:`GET /compare/records`(列表,`created_at` 倒序游标 + 「已下单」标记)、`GET /compare/records/{id}`(详情,限本人);`count_success` 给里程碑;`get_stats`(`status='success'` 计数 + `saved_amount_cents` 求和)给 [`GET /compare/stats`](../api/compare-stats.md) 喂「我的」页省钱战绩卡(完成比价 + 累计发现可省,**比价口径**);`report.py` 反查 `best_*`;admin 大盘/明细。 @@ -16,10 +16,10 @@ | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | 被 `price_report.comparison_record_id` 引用 | -| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | +| `user_id` | Integer | FK→user.id, index, **nullable**(2026-07 从 NOT NULL 放开) | 归属用户。后端 harvest 帧0 建行时(软鉴权 / 老客户端匿名)可能暂缺 → 可空;C 端「我的比价记录」按 `user_id` 过滤天然排除 null 行,admin 全看(含孤儿行) | | `device_id` | String(64) | nullable | 设备号(多设备区分 / 与不鉴权期对账) | | `business_type` | String(16) | NOT NULL, default `food`, index | 取值:`food`(当前唯一接通)/ `ecom` / `coupon` | -| `trace_id` | String(64) | NOT NULL | pricebot 侧 trace_id(关联调试落盘 + 幂等键) | +| `trace_id` | String(64) | NOT NULL, **UNIQUE** | 一次比价的唯一标识。**由 app-server 帧0 用 uuid 签发**(注入转发 body + 回填响应顶层给客户端;老客户端自带),全局唯一 = harvest upsert 键 + 关联 pricebot 调试落盘 | | `trace_url` | String(512) | nullable | 本次比价公网调试链接(`price.shaguabijia.com/traces/{dir}/`);dir 名含 pricebot 落盘时分秒,前端/server 拼不出必须存。查看接口按 `user.debug_trace_enabled`(或本机 agent 调试 `include_trace`)决定返不返回。旧记录 / 未开上云为 null | | `source_platform_id` / `_name` | String(32) | nullable | 源平台代号 / 中文名 | | `source_package` | String(128) | nullable | 源平台 Android 包名 | @@ -31,7 +31,7 @@ | `is_source_best` | Boolean | nullable | 源平台就是最便宜(= 这次没省到) | | `store_name` | String(128) | nullable | 店铺名。**与 `savings_record.shop_name` 按字符串相等关联**,给本记录打「已下单」 | | `total_dish_count` / `skipped_dish_count` | Integer | nullable | 菜品总数 / 目标平台没找到被跳过数 | -| `status` | String(16) | NOT NULL, default `success` | 取值:`success`(有非源且有价的目标结果)/ `failed`(出错/没采到目标价)。**里程碑只数 success** | +| `status` | String(16) | NOT NULL, default `success` | 取值:`running`(harvest 帧0 建行、比价进行中)/ `success`(有非源且有价的目标结果)/ `failed`(出错/没采到目标价)/ `cancelled`(用户终止 / Phase1 未识别,`harvest_abort` 写,**不降级已 success**)。**里程碑只数 success** | | `information` | String(256) | nullable | done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) | | `items` | JSON(PG: JSONB) | NOT NULL, default [] | 下单菜品 `[{name, qty, specs?}]` | | `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved(元),coupon_name,applied_coupons}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用),`applied_coupons`=`[{name,amount}]` 多券明细 | @@ -50,7 +50,8 @@ - 被 `comparison_milestone_claim` 间接依赖:解锁进度 = 本表 `status='success'` 计数。 ## 索引与约束 -- PK `id`;index `user_id`、`business_type`、`created_at`;复合 index `ix_comparison_status_created`(`status`, `created_at`)(按 `status='success'` 过滤 + 近期排序的聚合/轮播,避免随数据量退化为全表扫);UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(幂等覆盖)。 +- PK `id`;index `user_id`、`business_type`、`created_at`;复合 index `ix_comparison_status_created`(`status`, `created_at`)(按 `status='success'` 过滤 + 近期排序的聚合/轮播,避免随数据量退化为全表扫);UNIQUE(`trace_id`) = `uq_comparison_trace`(harvest 按它 upsert,一次比价一行)。 + > 2026-07 迁移 `comparison_record_trace_unique`:唯一键从复合 `uq_comparison_user_trace`(`user_id`,`trace_id`)改为 `trace_id` 单列——harvest 帧0 建行时 user_id 可能暂缺,不能再用复合键去重。上线前须确认历史无重复 `trace_id`(`SELECT trace_id,COUNT(*) c FROM comparison_record GROUP BY trace_id HAVING c>1`),否则建单列唯一会失败。 ## 注意 - 4 个 JSON 列用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。结构化金额列存「分」,`comparison_results.price`/`coupon_saved` 原样存「元」。 diff --git a/docs/integrations/sms.md b/docs/integrations/sms.md index 7086643..c8f9bdd 100644 --- a/docs/integrations/sms.md +++ b/docs/integrations/sms.md @@ -16,9 +16,9 @@ ## 函数 / 异常 | 函数 | 行为 | |---|---| -| `send_code(phone) -> int` | 防刷检查(冷却 + 每日上限)→ `secrets` 生成 N 位码 → **预占**(冷却/计数/存码)→ mock 打日志 / real 调极光 → 失败**回滚预占**。返回距下次可发秒数 | +| `send_code(phone) -> int` | 防刷检查(冷却)→ `secrets` 生成 N 位码 → **预占**(冷却/存码)→ mock 打日志 / real 调极光 → 失败**回滚预占**。返回距下次可发秒数 | | `verify_code(phone, code) -> bool` | mock 放行任意 6 位;real 比对存码,匹配即作废,失败累计到上限作废 | -| `SmsError(msg, status_code)` | `status_code` 决定 HTTP 码:过频/每日超限 **429**、供应商失败 **503**、号码无效 **400** | +| `SmsError(msg, status_code)` | `status_code` 决定 HTTP 码:过频 **429**、供应商失败 **503**、号码无效 **400** | ## 配置 | 配置项 | 默认 | 说明 | @@ -30,17 +30,16 @@ | `SMS_SIGN_ID` | 31729 | 极光签名 ID | | `SMS_TEMPLATE_ID` | 1 | 极光模板 ID(变量名 `code`) | | `SMS_CODE_LENGTH` | 6 | 验证码位数(本服务生成;前端 code 4-8 位兼容) | -| `SMS_DAILY_LIMIT_PER_PHONE` | 10 | 单号每日发送上限(防刷 + 控费) | | `SMS_MAX_VERIFY_ATTEMPTS` | 5 | 单码最多校验失败次数,超过作废(防爆破) | > **鉴权复用极光一键登录**:`/v1/messages` 用 `base64(JG_APP_KEY:JG_MASTER_SECRET)` 做 HTTP Basic Auth——短信与一键登录是**同一个极光应用**(同 AppKey)。**上线不需要额外凭证,只需 `SMS_MOCK=false`**(`JG_*` 一键登录已配)。 ## 防刷(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权) -1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却 -2. 单号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限 -3. 单 IP 频控:`/sms/send` 挂 `rate_limit(10,60)`、`/sms/login` 挂 `rate_limit(20,60)` -4. 单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废 -5. 运维侧建议在极光控制台叠加:**IP 白名单**(只许服务器 IP)+ **防轰炸设置** +> 2026-07-03 精简:登录风控只留「单号冷却 + 单设备频控」两道主策略(删单号每日上限、删登录纯 IP `rate_limit`);单码失败上限属验证码安全底线,保留。 +1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(单号维度) +2. 单设备(`device_id` + IP)每小时频控:`/sms/send` ≤ `SMS_SEND_MAX_PER_HOUR_PER_DEVICE`(5)、`/sms/login` ≤ `SMS_LOGIN_MAX_PER_HOUR`(5)——堵「换号绕开单号冷却」+ 挡登录撞库,在 `app/api/v1/auth.py` 内 `enforce_rate_limit` +3. 单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废 + 验过即作废(一次性) +4. 运维侧建议在极光控制台叠加:**IP 白名单**(只许服务器 IP)+ **防轰炸设置** ## 极光错误码(节选,映射在 `_send_via_jiguang`) | code | 含义 | 处理 | @@ -56,4 +55,4 @@ 3. 真机发一条验证:收到短信 + 能登录 ## 已知局限 -**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 每日上限 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../guides/待办与技术债.md)。 +**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../guides/待办与技术债.md)。 diff --git a/docs/后端技术实现.md b/docs/后端技术实现.md index dab383f..6d39c3b 100644 --- a/docs/后端技术实现.md +++ b/docs/后端技术实现.md @@ -169,7 +169,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert 短信冷却表存进程内存(`--workers 1` 下够用,多 worker/重启即失效)。 -**real 模式(`SMS_MOCK=false`,生产)**:自定义验证码——本服务 `secrets` 生成 6 位码 → 极光 `/v1/messages` 只负责发 → 本地校验(一次性 / 防爆破),鉴权**复用 `JG_APP_KEY`/`JG_MASTER_SECRET`**(短信与一键登录同一极光应用,**上线只需 `SMS_MOCK=false`**)。防刷四层(单号冷却 + 单号每日上限 + 单 IP `rate_limit` + 单码失败次数)+ 错误码 429/503/400。详见 [integrations/sms](./integrations/sms.md)。 +**real 模式(`SMS_MOCK=false`,生产)**:自定义验证码——本服务 `secrets` 生成 6 位码 → 极光 `/v1/messages` 只负责发 → 本地校验(一次性 / 防爆破),鉴权**复用 `JG_APP_KEY`/`JG_MASTER_SECRET`**(短信与一键登录同一极光应用,**上线只需 `SMS_MOCK=false`**)。防刷(单号冷却 + 单设备频控 + 单码失败次数;2026-07-03 精简删单号每日上限与登录纯 IP 限流)+ 错误码 429/503/400。详见 [integrations/sms](./integrations/sms.md)。 ### 4.3 Token 与刷新 diff --git a/pyproject.toml b/pyproject.toml index aa4ba63..1c4c03a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,9 @@ dependencies = [ # 邀请指纹归因:解析浏览器 UA 拿手机型号(Build.MODEL),跨端匹配用 "user-agents>=2.2.0", + + # 离线经纬度→城市反查(美团 CPS 按城市过滤);会带入 numpy/scipy 依赖 + "reverse_geocoder>=1.5.1", ] [project.optional-dependencies] @@ -50,6 +53,10 @@ dev = [ [tool.setuptools.packages.find] include = ["app*"] +# 随包分发的运行时数据文件(美团城市词典;被 app/utils/meituan_city.py 加载) +[tool.setuptools.package-data] +"app.utils" = ["data/*.txt"] + [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] diff --git a/scripts/load_meituan_coupon_tsv.py b/scripts/load_meituan_coupon_tsv.py new file mode 100644 index 0000000..70e624c --- /dev/null +++ b/scripts/load_meituan_coupon_tsv.py @@ -0,0 +1,170 @@ +"""把 meituan_coupon 的线上采样 TSV 灌进本地 SQLite。 + +用途:本地开发/调试时,把线上 `meituan_coupon` 表的采样数据(tests/meituan_coupon_data.tsv) +灌进 dev 库(默认 `./data/app.db`),免得每次都实时打美团接口。 + +TSV 说明: + - 制表符分隔,每行一条记录,列顺序与线上 PostgreSQL 物理列一致 + (image_size / image_type 是后加的迁移,排在最后两列 —— 与本地 SQLite 一致)。 + - 空字段 = NULL(文件里没有 `\\N` 标记)。 + - 个别记录的文本/JSON 字段内含换行,会把一条逻辑行拆成多物理行 —— 按“累计到 26 列”重组。 + - 文件尾部可能有一条被导出截断的残行(列数不足 / raw JSON 不完整),直接跳过。 + +datetime 三列(first_seen/last_seen/updated_at)去掉尾部时区偏移(`+08`), +存成 SQLAlchemy 在 SQLite 上用的朴素格式 `YYYY-MM-DD HH:MM:SS.ffffff`,保证 ORM 能读回。 + +用法: + python scripts/load_meituan_coupon_tsv.py # 默认 TSV + .env 里的库 + python scripts/load_meituan_coupon_tsv.py path/to.tsv # 指定 TSV + DATABASE_URL=sqlite:///./data/app.db python scripts/load_meituan_coupon_tsv.py +""" +from __future__ import annotations + +import json +import os +import re +import sqlite3 +import sys +from pathlib import Path + +_PROJECT_ROOT = Path(__file__).resolve().parent.parent + +# 列顺序 = TSV 字段顺序 = 本地 SQLite 物理列顺序 +COLS = [ + "id", "source", "platform", "biz_line", "city_id", "product_view_sign", + "sku_view_id", "name", "brand_name", "sell_price_cents", "original_price_cents", + "head_url", "sale_volume", "sale_volume_num", "commission_percent", + "commission_amount_cents", "poi_name", "available_poi_num", "delivery_distance_m", + "dedup_key", "raw", "first_seen", "last_seen", "updated_at", "image_size", "image_type", +] +NCOL = len(COLS) + +# 按列做类型转换(空串 -> None)。未列出的列 = 原样字符串(source/city_id/... 等 NOT NULL 文本)。 +_INT_COLS = {0, 2, 3, 9, 10, 13, 15, 17, 24} # id, platform, biz_line, prices, ... +_FLOAT_COLS = {14, 18} # commission_percent, delivery_distance_m +_NULLABLE_STR_COLS = {6, 7, 8, 11, 12, 16, 25} # sku_view_id, name, brand_name, ... +_DT_COLS = {21, 22, 23} # first_seen, last_seen, updated_at +_RAW_COL = 20 + +_TZ_SUFFIX = re.compile(r"[+-]\d{2}(:?\d{2})?$") # 尾部时区偏移 +08 / +08:00 / +0800 + + +def _resolve_sqlite_path() -> Path: + """从 DATABASE_URL(env 或 .env)解析出 SQLite 文件路径。只支持 sqlite://。""" + url = os.environ.get("DATABASE_URL", "") + if not url: + env = _PROJECT_ROOT / ".env" + if env.exists(): + for line in env.read_text(encoding="utf-8").splitlines(): + if line.strip().startswith("DATABASE_URL="): + url = line.split("=", 1)[1].strip() + break + if not url: + url = "sqlite:///./data/app.db" + if not url.startswith("sqlite:"): + sys.exit(f"仅支持 sqlite:// 库,当前 DATABASE_URL={url!r}") + rest = url.split("sqlite:///", 1)[1] if "sqlite:///" in url else url.split("sqlite://", 1)[1] + p = Path(rest) + if not p.is_absolute(): + p = (_PROJECT_ROOT / rest).resolve() + return p + + +def _reconstruct_rows(text: str) -> tuple[list[list[str]], int]: + """把文件文本重组成一条条 26 列的逻辑行。返回 (rows, skipped)。 + + 单个字段内含换行 -> 一条逻辑行被拆成多物理行:累计字段,拆点用 \\n 重新拼回, + 直到凑满 26 列。列数溢出(内嵌 TAB / 错位)或文件尾残行 -> 跳过并计数。 + """ + lines = text.split("\n") + while lines and lines[-1] == "": + lines.pop() + rows: list[list[str]] = [] + skipped = 0 + buf: list[str] = [] + for raw_line in lines: + parts = raw_line.split("\t") + if not buf: + buf = parts + else: + buf[-1] += "\n" + parts[0] # 拼回被换行拆开的字段 + buf.extend(parts[1:]) + if len(buf) == NCOL: + rows.append(buf) + buf = [] + elif len(buf) > NCOL: # 溢出:数据异常,丢弃这段重新开始 + print(f" [skip] 列数溢出({len(buf)}>{NCOL}),field0={buf[0][:20]!r}") + skipped += 1 + buf = [] + if buf: # 文件尾被截断的残行 + print(f" [skip] 尾部残行不足 {NCOL} 列(实 {len(buf)} 列),field0={buf[0][:20]!r}") + skipped += 1 + return rows, skipped + + +def _convert(row: list[str]) -> tuple | None: + """按列类型转换一行;非法(必填 int 为空 / raw 非 JSON)返回 None。""" + out: list = [] + for i, v in enumerate(row): + if i in _DT_COLS: + out.append(_TZ_SUFFIX.sub("", v)) + continue + if i == _RAW_COL: + try: + json.loads(v) + except Exception as e: + print(f" [skip] id={row[0]} raw 非法 JSON: {e}") + return None + out.append(v) + continue + if i in _INT_COLS: + out.append(int(v) if v != "" else None) + elif i in _FLOAT_COLS: + out.append(float(v) if v != "" else None) + elif i in _NULLABLE_STR_COLS: + out.append(v if v != "" else None) + else: # 必填文本列,原样 + out.append(v) + return tuple(out) + + +def main() -> None: + tsv = Path(sys.argv[1]) if len(sys.argv) > 1 else _PROJECT_ROOT / "tests" / "meituan_coupon_data.tsv" + if not tsv.is_absolute(): + tsv = (_PROJECT_ROOT / tsv).resolve() + db = _resolve_sqlite_path() + print(f"TSV: {tsv}") + print(f"DB : {db}") + if not tsv.exists(): + sys.exit(f"TSV 不存在: {tsv}") + if not db.exists(): + sys.exit(f"SQLite 库不存在: {db}(先跑 alembic upgrade head 建表)") + + rows, skipped = _reconstruct_rows(tsv.read_text(encoding="utf-8")) + print(f"重组逻辑行: {len(rows)} 跳过(残/异常): {skipped}") + + records = [] + bad = 0 + for r in rows: + rec = _convert(r) + if rec is None: + bad += 1 + continue + records.append(rec) + print(f"可入库: {len(records)} 转换失败: {bad}") + + placeholders = ",".join(["?"] * NCOL) + sql = f"INSERT OR REPLACE INTO meituan_coupon ({','.join(COLS)}) VALUES ({placeholders})" + con = sqlite3.connect(str(db)) + try: + before = con.execute("SELECT count(*) FROM meituan_coupon").fetchone()[0] + con.executemany(sql, records) + con.commit() + after = con.execute("SELECT count(*) FROM meituan_coupon").fetchone()[0] + finally: + con.close() + print(f"入库前 {before} 行 -> 入库后 {after} 行(本次 {len(records)} 条)") + + +if __name__ == "__main__": + main() diff --git a/scripts/mock_compare_records.py b/scripts/mock_compare_records.py new file mode 100644 index 0000000..cb8fe61 --- /dev/null +++ b/scripts/mock_compare_records.py @@ -0,0 +1,189 @@ +"""给指定用户灌一批 mock 比价记录(开发 / 真机走查用)。 + +为什么:首页「上次比价」横幅(4 分钟新鲜窗口)、比价记录页、「我的」省钱战绩卡都读 +`comparison_record`。dev 库里这个用户没数据时这些 UI 是空的,本脚本灌一批真实感的外卖 +比价记录,方便端上走查。 + +用法(项目根、已 pip install -e . 的环境): + python scripts/mock_compare_records.py # 默认 user 昵称后缀 DlSKoc5S7, 10 条 + python scripts/mock_compare_records.py --user DlSKoc5S7 # 按 昵称后缀/用户名/邀请码/手机号 + python scripts/mock_compare_records.py --user 5 # 直接按 user.id + python scripts/mock_compare_records.py --user 5 --count 6 + +要点: + - 幂等:trace_id 固定为 mock-<标识>-NN,重跑覆盖同号记录(不会越灌越多)。 + - 第 1 条 created_at = 跑脚本当下(4 分钟内),其余铺在近 7 天;重跑会刷新时间戳 + (所以重跑一次即可让「上次比价」横幅重新进入 4 分钟窗口)。 + - best_* / saved / status 由真实的 repositories.comparison.upsert_record 从 comparison_results 派生。 +""" +from __future__ import annotations + +import argparse +from datetime import datetime, timedelta + +from sqlalchemy import or_, select + +from app.core.rewards import CN_TZ +from app.db.session import SessionLocal +from app.models.user import User +from app.repositories.comparison import upsert_record +from app.schemas.compare_record import ComparisonRecordIn + +# 平台名 → id(展示用;横幅按 platform_name 渲染,id 仅留痕) +_PLATFORM_ID = {"美团": "meituan", "京东": "jd", "淘宝闪购": "taobao", "饿了么": "ele"} + +# 10 单真实感外卖订单:source=美团,targets=(平台, 到手价/元, 优惠来源名|None, 红包额/元)。 +# failed=True 表示目标平台没采到价(targets 价置 None → 派生 status=failed)。 +_CATALOG = [ + dict(store="瑞幸咖啡(国贸店)", items=[("生椰拿铁(大杯)", 2)], + source=("美团", 39.8), + targets=[("淘宝闪购", 29.9, "平台红包", 6.0), ("京东", 33.0, None, 0)]), + dict(store="茶百道(大悦城店)", items=[("杨梅冰萃(大杯)", 1), ("脆波波奶茶", 1)], + source=("美团", 31.0), + targets=[("京东", 24.5, "百亿补贴", 4.5), ("饿了么", 27.9, None, 0)]), + dict(store="麦当劳(西单店)", items=[("板烧鸡腿堡套餐", 1)], + source=("美团", 32.0), + targets=[("京东", 25.9, "神券", 3.0), ("淘宝闪购", 28.0, None, 0)]), + dict(store="肯德基(朝阳大悦城店)", items=[("疯狂星期四全家桶", 1)], + source=("美团", 89.0), + targets=[("京东", 79.9, "百亿补贴", 9.0)]), + dict(store="张亮麻辣烫(双井店)", items=[("自选麻辣烫", 1)], + source=("美团", 35.0), + targets=[("京东", None, None, 0), ("淘宝闪购", None, None, 0)], + failed=True, fail_reason="京东外卖、淘宝闪购均未找到该店"), + dict(store="必胜客(王府井店)", items=[("超级至尊比萨(9寸)", 1), ("香辣鸡翅", 1)], + source=("美团", 78.0), + targets=[("淘宝闪购", 62.0, "跨店满减", 10.0), ("京东", 69.0, None, 0)]), + dict(store="蜜雪冰城(中关村店)", items=[("多肉葡萄", 2), ("冰鲜柠檬水", 1)], + source=("美团", 21.0), + targets=[("京东", 16.5, None, 0)]), + dict(store="海底捞外送(三里屯店)", items=[("番茄锅底", 1), ("鲜毛肚", 2), ("虾滑", 1)], + source=("美团", 168.0), + targets=[("京东", 155.0, "大额神券", 13.0), ("饿了么", 162.0, None, 0)]), + dict(store="华莱士(回龙观店)", items=[("全鸡汉堡套餐", 1)], + source=("美团", 26.0), + targets=[("京东", None, None, 0)], + failed=True, fail_reason="该商品在京东外卖未上架"), + dict(store="星巴克(国贸店)", items=[("燕麦拿铁(大杯)", 2)], + source=("美团", 66.0), + targets=[("饿了么", 58.0, "会员券", 8.0), ("京东", 61.0, None, 0)]), +] + +# 第 2..N 条的 created_at 相对当下的回退量(都 > 4 分钟,确保只有第 1 条落在新鲜窗口)。 +_OFFSETS = [ + timedelta(minutes=25), timedelta(hours=2, minutes=10), timedelta(hours=6), + timedelta(days=1, hours=3), timedelta(days=1, hours=20), timedelta(days=2, hours=9), + timedelta(days=3, hours=14), timedelta(days=5, hours=7), timedelta(days=7, hours=2), +] + + +def _resolve_user(db, ident: str) -> User | None: + """按 id(纯数字)/ 用户名 / 邀请码 / 手机号 / 昵称(或昵称去掉「用户」前缀的后缀)解析用户。""" + if ident.isdigit(): + u = db.get(User, int(ident)) + if u is not None: + return u + return db.execute( + select(User).where( + or_( + User.username == ident, + User.invite_code == ident, + User.phone == ident, + User.nickname == ident, + User.nickname == f"用户{ident}", + User.nickname.like(f"%{ident}"), + ) + ) + ).scalars().first() + + +def _build_results(order: dict) -> tuple[list[dict], str]: + """造 comparison_results + information 文案;返回 (results, information)。""" + src_name, src_price = order["source"] + failed = order.get("failed", False) + rows: list[dict] = [dict( + platform_id=_PLATFORM_ID.get(src_name), platform_name=src_name, + price=src_price, is_source=True, status="success", + )] + for pname, price, cname, csaved in order["targets"]: + row = dict( + platform_id=_PLATFORM_ID.get(pname), platform_name=pname, + price=(None if failed else price), is_source=False, + status=("store_not_found" if failed else "success"), + ) + if not failed and csaved: + row["coupon_saved"] = csaved + row["coupon_name"] = cname + rows.append(row) + + # rank:有价的按升序 1..N;无价的不排 + priced = sorted((r for r in rows if r["price"] is not None), key=lambda r: r["price"]) + for i, r in enumerate(priced, start=1): + r["rank"] = i + + if failed: + return rows, order.get("fail_reason", "目标平台未找到该商品") + best = priced[0] + saved = src_price - best["price"] + if best["is_source"]: + info = f"{src_name}已是最低价,本单未比出更低" + else: + info = f"已在{best['platform_name']}比出最低价 ¥{best['price']:.2f}(比{src_name}省 ¥{saved:.2f})" + return rows, info + + +def main() -> None: + parser = argparse.ArgumentParser(description="给用户灌一批 mock 比价记录") + parser.add_argument("--user", default="DlSKoc5S7", help="user.id / 用户名 / 邀请码 / 手机号 / 昵称后缀") + parser.add_argument("--count", type=int, default=10, help="条数(1..10),默认 10") + args = parser.parse_args() + + count = max(1, min(args.count, len(_CATALOG))) + now = datetime.now(CN_TZ).replace(tzinfo=None) + + db = SessionLocal() + try: + user = _resolve_user(db, args.user) + if user is None: + raise SystemExit(f"❌ 找不到用户:{args.user}(试试 --user )") + 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() diff --git a/tests/test_admin_config.py b/tests/test_admin_config.py index 13c0e29..cfdf29a 100644 --- a/tests/test_admin_config.py +++ b/tests/test_admin_config.py @@ -68,7 +68,14 @@ def test_list_config(admin_client: TestClient, token: str) -> None: r = admin_client.get("/admin/api/config", headers=_auth(token)) assert r.status_code == 200, r.text items = {i["key"]: i for i in r.json()} - assert "signin_rewards" in items and "ad_daily_limit" in items + # 非 hidden 项照常返回;看广告组保留可见的:每日上限 / 单次金币上限 / 关闭后冷却。 + assert "signin_rewards" in items and "ad_daily_limit" in items and "ad_cooldown_sec" in items + # hidden 项(任务/里程碑、首页轮播数据源、看广告组的单次金币/每轮次数/信息流广告开关)不在配置页返回。 + for hidden_key in ( + "task_rewards", "record_milestones", "marquee_feed_mode", + "ad_reward_coin", "ad_round_count", "comparing_ad_enabled", + ): + assert hidden_key not in items, f"{hidden_key} 应被 hidden 过滤" assert items["signin_rewards"]["value"] == [ 200, 200, 300, 200, 400, 400, 800, ] diff --git a/tests/test_admin_roles.py b/tests/test_admin_roles.py new file mode 100644 index 0000000..ce3f4ca --- /dev/null +++ b/tests/test_admin_roles.py @@ -0,0 +1,152 @@ +"""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 diff --git a/tests/test_auth.py b/tests/test_auth.py index 4968a73..810c25a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -17,7 +17,6 @@ def _reset(phone: str) -> None: """清该号的进程内存状态,隔离 real 模式用例。""" sms._codes.pop(phone, None) sms._last_sent.pop(phone, None) - sms._daily_count.pop(phone, None) class _OkResp: @@ -76,7 +75,7 @@ def test_sms_send_too_frequent(client) -> None: def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None: """发码防刷:同一设备(device_id) + 同一 IP 每小时最多 N 次发码,超出 429。 - 用不同手机号(绕开单号 60s 冷却)证明限流按设备封顶 —— 堵「换号绕开单号冷却/每日上限」的洞。 + 用不同手机号(绕开单号 60s 冷却)证明限流按设备封顶 —— 堵「换号绕开单号冷却」的洞。 conftest 默认关限流;本用例临时打开 + 调小阈值便于测 + 清计数隔离。""" from app.api.v1 import auth from app.core import ratelimit @@ -155,21 +154,6 @@ def test_phone_format_validation(client) -> None: assert r.status_code == 422 -def test_sms_daily_limit(monkeypatch) -> None: - """单号每日上限(send 的防刷逻辑 mock/real 都跑;函数级绕开 60s 冷却)。""" - phone = "13511135000" - _reset(phone) - limit = 3 - monkeypatch.setattr(sms.settings, "SMS_DAILY_LIMIT_PER_PHONE", limit) - - for _ in range(limit): - sms.send_code(phone) - sms._last_sent.pop(phone, None) # 绕开冷却,只测每日上限 - - with pytest.raises(sms.SmsError, match="上限"): - sms.send_code(phone) - - # ============================ real 路径(不真发)============================ def test_sms_real_send_calls_jiguang(monkeypatch) -> None: @@ -255,24 +239,20 @@ def test_sms_real_balance_error_keeps_cooldown(monkeypatch) -> None: def test_sms_gc_purges_stale_only(monkeypatch) -> None: - """GC 清过期码 / 旧冷却 / 隔日计数,但不动今天有效的(阈值设 0 强制每次扫)。""" + """GC 清过期码 / 旧冷却,但不动今天有效的(阈值设 0 强制每次扫)。""" monkeypatch.setattr(sms, "_GC_THRESHOLD", 0) sms._codes.clear() sms._last_sent.clear() - sms._daily_count.clear() now = time.time() sms._codes["stale"] = sms._CodeRecord(code="111111", expires_at=now - 1) sms._codes["fresh"] = sms._CodeRecord(code="222222", expires_at=now + 999) sms._last_sent["old"] = now - 99999 sms._last_sent["recent"] = now - sms._daily_count["yesterday"] = ("2000-01-01", 3) - sms._daily_count["today"] = (sms._today(), 1) sms._gc(now) assert "stale" not in sms._codes and "fresh" in sms._codes assert "old" not in sms._last_sent and "recent" in sms._last_sent - assert "yesterday" not in sms._daily_count and "today" in sms._daily_count # ============================ 用户名 / 默认昵称 ============================ diff --git a/tests/test_compare_harvest.py b/tests/test_compare_harvest.py new file mode 100644 index 0000000..b13028e --- /dev/null +++ b/tests/test_compare_harvest.py @@ -0,0 +1,258 @@ +"""后端 harvest 落库测试(比价记录改后端 harvest 后新增)。 + +覆盖: + repo 层:harvest_running(建行/幂等回填)、harvest_done(派生+newly_success 幂等)、 + harvest_abort(夭折 / **不降级 success**)、upsert_record 不降级。 + 端点层:price/step 首帧 mint trace_id + 回传 + 建 running 行;done 帧落 success; + trace/finalize 落 cancelled;软鉴权(无 token 也放行、user_id 暂空)。 +pricebot 用 httpx mock,不真连(同 test_compare_proxy)。 +""" +from __future__ import annotations + +import json +import uuid +from unittest.mock import MagicMock, patch + +import httpx + +from app.db.session import SessionLocal +from app.models.comparison import ComparisonRecord +from app.repositories import comparison as crud +from app.schemas.compare_record import ComparisonRecordIn +from sqlalchemy import select + + +def _tid() -> str: + return uuid.uuid4().hex + + +def _done_params() -> dict: + """一份典型 done 帧 params:美团 25 元 vs 源淘宝闪购 30 元 → 省 5 元、success。""" + return { + "comparison_results": [ + {"platform_id": "meituan", "platform_name": "美团", "package": "com.sankuai.meituan", + "price": 25.0, "is_source": False, "rank": 1, "items": []}, + {"platform_id": "taobao_flash", "platform_name": "淘宝闪购", + "package": "com.taobao.taobao", "price": 30.0, "is_source": True, "rank": 2, + "store_name": "测试店", "items": [{"name": "肥牛饭", "qty": 1}]}, + ], + "information": "美团更便宜", + "trace_url": "https://price.shaguabijia.com/traces/done/", + } + + +def _get(db, trace_id: str) -> ComparisonRecord | None: + return db.execute( + select(ComparisonRecord).where(ComparisonRecord.trace_id == trace_id) + ).scalar_one_or_none() + + +# ============================================================ +# repo 层 +# ============================================================ + + +def test_harvest_running_creates_row(client) -> None: + tid = _tid() + with SessionLocal() as db: + crud.harvest_running( + db, trace_id=tid, user_id=None, device_id="dev-1", + device_info={"brand": "vivo", "model": "V2309A", "android_version": "14", + "rom_version": "14"}, + trace_url="https://price.shaguabijia.com/traces/run/", + ) + rec = _get(db, tid) + assert rec is not None + assert rec.status == "running" + assert rec.user_id is None + assert rec.device_id == "dev-1" + assert rec.device_model == "V2309A" + assert rec.device_manufacturer == "vivo" + assert rec.rom_version == 14 + assert rec.trace_url.endswith("/run/") + + +def test_harvest_running_idempotent_backfills(client) -> None: + tid = _tid() + with SessionLocal() as db: + crud.harvest_running(db, trace_id=tid, user_id=None, trace_url=None) + # 第二帧带上 trace_url + user_id → 回填空缺,不新建、不改 status + crud.harvest_running(db, trace_id=tid, user_id=None, + trace_url="https://price.shaguabijia.com/traces/late/") + rows = db.execute( + select(ComparisonRecord).where(ComparisonRecord.trace_id == tid) + ).scalars().all() + assert len(rows) == 1 # 幂等:仍一行 + assert rows[0].status == "running" + assert rows[0].trace_url.endswith("/late/") # 空缺被回填 + + +def test_harvest_done_derives_and_newly_success_once(client) -> None: + tid = _tid() + with SessionLocal() as db: + crud.harvest_running(db, trace_id=tid, user_id=None) + rec, newly = crud.harvest_done(db, trace_id=tid, user_id=None, + done_params=_done_params()) + assert newly is True # running → success 是"新落成" + assert rec.status == "success" + assert rec.source_platform_id == "taobao_flash" + assert rec.source_price_cents == 3000 + assert rec.best_platform_id == "meituan" + assert rec.best_price_cents == 2500 + assert rec.saved_amount_cents == 500 # 30 - 25 + assert rec.is_source_best is False + assert rec.store_name == "测试店" + assert rec.information == "美团更便宜" + assert rec.items == [{"name": "肥牛饭", "qty": 1}] + assert rec.trace_url.endswith("/done/") + # 再来一次(重试 done)→ 已 success,newly_success=False(发奖不重复触发) + _rec2, newly2 = crud.harvest_done(db, trace_id=tid, user_id=None, + done_params=_done_params()) + assert newly2 is False + + +def test_harvest_abort_cancels_running(client) -> None: + tid = _tid() + with SessionLocal() as db: + crud.harvest_running(db, trace_id=tid, user_id=None) + rec = crud.harvest_abort(db, trace_id=tid, status="cancelled", + reason="用户终止比价", + trace_url="https://price.shaguabijia.com/traces/ab/") + assert rec is not None + assert rec.status == "cancelled" + assert rec.information == "用户终止比价" + assert rec.trace_url.endswith("/ab/") + + +def test_harvest_abort_no_downgrade_success(client) -> None: + """已 success 的行,finalize 后到(收尾取消)→ 只 refresh trace_url,status 不降级。""" + tid = _tid() + with SessionLocal() as db: + crud.harvest_running(db, trace_id=tid, user_id=None) + crud.harvest_done(db, trace_id=tid, user_id=None, done_params=_done_params()) + rec = crud.harvest_abort(db, trace_id=tid, status="cancelled", reason="收尾误触") + assert rec.status == "success" # 不降级 + assert rec.information == "美团更便宜" # 不被 abort 的 reason 覆盖 + + +def test_harvest_abort_missing_row_returns_none(client) -> None: + with SessionLocal() as db: + assert crud.harvest_abort(db, trace_id=_tid(), status="cancelled", + reason=None) is None + + +def test_upsert_record_no_downgrade_after_harvest_success(client) -> None: + """harvest 落 success 后,老客户端 fromFailure 的 cancelled 上报不许把它盖回去。""" + tid = _tid() + with SessionLocal() as db: + crud.harvest_done(db, trace_id=tid, user_id=None, done_params=_done_params()) + payload = ComparisonRecordIn( + trace_id=tid, business_type="food", status="cancelled", + information="用户终止", comparison_results=[], + ) + # 用一个不会与顺序自增用户撞的合成 id(SQLite 测试库 FK 不强制;别用小整数, + # 否则会撞上别的测试 login 出来的真实 user_id → 记录混进那个用户的列表)。 + rec = crud.upsert_record(db, user_id=987654, payload=payload) + assert rec.status == "success" # 不降级 + assert rec.user_id == 987654 # 但补上了 user_id(原为 None) + + +# ============================================================ +# 端点层(mock pricebot) +# ============================================================ + + +def _mock_pricebot(resp_json: dict): + """patch httpx.AsyncClient.post 返回给定响应;捕获转发的 content。""" + captured: dict = {} + + async def fake_post(self, url, content=None, **kw): + captured["url"] = url + captured["content"] = content + m = MagicMock() + m.status_code = 200 + m.json = lambda: dict(resp_json) + return m + + return patch.object(httpx.AsyncClient, "post", fake_post), captured + + +def _stub_body(**over) -> dict: + body = { + "device_id": "dev-x", + "step": 0, + "query": "海底捞", + "device_info": {"brand": "OPPO", "model": "PEXM00", "android_version": "13", + "rom_version": "13"}, + "screen_state": {"screen": {"width": 1080, "height": 2340, "density": 3.0}, + "foreground": {"package": "com.taobao.taobao", "activity": ""}, + "windows": []}, + } + body.update(over) + return body + + +def test_price_step_mints_trace_id_and_creates_running(client) -> None: + """首帧不带 trace_id → app-server 签发 + 回传;并建 running 行(带机型/设备)。""" + wait_frame = {"success": True, "action": {"command": "wait", "params": {"duration_ms": 500}}, + "continue": True, "trace_url": "https://price.shaguabijia.com/traces/mint/"} + p, _cap = _mock_pricebot(wait_frame) + with p: + r = client.post("/api/v1/price/step", json=_stub_body()) # 无 trace_id、无 token + assert r.status_code == 200, r.text + tid = r.json().get("trace_id") + assert tid and len(tid) >= 16 # 回传了签发的 trace_id + with SessionLocal() as db: + rec = _get(db, tid) + assert rec is not None and rec.status == "running" + assert rec.user_id is None # 无 token → 软鉴权放行、user_id 暂空 + assert rec.device_model == "PEXM00" + assert rec.trace_url.endswith("/mint/") + + +def test_price_step_done_harvests_success(client) -> None: + tid = _tid() + done_frame = {"success": True, "continue": False, + "action": {"command": "done", "params": _done_params()}, + "trace_url": "https://price.shaguabijia.com/traces/done2/"} + p, _cap = _mock_pricebot(done_frame) + with p: + r = client.post("/api/v1/price/step", json=_stub_body(trace_id=tid, step=8)) + assert r.status_code == 200 + with SessionLocal() as db: + rec = _get(db, tid) + assert rec is not None and rec.status == "success" + assert rec.best_platform_id == "meituan" + assert rec.saved_amount_cents == 500 + + +def test_trace_finalize_harvests_abort(client) -> None: + tid = _tid() + with SessionLocal() as db: # 先有 running 行(帧0建的) + crud.harvest_running(db, trace_id=tid, user_id=None) + p, _cap = _mock_pricebot({"trace_url": "https://price.shaguabijia.com/traces/fin/"}) + with p: + r = client.post("/api/v1/trace/finalize", + json={"trace_id": tid, "status": "cancelled", "reason": "用户终止"}) + assert r.status_code == 200 + with SessionLocal() as db: + rec = _get(db, tid) + assert rec is not None and rec.status == "cancelled" + assert rec.trace_url.endswith("/fin/") + + +def test_price_step_binds_user_when_authed(client) -> None: + """带 JWT 的首帧 → running 行绑上 user_id。""" + client.post("/api/v1/auth/sms/send", json={"phone": "13800009001"}) + token = client.post("/api/v1/auth/sms/login", + json={"phone": "13800009001", "code": "123456"}).json()["access_token"] + wait_frame = {"success": True, "action": {"command": "wait", "params": {}}, + "continue": True, "trace_url": "https://price.shaguabijia.com/traces/auth/"} + p, _cap = _mock_pricebot(wait_frame) + with p: + r = client.post("/api/v1/price/step", json=_stub_body(), + headers={"Authorization": f"Bearer {token}"}) + tid = r.json()["trace_id"] + with SessionLocal() as db: + rec = _get(db, tid) + assert rec is not None and rec.user_id is not None # 绑上了登录用户 diff --git a/tests/test_compare_milestone.py b/tests/test_compare_milestone.py index 06165b4..66007ff 100644 --- a/tests/test_compare_milestone.py +++ b/tests/test_compare_milestone.py @@ -20,8 +20,10 @@ def _auth(token: str) -> dict[str, str]: def _report_success(client, token: str, trace_id: str) -> None: """上报一条成功比价(有非源有效价 → status 派生 success)。""" + # trace_id 现全局唯一(prod 由 app-server 签发 UUID);测试共用 session DB,故按 token + # (每用户不同)加前缀防跨用户/跨文件复用 "s-1" 撞车(见 comparison_record_trace_unique 迁移)。 payload = { - "trace_id": trace_id, + "trace_id": f"{token[-16:]}:{trace_id}", "business_type": "food", "store_name": "测试店", "source_platform_id": "taobao_flash", @@ -40,7 +42,7 @@ def _report_success(client, token: str, trace_id: str) -> None: def _report_failed(client, token: str, trace_id: str) -> None: """上报一条失败比价(只有源 → status 派生 failed,不计入解锁)。""" payload = { - "trace_id": trace_id, + "trace_id": f"{token[-16:]}:{trace_id}", "business_type": "food", "comparison_results": [ {"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "price": 30.0, diff --git a/tests/test_compare_proxy.py b/tests/test_compare_proxy.py index c278b93..b958be8 100644 --- a/tests/test_compare_proxy.py +++ b/tests/test_compare_proxy.py @@ -9,6 +9,7 @@ mock 掉对 pricebot 的 httpx 调用,验证(两个端点参数化同跑): """ from __future__ import annotations +import json from unittest.mock import MagicMock, patch import httpx @@ -41,7 +42,8 @@ def _stub_body() -> dict: @pytest.mark.parametrize("path,upstream", ENDPOINTS) def test_passes_body_through(client, path, upstream) -> None: - """无 token + pricebot 200 → 响应原样透传,body 原样转发,URL 去掉 /v1。""" + """无 token(软鉴权放行)+ pricebot 200 → 响应透传(+ app-server 注入 trace_id), + body 原样转发(自带 trace_id 时不重签),URL 去掉 /v1。""" fake_resp = { "success": True, "action": {"command": "wait", "params": {"duration_ms": 500}}, @@ -49,20 +51,25 @@ def test_passes_body_through(client, path, upstream) -> None: } captured: dict = {} - async def fake_post(self, url, json=None, **kw): + async def fake_post(self, url, content=None, **kw): + # 透传壳用 content=raw(bytes)转发,不是 json=;这里捕获 content 解回来核对 captured["url"] = url - captured["json"] = json + captured["content"] = content mock_resp = MagicMock() mock_resp.status_code = 200 - mock_resp.json = lambda: fake_resp + mock_resp.json = lambda: dict(fake_resp) # 每次新副本(端点会 setdefault trace_id 进去) return mock_resp with patch.object(httpx.AsyncClient, "post", fake_post): r = client.post(path, json=_stub_body()) assert r.status_code == 200, r.text - assert r.json() == fake_resp - assert captured["json"] == _stub_body() # body 原样转发(不鉴权,无需 token) + body = r.json() + assert body["success"] is True + assert body["action"] == fake_resp["action"] + assert body["trace_id"] == "test-trace-1" # 自带 trace_id → 原样回传, 不 mint + # body 原样转发(content=raw bytes;自带 trace_id 时不重新序列化) + assert json.loads(captured["content"]) == _stub_body() assert captured["url"].endswith(upstream) # /api/v1/xxx → /api/xxx diff --git a/tests/test_geo.py b/tests/test_geo.py new file mode 100644 index 0000000..98b70c4 --- /dev/null +++ b/tests/test_geo.py @@ -0,0 +1,137 @@ +"""reverse_geocoder 经纬度→城市 测试。 + +验证离线库对国内主要城市的匹配准确性。注意:gazetteer 的中国数据粒度不一致—— +直辖市/省会通常直接命中城市名,部分城市可能命中到区/街道级(如天津→Erwangzhuang、 +西安→Zhangjiabao),此时 admin1 为省级行政区。测试以 admin1(省级)匹配为主。 +""" +from __future__ import annotations + +import pytest + +from app.utils.geo import get_city + +# ─────────────── 国内主要城市 ─────────────── +# (城市, 纬度, 经度, 期望 admin1 包含字串) +_CITY_CASES = [ + # 直辖市 — admin1 即城市名(可能带 Shi 后缀) + ("北京", 39.9042, 116.4074, "Beijing"), + ("上海", 31.2304, 121.4737, "Shanghai"), + ("重庆", 29.4316, 106.9123, "Chongqing"), + ("天津", 39.3434, 117.3616, "Tianjin"), + # 省会 / 一线 — admin1 为省份 + ("广州", 23.1291, 113.2644, "Guangdong"), + ("深圳", 22.5431, 114.0579, "Guangdong"), + ("成都", 30.5728, 104.0668, "Sichuan"), + ("杭州", 30.2741, 120.1551, "Zhejiang"), + ("武汉", 30.5928, 114.3055, "Hubei"), + ("西安", 34.3416, 108.9398, "Shaanxi"), + ("南京", 32.0603, 118.7969, "Jiangsu"), + ("长沙", 28.2282, 112.9388, "Hunan"), + ("郑州", 34.7466, 113.6253, "Henan"), + ("济南", 36.6512, 116.9946, "Shandong"), + ("昆明", 25.0389, 102.7183, "Yunnan"), + ("福州", 26.0745, 119.2965, "Fujian"), + ("哈尔滨", 45.8038, 126.5350, "Heilongjiang"), + ("乌鲁木齐", 43.8256, 87.6168, "Xinjiang"), + ("拉萨", 29.6500, 91.1000, "Tibet"), + # 非省会 + ("厦门", 24.4798, 118.0894, "Fujian"), + ("苏州", 31.2990, 120.5853, "Jiangsu"), + ("青岛", 36.0671, 120.3826, "Shandong"), +] + + +@pytest.mark.parametrize("label,lat,lon,expected_admin1", _CITY_CASES) +def test_city_admin1_match(label: str, lat: float, lon: float, expected_admin1: str) -> None: + """所有城市经纬度应能匹配到正确的省级行政区 (admin1)。""" + r = get_city(lat, lon) + assert r["name"] != "", f"{label}: name should not be empty" + assert r["country"] == "CN", f"{label}: expected country=CN, got={r['country']}" + assert expected_admin1 in r["admin1"], \ + f"{label}: expected admin1 to contain '{expected_admin1}', got={r['admin1']!r}" + + +# ─────────────── 直辖市 / 省会直接命中城市名 ─────────────── +# 这些城市在 gazetteer 中的坐标恰好命中城市级条目(而非区/街道级), +# 验证 name 字段也正确。 +_DIRECT_HIT_CASES = [ + ("北京", 39.9042, 116.4074, "Beijing"), + ("上海", 31.2304, 121.4737, "Shanghai"), + ("广州", 23.1291, 113.2644, "Guangzhou"), + ("深圳", 22.5431, 114.0579, "Shenzhen"), + ("成都", 30.5728, 104.0668, "Chengdu"), + ("杭州", 30.2741, 120.1551, "Hangzhou"), + ("郑州", 34.7466, 113.6253, "Zhengzhou"), + ("济南", 36.6512, 116.9946, "Jinan"), + ("昆明", 25.0389, 102.7183, "Kunming"), + ("哈尔滨", 45.8038, 126.5350, "Harbin"), + ("厦门", 24.4798, 118.0894, "Xiamen"), + ("苏州", 31.2990, 120.5853, "Suzhou"), + ("青岛", 36.0671, 120.3826, "Qingdao"), + ("拉萨", 29.6500, 91.1000, "Lhasa"), +] + + +@pytest.mark.parametrize("label,lat,lon,expected_name", _DIRECT_HIT_CASES) +def test_city_name_direct_hit(label: str, lat: float, lon: float, expected_name: str) -> None: + """直辖市/省会等主要城市坐标应直接命中城市名(而非区/街道级)。""" + r = get_city(lat, lon) + assert r["name"] == expected_name, \ + f"{label}: expected name={expected_name}, got={r['name']!r}" + + +# ─────────────── 边界情况 ─────────────── + +def test_ocean_not_china() -> None: + """远洋坐标不应误判为国内城市。""" + # 太平洋中部 → 可能匹配到最近有人岛(如法属波利尼西亚 Taiohae),但绝不应是 CN + r = get_city(0.0, -140.0) + assert r["country"] != "CN", f"mid-Pacific should not be CN, got {r}" + + # 南大西洋 + r2 = get_city(-30.0, -20.0) + assert r2["country"] != "CN", f"South Atlantic should not be CN, got {r2}" + + +def test_return_keys_and_types() -> None: + """返回 dict 应包含全部五个字段且类型为 str。""" + r = get_city(39.9042, 116.4074) + for key in ("name", "admin1", "country", "latitude", "longitude"): + assert key in r, f"missing key: {key}" + assert isinstance(r[key], str), f"key {key} should be str, got {type(r[key])}" + + +def test_empty_result_keys() -> None: + """结果始终应包含完整字段且全为 str 类型(即使匹配到偏远地)。""" + # reverse_geocoder KDTree 总找最近聚居点;业务侧如需判定"是否有效城市" + # 应自行按 country / admin1 做二次校验,而非依赖空字符串。 + r = get_city(0.0, -140.0) + assert r["name"] != "" + assert isinstance(r["name"], str) + assert isinstance(r["admin1"], str) + assert isinstance(r["country"], str) + assert isinstance(r["latitude"], str) + assert isinstance(r["longitude"], str) + + +def test_same_coords_consistent() -> None: + """同一坐标两次查询应返回相同结果(幂等)。""" + r1 = get_city(31.2304, 121.4737) + r2 = get_city(31.2304, 121.4737) + assert r1 == r2 + + +def test_near_border_has_result() -> None: + """省界附近的坐标应返回结果(非空 + 国内)。""" + # 苏鲁豫皖交界区域(徐州/商丘/宿州附近) + r = get_city(34.2, 116.8) + assert r["name"] != "", "border region should find a nearby populated place" + assert r["country"] == "CN" + + +def test_extreme_lat_lon_no_crash() -> None: + """极值经纬度不应抛异常。""" + r1 = get_city(90.0, 0.0) # 北极 + r2 = get_city(-90.0, 0.0) # 南极 + assert isinstance(r1, dict) + assert isinstance(r2, dict) diff --git a/tests/test_meituan_city.py b/tests/test_meituan_city.py new file mode 100644 index 0000000..c6928ad --- /dev/null +++ b/tests/test_meituan_city.py @@ -0,0 +1,64 @@ +"""app.utils.meituan_city.get_meituan_city 经纬度→美团城市 反查测试。 + +覆盖 get_meituan_city 的分支(test_geo.py 只覆盖底层 get_city): + - 主要城市 → 正确省份 + 非空 city_id(+ 直辖市/省会命中中文城市名) + - 非中国境内坐标 → city_id 为空(接口据此降级返空) + - 返回 dict 结构 / 类型 + - 坐标量化(~1km)后近点命中同一缓存结果 + - 返回的是缓存副本(调用方原地修改不污染缓存) +""" +from __future__ import annotations + +import pytest + +from app.utils.meituan_city import get_meituan_city + +# (标签, 纬度, 经度, 期望省份名, 期望城市名) +_CASES = [ + ("北京", 39.9042, 116.4074, "北京市", "北京市"), + ("上海", 31.2304, 121.4737, "上海市", "上海市"), + ("广州", 23.1291, 113.2644, "广东省", "广州市"), + ("深圳", 22.5431, 114.0579, "广东省", "深圳市"), + ("成都", 30.5728, 104.0668, "四川省", "成都市"), + ("杭州", 30.2741, 120.1551, "浙江省", "杭州市"), + ("武汉", 30.5928, 114.3055, "湖北省", "武汉市"), + ("郑州", 34.7466, 113.6253, "河南省", "郑州市"), + ("厦门", 24.4798, 118.0894, "福建省", "厦门市"), + ("青岛", 36.0671, 120.3826, "山东省", "青岛市"), +] + + +@pytest.mark.parametrize("label,lat,lon,exp_province,exp_city", _CASES) +def test_major_city_resolves(label: str, lat: float, lon: float, + exp_province: str, exp_city: str) -> None: + r = get_meituan_city(lat, lon) + assert r["province_name"] == exp_province, f"{label}: province {r!r}" + assert r["city_name"] == exp_city, f"{label}: city {r!r}" + assert r["city_id"], f"{label}: city_id 不应为空 {r!r}" + + +def test_non_china_returns_empty_city_id() -> None: + """境外/远洋坐标 → city_id 空(接口据此返回 degraded 空列表)。""" + r = get_meituan_city(0.0, -140.0) # 太平洋中部 + assert r["city_id"] == "", f"境外不应给出 city_id: {r!r}" + + +def test_return_shape_and_types() -> None: + r = get_meituan_city(39.9042, 116.4074) + for key in ("city_id", "city_name", "province_name"): + assert key in r and isinstance(r[key], str) + + +def test_quantized_coords_hit_same_result() -> None: + """相距 <1km(round 到 2 位小数后相同)的两点应解析出同一城市。""" + a = get_meituan_city(39.9042, 116.4074) + b = get_meituan_city(39.9031, 116.4066) # round 后同为 (39.90, 116.41) + assert a == b + + +def test_result_is_defensive_copy() -> None: + """返回的是缓存副本:原地修改不应污染后续查询。""" + first = get_meituan_city(31.2304, 121.4737) + first["city_id"] = "TAMPERED" + second = get_meituan_city(31.2304, 121.4737) + assert second["city_id"] != "TAMPERED" diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 3b6f533..a56c9f0 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -65,3 +65,28 @@ def test_onboarding_missing_device_id_treated_as_incomplete(client) -> None: phone = "13800138003" data = _login(client, phone, device_id=None) assert data["onboarding_completed"] is False + + +def _reset(client, access: str, device_id: str): + return client.post( + "/api/v1/user/onboarding/reset", + json={"device_id": device_id}, + headers={"Authorization": f"Bearer {access}"}, + ) + + +def test_onboarding_reset_makes_relogin_reonboard(client) -> None: + """重置(删完成标记)后,同 (账号, 设备) 再登录 → onboarding_completed=False,重走引导。 + 对应客户端「开发设置 → 重置新手引导」:先删后端行,再退登录重开。""" + phone = "13800138004" + dev = "android-id-reset" + + # ① 走完引导 → 标记完成 → 再登录已完成 + access = _login(client, phone, dev)["access_token"] + assert _mark_complete(client, access, dev).status_code == 200 + assert _login(client, phone, dev)["onboarding_completed"] is True + + # ② 重置(幂等:重复重置也 200)→ 再登录变未完成 → 重走引导 + assert _reset(client, access, dev).status_code == 200 + assert _reset(client, access, dev).json()["ok"] is True + assert _login(client, phone, dev)["onboarding_completed"] is False