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

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