Initial STM32 project upload

This commit is contained in:
2026-05-04 02:52:30 +08:00
parent a32baa96e3
commit 03301d5b45
56 changed files with 0 additions and 14560 deletions
-33
View File
@@ -506,8 +506,6 @@ uint8_t ESP8266_HttpGet(const char* path, char* response_buffer, uint16_t buffer
return ESP8266_FAIL; return ESP8266_FAIL;
} }
// ========== 兼容旧版本的函数 ==========
/** /**
* @brief 查询学生在指定时段是否已签到(服务器端查重) * @brief 查询学生在指定时段是否已签到(服务器端查重)
* @param student_id 学号 * @param student_id 学号
@@ -624,34 +622,3 @@ uint8_t ESP8266_DownloadTodayRecords(const char* slot_start, const char* slot_en
DEBUG_PRINT("[ESP8266] Downloaded %d records: %s\r\n", count, student_ids); DEBUG_PRINT("[ESP8266] Downloaded %d records: %s\r\n", count, student_ids);
return count; return count;
} }
uint8_t ESP8266_ConnectTCPServer(const char* ip, const char* port)
{
// 重定向到HTTP版本
return ESP8266_ConnectHTTPServer(ip, port);
}
uint8_t ESP8266_SendData(char* Data)
{
char cmd_buffer[32];
uint16_t data_len = strlen(Data);
DEBUG_PRINT("[ESP8266] Sending %d bytes (legacy mode)\r\n", data_len);
sprintf(cmd_buffer, "AT+CIPSEND=%d", data_len);
ESP8266_SendCmd(cmd_buffer);
uint32_t start_time = Get_Time_ms();
Serial_ClearRxBuffer();
while(Get_Time_ms() - start_time < 1000) {
if (strstr(Serial_GetRxBuffer(), ">") != NULL) {
Serial_SendString(Data);
Delay_ms(50);
return ESP8266_SUCCESS;
}
Delay_ms(10);
}
return ESP8266_FAIL;
}
-4
View File
@@ -91,8 +91,4 @@ uint8_t ESP8266_CheckDuplicate(const char* student_id, const char* slot_start, c
*/ */
uint8_t ESP8266_DownloadTodayRecords(const char* slot_start, const char* slot_end, char* student_ids, uint16_t buffer_size); uint8_t ESP8266_DownloadTodayRecords(const char* slot_start, const char* slot_end, char* student_ids, uint16_t buffer_size);
// 兼容旧版本(已废弃,建议使用HTTP版本)
uint8_t ESP8266_ConnectTCPServer(const char* ip, const char* port);
uint8_t ESP8266_SendData(char* Data);
#endif #endif
-243
View File
@@ -1,243 +0,0 @@
/*
* ESP8266 WiFi模块驱动(支持HTTP加密传输)
* 新增功能:
* 1. HTTP POST请求支持
* 2. 加密数据传输
* 3. 自动重连机制
*/
#include "esp8266.h"
#include "Serial.h"
#include "Delay.h"
#include <stdio.h>
#include <string.h>
// 外部配置(在main.c中定义)
extern const char* WIFI_SSID;
extern const char* WIFI_PASSWORD;
extern const char* HTTP_SERVER_IP;
extern const char* HTTP_SERVER_PORT;
extern const char* HTTP_API_TOKEN;
// 内部函数声明
static uint8_t ESP8266_SendCommand(const char* cmd, const char* expected, uint16_t timeout);
static void ESP8266_ClearBuffer(void);
/**
* @brief 发送AT命令并等待响应
*/
static uint8_t ESP8266_SendCommand(const char* cmd, const char* expected, uint16_t timeout)
{
Serial_SendString(cmd);
Serial_SendString("\r\n");
Delay_ms(timeout);
if (expected == NULL) {
return ESP8266_SUCCESS;
}
// 检查是否收到期望的响应
char* response = Serial_GetRxData();
if (strstr(response, expected) != NULL) {
return ESP8266_SUCCESS;
}
return ESP8266_FAIL;
}
static void ESP8266_ClearBuffer(void)
{
Serial_ClearRxBuffer();
}
uint8_t ESP8266_Setup(void)
{
ESP8266_ClearBuffer();
// 测试AT指令
if (ESP8266_SendCommand("AT", "OK", 1000) == ESP8266_FAIL) {
return ESP8266_FAIL;
}
// 设置为STA模式(客户端模式)
if (ESP8266_SendCommand("AT+CWMODE=1", "OK", 1000) == ESP8266_FAIL) {
return ESP8266_FAIL;
}
// 关闭回显
ESP8266_SendCommand("ATE0", "OK", 500);
// 设置单连接模式
if (ESP8266_SendCommand("AT+CIPMUX=0", "OK", 1000) == ESP8266_FAIL) {
return ESP8266_FAIL;
}
return ESP8266_SUCCESS;
}
uint8_t ESP8266_ConnectWiFi(const char* ssid, const char* password)
{
char cmd[128];
ESP8266_ClearBuffer();
// 断开之前的WiFi连接
ESP8266_SendCommand("AT+CWQAP", "OK", 1000);
Delay_ms(1000);
// 连接到WiFi
sprintf(cmd, "AT+CWJAP=\"%s\",\"%s\"", ssid, password);
// WiFi连接可能需要较长时间
if (ESP8266_SendCommand(cmd, "OK", 10000) == ESP8266_FAIL) {
return ESP8266_FAIL;
}
Delay_ms(2000);
return ESP8266_SUCCESS;
}
uint8_t ESP8266_ConnectHTTPServer(const char* ip, const char* port)
{
char cmd[64];
ESP8266_ClearBuffer();
// 断开之前的TCP连接
ESP8266_SendCommand("AT+CIPCLOSE", "OK", 1000);
Delay_ms(500);
// 建立TCP连接到HTTP服务器
sprintf(cmd, "AT+CIPSTART=\"TCP\",\"%s\",%s", ip, port);
if (ESP8266_SendCommand(cmd, "OK", 5000) == ESP8266_FAIL) {
return ESP8266_FAIL;
}
Delay_ms(1000);
return ESP8266_SUCCESS;
}
uint8_t ESP8266_SendEncryptedData(const char* encrypted_data)
{
char http_request[1024];
char cmd[32];
uint32_t content_length = strlen(encrypted_data);
// 构建HTTP POST请求
sprintf(http_request,
"POST /api/v1/attendance/checkins HTTP/1.1\r\n"
"Host: %s:%s\r\n"
"Content-Type: application/octet-stream\r\n"
"X-Api-Token: %s\r\n"
"X-Encrypted: AES128-CBC\r\n"
"Content-Length: %u\r\n"
"Connection: keep-alive\r\n"
"\r\n"
"%s",
HTTP_SERVER_IP,
HTTP_SERVER_PORT,
HTTP_API_TOKEN,
content_length,
encrypted_data
);
uint32_t request_len = strlen(http_request);
// 发送数据长度命令
sprintf(cmd, "AT+CIPSEND=%u", request_len);
ESP8266_ClearBuffer();
if (ESP8266_SendCommand(cmd, ">", 2000) == ESP8266_FAIL) {
return ESP8266_FAIL;
}
// 发送HTTP请求
Serial_SendString(http_request);
// 等待发送完成(不阻塞太久)
Delay_ms(100);
// 可选:等待服务器响应(检查是否有"200 OK")
// 但为了不阻塞,建议不等待
return ESP8266_SUCCESS;
}
uint8_t ESP8266_CheckConnection(void)
{
ESP8266_ClearBuffer();
if (ESP8266_SendCommand("AT+CIPSTATUS", "OK", 1000) == ESP8266_FAIL) {
return ESP8266_FAIL;
}
// 检查响应中是否包含"STATUS:3"(已连接)
char* response = Serial_GetRxData();
if (strstr(response, "STATUS:3") != NULL ||
strstr(response, "STATUS:4") != NULL) {
return ESP8266_SUCCESS;
}
return ESP8266_FAIL;
}
uint8_t ESP8266_GetNetworkTime(ESP8266_DateTime* datetime)
{
// 启用SNTP
if (ESP8266_SendCommand("AT+CIPSNTPCFG=1,8,\"pool.ntp.org\"", "OK", 2000) == ESP8266_FAIL) {
return ESP8266_FAIL;
}
Delay_ms(3000); // 等待时间同步
ESP8266_ClearBuffer();
// 获取时间
if (ESP8266_SendCommand("AT+CIPSNTPTIME?", "OK", 1000) == ESP8266_FAIL) {
return ESP8266_FAIL;
}
// 解析响应(格式:+CIPSNTPTIME:Thu Jan 01 08:00:00 2024)
char* response = Serial_GetRxData();
char* time_str = strstr(response, "+CIPSNTPTIME:");
if (time_str != NULL) {
// 简单解析(这里只是示例,实际需要更完善的解析)
sscanf(time_str, "+CIPSNTPTIME:%*s %*s %d %d:%d:%d %d",
&datetime->day, &datetime->hour,
&datetime->minute, &datetime->second, &datetime->year);
return ESP8266_SUCCESS;
}
return ESP8266_FAIL;
}
// ========== 兼容旧版本的函数(已废弃) ==========
uint8_t ESP8266_ConnectTCPServer(const char* ip, const char* port)
{
// 重定向到HTTP版本
return ESP8266_ConnectHTTPServer(ip, port);
}
uint8_t ESP8266_SendData(char* Data)
{
// 警告:此函数已废弃,请使用ESP8266_SendEncryptedData
// 为了兼容,这里保留基本实现
char cmd[32];
sprintf(cmd, "AT+CIPSEND=%d", strlen(Data));
if (ESP8266_SendCommand(cmd, ">", 2000) == ESP8266_FAIL) {
return ESP8266_FAIL;
}
Serial_SendString(Data);
Delay_ms(50);
return ESP8266_SUCCESS;
}
-70
View File
@@ -1,70 +0,0 @@
#ifndef __ESP8266_H
#define __ESP8266_H
#include "stm32f10x.h"
// 定义布尔类型,方便返回状态
#define ESP8266_SUCCESS 1
#define ESP8266_FAIL 0
// 时间结构体
typedef struct {
uint16_t year;
uint8_t month;
uint8_t day;
uint8_t hour;
uint8_t minute;
uint8_t second;
} ESP8266_DateTime;
/*
--- 公共函数接口 ---
*/
/**
* @brief 对ESP8266模块进行基础设置
* @retval 1 表示成功, 0 表示失败
*/
uint8_t ESP8266_Setup(void);
/**
* @brief 命令ESP8266连接到指定的WiFi网络
* @param ssid 要连接的WiFi名称 (SSID)
* @param password WiFi的密码
* @retval 1 表示成功, 0 表示失败
*/
uint8_t ESP8266_ConnectWiFi(const char* ssid, const char* password);
/**
* @brief 命令ESP8266连接到HTTP服务器(新增)
* @param ip 服务器的IP地址
* @param port 服务器的端口号
* @retval 1 表示成功, 0 表示失败
*/
uint8_t ESP8266_ConnectHTTPServer(const char* ip, const char* port);
/**
* @brief 通过ESP8266发送HTTP POST请求(新增 - 加密版本)
* @param encrypted_data 加密后的Base64数据
* @retval 1 表示成功, 0 表示失败
*/
uint8_t ESP8266_SendEncryptedData(const char* encrypted_data);
/**
* @brief 检查TCP连接是否正常
* @retval 1 表示已连接, 0 表示未连接
*/
uint8_t ESP8266_CheckConnection(void);
/**
* @brief 从网络获取当前时间(NTP/SNTP)
* @param datetime 时间结构体指针,用于存储获取的时间
* @retval 1 表示成功, 0 表示失败
*/
uint8_t ESP8266_GetNetworkTime(ESP8266_DateTime* datetime);
// 兼容旧版本(已废弃,建议使用HTTP版本)
uint8_t ESP8266_ConnectTCPServer(const char* ip, const char* port);
uint8_t ESP8266_SendData(char* Data);
#endif
-59
View File
@@ -1,59 +0,0 @@
# Build directories
build/
build-*/
cmake-build-*/
out/
dist/
# CMake generated files
CMakeCache.txt
CMakeFiles/
cmake_install.cmake
install_manifest.txt
compile_commands.json
CTestTestfile.cmake
DartConfiguration.tcl
# Qt Creator local settings
.qtcreator/
*.user
*.user.*
# Qt generated artifacts
*_autogen/
*.qmlc
*.jsc
qrc_*.cpp
moc_*.cpp
moc_*.h
ui_*.h
# Compiled binaries and libraries
*.exe
*.dll
*.so
*.dylib
*.a
*.lib
*.o
*.obj
*.pdb
*.ilk
# Android build outputs
android-build/
*.apk
*.aab
# Logs and temporary files
*.log
*.tmp
*.temp
*.bak
*.swp
*~
# OS files
.DS_Store
Thumbs.db
desktop.ini
-145
View File
@@ -1,145 +0,0 @@
cmake_minimum_required(VERSION 3.16)
project(rewrite_qt VERSION 1.0.0 LANGUAGES CXX)
set(REWRITE_QT_DISPLAY_NAME "人脸识别考勤系统")
set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml")
set(REWRITE_QT_OUTPUT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/dist")
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${REWRITE_QT_OUTPUT_DIR}")
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${REWRITE_QT_OUTPUT_DIR}")
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${REWRITE_QT_OUTPUT_DIR}")
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOMOC_COMPILER_PREDEFINES OFF)
set(CMAKE_AUTORCC ON)
file(TO_CMAKE_PATH "$ENV{LOCALAPPDATA}" REWRITE_QT_LOCALAPPDATA)
if(REWRITE_QT_LOCALAPPDATA)
set(REWRITE_QT_AUTOGEN_DIR "${REWRITE_QT_LOCALAPPDATA}/rewrite_qt_autogen")
else()
set(REWRITE_QT_AUTOGEN_DIR "${CMAKE_BINARY_DIR}/autogen_ascii")
endif()
file(MAKE_DIRECTORY "${REWRITE_QT_AUTOGEN_DIR}")
find_package(Qt6 6.2 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 Network)
get_target_property(REWRITE_QT_QMAKE_EXECUTABLE Qt6::qmake IMPORTED_LOCATION)
if(REWRITE_QT_QMAKE_EXECUTABLE)
execute_process(
COMMAND "${REWRITE_QT_QMAKE_EXECUTABLE}" -query QT_INSTALL_QML
OUTPUT_VARIABLE REWRITE_QT_INSTALL_QML
OUTPUT_STRIP_TRAILING_WHITESPACE
)
endif()
qt_standard_project_setup()
set(REWRITE_QT_APP_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/favicon.ico")
set(REWRITE_QT_ICON_RESOURCES
assets/favicon.ico
assets/favicon-256x256.png
assets/favicon-192x192.png
assets/favicon-128x128.png
assets/favicon-64x64.png
assets/favicon-48x48.png
assets/favicon-32x32.png
assets/favicon-16x16.png
)
qt_add_executable(apprewrite_qt
main.cpp
src/AppController.cpp
src/ApiClient.cpp
src/CryptoUtils.cpp
src/RecordListModel.cpp
src/StatisticsListModel.cpp
src/TimeSlotListModel.cpp
src/MappingListModel.cpp
src/FaceListModel.cpp
)
if(REWRITE_QT_INSTALL_QML)
target_compile_definitions(apprewrite_qt PRIVATE
REWRITE_QT_INSTALL_QML="${REWRITE_QT_INSTALL_QML}"
)
endif()
if(WIN32 AND EXISTS "${REWRITE_QT_APP_ICON}")
set(REWRITE_QT_WIN32_ICON_RC "${CMAKE_CURRENT_BINARY_DIR}/app_icon.rc")
configure_file(cmake/app_icon.rc.in "${REWRITE_QT_WIN32_ICON_RC}" @ONLY)
target_sources(apprewrite_qt PRIVATE "${REWRITE_QT_WIN32_ICON_RC}")
endif()
if(ANDROID)
set_target_properties(apprewrite_qt PROPERTIES
QT_ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/android"
QT_ANDROID_APP_NAME "${REWRITE_QT_DISPLAY_NAME}"
QT_ANDROID_APP_ICON "@mipmap/ic_launcher"
)
endif()
target_include_directories(apprewrite_qt PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
set(REWRITE_QT_RESOURCE_FILES
qml/Main.qml
)
foreach(REWRITE_QT_ICON_RESOURCE IN LISTS REWRITE_QT_ICON_RESOURCES)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${REWRITE_QT_ICON_RESOURCE}")
list(APPEND REWRITE_QT_RESOURCE_FILES "${REWRITE_QT_ICON_RESOURCE}")
endif()
endforeach()
qt_add_resources(apprewrite_qt "qml_resources"
PREFIX "/"
FILES
${REWRITE_QT_RESOURCE_FILES}
)
target_link_libraries(apprewrite_qt
PRIVATE
Qt6::Core
Qt6::Gui
Qt6::Qml
Qt6::Quick
Qt6::QuickControls2
Qt6::Network
)
set_target_properties(apprewrite_qt PROPERTIES
AUTOGEN_BUILD_DIR "${REWRITE_QT_AUTOGEN_DIR}"
QT_QML_MODULE_NO_IMPORT_SCAN TRUE
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}
MACOSX_BUNDLE TRUE
WIN32_EXECUTABLE TRUE
)
include(GNUInstallDirs)
if(UNIX AND NOT APPLE AND NOT ANDROID)
set(REWRITE_QT_DESKTOP_FILE "${CMAKE_CURRENT_BINARY_DIR}/rewrite_qt.desktop")
configure_file(cmake/rewrite_qt.desktop.in "${REWRITE_QT_DESKTOP_FILE}" @ONLY)
install(FILES "${REWRITE_QT_DESKTOP_FILE}"
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/applications"
)
foreach(REWRITE_QT_ICON_SIZE IN ITEMS 16 32 48 64 128 192 256)
set(REWRITE_QT_LINUX_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/favicon-${REWRITE_QT_ICON_SIZE}x${REWRITE_QT_ICON_SIZE}.png")
if(EXISTS "${REWRITE_QT_LINUX_ICON}")
install(FILES "${REWRITE_QT_LINUX_ICON}"
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/${REWRITE_QT_ICON_SIZE}x${REWRITE_QT_ICON_SIZE}/apps"
RENAME rewrite_qt.png
)
endif()
endforeach()
endif()
install(TARGETS apprewrite_qt
BUNDLE DESTINATION .
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
)
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Jitianye
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-157
View File
@@ -1,157 +0,0 @@
# Qt 管理端 README
这是人脸识别考勤系统的 Qt 管理端,支持 Windows 桌面端和 Android 端。更完整的说明见:
```text
../docs/04_Qt管理端文档.md
../docs/00_项目总文档.md
```
## 1. 功能概览
Qt 管理端用于连接考勤服务端,完成以下工作:
1. 查看考勤记录。
2. 按班级、学号、姓名和日期筛选记录。
3. 导出考勤记录 CSV。
4. 查看学生维度的统计分析。
5. 导出统计 CSV。
6. 配置服务端地址。
7. 新增、编辑、删除、清空签到时间段。
8. 配置学号到姓名、班级编号到班级名称的显示映射。
9. 查看 K210 上传的人脸照片。
10. 为 K210 face_id 绑定真实学号。
11. 删除人脸信息,并可选择删除关联考勤记录。
## 2. 当前重要行为
### 2.1 默认服务端地址
```text
http://127.0.0.1:8082
```
启动后会优先读取本地保存的 `server/url`。
### 2.2 默认签到时间段
当前默认签到时间段为空:
```json
{
"time_slots": []
}
```
第一次使用时,需要在“配置中心”里新增时间段并保存同步到服务端。旧版本本地保存的四个默认时间段会被自动迁移为空配置。
### 2.3 API Token
Qt 每个请求都会带:
```text
X-Api-Token: <Token>
```
默认 Token 写在 `src/ApiClient.cpp`。可以通过环境变量覆盖:
```powershell
$env:ATTENDANCE_API_TOKEN="你的Token"
```
### 2.4 响应加密
服务端支持通过请求头 `X-Encrypted-Response: true` 返回 AES 加密响应,Qt 代码中保留了解密能力。
当前普通记录和统计请求没有默认开启这个请求头,实际按 JSON 明文解析。
## 3. 技术栈
| 分类 | 内容 |
| --- | --- |
| 语言 | C++17、QML |
| UI | Qt Quick / Qt Quick Controls 2 |
| Qt 模块 | Core、Gui、Qml、Quick、QuickControls2、Network |
| 构建 | CMake 3.16+ |
| 平台 | Windows 桌面端、Android |
## 4. 目录结构
```text
QT/
├── android/ # Android 打包配置和图标
├── assets/ # 桌面端图标
├── cmake/ # 平台模板
├── qml/Main.qml # 主界面
├── src/
│ ├── ApiClient.* # HTTP API 客户端
│ ├── AppController.* # 业务逻辑中枢
│ ├── AttendanceTypes.h # 数据结构
│ ├── CryptoUtils.* # AES 解密兼容工具
│ └── *ListModel.* # QML 列表模型
├── CMakeLists.txt
├── LICENSE
├── README.md
└── main.cpp
```
## 5. 构建环境
基础要求:
1. Qt 6.2 或更高。
2. CMake 3.16 或更高。
3. 支持 C++17 的编译器。
Windows 推荐使用 Qt 6.x MinGW 64-bit Kit。
Android 还需要:
1. Qt for Android。
2. Android SDK。
3. Android NDK。
4. JDK。
5. Android 平台版本,例如 `android-35`。
## 6. Qt Creator 构建
1. 打开 Qt Creator。
2. 打开 `QT/CMakeLists.txt`。
3. 选择 Desktop Qt 或 Android Kit。
4. 等待 CMake 配置完成。
5. 构建目标 `apprewrite_qt`。
6. 桌面端直接运行;Android 端部署到设备或模拟器。
## 7. 命令行构建示例
Windows 示例:
```powershell
cd D:\University\graduation\code\Reconstruct_Qt\QT
cmake -S . -B build\Desktop -DCMAKE_PREFIX_PATH=D:\Qt\6.10.0\mingw_64
cmake --build build\Desktop
```
Qt 安装路径需要按本机实际情况修改。
## 8. 本地数据
Qt 会保存这些本地数据:
| 数据 | 保存方式 |
| --- | --- |
| 服务端地址 | `QSettings`,键名 `server/url` |
| 时间段配置 | `QStandardPaths::AppDataLocation/timeslot_config.json` |
| 学生/班级映射 | `QStandardPaths::AppDataLocation/mapping_config.json` |
| 人脸照片缓存 | `QStandardPaths::AppDataLocation/face_photos/*.jpg` |
## 9. 常见问题
| 现象 | 处理 |
| --- | --- |
| 测试连接失败 | 检查服务端是否启动、地址是否正确、Token 是否一致 |
| 没有时间段 | 这是当前默认行为,需要手动新增并保存 |
| 识别成功但没有记录 | 检查 STM32 是否在时间段内、是否成功加密上传 |
| 人脸照片不显示 | 检查 K210 是否上传照片,服务端 `faces/` 是否有图片 |
| Android 布局拥挤 | 当前 QML 已做窄屏自适应,若仍异常请检查实际设备宽度 |
-2
View File
@@ -1,2 +0,0 @@
android.overridePathCheck=true
android.useAndroidX=true
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 627 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

-1
View File
@@ -1 +0,0 @@
IDI_ICON1 ICON "@REWRITE_QT_APP_ICON@"
-7
View File
@@ -1,7 +0,0 @@
[Desktop Entry]
Type=Application
Name=人脸识别考勤系统
Exec=apprewrite_qt
Icon=rewrite_qt
Terminal=false
Categories=Utility;
-63
View File
@@ -1,63 +0,0 @@
#include <QGuiApplication>
#include <QIcon>
#include <QLibraryInfo>
#include <QDir>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickStyle>
#include <QQuickWindow>
#include "src/AppController.h"
int main(int argc, char *argv[])
{
QGuiApplication app(argc, argv);
QIcon appIcon(QStringLiteral(":/assets/favicon.ico"));
appIcon.addFile(QStringLiteral(":/assets/favicon-256x256.png"));
appIcon.addFile(QStringLiteral(":/assets/favicon-192x192.png"));
appIcon.addFile(QStringLiteral(":/assets/favicon-128x128.png"));
appIcon.addFile(QStringLiteral(":/assets/favicon-64x64.png"));
appIcon.addFile(QStringLiteral(":/assets/favicon-48x48.png"));
appIcon.addFile(QStringLiteral(":/assets/favicon-32x32.png"));
appIcon.addFile(QStringLiteral(":/assets/favicon-16x16.png"));
app.setWindowIcon(appIcon);
QCoreApplication::setApplicationName("人脸识别考勤系统");
QCoreApplication::setApplicationVersion("3.0.0");
QCoreApplication::setOrganizationName("STM32 Face Recognition");
QQuickStyle::setStyle("Basic");
#if defined(Q_OS_ANDROID)
QQuickWindow::setTextRenderType(QQuickWindow::NativeTextRendering);
#endif
AppController controller;
QQmlApplicationEngine engine;
const QString qmlImportPath = QLibraryInfo::path(QLibraryInfo::QmlImportsPath);
if (!qmlImportPath.isEmpty()) {
engine.addImportPath(qmlImportPath);
}
#if defined(REWRITE_QT_INSTALL_QML)
const QString qtInstallQmlPath = QStringLiteral(REWRITE_QT_INSTALL_QML);
if (QDir(qtInstallQmlPath).exists()) {
engine.addImportPath(qtInstallQmlPath);
}
#endif
engine.rootContext()->setContextProperty("appController", &controller);
const QUrl url(QStringLiteral("qrc:/qml/Main.qml"));
QObject::connect(
&engine,
&QQmlApplicationEngine::objectCreated,
&app,
[url](QObject *obj, const QUrl &objUrl) {
if (!obj && url == objUrl) {
QCoreApplication::exit(-1);
}
},
Qt::QueuedConnection);
engine.load(url);
return app.exec();
}
-2946
View File
File diff suppressed because it is too large Load Diff
-673
View File
@@ -1,673 +0,0 @@
#include "ApiClient.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QDebug>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QProcessEnvironment>
#include <QUrl>
#include <QUrlQuery>
#include "CryptoUtils.h"
namespace {
constexpr int NetworkTimeoutMs = 5000;
constexpr auto DefaultApiToken = "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV";
}
ApiClient::ApiClient(QObject *parent)
: QObject(parent)
, m_serverUrl("http://127.0.0.1:8082")
{
}
void ApiClient::setServerUrl(const QString &url)
{
const QString trimmed = url.trimmed();
if (!trimmed.isEmpty()) {
m_serverUrl = trimmed;
}
}
QString ApiClient::serverUrl() const
{
return m_serverUrl;
}
QString ApiClient::buildUrl(const QString &endpoint, const QMap<QString, QString> &params) const
{
QString baseUrl = m_serverUrl.trimmed();
while (baseUrl.endsWith('/')) {
baseUrl.chop(1);
}
QString url = baseUrl + "/api" + endpoint;
if (!params.isEmpty()) {
QUrlQuery query;
for (auto it = params.begin(); it != params.end(); ++it) {
query.addQueryItem(it.key(), it.value());
}
url += "?" + query.toString(QUrl::FullyEncoded);
}
return url;
}
QNetworkRequest ApiClient::createRequest(const QString &url, bool encryptedResponse, bool jsonBody) const
{
QNetworkRequest request{QUrl(url)};
if (encryptedResponse) {
request.setRawHeader("X-Encrypted-Response", "true");
}
const QString apiToken = QProcessEnvironment::systemEnvironment()
.value(QStringLiteral("ATTENDANCE_API_TOKEN"),
QString::fromLatin1(DefaultApiToken));
request.setRawHeader("X-Api-Token", apiToken.toUtf8());
request.setRawHeader("Accept", "application/json");
if (jsonBody) {
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
}
request.setTransferTimeout(NetworkTimeoutMs);
return request;
}
void ApiClient::fetchSyncState(qulonglong lastVersion)
{
const QString url = buildUrl("/v1/sync/state", {
{"client", "qt"},
{"last_version", QString::number(lastVersion)}
});
qInfo() << "[ApiClient] GET sync state" << url;
QNetworkReply *reply = m_networkManager.get(createRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("同步状态加载失败"), reply);
reply->deleteLater();
return;
}
const QJsonObject root = QJsonDocument::fromJson(data).object();
const qulonglong version = static_cast<qulonglong>(root.value("version").toDouble(0));
const bool changed = root.value("changed").toBool(false);
if (!changed) {
emit syncStateFetched(version, false);
reply->deleteLater();
return;
}
const QJsonObject state = root.value("state").toObject();
if (state.contains("records")) {
emit recordsFetched(parseRecords(QJsonDocument(state.value("records").toArray()).toJson(QJsonDocument::Compact)));
}
if (state.contains("statistics")) {
const QJsonObject object = state.value("statistics").toObject();
emit statisticsFetched(object.value("total").toInt(),
object.value("today").toInt(),
object.value("server_time").toString());
}
if (state.contains("time_slots")) {
QVector<TimeSlot> timeSlots;
const QJsonArray array = state.value("time_slots").toArray();
for (const QJsonValue &value : array) {
const QJsonObject object = value.toObject();
timeSlots.push_back({
object.value("name").toString(),
QTime::fromString(object.value("start_time").toString(), "HH:mm"),
QTime::fromString(object.value("end_time").toString(), "HH:mm"),
object.value("enabled").toBool(true)
});
}
emit timeSlotsFetched(timeSlots);
}
if (state.contains("mapping_config")) {
const QJsonObject mappingConfig = state.value("mapping_config").toObject();
QMap<QString, QString> studentMap;
QMap<QString, QString> classMap;
const QJsonObject studentObject = mappingConfig.value("student_name_map").toObject();
for (auto it = studentObject.begin(); it != studentObject.end(); ++it) {
studentMap[it.key()] = it.value().toString();
}
const QJsonObject classObject = mappingConfig.value("class_name_map").toObject();
for (auto it = classObject.begin(); it != classObject.end(); ++it) {
classMap[it.key()] = it.value().toString();
}
emit mappingConfigFetched(studentMap, classMap);
}
if (state.contains("face_mappings")) {
const QJsonObject mappings = state.value("face_mappings").toObject();
QMap<QString, QString> mapping;
for (auto it = mappings.begin(); it != mappings.end(); ++it) {
mapping[it.key()] = it.value().toString();
}
emit faceIdMappingFetched(mapping);
}
if (state.contains("face_photos")) {
QStringList ids;
const QJsonArray photos = state.value("face_photos").toArray();
for (const QJsonValue &value : photos) {
ids.push_back(value.toObject().value("id").toString());
}
emit facePhotosFetched(ids);
}
if (state.contains("all_student_ids")) {
QStringList ids;
const QJsonArray array = state.value("all_student_ids").toArray();
for (const QJsonValue &value : array) {
ids.push_back(value.toString());
}
emit allStudentIdsFetched(ids);
}
emit syncStateFetched(version, true);
reply->deleteLater();
});
}
QByteArray ApiClient::maybeDecrypt(const QByteArray &data, bool encrypted) const
{
if (!encrypted) {
return data;
}
const QByteArray trimmed = data.trimmed();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return trimmed;
}
return CryptoUtils::decryptAes128CbcBase64(trimmed);
}
bool ApiClient::isConnectionError(QNetworkReply::NetworkError error) const
{
switch (error) {
case QNetworkReply::ConnectionRefusedError:
case QNetworkReply::RemoteHostClosedError:
case QNetworkReply::HostNotFoundError:
case QNetworkReply::TimeoutError:
case QNetworkReply::OperationCanceledError:
case QNetworkReply::SslHandshakeFailedError:
case QNetworkReply::TemporaryNetworkFailureError:
case QNetworkReply::NetworkSessionFailedError:
case QNetworkReply::BackgroundRequestNotAllowedError:
case QNetworkReply::TooManyRedirectsError:
case QNetworkReply::InsecureRedirectError:
case QNetworkReply::UnknownNetworkError:
case QNetworkReply::UnknownProxyError:
case QNetworkReply::UnknownContentError:
case QNetworkReply::ProtocolFailure:
case QNetworkReply::UnknownServerError:
return true;
default:
return false;
}
}
void ApiClient::emitNetworkError(const QString &prefix, QNetworkReply *reply)
{
emit networkError(prefix);
if (isConnectionError(reply->error())) {
emit connectionUnavailable(prefix);
}
}
QVector<AttendanceRecord> ApiClient::parseRecords(const QByteArray &data) const
{
QVector<AttendanceRecord> records;
const QJsonDocument doc = QJsonDocument::fromJson(data);
if (!doc.isArray()) {
return records;
}
const QJsonArray array = doc.array();
records.reserve(array.size());
for (const QJsonValue &value : array) {
const QJsonObject object = value.toObject();
AttendanceRecord record;
record.timestamp = object.value("timestamp").toString().trimmed();
record.studentId = object.value("student_id").toString().trimmed();
record.originalName = object.value("name").toString().trimmed();
record.confidence = object.value("confidence").toDouble();
const QJsonValue statusValue = object.value("status");
if (statusValue.isBool()) {
record.status = statusValue.toBool() ? QStringLiteral("success") : QStringLiteral("failed");
} else if (statusValue.isDouble()) {
record.status = QString::number(statusValue.toInt());
} else {
record.status = statusValue.toString().trimmed();
}
record.classId = object.value("class_id").toString().trimmed();
if (record.classId.isEmpty()) {
record.classId = "0";
}
if (!record.timestamp.startsWith("0000-") && !record.timestamp.isEmpty() && !record.studentId.isEmpty()) {
records.push_back(record);
}
}
return records;
}
void ApiClient::fetchRecords(int limit, int offset)
{
const QString url = buildUrl("/v1/attendance/records", {
{"limit", QString::number(limit)},
{"offset", QString::number(offset)}
});
qInfo() << "[ApiClient] GET" << url;
QNetworkReply *reply = m_networkManager.get(createRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray rawData = reply->readAll();
const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
qInfo() << "[ApiClient] records status=" << statusCode
<< "error=" << reply->error()
<< "bytes=" << rawData.size()
<< "body=" << rawData.left(200);
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载记录失败"), reply);
reply->deleteLater();
return;
}
const QByteArray data = maybeDecrypt(rawData, false);
if (data.isEmpty()) {
emit networkError("记录数据为空");
reply->deleteLater();
return;
}
QJsonParseError jsonError{};
const QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError);
if (jsonError.error != QJsonParseError::NoError || !doc.isArray()) {
emit networkError(QStringLiteral("记录数据解析失败: %1").arg(jsonError.errorString()));
reply->deleteLater();
return;
}
const QVector<AttendanceRecord> records = parseRecords(data);
qInfo() << "[ApiClient] parsed records=" << records.size();
emit recordsFetched(records);
reply->deleteLater();
});
}
void ApiClient::fetchStatistics()
{
const QString url = buildUrl("/v1/attendance/statistics");
qInfo() << "[ApiClient] GET" << url;
QNetworkReply *reply = m_networkManager.get(createRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray rawData = reply->readAll();
const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
qInfo() << "[ApiClient] statistics status=" << statusCode
<< "error=" << reply->error()
<< "bytes=" << rawData.size()
<< "body=" << rawData.left(200);
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载统计失败"), reply);
reply->deleteLater();
return;
}
const QByteArray data = maybeDecrypt(rawData, false);
if (data.isEmpty()) {
emit networkError("统计数据为空");
reply->deleteLater();
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(data);
const QJsonObject object = doc.object();
emit statisticsFetched(object.value("total").toInt(),
object.value("today").toInt(),
object.value("server_time").toString());
reply->deleteLater();
});
}
void ApiClient::clearAllRecords()
{
QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/attendance/records/clear"), false, true), QByteArray());
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("清空失败: %1").arg(reply->errorString()));
}
emit recordsCleared(false, reply->errorString());
reply->deleteLater();
return;
}
const QJsonObject object = QJsonDocument::fromJson(data).object();
emit recordsCleared(object.value("success").toBool(), object.value("message").toString());
reply->deleteLater();
});
}
void ApiClient::deleteRecord(const QString &timestamp, const QString &studentId)
{
QNetworkReply *reply = m_networkManager.deleteResource(createRequest(buildUrl("/v1/attendance/records", {
{"timestamp", timestamp},
{"student_id", studentId}
})));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("记录删除失败: %1").arg(reply->errorString()));
}
emit recordDeleted(false, reply->errorString());
reply->deleteLater();
return;
}
const QJsonObject object = QJsonDocument::fromJson(data).object();
emit recordDeleted(object.value("success").toBool(), object.value("message").toString());
reply->deleteLater();
});
}
void ApiClient::fetchTimeSlots()
{
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/config/time-slots")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载时间段失败"), reply);
reply->deleteLater();
return;
}
QVector<TimeSlot> timeSlots;
const QJsonObject root = QJsonDocument::fromJson(data).object();
const QJsonArray array = root.value("time_slots").toArray();
for (const QJsonValue &value : array) {
const QJsonObject object = value.toObject();
timeSlots.push_back({
object.value("name").toString(),
QTime::fromString(object.value("start_time").toString(), "HH:mm"),
QTime::fromString(object.value("end_time").toString(), "HH:mm"),
object.value("enabled").toBool(true)
});
}
emit timeSlotsFetched(timeSlots);
reply->deleteLater();
});
}
void ApiClient::uploadTimeSlots(const QVector<TimeSlot> &timeSlots)
{
QJsonArray array;
for (const TimeSlot &slot : timeSlots) {
QJsonObject object;
object["name"] = slot.name;
object["start_time"] = slot.startTime.toString("HH:mm");
object["end_time"] = slot.endTime.toString("HH:mm");
object["enabled"] = slot.enabled;
array.append(object);
}
QJsonObject payload;
payload["time_slots"] = array;
const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact);
qInfo() << "[ApiClient] POST" << buildUrl("/v1/config/time-slots")
<< "bytes=" << body.size()
<< "body=" << body.left(300);
QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/config/time-slots"), false, true), body);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("时间段同步失败: %1").arg(reply->errorString()));
}
emit timeSlotsUploaded(false, reply->errorString());
reply->deleteLater();
return;
}
const QJsonObject object = QJsonDocument::fromJson(data).object();
emit timeSlotsUploaded(object.value("success").toBool(), object.value("message").toString());
reply->deleteLater();
});
}
void ApiClient::fetchMappingConfig()
{
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/config/mappings")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载映射配置失败"), reply);
reply->deleteLater();
return;
}
const QJsonObject root = QJsonDocument::fromJson(data).object();
QMap<QString, QString> studentMap;
QMap<QString, QString> classMap;
const QJsonObject studentObject = root.value("student_name_map").toObject();
for (auto it = studentObject.begin(); it != studentObject.end(); ++it) {
studentMap[it.key()] = it.value().toString();
}
const QJsonObject classObject = root.value("class_name_map").toObject();
for (auto it = classObject.begin(); it != classObject.end(); ++it) {
classMap[it.key()] = it.value().toString();
}
emit mappingConfigFetched(studentMap, classMap);
reply->deleteLater();
});
}
void ApiClient::uploadMappingConfig(const QMap<QString, QString> &studentNameMap,
const QMap<QString, QString> &classNameMap)
{
QJsonObject studentObject;
for (auto it = studentNameMap.begin(); it != studentNameMap.end(); ++it) {
studentObject[it.key()] = it.value();
}
QJsonObject classObject;
for (auto it = classNameMap.begin(); it != classNameMap.end(); ++it) {
classObject[it.key()] = it.value();
}
QJsonObject payload;
payload["student_name_map"] = studentObject;
payload["class_name_map"] = classObject;
QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/config/mappings"), false, true),
QJsonDocument(payload).toJson(QJsonDocument::Compact));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("映射保存失败: %1").arg(reply->errorString()));
}
emit mappingConfigUploaded(false, reply->errorString());
reply->deleteLater();
return;
}
const QJsonObject object = QJsonDocument::fromJson(data).object();
emit mappingConfigUploaded(object.value("success").toBool(), object.value("message").toString());
reply->deleteLater();
});
}
void ApiClient::fetchFaceIdMapping()
{
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/mappings")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载人脸映射失败"), reply);
reply->deleteLater();
return;
}
const QJsonObject mappings = QJsonDocument::fromJson(data).object().value("mappings").toObject();
QMap<QString, QString> mapping;
for (auto it = mappings.begin(); it != mappings.end(); ++it) {
mapping[it.key()] = it.value().toString();
}
emit faceIdMappingFetched(mapping);
reply->deleteLater();
});
}
void ApiClient::uploadFaceIdMapping(const QMap<QString, QString> &mapping)
{
QJsonObject mappings;
for (auto it = mapping.begin(); it != mapping.end(); ++it) {
if (!it.value().trimmed().isEmpty()) {
mappings[it.key()] = it.value();
}
}
QJsonObject payload;
payload["mappings"] = mappings;
QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/faces/mappings"), false, true),
QJsonDocument(payload).toJson(QJsonDocument::Compact));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("人脸映射保存失败: %1").arg(reply->errorString()));
}
emit faceIdMappingUploaded(false, reply->errorString());
reply->deleteLater();
return;
}
const QJsonObject object = QJsonDocument::fromJson(data).object();
emit faceIdMappingUploaded(object.value("success").toBool(), object.value("message").toString());
reply->deleteLater();
});
}
void ApiClient::fetchFacePhotos()
{
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/photos")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载照片列表失败"), reply);
reply->deleteLater();
return;
}
QStringList ids;
const QJsonArray photos = QJsonDocument::fromJson(data).object().value("photos").toArray();
for (const QJsonValue &value : photos) {
ids.push_back(value.toObject().value("id").toString());
}
emit facePhotosFetched(ids);
reply->deleteLater();
});
}
void ApiClient::fetchFacePhoto(const QString &faceId)
{
if (faceId.trimmed().isEmpty()) {
emit facePhotoFetched(faceId, {}, false, QStringLiteral("人脸ID为空"));
return;
}
const QString encodedId = QString::fromUtf8(QUrl::toPercentEncoding(faceId.trimmed()));
QNetworkRequest request = createRequest(buildUrl(QStringLiteral("/v1/faces/photos/%1").arg(encodedId)));
request.setRawHeader("Accept", "image/jpeg,*/*");
QNetworkReply *reply = m_networkManager.get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply, faceId]() {
const QByteArray data = reply->readAll();
const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError || statusCode >= 400 || data.isEmpty()) {
const QString message = reply->error() != QNetworkReply::NoError
? reply->errorString()
: QStringLiteral("HTTP %1").arg(statusCode);
emit facePhotoFetched(faceId, {}, false, message);
reply->deleteLater();
return;
}
emit facePhotoFetched(faceId, data, true, QString());
reply->deleteLater();
});
}
void ApiClient::fetchAllStudentIds()
{
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/student-ids")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载学生ID失败"), reply);
reply->deleteLater();
return;
}
QStringList ids;
const QJsonArray array = QJsonDocument::fromJson(data).object().value("student_ids").toArray();
for (const QJsonValue &value : array) {
ids.push_back(value.toString());
}
emit allStudentIdsFetched(ids);
reply->deleteLater();
});
}
void ApiClient::deleteFace(const QString &studentId, bool deleteRecords)
{
QString baseUrl = m_serverUrl.trimmed();
while (baseUrl.endsWith('/')) {
baseUrl.chop(1);
}
QString url = baseUrl + "/api/v1/faces/" + QString::fromLatin1(QUrl::toPercentEncoding(studentId));
if (deleteRecords) {
url += "?delete_records=true";
}
QNetworkReply *reply = m_networkManager.deleteResource(createRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply, studentId]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("删除失败: %1").arg(reply->errorString()));
}
emit faceDeleted(false, studentId, 0, reply->errorString());
reply->deleteLater();
return;
}
const QJsonObject object = QJsonDocument::fromJson(data).object();
emit faceDeleted(object.value("success").toBool(),
studentId,
object.value("records_deleted").toInt(),
object.value("message").toString());
reply->deleteLater();
});
}
void ApiClient::checkHealth()
{
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/system/health")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
emit healthChecked(false, reply->errorString());
reply->deleteLater();
return;
}
const QJsonObject object = QJsonDocument::fromJson(data).object();
const bool healthy = object.value("status").toString() == "healthy";
emit healthChecked(healthy, healthy ? QStringLiteral("连接成功") : QStringLiteral("服务器无响应"));
reply->deleteLater();
});
}
-75
View File
@@ -1,75 +0,0 @@
#ifndef API_CLIENT_H
#define API_CLIENT_H
#include <QObject>
#include <QByteArray>
#include <QMap>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QStringList>
#include <QVector>
#include "AttendanceTypes.h"
class ApiClient : public QObject
{
Q_OBJECT
public:
explicit ApiClient(QObject *parent = nullptr);
void setServerUrl(const QString &url);
QString serverUrl() const;
void fetchSyncState(qulonglong lastVersion);
void fetchRecords(int limit = 1000, int offset = 0);
void fetchStatistics();
void clearAllRecords();
void deleteRecord(const QString &timestamp, const QString &studentId);
void fetchTimeSlots();
void uploadTimeSlots(const QVector<TimeSlot> &timeSlots);
void fetchMappingConfig();
void uploadMappingConfig(const QMap<QString, QString> &studentNameMap,
const QMap<QString, QString> &classNameMap);
void fetchFaceIdMapping();
void uploadFaceIdMapping(const QMap<QString, QString> &mapping);
void fetchFacePhotos();
void fetchFacePhoto(const QString &faceId);
void fetchAllStudentIds();
void deleteFace(const QString &studentId, bool deleteRecords);
void checkHealth();
signals:
void recordsFetched(const QVector<AttendanceRecord> &records);
void statisticsFetched(int total, int today, const QString &serverTime);
void recordsCleared(bool success, const QString &message);
void recordDeleted(bool success, const QString &message);
void timeSlotsFetched(const QVector<TimeSlot> &timeSlots);
void timeSlotsUploaded(bool success, const QString &message);
void mappingConfigFetched(const QMap<QString, QString> &studentNameMap,
const QMap<QString, QString> &classNameMap);
void mappingConfigUploaded(bool success, const QString &message);
void faceIdMappingFetched(const QMap<QString, QString> &mapping);
void faceIdMappingUploaded(bool success, const QString &message);
void facePhotosFetched(const QStringList &photoIds);
void facePhotoFetched(const QString &faceId, const QByteArray &data, bool success, const QString &message);
void allStudentIdsFetched(const QStringList &studentIds);
void faceDeleted(bool success, const QString &studentId, int recordsDeleted, const QString &message);
void healthChecked(bool healthy, const QString &message);
void connectionUnavailable(const QString &message);
void networkError(const QString &message);
void syncStateFetched(qulonglong version, bool changed);
private:
QString buildUrl(const QString &endpoint, const QMap<QString, QString> &params = {}) const;
QNetworkRequest createRequest(const QString &url, bool encryptedResponse = false, bool jsonBody = false) const;
QVector<AttendanceRecord> parseRecords(const QByteArray &data) const;
QByteArray maybeDecrypt(const QByteArray &data, bool encrypted) const;
void emitNetworkError(const QString &prefix, QNetworkReply *reply);
bool isConnectionError(QNetworkReply::NetworkError error) const;
QNetworkAccessManager m_networkManager;
QString m_serverUrl;
};
#endif
-851
View File
@@ -1,851 +0,0 @@
#include "AppController.h"
#include <QDir>
#include <QDebug>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSettings>
#include <QStandardPaths>
#include <QStringConverter>
#include <QSet>
#include <QTextStream>
#include <QUrl>
#include <algorithm>
#include <utility>
namespace {
constexpr int ReconnectIntervalMs = 5000;
QDate parseDate(const QString &value, const QDate &fallback)
{
const QDate date = QDate::fromString(value, Qt::ISODate);
return date.isValid() ? date : fallback;
}
bool isSuccessStatus(const QString &status)
{
const QString normalized = status.trimmed().toLower();
return normalized == "success"
|| normalized == "succeeded"
|| normalized == "ok"
|| normalized == "pass"
|| normalized == "passed"
|| normalized == "present"
|| normalized == "recognized"
|| normalized == "verified"
|| normalized == "valid"
|| normalized == "200"
|| normalized == "true"
|| normalized == "1"
|| normalized == QStringLiteral("成功")
|| normalized == QStringLiteral("签到成功")
|| normalized == QStringLiteral("正常");
}
QString csvCell(const QString &value)
{
QString escaped = value;
const bool needsQuotes = escaped.contains(',')
|| escaped.contains('"')
|| escaped.contains('\n')
|| escaped.contains('\r');
escaped.replace("\"", "\"\"");
return needsQuotes ? QStringLiteral("\"%1\"").arg(escaped) : escaped;
}
QString conciseStatusMessage(const QString &message)
{
if (message.contains(QStringLiteral("Error transferring"), Qt::CaseInsensitive)
|| message.contains(QStringLiteral("Connection refused"), Qt::CaseInsensitive)
|| message.contains(QStringLiteral("Host not found"), Qt::CaseInsensitive)
|| message.contains(QStringLiteral("Network unreachable"), Qt::CaseInsensitive)
|| message.contains(QStringLiteral("timed out"), Qt::CaseInsensitive)) {
return QStringLiteral("服务器未连接,稍后重试");
}
return message;
}
QString safeFileName(QString value)
{
value = value.trimmed();
for (int i = 0; i < value.size(); ++i) {
QChar ch = value.at(i);
if (!ch.isLetterOrNumber() && ch != '_' && ch != '-' && ch != '.') {
value[i] = '_';
}
}
return value.isEmpty() ? QStringLiteral("unknown") : value;
}
QVector<TimeSlot> legacyDefaultTimeSlots()
{
return {
{"上午签到", QTime(8, 0), QTime(9, 30), true},
{"中午签退", QTime(12, 0), QTime(13, 0), true},
{"下午签到", QTime(14, 0), QTime(15, 0), true},
{"晚上签退", QTime(17, 0), QTime(18, 30), true}
};
}
bool isLegacyDefaultTimeSlots(const QVector<TimeSlot> &timeSlots)
{
const QVector<TimeSlot> legacy = legacyDefaultTimeSlots();
if (timeSlots.size() != legacy.size()) {
return false;
}
for (int i = 0; i < legacy.size(); ++i) {
if (timeSlots.at(i).name != legacy.at(i).name
|| timeSlots.at(i).startTime != legacy.at(i).startTime
|| timeSlots.at(i).endTime != legacy.at(i).endTime
|| timeSlots.at(i).enabled != legacy.at(i).enabled) {
return false;
}
}
return true;
}
}
AppController::AppController(QObject *parent)
: QObject(parent)
{
loadLocalState();
m_apiClient.setServerUrl(m_serverUrl);
m_reconnectTimer.setSingleShot(true);
connect(&m_reconnectTimer, &QTimer::timeout, this, [this]() {
runReconnectCheck();
});
connect(&m_apiClient, &ApiClient::recordsFetched, this, [this](const QVector<AttendanceRecord> &records) {
m_allRecords = records;
updateFilterOptions();
rebuildFilteredRecords();
rebuildStatistics();
setStatusMessage(QStringLiteral("已加载 %1 条记录").arg(records.size()));
emit summaryChanged();
});
connect(&m_apiClient, &ApiClient::statisticsFetched, this, [this](int total, int today, const QString &) {
m_totalRecords = total;
m_todayRecords = today;
emit summaryChanged();
});
connect(&m_apiClient, &ApiClient::syncStateFetched, this, [this](qulonglong version, bool changed) {
if (version > 0) {
m_qtSyncVersion = version;
}
setStatusMessage(changed
? QStringLiteral("同步完成,版本 %1").arg(version)
: QStringLiteral("数据已是最新,版本 %1").arg(version));
});
connect(&m_apiClient, &ApiClient::recordsCleared, this, [this](bool success, const QString &message) {
setStatusMessage(success ? QStringLiteral("已清空全部记录") : QStringLiteral("清空失败: %1").arg(message));
if (success) {
refreshAll();
}
});
connect(&m_apiClient, &ApiClient::recordDeleted, this, [this](bool success, const QString &message) {
setStatusMessage(success ? QStringLiteral("记录删除成功") : QStringLiteral("记录删除失败: %1").arg(message));
if (success) {
refreshRecords();
}
});
connect(&m_apiClient, &ApiClient::timeSlotsFetched, this, [this](const QVector<TimeSlot> &timeSlots) {
m_timeSlotModel.setTimeSlots(timeSlots);
saveTimeSlotsToLocal();
setStatusMessage(timeSlots.isEmpty()
? QStringLiteral("已从服务器同步空时间段配置")
: QStringLiteral("已从服务器同步时间段"));
});
connect(&m_apiClient, &ApiClient::timeSlotsUploaded, this, [this](bool success, const QString &message) {
setStatusMessage(success ? QStringLiteral("时间段已保存并同步") : QStringLiteral("时间段同步失败: %1").arg(message));
});
connect(&m_apiClient, &ApiClient::mappingConfigFetched, this,
[this](const QMap<QString, QString> &studentNameMap, const QMap<QString, QString> &classNameMap) {
m_studentMappingModel.setMap(studentNameMap);
m_classMappingModel.setMap(classNameMap);
saveMappingsToLocal();
rebuildFilteredRecords();
rebuildStatistics();
setStatusMessage("映射配置已同步");
});
connect(&m_apiClient, &ApiClient::mappingConfigUploaded, this, [this](bool success, const QString &message) {
setStatusMessage(success ? QStringLiteral("映射配置已保存") : QStringLiteral("映射保存失败: %1").arg(message));
});
connect(&m_apiClient, &ApiClient::faceIdMappingFetched, this, [this](const QMap<QString, QString> &mapping) {
m_faceIdMapping = mapping;
mergeFaceItems();
});
connect(&m_apiClient, &ApiClient::faceIdMappingUploaded, this, [this](bool success, const QString &message) {
setStatusMessage(success ? QStringLiteral("人脸映射已保存") : QStringLiteral("人脸映射保存失败: %1").arg(message));
});
connect(&m_apiClient, &ApiClient::facePhotosFetched, this, [this](const QStringList &photoIds) {
m_facePhotoIds = photoIds;
m_facePhotoLocalUrls.clear();
mergeFaceItems();
for (const QString &id : m_facePhotoIds) {
m_apiClient.fetchFacePhoto(id);
}
});
connect(&m_apiClient, &ApiClient::facePhotoFetched, this,
[this](const QString &faceId, const QByteArray &data, bool success, const QString &message) {
if (!success) {
qWarning() << "[FacePhoto] load failed:" << faceId << message;
return;
}
const QString path = localFacePhotoPath(faceId);
QFile file(path);
if (!file.open(QIODevice::WriteOnly)) {
qWarning() << "[FacePhoto] cache open failed:" << path;
return;
}
file.write(data);
file.close();
m_facePhotoLocalUrls.insert(faceId, QUrl::fromLocalFile(path).toString());
mergeFaceItems();
});
connect(&m_apiClient, &ApiClient::allStudentIdsFetched, this, [this](const QStringList &studentIds) {
m_allStudentIds = studentIds;
mergeFaceItems();
});
connect(&m_apiClient, &ApiClient::faceDeleted, this, [this](bool success, const QString &studentId, int recordsDeleted, const QString &message) {
if (success) {
setStatusMessage(recordsDeleted > 0
? QStringLiteral("已删除 %1,并清除 %2 条记录").arg(studentId).arg(recordsDeleted)
: QStringLiteral("已删除 %1").arg(studentId));
refreshFaces();
refreshRecords();
} else {
setStatusMessage(QStringLiteral("删除失败: %1").arg(message));
}
});
connect(&m_apiClient, &ApiClient::healthChecked, this, [this](bool healthy, const QString &message) {
const bool wasReconnecting = m_reconnecting;
m_reconnectCheckInFlight = false;
if (healthy) {
stopReconnect();
setStatusMessage(wasReconnecting ? QStringLiteral("服务器已恢复连接,正在同步数据") : QStringLiteral("服务器连接正常"));
if (wasReconnecting) {
refreshAll();
refreshFaces();
}
} else {
startReconnect(message);
}
});
connect(&m_apiClient, &ApiClient::networkError, this, [this](const QString &message) {
setStatusMessage(message);
});
connect(&m_apiClient, &ApiClient::connectionUnavailable, this, [this](const QString &message) {
startReconnect(message);
});
m_reconnecting = true;
emit reconnectStateChanged();
runReconnectCheck();
}
RecordListModel *AppController::recordModel() { return &m_recordModel; }
StatisticsListModel *AppController::statisticsModel() { return &m_statisticsModel; }
TimeSlotListModel *AppController::timeSlotModel() { return &m_timeSlotModel; }
MappingListModel *AppController::studentMappingModel() { return &m_studentMappingModel; }
MappingListModel *AppController::classMappingModel() { return &m_classMappingModel; }
FaceListModel *AppController::faceModel() { return &m_faceModel; }
QStringList AppController::classOptions() const { return m_classOptions; }
QStringList AppController::studentOptions() const { return m_studentOptions; }
QString AppController::serverUrl() const { return m_serverUrl; }
QString AppController::statusMessage() const { return m_statusMessage; }
bool AppController::reconnecting() const { return m_reconnecting; }
int AppController::reconnectAttempt() const { return m_reconnectAttempt; }
int AppController::totalRecords() const { return m_totalRecords; }
int AppController::todayRecords() const { return m_todayRecords; }
int AppController::filteredCount() const { return m_filteredRecords.size(); }
int AppController::loadedRecords() const { return m_allRecords.size(); }
int AppController::statisticsStudentCount() const { return m_statisticsStudentCount; }
int AppController::statisticsRecordCount() const { return m_statisticsRecordCount; }
QString AppController::statisticsRangeText() const
{
return QString("%1 至 %2")
.arg(m_statisticsStartDate.toString("yyyy-MM-dd"))
.arg(m_statisticsEndDate.toString("yyyy-MM-dd"));
}
void AppController::setServerUrl(const QString &url)
{
const QString trimmed = url.trimmed();
if (trimmed.isEmpty() || trimmed == m_serverUrl) {
return;
}
m_serverUrl = trimmed;
m_qtSyncVersion = 0;
m_apiClient.setServerUrl(trimmed);
saveServerUrl();
emit serverUrlChanged();
stopReconnect();
setStatusMessage("服务器地址已更新,正在检查连接");
m_reconnecting = true;
emit reconnectStateChanged();
runReconnectCheck();
}
void AppController::refreshAll()
{
m_apiClient.fetchSyncState(m_qtSyncVersion);
}
void AppController::refreshRecords()
{
m_apiClient.fetchRecords(1000, 0);
}
void AppController::applyFilters(const QString &classId,
const QString &studentId,
const QString &nameFilter,
int dateFilterIndex,
const QString &specificDate)
{
m_selectedClassId = classId.isEmpty() ? QStringLiteral("全部") : classId;
m_selectedStudentId = studentId.isEmpty() ? QStringLiteral("全部") : studentId;
m_nameFilter = nameFilter.trimmed();
m_dateFilterIndex = dateFilterIndex;
m_specificDate = parseDate(specificDate, QDate::currentDate());
rebuildFilteredRecords();
}
void AppController::updateStatisticsRange(const QString &startDate, const QString &endDate)
{
m_statisticsStartDate = parseDate(startDate, QDate::currentDate().addDays(-30));
m_statisticsEndDate = parseDate(endDate, QDate::currentDate());
rebuildStatistics();
}
bool AppController::exportFilteredRecords(const QString &fileUrl)
{
const QString filePath = QUrl(fileUrl).toLocalFile();
if (filePath.isEmpty()) {
setStatusMessage("导出路径无效");
return false;
}
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
setStatusMessage("无法创建导出文件");
return false;
}
QTextStream out(&file);
out.setEncoding(QStringConverter::Utf8);
out << "# 考勤记录导出\n";
out << "# 导出时间: " << QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss") << "\n";
out << "# 记录总数: " << m_filteredRecords.size() << "\n";
out << "签到时间,班级编号,班级名称,学号,姓名,置信度,状态\n";
for (const AttendanceRecord &record : m_filteredRecords) {
out << csvCell(record.timestamp) << ","
<< csvCell(record.classId) << ","
<< csvCell(mappedClassName(record)) << ","
<< csvCell(record.studentId) << ","
<< csvCell(mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record)) << ","
<< QString::number(record.confidence, 'f', 4) << ","
<< csvCell(record.status) << "\n";
}
setStatusMessage(QStringLiteral("已导出 %1 条记录").arg(m_filteredRecords.size()));
return true;
}
bool AppController::exportStatistics(const QString &fileUrl)
{
const QString filePath = QUrl(fileUrl).toLocalFile();
if (filePath.isEmpty()) {
setStatusMessage("统计导出路径无效");
return false;
}
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
setStatusMessage("无法创建统计文件");
return false;
}
QTextStream out(&file);
out.setEncoding(QStringConverter::Utf8);
out << "考勤统计报表," << statisticsRangeText() << "\n";
out << "学号,姓名,总签到次数,今日签到,最后签到时间,平均置信度\n";
for (int row = 0; row < m_statisticsModel.rowCount(); ++row) {
const QModelIndex index = m_statisticsModel.index(row);
out << csvCell(m_statisticsModel.data(index, StatisticsListModel::StudentIdRole).toString()) << ","
<< csvCell(m_statisticsModel.data(index, StatisticsListModel::NameRole).toString()) << ","
<< m_statisticsModel.data(index, StatisticsListModel::TotalCountRole).toInt() << ","
<< m_statisticsModel.data(index, StatisticsListModel::TodayCountRole).toInt() << ","
<< csvCell(m_statisticsModel.data(index, StatisticsListModel::LastCheckInRole).toString()) << ","
<< QString::number(m_statisticsModel.data(index, StatisticsListModel::AverageConfidenceRole).toDouble(), 'f', 4) << "\n";
}
setStatusMessage("统计报表已导出");
return true;
}
void AppController::clearAllRecords()
{
m_apiClient.clearAllRecords();
}
void AppController::saveTimeSlots()
{
saveTimeSlotsToLocal();
m_apiClient.uploadTimeSlots(m_timeSlotModel.timeSlots());
}
void AppController::reloadTimeSlotsFromServer()
{
m_apiClient.fetchTimeSlots();
}
void AppController::loadMappingsFromServer()
{
m_apiClient.fetchMappingConfig();
}
void AppController::saveMappings()
{
saveMappingsToLocal();
m_apiClient.uploadMappingConfig(m_studentMappingModel.toMap(), m_classMappingModel.toMap());
rebuildFilteredRecords();
rebuildStatistics();
}
void AppController::refreshFaces()
{
m_apiClient.fetchFaceIdMapping();
m_apiClient.fetchFacePhotos();
m_apiClient.fetchAllStudentIds();
}
void AppController::saveFaceMappings()
{
QMap<QString, QString> mapping;
const QVector<FaceListModel::Item> items = m_faceModel.items();
for (const FaceListModel::Item &item : items) {
const QString faceId = item.faceId.trimmed();
const QString studentId = item.studentId.trimmed();
if (faceId.isEmpty() || studentId.isEmpty()) {
continue;
}
if (m_faceIdMapping.contains(faceId) || faceId != studentId) {
mapping[faceId] = studentId;
}
}
m_faceIdMapping = mapping;
m_apiClient.uploadFaceIdMapping(mapping);
}
void AppController::deleteFace(const QString &studentId, bool deleteRecords)
{
m_apiClient.deleteFace(studentId, deleteRecords);
}
void AppController::testConnection()
{
setStatusMessage("正在检查服务器连接");
m_apiClient.checkHealth();
}
void AppController::loadLocalState()
{
QSettings settings;
m_serverUrl = settings.value("server/url", "http://127.0.0.1:8082").toString();
m_qtSyncVersion = 0;
loadMappingsFromLocal();
loadTimeSlotsFromLocal();
}
void AppController::saveServerUrl()
{
QSettings settings;
settings.setValue("server/url", m_serverUrl);
}
void AppController::saveMappingsToLocal()
{
const QString path = localSettingsPath("mapping_config.json");
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
return;
}
QJsonObject root;
QJsonObject studentObject;
const QMap<QString, QString> studentMap = m_studentMappingModel.toMap();
for (auto it = studentMap.begin(); it != studentMap.end(); ++it) {
studentObject[it.key()] = it.value();
}
QJsonObject classObject;
const QMap<QString, QString> classMap = m_classMappingModel.toMap();
for (auto it = classMap.begin(); it != classMap.end(); ++it) {
classObject[it.key()] = it.value();
}
root["student_name_map"] = studentObject;
root["class_name_map"] = classObject;
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
}
void AppController::loadMappingsFromLocal()
{
QFile file(localSettingsPath("mapping_config.json"));
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
m_studentMappingModel.clear();
m_classMappingModel.clear();
return;
}
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
QMap<QString, QString> studentMap;
const QJsonObject studentObject = root.value("student_name_map").toObject();
for (auto it = studentObject.begin(); it != studentObject.end(); ++it) {
studentMap[it.key()] = it.value().toString();
}
QMap<QString, QString> classMap;
const QJsonObject classObject = root.value("class_name_map").toObject();
for (auto it = classObject.begin(); it != classObject.end(); ++it) {
classMap[it.key()] = it.value().toString();
}
m_studentMappingModel.setMap(studentMap);
m_classMappingModel.setMap(classMap);
}
void AppController::saveTimeSlotsToLocal()
{
QFile file(localSettingsPath("timeslot_config.json"));
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
return;
}
QJsonArray array;
for (const TimeSlot &slot : m_timeSlotModel.timeSlots()) {
QJsonObject object;
object["name"] = slot.name;
object["start_time"] = slot.startTime.toString("HH:mm");
object["end_time"] = slot.endTime.toString("HH:mm");
object["enabled"] = slot.enabled;
array.append(object);
}
QJsonObject root;
root["time_slots"] = array;
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
}
void AppController::loadTimeSlotsFromLocal()
{
QFile file(localSettingsPath("timeslot_config.json"));
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
m_timeSlotModel.setTimeSlots({});
return;
}
QVector<TimeSlot> timeSlots;
const QJsonArray array = QJsonDocument::fromJson(file.readAll()).object().value("time_slots").toArray();
for (const QJsonValue &value : array) {
const QJsonObject object = value.toObject();
timeSlots.push_back({
object.value("name").toString(),
QTime::fromString(object.value("start_time").toString(), "HH:mm"),
QTime::fromString(object.value("end_time").toString(), "HH:mm"),
object.value("enabled").toBool(true)
});
}
if (isLegacyDefaultTimeSlots(timeSlots)) {
timeSlots.clear();
}
m_timeSlotModel.setTimeSlots(timeSlots);
if (timeSlots.isEmpty()) {
saveTimeSlotsToLocal();
}
}
QString AppController::localSettingsPath(const QString &fileName) const
{
const QString dirPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
QDir().mkpath(dirPath);
return dirPath + "/" + fileName;
}
QString AppController::localFacePhotoPath(const QString &faceId) const
{
const QString dirPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/face_photos";
QDir().mkpath(dirPath);
return dirPath + "/" + safeFileName(faceId) + ".jpg";
}
void AppController::updateFilterOptions()
{
QSet<QString> classIds;
QSet<QString> studentIds;
for (const AttendanceRecord &record : m_allRecords) {
classIds.insert(record.classId);
studentIds.insert(record.studentId);
}
m_classOptions = {"全部"};
QStringList classValues = classIds.values();
std::sort(classValues.begin(), classValues.end());
m_classOptions.append(classValues);
m_studentOptions = {"全部"};
QStringList studentValues = studentIds.values();
std::sort(studentValues.begin(), studentValues.end());
m_studentOptions.append(studentValues);
emit filtersChanged();
}
void AppController::rebuildFilteredRecords()
{
QVector<RecordListModel::Item> items;
m_filteredRecords.clear();
const QDate today = QDate::currentDate();
for (const AttendanceRecord &record : m_allRecords) {
if (m_selectedClassId != "全部" && record.classId != m_selectedClassId) {
continue;
}
if (m_selectedStudentId != "全部" && record.studentId != m_selectedStudentId) {
continue;
}
const QString displayName = mappedStudentName(record);
const QString candidateName = displayName.isEmpty() ? record.originalName : displayName;
if (!m_nameFilter.isEmpty() && !candidateName.contains(m_nameFilter, Qt::CaseInsensitive)) {
continue;
}
const QDate recordDate = record.dateTime().date();
if (m_dateFilterIndex == 1 && recordDate != today) {
continue;
}
if (m_dateFilterIndex == 2 && recordDate.daysTo(today) > 7) {
continue;
}
if (m_dateFilterIndex == 3 && recordDate.daysTo(today) > 30) {
continue;
}
if (m_dateFilterIndex == 4 && recordDate != m_specificDate) {
continue;
}
m_filteredRecords.push_back(record);
}
std::sort(m_filteredRecords.begin(), m_filteredRecords.end(), [](const AttendanceRecord &left, const AttendanceRecord &right) {
return left.timestamp > right.timestamp;
});
items.reserve(m_filteredRecords.size());
for (const AttendanceRecord &record : m_filteredRecords) {
items.push_back({
record.timestamp,
record.classId,
mappedClassName(record),
record.studentId,
mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record),
record.confidence,
record.status,
isSuccessStatus(record.status)
});
}
m_recordModel.setItems(items);
emit summaryChanged();
}
void AppController::rebuildStatistics()
{
QMap<QString, StudentStatistics> statisticsMap;
const QDate today = QDate::currentDate();
for (const AttendanceRecord &record : m_allRecords) {
const QDate date = record.dateTime().date();
if (date < m_statisticsStartDate || date > m_statisticsEndDate) {
continue;
}
StudentStatistics &stats = statisticsMap[record.studentId];
stats.studentId = record.studentId;
stats.name = mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record);
stats.totalCount += 1;
stats.averageConfidence += record.confidence;
if (date == today) {
stats.todayCount += 1;
}
if (!stats.lastCheckIn.isValid() || record.dateTime() > stats.lastCheckIn) {
stats.lastCheckIn = record.dateTime();
}
}
QVector<StatisticsListModel::Item> items;
items.reserve(statisticsMap.size());
m_statisticsStudentCount = statisticsMap.size();
m_statisticsRecordCount = 0;
for (auto it = statisticsMap.begin(); it != statisticsMap.end(); ++it) {
StudentStatistics stats = it.value();
m_statisticsRecordCount += stats.totalCount;
if (stats.totalCount > 0) {
stats.averageConfidence /= static_cast<double>(stats.totalCount);
}
items.push_back({
stats.studentId,
stats.name,
stats.totalCount,
stats.todayCount,
stats.lastCheckIn.isValid() ? stats.lastCheckIn.toString("yyyy-MM-dd HH:mm:ss") : QStringLiteral("无"),
stats.averageConfidence
});
}
std::sort(items.begin(), items.end(), [](const StatisticsListModel::Item &left, const StatisticsListModel::Item &right) {
return left.studentId < right.studentId;
});
m_statisticsModel.setItems(items);
emit statisticsChanged();
}
QString AppController::mappedStudentName(const AttendanceRecord &record) const
{
return m_studentMappingModel.toMap().value(record.studentId);
}
QString AppController::mappedClassName(const AttendanceRecord &record) const
{
return m_classMappingModel.toMap().value(record.classId, record.classId);
}
void AppController::mergeFaceItems()
{
QSet<QString> allIds;
for (const QString &id : m_facePhotoIds) {
allIds.insert(id);
}
for (const QString &id : m_allStudentIds) {
allIds.insert(id);
}
for (auto it = m_faceIdMapping.cbegin(); it != m_faceIdMapping.cend(); ++it) {
allIds.insert(it.key());
}
QStringList sortedIds = allIds.values();
std::sort(sortedIds.begin(), sortedIds.end());
QVector<FaceListModel::Item> items;
items.reserve(sortedIds.size());
for (const QString &id : sortedIds) {
const QString photoUrl = m_facePhotoLocalUrls.value(id);
const bool hasPhoto = !photoUrl.isEmpty();
items.push_back({
id,
m_faceIdMapping.value(id, id),
hasPhoto,
photoUrl
});
}
m_faceModel.setItems(items);
}
void AppController::setStatusMessage(const QString &message)
{
const QString displayMessage = conciseStatusMessage(message);
if (displayMessage == m_statusMessage) {
return;
}
m_statusMessage = displayMessage;
emit statusMessageChanged();
}
void AppController::startReconnect(const QString &reason)
{
Q_UNUSED(reason)
if (!m_reconnecting) {
m_reconnecting = true;
m_reconnectAttempt = 0;
emit reconnectStateChanged();
}
if (m_reconnectCheckInFlight || m_reconnectTimer.isActive()) {
setStatusMessage(QStringLiteral("服务器未连接,稍后重试"));
return;
}
setStatusMessage(QStringLiteral("服务器未连接,稍后重试"));
scheduleReconnect();
}
void AppController::stopReconnect()
{
m_reconnectTimer.stop();
m_reconnectCheckInFlight = false;
if (!m_reconnecting && m_reconnectAttempt == 0) {
return;
}
m_reconnecting = false;
m_reconnectAttempt = 0;
emit reconnectStateChanged();
}
void AppController::scheduleReconnect()
{
m_reconnectTimer.start(ReconnectIntervalMs);
}
void AppController::runReconnectCheck()
{
if (!m_reconnecting || m_reconnectCheckInFlight) {
return;
}
m_reconnectCheckInFlight = true;
++m_reconnectAttempt;
emit reconnectStateChanged();
setStatusMessage(QStringLiteral("正在重连服务器"));
m_apiClient.checkHealth();
}
-155
View File
@@ -1,155 +0,0 @@
#ifndef APP_CONTROLLER_H
#define APP_CONTROLLER_H
#include <QDate>
#include <QHash>
#include <QMap>
#include <QObject>
#include <QStringList>
#include <QTimer>
#include <QVector>
#include "ApiClient.h"
#include "FaceListModel.h"
#include "MappingListModel.h"
#include "RecordListModel.h"
#include "StatisticsListModel.h"
#include "TimeSlotListModel.h"
class AppController : public QObject
{
Q_OBJECT
Q_PROPERTY(RecordListModel* recordModel READ recordModel CONSTANT)
Q_PROPERTY(StatisticsListModel* statisticsModel READ statisticsModel CONSTANT)
Q_PROPERTY(TimeSlotListModel* timeSlotModel READ timeSlotModel CONSTANT)
Q_PROPERTY(MappingListModel* studentMappingModel READ studentMappingModel CONSTANT)
Q_PROPERTY(MappingListModel* classMappingModel READ classMappingModel CONSTANT)
Q_PROPERTY(FaceListModel* faceModel READ faceModel CONSTANT)
Q_PROPERTY(QStringList classOptions READ classOptions NOTIFY filtersChanged)
Q_PROPERTY(QStringList studentOptions READ studentOptions NOTIFY filtersChanged)
Q_PROPERTY(QString serverUrl READ serverUrl WRITE setServerUrl NOTIFY serverUrlChanged)
Q_PROPERTY(QString statusMessage READ statusMessage NOTIFY statusMessageChanged)
Q_PROPERTY(bool reconnecting READ reconnecting NOTIFY reconnectStateChanged)
Q_PROPERTY(int reconnectAttempt READ reconnectAttempt NOTIFY reconnectStateChanged)
Q_PROPERTY(int totalRecords READ totalRecords NOTIFY summaryChanged)
Q_PROPERTY(int todayRecords READ todayRecords NOTIFY summaryChanged)
Q_PROPERTY(int filteredCount READ filteredCount NOTIFY summaryChanged)
Q_PROPERTY(int loadedRecords READ loadedRecords NOTIFY summaryChanged)
Q_PROPERTY(int statisticsStudentCount READ statisticsStudentCount NOTIFY statisticsChanged)
Q_PROPERTY(int statisticsRecordCount READ statisticsRecordCount NOTIFY statisticsChanged)
Q_PROPERTY(QString statisticsRangeText READ statisticsRangeText NOTIFY statisticsChanged)
public:
explicit AppController(QObject *parent = nullptr);
RecordListModel *recordModel();
StatisticsListModel *statisticsModel();
TimeSlotListModel *timeSlotModel();
MappingListModel *studentMappingModel();
MappingListModel *classMappingModel();
FaceListModel *faceModel();
QStringList classOptions() const;
QStringList studentOptions() const;
QString serverUrl() const;
QString statusMessage() const;
bool reconnecting() const;
int reconnectAttempt() const;
int totalRecords() const;
int todayRecords() const;
int filteredCount() const;
int loadedRecords() const;
int statisticsStudentCount() const;
int statisticsRecordCount() const;
QString statisticsRangeText() const;
void setServerUrl(const QString &url);
Q_INVOKABLE void refreshAll();
Q_INVOKABLE void refreshRecords();
Q_INVOKABLE void applyFilters(const QString &classId,
const QString &studentId,
const QString &nameFilter,
int dateFilterIndex,
const QString &specificDate);
Q_INVOKABLE void updateStatisticsRange(const QString &startDate, const QString &endDate);
Q_INVOKABLE bool exportFilteredRecords(const QString &fileUrl);
Q_INVOKABLE bool exportStatistics(const QString &fileUrl);
Q_INVOKABLE void clearAllRecords();
Q_INVOKABLE void saveTimeSlots();
Q_INVOKABLE void reloadTimeSlotsFromServer();
Q_INVOKABLE void loadMappingsFromServer();
Q_INVOKABLE void saveMappings();
Q_INVOKABLE void refreshFaces();
Q_INVOKABLE void saveFaceMappings();
Q_INVOKABLE void deleteFace(const QString &studentId, bool deleteRecords);
Q_INVOKABLE void testConnection();
signals:
void filtersChanged();
void serverUrlChanged();
void statusMessageChanged();
void reconnectStateChanged();
void summaryChanged();
void statisticsChanged();
private:
void loadLocalState();
void saveServerUrl();
void saveMappingsToLocal();
void loadMappingsFromLocal();
void saveTimeSlotsToLocal();
void loadTimeSlotsFromLocal();
QString localSettingsPath(const QString &fileName) const;
void updateFilterOptions();
void rebuildFilteredRecords();
void rebuildStatistics();
QString mappedStudentName(const AttendanceRecord &record) const;
QString mappedClassName(const AttendanceRecord &record) const;
QString localFacePhotoPath(const QString &faceId) const;
void mergeFaceItems();
void setStatusMessage(const QString &message);
void startReconnect(const QString &reason);
void stopReconnect();
void scheduleReconnect();
void runReconnectCheck();
ApiClient m_apiClient;
QTimer m_reconnectTimer;
RecordListModel m_recordModel;
StatisticsListModel m_statisticsModel;
TimeSlotListModel m_timeSlotModel;
MappingListModel m_studentMappingModel;
MappingListModel m_classMappingModel;
FaceListModel m_faceModel;
QVector<AttendanceRecord> m_allRecords;
QVector<AttendanceRecord> m_filteredRecords;
QStringList m_classOptions;
QStringList m_studentOptions;
QString m_serverUrl;
QString m_statusMessage;
bool m_reconnecting = false;
bool m_reconnectCheckInFlight = false;
int m_reconnectAttempt = 0;
int m_totalRecords = 0;
int m_todayRecords = 0;
QString m_selectedClassId = "全部";
QString m_selectedStudentId = "全部";
QString m_nameFilter;
int m_dateFilterIndex = 0;
QDate m_specificDate = QDate::currentDate();
QDate m_statisticsStartDate = QDate::currentDate().addDays(-30);
QDate m_statisticsEndDate = QDate::currentDate();
int m_statisticsStudentCount = 0;
int m_statisticsRecordCount = 0;
qulonglong m_qtSyncVersion = 0;
QMap<QString, QString> m_faceIdMapping;
QHash<QString, QString> m_facePhotoLocalUrls;
QStringList m_facePhotoIds;
QStringList m_allStudentIds;
};
#endif
-54
View File
@@ -1,54 +0,0 @@
#ifndef ATTENDANCE_TYPES_H
#define ATTENDANCE_TYPES_H
#include <QDate>
#include <QDateTime>
#include <QString>
#include <QTime>
#include <QVector>
struct AttendanceRecord
{
QString timestamp;
QString studentId;
QString originalName;
double confidence = 0.0;
QString status;
QString classId = "0";
QDateTime dateTime() const
{
return QDateTime::fromString(timestamp, "yyyy-MM-dd HH:mm:ss");
}
};
struct StudentStatistics
{
QString studentId;
QString name;
int totalCount = 0;
int todayCount = 0;
QDateTime lastCheckIn;
double averageConfidence = 0.0;
};
struct TimeSlot
{
QString name;
QTime startTime;
QTime endTime;
bool enabled = true;
};
inline QVector<TimeSlot> defaultTimeSlots()
{
return {};
}
Q_DECLARE_METATYPE(AttendanceRecord)
Q_DECLARE_METATYPE(StudentStatistics)
Q_DECLARE_METATYPE(TimeSlot)
Q_DECLARE_METATYPE(QVector<AttendanceRecord>)
Q_DECLARE_METATYPE(QVector<TimeSlot>)
#endif
-48
View File
@@ -1,48 +0,0 @@
#include "CryptoUtils.h"
#include "crypto/tiny_aes.hpp"
namespace {
QByteArray removePkcs7Padding(const QByteArray &data)
{
if (data.isEmpty()) {
return {};
}
const int padding = static_cast<unsigned char>(data.back());
if (padding < 1 || padding > AES_BLOCKLEN || padding > data.size()) {
return data;
}
for (int i = data.size() - padding; i < data.size(); ++i) {
if (static_cast<unsigned char>(data.at(i)) != padding) {
return data;
}
}
return data.left(data.size() - padding);
}
}
QByteArray CryptoUtils::decryptAes128CbcBase64(const QByteArray &encryptedBase64)
{
static const uint8_t key[AES_KEYLEN] = {
0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6,
0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c
};
static const uint8_t iv[AES_BLOCKLEN] = {
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f
};
const QByteArray encrypted = QByteArray::fromBase64(encryptedBase64);
if (encrypted.isEmpty() || (encrypted.size() % AES_BLOCKLEN) != 0) {
return {};
}
QByteArray buffer = encrypted;
AES_ctx ctx;
AES_init_ctx_iv(&ctx, key, iv);
AES_CBC_decrypt_buffer(&ctx, reinterpret_cast<uint8_t*>(buffer.data()), static_cast<size_t>(buffer.size()));
return removePkcs7Padding(buffer);
}
-12
View File
@@ -1,12 +0,0 @@
#ifndef CRYPTO_UTILS_H
#define CRYPTO_UTILS_H
#include <QByteArray>
class CryptoUtils
{
public:
static QByteArray decryptAes128CbcBase64(const QByteArray &encryptedBase64);
};
#endif
-85
View File
@@ -1,85 +0,0 @@
#include "FaceListModel.h"
FaceListModel::FaceListModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int FaceListModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_items.size();
}
QVariant FaceListModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) {
return {};
}
const Item &item = m_items.at(index.row());
switch (role) {
case FaceIdRole: return item.faceId;
case StudentIdRole: return item.studentId;
case HasPhotoRole: return item.hasPhoto;
case PhotoUrlRole: return item.photoUrl;
default: return {};
}
}
bool FaceListModel::setData(const QModelIndex &index, const QVariant &value, int role)
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) {
return false;
}
if (role == StudentIdRole) {
m_items[index.row()].studentId = value.toString().trimmed();
emit dataChanged(index, index, {role});
return true;
}
return false;
}
Qt::ItemFlags FaceListModel::flags(const QModelIndex &index) const
{
if (!index.isValid()) {
return Qt::NoItemFlags;
}
return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable;
}
QHash<int, QByteArray> FaceListModel::roleNames() const
{
return {
{FaceIdRole, "faceId"},
{StudentIdRole, "studentId"},
{HasPhotoRole, "hasPhoto"},
{PhotoUrlRole, "photoUrl"}
};
}
void FaceListModel::setItems(const QVector<Item> &items)
{
beginResetModel();
m_items = items;
endResetModel();
}
QVector<FaceListModel::Item> FaceListModel::items() const
{
return m_items;
}
void FaceListModel::updateStudentId(int index, const QString &studentId)
{
if (index < 0 || index >= m_items.size()) {
return;
}
m_items[index].studentId = studentId.trimmed();
emit dataChanged(this->index(index), this->index(index), {StudentIdRole});
}
-47
View File
@@ -1,47 +0,0 @@
#ifndef FACE_LIST_MODEL_H
#define FACE_LIST_MODEL_H
#include <QAbstractListModel>
#include <QByteArray>
#include <QHash>
#include <QModelIndex>
#include <QString>
#include <QVariant>
#include <QVector>
class FaceListModel : public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
FaceIdRole = Qt::UserRole + 1,
StudentIdRole,
HasPhotoRole,
PhotoUrlRole
};
explicit FaceListModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
bool setData(const QModelIndex &index, const QVariant &value, int role) override;
Qt::ItemFlags flags(const QModelIndex &index) const override;
QHash<int, QByteArray> roleNames() const override;
struct Item {
QString faceId;
QString studentId;
bool hasPhoto = false;
QString photoUrl;
};
void setItems(const QVector<Item> &items);
QVector<Item> items() const;
Q_INVOKABLE void updateStudentId(int index, const QString &studentId);
private:
QVector<Item> m_items;
};
#endif
-132
View File
@@ -1,132 +0,0 @@
#include "MappingListModel.h"
#include <algorithm>
MappingListModel::MappingListModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int MappingListModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_items.size();
}
QVariant MappingListModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) {
return {};
}
const Item &item = m_items.at(index.row());
switch (role) {
case KeyRole: return item.key;
case ValueRole: return item.value;
default: return {};
}
}
bool MappingListModel::setData(const QModelIndex &index, const QVariant &value, int role)
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) {
return false;
}
Item &item = m_items[index.row()];
switch (role) {
case KeyRole: item.key = value.toString().trimmed(); break;
case ValueRole: item.value = value.toString().trimmed(); break;
default: return false;
}
emit dataChanged(index, index, {role});
return true;
}
Qt::ItemFlags MappingListModel::flags(const QModelIndex &index) const
{
if (!index.isValid()) {
return Qt::NoItemFlags;
}
return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable;
}
QHash<int, QByteArray> MappingListModel::roleNames() const
{
return {
{KeyRole, "entryKey"},
{ValueRole, "entryValue"}
};
}
void MappingListModel::setMap(const QMap<QString, QString> &map)
{
QVector<Item> items;
items.reserve(map.size());
for (auto it = map.begin(); it != map.end(); ++it) {
items.push_back({it.key(), it.value()});
}
std::sort(items.begin(), items.end(), [](const Item &left, const Item &right) {
bool leftNumeric = false;
bool rightNumeric = false;
int leftValue = left.key.toInt(&leftNumeric);
int rightValue = right.key.toInt(&rightNumeric);
if (leftNumeric && rightNumeric) {
return leftValue < rightValue;
}
return left.key < right.key;
});
beginResetModel();
m_items = items;
endResetModel();
}
QMap<QString, QString> MappingListModel::toMap() const
{
QMap<QString, QString> map;
for (const Item &item : m_items) {
if (!item.key.trimmed().isEmpty()) {
map[item.key.trimmed()] = item.value.trimmed();
}
}
return map;
}
void MappingListModel::addEntry(const QString &key, const QString &value)
{
beginInsertRows(QModelIndex(), m_items.size(), m_items.size());
m_items.push_back({key.trimmed(), value.trimmed()});
endInsertRows();
}
void MappingListModel::updateEntry(int index, const QString &key, const QString &value)
{
if (index < 0 || index >= m_items.size()) {
return;
}
m_items[index] = {key.trimmed(), value.trimmed()};
emit dataChanged(this->index(index), this->index(index));
}
void MappingListModel::removeEntry(int index)
{
if (index < 0 || index >= m_items.size()) {
return;
}
beginRemoveRows(QModelIndex(), index, index);
m_items.removeAt(index);
endRemoveRows();
}
void MappingListModel::clear()
{
beginResetModel();
m_items.clear();
endResetModel();
}
-48
View File
@@ -1,48 +0,0 @@
#ifndef MAPPING_LIST_MODEL_H
#define MAPPING_LIST_MODEL_H
#include <QAbstractListModel>
#include <QByteArray>
#include <QHash>
#include <QMap>
#include <QModelIndex>
#include <QString>
#include <QVariant>
#include <QVector>
class MappingListModel : public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
KeyRole = Qt::UserRole + 1,
ValueRole
};
explicit MappingListModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
bool setData(const QModelIndex &index, const QVariant &value, int role) override;
Qt::ItemFlags flags(const QModelIndex &index) const override;
QHash<int, QByteArray> roleNames() const override;
void setMap(const QMap<QString, QString> &map);
QMap<QString, QString> toMap() const;
Q_INVOKABLE void addEntry(const QString &key, const QString &value);
Q_INVOKABLE void updateEntry(int index, const QString &key, const QString &value);
Q_INVOKABLE void removeEntry(int index);
Q_INVOKABLE void clear();
private:
struct Item {
QString key;
QString value;
};
QVector<Item> m_items;
};
#endif
-59
View File
@@ -1,59 +0,0 @@
#include "RecordListModel.h"
RecordListModel::RecordListModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int RecordListModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_items.size();
}
QVariant RecordListModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) {
return {};
}
const Item &item = m_items.at(index.row());
switch (role) {
case TimestampRole: return item.timestamp;
case ClassIdRole: return item.classId;
case ClassNameRole: return item.className;
case StudentIdRole: return item.studentId;
case NameRole: return item.name;
case ConfidenceRole: return item.confidence;
case ConfidenceTextRole: return QString::number(item.confidence * 100.0, 'f', 2) + "%";
case StatusRole: return item.status;
case StatusTextRole: return item.success ? QStringLiteral("成功") : QStringLiteral("失败");
case SuccessRole: return item.success;
default: return {};
}
}
QHash<int, QByteArray> RecordListModel::roleNames() const
{
return {
{TimestampRole, "timestamp"},
{ClassIdRole, "classId"},
{ClassNameRole, "className"},
{StudentIdRole, "studentId"},
{NameRole, "name"},
{ConfidenceRole, "confidence"},
{ConfidenceTextRole, "confidenceText"},
{StatusRole, "status"},
{StatusTextRole, "statusText"},
{SuccessRole, "success"}
};
}
void RecordListModel::setItems(const QVector<Item> &items)
{
beginResetModel();
m_items = items;
endResetModel();
}
-53
View File
@@ -1,53 +0,0 @@
#ifndef RECORD_LIST_MODEL_H
#define RECORD_LIST_MODEL_H
#include <QAbstractListModel>
#include <QByteArray>
#include <QHash>
#include <QModelIndex>
#include <QString>
#include <QVariant>
#include <QVector>
class RecordListModel : public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
TimestampRole = Qt::UserRole + 1,
ClassIdRole,
ClassNameRole,
StudentIdRole,
NameRole,
ConfidenceRole,
ConfidenceTextRole,
StatusRole,
StatusTextRole,
SuccessRole
};
explicit RecordListModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
struct Item {
QString timestamp;
QString classId;
QString className;
QString studentId;
QString name;
double confidence = 0.0;
QString status;
bool success = false;
};
void setItems(const QVector<Item> &items);
private:
QVector<Item> m_items;
};
#endif
-53
View File
@@ -1,53 +0,0 @@
#include "StatisticsListModel.h"
StatisticsListModel::StatisticsListModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int StatisticsListModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_items.size();
}
QVariant StatisticsListModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) {
return {};
}
const Item &item = m_items.at(index.row());
switch (role) {
case StudentIdRole: return item.studentId;
case NameRole: return item.name;
case TotalCountRole: return item.totalCount;
case TodayCountRole: return item.todayCount;
case LastCheckInRole: return item.lastCheckIn;
case AverageConfidenceRole: return item.averageConfidence;
case AverageConfidenceTextRole: return QString::number(item.averageConfidence * 100.0, 'f', 2) + "%";
default: return {};
}
}
QHash<int, QByteArray> StatisticsListModel::roleNames() const
{
return {
{StudentIdRole, "studentId"},
{NameRole, "name"},
{TotalCountRole, "totalCount"},
{TodayCountRole, "todayCount"},
{LastCheckInRole, "lastCheckIn"},
{AverageConfidenceRole, "averageConfidence"},
{AverageConfidenceTextRole, "averageConfidenceText"}
};
}
void StatisticsListModel::setItems(const QVector<Item> &items)
{
beginResetModel();
m_items = items;
endResetModel();
}
-48
View File
@@ -1,48 +0,0 @@
#ifndef STATISTICS_LIST_MODEL_H
#define STATISTICS_LIST_MODEL_H
#include <QAbstractListModel>
#include <QByteArray>
#include <QHash>
#include <QModelIndex>
#include <QString>
#include <QVariant>
#include <QVector>
class StatisticsListModel : public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
StudentIdRole = Qt::UserRole + 1,
NameRole,
TotalCountRole,
TodayCountRole,
LastCheckInRole,
AverageConfidenceRole,
AverageConfidenceTextRole
};
explicit StatisticsListModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
struct Item {
QString studentId;
QString name;
int totalCount = 0;
int todayCount = 0;
QString lastCheckIn;
double averageConfidence = 0.0;
};
void setItems(const QVector<Item> &items);
private:
QVector<Item> m_items;
};
#endif
-146
View File
@@ -1,146 +0,0 @@
#include "TimeSlotListModel.h"
#include <QJsonObject>
TimeSlotListModel::TimeSlotListModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int TimeSlotListModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_timeSlots.size();
}
QVariant TimeSlotListModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_timeSlots.size()) {
return {};
}
const TimeSlot &slot = m_timeSlots.at(index.row());
switch (role) {
case NameRole: return slot.name;
case StartTimeRole: return slot.startTime.toString("HH:mm");
case EndTimeRole: return slot.endTime.toString("HH:mm");
case EnabledRole: return slot.enabled;
default: return {};
}
}
bool TimeSlotListModel::setData(const QModelIndex &index, const QVariant &value, int role)
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_timeSlots.size()) {
return false;
}
TimeSlot &slot = m_timeSlots[index.row()];
switch (role) {
case NameRole: slot.name = value.toString(); break;
case StartTimeRole: slot.startTime = QTime::fromString(value.toString(), "HH:mm"); break;
case EndTimeRole: slot.endTime = QTime::fromString(value.toString(), "HH:mm"); break;
case EnabledRole: slot.enabled = value.toBool(); break;
default: return false;
}
emit dataChanged(index, index, {role});
return true;
}
Qt::ItemFlags TimeSlotListModel::flags(const QModelIndex &index) const
{
if (!index.isValid()) {
return Qt::NoItemFlags;
}
return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable;
}
QHash<int, QByteArray> TimeSlotListModel::roleNames() const
{
return {
{NameRole, "name"},
{StartTimeRole, "startTime"},
{EndTimeRole, "endTime"},
{EnabledRole, "enabled"}
};
}
void TimeSlotListModel::setTimeSlots(const QVector<TimeSlot> &timeSlots)
{
beginResetModel();
m_timeSlots = timeSlots;
endResetModel();
}
QVector<TimeSlot> TimeSlotListModel::timeSlots() const
{
return m_timeSlots;
}
QJsonArray TimeSlotListModel::toJsonArray() const
{
QJsonArray array;
for (const TimeSlot &slot : m_timeSlots) {
QJsonObject object;
object["name"] = slot.name;
object["start_time"] = slot.startTime.toString("HH:mm");
object["end_time"] = slot.endTime.toString("HH:mm");
object["enabled"] = slot.enabled;
array.append(object);
}
return array;
}
QVariantMap TimeSlotListModel::get(int index) const
{
if (index < 0 || index >= m_timeSlots.size()) {
return {};
}
const TimeSlot &slot = m_timeSlots.at(index);
return {
{"name", slot.name},
{"startTime", slot.startTime.toString("HH:mm")},
{"endTime", slot.endTime.toString("HH:mm")},
{"enabled", slot.enabled}
};
}
void TimeSlotListModel::addTimeSlot(const QString &name, const QString &startTime, const QString &endTime, bool enabled)
{
beginInsertRows(QModelIndex(), m_timeSlots.size(), m_timeSlots.size());
m_timeSlots.push_back({name.trimmed(), QTime::fromString(startTime, "HH:mm"), QTime::fromString(endTime, "HH:mm"), enabled});
endInsertRows();
}
void TimeSlotListModel::updateTimeSlot(int index, const QString &name, const QString &startTime, const QString &endTime, bool enabled)
{
if (index < 0 || index >= m_timeSlots.size()) {
return;
}
TimeSlot &slot = m_timeSlots[index];
slot.name = name.trimmed();
slot.startTime = QTime::fromString(startTime, "HH:mm");
slot.endTime = QTime::fromString(endTime, "HH:mm");
slot.enabled = enabled;
emit dataChanged(this->index(index), this->index(index));
}
void TimeSlotListModel::removeTimeSlot(int index)
{
if (index < 0 || index >= m_timeSlots.size()) {
return;
}
beginRemoveRows(QModelIndex(), index, index);
m_timeSlots.removeAt(index);
endRemoveRows();
}
void TimeSlotListModel::resetToDefault()
{
setTimeSlots(defaultTimeSlots());
}
-48
View File
@@ -1,48 +0,0 @@
#ifndef TIMESLOT_LIST_MODEL_H
#define TIMESLOT_LIST_MODEL_H
#include <QAbstractListModel>
#include <QByteArray>
#include <QHash>
#include <QJsonArray>
#include <QModelIndex>
#include <QVariant>
#include <QVariantMap>
#include <QVector>
#include "AttendanceTypes.h"
class TimeSlotListModel : public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
NameRole = Qt::UserRole + 1,
StartTimeRole,
EndTimeRole,
EnabledRole
};
explicit TimeSlotListModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
bool setData(const QModelIndex &index, const QVariant &value, int role) override;
Qt::ItemFlags flags(const QModelIndex &index) const override;
QHash<int, QByteArray> roleNames() const override;
void setTimeSlots(const QVector<TimeSlot> &timeSlots);
QVector<TimeSlot> timeSlots() const;
QJsonArray toJsonArray() const;
Q_INVOKABLE QVariantMap get(int index) const;
Q_INVOKABLE void addTimeSlot(const QString &name, const QString &startTime, const QString &endTime, bool enabled);
Q_INVOKABLE void updateTimeSlot(int index, const QString &name, const QString &startTime, const QString &endTime, bool enabled);
Q_INVOKABLE void removeTimeSlot(int index);
Q_INVOKABLE void resetToDefault();
private:
QVector<TimeSlot> m_timeSlots;
};
#endif
-217
View File
@@ -1,217 +0,0 @@
/*
* Tiny AES128 C++ 移植版本
* 与STM32端使用相同的AES实现,保证兼容性
*/
#ifndef _TINY_AES_HPP_
#define _TINY_AES_HPP_
#include <cstdint>
#include <cstring>
#define AES_BLOCKLEN 16
#define AES_KEYLEN 16
#define AES_keyExpSize 176
struct AES_ctx
{
uint8_t RoundKey[AES_keyExpSize];
uint8_t Iv[AES_BLOCKLEN];
};
static const uint8_t sbox[256] = {
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16
};
static const uint8_t rsbox[256] = {
0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb,
0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb,
0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e,
0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25,
0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92,
0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84,
0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06,
0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b,
0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73,
0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e,
0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b,
0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4,
0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f,
0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef,
0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61,
0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d
};
static const uint8_t Rcon[11] = {
0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36
};
#define getSBoxValue(num) (sbox[(num)])
#define getSBoxInvert(num) (rsbox[(num)])
inline void KeyExpansion(uint8_t *RoundKey, const uint8_t *Key)
{
unsigned i, j, k;
uint8_t tempa[4];
for (i = 0; i < 4; ++i) {
RoundKey[(i * 4) + 0] = Key[(i * 4) + 0];
RoundKey[(i * 4) + 1] = Key[(i * 4) + 1];
RoundKey[(i * 4) + 2] = Key[(i * 4) + 2];
RoundKey[(i * 4) + 3] = Key[(i * 4) + 3];
}
for (i = 4; i < 44; ++i) {
k = (i - 1) * 4;
tempa[0] = RoundKey[k + 0];
tempa[1] = RoundKey[k + 1];
tempa[2] = RoundKey[k + 2];
tempa[3] = RoundKey[k + 3];
if (i % 4 == 0) {
const uint8_t u8tmp = tempa[0];
tempa[0] = tempa[1];
tempa[1] = tempa[2];
tempa[2] = tempa[3];
tempa[3] = u8tmp;
tempa[0] = getSBoxValue(tempa[0]);
tempa[1] = getSBoxValue(tempa[1]);
tempa[2] = getSBoxValue(tempa[2]);
tempa[3] = getSBoxValue(tempa[3]);
tempa[0] ^= Rcon[i / 4];
}
j = i * 4;
k = (i - 4) * 4;
RoundKey[j + 0] = RoundKey[k + 0] ^ tempa[0];
RoundKey[j + 1] = RoundKey[k + 1] ^ tempa[1];
RoundKey[j + 2] = RoundKey[k + 2] ^ tempa[2];
RoundKey[j + 3] = RoundKey[k + 3] ^ tempa[3];
}
}
static void AddRoundKey(uint8_t round, uint8_t *state, const uint8_t *RoundKey)
{
uint8_t i, j;
for (i = 0; i < 4; ++i) {
for (j = 0; j < 4; ++j) {
state[i * 4 + j] ^= RoundKey[(round * 16) + (i * 4 + j)];
}
}
}
static void InvSubBytes(uint8_t *state)
{
for (uint8_t i = 0; i < 16; ++i) {
state[i] = getSBoxInvert(state[i]);
}
}
static void InvShiftRows(uint8_t *state)
{
uint8_t temp;
temp = state[13];
state[13] = state[9];
state[9] = state[5];
state[5] = state[1];
state[1] = temp;
temp = state[2];
state[2] = state[10];
state[10] = temp;
temp = state[6];
state[6] = state[14];
state[14] = temp;
temp = state[3];
state[3] = state[7];
state[7] = state[11];
state[11] = state[15];
state[15] = temp;
}
static uint8_t xtime(uint8_t x)
{
return ((x << 1) ^ (((x >> 7) & 1U) * 0x1bU));
}
static uint8_t Multiply(uint8_t x, uint8_t y)
{
return (((y & 1U) * x) ^
(((y >> 1U) & 1U) * xtime(x)) ^
(((y >> 2U) & 1U) * xtime(xtime(x))) ^
(((y >> 3U) & 1U) * xtime(xtime(xtime(x)))) ^
(((y >> 4U) & 1U) * xtime(xtime(xtime(xtime(x))))));
}
static void InvMixColumns(uint8_t *state)
{
int i;
uint8_t a, b, c, d;
for (i = 0; i < 4; ++i) {
a = state[i * 4 + 0];
b = state[i * 4 + 1];
c = state[i * 4 + 2];
d = state[i * 4 + 3];
state[i * 4 + 0] = Multiply(a, 0x0eU) ^ Multiply(b, 0x0bU) ^ Multiply(c, 0x0dU) ^ Multiply(d, 0x09U);
state[i * 4 + 1] = Multiply(a, 0x09U) ^ Multiply(b, 0x0eU) ^ Multiply(c, 0x0bU) ^ Multiply(d, 0x0dU);
state[i * 4 + 2] = Multiply(a, 0x0dU) ^ Multiply(b, 0x09U) ^ Multiply(c, 0x0eU) ^ Multiply(d, 0x0bU);
state[i * 4 + 3] = Multiply(a, 0x0bU) ^ Multiply(b, 0x0dU) ^ Multiply(c, 0x09U) ^ Multiply(d, 0x0eU);
}
}
inline void InvCipher(uint8_t *state, const uint8_t *RoundKey)
{
uint8_t round = 0;
AddRoundKey(10, state, RoundKey);
for (round = 9; ; --round) {
InvShiftRows(state);
InvSubBytes(state);
AddRoundKey(round, state, RoundKey);
if (round == 0) {
break;
}
InvMixColumns(state);
}
}
inline void AES_init_ctx_iv(struct AES_ctx *ctx, const uint8_t *key, const uint8_t *iv)
{
KeyExpansion(ctx->RoundKey, key);
memcpy(ctx->Iv, iv, AES_BLOCKLEN);
}
inline void AES_CBC_decrypt_buffer(struct AES_ctx *ctx, uint8_t *buf, size_t length)
{
size_t i;
uint8_t storeNextIv[AES_BLOCKLEN];
for (i = 0; i < length; i += AES_BLOCKLEN) {
memcpy(storeNextIv, buf + i, AES_BLOCKLEN);
InvCipher(buf + i, ctx->RoundKey);
for (uint8_t j = 0; j < AES_BLOCKLEN; ++j) {
buf[i + j] ^= ctx->Iv[j];
}
memcpy(ctx->Iv, storeNextIv, AES_BLOCKLEN);
}
}
#endif
-357
View File
@@ -1,357 +0,0 @@
# 人脸识别考勤系统项目总文档
## 1. 这是什么项目
本项目是一个多端协同的人脸识别考勤系统。它不是只有一个程序,而是由四个主要部分组成:
| 部分 | 所在目录 | 主要职责 | 可以把它理解成 |
| --- | --- | --- | --- |
| STM32 固件端 | `User/`、`Hardware/`、`System/`、`Library/`、`Start/` | 接收 K210 识别结果、判断是否允许签到、加密上传记录、控制 OLED/蜂鸣器/指示灯/补光灯 | 考勤机主控板 |
| K210 识别端 | `k210/` | 摄像头采集、人脸检测、人脸特征比对、添加/删除本地人脸、上传人脸照片、同步人脸映射 | 摄像头和 AI 识别模块 |
| C++ 服务端 | `server/` | 提供 HTTP API、保存考勤记录、保存配置、管理照片和同步状态 | 中心服务器 |
| Qt 管理端 | `QT/` | Windows/Android 图形管理界面,用于查看记录、统计、配置时间段、管理映射和照片 | 管理后台 |
整个系统的目标是:
1. K210 看到人脸并识别出一个人。
2. K210 通过串口把识别结果发给 STM32。
3. STM32 判断当前是否在允许签到的时间段内,并检查是否重复签到。
4. 如果允许签到,STM32 将签到数据用 AES-128-CBC 加密后通过 ESP8266 发给服务端。
5. 服务端保存数据,并提供给 Qt 管理端查看、统计、导出和管理。
6. Qt 管理端修改的配置会同步给服务端,再由 STM32 或 K210 拉取。
## 2. 新手先理解几个概念
### 2.1 人脸 ID 和学号不是同一个东西
K210 本地添加人脸时,会生成一个原始人脸编号,例如 `ID:1`、`ID:2`。这个编号只表示 K210 本地第几个录入的人脸。
真实考勤需要的是学号,例如 `20240001`。所以项目里有一个“人脸 ID 到学号”的映射:
```json
{
"mappings": {
"1": "20240001",
"2": "20240002"
}
}
```
含义是:K210 本地人脸 `1` 对应真实学号 `20240001`。
### 2.2 签到时间段默认是空的
当前代码中,系统启动时不再自动创建“上午签到”“中午签退”“下午签到”“晚上签退”这四个默认时间段。
也就是说,新部署时:
```json
{
"time_slots": []
}
```
如果没有配置时间段,STM32 会认为当前不在任何考勤时段内,识别到人脸也不会允许签到。需要在 Qt 管理端“配置中心”里新增时间段并保存同步。
### 2.3 服务端是数据中心
当前服务端以 SQLite 数据库为主,数据库文件是:
```text
attendance.db
```
服务端还会使用以下本地资源:
| 文件或目录 | 作用 |
| --- | --- |
| `attendance.db` | 主数据库,保存记录、配置、映射、同步状态 |
| `faces/` | 人脸照片目录,保存 K210 上传的 `.jpg` |
| `backups/` | 数据库备份目录 |
| `attendance_database.csv` | 旧版本记录文件,首次初始化时可导入 |
| `timeslots_config.json` | 旧版本时间段配置,首次初始化时可导入 |
| `mapping_config.json` | 旧版本姓名/班级映射,首次初始化时可导入 |
| `face_id_mapping.json` | 旧版本人脸映射,首次初始化时可导入 |
| `pending_delete.json` | 旧版本待删除人脸列表,首次初始化时可导入 |
| `sync_request.json` | 旧版本同步请求标志,首次初始化时可导入 |
| `sync_version.txt` | 旧版本同步版本号,首次初始化时可导入 |
### 2.4 同步版本号是什么
服务端维护一个全局同步版本号 `sync_version`。当考勤记录、时间段、映射、人脸照片、删除任务等数据发生变化时,服务端会把版本号加一。
设备同步时会带上自己上次同步到的版本:
```text
GET /api/v1/sync/state?client=stm32&last_version=3
```
如果服务端当前版本还是 `3`,会返回 `changed:false`。如果服务端当前版本是 `4` 或更高,会返回 `changed:true` 并带上该设备需要的数据。
这样做的好处是:设备不用不停下载所有数据,只要版本没变就不用重新处理。
## 3. 总体架构
```mermaid
flowchart LR
Camera["摄像头"] --> K210["K210 识别端"]
K210 -->|"UART1: ID, NAME, CONF"| STM32["STM32 主控"]
STM32 -->|"USART1 AT 指令"| ESP1["ESP8266"]
ESP1 -->|"HTTP + AES 签到"| Server["C++ 服务端 8082"]
K210 -->|"UART2 AT 指令"| ESP2["K210 侧 ESP8266"]
ESP2 -->|"HTTP 上传照片/同步映射"| Server
Qt["Qt 管理端 Windows/Android"] -->|"HTTP API"| Server
Server --> SQLite["attendance.db"]
Server --> Faces["faces/*.jpg"]
```
## 4. 数据流说明
### 4.1 正常签到流程
1. K210 摄像头采集画面。
2. K210 使用三个 KPU 模型执行人脸检测、关键点定位和特征提取。
3. K210 将当前人脸特征与 SD 卡中的 `recordftr.txt` 做比对。
4. 如果分数大于阈值,K210 认为识别成功。
5. K210 通过 UART1 发送给 STM32:
```text
ID:20240001,NAME:20240001,CONF:0.95
```
6. STM32 解析串口数据。
7. STM32 检查置信度是否超过 `0.75`。
8. STM32 检查当前时间是否落在已启用的时间段内。
9. STM32 检查本地缓存、今日记录缓存和服务端查重结果,避免同一个学生在同一时段重复签到。
10. STM32 生成 JSON:
```json
{
"student_id": "20240001",
"name": "20240001",
"confidence": 0.95,
"status": "success",
"class_id": "1"
}
```
11. STM32 使用 AES-128-CBC 加密并做 Base64 编码。
12. STM32 通过 ESP8266 发送:
```text
POST /api/v1/attendance/checkins
X-Api-Token: ...
X-Encrypted: AES128-CBC
```
13. 服务端解密、校验字段、写入 SQLite。
14. Qt 管理端刷新后可以看到新记录。
### 4.2 新增人脸流程
1. 在 K210 正常识别界面短按按键,进入添加人脸模式。
2. 摄像头检测到陌生人脸后,再短按确认。
3. K210 将人脸特征保存到 SD 卡 `recordftr.txt`。
4. K210 将对应照片保存到 `/sd/face_photos/<face_id>.jpg`。
5. K210 将照片 ID 放入上传队列。
6. 网络任务空闲时,K210 通过 ESP8266 上传照片到服务端:
```text
POST /api/v1/faces/photos/<face_id>
```
7. Qt 管理端进入“人脸管理”,刷新后可以看到照片。
8. 管理员为该 face_id 填写真实学号并保存映射。
9. 服务端保存映射并触发同步。
10. K210 拉取同步状态后,把本地人脸名称从 `ID:<face_id>` 更新为真实学号。
### 4.3 删除人脸流程
1. Qt 管理端删除某个学生或人脸。
2. 服务端删除照片、映射,按需要删除考勤记录。
3. 服务端写入待删除任务 `pending_delete`,并提升同步版本。
4. K210 定期检查同步请求或拉取统一同步状态。
5. K210 在本地删除对应特征和照片。
6. K210 向服务端发送 ACK:
```text
POST /api/v1/sync/ack
```
7. 服务端移除待删除任务。
## 5. 目录结构
```text
Reconstruct_Qt/
├── docs/ # 项目文档
├── server/
│ ├── server_encrypted.cpp # C++ HTTP 服务端,当前推荐版本
│ ├── regression_api_tests.ps1 # Windows API 回归检查脚本
│ └── tiny_aes.hpp # AES 辅助代码
├── QT/
│ ├── CMakeLists.txt # Qt 客户端构建配置
│ ├── main.cpp # Qt 程序入口
│ ├── qml/Main.qml # Qt 主界面
│ ├── src/ # Qt C++ 业务逻辑、网络客户端、模型
│ ├── android/ # Android 打包配置和图标
│ └── assets/ # 桌面端图标
├── k210/
│ ├── main.py # K210 主程序
│ └── view_attendance.py # 旧版/辅助查看本地记录脚本
├── User/
│ └── main.c # STM32 主循环
├── Hardware/ # STM32 外设驱动与业务模块
├── System/ # STM32 延时、调试等基础模块
├── Library/ # STM32 标准外设库
├── Start/ # 启动文件和芯片头文件
├── Project.uvprojx # Keil 工程文件
├── Project.uvoptx # Keil 选项文件
└── 连接图.md # 硬件接线表
```
## 6. 默认配置总览
| 配置项 | 当前值 | 所在位置 |
| --- | --- | --- |
| 服务端端口 | `8082` | `server/server_encrypted.cpp` |
| 服务端默认 API Token | `atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV` | 服务端、Qt、STM32、K210 |
| 服务端默认 CORS 来源 | `http://localhost` | `server/server_encrypted.cpp` |
| STM32 WiFi SSID | `yzfh` | `User/main.c` |
| STM32 WiFi 密码 | `yzfhroot` | `User/main.c` |
| STM32 服务端 IP | `27.106.113.205` | `User/main.c` |
| STM32 班级编号 | `1` | `User/main.c` |
| K210 WiFi SSID | `yzfh` | `k210/main.py` |
| K210 WiFi 密码 | `yzfhroot` | `k210/main.py` |
| K210 服务端 IP | `27.106.113.205` | `k210/main.py` |
| Qt 默认服务端地址 | `http://127.0.0.1:8082` | `QT/src/ApiClient.cpp`、`QT/src/AppController.cpp` |
| 默认签到时间段 | 空数组 | Qt、服务端、STM32 |
| AES 算法 | AES-128-CBC + PKCS#7 + Base64 | `Hardware/Crypto.c`、`server/server_encrypted.cpp`、`QT/src/CryptoUtils.cpp` |
注意:这些配置里包含真实网络信息和默认 Token。如果要部署到公网,建议改成自己的值,并用环境变量覆盖服务端和 Qt 的 Token。
## 7. 硬件连接概览
完整连接表在根目录 `连接图.md`。常用连接如下:
| 模块 | STM32 引脚 | 功能 |
| --- | --- | --- |
| ESP8266 | PA9 / PA10 | USART1,STM32 通过 AT 指令访问 WiFi 和 HTTP |
| K210 | PB10 / PB11 | USART3,K210 把识别结果发给 STM32 |
| OLED | PB8 / PB9 | I2C 显示 |
| 调试串口 | PA2 / PA3 | USART2 调试输出 |
| 成功 LED | PB4 | 高电平点亮 |
| 蜂鸣器 | PB5 | 低电平触发 |
| 光敏传感器 | PB0 | ADC 输入 |
| 补光灯 | PB1 | 高电平点亮 |
| DS1302 CLK/DAT/RST | PA4 / PA5 / PA6 | 软件模拟时序 |
K210 侧:
| 模块 | K210 引脚 | 功能 |
| --- | --- | --- |
| STM32 UART | 24 / 25 | UART1,发送识别结果给 STM32 |
| ESP8266 UART | 34 / 35 | UART2,上传照片和同步配置 |
| 按键 | 16 | 短按添加,长按删除,超长按删除全部 |
| RGB LED | 12 / 13 / 14 | 状态提示 |
## 8. 加密与鉴权
### 8.1 API Token
除少数公开接口外,服务端要求请求头中带 Token:
```text
X-Api-Token: atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV
```
服务端可用环境变量覆盖:
```powershell
$env:ATTENDANCE_API_TOKEN="你的新Token"
```
Qt 端也会读取同名环境变量;STM32 和 K210 目前是写在代码里的常量,需要改代码后重新烧录或上传脚本。
### 8.2 AES 参数
三端必须保持完全一致:
| 参数 | 值 |
| --- | --- |
| 算法 | AES-128-CBC |
| 填充 | PKCS#7 |
| 编码 | Base64 |
| Key | `2b 7e 15 16 28 ae d2 a6 ab f7 15 88 09 cf 4f 3c` |
| IV | `00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f` |
STM32 上传签到使用加密请求。Qt 当前普通记录和统计请求使用 JSON 明文响应,代码仍保留了解密能力。
## 9. 推荐启动顺序
1. 启动服务端,确认 `http://服务器IP:8082/api/v1/system/health` 返回健康状态。
2. 启动 Qt 管理端,在配置中心填写服务端地址并点击“测试连接”。
3. 在 Qt 管理端新增签到时间段并保存。
4. 烧录并启动 STM32,确认 OLED 显示系统就绪。
5. 启动 K210,确认摄像头画面、模型加载和 WiFi 连接正常。
6. 在 K210 添加人脸,等待照片上传到服务端。
7. 在 Qt 管理端为照片对应的人脸 ID 填写真实学号并保存映射。
8. K210 同步映射后,再让同一人进行识别签到。
9. 在 Qt 管理端查看考勤记录和统计结果。
## 10. 一键理解各端关系
| 你想做的事 | 应该操作哪里 | 背后发生什么 |
| --- | --- | --- |
| 改服务端地址 | Qt 配置中心、STM32/K210 代码常量 | 各端 HTTP 请求指向新服务器 |
| 设置允许签到的时间 | Qt 配置中心 | Qt 保存到服务端,STM32 定期下载 |
| 给人脸绑定学号 | Qt 人脸管理 | 服务端保存映射,K210 同步后更新本地人脸名称 |
| 添加人脸 | K210 按键短按 | K210 保存特征和照片,照片上传服务端 |
| 删除人脸 | Qt 人脸管理或 K210 删除模式 | Qt 删除会生成服务端同步任务;K210 本地删除只影响本地 |
| 查看记录 | Qt 考勤记录页 | Qt 从服务端读取最近记录 |
| 导出报表 | Qt 记录页或统计页 | Qt 把当前加载/筛选结果写成 CSV |
## 11. 新手常见问题
### 11.1 为什么识别成功但没有签到成功
常见原因:
1. 没有配置签到时间段。
2. 当前时间不在任何启用的时间段内。
3. STM32 时间不正确,DS1302 失效且网络对时失败。
4. 同一学生在当前时间段已经签过。
5. K210 发给 STM32 的 ID 不是想要的真实学号。
6. 置信度低于 `0.75`。
7. STM32 无法连接服务端,记录暂存在本地等待同步。
### 11.2 为什么 Qt 看不到 K210 的照片
可能原因:
1. K210 侧 ESP8266 没连上 WiFi。
2. K210 服务端 IP 或 Token 不正确。
3. K210 本地照片保存在 `/sd/face_photos`,但还没轮到网络队列上传。
4. 服务端 `faces/` 目录没有写入权限。
5. Qt 配置的服务端地址不是 K210 上传照片的那个服务端。
### 11.3 为什么删除后 K210 还认识这个人
Qt 删除会先在服务端生成待删除任务,K210 需要同步后才会删除本地 SD 卡中的特征。可以等待 K210 定期同步,也可以通过服务端同步请求接口触发。
### 11.4 为什么改了 Token 后有的端还能用,有的端不能用
服务端和 Qt 可以读取环境变量 `ATTENDANCE_API_TOKEN`。STM32 与 K210 的 Token 写在代码里,必须同步修改并重新烧录或上传脚本。
## 12. 文档索引
| 文档 | 内容 |
| --- | --- |
| `docs/00_项目总文档.md` | 总体介绍、架构、运行顺序、概念说明 |
| `docs/01_STM32固件端文档.md` | STM32 固件、硬件模块、签到判断、加密上传 |
| `docs/02_K210识别端文档.md` | K210 人脸识别、按键操作、照片上传、同步 |
| `docs/03_服务端文档.md` | 服务端 API、SQLite、同步版本、部署安全 |
| `docs/04_Qt管理端文档.md` | Qt 界面功能、数据模型、本地缓存、PC/Android 适配 |
| `docs/05_项目审查问题清单.md` | 当前实现状态、风险点、维护清单 |
| `docs/06_后端接口Windows测试命令.md` | Windows 下手动测试和回归检查命令 |
-337
View File
@@ -1,337 +0,0 @@
# STM32 固件端文档
## 1. STM32 在系统中做什么
STM32 是整套考勤机的主控。它不做人脸识别,人脸识别由 K210 完成。STM32 负责把 K210 的识别结果变成“是否允许签到”的业务判断,并控制硬件反馈。
它主要做这些事:
1. 初始化 OLED、蜂鸣器、LED、串口、RTC、光敏传感器、补光灯等硬件。
2. 接收 K210 通过 USART3 发来的识别结果。
3. 判断识别置信度是否足够。
4. 判断当前时间是否在服务端配置的签到时间段内。
5. 判断当前学生在本时间段是否已经签过。
6. 生成签到记录。
7. 使用 AES-128-CBC 加密签到 JSON。
8. 通过 ESP8266 发送 HTTP 请求到服务端。
9. 网络异常时把记录留在本地,后续自动重试。
## 2. 相关文件
| 文件 | 作用 |
| --- | --- |
| `User/main.c` | 主程序入口、初始化流程、主循环、联网和同步调度 |
| `Hardware/Attendance.c` | 考勤业务逻辑:时间段、重复判断、本地记录、同步状态 |
| `Hardware/Attendance.h` | 考勤数据结构和函数声明 |
| `Hardware/K210_USART.c` | 解析 K210 串口识别结果 |
| `Hardware/esp8266.c` | ESP8266 AT 指令、HTTP GET/POST、对时、查重 |
| `Hardware/Crypto.c` | AES-128-CBC 加密和 Base64 编码 |
| `Hardware/DS1302.c` | DS1302 RTC 时钟读写 |
| `Hardware/LightSensor.c` | 光敏电阻 ADC 和补光灯控制 |
| `Hardware/Buzzer_LED.c` | 蜂鸣器、成功 LED、状态 LED |
| `Hardware/OLED.c` | OLED 显示 |
| `System/Delay.c` | 延时和毫秒计时 |
| `System/Debug.c` | 调试串口输出 |
| `Project.uvprojx` | Keil 工程文件 |
## 3. 硬件连接
| 外设 | STM32 引脚 | 说明 |
| --- | --- | --- |
| ESP8266 TX/RX | PA10 / PA9 | USART1,波特率 115200 |
| K210 TX/RX | PB11 / PB10 | USART3,波特率 115200 |
| 调试串口 | PA3 / PA2 | USART2,波特率 115200 |
| OLED SCL/SDA | PB8 / PB9 | I2C |
| 成功 LED | PB4 | 高电平点亮 |
| 蜂鸣器 | PB5 | 低电平触发 |
| 光敏传感器 | PB0 | ADC1 通道 8 |
| 补光灯 | PB1 | 高电平点亮 |
| DS1302 CLK/DAT/RST | PA4 / PA5 / PA6 | 软件 GPIO 时序 |
| 板载状态 LED | PC13 | 低电平点亮,初始化中亮,就绪后灭 |
## 4. 代码中的主要配置
位置:`User/main.c`
| 配置 | 当前值 | 说明 |
| --- | --- | --- |
| `WIFI_SSID` | `yzfh` | ESP8266 要连接的 WiFi |
| `WIFI_PASSWORD` | `yzfhroot` | WiFi 密码 |
| `HTTP_SERVER_IP` | `27.106.113.205` | 服务端 IP |
| `HTTP_SERVER_PORT` | `8082` | 服务端 HTTP 端口 |
| `HTTP_API_TOKEN` | `atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV` | API Token |
| `CLASS_ID` | `1` | 当前 STM32 设备所属班级编号 |
| `TIME_SYNC_INTERVAL_MS` | `3600000` | 每小时对时一次 |
| `TIMESLOT_DOWNLOAD_INTERVAL_MS` | `30000` | 每 30 秒检查一次时间段配置 |
| `WIFI_RECONNECT_INTERVAL_MS` | `30000` | WiFi 断开后每 30 秒重连 |
| `RECONNECT_INTERVAL_MS` | `5000` | 服务端连接不可用后每 5 秒尝试重连 |
| `MIN_CONFIDENCE_THRESHOLD` | `0.75` | 最低识别置信度 |
| `MAX_ATTENDANCE_RECORDS` | `100` | STM32 本地最多缓存 100 条记录 |
| `MAX_TIME_SLOT_COUNT` | `10` | 最多支持 10 个签到时间段 |
## 5. 启动流程
STM32 上电后按下面顺序执行:
1. 初始化调试串口。
2. 初始化延时模块。
3. 初始化 OLED。
4. 初始化蜂鸣器和 LED。
5. 初始化 USART1,用于连接 ESP8266。
6. 初始化 USART3,用于接收 K210 数据。
7. 初始化考勤模块。
8. 初始化 AES 加密模块。
9. 初始化 DS1302,并尝试读取时间。
10. 初始化光敏传感器和补光灯。
11. 初始化 ESP8266。
12. 连接 WiFi。
13. 从服务端获取当前时间。
14. 连接 HTTP 服务端。
15. 下载签到时间段配置。
16. 下载今日当前时段已签到记录。
17. 进入主循环。
如果 WiFi 或服务端连接失败,程序不会停机,而是进入离线或重连状态。后续主循环会自动重试。
## 6. 主循环每次做什么
`User/main.c` 的主循环大致如下:
```c
while (1) {
ProcessBeep();
UpdateCurrentTime();
CheckServerConnection();
SyncPendingRecords();
UpdateTimeSlotConfig();
LightSensor_Update();
if (K210_GetRxFlag()) {
Attendance_ProcessFaceResult(&K210_FaceResult);
}
}
```
逐项解释:
| 函数 | 作用 |
| --- | --- |
| `ProcessBeep` | 非阻塞处理蜂鸣器和成功 LED |
| `UpdateCurrentTime` | 从 DS1302 或软件时钟维护当前时间,并定期网络对时 |
| `CheckServerConnection` | 检查当前 HTTP/TCP 连接状态,连接不可用时按需重连 |
| `SyncPendingRecords` | 把本地未同步记录加密上传 |
| `UpdateTimeSlotConfig` | 定期下载服务端签到时间段 |
| `LightSensor_Update` | 根据环境光自动开关补光灯 |
| `K210_GetRxFlag` | 看是否收到 K210 的新识别结果 |
注意:当前 STM32 HTTP 请求和服务端响应都使用 `Connection: close`,所以它不是长期保持一个 TCP 长连接,而是在每次请求前检查连接状态,并在需要时重新建立连接。
## 7. K210 发给 STM32 的数据格式
K210 通过 UART1 发送文本,STM32 通过 USART3 接收。
识别成功:
```text
ID:20240001,NAME:Zhang_San,CONF:0.95
```
字段说明:
| 字段 | 含义 |
| --- | --- |
| `ID` | 学号或 K210 同步后的身份编号 |
| `NAME` | 显示名,K210 代码中会用下划线代替空格 |
| `CONF` | 置信度,范围一般是 0.0 到 1.0 |
未识别:
```text
UNKNOWN
```
STM32 收到 `UNKNOWN` 后不会签到。
## 8. 签到判断逻辑
STM32 对一次识别结果的判断顺序如下:
1. 判断 K210 是否识别成功。
2. 判断置信度是否低于 `0.75`。
3. 判断当前是否在启用的时间段内。
4. 判断当前时段本地记录中是否已经有该学生。
5. 判断从服务端下载的今日缓存中是否已经有该学生。
6. 如果服务端在线,再调用服务端查重接口确认。
7. 通过所有检查后,生成一条本地考勤记录。
8. 如果服务端在线,后续同步任务会加密上传。
当前默认没有任何时间段。必须由 Qt 管理端新增并保存时间段,否则第 3 步会失败,OLED 会显示不在签到时间。
## 9. 时间段同步
STM32 调用:
```text
GET /api/v1/sync/state?client=stm32&last_version=<本地版本>
```
服务端返回示例:
```json
{
"version": 5,
"changed": true,
"client": "stm32",
"server_time": "2026-05-01 10:00:00",
"state": {
"time_slots": [
{
"name": "上午签到",
"start_time": "08:00",
"end_time": "09:30",
"enabled": true
}
]
}
}
```
如果 `time_slots` 是空数组,STM32 会清空本地时间段配置。
## 10. 今日记录缓存
STM32 为了减少重复签到,会在开机连接服务端后下载今日当前时间段的已签到学号:
```text
GET /api/v1/attendance/today?slot_start=08:00&slot_end=09:30
```
返回:
```json
{
"date": "2026-05-01",
"records": ["20240001", "20240002"]
}
```
下载后,STM32 会把这些学号放进缓存。之后如果同一个学生在同一个时段再次识别,会被判定为重复签到。
## 11. 加密上传
STM32 上传签到记录时使用:
```text
POST /api/v1/attendance/checkins
X-Api-Token: <Token>
X-Encrypted: AES128-CBC
Content-Type: application/octet-stream
```
请求体是 Base64 编码后的 AES 密文。明文 JSON 类似:
```json
{
"student_id": "20240001",
"name": "Zhang San",
"confidence": 0.95,
"status": "success",
"class_id": "1"
}
```
服务端解密成功后写入 `attendance.db`。
## 12. 本地记录和同步状态
STM32 的本地记录结构体:
```c
typedef struct {
char student_id[20];
char student_name[32];
float confidence;
uint32_t timestamp;
uint8_t time_slot;
SyncStatus sync_status;
uint8_t retry_count;
uint32_t last_retry_time;
} AttendanceRecord;
```
同步状态:
| 状态 | 含义 |
| --- | --- |
| `SYNC_PENDING` | 新记录,等待上传 |
| `SYNC_SYNCING` | 正在上传 |
| `SYNC_SYNCED` | 上传成功 |
| `SYNC_FAILED` | 上传失败,后续重试 |
## 13. OLED 和声光反馈
| 情况 | OLED | 蜂鸣器/LED |
| --- | --- | --- |
| 初始化中 | 显示 ESP/WiFi/HTTP 状态 | PC13 状态灯亮 |
| 服务端在线 | `System Ready / Online Mode` | 成功提示 |
| WiFi 在线但服务端断开 | `System Ready / Reconnecting..` | 后台重连 |
| WiFi 正在重连 | `System Ready / WiFi retry...` | 后台重连 |
| ESP8266 不可用 | `System Ready / Offline Mode` | 错误提示 |
| 签到成功 | `Check-in OK!` | 成功提示 |
| 重复签到 | `Already Signed!` | 错误提示 |
| 置信度低 | `Low Confidence` | 错误提示 |
| 不在时间段 | `Not Check Time` | 错误提示 |
## 14. 编译与烧录
使用 Keil 打开:
```text
Project.uvprojx
```
一般步骤:
1. 安装 Keil MDK 和 STM32F10x 支持包。
2. 打开 `Project.uvprojx`。
3. 确认芯片型号和启动文件与实际开发板一致。
4. 修改 `User/main.c` 中的 WiFi、服务端 IP、班级编号和 Token。
5. 编译工程。
6. 使用 ST-Link 或串口 Bootloader 烧录。
7. 打开调试串口查看日志。
## 15. 调试建议
### 15.1 先看调试串口
调试串口会输出:
1. 硬件初始化状态。
2. ESP8266 AT 指令结果。
3. WiFi 连接结果。
4. HTTP 服务端连接结果。
5. 时间同步结果。
6. 时间段下载结果。
7. K210 原始数据和解析结果。
8. 加密上传结果。
### 15.2 常见问题
| 现象 | 可能原因 | 处理 |
| --- | --- | --- |
| 一直显示 WiFi retry | SSID/密码错误、ESP8266 没接好、供电不足 | 检查 `WIFI_SSID`、`WIFI_PASSWORD`、供电和串口 |
| HTTP connection failed | 服务端 IP/端口错误、防火墙拦截、服务端没启动 | 用电脑访问健康检查接口 |
| Not Check Time | 没有配置时间段,或当前时间不在时间段内 | Qt 新增时间段并保存,检查 DS1302/NTP |
| Already Signed | 当前时段已经签过 | 换时间段或清空记录 |
| Low Confidence | K210 比对分数偏低 | 调整光线、重新录入人脸 |
| Qt 看不到记录 | STM32 未同步成功或服务端地址不一致 | 看服务端日志和 Qt 服务端地址 |
## 16. 修改配置时要同步修改哪里
| 你修改了什么 | 需要同步修改 |
| --- | --- |
| 服务端 IP | `User/main.c`、`k210/main.py`、Qt 配置中心 |
| API Token | 服务端环境变量或代码、`User/main.c`、`k210/main.py`、Qt 环境变量 |
| AES Key/IV | `Hardware/Crypto.c`、`server/server_encrypted.cpp`、`QT/src/CryptoUtils.cpp` |
| 班级编号 | 每块 STM32 对应修改 `CLASS_ID` |
| WiFi | `User/main.c` 和 `k210/main.py` |
-293
View File
@@ -1,293 +0,0 @@
# K210 识别端文档
## 1. K210 在系统中做什么
K210 负责“看见人脸并判断是谁”。它不直接决定能不能签到,最终签到判断由 STM32 完成。
K210 的职责包括:
1. 初始化摄像头和 LCD。
2. 加载人脸检测、关键点检测、特征提取三个 KPU 模型。
3. 从 SD 卡读取已经录入的人脸特征。
4. 实时检测画面中的人脸。
5. 将识别结果通过 UART1 发给 STM32。
6. 通过按键添加或删除本地人脸。
7. 保存新增人脸照片到 SD 卡。
8. 通过 K210 侧 ESP8266 上传照片到服务端。
9. 从服务端同步人脸 ID 到学号的映射。
10. 从服务端同步待删除任务,并删除本地特征。
## 2. 相关文件
| 文件 | 作用 |
| --- | --- |
| `k210/main.py` | K210 主程序,包含识别、添加、删除、同步、上传照片 |
| `k210/view_attendance.py` | 辅助脚本,用于查看旧版本地记录文件 |
K210 程序依赖 SD 卡上的模型文件:
```text
/sd/FaceDetection.smodel
/sd/FaceLandmarkDetection.smodel
/sd/FeatureExtraction.smodel
```
如果模型文件不存在或路径错误,程序会在加载 KPU 模型时报错。
## 3. 代码中的主要配置
位置:`k210/main.py`
| 配置 | 当前值 | 说明 |
| --- | --- | --- |
| `WIFI_SSID` | `yzfh` | K210 侧 ESP8266 要连接的 WiFi |
| `WIFI_PASSWORD` | `yzfhroot` | WiFi 密码 |
| `SERVER_IP` | `27.106.113.205` | 服务端 IP |
| `SERVER_PORT` | `8082` | 服务端端口 |
| `API_TOKEN` | `atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV` | 服务端鉴权 Token |
| `SYNC_CHECK_INTERVAL_MS` | `50000` | 每 50 秒检查一次服务端同步请求 |
| `NETWORK_FRAME_INTERVAL` | `300` | 每 300 帧处理一次网络任务 |
| `LOCAL_FACE_PHOTO_DIR` | `/sd/face_photos` | 本地照片缓存目录 |
| `CAMERA_HMIRROR` | `1` | 水平镜像 |
| `CAMERA_VFLIP` | `1` | 垂直翻转 |
## 4. 硬件连接
| 功能 | K210 引脚 | 说明 |
| --- | --- | --- |
| 发给 STM32 | 24 | UART1 TX |
| 收 STM32 | 25 | UART1 RX |
| 发给 ESP8266 | 34 | UART2 TX |
| 收 ESP8266 | 35 | UART2 RX |
| 蓝/绿/红 LED | 12 / 13 / 14 | 状态提示 |
| 按键 | 16 | 添加、删除、删除全部 |
UART 参数:
```text
115200, 8 data bits, no parity, 1 stop bit
```
## 5. SD 卡文件
| 文件或目录 | 作用 |
| --- | --- |
| `/sd/recordftr.txt` | 人脸特征库,每行一个人脸 |
| `/sd/face_mapping.txt` | face_id 到学号的本地映射 |
| `/sd/sync_version.txt` | K210 已同步到的服务端版本号 |
| `/sd/face_photos/` | 新增人脸时保存的本地照片 |
| `/sd/FaceDetection.smodel` | 人脸检测模型 |
| `/sd/FaceLandmarkDetection.smodel` | 人脸关键点模型 |
| `/sd/FeatureExtraction.smodel` | 人脸特征提取模型 |
`recordftr.txt` 的一行大致是:
```text
ID:1#[0.0123, -0.0456, ...]
```
或同步映射后:
```text
20240001#[0.0123, -0.0456, ...]
```
代码中已经不再用 `eval` 解析特征,而是只解析安全的浮点数组文本。
## 6. 启动流程
K210 启动后主要执行:
1. 设置堆内存到 1MB。
2. 初始化 UART1 和 UART2。
3. 初始化 K210 侧 ESP8266,尝试连接 WiFi。
4. 加载本地映射 `/sd/face_mapping.txt`。
5. 加载本地同步版本 `/sd/sync_version.txt`。
6. 加载三个 KPU 模型。
7. 初始化 LCD 和摄像头。
8. 初始化按键中断。
9. 加载本地人脸特征库 `/sd/recordftr.txt`。
10. 扫描本地照片目录,把未上传照片加入队列。
11. 如果 WiFi 可用,执行一次统一同步。
12. 进入主循环。
## 7. 人脸识别流程
每一帧画面大致按下面处理:
1. 摄像头采集图像。
2. `task_fd` 做人脸检测。
3. `task_ld` 做关键点定位。
4. 根据关键点做仿射变换,把人脸对齐到 128x128。
5. `task_fe` 提取人脸特征。
6. 与 `peoples` 中的本地特征逐个比对。
7. 如果最高分大于 `85`,认为识别成功。
8. 如果识别成功,LCD 画绿色框;陌生人画红色框。
9. 每隔 `SEND_INTERVAL_MS = 1000` 毫秒,向 STM32 发送一次状态变化。
发给 STM32 的格式:
```text
ID:<学号或face_id>,NAME:<显示名>,CONF:<置信度>
```
示例:
```text
ID:20240001,NAME:20240001,CONF:0.95
```
如果陌生人:
```text
UNKNOWN
```
## 8. 按键操作
| 操作 | 程序状态 | 作用 |
| --- | --- | --- |
| 短按 | 正常识别界面 | 进入添加人脸模式 |
| 长按约 2 秒 | 正常识别界面 | 进入删除人脸模式 |
| 超长按约 5 秒 | 正常识别界面 | 进入删除全部人脸模式 |
| 添加模式中短按 | 检测到陌生人脸时 | 确认添加 |
| 添加模式中长按 | 添加模式 | 退出添加模式 |
| 删除模式中短按 | 删除模式 | 切换候选项 |
| 删除模式中长按 | 删除模式 | 确认删除当前项 |
## 9. 添加人脸流程
1. 正常识别界面短按按键。
2. LCD 显示 `Add Face Mode`。
3. 摄像头检测到陌生人脸后显示 `Ready`。
4. 再短按确认添加。
5. K210 分配一个未使用的 `face_id`,例如 `1`。
6. 特征写入 `/sd/recordftr.txt`,名称为 `ID:1`。
7. 当前画面压缩为 JPEG。
8. JPEG 保存到 `/sd/face_photos/1.jpg`。
9. `1` 加入照片上传队列。
10. 后续网络任务会上传到服务端。
服务端接收接口:
```text
POST /api/v1/faces/photos/1
```
## 10. 人脸映射同步
新增人脸后,K210 只知道它是 `ID:1`,不知道真实学号。管理员需要在 Qt 管理端把 `1` 映射成学号:
```json
{
"mappings": {
"1": "20240001"
}
}
```
K210 同步后会:
1. 拉取服务端 `face_mappings`。
2. 修改本地 `face_mapping.txt`。
3. 修改 `recordftr.txt` 中对应人脸的名称。
4. 后续发送给 STM32 的 ID 就会变成真实学号。
## 11. 照片上传队列
K210 不会在识别每一帧时都做网络请求,因为网络请求会阻塞画面。它采用“分时网络任务”:
1. 新照片先保存到 SD 卡。
2. 只把 face_id 放入 `photo_upload_queue`。
3. 每隔一定帧数,主循环处理一个网络任务。
4. 如果上传失败,重新放回队列。
这样可以减少卡顿,并且即使网络临时不可用,照片也不会丢,因为原始 JPEG 还在 SD 卡。
## 12. 服务端同步
K210 主要使用统一同步接口:
```text
GET /api/v1/sync/state?client=k210&last_version=<本地版本>
```
如果有变化,服务端返回 K210 需要的数据:
```json
{
"version": 5,
"changed": true,
"state": {
"face_mappings": {
"1": "20240001"
},
"pending_delete": ["1"],
"delete_tasks": [
{
"task_id": "delete-1",
"face_id": "1"
}
]
}
}
```
K210 会先处理删除任务,再应用映射。删除成功后发送 ACK:
```json
{
"client": "k210",
"version": 5,
"deleted": ["1"],
"task_ids": ["delete-1"]
}
```
如果 ACK 失败,K210 不会推进本地版本号,下一次还会重试。
## 13. 删除人脸
K210 本地删除有两种来源:
### 13.1 K210 按键本地删除
这只会删除 K210 本地 `recordftr.txt` 和本地照片,不会自动删除服务端记录。适合现场维护。
### 13.2 Qt 管理端远程删除
Qt 删除会在服务端创建待删除任务。K210 同步后删除本地人脸,并向服务端确认。适合后台统一管理。
## 14. K210 常用接口
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `POST` | `/api/v1/faces/photos/<face_id>` | 上传 JPEG 照片 |
| `GET` | `/api/v1/faces/photos` | 查询服务端已有照片 |
| `GET` | `/api/v1/faces/mappings` | 拉取人脸 ID 到学号映射 |
| `GET` | `/api/v1/faces/pending-delete` | 拉取待删除列表 |
| `POST` | `/api/v1/faces/delete-ack` | 旧式删除确认 |
| `GET` | `/api/v1/sync/state?client=k210&last_version=N` | 推荐统一同步 |
| `POST` | `/api/v1/sync/ack` | 推荐统一同步确认 |
| `GET` | `/api/v1/sync/request` | 查询 Qt/服务端是否请求同步 |
| `POST` | `/api/v1/sync/request/ack` | 确认收到同步请求 |
## 15. 常见问题
| 现象 | 可能原因 | 处理 |
| --- | --- | --- |
| 模型加载失败 | SD 卡缺少 `.smodel` 文件 | 把三个模型放到 `/sd/` 根目录 |
| WiFi 连接失败 | SSID/密码错误、ESP8266 未启动、波特率不匹配 | 看串口日志,代码会尝试多个波特率 |
| 添加人脸失败 | 没检测到陌生人脸、SD 卡不可写、照片保存失败 | 检查光照、SD 卡和日志 |
| Qt 看不到照片 | 上传队列未完成、服务端地址不一致、Token 错误 | 看 K210 `[PHOTO]`、`[NET]`、`[ESP]` 日志 |
| 删除后仍识别 | K210 尚未同步或 ACK 失败 | 等待同步,检查 `/api/v1/sync/state` |
| 发给 STM32 的还是 `ID:1` | 映射没有保存或 K210 未同步 | Qt 保存映射后触发同步 |
## 16. 修改配置时要注意
1. K210 的服务端 IP、端口、Token 必须和服务端一致。
2. K210 的 WiFi 配置和 STM32 可以相同,也可以不同,但都要能访问服务端。
3. K210 侧照片上传使用自己的 ESP8266,不依赖 STM32 的 ESP8266。
4. K210 本地 SD 卡数据很重要,删除或格式化 SD 卡会丢失本地人脸特征。
5. 如果重新录入人脸,原 face_id 可能变化,需要重新在 Qt 管理端设置映射。
-657
View File
@@ -1,657 +0,0 @@
# 服务端文档
## 1. 服务端的作用
服务端是整个系统的数据中心。STM32、K210 和 Qt 管理端都通过 HTTP 与它通信。
它负责:
1. 接收 STM32 加密上传的签到记录。
2. 解密、校验并保存记录。
3. 提供考勤记录和统计数据给 Qt。
4. 保存签到时间段配置。
5. 保存学生姓名映射和班级名称映射。
6. 保存 K210 上传的人脸照片。
7. 保存人脸 ID 到真实学号的映射。
8. 维护待删除人脸任务。
9. 维护全局同步版本号。
10. 提供健康检查、存储状态和时间接口。
当前推荐服务端文件:
```text
server/server_encrypted.cpp
```
## 2. 技术栈
| 项 | 内容 |
| --- | --- |
| 语言 | C++ |
| HTTP 实现 | 原生 socket + 每连接一个线程 |
| 数据库 | SQLite |
| 加密库 | OpenSSL EVP AES-128-CBC |
| 图片存储 | 本地 `faces/*.jpg` |
| 默认端口 | `8082` |
| 默认数据库 | `attendance.db` |
## 3. 运行时文件
服务端启动后主要依赖当前工作目录下的文件。在哪个目录运行,就在哪个目录读写这些文件。
| 文件或目录 | 当前用途 |
| --- | --- |
| `attendance.db` | 主 SQLite 数据库 |
| `faces/` | 人脸照片目录 |
| `backups/` | 数据库备份目录 |
| `attendance_database.csv` | 旧版 CSV 记录,首次初始化时可导入 |
| `timeslots_config.json` | 旧版时间段配置,首次初始化时可导入 |
| `mapping_config.json` | 旧版姓名/班级映射,首次初始化时可导入 |
| `face_id_mapping.json` | 旧版人脸映射,首次初始化时可导入 |
| `pending_delete.json` | 旧版待删除列表,首次初始化时可导入 |
| `sync_request.json` | 旧版同步请求标志,首次初始化时可导入 |
| `sync_version.txt` | 旧版同步版本号,首次初始化时可导入 |
首次启动时,如果 SQLite 表中没有数据,服务端会尝试从这些旧文件导入;导入后主要读写 SQLite。
## 4. SQLite 表结构
服务端启动时会创建这些表:
### 4.1 `records`
保存考勤记录。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `id` | INTEGER PRIMARY KEY AUTOINCREMENT | 自增主键 |
| `timestamp` | TEXT | 签到时间 |
| `student_id` | TEXT | 学号 |
| `name` | TEXT | 名称 |
| `confidence` | TEXT | 置信度 |
| `status` | TEXT | 状态,例如 `success` |
| `class_id` | TEXT | 班级编号 |
索引:
```sql
idx_records_student_time(student_id,timestamp)
idx_records_timestamp(timestamp)
```
### 4.2 `kv_store`
保存配置型数据。
| key | value 内容 |
| --- | --- |
| `time_slots` | 时间段 JSON |
| `mapping_config` | 学生姓名和班级名称映射 |
| `sync_request` | K210 同步请求标志 |
| `sync_version` | 全局同步版本号 |
### 4.3 `face_mappings`
保存 K210 原始 face_id 到真实学号的映射。
| 字段 | 含义 |
| --- | --- |
| `face_id` | K210 本地人脸编号 |
| `student_id` | 真实学号 |
### 4.4 `pending_delete`
保存等待 K210 删除的任务。
| 字段 | 含义 |
| --- | --- |
| `task_id` | 删除任务 ID |
| `face_id` | K210 需要删除的人脸 ID |
| `student_id` | 对应学生 ID |
| `created_at` | 创建时间 |
| `status` | 状态,默认 `pending` |
| `ack_at` | 确认时间 |
### 4.5 `device_sync_state`
保存各客户端同步状态。
| 字段 | 含义 |
| --- | --- |
| `client` | 客户端名,例如 `qt`、`stm32`、`k210` |
| `last_seen_version` | 客户端最近看到的服务端版本 |
| `last_ack_version` | 客户端最近确认的版本 |
| `last_seen_at` | 最近访问时间 |
## 5. 默认配置
| 配置 | 当前值 | 说明 |
| --- | --- | --- |
| `HTTP_PORT` | `8082` | HTTP 监听端口 |
| `ATTENDANCE_BIND_HOST` | `0.0.0.0` | 默认监听地址,可用环境变量覆盖 |
| `DEFAULT_API_TOKEN` | `atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV` | 默认 API Token |
| `DEFAULT_CORS_ORIGIN` | `http://localhost` | 默认 CORS 来源 |
| `MAX_JSON_BODY_SIZE` | `64 KB` | 普通 JSON 请求体上限 |
| `MAX_PHOTO_BODY_SIZE` | `512 KB` | 照片上传请求体上限 |
| `DEFAULT_TIMESLOTS_JSON` | `{"time_slots":[]}` | 默认时间段为空 |
可以通过环境变量覆盖:
```powershell
$env:ATTENDANCE_BIND_HOST="0.0.0.0"
$env:ATTENDANCE_API_TOKEN="你的Token"
$env:ATTENDANCE_CORS_ORIGIN="http://localhost"
```
## 6. 鉴权规则
服务端对大多数接口要求请求头:
```text
X-Api-Token: <Token>
```
允许不带 Token 的情况:
1. `OPTIONS` 预检请求。
2. `GET /api/health`。
新接口和旧接口都使用同样的鉴权逻辑。
## 7. 路由兼容关系
服务端内部仍有一些旧路径,例如 `/api/records`、`/api/timeslots`。为了让接口更清晰,代码提供了 `/api/v1/...` 新路径,并在服务端内部转换到旧路径。
| 新路径 | 内部兼容路径 | 说明 |
| --- | --- | --- |
| `/api/v1/system/health` | `/api/health` | 健康检查 |
| `/api/v1/system/time` | `/api/time` | 获取服务器时间 |
| `/api/v1/attendance/checkins` | `/api/checkin` | 加密签到上传 |
| `/api/v1/attendance/records` | `/api/records` | 获取/删除考勤记录 |
| `/api/v1/attendance/records/clear` | `/api/clear` | 清空记录 |
| `/api/v1/attendance/statistics` | `/api/statistics` | 获取统计 |
| `/api/v1/attendance/duplicates` | `/api/check_duplicate` | 查重 |
| `/api/v1/attendance/today` | `/api/today_records` | 今日某时间段记录 |
| `/api/v1/config/time-slots` | `/api/timeslots` | 时间段 |
| `/api/v1/config/mappings` | `/api/mapping_config` | 学生/班级映射 |
| `/api/v1/faces/mappings` | `/api/face_id_mapping` | 人脸映射 |
| `/api/v1/faces/photos` | `/api/face_photos` | 照片列表 |
| `/api/v1/faces/photos/<id>` | `/api/face_photo/<id>` 或 `/api/face_photo_simple/<id>` | 下载/上传照片 |
| `/api/v1/faces/student-ids` | `/api/all_student_ids` | 全部学生 ID |
| `/api/v1/faces/pending-delete` | `/api/pending_delete` | 待删除任务 |
| `/api/v1/faces/delete-ack` | `/api/confirm_delete` | 旧式删除确认 |
| `/api/v1/faces/<id>` | `/api/face/<id>` | 删除人脸 |
| `/api/v1/sync/request` | `/api/sync_request` | 同步请求标志 |
| `/api/v1/sync/request/ack` | `/api/sync_ack` | 同步请求确认 |
| `/api/v1/sync/trigger` | `/api/trigger_sync` | 触发同步 |
文档推荐使用 `/api/v1/...`,旧路径主要为了兼容旧代码。
## 8. 核心 API
以下示例默认服务端地址是:
```text
http://127.0.0.1:8082
```
### 8.1 健康检查
```text
GET /api/v1/system/health
```
返回示例:
```json
{
"status": "healthy",
"time": "2026-05-01 10:00:00",
"storage": "sqlite",
"database": "attendance.db",
"database_ok": true,
"quick_check": "ok",
"sync_version": 1
}
```
### 8.2 存储状态
```text
GET /api/v1/system/storage
```
用于检查 SQLite、记录数量、照片数量、待删除任务数量等。
### 8.3 获取服务器时间
```text
GET /api/v1/system/time
```
返回:
```json
{
"epoch": 1777600800,
"server_time": "2026-05-01 10:00:00"
}
```
STM32 使用 `epoch` 转成 UTC+8 时间。
### 8.4 加密签到上传
```text
POST /api/v1/attendance/checkins
X-Encrypted: AES128-CBC
X-Api-Token: <Token>
```
请求体是 Base64 字符串。解密后的 JSON:
```json
{
"student_id": "20240001",
"name": "张三",
"confidence": "0.95",
"status": "success",
"class_id": "1"
}
```
字段校验:
| 字段 | 要求 |
| --- | --- |
| `student_id` | 必填,安全 ID,长度不超过 32 |
| `name` | 可选,长度不超过 64 |
| `confidence` | 必须是 0.0 到 1.0 |
| `status` | 只能是 `success`、`failed`、`late` |
| `class_id` | 可选,安全 ID,长度不超过 32,默认 `0` |
成功返回:
```json
{
"success": true,
"message": "签到成功",
"timestamp": "2026-05-01 10:00:00"
}
```
### 8.5 获取考勤记录
```text
GET /api/v1/attendance/records?limit=1000&offset=0
```
`limit` 范围是 1 到 5000,`offset` 范围是 0 到 1000000。
返回数组:
```json
[
{
"timestamp": "2026-05-01 08:10:00",
"student_id": "20240001",
"name": "张三",
"confidence": 0.95,
"status": "success",
"class_id": "1"
}
]
```
如果请求头带:
```text
X-Encrypted-Response: true
```
服务端会把响应 JSON 加密后以 Base64 返回。Qt 当前普通记录请求没有启用这个请求头。
### 8.6 获取统计
```text
GET /api/v1/attendance/statistics
```
返回:
```json
{
"total": 120,
"today": 8,
"server_time": "2026-05-01 10:00:00"
}
```
### 8.7 清空记录
```text
POST /api/v1/attendance/records/clear
```
成功后会删除 `records` 表中的所有记录,并提升同步版本。
### 8.8 删除单条记录
```text
DELETE /api/v1/attendance/records?timestamp=2026-05-01%2008%3A10%3A00&student_id=20240001
```
成功后提升同步版本。
### 8.9 时间段配置
获取:
```text
GET /api/v1/config/time-slots
```
保存:
```text
POST /api/v1/config/time-slots
Content-Type: application/json
```
请求体:
```json
{
"time_slots": [
{
"name": "上午签到",
"start_time": "08:00",
"end_time": "09:30",
"enabled": true
}
]
}
```
当前默认值是:
```json
{
"time_slots": []
}
```
服务端还包含旧默认四时段的迁移判断:如果数据库里保存的是旧的四个默认时段,会自动改为空数组。
### 8.10 重复签到检查
```text
GET /api/v1/attendance/duplicates?student_id=20240001&slot_start=08:00&slot_end=09:30
```
用于 STM32 在线时补充查重。
### 8.11 今日某时段已签到记录
```text
GET /api/v1/attendance/today?slot_start=08:00&slot_end=09:30
```
返回:
```json
{
"date": "2026-05-01",
"records": ["20240001", "20240002"]
}
```
STM32 开机后用它构建本地今日缓存。
### 8.12 学生姓名和班级名称映射
```text
GET /api/v1/config/mappings
POST /api/v1/config/mappings
```
格式:
```json
{
"student_name_map": {
"20240001": "张三"
},
"class_name_map": {
"1": "软件工程一班"
}
}
```
这个映射主要给 Qt 显示使用,不改变 STM32 签到上传的原始学号。
### 8.13 人脸照片
照片列表:
```text
GET /api/v1/faces/photos
```
返回:
```json
{
"photos": [
{
"id": "1",
"filename": "1.jpg"
}
]
}
```
上传照片:
```text
POST /api/v1/faces/photos/1
Content-Type: image/jpeg
```
下载照片:
```text
GET /api/v1/faces/photos/1
```
成功时返回 `image/jpeg`。
### 8.14 人脸 ID 映射
```text
GET /api/v1/faces/mappings
POST /api/v1/faces/mappings
```
格式:
```json
{
"mappings": {
"1": "20240001"
}
}
```
保存映射后服务端会:
1. 写入 `face_mappings`。
2. 尝试按映射重命名照片。
3. 提升同步版本。
4. 设置 K210 同步请求标志。
### 8.15 获取全部学生 ID
```text
GET /api/v1/faces/student-ids
```
从 `records` 表中提取所有出现过的学生 ID。
### 8.16 删除人脸
```text
DELETE /api/v1/faces/20240001
DELETE /api/v1/faces/20240001?delete_records=true
```
服务端会:
1. 删除相关照片。
2. 删除相关映射。
3. 如果 `delete_records=true`,删除该学生相关考勤记录。
4. 创建 K210 待删除任务。
5. 提升同步版本。
6. 设置同步请求标志。
返回示例:
```json
{
"success": true,
"photo_deleted": true,
"mapping_deleted": true,
"records_deleted": 3,
"k210_pending": true,
"sync_requested": true
}
```
## 9. 统一同步接口
### 9.1 获取版本
```text
GET /api/v1/sync/version
```
返回:
```json
{
"version": 6,
"server_time": "2026-05-01 10:00:00"
}
```
### 9.2 获取同步状态
```text
GET /api/v1/sync/state?client=<client>&last_version=<version>
```
`client` 可取:
| client | 服务端返回内容 |
| --- | --- |
| `stm32` | `time_slots` |
| `k210` | `face_mappings`、`pending_delete`、`delete_tasks` |
| `qt` | 记录、统计、时间段、映射、照片列表、学生 ID、人脸映射 |
| `all` 或空 | 尽可能返回全部 |
如果没有变化:
```json
{
"version": 6,
"changed": false,
"client": "stm32",
"server_time": "2026-05-01 10:00:00"
}
```
如果有变化:
```json
{
"version": 7,
"changed": true,
"client": "k210",
"server_time": "2026-05-01 10:00:00",
"state": {
"face_mappings": {},
"pending_delete": [],
"delete_tasks": []
}
}
```
### 9.3 同步确认
```text
POST /api/v1/sync/ack
```
请求体:
```json
{
"client": "k210",
"version": 7,
"deleted": ["1"],
"task_ids": ["task-1"]
}
```
用途:
1. 记录客户端已经确认的版本。
2. 移除已经完成的删除任务。
3. 必要时再次提升同步版本。
## 10. 编译与运行
### 10.1 Windows MinGW 示例
如果已经装好 MinGW、OpenSSL、SQLite:
```powershell
g++ -o server_encrypted.exe server_encrypted.cpp -lws2_32 -lcrypto -lssl -lsqlite3 -std=c++11
.\server_encrypted.exe
```
### 10.2 Linux 示例
```bash
g++ -o server_encrypted server_encrypted.cpp -lcrypto -lssl -lsqlite3 -std=c++11 -pthread
./server_encrypted
```
### 10.3 运行目录建议
建议在 `server/` 目录下运行,这样数据库、照片目录和备份目录都会生成在 `server/` 下面:
```powershell
cd D:\University\graduation\code\Reconstruct_Qt\server
.\server_encrypted.exe
```
## 11. 安全注意事项
1. 默认 Token 已写在代码里,不适合直接暴露公网。
2. 当前是 HTTP 明文传输,公网部署建议放到 HTTPS 反向代理后面。
3. AES Key/IV 是固定值,演示可以,生产环境不建议固定写死。
4. SQLite 数据库和照片目录需要定期备份。
5. `ATTENDANCE_CORS_ORIGIN` 不建议设置成任意来源。
6. 服务器运行目录要限制写权限,避免误删数据。
## 12. 常见问题
| 现象 | 可能原因 | 处理 |
| --- | --- | --- |
| 401 Unauthorized | 没带 `X-Api-Token` 或 Token 不一致 | 检查四端 Token |
| Qt 连接失败 | 服务端没启动、地址错、防火墙拦截 | 访问 `/api/v1/system/health` |
| STM32 解密失败 | AES Key/IV 不一致或请求体不是 Base64 密文 | 对照三端 AES 参数 |
| 时间段总是旧数据 | SQLite 中已有旧配置 | 当前代码会清理旧默认四时段;自定义旧配置需手动保存为空 |
| K210 删除不同步 | K210 未拉取同步状态或 ACK 失败 | 看 `/api/v1/sync/state?client=k210&last_version=0` |
| 照片上传失败 | 图片超过 512KB、face_id 非法、目录不可写 | 检查服务端日志和 `faces/` 权限 |
-436
View File
@@ -1,436 +0,0 @@
# Qt 管理端文档
## 1. Qt 管理端是什么
Qt 管理端是整个系统的图形化后台。它支持 Windows 桌面端和 Android 端,主要用于:
1. 查看考勤记录。
2. 按班级、学号、姓名、日期筛选记录。
3. 导出记录 CSV。
4. 查看统计分析。
5. 导出统计 CSV。
6. 配置服务端地址。
7. 配置签到时间段。
8. 配置学号到姓名、班级编号到班级名的显示映射。
9. 查看 K210 上传的人脸照片。
10. 给 K210 face_id 绑定真实学号。
11. 删除人脸信息,并可选择删除相关考勤记录。
## 2. 相关文件
| 文件 | 作用 |
| --- | --- |
| `QT/main.cpp` | Qt 程序入口,设置应用信息、图标、QML 引擎 |
| `QT/qml/Main.qml` | 主界面,包括所有页面和对话框 |
| `QT/src/AppController.*` | QML 与 C++ 业务逻辑的中枢 |
| `QT/src/ApiClient.*` | HTTP API 客户端 |
| `QT/src/AttendanceTypes.h` | 考勤记录、统计、时间段结构体 |
| `QT/src/CryptoUtils.*` | AES 解密工具 |
| `QT/src/RecordListModel.*` | 考勤记录模型 |
| `QT/src/StatisticsListModel.*` | 统计模型 |
| `QT/src/TimeSlotListModel.*` | 时间段模型 |
| `QT/src/MappingListModel.*` | 姓名/班级映射模型 |
| `QT/src/FaceListModel.*` | 人脸管理模型 |
| `QT/CMakeLists.txt` | Qt/CMake 构建配置 |
| `QT/android/` | Android Gradle 配置和图标 |
| `QT/assets/` | 桌面端图标 |
## 3. 技术栈
| 分类 | 内容 |
| --- | --- |
| 语言 | C++17、QML |
| UI | Qt Quick、Qt Quick Controls 2 |
| Qt 模块 | Core、Gui、Qml、Quick、QuickControls2、Network |
| 构建系统 | CMake 3.16+ |
| 样式 | `QQuickStyle::setStyle("Basic")` |
| Android 文字渲染 | `NativeTextRendering` |
| 本地配置 | `QSettings`、`QStandardPaths::AppDataLocation` |
## 4. 应用基础信息
位置:`QT/main.cpp`
| 项 | 值 |
| --- | --- |
| ApplicationName | `人脸识别考勤系统` |
| ApplicationVersion | `3.0.0` |
| OrganizationName | `STM32 Face Recognition` |
| 主 QML | `qrc:/qml/Main.qml` |
| 默认窗口大小 | `1560 x 920` |
| PC 最小窗口 | `900 x 620` |
| Android 最小窗口 | `360 x 560` |
## 5. 页面结构
Qt 管理端是单窗口应用,共 5 个页面:
| 页面 | 作用 |
| --- | --- |
| 考勤记录 | 查看、筛选、导出、清空记录 |
| 统计分析 | 按时间范围统计学生签到情况 |
| 配置中心 | 服务端地址、签到时间段、名称映射 |
| 人脸管理 | 查看照片、绑定学号、删除人脸 |
| 关于 | 系统说明、版本和作者信息 |
PC 宽屏显示左侧导航栏;窄屏和 Android 显示顶部/底部更紧凑的导航布局。
## 6. 网络访问规则
Qt 端的服务端地址默认是:
```text
http://127.0.0.1:8082
```
用户可在“配置中心”修改服务端地址。保存后写入本地 `QSettings`:
```text
server/url
```
`ApiClient::buildUrl` 会在地址后自动拼接 `/api`,例如:
```text
服务端地址: http://127.0.0.1:8082
传入 endpoint: /v1/system/health
实际请求: http://127.0.0.1:8082/api/v1/system/health
```
所有请求都会带:
```text
X-Api-Token: <Token>
Accept: application/json
```
Token 默认值写在 `QT/src/ApiClient.cpp`,也可以通过环境变量覆盖:
```powershell
$env:ATTENDANCE_API_TOKEN="你的Token"
```
网络超时:
```text
5000 ms
```
## 7. 启动流程
Qt 启动后:
1. `main.cpp` 创建 `QGuiApplication`。
2. 设置应用图标、应用名、版本号、组织名。
3. Android 下启用原生文字渲染。
4. 创建 `AppController`。
5. `AppController` 读取本地服务端地址、映射配置、时间段配置。
6. `ApiClient` 设置当前服务端地址。
7. 启动自动重连状态并执行健康检查。
8. QML 载入 `Main.qml`。
9. QML 通过上下文对象 `appController` 调用 C++。
## 8. 自动重连
如果健康检查失败,`AppController` 会进入重连状态。
相关参数:
```text
ReconnectIntervalMs = 5000
```
重连成功后,如果之前处于重连中,会调用:
1. `refreshAll()`
2. `refreshFaces()`
这样服务端恢复后,记录、配置、照片和映射会重新同步。
## 9. 考勤记录页
功能:
1. 显示服务端记录。
2. 显示总记录数、今日记录数、当前筛选结果数。
3. 按班级筛选。
4. 按学号筛选。
5. 按姓名关键字筛选。
6. 按日期范围筛选。
7. 导出当前筛选结果。
8. 清空服务端所有记录。
记录加载默认请求:
```text
GET /api/v1/attendance/records?limit=1000&offset=0
```
注意:当前 Qt 统计和筛选主要基于已加载的记录。如果服务端超过 1000 条,界面统计不一定覆盖全部历史记录。
## 10. 筛选规则
筛选在 Qt 本地完成。
支持:
| 筛选项 | 说明 |
| --- | --- |
| 班级 | `class_id` 匹配 |
| 学号 | `student_id` 匹配 |
| 姓名 | 映射姓名或原始 name 包含关键字 |
| 日期 | 全部、今天、最近 7 天、最近 30 天、指定日期 |
姓名显示优先级:
1. 如果学生映射里有该学号,显示映射姓名。
2. 否则显示服务端记录中的 `name`。
3. 如果都没有,则显示原始学号。
班级显示优先级:
1. 如果班级映射里有该 `class_id`,显示映射班级名。
2. 否则显示 `class_id`。
## 11. 记录导出
导出 CSV 文件时会写入:
```text
# 考勤记录导出
# 导出时间: yyyy-MM-dd HH:mm:ss
# 记录总数: N
签到时间,班级编号,班级名称,学号,姓名,置信度,状态
```
字段会做 CSV 转义,包含逗号、双引号、换行时会自动加引号并转义。
## 12. 统计分析页
统计页基于当前加载记录在本地计算。
支持时间范围:
1. 今天。
2. 最近 7 天。
3. 最近 30 天。
4. 本月。
5. 自定义开始和结束日期。
统计指标:
| 指标 | 含义 |
| --- | --- |
| 统计学生数 | 当前范围内出现过的学生数量 |
| 统计记录数 | 当前范围内记录数量 |
| 总签到次数 | 每个学生在范围内的记录数量 |
| 今日签到 | 每个学生今天的记录数量 |
| 最后签到时间 | 每个学生最新一条记录时间 |
| 平均置信度 | 每个学生记录的平均 confidence |
导出字段:
```text
考勤统计报表,<范围>
学号,姓名,总签到次数,今日签到,最后签到时间,平均置信度
```
## 13. 配置中心
配置中心包含三块:
1. 服务端地址。
2. 时间段配置。
3. 名称映射配置。
### 13.1 服务端地址
字段示例:
```text
http://27.106.113.205:8082
```
按钮:
| 按钮 | 作用 |
| --- | --- |
| 保存地址 | 写入 `QSettings`,并立即切换 `ApiClient` 的服务端 |
| 测试连接 | 请求 `/api/v1/system/health` |
### 13.2 时间段配置
时间段格式:
```json
{
"name": "上午签到",
"start_time": "08:00",
"end_time": "09:30",
"enabled": true
}
```
当前默认时间段为空。第一次使用时需要手动新增。
按钮:
| 按钮 | 作用 |
| --- | --- |
| 展开/收起 | 展示或隐藏时间段列表 |
| 新增时段 | 打开新增对话框 |
| 清空时段 | 清空本地时间段并保存同步到服务端 |
| 从服务器同步/同步 | 从服务端读取时间段;服务端为空数组时也会清空本地时间段 |
| 保存时间段/保存 | 保存到本地并上传服务端 |
本地文件:
```text
timeslot_config.json
```
如果本地文件中刚好是旧版本四个默认时间段,当前代码会自动迁移为空配置。
### 13.3 名称映射配置
学生映射:
```json
{
"20240001": "张三"
}
```
班级映射:
```json
{
"1": "软件工程一班"
}
```
本地文件:
```text
mapping_config.json
```
## 14. 人脸管理页
人脸管理页把三类数据合并展示:
1. 服务端照片列表。
2. 服务端已有学生 ID。
3. 服务端 face_id 到学生 ID 映射。
如果服务端已经把照片从原始 face_id 文件名重命名为学号文件名,Qt 仍会把已有映射中的原始 face_id 展示出来,保存时保留这类真实映射,并跳过未编辑的“ID 等于学号”的默认占位项。
用户可以:
1. 刷新人脸库。
2. 为每张照片填写真实学号。
3. 保存映射。
4. 删除人脸。
5. 删除人脸时选择是否同时删除该学生的考勤记录。
照片下载流程:
1. Qt 获取照片 ID 列表。
2. 对每个 ID 请求 `/api/v1/faces/photos/<id>`。
3. 下载成功后保存到本地缓存目录:
```text
<AppDataLocation>/face_photos/<id>.jpg
```
4. QML 使用本地 file URL 显示图片。
## 15. 关于页面
关于页显示:
1. 系统用途。
2. 功能范围。
3. 毕业设计说明。
4. 作者和版权信息。
5. 仓库地址。
6. 演示账号信息。
这些文本在 `QT/qml/Main.qml` 中。
## 16. PC 和 Android 适配
当前 QML 中有这些布局断点:
| 属性 | 条件 | 用途 |
| --- | --- | --- |
| `compactLayout` | `width < 1460` | PC 紧凑布局 |
| `denseLayout` | `width < 1380` | 更密集显示 |
| `narrowLayout` | `width < 1280` | 表格列裁剪 |
| `slimLayout` | `width < 1040` | 隐藏侧边栏,使用移动导航 |
| `phoneLayout` | PC 小于 560,Android 小于 640 | 手机布局 |
Android 特殊处理:
1. 最小窗口宽度是 `360`。
2. 最小窗口高度是 `560`。
3. 字体使用 `sans-serif`。
4. 粗体权重降低为 `Font.Medium`。
5. 使用原生文字渲染,减少中文粗体重影。
配置页卡片现在使用内容自适应高度,避免按钮遮挡。
## 17. 本地持久化
Qt 使用两类本地持久化。
### 17.1 QSettings
保存服务端地址:
```text
server/url
```
### 17.2 AppDataLocation 文件
| 文件 | 内容 |
| --- | --- |
| `timeslot_config.json` | 时间段配置 |
| `mapping_config.json` | 学生姓名和班级名称映射 |
| `face_photos/*.jpg` | 下载后的人脸照片缓存 |
## 18. 构建方式
### 18.1 Qt Creator
1. 打开 Qt Creator。
2. 打开 `QT/CMakeLists.txt`。
3. 选择 Desktop Qt 6.x 或 Android Kit。
4. 等待 CMake 配置完成。
5. 构建 `apprewrite_qt`。
6. 桌面端直接运行;Android 端部署到设备或模拟器。
### 18.2 命令行示例
Windows:
```powershell
cd D:\University\graduation\code\Reconstruct_Qt\QT
cmake -S . -B build\Desktop -DCMAKE_PREFIX_PATH=D:\Qt\6.10.0\mingw_64
cmake --build build\Desktop
```
实际路径要按本机 Qt 安装位置调整。
## 19. 常见问题
| 现象 | 可能原因 | 处理 |
| --- | --- | --- |
| 测试连接失败 | 服务端没启动、地址错、Token 不一致 | 检查服务端健康接口和 `ATTENDANCE_API_TOKEN` |
| 页面按钮重叠 | 旧版固定高度导致内容被裁切 | 当前已改为自适应高度 |
| Android 文字发虚/重影 | 字体渲染方式问题 | 当前 Android 使用原生渲染和中等粗体 |
| 人脸照片不显示 | 下载失败、本地缓存路径无效、服务端无照片 | 刷新人脸库,看服务端 `faces/` |
| 统计数量少于服务端总量 | Qt 默认只拉取 1000 条记录 | 增加分页或服务端统计接口 |
| 时间段为空导致无法签到 | 当前默认就是空配置 | 在配置中心新增时间段并保存 |
| 保存映射后 K210 没变化 | K210 尚未同步或 WiFi 不通 | 等待同步或检查 K210 日志 |
-283
View File
@@ -1,283 +0,0 @@
# 项目维护、验收与风险清单
## 1. 文档目的
这份文档用于回答三个问题:
1. 项目目前实现了什么。
2. 验收或答辩时可以从哪些方面说明。
3. 后续维护时有哪些风险和改进方向。
它不是单纯的 bug 列表,而是把功能完整性、稳定性、安全性、易用性和可维护性都列出来。
## 2. 当前实现状态总览
| 模块 | 当前状态 | 说明 |
| --- | --- | --- |
| STM32 主控 | 已实现 | 接收 K210、考勤判断、加密上传、断线重试、时间段同步 |
| K210 识别 | 已实现 | 人脸检测、特征比对、添加/删除、照片上传、映射同步 |
| 服务端 | 已实现 | SQLite 存储、HTTP API、Token、AES 解密、统一同步 |
| Qt 管理端 | 已实现 | 记录、统计、配置、人脸管理、PC/Android 响应式界面 |
| 默认时间段 | 已改为空 | 需要管理员在 Qt 中新增 |
| 数据导出 | 已实现 | Qt 可导出记录和统计 CSV |
| 自动化 API 回归 | 已有脚本 | `server/regression_api_tests.ps1` |
## 3. 功能验收清单
### 3.1 服务端验收
| 检查项 | 通过标准 |
| --- | --- |
| 服务可启动 | 控制台显示监听 `8082` |
| 健康检查 | `/api/v1/system/health` 返回 `healthy` |
| SQLite 初始化 | 生成或打开 `attendance.db` |
| 时间接口 | `/api/v1/system/time` 返回 `epoch` 和 `server_time` |
| Token 鉴权 | 不带 Token 访问受保护接口返回 401 |
| 时间段默认 | `/api/v1/config/time-slots` 返回 `{"time_slots":[]}` |
| 记录接口 | `/api/v1/attendance/records` 返回 JSON 数组 |
| 统计接口 | `/api/v1/attendance/statistics` 返回 `total`、`today` |
| 照片接口 | 可列出、上传、下载 JPEG |
| 同步接口 | `/api/v1/sync/state` 按客户端返回不同数据 |
### 3.2 Qt 管理端验收
| 检查项 | 通过标准 |
| --- | --- |
| 启动界面 | Windows 和 Android 都能打开主界面 |
| 服务地址 | 可保存地址并测试连接 |
| 配置页布局 | PC 和 Android 不出现按钮遮挡 |
| 时间段 | 默认为空,可新增、保存、同步、清空 |
| 映射 | 可新增学生姓名和班级名称映射 |
| 记录页 | 能加载、筛选和导出记录 |
| 统计页 | 能按日期范围统计并导出 |
| 人脸管理 | 能刷新照片、绑定学号、保存映射、删除人脸 |
| 自动重连 | 服务端恢复后能重新同步 |
### 3.3 STM32 验收
| 检查项 | 通过标准 |
| --- | --- |
| 硬件初始化 | OLED、蜂鸣器、LED、串口正常 |
| ESP8266 | 能连接 WiFi 和服务端 |
| 时间同步 | 能从服务端获取时间或读取 DS1302 |
| 时间段同步 | 能拉取服务端配置,空配置会清空本地时间段 |
| K210 接收 | 能解析 `ID:...,NAME:...,CONF:...` |
| 签到判断 | 置信度、时间段、重复签到逻辑生效 |
| 加密上传 | 服务端能收到并解密保存记录 |
| 断线重试 | 网络断开时记录保留,恢复后同步 |
### 3.4 K210 验收
| 检查项 | 通过标准 |
| --- | --- |
| 模型加载 | 三个 `.smodel` 正常加载 |
| 摄像头 | LCD 能显示画面,方向正确 |
| 人脸识别 | 已录入人脸能识别,陌生人显示陌生 |
| 添加人脸 | 短按进入添加,确认后写入 SD 卡 |
| 删除人脸 | 长按进入删除,可删除单个或全部 |
| 照片上传 | 新增人脸照片能上传到服务端 |
| 映射同步 | Qt 保存映射后 K210 本地名称更新 |
| 删除同步 | Qt 删除后 K210 能删除本地特征并 ACK |
## 4. 数据一致性清单
### 4.1 Token 一致
这些地方必须一致:
1. 服务端 `ATTENDANCE_API_TOKEN` 或默认 Token。
2. Qt 环境变量或默认 Token。
3. `User/main.c` 的 `HTTP_API_TOKEN`。
4. `k210/main.py` 的 `API_TOKEN`。
### 4.2 服务端地址一致
这些地方要指向同一个服务端:
1. Qt 配置中心的服务端地址。
2. `User/main.c` 的 `HTTP_SERVER_IP` 和 `HTTP_SERVER_PORT`。
3. `k210/main.py` 的 `SERVER_IP` 和 `SERVER_PORT`。
### 4.3 AES 参数一致
这些文件必须一致:
1. `Hardware/Crypto.c`
2. `server/server_encrypted.cpp`
3. `QT/src/CryptoUtils.cpp`
参数:
```text
Key: 2b 7e 15 16 28 ae d2 a6 ab f7 15 88 09 cf 4f 3c
IV : 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f
```
### 4.4 时间段一致
Qt 保存时间段后:
1. 服务端 `time_slots` 更新。
2. 服务端同步版本提升。
3. STM32 下一次同步拉取到新时间段。
4. STM32 使用新的时间段判断签到。
### 4.5 人脸映射一致
Qt 保存人脸映射后:
1. 服务端保存 `face_mappings`。
2. 服务端触发 K210 同步请求。
3. K210 同步后更新 `face_mapping.txt`。
4. K210 更新 `recordftr.txt`。
5. K210 发给 STM32 的 ID 变成真实学号。
## 5. 当前已知限制
### 5.1 Qt 记录统计基于当前加载记录
Qt 默认请求:
```text
limit=1000&offset=0
```
如果服务端已有超过 1000 条记录,Qt 本地筛选和统计只覆盖当前加载的这 1000 条。服务端 `/statistics` 的总数和今日数仍来自数据库。
改进方向:
1. 增加分页。
2. 增加服务端按条件查询。
3. 增加服务端按日期统计接口。
### 5.2 时间输入缺少强校验
Qt 时间段输入框允许手动输入文本。当前主要依赖 `QTime::fromString("HH:mm")` 解析。
改进方向:
1. 使用时间选择控件。
2. 保存前校验格式。
3. 检查开始时间必须早于结束时间。
4. 检查时间段重叠。
### 5.3 K210 JSON 解析仍偏轻量
K210 MicroPython 环境中很多 JSON 解析是手写字符串处理。简单数据够用,但复杂转义字符支持有限。
改进方向:
1. 尽量让服务端返回简单结构。
2. 避免映射 key/value 中出现引号、逗号等复杂字符。
3. 如果固件环境支持,使用 JSON 库。
### 5.4 STM32 本地缓存容量有限
STM32 本地记录最多 100 条,今日学生缓存最多 50 个 ID。
改进方向:
1. 根据实际班级人数扩大缓存。
2. 增加外部 Flash 或 SD 卡存储。
3. 对已同步记录做更明确的淘汰策略。
### 5.5 服务端是单文件原生 HTTP 实现
服务端用 socket 手写 HTTP,演示和毕业设计足够直观,但在生产环境上不如成熟 Web 框架。
改进方向:
1. 引入成熟 HTTP 库。
2. 增加结构化日志。
3. 增加配置文件。
4. 增加单元测试。
## 6. 安全风险和建议
| 风险 | 当前状态 | 建议 |
| --- | --- | --- |
| 默认 Token 写在代码中 | 存在 | 部署时用环境变量,并替换 STM32/K210 常量 |
| HTTP 明文传输 | 存在 | 公网部署加 HTTPS 反向代理 |
| AES Key/IV 固定 | 存在 | 演示可接受,生产应使用更安全的密钥管理 |
| 管理接口权限粗粒度 | 存在 | 增加用户登录、角色权限 |
| 清空记录接口风险高 | 存在 | 增加二次确认、审计日志 |
| 删除人脸会影响多端 | 已有同步任务 | 保留日志,避免误删 |
## 7. 答辩或展示可以讲的重点
### 7.1 多端协同
项目不是单独做一个 UI 或一个识别脚本,而是把嵌入式主控、AI 识别、服务端、桌面/移动管理端串起来。
### 7.2 安全传输
STM32 上传签到数据不是明文,而是 AES-128-CBC 加密后 Base64 传输。服务端负责解密和校验。
### 7.3 同步版本机制
服务端维护统一版本号,STM32、K210、Qt 都可以按版本拉取自己需要的数据,减少无意义请求。
### 7.4 断线容错
STM32 在网络异常时仍能运行,并保留待同步记录;K210 在照片上传失败时保存本地照片并重新排队。
### 7.5 跨平台管理端
Qt 管理端支持 PC 和 Android,配置页已做响应式布局,避免窄屏按钮遮挡。
### 7.6 数据可视化和导出
Qt 提供记录筛选、统计分析和 CSV 导出,方便后续汇总考勤数据。
## 8. 回归测试建议
### 8.1 每次改服务端后
1. 启动服务端。
2. 运行 `server/regression_api_tests.ps1`。
3. 手动测试上传时间段为空数组。
4. 手动测试保存人脸映射后同步版本是否变化。
5. 手动测试删除人脸后 `pending_delete` 是否出现任务。
### 8.2 每次改 Qt 后
1. 检查 PC 宽屏。
2. 检查 PC 窄屏。
3. 检查 Android 手机宽度。
4. 检查配置中心展开/收起。
5. 检查时间段为空时页面是否正常。
6. 检查保存映射和刷新照片。
### 8.3 每次改 STM32 后
1. 看调试串口启动日志。
2. 确认 ESP8266 能连 WiFi。
3. 确认能下载时间段。
4. 确认空时间段时不会签到。
5. 配置时间段后再确认能签到。
6. 断开服务端再恢复,检查待同步记录上传。
### 8.4 每次改 K210 后
1. 确认模型加载。
2. 确认添加人脸写入 SD 卡。
3. 确认照片进入上传队列。
4. 确认服务端能看到照片。
5. 确认 Qt 保存映射后 K210 能同步。
6. 确认 Qt 删除后 K210 能删除并 ACK。
## 9. 建议后续优化路线
优先级从高到低:
1. 增加 Qt 时间输入校验和重叠检测。
2. 给服务端增加配置文件,减少硬编码。
3. 给服务端增加更完整的自动化测试。
4. 给 Qt 增加记录分页。
5. 给服务端增加按条件查询和统计接口。
6. 把 Token 和 AES 密钥改成部署时配置。
7. 增加 HTTPS 部署说明。
8. 给 STM32 增加更大的离线缓存。
9. 给 K210 使用更稳健的 JSON 解析。
10. 增加服务端日志文件轮转。
-546
View File
@@ -1,546 +0,0 @@
# 后端接口 Windows 测试命令
## 1. 使用前准备
本文档用于在 Windows PowerShell 中手动测试服务端接口。
默认服务端地址:
```powershell
$BaseUrl = "http://127.0.0.1:8082"
```
默认 Token:
```powershell
$ApiToken = "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV"
```
建议先在 PowerShell 中设置:
```powershell
$BaseUrl = "http://127.0.0.1:8082"
$ApiToken = "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV"
$Headers = @{
"X-Api-Token" = $ApiToken
"Accept" = "application/json"
}
```
如果服务端运行在公网 IP,例如 `27.106.113.205`:
```powershell
$BaseUrl = "http://27.106.113.205:8082"
```
## 2. 自动化回归脚本
项目已经提供基础回归脚本:
```powershell
cd D:\University\graduation\code\Reconstruct_Qt\server
.\regression_api_tests.ps1 -BaseUrl "http://127.0.0.1:8082" -ApiToken "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV"
```
脚本会检查:
1. 健康检查。
2. 存储状态。
3. 时间接口。
4. 同步版本。
5. K210/STM32 同步状态。
6. 记录和统计。
7. 时间段。
8. 查重。
9. 今日记录。
10. 映射。
11. 照片。
12. 学生 ID。
13. 待删除任务。
14. 同步请求。
## 3. 基础连通性
### 3.1 健康检查
```powershell
Invoke-RestMethod -Uri "$BaseUrl/api/v1/system/health"
```
不需要 Token。正常应返回 `status = healthy`。
### 3.2 带 Token 健康检查
```powershell
Invoke-RestMethod -Uri "$BaseUrl/api/v1/system/health" -Headers $Headers
```
### 3.3 端口连通
```powershell
Test-NetConnection -ComputerName 127.0.0.1 -Port 8082
```
公网服务器:
```powershell
Test-NetConnection -ComputerName 27.106.113.205 -Port 8082
```
### 3.4 检查端口占用
```powershell
netstat -ano | findstr :8082
```
## 4. 系统接口
### 4.1 获取服务器时间
```powershell
Invoke-RestMethod -Uri "$BaseUrl/api/v1/system/time" -Headers $Headers
```
返回应包含:
```text
epoch
server_time
```
### 4.2 查看存储状态
```powershell
Invoke-RestMethod -Uri "$BaseUrl/api/v1/system/storage" -Headers $Headers
```
用于确认 SQLite、记录数、照片数、待删除任务等。
### 4.3 不带 Token 测试鉴权
```powershell
try {
Invoke-RestMethod -Uri "$BaseUrl/api/v1/system/storage"
} catch {
$_.Exception.Response.StatusCode.value__
}
```
正常应该得到 `401`。
## 5. 同步接口
### 5.1 查看当前同步版本
```powershell
Invoke-RestMethod -Uri "$BaseUrl/api/v1/sync/version" -Headers $Headers
```
### 5.2 Qt 同步状态
```powershell
Invoke-RestMethod -Uri "$BaseUrl/api/v1/sync/state?client=qt&last_version=0" -Headers $Headers
```
返回内容包含 Qt 需要的数据,例如记录、统计、时间段、映射、照片和学生 ID。
### 5.3 STM32 同步状态
```powershell
Invoke-RestMethod -Uri "$BaseUrl/api/v1/sync/state?client=stm32&last_version=0" -Headers $Headers
```
重点看:
```text
state.time_slots
```
当前默认应为空数组,除非已经在 Qt 中保存过时间段。
### 5.4 K210 同步状态
```powershell
Invoke-RestMethod -Uri "$BaseUrl/api/v1/sync/state?client=k210&last_version=0" -Headers $Headers
```
重点看:
```text
state.face_mappings
state.pending_delete
state.delete_tasks
```
### 5.5 K210 同步 ACK
```powershell
$Body = @{
client = "k210"
version = 1
deleted = @()
task_ids = @()
}
Invoke-RestMethod `
-Uri "$BaseUrl/api/v1/sync/ack" `
-Method POST `
-Headers $Headers `
-ContentType "application/json" `
-Body ($Body | ConvertTo-Json -Compress)
```
## 6. 考勤记录接口
### 6.1 获取记录
```powershell
Invoke-RestMethod -Uri "$BaseUrl/api/v1/attendance/records?limit=10&offset=0" -Headers $Headers
```
### 6.2 获取统计
```powershell
Invoke-RestMethod -Uri "$BaseUrl/api/v1/attendance/statistics" -Headers $Headers
```
### 6.3 删除单条记录
先准备参数:
```powershell
$Timestamp = [uri]::EscapeDataString("2026-05-01 08:10:00")
$StudentId = [uri]::EscapeDataString("20240001")
```
删除:
```powershell
Invoke-RestMethod `
-Uri "$BaseUrl/api/v1/attendance/records?timestamp=$Timestamp&student_id=$StudentId" `
-Method DELETE `
-Headers $Headers
```
### 6.4 清空全部记录
危险操作,会删除全部考勤记录:
```powershell
Invoke-RestMethod `
-Uri "$BaseUrl/api/v1/attendance/records/clear" `
-Method POST `
-Headers $Headers `
-ContentType "application/json"
```
## 7. 时间段接口
### 7.1 获取时间段
```powershell
Invoke-RestMethod -Uri "$BaseUrl/api/v1/config/time-slots" -Headers $Headers
```
新部署默认应为:
```json
{
"time_slots": []
}
```
### 7.2 保存空时间段
```powershell
$Body = @{
time_slots = @()
}
Invoke-RestMethod `
-Uri "$BaseUrl/api/v1/config/time-slots" `
-Method POST `
-Headers $Headers `
-ContentType "application/json" `
-Body ($Body | ConvertTo-Json -Depth 5 -Compress)
```
### 7.3 保存一个时间段
```powershell
$Body = @{
time_slots = @(
@{
name = "上午签到"
start_time = "08:00"
end_time = "09:30"
enabled = $true
}
)
}
Invoke-RestMethod `
-Uri "$BaseUrl/api/v1/config/time-slots" `
-Method POST `
-Headers $Headers `
-ContentType "application/json" `
-Body ($Body | ConvertTo-Json -Depth 5 -Compress)
```
### 7.4 检查重复签到
```powershell
Invoke-RestMethod `
-Uri "$BaseUrl/api/v1/attendance/duplicates?student_id=20240001&slot_start=08:00&slot_end=09:30" `
-Headers $Headers
```
### 7.5 获取今日某时间段记录
```powershell
Invoke-RestMethod `
-Uri "$BaseUrl/api/v1/attendance/today?slot_start=08:00&slot_end=09:30" `
-Headers $Headers
```
## 8. 名称映射接口
### 8.1 获取映射
```powershell
Invoke-RestMethod -Uri "$BaseUrl/api/v1/config/mappings" -Headers $Headers
```
### 8.2 保存映射
```powershell
$Body = @{
student_name_map = @{
"20240001" = "张三"
}
class_name_map = @{
"1" = "软件工程一班"
}
}
Invoke-RestMethod `
-Uri "$BaseUrl/api/v1/config/mappings" `
-Method POST `
-Headers $Headers `
-ContentType "application/json" `
-Body ($Body | ConvertTo-Json -Depth 10 -Compress)
```
## 9. 人脸照片和映射接口
### 9.1 获取照片列表
```powershell
Invoke-RestMethod -Uri "$BaseUrl/api/v1/faces/photos" -Headers $Headers
```
### 9.2 下载照片
```powershell
$FaceId = "1"
Invoke-WebRequest `
-Uri "$BaseUrl/api/v1/faces/photos/$FaceId" `
-Headers $Headers `
-OutFile ".\face_$FaceId.jpg"
```
如果服务端没有该照片,会返回 404。
### 9.3 上传测试照片
假设当前目录有 `test.jpg`:
```powershell
$FaceId = "test001"
$PhotoBytes = [System.IO.File]::ReadAllBytes((Resolve-Path ".\test.jpg"))
Invoke-RestMethod `
-Uri "$BaseUrl/api/v1/faces/photos/$FaceId" `
-Method POST `
-Headers $Headers `
-ContentType "image/jpeg" `
-Body $PhotoBytes
```
### 9.4 获取人脸映射
```powershell
Invoke-RestMethod -Uri "$BaseUrl/api/v1/faces/mappings" -Headers $Headers
```
### 9.5 保存人脸映射
```powershell
$Body = @{
mappings = @{
"1" = "20240001"
}
}
Invoke-RestMethod `
-Uri "$BaseUrl/api/v1/faces/mappings" `
-Method POST `
-Headers $Headers `
-ContentType "application/json" `
-Body ($Body | ConvertTo-Json -Depth 5 -Compress)
```
保存后应提升同步版本,并触发 K210 同步请求。
### 9.6 获取全部学生 ID
```powershell
Invoke-RestMethod -Uri "$BaseUrl/api/v1/faces/student-ids" -Headers $Headers
```
### 9.7 删除人脸
只删除人脸相关信息:
```powershell
$StudentId = [uri]::EscapeDataString("20240001")
Invoke-RestMethod `
-Uri "$BaseUrl/api/v1/faces/$StudentId" `
-Method DELETE `
-Headers $Headers
```
删除人脸并删除该学生考勤记录:
```powershell
$StudentId = [uri]::EscapeDataString("20240001")
Invoke-RestMethod `
-Uri "$BaseUrl/api/v1/faces/$StudentId?delete_records=true" `
-Method DELETE `
-Headers $Headers
```
## 10. K210 删除同步接口
### 10.1 获取待删除任务
```powershell
Invoke-RestMethod -Uri "$BaseUrl/api/v1/faces/pending-delete" -Headers $Headers
```
返回可能包含:
```text
pending_delete
delete_tasks
```
### 10.2 旧式删除确认
```powershell
$Body = @{
face_id = "1"
}
Invoke-RestMethod `
-Uri "$BaseUrl/api/v1/faces/delete-ack" `
-Method POST `
-Headers $Headers `
-ContentType "application/json" `
-Body ($Body | ConvertTo-Json -Compress)
```
### 10.3 查询同步请求标志
```powershell
Invoke-RestMethod -Uri "$BaseUrl/api/v1/sync/request" -Headers $Headers
```
### 10.4 确认同步请求
```powershell
Invoke-RestMethod `
-Uri "$BaseUrl/api/v1/sync/request/ack" `
-Method POST `
-Headers $Headers `
-ContentType "application/json" `
-Body '{"ack":true}'
```
### 10.5 触发 K210 同步
```powershell
Invoke-RestMethod `
-Uri "$BaseUrl/api/v1/sync/trigger" `
-Method POST `
-Headers $Headers `
-ContentType "application/json"
```
## 11. 加密签到接口说明
STM32 实际上传的是 AES-128-CBC 加密后的 Base64。手动构造比较麻烦,通常不建议用 PowerShell 直接手写。
服务端要求:
```text
POST /api/v1/attendance/checkins
X-Api-Token: <Token>
X-Encrypted: AES128-CBC
```
明文格式应是:
```json
{
"student_id": "20240001",
"name": "张三",
"confidence": "0.95",
"status": "success",
"class_id": "1"
}
```
如果只是测试记录列表、统计、配置和同步,不需要手动构造加密签到。
## 12. 常见错误
### 12.1 401 Unauthorized
原因:Token 缺失或不一致。
检查:
```powershell
$Headers
```
### 12.2 404 Not Found
原因:
1. 路径写错。
2. 服务端还没启动新版本。
3. 照片 ID 不存在。
### 12.3 Invoke-RestMethod 连接失败
检查服务端是否启动:
```powershell
Test-NetConnection -ComputerName 127.0.0.1 -Port 8082
```
### 12.4 Qt 能访问,本机命令不能访问
可能 Qt 用的是公网地址,而 PowerShell `$BaseUrl` 还是 `127.0.0.1`。统一服务端地址。
### 12.5 STM32/K210 不能访问,电脑能访问
可能原因:
1. STM32/K210 配置的是公网 IP,本机测的是本地地址。
2. WiFi 与服务器不在同一网络。
3. 路由器或服务器防火墙阻止端口。
4. Token 写错。
-1832
View File
File diff suppressed because it is too large Load Diff
-50
View File
@@ -1,50 +0,0 @@
# K210考勤数据查看工具
# 用于读取和显示SD卡中的考勤记录
import os
ATTENDANCE_FILE = "/sd/attendance_records.csv"
def view_attendance_records(limit=20):
"""查看最近的考勤记录"""
try:
if ATTENDANCE_FILE not in os.listdir("/sd"):
print("No attendance file found!")
return
with open(ATTENDANCE_FILE, "r") as f:
lines = f.readlines()
print("=" * 50)
print("Attendance Records (Total: {})".format(len(lines) - 1))
print("=" * 50)
# 显示表头
if len(lines) > 0:
print(lines[0].strip())
print("-" * 50)
# 显示最近的记录
start = max(1, len(lines) - limit)
for i in range(start, len(lines)):
print(lines[i].strip())
print("=" * 50)
except Exception as e:
print("Error reading file:", e)
def clear_attendance_records():
"""清空考勤记录(保留表头)"""
try:
with open(ATTENDANCE_FILE, "w") as f:
f.write("Timestamp,Student_ID,Name,Confidence,Status\n")
print("Attendance records cleared!")
except Exception as e:
print("Error clearing file:", e)
# 显示最近20条记录
view_attendance_records(20)
# 如果需要清空记录,取消下面这行的注释
# clear_attendance_records()
-62
View File
@@ -1,62 +0,0 @@
param(
[string]$BaseUrl = "http://127.0.0.1:8082",
[string]$ApiToken = "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV"
)
$ErrorActionPreference = "Stop"
$headers = @{
"X-Api-Token" = $ApiToken
"Accept" = "application/json"
}
function Invoke-ApiCheck {
param(
[string]$Name,
[string]$Path,
[string]$Method = "GET",
[object]$Body = $null
)
$uri = "$BaseUrl$Path"
$requestHeaders = @{} + $headers
$params = @{
Uri = $uri
Method = $Method
Headers = $requestHeaders
TimeoutSec = 8
}
if ($null -ne $Body) {
$params.ContentType = "application/json"
$params.Body = ($Body | ConvertTo-Json -Depth 10 -Compress)
}
try {
$response = Invoke-RestMethod @params
Write-Host "[PASS] $Name"
return $response
} catch {
Write-Host "[FAIL] $Name - $($_.Exception.Message)"
throw
}
}
Invoke-ApiCheck "health" "/api/v1/system/health" | Out-Null
Invoke-ApiCheck "storage" "/api/v1/system/storage" | Out-Null
Invoke-ApiCheck "time" "/api/v1/system/time" | Out-Null
Invoke-ApiCheck "sync version" "/api/v1/sync/version" | Out-Null
Invoke-ApiCheck "sync state k210" "/api/v1/sync/state?client=k210&last_version=0" | Out-Null
Invoke-ApiCheck "sync state stm32" "/api/v1/sync/state?client=stm32&last_version=0" | Out-Null
Invoke-ApiCheck "records" "/api/v1/attendance/records?limit=10&offset=0" | Out-Null
Invoke-ApiCheck "statistics" "/api/v1/attendance/statistics" | Out-Null
Invoke-ApiCheck "time slots" "/api/v1/config/time-slots" | Out-Null
Invoke-ApiCheck "duplicate check" "/api/v1/attendance/duplicates?student_id=1&slot_start=00:00&slot_end=23:59" | Out-Null
Invoke-ApiCheck "today records" "/api/v1/attendance/today?slot_start=00:00&slot_end=23:59" | Out-Null
Invoke-ApiCheck "mapping config" "/api/v1/config/mappings" | Out-Null
Invoke-ApiCheck "face photos" "/api/v1/faces/photos" | Out-Null
Invoke-ApiCheck "face mappings" "/api/v1/faces/mappings" | Out-Null
Invoke-ApiCheck "student ids" "/api/v1/faces/student-ids" | Out-Null
Invoke-ApiCheck "pending deletes" "/api/v1/faces/pending-delete" | Out-Null
Invoke-ApiCheck "sync request" "/api/v1/sync/request" | Out-Null
Write-Host "All regression API checks passed."
File diff suppressed because it is too large Load Diff
-291
View File
@@ -1,291 +0,0 @@
/*
* Tiny AES128 C++ 移植版本
* 与STM32端使用相同的AES实现,保证兼容性
*/
#ifndef _TINY_AES_HPP_
#define _TINY_AES_HPP_
#include <cstdint>
#include <cstring>
#define AES_BLOCKLEN 16
#define AES_KEYLEN 16
#define AES_keyExpSize 176
struct AES_ctx
{
uint8_t RoundKey[AES_keyExpSize];
uint8_t Iv[AES_BLOCKLEN];
};
// S-box
static const uint8_t sbox[256] = {
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16
};
// Inverse S-box
static const uint8_t rsbox[256] = {
0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb,
0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb,
0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e,
0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25,
0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92,
0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84,
0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06,
0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b,
0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73,
0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e,
0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b,
0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4,
0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f,
0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef,
0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61,
0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d
};
// Rcon
static const uint8_t Rcon[11] = {
0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36
};
#define getSBoxValue(num) (sbox[(num)])
#define getSBoxInvert(num) (rsbox[(num)])
// 导出这些函数供外部使用
void KeyExpansion(uint8_t* RoundKey, const uint8_t* Key);
void InvCipher(uint8_t* state, const uint8_t* RoundKey);
inline void KeyExpansion(uint8_t* RoundKey, const uint8_t* Key)
{
unsigned i, j, k;
uint8_t tempa[4];
for (i = 0; i < 4; ++i) {
RoundKey[(i * 4) + 0] = Key[(i * 4) + 0];
RoundKey[(i * 4) + 1] = Key[(i * 4) + 1];
RoundKey[(i * 4) + 2] = Key[(i * 4) + 2];
RoundKey[(i * 4) + 3] = Key[(i * 4) + 3];
}
for (i = 4; i < 44; ++i) {
k = (i - 1) * 4;
tempa[0] = RoundKey[k + 0];
tempa[1] = RoundKey[k + 1];
tempa[2] = RoundKey[k + 2];
tempa[3] = RoundKey[k + 3];
if (i % 4 == 0) {
const uint8_t u8tmp = tempa[0];
tempa[0] = tempa[1];
tempa[1] = tempa[2];
tempa[2] = tempa[3];
tempa[3] = u8tmp;
tempa[0] = getSBoxValue(tempa[0]);
tempa[1] = getSBoxValue(tempa[1]);
tempa[2] = getSBoxValue(tempa[2]);
tempa[3] = getSBoxValue(tempa[3]);
tempa[0] = tempa[0] ^ Rcon[i / 4];
}
j = i * 4; k = (i - 4) * 4;
RoundKey[j + 0] = RoundKey[k + 0] ^ tempa[0];
RoundKey[j + 1] = RoundKey[k + 1] ^ tempa[1];
RoundKey[j + 2] = RoundKey[k + 2] ^ tempa[2];
RoundKey[j + 3] = RoundKey[k + 3] ^ tempa[3];
}
}
static void AddRoundKey(uint8_t round, uint8_t* state, const uint8_t* RoundKey)
{
uint8_t i, j;
for (i = 0; i < 4; ++i) {
for (j = 0; j < 4; ++j) {
state[i * 4 + j] ^= RoundKey[(round * 16) + (i * 4 + j)];
}
}
}
static void SubBytes(uint8_t* state)
{
uint8_t i;
for (i = 0; i < 16; ++i) {
state[i] = getSBoxValue(state[i]);
}
}
static void InvSubBytes(uint8_t* state)
{
uint8_t i;
for (i = 0; i < 16; ++i) {
state[i] = getSBoxInvert(state[i]);
}
}
static void ShiftRows(uint8_t* state)
{
uint8_t temp;
temp = state[1];
state[1] = state[5];
state[5] = state[9];
state[9] = state[13];
state[13] = temp;
temp = state[2];
state[2] = state[10];
state[10] = temp;
temp = state[6];
state[6] = state[14];
state[14] = temp;
temp = state[15];
state[15] = state[11];
state[11] = state[7];
state[7] = state[3];
state[3] = temp;
}
static void InvShiftRows(uint8_t* state)
{
uint8_t temp;
temp = state[13];
state[13] = state[9];
state[9] = state[5];
state[5] = state[1];
state[1] = temp;
temp = state[2];
state[2] = state[10];
state[10] = temp;
temp = state[6];
state[6] = state[14];
state[14] = temp;
temp = state[3];
state[3] = state[7];
state[7] = state[11];
state[11] = state[15];
state[15] = temp;
}
static uint8_t xtime(uint8_t x)
{
return ((x << 1) ^ (((x >> 7) & 1) * 0x1b));
}
static void MixColumns(uint8_t* state)
{
uint8_t i;
uint8_t Tmp, Tm, t;
for (i = 0; i < 4; ++i) {
t = state[i * 4 + 0];
Tmp = state[i * 4 + 0] ^ state[i * 4 + 1] ^ state[i * 4 + 2] ^ state[i * 4 + 3];
Tm = state[i * 4 + 0] ^ state[i * 4 + 1]; Tm = xtime(Tm); state[i * 4 + 0] ^= Tm ^ Tmp;
Tm = state[i * 4 + 1] ^ state[i * 4 + 2]; Tm = xtime(Tm); state[i * 4 + 1] ^= Tm ^ Tmp;
Tm = state[i * 4 + 2] ^ state[i * 4 + 3]; Tm = xtime(Tm); state[i * 4 + 2] ^= Tm ^ Tmp;
Tm = state[i * 4 + 3] ^ t; Tm = xtime(Tm); state[i * 4 + 3] ^= Tm ^ Tmp;
}
}
static uint8_t Multiply(uint8_t x, uint8_t y)
{
return (((y & 1) * x) ^
((y >> 1 & 1) * xtime(x)) ^
((y >> 2 & 1) * xtime(xtime(x))) ^
((y >> 3 & 1) * xtime(xtime(xtime(x)))) ^
((y >> 4 & 1) * xtime(xtime(xtime(xtime(x))))));
}
static void InvMixColumns(uint8_t* state)
{
int i;
uint8_t a, b, c, d;
for (i = 0; i < 4; ++i) {
a = state[i * 4 + 0];
b = state[i * 4 + 1];
c = state[i * 4 + 2];
d = state[i * 4 + 3];
state[i * 4 + 0] = Multiply(a, 0x0e) ^ Multiply(b, 0x0b) ^ Multiply(c, 0x0d) ^ Multiply(d, 0x09);
state[i * 4 + 1] = Multiply(a, 0x09) ^ Multiply(b, 0x0e) ^ Multiply(c, 0x0b) ^ Multiply(d, 0x0d);
state[i * 4 + 2] = Multiply(a, 0x0d) ^ Multiply(b, 0x09) ^ Multiply(c, 0x0e) ^ Multiply(d, 0x0b);
state[i * 4 + 3] = Multiply(a, 0x0b) ^ Multiply(b, 0x0d) ^ Multiply(c, 0x09) ^ Multiply(d, 0x0e);
}
}
static void Cipher(uint8_t* state, const uint8_t* RoundKey)
{
uint8_t round = 0;
AddRoundKey(0, state, RoundKey);
for (round = 1; ; ++round) {
SubBytes(state);
ShiftRows(state);
if (round == 10) {
break;
}
MixColumns(state);
AddRoundKey(round, state, RoundKey);
}
AddRoundKey(10, state, RoundKey);
}
inline void InvCipher(uint8_t* state, const uint8_t* RoundKey)
{
uint8_t round = 0;
AddRoundKey(10, state, RoundKey);
for (round = 9; ; --round) {
InvShiftRows(state);
InvSubBytes(state);
AddRoundKey(round, state, RoundKey);
if (round == 0) {
break;
}
InvMixColumns(state);
}
}
inline void AES_init_ctx_iv(struct AES_ctx* ctx, const uint8_t* key, const uint8_t* iv)
{
KeyExpansion(ctx->RoundKey, key);
memcpy(ctx->Iv, iv, AES_BLOCKLEN);
}
inline void AES_CBC_decrypt_buffer(struct AES_ctx* ctx, uint8_t* buf, size_t length)
{
size_t i;
uint8_t storeNextIv[AES_BLOCKLEN];
for (i = 0; i < length; i += AES_BLOCKLEN) {
memcpy(storeNextIv, buf + i, AES_BLOCKLEN);
InvCipher(buf + i, ctx->RoundKey);
uint8_t j;
for (j = 0; j < AES_BLOCKLEN; ++j) {
buf[i + j] ^= ctx->Iv[j];
}
memcpy(ctx->Iv, storeNextIv, AES_BLOCKLEN);
}
}
#endif // _TINY_AES_HPP_