feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -0,0 +1,648 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
namespace addon\iot\command;
|
||||
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
use think\console\input\Argument;
|
||||
use think\console\input\Option;
|
||||
use think\facade\Db;
|
||||
use Workerman\Worker;
|
||||
use addon\iot\service\Broker;
|
||||
|
||||
/**
|
||||
* MQTT Broker 统一管理命令(start / stop / restart / status 合并到一个文件)
|
||||
*
|
||||
* php think iot start [-d] 启动(-d 后台守护,Linux 有效)
|
||||
* php think iot stop [-f] 停止(-f 跳过优雅退出,直接 SIGKILL)
|
||||
* php think iot restart [-d] 重启(线上固定加 -d,否则新进程是前台进程)
|
||||
* php think iot status 查看状态(进程 / 端口 / 配置 / 环境 / 日志 / DB 快照)
|
||||
* php think iot probe <uuid> [pos] 曲线/时序链路自检(只读;不经 HTTP,排「接口没有温度」)
|
||||
*
|
||||
* 兼容旧写法(同一实现,info.php 里注册了多个命令名):
|
||||
* php think iot:start / iot:stop / iot:restart / iot:status
|
||||
*
|
||||
* 为什么做成一个命令:
|
||||
* 1. 原来 4 个命令各自重复「找进程 → 停 → 复核端口」的样板代码,改动容易漏;
|
||||
* 2. 命令名里出现裸 `restart`/`stop` 会被 Workerman 的 parseCommand() 误当它自己的命令
|
||||
* (它直接扫 argv 的第一个白名单词),集中到一处更好解释与兜底,见 doStart() 注释。
|
||||
*
|
||||
* 设计要点(与旧版的差异全部保留):
|
||||
* - 进程查找不依赖 runtime/iot.pid:pid 丢失时按 Workerman 改写后的标题特征回退扫描;
|
||||
* - 停止有 SIGKILL 兜底 + 复核进程/端口,pid 文件只在「确认无任何 Broker 进程」后清理;
|
||||
* - 重启自管「停旧 → 等端口真释放 → 起新」,端口未释放直接报错中止;
|
||||
* - status 返回码 0=健康 / 1=异常,可挂监控。
|
||||
*/
|
||||
class Iot extends Command
|
||||
{
|
||||
/** status 配置摘要展示的键(顺序即输出顺序) */
|
||||
private const SHOW_KEYS = [
|
||||
'host', 'port', 'ws_port', 'allow_anonymous', 'allow_member_login',
|
||||
'acl_enabled', 'acl_default', 'rule_enabled', 'cluster_enabled',
|
||||
'max_connections', 'log_level', 'sys_enabled',
|
||||
];
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
// 允许被注册成多个名字(iot / iot:start / iot:stop / iot:restart / iot:status):
|
||||
// 保留构造期已设置的名字,避免多个注册项互相覆盖。
|
||||
$this->setName($this->getName() ?: 'iot')
|
||||
->addArgument('action', Argument::OPTIONAL, '子命令:start | stop | restart | status | probe', '')
|
||||
->addArgument('uuid', Argument::OPTIONAL, 'probe 用:设备 uuid', '')
|
||||
->addArgument('pos', Argument::OPTIONAL, 'probe 用:位置序号,默认 0', '0')
|
||||
->addOption('host', 'H', Option::VALUE_OPTIONAL, '监听地址(start,默认 0.0.0.0)', '0.0.0.0')
|
||||
->addOption('port', 'p', Option::VALUE_OPTIONAL, '监听端口(start,默认 1883)', 1883)
|
||||
->addOption('daemon', 'd', Option::VALUE_NONE, '后台守护运行(start / restart,Linux 有效)')
|
||||
->addOption('force', 'f', Option::VALUE_NONE, '跳过优雅退出,直接 SIGKILL 强杀(stop)')
|
||||
->addOption('tcp-only', null, Option::VALUE_NONE, '仅启动 MQTT TCP 监听(Windows 双进程模式用)')
|
||||
->addOption('ws-only', null, Option::VALUE_NONE, '仅启动 WebSocket 监听(Windows 双进程模式用)')
|
||||
->addOption('lines', 'l', Option::VALUE_OPTIONAL, '日志显示行数(status,默认 20)', 20)
|
||||
->addOption('no-db', null, Option::VALUE_NONE, '跳过数据库快照(status)')
|
||||
->setDescription('MQTT Broker 管理:iot start [-d] | iot stop [-f] | iot restart [-d] | iot status | iot probe <uuid>');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$action = $this->resolveAction($input);
|
||||
|
||||
switch ($action) {
|
||||
case 'start':
|
||||
return $this->doStart($input, $output);
|
||||
case 'stop':
|
||||
return $this->doStop($input, $output);
|
||||
case 'restart':
|
||||
return $this->doRestart($input, $output);
|
||||
case 'status':
|
||||
return $this->doStatus($input, $output);
|
||||
case 'probe':
|
||||
// 曲线/时序链路自检(实现见 command/Probe.php,与 iot:probe 共用同一份代码)
|
||||
return Probe::report(
|
||||
(string) $input->getArgument('uuid'),
|
||||
(int) $input->getArgument('pos'),
|
||||
1800,
|
||||
60,
|
||||
24,
|
||||
5,
|
||||
$output
|
||||
);
|
||||
case 'acltest':
|
||||
// ACL 验证工具是独立命令(含 30 条断言,会写 iot_acl 临时规则),不并入本文件
|
||||
$output->writeln('ACL 测试请用独立命令:php think iot:acltest');
|
||||
return 1;
|
||||
case '':
|
||||
$this->showUsage($output);
|
||||
return 0;
|
||||
default:
|
||||
$output->writeln('❌ 未知子命令:' . $action);
|
||||
$this->showUsage($output);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 子命令解析 / 帮助
|
||||
* ========================================================== */
|
||||
|
||||
/**
|
||||
* 子命令来源:优先显式参数(`iot start`),其次调用时使用的旧命令名(`iot:start`)。
|
||||
*/
|
||||
protected function resolveAction(Input $input): string
|
||||
{
|
||||
$action = strtolower(trim((string) $input->getArgument('action')));
|
||||
if ($action !== '') {
|
||||
return $action;
|
||||
}
|
||||
|
||||
// 注意:Input::getFirstArgument() 返回的是用户实际敲的命令名(tokens 未被消费)
|
||||
$invoked = strtolower(trim((string) $input->getFirstArgument()));
|
||||
$pos = strpos($invoked, ':');
|
||||
|
||||
return $pos === false ? '' : substr($invoked, $pos + 1);
|
||||
}
|
||||
|
||||
protected function showUsage(Output $output): void
|
||||
{
|
||||
$output->writeln('');
|
||||
$output->writeln('MQTT Broker 管理命令(必须在站点根目录执行)');
|
||||
$output->writeln(' php think iot start [-d] 启动 Broker(-d 后台守护;不加 -d 为前台调试,SSH 断开即退出)');
|
||||
$output->writeln(' php think iot stop [-f] 停止 Broker(-f 直接 SIGKILL,对付无响应/孤儿进程)');
|
||||
$output->writeln(' php think iot restart [-d] 重启 Broker(线上固定加 -d;端口未释放会明确报错中止)');
|
||||
$output->writeln(' php think iot status 查看状态(进程/端口/配置/环境/日志/DB 快照,返回码 0=正常)');
|
||||
$output->writeln(' php think iot probe <uuid> [pos] 曲线/时序自检:库表+行数+原始点+聚合桶(avg)+汇总(只读)');
|
||||
$output->writeln(' php think iot:acltest ACL 规则验证(独立命令)');
|
||||
$output->writeln('');
|
||||
$output->writeln('start 附加参数:-H <地址> -p <端口> --tcp-only --ws-only(后两个用于 Windows 双进程调试)');
|
||||
$output->writeln('status 附加参数:-l <行数> --no-db');
|
||||
$output->writeln('旧写法 iot:start / iot:stop / iot:restart / iot:status 仍然可用(同一实现)');
|
||||
$output->writeln('');
|
||||
}
|
||||
|
||||
/** 是否要求后台守护:优先取解析后的选项,再兜底查原始 argv(与 Workerman 判定一致) */
|
||||
protected function daemonRequested(Input $input): bool
|
||||
{
|
||||
if ($input->getOption('daemon')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$argv = (array) ($_SERVER['argv'] ?? []);
|
||||
|
||||
return in_array('-d', $argv, true) || in_array('--daemon', $argv, true);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* start
|
||||
* ========================================================== */
|
||||
|
||||
protected function doStart(Input $input, Output $output): int
|
||||
{
|
||||
$host = (string) $input->getOption('host');
|
||||
$port = (int) $input->getOption('port');
|
||||
|
||||
$mode = 'both';
|
||||
if ($input->getOption('ws-only')) {
|
||||
$mode = 'ws';
|
||||
} elseif ($input->getOption('tcp-only')) {
|
||||
$mode = 'tcp';
|
||||
}
|
||||
|
||||
$daemon = $this->daemonRequested($input);
|
||||
|
||||
$output->writeln("启动 MQTT Broker on {$host}:{$port}(协议 MQTT 3.1.1 / 5.0, 模式 {$mode})");
|
||||
if ($daemon) {
|
||||
$output->writeln('后台守护模式:Workerman 会把**所有输出(含启动失败原因)**重定向到 runtime/iot.log');
|
||||
} else {
|
||||
$output->writeln('前台调试模式:按 Ctrl+C 停止(SSH 断开即退出;线上请用 iot start -d)');
|
||||
}
|
||||
if (DIRECTORY_SEPARATOR === '\\') {
|
||||
$output->writeln('Windows 提示:一个 PHP 进程只能监听一个端口,TCP 与 WebSocket 请分窗口运行(--tcp-only / --ws-only)。');
|
||||
}
|
||||
|
||||
$this->warnPortBusy($output);
|
||||
|
||||
// Workerman 的 daemonize 依据 argv 中的 -d(Worker::parseCommand → case 'start'),
|
||||
// 这里再显式设置一次,保证 `--daemon` 长写法与合并命令名下的行为一致。
|
||||
if ($daemon) {
|
||||
Worker::$daemonize = true;
|
||||
}
|
||||
|
||||
// ⚠️ 合并命令后 argv 里会出现裸的 'start'/'restart' 词:Workerman::parseCommand() 是
|
||||
// 直接扫描 argv 取「第一个命中的白名单词」当自己的命令的。
|
||||
// · `iot start -d` → 命中 'start' → 与旧版 iot:start 完全一致;
|
||||
// · `iot restart -d` → 命中 'restart' → 走 Workerman 的 restart 分支,
|
||||
// 该分支按 runtime/iot.pid 找旧 master 发信号;doRestart() 已先杀旧进程并删除
|
||||
// pid 文件,所以 $masterPid=0、该分支只做「等待 → 设 daemonize」后继续启动,
|
||||
// 行为等价于 start(这也是能放心共用本方法的原因)。
|
||||
// 旧写法 iot:restart 命中不了任何白名单词,则依赖 Broker::start() 注入
|
||||
// Worker::$command='start' 兜底(见 Broker::start() 内注释)。
|
||||
(new Broker())->start($host, $port, $mode);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 端口预检:Workerman 的绑定失败信息只会写进 runtime/iot.log,终端看不到,这里先提示。
|
||||
*/
|
||||
protected function warnPortBusy(Output $output): void
|
||||
{
|
||||
$report = Broker::portReport();
|
||||
if (empty($report['busy'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hasMaster = false;
|
||||
foreach (Broker::findBrokerProcesses() as $info) {
|
||||
if ($info['role'] === 'master') {
|
||||
$hasMaster = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln('⚠️ 端口已被占用:' . implode(', ', $report['busy']));
|
||||
if ($hasMaster) {
|
||||
$output->writeln(' 已有 master 在运行 → Workerman 会以「already running」直接退出(无需重复启动)。');
|
||||
} else {
|
||||
$output->writeln(' 未发现 master 进程 → 多半是 master 被强杀后残留的孤儿 worker 仍持有监听端口。');
|
||||
$output->writeln(' 处理:php think iot stop(必要时加 -f)后再启动。');
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* stop
|
||||
* ========================================================== */
|
||||
|
||||
protected function doStop(Input $input, Output $output): int
|
||||
{
|
||||
if (DIRECTORY_SEPARATOR === '\\') {
|
||||
$output->writeln('Windows 下无守护进程概念:请直接关闭运行 iot start 的窗口(或按 Ctrl+C 停止)。');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$pidFile = Broker::pidFilePath();
|
||||
$procs = Broker::findBrokerProcesses();
|
||||
|
||||
if (empty($procs)) {
|
||||
$hadFile = is_file($pidFile);
|
||||
if ($hadFile) {
|
||||
@unlink($pidFile);
|
||||
}
|
||||
$output->writeln('未发现运行中的 MQTT Broker 进程。' . ($hadFile ? '(残留 pid 文件已清理)' : ''));
|
||||
foreach (Broker::portReport()['lines'] as $line) {
|
||||
$output->writeln(' · ' . $line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
$output->writeln('发现 ' . count($procs) . ' 个 Broker 进程:');
|
||||
foreach ($procs as $pid => $info) {
|
||||
$output->writeln(sprintf(' %-8s %-7s %s', $pid, $info['role'], $info['cmd']));
|
||||
}
|
||||
|
||||
$res = Broker::stopProcesses(array_keys($procs), 8, (bool) $input->getOption('force'));
|
||||
|
||||
if (!$res['supported']) {
|
||||
$output->writeln('⚠️ 当前 PHP 未启用 posix 扩展,或 posix_kill 被 disable_functions 禁用,无法发送信号。');
|
||||
$output->writeln(' 请用 root 手工执行:kill -9 ' . implode(' ', array_keys($procs)));
|
||||
$output->writeln(' (pid 文件已保留,手工杀干净后再执行一次本命令即可自动清理)');
|
||||
return 1;
|
||||
}
|
||||
|
||||
foreach ($res['steps'] as $step) {
|
||||
$output->writeln(' · ' . $step);
|
||||
}
|
||||
|
||||
// 复核:进程 + 端口(端口才是「真的停掉了」的判据)
|
||||
$leftover = Broker::findBrokerProcesses();
|
||||
$report = Broker::portReport();
|
||||
|
||||
if (!empty($leftover)) {
|
||||
$output->writeln('❌ 仍有进程存活:');
|
||||
foreach ($leftover as $pid => $info) {
|
||||
$output->writeln(sprintf(' %-8s %-7s %s', $pid, $info['role'], $info['cmd']));
|
||||
}
|
||||
$output->writeln(' 请用 root 执行:kill -9 ' . implode(' ', array_keys($leftover)));
|
||||
$output->writeln(' 或用:php think iot stop -f(强杀模式)');
|
||||
return 1;
|
||||
}
|
||||
|
||||
foreach ($report['lines'] as $line) {
|
||||
$output->writeln(' · ' . $line);
|
||||
}
|
||||
|
||||
if ($report['busy']) {
|
||||
$output->writeln('⚠️ Broker 进程已全部退出,但端口仍被占用 —— 占用者不是本插件的 Broker:');
|
||||
$output->writeln(' 请执行 ss -lntp | grep -E \':' . implode('|', $report['busy']) . '\' 查出占用进程。');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (is_file($pidFile)) {
|
||||
@unlink($pidFile);
|
||||
}
|
||||
$output->writeln('✅ MQTT Broker 已停止,端口已释放。');
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* restart
|
||||
* ========================================================== */
|
||||
|
||||
protected function doRestart(Input $input, Output $output): int
|
||||
{
|
||||
if (DIRECTORY_SEPARATOR === '\\') {
|
||||
$output->writeln('Windows 下请关闭运行 iot start 的窗口后,再重新运行 iot start'
|
||||
. '(需同时提供 TCP 与 WebSocket 时:--tcp-only 与 --ws-only 各开一个窗口)。');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$pidFile = Broker::pidFilePath();
|
||||
$procs = Broker::findBrokerProcesses();
|
||||
|
||||
// ① 停旧
|
||||
if (!empty($procs)) {
|
||||
$output->writeln('停止旧进程(' . count($procs) . ' 个)...');
|
||||
foreach ($procs as $pid => $info) {
|
||||
$output->writeln(sprintf(' %-8s %-7s %s', $pid, $info['role'], $info['cmd']));
|
||||
}
|
||||
$res = Broker::stopProcesses(array_keys($procs), 8, false);
|
||||
if (!$res['supported']) {
|
||||
$output->writeln('⚠️ 当前 PHP 未启用 posix 扩展,或 posix_kill 被 disable_functions 禁用,无法停止旧进程,重启中止。');
|
||||
$output->writeln(' 请用 root 执行:kill -9 ' . implode(' ', array_keys($procs)) . ' 后再运行本命令。');
|
||||
return 1;
|
||||
}
|
||||
foreach ($res['steps'] as $step) {
|
||||
$output->writeln(' · ' . $step);
|
||||
}
|
||||
} else {
|
||||
$output->writeln('未发现运行中的旧进程,直接启动。');
|
||||
}
|
||||
|
||||
// ② 等端口真正释放(否则 Workerman 的 checkPortAvailable() 会抛异常并被 runAll() 静默吞掉)
|
||||
$report = ['lines' => [], 'busy' => []];
|
||||
for ($i = 0; $i < 30; $i++) {
|
||||
$report = Broker::portReport();
|
||||
if (empty($report['busy'])) {
|
||||
break;
|
||||
}
|
||||
usleep(300000);
|
||||
}
|
||||
if (!empty($report['busy'])) {
|
||||
$output->writeln('❌ 端口未释放,重启中止:');
|
||||
foreach ($report['lines'] as $line) {
|
||||
$output->writeln(' · ' . $line);
|
||||
}
|
||||
$output->writeln(' 请执行 ss -lntp | grep -E \':' . implode('|', $report['busy'])
|
||||
. '\' 查出占用进程(可能不是本插件的 Broker),处理后再重启。');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// pid 文件先删掉:Workerman 的 restart 分支会按它找旧 master,删掉可确保它只做启动
|
||||
if (is_file($pidFile)) {
|
||||
@unlink($pidFile);
|
||||
}
|
||||
|
||||
// ③ 启新:daemonize 由 -d 显式决定(不依赖 argv 解析,更可靠)
|
||||
$daemon = $this->daemonRequested($input);
|
||||
$output->writeln('启动新进程(' . ($daemon ? '后台守护' : '前台调试,SSH 断开即退出') . ')...');
|
||||
Worker::$daemonize = $daemon;
|
||||
|
||||
(new Broker())->start();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* status
|
||||
* ========================================================== */
|
||||
|
||||
protected function doStatus(Input $input, Output $output): int
|
||||
{
|
||||
$output->writeln('');
|
||||
$output->writeln('MQTT Broker 状态 ' . date('Y-m-d H:i:s'));
|
||||
$output->writeln(str_repeat('─', 60));
|
||||
|
||||
if (is_dir('/proc')) {
|
||||
$healthy = $this->showProcess($output);
|
||||
$healthy = $this->showPidFile($output) && $healthy;
|
||||
$healthy = $this->showPorts($output) && $healthy;
|
||||
} else {
|
||||
// Windows 无 /proc,端口是否监听就是唯一判据
|
||||
$this->showProcess($output);
|
||||
$healthy = $this->showPorts($output);
|
||||
}
|
||||
$this->showConfig($output);
|
||||
$this->showEnv($output);
|
||||
$this->showLog($output, max(1, (int) $input->getOption('lines')));
|
||||
if (!$input->getOption('no-db')) {
|
||||
$this->showDb($output);
|
||||
}
|
||||
|
||||
$output->writeln(str_repeat('─', 60));
|
||||
$output->writeln($healthy ? '✅ 结论:Broker 正在运行且端口正常。' : '❌ 结论:Broker 未正常运行,请看上面各段的异常提示。');
|
||||
return $healthy ? 0 : 1;
|
||||
}
|
||||
|
||||
/** 进程段 */
|
||||
protected function showProcess(Output $output): bool
|
||||
{
|
||||
if (!is_dir('/proc')) {
|
||||
$output->writeln('[进程] Windows 环境无法扫描 /proc,请用 netstat -ano | findstr "1883 8083" 查看。');
|
||||
return false;
|
||||
}
|
||||
|
||||
$procs = Broker::findBrokerProcesses();
|
||||
if (empty($procs)) {
|
||||
$output->writeln('[进程] ❌ 未发现运行中的 Broker 进程。');
|
||||
$output->writeln(' 启动:php think iot start -d(必须在站点根执行)');
|
||||
return false;
|
||||
}
|
||||
|
||||
$output->writeln('[进程] 共 ' . count($procs) . ' 个');
|
||||
$output->writeln(' ' . sprintf('%-8s %-7s %-12s %s', 'PID', '角色', '已运行', '进程标题'));
|
||||
$hasMaster = false;
|
||||
$hasOrphan = false;
|
||||
foreach ($procs as $pid => $info) {
|
||||
$started = Broker::processStartTime($pid);
|
||||
$output->writeln(' ' . sprintf(
|
||||
'%-8s %-7s %-12s %s',
|
||||
$pid,
|
||||
$info['role'],
|
||||
$started > 0 ? $this->humanDuration(time() - $started) : '-',
|
||||
$info['cmd']
|
||||
));
|
||||
$hasMaster = $hasMaster || $info['role'] === 'master';
|
||||
$hasOrphan = $hasOrphan || $info['role'] === 'orphan';
|
||||
}
|
||||
|
||||
if ($hasOrphan) {
|
||||
$output->writeln(' ⚠️ 存在 orphan(孤儿)worker:master 已被杀但子进程仍持有监听端口。');
|
||||
$output->writeln(' 处理:php think iot stop(会自动兜底 SIGKILL)');
|
||||
}
|
||||
if (!$hasMaster) {
|
||||
$output->writeln(' ⚠️ 没有 master 进程,但仍有 worker 存活 → 端口会被占着,必须清掉后重启。');
|
||||
}
|
||||
return $hasMaster;
|
||||
}
|
||||
|
||||
/** pid 文件段 */
|
||||
protected function showPidFile(Output $output): bool
|
||||
{
|
||||
$pidFile = Broker::pidFilePath();
|
||||
if (!is_file($pidFile)) {
|
||||
$output->writeln('[pid 文件] ❌ 缺失:' . $pidFile);
|
||||
$output->writeln(' 影响:iot stop / iot restart 的常规路径失效(新版已能按进程标题兜底查找)。');
|
||||
return false;
|
||||
}
|
||||
|
||||
$pid = (int) trim((string) @file_get_contents($pidFile));
|
||||
$ok = $pid > 0 && function_exists('posix_kill') && @posix_kill($pid, 0);
|
||||
$output->writeln('[pid 文件] ' . $pidFile . ' → ' . $pid . ($ok ? '(进程存活)' : '(⚠️ 进程已不存在,属残留文件)'));
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/** 端口段 */
|
||||
protected function showPorts(Output $output): bool
|
||||
{
|
||||
$report = Broker::portReport();
|
||||
if (empty($report['lines'])) {
|
||||
$output->writeln('[端口] 未能解析配置中的端口(port / ws_port),跳过。');
|
||||
return true;
|
||||
}
|
||||
$output->writeln('[端口]');
|
||||
foreach ($report['lines'] as $line) {
|
||||
$output->writeln(' · ' . $line);
|
||||
}
|
||||
if (!empty($report['busy'])) {
|
||||
$output->writeln(' 说明:占用端口但进程列表里没有 master 时,占用者可能不是本插件。');
|
||||
$output->writeln(' 查询:ss -lntp | grep -E \':' . implode('|', $report['busy']) . '\'');
|
||||
}
|
||||
return empty($report['busy']);
|
||||
}
|
||||
|
||||
/** 配置段(⚠️ 进程只在启动时读一次) */
|
||||
protected function showConfig(Output $output): void
|
||||
{
|
||||
$cfg = Broker::currentConfig();
|
||||
if ($cfg === null) {
|
||||
$output->writeln('[配置] ⚠️ 读取失败(AddonService::config(\'iot\') 抛异常),无法展示。');
|
||||
return;
|
||||
}
|
||||
$output->writeln('[配置] 库/缓存中的当前值(⚠️ Broker 只在启动时读一次,改完必须 iot restart -d)');
|
||||
$parts = [];
|
||||
foreach (self::SHOW_KEYS as $key) {
|
||||
if (array_key_exists($key, $cfg)) {
|
||||
$parts[] = $key . '=' . (is_bool($cfg[$key]) ? (int) $cfg[$key] : (string) $cfg[$key]);
|
||||
} elseif (in_array($key, ['allow_member_login', 'acl_enabled', 'acl_default'], true)) {
|
||||
$parts[] = $key . '=⚠️未设置';
|
||||
}
|
||||
}
|
||||
$output->writeln(' ' . implode(' ', $parts));
|
||||
|
||||
if (!array_key_exists('allow_member_login', $cfg)) {
|
||||
$output->writeln(' ⚠️ allow_member_login 未保存过 → 会员账号/密码登录恒为「关」');
|
||||
$output->writeln(' 表现:用会员账号连上后订阅不了自己设备(身份降级 anonymous-named,无 member_id)。');
|
||||
$output->writeln(' 处理:后台保存一次 MQTT 配置 → php think iot restart -d');
|
||||
}
|
||||
}
|
||||
|
||||
/** 环境段(决定 stop/restart 能不能工作) */
|
||||
protected function showEnv(Output $output): void
|
||||
{
|
||||
$disabled = (string) ini_get('disable_functions');
|
||||
$posixOk = function_exists('posix_kill');
|
||||
$posixHit = $disabled !== '' && str_contains($disabled, 'posix_kill');
|
||||
$pcntlOk = function_exists('pcntl_fork');
|
||||
$pidDir = dirname(Broker::pidFilePath());
|
||||
|
||||
$output->writeln('[环境] PHP ' . PHP_VERSION . ' ' . PHP_OS . ' disable_functions='
|
||||
. ($disabled === '' ? '(空)' : $disabled));
|
||||
$output->writeln(' posix_kill=' . ($posixOk ? '可用' : '❌不可用')
|
||||
. ($posixHit ? '(被 disable_functions 禁用)' : '')
|
||||
. ' pcntl_fork=' . ($pcntlOk ? '可用' : '❌不可用')
|
||||
. ' /proc=' . (is_dir('/proc') ? '可读' : '不可读')
|
||||
. ' pid 目录=' . (is_dir($pidDir) ? (is_writable($pidDir) ? '可写' : '❌不可写') : '❌不存在'));
|
||||
if (!$posixOk || !$pcntlOk) {
|
||||
$output->writeln(' ⚠️ posix/pcntl 缺失会导致 iot stop / iot restart 失效,'
|
||||
. '且必须用与启动 Broker 相同的那个 PHP 来执行命令(宝塔多版本 PHP 极易用错)。');
|
||||
}
|
||||
}
|
||||
|
||||
/** 日志末尾(daemon 模式唯一的错误出口) */
|
||||
protected function showLog(Output $output, int $lines): void
|
||||
{
|
||||
$logFile = rtrim((string) runtime_path(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'iot.log';
|
||||
if (!is_file($logFile)) {
|
||||
$output->writeln('[日志] 无 ' . $logFile . '(未以 -d 启动,或从未写入)');
|
||||
return;
|
||||
}
|
||||
$size = (int) @filesize($logFile);
|
||||
$output->writeln('[日志] ' . $logFile . '(' . $this->humanSize($size) . ',末 ' . $lines . ' 行)');
|
||||
foreach ($this->tail($logFile, $lines) as $line) {
|
||||
$output->writeln(' ' . $line);
|
||||
}
|
||||
}
|
||||
|
||||
/** DB 快照段(全部防御式,失败不影响主流程) */
|
||||
protected function showDb(Output $output): void
|
||||
{
|
||||
try {
|
||||
$total = (int) Db::name('iot_device')->count();
|
||||
$bound = (int) Db::name('iot_device')->where('member_id', '>', 0)->count();
|
||||
$output->writeln('[数据库] iot_device 共 ' . $total . ' 台,已绑定会员 ' . $bound . ' 台');
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('[数据库] ⚠️ iot_device 查询失败:' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$online = (int) Db::name('iot_connection')->where('status', 1)->count();
|
||||
$output->writeln(' iot_connection status=1(记录值,非实时,实时看日志/后台):' . $online . ' 条');
|
||||
} catch (\Throwable $e) {
|
||||
// 表可能不存在,忽略
|
||||
}
|
||||
|
||||
try {
|
||||
$rows = Db::name('iot_device')
|
||||
->field('id,sn,uuid,member_id,create_at')
|
||||
->order('id', 'desc')
|
||||
->limit(5)
|
||||
->select()
|
||||
->toArray();
|
||||
if (empty($rows)) {
|
||||
$output->writeln(' 最近设备:(空)');
|
||||
return;
|
||||
}
|
||||
$output->writeln(' 最近 5 台:');
|
||||
foreach ($rows as $row) {
|
||||
$output->writeln(sprintf(
|
||||
' #%s sn=%s uuid=%s member_id=%s %s',
|
||||
$row['id'] ?? '-',
|
||||
$row['sn'] ?? '-',
|
||||
$row['uuid'] ?? '-',
|
||||
$row['member_id'] ?? '-',
|
||||
!empty($row['create_at']) ? date('Y-m-d H:i', (int) $row['create_at']) : '-'
|
||||
));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln(' 最近设备查询失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 小工具
|
||||
* ========================================================== */
|
||||
|
||||
/** 读文件末尾 N 行(只读尾部 128KB,避免大日志拖慢) */
|
||||
protected function tail(string $file, int $lines): array
|
||||
{
|
||||
$fp = @fopen($file, 'rb');
|
||||
if (!$fp) {
|
||||
return ['(无法读取)'];
|
||||
}
|
||||
$size = (int) @filesize($file);
|
||||
$chunk = 131072;
|
||||
$offset = max(0, $size - $chunk);
|
||||
fseek($fp, $offset);
|
||||
$buf = (string) fread($fp, $chunk);
|
||||
fclose($fp);
|
||||
if ($offset > 0) {
|
||||
$pos = strpos($buf, "\n");
|
||||
$buf = ($pos === false) ? '' : substr($buf, $pos + 1);
|
||||
}
|
||||
$arr = preg_split("/\r\n|\n|\r/", rtrim($buf)) ?: [];
|
||||
$arr = array_slice($arr, -max(1, $lines));
|
||||
|
||||
return array_filter($arr, static fn ($l): bool => $l !== '');
|
||||
}
|
||||
|
||||
/** 秒 → 人类可读 */
|
||||
protected function humanDuration(int $seconds): string
|
||||
{
|
||||
if ($seconds < 60) {
|
||||
return $seconds . ' 秒';
|
||||
}
|
||||
if ($seconds < 3600) {
|
||||
return intdiv($seconds, 60) . ' 分';
|
||||
}
|
||||
if ($seconds < 86400) {
|
||||
return intdiv($seconds, 3600) . ' 时 ' . intdiv($seconds % 3600, 60) . ' 分';
|
||||
}
|
||||
return intdiv($seconds, 86400) . ' 天 ' . intdiv($seconds % 86400, 3600) . ' 时';
|
||||
}
|
||||
|
||||
/** 字节 → 人类可读 */
|
||||
protected function humanSize(int $bytes): string
|
||||
{
|
||||
if ($bytes < 1024) {
|
||||
return $bytes . 'B';
|
||||
}
|
||||
if ($bytes < 1048576) {
|
||||
return round($bytes / 1024, 1) . 'KB';
|
||||
}
|
||||
return round($bytes / 1048576, 1) . 'MB';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user