init: 从 /root/zikai 根目录迁入
把原本散落在 /root/zikai 的 FastAPI 服务整理到 server/ 子目录, 开启独立 git 与 venv。运行时数据(config.yaml / keys / uploads) 按 .gitignore 留在工作目录但不入仓。
This commit is contained in:
6
app/services/__init__.py
Normal file
6
app/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""业务逻辑层(Service)。"""
|
||||
|
||||
from .system_service import SystemService
|
||||
from .upload_service import UploadService
|
||||
|
||||
__all__ = ["SystemService", "UploadService"]
|
||||
161
app/services/sftp_server.py
Normal file
161
app/services/sftp_server.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""嵌入式 SFTP 服务,与 HTTP API 共用 uploads/ 目录。
|
||||
|
||||
启动方式:
|
||||
python -m app.services.sftp_server
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import asyncssh
|
||||
import bcrypt
|
||||
|
||||
from ..config import PROJECT_ROOT, get_settings
|
||||
|
||||
logger = logging.getLogger("sftp")
|
||||
|
||||
|
||||
class ZikaiSFTPServer(asyncssh.SFTPServer):
|
||||
"""会话期把客户端 chroot 到 upload_root。"""
|
||||
|
||||
def __init__(self, chan, *, upload_root: Path) -> None:
|
||||
super().__init__(chan, chroot=str(upload_root).encode())
|
||||
try:
|
||||
self._username = chan.get_extra_info("username") or "unknown"
|
||||
except Exception: # pragma: no cover
|
||||
self._username = "unknown"
|
||||
logger.info("SFTP 会话开始 user=%s chroot=%s", self._username, upload_root)
|
||||
|
||||
def exit(self) -> None:
|
||||
logger.info("SFTP 会话结束 user=%s", self._username)
|
||||
|
||||
|
||||
class ZikaiSSHServer(asyncssh.SSHServer):
|
||||
"""支持密码(bcrypt)与公钥两种鉴权。"""
|
||||
|
||||
def __init__(self, settings, authorized_keys: asyncssh.SSHAuthorizedKeys | None) -> None:
|
||||
self._settings = settings
|
||||
self._authorized_keys = authorized_keys
|
||||
self._conn: asyncssh.SSHServerConnection | None = None
|
||||
|
||||
def connection_made(self, conn: asyncssh.SSHServerConnection) -> None: # type: ignore[override]
|
||||
self._conn = conn
|
||||
|
||||
def begin_auth(self, username: str) -> bool:
|
||||
# 始终要求鉴权;用户不存在时所有方法会失败 → 干净的 permission denied
|
||||
return True
|
||||
|
||||
# 密码
|
||||
|
||||
def password_auth_supported(self) -> bool:
|
||||
return True
|
||||
|
||||
def validate_password(self, username: str, password: str) -> bool:
|
||||
user = next((u for u in self._settings.sftp.users if u.username == username), None)
|
||||
if user is None or not user.password_hash or user.password_hash == "CHANGE_ME_BCRYPT_HASH":
|
||||
return False
|
||||
try:
|
||||
ok = bcrypt.checkpw(password.encode(), user.password_hash.encode())
|
||||
except (ValueError, TypeError):
|
||||
ok = False
|
||||
logger.info("SFTP 密码鉴权 %s user=%s", "OK" if ok else "FAIL", username)
|
||||
return ok
|
||||
|
||||
# 公钥
|
||||
|
||||
def public_key_auth_supported(self) -> bool:
|
||||
return self._authorized_keys is not None
|
||||
|
||||
def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool:
|
||||
if self._authorized_keys is None:
|
||||
return False
|
||||
if not any(u.username == username for u in self._settings.sftp.users):
|
||||
return False
|
||||
addr = ""
|
||||
if self._conn:
|
||||
peer = self._conn.get_extra_info("peername")
|
||||
addr = peer[0] if isinstance(peer, tuple) and peer else ""
|
||||
try:
|
||||
# asyncssh 命中返回 dict(可能为空),未命中返回 None
|
||||
result = self._authorized_keys.validate(key, client_host=addr, client_addr=addr)
|
||||
except Exception:
|
||||
result = None
|
||||
ok = result is not None
|
||||
logger.info("SFTP 公钥鉴权 %s user=%s", "OK" if ok else "FAIL", username)
|
||||
return ok
|
||||
|
||||
|
||||
def _load_authorized_keys(path: Path) -> asyncssh.SSHAuthorizedKeys | None:
|
||||
if not path.exists() or path.stat().st_size == 0:
|
||||
return None
|
||||
try:
|
||||
return asyncssh.read_authorized_keys(str(path))
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("解析 %s 失败:%s", path, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_host_key(path: Path) -> None:
|
||||
if path.exists():
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("生成 ed25519 SFTP 主机密钥:%s", path)
|
||||
key = asyncssh.generate_private_key("ssh-ed25519")
|
||||
key.write_private_key(str(path))
|
||||
key.write_public_key(str(path) + ".pub")
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
async def _run() -> None:
|
||||
settings = get_settings()
|
||||
if not settings.sftp.enabled:
|
||||
logger.info("SFTP 在配置中已禁用,进程退出。")
|
||||
return
|
||||
|
||||
upload_root = settings.resolved_upload_dir()
|
||||
upload_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
host_key_path = (PROJECT_ROOT / settings.sftp.host_key_path).resolve()
|
||||
_ensure_host_key(host_key_path)
|
||||
|
||||
authorized_keys = _load_authorized_keys(
|
||||
(PROJECT_ROOT / settings.sftp.authorized_keys_path).resolve()
|
||||
)
|
||||
|
||||
def sftp_factory(chan) -> ZikaiSFTPServer:
|
||||
return ZikaiSFTPServer(chan, upload_root=upload_root)
|
||||
|
||||
logger.info(
|
||||
"启动 SFTP 服务 %s:%d (root=%s)",
|
||||
settings.sftp.host, settings.sftp.port, upload_root,
|
||||
)
|
||||
await asyncssh.create_server(
|
||||
lambda: ZikaiSSHServer(settings, authorized_keys),
|
||||
settings.sftp.host,
|
||||
settings.sftp.port,
|
||||
server_host_keys=[str(host_key_path)],
|
||||
sftp_factory=sftp_factory,
|
||||
allow_scp=False,
|
||||
)
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
try:
|
||||
asyncio.run(_run())
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
logger.info("SFTP 服务退出。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
45
app/services/system_service.py
Normal file
45
app/services/system_service.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""主机 CPU / 内存 / 磁盘信息采集(基于 psutil)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import time
|
||||
|
||||
import psutil
|
||||
|
||||
from ..schemas.system import DiskUsage, MemoryUsage, SystemStatus
|
||||
|
||||
# 跳过的伪文件系统
|
||||
_IGNORED_FSTYPES = {"squashfs", "tmpfs", "devtmpfs", "overlay"}
|
||||
|
||||
|
||||
class SystemService:
|
||||
def get_status(self) -> SystemStatus:
|
||||
vm = psutil.virtual_memory()
|
||||
memory = MemoryUsage(
|
||||
total=vm.total, available=vm.available, used=vm.used, percent=vm.percent,
|
||||
)
|
||||
|
||||
disks: list[DiskUsage] = []
|
||||
seen: set[str] = set()
|
||||
for part in psutil.disk_partitions(all=False):
|
||||
if part.fstype in _IGNORED_FSTYPES or part.mountpoint in seen:
|
||||
continue
|
||||
try:
|
||||
u = psutil.disk_usage(part.mountpoint)
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
seen.add(part.mountpoint)
|
||||
disks.append(DiskUsage(
|
||||
device=part.device, mountpoint=part.mountpoint, fstype=part.fstype,
|
||||
total=u.total, used=u.used, free=u.free, percent=u.percent,
|
||||
))
|
||||
|
||||
return SystemStatus(
|
||||
hostname=platform.node(),
|
||||
cpu_percent=psutil.cpu_percent(interval=0.5),
|
||||
cpu_count=psutil.cpu_count(logical=True) or 0,
|
||||
memory=memory,
|
||||
disks=disks,
|
||||
uptime_seconds=max(0.0, time.time() - psutil.boot_time()),
|
||||
)
|
||||
122
app/services/upload_service.py
Normal file
122
app/services/upload_service.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""上传服务:流式落盘 + 原子改名 + 元数据入库。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
from ..config import get_settings
|
||||
from ..dao.uploaded_file_dao import UploadedFileDAO
|
||||
from ..models.uploaded_file import UploadedFile
|
||||
from ..schemas.file import FileUploadResponse, UploadedFileOut
|
||||
|
||||
|
||||
class UploadService:
|
||||
def __init__(self, dao: UploadedFileDAO) -> None:
|
||||
s = get_settings()
|
||||
self.dao = dao
|
||||
self.upload_root = s.resolved_upload_dir()
|
||||
self.chunk_bytes = s.storage.chunk_bytes
|
||||
self.hash_on_upload = s.storage.sha256_on_upload
|
||||
|
||||
# ---------------- 上传 ----------------
|
||||
|
||||
def stream_to_disk(
|
||||
self, file: UploadFile, source: str, uploaded_by: str,
|
||||
) -> FileUploadResponse:
|
||||
"""流式落盘并写元数据。
|
||||
|
||||
先写 ``<final>.part``,DB 行提交成功后再 ``os.replace`` 成正式名。
|
||||
任何失败仅会留下可识别的 ``.part``,由 start.sh 启动时自动清理。
|
||||
"""
|
||||
rel_dir = self._relative_dir()
|
||||
(self.upload_root / rel_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ext = self._safe_ext(file.filename or "")
|
||||
rel_path = rel_dir / f"{uuid.uuid4().hex}{ext}"
|
||||
abs_path = self.upload_root / rel_path
|
||||
part_path = abs_path.with_name(abs_path.name + ".part")
|
||||
|
||||
size, digest = self._write_part(file, part_path)
|
||||
|
||||
entity = UploadedFile(
|
||||
storage_path=str(rel_path),
|
||||
original_filename=os.path.basename(file.filename or rel_path.name),
|
||||
content_type=file.content_type or "",
|
||||
size_bytes=size,
|
||||
sha256=digest,
|
||||
source=source,
|
||||
uploaded_by=uploaded_by,
|
||||
)
|
||||
try:
|
||||
saved = self.dao.create(entity)
|
||||
except Exception:
|
||||
part_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
try:
|
||||
os.replace(part_path, abs_path)
|
||||
except Exception:
|
||||
try:
|
||||
self.dao.delete(saved.id)
|
||||
finally:
|
||||
part_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
return FileUploadResponse(
|
||||
id=saved.id,
|
||||
filename=saved.original_filename,
|
||||
size_bytes=saved.size_bytes,
|
||||
sha256=saved.sha256,
|
||||
storage_path=saved.storage_path,
|
||||
uploaded_at=saved.uploaded_at,
|
||||
)
|
||||
|
||||
# ---------------- 查询 ----------------
|
||||
|
||||
def list_files(self, limit: int = 100, offset: int = 0) -> tuple[int, list[UploadedFileOut]]:
|
||||
rows = self.dao.list(limit=limit, offset=offset)
|
||||
items = [UploadedFileOut.model_validate(r) for r in rows]
|
||||
return len(items), items
|
||||
|
||||
def get_out(self, file_id: int) -> UploadedFileOut | None:
|
||||
row = self.dao.get_by_id(file_id)
|
||||
return UploadedFileOut.model_validate(row) if row else None
|
||||
|
||||
def resolve_disk_path(self, file_id: int) -> Path | None:
|
||||
row = self.dao.get_by_id(file_id)
|
||||
return (self.upload_root / row.storage_path).resolve() if row else None
|
||||
|
||||
# ---------------- 内部 ----------------
|
||||
|
||||
@staticmethod
|
||||
def _relative_dir() -> Path:
|
||||
now = datetime.now(timezone.utc)
|
||||
return Path(f"{now:%Y}/{now:%m}")
|
||||
|
||||
@staticmethod
|
||||
def _safe_ext(filename: str) -> str:
|
||||
return os.path.splitext(os.path.basename(filename))[1]
|
||||
|
||||
def _write_part(self, file: UploadFile, part_path: Path) -> tuple[int, str]:
|
||||
"""流式写到 part_path 并 fsync;返回 (size, sha256)。失败时清理残品。"""
|
||||
hasher = hashlib.sha256() if self.hash_on_upload else None
|
||||
size = 0
|
||||
try:
|
||||
with part_path.open("wb") as out:
|
||||
while chunk := file.file.read(self.chunk_bytes):
|
||||
out.write(chunk)
|
||||
size += len(chunk)
|
||||
if hasher is not None:
|
||||
hasher.update(chunk)
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
except Exception:
|
||||
part_path.unlink(missing_ok=True)
|
||||
raise
|
||||
return size, (hasher.hexdigest() if hasher is not None else "")
|
||||
Reference in New Issue
Block a user