refactor: 存档层从「一牧场一个 JSON」换成 SQLite(单库单表)

`saves/farm.db` 一张 farms 表,farm_id 主键,**一个牧场一行**。
接口签名不变(save_state / load_state / delete_save 只多了 farm_id 参数),
调用方只改了路径语义。

设计取舍:标量列 + 整份 payload。
* day/gold/level/ap/weather/forecast/net_worth 单独成列——外部工具或 AI
  一条 SELECT 就能排榜、画曲线,不必先把每份存档读进内存;
* 完整状态仍整份存 payload JSON:游戏数据隔三差五长新字段,规范化成
  几十张表意味着每加字段写一次迁移,而存档只做整体读、整体写,
  所以结构化列管可查询,完整性交给 JSON——加字段不用改表结构。

崩溃安全升级:写入包在事务里。原来的"临时文件 + os.replace"能挡进程
崩溃,挡不住写到一半掉电;SQLite 的日志能。读取失败照旧降级开新档。

向后兼容:SAVE_VERSION=2 / MIN_SAVE_VERSION=1 不变。老 JSON 存档由
migrate_json_saves() 在服务器启动时自动导入:同名已存在就跳过(宁可不
迁不覆盖),导入成功原文件改名 *.json.migrated 留底,先写库再改名
保证幂等;坏档一律跳过——迁移不许有能力阻止服务器启动。

异常边界:StorageError = sqlite3.Error,刻意不是 OSError 子类——
调用方原来的 except OSError 全部跟着改,否则写盘失败会穿透出去。

性能实测:PRAGMA journal_mode=WAL 是持久属性,建库时设一次就够,
每次连接重设白花 2.2ms;save_state 约 6.9ms/次,大头是 synchronous=FULL
的 fsync——不省,存档的价值就是不丢档。bench 热路径无回归。

顺手修一个真 bug:connect() 建表失败时连接会泄漏,Windows 上句柄没关
就删不掉文件,而调用方正是靠"读失败→开新档"容错的。补 close。

测试:StorageTestCase 重写(往返/版本/损坏降级/多牧场隔离/迁移),
test_server 断言 *.json 的用例改查库。513 项全过,ruff/pyright 全绿。
端到端:预置 v1/v2 老档起真服务器验 22 项——自动迁移、字段还原、
list_farms() 排榜、重启后进度分毫不差,全过。
This commit is contained in:
snowy-yang committed 2026-09-19 19:04:30 +08:00
1 parent 6268283fc6
commit bc3ae96a38
10 files changed
+563 -113

No files matched your search

+11 -8
View File
@@ -43,6 +43,7 @@ from farm.server.session import (
validate_farm_id,
validate_owner,
)
from farm.storage import DB_NAME, farm_exists
try: # pragma: no cover - 环境相关
import fastapi # noqa: F401
@@ -216,9 +217,9 @@ class FarmSessionTestCase(unittest.TestCase):
self.assertIn("第 9 天", reopened.load_message)
def test_broken_save_degrades_to_a_new_farm(self):
path = os.path.join(self.dir, "alice.json")
path = os.path.join(self.dir, DB_NAME)
with open(path, "w", encoding="utf-8") as handle:
handle.write("这不是 JSON")
handle.write("这不是 SQLite")
session = FarmSession("alice", self.dir, 2400.0)
self.assertEqual(session.engine.state.day, 1)
self.assertIn("已开新档", session.load_message)
@@ -255,7 +256,7 @@ class FarmSessionTestCase(unittest.TestCase):
session.dirty = True
session.saved_at = 0.0
self.assertTrue(session.autosave())
self.assertTrue(os.path.exists(session.path))
self.assertTrue(farm_exists(session.db_path, session.farm_id))
def test_autosave_waits_between_writes(self):
session = FarmSession("alice", self.dir, 2400.0)
@@ -346,7 +347,7 @@ class SessionManagerTestCase(unittest.TestCase):
dropped = manager.reap()
self.assertEqual(dropped, ["alice"])
self.assertEqual(manager.count(), 0)
self.assertTrue(os.path.exists(session.path), "回收前必须先落盘")
self.assertTrue(farm_exists(session.db_path, session.farm_id), "回收前必须先落盘")
def test_reap_keeps_farms_that_are_still_fresh(self):
manager = self.manager(idle_timeout=3600)
@@ -387,7 +388,7 @@ class SessionManagerTestCase(unittest.TestCase):
manager.get(name)
manager.shutdown()
for name in ("alice", "bob"):
self.assertTrue(os.path.exists(os.path.join(self.dir, f"{name}.json")))
self.assertTrue(farm_exists(os.path.join(self.dir, DB_NAME), name))
class FarmClockTestCase(unittest.TestCase):
@@ -1024,8 +1025,10 @@ class RestApiTestCase(unittest.TestCase):
def test_save_goes_through_the_session_not_the_default_path(self):
"""存档位置由会话决定;直落 engine.save() 会写错地方、覆盖别人的档。"""
self.client.post("/api/actions?farm=alice", json={"action": "save", "params": {}})
self.assertTrue(os.path.exists(os.path.join(self.dir, "alice.json")))
self.assertFalse(os.path.exists(os.path.join(self.dir, "save.json")))
self.assertTrue(farm_exists(os.path.join(self.dir, DB_NAME), "alice"))
self.assertFalse(
os.path.exists(os.path.join(self.dir, "save.json")), "别在会话目录外另起炉灶"
)
def test_reset_only_touches_its_own_farm(self):
self.client.post(
@@ -1255,7 +1258,7 @@ class LiveServerTestCase(unittest.TestCase):
client = self.connect("live-save")
client.plant(1, "wheat")
client.save()
self.assertTrue(os.path.exists(os.path.join(self.dir, "live-save.json")))
self.assertTrue(farm_exists(os.path.join(self.dir, DB_NAME), "live-save"))
class SessionIsolationAcrossProcessesTestCase(unittest.TestCase):