#include "ApiClient.h" #include #include #include #include #include #include #include #include #include #include "CryptoUtils.h" namespace { constexpr int NetworkTimeoutMs = 5000; } // 函数作用:初始化网络客户端,并设置默认服务器地址和 API Token。 ApiClient::ApiClient(QObject *parent) : QObject(parent) , m_serverUrl("http://27.106.113.205:8082") // Token 优先从环境读取,便于发布或调试时不把真实 Token 写死在配置文件里。 , m_apiToken(QProcessEnvironment::systemEnvironment() .value(QStringLiteral("atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV"))) { } // 函数作用:更新服务端基础地址,忽略空字符串。 void ApiClient::setServerUrl(const QString &url) { const QString trimmed = url.trimmed(); if (!trimmed.isEmpty()) { m_serverUrl = trimmed; } } // 函数作用:返回当前服务端基础地址。 QString ApiClient::serverUrl() const { return m_serverUrl; } // 函数作用:更新 API Token,空值时回退读取环境变量。 void ApiClient::setApiToken(const QString &token) { const QString trimmed = token.trimmed(); m_apiToken = trimmed.isEmpty() ? QProcessEnvironment::systemEnvironment() .value(QStringLiteral("atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV")) : trimmed; } // 函数作用:返回当前 API Token。 QString ApiClient::apiToken() const { return m_apiToken; } // 函数作用:拼接完整 API 地址,并编码查询参数。 QString ApiClient::buildUrl(const QString &endpoint, const QMap ¶ms) const { QString baseUrl = m_serverUrl.trimmed(); // 统一去掉用户输入末尾的斜杠,避免拼接出 http://host//api 这类地址。 while (baseUrl.endsWith('/')) { baseUrl.chop(1); } QString url = baseUrl + "/api" + endpoint; if (!params.isEmpty()) { QUrlQuery query; for (auto it = params.begin(); it != params.end(); ++it) { query.addQueryItem(it.key(), it.value()); } url += "?" + query.toString(QUrl::FullyEncoded); } return url; } // 函数作用:创建带认证头、加密标记和超时时间的网络请求对象。 QNetworkRequest ApiClient::createRequest(const QString &url, bool encryptedResponse, bool jsonBody) const { QNetworkRequest request{QUrl(url)}; if (encryptedResponse) { // 告诉服务端此请求希望接收加密响应。实际是否加密以响应头 X-Encrypted 为准。 request.setRawHeader("X-Encrypted-Response", "true"); } request.setRawHeader("X-Api-Token", m_apiToken.toUtf8()); request.setRawHeader("Accept", "application/json"); if (jsonBody) { // 服务端约定加密后的 JSON 以 octet-stream 传输,并通过 X-Encrypted 声明算法。 request.setHeader(QNetworkRequest::ContentTypeHeader, "application/octet-stream"); request.setRawHeader("X-Encrypted", "AES128-CBC"); } request.setTransferTimeout(NetworkTimeoutMs); return request; } // 函数作用:请求服务端同步状态,根据版本号决定是否返回完整业务数据。 void ApiClient::fetchSyncState(qulonglong lastVersion) { // 统一同步入口:服务端会比较客户端版本,若 changed=false,客户端无需重复拉取各表。 const QString url = buildUrl("/v1/sync/state", { {"client", "qt"}, {"last_version", QString::number(lastVersion)} }); qInfo() << "[ApiClient] GET sync state" << url; QNetworkReply *reply = m_networkManager.get(createRequest(url, true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { emitNetworkError(QStringLiteral("同步状态加载失败"), reply); reply->deleteLater(); return; } const QJsonObject root = QJsonDocument::fromJson(data).object(); const qulonglong version = static_cast(root.value("version").toDouble(0)); const bool changed = root.value("changed").toBool(false); if (!changed) { // 服务端版本没有变化时只通知控制器更新状态文本,不重复解析或刷新各个模型。 emit syncStateFetched(version, false); reply->deleteLater(); return; } const QJsonObject state = root.value("state").toObject(); // state 中按需包含多个业务分区;每个分区解析后直接复用已有信号, // 让 AppController 不必区分“增量同步返回”还是“单独接口返回”。 if (state.contains("records")) { emit recordsFetched(parseRecords(QJsonDocument(state.value("records").toArray()).toJson(QJsonDocument::Compact))); } if (state.contains("statistics")) { const QJsonObject object = state.value("statistics").toObject(); emit statisticsFetched(object.value("total").toInt(), object.value("today").toInt(), object.value("server_time").toString()); } if (state.contains("time_slots")) { QVector timeSlots; const QJsonArray array = state.value("time_slots").toArray(); for (const QJsonValue &value : array) { const QJsonObject object = value.toObject(); timeSlots.push_back({ object.value("name").toString(), QTime::fromString(object.value("start_time").toString(), "HH:mm"), QTime::fromString(object.value("end_time").toString(), "HH:mm"), object.value("enabled").toBool(true) }); } emit timeSlotsFetched(timeSlots); } if (state.contains("students") || state.contains("classes")) { QMap studentMap; QMap studentClassMap; QMap classMap; // 学生数组先拆成“学号->姓名”和“学号->班级”两张映射,保持与本地模型结构一致。 const QJsonArray studentArray = state.value("students").toArray(); for (const QJsonValue &value : studentArray) { const QJsonObject object = value.toObject(); const QString studentId = object.value("student_id").toString().trimmed(); if (studentId.isEmpty()) { continue; } studentMap[studentId] = object.value("student_name").toString().trimmed(); studentClassMap[studentId] = object.value("class_id").toString().trimmed(); } const QJsonArray classes = state.value("classes").toArray(); // 班级数组单独解析,后面用 class_id 回填学生档案中的班级名称。 for (const QJsonValue &value : classes) { const QJsonObject object = value.toObject(); const QString classId = object.value("class_id").toString().trimmed(); if (!classId.isEmpty()) { classMap[classId] = object.value("class_name").toString().trimmed(); } } QVector studentItems; studentItems.reserve(studentMap.size()); // ApiClient 只做结构转换,是否保存到本地、是否刷新统计由 AppController 决定。 for (auto it = studentMap.cbegin(); it != studentMap.cend(); ++it) { const QString studentId = it.key(); studentItems.push_back({ studentId, it.value(), studentClassMap.value(studentId), classMap.value(studentClassMap.value(studentId), studentClassMap.value(studentId)), {}, {} }); } emit studentsFetched(studentItems); emit classesFetched(classMap); } if (state.contains("face_profiles")) { const QJsonArray profiles = state.value("face_profiles").toArray(); QVector faces; faces.reserve(profiles.size()); // 这里暂不补学生姓名和班级名称,这些派生字段需要控制器结合学生/班级映射完成。 for (const QJsonValue &value : profiles) { const QJsonObject object = value.toObject(); const QString faceId = object.value("face_id").toString().trimmed(); const QString studentId = object.value("student_id").toString().trimmed(); if (faceId.isEmpty()) { continue; } faces.push_back({ faceId, studentId, object.value("has_photo").toBool(false), {}, {}, {}, {}, object.value("status").toString().trimmed() }); } emit faceProfilesFetched(faces); } if (state.contains("face_photos")) { QStringList ids; const QJsonArray photos = state.value("face_photos").toArray(); // 同步状态只返回照片 ID 列表,真正的 JPEG 数据由控制器按需逐张下载。 for (const QJsonValue &value : photos) { ids.push_back(value.toObject().value("id").toString()); } emit facePhotosFetched(ids); } emit syncStateFetched(version, true); reply->deleteLater(); }); } // 函数作用:根据响应头判断是否需要解密响应体,并返回可解析的数据。 QByteArray ApiClient::maybeDecrypt(const QByteArray &data, bool encrypted) const { if (!encrypted) { return data; } const QByteArray trimmed = data.trimmed(); // 调试或兼容场景下,服务端可能带了加密头但直接返回明文 JSON; // 这里先识别 JSON 起始字符,避免对明文重复解密导致解析失败。 if (trimmed.startsWith('{') || trimmed.startsWith('[')) { return trimmed; } return CryptoUtils::decryptAes128CbcBase64(trimmed); } // 函数作用:读取 QNetworkReply 响应体,并按响应头自动解密。 QByteArray ApiClient::decryptedReplyBody(QNetworkReply *reply) const { const QByteArray data = reply->readAll(); const bool encrypted = reply->rawHeader("X-Encrypted").trimmed() == "AES128-CBC"; return maybeDecrypt(data, encrypted); } // 函数作用:把 JSON 请求体按服务端约定加密后发送。 QByteArray ApiClient::encryptedJsonBody(const QByteArray &body) const { return CryptoUtils::encryptAes128CbcBase64(body); } // 函数作用:判断网络错误是否属于连接不可用,需要触发重连。 bool ApiClient::isConnectionError(QNetworkReply::NetworkError error) const { // 这些错误大多表示服务端不可达或连接中断,需要通知 AppController 进入重连流程。 switch (error) { case QNetworkReply::ConnectionRefusedError: case QNetworkReply::RemoteHostClosedError: case QNetworkReply::HostNotFoundError: case QNetworkReply::TimeoutError: case QNetworkReply::OperationCanceledError: case QNetworkReply::SslHandshakeFailedError: case QNetworkReply::TemporaryNetworkFailureError: case QNetworkReply::NetworkSessionFailedError: case QNetworkReply::BackgroundRequestNotAllowedError: case QNetworkReply::TooManyRedirectsError: case QNetworkReply::InsecureRedirectError: case QNetworkReply::UnknownNetworkError: case QNetworkReply::UnknownProxyError: case QNetworkReply::UnknownContentError: case QNetworkReply::ProtocolFailure: case QNetworkReply::UnknownServerError: return true; default: return false; } } // 函数作用:统一发出网络错误信号,并在连接类错误时额外通知重连流程。 void ApiClient::emitNetworkError(const QString &prefix, QNetworkReply *reply) { // networkError 负责界面提示,connectionUnavailable 负责触发自动重连。 emit networkError(prefix); if (isConnectionError(reply->error())) { emit connectionUnavailable(prefix); } } // 函数作用:解析服务端返回的考勤记录 JSON 数组。 QVector ApiClient::parseRecords(const QByteArray &data) const { QVector records; const QJsonDocument doc = QJsonDocument::fromJson(data); if (!doc.isArray()) { return records; } const QJsonArray array = doc.array(); records.reserve(array.size()); // 服务端历史数据中 status 可能是 bool、数字或字符串,统一转换为字符串保存。 for (const QJsonValue &value : array) { const QJsonObject object = value.toObject(); AttendanceRecord record; // 只保留服务端的原始业务字段;姓名和班级显示值稍后由 AppController 映射生成。 record.timestamp = object.value("timestamp").toString().trimmed(); record.studentId = object.value("student_id").toString().trimmed(); record.originalName = object.value("name").toString().trimmed(); record.confidence = object.value("confidence").toDouble(); const QJsonValue statusValue = object.value("status"); if (statusValue.isBool()) { record.status = statusValue.toBool() ? QStringLiteral("success") : QStringLiteral("failed"); } else if (statusValue.isDouble()) { record.status = QString::number(statusValue.toInt()); } else { record.status = statusValue.toString().trimmed(); } record.classId = object.value("class_id").toString().trimmed(); if (record.classId.isEmpty()) { record.classId = "0"; } // 过滤掉初始化时间和缺失关键字段的无效记录,避免界面统计被脏数据污染。 if (!record.timestamp.startsWith("0000-") && !record.timestamp.isEmpty() && !record.studentId.isEmpty()) { records.push_back(record); } } return records; } // 函数作用:分页请求考勤记录列表。 void ApiClient::fetchRecords(int limit, int offset) { // 考勤记录分页接口。当前管理端默认一次取最近 1000 条,后续可通过 limit/offset 扩展分页。 const QString url = buildUrl("/v1/attendance/records", { {"limit", QString::number(limit)}, {"offset", QString::number(offset)} }); qInfo() << "[ApiClient] GET" << url; QNetworkReply *reply = m_networkManager.get(createRequest(url, true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QByteArray data = decryptedReplyBody(reply); const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); qInfo() << "[ApiClient] records status=" << statusCode << "error=" << reply->error() << "bytes=" << data.size() << "body=" << data.left(200); if (reply->error() != QNetworkReply::NoError) { emitNetworkError(QStringLiteral("加载记录失败"), reply); reply->deleteLater(); return; } if (data.isEmpty()) { emit networkError("记录数据为空"); reply->deleteLater(); return; } // 先显式验证 JSON 类型,避免 parseRecords 静默返回空列表时界面误以为服务端真的没有记录。 QJsonParseError jsonError{}; const QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); if (jsonError.error != QJsonParseError::NoError || !doc.isArray()) { emit networkError(QStringLiteral("记录数据解析失败: %1").arg(jsonError.errorString())); reply->deleteLater(); return; } const QVector records = parseRecords(data); qInfo() << "[ApiClient] parsed records=" << records.size(); emit recordsFetched(records); reply->deleteLater(); }); } // 函数作用:请求服务端考勤统计摘要。 void ApiClient::fetchStatistics() { const QString url = buildUrl("/v1/attendance/statistics"); qInfo() << "[ApiClient] GET" << url; QNetworkReply *reply = m_networkManager.get(createRequest(url, true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QByteArray data = decryptedReplyBody(reply); const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); qInfo() << "[ApiClient] statistics status=" << statusCode << "error=" << reply->error() << "bytes=" << data.size() << "body=" << data.left(200); if (reply->error() != QNetworkReply::NoError) { emitNetworkError(QStringLiteral("加载统计失败"), reply); reply->deleteLater(); return; } if (data.isEmpty()) { emit networkError("统计数据为空"); reply->deleteLater(); return; } const QJsonDocument doc = QJsonDocument::fromJson(data); const QJsonObject object = doc.object(); emit statisticsFetched(object.value("total").toInt(), object.value("today").toInt(), object.value("server_time").toString()); reply->deleteLater(); }); } // 函数作用:请求服务端清空全部考勤记录。 void ApiClient::clearAllRecords() { QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/attendance/records/clear"), true, true), encryptedJsonBody("{}")); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { if (isConnectionError(reply->error())) { emit connectionUnavailable(QStringLiteral("清空失败: %1").arg(reply->errorString())); } emit recordsCleared(false, reply->errorString()); reply->deleteLater(); return; } const QJsonObject object = QJsonDocument::fromJson(data).object(); emit recordsCleared(object.value("success").toBool(), object.value("message").toString()); reply->deleteLater(); }); } // 函数作用:根据时间戳和学号删除一条考勤记录。 void ApiClient::deleteRecord(const QString ×tamp, const QString &studentId) { QNetworkReply *reply = m_networkManager.deleteResource(createRequest(buildUrl("/v1/attendance/records", { {"timestamp", timestamp}, {"student_id", studentId} }), true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { if (isConnectionError(reply->error())) { emit connectionUnavailable(QStringLiteral("记录删除失败: %1").arg(reply->errorString())); } emit recordDeleted(false, reply->errorString()); reply->deleteLater(); return; } const QJsonObject object = QJsonDocument::fromJson(data).object(); emit recordDeleted(object.value("success").toBool(), object.value("message").toString()); reply->deleteLater(); }); } // 函数作用:从服务端加载签到时间段配置。 void ApiClient::fetchTimeSlots() { QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/config/time-slots"), true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { emitNetworkError(QStringLiteral("加载时间段失败"), reply); reply->deleteLater(); return; } QVector timeSlots; const QJsonObject root = QJsonDocument::fromJson(data).object(); const QJsonArray array = root.value("time_slots").toArray(); // 时间使用 HH:mm 字符串传输,进入模型前转换为 QTime,便于后续编辑和格式化。 for (const QJsonValue &value : array) { const QJsonObject object = value.toObject(); timeSlots.push_back({ object.value("name").toString(), QTime::fromString(object.value("start_time").toString(), "HH:mm"), QTime::fromString(object.value("end_time").toString(), "HH:mm"), object.value("enabled").toBool(true) }); } emit timeSlotsFetched(timeSlots); reply->deleteLater(); }); } // 函数作用:上传签到时间段配置到服务端。 void ApiClient::uploadTimeSlots(const QVector &timeSlots) { QJsonArray array; // 字段名与服务端 Flask 接口保持一致:name/start_time/end_time/enabled。 for (const TimeSlot &slot : timeSlots) { QJsonObject object; object["name"] = slot.name; object["start_time"] = slot.startTime.toString("HH:mm"); object["end_time"] = slot.endTime.toString("HH:mm"); object["enabled"] = slot.enabled; array.append(object); } QJsonObject payload; payload["time_slots"] = array; const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact); // 日志打印明文 JSON 只用于本地调试,真正发送前仍会在 encryptedJsonBody 中加密。 qInfo() << "[ApiClient] POST" << buildUrl("/v1/config/time-slots") << "bytes=" << body.size() << "body=" << body.left(300); QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/config/time-slots"), true, true), encryptedJsonBody(body)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { if (isConnectionError(reply->error())) { emit connectionUnavailable(QStringLiteral("时间段同步失败: %1").arg(reply->errorString())); } emit timeSlotsUploaded(false, reply->errorString()); reply->deleteLater(); return; } const QJsonObject object = QJsonDocument::fromJson(data).object(); emit timeSlotsUploaded(object.value("success").toBool(), object.value("message").toString()); reply->deleteLater(); }); } // 函数作用:从服务端加载学生档案列表。 void ApiClient::fetchStudents() { QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/students"), true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { emitNetworkError(QStringLiteral("加载学生档案失败"), reply); reply->deleteLater(); return; } QVector students; const QJsonArray array = QJsonDocument::fromJson(data).object().value("students").toArray(); students.reserve(array.size()); // className、faceIds、bindingStatus 是控制器派生字段,接口层保持为空。 for (const QJsonValue &value : array) { const QJsonObject object = value.toObject(); students.push_back({ object.value("student_id").toString().trimmed(), object.value("student_name").toString().trimmed(), object.value("class_id").toString().trimmed(), {}, {}, {} }); } emit studentsFetched(students); reply->deleteLater(); }); } // 函数作用:上传学生档案列表到服务端。 void ApiClient::uploadStudents(const QVector &students) { QJsonArray array; // 空学号的临时编辑行不上传,避免服务端产生无主键学生。 for (const StudentProfileListModel::Item &student : students) { const QString studentId = student.studentId.trimmed(); if (studentId.isEmpty()) { continue; } QJsonObject object; object["student_id"] = studentId; object["student_name"] = student.studentName.trimmed(); object["class_id"] = student.classId.trimmed(); array.append(object); } QJsonObject payload; payload["students"] = array; const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact); // 学生档案作为完整快照上传,服务端收到后以当前数组覆盖或同步对应表。 QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/students"), true, true), encryptedJsonBody(body)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { if (isConnectionError(reply->error())) { emit connectionUnavailable(QStringLiteral("学生档案保存失败: %1").arg(reply->errorString())); } emit studentsUploaded(false, reply->errorString()); reply->deleteLater(); return; } const QJsonObject object = QJsonDocument::fromJson(data).object(); emit studentsUploaded(object.value("success").toBool(), object.value("message").toString()); reply->deleteLater(); }); } // 函数作用:从服务端加载班级配置。 void ApiClient::fetchClasses() { QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/classes"), true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { emitNetworkError(QStringLiteral("加载班级配置失败"), reply); reply->deleteLater(); return; } QMap classMap; const QJsonArray array = QJsonDocument::fromJson(data).object().value("classes").toArray(); // 空班级编号不进入映射表,防止下拉框出现无法保存的无效选项。 for (const QJsonValue &value : array) { const QJsonObject object = value.toObject(); const QString classId = object.value("class_id").toString().trimmed(); if (!classId.isEmpty()) { classMap[classId] = object.value("class_name").toString().trimmed(); } } emit classesFetched(classMap); reply->deleteLater(); }); } // 函数作用:上传班级配置到服务端。 void ApiClient::uploadClasses(const QMap &classNameMap) { QJsonArray array; // QMap 会按 key 排序,上传顺序稳定,便于服务端日志和调试比对。 for (auto it = classNameMap.cbegin(); it != classNameMap.cend(); ++it) { const QString classId = it.key().trimmed(); if (classId.isEmpty()) { continue; } QJsonObject object; object["class_id"] = classId; object["class_name"] = it.value().trimmed(); array.append(object); } QJsonObject payload; payload["classes"] = array; const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact); QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/classes"), true, true), encryptedJsonBody(body)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { if (isConnectionError(reply->error())) { emit connectionUnavailable(QStringLiteral("班级配置保存失败: %1").arg(reply->errorString())); } emit classesUploaded(false, reply->errorString()); reply->deleteLater(); return; } const QJsonObject object = QJsonDocument::fromJson(data).object(); emit classesUploaded(object.value("success").toBool(), object.value("message").toString()); reply->deleteLater(); }); } // 函数作用:从服务端加载人脸档案绑定关系。 void ApiClient::fetchFaceProfiles() { QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/face_profiles"), true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { emitNetworkError(QStringLiteral("加载人脸档案失败"), reply); reply->deleteLater(); return; } QVector faces; const QJsonArray array = QJsonDocument::fromJson(data).object().value("face_profiles").toArray(); faces.reserve(array.size()); for (const QJsonValue &value : array) { const QJsonObject object = value.toObject(); faces.push_back({ object.value("face_id").toString().trimmed(), object.value("student_id").toString().trimmed(), object.value("has_photo").toBool(false), {}, {}, {}, {}, object.value("status").toString().trimmed() }); } emit faceProfilesFetched(faces); reply->deleteLater(); }); } // 函数作用:上传人脸档案绑定关系到服务端。 void ApiClient::uploadFaceProfiles(const QVector &faces) { QJsonArray array; // 人脸编号是服务端保存模板和照片的主键;空编号跳过,避免破坏已有档案。 for (const FaceListModel::Item &face : faces) { const QString faceId = face.faceId.trimmed(); if (faceId.isEmpty()) { continue; } QJsonObject object; object["face_id"] = faceId; object["student_id"] = face.studentId.trimmed(); // status 根据绑定状态自动生成,界面不直接编辑该字段。 object["status"] = face.studentId.trimmed().isEmpty() ? QStringLiteral("unbound") : QStringLiteral("active"); array.append(object); } QJsonObject payload; payload["face_profiles"] = array; const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact); QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/face_profiles"), true, true), encryptedJsonBody(body)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { if (isConnectionError(reply->error())) { emit connectionUnavailable(QStringLiteral("人脸档案保存失败: %1").arg(reply->errorString())); } emit faceProfilesUploaded(false, reply->errorString()); reply->deleteLater(); return; } const QJsonObject object = QJsonDocument::fromJson(data).object(); emit faceProfilesUploaded(object.value("success").toBool(), object.value("message").toString()); reply->deleteLater(); }); } // 函数作用:从服务端加载已保存照片的人脸编号列表。 void ApiClient::fetchFacePhotos() { QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/photos"), true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { emitNetworkError(QStringLiteral("加载照片列表失败"), reply); reply->deleteLater(); return; } QStringList ids; const QJsonArray photos = QJsonDocument::fromJson(data).object().value("photos").toArray(); // 只把照片编号交给控制器,避免在一个接口里一次性加载大量图片数据。 for (const QJsonValue &value : photos) { ids.push_back(value.toObject().value("id").toString()); } emit facePhotosFetched(ids); reply->deleteLater(); }); } // 函数作用:按人脸编号下载单张人脸照片。 void ApiClient::fetchFacePhoto(const QString &faceId) { if (faceId.trimmed().isEmpty()) { emit facePhotoFetched(faceId, {}, false, QStringLiteral("人脸ID为空")); return; } const QString encodedId = QString::fromUtf8(QUrl::toPercentEncoding(faceId.trimmed())); QNetworkRequest request = createRequest(buildUrl(QStringLiteral("/v1/faces/photos/%1").arg(encodedId))); // 照片接口返回 JPEG 二进制,不走 JSON 解密流程。 request.setRawHeader("Accept", "image/jpeg,*/*"); QNetworkReply *reply = m_networkManager.get(request); connect(reply, &QNetworkReply::finished, this, [this, reply, faceId]() { const QByteArray data = reply->readAll(); const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); if (reply->error() != QNetworkReply::NoError || statusCode >= 400 || data.isEmpty()) { const QString message = reply->error() != QNetworkReply::NoError ? reply->errorString() : QStringLiteral("HTTP %1").arg(statusCode); // 下载失败只影响单张照片显示,不阻断其它照片继续加载。 emit facePhotoFetched(faceId, {}, false, message); reply->deleteLater(); return; } emit facePhotoFetched(faceId, data, true, QString()); reply->deleteLater(); }); } // 函数作用:删除指定学生对应的人脸数据,并可选择删除其考勤记录。 void ApiClient::deleteFace(const QString &studentId, bool deleteRecords) { QString baseUrl = m_serverUrl.trimmed(); while (baseUrl.endsWith('/')) { baseUrl.chop(1); } QString url = baseUrl + "/api/v1/faces/" + QString::fromLatin1(QUrl::toPercentEncoding(studentId)); if (deleteRecords) { // delete_records=true 表示删除人脸时同步清除该学生关联的考勤记录。 url += "?delete_records=true"; } QNetworkReply *reply = m_networkManager.deleteResource(createRequest(url, true)); connect(reply, &QNetworkReply::finished, this, [this, reply, studentId]() { const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { if (isConnectionError(reply->error())) { emit connectionUnavailable(QStringLiteral("删除失败: %1").arg(reply->errorString())); } emit faceDeleted(false, studentId, 0, reply->errorString()); reply->deleteLater(); return; } const QJsonObject object = QJsonDocument::fromJson(data).object(); emit faceDeleted(object.value("success").toBool(), studentId, object.value("records_deleted").toInt(), object.value("message").toString()); reply->deleteLater(); }); } // 函数作用:请求服务端健康检查接口,判断当前连接是否可用。 void ApiClient::checkHealth() { // 健康检查使用最轻量的系统接口,供启动同步、手动测试和自动重连共用。 QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/system/health"), true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { // 健康检查只通过 healthChecked 返回结果,避免普通 networkError toast 反复刷屏。 emit healthChecked(false, reply->errorString()); reply->deleteLater(); return; } const QJsonObject object = QJsonDocument::fromJson(data).object(); const bool healthy = object.value("status").toString() == "healthy"; emit healthChecked(healthy, healthy ? QStringLiteral("连接成功") : QStringLiteral("服务器无响应")); reply->deleteLater(); }); }