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:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+648
View File
@@ -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';
}
}
+198
View File
@@ -0,0 +1,198 @@
<?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 addon\iot\service\SensorPointService;
/**
* 曲线/时序链路自检(不经 HTTP、不依赖 token、不受调用方影响)
*
* php think iot:probe <uuid> [pos=0] [--step=1800] [--window=60] [--hours=24] [--limit=5]
*
* 用途:「接口没有温度、曲线全 0」时,HTTP 外面还套着「调用方 → 中间层 → 路由缓存 →
* 代理」,看到的 JSON 可能早被映射过(典型:拿 sensorSeries 的键去读 sensorAvg 的返回,
* 温度恒 0 而时间轴正常)。本命令在服务器上直接跑接口同一份代码
* (SensorPointService::series / seriesAvg / recentAvg)、同一套库连接,一次打全。
*
* 判读(结论在最后一段):
* · 本命令有温度、HTTP 接口回 0 ⇒ 故障在 HTTP 外层/调用方(读错键、中间层映射)
* · rows_uuid=0 ⇒ 接口连的库/前缀里没这台设备(连错库、错站点)
* · rows_uuid>0 且 rows_range=0 ⇒ 时间条件或时钟/单位问题
* · 有行但 t 全 null/0 ⇒ 落库的就是无读数/垃圾行,与接口无关
*
* 只读命令:不写库、不改配置、不碰 Broker。
*/
class Probe extends Command
{
protected function configure()
{
$this->setName('iot:probe')
->addArgument('uuid', Argument::REQUIRED, '设备 uuid(dev_7f8b95e5e497e62d)')
->addArgument('pos', Argument::OPTIONAL, '位置序号 index,默认 0', '0')
->addOption('step', 's', Option::VALUE_OPTIONAL, '分桶秒数,默认 1800', 1800)
->addOption('window', 'w', Option::VALUE_OPTIONAL, 'recentAvg 窗口分钟,默认 60', 60)
->addOption('hours', 'H', Option::VALUE_OPTIONAL, '回溯小时数,默认 24', 24)
->addOption('limit', 'l', Option::VALUE_OPTIONAL, '每段打印条数,默认 5', 5)
->setDescription('曲线链路自检:库/表+行数+原始点+聚合桶均值(直连服务端代码,不经 HTTP)');
}
protected function execute(Input $input, Output $output)
{
return self::report(
(string) $input->getArgument('uuid'),
(int) $input->getArgument('pos'),
(int) $input->getOption('step'),
(int) $input->getOption('window'),
(int) $input->getOption('hours'),
(int) $input->getOption('limit'),
$output
);
}
/** 自检主体(iot:probe 与 iot probe 两个入口共用) */
public static function report(string $uuid, int $pos, int $step, int $windowMin, int $hours, int $limit, Output $output): int
{
$uuid = trim($uuid);
$pos = max(0, min(SensorPointService::MAX_POS - 1, $pos));
$step = max(60, min(86400, $step));
$windowMin = max(1, min(1440, $windowMin));
$hours = max(1, min(720, $hours));
$limit = max(1, min(50, $limit));
$now = time();
$from = $now - $hours * 3600;
$output->writeln('');
$output->writeln('曲线链路自检 ' . date('Y-m-d H:i:s') . ' uuid=' . $uuid . ' pos=' . $pos);
$output->writeln('回溯 ' . $hours . 'h 分桶 ' . $step . 's 窗口 ' . $windowMin . 'min 每段 ' . $limit . ' 条');
$output->writeln(str_repeat('-', 68));
if ($uuid === '') {
$output->writeln('[x] uuid 为空,退出。');
return 1;
}
$dbg = SensorPointService::seriesDebug($uuid, $pos, $from, $now, 3);
$output->writeln('[1] 库与表(接口实际连的库;与 SQL 客户端不一致 = 一票定生死)');
$output->writeln(' 库=' . $dbg['database'] . ' 表=' . $dbg['table']);
$output->writeln(' server_ts=' . $dbg['server_ts'] . ' (' . date('Y-m-d H:i', (int) $dbg['server_ts']) . ') 范围 '
. self::ts($from) . ' ~ ' . self::ts($now));
$output->writeln(' 行数:uuid=' . $dbg['rows_uuid'] . ' uuid+pos=' . $dbg['rows_pos'] . ' uuid+pos+范围=' . $dbg['rows_range']);
$output->writeln(' ts:uuid ' . self::ts($dbg['min_ts']) . ' ~ ' . self::ts($dbg['max_ts'])
. ' ;pos' . $pos . ' ' . self::ts($dbg['pos_min_ts']) . ' ~ ' . self::ts($dbg['pos_max_ts']));
if (!empty($dbg['error'])) {
$output->writeln(' [x] 查询异常(原先被静默吞掉的那条):' . $dbg['error']);
}
$output->writeln('[2] 库里最新原始行(ts / pos / iv / t / alert_mask)');
if (empty($dbg['recent'])) {
$output->writeln(' (无)');
}
foreach ((array) $dbg['recent'] as $r) {
$output->writeln(sprintf(
' %s pos=%s iv=%s t=%s mask=%s',
date('m-d H:i:s', (int) ($r['ts'] ?? 0)),
(string) ($r['pos_index'] ?? '-'),
(string) ($r['iv'] ?? '-'),
$r['t'] === null ? 'null' : (string) $r['t'],
(string) ($r['alert_mask'] ?? '-')
));
}
$pts = SensorPointService::series($uuid, $pos, $from, $now, $limit);
$output->writeln('[3] sensorSeries 最近 ' . count($pts) . ' 点(温度键 = temp;null = 该刻无读数)');
foreach ($pts as $p) {
$output->writeln(sprintf(
' %s interval=%s temp=%s temp_diff=%s mask=%s probes=%s',
date('m-d H:i:s', (int) $p['ts']),
(string) $p['interval'],
$p['temp'] === null ? 'null' : (string) $p['temp'],
$p['temp_diff'] === null ? 'null' : (string) $p['temp_diff'],
(string) $p['alert_mask'],
json_encode($p['probes'], JSON_UNESCAPED_UNICODE)
));
}
$bks = SensorPointService::seriesAvg($uuid, $pos, $step, $from, $now, $limit);
$lastAvg = null;
$output->writeln('[4] sensorAvg 最近 ' . count($bks) . ' 桶(温度键 = avg,没有 temp;step=' . $step . 's)');
foreach ($bks as $b) {
$lastAvg = $b['avg'];
$output->writeln(sprintf(
' %s avg=%s min=%s max=%s samples=%s coverage_s=%s',
date('m-d H:i:s', (int) $b['ts']),
$b['avg'] === null ? 'null' : (string) $b['avg'],
(string) $b['min'],
(string) $b['max'],
(string) $b['samples'],
(string) $b['coverage_s']
));
}
$rec = SensorPointService::recentAvg($uuid, $windowMin, $pos);
$output->writeln('[5] recentAvg 最近 ' . $windowMin . ' 分钟汇总(温度键 = avg;data_ok=false = 无样本)');
$output->writeln(' samples_total=' . $rec['samples_total']);
foreach ((array) ($rec['positions'] ?? []) as $p) {
$output->writeln(sprintf(
' 位置 %s(%s) avg=%s min=%s max=%s samples=%s coverage_s=%s last_ts=%s data_ok=%s',
(string) $p['index'],
(string) ($p['alias'] ?? ''),
$p['avg'] === null ? 'null' : (string) $p['avg'],
$p['min'] === null ? 'null' : (string) $p['min'],
$p['max'] === null ? 'null' : (string) $p['max'],
(string) $p['samples'],
(string) $p['coverage_s'],
(string) $p['last_ts'],
!empty($p['data_ok']) ? 'true' : 'false'
));
}
$output->writeln(str_repeat('-', 68));
$output->writeln(self::verdict($dbg, $pts, $lastAvg));
return 0;
}
/** 结论:把故障锁到某一层 */
private static function verdict(array $dbg, array $pts, $lastAvg): string
{
if ((int) $dbg['rows_uuid'] === 0) {
return '结论:本机库/前缀里没有这台设备 ⇒ 接口读的库与 SQL 客户端不是同一个(连错库、表前缀不符、或设备在另一个站点)。';
}
if ((int) $dbg['rows_pos'] === 0) {
return '结论:该 uuid 有行,但 pos ' . $dbg['pos'] . ' 一行都没有 ⇒ 位置没启用,或上报全落在别的位置(看 [2] 的 pos 值)。';
}
if ((int) $dbg['rows_range'] === 0) {
return '结论:范围内 0 行 ⇒ 时间条件/时钟问题(比 [1] 的 server_ts 与库里 ts,并确认 ts 是秒不是毫秒)。';
}
if ($lastAvg !== null && (float) $lastAvg > 0) {
return '结论:本机代码 + 本库能算出温度([4] 最新桶 avg=' . $lastAvg . ')⇒ 若 HTTP 接口仍回 0/空,'
. '故障在 HTTP 外层:调用方读错键(温度在 points[].avg,没有 temp)、中间层映射,或路由缓存打到旧实例。';
}
$nonNull = 0;
foreach ($pts as $p) {
if ($p['temp'] !== null) {
$nonNull++;
}
}
if ($nonNull === 0) {
return '结论:有行但读数全为 null/0 ⇒ 落库的是「无读数」或脚本发的垃圾行(t:0、iv:0),不是接口问题(看 [2])。';
}
return '结论:数据链正常,请结合 [3][4] 的键名核对调用方取值(series 用 temp,聚合用 avg)。';
}
/** ts → 可读时间(空值显示 -) */
private static function ts($v): string
{
$v = (int) $v;
return $v > 0 ? date('m-d H:i', $v) . '(' . $v . ')' : '-';
}
}
+116
View File
@@ -0,0 +1,116 @@
<?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\facade\Db;
use ywxapp\service\AddonService;
use addon\iot\service\Auth;
/**
* ACL 实网决策验证:用真实 DB 规则校验 Auth::checkAcl 的放行/拒绝逻辑。
* php think iot:acltest
* 验证项:deny 发布拒绝、默认放行、用户定向拒绝、超管绕过、$SYS 只读。
* 不依赖运行中的 Broker,直接复用 Broker 实际调用的 Auth 逻辑。
*/
class TestAcl extends Command
{
protected function configure()
{
$this->setName('iot:acltest')
->setDescription('验证 ACL 发布/订阅权限决策(含 deny 拒绝)');
}
protected function execute(Input $input, Output $output)
{
// 配置:开启 ACL,默认放行
$saved = [];
try { $saved = AddonService::config('iot') ?: []; } catch (\Throwable $e) {}
$config = array_merge($saved, ['acl_enabled' => '1', 'acl_default' => 'allow']);
$auth = new Auth($config);
$auth->ensureTables();
// 插入临时规则(测试后清理)
$rows = [
['target_type' => 'user', 'target' => 'alice', 'topic' => 'alice/secret', 'access' => 2, 'allow' => 0, 'sort' => 5, 'remark' => 'tmp'],
['target_type' => 'all', 'target' => '', 'topic' => 'secret/#', 'access' => 2, 'allow' => 0, 'sort' => 10, 'remark' => 'tmp'],
['target_type' => 'all', 'target' => '', 'topic' => 'public/#', 'access' => 3, 'allow' => 1, 'sort' => 20, 'remark' => 'tmp'],
];
$ids = [];
foreach ($rows as $r) {
$ids[] = Db::name('iot_acl')->insertGetId(array_merge($r, ['create_at' => time()]));
}
$results = [];
$chk = function (string $case, bool $got, bool $expect) use (&$results) {
$results[$case] = ['got' => $got, 'expect' => $expect, 'ok' => $got === $expect];
};
// 1) deny 发布 secret/# 被拒
$chk('deny publish secret/#', $auth->checkAcl(null, 'c1', 'secret/data', Auth::ACT_PUB), false);
// 2) 同一主题仅 deny 发布,订阅走默认放行
$chk('subscribe secret/# default-allow', $auth->checkAcl(null, 'c1', 'secret/data', Auth::ACT_SUB), true);
// 3) allow 规则 public/# 发布放行
$chk('allow publish public/info', $auth->checkAcl(null, 'c1', 'public/info', Auth::ACT_PUB), true);
// 4) 未命中规则走默认放行
$chk('default allow other/topic', $auth->checkAcl(null, 'c1', 'other/topic', Auth::ACT_PUB), true);
// 5) 用户定向 deny:alice 发布 alice/secret 被拒
$chk('user alice deny publish', $auth->checkAcl('alice', 'cA', 'alice/secret', Auth::ACT_PUB), false);
// 6) alice 订阅 alice/secret(user 规则仅限发布)走默认放行
$chk('user alice subscribe (rule is pub-only)', $auth->checkAcl('alice', 'cA', 'alice/secret', Auth::ACT_SUB), true);
// 7) 超管绕过 deny
$chk('superuser bypass deny', $auth->checkAcl('root', 'cR', 'secret/data', Auth::ACT_PUB, true), true);
// 8) $SYS 只读:订阅放行、发布拒绝
$chk('$SYS subscribe allowed', $auth->checkAcl(null, 'c2', '$SYS/broker/uptime', Auth::ACT_SUB), true);
$chk('$SYS publish denied', $auth->checkAcl(null, 'c2', '$SYS/broker/uptime', Auth::ACT_PUB), false);
// 9) 设备注册主题:发布 register/{sn} 与订阅 .../resp 始终放行(入网必经,不受 ACL 默认策略影响)
$chk('register publish allowed', $auth->checkAcl(null, 'c3', 'system/device/register/SN001', Auth::ACT_PUB), true);
$chk('register resp subscribe allowed', $auth->checkAcl(null, 'c3', 'system/device/register/SN001/resp', Auth::ACT_SUB), true);
// 10) 私有主题硬归属:即使 acl_default=allow,匿名/冒名身份也不得触碰他人设备与告警
$chk('anonymous sub other device state', $auth->checkAcl(null, 'c4', '/devices/dev_x/state', Auth::ACT_SUB), false);
$chk('named-anon sub other device state', $auth->checkAcl('someone', 'c4', '/devices/dev_x/state', Auth::ACT_SUB, false, 'anonymous-named'), false);
$chk('named-anon pub other device cmd', $auth->checkAcl('someone', 'c4', '/devices/dev_x/cmd', Auth::ACT_PUB, false, 'anonymous-named'), false);
$chk('anonymous sub notify of others', $auth->checkAcl(null, 'c4', '/notify/alice/#', Auth::ACT_SUB), false);
$chk('self notify subscribe allowed', $auth->checkAcl('alice', 'c4', '/notify/alice/#', Auth::ACT_SUB), true);
$chk('superuser bypass private topic', $auth->checkAcl(null, 'c4', '/devices/dev_x/state', Auth::ACT_SUB, true), true);
// 11) 通配符订阅封堵:`#` / `+/...` 不得绕过私有主题归属(acl_default=allow 下也必须拒绝)
// 背景:顶层通配符不以 /devices/ 开头 → 硬归属返回 null → 落到 acl_default 兜底,
// 若兜底为「允许」,订阅 `#` 就能收到全站设备消息与注册回执(含 secret)。
$chk('wildcard # subscribe denied', $auth->checkAcl(null, 'c5', '#', Auth::ACT_SUB), false);
$chk('wildcard system/# denied', $auth->checkAcl(null, 'c5', 'system/#', Auth::ACT_SUB), false);
$chk('wildcard +/state denied', $auth->checkAcl(null, 'c5', '+/state', Auth::ACT_SUB), false);
$chk('register wildcard sub denied', $auth->checkAcl(null, 'c5', 'system/device/register/#', Auth::ACT_SUB), false);
$chk('register precise sub still allowed', $auth->checkAcl(null, 'c5', 'system/device/register/SN001/resp', Auth::ACT_SUB), true);
$chk('$SYS wildcard allowed (readonly)', $auth->checkAcl(null, 'c5', '$SYS/#', Auth::ACT_SUB), true);
$chk('superuser wildcard # allowed', $auth->checkAcl(null, 'c5', '#', Auth::ACT_SUB, true), true);
// 清理临时规则
if ($ids) {
Db::name('iot_acl')->whereIn('id', $ids)->delete();
}
$pass = 0;
$fail = 0;
foreach ($results as $case => $r) {
$flag = $r['ok'] ? 'PASS' : 'FAIL';
if ($r['ok']) { $pass++; } else { $fail++; }
$output->writeln("[{$flag}] {$case} (got=" . var_export($r['got'], true) . " expect=" . var_export($r['expect'], true) . ")");
}
$output->writeln("");
$output->writeln("ACL 验证结果:PASS={$pass} FAIL={$fail}");
return $fail === 0 ? 0 : 1;
}
}