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

+233
View File
@@ -0,0 +1,233 @@
<?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\service;
use think\facade\Db;
/**
* MQTT 持久化服务:保留消息 + 离线消息队列
*
* - 保留消息(retained):broker 重启后从 DB 恢复,保证新订阅者仍能收到最后一条。
* - 离线消息(offline):clean=false 会话离线期间投递给它的 QoS1/2 消息入库,
* 客户端重连后取出补投。
*
* 均为二进制安全(longblob)。所有操作 try/catch,DB 不可用不影响 broker 运行。
* 由 persist_retain / persist_offline 配置开关控制。
*/
class Store
{
/** @var bool 启用保留消息持久化 */
protected $persistRetain;
/** @var bool 启用离线消息持久化 */
protected $persistOffline;
/** @var int 单客户端离线消息上限,超出丢弃最旧 */
protected $offlineLimit;
public function __construct(array $config)
{
$this->persistRetain = (string) ($config['persist_retain'] ?? '1') === '1';
$this->persistOffline = (string) ($config['persist_offline'] ?? '1') === '1';
$this->offlineLimit = (int) ($config['offline_limit'] ?? 1000);
}
public function ensureTables(): void
{
try {
Db::execute("CREATE TABLE IF NOT EXISTS `wxapp_iot_retain` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`topic` varchar(255) NOT NULL DEFAULT '',
`payload` longblob,
`qos` tinyint(1) NOT NULL DEFAULT '0',
`update_at` int DEFAULT NULL,
PRIMARY KEY (`id`), UNIQUE KEY `uk_topic` (`topic`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
Db::execute("CREATE TABLE IF NOT EXISTS `wxapp_iot_offline` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`client_id` varchar(191) NOT NULL DEFAULT '',
`topic` varchar(255) NOT NULL DEFAULT '',
`payload` longblob,
`qos` tinyint(1) NOT NULL DEFAULT '0',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`), KEY `idx_client_id` (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
Db::execute("CREATE TABLE IF NOT EXISTS `wxapp_iot_outbox` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`topic` varchar(255) NOT NULL DEFAULT '',
`payload` longblob,
`qos` tinyint(1) NOT NULL DEFAULT '0',
`retain` tinyint(1) NOT NULL DEFAULT '0',
`source` varchar(32) DEFAULT 'manual',
`create_at` int DEFAULT NULL,
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0待投递 1已投递',
PRIMARY KEY (`id`), KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
} catch (\Throwable $e) {
}
}
/* ---------------- 手动发布出站队列 ---------------- */
/**
* 入队一条手动发布(后台/API 发布经 DB 出栈,Broker 消费后内部路由,零外部依赖)
*/
public static function enqueueManual(string $topic, string $payload, int $qos, int $retain, string $source): bool
{
try {
Db::name('iot_outbox')->insert([
'topic' => $topic,
'payload' => $payload,
'qos' => $qos,
'retain' => $retain ? 1 : 0,
'source' => $source,
'create_at' => time(),
'status' => 0,
]);
// 下发留痕(仅设备命令主题)
DeviceLogService::log($topic, $payload, 'out');
return true;
} catch (\Throwable $e) {
return false;
}
}
/**
* 取出待投递的出站消息(status=0)
* @return array
*/
public function popOutbox(int $limit = 100): array
{
$out = [];
try {
$out = Db::name('iot_outbox')->where('status', 0)
->order('id', 'asc')->limit($limit)->select()->toArray();
} catch (\Throwable $e) {
}
return $out;
}
/**
* 标记出站消息已投递(避免重复路由)
*/
public function markOutboxSent(array $ids): void
{
if (empty($ids)) {
return;
}
try {
Db::name('iot_outbox')->whereIn('id', $ids)->update(['status' => 1]);
} catch (\Throwable $e) {
}
}
/* ---------------- 保留消息 ---------------- */
/**
* 启动时加载全部保留消息
* @return array topic => ['payload'=>string,'qos'=>int]
*/
public function loadRetained(): array
{
if (!$this->persistRetain) {
return [];
}
$out = [];
try {
foreach (Db::name('iot_retain')->select() as $row) {
$out[$row['topic']] = ['payload' => (string) $row['payload'], 'qos' => (int) $row['qos']];
}
} catch (\Throwable $e) {
}
return $out;
}
public function saveRetained(string $topic, string $payload, int $qos): void
{
if (!$this->persistRetain) {
return;
}
try {
$exists = Db::name('iot_retain')->where('topic', $topic)->find();
if ($exists) {
Db::name('iot_retain')->where('id', $exists['id'])
->update(['payload' => $payload, 'qos' => $qos, 'update_at' => time()]);
} else {
Db::name('iot_retain')->insert([
'topic' => $topic, 'payload' => $payload, 'qos' => $qos, 'update_at' => time(),
]);
}
} catch (\Throwable $e) {
}
}
public function deleteRetained(string $topic): void
{
if (!$this->persistRetain) {
return;
}
try {
Db::name('iot_retain')->where('topic', $topic)->delete();
} catch (\Throwable $e) {
}
}
/* ---------------- 离线消息 ---------------- */
public function queueOffline(string $clientId, string $topic, string $payload, int $qos): void
{
if (!$this->persistOffline) {
return;
}
try {
Db::name('iot_offline')->insert([
'client_id' => $clientId, 'topic' => $topic,
'payload' => $payload, 'qos' => $qos, 'create_at' => time(),
]);
// 超限裁剪:保留最新 offlineLimit 条
$count = Db::name('iot_offline')->where('client_id', $clientId)->count();
if ($count > $this->offlineLimit) {
$ids = Db::name('iot_offline')->where('client_id', $clientId)
->order('id', 'asc')->limit($count - $this->offlineLimit)->column('id');
if ($ids) {
Db::name('iot_offline')->whereIn('id', $ids)->delete();
}
}
} catch (\Throwable $e) {
}
}
/**
* 取出并清空某客户端的离线消息
* @return array<int,array{topic:string,payload:string,qos:int}>
*/
public function popOffline(string $clientId): array
{
if (!$this->persistOffline) {
return [];
}
$out = [];
try {
$rows = Db::name('iot_offline')->where('client_id', $clientId)->order('id', 'asc')->select();
foreach ($rows as $r) {
$out[] = ['topic' => $r['topic'], 'payload' => (string) $r['payload'], 'qos' => (int) $r['qos']];
}
if (!empty($out)) {
Db::name('iot_offline')->where('client_id', $clientId)->delete();
}
} catch (\Throwable $e) {
}
return $out;
}
}