码桶
发现社区成员的开源项目
cli.php10.7 KB
<?php
/**
* WebStats 命令行安装(宝塔 / SSH / 自动化部署场景)
*
* 用法:
* 交互式: php install/cli.php
* 一键式: php install/cli.php --db-name=webstats --db-user=root --db-pass=xxx \
* [email protected] --admin-pass=Admin@12345 \
* [--db-host=127.0.0.1] [--db-port=3306] [--create-db] \
* [--redis-host=127.0.0.1] [--redis-port=6379] [--redis-auth=] [--redis-db=0] [--skip-redis] \
* [--debug] [--allow-register=0]
* 其它: php install/cli.php --check 仅环境自检
* php install/cli.php --status 查看安装状态(含目录布局探测)
* php install/cli.php --unlock 解除安装锁(重装/迁移用)
* 目录覆盖:加 --root=/www/wwwroot/你的站点 指定项目根目录
* (等价于在安装器目录写 local.php;面板环境 open_basedir 受限时很常用)
* 加 --sql=/path/to/install.sql 指定建表脚本位置
*/
declare(strict_types=1);
require __DIR__ . '/lib.php';
use WstatInstall\Installer as I;
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("本文件仅限命令行执行。\n");
}
/* ---------- 参数解析 ---------- */
$opt = [];
foreach (array_slice($argv, 1) as $arg) {
if (preg_match('/^--([a-z0-9-]+)(?:=(.*))?$/i', $arg, $m)) {
$opt[strtolower($m[1])] = $m[2] ?? '1';
}
}
$has = static function (string $k) use ($opt): bool {
return array_key_exists($k, $opt);
};
$out = static function (string $s = ''): void {
fwrite(STDOUT, $s . "\n");
};
$err = static function (string $s): void {
fwrite(STDERR, $s . "\n");
};
/* ---------- 目录结构覆盖(面板 / 自定义目录时用) ---------- */
if ($has('root') && $opt['root'] !== '1') {
putenv('WSTAT_ROOT=' . $opt['root']);
$_SERVER['WSTAT_ROOT'] = $opt['root'];
}
if ($has('sql') && $opt['sql'] !== '1') {
putenv('WSTAT_SQL_FILE=' . $opt['sql']);
$_SERVER['WSTAT_SQL_FILE'] = $opt['sql'];
}
/* ---------- 环境自检 ---------- */
try {
$rows = I::envCheck();
} catch (\Throwable $ex) {
$err('✗ ' . $ex->getMessage());
$err('');
$err('部署布局探测结果:');
foreach (I::probeReport() as $r) {
$err(' ' . $r[0] . "\n → " . $r[1]);
}
$err('');
$err('可用 --root=/www/wwwroot/你的站点 显式指定项目根,或 --sql=/path/to/install.sql 指定建表脚本。');
exit(1);
}
if ($has('check')) {
$out('WebStats 环境自检');
$out(str_repeat('-', 68));
$group = '';
foreach ($rows as $r) {
if ($r['group'] !== $group) {
$group = $r['group'];
$out("\n[" . $group . ']');
}
$flag = $r['ok'] ? '✓' : ($r['required'] ? '✗' : '!');
$out(sprintf(' %s %-22s %-18s %s', $flag, $r['name'], $r['current'], $r['hint']));
}
$out('');
$out(I::envPass($rows) ? '结论:必需项全部通过,可执行安装。' : '结论:存在未满足的必需项,请先处理后重试。');
exit(I::envPass($rows) ? 0 : 1);
}
/* ---------- 安装状态 / 解锁 ---------- */
if ($has('status')) {
$lock = json_decode(I::readFile(I::lockFile()), true) ?: [];
$cfg = I::config();
$out('安装状态:' . (I::installed() ? '已安装' : '未安装'));
$out('安装时间:' . ($lock['installed_at'] ?? '-'));
$out('版本 :' . ($lock['version'] ?? I::VERSION));
$out('数据库 :' . ($cfg['db']['name'] ?? '-') . ' @ ' . ($cfg['db']['host'] ?? '-') . ':' . ($cfg['db']['port'] ?? '-'));
$out('Redis :' . ($cfg['redis']['host'] ?? '-') . ':' . ($cfg['redis']['port'] ?? '-') . ' / DB' . ($cfg['redis']['db'] ?? '-'));
$out('配置文件:' . I::configFile() . (I::isFile(I::configFile()) ? '(存在)' : '(缺失)'));
$out('锁文件 :' . I::lockFile() . (I::isFile(I::lockFile()) ? '(存在)' : '(缺失)'));
$out('');
$out('部署布局:');
foreach (I::layout() as $r) {
$out(' ' . $r['name'] . ':' . $r['value']);
}
exit(0);
}
if ($has('unlock')) {
I::unlock();
$out('已解除安装锁:' . I::lockFile());
$out('提示:重新安装会覆盖 ' . I::configFile() . ',请先备份。');
exit(0);
}
/* ---------- 已安装拦截 ---------- */
if (I::installed() && !$has('force')) {
$err('系统已安装。如需重新安装:php install/cli.php --unlock 后再执行本命令(会覆盖部署配置)。');
exit(1);
}
if (!I::envPass($rows)) {
$err('环境检测未通过,请先执行:php install/cli.php --check');
exit(1);
}
/* ---------- 收集参数(缺省则交互询问) ---------- */
$cfg = I::config();
$interactive = !$has('no-interactive') && (count($opt) === 0 || $has('interactive'));
$ask = static function (string $label, string $default = '', bool $secret = false) use ($out, $interactive): string {
if (!$interactive) {
return $default;
}
$suffix = $default !== '' ? " [{$default}]" : '';
if ($secret) {
// 隐藏输入(Windows/POSIX 通用降级:readline 不可用时明文回显)
$out($label . $suffix . ':');
if (function_exists('shell_exec') && stripos(PHP_OS, 'WIN') === false) {
@shell_exec('stty -echo');
$v = trim((string) fgets(STDIN));
@shell_exec('stty echo');
$out('');
return $v !== '' ? $v : $default;
}
$v = trim((string) fgets(STDIN));
return $v !== '' ? $v : $default;
}
$out($label . $suffix . ':');
$v = trim((string) fgets(STDIN));
return $v !== '' ? $v : $default;
};
$pick = static function (string $key, string $default) use ($opt, $ask, $has): string {
if (array_key_exists($key, $opt)) {
return (string) $opt[$key];
}
return $ask($key, $default);
};
$out('');
$out('=== WebStats 安装向导(命令行) ===');
$out('提示:直接回车使用方括号中的默认值。');
$out('');
$db = [
'host' => $pick('db-host', (string) ($cfg['db']['host'] ?: '127.0.0.1')),
'port' => (int) $pick('db-port', (string) ($cfg['db']['port'] ?: 3306)),
'name' => $pick('db-name', (string) ($cfg['db']['name'] ?? '')),
'user' => $pick('db-user', (string) ($cfg['db']['user'] ?? '')),
'pass' => array_key_exists('db-pass', $opt) ? (string) $opt['db-pass'] : $ask('数据库密码', (string) ($cfg['db']['pass'] ?? ''), true),
];
$createDb = $has('create-db');
if ($interactive && !$has('create-db')) {
$ans = $ask('库不存在时自动创建?(y/n)', 'y');
$createDb = in_array(strtolower($ans), ['y', 'yes', '1', '是'], true);
}
$skipRedis = $has('skip-redis');
if ($interactive && !$has('skip-redis')) {
$ans = $ask('跳过 Redis(降级模式)?(y/n)', 'n');
$skipRedis = in_array(strtolower($ans), ['y', 'yes', '1', '是'], true);
}
$redis = [
'host' => $pick('redis-host', (string) ($cfg['redis']['host'] ?: '127.0.0.1')),
'port' => (int) $pick('redis-port', (string) ($cfg['redis']['port'] ?: 6379)),
'auth' => array_key_exists('redis-auth', $opt) ? (string) $opt['redis-auth'] : $ask('Redis 密码(无则回车)', (string) ($cfg['redis']['auth'] ?? ''), true),
'db' => (int) $pick('redis-db', (string) ($cfg['redis']['db'] ?? 0)),
];
$adminEmail = $pick('admin-email', '');
$adminPass = array_key_exists('admin-pass', $opt) ? (string) $opt['admin-pass'] : $ask('管理员密码', '', true);
$adminNick = $pick('admin-nick', '');
$debug = $has('debug');
/* ---------- 校验 ---------- */
$fail = static function (string $msg) use ($err): void {
$err('✗ ' . $msg);
exit(1);
};
if (!preg_match('/^[A-Za-z0-9_]{1,64}$/', $db['name'])) {
$fail('数据库名非法(只允许字母、数字、下划线,1-64 位)');
}
if ($db['user'] === '') {
$fail('数据库用户名不能为空');
}
if (!filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) {
$fail('管理员邮箱格式不正确');
}
if (strlen($adminPass) < 8 || strlen($adminPass) > 72) {
$fail('管理员密码长度需 8-72 位');
}
/* ---------- 执行 ---------- */
$out('');
$out('[1/5] 连接 MySQL ...');
try {
$info = I::inspectServer($db);
$out(' OK,服务器版本 ' . $info['version']);
} catch (\Throwable $e) {
$fail('MySQL 连接失败:' . $e->getMessage());
}
$out('[2/5] 准备数据库 `' . $db['name'] . '` ...');
try {
if (!$info['db_exists']) {
if (!$createDb) {
$fail('数据库不存在且未指定 --create-db');
}
I::createDatabase($db);
$out(' 已创建');
} else {
$out(' 已存在,沿用');
}
} catch (\Throwable $e) {
$fail('创建数据库失败:' . $e->getMessage());
}
$out('[3/5] 执行建表脚本(幂等,不会删除已有数据)...');
try {
$schema = I::runSchema($db);
$missing = array_keys(array_filter($schema['tables'], static fn($ok) => !$ok));
$out(' 执行 ' . $schema['executed'] . ' 条语句,events 分区 ' . $schema['partition_count'] . ' 个');
if ($missing) {
$fail('以下表创建失败:' . implode(', ', $missing));
}
} catch (\Throwable $e) {
$fail('建表失败:' . $e->getMessage());
}
if (!$skipRedis) {
$out('[4/5] 测试 Redis ...');
$r = I::testRedis($redis);
if ($r['ok']) {
$out(' OK,版本 ' . $r['version'] . '(' . $r['latency_ms'] . 'ms)');
} else {
$err(' ! Redis 连接失败:' . $r['msg']);
$err(' ! 已自动切换为降级模式(实时/会话功能受限)。可稍后修正 ' . I::configFile() . '。');
$skipRedis = true;
}
} else {
$out('[4/5] 已跳过 Redis(降级模式)');
}
$out('[5/5] 创建管理员并写入配置 ...');
try {
$admin = I::createAdmin($db, $adminEmail, $adminPass, $adminNick);
$file = I::writeConfig($db, $redis + ['required' => !$skipRedis], [
'debug' => $debug,
'security' => ['allow_register' => $has('allow-register') ? (bool) ((int) $opt['allow-register']) : false],
]);
I::lock();
$out(' 管理员:' . $adminEmail . '(' . ($admin['created'] ? '已创建' : $admin['reason']) . ')');
$out(' 配置:' . $file);
$out(' 锁文件:' . I::lockFile());
} catch (\Throwable $e) {
$fail('写入配置失败:' . $e->getMessage());
}
$out('');
$out('✔ 安装完成!');
$out('');
$out('接下来必须执行(建议加入 crontab / 宝塔计划任务):');
foreach (I::checklist(I::baseUrl(), PHP_BINARY) as $title => $cmd) {
$out(' · ' . $title);
$out(' ' . $cmd);
}
$out('');
$out('安全提醒:安装完成后请删除 install 目录。');
$out('');
$out('站点接入代码(把 YOUR_SITE_KEY 换成后台创建的站点 key):');
$out(' ' . I::sdkSnippet(I::baseUrl()));
exit(0);