Files
Smb-Server/app/routers/users.py
T
bi c790e43cdf feat: SMB文件服务器管理后台 - 完整版本
- 用户管理:增删改、批量导入(Excel模板)、admin保护
- 部门管理:增删改(含回收站恢复机制)、删除保护(有用户不可删)
- 权限管理:部门内角色(admin/readwrite/readonly/none)
- 监控页面:CPU/内存/磁盘实时监控
- 日志页面:操作日志查询
- SMB权限:用户文件夹700(member)/750(admin)、ACL继承
- admin跨目录访问:通过setfacl ACL实现
2026-07-13 23:03:03 +08:00

465 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""用户管理路由"""
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
)