硬件基本完成
This commit is contained in:
@@ -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."
|
||||
+352
-67
@@ -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());
|
||||
}
|
||||
|
||||
result[key] = value;
|
||||
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;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user