fix(install): split Redis install check (step 2) from connection test (step 4)
Step 2 only TCP-probes host Redis without blocking the wizard; step 4 runs /connection-check on MySQL and Redis from .env before creating the admin account. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -97,7 +97,7 @@ Docker 栈**仅含 Nexus 容器**。
|
||||
| MySQL | `127.0.0.1:3306` | `host.docker.internal:3306` |
|
||||
| Redis | `127.0.0.1:6379` | `host.docker.internal:6379` |
|
||||
|
||||
步骤 2 须 Redis 连通;步骤 3 填自建库密码。
|
||||
步骤 2 仅检测 Redis 是否已安装;步骤 3 填连接信息;步骤 4 验证 MySQL/Redis 连通。
|
||||
|
||||
卸载旧 Compose 容器:
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# 2026-06-05 — 安装向导 Redis/MySQL 检测分步
|
||||
|
||||
## 摘要
|
||||
|
||||
- **步骤 2**:Redis 仅 TCP 检测宿主机是否已安装(`host.docker.internal:6379`),不阻塞进入步骤 3
|
||||
- **步骤 4**:新增 `GET /api/install/connection-check`,验证 `.env` 中 MySQL + Redis 真实连接
|
||||
- 创建管理员前须通过步骤 4 连接检测
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/api/install.py`
|
||||
- `web/app/install.html`
|
||||
|
||||
## 验证
|
||||
|
||||
- 步骤 2:Redis 未装仍可进步骤 3;已装显示绿色
|
||||
- 步骤 3 完成后步骤 4 自动检测 MySQL/Redis
|
||||
- 连接失败时「创建账号」按钮禁用
|
||||
+99
-31
@@ -112,6 +112,75 @@ def _resolve_redis_probe() -> tuple[str, int, str | None]:
|
||||
return host, port, None
|
||||
|
||||
|
||||
def _probe_redis_installed() -> tuple[bool, str]:
|
||||
"""Step 2: TCP probe only — Redis 是否在宿主机监听(不验证密码/库号)。"""
|
||||
import socket
|
||||
|
||||
targets: list[tuple[str, int]] = []
|
||||
if os.environ.get("NEXUS_DOCKER_WRITE_ENV") == "1":
|
||||
targets.append(("host.docker.internal", 6379))
|
||||
targets.append(("127.0.0.1", 6379))
|
||||
seen: set[tuple[str, int]] = set()
|
||||
for host, port in targets:
|
||||
if (host, port) in seen:
|
||||
continue
|
||||
seen.add((host, port))
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=2.0):
|
||||
return True, f"✓ 已安装({host}:{port} 可访问)"
|
||||
except OSError:
|
||||
continue
|
||||
hint = (
|
||||
"host.docker.internal:6379"
|
||||
if os.environ.get("NEXUS_DOCKER_WRITE_ENV") == "1"
|
||||
else "127.0.0.1:6379"
|
||||
)
|
||||
return False, f"✗ 未检测到 Redis,请在宿主机/1Panel 安装并监听 {hint}"
|
||||
|
||||
|
||||
def _parse_dotenv(path: Path) -> dict[str, str]:
|
||||
data: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return data
|
||||
for line in read_utf8_text(path).splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, raw = line.partition("=")
|
||||
val = raw.strip()
|
||||
if len(val) >= 2 and val[0] == val[-1] == '"':
|
||||
val = val[1:-1].replace('\\"', '"').replace("\\\\", "\\")
|
||||
data[key.strip()] = val
|
||||
return data
|
||||
|
||||
|
||||
async def _test_mysql_url(db_url: str) -> tuple[bool, str]:
|
||||
if not db_url:
|
||||
return False, "✗ 未配置 NEXUS_DATABASE_URL"
|
||||
try:
|
||||
patch_aiomysql_do_ping()
|
||||
engine = create_async_engine(db_url, pool_pre_ping=True, pool_recycle=300)
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
await engine.dispose()
|
||||
return True, "✓ MySQL 连接正常"
|
||||
except Exception as e:
|
||||
return False, f"✗ MySQL 连接失败: {e}"
|
||||
|
||||
|
||||
def _test_redis_url(redis_url: str) -> tuple[bool, str]:
|
||||
if not redis_url:
|
||||
return False, "✗ 未配置 NEXUS_REDIS_URL"
|
||||
try:
|
||||
import redis as rlib
|
||||
|
||||
client = rlib.Redis.from_url(redis_url, socket_timeout=3.0)
|
||||
client.ping()
|
||||
return True, "✓ Redis 连接正常"
|
||||
except Exception as e:
|
||||
return False, f"✗ Redis 连接失败: {e}"
|
||||
|
||||
|
||||
def _docker_wizard_defaults() -> dict[str, str] | None:
|
||||
"""Prefill install step 3 when running inside docker-compose.prod.yml."""
|
||||
if os.environ.get("NEXUS_DOCKER_WRITE_ENV") != "1":
|
||||
@@ -442,42 +511,21 @@ async def env_check():
|
||||
"pass": False,
|
||||
})
|
||||
|
||||
# Redis — 须先安装并连通(宿主机/1Panel);Docker 默认 host.docker.internal:6379
|
||||
redis_ok = False
|
||||
redis_msg = "未连接"
|
||||
rh, rp, rpass = _resolve_redis_probe()
|
||||
redis_label = f"{rh}:{rp}"
|
||||
try:
|
||||
import redis as rlib
|
||||
try:
|
||||
r = rlib.Redis(
|
||||
host=rh,
|
||||
port=rp,
|
||||
password=rpass or None,
|
||||
socket_timeout=2.0,
|
||||
)
|
||||
r.ping()
|
||||
redis_ok = True
|
||||
redis_msg = f"✓ 已连接 {redis_label}"
|
||||
except Exception as e:
|
||||
redis_msg = (
|
||||
f"连接失败: {e}(请先在宿主机/1Panel 安装 Redis;"
|
||||
f"Docker 部署须监听宿主机 6379 且 NEXUS_REDIS_URL 指向 {redis_label})"
|
||||
)
|
||||
except ImportError:
|
||||
redis_msg = "redis 库未安装"
|
||||
# Redis — 步骤2仅检测宿主机是否已安装(TCP);步骤4再验证连接
|
||||
redis_installed, redis_msg = _probe_redis_installed()
|
||||
checks.append({
|
||||
"name": "Redis",
|
||||
"required": "已连接",
|
||||
"name": "Redis(宿主机)",
|
||||
"required": "建议已安装",
|
||||
"current": redis_msg,
|
||||
"pass": redis_ok,
|
||||
"pass": redis_installed,
|
||||
"blocks_wizard": False,
|
||||
})
|
||||
|
||||
# MySQL database — user configures in step 3
|
||||
# MySQL — 步骤3配置,步骤4验证连接
|
||||
checks.append({
|
||||
"name": "MySQL 数据库",
|
||||
"required": "步骤3配置",
|
||||
"current": "— 请在步骤3填写数据库连接信息",
|
||||
"current": "— 步骤4将检测连接",
|
||||
"pass": True,
|
||||
})
|
||||
|
||||
@@ -498,7 +546,7 @@ async def env_check():
|
||||
"pass": root_writable,
|
||||
})
|
||||
|
||||
all_pass = all(c["pass"] for c in checks)
|
||||
all_pass = all(c["pass"] for c in checks if c.get("blocks_wizard", True))
|
||||
payload: dict = {"checks": checks, "all_pass": all_pass}
|
||||
defaults = _docker_wizard_defaults()
|
||||
if defaults:
|
||||
@@ -506,6 +554,26 @@ async def env_check():
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/connection-check")
|
||||
async def connection_check():
|
||||
"""Step 4: Verify MySQL + Redis using .env written in step 3."""
|
||||
_reject_if_locked()
|
||||
if not _has_env():
|
||||
raise HTTPException(400, "请先完成步骤3:数据库初始化")
|
||||
|
||||
env = _parse_dotenv(ENV_FILE)
|
||||
db_url = env.get("NEXUS_DATABASE_URL", "")
|
||||
redis_url = env.get("NEXUS_REDIS_URL", "")
|
||||
|
||||
mysql_ok, mysql_msg = await _test_mysql_url(db_url)
|
||||
redis_ok, redis_msg = _test_redis_url(redis_url)
|
||||
checks = [
|
||||
{"name": "MySQL", "required": "可连接", "current": mysql_msg, "pass": mysql_ok},
|
||||
{"name": "Redis", "required": "可连接", "current": redis_msg, "pass": redis_ok},
|
||||
]
|
||||
return {"checks": checks, "all_pass": mysql_ok and redis_ok}
|
||||
|
||||
|
||||
@router.post("/init-db")
|
||||
async def init_db(req: InitDbRequest):
|
||||
"""Step 3: Connect to MySQL, create tables, write configs."""
|
||||
@@ -636,7 +704,7 @@ async def init_db(req: InitDbRequest):
|
||||
"site_url": site_url,
|
||||
"api_port": req.api_port,
|
||||
"guardian_results": guardian_results,
|
||||
"step": 3,
|
||||
"step": 4,
|
||||
}
|
||||
write_utf8_lf(STATE_FILE, json.dumps(state, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
+52
-4
@@ -121,10 +121,10 @@
|
||||
</div>
|
||||
|
||||
<div x-show="!envLoading && envAllPass" class="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg mb-4 text-sm">
|
||||
✓ 所有环境检测通过,可以继续安装。
|
||||
✓ 环境检测通过。Redis 仅检测是否已安装;MySQL/Redis <b>连接</b>在步骤 4 验证。
|
||||
</div>
|
||||
<div x-show="!envLoading && !envAllPass && envChecks.length" class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg mb-4 text-sm">
|
||||
部分环境检测未通过,请先解决上述问题。
|
||||
部分必检项未通过,请先解决(Redis 未安装可先继续,但步骤 4 须连通)。
|
||||
</div>
|
||||
|
||||
<div x-show="!envLoading" class="flex gap-3 mt-4">
|
||||
@@ -258,6 +258,29 @@
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-800 mb-4">👤 管理员账号 + 系统名称</h2>
|
||||
|
||||
<!-- Step 4: MySQL + Redis connection -->
|
||||
<div class="mb-4">
|
||||
<h3 class="font-bold text-slate-700 mb-2">🔗 MySQL / Redis 连接检测</h3>
|
||||
<div x-show="connLoading" class="text-sm text-[var(--text-secondary)] py-2">正在检测连接...</div>
|
||||
<div x-show="!connLoading && connChecks.length" class="bg-slate-50 rounded-lg overflow-hidden mb-2">
|
||||
<template x-for="c in connChecks" :key="c.name">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-100 last:border-0 text-sm">
|
||||
<span class="font-medium text-slate-700" x-text="c.name"></span>
|
||||
<span class="font-semibold" :class="c.pass ? 'text-green-500' : 'text-red-500'" x-text="c.current"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div x-show="!connLoading && connAllPass" class="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg text-sm mb-2">
|
||||
✓ MySQL 与 Redis 均已连通,可创建管理员。
|
||||
</div>
|
||||
<div x-show="!connLoading && !connAllPass && connChecks.length" class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg text-sm mb-2">
|
||||
连接未通过:请返回步骤 3 核对配置,或在 1Panel 启动 MySQL/Redis 后重新检测。
|
||||
</div>
|
||||
<button type="button" @click="checkConnections()" class="bg-slate-200 hover:bg-slate-300 text-slate-700 px-4 py-2 rounded-lg text-sm font-semibold transition">
|
||||
重新检测连接
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 summary -->
|
||||
<div class="bg-slate-50 rounded-lg p-4 mb-4 text-sm">
|
||||
<div class="font-semibold text-green-600 mb-2">✓ 步骤 3 配置完成</div>
|
||||
@@ -305,9 +328,9 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button type="submit" :disabled="loading" class="bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white px-5 py-2.5 rounded-lg font-semibold transition flex items-center gap-2">
|
||||
<button type="submit" :disabled="loading || !connAllPass" class="bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white px-5 py-2.5 rounded-lg font-semibold transition flex items-center gap-2">
|
||||
<span x-show="loading" class="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full"></span>
|
||||
<span x-text="loading ? '创建中...' : '创建账号 →'"></span>
|
||||
<span x-text="loading ? '创建中...' : (connAllPass ? '创建账号 →' : '须先通过连接检测')"></span>
|
||||
</button>
|
||||
<button type="button" @click="step = 3" class="bg-slate-200 hover:bg-slate-300 text-slate-700 px-5 py-2.5 rounded-lg font-semibold transition">← 上一步</button>
|
||||
</div>
|
||||
@@ -570,6 +593,9 @@ function installWizard() {
|
||||
envLoading: false,
|
||||
envChecks: [],
|
||||
envAllPass: false,
|
||||
connLoading: false,
|
||||
connChecks: [],
|
||||
connAllPass: false,
|
||||
initResult: null,
|
||||
installToken: '',
|
||||
installDir: '/opt/nexus',
|
||||
@@ -631,10 +657,31 @@ function installWizard() {
|
||||
});
|
||||
if (d.install_dir) this.installDir = d.install_dir;
|
||||
if (d.pool_size) this.initResult = { pool_size: d.pool_size, max_overflow: d.max_overflow, guardian_results: d.guardian_results || [] };
|
||||
if (d.step >= 4) this.checkConnections();
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
async checkConnections() {
|
||||
this.connLoading = true;
|
||||
try {
|
||||
const r = await fetch(API + '/api/install/connection-check');
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
this.connChecks = d.checks || [];
|
||||
this.connAllPass = !!d.all_pass;
|
||||
} else {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
this.connChecks = [{ name: '连接检测', pass: false, current: d.detail || '请先完成步骤3' }];
|
||||
this.connAllPass = false;
|
||||
}
|
||||
} catch (e) {
|
||||
this.connChecks = [{ name: '连接检测', pass: false, current: '请求失败: ' + e.message }];
|
||||
this.connAllPass = false;
|
||||
}
|
||||
this.connLoading = false;
|
||||
},
|
||||
|
||||
async checkEnv() {
|
||||
this.envLoading = true;
|
||||
this.error = '';
|
||||
@@ -671,6 +718,7 @@ function installWizard() {
|
||||
this.installToken = d.install_token || '';
|
||||
this.btPanel = !!d.bt_panel;
|
||||
this.step = 4;
|
||||
await this.checkConnections();
|
||||
} else {
|
||||
this.error = d.detail || '初始化失败';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user