Template
- 用户管理:增删改、批量导入(Excel模板)、admin保护 - 部门管理:增删改(含回收站恢复机制)、删除保护(有用户不可删) - 权限管理:部门内角色(admin/readwrite/readonly/none) - 监控页面:CPU/内存/磁盘实时监控 - 日志页面:操作日志查询 - SMB权限:用户文件夹700(member)/750(admin)、ACL继承 - admin跨目录访问:通过setfacl ACL实现
465 lines
16 KiB
Python
465 lines
16 KiB
Python
"""用户管理路由"""
|
||
import os
|
||
import json
|
||
import openpyxl
|
||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request, Response
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select
|
||
|
||
from app.database import get_session
|
||
from app.models import User, Department, UserDepartment, OperationLog
|
||
from app.schemas import UserCreate, UserUpdate, UserOut, ImportResult
|
||
from app.utils.pinyin import cn_to_pinyin
|
||
from app.utils.password_hash import hash_password, verify_password
|
||
from app.services.linux_service import create_linux_user, delete_linux_user, add_user_to_group
|
||
from app.services.samba_service import add_samba_user, delete_samba_user
|
||
from app.routers.departments import _reload_samba
|
||
|
||
router = APIRouter()
|
||
DATA_DIR = os.environ.get("DATA_DIR", "/data/smb")
|
||
|
||
|
||
def generate_username(display_name: str) -> str:
|
||
"""生成全拼用户名(查重)"""
|
||
base = cn_to_pinyin(display_name)
|
||
return base
|
||
|
||
|
||
@router.get("", response_model=list[UserOut])
|
||
async def list_users(
|
||
dept_id: int = None,
|
||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||
session: AsyncSession = Depends(get_session)
|
||
):
|
||
from sqlalchemy.orm import selectinload
|
||
query = (
|
||
select(User)
|
||
.where(User.is_deleted == False)
|
||
.options(selectinload(User.departments).selectinload(UserDepartment.department))
|
||
)
|
||
if dept_id:
|
||
query = query.join(UserDepartment).where(UserDepartment.department_id == dept_id)
|
||
|
||
result = await session.execute(query)
|
||
users = result.scalars().all()
|
||
|
||
out = []
|
||
for user in users:
|
||
depts = []
|
||
for ud in user.departments:
|
||
depts.append({
|
||
"department_id": ud.department_id,
|
||
"department_name": ud.department.name if ud.department else "",
|
||
"role_in_dept": ud.role_in_dept
|
||
})
|
||
out.append(UserOut(
|
||
id=user.id,
|
||
username=user.username,
|
||
display_name=user.display_name,
|
||
is_deleted=user.is_deleted,
|
||
departments=depts,
|
||
created_at=user.created_at
|
||
))
|
||
return out
|
||
|
||
|
||
@router.post("")
|
||
async def create_user(
|
||
body: UserCreate,
|
||
request: Request,
|
||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||
session: AsyncSession = Depends(get_session)
|
||
):
|
||
username = generate_username(body.display_name)
|
||
|
||
# 查重:如果用户名已存在,追加数字后缀
|
||
counter = 1
|
||
original_username = username
|
||
while True:
|
||
existing = await session.execute(
|
||
select(User).where(User.username == username)
|
||
)
|
||
if not existing.scalar_one_or_none():
|
||
break
|
||
username = f"{original_username}{counter}"
|
||
counter += 1
|
||
|
||
# 检查所有部门都存在
|
||
for dept_info in body.departments:
|
||
dept_result = await session.execute(
|
||
select(Department).where(Department.id == dept_info["department_id"])
|
||
)
|
||
if not dept_result.scalar_one_or_none():
|
||
raise HTTPException(status_code=400, detail=f"部门 {dept_info['department_id']} 不存在")
|
||
|
||
# 创建 Linux 系统用户
|
||
linux_result = create_linux_user(username, body.password)
|
||
if not linux_result.get("success"):
|
||
raise HTTPException(status_code=500, detail=f"创建系统用户失败: {linux_result.get('error')}")
|
||
|
||
# 创建 SMB 用户
|
||
if not add_samba_user(username, body.password):
|
||
# Samba用户失败不影响主流程
|
||
pass
|
||
|
||
# 创建数据库用户
|
||
password_hash = hash_password(body.password)
|
||
user = User(
|
||
username=username,
|
||
display_name=body.display_name,
|
||
password_hash=password_hash
|
||
)
|
||
session.add(user)
|
||
await session.flush()
|
||
|
||
# 创建用户-部门关联和文件夹
|
||
for dept_info in body.departments:
|
||
dept_result = await session.execute(
|
||
select(Department).where(Department.id == dept_info["department_id"])
|
||
)
|
||
dept = dept_result.scalar_one()
|
||
|
||
role = dept_info.get("role_in_dept", "member")
|
||
|
||
# 加入部门关联
|
||
ud = UserDepartment(
|
||
user_id=user.id,
|
||
department_id=dept.id,
|
||
role_in_dept=role
|
||
)
|
||
session.add(ud)
|
||
|
||
# 创建个人文件夹
|
||
folder_path = os.path.join(DATA_DIR, f"dept-{dept.name}", username)
|
||
os.makedirs(folder_path, exist_ok=True)
|
||
|
||
# 设置权限:用户加入部门组,组可访问父目录
|
||
try:
|
||
import pwd, grp, subprocess
|
||
uid = pwd.getpwnam(username).pw_uid
|
||
# 部门组名用拼音(Linux不支持中文组名)
|
||
group_name = f"group_{cn_to_pinyin(dept.name)}"
|
||
try:
|
||
grp.getgrnam(group_name)
|
||
except KeyError:
|
||
subprocess.run(["groupadd", "-f", group_name], check=False, capture_output=True)
|
||
subprocess.run(["usermod", "-aG", group_name, username], check=False, capture_output=True)
|
||
gid = grp.getgrnam(group_name).gr_gid
|
||
# 部门父目录设 SGID
|
||
dept_path = os.path.dirname(folder_path)
|
||
os.chown(dept_path, -1, gid)
|
||
os.chmod(dept_path, 0o2770) # 部门共享,所有成员可读写
|
||
# 个人文件夹:member=700(仅本人),admin=750(部门admin可通过ACL访问)
|
||
os.chown(folder_path, uid, gid)
|
||
if role == "admin":
|
||
os.chmod(folder_path, 0o750)
|
||
# admin 需要能访问所有成员的文件夹,加 ACL
|
||
import subprocess as sub
|
||
sub.run(["setfacl", "-m", f"u:{username}:rwx", dept_path], check=False)
|
||
# 遍历同部门其他用户的文件夹加 ACL
|
||
for other_user_dir in os.listdir(dept_path):
|
||
other_path = os.path.join(dept_path, other_user_dir)
|
||
if os.path.isdir(other_path) and other_user_dir != username:
|
||
sub.run(["setfacl", "-m", f"u:{username}:rwx", other_path], check=False)
|
||
else:
|
||
os.chmod(folder_path, 0o700)
|
||
except Exception as ex:
|
||
os.chmod(folder_path, 0o700)
|
||
|
||
await session.commit()
|
||
|
||
# 更新 Samba 配置
|
||
await _reload_samba(session)
|
||
|
||
# 记录日志
|
||
log = OperationLog(
|
||
operator_username=current_user.username,
|
||
action="CREATE",
|
||
target_type="USER",
|
||
target_name=body.display_name,
|
||
details=json.dumps({"username": username}, ensure_ascii=False),
|
||
ip_address=request.client.host if request.client else "unknown"
|
||
)
|
||
session.add(log)
|
||
await session.commit()
|
||
|
||
return {
|
||
"id": user.id,
|
||
"username": user.username,
|
||
"display_name": user.display_name
|
||
}
|
||
|
||
|
||
@router.put("/{user_id}")
|
||
async def update_user(
|
||
user_id: int,
|
||
body: UserUpdate,
|
||
request: Request,
|
||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||
session: AsyncSession = Depends(get_session)
|
||
):
|
||
result = await session.execute(select(User).where(User.id == user_id))
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="用户不存在")
|
||
|
||
if body.display_name:
|
||
user.display_name = body.display_name
|
||
|
||
# 更新部门及角色
|
||
if body.departments is not None:
|
||
# 删除旧关联
|
||
old_uds = await session.execute(
|
||
select(UserDepartment).where(UserDepartment.user_id == user_id)
|
||
)
|
||
for ud in old_uds.scalars().all():
|
||
await session.delete(ud)
|
||
# 重建关联
|
||
for dept_info in body.departments:
|
||
dept_id = dept_info.get("department_id")
|
||
role = dept_info.get("role_in_dept", "member")
|
||
if dept_id:
|
||
from app.models import Department
|
||
dept_result = await session.execute(
|
||
select(Department).where(Department.id == dept_id, Department.is_deleted == False)
|
||
)
|
||
dept = dept_result.scalar_one_or_none()
|
||
if dept:
|
||
ud = UserDepartment(
|
||
user_id=user_id,
|
||
department_id=dept_id,
|
||
role_in_dept=role
|
||
)
|
||
session.add(ud)
|
||
|
||
# 如果是 admin 角色,给该用户加 ACL 读写权限
|
||
if role == "admin":
|
||
import os, subprocess as sub
|
||
dept_folder = f"/data/smb/dept-{dept.name}"
|
||
try:
|
||
# 部门共享文件夹本身也要可读写
|
||
sub.run(["setfacl", "-m", f"u:{user.username}:rwx", dept_folder], check=False)
|
||
# 同部门其他人的个人文件夹也要可读写
|
||
for other_dir in os.listdir(dept_folder):
|
||
other_path = os.path.join(dept_folder, other_dir)
|
||
if os.path.isdir(other_path) and other_dir != user.username:
|
||
sub.run(["setfacl", "-m", f"u:{user.username}:rwx", other_path], check=False)
|
||
except Exception:
|
||
pass
|
||
|
||
await session.commit()
|
||
|
||
return {"message": "Updated"}
|
||
|
||
|
||
@router.delete("/{user_id}")
|
||
async def delete_user(
|
||
user_id: int,
|
||
body: dict,
|
||
request: Request,
|
||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||
session: AsyncSession = Depends(get_session)
|
||
):
|
||
# 禁止删除 admin 用户
|
||
if user_id == 1:
|
||
raise HTTPException(status_code=403, detail="禁止删除系统管理员")
|
||
|
||
if not verify_password(body.get("password", ""), current_user.password_hash):
|
||
raise HTTPException(status_code=403, detail="密码错误")
|
||
|
||
result = await session.execute(select(User).where(User.id == user_id))
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="用户不存在")
|
||
|
||
# 软删除
|
||
user.is_deleted = True
|
||
|
||
# 从 Linux 系统删除用户
|
||
delete_linux_user(user.username)
|
||
|
||
# 从 Samba 删除用户
|
||
delete_samba_user(user.username)
|
||
|
||
# 删除所有部门关联
|
||
await session.execute(
|
||
select(UserDepartment).where(UserDepartment.user_id == user_id)
|
||
)
|
||
ud_result = await session.execute(
|
||
select(UserDepartment).where(UserDepartment.user_id == user_id)
|
||
)
|
||
for ud in ud_result.scalars().all():
|
||
await session.delete(ud)
|
||
|
||
await session.commit()
|
||
await _reload_samba(session)
|
||
|
||
log = OperationLog(
|
||
operator_username=current_user.username,
|
||
action="DELETE",
|
||
target_type="USER",
|
||
target_name=user.display_name,
|
||
details=json.dumps({"username": user.username}, ensure_ascii=False),
|
||
ip_address=request.client.host if request.client else "unknown"
|
||
)
|
||
session.add(log)
|
||
await session.commit()
|
||
|
||
return {"message": "Deleted"}
|
||
|
||
|
||
@router.post("/{user_id}/reset-password")
|
||
async def reset_password(
|
||
user_id: int,
|
||
body: dict,
|
||
request: Request,
|
||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||
session: AsyncSession = Depends(get_session)
|
||
):
|
||
new_password = body.get("new_password", "")
|
||
if len(new_password) < 6:
|
||
raise HTTPException(status_code=400, detail="密码至少6位")
|
||
|
||
result = await session.execute(select(User).where(User.id == user_id))
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="用户不存在")
|
||
|
||
user.password_hash = hash_password(new_password)
|
||
await session.commit()
|
||
|
||
# 更新 Samba 密码
|
||
add_samba_user(user.username, new_password)
|
||
|
||
log = OperationLog(
|
||
operator_username=current_user.username,
|
||
action="RESET_PASSWORD",
|
||
target_type="USER",
|
||
target_name=user.display_name,
|
||
details=json.dumps({"username": user.username}, ensure_ascii=False),
|
||
ip_address=request.client.host if request.client else "unknown"
|
||
)
|
||
session.add(log)
|
||
await session.commit()
|
||
|
||
return {"message": "Password reset"}
|
||
|
||
|
||
@router.get("/import-template")
|
||
async def download_import_template():
|
||
"""下载用户批量导入模板(Excel .xlsx)"""
|
||
import io
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.title = "用户导入"
|
||
# 表头
|
||
headers = ["姓名*", "密码*", "部门1", "部门2", "部门3", "部门1角色", "部门2角色", "部门3角色"]
|
||
ws.append(headers)
|
||
# 示例行
|
||
ws.append(["张三", "123456", "销售部", "", "", "member", "", ""])
|
||
ws.append(["李四", "123456", "技术部", "", "", "admin", "", ""])
|
||
# 设置列宽
|
||
ws.column_dimensions["A"].width = 12
|
||
ws.column_dimensions["B"].width = 10
|
||
ws.column_dimensions["C"].width = 10
|
||
ws.column_dimensions["D"].width = 10
|
||
ws.column_dimensions["E"].width = 10
|
||
ws.column_dimensions["F"].width = 10
|
||
ws.column_dimensions["G"].width = 10
|
||
ws.column_dimensions["H"].width = 10
|
||
# 加粗表头
|
||
for cell in ws[1]:
|
||
cell.font = openpyxl.styles.Font(bold=True)
|
||
buf = io.BytesIO()
|
||
wb.save(buf)
|
||
buf.seek(0)
|
||
return Response(
|
||
content=buf.getvalue(),
|
||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
headers={"Content-Disposition": "attachment; filename=smb_user_import_template.xlsx"}
|
||
)
|
||
|
||
|
||
@router.post("/import", response_model=ImportResult)
|
||
async def import_users(
|
||
file: UploadFile = File(...),
|
||
request: Request = None,
|
||
current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user),
|
||
session: AsyncSession = Depends(get_session)
|
||
):
|
||
contents = await file.read()
|
||
success_count = 0
|
||
fail_count = 0
|
||
failed_rows = []
|
||
|
||
try:
|
||
wb = openpyxl.load_workbook(contents)
|
||
ws = wb.active
|
||
|
||
for i, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
|
||
display_name, password = row[0], row[1]
|
||
if not display_name or not password:
|
||
failed_rows.append({"row": i, "error": "缺少姓名或密码"})
|
||
fail_count += 1
|
||
continue
|
||
|
||
try:
|
||
username = generate_username(str(display_name))
|
||
counter = 1
|
||
original_username = username
|
||
while True:
|
||
existing = await session.execute(
|
||
select(User).where(User.username == username)
|
||
)
|
||
if not existing.scalar_one_or_none():
|
||
break
|
||
username = f"{original_username}{counter}"
|
||
counter += 1
|
||
|
||
user = User(
|
||
username=username,
|
||
display_name=str(display_name),
|
||
password_hash=hash_password(str(password))
|
||
)
|
||
session.add(user)
|
||
await session.flush()
|
||
|
||
# 处理部门
|
||
for col_idx, role_field in [(2, "role1"), (3, "role2"), (4, "role3")]:
|
||
dept_name = row[col_idx] if len(row) > col_idx else None
|
||
role = row[col_idx + 3] if len(row) > col_idx + 3 else "member"
|
||
|
||
if dept_name:
|
||
dept_result = await session.execute(
|
||
select(Department).where(Department.name == str(dept_name).strip())
|
||
)
|
||
dept = dept_result.scalar_one_or_none()
|
||
if dept:
|
||
ud = UserDepartment(
|
||
user_id=user.id,
|
||
department_id=dept.id,
|
||
role_in_dept=role or "member"
|
||
)
|
||
session.add(ud)
|
||
|
||
# 创建 Linux 和 Samba 用户
|
||
create_linux_user(username, str(password))
|
||
add_samba_user(username, str(password))
|
||
|
||
success_count += 1
|
||
except Exception as e:
|
||
failed_rows.append({"row": i, "error": str(e)})
|
||
fail_count += 1
|
||
|
||
await session.commit()
|
||
await _reload_samba(session)
|
||
|
||
except Exception as e:
|
||
raise HTTPException(status_code=400, detail=f"Excel解析失败: {str(e)}")
|
||
|
||
return ImportResult(
|
||
success_count=success_count,
|
||
fail_count=fail_count,
|
||
failed_rows=failed_rows
|
||
)
|