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
+8 -8
View File
@@ -19,9 +19,9 @@ Qt 管理端用于连接考勤服务端,完成以下工作:
6. 导出统计 CSV。
7. 配置服务端地址和 API Token。
8. 新增、编辑、删除、清空签到时间段。
9. 配置学号到姓名、学号到班级编号、班级编号到班级名称的显示映射。
9. 维护班级信息。
10. 查看 K210 上传的人脸照片。
11. 为 K210 face_id 绑定真实学号。
11. 为 K210 face_id 编辑真实学号、姓名和班级。
12. 删除人脸信息,并可选择删除关联考勤记录。
## 2. 当前重要行为
@@ -60,11 +60,9 @@ API Token 不写入源码。可以通过“配置中心”保存,也可以通
$env:ATTENDANCE_API_TOKEN="你的Token"
```
### 2.4 响应加密
### 2.4 加密传输
服务端支持通过请求头 `X-Encrypted-Response: true` 返回 AES 加密响应,Qt 代码中保留了解密能力。
当前普通记录和统计请求没有默认开启这个请求头,实际按 JSON 明文解析。
除照片 JPEG 下载外,Qt 的业务 JSON 请求和响应统一使用 AES-128-CBC + PKCS#7 + Base64。GET/DELETE 请求会要求服务端返回加密响应,POST JSON 会先加密请求体并携带 `X-Encrypted: AES128-CBC`。
## 3. 技术栈
@@ -88,7 +86,7 @@ QT/
│ ├── ApiClient.* # HTTP API 客户端
│ ├── AppController.* # 业务逻辑中枢
│ ├── AttendanceTypes.h # 数据结构
│ ├── CryptoUtils.* # AES 解密工具
│ ├── CryptoUtils.* # AES 加解密工具
│ └── *ListModel.* # QML 列表模型
├── CMakeLists.txt
├── clear_qt_cache.ps1 # 清理 Qt Creator/CMake 本地缓存
@@ -156,6 +154,8 @@ powershell -ExecutionPolicy Bypass -File .\clear_qt_cache.ps1
| 保存 | 同时保存服务器地址和 API Token 到 `QSettings`,并切换 `ApiClient` 配置 |
| 测试连接 | 使用当前配置请求 `/api/v1/system/health` |
配置中心还包含签到时间段配置和班级管理。班级区域只负责从服务器加载班级、保存班级、新增班级和删除班级;学生学号、姓名和班级归属在人脸管理页点击“编辑”后维护。
## 10. 本地数据
Qt 会保存这些本地数据:
@@ -165,7 +165,7 @@ Qt 会保存这些本地数据:
| 服务端地址 | `QSettings`,键名 `server/url` |
| API Token | `QSettings`,键名 `server/apiToken` |
| 时间段配置 | `QStandardPaths::AppDataLocation/timeslot_config.json` |
| 学生/班级映射 | `QStandardPaths::AppDataLocation/mapping_config.json` |
| 学生、班级与人脸档案缓存 | `QStandardPaths::AppDataLocation/profiles_cache.json` |
| 人脸照片缓存 | `QStandardPaths::AppDataLocation/face_photos/*.jpg` |
## 11. 常见问题
+216 -291
View File
@@ -35,6 +35,12 @@ ApplicationWindow {
property string statisticsPreset: "最近30天"
property int editingTimeSlotIndex: -1
property string pendingDeleteFaceId: ""
property int pendingStudentIdChangeIndex: -1
property string pendingStudentIdChangeNewId: ""
property string pendingStudentIdChangeName: ""
property string pendingStudentIdChangeClassDisplay: ""
property string editingFaceId: ""
property string editingFaceOriginalStudentId: ""
property string toastMessage: ""
property bool toastVisible: false
property bool resizingWindow: false
@@ -516,6 +522,20 @@ ApplicationWindow {
markMappingsDirty();
}
function requestStudentProfileCommit(index, studentId, studentName, classDisplay) {
var impactMessage = appController.studentIdChangeImpactMessage(index, studentId);
if (impactMessage.length > 0) {
pendingStudentIdChangeIndex = index;
pendingStudentIdChangeNewId = studentId;
pendingStudentIdChangeName = studentName;
pendingStudentIdChangeClassDisplay = classDisplay;
studentIdChangeDialog.text = impactMessage;
studentIdChangeDialog.open();
return;
}
commitStudentProfile(index, studentId, studentName, classDisplay);
}
function studentProfileMatches(searchText, classFilter, bindingFilter, studentId, studentName, classId, className, faceIds) {
var normalizedSearch = searchText ? searchText.trim().toLowerCase() : "";
var classFilterId = appController.classIdFromDisplay(classFilter);
@@ -535,6 +555,39 @@ ApplicationWindow {
return haystack.indexOf(normalizedSearch) >= 0;
}
function openFaceEditor(faceId, studentId, studentName, classId) {
editingFaceId = faceId;
editingFaceOriginalStudentId = studentId;
faceEditStudentIdField.text = studentId;
faceEditStudentNameField.text = studentName;
faceEditClassCombo.currentIndex = classComboIndex(classId);
faceEditDialog.open();
}
function saveFaceEditorNow() {
var savedLocally = appController.editFaceProfile(editingFaceId,
faceEditStudentIdField.text,
faceEditStudentNameField.text,
appController.classIdFromDisplay(faceEditClassCombo.currentText));
if (!savedLocally) {
faceEditDialog.open();
return;
}
markMappingsDirty();
appController.saveFaceMappings();
}
function requestSaveFaceEditor() {
var oldId = editingFaceOriginalStudentId ? editingFaceOriginalStudentId.trim() : "";
var newId = faceEditStudentIdField.text ? faceEditStudentIdField.text.trim() : "";
if (oldId.length > 0 && oldId !== newId) {
faceStudentIdChangeDialog.text = "修改该人脸绑定会影响识别后发送给 STM32 的学号、考勤记录显示、班级筛选和导出结果。确定继续吗?";
faceStudentIdChangeDialog.open();
return;
}
saveFaceEditorNow();
}
Component.onCompleted: {
applyRecordFilters();
applyStatisticsPreset();
@@ -548,7 +601,8 @@ ApplicationWindow {
return;
}
if (appController.statusMessage.indexOf("映射配置已保存") >= 0
|| appController.statusMessage.indexOf("映射配置已同步") >= 0) {
|| appController.statusMessage.indexOf("映射配置已同步") >= 0
|| appController.statusMessage.indexOf("人脸档案已保存") >= 0) {
mappingsDirty = false;
}
toastMessage = appController.statusMessage;
@@ -572,7 +626,7 @@ ApplicationWindow {
id: recordExportDialog
fileMode: FileDialog.SaveFile
nameFilters: ["CSV 文件 (*.csv)"]
currentFile: "attendance_export.csv"
defaultSuffix: "csv"
onAccepted: appController.exportFilteredRecords(selectedFile)
}
@@ -580,7 +634,7 @@ ApplicationWindow {
id: statisticsExportDialog
fileMode: FileDialog.SaveFile
nameFilters: ["CSV 文件 (*.csv)"]
currentFile: "attendance_statistics.csv"
defaultSuffix: "csv"
onAccepted: appController.exportStatistics(selectedFile)
}
@@ -784,7 +838,7 @@ ApplicationWindow {
spacing: 12
Label {
text: "确定删除 ID 为 " + pendingDeleteFaceId + " 的人脸信息吗?"
text: "确定删除人脸编号 " + pendingDeleteFaceId + " 吗?"
wrapMode: Text.WordWrap
font.pixelSize: 18
font.weight: appBoldWeight
@@ -801,6 +855,116 @@ ApplicationWindow {
onAccepted: appController.deleteFace(pendingDeleteFaceId, deleteFaceRecordsCheck.checked)
}
Dialog {
id: faceEditDialog
modal: true
anchors.centerIn: Overlay.overlay
width: phoneLayout ? Math.min(window.width - 32, 420) : 460
title: "编辑人脸档案"
standardButtons: Dialog.Save | Dialog.Cancel
background: Rectangle {
radius: 20
color: panelStrong
border.color: lineColor
}
enter: Transition {
ParallelAnimation {
NumberAnimation {
property: "opacity"
from: 0
to: 1
duration: 150
}
NumberAnimation {
property: "scale"
from: 0.96
to: 1.0
duration: 180
easing.type: Easing.OutCubic
}
}
}
exit: Transition {
ParallelAnimation {
NumberAnimation {
property: "opacity"
from: 1
to: 0
duration: 120
}
NumberAnimation {
property: "scale"
from: 1.0
to: 0.98
duration: 120
}
}
}
contentItem: ColumnLayout {
spacing: 12
Label {
Layout.fillWidth: true
text: "人脸编号: " + editingFaceId
color: ink900
font.pixelSize: 18
font.weight: appBoldWeight
elide: Text.ElideRight
}
AppTextField {
id: faceEditStudentIdField
Layout.fillWidth: true
placeholderText: "真实学号"
}
AppTextField {
id: faceEditStudentNameField
Layout.fillWidth: true
placeholderText: "姓名"
}
AppComboBox {
id: faceEditClassCombo
Layout.fillWidth: true
model: appController.classSelectionOptions
}
Label {
Layout.fillWidth: true
text: "人脸编号由 K210 生成,不要改成学号;这里只编辑学号、姓名和班级。留空学号可解除绑定。"
wrapMode: Text.WordWrap
color: ink500
font.pixelSize: 12
}
}
onAccepted: requestSaveFaceEditor()
}
MessageDialog {
id: faceStudentIdChangeDialog
title: "确认修改绑定学号"
buttons: MessageDialog.Yes | MessageDialog.No
onAccepted: saveFaceEditorNow()
}
MessageDialog {
id: studentIdChangeDialog
title: "确认修改学号"
buttons: MessageDialog.Yes | MessageDialog.No
onAccepted: {
commitStudentProfile(pendingStudentIdChangeIndex,
pendingStudentIdChangeNewId,
pendingStudentIdChangeName,
pendingStudentIdChangeClassDisplay);
}
}
Rectangle {
anchors.fill: parent
anchors.margins: slimLayout ? 8 : 16
@@ -2353,8 +2517,8 @@ ApplicationWindow {
SectionHeader {
Layout.fillWidth: true
title: "名称映射配置"
description: "让学号和班级编号显示为更清晰的真实名称。"
title: "班级管理"
description: "新建、命名和删除班级;学生归属在人脸管理的编辑窗口中维护。"
accent: tealColor
}
@@ -2367,106 +2531,37 @@ ApplicationWindow {
}
GridLayout {
Layout.fillWidth: true
columns: 2
columns: phoneLayout ? 1 : 4
columnSpacing: 8
rowSpacing: 8
SecondaryButton {
Layout.fillWidth: true
visible: mappingsExpanded
text: phoneLayout ? "加载" : "从服务器加载"
text: phoneLayout ? "加载" : "从服务器加载班级"
onClicked: appController.loadMappingsFromServer()
}
PrimaryButton {
Layout.fillWidth: true
visible: mappingsExpanded
text: "保存映射"
text: "保存班级"
fillColor: tealColor
onClicked: appController.saveMappings()
}
}
}
TabBar {
id: mappingTabs
visible: mappingsExpanded
Layout.fillWidth: true
Layout.preferredHeight: visible ? 42 : 0
spacing: 8
padding: 0
background: Rectangle {
color: "transparent"
}
SegmentTabButton {
width: (mappingTabs.width - mappingTabs.spacing) / 2
text: "学生档案"
}
SegmentTabButton {
width: (mappingTabs.width - mappingTabs.spacing) / 2
text: "班级配置"
}
}
StackLayout {
visible: mappingsExpanded
Layout.fillWidth: true
Layout.preferredHeight: visible ? Math.max(phoneLayout ? 168 : 74, mappingTabs.currentIndex === 0 ? studentProfileListView.contentHeight : classMappingListView.contentHeight) : 0
currentIndex: mappingTabs.currentIndex
Rectangle {
color: "transparent"
ListView {
id: studentProfileListView
anchors.fill: parent
clip: false
interactive: false
spacing: 8
model: appController.studentProfileModel
header: ColumnLayout {
width: ListView.view.width
spacing: 8
GridLayout {
Layout.fillWidth: true
columns: phoneLayout ? 1 : 5
columnSpacing: 8
rowSpacing: 8
AppTextField {
Layout.fillWidth: true
placeholderText: "搜索学号、姓名、班级或人脸 ID"
text: appController.studentProfileSearchText
onTextEdited: appController.studentProfileSearchText = text
}
AppComboBox {
Layout.fillWidth: true
model: appController.studentProfileClassFilterOptions
currentIndex: optionIndex(model, appController.studentProfileClassFilter)
onActivated: appController.studentProfileClassFilter = currentText
}
AppComboBox {
Layout.fillWidth: true
model: ["全部", "已绑定", "未绑定"]
currentIndex: optionIndex(model, appController.studentProfileBindingFilter)
onActivated: appController.studentProfileBindingFilter = currentText
}
SecondaryButton {
Layout.fillWidth: true
text: "新增学生"
Layout.fillWidth: phoneLayout
Layout.preferredWidth: phoneLayout ? -1 : 120
visible: mappingsExpanded
text: "新增班级"
onClicked: {
appController.addStudentProfile();
appController.classMappingModel.addEntry("", "");
markMappingsDirty();
}
}
Label {
Layout.fillWidth: phoneLayout
Layout.alignment: Qt.AlignVCenter
visible: mappingsExpanded
text: mappingsDirty ? "有未保存改动" : "配置已保存"
color: mappingsDirty ? goldColor : successColor
font.pixelSize: 12
@@ -2475,184 +2570,16 @@ ApplicationWindow {
}
}
delegate: Rectangle {
id: studentProfileRow
width: ListView.view.width
height: visible ? (phoneLayout ? 168 : 64) : 0
visible: studentProfileMatches(appController.studentProfileSearchText,
appController.studentProfileClassFilter,
appController.studentProfileBindingFilter,
model.studentId,
model.studentName,
model.classId,
model.className,
model.faceIds)
property int rowIndex: index
property string rowClassId: model.classId
property bool hovered: studentProfileHoverHandler.hovered
radius: 8
color: hovered ? "#eef5f4" : (index % 2 === 0 ? "#ffffff" : "#f8f3ec")
border.color: hovered ? "#c7dddd" : (index % 2 === 0 ? "#ece4da" : "#e7ded3")
Behavior on color {
enabled: !resizingWindow
ColorAnimation {
duration: 120
}
}
Behavior on border.color {
enabled: !resizingWindow
ColorAnimation {
duration: 120
}
}
HoverHandler {
id: studentProfileHoverHandler
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
visible: !phoneLayout
spacing: 10
AppTextField {
id: profileStudentIdField
Layout.preferredWidth: 160
text: model.studentId
placeholderText: "学号"
onEditingFinished: commitStudentProfile(studentProfileRow.rowIndex, text, profileStudentNameField.text, profileClassCombo.currentText)
}
AppTextField {
id: profileStudentNameField
Layout.preferredWidth: 150
text: model.studentName
placeholderText: "姓名"
onEditingFinished: commitStudentProfile(studentProfileRow.rowIndex, profileStudentIdField.text, text, profileClassCombo.currentText)
}
AppComboBox {
id: profileClassCombo
Layout.preferredWidth: 220
model: appController.classSelectionOptions
Component.onCompleted: currentIndex = classComboIndex(studentProfileRow.rowClassId)
onModelChanged: currentIndex = classComboIndex(studentProfileRow.rowClassId)
onActivated: commitStudentProfile(studentProfileRow.rowIndex, profileStudentIdField.text, profileStudentNameField.text, currentText)
}
Label {
Layout.preferredWidth: 160
text: model.faceIds.length > 0 ? model.faceIds : "未绑定人脸"
color: model.faceIds.length > 0 ? successColor : ink500
font.pixelSize: 12
elide: Text.ElideRight
}
Item {
Layout.fillWidth: true
}
SecondaryButton {
text: "删除"
onClicked: {
appController.removeStudentProfile(studentProfileRow.rowIndex);
markMappingsDirty();
}
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 12
visible: phoneLayout
spacing: 8
RowLayout {
Layout.fillWidth: true
spacing: 8
AppTextField {
id: profileStudentIdFieldPhone
Layout.fillWidth: true
text: model.studentId
placeholderText: "学号"
onEditingFinished: commitStudentProfile(studentProfileRow.rowIndex, text, profileStudentNameFieldPhone.text, profileClassComboPhone.currentText)
}
AppTextField {
id: profileStudentNameFieldPhone
Layout.fillWidth: true
text: model.studentName
placeholderText: "姓名"
onEditingFinished: commitStudentProfile(studentProfileRow.rowIndex, profileStudentIdFieldPhone.text, text, profileClassComboPhone.currentText)
}
}
RowLayout {
Layout.fillWidth: true
spacing: 8
AppComboBox {
id: profileClassComboPhone
Layout.fillWidth: true
model: appController.classSelectionOptions
Component.onCompleted: currentIndex = classComboIndex(studentProfileRow.rowClassId)
onModelChanged: currentIndex = classComboIndex(studentProfileRow.rowClassId)
onActivated: commitStudentProfile(studentProfileRow.rowIndex, profileStudentIdFieldPhone.text, profileStudentNameFieldPhone.text, currentText)
}
SecondaryButton {
Layout.preferredWidth: 76
text: "删除"
onClicked: {
appController.removeStudentProfile(studentProfileRow.rowIndex);
markMappingsDirty();
}
}
}
Label {
Layout.fillWidth: true
text: model.faceIds.length > 0 ? "绑定人脸: " + model.faceIds : "未绑定人脸"
color: model.faceIds.length > 0 ? successColor : ink500
font.pixelSize: 12
elide: Text.ElideRight
}
}
}
}
}
Rectangle {
color: "transparent"
ListView {
id: classMappingListView
anchors.fill: parent
visible: mappingsExpanded
Layout.fillWidth: true
Layout.preferredHeight: visible ? Math.max(phoneLayout ? 112 : 64, contentHeight) : 0
clip: false
interactive: false
spacing: 8
model: appController.classMappingModel
header: RowLayout {
width: ListView.view.width
spacing: 12
SecondaryButton {
text: "新增班级"
onClicked: {
appController.classMappingModel.addEntry("", "");
markMappingsDirty();
}
}
Item {
Layout.fillWidth: true
}
}
delegate: Rectangle {
width: ListView.view.width
height: phoneLayout ? 112 : 56
@@ -2663,15 +2590,11 @@ ApplicationWindow {
Behavior on color {
enabled: !resizingWindow
ColorAnimation {
duration: 120
}
ColorAnimation { duration: 120 }
}
Behavior on border.color {
enabled: !resizingWindow
ColorAnimation {
duration: 120
}
ColorAnimation { duration: 120 }
}
HoverHandler {
@@ -2713,7 +2636,7 @@ ApplicationWindow {
SecondaryButton {
text: "删除"
onClicked: {
appController.classMappingModel.removeEntry(index);
if (appController.removeClassMapping(index))
markMappingsDirty();
}
}
@@ -2755,7 +2678,7 @@ ApplicationWindow {
Layout.preferredWidth: 76
text: "删除"
onClicked: {
appController.classMappingModel.removeEntry(index);
if (appController.removeClassMapping(index))
markMappingsDirty();
}
}
@@ -2765,8 +2688,6 @@ ApplicationWindow {
}
}
}
}
}
Item {
Layout.fillWidth: true
@@ -2817,8 +2738,8 @@ ApplicationWindow {
SectionHeader {
Layout.fillWidth: true
title: "K210 人脸照片与学号映射"
description: "同步照片状态,维护人脸与真实学号的对应关系。"
title: "K210 人脸照片与档案绑定"
description: "同步照片状态,维护人脸、学号、姓名和班级信息。"
accent: goldColor
}
@@ -2832,7 +2753,7 @@ ApplicationWindow {
onClicked: appController.refreshFaces()
}
PrimaryButton {
text: "保存映射"
text: "保存档案"
onClicked: appController.saveFaceMappings()
}
}
@@ -2853,12 +2774,12 @@ ApplicationWindow {
clip: true
property int cardColumns: phoneLayout ? 1 : Math.max(1, Math.floor(width / (compactLayout ? 236 : 260)))
cellWidth: Math.max(phoneLayout ? 280 : 240, Math.floor(width / cardColumns))
cellHeight: phoneLayout ? 462 : 424
cellHeight: phoneLayout ? 444 : 404
model: appController.faceModel
delegate: Rectangle {
width: Math.min(phoneLayout ? 300 : 244, GridView.view.cellWidth - 18)
height: phoneLayout ? 438 : 400
height: phoneLayout ? 420 : 380
property bool hovered: faceHoverHandler.hovered
radius: 8
color: hovered ? "#fff6ef" : "#ffffff"
@@ -2912,23 +2833,25 @@ ApplicationWindow {
}
Label {
text: "ID: " + model.faceId
text: "人脸编号: " + model.faceId
color: ink900
font.pixelSize: compactLayout ? 15 : 16
font.weight: appBoldWeight
}
AppTextField {
Layout.fillWidth: true
text: model.studentId
placeholderText: narrowLayout ? "学号" : "输入真实学号"
onEditingFinished: appController.updateFaceStudentId(index, text)
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Label {
Layout.fillWidth: true
text: model.studentId.length > 0 ? "学号: " + model.studentId : "学号: 未绑定"
color: model.studentId.length > 0 ? ink700 : goldColor
font.pixelSize: 12
font.weight: model.studentId.length > 0 ? appBoldWeight : Font.Normal
elide: Text.ElideRight
}
Label {
Layout.fillWidth: true
text: model.studentName.length > 0 ? "姓名: " + model.studentName : "姓名: 未建档"
@@ -2950,12 +2873,12 @@ ApplicationWindow {
Layout.fillWidth: true
Layout.preferredHeight: 34
radius: 8
color: model.studentId.length === 0 ? "#f9ebcf" : (model.studentName.length > 0 ? "#e6f4ec" : "#fce8e5")
color: model.studentId.length === 0 ? "#f9ebcf" : (model.studentName.length > 0 ? "#e6f4ec" : "#f9ebcf")
Label {
anchors.centerIn: parent
text: model.studentId.length === 0 ? "未绑定学生" : (model.studentName.length > 0 ? "已绑定学生档案" : "学号未建档")
color: model.studentId.length === 0 ? goldColor : (model.studentName.length > 0 ? successColor : dangerColor)
text: model.studentId.length === 0 ? "未绑定学生" : (model.studentName.length > 0 ? "已绑定学生档案" : "待补全学生信息")
color: model.studentId.length === 0 ? goldColor : (model.studentName.length > 0 ? successColor : goldColor)
font.pixelSize: narrowLayout ? 11 : 12
font.weight: appBoldWeight
}
@@ -2980,9 +2903,18 @@ ApplicationWindow {
Layout.fillHeight: true
}
RowLayout {
Layout.fillWidth: true
SecondaryButton {
Layout.fillWidth: true
text: "编辑"
onClicked: openFaceEditor(model.faceId, model.studentId, model.studentName, model.classId)
}
DangerButton {
Layout.fillWidth: true
text: "删除该人脸"
text: "删除"
onClicked: {
pendingDeleteFaceId = model.faceId;
deleteFaceRecordsCheck.checked = false;
@@ -2991,6 +2923,7 @@ ApplicationWindow {
}
}
}
}
ScrollBar.vertical: AppScrollBar {}
}
@@ -3040,12 +2973,10 @@ ApplicationWindow {
anchors.margins: phoneLayout ? 16 : 24
clip: true
contentWidth: availableWidth
contentHeight: aboutContentLayout.height
ColumnLayout {
id: aboutContentLayout
width: aboutScrollView.availableWidth
height: Math.max(aboutScrollView.availableHeight, implicitHeight)
spacing: phoneLayout ? 12 : 18
SectionHeader {
@@ -3079,12 +3010,6 @@ ApplicationWindow {
lineHeight: 1.25
}
Item {
Layout.fillHeight: true
Layout.minimumHeight: 0
Layout.preferredHeight: 0
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: thesisInfoTitle.implicitHeight + thesisInfoBody.paintedHeight + thesisInfoLayout.spacing + 32
+227 -146
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();
}
}
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 studentsFetched(studentItems);
emit classesFetched(classMap);
}
const QJsonObject classObject = mappingConfig.value("class_name_map").toObject();
for (auto it = classObject.begin(); it != classObject.end(); ++it) {
classMap[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;
}
emit mappingConfigFetched(studentMap, studentClassMap, classMap);
faces.push_back({
faceId,
studentId,
object.value("has_photo").toBool(false),
{},
{},
{},
{},
object.value("status").toString().trimmed()
});
}
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();
}
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 studentClassObject;
for (auto it = studentClassMap.begin(); it != studentClassMap.end(); ++it) {
studentClassObject[it.key()] = it.value();
QJsonObject object;
object["class_id"] = classId;
object["class_name"] = it.value().trimmed();
array.append(object);
}
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();
+16 -14
View File
@@ -10,6 +10,8 @@
#include <QStringList>
#include <QVector>
#include "AttendanceTypes.h"
#include "StudentProfileListModel.h"
#include "FaceListModel.h"
class ApiClient : public QObject
{
@@ -30,15 +32,14 @@ public:
void deleteRecord(const QString &timestamp, const QString &studentId);
void fetchTimeSlots();
void uploadTimeSlots(const QVector<TimeSlot> &timeSlots);
void fetchMappingConfig();
void uploadMappingConfig(const QMap<QString, QString> &studentNameMap,
const QMap<QString, QString> &studentClassMap,
const QMap<QString, QString> &classNameMap);
void fetchFaceIdMapping();
void uploadFaceIdMapping(const QMap<QString, QString> &mapping);
void fetchStudents();
void uploadStudents(const QVector<StudentProfileListModel::Item> &students);
void fetchClasses();
void uploadClasses(const QMap<QString, QString> &classNameMap);
void fetchFaceProfiles();
void uploadFaceProfiles(const QVector<FaceListModel::Item> &faces);
void fetchFacePhotos();
void fetchFacePhoto(const QString &faceId);
void fetchAllStudentIds();
void deleteFace(const QString &studentId, bool deleteRecords);
void checkHealth();
@@ -49,15 +50,14 @@ signals:
void recordDeleted(bool success, const QString &message);
void timeSlotsFetched(const QVector<TimeSlot> &timeSlots);
void timeSlotsUploaded(bool success, const QString &message);
void mappingConfigFetched(const QMap<QString, QString> &studentNameMap,
const QMap<QString, QString> &studentClassMap,
const QMap<QString, QString> &classNameMap);
void mappingConfigUploaded(bool success, const QString &message);
void faceIdMappingFetched(const QMap<QString, QString> &mapping);
void faceIdMappingUploaded(bool success, const QString &message);
void studentsFetched(const QVector<StudentProfileListModel::Item> &students);
void studentsUploaded(bool success, const QString &message);
void classesFetched(const QMap<QString, QString> &classNameMap);
void classesUploaded(bool success, const QString &message);
void faceProfilesFetched(const QVector<FaceListModel::Item> &faces);
void faceProfilesUploaded(bool success, const QString &message);
void facePhotosFetched(const QStringList &photoIds);
void facePhotoFetched(const QString &faceId, const QByteArray &data, bool success, const QString &message);
void allStudentIdsFetched(const QStringList &studentIds);
void faceDeleted(bool success, const QString &studentId, int recordsDeleted, const QString &message);
void healthChecked(bool healthy, const QString &message);
void connectionUnavailable(const QString &message);
@@ -69,6 +69,8 @@ private:
QNetworkRequest createRequest(const QString &url, bool encryptedResponse = false, bool jsonBody = false) const;
QVector<AttendanceRecord> parseRecords(const QByteArray &data) const;
QByteArray maybeDecrypt(const QByteArray &data, bool encrypted) const;
QByteArray decryptedReplyBody(QNetworkReply *reply) const;
QByteArray encryptedJsonBody(const QByteArray &body) const;
void emitNetworkError(const QString &prefix, QNetworkReply *reply);
bool isConnectionError(QNetworkReply::NetworkError error) const;
+250 -84
View File
@@ -198,33 +198,67 @@ AppController::AppController(QObject *parent)
setStatusMessage(success ? QStringLiteral("时间段已保存并同步") : QStringLiteral("时间段同步失败: %1").arg(message));
});
connect(&m_apiClient, &ApiClient::mappingConfigFetched, this,
[this](const QMap<QString, QString> &studentNameMap,
const QMap<QString, QString> &studentClassMap,
const QMap<QString, QString> &classNameMap) {
m_studentMappingModel.setMap(studentNameMap);
m_studentClassMappingModel.setMap(studentClassMap);
m_classMappingModel.setMap(classNameMap);
rebuildStudentProfiles();
connect(&m_apiClient, &ApiClient::studentsFetched, this, [this](const QVector<StudentProfileListModel::Item> &students) {
applyFetchedStudents(students);
saveMappingsToLocal();
updateFilterOptions();
rebuildFilteredRecords();
rebuildStatistics();
setStatusMessage("映射配置已同步");
setStatusMessage(QStringLiteral("学生档案已同步"));
});
connect(&m_apiClient, &ApiClient::mappingConfigUploaded, this, [this](bool success, const QString &message) {
setStatusMessage(success ? QStringLiteral("映射配置已保存") : QStringLiteral("映射保存失败: %1").arg(message));
connect(&m_apiClient, &ApiClient::studentsUploaded, this, [this](bool success, const QString &message) {
if (m_mappingSaveInProgress) {
if (success) {
m_apiClient.uploadClasses(m_pendingClassMap);
setStatusMessage(QStringLiteral("学生档案已保存,正在保存班级"));
} else {
m_mappingSaveInProgress = false;
m_pendingClassMap.clear();
m_pendingFaceItems.clear();
setStatusMessage(QStringLiteral("学生档案保存失败: %1").arg(message));
}
return;
}
setStatusMessage(success ? QStringLiteral("学生档案已保存") : QStringLiteral("学生档案保存失败: %1").arg(message));
});
connect(&m_apiClient, &ApiClient::faceIdMappingFetched, this, [this](const QMap<QString, QString> &mapping) {
m_faceIdMapping = mapping;
mergeFaceItems();
rebuildStudentProfiles();
connect(&m_apiClient, &ApiClient::classesFetched, this, [this](const QMap<QString, QString> &classNameMap) {
applyFetchedClasses(classNameMap);
saveMappingsToLocal();
updateFilterOptions();
rebuildFilteredRecords();
rebuildStatistics();
setStatusMessage(QStringLiteral("班级配置已同步"));
});
connect(&m_apiClient, &ApiClient::faceIdMappingUploaded, this, [this](bool success, const QString &message) {
setStatusMessage(success ? QStringLiteral("人脸映射已保存") : QStringLiteral("人脸映射保存失败: %1").arg(message));
connect(&m_apiClient, &ApiClient::classesUploaded, this, [this](bool success, const QString &message) {
if (m_mappingSaveInProgress) {
if (success) {
m_apiClient.uploadFaceProfiles(m_pendingFaceItems);
setStatusMessage(QStringLiteral("班级配置已保存,正在保存人脸档案"));
} else {
m_mappingSaveInProgress = false;
m_pendingClassMap.clear();
m_pendingFaceItems.clear();
setStatusMessage(QStringLiteral("班级保存失败: %1").arg(message));
}
return;
}
setStatusMessage(success ? QStringLiteral("班级配置已保存") : QStringLiteral("班级保存失败: %1").arg(message));
});
connect(&m_apiClient, &ApiClient::faceProfilesFetched, this, [this](const QVector<FaceListModel::Item> &faces) {
applyFetchedFaceProfiles(faces);
});
connect(&m_apiClient, &ApiClient::faceProfilesUploaded, this, [this](bool success, const QString &message) {
if (m_mappingSaveInProgress) {
m_mappingSaveInProgress = false;
m_pendingClassMap.clear();
m_pendingFaceItems.clear();
}
setStatusMessage(success ? QStringLiteral("人脸档案已保存") : QStringLiteral("人脸档案保存失败: %1").arg(message));
});
connect(&m_apiClient, &ApiClient::facePhotosFetched, this, [this](const QStringList &photoIds) {
@@ -451,7 +485,7 @@ void AppController::setStudentProfileBindingFilter(const QString &bindingFilter)
void AppController::refreshAll()
{
m_apiClient.fetchSyncState(m_qtSyncVersion);
m_apiClient.fetchSyncState(0);
}
void AppController::refreshRecords()
@@ -505,7 +539,7 @@ bool AppController::exportFilteredRecords(const QString &fileUrl)
out << csvCell(record.timestamp) << ","
<< csvCell(effectiveClassId(record)) << ","
<< csvCell(mappedClassName(record)) << ","
<< csvCell(record.studentId) << ","
<< csvCell(effectiveStudentId(record)) << ","
<< csvCell(mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record)) << ","
<< QString::number(record.confidence, 'f', 4) << ","
<< csvCell(record.status) << "\n";
@@ -566,7 +600,9 @@ void AppController::reloadTimeSlotsFromServer()
void AppController::loadMappingsFromServer()
{
m_apiClient.fetchMappingConfig();
m_apiClient.fetchStudents();
m_apiClient.fetchClasses();
m_apiClient.fetchFaceProfiles();
}
void AppController::saveMappings()
@@ -586,10 +622,10 @@ void AppController::saveMappings()
}
saveMappingsToLocal();
m_apiClient.uploadMappingConfig(m_studentMappingModel.toMap(),
m_studentClassMappingModel.toMap(),
m_classMappingModel.toMap());
m_apiClient.uploadFaceIdMapping(m_faceIdMapping);
m_mappingSaveInProgress = true;
m_pendingClassMap = m_classMappingModel.toMap();
m_pendingFaceItems = m_faceModel.items();
m_apiClient.uploadStudents(m_studentProfileModel.profiles());
rebuildStudentProfiles();
mergeFaceItems();
updateFilterOptions();
@@ -630,6 +666,23 @@ void AppController::updateStudentProfile(int index,
emit studentProfilesChanged();
}
QString AppController::studentIdChangeImpactMessage(int index, const QString &newStudentId) const
{
const QVector<StudentProfileListModel::Item> profiles = m_studentProfileModel.profiles();
if (index < 0 || index >= profiles.size()) {
return {};
}
const QString oldStudentId = profiles.at(index).studentId.trimmed();
const QString trimmedNewId = newStudentId.trimmed();
if (oldStudentId.isEmpty() || trimmedNewId.isEmpty() || oldStudentId == trimmedNewId) {
return {};
}
return QStringLiteral("修改学号会同步影响该学生的人脸绑定、考勤记录显示、班级筛选和导出结果。确定将 %1 改为 %2 吗?")
.arg(oldStudentId, trimmedNewId);
}
void AppController::removeStudentProfile(int index)
{
const QVector<StudentProfileListModel::Item> profiles = m_studentProfileModel.profiles();
@@ -653,6 +706,32 @@ void AppController::removeStudentProfile(int index)
emit studentProfilesChanged();
}
bool AppController::removeClassMapping(int index)
{
const QModelIndex modelIndex = m_classMappingModel.index(index);
if (!modelIndex.isValid()) {
return false;
}
const QString classId = m_classMappingModel.data(modelIndex, MappingListModel::KeyRole).toString().trimmed();
const QString normalizedClassId = classId.isEmpty() ? QStringLiteral("0") : classId;
int studentCount = 0;
const QVector<StudentProfileListModel::Item> profiles = m_studentProfileModel.profiles();
for (const StudentProfileListModel::Item &profile : profiles) {
if (profile.classId.trimmed() == normalizedClassId) {
studentCount++;
}
}
if (studentCount > 0) {
setStatusMessage(QStringLiteral("班级 %1 下还有 %2 名学生,不能删除").arg(normalizedClassId).arg(studentCount));
return false;
}
m_classMappingModel.removeEntry(index);
return true;
}
QString AppController::classDisplayForId(const QString &classId) const
{
const QString trimmed = classId.trimmed();
@@ -671,10 +750,73 @@ QString AppController::classIdFromDisplay(const QString &display) const
void AppController::refreshFaces()
{
m_apiClient.fetchFaceIdMapping();
m_apiClient.fetchFaceProfiles();
m_apiClient.fetchFacePhotos();
}
bool AppController::editFaceProfile(const QString &faceId,
const QString &studentId,
const QString &studentName,
const QString &classId)
{
const QString trimmedFaceId = faceId.trimmed();
const QString trimmedStudentId = studentId.trimmed();
const QString trimmedStudentName = studentName.trimmed();
const QString trimmedClassId = classId.trimmed();
if (trimmedFaceId.isEmpty()) {
setStatusMessage(QStringLiteral("人脸编号不能为空"));
return false;
}
if (!trimmedStudentId.isEmpty() && !trimmedClassId.isEmpty()) {
const QMap<QString, QString> classMap = m_classMappingModel.toMap();
if (!classMap.contains(trimmedClassId)) {
setStatusMessage(QStringLiteral("班级 %1 不存在,请先在配置中心新增班级").arg(trimmedClassId));
return false;
}
}
const QString oldStudentId = m_faceIdMapping.value(trimmedFaceId).trimmed();
if (trimmedStudentId.isEmpty()) {
m_faceIdMapping.remove(trimmedFaceId);
} else {
m_faceIdMapping[trimmedFaceId] = trimmedStudentId;
QVector<StudentProfileListModel::Item> profiles = m_studentProfileModel.profiles();
int existingIndex = -1;
int oldStudentIndex = -1;
for (int i = 0; i < profiles.size(); ++i) {
if (profiles.at(i).studentId.trimmed() == trimmedStudentId) {
existingIndex = i;
}
if (!oldStudentId.isEmpty() && profiles.at(i).studentId.trimmed() == oldStudentId) {
oldStudentIndex = i;
}
}
if (existingIndex >= 0) {
m_studentProfileModel.updateProfile(existingIndex, trimmedStudentId, trimmedStudentName, trimmedClassId);
} else if (oldStudentIndex >= 0) {
m_studentProfileModel.updateProfile(oldStudentIndex, trimmedStudentId, trimmedStudentName, trimmedClassId);
} else {
m_studentProfileModel.addProfile(trimmedStudentId, trimmedStudentName, trimmedClassId);
}
applyStudentProfilesToMappings();
}
mergeFaceItems();
rebuildStudentProfiles();
updateFilterOptions();
rebuildFilteredRecords();
rebuildStatistics();
emit studentProfilesChanged();
setStatusMessage(QStringLiteral("人脸档案已更新,正在保存到服务端"));
return true;
}
void AppController::updateFaceStudentId(int index, const QString &studentId)
{
const QVector<FaceListModel::Item> items = m_faceModel.items();
@@ -699,28 +841,7 @@ void AppController::updateFaceStudentId(int index, const QString &studentId)
void AppController::saveFaceMappings()
{
QString validationMessage;
if (!validateFaceMappings(&validationMessage)) {
setStatusMessage(validationMessage);
mergeFaceItems();
return;
}
QMap<QString, QString> mapping;
const QVector<FaceListModel::Item> items = m_faceModel.items();
for (const FaceListModel::Item &item : items) {
const QString faceId = item.faceId.trimmed();
const QString studentId = item.studentId.trimmed();
if (faceId.isEmpty() || studentId.isEmpty()) {
continue;
}
mapping[faceId] = studentId;
}
m_faceIdMapping = mapping;
mergeFaceItems();
rebuildStudentProfiles();
m_apiClient.uploadFaceIdMapping(mapping);
saveMappings();
}
void AppController::deleteFace(const QString &studentId, bool deleteRecords)
@@ -760,7 +881,7 @@ void AppController::saveApiToken()
void AppController::saveMappingsToLocal()
{
const QString path = localSettingsPath("mapping_config.json");
const QString path = localSettingsPath("profiles_cache.json");
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
return;
@@ -785,15 +906,15 @@ void AppController::saveMappingsToLocal()
studentClassObject[it.key()] = it.value();
}
root["student_name_map"] = studentObject;
root["student_class_map"] = studentClassObject;
root["class_name_map"] = classObject;
root["students"] = studentObject;
root["student_classes"] = studentClassObject;
root["classes"] = classObject;
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
}
void AppController::loadMappingsFromLocal()
{
QFile file(localSettingsPath("mapping_config.json"));
QFile file(localSettingsPath("profiles_cache.json"));
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
m_studentMappingModel.clear();
m_studentClassMappingModel.clear();
@@ -803,19 +924,19 @@ void AppController::loadMappingsFromLocal()
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
QMap<QString, QString> studentMap;
const QJsonObject studentObject = root.value("student_name_map").toObject();
const QJsonObject studentObject = root.value("students").toObject();
for (auto it = studentObject.begin(); it != studentObject.end(); ++it) {
studentMap[it.key()] = it.value().toString();
}
QMap<QString, QString> classMap;
const QJsonObject classObject = root.value("class_name_map").toObject();
const QJsonObject classObject = root.value("classes").toObject();
for (auto it = classObject.begin(); it != classObject.end(); ++it) {
classMap[it.key()] = it.value().toString();
}
QMap<QString, QString> studentClassMap;
const QJsonObject studentClassObject = root.value("student_class_map").toObject();
const QJsonObject studentClassObject = root.value("student_classes").toObject();
for (auto it = studentClassObject.begin(); it != studentClassObject.end(); ++it) {
studentClassMap[it.key()] = it.value().toString();
}
@@ -899,7 +1020,7 @@ void AppController::updateFilterOptions()
for (const AttendanceRecord &record : m_allRecords) {
classIds.insert(effectiveClassId(record));
studentIds.insert(record.studentId);
studentIds.insert(effectiveStudentId(record));
}
m_classOptions = {"全部"};
@@ -915,6 +1036,63 @@ void AppController::updateFilterOptions()
emit filtersChanged();
}
void AppController::applyFetchedStudents(const QVector<StudentProfileListModel::Item> &students)
{
QMap<QString, QString> studentNameMap;
QMap<QString, QString> studentClassMap;
for (const StudentProfileListModel::Item &student : students) {
const QString studentId = student.studentId.trimmed();
if (studentId.isEmpty()) {
continue;
}
studentNameMap[studentId] = student.studentName.trimmed();
studentClassMap[studentId] = student.classId.trimmed();
}
m_studentMappingModel.setMap(studentNameMap);
m_studentClassMappingModel.setMap(studentClassMap);
m_studentsLoadedFromServer = true;
for (auto it = m_faceIdMapping.begin(); it != m_faceIdMapping.end();) {
if (!studentNameMap.contains(it.value().trimmed())) {
it = m_faceIdMapping.erase(it);
} else {
++it;
}
}
rebuildStudentProfiles();
mergeFaceItems();
}
void AppController::applyFetchedClasses(const QMap<QString, QString> &classNameMap)
{
m_classMappingModel.setMap(classNameMap);
rebuildStudentProfiles();
mergeFaceItems();
}
void AppController::applyFetchedFaceProfiles(const QVector<FaceListModel::Item> &faces)
{
QMap<QString, QString> mapping;
const QMap<QString, QString> studentNameMap = m_studentMappingModel.toMap();
QStringList faceIds;
for (const FaceListModel::Item &face : faces) {
const QString faceId = face.faceId.trimmed();
if (faceId.isEmpty()) {
continue;
}
if (!faceIds.contains(faceId)) {
faceIds.push_back(faceId);
}
const QString studentId = face.studentId.trimmed();
if (!studentId.isEmpty() && (!m_studentsLoadedFromServer || studentNameMap.contains(studentId))) {
mapping[faceId] = studentId;
}
}
m_faceIdMapping = mapping;
m_facePhotoIds = faceIds;
mergeFaceItems();
rebuildStudentProfiles();
}
void AppController::rebuildStudentProfiles()
{
const QMap<QString, QString> studentNameMap = m_studentMappingModel.toMap();
@@ -926,17 +1104,9 @@ void AppController::rebuildStudentProfiles()
studentIds.insert(it.key());
}
for (auto it = studentClassMap.cbegin(); it != studentClassMap.cend(); ++it) {
if (studentNameMap.contains(it.key())) {
studentIds.insert(it.key());
}
for (auto it = m_faceIdMapping.cbegin(); it != m_faceIdMapping.cend(); ++it) {
if (!it.value().trimmed().isEmpty()) {
studentIds.insert(it.value().trimmed());
}
}
for (const AttendanceRecord &record : m_allRecords) {
if (!record.studentId.trimmed().isEmpty()) {
studentIds.insert(record.studentId.trimmed());
}
}
QStringList sortedIds = studentIds.values();
@@ -1021,19 +1191,7 @@ bool AppController::validateMappings(QString *message) const
}
return false;
}
if (studentName.isEmpty()) {
if (message) {
*message = QStringLiteral("学号 %1 的姓名不能为空").arg(studentId);
}
return false;
}
if (classId.isEmpty()) {
if (message) {
*message = QStringLiteral("学号 %1 的班级不能为空").arg(studentId);
}
return false;
}
if (!classMap.contains(classId)) {
if (!classId.isEmpty() && !classMap.contains(classId)) {
if (message) {
*message = QStringLiteral("学号 %1 选择的班级 %2 不存在").arg(studentId, classId);
}
@@ -1111,7 +1269,8 @@ void AppController::rebuildFilteredRecords()
continue;
}
if (m_selectedStudentId != "全部" && record.studentId != m_selectedStudentId) {
const QString studentId = effectiveStudentId(record);
if (m_selectedStudentId != "全部" && studentId != m_selectedStudentId) {
continue;
}
@@ -1148,7 +1307,7 @@ void AppController::rebuildFilteredRecords()
record.timestamp,
effectiveClassId(record),
mappedClassName(record),
record.studentId,
effectiveStudentId(record),
mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record),
record.confidence,
record.status,
@@ -1171,8 +1330,9 @@ void AppController::rebuildStatistics()
continue;
}
StudentStatistics &stats = statisticsMap[record.studentId];
stats.studentId = record.studentId;
const QString studentId = effectiveStudentId(record);
StudentStatistics &stats = statisticsMap[studentId];
stats.studentId = studentId;
stats.name = mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record);
stats.totalCount += 1;
stats.averageConfidence += record.confidence;
@@ -1215,12 +1375,18 @@ void AppController::rebuildStatistics()
QString AppController::mappedStudentName(const AttendanceRecord &record) const
{
return m_studentMappingModel.toMap().value(record.studentId);
return m_studentMappingModel.toMap().value(effectiveStudentId(record));
}
QString AppController::effectiveStudentId(const AttendanceRecord &record) const
{
const QString recordStudentId = record.studentId.trimmed();
return m_faceIdMapping.value(recordStudentId, recordStudentId).trimmed();
}
QString AppController::effectiveClassId(const AttendanceRecord &record) const
{
return m_studentClassMappingModel.toMap().value(record.studentId, record.classId);
return m_studentClassMappingModel.toMap().value(effectiveStudentId(record), record.classId);
}
QString AppController::mappedClassName(const AttendanceRecord &record) const
+14
View File
@@ -105,10 +105,16 @@ public:
const QString &studentId,
const QString &studentName,
const QString &classId);
Q_INVOKABLE QString studentIdChangeImpactMessage(int index, const QString &newStudentId) const;
Q_INVOKABLE void removeStudentProfile(int index);
Q_INVOKABLE bool removeClassMapping(int index);
Q_INVOKABLE QString classDisplayForId(const QString &classId) const;
Q_INVOKABLE QString classIdFromDisplay(const QString &display) const;
Q_INVOKABLE void refreshFaces();
Q_INVOKABLE bool editFaceProfile(const QString &faceId,
const QString &studentId,
const QString &studentName,
const QString &classId);
Q_INVOKABLE void updateFaceStudentId(int index, const QString &studentId);
Q_INVOKABLE void saveFaceMappings();
Q_INVOKABLE void deleteFace(const QString &studentId, bool deleteRecords);
@@ -136,6 +142,9 @@ private:
void loadTimeSlotsFromLocal();
QString localSettingsPath(const QString &fileName) const;
void updateFilterOptions();
void applyFetchedStudents(const QVector<StudentProfileListModel::Item> &students);
void applyFetchedClasses(const QMap<QString, QString> &classNameMap);
void applyFetchedFaceProfiles(const QVector<FaceListModel::Item> &faces);
void rebuildStudentProfiles();
void applyStudentProfilesToMappings();
bool validateMappings(QString *message) const;
@@ -143,6 +152,7 @@ private:
QString faceBindingTextForStudent(const QString &studentId) const;
void rebuildFilteredRecords();
void rebuildStatistics();
QString effectiveStudentId(const AttendanceRecord &record) const;
QString mappedStudentName(const AttendanceRecord &record) const;
QString effectiveClassId(const AttendanceRecord &record) const;
QString mappedClassName(const AttendanceRecord &record) const;
@@ -196,6 +206,10 @@ private:
QMap<QString, QString> m_faceIdMapping;
QHash<QString, QString> m_facePhotoLocalUrls;
QStringList m_facePhotoIds;
bool m_studentsLoadedFromServer = false;
bool m_mappingSaveInProgress = false;
QMap<QString, QString> m_pendingClassMap;
QVector<FaceListModel::Item> m_pendingFaceItems;
};
#endif
+28 -11
View File
@@ -2,6 +2,24 @@
#include "crypto/tiny_aes.hpp"
namespace {
const uint8_t AesKey[AES_KEYLEN] = {
0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6,
0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c
};
const uint8_t AesIv[AES_BLOCKLEN] = {
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f
};
QByteArray addPkcs7Padding(const QByteArray &data)
{
const int padding = AES_BLOCKLEN - (data.size() % AES_BLOCKLEN);
QByteArray padded = data;
padded.append(QByteArray(padding, static_cast<char>(padding)));
return padded;
}
QByteArray removePkcs7Padding(const QByteArray &data)
{
if (data.isEmpty()) {
@@ -23,18 +41,17 @@ QByteArray removePkcs7Padding(const QByteArray &data)
}
}
QByteArray CryptoUtils::encryptAes128CbcBase64(const QByteArray &plainData)
{
QByteArray buffer = addPkcs7Padding(plainData);
AES_ctx ctx;
AES_init_ctx_iv(&ctx, AesKey, AesIv);
AES_CBC_encrypt_buffer(&ctx, reinterpret_cast<uint8_t*>(buffer.data()), static_cast<size_t>(buffer.size()));
return buffer.toBase64();
}
QByteArray CryptoUtils::decryptAes128CbcBase64(const QByteArray &encryptedBase64)
{
static const uint8_t key[AES_KEYLEN] = {
0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6,
0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c
};
static const uint8_t iv[AES_BLOCKLEN] = {
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f
};
const QByteArray encrypted = QByteArray::fromBase64(encryptedBase64);
if (encrypted.isEmpty() || (encrypted.size() % AES_BLOCKLEN) != 0) {
return {};
@@ -42,7 +59,7 @@ QByteArray CryptoUtils::decryptAes128CbcBase64(const QByteArray &encryptedBase64
QByteArray buffer = encrypted;
AES_ctx ctx;
AES_init_ctx_iv(&ctx, key, iv);
AES_init_ctx_iv(&ctx, AesKey, AesIv);
AES_CBC_decrypt_buffer(&ctx, reinterpret_cast<uint8_t*>(buffer.data()), static_cast<size_t>(buffer.size()));
return removePkcs7Padding(buffer);
}
+1
View File
@@ -6,6 +6,7 @@
class CryptoUtils
{
public:
static QByteArray encryptAes128CbcBase64(const QByteArray &plainData);
static QByteArray decryptAes128CbcBase64(const QByteArray &encryptedBase64);
};
+81
View File
@@ -176,6 +176,76 @@ static void InvMixColumns(uint8_t *state)
}
}
static void SubBytes(uint8_t *state)
{
for (uint8_t i = 0; i < 16; ++i) {
state[i] = getSBoxValue(state[i]);
}
}
static void ShiftRows(uint8_t *state)
{
uint8_t temp;
temp = state[1];
state[1] = state[5];
state[5] = state[9];
state[9] = state[13];
state[13] = temp;
temp = state[2];
state[2] = state[10];
state[10] = temp;
temp = state[6];
state[6] = state[14];
state[14] = temp;
temp = state[15];
state[15] = state[11];
state[11] = state[7];
state[7] = state[3];
state[3] = temp;
}
static void MixColumns(uint8_t *state)
{
for (uint8_t i = 0; i < 4; ++i) {
const uint8_t t = state[i * 4 + 0];
const uint8_t tmp = state[i * 4 + 0] ^ state[i * 4 + 1] ^ state[i * 4 + 2] ^ state[i * 4 + 3];
uint8_t tm = state[i * 4 + 0] ^ state[i * 4 + 1];
tm = xtime(tm);
state[i * 4 + 0] ^= tm ^ tmp;
tm = state[i * 4 + 1] ^ state[i * 4 + 2];
tm = xtime(tm);
state[i * 4 + 1] ^= tm ^ tmp;
tm = state[i * 4 + 2] ^ state[i * 4 + 3];
tm = xtime(tm);
state[i * 4 + 2] ^= tm ^ tmp;
tm = state[i * 4 + 3] ^ t;
tm = xtime(tm);
state[i * 4 + 3] ^= tm ^ tmp;
}
}
inline void Cipher(uint8_t *state, const uint8_t *RoundKey)
{
AddRoundKey(0, state, RoundKey);
for (uint8_t round = 1; round < 10; ++round) {
SubBytes(state);
ShiftRows(state);
MixColumns(state);
AddRoundKey(round, state, RoundKey);
}
SubBytes(state);
ShiftRows(state);
AddRoundKey(10, state, RoundKey);
}
inline void InvCipher(uint8_t *state, const uint8_t *RoundKey)
{
uint8_t round = 0;
@@ -198,6 +268,17 @@ inline void AES_init_ctx_iv(struct AES_ctx *ctx, const uint8_t *key, const uint8
memcpy(ctx->Iv, iv, AES_BLOCKLEN);
}
inline void AES_CBC_encrypt_buffer(struct AES_ctx *ctx, uint8_t *buf, size_t length)
{
for (size_t i = 0; i < length; i += AES_BLOCKLEN) {
for (uint8_t j = 0; j < AES_BLOCKLEN; ++j) {
buf[i + j] ^= ctx->Iv[j];
}
Cipher(buf + i, ctx->RoundKey);
memcpy(ctx->Iv, buf + i, AES_BLOCKLEN);
}
}
inline void AES_CBC_decrypt_buffer(struct AES_ctx *ctx, uint8_t *buf, size_t length)
{
size_t i;