From bce8cd992335c5e2035f143bf6fc1669a341d2d9 Mon Sep 17 00:00:00 2001 From: zzhyyyyy <2685922758@qq.com> Date: Sat, 4 Jul 2026 17:52:42 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=90=E8=90=A5=E5=90=8E=E5=8F=B0=E5=90=8E?= =?UTF-8?q?=E7=AB=AF:RBAC=20=E6=9D=83=E9=99=90=E7=AE=A1=E7=90=86=20+=20?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E9=85=8D=E7=BD=AE=E4=B8=8B=E5=8F=91=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20+=20=E6=AF=94=E4=BB=B7=E8=AE=B0=E5=BD=95=E5=BA=97/?= =?UTF-8?q?=E5=95=86=E5=93=81=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、RBAC 权限管理(角色 → 可见页面) - 新增 admin_role 表 + 权限目录 permissions.py:角色持有一组页面 key,登录后左侧只展示这些页 - 内建角色 管理员(super_admin 全权锁定)/运营/财务/技术,页集对齐原型;key 承重(require_role 用),另加中文 label 展示 - 角色 CRUD 端点(仅 super_admin)+ 目录端点;登录/me 下发当前角色有效可见页 - admins 加删除、角色存在性校验;可复看已确定登录密码:UI 建的账号留存明文 plain_password,脚本建的超管不留存 二、系统配置下发修复 - 首页数据「保存即生效」:显式保存(apply_now)对 real/manual 直接落配置目标值、绕过只增不减护栏(修「改了 app 端不变」),护栏仍管自动 tick - 首页轮播新增数据源三选一:mixed(真实优先+种子)/real(只真实)/seed(只种子),get_feed 分支 + /marquee-seeds/mode 端点 - 福利页 Tab 隐藏 任务/里程碑,及看广告的单次金币/每轮次数/信息流开关:CONFIG_DEFS 加 hidden + list_config 过滤,业务读取默认值不受影响 三、比价记录店/商品搜索 - comparison_record 加 product_names 派生列(从下单 items 拼商品名),迁移建列并回填历史行 - admin 比价记录列表店/商品分列可搜:走 product_names 普通列 LIKE,规避 SQLite JSON 中文 ensure_ascii 转义搜不到的坑 Co-Authored-By: Claude Opus 4.8 --- alembic/versions/admin_role_table.py | 69 ++++++++ alembic/versions/admin_user_plain_password.py | 29 ++++ alembic/versions/comparison_product_names.py | 70 ++++++++ app/admin/main.py | 2 + app/admin/permissions.py | 80 +++++++++ app/admin/repositories/admin_role.py | 81 ++++++++++ app/admin/repositories/admin_user.py | 10 +- app/admin/repositories/queries.py | 10 +- app/admin/routers/admins.py | 52 +++++- app/admin/routers/auth.py | 16 +- app/admin/routers/comparison.py | 5 +- app/admin/routers/config.py | 9 +- app/admin/routers/ops_marquee_seed.py | 34 +++- app/admin/routers/roles.py | 129 +++++++++++++++ app/admin/schemas/admin.py | 6 +- app/admin/schemas/auth.py | 6 + app/admin/schemas/comparison.py | 1 + app/admin/schemas/role.py | 35 ++++ app/core/config_schema.py | 24 ++- app/models/__init__.py | 1 + app/models/admin.py | 3 + app/models/admin_role.py | 42 +++++ app/models/comparison.py | 4 + app/repositories/comparison.py | 27 +++- app/repositories/ops_marquee.py | 95 ++++++----- app/repositories/ops_stat.py | 7 +- tests/test_admin_config.py | 9 +- tests/test_admin_roles.py | 152 ++++++++++++++++++ 28 files changed, 941 insertions(+), 67 deletions(-) create mode 100644 alembic/versions/admin_role_table.py create mode 100644 alembic/versions/admin_user_plain_password.py create mode 100644 alembic/versions/comparison_product_names.py create mode 100644 app/admin/permissions.py create mode 100644 app/admin/repositories/admin_role.py create mode 100644 app/admin/routers/roles.py create mode 100644 app/admin/schemas/role.py create mode 100644 app/models/admin_role.py create mode 100644 tests/test_admin_roles.py 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/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/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/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/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/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/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 ea647b5..d21a201 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -86,6 +86,10 @@ class ComparisonRecord(Base): # ===== 订单概要 ===== store_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 下单商品名拼接串(顿号分隔,从 items[].name 去重派生),供 admin 列表「商品」列展示 + 商品名搜索。 + # items 是 JSON(SQLite 下 json.dumps ensure_ascii 把中文转义,无法直接 CAST+LIKE),故另派生成普通 + # 文本列——跨库 LIKE 一致、可加索引。写路径(upsert_record / harvest_done)落库时同步派生。 + product_names: Mapped[str | None] = mapped_column(String(512), nullable=True) total_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True) skipped_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True) diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index 1ad0bb1..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 @@ -81,10 +98,12 @@ def upsert_record( 新客户端不再 POST(改由 compare.py 透传壳 harvest 落库)。 """ derived = _derive(payload) + items = [it.model_dump(exclude_none=True) for it in payload.items] fields = dict( device_id=payload.device_id, business_type=payload.business_type, store_name=payload.store_name, + product_names=_product_names_from_items(items), source_platform_id=payload.source_platform_id, source_platform_name=payload.source_platform_name, source_package=payload.source_package, @@ -93,7 +112,7 @@ def upsert_record( trace_url=payload.trace_url, total_dish_count=payload.total_dish_count, skipped_dish_count=payload.skipped_dish_count, - items=[it.model_dump(exclude_none=True) for it in payload.items], + items=items, comparison_results=[r.model_dump() for r in payload.comparison_results], skipped_dish_names=list(payload.skipped_dish_names), # 客户端环境 / 性能(debug,客户端上报;旧客户端为 None) @@ -288,6 +307,8 @@ def harvest_done( 行不存在(理论上帧0已建;防御)则新建。""" results = done_params.get("comparison_results") or [] derived = _derive_from_results(results) + # 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items + items = next((r.get("items") or [] for r in results if r.get("is_source")), []) fields = dict( business_type=business_type or "food", information=done_params.get("information") or None, @@ -298,8 +319,8 @@ def harvest_done( skipped_dish_count=done_params.get("skipped_dish_count"), skipped_dish_names=list(done_params.get("skipped_dish_names") or []), comparison_results=results, - # 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items - items=next((r.get("items") or [] for r in results if r.get("is_source")), []), + items=items, + product_names=_product_names_from_items(items), raw_payload=done_params, **derived, **_device_cols_from_info(device_info), 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/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