diff --git a/client/.gitignore b/client/.gitignore deleted file mode 100644 index 1887b16..0000000 --- a/client/.gitignore +++ /dev/null @@ -1,70 +0,0 @@ -# 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/.gitkeep b/client/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/client/Bootstrap/CMakeLists.txt b/client/Bootstrap/CMakeLists.txt deleted file mode 100644 index 7b8e31c..0000000 --- a/client/Bootstrap/CMakeLists.txt +++ /dev/null @@ -1,6 +0,0 @@ -project(Bootstrap LANGUAGES CXX) - -add_executable(Bootstrap WIN32 main.cpp) -target_compile_features(Bootstrap PRIVATE cxx_std_17) -target_compile_definitions(Bootstrap PRIVATE UNICODE _UNICODE) -target_link_libraries(Bootstrap PRIVATE shell32) diff --git a/client/Bootstrap/main.cpp b/client/Bootstrap/main.cpp deleted file mode 100644 index 592d6b6..0000000 --- a/client/Bootstrap/main.cpp +++ /dev/null @@ -1,191 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace fs = std::filesystem; - -static std::wstring quote(const std::wstring& value) -{ - std::wstring result = L"\""; - unsigned backslashes = 0; - for (wchar_t ch : value) { - if (ch == L'\\') { ++backslashes; continue; } - if (ch == L'\"') { - result.append(backslashes * 2 + 1, L'\\'); - result.push_back(L'\"'); - backslashes = 0; - continue; - } - result.append(backslashes, L'\\'); - backslashes = 0; - result.push_back(ch); - } - result.append(backslashes * 2, L'\\'); - result.push_back(L'\"'); - return result; -} - -static std::wstring fromUtf8(const std::string& value) -{ - if (value.empty()) return {}; - const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), - static_cast(value.size()), nullptr, 0); - if (size <= 0) return {}; - std::wstring result(size, L'\0'); - MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), - static_cast(value.size()), result.data(), size); - return result; -} - -static bool safeRelative(const fs::path& path) -{ - if (path.empty() || path.is_absolute() || path.has_root_name()) return false; - for (const auto& part : path) - if (part == L"..") return false; - return true; -} - -static bool copyWithRetry(const fs::path& source, const fs::path& destination) -{ - std::error_code ec; - fs::create_directories(destination.parent_path(), ec); - for (int i = 0; i < 100; ++i) { - ec.clear(); - fs::copy_file(source, destination, fs::copy_options::overwrite_existing, ec); - if (!ec) return true; - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - } - return false; -} - -static bool removeWithRetry(const fs::path& path) -{ - std::error_code ec; - for (int i = 0; i < 100; ++i) { - ec.clear(); - if (!fs::exists(path, ec)) return !ec; - if (fs::remove(path, ec)) return true; - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - } - return false; -} - -static bool rollback(const fs::path& installDir, const fs::path& backupDir, - const std::vector& paths) -{ - bool success = true; - for (const auto& relative : paths) { - const fs::path destination = installDir / relative; - const fs::path backup = backupDir / relative; - std::error_code ec; - if (fs::exists(backup, ec)) { - if (!copyWithRetry(backup, destination)) success = false; - } else if (fs::exists(destination, ec) && !fs::remove(destination, ec)) { - success = false; - } - } - return success; -} - -static bool launchUpdater(const std::wstring& updater, const std::vector& updateArgs, - const std::wstring& result) -{ - std::wstring command = quote(updater); - for (const auto& arg : updateArgs) command += L" " + quote(arg); - command += L" " + quote(L"--bootstrap-resume=" + result); - STARTUPINFOW si{}; - si.cb = sizeof(si); - PROCESS_INFORMATION pi{}; - std::vector mutableCommand(command.begin(), command.end()); - mutableCommand.push_back(L'\0'); - const BOOL ok = CreateProcessW(updater.c_str(), mutableCommand.data(), nullptr, nullptr, - FALSE, 0, nullptr, fs::path(updater).parent_path().c_str(), &si, &pi); - if (ok) { CloseHandle(pi.hThread); CloseHandle(pi.hProcess); } - return ok == TRUE; -} - -int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) -{ - int argc = 0; - LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); - if (!argv || argc < 11) { - MessageBoxW(nullptr, L"Bootstrap 参数不完整。", L"更新接管失败", MB_ICONERROR); - if (argv) LocalFree(argv); - return 2; - } - const fs::path planFile = argv[1]; - const fs::path installDir = argv[2]; - const fs::path stagingDir = argv[3]; - const fs::path backupDir = argv[4]; - const std::wstring updater = argv[5]; - const DWORD updaterPid = static_cast(_wtoi(argv[6])); - std::vector updateArgs{argv[7], argv[8], argv[9], argv[10]}; - const std::wstring mode = argc >= 12 ? argv[11] : L"install"; - LocalFree(argv); - - if (HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, updaterPid)) { - WaitForSingleObject(process, 30000); - CloseHandle(process); - } - - struct PlanItem { wchar_t operation; fs::path relative; }; - std::ifstream input(planFile, std::ios::binary); - std::vector items; - std::vector paths; - std::string line; - while (std::getline(input, line)) { - if (!line.empty() && line.back() == '\r') line.pop_back(); - if (line.size() < 3 || line[1] != '\t' || (line[0] != 'C' && line[0] != 'D')) { - MessageBoxW(nullptr, L"更新计划操作格式无效。", L"更新接管失败", MB_ICONERROR); - return 3; - } - const fs::path relative(fromUtf8(line.substr(2))); - if (!safeRelative(relative)) { - MessageBoxW(nullptr, L"更新计划包含不安全路径。", L"更新接管失败", MB_ICONERROR); - return 3; - } - items.push_back({static_cast(line[0]), relative}); - paths.push_back(relative); - } - - bool success = input.eof(); - bool rolledBack = false; - fs::path failedPath; - if (mode == L"rollback") { - rolledBack = success && rollback(installDir, backupDir, paths); - success = false; - } else if (success) { - for (const auto& item : items) { - const fs::path& relative = item.relative; - if (_wcsicmp(relative.filename().c_str(), L"Bootstrap.exe") == 0) { - success = false; - failedPath = relative; - break; - } - const bool itemOk = item.operation == L'C' - ? copyWithRetry(stagingDir / relative, installDir / relative) - : removeWithRetry(installDir / relative); - if (!itemOk) { - success = false; - failedPath = relative; - break; - } - } - if (!success) rolledBack = rollback(installDir, backupDir, paths); - } - - const std::wstring result = success ? L"success" : (rolledBack ? L"rolledback" : L"rollback-failed"); - if (!launchUpdater(updater, updateArgs, result)) { - std::wstring message = L"无法重新启动 Updater.exe。"; - if (!failedPath.empty()) message += L"\n失败文件:" + failedPath.wstring(); - MessageBoxW(nullptr, message.c_str(), L"更新接管失败", MB_ICONERROR); - return 4; - } - return (success || rolledBack) ? 0 : 5; -} diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt deleted file mode 100644 index 3ee01df..0000000 --- a/client/CMakeLists.txt +++ /dev/null @@ -1,70 +0,0 @@ -cmake_minimum_required(VERSION 3.20) -project(ClientAll LANGUAGES C CXX) -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -if(MSVC) - add_compile_options(/utf-8) -endif() -# Qt全局配置 -set(CMAKE_INCLUDE_CURRENT_DIR ON) -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) -set(CMAKE_PREFIX_PATH "C:\\Qt\\5.15.2\\msvc2019_64" ${CMAKE_PREFIX_PATH}) -find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets) -# ========== OpenSSL 手动配置(完全抛弃find_package) ========== -set(OPENSSL_ROOT_DIR "C:/Program Files/OpenSSL-Win64") -set(OPENSSL_INC "${OPENSSL_ROOT_DIR}/include") -set(OPENSSL_LIB_DEBUG "${OPENSSL_ROOT_DIR}/lib/VC/x64/MDd") -set(OPENSSL_LIB_RELEASE "${OPENSSL_ROOT_DIR}/lib/VC/x64/MD") -# 全局宏,所有子项目统一启用OpenSSL -add_compile_definitions(HAVE_OPENSSL=1) -# ========================================================== -# 统一输出目录 -set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/lib) -set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin) -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin) -# Bootstrap 不依赖 Qt,可在 Updater 退出后替换 Updater 与 Qt 运行库 -add_subdirectory(Bootstrap) -# 先编译公共库 -add_subdirectory(Common) -# 再编译三个业务程序 -add_subdirectory(Launcher) -add_subdirectory(Updater) -add_subdirectory(MainApp) -# 默认启动项目 -set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher) -# Copy client.ini and config directory to output bin folder -set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.example.json") -set(CONFIG_SOURCE_DIR "${CMAKE_SOURCE_DIR}/config") -set(MANIFEST_PUBLIC_KEY_FILE "${CONFIG_SOURCE_DIR}/manifest_public_key.pem") -set(INI_TARGET_FOLDER "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}") -# app_config.json 包含运行时版本状态;如果存在旧 client.ini,则交给客户端首次启动迁移 -file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}") -file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}/config") -if(NOT EXISTS "${INI_TARGET_FOLDER}/config/app_config.json" AND NOT EXISTS "${INI_TARGET_FOLDER}/client.ini") - configure_file("${APP_CONFIG_SOURCE_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY) -endif() -foreach(RUNTIME_RESOURCE local_state.json version_policy.dat) - if(NOT EXISTS "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}") - configure_file("${CONFIG_SOURCE_DIR}/${RUNTIME_RESOURCE}" "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}" COPYONLY) - endif() -endforeach() -# 编译阶段同步静态配置资源 -add_custom_target(copy_client_resources ALL - COMMAND ${CMAKE_COMMAND} -E make_directory ${INI_TARGET_FOLDER} - COMMAND ${CMAKE_COMMAND} -E make_directory ${INI_TARGET_FOLDER}/config - COMMAND ${CMAKE_COMMAND} -E copy_if_different ${MANIFEST_PUBLIC_KEY_FILE} ${INI_TARGET_FOLDER}/config/manifest_public_key.pem - COMMAND ${CMAKE_COMMAND} -E copy_if_different ${MANIFEST_PUBLIC_KEY_FILE} ${INI_TARGET_FOLDER}/manifest_public_key.pem - COMMENT "Copy client config directory and manifest public key to output bin folder" -) -add_dependencies(Launcher copy_client_resources) -add_dependencies(Updater copy_client_resources) -add_dependencies(MainApp copy_client_resources) -# Install rules for packaging -if(EXISTS ${CONFIG_SOURCE_DIR}) - install(DIRECTORY ${CONFIG_SOURCE_DIR} DESTINATION bin/config) -endif() -if(EXISTS ${MANIFEST_PUBLIC_KEY_FILE}) - install(FILES ${MANIFEST_PUBLIC_KEY_FILE} DESTINATION bin) -endif() \ No newline at end of file diff --git a/client/Common/CMakeLists.txt b/client/Common/CMakeLists.txt deleted file mode 100644 index c1c5421..0000000 --- a/client/Common/CMakeLists.txt +++ /dev/null @@ -1,38 +0,0 @@ -project(Common LANGUAGES C CXX) -find_package(Qt5 REQUIRED COMPONENTS Core Network Widgets) - -set(SRC - HttpHelper.h - HttpHelper.cpp - FileHelper.h - FileHelper.cpp - ConfigHelper.h - ConfigHelper.cpp - PolicyHelper.h - PolicyHelper.cpp - LocalStateHelper.h - LocalStateHelper.cpp - TicketHelper.h - TicketHelper.cpp - IntegrityHelper.h - IntegrityHelper.cpp - DeviceIdentityHelper.h - DeviceIdentityHelper.cpp -) -add_library(Common STATIC ${SRC}) - -# Common编译自身需要OpenSSL头文件 -target_include_directories(Common - PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} - PRIVATE ${OPENSSL_INC} -) - -# 链接库、库目录通过INTERFACE传递给所有依赖Common的exe -target_link_directories(Common INTERFACE - $<$:${OPENSSL_LIB_DEBUG}> - $<$:${OPENSSL_LIB_RELEASE}> -) -target_link_libraries(Common - PRIVATE Qt5::Core Qt5::Network Qt5::Widgets - INTERFACE libssl.lib libcrypto.lib -) \ No newline at end of file diff --git a/client/Common/ConfigHelper.cpp b/client/Common/ConfigHelper.cpp deleted file mode 100644 index a2bf76c..0000000 --- a/client/Common/ConfigHelper.cpp +++ /dev/null @@ -1,181 +0,0 @@ -#include "ConfigHelper.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -ConfigHelper& ConfigHelper::instance() -{ - static ConfigHelper obj; - return obj; -} - -ConfigHelper::ConfigHelper() -{ - m_configPath = QApplication::applicationDirPath() + "/config/app_config.json"; - migrateLegacyIniIfNeeded(); - qDebug() << "Loading app config path:" << m_configPath; - qDebug() << "File exists?" << QFile::exists(m_configPath); -} - -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); - QFile file(m_configPath); - if (!file.open(QIODevice::ReadOnly)) - return QString(); - - QJsonParseError error; - const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error); - if (error.error != QJsonParseError::NoError || !document.isObject()) - return QString(); - - return document.object().value(key).toVariant().toString(); -} - -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 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; - } - if (!output.commit()) - { - m_error = QString("Cannot commit config file: %1").arg(output.errorString()); - return false; - } - return true; -} - -bool ConfigHelper::migrateLegacyIniIfNeeded() -{ - if (QFile::exists(m_configPath)) - return true; - - const QString legacyPath = QApplication::applicationDirPath() + "/client.ini"; - if (!QFile::exists(legacyPath)) - return false; - - QSettings ini(legacyPath, QSettings::IniFormat); - QJsonObject config; - const auto copyText = [&](const QString& section, const QString& key, const QString& fallback = QString()) { - const QString value = ini.value(section + "/" + key, fallback).toString(); - config.insert(key, value); - }; - - copyText("App", "app_id"); - copyText("App", "app_name", "Marsco Demo App"); - copyText("App", "channel", "stable"); - copyText("App", "current_version", "1.0.0"); - copyText("App", "client_protocol", "3"); - copyText("App", "launch_token"); - copyText("License", "license_key"); - copyText("Server", "api_base_url"); - copyText("Server", "client_token"); - 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"); - copyText("Runtime", "bootstrap_executable", "Bootstrap.exe"); - copyText("Runtime", "health_check_timeout_ms", "15000"); - config.insert("platform", "windows"); - config.insert("arch", "x64"); - - QDir().mkpath(QFileInfo(m_configPath).path()); - QSaveFile output(m_configPath); - if (!output.open(QIODevice::WriteOnly)) - return false; - output.write(QJsonDocument(config).toJson(QJsonDocument::Indented)); - const bool saved = output.commit(); - if (saved) - qDebug() << "Migrated legacy client.ini to" << m_configPath; - return saved; -} diff --git a/client/Common/ConfigHelper.h b/client/Common/ConfigHelper.h deleted file mode 100644 index dd6ce05..0000000 --- a/client/Common/ConfigHelper.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once -#include - -class ConfigHelper -{ -public: - static ConfigHelper& instance(); - 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 deleted file mode 100644 index e123676..0000000 --- a/client/Common/DeviceIdentityHelper.cpp +++ /dev/null @@ -1,49 +0,0 @@ -#include "DeviceIdentityHelper.h" -#include "ConfigHelper.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#ifdef HAVE_OPENSSL -#include -#include -#endif -DeviceIdentityHelper::DeviceIdentityHelper(const QString& dir):m_installDir(dir){} -QString DeviceIdentityHelper::deviceId() const{return m_deviceId;} -QString DeviceIdentityHelper::errorString() const{return m_error;} -bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,const QString& sig64){ -#ifndef HAVE_OPENSSL - Q_UNUSED(payload);Q_UNUSED(sig64);m_error="OpenSSL unavailable";return false; -#else - QFile f(QDir(m_installDir).filePath("config/manifest_public_key.pem")); if(!f.open(QIODevice::ReadOnly)){m_error="device public key missing";return false;} - QByteArray kd=f.readAll();BIO* b=BIO_new_mem_buf(kd.constData(),kd.size());EVP_PKEY* k=b?PEM_read_bio_PUBKEY(b,nullptr,nullptr,nullptr):nullptr;if(b)BIO_free(b);if(!k){m_error="device public key invalid";return false;} - EVP_MD_CTX* c=EVP_MD_CTX_new();QByteArray sig=QByteArray::fromBase64(sig64.toUtf8());bool ok=c&&EVP_DigestVerifyInit(c,nullptr,EVP_sha256(),nullptr,k)==1&&EVP_DigestVerifyUpdate(c,payload.constData(),payload.size())==1&&EVP_DigestVerifyFinal(c,reinterpret_cast(sig.constData()),sig.size())==1;if(c)EVP_MD_CTX_free(c);EVP_PKEY_free(k);if(!ok)m_error="device credential RSA signature invalid";return ok; -#endif -} -bool DeviceIdentityHelper::loadAndVerify(const QString& appId,const QString& channel){ - QFile f(QDir(m_installDir).filePath("config/client_identity.dat"));if(!f.open(QIODevice::ReadOnly))return false;QJsonParseError e;auto d=QJsonDocument::fromJson(f.readAll(),&e);if(e.error!=QJsonParseError::NoError||!d.isObject()){m_error="device credential JSON invalid";return false;}auto w=d.object();QByteArray text=w.value("identity_text").toString().toUtf8();if(text.isEmpty()||!verifySignature(text,w.value("signature").toString()))return false;auto identity=QJsonDocument::fromJson(text).object();QDateTime expiry=QDateTime::fromString(identity.value("valid_until").toString(),Qt::ISODate);if(identity.value("app_id").toString()!=appId||identity.value("channel").toString()!=channel||identity.value("license_id").toString().isEmpty()||identity.value("installation_id").toString().isEmpty()||identity.value("device_id").toString().isEmpty()){m_error="device/license credential identity mismatch";return false;}if(!expiry.isValid()||expiry<=QDateTime::currentDateTimeUtc()){m_error="license expired";return false;}m_deviceId=identity.value("device_id").toString();return true; -} -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;} - 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(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/DeviceIdentityHelper.h b/client/Common/DeviceIdentityHelper.h deleted file mode 100644 index 5b194f1..0000000 --- a/client/Common/DeviceIdentityHelper.h +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once -#include -class DeviceIdentityHelper { -public: - explicit DeviceIdentityHelper(const QString& installDir); - bool ensureIssued(const QString& apiBaseUrl, const QString& clientToken, const QString& appId, - const QString& channel, const QString& licenseKey); - bool verifyLocal(const QString& appId, const QString& channel); - QString deviceId() const; - QString errorString() const; -private: - bool loadAndVerify(const QString& expectedAppId, const QString& expectedChannel); - bool verifySignature(const QByteArray& payload, const QString& signatureBase64); - QString m_installDir, m_deviceId, m_error; -}; diff --git a/client/Common/FileHelper.cpp b/client/Common/FileHelper.cpp deleted file mode 100644 index 5aaf239..0000000 --- a/client/Common/FileHelper.cpp +++ /dev/null @@ -1,42 +0,0 @@ -#include "FileHelper.h" -#include - -bool FileHelper::createDir(const QString &path) -{ - QDir dir(path); - if (!dir.exists()) - return dir.mkpath("."); - return true; -} - -bool FileHelper::copyFileOverwrite(const QString &src, const QString &dst) -{ - if (QFile::exists(dst)) - { - if (!QFile::remove(dst)) - { - qDebug() << "无法删除旧文件:" << dst; - return false; - } - } - return QFile::copy(src, dst); -} - -bool FileHelper::isProcessRunning(const QString &exeName) -{ - QProcess process; - process.start("tasklist"); - process.waitForFinished(); - QString output = process.readAllStandardOutput(); - return output.contains(exeName, Qt::CaseInsensitive); -} - -bool FileHelper::killProcess(const QString &exeName) -{ - if (!isProcessRunning(exeName)) - return true; - QProcess process; - process.start("taskkill /f /im " + exeName); - process.waitForFinished(1000); - return !isProcessRunning(exeName); -} \ No newline at end of file diff --git a/client/Common/FileHelper.h b/client/Common/FileHelper.h deleted file mode 100644 index 828bbcf..0000000 --- a/client/Common/FileHelper.h +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once -#include -#include -#include -#include - -class FileHelper -{ -public: - static bool createDir(const QString& path); - static bool copyFileOverwrite(const QString& src, const QString& dst); - static bool isProcessRunning(const QString& exeName); - static bool killProcess(const QString& exeName); -}; \ No newline at end of file diff --git a/client/Common/HttpHelper.cpp b/client/Common/HttpHelper.cpp deleted file mode 100644 index 2bc0957..0000000 --- a/client/Common/HttpHelper.cpp +++ /dev/null @@ -1,67 +0,0 @@ -#include "HttpHelper.h" -#include -#include "ConfigHelper.h" -#include -#include -#include -#include - -void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody, - std::function callback) -{ - QNetworkAccessManager* manager = new QNetworkAccessManager(); - manager->setProxy(QNetworkProxy::NoProxy); - - QNetworkRequest req(url); - req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - // Add auth token header - QString token = ConfigHelper::instance().getValue("Server", "client_token"); - req.setRawHeader("X-Client-Token", token.toUtf8()); - QFile identity(QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat")); - if (identity.open(QIODevice::ReadOnly)) - req.setRawHeader("X-Device-Credential", identity.readAll().toBase64()); - - QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact); - qDebug() << "=== POST Request ==="; - qDebug() << "Url:" << url; - qDebug() << "Body:" << data; - - 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; - - retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - const QByteArray respData = reply->readAll(); - if (!respData.isEmpty()) { - qDebug() << "Server raw response:" << respData; - retObj = QJsonDocument::fromJson(respData).object(); - } - if (reply->error() != QNetworkReply::NoError) - { - qDebug() << "Network error code:" << reply->error(); - qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString(); - } - - callback(retCode, retObj); - - reply->deleteLater(); - manager->deleteLater(); -} \ No newline at end of file diff --git a/client/Common/HttpHelper.h b/client/Common/HttpHelper.h deleted file mode 100644 index 3bef462..0000000 --- a/client/Common/HttpHelper.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once -#include -#include -#include -#include -#include -#include -#include -#include - -class HttpHelper -{ -public: - // 每次调用独立创建manager,不做成成员变量 - static void postRequest(const QString& url, const QJsonObject& jsonBody, - std::function callback); -}; \ No newline at end of file diff --git a/client/Common/IntegrityHelper.cpp b/client/Common/IntegrityHelper.cpp deleted file mode 100644 index 83d2e39..0000000 --- a/client/Common/IntegrityHelper.cpp +++ /dev/null @@ -1,153 +0,0 @@ -#include "IntegrityHelper.h" -#include "ConfigHelper.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#ifdef HAVE_OPENSSL -#include -#include -#endif - -IntegrityHelper::IntegrityHelper(const QString& installDir) - : m_installDir(QDir::cleanPath(installDir)) {} - -QString IntegrityHelper::errorString() const { return m_error; } - -bool IntegrityHelper::safeRelativePath(const QString& path) const -{ - const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path)); - return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".." - && !clean.startsWith("../") && !clean.contains(":"); -} - -bool IntegrityHelper::runtimeProtectedPath(const QString& path) const -{ - const QString p = QDir::fromNativeSeparators(path).toCaseFolded(); - 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); -} - -QString IntegrityHelper::sha256(const QString& filePath) const -{ - QFile file(filePath); - if (!file.open(QIODevice::ReadOnly)) return {}; - QCryptographicHash hash(QCryptographicHash::Sha256); - while (!file.atEnd()) hash.addData(file.read(1024 * 1024)); - return QString::fromLatin1(hash.result().toHex()); -} - -bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) -{ -#ifndef HAVE_OPENSSL - Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false; -#else - 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()); - EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr; - if (bio) BIO_free(bio); - if (!key) { m_error = "manifest public key invalid"; return false; } - EVP_MD_CTX* ctx = EVP_MD_CTX_new(); - const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); - const bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1 - && EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1 - && EVP_DigestVerifyFinal(ctx, reinterpret_cast(signature.constData()), signature.size()) == 1; - if (ctx) EVP_MD_CTX_free(ctx); - EVP_PKEY_free(key); - if (!ok) m_error = "manifest RSA signature invalid"; - return ok; -#endif -} - -bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString& channel, - const QString& version) -{ - m_error.clear(); - 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; - const QJsonDocument wrapperDoc = QJsonDocument::fromJson(cache.readAll(), &wrapperError); - if (wrapperError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { - m_error = "manifest cache JSON invalid"; return false; - } - const QJsonObject wrapper = wrapperDoc.object(); - const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8(); - const QString signature = wrapper.value("manifest").toObject().value("signature").toString(); - if (manifestText.isEmpty() || signature.isEmpty() || !verifySignature(manifestText, signature)) return false; - - QJsonParseError manifestError; - const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError); - if (manifestError.error != QJsonParseError::NoError || !manifestDoc.isObject()) { - m_error = "signed manifest payload invalid"; return false; - } - const QJsonObject manifest = manifestDoc.object(); - if (manifest.value("app_id").toString() != appId - || manifest.value("channel").toString() != channel - || manifest.value("version").toString() != version) { - m_error = "manifest identity does not match local application"; return false; - } - - QSet declaredExecutables; - for (const QJsonValue& value : manifest.value("files").toArray()) { - const QJsonObject item = value.toObject(); - const QString path = QDir::fromNativeSeparators(item.value("path").toString()); - if (!safeRelativePath(path)) { m_error = "unsafe manifest path: " + path; return false; } - if (runtimeProtectedPath(path)) continue; - const QString fullPath = QDir(m_installDir).filePath(path); - if (!QFile::exists(fullPath)) { m_error = "required file missing: " + path; return false; } - const QString expected = item.value("sha256").toString(); - const QString actual = sha256(fullPath); - if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) { - m_error = "file hash mismatch: " + path; return false; - } - const QString suffix = QFileInfo(path).suffix().toCaseFolded(); - if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded()); - } - - QDir root(m_installDir); - QDirIterator it(m_installDir, QDir::Files, QDirIterator::Subdirectories); - while (it.hasNext()) { - 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/") - || 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; - } - } - return true; -} diff --git a/client/Common/IntegrityHelper.h b/client/Common/IntegrityHelper.h deleted file mode 100644 index 202bfc7..0000000 --- a/client/Common/IntegrityHelper.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once -#include - -class IntegrityHelper -{ -public: - explicit IntegrityHelper(const QString& installDir); - bool verifyInstalledVersion(const QString& appId, const QString& channel, - const QString& version); - QString errorString() const; - -private: - bool verifySignature(const QByteArray& payload, const QString& signatureBase64); - bool safeRelativePath(const QString& path) const; - bool runtimeProtectedPath(const QString& path) const; - QString sha256(const QString& filePath) const; - - QString m_installDir; - QString m_error; -}; diff --git a/client/Common/LocalStateHelper.cpp b/client/Common/LocalStateHelper.cpp deleted file mode 100644 index 330e8a8..0000000 --- a/client/Common/LocalStateHelper.cpp +++ /dev/null @@ -1,147 +0,0 @@ -#include "LocalStateHelper.h" -#include -#include -#include -#include - -LocalStateHelper::LocalStateHelper(const QString& baseDir) - : m_baseDir(baseDir) - , m_filePath(baseDir + "/config/local_state.json") -{ -} - -bool LocalStateHelper::loadState(const QString& relativePath) -{ - m_filePath = m_baseDir + "/" + relativePath; - QFile file(m_filePath); - if (!file.exists()) - { - QDir dir(QFileInfo(m_filePath).path()); - if (!dir.exists() && !dir.mkpath(".")) - { - m_error = QString("Cannot create state directory: %1").arg(dir.path()); - return false; - } - - m_state = QJsonObject{ - {"max_policy_seq", 0}, - {"last_success_run_at", QString()}, - {"last_online_verified_at", QString()}, - {"last_success_version", QString()} - }; - m_loaded = true; - if (!saveState()) - { - m_error = QString("Cannot write new state file: %1").arg(m_filePath); - return false; - } - return true; - } - - if (!file.open(QIODevice::ReadOnly)) - { - m_error = QString("Cannot open state file: %1").arg(m_filePath); - return false; - } - - QByteArray raw = file.readAll(); - file.close(); - - QJsonParseError parseError; - QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError); - if (parseError.error != QJsonParseError::NoError || !doc.isObject()) - { - m_error = QString("Invalid state JSON: %1").arg(parseError.errorString()); - return false; - } - - m_state = doc.object(); - m_loaded = true; - return true; -} - -bool LocalStateHelper::saveState() const -{ - if (!m_loaded) - return false; - - QFile file(m_filePath); - if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) - { - return false; - } - - QJsonDocument doc(m_state); - file.write(doc.toJson(QJsonDocument::Indented)); - file.close(); - return true; -} - -bool LocalStateHelper::isLoaded() const -{ - return m_loaded; -} - -QString LocalStateHelper::errorString() const -{ - return m_error; -} - -qint64 LocalStateHelper::maxPolicySeq() const -{ - return m_state.value("max_policy_seq").toVariant().toLongLong(); -} - -QDateTime LocalStateHelper::lastSuccessRunAt() const -{ - return QDateTime::fromString(m_state.value("last_success_run_at").toString(), Qt::ISODate); -} - -QDateTime LocalStateHelper::lastOnlineVerifiedAt() const -{ - return QDateTime::fromString(m_state.value("last_online_verified_at").toString(), Qt::ISODate); -} - -QString LocalStateHelper::lastSuccessVersion() const -{ - return m_state.value("last_success_version").toString(); -} - -bool LocalStateHelper::isPolicySeqRolledBack(qint64 policySeq) const -{ - if (!m_loaded) - return false; - return policySeq < maxPolicySeq(); -} - -bool LocalStateHelper::isSystemTimeRewound() const -{ - if (!m_loaded) - return false; - const QDateTime now = QDateTime::currentDateTimeUtc(); - const QDateTime lastRun = lastSuccessRunAt(); - const QDateTime lastOnline = lastOnlineVerifiedAt(); - return (lastRun.isValid() && now < lastRun) - || (lastOnline.isValid() && now < lastOnline); -} - -void LocalStateHelper::updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq) -{ - if (!m_loaded) - m_loaded = true; - - m_state["last_success_run_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); - m_state["last_success_version"] = currentVersion; - if (policySeq > maxPolicySeq()) - m_state["max_policy_seq"] = policySeq; -} - -void LocalStateHelper::updateOnlineVerified(qint64 policySeq) -{ - if (!m_loaded) - m_loaded = true; - - m_state["last_online_verified_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); - if (policySeq > maxPolicySeq()) - m_state["max_policy_seq"] = policySeq; -} diff --git a/client/Common/LocalStateHelper.h b/client/Common/LocalStateHelper.h deleted file mode 100644 index dd166c5..0000000 --- a/client/Common/LocalStateHelper.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once -#include -#include -#include - -class LocalStateHelper -{ -public: - explicit LocalStateHelper(const QString& baseDir); - - bool loadState(const QString& relativePath = "config/local_state.json"); - bool saveState() const; - bool isLoaded() const; - QString errorString() const; - - qint64 maxPolicySeq() const; - QDateTime lastSuccessRunAt() const; - QDateTime lastOnlineVerifiedAt() const; - QString lastSuccessVersion() const; - - bool isPolicySeqRolledBack(qint64 policySeq) const; - bool isSystemTimeRewound() const; - - void updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq); - void updateOnlineVerified(qint64 policySeq); - -private: - QString m_baseDir; - QString m_filePath; - QString m_error; - QJsonObject m_state; - bool m_loaded = false; -}; diff --git a/client/Common/PolicyHelper.cpp b/client/Common/PolicyHelper.cpp deleted file mode 100644 index cafdb6f..0000000 --- a/client/Common/PolicyHelper.cpp +++ /dev/null @@ -1,116 +0,0 @@ -#include "PolicyHelper.h" -#include -#include -#include -#include -#include -#include -#include -#ifdef HAVE_OPENSSL -#include -#include -#endif - -PolicyHelper::PolicyHelper(const QString& baseDir) : m_baseDir(baseDir) {} - -bool PolicyHelper::loadPolicy(const QString& relativePath) -{ - QFile file(m_baseDir + "/" + relativePath); - if (!file.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy file"; return false; } - QJsonParseError error; - const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error); - if (error.error != QJsonParseError::NoError || !doc.isObject()) { - m_error = "Invalid policy JSON: " + error.errorString(); return false; - } - return loadPolicyObject(doc.object()); -} - -bool PolicyHelper::loadPolicyObject(const QJsonObject& policy, const QString& signedText) -{ - m_policy = policy; m_signedText = signedText; m_loaded = true; m_error.clear(); - return isValid(); -} - -bool PolicyHelper::savePolicy(const QString& relativePath) const -{ - QSaveFile file(m_baseDir + "/" + relativePath); - if (!file.open(QIODevice::WriteOnly)) return false; - const QByteArray bytes = QJsonDocument(m_policy).toJson(QJsonDocument::Indented); - return file.write(bytes) == bytes.size() && file.commit(); -} - -QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const -{ - QJsonObject copy = policy; copy.remove("signature"); - auto sortObject = [&](auto&& self, const QJsonObject& obj) -> QJsonObject { - QStringList keys = obj.keys(); std::sort(keys.begin(), keys.end()); - QJsonObject sorted; - for (const QString& key : keys) { - QJsonValue value = obj.value(key); - if (value.isObject()) value = self(self, value.toObject()); - else if (value.isArray()) { - QJsonArray array; - for (QJsonValue item : value.toArray()) { - if (item.isObject()) item = self(self, item.toObject()); - array.append(item); - } - value = array; - } - sorted.insert(key, value); - } - return sorted; - }; - return QJsonDocument(sortObject(sortObject, copy)).toJson(QJsonDocument::Compact); -} - -bool PolicyHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) const -{ -#ifndef HAVE_OPENSSL - Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false; -#else - QString keyPath = m_baseDir + "/config/manifest_public_key.pem"; - QFile keyFile(keyPath); - if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy public key"; return false; } - const QByteArray keyData = keyFile.readAll(); - BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); - EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr; - if (bio) BIO_free(bio); - if (!key) { m_error = "Invalid policy public key"; return false; } - EVP_MD_CTX* ctx = EVP_MD_CTX_new(); - const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); - bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1 - && EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1 - && EVP_DigestVerifyFinal(ctx, reinterpret_cast(signature.constData()), signature.size()) == 1; - if (ctx) EVP_MD_CTX_free(ctx); EVP_PKEY_free(key); - if (!ok) m_error = "Invalid RSA policy signature"; - return ok; -#endif -} - -bool PolicyHelper::isValid() const -{ - if (!m_loaded) return false; - const QStringList required{"app_id","channel","current_version","policy_seq","allow_run", - "force_update","allow_rollback","offline_allowed","valid_until","signature_alg","key_id","signature"}; - for (const QString& key : required) if (!m_policy.contains(key)) { m_error = "Missing policy field: " + key; return false; } - if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") return false; - const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8(); - return verifySignature(payload, m_policy.value("signature").toString()); -} -QString PolicyHelper::errorString() const { return m_error; } -bool PolicyHelper::isVersionAllowed(const QString& version) const { - if (!isValid()) return false; - for (const QJsonValue& v : m_policy.value("disabled_versions").toArray()) if (v.toString() == version) return false; - return true; -} -bool PolicyHelper::allowRun() const { return isValid() && m_policy.value("allow_run").toBool(); } -bool PolicyHelper::forceUpdate() const { return isValid() && m_policy.value("force_update").toBool(); } -bool PolicyHelper::allowRollback() const { return isValid() && m_policy.value("allow_rollback").toBool(); } -bool PolicyHelper::isOfflineAllowed() const { return isValid() && m_policy.value("offline_allowed").toBool(); } -bool PolicyHelper::isExpired() const { - if (!isValid()) return true; - const QDateTime expiry = QDateTime::fromString(m_policy.value("valid_until").toString(), Qt::ISODate); - return !expiry.isValid() || QDateTime::currentDateTimeUtc() > expiry; -} -qint64 PolicyHelper::policySeq() const { return isValid() ? m_policy.value("policy_seq").toVariant().toLongLong() : 0; } -QString PolicyHelper::message() const { return m_policy.value("message").toString(); } diff --git a/client/Common/PolicyHelper.h b/client/Common/PolicyHelper.h deleted file mode 100644 index fc28fd0..0000000 --- a/client/Common/PolicyHelper.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once -#include -#include - -class PolicyHelper -{ -public: - explicit PolicyHelper(const QString& baseDir); - - bool loadPolicy(const QString& relativePath = "config/version_policy.dat"); - bool loadPolicyObject(const QJsonObject& policy, const QString& signedText = QString()); - bool savePolicy(const QString& relativePath = "config/version_policy.dat") const; - bool isValid() const; - QString errorString() const; - bool isVersionAllowed(const QString& currentVersion) const; - bool allowRun() const; - bool forceUpdate() const; - bool allowRollback() const; - bool isOfflineAllowed() const; - bool isExpired() const; - qint64 policySeq() const; - QString message() const; - -private: - QByteArray canonicalPolicyBytes(const QJsonObject& obj) const; - bool verifySignature(const QByteArray& payload, const QString& signatureBase64) const; - - QString m_baseDir; - QJsonObject m_policy; - QString m_signedText; - mutable QString m_error; - bool m_loaded = false; -}; diff --git a/client/Common/TicketHelper.cpp b/client/Common/TicketHelper.cpp deleted file mode 100644 index 9cec298..0000000 --- a/client/Common/TicketHelper.cpp +++ /dev/null @@ -1,91 +0,0 @@ -#include "TicketHelper.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -static QByteArray ticketMac(const QJsonObject& payload, const QString& secret) -{ - return QMessageAuthenticationCode::hash( - QJsonDocument(payload).toJson(QJsonDocument::Compact), secret.toUtf8(), QCryptographicHash::Sha256).toHex(); -} - -bool TicketHelper::createTicket(const QString& appId, const QString& deviceId, - const QString& version, const QString& secret, - QString* ticketPath, QString* errorMessage) -{ - if (appId.isEmpty() || version.isEmpty() || secret.isEmpty()) { - if (errorMessage) *errorMessage = "ticket identity or secret is empty"; - return false; - } - const QDateTime now = QDateTime::currentDateTimeUtc(); - QJsonObject payload{ - {"app_id", appId}, {"device_id", deviceId}, {"version", version}, - {"nonce", QUuid::createUuid().toString(QUuid::WithoutBraces)}, - {"issued_at", now.toString(Qt::ISODate)}, - {"expires_at", now.addSecs(60).toString(Qt::ISODate)}, - {"signature_alg", "HMAC-SHA256"} - }; - QJsonObject wrapper{{"payload", payload}, {"signature", QString::fromLatin1(ticketMac(payload, secret))}}; - const QString dirPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath("marsco_tickets"); - if (!QDir().mkpath(dirPath)) { if (errorMessage) *errorMessage = "cannot create ticket directory"; return false; } - const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json"); - QSaveFile file(path); - const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact); - if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) { - if (errorMessage) *errorMessage = "cannot save ticket"; - return false; - } - QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner); - if (ticketPath) *ticketPath = path; - return true; -} - -bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& expectedAppId, - const QString& expectedDeviceId, const QString& expectedVersion, - const QString& secret, QString* errorMessage) -{ - const QString consumingPath = ticketPath + ".consuming." - + QString::number(QCoreApplication::applicationPid()); - if (!QFile::rename(ticketPath, consumingPath)) { - if (errorMessage) *errorMessage = "ticket missing or already consumed"; - return false; - } - QFile file(consumingPath); - if (!file.open(QIODevice::ReadOnly)) { - QFile::remove(consumingPath); - if (errorMessage) *errorMessage = "cannot read claimed ticket"; - return false; - } - const QByteArray raw = file.readAll(); file.close(); - QFile::remove(consumingPath); // 一次性消费;无论成功失败都不能重放。 - QJsonParseError parseError; - const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError); - if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { - if (errorMessage) *errorMessage = "invalid ticket JSON"; return false; - } - const QJsonObject wrapper = doc.object(); - const QJsonObject payload = wrapper.value("payload").toObject(); - const QByteArray actual = wrapper.value("signature").toString().toLatin1(); - const QByteArray expected = ticketMac(payload, secret); - const QDateTime issued = QDateTime::fromString(payload.value("issued_at").toString(), Qt::ISODate); - const QDateTime expires = QDateTime::fromString(payload.value("expires_at").toString(), Qt::ISODate); - const QDateTime now = QDateTime::currentDateTimeUtc(); - const bool identityOk = payload.value("app_id").toString() == expectedAppId - && payload.value("device_id").toString() == expectedDeviceId - && payload.value("version").toString() == expectedVersion; - const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5) - && expires >= now && issued.secsTo(expires) <= 65; - if (actual.isEmpty() || actual != expected || !identityOk || !timeOk - || payload.value("nonce").toString().isEmpty()) { - if (errorMessage) *errorMessage = "ticket signature, identity, time or nonce invalid"; - return false; - } - return true; -} diff --git a/client/Common/TicketHelper.h b/client/Common/TicketHelper.h deleted file mode 100644 index 9531204..0000000 --- a/client/Common/TicketHelper.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once -#include - -class TicketHelper -{ -public: - static bool createTicket(const QString& appId, const QString& deviceId, - const QString& version, const QString& secret, - QString* ticketPath, QString* errorMessage = nullptr); - static bool consumeAndVerify(const QString& ticketPath, const QString& expectedAppId, - const QString& expectedDeviceId, const QString& expectedVersion, - const QString& secret, QString* errorMessage = nullptr); -}; diff --git a/client/Launcher/CMakeLists.txt b/client/Launcher/CMakeLists.txt deleted file mode 100644 index 2063e02..0000000 --- a/client/Launcher/CMakeLists.txt +++ /dev/null @@ -1,46 +0,0 @@ -cmake_minimum_required(VERSION 3.20) -project(LauncherDemo LANGUAGES C CXX) -# C++标准 -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -# Qt 自动处理ui/moc/rcc -set(CMAKE_INCLUDE_CURRENT_DIR ON) -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) -# 依赖Qt模块:网络、基础核心、窗口 -set(CMAKE_PREFIX_PATH "C:\\Qt\\5.15.2\\msvc2019_64" ${CMAKE_PREFIX_PATH}) -find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets) -# 所有源码文件 -set(SRC_LIST - main.cpp - UpdateLogic.h - UpdateLogic.cpp -) -# 生成可执行程序 -add_executable(Launcher ${SRC_LIST}) -if(WIN32) - set_target_properties(Launcher PROPERTIES WIN32_EXECUTABLE TRUE) -endif() - -# 关键:添加OpenSSL头文件目录 -target_include_directories(Launcher PRIVATE ${OPENSSL_INC}) - -# 链接Common,自动带上OpenSSL库 -target_link_libraries(Launcher Common Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets) -# 输出编译产物到 out 文件夹(干净不乱) -set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/lib) -set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin) -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin) -# 强制VS打开CMake文件夹时默认选中该可执行目标 -set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher) -# 编译完成自动执行windeployqt,复制Qt dll到exe目录 -if(WIN32) - get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION) - get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY) - set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe") - add_custom_command(TARGET Launcher POST_BUILD - COMMAND ${WINDEPLOYQT} $,--debug,--release> $ - COMMENT "自动部署Qt依赖dll" - ) -endif() diff --git a/client/Launcher/UpdateLogic.cpp b/client/Launcher/UpdateLogic.cpp deleted file mode 100644 index a8f0024..0000000 --- a/client/Launcher/UpdateLogic.cpp +++ /dev/null @@ -1,117 +0,0 @@ -#include "UpdateLogic.h" -#include "ConfigHelper.h" -#include -#include -#include -#include "PolicyHelper.h" -#include "LocalStateHelper.h" - -UpdateLogic::UpdateLogic(QObject* parent) - : QObject(parent) -{ - ConfigHelper& cfg = ConfigHelper::instance(); - m_serverAddr = cfg.getValue("Server", "api_base_url"); - m_appId = cfg.getValue("App", "app_id"); - m_curVer = cfg.getValue("App", "current_version"); - m_channel = cfg.getValue("App", "channel"); - - // Debug print config - qDebug() << "Read server addr:" << m_serverAddr; - qDebug() << "Read app id:" << m_appId; -} - -void UpdateLogic::checkUpdate() -{ - if (m_serverAddr.isEmpty() || m_appId.isEmpty() || m_curVer.isEmpty() || m_channel.isEmpty()) - { - qDebug() << "Config incomplete, abort update check"; - m_needUpdate = false; - return; - } - QString url = m_serverAddr + "/api/v1/update/check"; - QJsonObject body; - body["app_id"] = m_appId; - body["current_version"] = m_curVer; - body["channel"] = m_channel; - const int configuredProtocol = ConfigHelper::instance().getValue("App", "client_protocol").toInt(); - body["client_protocol"] = qMax(3, configuredProtocol); - - m_http.postRequest(url, body, [this](int code, const QJsonObject& resp) - { - qDebug() << "Check update HTTP code:" << code; - m_lastStatusCode = code; - m_checkResp = resp; - m_networkOk = (code == 200); - if (code == 200) - { - const QString appDir = QApplication::applicationDirPath(); - PolicyHelper onlinePolicy(appDir); - LocalStateHelper state(appDir); - const bool stateLoaded = state.loadState(); - const bool policyValid = onlinePolicy.loadPolicyObject( - resp.value("policy").toObject(), resp.value("policy_text").toString()); - if (!policyValid || !stateLoaded - || state.isPolicySeqRolledBack(onlinePolicy.policySeq()) - || !onlinePolicy.savePolicy()) - { - qDebug() << "Online policy rejected:" << onlinePolicy.errorString(); - m_networkOk = false; - m_needUpdate = false; - return; - } - state.updateOnlineVerified(onlinePolicy.policySeq()); - if (!state.saveState()) - { - qDebug() << "Cannot persist online policy state"; - m_networkOk = false; - m_needUpdate = false; - return; - } - m_needUpdate = resp["need_update"].toBool(); - m_latestVer = resp["latest_version"].toString(); - qDebug() << "Need update:" << m_needUpdate; - qDebug() << "Latest version:" << m_latestVer; - qDebug() << "Policy seq:" << onlinePolicy.policySeq(); - } - else - { - m_needUpdate = false; - qDebug() << "Check update api failed"; - } - }); -} - -void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId) -{ - QString url = m_serverAddr + "/api/v1/update/download-url"; - QJsonObject body; - body["app_id"] = appId; - body["channel"] = channel; - body["version"] = ver; - body["version_id"] = verId; - QJsonArray files; - body["files"] = files; - - m_http.postRequest(url, body, [](int code, const QJsonObject& resp) - { - qDebug() << "\n========== File Download Url =========="; - qDebug() << resp["files"].toArray(); - }); -} - -void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success) -{ - QString url = m_serverAddr + "/api/v1/update/report"; - QJsonObject body; - body["app_id"] = m_appId; - body["device_id"] = deviceId; - body["from_version"] = fromVer; - body["to_version"] = toVer; - body["result"] = success ? "success" : "fail"; - - m_http.postRequest(url, body, [](int code, const QJsonObject& resp) - { - qDebug() << "\n========== Update Report Result =========="; - qDebug() << resp; - }); -} \ No newline at end of file diff --git a/client/Launcher/UpdateLogic.h b/client/Launcher/UpdateLogic.h deleted file mode 100644 index 3f2ea84..0000000 --- a/client/Launcher/UpdateLogic.h +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once -#include -#include "HttpHelper.h" -#include -#include - -class UpdateLogic : public QObject -{ - Q_OBJECT -public: - explicit UpdateLogic(QObject* parent = nullptr); - - void checkUpdate(); - void getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId); - void reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success); - - bool getNeedUpdate() const { return m_needUpdate; } - QString getLatestVersion() const { return m_latestVer; } - QJsonObject getCheckResult() const { return m_checkResp; } - bool isNetworkOk() const { return m_networkOk; } - int lastStatusCode() const { return m_lastStatusCode; } - - QString getAppId() const { return m_appId; } - QString getChannel() const { return m_channel; } - -private: - HttpHelper m_http; - QString m_serverAddr; - QString m_appId; - QString m_curVer; - QString m_channel; - - bool m_needUpdate = false; - bool m_networkOk = false; - int m_lastStatusCode = 0; - QString m_latestVer; - QJsonObject m_checkResp; -}; \ No newline at end of file diff --git a/client/Launcher/main.cpp b/client/Launcher/main.cpp deleted file mode 100644 index 8a1c5fa..0000000 --- a/client/Launcher/main.cpp +++ /dev/null @@ -1,282 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "UpdateLogic.h" -#include "../Common/ConfigHelper.h" -#include "../Common/PolicyHelper.h" -#include "../Common/LocalStateHelper.h" -#include "../Common/TicketHelper.h" -#include "../Common/DeviceIdentityHelper.h" -#include -#include -#include - -int main(int argc, char* argv[]) -{ - SetConsoleOutputCP(65001); - QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8")); - QApplication app(argc, argv); - QApplication::setApplicationName("Marsco Launcher"); - - QProgressDialog progress("正在检查软件更新...", QString(), 0, 0); - progress.setWindowTitle("Marsco 软件启动器"); - progress.setCancelButton(nullptr); - progress.setWindowModality(Qt::ApplicationModal); - progress.setMinimumDuration(0); - progress.setAutoClose(false); - progress.show(); - QApplication::processEvents(); - - const QString appDir = QApplication::applicationDirPath(); - ConfigHelper& config = ConfigHelper::instance(); - 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; - logic.checkUpdate(); - - const bool needUpdate = logic.getNeedUpdate(); - const bool networkOk = logic.isNetworkOk(); - const QString latestVer = logic.getLatestVersion(); - const QJsonObject response = logic.getCheckResult(); - const int targetVersionId = response.value("version_id").toInt(); - const QString appId = logic.getAppId(); - const QString channel = logic.getChannel(); - - if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) { - progress.close(); - const QJsonValue detail = response.value("detail"); - const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString(); - 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 auto configuredName = [&](const QString& key, const QString& fallback) { - const QString value = config.getValue("Runtime", key).trimmed(); - return value.isEmpty() ? fallback : value; - }; - const QString mainExecutable = configuredName("main_executable", "MainApp.exe"); - const QString updaterExecutable = configuredName("updater_executable", "Updater.exe"); - const QString mainAppPath = QDir(appDir).filePath(mainExecutable); - const QString updaterPath = QDir(appDir).filePath(updaterExecutable); - const auto importOfflinePackage = [&]() { - const QString package = QFileDialog::getOpenFileName(nullptr, "选择离线更新包", QString(), "Marsco 离线更新包 (*.upd)"); - return package.isEmpty() ? false : QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)}); - }; - const QString deviceId = config.getValue("Update", "device_id"); - if (QCoreApplication::arguments().contains("--import-offline")) { - progress.close(); - if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包。"); - return 0; - } - - const auto startMainApp = [&]() { - QString ticketPath; - QString ticketError; - if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion, - launchToken, &ticketPath, &ticketError)) { - qDebug() << "Cannot create launch ticket:" << ticketError; - return false; - } - const bool started = QProcess::startDetached(mainAppPath, - QStringList{QString("--ticket-file=%1").arg(ticketPath)}); - if (!started) QFile::remove(ticketPath); - return started; - }; - - progress.setLabelText("正在验证本地运行策略..."); - QApplication::processEvents(); - - PolicyHelper policy(appDir); - if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid()) - { - progress.close(); - QMessageBox::critical(nullptr, "无法启动", QString("本地版本策略无效:%1").arg(policy.errorString())); - return -1; - } - if (policy.isExpired()) - { - progress.close(); - QMessageBox::critical(nullptr, "无法启动", "本地版本策略已过期,请连接网络或联系管理员。"); - return -1; - } - if ((!policy.allowRun() || !policy.isVersionAllowed(currentVersion)) && !(networkOk && needUpdate)) - { - progress.close(); - QMessageBox::critical(nullptr, "当前版本不可运行", - policy.message().isEmpty() ? QString("当前版本 %1 已被管理员停用。").arg(currentVersion) - : policy.message()); - return -1; - } - - LocalStateHelper state(appDir); - if (!state.loadState()) - { - progress.close(); - QMessageBox::critical(nullptr, "无法启动", QString("无法读取本地状态:%1").arg(state.errorString())); - return -1; - } - if (state.isPolicySeqRolledBack(policy.policySeq())) - { - progress.close(); - QMessageBox::critical(nullptr, "安全检查失败", "检测到版本策略序列回退,已阻止启动。"); - return -1; - } - if (state.isSystemTimeRewound()) - { - progress.close(); - QMessageBox::critical(nullptr, "安全检查失败", "检测到系统时间可能被回拨,已阻止启动。"); - return -1; - } - - if (networkOk && needUpdate) - { - progress.close(); - const bool rollbackOperation = response.value("action").toString() == "rollback_allowed"; - const QString dialogTitle = rollbackOperation ? "版本回退" - : (policy.forceUpdate() ? "必须更新" : "发现新版本"); - const QString prompt = policy.message().isEmpty() - ? QString(rollbackOperation ? "管理员提供了版本 %1 作为回退目标,是否现在降级?" - : "发现新版本 %1,是否现在更新?").arg(latestVer) - : policy.message() + QString("\n目标版本:%1").arg(latestVer); - bool accepted = true; - if (policy.forceUpdate()) { - QMessageBox::information(nullptr, dialogTitle, prompt); - } else { - accepted = QMessageBox::question(nullptr, dialogTitle, prompt, - QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes; - } - if (!accepted) { - if (!startMainApp()) { - QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath)); - return -1; - } - return 0; - } - const QStringList updaterArgs{appId, channel, latestVer, QString::number(targetVersionId)}; - if (!QProcess::startDetached(updaterPath, updaterArgs)) - { - QMessageBox::critical(nullptr, "更新器启动失败", QString("无法启动更新器:%1").arg(updaterPath)); - return -1; - } - return 0; - } - - if (!networkOk) { - progress.close(); - if (QMessageBox::question(nullptr, "服务器不可用", "当前无法连接更新服务器。是否导入离线更新包?", - QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) { - if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包或无法启动更新器。"); - return 0; - } - progress.show(); - } - - if (!networkOk && !policy.isOfflineAllowed()) - { - progress.close(); - QMessageBox::critical(nullptr, "网络不可用", "无法连接更新服务器,且当前策略不允许离线启动。"); - return -1; - } - - progress.setLabelText(networkOk ? "当前已是最新版本,正在启动..." : "当前处于离线模式,正在启动..."); - QApplication::processEvents(); - if (!startMainApp()) - { - progress.close(); - QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath)); - return -1; - } - progress.close(); - return 0; -} diff --git a/client/MainApp/CMakeLists.txt b/client/MainApp/CMakeLists.txt deleted file mode 100644 index 7182c39..0000000 --- a/client/MainApp/CMakeLists.txt +++ /dev/null @@ -1,21 +0,0 @@ -project(MainApp) -add_executable(MainApp - main.cpp - MainWindow.h - MainWindow.cpp - ../Common/ConfigHelper.h -) -target_include_directories(MainApp - PUBLIC ${CMAKE_SOURCE_DIR}/Common - PRIVATE ${OPENSSL_INC} -) -target_link_libraries(MainApp PRIVATE Common Qt5::Core Qt5::Gui Qt5::Widgets) - -if(WIN32) - get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION) - get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY) - set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe") - add_custom_command(TARGET MainApp POST_BUILD - COMMAND ${WINDEPLOYQT} $,--debug,--release> $ - ) -endif() diff --git a/client/MainApp/MainWindow.cpp b/client/MainApp/MainWindow.cpp deleted file mode 100644 index 77f4c58..0000000 --- a/client/MainApp/MainWindow.cpp +++ /dev/null @@ -1,72 +0,0 @@ -#include "MainWindow.h" -#include -#include -#include -#include -#include -#include -#include "../Common/PolicyHelper.h" -#include "../Common/ConfigHelper.h" - -static QString readDllVersion(const QString& dllPath) -{ - QFile file(dllPath); - if (!file.exists()) - return "missing"; - - if (!file.open(QIODevice::ReadOnly)) - return "unreadable"; - - QByteArray data = file.read(1024); - file.close(); - return QString::fromUtf8(QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex().left(8)); -} - -MainWindow::MainWindow(QWidget* parent) - : QWidget(parent) -{ - this->setWindowTitle("Marsco Demo MainApp"); - this->resize(600, 400); - - QString appVersion = ConfigHelper::instance().getValue("App", "current_version"); - if (appVersion.isEmpty()) - appVersion = "unknown"; - - QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll"); - - PolicyHelper policy(QApplication::applicationDirPath()); - QString policyResult; - if (!policy.loadPolicy("config/version_policy.dat")) - { - policyResult = "policy missing"; - } - else if (!policy.isValid()) - { - policyResult = "policy invalid"; - } - else if (!policy.isVersionAllowed(appVersion)) - { - policyResult = "version disabled"; - } - else if (policy.isExpired()) - { - policyResult = "policy expired"; - } - else - { - policyResult = "policy ok"; - } - - QVBoxLayout* layout = new QVBoxLayout(this); - QLabel* label = new QLabel(QString("Software Running Successfully\nVersion: %1\nDLL Version: %2\nPolicy: %3") - .arg(appVersion) - .arg(dllVersion) - .arg(policyResult)); - QFont font = label->font(); - font.setPointSize(14); - label->setFont(font); - label->setAlignment(Qt::AlignCenter); - - layout->addWidget(label); - this->setLayout(layout); -} \ No newline at end of file diff --git a/client/MainApp/MainWindow.h b/client/MainApp/MainWindow.h deleted file mode 100644 index 0c57cc2..0000000 --- a/client/MainApp/MainWindow.h +++ /dev/null @@ -1,11 +0,0 @@ -#pragma once -#include -#include -#include - -class MainWindow : public QWidget -{ - Q_OBJECT -public: - explicit MainWindow(QWidget* parent = nullptr); -}; \ No newline at end of file diff --git a/client/MainApp/main.cpp b/client/MainApp/main.cpp deleted file mode 100644 index 4d73d19..0000000 --- a/client/MainApp/main.cpp +++ /dev/null @@ -1,130 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include "MainWindow.h" -#include "../Common/ConfigHelper.h" -#include "../Common/PolicyHelper.h" -#include "../Common/LocalStateHelper.h" -#include "../Common/TicketHelper.h" -#include "../Common/IntegrityHelper.h" -#include "../Common/DeviceIdentityHelper.h" - -int main(int argc, char* argv[]) -{ - SetConsoleOutputCP(936); - QTextCodec::setCodecForLocale(QTextCodec::codecForName("GBK")); - - QApplication a(argc, argv); - - qDebug() << Qt::endl << "entered main app" << Qt::endl; - - ConfigHelper& config = ConfigHelper::instance(); - QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed(); - if (launcherExecutable.isEmpty()) launcherExecutable = "Launcher.exe"; - QString ticketFilePath; - QString healthFilePath; - for (int i = 1; i < argc; ++i) - { - const QString arg(argv[i]); - if (arg.startsWith("--ticket-file=")) - ticketFilePath = arg.mid(QString("--ticket-file=").size()); - else if (arg.startsWith("--health-file=")) - healthFilePath = arg.mid(QString("--health-file=").size()); - } - - QString ticketError; - if (ticketFilePath.isEmpty() - || !TicketHelper::consumeAndVerify(ticketFilePath, - config.getValue("App", "app_id"), config.getValue("Update", "device_id"), - config.getValue("App", "current_version"), config.getValue("App", "launch_token"), - &ticketError)) - { - QMessageBox::critical(nullptr, "Startup Restriction", - QString("Invalid or missing one-time launch ticket: %1\nPlease use %2.") - .arg(ticketError, launcherExecutable)); - return -1; - } - - QString appDir = QApplication::applicationDirPath(); - const QString installRoot = config.installRoot(); - DeviceIdentityHelper identity(appDir); - if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel"))) - { - QMessageBox::critical(nullptr, "License Error", QString("Local license invalid: %1").arg(identity.errorString())); - return -1; - } - PolicyHelper policy(appDir); - if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid()) - { - QMessageBox::critical(nullptr, "Policy Error", QString("Local policy invalid: %1").arg(policy.errorString())); - return -1; - } - - if (policy.isExpired()) - { - QMessageBox::critical(nullptr, "Policy Error", "Policy expired, cannot start the application."); - return -1; - } - - LocalStateHelper state(appDir); - if (!state.loadState()) - { - QMessageBox::critical(nullptr, "State Error", QString("Cannot load local state: %1").arg(state.errorString())); - return -1; - } - - if (state.isPolicySeqRolledBack(policy.policySeq())) - { - QMessageBox::critical(nullptr, "Policy Error", "Detected policy sequence rollback, startup blocked."); - return -1; - } - - if (state.isSystemTimeRewound()) - { - QMessageBox::critical(nullptr, "Policy Error", "System time appears to be rewound, startup blocked."); - return -1; - } - - IntegrityHelper integrity(installRoot); - if (!integrity.verifyInstalledVersion( - config.getValue("App", "app_id"), config.getValue("App", "channel"), - config.getValue("App", "current_version"))) - { - QMessageBox::critical(nullptr, "Integrity Check Failed", - QString("Application files failed signed Manifest verification:\n%1") - .arg(integrity.errorString())); - return -1; - } - - MainWindow w; - w.show(); - - state.updateAfterSuccessfulRun(ConfigHelper::instance().getValue("App", "current_version"), policy.policySeq()); - if (!state.saveState()) - { - QMessageBox::critical(nullptr, "State Error", QString("Cannot save local state: %1").arg(state.errorString())); - return -1; - } - - if (!healthFilePath.isEmpty()) - { - QDir().mkpath(QFileInfo(healthFilePath).path()); - QSaveFile healthFile(healthFilePath); - const QByteArray healthy("ok\n"); - if (!healthFile.open(QIODevice::WriteOnly) - || healthFile.write(healthy) != healthy.size() - || !healthFile.commit()) - { - QMessageBox::critical(nullptr, "Startup Error", "Cannot write update health confirmation file."); - return -1; - } - } - - qDebug() << "Main program MainApp is running normally"; - return a.exec(); -} diff --git a/client/READMe.txt b/client/READMe.txt deleted file mode 100644 index 328294c..0000000 --- a/client/READMe.txt +++ /dev/null @@ -1,27 +0,0 @@ -客户端配置说明 -============== - -统一从程序目录下的 config/app_config.json 读取配置。 -首次运行时如果该文件不存在且发现旧 client.ini,会自动迁移。 - -接入新软件时通常需要修改: - -1. app_id、app_name、channel、current_version。 -2. api_base_url、client_token、license_key、launch_token。 -3. main_executable:团队业务主程序文件名。 -4. launcher_executable、updater_executable、bootstrap_executable。 -5. health_check_timeout_ms:升级后等待业务程序健康确认的毫秒数,最小 1000。 - -完整格式参考 config/app_config.example.json。 -运行时生成的 app_config.json、client_identity.dat、local_state.json 等文件不得打入通用 SDK 模板。 - -Windows 发布打包: - -1. 使用 Release 配置编译全部客户端程序。 -2. 先完成当前版本在线校验,确认 out/bin/update/manifest_cache 中存在对应的签名 Manifest。 -3. 准备一份实际 app_config.json,确认其中包含正确的 License Key、当前版本和业务程序名。 -4. 在 PowerShell 执行: - powershell -ExecutionPolicy Bypass -File .\package-client.ps1 -ConfigFile .\config\app_config.json -5. 输出位于 dist/UpdateClient 和 dist/UpdateClient.zip。 - -脚本会拒绝 Debug DLL、PDB、嵌套重复主程序和缺少签名 Manifest 的发布源目录。 diff --git a/client/SDK_README.md b/client/SDK_README.md deleted file mode 100644 index 30f3b0a..0000000 --- a/client/SDK_README.md +++ /dev/null @@ -1,188 +0,0 @@ -# UpdateClientSDK 接入说明 - -这个 SDK 是“独立更新器 SDK / 升级运行时 SDK”。它不是传统的 `include + lib` 形态,而是把自动升级能力作为一组独立程序交给业务软件使用。 - -SDK 核心程序: - -- `Launcher.exe`:用户入口。检查版本、验证策略,决定启动业务主程序或启动 Updater。 -- `Updater.exe`:下载、校验、备份、安装、健康确认、提交或回滚。 -- `Bootstrap.exe`:替换运行中可能被占用的 EXE/DLL。 -- `config/app_config.json`:接入方配置。 -- `config/manifest_public_key.pem`:验证服务端签名用的公钥。 - -## 接入方需要做什么 - -假设接入的软件叫 `YourApp.exe`。 - -1. 在服务端管理后台创建应用,例如 `app_id=your_app_id`。 -2. 创建或确认渠道,例如 `stable`。 -3. 创建 License,把生成的 `license_key` 填到客户端配置。 -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 -{ - "app_id": "simcae", - "app_name": "SimCAE", - "channel": "stable", - "current_version": "1.0.0", - "client_protocol": "3", - "launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes", - "license_key": "", - "api_base_url": "http://YOUR_SERVER_IP:8000", - "client_token": "SimCAEClientToken2026", - "request_timeout_ms": "5000", - "temp_folder": "update_temp", - "device_id": "", - "install_root": "..", - "main_executable": "SimCAE.exe", - "launcher_executable": "Launcher.exe", - "updater_executable": "Updater.exe", - "bootstrap_executable": "Bootstrap.exe", - "health_check_timeout_ms": "15000", - "platform": "windows", - "arch": "x64" -} -``` - -字段说明: - -- `app_id`:服务端应用 ID,默认填 `simcae`;如果后台创建了别的 App ID,这里同步修改。 -- `channel`:发布渠道,例如 `stable`、`beta`、`dev`。 -- `current_version`:当前客户端初始版本。 -- `client_protocol`:客户端协议号,当前建议为 `3`。 -- `api_base_url`:服务端 API 地址,例如 `http://192.168.229.128:8000`;服务器 IP 无法提前知道,所以这里需要按现场地址修改。 -- `client_token`:服务端 `.env` 中的 `CLIENT_API_TOKEN`,默认交付包已填 `SimCAEClientToken2026`。 -- `license_key`:管理后台创建 License 后返回的密钥;模板里先留空,创建授权后再填。 -- `launch_token`:本机启动票据 HMAC 密钥,模板已给默认值,可试跑;正式交付建议改成你自己的 32 字符以上随机字符串。 -- `install_root`:安装根目录相对 `Launcher.exe` 所在目录的位置。SDK 放在 `bin` 时填 `..`。 -- `main_executable`:业务主程序相对 `Launcher.exe` 所在目录的路径,SimCAE 默认填 `SimCAE.exe`。 -- `health_check_timeout_ms`:升级后等待业务程序写健康标记的时间。 - -## 业务主程序需要配合什么 - -当前安全模式下,业务主程序需要配合两件事: - -1. 接收 `--ticket-file=` 参数,验证并消费一次性启动票据。 -2. 如果收到 `--health-file=` 参数,启动成功后向该路径写入 `ok\n`,让 Updater 确认新版本可用。 - -当前仓库里的 `client/MainApp/main.cpp` 是接入示例,已经实现了: - -- 启动票据校验。 -- 本地 License/设备身份校验。 -- 本地策略校验。 -- Manifest 完整性校验。 -- 健康标记写入。 - -如果第三方业务程序暂时不想改代码,可以先使用当前 `MainApp.exe` 作为 Demo 验证 SDK 包;真正接入时建议把这些启动检查逻辑移植到业务主程序。 - -## 如何生成 SDK 包 - -在 Windows PowerShell 中执行: - -```powershell -cd client -.\package-sdk.ps1 ` - -SourceDir .\out\bin ` - -OutputDir .\dist\UpdateClientSDK ` - -ZipFile .\dist\UpdateClientSDK.zip ` - -SdkVersion 0.1.0 -``` - -默认生成的 SDK 不包含 Qt 运行库,避免覆盖业务软件自带的 Qt。只有在接入的软件本身不带 Qt,且你确认要让 SDK 自带一套 Qt 运行库时,才额外添加 `-IncludeQtRuntime`。 - -生成结果: - -```text -dist/UpdateClientSDK/ - SimCAE自动升级SDK接入说明_v0.1.docx - sdk_manifest.json - bin/ - config/ - app_config.json - manifest_public_key.pem - scripts/ -``` - -把 `UpdateClientSDK.zip` 发给接入方即可。 - -## 如何生成某个产品的最终客户端包 - -SDK 是给开发者接入用的,最终给用户安装/分发时,可以使用: - -```powershell -cd client -.\package-client.ps1 ` - -SourceDir .\out\bin ` - -ConfigFile .\config\app_config.json ` - -OutputDir .\dist\UpdateClient ` - -ZipFile .\dist\UpdateClient.zip -``` - -`package-client.ps1` 会检查配置和必需文件,并生成具体产品的客户端包。 - -## 常见错误 - -1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。 -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/SimCAE_Crash_Report后端接口规范.docx b/client/SimCAE_Crash_Report后端接口规范.docx deleted file mode 100644 index 92ab6f5..0000000 Binary files a/client/SimCAE_Crash_Report后端接口规范.docx and /dev/null differ diff --git a/client/SimCAE自动升级SDK接入说明_v0.1.docx b/client/SimCAE自动升级SDK接入说明_v0.1.docx deleted file mode 100644 index b9664f4..0000000 Binary files a/client/SimCAE自动升级SDK接入说明_v0.1.docx and /dev/null differ diff --git a/client/Updater/CMakeLists.txt b/client/Updater/CMakeLists.txt deleted file mode 100644 index 167f8b6..0000000 --- a/client/Updater/CMakeLists.txt +++ /dev/null @@ -1,41 +0,0 @@ -# 强制所有编译、设计时生成均使用x64,禁止Win32 -set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁用Win32") -set(CMAKE_VS_PLATFORM_TOOLSET_HOST_ARCH x64) -# 关闭VS自动Win32设计时预生成 -set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD OFF) -# 清除32位Strawberry Perl路径干扰 -list(REMOVE_ITEM CMAKE_INCLUDE_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/include") -list(REMOVE_ITEM CMAKE_LIBRARY_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/lib") - -project(Updater) -set(SRC - main.cpp - UpdaterLogic.h - UpdaterLogic.cpp - UpdateTransaction.h - UpdateTransaction.cpp -) -add_executable(Updater ${SRC}) - -if(WIN32) - set_target_properties(Updater PROPERTIES WIN32_EXECUTABLE TRUE) -endif() - -# 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到 -target_include_directories(Updater PRIVATE ${OPENSSL_INC}) - -# 仅链接Common和Qt,OpenSSL库由Common INTERFACE自动传递 -target_link_libraries(Updater PRIVATE - Common - Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets -) - -# Qt部署脚本 -if(WIN32) - get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION) - get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY) - set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe") - add_custom_command(TARGET Updater POST_BUILD - COMMAND ${WINDEPLOYQT} $,--debug,--release> $ - ) -endif() diff --git a/client/Updater/UpdateTransaction.cpp b/client/Updater/UpdateTransaction.cpp deleted file mode 100644 index d6fc305..0000000 --- a/client/Updater/UpdateTransaction.cpp +++ /dev/null @@ -1,274 +0,0 @@ -#include "UpdateTransaction.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -UpdateTransaction::UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion, - const QString& toVersion, int manifestId) - : m_installDir(QDir::cleanPath(installDir)), - 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) -{ - m_stagingDir = QDir(m_updateDir).filePath("staging/" + m_transactionId); - m_backupDir = QDir(m_updateDir).filePath("backup/" + m_transactionId); -} - -bool UpdateTransaction::safeRelativePath(const QString& path) -{ - const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path)); - return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".." - && !clean.startsWith("../") && !clean.contains(":"); -} - -bool UpdateTransaction::copyOverwrite(const QString& source, const QString& destination) const -{ - if (!QDir().mkpath(QFileInfo(destination).path())) return false; - if (QFile::exists(destination) && !QFile::remove(destination)) return false; - return QFile::copy(source, destination); -} - -bool UpdateTransaction::writeState(const QString& status, const QString& errorCode, const QString& message) -{ - m_state["transaction_id"] = m_transactionId; - m_state["from_version"] = m_fromVersion; - m_state["to_version"] = m_toVersion; - m_state["status"] = status; - m_state["manifest_id"] = m_manifestId; - m_state["updated_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); - m_state["staging_dir"] = m_stagingDir; - m_state["backup_dir"] = m_backupDir; - m_state["error_code"] = errorCode; - m_state["message"] = message; - QJsonArray paths; - for (const QString& path : m_changedPaths) paths.append(path); - m_state["changed_paths"] = paths; - QJsonArray obsoletePaths; - for (const QString& path : m_obsoletePaths) obsoletePaths.append(path); - m_state["obsolete_paths"] = obsoletePaths; - - QDir().mkpath(m_updateDir); - QSaveFile file(m_stateFile); - if (!file.open(QIODevice::WriteOnly)) return false; - const QByteArray payload = QJsonDocument(m_state).toJson(QJsonDocument::Indented); - if (file.write(payload) != payload.size()) { file.cancelWriting(); return false; } - return file.commit(); -} - -bool UpdateTransaction::initialize() -{ - QDir(m_stagingDir).removeRecursively(); - QDir(m_backupDir).removeRecursively(); - if (!QDir().mkpath(m_stagingDir) || !QDir().mkpath(m_backupDir)) return false; - m_state["started_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); - return writeState("prepared"); -} - -bool UpdateTransaction::resumeExisting(QString* errorMessage) -{ - QFile file(m_stateFile); - if (!file.open(QIODevice::ReadOnly)) { - if (errorMessage) *errorMessage = "cannot open upgrade_state.json"; - return false; - } - const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - file.close(); - if (!doc.isObject()) { - if (errorMessage) *errorMessage = "invalid upgrade_state.json"; - return false; - } - m_state = doc.object(); - const QString expectedToVersion = m_toVersion; - const int expectedManifestId = m_manifestId; - const QString stateStatus = m_state.value("status").toString(); - m_transactionId = m_state.value("transaction_id").toString(); - m_fromVersion = m_state.value("from_version").toString(); - m_toVersion = m_state.value("to_version").toString(); - m_manifestId = m_state.value("manifest_id").toInt(); - if (m_toVersion != expectedToVersion || m_manifestId != expectedManifestId - || (stateStatus != "awaiting_bootstrap" && stateStatus != "post_verify" - && stateStatus != "rollback_required")) { - if (errorMessage) *errorMessage = "upgrade state does not match resume request"; - return false; - } - m_stagingDir = m_state.value("staging_dir").toString(); - m_backupDir = m_state.value("backup_dir").toString(); - m_changedPaths.clear(); - m_obsoletePaths.clear(); - for (const QJsonValue& value : m_state.value("changed_paths").toArray()) { - const QString path = value.toString(); - if (!safeRelativePath(path)) { - if (errorMessage) *errorMessage = "unsafe path in upgrade state"; - return false; - } - m_changedPaths.append(path); - } - for (const QJsonValue& value : m_state.value("obsolete_paths").toArray()) { - const QString path = value.toString(); - if (!safeRelativePath(path)) { - if (errorMessage) *errorMessage = "unsafe obsolete path in upgrade state"; - return false; - } - m_obsoletePaths.append(path); - } - if (m_transactionId.isEmpty() || m_stagingDir.isEmpty() || m_backupDir.isEmpty()) { - if (errorMessage) *errorMessage = "incomplete upgrade state"; - return false; - } - return true; -} - -bool UpdateTransaction::recordVerifiedFiles(const QStringList& changedPaths, - const QStringList& obsoletePaths) -{ - m_changedPaths.clear(); - m_obsoletePaths.clear(); - QSet changedKeys; - for (const QString& path : changedPaths) { - if (!safeRelativePath(path)) return false; - const QString normalized = QDir::fromNativeSeparators(path); - changedKeys.insert(normalized.toCaseFolded()); - m_changedPaths.append(normalized); - } - for (const QString& path : obsoletePaths) { - if (!safeRelativePath(path)) return false; - const QString normalized = QDir::fromNativeSeparators(path); - if (changedKeys.contains(normalized.toCaseFolded())) return false; - m_obsoletePaths.append(normalized); - } - return writeState("verified"); -} - -bool UpdateTransaction::backupCurrentFiles() -{ - if (!writeState("waiting_mainapp_exit")) return false; - QStringList paths = m_changedPaths; - paths.append(m_obsoletePaths); - for (const QString& path : paths) { - const QString installed = QDir(m_installDir).filePath(path); - if (!QFile::exists(installed)) continue; - const QString backup = QDir(m_backupDir).filePath(path); - if (!copyOverwrite(installed, backup)) - return markFailed("backup_failed", path); - } - return writeState("backed_up"); -} - -bool UpdateTransaction::installStagedFiles(QString* failedPath) -{ - if (!writeState("replacing")) return false; - for (const QString& path : m_changedPaths) { - const QString source = QDir(m_stagingDir).filePath(path); - const QString destination = QDir(m_installDir).filePath(path); - if (!copyOverwrite(source, destination)) { - if (failedPath) *failedPath = path; - writeState("rollback_required", "replace_failed", path); - return false; - } - } - return writeState("replaced"); -} - -bool UpdateTransaction::markAwaitingBootstrap() { return writeState("awaiting_bootstrap"); } - -bool UpdateTransaction::markRollbackRequired(const QString& reason) -{ return writeState("rollback_required", "post_install_failed", reason); } - -bool UpdateTransaction::markRolledBack() { return writeState("rolled_back"); } - -bool UpdateTransaction::markPostVerify() { return writeState("post_verify"); } - -bool UpdateTransaction::rollback(QString* failedPath) -{ - writeState("rolling_back"); - bool ok = true; - QStringList paths = m_changedPaths; - paths.append(m_obsoletePaths); - for (const QString& path : paths) { - const QString installed = QDir(m_installDir).filePath(path); - const QString backup = QDir(m_backupDir).filePath(path); - if (QFile::exists(backup)) { - if (!copyOverwrite(backup, installed)) { ok = false; if (failedPath) *failedPath = path; } - } else if (QFile::exists(installed) && !QFile::remove(installed)) { - ok = false; if (failedPath) *failedPath = path; - } - } - writeState(ok ? "rolled_back" : "failed", ok ? QString() : "rollback_failed", - failedPath ? *failedPath : QString()); - return ok; -} - -bool UpdateTransaction::commit() -{ - if (!writeState("committed")) return false; - QDir(m_stagingDir).removeRecursively(); - QDir(m_backupDir).removeRecursively(); - return true; -} - -bool UpdateTransaction::markFailed(const QString& errorCode, const QString& message) -{ return writeState("failed", errorCode, message); } - -QString UpdateTransaction::transactionId() const { return m_transactionId; } -QString UpdateTransaction::fromVersion() const { return m_fromVersion; } -QString UpdateTransaction::stagingDir() const { return m_stagingDir; } -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, const QString& updateDir, - QString* restoredVersion, QString* errorMessage) -{ - 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; } - const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - file.close(); - if (!doc.isObject()) { if (errorMessage) *errorMessage = "invalid upgrade_state.json"; return false; } - QJsonObject state = doc.object(); - const QString status = state.value("status").toString(); - if (status == "committed") return true; - if (status == "rolled_back") { - if (restoredVersion) *restoredVersion = state.value("from_version").toString(); - return true; - } - const QString backupDir = state.value("backup_dir").toString(); - QJsonArray paths = state.value("changed_paths").toArray(); - for (const QJsonValue& value : state.value("obsolete_paths").toArray()) paths.append(value); - const QString errorCode = state.value("error_code").toString(); - const bool mayContainNewFiles = status == "replacing" || status == "replaced" - || status == "post_verify" || status == "rollback_required" - || status == "awaiting_bootstrap" || status == "rolling_back" || errorCode == "rollback_failed"; - bool ok = true; - for (const QJsonValue& value : paths) { - const QString path = value.toString(); - if (!safeRelativePath(path)) { ok = false; continue; } - const QString installed = QDir(installDir).filePath(path); - const QString backup = QDir(backupDir).filePath(path); - if (QFile::exists(backup)) { - QDir().mkpath(QFileInfo(installed).path()); - if (QFile::exists(installed)) QFile::remove(installed); - if (!QFile::copy(backup, installed)) ok = false; - } else if (mayContainNewFiles) { - if (QFile::exists(installed) && !QFile::remove(installed)) ok = false; - } - } - if (restoredVersion) *restoredVersion = state.value("from_version").toString(); - state["status"] = ok ? "rolled_back" : "failed"; - QSaveFile output(stateFile); - if (output.open(QIODevice::WriteOnly)) { output.write(QJsonDocument(state).toJson(QJsonDocument::Indented)); output.commit(); } - if (!ok && errorMessage) *errorMessage = "failed to restore one or more files"; - return ok; -} diff --git a/client/Updater/UpdateTransaction.h b/client/Updater/UpdateTransaction.h deleted file mode 100644 index 9b068f1..0000000 --- a/client/Updater/UpdateTransaction.h +++ /dev/null @@ -1,54 +0,0 @@ -#pragma once -#include -#include -#include - -class UpdateTransaction -{ -public: - UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion, - const QString& toVersion, int manifestId); - - bool initialize(); - bool resumeExisting(QString* errorMessage = nullptr); - bool recordVerifiedFiles(const QStringList& changedPaths, const QStringList& obsoletePaths); - bool backupCurrentFiles(); - bool installStagedFiles(QString* failedPath = nullptr); - bool markAwaitingBootstrap(); - bool markRollbackRequired(const QString& reason); - bool markRolledBack(); - bool markPostVerify(); - bool rollback(QString* failedPath = nullptr); - bool commit(); - bool markFailed(const QString& errorCode, const QString& message); - - QString transactionId() const; - QString fromVersion() const; - QString stagingDir() const; - QString backupDir() const; - QStringList obsoletePaths() const; - QString healthFile() const; - - static bool recoverInterrupted(const QString& installDir, const QString& updateDir, - QString* restoredVersion = nullptr, - QString* errorMessage = nullptr); - -private: - bool writeState(const QString& status, const QString& errorCode = QString(), - const QString& message = QString()); - bool copyOverwrite(const QString& source, const QString& destination) const; - static bool safeRelativePath(const QString& path); - - QString m_installDir; - QString m_updateDir; - QString m_stateFile; - QString m_transactionId; - QString m_fromVersion; - QString m_toVersion; - int m_manifestId = 0; - QString m_stagingDir; - QString m_backupDir; - QStringList m_changedPaths; - QStringList m_obsoletePaths; - QJsonObject m_state; -}; diff --git a/client/Updater/UpdaterLogic.cpp b/client/Updater/UpdaterLogic.cpp deleted file mode 100644 index 98d6555..0000000 --- a/client/Updater/UpdaterLogic.cpp +++ /dev/null @@ -1,751 +0,0 @@ -#include "UpdaterLogic.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "ConfigHelper.h" - -#ifdef HAVE_OPENSSL -#include -#include -#include -#include -#endif - -UpdaterLogic::UpdaterLogic(QObject* parent) - : QObject(parent) -{ - m_serverAddr = ConfigHelper::instance().getValue("Server", "api_base_url"); -} - -void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId) -{ - QString url = m_serverAddr + "/api/v1/update/manifest"; - QJsonObject body; - body["app_id"] = appId; - body["channel"] = channel; - body["version"] = targetVer; - body["version_id"] = versionId; - - m_http.postRequest(url, body, [this](int code, const QJsonObject& resp) - { - qDebug() << "Manifest API returned code:" << code; - m_manifest = QJsonObject(); - m_manifestText.clear(); - - if (code == 200) - { - if (resp.contains("manifest_text") && resp.contains("manifest")) - { - m_manifestText = resp["manifest_text"].toString(); - m_manifest = resp["manifest"].toObject(); - qDebug() << "Received manifest version:" << m_manifest.value("version").toString(); - m_fileItems.clear(); - QJsonArray files = m_manifest.value("files").toArray(); - for (const QJsonValue& fileItem : files) - { - QJsonObject fileObj = fileItem.toObject(); - FileDownloadItem fi; - fi.path = fileObj.value("path").toString(); - fi.sha256 = fileObj.value("sha256").toString(); - fi.size = fileObj.value("size").toVariant().toLongLong(); - fi.url = m_serverAddr + "/api/v1/update/file/" + fi.path; // placeholder, actual download URL uses download-url or signed object URL - m_fileItems.append(fi); - } - } - else - { - qDebug() << "Manifest response missing fields"; - } - } - else - { - qDebug() << "Failed to get manifest"; - } - emit fetchUrlFinished(); - }); -} - -bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const -{ -#ifndef HAVE_OPENSSL - Q_UNUSED(payload); - Q_UNUSED(signatureBase64); - Q_UNUSED(publicKeyPath); - qDebug() << "OpenSSL not available, cannot verify manifest signature"; - return false; -#else - QFile keyFile(publicKeyPath); - if (!keyFile.open(QIODevice::ReadOnly)) - { - qDebug() << "Cannot open public key file:" << publicKeyPath; - return false; - } - - QByteArray keyData = keyFile.readAll(); - keyFile.close(); - - BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); - if (!bio) - { - qDebug() << "BIO_new_mem_buf failed"; - return false; - } - - EVP_PKEY* pkey = PEM_read_bio_PUBKEY(bio, NULL, NULL, NULL); - BIO_free(bio); - if (!pkey) - { - qDebug() << "PEM_read_bio_PUBKEY failed"; - return false; - } - - QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); - EVP_MD_CTX* mdctx = EVP_MD_CTX_new(); - if (!mdctx) - { - EVP_PKEY_free(pkey); - qDebug() << "EVP_MD_CTX_new failed"; - return false; - } - - bool ok = false; - if (EVP_DigestVerifyInit(mdctx, NULL, EVP_sha256(), NULL, pkey) == 1) - { - if (EVP_DigestVerifyUpdate(mdctx, payload.constData(), payload.size()) == 1) - { - if (EVP_DigestVerifyFinal(mdctx, reinterpret_cast(signature.constData()), signature.size()) == 1) - { - ok = true; - } - } - } - - EVP_MD_CTX_free(mdctx); - EVP_PKEY_free(pkey); - - if (!ok) - { - qDebug() << "Manifest signature verification failed"; - } - return ok; -#endif -} - -bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const -{ - if (m_manifest.isEmpty() || m_manifestText.isEmpty()) - { - qDebug() << "No manifest available to verify"; - return false; - } - QString signature = m_manifest.value("signature").toString(); - if (signature.isEmpty()) - { - qDebug() << "Manifest signature empty"; - return false; - } - - QString path = publicKeyPath; - if (!QFile::exists(path)) - { - path = QApplication::applicationDirPath() + "/" + publicKeyPath; - } - return verifySignature(m_manifestText.toUtf8(), signature, path); -} - -bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const -{ - if (m_manifest.isEmpty()) - return false; - - QDir dir(cacheDir); - if (!dir.exists() && !dir.mkpath(".")) - return false; - - QString version = m_manifest.value("version").toString(); - QString filePath = cacheDir + "/manifest_" + version + ".json"; - QFile file(filePath); - if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) - return false; - - QJsonObject wrapper; - wrapper["manifest"] = m_manifest; - wrapper["manifest_text"] = m_manifestText; - - QJsonDocument doc(wrapper); - file.write(doc.toJson(QJsonDocument::Indented)); - file.close(); - qDebug() << "Manifest cached to" << filePath; - return true; -} - -bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& version) -{ - QString filePath = cacheDir + "/manifest_" + version + ".json"; - QFile file(filePath); - if (!file.exists() || !file.open(QIODevice::ReadOnly)) - return false; - - QByteArray raw = file.readAll(); - file.close(); - QJsonDocument doc = QJsonDocument::fromJson(raw); - if (!doc.isObject()) - return false; - - QJsonObject wrapper = doc.object(); - if (!wrapper.contains("manifest") || !wrapper.contains("manifest_text")) - return false; - - m_manifest = wrapper["manifest"].toObject(); - m_manifestText = wrapper["manifest_text"].toString(); - qDebug() << "Loaded cached manifest" << version; - return true; -} - -QByteArray UpdaterLogic::canonicalManifestBytes(const QJsonObject& manifest) const -{ - QJsonObject copy = manifest; - copy.remove("signature"); - - auto sortObject = [&](auto&& self, const QJsonObject& obj) -> QJsonObject { - QStringList keys = obj.keys(); - std::sort(keys.begin(), keys.end(), std::less()); - QJsonObject sorted; - for (const auto& key : keys) - { - QJsonValue value = obj.value(key); - if (value.isObject()) - sorted.insert(key, self(self, value.toObject())); - else if (value.isArray()) - { - QJsonArray sortedArray; - for (const auto& element : value.toArray()) - { - if (element.isObject()) - sortedArray.append(self(self, element.toObject())); - else - sortedArray.append(element); - } - sorted.insert(key, sortedArray); - } - else - { - sorted.insert(key, value); - } - } - return sorted; - }; - - QJsonObject sorted = sortObject(sortObject, copy); - QJsonDocument doc(sorted); - return doc.toJson(QJsonDocument::Compact); -} - - -QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest) const -{ - QSet newPaths; - for (const QJsonValue& value : m_manifest.value("files").toArray()) { - const QString path = QDir::fromNativeSeparators(value.toObject().value("path").toString()); - if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded()); - } - - QSet protectedPaths{ - 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") - }; - 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()) { - const QString path = QDir::fromNativeSeparators(value.toObject().value("path").toString()); - const QString folded = path.toCaseFolded(); - if (!isSafeRelativePath(path) || protectedPaths.contains(folded) - || newPaths.contains(folded) || seen.contains(folded)) - continue; - seen.insert(folded); - obsolete.append(path); - } - obsolete.sort(Qt::CaseInsensitive); - return obsolete; -} - -bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString& installedDir) const -{ - if (m_manifest.isEmpty()) - return false; - - const QJsonArray files = m_manifest.value("files").toArray(); - for (const auto& item : files) - { - const QJsonObject fileObject = item.toObject(); - const QString path = QDir::fromNativeSeparators(fileObject.value("path").toString()); - const QString expectedSha = fileObject.value("sha256").toString(); - if (!isSafeRelativePath(path)) - return false; - if (isRuntimeProtectedPath(path)) { - qDebug() << "Runtime-protected manifest entry ignored:" << path; - continue; - } - - QString fullPath = QDir(stagingDir).filePath(path); - if (!QFile::exists(fullPath) && !installedDir.isEmpty()) - fullPath = QDir(installedDir).filePath(path); - - if (!QFile::exists(fullPath)) - { - qDebug() << "Manifest file missing from staging and installation:" << path; - return false; - } - const QString actualSha = calcLocalFileSha256(fullPath); - if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0) - { - qDebug() << "File hash mismatch:" << path << actualSha << expectedSha; - return false; - } - } - qDebug() << "Full manifest validation passed using staging plus installed files"; - return true; -} - -bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString& stagingDir) -{ - m_offlineError.clear(); - QFile package(packagePath); - if (!package.open(QIODevice::ReadOnly)) { m_offlineError = "无法打开离线更新包"; return false; } - if (package.read(8) != QByteArray("MUPD0001", 8)) { m_offlineError = "离线包格式标识无效"; return false; } - const QByteArray lengthBytes = package.read(8); - if (lengthBytes.size() != 8) { m_offlineError = "离线包头不完整"; return false; } - quint64 headerSize = 0; - for (int i = 0; i < 8; ++i) headerSize |= quint64(static_cast(lengthBytes[i])) << (i * 8); - if (headerSize == 0 || headerSize > 64ULL * 1024 * 1024 || headerSize > quint64(package.size() - 16)) { m_offlineError = "离线包头长度无效"; return false; } - QJsonParseError error; - const QJsonDocument wrapperDoc = QJsonDocument::fromJson(package.read(qint64(headerSize)), &error); - if (error.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { m_offlineError = "离线包头 JSON 无效"; return false; } - const QJsonObject wrapper = wrapperDoc.object(); - const QByteArray packageText = wrapper.value("package_text").toString().toUtf8(); - const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8(); - const QString publicKey = QApplication::applicationDirPath() + "/config/manifest_public_key.pem"; - if (!verifySignature(packageText, wrapper.value("package_signature").toString(), publicKey)) { m_offlineError = "离线包 RSA 签名无效"; return false; } - const QJsonObject packageMeta = QJsonDocument::fromJson(packageText, &error).object(); - if (error.error != QJsonParseError::NoError || packageMeta.value("format").toString() != "MUPD0001") { m_offlineError = "离线包签名元数据无效"; return false; } - if (QString::fromLatin1(QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex()) != packageMeta.value("manifest_sha256").toString()) { m_offlineError = "Manifest 摘要与包签名不一致"; return false; } - QJsonObject manifest = QJsonDocument::fromJson(manifestText, &error).object(); - if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = "离线 Manifest 无效"; return false; } - manifest.insert("signature", wrapper.value("manifest_signature").toString()); - m_manifest = manifest; m_manifestText = QString::fromUtf8(manifestText); m_fileItems.clear(); - if (manifest.value("app_id") != packageMeta.value("app_id") || manifest.value("channel") != packageMeta.value("channel") || manifest.value("version") != packageMeta.value("version")) { m_offlineError = "包信息与 Manifest 身份不一致"; return false; } - if (!verifyManifestSignature()) { m_offlineError = "离线 Manifest RSA 签名无效"; return false; } - const qint64 payloadStart = 16 + qint64(headerSize); - const QJsonArray entries = packageMeta.value("files").toArray(); - for (const QJsonValue& value : entries) { - const QJsonObject item = value.toObject(); const QString path = QDir::fromNativeSeparators(item.value("path").toString()); - const qint64 offset = item.value("offset").toVariant().toLongLong(); const qint64 size = item.value("size").toVariant().toLongLong(); - if (!isSafeRelativePath(path) || offset < 0 || size < 0 || payloadStart + offset + size > package.size()) { m_offlineError = "离线包包含不安全路径或越界数据: " + path; return false; } - FileDownloadItem fi{path, QString(), item.value("sha256").toString(), size}; m_fileItems.append(fi); - if (stagingDir.isEmpty()) continue; - const QString target = QDir(stagingDir).filePath(path); if (!QDir().mkpath(QFileInfo(target).path()) || !package.seek(payloadStart + offset)) { m_offlineError = "无法准备离线文件: " + path; return false; } - QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = "无法创建暂存文件: " + path; return false; } - QCryptographicHash hash(QCryptographicHash::Sha256); qint64 remaining = size; - while (remaining > 0) { const QByteArray block = package.read(qMin(remaining, 1024 * 1024)); if (block.isEmpty() || output.write(block) != block.size()) { output.cancelWriting(); m_offlineError = "离线文件读取失败: " + path; return false; } hash.addData(block); remaining -= block.size(); } - if (QString::fromLatin1(hash.result().toHex()).compare(fi.sha256, Qt::CaseInsensitive) != 0 || !output.commit()) { output.cancelWriting(); m_offlineError = "离线文件 Hash 或写入失败: " + path; return false; } - } - if (entries.size() != m_manifest.value("files").toArray().size()) { m_offlineError = "离线包文件数量与 Manifest 不一致"; return false; } - return true; -} - -void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId) -{ - QString url = m_serverAddr + "/api/v1/update/download-url"; - QJsonObject body; - body["app_id"] = appId; - body["channel"] = channel; - body["version"] = targetVer; - body["version_id"] = versionId; - - QJsonArray emptyFiles; - body["files"] = emptyFiles; - - m_http.postRequest(url, body, [this](int code, const QJsonObject& resp) - { - qDebug() << "Download URL API returned code:" << code; - m_fileItems.clear(); - - if (code == 200) - { - QJsonArray fileArr = resp["files"].toArray(); - for (auto item : fileArr) - { - QJsonObject obj = item.toObject(); - FileDownloadItem fi; - fi.path = obj["path"].toString(); - fi.url = obj["url"].toString(); - fi.sha256 = obj["sha256"].toString(); - fi.size = obj["size"].toVariant().toLongLong(); - m_fileItems.append(fi); - qDebug() << "File info:" << fi.path << fi.url << fi.sha256; - } - } - else - { - qDebug() << "Failed to get download URL"; - } - emit fetchUrlFinished(); - }); -} - - -QString UpdaterLogic::calcLocalFileSha256(const QString& filePath) const -{ - QFile f(filePath); - if (!f.open(QIODevice::ReadOnly)) - return ""; - QCryptographicHash hash(QCryptographicHash::Sha256); - while (!f.atEnd()) - { - QByteArray block = f.read(4096); - hash.addData(block); - } - f.close(); - return hash.result().toHex(); -} - -bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePath, - const QString& expectSha256, qint64 expectedSize, - const QString& resumePartPath) -{ - const QString partPath = resumePartPath.isEmpty() ? savePath + ".part" : resumePartPath; - if (!QDir().mkpath(QFileInfo(partPath).path())) return false; - if (QFile::exists(savePath) - && calcLocalFileSha256(savePath).compare(expectSha256, Qt::CaseInsensitive) == 0) - return true; - QFile::remove(savePath); - - for (int attempt = 0; attempt < 4; ++attempt) - { - qint64 existingSize = QFileInfo(partPath).size(); - if (expectedSize >= 0 && existingSize > expectedSize) - { - QFile::remove(partPath); - existingSize = 0; - } - if (expectedSize >= 0 && existingSize == expectedSize && existingSize > 0) - { - if (calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0) - { - QFile::remove(savePath); - return QFile::rename(partPath, savePath); - } - QFile::remove(partPath); - existingSize = 0; - } - - QFile partFile(partPath); - const QIODevice::OpenMode mode = QIODevice::WriteOnly - | (existingSize > 0 ? QIODevice::Append : QIODevice::Truncate); - if (!partFile.open(mode)) - { - qDebug() << "Cannot open partial download:" << partPath; - return false; - } - - QNetworkAccessManager manager; - manager.setProxy(QNetworkProxy::NoProxy); - QNetworkRequest request(url); - request.setTransferTimeout(60000); - if (existingSize > 0) - request.setRawHeader("Range", QByteArray("bytes=") + QByteArray::number(existingSize) + "-"); - - QNetworkReply* reply = manager.get(request); - QEventLoop loop; - bool writeOk = true; - QObject::connect(reply, &QNetworkReply::readyRead, [&]() { - const QByteArray data = reply->readAll(); - if (!data.isEmpty()) { - if (partFile.write(data) != data.size()) writeOk = false; - m_sessionDownloadedBytes += data.size(); - const qint64 received = qMin(m_downloadTotalBytes, - m_downloadCompletedBytes + partFile.size()); - const double speed = m_downloadTimer.elapsed() > 0 - ? (m_sessionDownloadedBytes * 1000.0 / m_downloadTimer.elapsed()) : 0.0; - emit downloadProgress(received, m_downloadTotalBytes, m_currentDownloadPath, speed); - } - }); - QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - loop.exec(); - const QByteArray remaining = reply->readAll(); - if (!remaining.isEmpty() && partFile.write(remaining) != remaining.size()) writeOk = false; - partFile.flush(); - partFile.close(); - - const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - const bool networkOk = reply->error() == QNetworkReply::NoError; - const QString networkError = reply->errorString(); - reply->deleteLater(); - - if (existingSize > 0 && httpStatus == 200) - { - // 服务端忽略 Range,当前文件是“旧片段 + 完整响应”,必须安全重下。 - qDebug() << "Server ignored Range; restart full download:" << savePath; - QFile::remove(partPath); - } - else if (networkOk && writeOk && (httpStatus == 200 || httpStatus == 206)) - { - const qint64 actualSize = QFileInfo(partPath).size(); - if ((expectedSize < 0 || actualSize == expectedSize) - && calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0) - { - QFile::remove(savePath); - if (QFile::rename(partPath, savePath)) - { - qDebug() << "Download completed/resumed & sha pass:" << savePath; - return true; - } - } - else if (expectedSize >= 0 && actualSize >= expectedSize) - { - qDebug() << "Completed partial file has invalid size or SHA; restart:" << savePath; - QFile::remove(partPath); - } - } - else - { - qDebug() << "Download attempt failed; partial file retained:" << attempt + 1 - << savePath << httpStatus << networkError << "bytes" << QFileInfo(partPath).size(); - } - - if (attempt < 3) - { - const unsigned long delayMs = static_cast(500 * (1 << attempt)); - QElapsedTimer delay; - delay.start(); - while (delay.elapsed() < static_cast(delayMs)) - { - QApplication::processEvents(); - QThread::msleep(50); - } - } - } - return false; -} - -bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const -{ - const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded(); - QSet protectedPaths{ - 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") - }; - 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); -} - -bool UpdaterLogic::isSafeRelativePath(const QString& path) const -{ - const QString normalized = QDir::fromNativeSeparators(path); - const QString clean = QDir::cleanPath(normalized); - return !clean.isEmpty() - && !QDir::isAbsolutePath(clean) - && clean != ".." - && !clean.startsWith("../") - && !clean.contains(":"); -} - -qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir, - const QStringList& obsoletePaths) const -{ - qint64 required = 0; - 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; - const QString installed = QDir(targetDir).filePath(relativePath); - if (QFile::exists(installed) - && calcLocalFileSha256(installed).compare(item.sha256, Qt::CaseInsensitive) == 0) - continue; - - const qint64 partialSize = QFileInfo(QDir(cacheDir).filePath( - item.sha256.toLower() + ".part")).size(); - required += qMax(0, item.size - qMin(item.size, partialSize)); - if (QFile::exists(installed)) required += QFileInfo(installed).size(); - } - for (const QString& path : obsoletePaths) { - const QString installed = QDir(targetDir).filePath(path); - if (QFile::exists(installed)) required += QFileInfo(installed).size(); - } - const qint64 reserve = qMax(128LL * 1024 * 1024, required / 10); - return required + reserve; -} - -bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targetDir) -{ - if (m_fileItems.isEmpty()) - { - m_downloadAllOk = false; - return false; - } - - QDir stagingDir(tempDir); - if (!FileHelper::createDir(tempDir)) - return false; - const QString resumeCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache"); - if (!FileHelper::createDir(resumeCacheDir)) - return false; - QSet activePartialNames; - for (const auto& item : m_fileItems) - activePartialNames.insert(item.sha256.toLower() + ".part"); - QDir cacheDir(resumeCacheDir); - for (const QString& fileName : cacheDir.entryList(QStringList() << "*.part", QDir::Files)) { - if (!activePartialNames.contains(fileName.toLower())) - cacheDir.remove(fileName); - } - - m_downloadTotalBytes = 0; - m_downloadCompletedBytes = 0; - m_sessionDownloadedBytes = 0; - for (const auto& item : m_fileItems) { - const QString itemPath = QDir::fromNativeSeparators(item.path); - if (isRuntimeProtectedPath(itemPath)) continue; - const QString installed = QDir(targetDir).filePath(itemPath); - if (!QFile::exists(installed) - || calcLocalFileSha256(installed).compare(item.sha256, Qt::CaseInsensitive) != 0) - m_downloadTotalBytes += item.size; - } - m_downloadTimer.restart(); - emit downloadProgress(0, m_downloadTotalBytes, QString(), 0.0); - - for (const auto& fi : m_fileItems) - { - if (!isSafeRelativePath(fi.path)) - { - qDebug() << "Unsafe relative path in manifest:" << fi.path; - m_downloadAllOk = false; - return false; - } - - const QString relativePath = QDir::fromNativeSeparators(fi.path); - if (isRuntimeProtectedPath(relativePath)) { - qDebug() << "Runtime-protected file skipped:" << relativePath; - continue; - } - const QString installedFile = QDir(targetDir).filePath(relativePath); - if (QFile::exists(installedFile) - && calcLocalFileSha256(installedFile).compare(fi.sha256, Qt::CaseInsensitive) == 0) - { - qDebug() << "Unchanged file, skip download:" << relativePath; - continue; - } - - const QString tempFile = QDir(tempDir).filePath(relativePath); - const QString parentDir = QFileInfo(tempFile).path(); - if (!QDir().mkpath(parentDir)) - { - qDebug() << "Cannot create staging subdirectory:" << parentDir; - m_downloadAllOk = false; - return false; - } - - const QString resumePartPath = QDir(resumeCacheDir).filePath(fi.sha256.toLower() + ".part"); - m_currentDownloadPath = relativePath; - const qint64 resumedBytes = qMin(fi.size, QFileInfo(resumePartPath).size()); - const double speed = m_downloadTimer.elapsed() > 0 - ? (m_sessionDownloadedBytes * 1000.0 / m_downloadTimer.elapsed()) : 0.0; - emit downloadProgress(m_downloadCompletedBytes + resumedBytes, - m_downloadTotalBytes, m_currentDownloadPath, speed); - if (!downloadSingleFile(fi.url, tempFile, fi.sha256, fi.size, resumePartPath)) - { - m_downloadAllOk = false; - return false; - } - m_downloadCompletedBytes += fi.size; - emit downloadProgress(m_downloadCompletedBytes, m_downloadTotalBytes, - m_currentDownloadPath, speed); - } - - m_downloadAllOk = true; - return true; -} - -void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel, - const QString& version, bool success) -{ - QJsonArray files; - for (const FileDownloadItem& item : m_fileItems) - files.append(QJsonObject{{"path", item.path}, {"size", item.size}}); - QJsonObject body{{"app_id", appId}, {"channel", channel}, {"version", version}, - {"result", success ? "success" : "fail"}, {"files", files}}; - m_http.postRequest(m_serverAddr + "/api/v1/update/download-report", body, - [](int code, const QJsonObject&) { qDebug() << "Download result report returned code:" << code; }); -} - -void UpdaterLogic::reportResult(const QString& deviceId, - const QString& fromVer, - const QString& toVer, - bool success) -{ - QString url = m_serverAddr + "/api/v1/update/report"; - - QJsonObject body; - body["app_id"] = ConfigHelper::instance().getValue("App", "app_id"); - body["device_id"] = deviceId; - body["from_version"] = fromVer; - body["to_version"] = toVer; - - if (success) - body["result"] = "success"; - else - body["result"] = "fail"; - - m_http.postRequest(url, body, [](int code, const QJsonObject& resp) - { - Q_UNUSED(resp); - qDebug() << "Update result report returned code:" << code; - }); -} - -QList UpdaterLogic::getFileList() const -{ - return m_fileItems; -} diff --git a/client/Updater/UpdaterLogic.h b/client/Updater/UpdaterLogic.h deleted file mode 100644 index 2f6c525..0000000 --- a/client/Updater/UpdaterLogic.h +++ /dev/null @@ -1,73 +0,0 @@ -#pragma once -#include -#include "HttpHelper.h" -#include "FileHelper.h" -#include -#include -#include -#include -#include -#include -#include - -struct FileDownloadItem -{ - QString path; - QString url; - QString sha256; - qint64 size; -}; - -class UpdaterLogic : public QObject -{ - Q_OBJECT -public: - explicit UpdaterLogic(QObject* parent = nullptr); - - void getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId); - bool verifyManifestSignature(const QString& publicKeyPath = "config/manifest_public_key.pem") const; - bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const; - bool saveManifestCache(const QString& cacheDir) const; - bool loadManifestCache(const QString& cacheDir, const QString& version); - bool loadOfflinePackage(const QString& packagePath, const QString& stagingDir = QString()); - QString offlineError() const { return m_offlineError; } - - void getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId); - void reportResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success); - void reportDownloadResult(const QString& appId, const QString& channel, const QString& version, bool success); - bool downloadAllFiles(const QString& tempDir, const QString& targetDir); - qint64 estimateAdditionalDiskBytes(const QString& targetDir, const QStringList& obsoletePaths) const; - QString calcLocalFileSha256(const QString& filePath) const; - - QList getFileList() const; - QJsonObject getManifest() const { return m_manifest; } - QStringList obsoleteFilesComparedTo(const QJsonObject& oldManifest) const; - QString getManifestText() const { return m_manifestText; } - bool allDownloadSuccess() const { return m_downloadAllOk; } - -signals: - void fetchUrlFinished(); - void downloadProgress(qint64 receivedBytes, qint64 totalBytes, - const QString& currentPath, double bytesPerSecond); - -private: - bool downloadSingleFile(const QString& url, const QString& savePath, const QString& expectSha256, - qint64 expectedSize, const QString& resumePartPath); - bool isSafeRelativePath(const QString& path) const; - bool isRuntimeProtectedPath(const QString& path) const; - QByteArray canonicalManifestBytes(const QJsonObject& manifest) const; - bool verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const; - - HttpHelper m_http; - QString m_serverAddr; - QJsonObject m_manifest; - QString m_manifestText; - QList m_fileItems; - bool m_downloadAllOk = false; - qint64 m_downloadTotalBytes = 0; - qint64 m_downloadCompletedBytes = 0; - qint64 m_sessionDownloadedBytes = 0; - QString m_currentDownloadPath; - QString m_offlineError; - QElapsedTimer m_downloadTimer; -}; \ No newline at end of file diff --git a/client/Updater/main.cpp b/client/Updater/main.cpp deleted file mode 100644 index fdb74b1..0000000 --- a/client/Updater/main.cpp +++ /dev/null @@ -1,410 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "UpdaterLogic.h" -#include "UpdateTransaction.h" -#include "FileHelper.h" -#include "ConfigHelper.h" -#include "TicketHelper.h" -#include "PolicyHelper.h" -#include - -int main(int argc, char* argv[]) -{ - SetConsoleOutputCP(65001); - QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8")); - QApplication app(argc, argv); - QApplication::setApplicationName("Marsco Updater"); - - UpdaterLogic logic; - QString offlinePackagePath; - QString appId; - QString channel; - QString targetVersion; - int targetVersionId = 0; - if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) { - offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size()); - if (!logic.loadOfflinePackage(offlinePackagePath)) { - QMessageBox::critical(nullptr, "离线包无效", logic.offlineError()); - return -1; - } - const QJsonObject packageManifest = logic.getManifest(); - appId = packageManifest.value("app_id").toString(); - channel = packageManifest.value("channel").toString(); - targetVersion = packageManifest.value("version").toString(); - targetVersionId = packageManifest.value("manifest_seq").toInt(); - } else { - if (argc < 5) { - QMessageBox::critical(nullptr, "更新器参数错误", "更新器缺少在线更新参数或离线更新包。"); - return -1; - } - appId = argv[1]; channel = argv[2]; targetVersion = argv[3]; targetVersionId = QString(argv[4]).toInt(); - } - QString bootstrapResult; - for (int i = 5; i < argc; ++i) { - const QString arg = argv[i]; - if (arg.startsWith("--bootstrap-resume=")) - bootstrapResult = arg.mid(QString("--bootstrap-resume=").size()); - } - const bool resumingFromBootstrap = !bootstrapResult.isEmpty(); - 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(runtimeDir); - if (!offlinePolicy.loadPolicy() || !offlinePolicy.isValid() || offlinePolicy.isExpired() - || !offlinePolicy.isOfflineAllowed() || !offlinePolicy.isVersionAllowed(targetVersion)) { - QMessageBox::critical(nullptr, "离线更新被拒绝", "本地签名策略已过期、禁止离线更新或不允许目标版本。"); - return -1; - } - if (QVersionNumber::compare(QVersionNumber::fromString(targetVersion), - QVersionNumber::fromString(fromVersion)) < 0 - && !offlinePolicy.allowRollback()) { - QMessageBox::critical(nullptr, "禁止降级", "当前签名策略不允许安装较低版本的离线包。"); - return -1; - } - } - QString deviceId = config.getValue("Update", "device_id"); - if (deviceId.isEmpty()) deviceId = "unknown_device"; - - if (!resumingFromBootstrap) { - QString restoredVersion; - QString recoveryError; - if (!UpdateTransaction::recoverInterrupted(targetDir, updateDir, &restoredVersion, &recoveryError)) { - QMessageBox::critical(nullptr, "更新恢复失败", - QString("检测到上次更新未完成,但无法恢复旧版本:%1\n请不要继续运行软件,并联系管理员。").arg(recoveryError)); - return -1; - } - if (!restoredVersion.isEmpty() && restoredVersion != fromVersion) { - if (!config.setValue("App", "current_version", restoredVersion)) { - QMessageBox::critical(nullptr, "更新恢复失败", "旧文件已经恢复,但无法恢复版本状态,请检查配置目录写入权限。"); - return -1; - } - fromVersion = restoredVersion; - } - } - - QProgressDialog progress("正在准备更新...", QString(), 0, 100); - progress.setWindowTitle(QString("正在更新到 %1").arg(targetVersion)); - progress.setCancelButton(nullptr); - progress.setWindowModality(Qt::ApplicationModal); - progress.setMinimumDuration(0); - progress.setAutoClose(false); - progress.setValue(2); - progress.show(); - QApplication::processEvents(); - - UpdateTransaction transaction(targetDir, updateDir, fromVersion, targetVersion, targetVersionId); - const auto setProgress = [&](int value, const QString& message) { - progress.setValue(value); - progress.setLabelText(message); - QApplication::processEvents(); - }; - const auto formatBytes = [](qint64 bytes) { - const double value = static_cast(bytes); - if (bytes >= 1024LL * 1024 * 1024) - return QString::number(value / (1024.0 * 1024 * 1024), 'f', 2) + " GB"; - if (bytes >= 1024LL * 1024) - return QString::number(value / (1024.0 * 1024), 'f', 2) + " MB"; - if (bytes >= 1024) - return QString::number(value / 1024.0, 'f', 1) + " KB"; - return QString::number(bytes) + " B"; - }; - QObject::connect(&logic, &UpdaterLogic::downloadProgress, - [&](qint64 received, qint64 total, const QString& path, double bytesPerSecond) { - const int value = total > 0 ? 30 + static_cast(30 * received / total) : 60; - const QString fileText = path.isEmpty() ? QString("正在准备下载...") - : QString("正在下载:%1").arg(path); - progress.setValue(value); - progress.setLabelText(QString("%1\n%2 / %3 · %4/s") - .arg(fileText, formatBytes(received), formatBytes(total), - formatBytes(static_cast(bytesPerSecond)))); - QApplication::processEvents(); - }); - const auto reportUpdateResult = [&](bool success) { - if (offlinePackagePath.isEmpty()) - logic.reportResult(deviceId, fromVersion, targetVersion, success); - }; - const auto fail = [&](const QString& title, const QString& message, - const QString& errorCode = QString("update_failed")) { - transaction.markFailed(errorCode, message); - reportUpdateResult(false); - progress.close(); - QMessageBox::critical(nullptr, title, message); - return -1; - }; - const auto configuredName = [&](const QString& key, const QString& fallback) { - const QString value = config.getValue("Runtime", key).trimmed(); - return value.isEmpty() ? fallback : value; - }; - const QString mainExecutable = configuredName("main_executable", "MainApp.exe"); - const QString updaterExecutable = configuredName("updater_executable", "Updater.exe"); - const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap.exe"); - bool timeoutOk = false; - int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk); - if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000; - 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; - QString ticketError; - const QString launchVersion = config.getValue("App", "current_version"); - if (!TicketHelper::createTicket(appId, deviceId, launchVersion, launchToken, - &ticketPath, &ticketError)) { - qDebug() << "Cannot create launch ticket:" << ticketError; - return false; - } - QStringList args{QString("--ticket-file=%1").arg(ticketPath)}; - if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile)); - const bool started = QProcess::startDetached(mainAppPath, args); - if (!started) QFile::remove(ticketPath); - return started; - }; - const auto bootstrapPlanFile = [&]() { - return QDir(updateDir).filePath("bootstrap_plan_" + transaction.transactionId() + ".txt"); - }; - const auto launchBootstrap = [&](const QString& mode) { - const QStringList args{ - bootstrapPlanFile(), targetDir, transaction.stagingDir(), transaction.backupDir(), updaterPath, - QString::number(QCoreApplication::applicationPid()), appId, channel, - targetVersion, QString::number(targetVersionId), mode - }; - return QFile::exists(bootstrapPath) && QProcess::startDetached(bootstrapPath, args); - }; - const auto delegateRollback = [&](const QString& title, const QString& reason) { - FileHelper::killProcess(mainExecutable); - transaction.markRollbackRequired(reason); - progress.setLabelText("正在将回滚工作移交给 Bootstrap..."); - QApplication::processEvents(); - if (launchBootstrap("rollback")) { - progress.close(); - return 0; - } - reportUpdateResult(false); - progress.close(); - QMessageBox::critical(nullptr, title, - reason + "\n\n无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。"); - return -1; - }; - - if (resumingFromBootstrap) { - QString resumeError; - if (!transaction.resumeExisting(&resumeError)) { - reportUpdateResult(false); - progress.close(); - QMessageBox::critical(nullptr, "事务续办失败", - QString("无法读取 Bootstrap 更新事务:%1").arg(resumeError)); - return -1; - } - fromVersion = transaction.fromVersion(); - if (QFile::exists(QDir(transaction.backupDir()).filePath(".offline_mode"))) - offlinePackagePath = "__bootstrap_resumed_offline__"; - if (bootstrapResult == "rolledback") { - const bool stateOk = config.setValue("App", "current_version", fromVersion); - transaction.markRolledBack(); - reportUpdateResult(false); - if (stateOk) launchMainApp(); - progress.close(); - QMessageBox::warning(nullptr, "更新已回滚", - stateOk ? "新版本安装或启动失败,已自动恢复并启动旧版本。" - : "旧文件已经恢复,但旧版本号写回失败,请检查配置目录权限。"); - return stateOk ? 0 : -1; - } - if (bootstrapResult != "success") { - reportUpdateResult(false); - progress.close(); - QMessageBox::critical(nullptr, "自动回滚失败", - "Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。"); - return -1; - } - } else if (!transaction.initialize()) { - return fail("更新准备失败", "无法创建更新事务目录或保存事务状态。", "transaction_init_failed"); - } else if (!offlinePackagePath.isEmpty()) { - QFile marker(QDir(transaction.backupDir()).filePath(".offline_mode")); - if (!marker.open(QIODevice::WriteOnly) || marker.write("offline\n") != 8) - return fail("更新准备失败", "无法保存离线事务标记。", "offline_marker_failed"); - } - - setProgress(resumingFromBootstrap ? 72 : 10, offlinePackagePath.isEmpty() ? "正在获取并验证版本清单..." : "正在验证离线更新包..."); - const QString manifestCacheDir = QDir(updateDir).filePath("manifest_cache"); - if (resumingFromBootstrap) { - if (!logic.loadManifestCache(manifestCacheDir, targetVersion)) - return delegateRollback("清单缓存失败", "Bootstrap 安装后无法读取签名 Manifest 缓存。"); - } else if (offlinePackagePath.isEmpty()) { - logic.getManifest(appId, channel, targetVersion, targetVersionId); - } - if (!logic.verifyManifestSignature()) { - if (resumingFromBootstrap) - return delegateRollback("安全验证失败", "Bootstrap 安装后无法重新验证版本清单签名。"); - return fail("安全验证失败", "版本清单签名无效,更新已停止。请联系管理员。", "manifest_signature_invalid"); - } - QStringList obsoletePaths; - if (!resumingFromBootstrap && fromVersion != targetVersion) { - UpdaterLogic oldManifestLogic; - if (oldManifestLogic.loadManifestCache(manifestCacheDir, fromVersion) - && oldManifestLogic.getManifest().value("version").toString() == fromVersion - && oldManifestLogic.verifyManifestSignature()) { - obsoletePaths = logic.obsoleteFilesComparedTo(oldManifestLogic.getManifest()); - qDebug() << "Obsolete files from signed previous manifest:" << obsoletePaths; - } else { - qDebug() << "No valid signed previous manifest cache; obsolete deletion skipped for safety"; - } - } - if (!logic.saveManifestCache(manifestCacheDir)) { - if (resumingFromBootstrap) - return delegateRollback("清单缓存失败", "无法保存新版本 Manifest 缓存。"); - return fail("清单缓存失败", "无法保存新版本 Manifest 缓存,更新已停止。", "manifest_cache_failed"); - } - - if (!resumingFromBootstrap) { - setProgress(25, offlinePackagePath.isEmpty() ? "正在获取安全下载地址..." : "正在准备离线包文件..."); - if (offlinePackagePath.isEmpty()) - logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId); - if (logic.getFileList().isEmpty()) - return fail("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "empty_file_list"); - - QStorageInfo storage(updateDir); - storage.refresh(); - const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths); - const qint64 availableBytes = storage.bytesAvailable(); - if (!storage.isValid() || !storage.isReady() || availableBytes < requiredBytes) { - return fail("磁盘空间不足", - QString("更新至少需要 %1 可用空间,安装盘当前仅剩 %2。\n" - "所需空间已包含下载文件、旧版本备份和安全余量。") - .arg(formatBytes(requiredBytes), formatBytes(qMax(0, availableBytes))), - "disk_space_insufficient"); - } - - const QString stagingDir = transaction.stagingDir(); - setProgress(30, QString(offlinePackagePath.isEmpty() ? "正在下载并校验 %1 个版本文件..." : "正在提取并校验 %1 个离线文件...").arg(logic.getFileList().size())); - if (!offlinePackagePath.isEmpty()) { - if (!logic.loadOfflinePackage(offlinePackagePath, stagingDir)) - return fail("离线包提取失败", logic.offlineError(), "offline_package_invalid"); - } else { - if (!logic.downloadAllFiles(stagingDir, targetDir)) { - logic.reportDownloadResult(appId, channel, targetVersion, false); - return fail("下载失败", "部分文件下载失败或 SHA-256 校验未通过,请检查网络后重试。", "download_failed"); - } - logic.reportDownloadResult(appId, channel, targetVersion, true); - } - - setProgress(58, "正在校验完整版本文件..."); - if (!logic.validateLocalFiles(stagingDir, targetDir)) - return fail("文件校验失败", "暂存文件与版本清单不一致,更新已停止。", "staging_verify_failed"); - - QStringList changedPaths; - QDir stagingRoot(stagingDir); - 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(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"); - - setProgress(66, "正在关闭主程序..."); - if (!FileHelper::killProcess(mainExecutable)) - return fail("无法关闭主程序", QString("%1 仍在运行,请手动关闭后重试。").arg(mainExecutable), "mainapp_close_failed"); - - setProgress(72, QString("正在备份 %1 个待变更文件(其中删除 %2 个)...") - .arg(changedPaths.size() + obsoletePaths.size()).arg(obsoletePaths.size())); - if (!transaction.backupCurrentFiles()) - return fail("备份失败", "无法备份当前版本文件,尚未安装新版本。请检查磁盘空间和目录权限。", "backup_failed"); - - const QString planFile = bootstrapPlanFile(); - QSaveFile plan(planFile); - if (!plan.open(QIODevice::WriteOnly)) - return fail("接管准备失败", "无法创建 Bootstrap 文件计划。", "bootstrap_plan_failed"); - const auto writePlanItem = [&](char operation, const QString& path) { - const QByteArray line = QByteArray(1, operation) + '\t' + path.toUtf8() + '\n'; - return plan.write(line) == line.size(); - }; - for (const QString& path : changedPaths) { - if (!writePlanItem('C', path)) { - plan.cancelWriting(); - return fail("接管准备失败", "无法写入 Bootstrap 复制计划。", "bootstrap_plan_failed"); - } - } - for (const QString& path : obsoletePaths) { - if (!writePlanItem('D', path)) { - plan.cancelWriting(); - return fail("接管准备失败", "无法写入 Bootstrap 删除计划。", "bootstrap_plan_failed"); - } - } - if (!plan.commit() || !transaction.markAwaitingBootstrap()) - return fail("接管准备失败", "无法提交 Bootstrap 文件计划或事务状态。", "bootstrap_plan_failed"); - - setProgress(78, "正在将安装工作移交给 Bootstrap..."); - if (!launchBootstrap("install")) - return fail("Bootstrap 启动失败", QString("无法启动独立更新接管程序:%1").arg(bootstrapPath), "bootstrap_start_failed"); - progress.close(); - return 0; - } - - setProgress(82, "正在校验 Bootstrap 安装结果..."); - if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir)) - return delegateRollback("安装校验失败", "新版本文件安装后校验未通过。"); - for (const QString& path : transaction.obsoletePaths()) { - if (QFile::exists(QDir(targetDir).filePath(path))) - return delegateRollback("废弃文件清理失败", QString("废弃文件仍然存在:%1").arg(path)); - } - - setProgress(89, "正在保存新版本状态..."); - if (!config.setValue("App", "current_version", targetVersion) - || config.getValue("App", "current_version") != targetVersion) - return delegateRollback("状态保存失败", "无法保存当前版本号。"); - - const QString healthFile = transaction.healthFile(); - QFile::remove(healthFile); - setProgress(94, "正在启动新版本并等待健康确认..."); - if (!launchMainApp(healthFile)) - return delegateRollback("启动失败", QString("%1 无法启动。").arg(mainExecutable)); - - QElapsedTimer healthTimer; - healthTimer.start(); - while (healthTimer.elapsed() < healthCheckTimeoutMs && !QFile::exists(healthFile)) { - QApplication::processEvents(); - QThread::msleep(100); - } - if (!QFile::exists(healthFile)) - return delegateRollback("启动确认失败", QString("新版本在 %1 毫秒内没有完成启动健康确认。").arg(healthCheckTimeoutMs)); - - setProgress(99, "正在提交更新事务..."); - if (!transaction.commit()) - return delegateRollback("事务提交失败", "新版本已经启动,但无法提交更新事务。"); - - QFile::remove(healthFile); - QFile::remove(bootstrapPlanFile()); - reportUpdateResult(true); - progress.setValue(100); - progress.close(); - QMessageBox::information(nullptr, "更新完成", QString("软件已成功更新到 %1,并通过启动健康检查。").arg(targetVersion)); - return 0; -} diff --git a/client/config/app_config.example.json b/client/config/app_config.example.json deleted file mode 100644 index e53aa3b..0000000 --- a/client/config/app_config.example.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "app_id": "simcae", - "app_name": "SimCAE", - "channel": "stable", - "current_version": "1.0.0", - "client_protocol": "3", - "launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes", - "license_key": "", - "api_base_url": "http://YOUR_SERVER_IP:8000", - "client_token": "SimCAEClientToken2026", - "request_timeout_ms": "5000", - "temp_folder": "update_temp", - "device_id": "", - "install_root": "..", - "main_executable": "SimCAE.exe", - "launcher_executable": "Launcher.exe", - "updater_executable": "Updater.exe", - "bootstrap_executable": "Bootstrap.exe", - "health_check_timeout_ms": "15000", - "platform": "windows", - "arch": "x64" -} diff --git a/client/config/manifest_public_key.pem b/client/config/manifest_public_key.pem deleted file mode 100644 index 781682f..0000000 --- a/client/config/manifest_public_key.pem +++ /dev/null @@ -1,9 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMROESbT36XU4d3YjuwU -WAC2h5p3btFu/IAeF3bVtHMovIA4ZXXKYsiq5FycDnDzyD86ou3B7PP7uHhVhn2l -Uru7QElYRfEfQAFSU5dErc+SZo+oT170cgq1ePPD/YleKPRqFAL221Tbh5pusHcZ -Ocujit2qrfg5f4rIEzWu7kBKVSJ6WChkjetEL6OZ43ClkDyUGebUuaQ9dv39YVlv -Fp2pfARi7/7djMMncLVaFU2AAIuSy3jgQg65DOFRVPSr1P/rRfuqU55ZmqAmmZIs -F/KVpne6zLFBXrxf5rNTBch+nxX+hcE7M+K0PJA5Ie669qRQFRwxoJlGpSOvMefQ -TwIDAQAB ------END PUBLIC KEY----- diff --git a/client/install-sdk.ps1 b/client/install-sdk.ps1 deleted file mode 100644 index 92eb3eb..0000000 --- a/client/install-sdk.ps1 +++ /dev/null @@ -1,74 +0,0 @@ -param( - [Parameter(Mandatory=$true)] - [string]$SdkRoot, - - [string]$ReleaseDir = (Get-Location).Path, - - [switch]$OverwriteConfig, - - [switch]$IncludeQtRuntime -) - -$ErrorActionPreference = "Stop" - -$sdk = (Resolve-Path $SdkRoot).Path -$release = (Resolve-Path $ReleaseDir).Path - -$binDir = Join-Path $sdk "bin" -$configDir = Join-Path $sdk "config" -$appConfig = Join-Path $configDir "app_config.json" -$publicKey = Join-Path $configDir "manifest_public_key.pem" - -foreach ($path in @($binDir, $appConfig, $publicKey)) { - if (-not (Test-Path $path)) { - throw "SDK file is missing: $path" - } -} - -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 - -$targetAppConfig = Join-Path $targetConfigDir "app_config.json" -if ((-not (Test-Path $targetAppConfig)) -or $OverwriteConfig) { - Copy-Item $appConfig $targetAppConfig -Force -} else { - Write-Host "Keep existing config/app_config.json. Use -OverwriteConfig to replace it." -} - -Copy-Item $publicKey (Join-Path $targetConfigDir "manifest_public_key.pem") -Force - -Write-Host "SDK files installed to: $release" -Write-Host "Next: edit config/app_config.json, then start Launcher.exe." diff --git a/client/package-client.ps1 b/client/package-client.ps1 deleted file mode 100644 index cc49841..0000000 --- a/client/package-client.ps1 +++ /dev/null @@ -1,138 +0,0 @@ -param( - [string]$SourceDir = "$PSScriptRoot/out/bin", - [Parameter(Mandatory = $true)] - [string]$ConfigFile, - [string]$OutputDir = "$PSScriptRoot/dist/UpdateClient", - [string]$ZipFile = "$PSScriptRoot/dist/UpdateClient.zip" -) - -$ErrorActionPreference = "Stop" - -$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", - "main_executable", "launcher_executable", "updater_executable", "bootstrap_executable" -) -foreach ($field in $requiredFields) { - if (-not $settings.$field) { - throw "Config file is missing required field: $field" - } -} - -$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 = @( - (Join-RelativePath $runtimeDirRelative $mainExecutable), - (Join-RelativePath $runtimeDirRelative $launcherExecutable), - (Join-RelativePath $runtimeDirRelative $updaterExecutable), - (Join-RelativePath $runtimeDirRelative $bootstrapExecutable) -) -foreach ($name in $requiredFiles) { - $nativeName = $name -replace '/', [IO.Path]::DirectorySeparatorChar - if (-not (Test-Path (Join-Path $source $nativeName))) { - throw "Source directory is missing required file: $name" - } -} - -$debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object { - $_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or - $_.Extension -in @('.pdb', '.ilk') -} -if ($debugArtifacts) { - throw "Source directory contains Debug artifacts. Clean out/bin and rebuild Release first. Example: $($debugArtifacts[0].FullName)" -} - -$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 ($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" -$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." -} - -if (Test-Path $OutputDir) { - Remove-Item $OutputDir -Recurse -Force -} -New-Item $OutputDir -ItemType Directory -Force | Out-Null - -Get-ChildItem $source -Force | Where-Object { - $_.Name -notin @("update", "update_temp") -} | Copy-Item -Destination $OutputDir -Recurse -Force - -$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 $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 ((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 - -$zipParent = Split-Path $ZipFile -Parent -New-Item $zipParent -ItemType Directory -Force | Out-Null -if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force } -Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal - -Write-Host "Package directory: $OutputDir" -Write-Host "ZIP file: $ZipFile" diff --git a/client/package-sdk.ps1 b/client/package-sdk.ps1 deleted file mode 100644 index 047d103..0000000 --- a/client/package-sdk.ps1 +++ /dev/null @@ -1,116 +0,0 @@ -param( - [string]$SourceDir = "$PSScriptRoot/out/bin", - [string]$OutputDir = "$PSScriptRoot/dist/UpdateClientSDK", - [string]$ZipFile = "$PSScriptRoot/dist/UpdateClientSDK.zip", - [string]$SdkVersion = "0.1.0", - [string]$ExampleConfig = "$PSScriptRoot/config/app_config.example.json", - [switch]$IncludeDemoMainApp, - [switch]$IncludeQtRuntime -) - -$ErrorActionPreference = "Stop" - -$source = (Resolve-Path $SourceDir).Path -$exampleConfigPath = (Resolve-Path $ExampleConfig).Path - -$requiredFiles = @("Launcher.exe", "Updater.exe", "Bootstrap.exe") -foreach ($name in $requiredFiles) { - $path = Join-Path $source $name - if (-not (Test-Path $path)) { - throw "SDK source directory is missing required file: $path" - } -} - -$publicKeyCandidates = @( - (Join-Path $source "config/manifest_public_key.pem"), - (Join-Path $source "manifest_public_key.pem"), - (Join-Path $PSScriptRoot "config/manifest_public_key.pem") -) -$publicKey = $publicKeyCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 -if (-not $publicKey) { - throw "manifest_public_key.pem is missing. Prepare the public key that matches the server signing private key." -} - -$debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object { - $_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or - $_.Extension -in @('.pdb', '.ilk') -} -if ($debugArtifacts) { - throw "SDK source directory contains Debug artifacts. Use a clean Release output directory. Example: $($debugArtifacts[0].FullName)" -} - -if (Test-Path $OutputDir) { Remove-Item $OutputDir -Recurse -Force } -New-Item $OutputDir -ItemType Directory -Force | Out-Null - -$binDir = Join-Path $OutputDir "bin" -$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", "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 -and -not (Test-IsQtRuntimeFile $_) -} | Copy-Item -Destination $binDir -Recurse -Force - -Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force -Copy-Item $publicKey (Join-Path $configDir "manifest_public_key.pem") -Force - -$wordGuideSource = Get-ChildItem $PSScriptRoot -File -Filter "*.docx" | Where-Object { - $_.Name -like "*SDK*.docx" -and $_.Name -notlike "~$*" -} | Sort-Object Name | Select-Object -First 1 -if (-not $wordGuideSource) { - throw "SDK integration Word guide is missing. Expected a *SDK*.docx file in the client directory." -} -Copy-Item $wordGuideSource.FullName (Join-Path $OutputDir $wordGuideSource.Name) -Force - -Copy-Item (Join-Path $PSScriptRoot "package-client.ps1") (Join-Path $scriptsDir "package-client.ps1") -Force -Copy-Item (Join-Path $PSScriptRoot "package-sdk.ps1") (Join-Path $scriptsDir "package-sdk.ps1") -Force -Copy-Item (Join-Path $PSScriptRoot "install-sdk.ps1") (Join-Path $scriptsDir "install-sdk.ps1") -Force - -@{ - sdk_version = $SdkVersion - generated_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") - sdk_type = "external-updater-runtime" - required_entry = "Launcher.exe" - contains_demo_main_app = [bool]$IncludeDemoMainApp -} | ConvertTo-Json -Depth 3 | Set-Content (Join-Path $OutputDir "sdk_manifest.json") -Encoding UTF8 - -$zipParent = Split-Path $ZipFile -Parent -New-Item $zipParent -ItemType Directory -Force | Out-Null -if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force } -Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal - -Write-Host "SDK directory: $OutputDir" -Write-Host "SDK zip: $ZipFile" -Write-Host "SDK version: $SdkVersion" diff --git a/client/pdf_requirements.txt b/client/pdf_requirements.txt deleted file mode 100644 index 05dcc33..0000000 --- a/client/pdf_requirements.txt +++ /dev/null @@ -1,2943 +0,0 @@ -MARSCO_内部 - -软件自动升级与版本控制系统开发设计文档 - -v0.1 - -角色 - -编写 - -校对 - -校正 - -姓名 - -待填写 - -待填写 - -待填写 - -日期 - -待填写 - -待填写 - -待填写 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 - - - - - - MARSCO_内部 - -修订记录 - -版本 - -日期 - -修订章节 - -修订内容 - -v0.1 - -2026-06- - -技术选型 - -明确客户端与服务端技术栈边界。 - -修订人 - -待填写 - -23 - -v0.1 - -2026-06- - -总体架构 - -梳理客户端、服务端和对象存储交互关 - -待填写 - -23 - -系。 - -v0.1 - -2026-06- - -客户端目录结 - -将目录树整理为正式表格。 - -待填写 - -23 - -构 - -v0.1 - -2026-06- - -本地配置文件 - -补充 app_config、client_identity、 - -待填写 - -23 - -设计 - -version_policy、local_state 字段说 - -明。 - -v0.1 - -2026-06- - -Manifest 设计 补充全量清单、签名和文件完整性规则。 待填写 - -23 - -v0.1 - -2026-06- - -启动票据设计 明确票据用途、校验规则和安全边界。 - -待填写 - -23 - -v0.1 - -2026-06- - -客户端启动逻 - -新增 Visio 流程图和步骤表。 - -待填写 - -23 - -辑 - -v0.1 - -2026-06- - -REST API 设计 明确设备身份接口非用户登录,补充管理 - -待填写 - -23 - -员登录仅用于后台。 - -v0.1 - -2026-06- - -数据库设计 - -补充应用、通道、版本、策略、设备、授 - -待填写 - -23 - -权和审计日志表。 - -v0.1 - -2026-06- - -对象存储目录 - -将对象存储目录整理为正式表格。 - -待填写 - -23 - -设计 - -v0.1 - -2026-06- - -Hash 校验策略 明确更新可增量、校验必须全量。 - -待填写 - -23 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -v0.1 - -2026-06- - -签名策略 - -补充签名算法、key_id 和稳定序列化要 - -待填写 - -23 - -求。 - -v0.1 - -2026-06- - -限流策略 - -将限流规则整理为表格。 - -待填写 - -23 - -v0.1 - -2026-06- - -回滚机制 - -补充事务状态机和失败恢复规则。 - -待填写 - -23 - -v0.1 - -2026-06- - -Demo 开发里程 - -按阶段整理开发计划。 - -待填写 - -23 - -碑 - -v0.1 - -2026-06- - -Demo 验收用例 补充升级、回滚、禁用、预览版、离线等 - -待填写 - -23 - -验收用例。 - -v0.1 - -2026-06- - -后续扩展方向 补充 License、灰度、多平台、日志上传 - -待填写 - -23 - -等扩展方向。 - -v0.1 - -2026-06- - -设计范围与登 - -明确本期默认可用且不做普通用户登录校 - -待填写 - -23 - -录边界 - -验;管理员登录仅用于后台。 - -v0.1 - -2026-06- - -本期范围 - -明确普通用户登录本期不做,默认可用; - -待填写 - -23 - -后续可扩展权限和授权校验。 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 - - MARSCO_内部 - -目录 - -1 设计范围与登录边界 ............................................................................................................. 6 - -2 技术选型 ............................................................................................................................. 6 - -2.1 客户端技术栈 ..................................................................................................................... 6 - -2.2 服务端技术栈 ..................................................................................................................... 7 - -2.3 文件存储 ............................................................................................................................ 8 - -3 总体架构 .............................................................................................................................. 8 - -4 客户端目录结构 .................................................................................................................... 9 - -5 本地配置文件设计 .............................................................................................................. 11 - -5.1 app_config.json ................................................................................................................ 11 - -5.2 client_identity.dat ............................................................................................................. 11 - -5.3 version_policy.dat ............................................................................................................ 11 - -5.4 local_state.json ................................................................................................................ 12 - -6 Manifest 设计 .................................................................................................................... 13 - -7 启动票据设计 ..................................................................................................................... 14 - -8 客户端启动逻辑 .................................................................................................................. 15 - -8.1 Launcher 启动逻辑 ........................................................................................................... 16 - -8.2 MainApp 启动逻辑 ............................................................................................................ 18 - -8.3 Updater 升级逻辑 ............................................................................................................. 20 - -9 REST API 设计 .................................................................................................................... 21 - -9.1 检查更新接口 ................................................................................................................... 21 - -9.2 获取下载链接接口 ............................................................................................................ 24 - -9.3 升级结果上报接口 ............................................................................................................ 25 - -9.4 设备身份签发接口(非用户登录) .................................................................................... 26 - -9.5 管理后台管理员登录接口(非本期普通客户端用户登录) ................................................. 27 - -9.6 管理后台接口摘要 ............................................................................................................ 28 - -10 数据库设计 ....................................................................................................................... 29 - -10.1 应用表 apps ................................................................................................................... 29 - -10.2 版本通道表 channels ...................................................................................................... 29 - -10.3 版本表 versions .............................................................................................................. 30 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -10.4 版本文件表 version_files ................................................................................................. 31 - -10.5 版本策略表 version_policies ............................................................................................ 32 - -10.6 设备表 devices ............................................................................................................... 33 - -10.7 授权表 licenses .............................................................................................................. 33 - -10.8 下载日志表 download_logs ............................................................................................. 34 - -10.9 升级日志表 upgrade_logs ................................................................................................ 35 - -10.10 管理员表 admin_users .................................................................................................. 35 - -10.11 管理员操作日志表 admin_audit_logs ............................................................................. 36 - -11 对象存储目录设计 ............................................................................................................. 37 - -12 Hash 校验策略 ................................................................................................................. 38 - -13 签名策略 .......................................................................................................................... 38 - -14 限流策略 .......................................................................................................................... 41 - -15 回滚机制 .......................................................................................................................... 42 - -16 Demo 开发里程碑 ............................................................................................................. 45 - -16.1 第一阶段:基础框架 ....................................................................................................... 45 - -16.2 第二阶段:服务端基础能力 ............................................................................................ 45 - -16.3 第三阶段:在线更新 ....................................................................................................... 45 - -16.4 第四阶段:版本策略 ....................................................................................................... 46 - -16.5 第五阶段:离线能力 ....................................................................................................... 46 - -16.6 第六阶段:可靠性与安全 ................................................................................................ 46 - -17 Demo 验收用例 ................................................................................................................ 46 - -18 后续扩展方向 ................................................................................................................... 47 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 - - - MARSCO_内部 - -1 设计范围与登录边界 - -本设计文档用于说明软件自动升级与版本控制系统第一版 Demo - -的技术方案。设计范围包括客户端启动器、主程序启动校验、独立升 - -级器、版本策略、manifest、签名校验、下载授权、离线运行、回滚机 - -制、管理后台和基础服务端接口。 - -本 期 普 通 客 户 端 用 户 默 认 可 以 使 用 软 件 , 不 需 要 登 录 账 号 。 - -Launcher 启动检查、MainApp 启动校验、Updater 下载与替换、离线 - -启动、离线更新包导入、版本禁用和降级控制均不做普通用户登录校 - -验,只使用设备身份、版本策略、manifest、启动票据和签名校验。 - -本期客户端侧不实现普通用户登录。所有客户端请求使用设备身 - -份凭证进行识别,设备身份凭证不代表用户账号,也不产生用户会话。 - -后续版本可以增加普通用户登录能力,用于用户权限校验、用户 - -授权状态判断,以及判断该用户是否可以使用该软件。该能力属于后 - -续扩展,不纳入本期 Demo 必做范围。 - -后续如增加普通用户登录,建议放在 Launcher 启动检查阶段或 - -MainApp 首次进入业务前,用于用户级授权状态判断。该能力不替代 - -设备身份、版本策略、manifest、启动票据和签名校验。 - -管理后台管理员登录属于本期范围,仅用于保护后台版本发布、 - -策略修改、设备管理、离线包生成和日志查看等管理操作。该登录不 - -是普通客户端用户登录,普通客户端用户不调用管理后台管理员登录 - -接口,也不持有后台访问令牌。 -2 技术选型 -2.1 客户端技术栈 - -客户端采用 Qt + C++ 开发。 - -客户端程序包括: - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -a) Launcher.exe - -软件启动器。 - -b) MainApp.exe - -主业务程序,用于 Demo 演示主软件启动、版本显示和 DLL 加载。 - -c) Updater.exe - -独立升级器,负责下载、校验、替换、回滚和重启。 - -d) demo_plugin.dll - -Demo 插件 DLL,用于验证 DLL 替换能力。 - -e) UpdateHelper.exe - -可选辅助程序,用于后续处理 Updater 自身更新。 - -客户端推荐使用 Qt 模块: - -a) QNetworkAccessManager:HTTP API 请求和文件下载。 - -b) QJsonDocument:JSON 解析。 - -c) QFile、QDir:文件操作。 - -d) QCryptographicHash:SHA-256 hash 计算。 - -e) QProcess:启动和关闭外部进程。 - -f) QTimer:定时检查版本。 - -g) QLockFile:防止重复启动升级器。 - -h) QSaveFile:安全写入本地配置文件。 - -i) QSettings:本地配置管理。 - -j) QTemporaryDir:临时目录管理。 - -2.2 服务端技术栈 - -Demo 阶段建议使用: - -a) Python FastAPI。 - -b) SQLite。 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -c) MinIO。 - -d) Redis 可选,用于限流。 - -正式阶段可选: - -a) Python FastAPI + PostgreSQL + Redis + MinIO / OSS。 - -b) Go + PostgreSQL + Redis + MinIO / OSS。 - -接口采用 HTTP REST JSON 方式约定。 - -2.3 文件存储 - -Demo 阶段使用 MinIO。 - -正式阶段可使用: - -a) 阿里云 OSS。 - -b) 腾讯云 COS。 - -c) 华为云 OBS。 - -d) AWS S3。 - -e) 私有化 MinIO。 - -f) 企业内网对象存储。 - -版本文件不建议直接存入数据库。数据库只存文件元数据、hash、 - -大小、存储路径和版本关系。 -3 总体架构 - -系统总体架构如下: - -客户端: - -a) Launcher.exe - -b) MainApp.exe - -c) Updater.exe - -d) 本地身份凭证 - -e) 本地版本策略缓存 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -f) 本地 manifest 缓存 - -g) staging 下载目录 - -h) backup 备份目录 - -i) - -logs 日志目录 - -服务端: - -a) API 服务。 - -b) 管理后台。 - -c) 数据库。 - -d) 对象存储。 - -e) Redis 限流模块。 - -f) 签名服务模块。 - -交互流程: - -a) 客户端请求服务端 API。 - -b) 服务端验证设备身份。 - -c) 服务端返回版本策略。 - -d) 客户端根据策略判断是否允许运行或升级。 - -e) 需要升级时,客户端请求下载链接。 - -f) 服务端生成短期对象存储下载 URL。 - -g) 客户端下载文件并校验。 - -h) 客户端替换文件并上报结果。 - -4 客户端目录结构 - -建议客户端目录如下: - -目录或文件 - -DemoApp/ - -Launcher.exe - -MainApp.exe - -用途 - -客户端安装根目录 - -默认启动入口,负责版本准入检查和启动票据生成 - -主业务程序 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -Updater.exe - -UpdateHelper.exe - -独立升级程序 - -可选辅助程序,用于处理 Updater 自身更新 - -plugins/demo_plugin.dll - -Demo 插件 DLL,用于验证 DLL 替换能力 - -bin/ - -config/app_config.json - -运行依赖目录 - -客户端基础配置 - -config/client_identity.dat - -本地设备身份凭证 - -config/version_policy.dat - -本地版本策略缓存 - -config/local_state.json - -本地运行状态和防回滚状态 - -update/manifest_cache/ - -manifest 缓存目录 - -update/staging/ - -update/backup/ - -update/logs/ - -data/ - -cache/ - -logs/ - -说明: - -更新文件下载临时目录 - -升级前备份目录 - -升级日志目录 - -用户数据目录,不纳入 hash 校验 - -缓存目录 - -运行日志目录 - -a) config/client_identity.dat:本地设备身份凭证。 - -b) config/version_policy.dat:本地版本策略。 - -c) update/manifest_cache:缓存 manifest。 - -d) update/staging:下载新文件的临时目录。 - -e) update/backup:升级前备份目录。 - -f) update/logs:升级日志。 - -g) plugins:插件 DLL 目录,需要进行白名单检查。 - -h) data:用户数据目录,不纳入 hash 校验。 - -i) - -logs:运行日志目录,不纳入 hash 校验。 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -5 本地配置文件设计 -5.1 app_config.json - -字段 - -app_id - -app_name - -channel - -api_base_url - -platform - -arch - -current_version -5.2 client_identity.dat - -示例值 - -marsco_demo_app - -Marsco Demo App - -stable - -https://api.example.com/api/v1 - -windows - -x64 - -1.0.0 - -该文件由服务端签发,客户端只验证,不自行伪造。 - -字段 - -client_id - -device_id - -license_id - -app_id - -channel - -issued_at - -valid_until - -signature -5.3 version_policy.dat - -字段 - -app_id - -client_id - -示例值 - -client_xxx - -device_xxx - -license_xxx - -marsco_demo_app - -stable - -2026-06-23T00:00:00Z - -2026-12-31T00:00:00Z - -base64_signature - -示例值 - -marsco_demo_app - -client_xxx - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -1024 - -stable - -1.0.0 - -True - -False - -False - -True - -2026-06-23T00:00:00Z - -2026-07-23T00:00:00Z - -1.0.2 - -1.0.0 - -当前版本允许使用 - -base64_signature - -policy_seq - -channel - -current_version - -allow_run - -force_update - -allow_rollback - -offline_allowed - -issued_at - -valid_until - -latest_version - -min_supported_version - -message - -signature -5.4 local_state.json - -用于记录本地状态,防止策略回滚和系统时间回拨。 - -字段 - -max_policy_seq - -last_success_run_at - -last_online_verified_at - -last_success_version - -示例值 - -1024 - -2026-06-23T10:00:00Z - -2026-06-23T09:50:00Z - -1.0.0 - -离线时间和策略回滚判断规则: - -a) max_policy_seq 只能递增,不能被旧策略覆盖。 - -b) 新读取的 version_policy.policy_seq 小于 max_policy_seq 时,客户 - -端应拒绝启动。 - -c) 当前系统时间早于 last_success_run_at 时,视为疑似时间回拨。 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -d) 当前系统时间早于 last_online_verified_at 时,视为疑似时间回拨。 - -e) 离线启动时必须同时满足:策略签名正确、policy_seq 未倒退、 - -offline_allowed 为 true、当前时间未超过 valid_until、未检测到 - -明显时间回拨。 - -f) 联网校验成功后更新 last_online_verified_at 和本地缓存策略。 - -g) 每次成功启动后更新 last_success_run_at。 - -6 Manifest 设计 - -manifest 必须是当前版本的全量受控文件清单。 - -字段 - -app_id - -version - -channel - -platform - -arch - -manifest_seq - -created_at - -files - -signature - -规则: - -示例值 - -marsco_demo_app - -1.0.2 - -stable - -windows - -x64 - -3001 - -2026-06-23T00:00:00Z - -[{"path": "Launcher.exe", "sha256": "aaa", "size": - -123456, "executable": true}, {"path": "MainApp.exe", - -"sha256": "bbb", "size": 223456, "executable": true}, - -{"path": "Updater.exe", "sha256": "ccc", "size": 323456, - -"executable": true}, {"path": "plugins/demo_plugin.dll", - -"sha256": "ddd", "size": 423456, "executable": false}] - -base64_signature - -a) manifest 中列出的文件必须存在。 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -b) manifest 中列出的文件 hash 必须一致。 - -c) 受控目录下未在 manifest 中出现的 exe、dll 文件应视为异常。 - -d) manifest 必须由服务端私钥签名。 - -e) 客户端使用内置公钥验证 manifest。 - -f) manifest 应支持版本号、通道、平台、架构。 - -7 启动票据设计 - -Launcher 启动 MainApp 时生成短期启动票据。 - -推荐方式: - -a) Launcher 生成 ticket 文件。 - -b) ticket 文件存放到系统临时目录。 - -c) Launcher 启动 MainApp,并传入 ticket 文件路径。 - -d) MainApp 读取并验证 ticket。 - -e) MainApp 验证成功后删除 ticket 文件。 - -启动参数示例: - -项目 - -启动参数 - -示例 - -MainApp.exe --ticket- - -ticket 内容: - -字段 - -payload - -file=C:\Users\xxx\AppData\Local\Temp\marsco_ticket_xxx.json - -示例值 - -{"app_id": "marsco_demo_app", "client_id": - -"client_xxx", "device_id": "device_xxx", "version": - -"1.0.0", "nonce": "uuid_xxx", "issued_at": "2026-06- - -23T10:00:00Z", "expires_at": "2026-06-23T10:01:00Z"} - -signature - -base64_signature - -校验规则: - -a) 签名正确。 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -b) 未过期。 - -c) app_id 一致。 - -d) client_id 一致。 - -e) device_id 一致。 - -f) version 一致。 - -g) nonce 未重复使用。 - -Demo 阶段可由 Launcher 生成本地签名票据。正式阶段需要区分 - -启动票据和服务端版本策略,真正的版本准入仍以服务端签名策略为 - -准。 - -启动票据安全边界: - -a) 启动票据只用于防止普通用户直接双击 MainApp 绕过 Launcher。 - -b) 启动票据不能替代 version_policy、manifest 和 client_identity 的 - -服务端签名校验。 - -c) 客户端不得保存服务端私钥。 - -d) Demo 阶段的本地票据签名密钥只用于本机进程间启动校验,不 - -用于签发版本策略、manifest 或设备身份。 - -e) 即使启动票据校验通过,MainApp 仍必须验证本地版本策略签名、 - -有效期、版本号和核心文件 hash。 - -8 客户端启动逻辑 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 8.1 Launcher 启动逻辑 - -MARSCO_内部 - -图 1 Launcher 启动准入流程 - -流程步骤: - -步骤 - -1 - -2 - -判断或处理 - -结果 - -启动 Launcher,读取 - -获得本地配置和设备身份 - -app_config 和 client_identity - -验证 client_identity 签名 - -身份无效时拒绝继续 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 - 3 - -4 - -5 - -6 - -7 - -8 - -9 - -MARSCO_内部 - -尝试请求 /update/check - -联网成功时进入在线策略校验 - -验证服务端返回策略签名,更新 - -获得最新运行策略 - -version_policy - -联网失败时读取本地 - -进入离线策略校验 - -version_policy - -验证本地策略签名、 - -离线策略无效时拒绝启动 - -valid_until、offline_allowed - -检查本地核心文件 hash - -核心文件异常时进入更新或提示 - -判断是否允许运行 - -允许时生成启动票据并启动 - -MainApp - -不允许运行或强制升级 - -启动 Updater 或提示用户更新 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 8.2 MainApp 启动逻辑 - -MARSCO_内部 - -图 2 MainApp 启动校验流程 - -流程步骤: - -步骤 - -判断或处理 - -结果 - -1 - -2 - -3 - -启动 MainApp,检查启动参数中 - -无票据时提示通过启动器启动并 - -是否存在 ticket-file - -退出 - -读取 ticket - -获得启动票据内容 - -验证 ticket 签名和有效期 - -票据无效时拒绝启动 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 - 4 - -5 - -6 - -7 - -8 - -MARSCO_内部 - -验证 version_policy - -策略无效时拒绝启动 - -验证当前版本是否允许运行 - -禁用版本或过期策略进入拦截 - -验证核心 DLL hash - -核心文件异常时拒绝启动 - -校验通过 - -进入主界面 - -主界面运行期间 - -启动定时器,定期检查更新 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -8.3 Updater 升级逻辑 - -图 3 Updater 升级与回滚流程 - -流程步骤: - -步骤 - -1 - -判断或处理 - -结果 - -启动 Updater,读取升级参数 - -获得目标版本和升级上下文 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 - - 2 - -3 - -4 - -5 - -6 - -7 - -8 - -9 - -10 - -11 -9 REST API 设计 -9.1 检查更新接口 - -方法 - -POST - -接口: - -请求: - -字段 - -app_id - -client_id - -device_id - -MARSCO_内部 - -请求 /update/check,获取目标 - -获得版本清单 - -版本 manifest - -验证 manifest 签名 - -签名无效时停止升级 - -扫描本地文件并对比 manifest - -生成待下载文件列表 - -全量 hash - -请求临时下载 URL - -获得短期下载地址 - -下载文件到 staging 并校验 - -下载文件可用于替换 - -hash - -通知 MainApp 退出并等待完全退 - -进入文件替换窗口 - -出 - -备份旧文件到 backup - -形成可回滚状态 - -替换新文件并执行安装后全量校 - -校验通过后启动 MainApp - -验 - -上报升级成功 - -任一步骤失败 - -服务端记录升级结果 - -执行回滚并上报失败原因 - -路径 - -/api/v1/update/check - -示例值 - -marsco_demo_app - -client_xxx - -device_xxx - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 license_id - -current_version - -target_version - -channel - -platform - -arch - -operation - -返回: - -字段 - -allow_run - -action - -message - -current_version - -latest_version - -channel - -force_update - -allow_rollback - -policy_seq - -MARSCO_内部 - -license_xxx - -1.0.0 - -stable - -windows - -x64 - -check - -示例值 - -True - -optional_update - -发现新版本 - -1.0.0 - -1.0.2 - -stable - -False - -False - -1025 - -policy_valid_until - -2026-07-23T00:00:00Z - -manifest_xxx - -https://download.example.com/xxx - -base64_signature - -manifest_id - -manifest_url - -signature - -action 可选值: - -字段/取值 - -allow - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 - MARSCO_内部 - -optional_update - -force_update - -blocked - -rollback_allowed - -rollback_denied - -license_invalid - -device_invalid - -policy_expired - -operation 可选值: - -字段/取值 - -check - -upgrade - -downgrade - -当 operation 为 downgrade 时,客户端必须传入 target_version。服务 - -端需要判断目标版本是否存在、是否属于当前通道、是否允许降级、 - -是否满足最低支持版本和数据兼容规则。 - -降级检查返回示例: - -字段 - -allow_run - -action - -message - -current_version - -latest_version - -target_version - -channel - -示例值 - -True - -rollback_allowed - -允许降级到 1.0.0 - -1.0.2 - -1.0.2 - -1.0.0 - -stable - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 force_update - -allow_rollback - -policy_seq - -MARSCO_内部 - -False - -True - -1030 - -policy_valid_until - -2026-07-23T00:00:00Z - -manifest_id - -manifest_url - -manifest_1_0_0 - -https://download.example.com/manifest-1.0.0.json - -signature -9.2 获取下载链接接口 - -base64_signature - -方法 - -POST - -接口: - -请求: - -字段 - -app_id - -client_id - -device_id - -license_id - -version - -channel - -files - -返回: - -字段 - -expires_in - -files - -路径 - -/api/v1/update/download-url - -示例值 - -marsco_demo_app - -client_xxx - -device_xxx - -license_xxx - -1.0.2 - -stable - -["MainApp.exe", "plugins/demo_plugin.dll"] - -示例值 - -600 - -[{"path": "MainApp.exe", "url": - -"https://oss.example.com/temp-url-1", "sha256": - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -"bbb", "size": 223456}, {"path": - -"plugins/demo_plugin.dll", "url": - -"https://oss.example.com/temp-url-2", "sha256": - -"ddd", "size": 423456}] - -9.3 升级结果上报接口 - -方法 - -POST - -接口: - -请求: - -字段 - -app_id - -client_id - -device_id - -license_id - -from_version - -to_version - -channel - -result - -error_code - -message - -started_at - -finished_at - -result 可选: - -字段/取值 - -success - -路径 - -/api/v1/update/report - -示例值 - -marsco_demo_app - -client_xxx - -device_xxx - -license_xxx - -1.0.0 - -1.0.2 - -stable - -success - -upgrade success - -2026-06-23T10:00:00Z - -2026-06-23T10:05:00Z - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 - MARSCO_内部 - -download_failed - -hash_failed - -signature_failed - -replace_failed - -rollback_success - -rollback_failed - -permission_failed - -disk_space_not_enough - -mainapp_restart_failed -9.4 设备身份签发接口(非用户登录) - -该接口用于首次签发或刷新设备身份凭证,不是客户端用户登录 - -接口。 - -Demo 阶 段 可 不 暴 露 该 接 口 , 由 测 试 数 据 或 预 置 文 件 生 成 - -client_identity.dat。正式阶段如需启用,也应定位为设备激活/身份签 - -发能力,而不是用户名密码登录。 - -接口: - -方法 - -POST - -请求: - -字段 - -app_id - -activation_code - -device_fingerprint - -platform - -arch - -路径 - -/api/v1/device/activate - -示例值 - -marsco_demo_app - -XXXX-XXXX-XXXX - -device_hash_xxx - -windows - -x64 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 返回: - -字段 - -client_id - -device_id - -license_id - -channel - -identity_token - -MARSCO_内部 - -示例值 - -client_xxx - -device_xxx - -license_xxx - -stable - -{"valid_until": "2026-12-31T00:00:00Z", "signature": - -"base64_signature"} - -Demo 阶段可以简化为预置 client_id,不做复杂激活流程。 - -9.5 管理后台管理员登录接口(非本期普通客户端用户登录) - -该接口仅用于管理后台管理员登录,与本期普通客户端用户无关。 - -普通客户端用户在本期客户端不登录,不调用该接口,也不持有该接 - -口返回的后台访问令牌。 - -后续如增加普通用户登录,应单独设计客户端用户登录接口、用 - -户授权模型和用户权限校验逻辑,不复用管理后台管理员登录接口。 - -Demo 阶段可使用单管理员账号。正式阶段可扩展为多管理员、角 - -色权限、双因素认证或企业单点登录。 - -方法 - -POST - -接口: - -请求: - -字段 - -username - -password - -返回: - -路径 - -/api/v1/admin/login - -示例值 - -admin - -password - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -示例值 - -jwt_or_session_token - -Bearer - -7200 - -字段 - -access_token - -token_type - -expires_in - -要求: - -a) 密码不得明文存储。 - -b) 登录失败应记录审计日志。 - -c) 管理后台接口必须校验管理员 token。 - -d) Demo 阶段可不实现复杂角色权限,但必须区分未登录和已登录 - -管理员;该状态只存在于管理后台,不存在于本期普通客户端。 - -9.6 管理后台接口摘要 - -第一版 Demo 至少需要以下后台接口: - -方法 - -POST - -POST - -POST - -POST - -POST - -POST - -POST - -POST - -POST - -GET - -GET - -GET - -路径 - -/api/v1/admin/apps - -/api/v1/admin/channels - -/api/v1/admin/versions - -/api/v1/admin/versions/{version_id}/files - -/api/v1/admin/versions/{version_id}/publish - -/api/v1/admin/policies - -/api/v1/admin/policies/{policy_id}/force-update - -/api/v1/admin/policies/{policy_id}/block - -/api/v1/admin/policies/{policy_id}/rollback - -/api/v1/admin/download-logs - -/api/v1/admin/upgrade-logs - -/api/v1/admin/audit-logs - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -后台接口规则: - -a) 所有后台接口都需要管理员 token。 - -b) 发布版本时生成 manifest、计算文件 hash 并写入版本文件表。 - -c) 修改强制升级、禁用、降级策略时必须生成新的 policy_seq。 - -d) 每次关键操作必须写入管理员操作记录。 - -e) Demo 阶段可使用简化管理页面或 API 调试页面完成后台操作。 - -10 数据库设计 -10.1 应用表 apps - -字段: - -字段/取值 - -id - -app_id - -app_name - -description - -created_at - -updated_at -10.2 版本通道表 channels - -字段: - -字段/取值 - -id - -app_id - -channel_code - -channel_name - -enabled - -created_at - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -updated_at - -示例: - -字段/取值 - -stable / 正式版 - -preview / 预览版 - -internal / 内测版 - -customer_a / 客户专版 -10.3 版本表 versions - -字段: - -字段/取值 - -id - -app_id - -channel_code - -version - -version_name - -status - -is_latest - -release_notes - -offline_allowed - -offline_valid_days - -allow_rollback - -rollback_targets - -force_update - -created_at - -published_at - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -updated_at - -status 可选: - -字段/取值 - -draft - -published - -deprecated - -force_update - -blocked - -archived - -说明: - -a) allow_rollback 表示该版本是否允许作为降级目标。 - -b) rollback_targets 可为空;为空时表示不允许主动降级到其他版本。 - -c) 如果需要精确控制降级目标,rollback_targets 记录允许降级到的 - -版本列表,或拆分为独立版本关系表。 - -10.4 版本文件表 version_files - -字段: - -字段/取值 - -id - -version_id - -relative_path - -storage_key - -sha256 - -size - -file_type - -is_required - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -created_at -10.5 版本策略表 version_policies - -字段: - -字段/取值 - -id - -app_id - -channel_code - -version - -policy_seq - -allow_run - -force_update - -allow_rollback - -rollback_targets - -offline_allowed - -valid_until - -min_supported_version - -latest_version - -message - -created_at - -updated_at - -说明: - -a) 每次策略变更必须递增 policy_seq。 - -b) 客户端记录已见过的最大 policy_seq,检测到倒退时拒绝使用。 - -c) allow_rollback 表示当前策略是否允许降级。 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -d) rollback_targets 用于限制允许降级的目标版本;未命中目标版本 - -时返回 rollback_denied。 - -10.6 设备表 devices - -字段: - -字段/取值 - -id - -client_id - -device_id - -license_id - -app_id - -channel_code - -status - -first_seen_at - -last_seen_at - -created_at - -updated_at - -status 可选: - -字段/取值 - -active - -disabled - -suspicious - -expired -10.7 授权表 licenses - -字段: - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -字段/取值 - -id - -license_id - -customer_name - -app_id - -channel_code - -max_devices - -valid_until - -status - -created_at - -updated_at -10.8 下载日志表 download_logs - -字段: - -字段/取值 - -id - -client_id - -device_id - -license_id - -app_id - -version - -channel_code - -file_path - -file_size - -ip - -user_agent - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -result - -created_at -10.9 升级日志表 upgrade_logs - -字段: - -字段/取值 - -id - -client_id - -device_id - -license_id - -app_id - -channel_code - -from_version - -to_version - -result - -error_code - -message - -started_at - -finished_at - -created_at -10.10 管理员表 admin_users - -字段: - -字段/取值 - -id - -username - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -password_hash - -status - -last_login_at - -created_at - -updated_at - -status 可选: - -字段/取值 - -active - -disabled - -规则: - -a) 密码只保存 hash,不保存明文。 - -b) Demo 阶段可预置一个管理员账号。 - -c) 正式阶段可扩展角色、权限、双因素认证和单点登录。 - -10.11 管理员操作日志表 admin_audit_logs - -字段: - -字段/取值 - -id - -admin_user_id - -action - -target_type - -target_id - -before_data - -after_data - -ip - -user_agent - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -created_at - -需要记录的关键操作: - -a) 登录成功和登录失败。 - -b) 创建或发布版本。 - -c) 上传版本文件。 - -d) 修改强制升级策略。 - -e) 禁用或恢复版本。 - -f) 修改降级策略。 - -g) 生成离线更新包。 - -h) 禁用或恢复设备。 - -11 对象存储目录设计 - -建议对象存储目录如下: - -对象路径 - -updates/{app_id}/ - -内容 - -应用根目录 - -updates/{app_id}/{channel}/ - -版本通道目录,例如 stable、 - -updates/{app_id}/{channel}/{version}/ - -preview - -单个版本目录 - -updates/{app_id}/{channel}/{version}/manifest.json - -当前版本 manifest - -updates/{app_id}/{channel}/{version}/manifest.sig - -manifest 签名 - -updates/{app_id}/{channel}/{version}/files/ - -版本文件目录 - -updates/{app_id}/{channel}/{version}/files/Launcher.exe - -Launcher 可执行文件 - -updates/{app_id}/{channel}/{version}/files/MainApp.exe - -主程序可执行文件 - -updates/{app_id}/{channel}/{version}/files/Updater.exe - -升级器可执行文件 - -updates/{app_id}/{channel}/{version}/files/plugins/demo_plugin.dll Demo 插件 DLL - -updates/{app_id}/{channel}/{version}/offline/ - -离线更新包目录 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -updates/{app_id}/{channel}/{version}/offline/{package}.upd - -离线更新包 - -规则: - -a) 已发布版本目录不可覆盖。 - -b) 如果要修复,应发布新版本号。 - -c) manifest 与 files 必须保持一致。 - -d) 离线包由同一套 manifest 生成。 - -e) 对象存储 bucket 正式环境应设置为私有。 - -12 Hash 校验策略 - -系统采用 SHA-256 作为文件 hash 算法。 - -规则: - -a) 服务端发布时计算所有受控文件 hash。 - -b) manifest 包含全量受控文件 hash。 - -c) 客户端升级前扫描本地文件。 - -d) 客户端只下载缺失或 hash 不一致的文件。 - -e) 下载完成后校验下载文件 hash。 - -f) 文件替换完成后执行全量 hash 校验。 - -g) 启动时执行核心文件 hash 校验。 - -h) 插件目录执行白名单检查。 - -结论: - -更新可以增量,校验必须全量。 - -13 签名策略 - -签名采用非对称签名机制。Demo 阶段建议使用 Ed25519;如果现 - -有技术栈不方便支持 Ed25519,可使用 RSA-2048 / SHA-256,但同一 - -套 Demo 中必须固定一种算法。 - -规则: - -a) 服务端保存私钥。 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -b) 客户端内置公钥。 - -c) version_policy 必须签名。 - -d) manifest 必须签名。 - -e) client_identity 必须签名。 - -f) 离线更新包必须签名。 - -g) 客户端验证签名失败时拒绝启动或拒绝安装。 - -客户端不得保存服务端私钥。 - -签名数据规则: - -a) 被签名对象必须包含 signature_alg 和 key_id。 - -b) 计算签名前必须移除 signature 字段。 - -c) JSON 签名载荷必须使用稳定序列化规则:字段按字典序排序、无 - -多余空白、UTF-8 编码。 - -d) 时间字段统一使用 UTC ISO-8601 格式。 - -e) 客户端根据 key_id 选择内置公钥进行验签。 - -f) 公钥轮换时,客户端可同时内置当前公钥和上一个公钥。 - -g) 签名失败返回或记录 signature_failed。 - -version_policy 签名字段至少包括: - -字段/取值 - -app_id - -client_id - -policy_seq - -channel - -current_version - -allow_run - -force_update - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -allow_rollback - -rollback_targets - -offline_allowed - -issued_at - -valid_until - -latest_version - -min_supported_version - -message - -signature_alg - -key_id - -manifest 签名字段至少包括: - -字段/取值 - -app_id - -version - -channel - -platform - -arch - -manifest_seq - -created_at - -files - -signature_alg - -key_id - -client_identity 签名字段至少包括: - -字段/取值 - -client_id - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -device_id - -license_id - -app_id - -channel - -issued_at - -valid_until - -signature_alg - -key_id -14 限流策略 - -服务端需要支持多维度限流。 - -建议限流维度: - -a) client_id。 - -b) device_id。 - -c) license_id。 - -d) IP。 - -e) app_id。 - -f) version。 - -g) file_path。 - -建议限流规则: - -维度 - -client_id - -device_id - -license_id - -IP - -超限后返回: - -限制 - -10 分钟内最多请求 10 次下载链接 - -1 小时内最多下载 3 次完整包 - -24 小时内最多下载指定额度 - -10 分钟内最多请求 200 次 API - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -示例值 - -RATE_LIMITED - -请求过于频繁,请稍后再试。 - -字段 - -code - -message -15 回滚机制 - -升级失败时必须支持回滚。 - -流程: - -a) 下载文件到 staging。 - -b) 校验 hash。 - -c) 备份当前版本到 backup。 - -d) 替换文件。 - -e) 替换完成后执行全量校验。 - -f) 启动 MainApp 验证。 - -g) 如果失败,使用 backup 回滚。 - -h) 回滚后再次校验旧版本文件。 - -i) 上报回滚结果。 - -升级事务状态文件: - -Updater 应 在 update/ 目 录 下 维 护 事 务 状 态 文 件 , 例 如 - -update/upgrade_state.json。 - -字段: - -字段 - -transaction_id - -from_version - -to_version - -status - -manifest_id - -示例值 - -uuid_xxx - -1.0.0 - -1.0.1 - -prepared - -manifest_xxx - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -2026-06-23T10:00:00Z - -2026-06-23T10:01:00Z - -update/staging/uuid_xxx - -update/backup/uuid_xxx - -started_at - -updated_at - -staging_dir - -backup_dir - -error_code - -message - -事务状态: - -字段/取值 - -idle - -prepared - -downloading - -downloaded - -verified - -waiting_mainapp_exit - -backed_up - -replacing - -replaced - -post_verify - -committed - -rollback_required - -rolling_back - -rolled_back - -failed - -状态规则: - -a) 下载前进入 prepared。 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 - - MARSCO_内部 - -b) 下载过程中进入 downloading。 - -c) 下载完成且 hash 校验通过后进入 verified。 - -d) MainApp 完全退出后才能进入 backed_up。 - -e) 备份完成后才能进入 replacing。 - -f) 替换完成后进入 post_verify 并执行全量 hash 校验。 - -g) 全量校验成功、MainApp 可启动后进入 committed。 - -h) 替换后校验失败、启动失败或异常中断时进入 rollback_required。 - -i) 回滚完成并校验旧版本成功后进入 rolled_back。 - -j) 回滚失败进入 failed,并提示用户重新安装或联系管理员。 - -异常恢复规则: - -a) Updater 启动时先读取 upgrade_state.json。 - -b) 如果状态为 downloading、downloaded 或 verified,可以清理 staging - -后重新下载。 - -c) 如果状态为 backed_up、replacing、replaced 或 post_verify,必须 - -优先尝试回滚。 - -d) 如果状态为 rollback_required 或 rolling_back,继续执行回滚。 - -e) 如果状态为 committed 或 rolled_back,可清理旧事务状态。 - -f) 清理 staging 和 backup 前必须确认事务已提交或已成功回滚。 - -需要处理的失败类型: - -a) 下载失败。 - -b) hash 失败。 - -c) 签名失败。 - -d) 文件占用。 - -e) 权限不足。 - -f) 磁盘空间不足。 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -g) 替换失败。 - -h) 启动失败。 - -i) 回滚失败。 -16 Demo 开发里程碑 -16.1 第一阶段:基础框架 - -目标: - -a) 完成 Launcher、MainApp、Updater 三个 Qt 程序。 - -b) MainApp 能显示当前版本。 - -c) MainApp 能加载 demo_plugin.dll。 - -d) Launcher 能启动 MainApp。 - -e) MainApp 直接启动时能拒绝进入。 - -16.2 第二阶段:服务端基础能力 - -目标: - -a) 搭建 FastAPI 或 Go API。 - -b) 搭建 SQLite 数据库。 - -c) 搭建 MinIO。 - -d) 实现应用、版本、通道基础数据。 - -e) 实现 /update/check。 - -f) 实现 manifest 返回。 -16.3 第三阶段:在线更新 - -目标: - -a) Updater 下载 manifest。 - -b) Updater 校验 manifest 签名。 - -c) Updater 对比本地 hash。 - -d) Updater 下载差异文件。 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -e) Updater 替换 MainApp 和 DLL。 - -f) Updater 自动重启 MainApp。 - -16.4 第四阶段:版本策略 - -目标: - -a) 支持强制升级。 - -b) 支持版本禁用。 - -c) 支持正式版和预览版。 - -d) 支持动态新增通道。 - -e) 支持允许降级和禁止降级。 - -16.5 第五阶段:离线能力 - -目标: - -a) 本地缓存 version_policy。 - -b) 支持离线有效期。 - -c) 离线凭证未过期时允许启动。 - -d) 离线凭证过期时禁止启动。 - -e) 支持离线更新包导入。 -16.6 第六阶段:可靠性与安全 - -目标: - -a) 支持升级失败回滚。 - -b) 支持 policy_seq 防回滚。 - -c) 支持系统时间回拨检测。 - -d) 支持插件白名单检查。 - -e) 支持下载限流。 - -f) 支持升级日志和下载日志。 - -17 Demo 验收用例 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -a) 通过 Launcher 正常启动 MainApp。 - -b) 直接启动 MainApp 被拒绝。 - -c) MainApp 显示主程序版本和 DLL 版本。 - -d) 后台发布 1.0.1。 - -e) 客户端从 1.0.0 升级到 1.0.1。 - -f) 升级后 DLL 版本变化。 - -g) 升级后 MainApp 自动重启。 - -h) 手动篡改 DLL 后启动失败。 - -i) 手动篡改 version_policy 后启动失败。 - -j) 后台设置 1.0.0 强制升级后客户端无法继续使用。 - -k) 后台禁用 1.0.0 后客户端拒绝启动。 - -l) 后台开放预览版后 preview 客户端获取 preview 更新。 - -m) 后台关闭降级通道后客户端无法降级。 - -n) 离线凭证未过期时可以启动。 - -o) 离线凭证过期后拒绝启动。 - -p) 模拟升级失败后成功回滚。 - -18 后续扩展方向 - -后续可扩展: - -a) 完整 License 系统。 - -b) 客户专版分发。 - -c) 灰度发布。 - -d) 多平台支持。 - -e) Windows 服务模式更新。 - -f) 管理员权限提权。 - -g) 代码签名证书。 - -h) 自动错误收集。 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 - -i) 客户端日志上传。 - -j) 增量二进制差分更新。 - -k) 多语言界面。 - -l) 企业内网镜像站点。 - -MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 \ No newline at end of file diff --git a/client/软件自动升级与版本控制系统开发设计文档 v0.1.pdf b/client/软件自动升级与版本控制系统开发设计文档 v0.1.pdf deleted file mode 100644 index 489b6f8..0000000 Binary files a/client/软件自动升级与版本控制系统开发设计文档 v0.1.pdf and /dev/null differ diff --git a/client/软件自动升级系统接入说明.docx b/client/软件自动升级系统接入说明.docx deleted file mode 100644 index 7661a2a..0000000 Binary files a/client/软件自动升级系统接入说明.docx and /dev/null differ diff --git a/server/.dockerignore b/server/.dockerignore deleted file mode 100644 index 1c52f22..0000000 --- a/server/.dockerignore +++ /dev/null @@ -1,8 +0,0 @@ -** -!Dockerfile -!requirements.txt -!main.py -!db.py -!minio_tool.py -!tables.sql -!admin.html diff --git a/server/.env.docker.example b/server/.env.docker.example deleted file mode 100644 index 44f5b07..0000000 --- a/server/.env.docker.example +++ /dev/null @@ -1,78 +0,0 @@ -# Docker 镜像名称。通常不用改;重新打镜像并改版本号时才需要同步修改。 -SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0 - -# 服务端端口。浏览器访问 http://服务器IP:8000/。 -SERVER_PORT=8000 - -# 容器内运行用户。通常不用改;如果服务器文件权限特殊,再改成对应用户的 uid/gid。 -APP_UID=1000 -APP_GID=1000 - -# 管理后台标题。可不填或不改。 -SERVICE_TITLE=SimCAE Update Service - -# 跨域来源。内网部署一般保持 * 即可;生产环境可改成指定域名。 -CORS_ALLOW_ORIGINS=* - -# 发布包目标平台。当前客户端是 Windows x64,通常不用改。 -TARGET_PLATFORM=windows -TARGET_ARCH=x64 - -# 业务主程序相对发布根目录的路径。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 - -# 管理后台令牌。已给默认值,可直接试跑;网页登录时在 X-Admin-Token 输入这个值,正式部署建议修改。 -ADMIN_TOKEN=SimCAEAdminToken2026 - -# 崩溃报告接口。已给默认值,可直接试跑;正式部署建议修改。 -CRASH_SERVICE_VERSION=1.0.0 -CRASH_REPORT_TOKEN=SimCAECrashReportToken2026 -CRASH_SYMBOL_TOKEN=SimCAESymbolToken2026 - -# 崩溃报告管理令牌。可不填;不填时查询/下载崩溃报告使用 ADMIN_TOKEN。 -CRASH_ADMIN_TOKEN= - -# 崩溃报告大小限制。通常不用改。 -CRASH_METADATA_MAX_KB=256 -CRASH_MINIDUMP_MAX_MB=128 -CRASH_ATTACHMENTS_MAX_MB=64 -CRASH_REQUEST_MAX_MB=200 -CRASH_SYMBOLS_MAX_MB=512 - -# MinIO 端口。通常不用改;如果端口被占用再改。 -MINIO_API_PORT=9000 -MINIO_CONSOLE_PORT=9001 - -# MinIO 用户名和密码。Docker 启动 MinIO 时会用这里的值初始化账号;已给默认值,可直接试跑,正式部署建议修改。 -MINIO_ACCESS_KEY=simcae_minio_admin -MINIO_SECRET_KEY=SimCAE_MinIO_2026_ChangeMe - -# MinIO 存储桶名称。通常不用改。 -MINIO_BUCKET=updates - -# 客户端下载升级文件时访问的 MinIO 地址。必须改成 Windows 客户端能访问到的服务器地址。 -MINIO_PUBLIC_ENDPOINT=http://服务器IP:9000 - -# MinIO 预签名下载链接有效期,单位分钟。通常不用改。 -SIGN_EXPIRE_MIN=60 - -# MinIO 连接超时和重试参数。通常不用改。 -MINIO_CONNECT_TIMEOUT_SEC=2 -MINIO_READ_TIMEOUT_SEC=5 -MINIO_RETRY_TOTAL=1 -MINIO_HEALTH_TIMEOUT_SEC=2 diff --git a/server/.env.example b/server/.env.example deleted file mode 100644 index a306efd..0000000 --- a/server/.env.example +++ /dev/null @@ -1,82 +0,0 @@ -# 服务监听地址和端口。本机开发一般保持 0.0.0.0:8000。 -SERVER_HOST=0.0.0.0 -SERVER_PORT=8000 - -# 本地开发时一般不用改;Docker 部署主要看 .env.docker.example。 -APP_UID=1000 -APP_GID=1000 -SERVICE_TITLE=Software Update Service -ADMIN_HTML_PATH=admin.html -CORS_ALLOW_ORIGINS=* -TARGET_PLATFORM=windows -TARGET_ARCH=x64 -RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe -SIGNING_KEY_ID=manifest-key-v1 - -# 客户端访问服务端 API 的令牌。已给默认值,可直接试跑;正式部署建议修改,并填到客户端 app_config.json 的 client_token。 -CLIENT_API_TOKEN=SimCAEClientToken2026 - -# 管理后台令牌。已给默认值,可直接试跑;网页登录时在 X-Admin-Token 输入这个值,正式部署建议修改。 -ADMIN_TOKEN=SimCAEAdminToken2026 - -# 数据库和 Manifest 私钥路径。本地开发一般不用改。 -DB_FILE=mini.db -SQL_FILE=tables.sql -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 - -# SimCAE 崩溃报告接口。接入崩溃上报时必须改;不接入崩溃上报也建议改成随机字符串。 -CRASH_SERVICE_VERSION=1.0.0 -CRASH_REPORT_TOKEN=SimCAECrashReportToken2026 -CRASH_SYMBOL_TOKEN=SimCAESymbolToken2026 - -# 崩溃报告管理令牌。可不填;不填时查询/下载崩溃报告使用 ADMIN_TOKEN。 -CRASH_ADMIN_TOKEN= -CRASH_STORAGE_ROOT=crash_storage - -# 崩溃报告大小限制。通常不用改。 -CRASH_METADATA_MAX_KB=256 -CRASH_MINIDUMP_MAX_MB=128 -CRASH_ATTACHMENTS_MAX_MB=64 -CRASH_REQUEST_MAX_MB=200 -CRASH_SYMBOLS_MAX_MB=512 - -# MinIO 地址。本地开发如果 MinIO 在本机 9000 端口,保持不变。 -MINIO_ENDPOINT=127.0.0.1:9000 - -# 客户端下载升级文件时访问的 MinIO 地址。必须改成 Windows 客户端能访问到的服务器地址。 -MINIO_PUBLIC_ENDPOINT=http://服务器IP:9000 - -# MinIO 用户名、密码和桶。必须和你的 MinIO 配置一致。 -MINIO_ACCESS_KEY=simcae_minio_admin -MINIO_SECRET_KEY=SimCAE_MinIO_2026_ChangeMe -MINIO_BUCKET=updates -MINIO_SECURE=false - -# MinIO 预签名下载链接和连接参数。通常不用改。 -SIGN_EXPIRE_MIN=60 -MINIO_CONNECT_TIMEOUT_SEC=2 -MINIO_READ_TIMEOUT_SEC=5 -MINIO_RETRY_TOTAL=1 -MINIO_HEALTH_TIMEOUT_SEC=2 - -# 本地演示便利项。Docker 部署时会强制关闭自动启动 MinIO。 -MINIO_AUTO_START=true -MINIO_AUTO_DOWNLOAD=false -MINIO_BIN_PATH=minio -MINIO_DATA_DIR=minio_data -MINIO_LISTEN_ADDRESS=:9000 -MINIO_START_WAIT_SEC=5 -MINIO_DOWNLOAD_URL=https://dl.min.io/server/minio/release/linux-amd64/minio diff --git a/server/.gitignore b/server/.gitignore deleted file mode 100644 index 9ef82b1..0000000 --- a/server/.gitignore +++ /dev/null @@ -1,59 +0,0 @@ -# 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 deleted file mode 100644 index 2bccc45..0000000 --- a/server/Dockerfile +++ /dev/null @@ -1,30 +0,0 @@ -FROM python:3.12-slim - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=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 server/admin.html ./admin.html - -RUN mkdir -p /data/uploads /data/upload_spool /run/secrets/update-keys \ - && chown -R updateapp:updateapp /app /data \ - && chmod 644 /app/admin.html - -USER updateapp - -EXPOSE 8000 - -HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \ - CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)" - -CMD ["python", "main.py"] diff --git a/server/admin.html b/server/admin.html deleted file mode 100644 index eb85fa5..0000000 --- a/server/admin.html +++ /dev/null @@ -1,2428 +0,0 @@ - - - - - - Marsco 发布控制台 - - -
-

软件发布控制台

-
- -
- - - - - - - - diff --git a/server/db.py b/server/db.py deleted file mode 100644 index b408345..0000000 --- a/server/db.py +++ /dev/null @@ -1,38 +0,0 @@ -import sqlite3 -from pathlib import Path -import os -from dotenv import load_dotenv - -load_dotenv() - -BASE_DIR = Path(__file__).resolve().parent - - -def configured_path(name: str, default: str) -> Path: - path = Path(os.getenv(name, default)).expanduser() - return path if path.is_absolute() else BASE_DIR / path - - -DB_FILE = configured_path("DB_FILE", "mini.db") -SQL_FILE = configured_path("SQL_FILE", "tables.sql") - -# 初始化数据库 -def init_db(): - conn = sqlite3.connect(DB_FILE, timeout=30, check_same_thread=False) - conn.execute("PRAGMA journal_mode=WAL") - cur = conn.cursor() - sql = Path(SQL_FILE).read_text("utf-8") - cur.executescript(sql) - conn.commit() - conn.close() - print(f"数据库 {DB_FILE} 创建完成") - -# 获取连接 -def get_conn(): - conn = sqlite3.connect(DB_FILE, timeout=30, check_same_thread=False) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA foreign_keys=ON") - return conn - -if __name__ == "__main__": - init_db() diff --git a/server/docker-compose.image.yml b/server/docker-compose.image.yml deleted file mode 100644 index 5304d09..0000000 --- a/server/docker-compose.image.yml +++ /dev/null @@ -1,75 +0,0 @@ -services: - minio: - image: minio/minio:RELEASE.2025-04-22T22-12-26Z - command: server /data --console-address ":9001" - restart: unless-stopped - environment: - MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} - MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} - ports: - - "${MINIO_API_PORT:-9000}:9000" - - "${MINIO_CONSOLE_PORT:-9001}:9001" - volumes: - - ./minio_data:/data - healthcheck: - test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"] - interval: 10s - timeout: 5s - retries: 10 - - minio-init: - image: minio/mc:RELEASE.2025-04-16T18-13-26Z - depends_on: - minio: - condition: service_healthy - restart: "no" - entrypoint: ["/bin/sh", "-c"] - command: - - >- - mc alias set update-store http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" && - mc mb --ignore-existing "update-store/$${MINIO_BUCKET}" - environment: - MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} - MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} - MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required} - - api: - image: ${SIMCAE_UPDATE_SERVER_IMAGE:-simcae-update-server:0.1.0} - restart: unless-stopped - user: "${APP_UID:-1000}:${APP_GID:-1000}" - env_file: - - .env - 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: /minio_data - CRASH_STORAGE_ROOT: /data/crash_storage - MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem - MINIO_ENDPOINT: minio:9000 - MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:?MINIO_PUBLIC_ENDPOINT is required} - MINIO_AUTO_START: "false" - MINIO_SECURE: "false" - 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: - condition: service_healthy - minio-init: - condition: service_completed_successfully - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"] - interval: 15s - timeout: 3s - start_period: 10s - retries: 3 diff --git a/server/docker-compose.yml b/server/docker-compose.yml deleted file mode 100644 index 73f677a..0000000 --- a/server/docker-compose.yml +++ /dev/null @@ -1,77 +0,0 @@ -services: - minio: - image: minio/minio:RELEASE.2025-04-22T22-12-26Z - command: server /data --console-address ":9001" - restart: unless-stopped - environment: - MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} - MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} - ports: - - "${MINIO_API_PORT:-9000}:9000" - - "${MINIO_CONSOLE_PORT:-9001}:9001" - volumes: - - ./minio_data:/data - healthcheck: - test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"] - interval: 10s - timeout: 5s - retries: 10 - - minio-init: - image: minio/mc:RELEASE.2025-04-16T18-13-26Z - depends_on: - minio: - condition: service_healthy - restart: "no" - entrypoint: ["/bin/sh", "-c"] - command: - - >- - mc alias set update-store http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" && - mc mb --ignore-existing "update-store/$${MINIO_BUCKET}" - environment: - MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} - MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} - MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required} - - api: - build: - context: .. - dockerfile: server/Dockerfile - restart: unless-stopped - user: "${APP_UID:-1000}:${APP_GID:-1000}" - env_file: - - .env - 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: /minio_data - CRASH_STORAGE_ROOT: /data/crash_storage - MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem - MINIO_ENDPOINT: minio:9000 - MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:?MINIO_PUBLIC_ENDPOINT is required} - MINIO_AUTO_START: "false" - MINIO_SECURE: "false" - 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: - condition: service_healthy - minio-init: - condition: service_completed_successfully - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"] - interval: 15s - timeout: 3s - start_period: 10s - retries: 3 diff --git a/server/keys/manifest_public_key.pem b/server/keys/manifest_public_key.pem deleted file mode 100644 index 781682f..0000000 --- a/server/keys/manifest_public_key.pem +++ /dev/null @@ -1,9 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMROESbT36XU4d3YjuwU -WAC2h5p3btFu/IAeF3bVtHMovIA4ZXXKYsiq5FycDnDzyD86ou3B7PP7uHhVhn2l -Uru7QElYRfEfQAFSU5dErc+SZo+oT170cgq1ePPD/YleKPRqFAL221Tbh5pusHcZ -Ocujit2qrfg5f4rIEzWu7kBKVSJ6WChkjetEL6OZ43ClkDyUGebUuaQ9dv39YVlv -Fp2pfARi7/7djMMncLVaFU2AAIuSy3jgQg65DOFRVPSr1P/rRfuqU55ZmqAmmZIs -F/KVpne6zLFBXrxf5rNTBch+nxX+hcE7M+K0PJA5Ie669qRQFRwxoJlGpSOvMefQ -TwIDAQAB ------END PUBLIC KEY----- diff --git a/server/main.py b/server/main.py deleted file mode 100644 index d92882d..0000000 --- a/server/main.py +++ /dev/null @@ -1,2244 +0,0 @@ -from fastapi import FastAPI, Body, Request, HTTPException, UploadFile, File, Form, Header, Depends -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse, JSONResponse, StreamingResponse -from pydantic import BaseModel -import os -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 -import secrets -import base64 -import json -import uuid -from datetime import datetime, timezone, timedelta -from fastapi.middleware.cors import CORSMiddleware -import socket -import subprocess -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 - -# 加载环境变量 -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: - value = os.getenv(name) - return default if value is None else value.strip().lower() in {"1", "true", "yes", "on"} - - -def configured_path(name: str, default: str) -> Path: - path = Path(os.getenv(name, default)).expanduser() - return path if path.is_absolute() else BASE_DIR / path - - -SERVER_HOST = os.getenv("SERVER_HOST", "0.0.0.0") -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().replace(chr(92), "/").strip("/") -SIGNING_KEY_ID = os.getenv("SIGNING_KEY_ID", "manifest-key-v1") - -# 大型 multipart 上传使用内存文件系统暂存,避免与 MinIO 对象双重占用根分区。 -UPLOAD_SPOOL_DIR = configured_path("UPLOAD_SPOOL_DIR", "upload_spool") -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") -CRASH_METADATA_MAX_BYTES = int(os.getenv("CRASH_METADATA_MAX_KB", "256")) * 1024 -CRASH_MINIDUMP_MAX_BYTES = int(os.getenv("CRASH_MINIDUMP_MAX_MB", "128")) * 1024 * 1024 -CRASH_ATTACHMENTS_MAX_BYTES = int(os.getenv("CRASH_ATTACHMENTS_MAX_MB", "64")) * 1024 * 1024 -CRASH_REQUEST_MAX_BYTES = int(os.getenv("CRASH_REQUEST_MAX_MB", "200")) * 1024 * 1024 -CRASH_SYMBOLS_MAX_BYTES = int(os.getenv("CRASH_SYMBOLS_MAX_MB", "512")) * 1024 * 1024 -CRASH_SERVICE_VERSION = os.getenv("CRASH_SERVICE_VERSION", "1.0.0") - -# 后台管理令牌:当前版本直接使用 .env 中的 ADMIN_TOKEN 明文配置。 -ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "").strip() -if not ADMIN_TOKEN: - raise RuntimeError("请在环境变量或 .env 中配置 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") - -# ========== 生命周期初始化 ========== -@asynccontextmanager -async def lifespan(app: FastAPI): - print("===== 进程启动,开始初始化数据库 =====") - db_file = db.DB_FILE - sql_file_path = db.SQL_FILE - conn = sqlite3.connect(db_file, check_same_thread=False) - conn.row_factory = sqlite3.Row - cur = conn.cursor() - - if sql_file_path.exists(): - with open(sql_file_path, "r", encoding="utf-8") as f: - full_sql = f.read() - cur.executescript(full_sql) - version_columns = {row[1] for row in cur.execute("PRAGMA table_info(versions)").fetchall()} - if "client_protocol" not in version_columns: - cur.execute("ALTER TABLE versions ADD COLUMN client_protocol INTEGER NOT NULL DEFAULT 1") - device_columns = {row[1] for row in cur.execute("PRAGMA table_info(devices)").fetchall()} - if "license_id" not in device_columns: cur.execute("ALTER TABLE devices ADD COLUMN license_id TEXT NOT NULL DEFAULT ''") - if "channel" not in device_columns: cur.execute("ALTER TABLE devices ADD COLUMN channel TEXT NOT NULL DEFAULT 'stable'") - for app_row in cur.execute("SELECT app_id FROM apps").fetchall(): - if not cur.execute("SELECT 1 FROM channels WHERE app_id=? LIMIT 1", (app_row[0],)).fetchone(): - cur.executemany("INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order) VALUES(?,?,?,?,?)", - [(app_row[0], "stable", "正式版", 1, 10), (app_row[0], "preview", "预览版", 1, 20), (app_row[0], "dev", "开发版", 1, 30)]) - conn.commit() - print(f"✅ 成功执行tables.sql,创建数据表与测试数据") - else: - print(f"❌ 错误:当前目录找不到 tables.sql 文件!") - conn.close() - print("===== 数据库初始化完成 =====") - - CRASH_STORAGE_ROOT.mkdir(parents=True, exist_ok=True) - (CRASH_STORAGE_ROOT / "reports").mkdir(parents=True, exist_ok=True) - (CRASH_STORAGE_ROOT / "symbols").mkdir(parents=True, exist_ok=True) - - start_minio_if_needed() - - yield - print("服务进程正常退出") - -def is_minio_running(endpoint: str) -> bool: - try: - if endpoint.startswith("http://") or endpoint.startswith("https://"): - base_url = endpoint.rstrip("/") - else: - scheme = "https" if env_bool("MINIO_SECURE") else "http" - base_url = f"{scheme}://{endpoint.rstrip('/')}" - health_url = base_url + "/minio/health/live" - with urllib.request.urlopen(health_url, timeout=float(os.getenv("MINIO_HEALTH_TIMEOUT_SEC", "2"))) as resp: - return resp.status == 200 - except Exception: - return False - - -def download_minio_binary(target_path: Path) -> str | None: - url = os.getenv("MINIO_DOWNLOAD_URL", "https://dl.min.io/server/minio/release/linux-amd64/minio") - try: - print(f"MinIO 二进制未找到,尝试下载到 {target_path}") - target_path.parent.mkdir(parents=True, exist_ok=True) - with urllib.request.urlopen(url, timeout=30) as response, open(target_path, 'wb') as out_file: - out_file.write(response.read()) - target_path.chmod(0o755) - abs_path = str(target_path.resolve()) - print(f"已下载 MinIO 二进制到 {abs_path}") - return abs_path - except Exception as err: - print(f"自动下载 MinIO 失败:{err}") - return None - - -def start_minio_if_needed(): - if not env_bool("MINIO_AUTO_START", True): - return - endpoint = os.getenv('MINIO_ENDPOINT') - if not endpoint: - return - - if is_minio_running(endpoint): - print(f"MinIO 已在 {endpoint} 运行,跳过启动") - return - - minio_cmd = shutil.which('minio') - if not minio_cmd: - minio_bin = Path(os.getenv('MINIO_BIN_PATH', 'minio')) - minio_cmd = str(minio_bin.resolve()) if minio_bin.exists() else None - if not minio_cmd and env_bool("MINIO_AUTO_DOWNLOAD", False): - minio_cmd = download_minio_binary(minio_bin) - if not minio_cmd: - print("MinIO 二进制未找到,且未启用或未完成自动下载") - return - - data_dir = MINIO_DATA_DIR - data_dir.mkdir(parents=True, exist_ok=True) - minio_address = os.getenv("MINIO_LISTEN_ADDRESS", ":9000") - print(f"尝试自动启动 MinIO:{minio_cmd} server {data_dir} --address {minio_address}") - try: - subprocess.Popen([minio_cmd, 'server', str(data_dir), '--address', minio_address], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - time.sleep(float(os.getenv("MINIO_START_WAIT_SEC", "5"))) - if is_minio_running(endpoint): - print("MinIO 已启动成功") - else: - print("MinIO 启动失败,仍然无法连接") - except Exception as err: - print(f"启动 MinIO 失败:{err}") - - -# Manifest signing key path -MANIFEST_PRIVATE_KEY_PATH = str(configured_path("MANIFEST_PRIVATE_KEY_PATH", "keys/manifest_private_key.pem")) - -# 创建APP绑定生命周期 -app = FastAPI(title=os.getenv("SERVICE_TITLE", "软件自动升级服务"), lifespan=lifespan) - -ADMIN_HTML_PATH = configured_path("ADMIN_HTML_PATH", "admin.html") - - -@app.get("/", include_in_schema=False) -@app.get("/admin.html", include_in_schema=False) -def admin_page(): - if not ADMIN_HTML_PATH.is_file(): - raise HTTPException(status_code=404, detail="管理页面文件不存在") - return FileResponse(ADMIN_HTML_PATH, headers={"Cache-Control": "no-store"}) - - -@app.get("/health", include_in_schema=False) -def health_check(): - return {"status": "ok"} - -# 本地上传备份文件目录(MinIO 不通时会保存到这里) -app.mount("/static", StaticFiles(directory=str(minio_tool.LOCAL_UPLOAD_ROOT)), name="static") - -# 跨域配置(给admin前端页面用) -cors_origins = [item.strip() for item in os.getenv("CORS_ALLOW_ORIGINS", "*").split(",") if item.strip()] -app.add_middleware( - CORSMiddleware, - allow_origins=cors_origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# 管理后台写操作统一审计;不保存令牌明文和请求正文。 -@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") - 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 - finally: - if should_audit: - try: - token = request.headers.get("X-Admin-Token", "") - conn = db.get_conn(); conn.execute("""INSERT INTO admin_audit_logs - (actor_hash,action,method,path,target,result,status_code,ip,user_agent) VALUES(?,?,?,?,?,?,?,?,?)""", - (token_digest(token)[:16] if token else "anonymous", request.url.path.removeprefix("/admin/"), - request.method, request.url.path, request.url.query[:500], - "success" if 200 <= status_code < 400 else "fail", status_code, - request.client.host if request.client else "", request.headers.get("user-agent", "")[:300])); conn.commit(); conn.close() - except Exception as audit_error: - print("管理员审计日志写入失败:", audit_error) - -# ===================== 全局客户端鉴权中间件 ===================== -@app.middleware("http") -async def auth_middleware(request: Request, call_next): - path = request.url.path - # 后台接口、文档接口、静态回退链接全部跳过客户端token校验 - skip_paths = ("/admin", "/docs", "/openapi.json", "/redoc", "/static", "/health", "/api/v1/health", "/api/v1/crash-reports", "/api/v1/symbols") - if path == "/" or path.startswith(skip_paths): - response = await call_next(request) - return response - - client_token = request.headers.get("X-Client-Token") - if client_token != VALID_CLIENT_TOKEN: - return JSONResponse(status_code=401, content={"detail": "非法客户端,令牌校验失败"}) - if path != "/api/v1/device/issue": - credential = request.headers.get("X-Device-Credential", "") - if not credential: - return JSONResponse(status_code=401, content={"detail": { - "error": "device_credential_required", "msg": "缺少设备身份凭证" - }}) - try: - request.state.device_identity = verify_device_credential(credential) - except HTTPException as exc: - return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) - response = await call_next(request) - return response - -# ========== 请求体模型 ========== -class CheckUpdateReq(BaseModel): - app_id: str - current_version: str - channel: str - client_protocol: int = 1 - -class DownloadUrlReq(BaseModel): - app_id: str - channel: str - version: str - version_id: int - files: list[str] - -class ManifestReq(BaseModel): - app_id: str - channel: str - version: str - version_id: int - -class ReportReq(BaseModel): - app_id: str - device_id: str - from_version: str - to_version: str - result: str - -class ChangeAdminTokenReq(BaseModel): - new_token: str - -class DownloadReportReq(BaseModel): - app_id: str - channel: str - version: str - result: str - files: list[dict] - -class DeviceIssueReq(BaseModel): - app_id: str - channel: str - license_key: str - installation_id: str - machine_hash: str = "" - -# 统一后台鉴权:全部接口从Header读取token,不再区分表单/头 -def admin_auth(X_Admin_Token: str = Header("")): - if not secrets.compare_digest(X_Admin_Token, ADMIN_TOKEN): - raise HTTPException(status_code=403, detail="后台密钥错误,禁止访问") - return True - -@app.get("/admin/auth/check") -def admin_auth_check(auth=Depends(admin_auth)): - return {"code": 0, "msg": "管理员令牌有效"} - -# Manifest signing helpers - -def load_manifest_private_key(): - key_path = Path(MANIFEST_PRIVATE_KEY_PATH) - if not key_path.exists(): - raise FileNotFoundError(f"Manifest private key not found: {key_path}") - key_data = key_path.read_bytes() - return load_pem_private_key(key_data, password=None) - - -def canonical_manifest_bytes(manifest_obj: dict) -> bytes: - copy = {k: manifest_obj[k] for k in manifest_obj if k != "signature"} - return json.dumps(copy, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") - - -def sign_manifest(manifest_obj: dict) -> str: - json_text = canonical_manifest_bytes(manifest_obj) - private_key = load_manifest_private_key() - signature = private_key.sign( - json_text, - padding.PKCS1v15(), - hashes.SHA256() - ) - return base64.b64encode(signature).decode("ascii") - - -def canonical_signed_bytes(obj: dict) -> bytes: - copy = {k: obj[k] for k in obj if k != "signature"} - return json.dumps(copy, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") - - -def sign_policy(policy_obj: dict) -> tuple[str, str]: - payload = canonical_signed_bytes(policy_obj) - signature = load_manifest_private_key().sign(payload, padding.PKCS1v15(), hashes.SHA256()) - return payload.decode("utf-8"), base64.b64encode(signature).decode("ascii") - - -def sign_device_identity(identity: dict) -> tuple[str, str]: - payload = canonical_signed_bytes(identity) - signature = load_manifest_private_key().sign(payload, padding.PKCS1v15(), hashes.SHA256()) - return payload.decode("utf-8"), base64.b64encode(signature).decode("ascii") - -def verify_device_credential(encoded: str) -> dict: - try: - wrapper = json.loads(base64.b64decode(encoded, validate=True).decode("utf-8")) - identity_text = wrapper["identity_text"] - signature = base64.b64decode(wrapper["signature"], validate=True) - load_manifest_private_key().public_key().verify(signature, identity_text.encode(), padding.PKCS1v15(), hashes.SHA256()) - identity = json.loads(identity_text) - if any(not identity.get(k) for k in ("device_id", "license_id", "app_id", "channel", "installation_id", "credential_seq", "valid_until")): - raise ValueError("missing field") - except Exception: - raise HTTPException(status_code=401, detail={"error":"invalid_device_credential","msg":"设备凭证格式或签名无效"}) - conn = db.get_conn(); row = conn.execute("SELECT * FROM devices WHERE device_id=?", (identity["device_id"],)).fetchone() - if not row: - conn.close(); raise HTTPException(status_code=401, detail={"error":"device_not_registered","msg":"设备未登记"}) - if row["disabled"]: - reason=row["disabled_reason"] or "设备已被管理员禁用"; conn.close() - raise HTTPException(status_code=403, detail={"error":"device_disabled","msg":reason}) - if row["app_id"] != identity["app_id"] or row["installation_id"] != identity["installation_id"] or int(row["credential_seq"]) != int(identity["credential_seq"]): - conn.close(); raise HTTPException(status_code=401, detail={"error":"device_credential_revoked","msg":"设备凭证已失效"}) - license_row = conn.execute("SELECT * FROM licenses WHERE license_id=?", (identity.get("license_id", ""),)).fetchone() - now = datetime.now(timezone.utc) - try: license_expiry = datetime.fromisoformat((license_row["valid_until"] if license_row else "").replace("Z", "+00:00")) - except ValueError: license_expiry = now - timedelta(seconds=1) - if not license_row or license_row["status"] != "active" or license_expiry <= now: - conn.close(); raise HTTPException(status_code=403, detail={"error":"license_invalid","msg":"授权不存在、已禁用或已过期"}) - if license_row["app_id"] != identity["app_id"] or license_row["channel_code"] != identity.get("channel"): - conn.close(); raise HTTPException(status_code=403, detail={"error":"license_scope_mismatch","msg":"授权应用或渠道不匹配"}) - conn.execute("UPDATE devices SET last_seen_at=datetime('now') WHERE device_id=?", (identity["device_id"],)); conn.commit(); conn.close() - return identity - -def policy_row_to_dict(row) -> dict: - if not row: - return { - "policy_seq": 1, "force_update": False, "allow_rollback": False, - "offline_allowed": True, "valid_until": "2099-12-31T23:59:59Z", - "min_supported_version": "", "disabled_versions": [], "message": "" - } - try: - disabled = json.loads(row["disabled_versions"] or "[]") - except (TypeError, json.JSONDecodeError): - disabled = [] - return { - "policy_seq": int(row["policy_seq"]), "force_update": bool(row["force_update"]), - "allow_rollback": bool(row["allow_rollback"]), "offline_allowed": bool(row["offline_allowed"]), - "valid_until": row["valid_until"], "min_supported_version": row["min_supported_version"] or "", - "disabled_versions": disabled if isinstance(disabled, list) else [], "message": row["message"] or "" - } - - -def validate_channel_code(code: str) -> bool: - return bool(code) and len(code) <= 32 and code[0].isalnum() and all(c.isalnum() or c in "_-" for c in code) - -def require_channel(conn, app_id: str, channel: str, require_enabled: bool = True): - row = conn.execute("SELECT * FROM channels WHERE app_id=? AND channel_code=?", (app_id, channel)).fetchone() - if not row: raise HTTPException(status_code=400, detail={"error":"channel_not_found","msg":f"渠道 {channel} 不存在"}) - if require_enabled and not row["enabled"]: raise HTTPException(status_code=403, detail={"error":"channel_disabled","msg":f"渠道 {channel} 已停用"}) - return row - -def is_executable_path(path: str) -> bool: - return path.lower().endswith((".exe", ".dll")) - -def version_key(version: str): - parts = [] - for part in version.split("."): - digits = "".join(ch for ch in part if ch.isdigit()) - parts.append(int(digits) if digits else 0) - return tuple(parts) - -def normalize_relative_path(raw_path: str) -> str: - if not isinstance(raw_path, str): - raise HTTPException(status_code=400, detail="文件相对路径无效") - normalized = raw_path.replace(chr(92), "/") - if normalized.startswith("/"): - raise HTTPException(status_code=400, detail=f"文件路径必须是相对路径: {raw_path}") - normalized = normalized.strip("/") - if not normalized or chr(0) in normalized: - raise HTTPException(status_code=400, detail="文件相对路径为空或包含非法字符") - path = PurePosixPath(normalized) - if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts): - raise HTTPException(status_code=400, detail=f"不安全的文件路径: {raw_path}") - if path.parts and ":" in path.parts[0]: - raise HTTPException(status_code=400, detail=f"文件路径不能包含盘符: {raw_path}") - return path.as_posix() - - -def validate_release_path(relative_path: str): - folded = relative_path.casefold() - parts = PurePosixPath(folded).parts - for part in parts[:-1]: - 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() - sha.update(data) - 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 - self.code = code - self.message = message - self.retryable = retryable - - -@app.exception_handler(CrashApiException) -async def crash_api_exception_handler(request: Request, exc: CrashApiException): - return JSONResponse( - status_code=exc.status_code, - content={"error": {"code": exc.code, "message": exc.message, "retryable": exc.retryable}}, - ) - - -def utc_now_text() -> str: - return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") - - -def crash_api_error(status_code: int, code: str, message: str, retryable: bool = False): - raise CrashApiException(status_code, code, message, retryable) - - -def bearer_token_from_request(request: Request) -> str: - value = request.headers.get("authorization", "") - if not value.lower().startswith("bearer "): - crash_api_error(401, "unauthorized", "missing bearer token", False) - token = value[7:].strip() - if not token: - crash_api_error(401, "unauthorized", "missing bearer token", False) - return token - - -def require_crash_bearer(request: Request, allowed_roles: set[str]) -> str: - token = bearer_token_from_request(request) - roles: set[str] = set() - if CRASH_REPORT_TOKEN and secrets.compare_digest(token, CRASH_REPORT_TOKEN): - roles.add("crash_report") - if CRASH_SYMBOL_TOKEN and secrets.compare_digest(token, CRASH_SYMBOL_TOKEN): - roles.add("symbols") - if CRASH_ADMIN_TOKEN: - if secrets.compare_digest(token, CRASH_ADMIN_TOKEN): - roles.add("admin") - elif secrets.compare_digest(token, ADMIN_TOKEN): - roles.add("admin") - if not roles: - crash_api_error(401, "unauthorized", "invalid bearer token", False) - if roles.isdisjoint(allowed_roles): - crash_api_error(403, "forbidden", "token is not allowed to access this endpoint", False) - return token - - -def ensure_multipart_request(request: Request, max_bytes: int = CRASH_REQUEST_MAX_BYTES): - content_type = request.headers.get("content-type", "") - if "multipart/form-data" not in content_type.lower(): - crash_api_error(415, "unsupported_media_type", "Content-Type must be multipart/form-data", False) - raw_length = request.headers.get("content-length") - if raw_length: - try: - content_length = int(raw_length) - except ValueError: - crash_api_error(400, "metadata_invalid", "Content-Length is invalid", False) - if content_length > max_bytes: - crash_api_error(413, "payload_too_large", "request body exceeds limit", False) - - -def is_upload_file(value) -> bool: - return hasattr(value, "filename") and hasattr(value, "read") - - -async def read_metadata_field(value, max_bytes: int) -> bytes: - if value is None: - crash_api_error(400, "metadata_invalid", "metadata is required", False) - if is_upload_file(value): - data = bytearray() - while True: - chunk = await value.read(64 * 1024) - if not chunk: - break - data.extend(chunk) - if len(data) > max_bytes: - crash_api_error(413, "payload_too_large", "metadata exceeds size limit", False) - return bytes(data) - data = str(value).encode("utf-8") - if len(data) > max_bytes: - crash_api_error(413, "payload_too_large", "metadata exceeds size limit", False) - return data - - -async def save_upload_limited(upload, target: Path, max_bytes: int, field_name: str) -> dict: - if not is_upload_file(upload): - crash_api_error(400, "metadata_invalid", f"{field_name} file is required", False) - target.parent.mkdir(parents=True, exist_ok=True) - total = 0 - digest = hashlib.sha256() - try: - with open(target, "wb") as out_file: - while True: - chunk = await upload.read(1024 * 1024) - if not chunk: - break - total += len(chunk) - if total > max_bytes: - crash_api_error(413, "payload_too_large", f"{field_name} exceeds size limit", False) - digest.update(chunk) - out_file.write(chunk) - except Exception: - if target.exists(): - target.unlink() - raise - if total <= 0: - crash_api_error(400, "metadata_invalid", f"{field_name} file is empty", False) - return {"sha256": digest.hexdigest(), "size": total, "filename": upload.filename or field_name} - - -def load_json_metadata(raw: bytes, error_code: str = "metadata_invalid") -> dict: - try: - obj = json.loads(raw.decode("utf-8")) - except Exception: - crash_api_error(400, error_code, "metadata must be valid UTF-8 JSON", False) - if not isinstance(obj, dict): - crash_api_error(400, error_code, "metadata must be a JSON object", False) - return obj - - -def require_metadata_fields(metadata: dict, fields: list[str], error_code: str = "metadata_invalid"): - for field in fields: - if metadata.get(field) in (None, ""): - crash_api_error(400, error_code, f"metadata.{field} is required", False) - if metadata.get("schemaVersion") != 1: - crash_api_error(400, error_code, "metadata.schemaVersion must be 1", False) - - -def normalize_declared_sha256(value, field_path: str) -> str: - if value in (None, ""): - return "" - text = str(value).strip().lower() - if len(text) != 64 or any(ch not in "0123456789abcdef" for ch in text): - crash_api_error(400, "metadata_invalid", f"{field_path} must be a SHA-256 hex string", False) - return text - - -def declared_file_sha256(metadata: dict, kind: str, filename: str) -> str: - files = metadata.get("files") - if not isinstance(files, list): - crash_api_error(400, "metadata_invalid", "metadata.files must be an array", False) - for index, item in enumerate(files): - if not isinstance(item, dict): - crash_api_error(400, "metadata_invalid", f"metadata.files[{index}] must be an object", False) - if item.get("kind") == kind or item.get("name") == filename: - return normalize_declared_sha256(item.get("sha256"), f"metadata.files[{index}].sha256") - return "" - - -def validate_client_report_id(value: str): - try: - uuid.UUID(value) - except Exception: - crash_api_error(400, "metadata_invalid", "metadata.clientReportId must be a UUID", False) - - -def safe_storage_component(value, fallback: str = "unknown") -> str: - text = str(value or fallback).strip() - cleaned = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in text) - return (cleaned[:96] or fallback) - - -def crash_content_hash(metadata_sha256: str, minidump_sha256: str, attachments_sha256: str) -> str: - payload = json.dumps( - {"metadata": metadata_sha256, "minidump": minidump_sha256, "attachments": attachments_sha256 or ""}, - sort_keys=True, - separators=(",", ":"), - ).encode("utf-8") - return hashlib.sha256(payload).hexdigest() - - -def generate_prefixed_id(conn, table: str, column: str, prefix: str) -> str: - day = datetime.now(timezone.utc).strftime("%Y%m%d") - for _ in range(20): - value = f"{prefix}_{day}_{secrets.token_hex(4)}" - if not conn.execute(f"SELECT 1 FROM {table} WHERE {column}=?", (value,)).fetchone(): - return value - crash_api_error(503, "service_unavailable", "could not allocate report id", True) - - -def cleanup_path(path: Path | None): - if not path: - return - try: - if path.is_dir(): - shutil.rmtree(path) - elif path.exists(): - path.unlink() - except Exception as err: - print(f"清理临时文件失败: {path}: {err}") - - -def crash_report_response(row, duplicate: bool, status_code: int): - return JSONResponse( - status_code=status_code, - content={ - "reportId": row["report_id"], - "duplicate": duplicate, - "status": row["status"], - "acceptedAtUtc": row["received_at_utc"], - }, - ) - - -def symbol_upload_response(row, duplicate: bool, status_code: int): - return JSONResponse( - status_code=status_code, - content={ - "symbolUploadId": row["symbol_upload_id"], - "status": row["status"], - "duplicate": duplicate, - "acceptedAtUtc": row["received_at_utc"], - }, - ) - - -@app.get("/api/v1/health") -def crash_health(): - return { - "status": "ok", - "service": "simcae-crash-server", - "version": CRASH_SERVICE_VERSION, - "timeUtc": utc_now_text(), - } - - -@app.post("/api/v1/crash-reports") -async def create_crash_report(request: Request): - tmp_dir: Path | None = None - final_dir: Path | None = None - stored = False - conn = None - try: - require_crash_bearer(request, {"crash_report"}) - ensure_multipart_request(request) - try: - form = await request.form() - except Exception: - crash_api_error(400, "metadata_invalid", "multipart form is invalid", False) - - metadata_raw = await read_metadata_field(form.get("metadata"), CRASH_METADATA_MAX_BYTES) - metadata_sha = hashlib.sha256(metadata_raw).hexdigest() - metadata = load_json_metadata(metadata_raw) - require_metadata_fields( - metadata, - ["schemaVersion", "clientReportId", "crashTimeUtc", "product", "appVersion", "gitCommit", "buildType", "channel", "platform", "crash", "userConsent", "files"], - ) - if metadata.get("product") != "SIMCAE": - crash_api_error(400, "metadata_invalid", "metadata.product must be SIMCAE", False) - if not isinstance(metadata.get("platform"), dict): - crash_api_error(400, "metadata_invalid", "metadata.platform must be an object", False) - if not isinstance(metadata.get("crash"), dict): - crash_api_error(400, "metadata_invalid", "metadata.crash must be an object", False) - if not isinstance(metadata.get("userConsent"), dict): - crash_api_error(400, "metadata_invalid", "metadata.userConsent must be an object", False) - - client_report_id = str(metadata.get("clientReportId") or "").strip() - validate_client_report_id(client_report_id) - idempotency_key = request.headers.get("idempotency-key", "").strip() - if not idempotency_key: - crash_api_error(400, "metadata_invalid", "Idempotency-Key header is required", False) - if idempotency_key != client_report_id: - crash_api_error(400, "metadata_invalid", "Idempotency-Key must equal metadata.clientReportId", False) - - tmp_dir = CRASH_STORAGE_ROOT / "_incoming" / f"crash_{secrets.token_hex(12)}" - tmp_dir.mkdir(parents=True, exist_ok=False) - (tmp_dir / "metadata.json").write_bytes(metadata_raw) - minidump_info = await save_upload_limited(form.get("minidump"), tmp_dir / "crash.dmp", CRASH_MINIDUMP_MAX_BYTES, "minidump") - declared_dump_sha = declared_file_sha256(metadata, "minidump", "crash.dmp") - if declared_dump_sha and declared_dump_sha != minidump_info["sha256"]: - crash_api_error(400, "file_hash_mismatch", "minidump SHA-256 does not match metadata.files", False) - - attachments_info = None - if form.get("attachments") is not None: - attachments_info = await save_upload_limited(form.get("attachments"), tmp_dir / "attachments.zip", CRASH_ATTACHMENTS_MAX_BYTES, "attachments") - - package_hash = crash_content_hash(metadata_sha, minidump_info["sha256"], attachments_info["sha256"] if attachments_info else "") - received_at = utc_now_text() - content_length = int(request.headers.get("content-length") or 0) - remote_addr = request.client.host if request.client else "" - server_info = { - "storageVersion": 1, - "clientReportId": client_report_id, - "receivedAtUtc": received_at, - "remoteAddress": remote_addr, - "contentLength": content_length, - } - - conn = db.get_conn() - existing = conn.execute("SELECT * FROM crash_reports WHERE client_report_id=?", (client_report_id,)).fetchone() - if existing: - if existing["content_hash"] != package_hash: - crash_api_error(409, "idempotency_conflict", "same clientReportId was uploaded with different content", False) - return crash_report_response(existing, True, 200) - - report_id = generate_prefixed_id(conn, "crash_reports", "report_id", "srv") - server_info["reportId"] = report_id - (tmp_dir / "server.json").write_text(json.dumps(server_info, ensure_ascii=False, indent=2), encoding="utf-8") - final_dir = CRASH_STORAGE_ROOT / "reports" / report_id - final_dir.parent.mkdir(parents=True, exist_ok=True) - tmp_dir.rename(final_dir) - tmp_dir = None - - crash_obj = metadata.get("crash") or {} - conn.execute( - """ - INSERT INTO crash_reports( - report_id,client_report_id,content_hash,status,symbolication_status,product,app_version, - git_commit,build_type,channel,exception_code,crash_time_utc,received_at_utc,remote_address, - content_length,metadata_sha256,metadata_size,minidump_sha256,minidump_size,attachments_sha256, - attachments_size,storage_path,metadata_json,server_json - ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - """, - ( - report_id, - client_report_id, - package_hash, - "stored", - "not_started", - metadata.get("product"), - metadata.get("appVersion"), - metadata.get("gitCommit"), - metadata.get("buildType"), - metadata.get("channel"), - str(crash_obj.get("exceptionCode") or ""), - metadata.get("crashTimeUtc"), - received_at, - remote_addr, - content_length, - metadata_sha, - len(metadata_raw), - minidump_info["sha256"], - minidump_info["size"], - attachments_info["sha256"] if attachments_info else "", - attachments_info["size"] if attachments_info else 0, - str(final_dir), - metadata_raw.decode("utf-8", errors="replace"), - json.dumps(server_info, ensure_ascii=False, separators=(",", ":")), - ), - ) - conn.commit() - stored = True - row = conn.execute("SELECT * FROM crash_reports WHERE report_id=?", (report_id,)).fetchone() - return crash_report_response(row, False, 201) - except sqlite3.IntegrityError: - if conn: - conn.rollback() - client_report_id = "" - try: - metadata = load_json_metadata(metadata_raw) - client_report_id = str(metadata.get("clientReportId") or "").strip() - except Exception: - pass - if client_report_id: - existing = conn.execute("SELECT * FROM crash_reports WHERE client_report_id=?", (client_report_id,)).fetchone() - if existing and existing["content_hash"] == package_hash: - return crash_report_response(existing, True, 200) - crash_api_error(409, "idempotency_conflict", "same clientReportId was uploaded with different content", False) - except CrashApiException: - if conn: - conn.rollback() - raise - except Exception as err: - if conn: - conn.rollback() - print("崩溃报告上传失败:", err) - crash_api_error(500, "server_error", "server failed to store crash report", True) - finally: - if conn: - conn.close() - cleanup_path(tmp_dir) - if final_dir and not stored: - cleanup_path(final_dir) - - -@app.get("/api/v1/crash-reports/{report_id}") -def get_crash_report(report_id: str, request: Request): - require_crash_bearer(request, {"admin"}) - conn = db.get_conn() - row = conn.execute("SELECT * FROM crash_reports WHERE report_id=?", (report_id,)).fetchone() - conn.close() - if not row: - crash_api_error(404, "metadata_invalid", "crash report not found", False) - return { - "reportId": row["report_id"], - "clientReportId": row["client_report_id"], - "status": row["status"], - "receivedAtUtc": row["received_at_utc"], - "symbolicationStatus": row["symbolication_status"], - "appVersion": row["app_version"], - "gitCommit": row["git_commit"], - "product": row["product"], - "buildType": row["build_type"], - "channel": row["channel"], - "exceptionCode": row["exception_code"], - "files": { - "metadata": {"name": "metadata.json", "sha256": row["metadata_sha256"], "size": row["metadata_size"]}, - "minidump": {"name": "crash.dmp", "sha256": row["minidump_sha256"], "size": row["minidump_size"]}, - "attachments": {"name": "attachments.zip", "sha256": row["attachments_sha256"], "size": row["attachments_size"]} if row["attachments_size"] else None, - "server": {"name": "server.json"}, - }, - } - - -@app.get("/api/v1/crash-reports/{report_id}/files/{file_name}") -def download_crash_report_file(report_id: str, file_name: str, request: Request): - token = require_crash_bearer(request, {"admin"}) - 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: - crash_api_error(404, "metadata_invalid", "file not found", False) - conn = db.get_conn() - row = conn.execute("SELECT * FROM crash_reports WHERE report_id=?", (report_id,)).fetchone() - if not row: - conn.close() - crash_api_error(404, "metadata_invalid", "crash report not found", False) - target = Path(row["storage_path"]) / stored_name - 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(token)[:16], request.client.host if request.client else "", request.headers.get("user-agent", "")[:300]), - ) - conn.commit() - finally: - conn.close() - if not target.is_file(): - crash_api_error(404, "metadata_invalid", "file not found", False) - return FileResponse(target, media_type="application/octet-stream", filename=stored_name) - - -@app.post("/api/v1/symbols") -async def upload_symbols(request: Request): - tmp_dir: Path | None = None - final_dir: Path | None = None - stored = False - conn = None - try: - require_crash_bearer(request, {"symbols"}) - ensure_multipart_request(request, CRASH_SYMBOLS_MAX_BYTES + CRASH_METADATA_MAX_BYTES) - try: - form = await request.form() - except Exception: - crash_api_error(400, "metadata_invalid", "multipart form is invalid", False) - metadata_raw = await read_metadata_field(form.get("metadata"), CRASH_METADATA_MAX_BYTES) - metadata_sha = hashlib.sha256(metadata_raw).hexdigest() - metadata = load_json_metadata(metadata_raw) - require_metadata_fields( - metadata, - ["schemaVersion", "product", "appVersion", "gitCommit", "buildType", "platform", "toolchain", "createdAtUtc", "files"], - ) - if metadata.get("product") != "SIMCAE": - crash_api_error(400, "metadata_invalid", "metadata.product must be SIMCAE", False) - if not isinstance(metadata.get("files"), list): - crash_api_error(400, "metadata_invalid", "metadata.files must be an array", False) - - tmp_dir = CRASH_STORAGE_ROOT / "_incoming" / f"symbols_{secrets.token_hex(12)}" - tmp_dir.mkdir(parents=True, exist_ok=False) - (tmp_dir / "metadata.json").write_bytes(metadata_raw) - symbols_info = await save_upload_limited(form.get("symbols"), tmp_dir / "symbols.zip", CRASH_SYMBOLS_MAX_BYTES, "symbols") - identity_payload = json.dumps( - { - "product": metadata.get("product"), - "appVersion": metadata.get("appVersion"), - "gitCommit": metadata.get("gitCommit"), - "buildType": metadata.get("buildType"), - "platform": metadata.get("platform"), - }, - sort_keys=True, - separators=(",", ":"), - ) - identity_hash = hashlib.sha256(identity_payload.encode("utf-8")).hexdigest() - received_at = utc_now_text() - conn = db.get_conn() - existing = conn.execute("SELECT * FROM crash_symbol_uploads WHERE identity_hash=?", (identity_hash,)).fetchone() - if existing: - if existing["symbols_sha256"] != symbols_info["sha256"]: - crash_api_error(409, "idempotency_conflict", "same build identity already has different symbols", False) - return symbol_upload_response(existing, True, 200) - - upload_id = generate_prefixed_id(conn, "crash_symbol_uploads", "symbol_upload_id", "sym") - final_dir = CRASH_STORAGE_ROOT / "symbols" / safe_storage_component(metadata.get("product")) / safe_storage_component(metadata.get("appVersion")) / safe_storage_component(metadata.get("gitCommit")) / safe_storage_component(metadata.get("buildType")) / safe_storage_component(metadata.get("platform")) - final_dir.parent.mkdir(parents=True, exist_ok=True) - if final_dir.exists(): - shutil.rmtree(final_dir) - server_info = { - "storageVersion": 1, - "symbolUploadId": upload_id, - "receivedAtUtc": received_at, - "remoteAddress": request.client.host if request.client else "", - "contentLength": int(request.headers.get("content-length") or 0), - } - (tmp_dir / "server.json").write_text(json.dumps(server_info, ensure_ascii=False, indent=2), encoding="utf-8") - tmp_dir.rename(final_dir) - tmp_dir = None - conn.execute( - """ - INSERT INTO crash_symbol_uploads( - symbol_upload_id,identity_hash,status,product,app_version,git_commit,build_type,platform,toolchain, - created_at_utc,received_at_utc,metadata_sha256,metadata_size,symbols_sha256,symbols_size,storage_path,metadata_json - ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - """, - ( - upload_id, - identity_hash, - "stored", - metadata.get("product"), - metadata.get("appVersion"), - metadata.get("gitCommit"), - metadata.get("buildType"), - metadata.get("platform"), - metadata.get("toolchain"), - metadata.get("createdAtUtc"), - received_at, - metadata_sha, - len(metadata_raw), - symbols_info["sha256"], - symbols_info["size"], - str(final_dir), - metadata_raw.decode("utf-8", errors="replace"), - ), - ) - conn.commit() - stored = True - row = conn.execute("SELECT * FROM crash_symbol_uploads WHERE symbol_upload_id=?", (upload_id,)).fetchone() - return symbol_upload_response(row, False, 201) - except sqlite3.IntegrityError: - if conn: - conn.rollback() - crash_api_error(409, "idempotency_conflict", "same build identity already exists", False) - except CrashApiException: - if conn: - conn.rollback() - raise - except Exception as err: - if conn: - conn.rollback() - print("符号包上传失败:", err) - crash_api_error(500, "server_error", "server failed to store symbols", True) - finally: - if conn: - conn.close() - cleanup_path(tmp_dir) - if final_dir and not stored: - cleanup_path(final_dir) - -# ==================== 原有客户端接口 完整保留 ==================== -# 0. 首次设备登记;仅此客户端接口允许没有设备凭证 -@app.post("/api/v1/device/issue") -async def issue_device(request: Request, body: DeviceIssueReq = Body(...)): - app_id, channel, installation_id = body.app_id.strip(), body.channel.strip(), body.installation_id.strip() - if not app_id or not validate_channel_code(channel) or not (16 <= len(installation_id) <= 128): raise HTTPException(status_code=400, detail="设备登记参数无效") - key_hash=token_digest(body.license_key.strip()); now=datetime.now(timezone.utc) - conn=db.get_conn(); conn.execute("BEGIN IMMEDIATE") - require_channel(conn, app_id, channel) - lic=conn.execute("SELECT * FROM licenses WHERE license_key_hash=?",(key_hash,)).fetchone() - try: expiry=datetime.fromisoformat((lic["valid_until"] if lic else "").replace("Z","+00:00")) - except ValueError: expiry=now-timedelta(seconds=1) - if not lic or lic["status"]!="active" or expiry<=now or lic["app_id"]!=app_id or lic["channel_code"]!=channel: - conn.rollback();conn.close();raise HTTPException(status_code=403,detail={"error":"license_invalid","msg":"授权密钥无效、已过期或不适用于当前应用/渠道"}) - row=conn.execute("SELECT * FROM devices WHERE app_id=? AND installation_id=?",(app_id,installation_id)).fetchone() - if row and row["disabled"]: reason=row["disabled_reason"] or "设备已被禁用";conn.rollback();conn.close();raise HTTPException(status_code=403,detail={"error":"device_disabled","msg":reason}) - if row and row["license_id"] not in ("",lic["license_id"]): conn.rollback();conn.close();raise HTTPException(status_code=409,detail="该安装实例已绑定其他授权") - device_id=row["device_id"] if row else "dev_"+secrets.token_hex(16); seq=int(row["credential_seq"]) if row else 1 - bound=conn.execute("SELECT 1 FROM license_devices WHERE license_id=? AND device_id=?",(lic["license_id"],device_id)).fetchone() - used=conn.execute("SELECT COUNT(*) FROM license_devices WHERE license_id=?",(lic["license_id"],)).fetchone()[0] - if not bound and used>=int(lic["max_devices"]): conn.rollback();conn.close();raise HTTPException(status_code=403,detail={"error":"license_device_limit","msg":"授权设备数量已达到上限"}) - ip=request.client.host if request.client else "" - if row: conn.execute("UPDATE devices SET machine_hash=?,license_id=?,channel=?,last_seen_at=datetime('now'),last_ip=? WHERE device_id=?",(body.machine_hash.strip().lower(),lic["license_id"],channel,ip,device_id)) - else: conn.execute("INSERT INTO devices(device_id,app_id,installation_id,machine_hash,credential_seq,last_ip,license_id,channel) VALUES(?,?,?,?,?,?,?,?)",(device_id,app_id,installation_id,body.machine_hash.strip().lower(),seq,ip,lic["license_id"],channel)) - conn.execute("INSERT OR IGNORE INTO license_devices(license_id,device_id) VALUES(?,?)",(lic["license_id"],device_id));conn.commit();conn.close() - identity={"device_id":device_id,"license_id":lic["license_id"],"app_id":app_id,"channel":channel,"installation_id":installation_id,"credential_seq":seq,"issued_at":now.replace(microsecond=0).isoformat().replace("+00:00","Z"),"valid_until":lic["valid_until"],"signature_alg":"RSA-2048-SHA256","key_id":SIGNING_KEY_ID} - identity_text,signature=sign_device_identity(identity);return {"identity":identity,"identity_text":identity_text,"signature":signature} - -# 1. 查询更新接口 -@app.post("/api/v1/update/check") -async def check_update(request: Request, body: CheckUpdateReq = Body(...)): - if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel: raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配") - print("收到客户端版本检测请求,参数:", body.model_dump()) - app_id, cur_ver, channel = body.app_id, body.current_version, body.channel - conn = db.get_conn() - require_channel(conn, app_id, channel) - ver = conn.execute( - "SELECT * FROM versions WHERE app_id=? AND channel=? AND latest=1", (app_id, channel) - ).fetchone() - policy_row = conn.execute( - "SELECT * FROM version_policies WHERE app_id=? AND channel=?", (app_id, channel) - ).fetchone() - conn.close() - - settings = policy_row_to_dict(policy_row) - latest_ver = ver["version"] if ver else "" - version_id = int(ver["id"]) if ver else 0 - target_protocol = int(ver["client_protocol"] or 1) if ver else 0 - protocol_compatible = not ver or target_protocol >= body.client_protocol - upgrade_available = bool(latest_ver) and version_key(latest_ver) > version_key(cur_ver) - rollback_candidate = bool(latest_ver) and version_key(latest_ver) < version_key(cur_ver) - rollback_allowed = rollback_candidate and bool(settings["allow_rollback"]) and protocol_compatible - need_update = upgrade_available or rollback_allowed - disabled = cur_ver in settings["disabled_versions"] - below_min = bool(settings["min_supported_version"]) and version_key(cur_ver) < version_key(settings["min_supported_version"]) - allow_run = not disabled and not below_min - force_update = bool(settings["force_update"] and upgrade_available) or disabled or below_min - if disabled: - action, message = "blocked", settings["message"] or f"当前版本 {cur_ver} 已被管理员禁用" - elif below_min: - action, message = "force_update", settings["message"] or f"当前版本低于最低支持版本 {settings['min_supported_version']}" - elif force_update: - action, message = "force_update", settings["message"] or "必须升级到最新版本后才能继续使用" - elif rollback_allowed: - action, message = "rollback_allowed", settings["message"] or f"管理员要求回退到版本 {latest_ver}" - elif rollback_candidate and not protocol_compatible: - action, message = "rollback_denied", f"目标版本 {latest_ver} 的客户端协议为 {target_protocol},低于当前协议 {body.client_protocol},禁止降级" - elif rollback_candidate: - action, message = "rollback_denied", settings["message"] or f"渠道目标版本 {latest_ver} 低于当前版本,策略禁止降级" - elif upgrade_available: - action, message = "optional_update", settings["message"] or "发现可用新版本" - else: - action, message = "allow", settings["message"] or "当前版本允许使用" - - issued_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") - policy = { - "app_id": app_id, "channel": channel, "current_version": cur_ver, - "policy_seq": settings["policy_seq"], "allow_run": allow_run, - "force_update": force_update, "allow_rollback": settings["allow_rollback"], - "rollback_targets": [], "offline_allowed": settings["offline_allowed"], - "issued_at": issued_at, "valid_until": settings["valid_until"], - "latest_version": latest_ver, "min_supported_version": settings["min_supported_version"], - "disabled_versions": settings["disabled_versions"], "message": message, - "signature_alg": "RSA-2048-SHA256", "key_id": SIGNING_KEY_ID - } - policy_text, policy_signature = sign_policy(policy) - policy["signature"] = policy_signature - return { - "allow_run": allow_run, "action": action, "message": message, - "need_update": need_update, "release_available": bool(ver), - "current_version": cur_ver, "latest_version": latest_ver, "version_id": version_id, - "force_update": force_update, "allow_rollback": settings["allow_rollback"], - "rollback": rollback_allowed, "client_protocol": body.client_protocol, - "target_client_protocol": target_protocol, "protocol_compatible": protocol_compatible, - "policy_seq": settings["policy_seq"], "policy_valid_until": settings["valid_until"], - "policy": policy, "policy_text": policy_text, "policy_signature": policy_signature - } - -# 2. 获取文件下载链接 -@app.post("/api/v1/update/download-url") -async def get_download_url(request: Request, body: DownloadUrlReq = Body(...)): - if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel: raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配") - ver_id = body.version_id - conn = db.get_conn() - version_row = conn.execute("SELECT app_id,channel,version FROM versions WHERE id=?", (ver_id,)).fetchone() - if not version_row or version_row["app_id"] != body.app_id or version_row["channel"] != body.channel or version_row["version"] != body.version: - conn.close(); raise HTTPException(status_code=404, detail="版本与应用/渠道不匹配") - rows = conn.execute( - "SELECT path, sha256, size FROM version_files WHERE version_id=?", - (ver_id,) - ).fetchall() - identity=request.state.device_identity; ip=request.client.host if request.client else ""; ua=request.headers.get("user-agent", "")[:300] - conn.executemany("""INSERT INTO download_logs(device_id,license_id,app_id,version,channel_code,file_path,file_size,result,ip,user_agent) - VALUES(?,?,?,?,?,?,?,?,?,?)""", [(identity["device_id"],identity["license_id"],body.app_id,body.version,body.channel,r["path"],r["size"],"authorized",ip,ua) for r in rows]) - conn.commit(); conn.close() - res = [] - base_path = f"{body.app_id}/{body.channel}/{body.version}/files/" - for r in rows: - full_path = base_path + r["path"] - url = minio_tool.get_url(full_path) - res.append({ - "path": r["path"], - "url": url, - "sha256": r["sha256"], - "size": r["size"] - }) - return {"files": res} - -@app.post("/api/v1/update/download-report") -async def report_download(request: Request, body: DownloadReportReq = Body(...)): - identity=request.state.device_identity - if identity["app_id"]!=body.app_id or identity["channel"]!=body.channel: raise HTTPException(status_code=403,detail="设备凭证与应用/渠道不匹配") - if body.result not in ("success","fail"): raise HTTPException(status_code=400,detail="下载结果无效") - conn=db.get_conn(); ip=request.client.host if request.client else "";ua=request.headers.get("user-agent","")[:300]; values=[] - for item in body.files[:5000]: - path=normalize_relative_path(str(item.get("path") or ""));size=max(0,int(item.get("size") or 0));values.append((identity["device_id"],identity["license_id"],body.app_id,body.version,body.channel,path,size,body.result,ip,ua)) - if values: conn.executemany("INSERT INTO download_logs(device_id,license_id,app_id,version,channel_code,file_path,file_size,result,ip,user_agent) VALUES(?,?,?,?,?,?,?,?,?,?)",values) - conn.commit();conn.close();return {"code":0,"logged":len(values)} - -# 2b. 获取版本 manifest -@app.post("/api/v1/update/manifest") -async def get_manifest(request: Request, body: ManifestReq = Body(...)): - if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel: raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配") - ver_id = body.version_id - conn = db.get_conn() - ver = conn.execute( - "SELECT app_id, channel, version, create_time FROM versions WHERE id=?", - (ver_id,) - ).fetchone() - if not ver: - conn.close() - raise HTTPException(status_code=404, detail="Version not found") - - rows = conn.execute( - "SELECT path, sha256, size FROM version_files WHERE version_id=?", - (ver_id,) - ).fetchall() - conn.close() - - files = [] - for r in rows: - files.append({ - "path": r["path"], - "sha256": r["sha256"], - "size": r["size"], - "executable": is_executable_path(r["path"]) - }) - - manifest = { - "app_id": ver["app_id"], - "version": ver["version"], - "channel": ver["channel"], - "platform": TARGET_PLATFORM, - "arch": TARGET_ARCH, - "manifest_seq": int(ver_id), - "created_at": ver["create_time"], - "files": files - } - manifest_text = canonical_manifest_bytes(manifest).decode("utf-8") - signature = sign_manifest(manifest) - manifest["signature"] = signature - return { - "manifest": manifest, - "manifest_text": manifest_text, - "signature": signature - } - -# 3. 升级结果上报 -@app.post("/api/v1/update/report") -async def report(request: Request, body: ReportReq = Body(...)): - identity=request.state.device_identity - if identity["app_id"] != body.app_id or identity["device_id"] != body.device_id: raise HTTPException(status_code=403, detail="上报身份与设备凭证不匹配") - conn = db.get_conn() - insert_sql = """ - INSERT INTO upgrade_logs(device_id,from_ver,to_ver,result,create_time) - VALUES (?,?,?,?,datetime('now')) - """ - conn.execute(insert_sql, (body.device_id, body.from_version, body.to_version, body.result)) - conn.commit() - conn.close() - return {"code": 0, "msg": "上报成功"} - -# ==================== 管理后台 Admin 全套接口(统一Header鉴权) ==================== -@app.post("/admin/token/change") -def admin_change_token(body: ChangeAdminTokenReq, auth=Depends(admin_auth)): - global ADMIN_TOKEN - new_token = body.new_token.strip() - if len(new_token) < 8: - 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 - os.environ["ADMIN_TOKEN"] = new_token - return {"code": 0, "msg": f"管理员令牌已更新,并已写入 {ENV_FILE_PATH}"} - -# 应用列表 GET -@app.get("/admin/app/list") -def admin_get_app_list(auth=Depends(admin_auth)): - conn = db.get_conn() - rows = conn.execute("SELECT app_id, app_name FROM apps").fetchall() - conn.close() - return {"list": [{"app_id": r["app_id"], "app_name": r["app_name"]} for r in rows]} - -# 新增应用 POST json -@app.post("/admin/app/add") -def admin_add_app(body: dict, auth=Depends(admin_auth)): - aid = body["app_id"] - aname = body["app_name"] - conn = db.get_conn() - try: - conn.execute("INSERT INTO apps(app_id, app_name) VALUES (?, ?)", (aid, aname)) - conn.executemany("INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order) VALUES(?,?,?,?,?)", - [(aid,"stable","正式版",1,10),(aid,"preview","预览版",1,20),(aid,"dev","开发版",1,30)]) - conn.commit() - except Exception as e: - return {"msg": f"创建失败:{str(e)}"} - conn.close() - return {"msg": "应用创建成功"} - -@app.get("/admin/channel/list") -def admin_channel_list(app_id: str, include_disabled: bool = True, auth=Depends(admin_auth)): - conn=db.get_conn();sql="SELECT channel_code,display_name,enabled,sort_order FROM channels WHERE app_id=?"+("" if include_disabled else " AND enabled=1")+" ORDER BY sort_order,channel_code";rows=conn.execute(sql,(app_id,)).fetchall();conn.close();return {"list":[{"channel_code":r["channel_code"],"display_name":r["display_name"],"enabled":bool(r["enabled"]),"sort_order":r["sort_order"]} for r in rows]} - -@app.post("/admin/channel/save") -def admin_channel_save(body: dict, auth=Depends(admin_auth)): - app_id=str(body.get("app_id") or "").strip();code=str(body.get("channel_code") or "").strip();name=str(body.get("display_name") or "").strip();enabled=bool(body.get("enabled",True));order=int(body.get("sort_order") or 100) - if not app_id or not validate_channel_code(code) or not name or len(name)>64: raise HTTPException(status_code=400,detail="渠道参数无效;代码仅允许字母、数字、下划线和连字符") - conn=db.get_conn(); - if not conn.execute("SELECT 1 FROM apps WHERE app_id=?",(app_id,)).fetchone():conn.close();raise HTTPException(status_code=404,detail="应用不存在") - conn.execute("INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order) VALUES(?,?,?,?,?) ON CONFLICT(app_id,channel_code) DO UPDATE SET display_name=excluded.display_name,enabled=excluded.enabled,sort_order=excluded.sort_order,updated_at=datetime('now')",(app_id,code,name,int(enabled),order));conn.commit();conn.close();return {"msg":"渠道已保存"} - -# 查询和保存应用渠道策略 -@app.get("/admin/policy") -def admin_get_policy(app_id: str, channel: str, auth=Depends(admin_auth)): - conn = db.get_conn() - row = conn.execute("SELECT * FROM version_policies WHERE app_id=? AND channel=?", (app_id, channel)).fetchone() - conn.close() - result = policy_row_to_dict(row) - result.update({"app_id": app_id, "channel": channel}) - return result - - -@app.post("/admin/policy/save") -def admin_save_policy(body: dict, auth=Depends(admin_auth)): - app_id = str(body.get("app_id") or "").strip() - channel = str(body.get("channel") or "").strip() - if not app_id or not validate_channel_code(channel): - raise HTTPException(status_code=400, detail="App ID 或渠道无效") - disabled = body.get("disabled_versions") or [] - if not isinstance(disabled, list) or any(not isinstance(v, str) for v in disabled): - raise HTTPException(status_code=400, detail="disabled_versions 必须是版本字符串数组") - valid_until = str(body.get("valid_until") or "").strip() - try: - datetime.fromisoformat(valid_until.replace("Z", "+00:00")) - except ValueError: - raise HTTPException(status_code=400, detail="valid_until 必须是 ISO-8601 时间") - conn = db.get_conn() - require_channel(conn, app_id, channel) - row = conn.execute("SELECT policy_seq FROM version_policies WHERE app_id=? AND channel=?", (app_id, channel)).fetchone() - next_seq = (int(row["policy_seq"]) + 1) if row else 1 - values = (next_seq, int(bool(body.get("force_update"))), int(bool(body.get("allow_rollback"))), - int(bool(body.get("offline_allowed"))), valid_until, - str(body.get("min_supported_version") or "").strip(), - json.dumps(disabled, ensure_ascii=False), str(body.get("message") or "").strip(), app_id, channel) - conn.execute(""" - INSERT INTO version_policies(policy_seq,force_update,allow_rollback,offline_allowed,valid_until, - min_supported_version,disabled_versions,message,app_id,channel) - VALUES(?,?,?,?,?,?,?,?,?,?) - ON CONFLICT(app_id,channel) DO UPDATE SET policy_seq=excluded.policy_seq, - force_update=excluded.force_update,allow_rollback=excluded.allow_rollback, - offline_allowed=excluded.offline_allowed,valid_until=excluded.valid_until, - min_supported_version=excluded.min_supported_version,disabled_versions=excluded.disabled_versions, - message=excluded.message,updated_at=datetime('now') - """, values) - conn.commit(); conn.close() - return {"msg": "策略已保存并递增 policy_seq", "policy_seq": next_seq} - - -# 发布新版本(文件上传) -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)): - 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)): - version_id = int(body.get("version_id") or 0) - conn=db.get_conn();ver=conn.execute("SELECT app_id,channel,version,create_time FROM versions WHERE id=?",(version_id,)).fetchone();rows=conn.execute("SELECT path,sha256,size FROM version_files WHERE version_id=? ORDER BY id",(version_id,)).fetchall();conn.close() - if not ver or not rows: raise HTTPException(status_code=404,detail="版本或版本文件不存在") - files=[{"path":r["path"],"sha256":r["sha256"],"size":r["size"],"executable":is_executable_path(r["path"])} for r in rows] - manifest={"app_id":ver["app_id"],"version":ver["version"],"channel":ver["channel"],"platform":TARGET_PLATFORM,"arch":TARGET_ARCH,"manifest_seq":version_id,"created_at":ver["create_time"],"files":files} - manifest_text=canonical_manifest_bytes(manifest).decode();manifest_signature=sign_manifest(manifest) - offset=0;entries=[] - for r in rows: entries.append({"path":r["path"],"offset":offset,"size":r["size"],"sha256":r["sha256"]});offset+=int(r["size"] or 0) - package={"format":"MUPD0001","app_id":ver["app_id"],"channel":ver["channel"],"version":ver["version"],"version_id":version_id,"created_at":datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00","Z"),"manifest_sha256":hashlib.sha256(manifest_text.encode()).hexdigest(),"payload_size":offset,"files":entries,"signature_alg":"RSA-2048-SHA256","key_id":SIGNING_KEY_ID} - package_text,package_signature=sign_policy(package) - wrapper=json.dumps({"package_text":package_text,"package_signature":package_signature,"manifest_text":manifest_text,"manifest_signature":manifest_signature},ensure_ascii=False,separators=(",",":")).encode() - prefix=f"{ver['app_id']}/{ver['channel']}/{ver['version']}/files/" - def stream(): - yield b"MUPD0001";yield len(wrapper).to_bytes(8,"little");yield wrapper - for r in rows: - yield from minio_tool.iter_file(prefix+r["path"]) - filename=f"{ver['app_id']}_{ver['channel']}_{ver['version']}.upd" - return StreamingResponse(stream(),media_type="application/octet-stream",headers={"Content-Disposition":f'attachment; filename="{filename}"',"Content-Length":str(16+len(wrapper)+offset)}) - -# 获取版本列表 GET -@app.get("/admin/version/list") -def admin_get_version_list(app_id: str, auth=Depends(admin_auth)): - conn = db.get_conn() - rows = conn.execute(""" - SELECT id, version, channel, latest, client_protocol, create_time - FROM versions WHERE app_id=? ORDER BY create_time DESC - """, (app_id,)).fetchall() - conn.close() - out = [] - for r in rows: - out.append({ - "id": r["id"], - "version": r["version"], - "channel": r["channel"], - "latest": bool(r["latest"]), - "client_protocol": int(r["client_protocol"] or 1), - "create_time": r["create_time"] - }) - return {"list": out} - -# 修正历史版本的客户端协议标记 -@app.post("/admin/version/set-protocol") -def admin_set_version_protocol(body: dict, auth=Depends(admin_auth)): - version_id = int(body.get("version_id") or 0) - client_protocol = int(body.get("client_protocol") or 0) - if version_id < 1 or client_protocol < 1: - raise HTTPException(status_code=400, detail="版本 ID 和客户端协议必须是正整数") - conn = db.get_conn() - cur = conn.execute("UPDATE versions SET client_protocol=? WHERE id=?", (client_protocol, version_id)) - if cur.rowcount != 1: - conn.close(); raise HTTPException(status_code=404, detail="版本不存在") - conn.commit(); conn.close() - return {"msg": "客户端协议已更新", "version_id": version_id, "client_protocol": client_protocol} - - -# 设置指定版本为渠道最新 POST json -@app.post("/admin/version/set-latest") -def admin_set_latest(body: dict, auth=Depends(admin_auth)): - vid = body["version_id"] - conn = db.get_conn() - cur = conn.cursor() - v_info = cur.execute("SELECT app_id, channel FROM versions WHERE id=?", (vid,)).fetchone() - aid, ch = v_info["app_id"], v_info["channel"] - cur.execute("UPDATE versions SET latest=0 WHERE app_id=? AND channel=?", (aid, ch)) - cur.execute("UPDATE versions SET latest=1 WHERE id=?", (vid,)) - conn.commit() - conn.close() - return {"msg": "已切换为渠道最新版本"} - -# 删除版本 POST json -@app.post("/admin/version/delete") -def admin_delete_version(body: dict, auth=Depends(admin_auth)): - vid = body["version_id"] - conn = db.get_conn() - cur = conn.cursor() - v_info = cur.execute("SELECT app_id, channel, version, latest FROM versions WHERE id=?", (vid,)).fetchone() - if not v_info: - conn.close() - raise HTTPException(status_code=404, detail="版本不存在") - aid, ch, ver = v_info["app_id"], v_info["channel"], v_info["version"] - was_latest = bool(v_info["latest"]) - # 删除minio下该版本全部文件 - prefix = f"{aid}/{ch}/{ver}/files/" - minio_tool.remove_prefix(prefix) - # 删除数据库记录 - cur.execute("DELETE FROM version_files WHERE version_id=?", (vid,)) - cur.execute("DELETE FROM versions WHERE id=?", (vid,)) - promoted_version = None - if was_latest: - replacement = cur.execute( - "SELECT id, version FROM versions WHERE app_id=? AND channel=? ORDER BY create_time DESC, id DESC LIMIT 1", - (aid, ch) - ).fetchone() - if replacement: - cur.execute("UPDATE versions SET latest=1 WHERE id=?", (replacement["id"],)) - promoted_version = replacement["version"] - conn.commit() - conn.close() - return { - "msg": "版本、云端文件、数据库记录全部删除完成", - "promoted_latest": promoted_version - } - -# 获取升级日志 GET -@app.get("/admin/report/list") -def admin_get_report_log(auth=Depends(admin_auth)): - conn = db.get_conn() - rows = conn.execute(""" - 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"], - "to_version": r["to_ver"], - "result": r["result"], - "create_time": r["create_time"] - }) - 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, - } - - -@app.get("/admin/download-log/list") -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() - try: expiry=datetime.fromisoformat(valid_until.replace("Z","+00:00")) - except ValueError: raise HTTPException(status_code=400,detail="valid_until 必须是 ISO-8601 时间") - if not app_id or not customer or not validate_channel_code(channel) or max_devices<1 or expiry<=datetime.now(timezone.utc): raise HTTPException(status_code=400,detail="授权参数无效") - license_id="lic_"+secrets.token_hex(12);license_key="MARSCO-"+secrets.token_urlsafe(24);conn=db.get_conn();require_channel(conn,app_id,channel);conn.execute("INSERT INTO licenses(license_id,license_key_hash,customer_name,app_id,channel_code,max_devices,valid_until,status) VALUES(?,?,?,?,?,?,?,'active')",(license_id,token_digest(license_key),customer,app_id,channel,max_devices,valid_until));conn.commit();conn.close();return {"license_id":license_id,"license_key":license_key,"msg":"授权已创建;密钥仅本次返回,请立即保存"} - -@app.get("/admin/license/list") -def admin_license_list(app_id: str="", auth=Depends(admin_auth)): - conn=db.get_conn();rows=conn.execute("SELECT l.*,(SELECT COUNT(*) FROM license_devices d WHERE d.license_id=l.license_id) used_devices FROM licenses l WHERE app_id=? ORDER BY created_at DESC",(app_id,)).fetchall() if app_id else conn.execute("SELECT l.*,(SELECT COUNT(*) FROM license_devices d WHERE d.license_id=l.license_id) used_devices FROM licenses l ORDER BY created_at DESC").fetchall();conn.close();return {"list":[{k:r[k] for k in ("license_id","customer_name","app_id","channel_code","max_devices","valid_until","status","created_at")}|{"used_devices":r["used_devices"]} for r in rows]} - -@app.post("/admin/license/set-status") -def admin_license_set_status(body: dict, auth=Depends(admin_auth)): - status=str(body.get("status") or "");license_id=str(body.get("license_id") or ""); - if status not in ("active","disabled"): raise HTTPException(status_code=400,detail="授权状态无效") - conn=db.get_conn();cur=conn.execute("UPDATE licenses SET status=?,updated_at=datetime('now') WHERE license_id=?",(status,license_id));conn.commit();conn.close() - if cur.rowcount!=1: raise HTTPException(status_code=404,detail="授权不存在") - return {"msg":"授权状态已更新"} - -@app.get("/admin/device/list") -def admin_device_list(app_id: str = "", auth=Depends(admin_auth)): - conn=db.get_conn(); rows=conn.execute("SELECT * FROM devices WHERE app_id=? ORDER BY last_seen_at DESC",(app_id,)).fetchall() if app_id else conn.execute("SELECT * FROM devices ORDER BY last_seen_at DESC LIMIT 500").fetchall(); conn.close() - return {"list":[{"device_id":r["device_id"],"app_id":r["app_id"],"installation_id":r["installation_id"],"disabled":bool(r["disabled"]),"disabled_reason":r["disabled_reason"],"credential_seq":r["credential_seq"],"first_seen_at":r["first_seen_at"],"last_seen_at":r["last_seen_at"],"last_ip":r["last_ip"]} for r in rows]} - -@app.post("/admin/device/set-disabled") -def admin_device_set_disabled(body: dict, auth=Depends(admin_auth)): - device_id=str(body.get("device_id") or "").strip(); disabled=bool(body.get("disabled")); reason=str(body.get("reason") or "").strip(); conn=db.get_conn(); cur=conn.execute("UPDATE devices SET disabled=?,disabled_reason=? WHERE device_id=?",(int(disabled),reason if disabled else "",device_id)) - if cur.rowcount != 1: conn.close(); raise HTTPException(status_code=404,detail="设备不存在") - conn.commit(); conn.close(); return {"msg":"设备已禁用" if disabled else "设备已恢复","device_id":device_id} - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT, workers=1) diff --git a/server/minio_tool.py b/server/minio_tool.py deleted file mode 100644 index 6201586..0000000 --- a/server/minio_tool.py +++ /dev/null @@ -1,168 +0,0 @@ -from minio import Minio -from datetime import timedelta -import os -from dotenv import load_dotenv -import io -import socket -import shutil -from pathlib import Path -import urllib3 -from urllib.parse import urlparse -from urllib3.util import Retry, Timeout - -load_dotenv() -BASE_DIR = Path(__file__).resolve().parent - - -def env_bool(name: str, default: bool = False) -> bool: - value = os.getenv(name) - return default if value is None else value.strip().lower() in {"1", "true", "yes", "on"} - -MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT") -MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY") -MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY") -MINIO_BUCKET = os.getenv("MINIO_BUCKET") -SIGN_EXPIRE_MIN = int(os.getenv("SIGN_EXPIRE_MIN") or 60) -LOCAL_UPLOAD_ROOT = Path(os.getenv("LOCAL_UPLOAD_ROOT", "local_uploads")).expanduser() -if not LOCAL_UPLOAD_ROOT.is_absolute(): - LOCAL_UPLOAD_ROOT = BASE_DIR / LOCAL_UPLOAD_ROOT -LOCAL_FILE_URL_BASE = os.getenv("LOCAL_FILE_URL_BASE") -MINIO_SECURE = env_bool("MINIO_SECURE") -MINIO_CONNECT_TIMEOUT_SEC = float(os.getenv("MINIO_CONNECT_TIMEOUT_SEC", "2")) -MINIO_READ_TIMEOUT_SEC = float(os.getenv("MINIO_READ_TIMEOUT_SEC", "5")) -MINIO_RETRY_TOTAL = int(os.getenv("MINIO_RETRY_TOTAL", "1")) -LOCAL_UPLOAD_ROOT.mkdir(parents=True, exist_ok=True) - -http_client = urllib3.PoolManager( - timeout=Timeout(connect=MINIO_CONNECT_TIMEOUT_SEC, read=MINIO_READ_TIMEOUT_SEC), - retries=Retry(total=MINIO_RETRY_TOTAL, connect=MINIO_RETRY_TOTAL, - read=MINIO_RETRY_TOTAL, status=MINIO_RETRY_TOTAL, backoff_factor=0.2), -) - -mc = Minio( - MINIO_ENDPOINT, - access_key=MINIO_ACCESS_KEY, - secret_key=MINIO_SECRET_KEY, - secure=MINIO_SECURE, - http_client=http_client, -) - -public_endpoint_raw = os.getenv("MINIO_PUBLIC_ENDPOINT", "").strip() -if public_endpoint_raw: - parsed_public_endpoint = urlparse( - public_endpoint_raw if "://" in public_endpoint_raw else f"http://{public_endpoint_raw}" - ) - public_mc = Minio( - parsed_public_endpoint.netloc, - access_key=MINIO_ACCESS_KEY, - secret_key=MINIO_SECRET_KEY, - secure=parsed_public_endpoint.scheme == "https", - ) -else: - public_mc = mc - - -def get_url(file_path: str) -> str: - bucket = MINIO_BUCKET - full_object_path = file_path - try: - mc.stat_object(bucket, full_object_path) - url = public_mc.presigned_get_object( - bucket, full_object_path, expires=timedelta(minutes=SIGN_EXPIRE_MIN) - ) - return url - except Exception as err: - print(f"generate url error: {err}") - local_path = LOCAL_UPLOAD_ROOT / file_path - if local_path.exists(): - if LOCAL_FILE_URL_BASE: - return LOCAL_FILE_URL_BASE.rstrip('/') + '/' + file_path - host = os.getenv('SERVER_HOST') or '127.0.0.1' - if host == '0.0.0.0': - try: - host = socket.gethostbyname(socket.gethostname()) - except Exception: - host = '127.0.0.1' - port = os.getenv('SERVER_PORT') or '8000' - return f"http://{host}:{port}/static/{file_path}" - scheme = "https" if MINIO_SECURE else "http" - return f"{scheme}://{MINIO_ENDPOINT}/{bucket}/{full_object_path}" - - -def put_file(object_path: str, data: bytes, size: int): - bucket = MINIO_BUCKET - try: - bio = io.BytesIO(data) - mc.put_object(bucket, object_path, bio, length=size) - return {'storage': 'minio', 'path': object_path} - except Exception as e: - print(f"minio put_file error: {e}") - local_path = LOCAL_UPLOAD_ROOT / object_path - local_path.parent.mkdir(parents=True, exist_ok=True) - with open(local_path, 'wb') as f: - f.write(data) - print(f"local fallback stored {local_path}") - 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: - objects = mc.list_objects(bucket, prefix=prefix, recursive=True) - for obj in objects: - try: - mc.remove_object(bucket, obj.object_name) - except Exception as e: - print(f"failed remove object {obj.object_name}: {e}") - except Exception as e: - print(f"minio remove_prefix error: {e}") - local_prefix = LOCAL_UPLOAD_ROOT / prefix - if local_prefix.exists(): - try: - shutil.rmtree(local_prefix) - except Exception as e: - print(f"local remove_prefix error: {e}") - -def iter_file(object_path: str, chunk_size: int = 1024 * 1024): - """Stream an object without creating a second full local copy.""" - response = None - emitted = 0 - try: - response = mc.get_object(MINIO_BUCKET, object_path) - while True: - chunk = response.read(chunk_size) - if not chunk: break - emitted += len(chunk) - yield chunk - return - except Exception as err: - if emitted: - raise RuntimeError(f"MinIO stream interrupted after {emitted} bytes: {object_path}") from err - print(f"MinIO stream fallback for {object_path}: {err}") - finally: - if response is not None: - response.close(); response.release_conn() - local_path = LOCAL_UPLOAD_ROOT / object_path - with open(local_path, "rb") as source: - while True: - chunk = source.read(chunk_size) - if not chunk: break - yield chunk diff --git a/server/package-offline-server.sh b/server/package-offline-server.sh deleted file mode 100755 index 73e7ee9..0000000 --- a/server/package-offline-server.sh +++ /dev/null @@ -1,511 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -VERSION="0.1.0" -OUTPUT_DIR="" -SKIP_BUILD=0 -SKIP_PULL=0 - -usage() { - cat <<'EOF' -Usage: ./package-offline-server.sh [options] - -Options: - --version VERSION Image/package version, default: 0.1.0 - --output-dir DIR Output package directory, default: server/dist/SimCAEServerDockerPackage - --skip-build Do not build simcae-update-server image - --skip-pull Do not pull MinIO images - -h, --help Show this help -EOF -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --version) - VERSION="${2:?--version requires a value}" - shift 2 - ;; - --output-dir) - OUTPUT_DIR="${2:?--output-dir requires a value}" - shift 2 - ;; - --skip-build) - SKIP_BUILD=1 - shift - ;; - --skip-pull) - SKIP_PULL=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unknown option: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -require_file() { - local path="$1" - local message="$2" - if [[ ! -e "$path" ]]; then - echo "$message" >&2 - exit 1 - fi -} - -require_cmd() { - local name="$1" - if ! command -v "$name" >/dev/null 2>&1; then - echo "Required command is missing: $name" >&2 - exit 1 - fi -} - -require_cmd docker -require_cmd tar - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -if [[ -z "$OUTPUT_DIR" ]]; then - OUTPUT_DIR="$SCRIPT_DIR/dist/SimCAEServerDockerPackage" -fi - -IMAGE_NAME="simcae-update-server:$VERSION" -MINIO_IMAGE="minio/minio:RELEASE.2025-04-22T22-12-26Z" -MC_IMAGE="minio/mc:RELEASE.2025-04-16T18-13-26Z" -PACKAGE_DIR="$OUTPUT_DIR" -IMAGES_DIR="$PACKAGE_DIR/images" -KEYS_DIR="$PACKAGE_DIR/keys" -TAR_FILE="$IMAGES_DIR/simcae-server-all-images_$VERSION.tar" -ARCHIVE_FILE="$PACKAGE_DIR.tar.gz" - -require_file "$SCRIPT_DIR/Dockerfile" "server/Dockerfile is missing." -require_file "$SCRIPT_DIR/docker-compose.image.yml" "server/docker-compose.image.yml is missing." -require_file "$SCRIPT_DIR/.env.docker.example" "server/.env.docker.example is missing." -require_file "$SCRIPT_DIR/keys/manifest_private_key.pem" "server/keys/manifest_private_key.pem is missing." -require_file "$SCRIPT_DIR/keys/manifest_public_key.pem" "server/keys/manifest_public_key.pem is missing." - -if [[ "$SKIP_BUILD" -eq 0 ]]; then - docker build -t "$IMAGE_NAME" -f "$SCRIPT_DIR/Dockerfile" "$PROJECT_ROOT" -fi - -if [[ "$SKIP_PULL" -eq 0 ]]; then - docker pull "$MINIO_IMAGE" - docker pull "$MC_IMAGE" -fi - -rm -rf "$PACKAGE_DIR" -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 部署包说明 - -这个包用于把 SimCAE 自动升级服务端部署到你的服务器。包里已经包含后端 API、MinIO 对象存储、初始化工具、配置模板、签名密钥和 Docker 编排文件;你的服务器即使不能联网,也可以按本文完成部署。 - -如果你只想先跑起来,按“二、最小部署流程”执行即可。后面的配置说明用于解释每个字段是什么意思、哪些必须改、哪些保持默认也能运行。 - -## 一、这个包里面有什么 - -解压后目录结构如下: - -```text -SimCAEServerDockerPackage/ - README.md 当前说明文档 - docker-compose.yml 容器编排文件 - .env.example 环境变量模板 - load-images.sh 导入 Docker 镜像并创建 .env 的脚本 - images/ - 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 服务器上,然后执行: - -```bash -tar -xzf SimCAEServerDockerPackage.tar.gz -cd SimCAEServerDockerPackage -pwd -ls -``` - -你需要确认当前目录就是解压后的 `SimCAEServerDockerPackage` 目录,并且能看到: - -```text -docker-compose.yml -.env.example -load-images.sh -images/ -keys/ -``` - -后面所有 `docker compose` 命令都要在这个目录执行,因为 `docker-compose.yml` 和 `.env` 都在这里。 - -继续在 `SimCAEServerDockerPackage` 目录执行: - -```bash -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`: - -```bash -nano .env -``` - -最少只需要把 `MINIO_PUBLIC_ENDPOINT` 改成 Windows 客户端能访问到的服务器地址: - -```text -MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000 -``` - -例如服务器 IP 是 `192.168.229.128`: - -```text -MINIO_PUBLIC_ENDPOINT=http://192.168.229.128:9000 -``` - -启动服务: - -```bash -docker compose up -d -``` - -查看状态和后端日志: - -```bash -docker compose ps -docker compose logs -f api -``` - -浏览器访问: - -```text -后台/API: http://你的服务器IP:8000/ -MinIO 控制台: http://你的服务器IP:9001/ -``` - -如果服务器开启防火墙,至少放行: - -```text -8000 后台/API -9000 客户端下载升级文件 -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' -from pathlib import Path -import sys -path = Path(sys.argv[1]) -version = sys.argv[2] -text = path.read_text(encoding='utf-8').replace('__VERSION__', version) -path.write_text(text, encoding='utf-8') -PYREADME - -cat > "$PACKAGE_DIR/load-images.sh" </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" - -python3 - "$PACKAGE_DIR/.env.example" "$IMAGE_NAME" <<'PYENV' -from pathlib import Path -import sys -path = Path(sys.argv[1]) -image = sys.argv[2] -text = path.read_text(encoding='utf-8') -text = text.replace('SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0', f'SIMCAE_UPDATE_SERVER_IMAGE={image}') -path.write_text(text, encoding='utf-8') -PYENV - -docker save -o "$TAR_FILE" "$IMAGE_NAME" "$MINIO_IMAGE" "$MC_IMAGE" - -rm -f "$ARCHIVE_FILE" -tar -czf "$ARCHIVE_FILE" -C "$(dirname "$PACKAGE_DIR")" "$(basename "$PACKAGE_DIR")" - -echo "Offline package directory: $PACKAGE_DIR" -echo "Offline package archive: $ARCHIVE_FILE" -echo "Images tar: $TAR_FILE" diff --git a/server/requirements.txt b/server/requirements.txt deleted file mode 100644 index 1025912..0000000 --- a/server/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -cryptography==49.0.0 -fastapi==0.138.0 -minio==7.2.20 -python-dotenv==1.2.2 -python-multipart==0.0.32 -uvicorn==0.49.0 diff --git a/server/tables.sql b/server/tables.sql deleted file mode 100644 index 3d2d80c..0000000 --- a/server/tables.sql +++ /dev/null @@ -1,187 +0,0 @@ --- 1.软件应用表:管理所有接入自动更新系统的软件 -CREATE TABLE IF NOT EXISTS apps ( - id INTEGER PRIMARY KEY AUTOINCREMENT, -- 自增主键ID - app_id TEXT NOT NULL UNIQUE, -- 软件唯一标识(客户端用来区分不同软件) - app_name TEXT NOT NULL -- 软件展示名称 -); - --- 2.软件版本表:存储每个软件各个渠道下的所有版本号 -CREATE TABLE IF NOT EXISTS versions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, -- 版本自增主键version_id,关联文件表 - app_id TEXT NOT NULL, -- 关联apps表的软件唯一标识 - channel TEXT DEFAULT 'stable', -- 更新渠道:stable正式稳定版 / beta测试版 - version TEXT NOT NULL, -- 版本号,如1.0.0、1.0.2 - latest INTEGER DEFAULT 0, -- 是否为当前渠道最新版本:1=是最新,0=历史旧版 - client_protocol INTEGER NOT NULL DEFAULT 1, -- 该版本客户端支持的更新/策略协议版本 - create_time TEXT DEFAULT (datetime('now')), -- 新增:版本创建时间 - UNIQUE(app_id, channel, version) -- 联合唯一约束:同一个软件+渠道不能重复存在相同版本 -); - --- 3.版本关联文件表:记录每个版本需要更新的全部文件信息(exe、dll、资源文件) -CREATE TABLE IF NOT EXISTS version_files ( - id INTEGER PRIMARY KEY AUTOINCREMENT, -- 文件记录自增ID - version_id INTEGER NOT NULL, -- 关联versions表的版本主键id - path TEXT NOT NULL, -- 文件在MinIO存储桶内的完整路径 - sha256 TEXT NOT NULL, -- 文件哈希值,客户端下载后做完整性/防篡改校验 - size INTEGER -- 文件字节大小,用于计算下载进度、校验磁盘空间 -); - --- 4.升级日志记录表:存储所有客户端上报的升级结果,用于后台统计排查问题 -CREATE TABLE IF NOT EXISTS upgrade_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, -- 日志自增主键 - device_id TEXT, -- 用户设备唯一标识,区分不同电脑客户端 - from_ver TEXT, -- 升级前本地旧版本号 - to_ver TEXT, -- 升级目标新版本号 - result TEXT, -- 升级结果:success成功 / fail失败 - create_time TEXT -- 升级上报时间 -); - ---5.日志数据表 -CREATE TABLE IF NOT EXISTS update_report ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - device_id TEXT NOT NULL, - app_id TEXT NOT NULL, - from_version TEXT NOT NULL, - to_version TEXT NOT NULL, - result TEXT NOT NULL, - create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- 6. 每个应用/渠道的运行与升级策略;每次保存必须递增 policy_seq -CREATE TABLE IF NOT EXISTS version_policies ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - app_id TEXT NOT NULL, - channel TEXT NOT NULL DEFAULT 'stable', - policy_seq INTEGER NOT NULL DEFAULT 1, - force_update INTEGER NOT NULL DEFAULT 0, - allow_rollback INTEGER NOT NULL DEFAULT 0, - offline_allowed INTEGER NOT NULL DEFAULT 1, - valid_until TEXT NOT NULL, - min_supported_version TEXT NOT NULL DEFAULT '', - disabled_versions TEXT NOT NULL DEFAULT '[]', - message TEXT NOT NULL DEFAULT '', - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')), - UNIQUE(app_id, channel) -); - - --- 7. RSA 签发的客户端设备身份 -CREATE TABLE IF NOT EXISTS devices ( - id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL UNIQUE, app_id TEXT NOT NULL, - installation_id TEXT NOT NULL, machine_hash TEXT NOT NULL DEFAULT '', credential_seq INTEGER NOT NULL DEFAULT 1, - disabled INTEGER NOT NULL DEFAULT 0, disabled_reason TEXT NOT NULL DEFAULT '', - first_seen_at TEXT DEFAULT (datetime('now')), last_seen_at TEXT DEFAULT (datetime('now')), last_ip TEXT NOT NULL DEFAULT '', - UNIQUE(app_id, installation_id) -); - - --- 8. 软件授权及其设备占用关系 -CREATE TABLE IF NOT EXISTS licenses ( - id INTEGER PRIMARY KEY AUTOINCREMENT, license_id TEXT NOT NULL UNIQUE, license_key_hash TEXT NOT NULL UNIQUE, - customer_name TEXT NOT NULL, app_id TEXT NOT NULL, channel_code TEXT NOT NULL DEFAULT 'stable', - max_devices INTEGER NOT NULL DEFAULT 1, valid_until TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', - created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) -); -CREATE TABLE IF NOT EXISTS license_devices ( - license_id TEXT NOT NULL, device_id TEXT NOT NULL UNIQUE, bound_at TEXT DEFAULT (datetime('now')), - PRIMARY KEY(license_id,device_id) -); - - --- 9. 每个应用独立维护的动态发布渠道 -CREATE TABLE IF NOT EXISTS channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL, channel_code TEXT NOT NULL, - display_name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 100, - created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), - UNIQUE(app_id, channel_code) -); - - --- 10. 文件下载授权与客户端完成结果 -CREATE TABLE IF NOT EXISTS download_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, license_id TEXT NOT NULL, - app_id TEXT NOT NULL, version TEXT NOT NULL, channel_code TEXT NOT NULL, file_path TEXT NOT NULL, - file_size INTEGER NOT NULL DEFAULT 0, result TEXT NOT NULL, ip TEXT NOT NULL DEFAULT '', - user_agent TEXT NOT NULL DEFAULT '', created_at TEXT DEFAULT (datetime('now')) -); -CREATE INDEX IF NOT EXISTS idx_download_logs_created ON download_logs(created_at); -CREATE INDEX IF NOT EXISTS idx_download_logs_device ON download_logs(device_id,created_at); - --- 11. 管理后台写操作审计 -CREATE TABLE IF NOT EXISTS admin_audit_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, actor_hash TEXT NOT NULL, action TEXT NOT NULL, - method TEXT NOT NULL, path TEXT NOT NULL, target TEXT NOT NULL DEFAULT '', result TEXT NOT NULL, - status_code INTEGER NOT NULL, ip TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '', - created_at TEXT DEFAULT (datetime('now')) -); -CREATE INDEX IF NOT EXISTS idx_admin_audit_created ON admin_audit_logs(created_at); - - --- 12. SimCAE 崩溃报告原始数据索引 -CREATE TABLE IF NOT EXISTS crash_reports ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - report_id TEXT NOT NULL UNIQUE, - client_report_id TEXT NOT NULL UNIQUE, - content_hash TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'stored', - symbolication_status TEXT NOT NULL DEFAULT 'not_started', - product TEXT NOT NULL, - app_version TEXT NOT NULL, - git_commit TEXT NOT NULL, - build_type TEXT NOT NULL, - channel TEXT NOT NULL, - exception_code TEXT NOT NULL DEFAULT '', - crash_time_utc TEXT NOT NULL, - received_at_utc TEXT NOT NULL, - remote_address TEXT NOT NULL DEFAULT '', - content_length INTEGER NOT NULL DEFAULT 0, - metadata_sha256 TEXT NOT NULL, - metadata_size INTEGER NOT NULL DEFAULT 0, - minidump_sha256 TEXT NOT NULL, - minidump_size INTEGER NOT NULL DEFAULT 0, - attachments_sha256 TEXT NOT NULL DEFAULT '', - attachments_size INTEGER NOT NULL DEFAULT 0, - storage_path TEXT NOT NULL, - metadata_json TEXT NOT NULL, - server_json TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_crash_reports_build ON crash_reports(product,app_version,git_commit); -CREATE INDEX IF NOT EXISTS idx_crash_reports_exception ON crash_reports(exception_code); -CREATE INDEX IF NOT EXISTS idx_crash_reports_received ON crash_reports(received_at_utc); - --- 13. SimCAE 符号包上传索引 -CREATE TABLE IF NOT EXISTS crash_symbol_uploads ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - symbol_upload_id TEXT NOT NULL UNIQUE, - identity_hash TEXT NOT NULL UNIQUE, - status TEXT NOT NULL DEFAULT 'stored', - product TEXT NOT NULL, - app_version TEXT NOT NULL, - git_commit TEXT NOT NULL, - build_type TEXT NOT NULL, - platform TEXT NOT NULL, - toolchain TEXT NOT NULL, - created_at_utc TEXT NOT NULL, - received_at_utc TEXT NOT NULL, - metadata_sha256 TEXT NOT NULL, - metadata_size INTEGER NOT NULL DEFAULT 0, - symbols_sha256 TEXT NOT NULL, - symbols_size INTEGER NOT NULL DEFAULT 0, - storage_path TEXT NOT NULL, - metadata_json TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_crash_symbols_build ON crash_symbol_uploads(product,app_version,git_commit,build_type,platform); -CREATE INDEX IF NOT EXISTS idx_crash_symbols_received ON crash_symbol_uploads(received_at_utc); - --- 14. 崩溃原始文件访问审计 -CREATE TABLE IF NOT EXISTS crash_file_access_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - report_id TEXT NOT NULL, - file_name TEXT NOT NULL, - actor_hash TEXT NOT NULL, - ip TEXT NOT NULL DEFAULT '', - user_agent TEXT NOT NULL DEFAULT '', - created_at TEXT DEFAULT (datetime('now')) -); -CREATE INDEX IF NOT EXISTS idx_crash_file_access_report ON crash_file_access_logs(report_id,created_at); diff --git a/server/服务端Docker镜像交付说明.md b/server/服务端Docker镜像交付说明.md deleted file mode 100644 index 119ab46..0000000 --- a/server/服务端Docker镜像交付说明.md +++ /dev/null @@ -1,196 +0,0 @@ -# 服务端 Docker 镜像交付说明 - -这份说明用于把 SimCAE 自动升级/崩溃上报服务端交付给开发组或部署人员。 - -## 1. 要不要给开发组发 Docker 镜像 - -分两种情况: - -- 如果开发组只负责接入客户端 SDK,通常不需要给他们镜像,只需要给一个已经部署好的测试服务地址、token、License 和 SDK 文档。 -- 如果开发组需要本地完整联调,建议给他们一个服务端 Docker 部署包。部署包不要只有一个镜像,还要包含 docker-compose.image.yml、.env 示例、keys 目录说明。 - -## 2. 服务端包含哪些容器 - -当前服务端不是单容器,而是三个服务: - -- api:FastAPI 服务,提供升级接口、后台页面、Manifest 签名、崩溃上报接口。 -- minio:对象存储,用于保存版本文件。 -- minio-init:启动时自动创建 MinIO bucket。 - -所以交付时推荐给 Docker Compose 部署包。 - -## 3. 构建服务端镜像 - -在项目根目录执行,注意 Dockerfile 的构建上下文必须是项目根目录,因为镜像会复制 server 代码和 client/admin.html。 - -```powershell -cd C:\Users\admin\Desktop - -docker build ` - -t simcae-update-server:0.1.0 ` - -f server\Dockerfile ` - . -``` - -验证镜像: - -```powershell -docker images simcae-update-server -``` - -## 4. 导出镜像给别人 - -如果接收方机器可以访问 Docker Hub,只导出 api 镜像即可: - -```powershell -docker save ` - -o simcae-update-server_0.1.0.tar ` - simcae-update-server:0.1.0 -``` - -如果接收方机器不能联网,建议把 api、minio、minio-init 使用的三个镜像一起导出: - -```powershell -docker pull minio/minio:RELEASE.2025-04-22T22-12-26Z -docker pull minio/mc:RELEASE.2025-04-16T18-13-26Z - -docker save ` - -o simcae-server-all-images_0.1.0.tar ` - simcae-update-server:0.1.0 ` - minio/minio:RELEASE.2025-04-22T22-12-26Z ` - minio/mc:RELEASE.2025-04-16T18-13-26Z -``` - -建议交付目录: - -```text -SimCAEServerDockerPackage/ - simcae-update-server_0.1.0.tar - docker-compose.image.yml - .env.example - keys/ - manifest_private_key.pem - README.md -``` - -注意:manifest_private_key.pem 是服务端签 Manifest 的私钥。客户端 SDK 中的 manifest_public_key.pem 必须和它匹配。 - - -## 4.1 推荐:在 Ubuntu 上使用离线打包脚本 - -如果接收方服务器不能联网,推荐直接使用 Ubuntu/bash 脚本生成完整离线包。脚本会完成:构建 api 镜像、拉取 MinIO 镜像、导出三张镜像、复制 docker-compose.yml、复制 .env.example、复制 keys、生成快速启动说明,并压缩成 tar.gz。 - -在当前 Ubuntu 机器上执行: - -```bash -cd /path/to/project/server - -bash ./package-offline-server.sh \ - --version 0.1.0 \ - --output-dir ./dist/SimCAEServerDockerPackage -``` - -生成结果: - -```text -server/dist/SimCAEServerDockerPackage/ - images/ - simcae-server-all-images_0.1.0.tar - keys/ - manifest_private_key.pem - manifest_public_key.pem - docker-compose.yml - .env.example - README.md - QUICK_START.md - load-images.sh - -server/dist/SimCAEServerDockerPackage.tar.gz -``` - -把 `SimCAEServerDockerPackage.tar.gz` 拷贝到离线服务器即可。 - -如果是在 Windows 机器上打包,也可以使用 `package-offline-server.ps1`,但 Ubuntu 服务端主流程建议使用 `package-offline-server.sh`。 - -## 5. 离线 Ubuntu 服务器如何运行 - -接收方服务器需要提前安装好 Docker Engine 和 Docker Compose plugin。离线包拷过去后执行: - -```bash -tar -xzf SimCAEServerDockerPackage.tar.gz -cd SimCAEServerDockerPackage - -bash ./load-images.sh -nano .env -``` - -`.env.example` 里已经填好一组可直接试跑的默认 token 和 MinIO 账号。你通常只需要先改服务器地址: - -```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`。 - -启动: - -```bash -docker compose up -d -``` - -查看状态: - -```bash -docker compose ps -``` - -查看日志: - -```bash -docker compose logs -f api -``` - -访问: - -```text -后台/API: http://服务器IP:8000/ -MinIO 控制台: http://服务器IP:9001/ -``` - -如果服务器开启了防火墙,至少放行: - -```text -8000 后台/API -9000 客户端下载升级文件 -9001 MinIO 控制台,可按需限制访问 -``` - -## 6. 客户端 SDK 需要哪些服务端信息 - -发给客户端开发组的信息通常是: - -```text -api_base_url=http://服务器IP:8000 -client_token=CLIENT_API_TOKEN 的值 -license_key=后台创建的 License -manifest_public_key.pem=与服务端 manifest_private_key.pem 匹配的公钥 -crash_report_token=CRASH_REPORT_TOKEN 的值,如果接入崩溃上报 -``` - -## 7. 生产部署提醒 - -- 不要把生产私钥和 token 发到无关人员手里。 -- 正式环境需要备份 runtime、minio_data、keys。 -- 对外开放端口至少包括 8000 和 9000;9001 是 MinIO 控制台,生产环境可限制访问。 -- 修改服务端签名私钥后,必须重新生成客户端 manifest_public_key.pem,并重新打 SDK/客户端包。