chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+308
View File
@@ -0,0 +1,308 @@
<?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');
}
}