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

+237
View File
@@ -0,0 +1,237 @@
<?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;
/**
* 跨进程消息桥接(基于 Redis Stream)
*
* 背景:Workerman 每个 Worker 是独立进程,内存不共享。要让 TCP 监听进程与
* WebSocket 监听进程(以及 count>1 的多进程)之间互通消息,需要一个共享总线。
*
* 方案:
* - 发布:每条消息 XADD 到 Redis Stream(带来源 nodeId,用于去重)。
* - 消费:每个进程用非阻塞 XREAD 周期轮询该 Stream(不阻塞事件循环),
* 收到非自身产生的消息后投递给本进程的本地订阅者。
* - Stream 通过 MAXLEN 近似裁剪,避免无限增长。
*
* 开关:cluster_enabled=1 且 Redis 可连时启用;否则 isEnabled()=false,
* Broker 退化为单进程本地路由,功能不受影响。
*
* 已知限制:跨进程的共享订阅($share/)会在每个进程各自 round-robin,
* 若同一共享组成员分布在不同进程,可能重复投递(同进程内正确)。
*/
class Bridge
{
/** @var array */
protected $config;
/** @var bool */
protected $enabled = false;
/** @var \Redis|null */
protected $redis;
/** @var string 本进程唯一标识 */
protected $nodeId;
/** @var string Stream key */
protected $stream;
/** @var string 统计 key 前缀(各进程指标 hash 前缀) */
protected $statsPrefix;
/** @var string 读取游标 */
protected $lastId = '0-0';
/** @var int Stream 近似最大长度 */
protected $maxlen;
public function __construct(array $config)
{
$this->config = $config;
$this->nodeId = getmypid() . '-' . substr(md5(uniqid('', true)), 0, 6);
$prefix = $config['redis_prefix'] ?? 'iot';
$this->stream = $prefix . ':bus';
$this->statsPrefix = $prefix;
$this->maxlen = (int) ($config['bridge_maxlen'] ?? 10000);
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function nodeId(): string
{
return $this->nodeId;
}
/**
* 连接 Redis 并初始化游标(须在 worker 进程内调用,即 onWorkerStart)
*/
public function connect(): bool
{
if ((string) ($this->config['cluster_enabled'] ?? '0') !== '1') {
$this->enabled = false;
return false;
}
if (!class_exists(\Redis::class)) {
$this->enabled = false;
return false;
}
try {
$r = new \Redis();
$r->connect($this->config['redis_host'] ?? '127.0.0.1', (int) ($this->config['redis_port'] ?? 6379), 1.5);
if (!empty($this->config['redis_password'])) {
$r->auth((string) $this->config['redis_password']);
}
if (isset($this->config['redis_db']) && $this->config['redis_db'] !== '') {
$r->select((int) $this->config['redis_db']);
}
$r->ping();
$this->redis = $r;
// 从当前末尾开始消费,忽略历史积压
$last = $r->xRevRange($this->stream, '+', '-', 1);
$this->lastId = $last ? (string) array_key_first($last) : '0-0';
$this->enabled = true;
} catch (\Throwable $e) {
$this->enabled = false;
}
return $this->enabled;
}
/**
* 将消息投递到总线(供其它进程消费)
*/
public function publish(string $topic, string $msg, int $qos, int $retain, ?string $from): void
{
if (!$this->enabled) {
return;
}
try {
$payload = json_encode([
'n' => $this->nodeId,
't' => $topic,
'p' => base64_encode($msg),
'q' => $qos,
'r' => $retain,
'f' => (string) $from,
], JSON_UNESCAPED_UNICODE);
// 近似裁剪,减少 XADD 开销
$this->redis->xAdd($this->stream, '*', ['d' => $payload], $this->maxlen, true);
} catch (\Throwable $e) {
$this->reconnect();
}
}
/**
* 非阻塞轮询总线,对每条非本进程消息回调 $cb($topic,$msg,$qos,$retain,$from)
*/
public function poll(callable $cb): void
{
if (!$this->enabled) {
return;
}
try {
$res = $this->redis->xRead([$this->stream => $this->lastId], 200); // 非阻塞,最多取 200 条
if (!$res || empty($res[$this->stream])) {
return;
}
foreach ($res[$this->stream] as $id => $fields) {
$this->lastId = (string) $id;
$d = json_decode($fields['d'] ?? '', true);
if (!is_array($d)) {
continue;
}
if (($d['n'] ?? '') === $this->nodeId) {
continue; // 跳过自身产生的消息,避免回环
}
$cb(
(string) ($d['t'] ?? ''),
base64_decode($d['p'] ?? ''),
(int) ($d['q'] ?? 0),
(int) ($d['r'] ?? 0),
$d['f'] ?? null
);
}
} catch (\Throwable $e) {
$this->reconnect();
}
}
protected function reconnect(): void
{
$this->enabled = false;
try {
$this->connect();
} catch (\Throwable $e) {
}
}
/**
* 将本进程运行指标写入共享 Redis(TTL 10s,进程退出自动过期)。
* 各进程周期调用,供 primary 聚合后落库展示全局连接数/消息数。
*/
public function writeStats(array $data): void
{
if (!$this->enabled) {
return;
}
try {
$key = $this->statsPrefix . ':stats:' . $this->nodeId;
$this->redis->hMSet($key, $data);
$this->redis->expire($key, 10);
} catch (\Throwable $e) {
$this->reconnect();
}
}
/**
* 聚合所有存活进程的指标。
* 连接数/订阅数/收发数为求和;retained 为全量快照(各进程相同),取最大值。
*/
public function aggregateStats(): array
{
$agg = [
'clients_online' => 0,
'clients_total' => 0,
'subscriptions' => 0,
'messages_received' => 0,
'messages_sent' => 0,
'retained' => 0,
];
if (!$this->enabled) {
return $agg;
}
try {
$keys = $this->redis->keys($this->statsPrefix . ':stats:*');
$retainedMax = 0;
foreach ($keys as $k) {
$v = $this->redis->hGetAll($k);
if (!is_array($v)) {
continue;
}
$agg['clients_online'] += (int) ($v['clients_online'] ?? 0);
$agg['clients_total'] += (int) ($v['clients_total'] ?? 0);
$agg['subscriptions'] += (int) ($v['subscriptions'] ?? 0);
$agg['messages_received'] += (int) ($v['messages_received'] ?? 0);
$agg['messages_sent'] += (int) ($v['messages_sent'] ?? 0);
$retainedMax = max($retainedMax, (int) ($v['retained'] ?? 0));
}
$agg['retained'] = $retainedMax;
} catch (\Throwable $e) {
$this->reconnect();
}
return $agg;
}
}