366 lines
16 KiB
PHP
366 lines
16 KiB
PHP
<?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(),
|
||
];
|
||
}
|
||
}
|