Files
YwxAppThink/addon/iot/service/Bridge.php
T
yangwx 18101cf11a 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(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

238 lines
7.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}