From e9a2400c4833893920c36a9797272d271163a991 Mon Sep 17 00:00:00 2001 From: nikelaluo <2135665716@qq.com> Date: Thu, 9 Jul 2026 09:00:51 +0000 Subject: [PATCH] chore: prepare repository for submodule split --- .dockerignore | 2 +- .gitignore | 8 + client/.gitignore | 70 + client/Common/ConfigHelper.cpp | 62 +- client/Common/ConfigHelper.h | 6 + client/Common/DeviceIdentityHelper.cpp | 12 +- client/Common/HttpHelper.cpp | 14 + client/Common/IntegrityHelper.cpp | 29 +- client/Launcher/main.cpp | 99 +- client/MainApp/main.cpp | 3 +- client/SDK_README.md | 79 +- client/Updater/UpdateTransaction.cpp | 15 +- client/Updater/UpdateTransaction.h | 4 +- client/Updater/UpdaterLogic.cpp | 31 +- client/Updater/main.cpp | 30 +- client/admin.html | 1079 ----------- client/client.ini | 14 - client/config/app_config.example.json | 1 + client/install-sdk.ps1 | 37 +- client/package-client.ps1 | 77 +- client/package-sdk.ps1 | 36 +- server/.env.docker.example | 13 +- server/.env.example | 11 +- server/.gitignore | 59 + server/Dockerfile | 5 +- server/admin.html | 2428 ++++++++++++++++++++++++ server/docker-compose.image.yml | 5 +- server/docker-compose.yml | 5 +- server/main.py | 929 +++++++-- server/minio_tool.py | 18 + server/package-offline-server.ps1 | 95 - server/package-offline-server.sh | 307 ++- 项目总览.md | 137 +- 33 files changed, 4175 insertions(+), 1545 deletions(-) create mode 100644 client/.gitignore delete mode 100644 client/admin.html delete mode 100644 client/client.ini create mode 100644 server/.gitignore create mode 100644 server/admin.html delete mode 100644 server/package-offline-server.ps1 diff --git a/.dockerignore b/.dockerignore index ae67530..8af2599 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,4 @@ !server/db.py !server/minio_tool.py !server/tables.sql -!client/admin.html +!server/admin.html diff --git a/.gitignore b/.gitignore index a4d29c4..8cac7ed 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,14 @@ Desktop.ini *.7z *.rar +# Requirement / handover documents kept outside Git +client/*.pdf +client/*.doc +client/*.docx +server/*.doc +server/*.docx +server/*交付说明*.md + # C/C++ generated artifacts outside build directories *.obj *.o diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..1887b16 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,70 @@ +# Operating systems and editors +.DS_Store +Thumbs.db +Desktop.ini +.vscode/ +.idea/ +.vs/ +*.suo +*.user +*.userosscache +*.sln.docstates + +# Temporary files +*.tmp +*.temp +*.bak +*.swp +*.log +*~ + +# Private requirement / handover documents +*.pdf +*.doc +*.docx + +# C/C++ generated artifacts +*.obj +*.o +*.pdb +*.ilk +*.idb +*.tlog +*.lastbuildstate +*.exp +*.lib +*.dll +*.exe + +# Build outputs +out/ +build/ +build-*/ +cmake-build-*/ +.cmake/ +CMakeFiles/ +CMakeCache.txt +CMakeSettings.json +CMakeUserPresets.json +Testing/ + +# Third-party/business binary drops and generated packages +App/ +dist/ +*.zip +*.tar +*.tar.gz +*.tgz +*.7z +*.rar + +# Local runtime state and credentials +config/app_config.json +config/client_identity.dat +config/local_state.json +config/version_policy.dat +config/*.local.json +config/*private*.pem +config/*private*.key +update/ +update_temp/ diff --git a/client/Common/ConfigHelper.cpp b/client/Common/ConfigHelper.cpp index a4e8aae..a2bf76c 100644 --- a/client/Common/ConfigHelper.cpp +++ b/client/Common/ConfigHelper.cpp @@ -28,6 +28,40 @@ QString ConfigHelper::configPath() const return m_configPath; } +QString ConfigHelper::installRoot() const +{ + QString relativeRoot = getValue("Runtime", "install_root").trimmed(); + if (relativeRoot.isEmpty()) + relativeRoot = "."; + return QDir::cleanPath(QDir(runtimeRoot()).filePath(relativeRoot)); +} + +QString ConfigHelper::runtimeRoot() const +{ + return QDir::cleanPath(QApplication::applicationDirPath()); +} + +QString ConfigHelper::updateRoot() const +{ + return QDir(runtimeRoot()).filePath("update"); +} + +QString ConfigHelper::runtimeRelativePath() const +{ + QString relative = QDir::fromNativeSeparators(QDir(installRoot()).relativeFilePath(runtimeRoot())); + relative = QDir::cleanPath(relative); + if (relative == ".") + return QString(); + while (relative.startsWith("./")) + relative = relative.mid(2); + return relative.trimmed(); +} + +QString ConfigHelper::lastError() const +{ + return m_error; +} + QString ConfigHelper::getValue(const QString& section, const QString& key) const { Q_UNUSED(section); @@ -46,33 +80,56 @@ QString ConfigHelper::getValue(const QString& section, const QString& key) const bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value) { Q_UNUSED(section); + m_error.clear(); + QFile input(m_configPath); QJsonObject config; if (input.exists()) { if (!input.open(QIODevice::ReadOnly)) + { + m_error = QString("Cannot open config for reading: %1").arg(input.errorString()); return false; + } QJsonParseError parseError; const QByteArray raw = input.readAll(); input.close(); const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError); if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + m_error = QString("Invalid config JSON: %1").arg(parseError.errorString()); return false; + } config = document.object(); } config.insert(key, value); - QDir().mkpath(QFileInfo(m_configPath).path()); + QDir dir(QFileInfo(m_configPath).path()); + if (!dir.exists() && !dir.mkpath(".")) + { + m_error = QString("Cannot create config directory: %1").arg(dir.path()); + return false; + } + QSaveFile output(m_configPath); if (!output.open(QIODevice::WriteOnly)) + { + m_error = QString("Cannot open config for writing: %1").arg(output.errorString()); return false; + } const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented); if (output.write(payload) != payload.size()) { + m_error = QString("Cannot write full config file: %1").arg(output.errorString()); output.cancelWriting(); return false; } - return output.commit(); + if (!output.commit()) + { + m_error = QString("Cannot commit config file: %1").arg(output.errorString()); + return false; + } + return true; } bool ConfigHelper::migrateLegacyIniIfNeeded() @@ -103,6 +160,7 @@ bool ConfigHelper::migrateLegacyIniIfNeeded() copyText("Update", "request_timeout_ms", "5000"); copyText("Update", "temp_folder", "update_temp"); copyText("Update", "device_id"); + copyText("Runtime", "install_root", "."); copyText("Runtime", "main_executable", "MainApp.exe"); copyText("Runtime", "launcher_executable", "Launcher.exe"); copyText("Runtime", "updater_executable", "Updater.exe"); diff --git a/client/Common/ConfigHelper.h b/client/Common/ConfigHelper.h index 86a40d5..dd6ce05 100644 --- a/client/Common/ConfigHelper.h +++ b/client/Common/ConfigHelper.h @@ -8,9 +8,15 @@ public: QString getValue(const QString& section, const QString& key) const; bool setValue(const QString& section, const QString& key, const QString& value); QString configPath() const; + QString installRoot() const; + QString runtimeRoot() const; + QString updateRoot() const; + QString runtimeRelativePath() const; + QString lastError() const; private: ConfigHelper(); bool migrateLegacyIniIfNeeded(); QString m_configPath; + QString m_error; }; diff --git a/client/Common/DeviceIdentityHelper.cpp b/client/Common/DeviceIdentityHelper.cpp index 6dc4b87..e123676 100644 --- a/client/Common/DeviceIdentityHelper.cpp +++ b/client/Common/DeviceIdentityHelper.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #ifdef HAVE_OPENSSL #include #include @@ -35,7 +36,14 @@ bool DeviceIdentityHelper::loadAndVerify(const QString& appId,const QString& cha bool DeviceIdentityHelper::verifyLocal(const QString& appId,const QString& channel){m_error.clear();return loadAndVerify(appId,channel);} bool DeviceIdentityHelper::ensureIssued(const QString& base,const QString& token,const QString& appId,const QString& channel,const QString& licenseKey){ m_error.clear();if(loadAndVerify(appId,channel)){ConfigHelper::instance().setValue("Update","device_id",m_deviceId);return true;} - QString installation=ConfigHelper::instance().getValue("Device","installation_id");if(installation.isEmpty()){installation=QUuid::createUuid().toString(QUuid::WithoutBraces);if(!ConfigHelper::instance().setValue("Device","installation_id",installation)){m_error="cannot save installation id";return false;}} + const QString trimmedBase=base.trimmed(); + if(appId.trimmed().isEmpty()){m_error="app_id is empty in config/app_config.json";return false;} + if(channel.trimmed().isEmpty()){m_error="channel is empty in config/app_config.json";return false;} + if(trimmedBase.isEmpty()||trimmedBase.contains("YOUR_SERVER_IP",Qt::CaseInsensitive)){m_error="api_base_url is not configured. Set it to the update server address, for example http://192.168.229.128:8000";return false;} + if(token.trimmed().isEmpty()){m_error="client_token is empty in config/app_config.json";return false;} + if(licenseKey.trimmed().isEmpty()){m_error="license_key is empty. Create a License in the admin page and paste the generated key into config/app_config.json";return false;} + ConfigHelper& config=ConfigHelper::instance(); + QString installation=config.getValue("Device","installation_id");if(installation.isEmpty()){installation=QUuid::createUuid().toString(QUuid::WithoutBraces);if(!config.setValue("Device","installation_id",installation)){m_error=QString("cannot save installation id to %1: %2").arg(config.configPath(),config.lastError());return false;}} QByteArray machine=QSysInfo::machineUniqueId()+installation.toUtf8();QString mh=QString::fromLatin1(QCryptographicHash::hash(machine,QCryptographicHash::Sha256).toHex());QJsonObject body{{"app_id",appId},{"channel",channel},{"license_key",licenseKey},{"installation_id",installation},{"machine_hash",mh}}; - QNetworkAccessManager manager;QNetworkRequest req{QUrl(base+"/api/v1/device/issue")};req.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");req.setRawHeader("X-Client-Token",token.toUtf8());QNetworkReply* reply=manager.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));QEventLoop loop;QObject::connect(reply,&QNetworkReply::finished,&loop,&QEventLoop::quit);loop.exec();int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();QByteArray raw=reply->readAll();reply->deleteLater();if(status!=200){m_error=QString("device issue failed (HTTP %1): %2").arg(status).arg(QString::fromUtf8(raw));return false;}auto response=QJsonDocument::fromJson(raw).object();QJsonObject wrapper{{"identity_text",response.value("identity_text")},{"signature",response.value("signature")}};QString path=QDir(m_installDir).filePath("config/client_identity.dat");QSaveFile out(path);QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);if(!out.open(QIODevice::WriteOnly)||out.write(bytes)!=bytes.size()||!out.commit()){m_error="cannot save device credential";return false;}if(!loadAndVerify(appId,channel))return false;if(!ConfigHelper::instance().setValue("Update","device_id",m_deviceId)){m_error="cannot save server device id";return false;}return true; + QNetworkAccessManager manager;QNetworkRequest req{QUrl(trimmedBase+"/api/v1/device/issue")};req.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");req.setRawHeader("X-Client-Token",token.toUtf8());QNetworkReply* reply=manager.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));QEventLoop loop;bool timeoutOk=false;int timeoutMs=ConfigHelper::instance().getValue("Update","request_timeout_ms").toInt(&timeoutOk);if(!timeoutOk||timeoutMs<1000)timeoutMs=5000;QTimer timer;timer.setSingleShot(true);QObject::connect(&timer,&QTimer::timeout,[&](){if(reply&&reply->isRunning())reply->abort();});QObject::connect(reply,&QNetworkReply::finished,&loop,&QEventLoop::quit);timer.start(timeoutMs);loop.exec();timer.stop();int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();QByteArray raw=reply->readAll();reply->deleteLater();if(status!=200){m_error=QString("device issue failed (HTTP %1): %2").arg(status).arg(QString::fromUtf8(raw));return false;}auto response=QJsonDocument::fromJson(raw).object();QJsonObject wrapper{{"identity_text",response.value("identity_text")},{"signature",response.value("signature")}};QString path=QDir(m_installDir).filePath("config/client_identity.dat");QSaveFile out(path);QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);if(!out.open(QIODevice::WriteOnly)||out.write(bytes)!=bytes.size()||!out.commit()){m_error=QString("cannot save device credential to %1: %2").arg(path,out.errorString());return false;}if(!loadAndVerify(appId,channel))return false;if(!config.setValue("Update","device_id",m_deviceId)){m_error=QString("cannot save server device id to %1: %2").arg(config.configPath(),config.lastError());return false;}return true; } diff --git a/client/Common/HttpHelper.cpp b/client/Common/HttpHelper.cpp index c781113..2bc0957 100644 --- a/client/Common/HttpHelper.cpp +++ b/client/Common/HttpHelper.cpp @@ -4,6 +4,7 @@ #include #include #include +#include void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody, std::function callback) @@ -27,9 +28,22 @@ void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody, QNetworkReply* reply = manager->post(req, data); QEventLoop loop; + bool timeoutOk = false; + int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk); + if (!timeoutOk || timeoutMs < 1000) timeoutMs = 5000; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, [&]() { + if (reply && reply->isRunning()) { + qDebug() << "Request timeout, abort:" << url; + reply->abort(); + } + }); QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + timer.start(timeoutMs); loop.exec(); + timer.stop(); int retCode = 0; QJsonObject retObj; diff --git a/client/Common/IntegrityHelper.cpp b/client/Common/IntegrityHelper.cpp index 44948c3..83d2e39 100644 --- a/client/Common/IntegrityHelper.cpp +++ b/client/Common/IntegrityHelper.cpp @@ -1,4 +1,5 @@ #include "IntegrityHelper.h" +#include "ConfigHelper.h" #include #include #include @@ -28,10 +29,19 @@ bool IntegrityHelper::safeRelativePath(const QString& path) const bool IntegrityHelper::runtimeProtectedPath(const QString& path) const { const QString p = QDir::fromNativeSeparators(path).toCaseFolded(); - static const QSet protectedPaths{ + QSet protectedPaths{ "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json", "config/client_identity.dat", "config/version_policy.dat" }; + const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); + if (!runtimePrefix.isEmpty()) { + const QStringList runtimeProtected{ + "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json", + "config/client_identity.dat", "config/version_policy.dat" + }; + for (const QString& protectedPath : runtimeProtected) + protectedPaths.insert(runtimePrefix + "/" + protectedPath); + } return protectedPaths.contains(p); } @@ -49,7 +59,10 @@ bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& #ifndef HAVE_OPENSSL Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false; #else - QFile keyFile(QDir(m_installDir).filePath("config/manifest_public_key.pem")); + QString keyPath = QDir(m_installDir).filePath("config/manifest_public_key.pem"); + if (!QFile::exists(keyPath)) + keyPath = QFileInfo(ConfigHelper::instance().configPath()).dir().filePath("manifest_public_key.pem"); + QFile keyFile(keyPath); if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "manifest public key missing"; return false; } const QByteArray keyData = keyFile.readAll(); BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); @@ -72,8 +85,12 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString const QString& version) { m_error.clear(); - const QString cachePath = QDir(m_installDir).filePath( + QString cachePath = QDir(ConfigHelper::instance().updateRoot()).filePath( + "manifest_cache/manifest_" + version + ".json"); + const QString legacyCachePath = QDir(m_installDir).filePath( "update/manifest_cache/manifest_" + version + ".json"); + if (!QFile::exists(cachePath)) + cachePath = legacyCachePath; QFile cache(cachePath); if (!cache.open(QIODevice::ReadOnly)) { m_error = "signed manifest cache missing for " + version; return false; } QJsonParseError wrapperError; @@ -121,8 +138,12 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString const QString fullPath = it.next(); const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath)); const QString folded = relative.toCaseFolded(); + const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); + const bool runtimeWorkDir = !runtimePrefix.isEmpty() + && (folded.startsWith(runtimePrefix + "/update/") + || folded.startsWith(runtimePrefix + "/update_temp/")); if (folded.startsWith("update/") || folded.startsWith("update_temp/") - || runtimeProtectedPath(relative)) continue; + || runtimeWorkDir || runtimeProtectedPath(relative)) continue; const QString suffix = QFileInfo(relative).suffix().toCaseFolded(); if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) { m_error = "undeclared executable or plugin: " + relative; return false; diff --git a/client/Launcher/main.cpp b/client/Launcher/main.cpp index a2a1c25..8a1c5fa 100644 --- a/client/Launcher/main.cpp +++ b/client/Launcher/main.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include #include "UpdateLogic.h" #include "../Common/ConfigHelper.h" @@ -13,6 +15,7 @@ #include "../Common/DeviceIdentityHelper.h" #include #include +#include int main(int argc, char* argv[]) { @@ -30,16 +33,81 @@ int main(int argc, char* argv[]) progress.show(); QApplication::processEvents(); + const QString appDir = QApplication::applicationDirPath(); ConfigHelper& config = ConfigHelper::instance(); - DeviceIdentityHelper identity(QApplication::applicationDirPath()); - if (!identity.ensureIssued(config.getValue("Server", "api_base_url"), - config.getValue("Server", "client_token"), - config.getValue("App", "app_id"), - config.getValue("App", "channel"), - config.getValue("License", "license_key"))) { - progress.close(); - QMessageBox::critical(nullptr, "设备身份验证失败", identity.errorString()); - return -1; + const auto isLicenseError = [](const QString& errorText) { + const QString text = errorText.toLower(); + return text.contains("license") + || errorText.contains("授权") + || errorText.contains("过期") + || errorText.contains("设备数量已达到上限") + || errorText.contains("已绑定其他授权"); + }; + const auto clearDeviceCredential = [&]() { + QFile::remove(QDir(appDir).filePath("config/client_identity.dat")); + config.setValue("Update", "device_id", QString()); + }; + QString licenseKey = config.getValue("License", "license_key").trimmed(); + auto promptAndSaveLicense = [&](const QString& reason) -> QString { + QString promptReason = reason; + while (true) { + progress.close(); + bool accepted = false; + const QString appName = config.getValue("App", "app_name").trimmed(); + const QString prompt = promptReason.trimmed().isEmpty() + ? QString("请输入%1授权 License:").arg(appName.isEmpty() ? "软件" : appName) + : QString("%1\n\n请重新输入%2授权 License:").arg(promptReason, appName.isEmpty() ? "软件" : appName); + licenseKey = QInputDialog::getText( + nullptr, + "输入 License", + prompt, + QLineEdit::Normal, + QString(), + &accepted + ).trimmed(); + if (!accepted) { + QMessageBox::information(nullptr, "需要 License", "首次启动需要输入管理员提供的 License。"); + return QString(); + } + if (licenseKey.isEmpty()) { + QMessageBox::warning(nullptr, "License 不能为空", "请粘贴管理员在后台创建的 License。"); + promptReason.clear(); + continue; + } + if (!config.setValue("License", "license_key", licenseKey)) { + QMessageBox::critical(nullptr, "保存 License 失败", + QString("无法写入配置文件:%1\n%2").arg(config.configPath(), config.lastError())); + return QString(); + } + progress.show(); + progress.setLabelText("正在验证 License..."); + QApplication::processEvents(); + return licenseKey; + } + }; + + while (true) { + if (licenseKey.isEmpty()) { + licenseKey = promptAndSaveLicense(QString()); + if (licenseKey.isEmpty()) return 0; + } + DeviceIdentityHelper identity(appDir); + if (identity.ensureIssued(config.getValue("Server", "api_base_url"), + config.getValue("Server", "client_token"), + config.getValue("App", "app_id"), + config.getValue("App", "channel"), + licenseKey)) { + break; + } + const QString error = identity.errorString(); + if (!isLicenseError(error)) { + progress.close(); + QMessageBox::critical(nullptr, "设备身份验证失败", error); + return -1; + } + clearDeviceCredential(); + licenseKey = promptAndSaveLicense(QString("当前 License 无法使用:%1").arg(error)); + if (licenseKey.isEmpty()) return 0; } UpdateLogic logic; @@ -57,13 +125,22 @@ int main(int argc, char* argv[]) progress.close(); const QJsonValue detail = response.value("detail"); const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString(); - QMessageBox::critical(nullptr, "授权被拒绝", message.isEmpty() ? "设备或 License 授权无效。" : message); + const QString displayMessage = message.isEmpty() ? "设备或 License 授权无效。" : message; + if (isLicenseError(displayMessage)) { + clearDeviceCredential(); + const QString newLicense = promptAndSaveLicense(QString("当前授权被服务端拒绝:%1").arg(displayMessage)); + if (!newLicense.isEmpty()) { + progress.close(); + QMessageBox::information(nullptr, "License 已保存", "请重新启动 Launcher 完成设备授权和更新检查。"); + } + return 0; + } + QMessageBox::critical(nullptr, "授权被拒绝", displayMessage); return -1; } const QString launchToken = config.getValue("App", "launch_token"); const QString currentVersion = config.getValue("App", "current_version"); - const QString appDir = QApplication::applicationDirPath(); const auto configuredName = [&](const QString& key, const QString& fallback) { const QString value = config.getValue("Runtime", key).trimmed(); return value.isEmpty() ? fallback : value; diff --git a/client/MainApp/main.cpp b/client/MainApp/main.cpp index 35258d0..4d73d19 100644 --- a/client/MainApp/main.cpp +++ b/client/MainApp/main.cpp @@ -51,6 +51,7 @@ int main(int argc, char* argv[]) } QString appDir = QApplication::applicationDirPath(); + const QString installRoot = config.installRoot(); DeviceIdentityHelper identity(appDir); if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel"))) { @@ -89,7 +90,7 @@ int main(int argc, char* argv[]) return -1; } - IntegrityHelper integrity(appDir); + IntegrityHelper integrity(installRoot); if (!integrity.verifyInstalledVersion( config.getValue("App", "app_id"), config.getValue("App", "channel"), config.getValue("App", "current_version"))) diff --git a/client/SDK_README.md b/client/SDK_README.md index cd1166b..30f3b0a 100644 --- a/client/SDK_README.md +++ b/client/SDK_README.md @@ -17,12 +17,63 @@ SDK 核心程序: 1. 在服务端管理后台创建应用,例如 `app_id=your_app_id`。 2. 创建或确认渠道,例如 `stable`。 3. 创建 License,把生成的 `license_key` 填到客户端配置。 -4. 把业务程序和依赖 DLL 放到同一个发布目录。 -5. 把 SDK 的 `Launcher.exe`、`Updater.exe`、`Bootstrap.exe` 和运行时 DLL 放到该目录。 -6. 把 `config/app_config.example.json` 复制成 `config/app_config.json` 并修改字段。 +4. 把业务软件完整安装目录作为发布根目录,例如 `SimCAE\`,其中主程序位于 `bin\SimCAE.exe`。 +5. 把 SDK 的 `Launcher.exe`、`Updater.exe`、`Bootstrap.exe` 放到 `SimCAE\bin\` 目录,和 `SimCAE.exe` 同级。不要覆盖 SimCAE 自带的 `Qt5*.dll` 和 Qt 插件目录。 +6. 把 `config/app_config.example.json` 复制成 `SimCAE\bin\config\app_config.json` 并修改字段。 7. 用户入口改成 `Launcher.exe`,不要直接双击业务主程序。 8. 在管理后台发布新版本时,选择包含业务主程序和 SDK 运行时的干净 Release 根目录。 +## 安装目录写权限要求 + +当前 SDK 会在 `Launcher.exe` 所在目录下写入运行时状态文件。SDK 放在 `SimCAE\bin` 时,这些文件实际位于 `SimCAE\bin` 下,例如: + +```text +config/app_config.json +config/client_identity.dat +config/local_state.json +config/version_policy.dat +update/ +update_temp/ +``` + +所以联调和普通运行时,`Launcher.exe` 所在目录必须允许当前 Windows 用户写入。不要直接把联调目录放在 `C:\Program Files\...` 后用普通用户启动;该目录默认禁止普通程序写文件,会导致首次启动报错,例如 `cannot save installation id`。 + +推荐联调目录: + +```text +D:\SimCAE_Release\ +C:\Users\<你的用户名>\Desktop\SimCAE_Release\ +``` + +如果最终产品必须安装到 `C:\Program Files\SimCAE\bin`,需要额外设计管理员提权、Windows 服务,或把运行时状态迁移到 `ProgramData` / `AppData`。当前交付版本默认按“安装目录可写”的模式工作。 + +## SimCAE 目录结构建议 + +SimCAE 当前安装目录是根目录下有 `bin/`、`Licenses/`、`installerResources/` 等子目录。SDK 推荐放在 `bin/` 目录,和 `SimCAE.exe` 同级;后台发布时仍选择整个安装根目录: + +```text +SimCAE/ + bin/ + Launcher.exe + Updater.exe + Bootstrap.exe + SimCAE.exe + config/ + app_config.json + manifest_public_key.pem + update/ + manifest_cache/ + Qt5Core.dll + ... + Licenses/ + installerResources/ + maintenancetool.exe +``` + +这种模式下,后台“发布新版本”时选择整个 `SimCAE/` 目录,服务端会检查 `bin/SimCAE.exe` 是否存在,并把整个安装结构写入 Manifest。客户端配置里 `install_root` 填 `..`,表示被更新的安装根目录是 `bin` 的上一级;升级事务、下载缓存和 Manifest 缓存仍放在 `SimCAE\bin\update`。 + +注意:SimCAE 自己已经带有 Qt 运行库。SDK 的 Launcher/Updater 应使用和 SimCAE 兼容的 Qt 编译,并复用 SimCAE 的 `Qt5*.dll`、`platforms/`、`imageformats/` 等目录。不要把另一套 Qt DLL 覆盖到 `SimCAE\bin`,否则会出现“无法定位程序输入点”一类错误。 + ## app_config.json 关键字段 ```json @@ -39,6 +90,7 @@ SDK 核心程序: "request_timeout_ms": "5000", "temp_folder": "update_temp", "device_id": "", + "install_root": "..", "main_executable": "SimCAE.exe", "launcher_executable": "Launcher.exe", "updater_executable": "Updater.exe", @@ -59,7 +111,8 @@ SDK 核心程序: - `client_token`:服务端 `.env` 中的 `CLIENT_API_TOKEN`,默认交付包已填 `SimCAEClientToken2026`。 - `license_key`:管理后台创建 License 后返回的密钥;模板里先留空,创建授权后再填。 - `launch_token`:本机启动票据 HMAC 密钥,模板已给默认值,可试跑;正式交付建议改成你自己的 32 字符以上随机字符串。 -- `main_executable`:业务主程序文件名,默认填 `SimCAE.exe`;如果你的主程序不是这个名字,这里同步修改。 +- `install_root`:安装根目录相对 `Launcher.exe` 所在目录的位置。SDK 放在 `bin` 时填 `..`。 +- `main_executable`:业务主程序相对 `Launcher.exe` 所在目录的路径,SimCAE 默认填 `SimCAE.exe`。 - `health_check_timeout_ms`:升级后等待业务程序写健康标记的时间。 ## 业务主程序需要配合什么 @@ -92,15 +145,19 @@ cd client -SdkVersion 0.1.0 ``` +默认生成的 SDK 不包含 Qt 运行库,避免覆盖业务软件自带的 Qt。只有在接入的软件本身不带 Qt,且你确认要让 SDK 自带一套 Qt 运行库时,才额外添加 `-IncludeQtRuntime`。 + 生成结果: ```text dist/UpdateClientSDK/ - README.md + SimCAE自动升级SDK接入说明_v0.1.docx + sdk_manifest.json bin/ config/ + app_config.json + manifest_public_key.pem scripts/ - samples/ ``` 把 `UpdateClientSDK.zip` 发给接入方即可。 @@ -123,7 +180,9 @@ cd client ## 常见错误 1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。 -2. 首次启动设备登记失败:检查 `api_base_url`、`client_token`、`license_key`、服务端 License 状态。 -3. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。 -4. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。 -5. 发布失败提示主程序不在根目录:选择发布目录时要选择业务主程序所在目录,而不是上层或下层目录。 +2. 首次启动提示 `cannot save installation id`:当前目录不可写,常见于 `C:\Program Files\...`;请换到可写目录联调,或用管理员权限/提权方案。 +3. 首次启动设备登记失败:检查 `api_base_url`、`client_token`、`license_key`、服务端 License 状态。 +4. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。 +5. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。 +6. 发布失败提示主程序不在根目录:选择发布目录时要选择业务主程序所在目录,而不是上层或下层目录。 +7. 启动时提示 `无法定位程序输入点 ... Qt5*.dll`:通常是 Qt DLL 被不同版本覆盖或混用。恢复业务软件原始 Qt DLL,并重新打包 SDK;SimCAE 场景下不要使用 `-IncludeQtRuntime`。 diff --git a/client/Updater/UpdateTransaction.cpp b/client/Updater/UpdateTransaction.cpp index cfed0fa..d6fc305 100644 --- a/client/Updater/UpdateTransaction.cpp +++ b/client/Updater/UpdateTransaction.cpp @@ -9,11 +9,11 @@ #include #include -UpdateTransaction::UpdateTransaction(const QString& installDir, const QString& fromVersion, +UpdateTransaction::UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion, const QString& toVersion, int manifestId) : m_installDir(QDir::cleanPath(installDir)), - m_updateDir(QDir(installDir).filePath("update")), - m_stateFile(QDir(installDir).filePath("update/upgrade_state.json")), + m_updateDir(QDir::cleanPath(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)), + m_stateFile(QDir(m_updateDir).filePath("upgrade_state.json")), m_transactionId(QUuid::createUuid().toString(QUuid::WithoutBraces)), m_fromVersion(fromVersion), m_toVersion(toVersion), m_manifestId(manifestId) { @@ -223,9 +223,14 @@ QString UpdateTransaction::backupDir() const { return m_backupDir; } QStringList UpdateTransaction::obsoletePaths() const { return m_obsoletePaths; } QString UpdateTransaction::healthFile() const { return QDir(m_updateDir).filePath("health_" + m_transactionId + ".ok"); } -bool UpdateTransaction::recoverInterrupted(const QString& installDir, QString* restoredVersion, QString* errorMessage) +bool UpdateTransaction::recoverInterrupted(const QString& installDir, const QString& updateDir, + QString* restoredVersion, QString* errorMessage) { - const QString stateFile = QDir(installDir).filePath("update/upgrade_state.json"); + QString stateFile = QDir(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir) + .filePath("upgrade_state.json"); + const QString legacyStateFile = QDir(installDir).filePath("update/upgrade_state.json"); + if (!QFile::exists(stateFile) && QFile::exists(legacyStateFile)) + stateFile = legacyStateFile; QFile file(stateFile); if (!file.exists()) return true; if (!file.open(QIODevice::ReadOnly)) { if (errorMessage) *errorMessage = "cannot open upgrade_state.json"; return false; } diff --git a/client/Updater/UpdateTransaction.h b/client/Updater/UpdateTransaction.h index 432ca9a..9b068f1 100644 --- a/client/Updater/UpdateTransaction.h +++ b/client/Updater/UpdateTransaction.h @@ -6,7 +6,7 @@ class UpdateTransaction { public: - UpdateTransaction(const QString& installDir, const QString& fromVersion, + UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion, const QString& toVersion, int manifestId); bool initialize(); @@ -29,7 +29,7 @@ public: QStringList obsoletePaths() const; QString healthFile() const; - static bool recoverInterrupted(const QString& installDir, + static bool recoverInterrupted(const QString& installDir, const QString& updateDir, QString* restoredVersion = nullptr, QString* errorMessage = nullptr); diff --git a/client/Updater/UpdaterLogic.cpp b/client/Updater/UpdaterLogic.cpp index 0bf7bdc..98d6555 100644 --- a/client/Updater/UpdaterLogic.cpp +++ b/client/Updater/UpdaterLogic.cpp @@ -263,7 +263,7 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded()); } - const QSet protectedPaths{ + QSet protectedPaths{ QStringLiteral("bootstrap.exe"), QStringLiteral("launcher.exe"), QStringLiteral("updater.exe"), @@ -273,6 +273,17 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest QStringLiteral("config/client_identity.dat"), QStringLiteral("config/version_policy.dat") }; + const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); + if (!runtimePrefix.isEmpty()) { + const QStringList runtimeProtected{ + QStringLiteral("bootstrap.exe"), QStringLiteral("launcher.exe"), QStringLiteral("updater.exe"), + QStringLiteral("client.ini"), QStringLiteral("config/app_config.json"), + QStringLiteral("config/local_state.json"), QStringLiteral("config/client_identity.dat"), + QStringLiteral("config/version_policy.dat") + }; + for (const QString& path : runtimeProtected) + protectedPaths.insert(runtimePrefix + "/" + path); + } QStringList obsolete; QSet seen; for (const QJsonValue& value : oldManifest.value("files").toArray()) { @@ -550,7 +561,7 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const { const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded(); - static const QSet protectedPaths{ + QSet protectedPaths{ QStringLiteral("bootstrap.exe"), QStringLiteral("client.ini"), QStringLiteral("config/app_config.json"), @@ -558,6 +569,16 @@ bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const QStringLiteral("config/client_identity.dat"), QStringLiteral("config/version_policy.dat") }; + const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); + if (!runtimePrefix.isEmpty()) { + const QStringList runtimeProtected{ + QStringLiteral("bootstrap.exe"), QStringLiteral("client.ini"), + QStringLiteral("config/app_config.json"), QStringLiteral("config/local_state.json"), + QStringLiteral("config/client_identity.dat"), QStringLiteral("config/version_policy.dat") + }; + for (const QString& protectedPath : runtimeProtected) + protectedPaths.insert(runtimePrefix + "/" + protectedPath); + } return protectedPaths.contains(normalized); } @@ -576,7 +597,7 @@ qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir, const QStringList& obsoletePaths) const { qint64 required = 0; - const QString cacheDir = QDir(targetDir).filePath("update/download_cache"); + const QString cacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache"); for (const auto& item : m_fileItems) { const QString relativePath = QDir::fromNativeSeparators(item.path); if (isRuntimeProtectedPath(relativePath)) continue; @@ -609,7 +630,7 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe QDir stagingDir(tempDir); if (!FileHelper::createDir(tempDir)) return false; - const QString resumeCacheDir = QDir(targetDir).filePath("update/download_cache"); + const QString resumeCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache"); if (!FileHelper::createDir(resumeCacheDir)) return false; QSet activePartialNames; @@ -727,4 +748,4 @@ void UpdaterLogic::reportResult(const QString& deviceId, QList UpdaterLogic::getFileList() const { return m_fileItems; -} \ No newline at end of file +} diff --git a/client/Updater/main.cpp b/client/Updater/main.cpp index 2fda78b..fdb74b1 100644 --- a/client/Updater/main.cpp +++ b/client/Updater/main.cpp @@ -59,16 +59,19 @@ int main(int argc, char* argv[]) bootstrapResult = arg.mid(QString("--bootstrap-resume=").size()); } const bool resumingFromBootstrap = !bootstrapResult.isEmpty(); - const QString targetDir = QApplication::applicationDirPath(); + const QString runtimeDir = QApplication::applicationDirPath(); ConfigHelper& config = ConfigHelper::instance(); + const QString targetDir = config.installRoot(); + const QString updateDir = config.updateRoot(); + QDir().mkpath(updateDir); if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) { QMessageBox::critical(nullptr, "离线包不适用", "更新包的应用或渠道与本机配置不一致。"); return -1; } QString fromVersion = config.getValue("App", "current_version"); if (!offlinePackagePath.isEmpty()) { - PolicyHelper offlinePolicy(targetDir); + PolicyHelper offlinePolicy(runtimeDir); if (!offlinePolicy.loadPolicy() || !offlinePolicy.isValid() || offlinePolicy.isExpired() || !offlinePolicy.isOfflineAllowed() || !offlinePolicy.isVersionAllowed(targetVersion)) { QMessageBox::critical(nullptr, "离线更新被拒绝", "本地签名策略已过期、禁止离线更新或不允许目标版本。"); @@ -87,7 +90,7 @@ int main(int argc, char* argv[]) if (!resumingFromBootstrap) { QString restoredVersion; QString recoveryError; - if (!UpdateTransaction::recoverInterrupted(targetDir, &restoredVersion, &recoveryError)) { + if (!UpdateTransaction::recoverInterrupted(targetDir, updateDir, &restoredVersion, &recoveryError)) { QMessageBox::critical(nullptr, "更新恢复失败", QString("检测到上次更新未完成,但无法恢复旧版本:%1\n请不要继续运行软件,并联系管理员。").arg(recoveryError)); return -1; @@ -111,7 +114,7 @@ int main(int argc, char* argv[]) progress.show(); QApplication::processEvents(); - UpdateTransaction transaction(targetDir, fromVersion, targetVersion, targetVersionId); + UpdateTransaction transaction(targetDir, updateDir, fromVersion, targetVersion, targetVersionId); const auto setProgress = [&](int value, const QString& message) { progress.setValue(value); progress.setLabelText(message); @@ -160,9 +163,9 @@ int main(int argc, char* argv[]) bool timeoutOk = false; int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk); if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000; - const QString mainAppPath = QDir(targetDir).filePath(mainExecutable); - const QString updaterPath = QDir(targetDir).filePath(updaterExecutable); - const QString bootstrapPath = QDir(targetDir).filePath(bootstrapExecutable); + const QString mainAppPath = QDir(runtimeDir).filePath(mainExecutable); + const QString updaterPath = QDir(runtimeDir).filePath(updaterExecutable); + const QString bootstrapPath = QDir(runtimeDir).filePath(bootstrapExecutable); const QString launchToken = config.getValue("App", "launch_token"); const auto launchMainApp = [&](const QString& healthFile = QString()) { QString ticketPath; @@ -180,7 +183,7 @@ int main(int argc, char* argv[]) return started; }; const auto bootstrapPlanFile = [&]() { - return QDir(targetDir).filePath("update/bootstrap_plan_" + transaction.transactionId() + ".txt"); + return QDir(updateDir).filePath("bootstrap_plan_" + transaction.transactionId() + ".txt"); }; const auto launchBootstrap = [&](const QString& mode) { const QStringList args{ @@ -245,7 +248,7 @@ int main(int argc, char* argv[]) } setProgress(resumingFromBootstrap ? 72 : 10, offlinePackagePath.isEmpty() ? "正在获取并验证版本清单..." : "正在验证离线更新包..."); - const QString manifestCacheDir = QDir(targetDir).filePath("update/manifest_cache"); + const QString manifestCacheDir = QDir(updateDir).filePath("manifest_cache"); if (resumingFromBootstrap) { if (!logic.loadManifestCache(manifestCacheDir, targetVersion)) return delegateRollback("清单缓存失败", "Bootstrap 安装后无法读取签名 Manifest 缓存。"); @@ -282,7 +285,7 @@ int main(int argc, char* argv[]) if (logic.getFileList().isEmpty()) return fail("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "empty_file_list"); - QStorageInfo storage(targetDir); + QStorageInfo storage(updateDir); storage.refresh(); const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths); const qint64 availableBytes = storage.bytesAvailable(); @@ -316,9 +319,12 @@ int main(int argc, char* argv[]) QDirIterator stagingFiles(stagingDir, QDir::Files, QDirIterator::Subdirectories); while (stagingFiles.hasNext()) changedPaths.append(QDir::fromNativeSeparators(stagingRoot.relativeFilePath(stagingFiles.next()))); + const QString runtimePrefix = config.runtimeRelativePath(); + const QString bootstrapManifestPath = QDir::fromNativeSeparators( + runtimePrefix.isEmpty() ? bootstrapExecutable : runtimePrefix + "/" + bootstrapExecutable); for (const QString& path : changedPaths) { - if (path.compare(bootstrapExecutable, Qt::CaseInsensitive) == 0) - return fail("Bootstrap 无法自更新", QString("本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。").arg(bootstrapExecutable), "bootstrap_self_update_blocked"); + if (path.compare(bootstrapManifestPath, Qt::CaseInsensitive) == 0) + return fail("Bootstrap 无法自更新", QString("本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。").arg(bootstrapManifestPath), "bootstrap_self_update_blocked"); } if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths)) return fail("事务记录失败", "无法保存已校验或待删除文件列表,更新已停止。", "transaction_record_failed"); diff --git a/client/admin.html b/client/admin.html deleted file mode 100644 index 34fd668..0000000 --- a/client/admin.html +++ /dev/null @@ -1,1079 +0,0 @@ - - - - - - Marsco 发布控制台 - - -
-

软件发布控制台

-
-

管理员令牌

-
- - - - - -
-
令牌默认隐藏,仅保存在当前浏览器;服务端按 .env 中的 ADMIN_TOKEN 校验。
-
当前连接服务端:
- -
- -
-

应用管理

-
- - - -
-
- - - - - -
-
- -
-

渠道管理

-
- - - - -
-
-
代码名称状态排序操作
-
- -
-

发布新版本

-
- - - - - - - -
-
- - - 请选择包含 MainApp.exe 的发布目录 - -
-
尚未选择发布目录
-
- -
-

版本列表

-
- - 当前应用:未选择 - 将历史版本设为最新时,仅在该渠道策略勾选“允许降级”后客户端才会回退。 -
- - - - - - - - - - - - - -
ID版本渠道协议最新创建时间操作
-
- -
-

版本运行策略

-
- - - - -
-
- - - -
-
- - - 当前 policy_seq:- -
-
- -
-

License 授权

-
- - - - -
-
-
-
License ID客户渠道设备有效期状态操作
-
- -
-

设备管理

-
显示当前应用登记的设备;禁用后客户端请求会立即被拒绝
-
设备 ID状态凭证序列最近访问IP操作
-
- -
-

升级日志

-
- -
- - - - - - - - - - - -
设备旧版本新版本结果时间
-
- -
-

文件下载日志

-
设备License版本/渠道文件大小结果IP时间
-
-
-

管理员审计日志

-
管理员指纹操作结果状态码IP时间
-
- -
-

调试输出

-
准备就绪...
-
- - - - diff --git a/client/client.ini b/client/client.ini deleted file mode 100644 index a9ea8f0..0000000 --- a/client/client.ini +++ /dev/null @@ -1,14 +0,0 @@ -[Server] -api_base_url=http://192.168.229.128:8000 -client_token=AuthKey202606Demo - -[App] -app_id=testadmin -current_version=1.0.0 -channel=stable -launch_token=UpdateSecret2026Demo - -[Update] -request_timeout_ms=5000 -temp_folder=update_temp -device_id=test_device_001 \ No newline at end of file diff --git a/client/config/app_config.example.json b/client/config/app_config.example.json index 6b1a899..e53aa3b 100644 --- a/client/config/app_config.example.json +++ b/client/config/app_config.example.json @@ -11,6 +11,7 @@ "request_timeout_ms": "5000", "temp_folder": "update_temp", "device_id": "", + "install_root": "..", "main_executable": "SimCAE.exe", "launcher_executable": "Launcher.exe", "updater_executable": "Updater.exe", diff --git a/client/install-sdk.ps1 b/client/install-sdk.ps1 index 8c94d94..92eb3eb 100644 --- a/client/install-sdk.ps1 +++ b/client/install-sdk.ps1 @@ -4,7 +4,9 @@ param( [string]$ReleaseDir = (Get-Location).Path, - [switch]$OverwriteConfig + [switch]$OverwriteConfig, + + [switch]$IncludeQtRuntime ) $ErrorActionPreference = "Stop" @@ -23,7 +25,38 @@ foreach ($path in @($binDir, $appConfig, $publicKey)) { } } -Copy-Item (Join-Path $binDir "*") $release -Recurse -Force +function Test-IsQtRuntimeItem { + param([System.IO.FileSystemInfo]$Item) + + if ($IncludeQtRuntime) { + return $false + } + + if ($Item.PSIsContainer) { + return $Item.Name -in @( + "bearer", + "iconengines", + "imageformats", + "platforms", + "styles", + "translations" + ) + } + + return ( + $Item.Name -match '^Qt5.*\.dll$' -or + $Item.Name -in @( + "libEGL.dll", + "libGLESv2.dll", + "opengl32sw.dll", + "d3dcompiler_47.dll" + ) + ) +} + +Get-ChildItem $binDir -Force | Where-Object { + -not (Test-IsQtRuntimeItem $_) +} | Copy-Item -Destination $release -Recurse -Force $targetConfigDir = Join-Path $release "config" New-Item $targetConfigDir -ItemType Directory -Force | Out-Null diff --git a/client/package-client.ps1 b/client/package-client.ps1 index a274ebf..cc49841 100644 --- a/client/package-client.ps1 +++ b/client/package-client.ps1 @@ -12,6 +12,31 @@ $source = (Resolve-Path $SourceDir).Path $config = (Resolve-Path $ConfigFile).Path $settings = Get-Content $config -Raw -Encoding UTF8 | ConvertFrom-Json +function Normalize-RelativePath([string]$PathValue) { + return ($PathValue -replace '\\', '/').Trim('/') +} + +function Get-RelativePathUnderSource([string]$FullPath, [string]$Fallback) { + $full = (Resolve-Path $FullPath).Path + if ($full.StartsWith($source, [System.StringComparison]::OrdinalIgnoreCase)) { + return Normalize-RelativePath ($full.Substring($source.Length).TrimStart('\', '/')) + } + return Normalize-RelativePath $Fallback +} + +$configRelativePath = Get-RelativePathUnderSource $config "config/app_config.json" +$configRelativeParent = Split-Path $configRelativePath -Parent +$runtimeDirRelative = Normalize-RelativePath (Split-Path $configRelativeParent -Parent) +if ($runtimeDirRelative -eq ".") { $runtimeDirRelative = "" } + +function Join-RelativePath([string]$Base, [string]$Child) { + $baseNorm = Normalize-RelativePath $Base + $childNorm = Normalize-RelativePath $Child + if ([string]::IsNullOrWhiteSpace($baseNorm)) { return $childNorm } + if ([string]::IsNullOrWhiteSpace($childNorm)) { return $baseNorm } + return "$baseNorm/$childNorm" +} + $requiredFields = @( "app_id", "channel", "api_base_url", "current_version", "client_token", "launch_token", "license_key", @@ -23,14 +48,20 @@ foreach ($field in $requiredFields) { } } +$mainExecutable = Normalize-RelativePath $settings.main_executable +$launcherExecutable = Normalize-RelativePath $settings.launcher_executable +$updaterExecutable = Normalize-RelativePath $settings.updater_executable +$bootstrapExecutable = Normalize-RelativePath $settings.bootstrap_executable + $requiredFiles = @( - $settings.main_executable, - $settings.launcher_executable, - $settings.updater_executable, - $settings.bootstrap_executable + (Join-RelativePath $runtimeDirRelative $mainExecutable), + (Join-RelativePath $runtimeDirRelative $launcherExecutable), + (Join-RelativePath $runtimeDirRelative $updaterExecutable), + (Join-RelativePath $runtimeDirRelative $bootstrapExecutable) ) foreach ($name in $requiredFiles) { - if (-not (Test-Path (Join-Path $source $name))) { + $nativeName = $name -replace '/', [IO.Path]::DirectorySeparatorChar + if (-not (Test-Path (Join-Path $source $nativeName))) { throw "Source directory is missing required file: $name" } } @@ -43,15 +74,25 @@ if ($debugArtifacts) { throw "Source directory contains Debug artifacts. Clean out/bin and rebuild Release first. Example: $($debugArtifacts[0].FullName)" } -$nestedMain = Get-ChildItem $source -Recurse -File -Filter $settings.main_executable | Where-Object { - $_.DirectoryName -ne $source +$expectedMainPath = Join-RelativePath $runtimeDirRelative $mainExecutable +$mainLeafName = Split-Path $mainExecutable -Leaf +$duplicateMain = Get-ChildItem $source -Recurse -File -Filter $mainLeafName | Where-Object { + $relative = Normalize-RelativePath ($_.FullName.Substring($source.Length).TrimStart('\', '/')) + $relative -ne $expectedMainPath } | Select-Object -First 1 -if ($nestedMain) { - throw "Source directory contains a nested duplicate main executable. Use a clean Release root directory: $($nestedMain.FullName)" +if ($duplicateMain) { + throw "Source directory contains a duplicate main executable outside $expectedMainPath. Use a clean Release root directory: $($duplicateMain.FullName)" } $manifestName = "manifest_$($settings.current_version).json" -$sourceManifest = Join-Path $source "update/manifest_cache/$manifestName" +$sourceManifestRelative = Join-RelativePath $runtimeDirRelative "update/manifest_cache/$manifestName" +$sourceManifest = Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar) +if (-not (Test-Path $sourceManifest)) { + $legacySourceManifest = Join-Path $source "update/manifest_cache/$manifestName" + if (Test-Path $legacySourceManifest) { + $sourceManifest = $legacySourceManifest + } +} if (-not (Test-Path $sourceManifest)) { throw "Missing signed Manifest cache for current version: $sourceManifest. Complete online update/verification for this version before packaging." } @@ -65,16 +106,26 @@ Get-ChildItem $source -Force | Where-Object { $_.Name -notin @("update", "update_temp") } | Copy-Item -Destination $OutputDir -Recurse -Force -$outputConfigDir = Join-Path $OutputDir "config" +$outputRuntimeUpdateDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update") -replace '/', [IO.Path]::DirectorySeparatorChar) +if (Test-Path $outputRuntimeUpdateDir) { + Remove-Item $outputRuntimeUpdateDir -Recurse -Force +} +$outputRuntimeTempDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update_temp") -replace '/', [IO.Path]::DirectorySeparatorChar) +if (Test-Path $outputRuntimeTempDir) { + Remove-Item $outputRuntimeTempDir -Recurse -Force +} + +$outputConfigPath = Join-Path $OutputDir ($configRelativePath -replace '/', [IO.Path]::DirectorySeparatorChar) +$outputConfigDir = Split-Path $outputConfigPath -Parent New-Item $outputConfigDir -ItemType Directory -Force | Out-Null -Copy-Item $config (Join-Path $outputConfigDir "app_config.json") -Force +Copy-Item $config $outputConfigPath -Force @("client_identity.dat", "local_state.json", "version_policy.dat") | ForEach-Object { $runtimeFile = Join-Path $outputConfigDir $_ if (Test-Path $runtimeFile) { Remove-Item $runtimeFile -Force } } -$manifestDir = Join-Path $OutputDir "update/manifest_cache" +$manifestDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update/manifest_cache") -replace '/', [IO.Path]::DirectorySeparatorChar) New-Item $manifestDir -ItemType Directory -Force | Out-Null Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force diff --git a/client/package-sdk.ps1 b/client/package-sdk.ps1 index e289708..047d103 100644 --- a/client/package-sdk.ps1 +++ b/client/package-sdk.ps1 @@ -4,7 +4,8 @@ param( [string]$ZipFile = "$PSScriptRoot/dist/UpdateClientSDK.zip", [string]$SdkVersion = "0.1.0", [string]$ExampleConfig = "$PSScriptRoot/config/app_config.example.json", - [switch]$IncludeDemoMainApp + [switch]$IncludeDemoMainApp, + [switch]$IncludeQtRuntime ) $ErrorActionPreference = "Stop" @@ -46,11 +47,40 @@ $configDir = Join-Path $OutputDir "config" $scriptsDir = Join-Path $OutputDir "scripts" New-Item $binDir,$configDir,$scriptsDir -ItemType Directory -Force | Out-Null -$excludedTopLevel = @("config", "update", "update_temp") +$excludedTopLevel = @("config", "update", "update_temp", "manifest_public_key.pem") if (-not $IncludeDemoMainApp) { $excludedTopLevel += "MainApp.exe" } +if (-not $IncludeQtRuntime) { + $excludedTopLevel += @( + "bearer", + "iconengines", + "imageformats", + "platforms", + "styles", + "translations" + ) +} + +function Test-IsQtRuntimeFile { + param([System.IO.FileSystemInfo]$Item) + + if ($IncludeQtRuntime -or $Item.PSIsContainer) { + return $false + } + + return ( + $Item.Name -match '^Qt5.*\.dll$' -or + $Item.Name -in @( + "libEGL.dll", + "libGLESv2.dll", + "opengl32sw.dll", + "d3dcompiler_47.dll" + ) + ) +} + Get-ChildItem $source -Force | Where-Object { - $_.Name -notin $excludedTopLevel + $_.Name -notin $excludedTopLevel -and -not (Test-IsQtRuntimeFile $_) } | Copy-Item -Destination $binDir -Recurse -Force Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force diff --git a/server/.env.docker.example b/server/.env.docker.example index afedf2e..44f5b07 100644 --- a/server/.env.docker.example +++ b/server/.env.docker.example @@ -18,12 +18,21 @@ CORS_ALLOW_ORIGINS=* TARGET_PLATFORM=windows TARGET_ARCH=x64 -# 业务主程序文件名。必须和你发布目录根级的主 exe 名称一致。 -RELEASE_MAIN_EXECUTABLE=SimCAE.exe +# 业务主程序相对发布根目录的路径。SimCAE 默认在 bin/SimCAE.exe。 +RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe # Manifest 签名密钥编号。通常不用改,换签名密钥体系时再改。 SIGNING_KEY_ID=manifest-key-v1 +# 发布新版本保护。超过上限会直接拒绝,避免大目录上传把服务器磁盘或内存拖死。 +# 如果你的正式发布包超过 4GB,可以按服务器磁盘空间调大。 +PUBLISH_MAX_REQUEST_MB=4096 +PUBLISH_MAX_FILES=20000 +# 通常不用改;每个上传文件还会带一个 relative_paths 字段,所以要大于 PUBLISH_MAX_FILES。 +PUBLISH_MAX_FIELDS=20100 +# 上传时额外保留的磁盘空间,单位 MB。通常不用改。 +UPLOAD_SPACE_RESERVE_MB=256 + # 客户端访问服务端 API 的令牌。已给默认值,可直接试跑;正式部署建议修改,并填到客户端 app_config.json 的 client_token。 CLIENT_API_TOKEN=SimCAEClientToken2026 diff --git a/server/.env.example b/server/.env.example index 23b0e6b..a306efd 100644 --- a/server/.env.example +++ b/server/.env.example @@ -6,11 +6,11 @@ SERVER_PORT=8000 APP_UID=1000 APP_GID=1000 SERVICE_TITLE=Software Update Service -ADMIN_HTML_PATH=../client/admin.html +ADMIN_HTML_PATH=admin.html CORS_ALLOW_ORIGINS=* TARGET_PLATFORM=windows TARGET_ARCH=x64 -RELEASE_MAIN_EXECUTABLE=MainApp.exe +RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe SIGNING_KEY_ID=manifest-key-v1 # 客户端访问服务端 API 的令牌。已给默认值,可直接试跑;正式部署建议修改,并填到客户端 app_config.json 的 client_token。 @@ -27,6 +27,13 @@ MANIFEST_PRIVATE_KEY_PATH=keys/manifest_private_key.pem # 上传暂存和本地兜底存储目录。通常不用改。 UPLOAD_SPOOL_DIR=upload_spool UPLOAD_SPACE_RESERVE_MB=256 + +# 发布新版本保护。超过上限会直接拒绝,避免大目录上传把服务器磁盘或内存拖死。 +# 如果你的正式发布包超过 4GB,可以按服务器磁盘空间调大。 +PUBLISH_MAX_REQUEST_MB=4096 +PUBLISH_MAX_FILES=20000 +# 通常不用改;每个上传文件还会带一个 relative_paths 字段,所以要大于 PUBLISH_MAX_FILES。 +PUBLISH_MAX_FIELDS=20100 LOCAL_UPLOAD_ROOT=local_uploads LOCAL_FILE_URL_BASE=http://127.0.0.1:8000/static diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..9ef82b1 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,59 @@ +# Operating systems and editors +.DS_Store +Thumbs.db +Desktop.ini +.vscode/ +.idea/ + +# Temporary files +*.tmp +*.temp +*.bak +*.swp +*.log +*~ + +# Private requirement / handover documents +*.doc +*.docx +*交付说明*.md + +# Python environments and caches +venv/ +.venv/ +__pycache__/ +**/__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Local configuration and secrets +.env +.env.* +!.env.example +!.env.docker.example +admin_token.sha256 +keys/*private*.pem +keys/*private*.key + +# Runtime databases, uploads, object storage and generated packages +*.db +*.db-journal +*.db-wal +*.db-shm +local_uploads/ +upload_spool/ +crash_storage/ +minio_data/ +runtime/ +dist/ +minio + +# Runtime files +*.pid + +# Docker/local generated files +.dockerignore.local diff --git a/server/Dockerfile b/server/Dockerfile index e0dad4d..2bccc45 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -6,12 +6,15 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app RUN addgroup --system updateapp && adduser --system --ingroup updateapp updateapp +RUN apt-get update \ + && apt-get install -y --no-install-recommends libarchive-tools \ + && rm -rf /var/lib/apt/lists/* COPY server/requirements.txt ./requirements.txt RUN pip install --no-cache-dir -r requirements.txt COPY server/main.py server/db.py server/minio_tool.py server/tables.sql ./ -COPY --chown=updateapp:updateapp client/admin.html ./admin.html +COPY --chown=updateapp:updateapp server/admin.html ./admin.html RUN mkdir -p /data/uploads /data/upload_spool /run/secrets/update-keys \ && chown -R updateapp:updateapp /app /data \ diff --git a/server/admin.html b/server/admin.html new file mode 100644 index 0000000..eb85fa5 --- /dev/null +++ b/server/admin.html @@ -0,0 +1,2428 @@ + + + + + + Marsco 发布控制台 + + +
+

软件发布控制台

+
+ +
+ + + + + + + + diff --git a/server/docker-compose.image.yml b/server/docker-compose.image.yml index 45542c0..5304d09 100644 --- a/server/docker-compose.image.yml +++ b/server/docker-compose.image.yml @@ -42,12 +42,13 @@ services: environment: SERVER_HOST: 0.0.0.0 SERVER_PORT: 8000 + ENV_FILE_PATH: /app/.env DB_FILE: /data/mini.db SQL_FILE: /app/tables.sql ADMIN_HTML_PATH: /app/admin.html LOCAL_UPLOAD_ROOT: /data/uploads UPLOAD_SPOOL_DIR: /data/upload_spool - MINIO_DATA_DIR: /data + MINIO_DATA_DIR: /minio_data CRASH_STORAGE_ROOT: /data/crash_storage MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem MINIO_ENDPOINT: minio:9000 @@ -57,7 +58,9 @@ services: ports: - "${SERVER_PORT:-8000}:8000" volumes: + - ./.env:/app/.env - ./runtime:/data + - ./minio_data:/minio_data:ro - ./keys:/run/secrets/update-keys:ro depends_on: minio: diff --git a/server/docker-compose.yml b/server/docker-compose.yml index d37e158..73f677a 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -44,12 +44,13 @@ services: environment: SERVER_HOST: 0.0.0.0 SERVER_PORT: 8000 + ENV_FILE_PATH: /app/.env DB_FILE: /data/mini.db SQL_FILE: /app/tables.sql ADMIN_HTML_PATH: /app/admin.html LOCAL_UPLOAD_ROOT: /data/uploads UPLOAD_SPOOL_DIR: /data/upload_spool - MINIO_DATA_DIR: /data + MINIO_DATA_DIR: /minio_data CRASH_STORAGE_ROOT: /data/crash_storage MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem MINIO_ENDPOINT: minio:9000 @@ -59,7 +60,9 @@ services: ports: - "${SERVER_PORT:-8000}:8000" volumes: + - ./.env:/app/.env - ./runtime:/data + - ./minio_data:/minio_data:ro - ./keys:/run/secrets/update-keys:ro depends_on: minio: diff --git a/server/main.py b/server/main.py index f2b10ef..d92882d 100644 --- a/server/main.py +++ b/server/main.py @@ -7,6 +7,7 @@ import sqlite3 from pathlib import Path, PurePosixPath from dotenv import load_dotenv from contextlib import asynccontextmanager +import asyncio import db import minio_tool import hashlib @@ -22,13 +23,18 @@ import time import shutil import tempfile import urllib.request +import zipfile +import tarfile from cryptography.hazmat.primitives.serialization import load_pem_private_key from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives import hashes # 加载环境变量 -load_dotenv() BASE_DIR = Path(__file__).resolve().parent +ENV_FILE_PATH = Path(os.getenv("ENV_FILE_PATH", str(BASE_DIR / ".env"))).expanduser() +if not ENV_FILE_PATH.is_absolute(): + ENV_FILE_PATH = BASE_DIR / ENV_FILE_PATH +load_dotenv(ENV_FILE_PATH) def env_bool(name: str, default: bool = False) -> bool: @@ -46,7 +52,7 @@ SERVER_PORT = int(os.getenv("SERVER_PORT", "8000")) VALID_CLIENT_TOKEN = os.getenv("CLIENT_API_TOKEN") TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows") TARGET_ARCH = os.getenv("TARGET_ARCH", "x64") -RELEASE_MAIN_EXECUTABLE = os.getenv("RELEASE_MAIN_EXECUTABLE", "MainApp.exe").strip() +RELEASE_MAIN_EXECUTABLE = os.getenv("RELEASE_MAIN_EXECUTABLE", "MainApp.exe").strip().replace(chr(92), "/").strip("/") SIGNING_KEY_ID = os.getenv("SIGNING_KEY_ID", "manifest-key-v1") # 大型 multipart 上传使用内存文件系统暂存,避免与 MinIO 对象双重占用根分区。 @@ -55,6 +61,10 @@ UPLOAD_SPOOL_DIR.mkdir(parents=True, exist_ok=True) tempfile.tempdir = str(UPLOAD_SPOOL_DIR) MINIO_DATA_DIR = configured_path("MINIO_DATA_DIR", "minio_data") UPLOAD_SPACE_RESERVE = int(os.getenv("UPLOAD_SPACE_RESERVE_MB", "256")) * 1024 * 1024 +PUBLISH_MAX_REQUEST_BYTES = int(os.getenv("PUBLISH_MAX_REQUEST_MB", "4096")) * 1024 * 1024 +PUBLISH_MAX_FILES = int(os.getenv("PUBLISH_MAX_FILES", "20000")) +PUBLISH_MAX_FIELDS = int(os.getenv("PUBLISH_MAX_FIELDS", str(PUBLISH_MAX_FILES + 100))) +PUBLISH_LOCK = asyncio.Lock() # SimCAE 崩溃报告私有存储与上传限制 CRASH_STORAGE_ROOT = configured_path("CRASH_STORAGE_ROOT", "crash_storage") @@ -73,6 +83,37 @@ if not ADMIN_TOKEN: def token_digest(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() + +def quote_env_value(value: str) -> str: + unsafe_chars = set(" \t\n\r#\"'") + if value and not any(ch in unsafe_chars for ch in value): + return value + return '"' + value.replace('\\', '\\\\').replace('"', '\"') + '"' + + +def persist_env_value(path: Path, key: str, value: str) -> bool: + line = f"{key}={quote_env_value(value)}\n" + if path.exists(): + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + replaced = False + for index, item in enumerate(lines): + stripped = item.lstrip() + if stripped.startswith("#") or "=" not in stripped: + continue + name = stripped.split("=", 1)[0].strip() + if name == key: + lines[index] = line + replaced = True + break + if not replaced: + if lines and not lines[-1].endswith(("\n", "\r")): + lines[-1] += "\n" + lines.append(line) + path.write_text("".join(lines), encoding="utf-8") + return True + path.write_text(line, encoding="utf-8") + return True + CRASH_REPORT_TOKEN = os.getenv("CRASH_REPORT_TOKEN") or os.getenv("SIMCAE_CRASH_CLIENT_TOKEN") or VALID_CLIENT_TOKEN CRASH_SYMBOL_TOKEN = os.getenv("CRASH_SYMBOL_TOKEN") or os.getenv("SIMCAE_SYMBOL_TOKEN") or ADMIN_TOKEN CRASH_ADMIN_TOKEN = os.getenv("CRASH_ADMIN_TOKEN") @@ -189,7 +230,7 @@ MANIFEST_PRIVATE_KEY_PATH = str(configured_path("MANIFEST_PRIVATE_KEY_PATH", "ke # 创建APP绑定生命周期 app = FastAPI(title=os.getenv("SERVICE_TITLE", "软件自动升级服务"), lifespan=lifespan) -ADMIN_HTML_PATH = configured_path("ADMIN_HTML_PATH", "../client/admin.html") +ADMIN_HTML_PATH = configured_path("ADMIN_HTML_PATH", "admin.html") @app.get("/", include_in_schema=False) @@ -220,7 +261,8 @@ app.add_middleware( # 管理后台写操作统一审计;不保存令牌明文和请求正文。 @app.middleware("http") async def admin_audit_middleware(request: Request, call_next): - should_audit = request.url.path.startswith("/admin/") and request.method not in ("GET", "HEAD", "OPTIONS") + should_audit = (request.url.path.startswith("/admin/") and request.method not in ("GET", "HEAD", "OPTIONS") + and not request.url.path.startswith("/admin/audit-log/")) status_code = 500 try: response = await call_next(request); status_code = response.status_code; return response @@ -447,16 +489,272 @@ def normalize_relative_path(raw_path: str) -> str: def validate_release_path(relative_path: str): folded = relative_path.casefold() parts = PurePosixPath(folded).parts - blocked_directories = {".git", ".vs", "cmakefiles", "debug", "update", "update_temp"} - blocked_suffixes = ("_autogen",) for part in parts[:-1]: - if (part in blocked_directories or part.startswith("build") - or part.endswith(blocked_suffixes)): + if should_skip_release_directory(part): raise HTTPException( status_code=400, detail=f"发布目录包含构建或运行时目录,必须选择干净的 Release 输出目录: {relative_path}" ) + +def should_skip_release_directory(part: str) -> bool: + folded = part.casefold() + blocked_directories = {".git", ".vs", "cmakefiles", "debug", "update", "update_temp"} + return folded in blocked_directories or folded.startswith("build") or folded.endswith("_autogen") + + +def should_skip_release_file(relative_path: str) -> bool: + folded = relative_path.casefold() + runtime_protected_files = { + "client.ini", + "bootstrap.exe", + "config/app_config.json", + "config/local_state.json", + "config/client_identity.dat", + "config/version_policy.dat", + } + if folded in runtime_protected_files: + return True + if folded.startswith("bin/") and folded[4:] in runtime_protected_files: + return True + if any(folded.endswith("/" + protected) for protected in runtime_protected_files): + return True + if any(folded.endswith("/bin/" + protected) for protected in runtime_protected_files): + return True + return folded.endswith((".pdb", ".ilk", ".obj")) + + +def should_skip_release_path(relative_path: str) -> bool: + folded = relative_path.casefold() + parts = PurePosixPath(folded).parts + if any(should_skip_release_directory(part) for part in parts[:-1]): + return True + return should_skip_release_file(relative_path) + + +def calc_local_file_sha256(path: Path, chunk_size: int = 1024 * 1024): + sha = hashlib.sha256() + total = 0 + try: + with path.open("rb") as stream: + while True: + chunk = stream.read(chunk_size) + if not chunk: + break + sha.update(chunk) + total += len(chunk) + except OSError as err: + raise HTTPException(status_code=500, detail={"error": "local_file_read_failed", "msg": str(err), "path": str(path)}) + return sha.hexdigest(), total + + +def supported_archive_kind(filename: str) -> str: + name = (filename or "").casefold() + if name.endswith(".zip"): + return "zip" + if name.endswith((".tar.gz", ".tgz", ".tar.bz2", ".tbz2")): + return "tar" + if name.endswith(".rar"): + return "rar" + return "" + + +def ensure_inside_directory(root: Path, relative_path: str) -> Path: + target = (root / relative_path).resolve() + root_resolved = root.resolve() + if target != root_resolved and root_resolved not in target.parents: + raise HTTPException(status_code=400, detail=f"压缩包包含不安全路径: {relative_path}") + return target + + +def check_extracted_publish_limits(total_size: int, file_count: int): + if PUBLISH_MAX_FILES > 0 and file_count > PUBLISH_MAX_FILES: + raise HTTPException(status_code=413, detail=f"压缩包解压后文件数量过多:{file_count},当前上限为 {PUBLISH_MAX_FILES}") + if PUBLISH_MAX_REQUEST_BYTES > 0 and total_size > PUBLISH_MAX_REQUEST_BYTES: + raise HTTPException(status_code=413, detail=publish_space_detail( + "publish_extracted_too_large", + f"压缩包解压后总大小过大,当前上限为 {PUBLISH_MAX_REQUEST_BYTES // 1024 // 1024} MB", + total_size, + PUBLISH_MAX_REQUEST_BYTES, + )) + + +def copy_member_stream_to_path(source, target: Path, size: int | None, limits: dict): + target.parent.mkdir(parents=True, exist_ok=True) + total = 0 + with target.open("wb") as output: + while True: + chunk = source.read(1024 * 1024) + if not chunk: + break + output.write(chunk) + total += len(chunk) + limits["total_size"] += len(chunk) + check_extracted_publish_limits(limits["total_size"], limits["file_count"]) + if size is not None and size >= 0 and total != size: + raise HTTPException(status_code=400, detail=f"压缩包文件大小异常: {target.name}") + + +def extract_zip_archive(archive_path: Path, extract_dir: Path): + limits = {"total_size": 0, "file_count": 0} + try: + with zipfile.ZipFile(archive_path) as archive: + for info in archive.infolist(): + if info.is_dir(): + continue + rel_path = normalize_relative_path(info.filename) + if should_skip_release_path(rel_path): + continue + target = ensure_inside_directory(extract_dir, rel_path) + limits["file_count"] += 1 + check_extracted_publish_limits(limits["total_size"] + max(info.file_size, 0), limits["file_count"]) + with archive.open(info, "r") as source: + copy_member_stream_to_path(source, target, info.file_size, limits) + except zipfile.BadZipFile: + raise HTTPException(status_code=400, detail="zip 发布包无效或已损坏") + + +def extract_tar_archive(archive_path: Path, extract_dir: Path): + limits = {"total_size": 0, "file_count": 0} + try: + with tarfile.open(archive_path, mode="r:*") as archive: + for member in archive.getmembers(): + if not member.isfile(): + continue + rel_path = normalize_relative_path(member.name) + if should_skip_release_path(rel_path): + continue + target = ensure_inside_directory(extract_dir, rel_path) + limits["file_count"] += 1 + check_extracted_publish_limits(limits["total_size"] + max(member.size, 0), limits["file_count"]) + source = archive.extractfile(member) + if source is None: + raise HTTPException(status_code=400, detail=f"无法读取压缩包文件: {member.name}") + with source: + copy_member_stream_to_path(source, target, member.size, limits) + except tarfile.TarError: + raise HTTPException(status_code=400, detail="tar 发布包无效或已损坏") + + +def extract_rar_archive(archive_path: Path, extract_dir: Path): + commands = [] + if shutil.which("bsdtar"): + commands.append(["bsdtar", "-xf", str(archive_path), "-C", str(extract_dir)]) + if shutil.which("unrar"): + commands.append(["unrar", "x", "-o+", "-idq", str(archive_path), str(extract_dir) + os.sep]) + if shutil.which("7z"): + commands.append(["7z", "x", "-y", f"-o{extract_dir}", str(archive_path)]) + if not commands: + raise HTTPException( + status_code=400, + detail="服务器未安装 rar 解压工具,无法解压 .rar。请安装 bsdtar/unrar/7z,或上传 zip/tar.gz/tar.bz2 发布包" + ) + last_error = "" + for command in commands: + try: + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=3600) + except Exception as err: + last_error = str(err) + continue + if result.returncode == 0: + return + last_error = (result.stderr or result.stdout or "").strip() + raise HTTPException(status_code=400, detail=f"rar 发布包解压失败: {last_error or 'unknown error'}") + + +def extract_release_archive(archive_path: Path, filename: str, extract_dir: Path): + kind = supported_archive_kind(filename) + if kind == "zip": + extract_zip_archive(archive_path, extract_dir) + elif kind == "tar": + extract_tar_archive(archive_path, extract_dir) + elif kind == "rar": + extract_rar_archive(archive_path, extract_dir) + else: + raise HTTPException(status_code=400, detail="压缩发布包仅支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar") + + +def release_items_from_extracted_dir(extract_dir: Path): + items = [] + for path in extract_dir.rglob("*"): + if path.is_symlink() or not path.is_file(): + continue + rel_path = normalize_relative_path(path.relative_to(extract_dir).as_posix()) + if should_skip_release_path(rel_path): + continue + items.append({"path": rel_path, "local_path": path}) + return normalize_release_item_roots(items) + + +def normalize_release_item_roots(items: list[dict]): + if not items: + return items + required_main_key = RELEASE_MAIN_EXECUTABLE.casefold() + lower_paths = [item["path"].casefold() for item in items] + if required_main_key in lower_paths: + return items + nested_main = next((path for path in lower_paths if path.endswith("/" + required_main_key)), None) + if not nested_main: + return items + top_levels = set() + for item in items: + parts = PurePosixPath(item["path"]).parts + if not parts: + continue + top_levels.add(parts[0]) + if len(top_levels) != 1: + return items + prefix = next(iter(top_levels)) + "/" + normalized = [] + for item in items: + if not item["path"].startswith(prefix): + return items + stripped = item["path"][len(prefix):] + if stripped: + clone = dict(item) + clone["path"] = stripped + normalized.append(clone) + return normalized + + +def validate_release_items(items: list[dict]): + if not items: + raise HTTPException(status_code=400, detail="未选择任何文件,请选择程序文件夹或压缩发布包") + if len(items) > PUBLISH_MAX_FILES: + raise HTTPException(status_code=413, detail=f"文件数量过多:{len(items)},当前上限为 {PUBLISH_MAX_FILES}") + + seen_paths = set() + filtered = [] + for item in items: + rel_path = normalize_relative_path(item["path"]) + if should_skip_release_path(rel_path): + continue + validate_release_path(rel_path) + path_key = rel_path.casefold() + if path_key in seen_paths: + raise HTTPException(status_code=400, detail=f"存在重复文件路径: {rel_path}") + seen_paths.add(path_key) + clone = dict(item) + clone["path"] = rel_path + filtered.append(clone) + + if not filtered: + raise HTTPException(status_code=400, detail="发布内容为空;请确认发布目录或压缩包中包含程序文件") + + required_main_key = RELEASE_MAIN_EXECUTABLE.casefold() + if required_main_key not in seen_paths: + nested_main = next((path for path in seen_paths if path.endswith("/" + required_main_key)), None) + if nested_main: + raise HTTPException(status_code=400, detail=f"{RELEASE_MAIN_EXECUTABLE} 位于更深层目录,请选择包含该相对路径的正确发布根目录。当前发现: {nested_main}") + raise HTTPException(status_code=400, detail=f"发布目录中缺少 {RELEASE_MAIN_EXECUTABLE}") + nested_main = next((path for path in seen_paths if path.endswith("/" + required_main_key)), None) + if nested_main: + raise HTTPException( + status_code=400, + detail=f"发布目录中存在嵌套的重复主程序 {nested_main},请使用干净的 Release 输出目录" + ) + return filtered + # 文件sha256工具 def calc_sha256(data: bytes): sha = hashlib.sha256() @@ -464,6 +762,69 @@ def calc_sha256(data: bytes): return sha.hexdigest() +def calc_upload_file_sha256(upload, chunk_size: int = 1024 * 1024): + stream = getattr(upload, "file", None) + if stream is None: + raise HTTPException(status_code=400, detail="上传文件对象无效") + sha = hashlib.sha256() + total = 0 + try: + stream.seek(0) + while True: + chunk = stream.read(chunk_size) + if not chunk: + break + if isinstance(chunk, str): + chunk = chunk.encode("utf-8") + sha.update(chunk) + total += len(chunk) + stream.seek(0) + except OSError as err: + raise HTTPException(status_code=500, detail={"error": "upload_file_read_failed", "msg": str(err)}) + return sha.hexdigest(), total + + +async def close_upload_safely(upload): + try: + close = getattr(upload, "close", None) + if close: + result = close() + if hasattr(result, "__await__"): + await result + return + except Exception as err: + print(f"close upload temp file failed: {err}") + stream = getattr(upload, "file", None) + if stream is not None: + try: + stream.close() + except Exception as err: + print(f"close upload stream failed: {err}") + + +def publish_space_detail(error: str, msg: str, required_bytes: int, available_bytes: int): + return { + "error": error, + "msg": msg, + "required_bytes": required_bytes, + "available_bytes": available_bytes, + "required_mb": round(required_bytes / 1024 / 1024, 2), + "available_mb": round(available_bytes / 1024 / 1024, 2), + } + + +def ensure_publish_storage_space(next_file_size: int): + required_bytes = max(next_file_size, 0) + UPLOAD_SPACE_RESERVE + available_bytes = shutil.disk_usage(MINIO_DATA_DIR).free + if available_bytes < required_bytes: + raise HTTPException(status_code=507, detail=publish_space_detail( + "storage_space_insufficient", + "版本存储空间不足,已停止发布。请删除旧版本、清理磁盘或扩容后重试", + required_bytes, + available_bytes, + )) + + class CrashApiException(Exception): def __init__(self, status_code: int, code: str, message: str, retryable: bool = False): self.status_code = status_code @@ -1235,8 +1596,13 @@ def admin_change_token(body: ChangeAdminTokenReq, auth=Depends(admin_auth)): raise HTTPException(status_code=400, detail="新令牌至少需要 8 个字符") if len(new_token) > 128: raise HTTPException(status_code=400, detail="新令牌不能超过 128 个字符") + try: + persist_env_value(ENV_FILE_PATH, "ADMIN_TOKEN", new_token) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"令牌已通过校验,但写入 .env 失败: {exc}") ADMIN_TOKEN = new_token - return {"code": 0, "msg": "管理员令牌已在当前服务进程生效;如需重启后继续使用,请同步修改 .env 的 ADMIN_TOKEN"} + os.environ["ADMIN_TOKEN"] = new_token + return {"code": 0, "msg": f"管理员令牌已更新,并已写入 {ENV_FILE_PATH}"} # 应用列表 GET @app.get("/admin/app/list") @@ -1322,178 +1688,238 @@ def admin_save_policy(body: dict, auth=Depends(admin_auth)): # 发布新版本(文件上传) +async def admin_publish_version_locked(request: Request): + upload_items = [] + archive_upload = None + archive_temp_dir = None + try: + # 在 Starlette 解析 multipart 前检查空间;否则大文件 rollover 会直接抛出 Errno 28。 + content_length_header = request.headers.get("content-length") + try: + content_length = int(content_length_header or 0) + except ValueError: + raise HTTPException(status_code=400, detail="无效的 Content-Length") + if content_length <= 0: + raise HTTPException(status_code=411, detail="发布请求必须提供 Content-Length") + if PUBLISH_MAX_REQUEST_BYTES > 0 and content_length > PUBLISH_MAX_REQUEST_BYTES: + raise HTTPException(status_code=413, detail=publish_space_detail( + "publish_request_too_large", + f"发布请求过大,当前上限为 {PUBLISH_MAX_REQUEST_BYTES // 1024 // 1024} MB。请减小发布目录、清理无关文件,或调整 PUBLISH_MAX_REQUEST_MB", + content_length, + PUBLISH_MAX_REQUEST_BYTES, + )) + + spool_free = shutil.disk_usage(UPLOAD_SPOOL_DIR).free + storage_free = shutil.disk_usage(MINIO_DATA_DIR).free + same_storage_device = os.stat(UPLOAD_SPOOL_DIR).st_dev == os.stat(MINIO_DATA_DIR).st_dev + if same_storage_device: + required_bytes = content_length * 2 + UPLOAD_SPACE_RESERVE + if spool_free < required_bytes: + raise HTTPException(status_code=507, detail=publish_space_detail( + "publish_space_insufficient", + "发布目录上传和版本存储位于同一磁盘,空间不足。请删除旧版本、扩容磁盘,或把 UPLOAD_SPOOL_DIR 放到其他磁盘", + required_bytes, + spool_free, + )) + else: + if spool_free < content_length + 64 * 1024 * 1024: + raise HTTPException(status_code=507, detail=publish_space_detail( + "upload_temp_space_insufficient", + "上传临时空间不足,请减小发布包或扩容上传临时目录", + content_length + 64 * 1024 * 1024, + spool_free, + )) + if storage_free < content_length + UPLOAD_SPACE_RESERVE: + raise HTTPException(status_code=507, detail=publish_space_detail( + "storage_space_insufficient", + "版本存储空间不足,请扩容服务器根卷或删除不再需要的历史版本", + content_length + UPLOAD_SPACE_RESERVE, + storage_free, + )) + + # 手动解析表单,增加兼容性并便于调试前端上传问题 + try: + form = await request.form(max_files=PUBLISH_MAX_FILES, max_fields=PUBLISH_MAX_FIELDS) + except OSError as err: + if getattr(err, "errno", None) == 28: + raise HTTPException(status_code=507, detail="上传临时空间已满,请扩容后重试") + raise + # 尝试从 form 中获取字段 + app_id = form.get("app_id") + channel = form.get("channel") or "" + version = form.get("version") + try: + client_protocol = int(form.get("client_protocol") or 2) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="client_protocol 必须是正整数") + if client_protocol < 1: + raise HTTPException(status_code=400, detail="client_protocol 必须是正整数") + + # 收集 files(可能是多个同名字段)和 archive(单个压缩包) + files = [] + archives = [] + if hasattr(form, "getlist"): + files = form.getlist("files") + archives = form.getlist("archive") + else: + # 兼容性降级:遍历所有表单项 + for k, v in form.multi_items(): + if k == "files": + files.append(v) + elif k == "archive": + archives.append(v) + files = [item for item in files if hasattr(item, "filename") and item.filename] + archives = [item for item in archives if hasattr(item, "filename") and item.filename] + + # 如果关键信息缺失,返回详细调试信息,方便前端定位问题 + if not app_id or not version: + headers = {k: v for k, v in request.headers.items()} + detail = { + "error": "missing form fields", + "have_app_id": bool(app_id), + "have_version": bool(version), + "content_type": request.headers.get("content-type"), + "headers": headers, + "form_keys": list(form.keys()), + "app_id_value": app_id, + "version_value": version, + "app_id_len": len(app_id or ""), + "version_len": len(version or "") + } + raise HTTPException(status_code=422, detail=detail) + + if files and archives: + raise HTTPException(status_code=400, detail="请只选择一种发布方式:软件根目录或压缩发布包") + if len(archives) > 1: + raise HTTPException(status_code=400, detail="一次只能上传一个压缩发布包") + if not files and not archives: + raise HTTPException(status_code=400, detail="未选择任何文件,请选择程序文件夹或压缩发布包") + + if archives: + archive_upload = archives[0] + archive_name = str(archive_upload.filename or "") + if not supported_archive_kind(archive_name): + raise HTTPException(status_code=400, detail="压缩发布包仅支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar") + archive_temp_dir = Path(tempfile.mkdtemp(prefix="publish_archive_", dir=UPLOAD_SPOOL_DIR)) + archive_path = archive_temp_dir / ("upload_" + secrets.token_hex(8)) + try: + archive_upload.file.seek(0) + with archive_path.open("wb") as target: + shutil.copyfileobj(archive_upload.file, target, length=1024 * 1024) + except OSError as err: + raise HTTPException(status_code=500, detail={"error": "archive_spool_failed", "msg": str(err)}) + extract_dir = archive_temp_dir / "extracted" + extract_dir.mkdir(parents=True, exist_ok=True) + extract_release_archive(archive_path, archive_name, extract_dir) + raw_items = release_items_from_extracted_dir(extract_dir) + upload_items = validate_release_items(raw_items) + extracted_total = sum(item["local_path"].stat().st_size for item in upload_items) + check_extracted_publish_limits(extracted_total, len(upload_items)) + else: + if len(files) > PUBLISH_MAX_FILES: + raise HTTPException(status_code=413, detail=f"文件数量过多:{len(files)},当前上限为 {PUBLISH_MAX_FILES}") + + relative_paths = form.getlist("relative_paths") if hasattr(form, "getlist") else [] + if relative_paths and len(relative_paths) != len(files): + raise HTTPException(status_code=400, detail="文件数量与相对路径数量不一致") + + raw_items = [] + for index, file in enumerate(files): + raw_path = relative_paths[index] if relative_paths else file.filename + raw_items.append({"upload": file, "path": str(raw_path)}) + upload_items = validate_release_items(raw_items) + + + conn = db.get_conn() + require_channel(conn, str(app_id), str(channel)) + publish_started = False + publish_prefix = f"{app_id}/{channel}/{version}/" + try: + cur = conn.cursor() + exists = cur.execute( + "SELECT 1 FROM versions WHERE app_id=? AND channel=? AND version=?", + (app_id, channel, version) + ).fetchone() + if exists: + raise HTTPException(status_code=400, detail="该版本已存在,请更换版本号或先删除旧版本") + + cur.execute("UPDATE versions SET latest=0 WHERE app_id=? AND channel=?", (app_id, channel)) + cur.execute( + "INSERT INTO versions(app_id, channel, version, latest, client_protocol) VALUES (?,?,?,1,?)", + (app_id, channel, version, client_protocol) + ) + new_vid = cur.lastrowid + publish_started = True + + for item in upload_items: + rel_path = item["path"] + if "upload" in item: + file = item["upload"] + f_sha, f_size = calc_upload_file_sha256(file) + stream = file.file + else: + local_path = item["local_path"] + f_sha, f_size = calc_local_file_sha256(local_path) + stream = local_path.open("rb") + try: + ensure_publish_storage_space(f_size) + obj_path = f"{app_id}/{channel}/{version}/files/{rel_path}" + put_result = minio_tool.put_file_stream(obj_path, stream, f_size) + if put_result.get('storage') == 'local': + print(f"MinIO 不可用,已保存到本地回退:{put_result.get('path')}") + cur.execute( + "INSERT INTO version_files(version_id, path, sha256, size) VALUES (?,?,?,?)", + (new_vid, rel_path, f_sha, f_size) + ) + finally: + if "local_path" in item: + stream.close() + + conn.commit() + except sqlite3.IntegrityError as err: + conn.rollback() + if publish_started: + minio_tool.remove_prefix(publish_prefix) + if 'UNIQUE constraint failed: versions.app_id, versions.channel, versions.version' in str(err): + raise HTTPException(status_code=400, detail="该版本已存在,请更换版本号或先删除旧版本") + raise HTTPException(status_code=500, detail=str(err)) + except sqlite3.OperationalError as err: + conn.rollback() + if publish_started: + minio_tool.remove_prefix(publish_prefix) + raise HTTPException(status_code=500, detail={"error": "database locked", "msg": str(err)}) + except HTTPException: + conn.rollback() + if publish_started: + minio_tool.remove_prefix(publish_prefix) + raise + except Exception as err: + conn.rollback() + if publish_started: + minio_tool.remove_prefix(publish_prefix) + if isinstance(err, OSError) and getattr(err, "errno", None) == 28: + raise HTTPException(status_code=507, detail="发布过程中存储空间耗尽,已清理未完成版本") + raise HTTPException(status_code=500, detail={"error": "publish_failed", "msg": str(err)}) + finally: + conn.close() + + return {"msg": f"版本 {version} 发布完成,共上传 {len(upload_items)} 个文件", "file_count": len(upload_items)} + finally: + for item in upload_items: + upload = item.get("upload") if isinstance(item, dict) else None + if upload is not None: + await close_upload_safely(upload) + if archive_upload is not None: + await close_upload_safely(archive_upload) + cleanup_path(archive_temp_dir) + + @app.post("/admin/publish") async def admin_publish_version(request: Request, auth=Depends(admin_auth)): - # 在 Starlette 解析 multipart 前检查空间;否则大文件 rollover 会直接抛出 Errno 28。 - content_length_header = request.headers.get("content-length") - try: - content_length = int(content_length_header or 0) - except ValueError: - raise HTTPException(status_code=400, detail="无效的 Content-Length") - if content_length <= 0: - raise HTTPException(status_code=411, detail="发布请求必须提供 Content-Length") - - spool_free = shutil.disk_usage(UPLOAD_SPOOL_DIR).free - storage_free = shutil.disk_usage(MINIO_DATA_DIR).free - if spool_free < content_length + 64 * 1024 * 1024: - raise HTTPException(status_code=507, detail={ - "error": "upload_temp_space_insufficient", - "msg": "上传临时空间不足,请减小发布包或扩容 /dev/shm", - "required_bytes": content_length + 64 * 1024 * 1024, - "available_bytes": spool_free, - }) - if storage_free < content_length + UPLOAD_SPACE_RESERVE: - raise HTTPException(status_code=507, detail={ - "error": "storage_space_insufficient", - "msg": "版本存储空间不足,请扩容服务器根卷或删除不再需要的历史版本", - "required_bytes": content_length + UPLOAD_SPACE_RESERVE, - "available_bytes": storage_free, - }) - - # 手动解析表单,增加兼容性并便于调试前端上传问题 - try: - form = await request.form() - except OSError as err: - if getattr(err, "errno", None) == 28: - raise HTTPException(status_code=507, detail="上传临时空间已满,请扩容后重试") - raise - # 尝试从 form 中获取字段 - app_id = form.get("app_id") - channel = form.get("channel") or "" - version = form.get("version") - try: - client_protocol = int(form.get("client_protocol") or 2) - except (TypeError, ValueError): - raise HTTPException(status_code=400, detail="client_protocol 必须是正整数") - if client_protocol < 1: - raise HTTPException(status_code=400, detail="client_protocol 必须是正整数") - - # 收集 files(可能是多个同名字段) - files = [] - if hasattr(form, "getlist"): - files = form.getlist("files") - else: - # 兼容性降级:遍历所有表单项 - for k, v in form.multi_items(): - if k == "files": - files.append(v) - - # 如果关键信息缺失,返回详细调试信息,方便前端定位问题 - if not app_id or not version: - headers = {k: v for k, v in request.headers.items()} - detail = { - "error": "missing form fields", - "have_app_id": bool(app_id), - "have_version": bool(version), - "content_type": request.headers.get("content-type"), - "headers": headers, - "form_keys": list(form.keys()), - "app_id_value": app_id, - "version_value": version, - "app_id_len": len(app_id or ""), - "version_len": len(version or "") - } - raise HTTPException(status_code=422, detail=detail) - - if not files: - raise HTTPException(status_code=400, detail="未选择任何文件,请选择程序文件夹") - - relative_paths = form.getlist("relative_paths") if hasattr(form, "getlist") else [] - if relative_paths and len(relative_paths) != len(files): - raise HTTPException(status_code=400, detail="文件数量与相对路径数量不一致") - - upload_items = [] - seen_paths = set() - for index, file in enumerate(files): - if not hasattr(file, "filename"): - raise HTTPException(status_code=400, detail="上传内容中包含无效文件") - raw_path = relative_paths[index] if relative_paths else file.filename - rel_path = normalize_relative_path(str(raw_path)) - validate_release_path(rel_path) - path_key = rel_path.lower() - if path_key in seen_paths: - raise HTTPException(status_code=400, detail=f"存在重复文件路径: {rel_path}") - seen_paths.add(path_key) - upload_items.append((file, rel_path)) - - required_main_key = RELEASE_MAIN_EXECUTABLE.casefold() - if required_main_key not in seen_paths: - nested_main = next((path for path in seen_paths if path.endswith("/" + required_main_key)), None) - if nested_main: - raise HTTPException(status_code=400, detail=f"{RELEASE_MAIN_EXECUTABLE} 不在发布根级,请改为选择其所在目录: {nested_main}") - raise HTTPException(status_code=400, detail=f"发布根目录中缺少 {RELEASE_MAIN_EXECUTABLE}") - nested_main = next((path for path in seen_paths if path.endswith("/" + required_main_key)), None) - if nested_main: - raise HTTPException( - status_code=400, - detail=f"发布目录中存在嵌套的重复主程序 {nested_main},请使用干净的 Release 输出目录" - ) - - - conn = db.get_conn() - require_channel(conn, str(app_id), str(channel)) - publish_started = False - publish_prefix = f"{app_id}/{channel}/{version}/" - try: - cur = conn.cursor() - exists = cur.execute( - "SELECT 1 FROM versions WHERE app_id=? AND channel=? AND version=?", - (app_id, channel, version) - ).fetchone() - if exists: - raise HTTPException(status_code=400, detail="该版本已存在,请更换版本号或先删除旧版本") - - cur.execute("UPDATE versions SET latest=0 WHERE app_id=? AND channel=?", (app_id, channel)) - cur.execute( - "INSERT INTO versions(app_id, channel, version, latest, client_protocol) VALUES (?,?,?,1,?)", - (app_id, channel, version, client_protocol) - ) - new_vid = cur.lastrowid - publish_started = True - - for file, rel_path in upload_items: - content = await file.read() - - f_size = len(content) - f_sha = calc_sha256(content) - obj_path = f"{app_id}/{channel}/{version}/files/{rel_path}" - put_result = minio_tool.put_file(obj_path, content, f_size) - if put_result.get('storage') == 'local': - print(f"MinIO 不可用,已保存到本地回退:{put_result.get('path')}") - cur.execute( - "INSERT INTO version_files(version_id, path, sha256, size) VALUES (?,?,?,?)", - (new_vid, rel_path, f_sha, f_size) - ) - - conn.commit() - except sqlite3.IntegrityError as err: - conn.rollback() - if publish_started: - minio_tool.remove_prefix(publish_prefix) - if 'UNIQUE constraint failed: versions.app_id, versions.channel, versions.version' in str(err): - raise HTTPException(status_code=400, detail="该版本已存在,请更换版本号或先删除旧版本") - raise HTTPException(status_code=500, detail=str(err)) - except sqlite3.OperationalError as err: - conn.rollback() - if publish_started: - minio_tool.remove_prefix(publish_prefix) - raise HTTPException(status_code=500, detail={"error": "database locked", "msg": str(err)}) - except HTTPException: - conn.rollback() - if publish_started: - minio_tool.remove_prefix(publish_prefix) - raise - except Exception as err: - conn.rollback() - if publish_started: - minio_tool.remove_prefix(publish_prefix) - if isinstance(err, OSError) and getattr(err, "errno", None) == 28: - raise HTTPException(status_code=507, detail="发布过程中存储空间耗尽,已清理未完成版本") - raise HTTPException(status_code=500, detail={"error": "publish_failed", "msg": str(err)}) - finally: - conn.close() - - return {"msg": f"版本 {version} 发布完成,共上传 {len(upload_items)} 个文件"} + if PUBLISH_LOCK.locked(): + raise HTTPException(status_code=409, detail="已有版本发布任务正在进行,请等待完成后再发布") + async with PUBLISH_LOCK: + return await admin_publish_version_locked(request) @app.post("/admin/version/offline-package") def admin_offline_package(body: dict, auth=Depends(admin_auth)): @@ -1605,13 +2031,14 @@ def admin_delete_version(body: dict, auth=Depends(admin_auth)): def admin_get_report_log(auth=Depends(admin_auth)): conn = db.get_conn() rows = conn.execute(""" - SELECT device_id,from_ver,to_ver,result,create_time + SELECT id,device_id,from_ver,to_ver,result,create_time FROM upgrade_logs ORDER BY create_time DESC LIMIT 200 """).fetchall() conn.close() res = [] for r in rows: res.append({ + "id": r["id"], "device_id": r["device_id"], "app_id": "", "from_version": r["from_ver"], @@ -1622,12 +2049,36 @@ def admin_get_report_log(auth=Depends(admin_auth)): return {"list": res} +@app.post("/admin/report/delete") +def admin_delete_report_log(body: dict, auth=Depends(admin_auth)): + log_id = int(body.get("id") or 0) + if log_id < 1: + raise HTTPException(status_code=400, detail="日志 ID 无效") + conn = db.get_conn() + cur = conn.execute("DELETE FROM upgrade_logs WHERE id=?", (log_id,)) + conn.commit(); conn.close() + if cur.rowcount != 1: + raise HTTPException(status_code=404, detail="升级日志不存在") + return {"msg": "升级日志已删除", "deleted": cur.rowcount} + + +@app.post("/admin/report/clear") +def admin_clear_report_logs(auth=Depends(admin_auth)): + conn = db.get_conn() + cur = conn.execute("DELETE FROM upgrade_logs") + conn.commit(); conn.close() + return {"msg": "升级日志已清空", "deleted": cur.rowcount} + + @app.get("/admin/runtime-config") def admin_runtime_config(auth=Depends(admin_auth)): return { "release_main_executable": RELEASE_MAIN_EXECUTABLE, "target_platform": TARGET_PLATFORM, "target_arch": TARGET_ARCH, + "publish_max_request_bytes": PUBLISH_MAX_REQUEST_BYTES, + "publish_max_files": PUBLISH_MAX_FILES, + "publish_max_fields": PUBLISH_MAX_FIELDS, } @@ -1635,10 +2086,128 @@ def admin_runtime_config(auth=Depends(admin_auth)): def admin_download_log_list(app_id: str="", limit: int=300, auth=Depends(admin_auth)): limit=max(1,min(limit,1000));conn=db.get_conn();rows=conn.execute("SELECT * FROM download_logs WHERE app_id=? ORDER BY id DESC LIMIT ?",(app_id,limit)).fetchall() if app_id else conn.execute("SELECT * FROM download_logs ORDER BY id DESC LIMIT ?",(limit,)).fetchall();conn.close();return {"list":[dict(r) for r in rows]} + +@app.post("/admin/download-log/delete") +def admin_download_log_delete(body: dict, auth=Depends(admin_auth)): + log_id = int(body.get("id") or 0) + if log_id < 1: + raise HTTPException(status_code=400, detail="日志 ID 无效") + conn = db.get_conn() + cur = conn.execute("DELETE FROM download_logs WHERE id=?", (log_id,)) + conn.commit(); conn.close() + if cur.rowcount != 1: + raise HTTPException(status_code=404, detail="下载日志不存在") + return {"msg": "下载日志已删除", "deleted": cur.rowcount} + + +@app.post("/admin/download-log/clear") +def admin_download_log_clear(body: dict = Body(default={}), auth=Depends(admin_auth)): + app_id = str((body or {}).get("app_id") or "").strip() + conn = db.get_conn() + if app_id: + cur = conn.execute("DELETE FROM download_logs WHERE app_id=?", (app_id,)) + else: + cur = conn.execute("DELETE FROM download_logs") + conn.commit(); conn.close() + return {"msg": "下载日志已清空", "deleted": cur.rowcount} + + @app.get("/admin/audit-log/list") def admin_audit_log_list(limit: int=300, auth=Depends(admin_auth)): conn=db.get_conn();rows=conn.execute("SELECT * FROM admin_audit_logs ORDER BY id DESC LIMIT ?",(max(1,min(limit,1000)),)).fetchall();conn.close();return {"list":[dict(r) for r in rows]} + +@app.post("/admin/audit-log/delete") +def admin_audit_log_delete(body: dict, auth=Depends(admin_auth)): + log_id = int(body.get("id") or 0) + if log_id < 1: + raise HTTPException(status_code=400, detail="日志 ID 无效") + conn = db.get_conn() + cur = conn.execute("DELETE FROM admin_audit_logs WHERE id=?", (log_id,)) + conn.commit(); conn.close() + if cur.rowcount != 1: + raise HTTPException(status_code=404, detail="审计日志不存在") + return {"msg": "审计日志已删除", "deleted": cur.rowcount} + + +@app.post("/admin/audit-log/clear") +def admin_audit_log_clear(auth=Depends(admin_auth)): + conn = db.get_conn() + cur = conn.execute("DELETE FROM admin_audit_logs") + conn.commit(); conn.close() + return {"msg": "审计日志已清空", "deleted": cur.rowcount} + + +@app.get("/admin/crash-report/list") +def admin_crash_report_list(limit: int = 300, auth=Depends(admin_auth)): + limit = max(1, min(limit, 1000)) + conn = db.get_conn() + rows = conn.execute( + """ + SELECT + id,report_id,client_report_id,status,symbolication_status,product,app_version, + git_commit,build_type,channel,exception_code,crash_time_utc,received_at_utc, + remote_address,content_length,metadata_size,minidump_size,attachments_size + FROM crash_reports + ORDER BY id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + conn.close() + return {"list": [dict(row) for row in rows]} + + +@app.get("/admin/crash-report/file/{report_id}/{file_name}") +def admin_crash_report_file( + report_id: str, + file_name: str, + request: Request, + X_Admin_Token: str = Header(""), + auth=Depends(admin_auth), +): + allowed = { + "metadata": "metadata.json", + "metadata.json": "metadata.json", + "minidump": "crash.dmp", + "crash.dmp": "crash.dmp", + "attachments": "attachments.zip", + "attachments.zip": "attachments.zip", + "server": "server.json", + "server.json": "server.json", + } + stored_name = allowed.get(file_name) + if not stored_name: + raise HTTPException(status_code=404, detail="崩溃报告文件不存在") + + conn = db.get_conn() + row = conn.execute("SELECT * FROM crash_reports WHERE report_id=?", (report_id,)).fetchone() + if not row: + conn.close() + raise HTTPException(status_code=404, detail="崩溃报告不存在") + + target = Path(row["storage_path"]) / stored_name + if not target.is_file(): + conn.close() + raise HTTPException(status_code=404, detail="崩溃报告文件不存在") + + try: + conn.execute( + "INSERT INTO crash_file_access_logs(report_id,file_name,actor_hash,ip,user_agent) VALUES(?,?,?,?,?)", + ( + report_id, + stored_name, + token_digest(X_Admin_Token)[:16], + request.client.host if request.client else "", + request.headers.get("user-agent", "")[:300], + ), + ) + conn.commit() + finally: + conn.close() + return FileResponse(target, media_type="application/octet-stream", filename=stored_name) + + @app.post("/admin/license/create") def admin_license_create(body: dict, auth=Depends(admin_auth)): app_id=str(body.get("app_id") or "").strip();channel=str(body.get("channel") or "").strip();customer=str(body.get("customer_name") or "").strip();max_devices=int(body.get("max_devices") or 1);valid_until=str(body.get("valid_until") or "").strip() diff --git a/server/minio_tool.py b/server/minio_tool.py index 0139bb3..6201586 100644 --- a/server/minio_tool.py +++ b/server/minio_tool.py @@ -105,6 +105,24 @@ def put_file(object_path: str, data: bytes, size: int): return {'storage': 'local', 'path': str(local_path)} + +def put_file_stream(object_path: str, source, size: int): + bucket = MINIO_BUCKET + try: + source.seek(0) + mc.put_object(bucket, object_path, source, length=size) + return {'storage': 'minio', 'path': object_path} + except Exception as e: + print(f"minio put_file_stream error: {e}") + local_path = LOCAL_UPLOAD_ROOT / object_path + local_path.parent.mkdir(parents=True, exist_ok=True) + source.seek(0) + with open(local_path, 'wb') as target: + shutil.copyfileobj(source, target, length=1024 * 1024) + print(f"local fallback stored {local_path}") + return {'storage': 'local', 'path': str(local_path)} + + def remove_prefix(prefix: str): bucket = MINIO_BUCKET try: diff --git a/server/package-offline-server.ps1 b/server/package-offline-server.ps1 deleted file mode 100644 index 8a68655..0000000 --- a/server/package-offline-server.ps1 +++ /dev/null @@ -1,95 +0,0 @@ -param( - [string]$Version = "0.1.0", - [string]$OutputDir = "$PSScriptRoot/dist/SimCAEServerDockerPackage", - [switch]$SkipBuild, - [switch]$SkipPull -) - -$ErrorActionPreference = "Stop" - -$serverDir = $PSScriptRoot -$projectRoot = (Resolve-Path (Join-Path $serverDir "..")).Path -$imageName = "simcae-update-server:$Version" -$minioImage = "minio/minio:RELEASE.2025-04-22T22-12-26Z" -$mcImage = "minio/mc:RELEASE.2025-04-16T18-13-26Z" -$packageDir = $OutputDir -$imagesDir = Join-Path $packageDir "images" -$keysDir = Join-Path $packageDir "keys" -$tarFile = Join-Path $imagesDir "simcae-server-all-images_$Version.tar" - -function Require-File($path, $message) { - if (-not (Test-Path $path)) { throw $message } -} - -Require-File (Join-Path $serverDir "Dockerfile") "server/Dockerfile is missing." -Require-File (Join-Path $serverDir "docker-compose.image.yml") "server/docker-compose.image.yml is missing." -Require-File (Join-Path $serverDir ".env.docker.example") "server/.env.docker.example is missing." -Require-File (Join-Path $serverDir "keys/manifest_private_key.pem") "server/keys/manifest_private_key.pem is missing." -Require-File (Join-Path $serverDir "keys/manifest_public_key.pem") "server/keys/manifest_public_key.pem is missing." - -if (-not $SkipBuild) { - docker build -t $imageName -f (Join-Path $serverDir "Dockerfile") $projectRoot -} - -if (-not $SkipPull) { - docker pull $minioImage - docker pull $mcImage -} - -if (Test-Path $packageDir) { Remove-Item $packageDir -Recurse -Force } -New-Item $imagesDir,$keysDir -ItemType Directory -Force | Out-Null - -Copy-Item (Join-Path $serverDir "docker-compose.image.yml") (Join-Path $packageDir "docker-compose.yml") -Force -Copy-Item (Join-Path $serverDir ".env.docker.example") (Join-Path $packageDir ".env.example") -Force -$readmeSource = Get-ChildItem $serverDir -File -Filter "*.md" | Where-Object { - $_.Name -like "*Docker*.md" -} | Select-Object -First 1 -if ($readmeSource) { - Copy-Item $readmeSource.FullName (Join-Path $packageDir "README.md") -Force -} -Copy-Item (Join-Path $serverDir "keys/manifest_private_key.pem") (Join-Path $keysDir "manifest_private_key.pem") -Force -Copy-Item (Join-Path $serverDir "keys/manifest_public_key.pem") (Join-Path $keysDir "manifest_public_key.pem") -Force - -$quickReadme = @" -# SimCAE Server Offline Docker Package - -1. Copy this package to the target server and extract it. -2. Run: ./load-images.ps1 -3. Edit .env and replace tokens, passwords, and SERVER_IP. -4. Run: docker compose up -d -5. Visit: http://SERVER_IP:8000/ - -Important: keys/manifest_private_key.pem is the server signing private key. Keep it secret. -"@ -$readmePath = Join-Path $packageDir "README.md" -if (Test-Path $readmePath) { - $oldReadme = Get-Content $readmePath -Raw -Encoding UTF8 - Set-Content $readmePath ($quickReadme + "`n---`n`n" + $oldReadme) -Encoding UTF8 -} else { - Set-Content $readmePath $quickReadme -Encoding UTF8 -} - -@" -`$ErrorActionPreference = "Stop" -docker load -i .\images\simcae-server-all-images_$Version.tar -if (-not (Test-Path .\.env)) { - Copy-Item .\.env.example .\.env - Write-Host "Created .env from .env.example. Edit .env before starting services." -} else { - Write-Host ".env already exists. Keeping existing file." -} -Write-Host "Next: edit .env, then run: docker compose up -d" -"@ | Set-Content (Join-Path $packageDir "load-images.ps1") -Encoding UTF8 - -$envExamplePath = Join-Path $packageDir ".env.example" -$envText = Get-Content $envExamplePath -Raw -Encoding UTF8 -$envText = $envText.Replace("SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0", "SIMCAE_UPDATE_SERVER_IMAGE=$imageName") -Set-Content $envExamplePath $envText -Encoding UTF8 - -docker save -o $tarFile $imageName $minioImage $mcImage - -Compress-Archive -Path (Join-Path $packageDir "*") -DestinationPath "$packageDir.zip" -CompressionLevel Optimal -Force - -Write-Host "Offline package directory: $packageDir" -Write-Host "Offline package zip: $packageDir.zip" -Write-Host "Images tar: $tarFile" diff --git a/server/package-offline-server.sh b/server/package-offline-server.sh index 02de630..73e7ee9 100755 --- a/server/package-offline-server.sh +++ b/server/package-offline-server.sh @@ -105,11 +105,15 @@ mkdir -p "$IMAGES_DIR" "$KEYS_DIR" cp "$SCRIPT_DIR/docker-compose.image.yml" "$PACKAGE_DIR/docker-compose.yml" cp "$SCRIPT_DIR/.env.docker.example" "$PACKAGE_DIR/.env.example" +cp "$SCRIPT_DIR/keys/manifest_private_key.pem" "$KEYS_DIR/manifest_private_key.pem" +cp "$SCRIPT_DIR/keys/manifest_public_key.pem" "$KEYS_DIR/manifest_public_key.pem" cat > "$PACKAGE_DIR/README.md" <<'EOF_README' # SimCAE 服务端 Docker 部署包说明 -这个包已经包含服务端运行需要的 Docker 镜像、配置模板、签名密钥和编排文件。你的服务器无论能不能联网,都可以按下面流程部署。 +这个包用于把 SimCAE 自动升级服务端部署到你的服务器。包里已经包含后端 API、MinIO 对象存储、初始化工具、配置模板、签名密钥和 Docker 编排文件;你的服务器即使不能联网,也可以按本文完成部署。 + +如果你只想先跑起来,按“二、最小部署流程”执行即可。后面的配置说明用于解释每个字段是什么意思、哪些必须改、哪些保持默认也能运行。 ## 一、这个包里面有什么 @@ -122,13 +126,24 @@ SimCAEServerDockerPackage/ .env.example 环境变量模板 load-images.sh 导入 Docker 镜像并创建 .env 的脚本 images/ - simcae-server-all-images___VERSION__.tar 三个 Docker 镜像:api、minio、minio-init + simcae-server-all-images___VERSION__.tar Docker 镜像包,包含 api、minio、minio-init keys/ manifest_private_key.pem 服务端 Manifest 签名私钥 manifest_public_key.pem 与客户端配套的验签公钥 ``` -## 二、你在服务器上怎么运行 +这些文件的关系可以这样理解: + +```text +images/*.tar 程序镜像包,相当于安装材料 +docker-compose.yml 容器部署图纸,说明启动哪些服务、端口怎么映射、目录怎么挂载 +.env 你的实际配置,第一次运行 load-images.sh 时由 .env.example 生成 +keys/ 发布版本时用于签名 Manifest,客户端用对应公钥验签 +runtime/ 启动后自动生成,保存服务端数据库、上传临时文件、崩溃报告等 +minio_data/ 启动后自动生成,保存升级包文件 +``` + +## 二、最小部署流程 先把 `SimCAEServerDockerPackage.tar.gz` 拷贝到要部署的 Ubuntu 服务器上,然后执行: @@ -151,9 +166,7 @@ keys/ 后面所有 `docker compose` 命令都要在这个目录执行,因为 `docker-compose.yml` 和 `.env` 都在这里。 -## 三、导入镜像并生成 .env - -在 `SimCAEServerDockerPackage` 目录执行: +继续在 `SimCAEServerDockerPackage` 目录执行: ```bash bash ./load-images.sh @@ -164,71 +177,41 @@ bash ./load-images.sh ```text 1. docker load 导入 images/simcae-server-all-images___VERSION__.tar 里的镜像 2. 如果当前目录没有 .env,就从 .env.example 复制一份 .env +3. 自动创建 runtime/、minio_data/ 等运行目录,并尽量修正目录权限 ``` -## 四、修改 .env - -继续在 `SimCAEServerDockerPackage` 目录执行: +然后修改 `.env`: ```bash nano .env ``` -`.env.example` 里已经填好一组可直接试跑的默认 token 和 MinIO 账号。你通常只需要先改服务器地址: +最少只需要把 `MINIO_PUBLIC_ENDPOINT` 改成 Windows 客户端能访问到的服务器地址: ```text MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000 ``` -正式部署时,建议同时修改这些默认值,避免所有环境共用同一套公开示例密码: - -```text -CLIENT_API_TOKEN=客户端 API token,对应客户端 app_config.json 的 client_token -ADMIN_TOKEN=后台管理员 token,网页登录时填这个值 -CRASH_REPORT_TOKEN=崩溃上传 token -CRASH_SYMBOL_TOKEN=符号上传 token -MINIO_ACCESS_KEY=MinIO 用户名 -MINIO_SECRET_KEY=MinIO 密码 -``` - -如果你在网页里点击“更改令牌”,新令牌只会在当前服务进程中立即生效。为了让服务重启后仍然使用新令牌,请同步修改 `.env` 里的 `ADMIN_TOKEN`,然后执行 `docker compose restart api`。 - -`MINIO_PUBLIC_ENDPOINT` 必须是 Windows 客户端能访问到的地址。比如服务器 IP 是 `192.168.229.128`,就填: +例如服务器 IP 是 `192.168.229.128`: ```text MINIO_PUBLIC_ENDPOINT=http://192.168.229.128:9000 ``` -## 五、启动服务 - -确认你仍然在 `SimCAEServerDockerPackage` 目录: - -```bash -pwd -``` - -然后启动: +启动服务: ```bash docker compose up -d ``` -如果提示 `docker: 'compose' is not a docker command`,说明服务器没有安装 Docker Compose plugin,需要先安装它。 - -## 六、查看状态和日志 - -这些命令也都在 `SimCAEServerDockerPackage` 目录执行: +查看状态和后端日志: ```bash docker compose ps docker compose logs -f api ``` -正常情况下可以看到 `api`、`minio` 处于 running/healthy 状态。 - -## 七、访问地址 - -把下面的 `你的服务器IP` 换成真实服务器 IP: +浏览器访问: ```text 后台/API: http://你的服务器IP:8000/ @@ -243,9 +226,228 @@ MinIO 控制台: http://你的服务器IP:9001/ 9001 MinIO 控制台,可选 ``` -## 八、重要提醒 +## 三、.env 配置怎么填 + +`.env.example` 里已经给了一组能直接试跑的默认值。下面按重要程度说明。 + +### 必须确认或修改 + +| 字段 | 是否必填 | 默认能否直接用 | 怎么填 | +| --- | --- | --- | --- | +| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成 Windows 客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 | +| `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 | +| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。当前 SimCAE 发布根目录下主程序是 `bin/SimCAE.exe`,所以默认是这个。 | +| `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 | +| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 | +| `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 | +| `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 | + +### 通常不用改 + +| 字段 | 作用 | 默认值说明 | +| --- | --- | --- | +| `SIMCAE_UPDATE_SERVER_IMAGE` | API 镜像名称 | 打包脚本会自动写成当前版本,例如 `simcae-update-server:__VERSION__`。 | +| `APP_UID` / `APP_GID` | API 容器写入 `runtime/` 时使用的用户 ID | `load-images.sh` 会尽量自动改成当前服务器用户。遇到权限问题时再检查。 | +| `SERVICE_TITLE` | 管理后台标题 | 默认 `SimCAE Update Service`。 | +| `CORS_ALLOW_ORIGINS` | 跨域来源 | 内网部署保持 `*` 即可。 | +| `TARGET_PLATFORM` / `TARGET_ARCH` | 发布包目标平台 | 当前客户端是 Windows x64,默认 `windows` / `x64`。 | +| `SIGNING_KEY_ID` | Manifest 签名密钥编号 | 默认 `manifest-key-v1`。只有更换签名体系时才需要改。 | +| `MINIO_API_PORT` | MinIO 文件下载端口 | 默认 `9000`。客户端下载升级文件要能访问这个端口。 | +| `MINIO_CONSOLE_PORT` | MinIO 控制台端口 | 默认 `9001`。不需要控制台时可以不开放到外部。 | +| `MINIO_BUCKET` | MinIO 存储桶名 | 默认 `updates`。 | +| `SIGN_EXPIRE_MIN` | 升级文件下载链接有效期 | 默认 `60` 分钟。 | +| `MINIO_CONNECT_TIMEOUT_SEC` / `MINIO_READ_TIMEOUT_SEC` / `MINIO_RETRY_TOTAL` / `MINIO_HEALTH_TIMEOUT_SEC` | MinIO 连接超时与重试 | 默认适合内网部署。 | + +### 发布保护参数 + +这些字段用于防止上传超大目录导致服务器磁盘、内存压力过大。默认一般不用改。 + +| 字段 | 作用 | 默认值 | +| --- | --- | --- | +| `PUBLISH_MAX_REQUEST_MB` | 单次发布请求最大大小,单位 MB | `4096` | +| `PUBLISH_MAX_FILES` | 单次发布最多文件数 | `20000` | +| `PUBLISH_MAX_FIELDS` | 表单字段数上限,通常要大于文件数 | `20100` | +| `UPLOAD_SPACE_RESERVE_MB` | 上传时额外保留的磁盘空间,单位 MB | `256` | + +如果正式发布目录超过 4GB,可以在确认服务器磁盘空间足够后调大 `PUBLISH_MAX_REQUEST_MB`。如果页面提示空间不足,优先清理旧版本、扩容磁盘,或把服务端部署到更大的数据盘。 + +### 崩溃报告接口参数 + +| 字段 | 是否必填 | 默认能否直接用 | 怎么填 | +| --- | --- | --- | --- | +| `CRASH_SERVICE_VERSION` | 必填 | 可以 | 崩溃报告接口版本,默认 `1.0.0`。 | +| `CRASH_REPORT_TOKEN` | 必填 | 可以 | 客户端上传崩溃报告时使用的令牌。正式环境建议改。 | +| `CRASH_SYMBOL_TOKEN` | 必填 | 可以 | 上传符号文件时使用的令牌。正式环境建议改。 | +| `CRASH_ADMIN_TOKEN` | 可不填 | 可以 | 崩溃报告管理令牌。不填时使用 `ADMIN_TOKEN`。 | +| `CRASH_METADATA_MAX_KB` | 必填 | 可以 | 崩溃报告 metadata 最大大小。 | +| `CRASH_MINIDUMP_MAX_MB` | 必填 | 可以 | minidump 最大大小。 | +| `CRASH_ATTACHMENTS_MAX_MB` | 必填 | 可以 | 附件最大大小。 | +| `CRASH_REQUEST_MAX_MB` | 必填 | 可以 | 崩溃报告完整请求最大大小。 | +| `CRASH_SYMBOLS_MAX_MB` | 必填 | 可以 | 符号文件最大大小。 | + +## 四、客户端配置要同步哪些值 + +客户端 `config/app_config.json` 至少要和服务端保持这两个值一致: + +```json +{ + "api_base_url": "http://你的服务器IP:8000", + "client_token": "和服务端 CLIENT_API_TOKEN 一样" +} +``` + +如果服务端 `.env` 里保持默认: + +```text +CLIENT_API_TOKEN=SimCAEClientToken2026 +``` + +客户端就填: + +```json +"client_token": "SimCAEClientToken2026" +``` + +`api_base_url` 是后端 API 地址,走 `SERVER_PORT`,不是 MinIO 地址。 + +## 五、发布新版本 + +进入后台后,“发布新版本”支持两种方式: + +```text +1. 选择软件发布根目录,例如 SimCAE 目录,目录内应包含 bin/SimCAE.exe +2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar +``` + +压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。当前默认是: + +```text +RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe +``` + +`.rar` 需要服务器镜像内有 rar 解压工具。当前 Docker 镜像已内置 `bsdtar`;如果某些 rar 变体仍然解压失败,请改用 zip/tar.gz/tar.bz2,或在服务器镜像中补充 `unrar`/`7z`。 + +## 六、常用命令 + +确认你仍然在 `SimCAEServerDockerPackage` 目录: + +```bash +pwd +``` + +启动: + +```bash +docker compose up -d +``` + +查看状态: + +```bash +docker compose ps +``` + +查看后端日志: + +```bash +docker compose logs -f api +``` + +查看所有容器日志: + +```bash +docker compose logs -f +``` + +重启后端: + +```bash +docker compose restart api +``` + +停止服务: + +```bash +docker compose down +``` + +如果提示 `docker: 'compose' is not a docker command`,说明服务器没有安装 Docker Compose plugin,需要先安装它。 + +## 七、数据、备份和迁移 + +运行后会生成这些目录或文件: + +```text +.env 当前服务器实际配置 +runtime/ 服务端数据库、上传临时目录、崩溃报告等 +minio_data/ MinIO 对象数据,也就是上传后的升级文件 +keys/ Manifest 签名密钥 +``` + +备份或迁移正式环境时,重点备份: + +```text +.env +runtime/ +minio_data/ +keys/ +docker-compose.yml +``` + +不要只备份容器。容器可以由镜像重新创建,真正重要的数据在上面这些挂载目录里。 + +## 八、常见问题 + +### 启动后看不到后端日志 + +`docker compose up -d` 是后台启动,不会持续打印日志。用下面命令看后端日志: + +```bash +docker compose logs -f api +``` + +### API 容器反复重启,提示 Permission denied: '/data/uploads' + +这是 `runtime/` 目录权限不对。进入 `SimCAEServerDockerPackage` 目录后执行: + +```bash +docker compose down +mkdir -p runtime/uploads runtime/upload_spool runtime/crash_storage +sudo chown -R $(id -u):$(id -g) runtime +docker compose up -d +``` + +### 网页能打开,但客户端提示令牌校验失败 + +检查客户端 `config/app_config.json`: + +```json +"client_token": "..." +``` + +必须和服务端 `.env`: + +```text +CLIENT_API_TOKEN=... +``` + +完全一致。 + +### 客户端能连 API,但下载升级文件失败 + +检查 `.env`: + +```text +MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000 +``` + +这个地址必须从 Windows 客户端能访问。还要确认服务器防火墙放行了 `9000` 端口。 + +## 九、安全提醒 `keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。 + +默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。 EOF_README python3 - "$PACKAGE_DIR/README.md" "$VERSION" <<'PYREADME' @@ -264,10 +466,27 @@ set -euo pipefail docker load -i ./images/simcae-server-all-images_$VERSION.tar if [[ ! -f .env ]]; then cp .env.example .env + sed -i "s/^APP_UID=.*/APP_UID=\$(id -u)/" .env + sed -i "s/^APP_GID=.*/APP_GID=\$(id -g)/" .env echo "Created .env from .env.example. Edit .env before starting services." else echo ".env already exists. Keeping existing file." fi + +APP_UID_VALUE="\$(grep -E '^APP_UID=' .env | tail -n 1 | cut -d= -f2-)" +APP_GID_VALUE="\$(grep -E '^APP_GID=' .env | tail -n 1 | cut -d= -f2-)" +APP_UID_VALUE="\${APP_UID_VALUE:-\$(id -u)}" +APP_GID_VALUE="\${APP_GID_VALUE:-\$(id -g)}" + +mkdir -p ./runtime/uploads ./runtime/upload_spool ./runtime/crash_storage ./minio_data + +if [[ -w ./runtime ]]; then + chown -R "\$APP_UID_VALUE:\$APP_GID_VALUE" ./runtime 2>/dev/null || true +else + echo "Warning: ./runtime is not writable by the current user." + echo "Run: sudo chown -R \$APP_UID_VALUE:\$APP_GID_VALUE ./runtime" +fi + echo "Next: edit .env, then run: docker compose up -d" EOF chmod +x "$PACKAGE_DIR/load-images.sh" diff --git a/项目总览.md b/项目总览.md index 6cb3b21..9ddae33 100644 --- a/项目总览.md +++ b/项目总览.md @@ -48,6 +48,8 @@ MainApp.exe 当前仓库内的示例业务程序 - 读取 `config/app_config.json` 配置。 - 首次设备登记和本地设备身份校验。 - License 校验。 +- `license_key` 为空时,`Launcher.exe` 会弹窗让用户粘贴后台创建的 License,并写入 `config/app_config.json`。当前实现会先检查配置项;即使本地已有 `config/client_identity.dat`,只要清空 `license_key` 仍会提示用户补填 License。 +- License 过期、错误、禁用、设备数达到上限或设备身份凭证与授权不匹配时,`Launcher.exe` 会引导用户重新输入 License,而不是要求用户手动查找配置文件。 - 版本策略校验。 - 防止策略序号回退。 - 检测系统时间回拨。 @@ -58,7 +60,7 @@ MainApp.exe 当前仓库内的示例业务程序 - 升级后健康检查 `--health-file`,业务程序启动成功后写入 `ok`。 - 升级失败回滚。 - 升级日志和下载日志上报服务端。 -- `admin.html` 管理后台布局已优化。 +- `server/admin.html` 管理后台布局已优化,升级日志和下载日志默认折叠。 ## 三、客户端 SDK 交付状态 @@ -104,7 +106,7 @@ server/main.py server/db.py server/tables.sql server/minio_tool.py -server/admin.html 已不再保留,当前使用 client/admin.html +server/admin.html 服务端管理后台页面,Docker 构建时复制进镜像 ``` 已经实现的能力: @@ -114,6 +116,8 @@ server/admin.html 已不再保留,当前使用 client/admin.html - License 管理。 - 设备首次登记。 - 版本发布。 +- 版本发布支持两种方式:选择软件发布根目录,或上传压缩好的发布包。管理页面已经拆成“软件根目录/压缩发布包”两个发布方式,避免用户误以为只能选择文件夹。 +- 压缩发布包支持 `zip`、`tar.gz`、`tgz`、`tar.bz2`、`tbz2`、`rar`;服务端会先解压,再按 `RELEASE_MAIN_EXECUTABLE` 校验主程序路径。 - Manifest 生成和私钥签名。 - MinIO 对象存储上传版本文件。 - 客户端检查更新接口。 @@ -161,10 +165,8 @@ server/Dockerfile server/docker-compose.yml server/docker-compose.image.yml server/package-offline-server.sh -server/package-offline-server.ps1 server/.env.example server/.env.docker.example -server/服务端Docker镜像交付说明.md ``` Ubuntu 主流程使用: @@ -195,6 +197,8 @@ server/dist/SimCAEServerDockerPackage.tar.gz - `keys/manifest_private_key.pem`。 - `keys/manifest_public_key.pem`。 +当前 Docker 镜像内已安装 `libarchive-tools`,用于后台发布 `.rar` 压缩包时调用 `bsdtar` 解压。`zip` 和 `tar.*` 由 Python 标准库直接支持。 + 注意:Docker 镜像包只包含软件本体和部署配置,不包含已经运行出来的数据。当前服务器上的数据库、上传过的版本文件、崩溃报告等数据在: ```text @@ -246,9 +250,11 @@ manifest_public_key.pem - Dockerfile 和 docker-compose 文件。 - `.env.example` 和 `.env.docker.example`。 - Word 接入说明文档。 -- 服务端 Docker 交付说明文档。 +- 服务端 Docker 离线包 README 生成逻辑。 - `.gitignore`。 +默认值说明:服务端和客户端示例配置里已经放了可直接试跑的默认 token、MinIO 用户名和密码,目的是让接收方不用一上来就被配置卡住。它们可以直接用于内网联调;如果进入正式生产或公网环境,再按安全要求替换。 + 不建议提交: - `client/App/` @@ -262,14 +268,41 @@ manifest_public_key.pem - `server/keys/manifest_private_key.pem` - 任何真实 token、License、私钥和运行日志。 -## 九、后续待办 +## 九、SimCAE 安装根目录模式 + +SimCAE 的真实目录结构是安装根目录下包含 `bin/`、`Licenses/`、`installerResources/` 和维护工具,业务主程序位于 `bin/SimCAE.exe`。当前交付配置按“SDK 在 bin、发布整个安装根目录”工作: + +```text +SimCAE/ + bin/ + Launcher.exe + Updater.exe + Bootstrap.exe + SimCAE.exe + config/ + app_config.json + manifest_public_key.pem + Licenses/ + installerResources/ + maintenancetool.exe +``` + +客户端配置使用 `install_root=..`、`main_executable=SimCAE.exe`,服务端配置使用 `RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe`。后台发布版本时可以选择整个 `SimCAE/` 安装根目录,也可以上传压缩好的 `SimCAE.zip` / `SimCAE.tar.gz` 等发布包;不要只选择或只压缩 `bin/`。升级事务目录固定放在 `Launcher.exe` 所在目录下,也就是 `SimCAE/bin/update/`。 + +## 十、运行目录权限说明 + +当前客户端 SDK 默认把运行时状态写在 `Launcher.exe` 所在目录下,包括 `config/app_config.json`、`config/client_identity.dat`、`config/local_state.json`、`config/version_policy.dat`、`update/` 和 `update_temp/`。当 SDK 放在 `SimCAE/bin` 时,更新事务、备份、暂存文件、下载断点和 Manifest 缓存都在 `SimCAE/bin/update/`,不会再在 `SimCAE/` 根目录生成顶层 `update/`。因此联调目录必须允许当前 Windows 用户写入。 + +如果把软件放在 `C:\Program Files\SimCAE\bin` 并用普通用户启动,Windows 会拒绝写入,首次启动可能提示 `cannot save installation id`。当前建议先在 `D:\SimCAE_Release` 或桌面目录联调;正式要安装到 `Program Files` 时,需要补管理员提权、Windows 服务,或将运行时状态迁移到 `ProgramData` / `AppData`。 + +## 十一、后续待办 - 等业务开发者按 SDK 文档改造 SimCAE 源码。 - 用改造后的 SimCAE Release 目录做完整升级联调。 - 确认直接启动 `SimCAE.exe` 会被拒绝,只能通过 `Launcher.exe` 启动。 - 验证升级成功、升级失败回滚、Manifest 验签失败拦截。 - 如需迁移现有服务数据,补充 `runtime/` 和 `minio_data/` 的备份恢复流程。 -- 生产部署前重新生成强随机 token 和 MinIO 密码。 +- 交付配置已提供可直接试跑的默认 token 和 MinIO 账号密码;正式生产环境建议替换为客户自己的强随机值。 - 生产私钥和测试私钥建议分开管理。 --- @@ -294,14 +327,14 @@ manifest_public_key.pem 自动升级系统的 Windows 在线更新主链路已经基本打通,并且已经从“能升级”推进到“可控、安全、可回滚、可离线导入”的阶段。客户端具备 Launcher、Updater、MainApp、Bootstrap 四个程序;服务端具备 FastAPI、SQLite、MinIO、本地回退存储、版本发布、Manifest、下载授权、升级结果上报、版本策略、设备身份、License 授权、动态渠道、离线更新包、下载日志、管理员审计日志和管理网页。客户端可以检查版本、验证 RSA 签名、差异下载、断点续传、备份旧文件、通过 Bootstrap 替换被占用文件、删除废弃文件、启动新版本并等待健康确认;失败时可以进入自动回滚流程。 -SimCAE 崩溃报告后端已经完成第一阶段核心接口:接收 SimCAECrashReporter.exe 上传的 metadata.json、crash.dmp 和可选 attachments.zip;根据 clientReportId 做幂等去重;校验 minidump SHA-256;保存原始文件;返回稳定 reportId;接收 CI/发布流程上传的 symbols.zip;并提供管理 token 查询和私有文件下载入口。自动符号化、聚合统计和专门后台页面属于第二阶段。 +SimCAE 崩溃报告后端已经完成第一阶段核心接口:接收 SimCAECrashReporter.exe 上传的 metadata.json、crash.dmp 和可选 attachments.zip;根据 clientReportId 做幂等去重;校验 minidump SHA-256;保存原始文件;返回稳定 reportId;接收 CI/发布流程上传的 symbols.zip;并提供管理 token 查询和私有文件下载入口。管理后台已经新增基础“崩溃报告”页面,可查看报告列表并下载 metadata、dmp、attachments 和 server.json。自动符号化、聚合统计、告警和问题分派属于后续增强。 按两份需求文档综合判断: 1. 自动升级 Demo 主链路:约 90%~95%,剩余主要是系统性故障测试和插件真实接口加载。 2. 自动升级完整设计文档:约 75%~80%,剩余主要是正式管理员体系、限流、代码签名、灰度、多平台、插件接口版本准入和生产化测试。 -3. Crash Report 第一阶段接口:约 85%~90%,已经能支撑客户端联调;剩余是 HTTPS 部署、限流、保留期清理和管理页面整合。 -4. Crash Report 完整平台:约 45%~55%,因为自动符号化、统计聚合、告警和问题分派还未做。 +3. Crash Report 第一阶段接口:约 90%~95%,已经按需求文档完成真实 HTTP 联调;剩余是 HTTPS 部署、限流和保留期清理。 +4. Crash Report 完整平台:约 50%~60%,因为自动符号化、统计聚合、告警和问题分派还未做。 当前最主要的剩余工作不再是普通在线更新,而是把已完成能力做实机回归、生产化安全加固,并把 SimCAE 崩溃上报客户端接入进来。 @@ -404,6 +437,8 @@ SimCAE 崩溃报告接口: 5. Manifest、MinIO 和客户端安装过程都保留相对路径。 6. 检查 MainApp.exe 是否位于发布根级。 7. 排除 Bootstrap、运行时配置、状态文件、策略缓存、PDB、ILK 和更新临时目录。 +8. 支持上传压缩发布包,服务端解压后按同一套路径、主程序、文件数量和空间规则校验,并自动过滤 `update/`、`update_temp/`、构建目录、运行时配置和调试产物。 +9. 压缩包可多一层顶层目录,例如 `SimCAE/bin/SimCAE.exe` 会自动归一为 `bin/SimCAE.exe`。 2.5 Manifest @@ -450,7 +485,7 @@ SimCAE 崩溃报告接口: 1. SHA 相同的文件跳过下载。 2. HTTP Range 断点续传。 -3. 使用 update/download_cache/.part 保存片段。 +3. 使用运行目录下的 `update/download_cache/.part` 保存片段;SimCAE 场景中即 `SimCAE/bin/update/download_cache/`。 4. Updater 重启后仍可继续未完成文件。 5. 单文件最多自动重试四次。 6. 使用递增等待时间重试。 @@ -473,7 +508,7 @@ SimCAE 崩溃报告接口: 2.9 升级事务状态机 -已实现 update/upgrade_state.json,包含: +已实现运行目录下的 `update/upgrade_state.json`,SimCAE 场景中即 `SimCAE/bin/update/upgrade_state.json`,包含: 1. transaction_id。 2. from_version。 @@ -557,6 +592,8 @@ Updater 启动时会读取旧事务,并根据状态尝试恢复或回滚。 10. 降级目标协议低于当前客户端协议时,服务端在安装前返回 rollback_denied。 11. 管理后台支持查看和修正历史版本的协议标签。 +这里的“客户端协议”不是 HTTP 协议,也不是软件版本号,而是 Launcher/Updater 支持的升级机制能力编号。当前默认是 `3`,代表支持一次性启动票据、健康检查、策略校验、受控降级等当前客户端机制;普通发布保持默认值即可,只有未来客户端升级机制发生不兼容变化时才需要调整。 + 2.14 离线运行基础 已实现: @@ -581,15 +618,16 @@ Updater 启动时会读取旧事务,并根据状态尝试恢复或回滚。 2. 修改管理员令牌。 3. 创建和选择应用。 4. 选择软件根目录发布。 -5. stable、preview、dev 渠道选择。 -6. 版本列表。 -7. 设置最新版本。 -8. 删除版本和云端文件。 -9. 版本策略读取和保存。 -10. 升级日志。 -11. 调试输出。 -12. 发布文件预览、大小和状态提示。 -13. 页面美化和响应式布局。 +5. 切换到“压缩发布包”模式上传压缩包发布,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar。 +6. stable、preview、dev 渠道选择。 +7. 版本列表。 +8. 设置最新版本。 +9. 删除版本和云端文件。 +10. 版本策略读取和保存。 +11. 升级日志。 +12. 调试输出。 +13. 发布文件预览、大小和状态提示。 +14. 页面美化和响应式布局。 2.16 一次性短期启动票据 @@ -627,12 +665,14 @@ Updater 启动时会读取旧事务,并根据状态尝试恢复或回滚。 3. 服务端逐请求验证设备凭证、授权状态、设备禁用状态、app_id 和 channel。 4. License 授权:licenses 和 license_devices 表,支持有效期、启用/禁用、最大设备数和设备占用。 5. 授权密钥只保存 SHA-256,明文只在创建时返回一次。 -6. 动态渠道:channels 表按 app_id 隔离,管理后台支持新增、编辑、启用和停用。 -7. 发布、策略、授权、设备登记和更新检查统一校验渠道。 -8. 离线更新包:管理后台可生成 MUPD0001/.upd 包,包内包含签名 Manifest、包头签名、文件偏移、大小和 SHA-256。 -9. Launcher/Updater 支持导入离线包,离线安装复用现有事务、Bootstrap、校验、健康确认和回滚机制。 -10. 下载日志:记录下载授权、客户端完成结果、文件大小、IP、User-Agent 和时间。 -11. 管理员审计日志:记录 /admin 写操作、管理员令牌指纹、路径、结果、状态码、IP 和 User-Agent。 +6. Launcher 首次启动时如果没有 License,会弹窗要求用户输入并自动写入配置;当前实现会先检查 `license_key` 配置项,即使本地已有 `client_identity.dat`,清空 `license_key` 仍会触发输入框。 +7. License 错误、过期、禁用、设备数达到上限或设备身份与授权不匹配时,Launcher 会清理旧设备身份并引导用户重新输入 License。 +8. 动态渠道:channels 表按 app_id 隔离,管理后台支持新增、编辑、启用和停用。 +9. 发布、策略、授权、设备登记和更新检查统一校验渠道。 +10. 离线更新包:管理后台可生成 MUPD0001/.upd 包,包内包含签名 Manifest、包头签名、文件偏移、大小和 SHA-256。 +11. Launcher/Updater 支持导入离线包,离线安装复用现有事务、Bootstrap、校验、健康确认和回滚机制。 +12. 下载日志:记录下载授权、客户端完成结果、文件大小、IP、User-Agent 和时间。 +13. 管理员审计日志:记录 /admin 写操作、管理员令牌指纹、路径、结果、状态码、IP 和 User-Agent。 2.19 SimCAE Crash Report 后端第一阶段 @@ -654,8 +694,11 @@ Updater 启动时会读取旧事务,并根据状态尝试恢复或回滚。 14. POST /api/v1/symbols 接收 CI/发布流程上传的 metadata.json 和 symbols.zip。 15. crash_symbol_uploads 表按 product、appVersion、gitCommit、buildType、platform 索引符号包。 16. 已配置 CRASH_STORAGE_ROOT,Docker 部署时落到 /data/crash_storage 持久化目录。 +17. 管理后台新增“崩溃报告”页面,登录后可查看 report_id、版本、gitCommit、构建类型、渠道、异常码、崩溃时间、接收时间、来源 IP 和文件大小。 +18. 管理后台可下载单条崩溃报告的 metadata.json、crash.dmp、attachments.zip 和 server.json,并写入 crash_file_access_logs。 +19. 已按需求文档的 curl 联调思路完成真实 HTTP 测试:上传成功、重复上传幂等、内容冲突 409、错误 token 401、缺 metadata 400、minidump SHA-256 错误 400、管理查询、原始文件下载、符号包上传和符号包重复上传均通过。 -说明:第一阶段只做“可靠接收、校验、保存、索引和查询”。自动符号化、聚合统计、告警、问题分派和专门后台页面属于第二阶段。 +说明:第一阶段已经做到“可靠接收、校验、保存、索引、查询和后台查看/下载”。自动符号化、聚合统计、告警和问题分派属于后续增强。 ============================================================ 三、部分实现的功能 @@ -757,7 +800,7 @@ RSA 签名已经实现,但仍缺少: 1. 自动符号化,把 crash.dmp 转成可读调用栈。 2. 根据 appVersion、gitCommit、buildType 自动匹配 symbols.zip。 -3. 崩溃报告列表、搜索、下载和统计后台页面。 +3. 崩溃报告高级搜索、筛选、统计和详情页面。 4. 按异常码、版本、GPU、命令、项目等维度聚合。 5. 保留期清理,例如 90 天或 180 天。 6. 上传限流和异常峰值告警。 @@ -1213,7 +1256,7 @@ SDK 是 Software Development Kit,中文通常叫“软件开发工具包”。 它不是单个 exe,也不只是源码,而是一套让别人能把你的能力接到自己软件里的交付包。一个合格 SDK 通常包含: 1. 可直接使用的二进制文件,例如 Launcher.exe、Updater.exe、Bootstrap.exe。 -2. 必要的运行时 DLL,例如 Qt、OpenSSL、平台插件等。 +2. 必要的运行时文件。对 SimCAE 这种自身已带 Qt 的软件,SDK 默认不再携带 Qt DLL,避免覆盖业务软件原有运行库。 3. 配置模板,例如 app_config.example.json。 4. 接入文档,例如如何配置 app_id、channel、api_base_url、license_key。 5. 打包脚本,例如 package-client.ps1。 @@ -1254,38 +1297,26 @@ SDK 是 Software Development Kit,中文通常叫“软件开发工具包”。 建议最终交付类似下面的目录: UpdateClientSDK/ - README.md - docs/ - 接入说明.md - 服务端接口说明.md - 常见错误.md + SimCAE自动升级SDK接入说明_v0.1.docx + sdk_manifest.json bin/ Launcher.exe Updater.exe Bootstrap.exe - Qt5Core.dll - Qt5Gui.dll - Qt5Widgets.dll - Qt5Network.dll - platforms/qwindows.dll - openssl 相关 DLL config/ - app_config.example.json + app_config.json manifest_public_key.pem scripts/ + install-sdk.ps1 package-client.ps1 - samples/ - minimal_app/ - MainApp.exe 或示例源码 - app_config.json 示例 - + package-sdk.ps1 对接方真正拿到后,通常只需要做这些事: -1. 把自己的主程序和依赖 DLL 放到发布目录。 -2. 设置 app_config.json:server 地址、app_id、channel、当前版本、license_key、主程序名等。 +1. 把 SDK 的 Launcher、Updater、Bootstrap 放到业务软件运行目录。 +2. 设置 app_config.json:server 地址、app_id、channel、当前版本、主程序名等。`license_key` 可以预先填入;如果为空,用户首次启动 Launcher 时会弹窗粘贴授权密钥。 3. 以后让用户启动 Launcher.exe。 4. 在管理后台创建应用、License、渠道和发布版本。 -5. 发布新版本时选择干净的 Release 输出目录。 +5. 发布新版本时选择干净的 Release 输出目录,或切换到“压缩发布包”模式上传 zip/tar.gz/tar.bz2/rar 等压缩发布包。 10.4 当前已有 package-client.ps1 的作用 @@ -1357,7 +1388,7 @@ Docker 运行时,依赖的是镜像里的 Python、镜像里的依赖、容器 1. 使用 python:3.12-slim 作为基础镜像。 2. 安装 server/requirements.txt 里的 FastAPI、MinIO、cryptography 等依赖。 3. 拷贝 main.py、db.py、minio_tool.py、tables.sql。 -4. 拷贝 client/admin.html 到容器内 /app/admin.html。 +4. 拷贝 server/admin.html 到容器内 /app/admin.html。 5. 使用非 root 用户 updateapp 运行。 6. 暴露 8000 端口。 7. 提供 healthcheck。 @@ -1469,7 +1500,7 @@ Docker 是给“服务端部署人员”用的。 第二步:准备客户端 SDK 包。 1. 确认 client/out/bin 是 Release 输出目录。 -2. 确认 out/bin 里有 Launcher.exe、Updater.exe、Bootstrap.exe、Qt DLL、platforms/qwindows.dll。 +2. 确认 out/bin 里有 Launcher.exe、Updater.exe、Bootstrap.exe。SimCAE 场景下不要把 SDK 自带 Qt DLL 覆盖到 SimCAE 的 bin 目录。 3. 确认 config/manifest_public_key.pem 是服务端私钥对应的公钥。 4. 填好 client/config/app_config.example.json 里的示例字段。 5. 在 Windows PowerShell 执行: @@ -1495,7 +1526,7 @@ cd client 第五步:补接入方文档和 Demo。 -1. 把 client/SDK_README.md 随 SDK 一起交付。 +1. SDK 根目录只保留一个 Word 接入说明作为入口,打开包后一眼能看到。 2. 准备一个最小 MainApp 示例,演示 --ticket-file 和 --health-file。 3. 准备一份常见错误说明。 4. 准备一份服务端 Docker 部署说明。