硬件基本完成

This commit is contained in:
2026-05-01 21:34:10 +08:00
parent df5b05e936
commit 5a31ff2179
14 changed files with 1005 additions and 439 deletions
+1
View File
@@ -31,6 +31,7 @@ QT/.qtcreator/
attendance.db
attendance.log
faces/
backups/
# OS/editor files
.DS_Store
+86 -23
View File
@@ -5,6 +5,8 @@
#include <string.h>
#include <stdlib.h> // ← 添加atoi函数声明
extern uint8_t g_server_connected;
// 配置参数
#define MAX_ATTENDANCE_RECORDS 100 // 最大记录数量
#define MIN_CONFIDENCE_THRESHOLD 0.75 // 最小置信度阈值
@@ -17,7 +19,9 @@ static uint8_t time_slot_count = 0; // 当前有效时间段数量
// 今日已签到学号缓存(从服务器下载)
static char cached_student_ids[MAX_CACHED_IDS][20];
static int8_t cached_time_slots[MAX_CACHED_IDS];
static uint8_t cached_id_count = 0;
static int8_t active_cache_slot = -1;
// 默认时间段配置(在服务器配置下载失败时使用)
static const TimeSlot default_time_slots[] = {
@@ -37,6 +41,28 @@ static AttendanceRecord attendance_records[MAX_ATTENDANCE_RECORDS];
static uint16_t record_count = 0;
static uint32_t total_attendance_count = 0; // 总考勤人次
static int8_t Attendance_FindTimeSlot(uint8_t hour, uint8_t minute)
{
uint16_t current_minutes = hour * 60 + minute;
for (uint8_t i = 0; i < time_slot_count; i++)
{
if (!time_slots[i].enabled) {
continue;
}
uint16_t start_minutes = time_slots[i].start_hour * 60 + time_slots[i].start_minute;
uint16_t end_minutes = time_slots[i].end_hour * 60 + time_slots[i].end_minute;
if (current_minutes >= start_minutes && current_minutes <= end_minutes)
{
return i;
}
}
return -1;
}
/**
* @brief 初始化考勤模块
*/
@@ -55,6 +81,8 @@ void Attendance_Init(void)
// 清空缓存
cached_id_count = 0;
memset(cached_student_ids, 0, sizeof(cached_student_ids));
memset(cached_time_slots, 0xFF, sizeof(cached_time_slots));
active_cache_slot = -1;
DEBUG_INFO("[Attendance] Initialized with time slot mode");
}
@@ -66,6 +94,8 @@ void Attendance_ClearCache(void)
{
cached_id_count = 0;
memset(cached_student_ids, 0, sizeof(cached_student_ids));
memset(cached_time_slots, 0xFF, sizeof(cached_time_slots));
active_cache_slot = Attendance_GetCurrentTimeSlot();
DEBUG_INFO("[Attendance] Cache cleared");
}
@@ -74,23 +104,37 @@ void Attendance_ClearCache(void)
*/
void Attendance_AddToCache(const char* student_id)
{
int8_t current_slot = Attendance_GetCurrentTimeSlot();
if (current_slot < 0) {
DEBUG_INFO("[Attendance] Not in any slot, skip cache add");
return;
}
if (cached_id_count >= MAX_CACHED_IDS) {
DEBUG_ERROR("[Attendance] Cache full, cannot add more IDs");
return;
}
if (active_cache_slot != current_slot) {
Attendance_ClearCache();
active_cache_slot = current_slot;
}
// 检查是否已存在
for (uint8_t i = 0; i < cached_id_count; i++) {
if (strcmp(cached_student_ids[i], student_id) == 0) {
if (cached_time_slots[i] == current_slot && strcmp(cached_student_ids[i], student_id) == 0) {
return; // 已存在,不重复添加
}
}
strncpy(cached_student_ids[cached_id_count], student_id, 19);
cached_student_ids[cached_id_count][19] = '\0';
cached_time_slots[cached_id_count] = current_slot;
cached_id_count++;
DEBUG_PRINT("[Attendance] Added to cache: %s (total: %d)\r\n", student_id, cached_id_count);
DEBUG_PRINT("[Attendance] Added to cache: %s slot=%d (total: %d)\r\n",
student_id, current_slot, cached_id_count);
}
/**
@@ -99,9 +143,20 @@ void Attendance_AddToCache(const char* student_id)
*/
uint8_t Attendance_IsDuplicateInCache(const char* student_id)
{
int8_t current_slot = Attendance_GetCurrentTimeSlot();
if (current_slot < 0) {
return 0;
}
if (active_cache_slot != current_slot) {
Attendance_ClearCache();
active_cache_slot = current_slot;
return 0;
}
for (uint8_t i = 0; i < cached_id_count; i++) {
if (strcmp(cached_student_ids[i], student_id) == 0) {
DEBUG_PRINT("[Attendance] Found in cache: %s\r\n", student_id);
if (cached_time_slots[i] == current_slot && strcmp(cached_student_ids[i], student_id) == 0) {
DEBUG_PRINT("[Attendance] Found in cache: %s slot=%d\r\n", student_id, current_slot);
return 1;
}
}
@@ -117,6 +172,13 @@ uint8_t Attendance_LoadTodayRecordsFromServer(void)
char slot_start[8];
char slot_end[8];
char records_buffer[512];
int8_t current_slot = Attendance_GetCurrentTimeSlot();
if (current_slot < 0) {
Attendance_ClearCache();
DEBUG_INFO("[Attendance] Not in time slot, skip loading all-day cache");
return 0;
}
// 获取当前时段时间
Attendance_GetCurrentSlotTime(slot_start, slot_end);
@@ -125,6 +187,7 @@ uint8_t Attendance_LoadTodayRecordsFromServer(void)
// 清空旧缓存
Attendance_ClearCache();
active_cache_slot = current_slot;
// 从服务器下载
uint8_t count = ESP8266_DownloadTodayRecords(slot_start, slot_end, records_buffer, sizeof(records_buffer));
@@ -333,8 +396,17 @@ uint8_t Attendance_ParseTimeSlotsJSON(const char* json_str)
*/
void Attendance_SetCurrentTime(uint8_t hour, uint8_t minute)
{
int8_t old_slot = Attendance_FindTimeSlot(current_hour, current_minute);
int8_t new_slot = Attendance_FindTimeSlot(hour, minute);
current_hour = hour;
current_minute = minute;
if (old_slot != new_slot) {
Attendance_ClearCache();
active_cache_slot = new_slot;
DEBUG_PRINT("[Attendance] Slot changed: %d -> %d, cache reset\r\n", old_slot, new_slot);
}
}
/**
@@ -343,24 +415,7 @@ void Attendance_SetCurrentTime(uint8_t hour, uint8_t minute)
*/
int8_t Attendance_GetCurrentTimeSlot(void)
{
for (uint8_t i = 0; i < time_slot_count; i++)
{
// 跳过禁用的时间段
if (!time_slots[i].enabled) {
continue;
}
uint16_t current_minutes = current_hour * 60 + current_minute;
uint16_t start_minutes = time_slots[i].start_hour * 60 + time_slots[i].start_minute;
uint16_t end_minutes = time_slots[i].end_hour * 60 + time_slots[i].end_minute;
if (current_minutes >= start_minutes && current_minutes <= end_minutes)
{
return i; // 返回时段编号
}
}
return -1; // 不在任何考勤时段内
return Attendance_FindTimeSlot(current_hour, current_minute);
}
/**
@@ -478,7 +533,15 @@ AttendanceStatus Attendance_ProcessFaceResult(FaceRecognitionResult* result)
return ATTENDANCE_DUPLICATE;
}
// 6. 添加新记录
// 6. 服务器在线时补一次服务端查重,避免多设备或缓存过期造成重复签到
if (g_server_connected && Attendance_IsDuplicateOnServer(result->student_id))
{
DEBUG_PRINT("[Attendance] Server duplicate in slot: %s\r\n", time_slots[current_slot].name);
Attendance_AddToCache(result->student_id);
return ATTENDANCE_DUPLICATE;
}
// 7. 添加新记录
if (record_count < MAX_ATTENDANCE_RECORDS)
{
// 复制学号和姓名
+40 -7
View File
@@ -183,6 +183,8 @@ uint8_t ESP8266_SendEncryptedData(const char* encrypted_data)
char http_request[1024];
char cmd_buffer[32];
uint32_t content_length = strlen(encrypted_data);
char* response;
uint8_t send_ok = 0;
if (ESP8266_CheckConnection() != ESP8266_SUCCESS) {
DEBUG_INFO("[ESP8266] HTTP connection not ready, reconnecting...");
@@ -229,19 +231,50 @@ uint8_t ESP8266_SendEncryptedData(const char* encrypted_data)
// 发送HTTP请求
Serial_SendString(http_request);
Delay_ms(100);
// 不等待SEND OK,直接返回成功
DEBUG_OK("[ESP8266] Data sent");
return ESP8266_SUCCESS;
break;
}
Delay_ms(10);
}
if (Get_Time_ms() - start_time >= 2000 && strstr(Serial_GetRxBuffer(), ">") == NULL) {
DEBUG_ERROR("[ESP8266] No '>' prompt");
return ESP8266_FAIL;
}
start_time = Get_Time_ms();
while(Get_Time_ms() - start_time < 8000) {
response = Serial_GetRxBuffer();
if (strstr(response, "SEND FAIL") != NULL || strstr(response, "ERROR") != NULL) {
DEBUG_ERROR("[ESP8266] POST send failed");
return ESP8266_FAIL;
}
if (strstr(response, "SEND OK") != NULL) {
send_ok = 1;
}
if (send_ok && strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "\"success\":true") != NULL) {
DEBUG_OK("[ESP8266] POST accepted by server");
return ESP8266_SUCCESS;
}
if (strstr(response, "HTTP/1.1 401") != NULL ||
strstr(response, "HTTP/1.1 400") != NULL ||
strstr(response, "HTTP/1.1 413") != NULL ||
strstr(response, "HTTP/1.1 500") != NULL ||
strstr(response, "\"success\":false") != NULL) {
DEBUG_PRINT("[ESP8266] POST rejected: %s\r\n", response);
return ESP8266_FAIL;
}
Delay_ms(10);
}
DEBUG_PRINT("[ESP8266] POST response timeout: %s\r\n", Serial_GetRxBuffer());
return ESP8266_FAIL;
}
uint8_t ESP8266_GetNetworkTime(ESP8266_DateTime* datetime)
{
char response_buffer[256];
@@ -450,9 +483,9 @@ uint8_t ESP8266_HttpGet(const char* path, char* response_buffer, uint16_t buffer
body_len = strlen(body_start);
}
// 5. 如果数据完整或超时,复制到缓冲区
// 5. 如果服务端给了Content-Length,必须收完整;否则才使用超时兜底
if ((found_header && body_len >= content_length) ||
(Get_Time_ms() - start_time > 3000 && body_len > 0)) {
(!found_header && Get_Time_ms() - start_time > 3000 && body_len > 0)) {
if (body_len >= buffer_size) {
body_len = buffer_size - 1;
}
+1 -1
View File
@@ -2861,7 +2861,7 @@ ApplicationWindow {
TextArea {
Layout.fillWidth: true
Layout.fillHeight: true
text: "本系统为纪田野的个人毕业设计作品,仅用于毕业设计展示、学习研究与系统功能验证。\nCopyright © 2026 纪田野。保留所有权利。\n仓库地址:https://git.yzfh.eu.cc/yzfh/qt.git\n查看账号:jtybishe 查看密码:jtybishe"
text: "本系统为纪田野的个人毕业设计作品,仅用于毕业设计展示、学习研究与系统功能验证。\nCopyright © 2026 纪田野。保留所有权利。\n仓库地址:https://git.yzfh.eu.cc/yzfh/qt.git\n演示账号与密码请在实际部署环境中单独配置。"
readOnly: true
selectByMouse: true
selectByKeyboard: true
+1 -1
View File
@@ -14,7 +14,7 @@
namespace {
constexpr int NetworkTimeoutMs = 5000;
constexpr auto DefaultApiToken = "change-me-token";
constexpr auto DefaultApiToken = "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV";
}
ApiClient::ApiClient(QObject *parent)
+53 -6
View File
@@ -22,7 +22,7 @@ const char* WIFI_SSID = "yzfh";
const char* WIFI_PASSWORD = "yzfhroot";
const char* HTTP_SERVER_IP = "27.106.113.205"; // 修改为实际服务器IP
const char* HTTP_SERVER_PORT = "8082"; // HTTP端口(8082)
const char* HTTP_API_TOKEN = "change-me-token"; // 必须与服务端 ATTENDANCE_API_TOKEN 一致
const char* HTTP_API_TOKEN = "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV"; // 必须与服务端 ATTENDANCE_API_TOKEN 一致
/* --- 班级配置 --- */
// 每个设备对应一个班级,修改此处的班级代码
@@ -84,6 +84,44 @@ uint32_t last_process_time = 0;
#define PROCESS_INTERVAL_MS 2000
/* --- 辅助函数 --- */
static uint8_t IsLeapYear(uint16_t year)
{
return ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) ? 1 : 0;
}
static uint8_t DaysInMonth(uint16_t year, uint8_t month)
{
static const uint8_t days_table[12] = {31,28,31,30,31,30,31,31,30,31,30,31};
if (month < 1 || month > 12) return 31;
if (month == 2 && IsLeapYear(year)) return 29;
return days_table[month - 1];
}
static void AdvanceSoftwareClockOneSecond(void)
{
g_current_time.second++;
if (g_current_time.second < 60) return;
g_current_time.second = 0;
g_current_time.minute++;
if (g_current_time.minute < 60) return;
g_current_time.minute = 0;
g_current_time.hour++;
if (g_current_time.hour < 24) return;
g_current_time.hour = 0;
g_current_time.day++;
if (g_current_time.day <= DaysInMonth(g_current_time.year, g_current_time.month)) return;
g_current_time.day = 1;
g_current_time.month++;
if (g_current_time.month <= 12) return;
g_current_time.month = 1;
g_current_time.year++;
}
void StartBeepSuccess(void)
{
BUZZER_OFF();
@@ -280,6 +318,7 @@ uint8_t SyncNetworkTime(void)
if (ESP8266_GetNetworkTime(&g_current_time) == ESP8266_SUCCESS) {
last_time_sync = Get_Time_ms();
last_rtc_read = last_time_sync;
DEBUG_PRINT("Time synced: %04d-%02d-%02d %02d:%02d:%02d\r\n",
g_current_time.year, g_current_time.month, g_current_time.day,
@@ -464,20 +503,22 @@ void UpdateCurrentTime(void)
rtc_time.hour, rtc_time.minute, rtc_time.second);
}
}
// 如果DS1302不可用,直接使用NTP时间(每秒更新考勤时间)
// 如果DS1302不可用,基于系统tick推进软件时钟
else if (!g_use_ds1302 && current_ms - last_rtc_read >= RTC_READ_INTERVAL_MS) {
last_rtc_read = current_ms;
while (current_ms - last_rtc_read >= RTC_READ_INTERVAL_MS) {
last_rtc_read += RTC_READ_INTERVAL_MS;
AdvanceSoftwareClockOneSecond();
}
// 直接使用全局NTP时间
Attendance_SetCurrentTime(g_current_time.hour, g_current_time.minute);
// 每10秒打印一次当前时间(调试用)
debug_counter++;
if (debug_counter >= 10) {
debug_counter = 0;
DEBUG_PRINT("[Time] NTP: %04d-%02d-%02d %02d:%02d\r\n",
DEBUG_PRINT("[Time] NTP-soft: %04d-%02d-%02d %02d:%02d:%02d\r\n",
g_current_time.year, g_current_time.month, g_current_time.day,
g_current_time.hour, g_current_time.minute);
g_current_time.hour, g_current_time.minute, g_current_time.second);
}
}
@@ -528,6 +569,9 @@ uint8_t TryReconnectWiFi(void)
// 下载时间段配置
DownloadTimeSlots();
// 刷新当前时段的今日记录缓存,避免重连后缓存过旧
Attendance_LoadTodayRecordsFromServer();
return 1;
} else {
DEBUG_ERROR("HTTP server connection failed");
@@ -562,6 +606,9 @@ uint8_t TryReconnectServer(void)
Delay_ms(50);
BUZZER_OFF();
DownloadTimeSlots();
Attendance_LoadTodayRecordsFromServer();
return 1;
}
+1 -2
View File
@@ -12,7 +12,7 @@
1. 服务端:`server/server_encrypted.cpp`(HTTP 8082)
2. STM32:`User/main.c`(加密上传 + 时间段同步)
3. K210:`k210/main.py`(识别 + 照片上传 + 同步)
4. Qt:`QT/AttendanceViewer.pro` 构建的客户端
4. Qt:`QT/CMakeLists.txt` 构建的客户端
## 3. 总体架构
1. K210 识别人脸,经 UART 上报 STM32。
@@ -28,7 +28,6 @@
- 服务端:`docs/03_服务端文档.md`
- Qt 管理端:`docs/04_Qt管理端文档.md`
- 项目审查问题清单:`docs/05_项目审查问题清单.md`
- 后续优化路线:`docs/07_后续优化路线.md`
## 5. 各端关键配置位置
- STM32 网络与班级配置:`User/main.c`
+35 -5
View File
@@ -11,10 +11,13 @@
## 2. 端口与数据文件
- HTTP 端口:`8082`
- SQLite 数据库:`attendance.db`
- 启动备份目录:`backups/`
- 日志:运行时生成 `attendance.log`
- 旧数据迁移来源:`attendance_database.csv`、`timeslots_config.json`、`mapping_config.json`、`face_id_mapping.json`、`pending_delete.json`、`sync_request.json`、`sync_version.txt`
- 人脸目录:`faces/`
- 全局同步版本:存储在 `attendance.db` 的 `kv_store` 表中。
- 删除任务:存储在 `pending_delete` 表中,包含 `task_id`、`face_id`、`student_id`、`created_at`、`status`、`ack_at`。
- 设备同步状态:存储在 `device_sync_state` 表中,用于追踪各端最近同步版本和 ACK 版本。
## 3. 签到数据链路
1. STM32 调用 `POST /api/v1/attendance/checkins` 上传 Base64 密文。
@@ -60,6 +63,7 @@
- 服务端是唯一权威状态源。
- 每次签到、清空/删除记录、修改时间段、修改映射、上传照片、删除人脸,服务端都会递增 `kv_store.sync_version`。
- 设备端保存自己的 `last_version`,请求时带给服务端。
- 服务端会记录每个 `client` 的 `last_seen_version`、`last_ack_version` 和 `last_seen_at`,可通过 `/api/v1/system/storage` 查看。
- 如果版本无变化,服务端返回 `changed:false`,设备端无需重复解析配置。
- 如果版本有变化,服务端返回 `changed:true` 和当前客户端需要的状态。
- `/api/v1/sync/version` 只返回当前版本和服务端时间,适合调试时快速确认同步版本。
@@ -76,7 +80,8 @@ K210 删除确认示例:
{
"client": "k210",
"version": 12,
"deleted": ["2", "3"]
"deleted": ["2", "3"],
"task_ids": ["2-1770000000", "3-1770000001"]
}
```
@@ -110,13 +115,38 @@ g++ -o server_encrypted server_encrypted.cpp -lcrypto -lssl -lsqlite3 -lpthread
说明:服务端启动时会自动创建 `attendance.db`。如果检测到旧 CSV/JSON 文件,会在首次启动时导入;之后以后端 API 和 SQLite 为准。
## 6. 版本说明
## 6. 备份与恢复
服务端启动时如果发现已有 `attendance.db`,会先复制到 `backups/attendance_YYYY-MM-DD_HH-MM-SS.db`。备份失败时服务端会停止启动,避免在没有恢复点的情况下继续写入。
恢复步骤:
1. 停止 `server_encrypted`。
2. 将当前 `attendance.db` 改名保留,例如 `attendance.broken.db`。
3. 从 `backups/` 选择最近可用备份,复制为 `attendance.db`。
4. 重新启动服务端,访问 `/api/v1/system/storage`,确认 `ok:true`、`quick_check:"ok"`。
## 7. 公网部署安全
本机或内网演示可以直接监听 `0.0.0.0:8082`。公网部署建议:
- 设置 `ATTENDANCE_BIND_HOST=127.0.0.1`,只让服务端监听本机。
- 使用 Nginx 或 Caddy 终止 HTTPS,并由反向代理转发到 `127.0.0.1:8082`。
- 配置 HTTPS 后立即轮换 `ATTENDANCE_API_TOKEN`、设备端 Token 和相关密钥。
- 反向代理层继续限制来源 IP、请求体大小和访问日志脱敏。
## 8. 自动化回归
服务端启动后可运行:
```powershell
powershell -ExecutionPolicy Bypass -File .\regression_api_tests.ps1 -BaseUrl "http://127.0.0.1:8082" -ApiToken "change-me-token"
```
脚本覆盖 health、storage、time、sync、records、statistics、time-slots、mappings、faces 等低风险接口;真实 AES 签到正文和真实照片上传仍保留手动验证。
## 9. 版本说明
仓库仅保留当前推荐版本:`server_encrypted.cpp`。旧版实验服务端与测试脚本已清理,避免误部署非鉴权接口。
## 7. 关键一致性要求
## 10. 关键一致性要求
- AES Key/IV 必须与 STM32、Qt 完全一致。
- 若 Qt 无法解析记录,先检查是否误连到非加密版本服务端。
## 8. 后续优化
服务端当前已经完成 SQLite 主存储、v1 接口和统一同步模型。后续如需继续增强 JSON 解析、同步版本拆分、删除任务 task_id 化、数据库备份或 Qt 同步状态面板,参考 `docs/07_后续优化路线.md`。
## 11. 后续优化
服务端当前已经完成 SQLite 主存储、v1 接口、自动备份和统一同步模型。后续需要继续跟踪的风险和优化项已统一记录在 `docs/05_项目审查问题清单.md`。
+209 -7
View File
@@ -21,6 +21,20 @@
| R-006 | P1 | 已修复 | STM32 固件 | `sscanf` 未限制宽度,UART 输入可溢出栈缓冲 |
| R-007 | P2 | 已修复 | 服务端 | 多个 `std::stoi` 可被非法参数打崩服务线程 |
| R-008 | P2 | 已修复 | Qt 管理端 | 删除人脸 URL 未编码 |
| R-009 | P1 | 已修复 | STM32 固件 | NTP-only 模式下时间不会前进 |
| R-010 | P1 | 已修复 | STM32 固件 | 发送成功判断过早,可能静默丢签到 |
| R-011 | P1 | 已修复 | STM32 固件 | 今日缓存不区分时段,会误判重复签到 |
| R-012 | P1 | 已修复 | 服务端 | 签到请求手写 JSON 解析会被特殊字符破坏 |
| R-013 | P2 | 已修复 | K210 识别端 | 从 SD 卡加载特征时使用 `eval` |
| R-014 | P2 | 已修复 | 文档 | 总文档指向不存在的 Qt 工程文件 |
| R-015 | P2 | 已修复 | 服务端 / 数据 | 数据库缺少自动备份与恢复流程 |
| R-016 | P2 | 已修复 | 服务端 / K210 | 删除任务缺少 task_id 和状态跟踪 |
| R-017 | P2 | 已修复 | 服务端 / 多端同步 | 设备同步状态不可追踪 |
| R-018 | P2 | 已修复 | 部署 / 安全 | 公网 HTTP 明文传输缺少 HTTPS 或反向代理保护 |
| R-019 | P3 | 已修复 | 测试 | 关键接口缺少自动化回归脚本 |
| R-020 | P1 | 已修复 | K210 / 同步 | 删除 ACK 失败后本地版本仍前进,可能卡住删除任务 |
| R-021 | P2 | 已修复 | STM32 固件 / 多设备 | 多设备或缓存过期时重复签到仍可能漏判 |
| R-022 | P2 | 已修复 | STM32 固件 / ESP8266 | HTTP GET 可能接受半截响应 |
## 2. 详细问题
@@ -48,7 +62,7 @@
2. 将服务地址、WiFi、Token 等改为外部配置文件或烧录参数。
3. 日志输出中屏蔽敏感请求头。
4. 清理已有日志文件中的敏感内容。
- 本次处理:主链路代码和文档中的 WiFi 密码、公网地址改为占位配置;日志中的疑似 API Key 已脱敏;Qt 默认服务地址改为本机地址。仍需在真实环境中轮换已暴露的 WiFi、API Key、演示账号和 AES Key/IV。
- 本次处理:主链路代码和文档中的 WiFi 密码、公网地址改为占位配置;Qt 关于页移除固定演示账号密码;日志中的疑似 API Key 已脱敏;Qt 默认服务地址改为本机地址。仍需在真实环境中轮换已暴露的 WiFi、API Key、演示账号和 AES Key/IV。
### R-003 `face_id` 可造成路径穿越写读删文件
@@ -125,14 +139,197 @@
3. 为删除人脸接口补充异常 ID 的测试用例。
- 本次处理:Qt 删除人脸路径段已使用 `QUrl::toPercentEncoding` 编码。
### R-009 NTP-only 模式下时间不会前进
- 优先级:P1
- 状态:已修复
- 位置:`User/main.c:467-472`
- 影响:DS1302 不可用时,系统每秒只是把 `g_current_time.hour/minute` 再写入考勤模块,没有按系统 tick 推进秒、分钟和日期。同步一次 NTP 后,考勤时间会冻结在同步瞬间,后续时段判断可能长期错误。
- 建议处理:
1. 在 NTP-only 模式下基于 `Get_Time_ms()` 维护软件时钟。
2. 每秒递增 `g_current_time`,正确处理分钟、小时、日期进位。
3. NTP 同步成功时重置软件时钟基准。
4. 增加跨分钟、跨小时、跨天的基本验证。
- 本次处理:`User/main.c` 增加软件时钟推进逻辑,DS1302 不可用时按 `Get_Time_ms()` 每秒递增秒、分钟、小时和日期;NTP 同步成功后重置推进基准。
### R-010 发送成功判断过早,可能静默丢签到
- 优先级:P1
- 状态:已修复
- 位置:`Hardware/esp8266.c:230-236`
- 影响:STM32 侧 POST 只等待 ESP8266 的 `>` 提示并写出 HTTP 字符串,随后立即返回成功;没有等待 `SEND OK`,也没有检查服务端 HTTP 200 或 JSON `success`。网络中断、服务端 401/500、请求体未落库时,调用方仍可能把记录标记为已同步,造成静默丢签到。
- 建议处理:
1. 发送后等待 `SEND OK`,失败时返回 `ESP8266_FAIL`。
2. 继续读取 HTTP 响应,确认状态码为 200 且响应体包含成功标志。
3. 超时、401、500、响应解析失败都应保留记录为待同步或失败待重试。
4. 记录服务端返回的错误信息,便于联调排查 Token、加密和数据库问题。
- 本次处理:`Hardware/esp8266.c` 的加密 POST 发送后会等待 `SEND OK`、HTTP 200 和响应体 `success:true`;超时、发送失败、401/400/413/500 或 `success:false` 都返回失败,使记录保留为待重试。
### R-011 今日缓存不区分时段,会误判重复签到
- 优先级:P1
- 状态:已修复
- 位置:`Hardware/Attendance.c:75-108`
- 影响:当前缓存只保存 `student_id`,不保存考勤时段。任一时段签到成功后都会进入缓存,后续中午、下午、晚上时段再签到可能被 `Attendance_IsDuplicateInCache` 判为重复。开机不在时段时还会按 `00:00-23:59` 拉取全天记录,误判范围更大。
- 建议处理:
1. 缓存项增加时段编号或 `slot_start/slot_end` 字段。
2. 本地新增签到写缓存时同时写入当前时段。
3. 服务器下载今日记录时只填充当前有效时段的缓存;不在时段内不要拉取全天缓存用于查重。
4. 切换时段时清理或重建缓存,避免跨时段污染。
- 本次处理:`Hardware/Attendance.c` 的缓存项增加时段绑定;新增签到和服务器下载缓存都写入当前时段;不在时段内不再拉取全天缓存;时段切换时自动清空缓存。
### R-012 签到请求手写 JSON 解析会被特殊字符破坏
- 优先级:P1
- 状态:已修复
- 位置:`server/server_encrypted.cpp:707-742`
- 影响:`parseJSON` 直接按逗号拆字段、按第一个冒号拆键值,无法正确处理字符串里的逗号、冒号、反斜杠和转义引号。签到姓名、状态或后续字段一旦包含这些字符,会解析错列甚至写入错误数据。
- 建议处理:
1. 引入 `nlohmann/json` 等成熟 JSON 库,优先替换签到请求体解析。
2. 对必填字段做类型、长度和取值校验。
3. JSON 解析失败时返回 400,避免写入半解析数据。
4. 增加包含逗号、冒号、引号和反斜杠的解析测试样例。
- 本次处理:`server/server_encrypted.cpp` 的签到 JSON 解析改为逐字符解析字符串字面量,支持逗号、冒号、反斜杠和转义引号;签到字段增加 ID、长度、置信度范围和状态取值校验,非法请求返回 400。
### R-013 K210 从 SD 卡加载特征时使用 `eval`
- 优先级:P2
- 状态:已修复
- 位置:`k210/main.py:1315-1321`
- 影响:`recordftr.txt` 内容来自可写 SD 卡,`eval(parts[1])` 会执行任意 Python 表达式。文件损坏或被替换时,可能执行意外代码、卡死或耗尽内存。
- 建议处理:
1. 如果 MaixPy 支持,改用 `ast.literal_eval`。
2. 如果不支持,改为明确的浮点数组解析器,只接受数字、逗号、方括号和空白。
3. 校验特征向量长度和数值范围,异常记录跳过并打印错误。
4. 保存特征时考虑使用更容易校验的 JSON 或二进制格式。
- 本次处理:`k210/main.py` 已用 `parse_feature_vector` 替换 `eval`,只接受浮点数组允许字符,并校验向量长度,异常记录会被跳过并打印解析错误。
### R-014 总文档指向不存在的 Qt 工程文件
- 优先级:P2
- 状态:已修复
- 位置:`docs/00_项目总文档.md:15`
- 影响:总文档写的是用 `QT/AttendanceViewer.pro` 构建,但仓库里没有该文件;实际 Qt 项目使用 `QT/CMakeLists.txt`。部署、答辩复现或新成员接手时会被误导。
- 建议处理:
1. 将总文档中的 Qt 构建入口改为 `QT/CMakeLists.txt`。
2. 与 `docs/04_Qt管理端文档.md` 保持一致,说明当前推荐 CMake 构建。
3. 全仓库搜索旧 `.pro`、`qmake` 和旧接口路径,清理过时说明。
- 本次处理:总文档中的 Qt 构建入口已改为 `QT/CMakeLists.txt`;删除 `docs/07_后续优化路线.md` 前同步清理了相关引用。
### R-015 数据库缺少自动备份与恢复流程
- 优先级:P2
- 状态:已修复
- 位置:`server/server_encrypted.cpp`、`docs/03_服务端文档.md`
- 影响:当前服务端使用 `attendance.db` 作为主存储,但没有自动备份、恢复步骤和误删后的应急流程。调试、答辩演示或清空记录接口误操作时,可能导致考勤记录、映射和同步状态难以恢复。
- 建议处理:
1. 增加服务端启动前或每日首次启动的 SQLite 备份逻辑。
2. 在服务端文档中补充停止服务、备份、恢复和校验步骤。
3. Qt 端后续可增加导出数据库备份入口。
4. 备份文件应带日期时间后缀,避免覆盖最近可用备份。
- 本次处理:服务端启动时会把已有 `attendance.db` 复制到 `backups/attendance_YYYY-MM-DD_HH-MM-SS.db`,备份失败则停止启动;`docs/03_服务端文档.md` 已补恢复和校验流程。
### R-016 删除任务缺少 task_id 和状态跟踪
- 优先级:P2
- 状态:已修复
- 位置:`server/server_encrypted.cpp`、`k210/main.py`
- 影响:当前待删除任务主要按 `face_id` 列表流转,K210 删除成功后按 ID ACK。重复删除、删除后重新添加同 ID、多设备并发同步时,可能难以区分旧任务和新任务,也不方便追踪任务创建、下发、确认和失败状态。
- 建议处理:
1. 将删除任务升级为带 `task_id`、`face_id`、`student_id`、`created_at`、`status`、`ack_at` 的记录。
2. K210 ACK 时回传 `task_id`,服务端按任务确认。
3. 保留兼容旧 `face_id` ACK 的过渡逻辑,避免一次性破坏现有联调链路。
4. 在 Qt 或服务端状态接口中暴露待删除任务数量和最近任务状态。
- 本次处理:服务端 `pending_delete` 升级为任务表,包含 `task_id`、`face_id`、`student_id`、`created_at`、`status`、`ack_at`;同步响应同时返回兼容旧数组和 `delete_tasks`;K210 ACK 会回传 `task_ids`,服务端仍兼容旧 `face_id` 确认。
### R-017 设备同步状态不可追踪
- 优先级:P2
- 状态:已修复
- 位置:`server/server_encrypted.cpp`
- 影响:各端只在本地保存自己的 `last_version`,服务端不知道 STM32、K210、Qt 最后看到或确认到了哪个同步版本。设备长期离线、同步卡住、版本落后时,只能靠日志和现场排查,联调成本较高。
- 建议处理:
1. 增加 `device_sync_state` 表,记录 `client`、`last_seen_version`、`last_ack_version`、`last_seen_at`。
2. 在 `/api/v1/sync/state` 和 `/api/v1/sync/ack` 中更新设备状态。
3. 在 `/api/v1/system/storage` 中返回设备同步摘要。
4. 后续 Qt 可展示同步状态,但不要求作为首阶段必须项。
- 本次处理:服务端新增 `device_sync_state` 表;`/api/v1/sync/state` 记录设备最近看到的服务端版本,`/api/v1/sync/ack` 记录 ACK 版本;`/api/v1/system/storage` 返回 `device_sync_state` 摘要。
### R-018 公网 HTTP 明文传输缺少 HTTPS 或反向代理保护
- 优先级:P2
- 状态:已修复
- 位置:`server/server_encrypted.cpp`、部署配置
- 影响:服务端当前直接提供 HTTP 8082。若部署在公网,`X-Api-Token`、配置接口、照片上传下载和管理操作都会以明文传输,Token 可能被窃听或重放。AES 签到正文只覆盖 STM32 上传内容,不能保护管理 API 和设备同步 API。
- 建议处理:
1. 公网部署时使用 Nginx/Caddy 等反向代理终止 HTTPS。
2. 服务端尽量只监听本机或内网地址,由反向代理对外暴露。
3. 配置 HTTPS 后轮换 API Token 和相关密钥。
4. 文档中明确本机演示和公网部署的不同安全要求。
- 本次处理:服务端增加 `ATTENDANCE_BIND_HOST` 监听地址配置;文档新增公网部署安全要求,建议服务端绑定 `127.0.0.1` 并通过 Nginx/Caddy 终止 HTTPS 后再对外暴露。
### R-019 关键接口缺少自动化回归脚本
- 优先级:P3
- 状态:已修复
- 位置:`docs/06_后端接口Windows测试命令.md`、待新增测试脚本
- 影响:当前主要依赖手动 curl 文档验证接口。服务端、Qt、STM32、K210 协议经常一起变动,缺少脚本化回归会让鉴权、同步状态、时间段配置、照片接口和记录接口的破坏更晚被发现。
- 建议处理:
1. 保留现有手动 curl 文档,同时增加 PowerShell 自动化测试脚本。
2. 覆盖 health、storage、records、statistics、time-slots、sync/state、faces/photos 等低风险接口。
3. 对需要真实硬件或真实 AES 正文的接口保留手动验证说明。
4. 在每次服务端改动后运行脚本,记录测试结果。
- 本次处理:新增 `server/regression_api_tests.ps1`,覆盖 health、storage、time、sync、records、statistics、time-slots、duplicates、today、mappings、faces 等低风险接口;手动命令文档已加入脚本使用方式。
### R-020 删除 ACK 失败后本地版本仍前进,可能卡住删除任务
- 优先级:P1
- 状态:已修复
- 位置:`k210/main.py:1227-1239`
- 影响:K210 删除本地人脸后会向服务端发送同步 ACK,但当前没有判断 ACK 响应是否成功,就保存了 `server_version`。如果 ACK 请求丢包、超时或服务端未确认,服务端仍保留待删除任务;K210 下一次用已推进的 `last_version` 请求同步时可能得到 `changed:false`,导致该删除任务长期留在服务端,后续同步状态不一致。
- 建议处理:
1. 检查 `ack_resp` 是否包含成功标志,例如 `"success":true`。
2. 只有删除成功且 ACK 成功时,才保存新的 `sync_last_version`。
3. ACK 失败时保留旧版本,并打印日志,等待下一轮同步重试。
4. 可在服务端状态接口或 Qt 端观察 pending delete 数量,验证任务不会卡住。
- 备注:这是联调稳定性问题,不属于必须复杂化同步协议的工业级需求;毕业设计中用简单 ACK 成功判断即可。
- 本次处理:`k210/main.py` 在删除任务 ACK 后检查响应中是否包含 `"success":true`;ACK 失败时将本轮同步标记为失败,不保存新的本地同步版本,等待下一轮继续重试。
### R-021 多设备或缓存过期时重复签到仍可能漏判
- 优先级:P2
- 状态:已修复
- 位置:`Hardware/Attendance.c:520-529`
- 影响:当前签到处理主要检查 STM32 本地记录和开机/重连时下载的今日缓存。`Attendance_IsDuplicateOnServer` 已存在,但没有接入主签到路径。如果现场只有一台设备演示,影响较小;如果多台设备同时使用,或设备启动后缓存长时间未刷新,服务端已有的同一时段签到记录可能不会被当前设备发现,导致重复签到。
- 建议处理:
1. 在 `g_server_connected` 为真时,签到前调用一次 `Attendance_IsDuplicateOnServer`。
2. 查询失败时仍允许本地签到并进入待同步队列,避免弱网影响正常演示。
3. 时段切换、服务器重连成功后刷新今日记录缓存。
4. 如果只保留单设备演示,也应在论文或文档中说明本地缓存查重的适用范围。
- 备注:毕业设计阶段不需要做复杂分布式一致性,优先保证演示环境下行为可解释。
- 本次处理:`Hardware/Attendance.c` 在本地记录和缓存查重之后,服务器在线时调用 `Attendance_IsDuplicateOnServer` 做一次服务端查重;查询失败仍按未重复处理,避免弱网阻断演示。`User/main.c` 在 WiFi/HTTP 服务端重连成功后刷新时间段配置和今日记录缓存。
### R-022 HTTP GET 可能接受半截响应
- 优先级:P2
- 状态:已修复
- 位置:`Hardware/esp8266.c:486-498`
- 影响:`ESP8266_HttpGet` 在发现响应体后,如果超过约 3 秒且 `body_len > 0`,即使存在 `Content-Length` 且尚未收完整,也会复制响应并返回成功。弱网、ESP8266 分包或服务端响应稍慢时,STM32 可能拿到半截 JSON,进而导致时间段配置、同步状态或今日记录解析失败。
- 建议处理:
1. 如果响应头包含 `Content-Length`,必须等到 `body_len >= content_length` 才返回成功。
2. 只有没有 `Content-Length` 时,才使用“超时且已有响应体”的兜底逻辑。
3. 超时未收完整时返回 `ESP8266_FAIL`,让上层保留当前配置并下次重试。
4. 用较大的同步状态响应或人工延迟响应测试 ESP8266 分包场景。
- 备注:该项主要提升弱网稳定性,不影响当前本地小数据量演示的主体功能。
- 本次处理:`Hardware/esp8266.c` 的 GET 响应处理改为:只在没有 `Content-Length` 时使用“超时且已有响应体”的兜底逻辑;如果服务端返回了 `Content-Length`,必须等到响应体长度达到声明长度才返回成功。
## 3. 建议修复顺序
1. 处理 R-002:轮换凭据,清理敏感日志,避免继续泄露。
2. 处理 R-001:服务端写接口增加鉴权,限制跨域来源。
3. 处理 R-003、R-004:修复文件路径安全和请求体读取,防止文件被覆盖或照片损坏。
4. 处理 R-006:修复固件串口解析溢出风险。
5. 处理 R-005:统一 CSV/JSON 转义,避免数据格式破坏。
6. 处理 R-007、R-008:增强参数健壮性和 Qt URL 构造。
1. 已完成 R-020:K210 删除 ACK 失败时不再推进本地同步版本,避免服务端待删除任务卡住。
2. 已完成 R-022:ESP8266 GET 在有 `Content-Length` 时必须收完整响应,减少弱网下配置同步解析失败。
3. 已完成 R-021:STM32 在线签到前补服务端查重,重连后刷新今日记录缓存。
4. R-002 仍需真实环境人工轮换已暴露凭据;代码层面已尽量外部配置化和脱敏。
5. 已修复项保持回归关注:R-001、R-003、R-004、R-005、R-006、R-007、R-008、R-009 至 R-022。
## 4. 验证记录
@@ -140,3 +337,8 @@
- `server/server_encrypted.cpp` 因当前本机缺少 OpenSSL 头文件,未完成语法编译验证。
- 现有 Keil 构建日志显示固件旧构建为 `0 Error(s), 0 Warning(s)`,但尚未在本次审查中重新编译固件。
- 2026-05-01:已完成主链路安全修复;旧版备用服务端与测试脚本已清理,避免误部署非鉴权接口。
- 2026-05-01:追加记录 R-009 至 R-014,作为后续优化和修复跟踪项。
- 2026-05-01:分析并合并 `docs/07_后续优化路线.md` 中仍需跟踪的项目,追加 R-015 至 R-019;同步删除该路线文档。
- 2026-05-01:完成 R-009 至 R-013、R-015 至 R-019 的代码和文档修复;新增服务端回归脚本 `server/regression_api_tests.ps1`。仍需上板验证 STM32/K210 运行时行为,并在真实部署中轮换 R-002 涉及的凭据。
- 2026-05-01:追加记录 R-020 至 R-022,作为后续优化跟踪项。
- 2026-05-01:完成 R-020 至 R-022 的代码修复;`k210/main.py` 已通过普通 Python 语法检查,STM32 相关改动仍需 Keil 编译和上板验证。
+47 -36
View File
@@ -17,15 +17,26 @@ http://127.0.0.1:8082
如果测试公网服务器,替换成:
```text
http://27.106.113.205:8082
https://your-domain.example
```
下面示例默认使用本机地址。
## 0. 自动化回归脚本
服务端启动后,优先运行脚本化检查:
```powershell
cd server
powershell -ExecutionPolicy Bypass -File .\regression_api_tests.ps1 -BaseUrl "http://127.0.0.1:8082" -ApiToken "change-me-token"
```
该脚本覆盖健康检查、存储状态、同步状态、记录、统计、时间段、映射和照片列表等低风险接口。需要真实硬件、真实 AES 正文或真实 JPEG 文件的接口仍按下方手动命令验证。
## 1. 测试端口和服务健康
```powershell
curl.exe -i --max-time 8 "http://27.106.113.205:8082/api/v1/system/health"
curl.exe -i --max-time 8 "http://127.0.0.1:8082/api/v1/system/health"
```
预期能看到 `HTTP/1.1 200 OK` 或类似成功响应。
@@ -33,13 +44,13 @@ curl.exe -i --max-time 8 "http://27.106.113.205:8082/api/v1/system/health"
## 2. 获取服务器时间
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/system/time"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/system/time"
```
## 2.0 查看 SQLite 存储状态
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/system/storage"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/system/storage"
```
重点看:
@@ -57,13 +68,13 @@ curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: applicati
## 2.0.1 查看当前同步版本
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/sync/version"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/sync/version"
```
## 2.1 统一同步状态:K210
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/sync/state?client=k210&last_version=0"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/sync/state?client=k210&last_version=0"
```
重点看:
@@ -78,7 +89,7 @@ curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: applicati
## 2.2 统一同步状态:STM32
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/sync/state?client=stm32&last_version=0"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/sync/state?client=stm32&last_version=0"
```
重点看 `time_slots` 是否正确返回。
@@ -86,7 +97,7 @@ curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: applicati
## 2.3 统一同步状态:Qt
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/sync/state?client=qt&last_version=0"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/sync/state?client=qt&last_version=0"
```
## 2.4 K210 确认统一同步删除
@@ -94,13 +105,13 @@ curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: applicati
把 `2` 换成真实已删除成功的人脸 ID。
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" -H "Content-Type: application/json" -X POST --data-binary "{\"client\":\"k210\",\"version\":1,\"deleted\":[\"2\"]}" "http://27.106.113.205:8082/api/v1/sync/ack"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" -H "Content-Type: application/json" -X POST --data-binary "{\"client\":\"k210\",\"version\":1,\"deleted\":[\"2\"]}" "http://127.0.0.1:8082/api/v1/sync/ack"
```
## 3. 获取全部考勤记录
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/attendance/records?limit=1000&offset=0"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/attendance/records?limit=1000&offset=0"
```
后端当前以 `attendance.db` 为准。旧 `attendance_database.csv` 只会在首次启动 SQLite 时作为迁移来源,之后新增记录不会再写回 CSV。
@@ -120,13 +131,13 @@ sqlite3.exe attendance.db ".backup attendance_backup.db"
## 4. 获取统计信息
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/attendance/statistics"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/attendance/statistics"
```
## 5. 获取签到时间段
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/config/time-slots"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/config/time-slots"
```
## 6. 保存签到时间段
@@ -134,49 +145,49 @@ curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: applicati
用于测试 Qt 端 `/api/v1/config/time-slots` 保存失败的问题。执行后观察后端日志,正常应该显示 `POST body size` 大于 0。
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" -H "Content-Type: application/json" -X POST --data-binary "{\"time_slots\":[{\"name\":\"上午签到\",\"start_time\":\"08:00\",\"end_time\":\"09:30\",\"enabled\":true},{\"name\":\"中午签退\",\"start_time\":\"12:00\",\"end_time\":\"13:00\",\"enabled\":true},{\"name\":\"下午签到\",\"start_time\":\"14:00\",\"end_time\":\"15:00\",\"enabled\":true},{\"name\":\"晚上签退\",\"start_time\":\"17:00\",\"end_time\":\"18:30\",\"enabled\":true}]}" "http://27.106.113.205:8082/api/v1/config/time-slots"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" -H "Content-Type: application/json" -X POST --data-binary "{\"time_slots\":[{\"name\":\"上午签到\",\"start_time\":\"08:00\",\"end_time\":\"09:30\",\"enabled\":true},{\"name\":\"中午签退\",\"start_time\":\"12:00\",\"end_time\":\"13:00\",\"enabled\":true},{\"name\":\"下午签到\",\"start_time\":\"14:00\",\"end_time\":\"15:00\",\"enabled\":true},{\"name\":\"晚上签退\",\"start_time\":\"17:00\",\"end_time\":\"18:30\",\"enabled\":true}]}" "http://127.0.0.1:8082/api/v1/config/time-slots"
```
## 7. 检查是否重复签到
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/attendance/duplicates?student_id=1&slot_start=00:00&slot_end=23:59"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/attendance/duplicates?student_id=1&slot_start=00:00&slot_end=23:59"
```
## 8. 获取今天指定时间段记录
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/attendance/today?slot_start=00:00&slot_end=23:59"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/attendance/today?slot_start=00:00&slot_end=23:59"
```
也可以测试指定签到时间段:
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/attendance/today?slot_start=14:00&slot_end=15:00"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/attendance/today?slot_start=14:00&slot_end=15:00"
```
## 9. 获取映射配置
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/config/mappings"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/config/mappings"
```
## 10. 保存映射配置
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" -H "Content-Type: application/json" -X POST --data-binary "{\"student_name_map\":{\"1\":\"ID1\"},\"class_name_map\":{\"1\":\"一班\"}}" "http://27.106.113.205:8082/api/v1/config/mappings"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" -H "Content-Type: application/json" -X POST --data-binary "{\"student_name_map\":{\"1\":\"ID1\"},\"class_name_map\":{\"1\":\"一班\"}}" "http://127.0.0.1:8082/api/v1/config/mappings"
```
## 11. 获取人脸照片列表
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/faces/photos"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/faces/photos"
```
## 12. 下载指定人脸照片
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" "http://27.106.113.205:8082/api/v1/faces/photos/1" --output "%TEMP%\backend_face_1.jpg"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" "http://127.0.0.1:8082/api/v1/faces/photos/1" --output "%TEMP%\backend_face_1.jpg"
```
如果 `face_id=1` 存在,照片会保存到:
@@ -190,55 +201,55 @@ curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" "http://27.106.113.20
把 `C:\path\to\face.jpg` 换成真实 jpg 文件路径。
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Content-Type: image/jpeg" --data-binary "@C:\path\to\face.jpg" "http://27.106.113.205:8082/api/v1/faces/photos/1"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Content-Type: image/jpeg" --data-binary "@C:\path\to\face.jpg" "http://127.0.0.1:8082/api/v1/faces/photos/1"
```
## 14. 获取人脸 ID 映射
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/faces/mappings"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/faces/mappings"
```
## 15. 保存人脸 ID 映射
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" -H "Content-Type: application/json" -X POST --data-binary "{\"mappings\":{\"1\":\"1\"}}" "http://27.106.113.205:8082/api/v1/faces/mappings"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" -H "Content-Type: application/json" -X POST --data-binary "{\"mappings\":{\"1\":\"1\"}}" "http://127.0.0.1:8082/api/v1/faces/mappings"
```
## 16. 获取全部学生 ID
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/faces/student-ids"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/faces/student-ids"
```
## 17. 获取待删除列表
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/faces/pending-delete"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/faces/pending-delete"
```
## 18. 确认删除同步完成
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" -H "Content-Type: application/json" -X POST --data-binary "{\"face_id\":\"1\"}" "http://27.106.113.205:8082/api/v1/faces/delete-ack"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" -H "Content-Type: application/json" -X POST --data-binary "{\"face_id\":\"1\"}" "http://127.0.0.1:8082/api/v1/faces/delete-ack"
```
## 19. K210 查询是否需要同步
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://27.106.113.205:8082/api/v1/sync/request"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" "http://127.0.0.1:8082/api/v1/sync/request"
```
## 20. 触发 K210 同步
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" -H "Content-Type: application/json" -X POST --data-binary "{\"sync\":true}" "http://27.106.113.205:8082/api/v1/sync/trigger"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" -H "Content-Type: application/json" -X POST --data-binary "{\"sync\":true}" "http://127.0.0.1:8082/api/v1/sync/trigger"
```
## 21. K210 同步确认
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" -H "Content-Type: application/json" -X POST --data-binary "{\"ack\":true}" "http://27.106.113.205:8082/api/v1/sync/request/ack"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" -H "Content-Type: application/json" -X POST --data-binary "{\"ack\":true}" "http://127.0.0.1:8082/api/v1/sync/request/ack"
```
## 22. 加密签到接口
@@ -246,7 +257,7 @@ curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: applicati
`/api/v1/attendance/checkins` 需要 STM32 生成的 AES-128-CBC Base64 加密正文。把 `BODY` 替换成真实加密数据。
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "X-Encrypted: AES128-CBC" -H "Content-Type: application/octet-stream" --data-binary "BODY" "http://27.106.113.205:8082/api/v1/attendance/checkins"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "X-Encrypted: AES128-CBC" -H "Content-Type: application/octet-stream" --data-binary "BODY" "http://127.0.0.1:8082/api/v1/attendance/checkins"
```
## 23. 删除单条考勤记录
@@ -254,7 +265,7 @@ curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "X-Encrypted: AES1
危险操作,会删除真实数据。执行前把时间和学生 ID 改成要删除的记录。
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -X DELETE "http://27.106.113.205:8082/api/v1/attendance/records?timestamp=2026-05-01%2014%3A30%3A38&student_id=1"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -X DELETE "http://127.0.0.1:8082/api/v1/attendance/records?timestamp=2026-05-01%2014%3A30%3A38&student_id=1"
```
## 24. 删除指定人脸
@@ -262,7 +273,7 @@ curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -X DELETE "http://27.
危险操作,只删除人脸信息,不删除考勤记录。
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -X DELETE "http://27.106.113.205:8082/api/v1/faces/1"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -X DELETE "http://127.0.0.1:8082/api/v1/faces/1"
```
## 25. 删除指定人脸并删除相关考勤记录
@@ -270,7 +281,7 @@ curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -X DELETE "http://27.
危险操作,会删除人脸和相关记录。
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -X DELETE "http://27.106.113.205:8082/api/v1/faces/1?delete_records=true"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -X DELETE "http://127.0.0.1:8082/api/v1/faces/1?delete_records=true"
```
## 26. 清空全部考勤记录
@@ -278,7 +289,7 @@ curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -X DELETE "http://27.
危险操作,会清空考勤数据库。
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Content-Type: application/json" -X POST --data-binary "{}" "http://27.106.113.205:8082/api/v1/attendance/records/clear"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Content-Type: application/json" -X POST --data-binary "{}" "http://127.0.0.1:8082/api/v1/attendance/records/clear"
```
## 27. Windows 上检查端口占用
@@ -298,7 +309,7 @@ Test-NetConnection 127.0.0.1 -Port 8082
公网服务器测试:
```powershell
Test-NetConnection 27.106.113.205 -Port 8082
Test-NetConnection 127.0.0.1 -Port 8082
```
`TcpTestSucceeded : True` 表示 TCP 端口可连通。
@@ -308,7 +319,7 @@ Test-NetConnection 27.106.113.205 -Port 8082
先手动执行这条:
```powershell
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" -H "Content-Type: application/json" -X POST --data-binary "{\"time_slots\":[{\"name\":\"下午签到\",\"start_time\":\"14:00\",\"end_time\":\"15:00\",\"enabled\":true}]}" "http://27.106.113.205:8082/api/v1/config/time-slots"
curl.exe -i --max-time 8 -H "X-Api-Token: change-me-token" -H "Accept: application/json" -H "Content-Type: application/json" -X POST --data-binary "{\"time_slots\":[{\"name\":\"下午签到\",\"start_time\":\"14:00\",\"end_time\":\"15:00\",\"enabled\":true}]}" "http://127.0.0.1:8082/api/v1/config/time-slots"
```
如果后端日志显示:
-272
View File
@@ -1,272 +0,0 @@
# 后续优化路线
本文档记录当前项目在“可继续优化但非当前阻塞”的方向,方便后续开发、答辩扩展或二次重构时逐项推进。
## 1. 当前架构状态
当前项目已经完成以下核心升级:
- API 已统一到 `/api/v1/...`,旧 `/api/...` 仅作为服务端内部兼容层。
- 服务端使用 `attendance.db` 作为主存储,旧 CSV/JSON 文件仅作为首次迁移来源。
- 服务端维护全局同步版本,Qt、STM32、K210 按 `last_version` 增量同步。
- K210 删除人脸使用待删除任务和 ACK 确认。
- K210 新增人脸照片先保存到本地 `/sd/face_photos/`,再补传服务端。
- Qt 端照片由 `ApiClient` 携带鉴权头下载并缓存为本地 `file://` 地址显示。
- 服务端提供 `/api/v1/system/storage` 和 `/api/v1/sync/version` 用于调试存储和同步状态。
结论:当前架构已经适合联调、演示和毕业设计答辩。后续优化以工程质量、稳定性和可维护性为主,不建议在演示前集中大改。
## 2. 优化优先级
| 优先级 | 方向 | 当前是否建议立即做 | 主要收益 |
| --- | --- | --- | --- |
| P1 | 服务端 JSON 解析换成 `nlohmann/json` | 暂缓 | 降低手写解析风险,提升维护性 |
| P1 | 数据库自动备份与恢复文档 | 可做 | 防止误删或调试破坏数据 |
| P2 | 同步版本按模块拆分 | 暂缓 | 降低无关数据重复同步 |
| P2 | 删除任务 task_id 化 | 暂缓 | 删除同步更严谨,避免 ID 歧义 |
| P2 | 设备同步 ACK 状态表 | 暂缓 | 可追踪每台设备同步进度 |
| P2 | 外部配置文件/环境变量统一管理 | 可做 | 降低硬编码凭据风险 |
| P3 | HTTPS/反向代理部署 | 视部署环境 | 公网安全性更好 |
| P3 | 自动化测试脚本 | 可做 | 回归验证更方便 |
| P3 | Qt 同步状态面板 | 可做 | 调试体验更好 |
## 3. 服务端 JSON 库化
当前状态:
- 服务端部分请求解析和响应构造仍使用字符串查找和手写 JSON 拼接。
- 已有 `jsonEscape()`,可以避免大多数响应字符串破坏。
- SQLite 已解决 CSV/多 JSON 文件存储不一致问题。
建议方案:
- 引入 `nlohmann/json` 单头文件版本。
- 文件建议放到:
```text
server/third_party/json.hpp
```
- 服务端引用方式:
```cpp
#include "third_party/json.hpp"
using json = nlohmann::json;
```
建议分阶段替换:
1. 替换签到请求体解析:`/api/v1/attendance/checkins`
2. 替换时间段保存校验:`/api/v1/config/time-slots`
3. 替换人脸映射保存:`/api/v1/faces/mappings`
4. 替换同步 ACK 解析:`/api/v1/sync/ack`
5. 最后替换大型响应构造:`/api/v1/sync/state`
注意事项:
- `nlohmann/json` 不需要额外链接库,只需要头文件。
- 引入后编译命令通常不变。
- 不建议一次性替换所有 JSON 逻辑,容易引入新问题。
## 4. 同步版本拆分
当前状态:
- 服务端维护一个全局同步版本。
- 任何数据变化都会让 Qt、STM32、K210 看到版本变化。
- 对当前规模足够清晰,也方便调试。
后续可升级为:
```text
records_version
config_version
faces_version
delete_tasks_version
```
收益:
- STM32 只关心 `config_version`。
- K210 只关心 `faces_version` 和 `delete_tasks_version`。
- Qt 可按模块决定是否刷新记录、配置、照片。
代价:
- 服务端同步响应结构要调整。
- Qt、STM32、K210 都要保存多个版本号。
- 调试复杂度会上升。
建议:当前不急,除非后续记录量明显变大或同步频率变高。
## 5. 删除任务 task_id 化
当前状态:
- 待删除任务以 `face_id` 列表形式保存。
- K210 删除成功后通过 ACK 返回已删除 ID。
后续可升级为:
```json
{
"task_id": "delete_20260501_0001",
"face_id": "1",
"student_id": "20210001",
"created_at": "2026-05-01 14:30:00",
"status": "pending"
}
```
收益:
- 删除任务可追踪。
- 重复删除、重新添加同 ID 时更不容易混淆。
- 可以在 Qt 端显示待删除任务状态。
建议表结构:
```sql
CREATE TABLE delete_tasks (
task_id TEXT PRIMARY KEY,
face_id TEXT NOT NULL,
student_id TEXT,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
ack_at TEXT
);
```
建议:如果后续要增强多设备同步审计,再做。
## 6. 设备同步状态表
当前状态:
- 各设备本地保存自己的 `last_version`。
- 服务端不记录每台设备最后同步到哪个版本。
后续可增加:
```sql
CREATE TABLE device_sync_state (
client TEXT PRIMARY KEY,
last_seen_version INTEGER,
last_ack_version INTEGER,
last_seen_at TEXT
);
```
收益:
- 可以知道 K210、STM32、Qt 是否长期未同步。
- 服务端可以在 `/api/v1/system/storage` 中显示设备同步状态。
- 方便做 Qt 同步状态面板。
建议:当前不是必须,适合作为后续展示增强。
## 7. 数据库备份与恢复
当前手动备份命令:
```powershell
sqlite3.exe attendance.db ".backup attendance_backup.db"
```
后续可增加:
- 服务端启动时自动备份前一天数据库。
- Qt 端提供“导出数据库备份”按钮。
- 文档中增加恢复步骤。
恢复示例:
```powershell
Copy-Item attendance_backup.db attendance.db
```
注意:恢复前应先停止服务端,避免写入冲突。
## 8. 配置外部化
当前仍存在部分硬编码配置:
- K210 WiFi 名称和密码。
- K210 服务端 IP 和端口。
- STM32 服务端 IP、WiFi、班级 ID。
- 默认 `X-Api-Token`。
- AES Key/IV。
建议方向:
- 服务端继续使用环境变量管理:
```text
ATTENDANCE_API_TOKEN
ATTENDANCE_CORS_ORIGIN
```
- K210 可使用 `/sd/config.txt` 保存 WiFi、服务端和 token。
- STM32 可集中放在 `User/main.c` 顶部,并在文档中标记部署必改项。
- Qt 可在设置页保存服务端地址,token 可后续加入设置项。
建议:真实公网部署前必须轮换默认 token、WiFi 密码和 AES Key/IV。
## 9. Qt 同步状态面板
后续可在 Qt 管理端增加一个调试/状态区域,展示:
- 服务器连接状态。
- 当前服务端同步版本。
- 最近同步时间。
- `records` 数量。
- `face_mappings` 数量。
- `face_photos` 数量。
- `pending_deletes` 数量。
数据来源:
```text
GET /api/v1/system/storage
GET /api/v1/sync/version
```
收益:
- 联调时不用一直看服务端日志。
- 演示时可以直观看到多端同步状态。
## 10. 自动化测试
当前已有手动 curl 文档:
```text
docs/06_后端接口Windows测试命令.md
```
后续可增加脚本化测试,但不要替代手动文档:
- Windows PowerShell 测试脚本。
- Linux shell 测试脚本。
- 服务端启动后自动测试 health、storage、records、time-slots、sync/state。
建议先保持手动命令文档为主,因为硬件联调环境经常变化。
## 11. 暂不建议立刻做的优化
以下方向有价值,但当前阶段不建议马上做:
- 删除旧 `/api/...` 兼容层:会降低调试容错,建议答辩后再考虑。
- 全量重写同步协议:当前全局版本方案已经足够。
- 把照片二进制直接存 SQLite:会让数据库变大,当前文件系统存照片、SQLite 存元数据更合适。
- 一次性替换所有 JSON 逻辑:风险高,建议分阶段。
- 引入复杂权限系统:当前 `X-Api-Token` 已满足演示需求,公网正式部署再细分权限。
## 12. 推荐后续开发顺序
1. 先完成 SQLite 版服务端手动编译和接口验证。
2. 用 `/api/v1/system/storage` 验证记录、映射、照片、待删除任务数量。
3. 如需继续提升服务端质量,先引入 `nlohmann/json` 替换请求解析。
4. 如需继续提升演示体验,再做 Qt 同步状态面板。
5. 如需继续提升同步严谨性,再做 task_id 删除任务和设备同步状态表。
+116 -11
View File
@@ -35,7 +35,7 @@ WIFI_SSID = "yzfh" # 修改为你的WiFi名称
WIFI_PASSWORD = "yzfhroot" # 修改为你的WiFi密码
SERVER_IP = "27.106.113.205" # 修改为服务器IP地址
SERVER_PORT = 8082 # 服务器HTTP端口
API_TOKEN = "change-me-token" # 必须与服务端 ATTENDANCE_API_TOKEN 一致
API_TOKEN = "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV" # 必须与服务端 ATTENDANCE_API_TOKEN 一致
ESP_DEBUG = True # 打印ESP8266 AT响应,便于排查WiFi连接问题
ESP_BAUD_RATES = (115200, 9600, 74880, 57600, 38400)
ESP_UART_BAUD = 115200
@@ -1104,6 +1104,35 @@ def parse_string_array_block(arr_str):
pos = end + 1
return values
def parse_delete_tasks(tasks_str):
tasks = []
pos = 0
while pos < len(tasks_str):
obj_start = tasks_str.find('{', pos)
if obj_start == -1:
break
obj_end = tasks_str.find('}', obj_start)
if obj_end == -1:
break
obj = tasks_str[obj_start:obj_end + 1]
task_id = json_get_string(obj, "task_id", "")
face_id = json_get_string(obj, "face_id", "")
if face_id:
tasks.append({"task_id": task_id, "face_id": face_id})
pos = obj_end + 1
return tasks
def json_get_string(text, key, default=""):
marker = '"{}":"'.format(key)
start = text.find(marker)
if start == -1:
return default
start += len(marker)
end = text.find('"', start)
if end == -1:
return default
return text[start:end]
def parse_photo_ids(response):
"""解析服务端 /api/v1/faces/photos 返回的照片ID列表"""
ids = {}
@@ -1175,23 +1204,37 @@ def sync_unified_state():
save_sync_version(server_version)
return True
tasks_arr = extract_json_block(response, "delete_tasks", '[', ']')
delete_tasks = parse_delete_tasks(tasks_arr)
pending_arr = extract_json_block(response, "pending_delete", '[', ']')
pending_delete = parse_string_array_block(pending_arr)
if not delete_tasks:
delete_tasks = [{"task_id": "", "face_id": face_id} for face_id in pending_delete]
deleted_ids = []
deleted_task_ids = []
delete_failed = False
print("[SYNC_STATE] 待删除: {}".format(pending_delete))
print("[SYNC_STATE] 待删除任务: {}".format(delete_tasks))
for face_id in pending_delete:
for task in delete_tasks:
face_id = task["face_id"]
if delete_face_by_id(face_id):
deleted_ids.append(face_id)
if task["task_id"]:
deleted_task_ids.append(task["task_id"])
else:
delete_failed = True
if deleted_ids:
deleted_json = ",".join(['"{}"'.format(x) for x in deleted_ids])
ack_body = '{{"client":"k210","version":{},"deleted":[{}]}}'.format(server_version, deleted_json)
task_json = ",".join(['"{}"'.format(x) for x in deleted_task_ids])
ack_body = '{{"client":"k210","version":{},"deleted":[{}],"task_ids":[{}]}}'.format(server_version, deleted_json, task_json)
ack_resp = esp_http_post_json("/api/v1/sync/ack", ack_body, 3000)
print("[SYNC_STATE] ACK响应:", ack_resp[:120] if ack_resp else "(空)")
ack_compact = ack_resp.replace(" ", "").replace("\r", "").replace("\n", "") if ack_resp else ""
ack_ok = '"success":true' in ack_compact
if not ack_ok:
delete_failed = True
print("[SYNC_STATE] 删除ACK失败,暂不推进本地版本,等待下次重试")
mappings_obj = extract_json_block(response, "face_mappings", '{', '}')
server_mappings = parse_mapping_object(mappings_obj)
@@ -1309,6 +1352,35 @@ class People:
return kpu.face_compare(self.feature, feature)
peoples = []
record_path = "/sd/recordftr.txt"
def parse_feature_vector(text):
"""只解析浮点数组文本,避免执行SD卡中的任意代码。"""
allowed = "0123456789+-eE.,[] \t\r\n"
for ch in text:
if ch not in allowed:
raise ValueError("invalid char in feature")
text = text.strip()
if not text.startswith("[") or not text.endswith("]"):
raise ValueError("feature must be list")
inner = text[1:-1].strip()
if not inner:
raise ValueError("empty feature")
values = []
for token in inner.split(","):
token = token.strip()
if not token:
raise ValueError("empty number")
values.append(float(token))
if len(values) > 512:
raise ValueError("feature too long")
if len(values) < 64:
raise ValueError("feature too short")
return values
def load_faces():
global peoples; peoples = []
try:
@@ -1317,16 +1389,49 @@ def load_faces():
# 使用 maxsplit=1 参数,只按第一个 '#' 分割,防止特征数据中的'#'导致错误
parts = line.strip().split('#', 1)
if len(parts) == 2 and parts[1]:
try: peoples.append(People(parts[0], eval(parts[1])))
except Exception as e_eval: print("Eval error on line for {}: {}".format(parts[0], e_eval))
try: peoples.append(People(parts[0], parse_feature_vector(parts[1])))
except Exception as e_eval: print("Feature parse error on line for {}: {}".format(parts[0], e_eval))
print("成功加载 %d 条人脸数据" % len(peoples))
except Exception as e: print("读取人脸数据库失败:", e)
def collect_used_face_ids():
"""收集本地已经占用的K210原始face_id,避免新增时覆盖旧人脸。"""
used = {}
for p in peoples:
name = str(p.name)
if name.startswith("ID:") and name.split(":", 1)[1].isdigit():
used[name.split(":", 1)[1]] = 1
elif name.isdigit() and name in face_id_mapping:
used[name] = 1
for face_id in face_id_mapping.keys():
face_id = str(face_id)
if face_id.isdigit():
used[face_id] = 1
if ensure_local_photo_dir():
try:
for filename in os.listdir(LOCAL_FACE_PHOTO_DIR):
if filename.endswith(".jpg"):
face_id = filename[:-4]
if face_id.isdigit():
used[face_id] = 1
except Exception as e:
print("[ADD] 扫描本地照片ID失败:", e)
return used
def allocate_next_face_id():
used = collect_used_face_ids()
next_id = 1
while str(next_id) in used:
next_id += 1
print("[ADD] 已占用face_id: {}, 新face_id: {}".format(sorted(used.keys()), next_id))
return next_id
def add_face(feature, face_img=None):
new_id_num = 1
if peoples:
id_nums = [int(p.name.split(':')[1]) for p in peoples if ':' in p.name and p.name.split(':')[1].isdigit()]
if id_nums: new_id_num = max(id_nums) + 1
else: new_id_num = len(peoples) + 1
new_id_num = allocate_next_face_id()
new_name = "ID:{}".format(new_id_num)
peoples.append(People(new_name, feature))
try:
+62
View File
@@ -0,0 +1,62 @@
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."
+349 -64
View File
@@ -63,9 +63,10 @@ const std::string FACES_DIR = "faces"; // 人脸照片目录
const std::string PENDING_DELETE_FILE = "pending_delete.json"; // 待删除人脸列表
const std::string SYNC_REQUEST_FILE = "sync_request.json"; // K210同步请求标志文件
const std::string SYNC_VERSION_FILE = "sync_version.txt"; // 全局同步版本号
const std::string DB_BACKUP_DIR = "backups";
const size_t MAX_JSON_BODY_SIZE = 64 * 1024;
const size_t MAX_PHOTO_BODY_SIZE = 512 * 1024;
const char* DEFAULT_API_TOKEN = "change-me-token";
const char* DEFAULT_API_TOKEN = "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV";
const char* DEFAULT_CORS_ORIGIN = "http://localhost";
const char* DEFAULT_TIMESLOTS_JSON =
"{\"time_slots\":["
@@ -93,12 +94,19 @@ const std::string& allowedCorsOrigin() {
return origin;
}
const std::string& bindAddress() {
static const std::string address = getEnvOrDefault("ATTENDANCE_BIND_HOST", "0.0.0.0");
return address;
}
std::string readFaceIdMapping();
std::string getPendingDeleteList();
std::string getFacePhotosList();
std::string getAllStudentIds();
bool setSyncRequest(bool sync);
bool removeFromPendingDelete(const std::string& face_id);
bool updateDeviceSyncState(const std::string& client, unsigned long long seen_version, unsigned long long ack_version);
std::string getDeviceSyncSummary();
bool initializeSqliteStorage();
bool saveAttendanceRecord(const std::string& timestamp, const std::string& student_id,
const std::string& name, const std::string& confidence,
@@ -704,39 +712,90 @@ std::map<std::string, std::string> parseQueryParams(const std::string& query) {
return params;
}
// 简单的JSON解析
std::map<std::string, std::string> parseJSON(const std::string& json) {
std::map<std::string, std::string> result;
bool parseJsonStringLiteral(const std::string& json, size_t& pos, std::string& output) {
if (pos >= json.size() || json[pos] != '"') return false;
pos++;
output.clear();
std::string content = json;
size_t start = content.find('{');
size_t end = content.rfind('}');
if (start != std::string::npos && end != std::string::npos) {
content = content.substr(start + 1, end - start - 1);
while (pos < json.size()) {
char c = json[pos++];
if (c == '"') {
return true;
}
if (c != '\\') {
output += c;
continue;
}
if (pos >= json.size()) return false;
char esc = json[pos++];
switch (esc) {
case '"': output += '"'; break;
case '\\': output += '\\'; break;
case '/': output += '/'; break;
case 'b': output += '\b'; break;
case 'f': output += '\f'; break;
case 'n': output += '\n'; break;
case 'r': output += '\r'; break;
case 't': output += '\t'; break;
case 'u':
// 当前接口字段只需要保留UTF-8原文;遇到unicode转义时不执行错误拆分。
output += "\\u";
for (int i = 0; i < 4 && pos < json.size(); ++i) {
output += json[pos++];
}
break;
default:
output += esc;
break;
}
}
std::istringstream ss(content);
std::string token;
return false;
}
while (std::getline(ss, token, ',')) {
size_t colon = token.find(':');
if (colon != std::string::npos) {
std::string key = token.substr(0, colon);
std::string value = token.substr(colon + 1);
// 解析签到接口使用的扁平JSON对象,支持字符串转义、逗号和冒号。
std::map<std::string, std::string> parseJSON(const std::string& json) {
std::map<std::string, std::string> result;
size_t pos = json.find('{');
if (pos == std::string::npos) return result;
pos++;
// 移除引号和空格
key.erase(std::remove_if(key.begin(), key.end(),
[](char c) { return c == '\"' || c == ' ' || c == '\t'; }), key.end());
value.erase(std::remove_if(value.begin(), value.end(),
[](char c) { return c == '\"'; }), value.end());
while (pos < json.size()) {
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) pos++;
if (pos >= json.size() || json[pos] == '}') break;
// 保留timestamp中的空格
if (key != "timestamp") {
value.erase(std::remove(value.begin(), value.end(), ' '), value.end());
std::string key;
if (!parseJsonStringLiteral(json, pos, key)) {
result.clear();
return result;
}
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) pos++;
if (pos >= json.size() || json[pos] != ':') {
result.clear();
return result;
}
pos++;
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) pos++;
std::string value;
if (pos < json.size() && json[pos] == '"') {
if (!parseJsonStringLiteral(json, pos, value)) {
result.clear();
return result;
}
} else {
size_t start = pos;
while (pos < json.size() && json[pos] != ',' && json[pos] != '}') pos++;
value = json.substr(start, pos - start);
size_t first = value.find_first_not_of(" \t\r\n");
size_t last = value.find_last_not_of(" \t\r\n");
value = first == std::string::npos ? "" : value.substr(first, last - first + 1);
}
result[key] = value;
}
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) pos++;
if (pos < json.size() && json[pos] == ',') pos++;
}
return result;
@@ -784,6 +843,54 @@ bool sqliteTableHasRows(sqlite3* db, const std::string& table) {
return has_rows;
}
bool sqliteTableHasColumn(sqlite3* db, const std::string& table, const std::string& column) {
sqlite3_stmt* stmt = nullptr;
bool found = false;
std::string sql = "PRAGMA table_info(" + table + ")";
if (sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr) == SQLITE_OK) {
while (sqlite3_step(stmt) == SQLITE_ROW) {
if (sqliteColumnText(stmt, 1) == column) {
found = true;
break;
}
}
}
sqlite3_finalize(stmt);
return found;
}
bool copyFileBinary(const std::string& src, const std::string& dst) {
std::ifstream in(src.c_str(), std::ios::binary);
if (!in.is_open()) return false;
std::ofstream out(dst.c_str(), std::ios::binary);
if (!out.is_open()) return false;
out << in.rdbuf();
return out.good();
}
bool backupSqliteDatabaseOnStartup() {
std::ifstream check(SQLITE_DB_FILE.c_str(), std::ios::binary);
if (!check.is_open()) {
return true;
}
check.close();
_mkdir(DB_BACKUP_DIR.c_str());
std::string ts = getCurrentTimestamp();
std::replace(ts.begin(), ts.end(), ':', '-');
std::replace(ts.begin(), ts.end(), ' ', '_');
const std::string backup_path = DB_BACKUP_DIR + "/attendance_" + ts + ".db";
if (!copyFileBinary(SQLITE_DB_FILE, backup_path)) {
std::cerr << "[BACKUP] Failed to create SQLite backup: " << backup_path << std::endl;
return false;
}
std::cout << "[BACKUP] SQLite backup created: " << backup_path << std::endl;
return true;
}
std::string readWholeFileOrDefault(const std::string& path, const std::string& fallback) {
std::ifstream file(path.c_str(), std::ios::binary);
if (!file.is_open()) return fallback;
@@ -968,7 +1075,19 @@ bool initializeSqliteStorage() {
"student_id TEXT NOT NULL"
");"
"CREATE TABLE IF NOT EXISTS pending_delete("
"face_id TEXT PRIMARY KEY"
"task_id TEXT PRIMARY KEY,"
"face_id TEXT NOT NULL,"
"student_id TEXT,"
"created_at TEXT NOT NULL,"
"status TEXT NOT NULL DEFAULT 'pending',"
"ack_at TEXT"
");"
"CREATE INDEX IF NOT EXISTS idx_pending_delete_face ON pending_delete(face_id);"
"CREATE TABLE IF NOT EXISTS device_sync_state("
"client TEXT PRIMARY KEY,"
"last_seen_version INTEGER NOT NULL DEFAULT 0,"
"last_ack_version INTEGER NOT NULL DEFAULT 0,"
"last_seen_at TEXT NOT NULL"
");");
if (!ok) {
@@ -976,6 +1095,25 @@ bool initializeSqliteStorage() {
return false;
}
if (!sqliteTableHasColumn(db, "pending_delete", "task_id")) {
sqliteExec(db, "ALTER TABLE pending_delete ADD COLUMN task_id TEXT");
}
if (!sqliteTableHasColumn(db, "pending_delete", "student_id")) {
sqliteExec(db, "ALTER TABLE pending_delete ADD COLUMN student_id TEXT");
}
if (!sqliteTableHasColumn(db, "pending_delete", "created_at")) {
sqliteExec(db, "ALTER TABLE pending_delete ADD COLUMN created_at TEXT");
}
if (!sqliteTableHasColumn(db, "pending_delete", "status")) {
sqliteExec(db, "ALTER TABLE pending_delete ADD COLUMN status TEXT DEFAULT 'pending'");
}
if (!sqliteTableHasColumn(db, "pending_delete", "ack_at")) {
sqliteExec(db, "ALTER TABLE pending_delete ADD COLUMN ack_at TEXT");
}
sqliteExec(db, "UPDATE pending_delete SET task_id='legacy-' || face_id WHERE task_id IS NULL OR task_id=''");
sqliteExec(db, "UPDATE pending_delete SET created_at='" + getCurrentTimestamp() + "' WHERE created_at IS NULL OR created_at=''");
sqliteExec(db, "UPDATE pending_delete SET status='pending' WHERE status IS NULL OR status=''");
if (!sqliteTableHasRows(db, "records")) {
std::ifstream csv(DB_FILE.c_str());
if (csv.is_open()) {
@@ -1037,12 +1175,16 @@ bool initializeSqliteStorage() {
std::vector<std::string> pending = parseJsonStringList(
readWholeFileOrDefault(PENDING_DELETE_FILE, "{\"pending_delete\":[]}"), "pending_delete");
sqlite3_stmt* stmt = nullptr;
const char* sql = "REPLACE INTO pending_delete(face_id) VALUES(?)";
const char* sql = "REPLACE INTO pending_delete(task_id,face_id,student_id,created_at,status) VALUES(?,?,?,?,?)";
if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) == SQLITE_OK) {
for (const auto& id : pending) {
sqlite3_reset(stmt);
sqlite3_clear_bindings(stmt);
sqliteBindText(stmt, 1, id);
sqliteBindText(stmt, 1, "legacy-" + id);
sqliteBindText(stmt, 2, id);
sqliteBindText(stmt, 3, id);
sqliteBindText(stmt, 4, getCurrentTimestamp());
sqliteBindText(stmt, 5, "pending");
sqlite3_step(stmt);
}
}
@@ -1280,9 +1422,26 @@ std::vector<std::string> extractJsonStringArray(const std::string& json, const s
return values;
}
unsigned long long jsonNumberValue(const std::string& json, const std::string& key, unsigned long long fallback) {
std::string marker = "\"" + key + "\":";
size_t pos = json.find(marker);
if (pos == std::string::npos) return fallback;
pos += marker.size();
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) pos++;
unsigned long long value = 0;
bool has_digit = false;
while (pos < json.size() && std::isdigit(static_cast<unsigned char>(json[pos]))) {
has_digit = true;
value = value * 10ULL + static_cast<unsigned long long>(json[pos] - '0');
pos++;
}
return has_digit ? value : fallback;
}
std::string makeSyncStateResponse(const std::string& client, unsigned long long last_version) {
const unsigned long long version = readSyncVersion();
const bool changed = last_version < version;
updateDeviceSyncState(client.empty() ? "all" : client, version, 0);
std::ostringstream json;
json << "{\"version\":" << version
@@ -1296,9 +1455,12 @@ std::string makeSyncStateResponse(const std::string& client, unsigned long long
if (client == "k210" || client == "qt" || client == "all" || client.empty()) {
const std::string mappings = extractJsonMemberValue(readFaceIdMapping(), "mappings", "{}");
const std::string pending = extractJsonMemberValue(getPendingDeleteList(), "pending_delete", "[]");
const std::string pending_json = getPendingDeleteList();
const std::string pending = extractJsonMemberValue(pending_json, "pending_delete", "[]");
const std::string delete_tasks = extractJsonMemberValue(pending_json, "delete_tasks", "[]");
json << "\"face_mappings\":" << mappings
<< ",\"pending_delete\":" << pending;
<< ",\"pending_delete\":" << pending
<< ",\"delete_tasks\":" << delete_tasks;
first = false;
}
@@ -1328,15 +1490,25 @@ std::string makeSyncStateResponse(const std::string& client, unsigned long long
std::string makeSyncAckResponse(const std::string& request_body) {
const std::string client = extractJsonStringValue(request_body, "client", "unknown");
const unsigned long long ack_version = static_cast<unsigned long long>(jsonNumberValue(request_body, "version", 0));
const std::vector<std::string> deleted = extractJsonStringArray(request_body, "deleted");
const std::vector<std::string> task_ids = extractJsonStringArray(request_body, "task_ids");
int confirmed_delete_count = 0;
for (const std::string& task_id : task_ids) {
if (!task_id.empty() && removeFromPendingDelete(task_id)) {
confirmed_delete_count++;
}
}
for (const std::string& id : deleted) {
if (!id.empty() && removeFromPendingDelete(id)) {
confirmed_delete_count++;
}
}
updateDeviceSyncState(client, readSyncVersion(), ack_version);
if (confirmed_delete_count > 0) {
bumpSyncVersion("sync_ack_delete");
}
@@ -1385,6 +1557,7 @@ std::string makeStorageStatusResponse() {
<< "\"face_photos\":" << face_photos << ","
<< "\"pending_deletes\":" << pending_deletes << ","
<< "\"sync_version\":" << readSyncVersion() << ","
<< "\"device_sync_state\":" << getDeviceSyncSummary() << ","
<< "\"server_time\":\"" << jsonEscape(getCurrentTimestamp()) << "\""
<< "}";
return json.str();
@@ -1824,56 +1997,145 @@ std::string getAllStudentIds() {
return json.str();
}
// 添加人脸ID到待删除列表
bool addToPendingDelete(const std::string& face_id) {
bool updateDeviceSyncState(const std::string& client, unsigned long long seen_version, unsigned long long ack_version) {
if (client.empty()) return false;
sqlite3* db = openSqliteDb();
if (!db) return false;
sqlite3_stmt* stmt = nullptr;
bool ok = false;
if (sqlite3_prepare_v2(db, "REPLACE INTO pending_delete(face_id) VALUES(?)", -1, &stmt, nullptr) == SQLITE_OK) {
sqliteBindText(stmt, 1, face_id);
const char* sql =
"INSERT INTO device_sync_state(client,last_seen_version,last_ack_version,last_seen_at) "
"VALUES(?,?,?,?) "
"ON CONFLICT(client) DO UPDATE SET "
"last_seen_version=MAX(device_sync_state.last_seen_version, excluded.last_seen_version),"
"last_ack_version=MAX(device_sync_state.last_ack_version, excluded.last_ack_version),"
"last_seen_at=excluded.last_seen_at";
if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) == SQLITE_OK) {
sqliteBindText(stmt, 1, client);
sqlite3_bind_int64(stmt, 2, static_cast<sqlite3_int64>(seen_version));
sqlite3_bind_int64(stmt, 3, static_cast<sqlite3_int64>(ack_version));
sqliteBindText(stmt, 4, getCurrentTimestamp());
ok = sqlite3_step(stmt) == SQLITE_DONE;
}
sqlite3_finalize(stmt);
sqlite3_close(db);
std::cout << "[PENDING_DELETE] Added: " << face_id << std::endl;
return ok;
}
std::string getDeviceSyncSummary() {
sqlite3* db = openSqliteDb();
if (!db) return "[]";
sqlite3_stmt* stmt = nullptr;
std::ostringstream json;
json << "[";
bool first = true;
const char* sql = "SELECT client,last_seen_version,last_ack_version,last_seen_at FROM device_sync_state ORDER BY client";
if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) == SQLITE_OK) {
while (sqlite3_step(stmt) == SQLITE_ROW) {
if (!first) json << ",";
json << "{"
<< "\"client\":\"" << jsonEscape(sqliteColumnText(stmt, 0)) << "\","
<< "\"last_seen_version\":" << sqlite3_column_int64(stmt, 1) << ","
<< "\"last_ack_version\":" << sqlite3_column_int64(stmt, 2) << ","
<< "\"last_seen_at\":\"" << jsonEscape(sqliteColumnText(stmt, 3)) << "\""
<< "}";
first = false;
}
}
sqlite3_finalize(stmt);
sqlite3_close(db);
json << "]";
return json.str();
}
// 添加人脸ID到待删除列表
bool addToPendingDelete(const std::string& face_id, const std::string& student_id) {
sqlite3* db = openSqliteDb();
if (!db) return false;
sqlite3_stmt* stmt = nullptr;
bool ok = false;
std::string task_id = face_id + "-" + std::to_string(static_cast<long long>(time(0)));
if (sqlite3_prepare_v2(db,
"REPLACE INTO pending_delete(task_id,face_id,student_id,created_at,status) VALUES(?,?,?,?,?)",
-1, &stmt, nullptr) == SQLITE_OK) {
sqliteBindText(stmt, 1, task_id);
sqliteBindText(stmt, 2, face_id);
sqliteBindText(stmt, 3, student_id);
sqliteBindText(stmt, 4, getCurrentTimestamp());
sqliteBindText(stmt, 5, "pending");
ok = sqlite3_step(stmt) == SQLITE_DONE;
}
sqlite3_finalize(stmt);
sqlite3_close(db);
std::cout << "[PENDING_DELETE] Added: " << face_id << " task=" << task_id << std::endl;
return ok;
}
// 获取待删除列表
std::string getPendingDeleteList() {
sqlite3* db = openSqliteDb();
if (!db) return "{\"pending_delete\":[]}";
if (!db) return "{\"pending_delete\":[],\"delete_tasks\":[]}";
sqlite3_stmt* stmt = nullptr;
std::ostringstream json;
json << "{\"pending_delete\":[";
bool first = true;
if (sqlite3_prepare_v2(db, "SELECT face_id FROM pending_delete ORDER BY face_id", -1, &stmt, nullptr) == SQLITE_OK) {
std::vector<std::map<std::string, std::string>> tasks;
if (sqlite3_prepare_v2(db,
"SELECT task_id,face_id,student_id,created_at,status,COALESCE(ack_at,'') FROM pending_delete WHERE status<>'acked' ORDER BY created_at,face_id",
-1, &stmt, nullptr) == SQLITE_OK) {
while (sqlite3_step(stmt) == SQLITE_ROW) {
if (!first) json << ",";
json << "\"" << jsonEscape(sqliteColumnText(stmt, 0)) << "\"";
first = false;
std::map<std::string, std::string> row;
row["task_id"] = sqliteColumnText(stmt, 0);
row["face_id"] = sqliteColumnText(stmt, 1);
row["student_id"] = sqliteColumnText(stmt, 2);
row["created_at"] = sqliteColumnText(stmt, 3);
row["status"] = sqliteColumnText(stmt, 4);
row["ack_at"] = sqliteColumnText(stmt, 5);
tasks.push_back(row);
}
}
sqlite3_finalize(stmt);
sqlite3_close(db);
std::ostringstream json;
json << "{\"pending_delete\":[";
bool first = true;
for (const auto& task : tasks) {
if (!first) json << ",";
json << "\"" << jsonEscape(task.at("face_id")) << "\"";
first = false;
}
json << "],\"delete_tasks\":[";
first = true;
for (const auto& task : tasks) {
if (!first) json << ",";
json << "{"
<< "\"task_id\":\"" << jsonEscape(task.at("task_id")) << "\","
<< "\"face_id\":\"" << jsonEscape(task.at("face_id")) << "\","
<< "\"student_id\":\"" << jsonEscape(task.at("student_id")) << "\","
<< "\"created_at\":\"" << jsonEscape(task.at("created_at")) << "\","
<< "\"status\":\"" << jsonEscape(task.at("status")) << "\""
<< "}";
first = false;
}
json << "]}";
return json.str();
}
// 从待删除列表中移除指定ID(K210确认删除后调用)
bool removeFromPendingDelete(const std::string& face_id) {
bool removeFromPendingDelete(const std::string& face_id_or_task_id) {
sqlite3* db = openSqliteDb();
if (!db) return false;
sqlite3_stmt* stmt = nullptr;
bool ok = false;
if (sqlite3_prepare_v2(db, "DELETE FROM pending_delete WHERE face_id=?", -1, &stmt, nullptr) == SQLITE_OK) {
sqliteBindText(stmt, 1, face_id);
if (sqlite3_prepare_v2(db,
"UPDATE pending_delete SET status='acked',ack_at=? WHERE (task_id=? OR face_id=?) AND status<>'acked'",
-1, &stmt, nullptr) == SQLITE_OK) {
sqliteBindText(stmt, 1, getCurrentTimestamp());
sqliteBindText(stmt, 2, face_id_or_task_id);
sqliteBindText(stmt, 3, face_id_or_task_id);
ok = sqlite3_step(stmt) == SQLITE_DONE && sqlite3_changes(db) > 0;
}
sqlite3_finalize(stmt);
sqlite3_close(db);
std::cout << "[PENDING_DELETE] Removed: " << face_id << std::endl;
std::cout << "[PENDING_DELETE] Acked: " << face_id_or_task_id << std::endl;
return ok;
}
@@ -2078,10 +2340,23 @@ void handleHttpRequest(SOCKET client_socket) {
// 构建考勤记录
std::string timestamp = getCurrentTimestamp();
std::string student_id = json_data["student_id"]; // K210已发送真实学号
std::string name = json_data["name"];
std::string confidence = json_data["confidence"];
std::string status = json_data["status"];
std::string name = json_data.count("name") ? json_data["name"] : "";
std::string confidence = json_data.count("confidence") ? json_data["confidence"] : "0";
std::string status = json_data.count("status") ? json_data["status"] : "success";
std::string class_id = json_data.count("class_id") ? json_data["class_id"] : "0";
char* conf_end = nullptr;
const double conf_value = std::strtod(confidence.c_str(), &conf_end);
if (!isSafeId(student_id) || student_id.size() > 32 || name.size() > 64 ||
class_id.size() > 32 || conf_end == confidence.c_str() || *conf_end != '\0' ||
conf_value < 0.0 || conf_value > 1.0 ||
(status != "success" && status != "failed" && status != "late")) {
response_body = makeJsonError("Invalid checkin fields");
status_code = 400;
status_text = "Bad Request";
sendResponse();
return;
}
std::cout << "[CHECKIN] Student ID: " << student_id << std::endl;
@@ -2404,7 +2679,7 @@ void handleHttpRequest(SOCKET client_socket) {
photo_deleted = deleteFacePhoto(face_id) || photo_deleted;
mapping_deleted = removeFaceIdFromMapping(face_id) || mapping_deleted;
}
pending_added = addToPendingDelete(face_id) || pending_added;
pending_added = addToPendingDelete(face_id, student_id) || pending_added;
}
for (const auto& id : student_ids) {
photo_deleted = deleteFacePhoto(id) || photo_deleted;
@@ -2431,21 +2706,17 @@ void handleHttpRequest(SOCKET client_socket) {
} else if (method == "POST" && endpoint == "/api/confirm_delete") {
// K210确认删除完成,从待删除列表移除
// 请求体格式: {"face_id":"xxx"}
std::string face_id;
size_t pos = request_body.find("\"face_id\":\"");
if (pos != std::string::npos) {
size_t start = pos + 11;
size_t end = request_body.find("\"", start);
if (end != std::string::npos) {
face_id = request_body.substr(start, end - start);
}
// 请求体格式: {"face_id":"xxx","task_id":"xxx"}
auto ack_json = parseJSON(request_body);
std::string ack_id = ack_json.count("task_id") ? ack_json["task_id"] : "";
if (ack_id.empty() && ack_json.count("face_id")) {
ack_id = ack_json["face_id"];
}
if (!face_id.empty() && removeFromPendingDelete(face_id)) {
response_body = "{\"success\":true,\"message\":\"Confirmed delete: " + jsonEscape(face_id) + "\"}";
if (!ack_id.empty() && removeFromPendingDelete(ack_id)) {
response_body = "{\"success\":true,\"message\":\"Confirmed delete: " + jsonEscape(ack_id) + "\"}";
bumpSyncVersion("confirm_delete");
std::cout << "[PENDING_DELETE] K210 confirmed delete: " << face_id << std::endl;
std::cout << "[PENDING_DELETE] K210 confirmed delete: " << ack_id << std::endl;
} else {
response_body = "{\"success\":false,\"message\":\"Invalid face_id or not in pending list\"}";
status_code = 400;
@@ -2502,7 +2773,16 @@ void httpServerThread() {
sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
const std::string bind_host = bindAddress();
if (bind_host == "0.0.0.0" || bind_host.empty()) {
server_addr.sin_addr.s_addr = INADDR_ANY;
} else {
if (inet_pton(AF_INET, bind_host.c_str(), &server_addr.sin_addr) != 1) {
std::cerr << "[HTTP] Invalid ATTENDANCE_BIND_HOST: " << bind_host << std::endl;
closesocket(server_socket);
return;
}
}
server_addr.sin_port = htons(HTTP_PORT);
if (bind(server_socket, (sockaddr*)&server_addr, sizeof(server_addr)) == SOCKET_ERROR) {
@@ -2512,7 +2792,7 @@ void httpServerThread() {
}
listen(server_socket, 10);
std::cout << "[HTTP] Server listening on port " << HTTP_PORT << std::endl;
std::cout << "[HTTP] Server listening on " << bind_host << ":" << HTTP_PORT << std::endl;
while (true) {
sockaddr_in client_addr;
@@ -2539,6 +2819,11 @@ int main() {
std::cout << "Database: " << SQLITE_DB_FILE << std::endl;
std::cout << "----------------------------------------" << std::endl;
if (!backupSqliteDatabaseOnStartup()) {
std::cerr << "[BACKUP] Startup backup failed, stop to avoid unsafe writes" << std::endl;
return 1;
}
// 初始化SQLite,并首次导入旧CSV/JSON配置
if (!initializeSqliteStorage()) {
std::cerr << "[SQLITE] Failed to initialize storage" << std::endl;