ソースを参照

feat: 完成工程管理与版本化 JSON 存储

main
suyu 1ヶ月前
コミット
01a7488cbe
13個のファイルの変更1771行の追加8行の削除
  1. +7
    -2
      app/integrated_platform.pro
  2. +63
    -0
      app/src/domain/project_storage.h
  3. +0
    -1
      app/src/infrastructure/.gitkeep
  4. +1134
    -0
      app/src/infrastructure/json_project_storage.cpp
  5. +16
    -0
      app/src/infrastructure/json_project_storage.h
  6. +0
    -1
      app/src/services/.gitkeep
  7. +181
    -0
      app/src/services/project_service.cpp
  8. +80
    -0
      app/src/services/project_service.h
  9. +237
    -0
      app/tests/project_management_tests.cpp
  10. +31
    -0
      app/tests/project_management_tests.pro
  11. +16
    -4
      docs/ai/handoff.md
  12. +5
    -0
      docs/architecture.md
  13. +1
    -0
      docs/开发顺序.md

+ 7
- 2
app/integrated_platform.pro ファイルの表示

@@ -22,7 +22,9 @@ SOURCES += \
src/domain/hmi_model.cpp \
src/domain/control_logic_model.cpp \
src/domain/project_model.cpp \
src/domain/runtime_state.cpp
src/domain/runtime_state.cpp \
src/services/project_service.cpp \
src/infrastructure/json_project_storage.cpp

HEADERS += \
src/ui/main_window.h \
@@ -31,7 +33,10 @@ HEADERS += \
src/domain/hmi_model.h \
src/domain/control_logic_model.h \
src/domain/project_model.h \
src/domain/runtime_state.h
src/domain/runtime_state.h \
src/domain/project_storage.h \
src/services/project_service.h \
src/infrastructure/json_project_storage.h

FORMS += \
src/ui/main_window.ui

+ 63
- 0
app/src/domain/project_storage.h ファイルの表示

@@ -0,0 +1,63 @@
#pragma once

#include "project_model.h"

#include <string>

namespace integrated_platform::domain {

// 工程文件存储失败原因
enum class ProjectStorageError
{
None, // 无错误
FileOpenFailed, // 文件打开失败
FileReadFailed, // 文件读取失败
FileWriteFailed, // 文件写入失败
InvalidJson, // JSON 格式无效
MissingField, // 缺少必要字段
InvalidField, // 字段值无效
UnsupportedVersion, // 工程版本不受支持
InvalidProject // 工程内容无效
};

// 工程保存结果
struct ProjectSaveResult
{
bool succeeded = false; // 保存是否成功
ProjectStorageError error = ProjectStorageError::None; // 保存失败原因
std::string message; // 结果描述信息
};

// 工程加载结果
struct ProjectLoadResult
{
bool succeeded = false; // 加载是否成功
Project project; // 加载得到的工程
ProjectStorageError error = ProjectStorageError::None; // 加载失败原因
std::string message; // 结果描述信息
};

// 工程文件存储边界
class ProjectStorage
{
public:
virtual ~ProjectStorage() = default;

/**
* @brief 将工程保存到指定文件
* @param project 需要保存的有效工程
* @param file_path UTF-8 编码的目标文件路径
* @return 工程保存结果
*/
virtual ProjectSaveResult save(
const Project &project, const std::string &file_path) = 0;

/**
* @brief 从指定文件加载工程
* @param file_path UTF-8 编码的工程文件路径
* @return 工程加载结果
*/
virtual ProjectLoadResult load(const std::string &file_path) = 0;
};

} // namespace integrated_platform::domain

+ 0
- 1
app/src/infrastructure/.gitkeep ファイルの表示

@@ -1 +0,0 @@


+ 1134
- 0
app/src/infrastructure/json_project_storage.cpp
ファイル差分が大きすぎるため省略します
ファイルの表示


+ 16
- 0
app/src/infrastructure/json_project_storage.h ファイルの表示

@@ -0,0 +1,16 @@
#pragma once

#include "domain/project_storage.h"

namespace integrated_platform::infrastructure {

// 使用版本化 JSON 文件保存和加载工程
class JsonProjectStorage final : public domain::ProjectStorage
{
public:
domain::ProjectSaveResult save(
const domain::Project &project, const std::string &file_path) override;
domain::ProjectLoadResult load(const std::string &file_path) override;
};

} // namespace integrated_platform::infrastructure

+ 0
- 1
app/src/services/.gitkeep ファイルの表示

@@ -1 +0,0 @@


+ 181
- 0
app/src/services/project_service.cpp ファイルの表示

@@ -0,0 +1,181 @@
#include "project_service.h"

#include <algorithm>
#include <chrono>
#include <cctype>
#include <random>
#include <sstream>
#include <utility>

namespace integrated_platform::services {

namespace {

// 使用高精度时间戳和随机值生成工程标识
std::string generateProjectId()
{
const auto timestamp = static_cast<unsigned long long>(
std::chrono::high_resolution_clock::now().time_since_epoch().count());
std::random_device random_device;
const auto random_value = static_cast<unsigned long long>(random_device());

std::ostringstream stream;
stream << "project-" << std::hex << timestamp << '-' << random_value;
return stream.str();
}

} // namespace

ProjectService::ProjectService(domain::ProjectStorage &storage)
: storage_(storage),
project_(makeNewProject("Untitled"))
{
}

const domain::Project &ProjectService::project() const
{
return project_;
}

domain::Project &ProjectService::editProject()
{
modified_ = true;
return project_;
}

const std::string &ProjectService::currentFilePath() const
{
return current_file_path_;
}

bool ProjectService::hasCurrentFile() const
{
return !current_file_path_.empty();
}

bool ProjectService::isModified() const
{
return modified_;
}

ProjectOperationResult ProjectService::createNewProject(const std::string &name)
{
if (isBlank(name))
{
return {false,
ProjectServiceError::InvalidProjectName,
domain::ProjectStorageError::None,
"project name must not be empty"};
}

// 新工程创建成功后清除原文件关联,并标记为待保存
project_ = makeNewProject(name);
current_file_path_.clear();
modified_ = true;
return {true, ProjectServiceError::None, domain::ProjectStorageError::None, {}};
}

ProjectOperationResult ProjectService::save()
{
if (!hasCurrentFile())
{
return {false,
ProjectServiceError::FilePathRequired,
domain::ProjectStorageError::None,
"project file path is required"};
}
return saveAs(current_file_path_);
}

ProjectOperationResult ProjectService::saveAs(const std::string &file_path)
{
if (isBlank(file_path))
{
return {false,
ProjectServiceError::FilePathRequired,
domain::ProjectStorageError::None,
"project file path is required"};
}

std::string validation_error;
if (!project_.validate(&validation_error))
{
return {false,
ProjectServiceError::InvalidProject,
domain::ProjectStorageError::InvalidProject,
validation_error};
}

// 先完成领域校验,避免将非法工程交给存储层
const domain::ProjectSaveResult result = storage_.save(project_, file_path);
if (!result.succeeded)
{
return storageFailure(result.error, result.message);
}

// 只有存储成功后才更新文件关联和未保存状态
current_file_path_ = file_path;
modified_ = false;
return {true, ProjectServiceError::None, domain::ProjectStorageError::None, {}};
}

ProjectOperationResult ProjectService::load(const std::string &file_path)
{
if (isBlank(file_path))
{
return {false,
ProjectServiceError::FilePathRequired,
domain::ProjectStorageError::None,
"project file path is required"};
}

domain::ProjectLoadResult result = storage_.load(file_path);
if (!result.succeeded)
{
return storageFailure(result.error, result.message);
}

std::string validation_error;
if (!result.project.validate(&validation_error))
{
return {false,
ProjectServiceError::InvalidProject,
domain::ProjectStorageError::InvalidProject,
validation_error};
}

// 文件读取和领域校验均成功后,才替换当前工程状态
project_ = std::move(result.project);
current_file_path_ = file_path;
modified_ = false;
return {true, ProjectServiceError::None, domain::ProjectStorageError::None, {}};
}

domain::Project ProjectService::makeNewProject(const std::string &name)
{
domain::Project project;
project.metadata.id = generateProjectId();
project.metadata.name = name;
project.metadata.formatVersion = "1.0";
return project;
}

bool ProjectService::isBlank(const std::string &value)
{
return value.empty()
|| std::all_of(
value.cbegin(),
value.cend(),
[](unsigned char character)
{
return std::isspace(character) != 0;
});
}

ProjectOperationResult ProjectService::storageFailure(
domain::ProjectStorageError error, const std::string &message)
{
return {false, ProjectServiceError::StorageFailure, error, message};
}

} // namespace integrated_platform::services

+ 80
- 0
app/src/services/project_service.h ファイルの表示

@@ -0,0 +1,80 @@
#pragma once

#include "domain/project_storage.h"

#include <string>

namespace integrated_platform::services {

// 工程管理操作失败原因
enum class ProjectServiceError
{
None, // 无错误
InvalidProjectName, // 工程名称为空或仅包含空白字符
FilePathRequired, // 未提供工程文件路径
InvalidProject, // 当前工程不符合领域规则
StorageFailure // 工程文件存储操作失败
};

// 工程管理操作结果
struct ProjectOperationResult
{
bool succeeded = false; // 操作是否成功
ProjectServiceError error = ProjectServiceError::None; // 服务层错误原因
domain::ProjectStorageError storageError = domain::ProjectStorageError::None; // 存储层错误原因
std::string message; // 面向调用方的结果描述
};

// 编排当前工程的新建、保存和加载流程
class ProjectService
{
public:
// 使用外部提供的工程存储实现创建服务
explicit ProjectService(domain::ProjectStorage &storage);

const domain::Project &project() const;
domain::Project &editProject();
const std::string &currentFilePath() const;
bool hasCurrentFile() const;
bool isModified() const;

/**
* @brief 创建未保存的新工程
* @param name 工程名称,不得为空或仅包含空白字符
* @return 新建操作结果
*/
ProjectOperationResult createNewProject(const std::string &name);

/**
* @brief 保存到当前工程文件
* @return 保存操作结果,当前工程没有文件路径时返回失败
*/
ProjectOperationResult save();

/**
* @brief 将当前工程保存到指定文件
* @param file_path UTF-8 编码的目标文件路径,不得为空或仅包含空白字符
* @return 保存操作结果
*/
ProjectOperationResult saveAs(const std::string &file_path);

/**
* @brief 从指定文件加载工程
* @param file_path UTF-8 编码的工程文件路径,不得为空或仅包含空白字符
* @return 加载操作结果,失败时不替换当前工程
*/
ProjectOperationResult load(const std::string &file_path);

private:
static domain::Project makeNewProject(const std::string &name);
static bool isBlank(const std::string &value);
static ProjectOperationResult storageFailure(
domain::ProjectStorageError error, const std::string &message);

domain::ProjectStorage &storage_; // 非拥有的工程存储依赖
domain::Project project_; // 当前编辑中的工程
std::string current_file_path_; // 当前工程对应的文件路径
bool modified_ = true; // 当前工程是否有未保存修改
};

} // namespace integrated_platform::services

+ 237
- 0
app/tests/project_management_tests.cpp ファイルの表示

@@ -0,0 +1,237 @@
#include "domain/project_storage.h"
#include "infrastructure/json_project_storage.h"
#include "services/project_service.h"

#include <QFile>
#include <QTemporaryDir>

#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>

namespace domain = integrated_platform::domain;
namespace infrastructure = integrated_platform::infrastructure;
namespace services = integrated_platform::services;

namespace {

void require(bool condition, const std::string &message)
{
if (!condition)
{
throw std::runtime_error(message);
}
}

domain::Project makeExampleProject()
{
domain::HmiControl start_button;
start_button.id = "start-button";
start_button.type = domain::HmiControlType::Button;
start_button.bounds = {10, 20, 120, 48};
start_button.text = "Start";
start_button.binding = domain::RegisterAddress{domain::RegisterArea::M, 0};
start_button.properties.emplace("color", "green");

domain::HmiPage page;
page.id = "main-page";
page.name = "Main";
page.controls.push_back(start_button);

domain::LogicNode contact;
contact.id = "start-contact";
contact.config = domain::ContactNodeConfig{
domain::RegisterAddress{domain::RegisterArea::M, 0},
domain::ContactMode::NormallyOpen};

domain::LogicNode compare;
compare.id = "temperature-check";
compare.config = domain::CompareNodeConfig{
domain::RegisterAddress{domain::RegisterArea::D, 2},
domain::ComparisonOperator::GreaterThanOrEqual,
static_cast<std::int16_t>(100)};

domain::LogicNode coil;
coil.id = "run-coil";
coil.config = domain::CoilNodeConfig{
domain::RegisterAddress{domain::RegisterArea::M, 1},
domain::CoilMode::Set};

domain::ControlLogic logic;
logic.id = "start-logic";
logic.name = "Start logic";
logic.enabled = false;
logic.nodes = {contact, compare, coil};
logic.connections.push_back({contact.id, compare.id});
logic.connections.push_back({compare.id, coil.id});

domain::Project project;
project.metadata = {"example-project", "Example project", "1.0"};
project.hmiPages.push_back(page);
project.controlLogics.push_back(logic);
return project;
}

void writeText(const QString &path, const QByteArray &content)
{
QFile file(path);
require(file.open(QIODevice::WriteOnly), "test file must be writable");
require(file.write(content) == content.size(), "test file must be written completely");
}

QByteArray readBytes(const QString &path)
{
QFile file(path);
require(file.open(QIODevice::ReadOnly), "saved project must be readable");
return file.readAll();
}

void testEmptyProjectRoundTrip()
{
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");

infrastructure::JsonProjectStorage storage;
services::ProjectService service(storage);
require(service.createNewProject("Empty project").succeeded,
"empty project creation must succeed");

const QString path = directory.filePath("empty.json");
require(service.saveAs(path.toStdString()).succeeded,
"empty project save must succeed");
require(!service.isModified(), "saved project must not be marked modified");

require(service.load(path.toStdString()).succeeded,
"empty project load must succeed");
require(service.project().metadata.name == "Empty project",
"empty project name must survive round trip");
require(service.project().hmiPages.empty(), "empty project must have no HMI pages");
require(service.project().controlLogics.empty(),
"empty project must have no control logics");
}

void testExampleProjectRoundTrip()
{
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");

infrastructure::JsonProjectStorage storage;
services::ProjectService service(storage);
service.editProject() = makeExampleProject();

const QString first_path = directory.filePath("example.json");
const QString second_path = directory.filePath("example-copy.json");
require(service.saveAs(first_path.toStdString()).succeeded,
"example project save must succeed");
require(service.load(first_path.toStdString()).succeeded,
"example project load must succeed");

const domain::Project &project = service.project();
require(project.metadata.id == "example-project", "project id must survive round trip");
require(project.hmiPages.size() == 1, "HMI page count must survive round trip");
require(project.hmiPages.front().controls.front().binding->area()
== domain::RegisterArea::M,
"HMI M binding must survive round trip");
require(project.hmiPages.front().controls.front().properties.at("color") == "green",
"HMI properties must survive round trip");
require(project.controlLogics.size() == 1,
"control logic count must survive round trip");
require(!project.controlLogics.front().enabled,
"control logic enabled state must survive round trip");
require(project.controlLogics.front().nodes.size() == 3,
"logic node count must survive round trip");

const auto &compare = std::get<domain::CompareNodeConfig>(
project.controlLogics.front().nodes.at(1).config);
require(compare.address.index() == 2 && compare.value == 100,
"comparison configuration must survive round trip");

require(service.saveAs(second_path.toStdString()).succeeded,
"save as must succeed after load");
require(readBytes(first_path) == readBytes(second_path),
"save and save as must produce stable JSON");
}

void testInvalidFiles()
{
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");

infrastructure::JsonProjectStorage storage;
services::ProjectService service(storage);
service.editProject().metadata.name = "Current project";
const QString invalid_json = directory.filePath("invalid-json.json");
const QString missing_field = directory.filePath("missing-field.json");
const QString unsupported_version = directory.filePath("unsupported-version.json");

writeText(invalid_json, "{");
auto result = service.load(invalid_json.toStdString());
require(!result.succeeded
&& result.storageError == domain::ProjectStorageError::InvalidJson,
"invalid JSON must be rejected");
require(service.project().metadata.name == "Current project",
"invalid load must keep current project");

writeText(missing_field, R"({"formatVersion":"1.0"})");
result = service.load(missing_field.toStdString());
require(!result.succeeded
&& result.storageError == domain::ProjectStorageError::MissingField,
"missing fields must be rejected");

writeText(unsupported_version, R"({"formatVersion":"2.0"})");
result = service.load(unsupported_version.toStdString());
require(!result.succeeded
&& result.storageError == domain::ProjectStorageError::UnsupportedVersion,
"unsupported versions must be rejected");
}

void testServiceStateAndSaveErrors()
{
QTemporaryDir directory;
require(directory.isValid(), "temporary directory must be valid");

infrastructure::JsonProjectStorage storage;
services::ProjectService service(storage);
require(service.save().error == services::ProjectServiceError::FilePathRequired,
"save without a current path must be rejected");
require(service.createNewProject(" ").error
== services::ProjectServiceError::InvalidProjectName,
"blank project names must be rejected");

const QString path = directory.filePath("state.json");
service.editProject().metadata.name = "State project";
require(service.saveAs(path.toStdString()).succeeded,
"state project save must succeed");
service.editProject().metadata.name = "Changed project";
require(service.isModified(), "editing the project must mark it modified");
require(service.save().succeeded, "save must use the current file path");
require(!service.isModified(), "successful save must clear modified state");

const QString failed_path = directory.filePath("missing/subdir/state.json");
require(!service.saveAs(failed_path.toStdString()).succeeded,
"save to an unavailable path must fail");
require(service.currentFilePath() == path.toStdString(),
"failed save as must keep the previous current path");
}

} // namespace

int main()
{
try
{
testEmptyProjectRoundTrip();
testExampleProjectRoundTrip();
testInvalidFiles();
testServiceStateAndSaveErrors();
}
catch (const std::exception &error)
{
std::cerr << "project management tests failed: " << error.what() << '\n';
return 1;
}

std::cout << "project management tests passed\n";
return 0;
}

+ 31
- 0
app/tests/project_management_tests.pro ファイルの表示

@@ -0,0 +1,31 @@
TEMPLATE = app
TARGET = project_management_tests

CONFIG += console c++17 testcase warn_on
CONFIG -= app_bundle

QT += core

INCLUDEPATH += ../src

SOURCES += \
project_management_tests.cpp \
../src/domain/register_address.cpp \
../src/domain/register_repository.cpp \
../src/domain/hmi_model.cpp \
../src/domain/control_logic_model.cpp \
../src/domain/project_model.cpp \
../src/domain/runtime_state.cpp \
../src/services/project_service.cpp \
../src/infrastructure/json_project_storage.cpp

HEADERS += \
../src/domain/register_address.h \
../src/domain/register_repository.h \
../src/domain/hmi_model.h \
../src/domain/control_logic_model.h \
../src/domain/project_model.h \
../src/domain/runtime_state.h \
../src/domain/project_storage.h \
../src/services/project_service.h \
../src/infrastructure/json_project_storage.h

+ 16
- 4
docs/ai/handoff.md ファイルの表示

@@ -1,8 +1,8 @@
# Current Handoff

- Goal: 完成综合平台编程器的核心数据模型与接口
- Goal: 完成综合平台编程器的工程管理和后续编辑功能
- Branch: `main`。
- Current status: 已完成开发顺序第 3 步,核心领域模型、寄存器仓库接口和运行模式边界已定义
- Current status: 已完成开发顺序第 4 步,工程管理服务、版本化 JSON 存储和核心往返测试已完成
- Changed files:
- `app/integrated_platform.pro`
- `app/src/main.cpp`
@@ -29,8 +29,15 @@
- `app/src/domain/project_model.cpp`
- `app/src/domain/runtime_state.h`
- `app/src/domain/runtime_state.cpp`
- `app/src/domain/project_storage.h`
- `app/src/services/project_service.h`
- `app/src/services/project_service.cpp`
- `app/src/infrastructure/json_project_storage.h`
- `app/src/infrastructure/json_project_storage.cpp`
- `app/tests/domain_tests.pro`
- `app/tests/domain_tests.cpp`
- `app/tests/project_management_tests.pro`
- `app/tests/project_management_tests.cpp`
- Decisions made:
- Qt 应用源码位于 `app/`,构建输出位于被忽略的 `build/`。
- 使用 qmake、Qt Widgets、Qt SerialBus 和 MinGW 8.1。
@@ -40,6 +47,9 @@
- 领域模型仅使用 C++17 标准库,不依赖 Qt UI、串口或 Modbus。
- M 地址只承载布尔值,D 地址只承载带符号 16 位值,地址范围统一为 `0~4000`。
- `RegisterRepository` 统一虚拟寄存器和真实 PLC 缓存的访问契约。
- `ProjectStorage` 是领域层的工程文件存储契约,具体 JSON 和 Qt 文件 API 位于基础设施层。
- `ProjectService` 负责工程路径、修改状态和新建保存加载流程;加载成功后才替换当前工程。
- 工程文件格式版本当前为 `1.0`,保存使用 `QSaveFile` 原子提交。
- 控制逻辑节点使用 `std::variant` 组合触点、线圈和数值比较的独立配置类型。
- 当前控制逻辑范围不包含定时器;不得因为设备手册或旧开发计划提前加入未确认功能。
- 新增逻辑节点时增加独立配置类型,不向通用节点结构堆叠无关可选字段。
@@ -54,6 +64,8 @@
- 在 `build/core-model/` 重新执行 qmake 和 `mingw32-make -j2`,应用构建成功。
- 在 `build/domain-tests/` 构建并运行领域测试,输出 `domain tests passed`。
- 控制逻辑节点配置重构后再次构建应用和领域测试,均通过。
- Remaining work: 进入开发顺序第 4 步,实现工程新建、保存、另存为和加载。
- 在 `build/project-management-tests/` 构建并运行工程管理测试,输出 `project management tests passed`。
- 工程管理加入应用后重新执行 Qt 应用构建,构建成功。
- Remaining work: 进入开发顺序第 5 步,完成主界面和状态管理。
- Known risks / blockers: 无。
- Suggested next command: 基于 `Project` 领域模型设计版本化的工程文件 DTO 与存储接口。
- Suggested next command: 在主窗口注入 `ProjectService`,接入新建、打开、保存和另存为操作

+ 5
- 0
docs/architecture.md ファイルの表示

@@ -56,11 +56,16 @@ main.cpp -> UI + Services + Infrastructure
| --- | --- |
| `register_address.*` | 定义 M/D 地址值对象,统一校验项目地址范围 `0~4000`。 |
| `register_repository.*` | 定义 M 位和 D 字的统一读写接口,并提供离线内存实现。 |
| `project_storage.*` | 定义工程文件保存和加载的领域存储契约,不依赖具体文件格式。 |
| `hmi_model.*` | 定义 HMI 页面、控件、位置、扩展属性和寄存器绑定。 |
| `control_logic_model.*` | 使用独立配置类型定义触点、线圈和数值比较节点,并校验地址区域与连接引用。 |
| `project_model.*` | 聚合工程元数据、HMI 页面和控制逻辑,并校验工程内标识唯一性。 |
| `runtime_state.*` | 定义编辑态、离线运行态、真机运行态及合法切换规则。 |

工程管理由 `services/ProjectService` 编排新建、保存、另存为和加载。JSON 文件转换和原子写入
由 `infrastructure/JsonProjectStorage` 实现。加载必须先完成文件解析、版本检查和领域校验,成功
后才能替换当前工程;保存失败或加载失败不得修改当前工程路径和内存内容。

`RegisterRepository` 是 HMI、逻辑执行器和运行服务唯一可见的寄存器边界。离线运行使用
`VirtualRegisterRepository`;真机运行后续注入由基础设施层实现的 PLC 缓存仓库。仓库读取的是
最近一次有效值,写入成功只表示请求被仓库接受;真实 PLC 的异步确认由服务层负责反馈。


+ 1
- 0
docs/开发顺序.md ファイルの表示

@@ -30,6 +30,7 @@

- 实现新建、保存、另存为和加载工程。
- 保存 HMI 配置、控制逻辑、M/D 绑定和必要的工程版本信息。
- 使用服务层编排工程操作,基础设施层负责版本化 JSON 转换和原子文件写入。
- 对无效文件、缺失字段和不兼容版本给出明确错误。

完成标准:空工程和示例工程可以保存后重新加载,数据保持一致。


読み込み中…
キャンセル
保存