Files
YwxAppThink/addon/iot/command/Iot.php
T
yangwx 18101cf11a 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(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

649 lines
29 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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';
}
}