diff --git a/.gitignore b/.gitignore index c0e5878..29e8ece 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ QT/.qtcreator/ attendance.db attendance.log faces/ +backups/ # OS/editor files .DS_Store diff --git a/Hardware/Attendance.c b/Hardware/Attendance.c index 0dd1d4c..271d490 100644 --- a/Hardware/Attendance.c +++ b/Hardware/Attendance.c @@ -5,6 +5,8 @@ #include #include // ← 添加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) { // 复制学号和姓名 diff --git a/Hardware/esp8266.c b/Hardware/esp8266.c index d0ab9ec..6f650b0 100644 --- a/Hardware/esp8266.c +++ b/Hardware/esp8266.c @@ -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,16 +231,47 @@ 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); } - DEBUG_ERROR("[ESP8266] No '>' prompt"); + 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; } @@ -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; } diff --git a/QT/qml/Main.qml b/QT/qml/Main.qml index da0f5a2..4c84f0c 100644 --- a/QT/qml/Main.qml +++ b/QT/qml/Main.qml @@ -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 diff --git a/QT/src/ApiClient.cpp b/QT/src/ApiClient.cpp index 21e55ac..1c25f59 100644 --- a/QT/src/ApiClient.cpp +++ b/QT/src/ApiClient.cpp @@ -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) diff --git a/User/main.c b/User/main.c index 9f96035..248f651 100644 --- a/User/main.c +++ b/User/main.c @@ -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; } diff --git a/docs/00_项目总文档.md b/docs/00_项目总文档.md index 5bf2e12..c4bb92d 100644 --- a/docs/00_项目总文档.md +++ b/docs/00_项目总文档.md @@ -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` diff --git a/docs/03_服务端文档.md b/docs/03_服务端文档.md index 22d368c..d14465f 100644 --- a/docs/03_服务端文档.md +++ b/docs/03_服务端文档.md @@ -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`。 diff --git a/docs/05_项目审查问题清单.md b/docs/05_项目审查问题清单.md index 3dbf868..b1037ec 100644 --- a/docs/05_项目审查问题清单.md +++ b/docs/05_项目审查问题清单.md @@ -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 编译和上板验证。 diff --git a/docs/06_后端接口Windows测试命令.md b/docs/06_后端接口Windows测试命令.md index 32bec0e..7a0c652 100644 --- a/docs/06_后端接口Windows测试命令.md +++ b/docs/06_后端接口Windows测试命令.md @@ -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" ``` 如果后端日志显示: diff --git a/docs/07_后续优化路线.md b/docs/07_后续优化路线.md deleted file mode 100644 index aa52c92..0000000 --- a/docs/07_后续优化路线.md +++ /dev/null @@ -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 删除任务和设备同步状态表。 - diff --git a/k210/main.py b/k210/main.py index ac0206d..7a10c5a 100644 --- a/k210/main.py +++ b/k210/main.py @@ -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: diff --git a/server/regression_api_tests.ps1 b/server/regression_api_tests.ps1 new file mode 100644 index 0000000..3d95abc --- /dev/null +++ b/server/regression_api_tests.ps1 @@ -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." diff --git a/server/server_encrypted.cpp b/server/server_encrypted.cpp index 1fd5b9f..75f1335 100644 --- a/server/server_encrypted.cpp +++ b/server/server_encrypted.cpp @@ -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 parseQueryParams(const std::string& query) { return params; } -// 简单的JSON解析 -std::map parseJSON(const std::string& json) { - std::map 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 parseJSON(const std::string& json) { + std::map 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(json[pos]))) pos++; + if (pos >= json.size() || json[pos] == '}') break; - // 保留timestamp中的空格 - if (key != "timestamp") { - value.erase(std::remove(value.begin(), value.end(), ' '), value.end()); - } - - result[key] = value; + std::string key; + if (!parseJsonStringLiteral(json, pos, key)) { + result.clear(); + return result; } + + while (pos < json.size() && std::isspace(static_cast(json[pos]))) pos++; + if (pos >= json.size() || json[pos] != ':') { + result.clear(); + return result; + } + pos++; + while (pos < json.size() && std::isspace(static_cast(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(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 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 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(json[pos]))) pos++; + unsigned long long value = 0; + bool has_digit = false; + while (pos < json.size() && std::isdigit(static_cast(json[pos]))) { + has_digit = true; + value = value * 10ULL + static_cast(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(jsonNumberValue(request_body, "version", 0)); const std::vector deleted = extractJsonStringArray(request_body, "deleted"); + const std::vector 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(seen_version)); + sqlite3_bind_int64(stmt, 3, static_cast(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(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> 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 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; - server_addr.sin_addr.s_addr = INADDR_ANY; + 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;