运营后台后端:RBAC 权限管理 + 系统配置下发修复 + 比价记录店/商品搜索
一、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 <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
Reference in New Issue
Block a user