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,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 . ')' : '-';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user