跳转到主内容

SeatFlow 的文件版本管理机制、FileMigrationService 迁移管道、IFileMigrator 接口实现和 version.py 版本管理脚本

文件版本与迁移系统

概述

SeatFlow 所有持久化 JSON 文件均携带 version 字段。加载时通过 FileMigrationService 自动执行向前迁移(forward-only migration),确保不同版本的应用能够安全读取旧格式的数据文件。

架构

file_versions.json(嵌入资源,编译到程序集)
       │
       ▼
FileVersionInfo.GetCurrentVersion(fileType)
       │
       ▼
文件加载 → 读取 version 字段 → 检测需要迁移 → FileMigrationService.Migrate()
       │                                                      │
       ▼                                                      ▼
   反序列化 ← ← ← ← ← ← ← ← ← ← ← ← 链式执行 IFileMigrator 列表

file_versions.json

版本定义文件为 SeatFlow.Infrastructure/Migration/file_versions.json,作为嵌入资源(Embedded Resource)编译到程序集中。

{
  "venue": "1.1",
  "roster": "1.1",
  "snapshot": "1.0",
  "venueInfo": "1.0",
  "appSettings": "1.0",
  "strategyConfig": "1.0",
  "strategyDatasetConfig": "1.0",
  "seatsets": "1.0"
}

FileVersionInfo.GetCurrentVersion

// 运行时读取最新版本
string currentVersion = FileVersionInfo.GetCurrentVersion("venue");
// 返回 "1.1"

各类文件当前版本

文件类型 版本 存储路径 包装类
Venue 1.1 {data}/Venues/*.venue.json VenueFile
Roster 1.01.1 {data}/Rosters/*.roster.json RosterFile
Snapshot 1.0 {data}/Assignments/{venueId}/{date}/*.json SeatingSnapshot
VenueInfo 1.0 {data}/Assignments/{venueId}/_venue.json VenueSnapshotInfo
AppSettings 1.0 {data}/AppSettings.json AppSettings
StrategyConfig 1.0 {data}/StrategyConfig/{strategyId}.config.json StrategyConfig
StrategyDatasetConfig 1.0 {data}/StrategyConfig/{strategyId}/*.config.json StrategyDatasetConfig
Seatsets 1.0 {data}/Seatsets/*.seatsets SeatsetsFile

VenueRoster 经历了从 1.0 到 1.1 的迁移。其余文件当前均为 1.0。

迁移管道

执行流程

每个仓库在加载文件时执行以下流程:

  1. 将文件内容解析为 JsonNode
  2. 读取文件中的 version 字段
  3. 调用 FileVersionService.Migrate(fileType, node, fileVersion, targetVersion)
  4. 服务查找所有注册的 IFileMigrator 实现,匹配 FileTypeFromVersionToVersion
  5. 按版本链顺序执行迁移器(例如 1.0 → 1.1 → 1.2)
  6. 返回迁移后的 JsonNode
  7. 反序列化为目标对象

关键特性

  • 仅向前迁移:不支持版本回滚。旧版本文件向前升级到当前版本
  • 链式执行:当文件版本与当前版本相差多个版本时,自动链式执行所有中间迁移器
  • 幂等性:每次加载都执行迁移,即使已是最新版本(匹配不到迁移器,无操作)

IFileMigrator 接口

public interface IFileMigrator
{
    /// <summary>文件类型标识,与 file_versions.json 的键一致(如 "venue")</summary>
    string FileType { get; }

    /// <summary>源版本号(如 "1.0")</summary>
    string FromVersion { get; }

    /// <summary>目标版本号(如 "1.1")</summary>
    string ToVersion { get; }

    /// <summary>执行迁移逻辑,操作 JsonNode 并返回迁移后的节点</summary>
    JsonNode Migrate(JsonNode root);
}

实现约定

  • IFileMigrator 实现为嵌套类,按文件类型分组在 Migration/Migrators/{FileType}Migrators.cs
  • 类名使用 Step_{From}_{To} 格式,如 Step_1_0_to_1_1
  • 迁移器应优先读取字符串格式的字段(如 layoutTypeString)而非数字枚举,以保持可读性

添加新迁移步骤

完整流程包含 5 个步骤:

步骤 1:创建 Migrator 类

Migration/Migrators/{FileType}Migrators.cs 中添加嵌套类:

public static class VenueMigrators
{
    public sealed class Step_1_0_to_1_1 : IFileMigrator
    {
        public string FileType => "venue";
        public string FromVersion => "1.0";
        public string ToVersion => "1.1";

        public JsonNode Migrate(JsonNode root)
        {
            // 读取并修改 JSON 节点
            var venueObject = root.AsObject();

            // 检查是否已经是新格式
            if (venueObject["layoutTypeString"]?.GetValue<string>() != null)
                return root; // 已迁移,跳过

            // 从 layoutType 数字推断字符串表示
            var layoutType = venueObject["layoutType"]?.GetValue<int>() ?? 0;
            venueObject["layoutTypeString"] = layoutType switch
            {
                0 => "Grid",
                1 => "Polar",
                2 => "Freeform",
                _ => "Grid"
            };

            // 重新排序座位(列主序 → 行主序)
            if (venueObject["seats"] is JsonArray seats)
            {
                // 按 Row 再按 Column 排序
                var sortedSeats = seats
                    .Select(s => s.AsObject())
                    .OrderBy(s => s["row"]?.GetValue<int>() ?? 0)
                    .ThenBy(s => s["column"]?.GetValue<int>() ?? 0)
                    .ToList();

                venueObject["seats"] = new JsonArray(sortedSeats
                    .Select(s => JsonNode.Parse(s.ToJsonString()))
                    .ToArray());
            }

            // 更新版本号
            venueObject["version"] = "1.1";

            return root;
        }
    }
}

步骤 2:注册到 DI

ServiceCollectionExtensions.cs 中注册:

services.AddSingleton<IFileMigrator, VenueMigrators.Step_1_0_to_1_1>();

步骤 3:更新 file_versions.json

{
  "venue": "1.1",
  ...  // 其他文件类型不变
}

步骤 4:更新 Model 默认 Version

Core/Models/ 对应模型类中更新默认 Version 属性:

public class VenueFile
{
    public string Version { get; set; } = "1.1";
    // ...
}

步骤 5:添加测试

Infrastructure.Tests/Migration/{FileType}MigratorsTests.cs 中添加覆盖测试:

public class VenueMigratorsTests
{
    [Fact]
    public void Step_1_0_to_1_1_ShouldAddLayoutTypeString()
    {
        // Arrange
        var migrator = new VenueMigrators.Step_1_0_to_1_1();
        var oldJson = JsonNode.Parse("""
            {
                "version": "1.0",
                "layoutType": 0,
                "seats": [
                    { "row": 2, "column": 1 },
                    { "row": 1, "column": 1 }
                ]
            }
            """)!;

        // Act
        var result = migrator.Migrate(oldJson);

        // Assert
        result["layoutTypeString"]?.GetValue<string>().Should().Be("Grid");
        result["version"]?.GetValue<string>().Should().Be("1.1");

        // 验证座位已排序为行主序
        var seats = result["seats"]!.AsArray();
        seats[0]!["row"]!.GetValue<int>().Should().Be(1);
        seats[1]!["row"]!.GetValue<int>().Should().Be(2);
    }
}

现有迁移器参考

VenueMigrators.Step_1_0_to_1_1

属性
FileType venue
FromVersion 1.0
ToVersion 1.1
变更内容 1. 添加 layoutTypeString 字段(从 layoutType 数字推断)
2. Grid 座位从列主序重排为行主序(先按 Row 排序,再按 Column 排序)

JSON 序列化约定

所有持久化 JSON 文件遵循以下序列化约定:

命名策略

// 序列化使用 CamelCase
var options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
  • 所有字段在 JSON 中为小写驼峰:rowcolumnlayoutTypeStringlogicalGroup

LayoutType 双字段

ClassroomLayoutDefinition.LayoutType 同时序列化为两个字段:

字段 类型 说明
layoutType int 0/1/2 数字枚举:0=Grid, 1=Polar, 2=Freeform
layoutTypeString string "Grid"/"Polar"/"Freeform" 字符串表示,迁移器中优先使用

Seat 多态序列化

Seat 使用 SeatJsonConverter 实现多态序列化:

{
  "type": 0,
  "Type": "Grid",
  "row": 1,
  "column": 2
}
字段 类型 说明
type int(小写) SeatType 枚举的 camelCase
Type string(大写 T) 鉴别器:"Grid"/"Polar"/"Freeform"

ContentHash

VenueFile.ContentHashRosterFile.ContentHash 在保存时计算 SHA256 哈希:

  1. ContentHash 为 null → 序列化为 JSON
  2. 计算 UTF-8 字节的 SHA256
  3. 设置 ContentHash 并重新序列化(输出包含 hash 的完整 JSON)

学生数据集哈希排除 importedAt/originalFileName(不稳定的时间戳),确保相同内容产生相同哈希值。

Version.py 版本管理脚本

scripts/version.py 统一管理项目中的 4 类版本号:App 版本、文件格式版本、策略清单版本、引导配置版本。

受管文件

体系 文件
App 版本 SeatFlow.Presentation.Avalonia/Data/about.json(zh-CN + en-US)
文件格式版本 file_versions.json + 7 个 Model C# 类 + JsonStudentWriter.cs
策略清单版本 7 个 Manifests/*.json
引导配置版本 onboarding_config.json

常用命令

cd scripts

# 查看版本概览
python3 version.py show

# 校验一致性(15+ 处版本定义)
python3 version.py check

# 调整 App 版本
python3 version.py bump-app patch --dry-run
python3 version.py bump-app minor --force

# 调整文件格式版本(自动同步 Model 类)
python3 version.py bump-file roster --set 1.2 --dry-run
python3 version.py bump-file roster --set 1.2 --force

# 调整策略清单版本
python3 version.py bump-strategy FixedSeat --set 1.1.0 --force
python3 version.py bump-strategy ALL --set 1.1.0 --dry-run

# 调整引导配置版本
python3 version.py bump-onboarding --set 3.2 --force

# 从 file_versions.json 同步所有 Model 类默认值
python3 version.py sync --force

重要注意事项

bump-file 命令自动同步 file_versions.json → 7 个 Model C# 类 → JsonStudentWriter.cs,无需手动修改。这确保了:

  • 文件格式版本定义(file_versions.json)是最权威的来源
  • 所有 Model 类的 Version 默认属性自动保持最新
  • 写入器(JsonStudentWriter)中的版本号自动同步

子命令参考

命令 功能
show 显示全部版本号概览(App / 文件格式 / 策略 / 引导配置)
check 校验 15+ 处版本定义的一致性
bump-app [major\|minor\|patch\| --set X.Y.Z] 调整 App 版本
bump-file TYPE --set X.Y 调整文件格式版本(自动同步 Model 类 + JsonStudentWriter)
bump-strategy ID --set X.Y.Z 调整策略清单版本(ID 或 ALL)
bump-onboarding --set X.Y 调整引导配置版本
sync 从 file_versions.json 同步所有 Model 类默认值

Grid 座位排序规则

GridLayoutBuilder.BuildGrid行主序创建座位:

// 外层循环:行,内层循环:列
for (int row = 1; row <= maxRows; row++)
{
    for (int col = 1; col <= maxCols; col++)
    {
        if (r <= rowsForCol[col - 1])  // 不规则网格检查
        {
            seats.Add(new GridSeat { Row = row, Column = col });
        }
    }
}
  • 确保 RandomFillStrategy 从左到右、从上到下填充座位
  • 不规则网格(ColumnRowCounts)中,maxRows = ColumnRowCounts.Max(),每列检查 r <= rowsForCol

版本迁移注意事项

竞赛条件防护

  • 迁移在内存中的 JsonNode 上执行,不影响原始文件
  • 仓库在迁移完成后将反序列化的对象返回给调用方,持久化由调用方负责
  • 同一文件被多个线程同时加载时,每个线程独立执行迁移(幂等)

嵌入式资源特殊性

Manifest JSON 和配置文件作为嵌入式资源编译到程序集中,不经过 FileMigrationService。其版本兼容性通过运行时检查处理:

// StrategyManifestProvider 中的版本警告
if (manifest.ManifestVersion > maxKnownVersion)
{
    logger.LogWarning("Manifest {Id} 版本 {Version} 超过最大已知版本 {Max}",
        manifest.Id, manifest.ManifestVersion, maxKnownVersion);
}

相关文档