Files

309 lines
13 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 是 23000Integrity 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');
}
}