commit cd3dca042d04eac03b4850b213f2dbba3f42f47a Author: nikelaluo <2135665716@qq.com> Date: Wed Jul 1 03:32:41 2026 +0000 1.0.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6095619 --- /dev/null +++ b/.gitignore @@ -0,0 +1,82 @@ +# Operating systems and editors +.DS_Store +Thumbs.db +Desktop.ini +.vscode/ +.idea/ +**/.vs/ +*.suo +*.user +*.userosscache +*.sln.docstates + +# Generic temporary files +*.tmp +*.temp +*.bak +*.swp +*.log + +# Client: CMake / Visual Studio build outputs +client/out/ +client/build/ +client/build-*/ +client/cmake-build-*/ +client/.cmake/ +client/CMakeSettings.json +client/CMakeUserPresets.json +client/Testing/ + +# Client: local runtime state and credentials +client/config/app_config.json +client/config/client_identity.dat +client/config/*.local.json +client/update/ +client/update_temp/ + +# C/C++ generated artifacts outside build directories +*.obj +*.o +*.pdb +*.ilk +*.idb +*.tlog +*.lastbuildstate +*.exp +*.lib +*.dll +*.exe + +# Keep source-controlled PEM public keys; never commit private keys +server/keys/*private*.pem +server/keys/*private*.key + +# Server: Python environments and caches +server/venv/ +server/.venv/ +server/__pycache__/ +server/**/*.py[cod] +server/.pytest_cache/ +server/.mypy_cache/ +server/.ruff_cache/ +server/.coverage +server/htmlcov/ + +# Server: local configuration and secrets +server/.env +server/.env.* +!server/.env.example +server/admin_token.sha256 + +# Server: runtime databases, uploads and object storage +server/*.db +server/*.db-journal +server/*.db-wal +server/*.db-shm +server/local_uploads/ +server/minio_data/ +server/minio + +# Server: runtime files +server/*.pid +server/*.log diff --git a/client/.gitkeep b/client/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/client/Bootstrap/CMakeLists.txt b/client/Bootstrap/CMakeLists.txt new file mode 100644 index 0000000..7b8e31c --- /dev/null +++ b/client/Bootstrap/CMakeLists.txt @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..592d6b6 --- /dev/null +++ b/client/Bootstrap/main.cpp @@ -0,0 +1,191 @@ +#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 new file mode 100644 index 0000000..3ee01df --- /dev/null +++ b/client/CMakeLists.txt @@ -0,0 +1,70 @@ +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 new file mode 100644 index 0000000..3995e31 --- /dev/null +++ b/client/Common/CMakeLists.txt @@ -0,0 +1,32 @@ +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 +) +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 new file mode 100644 index 0000000..7c6f9ad --- /dev/null +++ b/client/Common/ConfigHelper.cpp @@ -0,0 +1,116 @@ +#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::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); + QFile input(m_configPath); + QJsonObject config; + if (input.exists()) + { + if (!input.open(QIODevice::ReadOnly)) + 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()) + return false; + config = document.object(); + } + + config.insert(key, value); + QDir().mkpath(QFileInfo(m_configPath).path()); + QSaveFile output(m_configPath); + if (!output.open(QIODevice::WriteOnly)) + return false; + const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented); + if (output.write(payload) != payload.size()) + { + output.cancelWriting(); + return false; + } + return output.commit(); +} + +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", "launch_token"); + 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"); + 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 new file mode 100644 index 0000000..86a40d5 --- /dev/null +++ b/client/Common/ConfigHelper.h @@ -0,0 +1,16 @@ +#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; + +private: + ConfigHelper(); + bool migrateLegacyIniIfNeeded(); + QString m_configPath; +}; diff --git a/client/Common/FileHelper.cpp b/client/Common/FileHelper.cpp new file mode 100644 index 0000000..5aaf239 --- /dev/null +++ b/client/Common/FileHelper.cpp @@ -0,0 +1,42 @@ +#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 new file mode 100644 index 0000000..828bbcf --- /dev/null +++ b/client/Common/FileHelper.h @@ -0,0 +1,14 @@ +#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 new file mode 100644 index 0000000..49fdcc3 --- /dev/null +++ b/client/Common/HttpHelper.cpp @@ -0,0 +1,49 @@ +#include "HttpHelper.h" +#include +#include "ConfigHelper.h" + +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()); + + QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact); + qDebug() << "=== POST Request ==="; + qDebug() << "Url:" << url; + qDebug() << "Body:" << data; + + QNetworkReply* reply = manager->post(req, data); + QEventLoop loop; + + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + int retCode = 0; + QJsonObject retObj; + + if (reply->error() != QNetworkReply::NoError) + { + qDebug() << "Network error code:" << reply->error(); + qDebug() << "Error detail:" << reply->errorString(); + } + else + { + retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + QByteArray respData = reply->readAll(); + qDebug() << "Server raw response:" << respData; + QJsonDocument doc = QJsonDocument::fromJson(respData); + retObj = doc.object(); + } + + 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 new file mode 100644 index 0000000..3bef462 --- /dev/null +++ b/client/Common/HttpHelper.h @@ -0,0 +1,17 @@ +#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/LocalStateHelper.cpp b/client/Common/LocalStateHelper.cpp new file mode 100644 index 0000000..330e8a8 --- /dev/null +++ b/client/Common/LocalStateHelper.cpp @@ -0,0 +1,147 @@ +#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 new file mode 100644 index 0000000..dd166c5 --- /dev/null +++ b/client/Common/LocalStateHelper.h @@ -0,0 +1,33 @@ +#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 new file mode 100644 index 0000000..febed97 --- /dev/null +++ b/client/Common/PolicyHelper.cpp @@ -0,0 +1,115 @@ +#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::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 new file mode 100644 index 0000000..2a3fccb --- /dev/null +++ b/client/Common/PolicyHelper.h @@ -0,0 +1,32 @@ +#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 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/Launcher/CMakeLists.txt b/client/Launcher/CMakeLists.txt new file mode 100644 index 0000000..601eaf9 --- /dev/null +++ b/client/Launcher/CMakeLists.txt @@ -0,0 +1,46 @@ +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 $ + COMMENT "自动部署Qt依赖dll" + ) +endif() \ No newline at end of file diff --git a/client/Launcher/UpdateLogic.cpp b/client/Launcher/UpdateLogic.cpp new file mode 100644 index 0000000..ef88bd9 --- /dev/null +++ b/client/Launcher/UpdateLogic.cpp @@ -0,0 +1,114 @@ +#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; + + 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["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 new file mode 100644 index 0000000..3f2ea84 --- /dev/null +++ b/client/Launcher/UpdateLogic.h @@ -0,0 +1,38 @@ +#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 new file mode 100644 index 0000000..0048b2d --- /dev/null +++ b/client/Launcher/main.cpp @@ -0,0 +1,143 @@ +#include +#include +#include +#include +#include +#include +#include +#include "UpdateLogic.h" +#include "../Common/ConfigHelper.h" +#include "../Common/PolicyHelper.h" +#include "../Common/LocalStateHelper.h" + +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(); + + 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(); + + ConfigHelper& config = ConfigHelper::instance(); + const QString launchToken = config.getValue("App", "launch_token"); + const QString currentVersion = config.getValue("App", "current_version"); + const QString appDir = QApplication::applicationDirPath(); + const QString mainAppPath = appDir + "/MainApp.exe"; + const QString updaterPath = appDir + "/Updater.exe"; + const QStringList mainArgs{QString("--launcher-token=%1").arg(launchToken)}; + + 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 (!QProcess::startDetached(mainAppPath, mainArgs)) { + 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 && !policy.isOfflineAllowed()) + { + progress.close(); + QMessageBox::critical(nullptr, "网络不可用", "无法连接更新服务器,且当前策略不允许离线启动。"); + return -1; + } + + progress.setLabelText(networkOk ? "当前已是最新版本,正在启动..." : "当前处于离线模式,正在启动..."); + QApplication::processEvents(); + if (!QProcess::startDetached(mainAppPath, mainArgs)) + { + 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 new file mode 100644 index 0000000..7874908 --- /dev/null +++ b/client/MainApp/CMakeLists.txt @@ -0,0 +1,21 @@ +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 $ + ) +endif() \ No newline at end of file diff --git a/client/MainApp/MainWindow.cpp b/client/MainApp/MainWindow.cpp new file mode 100644 index 0000000..77f4c58 --- /dev/null +++ b/client/MainApp/MainWindow.cpp @@ -0,0 +1,72 @@ +#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 new file mode 100644 index 0000000..0c57cc2 --- /dev/null +++ b/client/MainApp/MainWindow.h @@ -0,0 +1,11 @@ +#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 new file mode 100644 index 0000000..ba2551a --- /dev/null +++ b/client/MainApp/main.cpp @@ -0,0 +1,110 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "MainWindow.h" +#include "../Common/ConfigHelper.h" +#include "../Common/PolicyHelper.h" +#include "../Common/LocalStateHelper.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; + + const QString validToken = ConfigHelper::instance().getValue("App", "launch_token"); + bool pass = false; + QString healthFilePath; + + for (int i = 0; i < argc; ++i) + { + QString arg(argv[i]); + if (arg.startsWith("--launcher-token=")) + { + QString tk = arg.split("=").last(); + if (tk == validToken) + { + pass = true; + } + } + else if (arg.startsWith("--health-file=")) + { + healthFilePath = arg.mid(QString("--health-file=").size()); + } + } + + + if (!pass) + { + QMessageBox::critical(nullptr, "Startup Restriction", "Direct double-clicking MainApp.exe is prohibited. Please use Launcher.exe to open the software!"); + return -1; + } + + QString appDir = QApplication::applicationDirPath(); + 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; + } + + 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(); +} \ No newline at end of file diff --git a/client/READMe.txt b/client/READMe.txt new file mode 100644 index 0000000..8b03d6b --- /dev/null +++ b/client/READMe.txt @@ -0,0 +1,3 @@ +client目录下的CMakeList.txt用来统一管理client下的Launcher工程和MainApp工程和Updater工程 + +cofig.ini要跟exe同级目录 \ No newline at end of file diff --git a/client/Updater/CMakeLists.txt b/client/Updater/CMakeLists.txt new file mode 100644 index 0000000..ba0221e --- /dev/null +++ b/client/Updater/CMakeLists.txt @@ -0,0 +1,41 @@ +# ǿб롢ʱɾʹ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() + +# ؼUpdaterOpenSSLͷļĿ¼#include openssl/*Ҳ +target_include_directories(Updater PRIVATE ${OPENSSL_INC}) + +# CommonQtOpenSSLCommon 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 $ + ) +endif() \ No newline at end of file diff --git a/client/Updater/UpdateTransaction.cpp b/client/Updater/UpdateTransaction.cpp new file mode 100644 index 0000000..cfed0fa --- /dev/null +++ b/client/Updater/UpdateTransaction.cpp @@ -0,0 +1,269 @@ +#include "UpdateTransaction.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +UpdateTransaction::UpdateTransaction(const QString& installDir, const QString& fromVersion, + const QString& toVersion, int manifestId) + : m_installDir(QDir::cleanPath(installDir)), + m_updateDir(QDir(installDir).filePath("update")), + m_stateFile(QDir(installDir).filePath("update/upgrade_state.json")), + m_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, QString* restoredVersion, QString* errorMessage) +{ + const QString stateFile = QDir(installDir).filePath("update/upgrade_state.json"); + 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 new file mode 100644 index 0000000..432ca9a --- /dev/null +++ b/client/Updater/UpdateTransaction.h @@ -0,0 +1,54 @@ +#pragma once +#include +#include +#include + +class UpdateTransaction +{ +public: + UpdateTransaction(const QString& installDir, 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, + 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 new file mode 100644 index 0000000..b86bd67 --- /dev/null +++ b/client/Updater/UpdaterLogic.cpp @@ -0,0 +1,670 @@ +#include "UpdaterLogic.h" +#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()); + } + + const 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") + }; + 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; +} + +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(); + static const 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") + }; + 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(targetDir).filePath("update/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(targetDir).filePath("update/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::reportResult(const QString& deviceId, + const QString& fromVer, + const QString& toVer, + bool success) +{ + QString url = m_serverAddr + "/api/v1/update/report"; + + QJsonObject body; + 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; +} \ No newline at end of file diff --git a/client/Updater/UpdaterLogic.h b/client/Updater/UpdaterLogic.h new file mode 100644 index 0000000..6b2c0a3 --- /dev/null +++ b/client/Updater/UpdaterLogic.h @@ -0,0 +1,69 @@ +#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); + + 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); + 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; + QElapsedTimer m_downloadTimer; +}; \ No newline at end of file diff --git a/client/Updater/main.cpp b/client/Updater/main.cpp new file mode 100644 index 0000000..f782472 --- /dev/null +++ b/client/Updater/main.cpp @@ -0,0 +1,325 @@ +#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" + +int main(int argc, char* argv[]) +{ + SetConsoleOutputCP(65001); + QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8")); + QApplication app(argc, argv); + QApplication::setApplicationName("Marsco Updater"); + + if (argc < 5) { + QMessageBox::critical(nullptr, "更新器参数错误", "更新器缺少应用、渠道或目标版本参数,请从 Launcher 启动。"); + return -1; + } + + const QString appId = argv[1]; + const QString channel = argv[2]; + const QString targetVersion = argv[3]; + const int 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 targetDir = QApplication::applicationDirPath(); + + ConfigHelper& config = ConfigHelper::instance(); + QString fromVersion = config.getValue("App", "current_version"); + QString deviceId = config.getValue("Update", "device_id"); + if (deviceId.isEmpty()) deviceId = "unknown_device"; + + if (!resumingFromBootstrap) { + QString restoredVersion; + QString recoveryError; + if (!UpdateTransaction::recoverInterrupted(targetDir, &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(); + + UpdaterLogic logic; + UpdateTransaction transaction(targetDir, 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 fail = [&](const QString& title, const QString& message, + const QString& errorCode = QString("update_failed")) { + transaction.markFailed(errorCode, message); + logic.reportResult(deviceId, fromVersion, targetVersion, false); + progress.close(); + QMessageBox::critical(nullptr, title, message); + return -1; + }; + const QString mainAppPath = QDir(targetDir).filePath("MainApp.exe"); + const QString updaterPath = QDir(targetDir).filePath("Updater.exe"); + const QString bootstrapPath = QDir(targetDir).filePath("Bootstrap.exe"); + const QString launchToken = config.getValue("App", "launch_token"); + const auto launchMainApp = [&](const QString& healthFile = QString()) { + QStringList args{QString("--launcher-token=%1").arg(launchToken)}; + if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile)); + return QProcess::startDetached(mainAppPath, args); + }; + const auto bootstrapPlanFile = [&]() { + return QDir(targetDir).filePath("update/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("MainApp.exe"); + transaction.markRollbackRequired(reason); + progress.setLabelText("正在将回滚工作移交给 Bootstrap..."); + QApplication::processEvents(); + if (launchBootstrap("rollback")) { + progress.close(); + return 0; + } + logic.reportResult(deviceId, fromVersion, targetVersion, false); + progress.close(); + QMessageBox::critical(nullptr, title, + reason + "\n\n无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。"); + return -1; + }; + + if (resumingFromBootstrap) { + QString resumeError; + if (!transaction.resumeExisting(&resumeError)) { + logic.reportResult(deviceId, fromVersion, targetVersion, false); + progress.close(); + QMessageBox::critical(nullptr, "事务续办失败", + QString("无法读取 Bootstrap 更新事务:%1").arg(resumeError)); + return -1; + } + fromVersion = transaction.fromVersion(); + if (bootstrapResult == "rolledback") { + const bool stateOk = config.setValue("App", "current_version", fromVersion); + transaction.markRolledBack(); + logic.reportResult(deviceId, fromVersion, targetVersion, false); + if (stateOk) launchMainApp(); + progress.close(); + QMessageBox::warning(nullptr, "更新已回滚", + stateOk ? "新版本安装或启动失败,已自动恢复并启动旧版本。" + : "旧文件已经恢复,但旧版本号写回失败,请检查配置目录权限。"); + return stateOk ? 0 : -1; + } + if (bootstrapResult != "success") { + logic.reportResult(deviceId, fromVersion, targetVersion, false); + progress.close(); + QMessageBox::critical(nullptr, "自动回滚失败", + "Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。"); + return -1; + } + } else if (!transaction.initialize()) { + return fail("更新准备失败", "无法创建更新事务目录或保存事务状态。", "transaction_init_failed"); + } + + setProgress(resumingFromBootstrap ? 72 : 10, "正在获取并验证版本清单..."); + logic.getManifest(appId, channel, targetVersion, targetVersionId); + if (!logic.verifyManifestSignature()) { + if (resumingFromBootstrap) + return delegateRollback("安全验证失败", "Bootstrap 安装后无法重新验证版本清单签名。"); + return fail("安全验证失败", "版本清单签名无效,更新已停止。请联系管理员。", "manifest_signature_invalid"); + } + const QString manifestCacheDir = QDir(targetDir).filePath("update/manifest_cache"); + 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, "正在获取安全下载地址..."); + logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId); + if (logic.getFileList().isEmpty()) + return fail("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "empty_file_list"); + + QStorageInfo storage(targetDir); + 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("正在下载并校验 %1 个版本文件...").arg(logic.getFileList().size())); + if (!logic.downloadAllFiles(stagingDir, targetDir)) + return fail("下载失败", "部分文件下载失败或 SHA-256 校验未通过,请检查网络后重试。", "download_failed"); + + 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()))); + for (const QString& path : changedPaths) { + if (path.compare("Bootstrap.exe", Qt::CaseInsensitive) == 0) + return fail("Bootstrap 无法自更新", "本次版本包含新的 Bootstrap.exe。请使用安装包升级 Bootstrap,再重新发布业务版本。", "bootstrap_self_update_blocked"); + } + if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths)) + return fail("事务记录失败", "无法保存已校验或待删除文件列表,更新已停止。", "transaction_record_failed"); + + setProgress(66, "正在关闭主程序..."); + if (!FileHelper::killProcess("MainApp.exe")) + return fail("无法关闭主程序", "MainApp.exe 仍在运行,请手动关闭后重试。", "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("启动失败", "MainApp.exe 无法启动。"); + + QElapsedTimer healthTimer; + healthTimer.start(); + while (healthTimer.elapsed() < 15000 && !QFile::exists(healthFile)) { + QApplication::processEvents(); + QThread::msleep(100); + } + if (!QFile::exists(healthFile)) + return delegateRollback("启动确认失败", "新版本在 15 秒内没有完成启动健康确认。"); + + setProgress(99, "正在提交更新事务..."); + if (!transaction.commit()) + return delegateRollback("事务提交失败", "新版本已经启动,但无法提交更新事务。"); + + QFile::remove(healthFile); + QFile::remove(bootstrapPlanFile()); + logic.reportResult(deviceId, fromVersion, targetVersion, true); + progress.setValue(100); + progress.close(); + QMessageBox::information(nullptr, "更新完成", QString("软件已成功更新到 %1,并通过启动健康检查。").arg(targetVersion)); + return 0; +} diff --git a/client/admin.html b/client/admin.html new file mode 100644 index 0000000..6f9191e --- /dev/null +++ b/client/admin.html @@ -0,0 +1,622 @@ + + + + + + Marsco 发布控制台 + + +
+

软件发布控制台

+
+

管理员令牌

+
+ + + + + +
+
令牌默认隐藏,仅保存在当前浏览器;服务端只保存令牌哈希。
+ +
+ +
+

应用管理

+
+ + + +
+
+ + + + + +
+
+ +
+

发布新版本

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

版本列表

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

版本运行策略

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

升级日志

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

调试输出

+
准备就绪...
+
+ + + + diff --git a/client/client.ini b/client/client.ini new file mode 100644 index 0000000..a9ea8f0 --- /dev/null +++ b/client/client.ini @@ -0,0 +1,14 @@ +[Server] +api_base_url=http://192.168.229.128:8000 +client_token=AuthKey202606Demo + +[App] +app_id=testadmin +current_version=1.0.0 +channel=stable +launch_token=UpdateSecret2026Demo + +[Update] +request_timeout_ms=5000 +temp_folder=update_temp +device_id=test_device_001 \ No newline at end of file diff --git a/client/config/app_config.example.json b/client/config/app_config.example.json new file mode 100644 index 0000000..b13e4b3 --- /dev/null +++ b/client/config/app_config.example.json @@ -0,0 +1,14 @@ +{ + "app_id": "your_app_id", + "app_name": "Your Application", + "channel": "stable", + "api_base_url": "http://127.0.0.1:8000", + "platform": "windows", + "arch": "x64", + "current_version": "1.0.0", + "client_token": "replace_me", + "launch_token": "replace_me", + "request_timeout_ms": 5000, + "temp_folder": "update_temp", + "device_id": "" +} diff --git a/client/config/local_state.json b/client/config/local_state.json new file mode 100644 index 0000000..daa85ab --- /dev/null +++ b/client/config/local_state.json @@ -0,0 +1,6 @@ +{ + "max_policy_seq": 0, + "last_success_run_at": "", + "last_online_verified_at": "", + "last_success_version": "" +} diff --git a/client/config/manifest_public_key.pem b/client/config/manifest_public_key.pem new file mode 100644 index 0000000..781682f --- /dev/null +++ b/client/config/manifest_public_key.pem @@ -0,0 +1,9 @@ +-----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/config/version_policy.dat b/client/config/version_policy.dat new file mode 100644 index 0000000..da9b067 --- /dev/null +++ b/client/config/version_policy.dat @@ -0,0 +1 @@ +{"disabled_versions":[],"force_update":false,"offline_allowed":true,"policy_seq":1,"valid_until":"2099-12-31T23:59:59Z","signature":"d6982cb02d9a0c6fb6b7176961a7736dbee0decfbd5e422bd189521c72664205"} \ No newline at end of file diff --git a/client/pdf_requirements.txt b/client/pdf_requirements.txt new file mode 100644 index 0000000..05dcc33 --- /dev/null +++ b/client/pdf_requirements.txt @@ -0,0 +1,2943 @@ +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 new file mode 100644 index 0000000..489b6f8 Binary files /dev/null and b/client/软件自动升级与版本控制系统开发设计文档 v0.1.pdf differ diff --git a/client/项目进度.txt b/client/项目进度.txt new file mode 100644 index 0000000..61e1da3 --- /dev/null +++ b/client/项目进度.txt @@ -0,0 +1,538 @@ +软件自动升级与版本控制系统——项目进度 +更新时间:2026-07-01 +依据:《软件自动升级与版本控制系统开发设计文档 v0.1》及当前 server/client 源码 + +============================================================ +一、项目进度概述 +============================================================ + +目前项目已经完成一套可实际运行的 Windows 在线自动更新主链路。 + +客户端已经具备 Launcher、Updater、MainApp、Bootstrap 四个程序;服务端已经具备 FastAPI、SQLite、MinIO、版本发布、Manifest、下载授权、升级结果上报、版本策略管理和管理网页。客户端可以检查版本、验证 RSA 签名、差异下载、断点续传、备份旧文件、通过 Bootstrap 替换被占用文件、删除废弃文件、启动新版本并等待健康确认;失败时可以进入自动回滚流程。 + +目前 1.1.3 的正常升级流程已经实际运行成功,说明在线更新的成功路径已经基本打通。 + +按需求文档的 Demo 里程碑判断: + +1. 第一阶段“基础框架”:基本完成。 +2. 第二阶段“服务端基础能力”:基本完成。 +3. 第三阶段“在线更新”:基本完成,正常升级已实测。 +4. 第四阶段“版本策略”:大部分完成。 +5. 第五阶段“离线能力”:完成离线运行基础,离线更新包尚未实现。 +6. 第六阶段“可靠性与安全”:完成事务、回滚、策略防回退和时间检测;插件白名单、限流、完整日志等尚未实现。 + +如果只计算“在线更新 Demo”,当前完成度约为 80%。 +如果按照整份设计文档计算,当前完成度约为 60%~65%。 + +当前最主要的剩余工作不是普通在线更新,而是进一步补齐安全准入和管理能力,包括:一次性启动票据、MainApp 启动时核心文件完整性检查、设备身份、插件白名单、下载与审计日志、动态渠道、限流、离线更新包等。 + +============================================================ +二、已经实现的功能 +============================================================ + +2.1 客户端基础框架 + +已实现: + +1. Launcher.exe。 +2. Updater.exe。 +3. MainApp.exe。 +4. 独立原生 Bootstrap.exe。 +5. Qt 图形提示和更新进度界面。 +6. MainApp 显示当前版本。 +7. MainApp 显示 Demo DLL 的简化 Hash 标识。 +8. Launcher 启动 MainApp。 +9. 直接双击 MainApp 时拒绝运行。 +10. Windows x64 CMake 工程。 +11. Qt 5.15、MSVC、OpenSSL 构建配置。 + +说明:MainApp 当前读取 Demo DLL 内容并显示 Hash 标识,但还没有通过 QLibrary 真正加载并调用插件接口,因此“插件实际加载”只算部分完成。 + +2.2 本地 JSON 配置 + +已实现: + +1. 使用 config/app_config.json 保存客户端配置。 +2. 支持旧 client.ini 自动迁移。 +3. 保存 API 地址、App ID、当前版本、渠道、设备 ID、客户端 Token、启动 Token、平台和架构。 +4. 更新成功后写入 current_version。 +5. 使用 QSaveFile 原子写入主要配置。 +6. 运行时配置不进入发布包。 +7. 已配置项目 .gitignore。 + +2.3 服务端基础能力 + +已实现: + +1. FastAPI HTTP 服务。 +2. SQLite 数据库。 +3. MinIO 对象存储。 +4. 应用创建与查询。 +5. 版本发布、查询、设置最新和删除。 +6. stable、preview、dev 三个固定渠道。 +7. 删除版本时同步删除 MinIO 文件。 +8. 发布失败时清理 MinIO 和本地回退目录中的半成品。 +9. 大文件上传前检查磁盘空间。 +10. multipart 临时文件存放到 /dev/shm,避免与 MinIO 双重占用根分区。 +11. MinIO 不可用时支持本地存储回退。 +12. 管理员 Token 鉴权和令牌修改。 +13. 管理页面和跨域配置。 + +当前主要接口: + +1. /api/v1/update/check +2. /api/v1/update/manifest +3. /api/v1/update/download-url +4. /api/v1/update/report +5. /admin/publish +6. /admin/version/list +7. /admin/version/set-latest +8. /admin/version/delete +9. /admin/policy +10. /admin/policy/save +11. /admin/report/list + +2.4 软件根目录和多层目录发布 + +已实现: + +1. 浏览器一次选择软件根目录。 +2. 保留所有文件的相对路径。 +3. 支持多层 DLL、插件和资源目录。 +4. 服务端阻止绝对路径、..、盘符路径和重复路径。 +5. Manifest、MinIO 和客户端安装过程都保留相对路径。 +6. 检查 MainApp.exe 是否位于发布根级。 +7. 排除 Bootstrap、运行时配置、状态文件、策略缓存、PDB、ILK 和更新临时目录。 + +2.5 Manifest + +已实现: + +1. 服务端动态生成全量 Manifest。 +2. 包含 App ID、版本、渠道、平台、架构、Manifest 序列和创建时间。 +3. 包含文件相对路径、大小、SHA-256 和 executable 标记。 +4. 使用稳定 JSON 序列化。 +5. 使用 RSA-2048/SHA-256 签名。 +6. 客户端使用内置公钥验签。 +7. 签名失败时拒绝安装。 +8. Manifest 本地缓存。 +9. 新旧 Manifest 对比。 +10. 根据新旧 Manifest 差集删除废弃文件。 + +2.6 在线升级完整流程 + +已实现流程: + +1. Launcher 请求更新检查。 +2. 服务端返回目标版本和签名策略。 +3. Launcher 验证版本策略 RSA 签名。 +4. Launcher 根据策略决定升级、降级、继续运行或禁止运行。 +5. 启动 Updater。 +6. Updater 获取并验证 Manifest。 +7. 获取 MinIO 预签名下载地址。 +8. 比较本地文件 SHA-256。 +9. 只下载新增或变化的文件。 +10. 校验文件大小和 SHA-256。 +11. 备份旧文件。 +12. Updater 退出并移交 Bootstrap。 +13. Bootstrap 替换文件或删除废弃文件。 +14. Bootstrap 重新启动 Updater 续办事务。 +15. Updater 完整校验安装结果。 +16. 保存新版本号。 +17. 启动 MainApp 并等待健康确认。 +18. 健康确认成功后提交事务。 +19. 上报升级成功结果。 + +2.7 差异下载、断点续传和下载体验 + +已实现: + +1. SHA 相同的文件跳过下载。 +2. HTTP Range 断点续传。 +3. 使用 update/download_cache/.part 保存片段。 +4. Updater 重启后仍可继续未完成文件。 +5. 单文件最多自动重试四次。 +6. 使用递增等待时间重试。 +7. 服务端不支持 Range 时安全地完整重下。 +8. 下载后验证大小和 SHA-256。 +9. 显示当前文件、已下载量、总下载量、速度和总进度。 +10. 清理不属于当前 Manifest 的旧片段。 + +2.8 客户端磁盘空间预检 + +下载前会计算: + +1. 尚未下载的字节数。 +2. 已存在的断点片段大小。 +3. 被覆盖文件需要的备份空间。 +4. 废弃文件需要的备份空间。 +5. 至少 128MB 的安全余量。 + +空间不足时会在开始下载前阻止更新,并显示所需空间和当前可用空间。 + +2.9 升级事务状态机 + +已实现 update/upgrade_state.json,包含: + +1. transaction_id。 +2. from_version。 +3. to_version。 +4. status。 +5. manifest_id。 +6. staging_dir。 +7. backup_dir。 +8. changed_paths。 +9. obsolete_paths。 +10. error_code 和 message。 + +已经实现的主要状态: + +prepared、verified、waiting_mainapp_exit、backed_up、awaiting_bootstrap、replacing、replaced、post_verify、rollback_required、rolling_back、rolled_back、committed、failed。 + +Updater 启动时会读取旧事务,并根据状态尝试恢复或回滚。 + +2.10 Bootstrap 自更新机制 + +已实现: + +1. Bootstrap 不依赖 Qt。 +2. Updater 退出后由 Bootstrap 接管安装。 +3. 可以替换 Updater.exe、Launcher.exe、MainApp.exe、Qt DLL、OpenSSL DLL、Qt 插件和业务文件。 +4. 完成后重新启动 Updater 续办事务。 +5. 回滚也由 Bootstrap 执行,避免运行中的 Updater 锁住自己。 +6. Bootstrap 自身属于不可由普通更新事务替换的根组件。 +7. 旧 Manifest 即使包含 Bootstrap,也会由客户端作为受保护文件忽略。 + +2.11 自动回滚和健康检查 + +已实现: + +1. 安装前备份旧文件。 +2. 替换失败时回滚。 +3. 安装后 Hash 校验失败时回滚。 +4. MainApp 无法启动时回滚。 +5. MainApp 15 秒内未写入健康标记时回滚。 +6. 版本状态保存失败时回滚。 +7. 回滚后恢复旧版本号。 +8. 回滚后重新启动旧 MainApp。 +9. 被删除的废弃文件也会在回滚时恢复。 +10. Updater 自身发生变化时由 Bootstrap 执行回滚。 + +2.12 版本运行策略 + +已实现: + +1. version_policies 数据表。 +2. 每个应用和渠道分别保存策略。 +3. 每次修改自动递增 policy_seq。 +4. RSA 签名策略在线下发。 +5. Launcher 验证策略签名。 +6. 签名失败时拒绝使用。 +7. 策略原子缓存到本地。 +8. policy_seq 防回滚。 +9. 强制升级。 +10. 禁用指定版本。 +11. 最低支持版本。 +12. 允许或禁止降级。 +13. 允许或禁止离线启动。 +14. 策略有效期。 +15. 自定义客户端提示。 +16. 管理页面策略编辑区。 +17. 上次在线验证时间和系统时间回拨检测。 + +2.13 受控降级和用户选择 + +已实现: + +1. 管理员可以将历史版本设置为渠道最新。 +2. 允许降级时服务端返回 rollback_allowed。 +3. 禁止降级时返回 rollback_denied。 +4. 用户可以选择是否执行降级。 +5. 用户拒绝降级后继续运行当前版本。 +6. 普通可选升级也允许用户选择稍后更新。 +7. 强制升级不能跳过。 +8. 降级复用完整事务、Bootstrap、校验、健康确认和失败回滚机制。 + +2.14 离线运行基础 + +已实现: + +1. 在线策略本地缓存。 +2. 本地策略 RSA 验签。 +3. offline_allowed。 +4. valid_until。 +5. 策略过期时拒绝启动。 +6. policy_seq 防回滚。 +7. 记录上次成功启动时间。 +8. 记录上次在线验证时间。 +9. 检测系统时间是否回拨。 + +说明:目前实现的是“离线运行”,不是“离线升级”。 + +2.15 管理页面 + +已实现: + +1. 管理员令牌输入、隐藏、显示和保存。 +2. 修改管理员令牌。 +3. 创建和选择应用。 +4. 选择软件根目录发布。 +5. stable、preview、dev 渠道选择。 +6. 版本列表。 +7. 设置最新版本。 +8. 删除版本和云端文件。 +9. 版本策略读取和保存。 +10. 升级日志。 +11. 调试输出。 +12. 发布文件预览、大小和状态提示。 +13. 页面美化和响应式布局。 + +============================================================ +三、部分实现的功能 +============================================================ + +3.1 启动票据 + +当前实现:Launcher 或 Updater 通过 --launcher-token 参数启动 MainApp,直接双击 MainApp 会被拒绝。 + +与需求差距: + +1. 不是临时 ticket 文件。 +2. 没有 app_id、client_id、device_id、version、nonce、issued_at、expires_at。 +3. 没有一次性使用后删除。 +4. 没有防重放。 +5. 使用的是配置中的长期固定 Token。 + +结论:只能防止普通用户直接双击,不能算完整安全启动票据。 + +3.2 MainApp 启动完整性检查 + +当前 MainApp 会检查启动 Token、版本策略签名、策略有效期、policy_seq 和时间回拨。 + +尚缺少: + +1. 启动时重新验证当前 Manifest。 +2. 核心 EXE/DLL 全量 Hash。 +3. 检测受控目录中未被 Manifest 声明的额外 EXE/DLL。 +4. 插件白名单。 +5. MainApp 自身 Hash 验证。 + +因此需求中的“手动篡改 DLL 后启动失败”目前不能保证通过。 + +3.3 回滚完整性 + +文件恢复和版本号恢复已经实现,但仍缺少: + +1. 回滚完成后加载旧 Manifest 并进行完整 Hash 校验。 +2. 更详细的逐文件回滚错误。 +3. 回滚失败后的修复安装入口。 +4. 完整断电、杀进程、文件占用测试。 + +3.4 Manifest 安全字段 + +RSA 签名已经实现,但仍缺少: + +1. signature_alg 字段。 +2. key_id 字段。 +3. 当前和上一公钥同时内置。 +4. 公钥轮换流程。 +5. 密钥吊销机制。 + +3.5 升级日志 + +当前只记录 device_id、旧版本、新版本、success/fail 和时间。 + +尚缺少: + +1. app_id 和 channel。 +2. transaction_id。 +3. error_code。 +4. 失败阶段和失败文件。 +5. 下载字节数和耗时。 +6. 回滚结果。 +7. 操作系统、架构和客户端 IP。 + +3.6 REST API 契约 + +当前更新检查主要接收 app_id、current_version 和 channel。 + +需求文档中的以下字段尚未进入完整闭环: + +1. client_id。 +2. device_id。 +3. license_id。 +4. platform。 +5. arch。 +6. operation。 +7. target_version。 + +3.7 管理员登录 + +当前采用单管理员 Token,服务端只保存 Token Hash。 + +尚缺少: + +1. admin_users 表。 +2. 正式登录接口。 +3. Token 过期时间。 +4. 多管理员和角色权限。 +5. 登录失败审计。 +6. 管理员操作审计。 + +3.8 数据库结构 + +已有 apps、versions、version_files、version_policies、upgrade_logs、update_report。 + +部分字段仍未达到文档设计,例如: + +1. versions 缺少 status、allow_rollback、rollback_targets、描述和发布时间等字段。 +2. version_files 缺少 storage_key、file_type、is_required。 +3. apps 字段较少。 +4. 缺少较完整的外键、索引和约束。 + +3.9 精确降级控制 + +已有允许/禁止降级,但仍缺少: + +1. rollback_targets 目标白名单。 +2. 数据格式兼容性规则。 +3. 主动输入目标版本。 +4. 不同版本间的允许降级关系。 +5. “允许用户降级”和“管理员强制回退”的独立策略。 + +============================================================ +四、尚未实现的核心功能 +============================================================ + +4.1 一次性短期启动票据 + +需要实现临时 ticket 文件、有效期、nonce、签名、版本绑定、设备绑定、一次使用后删除和防重放。 + +4.2 MainApp 核心文件完整性准入 + +需要在每次启动时验证当前已安装 Manifest、MainApp、核心 DLL、插件目录和额外可执行文件。 + +4.3 设备身份和激活 + +尚未实现: + +1. client_identity.dat 正式签发。 +2. 设备身份 RSA 签名。 +3. /api/v1/device/issue。 +4. 首次设备激活和刷新。 +5. 设备禁用。 +6. devices 表。 +7. 每次请求验证设备身份。 + +当前 device_id 只是普通配置字符串,客户端请求使用共享 client_token。 + +4.4 License 授权系统 + +尚未实现 licenses 表、授权有效期、设备绑定、最大设备数、授权禁用、许可证签名和授权渠道控制。 + +4.5 动态渠道管理 + +目前 stable、preview、dev 写死在客户端和后台。 + +尚未实现 channels 表、渠道新增、启用、禁用、渠道名称和按应用配置渠道。 + +4.6 离线更新包 + +尚未实现: + +1. .upd 离线包。 +2. 离线包生成。 +3. 离线包 Manifest 和 RSA 签名。 +4. 本地导入和校验。 +5. 解包到 staging。 +6. 离线事务安装。 + +4.7 插件白名单 + +尚未实现插件目录扫描、未知 DLL 检测、插件签名/Hash 准入和插件接口版本检查。 + +4.8 服务端限流 + +尚未实现更新检查限流、下载链接限流、完整包下载次数限制、每日字节额度、管理 API 限流以及 HTTP 429/RATE_LIMITED。 + +4.9 下载日志 + +尚未实现 download_logs 表,以及设备、版本、文件、下载字节、结果、IP 和时间等业务下载记录。 + +4.10 管理员审计日志 + +尚未实现 admin_audit_logs,包括发布、删除、设置最新、修改策略、修改令牌、登录失败等操作记录。 + +4.11 代码签名 + +尚未实现 Windows Authenticode、发布者验证和 EXE/DLL 代码签名检查。 + +4.12 灰度发布 + +尚未实现按设备、客户、地区、百分比或批次灰度,以及失败率自动停止。 + +4.13 多平台 + +目前只支持 Windows x64,尚未支持 Windows ARM64、Linux、macOS 和多平台 Manifest 分流。 + +============================================================ +五、需求文档 Demo 里程碑状态 +============================================================ + +第一阶段:基础框架——基本完成。 +缺口:Demo 插件尚未真正通过接口加载。 + +第二阶段:服务端基础能力——基本完成。 +缺口:动态 channels 表和设备身份未完成。 + +第三阶段:在线更新——基本完成。 +正常升级 1.1.3 已实际测试成功。 + +第四阶段:版本策略——大部分完成。 +缺口:动态渠道、rollback_targets 和强制回退独立策略。 + +第五阶段:离线能力——部分完成。 +已经支持离线运行和策略有效期;尚未实现离线更新包。 + +第六阶段:可靠性与安全——部分完成。 +已经支持事务回滚、policy_seq 和时间回拨检测;尚未实现插件白名单、限流、完整下载日志和启动完整性准入。 + +============================================================ +六、需求文档验收用例状态 +============================================================ + +1. 通过 Launcher 正常启动 MainApp:已实现。 +2. 直接启动 MainApp 被拒绝:已实现,但启动票据较弱。 +3. MainApp 显示主程序和 DLL 版本:已实现简化版。 +4. 后台发布版本:已实现。 +5. 客户端在线升级:已实现并实测。 +6. 升级后 DLL 变化:支持。 +7. 升级后 MainApp 自动重启:已实现。 +8. 手动篡改 DLL 后启动失败:未完整实现。 +9. 手动篡改 version_policy 后启动失败:已实现 RSA 验签。 +10. 强制升级:已实现,待最终客户端测试。 +11. 禁用版本:已实现,待最终客户端测试。 +12. preview 客户端获取 preview 更新:基础支持,待测试。 +13. 禁止降级:已实现并进行过 API 测试。 +14. 离线凭证未过期时启动:已实现,待测试。 +15. 离线凭证过期后拒绝启动:已实现,待测试。 +16. 模拟升级失败后成功回滚:代码已实现,待完整故障测试。 + +============================================================ +七、推荐后续开发顺序 +============================================================ + +建议依次实现: + +1. 一次性、短期、防重放的启动票据。 +2. MainApp 启动时核心文件完整性检查和插件白名单。 +3. 设备身份签发与服务端验证。 +4. 升级日志错误码、失败阶段、回滚结果和下载日志。 +5. 管理员审计日志。 +6. 动态渠道管理。 +7. rollback_targets 精确降级目标。 +8. 服务端限流。 +9. 离线更新包。 +10. 最后统一进行成功升级、强制升级、版本禁用、离线、断点续传、文件占用、进程中断、回滚和策略篡改测试。 + +当前下一项最值得实现的是:一次性启动票据,以及 MainApp 启动时的核心文件完整性检查。这两项是目前客户端启动准入中最大的安全缺口。 diff --git a/server/db.py b/server/db.py new file mode 100644 index 0000000..fa004f3 --- /dev/null +++ b/server/db.py @@ -0,0 +1,30 @@ +import sqlite3 +from pathlib import Path +import os +from dotenv import load_dotenv + +load_dotenv() + +DB_FILE = os.getenv("DB_FILE") +SQL_FILE = os.getenv("SQL_FILE") + +# 初始化数据库 +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() \ No newline at end of file diff --git a/server/keys/manifest_public_key.pem b/server/keys/manifest_public_key.pem new file mode 100644 index 0000000..781682f --- /dev/null +++ b/server/keys/manifest_public_key.pem @@ -0,0 +1,9 @@ +-----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 new file mode 100644 index 0000000..28ec09f --- /dev/null +++ b/server/main.py @@ -0,0 +1,780 @@ +from fastapi import FastAPI, Body, Request, HTTPException, UploadFile, File, Form, Header, Depends +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel +import os +import sqlite3 +from pathlib import Path, PurePosixPath +from dotenv import load_dotenv +from contextlib import asynccontextmanager +import db +import minio_tool +import hashlib +import secrets +import base64 +import json +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 +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives import hashes + +# 加载环境变量 +load_dotenv() +SERVER_HOST = os.getenv("SERVER_HOST") +SERVER_PORT = int(os.getenv("SERVER_PORT")) +VALID_CLIENT_TOKEN = os.getenv("CLIENT_API_TOKEN") + +# 大型 multipart 上传使用内存文件系统暂存,避免与 MinIO 对象双重占用根分区。 +UPLOAD_SPOOL_DIR = Path(os.getenv("UPLOAD_SPOOL_DIR", "/dev/shm/marsco-upload")) +UPLOAD_SPOOL_DIR.mkdir(parents=True, exist_ok=True) +tempfile.tempdir = str(UPLOAD_SPOOL_DIR) +MINIO_DATA_DIR = Path(os.getenv("MINIO_DATA_DIR", "minio_data")) +UPLOAD_SPACE_RESERVE = 256 * 1024 * 1024 + +# 后台管理令牌:仅持久化 SHA-256 哈希,不保存明文 +ADMIN_TOKEN_HASH_PATH = Path(os.getenv("ADMIN_TOKEN_HASH_PATH", "admin_token.sha256")) +DEFAULT_ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "AdminSecret2026") + +def token_digest(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + +def load_admin_token_hash() -> str: + if ADMIN_TOKEN_HASH_PATH.exists(): + stored = ADMIN_TOKEN_HASH_PATH.read_text(encoding="utf-8").strip() + if len(stored) == 64: + return stored + return token_digest(DEFAULT_ADMIN_TOKEN) + +ADMIN_TOKEN_HASH = load_admin_token_hash() + +print("读取到的CLIENT_API_TOKEN:", repr(VALID_CLIENT_TOKEN)) + +# ========== 生命周期初始化 ========== +@asynccontextmanager +async def lifespan(app: FastAPI): + print("===== 进程启动,开始初始化数据库 =====") + db_file = "mini.db" + sql_file_path = Path("./tables.sql") + 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) + conn.commit() + print(f"✅ 成功执行tables.sql,创建数据表与测试数据") + else: + print(f"❌ 错误:当前目录找不到 tables.sql 文件!") + conn.close() + print("===== 数据库初始化完成 =====") + + 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: + base_url = f"http://{endpoint.rstrip('/')}" + health_url = base_url + "/minio/health/live" + with urllib.request.urlopen(health_url, timeout=2) as resp: + return resp.status == 200 + except Exception: + return False + + +def download_minio_binary(target_path: Path) -> str | None: + 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(): + 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 = download_minio_binary(minio_bin) + if not minio_cmd: + print("MinIO 二进制未找到且自动下载失败,无法启动 MinIO") + return + + data_dir = Path(os.getenv('MINIO_DATA_DIR', 'minio_data')) + data_dir.mkdir(parents=True, exist_ok=True) + print(f"尝试自动启动 MinIO:{minio_cmd} server {data_dir} --address ':9000'") + try: + subprocess.Popen([minio_cmd, 'server', str(data_dir), '--address', ':9000'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(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 = os.getenv("MANIFEST_PRIVATE_KEY_PATH", str(Path(__file__).resolve().parent / "keys" / "manifest_private_key.pem")) + +# 创建APP绑定生命周期 +app = FastAPI(title="最小自动更新服务", lifespan=lifespan) + +# 本地上传备份文件目录(MinIO 不通时会保存到这里) +app.mount("/static", StaticFiles(directory="local_uploads"), name="static") + +# 跨域配置(给admin前端页面用) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# ===================== 全局客户端鉴权中间件 ===================== +@app.middleware("http") +async def auth_middleware(request: Request, call_next): + path = request.url.path + # 后台接口、文档接口、静态回退链接全部跳过客户端token校验 + skip_paths = ("/admin", "/docs", "/openapi.json", "/redoc", "/static") + if path.startswith(skip_paths): + response = await call_next(request) + return response + + client_token = request.headers.get("X-Client-Token") + print("本次请求携带token:", repr(client_token)) + if client_token != VALID_CLIENT_TOKEN: + raise HTTPException(status_code=401, detail="非法客户端,令牌校验失败") + response = await call_next(request) + return response + +# ========== 请求体模型 ========== +class CheckUpdateReq(BaseModel): + app_id: str + current_version: str + channel: str + +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): + device_id: str + from_version: str + to_version: str + result: str + +class ChangeAdminTokenReq(BaseModel): + new_token: str + +# 统一后台鉴权:全部接口从Header读取token,不再区分表单/头 +def admin_auth(X_Admin_Token: str = Header("")): + if not secrets.compare_digest(token_digest(X_Admin_Token), ADMIN_TOKEN_HASH): + raise HTTPException(status_code=403, detail="后台密钥错误,禁止访问") + return True + +# 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 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 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() + +# 文件sha256工具 +def calc_sha256(data: bytes): + sha = hashlib.sha256() + sha.update(data) + return sha.hexdigest() + +# ==================== 原有客户端接口 完整保留 ==================== +# 1. 查询更新接口 +@app.post("/api/v1/update/check") +async def check_update(body: CheckUpdateReq = Body(...)): + print("收到客户端版本检测请求,参数:", body.model_dump()) + app_id, cur_ver, channel = body.app_id, body.current_version, body.channel + conn = db.get_conn() + 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 + 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"]) + 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: + 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": "manifest-key-v1" + } + 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, + "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(body: DownloadUrlReq = Body(...)): + ver_id = body.version_id + conn = db.get_conn() + rows = conn.execute( + "SELECT path, sha256, size FROM version_files WHERE version_id=?", + (ver_id,) + ).fetchall() + 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} + +# 2b. 获取版本 manifest +@app.post("/api/v1/update/manifest") +async def get_manifest(body: ManifestReq = Body(...)): + 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": "windows", + "arch": "x64", + "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(body: ReportReq = Body(...)): + 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_HASH + 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 个字符") + new_hash = token_digest(new_token) + ADMIN_TOKEN_HASH_PATH.write_text(new_hash, encoding="utf-8") + ADMIN_TOKEN_HASH = new_hash + return {"code": 0, "msg": "管理员令牌已更新"} + +# 应用列表 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.commit() + except Exception as e: + return {"msg": f"创建失败:{str(e)}"} + conn.close() + return {"msg": "应用创建成功"} + +# 查询和保存应用渠道策略 +@app.get("/admin/policy") +def admin_get_policy(app_id: str, channel: str = "stable", 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 "stable").strip() + if not app_id or channel not in ("stable", "preview", "dev"): + 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() + 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} + + +# 发布新版本(文件上传) +@app.post("/admin/publish") +async def admin_publish_version(request: Request, auth=Depends(admin_auth)): + # 在 Starlette 解析 multipart 前检查空间;否则大文件 rollover 会直接抛出 Errno 28。 + content_length_header = request.headers.get("content-length") + try: + content_length = int(content_length_header or 0) + except ValueError: + raise HTTPException(status_code=400, detail="无效的 Content-Length") + if content_length <= 0: + raise HTTPException(status_code=411, detail="发布请求必须提供 Content-Length") + + spool_free = shutil.disk_usage(UPLOAD_SPOOL_DIR).free + storage_free = shutil.disk_usage(MINIO_DATA_DIR).free + if spool_free < content_length + 64 * 1024 * 1024: + raise HTTPException(status_code=507, detail={ + "error": "upload_temp_space_insufficient", + "msg": "上传临时空间不足,请减小发布包或扩容 /dev/shm", + "required_bytes": content_length + 64 * 1024 * 1024, + "available_bytes": spool_free, + }) + if storage_free < content_length + UPLOAD_SPACE_RESERVE: + raise HTTPException(status_code=507, detail={ + "error": "storage_space_insufficient", + "msg": "版本存储空间不足,请扩容服务器根卷或删除不再需要的历史版本", + "required_bytes": content_length + UPLOAD_SPACE_RESERVE, + "available_bytes": storage_free, + }) + + # 手动解析表单,增加兼容性并便于调试前端上传问题 + try: + form = await request.form() + except OSError as err: + if getattr(err, "errno", None) == 28: + raise HTTPException(status_code=507, detail="上传临时空间已满,请扩容后重试") + raise + # 尝试从 form 中获取字段 + app_id = form.get("app_id") + channel = form.get("channel") or "stable" + version = form.get("version") + + # 收集 files(可能是多个同名字段) + files = [] + if hasattr(form, "getlist"): + files = form.getlist("files") + else: + # 兼容性降级:遍历所有表单项 + for k, v in form.multi_items(): + if k == "files": + files.append(v) + + # 如果关键信息缺失,返回详细调试信息,方便前端定位问题 + if not app_id or not version: + headers = {k: v for k, v in request.headers.items()} + detail = { + "error": "missing form fields", + "have_app_id": bool(app_id), + "have_version": bool(version), + "content_type": request.headers.get("content-type"), + "headers": headers, + "form_keys": list(form.keys()), + "app_id_value": app_id, + "version_value": version, + "app_id_len": len(app_id or ""), + "version_len": len(version or "") + } + raise HTTPException(status_code=422, detail=detail) + + if not files: + raise HTTPException(status_code=400, detail="未选择任何文件,请选择程序文件夹") + + relative_paths = form.getlist("relative_paths") if hasattr(form, "getlist") else [] + if relative_paths and len(relative_paths) != len(files): + raise HTTPException(status_code=400, detail="文件数量与相对路径数量不一致") + + upload_items = [] + seen_paths = set() + for index, file in enumerate(files): + if not hasattr(file, "filename"): + raise HTTPException(status_code=400, detail="上传内容中包含无效文件") + raw_path = relative_paths[index] if relative_paths else file.filename + rel_path = normalize_relative_path(str(raw_path)) + path_key = rel_path.lower() + if path_key in seen_paths: + raise HTTPException(status_code=400, detail=f"存在重复文件路径: {rel_path}") + seen_paths.add(path_key) + upload_items.append((file, rel_path)) + + if "mainapp.exe" not in seen_paths: + nested_main = next((path for path in seen_paths if path.endswith("/mainapp.exe")), None) + if nested_main: + raise HTTPException(status_code=400, detail=f"MainApp.exe 不在发布根级,请改为选择其所在目录: {nested_main}") + raise HTTPException(status_code=400, detail="发布根目录中缺少 MainApp.exe") + + + conn = db.get_conn() + 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) VALUES (?,?,?,1)", + (app_id, channel, version) + ) + new_vid = cur.lastrowid + publish_started = True + + for file, rel_path in upload_items: + content = await file.read() + + f_size = len(content) + f_sha = calc_sha256(content) + obj_path = f"{app_id}/{channel}/{version}/files/{rel_path}" + put_result = minio_tool.put_file(obj_path, content, f_size) + if put_result.get('storage') == 'local': + print(f"MinIO 不可用,已保存到本地回退:{put_result.get('path')}") + cur.execute( + "INSERT INTO version_files(version_id, path, sha256, size) VALUES (?,?,?,?)", + (new_vid, rel_path, f_sha, f_size) + ) + + conn.commit() + except sqlite3.IntegrityError as err: + conn.rollback() + if publish_started: + minio_tool.remove_prefix(publish_prefix) + if 'UNIQUE constraint failed: versions.app_id, versions.channel, versions.version' in str(err): + raise HTTPException(status_code=400, detail="该版本已存在,请更换版本号或先删除旧版本") + raise HTTPException(status_code=500, detail=str(err)) + except sqlite3.OperationalError as err: + conn.rollback() + if publish_started: + minio_tool.remove_prefix(publish_prefix) + raise HTTPException(status_code=500, detail={"error": "database locked", "msg": str(err)}) + except HTTPException: + conn.rollback() + if publish_started: + minio_tool.remove_prefix(publish_prefix) + raise + except Exception as err: + conn.rollback() + if publish_started: + minio_tool.remove_prefix(publish_prefix) + if isinstance(err, OSError) and getattr(err, "errno", None) == 28: + raise HTTPException(status_code=507, detail="发布过程中存储空间耗尽,已清理未完成版本") + raise HTTPException(status_code=500, detail={"error": "publish_failed", "msg": str(err)}) + finally: + conn.close() + + return {"msg": f"版本 {version} 发布完成,共上传 {len(upload_items)} 个文件"} + +# 获取版本列表 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, 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"]), + "create_time": r["create_time"] + }) + return {"list": out} + +# 设置指定版本为渠道最新 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 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({ + "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} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT, workers=1) \ No newline at end of file diff --git a/server/minio_tool.py b/server/minio_tool.py new file mode 100644 index 0000000..eacc0f5 --- /dev/null +++ b/server/minio_tool.py @@ -0,0 +1,93 @@ +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 urllib3.util import Retry, Timeout + +load_dotenv() + +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")) +LOCAL_FILE_URL_BASE = os.getenv("LOCAL_FILE_URL_BASE") +LOCAL_UPLOAD_ROOT.mkdir(parents=True, exist_ok=True) + +http_client = urllib3.PoolManager( + timeout=Timeout(connect=2.0, read=5.0), + retries=Retry(total=1, connect=1, read=1, status=1, backoff_factor=0.2), +) + +mc = Minio( + MINIO_ENDPOINT, + access_key=MINIO_ACCESS_KEY, + secret_key=MINIO_SECRET_KEY, + secure=False, + http_client=http_client, +) + + +def get_url(file_path: str) -> str: + bucket = MINIO_BUCKET + full_object_path = file_path + try: + mc.stat_object(bucket, full_object_path) + url = 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}" + return f"http://{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 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}") diff --git a/server/tables.sql b/server/tables.sql new file mode 100644 index 0000000..1cdbde7 --- /dev/null +++ b/server/tables.sql @@ -0,0 +1,65 @@ +-- 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=历史旧版 + 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) +);