Initial STM32 project upload
@@ -506,8 +506,6 @@ uint8_t ESP8266_HttpGet(const char* path, char* response_buffer, uint16_t buffer
|
||||
return ESP8266_FAIL;
|
||||
}
|
||||
|
||||
// ========== 兼容旧版本的函数 ==========
|
||||
|
||||
/**
|
||||
* @brief 查询学生在指定时段是否已签到(服务器端查重)
|
||||
* @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);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 兼容旧版本(已废弃,建议使用HTTP版本)
|
||||
uint8_t ESP8266_ConnectTCPServer(const char* ip, const char* port);
|
||||
uint8_t ESP8266_SendData(char* Data);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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 已做窄屏自适应,若仍异常请检查实际设备宽度 |
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
android.overridePathCheck=true
|
||||
android.useAndroidX=true
|
||||
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 627 B |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 99 KiB |
@@ -1 +0,0 @@
|
||||
IDI_ICON1 ICON "@REWRITE_QT_APP_ICON@"
|
||||
@@ -1,7 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=人脸识别考勤系统
|
||||
Exec=apprewrite_qt
|
||||
Icon=rewrite_qt
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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> ¶ms) 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 ×tamp, 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();
|
||||
});
|
||||
}
|
||||
@@ -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 ×tamp, 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> ¶ms = {}) 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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
#ifndef CRYPTO_UTILS_H
|
||||
#define CRYPTO_UTILS_H
|
||||
|
||||
#include <QByteArray>
|
||||
|
||||
class CryptoUtils
|
||||
{
|
||||
public:
|
||||
static QByteArray decryptAes128CbcBase64(const QByteArray &encryptedBase64);
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 下手动测试和回归检查命令 |
|
||||
|
||||
@@ -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` |
|
||||
@@ -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 管理端设置映射。
|
||||
|
||||
@@ -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/` 权限 |
|
||||
@@ -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 日志 |
|
||||
@@ -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. 增加服务端日志文件轮转。
|
||||
|
||||
@@ -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 写错。
|
||||
|
||||
@@ -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()
|
||||
@@ -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."
|
||||
@@ -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_
|
||||