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(), ]; } }