Files
YwxAppThink/addon/iot/command/Probe.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

199 lines
9.9 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 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 . ')' : '-';
}
}