fix: 全站 bug 审查修复 (前端9项 + 后端5项)
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run

前端修复:
- P0: App.vue http 未导入导致全局搜索崩溃
- P1: TOTP 登录流程断裂 (TotpRequiredError 非 ApiError 子类)
- P1: ServersPage 编辑服务器 domain 被端口字符串污染
- P1: PushPage Set[0] 无效 → values().next().value
- P1: SettingsPage API 返回字符串未转数字
- P2: api/index.ts getList 重复定义移除
- P2: LoginPage 锁定倒计时 now ref + watch 更新
- P2: TerminalPage 路径验证正则放松 (允许空格/中文)
- P2: useWebSocket CONNECTING/CLOSING 状态关闭旧连接

后端修复:
- P1: websocket.py redis.keys() → scan_iter() 避免阻塞
- P1: auth_service.py TTL 仅在 key 无 TTL 时设置
- P1: servers.py 批量操作加 asyncio.Lock 避免并发 session commit
- P2: settings.py asyncio.get_event_loop() → get_running_loop()
- P2: settings.py datetime.utcfromtimestamp() → datetime.fromtimestamp(tz=utc)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-06-01 11:24:55 +08:00
parent df2c188f5d
commit 6183047fd6
16 changed files with 60 additions and 38 deletions
+1
View File
@@ -11,6 +11,7 @@
"name": "Frontend Dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"cwd": "frontend",
"port": 3000
}
]
+1 -1
View File
@@ -236,7 +236,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useTheme } from 'vuetify'
import { useAuthStore } from '@/stores/auth'
import { useWebSocket } from '@/composables/useWebSocket'
import { api } from '@/api'
import { api, http } from '@/api'
const route = useRoute()
const router = useRouter()
-6
View File
@@ -107,12 +107,6 @@ export const http = {
/** Upload FormData (multipart, auto Content-Type with boundary) */
upload: <T = any>(path: string, formData: FormData) =>
api<T>(path, { method: 'POST', body: formData }),
/** GET a list that returns a bare array — wraps in {items, total} */
getList: async <T = any>(path: string, params?: Record<string, any>) => {
const arr = await api<T[]>(path, { method: 'GET', params })
return { items: (arr || []) as T[], total: (arr || []).length }
},
}
export class ApiError extends Error {
+8 -1
View File
@@ -49,7 +49,14 @@ export function useWebSocket() {
})
function connect() {
if (!auth.token || ws.value?.readyState === WebSocket.OPEN) return
if (!auth.token) return
if (ws.value?.readyState === WebSocket.OPEN) return
// Close stale connection if still CONNECTING/CLOSING
if (ws.value) {
ws.value.onclose = null // prevent reconnect loop
ws.value.close()
ws.value = null
}
const url = `${getWsUrl()}?token=${auth.token}`
const socket = new WebSocket(url)
+19 -8
View File
@@ -89,10 +89,10 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ApiError, http } from '@/api'
import { ApiError, TotpRequiredError, http } from '@/api'
const route = useRoute()
const router = useRouter()
@@ -145,12 +145,23 @@ onMounted(async () => {
onUnmounted(() => {
if (rotateTimer) clearInterval(rotateTimer)
if (lockoutTimer) clearInterval(lockoutTimer)
})
const now = ref(Date.now())
let lockoutTimer: ReturnType<typeof setInterval> | null = null
const remainingMinutes = computed(() => {
if (!lockoutUntil.value) return 0
const diff = new Date(lockoutUntil.value).getTime() - Date.now()
return Math.max(1, Math.ceil(diff / 60000))
const diff = new Date(lockoutUntil.value).getTime() - now.value
return Math.max(0, Math.ceil(diff / 60000))
})
watch(lockoutUntil, (val) => {
if (lockoutTimer) { clearInterval(lockoutTimer); lockoutTimer = null }
if (val) {
lockoutTimer = setInterval(() => { now.value = Date.now() }, 30000)
}
})
async function doLogin() {
@@ -167,13 +178,13 @@ async function doLogin() {
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
} catch (e: any) {
if (e instanceof ApiError) {
if (e instanceof TotpRequiredError) {
needTotp.value = true
error.value = e.message
} else if (e instanceof ApiError) {
if (e.status === 429) {
lockoutUntil.value = new Date(Date.now() + 15 * 60 * 1000).toISOString()
error.value = '登录尝试过多,账户已锁定 15 分钟'
} else if (e.message.includes('TOTP') || e.message.includes('totp') || e.message.includes('验证码')) {
needTotp.value = true
error.value = e.message
} else {
failedAttempts.value++
error.value = e.message
+1 -1
View File
@@ -496,7 +496,7 @@ async function doPreview() {
previewData.value = await http.post('/sync/preview', {
source_path: effectiveSourcePath(),
target_path: targetPath.value,
server_id: selectedIds.value[0],
server_id: selectedIds.value.values().next().value,
sync_mode: syncMode.value,
})
showPreview.value = true
+1 -1
View File
@@ -464,7 +464,7 @@ function editServer(item: ServerApiItem) {
editing.value = true
editingId.value = item.id
form.value = {
name: item.name, address: `${item.domain}:${item.port}`, category: item.category || '',
name: item.name, address: item.domain || '', category: item.category || '',
platform_id: item.platform_id, ssh_user: item.username || '',
ssh_port: item.port || 22, password: '',
}
+6 -1
View File
@@ -252,7 +252,12 @@ async function loadSettings() {
const key = item.key
if (key && knownKeys.includes(key)) {
const val = item.value
;(settings.value as Record<string, unknown>)[key] = val
// Type-cast numeric strings back to numbers for number inputs
if (typeof val === 'string' && /^\d+(\.\d+)?$/.test(val)) {
(settings.value as Record<string, unknown>)[key] = Number(val)
} else {
(settings.value as Record<string, unknown>)[key] = val
}
}
}
} catch {
+1 -1
View File
@@ -273,7 +273,7 @@ const snackbar = useSnackbar()
const initialServerId = computed(() => Number(route.query.server_id) || 0)
const initialPath = computed(() => {
const p = (route.query.path as string) || ''
return p && /^[a-zA-Z0-9/._\-]+$/.test(p) ? p : ''
return p && /^[^\x00-\x1f\x7f"']+$/.test(p) ? p : ''
})
// ── localStorage keys ──
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

+12 -7
View File
@@ -757,6 +757,7 @@ async def batch_detect_path(
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
sem = asyncio.Semaphore(5)
db_lock = asyncio.Lock()
results = []
async def _detect_one(sid: int):
@@ -786,9 +787,10 @@ async def batch_detect_path(
# 取目录路径(可能有多个结果,取第一个)
first_match = found.split("\n")[0].strip()
target_dir = os.path.dirname(first_match)
# 更新服务器 target_path
server.target_path = target_dir
await db.commit()
# 更新服务器 target_path — lock to avoid concurrent session commits
async with db_lock:
server.target_path = target_dir
await db.commit()
return BatchAgentResultItem(
server_id=sid, server_name=server_name, success=True,
stdout=f"target_path → {target_dir}",
@@ -833,6 +835,7 @@ async def batch_uninstall_agent(
raise HTTPException(status_code=400, detail="NEXUS_API_BASE_URL 未配置")
sem = asyncio.Semaphore(5)
db_lock = asyncio.Lock()
results = []
async def _uninstall_one(sid: int):
@@ -860,10 +863,12 @@ async def batch_uninstall_agent(
await redis.delete(f"{REDIS_KEY_PREFIX}{sid}")
except Exception as redis_err:
logger.warning(f"Failed to clear Redis heartbeat for server {sid}: {redis_err}")
server.is_online = False
server.agent_version = None
server.agent_api_key = None
await db.commit()
# Lock to avoid concurrent session commits
async with db_lock:
server.is_online = False
server.agent_version = None
server.agent_api_key = None
await db.commit()
return BatchAgentResultItem(
server_id=sid, server_name=server_name, success=ok,
+4 -4
View File
@@ -74,7 +74,7 @@ async def _is_private_host(hostname: str) -> bool:
pass # Not a literal IP, do DNS resolution
# F-2: Async DNS resolution (non-blocking)
try:
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
infos = await loop.getaddrinfo(hostname, None, family=socket.AF_UNSPEC, type=socket.SOCK_STREAM)
for _family, _, _, _, sockaddr in infos:
ip = ipaddress.ip_address(sockaddr[0])
@@ -157,12 +157,12 @@ _MAX_AGE_DAYS = 7 # auto-clean older than 7 days
def _cleanup_old_wallpapers() -> int:
"""Remove wallpapers older than 7 days. Returns count removed."""
import time
from datetime import datetime, timedelta
cutoff = datetime.utcnow() - timedelta(days=_MAX_AGE_DAYS)
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=_MAX_AGE_DAYS)
removed = 0
for f in sorted(_WALLPAPER_DIR.glob("*.jpg")):
try:
mtime = datetime.utcfromtimestamp(f.stat().st_mtime)
mtime = datetime.fromtimestamp(f.stat().st_mtime, tz=timezone.utc).replace(tzinfo=None)
if mtime < cutoff:
f.unlink()
removed += 1
+2 -5
View File
@@ -125,12 +125,9 @@ class ConnectionManager:
redis = get_redis()
# Each worker periodically writes its count to Redis with TTL
await redis.set(f"ws:count:{id(self)}", len(self._connections), ex=90)
# Sum all worker counts
keys = await redis.keys("ws:count:*")
if not keys:
return len(self._connections)
# Sum all worker counts — use SCAN instead of KEYS to avoid blocking
total = 0
for key in keys:
async for key in redis.scan_iter(match="ws:count:*"):
val = await redis.get(key)
if val:
total += int(val)
+4 -2
View File
@@ -537,8 +537,10 @@ class AuthService:
key = f"{REFRESH_TOKEN_REDIS_PREFIX}{admin_id}"
token_hash = self._hash_token(token)
await redis.sadd(key, token_hash)
# TTL = refresh token lifetime + small buffer
await redis.expire(key, JWT_REFRESH_TOKEN_EXPIRE_DAYS * 86400 + 3600)
# Only set expire if key has no TTL yet (avoid resetting TTL on every refresh)
ttl = await redis.ttl(key)
if ttl < 0:
await redis.expire(key, JWT_REFRESH_TOKEN_EXPIRE_DAYS * 86400 + 3600)
except Exception as e:
logger.error(f"Failed to store refresh token in Redis for admin {admin_id}: {e}")