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

58 KiB
Raw Permalink Blame History

故障排查处理记录

一、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

netsh interface portproxy add v4tov4 listenport=8160 listenaddress=100.83.103.1 connectport=8160 connectaddress=172.18.127.247

EasyGBD 中 SIP 传输协议改为 TCPnetsh 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 端口:

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 只导入了 getpost,缺少 patchdelupdateCameradeleteCamera 调用了未定义的函数。

处理

// 修改前
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/

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. archiveAndRestartstopRecording 中将 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

# 修改前
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 默认未启用 systemdservice cron start 失败("Failed to connect to bus"),cron 守护进程未运行。

处理

  1. 手动启动 cronsudo cron
  2. 配置 WSL2 启用 systemd,写入 /etc/wsl.conf
    [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=ENABLEDx-id=GetObject 查询参数。Ceph RGW (squid) 在验证签名时无法正确处理这些参数,返回 403 Forbidden。

处理:在 s3.service.ts 中用 Node.js crypto 模块手动实现 AWS S3 V4 签名,完全绕过 AWS SDK v3 的 presigner,不添加任何额外参数:

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

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

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 文件:
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
  1. 重启 WVP 和 ZLMediaKit 容器,等待摄像头重新注册。
  2. 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

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. 构建生产版本

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

// 核心逻辑:
// 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. 启动命令

# 构建前端
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 显示 inactive1883 端口未监听。
  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
    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. 创建明文密码文件并加密:
    echo "pan:pan" > /etc/vernemq/vmq.passwd
    vmq-passwd -U /etc/vernemq/vmq.passwd   # 将明文转为哈希
    
  2. 设置文件权限:
    chown vernemq:vernemq /etc/vernemq/vmq.passwd
    chmod 640 /etc/vernemq/vmq.passwd
    
  3. 重启 VerneMQsudo 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"
<application
  android:name=".MainApplication"
  android:usesCleartextTraffic="true"
  ...>
  1. 重新编译 Debug APK 并安装:
$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
  1. USB 连接时需设置 ADB 端口转发让手机访问 Metro:
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}/streamVideoScreen.tsxhandlePlayClip 直接将该相对路径传给 VideoPlayerScreenstreamUrl 参数。react-native-video 底层的 ExoPlayer 需要完整的 HTTP URLhttp://host:port/path),无法解析相对路径,导致 error_code_io_file_not_found

此外,修改代码后 Metro 缓存了旧的 JS Bundle,普通 reload 未生效,需要 --reset-cache 重启 Metro 才能加载新代码。

处理

  1. src/api/client.ts 中新增 resolveUrl 函数,将相对路径转为完整 URL
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;
};
  1. src/screens/VideoScreen.tsx 中导入并使用 resolveUrl
// 修改前
streamUrl: clip.playbackUrl,

// 修改后
streamUrl: resolveUrl(clip.playbackUrl),
  1. 修改代码后若普通 reload 不生效,需重启 Metro 并清除缓存:
# 先杀掉占用 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 配置:

// 修改前
{ 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 遇到 MediaMSEErrorNetworkError 时直接销毁播放器并显示错误,不自动重连。直播流因设备心跳超时等原因短暂中断后无法恢复。

处理:在 VideoPlayer.tsx 中为 FLV 播放器添加自动重连机制(最多 5 次):

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 变为 18080server.envWVP_API_BASE=http://localhost:18080),Go 后端的 startDeviceStatusSync 使用 slog.Debug 记录同步失败,在默认日志级别下不可见。

2. 录制流结束后 activeRecordings 未清理(代码 Bug

录制流程涉及两个服务:

  • Go 后端video_record.go):维护内存表 activeRecordings,前端每 5 秒轮询 /video/recordings/active 获取录制状态。
  • recorder-gowvp/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_key8G2323080741518 更新为 862323080741518

  2. 后端 IMEI 规范化:在 mqtt.gohandleMessage 中,对 imei 字段做规范化处理,将 G 替换为 6

    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/telemetrysilk/+/+/+/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.tsxvideo.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_KEYS3_BUCKET_ARCHIVES3_BUCKET_EVENTSS3_ENDPOINT没有 S3_SECRET_KEY
  4. 检查 config.goS3SecretKeyenvDefault:"" —— 默认值为空字符串
  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.shnohup ... & 在 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=2000020 秒无人观看即关流)+ 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 阻止跨域 fetchGo 后端 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.streamNoneReaderDelayMS200003600000
    • 11 个 hook URLhook.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.gofixZlmPort 函数,提取 /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.goactiveRecordings 是 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.gostreamLive 函数在 StartPlay 成功后,直接使用 result.FLV 作为 http.Get() 的参数。但 WVP 返回的 FLV 地址是相对路径(如 /rtp/34020000002000000001_34020000001310000001.live.flv?...),Go 的 http.Get() 无法处理无协议头的 URL,返回 502 错误。

// 修复前(有 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
// 确保地址是完整的 ZLM 地址(WVP 返回的可能是相对路径 /rtp/...)
if strings.HasPrefix(sourceURL, "/") {
    sourceURL = strings.TrimSuffix(cfg.ZLMAPIBase, "/") + sourceURL
}
  1. StartPlay 失败的降级分支也改用 cfg.ZLMAPIBase 拼接(不再硬编码 IP):
sourceURL = strings.TrimSuffix(cfg.ZLMAPIBase, "/") + "/rtp/" + *camera.GbDeviceID + "_" + *camera.GbChannelID + ".live.flv?originTypeStr=rtp_push&videoCodec=H264"
  1. 同步更新路由注册: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_URLapp/src/api/client.ts),但项目根目录下只有 .env.example 没有 .env 文件。API_BASE_URLundefinedfallback 到 http://localhost:3000/api/v1

真机上 localhost 指向设备自身,无法访问 PC 或远程后端,所有 API 请求失败。由于 VideoScreen.tsxgetCameras().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

    $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):

    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-iconsMaterialCommunityIcons 字体渲染图标(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 目录:

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

    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-paperList.IconIconButton 也依赖 MaterialCommunityIcons 字体

三十一、APP 设置页面无法向下滚动

现象:点击 APP 右上角齿轮图标进入设置页面后,内容超出屏幕底部,但无法向下滑动查看"服务器配置""关于""退出登录"等下方内容。

原因SettingsScreen.tsx 使用 <View> 作为内容容器,<View> 不支持滚动。当内容(用户头像 + 用户信息卡片 + 服务器配置卡片 + 关于卡片 + 退出登录按钮)总高度超过屏幕时,下方内容被裁剪且无法访问。

处理:将外层 <View> 替换为 <ScrollView>

// 修改前
<View style={styles.content}>
  {/* 内容 */}
</View>

// 修改后
<ScrollView style={styles.scrollView} contentContainerStyle={styles.content}>
  {/* 内容 */}
</ScrollView>

对应 styles 新增 scrollView: { flex: 1 }content 样式改为 contentContainerStyle 使用。

注意事项

  • React Native 中 <View> 不支持滚动,内容超出屏幕需用 <ScrollView><FlatList>
  • ScrollViewstyle 设置容器样式(如 flex、背景色),contentContainerStyle 设置内容样式(如 padding
  • 仅修改 JS/TS 代码,Metro reload 即可生效,无需重新构建 APK

三十二、Web 知识库症状图谱预览打不开且保存不生效

现象:Web「知识库 → 蚕病百科」编辑病种时上传新的症状图谱:编辑弹窗里的预览图打不开;点击提交提示"保存成功",但重新打开/刷新后图谱仍未绑定(image_url 为空)。

原因(两个独立问题,均为前端问题,后端与存储正常)

  1. 预览图打不开:CSP 拦截跨源图片 web/server.cjsContent-Security-Policyimg-src 'self' data: blob:,只允许同源图片。上传成功后返回的图片 URL 是 http://100.83.103.1:7480/silk-images/...Ceph RGW,端口 7480 与页面 5174 不同源),浏览器直接拦截,图片本身可匿名访问(200 image/jpeg)。

  2. 保存不生效:imageUrl 未注册到表单,提交时被丢弃 web/src/pages/Knowledge.tsx 上传成功后执行 form.setFieldValue('imageUrl', res.url),但「症状图谱图片」的 Form.Item 只有 label、没有 name="imageUrl"。antd 的 form.validateFields() 只返回已注册(有 Form.Item name)的字段,imageUrl 不在其中,因此 PATCH /knowledge/diseases/:id 的请求体里根本没有 imageUrl,后端收到 200 但 image_url 列不更新(updated_at 会变)。

排查证据(开发服务器 100.83.103.1

  • web/web.logPOST /api/v1/knowledge/imagesPATCH /api/v1/knowledge/diseases/17b1cae4-21cc-4996-9db3-96f785aef800GET /api/v1/knowledge/diseases
  • server-go/server-go.log(实际生效日志,非 /home/pan/silk/server-go.log):15:57:54 POST images 20015:58:03 PATCH 200
  • Ceph silk-images 桶:存在 knowledge/20260812/36a2bc56e466fb41.jpeg286608 字节,image/jpeg),匿名 GET 200
  • PostgreSQL:白僵病 image_url 为 NULL;全表无任何病种有 image_url
  • 部署产物校验:服务器 server.cjs md5 与仓库一致(CSP 生效);新 bundle 中已含修复代码

处理

  1. 前端修复(web/src/pages/Knowledge.tsxweb/server.cjs):
    • 「症状图谱图片」后新增隐藏字段,使 imageUrl 注册进表单并被 validateFields() 返回:
      <Form.Item name="imageUrl" hidden>
        <Input />
      </Form.Item>
      
    • CSP img-src 放行开发环境 S3 源:img-src 'self' data: blob: http://100.83.103.1:7480
  2. 构建与部署(本地 npm run lintnpm run build 均通过):
    • 备份旧产物:/home/pan/backups/web-20260812-1605/dist、server.cjs、web.log
    • 上传新 distserver.cjs,重启 node(旧 pid 3764542 → 新 pid 3803472
    • 验证:http://localhost:5174/ 200CSP 头含 http://100.83.103.1:7480;新 bundle index-CSVEb7gG.js 200/api/v1/health 200
  3. 数据补绑(用户确认直接改库):
    UPDATE diseases
    SET image_url = 'http://100.83.103.1:7480/silk-images/knowledge/20260812/36a2bc56e466fb41.jpeg',
        updated_at = now()
    WHERE id = '17b1cae4-21cc-4996-9db3-96f785aef800';
    
    结果:UPDATE 1,查询确认 image_url 已写入;图片匿名 GET 200。

验证结果:DB 行已绑定图谱 URL;CSP 已放行;部署产物包含隐藏字段注册;lint/build 通过(详见上文)。

回滚点

  • 前端回滚:恢复 /home/pan/backups/web-20260812-1605/ 下的旧 dist/server.cjs 并重启 node
  • 数据回滚:UPDATE diseases SET image_url = NULL, updated_at = now() WHERE id = '17b1cae4-21cc-4996-9db3-96f785aef800';pg_restore 恢复 /home/pan/backups/diseases-20260812-1605.dump

注意事项

  • /home/pan/silk 不是 git 仓库,无法打 tag 回滚,只能依赖文件备份(本次已备份)
  • 部署指南中的 admin/admin123 已失效;已用当前 admin 账号完成 API 级冒烟(登录成功 → GET /api/v1/knowledge/diseases 返回白僵病 imageUrl 已绑定),密码本身不写入文档
  • 已知边界:编辑页若点「移除」图片再保存,imageUrl 会以 undefined 提交(键被省略),不会清空库里已有图片;如需「移除即清空」后续应显式传 null
  • CSP 当前放行的是开发服务器 IP;生产切云 OSS/COS 时需把 OSS/CDN 域名加入 img-src,或改为同源代理

2026-08-12/13 部署与开发环境踩坑合集(重要)

1. NetBird 登录过期与隧道闪断

现象SSH 突然超时;netbird status 显示 peer login has expired, please log in once more处理netbird login --management-url https://115.191.19.95:6680 重新 SSO(需用户在浏览器完成,链接有时效),再 netbird up;数据面恢复需要几十秒,可用 ping/端口探测确认后再继续。

2. 服务器 pip 慢 / 未配置国内源

现象:服务器 pip 安装依赖极慢(官方 PyPI)。 处理:已持久化配置清华源 pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple(写入 /home/pan/.config/pip/pip.conf,对 pan 全部 venv 生效)。另需 python3.10-venv 才能建 venv。

3. web 构建报 WebAssembly.Memory.grow(): Maximum memory size exceeded

现象npm run build 在 rolldown 压缩阶段失败(超大单 chunk)。 处理vite.config.ts 增加 build.rollupOptions.output.manualChunks(函数形式,按 react/antd/pro/echarts/vendor 拆分),构建通过。

4. 服务器上覆盖运行中的二进制报 text file busy

现象cp 新二进制到正在运行的 server-go-linux 报 ETXTBSY。 处理:先 mv 旧文件(如 server-go-linux.old-<时间戳>)再 cp 新文件,最后 fuser -k 3000/tcp + start.sh 重启。

5. Gin 路由注册相关三个坑(2026-08-13 #24 部署)

  • /rooms/:id/health-profile 与已有 /rooms/:id 冲突 → 启动 panic wildcard segment conflicts;改为独立路径 /health-profiles/:roomId
  • 同一路由注册函数被调两次 → panic handlers are already registered for path ...;检查 main.go 是否重复注册。
  • 用 apply_patch 新增文件时若文件已存在会覆盖旧内容:新增 handler/health.go 覆盖了原 /health 路由(health 变 404);已恢复原函数并把健康画像独立为 RegisterHealthProfileRoutes
  • 路由参数名与 handler 读取名不一致(:roomIdc.Param("id"))→ 返回 404/参数为空;保持命名一致。

6. PowerShell 传中文 JSON 导致请求 400

现象curl -d '{"disease":"白僵病"}'PowerShell 控制台按 GBK 编码)→ 后端 JSON 解析失败返回 400。 处理:冒烟测试避免在 -d 中直接写中文;改用 ASCII 字段值,或通过文件/脚本传 UTF-8 字节。

7. tar 打包报「时间戳是未来」等警告

现象Windows tar 打包 dist 时提示时间戳在未来(非致命)。 处理:忽略即可;不影响部署。

8. SSH 长命令整链被 & 后台化

现象:一条部署命令里 A && B & C 语法把整条链后台化,导致命令“卡住”超时。 处理:后台启动(node/uvicorn)单独一条命令,先 fuser -k + 换 dist/二进制,再单独 nohup ... & 启动。