Kaynağa Gözat

Add realtime gateway and simulator bridge

zhangyan 1 hafta önce
ebeveyn
işleme
2c0fd4c549
36 değiştirilmiş dosya ile 6852 ekleme ve 1 silme
  1. 2 0
      .gitignore
  2. 287 0
      doc/cloudflare-tunnel-dev-guide.md
  3. 123 0
      doc/gateway-mvp-task-breakdown.md
  4. 344 0
      doc/gateway-protocol-spec.md
  5. 877 0
      doc/realtime-device-gateway-architecture.md
  6. 443 0
      doc/realtime-gateway-runbook.md
  7. 149 0
      realtime-gateway/README.md
  8. 48 0
      realtime-gateway/cmd/gateway/main.go
  9. 354 0
      realtime-gateway/cmd/mock-consumer/main.go
  10. 190 0
      realtime-gateway/cmd/mock-producer/main.go
  11. 15 0
      realtime-gateway/config/consumer-gps-heart.example.json
  12. 10 0
      realtime-gateway/config/consumer-tunnel.example.json
  13. 28 0
      realtime-gateway/config/dev.json
  14. 28 0
      realtime-gateway/config/tunnel-dev.json
  15. 7 0
      realtime-gateway/deploy/cloudflared/config.example.yml
  16. 5 0
      realtime-gateway/go.mod
  17. 2 0
      realtime-gateway/go.sum
  18. 262 0
      realtime-gateway/internal/channel/manager.go
  19. 123 0
      realtime-gateway/internal/config/config.go
  20. 228 0
      realtime-gateway/internal/gateway/admin_ui.go
  21. 604 0
      realtime-gateway/internal/gateway/adminui/app.js
  22. 214 0
      realtime-gateway/internal/gateway/adminui/index.html
  23. 508 0
      realtime-gateway/internal/gateway/adminui/style.css
  24. 24 0
      realtime-gateway/internal/gateway/auth.go
  25. 58 0
      realtime-gateway/internal/gateway/channel_api.go
  26. 277 0
      realtime-gateway/internal/gateway/client.go
  27. 109 0
      realtime-gateway/internal/gateway/server.go
  28. 13 0
      realtime-gateway/internal/logging/logging.go
  29. 64 0
      realtime-gateway/internal/model/message.go
  30. 53 0
      realtime-gateway/internal/plugin/bus.go
  31. 337 0
      realtime-gateway/internal/router/hub.go
  32. 109 0
      realtime-gateway/internal/session/session.go
  33. 116 0
      tools/mock-gps-sim/README.md
  34. 60 0
      tools/mock-gps-sim/public/index.html
  35. 378 0
      tools/mock-gps-sim/public/simulator.js
  36. 403 1
      tools/mock-gps-sim/server.js

+ 2 - 0
.gitignore

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

+ 287 - 0
doc/cloudflare-tunnel-dev-guide.md

@@ -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
+
+这条路径最轻、最稳,也最符合你现在“先不正式上线”的目标。

+ 123 - 0
doc/gateway-mvp-task-breakdown.md

@@ -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 插件

+ 344 - 0
doc/gateway-protocol-spec.md

@@ -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`

+ 877 - 0
doc/realtime-device-gateway-architecture.md

@@ -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 插件完成”
+
+这套方案能同时覆盖:
+
+- 开发模拟
+- 家长端监控
+- 场控
+- 规则判定
+- 通知分发
+- 数据回放
+- 后续更多传感器接入
+
+同时又能把实时性能放在系统设计的首位。

+ 443 - 0
doc/realtime-gateway-runbook.md

@@ -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 - 0
realtime-gateway/README.md

@@ -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)

+ 48 - 0
realtime-gateway/cmd/gateway/main.go

@@ -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)
+		}
+	}
+}

+ 354 - 0
realtime-gateway/cmd/mock-consumer/main.go

@@ -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)
+}

+ 190 - 0
realtime-gateway/cmd/mock-producer/main.go

@@ -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
+}

+ 15 - 0
realtime-gateway/config/consumer-gps-heart.example.json

@@ -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"
+    }
+  ]
+}

+ 10 - 0
realtime-gateway/config/consumer-tunnel.example.json

@@ -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
+}

+ 28 - 0
realtime-gateway/config/dev.json

@@ -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
+  }
+}

+ 28 - 0
realtime-gateway/config/tunnel-dev.json

@@ -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
+  }
+}

+ 7 - 0
realtime-gateway/deploy/cloudflared/config.example.yml

@@ -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 - 0
realtime-gateway/go.mod

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

+ 2 - 0
realtime-gateway/go.sum

@@ -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=

+ 262 - 0
realtime-gateway/internal/channel/manager.go

@@ -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)
+}

+ 123 - 0
realtime-gateway/internal/config/config.go

@@ -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
+}

+ 228 - 0
realtime-gateway/internal/gateway/admin_ui.go

@@ -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,
+	}
+}

+ 604 - 0
realtime-gateway/internal/gateway/adminui/app.js

@@ -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()
+})()

+ 214 - 0
realtime-gateway/internal/gateway/adminui/index.html

@@ -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>

+ 508 - 0
realtime-gateway/internal/gateway/adminui/style.css

@@ -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;
+  }
+}

+ 24 - 0
realtime-gateway/internal/gateway/auth.go

@@ -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
+	}
+}

+ 58 - 0
realtime-gateway/internal/gateway/channel_api.go

@@ -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),
+	})
+}

+ 277 - 0
realtime-gateway/internal/gateway/client.go

@@ -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
+}

+ 109 - 0
realtime-gateway/internal/gateway/server.go

@@ -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)
+}

+ 13 - 0
realtime-gateway/internal/logging/logging.go

@@ -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)
+}

+ 64 - 0
realtime-gateway/internal/model/message.go

@@ -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"`
+}

+ 53 - 0
realtime-gateway/internal/plugin/bus.go

@@ -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)
+}

+ 337 - 0
realtime-gateway/internal/router/hub.go

@@ -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
+}

+ 109 - 0
realtime-gateway/internal/session/session.go

@@ -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:])
+}

+ 116 - 0
tools/mock-gps-sim/README.md

@@ -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
+- 已发送条数
+- 丢弃条数
+- 最近错误
 
 ## 加载自己的地图
 

+ 60 - 0
tools/mock-gps-sim/public/index.html

@@ -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>

+ 378 - 0
tools/mock-gps-sim/public/simulator.js

@@ -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()
 })()

+ 403 - 1
tools/mock-gps-sim/server.js

@@ -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`)
+  }
 })