diff --git a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs
index c04a37f..a22b0fc 100644
--- a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs
+++ b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs
@@ -222,6 +222,13 @@ public class AppConfigStoreTests
[DataRow("\"column-width\": { \"custom-pixels\": \"wide\" }")]
[DataRow("\"confirmations\": { \"delete-card\": \"nope\" }")]
[DataRow("\"version\": \"one\"")]
+ [DataRow("\"version\": \"1\"")]
+ [DataRow("\"language\": [\"zh-Hans\"]")]
+ [DataRow("\"confirmations\": { \"delete-card\": 0 }")]
+ [DataRow("\"confirmations\": { \"delete-card\": \"false\" }")]
+ [DataRow("\"column-width\": { \"custom-pixels\": \"500\" }")]
+ [DataRow("\"column-width\": { \"custom-pixels\": 500.0 }")]
+ [DataRow("\"column-width\": { \"custom-pixels\": 99999999999 }")]
public void InvalidFieldFallsBackAloneWithoutBackup(string invalidMember)
{
using var directory = new TempDirectory();
diff --git a/YKanBan/Storage/AppData/AppConfigStore.cs b/YKanBan/Storage/AppData/AppConfigStore.cs
index 5cf8446..29edb52 100644
--- a/YKanBan/Storage/AppData/AppConfigStore.cs
+++ b/YKanBan/Storage/AppData/AppConfigStore.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
@@ -8,7 +9,7 @@ namespace YKanBan.Storage.AppData;
/// Loads and saves app.json with graceful degradation: a missing file yields
/// defaults; a file that is not a JSON object is backed up to app.json.bak and
/// then defaults are used; an individual invalid field (unknown enum value or
-/// language, out-of-range width, wrong type) falls back to its own default while
+/// language, out-of-range width, wrong JSON type — no implicit conversion) falls back to its own default while
/// the other fields are kept; a file that exists but cannot be read (I/O or
/// permission error) also yields defaults. Saving happens only when this session
/// changed a setting (), and writes the file directly (no atomic replace) —
@@ -29,7 +30,7 @@ public sealed class AppConfigStore
private static readonly JsonSerializer FieldTolerantSerializer = JsonSerializer.Create(new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore,
- Converters = { new StringEnumConverter { AllowIntegerValues = false } },
+ Converters = { new StringEnumConverter { AllowIntegerValues = false }, new StrictPrimitiveConverter() },
// The JSON text is already known to be well formed here, so every error is a
// single member that fails to convert: skip it and keep that member's default.
@@ -211,6 +212,41 @@ public sealed class AppConfigStore
File.WriteAllText(FilePath, json);
}
+ ///
+ /// Reads booleans, integers and strings only from JSON tokens of exactly that
+ /// type, so a wrong type is an invalid field (falling back to its default)
+ /// instead of being converted: 0 or "false" is not a boolean,
+ /// "500" or 500.0 is not an integer, 5 is not a string.
+ ///
+ private sealed class StrictPrimitiveConverter : JsonConverter
+ {
+ ///
+ public override bool CanWrite => false;
+
+ ///
+ public override bool CanConvert(Type objectType) =>
+ objectType == typeof(bool) || objectType == typeof(int) || objectType == typeof(string);
+
+ ///
+ public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
+ {
+ JsonToken expected = objectType == typeof(bool) ? JsonToken.Boolean
+ : objectType == typeof(int) ? JsonToken.Integer
+ : JsonToken.String;
+ if (reader.TokenType != expected)
+ {
+ throw new JsonSerializationException($"Expected a {expected} token for {objectType.Name}, got {reader.TokenType}.");
+ }
+
+ // Integer tokens are read as Int64 (or BigInteger); a value outside the Int32 range is invalid too.
+ return objectType == typeof(int) ? checked((int)Convert.ToInt64(reader.Value, CultureInfo.InvariantCulture)) : reader.Value;
+ }
+
+ ///
+ public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) =>
+ throw new NotSupportedException();
+ }
+
///
/// Serializes a configuration in the on-disk format.
///
diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md
index e9868ed..7bb0fba 100644
--- a/docs/ACCEPTANCE.md
+++ b/docs/ACCEPTANCE.md
@@ -44,7 +44,7 @@
- [ ] 删除 `app.json`,打开设置、不改任何项直接确认后退出 → `app.json` 不被创建;改一项再改回原值后退出 → `app.json` 被写出(脏标记按"改动过"判定)
- [ ] 处于错误页(例如参数错误页)的实例退出时不写 `app.json`(可先删除 `app.json`,退出后确认文件未被创建)
- [ ] `app.json` 写成非法 JSON → 启动正常、使用默认值,生成 `app.json.bak`;改一次设置退出后 `app.json` 恢复为合法内容
-- [ ] `app.json` 中单个字段非法(如 `"theme": "pink"`、列宽 `"custom-pixels": 9999`)→ 仅该字段回退默认,其余设置保留,不生成 `.bak`
+- [ ] `app.json` 中单个字段非法(如 `"theme": "pink"`、列宽 `"custom-pixels": 9999`、类型不符如 `"delete-card": 0`、`"custom-pixels": "500"`)→ 仅该字段回退默认,其余设置保留,不生成 `.bak`
## B. 启动与错误页(M4 / M4R)
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 49fb768..f37f441 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -16,7 +16,7 @@
- 网络共享明确不支持;命令行参数边界;macOS 无参启动行为写入文档(§2.3、§4.1、附录)
- 补充:导出转义规则、卡片列表虚拟化、模态窗口 FlowDirection、`Resources` 类名使用规则、CI osx-x64 运行器(§5、§7、§8)
- 依赖:Avalonia 11.3.6 → 11.3.22,Semi.Avalonia 11.2.1.10 → 11.3.22(§7)
-- 实现回补:搜索括号嵌套上限 64 层、单个表达式至多 256 个原子,空短语、短语紧贴文本、`key:` 后空白均为非法(§6.1、§6.2);导出表格单元格转义 `\`、默认文件名剔除非法字符(§5.8);初始化中途锁冲突进锁冲突页、数据库文件缺失不静默新建(§4.4);app.json 读不出 / 备份写不出 / 退出写盘失败均不阻断启动与退出(§4.3);主题下拉选中即预览、取消恢复(§5.6);空白新卡片确认不创建(§5.4);app.json 脏标记按"改动过"判定(改回原值仍写盘)(§4.3)
+- 实现回补:搜索括号嵌套上限 64 层、单个表达式至多 256 个原子,空短语、短语紧贴文本、`key:` 后空白均为非法(§6.1、§6.2);导出表格单元格转义 `\`、默认文件名剔除非法字符(§5.8);初始化中途锁冲突进锁冲突页、数据库文件缺失不静默新建(§4.4);app.json 读不出 / 备份写不出 / 退出写盘失败均不阻断启动与退出(§4.3);主题下拉选中即预览、取消恢复(§5.6);空白新卡片确认不创建(§5.4);app.json 脏标记按"改动过"判定(改回原值仍写盘),字段类型不符不做隐式转换(§4.3)
- 里程碑:标注 M0–M4 已完成,新增 **M4R(v1.9 回补)**(§9)
## 1. 项目概述
@@ -141,7 +141,7 @@ card_tags: (card_id, tag_id) 复合主键,双向 ON DELETE CASCADE
- 读取:
- 文件缺失 → 默认值启动照常运行(下次脏退出时自然创建)
- JSON 本身无法解析 → 备份为 `app.json.bak`(覆盖旧备份)→ 以默认值启动
- - JSON 完好但个别字段非法(未知枚举值、未知语言、列宽越界 160–720、类型不符等)→ **仅该字段回退默认值**,其余字段保留,不生成备份
+ - JSON 完好但个别字段非法(未知枚举值、未知语言、列宽越界 160–720、类型不符等;类型须严格匹配,不做隐式转换——如布尔字段写 `0` / `"false"`、整数字段写 `"500"` / `500.0` 均视为非法)→ **仅该字段回退默认值**,其余字段保留,不生成备份
- 文件存在但无法读取(IO / 权限错误)→ 以默认值启动;`.bak` 备份写不出时跳过备份,照常以默认值启动
- 退出写盘失败(IO / 权限错误)→ 忽略,等同丢失当次会话的设置变更;无论写盘成败都释放工作区锁与连接