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

+384
View File
@@ -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) {
}
}
}