Files
coolcoth.com/app/Core/Installer.php
T
2026-08-08 17:19:44 +08:00

366 lines
16 KiB
PHP
Raw 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
namespace Core;
/**
* 安装 / 数据升级 核心逻辑(供 install.php、后台「数据升级」与「数据库初始化」共用)
*
* 设计原则:
* - install() 首次安装:建全部表 + 插种子(可指定自定义超级管理员),不破坏已有数据。
* - upgrade() 增量升级:补齐新增模块表/列 + 按 id 补齐缺失种子,绝不 DELETE / 覆盖客户数据。
* - 所有 DDL 均为 IF NOT EXISTS / ADD COLUMN IF NOT EXISTS 幂等写法。
*/
class Installer
{
/** 完整安装(首次)。$super 非空时用自定义超级管理员覆盖种子账号。 */
public static function install(?array $super = null): array
{
$seed = require BASE_PATH . '/install/seed.php';
if ($super) {
$seed['admin_users'] = [[
'id' => 1,
'username' => $super['username'],
'password' => password_hash($super['password'], PASSWORD_DEFAULT),
'name' => $super['name'] ?? '超级管理员',
'role' => 'super_admin',
'crm_role' => 'admin',
'psi_role' => 'admin',
'status' => 1,
'created_at' => date('Y-m-d'),
]];
}
$driver = Db::driver();
$msgs = [];
if ($driver === 'file') {
$dir = Db::fileDir();
foreach ($seed as $table => $rows) {
$i = 1;
foreach ($rows as &$r) { if (!isset($r['id'])) { $r['id'] = $i; } $i++; }
unset($r);
file_put_contents($dir . "/{$table}.json", json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
$msgs[] = "写入 {$table}.json (" . count($rows) . " 条)";
}
foreach (['crm_contacts'] as $t) {
$f = $dir . "/{$t}.json";
if (!is_file($f)) { file_put_contents($f, '[]'); $msgs[] = "创建 {$t}.json"; }
}
} else {
$pdo = Db::pdo();
$pdo->exec(file_get_contents(BASE_PATH . '/install/schema.sql'));
$msgs[] = "数据表已创建/更新(基础 + CRM + PSI";
foreach (['categories', 'products', 'news', 'cases'] as $t) {
try { $pdo->exec("ALTER TABLE `{$t}` ADD COLUMN `layout` TEXT"); } catch (\Throwable $e) {}
}
self::ensureColumns($pdo, $msgs);
$map = self::modelMap();
foreach ($seed as $table => $rows) {
$m = $map[$table] ?? null;
if (!$m) continue;
foreach ($rows as $r) { $m->insert($r); }
$msgs[] = "插入 {$table} (" . count($rows) . " 条)";
}
}
try { Theme::regenerate(); $msgs[] = "主题样式 theme.css 已生成"; } catch (\Throwable $e) {}
@file_put_contents(BASE_PATH . '/storage/installed.lock', date('Y-m-d H:i:s') . " installed\n");
return $msgs;
}
/** 数据升级(后台按钮 / 已安装系统):补齐新模块表/列,按 id 补齐缺失种子,保留客户数据 */
public static function upgrade(): array
{
$seed = require BASE_PATH . '/install/seed.php';
$driver = Db::driver();
$msgs = [];
$content = [
'categories', 'products', 'news', 'cases', 'pages', 'banners', 'settings',
'crm_customers', 'crm_leads', 'crm_followups', 'crm_contacts',
'psi_materials', 'psi_products', 'psi_suppliers', 'psi_purchases', 'psi_sales',
];
if ($driver === 'file') {
$dir = Db::fileDir();
foreach ($seed as $table => $rows) {
if (!in_array($table, $content, true)) continue;
$ef = $dir . "/{$table}.json";
$existing = [];
if (is_file($ef)) {
$ed = @json_decode(file_get_contents($ef), true);
if (is_array($ed)) $existing = $ed;
}
if ($table === 'settings') {
$keys = [];
foreach ($existing as $er) { if (isset($er['skey'])) $keys[$er['skey']] = true; }
foreach ($rows as $r) { if (!isset($keys[$r['skey']])) $existing[] = $r; }
} else {
$ids = [];
foreach ($existing as $er) { if (isset($er['id'])) $ids[$er['id']] = true; }
foreach ($rows as $r) {
$rid = $r['id'] ?? null;
if ($rid !== null && !isset($ids[$rid])) $existing[] = $r;
}
}
file_put_contents($ef, json_encode($existing, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
$msgs[] = "刷新 {$table}.json (" . count($existing) . " 条,保留客户数据)";
}
foreach (['orders', 'payments', 'crm_contacts'] as $t) {
$f = $dir . "/{$t}.json";
if (!is_file($f)) { file_put_contents($f, '[]'); $msgs[] = "创建 {$t}.json"; }
}
} else {
$pdo = Db::pdo();
$pdo->exec(file_get_contents(BASE_PATH . '/install/schema.sql'));
$msgs[] = "数据表已创建/更新(补齐新增模块表)";
foreach (['categories', 'products', 'news', 'cases'] as $t) {
try { $pdo->exec("ALTER TABLE `{$t}` ADD COLUMN `layout` TEXT"); } catch (\Throwable $e) {}
}
self::ensureColumns($pdo, $msgs);
$map = self::modelMap();
foreach ($seed as $table => $rows) {
if (!in_array($table, $content, true)) continue;
$m = $map[$table] ?? null;
if (!$m) continue;
if ($table === 'settings') {
$keys = [];
try { $rs = Db::query("SELECT skey FROM `settings`"); foreach ($rs->fetchAll() as $er) $keys[$er['skey']] = true; } catch (\Throwable $e) {}
$added = 0;
foreach ($rows as $r) { if (!isset($keys[$r['skey']])) { $m->insert($r); $added++; } }
$msgs[] = "补齐 settings (" . $added . " 条)";
continue;
}
$ids = [];
try { $rs = Db::query("SELECT id FROM `{$table}`"); foreach ($rs->fetchAll() as $er) $ids[$er['id']] = true; } catch (\Throwable $e) {}
$added = 0;
foreach ($rows as $r) {
$rid = $r['id'] ?? null;
if ($rid !== null && !isset($ids[$rid])) { $m->insert($r); $added++; }
}
$msgs[] = "补齐 {$table} 缺失种子 (" . $added . " 条,已有 " . count($ids) . " 条保留)";
}
}
try { Theme::regenerate(); $msgs[] = "主题样式 theme.css 已生成"; } catch (\Throwable $e) {}
return $msgs;
}
/** 执行单个 SQL 文件(用于「数据库升级」的升级包)。失败会抛出异常由调用方捕获。 */
public static function applySqlFile(string $path): void
{
if (!is_file($path)) {
throw new \RuntimeException("升级文件不存在:{$path}");
}
$sql = file_get_contents($path);
if ($sql === false || trim($sql) === '') return;
self::dbExecute($sql);
}
/**
* 分段执行 SQL(支持 DELIMITER 命令,兼容 PDO 不支持的客户端语法)。
* @param string $sql 原始 SQL 文本(含 / 不含 DELIMITER 均可)
* @throws \Throwable
*/
private static function dbExecute(string $sql): void
{
$pdo = Db::pdo();
// 逐行解析 DELIMITER 与多语句拆分
$lines = explode("\n", $sql);
$delimiter = ';';
$buffer = '';
foreach ($lines as $raw) {
$line = trim($raw);
// 跳过空行与单行注释(兼容 PHP 7,不用 str_starts_with
if ($line === '' || strpos($line, '--') === 0 || strpos($line, '#') === 0) continue;
// 检测 DELIMITER 命令(客户端命令,不进入 SQL 执行)
if (strtoupper(substr($line, 0, 10)) === 'DELIMITER ') {
// 积压的 SQL 遇到 DELIMITER 修改时先执行
$stmt = trim($buffer);
if ($stmt !== '') {
if ($pdo->exec($stmt) === false) {
$err = $pdo->errorInfo();
throw new \RuntimeException("SQL 执行错误:{$err[2]} (SQL: " . substr($stmt, 0, 120) . ')');
}
}
$buffer = '';
$delimiter = trim(substr($line, 10));
continue;
}
$buffer .= $raw . "\n";
// 检查 buffer 是否以当前分隔符结尾(忽略末尾空白与行末注释)
$trimmed = rtrim($buffer, " \t\r\n");
if (substr($trimmed, -strlen($delimiter)) === $delimiter) {
$stmt = rtrim(substr($trimmed, 0, -strlen($delimiter)));
$stmt = trim($stmt);
if ($stmt !== '') {
if ($pdo->exec($stmt) === false) {
$err = $pdo->errorInfo();
throw new \RuntimeException("SQL 执行错误:{$err[2]} (SQL: " . substr($stmt, 0, 120) . ')');
}
}
$buffer = '';
}
}
// 最后一段(无结束分隔符的纯 SQL)
$stmt = trim($buffer);
if ($stmt !== '') {
if ($pdo->exec($stmt) === false) {
$err = $pdo->errorInfo();
throw new \RuntimeException("SQL 执行错误:{$err[2]} (SQL: " . substr($stmt, 0, 120) . ')');
}
}
}
/** 确保升级记录表存在(幂等) */
public static function ensureUpgradeLog(): void
{
if (Db::driver() !== 'mysql') return;
try {
Db::pdo()->exec("CREATE TABLE IF NOT EXISTS db_upgrades (
id INT AUTO_INCREMENT PRIMARY KEY,
file VARCHAR(255) NOT NULL COMMENT '升级包文件名',
hash CHAR(32) NOT NULL COMMENT '文件 MD5,用于识别内容变更',
applied_at DATETIME NOT NULL COMMENT '执行时间',
applied_by VARCHAR(64) DEFAULT '' COMMENT '操作人',
note TEXT COMMENT '备注'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
} catch (\Throwable $e) {}
}
public static function isInstalled(): bool
{
if (Db::driver() !== 'mysql') return true; // file 模式无「安装」概念
try {
$cnt = (new \App\Models\AdminUser())->count();
return $cnt > 0;
} catch (\Throwable $e) {
return false;
}
}
/** 返回每张预期表的存在状态(mysql 模式) */
public static function tableStatus(): array
{
if (Db::driver() !== 'mysql') return [];
$pdo = Db::pdo();
$exist = $pdo->query("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()")->fetchAll(\PDO::FETCH_COLUMN);
$all = [
'categories', 'products', 'news', 'cases', 'pages', 'banners', 'admin_users',
'crm_customers', 'crm_leads', 'crm_followups', 'crm_contacts',
'psi_materials', 'psi_products', 'psi_suppliers', 'psi_purchases', 'psi_sales', 'psi_stock_moves',
'settings', 'orders', 'payments',
];
$out = [];
foreach ($all as $t) { $out[$t] = in_array($t, $exist, true); }
return $out;
}
/** 补齐各表新增列(防御性,schema.sql 已含,此处兜底供「数据升级」使用,幂等) */
private static function ensureColumns($pdo, array &$msgs): void
{
$map = [
'pages' => [
'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'",
],
'products' => [
'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'",
],
'news' => [
'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'",
],
'cases' => [
'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'",
],
'categories' => [
'mode' => "VARCHAR(16) NOT NULL DEFAULT 'fixed'",
],
'admin_users' => [
'crm_role' => "VARCHAR(20) DEFAULT 'none'",
'psi_role' => "VARCHAR(20) DEFAULT 'none'",
'crm_perms' => "TEXT",
'psi_perms' => "TEXT",
],
'crm_customers' => [
'customer_no' => "VARCHAR(40) DEFAULT ''",
'industry' => "VARCHAR(20) DEFAULT ''",
'region' => "VARCHAR(40) DEFAULT ''",
'credit_limit'=> "DECIMAL(12,2) DEFAULT 0",
'status' => "VARCHAR(20) DEFAULT 'lead'",
],
'crm_leads' => [
'source' => "VARCHAR(30) DEFAULT ''",
'probability' => "TINYINT DEFAULT 0",
],
'crm_followups' => [
'way' => "VARCHAR(20) DEFAULT ''",
'result' => "VARCHAR(60) DEFAULT ''",
],
'psi_materials' => [
'composition' => "VARCHAR(60) DEFAULT ''",
'weight_gsm' => "DECIMAL(8,2) DEFAULT 0",
'width_cm' => "DECIMAL(8,2) DEFAULT 0",
'color' => "VARCHAR(40) DEFAULT ''",
'batch_no' => "VARCHAR(40) DEFAULT ''",
],
'psi_products' => [
'style_no' => "VARCHAR(40) DEFAULT ''",
'color' => "VARCHAR(40) DEFAULT ''",
'size_run' => "VARCHAR(60) DEFAULT ''",
'season' => "VARCHAR(20) DEFAULT ''",
'year' => "VARCHAR(10) DEFAULT ''",
],
'psi_suppliers' => [
'type' => "VARCHAR(20) DEFAULT ''",
'grade' => "VARCHAR(20) DEFAULT ''",
'ontime_rate'=> "DECIMAL(5,2) DEFAULT 0",
'qc_rate' => "DECIMAL(5,2) DEFAULT 0",
],
'psi_purchases' => [
'batch_no' => "VARCHAR(40) DEFAULT ''",
'expected_at' => "VARCHAR(20) DEFAULT ''",
],
'psi_sales' => [
'region' => "VARCHAR(40) DEFAULT ''",
'batch_no' => "VARCHAR(40) DEFAULT ''",
],
'psi_stock_moves' => [
'batch_no' => "VARCHAR(40) DEFAULT ''",
],
];
foreach ($map as $table => $cols) {
try {
$have = $pdo->query("SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='{$table}'")->fetchAll(\PDO::FETCH_COLUMN);
} catch (\Throwable $e) {
continue;
}
foreach ($cols as $c => $def) {
if (!in_array($c, $have, true)) {
try {
$pdo->exec("ALTER TABLE `{$table}` ADD COLUMN `{$c}` {$def}");
$msgs[] = "已添加 {$table}.{$c}";
} catch (\Throwable $e) {}
}
}
}
}
private static function modelMap(): array
{
return [
'categories' => new \App\Models\Category(),
'products' => new \App\Models\Product(),
'news' => new \App\Models\News(),
'cases' => new \App\Models\CustomerCase(),
'pages' => new \App\Models\Page(),
'banners' => new \App\Models\Banner(),
'admin_users'=> new \App\Models\AdminUser(),
'settings' => new \App\Models\Setting(),
'orders' => new \App\Models\Order(),
'payments' => new \App\Models\Payment(),
'crm_customers' => new \App\Models\CRM\Customer(),
'crm_leads' => new \App\Models\CRM\Lead(),
'crm_followups' => new \App\Models\CRM\FollowUp(),
'crm_contacts' => new \App\Models\CRM\Contact(),
'psi_suppliers' => new \App\Models\PSI\Supplier(),
'psi_materials' => new \App\Models\PSI\Material(),
'psi_products' => new \App\Models\PSI\Product(),
'psi_purchases' => new \App\Models\PSI\Purchase(),
'psi_sales' => new \App\Models\PSI\Sales(),
'psi_stock_moves'=> new \App\Models\PSI\StockMove(),
];
}
}