diff --git a/backend/auth/apps/logs/__init__.py b/backend/auth/apps/logs/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/auth/apps/logs/urls.py b/backend/auth/apps/logs/urls.py
new file mode 100644
index 0000000..a5af432
--- /dev/null
+++ b/backend/auth/apps/logs/urls.py
@@ -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,
+ }
diff --git a/backend/auth/apps/permissions/urls.py b/backend/auth/apps/permissions/urls.py
index 9d173f0..f9fec4e 100644
--- a/backend/auth/apps/permissions/urls.py
+++ b/backend/auth/apps/permissions/urls.py
@@ -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 import select, and_
from sqlalchemy.orm import selectinload
@@ -17,6 +17,7 @@ from schemas import (
from middleware import get_current_user
from models import User
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"])
@@ -27,6 +28,7 @@ permission_crud = FastCRUD(Permission, PermissionResponse)
@router.post("/", response_model=PermissionResponse)
async def create_permission(
permission_data: PermissionCreate,
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -46,15 +48,24 @@ async def create_permission(
)
db.add(db_permission)
- await db.commit()
+ await db.flush()
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
@router.post("/search", response_model=list[PermissionResponse])
async def search_permissions(
query: dict = {},
+ request: Request = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -70,11 +81,21 @@ async def search_permissions(
stmt = stmt.where(and_(*filters))
result = await db.execute(stmt)
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]
@router.get("/tree", response_model=list[PermissionTreeResponse])
async def get_permission_tree(
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -89,6 +110,7 @@ async def get_permission_tree(
"name": perm.name,
"code": perm.code,
"parentid": perm.parentid,
+ "route": perm.route,
"description": perm.description,
"createtime": perm.createtime,
"updatetime": perm.updatetime,
@@ -106,11 +128,20 @@ async def get_permission_tree(
# 子节点,添加到父节点的 children
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
@router.get("/menu", response_model=list[PermissionTreeResponse])
async def get_menu(
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -122,20 +153,20 @@ async def get_menu(
.filter(Role.id == current_user.role_id)
)
role = result.scalar_one_or_none()
-
+
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
# 获取用户有权限的权限编码集合
user_permission_codes = {perm.code for perm in role.permissions}
-
+
# 获取所有顶级模块和二级模块权限(有route字段的)
result = await db.execute(
select(Permission)
.filter(Permission.route.isnot(None))
)
all_menu_permissions = result.scalars().all()
-
+
# 过滤用户有权限的菜单
user_menu_permissions = []
for perm in all_menu_permissions:
@@ -147,7 +178,7 @@ async def get_menu(
break
if has_access:
user_menu_permissions.append(perm)
-
+
# 转换为字典,便于查找
perm_dict = {perm.id: {
"id": perm.id,
@@ -160,7 +191,7 @@ async def get_menu(
"updatetime": perm.updatetime,
"children": []
} for perm in user_menu_permissions}
-
+
# 构建树形结构
tree = []
for perm_id, perm_data in perm_dict.items():
@@ -171,13 +202,22 @@ async def get_menu(
elif parentid in perm_dict:
# 子节点,添加到父节点的 children
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
@router.get("/{permission_id}", response_model=PermissionResponse)
async def get_permission(
permission_id: int,
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -185,6 +225,15 @@ async def get_permission(
permission = await permission_crud.get(db, id=permission_id)
if not permission:
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
@@ -192,10 +241,17 @@ async def get_permission(
async def update_permission(
permission_id: int,
permission_data: PermissionCreate,
+ request: Request,
db: AsyncSession = Depends(get_db),
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(
select(Permission).filter(Permission.code == permission_data.code, Permission.id != permission_id)
@@ -213,12 +269,27 @@ async def update_permission(
)
if not updated_permission:
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
@router.delete("/{permission_id}")
async def delete_permission(
permission_id: int,
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -228,6 +299,13 @@ async def delete_permission(
existing_permission = result.scalar_one_or_none()
if not existing_permission:
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. 删除角色-权限关联表中的记录
from sqlalchemy import delete as sql_delete
@@ -256,6 +334,7 @@ async def delete_permission(
@router.post("/assign", response_model=RolePermissionResponse)
async def assign_permissions_to_role(
data: RolePermissionAssign,
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -276,6 +355,14 @@ async def assign_permissions_to_role(
# 更新角色的权限
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.refresh(role)
@@ -290,6 +377,7 @@ async def assign_permissions_to_role(
@router.get("/role/{role_id}", response_model=RolePermissionResponse)
async def get_role_permissions(
role_id: int,
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -300,6 +388,14 @@ async def get_role_permissions(
if not role:
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(
role_id=role.id,
@@ -310,6 +406,7 @@ async def get_role_permissions(
@router.get("/user/me", response_model=RolePermissionResponse)
async def get_current_user_permissions(
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -321,20 +418,20 @@ async def get_current_user_permissions(
.filter(Role.id == current_user.role_id)
)
role = result.scalar_one_or_none()
-
+
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
# 获取用户有权限的权限编码集合
user_permission_codes = {perm.code for perm in role.permissions}
-
+
# 获取所有顶级模块和二级模块权限(有route字段的)
result = await db.execute(
select(Permission)
.filter(Permission.route.isnot(None))
)
all_menu_permissions = result.scalars().all()
-
+
# 过滤用户有权限的菜单
user_menu_permissions = []
for perm in all_menu_permissions:
@@ -346,7 +443,7 @@ async def get_current_user_permissions(
break
if has_access:
user_menu_permissions.append(perm)
-
+
# 转换为字典,便于查找
perm_dict = {perm.id: {
"id": perm.id,
@@ -359,7 +456,7 @@ async def get_current_user_permissions(
"updatetime": perm.updatetime,
"children": []
} for perm in user_menu_permissions}
-
+
# 构建树形结构
menu_tree = []
for perm_id, perm_data in perm_dict.items():
@@ -371,6 +468,14 @@ async def get_current_user_permissions(
# 子节点,添加到父节点的 children
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(
role_id=role.id,
diff --git a/backend/auth/apps/roles/urls.py b/backend/auth/apps/roles/urls.py
index 77a5b95..40082bc 100644
--- a/backend/auth/apps/roles/urls.py
+++ b/backend/auth/apps/roles/urls.py
@@ -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 import select
from fastcrud import FastCRUD
@@ -8,6 +8,7 @@ from models import Role, User
from schemas import RoleCreate, RoleResponse
from middleware import get_current_user
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"])
@@ -18,13 +19,12 @@ role_crud = FastCRUD(Role, RoleResponse, RoleCreate)
@router.post("/", response_model=RoleResponse)
async def create_role(
role_data: RoleCreate,
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""创建角色"""
# 检查角色名是否已存在
- from sqlalchemy import select
-
result = await db.execute(select(Role).filter(Role.name == role_data.name))
existing_role = result.scalar_one_or_none()
if existing_role:
@@ -38,18 +38,26 @@ async def create_role(
name=role_data.name,
creator=role_data.creator
)
-
+
db.add(new_role)
await db.flush() # 获取生成的ID
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()
-
+
return RoleResponse.model_validate(new_role)
@router.post("/search", response_model=list[RoleResponse])
async def search_roles(
query: dict = {},
+ request: Request = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -61,12 +69,22 @@ async def search_roles(
stmt = stmt.where(cond)
result = await db.execute(stmt)
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]
@router.get("/{role_id}", response_model=RoleResponse)
async def get_role(
role_id: int,
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -75,6 +93,15 @@ async def get_role(
role = result.scalar_one_or_none()
if not role:
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)
@@ -82,13 +109,18 @@ async def get_role(
async def update_role(
role_id: int,
role_data: RoleCreate,
+ request: Request,
db: AsyncSession = Depends(get_db),
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(
select(Role).filter(Role.name == role_data.name, Role.id != role_id)
)
@@ -98,29 +130,39 @@ async def update_role(
# 使用原生 SQLAlchemy 更新角色(FastCRUD 有问题)
from sqlalchemy import update
-
+
stmt = (
update(Role)
.where(Role.id == role_id)
.values(name=role_data.name)
)
result = await db.execute(stmt)
-
+
if result.rowcount == 0:
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()
-
+
# 查询更新后的数据
result = await db.execute(select(Role).filter(Role.id == role_id))
updated_role = result.scalar_one_or_none()
-
+
return updated_role
@router.delete("/{role_id}")
async def delete_role(
role_id: int,
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -139,6 +181,13 @@ async def delete_role(
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删除角色
await role_crud.delete(db, id=role_id)
return {"message": "角色删除成功"}
diff --git a/backend/auth/apps/settings/urls.py b/backend/auth/apps/settings/urls.py
index 2ccab61..810cf5e 100644
--- a/backend/auth/apps/settings/urls.py
+++ b/backend/auth/apps/settings/urls.py
@@ -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 import select, and_
from fastcrud import FastCRUD
@@ -16,6 +16,7 @@ from schemas import (
)
from middleware import get_current_user
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"])
@@ -30,6 +31,7 @@ UPLOAD_DIR.mkdir(exist_ok=True)
@router.post("/", response_model=SystemSettingResponse)
async def create_setting(
setting_data: SystemSettingCreate,
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -50,20 +52,39 @@ async def create_setting(
)
db.add(db_setting)
- await db.commit()
+ await db.flush()
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
@router.get("/", response_model=list[SystemSettingResponse])
async def get_settings(
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取所有设置项"""
result = await db.execute(select(SystemSetting))
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]
@@ -97,6 +118,7 @@ async def get_public_setting(
@router.get("/{setting_id}", response_model=SystemSettingResponse)
async def get_setting(
setting_id: int,
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -104,12 +126,22 @@ async def get_setting(
setting = await setting_crud.get(db, id=setting_id)
if not setting:
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
@router.get("/key/{key}", response_model=SystemSettingResponse)
async def get_setting_by_key(
key: str,
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -118,6 +150,15 @@ async def get_setting_by_key(
setting = result.scalar_one_or_none()
if not setting:
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
@@ -125,10 +166,17 @@ async def get_setting_by_key(
async def update_setting(
setting_id: int,
setting_data: SystemSettingUpdate,
+ request: Request,
db: AsyncSession = Depends(get_db),
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:
result = await db.execute(
@@ -147,12 +195,28 @@ async def update_setting(
)
if not updated_setting:
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
@router.delete("/{setting_id}")
async def delete_setting(
setting_id: int,
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -163,6 +227,14 @@ async def delete_setting(
if not existing_setting:
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删除设置
deleted = await setting_crud.delete(db, id=setting_id)
return {"message": "设置项删除成功"}
@@ -186,7 +258,7 @@ async def upload_image(
if file_size > 2 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件大小不能超过2MB")
chunk = await file.read(1024)
-
+
# 重置文件指针
await file.seek(0)
diff --git a/backend/auth/apps/urls.py b/backend/auth/apps/urls.py
index f48fa99..899f694 100644
--- a/backend/auth/apps/urls.py
+++ b/backend/auth/apps/urls.py
@@ -3,6 +3,7 @@ from .users.urls import router as users_router
from .roles.urls import router as roles_router
from .permissions.urls import router as permissions_router
from .settings.urls import router as settings_router
+from .logs.urls import router as logs_router
# 创建主路由
api_router = APIRouter()
@@ -11,4 +12,5 @@ api_router = APIRouter()
api_router.include_router(users_router)
api_router.include_router(roles_router)
api_router.include_router(permissions_router)
-api_router.include_router(settings_router)
\ No newline at end of file
+api_router.include_router(settings_router)
+api_router.include_router(logs_router)
\ No newline at end of file
diff --git a/backend/auth/apps/users/urls.py b/backend/auth/apps/users/urls.py
index 8dad310..13cfc1b 100644
--- a/backend/auth/apps/users/urls.py
+++ b/backend/auth/apps/users/urls.py
@@ -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 sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
@@ -19,10 +19,11 @@ from schemas import (
from utils.password import verify_password, get_password_hash
from utils.jwt import create_access_token
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 middleware import get_current_user
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"])
@@ -35,26 +36,31 @@ async def get_captcha(redis_conn: redis.Redis = Depends(get_redis)):
"""获取验证码图片"""
# 创建验证码
captcha_id, image_bytes = await create_captcha(redis_conn)
-
+
# 返回验证码图片,同时在响应头中返回验证码ID
response = Response(content=image_bytes, media_type="image/png")
response.headers["X-Captcha-ID"] = captcha_id
-
+
return response
@router.post("/login", response_model=Token)
async def login(
user_data: UserLogin,
+ request: Request,
db: AsyncSession = Depends(get_db),
redis_conn: redis.Redis = Depends(get_redis),
):
"""用户登录"""
+ ip = get_client_ip(request)
+ ua = get_user_agent(request)
+
# 验证验证码
is_captcha_valid = await validate_captcha(
redis_conn, user_data.captcha_id, user_data.captcha_code
)
if not is_captcha_valid:
+ await save_login_log(db, user_data.username, ip, ua, "failure", "验证码错误或已过期")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="验证码错误或已过期",
@@ -67,6 +73,7 @@ async def login(
# 验证用户是否存在且密码正确
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(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
@@ -88,12 +95,16 @@ async def login(
user.lastlogintime = datetime.utcnow()
await db.commit()
+ # 记录登录成功日志
+ await save_login_log(db, user.username, ip, ua, "success", None)
+
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/", response_model=UserResponse)
async def create_user(
user_data: UserCreateRequest,
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -118,12 +129,23 @@ async def create_user(
created_user = await user_crud.create(
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
@router.post("/search", response_model=list[UserResponse])
async def search_users(
query: dict = {},
+ request: Request = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -144,18 +166,39 @@ async def search_users(
stmt = stmt.where(and_(*filters))
result = await db.execute(stmt)
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]
@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)"""
+ # 记录查询日志
+ 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
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -163,6 +206,15 @@ async def get_user(
user = await user_crud.get(db, id=user_id)
if not user:
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
@@ -170,10 +222,17 @@ async def get_user(
async def update_user(
user_id: int,
user_data: UserUpdate,
+ request: Request,
db: AsyncSession = Depends(get_db),
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(
select(User).filter(User.username == user_data.username, User.id != user_id)
@@ -199,12 +258,28 @@ async def update_user(
)
if not updated_user:
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
@router.delete("/{user_id}")
async def delete_user(
user_id: int,
+ request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -219,6 +294,13 @@ async def delete_user(
elif existing_user.role_id == 1:
raise HTTPException(status_code=400, detail="不能删除管理员用户")
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删除用户
deleted = await user_crud.delete(db, id=user_id)
return {"message": "用户删除成功"}
@@ -226,11 +308,18 @@ async def delete_user(
@router.post("/logout")
async def logout(
+ request: Request,
+ db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
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 save_login_log(
+ db, current_user.username,
+ get_client_ip(request), get_user_agent(request),
+ "success", None,
+ )
return {"message": "登出成功"}
diff --git a/backend/auth/init_data.py b/backend/auth/init_data.py
index d5e648b..76dc928 100644
--- a/backend/auth/init_data.py
+++ b/backend/auth/init_data.py
@@ -14,7 +14,8 @@ async def init_permissions(db: AsyncSession):
# ========================================
{"name": "权限管理", "code": "perm", "parentid": 0, "route": "/admin/permissions", "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": "菜单管理子模块"},
# ========================================
- # 用户管理操作(parentid=3)
+ # 用户管理操作(parentid=4)
# ========================================
- {"name": "查看列表", "code": "perm:user:list", "parentid": 3, "description": "查看用户列表"},
- {"name": "查看详情", "code": "perm:user:detail", "parentid": 3, "description": "查看用户详情"},
- {"name": "搜索", "code": "perm:user:search", "parentid": 3, "description": "搜索用户"},
- {"name": "创建", "code": "perm:user:create", "parentid": 3, "description": "创建新用户"},
- {"name": "编辑", "code": "perm:user:edit", "parentid": 3, "description": "编辑用户信息"},
- {"name": "删除", "code": "perm:user:delete", "parentid": 3, "description": "删除用户"},
- {"name": "导出", "code": "perm:user:export", "parentid": 3, "description": "导出用户数据"},
- {"name": "批量操作", "code": "perm:user:batch", "parentid": 3, "description": "批量操作用户"},
+ {"name": "查看列表", "code": "perm:user:list", "parentid": 4, "description": "查看用户列表"},
+ {"name": "查看详情", "code": "perm:user:detail", "parentid": 4, "description": "查看用户详情"},
+ {"name": "搜索", "code": "perm:user:search", "parentid": 4, "description": "搜索用户"},
+ {"name": "创建", "code": "perm:user:create", "parentid": 4, "description": "创建新用户"},
+ {"name": "编辑", "code": "perm:user:edit", "parentid": 4, "description": "编辑用户信息"},
+ {"name": "删除", "code": "perm:user:delete", "parentid": 4, "description": "删除用户"},
+ {"name": "导出", "code": "perm:user:export", "parentid": 4, "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:detail", "parentid": 4, "description": "查看角色详情"},
- {"name": "搜索", "code": "perm:role:search", "parentid": 4, "description": "搜索角色"},
- {"name": "创建", "code": "perm:role:create", "parentid": 4, "description": "创建新角色"},
- {"name": "编辑", "code": "perm:role:edit", "parentid": 4, "description": "编辑角色信息"},
- {"name": "删除", "code": "perm:role:delete", "parentid": 4, "description": "删除角色"},
- {"name": "导出", "code": "perm:role:export", "parentid": 4, "description": "导出角色数据"},
- {"name": "分配权限", "code": "perm:role:assign", "parentid": 4, "description": "为角色分配权限"},
+ {"name": "查看列表", "code": "perm:role:list", "parentid": 5, "description": "查看角色列表"},
+ {"name": "查看详情", "code": "perm:role:detail", "parentid": 5, "description": "查看角色详情"},
+ {"name": "搜索", "code": "perm:role:search", "parentid": 5, "description": "搜索角色"},
+ {"name": "创建", "code": "perm:role:create", "parentid": 5, "description": "创建新角色"},
+ {"name": "编辑", "code": "perm:role:edit", "parentid": 5, "description": "编辑角色信息"},
+ {"name": "删除", "code": "perm:role:delete", "parentid": 5, "description": "删除角色"},
+ {"name": "导出", "code": "perm:role:export", "parentid": 5, "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:detail", "parentid": 5, "description": "查看权限详情"},
- {"name": "搜索", "code": "perm:menu:search", "parentid": 5, "description": "搜索权限"},
- {"name": "创建", "code": "perm:menu:create", "parentid": 5, "description": "创建新权限"},
- {"name": "编辑", "code": "perm:menu:edit", "parentid": 5, "description": "编辑权限信息"},
- {"name": "删除", "code": "perm:menu:delete", "parentid": 5, "description": "删除权限"},
- {"name": "导出", "code": "perm:menu:export", "parentid": 5, "description": "导岀权限数据"},
-
+ {"name": "查看列表", "code": "perm:menu:list", "parentid": 6, "description": "查看菜单列表"},
+ {"name": "查看详情", "code": "perm:menu:detail", "parentid": 6, "description": "查看菜单详情"},
+ {"name": "搜索", "code": "perm:menu:search", "parentid": 6, "description": "搜索菜单"},
+ {"name": "创建", "code": "perm:menu:create", "parentid": 6, "description": "创建新菜单"},
+ {"name": "编辑", "code": "perm:menu:edit", "parentid": 6, "description": "编辑菜单信息"},
+ {"name": "删除", "code": "perm:menu:delete", "parentid": 6, "description": "删除菜单"},
+ {"name": "导出", "code": "perm:menu:export", "parentid": 6, "description": "导出菜单数据"},
+
# ========================================
- # 系统设置 -> 基础配置操作(parentid=6)
+ # 日志管理 -> 子模块(parentid=3)
# ========================================
- {"name": "查看配置", "code": "system:basic:view", "parentid": 6, "description": "查看基础配置"},
- {"name": "修改配置", "code": "system:basic:edit", "parentid": 6, "description": "修改基础配置"},
-
+ {"name": "登录日志", "code": "log:login", "parentid": 3, "route": "/admin/login-logs", "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": "system:advanced:edit", "parentid": 7, "description": "修改高级配置"},
+ {"name": "查看列表", "code": "log:login:list", "parentid": 30, "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": "页脚信息",
- "value": "© 2026 图书管理系统 版权所有 | 技术支持:强蕊科技",
+ "value": "Copyright © 2026 图书管理系统 版权所有 | 技术支持:强蕊科技",
"type": "string",
"description": "页脚信息"
},
diff --git a/backend/auth/middleware.py b/backend/auth/middleware.py
index fed45b6..ef7e6bf 100644
--- a/backend/auth/middleware.py
+++ b/backend/auth/middleware.py
@@ -8,6 +8,7 @@ from typing import Callable
from database import get_db
from utils.jwt import decode_token
from utils.redis_client import get_redis, check_token_in_redis
+from models import User
security = HTTPBearer()
@@ -41,9 +42,6 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)
- # 查找用户
- from models import User
-
result = await db.execute(select(User).filter(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
diff --git a/backend/auth/models.py b/backend/auth/models.py
index dacd443..ccae4e4 100644
--- a/backend/auth/models.py
+++ b/backend/auth/models.py
@@ -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.ext.declarative import declarative_base
from datetime import datetime
@@ -97,3 +97,44 @@ class SystemSetting(Base):
updatetime = Column(
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)
\ No newline at end of file
diff --git a/backend/auth/schemas.py b/backend/auth/schemas.py
index c6e7f59..3185148 100644
--- a/backend/auth/schemas.py
+++ b/backend/auth/schemas.py
@@ -227,3 +227,62 @@ class SystemSettingResponse(BaseModel):
class Config:
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="结束时间")
\ No newline at end of file
diff --git a/backend/auth/utils/log_utils.py b/backend/auth/utils/log_utils.py
new file mode 100644
index 0000000..20d5605
--- /dev/null
+++ b/backend/auth/utils/log_utils.py
@@ -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)
diff --git a/frontend/src/modules/admin/layout/AdminLayout.vue b/frontend/src/modules/admin/layout/AdminLayout.vue
index 5d151bb..a45057d 100644
--- a/frontend/src/modules/admin/layout/AdminLayout.vue
+++ b/frontend/src/modules/admin/layout/AdminLayout.vue
@@ -68,7 +68,7 @@
diff --git a/frontend/src/modules/admin/views/logs/OperationLogManage.vue b/frontend/src/modules/admin/views/logs/OperationLogManage.vue
new file mode 100644
index 0000000..f1fe591
--- /dev/null
+++ b/frontend/src/modules/admin/views/logs/OperationLogManage.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 确认清理
+
+
+
+
+
diff --git a/frontend/src/modules/admin/views/logs/login-log-crud.ts b/frontend/src/modules/admin/views/logs/login-log-crud.ts
new file mode 100644
index 0000000..a651b67
--- /dev/null
+++ b/frontend/src/modules/admin/views/logs/login-log-crud.ts
@@ -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 {
+ return () => ({
+ crudOptions: {
+ pagination: { show: false },
+ search: {
+ show: true,
+ options: { layout: "inline" },
+ expand: false,
+ },
+ request: {
+ pageRequest: async (query) => {
+ const { data } = await http.post(
+ "/login-logs/search",
+ query
+ );
+ return data;
+ },
+ transformQuery: ({ form }) => {
+ const params: Record = {};
+ 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)[key as string] = dayjs(
+ value
+ ).format("YYYY-MM-DD HH:mm:ss");
+ },
+ },
+ },
+ },
+ });
+}
diff --git a/frontend/src/modules/admin/views/logs/operation-log-crud.ts b/frontend/src/modules/admin/views/logs/operation-log-crud.ts
new file mode 100644
index 0000000..cf7086e
--- /dev/null
+++ b/frontend/src/modules/admin/views/logs/operation-log-crud.ts
@@ -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 {
+ return () => ({
+ crudOptions: {
+ pagination: { show: false },
+ search: {
+ show: true,
+ options: { layout: "inline" },
+ expand: false,
+ },
+ request: {
+ pageRequest: async (query) => {
+ const { data } = await http.post(
+ "/operation-logs/search",
+ query
+ );
+ return data;
+ },
+ transformQuery: ({ form }) => {
+ const params: Record = {};
+ 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)[key as string] = dayjs(
+ value
+ ).format("YYYY-MM-DD HH:mm:ss");
+ },
+ },
+ },
+ },
+ });
+}
diff --git a/frontend/src/modules/admin/views/permissions/permission-crud.ts b/frontend/src/modules/admin/views/permissions/permission-crud.ts
index f7481ec..66603d1 100644
--- a/frontend/src/modules/admin/views/permissions/permission-crud.ts
+++ b/frontend/src/modules/admin/views/permissions/permission-crud.ts
@@ -9,6 +9,7 @@ export type PermissionRow = {
name: string;
code: string;
parentid: number;
+ route?: string | null;
description?: string | null;
createtime: string;
updatetime: string;
@@ -134,6 +135,15 @@ export const createPermissionCrudOptions: CreateCrudOptions = ()
type: "textarea",
column: { show: false },
},
+ route: {
+ title: "路由",
+ key: "route",
+ type: "text",
+ column: { width: 200 },
+ form: {
+ placeholder: "如:/admin/users",
+ },
+ },
createtime: {
title: "创建时间",
key: "createtime",
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 9429df4..bda0c55 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -52,6 +52,20 @@ const router = createRouter({
component: () =>
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"),
+ },
],
},
],