Files
silk/变更记录_3ca92cb.md
T

673 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 变更记录
## 2026-07-17 安全与界面优化整改
### 变更背景
依据 `e:\silk\优化.md` 中的安全测试结果与界面优化建议,对前后端进行整改。经核验,绝大多数问题属实,部分(如仪表盘英文字段名)在当前代码中已不存在。
### 后端变更(server-go
| 类别 | 文件 | 变更 |
|------|------|------|
| 安全头 | `internal/middleware/security.go`(新增) | 统一补齐 `X-Content-Type-Options`/`X-Frame-Options`/`Referrer-Policy`/`Content-Security-Policy`/`Strict-Transport-Security` |
| 登录限流 | `internal/middleware/ratelimit.go`(新增) | 按 IP+用户名维度计数,连续失败 5 次锁定 15 分钟,返回 429 |
| 令牌黑名单 | `internal/middleware/token_blacklist.go`(新增) | 登出令牌加入内存黑名单(按 jti),鉴权中间件校验 |
| 鉴权中间件 | `internal/middleware/auth.go` | 校验黑名单;拒绝 refresh 令牌访问业务接口;白名单新增 `/auth/refresh` |
| 认证接口 | `internal/handler/auth.go` | 新增 `/auth/logout``/auth/refresh``/auth/change-password`;登录失败/成功联动限流;错误提示统一中文("用户名或密码错误");令牌加入 jti 与 tokenType |
| 配置 | `internal/config/config.go` | `JWT_EXPIRES_IN` 默认值由 `7d` 改为 `2h`(刷新令牌 7d,访问令牌 2h) |
| 房间模型 | `internal/model/models.go` | `Room` 新增 `Capacity``Stage` 字段(修复蚕房管理表单字段不持久化问题) |
| 路由装配 | `cmd/server/main.go` | 注册 `SecurityHeaders` 中间件 |
### 前端变更(web
| 类别 | 文件 | 变更 |
|------|------|------|
| 令牌刷新 | `src/api/http.ts` | 响应拦截器:401 时用 refreshToken 静默换新并重试;统一读取 `error` 字段为错误提示 |
| 认证服务 | `src/services/auth.ts``src/dal/auth.ts` | 登录存储 refreshToken;登出调用后端 `/auth/logout` 吊销令牌;新增 `changePassword` |
| 登录页 | `src/pages/Login.tsx` | 密码框补 `autocomplete="current-password"`,用户名框补 `autocomplete="username"`;登录失败显示中文错误 |
| 修改密码 | `src/layout/BasicLayout.tsx` | 头像下拉菜单新增"修改密码"入口与弹窗,修改成功后强制重新登录;登出改为调用后端 |
| 表格 UUID 列 | `Silkworm/Device/Threshold/Alert/Log.tsx` | 隐藏 ID 列,改用"序号"列(`indexBorder` |
| 控制日志 | `src/pages/Log.tsx` | 移除冗余"设备ID"列 |
| 告警中心 | `src/pages/Alert.tsx` | 移除占位按钮"保留告警视频入口" |
| 设备控制 | `src/pages/Control.tsx` | 离线设备电压/功率/电量统一显示 `--` 并加"数据为历史缓存,已隐藏"提示 |
| 仪表盘 | `src/pages/Dashboard.tsx``src/dal/dashboard.ts` | "蚕房状态概览"文案由"在线/离线"改为"运行中/停用",避免与设备在线状态混淆 |
| 蚕房表单 | `src/pages/Silkworm.tsx` | 位置/容量/阶段改为必填,配合后端新字段持久化 |
| 404 页 | `src/pages/NotFound.tsx`(新增)、`src/router.tsx` | 未知路由展示"页面不存在"提示,不再静默跳转仪表盘 |
### 未在本次整改范围
- **明文 HTTP 传输凭据(中风险#6)**:需在网关层终止 TLS,属基础设施变更,未在代码中处理。
- **令牌改用 HttpOnly Cookie(高风险#3)**:涉及前后端鉴权架构重写与 CSRF 防护,本次以"缩短有效期 + 服务端登出黑名单 + 刷新令牌"缓解。
- **仪表盘英文字段名(UI#1)**:核验当前 Web 仪表盘已使用中文标签(温度/湿度/CO₂/光照),该问题在 Web 端不成立。
### 验证
- 后端:`go build ./...``go vet ./...` 均通过
- 前端:`npm run build` 通过(tsc + vite build
### 部署提示
- 后端重启后内存黑名单清空(令牌自然过期兜底);新访问令牌有效期缩短为 2h,旧 7d 令牌在过期前仍有效
- 数据库需执行 GORM AutoMigrate(启动时自动)以新增 `rooms.capacity``rooms.stage`
## 2026-07-04 视频录制改为不落地方案
### 变更背景
原方案中,视频录制分三步:ZLMediaKit 录制到容器本地磁盘 → cron 脚本每分钟扫描 → 下载到本机再上传到 Ceph S3。录制文件经过两次网络传输,效率低,且容器磁盘占用持续增长。
### 变更内容
将录制流程改为**不落地模式**ZLMediaKit 做 RTP→fMP4 协议转换,Python 服务直接 HTTP 拉流,内存中缓冲到 5MB 分块即触发 S3 Multipart Upload,全程不写入本地磁盘。
### 架构变更
**变更前(落地方案):**
```
EasyGBD → ZLMediaKit(RTP→MP4, 录制到容器磁盘) → cron脚本(docker cp到/tmp) → boto3上传Ceph S3
```
**变更后(不落地方案):**
```
EasyGBD → ZLMediaKit(RTP→fMP4, 仅协议转换) → stream_to_s3.py(HTTP拉流→内存缓冲5MB→S3 Multipart Upload) → Ceph S3
```
### 新增文件
| 文件 | 位置 | 说明 |
|------|------|------|
| `stream_to_s3.py` | 100.83.103.1:/home/pan/ | Python 不落地录制服务,监听 9090 端口 |
| `stream_to_s3.py` | e:\silk\wvp\ | 本地副本 |
### 修改文件
| 文件 | 变更内容 |
|------|----------|
| `server/src/modules/video/recording.service.ts` | 移除 `startRecord`/`stopRecord`/`archiveRecording` 调用,改为通过 HTTP 调用 Python 服务的 `/record/start``/record/stop` 接口 |
| `server/.env` | 新增 `RECORDER_API_BASE=http://100.83.103.1:9090` |
### 删除/清理
| 清理项 | 说明 |
|--------|------|
| ZLMediaKit 容器内录制文件 | 清理 2.1GB 旧录制文件 |
| /tmp 临时 MP4 文件 | 清理约 2GB 临时文件 |
| cron `archive_recordings.py` 任务 | 不再需要,已从 crontab 移除 |
### 环境配置
| 配置项 | 值 |
|--------|-----|
| `RECORDER_API_BASE` | `http://100.83.103.1:9090` |
| Windows portproxy | `9090` 端口转发到 WSL2 `172.18.127.247:9090` |
| Python 服务启动 | `nohup python3 /home/pan/stream_to_s3.py > /home/pan/stream_to_s3.log 2>&1 &` |
### 工作流程
1. **自动检测**:Python 服务每 60 秒检查在线摄像头(通过 NestJS API)
2. **开始录制**HTTP 从 ZLMediaKit 拉 fMP4 流 → 内存缓冲 → 每 5MB 调用 `UploadPart`
3. **分段归档**:每 5 分钟 `CompleteMultipartUpload` 收尾 → 调用 NestJS API 建库记录 → 自动开始下一段
4. **手动控制**:前端「摄像头管理」Tab 或 NestJS API 可手动开始/停止录制
### 验证结果
- 24 个旧落地文件在 18 秒内全部归档完成
- 不落地模式首个分段:17:06:42 开始,311 秒,152MB29 个 UploadPart
- 上传速度:约 5MB/9 秒(localhost 传输)
- 数据库记录正确创建,前端「历史回看」可查询和播放
## 2026-07-10 新增移动端客户端(React Native APP + Taro 小程序)
### 变更背景
根据 spec.md「客户端」章节要求,系统需提供 Web 管理后台、微信小程序、移动 APP 三端客户端覆盖。此前仅有 Web 管理后台(`web/`)和 Go 后端(`server-go/`),缺少移动端 APP 和微信小程序。
### 变更内容
新增两个移动端客户端工程,完整对接现有 Go 后端 API(58 个 REST 端点 + WebSocket),实现登录认证、实时监测、设备控制、告警管理、阈值配置、视频监控等全功能。
### 新增工程
#### 1. React Native 移动 APP`e:\silk\app\`
**技术栈**React Native 0.74.5 + TypeScript + React Navigation 6 + Zustand + Axios + React Native Paper + react-native-video
**页面(11 个)**
| 页面 | 文件 | 功能 |
|------|------|------|
| 登录 | `src/screens/LoginScreen.tsx` | 账号密码登录,预填 admin/silk@123 |
| 仪表盘 | `src/screens/DashboardScreen.tsx` | 统计卡片 + 实时指标 + 趋势图 + 最近告警,30s 轮询 |
| 蚕房列表 | `src/screens/RoomsScreen.tsx` | 蚕房搜索、状态徽章 |
| 蚕房详情 | `src/screens/RoomDetailScreen.tsx` | 实时指标 + 趋势图 + 设备列表,30s 轮询 |
| 设备列表 | `src/screens/DevicesScreen.tsx` | 设备搜索、在线/离线状态、设备类型图标 |
| 设备控制 | `src/screens/DeviceControlScreen.tsx` | 开关、模式、风速、温度设置 |
| 告警中心 | `src/screens/AlertsScreen.tsx` | 未处理/全部筛选、确认告警、查看片段 |
| 阈值管理 | `src/screens/ThresholdsScreen.tsx` | 阈值 CRUD、启用/禁用开关 |
| 视频监控 | `src/screens/VideoScreen.tsx` | 摄像头列表 + 录像片段 Tab 切换 |
| 视频播放 | `src/screens/VideoPlayerScreen.tsx` | HLS 直播流播放 |
| 设置 | `src/screens/SettingsScreen.tsx` | 用户信息、API 地址配置、退出登录 |
**API 层**`src/api/`):client.ts、auth.ts、rooms.ts、devices.ts、telemetry.ts、alarms.ts、thresholds.ts、video.ts
**状态管理**`src/store/authStore.ts`Zustand + AsyncStorage 持久化)、`src/store/appStore.ts`
**工具**`src/utils/ws.ts`(WebSocket 管理器,自动重连)、`src/utils/format.ts`(格式化)
**组件**`src/components/StatCard.tsx``src/components/MiniChart.tsx`(纯 RN 组件实现的柱状图)
#### 2. Taro 微信小程序(`e:\silk\miniapp\`
**技术栈**Taro 4.1.9 + React 18 + TypeScript + SCSS + Zustand,一套代码输出微信小程序和 H5
**页面(10 个)**
| 页面 | 路径 | 功能 |
|------|------|------|
| 登录 | `pages/login/index` | 账号密码登录 |
| 仪表盘 | `pages/dashboard/index` | 概览卡片 + 蚕房选择 + 实时遥测 + 最近告警,30s 轮询 + WebSocket 推送 |
| 蚕房列表 | `pages/rooms/index` | 蚕房卡片列表 |
| 蚕房详情 | `pages/rooms/detail/index` | 蚕房信息 + 设备遥测 |
| 设备 | `pages/devices/index` | 设备列表 + 筛选 + 控制按钮 |
| 告警 | `pages/alerts/index` | 告警列表 + 确认 + 查看录像 |
| 阈值管理 | `pages/thresholds/index` | 阈值 CRUD + 弹窗表单 |
| 视频监控 | `pages/video/index` | 摄像头列表 + 录像回放 |
| 视频播放 | `pages/video/player/index` | 全屏视频播放,HLS/FLV/WebRTC 切换 |
| 设置 | `pages/settings/index` | 用户信息 + 退出登录 |
**TabBar**:5 个底部标签(仪表盘/蚕房/设备/告警/视频),含 10 个 SVG 图标
**API 层**`src/api/request.ts`Taro.request 封装,JWT 拦截器)、auth.ts、rooms.ts、devices.ts、telemetry.ts、alarms.ts、thresholds.ts、video.ts
### 构建与部署配置
#### React Native APP 构建配置
| 配置项 | 值 | 说明 |
|--------|-----|------|
| Gradle 镜像 | `https://mirrors.cloud.tencent.com/gradle/gradle-8.6-all.zip` | 解决 Gradle 下载超时 |
| Maven 镜像 | `https://maven.aliyun.com/repository/google` / `public` | 加速依赖下载 |
| JDK | `E:\openjdk-17.0.2_windows-x64_bin\jdk-17.0.2` | JDK 17AGP 要求) |
| NDK | `26.3.11579264` | 原 `26.1.10909125` 损坏,替换为可用版本 |
| media3 版本 | 强制 `1.4.1` | `react-native-video` 依赖的 media3 1.8.0 要求 SDK 35,降级兼容 SDK 34 |
| API 地址 | `http://100.83.103.1:3000/api/v1` | 后端 Tailscale IP |
| WebSocket | `ws://100.83.103.1:3000/ws` | 实时推送 |
| Android 包名 | `com.silkmonitor` | 显示名「蚕房监控」 |
#### Taro 小程序构建配置
| 配置项 | 值 | 说明 |
|--------|-----|------|
| H5 devServer proxy | `/api` -> `http://localhost:3000` | H5 调试时代理转发 |
| 小程序 API 地址 | `http://100.83.103.1:3000/api/v1` | 直连后端 |
| `project.config.json` | `urlCheck: false` | 允许 localhost 开发请求 |
### 共同特性
- JWT 认证,401 自动清除 Token 并跳转登录页
- WebSocket 实时推送(遥测数据、告警事件)
- 8 个 API 模块完整对接后端
- 设备远程控制(`POST /control/send`
- 视频流播放(HLS
- 中文 UI
- 30 秒轮询 + WebSocket 双重实时数据更新
### 验证结果
- React Native APP`tsc --noEmit` 零错误通过;Android APK 构建成功并安装到物理设备(OXF-AN10, Android 12),登录成功
- Taro 小程序:H5 和 weapp 均编译成功(`webpack 5.78.0 compiled successfully`
## 2026-07-14 物联网设备接入 + 录制状态修复 + 前端新增能耗管理和设备控制页面
### 变更背景
1. WVP 迁移到物理机 Docker 后,设备离线时录制流中断但前端一直显示"录制中",再次点击"开始录制"返回 503
2. VerneMQ 服务未设置开机自启,物理机重启后 MQTT 不可用
3. 需要接入 GSTMB1 温湿度传感器和 GSPE1B 智能插座
4. 前端缺少能耗管理和设备控制入口
### 变更内容
#### 1. 录制状态自动清理(recorder-go -> Go 后端通知)
录制流因设备离线等原因中断时,recorder-go 通知 Go 后端自动清理录制状态和 WVP play session,前端无需手动点击停止。
**修改文件:**
| 文件 | 变更内容 |
|------|----------|
| `server-go/internal/handler/video_record.go` | 新增内部接口 `POST /video/recordings/internal/end`,接收 recorder-go 的录制结束通知,清理 `activeRecordings` 内存表并调用 `media.StopPlay()` |
| `wvp/recorder-go/main.go` | 录制线程退出时(defer)调用 `notifyRecordingEnd()` 通知 Go 后端 |
| `server-go/internal/middleware/auth.go` | 白名单添加 `/video/recordings/internal/end` |
#### 2. VerneMQ MQTT 服务
- 启动 VerneMQ 并设置开机自启:`sudo systemctl enable --now vernemq`
- 修改监听地址为 `0.0.0.0:1883`(原绑定 `127.0.0.1`
- 创建用户 `pan/pan`
#### 3. GSTMB1 温湿度传感器适配
**修改文件:**
| 文件 | 变更内容 |
|------|----------|
| `server-go/internal/service/mqtt.go` | `handleMessage` 增加 GSTMB1 消息识别:用 `mac` 字段作为 deviceKey`source=="command"` 命令响应处理;定时上报提取 temperature/humidity/signal 等字段入库;新增 `deviceTopics` 内存表和 `PublishToDevice` 方法 |
| `server-go/internal/handler/control.go` | 新增 API`POST /devices/:id/gstmb1/command``/info``/restart``/interval` |
| `server-go/cmd/server/main.go` | 注入 `DeviceCommander` 到 control handler |
#### 4. GSPE1B 智能插座适配
**发现的问题:** 插座的 MQTT 主题方向与常规配置相反--设备发布到 `/down/cmd`,订阅 `/up/telemetry`
**修改文件:**
| 文件 | 变更内容 |
|------|----------|
| `server-go/internal/service/mqtt.go` | 增加订阅 `silk/+/+/+/down/cmd``PublishToDevice` 自动检测主题方向,反配设备发送到 `/up/telemetry`;指标提取增加 voltage/current/power/energy/key/onState;命令响应处理增加 `info-statistic``controller-event` |
| `server-go/internal/handler/control.go` | 新增插座 API`POST /devices/:id/plug/on``/off``/statistic``/info` |
| `server-go/internal/handler/device.go` | `listDevices` 增加 `kind` 查询参数过滤 |
**数据库变更:**
| 设备 | device_key | kind | 说明 |
|------|-----------|------|------|
| 传感器1# | `28562f8c35e8` | `sensor` | GSTMB1 温湿度传感器 |
| 智能插座 | `e8f60a2f01e8` | `actuator` | GSPE1B 智能插座 |
#### 5. 前端新增页面
**新增文件:**
| 文件 | 说明 |
|------|------|
| `web/src/pages/Energy.tsx` | 能耗管理页面:4 个数据卡片(电压/电流/功率/电量)+ 4 个趋势图表(ECharts),支持设备选择和时间范围(1h/6h/24h),15 秒自动刷新 |
| `web/src/pages/Control.tsx` | 设备控制页面:卡片式展示控制器设备,大开关按钮控制通/断电,实时显示电压/功率/电量,查询信息和电量按钮,15 秒自动刷新。本地状态管理提供即时视觉反馈 |
**修改文件:**
| 文件 | 变更内容 |
|------|----------|
| `web/src/layout/BasicLayout.tsx` | 原"设备控制"改名"设备管理",新增"能耗管理"(`/energy`)和"设备控制"(`/control`)菜单项 |
| `web/src/router.tsx` | 新增 `/energy``/control` 路由 |
| `web/src/pages/Device.tsx` | "类型"列改为"设备类型",用彩色 Tag 显示中文标签(传感器=蓝色、控制器=橙色、网关=紫色);表单选项"执行器"改为"控制器" |
#### 6. 遥测 API 修复
**修改文件:** `server-go/internal/handler/telemetry.go`
| 端点 | 变更前 | 变更后 |
|------|--------|--------|
| `GET /telemetry/:deviceKey/latest` | 返回单条最新记录(单个对象) | 返回所有指标的最新值数组(`SELECT DISTINCT ON (metric)` |
| `GET /telemetry/:deviceKey/:metric/history` | 返回 `{bucket, avg, min, max}`,依赖 TimescaleDB | 返回 `{time, value}`TimescaleDB 不可用时降级为原始数据查询 |
#### 7. 前端静态服务器缓存策略修复
**修改文件:** `web/server.cjs`
- `index.html``Cache-Control: no-cache, no-store, must-revalidate`(原为 `max-age=86400` 导致更新后浏览器不刷新)
- 静态资源(带 hash 文件名):`Cache-Control: public, max-age=604800`7 天)
- 部署路径修正为 `/home/pan/silk/web/dist/`
### 环境配置
| 配置项 | 值 | 说明 |
|--------|-----|------|
| VerneMQ 监听 | `0.0.0.0:1883` | 物理机直连,不再经过 WSL2 portproxy |
| VerneMQ 用户 | `pan/pan` | MQTT 认证 |
| VerneMQ 自启 | `systemctl enable vernemq` | 物理机重启后自动启动 |
### 新增文档
| 文件 | 说明 |
|------|------|
| `物联网设备接口和数据格式.md` | MQTT Topic 结构、GSTMB1 传感器、GSPE1B 智能插座、通用数据格式、后端处理逻辑 |
### 验证结果
- GSTMB1 传感器:温度 28.24°C、湿度 57.83% 已入库,设备在线
- GSPE1B 智能插座:电压 226.2V、电流 0.327A、功率 66.8W、电量 0.025kWh 已入库,通断控制正常
- `/control` 页面:只显示控制器设备(传感器不出现),开关控制有即时反馈
- `/energy` 页面:数据卡片和趋势图表正常显示
- `GET /telemetry/:deviceKey/latest` 返回所有指标数组
- `GET /telemetry/:deviceKey/:metric/history` 返回 `{time, value}[]` 格式
## 2026-07-15 红外控制器(GSCU1B-4G)心跳机制
### 变更背景
GSCU1B-4G 智能红外控制器是 4G LTE 短连接、命令驱动型设备,**无定时上报数据功能**:
- 不主动上报任何遥测数据
- 仅在收到后端命令时才回复(info/learn/emit 响应)
- 连接超时会自动断开 MQTT
原离线检测逻辑对所有设备一视同仁:超过 5 分钟未收到数据即标记离线。这导致红外设备一旦超过 5 分钟未操作就被标记为"离线",前端按钮被禁用,用户无法下发命令唤醒设备(形成死锁)。
### 变更内容
为红外控制器引入**主动心跳机制**:超时未收到数据时,先下发 `info` 命令查询设备状态,给 5 分钟宽限期等待响应;若心跳后仍未收到响应,才标记离线。
### 工作机制
| 设备类型 | 超时 5 分钟后的行为 |
|---------|---------------------|
| 普通设备(GSTMB1/GSPE1B/GSCW1M | 直接标记离线(原逻辑不变) |
| 红外设备(GSCU1B-4G)首次超时 | 下发 `info` 命令作心跳,**不标记离线** |
| 红外设备心跳后 5 分钟仍未响应 | 标记离线,清理心跳记录 |
### 流程
```
设备正常运行
5 分钟无数据上报
[红外设备] 下发 info 命令 → 设备响应 → last_seen 更新 → 重新计数
↓ (5 分钟内未收到响应)
标记离线
用户重启设备
下次 checkOffline 触发心跳 → 设备响应 → 自动恢复在线
```
### 设备识别方式
通过以下任一条件识别红外控制器:
- `model` 字段为 `"GSCU1B-4G"`
- `name` 字段包含"红外"
### 修改文件
| 文件 | 变更内容 |
|------|----------|
| `server-go/internal/service/mqtt.go` | `MQTTService` 新增 `irHeartbeat map[string]time.Time` 字段;新增 `isIRController()` 函数识别红外设备;`checkOffline()` 对红外设备做心跳处理:超时先下发 info 命令,5 分钟内未响应才标记离线 |
### 心跳命令
```json
{"type":"info","messageId":"<timestamp_ms>"}
```
与前端"查询信息"按钮下发的命令相同,设备响应后 `handleMessage` 自动更新 `last_seen` 和在线状态。
### 预期效果
1. **设备真正离线时**:约 10 分钟后(5 分钟超时 + 5 分钟心跳宽限)才标记离线
2. **设备重启后**:最多 5 分钟内自动恢复在线(下次 checkOffline 触发心跳)
3. **用户操作不变**:前端"查询信息"按钮仍可手动触发 info,与心跳互不影响
4. **checkOffline 频率不变**:仍为每 60 秒运行一次,心跳通过 `irHeartbeat` 内存表去重,约 5 分钟下发一次
### 部署状态
- 后端已交叉编译并上传到 `/home/pan/silk/server-go/silk-server-go-linux`
- 服务已重启,端口 3000 正常监听
- 日志显示 MQTT 已订阅 `silk/+/+/+/up/telemetry``silk/+/+/+/down/cmd`
## 2026-07-15 红外控制器空调遥控面板
### 变更背景
`/ir` 页面红外控制器卡片只提供"指定编号发射/学习"的通用功能,用户需要记忆每个编号对应什么功能,操作不便。空调遥控是红外控制器的主要使用场景,需要一个类似真实空调遥控器的可视化面板,让用户直接点击"关机/开机/制冷/制热/除湿"按钮操作,并支持一键顺序学习。
### 变更内容
#### 1. 前端空调遥控面板(`web/src/pages/IRControl.tsx`
**布局重构**:每张红外设备卡片改为左右分栏
- 左侧:保留原有发射/学习/查询信息/擦除全部等通用功能
- 右侧:新增空调遥控面板,分为三个区域
**按钮分区设计**
```
┌──────────────────────────────┐
│ 开始学习 / 取消学习 │ ← 顺序学习按钮(单独一行)
├──────────────┬───────────────┤
│ 关机(1) │ 开机(2) │ ← 空调控制按钮(顺序学习)
│ 制冷(6) │ 制热(7) │
│ 除湿(5) │ │
├────┬────┬────┬────┤
│23度│25度│27度│29度│ ← 温度按钮(单独学习)
│(3) │(4) │(8) │(9) │
└────┴────┴────┴────┘
```
**空调控制按钮**5 个,顺序学习):
- 关机(红外码 1
- 开机(红外码 2
- 制冷(红外码 6
- 制热(红外码 7
- 除湿(红外码 5
**温度按钮**4 个,单独学习):
- 23 度(红外码 3
- 25 度(红外码 4
- 27 度(红外码 8
- 29 度(红外码 9
#### 2. 学习模式
**顺序学习**5 个空调按钮):
1. 点击"开始学习" → 下发 `learn no=1`,关机按钮闪烁
2. 用户用空调遥控器对准红外控制器按下关机键
3. 听到"嘀"声 → 关机按钮变绿,自动下发下一个 `learn` 命令
4. 按顺序学习:关机 → 开机 → 制冷 → 制热 → 除湿
5. 全部完成显示"全部学习完成"
**单独学习**4 个温度按钮):
- 点击未学习的温度按钮 → 该按钮闪烁,开始单独学习
- 收到遥控信号后 → 按钮变绿,学习完成
- 点击已学习的按钮 → 发射红外码
**关键技术细节**
- 设备学习响应为两步:先返回 `success=false`(进入学习模式),再返回 `success=true`(学习成功)
- 前端 `success=false` 时继续轮询,不当作失败处理
- 使用 `startTime` 过滤旧结果,避免轮询匹配到上一个编号的结果
- 设备学习成功时返回 `no=0`(不是发送的编号),匹配条件兼容 `no=0`
- `learningDeviceRef` 使用 `useRef` 跟踪当前学习状态,规避轮询中的闭包陷阱
#### 3. 按钮状态
- **未学习**(默认灰色):空调按钮点击发射红外码;温度按钮点击开始单独学习
- **正在学习**(黄色 CSS 闪烁动画,`@keyframes ir-blink`):等待遥控器信号
- **已学习**(绿色边框+绿色文字):空调按钮点击发射;温度按钮点击发射
#### 4. 后端修复:已学习红外码编号记录(`server-go/internal/service/mqtt.go`
**问题**:设备学习成功时返回 `no=0`,导致后端 `irLearnedCodes``no > 0` 条件未记录编号。
**修复**
- 新增 `pendingLearnNo map[string]int` 记录发送 learn 命令时的编号
- `PublishToDevice` 检测 learn 命令时记录编号到 `pendingLearnNo`
- `handleMessage` 收到 learn 成功响应且 `no=0` 时,从 `pendingLearnNo` 还原编号,正确记录到 `irLearnedCodes`
#### 5. 擦除按钮修复
**问题**:擦除按钮使用 `Modal.confirm` 确认对话框,用户可能没注意到需要二次点击"确认擦除",且设备对 erase 命令可能不返回响应,导致"无反馈"。
**修复**
- 改为 `Popconfirm` 气泡确认(更直观,出现在按钮旁边)
- 点击确认后立即清空本地 `learnedCodes` 列表
- 新增 `pollEraseResult` 函数:短时间轮询(10 秒,5 次)确认擦除结果
- 超时显示"设备未响应擦除结果,本地红外码列表已清空"
- `pollIRResult``success=false` 时继续轮询,不当作失败(设备先返回进入擦除模式,再返回成功)
#### 6. 前端按钮 disabled 逻辑调整
- 空调面板按钮:移除 `disabled={!online}` 限制(红外设备为短连接,可能显示"离线"但实际可下发命令)
- 左侧通用按钮:保留原逻辑
### 涉及文件
| 文件 | 变更内容 |
|------|----------|
| `web/src/pages/IRControl.tsx` | 新增空调遥控面板、顺序学习、单独学习、按钮闪烁动画;`AIRCON_BUTTONS`/`TEMP_BUTTONS`/`LEARN_SEQUENCE`/`ALL_BUTTONS` 常量定义;`pollSequentialLearn`/`startSequentialLearn`/`cancelSequentialLearn`/`learnSingleTemp`/`emitAircon`/`pollEraseResult` 函数;`learningDeviceRef` 跟踪学习状态 |
| `server-go/internal/service/mqtt.go` | 新增 `pendingLearnNo` 内存表;`PublishToDevice` 记录 learn 编号;`handleMessage` 还原 `no=0` 时的学习编号到 `irLearnedCodes` |
### 部署状态
- 前端构建并上传到 `/home/pan/silk/web/dist/`5174 端口已重启
- 后端交叉编译并上传到 `/home/pan/silk/server-go/silk-server-go-linux`3000 端口已重启
### 验证结果
- 空调遥控面板按钮布局正确,5 个空调按钮 + 4 个温度按钮 + 开始学习按钮
- 顺序学习:关机 → 开机 → 制冷 → 制热 → 除湿,每个按钮学习成功后自动进入下一个
- 单独学习:点击温度按钮独立学习,不干扰顺序学习流程
- 擦除按钮使用 `Popconfirm`,确认后立即清空本地列表
- 已学习按钮显示绿色边框,点击可发射红外码
## 2026-07-18 RBAC 权限改造 + 视频播放与录制状态修复
### 变更背景
1. 系统原为多用户双角色(user/admin)架构,非严格 RBAC,需改造为多角色 + 权限码控制
2. RBAC 改造后用户反馈 `/videos` 实时监控 FLV 播放失败、历史回看查不到录像
3. 用户对录制状态有疑问,前端"摄像头管理"显示状态与实际录制情况不一致
### 变更内容
#### 1. RBAC 权限系统改造(前后端)
引入 4 个角色(admin/operator/viewer/farmer+ 15 个权限码,前端实现路由守卫+菜单+按钮级权限控制。
**后端新增文件:**
| 文件 | 说明 |
|------|------|
| `server-go/internal/model/permission.go` | 定义 Permission、RolePermission 模型和 4 个角色常量 |
| `server-go/internal/model/permission_seed.go` | 15 个权限码和角色-权限映射种子数据 |
| `server-go/internal/middleware/permission.go` | RequirePermission 中间件,带 5 分钟内存缓存,admin 自动放行 |
| `server-go/internal/handler/permission.go` | `GET /permissions``GET /roles` 查询 API |
**后端修改文件:**
| 文件 | 变更内容 |
|------|----------|
| `server-go/internal/database/db.go` | AutoMigrate 加入 Permission、RolePermission 表;新增 `seedPermissions()` 初始化种子数据 |
| `server-go/internal/handler/auth.go` | meHandler 与 buildLoginPayload 返回 permissions 列表;注册默认角色从 `user` 改为 `viewer`;新增 `getUserPermissionCodes()` |
| `server-go/internal/handler/user.go` | AdminMiddleware → `RequirePermission("user:manage")`;新增角色有效性校验 |
| `server-go/internal/handler/audit.go` | AdminMiddleware → `RequirePermission("audit:read")` |
| `server-go/internal/handler/{room,device,sensor,threshold,alarm,alarm_clip,control,video_camera,video_clip,video_record,telemetry,notification,storage}.go` | 各路由添加对应权限中间件 |
| `server-go/cmd/server/main.go` | 移除 admin 路由组;新增 RegisterPermissionRoutes 注册 |
**前端新增/修改文件:**
| 文件 | 说明 |
|------|------|
| `web/src/components/HasPermission.tsx`(新增) | 按钮级权限组件,根据 `authService.hasPermission(permission)` 控制显隐 |
| `web/src/dal/auth.ts` | LoginResp.user 加 `permissions` 字段;新增 `MeResp` 接口类型 |
| `web/src/services/auth.ts` | 新增 USER_KEY 存储、`getUser()``getPermissions()``hasPermission()``refreshUser()` |
| `web/src/router.tsx` | 新增 `RequirePermission` 路由守卫组件,每个路由配置对应权限码 |
| `web/src/layout/BasicLayout.tsx` | menuData 每项加 `permission` 字段;菜单渲染按权限过滤;头像显示真实用户名和角色中文名 |
**权限矩阵:**
| 角色 | 权限码 |
|------|--------|
| admin | 自动放行所有接口 |
| operator | dashboard:view, room:read/write, device:read/control, threshold:read/write, alarm:read/ack, video:read/record, energy:view, log:read, audit:read |
| viewer | dashboard:view, room:read, device:read, threshold:read, alarm:read, video:read, energy:view, log:read |
| farmer | dashboard:view, room:read, device:read/control, threshold:read, alarm:read/ack, video:read, energy:view |
#### 2. ZLMediaKit 流媒体配置修复
通过 ZLM `setServerConfig` API 修正运行时配置(WVP auto-config 未正确应用 `ZLM_HOOK_HOST` 环境变量):
| 配置项 | 修改前 | 修改后 | 说明 |
|--------|--------|--------|------|
| `general.streamNoneReaderDelayMS` | 20000 | 3600000 | 避免无人观看 20 秒后流被关闭 |
| `hook.on_publish`/`on_play` 等 11 项 | `http://127.0.0.1:18080/...` | `http://polaris-wvp:18080/...` | 修正 ZLM 容器内 hook URL127.0.0.1 指向 ZLM 自身而非 WVP |
**新增脚本:** `/home/pan/silk/fix_zlm_hook.py`(服务器留存)— 调用 ZLM API 修复 hook URL 和 streamNoneReaderDelayMS。WVP 重启后若 hook 配置被覆盖回 127.0.0.1,执行 `python3 /home/pan/silk/fix_zlm_hook.py` 即可修复。
**WVP 配置修改:** `stream-on-demand: true → false`(避免 hook 返回"关闭=true"导致流被关闭)
#### 3. 前端 FLV 播放跨域问题修复
**修改文件:** `server-go/internal/service/media.go``fixZlmPort` 函数
**变更前:** 返回绝对路径 `http://100.83.103.1:8081/rtp/xxx.live.flv?...`
**变更后:** 返回相对路径 `/rtp/xxx.live.flv?...`
**原因:** `web/server.cjs` 的 CSP 头 `connect-src 'self' ws: wss:` 只允许同源请求,flv.js 跨域 fetch ZLMediaKit 时被浏览器阻止,报 "NetworkError - Exception"。改为相对路径后,前端浏览器走 `server.cjs` 内置的 `/rtp/` 代理转发到 `127.0.0.1:8081`,避免跨域。
#### 4. 前端历史回看时间范围修复
**修改文件:** `web/src/pages/Video.tsx` PlaybackTab
| 修改点 | 变更前 | 变更后 |
|--------|--------|--------|
| `dateRange` state 类型 | `[Dayjs, Dayjs]` | `[Dayjs, Dayjs] \| null` |
| RangePicker `onChange` 清空 | 不更新 dateRange(保持默认今天) | 设为 `null` |
| `handleSearch` 查询参数 | 硬编码 `to: dayjs().toISOString()` | 仅当 dateRange 非空时传 from/to,为 null 时查全部 |
| `listClips` 调用 | 始终传 from/to | `ClipQuery` 仅在 dateRange 非空时设置 from/to |
**新增类型导入:** `web/src/pages/Video.tsx` 引入 `type ClipQuery`
#### 5. 录制状态查询改进
**修改文件:** `server-go/internal/handler/video_record.go``listActiveRecordings` 函数
**变更前:** 仅读 Go 后端内存 map `activeRecordings`,Go 后端重启后 map 清空,前端显示"开始录制"按钮(即使 recorder-go 仍在实际录制)。
**变更后:** 优先调用 recorder-go 的 `/record/status` 接口获取真实录制状态,并自愈内存 map。recorder-go 不可用时回退到内存 map。
### 环境配置
| 配置项 | 值 | 说明 |
|--------|-----|------|
| `WVP_API_BASE` | `http://localhost:18080` | WVP HTTP API 实际端口(start.sh 已正确配置) |
| admin 密码 | `admin123` | 已通过 bcrypt 重置 |
| ZLM hook URL | `http://polaris-wvp:18080/index/hook/...` | 容器间通过容器名访问 |
| ZLM streamNoneReaderDelayMS | `3600000`1 小时) | 避免短时间关流 |
| WVP stream-on-demand | `false` | 不按需拉流 |
### 验证结果
- 后端 `go build ./...` 通过
- 前端 `npm run build` 通过
- WVP `/api/play/start` 点播接口返回 `code=0` 成功,包含 FLV/HLS/FMP4 地址
- Go 后端 `/api/v1/video/cameras/1/live` 返回相对路径 FLV URL
- 通过 `server.cjs` 5174 端口代理访问 FLV 流,能正常返回视频二进制数据
- 前端清空时间范围查询历史录像,能查到 7月17日的 17 条录像
- recorder-go `/record/status` 反映真实录制状态
### 部署提示
- 系统启动时自动迁移 Permission、RolePermission 表并初始化种子数据
- 旧用户角色为 `user` 的将无权限,需管理员修改为 `viewer`/`operator`/`farmer`
- 新注册用户默认角色为 `viewer`
- WVP 重启后若 FLV 播放失败,执行 `python3 /home/pan/silk/fix_zlm_hook.py` 修复 ZLM hook 配置
## 2026-07-18 React Native APP 启动与登录流程修复
### 变更背景
安卓应用(React Native)首次启动时出现登录导航错误,无法正常显示登录页面。
### 问题根因
1. **导航器条件渲染**`AppNavigator.tsx``token ? ... : <Login>` 的条件渲染导致当 token 存在时(即使过期),导航器只注册 Main 屏幕,不包含 Login 屏幕。当后端返回 401 时,拦截器尝试导航到 Login,但导航器中找不到该屏幕。
2. **恢复会话逻辑缺陷**`authStore.ts``restoreSession` 设置过期 token 后,`fetchUser` catch 块静默忽略错误(不抛出异常),导致 `restoreSession` 的 try-catch 无法捕获到 token 无效,无法自动清除。
3. **拦截器竞态条件**:响应拦截器在导航器初始化完成前就尝试调用 `navigationRef.navigate('Login')`,导致导航失败。
### 修复内容
| 文件 | 变更内容 |
|------|----------|
| `app/src/navigation/AppNavigator.tsx` | 移除条件渲染,始终注册所有屏幕(包括 Login);改用 `initialRouteName={token ? 'Main' : 'Login'}` 控制初始页面 |
| `app/src/store/authStore.ts` | `fetchUser` 移除 try-catch,让错误向上抛出;`restoreSession``fetchUser` 失败时调用 `logout()` 清除过期 token |
| `app/src/api/client.ts` | 移除拦截器中的 `navigationRef.navigate('Login')` 逻辑,避免导航竞态条件 |
### 验证结果
- 应用成功安装并启动到物理设备(OXF-AN10, Android 12
- 登录页面正常显示,可输入账号密码登录
- 实时视频监控功能正常(FLV 流播放)
- 历史录像回看功能正常
- 密码已通过前端修改为 `silk@123`