Add realtime gateway and simulator bridge

This commit is contained in:
2026-03-27 21:06:17 +08:00
parent 0703fd47a2
commit 2c0fd4c549
36 changed files with 6852 additions and 1 deletions

2
.gitignore vendored
View File

@@ -21,3 +21,5 @@ pnpm-debug.log*
*.swp
.DS_Store
Thumbs.db
realtime-gateway/bin/
realtime-gateway/.tmp-gateway.*

View File

@@ -0,0 +1,287 @@
# Realtime Gateway + Cloudflare Tunnel 本机联调说明
本文档说明如何在**不正式部署到线上服务器**的前提下,把本机的 `realtime-gateway` 暴露给外部设备或远程联调方。
适用场景:
- 真机调试
- 外网联调
- 家长端 / 场控端远程验证
- 演示环境
不适用场景:
- 正式生产实时链路
- 严格 SLA 场景
- 高稳定长时间压测
---
## 1. 推荐结论
当前阶段建议:
1. 网关继续运行在本机
2. 本机使用 Cloudflare Tunnel 暴露新网关
3. 旧模拟器继续本地运行
4. 旧模拟器通过桥接把数据发给本机新网关
5. 外部客户端只访问 Cloudflare Tunnel 暴露出来的 `wss` 地址
这样做的好处:
- 不需要先买独立服务器
- 不需要先做正式公网部署
- 不改变当前本机开发结构
- 新旧链路可以并行使用
---
## 2. 本机网关配置
推荐先使用:
[tunnel-dev.json](D:/dev/cmr-mini/realtime-gateway/config/tunnel-dev.json)
这个配置相比开发默认配置更适合 Tunnel 联调:
- 端口改为 `18080`
- 关闭匿名 consumer
- token 不再使用默认开发值
建议先把其中的 token 改成你自己的值。
启动方式:
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go build -o .\bin\gateway.exe .\cmd\gateway
.\bin\gateway.exe -config .\config\tunnel-dev.json
```
本机地址:
- HTTP: `http://127.0.0.1:18080`
- WebSocket: `ws://127.0.0.1:18080/ws`
---
## 3. Quick Tunnel 方案
Quick Tunnel 最适合当前阶段。
Cloudflare 官方文档说明:
- Quick Tunnel 用于测试和开发,不建议生产使用
- 可以直接把本地 `http://localhost:8080` 之类的地址暴露出去
- 命令形式是 `cloudflared tunnel --url http://localhost:8080`
来源:
- [Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/)
- [Set up Cloudflare Tunnel](https://developers.cloudflare.com/tunnel/setup/)
### 3.1 启动 Quick Tunnel
你的网关如果跑在 `18080`
```powershell
cloudflared tunnel --url http://localhost:18080
```
启动后,`cloudflared` 会输出一个随机 `trycloudflare.com` 域名。
例如:
```text
https://random-name.trycloudflare.com
```
对应的 WebSocket 地址就是:
```text
wss://random-name.trycloudflare.com/ws
```
注意:
- 对外使用时WebSocket 必须写成 `wss://`
- 本地 origin 仍然是 `http://localhost:18080`
### 3.2 Quick Tunnel 限制
Cloudflare 官方说明里Quick Tunnel 当前有这些限制:
- 仅适合测试和开发
- 有并发请求上限
- 不支持 SSE
因此它适合:
- 临时分享联调地址
- 验证 WebSocket 接入
- 短期演示
不适合拿来当正式生产入口。
另外,官方文档提到:
- 如果本机 `.cloudflared` 目录里已有 `config.yaml`Quick Tunnel 可能不能直接使用
来源:
- [Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/)
---
## 4. Named Tunnel 方案
如果你后面不想每次拿随机域名,可以改用 Named Tunnel。
这时推荐的本地 `cloudflared` 配置示例在:
[config.example.yml](D:/dev/cmr-mini/realtime-gateway/deploy/cloudflared/config.example.yml)
示例内容:
```yaml
tunnel: YOUR_TUNNEL_ID
credentials-file: C:\Users\YOUR_USER\.cloudflared\YOUR_TUNNEL_ID.json
ingress:
- hostname: gateway-dev.example.com
service: http://localhost:18080
- service: http_status:404
```
关键点:
- Tunnel 把 `gateway-dev.example.com` 映射到本机 `http://localhost:18080`
- 最后一条 `http_status:404` 是 catch-all
Cloudflare 官方文档对 published application 和 ingress 的说明见:
- [Set up Cloudflare Tunnel](https://developers.cloudflare.com/tunnel/setup/)
- [Protocols for published applications](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/routing-to-tunnel/protocols/)
启动后,对外 WebSocket 地址就是:
```text
wss://gateway-dev.example.com/ws
```
---
## 4.1 外部 consumer 小工具
如果你要在另一台机器上用仓库里的 `mock-consumer` 做联调,推荐复制:
[consumer-tunnel.example.json](D:/dev/cmr-mini/realtime-gateway/config/consumer-tunnel.example.json)
[consumer-gps-heart.example.json](D:/dev/cmr-mini/realtime-gateway/config/consumer-gps-heart.example.json)
填好实际的公网地址和 token例如
```json
{
"url": "wss://gateway-dev.example.com/ws",
"token": "your-consumer-token",
"deviceId": "child-001",
"topics": [
"telemetry.location",
"telemetry.heart_rate"
],
"snapshot": true
}
```
然后运行:
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go run .\cmd\mock-consumer -config .\config\consumer-tunnel.example.json
```
命令行参数会覆盖配置文件中的同名项,所以临时换 `deviceId` 时可以直接追加:
```powershell
go run .\cmd\mock-consumer -config .\config\consumer-tunnel.example.json -device-id child-002
```
如果只想临时直接从命令行看 GPS 和心率:
```powershell
go run .\cmd\mock-consumer -url wss://gateway-dev.example.com/ws -token your-consumer-token -device-id child-001 -topics telemetry.location,telemetry.heart_rate
```
---
## 5. 旧模拟器如何配合
旧模拟器仍然本地运行:
```powershell
cd D:\dev\cmr-mini
npm run mock-gps-sim
```
然后在旧模拟器页面的“新网关桥接”区域里填:
- 网关地址:`ws://127.0.0.1:18080/ws`
- Producer Token与你 `tunnel-dev.json` 里配置一致
- 目标 Device ID按你的联调对象填写
- Source ID例如 `mock-gps-sim-a`
这里要注意:
- 旧模拟器桥接到的是**本机网关地址**
- 外部消费者连接的是**Cloudflare Tunnel 暴露的公网地址**
不要把旧模拟器桥接目标直接写成公网 `wss` 地址。
旧模拟器和网关都在本机,直接走本地回环最稳。
---
## 6. 推荐联调拓扑
```text
旧模拟器页面
-> 本机 mock-gps-sim
-> 本机 realtime-gateway (ws://127.0.0.1:18080/ws)
-> Cloudflare Tunnel
-> 外部 consumer / 真机 / 调试端 (wss://<public-host>/ws)
```
这样职责最清晰:
- 旧模拟器只负责发模拟数据
- 新网关负责实时中转
- Cloudflare Tunnel 只负责把本机网关暴露到外部
---
## 7. 当前阶段的安全建议
即使只是联调,也建议至少做到:
- `allowAnonymousConsumers = false`
- Producer / Consumer / Controller token 全部替换
- 不把默认 token 长期暴露在公网链接中
- Tunnel 只暴露新网关,不一定要暴露旧模拟器 UI
如果只是你自己本机调试:
- 旧模拟器 UI 保持本地访问即可
- 只把 `realtime-gateway` 暴露出去
---
## 8. 结论
当前阶段最推荐的方案是:
1. 本机用 [tunnel-dev.json](D:/dev/cmr-mini/realtime-gateway/config/tunnel-dev.json) 启动新网关
2. 本机旧模拟器桥接到 `ws://127.0.0.1:18080/ws`
3.`cloudflared tunnel --url http://localhost:18080` 先跑 Quick Tunnel
4. 外部客户端使用 `wss://<quick-tunnel-domain>/ws`
5. 等你需要固定域名或更稳定的入口时,再切换 Named Tunnel
这条路径最轻、最稳,也最符合你现在“先不正式上线”的目标。

View File

@@ -0,0 +1,123 @@
# 实时网关 MVP 拆分
本文档用于把 `realtime-gateway` 第一阶段工作拆成可执行任务。
---
## 1. 目标
第一阶段只解决以下问题:
- 建立一个独立于现有模拟器的新 Go 网关工程
- 能接收 Producer 上报
- 能让 Consumer 按设备订阅
- 能转发位置和基础 telemetry
- 能保存 latest state
- 能提供最基础的健康检查和运行入口
---
## 2. 任务拆分
### 2.1 工程骨架
- 新建 `realtime-gateway/`
- 初始化 `go.mod`
- 建立 `cmd/``internal/` 结构
- 提供最小运行 README
- 提供开发配置文件
### 2.2 网络入口
- HTTP server
- `/healthz`
- `/metrics`
- `/ws`
- 优雅关闭
### 2.3 会话管理
- 生成 `sessionId`
- 维护连接生命周期
- 记录角色和订阅
- 断线清理
### 2.4 协议处理
- `authenticate`
- `subscribe`
- `publish`
- `snapshot`
- 错误返回格式统一
### 2.5 路由与 latest state
-`deviceId` 路由
- 支持 `groupId``topic` 过滤
- latest state 内存缓存
- `snapshot` 获取最新值
### 2.6 轻鉴权
- Producer token
- Controller token
- Consumer token 或匿名策略
### 2.7 基础观测
- JSON 日志
- 连接日志
- 发布日志
- 基础 metrics 占位接口
---
## 3. 暂不进入 MVP
- 规则引擎正式实现
- 通知分发正式实现
- Recorder 正式实现
- Replayer 正式实现
- Redis
- 集群
- 数据库存档
- 高级限流
- 复杂 ACL
---
## 4. 建议开发顺序
1. 骨架与配置
2. HTTP 和 WebSocket 入口
3. 会话管理
4. 发布与订阅
5. latest state
6. 鉴权
7. 观察与日志
8. 插件总线占位
---
## 5. 完成标准
满足以下条件可视为 MVP 跑通:
- 可以启动一个独立 Go 服务
- 模拟 Producer 能发布 `telemetry.location`
- Consumer 能订阅某个 `deviceId`
- Consumer 能收到实时转发
- Consumer 可读取某个设备的 latest state
- Producer、Controller、Consumer 的角色边界基本可用
---
## 6. 下一阶段
MVP 跑通后优先做:
1. 插件总线正式化
2. Recorder 插件
3. 模拟器对接新协议
4. 简单规则插件
5. Dispatcher 插件

View File

@@ -0,0 +1,344 @@
# 实时网关协议草案
本文档描述 `realtime-gateway` 第一版协议草案,范围只覆盖 MVP。
---
## 1. 连接方式
- 协议WebSocket
- 地址:`/ws`
- 编码JSON
- 通信模式:客户端主动发消息,服务端返回状态或事件
---
## 2. 消息类型
客户端上行 `type`
- `authenticate`
- `join_channel`
- `subscribe`
- `publish`
- `snapshot`
服务端下行 `type`
- `welcome`
- `authenticated`
- `subscribed`
- `published`
- `snapshot`
- `event`
- `error`
---
## 3. 鉴权
### 3.1 authenticate
```json
{
"type": "authenticate",
"role": "producer",
"token": "dev-producer-token"
}
```
说明:
- `role` 可选值:`producer``consumer``controller`
- 第一版 `consumer` 可允许匿名,是否启用由配置控制
### 3.2 join_channel
```json
{
"type": "join_channel",
"role": "producer",
"channelId": "ch-xxxx",
"token": "producer-token"
}
```
说明:
- `channelId` 必填
- `token` 必须和 `channelId` 对应
- `role` 不同,使用的 token 也不同
- `producerToken`
- `consumerToken`
- `controllerToken`
---
## 4. 订阅
### 4.1 subscribe
```json
{
"type": "subscribe",
"subscriptions": [
{
"deviceId": "child-001",
"topic": "telemetry.location"
}
]
}
```
支持字段:
- `channelId`
- `deviceId`
- `groupId`
- `topic`
第一版匹配规则:
- 非空字段必须全部匹配
- 空字段视为不过滤
---
## 5. 发布
### 5.1 publish
```json
{
"type": "publish",
"envelope": {
"schemaVersion": 1,
"messageId": "msg-001",
"timestamp": 1711267200000,
"topic": "telemetry.location",
"source": {
"kind": "producer",
"id": "watch-001",
"mode": "real"
},
"target": {
"channelId": "ch-xxxx",
"deviceId": "child-001",
"groupId": "class-a"
},
"payload": {
"lat": 31.2304,
"lng": 121.4737,
"speed": 1.2,
"bearing": 90,
"accuracy": 6,
"coordSystem": "GCJ02"
}
}
}
```
说明:
- 只有 `producer``controller` 能发布
- 第一版不做复杂 schema 校验
- 第一版缓存键为 `channelId + deviceId`
- 如果连接已经通过 `join_channel` 加入通道,服务端会自动补全 `target.channelId`
---
## 6. 快照
### 6.1 snapshot
```json
{
"type": "snapshot",
"subscriptions": [
{
"channelId": "ch-xxxx",
"deviceId": "child-001"
}
]
}
```
服务端返回:
```json
{
"type": "snapshot",
"sessionId": "sess-2",
"state": {
"schemaVersion": 1,
"timestamp": 1711267200000,
"topic": "telemetry.location",
"source": {
"kind": "producer",
"id": "watch-001",
"mode": "real"
},
"target": {
"deviceId": "child-001"
},
"payload": {
"lat": 31.2304,
"lng": 121.4737
}
}
}
```
---
## 7. 服务端消息
### 7.1 welcome
```json
{
"type": "welcome",
"sessionId": "sess-1"
}
```
### 7.2 authenticated
```json
{
"type": "authenticated",
"sessionId": "sess-1"
}
```
### 7.3 subscribed
```json
{
"type": "subscribed",
"sessionId": "sess-1"
}
```
### 7.4 joined_channel
```json
{
"type": "joined_channel",
"sessionId": "sess-1",
"state": {
"channelId": "ch-xxxx",
"deliveryMode": "cache_latest"
}
}
```
### 7.5 published
```json
{
"type": "published",
"sessionId": "sess-1"
}
```
### 7.6 event
```json
{
"type": "event",
"envelope": {
"schemaVersion": 1,
"timestamp": 1711267200000,
"topic": "telemetry.location",
"source": {
"kind": "producer",
"id": "watch-001",
"mode": "real"
},
"target": {
"channelId": "ch-xxxx",
"deviceId": "child-001"
},
"payload": {
"lat": 31.2304,
"lng": 121.4737
}
}
}
```
### 7.7 error
```json
{
"type": "error",
"error": "authentication failed"
}
```
---
## 8. 第一版约束
- 不做历史回放协议
- 不做压缩和二进制编码
- 不做批量发布
- 不做通配符 topic
- 不做持久会话
- 不做 ACK 队列
---
## 9. 管理接口
### 9.1 创建 channel
`POST /api/channel/create`
请求:
```json
{
"label": "debug-a",
"deliveryMode": "cache_latest",
"ttlSeconds": 28800
}
```
返回:
```json
{
"snapshot": {
"id": "ch-xxxx",
"label": "debug-a",
"deliveryMode": "cache_latest"
},
"producerToken": "....",
"consumerToken": "....",
"controllerToken": "...."
}
```
### 9.2 管理台读取接口
- `GET /api/admin/overview`
- `GET /api/admin/sessions`
- `GET /api/admin/latest`
- `GET /api/admin/channels`
- `GET /api/admin/traffic`
- `GET /api/admin/live`
---
## 10. 第二阶段预留
后续协议可以增加:
- `command`
- `batch_publish`
- `rule_event`
- `plugin_status`
- `replay_control`
- `auth_refresh`

View File

@@ -0,0 +1,877 @@
# 实时设备数据网关最终方案
本文档用于收敛当前关于 GPS 模拟、中转、监控、规则判定、回放、通知分发等讨论,给出一版可直接进入实现设计的最终方案。
---
## 1. 目标与定位
本系统不再定义为“GPS 模拟器”,而定义为:
- 一个独立部署的实时设备数据网关
- 负责实时接入、标准化、路由、订阅、最新状态同步
- 默认不依赖数据库,不承担历史存储职责
- 通过插件扩展规则判定、通知分发、回放、归档等能力
一句话定义:
> 一个以实时中转为核心、以插件扩展业务能力的轻量遥测网关。
---
## 2. 设计原则
### 2.1 核心优先级
核心目标按优先级排序如下:
1. 实时性能
2. 稳定性
3. 低耦合
4. 易扩展
5. 易观测
### 2.2 核心边界
核心服务只负责:
- 长连接接入
- 消息标准化
- 路由转发
- 订阅管理
- latest state 缓存
- 连接管理
- 心跳和断线清理
- 基础鉴权
- 限流与基本防护
核心服务不负责:
- 历史存储
- 业务报表
- 复杂规则引擎
- 第三方通知发送
- 业务数据库查询
- 面向家长端或场控端的专属业务逻辑
---
## 3. 总体架构
### 3.1 拓扑关系
```text
Producer
├─ 手机客户端
├─ 外部模拟器
├─ 回放器
└─ 设备接入器
|
v
Realtime Device Gateway
├─ Connection Manager
├─ Protocol Layer
├─ Session Manager
├─ Router / Fanout
├─ Latest State Cache
└─ Plugin Bus
|
+--> Consumer
| ├─ 小程序 / App
| ├─ 家长端
| ├─ 场控端
| └─ 调试端 / 大屏
|
+--> Plugins
├─ Rule Engine
├─ Dispatcher
├─ Recorder
├─ Replayer Adapter
└─ Webhook / Bridge
Business Server
├─ 用户与设备关系
├─ 配置管理
├─ 历史归档
└─ 业务接口
```
### 3.2 在整个系统中的定位
实时网关在整个系统中,不是主业务服务器的替代品,而是一层独立的实时中枢。
职责分工建议固定为:
- 设备、模拟器、回放器
- 负责产出实时数据
- 实时网关
- 负责接入、路由、订阅、latest state、实时分发、运行态观察
- 业务服务器
- 负责用户、设备归属、配置、历史存档、业务查询
- 插件
- 负责规则、通知、归档、回放等异步能力
一句话理解:
> 主业务服务器负责“谁是谁”,实时网关负责“现在发生了什么,发给谁看”。
### 3.3 服务角色
- `Producer`
- 实时数据生产者
- 包括真实设备、模拟器、回放器
- `Consumer`
- 实时数据消费者
- 包括家长端、场控端、调试端、大屏
- `Controller`
- 可向 Producer 或 Gateway 下发控制命令
- 包括调试控制台、场控后台、回放控制器
### 3.4 角色使用流程
#### 开发模拟
1. Controller 在管理台创建 `channel`
2. 网关返回 `channelId / producerToken / consumerToken`
3. 老模拟器作为 Producer 加入 channel 并开始发 `telemetry.location / telemetry.heart_rate`
4. 管理台和调试 Consumer 实时观察数据
5. 业务服务器此时可以不参与,或仅做低频归档
#### 家长端监控
1. 真机设备作为 Producer 向网关持续上报位置和状态
2. 家长端作为 Consumer 订阅某个 `deviceId``groupId`
3. 网关负责实时分发
4. 业务服务器负责账号关系、历史数据和业务页面
#### 场控
1. 场控端作为 Consumer 订阅多个设备或一个分组
2. 网关持续推送实时位置、状态和事件
3. 如果有控制需求,场控端再以 Controller 身份下发命令
4. 规则插件可基于同一条实时流做违规判定
#### 归档
1. Producer 只向网关上报实时数据
2. Recorder 插件异步消费网关流
3. Recorder 再按 `10-30s` 批量写入业务服务器
这样可以保证:
- 实时链路和归档链路解耦
- 客户端可以逐步从双写演进到单写网关
- 实时数据和历史数据口径更一致
---
## 4. 部署原则
### 4.1 独立部署
实时网关必须独立于主业务服务器部署。
这样做的原因:
- 不占用主业务服务器的实时长连接资源
- 不让实时 fanout 干扰主业务接口
- 实时服务可独立扩容
- 便于分离故障和性能问题
### 4.2 与主业务服务的关系
主业务服务只提供控制面能力:
- 用户与设备归属关系
- 权限配置
- 策略配置
- 历史存档入口
实时网关处理数据面:
- 高频 telemetry
- 最新状态同步
- 实时下行通知和控制
原则上,不允许每条实时上报都回主业务服务查库。
---
## 5. 0 数据库方案
### 5.1 可行性
第一版核心网关可以做到 0 数据库依赖。
这里的含义是:
- 不接 MySQL
- 不接 PostgreSQL
- 不接业务持久化存储
- 不存历史轨迹
- 不做持久化 session
### 5.2 保留的内存态
即使 0 数据库,网关仍然必须保留以下运行时内存数据:
- 连接表
- 订阅关系
- 设备 latest state
- 会话元数据
- 限流计数
- 心跳状态
### 5.3 重启语义
网关重启后允许丢失:
- 当前连接
- 当前订阅
- latest state
- 临时限流计数
系统恢复方式:
- 客户端自动重连
- 重新鉴权
- 重新订阅
- Producer 继续上报最新点
这对实时网关是可接受的。
---
## 6. 性能目标
### 6.1 第一版容量目标
第一版至少满足:
- `1000-2000` 设备同时在线上报
- 默认 `1 Hz` 实时上报能力
- 支持部分调试设备升频到 `2-5 Hz`
- 端到端实时链路延迟目标 `< 500 ms`
- 插件故障不阻塞核心中转
### 6.2 带宽认知
实时中转一定消耗网关所在服务器的带宽,但不会占用主业务服务器带宽。
不做历史存储,只能减少:
- 磁盘 IO
- 存储成本
- 数据库压力
不能减少:
- 长连接压力
- 实时 fanout 压力
- 网关入口和出口带宽
### 6.3 核心优化原则
- 不允许全量广播
- 必须按设备或组定向订阅
- payload 尽量小
- latest state 只保留当前值
- 插件必须异步消费
- 高频调试源必须可限流
---
## 7. 消息模型
### 7.1 统一信封
所有进入网关的数据统一使用一个标准信封:
```json
{
"schemaVersion": 1,
"messageId": "uuid",
"timestamp": 1711267200000,
"topic": "telemetry.location",
"source": {
"kind": "producer",
"id": "watch-001",
"mode": "real"
},
"target": {
"deviceId": "child-001",
"groupId": "class-a"
},
"payload": {}
}
```
### 7.2 topic 分类
建议只保留四类顶级 topic
- `telemetry.*`
- `state.*`
- `event.*`
- `command.*`
示例:
- `telemetry.location`
- `telemetry.heart_rate`
- `telemetry.motion`
- `state.device`
- `event.rule_violation`
- `event.sos`
- `command.replay.start`
- `command.simulation.stop`
### 7.3 source mode
建议显式区分数据来源模式:
- `real`
- `mock`
- `replay`
- `control`
这样业务侧可以明确识别是真实数据还是模拟/回放数据。
---
## 8. 主要数据载荷
### 8.1 位置 telemetry
```json
{
"lat": 31.2304,
"lng": 121.4737,
"altitude": 12.3,
"speed": 1.5,
"bearing": 90,
"accuracy": 8,
"coordSystem": "GCJ02"
}
```
最少保留字段:
- `timestamp`
- `lat`
- `lng`
- `speed`
- `bearing`
- `accuracy`
- `coordSystem`
### 8.2 心率 telemetry
```json
{
"bpm": 142,
"confidence": 0.92
}
```
### 8.3 运动 telemetry
```json
{
"cadence": 168,
"stepCount": 3201,
"calories": 248.5,
"movementState": "run"
}
```
### 8.4 设备状态
```json
{
"online": true,
"battery": 76,
"network": "4g",
"charging": false
}
```
### 8.5 事件
```json
{
"eventType": "rule_violation",
"ruleId": "geo-fence-01",
"severity": "high",
"text": "leave allowed area"
}
```
### 8.6 控制命令
```json
{
"command": "simulation.start",
"args": {
"sessionId": "sim-001",
"speedRate": 2
}
}
```
---
## 9. 订阅与路由模型
### 9.1 路由单位
实时网关按以下维度路由:
- `deviceId`
- `groupId`
- `topic`
### 9.2 推荐订阅粒度
- 订阅某个设备全部实时消息
- 订阅某个设备某类消息
- 订阅某个组某类消息
示例:
- `device:child-001`
- `device:child-001/topic:telemetry.location`
- `group:class-a/topic:event.*`
### 9.3 latest state
每个设备保留一个最新状态快照,不存历史。
用途:
- 新订阅者刚连上时立即获得当前状态
- 页面重连后快速恢复
- 插件可从最新态快速计算
---
## 10. 鉴权策略
### 10.1 不建议完全不鉴权
如果完全不鉴权,会带来两个高风险问题:
- 任意客户端都能伪造位置或心率
- 任意客户端都可能下发控制命令
### 10.2 推荐分级鉴权
- `Producer`
- 必须强鉴权
- 防止伪造上报
- `Controller`
- 必须强鉴权
- 防止误操作和恶意控制
- `Consumer`
- 可轻鉴权
- 可按内网、白名单、临时 token、短期票据放宽
### 10.3 第一版推荐方式
第一版建议:
- Producer 使用签名 token 或短期接入 token
- Controller 使用管理 token
- Consumer 使用轻量订阅 token
网关本身不查数据库,只做:
- token 基本校验
- 角色识别
- 权限范围校验
复杂鉴权可以由独立鉴权服务承担。
---
## 11. 插件体系
### 11.1 原则
事件处理不是核心同步主链路的一部分,而是插件。
同步主链路只做:
`ingest -> normalize -> route -> fanout -> update latest state`
然后异步投递到插件总线:
`publish -> plugin bus -> plugin async consume`
### 11.2 第一批插件类型
- `Rule Engine`
- 实时规则判定
- `Dispatcher`
- 通知和下行分发
- `Recorder`
- 归档与历史写入
- `Replayer`
- 文件或历史流回放
- `Webhook`
- 向外部系统桥接
### 11.3 插件要求
- 不能阻塞主链路
- 失败必须隔离
- 可独立启停
- 最好支持单独部署
---
## 12. 规则判定与通知分发
### 12.1 规则不进核心
规则判定必须在插件层执行,不进入中转核心。
原因:
- 规则计算不稳定
- 容易引入复杂状态
- 容易放大 CPU 和 IO 消耗
- 不应影响实时转发
### 12.2 推荐规则类型
- 阈值规则
- 心率过高
- 速度异常
- 电量过低
- 时空规则
- 离开围栏
- 进入禁区
- 停留超时
- 偏离路线
- 行为规则
- 长时间静止
- 轨迹跳变
- 设备离线
### 12.3 动作分发
规则命中后由 Dispatcher 插件执行动作:
- 下发终端警告
- 推送给家长端
- 推送给场控端
- 上报业务服务
- 触发 Webhook
### 12.4 规则系统必须处理的问题
- 去重
- 冷却时间
- 恢复事件
- 幂等
---
## 13. 客户端上报策略
### 13.1 当前推荐的第一阶段方案
客户端保留两条链路:
- 实时链路
- 有需要时实时上报到网关
- 归档链路
-`10-30s` 打包一次发给业务服务器存档
这个方案的优点:
- 改造快
- 实时与归档解耦
- 主业务服务器不承受高频位置流
### 13.2 双写风险
客户端双写存在天然风险:
- 两边成功率不同
- 数据口径可能不一致
- 客户端逻辑更复杂
- 重试和去重更难
### 13.3 推荐演进方向
推荐第二阶段改成:
- 客户端只向网关实时上报
- `Recorder` 插件按 `10-30s` 批量归档到业务服务器
这样可以保证:
- 实时和归档数据同源
- 客户端逻辑更轻
- 后续回放、规则、告警直接复用同一条实时流
### 13.4 客户端升频策略
建议客户端支持策略切换:
- `normal`
- 低频或关闭实时上报
- `monitor`
- `1-5s` 实时上报
- `debug`
- `1 Hz`
- `alert`
- 临时升频
---
## 14. 模拟与回放
### 14.1 模拟器定位
模拟器不再是一个独立特殊系统,而是一个标准 Producer。
它可以上报:
- `telemetry.location`
- `telemetry.heart_rate`
- `telemetry.motion`
- `state.device`
### 14.2 回放器定位
回放器也是 Producer。
区别只是:
- source mode 为 `replay`
- 输入来源是文件或历史流
- 支持播放、暂停、倍速、循环
### 14.3 地图模拟器要求
地图拖点时不应直接瞬移发点,至少要支持:
- 时间戳
- 插值
- 平滑
- 速度和朝向生成
否则业务侧会看到不真实的轨迹。
---
## 15. 技术选型建议
### 15.1 技术选型原则
本系统技术选型必须遵守以下原则:
- 轻量
- 健壮
- 性能优先
- 少依赖
- 易部署
- 易排障
目标形态应更接近:
- 软路由插件
- 轻量代理
- 边缘网关
- 单二进制网络服务
而不是典型的重业务后台服务。
### 15.2 第一版推荐
核心网关建议明确采用以下技术栈:
- 服务端语言:`Go`
- 实时协议:`WebSocket`
- 管理接口:`HTTP`
- 消息格式:`JSON`
- 运行态存储:纯内存
- 配置方式:本地配置文件加环境变量
- 部署形态:单二进制
- 日志:结构化日志
推荐原因:
- 更适合长连接、低延迟、fanout 场景
- 内存和 CPU 占用更可控
- 二进制部署简单
- 依赖少,更接近基础设施服务风格
- 后续扩到更高在线数更稳
如果目标是先稳定支撑 `1000-2000` 在线连接,推荐优先考虑:
- 服务端:`Go`
- 实时协议:`WebSocket`
- 管理接口:`HTTP`
- 配置来源:本地配置文件或环境变量
- latest state进程内内存
原因:
- 长连接和 fanout 场景更贴近 Go 的强项
- 资源占用更稳
- 后续扩到更高并发成本更低
### 15.3 网关内部建议模块
为了保持“轻量但可扩展”,建议核心网关内部拆为以下模块:
- `listener`
- 管理 WebSocket 和 HTTP 入口
- `session manager`
- 管理连接、身份、心跳、会话元数据
- `router`
- 负责 topic、device、group 维度路由
- `fanout hub`
- 负责多订阅者分发
- `state cache`
- 保存设备 latest state
- `auth verifier`
- 校验 Producer、Consumer、Controller 的 token
- `rate limiter`
- 限制异常高频上报
- `plugin bus`
- 异步发布给规则、通知、归档等插件
这些模块都应保持进程内实现,避免第一版引入外部组件。
### 15.4 第一版不建议引入的依赖
第一版不建议引入:
- 数据库
- 消息队列
- 服务注册中心
- 复杂配置中心
- 脚本型规则执行器
- 重型 RPC 框架
这些能力都可以在后续容量和业务复杂度真正需要时再补。
### 15.5 可接受的替代方案
如果团队更熟悉 TypeScript也可以先用
- `Node.js + TypeScript + WebSocket`
但要注意:
- 网关层应保持非常薄
- 不要过早塞业务逻辑
- 一开始就准备好以后拆分插件
但如果核心目标明确是“像软路由一样稳定跑实时中转”,仍然优先推荐 Go。
---
## 16. MVP 范围
第一版只做以下内容:
### 16.1 核心网关
- WebSocket 接入
- Producer / Consumer / Controller 三类角色
- 统一消息信封
- 按设备订阅
- latest state 缓存
- 心跳和断线处理
- 基础限流
- 基础鉴权
### 16.2 模拟器接入
- 地图拖点
- 轨迹文件导入
- 播放 / 暂停 / 倍速 / 循环
- 输出位置和心率等标准 telemetry
### 16.3 观察端
- 订阅设备位置和状态
- 查看最新点
- 接收实时事件
### 16.4 插件最小集
- Recorder
- 可选启用
- Rule Engine
- 先做最简单几条规则
- Dispatcher
- 先支持网关内消息通知
### 16.5 明确不做
- 业务数据库耦合
- 复杂多租户
- 大报表系统
- 历史查询中心
- 集群调度系统
---
## 17. 第二阶段演进
第二阶段建议逐步增加:
- 归档从客户端双写迁移到 Recorder
- 多实例网关
- Redis 作为共享状态和实例间消息桥
- 更细粒度权限
- 完整规则库
- 回放服务独立化
- 外部通知集成
第三阶段再考虑:
- 数据库落盘
- 历史检索
- 统计分析
- 高可用和地域分布
---
## 18. 最终结论
最终方案建议如下:
- 以“实时设备数据网关”作为系统核心而不是继续围绕“GPS 模拟器”扩展
- 网关独立部署,不占主业务服务器的实时带宽和连接资源
- 第一版采用 0 数据库设计,仅保留运行时内存态
- 核心只做实时接入、标准化、路由、订阅和 latest state
- 规则判定、通知分发、回放、归档全部插件化
- 第一版先支撑 `1000-2000` 在线上报
- 当前客户端可继续采用“实时发网关 + 10-30 秒批量发业务服”的过渡方案
- 中期应演进为“客户端单写网关,归档由 Recorder 插件完成”
这套方案能同时覆盖:
- 开发模拟
- 家长端监控
- 场控
- 规则判定
- 通知分发
- 数据回放
- 后续更多传感器接入
同时又能把实时性能放在系统设计的首位。

View File

@@ -0,0 +1,443 @@
# Realtime Gateway 运行手册
本文档用于整理当前 `realtime-gateway` 的构建、运行、联调和排障方式,覆盖今天已经落地的能力。
## 1. 当前组成
当前工程由 3 部分组成:
- 新实时网关
- 路径:`D:\dev\cmr-mini\realtime-gateway`
- 老模拟器
- 路径:`D:\dev\cmr-mini\tools\mock-gps-sim`
- 文档目录
- 路径:`D:\dev\cmr-mini\doc`
当前推荐的本地开发方式:
- 老模拟器继续负责地图拖点、路径回放、心率模拟
- 新网关负责实时中转、channel 管理、实时查看、流量统计
## 1.1 中转服务器在整个系统中的用法
中转服务器在整个系统里,应当被当成“实时中枢”来使用,而不是业务主服务器的一部分。
当前建议的角色分工:
- `Producer`
- 老模拟器
- 后续真机设备
- 后续回放器
- `Consumer`
- 管理台
- CLI 调试端
- 后续家长端 / 场控端 / 手机观察端
- `Controller`
- 管理台
- 后续场控后台
- 后续回放控制器
- `Business Server`
- 用户、设备关系、配置、历史存档
系统里的基本流向应当是:
`Producer -> Gateway -> Consumer / Plugin -> Business Server`
也就是说:
- 实时数据先进入网关
- 网关负责实时观察和分发
- 业务服务器负责低频业务数据和历史
## 2. 端口约定
本地开发建议统一使用以下端口:
- 老模拟器 HTTP / WS`17865`
- 新网关 HTTP / WS`18080`
对应地址:
- 老模拟器页面:`http://127.0.0.1:17865/`
- 老模拟器本地 mock WS`ws://127.0.0.1:17865/mock-gps`
- 新网关管理台:`http://127.0.0.1:18080/`
- 新网关 WebSocket`ws://127.0.0.1:18080/ws`
## 3. 构建命令
### 3.1 构建网关
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go build -o .\bin\gateway.exe .\cmd\gateway
```
### 3.2 构建调试 CLI
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go build -o .\bin\mock-producer.exe .\cmd\mock-producer
go build -o .\bin\mock-consumer.exe .\cmd\mock-consumer
```
### 3.3 一次性编译检查
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go build ./...
```
## 4. 运行命令
### 4.1 启动新网关
开发期建议直接使用 Tunnel 开发配置:
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go run .\cmd\gateway -config .\config\tunnel-dev.json
```
如果只想本机用默认开发配置:
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go run .\cmd\gateway -config .\config\dev.json
```
### 4.2 启动老模拟器
在仓库根目录:
```powershell
cd D:\dev\cmr-mini
npm run mock-gps-sim
```
### 4.3 打开页面
- 新网关管理台:`http://127.0.0.1:18080/`
- 老模拟器页面:`http://127.0.0.1:17865/`
如果页面和实际代码不一致,先强刷一次:
```text
Ctrl + F5
```
## 5. 当前管理台能力
新网关管理台已经包含:
- 会话列表
- channel 创建与查看
- latest state 查看
- 实时数据窗口
- GPS / 心率专用格式化显示
- 轨迹预览
- 流量统计
- Published
- Dropped
- Fanout
- Topic 统计
- Channel 统计
相关接口:
- `/api/admin/overview`
- `/api/admin/sessions`
- `/api/admin/latest`
- `/api/admin/channels`
- `/api/admin/traffic`
- `/api/admin/live`
## 6. 本地联调方式
### 6.1 方式 A直接用 CLI
1. 启动网关
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go run .\cmd\gateway -config .\config\tunnel-dev.json
```
2. 启动 consumer
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go run .\cmd\mock-consumer -config .\config\consumer-gps-heart.example.json
```
3. 发 GPS
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go run .\cmd\mock-producer -device-id child-001 -topic telemetry.location -count 5
```
4. 发心率
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go run .\cmd\mock-producer -device-id child-001 -topic telemetry.heart_rate -bpm 148 -count 5
```
### 6.2 方式 B老模拟器桥接新网关
这是当前最推荐的开发联调方式。
1. 启动网关
2. 启动老模拟器
3. 在老模拟器页面里打开“新网关桥接”
4. 填入:
- 网关地址:`ws://127.0.0.1:18080/ws`
- `Producer Token / Channel Token`
- `Channel ID` 可选
- `Device ID`
- `Group ID`
- `Source ID`
- `Source Mode`
5. 点“应用桥接配置”
这样可以同时保留:
- 老 mock 小程序链路
- 新网关旁路链路
### 6.3 现在最推荐的使用方式
今天这个阶段,最推荐这样用:
1. 老模拟器作为主 Producer
2. 新网关作为实时中转和观测面板
3. 管理台负责创建 channel、查看会话、观察实时流量
4. 业务服务器暂时不接入高频实时链路
这个组合的好处是:
- 不影响你现有模拟和调试方式
- 新网关可以独立收敛协议和运行态
- 出问题时边界清晰,便于排查
## 7. channel 模式怎么用
当前网关支持两种生产者接入模式。
### 7.1 老模式
不使用 channel直接走 `authenticate`
```json
{"type":"authenticate","role":"producer","token":"dev-producer-token"}
```
适合:
- 早期本机调试
- 兼容老桥接工具
### 7.2 新模式
先创建 channel再用 `join_channel`
```json
{"type":"join_channel","role":"producer","channelId":"ch-xxxx","token":"<producerToken>"}
```
适合:
- 多人联调
- 多会话隔离
- `drop_if_no_consumer` / `cache_latest` 策略
### 7.3 管理台创建 channel
在管理台可直接创建 channel返回
- `channelId`
- `producerToken`
- `consumerToken`
- `controllerToken`
### 7.4 老模拟器接 channel
老模拟器现在已经支持:
- 不填 `Channel ID`:走老的 `authenticate`
-`Channel ID`:自动走 `join_channel`
所以如果你在管理台里创建了 channel
- `Channel ID``channelId`
- `Producer Token / Channel Token``producerToken`
两者必须配套使用。
## 8. delivery mode 说明
当前 channel 支持两种分发策略:
- `cache_latest`
- 即使没有消费者,也会保留 latest state
- `drop_if_no_consumer`
- 没有消费者就直接丢弃,不保留 latest state
适用建议:
- 开发调试或临时通道:可用 `drop_if_no_consumer`
- 常规联调和状态观察:建议 `cache_latest`
## 9. 管理台实时窗口说明
“实时数据窗口”当前支持:
-`topic` 过滤
-`channelId` 过滤
-`deviceId` 过滤
- 实时事件滚动显示
- GPS 专用摘要
- 经纬度
- 速度
- 航向
- 精度
- 心率专用摘要
- bpm
- 轨迹预览
-`channelId / deviceId` 聚合
建议使用方式:
- 如果联调设备较多,先填 `channelId`
- 如果只看单个对象,再加 `deviceId`
## 10. 流量统计说明
网关已累计以下指标:
- `Published`
- 收到的总发布数
- `Dropped`
- 因策略被丢弃的发布数
- `Fanout`
- 实际分发到消费者的总次数
同时支持:
-`topic` 统计
-`channel` 统计
这几个指标适合用来观察:
- 老模拟器是否在稳定发流
- 哪个 topic 最热
- 哪个 channel 在大量占用实时流量
- `drop_if_no_consumer` 是否正在生效
## 11. 常用命令备忘
### 11.1 启动网关
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go run .\cmd\gateway -config .\config\tunnel-dev.json
```
### 11.2 启动老模拟器
```powershell
cd D:\dev\cmr-mini
npm run mock-gps-sim
```
### 11.3 编译检查
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go build ./...
```
### 11.4 直接发 GPS
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go run .\cmd\mock-producer -device-id child-001 -topic telemetry.location -count 5
```
### 11.5 直接发心率
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go run .\cmd\mock-producer -device-id child-001 -topic telemetry.heart_rate -bpm 148 -count 5
```
### 11.6 channel 模式下的 producer
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go run .\cmd\mock-producer -channel-id ch-xxxx -token <producer-token> -topic telemetry.location -count 5
```
### 11.7 channel 模式下的 consumer
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go run .\cmd\mock-consumer -channel-id ch-xxxx -token <consumer-token> -topics telemetry.location,telemetry.heart_rate
```
## 12. 常见问题
### 12.1 老模拟器显示 `authentication failed`
通常是这两种情况:
- 你填了 `producerToken`,但没填 `channelId`
- 你填的是 channel 的 token却还在走老的 `authenticate`
处理方式:
- 如果用 channel
- 必须同时填 `Channel ID` 和对应 `producerToken`
- 如果不用 channel
- `Channel ID` 留空
- token 使用全局 `dev-producer-token`
### 12.2 模拟器还连到 8080
当前开发统一使用 `18080`
如果页面还显示旧地址:
- 重启模拟器服务
- 浏览器强刷 `Ctrl + F5`
### 12.3 管理台创建 channel 成功,但页面没显示
这通常是浏览器缓存旧前端资源。
处理方式:
- 重启网关
- 打开 `http://127.0.0.1:18080/`
- 按一次 `Ctrl + F5`
### 12.4 管理台看不到实时数据
先检查:
- 网关是否启动在 `18080`
- 老模拟器桥接是否已认证
- 管理台实时窗口是否误填了 `channelId / deviceId` 过滤
## 13. 当前建议
今天这个阶段,最稳的开发方式是:
- 老模拟器继续做主生产者
- 新网关继续做中转和观测
- 手机端暂时不接正式消费链路
- 先把网关本身的运行态、流量、实时查看能力做稳
这也是当前最省风险的组合。

149
realtime-gateway/README.md Normal file
View File

@@ -0,0 +1,149 @@
# Realtime Gateway
`realtime-gateway` 是一个独立于现有模拟器的 Go 实时设备数据网关工程。
当前目标:
- 以实时中转为核心
- 纯内存运行
- 单二进制部署
- 支持 WebSocket 接入
- 支持 channel 模型
- 支持内置管理台、实时窗口、流量统计
- 为后续规则、通知、归档、回放预留插件总线
## 文档入口
优先看这几份:
- 运行手册:[realtime-gateway-runbook.md](D:/dev/cmr-mini/doc/realtime-gateway-runbook.md)
- 架构方案:[realtime-device-gateway-architecture.md](D:/dev/cmr-mini/doc/realtime-device-gateway-architecture.md)
- 协议说明:[gateway-protocol-spec.md](D:/dev/cmr-mini/doc/gateway-protocol-spec.md)
- Tunnel 联调:[cloudflare-tunnel-dev-guide.md](D:/dev/cmr-mini/doc/cloudflare-tunnel-dev-guide.md)
- 老模拟器桥接说明:[README.md](D:/dev/cmr-mini/tools/mock-gps-sim/README.md)
## 快速开始
### 1. 启动网关
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go run .\cmd\gateway -config .\config\tunnel-dev.json
```
### 2. 打开管理台
```text
http://127.0.0.1:18080/
```
### 3. 启动老模拟器
```powershell
cd D:\dev\cmr-mini
npm run mock-gps-sim
```
### 4. 打开模拟器页面
```text
http://127.0.0.1:17865/
```
### 5. 用老模拟器桥接新网关
在“新网关桥接”区域填写:
- 网关地址:`ws://127.0.0.1:18080/ws`
- `Producer Token / Channel Token`
- `Channel ID` 可选
- `Device ID`
## 目录结构
```text
realtime-gateway/
├── cmd/gateway
├── cmd/mock-consumer
├── cmd/mock-producer
├── config
├── deploy
├── internal/channel
├── internal/config
├── internal/gateway
├── internal/model
├── internal/plugin
├── internal/router
└── internal/session
```
## 构建
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go build -o .\bin\gateway.exe .\cmd\gateway
go build -o .\bin\mock-producer.exe .\cmd\mock-producer
go build -o .\bin\mock-consumer.exe .\cmd\mock-consumer
```
或直接做一次完整编译检查:
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go build ./...
```
## 默认地址
推荐开发配置:
- 网关 HTTP / Admin`http://127.0.0.1:18080/`
- 网关 WebSocket`ws://127.0.0.1:18080/ws`
- 模拟器页面:`http://127.0.0.1:17865/`
- 模拟器 mock WS`ws://127.0.0.1:17865/mock-gps`
## CLI 调试
### producer
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go run .\cmd\mock-producer -device-id child-001 -topic telemetry.location -count 5
go run .\cmd\mock-producer -device-id child-001 -topic telemetry.heart_rate -bpm 148 -count 5
```
### consumer
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go run .\cmd\mock-consumer -config .\config\consumer-gps-heart.example.json
```
### channel 模式
```powershell
cd D:\dev\cmr-mini\realtime-gateway
go run .\cmd\mock-producer -channel-id ch-xxxx -token <producer-token> -topic telemetry.location -count 5
go run .\cmd\mock-consumer -channel-id ch-xxxx -token <consumer-token> -topics telemetry.location,telemetry.heart_rate
```
## 当前已落地能力
- WebSocket 接入
- `authenticate`
- `join_channel`
- `subscribe`
- `publish`
- `snapshot`
- latest state 缓存
- channel 管理
- `cache_latest`
- `drop_if_no_consumer`
- 管理台
- 实时数据窗口
- GPS 轨迹预览
- 流量统计
更多运行和排障细节,直接看:
- [realtime-gateway-runbook.md](D:/dev/cmr-mini/doc/realtime-gateway-runbook.md)

View File

@@ -0,0 +1,48 @@
package main
import (
"context"
"flag"
"os"
"os/signal"
"syscall"
"realtime-gateway/internal/config"
"realtime-gateway/internal/gateway"
"realtime-gateway/internal/logging"
)
func main() {
configPath := flag.String("config", "./config/dev.json", "path to config file")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
panic(err)
}
logger := logging.New()
app, err := gateway.NewServer(cfg, logger)
if err != nil {
logger.Error("failed to create server", "error", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
errCh <- app.Run(ctx)
}()
select {
case <-ctx.Done():
logger.Info("shutdown signal received")
case err := <-errCh:
if err != nil {
logger.Error("server stopped with error", "error", err)
os.Exit(1)
}
}
}

View File

@@ -0,0 +1,354 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"realtime-gateway/internal/model"
)
type consumerConfig struct {
URL string `json:"url"`
Token string `json:"token"`
ChannelID string `json:"channelId"`
DeviceID string `json:"deviceId"`
GroupID string `json:"groupId"`
Topic string `json:"topic"`
Topics []string `json:"topics"`
Subscriptions []model.Subscription `json:"subscriptions"`
Snapshot *bool `json:"snapshot"`
}
func main() {
configPath := findConfigPath(os.Args[1:])
fileConfig, err := loadConsumerConfig(configPath)
if err != nil {
log.Fatalf("load config: %v", err)
}
flag.StringVar(&configPath, "config", configPath, "path to consumer config file")
url := flag.String("url", valueOr(fileConfig.URL, "ws://127.0.0.1:8080/ws"), "gateway websocket url")
token := flag.String("token", fileConfig.Token, "consumer token, leave empty if anonymous consumers are allowed")
channelID := flag.String("channel-id", fileConfig.ChannelID, "channel id to join before subscribe")
deviceID := flag.String("device-id", valueOr(fileConfig.DeviceID, "child-001"), "device id to subscribe")
groupID := flag.String("group-id", fileConfig.GroupID, "group id to subscribe")
topic := flag.String("topic", valueOr(fileConfig.Topic, "telemetry.location"), "single topic to subscribe")
topics := flag.String("topics", strings.Join(fileConfig.Topics, ","), "comma-separated topics to subscribe, overrides -topic when set")
snapshot := flag.Bool("snapshot", boolValue(fileConfig.Snapshot, true), "request latest snapshot after subscribe")
flag.Parse()
subscriptions := resolveSubscriptions(fileConfig, *deviceID, *groupID, *topic, *topics)
if len(subscriptions) == 0 {
log.Fatalf("no subscriptions configured")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
conn, _, err := websocket.Dial(ctx, *url, nil)
if err != nil {
log.Fatalf("dial gateway: %v", err)
}
defer conn.Close(websocket.StatusNormalClosure, "consumer closed")
var welcome model.ServerMessage
if err := wsjson.Read(ctx, conn, &welcome); err != nil {
log.Fatalf("read welcome: %v", err)
}
log.Printf("connected: session=%s", welcome.SessionID)
if *token != "" || *channelID != "" {
authReq := model.ClientMessage{
Type: "authenticate",
Role: model.RoleConsumer,
Token: *token,
}
if *channelID != "" {
authReq.Type = "join_channel"
authReq.ChannelID = *channelID
}
if err := wsjson.Write(ctx, conn, authReq); err != nil {
log.Fatalf("send authenticate: %v", err)
}
var authResp model.ServerMessage
if err := wsjson.Read(ctx, conn, &authResp); err != nil {
log.Fatalf("read authenticate response: %v", err)
}
if authResp.Type == "error" {
log.Fatalf("authenticate failed: %s", authResp.Error)
}
log.Printf("authenticated: session=%s", authResp.SessionID)
}
subReq := model.ClientMessage{
Type: "subscribe",
Subscriptions: subscriptions,
}
if err := wsjson.Write(ctx, conn, subReq); err != nil {
log.Fatalf("send subscribe: %v", err)
}
var subResp model.ServerMessage
if err := wsjson.Read(ctx, conn, &subResp); err != nil {
log.Fatalf("read subscribe response: %v", err)
}
if subResp.Type == "error" {
log.Fatalf("subscribe failed: %s", subResp.Error)
}
log.Printf("subscribed: %s", describeSubscriptions(subscriptions))
if *snapshot {
req := model.ClientMessage{
Type: "snapshot",
Subscriptions: []model.Subscription{
{DeviceID: firstSnapshotDeviceID(subscriptions, *deviceID)},
},
}
if err := wsjson.Write(ctx, conn, req); err != nil {
log.Fatalf("send snapshot: %v", err)
}
var resp model.ServerMessage
if err := wsjson.Read(ctx, conn, &resp); err != nil {
log.Fatalf("read snapshot response: %v", err)
}
if resp.Type == "snapshot" {
log.Printf("snapshot: %s", compactJSON(resp.State))
} else if resp.Type == "error" {
log.Printf("snapshot unavailable: %s", resp.Error)
}
}
for {
var message model.ServerMessage
if err := wsjson.Read(ctx, conn, &message); err != nil {
log.Fatalf("read event: %v", err)
}
switch message.Type {
case "event":
log.Printf("event: %s", describeEnvelope(message.Envelope))
case "error":
log.Printf("server error: %s", message.Error)
default:
log.Printf("message: type=%s", message.Type)
}
}
}
func loadConsumerConfig(path string) (consumerConfig, error) {
if strings.TrimSpace(path) == "" {
return consumerConfig{}, nil
}
data, err := os.ReadFile(path)
if err != nil {
return consumerConfig{}, fmt.Errorf("read %s: %w", path, err)
}
var cfg consumerConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return consumerConfig{}, fmt.Errorf("parse %s: %w", path, err)
}
return cfg, nil
}
func findConfigPath(args []string) string {
for index := 0; index < len(args); index++ {
arg := args[index]
if strings.HasPrefix(arg, "-config=") {
return strings.TrimSpace(strings.TrimPrefix(arg, "-config="))
}
if arg == "-config" && index+1 < len(args) {
return strings.TrimSpace(args[index+1])
}
if strings.HasPrefix(arg, "--config=") {
return strings.TrimSpace(strings.TrimPrefix(arg, "--config="))
}
if arg == "--config" && index+1 < len(args) {
return strings.TrimSpace(args[index+1])
}
}
return ""
}
func valueOr(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func boolValue(value *bool, fallback bool) bool {
if value == nil {
return fallback
}
return *value
}
func resolveSubscriptions(fileConfig consumerConfig, deviceID string, groupID string, topic string, topicsCSV string) []model.Subscription {
if len(fileConfig.Subscriptions) > 0 && strings.TrimSpace(topicsCSV) == "" && strings.TrimSpace(topic) == valueOr(fileConfig.Topic, "telemetry.location") {
return filterValidSubscriptions(fileConfig.Subscriptions)
}
topics := parseTopics(topicsCSV)
if len(topics) == 0 {
topics = []string{topic}
}
subscriptions := make([]model.Subscription, 0, len(topics))
for _, entry := range topics {
subscriptions = append(subscriptions, model.Subscription{
DeviceID: deviceID,
GroupID: groupID,
Topic: entry,
})
}
return filterValidSubscriptions(subscriptions)
}
func parseTopics(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.Split(value, ",")
topics := make([]string, 0, len(parts))
for _, part := range parts {
topic := strings.TrimSpace(part)
if topic == "" {
continue
}
topics = append(topics, topic)
}
return topics
}
func filterValidSubscriptions(items []model.Subscription) []model.Subscription {
subscriptions := make([]model.Subscription, 0, len(items))
for _, item := range items {
if strings.TrimSpace(item.ChannelID) == "" && strings.TrimSpace(item.DeviceID) == "" && strings.TrimSpace(item.GroupID) == "" {
continue
}
subscriptions = append(subscriptions, model.Subscription{
ChannelID: strings.TrimSpace(item.ChannelID),
DeviceID: strings.TrimSpace(item.DeviceID),
GroupID: strings.TrimSpace(item.GroupID),
Topic: strings.TrimSpace(item.Topic),
})
}
return subscriptions
}
func describeSubscriptions(items []model.Subscription) string {
parts := make([]string, 0, len(items))
for _, item := range items {
scope := item.DeviceID
if scope == "" {
if item.GroupID != "" {
scope = "group:" + item.GroupID
} else {
scope = "all"
}
}
if item.ChannelID != "" {
scope = "channel:" + item.ChannelID + " / " + scope
}
if item.Topic != "" {
scope += "/" + item.Topic
}
parts = append(parts, scope)
}
return strings.Join(parts, ", ")
}
func firstSnapshotDeviceID(items []model.Subscription, fallback string) string {
for _, item := range items {
if strings.TrimSpace(item.DeviceID) != "" {
return item.DeviceID
}
}
return fallback
}
func describeEnvelope(envelope *model.Envelope) string {
if envelope == nil {
return "{}"
}
switch envelope.Topic {
case "telemetry.location":
return describeLocationEnvelope(envelope)
case "telemetry.heart_rate":
return describeHeartRateEnvelope(envelope)
default:
return compactEnvelope(envelope)
}
}
func describeLocationEnvelope(envelope *model.Envelope) string {
var payload struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Speed float64 `json:"speed"`
Bearing float64 `json:"bearing"`
Accuracy float64 `json:"accuracy"`
CoordSystem string `json:"coordSystem"`
}
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
return compactEnvelope(envelope)
}
return fmt.Sprintf(
"gps device=%s lat=%.6f lng=%.6f speed=%.2f bearing=%.1f accuracy=%.1f coord=%s",
envelope.Target.DeviceID,
payload.Lat,
payload.Lng,
payload.Speed,
payload.Bearing,
payload.Accuracy,
payload.CoordSystem,
)
}
func describeHeartRateEnvelope(envelope *model.Envelope) string {
var payload struct {
BPM int `json:"bpm"`
Confidence float64 `json:"confidence"`
}
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
return compactEnvelope(envelope)
}
if payload.Confidence > 0 {
return fmt.Sprintf("heart_rate device=%s bpm=%d confidence=%.2f", envelope.Target.DeviceID, payload.BPM, payload.Confidence)
}
return fmt.Sprintf("heart_rate device=%s bpm=%d", envelope.Target.DeviceID, payload.BPM)
}
func compactEnvelope(envelope *model.Envelope) string {
if envelope == nil {
return "{}"
}
data, err := json.Marshal(envelope)
if err != nil {
return "{}"
}
return compactJSON(data)
}
func compactJSON(data []byte) string {
var value any
if err := json.Unmarshal(data, &value); err != nil {
return string(data)
}
compact, err := json.Marshal(value)
if err != nil {
return string(data)
}
return string(compact)
}

View File

@@ -0,0 +1,190 @@
package main
import (
"context"
"flag"
"log"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"realtime-gateway/internal/model"
)
func main() {
url := flag.String("url", "ws://127.0.0.1:8080/ws", "gateway websocket url")
token := flag.String("token", "dev-producer-token", "producer token")
channelID := flag.String("channel-id", "", "channel id to join before publish")
deviceID := flag.String("device-id", "child-001", "target device id")
groupID := flag.String("group-id", "", "target group id")
sourceID := flag.String("source-id", "mock-producer-001", "source id")
mode := flag.String("mode", "mock", "source mode")
topic := flag.String("topic", "telemetry.location", "publish topic")
lat := flag.Float64("lat", 31.2304, "start latitude")
lng := flag.Float64("lng", 121.4737, "start longitude")
speed := flag.Float64("speed", 1.2, "speed value")
accuracy := flag.Float64("accuracy", 6, "accuracy value")
bpm := flag.Int("bpm", 120, "heart rate value when topic is telemetry.heart_rate")
confidence := flag.Float64("confidence", 0, "heart rate confidence when topic is telemetry.heart_rate")
interval := flag.Duration("interval", time.Second, "publish interval")
count := flag.Int("count", 0, "number of messages to send, 0 means unlimited")
flag.Parse()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
conn, _, err := websocket.Dial(ctx, *url, nil)
if err != nil {
log.Fatalf("dial gateway: %v", err)
}
defer conn.Close(websocket.StatusNormalClosure, "producer closed")
var welcome model.ServerMessage
if err := wsjson.Read(ctx, conn, &welcome); err != nil {
log.Fatalf("read welcome: %v", err)
}
log.Printf("connected: session=%s", welcome.SessionID)
authReq := model.ClientMessage{
Type: "authenticate",
Role: model.RoleProducer,
Token: *token,
}
if *channelID != "" {
authReq.Type = "join_channel"
authReq.ChannelID = *channelID
}
if err := wsjson.Write(ctx, conn, authReq); err != nil {
log.Fatalf("send authenticate: %v", err)
}
var authResp model.ServerMessage
if err := wsjson.Read(ctx, conn, &authResp); err != nil {
log.Fatalf("read authenticate response: %v", err)
}
if authResp.Type == "error" {
log.Fatalf("authenticate failed: %s", authResp.Error)
}
log.Printf("authenticated: session=%s", authResp.SessionID)
ticker := time.NewTicker(*interval)
defer ticker.Stop()
sent := 0
for {
select {
case <-ctx.Done():
log.Println("producer stopping")
return
case <-ticker.C:
envelope := model.Envelope{
SchemaVersion: 1,
MessageID: messageID(sent + 1),
Timestamp: time.Now().UnixMilli(),
Topic: *topic,
Source: model.Source{
Kind: model.RoleProducer,
ID: *sourceID,
Mode: *mode,
},
Target: model.Target{
DeviceID: *deviceID,
GroupID: *groupID,
},
Payload: payloadForTopic(*topic, *lat, *lng, *speed, *accuracy, *bpm, *confidence, sent),
}
req := model.ClientMessage{
Type: "publish",
Envelope: &envelope,
}
if err := wsjson.Write(ctx, conn, req); err != nil {
log.Fatalf("publish failed: %v", err)
}
var resp model.ServerMessage
if err := wsjson.Read(ctx, conn, &resp); err != nil {
log.Fatalf("read publish response: %v", err)
}
if resp.Type == "error" {
log.Fatalf("publish rejected: %s", resp.Error)
}
sent++
log.Printf("published #%d %s", sent, describePublished(*topic, *deviceID, *lat, *lng, *speed, *accuracy, *bpm, *confidence, sent))
if *count > 0 && sent >= *count {
log.Println("producer completed")
return
}
}
}
}
func payloadForTopic(topic string, baseLat, baseLng, speed, accuracy float64, bpm int, confidence float64, step int) []byte {
if topic == "telemetry.heart_rate" {
return heartRatePayload(bpm, confidence)
}
return locationPayload(baseLat, baseLng, speed, accuracy, step)
}
func locationPayload(baseLat, baseLng, speed, accuracy float64, step int) []byte {
lat := currentLat(baseLat, step)
lng := currentLng(baseLng, step)
return []byte(
`{"lat":` + formatFloat(lat) +
`,"lng":` + formatFloat(lng) +
`,"speed":` + formatFloat(speed) +
`,"bearing":90` +
`,"accuracy":` + formatFloat(accuracy) +
`,"coordSystem":"GCJ02"}`,
)
}
func heartRatePayload(bpm int, confidence float64) []byte {
if confidence > 0 {
return []byte(
`{"bpm":` + strconv.Itoa(maxInt(1, bpm)) +
`,"confidence":` + formatFloat(confidence) +
`}`,
)
}
return []byte(`{"bpm":` + strconv.Itoa(maxInt(1, bpm)) + `}`)
}
func currentLat(base float64, step int) float64 {
return base + float64(step)*0.00002
}
func currentLng(base float64, step int) float64 {
return base + float64(step)*0.00003
}
func messageID(step int) string {
return "msg-" + strconv.Itoa(step)
}
func formatFloat(v float64) string {
return strconv.FormatFloat(v, 'f', 6, 64)
}
func describePublished(topic string, deviceID string, lat, lng, speed, accuracy float64, bpm int, confidence float64, step int) string {
if topic == "telemetry.heart_rate" {
if confidence > 0 {
return "device=" + deviceID + " topic=" + topic + " bpm=" + strconv.Itoa(maxInt(1, bpm)) + " confidence=" + formatFloat(confidence)
}
return "device=" + deviceID + " topic=" + topic + " bpm=" + strconv.Itoa(maxInt(1, bpm))
}
return "device=" + deviceID + " topic=" + topic + " lat=" + formatFloat(currentLat(lat, step)) + " lng=" + formatFloat(currentLng(lng, step)) + " speed=" + formatFloat(speed) + " accuracy=" + formatFloat(accuracy)
}
func maxInt(left int, right int) int {
if left > right {
return left
}
return right
}

View File

@@ -0,0 +1,15 @@
{
"url": "ws://127.0.0.1:18080/ws",
"token": "replace-with-dev-consumer-token",
"snapshot": true,
"subscriptions": [
{
"deviceId": "child-001",
"topic": "telemetry.location"
},
{
"deviceId": "child-001",
"topic": "telemetry.heart_rate"
}
]
}

View File

@@ -0,0 +1,10 @@
{
"url": "wss://your-tunnel-host.example.com/ws",
"token": "replace-with-dev-consumer-token",
"topics": [
"telemetry.location",
"telemetry.heart_rate"
],
"deviceId": "child-001",
"snapshot": true
}

View File

@@ -0,0 +1,28 @@
{
"server": {
"httpListen": ":8080",
"readTimeoutSeconds": 15,
"writeTimeoutSeconds": 15,
"idleTimeoutSeconds": 60,
"shutdownTimeoutSeconds": 10
},
"gateway": {
"maxPayloadBytes": 65536,
"writeWaitSeconds": 10,
"pongWaitSeconds": 60,
"pingIntervalSeconds": 25,
"maxLatestStateEntries": 10000
},
"auth": {
"producerTokens": [
"dev-producer-token"
],
"consumerTokens": [
"dev-consumer-token"
],
"controllerTokens": [
"dev-controller-token"
],
"allowAnonymousConsumers": true
}
}

View File

@@ -0,0 +1,28 @@
{
"server": {
"httpListen": ":18080",
"readTimeoutSeconds": 15,
"writeTimeoutSeconds": 15,
"idleTimeoutSeconds": 60,
"shutdownTimeoutSeconds": 10
},
"gateway": {
"maxPayloadBytes": 65536,
"writeWaitSeconds": 10,
"pongWaitSeconds": 60,
"pingIntervalSeconds": 25,
"maxLatestStateEntries": 10000
},
"auth": {
"producerTokens": [
"replace-with-dev-producer-token"
],
"consumerTokens": [
"replace-with-dev-consumer-token"
],
"controllerTokens": [
"replace-with-dev-controller-token"
],
"allowAnonymousConsumers": false
}
}

View File

@@ -0,0 +1,7 @@
tunnel: YOUR_TUNNEL_ID
credentials-file: C:\Users\YOUR_USER\.cloudflared\YOUR_TUNNEL_ID.json
ingress:
- hostname: gateway-dev.example.com
service: http://localhost:18080
- service: http_status:404

5
realtime-gateway/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module realtime-gateway
go 1.25.1
require github.com/coder/websocket v1.8.14

2
realtime-gateway/go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=

View File

@@ -0,0 +1,262 @@
package channel
import (
"crypto/rand"
"encoding/hex"
"errors"
"sort"
"strconv"
"sync"
"time"
"realtime-gateway/internal/model"
)
const (
DeliveryModeCacheLatest = "cache_latest"
DeliveryModeDropIfNoConsumer = "drop_if_no_consumer"
)
var (
ErrChannelNotFound = errors.New("channel not found")
ErrChannelExpired = errors.New("channel expired")
ErrChannelUnauthorized = errors.New("channel token invalid")
ErrInvalidRole = errors.New("invalid channel role")
)
type CreateRequest struct {
Label string
DeliveryMode string
TTLSeconds int
}
type Snapshot struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
DeliveryMode string `json:"deliveryMode"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
ActiveProducers int `json:"activeProducers"`
ActiveConsumers int `json:"activeConsumers"`
ActiveControllers int `json:"activeControllers"`
}
type CreateResponse struct {
Snapshot Snapshot `json:"snapshot"`
ProducerToken string `json:"producerToken"`
ConsumerToken string `json:"consumerToken"`
ControllerToken string `json:"controllerToken"`
}
type Manager struct {
mu sync.RWMutex
defaultTTL time.Duration
channels map[string]*channelState
}
type channelState struct {
id string
label string
deliveryMode string
createdAt time.Time
expiresAt time.Time
producerToken string
consumerToken string
controllerToken string
activeProducers int
activeConsumers int
activeControllers int
}
func NewManager(defaultTTL time.Duration) *Manager {
if defaultTTL <= 0 {
defaultTTL = 8 * time.Hour
}
return &Manager{
defaultTTL: defaultTTL,
channels: make(map[string]*channelState),
}
}
func (m *Manager) Create(request CreateRequest) (CreateResponse, error) {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
deliveryMode := normalizeDeliveryMode(request.DeliveryMode)
ttl := m.defaultTTL
if request.TTLSeconds > 0 {
ttl = time.Duration(request.TTLSeconds) * time.Second
}
state := &channelState{
id: "ch-" + randomHex(6),
label: request.Label,
deliveryMode: deliveryMode,
createdAt: now,
expiresAt: now.Add(ttl),
producerToken: randomHex(16),
consumerToken: randomHex(16),
controllerToken: randomHex(16),
}
m.channels[state.id] = state
return CreateResponse{
Snapshot: snapshotOf(state),
ProducerToken: state.producerToken,
ConsumerToken: state.consumerToken,
ControllerToken: state.controllerToken,
}, nil
}
func (m *Manager) Join(channelID string, token string, role model.Role) (Snapshot, error) {
m.mu.RLock()
defer m.mu.RUnlock()
state, ok := m.channels[channelID]
if !ok {
return Snapshot{}, ErrChannelNotFound
}
if state.expiresAt.Before(time.Now()) {
return Snapshot{}, ErrChannelExpired
}
if !authorizeToken(state, role, token) {
return Snapshot{}, ErrChannelUnauthorized
}
return snapshotOf(state), nil
}
func (m *Manager) Bind(channelID string, role model.Role) error {
m.mu.Lock()
defer m.mu.Unlock()
state, ok := m.channels[channelID]
if !ok {
return ErrChannelNotFound
}
if state.expiresAt.Before(time.Now()) {
return ErrChannelExpired
}
switch role {
case model.RoleProducer:
state.activeProducers++
case model.RoleConsumer:
state.activeConsumers++
case model.RoleController:
state.activeControllers++
default:
return ErrInvalidRole
}
return nil
}
func (m *Manager) Unbind(channelID string, role model.Role) {
m.mu.Lock()
defer m.mu.Unlock()
state, ok := m.channels[channelID]
if !ok {
return
}
switch role {
case model.RoleProducer:
if state.activeProducers > 0 {
state.activeProducers--
}
case model.RoleConsumer:
if state.activeConsumers > 0 {
state.activeConsumers--
}
case model.RoleController:
if state.activeControllers > 0 {
state.activeControllers--
}
}
}
func (m *Manager) DeliveryMode(channelID string) string {
m.mu.RLock()
defer m.mu.RUnlock()
state, ok := m.channels[channelID]
if !ok {
return DeliveryModeCacheLatest
}
return state.deliveryMode
}
func (m *Manager) HasConsumers(channelID string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
state, ok := m.channels[channelID]
if !ok {
return false
}
return state.activeConsumers > 0
}
func (m *Manager) List() []Snapshot {
m.mu.RLock()
defer m.mu.RUnlock()
now := time.Now()
items := make([]Snapshot, 0, len(m.channels))
for _, state := range m.channels {
if state.expiresAt.Before(now) {
continue
}
items = append(items, snapshotOf(state))
}
sort.Slice(items, func(i int, j int) bool {
return items[i].CreatedAt.After(items[j].CreatedAt)
})
return items
}
func normalizeDeliveryMode(value string) string {
switch value {
case DeliveryModeDropIfNoConsumer:
return DeliveryModeDropIfNoConsumer
default:
return DeliveryModeCacheLatest
}
}
func authorizeToken(state *channelState, role model.Role, token string) bool {
switch role {
case model.RoleProducer:
return state.producerToken == token
case model.RoleConsumer:
return state.consumerToken == token
case model.RoleController:
return state.controllerToken == token
default:
return false
}
}
func snapshotOf(state *channelState) Snapshot {
return Snapshot{
ID: state.id,
Label: state.label,
DeliveryMode: state.deliveryMode,
CreatedAt: state.createdAt,
ExpiresAt: state.expiresAt,
ActiveProducers: state.activeProducers,
ActiveConsumers: state.activeConsumers,
ActiveControllers: state.activeControllers,
}
}
func randomHex(size int) string {
if size <= 0 {
size = 8
}
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 16)
}
return hex.EncodeToString(buf)
}

View File

@@ -0,0 +1,123 @@
package config
import (
"encoding/json"
"fmt"
"os"
"time"
)
type Config struct {
Server ServerConfig `json:"server"`
Gateway GatewayConfig `json:"gateway"`
Auth AuthConfig `json:"auth"`
}
type ServerConfig struct {
HTTPListen string `json:"httpListen"`
ReadTimeoutSeconds int `json:"readTimeoutSeconds"`
WriteTimeoutSeconds int `json:"writeTimeoutSeconds"`
IdleTimeoutSeconds int `json:"idleTimeoutSeconds"`
ShutdownTimeoutSeconds int `json:"shutdownTimeoutSeconds"`
}
type GatewayConfig struct {
MaxPayloadBytes int `json:"maxPayloadBytes"`
WriteWaitSeconds int `json:"writeWaitSeconds"`
PongWaitSeconds int `json:"pongWaitSeconds"`
PingIntervalSeconds int `json:"pingIntervalSeconds"`
MaxLatestStateEntries int `json:"maxLatestStateEntries"`
}
type AuthConfig struct {
ProducerTokens []string `json:"producerTokens"`
ConsumerTokens []string `json:"consumerTokens"`
ControllerTokens []string `json:"controllerTokens"`
AllowAnonymousConsumers bool `json:"allowAnonymousConsumers"`
}
func Load(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("read config: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("parse config: %w", err)
}
cfg.applyDefaults()
if err := cfg.validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func (c *Config) applyDefaults() {
if c.Server.HTTPListen == "" {
c.Server.HTTPListen = ":8080"
}
if c.Server.ReadTimeoutSeconds <= 0 {
c.Server.ReadTimeoutSeconds = 15
}
if c.Server.WriteTimeoutSeconds <= 0 {
c.Server.WriteTimeoutSeconds = 15
}
if c.Server.IdleTimeoutSeconds <= 0 {
c.Server.IdleTimeoutSeconds = 60
}
if c.Server.ShutdownTimeoutSeconds <= 0 {
c.Server.ShutdownTimeoutSeconds = 10
}
if c.Gateway.MaxPayloadBytes <= 0 {
c.Gateway.MaxPayloadBytes = 64 * 1024
}
if c.Gateway.WriteWaitSeconds <= 0 {
c.Gateway.WriteWaitSeconds = 10
}
if c.Gateway.PongWaitSeconds <= 0 {
c.Gateway.PongWaitSeconds = 60
}
if c.Gateway.PingIntervalSeconds <= 0 {
c.Gateway.PingIntervalSeconds = 25
}
if c.Gateway.MaxLatestStateEntries <= 0 {
c.Gateway.MaxLatestStateEntries = 10000
}
}
func (c Config) validate() error {
if c.Gateway.PingInterval() >= c.Gateway.PongWait() {
return fmt.Errorf("gateway.pingIntervalSeconds must be smaller than gateway.pongWaitSeconds")
}
return nil
}
func (c ServerConfig) ReadTimeout() time.Duration {
return time.Duration(c.ReadTimeoutSeconds) * time.Second
}
func (c ServerConfig) WriteTimeout() time.Duration {
return time.Duration(c.WriteTimeoutSeconds) * time.Second
}
func (c ServerConfig) IdleTimeout() time.Duration {
return time.Duration(c.IdleTimeoutSeconds) * time.Second
}
func (c ServerConfig) ShutdownTimeout() time.Duration {
return time.Duration(c.ShutdownTimeoutSeconds) * time.Second
}
func (c GatewayConfig) WriteWait() time.Duration {
return time.Duration(c.WriteWaitSeconds) * time.Second
}
func (c GatewayConfig) PongWait() time.Duration {
return time.Duration(c.PongWaitSeconds) * time.Second
}
func (c GatewayConfig) PingInterval() time.Duration {
return time.Duration(c.PingIntervalSeconds) * time.Second
}

View File

@@ -0,0 +1,228 @@
package gateway
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"sort"
"strings"
"time"
"realtime-gateway/internal/model"
)
//go:embed adminui/*
var adminUIFiles embed.FS
type adminOverview struct {
Status string `json:"status"`
StartedAt time.Time `json:"startedAt"`
Now time.Time `json:"now"`
UptimeSeconds int64 `json:"uptimeSeconds"`
HTTPListen string `json:"httpListen"`
Anonymous bool `json:"anonymousConsumers"`
Metrics map[string]any `json:"metrics"`
Auth map[string]any `json:"auth"`
Endpoints map[string]any `json:"endpoints"`
}
func (s *Server) registerAdminRoutes(mux *http.ServeMux) error {
sub, err := fs.Sub(adminUIFiles, "adminui")
if err != nil {
return err
}
fileServer := http.FileServer(http.FS(sub))
mux.Handle("/assets/", http.StripPrefix("/assets/", noStoreHandler(fileServer)))
mux.HandleFunc("/", s.handleAdminIndex)
mux.HandleFunc("/admin", s.handleAdminIndex)
mux.HandleFunc("/api/admin/overview", s.handleAdminOverview)
mux.HandleFunc("/api/admin/sessions", s.handleAdminSessions)
mux.HandleFunc("/api/admin/latest", s.handleAdminLatest)
mux.HandleFunc("/api/admin/traffic", s.handleAdminTraffic)
mux.HandleFunc("/api/admin/live", s.handleAdminLive)
return nil
}
func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && r.URL.Path != "/admin" {
http.NotFound(w, r)
return
}
data, err := adminUIFiles.ReadFile("adminui/index.html")
if err != nil {
http.Error(w, "admin ui unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(data)
}
func noStoreHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
func (s *Server) handleAdminOverview(w http.ResponseWriter, _ *http.Request) {
subscriberCount, latestStateCount := s.hub.Stats()
traffic := s.hub.TrafficSnapshot()
now := time.Now()
writeJSON(w, http.StatusOK, adminOverview{
Status: "ok",
StartedAt: s.startedAt,
Now: now,
UptimeSeconds: int64(now.Sub(s.startedAt).Seconds()),
HTTPListen: s.cfg.Server.HTTPListen,
Anonymous: s.cfg.Auth.AllowAnonymousConsumers,
Metrics: map[string]any{
"sessions": s.sessions.Count(),
"subscribers": subscriberCount,
"latestState": latestStateCount,
"channels": len(s.channels.List()),
"pluginHandlers": s.plugins.HandlerCount(),
"published": traffic.Published,
"dropped": traffic.Dropped,
"fanout": traffic.Fanout,
},
Auth: map[string]any{
"producerTokens": len(s.cfg.Auth.ProducerTokens),
"consumerTokens": len(s.cfg.Auth.ConsumerTokens),
"controllerTokens": len(s.cfg.Auth.ControllerTokens),
},
Endpoints: map[string]any{
"websocket": "/ws",
"health": "/healthz",
"metrics": "/metrics",
"createChannel": "/api/channel/create",
"channels": "/api/admin/channels",
"traffic": "/api/admin/traffic",
"admin": "/admin",
},
})
}
func (s *Server) handleAdminSessions(w http.ResponseWriter, _ *http.Request) {
snapshots := s.sessions.List()
sort.Slice(snapshots, func(i int, j int) bool {
return snapshots[i].CreatedAt.After(snapshots[j].CreatedAt)
})
writeJSON(w, http.StatusOK, map[string]any{
"items": snapshots,
"count": len(snapshots),
})
}
func (s *Server) handleAdminLatest(w http.ResponseWriter, r *http.Request) {
envelopes := s.hub.LatestStates()
sort.Slice(envelopes, func(i int, j int) bool {
return envelopes[i].Timestamp > envelopes[j].Timestamp
})
query := strings.TrimSpace(r.URL.Query().Get("topic"))
if query != "" {
filtered := make([]any, 0, len(envelopes))
for _, envelope := range envelopes {
if envelope.Topic != query {
continue
}
filtered = append(filtered, adminLatestItem(envelope))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": filtered,
"count": len(filtered),
})
return
}
items := make([]any, 0, len(envelopes))
for _, envelope := range envelopes {
items = append(items, adminLatestItem(envelope))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"count": len(items),
})
}
func (s *Server) handleAdminLive(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
topic := strings.TrimSpace(r.URL.Query().Get("topic"))
channelID := strings.TrimSpace(r.URL.Query().Get("channelId"))
deviceID := strings.TrimSpace(r.URL.Query().Get("deviceId"))
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
id, stream := s.hub.SubscribeLive(64)
defer s.hub.UnsubscribeLive(id)
fmt.Fprint(w, ": live stream ready\n\n")
flusher.Flush()
ping := time.NewTicker(15 * time.Second)
defer ping.Stop()
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case <-ping.C:
fmt.Fprint(w, ": ping\n\n")
flusher.Flush()
case envelope, ok := <-stream:
if !ok {
return
}
if topic != "" && envelope.Topic != topic {
continue
}
if channelID != "" && envelope.Target.ChannelID != channelID {
continue
}
if deviceID != "" && envelope.Target.DeviceID != deviceID {
continue
}
data, err := json.Marshal(adminLatestItem(envelope))
if err != nil {
continue
}
fmt.Fprintf(w, "event: envelope\ndata: %s\n\n", data)
flusher.Flush()
}
}
}
func (s *Server) handleAdminTraffic(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, s.hub.TrafficSnapshot())
}
func adminLatestItem(envelope model.Envelope) map[string]any {
payload := map[string]any{}
_ = json.Unmarshal(envelope.Payload, &payload)
return map[string]any{
"timestamp": envelope.Timestamp,
"topic": envelope.Topic,
"channelId": envelope.Target.ChannelID,
"deviceId": envelope.Target.DeviceID,
"groupId": envelope.Target.GroupID,
"sourceId": envelope.Source.ID,
"mode": envelope.Source.Mode,
"payload": payload,
}
}

View File

@@ -0,0 +1,604 @@
(function () {
const elements = {
serviceBadge: document.getElementById('serviceBadge'),
listenText: document.getElementById('listenText'),
uptimeText: document.getElementById('uptimeText'),
anonymousText: document.getElementById('anonymousText'),
heroText: document.getElementById('heroText'),
sessionsCount: document.getElementById('sessionsCount'),
subscribersCount: document.getElementById('subscribersCount'),
latestCount: document.getElementById('latestCount'),
channelsCount: document.getElementById('channelsCount'),
publishedCount: document.getElementById('publishedCount'),
droppedCount: document.getElementById('droppedCount'),
fanoutCount: document.getElementById('fanoutCount'),
pluginsCount: document.getElementById('pluginsCount'),
channelsTable: document.getElementById('channelsTable'),
channelLabelInput: document.getElementById('channelLabelInput'),
channelModeSelect: document.getElementById('channelModeSelect'),
channelTTLInput: document.getElementById('channelTTLInput'),
createChannelBtn: document.getElementById('createChannelBtn'),
createChannelResult: document.getElementById('createChannelResult'),
sessionsTable: document.getElementById('sessionsTable'),
latestTable: document.getElementById('latestTable'),
topicTrafficTable: document.getElementById('topicTrafficTable'),
channelTrafficTable: document.getElementById('channelTrafficTable'),
topicFilter: document.getElementById('topicFilter'),
liveTopicFilter: document.getElementById('liveTopicFilter'),
liveChannelFilter: document.getElementById('liveChannelFilter'),
liveDeviceFilter: document.getElementById('liveDeviceFilter'),
liveReconnectBtn: document.getElementById('liveReconnectBtn'),
liveClearBtn: document.getElementById('liveClearBtn'),
liveStatus: document.getElementById('liveStatus'),
liveSummary: document.getElementById('liveSummary'),
liveLocationCount: document.getElementById('liveLocationCount'),
liveHeartRateCount: document.getElementById('liveHeartRateCount'),
liveLastDevice: document.getElementById('liveLastDevice'),
liveLastTopic: document.getElementById('liveLastTopic'),
liveTrack: document.getElementById('liveTrack'),
liveTrackLegend: document.getElementById('liveTrackLegend'),
liveFeed: document.getElementById('liveFeed'),
refreshBtn: document.getElementById('refreshBtn'),
autoRefreshInput: document.getElementById('autoRefreshInput'),
}
let timer = 0
let liveSource = null
let liveCount = 0
const maxLiveLines = 120
const maxTrackPoints = 80
const liveTrackSeries = new Map()
const liveStats = {
location: 0,
heartRate: 0,
lastDevice: '--',
lastTopic: '--',
}
const liveTrackPalette = ['#0f7a68', '#d57a1f', '#2878c8', '#8a4bd6', '#b24f6a', '#2c9f5e']
function setBadge(status) {
elements.serviceBadge.textContent = status === 'ok' ? 'Online' : 'Unavailable'
elements.serviceBadge.className = status === 'ok' ? 'badge is-ok' : 'badge'
}
function formatDuration(seconds) {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
return `${hours}h ${minutes}m ${secs}s`
}
function formatTime(value) {
if (!value) {
return '--'
}
return new Date(value).toLocaleString()
}
async function loadJSON(url) {
const response = await fetch(url, { cache: 'no-store' })
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.json()
}
function renderSessions(payload) {
const items = Array.isArray(payload.items) ? payload.items : []
if (!items.length) {
elements.sessionsTable.innerHTML = '<div class="empty">当前没有活跃会话。</div>'
return
}
const rows = items.map((item) => {
const subscriptions = Array.isArray(item.subscriptions) && item.subscriptions.length
? item.subscriptions.map((entry) => {
const scope = entry.deviceId || `group:${entry.groupId || '--'}`
return `${scope}${entry.topic ? ` / ${entry.topic}` : ''}`
}).join('<br>')
: '--'
return `
<tr>
<td><code>${item.id || '--'}</code></td>
<td><code>${item.channelId || '--'}</code></td>
<td>${item.role || '--'}</td>
<td>${item.authenticated ? 'yes' : 'no'}</td>
<td>${formatTime(item.createdAt)}</td>
<td><div class="json-chip">${subscriptions}</div></td>
</tr>
`
}).join('')
elements.sessionsTable.innerHTML = `
<table>
<thead>
<tr>
<th>Session</th>
<th>Channel</th>
<th>Role</th>
<th>Auth</th>
<th>Created</th>
<th>Subscriptions</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`
}
function renderChannels(payload) {
const items = Array.isArray(payload.items) ? payload.items : []
if (!items.length) {
elements.channelsTable.innerHTML = '<div class="empty">当前没有 channel。</div>'
return
}
const rows = items.map((item) => `
<tr>
<td><code>${item.id || '--'}</code></td>
<td>${item.label || '--'}</td>
<td>${item.deliveryMode || '--'}</td>
<td>${item.activeProducers || 0} / ${item.activeConsumers || 0} / ${item.activeControllers || 0}</td>
<td>${formatTime(item.expiresAt)}</td>
</tr>
`).join('')
elements.channelsTable.innerHTML = `
<table>
<thead>
<tr>
<th>Channel</th>
<th>Label</th>
<th>Mode</th>
<th>P / C / Ctrl</th>
<th>Expires</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`
}
function renderLatest(payload) {
const items = Array.isArray(payload.items) ? payload.items : []
if (!items.length) {
elements.latestTable.innerHTML = '<div class="empty">当前没有 latest state。</div>'
return
}
const rows = items.map((item) => `
<tr>
<td>${item.deviceId || '--'}</td>
<td>${item.channelId || '--'}</td>
<td>${item.topic || '--'}</td>
<td>${item.sourceId || '--'}${item.mode ? ` / ${item.mode}` : ''}</td>
<td>${formatTime(item.timestamp)}</td>
<td><div class="json-chip">${escapeHTML(JSON.stringify(item.payload || {}))}</div></td>
</tr>
`).join('')
elements.latestTable.innerHTML = `
<table>
<thead>
<tr>
<th>Device</th>
<th>Channel</th>
<th>Topic</th>
<th>Source</th>
<th>Timestamp</th>
<th>Payload</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`
}
function renderTrafficTable(container, columns, rows, emptyText) {
if (!rows.length) {
container.innerHTML = `<div class="empty">${emptyText}</div>`
return
}
const header = columns.map((column) => `<th>${column.label}</th>`).join('')
const body = rows.map((row) => `
<tr>${columns.map((column) => `<td>${column.render(row)}</td>`).join('')}</tr>
`).join('')
container.innerHTML = `
<table>
<thead>
<tr>${header}</tr>
</thead>
<tbody>${body}</tbody>
</table>
`
}
function renderTraffic(payload) {
const topicItems = Array.isArray(payload.topics) ? payload.topics.slice() : []
topicItems.sort((left, right) => Number(right.published || 0) - Number(left.published || 0))
renderTrafficTable(
elements.topicTrafficTable,
[
{ label: 'Topic', render: (row) => `<code>${escapeHTML(row.topic || '--')}</code>` },
{ label: 'Published', render: (row) => String(row.published || 0) },
{ label: 'Dropped', render: (row) => String(row.dropped || 0) },
{ label: 'Fanout', render: (row) => String(row.fanout || 0) },
],
topicItems,
'当前没有 topic 流量。',
)
const channelItems = Array.isArray(payload.channels) ? payload.channels.slice() : []
channelItems.sort((left, right) => Number(right.published || 0) - Number(left.published || 0))
renderTrafficTable(
elements.channelTrafficTable,
[
{ label: 'Channel', render: (row) => `<code>${escapeHTML(row.channelId || '--')}</code>` },
{ label: 'Published', render: (row) => String(row.published || 0) },
{ label: 'Dropped', render: (row) => String(row.dropped || 0) },
{ label: 'Fanout', render: (row) => String(row.fanout || 0) },
],
channelItems,
'当前没有 channel 流量。',
)
}
function escapeHTML(text) {
return String(text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
}
function setLiveStatus(status, summary) {
elements.liveStatus.textContent = status
elements.liveStatus.className = status === 'Online' ? 'badge is-ok' : 'badge'
elements.liveSummary.textContent = summary
}
function updateLiveStats() {
elements.liveLocationCount.textContent = String(liveStats.location)
elements.liveHeartRateCount.textContent = String(liveStats.heartRate)
elements.liveLastDevice.textContent = liveStats.lastDevice
elements.liveLastTopic.textContent = liveStats.lastTopic
}
function formatNumber(value, digits) {
const num = Number(value)
if (!Number.isFinite(num)) {
return '--'
}
return num.toFixed(digits)
}
function formatLiveSummary(item) {
if (item.topic === 'telemetry.location') {
const payload = item.payload || {}
return `定位 ${formatNumber(payload.lat, 6)}, ${formatNumber(payload.lng, 6)} | 速度 ${formatNumber(payload.speed, 1)} m/s | 航向 ${formatNumber(payload.bearing, 0)}° | 精度 ${formatNumber(payload.accuracy, 1)} m`
}
if (item.topic === 'telemetry.heart_rate') {
const payload = item.payload || {}
return `心率 ${formatNumber(payload.bpm, 0)} bpm`
}
return '原始数据'
}
function trackKey(item) {
return `${item.channelId || '--'} / ${item.deviceId || '--'}`
}
function ensureTrackSeries(item) {
const key = trackKey(item)
if (!liveTrackSeries.has(key)) {
liveTrackSeries.set(key, {
key,
color: liveTrackPalette[liveTrackSeries.size % liveTrackPalette.length],
points: [],
lastTopic: item.topic || '--',
})
}
return liveTrackSeries.get(key)
}
function updateTrack(item) {
if (item.topic !== 'telemetry.location') {
return
}
const payload = item.payload || {}
const lat = Number(payload.lat)
const lng = Number(payload.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
return
}
const series = ensureTrackSeries(item)
series.lastTopic = item.topic || '--'
series.points.push({ lat, lng, timestamp: item.timestamp })
if (series.points.length > maxTrackPoints) {
series.points.shift()
}
renderLiveTrack()
}
function renderLiveTrack() {
const activeSeries = Array.from(liveTrackSeries.values()).filter((entry) => entry.points.length > 0)
if (!activeSeries.length) {
elements.liveTrack.innerHTML = '<div class="live-track__empty">等待 GPS 数据...</div>'
elements.liveTrackLegend.innerHTML = '<div class="empty">暂无轨迹。</div>'
return
}
let minLat = Infinity
let maxLat = -Infinity
let minLng = Infinity
let maxLng = -Infinity
activeSeries.forEach((series) => {
series.points.forEach((point) => {
minLat = Math.min(minLat, point.lat)
maxLat = Math.max(maxLat, point.lat)
minLng = Math.min(minLng, point.lng)
maxLng = Math.max(maxLng, point.lng)
})
})
const width = 340
const height = 320
const padding = 18
const lngSpan = Math.max(maxLng - minLng, 0.0001)
const latSpan = Math.max(maxLat - minLat, 0.0001)
const polylines = activeSeries.map((series) => {
const points = series.points.map((point) => {
const x = padding + ((point.lng - minLng) / lngSpan) * (width - padding * 2)
const y = height - padding - ((point.lat - minLat) / latSpan) * (height - padding * 2)
return `${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
const last = series.points[series.points.length - 1]
const lastX = padding + ((last.lng - minLng) / lngSpan) * (width - padding * 2)
const lastY = height - padding - ((last.lat - minLat) / latSpan) * (height - padding * 2)
return `
<polyline fill="none" stroke="${series.color}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" points="${points}" />
<circle cx="${lastX.toFixed(1)}" cy="${lastY.toFixed(1)}" r="4.5" fill="${series.color}" stroke="rgba(255,255,255,0.95)" stroke-width="2" />
`
}).join('')
const grid = [25, 50, 75].map((ratio) => {
const x = (width * ratio) / 100
const y = (height * ratio) / 100
return `
<line x1="${x}" y1="0" x2="${x}" y2="${height}" stroke="rgba(21,38,31,0.08)" stroke-width="1" />
<line x1="0" y1="${y}" x2="${width}" y2="${y}" stroke="rgba(21,38,31,0.08)" stroke-width="1" />
`
}).join('')
elements.liveTrack.innerHTML = `
<svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" aria-label="live track preview">
<rect x="0" y="0" width="${width}" height="${height}" fill="transparent" />
${grid}
${polylines}
</svg>
`
elements.liveTrackLegend.innerHTML = activeSeries.map((series) => {
const last = series.points[series.points.length - 1]
return `
<div class="live-track-legend__item">
<span class="live-track-legend__swatch" style="background:${series.color}"></span>
<span>${escapeHTML(series.key)} | ${formatNumber(last.lat, 6)}, ${formatNumber(last.lng, 6)} | ${series.points.length} 点</span>
</div>
`
}).join('')
}
function renderLiveEntry(item) {
const line = document.createElement('div')
line.className = 'live-line'
const meta = document.createElement('div')
meta.className = 'live-line__meta'
meta.innerHTML = [
`<span>${escapeHTML(formatTime(item.timestamp))}</span>`,
`<span>${escapeHTML(item.topic || '--')}</span>`,
`<span>ch=${escapeHTML(item.channelId || '--')}</span>`,
`<span>device=${escapeHTML(item.deviceId || '--')}</span>`,
`<span>source=${escapeHTML(item.sourceId || '--')}${item.mode ? ` / ${escapeHTML(item.mode)}` : ''}</span>`,
].join('')
const summary = document.createElement('div')
summary.className = 'live-line__summary'
summary.textContent = formatLiveSummary(item)
const payload = document.createElement('div')
payload.className = 'live-line__payload'
payload.textContent = JSON.stringify(item.payload || {}, null, 2)
line.appendChild(meta)
line.appendChild(summary)
line.appendChild(payload)
if (elements.liveFeed.firstChild && elements.liveFeed.firstChild.classList && elements.liveFeed.firstChild.classList.contains('live-feed__empty')) {
elements.liveFeed.innerHTML = ''
}
elements.liveFeed.prepend(line)
liveCount += 1
liveStats.lastDevice = item.deviceId || '--'
liveStats.lastTopic = item.topic || '--'
if (item.topic === 'telemetry.location') {
liveStats.location += 1
} else if (item.topic === 'telemetry.heart_rate') {
liveStats.heartRate += 1
}
updateLiveStats()
updateTrack(item)
while (elements.liveFeed.childElementCount > maxLiveLines) {
elements.liveFeed.removeChild(elements.liveFeed.lastElementChild)
}
setLiveStatus('Online', `实时流已连接,已接收 ${liveCount} 条数据。`)
}
function clearLiveFeed() {
liveCount = 0
liveStats.location = 0
liveStats.heartRate = 0
liveStats.lastDevice = '--'
liveStats.lastTopic = '--'
liveTrackSeries.clear()
elements.liveFeed.innerHTML = '<div class="live-feed__empty">等待实时数据...</div>'
elements.liveTrack.innerHTML = '<div class="live-track__empty">等待 GPS 数据...</div>'
elements.liveTrackLegend.innerHTML = '<div class="empty">暂无轨迹。</div>'
updateLiveStats()
setLiveStatus(liveSource ? 'Online' : 'Connecting', liveSource ? '实时流已连接,等待数据...' : '正在连接实时流...')
}
function closeLiveStream() {
if (!liveSource) {
return
}
liveSource.close()
liveSource = null
}
function connectLiveStream() {
closeLiveStream()
const params = new URLSearchParams()
if (elements.liveTopicFilter.value) {
params.set('topic', elements.liveTopicFilter.value)
}
if (elements.liveChannelFilter.value.trim()) {
params.set('channelId', elements.liveChannelFilter.value.trim())
}
if (elements.liveDeviceFilter.value.trim()) {
params.set('deviceId', elements.liveDeviceFilter.value.trim())
}
clearLiveFeed()
setLiveStatus('Connecting', '正在连接实时流...')
const url = `/api/admin/live${params.toString() ? `?${params.toString()}` : ''}`
liveSource = new EventSource(url)
liveSource.addEventListener('open', () => {
setLiveStatus('Online', liveCount > 0 ? `实时流已连接,已接收 ${liveCount} 条数据。` : '实时流已连接,等待数据...')
})
liveSource.addEventListener('envelope', (event) => {
try {
const payload = JSON.parse(event.data)
renderLiveEntry(payload)
} catch (_error) {
setLiveStatus('Error', '实时流收到不可解析数据。')
}
})
liveSource.addEventListener('error', () => {
setLiveStatus('Error', '实时流已断开,可手动重连。')
})
}
async function refreshDashboard() {
try {
const topic = elements.topicFilter.value
const [overview, sessions, latest, channels, traffic] = await Promise.all([
loadJSON('/api/admin/overview'),
loadJSON('/api/admin/sessions'),
loadJSON(`/api/admin/latest${topic ? `?topic=${encodeURIComponent(topic)}` : ''}`),
loadJSON('/api/admin/channels'),
loadJSON('/api/admin/traffic'),
])
setBadge(overview.status)
elements.listenText.textContent = overview.httpListen || '--'
elements.uptimeText.textContent = formatDuration(overview.uptimeSeconds || 0)
elements.anonymousText.textContent = overview.anonymousConsumers ? 'enabled' : 'disabled'
elements.sessionsCount.textContent = String(overview.metrics.sessions || 0)
elements.subscribersCount.textContent = String(overview.metrics.subscribers || 0)
elements.latestCount.textContent = String(overview.metrics.latestState || 0)
elements.channelsCount.textContent = String(overview.metrics.channels || 0)
elements.publishedCount.textContent = String(overview.metrics.published || 0)
elements.droppedCount.textContent = String(overview.metrics.dropped || 0)
elements.fanoutCount.textContent = String(overview.metrics.fanout || 0)
elements.pluginsCount.textContent = String(overview.metrics.pluginHandlers || 0)
elements.heroText.textContent = `运行中,启动于 ${formatTime(overview.startedAt)},当前时间 ${formatTime(overview.now)}`
renderSessions(sessions)
renderLatest(latest)
renderChannels(channels)
renderTraffic(traffic)
} catch (error) {
setBadge('error')
elements.heroText.textContent = error && error.message ? error.message : '加载失败'
elements.channelsTable.innerHTML = '<div class="empty">无法加载 channel 信息。</div>'
elements.sessionsTable.innerHTML = '<div class="empty">无法加载会话信息。</div>'
elements.latestTable.innerHTML = '<div class="empty">无法加载 latest state。</div>'
elements.topicTrafficTable.innerHTML = '<div class="empty">无法加载 topic 流量。</div>'
elements.channelTrafficTable.innerHTML = '<div class="empty">无法加载 channel 流量。</div>'
}
}
async function createChannel() {
const payload = {
label: elements.channelLabelInput.value.trim(),
deliveryMode: elements.channelModeSelect.value,
ttlSeconds: Number(elements.channelTTLInput.value) || 28800,
}
try {
const response = await fetch('/api/channel/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data && data.error ? data.error : `HTTP ${response.status}`)
}
elements.createChannelResult.textContent = [
`channelId: ${data.snapshot.id}`,
`label: ${data.snapshot.label || '--'}`,
`deliveryMode: ${data.snapshot.deliveryMode || '--'}`,
`producerToken: ${data.producerToken}`,
`consumerToken: ${data.consumerToken}`,
`controllerToken: ${data.controllerToken}`,
].join('\n')
await refreshDashboard()
} catch (error) {
elements.createChannelResult.textContent = error && error.message ? error.message : '创建失败'
}
}
function resetAutoRefresh() {
if (timer) {
window.clearInterval(timer)
timer = 0
}
if (elements.autoRefreshInput.checked) {
timer = window.setInterval(refreshDashboard, 3000)
}
}
elements.refreshBtn.addEventListener('click', refreshDashboard)
elements.createChannelBtn.addEventListener('click', createChannel)
elements.topicFilter.addEventListener('change', refreshDashboard)
elements.liveReconnectBtn.addEventListener('click', connectLiveStream)
elements.liveClearBtn.addEventListener('click', clearLiveFeed)
elements.liveTopicFilter.addEventListener('change', connectLiveStream)
elements.liveChannelFilter.addEventListener('change', connectLiveStream)
elements.liveDeviceFilter.addEventListener('change', connectLiveStream)
elements.autoRefreshInput.addEventListener('change', resetAutoRefresh)
clearLiveFeed()
connectLiveStream()
refreshDashboard()
resetAutoRefresh()
})()

View File

@@ -0,0 +1,214 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Realtime Gateway Console</title>
<link rel="stylesheet" href="/assets/style.css?v=20260327e">
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="brand">
<div class="brand__eyebrow">REALTIME GATEWAY</div>
<h1>控制台</h1>
<div id="serviceBadge" class="badge">Loading</div>
</div>
<div class="sidebar__section">
<div class="sidebar__label">运行信息</div>
<div class="meta-list">
<div class="meta-row"><span>监听</span><strong id="listenText">--</strong></div>
<div class="meta-row"><span>运行时长</span><strong id="uptimeText">--</strong></div>
<div class="meta-row"><span>匿名订阅</span><strong id="anonymousText">--</strong></div>
</div>
</div>
<div class="sidebar__section">
<div class="sidebar__label">接口</div>
<div class="endpoint-list">
<code>/ws</code>
<code>/healthz</code>
<code>/metrics</code>
<code>/api/admin/overview</code>
<code>/api/admin/sessions</code>
<code>/api/admin/latest</code>
<code>/api/admin/traffic</code>
<code>/api/admin/live</code>
</div>
</div>
<div class="sidebar__section">
<button id="refreshBtn" class="action-btn">立即刷新</button>
<label class="toggle">
<input id="autoRefreshInput" type="checkbox" checked>
<span>自动刷新 (3s)</span>
</label>
</div>
</aside>
<main class="main">
<section class="hero">
<div class="hero__title">
<span class="hero__tag">Router Style</span>
<h2>实时设备网关管理台</h2>
</div>
<p id="heroText" class="hero__text">正在加载运行状态...</p>
</section>
<section class="grid stats-grid">
<article class="card metric-card">
<div class="card__label">Sessions</div>
<div id="sessionsCount" class="metric-card__value">0</div>
</article>
<article class="card metric-card">
<div class="card__label">Subscribers</div>
<div id="subscribersCount" class="metric-card__value">0</div>
</article>
<article class="card metric-card">
<div class="card__label">Latest State</div>
<div id="latestCount" class="metric-card__value">0</div>
</article>
<article class="card metric-card">
<div class="card__label">Channels</div>
<div id="channelsCount" class="metric-card__value">0</div>
</article>
<article class="card metric-card">
<div class="card__label">Published</div>
<div id="publishedCount" class="metric-card__value">0</div>
</article>
<article class="card metric-card">
<div class="card__label">Dropped</div>
<div id="droppedCount" class="metric-card__value">0</div>
</article>
<article class="card metric-card">
<div class="card__label">Fanout</div>
<div id="fanoutCount" class="metric-card__value">0</div>
</article>
<article class="card metric-card">
<div class="card__label">Plugins</div>
<div id="pluginsCount" class="metric-card__value">0</div>
</article>
</section>
<section class="grid detail-grid">
<article class="card">
<div class="card__header">
<div>
<div class="card__title">Channel 管理</div>
<div class="card__hint">创建临时通道并查看当前在线角色</div>
</div>
</div>
<div class="channel-form">
<input id="channelLabelInput" class="filter-select" type="text" placeholder="可选标签,例如: debug-a">
<select id="channelModeSelect" class="filter-select">
<option value="cache_latest">cache_latest</option>
<option value="drop_if_no_consumer">drop_if_no_consumer</option>
</select>
<input id="channelTTLInput" class="filter-select" type="number" min="60" value="28800" placeholder="TTL 秒">
<button id="createChannelBtn" class="action-btn">创建 Channel</button>
</div>
<div class="card__hint">创建成功后,这里会显示 `channelId` 与三种 token。</div>
<pre id="createChannelResult" class="result-box">创建结果会显示在这里。</pre>
<div id="channelsTable" class="table-wrap"></div>
</article>
<article class="card">
<div class="card__header">
<div>
<div class="card__title">会话列表</div>
<div id="sessionsHint" class="card__hint">当前连接与订阅</div>
</div>
</div>
<div id="sessionsTable" class="table-wrap"></div>
</article>
<article class="card">
<div class="card__header">
<div>
<div class="card__title">Latest State</div>
<div class="card__hint">每个设备最近一条消息</div>
</div>
<select id="topicFilter" class="filter-select">
<option value="">全部 Topic</option>
<option value="telemetry.location">telemetry.location</option>
<option value="telemetry.heart_rate">telemetry.heart_rate</option>
</select>
</div>
<div id="latestTable" class="table-wrap"></div>
</article>
<article class="card traffic-card">
<div class="card__header">
<div>
<div class="card__title">流量统计</div>
<div class="card__hint">按 topic 和 channel 看累计发布、丢弃和扇出</div>
</div>
</div>
<div class="traffic-grid">
<section>
<div class="card__hint">Topic 统计</div>
<div id="topicTrafficTable" class="table-wrap"></div>
</section>
<section>
<div class="card__hint">Channel 统计</div>
<div id="channelTrafficTable" class="table-wrap"></div>
</section>
</div>
</article>
<article class="card live-card">
<div class="card__header">
<div>
<div class="card__title">实时数据窗口</div>
<div class="card__hint">直接查看网关收到的实时 GPS / 心率数据</div>
</div>
<div class="live-controls">
<select id="liveTopicFilter" class="filter-select">
<option value="">全部 Topic</option>
<option value="telemetry.location">telemetry.location</option>
<option value="telemetry.heart_rate">telemetry.heart_rate</option>
</select>
<input id="liveChannelFilter" class="filter-select" type="text" placeholder="channelId 可选">
<input id="liveDeviceFilter" class="filter-select" type="text" placeholder="deviceId 可选">
<button id="liveReconnectBtn" class="action-btn action-btn--inline">重连实时流</button>
<button id="liveClearBtn" class="action-btn action-btn--inline action-btn--muted">清空窗口</button>
</div>
</div>
<div class="live-meta">
<div id="liveStatus" class="badge">Connecting</div>
<div id="liveSummary" class="card__hint">等待实时流...</div>
</div>
<div class="live-stats">
<div class="live-stat">
<span class="live-stat__label">定位消息</span>
<strong id="liveLocationCount">0</strong>
</div>
<div class="live-stat">
<span class="live-stat__label">心率消息</span>
<strong id="liveHeartRateCount">0</strong>
</div>
<div class="live-stat">
<span class="live-stat__label">最后设备</span>
<strong id="liveLastDevice">--</strong>
</div>
<div class="live-stat">
<span class="live-stat__label">最后主题</span>
<strong id="liveLastTopic">--</strong>
</div>
</div>
<div class="live-panel-grid">
<section class="live-track-panel">
<div class="card__hint">轨迹预览,建议配合 `channelId / deviceId` 过滤使用</div>
<div id="liveTrack" class="live-track"></div>
<div id="liveTrackLegend" class="live-track-legend"></div>
</section>
<section>
<div id="liveFeed" class="live-feed"></div>
</section>
</div>
</article>
</section>
</main>
</div>
<script src="/assets/app.js?v=20260327e"></script>
</body>
</html>

View File

@@ -0,0 +1,508 @@
:root {
--bg: #d9dfd3;
--panel: rgba(247, 249, 243, 0.94);
--card: rgba(255, 255, 255, 0.94);
--line: rgba(28, 43, 34, 0.12);
--text: #15261f;
--muted: #5e6f66;
--accent: #0f7a68;
--accent-2: #d57a1f;
--ok: #13754c;
--warn: #9a5a11;
--shadow: 0 18px 40px rgba(28, 43, 34, 0.12);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background:
radial-gradient(circle at top left, rgba(213, 122, 31, 0.22), transparent 28%),
radial-gradient(circle at bottom right, rgba(15, 122, 104, 0.22), transparent 24%),
var(--bg);
color: var(--text);
font-family: "Bahnschrift", "Segoe UI Variable Text", "PingFang SC", sans-serif;
}
.shell {
display: grid;
grid-template-columns: 320px 1fr;
min-height: 100vh;
}
.sidebar {
padding: 24px;
background: rgba(22, 35, 29, 0.92);
color: #eef4ed;
border-right: 1px solid rgba(255, 255, 255, 0.08);
}
.brand__eyebrow {
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(238, 244, 237, 0.68);
}
.brand h1 {
margin: 10px 0 12px;
font-size: 34px;
line-height: 1;
}
.badge {
display: inline-flex;
min-height: 30px;
align-items: center;
padding: 0 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: rgba(238, 244, 237, 0.82);
font-size: 13px;
font-weight: 700;
}
.badge.is-ok {
background: rgba(19, 117, 76, 0.24);
color: #9ef4c7;
}
.sidebar__section {
margin-top: 26px;
}
.sidebar__label {
margin-bottom: 12px;
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(238, 244, 237, 0.58);
}
.meta-list,
.endpoint-list {
display: grid;
gap: 10px;
}
.meta-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.meta-row span {
color: rgba(238, 244, 237, 0.66);
}
.endpoint-list code {
display: block;
padding: 10px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
color: #dbe8e1;
font-family: "Cascadia Code", "Consolas", monospace;
}
.action-btn,
.filter-select {
min-height: 42px;
border: 0;
border-radius: 14px;
font: inherit;
}
.action-btn {
width: 100%;
font-weight: 800;
color: #113128;
background: linear-gradient(135deg, #f0d96b, #d57a1f);
cursor: pointer;
}
.action-btn--inline {
width: auto;
padding: 0 16px;
}
.action-btn--muted {
color: #eef4ed;
background: linear-gradient(135deg, #466055, #2f473d);
}
.toggle {
display: flex;
align-items: center;
gap: 10px;
margin-top: 14px;
color: rgba(238, 244, 237, 0.82);
}
.main {
padding: 28px;
}
.hero {
padding: 24px 26px;
border-radius: 24px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(233, 239, 232, 0.9));
box-shadow: var(--shadow);
}
.hero__tag {
display: inline-block;
margin-bottom: 10px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(15, 122, 104, 0.12);
color: var(--accent);
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 800;
}
.hero h2 {
margin: 0;
font-size: 32px;
}
.hero__text {
margin: 10px 0 0;
color: var(--muted);
font-size: 15px;
}
.grid {
display: grid;
gap: 18px;
margin-top: 20px;
}
.stats-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.detail-grid {
grid-template-columns: 1.15fr 1fr;
}
.traffic-card {
grid-column: 1 / -1;
}
.live-card {
grid-column: 1 / -1;
}
.channel-form {
display: grid;
grid-template-columns: 1.3fr 1fr 0.8fr 0.9fr;
gap: 12px;
margin-bottom: 14px;
}
.card {
padding: 20px;
border: 1px solid var(--line);
border-radius: 22px;
background: var(--card);
box-shadow: var(--shadow);
}
.metric-card__value {
margin-top: 10px;
font-size: 42px;
line-height: 1;
font-weight: 900;
letter-spacing: -0.04em;
}
.card__label {
color: var(--muted);
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.card__header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 16px;
}
.card__title {
font-size: 20px;
font-weight: 900;
}
.card__hint {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
}
.filter-select {
min-width: 220px;
padding: 0 14px;
border: 1px solid var(--line);
background: #f6f8f2;
}
.live-controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: flex-end;
}
.live-meta {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 14px;
}
.live-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.live-stat {
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(244, 247, 240, 0.88);
}
.live-stat__label {
display: block;
margin-bottom: 8px;
color: var(--muted);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.live-stat strong {
display: block;
font-size: 22px;
line-height: 1.1;
}
.live-panel-grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 14px;
}
.traffic-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.live-track-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.live-track {
min-height: 320px;
border: 1px solid var(--line);
border-radius: 16px;
background:
radial-gradient(circle at top left, rgba(15, 122, 104, 0.15), transparent 30%),
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(237, 242, 234, 0.96));
overflow: hidden;
}
.live-track svg {
display: block;
width: 100%;
height: 320px;
}
.live-track__empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 320px;
color: var(--muted);
font-size: 13px;
}
.live-track-legend {
display: grid;
gap: 8px;
}
.live-track-legend__item {
display: flex;
gap: 10px;
align-items: center;
padding: 8px 10px;
border-radius: 12px;
background: rgba(244, 247, 240, 0.88);
border: 1px solid var(--line);
font-size: 12px;
}
.live-track-legend__swatch {
width: 10px;
height: 10px;
border-radius: 999px;
flex: 0 0 auto;
}
.live-feed {
min-height: 320px;
max-height: 420px;
overflow: auto;
padding: 14px;
border: 1px solid var(--line);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(19, 29, 24, 0.98), rgba(14, 24, 20, 0.98));
color: #dceee7;
font-family: "Cascadia Code", "Consolas", monospace;
font-size: 12px;
line-height: 1.6;
}
.live-feed__empty {
color: rgba(220, 238, 231, 0.68);
}
.live-line {
padding: 10px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
}
.live-line + .live-line {
margin-top: 10px;
}
.live-line__meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 8px;
color: #8dd9c7;
}
.live-line__summary {
margin-bottom: 8px;
color: #f1d88e;
font-weight: 700;
}
.live-line__payload {
color: rgba(241, 246, 244, 0.8);
white-space: pre-wrap;
word-break: break-word;
}
.table-wrap {
overflow: auto;
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(244, 247, 240, 0.8);
}
.result-box {
min-height: 88px;
margin: 10px 0 16px;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid var(--line);
background: #f4f7ef;
color: var(--text);
font-family: "Cascadia Code", "Consolas", monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
th {
position: sticky;
top: 0;
background: #edf2ea;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
td code,
.json-chip {
font-family: "Cascadia Code", "Consolas", monospace;
font-size: 12px;
}
.json-chip {
display: inline-block;
max-width: 100%;
padding: 8px 10px;
border-radius: 12px;
background: #f2f5ef;
white-space: pre-wrap;
word-break: break-word;
}
.empty {
padding: 18px;
color: var(--muted);
}
@media (max-width: 1180px) {
.shell {
grid-template-columns: 1fr;
}
.stats-grid,
.detail-grid {
grid-template-columns: 1fr;
}
.channel-form {
grid-template-columns: 1fr;
}
.live-stats,
.traffic-grid,
.live-panel-grid {
grid-template-columns: 1fr;
}
.live-controls {
justify-content: stretch;
}
}

View File

@@ -0,0 +1,24 @@
package gateway
import (
"slices"
"realtime-gateway/internal/config"
"realtime-gateway/internal/model"
)
func authorize(cfg config.AuthConfig, role model.Role, token string) bool {
switch role {
case model.RoleProducer:
return slices.Contains(cfg.ProducerTokens, token)
case model.RoleController:
return slices.Contains(cfg.ControllerTokens, token)
case model.RoleConsumer:
if cfg.AllowAnonymousConsumers && token == "" {
return true
}
return slices.Contains(cfg.ConsumerTokens, token)
default:
return false
}
}

View File

@@ -0,0 +1,58 @@
package gateway
import (
"encoding/json"
"net/http"
"realtime-gateway/internal/channel"
)
type createChannelRequest struct {
Label string `json:"label"`
DeliveryMode string `json:"deliveryMode"`
TTLSeconds int `json:"ttlSeconds"`
}
func (s *Server) registerChannelRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/channel/create", s.handleCreateChannel)
mux.HandleFunc("/api/admin/channels", s.handleAdminChannels)
}
func (s *Server) handleCreateChannel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{
"error": "method not allowed",
})
return
}
var request createChannelRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{
"error": "invalid json body",
})
return
}
created, err := s.channels.Create(channel.CreateRequest{
Label: request.Label,
DeliveryMode: request.DeliveryMode,
TTLSeconds: request.TTLSeconds,
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{
"error": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, created)
}
func (s *Server) handleAdminChannels(w http.ResponseWriter, _ *http.Request) {
items := s.channels.List()
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"count": len(items),
})
}

View File

@@ -0,0 +1,277 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"realtime-gateway/internal/channel"
"realtime-gateway/internal/config"
"realtime-gateway/internal/model"
"realtime-gateway/internal/plugin"
"realtime-gateway/internal/router"
"realtime-gateway/internal/session"
)
type client struct {
conn *websocket.Conn
logger *slog.Logger
cfg config.GatewayConfig
hub *router.Hub
channels *channel.Manager
plugins *plugin.Bus
session *session.Session
auth config.AuthConfig
writeMu sync.Mutex
}
func serveClient(
w http.ResponseWriter,
r *http.Request,
logger *slog.Logger,
cfg config.Config,
hub *router.Hub,
channels *channel.Manager,
plugins *plugin.Bus,
sessions *session.Manager,
) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: true,
})
if err != nil {
logger.Error("websocket accept failed", "error", err)
return
}
sess := sessions.Create()
c := &client{
conn: conn,
logger: logger.With("sessionId", sess.ID),
cfg: cfg.Gateway,
hub: hub,
channels: channels,
plugins: plugins,
session: sess,
auth: cfg.Auth,
}
hub.Register(c, nil)
defer func() {
if sess.ChannelID != "" {
channels.Unbind(sess.ChannelID, sess.Role)
}
hub.Unregister(sess.ID)
sessions.Delete(sess.ID)
_ = conn.Close(websocket.StatusNormalClosure, "session closed")
}()
if err := c.run(r.Context()); err != nil && !errors.Is(err, context.Canceled) {
c.logger.Warn("client closed", "error", err)
}
}
func (c *client) ID() string {
return c.session.ID
}
func (c *client) Send(message model.ServerMessage) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), c.cfg.WriteWait())
defer cancel()
return wsjson.Write(ctx, c.conn, message)
}
func (c *client) run(ctx context.Context) error {
if err := c.Send(model.ServerMessage{
Type: "welcome",
SessionID: c.session.ID,
}); err != nil {
return err
}
pingCtx, cancelPing := context.WithCancel(ctx)
defer cancelPing()
go c.pingLoop(pingCtx)
for {
readCtx, cancel := context.WithTimeout(ctx, c.cfg.PongWait())
var message model.ClientMessage
err := wsjson.Read(readCtx, c.conn, &message)
cancel()
if err != nil {
return err
}
if err := c.handleMessage(message); err != nil {
_ = c.Send(model.ServerMessage{
Type: "error",
Error: err.Error(),
})
}
}
}
func (c *client) handleMessage(message model.ClientMessage) error {
switch message.Type {
case "authenticate":
return c.handleAuthenticate(message)
case "join_channel":
return c.handleJoinChannel(message)
case "subscribe":
return c.handleSubscribe(message)
case "publish":
return c.handlePublish(message)
case "snapshot":
return c.handleSnapshot(message)
default:
return errors.New("unsupported message type")
}
}
func (c *client) handleJoinChannel(message model.ClientMessage) error {
if strings.TrimSpace(message.ChannelID) == "" {
return errors.New("channelId is required")
}
snapshot, err := c.channels.Join(message.ChannelID, message.Token, message.Role)
if err != nil {
return err
}
if c.session.ChannelID != "" {
c.channels.Unbind(c.session.ChannelID, c.session.Role)
}
if err := c.channels.Bind(snapshot.ID, message.Role); err != nil {
return err
}
c.session.Role = message.Role
c.session.Authenticated = true
c.session.ChannelID = snapshot.ID
c.session.Subscriptions = nil
c.hub.UpdateSubscriptions(c.session.ID, nil)
return c.Send(model.ServerMessage{
Type: "joined_channel",
SessionID: c.session.ID,
State: json.RawMessage([]byte(
`{"channelId":"` + snapshot.ID + `","deliveryMode":"` + snapshot.DeliveryMode + `"}`,
)),
})
}
func (c *client) handleAuthenticate(message model.ClientMessage) error {
if !authorize(c.auth, message.Role, message.Token) {
return errors.New("authentication failed")
}
c.session.Role = message.Role
c.session.Authenticated = true
return c.Send(model.ServerMessage{
Type: "authenticated",
SessionID: c.session.ID,
})
}
func (c *client) handleSubscribe(message model.ClientMessage) error {
if !c.session.Authenticated && !c.auth.AllowAnonymousConsumers {
return errors.New("consumer must authenticate before subscribe")
}
subscriptions := normalizeSubscriptions(c.session.ChannelID, message.Subscriptions)
c.session.Subscriptions = subscriptions
c.hub.UpdateSubscriptions(c.session.ID, subscriptions)
return c.Send(model.ServerMessage{
Type: "subscribed",
SessionID: c.session.ID,
})
}
func (c *client) handlePublish(message model.ClientMessage) error {
if !c.session.Authenticated {
return errors.New("authentication required")
}
if c.session.Role != model.RoleProducer && c.session.Role != model.RoleController {
return errors.New("publish is only allowed for producer or controller")
}
if message.Envelope == nil {
return errors.New("envelope is required")
}
envelope := *message.Envelope
if envelope.Source.Kind == "" {
envelope.Source.Kind = c.session.Role
}
if c.session.ChannelID != "" {
envelope.Target.ChannelID = c.session.ChannelID
}
deliveryMode := channel.DeliveryModeCacheLatest
if envelope.Target.ChannelID != "" {
deliveryMode = c.channels.DeliveryMode(envelope.Target.ChannelID)
}
result := c.hub.Publish(envelope, deliveryMode)
if !result.Dropped {
c.plugins.Publish(envelope)
}
return c.Send(model.ServerMessage{
Type: "published",
SessionID: c.session.ID,
})
}
func (c *client) handleSnapshot(message model.ClientMessage) error {
if len(message.Subscriptions) == 0 || message.Subscriptions[0].DeviceID == "" {
return errors.New("snapshot requires deviceId in first subscription")
}
channelID := message.Subscriptions[0].ChannelID
if channelID == "" {
channelID = c.session.ChannelID
}
state, ok := c.hub.Snapshot(channelID, message.Subscriptions[0].DeviceID)
if !ok {
return errors.New("snapshot not found")
}
return c.Send(model.ServerMessage{
Type: "snapshot",
SessionID: c.session.ID,
State: json.RawMessage(state),
})
}
func (c *client) pingLoop(ctx context.Context) {
ticker := time.NewTicker(c.cfg.PingInterval())
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
pingCtx, cancel := context.WithTimeout(ctx, c.cfg.WriteWait())
_ = c.conn.Ping(pingCtx)
cancel()
}
}
}
func normalizeSubscriptions(channelID string, subscriptions []model.Subscription) []model.Subscription {
items := make([]model.Subscription, 0, len(subscriptions))
for _, entry := range subscriptions {
if channelID != "" && strings.TrimSpace(entry.ChannelID) == "" {
entry.ChannelID = channelID
}
items = append(items, entry)
}
return items
}

View File

@@ -0,0 +1,109 @@
package gateway
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
"realtime-gateway/internal/channel"
"realtime-gateway/internal/config"
"realtime-gateway/internal/plugin"
"realtime-gateway/internal/router"
"realtime-gateway/internal/session"
)
type Server struct {
cfg config.Config
logger *slog.Logger
httpSrv *http.Server
channels *channel.Manager
hub *router.Hub
plugins *plugin.Bus
sessions *session.Manager
startedAt time.Time
}
func NewServer(cfg config.Config, logger *slog.Logger) (*Server, error) {
channels := channel.NewManager(8 * time.Hour)
hub := router.NewHub(cfg.Gateway.MaxLatestStateEntries)
plugins := plugin.NewBus(logger.With("component", "plugin-bus"))
sessions := session.NewManager()
mux := http.NewServeMux()
server := &Server{
cfg: cfg,
logger: logger,
channels: channels,
hub: hub,
plugins: plugins,
sessions: sessions,
startedAt: time.Now(),
httpSrv: &http.Server{
Addr: cfg.Server.HTTPListen,
ReadTimeout: cfg.Server.ReadTimeout(),
WriteTimeout: cfg.Server.WriteTimeout(),
IdleTimeout: cfg.Server.IdleTimeout(),
},
}
mux.HandleFunc("/healthz", server.handleHealth)
mux.HandleFunc("/metrics", server.handleMetrics)
mux.HandleFunc("/ws", server.handleWS)
server.registerChannelRoutes(mux)
if err := server.registerAdminRoutes(mux); err != nil {
return nil, err
}
server.httpSrv.Handler = mux
return server, nil
}
func (s *Server) Run(ctx context.Context) error {
errCh := make(chan error, 1)
go func() {
s.logger.Info("gateway listening", "addr", s.cfg.Server.HTTPListen)
errCh <- s.httpSrv.ListenAndServe()
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), s.cfg.Server.ShutdownTimeout())
defer cancel()
s.logger.Info("shutting down gateway")
return s.httpSrv.Shutdown(shutdownCtx)
case err := <-errCh:
if err == http.ErrServerClosed {
return nil
}
return err
}
}
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
})
}
func (s *Server) handleMetrics(w http.ResponseWriter, _ *http.Request) {
subscriberCount, latestStateCount := s.hub.Stats()
writeJSON(w, http.StatusOK, map[string]any{
"sessions": s.sessions.Count(),
"subscribers": subscriberCount,
"latestState": latestStateCount,
"pluginHandlers": s.plugins.HandlerCount(),
"httpListen": s.cfg.Server.HTTPListen,
"anonymousClient": s.cfg.Auth.AllowAnonymousConsumers,
})
}
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
serveClient(w, r, s.logger, s.cfg, s.hub, s.channels, s.plugins, s.sessions)
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}

View File

@@ -0,0 +1,13 @@
package logging
import (
"log/slog"
"os"
)
func New() *slog.Logger {
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
return slog.New(handler)
}

View File

@@ -0,0 +1,64 @@
package model
import "encoding/json"
type Role string
const (
RoleProducer Role = "producer"
RoleConsumer Role = "consumer"
RoleController Role = "controller"
)
type Envelope struct {
SchemaVersion int `json:"schemaVersion"`
MessageID string `json:"messageId,omitempty"`
Timestamp int64 `json:"timestamp"`
Topic string `json:"topic"`
Source Source `json:"source"`
Target Target `json:"target"`
Payload json.RawMessage `json:"payload"`
}
type Source struct {
Kind Role `json:"kind"`
ID string `json:"id"`
Mode string `json:"mode,omitempty"`
}
type Target struct {
ChannelID string `json:"channelId,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
GroupID string `json:"groupId,omitempty"`
}
type ClientMessage struct {
Type string `json:"type"`
Role Role `json:"role,omitempty"`
ChannelID string `json:"channelId,omitempty"`
Token string `json:"token,omitempty"`
Subscriptions []Subscription `json:"subscriptions,omitempty"`
Envelope *Envelope `json:"envelope,omitempty"`
}
type Subscription struct {
ChannelID string `json:"channelId,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
GroupID string `json:"groupId,omitempty"`
Topic string `json:"topic,omitempty"`
}
type ServerMessage struct {
Type string `json:"type"`
SessionID string `json:"sessionId,omitempty"`
Error string `json:"error,omitempty"`
Envelope *Envelope `json:"envelope,omitempty"`
State json.RawMessage `json:"state,omitempty"`
}
type LatestState struct {
DeviceID string `json:"deviceId"`
GroupID string `json:"groupId,omitempty"`
UpdatedAt int64 `json:"updatedAt"`
Topics []string `json:"topics"`
}

View File

@@ -0,0 +1,53 @@
package plugin
import (
"context"
"log/slog"
"sync"
"realtime-gateway/internal/model"
)
type Handler interface {
Name() string
Handle(context.Context, model.Envelope) error
}
type Bus struct {
logger *slog.Logger
mu sync.RWMutex
handlers []Handler
}
func NewBus(logger *slog.Logger) *Bus {
return &Bus{
logger: logger,
}
}
func (b *Bus) Register(handler Handler) {
b.mu.Lock()
b.handlers = append(b.handlers, handler)
b.mu.Unlock()
}
func (b *Bus) Publish(envelope model.Envelope) {
b.mu.RLock()
handlers := append([]Handler(nil), b.handlers...)
b.mu.RUnlock()
for _, handler := range handlers {
handler := handler
go func() {
if err := handler.Handle(context.Background(), envelope); err != nil {
b.logger.Warn("plugin handler failed", "handler", handler.Name(), "error", err)
}
}()
}
}
func (b *Bus) HandlerCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.handlers)
}

View File

@@ -0,0 +1,337 @@
package router
import (
"encoding/json"
"sync"
"realtime-gateway/internal/model"
)
type Subscriber interface {
ID() string
Send(message model.ServerMessage) error
}
type Hub struct {
mu sync.RWMutex
subscribers map[string]Subscriber
filters map[string][]model.Subscription
latestState map[string]model.Envelope
liveFeeds map[uint64]chan model.Envelope
nextLiveID uint64
stats trafficStats
maxLatest int
}
type TrafficSnapshot struct {
Published uint64 `json:"published"`
Dropped uint64 `json:"dropped"`
Fanout uint64 `json:"fanout"`
Topics []TopicTrafficItem `json:"topics"`
Channels []ChannelTrafficItem `json:"channels"`
}
type TopicTrafficItem struct {
Topic string `json:"topic"`
Published uint64 `json:"published"`
Dropped uint64 `json:"dropped"`
Fanout uint64 `json:"fanout"`
}
type ChannelTrafficItem struct {
ChannelID string `json:"channelId"`
Published uint64 `json:"published"`
Dropped uint64 `json:"dropped"`
Fanout uint64 `json:"fanout"`
}
type trafficStats struct {
Published uint64
Dropped uint64
Fanout uint64
Topics map[string]*trafficCounter
Channels map[string]*trafficCounter
}
type trafficCounter struct {
Published uint64
Dropped uint64
Fanout uint64
}
type PublishResult struct {
Matched int `json:"matched"`
Stored bool `json:"stored"`
Dropped bool `json:"dropped"`
}
func NewHub(maxLatest int) *Hub {
return &Hub{
subscribers: make(map[string]Subscriber),
filters: make(map[string][]model.Subscription),
latestState: make(map[string]model.Envelope),
liveFeeds: make(map[uint64]chan model.Envelope),
stats: trafficStats{
Topics: make(map[string]*trafficCounter),
Channels: make(map[string]*trafficCounter),
},
maxLatest: maxLatest,
}
}
func (h *Hub) Register(subscriber Subscriber, subscriptions []model.Subscription) {
h.mu.Lock()
defer h.mu.Unlock()
h.subscribers[subscriber.ID()] = subscriber
h.filters[subscriber.ID()] = subscriptions
}
func (h *Hub) Unregister(subscriberID string) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.subscribers, subscriberID)
delete(h.filters, subscriberID)
}
func (h *Hub) UpdateSubscriptions(subscriberID string, subscriptions []model.Subscription) {
h.mu.Lock()
defer h.mu.Unlock()
h.filters[subscriberID] = subscriptions
}
func (h *Hub) Publish(envelope model.Envelope, deliveryMode string) PublishResult {
h.mu.RLock()
matches := make([]Subscriber, 0, len(h.subscribers))
for subscriberID, subscriber := range h.subscribers {
subscriptions := h.filters[subscriberID]
if !matchesAny(envelope, subscriptions) {
continue
}
matches = append(matches, subscriber)
}
h.mu.RUnlock()
if deliveryMode == "drop_if_no_consumer" && len(matches) == 0 {
h.recordTraffic(envelope, 0, true)
return PublishResult{
Matched: 0,
Stored: false,
Dropped: true,
}
}
h.storeLatest(envelope)
h.publishLive(envelope)
h.recordTraffic(envelope, len(matches), false)
for _, subscriber := range matches {
_ = subscriber.Send(model.ServerMessage{
Type: "event",
Envelope: &envelope,
})
}
return PublishResult{
Matched: len(matches),
Stored: true,
Dropped: false,
}
}
func (h *Hub) Snapshot(channelID string, deviceID string) (json.RawMessage, bool) {
h.mu.RLock()
defer h.mu.RUnlock()
envelope, ok := h.latestState[latestStateKey(channelID, deviceID)]
if !ok {
return nil, false
}
data, err := json.Marshal(envelope)
if err != nil {
return nil, false
}
return data, true
}
func (h *Hub) Stats() (subscriberCount int, latestStateCount int) {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.subscribers), len(h.latestState)
}
func (h *Hub) LatestStates() []model.Envelope {
h.mu.RLock()
defer h.mu.RUnlock()
items := make([]model.Envelope, 0, len(h.latestState))
for _, envelope := range h.latestState {
items = append(items, envelope)
}
return items
}
func (h *Hub) TrafficSnapshot() TrafficSnapshot {
h.mu.RLock()
defer h.mu.RUnlock()
topics := make([]TopicTrafficItem, 0, len(h.stats.Topics))
for topic, counter := range h.stats.Topics {
topics = append(topics, TopicTrafficItem{
Topic: topic,
Published: counter.Published,
Dropped: counter.Dropped,
Fanout: counter.Fanout,
})
}
channels := make([]ChannelTrafficItem, 0, len(h.stats.Channels))
for channelID, counter := range h.stats.Channels {
channels = append(channels, ChannelTrafficItem{
ChannelID: channelID,
Published: counter.Published,
Dropped: counter.Dropped,
Fanout: counter.Fanout,
})
}
return TrafficSnapshot{
Published: h.stats.Published,
Dropped: h.stats.Dropped,
Fanout: h.stats.Fanout,
Topics: topics,
Channels: channels,
}
}
func (h *Hub) SubscribeLive(buffer int) (uint64, <-chan model.Envelope) {
if buffer <= 0 {
buffer = 32
}
h.mu.Lock()
defer h.mu.Unlock()
h.nextLiveID += 1
id := h.nextLiveID
ch := make(chan model.Envelope, buffer)
h.liveFeeds[id] = ch
return id, ch
}
func (h *Hub) UnsubscribeLive(id uint64) {
h.mu.Lock()
defer h.mu.Unlock()
ch, ok := h.liveFeeds[id]
if !ok {
return
}
delete(h.liveFeeds, id)
close(ch)
}
func (h *Hub) storeLatest(envelope model.Envelope) {
if envelope.Target.DeviceID == "" {
return
}
h.mu.Lock()
defer h.mu.Unlock()
if len(h.latestState) >= h.maxLatest {
for key := range h.latestState {
delete(h.latestState, key)
break
}
}
h.latestState[latestStateKey(envelope.Target.ChannelID, envelope.Target.DeviceID)] = envelope
}
func (h *Hub) publishLive(envelope model.Envelope) {
h.mu.RLock()
feeds := make([]chan model.Envelope, 0, len(h.liveFeeds))
for _, ch := range h.liveFeeds {
feeds = append(feeds, ch)
}
h.mu.RUnlock()
for _, ch := range feeds {
select {
case ch <- envelope:
default:
}
}
}
func (h *Hub) recordTraffic(envelope model.Envelope, matched int, dropped bool) {
h.mu.Lock()
defer h.mu.Unlock()
h.stats.Published += 1
h.stats.Fanout += uint64(matched)
if dropped {
h.stats.Dropped += 1
}
topicKey := envelope.Topic
if topicKey == "" {
topicKey = "--"
}
topicCounter := h.stats.Topics[topicKey]
if topicCounter == nil {
topicCounter = &trafficCounter{}
h.stats.Topics[topicKey] = topicCounter
}
topicCounter.Published += 1
topicCounter.Fanout += uint64(matched)
if dropped {
topicCounter.Dropped += 1
}
channelKey := envelope.Target.ChannelID
if channelKey == "" {
channelKey = "--"
}
channelCounter := h.stats.Channels[channelKey]
if channelCounter == nil {
channelCounter = &trafficCounter{}
h.stats.Channels[channelKey] = channelCounter
}
channelCounter.Published += 1
channelCounter.Fanout += uint64(matched)
if dropped {
channelCounter.Dropped += 1
}
}
func matchesAny(envelope model.Envelope, subscriptions []model.Subscription) bool {
if len(subscriptions) == 0 {
return false
}
for _, subscription := range subscriptions {
if matches(envelope, subscription) {
return true
}
}
return false
}
func matches(envelope model.Envelope, subscription model.Subscription) bool {
if subscription.ChannelID != "" && subscription.ChannelID != envelope.Target.ChannelID {
return false
}
if subscription.DeviceID != "" && subscription.DeviceID != envelope.Target.DeviceID {
return false
}
if subscription.GroupID != "" && subscription.GroupID != envelope.Target.GroupID {
return false
}
if subscription.Topic != "" && subscription.Topic != envelope.Topic {
return false
}
return true
}
func latestStateKey(channelID string, deviceID string) string {
if channelID == "" {
return deviceID
}
return channelID + "::" + deviceID
}

View File

@@ -0,0 +1,109 @@
package session
import (
"sync"
"sync/atomic"
"time"
"realtime-gateway/internal/model"
)
type Session struct {
ID string
Role model.Role
Authenticated bool
ChannelID string
Subscriptions []model.Subscription
CreatedAt time.Time
}
type Snapshot struct {
ID string `json:"id"`
Role model.Role `json:"role"`
Authenticated bool `json:"authenticated"`
ChannelID string `json:"channelId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Subscriptions []model.Subscription `json:"subscriptions"`
}
type Manager struct {
mu sync.RWMutex
sequence atomic.Uint64
sessions map[string]*Session
}
func NewManager() *Manager {
return &Manager{
sessions: make(map[string]*Session),
}
}
func (m *Manager) Create() *Session {
id := m.sequence.Add(1)
session := &Session{
ID: formatSessionID(id),
Role: model.RoleConsumer,
CreatedAt: time.Now(),
}
m.mu.Lock()
m.sessions[session.ID] = session
m.mu.Unlock()
return session
}
func (m *Manager) Delete(sessionID string) {
m.mu.Lock()
delete(m.sessions, sessionID)
m.mu.Unlock()
}
func (m *Manager) Get(sessionID string) (*Session, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
session, ok := m.sessions[sessionID]
return session, ok
}
func (m *Manager) Count() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.sessions)
}
func (m *Manager) List() []Snapshot {
m.mu.RLock()
defer m.mu.RUnlock()
snapshots := make([]Snapshot, 0, len(m.sessions))
for _, current := range m.sessions {
subscriptions := append([]model.Subscription(nil), current.Subscriptions...)
snapshots = append(snapshots, Snapshot{
ID: current.ID,
Role: current.Role,
Authenticated: current.Authenticated,
ChannelID: current.ChannelID,
CreatedAt: current.CreatedAt,
Subscriptions: subscriptions,
})
}
return snapshots
}
func formatSessionID(id uint64) string {
return "sess-" + itoa(id)
}
func itoa(v uint64) string {
if v == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
return string(buf[i:])
}

View File

@@ -27,6 +27,122 @@ npm run mock-gps-sim
- 上传轨迹文件回放GPX / KML / GeoJSON
- 路径回放
- 速度、频率、精度调节
- 可选桥接到新实时网关
## 桥接到新网关
旧模拟器现在支持保留原有本地广播链路的同时,把数据旁路转发到新的 Go 实时网关。
默认行为:
- 小程序仍可继续连接 `ws://127.0.0.1:17865/mock-gps`
- 页面里可以直接配置并启用新网关桥接
- 环境变量只作为服务启动时的默认值
### 页面里直接配置
启动模拟器后,打开:
```text
http://127.0.0.1:17865/
```
在“新网关桥接”区域可以直接配置:
- 是否启用桥接
- 网关地址
- Producer Token
- Channel ID
- 目标 Device ID
- Group ID
- Source ID
- Source Mode
- 本地桥接预设
点“应用桥接配置”后立即生效,不需要重启模拟器。
预设说明:
- 预设保存在浏览器本地存储
- 适合多人联调时快速切换 `deviceId / groupId / sourceId`
- “套用预设”只会填入表单,不会自动提交到服务端
- 需要再点一次“应用桥接配置”才会真正切换运行时桥接目标
### PowerShell 启动示例
在仓库根目录执行:
```powershell
$env:MOCK_SIM_GATEWAY_ENABLED='1'
$env:MOCK_SIM_GATEWAY_URL='ws://127.0.0.1:18080/ws'
$env:MOCK_SIM_GATEWAY_TOKEN='dev-producer-token'
$env:MOCK_SIM_GATEWAY_CHANNEL_ID=''
$env:MOCK_SIM_GATEWAY_DEVICE_ID='child-001'
$env:MOCK_SIM_GATEWAY_SOURCE_ID='mock-gps-sim-a'
npm run mock-gps-sim
```
如果你使用新网关管理台创建的 `channel`,则要这样填:
```powershell
$env:MOCK_SIM_GATEWAY_ENABLED='1'
$env:MOCK_SIM_GATEWAY_URL='ws://127.0.0.1:18080/ws'
$env:MOCK_SIM_GATEWAY_TOKEN='<producerToken>'
$env:MOCK_SIM_GATEWAY_CHANNEL_ID='<channelId>'
$env:MOCK_SIM_GATEWAY_DEVICE_ID='child-001'
npm run mock-gps-sim
```
说明:
- 不填 `MOCK_SIM_GATEWAY_CHANNEL_ID` 时,旧模拟器走老的 `authenticate` 模式
- 填了 `MOCK_SIM_GATEWAY_CHANNEL_ID` 时,旧模拟器自动走 `join_channel` 模式
- 管理台里复制出来的 `producerToken` 只能和对应的 `channelId` 配套使用
### 可用环境变量
- `MOCK_SIM_GATEWAY_ENABLED`
- `1` 表示启用桥接
- `MOCK_SIM_GATEWAY_URL`
- 新网关地址,默认 `ws://127.0.0.1:18080/ws`
- `MOCK_SIM_GATEWAY_TOKEN`
- Producer token默认 `dev-producer-token`
- `MOCK_SIM_GATEWAY_CHANNEL_ID`
- 可选 channel id填写后会改走 `join_channel`
- `MOCK_SIM_GATEWAY_DEVICE_ID`
- 转发目标 `deviceId`,默认 `child-001`
- `MOCK_SIM_GATEWAY_GROUP_ID`
- 可选 `groupId`
- `MOCK_SIM_GATEWAY_SOURCE_ID`
- source id默认 `mock-gps-sim`
- `MOCK_SIM_GATEWAY_SOURCE_MODE`
- source mode默认 `mock`
- `MOCK_SIM_GATEWAY_RECONNECT_MS`
- 断线重连间隔,默认 `3000`
### 桥接状态查看
启动后可查看:
```text
http://127.0.0.1:17865/bridge-status
```
桥接配置接口:
```text
http://127.0.0.1:17865/bridge-config
```
返回内容包含:
- 是否启用桥接
- 是否已连上新网关
- 是否已认证
- 最近发送 topic
- 已发送条数
- 丢弃条数
- 最近错误
## 加载自己的地图

View File

@@ -73,6 +73,66 @@
</label>
</section>
<section class="group">
<div class="group__title">新网关桥接</div>
<div id="gatewayBridgeStatus" class="group__status">未启用</div>
<div id="gatewayBridgeTarget" class="group__status">目标设备: --</div>
<div id="gatewayBridgeLast" class="group__status">最近状态: --</div>
<label class="field">
<span>桥接预设</span>
<select id="gatewayBridgePresetSelect">
<option value="">选择预设</option>
</select>
</label>
<label class="field">
<span>预设名称</span>
<input id="gatewayBridgePresetNameInput" type="text" placeholder="例如:家长端-A / 场控-B">
</label>
<div class="row">
<button id="applyGatewayBridgePresetBtn" class="btn">套用预设</button>
<button id="saveGatewayBridgePresetBtn" class="btn">保存预设</button>
</div>
<div class="row">
<button id="deleteGatewayBridgePresetBtn" class="btn">删除预设</button>
</div>
<label class="field field--check">
<input id="gatewayBridgeEnabledInput" type="checkbox">
<span>启用新网关桥接</span>
</label>
<label class="field">
<span>网关地址</span>
<input id="gatewayBridgeUrlInput" type="text" placeholder="ws://127.0.0.1:18080/ws">
</label>
<label class="field">
<span>Producer Token / Channel Token</span>
<input id="gatewayBridgeTokenInput" type="text" placeholder="producerToken 或 dev-producer-token">
</label>
<label class="field">
<span>Channel ID</span>
<input id="gatewayBridgeChannelIdInput" type="text" placeholder="ch-xxxx">
</label>
<label class="field">
<span>目标 Device ID</span>
<input id="gatewayBridgeDeviceIdInput" type="text" placeholder="child-001">
</label>
<label class="field">
<span>目标 Group ID</span>
<input id="gatewayBridgeGroupIdInput" type="text" placeholder="class-a">
</label>
<label class="field">
<span>Source ID</span>
<input id="gatewayBridgeSourceIdInput" type="text" placeholder="mock-gps-sim">
</label>
<label class="field">
<span>Source Mode</span>
<input id="gatewayBridgeSourceModeInput" type="text" placeholder="mock">
</label>
<div class="row">
<button id="applyGatewayBridgeConfigBtn" class="btn btn--primary">应用桥接配置</button>
<button id="reloadGatewayBridgeConfigBtn" class="btn">重新读取</button>
</div>
</section>
<section class="group">
<div class="group__title">心率模拟</div>
<div id="heartRateStatus" class="group__status">心率模拟待命</div>

View File

@@ -4,6 +4,13 @@
const DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
const PROXY_BASE_URL = `${location.origin}/proxy?url=`
const WS_URL = `ws://${location.hostname}:17865/mock-gps`
const DEFAULT_GATEWAY_BRIDGE_URL = 'ws://127.0.0.1:18080/ws'
const LEGACY_GATEWAY_BRIDGE_URLS = new Set([
'ws://127.0.0.1:8080/ws',
'ws://localhost:8080/ws',
])
const BRIDGE_CONFIG_STORAGE_KEY = 'mock-gps-sim.bridge-config'
const BRIDGE_PRESETS_STORAGE_KEY = 'mock-gps-sim.bridge-presets'
const map = L.map('map').setView(DEFAULT_CENTER, 16)
let tileLayer = createTileLayer(DEFAULT_TILE_URL, {
@@ -52,6 +59,13 @@
heartRateSampleStartedAt: 0,
loadedCourse: null,
resourceLoading: false,
bridgeEnabled: false,
bridgeConnected: false,
bridgeAuthenticated: false,
bridgeTargetText: '--',
bridgeLastStatusText: '--',
bridgeConfigSaving: false,
bridgePresets: [],
}
const elements = {
@@ -70,6 +84,24 @@
courseJumpList: document.getElementById('courseJumpList'),
realtimeStatus: document.getElementById('realtimeStatus'),
lastSendStatus: document.getElementById('lastSendStatus'),
gatewayBridgeStatus: document.getElementById('gatewayBridgeStatus'),
gatewayBridgeTarget: document.getElementById('gatewayBridgeTarget'),
gatewayBridgeLast: document.getElementById('gatewayBridgeLast'),
gatewayBridgePresetSelect: document.getElementById('gatewayBridgePresetSelect'),
gatewayBridgePresetNameInput: document.getElementById('gatewayBridgePresetNameInput'),
applyGatewayBridgePresetBtn: document.getElementById('applyGatewayBridgePresetBtn'),
saveGatewayBridgePresetBtn: document.getElementById('saveGatewayBridgePresetBtn'),
deleteGatewayBridgePresetBtn: document.getElementById('deleteGatewayBridgePresetBtn'),
gatewayBridgeEnabledInput: document.getElementById('gatewayBridgeEnabledInput'),
gatewayBridgeUrlInput: document.getElementById('gatewayBridgeUrlInput'),
gatewayBridgeTokenInput: document.getElementById('gatewayBridgeTokenInput'),
gatewayBridgeChannelIdInput: document.getElementById('gatewayBridgeChannelIdInput'),
gatewayBridgeDeviceIdInput: document.getElementById('gatewayBridgeDeviceIdInput'),
gatewayBridgeGroupIdInput: document.getElementById('gatewayBridgeGroupIdInput'),
gatewayBridgeSourceIdInput: document.getElementById('gatewayBridgeSourceIdInput'),
gatewayBridgeSourceModeInput: document.getElementById('gatewayBridgeSourceModeInput'),
applyGatewayBridgeConfigBtn: document.getElementById('applyGatewayBridgeConfigBtn'),
reloadGatewayBridgeConfigBtn: document.getElementById('reloadGatewayBridgeConfigBtn'),
playbackStatus: document.getElementById('playbackStatus'),
heartRateStatus: document.getElementById('heartRateStatus'),
lastHeartRateStatus: document.getElementById('lastHeartRateStatus'),
@@ -190,6 +222,23 @@
elements.lastSendStatus.textContent = `最近发送: ${state.lastSentText}`
elements.lastHeartRateStatus.textContent = `最近发送: ${state.lastHeartRateSentText}`
elements.resourceDetail.textContent = state.lastResourceDetailText
elements.gatewayBridgeTarget.textContent = `目标设备: ${state.bridgeTargetText}`
elements.gatewayBridgeLast.textContent = `最近状态: ${state.bridgeLastStatusText}`
elements.applyGatewayBridgeConfigBtn.disabled = state.bridgeConfigSaving
elements.reloadGatewayBridgeConfigBtn.disabled = state.bridgeConfigSaving
elements.applyGatewayBridgePresetBtn.disabled = state.bridgeConfigSaving || !elements.gatewayBridgePresetSelect.value
elements.saveGatewayBridgePresetBtn.disabled = state.bridgeConfigSaving
elements.deleteGatewayBridgePresetBtn.disabled = state.bridgeConfigSaving || !elements.gatewayBridgePresetSelect.value
if (!state.bridgeEnabled) {
elements.gatewayBridgeStatus.textContent = '未启用'
} else if (state.bridgeConnected && state.bridgeAuthenticated) {
elements.gatewayBridgeStatus.textContent = '已连接并已认证'
} else if (state.bridgeConnected) {
elements.gatewayBridgeStatus.textContent = '已连接,等待认证'
} else {
elements.gatewayBridgeStatus.textContent = '已启用,未连接'
}
if (state.connected && state.streaming) {
elements.realtimeStatus.textContent = `桥接已连接,正在以 ${elements.hzSelect.value} Hz 连续发送`
@@ -224,6 +273,204 @@
}
}
function bridgeConfigFromServerPayload(payload) {
const config = payload && payload.config ? payload.config : {}
return {
enabled: Boolean(config.enabled),
url: normalizeGatewayBridgeUrl(typeof config.url === 'string' ? config.url : ''),
token: typeof config.token === 'string' ? config.token : '',
channelId: typeof config.channelId === 'string' ? config.channelId : '',
deviceId: typeof config.deviceId === 'string' ? config.deviceId : '',
groupId: typeof config.groupId === 'string' ? config.groupId : '',
sourceId: typeof config.sourceId === 'string' ? config.sourceId : '',
sourceMode: typeof config.sourceMode === 'string' ? config.sourceMode : 'mock',
}
}
function normalizeGatewayBridgeUrl(value) {
const next = String(value || '').trim()
if (!next) {
return DEFAULT_GATEWAY_BRIDGE_URL
}
if (LEGACY_GATEWAY_BRIDGE_URLS.has(next)) {
return DEFAULT_GATEWAY_BRIDGE_URL
}
return next
}
function getBridgeConfigDraft() {
try {
const raw = window.localStorage.getItem(BRIDGE_CONFIG_STORAGE_KEY)
if (!raw) {
return null
}
const parsed = JSON.parse(raw)
return {
enabled: Boolean(parsed.enabled),
url: normalizeGatewayBridgeUrl(typeof parsed.url === 'string' ? parsed.url : ''),
token: typeof parsed.token === 'string' ? parsed.token : '',
channelId: typeof parsed.channelId === 'string' ? parsed.channelId : '',
deviceId: typeof parsed.deviceId === 'string' ? parsed.deviceId : '',
groupId: typeof parsed.groupId === 'string' ? parsed.groupId : '',
sourceId: typeof parsed.sourceId === 'string' ? parsed.sourceId : '',
sourceMode: typeof parsed.sourceMode === 'string' ? parsed.sourceMode : 'mock',
}
} catch (_error) {
return null
}
}
function loadBridgePresets() {
try {
const raw = window.localStorage.getItem(BRIDGE_PRESETS_STORAGE_KEY)
if (!raw) {
return []
}
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) {
return []
}
return parsed
.map((item) => {
const config = item && item.config ? item.config : {}
return {
name: item && typeof item.name === 'string' ? item.name.trim() : '',
config: {
enabled: Boolean(config.enabled),
url: normalizeGatewayBridgeUrl(typeof config.url === 'string' ? config.url : ''),
token: typeof config.token === 'string' ? config.token : '',
channelId: typeof config.channelId === 'string' ? config.channelId : '',
deviceId: typeof config.deviceId === 'string' ? config.deviceId : '',
groupId: typeof config.groupId === 'string' ? config.groupId : '',
sourceId: typeof config.sourceId === 'string' ? config.sourceId : '',
sourceMode: typeof config.sourceMode === 'string' ? config.sourceMode : 'mock',
},
}
})
.filter((item) => item.name)
} catch (_error) {
return []
}
}
function saveBridgePresets() {
try {
window.localStorage.setItem(BRIDGE_PRESETS_STORAGE_KEY, JSON.stringify(state.bridgePresets))
} catch (_error) {
// noop
}
}
function renderBridgePresetOptions(selectedName) {
const currentValue = typeof selectedName === 'string'
? selectedName
: elements.gatewayBridgePresetSelect.value
elements.gatewayBridgePresetSelect.innerHTML = '<option value="">选择预设</option>'
state.bridgePresets.forEach((preset) => {
const option = document.createElement('option')
option.value = preset.name
option.textContent = preset.name
if (preset.name === currentValue) {
option.selected = true
}
elements.gatewayBridgePresetSelect.appendChild(option)
})
}
function saveBridgeConfigDraft(config) {
try {
window.localStorage.setItem(BRIDGE_CONFIG_STORAGE_KEY, JSON.stringify({
...config,
url: normalizeGatewayBridgeUrl(config && config.url),
}))
} catch (_error) {
// noop
}
}
function fillBridgeConfigForm(config) {
elements.gatewayBridgeEnabledInput.checked = Boolean(config.enabled)
elements.gatewayBridgeUrlInput.value = normalizeGatewayBridgeUrl(config && config.url)
elements.gatewayBridgeTokenInput.value = config.token || ''
elements.gatewayBridgeChannelIdInput.value = config.channelId || ''
elements.gatewayBridgeDeviceIdInput.value = config.deviceId || ''
elements.gatewayBridgeGroupIdInput.value = config.groupId || ''
elements.gatewayBridgeSourceIdInput.value = config.sourceId || ''
elements.gatewayBridgeSourceModeInput.value = config.sourceMode || 'mock'
}
function readBridgeConfigForm() {
return {
enabled: elements.gatewayBridgeEnabledInput.checked,
url: normalizeGatewayBridgeUrl(elements.gatewayBridgeUrlInput.value),
token: String(elements.gatewayBridgeTokenInput.value || '').trim(),
channelId: String(elements.gatewayBridgeChannelIdInput.value || '').trim(),
deviceId: String(elements.gatewayBridgeDeviceIdInput.value || '').trim(),
groupId: String(elements.gatewayBridgeGroupIdInput.value || '').trim(),
sourceId: String(elements.gatewayBridgeSourceIdInput.value || '').trim(),
sourceMode: String(elements.gatewayBridgeSourceModeInput.value || '').trim() || 'mock',
}
}
function selectedBridgePreset() {
const name = String(elements.gatewayBridgePresetSelect.value || '').trim()
if (!name) {
return null
}
return state.bridgePresets.find((item) => item.name === name) || null
}
function applyBridgePresetToForm() {
const preset = selectedBridgePreset()
if (!preset) {
log('未选择桥接预设')
return
}
fillBridgeConfigForm(preset.config)
elements.gatewayBridgePresetNameInput.value = preset.name
saveBridgeConfigDraft(preset.config)
updateUiState()
log(`已载入桥接预设: ${preset.name}`)
}
function saveCurrentBridgePreset() {
const name = String(elements.gatewayBridgePresetNameInput.value || '').trim()
if (!name) {
log('请先输入预设名称')
return
}
const config = readBridgeConfigForm()
const nextPreset = { name, config }
const existingIndex = state.bridgePresets.findIndex((item) => item.name === name)
if (existingIndex >= 0) {
state.bridgePresets.splice(existingIndex, 1, nextPreset)
} else {
state.bridgePresets.push(nextPreset)
state.bridgePresets.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
}
saveBridgePresets()
renderBridgePresetOptions(name)
log(`已保存桥接预设: ${name}`)
updateUiState()
}
function deleteSelectedBridgePreset() {
const preset = selectedBridgePreset()
if (!preset) {
log('未选择桥接预设')
return
}
state.bridgePresets = state.bridgePresets.filter((item) => item.name !== preset.name)
saveBridgePresets()
renderBridgePresetOptions('')
if (elements.gatewayBridgePresetNameInput.value.trim() === preset.name) {
elements.gatewayBridgePresetNameInput.value = ''
}
log(`已删除桥接预设: ${preset.name}`)
updateUiState()
}
function connectSocket() {
if (state.socket && (state.socket.readyState === WebSocket.OPEN || state.socket.readyState === WebSocket.CONNECTING)) {
return
@@ -265,6 +512,100 @@
})
}
async function refreshGatewayBridgeStatus() {
try {
const response = await fetch('/bridge-status', {
cache: 'no-store',
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const status = await response.json()
state.bridgeEnabled = Boolean(status.enabled)
state.bridgeConnected = Boolean(status.connected)
state.bridgeAuthenticated = Boolean(status.authenticated)
if (status.channelId) {
state.bridgeTargetText = `${status.channelId}${status.deviceId ? ` / ${status.deviceId}` : ''}${status.groupId ? ` / ${status.groupId}` : ''}`
} else {
state.bridgeTargetText = status.deviceId
? `${status.deviceId}${status.groupId ? ` / ${status.groupId}` : ''}`
: '--'
}
state.bridgeLastStatusText = status.lastError
? `错误: ${status.lastError}`
: status.lastSentAt
? `${status.lastSentTopic || 'unknown'} @ ${formatClockTime(status.lastSentAt)}`
: '待命'
updateUiState()
} catch (_error) {
state.bridgeEnabled = false
state.bridgeConnected = false
state.bridgeAuthenticated = false
state.bridgeTargetText = '--'
state.bridgeLastStatusText = '状态读取失败'
updateUiState()
}
}
async function loadGatewayBridgeConfig(options) {
const preserveForm = Boolean(options && options.preserveForm)
const response = await fetch('/bridge-config', {
cache: 'no-store',
})
if (!response.ok) {
throw new Error(`桥接配置读取失败: HTTP ${response.status}`)
}
const payload = await response.json()
if (!preserveForm) {
fillBridgeConfigForm(bridgeConfigFromServerPayload(payload))
}
return payload
}
async function applyGatewayBridgeConfig() {
const config = readBridgeConfigForm()
if (!config.url) {
log('桥接配置缺少网关地址')
return
}
if (!config.deviceId) {
log('桥接配置缺少目标 Device ID')
return
}
if (!config.sourceId) {
log('桥接配置缺少 Source ID')
return
}
state.bridgeConfigSaving = true
updateUiState()
try {
const response = await fetch('/bridge-config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
})
const payload = await response.json()
if (!response.ok) {
throw new Error(payload && payload.error ? payload.error : `HTTP ${response.status}`)
}
saveBridgeConfigDraft(config)
fillBridgeConfigForm(bridgeConfigFromServerPayload(payload))
await refreshGatewayBridgeStatus()
log(`已应用新网关桥接配置 -> ${config.deviceId}`)
} catch (error) {
log(error && error.message ? error.message : '桥接配置应用失败')
} finally {
state.bridgeConfigSaving = false
updateUiState()
}
}
function proxyUrl(targetUrl) {
return `${PROXY_BASE_URL}${encodeURIComponent(targetUrl)}`
}
@@ -1257,6 +1598,24 @@
})
elements.connectBtn.addEventListener('click', connectSocket)
elements.applyGatewayBridgePresetBtn.addEventListener('click', applyBridgePresetToForm)
elements.saveGatewayBridgePresetBtn.addEventListener('click', saveCurrentBridgePreset)
elements.deleteGatewayBridgePresetBtn.addEventListener('click', deleteSelectedBridgePreset)
elements.gatewayBridgePresetSelect.addEventListener('change', () => {
const preset = selectedBridgePreset()
elements.gatewayBridgePresetNameInput.value = preset ? preset.name : ''
updateUiState()
})
elements.applyGatewayBridgeConfigBtn.addEventListener('click', applyGatewayBridgeConfig)
elements.reloadGatewayBridgeConfigBtn.addEventListener('click', async () => {
try {
await loadGatewayBridgeConfig()
await refreshGatewayBridgeStatus()
log('已重新读取桥接配置')
} catch (error) {
log(error && error.message ? error.message : '桥接配置读取失败')
}
})
elements.importTrackBtn.addEventListener('click', () => {
elements.trackFileInput.click()
})
@@ -1335,6 +1694,25 @@
updateReadout()
setSocketBadge(false)
setResourceStatus('支持直接载入 game.json也支持单独填瓦片模板和 KML 地址。', null)
state.bridgePresets = loadBridgePresets()
renderBridgePresetOptions('')
updateUiState()
const draftBridgeConfig = getBridgeConfigDraft()
if (draftBridgeConfig) {
fillBridgeConfigForm(draftBridgeConfig)
}
loadGatewayBridgeConfig({ preserveForm: Boolean(draftBridgeConfig) })
.then(async () => {
if (draftBridgeConfig) {
log('已恢复上次桥接配置草稿,可直接点“应用桥接配置”')
}
await refreshGatewayBridgeStatus()
})
.catch((error) => {
log(error && error.message ? error.message : '桥接配置读取失败')
refreshGatewayBridgeStatus()
})
refreshGatewayBridgeStatus()
window.setInterval(refreshGatewayBridgeStatus, 3000)
connectSocket()
})()

View File

@@ -1,13 +1,29 @@
const http = require('http')
const fs = require('fs')
const path = require('path')
const { WebSocketServer } = require('ws')
const WebSocket = require('ws')
const { WebSocketServer } = WebSocket
const HOST = '0.0.0.0'
const PORT = 17865
const WS_PATH = '/mock-gps'
const PROXY_PATH = '/proxy'
const BRIDGE_STATUS_PATH = '/bridge-status'
const BRIDGE_CONFIG_PATH = '/bridge-config'
const PUBLIC_DIR = path.join(__dirname, 'public')
const DEFAULT_GATEWAY_BRIDGE_URL = 'ws://127.0.0.1:18080/ws'
const INITIAL_BRIDGE_CONFIG = {
enabled: process.env.MOCK_SIM_GATEWAY_ENABLED === '1',
url: process.env.MOCK_SIM_GATEWAY_URL || DEFAULT_GATEWAY_BRIDGE_URL,
token: process.env.MOCK_SIM_GATEWAY_TOKEN || 'dev-producer-token',
channelId: process.env.MOCK_SIM_GATEWAY_CHANNEL_ID || '',
deviceId: process.env.MOCK_SIM_GATEWAY_DEVICE_ID || 'child-001',
groupId: process.env.MOCK_SIM_GATEWAY_GROUP_ID || '',
sourceId: process.env.MOCK_SIM_GATEWAY_SOURCE_ID || 'mock-gps-sim',
sourceMode: process.env.MOCK_SIM_GATEWAY_SOURCE_MODE || 'mock',
reconnectMs: Math.max(1000, Number(process.env.MOCK_SIM_GATEWAY_RECONNECT_MS) || 3000),
}
function getContentType(filePath) {
const ext = path.extname(filePath).toLowerCase()
@@ -29,6 +45,15 @@ function getContentType(filePath) {
return 'text/plain; charset=utf-8'
}
function respondJson(response, statusCode, payload) {
response.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*',
})
response.end(JSON.stringify(payload))
}
function serveStatic(requestPath, response) {
const safePath = requestPath === '/' ? '/index.html' : requestPath
const resolvedPath = path.normalize(path.join(PUBLIC_DIR, safePath))
@@ -96,12 +121,379 @@ async function handleProxyRequest(request, response) {
}
}
async function readJsonBody(request) {
return new Promise((resolve, reject) => {
const chunks = []
request.on('data', (chunk) => {
chunks.push(chunk)
})
request.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8').trim()
if (!raw) {
resolve({})
return
}
try {
resolve(JSON.parse(raw))
} catch (error) {
reject(error)
}
})
request.on('error', reject)
})
}
function normalizeBridgeConfig(input, currentConfig) {
const source = input || {}
const fallback = currentConfig || INITIAL_BRIDGE_CONFIG
return {
enabled: typeof source.enabled === 'boolean' ? source.enabled : fallback.enabled,
url: typeof source.url === 'string' && source.url.trim() ? source.url.trim() : fallback.url,
token: typeof source.token === 'string' ? source.token.trim() : fallback.token,
channelId: typeof source.channelId === 'string' ? source.channelId.trim() : fallback.channelId,
deviceId: typeof source.deviceId === 'string' && source.deviceId.trim() ? source.deviceId.trim() : fallback.deviceId,
groupId: typeof source.groupId === 'string' ? source.groupId.trim() : fallback.groupId,
sourceId: typeof source.sourceId === 'string' && source.sourceId.trim() ? source.sourceId.trim() : fallback.sourceId,
sourceMode: typeof source.sourceMode === 'string' && source.sourceMode.trim() ? source.sourceMode.trim() : fallback.sourceMode,
reconnectMs: Math.max(1000, Number(source.reconnectMs) || fallback.reconnectMs),
}
}
function createGatewayBridge() {
const bridgeState = {
config: { ...INITIAL_BRIDGE_CONFIG },
socket: null,
connecting: false,
connected: false,
authenticated: false,
reconnectTimer: 0,
lastError: '',
lastSentAt: 0,
lastSentTopic: '',
sentCount: 0,
droppedCount: 0,
}
function logBridge(message) {
console.log(`[gateway-bridge] ${message}`)
}
function clearReconnectTimer() {
if (!bridgeState.reconnectTimer) {
return
}
clearTimeout(bridgeState.reconnectTimer)
bridgeState.reconnectTimer = 0
}
function scheduleReconnect() {
if (!bridgeState.config.enabled || bridgeState.reconnectTimer) {
return
}
bridgeState.reconnectTimer = setTimeout(() => {
bridgeState.reconnectTimer = 0
connect()
}, bridgeState.config.reconnectMs)
}
function resetSocketState() {
bridgeState.socket = null
bridgeState.connecting = false
bridgeState.connected = false
bridgeState.authenticated = false
}
function handleGatewayMessage(rawMessage) {
let parsed
try {
parsed = JSON.parse(String(rawMessage))
} catch (_error) {
return
}
if (parsed.type === 'welcome') {
if (!bridgeState.socket || bridgeState.socket.readyState !== WebSocket.OPEN) {
return
}
if (bridgeState.config.channelId) {
bridgeState.socket.send(JSON.stringify({
type: 'join_channel',
role: 'producer',
channelId: bridgeState.config.channelId,
token: bridgeState.config.token,
}))
} else {
bridgeState.socket.send(JSON.stringify({
type: 'authenticate',
role: 'producer',
token: bridgeState.config.token,
}))
}
return
}
if (parsed.type === 'authenticated' || parsed.type === 'joined_channel') {
bridgeState.authenticated = true
bridgeState.lastError = ''
if (bridgeState.config.channelId) {
logBridge(`joined channel=${bridgeState.config.channelId}, device=${bridgeState.config.deviceId}, source=${bridgeState.config.sourceId}`)
} else {
logBridge(`authenticated, device=${bridgeState.config.deviceId}, source=${bridgeState.config.sourceId}`)
}
return
}
if (parsed.type === 'error') {
bridgeState.lastError = parsed.error || 'gateway error'
logBridge(`error: ${bridgeState.lastError}`)
}
}
function closeSocket() {
if (!bridgeState.socket) {
return
}
try {
bridgeState.socket.close()
} catch (_error) {
// noop
}
resetSocketState()
}
function connect() {
if (!bridgeState.config.enabled || bridgeState.connecting) {
return
}
if (bridgeState.socket && (bridgeState.socket.readyState === WebSocket.OPEN || bridgeState.socket.readyState === WebSocket.CONNECTING)) {
return
}
clearReconnectTimer()
bridgeState.connecting = true
bridgeState.lastError = ''
logBridge(`connecting to ${bridgeState.config.url}`)
const socket = new WebSocket(bridgeState.config.url)
bridgeState.socket = socket
socket.on('open', () => {
bridgeState.connecting = false
bridgeState.connected = true
logBridge('connected')
})
socket.on('message', handleGatewayMessage)
socket.on('close', () => {
const wasConnected = bridgeState.connected || bridgeState.authenticated
resetSocketState()
if (wasConnected) {
logBridge('disconnected')
}
scheduleReconnect()
})
socket.on('error', (error) => {
bridgeState.lastError = error && error.message ? error.message : 'gateway socket error'
logBridge(`socket error: ${bridgeState.lastError}`)
})
}
function toGatewayEnvelope(payload) {
if (isMockGpsPayload(payload)) {
return {
schemaVersion: 1,
messageId: `gps-${payload.timestamp}`,
timestamp: payload.timestamp,
topic: 'telemetry.location',
source: {
kind: 'producer',
id: bridgeState.config.sourceId,
mode: bridgeState.config.sourceMode,
},
target: {
channelId: bridgeState.config.channelId,
deviceId: bridgeState.config.deviceId,
groupId: bridgeState.config.groupId,
},
payload: {
lat: Number(payload.lat),
lng: Number(payload.lon),
speed: Number(payload.speedMps) || 0,
bearing: Number(payload.headingDeg) || 0,
accuracy: Number(payload.accuracyMeters) || 6,
coordSystem: 'GCJ02',
},
}
}
if (isMockHeartRatePayload(payload)) {
return {
schemaVersion: 1,
messageId: `hr-${payload.timestamp}`,
timestamp: payload.timestamp,
topic: 'telemetry.heart_rate',
source: {
kind: 'producer',
id: bridgeState.config.sourceId,
mode: bridgeState.config.sourceMode,
},
target: {
channelId: bridgeState.config.channelId,
deviceId: bridgeState.config.deviceId,
groupId: bridgeState.config.groupId,
},
payload: {
bpm: Math.max(1, Math.round(Number(payload.bpm))),
},
}
}
return null
}
function publish(payload) {
if (!bridgeState.config.enabled) {
return
}
if (!bridgeState.socket || bridgeState.socket.readyState !== WebSocket.OPEN || !bridgeState.authenticated) {
bridgeState.droppedCount += 1
connect()
return
}
const envelope = toGatewayEnvelope(payload)
if (!envelope) {
return
}
bridgeState.socket.send(JSON.stringify({
type: 'publish',
envelope,
}))
bridgeState.lastSentAt = Date.now()
bridgeState.lastSentTopic = envelope.topic
bridgeState.sentCount += 1
}
function updateConfig(nextConfigInput) {
const nextConfig = normalizeBridgeConfig(nextConfigInput, bridgeState.config)
const changed = JSON.stringify(nextConfig) !== JSON.stringify(bridgeState.config)
bridgeState.config = nextConfig
if (!changed) {
return getStatus()
}
bridgeState.lastError = ''
if (!bridgeState.config.enabled) {
clearReconnectTimer()
closeSocket()
logBridge('disabled')
return getStatus()
}
clearReconnectTimer()
closeSocket()
connect()
return getStatus()
}
function getConfig() {
return { ...bridgeState.config }
}
function getStatus() {
return {
enabled: bridgeState.config.enabled,
url: bridgeState.config.url,
connected: bridgeState.connected,
authenticated: bridgeState.authenticated,
channelId: bridgeState.config.channelId,
deviceId: bridgeState.config.deviceId,
groupId: bridgeState.config.groupId,
sourceId: bridgeState.config.sourceId,
sourceMode: bridgeState.config.sourceMode,
reconnectMs: bridgeState.config.reconnectMs,
hasToken: Boolean(bridgeState.config.token),
sentCount: bridgeState.sentCount,
droppedCount: bridgeState.droppedCount,
lastSentAt: bridgeState.lastSentAt,
lastSentTopic: bridgeState.lastSentTopic,
lastError: bridgeState.lastError,
}
}
if (bridgeState.config.enabled) {
connect()
}
return {
publish,
updateConfig,
getConfig,
getStatus,
}
}
const gatewayBridge = createGatewayBridge()
const server = http.createServer((request, response) => {
if (request.method === 'OPTIONS') {
response.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
})
response.end()
return
}
if ((request.url || '').startsWith(PROXY_PATH)) {
handleProxyRequest(request, response)
return
}
if ((request.url || '').startsWith(BRIDGE_CONFIG_PATH)) {
if (request.method === 'GET') {
respondJson(response, 200, {
config: gatewayBridge.getConfig(),
status: gatewayBridge.getStatus(),
})
return
}
if (request.method === 'POST') {
readJsonBody(request)
.then((payload) => {
const status = gatewayBridge.updateConfig(payload)
respondJson(response, 200, {
config: gatewayBridge.getConfig(),
status,
})
})
.catch((error) => {
respondJson(response, 400, {
error: error && error.message ? error.message : 'Invalid JSON body',
})
})
return
}
respondJson(response, 405, {
error: 'Method Not Allowed',
})
return
}
if ((request.url || '').startsWith(BRIDGE_STATUS_PATH)) {
respondJson(response, 200, gatewayBridge.getStatus())
return
}
serveStatic(request.url || '/', response)
})
@@ -137,6 +529,8 @@ wss.on('connection', (socket) => {
bpm: Math.max(1, Math.round(Number(parsed.bpm))),
})
gatewayBridge.publish(JSON.parse(serialized))
wss.clients.forEach((client) => {
if (client.readyState === client.OPEN) {
client.send(serialized)
@@ -161,4 +555,12 @@ server.listen(PORT, HOST, () => {
console.log(` UI: http://127.0.0.1:${PORT}/`)
console.log(` WS: ws://127.0.0.1:${PORT}${WS_PATH}`)
console.log(` Proxy: http://127.0.0.1:${PORT}${PROXY_PATH}?url=<remote-url>`)
console.log(` Bridge status: http://127.0.0.1:${PORT}${BRIDGE_STATUS_PATH}`)
console.log(` Bridge config: http://127.0.0.1:${PORT}${BRIDGE_CONFIG_PATH}`)
if (INITIAL_BRIDGE_CONFIG.enabled) {
console.log(` Gateway bridge: enabled -> ${INITIAL_BRIDGE_CONFIG.url}`)
console.log(` Gateway target device: ${INITIAL_BRIDGE_CONFIG.deviceId}`)
} else {
console.log(` Gateway bridge: disabled`)
}
})