运营后台后端:RBAC 权限管理 + 系统配置下发修复 + 比价记录店/商品搜索 #117
@@ -0,0 +1,69 @@
|
||||
"""admin_role 表(RBAC 角色 → 可见页面)+ 种子内建角色
|
||||
|
||||
新增后台 RBAC:角色持有一组页面 key,登录后左侧只展示这些页。super_admin 内建全权(pages 存空、
|
||||
effective_pages 特判为全部)、不可编辑删除;operator/finance 播默认页集,可由 super_admin 增删改。
|
||||
admin_user.role 弱引用本表 name。全新环境顺序应用即得三条内建角色。
|
||||
|
||||
Revision ID: admin_role_table
|
||||
Revises: comparison_product_names
|
||||
Create Date: 2026-07-04 00:00:00.000000
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "admin_role_table"
|
||||
down_revision: str | Sequence[str] | None = "comparison_product_names"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||
|
||||
# 与 app/admin/permissions.py 的 BUILTIN_ROLES 同口径(迁移内联一份,不耦合 app 常量)。
|
||||
# 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES。key 承重(require_role),label 为展示名。
|
||||
_BUILTIN = [
|
||||
{"name": "super_admin", "label": "管理员", "pages": [], "is_builtin": True},
|
||||
{"name": "operator", "label": "运营", "pages": [
|
||||
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
|
||||
"cps", "device-liveness", "price-reports", "feedbacks",
|
||||
], "is_builtin": False},
|
||||
{"name": "finance", "label": "财务", "pages": [
|
||||
"dashboard", "ad-revenue-report", "cps", "withdraws",
|
||||
], "is_builtin": False},
|
||||
{"name": "tech", "label": "技术", "pages": [
|
||||
"dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs",
|
||||
], "is_builtin": False},
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"admin_role",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(length=32), nullable=False),
|
||||
sa.Column("label", sa.String(length=32), nullable=False, server_default=""),
|
||||
sa.Column("pages", _JSON, nullable=False),
|
||||
sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(), nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index("ix_admin_role_name", "admin_role", ["name"], unique=True)
|
||||
|
||||
tbl = sa.table(
|
||||
"admin_role",
|
||||
sa.column("name", sa.String),
|
||||
sa.column("label", sa.String),
|
||||
sa.column("pages", _JSON),
|
||||
sa.column("is_builtin", sa.Boolean),
|
||||
)
|
||||
op.bulk_insert(tbl, _BUILTIN)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_admin_role_name", table_name="admin_role")
|
||||
op.drop_table("admin_role")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""admin_user 加 plain_password 列(明文登录密码,仅后台 UI 建/重置的账号留存)
|
||||
|
||||
权限管理页需能复看某成员已确定的登录密码转交本人。系统只存哈希无法反推,故对「后台 UI 创建/重置」
|
||||
的管理员额外留存一份明文;脚本/起后台建的超管为 None(前端不显示密码)。旧账号无此列值 → 同样不显示。
|
||||
|
||||
Revision ID: admin_user_plain_password
|
||||
Revises: admin_role_table
|
||||
Create Date: 2026-07-04 00:00:00.000000
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "admin_user_plain_password"
|
||||
down_revision: str | Sequence[str] | None = "admin_role_table"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("admin_user", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("plain_password", sa.String(length=128), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("admin_user", schema=None) as batch_op:
|
||||
batch_op.drop_column("plain_password")
|
||||
@@ -0,0 +1,70 @@
|
||||
"""comparison_record 加 product_names 列(下单商品名派生串)+ 回填历史行
|
||||
|
||||
admin 比价记录页要把原「店/商品」一列拆成「店」+「商品」两列、并支持按商品名搜索。
|
||||
items 是 JSON(SQLite 下 json.dumps ensure_ascii 会把中文转义存成 \\uXXXX,无法直接 CAST+LIKE
|
||||
命中中文),故把商品名派生成普通文本列,跨库 LIKE 一致、可索引。
|
||||
|
||||
写路径(upsert_record / harvest_done)已同步派生;本迁移建列并从已存 items 回填历史行。
|
||||
全新环境顺序应用即得空列 + 回填(表本为空,回填 no-op)。
|
||||
|
||||
Revision ID: comparison_product_names
|
||||
Revises: comparison_record_trace_unique
|
||||
Create Date: 2026-07-04 00:00:00.000000
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "comparison_product_names"
|
||||
down_revision: str | Sequence[str] | None = "comparison_record_trace_unique"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _product_names(items) -> str | None:
|
||||
"""items([{name,...}]) → 顿号分隔的商品名串(去重保序)。与 repositories.comparison
|
||||
._product_names_from_items 同口径,迁移内联一份避免耦合 app 代码。"""
|
||||
if isinstance(items, str): # SQLite: items 以 JSON 文本存,取回是 str
|
||||
try:
|
||||
items = json.loads(items)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(items, list) or not items:
|
||||
return None
|
||||
names: list[str] = []
|
||||
for it in items:
|
||||
name = it.get("name") if isinstance(it, dict) else None
|
||||
if not name:
|
||||
continue
|
||||
s = str(name).strip()
|
||||
if s and s not in names:
|
||||
names.append(s)
|
||||
joined = "、".join(names)
|
||||
return joined[:500] or None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("product_names", sa.String(length=512), nullable=True))
|
||||
|
||||
# 回填:从已存 items 派生商品名。items 小(菜名列表),一次取回即可;只更新有商品名的行。
|
||||
bind = op.get_bind()
|
||||
rows = bind.execute(
|
||||
sa.text("SELECT id, items FROM comparison_record")
|
||||
).fetchall()
|
||||
for rid, items in rows:
|
||||
pn = _product_names(items)
|
||||
if pn:
|
||||
bind.execute(
|
||||
sa.text("UPDATE comparison_record SET product_names = :pn WHERE id = :id"),
|
||||
{"pn": pn, "id": rid},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
|
||||
batch_op.drop_column("product_names")
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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="改某项配置(带审计)")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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=只用种子/合成(演示)。",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""admin 角色 → 可见页面(权限)映射表。
|
||||
|
||||
RBAC 的「角色」侧:每个角色持有一组「页面 key」(= 左侧导航项),决定该角色登录后台后左边能看到
|
||||
哪些页。super_admin 是内建全权角色(is_builtin=True),恒可见全部页(effective_pages 里特判,
|
||||
不依赖本表存的 pages)、不可编辑/删除。其余角色(含 operator/finance)可由 super_admin 增删改。
|
||||
|
||||
admin_user.role 存的是本表的 name(字符串弱引用,不建外键——与既有 require_role 字符串口径一致;
|
||||
删除在用角色由业务层拦截,见 routers/roles.py)。页面 key 清单见 app/admin/permissions.py。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
_JSON = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
class AdminRole(Base):
|
||||
__tablename__ = "admin_role"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 角色标识 key(不可变,承重):admin_user.role 引用它、require_role 按它鉴权。
|
||||
# 内建 = super_admin/operator/finance/tech(英文,勿改);自定义 = 创建时的名称。
|
||||
name: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False)
|
||||
# 展示名(可改):内建 = 管理员/运营/财务/技术;自定义默认 = name。UI 一律展示 label。
|
||||
label: Mapped[str] = mapped_column(String(32), nullable=False, default="")
|
||||
# 该角色可见页面 key 列表(= 左侧导航项),见 app/admin/permissions.py 的 PERMISSION_CATALOG
|
||||
pages: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||
# 内建角色(当前仅 super_admin):不可编辑/删除,恒全权
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<AdminRole name={self.name} pages={len(self.pages)} builtin={self.is_builtin}>"
|
||||
@@ -86,6 +86,10 @@ class ComparisonRecord(Base):
|
||||
|
||||
# ===== 订单概要 =====
|
||||
store_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 下单商品名拼接串(顿号分隔,从 items[].name 去重派生),供 admin 列表「商品」列展示 + 商品名搜索。
|
||||
# items 是 JSON(SQLite 下 json.dumps ensure_ascii 把中文转义,无法直接 CAST+LIKE),故另派生成普通
|
||||
# 文本列——跨库 LIKE 一致、可加索引。写路径(upsert_record / harvest_done)落库时同步派生。
|
||||
product_names: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
total_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
skipped_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
|
||||
@@ -24,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),
|
||||
|
||||
@@ -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 分钟),降序、像真实流水。
|
||||
|
||||
@@ -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:
|
||||
# 首次无值:按当前模式播种,使配置后立即有合理展示值
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user