
# 控制台与自助接入

网关自带一套网页界面，部署后无需额外服务即可访问：

| 地址 | 用途 | 是否需要凭据 |
| --- | --- | --- |
| `/` | 地球视图：在三维地球上看本租户的真实资源 | 是 |
| `/docs` | 本目录下的手册，与网关同版本发布 | 否 |
| `/console` | 租户控制台；未登录时在登录按钮下展示接入概览 | 是 |
| `/app` | 地球视图兼容别名 | 是 |
| `/api-docs` | Swagger UI API 文档与试调界面 | 否 |
| `/openapi.json` | 机器可读或下载用的 OpenAPI JSON | 否 |

`/` 和 `/app` 需要先执行 `npm run build`；没有构建产物时地球视图不可用，其余页面不受影响。整套网页可用 `WEB_ENABLED=false` 关闭，只保留 API。

## 1. 三种凭据

| 角色 | 形态 | 能做什么 | 谁持有 |
| --- | --- | --- | --- |
| 管理员 | `ADMIN_API_KEY` 环境变量 | 查看和治理租户、跨租户读写、`/metrics`、全量审计 | 平台运维 |
| 租户 | `etk_…`，控制台生成 | 在**本租户内**注册和删除资源、下发命令、读事件与审计 | 客户管理员 |
| 资源 | `erk_…`，注册资源时生成 | 只能上报**自己**的遥测、订阅自己的通道 | 资源固件 |

三者互不越权：租户密钥碰不到别的租户，也读不到 `/metrics`；资源密钥不能注册资源，也不能给同租户的其他资源下发命令。密钥只保存加了 pepper 的 SHA-256 结果，明文只在生成时返回一次。

## 2. 开通一个客户

客户打开 `/signup`，通过邮件链接或 Google 证明自己控制该邮箱，然后选择租户 ID。租户记录会保存账号邮箱，管理员不能代建一个没有账号归属的租户。

账号进入控制台后，在「凭据」页按用途创建命名、限范围的 `etk_…` 程序密钥；控制台本身始终使用账号会话。

轮换和吊销的生效范围（见[运维文档](OPERATIONS.zh-CN.md)的安全清单）：新的认证立即被拒；**已经签发但未使用的票据立即作废**；**已经建立的 WebSocket / MQTT / WebTransport 会话会被主动断开**，多实例下也会跨实例断开。资源密钥是独立凭据，吊销租户密钥不会影响该租户下资源自己的连接。

## 3. 客户自助注册资源

### 本地起一个来试

```bash
ADMIN_API_KEY=admin-key CREDENTIAL_PEPPER=dev-pepper \
PUBLIC_BASE_URL=http://127.0.0.1:8787 \
ADMIN_EMAILS=admin@example.com \
MAIL_DEV_ECHO=true \
node server/index.js
```

`MAIL_DEV_ECHO=true` 时没配邮件也能用邮件链接登录——链接直接回显在响应里（非 production 下默认就是开的）。

**普通用户**：`/signup` 注册 → 跳到 `/tenant` 选一个租户 ID → 进控制台。租户 ID 在注册时**不会**一起填，因为它会出现在 MQTT 用户名和主题里，是你自己的选择。

**管理员**：把邮箱写进 `ADMIN_EMAILS`，然后**用完全一样的方式注册/登录**。表单上没有「管理员」入口——身份由环境变量决定，不由你在页面上声明。登录后没有默认租户，在「租户」页点「切换」选一个。

```
普通用户 dana@example.com    admin=false  tenantId=acme
管理员  admin@example.com    admin=true   tenantId=null   ← 登录后自己选
```

三条容易踩的：

- **改了 `ADMIN_EMAILS` 要重启**。名单是每次请求现查的，但配置在启动时读。重启后**原来的 cookie 不用重新登录**，身份立刻变。
- **把自己的账号提成管理员之后，你自己的租户不再是默认的**。归属还在（你仍然拥有它），但管理员没有默认租户，所以调 API 要点名 `?tenantId=acme`，控制台里要在「租户」页切换过去。本地试的时候建议**用两个不同的邮箱**，别把自己的用户账号提成管理员。
- **`ADMIN_API_KEY` 不能登录控制台**，它只给脚本、CI 和 `/metrics` 用。

**控制台只能用账号登录**：新账号从 `/signup` 用 Google 或邮件链接验证邮箱；验证后可以在「账号」页设置密码，之后 `/login` 也能用邮箱加密码登录。API 密钥是给程序用的，粘贴进浏览器等于给页面一个它既无法限定范围也无法作废的长期凭据，事后也看不出是谁在操作。管理员由部署方在 `ADMIN_EMAILS` 里列出真实邮箱，用自己的账号登录即可；管理员没有自己的租户，登录后在「租户」页选一个。

登录之后：

1. 在「注册新资源」里填资源 ID（必填）、名称、型号、站点和经纬度。
2. 提交后页面显示该资源的 `erk_` 密钥，**只显示这一次**，旁边就是已经填好本资源参数的接入示例（curl / MQTT / WebSocket / WebTransport，只列出本部署实际启用的协议）。
3. 把密钥烧进资源，按示例上报。

### 「审计」和「实时数据」看的不是一回事

两个都在，是因为它们回答两个问题：

| | 审计 | 实时数据 |
| --- | --- | --- |
| 回答 | **谁做了什么** | **发了什么** |
| 内容 | 动作、对象、执行者、主题、序号 | 消息体本身 |
| 消息体 | **没有，也不该有**——这是访问控制记录，不是数据仓库 | 有 |
| 时间范围 | 保留 `AUDIT_RETENTION_DAYS` 天 | 只有订阅期间发生的 |

「审计里看不到 payload」不是缺功能，但**够不到它**曾经是**缺功能**——不开着实时数据页，就没法回头知道某条消息发了什么。

现在审计页有一个**「显示消息内容」**开关，默认关：

- 关着：和以前一样，只读审计库，一次请求。
- 打开：按每行记着的**频道 + 序号**去事件流里把那条消息取回来（`GET /v1/channel-events`），按频道合并请求，不是一行一个。

消息体**没有被复制进审计库**——两者只是可以对上了。这样保留期不会错位（审计按 `AUDIT_RETENTION_DAYS`，消息按事件流自己的规则），审计的体积不会因为消息变大，而「谁能读审计」和「谁能读客户数据」仍然是两个可以分开回答的问题：取内容走的是和 MQTT 连接同一套空间判定，一把只有 `ops` 的密钥在这里也读不到默认空间。

三种入口（HTTP `telemetry.ingest`、MQTT/WebSocket `message.publish`、下发 `command.issue`）的 `resource` 格式各不相同，所以审计条目现在**直接记下频道**，而不是让读的人去反推是哪一种格式。

#### 曲线分析

再勾「曲线分析」，就把这些消息里的**数值字段**按时间画出来。**一个字段一张图**，不是一张图多条线——载荷里电压在 230、湿度在 60、rssi 在 -70，同一个 y 轴要么把两条压成一条直线，要么得加第二个刻度，而双 y 轴会让两条毫无关系的曲线看起来在「交叉」。分开画是唯一诚实的读法。只画 `number`，字符串和嵌套对象跳过。

**数据量怎么办**，这是两个问题，答案不一样：

| | 控制的是 | 手段 |
| --- | --- | --- |
| 取多少 | 一次看多长的历史 | 窗口下拉（最近 100 / 500 / 1000 条），服务端上限 1000 |
| 画多少 | 画布装得下多少 | 按像素列抽稀，点数不超过图宽 |

抽稀**不是「每 N 条取一条」**。那样点数一样能压下来，但会把单点尖峰整个删掉——而尖峰通常正是你打开这张图要找的东西。这里每个像素列保留**最小值和最大值**两点，包络线因此完整，一个采样点的突刺照样画得出来。测试里直接对比了两种做法：等间隔采样把那根尖峰丢了，抽稀没有。

**打开「实时数据」页就会自动订阅**（以前要自己找按钮点一下，于是「没人在发」和「根本没在听」看起来一模一样）。列表空的时候会写明是哪一种。

**详情太长的地方都能展开**：审计的 detail、实时数据的消息体、资源详情的最近事件——行内仍然截断（一条消息不该把表格撑宽），完整内容在下面一行里。

### 控制台的刷新机制

控制台开着的时候有**一条 WebSocket**，它属于整个控制台，不属于某一页。上面跑两种订阅：

| 订阅 | 载什么 | 谁在用 |
| --- | --- | --- |
| `watch` | `tenant-event`——持久化、带序号、可回放的**事件** | 实时数据页 |
| `watch-status` | `status`——连接中/已连接/重试第几次这类**运行时状态**，不进事件库、没有序号 | 外部 Broker 页 |

分成两种订阅是因为它们是两类东西。运行时状态活在某个实例的内存里，事后一文不值，所以不该伪装成事件塞进事件库；但它同样需要被推送，否则页面只能靠轮询或者等人点刷新。

多实例下状态通过集群总线转发——控制台连的实例，未必就是那个向外拨号的实例。

其余页面（审计、空间、组、凭据、发证、识别、租户）目前仍然是**手动刷新**。

### 地球视图的两条实时通道

登录后打开地球，默认走网关自己的 WebSocket 流；加 `?stream=mqtt` 改走**真实 MQTT 客户端**，连的是部署里给资源用的那个端口（`MQTT_WS_PORT`，`/v1/public-config` 会报出来）。两条并列，不是谁替换谁：

| | WebSocket（默认） | `?stream=mqtt` |
| --- | --- | --- |
| 覆盖 | 整个租户一条连接 | 一条连接覆盖凭据能到的所有空间（`@*`） |
| 断线 | 能补齐 | 只收连着时发生的 |
| 意义 | 更好的产品路径 | 走的是资源真正走的那条路 |

两边送的是**同一个事件对象**（有测试钉着 `eventId` 和 `sequence` 相等）。状态栏会写明当前是哪条、用的是 `etk` 还是 `ewt`。

想看真数据流又没有真资源，用 `node scripts/publish-demo.js --url … --key etk_…`：它注册若干个 `demo-*`，**每个开一条真的 MQTT 连接**用自己的资源密钥上报——数值是编的，连接、认证、路由、落盘全是真的。

### 凭据：程序密钥和资源批次是同一件事

控制台用账号，**密钥是发给别人的**——「凭据」页一个表单，第一个问题是**给谁用**：

| 给谁用 | 产出 | 几个持有者 |
| --- | --- | --- |
| 一个程序 | 一把 `etk_` 密钥，你自己贴进配置 | 1 |
| 一批资源 | 一个发证批次，资源各自换走自己的 `erk_` | N，每个一把 |

两者都是**先划好范围再发**，而且共用同一个「空间」框：批次上写 `ops`，它发出去的每个资源就都只在 `ops` 里。
组和能力只问程序密钥，不是表单偷懒——资源连管理路由都够不到（`assertTenantAdmin` 不接受 resource 角色），也不是它租户所属组的成员，这两个轴对资源各自只有一个可能取值。

程序密钥的三个轴：

| 轴 | 取值 | 默认 |
| --- | --- | --- |
| 空间 | `*`、或 `ops, mkt` 这样的列表 | `*` |
| 组 | 能进 / 不能进 | **不能进** |
| 能力 | `data`（收发数据）/ `data + manage`（还能注册资源、建空间、建发证批次） | **只有 `data`** |

两个默认都取**安全**的那一边：忘了想范围，产出的是最没危险的密钥而不是最好用的那把。

**换钥不再有断档。** 以前一个租户只有一把，发新的就当场作废旧的；现在是「新建一把 → 部署 → 删掉旧的」，中间两把并存。删掉一把只影响那一把，用它建立的 MQTT / WebSocket 会话会立即断开，其他密钥和你自己的登录都不受影响。

范围在**每条路上都成立**，不只是 HTTP：

```
密钥范围 spaces=['ops']
  acme@ops   连得上
  acme@mkt   CONNACK 5，在发出第一个包之前就被拒
  acme@*     连得上，但 * 只展开成 ops——通配符不能越过密钥
```

WebSocket 票据会**带着范围**一起兑换，否则浏览器那条路就成了绕过范围的方法。

几条刻意的限制：

- **密钥不能建密钥、也不能吊销密钥**（403 `account_required`）。否则一把窄的可以给自己发一把宽的，范围就白设了。管理密钥要用账号登录。
- **租户只能由已验证账号自助创建。** 管理员只能查看和治理已有租户，不能制造没有邮箱归属的租户。
- **删除租户会连同它的所有密钥一起删**，否则那些密钥会继续对着一个不存在的租户通过认证。

### 标签

资源可以带任意多个**标签**，在资源详情页里逗号分隔填写。列表页顶部会列出**当前实际在用**的标签（从已加载的资源里现算，不会过时），点一个筛选，点多个是**收窄**——资源必须同时带上所有选中的标签。

标签是纯粹的组织手段：**消息流现在完全不认识它们**。打了标签的资源，主题一个字都不变，订阅行为也一个字不变。所以这是控制台里的筛选，不是订阅的筛选。

两条设计上的取舍值得知道：

- **打标签走 `PUT /v1/resources/{resourceId}/tags`，不是重新注册资源。** 重新注册会签发新密钥并覆盖旧的哈希，如果打标签走那条路，等于打个标签就把资源弄下线了。这个接口只改标签，不碰凭据。轮换密钥也不会丢标签——换密钥不是忘记机队怎么组织的理由。
- **标签按主题合法的标识符校验**（不能有 `/`、`+`、`#`），尽管现在没有任何东西路由到它们。这样以后如果要做「按标签订阅」，那是一次新增而不是一次迁移。

标签是**整体替换**而不是合并，否则没有办法删掉其中一个。

资源列表实时显示在线状态、最近上报时间和最新遥测；点「详情」可以看最近事件、下发命令、复制接入示例。「轮换密钥」生成新密钥并立即作废旧的，「删除」移除密钥和快照（已记录的事件保留到过期）。

命令行等价物：

```bash
curl -sS -X POST https://resources.example.com/v1/resources \
  -H "Authorization: Bearer $TENANT_API_KEY" \
  -H 'X-Tenant-Id: acme' \
  -H 'Content-Type: application/json' \
  -d '{"tenantId":"acme","resourceId":"sensor-01","name":"仓库传感器",
       "metadata":{"lat":31.23,"lon":121.47,"type":"sensor","site":"shanghai-wh1"}}'
```

租户密钥通过「带 `X-Tenant-Id`、不带 `X-Resource-Id`」被识别；资源密钥两个头都要带。

## 4. metadata 约定

`metadata` 是自由 JSON，网关不做业务解释，但地球视图会读其中几个键：

| 键 | 作用 | 缺省时 |
| --- | --- | --- |
| `lat` / `lon` | 资源在地球上的位置 | 围绕所属站点锚点散开；整个站点都没有坐标时归入「未定位」 |
| `site` | 站点分组（左侧集群列表的一行） | 归入名为租户 ID 的默认站点 |
| `type` | 型号图例：`switch` `cct` `cloudbox` `relay` `panel` `curtain` `sensor` `meter` | 通用资源 |
| `parentId` | 同站点内的上级资源，用于画现场总线拓扑 | 作为根节点 |
| `link` | 与上级的链路：`net` `wifi` `rs485` `can` `zigbee` | `net` |

遥测 `data` 同样是自由 JSON。地球视图从中识别 `voltage`/`v`、`current`/`i`、`power`/`p`、`temperature`/`temp`/`t`、`rssi`、`uptime`/`up` 和 `online` 用于绘图，其余字段原样保留在详情面板里。

## 5. 浏览器如何订阅实时数据

浏览器无法在 WebSocket 升级请求上设置 `Authorization`，所以控制台和地球视图不直接用长期密钥连接，而是先换一张一次性票据：

```bash
curl -sS -X POST https://resources.example.com/v1/ws-tickets \
  -H "Authorization: Bearer $TENANT_API_KEY" -H 'X-Tenant-Id: acme'
# → {"ticket":"ewt_…","expiresAt":…,"expiresInMs":60000}
```

再用 `wss://resources.example.com/v1/ws?ticket=ewt_…` 建立连接。票据默认 60 秒过期（`WS_TICKET_TTL_MS`），并在第一次使用后立即销毁，重放会被拒。长期密钥始终不进 URL。

连接建立后，租户或管理员可以订阅整个租户的实时流：

```json
{"kind":"watch","tenantId":"acme"}
```

服务端回 `{"kind":"watching","tenantId":"acme"}`，随后每条持久化事件以 `{"kind":"tenant-event","tenantId":"acme","event":{…}}` 推送。`{"kind":"unwatch","tenantId":"acme"}` 取消。

这条通道是**只读的实时视图**，没有游标、不重放、不需要 ACK——它服务于屏幕，不服务于可靠消费。需要不丢事件的消费端仍应按 [资源接入手册](RESOURCE-INTEGRATION.zh-CN.md) 第 5 节，订阅具体资源通道并按 `sequence` 确认。

## 6. 地球视图

`/` 和控制台同源，会话是 cookie，所以点「打开地球视图」本来就是登录状态，不需要交接任何东西；直接打开也一样。`/app` 只保留给旧链接。凭据不写入 URL。

进入后：视图按资源的实际经纬度取景，左侧是站点列表，点击站点或地球上的光柱展开该站点的三维节点拓扑，右下角是实时事件流。断开租户会回到内置仿真数据。

还没有注册任何资源的租户登进来会看到一个空地球，右侧面板会说明原因并给出控制台入口，而不是一片沉默。

资源清单在页面加载时读取一次并每 30 秒复查一次；新注册的资源需要刷新页面才会画到地球上（三维拓扑在启动时一次性建好）。已有资源的状态变化是实时的。

## 7. 开发环境

```bash
npm run server                     # 网关，含控制台与文档，:8787
npm run dev                        # 地球视图热更新，:5173，API 反代到 :8787
npm run build                      # 产出 dist/，网关随后在 / 提供
```

`npm run dev` 起的 Vite 会把 `/v1`、`/console`、`/docs` 反代到 `127.0.0.1:8787`，因此开发时也是同源，不需要配 CORS。用别的域名承载前端时才需要在网关设置 `CORS_ORIGINS`。
