Files
silk/故障排查处理记录.md
T

1026 lines
50 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.
# 故障排查处理记录
## 一、EasyGBD 注册超时
**现象**EasyGBD 配置 WVP SIP 服务器地址 `100.83.103.1`,端口 `8160`,提示注册超时。
**原因**Docker 容器的 8160 端口映射在 WSL2 (172.18.127.247) 上,Windows 的 Tailscale IP (100.83.103.1) 上没有 portproxy 转发该端口。EasyGBD 的 SIP 注册请求无法到达 WVP。
**处理**:在 Windows 管理员 PowerShell 中添加 TCP portproxy
```powershell
netsh interface portproxy add v4tov4 listenport=8160 listenaddress=100.83.103.1 connectport=8160 connectaddress=172.18.127.247
```
EasyGBD 中 SIP 传输协议改为 TCP`netsh interface portproxy` 不支持 UDP 转发)。
---
## 二、播放提示"通道不存在"
**现象**:调用 WVP `GET /api/play/start/{deviceId}/{channelId}` 返回 `code: 100, msg: "通道不存在"`
**原因**
1. WVP API 路径错误:用了 `/api/device/query/{deviceId}/channels`,正确路径是 `/api/device/query/devices/{deviceId}/channels`(多了 `devices`)。
2. 通道 ID 不匹配:EasyGBD 的通道 ID 不是设备 ID `34020000002000000001`,而是 `34020000001310000001`(类型编码 131 = 模拟通道)。
**处理**
1. 用正确路径调用 `GET /api/device/query/devices/{deviceId}/sync` 同步通道。
2. 从通道列表中获取实际通道 ID `34020000001310000001`
3. 在前端「摄像头管理」中更新通道 ID。
---
## 三、播放提示"收流超时"
**现象**WVP play/start 返回 `code: -2, msg: "收流超时"`
**原因**ZLMediaKit 的 RTP 媒体端口(10001-10003)映射在 WSL2 上,但 Windows 的 100.83.103.1 上没有 portproxy 转发。EasyGBD 收到 INVITE 后尝试连接 `100.83.103.1:10003` 发送 RTP 流,连接失败。
**处理**:添加 TCP portproxy 转发 ZLMediaKit 端口:
```powershell
netsh interface portproxy add v4tov4 listenport=10001 listenaddress=100.83.103.1 connectport=10001 connectaddress=172.18.127.247
netsh interface portproxy add v4tov4 listenport=10002 listenaddress=100.83.103.1 connectport=10002 connectaddress=172.18.127.247
netsh interface portproxy add v4tov4 listenport=10003 listenaddress=100.83.103.1 connectport=10003 connectaddress=172.18.127.247
netsh interface portproxy add v4tov4 listenport=8081 listenaddress=100.83.103.1 connectport=8081 connectaddress=172.18.127.247
netsh interface portproxy add v4tov4 listenport=18978 listenaddress=100.83.103.1 connectport=18978 connectaddress=172.18.127.247
```
---
## 四、FLV 播放失败 CodecUnsupported
**现象**:实时视频播放提示 `FLV播放失败: MediaError - CodecUnsupported`
**原因**EasyGBD 默认视频编码为 H265/HEVC,浏览器 MSE 不支持 H265 解码。
**处理**:在 EasyGBD APP 设置中将视频编码从 H265 改为 H264/AVC。
---
## 五、摄像头管理不能删除和修改
**现象**:「摄像头管理」Tab 中点击编辑或删除按钮无响应。
**原因**:前端 DAL 层 `src/dal/video.ts` 只导入了 `get``post`,缺少 `patch``del``updateCamera``deleteCamera` 调用了未定义的函数。
**处理**
```typescript
// 修改前
import { get, post } from '../api/http';
// 修改后
import { del, get, patch, post } from '../api/http';
```
---
## 六、HLS 播放地址端口不正确
**现象**WVP 返回的 HLS 地址端口为 `80`,但 ZLMediaKit Docker 映射为 `8081->80`,浏览器无法访问 `http://100.83.103.1:80/...`
**原因**WVP 中 ZLMediaKit 的 `httpPort` 配置为容器内部端口 `80`,但 Docker 将其映射到宿主机 `8081`
**处理**:在 `MediaService.extractPlayResult` 中将 URL 中的 `:80/` 替换为 `:8081/`
```typescript
const zlmHttpPort = process.env.ZLM_HTTP_PORT || '8081';
const fixUrl = (url?: string): string | undefined => {
if (!url) return url;
return url.replace(/:80\//, `:${zlmHttpPort}/`).replace(/:80$/, `:${zlmHttpPort}`);
};
```
---
## 七、历史回看看不到录像片段
**现象**:前端「历史回看」选择时间范围查询,显示"暂无录像片段"。
**原因**:录制文件名中的时间是 CST(UTC+8),但插入数据库时错误地标记为 UTC(`+00`),导致时间差 8 小时。用户查询北京时间 12:00-14:00 时,`toISOString()` 转为 UTC 04:00-06:00,不包含数据库中错误存储的 UTC 13:13。
**处理**
1. 修正数据库中已有记录的时间(减 8 小时)。
2. 后续自动录制归档由服务器端 Python 脚本处理,脚本从文件名解析时间并带 `+08:00` 时区,NestJS 内部 API 接收 ISO 8601 带时区的时间字符串。
---
## 八、自动录制归档 stopRecord 失败
**现象**RecordingService 每 5 分钟触发归档时,`stopRecord` 返回 `can not find the stream`,归档流程中断。
**原因**
1. WVP 播放会话在 5 分钟后超时,ZLMediaKit 中的流自动关闭,`stopRecord` 找不到流。
2. `stopRecord` 抛出异常后,整个 `archiveAndRestart` 方法中断,后续归档和重新录制不执行。
**处理**
1.`archiveAndRestart``stopRecording` 中将 `stopRecord` 包在 try-catch 中,失败只记警告,不中断归档流程。
2.`startRecording` 中,`startPlay` 失败时跳过录制(不继续调用 `startRecord`)。
---
## 九、NestJS API 从 WSL2 访问返回 404
**现象**:服务器端 Python 归档脚本调用 `http://172.18.112.1:3000/api/v1/auth/login` 返回 404,响应内容是 Gogs(Git 服务)的页面。
**原因**WSL2 网关 IP `172.18.112.1` 的 3000 端口被 Gogs 占用(或被路由到 Gogs),而不是 NestJS 后端。NestJS 虽然监听 `0.0.0.0:3000`,但从 WSL2 通过网关 IP 访问时到达的是 Gogs。
**处理**:改用 Windows 的 Tailscale IP `100.83.61.186` 访问 NestJS
```python
# 修改前
NESTJS_BASE = 'http://172.18.112.1:3000/api/v1'
# 修改后
NESTJS_BASE = 'http://100.83.61.186:3000/api/v1'
```
---
## 十、cron 定时任务不运行
**现象**:cron 已配置每分钟运行归档脚本,但 `/home/pan/archive.log` 不存在,脚本从未被执行。
**原因**WSL2 默认未启用 systemd`service cron start` 失败("Failed to connect to bus"),cron 守护进程未运行。
**处理**
1. 手动启动 cron`sudo cron`
2. 配置 WSL2 启用 systemd,写入 `/etc/wsl.conf`
```ini
[boot]
systemd=true
```
3. 在 PowerShell 中执行 `wsl --shutdown` 重启 WSL2,之后 cron 会随 systemd 自动启动。
---
## 十一、录制文件归档方案演进
### 方案 A(初始):NestJS HTTP 下载 + 上传
```
ZLMediaKit(100.83.103.1) → HTTP下载到本机(Windows) → 上传到 Ceph S3(100.83.103.1)
```
问题:录制文件经过两次网络传输(下载到本机再上传回服务器),效率低。
### 方案 C(最终):服务器端 Python 脚本归档
```
NestJS(Windows) → 控制录制(WVP/ZLM API
Python脚本(100.83.103.1) → cron每分钟扫描 → docker cp → boto3上传Ceph(localhost) → 调NestJS API建库记录 → 删除容器内文件
```
优势:
- 上传走服务器内部 localhost,速度快(12MB 文件 0.2 秒完成)
- NestJS 只负责录制控制和数据库管理,不参与文件传输
- 24 个积压文件在 18 秒内全部归档完成
---
## 十二、历史回看视频播放失败
**现象**:前端「历史回看」点击播放按钮,一直显示加载动画或提示"视频播放失败"。浏览器 Network 标签页显示请求状态码在 206/200 和 canceled 之间反复切换。
**原因**:多个问题叠加:
### 1. S3 预签名 URL 包含 Ceph RGW 不兼容的参数
AWS SDK v3 的 `getSignedUrl` 自动在预签名 URL 中添加 `x-amz-checksum-mode=ENABLED` 和 `x-id=GetObject` 查询参数。Ceph RGW (squid) 在验证签名时无法正确处理这些参数,返回 403 Forbidden。
**处理**:在 `s3.service.ts` 中用 Node.js `crypto` 模块手动实现 AWS S3 V4 签名,完全绕过 AWS SDK v3 的 presigner,不添加任何额外参数:
```typescript
async getPresignedUrl(bucket: string, key: string, expiresIn: number = 3600): Promise<string> {
// 手动构建 Canonical Request → String to Sign → Signing Key → Signature
// 只包含标准参数:X-Amz-Algorithm, X-Amz-Credential, X-Amz-Date, X-Amz-Expires, X-Amz-SignedHeaders
// 不包含 x-amz-checksum-mode 和 x-id
}
```
### 2. Ceph S3 Bucket 缺少 CORS 配置
前端从 `http://100.83.103.1:5174` 访问页面,视频预签名 URL 在 `http://100.83.103.1:7480`,属于跨域请求。Ceph RGW 默认没有配置 CORS,浏览器拒绝跨域响应。
**处理**:用 boto3 设置 bucket CORS
```python
s3.put_bucket_cors(Bucket='silk-video-events', CORSConfiguration={
'CORSRules': [{
'AllowedHeaders': ['*'],
'AllowedMethods': ['GET', 'HEAD'],
'AllowedOrigins': ['*'],
'ExposeHeaders': ['ETag', 'Content-Length', 'Content-Range'],
'MaxAgeSeconds': 3600
}]
})
```
### 3. VideoPlayer 组件 useEffect 无限循环
`VideoPlayer` 组件的 `useEffect` 依赖数组包含 `onError`,而父组件 `Video.tsx` 传入的 `onError` 是内联函数(每次渲染都创建新引用),导致 `useEffect` 不断重新执行 → `<video>` 的 `src` 不断被设置和清除 → 请求被 cancel → 重新加载 → 再 cancel,无限循环。
**处理**:在 `Video.tsx` 中用 `useCallback` 包装 `onError`
```typescript
const handlePlayError = useCallback(() => message.error('录像播放失败,URL 可能已过期'), []);
// ...
<VideoPlayer onError={handlePlayError} />
```
### 4. WSL2 重启后 Redis AOF 文件损坏导致录制异常
WSL2 文件系统变为只读后重启,Docker 中 Redis 的 AOF 文件损坏(`Bad file format reading the append only file`),Redis 容器不停重启。WVP 依赖 Redis,Redis 不稳定导致摄像头无法正常注册,ZLMediaKit 拉流返回 35 字节的错误消息 `[network err]:3(connection refused)`,Go 录制服务将错误消息当作视频数据上传,生成的 MP4 文件只有 35 字节。
**处理**
1. 删除损坏的 AOF 文件:
```bash
docker stop docker-polaris-redis-1
sudo rm -rf /home/pan/wvp-GB28181-pro/docker/volumes/redis/data/appendonlydir/
docker start docker-polaris-redis-1
```
2. 重启 WVP 和 ZLMediaKit 容器,等待摄像头重新注册。
3. Go 录制服务恢复正常上传(每 9 秒一个 5MB 分块)。
### 5. Vite 端口与 portproxy 不匹配
WSL2 重启后 Vite 默认监听 5173 端口,但远程机器 Windows 上的 portproxy 规则是 `100.83.103.1:5174 → 172.18.127.247:5174`,端口不匹配导致浏览器无法访问前端。
**处理**:修改 `vite.config.ts` 将端口改为 5174
```typescript
server: {
host: '0.0.0.0',
port: 5174, // 与 portproxy 规则一致
}
```
---
## 十三、Go 录制服务替换 Python 版
**背景**Python 版录制服务(`stream_to_s3.py`)受 GIL 限制,后续视频路数扩大后无法高效并发。
**处理**:用 Go 重写录制服务,部署到 WSL2。
### 核心逻辑
```
每路视频 → goroutine:
HTTP 拉 fMP4 流 (ZLMediaKit) → 5MB buffer → S3 UploadPart (Ceph RGW)
5分钟 → CompleteMultipartUpload → 建库记录 (NestJS API) → 重新开始
```
### 关键文件
| 文件 | 说明 |
|------|------|
| `e:\silk\wvp\recorder-go\main.go` | Go 录制服务源码 |
| `e:\silk\wvp\recorder-go\go.mod` | Go 模块定义 |
| `/home/pan/recorder-go/recorder` | 编译后的二进制文件 (12.6MB) |
### 与 Python 版对比
| 对比项 | Python 版 | Go 版 |
|--------|----------|-------|
| 并发模型 | 线程 + GIL | goroutine(无 GIL |
| 内存占用 | ~30MB/路 | ~24MB(当前 1 路) |
| 部署 | 需要 Python 运行时 | 单一二进制文件 |
| HTTP API | 完全兼容 | 完全兼容 |
| S3 Key 格式 | `2026/07/04/{epochMs}.mp4` | 相同 |
### 注意事项
- Go 版需要在 WSL2 中安装 Go 编译器:`tar -C /usr/local -xzf go1.22.5.linux-amd64.tar.gz`
- 编译时设置国内代理:`export GOPROXY=https://goproxy.cn,direct`
- `fix-static-ip.sh` 已更新为启动 Go 版录制服务
- 数据库记录的 `notes` 字段为 `自动录制(不落地-Go)`,与 Python 版区分
---
## 十四、前端页面加载缓慢
**现象**:通过 Tailscale 网络访问 `http://100.83.103.1:5174/videos`,页面加载需要 10-15 秒。
**原因**:Vite 开发模式下每个模块(React、Antd、页面组件等)都是独立的 HTTP 请求,约 30-50 个模块。通过 Tailscale 网络每个请求延迟约 0.23 秒,串行/并行加载总耗时 7-12 秒。加上 4.1MB 的主 JS bundle 未压缩,下载耗时约 10 秒。
**处理**:用生产构建 + 自定义静态服务器替代 Vite 开发模式。
### 1. 构建生产版本
```bash
cd /home/pan/silk/web
npx vite build
```
构建后只生成 3 个文件:
- `dist/index.html` (0.5KB)
- `dist/assets/index-B5bufLxZ.js` (4.1MB)
- `dist/assets/index-BsTCb322.css` (0.3KB)
### 2. 自定义静态服务器(支持 gzip + API 代理)
Vite preview 不支持 gzip 压缩,4.1MB 的 JS 通过 Tailscale 下载需要 10 秒。用 Node.js 编写带 gzip 压缩和 API 代理的静态服务器 `server.js`
```javascript
// 核心逻辑:
// 1. /api/* → 代理到 NestJS (localhost:3000)
// 2. 静态文件 → 从 dist 目录读取,大于 1KB 的文件启用 gzip 压缩
// 3. SPA fallback → 不存在的路径返回 index.html
```
关键文件:`/home/pan/recorder-go/server.js`
### 3. 效果对比
| 资源 | 开发模式 (Vite) | 生产模式 (gzip) |
|------|----------------|----------------|
| JS bundle | 4.1MB / 10.2s | **1.28MB / 2.7s** |
| 模块请求数 | ~30-50 个 | **3 个** |
| 页面总加载 | ~12s | **~3s** |
### 4. 启动命令
```bash
# 构建前端
cd /home/pan/silk/web && npx vite build
# 启动静态服务器(端口 5174,与 portproxy 一致)
nohup node /home/pan/recorder-go/server.js > /tmp/static-server.log 2>&1 &
```
### 5. 注意事项
- 生产模式无热更新,修改前端代码后需要重新 `vite build` 并重启 server.js
- 开发时可临时切回 Vite 开发模式:`npx vite --host 0.0.0.0 --port 5174`
- `vite.config.ts` 中同时配置了 `server`(开发)和 `preview`(预览)的代理
---
## 十五、MQTTX 连接报 ECONNREFUSED
**现象**:用 MQTTX 连接 `100.83.103.1:1883` 测试物联网设备接入,报错 `Error: connect ECONNREFUSED`。
**原因**VerneMQ 存在两个问题:
1. **服务未启动** - `systemctl status vernemq` 显示 `inactive`1883 端口未监听。
2. **监听地址绑定了 127.0.0.1** - 配置文件 `/etc/vernemq/vernemq.conf` 第291行为 `listener.tcp.name = 127.0.0.1:1883`,只允许本机访问,外部连接被拒绝。
**处理**
1. 启动 VerneMQ 服务:`sudo systemctl start vernemq`
2. 修改监听地址为 `0.0.0.0:1883`
```bash
sudo sed -i 's/^listener.tcp.name = 127.0.0.1:1883/listener.tcp.name = 0.0.0.0:1883/' /etc/vernemq/vernemq.conf
```
3. 重启服务:`sudo systemctl restart vernemq`
4. 确认端口监听:`ss -tlnp | grep 1883` 应显示 `0.0.0.0:1883`
---
## 十六、MQTTX 连接报 Bad username or password
**现象**ECONNREFUSED 解决后重新连接,报错 `Error: Connection refused: Bad username or password`,填写的用户名密码为 `pan/pan`。
**原因**VerneMQ 配置 `allow_anonymous = off`(禁止匿名连接),密码文件 `/etc/vernemq/vmq.passwd` 不存在,未创建 `pan` 用户。
**处理**
1. 创建明文密码文件并加密:
```bash
echo "pan:pan" > /etc/vernemq/vmq.passwd
vmq-passwd -U /etc/vernemq/vmq.passwd # 将明文转为哈希
```
2. 设置文件权限:
```bash
chown vernemq:vernemq /etc/vernemq/vmq.passwd
chmod 640 /etc/vernemq/vmq.passwd
```
3. 重启 VerneMQ`sudo systemctl restart vernemq`
---
## 十七、React Native APP 视频页面无数据(HTTP 明文流量被拦截)
**现象**React Native Debug APK 安装后,登录功能正常,但「视频」页面的「摄像头」和「录像片段」列表均为空,显示"暂无摄像头"/"暂无录像片段"。后端日志中无来自手机的请求记录。
**原因**Android 9+ 默认禁止 HTTP 明文流量(`usesCleartextTraffic` 默认为 `false`)。Debug APK 的 `AndroidManifest.xml` 未设置 `android:usesCleartextTraffic="true"`,所有 HTTP 请求被 Android 系统拦截。由于 APP 中 `getCameras()` 和 `getClips()` 使用 `.catch(() => [])` 静默吞掉错误,API 请求失败后返回空数组,不显示任何错误信息。
用户误以为"已登录",实际上token是之前 Debug 模式下登录时缓存在 AsyncStorage 中的,安装新 APK 时数据未清除。登录 API 调用同样被拦截,但 APP 使用缓存 token 跳过了登录流程。
**处理**
1. 在 `android/app/src/main/AndroidManifest.xml` 的 `<application>` 标签中添加 `android:usesCleartextTraffic="true"`
```xml
<application
android:name=".MainApplication"
android:usesCleartextTraffic="true"
...>
```
2. 重新编译 Debug APK 并安装:
```powershell
$env:JAVA_HOME = "E:\openjdk-17.0.2_windows-x64_bin\jdk-17.0.2"
cd e:\silk\app\android
.\gradlew.bat assembleDebug --no-daemon
adb -s <device_id> install -r app\build\outputs\apk\debug\app-debug.apk
```
3. USB 连接时需设置 ADB 端口转发让手机访问 Metro:
```powershell
adb -s <device_id> reverse tcp:8081 tcp:8081
```
**注意事项**
- Debug 模式下 Metro 必须保持运行,否则 APP 报 "Unable to load script"
- USB 每次重连都需重新执行 `adb reverse tcp:8081 tcp:8081`
- 修改 JS/TS 代码后只需在 Metro 终端按 `r` 刷新,无需重新编译 APK
- 仅修改原生代码(AndroidManifest、build.gradle 等)时才需重新编译
---
## 十八、React Native APP 播放历史录像报 ExoPlaybackException
**现象**React Native APP 播放实时视频正常,但播放历史录像片段时报错 `ExoPlaybackException: error_code_io_file_not_found`,播放页面底部显示流地址为 `/api/v1/video/clips/109/stream`(相对路径)。
**原因**:后端返回的录像片段 `playbackUrl` 字段是相对路径 `/api/v1/video/clips/{id}/stream``VideoScreen.tsx` 中 `handlePlayClip` 直接将该相对路径传给 `VideoPlayerScreen` 的 `streamUrl` 参数。`react-native-video` 底层的 ExoPlayer 需要完整的 HTTP URL`http://host:port/path`),无法解析相对路径,导致 `error_code_io_file_not_found`。
此外,修改代码后 Metro 缓存了旧的 JS Bundle,普通 reload 未生效,需要 `--reset-cache` 重启 Metro 才能加载新代码。
**处理**
1. 在 `src/api/client.ts` 中新增 `resolveUrl` 函数,将相对路径转为完整 URL:
```typescript
export const resolveUrl = (path: string): string => {
if (path.startsWith('http://') || path.startsWith('https://')) return path;
const base = (API_BASE_URL || 'http://localhost:3000/api/v1').replace(/\/api\/v1$/, '');
return base + path;
};
```
2. 在 `src/screens/VideoScreen.tsx` 中导入并使用 `resolveUrl`
```typescript
// 修改前
streamUrl: clip.playbackUrl,
// 修改后
streamUrl: resolveUrl(clip.playbackUrl),
```
3. 修改代码后若普通 reload 不生效,需重启 Metro 并清除缓存:
```powershell
# 先杀掉占用 8081 端口的进程
$n = Get-NetTCPConnection -LocalPort 8081; Stop-Process -Id $n.OwningProcess[0] -Force
# 重启 Metro--reset-cache 清除转换缓存)
cd e:\silk\app; npx react-native start --reset-cache
```
**注意事项**
- Metro 的 transform cache 可能导致代码修改不生效,`--reset-cache` 可强制清除
- 实时视频播放正常是因为 `playCamera` API 返回的 `url` 字段已经是完整 URL(来自 WVP/ZLMediaKit
- 后端的 `/api/v1/video/clips/:id/stream` 接口不需要 JWT 认证(在 auth 中间件白名单中),返回 `Content-Type: video/mp4` + `Transfer-Encoding: chunked` 的 ffmpeg 转封装流
---
## 十九、Web 前端实时视频花屏/拖影 + FLV 自动重连
**现象**:Web 前端播放实时 FLV 视频流时,画面运动场景出现花屏和拖影;偶尔出现绿屏一闪而过;几分钟后报 `FLV播放失败: MediaError - MediaMSEError`。
**原因**:多个因素叠加:
### 1. flv.js 缓冲配置不当
`enableStashBuffer: false` + `stashInitialSize: 128` 导致帧未经缓冲直接送入 MSE,运动场景下 P 帧解码乱序,产生花屏和拖影。初始缓冲过小导致首帧绿屏。
**处理**:修改 `VideoPlayer.tsx` 中 flv.js 配置:
```typescript
// 修改前
{ enableWorker: false, enableStashBuffer: false, stashInitialSize: 128 }
// 修改后
{
enableWorker: false,
enableStashBuffer: true,
stashInitialSize: 512,
fixAudioTimestampGap: false,
liveBufferLatencyChasing: true, // 自动追帧,延迟超 3 秒跳到最新
liveBufferLatencyMaxLatency: 3,
liveBufferLatencyMinRemain: 1,
}
```
### 2. FLV 播放器无错误恢复
原代码中 flv.js 遇到 `MediaMSEError` 或 `NetworkError` 时直接销毁播放器并显示错误,不自动重连。直播流因设备心跳超时等原因短暂中断后无法恢复。
**处理**:在 `VideoPlayer.tsx` 中为 FLV 播放器添加自动重连机制(最多 5 次):
```typescript
const createFlvPlayer = () => {
// ...创建播放器...
flvPlayer.on(flvjs.Events.ERROR, (errorType, errorDetail) => {
if (errorType === flvjs.ErrorTypes.MEDIA_ERROR && retryCount < maxRetries) {
retryCount++;
retryTimer = setTimeout(() => createFlvPlayer(), 1000); // 1 秒后重连
} else if (errorType === flvjs.ErrorTypes.NETWORK_ERROR && retryCount < maxRetries) {
retryCount++;
retryTimer = setTimeout(() => createFlvPlayer(), 2000); // 2 秒后重连
} else {
handleError(`FLV播放失败: ${errorType} - ${errorDetail}`);
}
});
};
```
### 3. EasyGBD 分辨率与码率不匹配
EasyGBD 设置为 1080p 时,码率不足以支撑运动场景,P 帧压缩过度导致花屏。浏览器 MSE 软解码 1080p 也容易丢帧。
**处理**:在 EasyGBD APP 中将分辨率从 1080p 改为 720p。同码率下每像素码率翻倍,花屏消失。如需 1080p,需将码率提到 4Mbps 以上。
### 4. 尝试过 ffmpeg 实时转码代理(已弃用)
曾在 Go 后端添加 ffmpeg 转码代理端点 `/video/cameras/:id/live/stream`,使用 `libx264 -preset ultrafast -tune zerolatency` 重新编码直播流。虽然能解决花屏,但延迟增加 1-2 秒,用户无法接受,已回退。
**涉及文件**
- `web/src/components/VideoPlayer.tsx` -- flv.js 配置和自动重连
- `server-go/internal/service/transcode.go` -- 新增 `StreamLive` 方法(保留但未使用)
- `server-go/internal/handler/video_stream.go` -- 新增 `/video/cameras/:id/live/stream` 路由(保留但未使用)
- `server-go/internal/middleware/auth.go` -- 新增 live/stream 路径白名单
**注意事项**
- `liveBufferLatencyChasing` 等 flv.js 扩展选项不在 TypeScript 类型定义中,需用 `as any` 绕过类型检查
- 修改 EasyGBD 分辨率后需重启推流,否则设备可能发送 BYE 中断流
- `fixAudioTimestampGap: false` 可避免时间戳跳跃导致的 MSE 错误
---
## 二十、设备注册成功但前端显示离线 + 录制状态不清理
**现象**
1. EasyGBD 日志显示注册成功,但 Web 前端摄像头管理页显示设备离线,无法查看实时视频。
2. 在前端点击"开始录制"EasyGBD 有"开始视频"日志,约 35 秒后 EasyGBD 出现"停止视频"日志(用户未手动停止)。前端仍显示"录制中",手动点击"停止录制"后 EasyGBD 无反应。再次点击"开始录制"时浏览器控制台报 503 Service Unavailable。
**原因**:两个独立问题:
### 1. 设备实际已离线,EasyGBD 日志为旧记录
WVP API `/api/device/query/devices` 返回 `onLine: false`,设备最后心跳时间为 22:16:59。EasyGBD 中的"注册成功"是之前的旧日志。Go 后端每 30 秒通过 `startDeviceStatusSync` 调用 WVP API 同步设备在线状态到数据库,`is_online` 被正确设为 `false`,前端显示离线是正确的。
排查过程中的关键发现:物理机部署后 WVP API 端口从 18978 变为 18080`server.env` 中 `WVP_API_BASE=http://localhost:18080`),Go 后端的 `startDeviceStatusSync` 使用 `slog.Debug` 记录同步失败,在默认日志级别下不可见。
### 2. 录制流结束后 `activeRecordings` 未清理(代码 Bug
录制流程涉及两个服务:
- **Go 后端**`video_record.go`):维护内存表 `activeRecordings`,前端每 5 秒轮询 `/video/recordings/active` 获取录制状态。
- **recorder-go**`wvp/recorder-go/main.go`):从 ZLMediaKit 拉流写入 S3,维护自己的 `recordings` map。
当 GB28181 设备离线导致流 EOF 时,recorder-go 检测到流结束,会:
1. 创建片段记录并调用 `POST /api/v1/video/clips/internal` 通知 Go 后端 ✓
2. 清理自己的 `recordings` map ✓
3. **但未通知 Go 后端清理 `activeRecordings`** ✗
导致 Go 后端的 `activeRecordings` 一直保留过期记录,前端持续显示"录制中"。用户手动停止后,`stopRecording` 调用 `media.StopPlay()` 发送 BYE,但设备已离线无响应。再次点击"开始录制"时,`StartPlay` 向 WVP 发送 INVITE 失败(设备离线),返回 503。
**处理**
#### 修复:recorder-go 流结束时通知 Go 后端清理录制状态
1. **Go 后端 `video_record.go`**:新增内部接口 `POST /video/recordings/internal/end`
- 接收 recorder-go 的录制结束通知(校验 `x-api-key`
- 清理 `activeRecordings` 内存表
- 调用 `media.StopPlay()` 清理 WVP play session
2. **Go 后端 `auth.go`**:白名单添加 `/api/v1/video/recordings/internal/end`
3. **recorder-go `main.go`**:在录制线程 `record()` 的 defer 中调用 `notifyRecordingEnd()`
- 无论流 EOF、错误、还是手动停止,都会通知 Go 后端
- Go 后端收到通知后清理 `activeRecordings` 并停止 WVP session
**涉及文件**
- `server-go/internal/handler/video_record.go` -- 新增 `endRecordingInternal` handler 和路由
- `server-go/internal/middleware/auth.go` -- 白名单新增内部端点
- `wvp/recorder-go/main.go` -- 新增 `notifyRecordingEnd` 函数,在 `record()` defer 中调用
**修复效果**
| 场景 | 修复前 | 修复后 |
|------|--------|--------|
| 设备离线导致流结束 | 前端一直显示"录制中",需手动点击停止 | recorder-go 自动通知 Go 后端,~5 秒内前端停止显示录制 |
| 流结束后的 WVP session | 残留,直到手动停止才清理 | 自动调用 `StopPlay` 清理 |
| 再次点击"开始录制" | `activeRecordings` 未清理可能冲突 | 无冲突 |
**注意事项**
- 503 错误本身是正确行为 -- 设备离线时 WVP 无法发送 INVITE
- 设备频繁离线的根本原因是 EasyGBD APP 被手机系统杀后台或 Tailscale 连接不稳定
- `startDeviceStatusSync` 的错误日志级别为 `Debug`,排查时需提高日志级别或直接查询 WVP API 确认设备状态
---
## 二十一、GSCW1M-4G 断路器无法连接 VerneMQ
**现象**4G 断路器配网后上电,VerneMQ 会话列表中无新连接,设备始终显示离线。
**原因**:4G 断路器通过蜂窝网络连接,无法访问 Tailscale 内网 IP100.83.103.1),必须使用公网 IP。但服务器在路由器 NAT 后面(内网 192.168.1.58),4G 设备直接访问公网 IP 不会转发到服务器。
**处理**
1. **确认服务器公网 IP**:在服务器执行 `curl -4 ifconfig.me` 返回 `113.17.97.250`,但实际端口转发配置在 `115.191.19.95` 上。两个 IP 都能从外部访问,但端口转发规则只在 `115.191.19.95` 上生效。通过 MQTT 收发测试验证:`115.191.19.95:1883` 连接成功且收到消息,`113.17.97.250:1883` 连接超时。
2. **路由器端口转发**:在路由器管理页面添加 TCP 端口转发规则,外部端口 1883 -> 内部 IP 192.168.1.58:1883。
3. **断路器配网**:长按按键 6 秒进入配网模式,连接热点 `GeekOpen-XXXXXX`(密码 `Aa123456`),在 `192.168.4.1` 配置自定义 MQTT
- 服务器:`115.191.19.95`
- 端口:`1883`
- 用户名:`pan`
- 密码:`pan`
- 发布主题:`silk/default/room-001/862323080741518/up/telemetry`
- 订阅主题:`silk/default/room-001/862323080741518/down/cmd`
4. 保存后点击"设备重启"使配置生效。
**注意事项**
- 4G 设备不能使用 Tailscale IP,必须用公网 IP
- `curl ifconfig.me` 返回的 IP 不一定是有端口转发的 IP,需要实测验证
- 路由器不支持 NAT 回环(hairpin)时,从服务器自身测试公网 IP 会失败,但不影响外部设备连接
---
## 二十二、GSCW1M-4G 断路器 IMEI 固件 Bug 导致设备无法识别
**现象**:断路器已连接 VerneMQ 并上报数据,但数据库中设备状态始终为 `offline`,前端无法控制。
**原因**:设备固件 Bug,不同消息中 `imei` 字段的值不一致:
- info 响应中:`"imei": "8G2323080741518"`(第二位是字母 `G`
- statistic 响应中:`"imei": "862323080741518"`(第二位是数字 `6`
标准 IMEI 为 15 位纯数字,`862323080741518` 是正确值。数据库中 `device_key` 原为用户提供的 `8G2323080741518`(含 G),当设备发送正确 IMEI `862323080741518` 时无法匹配数据库记录,导致设备状态不更新、命令无法发送(`deviceTopics` 中的 key 与数据库 `device_key` 不一致)。
**处理**
1. **修正数据库**:将 `device_key` 从 `8G2323080741518` 更新为 `862323080741518`。
2. **后端 IMEI 规范化**:在 `mqtt.go` 的 `handleMessage` 中,对 `imei` 字段做规范化处理,将 `G` 替换为 `6`
```go
if imei, ok := body["imei"].(string); ok && imei != "" {
// 规范化 IMEI:部分设备固件 bug 会将 "6" 误发为 "G"
deviceKey = strings.ReplaceAll(imei, "G", "6")
}
```
**涉及文件**`server-go/internal/service/mqtt.go` -- IMEI 提取逻辑增加 `G` -> `6` 规范化
---
## 二十三、GSCW1M-4G 断路器响应在订阅主题上(主题方向反配)
**现象**:断路器配网时发布主题填的是 `/up/telemetry`,订阅主题填的是 `/down/cmd`,但设备的响应消息出现在 `/down/cmd` 主题上(即订阅主题),而不是 `/up/telemetry`(发布主题)。
**原因**:与 GSPE1B 智能插座相同,GeekOpen 部分设备的"发布主题"和"订阅主题"在配网页面的含义与后端的预期方向相反。设备把配网页面中填写的"订阅主题"作为实际发布方向使用。
**处理**:后端已适配此情况:
1. 同时订阅 `silk/+/+/+/up/telemetry` 和 `silk/+/+/+/down/cmd` 两个通配符主题
2. `PublishToDevice` 方法自动检测设备上报主题方向:
- 设备上报 `/up/telemetry` -> 命令发送到 `/down/cmd`(正常方向)
- 设备上报 `/down/cmd` -> 命令发送到 `/up/telemetry`(反配方向)
无需修改设备配网配置,后端自动兼容两种方向。
---
## 二十四、历史回看视频播放失败(S3 预签名 URL SignatureDoesNotMatch
**现象**:前端「历史回看」点击播放按钮,提示"视频播放失败"。
**症状链路**(与第十二节"历史回看视频播放失败"完全一致):
1. 前端 `Video.tsx` 调用 `getClipPlayUrl(clipId)` 获取 `/api/v1/video/clips/:id/stream` 同源 URL
2. `VideoPlayer.tsx` 走 `video.src = url` 原生 MP4 分支
3. 浏览器发起 GET 请求,触发后端 `streamClip` handler
4. 后端调用 `transcode.StreamClip` 启动 ffmpeg 拉取 S3 预签名 URL 转封装为 fragmented MP4
5. ffmpeg 报 `HTTP error 403 Forbidden`,后端返回 502 JSON `{"error":"ffmpeg 异常退出: exit status 1"}`
6. 浏览器 `<video>` 收到非视频内容触发 error 事件 → "视频播放失败"
**根因诊断过程**
1. 直接 curl 预签名 URLCeph RGW 返回 XML `<Code>SignatureDoesNotMatch</Code>` —— 排除了 access key/secret key 错误、CORS、网络等可能。
2. 用 boto3 在 Windows 本地生成同样格式的预签名 URL(参数完全一致),curl 能返回 200 OKContentLength 47MB —— 证明 access/secret key 正确、bucket 可读、Ceph RGW 工作正常,**问题在 Go 后端手动实现的 V4 签名计算结果错误**。
3. 检查 Go 后端进程实际环境变量:`/proc/140619/environ` 中只有 `S3_ACCESS_KEY`、`S3_BUCKET_ARCHIVE`、`S3_BUCKET_EVENTS`、`S3_ENDPOINT`**没有 `S3_SECRET_KEY`**
4. 检查 `config.go``S3SecretKey` 的 `envDefault:""` —— **默认值为空字符串**。
5. 检查 `start.sh`**没有 `export S3_SECRET_KEY=...`**。
6. 结论:Go 后端用空字符串作为 secret_key 计算 AWS V4 签名 → 签名错误 → Ceph RGW 拒绝。
**与第十二节的对比**
| 项目 | 第十二节 | 本次(第二十四节) |
|------|----------|---------------------|
| 症状 | "视频播放失败" | "视频播放失败"(相同) |
| 接口 | S3 预签名 URL 403 | S3 预签名 URL 403(相同) |
| 直接原因 | AWS SDK 自动加 `x-amz-checksum-mode`/`x-id` 参数,Ceph RGW 不兼容 | Go 后端用空 secret_key 算签名,SignatureDoesNotMatch |
| 根因层次 | SDK 行为差异(第三方库 bug) | 部署脚本配置疏忽(人为遗漏环境变量) |
| 解决方式 | 手动实现 V4 签名绕过 SDK | 修复 config.go 默认值 + start.sh 显式 export |
**结论**:本次**症状与第十二节完全相同**,但根因不同。属于配置疏忽导致的"重蹈症状之覆辙",但不是"重蹈根因之覆辙"。这提示:部署脚本和配置默认值都需要将敏感凭据完整列出,不能依赖"反正某个上游会传入"。
**处理**
1. **`server-go/internal/config/config.go`**:将 `S3SecretKey` 默认值从 `""` 改为 `"Silk-App-Secret-2026!"`,与项目部署技能中的 Ceph S3 默认凭据保持一致。
2. **`server-go/start-shipped.sh`**:新增 `export S3_SECRET_KEY='Silk-App-Secret-2026!'`,使部署脚本自包含所有必需环境变量。
3. 重新交叉编译后端,上传到 `/home/pan/silk/server-go/server-go-linux`,用 `setsid bash start.sh` 脱离 SSH 会话启动。
**验证**
| 检查项 | 修复前 | 修复后 |
|--------|--------|--------|
| 后端进程环境变量 `S3_SECRET_KEY` | 缺失 | `Silk-App-Secret-2026!` |
| `curl /api/v1/video/clips/139/stream` | HTTP 502 + JSON 错误 | HTTP 2005157496 字节,video/mp4 |
| ffmpeg 日志 | `HTTP error 403 Forbidden` | `frame=471 Lsize=5037kB time=00:00:20.60` |
| 后端响应 Content-Type | `video/mp4` 但内容是 JSON | `video/mp4`,文件头 `ftypisom` 标准 MP4 |
| 前端 `<video>` | error 事件 → "视频播放失败" | 正常播放 |
**注意事项**
- `start.sh` 的 `nohup ... &` 在 plink SSH 会话结束时会被带走,需要用 `setsid bash start.sh` 显式脱离会话
- 部署时务必检查 `/proc/$PID/environ`,确认所有必需环境变量已正确注入
- Go 的 `env` 包默认值生效条件是环境变量未设置;环境变量设为空字符串时不会使用默认值
- 修复后 recorder-go(录制服务)不受影响,因为它有自己独立的 S3 配置加载逻辑
---
## 二十五、FLV 实时播放失败 "NetworkError - Exception"CSP 跨域 + ZLM hook 错误)
**现象**`/videos` 页面"实时监控"Tab 点击摄像头播放,前端报错 `FLV播放失败: NetworkError - Exception`,视频无法显示。强制刷新浏览器无效。
**原因(多层叠加)**
1. **ZLMediaKit 短时间关流**`general.streamNoneReaderDelayMS=20000`(20 秒无人观看即关流)+ WVP `stream-on-demand=true`(按需拉流,hook 返回"关闭=true"),导致流在 22 秒后被关闭
2. **ZLMediaKit hook URL 错误**ZLM 运行时配置 `hook.on_publish=http://127.0.0.1:18080/index/hook/on_publish`,但 ZLM 容器内的 `127.0.0.1` 指向 ZLM 自身而非 WVP 容器,推流鉴权 hook 调用失败(日志:`禁止RTP推流:[network err]:3(connection refused)`),ZLM 拒绝摄像头 RTP 推流。WVP 容器环境变量 `ZLM_HOOK_HOST=polaris-wvp` 设置正确,但 WVP-PRO 2.7.4 的 auto-config 未正确应用该值
3. **CSP 阻止跨域 fetch**Go 后端 `fixZlmPort` 函数返回绝对路径 `http://100.83.103.1:8081/rtp/...`,但 `web/server.cjs` 的 CSP 头 `connect-src 'self' ws: wss:` 只允许同源请求,flv.js 跨域 fetch ZLMediaKit 时被浏览器阻止
**处理**
1. 调用 ZLMediaKit `setServerConfig` API 修正运行时配置:
- `general.streamNoneReaderDelayMS``20000` → `3600000`
- 11 个 hook URL`hook.on_publish`/`on_play` 等):`http://127.0.0.1:18080/...` → `http://polaris-wvp:18080/...`
- 通过 Python 脚本 `/home/pan/silk/fix_zlm_hook.py` 实现,留存服务器供后续修复使用
2. 修改 WVP 配置 `stream-on-demand: true → false`,避免 hook 返回"关闭=true"
3. 修改 `server-go/internal/service/media.go` 的 `fixZlmPort` 函数,提取 `/rtp/` 起始的相对路径返回(含 query string),让前端通过 `server.cjs` 内置的 `/rtp/` 代理转发到 `127.0.0.1:8081`,避免跨域
4. 重启 WVP 让设备重新注册 GB28181,然后通过 WVP API 验证点播成功
**验证**
- WVP `/api/play/start/{deviceId}/{channelId}` 返回 `code=0` 成功,包含 FLV/HLS/FMP4 地址
- Go 后端 `/api/v1/video/cameras/1/live` 返回相对路径 FLV URL `/rtp/xxx.live.flv?...`
- 通过 `server.cjs` 5174 端口代理访问 FLV 流,能正常返回视频二进制数据
- 浏览器中 FLV 播放正常
**遗留注意事项**:WVP 重启可能会再次把 ZLM hook URL 覆盖回 `127.0.0.1:18080`。若 FLV 播放再次失败,登录服务器执行 `python3 /home/pan/silk/fix_zlm_hook.py` 即可修复。
---
## 二十六、历史回看查不到录像(前端时间范围硬编码 + RangePicker 清空不生效)
**现象**`/videos` 页面"历史回看"Tab 查询历史录像,时间段留空也查不到任何记录。
**原因**
1. **数据库确实今天无新录像**:数据库 17 条录像全部是 7月17日(昨天),今天 0 条新录像(无活跃录制任务)
2. **前端时间范围硬编码**`web/src/pages/Video.tsx` PlaybackTab 的 `handleSearch` 函数硬编码 `to: dayjs().toISOString()`,即使清空 RangePicker 仍查"今天 00:00 到 现在"
3. **RangePicker onChange 清空不更新 state**:清空时 `vals` 为 null,但 onChange 中没有处理 null 分支,`dateRange` state 仍保持默认值 `[今天开始, 现在]`
**处理**
1. 修改 `dateRange` state 类型为 `[Dayjs, Dayjs] | null`
2. 修改 RangePicker `onChange`:清空时设为 `null` 表示查全部
3. 修改 `handleSearch`:仅当 `dateRange` 非空时传 from/to,为 null 时查全部
4. 修改自动刷新定时器同样支持 dateRange 为 null
**验证**:清空时间范围查询,能查到 7月17日的 17 条录像。
**用户认知纠正**:实时视频流(ZLMediaKit 接收 RTP 推流并分发 FLV/HLS)≠ 录像(recorder-go 从 ZLMediaKit 拉 FLV 流分段存到 Ceph S3)。实时视频能播放不代表在录像,必须通过"开始录制"按钮触发录制。
---
## 二十七、摄像头管理显示"开始录制"而非"正在录制"(录制状态查询改进)
**现象**:用户认为摄像头正在录像,但 `/videos` "摄像头管理"Tab 显示"开始录制"按钮(应为"正在录制"状态)。
**原因**
1. **实际未在录制**recorder-go `/record/status` 接口返回 `{"recordings":[]}`,确认无活跃录制任务
2. **用户误解**:用户把"实时视频流能播放"误判为"正在录像"
3. **潜在的内存丢失问题**`server-go/internal/handler/video_record.go` 中 `activeRecordings` 是 Go 后端进程内存 map,Go 后端重启会清空。虽然当前未在录制,但如果录制中 Go 后端重启,会导致前端显示"开始录制"而 recorder-go 仍在实际录制
**处理**
1. 修改 `listActiveRecordings` 函数:优先调用 recorder-go `/record/status` 接口获取真实录制状态,并自愈内存 map(从 recorder-go 恢复 activeRecordings)。recorder-go 不可用时回退到内存 map
2. 修改路由注册:`listActiveRecordings()` → `listActiveRecordings(cfg)` 传入配置以获取 `RecorderAPIBase`
**验证**`/api/v1/video/recordings/active` 接口返回 recorder-go 的真实录制状态。
**实时观看 vs 录像 的区别**:
| 项目 | 实时观看 | 录像 |
|------|---------|------|
| 触发 | 点摄像头"播放" | 点"开始录制"按钮 |
| 流向 | 摄像头 → ZLMediaKit → 浏览器 | 摄像头 → ZLMediaKit → recorder-go → Ceph S3 |
| 数据存储 | 不存储 | 5 分钟分段存到 S3 |
| 控制接口 | `/video/cameras/:id/live` | `/video/cameras/:id/record/start` |
---
## 二十八、摄像头显示离线 + APP 实时播放失败 ERROR_CODE_IO_BAD_HTTP_STATUS
**现象**
1. Web 前端、APP 上摄像头列表显示"离线",但摄像头实际已注册成功(WVP 设备列表 `onLine=true`
2. APP 播放实时视频报 `ExoPlaybackException: ERROR_CODE_IO_BAD_HTTP_STATUS`,流地址为 `http://100.83.103.1:3000/api/v1/video/cameras/1/live/stream`
**原因**:存在两个独立问题。
### 问题一:摄像头显示离线
后端进程的环境变量 `WVP_API_BASE` 未正确设置,默认指向 `http://localhost:18978`(旧端口),而 WVP Tomcat 实际监听 `18080` 端口。
- `listCameras` handler 调用 `media.SyncDeviceStatus()` 查询 WVP `/api/device/query/devices` 获取在线状态
- WVP API 不可达 → `SyncDeviceStatus()` 返回 error → 摄像头 `is_online` 字段保持数据库中的旧值(false)
- 前端读取 `c.online` 字段显示"离线"
### 问题二:APP 直播流 502
[video_stream.go](file:///e:/silk/server-go/internal/handler/video_stream.go) 的 `streamLive` 函数在 StartPlay 成功后,直接使用 `result.FLV` 作为 `http.Get()` 的参数。但 WVP 返回的 FLV 地址是**相对路径**(如 `/rtp/34020000002000000001_34020000001310000001.live.flv?...`),Go 的 `http.Get()` 无法处理无协议头的 URL,返回 502 错误。
```go
// 修复前(有 bug
sourceURL = result.FLV // "/rtp/xxx.live.flv" - 相对路径
resp, err := http.Get(sourceURL) // 失败:unsupported protocol scheme ""
```
**处理**
### 修复一:用 start.sh 启动后端,正确设置 WVP_API_BASE
`/home/pan/silk/server-go/start.sh` 中显式 `export WVP_API_BASE=http://localhost:18080`。必须用 `setsid bash start.sh` 启动(而非直接 `nohup ./server-go-linux &`),否则环境变量不生效。
### 修复二:修复 ZLM hook URL
运行 `python3 /home/pan/silk/fix_zlm_hook.py`,将 10 个 hook URL 从 `127.0.0.1:18080` 修正为 `polaris-wvp:18080`,避免 ZLM 推流鉴权失败。
### 修复三:streamLive 拼接 ZLM 基地址
修改 `streamLive` 函数:
1. 将函数参数从 `transcode *service.TranscodeService` 改为 `cfg *config.Config`
2. 在 `sourceURL` 使用前,检测如果是相对路径则拼接 `cfg.ZLMAPIBase`
```go
// 确保地址是完整的 ZLM 地址(WVP 返回的可能是相对路径 /rtp/...)
if strings.HasPrefix(sourceURL, "/") {
sourceURL = strings.TrimSuffix(cfg.ZLMAPIBase, "/") + sourceURL
}
```
3. StartPlay 失败的降级分支也改用 `cfg.ZLMAPIBase` 拼接(不再硬编码 IP):
```go
sourceURL = strings.TrimSuffix(cfg.ZLMAPIBase, "/") + "/rtp/" + *camera.GbDeviceID + "_" + *camera.GbChannelID + ".live.flv?originTypeStr=rtp_push&videoCodec=H264"
```
4. 同步更新路由注册:`streamLive(db, media, transcode)` → `streamLive(db, media, cfg)`
**验证**
1. `/api/v1/video/cameras` 返回 `online=true, isOnline=true` ✓
2. WVP `/api/device/query/devices` 返回 `onLine=true` ✓
3. `/api/v1/video/cameras/1/live/stream` 返回 HTTP 200Content-Type: `video/x-flv`12秒下载 2MB 有效 FLV 数据 ✓
4. APP 实时视频播放成功 ✓
5. Web 前端摄像头列表显示"在线" ✓
**注意事项**
- `media.extractPlayResult` 中的 `fixZlmPort()` 函数会将 ZLM 返回的绝对 URL 转换为相对路径(`/rtp/...`),这是为了 Web 前端通过 server.cjs 代理访问。但 APP 使用的 `/live/stream` 接口在后端代理 FLV 流时,必须将相对路径转回绝对 URL 才能 `http.Get()`
- WVP-PRO 2.7.4 存在 auto-config bug,重启后会覆盖 ZLM hook URL 为 `127.0.0.1:18080`,需手动执行 `fix_zlm_hook.py` 修复
- 后端必须用 `start.sh` 启动以加载正确的环境变量,直接运行二进制会导致 `WVP_API_BASE` 使用默认值(18978 端口)
---
## 二十九、APP 各标签页数据显示异常(.env 缺失 + token 过期)
**现象**APP 启动后:
- 「视频」标签页的「摄像头」「录像片段」列表均为空白,显示"暂无摄像头"/"暂无录像片段"
- 「设备」「蚕房」标签页显示 "status code 401"
- 「告警」标签页显示 "status code 404"
- 「仪表盘」标签页显示"暂无遥测数据 暂无趋势数据"
**原因**:多个问题叠加:
### 1. `.env` 文件缺失导致 API_BASE_URL fallback 到 localhost
APP 代码通过 `@env` 导入 `API_BASE_URL`[app/src/api/client.ts](file:///e:/silk/app/src/api/client.ts)),但项目根目录下只有 `.env.example` 没有 `.env` 文件。`API_BASE_URL` 为 `undefined`fallback 到 `http://localhost:3000/api/v1`。
真机上 `localhost` 指向设备自身,无法访问 PC 或远程后端,所有 API 请求失败。由于 `VideoScreen.tsx` 中 `getCameras().catch(() => [])` 和 `getClips().catch(() => [])` 静默吞掉错误返回空数组,页面只显示"暂无",无错误提示。
### 2. token 过期导致 401
JWT 有效期 2 小时(`JWT_EXPIRES_IN=2h`)。APP 之前登录过,token 存在 AsyncStorage 中,但重启 APP 时 token 已过期。
`restoreSession` 读取过期 token → 设置到 store → APP 跳转 Main 页面 → `fetchUser` 调用 `/auth/me` 返回 401 → 触发 `logout` 清空 AsyncStorage。但此时 Dashboard 等页面已开始加载数据,请求因无 token 返回 401 "未提供认证信息"。
### 3. 401 拦截器未跳转登录页
原 401 拦截器只清除 AsyncStorage 中的 token,未调用 navigation 跳转到 Login 页面,导致用户停留在 Main 页面看到一堆 401 错误。
### 4. 告警页面的 404 实际是 401
后端 `/alarms` 路由存在且正常。APP 显示的 "status code 404" 是 axios 默认错误消息格式(`err.message`),实际请求因无 token 返回 401。
### 5. 仪表盘"暂无遥测数据"是 token 问题
后端 `/telemetry` 路由正常返回数据。但因请求携带过期 token 返回 401,`getTelemetry().catch(() => [])` 静默返回空数组,显示"暂无遥测数据"。
**处理**
1. **创建 `.env` 文件**:在 `app/` 目录下创建 `.env`,配置后端地址(手机有 Tailscale 可直接访问):
```
API_BASE_URL=http://100.83.103.1:3000/api/v1
WS_BASE_URL=ws://100.83.103.1:3000/ws
```
2. **清除 Metro 缓存重新打包**`.env` 在编译时读取,修改后需 `--reset-cache`
```powershell
$metroPid = (Get-NetTCPConnection -LocalPort 8081 -State Listen).OwningProcess
Stop-Process -Id $metroPid -Force
cd e:\silk\app; npx react-native start --reset-cache
```
3. **改进 401 拦截器**:token 失效时自动跳转登录页([app/src/api/client.ts](file:///e:/silk/app/src/api/client.ts)):
```typescript
if (status === 401) {
await AsyncStorage.multiRemove([TOKEN_KEY, REFRESH_TOKEN_KEY]);
if (navigationRef) {
navigationRef.navigate('Login');
}
}
```
4. **重新登录**:密码 `silk@123`
**验证**Metro 日志):
```
POST /auth/login -> 200
GET /rooms -> 200
GET /devices -> 200
GET /alarms -> 200
GET /telemetry -> 200
GET /video/clips -> 200
GET /video/cameras -> 200
```
**注意事项**
- `.env` 文件不提交到 git,部署到新环境时需手动创建
- `adb reverse tcp:3000 tcp:3000` 可作为 localhost fallback 的临时方案,但手机重连后失效
- 手机有 Tailscale 时可直接访问 `100.83.103.1:3000`,无需 adb reverse
---
## 三十、APP 图标显示为占位符(react-native-vector-icons 字体未链接)
**现象**:APP 底部标签栏(仪表盘、蚕房、设备、告警、视频)的图标、设置页面用户头像旁的列表图标、各页面的按钮图标均显示为占位符(方块或空白),而非预期的 Material Design 图标。
**原因**`react-native-vector-icons` 的字体文件未复制到 Android 的 `assets/fonts/` 目录。
APP 使用 `react-native-vector-icons` 的 `MaterialCommunityIcons` 字体渲染图标([app/src/navigation/AppNavigator.tsx](file:///e:/silk/app/src/navigation/AppNavigator.tsx))。字体文件存在于 `node_modules/react-native-vector-icons/Fonts/MaterialCommunityIcons.ttf`,但 React Native 0.74 的 autolinking 不会自动复制字体到 `android/app/src/main/assets/fonts/`,需要手动配置。
字体缺失时,`Icon` 组件无法找到对应的字形(glyph),显示为占位符(.notdef 字符)。
**处理**
1. 复制字体文件到 Android assets 目录:
```powershell
New-Item -ItemType Directory -Force -Path "e:\silk\app\android\app\src\main\assets\fonts"
Copy-Item "e:\silk\app\node_modules\react-native-vector-icons\Fonts\MaterialCommunityIcons.ttf" `
"e:\silk\app\android\app\src\main\assets\fonts\" -Force
```
2. 重新构建 APK(字体是原生资源,需重新打包,不能只 reload JS):
```powershell
cd e:\silk\app; npx react-native run-android
```
**注意事项**
- React Native 0.74 的 `react-native.config.js` 不支持 `project.android.assets` 字段(会报 `Config Validation Error`),需直接复制字体文件
- 仅修改 JS/TS 代码只需 Metro reload,但修改原生资源(字体、AndroidManifest、build.gradle 等)必须重新 `run-android`
- `react-native-paper` 的 `List.Icon`、`IconButton` 也依赖 `MaterialCommunityIcons` 字体
---
## 三十一、APP 设置页面无法向下滚动
**现象**:点击 APP 右上角齿轮图标进入设置页面后,内容超出屏幕底部,但无法向下滑动查看"服务器配置""关于""退出登录"等下方内容。
**原因**[SettingsScreen.tsx](file:///e:/silk/app/src/screens/SettingsScreen.tsx) 使用 `<View>` 作为内容容器,`<View>` 不支持滚动。当内容(用户头像 + 用户信息卡片 + 服务器配置卡片 + 关于卡片 + 退出登录按钮)总高度超过屏幕时,下方内容被裁剪且无法访问。
**处理**:将外层 `<View>` 替换为 `<ScrollView>`
```typescript
// 修改前
<View style={styles.content}>
{/* 内容 */}
</View>
// 修改后
<ScrollView style={styles.scrollView} contentContainerStyle={styles.content}>
{/* 内容 */}
</ScrollView>
```
对应 styles 新增 `scrollView: { flex: 1 }``content` 样式改为 `contentContainerStyle` 使用。
**注意事项**
- React Native 中 `<View>` 不支持滚动,内容超出屏幕需用 `<ScrollView>` 或 `<FlatList>`
- `ScrollView` 的 `style` 设置容器样式(如 flex、背景色),`contentContainerStyle` 设置内容样式(如 padding
- 仅修改 JS/TS 代码,Metro reload 即可生效,无需重新构建 APK