fix: 全站 ruff 清零 — 77 errors → 0

Fixes:
- F821: install.py site_url 未定义(NameError)、sync_v2.py os 未导入、script_jobs.py LOG f-string
- F401: 移除 22 个未使用导入(models/__init__.py、install.py、auth.py 等)
- B904: 20 个 except 块 raise 添加 from e/from None
- S110: 20 个有意的 try-except-pass 添加 noqa 注释(SSH清理/DDL幂等/WS断连)
- B007: 3 个未使用循环变量重命名(dirs→_dirs、server_id→_server_id)
- F541: 3 个无占位符 f-string 修正
- F841: auth.py 未使用 ip_address 变量移除
- S105: auth_service.py Redis key prefix 误报 noqa
- B023: script_execution_flush.py 循环变量绑定修复

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-05-30 20:07:45 +08:00
parent ebe276ea29
commit 32feb1b6db
21 changed files with 63 additions and 72 deletions
-1
View File
@@ -334,7 +334,6 @@ async def issue_webssh_token(
if not server.domain:
raise HTTPException(status_code=400, detail="Server has no domain configured")
ip_address = request.client.host if request.client else ""
result = await service.create_webssh_token(admin, payload.server_id)
return {**result, "server_name": server.name}
+10 -11
View File
@@ -17,7 +17,7 @@ from urllib.parse import quote_plus
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine
from server.infrastructure.database.engine_compat import patch_aiomysql_do_ping
@@ -206,7 +206,7 @@ def _configure_guardian(install_dir: str, api_port: str) -> list[str]:
log_dir.mkdir(parents=True, exist_ok=True)
results.append(f"✓ 日志目录已创建 {log_dir}")
except OSError:
results.append(f"✗ 日志目录创建失败(需 root 权限)")
results.append("✗ 日志目录创建失败(需 root 权限)")
# 2. Supervisor config
if bt_panel:
@@ -320,7 +320,6 @@ async def env_check():
"""Detect environment readiness — Python, MySQL client, Redis, write permissions."""
_reject_post_install_wizard()
import sys
import importlib
checks = []
@@ -406,12 +405,13 @@ async def init_db(req: InitDbRequest):
db_url = _make_db_url(req)
redis_url = _build_redis_url(req)
site_url = req.site_url.rstrip("/") if req.site_url else f"http://127.0.0.1:{req.api_port}"
try:
patch_aiomysql_do_ping()
engine = create_async_engine(db_url, pool_pre_ping=True, pool_recycle=300)
except Exception as e:
raise HTTPException(400, f"数据库引擎创建失败: {e}")
raise HTTPException(400, f"数据库引擎创建失败: {e}") from e
try:
async with engine.begin() as conn:
@@ -435,7 +435,7 @@ async def init_db(req: InitDbRequest):
for idx_sql in indexes:
try:
await conn.execute(text(idx_sql))
except Exception:
except Exception: # noqa: S110 — DDL idempotent, may already exist
pass # Index may already exist
# Schema migrations for existing databases (safe — no-op if column exists)
@@ -445,7 +445,7 @@ async def init_db(req: InitDbRequest):
for mig_sql in migrations:
try:
await conn.execute(text(mig_sql))
except Exception:
except Exception: # noqa: S110 — DDL idempotent, may already exist
pass # Column may already exist
# Calculate pool_size from max_connections
@@ -462,7 +462,7 @@ async def init_db(req: InitDbRequest):
secret_key = secrets.token_hex(32)
api_key = secrets.token_hex(16)
# Generate Fernet-compatible encryption key (32 url-safe base64 bytes)
import base64, hashlib
import base64
encryption_key = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()
# Insert settings
@@ -491,12 +491,11 @@ async def init_db(req: InitDbRequest):
except Exception as e:
await engine.dispose()
raise HTTPException(400, f"数据库初始化失败: {e}")
raise HTTPException(400, f"数据库初始化失败: {e}") from e
await engine.dispose()
# Write .env
site_url = req.site_url.rstrip("/") if req.site_url else f"http://127.0.0.1:{req.api_port}"
_write_env(req, secret_key, api_key, encryption_key, pool_size, max_overflow, redis_url, site_url)
# Write config.json
@@ -588,7 +587,7 @@ async def create_admin(req: CreateAdminRequest):
await engine.dispose()
except Exception as e:
raise HTTPException(400, f"创建管理员失败: {e}")
raise HTTPException(400, f"创建管理员失败: {e}") from e
# Update config.json with brand
if CONFIG_JSON.exists():
@@ -597,7 +596,7 @@ async def create_admin(req: CreateAdminRequest):
config = json.loads(CONFIG_JSON.read_text())
config["app_name"] = req.system_name
CONFIG_JSON.write_text(json.dumps(config, indent=2, ensure_ascii=False))
except Exception:
except Exception: # noqa: S110 — best-effort cleanup
pass
# Update .env with brand
-1
View File
@@ -3,7 +3,6 @@ Presentation layer — receives HTTP requests, delegates to ScriptService.
All operations require JWT authentication.
"""
import json
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
+7 -7
View File
@@ -19,7 +19,7 @@ from server.infrastructure.database.push_schedule_repo import PushScheduleReposi
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
from server.infrastructure.database.ssh_key_preset_repo import SshKeyPresetRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.domain.models import Setting, PushSchedule, PushRetryJob, PasswordPreset, SshKeyPreset, AuditLog, Admin
from server.domain.models import PushSchedule, PushRetryJob, PasswordPreset, SshKeyPreset, AuditLog, Admin
from sqlalchemy.ext.asyncio import AsyncSession
@@ -327,7 +327,7 @@ async def reveal_preset(
try:
plaintext = decrypt_value(preset.encrypted_pw)
except ValueError:
raise HTTPException(status_code=500, detail="解密失败")
raise HTTPException(status_code=500, detail="解密失败") from None
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
@@ -472,7 +472,7 @@ async def reveal_ssh_key_preset(
try:
plaintext = decrypt_value(preset.encrypted_private_key)
except ValueError:
raise HTTPException(status_code=500, detail="解密失败")
raise HTTPException(status_code=500, detail="解密失败") from None
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
@@ -689,7 +689,7 @@ async def telegram_get_chats(
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"请求失败: {e}")
raise HTTPException(status_code=502, detail=f"请求失败: {e}") from e
# Extract unique chats
seen: set[int] = set()
@@ -748,11 +748,11 @@ async def parse_subscription(
raise HTTPException(status_code=400, detail=f"订阅链接返回 HTTP {resp.status_code}")
raw = resp.text
except httpx.TimeoutException:
raise HTTPException(status_code=400, detail="请求订阅链接超时(15s")
raise HTTPException(status_code=400, detail="请求订阅链接超时(15s") from None
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=f"获取订阅失败: {e}")
raise HTTPException(status_code=400, detail=f"获取订阅失败: {e}") from e
hosts = parse_subscription_content(raw)
if not hosts:
@@ -914,7 +914,7 @@ async def get_ip_allowlist(
):
"""Return full allowlist state."""
from server.config import settings as _settings
from server.background.ip_allowlist_refresh import get_last_refresh_time, get_subscription_count
from server.background.ip_allowlist_refresh import get_last_refresh_time
allowlist_on = (_settings.LOGIN_ALLOWLIST_ENABLED or "false").lower() in ("true","1","yes","on")
sub_ips_raw = _settings.LOGIN_SUBSCRIPTION_IPS or ""
+13 -13
View File
@@ -10,7 +10,6 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, LocalFilePreview, SyncVerify, SyncCancel, ValidateSourcePath, SyncDiagnose, FileSyncDiff
from server.api.dependencies import get_server_service
from server.application.services.sync_engine_v2 import SyncEngineV2
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
@@ -227,7 +226,7 @@ async def browse_local_directory(
except OSError:
continue
except PermissionError:
raise HTTPException(status_code=403, detail="无权限访问该目录")
raise HTTPException(status_code=403, detail="无权限访问该目录") from None
return {"path": real_path, "entries": entries}
@@ -261,7 +260,7 @@ async def local_file_operation(
else:
os.unlink(real_path)
except OSError as e:
raise HTTPException(status_code=500, detail=f"删除失败: {e}")
raise HTTPException(status_code=500, detail=f"删除失败: {e}") from e
await _audit_sync(
"local_file_delete", "sync", 0,
@@ -293,7 +292,7 @@ async def local_file_operation(
try:
os.rename(real_path, new_path)
except OSError as e:
raise HTTPException(status_code=500, detail=f"重命名失败: {e}")
raise HTTPException(status_code=500, detail=f"重命名失败: {e}") from e
await _audit_sync(
"local_file_rename", "sync", 0,
@@ -398,7 +397,7 @@ async def cancel_sync(
redis = get_redis()
await redis.set(f"sync:cancel:{payload.batch_id}", "1", ex=3600)
except Exception as e:
raise HTTPException(status_code=500, detail=f"取消操作失败: {e}")
raise HTTPException(status_code=500, detail=f"取消操作失败: {e}") from e
await _audit_sync(
"sync_cancel", "sync", 0,
@@ -745,7 +744,7 @@ async def upload_file(
try:
server_id = int(server_id_raw)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="server_id 必须为数字")
raise HTTPException(status_code=400, detail="server_id 必须为数字") from None
if not file or not hasattr(file, "filename") or not file.filename:
raise HTTPException(status_code=400, detail="请选择要上传的文件")
@@ -785,11 +784,11 @@ async def upload_file(
finally:
await ssh_pool.release(server.id)
except asyncssh.PermissionDenied:
raise HTTPException(status_code=403, detail=f"SSH 权限不足,无法写入 {full_remote_path}")
raise HTTPException(status_code=403, detail=f"SSH 权限不足,无法写入 {full_remote_path}") from None
except asyncssh.SFTPError as e:
raise HTTPException(status_code=400, detail=f"SFTP 上传失败: {e}")
raise HTTPException(status_code=400, detail=f"SFTP 上传失败: {e}") from e
except Exception as e:
raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}")
raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}") from e
# Audit
await _audit_sync(
@@ -881,7 +880,7 @@ async def upload_zip(
# Build file listing (relative paths, capped at 500)
files = []
for root, dirs, fnames in os.walk(extract_dir):
for root, _dirs, fnames in os.walk(extract_dir):
for fn in fnames:
rel = os.path.relpath(os.path.join(root, fn), extract_dir)
files.append(rel)
@@ -921,14 +920,14 @@ async def upload_zip(
os.unlink(zip_path)
except OSError:
pass
raise HTTPException(status_code=400, detail="无效的 ZIP 文件")
raise HTTPException(status_code=400, detail="无效的 ZIP 文件") from None
except Exception as e:
shutil.rmtree(extract_dir, ignore_errors=True)
try:
os.unlink(zip_path)
except OSError:
pass
raise HTTPException(status_code=500, detail=f"解压失败: {e}")
raise HTTPException(status_code=500, detail=f"解压失败: {e}") from e
# ── Post-Push Verification (sha256sum comparison) ──
@@ -947,6 +946,7 @@ async def verify_sync(
"""
import asyncio
import hashlib
import os
import shlex
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
from server.infrastructure.database.server_repo import ServerRepositoryImpl
@@ -958,7 +958,7 @@ async def verify_sync(
# Build local file manifest: {relative_path: sha256hex}
local_files = {}
for root, dirs, fnames in os.walk(payload.source_path):
for root, _dirs, fnames in os.walk(payload.source_path):
for fn in fnames:
full = os.path.join(root, fn)
rel = os.path.relpath(full, payload.source_path)
+2 -2
View File
@@ -15,7 +15,7 @@ import asyncio
import json
import logging
import time
from typing import Dict, Set, Optional
from typing import Dict, Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
@@ -71,7 +71,7 @@ class ConnectionManager:
)
except RuntimeError:
pass # No running event loop (e.g. during shutdown)
except Exception:
except Exception: # noqa: S110 — client disconnected, best-effort cleanup
pass
logger.info(f"WebSocket disconnected: {client_id}, total: {len(self._connections)}")
+9 -10
View File
@@ -5,7 +5,6 @@ import asyncio
import json
import logging
import uuid
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query, Path
@@ -166,11 +165,11 @@ async def terminal_ws(
logger.error(f"WebSSH connection failed: {e}")
try:
await websocket.send_json({"type": MSG_ERROR, "message": f"SSH连接失败: {str(e)}"})
except Exception:
except Exception: # noqa: S110 — best-effort cleanup
pass
try:
await websocket.close(code=4003, reason="SSH connection failed")
except Exception:
except Exception: # noqa: S110 — best-effort cleanup
pass
await _close_ssh_session(session_id)
return
@@ -199,11 +198,11 @@ async def terminal_ws(
logger.error(f"WebSSH shell creation failed on fresh connection (server={server.id}): {type(e2).__name__}: {e2!r}")
try:
await websocket.send_json({"type": MSG_ERROR, "message": f"Shell创建失败: {type(e2).__name__}"})
except Exception:
except Exception: # noqa: S110 — best-effort cleanup
pass
try:
await websocket.close(code=4003, reason="Shell creation failed")
except Exception:
except Exception: # noqa: S110 — best-effort cleanup
pass
ws_closed = True
await _close_ssh_session(session_id)
@@ -240,7 +239,7 @@ async def terminal_ws(
"type": MSG_DATA,
"data": data,
})
except Exception:
except Exception: # noqa: S110 — SSH read loop, connection teardown
pass
async def _read_websocket_input():
@@ -296,7 +295,7 @@ async def terminal_ws(
except WebSocketDisconnect:
pass
except Exception:
except Exception: # noqa: S110 — WebSocket read loop, connection teardown
pass
# Run both directions concurrently
@@ -323,7 +322,7 @@ async def terminal_ws(
if not ws_closed:
try:
await websocket.send_json({"type": MSG_ERROR, "message": f"Shell创建失败: {type(e).__name__}"})
except Exception:
except Exception: # noqa: S110 — best-effort cleanup
pass
finally:
# ── Cleanup ──
@@ -333,12 +332,12 @@ async def terminal_ws(
if not ws_closed:
try:
await websocket.send_json({"type": MSG_CLOSE, "session_id": session_id})
except Exception:
except Exception: # noqa: S110 — best-effort cleanup
pass
try:
await websocket.close()
except Exception:
except Exception: # noqa: S110 — best-effort cleanup
pass
logger.info(f"WebSSH session closed: admin={admin.username}, server={server.name}, session={session_id}")
+1 -1
View File
@@ -60,7 +60,7 @@ JWT_REFRESH_TOKEN_EXPIRE_DAYS = 30 # 30 days
JWT_WEBSSH_TOKEN_EXPIRE_MINUTES = 15
# Redis key prefix for multi-device refresh token storage
REFRESH_TOKEN_REDIS_PREFIX = "refresh_tokens:"
REFRESH_TOKEN_REDIS_PREFIX = "refresh_tokens:" # noqa: S105
class AuthService:
+1 -1
View File
@@ -62,7 +62,7 @@ def build_long_task_command(
"mkdir -p /var/log/nexus-job && "
'LOG=/var/log/nexus-job/job-$(date +%Y%m%d-%H%M%S).log && '
f"nohup bash -c '{inner}' >> \"$LOG\" 2>&1 & "
f'echo $! > "${LOG}.pid" && '
'echo $! > "${LOG}.pid" && '
f'echo "started job_id={job_id} pid=$! log=$LOG"'
)
@@ -9,7 +9,6 @@ import asyncio
import logging
import os
import re
import shutil
import tempfile
import uuid
from datetime import datetime, timezone
-1
View File
@@ -4,7 +4,6 @@ MySQL only stores historical snapshots; frontend reads live data from Redis.
"""
import asyncio
import json
import logging
from datetime import datetime, timezone
+1 -1
View File
@@ -8,7 +8,7 @@ import logging
from datetime import datetime, timezone
from server.infrastructure.database.session import AsyncSessionLocal
from server.domain.models import PushSchedule, SyncLog, AuditLog
from server.domain.models import PushSchedule
logger = logging.getLogger("nexus.schedule_runner")
+2 -2
View File
@@ -26,8 +26,8 @@ async def script_execution_flush_loop():
repo = ScriptExecutionRepositoryImpl(session)
audit_repo = AuditLogRepositoryImpl(session)
async def _audit(action, target_type, target_id, operator):
await audit_repo.create(AuditLog(
async def _audit(action, target_type, target_id, operator, _repo=audit_repo): # noqa: B023
await _repo.create(AuditLog(
admin_username=operator or "system",
action=action,
target_type=target_type,
+2 -2
View File
@@ -68,7 +68,7 @@ async def self_monitor_loop():
# Redis recovery notification
if redis_ok and not _prev_redis_ok:
try:
await send_telegram_system_alert(f"🟢 Redis连接已恢复正常", notify_key="NOTIFY_SYSTEM_REDIS")
await send_telegram_system_alert("🟢 Redis连接已恢复正常", notify_key="NOTIFY_SYSTEM_REDIS")
_last_telegram_time.pop("NOTIFY_SYSTEM_REDIS", None) # clear cooldown
except Exception:
logger.error("Failed to send Redis recovery via Telegram")
@@ -92,7 +92,7 @@ async def self_monitor_loop():
# MySQL recovery notification
if mysql_ok and not _prev_mysql_ok:
try:
await send_telegram_system_alert(f"🟢 MySQL连接已恢复正常", notify_key="NOTIFY_SYSTEM_MYSQL")
await send_telegram_system_alert("🟢 MySQL连接已恢复正常", notify_key="NOTIFY_SYSTEM_MYSQL")
_last_telegram_time.pop("NOTIFY_SYSTEM_MYSQL", None) # clear cooldown
except Exception:
logger.error("Failed to send MySQL recovery via Telegram")
+2 -4
View File
@@ -5,11 +5,9 @@ SQLAlchemy ORM models for all database tables.
import datetime
from datetime import timezone
import uuid
import base64
import hashlib
from sqlalchemy import (
create_engine, Column, Integer, String, Boolean,
DateTime, Text, ForeignKey, Enum, Index, JSON
Column, Integer, String, Boolean,
DateTime, Text, ForeignKey, Index, JSON
)
from sqlalchemy.orm import declarative_base, relationship
+1 -1
View File
@@ -3,7 +3,7 @@ Protocol-based interfaces for dependency inversion.
Concrete implementations in infrastructure/database/.
"""
from typing import Protocol, Optional, List, Dict
from typing import Protocol, Optional, List
from server.domain.models import (
Server, SyncLog, Script, ScriptExecution, DbCredential,
Admin, LoginAttempt, Setting, PasswordPreset,
+1 -1
View File
@@ -1,6 +1,6 @@
"""Nexus — Admin & LoginAttempt Repository (Async SQLAlchemy)"""
from typing import Optional, List
from typing import Optional
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
+1 -2
View File
@@ -8,9 +8,8 @@ Also handles safe schema migrations for existing databases (adding new columns).
import logging
from sqlalchemy import select, update, text
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import Server, Node, Platform, Base
from server.domain.models import Server, Node, Platform
from server.infrastructure.database.session import AsyncSessionLocal
logger = logging.getLogger("nexus.migration")
+1 -1
View File
@@ -60,7 +60,7 @@ async def init_redis(app: FastAPI) -> None:
except Exception as e:
logger.error(f"Redis startup validation failed: {e}")
raise SystemExit(f"Redis unavailable — Nexus cannot start (ADR-009). Error: {e}")
raise SystemExit(f"Redis unavailable — Nexus cannot start (ADR-009). Error: {e}") from e
async def close_redis() -> None:
+7 -7
View File
@@ -74,10 +74,10 @@ class AsyncSSHPool:
pass
async with self._lock:
for server_id, pooled in self._pool.items():
for _server_id, pooled in self._pool.items():
try:
pooled.conn.close()
except Exception:
except Exception: # noqa: S110 — best-effort cleanup
pass
self._pool.clear()
logger.info("AsyncSSH pool stopped — all connections closed")
@@ -118,7 +118,7 @@ class AsyncSSHPool:
# Close our new connection, reuse the existing one
try:
conn.close()
except Exception:
except Exception: # noqa: S110 — best-effort cleanup
pass
pooled.ref_count += 1
pooled.last_used = time.monotonic()
@@ -153,7 +153,7 @@ class AsyncSSHPool:
if pooled:
try:
pooled.conn.close()
except Exception:
except Exception: # noqa: S110 — best-effort cleanup
pass
logger.debug(f"Force-closed SSH connection: server={server_id}")
@@ -190,7 +190,7 @@ class AsyncSSHPool:
conn = await asyncssh.connect(**connect_kwargs)
return conn
except asyncssh.Error as e:
raise ConnectionError(f"SSH connection failed to {server.domain}:{server.port}: {e}")
raise ConnectionError(f"SSH connection failed to {server.domain}:{server.port}: {e}") from e
async def _evict_one(self) -> bool:
"""Evict the oldest idle connection from the pool.
@@ -209,7 +209,7 @@ class AsyncSSHPool:
pooled = self._pool.pop(oldest_idle)
try:
pooled.conn.close()
except Exception:
except Exception: # noqa: S110 — best-effort cleanup
pass
logger.debug(f"Evicted idle SSH connection: server={oldest_idle}")
return True
@@ -233,7 +233,7 @@ class AsyncSSHPool:
pooled = self._pool.pop(server_id)
try:
pooled.conn.close()
except Exception:
except Exception: # noqa: S110 — best-effort cleanup
pass
if to_remove:
+2 -2
View File
@@ -47,7 +47,7 @@ def _parse_ss(line: str) -> Optional[str]:
if "@" in decoded:
host_part = decoded.split("@", 1)[1].split(":")[0]
return host_part
except Exception:
except Exception: # noqa: S110 — non-critical URL parse fallback
pass
return None
@@ -93,7 +93,7 @@ def parse_subscription_content(raw_content: str) -> list[str]:
decoded = _decode_b64(content)
if any(decoded.startswith(p) for p in ("ss://", "vmess://", "vless://", "trojan://")):
content = decoded
except Exception:
except Exception: # noqa: S110 — non-critical parse, treat as non-encoded
pass
hosts: list[str] = []