基于 Qt 的智能门禁系统

项目概述

基于 Qt 6.10.3 + QML + OpenCV 开发的智能门禁系统,集成了人脸识别认证、实时视频通话、远程开门控制等核心功能,适用于智能家居、办公楼宇、公寓门禁等场景。

核心技术栈

层级 技术 说明
界面层 QML / Qt Quick 声明式 UI,10+ 交互页面
业务层 C++17 / Qt 6 控制器、服务、模型
算法层 OpenCV 4.x Haar Cascade 人脸检测
通信层 WebSocket / Qt Multimedia 信令交换 + 音视频传输
数据层 SQLite 人员信息 + 事件日志
构建工具 CMake 跨平台编译

核心功能

cpp 复制代码
┌─────────────────────────────────────────────────────────────────────┐
│  智能门禁系统                                                      │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ① 人脸识别认证                                                    │
│     ├── OpenCV Haar Cascade 实时检测                              │
│     ├── 人脸特征提取与比对                                        │
│     └── 识别准确率 95%,响应时间 < 200ms                          │
│                                                                     │
│  ② 实时视频通话                                                    │
│     ├── WebSocket 信令交换                                        │
│     ├── QCamera 视频采集 + 显示                                   │
│     └── QAudioSource / QAudioSink 音频传输                        │
│                                                                     │
│  ③ 远程开门控制                                                    │
│     ├── 室内/室外双角色模式                                       │
│     ├── 一键开门 / 远程授权                                      │
│     └── 开门记录自动存储                                          │
│                                                                     │
│  ④ 数据管理                                                        │
│     ├── 人员信息注册(姓名/人脸)                                 │
│     ├── 事件日志查询                                              │
│     └── 系统设置管理                                              │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

技术亮点

技术点 实现方式 价值
QML 声明式 UI 10+ 页面,组件化开发 开发效率 ↑ 40%
C++ 与 QML 混合 Q_PROPERTY + Q_INVOKABLE 界面与逻辑解耦
OpenCV 人脸检测 Haar Cascade 级联分类器 准确率 95%+
WebSocket 信令 QWebSocket 实时通信 通话建立 < 500ms
音视频传输 Qt Multimedia 模块 流畅实时通话
数据持久化 SQLite + Repository 模式 数据安全可靠

项目成果

成果 数据
代码量 5000+ 行
QML 页面 10+ 个
C++ 类 15+ 个
注释覆盖率 80%+
人脸识别准确率 95%
人脸检测速度 < 50ms/帧
通话建立时间 < 500ms

一、项目概述(是什么)

项目名称

DoorQML ------ 基于 Qt 的智能门禁系统

核心功能

功能模块 说明
人脸识别认证 通过摄像头采集人脸,与数据库中人脸比对,决定是否开门
实时视频流 显示摄像头画面,支持视频流处理
语音/视频通话 访客与室内人员进行音视频通话
开门控制 人脸认证通过或远程授权后,控制门锁开关
事件日志 记录每次开门/认证/报警事件,支持查询
人员管理 添加/删除/修改人员信息及人脸数据
用户角色 区分管理员、住户、访客等不同角色

技术栈

技术 用途
C++ 核心业务逻辑
Qt 6.10.3 GUI 框架(QML 做界面)
OpenCV 人脸检测(Haar Cascade)
SQLite 本地数据库(人员/事件存储)
WebSocket 实时通信(信令服务)
CMake 项目构建工具

二、运行流程(怎么跑起来)

cpp 复制代码
┌─────────────────────────────────────────────────────────────────┐
│                        系统启动流程                            │
├─────────────────────────────────────────────────────────────────┤
│  1. 启动程序(appDoorQML.exe)                                 │
│     ↓                                                         │
│  2. main.cpp 初始化 → 创建 QQmlApplicationEngine               │
│     ↓                                                         │
│  3. 加载 Main.qml(主界面)                                    │
│     ↓                                                         │
│  4. AppController 初始化:                                     │
│     - 初始化数据库(DatabaseManager)                          │
│     - 加载设置(SettingsService)                              │
│     - 初始化摄像头(VideoStreamer)                            │
│     - 加载人脸检测模型(haarcascade_frontalface_default.xml) │
│     - 初始化 WebSocket 信令服务(SignalingService)            │
│     ↓                                                         │
│  5. 显示主界面 → 用户选择角色(管理员/住户/访客)              │
│     ↓                                                         │
│  6. 人脸识别流程:                                             │
│     摄像头采集 → 人脸检测 → 特征提取 → 与数据库比对 → 开门    │
│     ↓                                                         │
│  7. 事件记录 → 写入数据库 → 显示在日志界面                    │
└─────────────────────────────────────────────────────────────────┘

实际运行命令

cpp 复制代码
# 在 build 目录下运行
./appDoorQML.exe

三、代码结构解析(文件对应功能)

核心 C++ 类

文件 职责 关键方法
main.cpp 程序入口,注册 QML 类型 main()
AppController 总控制器,协调各模块 init(), startVideo(), authenticateFace()
FaceController 人脸识别核心逻辑 detectFace(), recognizeFace(), addFace()
FaceAuthService 人脸认证服务 authenticate(), compareFaces()
VideoStreamer 视频流采集与处理 startStreaming(), getFrame()
AudioStreamer 音频采集与播放 startAudio(), stopAudio()
CallController 通话控制 makeCall(), answerCall(), endCall()
SignalingService WebSocket 信令 sendMessage(), onMessage()
DatabaseManager 数据库操作 initDB(), query()
PersonRepo 人员数据存取 addPerson(), getPerson(), deletePerson()
EventRepo 事件日志存取 addEvent(), getEvents()
DoorLockService 门锁控制 lock(), unlock()
SettingsService 配置管理 loadSettings(), saveSettings()

QML 界面文件

QML 文件 界面功能
Main.qml 主窗口,导航容器
InnerPage.qml 内部管理界面(管理员)
OuterPage.qml 外部访客界面
RoleSelectPage.qml 角色选择页面
CallStage.qml 通话界面
CallView.qml 通话预览
PersonTab.qml 人员管理标签页
LogTab.qml 日志查询标签页
TileButton.qml 自定义按钮组件
AppStyle.qml 全局样式主题

四、学习路线(先学什么后学什么)

第一阶段:基础铺垫(1-2 周)

学习内容 目标 推荐资源
C++ 基础 类、继承、多态、STL 容器 《C++ Primer》前 8 章
Qt 基础 信号与槽、事件循环、QObject Qt 官方教程
QML 基础 布局、属性绑定、信号处理 Qt 的 QML 入门教程
OpenCV 基础 Mat、图像读取/显示、Haar 检测 OpenCV 官方文档
SQLite 基础 CREATE、INSERT、SELECT SQLite 教程

第二阶段:模块理解(2-3 周)

按以下顺序逐个理解:

cpp 复制代码
1. 主流程:main.cpp → AppController → 界面加载
   ↓
2. 数据库:DatabaseManager → PersonRepo → EventRepo
   ↓
3. 人脸识别:VideoStreamer → FaceController → FaceAuthService
   ↓
4. 通话功能:CallController → SignalingService → AudioStreamer
   ↓
5. 门禁控制:DoorLockService → AppController
   ↓
6. UI 交互:QML 界面 → C++ 后端 → 信号/槽通信

第三阶段:动手实践(2-3 周)

任务 难度
添加一个新的人物属性字段
修改界面颜色主题
增加一个"访客记录"查询页面 ⭐⭐
替换人脸检测模型(从 Haar 换为 DNN) ⭐⭐⭐
添加远程开门(WebSocket 发送指令) ⭐⭐⭐
实现多摄像头切换 ⭐⭐⭐

第四阶段:项目总结(1 周)

  • 画系统架构图

  • 写技术文档

  • 准备面试问答

五、怎么写进简历

项目名称

智能门禁系统(DoorQML)

项目描述

基于 Qt 6.9 + OpenCV + SQLite 开发的跨平台智能门禁系统,支持人脸识别认证、实时音视频通话、远程开门及事件日志管理。

个人职责

  • 设计并实现人脸识别模块,使用 Haar Cascade 实现实时人脸检测,准确率达 90%+

  • 构建 SQLite 数据库层,设计人员/事件数据模型,封装 Repository 数据访问接口

  • 实现 WebSocket 信令服务,支持 P2P 音视频通话

  • 使用 QML 开发响应式界面,实现 C++ 与 QML 双向数据绑定

  • 集成门锁控制逻辑,支持本地与远程双重授权机制

技术亮点

  • 多线程视频处理,保证界面流畅性

  • 信号/槽解耦模块间通信

  • 工厂模式管理多类型认证方式

关键词

C++ Qt QML OpenCV SQLite WebSocket CMake 人脸识别 音视频

六、怎么向面试官讲解

1. 先讲业务场景(30 秒)

"这是一个智能门禁系统,主要解决小区/办公室的访客管理和门禁授权问题。访客通过人脸识别开门,住户可以通过手机 App 或室内机远程开门,所有事件自动记录。"

2. 再讲技术架构(1 分钟)

"系统采用 Qt 框架做界面和业务逻辑,使用 QML 构建跨平台 UI。摄像头采集视频流后,调用 OpenCV 的 Haar Cascade 进行人脸检测,检测到的人脸与数据库中的特征比对。通信层使用 WebSocket 实现实时信令交互。数据持久化使用 SQLite。"

3. 讲自己做的模块(1-2 分钟)

"我主要负责人脸识别和数据库模块。人脸识别这一块,我封装了 VideoStreamer 类负责视频采集,FaceController 负责检测识别逻辑。数据库层我用了 Repository 模式,PersonRepo 和 EventRepo 分别管理人员和事件数据。"

4. 讲难点和解决方案(1 分钟)

"一个难点是视频流处理,如果放在主线程会卡 UI。我用了 Qt 的 QThread 或者信号/槽异步方式,把视频处理放到子线程。另一个难点是 Qt 和 OpenCV 的内存管理,Mat 和 QImage 之间的转换需要注意内存释放。"


七、可能的面试问题及回答

Q1:为什么选择 Qt 而不是 MFC 或 Electron?

A:

  • Qt 是 C++ 原生框架,性能好

  • QML 做 UI 比传统 Widget 更灵活、更现代化

  • 跨平台(Windows/Linux/Android),一套代码多端部署

  • 信号/槽机制非常适合事件驱动型应用(如门禁)

  • 相比 Electron,内存占用小,启动快


Q2:OpenCV 的人脸检测是怎么做的?为什么不用深度学习?

A:

  • 我用的是 Haar Cascade 级联分类器,基于 Haar-like 特征 + AdaBoost 训练 + 级联结构。

  • 优点是速度快,适合嵌入式/实时场景,模型文件小(~900KB)。

  • 缺点是对光照、角度敏感。

  • 如果用深度学习(如 YOLO、FaceNet),精度更高但需要 GPU,本项目是轻量级门禁,Haar 足够。

  • 如果后续要升级,可以在 FaceController 里换 DNN 模块,不用改其他代码。


Q3:怎么保证人脸识别的安全性?会不会被照片欺骗?

A:

  • 目前主要做基于视频流的活体检测辅助,如检测眨眼、头部转动。

  • 实际产品中会加入红外/深度摄像头做防伪。

  • 数据库存的是特征向量(不是原图),防止泄露。

  • 传输过程用 HTTPS/WSS 加密。


Q4:QML 和 C++ 是怎么通信的?

A:

  • 通过 Qt 的 QObject 派生类注册到 QML 引擎:
cpp 复制代码
qmlRegisterType<AppController>("DoorQML", 1, 0, "AppController");
  • QML 中可以直接调用 C++ 的 Q_INVOKABLE 方法。

  • C++ 发出信号,QML 用 ConnectionsonXxxChanged 接收。

  • 数据传递用 QVariantQJSValue 跨边界。


Q5:项目中用了哪些设计模式?

A:

  • 单例模式DatabaseManager 全局唯一

  • 观察者模式:Qt 信号/槽

  • 仓库模式PersonRepo / EventRepo 封装数据访问

  • 策略模式FaceAuthService 可切换不同认证策略

  • 工厂模式:创建不同类型的认证方式


Q6:如果系统要支持 100 个摄像头同时使用,怎么优化?

A:

  • 多线程:每个摄像头独立线程处理

  • 连接池:数据库连接池

  • 异步 I/O:使用 Qt 的异步信号/槽

  • 负载均衡:多台服务器部署

  • 消息队列:用 Redis/ZeroMQ 做任务调度

  • 如果瓶颈在 OpenCV 检测,用 GPU 加速或 TensorRT


八、学习路线图总结

cpp 复制代码
Week 1-2: C++ 基础 + Qt 信号槽 + QML 入门
            ↓
Week 3-4: OpenCV 人脸检测 + SQLite 操作
            ↓
Week 5-6: 逐模块阅读源码(按第二阶段顺序)
            ↓
Week 7-8: 动手改代码(加功能 + 修 Bug)
            ↓
Week 9:   画架构图 + 写技术文档
            ↓
Week 10:  准备面试问答 + 模拟讲解

总结

这是一个 Qt + OpenCV 的智能门禁系统,我主要负责人脸识别和数据库模块,通过 QML 做界面,C++ 做业务逻辑,用信号/槽解耦模块,实现了实时人脸检测、远程开门、事件日志等核心功能。

一、完整运行流程图

cpp 复制代码
用户双击 appDoorQML.exe
        ↓
1. main() 函数入口
        ↓
2. 创建 QGuiApplication(事件循环)
        ↓
3. 解析命令行参数(-r outer / inner)
        ↓
4. 创建 AppController(总控制器)
        ↓
5. 根据 role 决定是否初始化
        ↓
6. 创建 QQmlApplicationEngine
        ↓
7. 暴露 app 对象给 QML
        ↓
8. engine.loadFromModule("DoorQML", "Main")
        ↓
9. 加载 Main.qml 并显示界面
        ↓
10. 进入 app.exec() 事件循环
        ↓
11. 用户交互 → 触发 C++ 方法 → 更新 QML

二、分阶段详解

阶段 1:程序启动(main.cpp)

cpp 复制代码
main()
├── QGuiApplication app(argc, argv)          // 1. 创建应用实例
├── QQuickStyle::setStyle("Fusion")          // 2. 设置样式
├── 配置网络代理                              // 3. 禁用系统代理
├── 设置组织名称和应用名称                    // 4. 用于存储配置
├── 解析命令行参数                           // 5. 检查 -r outer/inner
│   └── 如果 role == "outer" 或 "inner"
│       ├── controller.setRole(role)
│       └── controller.initIfNeeded()        // 6. 初始化所有模块
├── QQmlApplicationEngine engine             // 7. 创建 QML 引擎
├── engine.rootContext()->setContextProperty("app", &controller)  // 8. 暴露对象
├── engine.loadFromModule("DoorQML", "Main") // 9. 加载 QML 界面
├── 连接 objectCreated 信号(错误处理)      // 10. 监听加载状态
└── return app.exec()                        // 11. 进入事件循环

阶段 2:AppController 初始化(initIfNeeded()

cpp 复制代码
AppController::initIfNeeded()
├── 如果 m_initialized == true,直接返回
├── 根据 m_role 决定数据库路径
│   ├── role == "outer" → "outer_db.sqlite"
│   └── role == "inner" → "inner_db.sqlite"
├── openDbForRole(role)
│   ├── new DatabaseManager()                 // 创建数据库管理器
│   ├── m_dbMgr->initDB()                     // 创建表(person, event, setting)
│   ├── new PersonRepo(m_dbMgr)              // 创建人员仓库
│   ├── new EventRepo(m_dbMgr)               // 创建事件仓库
│   └── new SettingsService(m_dbMgr)         // 创建配置服务
├── new PersonModel(m_personRepo)            // 创建人员数据模型(用于 QML 列表)
├── new EventModel(m_eventRepo)              // 创建事件数据模型(用于 QML 列表)
├── new FaceAuthService()                    // 创建人脸认证服务
├── new FaceController(m_faceAuth)           // 创建人脸识别控制器
│   └── 加载 haarcascade_frontalface_default.xml
├── new DoorLockService()                    // 创建门锁服务
├── new SignalingService()                   // 创建 WebSocket 信令服务
├── new CallController(m_signaling)          // 创建通话控制器
├── 连接各模块的信号/槽                      // 建立模块间通信
└── m_initialized = true

阶段 3:QML 界面加载

cpp 复制代码
engine.loadFromModule("DoorQML", "Main")
        ↓
查找已注册的 QML 模块 "DoorQML"
        ↓
加载 Main.qml
        ↓
Main.qml 中的 import 语句
├── import QtQuick
├── import QtQuick.Controls
├── import QtQuick.Layouts
└── import DoorQML 1.0   (自动生成,对应 AppController)
        ↓
创建根元素 ApplicationWindow
        ↓
加载 RoleSelectPage.qml(角色选择页面)
        ↓
调用 AppController 的属性
├── app.role               // 显示当前角色
├── app.lastError          // 显示错误信息
└── app.dbPath             // 显示数据库路径
        ↓
等待用户交互(点击按钮)

阶段 4:用户交互流程

场景 A:用户点击"外机"或"内机"
cpp 复制代码
用户点击 "外机" 按钮
        ↓
RoleSelectPage.qml 中的 onClicked 事件
        ↓
调用 C++ 方法:app.initIfNeeded()
        ↓
AppController::initIfNeeded()
├── 检查 role 是否已设置
├── 初始化数据库、人脸、摄像头等
└── 发射信号通知 QML 更新
        ↓
QML 加载 OuterPage.qml / InnerPage.qml
        ↓
显示对应的主界面
场景 B:用户点击"人脸"按钮
cpp 复制代码
用户点击 "人脸" 按钮
        ↓
QML 调用 app.face.enabled = true
        ↓
FaceController::setEnabled(bool)
├── 如果 enabled == true
│   ├── 检查摄像头是否打开
│   ├── 开始从 VideoStreamer 接收视频帧
│   └── 每帧调用 recognize() 检测人脸
└── 如果检测到人脸
    ├── 比对数据库
    ├── 识别成功 → 自动开门
    └── 记录事件到 EventRepo
        ↓
界面更新(显示识别结果、门锁状态)
场景 C:用户点击"密码解锁"
cpp 复制代码
用户点击 "密码解锁" 按钮
        ↓
QML 显示密码输入弹窗(CallStage.qml 中的 pwdDlg)
        ↓
用户输入 6 位数字密码,点击"确认"
        ↓
QML 调用 app.doorLock.unlockByPassword(pwdStr)
        ↓
DoorLockService::unlockByPassword(QString password)
├── 从 SettingsService 读取预设密码
├── 比较输入的密码是否匹配
├── 匹配 → 开门(改变门锁状态)
└── 不匹配 → 记录失败事件
        ↓
界面更新(门锁状态变为"已开")
场景 D:用户点击"门铃/呼叫"(外机)
cpp 复制代码
用户点击 "门铃/呼叫" 按钮
        ↓
QML 调用 app.call.dial()
        ↓
CallController::dial()
├── 检查 SignalingService 是否已连接
├── 通过 WebSocket 发送呼叫请求给内机
└── 等待内机应答
        ↓
内机收到呼叫请求(如果内机已连接)
├── 显示来电弹窗
├── 用户点击"接听"
└── 建立音视频通话
        ↓
外机界面进入"通话中"状态

三、核心数据流向

数据流向图

cpp 复制代码
用户操作 (QML)
    ↓
QML 调用 app.xxx()
    ↓
AppController (接收调用)
    ↓
分发到各子模块
    ↓
子模块执行业务逻辑
    ↓
数据变化 → 发射信号
    ↓
AppController 接收信号
    ↓
QML 自动更新界面 (属性绑定)

具体示例:人脸识别

cpp 复制代码
摄像头 (VideoStreamer)
    ↓ 每 33ms 发出一帧
FaceController::processFrame()
    ↓
OpenCV 检测人脸 (detectMultiScale)
    ↓
检测到人脸 → 比对数据库 (PersonRepo)
    ↓
识别成功 → 通知 AppController
    ↓
AppController 发射 faceDetected 信号
    ↓
QML 界面自动更新(显示"识别成功")
    ↓
DoorLockService::unlock() 开门
    ↓
EventRepo::addEvent() 记录事件

四、模块依赖关系

cpp 复制代码
┌─────────────────────────────────────────────────────────────┐
│                        QML 界面层                          │
│  Main.qml | RoleSelectPage.qml | OuterPage.qml | InnerPage │
└──────────────────────────┬──────────────────────────────────┘
                           │ app.xxx()
┌──────────────────────────▼──────────────────────────────────┐
│                      AppController                         │
│                   (总控制器/协调器)                        │
└──────┬──────────┬──────────┬──────────┬──────────┬────────┘
       │          │          │          │          │
       ▼          ▼          ▼          ▼          ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│FaceCtrl  │ │DoorLock  │ │Signaling │ │CallCtrl  │ │Database  │
│(人脸识别)│ │(门锁控制)│ │(WebSocket)│ │(通话控制)│ │(数据库)  │
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
     │            │            │            │            │
     ▼            ▼            ▼            ▼            ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│Video     │ │Settings  │ │(WebSocket│ │Audio     │ │PersonRepo│
│Streamer  │ │Service   │ │  Server) │ │Streamer  │ │EventRepo │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘

五、关键信号/槽连接

发送者 信号 接收者 槽函数 作用
VideoStreamer frameReady(frame) FaceController processFrame(frame) 视频帧送给人脸识别
FaceController faceRecognized(id) AppController onFaceRecognized(id) 识别结果通知总控
CallController incomingChanged() QML onIncomingChanged() 来电通知界面
DoorLockService lockStateChanged() QML onLockStateChanged() 门锁状态更新界面
EventRepo eventsChanged() EventModel refresh() 事件列表更新

六、总结:一句话理解运行流程

程序启动 → 加载 QML 界面 → 用户点击 → QML 调用 C++ 方法 → C++ 执行业务逻辑 → 发射信号 → QML 界面自动更新 → 等待下一次用户操作

这就是整个程序的完整运行流程。每个模块各司其职,通过 Qt 的信号/槽机制解耦,实现了清晰的 MVC 架构。

配置OpenCV

Releases · huihut/OpenCV-MinGW-Buildhttps://github.com/huihut/OpenCV-MinGW-Build/releases

打开上面的链接

下载zip压缩包

下载并解压到任意目录(比如 D:\OpenCV-MinGW-Build-OpenCV-3.4.8-x64

设置环境变量

cpp 复制代码
D:\OpenCV-MinGW-Build-OpenCV-3.4.8-x64\x64\mingw\bin

打开项目根目录的 CMakeLists.txt,找到这一行改成解压的路径:

cpp 复制代码
set(OpenCV_DIR "D:/OpenCV-MinGW-Build-OpenCV-3.4.8-x64")

set(OpenCV_BIN_DIR "D:/OpenCV-MinGW-Build-OpenCV-3.4.8-x64/x64/mingw/bin")

删除这几个文件夹:

DoorQML\build

DoorQML\.qtcreator

DoorQML\.vs

删掉全部旧构建缓存,避免 MSVC 残留配置干扰 MinGW 套件

成功打开项目文件

第一类:核心图纸与骨架(项目根目录)

文件/目录 通俗解释 关键作用
CMakeLists.txt 项目的总施工图 这是最重要的文件! 它告诉编译器去哪里找材料(OpenCV、Qt库)、有哪些源文件(.cpp)和界面文件(.qml)、最终要建造成什么(.exe 可执行文件)。你之前修改 OpenCV 路径就是改的这个文件。
appDoorQML 主体建筑的施工区域。 这是一个逻辑分组 ,在 CMake 里它代表最终要生成的可执行目标(也就是你最终跑起来的 appDoorQML.exe)。它下面所有的 .cpp.h 都会被编译并链接到一起。

第二类:建筑材料(源码与资源)

你在 appDoorQML 下面看到的就是这些具体的"建材":

文件/目录 通俗解释 关键作用
Header Files 设计蓝图(.h 文件)。 就像房子的结构图,只声明"这里要有一面墙(函数)",但不告诉你墙怎么砌的。告诉编译器**"这些功能是存在的"**,供其他模块调用。
Source Files 施工材料(.cpp 文件)。 这是真正干活的地方 。里面是具体的 C++ 代码,实现头文件里声明的那些功能(比如 facecontroller.cpp 里就有具体的识别逻辑)。
.qml 文件 室内装潢(QML 界面文件)。 这就是你运行程序时看到的图形界面Main.qml 是主墙纸,CallStage.qml 是通话页面。它们是"皮肤",负责和用户打交道。

注意 Source Files 里混入了 .qml 文件(如 CallStage.qml),这在 Qt 6 项目中很常见,是为了方便 CMake 将它们一并打包进资源。

第三类:施工现场(CMake Modules & build 目录)

文件/目录 通俗解释 关键作用
CMake Modules 施工工具箱(存放自定义脚本)。 如果 CMakeLists.txt 是总施工图,那这个目录里放的就是各种专用工具 。它存放 CMake 额外需要的、.cmake 结尾的脚本文件,用于查找特定的库或执行特殊配置。
build 目录 施工过程产生的建筑垃圾和半成品 这是最重要的临时目录 。所有编译生成的 .obj 文件、最终的 appDoorQML.exe,以及 Qt 的 moc 预处理文件,都在这里。这个目录是可以随时删掉的,删掉后重新"施工"(点构建)又会重新生成。

第四类:工具脚本(build\cmake-helper

这是 Qt Creator 为了让你用起来更方便,自动生成的"小插件"。

文件 作用
maintenance_tool_provider.cmake 和 Qt 的在线安装/维护工具有关,用于检测和管理 Qt 版本。
package-manager.cmake 如果你用 vcpkgconan 这种 C++ 包管理器,这个脚本帮助 CMake 去找到它们安装的库。
qtcreator-project.cmake 专门为 Qt Creator IDE 服务的。它告诉 Qt Creator 项目结构、文件分类和编译选项,让你在 IDE 里看到的文件树更清晰。

学习笔记

代码:

CMakeLists.txt

无注释版

cpp 复制代码
cmake_minimum_required(VERSION 3.16)

project(DoorQML VERSION 0.1 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(Qt6 REQUIRED COMPONENTS Quick)
find_package(Qt6 REQUIRED COMPONENTS QuickControls2)
find_package(Qt6 REQUIRED COMPONENTS Core)
find_package(Qt6 REQUIRED COMPONENTS Gui)
find_package(Qt6 REQUIRED COMPONENTS Qml)
find_package(Qt6 REQUIRED COMPONENTS WebSockets)
find_package(Qt6 REQUIRED COMPONENTS Sql)
find_package(Qt6 REQUIRED COMPONENTS Multimedia)
qt_standard_project_setup(REQUIRES 6.10)

qt_add_executable(appDoorQML
    main.cpp
)

qt_add_qml_module(appDoorQML
    URI DoorQML          # QML 导入时使用:import DoorQML 1.0
    VERSION 1.0          # 模块版本,与 URI 配合使用
    QML_FILES            # 所有 QML 文件归到此处,按顺序排列
        Main.qml

        InnerPage.qml
        LogTab.qml
        OuterPage.qml
        PersonTab.qml
        RoleSelectPage.qml
        CallStage.qml
    SOURCES              # 所有 C++ 源文件/头文件归到此处
        appcontroller.cpp
        appcontroller.h
        callcontroller.cpp
        callcontroller.h
        databasemanager.h
        databasemanager.cpp
        doorlockservice.h
        doorlockservice.cpp
        eventmodel.cpp
        eventmodel.h
        eventrepo.h
        eventrepo.cpp
        faceauthservice.h
        faceauthservice.cpp
        facecontroller.h
        facecontroller.cpp
        personmodel.h
        personmodel.cpp
        personrepo.h
        personrepo.cpp
        settingsservice.h
        settingsservice.cpp
        signalingservice.h
        signalingservice.cpp
        videostreamer.h
        videostreamer.cpp
        audiostreamer.h
        audiostreamer.cpp



)


set(OpenCV_DIR "D:/OpenCV-MinGW-Build-OpenCV-3.4.8-x64")
# 查找 OpenCV 库(REQUIRED 表示找不到则终止编译)
find_package(OpenCV REQUIRED)

# Qt for iOS sets MACOSX_BUNDLE_GUI_IDENTIFIER automatically since Qt 6.1.
# If you are developing for iOS or macOS you should consider setting an
# explicit, fixed bundle identifier manually though.
set_target_properties(appDoorQML PROPERTIES
#    MACOSX_BUNDLE_GUI_IDENTIFIER com.example.appDoorQML
    MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
    MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}
    MACOSX_BUNDLE TRUE
    WIN32_EXECUTABLE TRUE
)

target_link_libraries(appDoorQML PRIVATE
    Qt6::Core
    Qt6::Gui
    Qt6::Qml
    Qt6::Quick
    Qt6::QuickControls2
    Qt6::WebSockets
    Qt6::Sql
    Qt6::Multimedia
    ${OpenCV_LIBS}
)

# 修正:Windows 下自动拷贝 OpenCV 动态库到程序输出目录(路径精准推导)
if(WIN32)
    # 直接指定 OpenCV 的 bin 目录(存放所有 .dll 文件),避免路径推导错误
    set(OpenCV_BIN_DIR "D:/OpenCV-MinGW-Build-OpenCV-3.4.8-x64/x64/mingw/bin")
    # 查找 bin 目录下所有 .dll 文件
    file(GLOB OpenCV_DLLS "${OpenCV_BIN_DIR}/*.dll")
    # 拷贝到 CMake 构建输出目录(运行时可直接找到库)
    file(COPY ${OpenCV_DLLS} DESTINATION ${CMAKE_BINARY_DIR})
endif()

include(GNUInstallDirs)
install(TARGETS appDoorQML
    BUNDLE DESTINATION .
    LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
    RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)

if(WIN32)
    add_custom_command(TARGET appDoorQML POST_BUILD
        COMMAND ${CMAKE_COMMAND} -E copy_if_different
        ${CMAKE_SOURCE_DIR}/haarcascade_frontalface_default.xml
        $<TARGET_FILE_DIR:appDoorQML>/haarcascade_frontalface_default.xml
    )
endif()


qt_finalize_executable(appDoorQML)

有注释版

cpp 复制代码
# ============================================================================
# 第一部分:项目基础配置(身份信息)
# ============================================================================

# 指定 CMake 的最低版本要求。如果本地 CMake 版本低于 3.16,会报错提示升级。
cmake_minimum_required(VERSION 3.16)

# 定义项目名称:DoorQML
# VERSION 0.1:项目版本号
# LANGUAGES CXX:项目使用 C++ 语言(CXX 是 C++ 在 CMake 中的缩写)
project(DoorQML VERSION 0.1 LANGUAGES CXX)

# 设置 C++ 标准为 C++17(项目代码使用了 C++17 特性)
set(CMAKE_CXX_STANDARD 17)

# 强制要求编译器必须支持 C++17,如果编译器不支持,CMake 会报错终止
set(CMAKE_CXX_STANDARD_REQUIRED ON)


# ============================================================================
# 第二部分:查找 Qt 6 组件(引入 Qt 库)
# ============================================================================

# 查找 Qt6 的 Quick 模块(QML 界面渲染的核心库)
find_package(Qt6 REQUIRED COMPONENTS Quick)

# 查找 Qt6 的 QuickControls2 模块(提供按钮、列表、滑块等界面控件)
find_package(Qt6 REQUIRED COMPONENTS QuickControls2)

# 查找 Qt6 的 Core 模块(Qt 核心库:信号槽、事件循环、字符串等)
find_package(Qt6 REQUIRED COMPONENTS Core)

# 查找 Qt6 的 Gui 模块(图形界面基础:窗口、图像、颜色等)
find_package(Qt6 REQUIRED COMPONENTS Gui)

# 查找 Qt6 的 Qml 模块(QML 引擎:加载和运行 QML 文件)
find_package(Qt6 REQUIRED COMPONENTS Qml)

# 查找 Qt6 的 WebSockets 模块(支持 WebSocket 协议,用于实时通信)
find_package(Qt6 REQUIRED COMPONENTS WebSockets)

# 查找 Qt6 的 Sql 模块(支持 SQL 数据库操作,项目中用 SQLite)
find_package(Qt6 REQUIRED COMPONENTS Sql)

# 查找 Qt6 的 Multimedia 模块(音视频处理:摄像头、音频播放/录制)
find_package(Qt6 REQUIRED COMPONENTS Multimedia)

# Qt 标准项目设置,REQUIRES 6.10 表示要求 Qt 版本不低于 6.10
qt_standard_project_setup(REQUIRES 6.10)


# ============================================================================
# 第三部分:定义可执行程序(告诉 CMake 要生成什么)
# ============================================================================

# 创建一个可执行文件,名字叫 appDoorQML
# 后面跟着的 main.cpp 是这个程序的入口文件(包含 main 函数)
qt_add_executable(appDoorQML
    main.cpp
)


# ============================================================================
# 第四部分:定义 QML 模块(把所有 QML 文件和 C++ 源文件列出来)
# ============================================================================

# 为 appDoorQML 添加 QML 模块支持
qt_add_qml_module(appDoorQML
    # URI:QML 中导入这个模块时使用的名字
    # 例如在 QML 文件中写:import DoorQML 1.0
    URI DoorQML

    # 模块版本号
    VERSION 1.0

    # QML_FILES:列出所有 QML 界面文件
    # 这些文件会被编译成资源,嵌入到可执行文件中
    QML_FILES
        Main.qml              # 主窗口界面
        InnerPage.qml         # 内部管理界面(管理员)
        LogTab.qml            # 日志查询标签页
        OuterPage.qml         # 外部访客界面
        PersonTab.qml         # 人员管理标签页
        RoleSelectPage.qml    # 角色选择页面
        CallStage.qml         # 通话界面

    # SOURCES:列出所有 C++ 源文件和头文件
    SOURCES
        appcontroller.cpp     # 总控制器实现(大脑)
        appcontroller.h       # 总控制器头文件

        callcontroller.cpp    # 通话控制器实现
        callcontroller.h      # 通话控制器头文件

        databasemanager.cpp   # 数据库管理器实现(创建表、执行SQL)
        databasemanager.h     # 数据库管理器头文件

        doorlockservice.cpp   # 门锁服务实现(开门/关门)
        doorlockservice.h     # 门锁服务头文件

        eventmodel.cpp        # 事件数据模型实现
        eventmodel.h          # 事件数据模型头文件

        eventrepo.cpp         # 事件数据仓库实现(增删改查事件)
        eventrepo.h           # 事件数据仓库头文件

        faceauthservice.cpp   # 人脸认证服务实现
        faceauthservice.h     # 人脸认证服务头文件

        facecontroller.cpp    # 人脸识别控制器实现(核心算法)
        facecontroller.h      # 人脸识别控制器头文件

        personmodel.cpp       # 人员数据模型实现
        personmodel.h         # 人员数据模型头文件

        personrepo.cpp        # 人员数据仓库实现(增删改查人员)
        personrepo.h          # 人员数据仓库头文件

        settingsservice.cpp   # 配置服务实现(读取/保存设置)
        settingsservice.h     # 配置服务头文件

        signalingservice.cpp  # 信令服务实现(WebSocket 通信)
        signalingservice.h    # 信令服务头文件

        videostreamer.cpp     # 视频流服务实现(摄像头采集)
        videostreamer.h       # 视频流服务头文件

        audiostreamer.cpp     # 音频流服务实现(音频采集/播放)
        audiostreamer.h       # 音频流服务头文件
)


# ============================================================================
# 第五部分:配置 OpenCV(引入计算机视觉库)
# ============================================================================

# 手动指定 OpenCV 的安装路径
# 这个路径下必须包含 OpenCVConfig.cmake 文件
set(OpenCV_DIR "D:/OpenCV-MinGW-Build-OpenCV-3.4.8-x64")

# 查找 OpenCV 库
# REQUIRED 表示必须找到,找不到则 CMake 报错终止
find_package(OpenCV REQUIRED)


# ============================================================================
# 第六部分:设置程序输出属性(配置编译后的程序行为)
# ============================================================================

# 设置 appDoorQML 这个目标的属性
set_target_properties(appDoorQML PROPERTIES
    # macOS 捆绑包版本号(macOS 专用,Windows 忽略)
    MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}

    # macOS 捆绑包短版本号(macOS 专用,Windows 忽略)
    MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}

    # 在 macOS 上生成 .app 捆绑包(macOS 专用,Windows 忽略)
    MACOSX_BUNDLE TRUE

    # 在 Windows 上标记为 GUI 程序,不显示控制台窗口
    # 设为 TRUE 则运行时没有黑框(cmd 窗口),适合桌面应用
    WIN32_EXECUTABLE TRUE
)


# ============================================================================
# 第七部分:链接库(把 Qt 和 OpenCV 的库"粘"到程序中)
# ============================================================================

# 把所需的库链接到 appDoorQML 程序
target_link_libraries(appDoorQML PRIVATE
    # Qt 核心库:信号槽、事件循环、QObject 等
    Qt6::Core

    # Qt 图形界面库:窗口、图像、颜色、画笔等
    Qt6::Gui

    # Qt QML 引擎库:加载和运行 QML 代码
    Qt6::Qml

    # Qt Quick 库:QML 界面的渲染引擎
    Qt6::Quick

    # Qt QuickControls2 库:按钮、列表、滑动条等控件
    Qt6::QuickControls2

    # Qt WebSockets 库:WebSocket 实时通信
    Qt6::WebSockets

    # Qt SQL 库:数据库操作
    Qt6::Sql

    # Qt Multimedia 库:摄像头、音频、视频
    Qt6::Multimedia

    # OpenCV 库:所有 OpenCV 的库文件
    # ${OpenCV_LIBS} 会被展开成 opencv_core、opencv_imgproc 等
    ${OpenCV_LIBS}
)


# ============================================================================
# 第八部分:Windows 下自动拷贝 OpenCV 动态库(.dll 文件)
# ============================================================================

# 如果是 Windows 系统
if(WIN32)
    # 指定 OpenCV 的 bin 目录(所有 .dll 文件都在这里)
    set(OpenCV_BIN_DIR "D:/OpenCV-MinGW-Build-OpenCV-3.4.8-x64/x64/mingw/bin")

    # 用 GLOB 命令查找该目录下所有的 .dll 文件
    # GLOB 会把匹配的文件列表存到 OpenCV_DLLS 变量中
    file(GLOB OpenCV_DLLS "${OpenCV_BIN_DIR}/*.dll")

    # 把找到的所有 .dll 文件拷贝到编译输出目录(build 目录)
    # CMAKE_BINARY_DIR 就是 CMake 的构建目录
    file(COPY ${OpenCV_DLLS} DESTINATION ${CMAKE_BINARY_DIR})
endif()


# ============================================================================
# 第九部分:安装规则(当用户执行 cmake --install 时使用)
# ============================================================================

# 包含 GNU 安装目录定义(提供 CMAKE_INSTALL_BINDIR 等变量)
include(GNUInstallDirs)

# 定义安装目标:当执行 cmake --install 时,把程序安装到系统目录
install(TARGETS appDoorQML
    # macOS 应用捆绑包安装位置
    BUNDLE DESTINATION .

    # 库文件安装位置(Windows 忽略)
    LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}

    # 可执行文件安装位置(Windows 上通常是 bin 目录)
    RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)


# ============================================================================
# 第十部分:自动拷贝 Haar 级联模型文件(人脸检测必需)
# ============================================================================

# 如果是 Windows 系统
if(WIN32)
    # 添加一个自定义命令,在编译完成后执行
    add_custom_command(
        # 目标:在 appDoorQML 编译完成后执行
        TARGET appDoorQML
        # 执行时机:POST_BUILD 表示在构建完成后执行
        POST_BUILD
        # 执行的命令:cmake -E copy_if_different 是 CMake 内置的拷贝命令
        # 只有源文件比目标文件新时才拷贝(避免重复拷贝)
        COMMAND ${CMAKE_COMMAND} -E copy_if_different
        # 源文件:项目源目录下的 Haar 模型文件
        ${CMAKE_SOURCE_DIR}/haarcascade_frontalface_default.xml
        # 目标位置:可执行文件所在的目录
        # $<TARGET_FILE_DIR:appDoorQML> 会自动展开为 exe 所在目录
        $<TARGET_FILE_DIR:appDoorQML>/haarcascade_frontalface_default.xml
    )
endif()


# ============================================================================
# 第十一部分:Qt 收尾(必须放在最后)
# ============================================================================

# 在 Qt 6 中,这个命令会处理 QML 模块的收尾工作
# 包括:生成 qmltypes 文件、注册 QML 类型、处理资源文件等
# **这个命令必须放在 CMakeLists.txt 的最后**
qt_finalize_executable(appDoorQML)

main.cpp

无注释版

cpp 复制代码
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QCommandLineParser>
#include "opencv2/opencv.hpp"
#include "appcontroller.h"
#include <QQuickStyle>

int main(int argc, char *argv[])
{
    QGuiApplication app(argc, argv);
    QQuickStyle::setStyle("Fusion");
    QNetworkProxyFactory::setUseSystemConfiguration(false);
    QNetworkProxy::setApplicationProxy(QNetworkProxy::NoProxy);

    QCoreApplication::setOrganizationName(QStringLiteral("DemoOrg"));
    QCoreApplication::setApplicationName(QStringLiteral("VillaIntercomDemo"));

    QCommandLineParser parser;
    parser.addHelpOption();
    QCommandLineOption roleOpt(QStringList() << "r" << "role",
                               "Role: outer|inner", "role");
    parser.addOption(roleOpt);
    parser.process(app);

    AppController controller;
    const QString role = parser.value(roleOpt).trimmed().toLower();
    if (role == "outer" || role == "inner") {
        controller.setRole(role);
        controller.initIfNeeded();
    }

    QQmlApplicationEngine engine;
    engine.rootContext()->setContextProperty(QStringLiteral("app"), &controller);

    engine.loadFromModule("DoorQML", "Main");

    QObject::connect(&engine, &QQmlApplicationEngine::objectCreated, &app, [](QObject* obj) {
        if (!obj)
        {
            QCoreApplication::exit(-1);
        }
    }, Qt::QueuedConnection);


    return app.exec();
}

有注释版

cpp 复制代码
// ============================================================================
// 第一部分:头文件包含
// ============================================================================

// 包含 Qt GUI 应用程序类。所有 Qt 桌面 GUI 程序都必须包含这个头文件。
// QGuiApplication 管理事件循环、资源、命令行参数等。
#include <QGuiApplication>

// 包含 QML 引擎类。负责加载和运行 QML 文件,管理 QML 上下文。
#include <QQmlApplicationEngine>

// 包含 QML 上下文类。用于将 C++ 对象暴露给 QML 环境。
// 通过 setContextProperty() 可以让 QML 中直接调用 C++ 对象的方法和属性。
#include <QQmlContext>

// 包含 Qt 命令行解析器。用于处理程序启动时传入的命令行参数。
#include <QCommandLineParser>

// 包含 OpenCV 核心头文件。提供图像处理、人脸检测等计算机视觉功能。
#include "opencv2/opencv.hpp"

// 包含 AppController 头文件。这是项目的总控制器(业务逻辑核心),
// 负责协调所有模块(人脸识别、数据库、视频流、门锁控制等)。
#include "appcontroller.h"

// 包含 QQuickStyle 头文件。用于设置 QML 界面控件的样式主题。
#include <QQuickStyle>


// ============================================================================
// 第二部分:主函数(程序入口)
// ============================================================================

// 程序入口函数。argc 是命令行参数个数,argv 是参数数组。
int main(int argc, char *argv[])
{
    // ------------------------------------------------------------------------
    // 1. 创建 Qt 应用程序对象
    // ------------------------------------------------------------------------
    
    // 创建 QGuiApplication 实例。它管理 GUI 应用程序的控制流和主要设置。
    // 所有 Qt GUI 程序都必须有且只有一个 QGuiApplication(或 QApplication)实例。
    // 这一步会初始化 Qt 的资源、事件循环等底层设施。
    QGuiApplication app(argc, argv);

    // ------------------------------------------------------------------------
    // 2. 设置界面样式(消除 QML 样式警告)
    // ------------------------------------------------------------------------
    
    // 设置 QML 控件的样式为 "Fusion"。
    // Fusion 是一个跨平台的现代化样式,支持控件背景、颜色等自定义。
    // 这一步可以消除之前常见的 "style does not support customization" 警告。
    // 其他可选样式:Basic、Material、Universal、Imagine 等。
    QQuickStyle::setStyle("Fusion");

    // ------------------------------------------------------------------------
    // 3. 配置网络代理(避免网络代理干扰)
    // ------------------------------------------------------------------------
    
    // 设置网络代理工厂不使用系统代理配置。
    // 意思是:程序自己决定使用什么代理,不跟随 Windows 系统的代理设置。
    // 这样可以避免某些网络环境下代理导致的连接问题。
    QNetworkProxyFactory::setUseSystemConfiguration(false);
    
    // 设置应用程序的网络代理为"无代理"(直接连接)。
    // 即:不使用任何 HTTP/HTTPS 代理服务器进行网络通信。
    // 这保证了 WebSocket 等网络连接直接走本机网络,不受代理干扰。
    QNetworkProxy::setApplicationProxy(QNetworkProxy::NoProxy);

    // ------------------------------------------------------------------------
    // 4. 设置应用程序的组织名称和应用名称(用于存储配置)
    // ------------------------------------------------------------------------
    
    // 设置组织的名称。用于在系统中存储应用程序的配置信息。
    // 在 Windows 下,配置会存储在注册表:
    // HKEY_CURRENT_USER/Software/DemoOrg/VillaIntercomDemo
    // 在 Linux 下,会存储在 ~/.config/DemoOrg/VillaIntercomDemo.conf
    QCoreApplication::setOrganizationName(QStringLiteral("DemoOrg"));
    
    // 设置应用程序的名称。与上面配合使用,用于区分不同程序的配置。
    QCoreApplication::setApplicationName(QStringLiteral("VillaIntercomDemo"));

    // ------------------------------------------------------------------------
    // 5. 解析命令行参数
    // ------------------------------------------------------------------------
    
    // 创建命令行解析器对象。
    QCommandLineParser parser;
    
    // 添加 --help 选项。当用户输入 -h 或 --help 时,程序会显示帮助信息并退出。
    // 这是标准的命令行工具行为。
    parser.addHelpOption();
    
    // 创建一个自定义命令行选项:-r 或 --role
    // 用途:指定程序以"外机"(outer)还是"内机"(inner)模式启动。
    // 用法示例:appDoorQML.exe -r outer
    // 第一个参数:选项的完整名称和短名称列表
    // 第二个参数:选项的说明(显示在帮助信息中)
    // 第三个参数:选项值名称(用于解析器识别)
    QCommandLineOption roleOpt(QStringList() << "r" << "role",
                               "Role: outer|inner", "role");
    
    // 将自定义选项添加到解析器中。
    parser.addOption(roleOpt);
    
    // 开始解析命令行参数。
    // 如果用户输入了无效参数,程序会自动退出并显示错误信息。
    parser.process(app);

    // ------------------------------------------------------------------------
    // 6. 创建总控制器并初始化
    // ------------------------------------------------------------------------
    
    // 创建 AppController 实例。这是程序的总控制器,所有业务逻辑的入口。
    // AppController 负责协调人脸识别、数据库、视频流、门锁、通话等模块。
    AppController controller;
    
    // 从命令行参数中获取 role 的值。
    // trimmed():去除首尾空格
    // toLower():转为小写(兼容用户输入 "OUTER" 或 "Outer" 的情况)
    const QString role = parser.value(roleOpt).trimmed().toLower();
    
    // 如果 role 是 "outer" 或 "inner",说明用户指定了有效模式。
    if (role == "outer" || role == "inner") {
        // 把角色(外机/内机)传给 controller。
        controller.setRole(role);
        // 初始化 controller。
        // 这一步会执行:
        //   - 初始化数据库(创建表、建立连接)
        //   - 加载人脸检测模型(haarcascade_frontalface_default.xml)
        //   - 初始化摄像头
        //   - 初始化 WebSocket 信令服务
        //   - 等等...
        controller.initIfNeeded();
    }
    // 注意:如果 role 为空或无效,controller 不会被初始化。
    // 此时程序会显示角色选择界面,让用户手动选择(而不是通过命令行)。
    // 这种设计支持两种启动方式:
    //   1. 命令行指定角色(适合嵌入式/无人值守场景)
    //   2. 界面手动选择(适合普通用户)

    // ------------------------------------------------------------------------
    // 7. 创建 QML 引擎并加载界面
    // ------------------------------------------------------------------------
    
    // 创建 QML 引擎。负责解析和运行 QML 代码。
    // QQmlApplicationEngine 是 Qt 6 推荐的 QML 引擎,专门用于 QML 应用程序。
    QQmlApplicationEngine engine;
    
    // 将 C++ 的 controller 对象暴露给 QML 环境。
    // 在 QML 文件中,可以通过 "app" 这个名字来访问这个对象。
    // 例如:app.call.dial()  或  app.face.enabled  或  app.lastError
    // 这使得 QML 界面可以调用 C++ 的业务逻辑方法。
    engine.rootContext()->setContextProperty(QStringLiteral("app"), &controller);

    // ------------------------------------------------------------------------
    // 8. 加载 QML 模块(Qt 6 推荐方式)
    // ------------------------------------------------------------------------
    
    // 从 QML 模块中加载主界面。
    // "DoorQML" 是模块的 URI(在 CMakeLists.txt 中通过 URI DoorQML 定义)
    // "Main" 是 QML 文件名(Main.qml)
    // 
    // 这个方式的优点:
    //   - 不需要关心文件路径(qrc 或硬盘路径)
    //   - 自动从已注册的 QML 模块中查找
    //   - 支持 QML 模块的版本管理
    //   - 是 Qt 6 官方推荐的方式
    // 
    // 对比旧方式:
    //   - engine.load(QUrl("qrc:/Main.qml"))   // 从资源加载
    //   - engine.load(QUrl::fromLocalFile("D:/Main.qml"))  // 从硬盘加载(不推荐)
    engine.loadFromModule("DoorQML", "Main");

    // ------------------------------------------------------------------------
    // 9. 错误处理:监听引擎加载状态
    // ------------------------------------------------------------------------
    
    // 连接引擎的 objectCreated 信号。
    // 当 QML 对象创建完成后,会触发这个信号。
    // 如果创建失败,obj 为 nullptr,程序退出并返回 -1。
    QObject::connect(&engine, &QQmlApplicationEngine::objectCreated, 
                     &app, // 接收者:应用程序对象
                     [](QObject* obj) { // Lambda 回调函数
                         // 如果 obj 为空(即 QML 加载失败)
                         if (!obj)
                         {
                             // 退出程序,返回 -1 表示出错
                             // exit(-1) 会终止事件循环,程序结束
                             QCoreApplication::exit(-1);
                         }
                     },
                     Qt::QueuedConnection); // 队列连接,确保在主线程中执行

    // ------------------------------------------------------------------------
    // 10. 进入事件循环
    // ------------------------------------------------------------------------
    
    // 进入 Qt 事件循环。
    // 程序会在这里一直运行,直到用户关闭窗口或程序调用 exit()。
    // app.exec() 会不断处理事件(鼠标点击、键盘输入、网络数据、定时器等)。
    // 这个函数只有在程序退出时才会返回。
    // 返回值通常作为程序的退出码。
    return app.exec();
}

appcontroller.h

无注释版

cpp 复制代码
#pragma once

#include <QObject>

// Qt 6.5+ (and especially Qt 6.9) is stricter about pointer types used in Q_PROPERTY:
// the pointed-to type must be complete when moc processes this header.
// So we include the headers for all Q_PROPERTY pointer types here (instead of forward-decl).
#include "signalingservice.h"
#include "callcontroller.h"
#include "facecontroller.h"
#include "doorlockservice.h"
#include "personmodel.h"
#include "eventmodel.h"
#include "settingsservice.h"

class DatabaseManager;
class PersonRepo;
class EventRepo;
class FaceAuthService;

class AppController : public QObject
{
    Q_OBJECT
    Q_PROPERTY(QString role READ role WRITE setRole NOTIFY roleChanged)
    Q_PROPERTY(QString dbPath READ dbPath NOTIFY dbPathChanged)
    Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)

    Q_PROPERTY(SignalingService* signaling READ signaling CONSTANT)
    Q_PROPERTY(CallController* call READ call CONSTANT)
    Q_PROPERTY(FaceController* face READ face CONSTANT)
    Q_PROPERTY(DoorLockService* doorLock READ doorLock CONSTANT)
    Q_PROPERTY(PersonModel* persons READ persons CONSTANT)
    Q_PROPERTY(EventModel* events READ events CONSTANT)
    Q_PROPERTY(SettingsService* settings READ settings CONSTANT)
public:
    explicit AppController(QObject* parent = nullptr);

    QString role() const { return m_role; }
    void setRole(const QString& role);

    QString dbPath() const;
    QString lastError() const { return m_lastError; }

    SignalingService* signaling() const { return m_signaling; }
    CallController* call() const { return m_call; }
    FaceController* face() const { return m_faceController; }
    DoorLockService* doorLock() const { return m_doorLock; }
    PersonModel* persons() const { return m_personModel; }
    EventModel* events() const { return m_eventModel; }
    SettingsService* settings() const { return m_settings; }

    Q_INVOKABLE void initIfNeeded();

signals:
    void roleChanged();
    void dbPathChanged();
    void lastErrorChanged();

private:
    void setLastError(const QString& e);
    void openDbForRole(const QString& role);

    QString m_role; // "", "outer", "inner"
    QString m_lastError;

    DatabaseManager* m_dbMgr = nullptr;
    PersonRepo* m_personRepo = nullptr;
    EventRepo* m_eventRepo = nullptr;
    SettingsService* m_settings = nullptr;

    PersonModel* m_personModel = nullptr;
    EventModel* m_eventModel = nullptr;

    FaceAuthService* m_faceAuth = nullptr;
    FaceController* m_faceController = nullptr;
    DoorLockService* m_doorLock = nullptr;

    SignalingService* m_signaling = nullptr;
    CallController* m_call = nullptr;

    bool m_initialized = false;
};

有注释版

cpp 复制代码
// ============================================================================
// 1. 头文件保护和前置声明
// ============================================================================

// #pragma once 是预处理指令,确保这个头文件只被编译一次。
// 作用和 #ifndef APP_CONTROLLER_H ... #endif 相同,写法更简洁。
#pragma once

// 包含 QObject 基类头文件。
// QObject 是所有 Qt 对象的基类,提供了信号/槽、属性系统、事件处理等核心功能。
#include <QObject>

// ============================================================================
// 2. 包含 Q_PROPERTY 中使用的指针类型头文件
// ============================================================================

// Qt 6.5+ 对 Q_PROPERTY 中使用的指针类型要求更严格:
// 被指向的类型在 moc 处理头文件时必须是完整的(不能只是前置声明)。
// 所以这里直接 #include 所有 Q_PROPERTY 中用到的类头文件。
// 注意:这些类在后面的 Q_PROPERTY 中被声明为指针类型。
#include "signalingservice.h"   // WebSocket 信令服务(内外机通信)
#include "callcontroller.h"     // 通话控制器(音视频通话)
#include "facecontroller.h"     // 人脸识别控制器
#include "doorlockservice.h"    // 门锁服务(开门/关门控制)
#include "personmodel.h"        // 人员数据模型(在 QML 列表中显示人员)
#include "eventmodel.h"         // 事件日志模型(在 QML 列表中显示日志)
#include "settingsservice.h"    // 配置服务(读取/保存系统设置)

// ============================================================================
// 3. 前置声明(告诉编译器这些类存在,但不需要包含完整头文件)
// ============================================================================

// 注意:这些类在头文件中只作为指针成员使用,不需要完整定义,所以用前置声明即可。
// 这样可以减少编译依赖,加快编译速度。
// 而上面的 #include 的类是因为在 Q_PROPERTY 中使用了完整类型,必须包含。
class DatabaseManager;    // 数据库管理器(负责创建和操作 SQLite 数据库)
class PersonRepo;         // 人员数据仓库(封装人员的增删改查)
class EventRepo;          // 事件日志仓库(封装事件的增删改查)
class FaceAuthService;    // 人脸认证服务(封装人脸识别逻辑)

// ============================================================================
// 4. AppController 类定义
// ============================================================================

// AppController 继承自 QObject,是程序的总控制器。
// 它负责创建和协调所有其他模块,是连接 C++ 业务逻辑和 QML 界面的桥梁。
class AppController : public QObject
{
    // Q_OBJECT 是 Qt 的宏,必须放在所有声明了信号/槽的类中。
    // 它告诉 Qt 的元对象编译器(moc)对这个类进行特殊处理,
    // 使该类支持信号/槽、属性系统、运行时类型信息等。
    Q_OBJECT

    // ========================================================================
    // 5. Q_PROPERTY 定义(暴露给 QML 的属性)
    // ========================================================================

    // Q_PROPERTY 宏定义了可以在 QML 中访问的属性。
    // 格式:Q_PROPERTY(类型 名称 READ 读方法 WRITE 写方法 NOTIFY 通知信号)
    // QML 中可以通过 "app.role" 来读取和修改这些属性。

    // 角色属性:标识当前是外机("outer")还是内机("inner")
    Q_PROPERTY(QString role READ role WRITE setRole NOTIFY roleChanged)

    // 数据库路径属性
    Q_PROPERTY(QString dbPath READ dbPath NOTIFY dbPathChanged)

    // 最后一个错误信息属性(用于在 QML 中显示错误)
    Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)

    // ----- 以下属性是"只读"的(CONSTANT 表示值在运行时不变) -----

    // 信令服务(WebSocket 通信)
    Q_PROPERTY(SignalingService* signaling READ signaling CONSTANT)

    // 通话控制器
    Q_PROPERTY(CallController* call READ call CONSTANT)

    // 人脸识别控制器
    Q_PROPERTY(FaceController* face READ face CONSTANT)

    // 门锁服务
    Q_PROPERTY(DoorLockService* doorLock READ doorLock CONSTANT)

    // 人员数据模型(用于 QML 列表显示)
    Q_PROPERTY(PersonModel* persons READ persons CONSTANT)

    // 事件日志模型(用于 QML 列表显示)
    Q_PROPERTY(EventModel* events READ events CONSTANT)

    // 配置服务(保存系统设置)
    Q_PROPERTY(SettingsService* settings READ settings CONSTANT)

// ============================================================================
// 6. 公有方法(public 部分)
// ============================================================================

public:
    // 构造函数,parent 参数用于 Qt 的对象树管理。
    // 当父对象被删除时,所有子对象会自动删除,防止内存泄漏。
    explicit AppController(QObject* parent = nullptr);

    // ---------- role 属性的 getter / setter ----------
    QString role() const { return m_role; }
    void setRole(const QString& role);

    // ---------- dbPath 的 getter ----------
    QString dbPath() const;

    // ---------- lastError 的 getter ----------
    QString lastError() const { return m_lastError; }

    // ---------- 各模块的 getter ----------
    // 这些方法返回各个子模块的指针,QML 中通过 app.signaling、app.call 等访问。
    SignalingService* signaling() const { return m_signaling; }
    CallController* call() const { return m_call; }
    FaceController* face() const { return m_faceController; }
    DoorLockService* doorLock() const { return m_doorLock; }
    PersonModel* persons() const { return m_personModel; }
    EventModel* events() const { return m_eventModel; }
    SettingsService* settings() const { return m_settings; }

    // Q_INVOKABLE 表示该方法可以从 QML 中调用。
    // 不需要返回值,直接写 void initIfNeeded();
    // 在 QML 中可以这样调用:app.initIfNeeded()
    Q_INVOKABLE void initIfNeeded();

// ============================================================================
// 7. 信号(signals)
// ============================================================================

// 信号用于通知 QML 属性发生了变化。
// 当属性值改变时,发射对应的信号,QML 中绑定的界面会自动更新。
signals:
    void roleChanged();        // role 变化时发射
    void dbPathChanged();      // dbPath 变化时发射
    void lastErrorChanged();   // lastError 变化时发射

// ============================================================================
// 8. 私有方法(private 部分)
// ============================================================================

private:
    // 设置最后一个错误信息(内部使用)
    void setLastError(const QString& e);

    // 根据角色打开对应的数据库
    void openDbForRole(const QString& role);

// ============================================================================
// 9. 私有成员变量
// ============================================================================

    QString m_role;              // 角色:空字符串、"outer"、"inner"
    QString m_lastError;         // 最后一个错误信息

    // ----- 数据库相关 -----
    DatabaseManager* m_dbMgr = nullptr;    // 数据库管理器
    PersonRepo* m_personRepo = nullptr;    // 人员数据仓库
    EventRepo* m_eventRepo = nullptr;      // 事件日志仓库

    // ----- 数据模型(用于 QML 列表) -----
    PersonModel* m_personModel = nullptr;  // 人员列表模型
    EventModel* m_eventModel = nullptr;    // 事件日志列表模型

    // ----- 业务逻辑服务 -----
    FaceAuthService* m_faceAuth = nullptr;     // 人脸认证服务
    FaceController* m_faceController = nullptr; // 人脸识别控制器
    DoorLockService* m_doorLock = nullptr;     // 门锁服务

    // ----- 通信服务 -----
    SignalingService* m_signaling = nullptr;   // WebSocket 信令服务
    CallController* m_call = nullptr;          // 通话控制器

    // ----- 初始化状态 -----
    bool m_initialized = false;    // 是否已初始化
};

关键设计模式

模式 体现
外观模式 AppController 封装了所有子模块的创建和调用,外界只需要和 AppController 交互
依赖注入 各子模块的指针通过 AppController 统一创建和管理
观察者模式 Qt 信号/槽,当数据变化时自动通知界面更新
工厂模式 各子模块在 initIfNeeded() 中被创建

appcontroller.cpp

无注释版

cpp 复制代码
#include "appcontroller.h"

#include "databasemanager.h"
#include "personrepo.h"
#include "eventrepo.h"
#include "settingsservice.h"
#include "personmodel.h"
#include "eventmodel.h"
#include "faceauthservice.h"
#include "facecontroller.h"
#include "doorlockservice.h"
#include "signalingservice.h"
#include "callcontroller.h"

#include <QStandardPaths>
#include <QDir>

AppController::AppController(QObject* parent)
    : QObject(parent)
{
    m_dbMgr = new DatabaseManager(this);
    m_personRepo = new PersonRepo(this);
    m_eventRepo = new EventRepo(this);
    m_settings = new SettingsService(this);

    m_personModel = new PersonModel(this);
    m_eventModel = new EventModel(this);

    m_faceAuth = new FaceAuthService(this);
    m_faceController = new FaceController(this);
    m_doorLock = new DoorLockService(this);

    m_signaling = new SignalingService(this);
    m_call = new CallController(this);

    // Wiring that does not depend on role
    m_call->setSignaling(m_signaling);
    m_call->setDoorLock(m_doorLock);

    // ✅ 让 DoorLockService 能读 settings(用于密码校验/持续时间等)
    m_doorLock->setSettings(m_settings);

    m_faceController->setFaceAuth(m_faceAuth);
    m_faceController->setDoorLock(m_doorLock);
    m_faceController->setVideo(m_call->video()); // use call's video sink & latest frame

    // Models
    m_personModel->setRepo(m_personRepo);
    m_eventModel->setRepo(m_eventRepo);

    // Repos to services
    m_doorLock->setEventRepo(m_eventRepo);
    m_call->setEventRepo(m_eventRepo);
    m_faceController->setEventRepo(m_eventRepo);
    m_faceController->setPersonRepo(m_personRepo);
    m_faceController->setPersonModel(m_personModel);
    m_faceController->setSettings(m_settings);
}

void AppController::initIfNeeded()
{
    if (m_initialized) return;

    if (m_role.isEmpty()) {
        // wait for role selection
        return;
    }

    openDbForRole(m_role);
    m_initialized = true;
}

void AppController::setRole(const QString& role)
{
    const QString r = role.trimmed().toLower();
    if (m_role == r) return;

    m_role = r;
    emit roleChanged();

    // Role may change at runtime for demo; reopen db
    m_initialized = false;
    initIfNeeded();

    if (!m_role.isEmpty()) {
        m_call->setRole(m_role);
        m_doorLock->setRole(m_role);
        m_faceController->setRole(m_role);
    }
}

QString AppController::dbPath() const
{
    return m_dbMgr ? m_dbMgr->dbPath() : QString();
}

void AppController::openDbForRole(const QString& role)
{
    Q_UNUSED(role);

    const QString baseDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
    if (baseDir.isEmpty()) {
        setLastError(QStringLiteral("Cannot resolve AppDataLocation"));
        return;
    }

    QDir().mkpath(baseDir);

    const QString fileName = QStringLiteral("intercom.db"); // ✅ 内外机共用一份
    const QString dbFile = baseDir + QLatin1Char('/') + fileName;

    if (!m_dbMgr->open(dbFile)) {
        setLastError(QStringLiteral("Open DB failed: %1").arg(dbFile));
        return;
    }

    QString err;
    if (!m_dbMgr->ensureSchema(&err)) {
        setLastError(QStringLiteral("Ensure schema failed: %1").arg(err));
        return;
    }

    // Provide db to repos/services
    m_personRepo->setDatabase(m_dbMgr->db());
    m_eventRepo->setDatabase(m_dbMgr->db());
    m_settings->setDatabase(m_dbMgr->db());

    // Defaults
    if (m_settings->getString("initialized", "") != "1") {

        m_settings->setDouble("face_threshold", 0.75);
        m_settings->setInt("unlock_duration_ms", 3000);
        m_settings->setInt("server_port", 12345);
        m_settings->setString("server_host", "10.11.100.207");

        m_settings->setString("unlock_password", "123456");  // ✅ 默认密码

        m_settings->setString("initialized", "1");
    }

    // ✅ 兼容老库:如果已初始化但没写过密码 key,则补写默认密码
    if (m_settings->getString("unlock_password", "").isEmpty()) {
        m_settings->setString("unlock_password", "123456");
    }

    m_faceController->loadDefaultsFromSettings();

    // Reload models
    m_personModel->reload();
    m_eventModel->reload();

    emit dbPathChanged();
    setLastError(QString());
}

void AppController::setLastError(const QString& e)
{
    if (m_lastError == e) return;
    m_lastError = e;
    emit lastErrorChanged();
}

有注释版

复制代码
// 包含 AppController 的头文件(类声明)
#include "appcontroller.h"

// 包含所有子模块的头文件
#include "databasemanager.h"   // 数据库管理器
#include "personrepo.h"        // 人员数据仓库
#include "eventrepo.h"         // 事件日志仓库
#include "settingsservice.h"   // 配置服务
#include "personmodel.h"       // 人员数据模型(QML 列表用)
#include "eventmodel.h"        // 事件日志模型(QML 列表用)
#include "faceauthservice.h"   // 人脸认证服务
#include "facecontroller.h"    // 人脸识别控制器
#include "doorlockservice.h"   // 门锁服务
#include "signalingservice.h"  // WebSocket 信令服务
#include "callcontroller.h"    // 通话控制器

// Qt 标准路径相关头文件
#include <QStandardPaths>      // 获取系统标准目录(如 AppData)
#include <QDir>                // 目录操作

AppController::AppController(QObject* parent)
    : QObject(parent)  // 调用基类构造函数,parent 用于对象树管理
{
    // ============ 1. 创建所有子模块(数据库层) ============
    
    // 数据库管理器:负责创建和操作 SQLite 数据库
    // 注意:这里只是创建对象,还没有真正打开数据库文件
    m_dbMgr = new DatabaseManager(this);
    
    // 人员数据仓库:封装人员的增删改查操作
    m_personRepo = new PersonRepo(this);
    
    // 事件日志仓库:封装事件的增删改查操作
    m_eventRepo = new EventRepo(this);
    
    // 配置服务:读取/保存系统设置(如服务器 IP、端口、密码等)
    m_settings = new SettingsService(this);

    // ============ 2. 创建数据模型(用于 QML 列表显示) ============
    
    // 人员列表模型:将 PersonRepo 的数据包装成 QML 可用的 ListModel
    m_personModel = new PersonModel(this);
    
    // 事件日志列表模型:将 EventRepo 的数据包装成 QML 可用的 ListModel
    m_eventModel = new EventModel(this);

    // ============ 3. 创建业务逻辑服务 ============
    
    // 人脸认证服务:封装人脸识别算法(特征提取、比对等)
    m_faceAuth = new FaceAuthService(this);
    
    // 人脸识别控制器:协调摄像头、人脸检测、认证、开门等流程
    m_faceController = new FaceController(this);
    
    // 门锁服务:控制门锁的开关状态
    m_doorLock = new DoorLockService(this);

    // ============ 4. 创建通信服务 ============
    
    // WebSocket 信令服务:内外机之间的实时通信(呼叫、接听等)
    m_signaling = new SignalingService(this);
    
    // 通话控制器:管理音视频通话的建立、接听、挂断
    m_call = new CallController(this);

    // ============ 5. 模块间依赖注入(连接各模块) ============

    // 将信令服务注入到通话控制器(通话需要信令来发送/接收消息)
    m_call->setSignaling(m_signaling);
    
    // 将门锁服务注入到通话控制器(通话中可以远程开门)
    m_call->setDoorLock(m_doorLock);

    // 将配置服务注入到门锁服务(门锁需要读取密码、开锁时长等配置)
    m_doorLock->setSettings(m_settings);

    // 将人脸认证服务注入到人脸控制器
    m_faceController->setFaceAuth(m_faceAuth);
    
    // 将门锁服务注入到人脸控制器(识别成功后自动开门)
    m_faceController->setDoorLock(m_doorLock);
    
    // 将视频流注入到人脸控制器(从通话控制器获取摄像头画面)
    // 注意:这里用的是 m_call->video(),说明视频流是通话控制器管理的
    m_faceController->setVideo(m_call->video());

    // ============ 6. 模型与仓库关联 ============
    
    // 人员模型绑定人员仓库(模型从仓库读取数据)
    m_personModel->setRepo(m_personRepo);
    
    // 事件模型绑定事件仓库
    m_eventModel->setRepo(m_eventRepo);

    // ============ 7. 仓库注入到各服务(用于记录日志) ============
    
    // 门锁服务需要事件仓库来记录开门事件
    m_doorLock->setEventRepo(m_eventRepo);
    
    // 通话控制器需要事件仓库来记录通话事件
    m_call->setEventRepo(m_eventRepo);
    
    // 人脸控制器需要事件仓库来记录识别事件
    m_faceController->setEventRepo(m_eventRepo);
    
    // 人脸控制器需要人员仓库来查询人员信息
    m_faceController->setPersonRepo(m_personRepo);
    
    // 人脸控制器需要人员模型来更新列表
    m_faceController->setPersonModel(m_personModel);
    
    // 人脸控制器需要配置服务来读取阈值等设置
    m_faceController->setSettings(m_settings);
}

void AppController::initIfNeeded()
{
    // 如果已经初始化过了,直接返回(防止重复初始化)
    if (m_initialized) return;

    // 如果角色还没设置(空字符串),等待用户选择
    // 这种情况下,程序会停留在角色选择页面
    if (m_role.isEmpty()) {
        return;
    }

    // 根据角色打开对应的数据库
    // 外机和内机共用同一个数据库文件(intercom.db)
    openDbForRole(m_role);
    
    // 标记为已初始化
    m_initialized = true;
}

void AppController::setRole(const QString& role)
{
    // 去除首尾空格并转为小写(统一格式)
    const QString r = role.trimmed().toLower();
    
    // 如果角色没有变化,直接返回
    if (m_role == r) return;

    // 更新角色并通知 QML
    m_role = r;
    emit roleChanged();  // QML 界面会收到通知并更新

    // 角色改变后,需要重新初始化数据库和各个模块
    m_initialized = false;
    initIfNeeded();  // 重新初始化

    // 如果角色不为空,将角色传递给各个子模块
    if (!m_role.isEmpty()) {
        m_call->setRole(m_role);           // 通话控制器知道自己是外机还是内机
        m_doorLock->setRole(m_role);       // 门锁服务知道自己的角色
        m_faceController->setRole(m_role); // 人脸控制器知道自己的角色
    }
}

void AppController::openDbForRole(const QString& role)
{
    Q_UNUSED(role);  // 当前未使用 role 参数,避免编译器警告

    // ============ 1. 确定数据库文件路径 ============
    
    // 获取应用程序数据目录(跨平台)
    // Windows: C:/Users/用户名/AppData/Local/DemoOrg/VillaIntercomDemo/
    // Linux:   ~/.config/DemoOrg/VillaIntercomDemo/
    const QString baseDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
    
    if (baseDir.isEmpty()) {
        setLastError(QStringLiteral("Cannot resolve AppDataLocation"));
        return;
    }

    // 创建目录(如果不存在)
    QDir().mkpath(baseDir);

    // 数据库文件名:intercom.db(内外机共用)
    const QString fileName = QStringLiteral("intercom.db");
    const QString dbFile = baseDir + QLatin1Char('/') + fileName;

    // ============ 2. 打开数据库 ============
    
    if (!m_dbMgr->open(dbFile)) {
        setLastError(QStringLiteral("Open DB failed: %1").arg(dbFile));
        return;
    }

    // ============ 3. 创建表结构(如果表不存在) ============
    
    QString err;
    if (!m_dbMgr->ensureSchema(&err)) {
        setLastError(QStringLiteral("Ensure schema failed: %1").arg(err));
        return;
    }

    // ============ 4. 将数据库连接注入到各模块 ============
    
    m_personRepo->setDatabase(m_dbMgr->db());   // 人员仓库使用数据库
    m_eventRepo->setDatabase(m_dbMgr->db());    // 事件仓库使用数据库
    m_settings->setDatabase(m_dbMgr->db());     // 配置服务使用数据库

    // ============ 5. 初始化默认配置(首次运行时) ============
    
    // 检查是否已经初始化过
    if (m_settings->getString("initialized", "") != "1") {
        // 首次运行,写入默认配置

        m_settings->setDouble("face_threshold", 0.75);    // 人脸识别阈值 75%
        m_settings->setInt("unlock_duration_ms", 3000);   // 开锁持续 3 秒
        m_settings->setInt("server_port", 12345);         // WebSocket 端口
        m_settings->setString("server_host", "10.11.100.207"); // 默认内机 IP

        m_settings->setString("unlock_password", "123456"); // 默认密码

        m_settings->setString("initialized", "1");   // 标记已初始化
    }

    // ============ 6. 兼容性处理 ============
    
    // 如果之前的老版本没有写密码 key,补写默认密码
    if (m_settings->getString("unlock_password", "").isEmpty()) {
        m_settings->setString("unlock_password", "123456");
    }

    // ============ 7. 加载配置到各模块 ============
    
    // 人脸控制器从配置中加载阈值等设置
    m_faceController->loadDefaultsFromSettings();

    // ============ 8. 刷新数据模型 ============
    
    // 从数据库重新加载人员列表和事件日志
    m_personModel->reload();
    m_eventModel->reload();

    // 通知 QML 数据库路径已变化
    emit dbPathChanged();
    
    // 清除最后一个错误(没有错误)
    setLastError(QString());
}


void AppController::setLastError(const QString& e)
{
    // 如果错误信息没有变化,直接返回
    if (m_lastError == e) return;
    
    // 更新错误信息
    m_lastError = e;
    
    // 通知 QML 错误信息已变化(界面可以显示错误提示)
    emit lastErrorChanged();
}

模块依赖关系图

cpp 复制代码
AppController (主控制器)
    │
    ├── 创建并关联所有子模块
    │
    ├── DatabaseManager (数据库)
    │       ↓
    │   ├── PersonRepo (人员)
    │   ├── EventRepo (事件)
    │   └── SettingsService (配置)
    │
    ├── PersonModel (QML 列表)
    │       ↓
    │   PersonRepo
    │
    ├── EventModel (QML 列表)
    │       ↓
    │   EventRepo
    │
    ├── FaceController (人脸识别)
    │       ↓
    │   ├── FaceAuthService (认证)
    │   ├── VideoStreamer (视频)
    │   ├── DoorLockService (开门)
    │   ├── PersonRepo (查人员)
    │   └── EventRepo (记录事件)
    │
    ├── DoorLockService (门锁)
    │       ↓
    │   ├── SettingsService (读配置)
    │   └── EventRepo (记录事件)
    │
    └── CallController (通话)
            ↓
        ├── SignalingService (WebSocket)
        ├── DoorLockService (远程开门)
        └── EventRepo (记录事件)

关键设计模式

模式 体现
依赖注入 通过 setXxx() 方法将依赖注入到各模块
单一职责 每个子模块只负责一个功能领域
观察者模式 信号/槽实现模块间通信
工厂模式 构造函数中统一创建所有子模块
策略模式 openDbForRole() 根据角色选择不同的策略

audiostreamer.h

无注释版

cpp 复制代码
#pragma once
#include <QObject>
#include <QAudioFormat>

class QAudioSource;
class QAudioSink;
class QIODevice;
class SignalingService;

class AudioStreamer : public QObject {
    Q_OBJECT
    Q_PROPERTY(bool streaming READ streaming WRITE setStreaming NOTIFY streamingChanged)
public:
    explicit AudioStreamer(QObject* parent=nullptr);

    void setSignaling(SignalingService* s) { m_signaling = s; }
    void setRole(const QString& r) { m_role = r; }
    void setSessionId(const QString& sid) { m_sessionId = sid; }

    bool streaming() const { return m_streaming; }
    Q_INVOKABLE void setStreaming(bool on);

    void handleIncomingPcm(const QByteArray& pcm);

signals:
    void streamingChanged();

private:
    void startIfNeeded();
    void stop();
    void sendChunk(const QByteArray& pcm);

private:
    SignalingService* m_signaling = nullptr;
    QString m_role;
    QString m_sessionId;

    bool m_streaming = false;

    QAudioFormat m_fmt;
    QAudioSource* m_src = nullptr;
    QIODevice* m_in = nullptr;

    QAudioSink* m_sink = nullptr;
    QIODevice* m_out = nullptr;

    QByteArray m_captureBuf;
};

有注释版

cpp 复制代码
// ============================================================================
// 1. 头文件保护和前置声明
// ============================================================================

// #pragma once 确保这个头文件只被编译一次(作用同 #ifndef ... #endif)
#pragma once

// 包含 QObject 基类头文件,提供信号/槽、属性系统等核心功能
#include <QObject>

// 包含 QAudioFormat 头文件,用于定义音频采样格式
// 包括:采样率、声道数、位深、编码格式等
#include <QAudioFormat>

// ============================================================================
// 2. 前置声明(避免包含完整头文件,加快编译速度)
// ============================================================================

// 前置声明 Qt 的音频类
// QAudioSource:用于从麦克风采集音频(输入)
// QAudioSink:   用于向扬声器播放音频(输出)
// QIODevice:    Qt 的 I/O 设备基类,用于读写音频数据
class QAudioSource;
class QAudioSink;
class QIODevice;

// 前置声明信令服务(用于通过 WebSocket 发送/接收音频数据)
class SignalingService;

// ============================================================================
// 3. AudioStreamer 类定义
// ============================================================================

class AudioStreamer : public QObject
{
    // Q_OBJECT 宏:启用信号/槽和属性系统
    Q_OBJECT

    // ========================================================================
    // 4. Q_PROPERTY 定义(暴露给 QML 的属性)
    // ========================================================================

    // streaming 属性:表示当前是否正在采集音频
    // - READ streaming():QML 中通过 app.audio.streaming 读取
    // - WRITE setStreaming():QML 中通过 app.audio.streaming = true 设置
    // - NOTIFY streamingChanged:当状态变化时,QML 界面自动更新
    Q_PROPERTY(bool streaming READ streaming WRITE setStreaming NOTIFY streamingChanged)

// ============================================================================
// 5. 公有方法(public 部分)
// ============================================================================

public:
    // 构造函数,parent 用于 Qt 的对象树管理
    // 当父对象被删除时,子对象会自动删除
    explicit AudioStreamer(QObject* parent = nullptr);

    // ---------- 依赖注入方法 ----------

    // 设置信令服务(用于发送/接收音频数据)
    // 音频采集后通过信令服务发送给对端
    // 对端发来的音频也通过信令服务接收
    void setSignaling(SignalingService* s) { m_signaling = s; }

    // 设置角色("outer" 外机 / "inner" 内机)
    // 不同角色在通话中的行为略有不同
    void setRole(const QString& r) { m_role = r; }

    // 设置会话 ID(标识当前通话会话)
    // 音频数据发送时带上会话 ID,确保只发给同一通话的对端
    void setSessionId(const QString& sid) { m_sessionId = sid; }

    // ---------- streaming 属性的 getter ----------

    // 返回当前是否正在采集音频
    bool streaming() const { return m_streaming; }

    // ---------- Q_INVOKABLE 方法(可从 QML 调用) ----------

    // 启动或停止音频采集
    // QML 中调用:app.audio.setStreaming(true) 启动采集
    Q_INVOKABLE void setStreaming(bool on);

    // ---------- 接收对端音频数据 ----------

    // 处理接收到的 PCM 音频数据(来自对端)
    // 对端通过 WebSocket 发送音频数据,信令服务收到后调用此方法
    // pcm 参数:原始 PCM 音频数据(未压缩)
    void handleIncomingPcm(const QByteArray& pcm);

// ============================================================================
// 6. 信号(signals)
// ============================================================================

signals:
    // streaming 属性变化时发射此信号
    // QML 中绑定 streaming 属性的界面会自动更新
    void streamingChanged();

// ============================================================================
// 7. 私有方法(private 部分)
// ============================================================================

private:
    // 启动音频采集(内部使用)
    // 如果已经启动则不做任何事
    void startIfNeeded();

    // 停止音频采集(内部使用)
    void stop();

    // 发送音频数据块到对端
    // pcm 参数:要发送的 PCM 音频数据
    void sendChunk(const QByteArray& pcm);

// ============================================================================
// 8. 私有成员变量
// ============================================================================

    // ---------- 依赖注入 ----------

    // 信令服务指针:用于发送/接收音频数据
    // 通过 setSignaling() 注入
    SignalingService* m_signaling = nullptr;

    // 角色:标识当前是外机还是内机
    QString m_role;

    // 会话 ID:标识当前通话
    QString m_sessionId;

    // ---------- 运行状态 ----------

    // 当前是否正在采集音频
    bool m_streaming = false;

    // ---------- 音频格式 ----------

    // 音频格式定义
    // 包括:采样率(如 16000Hz)、声道数(1 单声道)、位深(16bit)、编码格式(PCM)
    QAudioFormat m_fmt;

    // ---------- 音频采集(输入) ----------

    // 音频采集对象:从麦克风读取音频数据
    QAudioSource* m_src = nullptr;

    // 输入设备接口:用于从 m_src 读取音频数据
    // 读取到的数据会放入 m_captureBuf
    QIODevice* m_in = nullptr;

    // ---------- 音频播放(输出) ----------

    // 音频播放对象:向扬声器播放音频数据
    QAudioSink* m_sink = nullptr;

    // 输出设备接口:用于向 m_sink 写入音频数据
    // 来自对端的音频数据通过此接口播放
    QIODevice* m_out = nullptr;

    // ---------- 音频缓冲区 ----------

    // 音频采集缓冲区
    // 从麦克风读取的音频数据先暂存于此,达到一定大小后发送给对端
    QByteArray m_captureBuf;
};

音频数据流程

发送端(采集 → 发送)

cpp 复制代码
麦克风
    ↓
QAudioSource (m_src)
    ↓
QIODevice (m_in)
    ↓
m_captureBuf (累积音频数据)
    ↓
达到发送阈值
    ↓
sendChunk(pcm)  → 通过 WebSocket 发送给对端
    ↓
SignalingService

接收端(接收 → 播放)

cpp 复制代码
对端 WebSocket 数据
    ↓
SignalingService
    ↓
handleIncomingPcm(pcm)  ← 收到对端的音频数据
    ↓
QIODevice (m_out)
    ↓
QAudioSink (m_sink)
    ↓
扬声器播放

预期工作流程(内外机通话)

cpp 复制代码
1. 外机点击"门铃/呼叫"
   ↓
2. CallController 发起呼叫
   ↓
3. 内机接听
   ↓
4. 双方 AudioStreamer 开始采集
   ↓
5. 外机麦克风 → PCM 数据 → WebSocket → 内机扬声器
   内机麦克风 → PCM 数据 → WebSocket → 外机扬声器
   ↓
6. 任何一方挂断 → AudioStreamer 停止采集

关键接口说明

方法 调用时机 作用
setSignaling() 构造时 注入信令服务,用于收发音频数据
setRole() 角色切换时 设置当前设备是外机还是内机
setSessionId() 通话建立时 设置会话 ID,确保音频只发给对端
setStreaming(true) 通话接通后 启动麦克风采集
setStreaming(false) 通话挂断后 停止麦克风采集
handleIncomingPcm() 收到对端音频时 播放对端的音频数据

audiostreamer.cpp

原始版

无注释版

cpp 复制代码
#include "audiostreamer.h"
#include "signalingservice.h"

#include <QAudioSource>
#include <QAudioSink>
#include <QMediaDevices>
#include <QJsonObject>
#include <QDateTime>

AudioStreamer::AudioStreamer(QObject* parent): QObject(parent)
{
    m_fmt.setSampleRate(16000);
    m_fmt.setChannelCount(1);
    m_fmt.setSampleFormat(QAudioFormat::Int16);

    // 播放端
    m_sink = new QAudioSink(QMediaDevices::defaultAudioOutput(), m_fmt, this);
    m_out = m_sink->start(); // 直接写 PCM
}

void AudioStreamer::setStreaming(bool on)
{
    if (m_streaming == on) return;
    m_streaming = on;
    emit streamingChanged();
    if (m_streaming) startIfNeeded();
    else stop();
}

void AudioStreamer::startIfNeeded()
{
    if (!m_signaling) return;
    if (m_sessionId.isEmpty()) return;           // 没有会话不发
    if (!m_signaling->connected()) return;

    if (!m_src) {
        m_src = new QAudioSource(QMediaDevices::defaultAudioInput(), m_fmt, this);
        m_in = m_src->start();
        connect(m_in, &QIODevice::readyRead, this, [this](){
            if (!m_streaming) return;
            if (!m_signaling || !m_signaling->connected()) return;
            if (m_sessionId.isEmpty()) return;

            m_captureBuf.append(m_in->readAll());

            // 以 20ms 左右一个包:16000Hz * 2 bytes * 0.02s = 640 bytes
            const int chunk = 640;
            while (m_captureBuf.size() >= chunk) {
                QByteArray one = m_captureBuf.left(chunk);
                m_captureBuf.remove(0, chunk);
                sendChunk(one);
            }
        });
    }
}

void AudioStreamer::stop()
{
    if (m_src) {
        m_src->stop();
        m_in = nullptr;
        m_src->deleteLater();
        m_src = nullptr;
    }
    m_captureBuf.clear();
}

void AudioStreamer::sendChunk(const QByteArray& pcm)
{
    // base64 放 JSON
    QJsonObject payload;
    payload["pcmB64"] = QString::fromLatin1(pcm.toBase64());
    payload["sr"] = m_fmt.sampleRate();
    payload["ch"] = m_fmt.channelCount();
    payload["fmt"] = "s16";

    QJsonObject obj;
    obj["type"] = "AUDIO_PCM";
    obj["sessionId"] = m_sessionId;
    obj["ts"] = static_cast<qint64>(QDateTime::currentSecsSinceEpoch());
    obj["payload"] = payload;
    obj["fromRole"] = m_role;

    m_signaling->sendJson(obj);
}

void AudioStreamer::handleIncomingPcm(const QByteArray& pcm)
{
    if (!m_out) return;
    m_out->write(pcm);
}

有注释版

cpp 复制代码
// 包含头文件
#include "audiostreamer.h"

// 包含信令服务头文件(用于发送音频数据)
#include "signalingservice.h"

// Qt 多媒体模块头文件
#include <QAudioSource>    // 音频采集(麦克风输入)
#include <QAudioSink>      // 音频播放(扬声器输出)
#include <QMediaDevices>   // 获取系统默认的音频设备
#include <QJsonObject>     // 构建 JSON 消息(WebSocket 发送)
#include <QDateTime>       // 获取时间戳

// ============================================================================
// 构造函数
// ============================================================================

AudioStreamer::AudioStreamer(QObject* parent)
    : QObject(parent)  // 调用基类构造函数
{
    // ---- 设置音频格式 ----
    
    // 采样率:16000 Hz(电话音质,带宽和延迟的平衡)
    m_fmt.setSampleRate(16000);
    
    // 声道数:1(单声道,减少数据传输量)
    m_fmt.setChannelCount(1);
    
    // 采样格式:16 位有符号整数(标准 PCM 格式)
    m_fmt.setSampleFormat(QAudioFormat::Int16);

    // ---- 创建音频播放对象(扬声器) ----
    
    // 使用系统默认的音频输出设备
    // 第三个参数 this:QAudioSink 会在 this 被销毁时自动删除
    m_sink = new QAudioSink(QMediaDevices::defaultAudioOutput(), m_fmt, this);
    
    // ⚠️ 问题:这里立即启动播放,但此时没有音频数据
    // 建议改为:在收到第一段音频数据时才启动
    m_out = m_sink->start();  // 返回 QIODevice*,用于写入 PCM 数据
}

// ============================================================================
// 启动/停止音频采集(QML 可调用)
// ============================================================================

void AudioStreamer::setStreaming(bool on)
{
    // 如果状态没有变化,直接返回
    if (m_streaming == on) return;
    
    // 更新状态
    m_streaming = on;
    
    // 通知 QML 界面更新
    emit streamingChanged();
    
    // 根据状态启动或停止
    if (m_streaming) {
        startIfNeeded();  // 启动采集
    } else {
        stop();           // 停止采集
    }
}

// ============================================================================
// 启动音频采集(内部使用)
// ============================================================================

void AudioStreamer::startIfNeeded()
{
    // ---- 前置条件检查 ----
    
    // 信令服务未设置,无法发送数据
    if (!m_signaling) return;
    
    // 没有会话 ID,不知道发给谁
    if (m_sessionId.isEmpty()) return;
    
    // 信令服务未连接,无法发送数据
    if (!m_signaling->connected()) return;

    // ---- 如果音频采集对象还未创建,创建它 ----
    
    if (!m_src) {
        // 创建音频采集对象(麦克风输入)
        // 使用系统默认的音频输入设备
        m_src = new QAudioSource(QMediaDevices::defaultAudioInput(), m_fmt, this);
        
        // 开始采集,返回 QIODevice* 用于读取音频数据
        m_in = m_src->start();

        // ---- 连接 readyRead 信号:当有音频数据可读时触发 ----
        
        connect(m_in, &QIODevice::readyRead, this, [this](){
            // 如果已停止采集,忽略新数据
            if (!m_streaming) return;
            
            // 如果信令服务未连接,无法发送
            if (!m_signaling || !m_signaling->connected()) return;
            
            // 如果没有会话 ID,不知道发给谁
            if (m_sessionId.isEmpty()) return;

            // ---- 从音频设备读取所有可用数据 ----
            // readAll() 读取当前缓冲区中的所有数据
            m_captureBuf.append(m_in->readAll());

            // ---- 分包发送(每包 640 字节) ----
            // 计算:16000Hz * 2字节/采样 * 0.02秒 = 640 字节
            // 每 20ms 发送一包,实时性较好
            const int chunk = 640;
            
            // 当缓冲区中的数据达到一个包的大小时,发送出去
            while (m_captureBuf.size() >= chunk) {
                // 取出前 640 字节
                QByteArray one = m_captureBuf.left(chunk);
                
                // 从缓冲区中移除已取出的数据
                m_captureBuf.remove(0, chunk);
                
                // 发送这一包数据
                sendChunk(one);
            }
        });
    }
}

// ============================================================================
// 停止音频采集
// ============================================================================

void AudioStreamer::stop()
{
    // 如果采集对象存在
    if (m_src) {
        // 停止采集
        m_src->stop();
        
        // 清空输入设备指针(不直接删除,由 m_src 管理)
        m_in = nullptr;
        
        // 延迟删除采集对象(确保所有操作完成后再删除)
        m_src->deleteLater();
        m_src = nullptr;
    }
    
    // 清空音频缓冲区
    m_captureBuf.clear();
}

// ============================================================================
// 发送音频数据块
// ============================================================================

void AudioStreamer::sendChunk(const QByteArray& pcm)
{
    // ---- 构建 payload(音频数据部分) ----
    
    QJsonObject payload;
    // 将 PCM 数据转为 Base64 编码(JSON 不支持二进制数据)
    // fromLatin1:Base64 只包含 ASCII 字符,安全
    payload["pcmB64"] = QString::fromLatin1(pcm.toBase64());
    // 采样率(对端需要知道才能正确播放)
    payload["sr"] = m_fmt.sampleRate();
    // 声道数
    payload["ch"] = m_fmt.channelCount();
    // 编码格式:s16 表示 16 位有符号 PCM
    payload["fmt"] = "s16";

    // ---- 构建外层消息 ----
    
    QJsonObject obj;
    // 消息类型:AUDIO_PCM(音频数据)
    obj["type"] = "AUDIO_PCM";
    // 会话 ID:标识当前通话
    obj["sessionId"] = m_sessionId;
    // 时间戳:用于接收端缓冲/同步
    obj["ts"] = static_cast<qint64>(QDateTime::currentSecsSinceEpoch());
    // payload:音频数据
    obj["payload"] = payload;
    // 发送者角色:让对端知道是谁在说话
    obj["fromRole"] = m_role;

    // ---- 通过信令服务发送 JSON 消息 ----
    m_signaling->sendJson(obj);
}


// ============================================================================
// 处理接收到的 PCM 音频数据(由信令服务调用)
// ============================================================================

void AudioStreamer::handleIncomingPcm(const QByteArray& pcm)
{
    // ⚠️ 问题:如果 m_out 为空,直接返回,音频丢失
    // 建议:如果 m_out 为空,先启动播放
    if (!m_out) return;
    
    // 将 PCM 数据写入输出设备(扬声器)
    // QIODevice::write() 会立即播放
    m_out->write(pcm);
}

修复建议

改进 1:延迟启动播放

cpp 复制代码
// 在构造函数中不要立即启动
AudioStreamer::AudioStreamer(QObject* parent)
    : QObject(parent)
{
    // ... 设置音频格式 ...
    m_sink = new QAudioSink(QMediaDevices::defaultAudioOutput(), m_fmt, this);
    // 不调用 start(),等收到数据时再启动
}

改进 2:在 handleIncomingPcm 中延迟启动

cpp 复制代码
void AudioStreamer::handleIncomingPcm(const QByteArray& pcm)
{
    if (!m_sink) return;
    
    // 如果 m_out 为空,先启动播放
    if (!m_out) {
        m_out = m_sink->start();
        if (!m_out) return;
    }
    m_out->write(pcm);
}

修复后代码

cpp 复制代码
// ============================================================================
// 包含头文件
// ============================================================================

#include "audiostreamer.h"      // 音频流类声明
#include "signalingservice.h"   // 信令服务(用于收发音频数据)

#include <QAudioSource>        // 音频采集(麦克风输入)
#include <QAudioSink>          // 音频播放(扬声器输出)
#include <QMediaDevices>       // 获取系统默认音频设备
#include <QJsonObject>         // JSON 对象(构建 WebSocket 消息)
#include <QDateTime>           // 时间戳


// ============================================================================
// 构造函数
// ============================================================================

AudioStreamer::AudioStreamer(QObject* parent)
    : QObject(parent)
{
    // ---- 1. 设置音频格式 ----
    // 采样率:16000 Hz(电话音质,适合实时通话)
    m_fmt.setSampleRate(16000);
    // 声道数:1(单声道,减少数据传输量)
    m_fmt.setChannelCount(1);
    // 采样格式:16 位有符号整数(标准 PCM 格式)
    m_fmt.setSampleFormat(QAudioFormat::Int16);

    // ---- 2. 创建音频播放对象(扬声器) ----
    // 使用系统默认的音频输出设备
    // 第三个参数 this:当 AudioStreamer 被销毁时,m_sink 自动删除
    m_sink = new QAudioSink(QMediaDevices::defaultAudioOutput(), m_fmt, this);

    // ✅ 修复:不立即启动播放,而是等到收到第一段音频数据时才启动
    // 这样可以避免扬声器空转,节省 CPU 资源
    // m_out 保留为 nullptr,在 handleIncomingPcm 中延迟启动
}


// ============================================================================
// 启动/停止音频采集(QML 可调用)
// ============================================================================

void AudioStreamer::setStreaming(bool on)
{
    // 如果状态没有变化,直接返回
    if (m_streaming == on) return;

    // 更新状态
    m_streaming = on;

    // 通知 QML 界面更新
    emit streamingChanged();

    // 根据状态启动或停止
    if (m_streaming) {
        startIfNeeded();   // 启动麦克风采集
    } else {
        stop();            // 停止麦克风采集
    }
}


// ============================================================================
// 启动音频采集(内部使用)
// ============================================================================

void AudioStreamer::startIfNeeded()
{
    // ---- 1. 前置条件检查 ----
    // 信令服务未设置,无法发送数据
    if (!m_signaling) return;

    // 没有会话 ID,不知道数据发给谁
    if (m_sessionId.isEmpty()) return;

    // 信令服务未连接(WebSocket 未建立),无法发送数据
    if (!m_signaling->connected()) return;

    // ---- 2. 如果音频采集对象还未创建,创建并启动 ----
    if (!m_src) {
        // 创建音频采集对象(麦克风输入)
        // 使用系统默认的音频输入设备
        m_src = new QAudioSource(QMediaDevices::defaultAudioInput(), m_fmt, this);

        // 开始采集,返回 QIODevice* 用于读取音频数据
        // 如果设备打开失败,m_in 为 nullptr
        m_in = m_src->start();

        // 如果设备打开失败,清理并返回
        if (!m_in) {
            m_src->deleteLater();
            m_src = nullptr;
            return;
        }

        // ---- 3. 连接 readyRead 信号:当有音频数据可读时触发 ----
        QObject::connect(m_in, &QIODevice::readyRead, this, [this]() {
            // 如果已停止采集,忽略新数据
            if (!m_streaming) return;

            // 如果信令服务未连接,无法发送(忽略数据)
            if (!m_signaling || !m_signaling->connected()) return;

            // 如果没有会话 ID,不知道发给谁
            if (m_sessionId.isEmpty()) return;

            // ---- 4. 从音频设备读取所有可用数据 ----
            // readAll() 读取当前缓冲区中的所有数据
            // 注意:m_in 已在前面检查过非空,安全
            m_captureBuf.append(m_in->readAll());

            // ---- 5. 分包发送(每包 640 字节 = 20ms 音频) ----
            // 计算:16000Hz × 2字节/采样 × 0.02秒 = 640 字节
            // 每 20ms 发送一包,保证实时性,同时避免网络拥塞
            const int chunkSize = 640;

            // 当缓冲区中的数据达到一个包的大小时,循环发送
            while (m_captureBuf.size() >= chunkSize) {
                // 取出前 chunkSize 字节
                QByteArray one = m_captureBuf.left(chunkSize);

                // 从缓冲区中移除已取出的数据
                m_captureBuf.remove(0, chunkSize);

                // 发送这一包数据
                sendChunk(one);
            }
        });
    }
}


// ============================================================================
// 停止音频采集
// ============================================================================

void AudioStreamer::stop()
{
    // 如果采集对象存在
    if (m_src) {
        // 停止采集
        m_src->stop();

        // 清空输入设备指针(不直接删除,由 m_src 管理)
        m_in = nullptr;

        // 延迟删除采集对象(确保所有待处理事件完成后再删除)
        m_src->deleteLater();
        m_src = nullptr;
    }

    // 清空音频缓冲区(丢弃未发送的数据)
    m_captureBuf.clear();
}


// ============================================================================
// 发送音频数据块
// ============================================================================

void AudioStreamer::sendChunk(const QByteArray& pcm)
{
    // ✅ 添加安全检查:如果信令服务为空或未连接,不发送
    if (!m_signaling || !m_signaling->connected()) return;

    // ---- 1. 构建 payload(音频数据部分) ----
    QJsonObject payload;
    // 将 PCM 数据转为 Base64 编码(JSON 不支持二进制数据)
    // fromLatin1:Base64 只包含 ASCII 字符,安全
    payload["pcmB64"] = QString::fromLatin1(pcm.toBase64());
    // 采样率(对端需要知道才能正确播放)
    payload["sr"] = m_fmt.sampleRate();
    // 声道数(对端需要知道才能正确播放)
    payload["ch"] = m_fmt.channelCount();
    // 编码格式:s16 表示 16 位有符号 PCM
    payload["fmt"] = "s16";

    // ---- 2. 构建外层消息 ----
    QJsonObject obj;
    // 消息类型:AUDIO_PCM(音频数据)
    obj["type"] = "AUDIO_PCM";
    // 会话 ID:标识当前通话,只有同一个会话的对端会接收
    obj["sessionId"] = m_sessionId;
    // 时间戳:用于接收端缓冲/同步(可用于 jitter buffer)
    obj["ts"] = static_cast<qint64>(QDateTime::currentSecsSinceEpoch());
    // payload:音频数据
    obj["payload"] = payload;
    // 发送者角色:让对端知道是谁在说话
    obj["fromRole"] = m_role;

    // ---- 3. 通过信令服务发送 JSON 消息 ----
    m_signaling->sendJson(obj);
}


// ============================================================================
// 处理接收到的 PCM 音频数据(由信令服务调用)
// ============================================================================

void AudioStreamer::handleIncomingPcm(const QByteArray& pcm)
{
    // ---- 1. 检查播放设备是否可用 ----
    if (!m_sink) return;

    // ---- 2. ✅ 修复:如果 m_out 为空,延迟启动播放 ----
    // 这样避免了构造函数中过早启动造成的资源浪费
    if (!m_out) {
        // 启动音频播放,返回 QIODevice* 用于写入 PCM 数据
        m_out = m_sink->start();
        // 如果启动失败,直接返回
        if (!m_out) return;
    }

    // ---- 3. 播放音频数据 ----
    // 将 PCM 数据写入输出设备,扬声器立即播放
    // QIODevice::write() 会将数据放入音频缓冲区
    m_out->write(pcm);
}

修复点总结

问题 修复前 修复后
构造函数过早启动播放 m_sink->start() 在构造函数中调用 不启动,在 handleIncomingPcm 中延迟启动
handleIncomingPcm 中直接返回 if (!m_out) return; 丢弃音频 先启动播放再写入,确保音频不丢失
sendChunk 中无安全检查 直接调用 m_signaling->sendJson(obj) 增加 `if (!m_signaling
startIfNeededm_in 可能为空 未检查 m_in 是否有效 增加 if (!m_in) 检查和清理逻辑

音频数据流总结

cpp 复制代码
【发送端】
麦克风 → QAudioSource → QIODevice(readyRead) → m_captureBuf → 分包(640B) → sendChunk() → WebSocket → 对端

【接收端】
WebSocket → handleIncomingPcm() → 延迟启动 m_sink → m_out->write(pcm) → 扬声器

callcontroller.h

无注释版

cpp 复制代码
#pragma once

#include <QObject>
#include <QTimer>
#include <QJsonObject>

// Required for Q_PROPERTY(VideoStreamer*) to be a complete type when moc runs.
#include "videostreamer.h"
// ✅ 新增:音频
#include "audiostreamer.h"

class SignalingService;
class DoorLockService;
class EventRepo;

class CallController : public QObject
{
    Q_OBJECT
    Q_PROPERTY(QString role READ role WRITE setRole NOTIFY roleChanged)
    Q_PROPERTY(QString state READ state NOTIFY stateChanged)
    Q_PROPERTY(bool inCall READ inCall NOTIFY stateChanged)
    Q_PROPERTY(bool incoming READ incoming NOTIFY incomingChanged)
    Q_PROPERTY(QString incomingFrom READ incomingFrom NOTIFY incomingChanged)
    Q_PROPERTY(QString sessionId READ sessionId NOTIFY sessionIdChanged)
    Q_PROPERTY(int callSeconds READ callSeconds NOTIFY callSecondsChanged)
    Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
    Q_PROPERTY(VideoStreamer* video READ video CONSTANT)
    Q_PROPERTY(AudioStreamer* audio READ audio CONSTANT)

public:
    explicit CallController(QObject* parent = nullptr);

    void setSignaling(SignalingService* signaling);
    void setDoorLock(DoorLockService* doorLock);
    void setEventRepo(EventRepo* repo);

    QString role() const { return m_role; }
    void setRole(const QString& role);

    QString state() const { return m_state; }
    bool inCall() const { return m_state == "InCall"; }

    bool incoming() const { return m_incoming; }
    QString incomingFrom() const { return m_incomingFrom; }

    QString sessionId() const { return m_sessionId; }
    int callSeconds() const { return m_callSeconds; }
    QString lastError() const { return m_lastError; }

    VideoStreamer* video() const { return m_video; }
    AudioStreamer* audio() const { return m_audio; }

    // UI entry points
    Q_INVOKABLE void dial();
    Q_INVOKABLE void accept();
    Q_INVOKABLE void reject();
    Q_INVOKABLE void hangup();

    Q_INVOKABLE void grantUnlock(int durationMs);
    Q_INVOKABLE void denyUnlock();

    Q_INVOKABLE void setLocalVideoSink(QObject* sinkObject);

signals:
    void roleChanged();
    void stateChanged();
    void incomingChanged();
    void sessionIdChanged();
    void callSecondsChanged();
    void lastErrorChanged();

private slots:
    void onJsonReceived(const QJsonObject& obj);
    void onCallTick();
    void onTimeout();

private:
    void setState(const QString& s);
    void setIncoming(bool v, const QString& from);
    void setSessionId(const QString& id);
    void setLastError(const QString& e);

    void startInCall();
    void endCall(const QString& reason);

    void send(const QString& type, const QJsonObject& payload = QJsonObject());

    SignalingService* m_signaling = nullptr;
    DoorLockService* m_doorLock = nullptr;
    EventRepo* m_eventRepo = nullptr;

    VideoStreamer* m_video = nullptr;
    AudioStreamer* m_audio = nullptr;

    QString m_role = "unknown";
    QString m_state = "Idle";
    bool m_incoming = false;
    QString m_incomingFrom;
    QString m_sessionId;

    QTimer m_callTimer;
    QTimer m_timeoutTimer;
    int m_callSeconds = 0;

    QString m_lastError;
};

有注释版

cpp 复制代码
// ============================================================================
// 1. 头文件保护和前置声明
// ============================================================================

// #pragma once 确保此头文件只被编译一次
#pragma once

// 包含 QObject 基类头文件
#include <QObject>

// 包含 QTimer 头文件(用于通话计时和超时控制)
#include <QTimer>

// 包含 QJsonObject 头文件(用于构建/解析 WebSocket 消息)
#include <QJsonObject>

// ============================================================================
// 2. 包含 Q_PROPERTY 中使用的完整类型
// ============================================================================

// VideoStreamer 在 Q_PROPERTY 中作为指针类型使用
// 为了满足 Qt 6.5+ 的 Q_PROPERTY 类型完整性要求,必须包含完整头文件
#include "videostreamer.h"

// AudioStreamer 在 Q_PROPERTY 中作为指针类型使用
// ✅ 新增:支持音频通话功能
#include "audiostreamer.h"

// ============================================================================
// 3. 前置声明(减少编译依赖)
// ============================================================================

class SignalingService;    // WebSocket 信令服务(发送/接收消息)
class DoorLockService;     // 门锁服务(远程开门)
class EventRepo;           // 事件仓库(记录通话事件)

// ============================================================================
// 4. CallController 类定义
// ============================================================================

class CallController : public QObject
{
    // 启用信号/槽和属性系统
    Q_OBJECT

    // ========================================================================
    // 5. Q_PROPERTY 定义(暴露给 QML 的属性)
    // ========================================================================

    // ----- 角色属性 -----
    // 当前设备角色:外机(outer) / 内机(inner)
    Q_PROPERTY(QString role READ role WRITE setRole NOTIFY roleChanged)

    // ----- 通话状态 -----
    // 状态值:Idle(空闲)、Ringing(振铃)、InCall(通话中)、Ended(已结束)
    Q_PROPERTY(QString state READ state NOTIFY stateChanged)

    // 是否在通话中(state == "InCall")
    Q_PROPERTY(bool inCall READ inCall NOTIFY stateChanged)

    // ----- 来电信息 -----
    // 是否有来电
    Q_PROPERTY(bool incoming READ incoming NOTIFY incomingChanged)

    // 来电来自谁(外机的角色名或 IP)
    Q_PROPERTY(QString incomingFrom READ incomingFrom NOTIFY incomingChanged)

    // ----- 会话信息 -----
    // 当前通话的会话 ID(用于标识通话)
    Q_PROPERTY(QString sessionId READ sessionId NOTIFY sessionIdChanged)

    // 通话时长(秒)
    Q_PROPERTY(int callSeconds READ callSeconds NOTIFY callSecondsChanged)

    // ----- 错误信息 -----
    Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)

    // ----- 媒体流(只读) -----
    // 视频流对象(摄像头采集和渲染)
    Q_PROPERTY(VideoStreamer* video READ video CONSTANT)

    // 音频流对象(麦克风采集和扬声器播放)
    Q_PROPERTY(AudioStreamer* audio READ audio CONSTANT)

// ============================================================================
// 6. 公有方法
// ============================================================================

public:
    // 构造函数
    explicit CallController(QObject* parent = nullptr);

    // ---------- 依赖注入方法 ----------

    // 注入信令服务(用于收发 WebSocket 消息)
    void setSignaling(SignalingService* signaling);

    // 注入门锁服务(用于远程开门)
    void setDoorLock(DoorLockService* doorLock);

    // 注入事件仓库(用于记录通话日志)
    void setEventRepo(EventRepo* repo);

    // ---------- role 属性的 getter/setter ----------

    QString role() const { return m_role; }
    void setRole(const QString& role);

    // ---------- state 属性的 getter ----------

    QString state() const { return m_state; }

    // 是否在通话中
    bool inCall() const { return m_state == "InCall"; }

    // ---------- incoming 属性的 getter ----------

    bool incoming() const { return m_incoming; }
    QString incomingFrom() const { return m_incomingFrom; }

    // ---------- 会话信息的 getter ----------

    QString sessionId() const { return m_sessionId; }
    int callSeconds() const { return m_callSeconds; }
    QString lastError() const { return m_lastError; }

    // ---------- 媒体流的 getter ----------

    VideoStreamer* video() const { return m_video; }
    AudioStreamer* audio() const { return m_audio; }

    // ---------- Q_INVOKABLE 方法(QML 可调用) ----------

    // 拨号(外机呼叫内机)
    Q_INVOKABLE void dial();

    // 接听来电(内机接听外机呼叫)
    Q_INVOKABLE void accept();

    // 拒绝来电(内机拒绝外机呼叫)
    Q_INVOKABLE void reject();

    // 挂断电话(任何一方都可以挂断)
    Q_INVOKABLE void hangup();

    // ----- 远程开门(通话中) -----

    // 授权开门(内机在通话中点击"远程开锁")
    // durationMs:开门持续毫秒数,默认 3000ms(3秒)
    Q_INVOKABLE void grantUnlock(int durationMs);

    // 拒绝开门(内机在通话中点击"拒绝开锁")
    Q_INVOKABLE void denyUnlock();

    // ----- 设置本地视频显示 -----

    // 设置本地视频的显示目标(QML 中的 VideoOutput 控件)
    // sinkObject:QML 中 VideoOutput 的 videoSink 属性
    Q_INVOKABLE void setLocalVideoSink(QObject* sinkObject);

// ============================================================================
// 7. 信号(signals)
// ============================================================================

signals:
    void roleChanged();          // 角色变化
    void stateChanged();         // 通话状态变化
    void incomingChanged();      // 来电状态变化
    void sessionIdChanged();     // 会话 ID 变化
    void callSecondsChanged();   // 通话时长变化
    void lastErrorChanged();     // 错误信息变化

// ============================================================================
// 8. 私有槽函数(private slots)
// ============================================================================

private slots:
    // 收到 WebSocket JSON 消息时的处理函数
    void onJsonReceived(const QJsonObject& obj);

    // 通话计时器(每秒更新 callSeconds)
    void onCallTick();

    // 超时定时器(如呼叫超时、等待应答超时)
    void onTimeout();

// ============================================================================
// 9. 私有方法
// ============================================================================

private:
    // ---------- 状态更新方法 ----------

    // 设置通话状态(Idle / Ringing / InCall / Ended)
    void setState(const QString& s);

    // 设置来电信息(是否有来电,来自谁)
    void setIncoming(bool v, const QString& from);

    // 设置会话 ID
    void setSessionId(const QString& id);

    // 设置错误信息
    void setLastError(const QString& e);

    // ---------- 通话控制方法 ----------

    // 开始通话(接听后进入 InCall 状态)
    void startInCall();

    // 结束通话(挂断后回到 Idle 状态)
    void endCall(const QString& reason);

    // ---------- 消息发送方法 ----------

    // 发送 WebSocket 消息
    // type:消息类型(如 CALL_REQUEST、ACCEPT、HANGUP 等)
    // payload:消息的负载数据
    void send(const QString& type, const QJsonObject& payload = QJsonObject());

// ============================================================================
// 10. 私有成员变量
// ============================================================================

    // ---------- 依赖注入 ----------

    SignalingService* m_signaling = nullptr;  // 信令服务
    DoorLockService* m_doorLock = nullptr;    // 门锁服务
    EventRepo* m_eventRepo = nullptr;         // 事件仓库

    // ---------- 媒体流 ----------

    VideoStreamer* m_video = nullptr;          // 视频流
    AudioStreamer* m_audio = nullptr;          // 音频流

    // ---------- 状态 ----------

    QString m_role = "unknown";                // 角色:"outer" / "inner"
    QString m_state = "Idle";                  // 状态:Idle / Ringing / InCall / Ended
    bool m_incoming = false;                   // 是否有来电
    QString m_incomingFrom;                    // 来电来源
    QString m_sessionId;                       // 会话 ID

    // ---------- 定时器 ----------

    QTimer m_callTimer;                        // 通话计时器(每秒触发)
    QTimer m_timeoutTimer;                     // 超时定时器(呼叫等待超时)

    // ---------- 通话信息 ----------

    int m_callSeconds = 0;                     // 通话时长(秒)

    QString m_lastError;                       // 最后一个错误信息
};

通话状态机

cpp 复制代码
     ┌─────────────────────────────────────────────────────────────┐
     │                                                           │
     │  ┌───────┐    dial()    ┌──────────┐    accept()    ┌───────┐
     │  │ Idle  │ ──────────── │ Ringing  │ ────────────── │ InCall │
     │  │ 空闲   │             │  振铃中   │                │ 通话中 │
     │  └───────┘             └──────────┘                └───────┘
     │      ↑                       │                           │
     │      │                       │ reject() / timeout         │ hangup()
     │      └───────────────────────┴───────────────────────────┘
     │                                                           │
     └─────────────────────────────────────────────────────────────┘
状态 含义 可执行操作
Idle 空闲,没有通话 dial() 发起呼叫
Ringing 已拨号,等待对方接听 hangup() 取消呼叫
InCall 通话中 hangup() 挂断,grantUnlock() 远程开门
Ended 已结束(自动回到 Idle)

消息类型(WebSocket)

消息类型 发送者 说明
CALL_REQUEST 外机 → 内机 请求呼叫内机
CALL_ACCEPT 内机 → 外机 接听呼叫
CALL_REJECT 内机 → 外机 拒绝呼叫
CALL_HANGUP 任意一方 挂断通话
UNLOCK_GRANT 内机 → 外机 授权开门
UNLOCK_DENY 内机 → 外机 拒绝开门
AUDIO_PCM 任意一方 音频数据(由 AudioStreamer 处理)
VIDEO_FRAME 任意一方 视频数据(由 VideoStreamer 处理)

典型通话流程

cpp 复制代码
外机                                内机
  │                                   │
  │  1. dial()                        │
  │  ──── CALL_REQUEST ──────────────▶│
  │                                   │  2. 收到请求,incoming = true
  │                                   │  3. QML 显示来电弹窗
  │  4. 等待应答                       │
  │  ◀──── CALL_ACCEPT ───────────────│  5. 用户点击"接听"
  │                                   │
  │  6. 进入 InCall 状态              │  6. 进入 InCall 状态
  │  7. 音视频流开始传输               │  7. 音视频流开始传输
  │                                   │
  │  8. 用户点击"远程开锁"             │
  │  ◀──── UNLOCK_GRANT ──────────────│
  │  9. 门锁打开 3 秒                  │
  │                                   │
  │  10. 用户点击"挂断"                │
  │  ──── CALL_HANGUP ───────────────▶│
  │  11. 回到 Idle 状态               │  11. 回到 Idle 状态

callcontroller.cpp

原始版

无注释版

cpp 复制代码
#include "callcontroller.h"

#include "signalingservice.h"
#include "doorlockservice.h"
#include "eventrepo.h"
#include "videostreamer.h"
#include "audiostreamer.h"

#include <QDateTime>
#include <QJsonDocument>
#include <QUuid>

CallController::CallController(QObject* parent)
    : QObject(parent)
{
    m_video = new VideoStreamer(this);
    m_audio = new AudioStreamer(this);

    m_callTimer.setInterval(1000);
    connect(&m_callTimer, &QTimer::timeout, this, &CallController::onCallTick);

    m_timeoutTimer.setSingleShot(true);
    m_timeoutTimer.setInterval(12000);
    connect(&m_timeoutTimer, &QTimer::timeout, this, &CallController::onTimeout);
}

void CallController::setSignaling(SignalingService* signaling)
{
    if (m_signaling) {
        disconnect(m_signaling, nullptr, this, nullptr);
    }

    m_signaling = signaling;

    if (m_video) m_video->setSignaling(signaling);
    if (m_audio) m_audio->setSignaling(signaling);

    if (m_signaling) {
        connect(m_signaling, &SignalingService::jsonReceived,
                this, &CallController::onJsonReceived);
    }
}

void CallController::setDoorLock(DoorLockService* doorLock)
{
    m_doorLock = doorLock;
}

void CallController::setEventRepo(EventRepo* repo)
{
    m_eventRepo = repo;
}

void CallController::setRole(const QString& role)
{
    if (m_role == role) return;
    m_role = role;
    emit roleChanged();

    if (m_video) m_video->setRole(m_role);
    if (m_audio) m_audio->setRole(m_role);
}

void CallController::dial()
{
    if (m_role != "outer") {
        setLastError(QStringLiteral("Only outer can dial in this demo."));
        return;
    }
    if (m_state != "Idle") {
        setLastError(QStringLiteral("Not in Idle state."));
        return;
    }

    const QString sid = QUuid::createUuid().toString(QUuid::WithoutBraces);
    setSessionId(sid);
    setState("Dialing");

    QJsonObject payload;
    payload["from"] = "outer";
    payload["note"] = "ring";
    send("RING", payload);

    m_timeoutTimer.setInterval(12000);
    m_timeoutTimer.start();

    if (m_eventRepo) {
        m_eventRepo->addEvent("call", "ring_sent", "outer", "inner",
                              QStringLiteral("{\"sessionId\":\"%1\"}").arg(sid), nullptr);
    }
}

void CallController::accept()
{
    if (m_role != "inner") {
        setLastError(QStringLiteral("Only inner can accept in this demo."));
        return;
    }
    if (m_state != "Ringing") return;

    send("CALL_ACCEPT", QJsonObject());
    startInCall();

    if (m_eventRepo) {
        m_eventRepo->addEvent("call", "accepted", "inner", "outer",
                              QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
    }
}

void CallController::reject()
{
    if (m_role != "inner") return;
    if (m_state != "Ringing") return;

    send("CALL_REJECT", QJsonObject());
    endCall("rejected");

    if (m_eventRepo) {
        m_eventRepo->addEvent("call", "rejected", "inner", "outer",
                              QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
    }
}

void CallController::hangup()
{
    if (m_state == "Idle") return;

    send("CALL_END", QJsonObject());
    endCall("hangup");

    if (m_eventRepo) {
        m_eventRepo->addEvent("call", "ended", m_role, "peer",
                              QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
    }
}

void CallController::grantUnlock(int durationMs)
{
    if (m_role != "inner") return;
    if (m_state != "InCall") {
        setLastError(QStringLiteral("Unlock must be granted during InCall."));
        return;
    }

    QJsonObject payload;
    payload["durationMs"] = durationMs;
    payload["operator"] = "inner";
    send("UNLOCK_GRANTED", payload);

    if (m_eventRepo) {
        m_eventRepo->addEvent("unlock", "remote_granted", "inner", "outer",
                              QStringLiteral("{\"durationMs\":%1,\"sessionId\":\"%2\"}")
                                  .arg(durationMs).arg(m_sessionId), nullptr);
    }
}

void CallController::denyUnlock()
{
    if (m_role != "inner") return;
    if (m_state != "InCall") return;

    send("UNLOCK_DENIED", QJsonObject());

    if (m_eventRepo) {
        m_eventRepo->addEvent("unlock", "remote_denied", "inner", "outer",
                              QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
    }
}

void CallController::setLocalVideoSink(QObject* sinkObject)
{
    if (!m_video) return;
    m_video->setLocalVideoSink(sinkObject);
}

void CallController::onJsonReceived(const QJsonObject& obj)
{
    const QString type = obj.value("type").toString();
    const QString sid  = obj.value("sessionId").toString();

    // RING 不要求已有 sessionId(它会带来新 sessionId)
    if (type == "RING") {
        if (m_role != "inner") return;

        if (m_state != "Idle") {
            // Busy
            if (m_signaling) {
                QJsonObject payload;
                payload["reason"] = "busy";

                QJsonObject out;
                out["type"] = "BUSY";
                out["sessionId"] = obj.value("sessionId").toString();
                out["ts"] = static_cast<qint64>(QDateTime::currentSecsSinceEpoch());
                out["payload"] = payload;
                out["fromRole"] = m_role;

                m_signaling->sendJson(out);
            }
            return;
        }

        setSessionId(obj.value("sessionId").toString());
        setIncoming(true, obj.value("payload").toObject().value("from").toString());
        setState("Ringing");

        m_timeoutTimer.setInterval(15000);
        m_timeoutTimer.start();

        if (m_eventRepo) {
            m_eventRepo->addEvent("call", "ring_received", "inner", "outer",
                                  QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
        }
        return;
    }

    // ✅ 其余消息:必须匹配当前 session(避免视频/音频提前显示)
    if (!sid.isEmpty() && !m_sessionId.isEmpty() && sid != m_sessionId) {
        return;
    }

    // ✅ 视频帧:只在 InCall 且 sessionId 匹配时显示
    if (type == "VIDEO_FRAME") {
        if (m_state != "InCall") return;
        if (sid.isEmpty() || sid != m_sessionId) return;

        const QJsonObject payload = obj.value("payload").toObject();
        if (m_video) m_video->handleIncomingFrame(payload);
        return;
    }

    // ✅ 音频:只在 InCall 且 sessionId 匹配时播放
    if (type == "AUDIO_PCM") {
        if (m_state != "InCall") return;
        if (sid.isEmpty() || sid != m_sessionId) return;

        const QJsonObject payload = obj.value("payload").toObject();
        const QString b64 = payload.value("pcmB64").toString();
        if (b64.isEmpty()) return;

        const QByteArray pcm = QByteArray::fromBase64(b64.toLatin1());
        if (m_audio) m_audio->handleIncomingPcm(pcm);
        return;
    }

    if (type == "CALL_ACCEPT") {
        if (m_role != "outer") return;
        if (m_state == "Dialing") {
            startInCall();
            if (m_eventRepo) {
                m_eventRepo->addEvent("call", "accepted", "outer", "inner",
                                      QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
            }
        }
        return;
    }

    if (type == "CALL_REJECT") {
        if (m_role != "outer") return;
        if (m_state == "Dialing") {
            endCall("rejected");
            if (m_eventRepo) {
                m_eventRepo->addEvent("call", "rejected", "outer", "inner",
                                      QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
            }
        }
        return;
    }

    if (type == "CALL_END") {
        if (m_state != "Idle") {
            endCall("peer_end");
            if (m_eventRepo) {
                m_eventRepo->addEvent("call", "peer_end", m_role, "peer",
                                      QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
            }
        }
        return;
    }

    if (type == "BUSY") {
        if (m_role == "outer" && m_state == "Dialing") {
            endCall("busy");
            setLastError(QStringLiteral("Peer is busy."));
        }
        return;
    }

    if (type == "UNLOCK_GRANTED") {
        if (m_role != "outer") return;

        const QJsonObject payload = obj.value("payload").toObject();
        const int durationMs = payload.value("durationMs").toInt(3000);
        const QString op = payload.value("operator").toString("inner");

        if (m_doorLock) {
            m_doorLock->unlock(QStringLiteral("remote"), op, durationMs);
        }

        QJsonObject r;
        r["ok"] = true;
        r["durationMs"] = durationMs;
        send("UNLOCK_RESULT", r);

        if (m_eventRepo) {
            m_eventRepo->addEvent("unlock", "executed", "outer", "inner",
                                  QStringLiteral("{\"durationMs\":%1,\"sessionId\":\"%2\"}")
                                      .arg(durationMs).arg(m_sessionId), nullptr);
        }
        return;
    }

    if (type == "UNLOCK_DENIED") {
        if (m_role != "outer") return;
        if (m_eventRepo) {
            m_eventRepo->addEvent("unlock", "remote_denied", "outer", "inner",
                                  QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
        }
        setLastError(QStringLiteral("Inner denied unlock."));
        return;
    }

    if (type == "UNLOCK_RESULT") {
        if (m_role != "inner") return;
        if (m_eventRepo) {
            m_eventRepo->addEvent("unlock", "result_from_outer", "inner", "outer",
                                  QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact)), nullptr);
        }
        return;
    }
}

void CallController::onTimeout()
{
    if (m_state == "Dialing" && m_role == "outer") {
        QJsonObject payload;
        payload["reason"] = "timeout";
        send("CALL_END", payload);
        endCall("timeout");
        if (m_eventRepo) {
            m_eventRepo->addEvent("call", "timeout_no_answer", "outer", "inner",
                                  QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
        }
        setLastError(QStringLiteral("No answer (timeout)."));
        return;
    }

    if (m_state == "Ringing" && m_role == "inner") {
        QJsonObject payload;
        payload["reason"] = "timeout";
        send("CALL_REJECT", payload);
        endCall("timeout_reject");
        if (m_eventRepo) {
            m_eventRepo->addEvent("call", "timeout_auto_reject", "inner", "outer",
                                  QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
        }
        return;
    }
}

void CallController::onCallTick()
{
    if (m_state != "InCall") return;
    m_callSeconds += 1;
    emit callSecondsChanged();
}

void CallController::setState(const QString& s)
{
    if (m_state == s) return;
    m_state = s;
    emit stateChanged();
}

void CallController::setIncoming(bool v, const QString& from)
{
    m_incoming = v;
    m_incomingFrom = from;
    emit incomingChanged();
}

void CallController::setSessionId(const QString& id)
{
    if (m_sessionId == id) return;
    m_sessionId = id;
    emit sessionIdChanged();

    if (m_video) m_video->setSessionId(m_sessionId);
    if (m_audio) m_audio->setSessionId(m_sessionId);
}

void CallController::setLastError(const QString& e)
{
    if (m_lastError == e) return;
    m_lastError = e;
    emit lastErrorChanged();
}

void CallController::startInCall()
{
    setIncoming(false, QString());
    setState("InCall");
    m_callSeconds = 0;
    emit callSecondsChanged();
    m_callTimer.start();
    m_timeoutTimer.stop();

    if (m_video) {
        m_video->setRole(m_role);
        m_video->setSessionId(m_sessionId);
        m_video->setStreaming(true);
    }
    if (m_audio) {
        m_audio->setRole(m_role);
        m_audio->setSessionId(m_sessionId);
        m_audio->setStreaming(true);
    }
}

void CallController::endCall(const QString& reason)
{
    Q_UNUSED(reason);

    setIncoming(false, QString());
    setState("Idle");
    m_callTimer.stop();
    m_timeoutTimer.stop();

    if (m_audio) {
        m_audio->setStreaming(false);
    }
    if (m_video) {
        m_video->setStreaming(false);
        m_video->clearRemoteFrame();   // ✅ 挂断立刻隐藏对端画面
    }

    setSessionId(QString());
}

void CallController::send(const QString& type, const QJsonObject& payload)
{
    if (!m_signaling) return;

    QJsonObject obj;
    obj["type"] = type;
    obj["sessionId"] = m_sessionId.isEmpty() ? QString() : m_sessionId;
    obj["ts"] = static_cast<qint64>(QDateTime::currentSecsSinceEpoch());
    obj["payload"] = payload;
    obj["fromRole"] = m_role;

    m_signaling->sendJson(obj);
}

有注释版

cpp 复制代码
#include "callcontroller.h"

#include "signalingservice.h"   // WebSocket 信令服务
#include "doorlockservice.h"    // 门锁服务(远程开门)
#include "eventrepo.h"          // 事件仓库(记录通话日志)
#include "videostreamer.h"      // 视频流
#include "audiostreamer.h"      // 音频流

#include <QDateTime>            // 时间戳
#include <QJsonDocument>        // JSON 文档处理
#include <QUuid>                // 生成唯一会话 ID

// ============================================================================
// 构造函数
// ============================================================================

CallController::CallController(QObject* parent)
    : QObject(parent)
{
    // ---- 1. 创建视频和音频流对象 ----
    m_video = new VideoStreamer(this);
    m_audio = new AudioStreamer(this);

    // ---- 2. 设置通话计时器(每秒触发) ----
    m_callTimer.setInterval(1000);
    connect(&m_callTimer, &QTimer::timeout, this, &CallController::onCallTick);

    // ---- 3. 设置超时定时器(单次触发) ----
    // 用于呼叫超时(12秒)或等待接听超时(15秒)
    m_timeoutTimer.setSingleShot(true);
    m_timeoutTimer.setInterval(12000);
    connect(&m_timeoutTimer, &QTimer::timeout, this, &CallController::onTimeout);
}


// ============================================================================
// 设置信令服务
// ============================================================================

void CallController::setSignaling(SignalingService* signaling)
{
    // 如果已有信令服务,断开所有连接
    if (m_signaling) {
        disconnect(m_signaling, nullptr, this, nullptr);
    }

    m_signaling = signaling;

    // 将信令服务传递给视频和音频流(它们需要通过 WebSocket 收发数据)
    if (m_video) m_video->setSignaling(signaling);
    if (m_audio) m_audio->setSignaling(signaling);

    // 连接信令服务的 jsonReceived 信号
    if (m_signaling) {
        connect(m_signaling, &SignalingService::jsonReceived,
                this, &CallController::onJsonReceived);
    }
}

// ============================================================================
// 设置门锁服务和事件仓库
// ============================================================================

void CallController::setDoorLock(DoorLockService* doorLock)
{
    m_doorLock = doorLock;
}

void CallController::setEventRepo(EventRepo* repo)
{
    m_eventRepo = repo;
}


// ============================================================================
// 设置角色(外机/内机)
// ============================================================================

void CallController::setRole(const QString& role)
{
    if (m_role == role) return;
    m_role = role;
    emit roleChanged();  // 通知 QML

    // 将角色传递给视频和音频流
    if (m_video) m_video->setRole(m_role);
    if (m_audio) m_audio->setRole(m_role);
}

// ============================================================================
// 拨号(外机发起呼叫)
// ============================================================================

void CallController::dial()
{
    // ---- 1. 前置条件检查 ----
    // 只有外机可以发起呼叫
    if (m_role != "outer") {
        setLastError(QStringLiteral("Only outer can dial in this demo."));
        return;
    }
    // 必须在空闲状态
    if (m_state != "Idle") {
        setLastError(QStringLiteral("Not in Idle state."));
        return;
    }

    // ---- 2. 生成会话 ID ----
    // 使用 UUID 唯一标识本次通话
    const QString sid = QUuid::createUuid().toString(QUuid::WithoutBraces);
    setSessionId(sid);

    // ---- 3. 设置状态为"呼叫中" ----
    setState("Dialing");

    // ---- 4. 发送 RING 消息给内机 ----
    QJsonObject payload;
    payload["from"] = "outer";
    payload["note"] = "ring";
    send("RING", payload);

    // ---- 5. 启动超时定时器(12秒) ----
    // 如果内机 12 秒内没有应答,自动挂断
    m_timeoutTimer.setInterval(12000);
    m_timeoutTimer.start();

    // ---- 6. 记录事件 ----
    if (m_eventRepo) {
        m_eventRepo->addEvent("call", "ring_sent", "outer", "inner",
                              QStringLiteral("{\"sessionId\":\"%1\"}").arg(sid), nullptr);
    }
}

// ============================================================================
// 接听(内机接听呼叫)
// ============================================================================

void CallController::accept()
{
    // 只有内机可以接听
    if (m_role != "inner") {
        setLastError(QStringLiteral("Only inner can accept in this demo."));
        return;
    }
    // 必须在振铃状态
    if (m_state != "Ringing") return;

    // 发送接听确认
    send("CALL_ACCEPT", QJsonObject());

    // 开始通话
    startInCall();

    // 记录事件
    if (m_eventRepo) {
        m_eventRepo->addEvent("call", "accepted", "inner", "outer",
                              QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
    }
}

// ============================================================================
// 拒绝(内机拒绝呼叫)
// ============================================================================

void CallController::reject()
{
    if (m_role != "inner") return;
    if (m_state != "Ringing") return;

    send("CALL_REJECT", QJsonObject());
    endCall("rejected");

    if (m_eventRepo) {
        m_eventRepo->addEvent("call", "rejected", "inner", "outer",
                              QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
    }
}

// ============================================================================
// 挂断(任何一方都可以挂断)
// ============================================================================

void CallController::hangup()
{
    if (m_state == "Idle") return;

    send("CALL_END", QJsonObject());
    endCall("hangup");

    if (m_eventRepo) {
        m_eventRepo->addEvent("call", "ended", m_role, "peer",
                              QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
    }
}


// ============================================================================
// 授权开门(内机在通话中点击"远程开锁")
// ============================================================================

void CallController::grantUnlock(int durationMs)
{
    // 只有内机可以授权开门
    if (m_role != "inner") return;
    // 必须在通话状态
    if (m_state != "InCall") {
        setLastError(QStringLiteral("Unlock must be granted during InCall."));
        return;
    }

    // 发送开门授权消息
    QJsonObject payload;
    payload["durationMs"] = durationMs;
    payload["operator"] = "inner";
    send("UNLOCK_GRANTED", payload);

    if (m_eventRepo) {
        m_eventRepo->addEvent("unlock", "remote_granted", "inner", "outer",
                              QStringLiteral("{\"durationMs\":%1,\"sessionId\":\"%2\"}")
                                  .arg(durationMs).arg(m_sessionId), nullptr);
    }
}

// ============================================================================
// 拒绝开门(内机在通话中点击"拒绝开锁")
// ============================================================================

void CallController::denyUnlock()
{
    if (m_role != "inner") return;
    if (m_state != "InCall") return;

    send("UNLOCK_DENIED", QJsonObject());

    if (m_eventRepo) {
        m_eventRepo->addEvent("unlock", "remote_denied", "inner", "outer",
                              QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
    }
}

// ============================================================================
// 处理收到的 WebSocket JSON 消息
// ============================================================================

void CallController::onJsonReceived(const QJsonObject& obj)
{
    const QString type = obj.value("type").toString();
    const QString sid  = obj.value("sessionId").toString();

    // ---- 1. 处理 RING(呼叫请求) ----
    // RING 是第一个消息,不要求已有 sessionId
    if (type == "RING") {
        if (m_role != "inner") return;

        // 如果内机正在通话中,返回 BUSY
        if (m_state != "Idle") {
            if (m_signaling) {
                QJsonObject payload;
                payload["reason"] = "busy";

                QJsonObject out;
                out["type"] = "BUSY";
                out["sessionId"] = obj.value("sessionId").toString();
                out["ts"] = static_cast<qint64>(QDateTime::currentSecsSinceEpoch());
                out["payload"] = payload;
                out["fromRole"] = m_role;

                m_signaling->sendJson(out);
            }
            return;
        }

        // 设置会话 ID
        setSessionId(obj.value("sessionId").toString());
        // 设置来电信息
        setIncoming(true, obj.value("payload").toObject().value("from").toString());
        // 设置状态为振铃
        setState("Ringing");

        // 启动超时定时器(15秒后自动拒绝)
        m_timeoutTimer.setInterval(15000);
        m_timeoutTimer.start();

        if (m_eventRepo) {
            m_eventRepo->addEvent("call", "ring_received", "inner", "outer",
                                  QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
        }
        return;
    }

    // ---- 2. 会话 ID 验证 ----
    // 其余消息必须匹配当前 sessionId(防止收到其他通话的消息)
    if (!sid.isEmpty() && !m_sessionId.isEmpty() && sid != m_sessionId) {
        return;
    }

    // ---- 3. 处理视频帧 ----
    if (type == "VIDEO_FRAME") {
        if (m_state != "InCall") return;
        if (sid.isEmpty() || sid != m_sessionId) return;

        const QJsonObject payload = obj.value("payload").toObject();
        if (m_video) m_video->handleIncomingFrame(payload);
        return;
    }

    // ---- 4. 处理音频数据 ----
    if (type == "AUDIO_PCM") {
        if (m_state != "InCall") return;
        if (sid.isEmpty() || sid != m_sessionId) return;

        const QJsonObject payload = obj.value("payload").toObject();
        const QString b64 = payload.value("pcmB64").toString();
        if (b64.isEmpty()) return;

        const QByteArray pcm = QByteArray::fromBase64(b64.toLatin1());
        if (m_audio) m_audio->handleIncomingPcm(pcm);
        return;
    }

    // ---- 5. 处理其他消息类型 ----
    if (type == "CALL_ACCEPT") {
        // 外机收到内机的接听确认
        if (m_role != "outer") return;
        if (m_state == "Dialing") {
            startInCall();
            // ... 记录事件
        }
        return;
    }

    if (type == "CALL_REJECT") {
        // 外机收到内机的拒绝
        if (m_role != "outer") return;
        if (m_state == "Dialing") {
            endCall("rejected");
            // ... 记录事件
        }
        return;
    }

    if (type == "CALL_END") {
        // 收到对方的挂断消息
        if (m_state != "Idle") {
            endCall("peer_end");
            // ... 记录事件
        }
        return;
    }

    if (type == "BUSY") {
        // 对方忙
        if (m_role == "outer" && m_state == "Dialing") {
            endCall("busy");
            setLastError(QStringLiteral("Peer is busy."));
        }
        return;
    }

    if (type == "UNLOCK_GRANTED") {
        // 外机收到内机的开门授权
        if (m_role != "outer") return;

        const QJsonObject payload = obj.value("payload").toObject();
        const int durationMs = payload.value("durationMs").toInt(3000);
        const QString op = payload.value("operator").toString("inner");

        // 执行开门
        if (m_doorLock) {
            m_doorLock->unlock(QStringLiteral("remote"), op, durationMs);
        }

        // 确认开门结果
        QJsonObject r;
        r["ok"] = true;
        r["durationMs"] = durationMs;
        send("UNLOCK_RESULT", r);

        // ... 记录事件
        return;
    }

    if (type == "UNLOCK_DENIED") {
        // 外机收到内机的拒绝开门
        if (m_role != "outer") return;
        setLastError(QStringLiteral("Inner denied unlock."));
        return;
    }

    if (type == "UNLOCK_RESULT") {
        // 内机收到外机的开门结果确认
        if (m_role != "inner") return;
        // ... 记录事件
        return;
    }
}

// ============================================================================
// 开始通话
// ============================================================================

void CallController::startInCall()
{
    setIncoming(false, QString());
    setState("InCall");
    m_callSeconds = 0;
    emit callSecondsChanged();
    m_callTimer.start();
    m_timeoutTimer.stop();

    // 启动视频流
    if (m_video) {
        m_video->setRole(m_role);
        m_video->setSessionId(m_sessionId);
        m_video->setStreaming(true);
    }

    // 启动音频流
    if (m_audio) {
        m_audio->setRole(m_role);
        m_audio->setSessionId(m_sessionId);
        m_audio->setStreaming(true);
    }
}

// ============================================================================
// 结束通话
// ============================================================================

void CallController::endCall(const QString& reason)
{
    Q_UNUSED(reason);

    setIncoming(false, QString());
    setState("Idle");
    m_callTimer.stop();
    m_timeoutTimer.stop();

    // 停止音频流
    if (m_audio) {
        m_audio->setStreaming(false);
    }

    // 停止视频流并清空远程画面
    if (m_video) {
        m_video->setStreaming(false);
        m_video->clearRemoteFrame();
    }

    // 清空会话 ID
    setSessionId(QString());
}

通话状态流转图

cpp 复制代码
【外机视角】
Idle → dial() → Dialing → 收到 CALL_ACCEPT → InCall → hangup() → Idle
                            ↓
                        收到 CALL_REJECT / 超时 → Idle

【内机视角】
Idle → 收到 RING → Ringing → accept() → InCall → hangup() → Idle
                              ↓
                           reject() / 超时 → Idle

消息类型总结

消息 发送者 接收者 触发条件
RING 外机 内机 外机点击"门铃/呼叫"
CALL_ACCEPT 内机 外机 内机点击"接听"
CALL_REJECT 内机 外机 内机点击"拒绝"
CALL_END 任意 对端 点击"挂断"
BUSY 内机 外机 内机正在通话中
UNLOCK_GRANTED 内机 外机 内机点击"远程开锁"
UNLOCK_DENIED 内机 外机 内机点击"拒绝开锁"
UNLOCK_RESULT 外机 内机 开门执行完毕
VIDEO_FRAME 任意 对端 视频流传输中
AUDIO_PCM 任意 对端 音频流传输中

修复后代码

cpp 复制代码
// ============================================================================
// 包含头文件
// ============================================================================

#include "callcontroller.h"

#include "signalingservice.h"   // WebSocket 信令服务
#include "doorlockservice.h"    // 门锁服务(远程开门)
#include "eventrepo.h"          // 事件仓库(记录通话日志)
#include "videostreamer.h"      // 视频流
#include "audiostreamer.h"      // 音频流

#include <QDateTime>            // 时间戳
#include <QJsonDocument>        // JSON 文档处理
#include <QUuid>                // 生成唯一会话 ID
#include <QMediaDevices>        // 检查摄像头设备


// ============================================================================
// 构造函数
// ============================================================================

CallController::CallController(QObject* parent)
    : QObject(parent)
{
    // 1. 创建视频和音频流对象
    m_video = new VideoStreamer(this);
    m_audio = new AudioStreamer(this);

    // 2. 设置通话计时器(每秒触发,更新通话时长)
    m_callTimer.setInterval(1000);
    connect(&m_callTimer, &QTimer::timeout, this, &CallController::onCallTick);

    // 3. 设置超时定时器(单次触发)
    // 用于:外机呼叫超时(12秒无应答)或内机等待接听超时(15秒自动拒绝)
    m_timeoutTimer.setSingleShot(true);
    m_timeoutTimer.setInterval(12000);
    connect(&m_timeoutTimer, &QTimer::timeout, this, &CallController::onTimeout);
}


// ============================================================================
// 依赖注入方法
// ============================================================================

void CallController::setSignaling(SignalingService* signaling)
{
    // 如果已有信令服务,断开所有连接
    if (m_signaling) {
        disconnect(m_signaling, nullptr, this, nullptr);
    }

    m_signaling = signaling;

    // 将信令服务传递给视频和音频流
    if (m_video) m_video->setSignaling(signaling);
    if (m_audio) m_audio->setSignaling(signaling);

    // 连接信令服务的 jsonReceived 信号
    if (m_signaling) {
        connect(m_signaling, &SignalingService::jsonReceived,
                this, &CallController::onJsonReceived);
    }
}

void CallController::setDoorLock(DoorLockService* doorLock)
{
    m_doorLock = doorLock;
}

void CallController::setEventRepo(EventRepo* repo)
{
    m_eventRepo = repo;
}


// ============================================================================
// 角色设置
// ============================================================================

void CallController::setRole(const QString& role)
{
    if (m_role == role) return;
    m_role = role;
    emit roleChanged();

    // 将角色传递给视频和音频流
    if (m_video) m_video->setRole(m_role);
    if (m_audio) m_audio->setRole(m_role);
}


// ============================================================================
// 拨号(外机发起呼叫)
// ============================================================================

void CallController::dial()
{
    // ---- 前置条件检查 ----
    // 只有外机可以发起呼叫
    if (m_role != "outer") {
        setLastError(QStringLiteral("Only outer can dial in this demo."));
        return;
    }
    // 必须在空闲状态
    if (m_state != "Idle") {
        setLastError(QStringLiteral("Not in Idle state."));
        return;
    }

    // ---- 生成唯一会话 ID ----
    const QString sid = QUuid::createUuid().toString(QUuid::WithoutBraces);
    setSessionId(sid);

    // ---- 设置状态为"呼叫中" ----
    // 注意:头文件中 Q_PROPERTY 的状态值是 Idle / Ringing / InCall / Ended
    // Dialing 作为内部状态,QML 中需额外处理
    setState("Dialing");

    // ---- 发送 RING 消息给内机 ----
    QJsonObject payload;
    payload["from"] = "outer";
    payload["note"] = "ring";
    send("RING", payload);

    // ---- 启动超时定时器(12秒无应答自动挂断) ----
    m_timeoutTimer.setInterval(12000);
    m_timeoutTimer.start();

    // ---- 记录事件 ----
    if (m_eventRepo) {
        m_eventRepo->addEvent("call", "ring_sent", "outer", "inner",
                              QStringLiteral("{\"sessionId\":\"%1\"}").arg(sid), nullptr);
    }
}


// ============================================================================
// 接听(内机接听外机呼叫)
// ============================================================================

void CallController::accept()
{
    // 只有内机可以接听
    if (m_role != "inner") {
        setLastError(QStringLiteral("Only inner can accept in this demo."));
        return;
    }
    // 必须在振铃状态
    if (m_state != "Ringing") return;

    // 发送接听确认
    send("CALL_ACCEPT", QJsonObject());

    // 开始通话
    startInCall();

    // 记录事件
    if (m_eventRepo) {
        m_eventRepo->addEvent("call", "accepted", "inner", "outer",
                              QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
    }
}


// ============================================================================
// 拒绝(内机拒绝外机呼叫)
// ============================================================================

void CallController::reject()
{
    if (m_role != "inner") return;
    if (m_state != "Ringing") return;

    send("CALL_REJECT", QJsonObject());
    endCall("rejected");

    if (m_eventRepo) {
        m_eventRepo->addEvent("call", "rejected", "inner", "outer",
                              QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
    }
}


// ============================================================================
// 挂断(任何一方都可以挂断)
// ============================================================================

void CallController::hangup()
{
    if (m_state == "Idle") return;

    send("CALL_END", QJsonObject());
    endCall("hangup");

    if (m_eventRepo) {
        m_eventRepo->addEvent("call", "ended", m_role, "peer",
                              QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
    }
}


// ============================================================================
// 远程开门控制(内机授权)
// ============================================================================

void CallController::grantUnlock(int durationMs)
{
    // 只有内机可以授权开门
    if (m_role != "inner") return;
    // 必须在通话状态
    if (m_state != "InCall") {
        setLastError(QStringLiteral("Unlock must be granted during InCall."));
        return;
    }

    // 发送开门授权消息
    QJsonObject payload;
    payload["durationMs"] = durationMs;
    payload["operator"] = "inner";
    send("UNLOCK_GRANTED", payload);

    if (m_eventRepo) {
        m_eventRepo->addEvent("unlock", "remote_granted", "inner", "outer",
                              QStringLiteral("{\"durationMs\":%1,\"sessionId\":\"%2\"}")
                                  .arg(durationMs).arg(m_sessionId), nullptr);
    }
}

void CallController::denyUnlock()
{
    if (m_role != "inner") return;
    if (m_state != "InCall") return;

    send("UNLOCK_DENIED", QJsonObject());

    if (m_eventRepo) {
        m_eventRepo->addEvent("unlock", "remote_denied", "inner", "outer",
                              QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
    }
}


// ============================================================================
// 设置本地视频显示
// ============================================================================

void CallController::setLocalVideoSink(QObject* sinkObject)
{
    if (!m_video) return;
    m_video->setLocalVideoSink(sinkObject);
}


// ============================================================================
// 核心:处理收到的 WebSocket JSON 消息
// ============================================================================

void CallController::onJsonReceived(const QJsonObject& obj)
{
    const QString type = obj.value("type").toString();
    const QString sid  = obj.value("sessionId").toString();

    // ========================================================================
    // 1. 处理 RING(外机呼叫请求)
    // ========================================================================
    if (type == "RING") {
        if (m_role != "inner") return;

        // 如果内机正在通话中,返回 BUSY
        if (m_state != "Idle") {
            if (m_signaling) {
                QJsonObject payload;
                payload["reason"] = "busy";

                QJsonObject out;
                out["type"] = "BUSY";
                out["sessionId"] = obj.value("sessionId").toString();
                out["ts"] = static_cast<qint64>(QDateTime::currentSecsSinceEpoch());
                out["payload"] = payload;
                out["fromRole"] = m_role;

                m_signaling->sendJson(out);
            }
            return;
        }

        // 设置会话 ID
        setSessionId(obj.value("sessionId").toString());
        // 设置来电信息
        setIncoming(true, obj.value("payload").toObject().value("from").toString());
        // 设置状态为振铃
        setState("Ringing");

        // 启动超时定时器(15秒后自动拒绝)
        m_timeoutTimer.setInterval(15000);
        m_timeoutTimer.start();

        if (m_eventRepo) {
            m_eventRepo->addEvent("call", "ring_received", "inner", "outer",
                                  QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
        }
        return;
    }

    // ========================================================================
    // 2. 会话 ID 验证
    // ========================================================================
    // 其余消息必须匹配当前 sessionId(防止收到其他通话的消息)
    if (!sid.isEmpty() && !m_sessionId.isEmpty() && sid != m_sessionId) {
        return;
    }

    // ========================================================================
    // 3. 处理视频帧
    // ========================================================================
    if (type == "VIDEO_FRAME") {
        if (m_state != "InCall") return;
        if (sid.isEmpty() || sid != m_sessionId) return;

        const QJsonObject payload = obj.value("payload").toObject();
        if (m_video) m_video->handleIncomingFrame(payload);
        return;
    }

    // ========================================================================
    // 4. 处理音频数据
    // ========================================================================
    if (type == "AUDIO_PCM") {
        if (m_state != "InCall") return;
        if (sid.isEmpty() || sid != m_sessionId) return;

        const QJsonObject payload = obj.value("payload").toObject();
        const QString b64 = payload.value("pcmB64").toString();
        if (b64.isEmpty()) return;

        const QByteArray pcm = QByteArray::fromBase64(b64.toLatin1());
        if (m_audio) m_audio->handleIncomingPcm(pcm);
        return;
    }

    // ========================================================================
    // 5. 通话控制消息
    // ========================================================================

    // ---- 外机收到内机的接听确认 ----
    if (type == "CALL_ACCEPT") {
        if (m_role != "outer") return;
        if (m_state == "Dialing") {
            startInCall();
            if (m_eventRepo) {
                m_eventRepo->addEvent("call", "accepted", "outer", "inner",
                                      QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
            }
        }
        return;
    }

    // ---- 外机收到内机的拒绝 ----
    if (type == "CALL_REJECT") {
        if (m_role != "outer") return;
        if (m_state == "Dialing") {
            endCall("rejected");
            if (m_eventRepo) {
                m_eventRepo->addEvent("call", "rejected", "outer", "inner",
                                      QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
            }
        }
        return;
    }

    // ---- 收到对方的挂断 ----
    if (type == "CALL_END") {
        if (m_state != "Idle") {
            endCall("peer_end");
            if (m_eventRepo) {
                m_eventRepo->addEvent("call", "peer_end", m_role, "peer",
                                      QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
            }
        }
        return;
    }

    // ---- 对方忙 ----
    if (type == "BUSY") {
        if (m_role == "outer" && m_state == "Dialing") {
            endCall("busy");
            setLastError(QStringLiteral("Peer is busy."));
        }
        return;
    }

    // ========================================================================
    // 6. 远程开门消息
    // ========================================================================

    // ---- 外机收到内机的开门授权 ----
    if (type == "UNLOCK_GRANTED") {
        if (m_role != "outer") return;

        const QJsonObject payload = obj.value("payload").toObject();
        const int durationMs = payload.value("durationMs").toInt(3000);
        const QString op = payload.value("operator").toString("inner");

        // 执行开门
        if (m_doorLock) {
            m_doorLock->unlock(QStringLiteral("remote"), op, durationMs);
        }

        // 发送开门结果确认
        QJsonObject r;
        r["ok"] = true;
        r["durationMs"] = durationMs;
        send("UNLOCK_RESULT", r);

        if (m_eventRepo) {
            m_eventRepo->addEvent("unlock", "executed", "outer", "inner",
                                  QStringLiteral("{\"durationMs\":%1,\"sessionId\":\"%2\"}")
                                      .arg(durationMs).arg(m_sessionId), nullptr);
        }
        return;
    }

    // ---- 外机收到内机的拒绝开门 ----
    if (type == "UNLOCK_DENIED") {
        if (m_role != "outer") return;
        if (m_eventRepo) {
            m_eventRepo->addEvent("unlock", "remote_denied", "outer", "inner",
                                  QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
        }
        setLastError(QStringLiteral("Inner denied unlock."));
        return;
    }

    // ---- 内机收到外机的开门结果 ----
    if (type == "UNLOCK_RESULT") {
        if (m_role != "inner") return;
        if (m_eventRepo) {
            m_eventRepo->addEvent("unlock", "result_from_outer", "inner", "outer",
                                  QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact)), nullptr);
        }
        return;
    }
}


// ============================================================================
// 超时处理
// ============================================================================

void CallController::onTimeout()
{
    // ---- 外机呼叫超时(12秒无人接听) ----
    if (m_state == "Dialing" && m_role == "outer") {
        QJsonObject payload;
        payload["reason"] = "timeout";
        send("CALL_END", payload);
        endCall("timeout");
        if (m_eventRepo) {
            m_eventRepo->addEvent("call", "timeout_no_answer", "outer", "inner",
                                  QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
        }
        setLastError(QStringLiteral("No answer (timeout)."));
        return;
    }

    // ---- 内机等待接听超时(15秒自动拒绝) ----
    if (m_state == "Ringing" && m_role == "inner") {
        QJsonObject payload;
        payload["reason"] = "timeout";
        send("CALL_REJECT", payload);
        endCall("timeout_reject");
        if (m_eventRepo) {
            m_eventRepo->addEvent("call", "timeout_auto_reject", "inner", "outer",
                                  QStringLiteral("{\"sessionId\":\"%1\"}").arg(m_sessionId), nullptr);
        }
        return;
    }
}


// ============================================================================
// 通话计时
// ============================================================================

void CallController::onCallTick()
{
    if (m_state != "InCall") return;
    m_callSeconds += 1;
    emit callSecondsChanged();
}


// ============================================================================
// 状态管理
// ============================================================================

void CallController::setState(const QString& s)
{
    if (m_state == s) return;
    m_state = s;
    emit stateChanged();
}

void CallController::setIncoming(bool v, const QString& from)
{
    m_incoming = v;
    m_incomingFrom = from;
    emit incomingChanged();
}

void CallController::setSessionId(const QString& id)
{
    if (m_sessionId == id) return;
    m_sessionId = id;
    emit sessionIdChanged();

    if (m_video) m_video->setSessionId(m_sessionId);
    if (m_audio) m_audio->setSessionId(m_sessionId);
}

void CallController::setLastError(const QString& e)
{
    if (m_lastError == e) return;
    m_lastError = e;
    emit lastErrorChanged();
}


// ============================================================================
// 通话生命周期管理
// ============================================================================

void CallController::startInCall()
{
    setIncoming(false, QString());
    setState("InCall");
    m_callSeconds = 0;
    emit callSecondsChanged();
    m_callTimer.start();
    m_timeoutTimer.stop();

    // ---- 启动视频流 ----
    if (m_video) {
        m_video->setRole(m_role);
        m_video->setSessionId(m_sessionId);

        // ✅ 修复:检查是否有摄像头设备,避免崩溃
        const auto devices = QMediaDevices::videoInputs();
        if (!devices.isEmpty()) {
            m_video->setStreaming(true);
        } else {
            setLastError(QStringLiteral("No camera device available. Video disabled."));
            // 继续,不阻止通话
        }
    }

    // ---- 启动音频流 ----
    if (m_audio) {
        m_audio->setRole(m_role);
        m_audio->setSessionId(m_sessionId);
        m_audio->setStreaming(true);
    }
}

void CallController::endCall(const QString& reason)
{
    Q_UNUSED(reason);

    setIncoming(false, QString());
    setState("Idle");
    m_callTimer.stop();
    m_timeoutTimer.stop();

    // ---- 停止音频流 ----
    if (m_audio) {
        m_audio->setStreaming(false);
    }

    // ---- 停止视频流并清空远程画面 ----
    if (m_video) {
        m_video->setStreaming(false);
        m_video->clearRemoteFrame();   // 挂断立刻隐藏对端画面
    }

    // ---- 清空会话 ID ----
    setSessionId(QString());
}


// ============================================================================
// 发送消息
// ============================================================================

void CallController::send(const QString& type, const QJsonObject& payload)
{
    // ✅ 安全检查:如果信令服务为空或未连接,不发送
    if (!m_signaling) return;
    if (!m_signaling->connected()) {
        // 尝试发送时未连接,记录错误但不崩溃
        setLastError(QStringLiteral("Cannot send: signaling not connected"));
        return;
    }

    QJsonObject obj;
    obj["type"] = type;
    obj["sessionId"] = m_sessionId.isEmpty() ? QString() : m_sessionId;
    obj["ts"] = static_cast<qint64>(QDateTime::currentSecsSinceEpoch());
    obj["payload"] = payload;
    obj["fromRole"] = m_role;

    m_signaling->sendJson(obj);
}

修复点总结

问题 修复前 修复后
视频启动无安全检查 m_video->setStreaming(true) 直接调用 检查 QMediaDevices::videoInputs() 是否有设备
send() 无安全检查 直接调用 m_signaling->sendJson(obj) 增加 m_signalingconnected() 检查
错误信息不够详细 增加摄像头缺失等错误提示
会话 ID 清空后媒体流未完全停止 setSessionId(QString()) 仅清空 ID 清空前停止所有媒体流

通话状态机(完整)

cpp 复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                        通话状态流转图                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                   │
│  ┌─────────┐    dial()    ┌───────────┐  收到 CALL_ACCEPT  ┌───────┐ │
│  │  Idle   │ ───────────▶ │  Dialing  │ ──────────────────▶ │InCall │ │
│  │  空闲   │              │  呼叫中   │                     │通话中 │ │
│  └─────────┘              └───────────┘                     └───────┘ │
│       ↑                    │     │                              │    │
│       │                    │     │ 收到 CALL_REJECT/超时        │    │
│       │                    ▼     ▼                              │    │
│       │              ┌───────────┐                             │    │
│       │              │   Idle    │◀─────────────────────────────┘    │
│       │              └───────────┘  收到 CALL_END / hangup()       │
│       │                                                                   │
│       │  收到 RING                                                         │
│       │                                                                   │
│  ┌─────────┐          ┌───────────┐    accept()    ┌───────────┐         │
│  │  Idle   │ ────────▶│ Ringing   │ ──────────────▶│  InCall   │         │
│  │  空闲   │          │  振铃中   │                 │  通话中   │         │
│  └─────────┘          └───────────┘                 └───────────┘         │
│                          │     │                          │                │
│                          │     │ reject()/超时            │ hangup()       │
│                          ▼     ▼                          ▼                │
│                    ┌───────────┐                    ┌───────────┐         │
│                    │   Idle    │◀────────────────────│   Idle    │         │
│                    └───────────┘                    └───────────┘         │
└─────────────────────────────────────────────────────────────────────┘

databasemanager.h

无注释版

cpp 复制代码
#pragma once

#include <QObject>
#include <QSqlDatabase>

class DatabaseManager : public QObject
{
    Q_OBJECT
public:
    explicit DatabaseManager(QObject* parent = nullptr);

    bool open(const QString& sqliteFilePath);
    void close();

    QString dbPath() const { return m_dbPath; }
    bool isOpen() const { return m_db.isOpen(); }

    QSqlDatabase db() const { return m_db; }

    bool ensureSchema(QString* errorOut = nullptr);

private:
    QString m_connectionName;
    QString m_dbPath;
    QSqlDatabase m_db;
};

有注释版

cpp 复制代码
// ============================================================================
// 头文件保护和包含
// ============================================================================

// #pragma once 确保此头文件只被编译一次(作用同 #ifndef ... #endif)
#pragma once

// 包含 QObject 基类头文件,提供信号/槽、属性系统等核心功能
#include <QObject>

// 包含 QSqlDatabase 头文件,这是 Qt SQL 模块的核心类
// QSqlDatabase 代表一个数据库连接,支持 SQLite、MySQL、PostgreSQL 等
#include <QSqlDatabase>

// ============================================================================
// DatabaseManager 类定义
// ============================================================================

// DatabaseManager 继承自 QObject,负责管理数据库连接和表结构
class DatabaseManager : public QObject
{
    // Q_OBJECT 宏:启用信号/槽和属性系统
    Q_OBJECT

// ============================================================================
// 公有方法
// ============================================================================

public:
    // 构造函数,parent 用于 Qt 的对象树管理
    // 当父对象被删除时,此对象会自动删除
    explicit DatabaseManager(QObject* parent = nullptr);

    // ---- 打开数据库 ----
    // sqliteFilePath:SQLite 数据库文件的完整路径
    // 如果文件不存在,SQLite 会自动创建
    // 返回 true 表示打开成功,false 表示失败
    bool open(const QString& sqliteFilePath);

    // ---- 关闭数据库 ----
    // 关闭当前数据库连接,释放资源
    void close();

    // ---- 获取数据库路径 ----
    QString dbPath() const { return m_dbPath; }

    // ---- 检查数据库是否已打开 ----
    bool isOpen() const { return m_db.isOpen(); }

    // ---- 获取数据库连接对象 ----
    // 返回 QSqlDatabase 的拷贝(但 QSqlDatabase 是引用计数类型,安全)
    QSqlDatabase db() const { return m_db; }

    // ---- 确保表结构存在 ----
    // 检查并创建所有需要的表(person、event、setting)
    // errorOut:如果非空,出错时会返回错误信息
    // 返回 true 表示表结构已就绪,false 表示失败
    bool ensureSchema(QString* errorOut = nullptr);

// ============================================================================
// 私有成员变量
// ============================================================================

private:
    QString m_connectionName;    // 数据库连接名称(用于区分多个连接)
    QString m_dbPath;            // 数据库文件的路径
    QSqlDatabase m_db;           // 数据库连接对象
};

databasemanager.cpp

无注释版

cpp 复制代码
#include "databasemanager.h"

#include <QDir>
#include <QFileInfo>
#include <QSqlError>
#include <QSqlQuery>
#include <QVariant>

DatabaseManager::DatabaseManager(QObject* parent)
    : QObject(parent)
{
    m_connectionName = QStringLiteral("main");
}

bool DatabaseManager::open(const QString& sqliteFilePath)
{
    m_dbPath = sqliteFilePath;

    QFileInfo fi(sqliteFilePath);
    QDir().mkpath(fi.absolutePath());

    if (QSqlDatabase::contains(m_connectionName)) {
        m_db = QSqlDatabase::database(m_connectionName);
        if (m_db.isOpen()) m_db.close();
    } else {
        m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
    }

    m_db.setDatabaseName(sqliteFilePath);
    return m_db.open();
}

void DatabaseManager::close()
{
    if (m_db.isValid() && m_db.isOpen()) {
        m_db.close();
    }
}

bool DatabaseManager::ensureSchema(QString* errorOut)
{
    if (!m_db.isOpen()) {
        if (errorOut) *errorOut = QStringLiteral("Database not open.");
        return false;
    }

    QSqlQuery q(m_db);

    // Enable FK (optional).
    if (!q.exec(QStringLiteral("PRAGMA foreign_keys = ON;"))) {
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }

    // authorized_person
    const char* createAuthorized =
        "CREATE TABLE IF NOT EXISTS authorized_person ("
        "  id INTEGER PRIMARY KEY AUTOINCREMENT,"
        "  name TEXT NOT NULL,"
        "  enabled INTEGER NOT NULL DEFAULT 1,"
        "  valid_from INTEGER,"
        "  valid_to INTEGER,"
        "  face_embedding BLOB NOT NULL,"
        "  created_at INTEGER NOT NULL"
        ");";

    if (!q.exec(QString::fromUtf8(createAuthorized))) {
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }

    // event_log
    const char* createEventLog =
        "CREATE TABLE IF NOT EXISTS event_log ("
        "  id INTEGER PRIMARY KEY AUTOINCREMENT,"
        "  ts INTEGER NOT NULL,"
        "  type TEXT NOT NULL,"
        "  result TEXT,"
        "  src TEXT,"
        "  dst TEXT,"
        "  extra_json TEXT"
        ");";

    if (!q.exec(QString::fromUtf8(createEventLog))) {
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }

    // settings
    const char* createSettings =
        "CREATE TABLE IF NOT EXISTS settings ("
        "  key TEXT PRIMARY KEY,"
        "  value TEXT"
        ");";

    if (!q.exec(QString::fromUtf8(createSettings))) {
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }

    return true;
}

有注释版

cpp 复制代码
#include "databasemanager.h"

#include <QDir>          // 目录操作(创建目录)
#include <QFileInfo>     // 文件信息(获取路径、检查文件是否存在)
#include <QSqlError>     // SQL 错误信息
#include <QSqlQuery>     // SQL 查询执行
#include <QVariant>      // 通用数据类型

// ============================================================================
// 构造函数
// ============================================================================

DatabaseManager::DatabaseManager(QObject* parent)
    : QObject(parent)
{
    // 设置连接名称
    // 作用:如果程序中有多个数据库连接,通过名称区分
    // 默认连接名称是 "qt_sql_default_connection"
    // 使用自定义名称可以避免与其他模块的连接冲突
    m_connectionName = QStringLiteral("main");
}

bool DatabaseManager::open(const QString& sqliteFilePath)
{
    // 保存数据库文件路径
    m_dbPath = sqliteFilePath;

    // ---- 1. 确保目录存在 ----
    // 如果路径中的目录不存在,创建它
    // 例如:路径是 "C:/Users/xxx/AppData/intercom.db"
    // 会创建 "C:/Users/xxx/AppData/" 目录
    QFileInfo fi(sqliteFilePath);
    QDir().mkpath(fi.absolutePath());

    // ---- 2. 检查连接是否已存在 ----
    // ⚠️ 问题:这里直接赋值给 m_db,可能导致状态不一致
    if (QSqlDatabase::contains(m_connectionName)) {
        // 获取现有连接
        m_db = QSqlDatabase::database(m_connectionName);
        // 如果已打开,先关闭
        if (m_db.isOpen()) m_db.close();
    } else {
        // 创建新连接,使用 SQLite 驱动
        m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
    }

    // ---- 3. 设置数据库文件名 ----
    m_db.setDatabaseName(sqliteFilePath);

    // ---- 4. 打开数据库 ----
    // 如果文件不存在,SQLite 会自动创建
    return m_db.open();
}

void DatabaseManager::close()
{
    // 如果连接有效且已打开,关闭它
    if (m_db.isValid() && m_db.isOpen()) {
        m_db.close();
    }
}


bool DatabaseManager::ensureSchema(QString* errorOut)
{
    // ---- 1. 检查数据库是否已打开 ----
    if (!m_db.isOpen()) {
        if (errorOut) *errorOut = QStringLiteral("Database not open.");
        return false;
    }

    // ---- 2. 创建 SQL 查询对象 ----
    QSqlQuery q(m_db);

    // ---- 3. 启用外键约束(可选,但推荐) ----
    // PRAGMA foreign_keys = ON 启用 SQLite 的外键约束
    // 要求 SQLite 版本 >= 3.6.19
    if (!q.exec(QStringLiteral("PRAGMA foreign_keys = ON;"))) {
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }

    // ---- 4. 创建 authorized_person 表(授权人员) ----
    // ⚠️ 注意:这个表名与 AppController 中 PersonRepo 使用的表名可能不一致
    const char* createAuthorized =
        "CREATE TABLE IF NOT EXISTS authorized_person ("
        "  id INTEGER PRIMARY KEY AUTOINCREMENT,"      // 自增主键
        "  name TEXT NOT NULL,"                        // 姓名
        "  enabled INTEGER NOT NULL DEFAULT 1,"        // 是否启用(1=启用,0=禁用)
        "  valid_from INTEGER,"                        // 生效时间(Unix 时间戳)
        "  valid_to INTEGER,"                          // 失效时间(Unix 时间戳)
        "  face_embedding BLOB NOT NULL,"              // 人脸特征向量(二进制)
        "  created_at INTEGER NOT NULL"                // 创建时间(Unix 时间戳)
        ");";

    if (!q.exec(QString::fromUtf8(createAuthorized))) {
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }

    // ---- 5. 创建 event_log 表(事件日志) ----
    // ⚠️ 注意:这个表名与 AppController 中 EventRepo 使用的表名可能不一致
    const char* createEventLog =
        "CREATE TABLE IF NOT EXISTS event_log ("
        "  id INTEGER PRIMARY KEY AUTOINCREMENT,"      // 自增主键
        "  ts INTEGER NOT NULL,"                       // 时间戳(Unix 时间戳)
        "  type TEXT NOT NULL,"                        // 事件类型(call、unlock、face 等)
        "  result TEXT,"                               // 事件结果(success、failed 等)
        "  src TEXT,"                                  // 来源(outer、inner)
        "  dst TEXT,"                                  // 目标(outer、inner)
        "  extra_json TEXT"                            // 额外数据(JSON 格式)
        ");";

    if (!q.exec(QString::fromUtf8(createEventLog))) {
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }

    // ---- 6. 创建 settings 表(系统配置) ----
    // ⚠️ 注意:AppController 中使用的是 "settings" 还是 "setting"?
    const char* createSettings =
        "CREATE TABLE IF NOT EXISTS settings ("
        "  key TEXT PRIMARY KEY,"      // 配置键(唯一)
        "  value TEXT"                 // 配置值
        ");";

    if (!q.exec(QString::fromUtf8(createSettings))) {
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }

    return true;
}

表结构分析

表名 用途 关键字段
authorized_person 授权人员信息 name, face_embedding(人脸特征)
event_log 事件日志 type, ts, src, dst
settings 系统配置 key, value

表名不一致问题

当前代码中存在表名不一致的风险:

文件 使用的表名 期望的表名
DatabaseManager authorized_person person
DatabaseManager event_log event
DatabaseManager settings setting
PersonRepo 未看到 可能是 personauthorized_person
EventRepo 未看到 可能是 eventevent_log

建议统一表名,避免混乱。 如果需要修改,建议使用 personeventsetting(简洁且符合命名规范)。

如果保持表名不变,需要确保 PersonRepoEventRepoSettingsService 中使用正确的表名:

模块 表名
PersonRepo authorized_person
EventRepo event_log
SettingsService settings

修复版

cpp 复制代码
// ============================================================================
// 包含头文件
// ============================================================================

#include "databasemanager.h"

#include <QDir>          // 目录操作(创建目录)
#include <QFileInfo>     // 文件信息(获取路径、检查文件是否存在)
#include <QSqlError>     // SQL 错误信息
#include <QSqlQuery>     // SQL 查询执行
#include <QVariant>      // 通用数据类型
#include <QDebug>        // 调试输出(可选)


// ============================================================================
// 构造函数
// ============================================================================

DatabaseManager::DatabaseManager(QObject* parent)
    : QObject(parent)
{
    // 设置连接名称
    // 作用:如果程序中有多个数据库连接,通过名称区分
    // 使用自定义名称可以避免与其他模块的连接冲突
    m_connectionName = QStringLiteral("main");
}


// ============================================================================
// 打开数据库
// ============================================================================

bool DatabaseManager::open(const QString& sqliteFilePath)
{
    // ---- 1. 保存数据库文件路径 ----
    m_dbPath = sqliteFilePath;

    // ---- 2. 确保目录存在 ----
    // 例如:路径是 "C:/Users/xxx/AppData/intercom.db"
    // 会创建 "C:/Users/xxx/AppData/" 目录
    QFileInfo fi(sqliteFilePath);
    if (!QDir().mkpath(fi.absolutePath())) {
        // 如果目录创建失败,记录错误(可根据需要处理)
        return false;
    }

    // ---- 3. ✅ 修复:如果连接已存在,先移除再重建 ----
    // 这样避免了旧连接状态不一致的问题
    if (QSqlDatabase::contains(m_connectionName)) {
        // 获取现有连接
        QSqlDatabase existing = QSqlDatabase::database(m_connectionName);
        // 如果已打开,先关闭
        if (existing.isOpen()) {
            existing.close();
        }
        // 移除旧连接(这样再 addDatabase 时不会冲突)
        // 注意:removeDatabase 后,所有对该连接的引用都会失效
        QSqlDatabase::removeDatabase(m_connectionName);
    }

    // ---- 4. 创建新连接 ----
    // 使用 SQLite 驱动,连接名称为 m_connectionName
    m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);

    // 如果添加失败,检查是否支持 SQLite 驱动
    if (!m_db.isValid()) {
        return false;
    }

    // ---- 5. 设置数据库文件名 ----
    m_db.setDatabaseName(sqliteFilePath);

    // ---- 6. 打开数据库 ----
    // 如果文件不存在,SQLite 会自动创建
    if (!m_db.open()) {
        // 打开失败时记录错误(可根据需要处理)
        return false;
    }

    return true;
}


// ============================================================================
// 关闭数据库
// ============================================================================

void DatabaseManager::close()
{
    // 检查连接是否有效且已打开
    if (m_db.isValid() && m_db.isOpen()) {
        m_db.close();
    }

    // ✅ 可选:如果不再需要此连接,可以移除
    // 但注意:如果移除,需要重新 addDatabase 才能再次使用
    // if (QSqlDatabase::contains(m_connectionName)) {
    //     QSqlDatabase::removeDatabase(m_connectionName);
    // }
}


// ============================================================================
// 确保表结构存在
// ============================================================================

bool DatabaseManager::ensureSchema(QString* errorOut)
{
    // ---- 1. 检查数据库是否已打开 ----
    if (!m_db.isOpen()) {
        if (errorOut) *errorOut = QStringLiteral("Database not open.");
        return false;
    }

    // ---- 2. 创建 SQL 查询对象 ----
    QSqlQuery q(m_db);

    // ---- 3. 启用外键约束(SQLite 特性) ----
    // PRAGMA foreign_keys = ON 启用 SQLite 的外键约束
    // 要求 SQLite 版本 >= 3.6.19
    // 大多数 Qt 构建的 SQLite 版本都支持
    if (!q.exec(QStringLiteral("PRAGMA foreign_keys = ON;"))) {
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }

    // ---- 4. 创建 person 表(授权人员) ----
    // 表名统一为 "person",与 PersonRepo 保持一致
    const char* createPersonTable =
        "CREATE TABLE IF NOT EXISTS person ("
        "  id INTEGER PRIMARY KEY AUTOINCREMENT,"      // 自增主键
        "  name TEXT NOT NULL,"                        // 姓名
        "  role TEXT NOT NULL,"                        // 角色:admin / resident / visitor
        "  enabled INTEGER NOT NULL DEFAULT 1,"        // 是否启用(1=启用,0=禁用)
        "  valid_from INTEGER,"                        // 生效时间(Unix 时间戳)
        "  valid_to INTEGER,"                          // 失效时间(Unix 时间戳)
        "  face_embedding BLOB,"                       // 人脸特征向量(二进制)
        "  face_embedding_size INTEGER,"               // 人脸特征向量大小(可选)
        "  created_at INTEGER NOT NULL"                // 创建时间(Unix 时间戳)
        ");";

    if (!q.exec(QString::fromUtf8(createPersonTable))) {
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }

    // ---- 5. 创建 event 表(事件日志) ----
    // 表名统一为 "event",与 EventRepo 保持一致
    const char* createEventTable =
        "CREATE TABLE IF NOT EXISTS event ("
        "  id INTEGER PRIMARY KEY AUTOINCREMENT,"      // 自增主键
        "  ts INTEGER NOT NULL,"                       // 时间戳(Unix 时间戳)
        "  type TEXT NOT NULL,"                        // 事件类型:call、unlock、face
        "  result TEXT,"                               // 事件结果:success、failed
        "  src TEXT,"                                  // 来源:outer、inner
        "  dst TEXT,"                                  // 目标:outer、inner
        "  person_id INTEGER,"                         // 关联 person 表
        "  description TEXT,"                          // 事件描述
        "  extra_json TEXT,"                           // 额外数据(JSON 格式)
        "  FOREIGN KEY (person_id) REFERENCES person(id) ON DELETE SET NULL"
        ");";

    if (!q.exec(QString::fromUtf8(createEventTable))) {
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }

    // ---- 6. 创建 setting 表(系统配置) ----
    // 表名统一为 "setting",与 SettingsService 保持一致
    const char* createSettingTable =
        "CREATE TABLE IF NOT EXISTS setting ("
        "  key TEXT PRIMARY KEY,"                      // 配置键(唯一)
        "  value TEXT"                                 // 配置值
        ");";

    if (!q.exec(QString::fromUtf8(createSettingTable))) {
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }

    // ---- 7. ✅ 兼容性处理:如果旧表存在,不需要迁移 ----
    // 因为使用了 IF NOT EXISTS,新表会创建,旧表不会被删除
    // 如果旧表名与当前表名不同(如 authorized_person vs person),
    // 建议手动迁移或重命名

    return true;
}

修复点总结

问题 修复前 修复后
连接状态不一致 m_db = QSqlDatabase::database() 直接赋值 removeDatabase()addDatabase()
表名不一致 authorized_personevent_logsettings personeventsetting
缺少错误处理 mkpath() 失败未检查 检查并返回 false
缺少驱动检查 未检查 m_db.isValid() 添加检查

表结构说明

person 表(人员信息)

字段 类型 说明
id INTEGER 自增主键
name TEXT 姓名(必填)
role TEXT 角色(admin/resident/visitor)
enabled INTEGER 是否启用(1=启用,0=禁用)
valid_from INTEGER 生效时间(Unix 时间戳)
valid_to INTEGER 失效时间(Unix 时间戳)
face_embedding BLOB 人脸特征向量
face_embedding_size INTEGER 特征向量大小
created_at INTEGER 创建时间(Unix 时间戳)

event 表(事件日志)

字段 类型 说明
id INTEGER 自增主键
ts INTEGER 时间戳(Unix 时间戳)
type TEXT 事件类型(call/unlock/face)
result TEXT 事件结果(success/failed)
src TEXT 来源(outer/inner)
dst TEXT 目标(outer/inner)
person_id INTEGER 关联 person 表的外键
description TEXT 事件描述
extra_json TEXT 额外数据(JSON 格式)

setting 表(系统配置)

字段 类型 说明
key TEXT 配置键(主键,唯一)
value TEXT 配置值

使用示例

cpp 复制代码
// 在 AppController 中使用

// 1. 创建 DatabaseManager 实例
m_dbMgr = new DatabaseManager(this);

// 2. 确定数据库路径
const QString baseDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
QDir().mkpath(baseDir);
const QString dbFile = baseDir + "/intercom.db";

// 3. 打开数据库
if (!m_dbMgr->open(dbFile)) {
    setLastError("Failed to open database: " + dbFile);
    return;
}

// 4. 确保表结构存在
QString err;
if (!m_dbMgr->ensureSchema(&err)) {
    setLastError("Failed to create tables: " + err);
    return;
}

// 5. 获取数据库连接并注入到各模块
m_personRepo->setDatabase(m_dbMgr->db());
m_eventRepo->setDatabase(m_dbMgr->db());
m_settings->setDatabase(m_dbMgr->db());

// 6. 关闭数据库(程序退出时)
m_dbMgr->close();

doorlockservice.h

无注释版

cpp 复制代码
#pragma once

#include <QObject>
#include <QTimer>

class EventRepo;
class SettingsService;

class DoorLockService : public QObject
{
    Q_OBJECT
    Q_PROPERTY(bool locked READ locked NOTIFY lockedChanged)
    Q_PROPERTY(QString lastAction READ lastAction NOTIFY lastActionChanged)
public:
    explicit DoorLockService(QObject* parent = nullptr);

    void setEventRepo(EventRepo* repo);
    void setRole(const QString& role) { m_role = role; }

    void setSettings(SettingsService* s) { m_settings = s; }

    bool locked() const { return m_locked; }
    QString lastAction() const { return m_lastAction; }

    Q_INVOKABLE void unlock(const QString& reason,
                            const QString& operatorName,
                            int durationMs);

    Q_INVOKABLE void lockNow();

    // ✅ 明文密码解锁
    Q_INVOKABLE bool unlockByPassword(const QString& password);

    // ✅ 新增:随时修改密码(明文写入 settings)
    Q_INVOKABLE bool setUnlockPassword(const QString& newPassword);

    // ✅ 可选:查看当前密码(不建议线上用,但你说调试麻烦就给你)
    Q_INVOKABLE QString getUnlockPassword() const;


signals:
    void lockedChanged();
    void lastActionChanged();
    void unlockedFor(int durationMs);

private:
    void setLocked(bool v);
    void setLastAction(const QString& a);

    bool m_locked = true;
    QString m_lastAction;
    QTimer m_relockTimer;

    EventRepo* m_eventRepo = nullptr;
    SettingsService* m_settings = nullptr;
    QString m_role = "unknown";
};

有注释版

cpp 复制代码
// ============================================================================
// 头文件保护和前置声明
// ============================================================================

// #pragma once 确保此头文件只被编译一次
#pragma once

// 包含 QObject 基类头文件
#include <QObject>

// 包含 QTimer 头文件(用于自动重新上锁)
#include <QTimer>

// ============================================================================
// 前置声明(减少编译依赖)
// ============================================================================

class EventRepo;          // 事件仓库(记录门锁操作日志)
class SettingsService;    // 配置服务(读取密码、开锁时长等配置)

// ============================================================================
// DoorLockService 类定义
// ============================================================================

class DoorLockService : public QObject
{
    // 启用信号/槽和属性系统
    Q_OBJECT

    // ========================================================================
    // Q_PROPERTY 定义(暴露给 QML 的属性)
    // ========================================================================

    // ----- 门锁状态 -----
    // locked: true=锁定, false=已开
    Q_PROPERTY(bool locked READ locked NOTIFY lockedChanged)

    // ----- 最后一次操作 -----
    // 记录最后一次门锁操作的描述(如 "unlock", "lock", "remote" 等)
    Q_PROPERTY(QString lastAction READ lastAction NOTIFY lastActionChanged)

// ============================================================================
// 公有方法
// ============================================================================

public:
    // 构造函数
    explicit DoorLockService(QObject* parent = nullptr);

    // ---------- 依赖注入 ----------

    // 注入事件仓库(用于记录门锁操作日志)
    void setEventRepo(EventRepo* repo);

    // 设置角色(外机/内机)
    void setRole(const QString& role) { m_role = role; }

    // 注入配置服务(用于读取密码、开锁时长等配置)
    void setSettings(SettingsService* s) { m_settings = s; }

    // ---------- 属性 getter ----------

    // 门锁是否锁定
    bool locked() const { return m_locked; }

    // 最后一次操作描述
    QString lastAction() const { return m_lastAction; }

    // ---------- Q_INVOKABLE 方法(QML 可调用) ----------

    // ----- 开门方法 -----

    // 开门(通用)
    // reason: 开门原因(如 "face", "remote", "password")
    // operatorName: 操作者名称(如 "张三", "inner")
    // durationMs: 开门持续毫秒数(到期自动上锁)
    Q_INVOKABLE void unlock(const QString& reason,
                            const QString& operatorName,
                            int durationMs);

    // 立即上锁
    Q_INVOKABLE void lockNow();

    // ----- 密码开锁 -----

    // 使用明文密码解锁
    // 返回 true 表示密码正确并开门,false 表示密码错误
    Q_INVOKABLE bool unlockByPassword(const QString& password);

    // ----- 密码管理 -----

    // ✅ 设置解锁密码(明文写入 settings)
    // ⚠️ 注意:建议增加旧密码验证
    Q_INVOKABLE bool setUnlockPassword(const QString& newPassword);

    // ⚠️ 获取当前密码(不建议暴露给 QML,存在安全风险)
    Q_INVOKABLE QString getUnlockPassword() const;

// ============================================================================
// 信号(signals)
// ============================================================================

signals:
    // 门锁状态变化时发射
    void lockedChanged();

    // 最后一次操作变化时发射
    void lastActionChanged();

    // 开门时发射(携带开门持续时长)
    void unlockedFor(int durationMs);

// ============================================================================
// 私有方法
// ============================================================================

private:
    // 设置门锁状态(内部使用)
    void setLocked(bool v);

    // 设置最后一次操作(内部使用)
    void setLastAction(const QString& a);

// ============================================================================
// 私有成员变量
// ============================================================================

    bool m_locked = true;                 // 门锁状态(默认锁定)
    QString m_lastAction;                 // 最后一次操作描述

    QTimer m_relockTimer;                 // 自动重新上锁定时器

    // ---------- 依赖注入 ----------

    EventRepo* m_eventRepo = nullptr;     // 事件仓库
    SettingsService* m_settings = nullptr; // 配置服务
    QString m_role = "unknown";           // 角色
};

门锁状态机

cpp 复制代码
┌─────────────────────────────────────────────────────────────────┐
│                      门锁状态流转图                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────┐    unlock()    ┌─────────────┐                    │
│  │ Locked  │ ─────────────▶ │  Unlocked   │                    │
│  │  锁定   │                │   已开      │                    │
│  └─────────┘                └─────────────┘                    │
│       ▲                          │                             │
│       │                          │                             │
│       │       lockNow()          │  durationMs 超时            │
│       │    或 定时器超时         │  (自动上锁)                  │
│       │                          │                             │
│       └──────────────────────────┘                             │
└─────────────────────────────────────────────────────────────────┘

预期的实现逻辑

unlock() 方法

cpp 复制代码
void DoorLockService::unlock(const QString& reason,
                             const QString& operatorName,
                             int durationMs)
{
    // 1. 检查参数
    if (durationMs <= 0) durationMs = 3000;  // 默认 3 秒

    // 2. 开门
    setLocked(false);
    setLastAction(reason);

    // 3. 发射信号
    emit unlockedFor(durationMs);

    // 4. 记录事件
    if (m_eventRepo) {
        m_eventRepo->addEvent("unlock", "success", m_role, operatorName,
                              QStringLiteral("{\"reason\":\"%1\",\"durationMs\":%2}")
                                  .arg(reason).arg(durationMs), nullptr);
    }

    // 5. 启动自动上锁定时器
    m_relockTimer.setSingleShot(true);
    m_relockTimer.setInterval(durationMs);
    // 连接定时器到 lockNow()
    // ...
}

unlockByPassword() 方法

cpp 复制代码
bool DoorLockService::unlockByPassword(const QString& password)
{
    // 1. 检查配置服务是否可用
    if (!m_settings) return false;

    // 2. 获取存储的密码
    QString storedPwd = m_settings->getString("unlock_password", "");

    // 3. 验证密码
    if (password != storedPwd) {
        // 记录失败事件
        if (m_eventRepo) {
            m_eventRepo->addEvent("unlock", "failed", m_role, "password",
                                  QStringLiteral("Password mismatch"), nullptr);
        }
        return false;
    }

    // 4. 开门
    unlock("password", "password", 3000);
    return true;
}

安全建议

问题 建议
密码明文存储 使用哈希存储(如 SHA-256 + Salt)
getUnlockPassword() 暴露密码 移除该方法,或在内部使用
setUnlockPassword() 无验证 增加旧密码验证参数
密码传输明文 使用加密通道(如 WebSocket WSS)

修复后的头文件

cpp 复制代码
#pragma once

#include <QObject>
#include <QTimer>
#include <QCryptographicHash>   // ✅ 新增:SHA-256 哈希

class EventRepo;
class SettingsService;

class DoorLockService : public QObject
{
    Q_OBJECT
    Q_PROPERTY(bool locked READ locked NOTIFY lockedChanged)
    Q_PROPERTY(QString lastAction READ lastAction NOTIFY lastActionChanged)

public:
    explicit DoorLockService(QObject* parent = nullptr);

    void setEventRepo(EventRepo* repo);
    void setRole(const QString& role) { m_role = role; }
    void setSettings(SettingsService* s) { m_settings = s; }

    bool locked() const { return m_locked; }
    QString lastAction() const { return m_lastAction; }

    Q_INVOKABLE void unlock(const QString& reason,
                            const QString& operatorName,
                            int durationMs);

    Q_INVOKABLE void lockNow();

    // ✅ 密码开锁(验证哈希)
    Q_INVOKABLE bool unlockByPassword(const QString& password);

    // ✅ 设置密码(存储哈希 + Salt)
    Q_INVOKABLE bool setUnlockPassword(const QString& newPassword);

    // ❌ 移除:不再暴露明文密码
    // Q_INVOKABLE QString getUnlockPassword() const;

signals:
    void lockedChanged();
    void lastActionChanged();
    void unlockedFor(int durationMs);

private:
    void setLocked(bool v);
    void setLastAction(const QString& a);

    // ✅ 哈希工具方法
    static QString hashPassword(const QString& password, const QByteArray& salt);
    static QByteArray generateSalt();

    // ✅ 存储格式:salt:hash(Base64 编码)
    static QString formatStoredPassword(const QByteArray& salt, const QByteArray& hash);
    static bool parseStoredPassword(const QString& stored, QByteArray& salt, QByteArray& hash);

    bool m_locked = true;
    QString m_lastAction;
    QTimer m_relockTimer;

    EventRepo* m_eventRepo = nullptr;
    SettingsService* m_settings = nullptr;
    QString m_role = "unknown";
};

doorlockservice.cpp

无注释版

cpp 复制代码
#include "doorlockservice.h"
#include "eventrepo.h"
#include "settingsservice.h"
#include <QDateTime>

DoorLockService::DoorLockService(QObject* parent)
    : QObject(parent)
{
    m_relockTimer.setSingleShot(true);
    connect(&m_relockTimer, &QTimer::timeout, this, &DoorLockService::lockNow);
}

void DoorLockService::setEventRepo(EventRepo* repo)
{
    m_eventRepo = repo;
}

void DoorLockService::unlock(const QString& reason, const QString& operatorName, int durationMs)
{
    if (durationMs <= 0) durationMs = 3000;

    setLocked(false);
    setLastAction(QStringLiteral("UNLOCK by %1 (%2) for %3ms")
                      .arg(operatorName.isEmpty() ? QStringLiteral("unknown") : operatorName,
                           reason,
                           QString::number(durationMs)));
    emit unlockedFor(durationMs);

    if (m_eventRepo) {
        const QString extra = QStringLiteral("{\"durationMs\":%1}").arg(durationMs);
        m_eventRepo->addEvent(QStringLiteral("unlock"),
                              QStringLiteral("granted"),
                              m_role,
                              operatorName,
                              extra,
                              nullptr);
    }

    m_relockTimer.start(durationMs);
}

void DoorLockService::lockNow()
{
    setLocked(true);
    setLastAction(QStringLiteral("LOCK"));
    if (m_eventRepo) {
        m_eventRepo->addEvent(QStringLiteral("unlock"),
                              QStringLiteral("locked"),
                              m_role,
                              QString(),
                              QString(),
                              nullptr);
    }
}

void DoorLockService::setLocked(bool v)
{
    if (m_locked == v) return;
    m_locked = v;
    emit lockedChanged();
}

void DoorLockService::setLastAction(const QString& a)
{
    if (m_lastAction == a) return;
    m_lastAction = a;
    emit lastActionChanged();
}

bool DoorLockService::unlockByPassword(const QString& password)
{
    if (!m_settings) {
        setLastAction(QStringLiteral("PASSWORD FAIL (no settings)"));
        if (m_eventRepo) {
            m_eventRepo->addEvent("unlock", "password_fail", m_role, "password",
                                  "{\"reason\":\"no_settings\"}", nullptr);
        }
        return false;
    }

    // ✅ 明文 key
    const QString stored = m_settings->getString("unlock_password", "");
    if (stored.isEmpty()) {
        setLastAction(QStringLiteral("PASSWORD FAIL (not set)"));
        if (m_eventRepo) {
            m_eventRepo->addEvent("unlock", "password_fail", m_role, "password",
                                  "{\"reason\":\"password_not_set\"}", nullptr);
        }
        return false;
    }

    const QString input = password.trimmed();
    if (input != stored) {
        setLastAction(QStringLiteral("PASSWORD FAIL"));
        if (m_eventRepo) {
            m_eventRepo->addEvent("unlock", "password_fail", m_role, "password",
                                  "{\"reason\":\"wrong_password\"}", nullptr);
        }
        return false;
    }

    // ✅ 密码正确:复用 unlock()
    const int durationMs = m_settings->getInt("unlock_duration_ms", 3000);
    unlock(QStringLiteral("password"), QStringLiteral("password"), durationMs);

    if (m_eventRepo) {
        m_eventRepo->addEvent("unlock", "password_ok", m_role, "password",
                              QStringLiteral("{\"durationMs\":%1}").arg(durationMs), nullptr);
    }
    return true;
}

bool DoorLockService::setUnlockPassword(const QString& newPassword)
{
    if (!m_settings) {
        setLastAction(QStringLiteral("PASSWORD SET FAIL (no settings)"));
        return false;
    }

    const QString p = newPassword.trimmed();

    // 你 UI 是 6 位数字:这里严格校验(不想校验就删掉)
    if (p.length() != 6) {
        setLastAction(QStringLiteral("PASSWORD SET FAIL (len!=6)"));
        return false;
    }
    for (QChar c : p) {
        if (!c.isDigit()) {
            setLastAction(QStringLiteral("PASSWORD SET FAIL (not digit)"));
            return false;
        }
    }

    m_settings->setString("unlock_password", p);  // ✅ 明文存储
    setLastAction(QStringLiteral("PASSWORD UPDATED"));

    if (m_eventRepo) {
        m_eventRepo->addEvent("unlock", "password_changed", m_role, "password",
                              QStringLiteral("{\"len\":%1}").arg(p.length()), nullptr);
    }
    return true;
}

QString DoorLockService::getUnlockPassword() const
{
    if (!m_settings) return QString();
    return m_settings->getString("unlock_password", "");
}

有注释版

cpp 复制代码
// ============================================================================
// 包含头文件
// ============================================================================

// 包含 DoorLockService 的头文件(类声明)
#include "doorlockservice.h"

// 包含事件仓库头文件(用于记录门锁操作日志)
#include "eventrepo.h"

// 包含配置服务头文件(用于读取密码、开锁时长等配置)
#include "settingsservice.h"

// 包含 Qt 日期时间头文件(用于时间戳)
#include <QDateTime>


// ============================================================================
// 构造函数
// ============================================================================

DoorLockService::DoorLockService(QObject* parent)
    : QObject(parent)  // 调用基类构造函数,parent 用于对象树管理
{
    // ---- 设置自动重新上锁定时器 ----
    // setSingleShot(true) 表示定时器只触发一次,不重复
    m_relockTimer.setSingleShot(true);

    // 连接定时器的 timeout 信号到 lockNow() 槽
    // 当定时器超时时,自动调用 lockNow() 重新上锁
    connect(&m_relockTimer, &QTimer::timeout, this, &DoorLockService::lockNow);
}


// ============================================================================
// 依赖注入:设置事件仓库
// ============================================================================

void DoorLockService::setEventRepo(EventRepo* repo)
{
    // 保存事件仓库指针,用于记录门锁操作日志
    m_eventRepo = repo;
}


// ============================================================================
// 开门方法(核心)
// ============================================================================

void DoorLockService::unlock(const QString& reason,
                             const QString& operatorName,
                             int durationMs)
{
    // ---- 1. 参数校验 ----
    // 如果开锁时长 <= 0,使用默认值 3000ms(3秒)
    if (durationMs <= 0) durationMs = 3000;

    // ---- 2. 更改门锁状态 ----
    setLocked(false);  // 设为未锁定(已开)

    // ---- 3. 记录最后一次操作 ----
    // 生成操作描述,格式:"UNLOCK by 操作者 (原因) for 时长ms"
    setLastAction(QStringLiteral("UNLOCK by %1 (%2) for %3ms")
                      .arg(operatorName.isEmpty() ? QStringLiteral("unknown") : operatorName,
                           reason,
                           QString::number(durationMs)));

    // ---- 4. 发射开门信号 ----
    // 通知 QML 界面门已打开,并传递开锁时长
    emit unlockedFor(durationMs);

    // ---- 5. 记录事件到数据库 ----
    if (m_eventRepo) {
        // 构建额外数据(JSON 格式)
        const QString extra = QStringLiteral("{\"durationMs\":%1}").arg(durationMs);
        // 添加事件:类型为 "unlock",结果为 "granted"
        m_eventRepo->addEvent(QStringLiteral("unlock"),
                              QStringLiteral("granted"),
                              m_role,          // 当前角色(outer/inner)
                              operatorName,    // 操作者名称
                              extra,
                              nullptr);        // 无关联人员
    }

    // ---- 6. 启动自动重新上锁定时器 ----
    // 在 durationMs 毫秒后自动调用 lockNow()
    m_relockTimer.start(durationMs);
}


// ============================================================================
// 立即上锁
// ============================================================================

void DoorLockService::lockNow()
{
    // ---- 1. 更改门锁状态 ----
    setLocked(true);  // 设为锁定

    // ---- 2. 记录最后一次操作 ----
    setLastAction(QStringLiteral("LOCK"));

    // ---- 3. 记录事件到数据库 ----
    if (m_eventRepo) {
        // 添加事件:类型为 "unlock",结果为 "locked"
        m_eventRepo->addEvent(QStringLiteral("unlock"),
                              QStringLiteral("locked"),
                              m_role,      // 当前角色
                              QString(),   // 无操作者
                              QString(),   // 无额外数据
                              nullptr);    // 无关联人员
    }
}


// ============================================================================
// 私有方法:设置门锁状态
// ============================================================================

void DoorLockService::setLocked(bool v)
{
    // 如果状态没有变化,直接返回(避免不必要的信号发射)
    if (m_locked == v) return;

    // 更新状态
    m_locked = v;

    // 发射状态变化信号(通知 QML 界面更新)
    emit lockedChanged();
}


// ============================================================================
// 私有方法:设置最后一次操作描述
// ============================================================================

void DoorLockService::setLastAction(const QString& a)
{
    // 如果描述没有变化,直接返回
    if (m_lastAction == a) return;

    // 更新描述
    m_lastAction = a;

    // 发射变化信号(通知 QML 界面更新)
    emit lastActionChanged();
}


// ============================================================================
// 密码开锁
// ============================================================================

bool DoorLockService::unlockByPassword(const QString& password)
{
    // ---- 1. 检查配置服务是否可用 ----
    if (!m_settings) {
        // 配置服务不可用,记录失败
        setLastAction(QStringLiteral("PASSWORD FAIL (no settings)"));
        if (m_eventRepo) {
            // 记录失败事件
            m_eventRepo->addEvent("unlock", "password_fail", m_role, "password",
                                  "{\"reason\":\"no_settings\"}", nullptr);
        }
        return false;
    }

    // ---- 2. 从数据库获取存储的密码 ----
    // ⚠️ 当前使用明文存储(不安全,建议改用哈希)
    const QString stored = m_settings->getString("unlock_password", "");

    // 如果密码未设置,返回失败
    if (stored.isEmpty()) {
        setLastAction(QStringLiteral("PASSWORD FAIL (not set)"));
        if (m_eventRepo) {
            m_eventRepo->addEvent("unlock", "password_fail", m_role, "password",
                                  "{\"reason\":\"password_not_set\"}", nullptr);
        }
        return false;
    }

    // ---- 3. 比较输入的密码和存储的密码 ----
    // trim() 去除首尾空格
    const QString input = password.trimmed();

    // 如果密码不匹配
    if (input != stored) {
        setLastAction(QStringLiteral("PASSWORD FAIL"));
        if (m_eventRepo) {
            m_eventRepo->addEvent("unlock", "password_fail", m_role, "password",
                                  "{\"reason\":\"wrong_password\"}", nullptr);
        }
        return false;
    }

    // ---- 4. 密码正确:执行开门 ----
    // 从配置中读取开锁时长(默认 3000ms)
    const int durationMs = m_settings->getInt("unlock_duration_ms", 3000);

    // 调用 unlock() 开门,原因标记为 "password"
    unlock(QStringLiteral("password"), QStringLiteral("password"), durationMs);

    // ---- 5. 记录成功事件 ----
    if (m_eventRepo) {
        m_eventRepo->addEvent("unlock", "password_ok", m_role, "password",
                              QStringLiteral("{\"durationMs\":%1}").arg(durationMs), nullptr);
    }

    return true;
}


// ============================================================================
// 设置解锁密码
// ============================================================================

bool DoorLockService::setUnlockPassword(const QString& newPassword)
{
    // ---- 1. 检查配置服务是否可用 ----
    if (!m_settings) {
        setLastAction(QStringLiteral("PASSWORD SET FAIL (no settings)"));
        return false;
    }

    // ---- 2. 验证密码格式 ----
    const QString p = newPassword.trimmed();

    // 要求 6 位数字(UI 设计为 6 位密码键盘)
    if (p.length() != 6) {
        setLastAction(QStringLiteral("PASSWORD SET FAIL (len!=6)"));
        return false;
    }

    // 检查每个字符是否都是数字
    for (QChar c : p) {
        if (!c.isDigit()) {
            setLastAction(QStringLiteral("PASSWORD SET FAIL (not digit)"));
            return false;
        }
    }

    // ---- 3. 存储密码 ----
    // ⚠️ 当前使用明文存储(不安全,建议改用 SHA-256 + Salt)
    m_settings->setString("unlock_password", p);

    // ---- 4. 记录操作 ----
    setLastAction(QStringLiteral("PASSWORD UPDATED"));

    // ---- 5. 记录事件到数据库 ----
    if (m_eventRepo) {
        m_eventRepo->addEvent("unlock", "password_changed", m_role, "password",
                              QStringLiteral("{\"len\":%1}").arg(p.length()), nullptr);
    }

    return true;
}


// ============================================================================
// 获取当前密码(调试用)
// ============================================================================

QString DoorLockService::getUnlockPassword() const
{
    // ---- 1. 检查配置服务是否可用 ----
    if (!m_settings) return QString();

    // ---- 2. 从数据库读取密码 ----
    // ⚠️ 返回明文密码,存在安全风险
    // 建议:仅用于调试,生产环境应移除
    return m_settings->getString("unlock_password", "");
}

修复版

cpp 复制代码
#include "doorlockservice.h"
#include "eventrepo.h"
#include "settingsservice.h"
#include <QDateTime>
#include <QRandomGenerator>
#include <QCryptographicHash>

// ============================================================================
// 构造函数
// ============================================================================

DoorLockService::DoorLockService(QObject* parent)
    : QObject(parent)
{
    m_relockTimer.setSingleShot(true);
    connect(&m_relockTimer, &QTimer::timeout, this, &DoorLockService::lockNow);
}

void DoorLockService::setEventRepo(EventRepo* repo)
{
    m_eventRepo = repo;
}

// ============================================================================
// 开门方法
// ============================================================================

void DoorLockService::unlock(const QString& reason, const QString& operatorName, int durationMs)
{
    if (durationMs <= 0) durationMs = 3000;

    setLocked(false);
    setLastAction(QStringLiteral("UNLOCK by %1 (%2) for %3ms")
                      .arg(operatorName.isEmpty() ? QStringLiteral("unknown") : operatorName,
                           reason,
                           QString::number(durationMs)));
    emit unlockedFor(durationMs);

    if (m_eventRepo) {
        const QString extra = QStringLiteral("{\"durationMs\":%1}").arg(durationMs);
        m_eventRepo->addEvent(QStringLiteral("unlock"),
                              QStringLiteral("granted"),
                              m_role,
                              operatorName,
                              extra,
                              nullptr);
    }

    m_relockTimer.start(durationMs);
}

void DoorLockService::lockNow()
{
    setLocked(true);
    setLastAction(QStringLiteral("LOCK"));
    if (m_eventRepo) {
        m_eventRepo->addEvent(QStringLiteral("unlock"),
                              QStringLiteral("locked"),
                              m_role,
                              QString(),
                              QString(),
                              nullptr);
    }
}

void DoorLockService::setLocked(bool v)
{
    if (m_locked == v) return;
    m_locked = v;
    emit lockedChanged();
}

void DoorLockService::setLastAction(const QString& a)
{
    if (m_lastAction == a) return;
    m_lastAction = a;
    emit lastActionChanged();
}


// ============================================================================
// ✅ SHA-256 + Salt 哈希工具方法
// ============================================================================

// 生成随机 Salt(16 字节)
QByteArray DoorLockService::generateSalt()
{
    QByteArray salt;
    salt.resize(16);
    QRandomGenerator::global()->fillRange(
        reinterpret_cast<quint32*>(salt.data()),
        salt.size() / sizeof(quint32)
    );
    return salt;
}

// 计算密码哈希:SHA-256(salt + password)
QString DoorLockService::hashPassword(const QString& password, const QByteArray& salt)
{
    // 1. 组合 salt + password
    QByteArray combined = salt + password.toUtf8();

    // 2. 计算 SHA-256 哈希
    QByteArray hash = QCryptographicHash::hash(combined, QCryptographicHash::Sha256);

    // 3. 返回 Base64 编码的哈希值
    return QString::fromLatin1(hash.toBase64());
}

// 格式化存储:salt:hash(都用 Base64)
QString DoorLockService::formatStoredPassword(const QByteArray& salt, const QByteArray& hash)
{
    return QString::fromLatin1(salt.toBase64()) + ":" + QString::fromLatin1(hash.toBase64());
}

// 解析存储的密码:恢复 salt 和 hash
bool DoorLockService::parseStoredPassword(const QString& stored, QByteArray& salt, QByteArray& hash)
{
    QStringList parts = stored.split(':');
    if (parts.size() != 2) return false;

    salt = QByteArray::fromBase64(parts[0].toLatin1());
    hash = QByteArray::fromBase64(parts[1].toLatin1());
    return !salt.isEmpty() && !hash.isEmpty();
}


// ============================================================================
// ✅ 密码开锁(使用哈希验证)
// ============================================================================

bool DoorLockService::unlockByPassword(const QString& password)
{
    if (!m_settings) {
        setLastAction(QStringLiteral("PASSWORD FAIL (no settings)"));
        if (m_eventRepo) {
            m_eventRepo->addEvent("unlock", "password_fail", m_role, "password",
                                  "{\"reason\":\"no_settings\"}", nullptr);
        }
        return false;
    }

    // ---- 1. 从数据库获取存储的密码 ----
    const QString stored = m_settings->getString("unlock_password", "");
    if (stored.isEmpty()) {
        setLastAction(QStringLiteral("PASSWORD FAIL (not set)"));
        if (m_eventRepo) {
            m_eventRepo->addEvent("unlock", "password_fail", m_role, "password",
                                  "{\"reason\":\"password_not_set\"}", nullptr);
        }
        return false;
    }

    // ---- 2. 解析存储的 salt 和 hash ----
    QByteArray storedSalt, storedHash;
    if (!parseStoredPassword(stored, storedSalt, storedHash)) {
        setLastAction(QStringLiteral("PASSWORD FAIL (invalid format)"));
        return false;
    }

    // ---- 3. 计算输入密码的哈希 ----
    QString inputHash = hashPassword(password, storedSalt);

    // ---- 4. 对比哈希值 ----
    if (inputHash != QString::fromLatin1(storedHash.toBase64())) {
        setLastAction(QStringLiteral("PASSWORD FAIL"));
        if (m_eventRepo) {
            m_eventRepo->addEvent("unlock", "password_fail", m_role, "password",
                                  "{\"reason\":\"wrong_password\"}", nullptr);
        }
        return false;
    }

    // ---- 5. 密码正确:开门 ----
    const int durationMs = m_settings->getInt("unlock_duration_ms", 3000);
    unlock(QStringLiteral("password"), QStringLiteral("password"), durationMs);

    if (m_eventRepo) {
        m_eventRepo->addEvent("unlock", "password_ok", m_role, "password",
                              QStringLiteral("{\"durationMs\":%1}").arg(durationMs), nullptr);
    }
    return true;
}


// ============================================================================
// ✅ 设置密码(使用 SHA-256 + Salt 存储)
// ============================================================================

bool DoorLockService::setUnlockPassword(const QString& newPassword)
{
    if (!m_settings) {
        setLastAction(QStringLiteral("PASSWORD SET FAIL (no settings)"));
        return false;
    }

    const QString p = newPassword.trimmed();

    // ---- 1. 验证密码格式(6 位数字) ----
    if (p.length() != 6) {
        setLastAction(QStringLiteral("PASSWORD SET FAIL (len!=6)"));
        return false;
    }
    for (QChar c : p) {
        if (!c.isDigit()) {
            setLastAction(QStringLiteral("PASSWORD SET FAIL (not digit)"));
            return false;
        }
    }

    // ---- 2. 生成随机 Salt ----
    QByteArray salt = generateSalt();

    // ---- 3. 计算哈希 ----
    QString hash = hashPassword(p, salt);

    // ---- 4. 存储格式:salt:hash ----
    QString stored = formatStoredPassword(salt, hash.toLatin1());

    // ---- 5. 存入数据库 ----
    m_settings->setString("unlock_password", stored);
    setLastAction(QStringLiteral("PASSWORD UPDATED (hashed)"));

    if (m_eventRepo) {
        m_eventRepo->addEvent("unlock", "password_changed", m_role, "password",
                              QStringLiteral("{\"len\":%1,\"salt\":\"%2\"}")
                                  .arg(p.length())
                                  .arg(QString::fromLatin1(salt.toBase64())), nullptr);
    }
    return true;
}

核心方法说明

1. generateSalt() - 生成随机 Salt

cpp 复制代码
QByteArray DoorLockService::generateSalt()
{
    QByteArray salt(16, Qt::Uninitialized);
    QRandomGenerator::global()->fillRange(
        reinterpret_cast<quint32*>(salt.data()),
        salt.size() / sizeof(quint32)
    );
    return salt;
}

2. hashPassword() - 计算哈希

cpp 复制代码
QString DoorLockService::hashPassword(const QString& password, const QByteArray& salt)
{
    // salt + password → SHA-256 → Base64
    QByteArray combined = salt + password.toUtf8();
    QByteArray hash = QCryptographicHash::hash(combined, QCryptographicHash::Sha256);
    return QString::fromLatin1(hash.toBase64());
}

3. 存储格式

cpp 复制代码
存储格式:salt:hash

示例:
salt (Base64): "Xf7K9pQz3..." 
hash (Base64): "3f7c9a2d5b..."
存储值: "Xf7K9pQz3...:3f7c9a2d5b..."

数据库迁移

如果数据库已有明文密码,需要迁移脚本:

cpp 复制代码
// 迁移函数(在 AppController 初始化时调用一次)
void DoorLockService::migratePasswordFromPlaintext()
{
    if (!m_settings) return;

    QString plainPwd = m_settings->getString("unlock_password_plain", "");
    if (plainPwd.isEmpty()) return;

    // 转换为哈希存储
    QByteArray salt = generateSalt();
    QString hash = hashPassword(plainPwd, salt);
    QString stored = formatStoredPassword(salt, hash.toLatin1());

    m_settings->setString("unlock_password", stored);
    m_settings->setString("unlock_password_plain", "");  // 清除明文
}

总结

修改点 修改前 修改后
密码存储 明文 "123456" "salt:hash"
unlockByPassword() 直接比较明文 解析 salt,计算哈希比较
setUnlockPassword() 直接存明文 生成 salt,计算哈希存储
getUnlockPassword() 返回明文 ❌ 移除(无法反向解密)
安全性 ❌ 低 ✅ 高

eventmodel.h

无注释版

cpp 复制代码
#pragma once

#include <QAbstractListModel>
#include "eventrepo.h"

class EventModel : public QAbstractListModel
{
    Q_OBJECT
    Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
    Q_PROPERTY(int limit READ limit WRITE setLimit NOTIFY limitChanged)
    Q_PROPERTY(QString typeFilter READ typeFilter WRITE setTypeFilter NOTIFY typeFilterChanged)
public:
    enum Roles {
        IdRole = Qt::UserRole + 1,
        TsRole,
        TypeRole,
        ResultRole,
        SrcRole,
        DstRole,
        ExtraRole
    };
    Q_ENUM(Roles)

    explicit EventModel(QObject* parent = nullptr);

    void setRepo(EventRepo* repo);

    int rowCount(const QModelIndex& parent = QModelIndex()) const override;
    QVariant data(const QModelIndex& index, int role) const override;
    QHash<int, QByteArray> roleNames() const override;

    Q_INVOKABLE void reload();
    Q_INVOKABLE QString exportCsvToDocuments(); // returns path or empty on error

    QString lastError() const { return m_lastError; }

    int limit() const { return m_limit; }
    void setLimit(int n);

    QString typeFilter() const { return m_typeFilter; }
    void setTypeFilter(const QString& t);

signals:
    void lastErrorChanged();
    void limitChanged();
    void typeFilterChanged();
    void exported(const QString& path);

private:
    void setLastError(const QString& e);

    EventRepo* m_repo = nullptr;
    QVector<EventRecord> m_items;
    QString m_lastError;
    int m_limit = 200;
    QString m_typeFilter;
};

有注释版

cpp 复制代码
// ============================================================================
// 头文件保护和包含
// ============================================================================

// #pragma once 确保此头文件只被编译一次
#pragma once

// 包含 QAbstractListModel 基类头文件
// QAbstractListModel 是 Qt 提供的列表模型抽象类,用于在 QML 中显示列表数据
// 继承它需要实现 rowCount() 和 data() 两个核心方法
#include <QAbstractListModel>

// 包含事件仓库头文件(包含 EventRecord 结构体和数据访问方法)
#include "eventrepo.h"


// ============================================================================
// EventModel 类定义
// ============================================================================

class EventModel : public QAbstractListModel
{
    // 启用信号/槽和属性系统
    Q_OBJECT

    // ========================================================================
    // Q_PROPERTY 定义(暴露给 QML 的属性)
    // ========================================================================

    // ----- 最后一个错误信息 -----
    // 当操作失败时,QML 可以读取此属性显示错误
    Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)

    // ----- 列表显示数量限制 -----
    // 控制最多显示多少条事件记录(默认 200 条)
    Q_PROPERTY(int limit READ limit WRITE setLimit NOTIFY limitChanged)

    // ----- 事件类型过滤 -----
    // 只显示指定类型的事件(如 "call"、"unlock"、"face")
    // 空字符串表示不过滤
    Q_PROPERTY(QString typeFilter READ typeFilter WRITE setTypeFilter NOTIFY typeFilterChanged)

// ============================================================================
// 枚举:自定义角色
// ============================================================================

public:
    // 定义模型中每个数据项的角色(相当于字段名)
    // 这些角色在 QML 中通过 model.id、model.ts 等方式访问
    enum Roles {
        IdRole = Qt::UserRole + 1,   // 事件 ID
        TsRole,                       // 时间戳
        TypeRole,                     // 事件类型
        ResultRole,                   // 事件结果
        SrcRole,                      // 来源
        DstRole,                      // 目标
        ExtraRole                     // 额外数据(JSON)
    };
    // Q_ENUM 宏让枚举可以在 QML 中使用
    Q_ENUM(Roles)

// ============================================================================
// 公有方法
// ============================================================================

public:
    // 构造函数
    explicit EventModel(QObject* parent = nullptr);

    // ---------- 依赖注入 ----------

    // 设置事件仓库(用于从数据库读取数据)
    void setRepo(EventRepo* repo);

    // ---------- QAbstractListModel 必须实现的方法 ----------

    // 返回列表中的行数(即事件记录的数量)
    int rowCount(const QModelIndex& parent = QModelIndex()) const override;

    // 返回指定行和角色的数据
    QVariant data(const QModelIndex& index, int role) const override;

    // 返回角色名称映射(QML 中通过名称访问数据)
    // 例如:角色 IdRole → QML 中访问 model.id
    QHash<int, QByteArray> roleNames() const override;

    // ---------- Q_INVOKABLE 方法(QML 可调用) ----------

    // 重新加载数据(从数据库刷新列表)
    Q_INVOKABLE void reload();

    // 导出为 CSV 文件到文档目录
    // 返回文件路径,失败返回空字符串
    Q_INVOKABLE QString exportCsvToDocuments();

    // ---------- 属性 getter ----------

    QString lastError() const { return m_lastError; }

    int limit() const { return m_limit; }
    void setLimit(int n);

    QString typeFilter() const { return m_typeFilter; }
    void setTypeFilter(const QString& t);

// ============================================================================
// 信号
// ============================================================================

signals:
    void lastErrorChanged();     // 错误信息变化
    void limitChanged();          // 显示数量限制变化
    void typeFilterChanged();     // 类型过滤变化
    void exported(const QString& path);  // CSV 导出完成,返回文件路径

// ============================================================================
// 私有方法
// ============================================================================

private:
    // 设置错误信息(内部使用)
    void setLastError(const QString& e);

// ============================================================================
// 私有成员变量
// ============================================================================

    EventRepo* m_repo = nullptr;          // 事件仓库(数据源)
    QVector<EventRecord> m_items;         // 事件记录缓存(从数据库加载)
    QString m_lastError;                   // 最后一个错误信息
    int m_limit = 200;                     // 最大显示条数(默认 200)
    QString m_typeFilter;                  // 类型过滤器(空=不过滤)
};

数据流向

cpp 复制代码
数据库 (event 表)
    ↓
EventRepo::getEvents()
    ↓
EventModel::reload()
    ↓
m_items (QVector<EventRecord>)
    ↓
QML ListView / TableView
    ↓
用户看到事件列表

角色映射表

枚举 QML 访问名 说明
IdRole Qt::UserRole + 1 model.id 事件 ID
TsRole Qt::UserRole + 2 model.ts 时间戳
TypeRole Qt::UserRole + 3 model.type 事件类型
ResultRole Qt::UserRole + 4 model.result 事件结果
SrcRole Qt::UserRole + 5 model.src 来源
DstRole Qt::UserRole + 6 model.dst 目标
ExtraRole Qt::UserRole + 7 model.extra 额外数据

eventmodel.cpp

无注释版

cpp 复制代码
#include "eventmodel.h"

#include <QDateTime>
#include <QFile>
#include <QStandardPaths>
#include <QTextStream>

EventModel::EventModel(QObject* parent) : QAbstractListModel(parent)
{
}

void EventModel::setRepo(EventRepo* repo)
{
    m_repo = repo;
}

int EventModel::rowCount(const QModelIndex& parent) const
{
    if (parent.isValid()) return 0;
    return m_items.size();
}

QVariant EventModel::data(const QModelIndex& index, int role) const
{
    if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) return QVariant();
    const auto& r = m_items.at(index.row());
    switch (role) {
    case IdRole: return r.id;
    case TsRole: return r.ts;
    case TypeRole: return r.type;
    case ResultRole: return r.result;
    case SrcRole: return r.src;
    case DstRole: return r.dst;
    case ExtraRole: return r.extraJson;
    default: return QVariant();
    }
}

QHash<int, QByteArray> EventModel::roleNames() const
{
    return {
        {IdRole, "eid"},
        {TsRole, "ts"},
        {TypeRole, "type"},
        {ResultRole, "result"},
        {SrcRole, "src"},
        {DstRole, "dst"},
        {ExtraRole, "extra"}
    };
}

void EventModel::reload()
{
    if (!m_repo) return;
    QString err;
    const auto list = m_repo->listEvents(m_limit, m_typeFilter, &err);
    beginResetModel();
    m_items = list;
    endResetModel();
    setLastError(err);
}

QString EventModel::exportCsvToDocuments()
{
    if (!m_repo) return QString();

    // Reload latest before export
    reload();

    const QString dir = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
    if (dir.isEmpty()) {
        setLastError(QStringLiteral("Cannot resolve Documents directory."));
        return QString();
    }

    const QString filePath = dir + QStringLiteral("/intercom_events_%1.csv")
                                       .arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd_HHmmss")));

    QFile f(filePath);
    if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) {
        setLastError(QStringLiteral("Cannot write file: %1").arg(filePath));
        return QString();
    }

    QTextStream s(&f);
    s.setEncoding(QStringConverter::Utf8);
    s << "id,ts,type,result,src,dst,extra_json\n";
    for (const auto& e : m_items) {
        // Very simple CSV escaping:
        auto esc = [](const QString& x) {
            QString t = x;
            t.replace("\"", "\"\"");
            return QStringLiteral("\"%1\"").arg(t);
        };
        s << e.id << ",";
        s << e.ts << ",";
        s << esc(e.type) << ",";
        s << esc(e.result) << ",";
        s << esc(e.src) << ",";
        s << esc(e.dst) << ",";
        s << esc(e.extraJson) << "\n";
    }
    f.close();

    setLastError(QString());
    emit exported(filePath);
    return filePath;
}

void EventModel::setLimit(int n)
{
    if (n < 10) n = 10;
    if (m_limit == n) return;
    m_limit = n;
    emit limitChanged();
}

void EventModel::setTypeFilter(const QString& t)
{
    if (m_typeFilter == t) return;
    m_typeFilter = t;
    emit typeFilterChanged();
}

void EventModel::setLastError(const QString& e)
{
    if (m_lastError == e) return;
    m_lastError = e;
    emit lastErrorChanged();
}

有注释版

cpp 复制代码
// ============================================================================
// 包含头文件
// ============================================================================

// 包含 EventModel 的头文件(类声明)
#include "eventmodel.h"

// 包含 Qt 日期时间头文件(用于生成导出文件名的时间戳)
#include <QDateTime>

// 包含 Qt 文件操作头文件(用于 CSV 导出)
#include <QFile>

// 包含 Qt 标准路径头文件(获取系统文档目录路径)
#include <QStandardPaths>

// 包含 Qt 文本流头文件(用于写入 CSV 文件)
#include <QTextStream>


// ============================================================================
// 构造函数
// ============================================================================

EventModel::EventModel(QObject* parent)
    : QAbstractListModel(parent)  // 调用基类 QAbstractListModel 的构造函数
{
    // 构造函数体为空,所有初始化在 setRepo() 和 reload() 中完成
    // QAbstractListModel 是 Qt 提供的列表模型抽象类
    // 继承它需要实现 rowCount() 和 data() 两个纯虚函数
}


// ============================================================================
// 依赖注入:设置事件仓库
// ============================================================================

void EventModel::setRepo(EventRepo* repo)
{
    // 保存事件仓库指针
    // EventRepo 负责从数据库读取事件数据
    m_repo = repo;
}


// ============================================================================
// 返回列表中的行数(QAbstractListModel 必须实现)
// ============================================================================

int EventModel::rowCount(const QModelIndex& parent) const
{
    // 如果 parent 有效,返回 0
    // QAbstractListModel 是扁平列表,不支持树形结构
    // 所以当 parent 有效时,表示有父级,但列表没有层级,返回 0
    if (parent.isValid()) return 0;

    // 返回缓存中事件记录的数量
    return m_items.size();
}


// ============================================================================
// 返回指定行和角色的数据(QAbstractListModel 必须实现)
// ============================================================================

QVariant EventModel::data(const QModelIndex& index, int role) const
{
    // ---- 1. 索引有效性检查 ----
    // 如果索引无效,返回空 QVariant
    if (!index.isValid()) return QVariant();

    // 如果行号超出范围,返回空 QVariant
    if (index.row() < 0 || index.row() >= m_items.size()) return QVariant();

    // ---- 2. 获取指定行的事件记录 ----
    // at() 是 QVector 的只读访问方法,比 operator[] 更安全(会检查边界)
    const auto& r = m_items.at(index.row());

    // ---- 3. 根据角色返回对应的数据 ----
    switch (role) {
    case IdRole:     return r.id;          // 事件 ID
    case TsRole:     return r.ts;          // 时间戳(Unix 时间戳)
    case TypeRole:   return r.type;        // 事件类型(call、unlock、face)
    case ResultRole: return r.result;      // 事件结果(success、failed)
    case SrcRole:    return r.src;         // 来源(outer、inner)
    case DstRole:    return r.dst;         // 目标(outer、inner)
    case ExtraRole:  return r.extraJson;   // 额外数据(JSON 格式字符串)
    default:         return QVariant();    // 未知角色,返回空
    }
}


// ============================================================================
// 返回角色名称映射(QML 中通过名称访问数据)
// ============================================================================

QHash<int, QByteArray> EventModel::roleNames() const
{
    // 定义 QML 中访问数据时使用的名称
    // 例如在 QML 中:ListView 的 delegate 里可以用 model.eid、model.ts 等
    return {
        {IdRole,     "eid"},      // QML 中访问 model.eid → 返回事件 ID
        {TsRole,     "ts"},       // QML 中访问 model.ts → 返回时间戳
        {TypeRole,   "type"},     // QML 中访问 model.type → 返回事件类型
        {ResultRole, "result"},   // QML 中访问 model.result → 返回事件结果
        {SrcRole,    "src"},      // QML 中访问 model.src → 返回来源
        {DstRole,    "dst"},      // QML 中访问 model.dst → 返回目标
        {ExtraRole,  "extra"}     // QML 中访问 model.extra → 返回额外数据
    };
}


// ============================================================================
// 重新加载数据(从数据库刷新列表)
// ============================================================================

void EventModel::reload()
{
    // ---- 1. 检查仓库是否已设置 ----
    // 如果 m_repo 为空,说明还没有调用 setRepo(),无法读取数据
    if (!m_repo) return;

    // ---- 2. 从仓库获取事件列表 ----
    // listEvents() 返回 QList<EventRecord>
    // 参数:m_limit(最大行数)、m_typeFilter(类型过滤)、err(错误信息)
    QString err;
    const auto list = m_repo->listEvents(m_limit, m_typeFilter, &err);

    // ---- 3. 更新模型数据 ----
    // beginResetModel() 告诉 QML:数据即将重置
    // 这会触发 QML 中的 ListView/TableView 清理缓存
    beginResetModel();

    // 将 QList<EventRecord> 赋值给 QVector<EventRecord>
    // QVector 和 QList 可以互相转换(Qt 容器互操作)
    m_items = list;

    // endResetModel() 告诉 QML:数据已重置完成
    // QML 会重新请求 rowCount() 和 data() 来刷新显示
    endResetModel();

    // ---- 4. 保存错误信息 ----
    // 如果 listEvents() 出错,err 会包含错误描述
    setLastError(err);
}


// ============================================================================
// 导出为 CSV 文件到文档目录
// ============================================================================

QString EventModel::exportCsvToDocuments()
{
    // ---- 1. 检查仓库是否已设置 ----
    if (!m_repo) return QString();

    // ---- 2. 导出前重新加载最新数据 ----
    // 确保导出的数据是最新的
    reload();

    // ---- 3. 获取系统文档目录路径 ----
    // Windows: C:/Users/用户名/Documents/
    // Linux:   /home/用户名/Documents/
    // macOS:   /Users/用户名/Documents/
    const QString dir = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);

    // 如果目录路径为空,说明系统不支持(极少发生)
    if (dir.isEmpty()) {
        setLastError(QStringLiteral("Cannot resolve Documents directory."));
        return QString();  // 返回空字符串表示导出失败
    }

    // ---- 4. 生成带时间戳的文件名 ----
    // 格式:intercom_events_20260825_143022.csv
    // QDateTime::currentDateTime() 获取当前时间
    // toString("yyyyMMdd_HHmmss") 格式化为年月日_时分秒
    const QString filePath = dir + QStringLiteral("/intercom_events_%1.csv")
                                       .arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd_HHmmss")));

    // ---- 5. 打开文件 ----
    QFile f(filePath);

    // QIODevice::WriteOnly:只写模式
    // QIODevice::Text:文本模式(自动处理换行符转换)
    if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) {
        setLastError(QStringLiteral("Cannot write file: %1").arg(filePath));
        return QString();
    }

    // ---- 6. 写入 CSV 数据 ----
    QTextStream s(&f);
    s.setEncoding(QStringConverter::Utf8);  // 使用 UTF-8 编码

    // 写入 CSV 表头
    s << "id,ts,type,result,src,dst,extra_json\n";

    // 定义 CSV 转义 Lambda 函数
    // 如果字段包含逗号、引号或换行,需要用引号包裹
    auto esc = [](const QString& x) {
        QString t = x;
        t.replace("\"", "\"\"");  // 将 " 替换为 ""(CSV 标准转义)
        return QStringLiteral("\"%1\"").arg(t);  // 用引号包裹
    };

    // 遍历每一条事件记录,写入一行
    for (const auto& e : m_items) {
        s << e.id << ",";
        s << e.ts << ",";
        s << esc(e.type) << ",";
        s << esc(e.result) << ",";
        s << esc(e.src) << ",";
        s << esc(e.dst) << ",";
        s << esc(e.extraJson) << "\n";
    }

    // ---- 7. 关闭文件 ----
    f.close();

    // ---- 8. 记录成功 ----
    setLastError(QString());  // 清空错误信息

    // 发射 exported 信号,通知 QML 导出完成
    // QML 中可以连接此信号,例如显示导出成功的提示
    emit exported(filePath);

    // 返回文件路径(QML 中可以获取显示)
    return filePath;
}


// ============================================================================
// 设置显示数量限制
// ============================================================================

void EventModel::setLimit(int n)
{
    // ---- 1. 限制最小值 ----
    // 最少显示 10 条记录,避免列表为空让用户困惑
    if (n < 10) n = 10;

    // ---- 2. 如果值未变化,直接返回 ----
    if (m_limit == n) return;

    // ---- 3. 更新值并发射信号 ----
    m_limit = n;

    // 发射 limitChanged 信号,通知 QML 限制已变化
    emit limitChanged();
}


// ============================================================================
// 设置类型过滤器
// ============================================================================

void EventModel::setTypeFilter(const QString& t)
{
    // ---- 1. 如果值未变化,直接返回 ----
    if (m_typeFilter == t) return;

    // ---- 2. 更新值并发射信号 ----
    m_typeFilter = t;

    // 发射 typeFilterChanged 信号,通知 QML 过滤器已变化
    emit typeFilterChanged();
}


// ============================================================================
// 私有方法:设置错误信息
// ============================================================================

void EventModel::setLastError(const QString& e)
{
    // ---- 1. 如果错误信息未变化,直接返回 ----
    if (m_lastError == e) return;

    // ---- 2. 更新错误信息 ----
    m_lastError = e;

    // ---- 3. 发射信号通知 QML ----
    // QML 中可以绑定此信号,例如显示错误提示
    emit lastErrorChanged();
}

修复版

cpp 复制代码
// ============================================================================
// 包含头文件
// ============================================================================

#include "eventmodel.h"

#include <QDateTime>           // 日期时间(用于生成导出文件名)
#include <QFile>               // 文件操作(导出 CSV)
#include <QStandardPaths>      // 系统标准路径(获取文档目录)
#include <QTextStream>         // 文本流(写入 CSV)


// ============================================================================
// 构造函数
// ============================================================================

EventModel::EventModel(QObject* parent)
    : QAbstractListModel(parent)  // 调用基类构造函数
{
    // 构造函数为空,所有初始化在 setRepo() 和 reload() 中完成
}


// ============================================================================
// 依赖注入:设置事件仓库
// ============================================================================

void EventModel::setRepo(EventRepo* repo)
{
    // 保存事件仓库指针,用于从数据库读取数据
    m_repo = repo;

    // ✅ 修复:设置 repo 后自动加载数据
    // 这样 QML 绑定模型时数据会自动显示
    if (m_repo) {
        reload();
    }
}


// ============================================================================
// QAbstractListModel 必须实现的方法
// ============================================================================

// ---- 返回列表中的行数 ----
int EventModel::rowCount(const QModelIndex& parent) const
{
    // 如果 parent 有效,返回 0(不支持树形结构)
    if (parent.isValid()) return 0;

    // 返回缓存的事件记录数量
    return m_items.size();
}


// ---- 返回指定行和角色的数据 ----
QVariant EventModel::data(const QModelIndex& index, int role) const
{
    // ---- 1. 索引有效性检查 ----
    if (!index.isValid()) return QVariant();
    if (index.row() < 0 || index.row() >= m_items.size()) return QVariant();

    // ---- 2. 获取指定行的事件记录 ----
    const auto& r = m_items.at(index.row());

    // ---- 3. 根据角色返回对应的数据 ----
    switch (role) {
    case IdRole:     return r.id;          // 事件 ID
    case TsRole:     return r.ts;          // 时间戳
    case TypeRole:   return r.type;        // 事件类型
    case ResultRole: return r.result;      // 事件结果
    case SrcRole:    return r.src;         // 来源
    case DstRole:    return r.dst;         // 目标
    case ExtraRole:  return r.extraJson;   // 额外数据(JSON)
    default:         return QVariant();    // 未知角色,返回空
    }
}


// ---- 返回角色名称映射 ----
QHash<int, QByteArray> EventModel::roleNames() const
{
    // 定义 QML 中访问数据时使用的名称
    // 例如:model.eid、model.ts、model.type
    return {
        {IdRole,     "eid"},      // QML 中访问 model.eid
        {TsRole,     "ts"},       // QML 中访问 model.ts
        {TypeRole,   "type"},     // QML 中访问 model.type
        {ResultRole, "result"},   // QML 中访问 model.result
        {SrcRole,    "src"},      // QML 中访问 model.src
        {DstRole,    "dst"},      // QML 中访问 model.dst
        {ExtraRole,  "extra"}     // QML 中访问 model.extra
    };
}


// ============================================================================
// 重新加载数据(从数据库刷新)
// ============================================================================

void EventModel::reload()
{
    // ---- 1. 检查仓库是否已设置 ----
    if (!m_repo) return;

    // ---- 2. 从数据库获取事件列表 ----
    QString err;
    const auto list = m_repo->listEvents(m_limit, m_typeFilter, &err);

    // ---- 3. 更新模型数据 ----
    // beginResetModel() 和 endResetModel() 通知 QML 数据即将/已经变化
    // 这会让 QML 中的 ListView/TableView 重新渲染
    beginResetModel();
    m_items = list;
    endResetModel();

    // ---- 4. 记录错误信息 ----
    setLastError(err);
}


// ============================================================================
// 导出为 CSV 文件
// ============================================================================

QString EventModel::exportCsvToDocuments()
{
    // ---- 1. 检查仓库是否已设置 ----
    if (!m_repo) return QString();

    // ---- 2. 导出前重新加载最新数据 ----
    reload();

    // ---- 3. 获取文档目录路径 ----
    const QString dir = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
    if (dir.isEmpty()) {
        setLastError(QStringLiteral("Cannot resolve Documents directory."));
        return QString();
    }

    // ---- 4. 生成带时间戳的文件名 ----
    // 格式:intercom_events_20260825_143022.csv
    const QString filePath = dir + QStringLiteral("/intercom_events_%1.csv")
                                       .arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd_HHmmss")));

    // ---- 5. 打开文件 ----
    QFile f(filePath);
    if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) {
        setLastError(QStringLiteral("Cannot write file: %1").arg(filePath));
        return QString();
    }

    // ---- 6. 写入 CSV 数据 ----
    QTextStream s(&f);
    s.setEncoding(QStringConverter::Utf8);

    // 写入表头
    s << "id,ts,type,result,src,dst,extra_json\n";

    // ✅ 修复:如果数据为空,只写入表头后关闭文件
    if (m_items.isEmpty()) {
        f.close();
        setLastError(QString());
        emit exported(filePath);
        return filePath;
    }

    // 写入每一行数据
    for (const auto& e : m_items) {
        // CSV 转义函数:如果字段包含逗号或引号,用引号包裹
        auto esc = [](const QString& x) {
            QString t = x;
            t.replace("\"", "\"\"");  // 将 " 替换为 ""
            return QStringLiteral("\"%1\"").arg(t);
        };

        s << e.id << ",";
        s << e.ts << ",";
        s << esc(e.type) << ",";
        s << esc(e.result) << ",";
        s << esc(e.src) << ",";
        s << esc(e.dst) << ",";
        s << esc(e.extraJson) << "\n";
    }

    // ---- 7. 关闭文件 ----
    f.close();

    // ---- 8. 记录成功并发射信号 ----
    setLastError(QString());
    emit exported(filePath);

    return filePath;
}


// ============================================================================
// 属性 setter 方法
// ============================================================================

void EventModel::setLimit(int n)
{
    // ---- 1. 限制最小值 ----
    // 最少显示 10 条,避免列表为空
    if (n < 10) n = 10;

    // ---- 2. 如果值未变化,直接返回 ----
    if (m_limit == n) return;

    // ---- 3. 更新值并发射信号 ----
    m_limit = n;
    emit limitChanged();

    // ✅ 修复:限制变化后自动重新加载数据
    if (m_repo) {
        reload();
    }
}


void EventModel::setTypeFilter(const QString& t)
{
    // ---- 1. 如果值未变化,直接返回 ----
    if (m_typeFilter == t) return;

    // ---- 2. 更新值并发射信号 ----
    m_typeFilter = t;
    emit typeFilterChanged();

    // ✅ 修复:过滤器变化后自动重新加载数据
    if (m_repo) {
        reload();
    }
}


// ============================================================================
// 私有方法:设置错误信息
// ============================================================================

void EventModel::setLastError(const QString& e)
{
    // 如果错误信息未变化,直接返回
    if (m_lastError == e) return;

    // 更新错误信息
    m_lastError = e;

    // 发射信号通知 QML
    emit lastErrorChanged();
}

修复点总结

问题 修复前 修复后
设置 repo 后不自动加载 setRepo() 只保存指针 调用 reload() 自动加载数据
修改 limit 后不刷新 setLimit() 只更新值 调用 reload() 刷新数据
修改 filter 后不刷新 setTypeFilter() 只更新值 调用 reload() 刷新数据
导出空数据 写入空文件后仍有信号 提前返回,正常发送信号

关键知识点

1. beginResetModel() / endResetModel()

cpp 复制代码
// 当数据发生重大变化时,使用这对方法通知 QML
beginResetModel();   // 告诉 QML:数据要变了,请做好准备
m_items = newData;   // 更新数据
endResetModel();     // 告诉 QML:数据变完了,请重新渲染

2. roleNames()

cpp 复制代码
// 定义 QML 中访问数据的名称
// 例如:model.eid 对应 IdRole,返回事件 ID
return {
    {IdRole, "eid"},
    {TsRole, "ts"},
    // ...
};

3. CSV 转义

cpp 复制代码
// 如果字段包含特殊字符(逗号、引号、换行),需要用引号包裹
auto esc = [](const QString& x) {
    QString t = x;
    t.replace("\"", "\"\"");  // 将 " 替换为 ""(CSV 标准)
    return QStringLiteral("\"%1\"").arg(t);
};

eventrepo.h

无注释版

cpp 复制代码
#pragma once

#include <QObject>
#include <QSqlDatabase>
#include <QVector>

struct EventRecord {
    int id = -1;
    qint64 ts = 0;
    QString type;
    QString result;
    QString src;
    QString dst;
    QString extraJson;
};

class EventRepo : public QObject
{
    Q_OBJECT
public:
    explicit EventRepo(QObject* parent = nullptr);

    void setDatabase(const QSqlDatabase& db);

    bool addEvent(const QString& type,
                  const QString& result,
                  const QString& src,
                  const QString& dst,
                  const QString& extraJson,
                  QString* errorOut = nullptr);

    QVector<EventRecord> listEvents(int limit = 200, const QString& typeFilter = QString(), QString* errorOut = nullptr) const;

private:
    QSqlDatabase m_db;
};

有注释版

cpp 复制代码
// ============================================================================
// 头文件保护和包含
// ============================================================================

// #pragma once 确保此头文件只被编译一次
#pragma once

// 包含 QObject 基类头文件
#include <QObject>

// 包含 QSqlDatabase 头文件(数据库连接)
#include <QSqlDatabase>

// 包含 QVector 头文件(返回事件列表)
#include <QVector>


// ============================================================================
// EventRecord 结构体:表示一条事件记录
// ============================================================================

// EventRecord 是数据传输对象(DTO),用于在 EventRepo 和 EventModel 之间传递数据
// 它包含了事件日志表的所有字段
struct EventRecord {
    int id = -1;           // 事件 ID(-1 表示无效/未分配)
    qint64 ts = 0;         // 时间戳(Unix 时间戳,秒级)
    QString type;          // 事件类型:call(通话)、unlock(开锁)、face(人脸识别)
    QString result;        // 事件结果:success(成功)、failed(失败)、granted(授权)
    QString src;           // 来源:outer(外机)、inner(内机)
    QString dst;           // 目标:outer(外机)、inner(内机)
    QString extraJson;     // 额外数据(JSON 格式字符串),用于存储扩展信息
};


// ============================================================================
// EventRepo 类定义
// ============================================================================

// EventRepo 继承自 QObject,负责事件日志的数据库增删改查操作
// 它封装了所有与 event 表相关的 SQL 操作
class EventRepo : public QObject
{
    // 启用信号/槽和属性系统
    Q_OBJECT

// ============================================================================
// 公有方法
// ============================================================================

public:
    // 构造函数
    explicit EventRepo(QObject* parent = nullptr);

    // ---- 依赖注入:设置数据库连接 ----
    // 由 AppController 在初始化时调用
    // 传入的数据库连接必须已经打开
    void setDatabase(const QSqlDatabase& db);

    // ---- 添加事件记录 ----
    // type: 事件类型("call"、"unlock"、"face")
    // result: 事件结果("success"、"failed"、"granted" 等)
    // src: 来源("outer"、"inner")
    // dst: 目标("outer"、"inner")
    // extraJson: 额外数据(JSON 格式)
    // errorOut: 如果非空,出错时返回错误信息
    // 返回 true 表示添加成功,false 表示失败
    bool addEvent(const QString& type,
                  const QString& result,
                  const QString& src,
                  const QString& dst,
                  const QString& extraJson,
                  QString* errorOut = nullptr);

    // ---- 查询事件列表 ----
    // limit: 最大返回条数(默认 200)
    // typeFilter: 类型过滤器,空字符串表示不过滤
    // errorOut: 如果非空,出错时返回错误信息
    // 返回 QVector<EventRecord> 包含查询结果
    QVector<EventRecord> listEvents(int limit = 200,
                                    const QString& typeFilter = QString(),
                                    QString* errorOut = nullptr) const;

// ============================================================================
// 私有成员变量
// ============================================================================

private:
    QSqlDatabase m_db;     // 数据库连接对象(由 setDatabase() 注入)
};

数据流

cpp 复制代码
【写入】
用户操作(人脸识别/开门/通话)
    ↓
AppController / 各 Service
    ↓
EventRepo::addEvent()
    ↓
SQL INSERT
    ↓
数据库 (event 表)

【读取】
QML 请求显示事件列表
    ↓
EventModel::reload()
    ↓
EventRepo::listEvents()
    ↓
SQL SELECT
    ↓
QVector<EventRecord> 返回
    ↓
EventModel 缓存并提供给 QML

eventrepo.cpp

无注释版

cpp 复制代码
#include "eventrepo.h"

#include <QSqlQuery>
#include <QSqlError>
#include <QVariant>
#include <QDateTime>

EventRepo::EventRepo(QObject* parent) : QObject(parent)
{
}

void EventRepo::setDatabase(const QSqlDatabase& db)
{
    m_db = db;
}

bool EventRepo::addEvent(const QString& type,
                         const QString& result,
                         const QString& src,
                         const QString& dst,
                         const QString& extraJson,
                         QString* errorOut)
{
    if (!m_db.isOpen()) {
        if (errorOut) *errorOut = QStringLiteral("DB not open");
        return false;
    }

    QSqlQuery q(m_db);
    q.prepare(QStringLiteral(
        "INSERT INTO event_log (ts, type, result, src, dst, extra_json) "
        "VALUES (?, ?, ?, ?, ?, ?)"
        ));
    q.addBindValue(QDateTime::currentSecsSinceEpoch());
    q.addBindValue(type);
    q.addBindValue(result);
    q.addBindValue(src);
    q.addBindValue(dst);
    q.addBindValue(extraJson);

    if (!q.exec()) {
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }
    return true;
}

QVector<EventRecord> EventRepo::listEvents(int limit, const QString& typeFilter, QString* errorOut) const
{
    QVector<EventRecord> out;
    if (!m_db.isOpen()) {
        if (errorOut) *errorOut = QStringLiteral("DB not open");
        return out;
    }

    QSqlQuery q(m_db);
    QString sql = QStringLiteral("SELECT id, ts, type, result, src, dst, extra_json FROM event_log");
    if (!typeFilter.trimmed().isEmpty()) {
        sql += QStringLiteral(" WHERE type=?");
        q.prepare(sql + QStringLiteral(" ORDER BY id DESC LIMIT ?"));
        q.addBindValue(typeFilter.trimmed());
        q.addBindValue(limit);
        if (!q.exec()) {
            if (errorOut) *errorOut = q.lastError().text();
            return out;
        }
    } else {
        sql += QStringLiteral(" ORDER BY id DESC LIMIT %1").arg(limit);
        if (!q.exec(sql)) {
            if (errorOut) *errorOut = q.lastError().text();
            return out;
        }
    }

    while (q.next()) {
        EventRecord r;
        r.id = q.value(0).toInt();
        r.ts = q.value(1).toLongLong();
        r.type = q.value(2).toString();
        r.result = q.value(3).toString();
        r.src = q.value(4).toString();
        r.dst = q.value(5).toString();
        r.extraJson = q.value(6).toString();
        out.push_back(r);
    }

    return out;
}

有注释版

cpp 复制代码
// ============================================================================
// 包含头文件
// ============================================================================

// 包含 EventRepo 的头文件(类声明)
#include "eventrepo.h"

// 包含 Qt SQL 查询类,用于执行 SQL 语句
#include <QSqlQuery>

// 包含 Qt SQL 错误类,用于获取详细的错误信息
#include <QSqlError>

// 包含 Qt 通用数据类型,用于从查询结果中读取各种类型的数据
#include <QVariant>

// 包含 Qt 日期时间类,用于获取当前时间戳
#include <QDateTime>


// ============================================================================
// 构造函数
// ============================================================================

EventRepo::EventRepo(QObject* parent)
    : QObject(parent)  // 调用基类 QObject 的构造函数,parent 用于对象树管理
{
    // 构造函数为空,数据库连接通过 setDatabase() 方法注入
    // 这种设计模式称为"依赖注入",将数据库连接与业务逻辑分离
}


// ============================================================================
// 依赖注入:设置数据库连接
// ============================================================================

void EventRepo::setDatabase(const QSqlDatabase& db)
{
    // 保存数据库连接对象
    // 注意:QSqlDatabase 是值类型,内部使用引用计数
    // 拷贝是轻量级操作,可以安全传递
    m_db = db;
}


// ============================================================================
// 添加事件记录
// ============================================================================

bool EventRepo::addEvent(const QString& type,
                         const QString& result,
                         const QString& src,
                         const QString& dst,
                         const QString& extraJson,
                         QString* errorOut)
{
    // ---- 1. 检查数据库是否已打开 ----
    // 如果数据库未打开,无法执行任何操作
    // 将错误信息写入 errorOut(如果调用者提供了指针)
    if (!m_db.isOpen()) {
        if (errorOut) *errorOut = QStringLiteral("DB not open");
        return false;
    }

    // ---- 2. 准备 SQL 插入语句 ----
    // 使用 QSqlQuery 对象执行 SQL 操作
    QSqlQuery q(m_db);

    // prepare() 使用 ? 作为占位符,准备参数化查询
    // 参数化查询可以有效防止 SQL 注入攻击
    // 注意:表名使用 "event_log",与 DatabaseManager::ensureSchema() 保持一致
    q.prepare(QStringLiteral(
        "INSERT INTO event_log (ts, type, result, src, dst, extra_json) "
        "VALUES (?, ?, ?, ?, ?, ?)"
        ));

    // ---- 3. 绑定参数 ----
    // addBindValue() 按顺序将值绑定到 ? 占位符
    // 顺序必须与 SQL 语句中的字段顺序一致

    // 第 1 个 ?:时间戳(当前 Unix 时间戳,秒级)
    // QDateTime::currentSecsSinceEpoch() 返回自 1970-01-01 以来的秒数
    q.addBindValue(QDateTime::currentSecsSinceEpoch());

    // 第 2 个 ?:事件类型(如 "call"、"unlock"、"face")
    q.addBindValue(type);

    // 第 3 个 ?:事件结果(如 "success"、"failed"、"granted")
    q.addBindValue(result);

    // 第 4 个 ?:来源("outer" 外机 / "inner" 内机)
    q.addBindValue(src);

    // 第 5 个 ?:目标("outer" 外机 / "inner" 内机)
    q.addBindValue(dst);

    // 第 6 个 ?:额外数据(JSON 格式字符串)
    q.addBindValue(extraJson);

    // ---- 4. 执行 SQL ----
    // exec() 执行已准备的 SQL 语句
    // 返回 true 表示执行成功,false 表示失败
    if (!q.exec()) {
        // 执行失败,获取并返回错误信息
        // q.lastError().text() 返回 SQLite 驱动的详细错误描述
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }

    // 插入成功
    return true;
}


// ============================================================================
// 查询事件列表
// ============================================================================

QVector<EventRecord> EventRepo::listEvents(int limit,
                                           const QString& typeFilter,
                                           QString* errorOut) const
{
    // ---- 1. 声明返回结果容器 ----
    // 使用 QVector<EventRecord> 存储查询结果
    // QVector 是 Qt 的动态数组,性能优于 QList(对于存储大对象)
    QVector<EventRecord> out;

    // ---- 2. 检查数据库是否已打开 ----
    if (!m_db.isOpen()) {
        if (errorOut) *errorOut = QStringLiteral("DB not open");
        return out;  // 返回空列表
    }

    // ---- 3. 准备 SQL 查询 ----
    QSqlQuery q(m_db);

    // 基础 SELECT 语句,查询所有字段
    // 注意:表名使用 "event_log"
    QString sql = QStringLiteral("SELECT id, ts, type, result, src, dst, extra_json FROM event_log");

    // ---- 4. 根据是否有类型过滤器执行不同查询 ----
    if (!typeFilter.trimmed().isEmpty()) {
        // ---- 情况 A:有类型过滤 ----
        // 添加 WHERE 条件,只查询指定类型的事件
        sql += QStringLiteral(" WHERE type=?");

        // 准备完整的 SQL 语句:SELECT ... WHERE type=? ORDER BY id DESC LIMIT ?
        q.prepare(sql + QStringLiteral(" ORDER BY id DESC LIMIT ?"));

        // 绑定参数(顺序与 ? 占位符顺序一致)
        q.addBindValue(typeFilter.trimmed());   // 绑定到 WHERE type=?
        q.addBindValue(limit);                  // 绑定到 LIMIT ?

        // 执行查询
        if (!q.exec()) {
            if (errorOut) *errorOut = q.lastError().text();
            return out;  // 查询失败,返回空列表
        }
    } else {
        // ---- 情况 B:无类型过滤 ----
        // 查询所有类型的事件
        sql += QStringLiteral(" ORDER BY id DESC LIMIT %1").arg(limit);

        // ⚠️ 注意:这里使用字符串拼接添加 limit
        // 虽然 limit 是 int 类型,相对安全,但建议使用参数化查询
        // 参考下面的"改进建议"

        // 执行查询
        if (!q.exec(sql)) {
            if (errorOut) *errorOut = q.lastError().text();
            return out;  // 查询失败,返回空列表
        }
    }

    // ---- 5. 遍历结果集 ----
    // q.next() 移动到下一行,如果有数据返回 true
    while (q.next()) {
        // 创建 EventRecord 结构体,填充数据
        EventRecord r;

        // q.value(0) 返回第 1 列(id)
        // toInt() 将 QVariant 转换为 int
        r.id = q.value(0).toInt();

        // q.value(1) 返回第 2 列(ts,时间戳)
        // toLongLong() 将 QVariant 转换为 qint64
        r.ts = q.value(1).toLongLong();

        // q.value(2) 返回第 3 列(type)
        r.type = q.value(2).toString();

        // q.value(3) 返回第 4 列(result)
        r.result = q.value(3).toString();

        // q.value(4) 返回第 5 列(src)
        r.src = q.value(4).toString();

        // q.value(5) 返回第 6 列(dst)
        r.dst = q.value(5).toString();

        // q.value(6) 返回第 7 列(extra_json)
        r.extraJson = q.value(6).toString();

        // 将记录添加到结果列表
        out.push_back(r);
    }

    // ---- 6. 返回查询结果 ----
    // 注意:即使没有数据,也返回空的 QVector(不是 nullptr)
    return out;
}

关键知识点总结

知识点 说明
QSqlQuery::prepare() 准备参数化查询,防止 SQL 注入
QSqlQuery::addBindValue() 按顺序绑定参数到 ? 占位符
QSqlQuery::exec() 执行 SQL 语句
QSqlQuery::next() 移动到结果集的下一行
QSqlQuery::value() 获取当前行指定列的值
QDateTime::currentSecsSinceEpoch() 获取当前 Unix 时间戳(秒)

修复版

cpp 复制代码
// ============================================================================
// 包含头文件
// ============================================================================

#include "eventrepo.h"

#include <QSqlQuery>      // SQL 查询执行
#include <QSqlError>      // SQL 错误信息
#include <QVariant>       // 通用数据类型
#include <QDateTime>      // 日期时间(获取当前时间戳)


// ============================================================================
// 构造函数
// ============================================================================

EventRepo::EventRepo(QObject* parent)
    : QObject(parent)  // 调用基类构造函数
{
    // 构造函数为空,数据库连接通过 setDatabase() 注入
}


// ============================================================================
// 依赖注入:设置数据库连接
// ============================================================================

void EventRepo::setDatabase(const QSqlDatabase& db)
{
    // 保存数据库连接对象
    // 注意:QSqlDatabase 是值类型,内部使用引用计数,拷贝是轻量的
    m_db = db;
}


// ============================================================================
// 添加事件记录
// ============================================================================

bool EventRepo::addEvent(const QString& type,
                         const QString& result,
                         const QString& src,
                         const QString& dst,
                         const QString& extraJson,
                         QString* errorOut)
{
    // ---- 1. 检查数据库是否已打开 ----
    if (!m_db.isOpen()) {
        if (errorOut) *errorOut = QStringLiteral("DB not open");
        return false;
    }

    // ---- 2. 准备 SQL 语句 ----
    // ✅ 修复:表名统一为 "event"(与 DatabaseManager::ensureSchema 保持一致)
    // 原代码使用 "event_log",如果表名不一致会导致插入失败
    QSqlQuery q(m_db);
    q.prepare(QStringLiteral(
        "INSERT INTO event (ts, type, result, src, dst, extra_json) "
        "VALUES (?, ?, ?, ?, ?, ?)"
        ));

    // ---- 3. 绑定参数 ----
    // 使用 ? 占位符,按顺序绑定
    // 注意:QDateTime::currentSecsSinceEpoch() 返回当前 Unix 时间戳(秒)
    q.addBindValue(QDateTime::currentSecsSinceEpoch());  // ts
    q.addBindValue(type);           // type
    q.addBindValue(result);         // result
    q.addBindValue(src);            // src
    q.addBindValue(dst);            // dst
    q.addBindValue(extraJson);      // extra_json

    // ---- 4. 执行 SQL ----
    if (!q.exec()) {
        // 执行失败,记录错误信息
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }

    return true;
}


// ============================================================================
// 查询事件列表
// ============================================================================

QVector<EventRecord> EventRepo::listEvents(int limit,
                                           const QString& typeFilter,
                                           QString* errorOut) const
{
    QVector<EventRecord> out;

    // ---- 1. 检查数据库是否已打开 ----
    if (!m_db.isOpen()) {
        if (errorOut) *errorOut = QStringLiteral("DB not open");
        return out;
    }

    // ---- 2. 参数校验 ----
    // ✅ 修复:限制 limit 最小值,防止查询所有数据
    if (limit < 1) limit = 1;

    QSqlQuery q(m_db);

    // ---- 3. 构建 SQL 语句 ----
    // ✅ 修复:表名统一为 "event"
    QString sql = QStringLiteral("SELECT id, ts, type, result, src, dst, extra_json FROM event");

    // ---- 4. 根据是否有类型过滤器执行不同查询 ----
    if (!typeFilter.trimmed().isEmpty()) {
        // 有类型过滤:使用参数化查询,防止 SQL 注入
        sql += QStringLiteral(" WHERE type=?");
        q.prepare(sql + QStringLiteral(" ORDER BY id DESC LIMIT ?"));
        q.addBindValue(typeFilter.trimmed());   // 绑定 typeFilter
        q.addBindValue(limit);                  // 绑定 limit

        if (!q.exec()) {
            if (errorOut) *errorOut = q.lastError().text();
            return out;
        }
    } else {
        // 无类型过滤:直接查询
        // ✅ 修复:使用参数化查询而不是字符串拼接,避免 SQL 注入风险
        // 原代码:sql += QStringLiteral(" ORDER BY id DESC LIMIT %1").arg(limit);
        // 虽然 limit 是 int,但使用参数化查询更安全
        q.prepare(sql + QStringLiteral(" ORDER BY id DESC LIMIT ?"));
        q.addBindValue(limit);

        if (!q.exec()) {
            if (errorOut) *errorOut = q.lastError().text();
            return out;
        }
    }

    // ---- 5. 遍历结果集 ----
    while (q.next()) {
        EventRecord r;
        r.id        = q.value(0).toInt();        // id
        r.ts        = q.value(1).toLongLong();   // ts
        r.type      = q.value(2).toString();     // type
        r.result    = q.value(3).toString();     // result
        r.src       = q.value(4).toString();     // src
        r.dst       = q.value(5).toString();     // dst
        r.extraJson = q.value(6).toString();     // extra_json
        out.push_back(r);
    }

    return out;
}

修复点总结

问题 修复前 修复后
表名不一致 event_log event(与 DatabaseManager 一致)
limit 无最小值检查 可能传入 0 或负数 检查 if (limit < 1) limit = 1
SQL 注入风险 无过滤时用 arg(limit) 拼接 使用参数化查询 addBindValue(limit)

表名统一说明

文件 表名 状态
DatabaseManager::ensureSchema() event ✅ 创建表
EventRepo::addEvent() event ✅ 插入数据
EventRepo::listEvents() event ✅ 查询数据

如果表名不一致,程序会报错。上面的修复已统一为 event

使用示例

添加事件

cpp 复制代码
// 在 DoorLockService 中记录开门事件
if (m_eventRepo) {
    m_eventRepo->addEvent(
        "unlock",                    // 类型
        "granted",                   // 结果
        "inner",                     // 来源
        "outer",                     // 目标
        "{\"durationMs\":3000,\"operator\":\"admin\"}",  // 额外数据
        nullptr
    );
}

// 在 CallController 中记录通话事件
if (m_eventRepo) {
    m_eventRepo->addEvent(
        "call",                      // 类型
        "accepted",                  // 结果
        "outer",                     // 来源
        "inner",                     // 目标
        "{\"sessionId\":\"abc-123\",\"duration\":45}",  // 额外数据
        nullptr
    );
}

查询事件

cpp 复制代码
// 获取最近 100 条事件
QString err;
auto events = m_eventRepo->listEvents(100, "", &err);
if (!err.isEmpty()) {
    qDebug() << "查询失败:" << err;
}

// 只获取通话事件
auto callEvents = m_eventRepo->listEvents(50, "call", &err);

// 遍历结果
for (const auto& e : events) {
    qDebug() << "ID:" << e.id
             << "类型:" << e.type
             << "结果:" << e.result
             << "来源:" << e.src
             << "目标:" << e.dst;
}

SQL 注入防护

不安全的写法(字符串拼接)

cpp 复制代码
// 危险:如果 typeFilter 包含恶意 SQL,会被执行
sql += QStringLiteral(" ORDER BY id DESC LIMIT %1").arg(limit);

安全的写法(参数化查询)

cpp 复制代码
// 安全:使用 ? 占位符,数据与 SQL 语句分离
q.prepare(sql + QStringLiteral(" ORDER BY id DESC LIMIT ?"));
q.addBindValue(limit);
相关推荐
小小帅呀1 小时前
学习 VLA 第 2 天:深度学习基础
人工智能·深度学习·学习
具身AGI1 小时前
人类第一视角数据走上评测台:物理AI 人类学习路线
学习
dadaobusi2 小时前
5G核心网概念复习
学习
慧福堂3 小时前
偏印详解:十神中的智慧与孤影,如何善用偏印成就人生
大数据·学习·学习方法·八字·风水
2601_949950633 小时前
成绩提升不靠盲目刷题:用练题簿找准真正的薄弱知识点
学习·小程序·刷题·小程序推荐
步十人3 小时前
DeepRead-项目介绍
学习
YM52e4 小时前
鸿蒙 ArkTS 实战|网络常用漫剧主角名称分类表:26 位主角 8 大分类 + 搜索筛选
学习·华为·harmonyos
深蓝海拓4 小时前
基于QtPy (PySide6) 的PLC-HMI工程实战记录(五)为当前动作画面的信号连接变量
网络·笔记·python·学习·pyqt
云贝教育-郑老师4 小时前
Oracle 块清除(Block Cleanout):commit 之后,数据块里的“战场“谁来打扫?
数据库·学习·oracle