阶段四:实战篇 ⭐⭐⭐ 实战

第 11 章:NAS 核心模块分步实现

用 AI 逐一实现存储、共享、用户、Web 四大核心模块

🎯 本章学习目标

  1. 完整实现存储管理模块:磁盘发现、挂载、RAID、SMART 监控
  2. 实现网络服务管理:Samba、NFS 的配置与动态管理
  3. 构建用户与权限系统:认证、授权、配额管理
  4. 开发Web 管理后台:FastAPI + Vue 3 全栈实现
  5. 掌握AI 协作编程的完整实战流程

11.1 模块 1:存储管理系统

核心类设计

"""nas/storage/disk_manager.py — 磁盘管理核心模块

使用 AI 生成的提示词模板:
角色:Linux 系统编程专家
任务:实现磁盘发现、信息获取、挂载管理
约束:Python 3.11+, 系统命令通过 CommandExecutor 封装
"""

import subprocess
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional


@dataclass
class DiskInfo:
    """磁盘信息数据模型"""
    device: str              # /dev/sda
    model: str = ""          # WDC WD40EFRX-68N
    serial: str = ""         # 序列号
    size_bytes: int = 0      # 总字节数
    transport: str = ""      # sata / usb / nvme
    rotational: bool = True  # True=HDD, False=SSD
    partitions: list[dict] = field(default_factory=list)
    smart_status: str = "unknown"  # ok / warning / error / unknown
    smart_attrs: dict = field(default_factory=dict)


class DiskManager:
    """磁盘管理器 — 发现、信息获取、健康监控"""

    @staticmethod
    def list_disks(include_partitions: bool = False) -> list[DiskInfo]:
        """通过 lsblk 发现所有块设备"""
        cmd = ["lsblk", "-J", "-o",
               "NAME,SIZE,MODEL,SERIAL,TRAN,ROTA,MOUNTPOINT,FSTYPE,UUID"]
        if not include_partitions:
            cmd.insert(2, "-d")  # 只显示顶层设备

        result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
        if result.returncode != 0:
            raise RuntimeError(f"lsblk 执行失败: {result.stderr}")

        data = json.loads(result.stdout)
        disks = []
        for dev in data.get("blockdevices", []):
            disk = DiskInfo(
                device=f"/dev/{dev['name']}",
                model=dev.get("model", ""),
                serial=dev.get("serial", ""),
                size_bytes=dev.get("size", 0),
                transport=dev.get("tran", ""),
                rotational=dev.get("rota", True),
                partitions=dev.get("children", []),
            )
            # 获取 SMART 状态
            disk.smart_status = DiskManager._get_smart_status(disk.device)
            disks.append(disk)
        return disks

    @staticmethod
    def get_disk_detail(device: str) -> Optional[DiskInfo]:
        """获取单个磁盘的详细信息"""
        disks = DiskManager.list_disks()
        for d in disks:
            if d.device == device:
                d.smart_attrs = DiskManager._get_smart_attributes(device)
                return d
        return None

    @staticmethod
    def _get_smart_status(device: str) -> str:
        """获取 SMART 健康状态"""
        try:
            result = subprocess.run(
                ["smartctl", "-H", device],
                capture_output=True, text=True, timeout=30
            )
            if "PASSED" in result.stdout:
                return "ok"
            elif "FAILED" in result.stdout:
                return "error"
            return "unknown"
        except (subprocess.TimeoutExpired, FileNotFoundError):
            return "unknown"

    @staticmethod
    def _get_smart_attributes(device: str) -> dict:
        """获取详细 SMART 属性"""
        try:
            result = subprocess.run(
                ["smartctl", "-A", device],
                capture_output=True, text=True, timeout=30
            )
            attrs = {}
            for line in result.stdout.split("\n"):
                parts = line.strip().split()
                if len(parts) >= 10 and parts[0].isdigit():
                    attrs[parts[1]] = {
                        "id": int(parts[0]),
                        "value": int(parts[3]),
                        "worst": int(parts[4]),
                        "threshold": int(parts[5]),
                        "raw_value": parts[9],
                    }
            return attrs
        except Exception:
            return {}


class MountManager:
    """挂载管理器 — 挂载、卸载、fstab 管理"""

    MOUNT_BASE = Path("/mnt/nas")

    @classmethod
    def mount(cls, device: str, mount_point: str, fs_type: str = "auto",
              options: list[str] = None) -> bool:
        """挂载设备到指定目录"""
        target = cls.MOUNT_BASE / mount_point.lstrip("/")
        target.mkdir(parents=True, exist_ok=True)

        cmd = ["mount"]
        if fs_type != "auto":
            cmd.extend(["-t", fs_type])
        if options:
            cmd.extend(["-o", ",".join(options)])
        cmd.extend([device, str(target)])

        result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
        if result.returncode != 0:
            raise OSError(f"挂载失败: {result.stderr.strip()}")
        return True

    @classmethod
    def unmount(cls, mount_point: str, force: bool = False) -> bool:
        """卸载设备"""
        target = cls.MOUNT_BASE / mount_point.lstrip("/")
        cmd = ["umount"]
        if force:
            cmd.append("-f")
        cmd.append(str(target))

        result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
        if result.returncode != 0:
            raise OSError(f"卸载失败: {result.stderr.strip()}")
        return True

    @classmethod
    def get_mounts(cls) -> list[dict]:
        """获取当前所有挂载点"""
        with open("/proc/mounts") as f:
            mounts = []
            for line in f:
                parts = line.strip().split()
                if len(parts) >= 4:
                    mounts.append({
                        "device": parts[0],
                        "mount_point": parts[1],
                        "fs_type": parts[2],
                        "options": parts[3],
                    })
            return mounts

11.2 模块 2:网络服务管理

Samba 共享管理器

"""nas/services/samba_manager.py — SMB 共享管理"""

import subprocess
import tempfile
import shutil
from pathlib import Path
from dataclasses import dataclass, field


@dataclass
class SMBShareConfig:
    """SMB 共享配置"""
    name: str
    path: str
    comment: str = ""
    browseable: bool = True
    read_only: bool = False
    guest_ok: bool = False
    valid_users: list[str] = field(default_factory=list)
    write_list: list[str] = field(default_factory=list)
    create_mask: str = "0664"
    directory_mask: str = "0775"


class SambaManager:
    """Samba 服务管理器"""

    SMB_CONF = Path("/etc/samba/smb.conf")
    BACKUP_DIR = Path("/etc/samba/backups")

    @classmethod
    def add_share(cls, config: SMBShareConfig) -> bool:
        """添加 SMB 共享"""
        # 验证共享名
        if not config.name or len(config.name) > 15:
            raise ValueError("共享名必须是 1-15 个字符")
        if not Path(config.path).exists():
            Path(config.path).mkdir(parents=True, exist_ok=True)

        # 生成配置段
        section = cls._generate_config_section(config)

        # 备份原配置
        cls._backup_config()

        # 追加配置
        with open(cls.SMB_CONF, 'a') as f:
            f.write(f"\n{section}\n")

        # 重载服务
        return cls.reload()

    @classmethod
    def remove_share(cls, name: str) -> bool:
        """删除 SMB 共享"""
        cls._backup_config()
        content = cls.SMB_CONF.read_text()
        # 移除 [share_name] 段(包括其所有配置行)
        pattern = rf'\n?\s*\[{re.escape(name)}\].*?(?=\n\s*\[|\Z)'
        content = re.sub(pattern, '', content, flags=re.DOTALL)
        cls.SMB_CONF.write_text(content)
        return cls.reload()

    @classmethod
    def list_shares(cls) -> list[dict]:
        """列出所有自定义共享"""
        content = cls.SMB_CONF.read_text()
        shares = []
        in_share = False
        current = {}

        for line in content.split("\n"):
            line = line.strip()
            if line.startswith("[") and line.endswith("]"):
                if in_share and current:
                    shares.append(current)
                name = line[1:-1]
                # 跳过全局段和默认共享
                if name not in ("global", "homes", "printers", "print$"):
                    in_share = True
                    current = {"name": name}
                else:
                    in_share = False
                    current = {}
            elif in_share and "=" in line:
                key, val = line.split("=", 1)
                current[key.strip()] = val.strip()

        if in_share and current:
            shares.append(current)
        return shares

    @classmethod
    def reload(cls) -> bool:
        """重载 Samba 配置"""
        result = subprocess.run(
            ["systemctl", "reload", "smbd"],
            capture_output=True, text=True, timeout=10
        )
        return result.returncode == 0

    @staticmethod
    def _generate_config_section(config: SMBShareConfig) -> str:
        lines = [
            f"[{config.name}]",
            f"  path = {config.path}",
        ]
        if config.comment:
            lines.append(f"  comment = {config.comment}")
        lines.append(f"  browseable = {'yes' if config.browseable else 'no'}")
        lines.append(f"  read only = {'yes' if config.read_only else 'no'}")
        lines.append(f"  guest ok = {'yes' if config.guest_ok else 'no'}")
        if config.valid_users:
            lines.append(f"  valid users = {' '.join(config.valid_users)}")
        if config.write_list:
            lines.append(f"  write list = {' '.join(config.write_list)}")
        lines.append(f"  create mask = {config.create_mask}")
        lines.append(f"  directory mask = {config.directory_mask}")
        return "\n".join(lines)

    @classmethod
    def _backup_config(cls):
        cls.BACKUP_DIR.mkdir(parents=True, exist_ok=True)
        import time
        stamp = time.strftime("%Y%m%d_%H%M%S")
        shutil.copy2(cls.SMB_CONF, cls.BACKUP_DIR / f"smb.conf.{stamp}")

11.3 模块 3:用户与权限系统

"""nas/auth/user_manager.py — 用户认证与权限管理"""

import subprocess
import pwd
import grp
import os
from dataclasses import dataclass
from typing import Optional


@dataclass
class UserInfo:
    username: str
    uid: int
    gid: int
    groups: list[str]
    home: str
    shell: str
    quota_gb: float = 0


class UserManager:
    """NAS 用户管理器 — PAM 认证 + 本地用户管理"""

    @staticmethod
    def list_users(min_uid: int = 1000) -> list[UserInfo]:
        """列出所有普通用户"""
        users = []
        for entry in pwd.getpwall():
            if entry.pw_uid >= min_uid and entry.pw_uid < 65534:
                groups = [
                    g.gr_name for g in grp.getgrall()
                    if entry.pw_name in g.gr_mem
                ]
                # 获取用户主组
                main_group = grp.getgrgid(entry.pw_gid)
                if main_group.gr_name not in groups:
                    groups.insert(0, main_group.gr_name)

                users.append(UserInfo(
                    username=entry.pw_name,
                    uid=entry.pw_uid,
                    gid=entry.pw_gid,
                    groups=groups,
                    home=entry.pw_dir,
                    shell=entry.pw_shell,
                ))
        return users

    @classmethod
    def create_user(cls, username: str, password: str,
                    groups: list[str] = None, shell: str = "/bin/bash") -> bool:
        """创建 NAS 用户"""
        # 验证用户名
        if not username or not username.isalnum():
            raise ValueError("用户名只能包含字母和数字")

        # 检查是否已存在
        try:
            pwd.getpwnam(username)
            raise ValueError(f"用户 {username} 已存在")
        except KeyError:
            pass

        # 创建用户
        home = f"/home/{username}"
        cmd = ["useradd", "-m", "-d", home, "-s", shell, username]
        result = subprocess.run(cmd, capture_output=True, text=True)
        if result.returncode != 0:
            raise RuntimeError(f"用户创建失败: {result.stderr}")

        # 设置密码
        set_pw = subprocess.run(
            ["chpasswd"],
            input=f"{username}:{password}",
            capture_output=True, text=True
        )
        if set_pw.returncode != 0:
            # 回滚
            subprocess.run(["userdel", "-r", username])
            raise RuntimeError(f"密码设置失败: {set_pw.stderr}")

        # 添加到附加组
        if groups:
            subprocess.run(["usermod", "-aG", ",".join(groups), username])

        return True

    @classmethod
    def delete_user(cls, username: str, remove_home: bool = True) -> bool:
        """删除用户"""
        # 防止删除系统用户
        entry = pwd.getpwnam(username)
        if entry.pw_uid < 1000:
            raise ValueError(f"不能删除系统用户: {username}")

        cmd = ["userdel"]
        if remove_home:
            cmd.append("-r")
        cmd.append(username)

        result = subprocess.run(cmd, capture_output=True, text=True)
        return result.returncode == 0

    @classmethod
    def set_quota(cls, username: str, soft_gb: float, hard_gb: float,
                  filesystem: str = "/") -> bool:
        """设置用户磁盘配额"""
        soft_kb = int(soft_gb * 1024 * 1024)
        hard_kb = int(hard_gb * 1024 * 1024)

        result = subprocess.run(
            ["setquota", "-u", username,
             str(soft_kb), str(hard_kb), "0", "0", filesystem],
            capture_output=True, text=True
        )
        return result.returncode == 0

    @staticmethod
    def authenticate(username: str, password: str) -> bool:
        """通过 PAM 验证用户密码"""
        import pwd
        import crypt
        import spwd

        try:
            shadow = spwd.getspnam(username)
            hashed = shadow.sp_pwd if shadow.sp_pwd != "!" else None
            if not hashed:
                return False
            return crypt.crypt(password, hashed) == hashed
        except (KeyError, PermissionError):
            return False

📋 本章核心提示词模板

模块实现的标准提示词模板

📋
## 角色
你是一位 [NAS 存储系统 / Linux 网络服务 / 安全认证系统] 专家。

## 任务
为 NAS 操作系统实现 [模块名] 模块。

### 功能需求
[列出具体功能,每个功能一行,可验证的]

### 接口契约
模块必须提供以下公开接口:
- [方法签名] — [功能描述]
- [方法签名] — [功能描述]

## 约束
- Python 3.11+,类型注解完整
- 所有系统命令通过 subprocess 调用,带超时
- 所有公开方法有 Google-style docstring
- 错误处理:系统调用失败 → 抛出自定义异常(不是 None 或 False)
- 线程安全(如果涉及共享状态)

## 输出
1. 完整的 Python 模块文件
2. 单元测试文件(使用 pytest)
3. 使用示例代码

🛠️ 实战演练:Web API 端点实现

用 AI 生成 FastAPI 磁盘管理 API

提示词

请为 NAS 磁盘管理模块生成 FastAPI 路由。
DiskManager 和 MountManager 类已实现(文件名 disk_manager.py)。

需要的端点:
GET  /api/disks          — 列出所有磁盘(含 SMART 状态)
GET  /api/disks/{device} — 获取单个磁盘详情
POST /api/disks/mount    — 挂载磁盘
POST /api/disks/unmount  — 卸载磁盘
GET  /api/disks/mounts   — 列出所有挂载点

要求:
- 使用 FastAPI 依赖注入获取 DiskManager 实例
- 所有端点需要 JWT 认证(使用已有的 get_current_user 依赖)
- 请求/响应使用 Pydantic 模型
- 错误响应统一格式:{"detail": "错误描述"}
- 返回中文错误消息

AI 输出

"""nas/api/disk_routes.py — 磁盘管理 API 路由"""

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from typing import Optional

from nas.storage.disk_manager import DiskManager, MountManager
from nas.auth.dependencies import get_current_user

router = APIRouter(prefix="/api/disks", tags=["磁盘管理"])


class MountRequest(BaseModel):
    device: str = Field(..., description="设备路径,如 /dev/sdb1")
    mount_point: str = Field(..., description="挂载点,如 data/backup")
    fs_type: str = Field(default="auto", description="文件系统类型")
    options: Optional[list[str]] = Field(default=None)

class UnmountRequest(BaseModel):
    mount_point: str = Field(..., description="挂载点路径")
    force: bool = Field(default=False, description="强制卸载")


@router.get("/", summary="列出所有磁盘")
async def list_disks(user=Depends(get_current_user)):
    """获取系统中所有块设备及其 SMART 健康状态"""
    try:
        disks = DiskManager.list_disks()
        return {
            "total": len(disks),
            "disks": [
                {
                    "device": d.device,
                    "model": d.model,
                    "size_bytes": d.size_bytes,
                    "size_human": DiskManager._format_size(d.size_bytes),
                    "transport": d.transport,
                    "type": "HDD" if d.rotational else "SSD",
                    "smart_status": d.smart_status,
                }
                for d in disks
            ]
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"获取磁盘列表失败: {str(e)}")


@router.get("/{device}", summary="获取磁盘详情")
async def get_disk_detail(device: str, user=Depends(get_current_user)):
    """获取指定磁盘的详细信息(含 SMART 属性)"""
    try:
        info = DiskManager.get_disk_detail(f"/dev/{device}")
        if not info:
            raise HTTPException(status_code=404, detail=f"磁盘 {device} 不存在")
        return {
            "device": info.device,
            "model": info.model,
            "serial": info.serial,
            "size_bytes": info.size_bytes,
            "smart_status": info.smart_status,
            "smart_attributes": info.smart_attrs,
            "partitions": info.partitions,
        }
    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@router.post("/mount", summary="挂载磁盘")
async def mount_disk(req: MountRequest, user=Depends(get_current_user)):
    """将磁盘分区挂载到指定目录"""
    try:
        MountManager.mount(req.device, req.mount_point, req.fs_type, req.options)
        return {"status": "ok", "device": req.device, "mount_point": req.mount_point}
    except OSError as e:
        raise HTTPException(status_code=400, detail=str(e))


@router.post("/unmount", summary="卸载磁盘")
async def unmount_disk(req: UnmountRequest, user=Depends(get_current_user)):
    """卸载已挂载的磁盘"""
    try:
        MountManager.unmount(req.mount_point, req.force)
        return {"status": "ok", "mount_point": req.mount_point}
    except OSError as e:
        raise HTTPException(status_code=400, detail=str(e))


@router.get("/mounts/list", summary="列出挂载点")
async def list_mounts(user=Depends(get_current_user)):
    """获取当前系统的所有挂载点"""
    return {"mounts": MountManager.get_mounts()}

⚠️ 常见坑点

🕳️ 坑 1:系统命令超时不处理

mount、umount、smartctl 等命令在异常磁盘上可能挂起数分钟。所有 subprocess.run 必须带 timeout 参数。

🕳️ 坑 2:直接拼接 shell 命令

永远不要用 shell=True 或 os.system。用 subprocess.run([...]) 的列表形式,避免命令注入。

🕳️ 坑 3:修改系统配置不备份

修改 /etc/samba/smb.conf、/etc/fstab 等文件前必须备份。使用 timestamp + copy2 的组合。

📝 本章小结

  • 存储管理:通过 lsblk/smartctl/mount 等系统命令封装,AI 帮助生成类型安全的数据模型和错误处理
  • 网络服务:Samba 配置管理遵循"备份-修改-重载"的安全流程
  • 用户管理:PAM + crypt 认证,配额管理通过 setquota 系统命令实现
  • Web API:FastAPI + Pydantic 模型,所有端点需要 JWT 认证
  • 每个模块的 AI 生成都遵循相同的角色-任务-约束-输出模板

🤔 思考练习

  1. 用本章的代码框架,让 AI 补充实现 NFS 导出管理器(NFSManager)。
  2. 为 UserManager 编写完整的 pytest 测试套件,包括 Mock 系统调用。
  3. 用 AI 生成一个磁盘性能基准测试工具(使用 fio 或 dd 命令),并集成到 DiskManager 中。