feat: 添加登录日志和操作日志功能

1. 新增日志相关数据库模型、API路由、前端页面与权限配置
2. 为现有业务接口添加操作日志记录功能
3. 优化导入顺序与权限初始化数据
This commit is contained in:
jayhgq 2026-06-12 22:41:41 +08:00
parent acb86e4fe1
commit 19d6db6ecc
19 changed files with 1173 additions and 77 deletions

View File

View File

@ -0,0 +1,116 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, delete
from database import get_db
from models import LoginLog, OperationLog, User
from schemas import (
LoginLogResponse,
LoginLogSearch,
LoginLogCleanRequest,
OperationLogResponse,
OperationLogSearch,
)
from middleware import get_current_user, has_permission
from utils.like_filter import ilike_contains
router = APIRouter(tags=["logs"])
# ========================================
# 登录日志
# ========================================
@router.post("/login-logs/search", response_model=list[LoginLogResponse])
async def search_login_logs(
query: LoginLogSearch,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(has_permission("log:login")),
):
"""搜索登录日志"""
stmt = select(LoginLog)
filters = []
cond = ilike_contains(LoginLog.username, query.username)
if cond is not None:
filters.append(cond)
if query.status:
filters.append(LoginLog.status == query.status)
if query.start_time:
filters.append(LoginLog.login_time >= query.start_time)
if query.end_time:
filters.append(LoginLog.login_time <= query.end_time)
if filters:
stmt = stmt.where(and_(*filters))
stmt = stmt.order_by(LoginLog.login_time.desc())
result = await db.execute(stmt)
rows = result.scalars().all()
return [LoginLogResponse.model_validate(r) for r in rows]
@router.delete("/login-logs/clean")
async def clean_login_logs(
clean_data: LoginLogCleanRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(has_permission("log:clean")),
):
"""清理指定日期之前的登录日志"""
stmt = delete(LoginLog).where(LoginLog.login_time < clean_data.before_date)
result = await db.execute(stmt)
await db.commit()
return {
"message": f"已清理 {result.rowcount} 条登录日志",
"deleted_count": result.rowcount,
}
# ========================================
# 操作日志
# ========================================
@router.post("/operation-logs/search", response_model=list[OperationLogResponse])
async def search_operation_logs(
query: OperationLogSearch,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(has_permission("log:operation")),
):
"""搜索操作日志"""
stmt = select(OperationLog)
filters = []
cond = ilike_contains(OperationLog.username, query.username)
if cond is not None:
filters.append(cond)
if query.action_type:
filters.append(OperationLog.action_type == query.action_type)
if query.target_type:
filters.append(OperationLog.target_type == query.target_type)
if query.start_time:
filters.append(OperationLog.createtime >= query.start_time)
if query.end_time:
filters.append(OperationLog.createtime <= query.end_time)
if filters:
stmt = stmt.where(and_(*filters))
stmt = stmt.order_by(OperationLog.createtime.desc())
result = await db.execute(stmt)
rows = result.scalars().all()
return [OperationLogResponse.model_validate(r) for r in rows]
@router.delete("/operation-logs/clean")
async def clean_operation_logs(
clean_data: LoginLogCleanRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(has_permission("log:clean")),
):
"""清理指定日期之前的操作日志"""
stmt = delete(OperationLog).where(OperationLog.createtime < clean_data.before_date)
result = await db.execute(stmt)
await db.commit()
return {
"message": f"已清理 {result.rowcount} 条操作日志",
"deleted_count": result.rowcount,
}

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_ from sqlalchemy import select, and_
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@ -17,6 +17,7 @@ from schemas import (
from middleware import get_current_user from middleware import get_current_user
from models import User from models import User
from utils.like_filter import ilike_contains from utils.like_filter import ilike_contains
from utils.log_utils import get_client_ip, get_user_agent, create_operation_log
router = APIRouter(prefix="/permissions", tags=["permissions"]) router = APIRouter(prefix="/permissions", tags=["permissions"])
@ -27,6 +28,7 @@ permission_crud = FastCRUD(Permission, PermissionResponse)
@router.post("/", response_model=PermissionResponse) @router.post("/", response_model=PermissionResponse)
async def create_permission( async def create_permission(
permission_data: PermissionCreate, permission_data: PermissionCreate,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -46,15 +48,24 @@ async def create_permission(
) )
db.add(db_permission) db.add(db_permission)
await db.commit() await db.flush()
await db.refresh(db_permission) await db.refresh(db_permission)
# 记录操作日志
await create_operation_log(
db, current_user.id, current_user.username, "create", "permission",
db_permission.id, db_permission.name,
{"name": permission_data.name, "code": permission_data.code},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return db_permission return db_permission
@router.post("/search", response_model=list[PermissionResponse]) @router.post("/search", response_model=list[PermissionResponse])
async def search_permissions( async def search_permissions(
query: dict = {}, query: dict = {},
request: Request = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -70,11 +81,21 @@ async def search_permissions(
stmt = stmt.where(and_(*filters)) stmt = stmt.where(and_(*filters))
result = await db.execute(stmt) result = await db.execute(stmt)
permissions = result.scalars().all() permissions = result.scalars().all()
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "permission",
None, None, {"filters": {k: v for k, v in query.items() if v}},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return [PermissionResponse.model_validate(p) for p in permissions] return [PermissionResponse.model_validate(p) for p in permissions]
@router.get("/tree", response_model=list[PermissionTreeResponse]) @router.get("/tree", response_model=list[PermissionTreeResponse])
async def get_permission_tree( async def get_permission_tree(
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -89,6 +110,7 @@ async def get_permission_tree(
"name": perm.name, "name": perm.name,
"code": perm.code, "code": perm.code,
"parentid": perm.parentid, "parentid": perm.parentid,
"route": perm.route,
"description": perm.description, "description": perm.description,
"createtime": perm.createtime, "createtime": perm.createtime,
"updatetime": perm.updatetime, "updatetime": perm.updatetime,
@ -106,11 +128,20 @@ async def get_permission_tree(
# 子节点,添加到父节点的 children # 子节点,添加到父节点的 children
perm_dict[parentid]["children"].append(perm_data) perm_dict[parentid]["children"].append(perm_data)
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "permission",
None, None, None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return tree return tree
@router.get("/menu", response_model=list[PermissionTreeResponse]) @router.get("/menu", response_model=list[PermissionTreeResponse])
async def get_menu( async def get_menu(
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -122,20 +153,20 @@ async def get_menu(
.filter(Role.id == current_user.role_id) .filter(Role.id == current_user.role_id)
) )
role = result.scalar_one_or_none() role = result.scalar_one_or_none()
if not role: if not role:
raise HTTPException(status_code=404, detail="角色不存在") raise HTTPException(status_code=404, detail="角色不存在")
# 获取用户有权限的权限编码集合 # 获取用户有权限的权限编码集合
user_permission_codes = {perm.code for perm in role.permissions} user_permission_codes = {perm.code for perm in role.permissions}
# 获取所有顶级模块和二级模块权限有route字段的 # 获取所有顶级模块和二级模块权限有route字段的
result = await db.execute( result = await db.execute(
select(Permission) select(Permission)
.filter(Permission.route.isnot(None)) .filter(Permission.route.isnot(None))
) )
all_menu_permissions = result.scalars().all() all_menu_permissions = result.scalars().all()
# 过滤用户有权限的菜单 # 过滤用户有权限的菜单
user_menu_permissions = [] user_menu_permissions = []
for perm in all_menu_permissions: for perm in all_menu_permissions:
@ -147,7 +178,7 @@ async def get_menu(
break break
if has_access: if has_access:
user_menu_permissions.append(perm) user_menu_permissions.append(perm)
# 转换为字典,便于查找 # 转换为字典,便于查找
perm_dict = {perm.id: { perm_dict = {perm.id: {
"id": perm.id, "id": perm.id,
@ -160,7 +191,7 @@ async def get_menu(
"updatetime": perm.updatetime, "updatetime": perm.updatetime,
"children": [] "children": []
} for perm in user_menu_permissions} } for perm in user_menu_permissions}
# 构建树形结构 # 构建树形结构
tree = [] tree = []
for perm_id, perm_data in perm_dict.items(): for perm_id, perm_data in perm_dict.items():
@ -171,13 +202,22 @@ async def get_menu(
elif parentid in perm_dict: elif parentid in perm_dict:
# 子节点,添加到父节点的 children # 子节点,添加到父节点的 children
perm_dict[parentid]["children"].append(perm_data) perm_dict[parentid]["children"].append(perm_data)
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "permission",
None, None, {"type": "menu_tree"},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return tree return tree
@router.get("/{permission_id}", response_model=PermissionResponse) @router.get("/{permission_id}", response_model=PermissionResponse)
async def get_permission( async def get_permission(
permission_id: int, permission_id: int,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -185,6 +225,15 @@ async def get_permission(
permission = await permission_crud.get(db, id=permission_id) permission = await permission_crud.get(db, id=permission_id)
if not permission: if not permission:
raise HTTPException(status_code=404, detail="权限不存在") raise HTTPException(status_code=404, detail="权限不存在")
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "permission",
permission_id, permission.name, None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return permission return permission
@ -192,10 +241,17 @@ async def get_permission(
async def update_permission( async def update_permission(
permission_id: int, permission_id: int,
permission_data: PermissionCreate, permission_data: PermissionCreate,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""更新权限""" """更新权限"""
# 获取原权限数据
result = await db.execute(select(Permission).filter(Permission.id == permission_id))
old_permission = result.scalar_one_or_none()
if not old_permission:
raise HTTPException(status_code=404, detail="权限不存在")
# 检查权限编码是否被其他权限使用 # 检查权限编码是否被其他权限使用
result = await db.execute( result = await db.execute(
select(Permission).filter(Permission.code == permission_data.code, Permission.id != permission_id) select(Permission).filter(Permission.code == permission_data.code, Permission.id != permission_id)
@ -213,12 +269,27 @@ async def update_permission(
) )
if not updated_permission: if not updated_permission:
raise HTTPException(status_code=404, detail="权限不存在") raise HTTPException(status_code=404, detail="权限不存在")
# 记录操作日志
changed = {}
for field in ["name", "code", "parentid", "description"]:
old_val = getattr(old_permission, field, None)
new_val = update_data.get(field)
if old_val != new_val:
changed[field] = {"old": old_val, "new": new_val}
await create_operation_log(
db, current_user.id, current_user.username, "update", "permission",
permission_id, old_permission.name, changed if changed else None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return updated_permission return updated_permission
@router.delete("/{permission_id}") @router.delete("/{permission_id}")
async def delete_permission( async def delete_permission(
permission_id: int, permission_id: int,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -228,6 +299,13 @@ async def delete_permission(
existing_permission = result.scalar_one_or_none() existing_permission = result.scalar_one_or_none()
if not existing_permission: if not existing_permission:
raise HTTPException(status_code=404, detail="权限不存在") raise HTTPException(status_code=404, detail="权限不存在")
# 记录操作日志(删除前)
await create_operation_log(
db, current_user.id, current_user.username, "delete", "permission",
permission_id, existing_permission.name,
{"name": existing_permission.name, "code": existing_permission.code},
get_client_ip(request), get_user_agent(request),
)
# 1. 删除角色-权限关联表中的记录 # 1. 删除角色-权限关联表中的记录
from sqlalchemy import delete as sql_delete from sqlalchemy import delete as sql_delete
@ -256,6 +334,7 @@ async def delete_permission(
@router.post("/assign", response_model=RolePermissionResponse) @router.post("/assign", response_model=RolePermissionResponse)
async def assign_permissions_to_role( async def assign_permissions_to_role(
data: RolePermissionAssign, data: RolePermissionAssign,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -276,6 +355,14 @@ async def assign_permissions_to_role(
# 更新角色的权限 # 更新角色的权限
role.permissions = permissions role.permissions = permissions
# 记录操作日志
await create_operation_log(
db, current_user.id, current_user.username, "update", "role",
role.id, role.name,
{"permission_ids": data.permission_ids},
get_client_ip(request), get_user_agent(request),
)
await db.commit() await db.commit()
await db.refresh(role) await db.refresh(role)
@ -290,6 +377,7 @@ async def assign_permissions_to_role(
@router.get("/role/{role_id}", response_model=RolePermissionResponse) @router.get("/role/{role_id}", response_model=RolePermissionResponse)
async def get_role_permissions( async def get_role_permissions(
role_id: int, role_id: int,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -300,6 +388,14 @@ async def get_role_permissions(
if not role: if not role:
raise HTTPException(status_code=404, detail="角色不存在") raise HTTPException(status_code=404, detail="角色不存在")
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "permission",
role_id, role.name, {"type": "role_permissions"},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
# 返回角色权限信息 # 返回角色权限信息
return RolePermissionResponse( return RolePermissionResponse(
role_id=role.id, role_id=role.id,
@ -310,6 +406,7 @@ async def get_role_permissions(
@router.get("/user/me", response_model=RolePermissionResponse) @router.get("/user/me", response_model=RolePermissionResponse)
async def get_current_user_permissions( async def get_current_user_permissions(
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -321,20 +418,20 @@ async def get_current_user_permissions(
.filter(Role.id == current_user.role_id) .filter(Role.id == current_user.role_id)
) )
role = result.scalar_one_or_none() role = result.scalar_one_or_none()
if not role: if not role:
raise HTTPException(status_code=404, detail="角色不存在") raise HTTPException(status_code=404, detail="角色不存在")
# 获取用户有权限的权限编码集合 # 获取用户有权限的权限编码集合
user_permission_codes = {perm.code for perm in role.permissions} user_permission_codes = {perm.code for perm in role.permissions}
# 获取所有顶级模块和二级模块权限有route字段的 # 获取所有顶级模块和二级模块权限有route字段的
result = await db.execute( result = await db.execute(
select(Permission) select(Permission)
.filter(Permission.route.isnot(None)) .filter(Permission.route.isnot(None))
) )
all_menu_permissions = result.scalars().all() all_menu_permissions = result.scalars().all()
# 过滤用户有权限的菜单 # 过滤用户有权限的菜单
user_menu_permissions = [] user_menu_permissions = []
for perm in all_menu_permissions: for perm in all_menu_permissions:
@ -346,7 +443,7 @@ async def get_current_user_permissions(
break break
if has_access: if has_access:
user_menu_permissions.append(perm) user_menu_permissions.append(perm)
# 转换为字典,便于查找 # 转换为字典,便于查找
perm_dict = {perm.id: { perm_dict = {perm.id: {
"id": perm.id, "id": perm.id,
@ -359,7 +456,7 @@ async def get_current_user_permissions(
"updatetime": perm.updatetime, "updatetime": perm.updatetime,
"children": [] "children": []
} for perm in user_menu_permissions} } for perm in user_menu_permissions}
# 构建树形结构 # 构建树形结构
menu_tree = [] menu_tree = []
for perm_id, perm_data in perm_dict.items(): for perm_id, perm_data in perm_dict.items():
@ -371,6 +468,14 @@ async def get_current_user_permissions(
# 子节点,添加到父节点的 children # 子节点,添加到父节点的 children
perm_dict[parentid]["children"].append(perm_data) perm_dict[parentid]["children"].append(perm_data)
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "permission",
None, None, {"type": "user_permissions_menu"},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
# 返回用户权限信息和菜单 # 返回用户权限信息和菜单
return RolePermissionResponse( return RolePermissionResponse(
role_id=role.id, role_id=role.id,

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from fastcrud import FastCRUD from fastcrud import FastCRUD
@ -8,6 +8,7 @@ from models import Role, User
from schemas import RoleCreate, RoleResponse from schemas import RoleCreate, RoleResponse
from middleware import get_current_user from middleware import get_current_user
from utils.like_filter import ilike_contains from utils.like_filter import ilike_contains
from utils.log_utils import get_client_ip, get_user_agent, create_operation_log
router = APIRouter(prefix="/roles", tags=["roles"]) router = APIRouter(prefix="/roles", tags=["roles"])
@ -18,13 +19,12 @@ role_crud = FastCRUD(Role, RoleResponse, RoleCreate)
@router.post("/", response_model=RoleResponse) @router.post("/", response_model=RoleResponse)
async def create_role( async def create_role(
role_data: RoleCreate, role_data: RoleCreate,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""创建角色""" """创建角色"""
# 检查角色名是否已存在 # 检查角色名是否已存在
from sqlalchemy import select
result = await db.execute(select(Role).filter(Role.name == role_data.name)) result = await db.execute(select(Role).filter(Role.name == role_data.name))
existing_role = result.scalar_one_or_none() existing_role = result.scalar_one_or_none()
if existing_role: if existing_role:
@ -38,18 +38,26 @@ async def create_role(
name=role_data.name, name=role_data.name,
creator=role_data.creator creator=role_data.creator
) )
db.add(new_role) db.add(new_role)
await db.flush() # 获取生成的ID await db.flush() # 获取生成的ID
await db.refresh(new_role) await db.refresh(new_role)
# 记录操作日志
await create_operation_log(
db, current_user.id, current_user.username, "create", "role",
new_role.id, new_role.name, {"name": role_data.name},
get_client_ip(request), get_user_agent(request),
)
await db.commit() await db.commit()
return RoleResponse.model_validate(new_role) return RoleResponse.model_validate(new_role)
@router.post("/search", response_model=list[RoleResponse]) @router.post("/search", response_model=list[RoleResponse])
async def search_roles( async def search_roles(
query: dict = {}, query: dict = {},
request: Request = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -61,12 +69,22 @@ async def search_roles(
stmt = stmt.where(cond) stmt = stmt.where(cond)
result = await db.execute(stmt) result = await db.execute(stmt)
rows = result.scalars().all() rows = result.scalars().all()
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "role",
None, None, {"filters": {k: v for k, v in query.items() if v}},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return [RoleResponse.model_validate(r) for r in rows] return [RoleResponse.model_validate(r) for r in rows]
@router.get("/{role_id}", response_model=RoleResponse) @router.get("/{role_id}", response_model=RoleResponse)
async def get_role( async def get_role(
role_id: int, role_id: int,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -75,6 +93,15 @@ async def get_role(
role = result.scalar_one_or_none() role = result.scalar_one_or_none()
if not role: if not role:
raise HTTPException(status_code=404, detail="角色不存在") raise HTTPException(status_code=404, detail="角色不存在")
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "role",
role_id, role.name, None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return RoleResponse.model_validate(role) return RoleResponse.model_validate(role)
@ -82,13 +109,18 @@ async def get_role(
async def update_role( async def update_role(
role_id: int, role_id: int,
role_data: RoleCreate, role_data: RoleCreate,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""更新角色""" """更新角色"""
# 检查角色名是否被其他角色使用 # 获取原角色数据
from sqlalchemy import select result = await db.execute(select(Role).filter(Role.id == role_id))
old_role = result.scalar_one_or_none()
if not old_role:
raise HTTPException(status_code=404, detail="角色不存在")
# 检查角色名是否被其他角色使用
result = await db.execute( result = await db.execute(
select(Role).filter(Role.name == role_data.name, Role.id != role_id) select(Role).filter(Role.name == role_data.name, Role.id != role_id)
) )
@ -98,29 +130,39 @@ async def update_role(
# 使用原生 SQLAlchemy 更新角色FastCRUD 有问题) # 使用原生 SQLAlchemy 更新角色FastCRUD 有问题)
from sqlalchemy import update from sqlalchemy import update
stmt = ( stmt = (
update(Role) update(Role)
.where(Role.id == role_id) .where(Role.id == role_id)
.values(name=role_data.name) .values(name=role_data.name)
) )
result = await db.execute(stmt) result = await db.execute(stmt)
if result.rowcount == 0: if result.rowcount == 0:
raise HTTPException(status_code=404, detail="角色不存在") raise HTTPException(status_code=404, detail="角色不存在")
# 记录操作日志
changed = {}
if old_role.name != role_data.name:
changed["name"] = {"old": old_role.name, "new": role_data.name}
await create_operation_log(
db, current_user.id, current_user.username, "update", "role",
role_id, old_role.name, changed if changed else None,
get_client_ip(request), get_user_agent(request),
)
await db.commit() await db.commit()
# 查询更新后的数据 # 查询更新后的数据
result = await db.execute(select(Role).filter(Role.id == role_id)) result = await db.execute(select(Role).filter(Role.id == role_id))
updated_role = result.scalar_one_or_none() updated_role = result.scalar_one_or_none()
return updated_role return updated_role
@router.delete("/{role_id}") @router.delete("/{role_id}")
async def delete_role( async def delete_role(
role_id: int, role_id: int,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -139,6 +181,13 @@ async def delete_role(
status_code=400, detail=f"该角色正在被 {len(users)} 个用户使用,无法删除" status_code=400, detail=f"该角色正在被 {len(users)} 个用户使用,无法删除"
) )
# 记录操作日志(删除前)
await create_operation_log(
db, current_user.id, current_user.username, "delete", "role",
role_id, existing_role.name, {"name": existing_role.name},
get_client_ip(request), get_user_agent(request),
)
# 使用FastCRUD删除角色 # 使用FastCRUD删除角色
await role_crud.delete(db, id=role_id) await role_crud.delete(db, id=role_id)
return {"message": "角色删除成功"} return {"message": "角色删除成功"}

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile from fastapi import APIRouter, Depends, HTTPException, File, UploadFile, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_ from sqlalchemy import select, and_
from fastcrud import FastCRUD from fastcrud import FastCRUD
@ -16,6 +16,7 @@ from schemas import (
) )
from middleware import get_current_user from middleware import get_current_user
from utils.like_filter import ilike_contains from utils.like_filter import ilike_contains
from utils.log_utils import get_client_ip, get_user_agent, create_operation_log
router = APIRouter(prefix="/settings", tags=["settings"]) router = APIRouter(prefix="/settings", tags=["settings"])
@ -30,6 +31,7 @@ UPLOAD_DIR.mkdir(exist_ok=True)
@router.post("/", response_model=SystemSettingResponse) @router.post("/", response_model=SystemSettingResponse)
async def create_setting( async def create_setting(
setting_data: SystemSettingCreate, setting_data: SystemSettingCreate,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -50,20 +52,39 @@ async def create_setting(
) )
db.add(db_setting) db.add(db_setting)
await db.commit() await db.flush()
await db.refresh(db_setting) await db.refresh(db_setting)
# 记录操作日志
await create_operation_log(
db, current_user.id, current_user.username, "create", "setting",
db_setting.id, db_setting.key,
{"key": setting_data.key, "type": setting_data.type},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return db_setting return db_setting
@router.get("/", response_model=list[SystemSettingResponse]) @router.get("/", response_model=list[SystemSettingResponse])
async def get_settings( async def get_settings(
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""获取所有设置项""" """获取所有设置项"""
result = await db.execute(select(SystemSetting)) result = await db.execute(select(SystemSetting))
settings = result.scalars().all() settings = result.scalars().all()
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "setting",
None, None, None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return [SystemSettingResponse.model_validate(s) for s in settings] return [SystemSettingResponse.model_validate(s) for s in settings]
@ -97,6 +118,7 @@ async def get_public_setting(
@router.get("/{setting_id}", response_model=SystemSettingResponse) @router.get("/{setting_id}", response_model=SystemSettingResponse)
async def get_setting( async def get_setting(
setting_id: int, setting_id: int,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -104,12 +126,22 @@ async def get_setting(
setting = await setting_crud.get(db, id=setting_id) setting = await setting_crud.get(db, id=setting_id)
if not setting: if not setting:
raise HTTPException(status_code=404, detail="设置项不存在") raise HTTPException(status_code=404, detail="设置项不存在")
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "setting",
setting_id, setting.key, None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return setting return setting
@router.get("/key/{key}", response_model=SystemSettingResponse) @router.get("/key/{key}", response_model=SystemSettingResponse)
async def get_setting_by_key( async def get_setting_by_key(
key: str, key: str,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -118,6 +150,15 @@ async def get_setting_by_key(
setting = result.scalar_one_or_none() setting = result.scalar_one_or_none()
if not setting: if not setting:
raise HTTPException(status_code=404, detail="设置项不存在") raise HTTPException(status_code=404, detail="设置项不存在")
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "setting",
setting.id, setting.key, None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return setting return setting
@ -125,10 +166,17 @@ async def get_setting_by_key(
async def update_setting( async def update_setting(
setting_id: int, setting_id: int,
setting_data: SystemSettingUpdate, setting_data: SystemSettingUpdate,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""更新设置项""" """更新设置项"""
# 获取原设置数据
result = await db.execute(select(SystemSetting).filter(SystemSetting.id == setting_id))
old_setting = result.scalar_one_or_none()
if not old_setting:
raise HTTPException(status_code=404, detail="设置项不存在")
# 检查设置键名是否被其他设置使用 # 检查设置键名是否被其他设置使用
if setting_data.key: if setting_data.key:
result = await db.execute( result = await db.execute(
@ -147,12 +195,28 @@ async def update_setting(
) )
if not updated_setting: if not updated_setting:
raise HTTPException(status_code=404, detail="设置项不存在") raise HTTPException(status_code=404, detail="设置项不存在")
# 记录操作日志
changed = {}
for field in ["key", "value", "type", "description"]:
old_val = getattr(old_setting, field, None)
new_val = update_data.get(field) if field in update_data else old_val
if old_val != new_val:
changed[field] = {"old": old_val, "new": new_val}
await create_operation_log(
db, current_user.id, current_user.username, "update", "setting",
setting_id, old_setting.key, changed if changed else None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return updated_setting return updated_setting
@router.delete("/{setting_id}") @router.delete("/{setting_id}")
async def delete_setting( async def delete_setting(
setting_id: int, setting_id: int,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -163,6 +227,14 @@ async def delete_setting(
if not existing_setting: if not existing_setting:
raise HTTPException(status_code=404, detail="设置项不存在") raise HTTPException(status_code=404, detail="设置项不存在")
# 记录操作日志(删除前)
await create_operation_log(
db, current_user.id, current_user.username, "delete", "setting",
setting_id, existing_setting.key,
{"key": existing_setting.key, "value": existing_setting.value},
get_client_ip(request), get_user_agent(request),
)
# 使用FastCRUD删除设置 # 使用FastCRUD删除设置
deleted = await setting_crud.delete(db, id=setting_id) deleted = await setting_crud.delete(db, id=setting_id)
return {"message": "设置项删除成功"} return {"message": "设置项删除成功"}
@ -186,7 +258,7 @@ async def upload_image(
if file_size > 2 * 1024 * 1024: if file_size > 2 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件大小不能超过2MB") raise HTTPException(status_code=400, detail="文件大小不能超过2MB")
chunk = await file.read(1024) chunk = await file.read(1024)
# 重置文件指针 # 重置文件指针
await file.seek(0) await file.seek(0)

View File

@ -3,6 +3,7 @@ from .users.urls import router as users_router
from .roles.urls import router as roles_router from .roles.urls import router as roles_router
from .permissions.urls import router as permissions_router from .permissions.urls import router as permissions_router
from .settings.urls import router as settings_router from .settings.urls import router as settings_router
from .logs.urls import router as logs_router
# 创建主路由 # 创建主路由
api_router = APIRouter() api_router = APIRouter()
@ -11,4 +12,5 @@ api_router = APIRouter()
api_router.include_router(users_router) api_router.include_router(users_router)
api_router.include_router(roles_router) api_router.include_router(roles_router)
api_router.include_router(permissions_router) api_router.include_router(permissions_router)
api_router.include_router(settings_router) api_router.include_router(settings_router)
api_router.include_router(logs_router)

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
from fastapi.responses import Response from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_ from sqlalchemy import select, and_
@ -19,10 +19,11 @@ from schemas import (
from utils.password import verify_password, get_password_hash from utils.password import verify_password, get_password_hash
from utils.jwt import create_access_token from utils.jwt import create_access_token
from config import app_settings from config import app_settings
from utils.redis_client import get_redis, set_token_in_redis from utils.redis_client import get_redis, set_token_in_redis, delete_token_from_redis
from utils.captcha import create_captcha, validate_captcha from utils.captcha import create_captcha, validate_captcha
from middleware import get_current_user from middleware import get_current_user
from utils.like_filter import ilike_contains from utils.like_filter import ilike_contains
from utils.log_utils import get_client_ip, get_user_agent, save_login_log, create_operation_log
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@ -35,26 +36,31 @@ async def get_captcha(redis_conn: redis.Redis = Depends(get_redis)):
"""获取验证码图片""" """获取验证码图片"""
# 创建验证码 # 创建验证码
captcha_id, image_bytes = await create_captcha(redis_conn) captcha_id, image_bytes = await create_captcha(redis_conn)
# 返回验证码图片同时在响应头中返回验证码ID # 返回验证码图片同时在响应头中返回验证码ID
response = Response(content=image_bytes, media_type="image/png") response = Response(content=image_bytes, media_type="image/png")
response.headers["X-Captcha-ID"] = captcha_id response.headers["X-Captcha-ID"] = captcha_id
return response return response
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
async def login( async def login(
user_data: UserLogin, user_data: UserLogin,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
redis_conn: redis.Redis = Depends(get_redis), redis_conn: redis.Redis = Depends(get_redis),
): ):
"""用户登录""" """用户登录"""
ip = get_client_ip(request)
ua = get_user_agent(request)
# 验证验证码 # 验证验证码
is_captcha_valid = await validate_captcha( is_captcha_valid = await validate_captcha(
redis_conn, user_data.captcha_id, user_data.captcha_code redis_conn, user_data.captcha_id, user_data.captcha_code
) )
if not is_captcha_valid: if not is_captcha_valid:
await save_login_log(db, user_data.username, ip, ua, "failure", "验证码错误或已过期")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="验证码错误或已过期", detail="验证码错误或已过期",
@ -67,6 +73,7 @@ async def login(
# 验证用户是否存在且密码正确 # 验证用户是否存在且密码正确
if not user or not verify_password(user_data.password, user.password_hash): if not user or not verify_password(user_data.password, user.password_hash):
await save_login_log(db, user_data.username, ip, ua, "failure", "用户名或密码错误")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误", detail="用户名或密码错误",
@ -88,12 +95,16 @@ async def login(
user.lastlogintime = datetime.utcnow() user.lastlogintime = datetime.utcnow()
await db.commit() await db.commit()
# 记录登录成功日志
await save_login_log(db, user.username, ip, ua, "success", None)
return {"access_token": access_token, "token_type": "bearer"} return {"access_token": access_token, "token_type": "bearer"}
@router.post("/", response_model=UserResponse) @router.post("/", response_model=UserResponse)
async def create_user( async def create_user(
user_data: UserCreateRequest, user_data: UserCreateRequest,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -118,12 +129,23 @@ async def create_user(
created_user = await user_crud.create( created_user = await user_crud.create(
db, user_data_create, schema_to_select=UserResponse, return_as_model=True db, user_data_create, schema_to_select=UserResponse, return_as_model=True
) )
# 记录操作日志
await create_operation_log(
db, current_user.id, current_user.username, "create", "user",
created_user.id, created_user.username,
{"username": user_data.username, "nickname": user_data.nickname},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return created_user return created_user
@router.post("/search", response_model=list[UserResponse]) @router.post("/search", response_model=list[UserResponse])
async def search_users( async def search_users(
query: dict = {}, query: dict = {},
request: Request = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -144,18 +166,39 @@ async def search_users(
stmt = stmt.where(and_(*filters)) stmt = stmt.where(and_(*filters))
result = await db.execute(stmt) result = await db.execute(stmt)
rows = result.scalars().all() rows = result.scalars().all()
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "user",
None, None, {"filters": {k: v for k, v in query.items() if v}},
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return [UserResponse.model_validate(u) for u in rows] return [UserResponse.model_validate(u) for u in rows]
@router.get("/me", response_model=UserResponse) @router.get("/me", response_model=UserResponse)
async def get_current_user_info(current_user: User = Depends(get_current_user)): async def get_current_user_info(
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取当前用户信息(须声明在 /{user_id} 之前,否则会被当成 id""" """获取当前用户信息(须声明在 /{user_id} 之前,否则会被当成 id"""
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "user",
current_user.id, current_user.username, None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return current_user return current_user
@router.get("/{user_id}", response_model=UserResponse) @router.get("/{user_id}", response_model=UserResponse)
async def get_user( async def get_user(
user_id: int, user_id: int,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -163,6 +206,15 @@ async def get_user(
user = await user_crud.get(db, id=user_id) user = await user_crud.get(db, id=user_id)
if not user: if not user:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
# 记录查询日志
await create_operation_log(
db, current_user.id, current_user.username, "search", "user",
user_id, user.username, None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return user return user
@ -170,10 +222,17 @@ async def get_user(
async def update_user( async def update_user(
user_id: int, user_id: int,
user_data: UserUpdate, user_data: UserUpdate,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""更新用户""" """更新用户"""
# 获取原用户数据用于记录变更
result = await db.execute(select(User).filter(User.id == user_id))
old_user = result.scalar_one_or_none()
if not old_user:
raise HTTPException(status_code=404, detail="用户不存在")
# 检查用户名是否被其他用户使用 # 检查用户名是否被其他用户使用
result = await db.execute( result = await db.execute(
select(User).filter(User.username == user_data.username, User.id != user_id) select(User).filter(User.username == user_data.username, User.id != user_id)
@ -199,12 +258,28 @@ async def update_user(
) )
if not updated_user: if not updated_user:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
# 记录操作日志(计算变更字段)
changed = {}
for field in ["username", "nickname", "email", "phone", "role_id"]:
old_val = getattr(old_user, field, None)
new_val = update_data.get(field)
if old_val != new_val:
changed[field] = {"old": old_val, "new": new_val}
await create_operation_log(
db, current_user.id, current_user.username, "update", "user",
user_id, old_user.username, changed if changed else None,
get_client_ip(request), get_user_agent(request),
)
await db.commit()
return updated_user return updated_user
@router.delete("/{user_id}") @router.delete("/{user_id}")
async def delete_user( async def delete_user(
user_id: int, user_id: int,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -219,6 +294,13 @@ async def delete_user(
elif existing_user.role_id == 1: elif existing_user.role_id == 1:
raise HTTPException(status_code=400, detail="不能删除管理员用户") raise HTTPException(status_code=400, detail="不能删除管理员用户")
else: else:
# 记录操作日志(删除前记录被删用户信息)
await create_operation_log(
db, current_user.id, current_user.username, "delete", "user",
user_id, existing_user.username,
{"username": existing_user.username, "nickname": existing_user.nickname},
get_client_ip(request), get_user_agent(request),
)
# 使用FastCRUD删除用户 # 使用FastCRUD删除用户
deleted = await user_crud.delete(db, id=user_id) deleted = await user_crud.delete(db, id=user_id)
return {"message": "用户删除成功"} return {"message": "用户删除成功"}
@ -226,11 +308,18 @@ async def delete_user(
@router.post("/logout") @router.post("/logout")
async def logout( async def logout(
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
redis_conn: redis.Redis = Depends(get_redis), redis_conn: redis.Redis = Depends(get_redis),
): ):
"""用户登出""" """用户登出"""
from utils.redis_client import delete_token_from_redis
await delete_token_from_redis(redis_conn, current_user.id) await delete_token_from_redis(redis_conn, current_user.id)
# 记录登出日志
await save_login_log(
db, current_user.username,
get_client_ip(request), get_user_agent(request),
"success", None,
)
return {"message": "登出成功"} return {"message": "登出成功"}

View File

@ -14,7 +14,8 @@ async def init_permissions(db: AsyncSession):
# ======================================== # ========================================
{"name": "权限管理", "code": "perm", "parentid": 0, "route": "/admin/permissions", "description": "权限管理模块"}, {"name": "权限管理", "code": "perm", "parentid": 0, "route": "/admin/permissions", "description": "权限管理模块"},
{"name": "系统设置", "code": "system", "parentid": 0, "route": "/admin/settings", "description": "系统设置模块"}, {"name": "系统设置", "code": "system", "parentid": 0, "route": "/admin/settings", "description": "系统设置模块"},
{"name": "日志管理", "code": "log", "parentid": 0, "route": "/admin/logs", "description": "日志管理模块"},
# ======================================== # ========================================
# 权限管理 -> 二级子模块 # 权限管理 -> 二级子模块
# ======================================== # ========================================
@ -23,51 +24,62 @@ async def init_permissions(db: AsyncSession):
{"name": "菜单管理", "code": "perm:menu", "parentid": 1, "route": "/admin/menu", "description": "菜单管理子模块"}, {"name": "菜单管理", "code": "perm:menu", "parentid": 1, "route": "/admin/menu", "description": "菜单管理子模块"},
# ======================================== # ========================================
# 用户管理操作parentid=3 # 用户管理操作parentid=4
# ======================================== # ========================================
{"name": "查看列表", "code": "perm:user:list", "parentid": 3, "description": "查看用户列表"}, {"name": "查看列表", "code": "perm:user:list", "parentid": 4, "description": "查看用户列表"},
{"name": "查看详情", "code": "perm:user:detail", "parentid": 3, "description": "查看用户详情"}, {"name": "查看详情", "code": "perm:user:detail", "parentid": 4, "description": "查看用户详情"},
{"name": "搜索", "code": "perm:user:search", "parentid": 3, "description": "搜索用户"}, {"name": "搜索", "code": "perm:user:search", "parentid": 4, "description": "搜索用户"},
{"name": "创建", "code": "perm:user:create", "parentid": 3, "description": "创建新用户"}, {"name": "创建", "code": "perm:user:create", "parentid": 4, "description": "创建新用户"},
{"name": "编辑", "code": "perm:user:edit", "parentid": 3, "description": "编辑用户信息"}, {"name": "编辑", "code": "perm:user:edit", "parentid": 4, "description": "编辑用户信息"},
{"name": "删除", "code": "perm:user:delete", "parentid": 3, "description": "删除用户"}, {"name": "删除", "code": "perm:user:delete", "parentid": 4, "description": "删除用户"},
{"name": "导出", "code": "perm:user:export", "parentid": 3, "description": "导出用户数据"}, {"name": "导出", "code": "perm:user:export", "parentid": 4, "description": "导出用户数据"},
{"name": "批量操作", "code": "perm:user:batch", "parentid": 3, "description": "批量操作用户"}, {"name": "批量操作", "code": "perm:user:batch", "parentid": 4, "description": "批量操作用户"},
# ======================================== # ========================================
# 角色管理操作parentid=4 # 角色管理操作parentid=5
# ======================================== # ========================================
{"name": "查看列表", "code": "perm:role:list", "parentid": 4, "description": "查看角色列表"}, {"name": "查看列表", "code": "perm:role:list", "parentid": 5, "description": "查看角色列表"},
{"name": "查看详情", "code": "perm:role:detail", "parentid": 4, "description": "查看角色详情"}, {"name": "查看详情", "code": "perm:role:detail", "parentid": 5, "description": "查看角色详情"},
{"name": "搜索", "code": "perm:role:search", "parentid": 4, "description": "搜索角色"}, {"name": "搜索", "code": "perm:role:search", "parentid": 5, "description": "搜索角色"},
{"name": "创建", "code": "perm:role:create", "parentid": 4, "description": "创建新角色"}, {"name": "创建", "code": "perm:role:create", "parentid": 5, "description": "创建新角色"},
{"name": "编辑", "code": "perm:role:edit", "parentid": 4, "description": "编辑角色信息"}, {"name": "编辑", "code": "perm:role:edit", "parentid": 5, "description": "编辑角色信息"},
{"name": "删除", "code": "perm:role:delete", "parentid": 4, "description": "删除角色"}, {"name": "删除", "code": "perm:role:delete", "parentid": 5, "description": "删除角色"},
{"name": "导出", "code": "perm:role:export", "parentid": 4, "description": "导出角色数据"}, {"name": "导出", "code": "perm:role:export", "parentid": 5, "description": "导出角色数据"},
{"name": "分配权限", "code": "perm:role:assign", "parentid": 4, "description": "为角色分配权限"}, {"name": "分配权限", "code": "perm:role:assign", "parentid": 5, "description": "为角色分配权限"},
# ======================================== # ========================================
# 权限配置操作parentid=5 # 菜单管理操作parentid=6
# ======================================== # ========================================
{"name": "查看列表", "code": "perm:menu:list", "parentid": 5, "description": "查看权限列表"}, {"name": "查看列表", "code": "perm:menu:list", "parentid": 6, "description": "查看菜单列表"},
{"name": "查看详情", "code": "perm:menu:detail", "parentid": 5, "description": "查看权限详情"}, {"name": "查看详情", "code": "perm:menu:detail", "parentid": 6, "description": "查看菜单详情"},
{"name": "搜索", "code": "perm:menu:search", "parentid": 5, "description": "搜索权限"}, {"name": "搜索", "code": "perm:menu:search", "parentid": 6, "description": "搜索菜单"},
{"name": "创建", "code": "perm:menu:create", "parentid": 5, "description": "创建新权限"}, {"name": "创建", "code": "perm:menu:create", "parentid": 6, "description": "创建新菜单"},
{"name": "编辑", "code": "perm:menu:edit", "parentid": 5, "description": "编辑权限信息"}, {"name": "编辑", "code": "perm:menu:edit", "parentid": 6, "description": "编辑菜单信息"},
{"name": "删除", "code": "perm:menu:delete", "parentid": 5, "description": "删除权限"}, {"name": "删除", "code": "perm:menu:delete", "parentid": 6, "description": "删除菜单"},
{"name": "导出", "code": "perm:menu:export", "parentid": 5, "description": "岀权限数据"}, {"name": "导出", "code": "perm:menu:export", "parentid": 6, "description": "出菜单数据"},
# ======================================== # ========================================
# 系统设置 -> 基础配置操作parentid=6 # 日志管理 -> 子模块parentid=3
# ======================================== # ========================================
{"name": "查看配置", "code": "system:basic:view", "parentid": 6, "description": "查看基础配置"}, {"name": "登录日志", "code": "log:login", "parentid": 3, "route": "/admin/login-logs", "description": "登录日志查看"},
{"name": "修改配置", "code": "system:basic:edit", "parentid": 6, "description": "修改基础配置"}, {"name": "操作日志", "code": "log:operation", "parentid": 3, "route": "/admin/operation-logs", "description": "操作日志查看"},
# ======================================== # ========================================
# 系统设置 -> 高级配置操作parentid=7 # 登录日志操作parentid=30
# ======================================== # ========================================
{"name": "查看配置", "code": "system:advanced:view", "parentid": 7, "description": "查看高级配置"}, {"name": "查看列表", "code": "log:login:list", "parentid": 30, "description": "查看登录日志列表"},
{"name": "修改配置", "code": "system:advanced:edit", "parentid": 7, "description": "修改高级配置"}, {"name": "搜索", "code": "log:login:search", "parentid": 30, "description": "搜索登录日志"},
# ========================================
# 操作日志操作parentid=31
# ========================================
{"name": "查看列表", "code": "log:operation:list", "parentid": 31, "description": "查看操作日志列表"},
{"name": "搜索", "code": "log:operation:search", "parentid": 31, "description": "搜索操作日志"},
# ========================================
# 日志清理parentid=3
# ========================================
{"name": "清理日志", "code": "log:clean", "parentid": 3, "description": "清理旧日志"},
] ]
# 检查现有权限 # 检查现有权限
@ -351,7 +363,7 @@ async def init_system_settings(db: AsyncSession):
}, },
{ {
"key": "页脚信息", "key": "页脚信息",
"value": "© 2026 图书管理系统 版权所有 | 技术支持:强蕊科技", "value": "Copyright © 2026 图书管理系统 版权所有 | 技术支持:强蕊科技",
"type": "string", "type": "string",
"description": "页脚信息" "description": "页脚信息"
}, },

View File

@ -8,6 +8,7 @@ from typing import Callable
from database import get_db from database import get_db
from utils.jwt import decode_token from utils.jwt import decode_token
from utils.redis_client import get_redis, check_token_in_redis from utils.redis_client import get_redis, check_token_in_redis
from models import User
security = HTTPBearer() security = HTTPBearer()
@ -41,9 +42,6 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# 查找用户
from models import User
result = await db.execute(select(User).filter(User.id == user_id)) result = await db.execute(select(User).filter(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:

View File

@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func, Table from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func, Table, Index, Text
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime from datetime import datetime
@ -97,3 +97,44 @@ class SystemSetting(Base):
updatetime = Column( updatetime = Column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间" DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
) )
class LoginLog(Base):
"""登录日志"""
__tablename__ = "login_log"
id = Column(Integer, primary_key=True, index=True, comment="日志ID")
username = Column(String(50), nullable=False, comment="用户名")
login_time = Column(DateTime, default=datetime.utcnow, comment="登录时间")
ip_address = Column(String(50), nullable=True, comment="IP地址")
user_agent = Column(String(500), nullable=True, comment="User-Agent")
status = Column(String(20), nullable=False, comment="状态: success/failure")
failure_reason = Column(String(255), nullable=True, comment="失败原因")
Index("ix_login_log_login_time", LoginLog.login_time)
Index("ix_login_log_username", LoginLog.username)
Index("ix_login_log_status", LoginLog.status)
class OperationLog(Base):
"""操作日志"""
__tablename__ = "operation_log"
id = Column(Integer, primary_key=True, index=True, comment="日志ID")
user_id = Column(Integer, nullable=False, comment="用户ID")
username = Column(String(50), nullable=False, comment="用户名")
action_type = Column(String(20), nullable=False, comment="操作类型: create/update/delete")
target_type = Column(String(50), nullable=False, comment="目标类型: user/role/permission/setting")
target_id = Column(Integer, nullable=True, comment="目标ID")
target_name = Column(String(100), nullable=True, comment="目标名称")
detail = Column(Text, nullable=True, comment="变更详情(JSON)")
ip_address = Column(String(50), nullable=True, comment="IP地址")
user_agent = Column(String(500), nullable=True, comment="User-Agent")
createtime = Column(DateTime, default=datetime.utcnow, comment="创建时间")
Index("ix_operation_log_createtime", OperationLog.createtime)
Index("ix_operation_log_username", OperationLog.username)
Index("ix_operation_log_action_type", OperationLog.action_type)
Index("ix_operation_log_target_type", OperationLog.target_type)

View File

@ -227,3 +227,62 @@ class SystemSettingResponse(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
class LoginLogResponse(BaseModel):
"""登录日志响应模型"""
id: int = Field(..., description="日志ID")
username: str = Field(..., description="用户名")
login_time: datetime = Field(..., description="登录时间")
ip_address: Optional[str] = Field(None, description="IP地址")
user_agent: Optional[str] = Field(None, description="User-Agent")
status: str = Field(..., description="状态: success/failure")
failure_reason: Optional[str] = Field(None, description="失败原因")
class Config:
from_attributes = True
class LoginLogSearch(BaseModel):
"""登录日志搜索模型"""
username: Optional[str] = Field(None, description="用户名(模糊搜索)")
status: Optional[str] = Field(None, description="状态")
start_time: Optional[datetime] = Field(None, description="开始时间")
end_time: Optional[datetime] = Field(None, description="结束时间")
class LoginLogCleanRequest(BaseModel):
"""登录日志清理请求模型"""
before_date: datetime = Field(..., description="清理此日期之前的日志")
class OperationLogResponse(BaseModel):
"""操作日志响应模型"""
id: int = Field(..., description="日志ID")
user_id: int = Field(..., description="用户ID")
username: str = Field(..., description="用户名")
action_type: str = Field(..., description="操作类型: create/update/delete")
target_type: str = Field(..., description="目标类型: user/role/permission/setting")
target_id: Optional[int] = Field(None, description="目标ID")
target_name: Optional[str] = Field(None, description="目标名称")
detail: Optional[str] = Field(None, description="变更详情(JSON)")
ip_address: Optional[str] = Field(None, description="IP地址")
user_agent: Optional[str] = Field(None, description="User-Agent")
createtime: datetime = Field(..., description="创建时间")
class Config:
from_attributes = True
class OperationLogSearch(BaseModel):
"""操作日志搜索模型"""
username: Optional[str] = Field(None, description="用户名(模糊搜索)")
action_type: Optional[str] = Field(None, description="操作类型")
target_type: Optional[str] = Field(None, description="目标类型")
start_time: Optional[datetime] = Field(None, description="开始时间")
end_time: Optional[datetime] = Field(None, description="结束时间")

View File

@ -0,0 +1,70 @@
import json
from datetime import datetime
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession
from models import LoginLog, OperationLog
def get_client_ip(request: Request) -> str:
"""从请求中提取客户端IP地址支持代理转发"""
xff = request.headers.get("X-Forwarded-For")
if xff:
return xff.split(",")[0].strip()
xri = request.headers.get("X-Real-IP")
if xri:
return xri.strip()
return request.client.host if request.client else "unknown"
def get_user_agent(request: Request) -> str:
"""从请求中提取 User-Agent"""
return request.headers.get("User-Agent", "")
async def save_login_log(
db: AsyncSession,
username: str,
ip_address: str,
user_agent: str,
status: str,
failure_reason: str | None = None,
):
"""保存登录日志(独立提交,确保失败记录不丢失)"""
log = LoginLog(
username=username,
login_time=datetime.utcnow(),
ip_address=ip_address,
user_agent=user_agent,
status=status,
failure_reason=failure_reason,
)
db.add(log)
await db.commit()
async def create_operation_log(
db: AsyncSession,
user_id: int,
username: str,
action_type: str,
target_type: str,
target_id: int | None = None,
target_name: str | None = None,
detail: dict | None = None,
ip_address: str = "",
user_agent: str = "",
):
"""创建操作日志(不提交,由调用方事务统一提交)"""
log = OperationLog(
user_id=user_id,
username=username,
action_type=action_type,
target_type=target_type,
target_id=target_id,
target_name=target_name,
detail=json.dumps(detail, ensure_ascii=False) if detail else None,
ip_address=ip_address,
user_agent=user_agent,
createtime=datetime.utcnow(),
)
db.add(log)

View File

@ -68,7 +68,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, markRaw } from "vue"; import { computed, onMounted, ref, markRaw } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Key, Setting, Folder } from "@element-plus/icons-vue"; import { Key, Setting, Folder, Document } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useAuthStore } from "@/modules/admin/stores/auth"; import { useAuthStore } from "@/modules/admin/stores/auth";
import axios from "axios"; import axios from "axios";
@ -87,6 +87,7 @@ const pageTitle = computed(() => (route.meta.title as string) || "");
const iconMap: Record<string, any> = { const iconMap: Record<string, any> = {
"perm": markRaw(Key), "perm": markRaw(Key),
"system": markRaw(Setting), "system": markRaw(Setting),
"log": markRaw(Document),
}; };
function getIcon(code: string) { function getIcon(code: string) {

View File

@ -0,0 +1,83 @@
<template>
<fs-page>
<fs-crud ref="crudRef" v-bind="crudBinding" />
</fs-page>
<!-- 清理旧日志对话框 -->
<el-dialog v-model="cleanDlgVisible" title="清理旧日志" width="420px">
<el-form>
<el-form-item label="清理此日期之前的日志">
<el-date-picker
v-model="cleanBeforeDate"
type="datetime"
placeholder="选择日期时间"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="cleanDlgVisible = false">取消</el-button>
<el-button
type="warning"
:loading="cleanLoading"
@click="onClean"
>确认清理</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import { ElMessage, ElMessageBox } from "element-plus";
import dayjs from "dayjs";
import { http } from "@/modules/admin/api/http";
import { buildLoginLogCrudOptions } from "./login-log-crud";
const { crudBinding, crudRef, crudExpose } = useFs({
createCrudOptions: buildLoginLogCrudOptions({
onClean: () => {
cleanDlgVisible.value = true;
},
}),
});
const cleanDlgVisible = ref(false);
const cleanBeforeDate = ref<Date>();
const cleanLoading = ref(false);
async function onClean() {
if (!cleanBeforeDate.value) {
ElMessage.warning("请选择要清理的日期");
return;
}
try {
await ElMessageBox.confirm(
`确定要清理 ${dayjs(cleanBeforeDate.value).format("YYYY-MM-DD HH:mm:ss")} 之前的所有登录日志吗?此操作不可撤销。`,
"确认清理",
{ confirmButtonText: "确认清理", cancelButtonText: "取消", type: "warning" }
);
} catch {
return;
}
cleanLoading.value = true;
try {
const { data } = await http.delete("/login-logs/clean", {
data: { before_date: cleanBeforeDate.value.toISOString() },
});
ElMessage.success(data.message || "清理成功");
cleanDlgVisible.value = false;
crudExpose.doRefresh();
} catch (error) {
ElMessage.error("清理失败");
} finally {
cleanLoading.value = false;
}
}
onMounted(() => {
crudExpose.doRefresh();
});
</script>

View File

@ -0,0 +1,83 @@
<template>
<fs-page>
<fs-crud ref="crudRef" v-bind="crudBinding" />
</fs-page>
<!-- 清理旧日志对话框 -->
<el-dialog v-model="cleanDlgVisible" title="清理旧日志" width="420px">
<el-form>
<el-form-item label="清理此日期之前的日志">
<el-date-picker
v-model="cleanBeforeDate"
type="datetime"
placeholder="选择日期时间"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="cleanDlgVisible = false">取消</el-button>
<el-button
type="warning"
:loading="cleanLoading"
@click="onClean"
>确认清理</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import { ElMessage, ElMessageBox } from "element-plus";
import dayjs from "dayjs";
import { http } from "@/modules/admin/api/http";
import { buildOperationLogCrudOptions } from "./operation-log-crud";
const { crudBinding, crudRef, crudExpose } = useFs({
createCrudOptions: buildOperationLogCrudOptions({
onClean: () => {
cleanDlgVisible.value = true;
},
}),
});
const cleanDlgVisible = ref(false);
const cleanBeforeDate = ref<Date>();
const cleanLoading = ref(false);
async function onClean() {
if (!cleanBeforeDate.value) {
ElMessage.warning("请选择要清理的日期");
return;
}
try {
await ElMessageBox.confirm(
`确定要清理 ${dayjs(cleanBeforeDate.value).format("YYYY-MM-DD HH:mm:ss")} 之前的所有操作日志吗?此操作不可撤销。`,
"确认清理",
{ confirmButtonText: "确认清理", cancelButtonText: "取消", type: "warning" }
);
} catch {
return;
}
cleanLoading.value = true;
try {
const { data } = await http.delete("/operation-logs/clean", {
data: { before_date: cleanBeforeDate.value.toISOString() },
});
ElMessage.success(data.message || "清理成功");
cleanDlgVisible.value = false;
crudExpose.doRefresh();
} catch (error) {
ElMessage.error("清理失败");
} finally {
cleanLoading.value = false;
}
}
onMounted(() => {
crudExpose.doRefresh();
});
</script>

View File

@ -0,0 +1,124 @@
import type { CreateCrudOptions } from "@fast-crud/fast-crud";
import { dict } from "@fast-crud/fast-crud";
import dayjs from "dayjs";
import { http } from "@/modules/admin/api/http";
export type LoginLogRow = {
id: number;
username: string;
login_time: string;
ip_address: string;
user_agent: string;
status: string;
failure_reason: string | null;
};
export function buildLoginLogCrudOptions(opts: {
onClean: () => void;
}): CreateCrudOptions<LoginLogRow> {
return () => ({
crudOptions: {
pagination: { show: false },
search: {
show: true,
options: { layout: "inline" },
expand: false,
},
request: {
pageRequest: async (query) => {
const { data } = await http.post<LoginLogRow[]>(
"/login-logs/search",
query
);
return data;
},
transformQuery: ({ form }) => {
const params: Record<string, unknown> = {};
if (form?.username) params.username = form.username;
if (form?.status) params.status = form.status;
return params;
},
transformRes: ({ res }) => {
const records = Array.isArray(res) ? res : [];
return {
currentPage: 1,
pageSize: Math.max(records.length, 1),
total: records.length,
records,
};
},
},
rowHandle: { show: false },
toolbar: {
buttons: {
clean: {
text: "清理旧日志",
type: "warning",
click: () => opts.onClean(),
},
},
},
columns: {
id: {
title: "ID",
key: "id",
type: "number",
column: { width: 70 },
form: { show: false },
},
username: {
title: "用户名",
key: "username",
type: "text",
search: { show: true },
},
status: {
title: "状态",
key: "status",
type: "dict-select",
search: { show: true },
dict: dict({
data: [
{ value: "success", label: "成功" },
{ value: "failure", label: "失败" },
],
value: "value",
label: "label",
}),
column: { width: 80 },
},
failure_reason: {
title: "失败原因",
key: "failure_reason",
type: "text",
column: { width: 160 },
},
ip_address: {
title: "IP地址",
key: "ip_address",
type: "text",
column: { width: 150 },
},
user_agent: {
title: "User-Agent",
key: "user_agent",
type: "text",
column: { width: 300 },
},
login_time: {
title: "登录时间",
key: "login_time",
type: "text",
column: { width: 170 },
form: { show: false },
valueBuilder({ value, row, key }) {
if (!row || value == null) return;
(row as Record<string, unknown>)[key as string] = dayjs(
value
).format("YYYY-MM-DD HH:mm:ss");
},
},
},
},
});
}

View File

@ -0,0 +1,168 @@
import type { CreateCrudOptions } from "@fast-crud/fast-crud";
import { dict } from "@fast-crud/fast-crud";
import dayjs from "dayjs";
import { http } from "@/modules/admin/api/http";
export type OperationLogRow = {
id: number;
user_id: number;
username: string;
action_type: string;
target_type: string;
target_id: number | null;
target_name: string | null;
detail: string | null;
ip_address: string;
user_agent: string;
createtime: string;
};
export function buildOperationLogCrudOptions(opts: {
onClean: () => void;
}): CreateCrudOptions<OperationLogRow> {
return () => ({
crudOptions: {
pagination: { show: false },
search: {
show: true,
options: { layout: "inline" },
expand: false,
},
request: {
pageRequest: async (query) => {
const { data } = await http.post<OperationLogRow[]>(
"/operation-logs/search",
query
);
return data;
},
transformQuery: ({ form }) => {
const params: Record<string, unknown> = {};
if (form?.username) params.username = form.username;
if (form?.action_type) params.action_type = form.action_type;
if (form?.target_type) params.target_type = form.target_type;
return params;
},
transformRes: ({ res }) => {
const records = Array.isArray(res) ? res : [];
return {
currentPage: 1,
pageSize: Math.max(records.length, 1),
total: records.length,
records,
};
},
},
rowHandle: { show: false },
toolbar: {
buttons: {
clean: {
text: "清理旧日志",
type: "warning",
click: () => opts.onClean(),
},
},
},
columns: {
id: {
title: "ID",
key: "id",
type: "number",
column: { width: 70 },
form: { show: false },
},
user_id: {
title: "用户ID",
key: "user_id",
type: "number",
column: { width: 80 },
form: { show: false },
},
username: {
title: "操作人",
key: "username",
type: "text",
search: { show: true },
},
action_type: {
title: "操作类型",
key: "action_type",
type: "dict-select",
search: { show: true },
dict: dict({
data: [
{ value: "create", label: "创建" },
{ value: "update", label: "更新" },
{ value: "delete", label: "删除" },
],
value: "value",
label: "label",
}),
column: { width: 90 },
},
target_type: {
title: "目标类型",
key: "target_type",
type: "dict-select",
search: { show: true },
dict: dict({
data: [
{ value: "user", label: "用户" },
{ value: "role", label: "角色" },
{ value: "permission", label: "权限" },
{ value: "setting", label: "系统设置" },
],
value: "value",
label: "label",
}),
column: { width: 100 },
},
target_name: {
title: "目标名称",
key: "target_name",
type: "text",
column: { width: 140 },
},
target_id: {
title: "目标ID",
key: "target_id",
type: "number",
column: { width: 80 },
form: { show: false },
},
detail: {
title: "变更详情",
key: "detail",
type: "text",
column: { width: 200 },
form: { show: false },
},
ip_address: {
title: "IP地址",
key: "ip_address",
type: "text",
column: { width: 150 },
},
user_agent: {
title: "User-Agent",
key: "user_agent",
type: "text",
column: { width: 300 },
},
createtime: {
title: "操作时间",
key: "createtime",
type: "text",
column: { width: 170 },
form: { show: false },
valueBuilder({ value, row, key }) {
if (!row || value == null) return;
(row as Record<string, unknown>)[key as string] = dayjs(
value
).format("YYYY-MM-DD HH:mm:ss");
},
},
},
},
});
}

View File

@ -9,6 +9,7 @@ export type PermissionRow = {
name: string; name: string;
code: string; code: string;
parentid: number; parentid: number;
route?: string | null;
description?: string | null; description?: string | null;
createtime: string; createtime: string;
updatetime: string; updatetime: string;
@ -134,6 +135,15 @@ export const createPermissionCrudOptions: CreateCrudOptions<PermissionRow> = ()
type: "textarea", type: "textarea",
column: { show: false }, column: { show: false },
}, },
route: {
title: "路由",
key: "route",
type: "text",
column: { width: 200 },
form: {
placeholder: "如:/admin/users",
},
},
createtime: { createtime: {
title: "创建时间", title: "创建时间",
key: "createtime", key: "createtime",

View File

@ -52,6 +52,20 @@ const router = createRouter({
component: () => component: () =>
import("@/modules/admin/views/settings/SettingManage.vue"), import("@/modules/admin/views/settings/SettingManage.vue"),
}, },
{
path: "login-logs",
name: "admin-login-logs",
meta: { title: "登录日志", permission: "log:" },
component: () =>
import("@/modules/admin/views/logs/LoginLogManage.vue"),
},
{
path: "operation-logs",
name: "admin-operation-logs",
meta: { title: "操作日志", permission: "log:" },
component: () =>
import("@/modules/admin/views/logs/OperationLogManage.vue"),
},
], ],
}, },
], ],