安装向导:首次部署浏览器引导建库建表(批 26)

- 新增 install.php 独立安装向导:环境检查 → 表单(站点标题/家庭名/数据库/
  高德/管理员)→ 全量建表 → 基本种子数据 → 生成 config.php → 自动自删。
  已安装判定以 config.php 为准,重复访问只显示「已安装」。
- 新增 sql/schema.sql 全量建表脚本(15 张表,按外键依赖排序,全部
  IF NOT EXISTS),由向导执行;tools/migrate.php 继续负责增量迁移。
- index.php 加未安装检测:config.php 缺失或无效时跳转安装页。
- layout.php 标题回退值接通 app.name,安装时填的网站标题在页面上生效。
- 种子数据仅基本款:节点类型 5、角色标签 4、出行方式 9(不含需绑定
  车辆的自驾两类);管理员账号按既有加密方式写入。
- README 部署章节改为向导优先,手动方式降为备选。
- 验证:本地 60 文件 php -l 全过;渲染检查 386 项全过;
  安装流程端到端 46 项全过(临时副本 + 独立 MariaDB:跳转、表单、
  建表、种子、加密可逆、自删、已安装拒绝、错误路径)。
This commit is contained in:
2026-09-28 20:55:16 +08:00
parent 4a5dd19503
commit 7ec3ea559e
5 changed files with 853 additions and 31 deletions
+529
View File
@@ -0,0 +1,529 @@
<?php
declare(strict_types=1);
/**
* ============================================================================
* TripLog Web 安装向导(独立入口,不加载框架)
* ============================================================================
*
* 流程:
* 1) 已安装检测 —— config.php 存在且 db.name 非空 → 拒绝再装(config.php 本身就是锁);
* 2) 环境检查 —— PHP 版本、扩展、目录可写;
* 3) 表单 —— 网站标题 / 家庭名称 / 数据库 / 高德 / 管理员;
* 4) 执行 —— 测库连接 → 跑 sql/schema.sql 全量建表 → src/Migrate.php 幂等迁移
* → 写入基本种子数据(家庭、管理员、节点类型、角色标签、出行方式);
* 5) 生成 config.php(临时文件 include 验证后 rename)→ 自动删除本文件。
*
* 安全设计:
* * 「已安装」以 config.php 为准,安装器重复访问无害;
* * 自删失败只提示手动删除,不阻塞使用;
* * 所有输出经 e() 转义;CSRF token;密码不明文回显;
* * config.php 写入前先写临时文件并 include 验证,失败即删,绝不留下半残配置。
*/
// ---------------------------------------------------------------------------
// 0) 已安装检测(最先做,不做任何环境假设)
// ---------------------------------------------------------------------------
$ROOT = __DIR__;
$CFG_FILE = $ROOT . '/config.php';
$installed = false;
if (is_file($CFG_FILE)) {
$cfgProbe = @include $CFG_FILE;
$installed = is_array($cfgProbe) && !empty($cfgProbe['db']['name'] ?? null);
unset($cfgProbe);
}
unset($CFG_FILE);
// ---------------------------------------------------------------------------
// 1) 会话与工具
// ---------------------------------------------------------------------------
session_start();
if (empty($_SESSION['install_csrf'])) {
$_SESSION['install_csrf'] = bin2hex(random_bytes(16));
}
/** HTML 转义 */
function e(string $s): string
{
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
/** 手机号加密 —— 必须与 src/Controllers/UserController::encrypt() 保持一致 */
function install_encrypt(string $plain): string
{
$key = getenv('APP_ENC_KEY') ?: 'family-trip-default-key-change-me';
$iv = random_bytes(16);
$cipher = openssl_encrypt($plain, 'AES-256-CBC', hash('sha256', $key, true), OPENSSL_RAW_DATA, $iv);
return base64_encode($iv . $cipher);
}
/** 环境检查:返回 [项名, 是否通过, 说明] 列表 */
function env_checks(string $root): array
{
$exts = ['pdo_mysql', 'openssl', 'gd', 'curl', 'mbstring', 'json', 'session'];
$rows = [];
$rows[] = ['PHP 版本 ≥ 7.4', version_compare(PHP_VERSION, '7.4.0', '>='), '当前 ' . PHP_VERSION];
foreach ($exts as $ext) {
$rows[] = ["扩展 {$ext}", extension_loaded($ext), extension_loaded($ext) ? '已加载' : '未安装'];
}
$rows[] = ['站点根目录可写(用于生成 config.php)', is_writable($root), is_writable($root) ? '可写' : '不可写'];
$rows[] = ['uploads/ 目录可写(用于存放照片)', is_writable($root . '/uploads'), is_writable($root . '/uploads') ? '可写' : '不可写'];
$rows[] = ['sql/schema.sql 存在', is_file($root . '/sql/schema.sql'), is_file($root . '/sql/schema.sql') ? '存在' : '缺失'];
return $rows;
}
/** 把 schema.sql 拆成可逐条执行的 SQL(剥离注释行、过滤空段) */
function schema_statements(string $root): array
{
$raw = (string)file_get_contents($root . '/sql/schema.sql');
$parts = preg_split('/;\s*[\r\n]+/', $raw) ?: [];
$out = [];
foreach ($parts as $part) {
// 去掉每段里以 -- 开头的注释行
$lines = preg_grep('/^\s*--/', explode("\n", $part), PREG_GREP_INVERT) ?: [];
$stmt = trim(implode("\n", $lines));
if ($stmt !== '' && stripos($stmt, 'CREATE TABLE') === 0) {
$out[] = $stmt . ';';
}
}
return $out;
}
// ---------------------------------------------------------------------------
// 2) 已安装:直接拒绝
// ---------------------------------------------------------------------------
if ($installed) {
?><!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TripLog 安装</title></head>
<body style="font-family:system-ui,-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;background:#f4f6fa;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;">
<div style="background:#fff;border-radius:14px;padding:40px 44px;max-width:460px;box-shadow:0 8px 30px rgba(30,50,90,.10);text-align:center;">
<div style="font-size:44px;">✅</div>
<h1 style="font-size:20px;margin:12px 0 8px;">TripLog 已安装</h1>
<p style="color:#6b7686;font-size:14px;line-height:1.7;margin:0 0 20px;">
检测到站点已完成安装(config.php 已存在)。<br>
为安全起见,安装向导已停用。<br>
如果 <code>install.php</code> 仍在站点目录中,请手动删除它。
</p>
<a href="index.php" style="display:inline-block;background:#3d6bf5;color:#fff;text-decoration:none;border-radius:8px;padding:10px 28px;font-size:14px;">进入首页</a>
</div></body></html><?php
exit;
}
// ---------------------------------------------------------------------------
// 3) 安装执行(POST)
// ---------------------------------------------------------------------------
$errors = [];
$values = [
'site_name' => '',
'family_name' => '我的家庭',
'db_host' => '127.0.0.1',
'db_port' => '3306',
'db_name' => '',
'db_user' => '',
'db_pass' => '',
'amap_key' => '',
'amap_sec' => '',
'amap_nav_src' => 'trip.example.com',
'admin_name' => '',
'admin_phone' => '',
];
$done = false;
$removed = false; // install.php 是否自删成功
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$token = (string)($_POST['_csrf'] ?? '');
if (!hash_equals($_SESSION['install_csrf'], $token)) {
$errors[] = '页面已过期,请刷新后重试。';
} else {
// ---- 收集(密码类不回填,单独处理)----
foreach ($values as $k => $_) {
if (isset($_POST[$k]) && is_string($_POST[$k])) {
$values[$k] = trim($_POST[$k]);
}
}
$adminPass = (string)($_POST['admin_pass'] ?? '');
$adminPass2 = (string)($_POST['admin_pass2'] ?? '');
$dbPass = $values['db_pass'];
// ---- 校验 ----
if ($values['site_name'] === '') {
$errors[] = '网站标题不能为空';
} elseif (mb_strlen($values['site_name']) > 50) {
$errors[] = '网站标题不能超过 50 个字符';
}
if ($values['family_name'] === '') {
$values['family_name'] = '我的家庭';
} elseif (mb_strlen($values['family_name']) > 30) {
$errors[] = '家庭名称不能超过 30 个字符';
}
if (!preg_match('/^\d{1,5}$/', $values['db_port']) || (int)$values['db_port'] < 1 || (int)$values['db_port'] > 65535) {
$errors[] = '数据库端口不正确';
}
if (!preg_match('/^[A-Za-z0-9_]+$/', $values['db_name'])) {
$errors[] = '数据库名只能包含字母、数字和下划线';
}
if ($values['db_user'] === '') {
$errors[] = '数据库用户名不能为空';
}
if ($values['admin_name'] === '') {
$errors[] = '管理员姓名不能为空';
}
if (!preg_match('/^1[3-9]\d{9}$/', $values['admin_phone'])) {
$errors[] = '管理员手机号格式不正确(11 位,1 开头)';
}
if (strlen($adminPass) < 6) {
$errors[] = '管理员密码至少 6 位';
}
if ($adminPass !== $adminPass2) {
$errors[] = '两次输入的管理员密码不一致';
}
if ($values['amap_nav_src'] === '') {
$values['amap_nav_src'] = 'trip.example.com';
}
// ---- 执行安装 ----
if (!$errors) {
$pdo = null;
try {
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
$values['db_host'], (int)$values['db_port'], $values['db_name']
);
$pdo = new PDO($dsn, $values['db_user'], $dbPass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
} catch (PDOException $ex) {
$code = (int)$ex->getCode();
$msg = $ex->getMessage();
if (strpos($msg, '1049') !== false || $code === 1049
|| strpos($msg, '1044') !== false || $code === 1044) {
// MySQL 8 对不存在的库报 1049,MariaDB 报 1044(Access denied)——
// 对用户来说两种情况都是「库没建好或账号不对」,合并成一条可操作的提示
$errors[] = '数据库不存在,或该用户无权访问「' . $values['db_name'] . '」:请先在 MySQL 里创建名为「'
. $values['db_name'] . '」的空数据库(CREATE DATABASE ' . $values['db_name']
. ' DEFAULT CHARACTER SET utf8mb4;)并把它的全部权限授予刚才填写的用户,再回到本页重试。';
} elseif (strpos($msg, '1045') !== false || $code === 1045) {
$errors[] = '无法连接数据库主机「' . $values['db_host'] . '」,请确认主机地址与端口。';
} else {
$errors[] = '数据库连接失败:' . $msg;
}
}
if ($pdo) {
try {
// ---- 建表(schema.sql 全量 + Migrate 增量,均幂等)----
$stmts = schema_statements($ROOT);
if (count($stmts) < 10) {
throw new RuntimeException('sql/schema.sql 内容异常(仅解析出 ' . count($stmts) . ' 条建表语句)');
}
foreach ($stmts as $stmt) {
$pdo->exec($stmt);
}
require $ROOT . '/src/Migrate.php';
\App\Migrate::run($pdo);
// ---- 种子数据(一个事务;均只在空表时插入,重复执行不重复)----
$pdo->beginTransaction();
$familyName = $values['family_name'];
$pdo->prepare('INSERT INTO families (name) VALUES (?)')->execute([$familyName]);
$familyId = (int)$pdo->lastInsertId();
$pdo->prepare('INSERT INTO users
(family_id, name, phone_encrypted, phone_hash, password_hash, role, status)
VALUES (?, ?, ?, ?, ?, ?, ?)')
->execute([
$familyId,
$values['admin_name'],
install_encrypt($values['admin_phone']),
hash('sha256', $values['admin_phone']),
password_hash($adminPass, PASSWORD_DEFAULT),
'admin',
'active',
]);
// 节点类型(内置,不可删除)
$insType = $pdo->prepare('INSERT IGNORE INTO node_types (family_id, name, icon, is_system) VALUES (?, ?, ?, 1)');
foreach ([
['游玩', '🎡'], ['用餐', '🍜'], ['住宿', '🏨'], ['赶路', '🚄'], ['休息', '☕'],
] as $t) {
$insType->execute([$familyId, $t[0], $t[1]]);
}
// 角色标签
$insTag = $pdo->prepare('INSERT IGNORE INTO role_tags (family_id, name, icon) VALUES (?, ?, ?)');
foreach ([
['司机', '🚗'], ['摄影担当', '📷'], ['向导', '🗺️'], ['财务', '💰'],
] as $t) {
$insTag->execute([$familyId, $t[0], $t[1]]);
}
// 出行方式:不含自驾两种(自驾需先在「车辆管理」里添加车辆,
// travel_modes.vehicle_id 外键指向车辆,空挂没有意义)
$insMode = $pdo->prepare('INSERT IGNORE INTO travel_modes (family_id, mode_type) VALUES (?, ?)');
foreach (['train', 'plane', 'bus', 'subway', 'taxi', 'bike', 'walk', 'ship', 'other'] as $m) {
$insMode->execute([$familyId, $m]);
}
$pdo->commit();
// ---- 生成 config.php(临时文件 → include 验证 → rename)----
$configArr = [
'db' => [
'host' => $values['db_host'],
'port' => (int)$values['db_port'],
'name' => $values['db_name'],
'user' => $values['db_user'],
'pass' => $dbPass,
'charset' => 'utf8mb4',
],
'app' => [
'name' => $values['site_name'],
'timezone' => 'Asia/Shanghai',
],
'amap' => [
'key' => $values['amap_key'],
'security_js' => $values['amap_sec'],
'nav_src' => $values['amap_nav_src'],
],
];
$php = "<?php\n"
. "/**\n"
. " * 由 TripLog 安装向导生成于 " . date('Y-m-d H:i:s') . "\n"
. " * 结构与 config.example.php 一致;手工调整后注意保持键完整。\n"
. " */\n"
. 'return ' . var_export($configArr, true) . ";\n";
$tmp = $ROOT . '/config.install-tmp.php';
if (@file_put_contents($tmp, $php) === false) {
throw new RuntimeException('无法写入配置文件(站点根目录不可写)。请把 Web 运行账号对该目录授予写权限后重试。');
}
$check = @include $tmp;
if (!is_array($check) || empty($check['db']['name']) || empty($check['app']['name'])) {
@unlink($tmp);
throw new RuntimeException('生成的配置文件校验未通过,已中止。请重试安装。');
}
if (!@rename($tmp, $ROOT . '/config.php')) {
@unlink($tmp);
throw new RuntimeException('配置文件落位失败(无法重命名为 config.php)。请检查站点根目录写权限后重试。');
}
// ---- 自删 install.php(失败不阻塞,config.php 已是锁)----
$removed = @unlink(__FILE__);
$done = true;
} catch (Throwable $ex) {
if ($pdo && $pdo->inTransaction()) {
$pdo->rollBack();
}
$errors[] = $ex instanceof PDOException
? '安装执行失败:' . $ex->getMessage()
: $ex->getMessage();
}
}
}
}
}
// ---------------------------------------------------------------------------
// 4) 结果页 / 表单页
// ---------------------------------------------------------------------------
$checks = env_checks($ROOT);
$envOk = true;
foreach ($checks as $c) {
if (!$c[1]) { $envOk = false; break; }
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>TripLog 安装向导</title>
<style>
* { box-sizing: border-box; }
body { margin:0; font-family:system-ui,-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;
background:#f4f6fa; color:#243041; font-size:14px; }
.wrap { max-width:640px; margin:0 auto; padding:36px 16px 60px; }
.head { text-align:center; margin-bottom:26px; }
.head .logo { font-size:40px; }
.head h1 { font-size:22px; margin:8px 0 4px; }
.head p { color:#6b7686; margin:0; }
.card { background:#fff; border-radius:14px; padding:24px 26px; margin-bottom:18px;
box-shadow:0 8px 30px rgba(30,50,90,.10); }
.card h2 { font-size:15px; margin:0 0 14px; padding-bottom:10px; border-bottom:1px solid #eef1f6; }
.check-row { display:flex; align-items:center; gap:10px; padding:5px 0; }
.check-ok { color:#178a50; }
.check-bad { color:#c6362f; }
.check-note { color:#98a2b3; font-size:12px; margin-left:auto; }
.field { margin-bottom:14px; }
.field label { display:block; font-weight:600; margin-bottom:5px; }
.field label .req { color:#c6362f; }
.field input { width:100%; border:1px solid #d8dfe9; border-radius:8px; padding:9px 12px;
font-size:14px; background:#fbfcfe; }
.field input:focus { outline:none; border-color:#3d6bf5; background:#fff; }
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:0 14px; }
.hint { color:#8a94a6; font-size:12px; margin-top:4px; line-height:1.6; }
.btn { display:block; width:100%; background:#3d6bf5; color:#fff; border:0; border-radius:9px;
padding:12px; font-size:15px; font-weight:600; cursor:pointer; }
.btn:disabled { background:#b9c6dd; cursor:not-allowed; }
.btn-go { display:inline-block; background:#3d6bf5; color:#fff; text-decoration:none;
border-radius:8px; padding:11px 34px; font-size:15px; font-weight:600; }
.errors { background:#fdf1f0; border:1px solid #f3c2be; color:#a1322b; border-radius:9px;
padding:12px 16px; margin-bottom:18px; line-height:1.8; }
.okbox { text-align:center; padding:14px 0 4px; }
.okbox .big { font-size:48px; }
.okbox h1 { font-size:21px; margin:10px 0 6px; }
.okbox p { color:#6b7686; line-height:1.8; margin:0 0 22px; }
code { background:#eef2f8; border-radius:4px; padding:1px 6px; font-size:12.5px; }
</style>
</head>
<body>
<div class="wrap">
<div class="head">
<div class="logo">🧭</div>
<h1>TripLog 安装向导</h1>
<p>家庭旅行日记 · 首次部署配置</p>
</div>
<?php if ($done): ?>
<div class="card">
<div class="okbox">
<div class="big">🎉</div>
<h1>安装完成!</h1>
<p>
数据表已创建,基本数据已就绪(节点类型、角色标签、出行方式)。<br>
请使用刚才设置的管理员手机号和密码登录。<br>
<?php if (!$removed): ?>
<span style="color:#c6362f;">⚠️ 安装文件 install.php 未能自动删除,请手动删除它。</span><br>
<?php endif; ?>
</p>
<a class="btn-go" href="index.php">开始使用</a>
</div>
</div>
<?php else: ?>
<?php if ($errors): ?>
<div class="errors">
<strong>安装未完成,请修正以下问题:</strong><br>
<?php foreach ($errors as $err): ?>· <?= e($err) ?><br><?php endforeach; ?>
</div>
<?php endif; ?>
<div class="card">
<h2>环境检查</h2>
<?php foreach ($checks as [$label, $ok, $note]): ?>
<div class="check-row">
<span class="<?= $ok ? 'check-ok' : 'check-bad' ?>"><?= $ok ? '✓' : '✗' ?></span>
<span><?= e($label) ?></span>
<span class="check-note"><?= e($note) ?></span>
</div>
<?php endforeach; ?>
</div>
<form method="post" autocomplete="off">
<input type="hidden" name="_csrf" value="<?= e($_SESSION['install_csrf']) ?>">
<div class="card">
<h2>站点信息</h2>
<div class="field">
<label>网站标题 <span class="req">*</span></label>
<input type="text" name="site_name" maxlength="50" required
placeholder="例如:我们一家的旅行日记"
value="<?= e($values['site_name']) ?>">
</div>
<div class="field">
<label>家庭名称</label>
<input type="text" name="family_name" maxlength="30"
placeholder="我的家庭" value="<?= e($values['family_name']) ?>">
<div class="hint">所有成员、行程、花销都归属这个家庭;之后还可以在后台调整。</div>
</div>
</div>
<div class="card">
<h2>数据库(MySQL)</h2>
<div class="hint" style="margin:0 0 12px;">
请先在 MySQL 中创建一个<strong>空的</strong>数据库,表结构由安装向导自动生成。
</div>
<div class="grid2">
<div class="field">
<label>主机 <span class="req">*</span></label>
<input type="text" name="db_host" required value="<?= e($values['db_host']) ?>">
</div>
<div class="field">
<label>端口 <span class="req">*</span></label>
<input type="text" name="db_port" required value="<?= e($values['db_port']) ?>">
</div>
</div>
<div class="field">
<label>数据库名 <span class="req">*</span></label>
<input type="text" name="db_name" required placeholder="trip"
value="<?= e($values['db_name']) ?>">
</div>
<div class="grid2">
<div class="field">
<label>用户名 <span class="req">*</span></label>
<input type="text" name="db_user" required value="<?= e($values['db_user']) ?>">
</div>
<div class="field">
<label>密码 <span class="req">*</span></label>
<input type="password" name="db_pass" autocomplete="new-password">
</div>
</div>
</div>
<div class="card">
<h2>高德开放平台(可稍后在 config.php 中补充)</h2>
<div class="hint" style="margin:0 0 12px;">
用于地图展示与地点搜索。<a href="https://lbs.amap.com/" target="_blank" rel="noopener">lbs.amap.com</a>
→ 控制台 → 应用管理 → 创建应用(Web端(JS API) + Web服务)后填写。
</div>
<div class="field">
<label>Key(Web端 JS API)</label>
<input type="text" name="amap_key" value="<?= e($values['amap_key']) ?>">
</div>
<div class="field">
<label>安全密钥(securityJsCode)</label>
<input type="text" name="amap_sec" value="<?= e($values['amap_sec']) ?>">
</div>
<div class="field">
<label>导航来源标识(src)</label>
<input type="text" name="amap_nav_src" value="<?= e($values['amap_nav_src']) ?>">
<div class="hint">仅用于高德侧的调用来源统计,填任意英数字串均可,通常填你的域名。</div>
</div>
</div>
<div class="card">
<h2>管理员账号</h2>
<div class="field">
<label>姓名 <span class="req">*</span></label>
<input type="text" name="admin_name" maxlength="50" required
value="<?= e($values['admin_name']) ?>">
</div>
<div class="field">
<label>手机号 <span class="req">*</span></label>
<input type="text" name="admin_phone" maxlength="11" required placeholder="11 位手机号(用于登录)"
value="<?= e($values['admin_phone']) ?>">
</div>
<div class="grid2">
<div class="field">
<label>密码 <span class="req">*</span></label>
<input type="password" name="admin_pass" minlength="6" required autocomplete="new-password">
</div>
<div class="field">
<label>确认密码 <span class="req">*</span></label>
<input type="password" name="admin_pass2" minlength="6" required autocomplete="new-password">
</div>
</div>
</div>
<button class="btn" type="submit" <?= $envOk ? '' : 'disabled' ?>>
<?= $envOk ? '开始安装' : '环境检查未通过,无法安装' ?>
</button>
</form>
<?php endif; ?>
</div>
</body>
</html>