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,384 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: ywxapp<admin@ywxapp.cn>
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace addon\iot\service;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 告警/通知服务(设备离线、场景联动、OTA 失败等)
|
||||
*
|
||||
* 落点:
|
||||
* - iot_alert 表:站内告警(App/后台可查、可标记已读)
|
||||
* - /notify/{account}/# 主题:MQTT 实时通知(retain 兜底,App 在线即推)
|
||||
*
|
||||
* 离线告警抑制:同一设备 10 分钟内只记一次,避免断连抖动刷屏
|
||||
*/
|
||||
class AlertService
|
||||
{
|
||||
/** 离线告警抑制窗口(秒) */
|
||||
public const OFFLINE_SUPPRESS = 600;
|
||||
|
||||
/** pos_index 取值:255 = 该告警与传感器位置无关(离线/场景/OTA 等) */
|
||||
public const POS_NA = 255;
|
||||
|
||||
/**
|
||||
* 设备 event 里 rs(reason 短码)→ 人话
|
||||
*
|
||||
* v1.6.0 起固件不再把中文塞进每条告警(中文 3 字节/字,"位置内温差超限"一条就 24B),
|
||||
* 改发两个字母的码;人话在服务端这一处展开 —— 想改文案不用重烧固件。
|
||||
* ov = 探头越限(某个探头超出 upper/lower)
|
||||
* df = 位置内温差超限(同总线两颗探头极差 ≥ gap)
|
||||
*/
|
||||
public const REASON_TEXT = [
|
||||
'ov' => '探头越限',
|
||||
'df' => '位置内温差超限',
|
||||
];
|
||||
|
||||
/** 确保 alert 表存在(自愈) */
|
||||
public static function ensureTable(): bool
|
||||
{
|
||||
$prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_';
|
||||
try {
|
||||
Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_alert` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属会员',
|
||||
`uuid` varchar(64) NOT NULL DEFAULT '' COMMENT '关联设备uuid(可空)',
|
||||
`pos_index` tinyint unsigned NOT NULL DEFAULT '255' COMMENT '传感器位置序号(255=不适用)',
|
||||
`type` varchar(32) NOT NULL DEFAULT '' COMMENT 'offline/scene/condition/custom',
|
||||
`level` tinyint(1) NOT NULL DEFAULT '2' COMMENT '1提示 2警告 3严重',
|
||||
`title` varchar(128) NOT NULL DEFAULT '' COMMENT '标题',
|
||||
`content` varchar(512) NOT NULL DEFAULT '' COMMENT '内容',
|
||||
`is_read` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0未读 1已读',
|
||||
`create_at` int DEFAULT NULL,
|
||||
PRIMARY KEY (`id`), KEY `idx_member` (`member_id`), KEY `idx_create_at` (`create_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||
self::ensureColumns();
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 老库加列自愈(pos_index:把告警精确到传感器位置
|
||||
* —— 通知里只有 alias 文本时,多个位置同名就无法定位到具体位置)
|
||||
*/
|
||||
public static function ensureColumns(): bool
|
||||
{
|
||||
$prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_';
|
||||
$table = $prefix . 'iot_alert';
|
||||
try {
|
||||
$cols = [];
|
||||
foreach (Db::query("SHOW COLUMNS FROM `{$table}`") as $r) {
|
||||
$cols[(string) $r['Field']] = true;
|
||||
}
|
||||
if (!isset($cols['pos_index'])) {
|
||||
Db::execute("ALTER TABLE `{$table}` ADD COLUMN `pos_index` tinyint unsigned NOT NULL DEFAULT '255' COMMENT '传感器位置序号(255=不适用)' AFTER `uuid`");
|
||||
}
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增告警(写表 + 实时通知)
|
||||
*
|
||||
* @param int $posIndex 传感器位置序号;与位置无关的告警传 self::POS_NA(255)。
|
||||
* 带上它,App/后台才能把通知精确对应到某个位置卡片
|
||||
* (仅靠 title 里的 alias 文本,同名位置无法区分)。
|
||||
* @return int 告警ID
|
||||
*/
|
||||
public static function add(int $memberId, string $type, int $level, string $title, string $content, string $uuid = '', int $posIndex = self::POS_NA): int
|
||||
{
|
||||
self::ensureTable();
|
||||
$now = time();
|
||||
$pos = max(0, min(255, $posIndex));
|
||||
$id = 0;
|
||||
try {
|
||||
$id = (int) Db::name('iot_alert')->insertGetId([
|
||||
'member_id' => $memberId,
|
||||
'uuid' => $uuid,
|
||||
'pos_index' => $pos,
|
||||
'type' => $type,
|
||||
'level' => max(1, min(3, $level)),
|
||||
'title' => mb_substr($title, 0, 128),
|
||||
'content' => mb_substr($content, 0, 512),
|
||||
'is_read' => 0,
|
||||
'create_at' => $now,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
if ($id > 0) {
|
||||
self::push($memberId, 'alert', (string) json_encode([
|
||||
'id' => $id, 'type' => $type, 'level' => max(1, min(3, $level)),
|
||||
'title' => mb_substr($title, 0, 128), 'content' => mb_substr($content, 0, 512),
|
||||
'uuid' => $uuid, 'pos' => $pos, 'time' => $now,
|
||||
]));
|
||||
}
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备离线告警(带抑制窗口,Broker onTcpClose 调用)
|
||||
*/
|
||||
public static function onDeviceOffline(string $uuid): void
|
||||
{
|
||||
if ($uuid === '') {
|
||||
return;
|
||||
}
|
||||
$dev = DeviceService::findByUuid($uuid);
|
||||
if (!$dev || (int) $dev['member_id'] <= 0) {
|
||||
return; // 非绑定设备/未绑定会员不告警
|
||||
}
|
||||
self::ensureTable();
|
||||
$now = time();
|
||||
try {
|
||||
// 抑制:10 分钟内已有同设备 offline 告警则跳过
|
||||
$recent = Db::name('iot_alert')
|
||||
->where('uuid', $uuid)
|
||||
->where('type', 'offline')
|
||||
->where('create_at', '>', $now - self::OFFLINE_SUPPRESS)
|
||||
->count();
|
||||
if ($recent > 0) {
|
||||
return;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return;
|
||||
}
|
||||
self::add((int) $dev['member_id'], 'offline', 2, '设备离线',
|
||||
'设备「' . $dev['title'] . '」已断开连接', $uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备主动事件上报 → 告警 + 实时通知(Broker 钩子 /devices/{uuid}/event 调用)
|
||||
*
|
||||
* 支持两类 payload:
|
||||
* 1) 通用事件:{"level":1,"title":"门被打开","content":"前门传感器检测到开合"}
|
||||
* level: 1提示 2警告 3严重(越界自动收敛)
|
||||
* 2) 传感器结构化告警(水温固件 v1.4+)—— 各版式都认:
|
||||
* v1.7.0 {"type":"alert","alerts":[{"alias":"进水","t":95.6,"df":0.3,"rs":"ov",
|
||||
* "pr":[[0,"A3F2",95.6]]}, null]}
|
||||
* = v1.6.0 同构,只是键名压短(probes → pr);下面解析对新旧键都兜底
|
||||
* v1.6.0 {"type":"alert","alerts":[{"alias":"进水","t":95.6,"df":0.3,"rs":"ov",
|
||||
* "probes":[[0,"A3F2",95.6]]}, null]}
|
||||
* alerts 下标 = 位置序号(未告警的位置写 null 占位)
|
||||
* v1.5.0 {"alerts":[{"index":0,"alias":"进水","temp":95.6,"temp_diff":0.3,
|
||||
* "reason":"探头越限","probes":[{"slot":0,"id":"A3F2","temp":95.6}]}]}
|
||||
* ≤1.4.0 {"alerts":[{"alias":"位置1","t":95.6,"df":0.3,"reason":"overunder",
|
||||
* "bad":[0],"bad_ids":["A3F2"]}]} ← 没带位置号,只能记 POS_NA
|
||||
* 设备端 alertMaskOf() 是「实时兜底」(断网也报),这里把结构化告警逐位置落库,
|
||||
* 并带上 pos_index(位置号)与探头 ROM 身份,否则通知里只剩一段原始 JSON,
|
||||
* 既定位不到位置也认不出是哪颗探头。
|
||||
*/
|
||||
public static function onDeviceEvent(string $uuid, string $payload): void
|
||||
{
|
||||
if ($uuid === '') {
|
||||
return;
|
||||
}
|
||||
$data = json_decode($payload, true);
|
||||
if (!is_array($data)) {
|
||||
return;
|
||||
}
|
||||
$dev = DeviceService::findByUuid($uuid);
|
||||
if (!$dev || (int) $dev['member_id'] <= 0) {
|
||||
return; // 非绑定设备不告警
|
||||
}
|
||||
$memberId = (int) $dev['member_id'];
|
||||
|
||||
/* 结构化传感器告警:一个位置一条,便于 App 精确标注到位置卡片 */
|
||||
if (isset($data['alerts']) && is_array($data['alerts']) && $data['alerts']) {
|
||||
foreach (array_values($data['alerts']) as $k => $a) {
|
||||
if (!is_array($a)) {
|
||||
continue; // v1.6.0 的 null 占位 = 该位置本次没告警
|
||||
}
|
||||
/* 位置序号:v1.6.0 认数组下标(报文里已没有 index 键);v1.5.0 认 index;
|
||||
≤1.4.0 两者都没有(那时 alerts 是稀疏数组、位置号根本没上过报文)→ POS_NA。
|
||||
v1.6 与 ≤1.4.0 都没有 index,用 rs 键区分:只有 v1.6 才有 rs。 */
|
||||
if (isset($a['index'])) {
|
||||
$i = (int) $a['index'];
|
||||
} elseif (isset($a['i'])) {
|
||||
$i = (int) $a['i'];
|
||||
} elseif (array_key_exists('rs', $a)) {
|
||||
$i = $k;
|
||||
} else {
|
||||
$i = self::POS_NA;
|
||||
}
|
||||
$alias = (string) ($a['alias'] ?? ('位置' . ($i + 1)));
|
||||
/* v1.6.0 = t/df,v1.5.0 = temp/temp_diff(可为 null),≤1.4.0 = t/df */
|
||||
$rawT = $a['temp'] ?? $a['t'] ?? null;
|
||||
$rawD = $a['temp_diff'] ?? $a['df'] ?? null;
|
||||
$t = is_numeric($rawT) ? (float) $rawT : null;
|
||||
$df = is_numeric($rawD) ? (float) $rawD : null;
|
||||
/* 告警原因:v1.6.0 发短码 rs(省掉每条约 24B 的中文重复),v1.5.0 发中文,
|
||||
≤1.4.0 发 overunder/gap 之类的英文码 —— 短码在这里统一翻成人话 */
|
||||
$why = trim((string) ($a['reason'] ?? ''));
|
||||
if ($why === '' && isset($a['rs'])) {
|
||||
$why = self::REASON_TEXT[(string) $a['rs']] ?? '';
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
if ($why !== '') {
|
||||
$parts[] = $why;
|
||||
}
|
||||
if ($t !== null) {
|
||||
$parts[] = sprintf('位置温度 %.2f℃', $t);
|
||||
}
|
||||
if ($df !== null) {
|
||||
$parts[] = sprintf('位置内极差 %.2f℃', $df);
|
||||
}
|
||||
/* 探头身份:明确「哪一颗」,而不是「第几号下标」 */
|
||||
$named = [];
|
||||
/* 键名 pr(v1.7.0)优先、回落 probes(≤1.6.2)—— 现场老固件不重烧,长期兼容 */
|
||||
$rawPr = $a['pr'] ?? $a['probes'] ?? null;
|
||||
if (is_array($rawPr)) {
|
||||
/* 身份与读数一起给出,通知里可直接展示:
|
||||
v1.7.0/v1.6.0 pr|probes[[槽位, id, temp]] 三元组;v1.5.0 [{slot,id,temp}] 对象 */
|
||||
foreach (array_values($rawPr) as $p) {
|
||||
if (!is_array($p)) {
|
||||
continue;
|
||||
}
|
||||
if (array_key_exists(0, $p)) {
|
||||
$pidRaw = $p[1] ?? null;
|
||||
$pid = (is_string($pidRaw) && $pidRaw !== '') ? $pidRaw : '';
|
||||
$pt = is_numeric($p[2] ?? null) ? sprintf('%.2f℃', (float) $p[2]) : '';
|
||||
} else {
|
||||
$pid = (isset($p['id']) && is_string($p['id']) && $p['id'] !== '') ? $p['id'] : '';
|
||||
$pt = (isset($p['temp']) && is_numeric($p['temp'])) ? sprintf('%.2f℃', (float) $p['temp']) : '';
|
||||
}
|
||||
if ($pid !== '' && $pt !== '') {
|
||||
$named[] = $pid . ' ' . $pt;
|
||||
} elseif ($pid !== '') {
|
||||
$named[] = $pid;
|
||||
} elseif ($pt !== '') {
|
||||
$named[] = $pt;
|
||||
}
|
||||
}
|
||||
} elseif (isset($a['bad_ids']) && is_array($a['bad_ids'])) {
|
||||
/* ≤1.4.0:bad[] 槽位下标 + bad_ids[] 身份,按位对应 */
|
||||
foreach ($a['bad_ids'] as $b) {
|
||||
if (is_string($b) && $b !== '') {
|
||||
$named[] = $b;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($named) {
|
||||
$parts[] = '异常探头 ' . implode('、', $named);
|
||||
} elseif (isset($a['bad']) && is_array($a['bad']) && $a['bad']) {
|
||||
$parts[] = '异常探头下标 ' . implode('、', array_map('strval', $a['bad']));
|
||||
}
|
||||
$content = implode(';', $parts);
|
||||
if ($content === '') {
|
||||
$content = '设备上报温度异常';
|
||||
}
|
||||
self::add($memberId, 'event', 3, '温度异常:' . $alias, $content, $uuid, $i);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* 通用事件 */
|
||||
$level = (int) ($data['level'] ?? 2);
|
||||
$title = (string) ($data['title'] ?? '设备事件');
|
||||
$content = (string) ($data['content'] ?? ($data['msg'] ?? $payload));
|
||||
self::add($memberId, 'event', $level, $title, $content, $uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送到会员 MQTT 通知主题(retain=1 兜底)
|
||||
* @param string $topic 如 alert / scene / ota
|
||||
*/
|
||||
public static function push(int $memberId, string $topic, string $payload): void
|
||||
{
|
||||
$account = DeviceService::memberAccount($memberId);
|
||||
if ($account === '') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Db::name('iot_outbox')->insert([
|
||||
'topic' => sprintf('/notify/%s/%s', $account, $topic),
|
||||
'payload' => $payload,
|
||||
'qos' => 1,
|
||||
'retain' => 1,
|
||||
'status' => 0,
|
||||
'create_at' => time(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员告警分页(App/后台)
|
||||
* @return array{list:array,total:int,unread:int}
|
||||
*/
|
||||
public static function page(int $memberId, int $page = 1, int $size = 20, bool $onlyUnread = false): array
|
||||
{
|
||||
self::ensureTable();
|
||||
$q = Db::name('iot_alert')->where('member_id', $memberId);
|
||||
if ($onlyUnread) {
|
||||
$q->where('is_read', 0);
|
||||
}
|
||||
$total = (int) $q->count();
|
||||
$unread = (int) Db::name('iot_alert')
|
||||
->where('member_id', $memberId)
|
||||
->where('is_read', 0)
|
||||
->count();
|
||||
$list = (clone $q)
|
||||
->order('id', 'desc')
|
||||
->page(max(1, $page), min(100, max(1, $size)))
|
||||
->select()
|
||||
->toArray();
|
||||
return ['list' => $list, 'total' => $total, 'unread' => $unread];
|
||||
}
|
||||
|
||||
/** 后台:全部会员告警分页 */
|
||||
public static function pageAll(int $page = 1, int $size = 20, bool $onlyUnread = false): array
|
||||
{
|
||||
self::ensureTable();
|
||||
$q = Db::name('iot_alert');
|
||||
if ($onlyUnread) {
|
||||
$q->where('is_read', 0);
|
||||
}
|
||||
$total = (int) $q->count();
|
||||
$list = (clone $q)->order('id', 'desc')
|
||||
->page(max(1, $page), min(100, max(1, $size)))
|
||||
->select()->toArray();
|
||||
return ['list' => $list, 'total' => $total];
|
||||
}
|
||||
|
||||
/** 标记已读(空 ids = 全部已读),返回影响行数 */
|
||||
public static function markRead(int $memberId, array $ids = []): int
|
||||
{
|
||||
self::ensureTable();
|
||||
$q = Db::name('iot_alert')->where('member_id', $memberId)->where('is_read', 0);
|
||||
if ($ids) {
|
||||
$q->whereIn('id', array_map('intval', $ids));
|
||||
}
|
||||
try {
|
||||
return (int) $q->update(['is_read' => 1]);
|
||||
} catch (\Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除告警(支持批量) */
|
||||
public static function delete(int $memberId, array $ids = []): void
|
||||
{
|
||||
$q = Db::name('iot_alert')->where('member_id', $memberId);
|
||||
if ($ids) {
|
||||
$q->whereIn('id', array_map('intval', $ids));
|
||||
}
|
||||
try {
|
||||
$q->delete();
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user