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 文件服务器
+
+
+
+
+
+
+
+
+
+
📁
+
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