diff --git a/README.md b/README.md index 661b3f7..544928c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1 @@ # Smb-Server - -ubuntu docker 共享文件服务器 \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..36a7799 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# SMB File Server API \ No newline at end of file diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..97048ef --- /dev/null +++ b/app/database.py @@ -0,0 +1,25 @@ +"""数据库初始化""" +import os +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import declarative_base + +Base = declarative_base() + +engine = None +async_session_factory = None + + +async def init_db(db_path: str): + global engine, async_session_factory + os.makedirs(os.path.dirname(db_path), exist_ok=True) + connect_str = f"sqlite+aiosqlite:///{db_path}" + engine = create_async_engine(connect_str, echo=False) + async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with engine.begin() as conn: + from app.models import Department, User, UserDepartment, OperationLog, DiskUsageSnapshot + await conn.run_sync(Base.metadata.create_all) + + +async def get_session(): + async with async_session_factory() as session: + yield session diff --git a/app/departments.html b/app/departments.html new file mode 100644 index 0000000..303b0dc --- /dev/null +++ b/app/departments.html @@ -0,0 +1,115 @@ + + + + +部门管理 - SMB 文件服务器 + + + + +
+ +
+

👥 部门管理

+
+
+
+
+ + +
+
+
+ + + +
ID部门名称文件夹路径用户数已用空间创建时间操作
加载中...
+
+
+
+
+ + + + +
+ + + + diff --git a/app/departments.py b/app/departments.py new file mode 100644 index 0000000..7141567 --- /dev/null +++ b/app/departments.py @@ -0,0 +1,225 @@ +"""部门管理路由""" +import os +import json +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.database import get_session +from app.models import Department, UserDepartment, OperationLog +from app.schemas import DepartmentCreate, DepartmentUpdate, DepartmentOut +from app.services.linux_service import create_department_group, create_department_folders +from app.services.samba_service import generate_smb_conf, apply_smb_config + +router = APIRouter() +DATA_DIR = os.environ.get("DATA_DIR", "/data/smb") + + +async def log_operation(session: AsyncSession, username: str, action: str, + target_type: str, target_name: str, details: dict, ip: str): + log = OperationLog( + operator_username=username, + action=action, + target_type=target_type, + target_name=target_name, + details=json.dumps(details, ensure_ascii=False), + ip_address=ip + ) + session.add(log) + await session.commit() + + +@router.get("", response_model=list[DepartmentOut]) +async def list_departments( + current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user), + session: AsyncSession = Depends(get_session) +): + result = await session.execute( + select(Department).where(Department.is_deleted == False) + ) + departments = result.scalars().all() + + out = [] + for dept in departments: + # 统计用户数 + count_result = await session.execute( + select(func.count(UserDepartment.id)) + .join(Department, UserDepartment.department_id == Department.id) + .where(Department.id == dept.id) + ) + user_count = count_result.scalar() or 0 + + # 计算磁盘使用 + dept_path = dept.folder_path + used_space = 0 + if os.path.exists(dept_path): + rc, stdout, _ = __import__("app.utils.helpers", fromlist=["run_cmd"]).run_cmd( + ["du", "-sb", dept_path], timeout=60 + ) + if rc == 0: + used_space = int(stdout.split()[0]) + + out.append(DepartmentOut( + id=dept.id, + name=dept.name, + folder_path=dept.folder_path, + user_count=user_count, + used_space=used_space, + is_deleted=dept.is_deleted, + created_at=dept.created_at + )) + return out + + +@router.get("/{dept_id}", response_model=DepartmentOut) +async def get_department(dept_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute( + select(Department).where(Department.id == dept_id) + ) + dept = result.scalar_one_or_none() + if not dept: + raise HTTPException(status_code=404, detail="部门不存在") + + count_result = await session.execute( + select(func.count(UserDepartment.id)) + .join(Department, UserDepartment.department_id == Department.id) + .where(Department.id == dept.id) + ) + user_count = count_result.scalar() or 0 + + return DepartmentOut( + id=dept.id, + name=dept.name, + folder_path=dept.folder_path, + user_count=user_count, + used_space=0, + is_deleted=dept.is_deleted, + created_at=dept.created_at + ) + + +@router.post("") +async def create_department( + body: DepartmentCreate, + request: Request, + current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user), + session: AsyncSession = Depends(get_session) +): + # 检查是否已存在 + existing = await session.execute( + select(Department).where(Department.name == body.name, Department.is_deleted == False) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="部门已存在") + + folder_path = os.path.join(DATA_DIR, f"dept-{body.name}") + + # 检查同名文件夹是否已存在(软删除后重建场景) + if os.path.exists(folder_path): + # 已有文件夹,直接关联 + pass + else: + # 创建 Linux 组 + group_name = f"group_{body.name}" + create_department_group(group_name) + # 创建部门文件夹 + create_department_folders(body.name) + + dept = Department( + name=body.name, + folder_path=folder_path + ) + session.add(dept) + await session.commit() + await session.refresh(dept) + + # 更新 Samba 配置 + await _reload_samba(session) + + await log_operation(session, current_user.username, "CREATE", + "DEPARTMENT", body.name, {"folder": folder_path}, + request.client.host if request.client else "unknown") + + return {"id": dept.id, "name": dept.name, "folder_path": dept.folder_path} + + +@router.put("/{dept_id}") +async def update_department( + dept_id: int, + body: DepartmentUpdate, + 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(Department).where(Department.id == dept_id) + ) + dept = result.scalar_one_or_none() + if not dept: + raise HTTPException(status_code=404, detail="部门不存在") + + old_name = dept.name + dept.name = body.name + await session.commit() + + await log_operation(session, current_user.username, "UPDATE", + "DEPARTMENT", body.name, + {"old_name": old_name, "new_name": body.name}, + request.client.host if request.client else "unknown") + + return {"message": "Updated"} + + +@router.delete("/{dept_id}") +async def delete_department( + dept_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) +): + from app.utils.password_hash import verify_password + if not verify_password(body.get("password", ""), current_user.password_hash): + raise HTTPException(status_code=403, detail="密码错误") + + result = await session.execute( + select(Department).where(Department.id == dept_id) + ) + dept = result.scalar_one_or_none() + if not dept: + raise HTTPException(status_code=404, detail="部门不存在") + + # 检查部门是否有关联用户 + users_result = await session.execute( + select(func.count(UserDepartment.id)).where(UserDepartment.department_id == dept_id) + ) + user_count = users_result.scalar() or 0 + if user_count > 0: + raise HTTPException(status_code=400, detail=f"该部门还有 {user_count} 个用户,无法删除。请先删除或转移所有用户。") + + dept.is_deleted = True + await session.commit() + + await _reload_samba(session) + + await log_operation(session, current_user.username, "DELETE", + "DEPARTMENT", dept.name, {}, + request.client.host if request.client else "unknown") + + return {"message": "Deleted"} + + +async def _reload_samba(session: AsyncSession): + """重新生成并应用 Samba 配置""" + from app.models import User + depts_result = await session.execute( + select(Department).where(Department.is_deleted == False) + ) + depts = [{"id": d.id, "name": d.name, "folder_path": d.folder_path, "is_deleted": d.is_deleted} + for d in depts_result.scalars().all()] + users_result = await session.execute( + select(User).where(User.is_deleted == False) + ) + users = [{"id": u.id, "username": u.username} for u in users_result.scalars().all()] + config = generate_smb_conf(depts, users) + apply_smb_config(config) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..7051c9c --- /dev/null +++ b/app/main.py @@ -0,0 +1,107 @@ +"""FastAPI 主应用入口""" +import os +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse, FileResponse +from fastapi.staticfiles import StaticFiles + +DATA_DIR = os.environ.get("DATA_DIR", "/data/smb") +DB_DIR = os.environ.get("DB_DIR", "/data/db") +DB_PATH = os.path.join(DB_DIR, "app.db") + +_BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +TEMPLATES_DIR = os.path.join(_BASE_DIR, "templates") +STATIC_DIR = os.path.join(_BASE_DIR, "static") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + os.makedirs(DATA_DIR, exist_ok=True) + os.makedirs(DB_DIR, exist_ok=True) + from app.database import init_db + await init_db(DB_PATH) + # 确保交换站目录存在 + from app.services.samba_service import ensure_exchange_folder + ensure_exchange_folder() + yield + + +app = FastAPI( + title="SMB 文件服务器", + description="基于 Docker Compose 的 SMB 共享文件服务器管理后台", + version="1.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +from app.routers import auth, departments, users, permissions, monitor, logs + +app.include_router(auth.router, prefix="/api/auth", tags=["认证"]) +app.include_router(departments.router, prefix="/api/departments", tags=["部门管理"]) +app.include_router(users.router, prefix="/api/users", tags=["用户管理"]) +app.include_router(permissions.router, prefix="/api/permissions", tags=["权限管理"]) +app.include_router(monitor.router, prefix="/api/monitor", tags=["监控"]) +app.include_router(logs.router, prefix="/api/logs", tags=["日志"]) + +# 静态文件(css/js 从 templates 目录暴露) +CSS_DIR = os.path.join(TEMPLATES_DIR, "css") +JS_DIR = os.path.join(TEMPLATES_DIR, "js") +if os.path.exists(CSS_DIR): + app.mount("/css", StaticFiles(directory=CSS_DIR), name="css") +if os.path.exists(JS_DIR): + app.mount("/js", StaticFiles(directory=JS_DIR), name="js") +if os.path.exists(STATIC_DIR): + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + +def read_html(path): + with open(path, "r", encoding="utf-8") as f: + return f.read() + + +@app.get("/api/health") +async def health_check(): + return {"status": "ok", "service": "SMB File Server"} + + +@app.get("/", response_class=HTMLResponse) +async def root(): + return HTMLResponse(read_html(os.path.join(TEMPLATES_DIR, "login.html"))) + + +@app.get("/dashboard", response_class=HTMLResponse) +async def dashboard(): + return HTMLResponse(read_html(os.path.join(TEMPLATES_DIR, "views", "dashboard.html"))) + + +@app.get("/departments", response_class=HTMLResponse) +async def departments_page(): + return HTMLResponse(read_html(os.path.join(TEMPLATES_DIR, "views", "departments.html"))) + + +@app.get("/users", response_class=HTMLResponse) +async def users_page(): + return HTMLResponse(read_html(os.path.join(TEMPLATES_DIR, "views", "users.html"))) + + +@app.get("/permissions", response_class=HTMLResponse) +async def permissions_page(): + return HTMLResponse(read_html(os.path.join(TEMPLATES_DIR, "views", "permissions.html"))) + + +@app.get("/monitor", response_class=HTMLResponse) +async def monitor_page(): + return HTMLResponse(read_html(os.path.join(TEMPLATES_DIR, "views", "monitor.html"))) + + +@app.get("/logs", response_class=HTMLResponse) +async def logs_page(): + return HTMLResponse(read_html(os.path.join(TEMPLATES_DIR, "views", "logs.html"))) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..ae6dec0 --- /dev/null +++ b/app/models.py @@ -0,0 +1,70 @@ +"""SQLAlchemy 数据模型""" +from datetime import datetime +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, Float +from sqlalchemy.orm import relationship +from app.database import Base + + +class Department(Base): + __tablename__ = "departments" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), unique=True, nullable=False) + folder_path = Column(String(500), nullable=False) + is_deleted = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + users = relationship("UserDepartment", back_populates="department") + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, autoincrement=True) + username = Column(String(100), unique=True, nullable=False) + display_name = Column(String(100), nullable=False) + password_hash = Column(String(255), nullable=False) + is_deleted = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + departments = relationship("UserDepartment", back_populates="user") + + +class UserDepartment(Base): + __tablename__ = "user_departments" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + department_id = Column(Integer, ForeignKey("departments.id"), nullable=False) + role_in_dept = Column(String(20), default="member") + created_at = Column(DateTime, default=datetime.utcnow) + + user = relationship("User", back_populates="departments") + department = relationship("Department", back_populates="users") + + +class OperationLog(Base): + __tablename__ = "operation_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + operator_username = Column(String(100), nullable=True) + action = Column(String(50), nullable=False) + target_type = Column(String(50), nullable=False) + target_name = Column(String(200), nullable=False) + details = Column(Text, nullable=True) + ip_address = Column(String(50), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + +class DiskUsageSnapshot(Base): + __tablename__ = "disk_usage_snapshots" + + id = Column(Integer, primary_key=True, autoincrement=True) + total_space = Column(Integer, nullable=False) + used_space = Column(Integer, nullable=False) + free_space = Column(Integer, nullable=False) + usage_percent = Column(Float, nullable=False) + dept_usages = Column(Text, nullable=True) + snapshot_time = Column(DateTime, default=datetime.utcnow) diff --git a/app/monitor.py b/app/monitor.py new file mode 100644 index 0000000..cb832ea --- /dev/null +++ b/app/monitor.py @@ -0,0 +1,41 @@ +"""监控路由""" +import json +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.database import get_session +from app.models import DiskUsageSnapshot +from app.models import Department +from app.schemas import DiskUsageResponse +from app.services.disk_monitor import get_disk_usage, get_dept_usages + +router = APIRouter() + + +@router.get("/disk", response_model=DiskUsageResponse) +async def get_disk( + current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user), + session: AsyncSession = Depends(get_session) +): + usage = get_disk_usage() + dept_usages = get_dept_usages() + + # 保存快照 + snapshot = DiskUsageSnapshot( + total_space=usage["total_space"], + used_space=usage["used_space"], + free_space=usage["free_space"], + usage_percent=usage["usage_percent"], + dept_usages=json.dumps(dept_usages, ensure_ascii=False) + ) + session.add(snapshot) + await session.commit() + + return DiskUsageResponse( + total_space=usage["total_space"], + used_space=usage["used_space"], + free_space=usage["free_space"], + usage_percent=usage["usage_percent"], + dept_usages=dept_usages + ) diff --git a/app/permissions.html b/app/permissions.html new file mode 100644 index 0000000..4043e72 --- /dev/null +++ b/app/permissions.html @@ -0,0 +1,112 @@ + + + + +权限管理 - SMB 文件服务器 + + + + +
+ +
+

🔒 权限管理

+
+ + +
+
+
+

🔒 权限说明

+

本系统的「权限」指 SMB 文件共享权限。每个部门对应一个共享文件夹,部门内的用户可以访问该文件夹。

+

🔗 Windows 访问方式:在文件管理器地址栏输入 \\192.168.110.8\部门名 ,然后输入账号密码登录。

+

🔗 Mac 访问方式:在 Finder - 前往 - 连接服务器,输入 smb://192.168.110.8/部门名 。

+
+
+
+ +
+
👥 部门 - 用户权限树
+ +
+ +
加载中...
+ +
+
+
+
+ + + + diff --git a/app/permissions.py b/app/permissions.py new file mode 100644 index 0000000..d349339 --- /dev/null +++ b/app/permissions.py @@ -0,0 +1,78 @@ +"""权限管理路由""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from app.database import get_session +from app.models import Department, User, UserDepartment +from app.routers.auth import get_current_user + +router = APIRouter() + + +@router.get("/tree") +async def get_permission_tree( + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session) +): + result = await session.execute( + select(Department) + .where(Department.is_deleted == False) + .options(selectinload(Department.users).selectinload(UserDepartment.user)) + ) + departments = result.scalars().all() + + tree = [] + for dept in departments: + admins = [] + members = [] + for ud in dept.users: + if ud.user and not ud.user.is_deleted: + info = { + "user_id": ud.user.id, + "username": ud.user.username, + "display_name": ud.user.display_name, + "role_in_dept": ud.role_in_dept + } + if ud.role_in_dept == "admin": + admins.append(info) + else: + members.append(info) + + tree.append({ + "department_id": dept.id, + "department_name": dept.name, + "admins": admins, + "members": members + }) + + return tree + + +@router.put("/{user_id}/{dept_id}") +async def update_user_dept_role( + user_id: int, + dept_id: int, + body: dict, + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session) +): + new_role = body.get("role_in_dept") + if new_role not in ("admin", "member"): + raise HTTPException(status_code=400, detail="角色必须是 admin 或 member") + + result = await session.execute( + select(UserDepartment).where( + UserDepartment.user_id == user_id, + UserDepartment.department_id == dept_id + ) + ) + ud = result.scalar_one_or_none() + if not ud: + raise HTTPException(status_code=404, detail="用户-部门关联不存在") + + ud.role_in_dept = new_role + await session.commit() + + return {"message": "Role updated"} diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..016c838 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +# routers __init__.py diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..2aea49b --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,92 @@ +"""认证路由""" +from datetime import datetime, timedelta +from fastapi import APIRouter, HTTPException, Depends, Request +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from jose import jwt, JWTError + +from app.database import get_session +from app.models import User +from app.schemas import LoginRequest, LoginResponse +from app.utils.password_hash import verify_password, hash_password + +router = APIRouter() +security = HTTPBearer() + +SECRET_KEY = "smb-file-server-secret-key-change-in-production" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_HOURS = 1 + + +def create_token(username: str) -> str: + expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) + payload = {"sub": username, "exp": expire} + return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + session: AsyncSession = Depends(get_session) +): + token = credentials.credentials + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username = payload.get("sub") + if not username: + raise HTTPException(status_code=401, detail="Invalid token") + except JWTError: + raise HTTPException(status_code=401, detail="Invalid token") + + result = await session.execute( + select(User).where(User.username == username, User.is_deleted == False) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=401, detail="User not found") + return user + + +@router.post("/login", response_model=LoginResponse) +async def login(body: LoginRequest, session: AsyncSession = Depends(get_session)): + result = await session.execute( + select(User).where(User.username == body.username, User.is_deleted == False) + ) + user = result.scalar_one_or_none() + + # 首次登录:检查是否有管理员,没有则创建默认管理员 + if not user and body.username == "admin": + from app.services.samba_service import ensure_exchange_folder + ensure_exchange_folder() + from app.services.linux_service import create_linux_user + create_linux_user("admin", body.password) + password_hash = hash_password(body.password) + user = User( + username="admin", + display_name="系统管理员", + password_hash=password_hash, + is_deleted=False + ) + session.add(user) + await session.commit() + await session.refresh(user) + + if not user or not verify_password(body.password, user.password_hash): + raise HTTPException(status_code=401, detail="Invalid username or password") + + token = create_token(user.username) + return LoginResponse(access_token=token) + + +@router.get("/me") +async def get_me(current_user: User = Depends(get_current_user)): + return { + "id": current_user.id, + "username": current_user.username, + "display_name": current_user.display_name, + } + + +@router.post("/logout") +async def logout(current_user: User = Depends(get_current_user)): + return {"message": "Logged out"} diff --git a/app/routers/departments.py b/app/routers/departments.py new file mode 100644 index 0000000..4e2c09b --- /dev/null +++ b/app/routers/departments.py @@ -0,0 +1,234 @@ +"""部门管理路由""" +import os +import json +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.database import get_session +from app.models import Department, UserDepartment, OperationLog +from app.schemas import DepartmentCreate, DepartmentUpdate, DepartmentOut +from app.services.linux_service import create_department_group, create_department_folders +from app.services.samba_service import generate_smb_conf, apply_smb_config + +router = APIRouter() +DATA_DIR = os.environ.get("DATA_DIR", "/data/smb") + + +async def log_operation(session: AsyncSession, username: str, action: str, + target_type: str, target_name: str, details: dict, ip: str): + log = OperationLog( + operator_username=username, + action=action, + target_type=target_type, + target_name=target_name, + details=json.dumps(details, ensure_ascii=False), + ip_address=ip + ) + session.add(log) + await session.commit() + + +@router.get("", response_model=list[DepartmentOut]) +async def list_departments( + current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user), + session: AsyncSession = Depends(get_session) +): + result = await session.execute( + select(Department).where(Department.is_deleted == False) + ) + departments = result.scalars().all() + + out = [] + for dept in departments: + # 统计用户数 + count_result = await session.execute( + select(func.count(UserDepartment.id)) + .join(Department, UserDepartment.department_id == Department.id) + .where(Department.id == dept.id) + ) + user_count = count_result.scalar() or 0 + + # 计算磁盘使用 + dept_path = dept.folder_path + used_space = 0 + if os.path.exists(dept_path): + rc, stdout, _ = __import__("app.utils.helpers", fromlist=["run_cmd"]).run_cmd( + ["du", "-sb", dept_path], timeout=60 + ) + if rc == 0: + used_space = int(stdout.split()[0]) + + out.append(DepartmentOut( + id=dept.id, + name=dept.name, + folder_path=dept.folder_path, + user_count=user_count, + used_space=used_space, + is_deleted=dept.is_deleted, + created_at=dept.created_at + )) + return out + + +@router.get("/{dept_id}", response_model=DepartmentOut) +async def get_department(dept_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute( + select(Department).where(Department.id == dept_id) + ) + dept = result.scalar_one_or_none() + if not dept: + raise HTTPException(status_code=404, detail="部门不存在") + + count_result = await session.execute( + select(func.count(UserDepartment.id)) + .join(Department, UserDepartment.department_id == Department.id) + .where(Department.id == dept.id) + ) + user_count = count_result.scalar() or 0 + + return DepartmentOut( + id=dept.id, + name=dept.name, + folder_path=dept.folder_path, + user_count=user_count, + used_space=0, + is_deleted=dept.is_deleted, + created_at=dept.created_at + ) + + +@router.post("") +async def create_department( + body: DepartmentCreate, + request: Request, + current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user), + session: AsyncSession = Depends(get_session) +): + # 检查是否已存在(未删除) + existing_active = await session.execute( + select(Department).where(Department.name == body.name, Department.is_deleted == False) + ) + if existing_active.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="部门已存在") + + # 检查是否软删除后重建 + existing_deleted = await session.execute( + select(Department).where(Department.name == body.name, Department.is_deleted == True) + ) + old_dept = existing_deleted.scalar_one_or_none() + + folder_path = os.path.join(DATA_DIR, f"dept-{body.name}") + + if old_dept: + # 软删除后重建:恢复记录 + old_dept.is_deleted = False + old_dept.folder_path = folder_path + dept = old_dept + else: + # 新建部门 + if os.path.exists(folder_path): + pass # 已有文件夹 + else: + group_name = f"group_{body.name}" + create_department_group(group_name) + create_department_folders(body.name) + dept = Department( + name=body.name, + folder_path=folder_path + ) + session.add(dept) + + await session.commit() + await session.refresh(dept) + + # 更新 Samba 配置 + await _reload_samba(session) + + await log_operation(session, current_user.username, "CREATE", + "DEPARTMENT", body.name, {"folder": folder_path}, + request.client.host if request.client else "unknown") + + return {"id": dept.id, "name": dept.name, "folder_path": dept.folder_path} + + +@router.put("/{dept_id}") +async def update_department( + dept_id: int, + body: DepartmentUpdate, + 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(Department).where(Department.id == dept_id) + ) + dept = result.scalar_one_or_none() + if not dept: + raise HTTPException(status_code=404, detail="部门不存在") + + old_name = dept.name + dept.name = body.name + await session.commit() + + await log_operation(session, current_user.username, "UPDATE", + "DEPARTMENT", body.name, + {"old_name": old_name, "new_name": body.name}, + request.client.host if request.client else "unknown") + + return {"message": "Updated"} + + +@router.delete("/{dept_id}") +async def delete_department( + dept_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) +): + from app.utils.password_hash import verify_password + if not verify_password(body.get("password", ""), current_user.password_hash): + raise HTTPException(status_code=403, detail="密码错误") + + result = await session.execute( + select(Department).where(Department.id == dept_id) + ) + dept = result.scalar_one_or_none() + if not dept: + raise HTTPException(status_code=404, detail="部门不存在") + + # 检查部门是否有关联用户 + users_result = await session.execute( + select(func.count(UserDepartment.id)).where(UserDepartment.department_id == dept_id) + ) + user_count = users_result.scalar() or 0 + if user_count > 0: + raise HTTPException(status_code=400, detail=f"该部门还有 {user_count} 个用户,无法删除。请先删除或转移所有用户。") + + dept.is_deleted = True + await session.commit() + + await _reload_samba(session) + + await log_operation(session, current_user.username, "DELETE", + "DEPARTMENT", dept.name, {}, + request.client.host if request.client else "unknown") + + return {"message": "Deleted"} + + +async def _reload_samba(session: AsyncSession): + """重新生成并应用 Samba 配置""" + from app.models import User + depts_result = await session.execute( + select(Department).where(Department.is_deleted == False) + ) + depts = [{"id": d.id, "name": d.name, "folder_path": d.folder_path, "is_deleted": d.is_deleted} + for d in depts_result.scalars().all()] + users_result = await session.execute( + select(User).where(User.is_deleted == False) + ) + users = [{"id": u.id, "username": u.username} for u in users_result.scalars().all()] + config = generate_smb_conf(depts, users) + apply_smb_config(config) diff --git a/app/routers/logs.py b/app/routers/logs.py new file mode 100644 index 0000000..6d9d443 --- /dev/null +++ b/app/routers/logs.py @@ -0,0 +1,82 @@ +"""日志路由""" +import csv +import io +from fastapi import APIRouter, Depends, Query +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.database import get_session +from app.models import OperationLog +from app.schemas import LogOut +from app.routers.auth import get_current_user + +router = APIRouter() + + +@router.get("", response_model=list[LogOut]) +async def list_logs( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + action: str = None, + target_type: str = None, + current_user=Depends(get_current_user), + session: AsyncSession = Depends(get_session) +): + query = select(OperationLog).order_by(OperationLog.created_at.desc()) + + if action: + query = query.where(OperationLog.action == action) + if target_type: + query = query.where(OperationLog.target_type == target_type) + + query = query.offset((page - 1) * page_size).limit(page_size) + + result = await session.execute(query) + logs = result.scalars().all() + + return [ + LogOut( + id=log.id, + operator_name=log.operator_username or "system", + target_type=log.target_type, + target_name=log.target_name, + action=log.action, + details=log.details, + ip_address=log.ip_address, + created_at=log.created_at + ) + for log in logs + ] + + +@router.get("/export") +async def export_logs( + current_user=Depends(get_current_user), + session: AsyncSession = Depends(get_session) +): + result = await session.execute( + select(OperationLog).order_by(OperationLog.created_at.desc()).limit(10000) + ) + logs = result.scalars().all() + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["时间", "操作者", "操作类型", "对象类型", "对象名称", "详情", "IP地址"]) + for log in logs: + writer.writerow([ + log.created_at.isoformat() if log.created_at else "", + log.operator_username or "system", + log.action, + log.target_type, + log.target_name, + log.details or "", + log.ip_address or "" + ]) + + output.seek(0) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=logs.csv"} + ) diff --git a/app/routers/monitor.py b/app/routers/monitor.py new file mode 100644 index 0000000..a1ad226 --- /dev/null +++ b/app/routers/monitor.py @@ -0,0 +1,40 @@ +"""监控路由""" +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.database import get_session +from app.models import DiskUsageSnapshot +from app.models import Department +from app.schemas import DiskUsageResponse +from app.services.disk_monitor import get_disk_usage, get_dept_usages + +router = APIRouter() + + +@router.get("/disk", response_model=DiskUsageResponse) +async def get_disk( + current_user=Depends(__import__("app.routers.auth", fromlist=["get_current_user"]).get_current_user), + session: AsyncSession = Depends(get_session) +): + usage = get_disk_usage() + dept_usages = get_dept_usages() + + # 保存快照 + snapshot = DiskUsageSnapshot( + total_space=usage["total_space"], + used_space=usage["used_space"], + free_space=usage["free_space"], + usage_percent=usage["usage_percent"], + dept_usages=str(dept_usages) + ) + session.add(snapshot) + await session.commit() + + return DiskUsageResponse( + total_space=usage["total_space"], + used_space=usage["used_space"], + free_space=usage["free_space"], + usage_percent=usage["usage_percent"], + dept_usages=dept_usages + ) diff --git a/app/routers/permissions.py b/app/routers/permissions.py new file mode 100644 index 0000000..d349339 --- /dev/null +++ b/app/routers/permissions.py @@ -0,0 +1,78 @@ +"""权限管理路由""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from app.database import get_session +from app.models import Department, User, UserDepartment +from app.routers.auth import get_current_user + +router = APIRouter() + + +@router.get("/tree") +async def get_permission_tree( + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session) +): + result = await session.execute( + select(Department) + .where(Department.is_deleted == False) + .options(selectinload(Department.users).selectinload(UserDepartment.user)) + ) + departments = result.scalars().all() + + tree = [] + for dept in departments: + admins = [] + members = [] + for ud in dept.users: + if ud.user and not ud.user.is_deleted: + info = { + "user_id": ud.user.id, + "username": ud.user.username, + "display_name": ud.user.display_name, + "role_in_dept": ud.role_in_dept + } + if ud.role_in_dept == "admin": + admins.append(info) + else: + members.append(info) + + tree.append({ + "department_id": dept.id, + "department_name": dept.name, + "admins": admins, + "members": members + }) + + return tree + + +@router.put("/{user_id}/{dept_id}") +async def update_user_dept_role( + user_id: int, + dept_id: int, + body: dict, + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session) +): + new_role = body.get("role_in_dept") + if new_role not in ("admin", "member"): + raise HTTPException(status_code=400, detail="角色必须是 admin 或 member") + + result = await session.execute( + select(UserDepartment).where( + UserDepartment.user_id == user_id, + UserDepartment.department_id == dept_id + ) + ) + ud = result.scalar_one_or_none() + if not ud: + raise HTTPException(status_code=404, detail="用户-部门关联不存在") + + ud.role_in_dept = new_role + await session.commit() + + return {"message": "Role updated"} diff --git a/app/routers/users.py b/app/routers/users.py new file mode 100644 index 0000000..44b32fc --- /dev/null +++ b/app/routers/users.py @@ -0,0 +1,464 @@ +"""用户管理路由""" +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 + ) diff --git a/app/samba_service.py b/app/samba_service.py new file mode 100644 index 0000000..9092f53 --- /dev/null +++ b/app/samba_service.py @@ -0,0 +1,94 @@ +"""Samba 配置生成服务""" +import os +import subprocess +from app.utils.helpers import run_cmd + + +SMB_CONF_PATH = "/etc/samba/smb.conf" +DATA_DIR = os.environ.get("DATA_DIR", "/data/smb") + + +def generate_smb_conf(departments: list, users: list) -> str: + """生成 Samba 配置文件内容""" + lines = [ + "[global]", + " workgroup = WORKGROUP", + " server string = SMB File Server", + " security = user", + " map to guest = bad user", + " unix charset = UTF-8", + " dos charset = cp936", + " disable spoolss = yes", + "", + "# 共享交换站", + "[exchange]", + " path = /data/smb/exchange", + " browseable = yes", + " writable = yes", + " guest ok = yes", + " create mask = 0666", + " directory mask = 0777", + "", + ] + + # 按部门生成配置 + for dept in departments: + if dept.get("is_deleted"): + continue + dept_name = dept["name"] + # 安全的共享名:去除中文,只保留字母数字下划线 + share_name = f"dept_{dept_name}" + path = dept.get("folder_path", f"/data/smb/dept-{dept_name}") + + lines.append(f"# 部门:{dept_name}") + lines.append(f"[{share_name}]") + lines.append(f" path = {path}") + lines.append(f" browseable = yes") + lines.append(f" writable = yes") + lines.append(f" create mask = 0750") + lines.append(f" directory mask = 0750") + lines.append("") + + return "\n".join(lines) + + +def apply_smb_config(config_content: str) -> dict: + """写入并应用 Samba 配置""" + try: + os.makedirs(os.path.dirname(SMB_CONF_PATH), exist_ok=True) + with open(SMB_CONF_PATH, "w") as f: + f.write(config_content) + + # 验证语法 + rc, stdout, stderr = run_cmd(["testparm", "-s"]) + if rc != 0: + return {"success": False, "error": f"Config invalid: {stderr}"} + + # 重载配置 + run_cmd(["smbcontrol", "all", "reload-config"], timeout=5) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + +def add_samba_user(username: str, password: str) -> bool: + """添加 Samba 用户""" + proc = subprocess.run( + ["smbpasswd", "-s", "-a", username], + input=f"{password}\n{password}\n", + capture_output=True, text=True + ) + return proc.returncode == 0 + + +def delete_samba_user(username: str) -> bool: + """删除 Samba 用户""" + rc, _, _ = run_cmd(["pdbedit", "-x", username]) + return rc == 0 + + +def ensure_exchange_folder(): + """确保交换站文件夹存在""" + exchange_path = os.path.join(DATA_DIR, "exchange") + os.makedirs(exchange_path, exist_ok=True) + os.chmod(exchange_path, 0o777) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..7bacbd4 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,113 @@ +"""Pydantic 数据校验""" +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + + +# ========== 认证 ========== +class LoginRequest(BaseModel): + username: str = Field(..., min_length=1, max_length=100) + password: str = Field(..., min_length=1) + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +# ========== 部门 ========== +class DepartmentCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100, pattern=r'^[\u4e00-\u9fa5a-zA-Z0-9]+$') + + +class DepartmentUpdate(BaseModel): + name: str = Field(..., min_length=1, max_length=100, pattern=r'^[\u4e00-\u9fa5a-zA-Z0-9]+$') + + +class DepartmentOut(BaseModel): + id: int + name: str + folder_path: str + user_count: int = 0 + used_space: int = 0 + is_deleted: bool + created_at: datetime | None = None + + model_config = {"from_attributes": True} + + +# ========== 用户 ========== +class UserCreate(BaseModel): + display_name: str = Field(..., min_length=1, max_length=100) + password: str = Field(..., min_length=6) + departments: List[dict] = Field(..., min_length=1) + # 格式: [{"department_id": 1, "role_in_dept": "member"}, ...] + + +class UserUpdate(BaseModel): + display_name: Optional[str] = None + departments: Optional[List[dict]] = None + role_in_dept: Optional[dict] = None + + +class UserOut(BaseModel): + id: int + username: str + display_name: str + is_deleted: bool + departments: List[dict] = [] + created_at: datetime | None = None + + model_config = {"from_attributes": True} + + +class UserImportItem(BaseModel): + display_name: str + password: str + department1: Optional[str] = None + department2: Optional[str] = None + department3: Optional[str] = None + role1: str = "member" + role2: str = "member" + role3: str = "member" + + +class ImportResult(BaseModel): + success_count: int + fail_count: int + failed_rows: List[dict] = [] + + +# ========== 权限 ========== +class PermissionTreeItem(BaseModel): + department_id: int + department_name: str + admins: List[dict] = [] + members: List[dict] = [] + + +# ========== 监控 ========== +class DiskUsageResponse(BaseModel): + total_space: int + used_space: int + free_space: int + usage_percent: float + dept_usages: List[dict] = [] + + +# ========== 日志 ========== +class LogOut(BaseModel): + id: int + operator_name: str + target_type: str + target_name: str + action: str + details: Optional[str] = None + ip_address: Optional[str] = None + created_at: datetime | None = None + + model_config = {"from_attributes": True} + + +class PasswordConfirm(BaseModel): + password: str = Field(..., min_length=1) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..6b9f2ed --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +# services __init__.py diff --git a/app/services/disk_monitor.py b/app/services/disk_monitor.py new file mode 100644 index 0000000..f07d45f --- /dev/null +++ b/app/services/disk_monitor.py @@ -0,0 +1,71 @@ +"""磁盘监控服务""" +import os +import json +import subprocess +from app.utils.helpers import run_cmd + +DATA_DIR = os.environ.get("DATA_DIR", "/data/smb") + + +def get_disk_usage() -> dict: + """获取总磁盘使用情况""" + # df -B1 以字节为单位 + rc, stdout, _ = run_cmd(["df", "-B1", DATA_DIR or "/data/smb"]) + if rc != 0 or not stdout.strip(): + return { + "total_space": 0, "used_space": 0, "free_space": 0, + "usage_percent": 0.0 + } + + lines = stdout.strip().split("\n") + if len(lines) < 2: + return {"total_space": 0, "used_space": 0, "free_space": 0, "usage_percent": 0.0} + + fields = lines[1].split() + total = int(fields[1]) + used = int(fields[2]) + free = int(fields[3]) + pct = float(fields[4].replace("%", "")) + + return { + "total_space": total, + "used_space": used, + "free_space": free, + "usage_percent": pct + } + + +def get_dept_usages() -> list: + """获取各部门磁盘使用""" + usages = [] + if not os.path.exists(DATA_DIR): + return usages + + for entry in os.scandir(DATA_DIR): + if entry.is_dir() and entry.name.startswith("dept-"): + dept_name = entry.name[4:] # 去掉 "dept-" 前缀 + path = entry.path + rc, stdout, _ = run_cmd(["du", "-sb", path], timeout=60) + if rc == 0: + size = int(stdout.split()[0]) + else: + size = 0 + usages.append({ + "dept_name": dept_name, + "used_bytes": size + }) + + # 加上 exchange 目录 + exchange_path = os.path.join(DATA_DIR, "exchange") + if os.path.exists(exchange_path): + rc, stdout, _ = run_cmd(["du", "-sb", exchange_path], timeout=60) + if rc == 0: + size = int(stdout.split()[0]) + else: + size = 0 + usages.append({ + "dept_name": "exchange", + "used_bytes": size + }) + + return usages diff --git a/app/services/linux_service.py b/app/services/linux_service.py new file mode 100644 index 0000000..14ed722 --- /dev/null +++ b/app/services/linux_service.py @@ -0,0 +1,112 @@ +"""Linux 系统用户和文件夹权限管理服务""" +import os +import subprocess +from app.utils.helpers import run_cmd + +DATA_DIR = os.environ.get("DATA_DIR", "/data/smb") + + +def create_linux_user(username: str, password: str) -> dict: + """创建 Linux 系统用户(无登录shell)""" + # 先检查是否已存在 + rc, stdout, _ = run_cmd(["id", username]) + if rc == 0: + return {"success": True, "exists": True} + + # 创建用户(无home目录,无登录shell) + rc, _, stderr = run_cmd([ + "useradd", "-M", "-s", "/usr/sbin/nologin", username + ]) + if rc != 0: + return {"success": False, "error": f"useradd failed: {stderr}"} + + # 设置密码 + proc = subprocess.run( + ["chpasswd"], + input=f"{username}:{password}", + capture_output=True, text=True + ) + if proc.returncode != 0: + # 回滚用户创建 + run_cmd(["userdel", username]) + return {"success": False, "error": "password set failed"} + + return {"success": True, "exists": False} + + +def delete_linux_user(username: str) -> dict: + """删除 Linux 系统用户""" + rc, _, _ = run_cmd(["id", username]) + if rc != 0: + return {"success": True} # 用户不存在,视为成功 + + rc, _, stderr = run_cmd(["userdel", username]) + if rc != 0: + return {"success": False, "error": f"userdel failed: {stderr}"} + return {"success": True} + + +def add_user_to_group(username: str, group_name: str) -> bool: + """将用户加入 Linux 组""" + rc, _, _ = run_cmd(["usermod", "-aG", group_name, username]) + return rc == 0 + + +def remove_user_from_group(username: str, group_name: str) -> bool: + """将用户从 Linux 组移除""" + rc, _, _ = run_cmd(["gpasswd", "-d", username, group_name]) + return rc == 0 + + +def create_department_group(group_name: str) -> dict: + """创建部门 Linux 组""" + rc, _, _ = run_cmd(["groupadd", "-f", group_name]) + return {"success": rc == 0 or rc == 4} # 4=组已存在 + + +def create_user_folder(username: str, dept_name: str, role: str = "member") -> dict: + """创建用户的个人文件夹""" + folder_path = os.path.join(DATA_DIR, f"dept-{dept_name}", username) + try: + os.makedirs(folder_path, exist_ok=True) + # 设置所有者 + os.chown(folder_path, -1, -1) # 待Samba挂载后修正 + os.chmod(folder_path, 0o700 if role == "member" else 0o750) + return {"success": True, "path": folder_path} + except Exception as e: + return {"success": False, "error": str(e)} + + +def set_folder_owner(username: str, folder_path: str, group_name: str = None) -> bool: + """设置文件夹所有权""" + import pwd + try: + uid = pwd.getpwnam(username).pw_uid + gid = pwd.getpwnam(username).pw_gid if not group_name else -1 + if group_name: + import grp + gid = grp.getgrnam(group_name).gr_gid + os.chown(folder_path, uid, gid) + return True + except Exception: + return False + + +def create_department_folders(dept_name: str) -> dict: + """创建部门根文件夹""" + dept_path = os.path.join(DATA_DIR, f"dept-{dept_name}") + admin_path = os.path.join(dept_path, "admin") + try: + os.makedirs(admin_path, exist_ok=True) + # admin 文件夹允许部门管理员访问 + os.chmod(dept_path, 0o750) + os.chmod(admin_path, 0o700) + return {"success": True, "dept_path": dept_path, "admin_path": admin_path} + except Exception as e: + return {"success": False, "error": str(e)} + + +def apply_posix_acl(folder_path: str, username: str, perms: str = "rwx") -> bool: + """设置 POSIX ACL 权限""" + rc, _, _ = run_cmd(["setfacl", "-m", f"u:{username}:{perms}", folder_path]) + return rc == 0 diff --git a/app/services/samba_service.py b/app/services/samba_service.py new file mode 100644 index 0000000..c6fc13b --- /dev/null +++ b/app/services/samba_service.py @@ -0,0 +1,95 @@ +"""Samba 配置生成服务""" +import os +import subprocess +from app.utils.helpers import run_cmd + + +SMB_CONF_PATH = "/etc/samba/smb.conf" +DATA_DIR = os.environ.get("DATA_DIR", "/data/smb") + + +def generate_smb_conf(departments: list, users: list) -> str: + """生成 Samba 配置文件内容""" + lines = [ + "[global]", + " workgroup = WORKGROUP", + " server string = SMB File Server", + " security = user", + " map to guest = bad user", + " unix charset = UTF-8", + " dos charset = cp936", + " tcp nodelay = yes", + " disable spoolss = yes", + "", + "# 共享交换站", + "[exchange]", + " path = /data/smb/exchange", + " browseable = yes", + " writable = yes", + " guest ok = yes", + " create mask = 0666", + " directory mask = 0777", + "", + ] + + # 按部门生成配置 + for dept in departments: + if dept.get("is_deleted"): + continue + dept_name = dept["name"] + # 安全的共享名:去除中文,只保留字母数字下划线 + share_name = f"dept_{dept_name}" + path = dept.get("folder_path", f"/data/smb/dept-{dept_name}") + + lines.append(f"# 部门:{dept_name}") + lines.append(f"[{share_name}]") + lines.append(f" path = {path}") + lines.append(f" browseable = yes") + lines.append(f" writable = yes") + lines.append(f" create mask = 0750") + lines.append(f" directory mask = 0750") + lines.append("") + + return "\n".join(lines) + + +def apply_smb_config(config_content: str) -> dict: + """写入并应用 Samba 配置""" + try: + os.makedirs(os.path.dirname(SMB_CONF_PATH), exist_ok=True) + with open(SMB_CONF_PATH, "w") as f: + f.write(config_content) + + # 验证语法 + rc, stdout, stderr = run_cmd(["testparm", "-s"]) + if rc != 0: + return {"success": False, "error": f"Config invalid: {stderr}"} + + # 重载配置 + run_cmd(["smbcontrol", "all", "reload-config"], timeout=5) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + +def add_samba_user(username: str, password: str) -> bool: + """添加 Samba 用户""" + proc = subprocess.run( + ["smbpasswd", "-s", "-a", username], + input=f"{password}\n{password}\n", + capture_output=True, text=True + ) + return proc.returncode == 0 + + +def delete_samba_user(username: str) -> bool: + """删除 Samba 用户""" + rc, _, _ = run_cmd(["pdbedit", "-x", username]) + return rc == 0 + + +def ensure_exchange_folder(): + """确保交换站文件夹存在""" + exchange_path = os.path.join(DATA_DIR, "exchange") + os.makedirs(exchange_path, exist_ok=True) + os.chmod(exchange_path, 0o777) diff --git a/app/templates/css/style.css b/app/templates/css/style.css new file mode 100644 index 0000000..411d809 --- /dev/null +++ b/app/templates/css/style.css @@ -0,0 +1,121 @@ +/* AI科技风格深色主题 */ +:root{ + --bg:#0a0e27; + --bg2:#14193c; + --bg3:#1a2045; + --accent:#00d4ff; + --accent2:#0099cc; + --text:#ffffff; + --text2:rgba(255,255,255,0.7); + --text3:rgba(255,255,255,0.4); + --border:rgba(0,212,255,0.15); + --danger:#ff6b6b; + --success:#4ecdc4; + --warning:#ffd93d; + --radius:10px; +} +*{margin:0;padding:0;box-sizing:border-box} +html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);} + +/* 布局 */ +.app{display:flex;height:100vh;overflow:hidden} +.sidebar{width:220px;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0} +.sidebar-logo{padding:20px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px} +.sidebar-logo span{font-size:20px} +.sidebar-logo h2{font-size:14px;font-weight:600;color:var(--accent)} +.sidebar-logo small{font-size:11px;color:var(--text3)} +.nav{flex:1;padding:12px 0} +.nav-item{display:flex;align-items:center;gap:10px;padding:10px 16px;color:var(--text2);text-decoration:none;font-size:13px;border-radius:8px;margin:2px 8px;transition:all 0.2s;cursor:pointer} +.nav-item:hover{background:var(--bg3);color:var(--text)} +.nav-item.active{background:rgba(0,212,255,0.1);color:var(--accent);border-left:3px solid var(--accent)} +.nav-item svg{width:18px;height:18px;flex-shrink:0} +.sidebar-user{padding:12px 16px;border-top:1px solid var(--border);font-size:12px;color:var(--text3)} +.sidebar-user strong{color:var(--text);display:block} + +.main{flex:1;display:flex;flex-direction:column;overflow:hidden} +.topbar{height:56px;background:var(--bg2);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 24px;gap:16px;flex-shrink:0} +.topbar h1{font-size:16px;font-weight:600} +.topbar-actions{display:flex;gap:8px;margin-left:auto} +.content{flex:1;overflow-y:auto;padding:24px} + +/* 通用卡片 */ +.card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin-bottom:16px} +.card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px} +.card-title{font-size:14px;font-weight:600;color:var(--text)} + +/* 按钮 */ +.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;font-size:13px;font-weight:500;cursor:pointer;border:none;transition:all 0.2s;text-decoration:none} +.btn-primary{background:var(--accent);color:#000} +.btn-primary:hover{background:var(--accent2)} +.btn-danger{background:rgba(255,107,107,0.15);color:var(--danger);border:1px solid rgba(255,107,107,0.3)} +.btn-danger:hover{background:rgba(255,107,107,0.25)} +.btn-ghost{background:transparent;color:var(--text2);border:1px solid var(--border)} +.btn-ghost:hover{background:var(--bg3);color:var(--text)} +.btn-sm{padding:5px 10px;font-size:12px} + +/* 表格 */ +.table{width:100%;border-collapse:collapse;font-size:13px} +.table th{text-align:left;padding:10px 12px;color:var(--text3);font-weight:500;border-bottom:1px solid var(--border);white-space:nowrap} +.table td{padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.05)} +.table tr:hover td{background:rgba(0,212,255,0.03)} +.table-empty{text-align:center;color:var(--text3);padding:40px;font-size:13px} + +/* 表单 */ +.form-group{margin-bottom:16px} +.form-label{display:block;font-size:13px;color:var(--text2);margin-bottom:6px} +.form-input,.form-select{width:100%;padding:9px 12px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;outline:none;transition:all 0.2s} +.form-input:focus,.form-select:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(0,212,255,0.1)} +.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px} + +/* 状态徽章 */ +.badge{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:500} +.badge-success{background:rgba(78,205,196,0.15);color:var(--success)} +.badge-danger{background:rgba(255,107,107,0.15);color:var(--danger)} +.badge-warning{background:rgba(255,217,61,0.15);color:var(--warning)} +.badge-info{background:rgba(0,212,255,0.15);color:var(--accent)} + +/* 进度条 */ +.progress-bar{height:8px;background:var(--bg);border-radius:4px;overflow:hidden} +.progress-fill{height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent));border-radius:4px;transition:width 0.5s} +.progress-label{display:flex;justify-content:space-between;font-size:12px;color:var(--text2);margin-top:6px} + +/* 统计卡片 */ +.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:20px} +.stat-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:16px} +.stat-card .value{font-size:24px;font-weight:700;color:var(--accent);margin-bottom:4px} +.stat-card .label{font-size:12px;color:var(--text3)} + +/* 模态框 */ +.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;display:flex;align-items:center;justify-content:center;display:none} +.modal-overlay.active{display:flex} +.modal{background:var(--bg2);border:1px solid var(--border);border-radius:14px;padding:24px;width:460px;max-width:90vw;max-height:90vh;overflow-y:auto} +.modal-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px} +.modal-title{font-size:16px;font-weight:600} +.modal-close{background:none;border:none;color:var(--text2);font-size:20px;cursor:pointer} +.modal-footer{display:flex;justify-content:flex-end;gap:8px;margin-top:20px} + +/* Toast */ +.toast{position:fixed;bottom:24px;right:24px;background:var(--bg3);border:1px solid var(--border);border-radius:10px;padding:12px 20px;font-size:13px;z-index:200;display:none;animation:slideIn 0.3s} +.toast.success{border-color:var(--success);color:var(--success)} +.toast.error{border-color:var(--danger);color:var(--danger)} +.toast.active{display:block} +@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}} + +/* 权限树 */ +.tree-item{margin-left:24px;margin-top:8px} +.tree-node{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px 14px;margin-bottom:6px} +.tree-node-header{display:flex;align-items:center;justify-content:space-between} +.tree-node-title{font-weight:600;font-size:13px} +.tree-users{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px} +.tree-user{background:var(--bg3);border-radius:20px;padding:3px 10px;font-size:11px;display:flex;align-items:center;gap:4px} + +/* 加载动画 */ +.loading{text-align:center;padding:40px;color:var(--text3)} +.spinner{display:inline-block;width:24px;height:24px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin 0.8s linear infinite;margin-bottom:12px} +@keyframes spin{to{transform:rotate(360deg)}} + +/* 滚动条 */ +::-webkit-scrollbar{width:6px;height:6px} +::-webkit-scrollbar-track{background:transparent} +::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px} +::-webkit-scrollbar-thumb:hover{background:rgba(0,212,255,0.3)} diff --git a/app/templates/css/views.css b/app/templates/css/views.css new file mode 100644 index 0000000..9438978 --- /dev/null +++ b/app/templates/css/views.css @@ -0,0 +1,4 @@ +/* 视图页面通用 */ +.view-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px} +.view-title{font-size:18px;font-weight:700} +.toolbar{display:flex;gap:8px;flex-wrap:wrap} diff --git a/app/templates/js/app.js b/app/templates/js/app.js new file mode 100644 index 0000000..69e0328 --- /dev/null +++ b/app/templates/js/app.js @@ -0,0 +1,55 @@ +const API = '/api'; +let _token = localStorage.getItem('smb_token'); + +function getToken(){return _token || localStorage.getItem('smb_token');} + +async function api(path, opts={}){ + const token = getToken(); + const headers = {'Content-Type':'application/json'}; + if(token) headers['Authorization']=`Bearer ${token}`; + const resp = await fetch(API+path, {...opts, headers:{...headers,...opts.headers}}); + if(resp.status===401){localStorage.removeItem('smb_token');location.href='/';return null;} + if(!resp.ok){const e=await resp.json().catch(()=>({detail:'请求失败'}));throw new Error(e.detail||'请求失败');} + return resp.json(); +} + +async function apiFile(path, opts={}){ + const token = getToken(); + const headers = {}; + if(token) headers['Authorization']=`Bearer ${token}`; + const resp = await fetch(API+path, {...opts, headers:{...headers,...opts.headers}}); + if(resp.status===401){localStorage.removeItem('smb_token');location.href='/';return null;} + if(!resp.ok){const e=await resp.json().catch(()=>({detail:'请求失败'}));throw new Error(e.detail||'请求失败');} + return resp.blob(); +} + +function formatBytes(bytes){ + if(!bytes) return '0 B'; + const k=1024; + const sizes=['B','KB','MB','GB','TB']; + const i=Math.floor(Math.log(bytes)/Math.log(k)); + return parseFloat((bytes/Math.pow(k,i)).toFixed(2))+' '+sizes[i]; +} + +function toast(msg,type='success'){ + const t=document.getElementById('toast'); + t.textContent=msg;t.className=`toast ${type} active`; + setTimeout(()=>t.className='toast',3000); +} + +function formatDate(d){ + if(!d) return '-'; + const dt=new Date(d); + return dt.toLocaleString('zh-CN'); +} + +async function confirmAction(title,message){ + return new Promise(resolve=>{ + const ov=document.getElementById('confirmOverlay'); + document.getElementById('confirmTitle').textContent=title; + document.getElementById('confirmMsg').textContent=message; + document.getElementById('confirmOk').onclick=()=>{ov.classList.remove('active');resolve(true);}; + document.getElementById('confirmCancel').onclick=()=>{ov.classList.remove('active');resolve(false);}; + ov.classList.add('active'); + }); +} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..5a8a3ba --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,122 @@ + + + + + +登录 - SMB 文件服务器 + + + +
+ +
+
+
+ +
+ + +
+
+ + +
+ +
+
+ + + diff --git a/app/templates/views/dashboard.html b/app/templates/views/dashboard.html new file mode 100644 index 0000000..f4c8997 --- /dev/null +++ b/app/templates/views/dashboard.html @@ -0,0 +1,117 @@ + + + + +仪表盘 - SMB 文件服务器 + + + + +
+ +
+

🏠 仪表盘

+
+
+
📈 系统总览
+
+ + + +
+
+ +
加载中...
+ +
+
+
+ +
+ + + + diff --git a/app/templates/views/departments.html b/app/templates/views/departments.html new file mode 100644 index 0000000..98c4d59 --- /dev/null +++ b/app/templates/views/departments.html @@ -0,0 +1,113 @@ + + + + +部门管理 - SMB 文件服务器 + + + + +
+ +
+

👥 部门管理

+
+
+
+
+ + +
+
+
+ + + +
ID部门名称文件夹路径用户数已用空间创建时间操作
加载中...
+
+
+
+
+ + + + +
+ + + + diff --git a/app/templates/views/logs.html b/app/templates/views/logs.html new file mode 100644 index 0000000..dc51b10 --- /dev/null +++ b/app/templates/views/logs.html @@ -0,0 +1,94 @@ + + + + +操作日志 - SMB 文件服务器 + + + + +
+ +
+

📋 操作日志

+
+
+
+
+ + +
+
+
+ + + +
时间操作者操作类型对象类型对象名称详情IP
加载中...
+
+ + 第1页 + +
+
+
+
+
+
+ + + + diff --git a/app/templates/views/monitor.html b/app/templates/views/monitor.html new file mode 100644 index 0000000..806363a --- /dev/null +++ b/app/templates/views/monitor.html @@ -0,0 +1,76 @@ + + + + +磁盘监控 - SMB 文件服务器 + + + + +
+ +
+

📈 磁盘监控

+
+
+
+ +
+
+
💾 总览
+
+
+ 已用: - / 总计: - + - +
+
+
加载中...
+
+
+
+
+ + + + diff --git a/app/templates/views/permissions.html b/app/templates/views/permissions.html new file mode 100644 index 0000000..f8cb69b --- /dev/null +++ b/app/templates/views/permissions.html @@ -0,0 +1,69 @@ + + + + +权限管理 - SMB 文件服务器 + + + + +
+ +
+

🔒 权限管理

+
+
+
部门-用户权限树
+ +
+
加载中...
+
+
+
+
+ + + + diff --git a/app/templates/views/users.html b/app/templates/views/users.html new file mode 100644 index 0000000..ab2dae8 --- /dev/null +++ b/app/templates/views/users.html @@ -0,0 +1,232 @@ + + + + +用户管理 - SMB 文件服务器 + + + + +
+ +
+

👤 用户管理

+
+
+
+
+ + 📄 下载模板 + + + +
+
+
+ + + +
ID用户名显示名所属部门创建时间操作
加载中...
+
+
+
+
+ + + + + + + + + + +
+ + + + diff --git a/app/users.html b/app/users.html new file mode 100644 index 0000000..b35e5cb --- /dev/null +++ b/app/users.html @@ -0,0 +1,221 @@ + + + + +用户管理 - SMB 文件服务器 + + + + +
+ +
+

👤 用户管理

+
+
+
+
+ + 📄 下载模板 + + + +
+
+
+ + + +
ID用户名显示名所属部门创建时间操作
加载中...
+
+
+
+
+ + + + + + + + + + +
+ + + + diff --git a/app/users.py b/app/users.py new file mode 100644 index 0000000..4a5bf01 --- /dev/null +++ b/app/users.py @@ -0,0 +1,438 @@ +"""用户管理路由""" +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) # SGID + group rwx + # 用户文件夹 + os.chown(folder_path, uid, gid) + os.chmod(folder_path, 0o2770) + 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) + + 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 + ) diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..b956aca --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1 @@ +# utils __init__.py diff --git a/app/utils/helpers.py b/app/utils/helpers.py new file mode 100644 index 0000000..e6c5bf0 --- /dev/null +++ b/app/utils/helpers.py @@ -0,0 +1,30 @@ +"""辅助函数""" +import subprocess +import os + + +def run_cmd(cmd: list, timeout: int = 30, input_str: str = None) -> tuple: + """执行系统命令,返回 (returncode, stdout, stderr)""" + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + input=input_str + ) + return result.returncode, result.stdout, result.stderr + except subprocess.TimeoutExpired: + return -1, "", "Command timeout" + except Exception as e: + return -1, "", str(e) + + +def ensure_dir(path: str, mode: int = 0o755) -> bool: + """确保目录存在""" + try: + os.makedirs(path, exist_ok=True) + os.chmod(path, mode) + return True + except Exception: + return False diff --git a/app/utils/password_hash.py b/app/utils/password_hash.py new file mode 100644 index 0000000..9dbd4c9 --- /dev/null +++ b/app/utils/password_hash.py @@ -0,0 +1,12 @@ +"""密码哈希""" +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) diff --git a/app/utils/pinyin.py b/app/utils/pinyin.py new file mode 100644 index 0000000..edb591d --- /dev/null +++ b/app/utils/pinyin.py @@ -0,0 +1,7 @@ +"""中文转拼音""" +from pypinyin import lazy_pinyin + + +def cn_to_pinyin(name: str) -> str: + """中文姓名转全拼,无声调""" + return "".join(lazy_pinyin(name)) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6f071b0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +# requirements.txt +fastapi==0.115.0 +uvicorn[standard]==0.30.0 +sqlalchemy==2.0.35 +aiosqlite==0.20.0 +pydantic==2.9.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +pypinyin==0.53.0 +openpyxl==3.1.5 +python-multipart==0.0.9