Просмотр исходного кода

feat(backend): add user admin, role menu grant, and oper-log list

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 месяцев назад
Родитель
Сommit
ce947c0fe4

+ 209 - 3
backend/app/api/admin.py

@@ -1,5 +1,5 @@
-"""管理端 API:工作台、台账办理、配置、附件上传。"""
-from typing import Annotated, Optional
+"""管理端 API:工作台、台账办理、配置、用户/角色、审计、附件上传。"""
+from typing import Annotated, Any, Dict, List, Optional
 
 from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
 from sqlalchemy.orm import Session
@@ -8,15 +8,69 @@ from app.core import storage
 from app.core.database import get_db
 from app.core.deps import get_current_user, require_menu
 from app.core.response import AppHTTPError, success_response
+from app.core.security import hash_password
 from app.core.storage import StorageError
 from app.crud import attachment as attachment_crud
+from app.crud import menu as menu_crud
+from app.crud import oper_log as oper_log_crud
+from app.crud import role as role_crud
+from app.crud import user as user_crud
 from app.models.system import SysUser
 from app.schemas.business import HotlineUpdateBody, QrcodeUpdateBody, StatusUpdateBody
-from app.services import admin_ledger_service, config_service, dashboard_service
+from app.schemas.system import (
+    ResetPasswordBody,
+    RoleMenusBody,
+    UserCreateBody,
+    UserUpdateBody,
+)
+from app.services import admin_ledger_service, auth_service, config_service, dashboard_service
 
 router = APIRouter(prefix="/web/admin", tags=["admin"])
 
 
+def _iso(dt) -> Optional[str]:
+    if not dt:
+        return None
+    return dt.isoformat(sep=" ", timespec="seconds")
+
+
+def _user_to_dict(db: Session, user: SysUser) -> Dict[str, Any]:
+    role = user_crud.get_role_by_id(db, user.role_id)
+    return {
+        "id": user.id,
+        "username": user.username,
+        "displayName": user.display_name,
+        "roleId": user.role_id,
+        "roleCode": role.role_code if role else "",
+        "roleName": role.role_name if role else "",
+        "status": user.status,
+        "createTime": _iso(user.create_time),
+        "updateTime": _iso(user.update_time),
+    }
+
+
+def _role_to_dict(role) -> Dict[str, Any]:
+    return {
+        "id": role.id,
+        "roleCode": role.role_code,
+        "roleName": role.role_name,
+        "remark": role.remark or "",
+    }
+
+
+def _oper_log_to_dict(row) -> Dict[str, Any]:
+    return {
+        "id": row.id,
+        "userId": row.user_id,
+        "username": row.username,
+        "module": row.module,
+        "action": row.action,
+        "detail": row.detail,
+        "ip": row.ip,
+        "createTime": _iso(row.create_time),
+    }
+
+
 @router.get("/dashboard")
 def dashboard(
     db: Annotated[Session, Depends(get_db)],
@@ -205,3 +259,155 @@ async def admin_upload(
             "size": row.size,
         }
     )
+
+
+@router.get("/users")
+def list_users(
+    db: Annotated[Session, Depends(get_db)],
+    _user: Annotated[SysUser, Depends(require_menu("user:manage"))],
+):
+    rows = user_crud.list_users(db)
+    return success_response([_user_to_dict(db, u) for u in rows])
+
+
+@router.post("/users")
+def create_user(
+    body: UserCreateBody,
+    db: Annotated[Session, Depends(get_db)],
+    _user: Annotated[SysUser, Depends(require_menu("user:manage"))],
+):
+    username = (body.username or "").strip()
+    password = body.password or ""
+    display_name = (body.display_name or "").strip()
+    if not username or not password or not display_name:
+        raise AppHTTPError(400, "用户名、密码、显示名不能为空")
+    if user_crud.get_user_by_username(db, username):
+        raise AppHTTPError(400, "用户名已存在")
+    role = user_crud.get_role_by_id(db, body.role_id)
+    if not role:
+        raise AppHTTPError(400, "角色不存在")
+    row = user_crud.create_user(
+        db,
+        username=username,
+        password_hash=hash_password(password),
+        display_name=display_name,
+        role_id=role.id,
+    )
+    return success_response(_user_to_dict(db, row))
+
+
+@router.put("/users/{user_id}")
+def update_user(
+    user_id: str,
+    body: UserUpdateBody,
+    db: Annotated[Session, Depends(get_db)],
+    _user: Annotated[SysUser, Depends(require_menu("user:manage"))],
+):
+    row = user_crud.get_user_by_id(db, user_id)
+    if not row:
+        raise AppHTTPError(400, "用户不存在")
+    role_id = body.role_id
+    if role_id is not None:
+        role = user_crud.get_role_by_id(db, role_id)
+        if not role:
+            raise AppHTTPError(400, "角色不存在")
+    status = body.status
+    if status is not None and status not in ("0", "1"):
+        raise AppHTTPError(400, "状态无效")
+    display_name = body.display_name
+    if display_name is not None:
+        display_name = display_name.strip()
+        if not display_name:
+            raise AppHTTPError(400, "显示名不能为空")
+    updated = user_crud.update_user(
+        db,
+        row,
+        display_name=display_name,
+        role_id=role_id,
+        status=status,
+    )
+    return success_response(_user_to_dict(db, updated))
+
+
+@router.put("/users/{user_id}/reset-password")
+def reset_user_password(
+    user_id: str,
+    body: ResetPasswordBody,
+    db: Annotated[Session, Depends(get_db)],
+    _user: Annotated[SysUser, Depends(require_menu("user:manage"))],
+):
+    row = user_crud.get_user_by_id(db, user_id)
+    if not row:
+        raise AppHTTPError(400, "用户不存在")
+    password = body.password or ""
+    if not password:
+        raise AppHTTPError(400, "密码不能为空")
+    user_crud.update_user(db, row, password_hash=hash_password(password))
+    return success_response({"id": user_id})
+
+
+@router.get("/roles")
+def list_roles(
+    db: Annotated[Session, Depends(get_db)],
+    _user: Annotated[SysUser, Depends(get_current_user)],
+):
+    return success_response([_role_to_dict(r) for r in role_crud.list_roles(db)])
+
+
+@router.get("/menus/tree")
+def menus_tree(
+    db: Annotated[Session, Depends(get_db)],
+    _user: Annotated[SysUser, Depends(get_current_user)],
+):
+    menus = menu_crud.list_all_menus(db)
+    return success_response(auth_service.build_menu_tree(menus))
+
+
+@router.get("/roles/{role_id}/menus")
+def get_role_menus(
+    role_id: str,
+    db: Annotated[Session, Depends(get_db)],
+    _user: Annotated[SysUser, Depends(get_current_user)],
+):
+    role = role_crud.get_role_by_id(db, role_id)
+    if not role:
+        raise AppHTTPError(400, "角色不存在")
+    return success_response({"menuIds": role_crud.list_menu_ids_for_role(db, role_id)})
+
+
+@router.put("/roles/{role_id}/menus")
+def put_role_menus(
+    role_id: str,
+    body: RoleMenusBody,
+    db: Annotated[Session, Depends(get_db)],
+    _user: Annotated[SysUser, Depends(require_menu("role:grant"))],
+):
+    role = role_crud.get_role_by_id(db, role_id)
+    if not role:
+        raise AppHTTPError(400, "角色不存在")
+    menu_ids: List[str] = list(body.menu_ids or [])
+    if menu_ids:
+        valid_ids = {m.id for m in menu_crud.list_all_menus(db)}
+        invalid = [mid for mid in menu_ids if mid not in valid_ids]
+        if invalid:
+            raise AppHTTPError(400, "存在无效菜单")
+    role_crud.replace_role_menus(db, role_id, menu_ids)
+    return success_response({"menuIds": menu_ids})
+
+
+@router.get("/oper-logs")
+def list_oper_logs(
+    db: Annotated[Session, Depends(get_db)],
+    _user: Annotated[SysUser, Depends(require_menu("audit"))],
+    page: int = Query(1, ge=1),
+    pageSize: int = Query(10, ge=1, le=100),
+):
+    items, total = oper_log_crud.list_oper_logs(db, page=page, page_size=pageSize)
+    return success_response(
+        {
+            "items": [_oper_log_to_dict(r) for r in items],
+            "total": total,
+            "page": page,
+            "pageSize": pageSize,
+        }
+    )

+ 10 - 0
backend/app/crud/menu.py

@@ -35,6 +35,16 @@ def list_page_menus_for_role(db: Session, role_id: str) -> List[SysMenu]:
     )
 
 
+def list_all_menus(db: Session) -> List[SysMenu]:
+    """全量菜单(含按钮),授权页勾选用。"""
+    return (
+        db.query(SysMenu)
+        .filter(SysMenu.del_flag == "0")
+        .order_by(SysMenu.sort.asc())
+        .all()
+    )
+
+
 def role_has_any_menu_code(db: Session, role_id: str, codes: Set[str]) -> bool:
     if not codes:
         return False

+ 48 - 0
backend/app/crud/role.py

@@ -0,0 +1,48 @@
+"""角色 CRUD"""
+import uuid
+from typing import List, Optional
+
+from sqlalchemy.orm import Session
+
+from app.models.system import SysRole, SysRoleMenu
+
+
+def list_roles(db: Session) -> List[SysRole]:
+    return (
+        db.query(SysRole)
+        .filter(SysRole.del_flag == "0")
+        .order_by(SysRole.role_code.asc())
+        .all()
+    )
+
+
+def get_role_by_id(db: Session, role_id: str) -> Optional[SysRole]:
+    return (
+        db.query(SysRole)
+        .filter(SysRole.id == role_id, SysRole.del_flag == "0")
+        .first()
+    )
+
+
+def list_menu_ids_for_role(db: Session, role_id: str) -> List[str]:
+    rows = (
+        db.query(SysRoleMenu.menu_id)
+        .filter(SysRoleMenu.role_id == role_id)
+        .all()
+    )
+    return [r[0] for r in rows]
+
+
+def replace_role_menus(db: Session, role_id: str, menu_ids: List[str]) -> None:
+    db.query(SysRoleMenu).filter(SysRoleMenu.role_id == role_id).delete(
+        synchronize_session=False
+    )
+    for menu_id in menu_ids:
+        db.add(
+            SysRoleMenu(
+                id=str(uuid.uuid4()),
+                role_id=role_id,
+                menu_id=menu_id,
+            )
+        )
+    db.commit()

+ 32 - 1
backend/app/crud/user.py

@@ -1,5 +1,5 @@
 """用户 CRUD"""
-from typing import Optional, Tuple
+from typing import List, Optional, Tuple
 
 from sqlalchemy.orm import Session
 
@@ -38,6 +38,15 @@ def get_role_by_code(db: Session, role_code: str) -> Optional[SysRole]:
     )
 
 
+def list_users(db: Session) -> List[SysUser]:
+    return (
+        db.query(SysUser)
+        .filter(SysUser.del_flag == "0")
+        .order_by(SysUser.create_time.desc())
+        .all()
+    )
+
+
 def create_user(
     db: Session,
     *,
@@ -61,6 +70,28 @@ def create_user(
     return row
 
 
+def update_user(
+    db: Session,
+    user: SysUser,
+    *,
+    display_name: Optional[str] = None,
+    role_id: Optional[str] = None,
+    status: Optional[str] = None,
+    password_hash: Optional[str] = None,
+) -> SysUser:
+    if display_name is not None:
+        user.display_name = display_name
+    if role_id is not None:
+        user.role_id = role_id
+    if status is not None:
+        user.status = status
+    if password_hash is not None:
+        user.password_hash = password_hash
+    db.commit()
+    db.refresh(user)
+    return user
+
+
 def get_user_with_role(
     db: Session, user_id: str
 ) -> Tuple[Optional[SysUser], Optional[SysRole]]:

+ 37 - 0
backend/app/schemas/system.py

@@ -0,0 +1,37 @@
+"""系统管理 schemas(对外 camelCase)"""
+from typing import List, Optional
+
+from pydantic import BaseModel, ConfigDict
+
+
+def _to_camel(name: str) -> str:
+    parts = name.split("_")
+    return parts[0] + "".join(p.capitalize() for p in parts[1:])
+
+
+class CamelModel(BaseModel):
+    model_config = ConfigDict(
+        alias_generator=_to_camel,
+        populate_by_name=True,
+    )
+
+
+class UserCreateBody(CamelModel):
+    username: str
+    password: str
+    display_name: str
+    role_id: str
+
+
+class UserUpdateBody(CamelModel):
+    display_name: Optional[str] = None
+    role_id: Optional[str] = None
+    status: Optional[str] = None
+
+
+class ResetPasswordBody(CamelModel):
+    password: str
+
+
+class RoleMenusBody(CamelModel):
+    menu_ids: List[str]

+ 155 - 0
backend/tests/test_role_menus.py

@@ -0,0 +1,155 @@
+"""用户管理 / 角色授权 / 操作审计 API 测试"""
+from app.core.database import get_db
+from app.core.security import hash_password
+from app.crud import user as user_crud
+from app.main import app as fastapi_app
+
+
+def _login(client, username: str, password: str) -> dict:
+    r = client.post(
+        "/api/web/auth/login", json={"username": username, "password": password}
+    )
+    assert r.status_code == 200, r.text
+    token = r.json()["data"]["token"]
+    return {"Authorization": f"Bearer {token}"}
+
+
+def _client_db():
+    gen = fastapi_app.dependency_overrides[get_db]()
+    return next(gen)
+
+
+def _ensure_role_user(client, username: str, role_code: str, password: str = "Pass@123"):
+    db = _client_db()
+    role = user_crud.get_role_by_code(db, role_code)
+    assert role is not None
+    existing = user_crud.get_user_by_username(db, username)
+    if not existing:
+        user_crud.create_user(
+            db,
+            username=username,
+            password_hash=hash_password(password),
+            display_name=username,
+            role_id=role.id,
+        )
+    return _login(client, username, password)
+
+
+def test_role_grant_updates_menus(client):
+    admin_headers = _login(client, "admin", "Admin@123")
+
+    roles = client.get("/api/web/admin/roles", headers=admin_headers).json()["data"]
+    viewer = next(r for r in roles if r["roleCode"] == "viewer")
+
+    menus_before = client.get(
+        f"/api/web/admin/roles/{viewer['id']}/menus", headers=admin_headers
+    ).json()["data"]
+    menu_ids = list(menus_before["menuIds"])
+    assert len(menu_ids) >= 1
+
+    tree = client.get("/api/web/admin/menus/tree", headers=admin_headers).json()["data"]
+
+    def _flatten(nodes):
+        out = []
+        for n in nodes or []:
+            out.append(n)
+            out.extend(_flatten(n.get("children") or []))
+        return out
+
+    flat = _flatten(tree)
+    suggestion = next(m for m in flat if m["menuCode"] == "suggestion")
+    new_ids = [mid for mid in menu_ids if mid != suggestion["id"]]
+    assert suggestion["id"] in menu_ids
+
+    put = client.put(
+        f"/api/web/admin/roles/{viewer['id']}/menus",
+        headers=admin_headers,
+        json={"menuIds": new_ids},
+    )
+    assert put.status_code == 200, put.text
+
+    viewer_headers = _ensure_role_user(client, "viewer_grant", "viewer")
+    menus = client.get("/api/web/auth/menus", headers=viewer_headers).json()["data"]
+    tree_codes = {m["menuCode"] for m in _flatten(menus["tree"])}
+    assert "suggestion" not in tree_codes
+    assert "dashboard" in tree_codes
+    assert "feedback" in tree_codes
+
+
+def test_user_manage_crud(client):
+    admin_headers = _login(client, "admin", "Admin@123")
+    roles = client.get("/api/web/admin/roles", headers=admin_headers).json()["data"]
+    handler = next(r for r in roles if r["roleCode"] == "handler")
+
+    created = client.post(
+        "/api/web/admin/users",
+        headers=admin_headers,
+        json={
+            "username": "u_task7",
+            "password": "Pass@123",
+            "displayName": "任务七用户",
+            "roleId": handler["id"],
+        },
+    )
+    assert created.status_code == 200, created.text
+    user = created.json()["data"]
+    assert user["username"] == "u_task7"
+    assert user["displayName"] == "任务七用户"
+    assert user["roleId"] == handler["id"]
+
+    listed = client.get("/api/web/admin/users", headers=admin_headers)
+    assert listed.status_code == 200
+    usernames = {u["username"] for u in listed.json()["data"]}
+    assert "u_task7" in usernames
+
+    updated = client.put(
+        f"/api/web/admin/users/{user['id']}",
+        headers=admin_headers,
+        json={"displayName": "已改名", "status": "1"},
+    )
+    assert updated.status_code == 200
+    assert updated.json()["data"]["displayName"] == "已改名"
+    assert updated.json()["data"]["status"] == "1"
+
+    reset = client.put(
+        f"/api/web/admin/users/{user['id']}/reset-password",
+        headers=admin_headers,
+        json={"password": "NewPass@123"},
+    )
+    assert reset.status_code == 200
+
+    # 启停后不可登录;先启再用新密码登录
+    client.put(
+        f"/api/web/admin/users/{user['id']}",
+        headers=admin_headers,
+        json={"status": "0"},
+    )
+    login = client.post(
+        "/api/web/auth/login",
+        json={"username": "u_task7", "password": "NewPass@123"},
+    )
+    assert login.status_code == 200
+
+
+def test_oper_logs_list_requires_audit(client):
+    admin_headers = _login(client, "admin", "Admin@123")
+    r = client.get(
+        "/api/web/admin/oper-logs",
+        headers=admin_headers,
+        params={"page": 1, "pageSize": 10},
+    )
+    assert r.status_code == 200
+    data = r.json()["data"]
+    assert "items" in data
+    assert "total" in data
+    assert data["page"] == 1
+    assert data["pageSize"] == 10
+    assert data["total"] >= 1  # login 已写日志
+
+    viewer_headers = _ensure_role_user(client, "viewer_audit", "viewer")
+    denied = client.get(
+        "/api/web/admin/oper-logs",
+        headers=viewer_headers,
+        params={"page": 1, "pageSize": 10},
+    )
+    assert denied.status_code == 403