// +---------------------------------------------------------------------- /** * Installer 类 * * @author ywxapp */ class Installer { /** @var string 项目根目录 */ protected $root; /** * 方法说明 * @return mixed 返回值描述 * @author ywxapp */ public function __construct() { // public/install -> 项目根 $this->root = dirname(__DIR__, 2); } /** * 是否已安装(依据根目录 install.lock) */ public function isInstalled(): bool { return is_file($this->root . '/install.lock'); } /** * 从表单输入整理数据库配置 */ public function getDbConfig(array $input): array { return [ 'host' => trim((string) ($input['db_host'] ?? '127.0.0.1')), 'port' => trim((string) ($input['db_port'] ?? '3306')), 'name' => trim((string) ($input['db_name'] ?? '')), 'member' => trim((string) ($input['db_user'] ?? '')), 'pass' => (string) ($input['db_pass'] ?? ''), 'charset' => trim((string) ($input['db_charset'] ?? 'utf8mb4')), 'prefix' => trim((string) ($input['db_prefix'] ?? 'wxapp_')), ]; } /** * 环境检测 */ public function checkEnv(): array { $items = []; $items[] = $this->envItem('PHP 版本', '>= 8.0', PHP_VERSION, version_compare(PHP_VERSION, '8.0.0', '>=')); $items[] = $this->envItem('PDO 扩展', '开启', $this->ext('pdo'), extension_loaded('pdo')); $items[] = $this->envItem('PDO_MYSQL 扩展', '开启', $this->ext('pdo_mysql'), extension_loaded('pdo_mysql')); $items[] = $this->envItem('MBSTRING 扩展', '开启', $this->ext('mbstring'), extension_loaded('mbstring')); $items[] = $this->envItem('OPENSSL 扩展', '开启', $this->ext('openssl'), extension_loaded('openssl')); $items[] = $this->envItem('GD 扩展', '开启', $this->ext('gd'), extension_loaded('gd')); $items[] = $this->envItem('CURL 扩展', '开启', $this->ext('curl'), extension_loaded('curl')); foreach (['runtime', 'public/storage'] as $dir) { $path = $this->root . '/' . $dir; $ok = is_writable($path) || (!is_dir($path) && is_writable(dirname($path))); $items[] = $this->envItem($dir . ' 可写', '可写', $ok ? '可写' : '不可写', $ok); } $envFile = $this->root . '/.env'; $envOk = is_writable($envFile) || (!is_file($envFile) && is_writable($this->root)); $items[] = $this->envItem('.env 可写', '可写', $envOk ? '可写' : '不可写', $envOk); return $items; } /** * 方法说明 * * @param string $name 参数描述 * @return string 返回值描述 * @author ywxapp */ protected function ext(string $name): string { return extension_loaded($name) ? '已开启' : '未开启'; } /** * 方法说明 * * @param string $name 参数描述 * @param string $require 参数描述 * @param string $current 参数描述 * @param bool $ok 参数描述 * @return array 返回值描述 * @author ywxapp */ protected function envItem(string $name, string $require, string $current, bool $ok): array { return ['name' => $name, 'require' => $require, 'current' => $current, 'ok' => $ok]; } /** * 测试数据库连接,必要时创建数据库 */ public function testDb(array $cfg): array { try { $dsn = "mysql:host={$cfg['host']};port={$cfg['port']};charset={$cfg['charset']}"; $pdo = new \PDO($dsn, $cfg['member'], $cfg['pass'], [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); $quoted = $pdo->quote($cfg['name']); $exists = $pdo->query("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = {$quoted}")->fetch(); if (!$exists) { $pdo->exec("CREATE DATABASE IF NOT EXISTS `{$cfg['name']}` DEFAULT CHARACTER SET {$cfg['charset']}"); } return ['ok' => true]; } catch (\Throwable $e) { return ['ok' => false, 'msg' => $e->getMessage()]; } } /** * 导入 install.sql(建表 + 初始数据),幂等 */ public function importSql(array $cfg): int { $dsn = "mysql:host={$cfg['host']};port={$cfg['port']};dbname={$cfg['name']};charset={$cfg['charset']}"; $pdo = new \PDO($dsn, $cfg['member'], $cfg['pass'], [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); $sql = (string) file_get_contents(__DIR__ . '/install.sql'); // 兼容性:utf8mb4_0900_ai_ci 为 MySQL8.0 特有,替换为通用排序规则 $sql = preg_replace('/utf8mb4_0900_ai_ci/', 'utf8mb4_general_ci', $sql); // 将导出时硬编码的库名替换为用户填写的库名(testDb 已创建该库) $sql = preg_replace('/`ywxapp_dev`/', '`' . str_replace('`', '', $cfg['name']) . '`', $sql); // 表前缀替换:install.sql 中以 __PREFIX__ 占位,安装时统一替换为用户填写的前缀 $prefix = !empty($cfg['prefix']) ? $cfg['prefix'] : 'wxapp_'; $sql = preg_replace('/`__PREFIX__(\w+)`/', '`' . $prefix . '${1}`', $sql); $sql = preg_replace('/__PREFIX__(\w+)/', $prefix . '${1}', $sql); $statements = $this->splitSql($sql); $count = 0; $errors = []; foreach ($statements as $stmt) { $upper = strtoupper(ltrim($stmt)); if (!preg_match('/^(CREATE\s+(?:TABLE|DATABASE)|INSERT|SET|USE|ALTER|DROP)/', $upper)) { continue; } try { $pdo->exec($stmt); $count++; } catch (\Throwable $e) { $msg = $e->getMessage(); // 幂等:表已存在(1050)、列已存在(1060)、数据重复(1062) 视为正常跳过 // 注:1062 的 SQLSTATE 是 23000(Integrity constraint violation),需显式覆盖 if (preg_match('/SQLSTATE\[(HY000|23000)\] \[10(50|60|62)\]/i', $msg) || stripos($msg, 'already exists') !== false || stripos($msg, 'duplicate') !== false) { continue; } $errors[] = $msg; } } if ($count === 0 && $errors) { throw new \RuntimeException('数据库导入失败:' . implode(' | ', array_slice($errors, 0, 3))); } return $count; } /** * 安装时创建管理员账号(取代 install.sql 中预置的测试管理员) * @param array $cfg 数据库配置 * @param array $admin 管理员表单数据:account, password, nickname, email, mobile */ public function createAdmin(array $cfg, array $admin): int { $dsn = "mysql:host={$cfg['host']};port={$cfg['port']};dbname={$cfg['name']};charset={$cfg['charset']}"; $pdo = new \PDO($dsn, $cfg['member'], $cfg['pass'], [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); $prefix = $cfg['prefix'] ?: 'wxapp_'; $account = trim((string) ($admin['account'] ?? '')); $password = (string) ($admin['password'] ?? ''); if ($account === '' || $password === '') { throw new \RuntimeException('管理员账号或密码不能为空'); } $nickname = trim((string) ($admin['nickname'] ?? '')) ?: $account; $email = trim((string) ($admin['email'] ?? '')); $mobile = trim((string) ($admin['mobile'] ?? '')); $hash = password_hash($password, PASSWORD_DEFAULT); $now = time(); // 幂等:重装时账号可能已存在(install.sql 预置或上次安装遗留),存在则更新、不存在则插入 $chk = $pdo->prepare("SELECT `id` FROM `{$prefix}admin` WHERE `account` = ?"); $chk->execute([$account]); $row = $chk->fetch(\PDO::FETCH_ASSOC); if ($row) { $adminId = (int) $row['id']; $pdo->prepare("UPDATE `{$prefix}admin` SET " . "`nickname`=?,`password`=?,`salt`=?,`email`=?,`mobile`=?,`is_super`=?,`status`=?,`update_at`=? " . "WHERE `id`=?") ->execute([$nickname, $hash, '', $email, $mobile, 1, 1, $now, $adminId]); } else { $stmt = $pdo->prepare("INSERT INTO `{$prefix}admin` " . "(`account`,`nickname`,`password`,`salt`,`email`,`mobile`,`dept_id`,`is_super`,`avatar`,`status`,`create_at`,`update_at`,`delete_at`) " . "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"); $stmt->execute([ $account, $nickname, $hash, '', $email, $mobile, 0, 1, '/static/avatar/default.jpg', 1, $now, $now, 0, ]); $adminId = (int) $pdo->lastInsertId(); } // 关联超级管理员角色(admin_role.id = 1, name = superadmin),已关联则跳过 $relChk = $pdo->prepare("SELECT 1 FROM `{$prefix}admin_role_access` WHERE `admin_id` = ? AND `role_id` = 1"); $relChk->execute([$adminId]); if (!$relChk->fetch()) { $pdo->prepare("INSERT INTO `{$prefix}admin_role_access` (`admin_id`,`role_id`,`create_at`) VALUES (?,1,?)") ->execute([$adminId, $now]); } return $adminId; } /** * 健壮拆分 MySQL SQL 文件为单条语句 * 正确处理 /* *\/ 块注释、-- 与 # 行注释、字符串内的分号 */ protected function splitSql(string $sql): array { $statements = []; $current = ''; $inString = false; $quote = ''; $inComment = false; $len = strlen($sql); $i = 0; while ($i < $len) { $c = $sql[$i]; $next = $i + 1 < $len ? $sql[$i + 1] : ''; if ($inComment) { if ($c === '*' && $next === '/') { $inComment = false; $i += 2; continue; } $i++; continue; } if ($inString) { $current .= $c; if ($c === '\\' && $i + 1 < $len) { $current .= $sql[$i + 1]; $i += 2; continue; } if ($c === $quote) { $inString = false; } $i++; continue; } if ($c === '/' && $next === '*') { $inComment = true; $i += 2; continue; } if ($c === '-' && $next === '-') { while ($i < $len && $sql[$i] !== "\n") $i++; continue; } if ($c === '#') { while ($i < $len && $sql[$i] !== "\n") $i++; continue; } if ($c === '\'' || $c === '"' || $c === '`') { $inString = true; $quote = $c; $current .= $c; $i++; continue; } if ($c === ';') { if (trim($current) !== '') { $statements[] = trim($current); } $current = ''; $i++; continue; } $current .= $c; $i++; } if (trim($current) !== '') { $statements[] = trim($current); } return $statements; } /** * 将数据库配置写入 .env(保留其余配置) */ public function writeEnv(array $cfg): void { $envFile = $this->root . '/.env'; $content = file_exists($envFile) ? (string) file_get_contents($envFile) : ''; $map = [ 'APP_DEBUG' => 'false', 'DB_TYPE' => 'mysql', 'DB_HOST' => $cfg['host'], 'DB_NAME' => $cfg['name'], 'DB_USER' => $cfg['member'], 'DB_PASS' => $cfg['pass'], 'DB_PORT' => $cfg['port'], 'DB_CHARSET' => $cfg['charset'], 'DB_PREFIX' => $cfg['prefix'], ]; foreach ($map as $key => $val) { $val = (string) $val; $pattern = '/^' . preg_quote($key, '/') . '\s*=.*$/m'; if (preg_match($pattern, $content)) { $content = preg_replace($pattern, $key . ' = ' . $val, $content); } else { $content .= "\n" . $key . ' = ' . $val; } } file_put_contents($envFile, $content); } /** * 生成安装锁(根目录 install.lock,后台据此判断已安装) */ public function makeLock(): void { file_put_contents($this->root . '/install.lock', date('Y-m-d H:i:s') . ' installed by web wizard'); } }