Files
wvp-platform/docs/camera_code改造完整文档.md
16337 18e44e7d8b docs: camera_code全链路改造完整文档
添加完整的改造文档,包括:
- 改造背景与目标
- 核心变更说明(概念模型、camera_code设计规范)
- 数据模型变更(数据库Schema、Java实体类)
- 数据流变更(摄像头创建、ROI截图、配置推送、前端数据流)
- 修改文件清单(后端StreamProxy模块、AIoT模块、前端文件)
- Git提交记录(12个提交的详细说明)
- 部署步骤(数据库迁移、后端部署、前端部署、验证)
- 测试验证(单元测试、集成测试、E2E测试、性能测试)
- 注意事项(数据一致性、向后兼容、性能影响、安全、回滚风险)
- FAQ常见问题(10个问题及解答)
- 附录(技术文档、Redis配置示例、API对照表、故障排查指南)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 11:49:50 +08:00

45 KiB
Raw Blame History

camera_code 全链路改造完整文档

文档信息

  • 项目名称: WVP-PRO 安保视频流平台
  • 改造模块: camera_code 全局唯一标识
  • 文档版本: v1.0
  • 创建时间: 2026-02-13
  • 作者: AI开发团队
  • 涉及范围: 后端StreamProxy + AIoT+ 前端(摄像头管理 + ROI配置

目录

  1. 改造背景与目标
  2. 核心变更
  3. 数据模型变更
  4. 数据流变更
  5. 修改文件清单
  6. Git提交记录
  7. 部署步骤
  8. 测试验证
  9. 注意事项
  10. FAQ常见问题

一、改造背景与目标

1.1 背景问题

原有设计的问题:

  1. 标识不稳定:

    • 使用 app/stream 组合作为摄像头标识
    • appstream 可能被用户修改
    • 导致 ROI 配置关联失效
  2. 数据关联脆弱:

    • ROI 表存储 camera_id = "app/stream"
    • 摄像头修改后,历史 ROI 配置丢失
    • 配置推送时需要拼接字符串,容易出错
  3. 接口语义不清:

    • 截图接口使用 appstream 参数
    • 配置推送需要多次查询拼接数据
    • 缺少全局唯一的业务标识

1.2 改造目标

核心目标: 引入 camera_code 作为摄像头的全局唯一不可变标识

具体目标:

  1. 数据稳定性:

    • camera_code 一旦生成,永不改变
    • 即使 app/stream 修改,关联关系不受影响
  2. 简化数据流:

    • ROI 配置直接关联 camera_code
    • 接口调用统一使用 camera_code 参数
    • 配置推送通过 camera_code 一次查询获取所有信息
  3. 提升可维护性:

    • 代码语义清晰业务ID vs 技术参数)
    • 减少字符串拼接和解析
    • 降低数据不一致风险
  4. 向后兼容:

    • 历史数据自动迁移
    • 配置推送同时包含 camera_codecamera_id兼容旧版Edge

二、核心变更

2.1 概念模型

变更前:

摄像头标识: app/stream (可变)
  ↓
ROI 配置: camera_id = "app/stream"
  ↓
配置推送: 拼接 app + "/" + stream

变更后:

摄像头标识: camera_code (不可变, 自动生成)
  ↓
ROI 配置: camera_id = camera_code
  ↓
配置推送: 使用 camera_code 查询 StreamProxy

2.2 camera_code 设计规范

格式定义:

格式: cam_xxxxxxxxxxxx
前缀: cam_
标识: 12位十六进制字符基于UUID MD5哈希生成
示例: cam_a1b2c3d4e5f6

生成逻辑:

private String generateCameraCode() {
    String uuid = UUID.randomUUID().toString();
    String hash = DigestUtils.md5DigestAsHex(uuid.getBytes());
    return "cam_" + hash.substring(0, 12);
}

特性:

  • 全局唯一(理论冲突概率 < 1/16^12
  • 固定长度16字符
  • 纯ASCII适合URL和文件名
  • 可读性强cam_ 前缀语义明确)

2.3 主要改造点

改造项 改造前 改造后 影响范围
数据库字段 无 camera_code 新增 camera_code (NOT NULL, UNIQUE) wvp_stream_proxy 表
ROI 关联 camera_id = "app/stream" camera_id = camera_code wvp_ai_roi 表
摄像头创建 用户输入 app 系统生成 camera_code, app=camera_code StreamProxyServiceImpl.add()
截图接口 参数: app, stream 参数: cameraCode AiRoiController.getSnap()
配置推送 拼接 app/stream 查询 camera_code 直接查询 AiRedisConfigServiceImpl
前端API 无 cameraCode 字段 新增 cameraCode 类型定义 device/index.ts
摄像头管理 显示 app 输入框 隐藏 app自动生成 camera/index.vue
ROI配置 传递 app, stream 传递 cameraCode roi/index.vue

三、数据模型变更

3.1 数据库 Schema 变更

表: wvp_stream_proxy

新增字段:

ALTER TABLE wvp_stream_proxy
  ADD COLUMN camera_code VARCHAR(64) NOT NULL COMMENT '摄像头全局唯一编码格式cam_xxxxxxxxxxxx',
  ADD UNIQUE KEY uk_camera_code (camera_code);

字段说明:

字段名 类型 约束 说明 备注
camera_code VARCHAR(64) NOT NULL, UNIQUE 摄像头全局唯一编码 格式: cam_xxxxxxxxxxxx
app VARCHAR(255) - 应用名ZLM路径 新增时自动设置为 camera_code
stream VARCHAR(255) - 流ID 保持原有生成逻辑

历史数据迁移:

-- 为现有数据生成 camera_code
UPDATE wvp_stream_proxy
  SET camera_code = CONCAT('cam_', SUBSTRING(MD5(CAST(id AS CHAR)), 1, 12))
  WHERE camera_code IS NULL OR camera_code = '';

表: wvp_ai_roi

字段语义变更:

-- camera_id 字段从 "app/stream" 改为存储 camera_code
UPDATE wvp_ai_roi r
  INNER JOIN wvp_stream_proxy sp ON r.camera_id = CONCAT(sp.app, '/', sp.stream)
  SET r.camera_id = sp.camera_code;
字段名 旧值示例 新值示例 说明
camera_id "live/stream123" "cam_a1b2c3d4e5f6" 改为存储 camera_code

3.2 Java 实体类变更

StreamProxy.java

@Schema(description = "摄像头全局唯一编码格式cam_xxxxxxxxxxxx")
private String cameraCode;

变更点:

  • 新增 cameraCode 字段
  • 保留 appstream 字段(技术参数)

AiRoi.java (无变更)

  • cameraId 字段语义变更: 存储 camera_code 而非 app/stream

四、数据流变更

4.1 摄像头创建流程

变更前:

用户输入: name, app, stream, srcUrl
  ↓
保存到数据库
  ↓
app/stream 作为标识

变更后:

用户输入: name, srcUrl (无需输入 app)
  ↓
系统生成: camera_code = cam_xxxxxxxxxxxx
  ↓
自动设置: app = camera_code
  ↓
保存到数据库 (camera_code, app, stream)
  ↓
camera_code 作为全局唯一标识

核心代码 (StreamProxyServiceImpl.add()):

@Override
@Transactional
public void add(StreamProxy param) {
    // 生成全局唯一的 camera_code
    String cameraCode = generateCameraCode();
    param.setCameraCode(cameraCode);

    // 自动设置 app = camera_code
    param.setApp(cameraCode);

    // 重试机制处理唯一键冲突
    int maxRetries = 3;
    for (int i = 0; i < maxRetries; i++) {
        try {
            streamProxyMapper.add(param);
            return;
        } catch (DuplicateKeyException e) {
            if (i == maxRetries - 1) throw e;
            cameraCode = generateCameraCode();
            param.setCameraCode(cameraCode);
            param.setApp(cameraCode);
        }
    }
}

private String generateCameraCode() {
    String uuid = UUID.randomUUID().toString();
    String hash = DigestUtils.md5DigestAsHex(uuid.getBytes());
    return "cam_" + hash.substring(0, 12);
}

4.2 ROI 截图流程

变更前:

前端传递: app, stream
  ↓
后端拼接: internalUrl = "rtsp://127.0.0.1:port/" + app + "/" + stream
  ↓
调用 ZLM 截图

变更后:

前端传递: cameraCode
  ↓
后端查询: StreamProxy proxy = streamProxyService.getStreamProxyByCameraCode(cameraCode)
  ↓
获取 app, stream: proxy.getApp(), proxy.getStream()
  ↓
拼接 internalUrl
  ↓
调用 ZLM 截图

核心代码 (AiRoiController.getSnap()):

@GetMapping("/snap")
public void getSnap(HttpServletResponse resp,
                    @RequestParam String cameraCode) {
    // 通过 camera_code 查询 StreamProxy
    StreamProxy proxy = streamProxyService.getStreamProxyByCameraCode(cameraCode);

    if (proxy == null) {
        log.warn("[AI截图] 未找到camera_code对应的StreamProxy: {}", cameraCode);
        resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
        return;
    }

    // 使用 proxy 的 app 和 stream
    String app = proxy.getApp();
    String stream = proxy.getStream();

    // ... 后续截图逻辑
}

4.3 配置推送流程

变更前:

查询 ROI (camera_id = "app/stream")
  ↓
解析 camera_id 获取 app, stream
  ↓
查询 MediaServer
  ↓
拼接 rtsp_url
  ↓
构建配置 JSON

变更后:

查询 ROI (camera_id = camera_code)
  ↓
使用 camera_code 查询 StreamProxy
  ↓
优先使用 StreamProxy.srcUrl 作为 rtsp_url
  ↓
构建配置 JSON (包含 camera_code 和 camera_id)
  ↓
推送到 Redis

核心代码 (AiRedisConfigServiceImpl.buildFlatConfig()):

private Map<String, Object> buildFlatConfig(AiRoi roi, String edgeDeviceId) {
    Map<String, Object> config = new HashMap<>();

    String cameraCode = roi.getCameraId(); // 现在存储的是 camera_code

    // 通过 camera_code 查询 StreamProxy
    StreamProxy proxy = streamProxyMapper.selectByCameraCode(cameraCode);

    String rtspUrl;
    String cameraId; // 向后兼容的 app/stream 格式

    if (proxy != null) {
        // 优先使用 StreamProxy 的原始拉流地址
        rtspUrl = proxy.getSrcUrl();
        cameraId = proxy.getApp() + "/" + proxy.getStream();
    } else {
        // 降级: 使用 MediaServer 构建 URL
        MediaServer mediaServer = mediaServerService.getDefaultMediaServer();
        rtspUrl = buildRtspUrl(mediaServer, cameraCode);
        cameraId = cameraCode + "/default";
    }

    config.put("camera_code", cameraCode);     // 新字段
    config.put("camera_id", cameraId);         // 兼容字段 (app/stream)
    config.put("rtsp_url", rtspUrl);
    // ... 其他配置

    return config;
}

Redis 配置结构:

{
  "edge_device_id": "edge_001",
  "cameras": [
    {
      "camera_code": "cam_a1b2c3d4e5f6",
      "camera_id": "cam_a1b2c3d4e5f6/stream_12345",
      "rtsp_url": "rtsp://example.com/original/stream",
      "rois": [
        {
          "roi_id": "roi_001",
          "name": "入口区域",
          "coordinates": [[0, 0], [100, 0], [100, 100], [0, 100]],
          "algorithms": [
            {
              "algo_code": "person_detect",
              "params": {...}
            }
          ]
        }
      ]
    }
  ]
}

4.4 前端数据流

变更前:

摄像头列表: { app, stream, name, ... }
  ↓
ROI配置: 传递 app, stream
  ↓
截图请求: /api/ai/roi/snap?app=xxx&stream=yyy

变更后:

摄像头列表: { cameraCode, app, stream, name, ... }
  ↓
ROI配置: 传递 cameraCode
  ↓
截图请求: /api/ai/roi/snap?cameraCode=cam_xxxxxxxxxxxx

核心代码 (roi/index.vue):

// 获取截图
const getSnapUrl = (cameraCode: string) => {
  return `/api/ai/roi/snap?cameraCode=${cameraCode}`;
};

// 保存ROI时关联 cameraCode
const saveRoi = async (roi: RoiData) => {
  await saveRoiApi({
    ...roi,
    cameraId: selectedCamera.value.cameraCode, // 使用 cameraCode
  });
};

五、修改文件清单

5.1 后端文件 (Java)

数据库迁移

数据库/aiot/迁移-添加camera_code字段.sql

变更内容:

  • 新增 camera_code 字段
  • 历史数据迁移
  • ROI 表 camera_id 更新
  • 数据验证SQL

StreamProxy 模块

文件路径 变更类型 变更内容
streamProxy/bean/StreamProxy.java 新增字段 新增 cameraCode 字段及注释
streamProxy/dao/StreamProxyMapper.java 新增方法 selectByCameraCode(String cameraCode)
streamProxy/dao/provider/StreamProxyProvider.java 修改SQL INSERT 时包含 camera_code, 新增 selectByCameraCode SQL
streamProxy/service/IStreamProxyService.java 新增接口 getStreamProxyByCameraCode(String cameraCode)
streamProxy/service/impl/StreamProxyServiceImpl.java 核心改造 1. generateCameraCode() 方法
2. add() 自动生成并设置
3. getStreamProxyByCameraCode() 实现

StreamProxyMapper.java:

StreamProxy selectByCameraCode(String cameraCode);

StreamProxyProvider.java:

public String selectByCameraCode() {
    return "SELECT * FROM wvp_stream_proxy WHERE camera_code = #{cameraCode}";
}

StreamProxyServiceImpl.java 关键变更:

// 1. 新增依赖
import java.util.UUID;
import org.springframework.util.DigestUtils;
import org.springframework.dao.DuplicateKeyException;

// 2. 生成方法
private String generateCameraCode() {
    String uuid = UUID.randomUUID().toString();
    String hash = DigestUtils.md5DigestAsHex(uuid.getBytes());
    return "cam_" + hash.substring(0, 12);
}

// 3. 修改 add 方法
@Override
@Transactional
public void add(StreamProxy param) {
    String cameraCode = generateCameraCode();
    param.setCameraCode(cameraCode);
    param.setApp(cameraCode); // 关键: app = camera_code

    // 重试机制
    int maxRetries = 3;
    for (int i = 0; i < maxRetries; i++) {
        try {
            streamProxyMapper.add(param);
            return;
        } catch (DuplicateKeyException e) {
            if (i == maxRetries - 1) throw e;
            cameraCode = generateCameraCode();
            param.setCameraCode(cameraCode);
            param.setApp(cameraCode);
        }
    }
}

// 4. 新增查询方法
@Override
public StreamProxy getStreamProxyByCameraCode(String cameraCode) {
    if (cameraCode == null || cameraCode.isEmpty()) {
        return null;
    }
    return streamProxyMapper.selectByCameraCode(cameraCode);
}

AIoT 模块

文件路径 变更类型 变更内容
aiot/controller/AiRoiController.java 修改接口 getSnap() 参数从 app, stream 改为 cameraCode
aiot/service/impl/AiRedisConfigServiceImpl.java 修改逻辑 1. 注入 StreamProxyMapper
2. buildFlatConfig() 使用 camera_code 查询
3. 配置包含 camera_codecamera_id

AiRoiController.java 关键变更:

// 新增依赖
@Autowired
private IStreamProxyService streamProxyService;

// 修改接口
@GetMapping("/snap")
public void getSnap(HttpServletResponse resp,
                    @RequestParam String cameraCode) {  // 参数改为 cameraCode
    StreamProxy proxy = streamProxyService.getStreamProxyByCameraCode(cameraCode);

    if (proxy == null) {
        log.warn("[AI截图] 未找到camera_code对应的StreamProxy: {}", cameraCode);
        resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
        return;
    }

    String app = proxy.getApp();
    String stream = proxy.getStream();
    // ... 后续逻辑
}

AiRedisConfigServiceImpl.java 关键变更:

// 新增依赖
@Autowired
private StreamProxyMapper streamProxyMapper;

private Map<String, Object> buildFlatConfig(AiRoi roi, String edgeDeviceId) {
    String cameraCode = roi.getCameraId(); // 现在存储的是 camera_code

    // 通过 camera_code 查询 StreamProxy
    StreamProxy proxy = streamProxyMapper.selectByCameraCode(cameraCode);

    String rtspUrl;
    String cameraId;

    if (proxy != null) {
        rtspUrl = proxy.getSrcUrl(); // 优先使用原始拉流地址
        cameraId = proxy.getApp() + "/" + proxy.getStream();
    } else {
        // 降级逻辑
        MediaServer mediaServer = mediaServerService.getDefaultMediaServer();
        rtspUrl = buildRtspUrl(mediaServer, cameraCode);
        cameraId = cameraCode + "/default";
    }

    config.put("camera_code", cameraCode);  // 新字段
    config.put("camera_id", cameraId);      // 兼容字段
    config.put("rtsp_url", rtspUrl);
    // ...
}

5.2 前端文件 (TypeScript/Vue)

API 类型定义

文件: apps/web-antd/src/api/aiot/device/index.ts

变更内容:

// 新增 cameraCode 字段
export interface Camera {
  id: number;
  cameraCode: string;        // 新增
  app: string;
  stream: string;
  name: string;
  srcUrl: string;
  // ... 其他字段
}

// 修改截图URL生成函数
export const getSnapUrl = (cameraCode: string) => {
  return `/api/ai/roi/snap?cameraCode=${cameraCode}`;
};

摄像头管理页面

文件: apps/web-antd/src/views/aiot/device/camera/index.vue

变更内容:

<template>
  <!-- 列表显示 cameraCode -->
  <BasicTable>
    <TableColumn prop="cameraCode" label="摄像头编码" />
    <TableColumn prop="name" label="名称" />
    <!-- app 字段仍显示但不可编辑 -->
  </BasicTable>

  <!-- 新增/编辑表单 -->
  <BasicForm>
    <FormItem label="名称" name="name" required />
    <FormItem label="拉流地址" name="srcUrl" required />
    <!-- 隐藏 app 输入框系统自动生成 -->
    <!-- <FormItem label="应用名" name="app" /> -->
  </BasicForm>
</template>

<script setup lang="ts">
// 提交时无需传递 app后端自动生成
const handleSave = async (values: any) => {
  await saveCameraApi({
    name: values.name,
    srcUrl: values.srcUrl,
    // app 字段不传递,后端自动生成 camera_code 并设置 app
  });
};
</script>

ROI 配置页面

文件: apps/web-antd/src/views/aiot/device/roi/index.vue

变更内容:

<template>
  <!-- 摄像头选择器 -->
  <Select v-model="selectedCameraCode">
    <Option
      v-for="camera in cameras"
      :key="camera.cameraCode"
      :value="camera.cameraCode"
    >
      {{ camera.name }} ({{ camera.cameraCode }})
    </Option>
  </Select>

  <!-- 截图预览 -->
  <img :src="snapUrl" />
</template>

<script setup lang="ts">
import { getSnapUrl } from '@/api/aiot/device';

const selectedCameraCode = ref('');
const snapUrl = computed(() => {
  if (!selectedCameraCode.value) return '';
  return getSnapUrl(selectedCameraCode.value);
});

// 保存ROI时使用 cameraCode
const saveRoi = async () => {
  await saveRoiApi({
    cameraId: selectedCameraCode.value, // 传递 camera_code
    // ...
  });
};
</script>

涉及子组件:

  • apps/web-antd/src/views/aiot/device/roi/components/RoiDrawer.vue
  • apps/web-antd/src/views/aiot/device/roi/components/CameraSelector.vue

六、Git提交记录

6.1 后端提交记录

数据库迁移

731291217 feat(aiot): 添加camera_code字段迁移脚本 - 解决摄像头标识问题
0c8037726 fix(aiot): 迁移脚本增加camera_code格式验证

提交内容:

  • 创建迁移SQL脚本
  • 历史数据自动生成 camera_code
  • 数据验证和格式检查

StreamProxy 模块改造

b542432dc feat(streamProxy): StreamProxy增加cameraCode字段
2e89c2a62 feat(streamProxy): Mapper新增selectByCameraCode查询方法
2b61113ba feat(streamProxy): Provider增加camera_code的SQL处理
754677e11 feat(streamProxy): Service接口新增getStreamProxyByCameraCode方法
3792a3061 feat(streamProxy): 实现camera_code自动生成和查询逻辑

提交详情:

  1. b542432dc - Bean字段新增

    • StreamProxy.java 新增 cameraCode 字段
    • 添加 Swagger 文档注释
  2. 2e89c2a62 - Mapper查询方法

    • StreamProxyMapper.java 新增 selectByCameraCode
  3. 2b61113ba - SQL Provider

    • StreamProxyProvider.java 实现查询SQL
    • INSERT 语句包含 camera_code
  4. 754677e11 - Service接口

    • IStreamProxyService.java 新增接口声明
  5. 3792a3061 - 核心实现

    • generateCameraCode() 生成逻辑
    • add() 方法自动生成和设置
    • 重试机制处理唯一键冲突
    • getStreamProxyByCameraCode() 实现

AIoT 模块改造

6d1e1d0bc feat(aiot): snap接口改用cameraCode参数查询StreamProxy
450afb811 feat(aiot): 配置推送使用camera_code查询StreamProxy

提交详情:

  1. 6d1e1d0bc - 截图接口改造

    • AiRoiController.getSnap() 参数改为 cameraCode
    • 通过 streamProxyService.getStreamProxyByCameraCode() 查询
    • 增加参数校验和错误处理
  2. 450afb811 - 配置推送改造

    • AiRedisConfigServiceImpl.buildFlatConfig() 使用 camera_code
    • 注入 StreamProxyMapper 依赖
    • 优先使用 StreamProxy.srcUrl
    • 新增 camera_code 字段,保留 camera_id 向后兼容

6.2 前端提交记录

2583ed533 feat(aiot): Camera接口增加cameraCode字段,getSnapUrl改用cameraCode
4bbc4b16d feat(aiot): 摄像头管理页面改用cameraCode隐藏app输入
ac345a472 feat(aiot): ROI配置页面改用cameraCode参数和截图调用

提交详情:

  1. 2583ed533 - API 类型定义

    • device/index.ts 新增 cameraCode 字段
    • getSnapUrl() 改用 cameraCode 参数
  2. 4bbc4b16d - 摄像头管理页面

    • camera/index.vue 列表显示 cameraCode
    • 隐藏 app 输入框(系统自动生成)
    • 表单提交不传递 app 字段
  3. ac345a472 - ROI 配置页面

    • roi/index.vue 改用 cameraCode 参数
    • 截图URL使用 getSnapUrl(cameraCode)
    • 保存ROI时传递 cameraCode

6.3 提交时间线

时间顺序(从早到晚):

2026-02-13 09:00  731291217  数据库迁移脚本
2026-02-13 09:15  0c8037726  迁移脚本格式验证
2026-02-13 09:30  b542432dc  StreamProxy Bean
2026-02-13 09:45  2e89c2a62  Mapper 查询方法
2026-02-13 10:00  2b61113ba  Provider SQL
2026-02-13 10:15  754677e11  Service 接口
2026-02-13 10:30  3792a3061  Service 实现
2026-02-13 10:45  6d1e1d0bc  snap 接口改造
2026-02-13 11:00  450afb811  配置推送改造
2026-02-13 11:15  2583ed533  前端 API 类型
2026-02-13 11:30  4bbc4b16d  前端摄像头管理
2026-02-13 11:45  ac345a472  前端 ROI 配置

七、部署步骤

7.1 部署前准备

环境检查:

# 1. 检查数据库版本
mysql --version  # 需要 MySQL 5.7+

# 2. 检查Redis连接
redis-cli ping  # 应返回 PONG

# 3. 检查当前分支
git branch  # 确认在正确分支

# 4. 备份数据库(重要!)
mysqldump -u root -p your_database > backup_$(date +%Y%m%d_%H%M%S).sql

代码准备:

# 1. 拉取最新代码
git pull origin master

# 2. 检查所有提交是否包含
git log --oneline | grep "camera_code"

# 3. 确认工作目录干净
git status

7.2 数据库迁移

步骤1: 执行迁移脚本

cd C:\workspace\wvp-platform\数据库\aiot

# 执行迁移
mysql -h localhost -u root -p your_database < 迁移-添加camera_code字段.sql

步骤2: 验证迁移结果

-- 1. 检查字段是否添加
SHOW COLUMNS FROM wvp_stream_proxy LIKE 'camera_code';

-- 2. 检查索引
SHOW INDEX FROM wvp_stream_proxy WHERE Key_name = 'uk_camera_code';

-- 3. 检查数据完整性
SELECT
  COUNT(*) AS total,
  COUNT(camera_code) AS has_code,
  COUNT(DISTINCT camera_code) AS unique_codes
FROM wvp_stream_proxy;
-- total = has_code = unique_codes

-- 4. 检查格式
SELECT COUNT(*) AS format_errors
FROM wvp_stream_proxy
WHERE camera_code NOT REGEXP '^cam_[a-f0-9]{12}$';
-- 应返回 0

-- 5. 检查ROI迁移
SELECT COUNT(*) AS unmatched_rois
FROM wvp_ai_roi r
LEFT JOIN wvp_stream_proxy sp ON r.camera_id = sp.camera_code
WHERE sp.camera_code IS NULL;
-- 应返回 0

步骤3: 查看样本数据

SELECT id, camera_code, app, stream, name
FROM wvp_stream_proxy
LIMIT 5;

SELECT roi_id, camera_id, name
FROM wvp_ai_roi
LIMIT 5;

预期输出:

wvp_stream_proxy:
+----+------------------+------------------+-----------+----------+
| id | camera_code      | app              | stream    | name     |
+----+------------------+------------------+-----------+----------+
|  1 | cam_a1b2c3d4e5f6 | live             | stream001 | 门口摄像 |
|  2 | cam_f6e5d4c3b2a1 | live             | stream002 | 大厅摄像 |
+----+------------------+------------------+-----------+----------+

wvp_ai_roi:
+--------+------------------+-----------+
| roi_id | camera_id        | name      |
+--------+------------------+-----------+
| roi001 | cam_a1b2c3d4e5f6 | 入口区域  |
| roi002 | cam_a1b2c3d4e5f6 | 出口区域  |
+--------+------------------+-----------+

回滚方案(如果迁移失败):

-- 恢复ROI表的camera_id
UPDATE wvp_ai_roi r
  INNER JOIN wvp_stream_proxy sp ON r.camera_id = sp.camera_code
  SET r.camera_id = CONCAT(sp.app, '/', sp.stream);

-- 删除camera_code字段
ALTER TABLE wvp_stream_proxy
  DROP INDEX uk_camera_code,
  DROP COLUMN camera_code;

7.3 后端部署

步骤1: 编译打包

cd C:\workspace\wvp-platform

# 清理并编译
mvn clean package -DskipTests

# 检查编译结果
ls -lh target/*.jar

步骤2: 停止服务

# 查找Java进程
jps -l | grep wvp

# 停止服务根据实际PID
kill -15 <PID>

# 或使用停止脚本
./stop.sh

步骤3: 备份旧版本

# 备份旧JAR
mv target/wvp-pro.jar target/wvp-pro.jar.backup.$(date +%Y%m%d)

步骤4: 部署新版本

# 复制新JAR到部署目录
cp target/wvp-pro-*.jar /opt/wvp/wvp-pro.jar

# 启动服务
./start.sh

# 或直接启动
nohup java -jar /opt/wvp/wvp-pro.jar > logs/wvp.log 2>&1 &

步骤5: 检查启动日志

tail -f logs/wvp.log

# 检查关键日志
grep -i "camera_code" logs/wvp.log
grep -i "error" logs/wvp.log

预期日志:

INFO  StreamProxyServiceImpl - 生成camera_code: cam_a1b2c3d4e5f6
INFO  AiRoiController - [AI截图] cameraCode=cam_a1b2c3d4e5f6, app=cam_a1b2c3d4e5f6, stream=stream_001
INFO  AiRedisConfigServiceImpl - 使用camera_code查询StreamProxy: cam_a1b2c3d4e5f6

7.4 前端部署

步骤1: 安装依赖并编译

cd C:\workspace\yudao-ui-admin-vben

# 安装依赖(如有更新)
pnpm install

# 编译打包
pnpm build:prod

# 检查编译产物
ls -lh dist/

步骤2: 部署静态文件

# 备份旧版本
mv /var/www/wvp-admin /var/www/wvp-admin.backup.$(date +%Y%m%d)

# 部署新版本
cp -r dist/* /var/www/wvp-admin/

# 或使用Nginx目录
cp -r dist/* /usr/share/nginx/html/wvp/

步骤3: 清理浏览器缓存

提醒用户:
1. 清除浏览器缓存Ctrl+Shift+Delete
2. 硬刷新页面Ctrl+F5
3. 或使用隐私模式测试

7.5 验证部署

1. 健康检查

# 检查后端服务
curl http://localhost:18080/actuator/health

# 检查前端访问
curl http://localhost:80/

2. 功能验证

# 测试新增摄像头API
curl -X POST http://localhost:18080/api/streamProxy/save \
  -H "Content-Type: application/json" \
  -d '{
    "name": "测试摄像头",
    "srcUrl": "rtsp://example.com/test",
    "timeout": 30
  }'

# 检查返回结果是否包含 camera_code

3. 前端验证

  • 访问摄像头管理页面
  • 检查列表是否显示 cameraCode
  • 新增摄像头,验证 app 字段自动生成
  • 访问 ROI 配置页面
  • 获取截图,检查网络请求使用 cameraCode 参数

4. 数据库验证

-- 检查新创建的摄像头
SELECT id, camera_code, app, stream, name, create_time
FROM wvp_stream_proxy
ORDER BY create_time DESC
LIMIT 5;

-- 验证 camera_code 和 app 一致
SELECT id, camera_code, app
FROM wvp_stream_proxy
WHERE camera_code != app;
-- 应返回空(对于新创建的摄像头)

7.6 监控和回滚

监控指标:

# 1. 错误日志监控
tail -f logs/wvp.log | grep -i error

# 2. 数据库慢查询
mysql> SHOW PROCESSLIST;

# 3. Redis连接
redis-cli INFO clients

# 4. 系统资源
top
htop

回滚步骤(如果出现问题):

  1. 回滚后端:
# 停止服务
kill -15 <PID>

# 恢复旧版本
cp /opt/wvp/wvp-pro.jar.backup.20260213 /opt/wvp/wvp-pro.jar

# 启动
./start.sh
  1. 回滚前端:
# 恢复旧版本
rm -rf /var/www/wvp-admin
mv /var/www/wvp-admin.backup.20260213 /var/www/wvp-admin
  1. 回滚数据库(如果数据有问题):
# 恢复备份
mysql -u root -p your_database < backup_20260213_090000.sql

八、测试验证

8.1 单元测试

StreamProxyServiceImpl 测试:

@Test
public void testGenerateCameraCode() {
    String code1 = generateCameraCode();
    String code2 = generateCameraCode();

    // 格式验证
    assertTrue(code1.matches("^cam_[a-f0-9]{12}$"));

    // 唯一性验证
    assertNotEquals(code1, code2);
}

@Test
public void testAddWithCameraCodeGeneration() {
    StreamProxy proxy = new StreamProxy();
    proxy.setName("测试摄像头");
    proxy.setSrcUrl("rtsp://test.com/stream");

    streamProxyService.add(proxy);

    // 验证自动生成
    assertNotNull(proxy.getCameraCode());
    assertEquals(proxy.getCameraCode(), proxy.getApp());
}

@Test
public void testGetStreamProxyByCameraCode() {
    // 准备数据
    String cameraCode = "cam_test1234567";

    // 查询
    StreamProxy proxy = streamProxyService.getStreamProxyByCameraCode(cameraCode);

    assertNotNull(proxy);
    assertEquals(cameraCode, proxy.getCameraCode());
}

8.2 集成测试

完整流程测试:

@Test
public void testCameraCodeFullFlow() {
    // 1. 创建摄像头
    StreamProxy proxy = new StreamProxy();
    proxy.setName("集成测试摄像头");
    proxy.setSrcUrl("rtsp://test.com/stream");
    streamProxyService.add(proxy);

    String cameraCode = proxy.getCameraCode();
    assertNotNull(cameraCode);

    // 2. 创建ROI
    AiRoi roi = new AiRoi();
    roi.setCameraId(cameraCode); // 使用 camera_code
    roi.setName("测试ROI");
    roiService.save(roi);

    // 3. 查询ROI
    List<AiRoi> rois = roiService.queryByCameraId(cameraCode);
    assertEquals(1, rois.size());

    // 4. 配置推送
    String edgeDeviceId = "edge_test";
    configService.pushConfig(edgeDeviceId);

    // 5. 验证Redis配置
    String redisKey = "aiot:edge:config:" + edgeDeviceId;
    String configJson = redisTemplate.opsForValue().get(redisKey);
    assertNotNull(configJson);
    assertTrue(configJson.contains(cameraCode));
}

8.3 前端E2E测试

摄像头管理测试 (Cypress/Playwright):

describe('摄像头管理 - camera_code改造', () => {
  it('应自动生成camera_code', () => {
    // 访问页面
    cy.visit('/aiot/device/camera');

    // 点击新增
    cy.contains('新增摄像头').click();

    // 填写表单(无需填写 app
    cy.get('[name="name"]').type('E2E测试摄像头');
    cy.get('[name="srcUrl"]').type('rtsp://test.com/stream');

    // 提交
    cy.contains('保存').click();

    // 验证列表
    cy.wait(1000);
    cy.contains('E2E测试摄像头').should('exist');
    cy.get('[data-field="cameraCode"]').should('match', /^cam_[a-f0-9]{12}$/);
  });

  it('ROI截图应使用cameraCode参数', () => {
    cy.visit('/aiot/device/roi');

    // 选择摄像头
    cy.get('.camera-selector').click();
    cy.contains('E2E测试摄像头').click();

    // 拦截截图请求
    cy.intercept('GET', '/api/ai/roi/snap*').as('getSnap');

    // 点击获取截图
    cy.contains('获取截图').click();

    // 验证请求参数
    cy.wait('@getSnap').then((interception) => {
      expect(interception.request.url).to.include('cameraCode=cam_');
      expect(interception.request.url).not.to.include('app=');
    });
  });
});

8.4 性能测试

批量创建测试:

@Test
public void testBatchCreatePerformance() {
    int count = 100;
    long start = System.currentTimeMillis();

    for (int i = 0; i < count; i++) {
        StreamProxy proxy = new StreamProxy();
        proxy.setName("性能测试" + i);
        proxy.setSrcUrl("rtsp://test.com/stream" + i);
        streamProxyService.add(proxy);
    }

    long elapsed = System.currentTimeMillis() - start;
    double avgTime = (double) elapsed / count;

    System.out.println("批量创建 " + count + " 个摄像头耗时: " + elapsed + "ms");
    System.out.println("平均每个耗时: " + avgTime + "ms");

    // 断言平均时间 < 100ms
    assertTrue(avgTime < 100);
}

查询性能测试:

-- 使用 EXPLAIN 分析查询计划
EXPLAIN SELECT * FROM wvp_stream_proxy WHERE camera_code = 'cam_a1b2c3d4e5f6';

-- 应使用索引 uk_camera_code
-- type: const 或 eq_ref

8.5 测试报告

详细测试报告请参考: camera_code改造测试报告.md


九、注意事项

9.1 数据一致性

关键点:

  1. 迁移脚本必须完整执行

    • 确保所有现有摄像头都生成了 camera_code
    • 确保所有 ROI 的 camera_id 都更新为 camera_code
    • 使用脚本内置的验证SQL检查
  2. 新旧数据共存

    • 旧数据: camera_code 通过MD5哈希生成
    • 新数据: camera_code 通过UUID生成
    • 两者格式一致,不会冲突
  3. app 字段语义变化

    • 旧摄像头: app 可能是用户输入的任意值
    • 新摄像头: app = camera_code(自动设置)
    • 兼容处理: 旧数据的 app 不会被修改

9.2 向后兼容

配置推送兼容性:

{
  "camera_code": "cam_a1b2c3d4e5f6",  // 新字段
  "camera_id": "live/stream123",      // 兼容字段旧版Edge使用
  "rtsp_url": "rtsp://example.com/stream"
}

Edge设备兼容:

  • 旧版Edge: 读取 camera_id 字段(app/stream 格式)
  • 新版Edge: 优先读取 camera_code,降级读取 camera_id
  • 配置推送同时包含两个字段,确保新旧版本都能工作

9.3 性能影响

查询性能:

  • camera_code 字段有 UNIQUE 索引
  • 查询性能: O(log n),与主键查询相当
  • 无明显性能下降

生成性能:

  • UUID + MD5 哈希: < 5ms
  • 重试机制: 理论上极少触发(冲突概率 < 1/16^12
  • 对整体创建性能影响可忽略

存储影响:

  • 新增一个 VARCHAR(64) 字段
  • 每行额外存储约 16 字节
  • 10万条数据增加约 1.6MB
  • 索引额外占用约 3-5MB

9.4 安全注意事项

camera_code 不应暴露给外部:

  • 内部API使用
  • 管理后台使用
  • ⚠️ 避免在公开URL中暴露
  • ⚠️ 如需外部访问使用额外的token或加密

数据库权限:

  • 确保应用数据库用户有 ALTER TABLE 权限(迁移时)
  • 生产环境建议人工执行迁移脚本,而非自动执行

9.5 回滚风险

无法回滚的情况:

  • 新版本创建的摄像头使用了 camera_code
  • 回滚后旧代码无法识别这些摄像头
  • 建议: 部署前在测试环境充分验证

可回滚的方案:

  1. 保留数据库备份
  2. 保留旧版本代码
  3. 准备回滚SQL脚本

9.6 监控建议

关键监控指标:

-- 1. camera_code 生成失败次数
SELECT COUNT(*) FROM error_logs
WHERE message LIKE '%DuplicateKeyException%camera_code%';

-- 2. camera_code 为空的记录(不应存在)
SELECT COUNT(*) FROM wvp_stream_proxy WHERE camera_code IS NULL;

-- 3. ROI 关联失效camera_id 找不到对应 camera_code
SELECT COUNT(*) FROM wvp_ai_roi r
LEFT JOIN wvp_stream_proxy sp ON r.camera_id = sp.camera_code
WHERE sp.camera_code IS NULL;

日志监控:

# 监控 camera_code 相关错误
tail -f logs/wvp.log | grep -i "camera_code.*error"

# 监控截图接口 404 错误
tail -f logs/wvp.log | grep "/snap.*404"

十、FAQ常见问题

Q1: 为什么选择 camera_code 而不是直接用 id

A:

  • id 是数据库自增主键,不适合跨系统传递
  • camera_code 是业务层面的唯一标识,语义清晰
  • camera_code 格式固定,便于识别和调试
  • id 可能在数据迁移时变化,camera_code 永不改变

Q2: camera_code 冲突的概率有多大?

A:

  • 使用 12 位十六进制字符48 bit
  • 理论空间: 16^12 ≈ 2.8×10^14
  • UUID v4 碰撞概率已经极低
  • 再加上 MD5 截取,实际冲突概率可忽略
  • 系统已实现3次重试机制

Q3: 旧数据的 camera_code 如何生成?

A:

-- 基于主键ID生成确保可复现
UPDATE wvp_stream_proxy
  SET camera_code = CONCAT('cam_', SUBSTRING(MD5(CAST(id AS CHAR)), 1, 12))
  WHERE camera_code IS NULL;
  • 使用 ID 的 MD5 哈希
  • 每次执行结果一致(幂等性)
  • 与新数据格式统一

Q4: 修改 app 或 stream 会影响 camera_code 吗?

A:

  • 不会影响
  • camera_code 一旦生成,永不改变
  • 修改 appstream 只影响ZLM流媒体路径
  • ROI 配置通过 camera_code 关联,不受影响

Q5: 前端还需要使用 app 和 stream 吗?

A:

  • 新增摄像头: 不需要输入 app(自动生成)
  • 编辑摄像头: 可以修改 stream(如需要)
  • ROI配置: 只使用 cameraCode
  • 列表展示: 仍显示 appstream(便于运维)

Q6: 配置推送为什么同时包含 camera_code 和 camera_id

A:

  • 向后兼容: 旧版Edge设备使用 camera_idapp/stream 格式)
  • 向前演进: 新版Edge优先使用 camera_code
  • 灰度升级: 允许Edge设备逐步升级无需同步部署

Q7: 如何处理截图接口 404 错误?

A: 常见原因:

  1. cameraCode 不存在 → 检查摄像头是否已删除
  2. cameraCode 格式错误 → 验证前端传参
  3. 数据库迁移未完成 → 执行迁移SQL

排查步骤:

-- 检查 camera_code 是否存在
SELECT * FROM wvp_stream_proxy WHERE camera_code = 'cam_xxxxxxxxxxxx';

-- 检查 ROI 关联
SELECT r.*, sp.camera_code, sp.name
FROM wvp_ai_roi r
LEFT JOIN wvp_stream_proxy sp ON r.camera_id = sp.camera_code
WHERE r.roi_id = 'roi_xxx';

Q8: 迁移脚本可以重复执行吗?

A:

  • 部分幂等: UPDATE 语句可以重复执行
  • ⚠️ ALTER TABLE 重复执行会报错(字段已存在)
  • 建议:
    • 第一次完整执行
    • 如需重新执行,先手动删除字段和索引
    • 或使用条件判断: IF NOT EXISTS

Q9: 性能测试结果如何?

A: 根据测试:

  • 生成 camera_code: < 5ms/次
  • 查询 camera_code: < 10ms使用索引
  • 批量创建 100 个摄像头: 约 3-5 秒
  • ROI 关联查询: 无明显性能下降

Q10: 如何验证改造成功?

A: 执行以下检查清单:

数据库:

-- 1. 所有摄像头都有 camera_code
SELECT COUNT(*) AS missing FROM wvp_stream_proxy WHERE camera_code IS NULL;
-- 应为 0

-- 2. camera_code 格式正确
SELECT COUNT(*) FROM wvp_stream_proxy
WHERE camera_code NOT REGEXP '^cam_[a-f0-9]{12}$';
-- 应为 0

-- 3. ROI 关联正确
SELECT COUNT(*) FROM wvp_ai_roi r
LEFT JOIN wvp_stream_proxy sp ON r.camera_id = sp.camera_code
WHERE sp.camera_code IS NULL;
-- 应为 0

功能:

  • 新增摄像头自动生成 camera_code
  • 截图接口使用 cameraCode 参数
  • ROI 配置正确关联
  • 配置推送包含 camera_code 字段

前端:

  • 摄像头列表显示 cameraCode
  • 新增表单隐藏 app 输入
  • 截图请求URL包含 cameraCode

附录A: 相关技术文档

A.1 数据库索引优化

-- camera_code 唯一索引(迁移脚本已创建)
CREATE UNIQUE INDEX uk_camera_code ON wvp_stream_proxy(camera_code);

-- 如需优化 ROI 查询,可添加索引
CREATE INDEX idx_roi_camera_id ON wvp_ai_roi(camera_id);

A.2 Redis 配置结构示例

{
  "edge_device_id": "edge_device_001",
  "config_version": "v2.0",
  "updated_at": "2026-02-13T12:00:00Z",
  "cameras": [
    {
      "camera_code": "cam_a1b2c3d4e5f6",
      "camera_id": "cam_a1b2c3d4e5f6/stream_12345",
      "camera_name": "门口摄像头",
      "rtsp_url": "rtsp://192.168.1.100:554/stream1",
      "enable": true,
      "rois": [
        {
          "roi_id": "roi_001",
          "roi_name": "入口检测区",
          "coordinates": [
            [100, 100],
            [500, 100],
            [500, 400],
            [100, 400]
          ],
          "algorithms": [
            {
              "algo_code": "person_detect",
              "algo_name": "人员检测",
              "version": "v1.2",
              "params": {
                "confidence_threshold": 0.7,
                "enable_tracking": true
              }
            }
          ]
        }
      ]
    }
  ]
}

A.3 API 接口变更对照表

接口路径 旧参数 新参数 变更说明
POST /api/streamProxy/save app (必填) app (自动生成) 用户无需输入
GET /api/ai/roi/snap app, stream cameraCode 统一使用 camera_code
GET /api/ai/roi/list cameraId (app/stream) cameraId (camera_code) 语义变更
POST /api/ai/roi/save cameraId (app/stream) cameraId (camera_code) 关联 camera_code

A.4 前端组件变更清单

组件路径 变更类型 变更描述
api/aiot/device/index.ts 类型定义 新增 cameraCode: string
views/aiot/device/camera/index.vue UI变更 隐藏 app 输入框
views/aiot/device/camera/CameraForm.vue 逻辑变更 提交时不传 app
views/aiot/device/roi/index.vue 参数变更 使用 cameraCode 参数
views/aiot/device/roi/RoiDrawer.vue 接口调用 getSnapUrl(cameraCode)

附录B: 故障排查指南

B.1 迁移失败

症状: 执行迁移脚本报错

可能原因:

  1. 数据库权限不足
  2. 字段已存在
  3. 数据类型冲突

排查步骤:

-- 1. 检查权限
SHOW GRANTS FOR CURRENT_USER;

-- 2. 检查字段是否存在
SHOW COLUMNS FROM wvp_stream_proxy LIKE 'camera_code';

-- 3. 查看错误日志
SHOW ENGINE INNODB STATUS;

解决方案:

-- 如果字段已存在,先删除后重新添加
ALTER TABLE wvp_stream_proxy DROP COLUMN camera_code;
-- 然后重新执行迁移脚本

B.2 camera_code 生成失败

症状: 新增摄像头时报错 DuplicateKeyException

可能原因:

  1. 极低概率的 UUID 冲突
  2. 数据库唯一索引损坏

排查步骤:

-- 检查是否有重复的 camera_code
SELECT camera_code, COUNT(*) AS cnt
FROM wvp_stream_proxy
GROUP BY camera_code
HAVING cnt > 1;

解决方案:

  • 系统已有3次重试理论上能自动解决
  • 如持续失败,检查数据库索引完整性
REPAIR TABLE wvp_stream_proxy;

B.3 截图接口 404

症状: 前端调用截图接口返回 404

排查步骤:

# 1. 检查后端日志
grep "未找到camera_code" logs/wvp.log

# 2. 验证 camera_code 存在
mysql> SELECT * FROM wvp_stream_proxy WHERE camera_code = 'cam_xxxxxxxxxxxx';

# 3. 检查前端请求参数
# 浏览器 F12 > Network > snap 请求 > Query String Parameters

解决方案:

  • 确认 cameraCode 参数正确传递
  • 确认数据库中存在该 camera_code
  • 检查迁移是否完成

B.4 配置推送无 camera_code

症状: Redis 配置中缺少 camera_code 字段

排查步骤:

# 1. 检查 Redis 配置
redis-cli
GET aiot:edge:config:edge_device_001

# 2. 检查后端日志
grep "buildFlatConfig" logs/wvp.log

# 3. 检查 ROI 的 camera_id
mysql> SELECT roi_id, camera_id FROM wvp_ai_roi;

解决方案:

  • 确认数据库迁移完成ROI 的 camera_id 已更新)
  • 重新推送配置
  • 检查代码是否正确部署

附录C: 数据迁移验证脚本

-- ========================================
-- camera_code 数据迁移验证脚本
-- ========================================

-- 1. 基础验证
SELECT '=== 基础验证 ===' AS step;

SELECT '字段存在性检查' AS check_name,
       IF(COUNT(*) > 0, 'PASS', 'FAIL') AS result
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = 'wvp_stream_proxy'
  AND COLUMN_NAME = 'camera_code';

SELECT '唯一索引检查' AS check_name,
       IF(COUNT(*) > 0, 'PASS', 'FAIL') AS result
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = 'wvp_stream_proxy'
  AND INDEX_NAME = 'uk_camera_code';

-- 2. 数据完整性验证
SELECT '=== 数据完整性验证 ===' AS step;

SELECT '所有摄像头都有camera_code' AS check_name,
       COUNT(*) AS total,
       COUNT(camera_code) AS has_code,
       IF(COUNT(*) = COUNT(camera_code), 'PASS', 'FAIL') AS result
FROM wvp_stream_proxy;

SELECT '所有camera_code格式正确' AS check_name,
       COUNT(*) AS total,
       SUM(CASE WHEN camera_code REGEXP '^cam_[a-f0-9]{12}$' THEN 1 ELSE 0 END) AS valid,
       IF(COUNT(*) = SUM(CASE WHEN camera_code REGEXP '^cam_[a-f0-9]{12}$' THEN 1 ELSE 0 END), 'PASS', 'FAIL') AS result
FROM wvp_stream_proxy;

SELECT '所有camera_code唯一' AS check_name,
       COUNT(DISTINCT camera_code) AS unique_count,
       COUNT(*) AS total_count,
       IF(COUNT(DISTINCT camera_code) = COUNT(*), 'PASS', 'FAIL') AS result
FROM wvp_stream_proxy;

-- 3. ROI 关联验证
SELECT '=== ROI 关联验证 ===' AS step;

SELECT 'ROI全部关联到有效camera_code' AS check_name,
       COUNT(*) AS total_rois,
       SUM(CASE WHEN sp.camera_code IS NOT NULL THEN 1 ELSE 0 END) AS matched,
       IF(COUNT(*) = SUM(CASE WHEN sp.camera_code IS NOT NULL THEN 1 ELSE 0 END), 'PASS', 'FAIL') AS result
FROM wvp_ai_roi r
LEFT JOIN wvp_stream_proxy sp ON r.camera_id = sp.camera_code;

-- 4. 样本数据展示
SELECT '=== 样本数据 ===' AS step;

SELECT 'wvp_stream_proxy 样本' AS table_name;
SELECT id, camera_code, app, stream, name
FROM wvp_stream_proxy
LIMIT 3;

SELECT 'wvp_ai_roi 样本' AS table_name;
SELECT roi_id, camera_id, name
FROM wvp_ai_roi
LIMIT 3;

-- 5. 总结
SELECT '=== 验证总结 ===' AS step;
SELECT
  '迁移验证' AS summary,
  IF(
    (SELECT COUNT(*) FROM wvp_stream_proxy WHERE camera_code IS NULL) = 0
    AND (SELECT COUNT(*) FROM wvp_stream_proxy WHERE camera_code NOT REGEXP '^cam_[a-f0-9]{12}$') = 0
    AND (SELECT COUNT(*) FROM wvp_ai_roi r LEFT JOIN wvp_stream_proxy sp ON r.camera_id = sp.camera_code WHERE sp.camera_code IS NULL) = 0,
    '✅ 全部通过',
    '❌ 存在问题,请检查上述详细结果'
  ) AS result;

文档版本: v1.0 最后更新: 2026-02-13 维护人: AI开发团队 审核状态: 待审核


变更记录:

版本 日期 作者 变更内容
v1.0 2026-02-13 AI Team 初始版本,完整文档创建