diff --git a/.gitignore b/.gitignore index 083acd3..601ac0a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ pnpm-debug.log* *.swp .DS_Store Thumbs.db +realtime-gateway/bin/ +realtime-gateway/.tmp-gateway.* diff --git a/doc/cloudflare-tunnel-dev-guide.md b/doc/cloudflare-tunnel-dev-guide.md new file mode 100644 index 0000000..07ee4af --- /dev/null +++ b/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:///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:///ws` +5. 等你需要固定域名或更稳定的入口时,再切换 Named Tunnel + +这条路径最轻、最稳,也最符合你现在“先不正式上线”的目标。 diff --git a/doc/gateway-mvp-task-breakdown.md b/doc/gateway-mvp-task-breakdown.md new file mode 100644 index 0000000..89d9bdb --- /dev/null +++ b/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 插件 diff --git a/doc/gateway-protocol-spec.md b/doc/gateway-protocol-spec.md new file mode 100644 index 0000000..b7c6660 --- /dev/null +++ b/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` diff --git a/doc/realtime-device-gateway-architecture.md b/doc/realtime-device-gateway-architecture.md new file mode 100644 index 0000000..9f191c7 --- /dev/null +++ b/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 插件完成” + +这套方案能同时覆盖: + +- 开发模拟 +- 家长端监控 +- 场控 +- 规则判定 +- 通知分发 +- 数据回放 +- 后续更多传感器接入 + +同时又能把实时性能放在系统设计的首位。 diff --git a/doc/realtime-gateway-runbook.md b/doc/realtime-gateway-runbook.md new file mode 100644 index 0000000..d03a4f0 --- /dev/null +++ b/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":""} +``` + +适合: + +- 多人联调 +- 多会话隔离 +- `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 -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 -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. 当前建议 + +今天这个阶段,最稳的开发方式是: + +- 老模拟器继续做主生产者 +- 新网关继续做中转和观测 +- 手机端暂时不接正式消费链路 +- 先把网关本身的运行态、流量、实时查看能力做稳 + +这也是当前最省风险的组合。 diff --git a/realtime-gateway/README.md b/realtime-gateway/README.md new file mode 100644 index 0000000..7321967 --- /dev/null +++ b/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 -topic telemetry.location -count 5 +go run .\cmd\mock-consumer -channel-id ch-xxxx -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) diff --git a/realtime-gateway/cmd/gateway/main.go b/realtime-gateway/cmd/gateway/main.go new file mode 100644 index 0000000..76ccf57 --- /dev/null +++ b/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) + } + } +} diff --git a/realtime-gateway/cmd/mock-consumer/main.go b/realtime-gateway/cmd/mock-consumer/main.go new file mode 100644 index 0000000..1351aca --- /dev/null +++ b/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) +} diff --git a/realtime-gateway/cmd/mock-producer/main.go b/realtime-gateway/cmd/mock-producer/main.go new file mode 100644 index 0000000..67676aa --- /dev/null +++ b/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 +} diff --git a/realtime-gateway/config/consumer-gps-heart.example.json b/realtime-gateway/config/consumer-gps-heart.example.json new file mode 100644 index 0000000..305e5d3 --- /dev/null +++ b/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" + } + ] +} diff --git a/realtime-gateway/config/consumer-tunnel.example.json b/realtime-gateway/config/consumer-tunnel.example.json new file mode 100644 index 0000000..2f9b7f7 --- /dev/null +++ b/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 +} diff --git a/realtime-gateway/config/dev.json b/realtime-gateway/config/dev.json new file mode 100644 index 0000000..cc12aeb --- /dev/null +++ b/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 + } +} diff --git a/realtime-gateway/config/tunnel-dev.json b/realtime-gateway/config/tunnel-dev.json new file mode 100644 index 0000000..6280879 --- /dev/null +++ b/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 + } +} diff --git a/realtime-gateway/deploy/cloudflared/config.example.yml b/realtime-gateway/deploy/cloudflared/config.example.yml new file mode 100644 index 0000000..1608b7d --- /dev/null +++ b/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 diff --git a/realtime-gateway/go.mod b/realtime-gateway/go.mod new file mode 100644 index 0000000..61b9fae --- /dev/null +++ b/realtime-gateway/go.mod @@ -0,0 +1,5 @@ +module realtime-gateway + +go 1.25.1 + +require github.com/coder/websocket v1.8.14 diff --git a/realtime-gateway/go.sum b/realtime-gateway/go.sum new file mode 100644 index 0000000..c80e2f0 --- /dev/null +++ b/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= diff --git a/realtime-gateway/internal/channel/manager.go b/realtime-gateway/internal/channel/manager.go new file mode 100644 index 0000000..78e6fe1 --- /dev/null +++ b/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) +} diff --git a/realtime-gateway/internal/config/config.go b/realtime-gateway/internal/config/config.go new file mode 100644 index 0000000..7a3d354 --- /dev/null +++ b/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 +} diff --git a/realtime-gateway/internal/gateway/admin_ui.go b/realtime-gateway/internal/gateway/admin_ui.go new file mode 100644 index 0000000..beafec6 --- /dev/null +++ b/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, + } +} diff --git a/realtime-gateway/internal/gateway/adminui/app.js b/realtime-gateway/internal/gateway/adminui/app.js new file mode 100644 index 0000000..cc2cf06 --- /dev/null +++ b/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 = '
当前没有活跃会话。
' + 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('
') + : '--' + + return ` + + ${item.id || '--'} + ${item.channelId || '--'} + ${item.role || '--'} + ${item.authenticated ? 'yes' : 'no'} + ${formatTime(item.createdAt)} +
${subscriptions}
+ + ` + }).join('') + + elements.sessionsTable.innerHTML = ` + + + + + + + + + + + + ${rows} +
SessionChannelRoleAuthCreatedSubscriptions
+ ` + } + + function renderChannels(payload) { + const items = Array.isArray(payload.items) ? payload.items : [] + if (!items.length) { + elements.channelsTable.innerHTML = '
当前没有 channel。
' + return + } + + const rows = items.map((item) => ` + + ${item.id || '--'} + ${item.label || '--'} + ${item.deliveryMode || '--'} + ${item.activeProducers || 0} / ${item.activeConsumers || 0} / ${item.activeControllers || 0} + ${formatTime(item.expiresAt)} + + `).join('') + + elements.channelsTable.innerHTML = ` + + + + + + + + + + + ${rows} +
ChannelLabelModeP / C / CtrlExpires
+ ` + } + + function renderLatest(payload) { + const items = Array.isArray(payload.items) ? payload.items : [] + if (!items.length) { + elements.latestTable.innerHTML = '
当前没有 latest state。
' + return + } + + const rows = items.map((item) => ` + + ${item.deviceId || '--'} + ${item.channelId || '--'} + ${item.topic || '--'} + ${item.sourceId || '--'}${item.mode ? ` / ${item.mode}` : ''} + ${formatTime(item.timestamp)} +
${escapeHTML(JSON.stringify(item.payload || {}))}
+ + `).join('') + + elements.latestTable.innerHTML = ` + + + + + + + + + + + + ${rows} +
DeviceChannelTopicSourceTimestampPayload
+ ` + } + + function renderTrafficTable(container, columns, rows, emptyText) { + if (!rows.length) { + container.innerHTML = `
${emptyText}
` + return + } + + const header = columns.map((column) => `${column.label}`).join('') + const body = rows.map((row) => ` + ${columns.map((column) => `${column.render(row)}`).join('')} + `).join('') + + container.innerHTML = ` + + + ${header} + + ${body} +
+ ` + } + + 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) => `${escapeHTML(row.topic || '--')}` }, + { 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) => `${escapeHTML(row.channelId || '--')}` }, + { 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('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + } + + 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 = '
等待 GPS 数据...
' + elements.liveTrackLegend.innerHTML = '
暂无轨迹。
' + 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 ` + + + ` + }).join('') + + const grid = [25, 50, 75].map((ratio) => { + const x = (width * ratio) / 100 + const y = (height * ratio) / 100 + return ` + + + ` + }).join('') + + elements.liveTrack.innerHTML = ` + + + ${grid} + ${polylines} + + ` + + elements.liveTrackLegend.innerHTML = activeSeries.map((series) => { + const last = series.points[series.points.length - 1] + return ` +
+ + ${escapeHTML(series.key)} | ${formatNumber(last.lat, 6)}, ${formatNumber(last.lng, 6)} | ${series.points.length} 点 +
+ ` + }).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 = [ + `${escapeHTML(formatTime(item.timestamp))}`, + `${escapeHTML(item.topic || '--')}`, + `ch=${escapeHTML(item.channelId || '--')}`, + `device=${escapeHTML(item.deviceId || '--')}`, + `source=${escapeHTML(item.sourceId || '--')}${item.mode ? ` / ${escapeHTML(item.mode)}` : ''}`, + ].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 = '
等待实时数据...
' + elements.liveTrack.innerHTML = '
等待 GPS 数据...
' + elements.liveTrackLegend.innerHTML = '
暂无轨迹。
' + 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 = '
无法加载 channel 信息。
' + elements.sessionsTable.innerHTML = '
无法加载会话信息。
' + elements.latestTable.innerHTML = '
无法加载 latest state。
' + elements.topicTrafficTable.innerHTML = '
无法加载 topic 流量。
' + elements.channelTrafficTable.innerHTML = '
无法加载 channel 流量。
' + } + } + + 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() +})() diff --git a/realtime-gateway/internal/gateway/adminui/index.html b/realtime-gateway/internal/gateway/adminui/index.html new file mode 100644 index 0000000..d052b1c --- /dev/null +++ b/realtime-gateway/internal/gateway/adminui/index.html @@ -0,0 +1,214 @@ + + + + + + Realtime Gateway Console + + + +
+ + +
+
+
+ Router Style +

实时设备网关管理台

+
+

正在加载运行状态...

+
+ +
+
+
Sessions
+
0
+
+
+
Subscribers
+
0
+
+
+
Latest State
+
0
+
+
+
Channels
+
0
+
+
+
Published
+
0
+
+
+
Dropped
+
0
+
+
+
Fanout
+
0
+
+
+
Plugins
+
0
+
+
+ +
+
+
+
+
Channel 管理
+
创建临时通道并查看当前在线角色
+
+
+
+ + + + +
+
创建成功后,这里会显示 `channelId` 与三种 token。
+
创建结果会显示在这里。
+
+
+ +
+
+
+
会话列表
+
当前连接与订阅
+
+
+
+
+ +
+
+
+
Latest State
+
每个设备最近一条消息
+
+ +
+
+
+ +
+
+
+
流量统计
+
按 topic 和 channel 看累计发布、丢弃和扇出
+
+
+
+
+
Topic 统计
+
+
+
+
Channel 统计
+
+
+
+
+ +
+
+
+
实时数据窗口
+
直接查看网关收到的实时 GPS / 心率数据
+
+
+ + + + + +
+
+
+
Connecting
+
等待实时流...
+
+
+
+ 定位消息 + 0 +
+
+ 心率消息 + 0 +
+
+ 最后设备 + -- +
+
+ 最后主题 + -- +
+
+
+
+
轨迹预览,建议配合 `channelId / deviceId` 过滤使用
+
+
+
+
+
+
+
+
+
+
+
+ + + + diff --git a/realtime-gateway/internal/gateway/adminui/style.css b/realtime-gateway/internal/gateway/adminui/style.css new file mode 100644 index 0000000..5d193e8 --- /dev/null +++ b/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; + } +} diff --git a/realtime-gateway/internal/gateway/auth.go b/realtime-gateway/internal/gateway/auth.go new file mode 100644 index 0000000..eca5d0c --- /dev/null +++ b/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 + } +} diff --git a/realtime-gateway/internal/gateway/channel_api.go b/realtime-gateway/internal/gateway/channel_api.go new file mode 100644 index 0000000..05a737a --- /dev/null +++ b/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), + }) +} diff --git a/realtime-gateway/internal/gateway/client.go b/realtime-gateway/internal/gateway/client.go new file mode 100644 index 0000000..2ab5b7c --- /dev/null +++ b/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 +} diff --git a/realtime-gateway/internal/gateway/server.go b/realtime-gateway/internal/gateway/server.go new file mode 100644 index 0000000..8126414 --- /dev/null +++ b/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) +} diff --git a/realtime-gateway/internal/logging/logging.go b/realtime-gateway/internal/logging/logging.go new file mode 100644 index 0000000..a596f45 --- /dev/null +++ b/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) +} diff --git a/realtime-gateway/internal/model/message.go b/realtime-gateway/internal/model/message.go new file mode 100644 index 0000000..324b98d --- /dev/null +++ b/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"` +} diff --git a/realtime-gateway/internal/plugin/bus.go b/realtime-gateway/internal/plugin/bus.go new file mode 100644 index 0000000..e3aa644 --- /dev/null +++ b/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) +} diff --git a/realtime-gateway/internal/router/hub.go b/realtime-gateway/internal/router/hub.go new file mode 100644 index 0000000..42953d6 --- /dev/null +++ b/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 +} diff --git a/realtime-gateway/internal/session/session.go b/realtime-gateway/internal/session/session.go new file mode 100644 index 0000000..5572a49 --- /dev/null +++ b/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:]) +} diff --git a/tools/mock-gps-sim/README.md b/tools/mock-gps-sim/README.md index 04f9a94..08dca7a 100644 --- a/tools/mock-gps-sim/README.md +++ b/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='' +$env:MOCK_SIM_GATEWAY_CHANNEL_ID='' +$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 +- 已发送条数 +- 丢弃条数 +- 最近错误 ## 加载自己的地图 diff --git a/tools/mock-gps-sim/public/index.html b/tools/mock-gps-sim/public/index.html index 5729944..120f5a8 100644 --- a/tools/mock-gps-sim/public/index.html +++ b/tools/mock-gps-sim/public/index.html @@ -73,6 +73,66 @@ +
+
新网关桥接
+
未启用
+
目标设备: --
+
最近状态: --
+ + +
+ + +
+
+ +
+ + + + + + + + +
+ + +
+
+
心率模拟
心率模拟待命
diff --git a/tools/mock-gps-sim/public/simulator.js b/tools/mock-gps-sim/public/simulator.js index 818d0fb..1bfe151 100644 --- a/tools/mock-gps-sim/public/simulator.js +++ b/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 = '' + + 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() })() diff --git a/tools/mock-gps-sim/server.js b/tools/mock-gps-sim/server.js index fcc5bf7..9511c10 100644 --- a/tools/mock-gps-sim/server.js +++ b/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=`) + 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`) + } })