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.0 → 1.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 |
Venue 和 Roster 经历了从 1.0 到 1.1 的迁移。其余文件当前均为 1.0。
迁移管道
执行流程
每个仓库在加载文件时执行以下流程:
- 将文件内容解析为
JsonNode - 读取文件中的
version字段 - 调用
FileVersionService.Migrate(fileType, node, fileVersion, targetVersion) - 服务查找所有注册的
IFileMigrator实现,匹配FileType、FromVersion、ToVersion - 按版本链顺序执行迁移器(例如 1.0 → 1.1 → 1.2)
- 返回迁移后的
JsonNode - 反序列化为目标对象
关键特性
- 仅向前迁移:不支持版本回滚。旧版本文件向前升级到当前版本
- 链式执行:当文件版本与当前版本相差多个版本时,自动链式执行所有中间迁移器
- 幂等性:每次加载都执行迁移,即使已是最新版本(匹配不到迁移器,无操作)
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 中为小写驼峰:
row、column、layoutTypeString、logicalGroup
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.ContentHash 和 RosterFile.ContentHash 在保存时计算 SHA256 哈希:
- ContentHash 为 null → 序列化为 JSON
- 计算 UTF-8 字节的 SHA256
- 设置 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);
}