309 lines
13 KiB
PHP
309 lines
13 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Author: ywxapp<admin@ywxapp.cn>
|
||
// +----------------------------------------------------------------------
|
||
|
||
/**
|
||
* Installer 类
|
||
*
|
||
* @author ywxapp <admin@ywxapp.cn>
|
||
*/
|
||
class Installer
|
||
{
|
||
/** @var string 项目根目录 */
|
||
protected $root;
|
||
|
||
/**
|
||
* 方法说明
|
||
* @return mixed 返回值描述
|
||
* @author ywxapp <admin@ywxapp.cn>
|
||
*/
|
||
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 <admin@ywxapp.cn>
|
||
*/
|
||
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 <admin@ywxapp.cn>
|
||
*/
|
||
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');
|
||
}
|
||
}
|