This commit is contained in:
2026-05-05 00:16:23 +08:00
parent fca1ea21d2
commit b0854509fe
9 changed files with 936 additions and 649 deletions
+228 -147
View File
@@ -77,7 +77,8 @@ QNetworkRequest ApiClient::createRequest(const QString &url, bool encryptedRespo
request.setRawHeader("X-Api-Token", m_apiToken.toUtf8());
request.setRawHeader("Accept", "application/json");
if (jsonBody) {
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/octet-stream");
request.setRawHeader("X-Encrypted", "AES128-CBC");
}
request.setTransferTimeout(NetworkTimeoutMs);
return request;
@@ -91,9 +92,9 @@ void ApiClient::fetchSyncState(qulonglong lastVersion)
});
qInfo() << "[ApiClient] GET sync state" << url;
QNetworkReply *reply = m_networkManager.get(createRequest(url));
QNetworkReply *reply = m_networkManager.get(createRequest(url, true));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
const QByteArray data = decryptedReplyBody(reply);
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("同步状态加载失败"), reply);
reply->deleteLater();
@@ -137,36 +138,70 @@ void ApiClient::fetchSyncState(qulonglong lastVersion)
emit timeSlotsFetched(timeSlots);
}
if (state.contains("mapping_config")) {
const QJsonObject mappingConfig = state.value("mapping_config").toObject();
if (state.contains("students") || state.contains("classes")) {
QMap<QString, QString> studentMap;
QMap<QString, QString> studentClassMap;
QMap<QString, QString> classMap;
const QJsonObject studentObject = mappingConfig.value("student_name_map").toObject();
for (auto it = studentObject.begin(); it != studentObject.end(); ++it) {
studentMap[it.key()] = it.value().toString();
const 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 QJsonObject studentClassObject = mappingConfig.value("student_class_map").toObject();
for (auto it = studentClassObject.begin(); it != studentClassObject.end(); ++it) {
studentClassMap[it.key()] = it.value().toString();
const QJsonArray classes = state.value("classes").toArray();
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();
}
}
const QJsonObject classObject = mappingConfig.value("class_name_map").toObject();
for (auto it = classObject.begin(); it != classObject.end(); ++it) {
classMap[it.key()] = it.value().toString();
QVector<StudentProfileListModel::Item> studentItems;
studentItems.reserve(studentMap.size());
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 mappingConfigFetched(studentMap, studentClassMap, classMap);
emit studentsFetched(studentItems);
emit classesFetched(classMap);
}
if (state.contains("face_mappings")) {
const QJsonObject mappings = state.value("face_mappings").toObject();
QMap<QString, QString> mapping;
for (auto it = mappings.begin(); it != mappings.end(); ++it) {
mapping[it.key()] = it.value().toString();
if (state.contains("face_profiles")) {
const QJsonArray profiles = state.value("face_profiles").toArray();
QVector<FaceListModel::Item> 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 faceIdMappingFetched(mapping);
emit faceProfilesFetched(faces);
}
if (state.contains("face_photos")) {
@@ -178,15 +213,6 @@ void ApiClient::fetchSyncState(qulonglong lastVersion)
emit facePhotosFetched(ids);
}
if (state.contains("all_student_ids")) {
QStringList ids;
const QJsonArray array = state.value("all_student_ids").toArray();
for (const QJsonValue &value : array) {
ids.push_back(value.toString());
}
emit allStudentIdsFetched(ids);
}
emit syncStateFetched(version, true);
reply->deleteLater();
});
@@ -204,6 +230,18 @@ QByteArray ApiClient::maybeDecrypt(const QByteArray &data, bool encrypted) const
return CryptoUtils::decryptAes128CbcBase64(trimmed);
}
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);
}
QByteArray ApiClient::encryptedJsonBody(const QByteArray &body) const
{
return CryptoUtils::encryptAes128CbcBase64(body);
}
bool ApiClient::isConnectionError(QNetworkReply::NetworkError error) const
{
switch (error) {
@@ -280,21 +318,20 @@ void ApiClient::fetchRecords(int limit, int offset)
{"offset", QString::number(offset)}
});
qInfo() << "[ApiClient] GET" << url;
QNetworkReply *reply = m_networkManager.get(createRequest(url));
QNetworkReply *reply = m_networkManager.get(createRequest(url, true));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray rawData = reply->readAll();
const QByteArray data = decryptedReplyBody(reply);
const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
qInfo() << "[ApiClient] records status=" << statusCode
<< "error=" << reply->error()
<< "bytes=" << rawData.size()
<< "body=" << rawData.left(200);
<< "bytes=" << data.size()
<< "body=" << data.left(200);
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载记录失败"), reply);
reply->deleteLater();
return;
}
const QByteArray data = maybeDecrypt(rawData, false);
if (data.isEmpty()) {
emit networkError("记录数据为空");
reply->deleteLater();
@@ -320,21 +357,20 @@ void ApiClient::fetchStatistics()
{
const QString url = buildUrl("/v1/attendance/statistics");
qInfo() << "[ApiClient] GET" << url;
QNetworkReply *reply = m_networkManager.get(createRequest(url));
QNetworkReply *reply = m_networkManager.get(createRequest(url, true));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray rawData = reply->readAll();
const QByteArray data = decryptedReplyBody(reply);
const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
qInfo() << "[ApiClient] statistics status=" << statusCode
<< "error=" << reply->error()
<< "bytes=" << rawData.size()
<< "body=" << rawData.left(200);
<< "bytes=" << data.size()
<< "body=" << data.left(200);
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载统计失败"), reply);
reply->deleteLater();
return;
}
const QByteArray data = maybeDecrypt(rawData, false);
if (data.isEmpty()) {
emit networkError("统计数据为空");
reply->deleteLater();
@@ -352,9 +388,10 @@ void ApiClient::fetchStatistics()
void ApiClient::clearAllRecords()
{
QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/attendance/records/clear"), false, true), QByteArray());
QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/attendance/records/clear"), true, true),
encryptedJsonBody("{}"));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
const QByteArray data = decryptedReplyBody(reply);
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("清空失败: %1").arg(reply->errorString()));
@@ -375,9 +412,9 @@ void ApiClient::deleteRecord(const QString &timestamp, 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 = reply->readAll();
const QByteArray data = decryptedReplyBody(reply);
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("记录删除失败: %1").arg(reply->errorString()));
@@ -395,9 +432,9 @@ void ApiClient::deleteRecord(const QString &timestamp, const QString &studentId)
void ApiClient::fetchTimeSlots()
{
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/config/time-slots")));
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/config/time-slots"), true));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
const QByteArray data = decryptedReplyBody(reply);
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载时间段失败"), reply);
reply->deleteLater();
@@ -439,9 +476,10 @@ void ApiClient::uploadTimeSlots(const QVector<TimeSlot> &timeSlots)
qInfo() << "[ApiClient] POST" << buildUrl("/v1/config/time-slots")
<< "bytes=" << body.size()
<< "body=" << body.left(300);
QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/config/time-slots"), false, true), body);
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 = reply->readAll();
const QByteArray data = decryptedReplyBody(reply);
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("时间段同步失败: %1").arg(reply->errorString()));
@@ -456,139 +494,203 @@ void ApiClient::uploadTimeSlots(const QVector<TimeSlot> &timeSlots)
});
}
void ApiClient::fetchMappingConfig()
void ApiClient::fetchStudents()
{
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/config/mappings")));
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/students"), true));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
const QByteArray data = decryptedReplyBody(reply);
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载映射配置失败"), reply);
emitNetworkError(QStringLiteral("加载学生档案失败"), reply);
reply->deleteLater();
return;
}
QVector<StudentProfileListModel::Item> students;
const QJsonArray array = QJsonDocument::fromJson(data).object().value("students").toArray();
students.reserve(array.size());
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<StudentProfileListModel::Item> &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;
}
const QJsonObject root = QJsonDocument::fromJson(data).object();
QMap<QString, QString> studentMap;
QMap<QString, QString> studentClassMap;
QMap<QString, QString> classMap;
const QJsonObject studentObject = root.value("student_name_map").toObject();
for (auto it = studentObject.begin(); it != studentObject.end(); ++it) {
studentMap[it.key()] = it.value().toString();
const 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();
}
}
const QJsonObject studentClassObject = root.value("student_class_map").toObject();
for (auto it = studentClassObject.begin(); it != studentClassObject.end(); ++it) {
studentClassMap[it.key()] = it.value().toString();
}
const QJsonObject classObject = root.value("class_name_map").toObject();
for (auto it = classObject.begin(); it != classObject.end(); ++it) {
classMap[it.key()] = it.value().toString();
}
emit mappingConfigFetched(studentMap, studentClassMap, classMap);
emit classesFetched(classMap);
reply->deleteLater();
});
}
void ApiClient::uploadMappingConfig(const QMap<QString, QString> &studentNameMap,
const QMap<QString, QString> &studentClassMap,
const QMap<QString, QString> &classNameMap)
void ApiClient::uploadClasses(const QMap<QString, QString> &classNameMap)
{
QJsonObject studentObject;
for (auto it = studentNameMap.begin(); it != studentNameMap.end(); ++it) {
studentObject[it.key()] = it.value();
QJsonArray array;
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 studentClassObject;
for (auto it = studentClassMap.begin(); it != studentClassMap.end(); ++it) {
studentClassObject[it.key()] = it.value();
}
QJsonObject classObject;
for (auto it = classNameMap.begin(); it != classNameMap.end(); ++it) {
classObject[it.key()] = it.value();
}
QJsonObject payload;
payload["student_name_map"] = studentObject;
payload["student_class_map"] = studentClassObject;
payload["class_name_map"] = classObject;
QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/config/mappings"), false, true),
QJsonDocument(payload).toJson(QJsonDocument::Compact));
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 = reply->readAll();
const QByteArray data = decryptedReplyBody(reply);
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("映射保存失败: %1").arg(reply->errorString()));
emit connectionUnavailable(QStringLiteral("班级配置保存失败: %1").arg(reply->errorString()));
}
emit mappingConfigUploaded(false, reply->errorString());
emit classesUploaded(false, reply->errorString());
reply->deleteLater();
return;
}
const QJsonObject object = QJsonDocument::fromJson(data).object();
emit mappingConfigUploaded(object.value("success").toBool(), object.value("message").toString());
emit classesUploaded(object.value("success").toBool(), object.value("message").toString());
reply->deleteLater();
});
}
void ApiClient::fetchFaceIdMapping()
void ApiClient::fetchFaceProfiles()
{
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/mappings")));
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/face_profiles"), true));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
const QByteArray data = decryptedReplyBody(reply);
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载人脸映射失败"), reply);
emitNetworkError(QStringLiteral("加载人脸档案失败"), reply);
reply->deleteLater();
return;
}
const QJsonObject mappings = QJsonDocument::fromJson(data).object().value("mappings").toObject();
QMap<QString, QString> mapping;
for (auto it = mappings.begin(); it != mappings.end(); ++it) {
mapping[it.key()] = it.value().toString();
QVector<FaceListModel::Item> 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 faceIdMappingFetched(mapping);
emit faceProfilesFetched(faces);
reply->deleteLater();
});
}
void ApiClient::uploadFaceIdMapping(const QMap<QString, QString> &mapping)
void ApiClient::uploadFaceProfiles(const QVector<FaceListModel::Item> &faces)
{
QJsonObject mappings;
for (auto it = mapping.begin(); it != mapping.end(); ++it) {
if (!it.value().trimmed().isEmpty()) {
mappings[it.key()] = it.value();
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();
object["status"] = face.studentId.trimmed().isEmpty() ? QStringLiteral("unbound") : QStringLiteral("active");
array.append(object);
}
QJsonObject payload;
payload["mappings"] = mappings;
QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/faces/mappings"), false, true),
QJsonDocument(payload).toJson(QJsonDocument::Compact));
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 = reply->readAll();
const QByteArray data = decryptedReplyBody(reply);
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("人脸映射保存失败: %1").arg(reply->errorString()));
emit connectionUnavailable(QStringLiteral("人脸档案保存失败: %1").arg(reply->errorString()));
}
emit faceIdMappingUploaded(false, reply->errorString());
emit faceProfilesUploaded(false, reply->errorString());
reply->deleteLater();
return;
}
const QJsonObject object = QJsonDocument::fromJson(data).object();
emit faceIdMappingUploaded(object.value("success").toBool(), object.value("message").toString());
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")));
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/photos"), true));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
const QByteArray data = decryptedReplyBody(reply);
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载照片列表失败"), reply);
reply->deleteLater();
@@ -634,27 +736,6 @@ void ApiClient::fetchFacePhoto(const QString &faceId)
});
}
void ApiClient::fetchAllStudentIds()
{
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/student-ids")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载学生ID失败"), reply);
reply->deleteLater();
return;
}
QStringList ids;
const QJsonArray array = QJsonDocument::fromJson(data).object().value("student_ids").toArray();
for (const QJsonValue &value : array) {
ids.push_back(value.toString());
}
emit allStudentIdsFetched(ids);
reply->deleteLater();
});
}
void ApiClient::deleteFace(const QString &studentId, bool deleteRecords)
{
QString baseUrl = m_serverUrl.trimmed();
@@ -665,9 +746,9 @@ void ApiClient::deleteFace(const QString &studentId, bool deleteRecords)
if (deleteRecords) {
url += "?delete_records=true";
}
QNetworkReply *reply = m_networkManager.deleteResource(createRequest(url));
QNetworkReply *reply = m_networkManager.deleteResource(createRequest(url, true));
connect(reply, &QNetworkReply::finished, this, [this, reply, studentId]() {
const QByteArray data = reply->readAll();
const QByteArray data = decryptedReplyBody(reply);
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("删除失败: %1").arg(reply->errorString()));
@@ -688,9 +769,9 @@ void ApiClient::deleteFace(const QString &studentId, bool deleteRecords)
void ApiClient::checkHealth()
{
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/system/health")));
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/system/health"), true));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
const QByteArray data = decryptedReplyBody(reply);
if (reply->error() != QNetworkReply::NoError) {
emit healthChecked(false, reply->errorString());
reply->deleteLater();