Browse Source

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

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 months ago
parent
commit
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 fastapi import APIRouter, Depends, File, Form, Query, UploadFile
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
@@ -8,15 +8,69 @@ from app.core import storage
 from app.core.database import get_db
 from app.core.database import get_db
 from app.core.deps import get_current_user, require_menu
 from app.core.deps import get_current_user, require_menu
 from app.core.response import AppHTTPError, success_response
 from app.core.response import AppHTTPError, success_response
+from app.core.security import hash_password
 from app.core.storage import StorageError
 from app.core.storage import StorageError
 from app.crud import attachment as attachment_crud
 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.models.system import SysUser
 from app.schemas.business import HotlineUpdateBody, QrcodeUpdateBody, StatusUpdateBody
 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"])
 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")
 @router.get("/dashboard")
 def dashboard(
 def dashboard(
     db: Annotated[Session, Depends(get_db)],
     db: Annotated[Session, Depends(get_db)],
@@ -205,3 +259,155 @@ async def admin_upload(
             "size": row.size,
             "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:
 def role_has_any_menu_code(db: Session, role_id: str, codes: Set[str]) -> bool:
     if not codes:
     if not codes:
         return False
         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"""
 """用户 CRUD"""
-from typing import Optional, Tuple
+from typing import List, Optional, Tuple
 
 
 from sqlalchemy.orm import Session
 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(
 def create_user(
     db: Session,
     db: Session,
     *,
     *,
@@ -61,6 +70,28 @@ def create_user(
     return row
     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(
 def get_user_with_role(
     db: Session, user_id: str
     db: Session, user_id: str
 ) -> Tuple[Optional[SysUser], Optional[SysRole]]:
 ) -> 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