// +---------------------------------------------------------------------- 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 [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 . ')' : '-'; } }