安全(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(市场 / 开发者中心 / 审核 / 分润)
238 lines
7.9 KiB
PHP
238 lines
7.9 KiB
PHP
<?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;
|
||
}
|
||
}
|