文件还在测试中
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user