fixed # 修复迁移数据库失败的BUG

This commit is contained in:
2026-10-20 15:31:15 +08:00
parent 9e3fa69219
commit 024541bea2
11 changed files with 464 additions and 120 deletions
+29 -1
View File
@@ -33,7 +33,8 @@ FARM_SERVICE_TOKEN,未把原值复制到新仓库。
## 真实用户数据迁移建议
当前没有读写任何真实玩家库,以下尚未实施:
当前没有读写任何真实玩家库。以下是真实部署时的建议;已实现的自动迁移
范围见下一节,不能将隔离测试等同于真实玩家数据验收:
1. 盘点实际部署版本、schema、来源平台、玩家数量和货币含义。
2. 停写并备份,记录 schema、文件哈希和备份位置;备份不入 Git。
@@ -50,6 +51,33 @@ FARM_SERVICE_TOKEN,未把原值复制到新仓库。
是否保留已有资产、重置、补偿或人工对账必须由用户明确决定,
不在迁移脚本中暗自纠正或清零。
## 真寻旧版 farm.db 自动迁移
新版默认使用 `data/farm_db/farm.sqlite3`,只读同目录的旧版 `farm.db`。
首次收到用户农场指令时,按旧裸 UID 查找数据并转换;没有旧账号则沿用
正常注册流程。旧程序应停止写入,切换前自行备份,旧文件不会修改或删除。
- 身份绑定:使用首次请求的 deployment、机器人 ID、平台、用户 ID。
导入记录和资产在同一 SQLite 事务提交,同 UID 在整个目标库只导入一次。
再次请求不重置余额,重启后仍保持绑定;其他机器人或平台请求会被拒绝。
- 账户:保留名称、point、vipPoint、exp;不额外发放注册初始资产。
初始资产写入新版 ledger,并关联 `legacy_import` 操作。
- 仓库:按名称唯一匹配内置作物 ID,合并种子与产物,保留数量与锁定。
同名歧义、未知作物、重复行、非法数值都会回滚,不猜测或清零。
- 土地:按 soil 创建已解锁地块,缺失的行补为空地;保留作物、成熟时间、
收获季数与枯萎状态,土地等级映射为原有产量/经验加成。
旧虫害标志映射为一只虫;当前季保持旧成熟时间,不追溯套用新版生虫时钟,
下一季开始使用新版生长规则。
- 保留:所有含 uid 字段的旧表中该用户的原始行,存入
`operations.result` 的 `legacy_rows`。道具、签到、偷菜、杂草、缺水、
施肥及土地等级原值等新版尚无完整对应玩法的状态可在此核查;这些状态
不宣称已恢复为可用功能。额外表列在 `unsupported_tables`,日志记录表行数。
- 冲突:新账号已经存在时不覆盖、不累加;迁移失败不注册空账号。
检查服务器日志,修复副本并核对后再重试;原始旧库和已提交的新账号均保留。
`farm_preview` 目录与未发布的 `farm-preview.sqlite3` 不在此迁移范围。
本实现使用构造的旧版结构做隔离测试,尚未验证用户服务器上的真实数据库。
## 首次发布仓库接管
当前本地 Beta 基线不等于三个发布仓库 main。
+9 -9
View File
@@ -28,16 +28,16 @@
## 数据目录
默认路径改为宿主 `DATA_PATH / farm_db`。为保持数据连续,数据库文件名
`farm-preview.sqlite3`、日志文件名和 deployment 标识不变。
默认路径为宿主 `DATA_PATH / farm_db`,新版数据库名为 `farm.sqlite3`。
日志文件名和 deployment 标识保持不变。不再自动移动 `farm_preview`
目录,也不再因两个目录同时存在而阻止启动。
更新插件后下次启动时,若默认旧目录 `farm_preview` 中存在本项目 SQLite,
且 `farm_db` 不存在,会把整个旧目录重命名为 `farm_db`,同时保留日志和
SQLite 附属文件。迁移在打开数据库前进行,成功后记录迁移日志。
同目录旧版 `farm.db` 按用户在首次使用指令时迁移。用户永久绑定首次请求
的 deployment、机器人 ID 和平台,不跨身份重复导入;转换规则、失败处理
和不支持字段的保留方式见 [旧数据迁移](05-migration.md)。
若两个目录同时存在,启动失败并提示明确选择数据目录,禁止覆盖或默默
创建空账户。文件被占用导致重命名失败时同样终止启动;应停止旧实例再启动。
原有显式 `farm_preview_data_dir` 配置继续生效;该配置用于自定义路径,
不会被自动移动。若显式配置仍指向 `farm_preview`,需自行更改该路径。
原有显式 `farm_preview_data_dir` 配置继续生效。仅从选定目录读取旧版
`farm.db`;不会搜索其他目录。未发布预览版的 `farm-preview.sqlite3`
不自动改名或导入,预览数据由开发者手动处理。
本轮只修改开发源并进行隔离测试;没有移动正在使用的宿主数据或重启机器人。
+2 -1
View File
@@ -2,7 +2,8 @@
发布目标:https://github.com/Shu-Ying/zhenxun_plugin_farm.git
原指令、独立预览数据目录、身份和事件幂等、启动/关闭及发图接入。
不接入真寻金币兑换,不迁移旧数据。核心/渲染由主仓库白名单打包,
不接入真寻金币兑换;旧版 farm.db 首次使用时按用户迁移到 farm.sqlite3。
核心/渲染由主仓库白名单打包,
生成时修正为插件私有相对导入,本目录本身不是完整安装包。
使用 scripts/build_zhenxun.py 生成ZIP,详见 docs/11-zhenxun-preview.md。
本轮没有拉取、推送或操作真实机器人。
+2 -3
View File
@@ -15,12 +15,12 @@ from zhenxun.utils.message import MessageUtils
from zhenxun.utils.platform import PlatformUtils
from .runtime import COMMANDS, Runtime
from .data_paths import data_directories
from .data_paths import data_directory
from .config_migration import migrate_config
logger = logging.getLogger(__name__)
driver = get_driver()
data_dir, legacy_data_dir = data_directories(
data_dir = data_directory(
DATA_PATH, getattr(driver.config, 'farm_preview_data_dir', None),
)
runtime = Runtime(
@@ -29,7 +29,6 @@ runtime = Runtime(
getattr(driver.config, "farm_preview_deployment", "zhenxun-preview"),
str(getattr(driver.config, "farm_preview_image_format", "AUTO")).upper(),
font_dir=FONT_PATH,
legacy_data_dir=legacy_data_dir,
)
async def start_runtime():
from zhenxun.configs.config import Config
+4 -25
View File
@@ -1,30 +1,9 @@
"""农场默认目录与旧预览目录的无覆盖迁移。"""
"""农场数据目录选择。"""
from pathlib import Path
def data_directories(base, configured=None):
def data_directory(base, configured=None):
if configured is not None:
return Path(configured), None
return Path(base) / 'farm_db', Path(base) / 'farm_preview'
def prepare_directory(target, legacy=None):
target = Path(target).absolute()
if legacy is not None:
legacy = Path(legacy).absolute()
if (target.name != 'farm_db' or legacy.name != 'farm_preview'
or target.parent.resolve() != legacy.parent.resolve()):
raise ValueError('Farm migration requires default sibling paths')
if target.is_symlink() or legacy.is_symlink():
raise ValueError('Farm migration does not follow directory links')
if (legacy / 'farm-preview.sqlite3').is_file():
if target.exists():
raise FileExistsError(
'farm_db already exists beside farm_preview; '
'select farm_preview_data_dir explicitly before startup'
)
legacy.rename(target)
return True
target.mkdir(parents=True, exist_ok=True)
return False
return Path(configured)
return Path(base) / 'farm_db'
+21 -8
View File
@@ -13,7 +13,6 @@ from ._vendor.farm_core.presentation import receipt_text, snapshot_text
from ._vendor.farm_render import FarmRenderer, RenderOptions, RenderService
from ._vendor.farm_render.seasons import season_for
from ._vendor.farm_render.shop import shop_page
from .data_paths import prepare_directory
logger = logging.getLogger(__name__)
COMMANDS = {
@@ -36,9 +35,13 @@ HELP = (
"每只虫使生长速度降低5%;成熟时每只虫使本季减产5%。\n"
"成熟后除虫不能恢复本季减产;生长期间可发送:农场除虫。\n"
"我的农场按北京时间自动切换春夏秋冬背景;高清使用相同季节。\n"
"本版使用独立预览数据库,不会读取旧版资产。"
"旧版农场数据在首次使用时迁移,绑定当前机器人和平台。"
)
ERRORS = {
"legacy_identity_bound":"旧农场已绑定其他机器人或平台,请联系管理员。",
"legacy_account_conflict":"新旧农场账号冲突,未覆盖,请联系管理员。",
"legacy_schema_unsupported":"旧农场数据库结构不兼容,请联系管理员。",
"legacy_data_invalid":"旧农场数据异常,迁移已回滚,请联系管理员。",
"not_registered":"请先发送:开通农场",
"unknown_plant":"没有这种作物,请查看种子商店。",
"ambiguous_plant":"存在同名作物,请使用商店或仓库中的 #编号。",
@@ -69,23 +72,23 @@ def operation_id(bot_id, platform, user_id, message_id, action):
class Runtime:
def __init__(self, data_dir, font_path=None, deployment="zhenxun-preview",
image_format="AUTO", *, font_dir=None, legacy_data_dir=None):
image_format="AUTO", *, font_dir=None):
self.data_dir = Path(data_dir).resolve()
self.legacy_data_dir = legacy_data_dir
self.font_path = font_path
self.font_dir = Path(font_dir or "resources/font")
self.deployment = deployment
self.image_format = image_format
self.store = SQLiteStore(self.data_dir / "farm-preview.sqlite3")
self.store = SQLiteStore(self.data_dir / "farm.sqlite3")
self.service = FarmService(self.store, Catalogue.bundled())
self.rendering = None
self.started = False
self.log_handler = None
self.legacy_source = self.data_dir / 'farm.db'
async def start(self):
if self.started:
return
migrated = prepare_directory(self.data_dir, self.legacy_data_dir)
self.data_dir.mkdir(parents=True, exist_ok=True)
self.log_handler = logging.FileHandler(
self.data_dir / "farm-preview.log", encoding="utf-8",
)
@@ -94,8 +97,6 @@ class Runtime:
))
logging.getLogger(__package__).setLevel(logging.INFO)
logging.getLogger(__package__).addHandler(self.log_handler)
if migrated:
logger.info('farm data directory migrated farm_preview -> farm_db')
try:
await self.store.initialize(catalogue=self.service.catalogue)
except Exception:
@@ -148,6 +149,18 @@ class Runtime:
return "农场尚未初始化,请检查启动日志。"
try:
who = Identity(self.deployment, str(bot_id), platform, str(user_id))
try:
await self.store.migrate_legacy(
self.legacy_source, who, self.service.catalogue,
self.service.clock(),
)
except (FarmError, KeyError, TypeError) as error:
logger.exception('farm legacy migration failed')
code = getattr(error, 'code', '')
return ERRORS.get(
code if code.startswith('legacy_') else '',
'旧农场数据无法迁移,请联系管理员检查日志。',
)
args = shlex.split(arguments)
if action == "farm" and args:
aliases = {
+128
View File
@@ -0,0 +1,128 @@
"""只读旧玩家库并在调用方事务内转换单个用户。"""
from contextlib import closing
from dataclasses import asdict
import hashlib
import json
from pathlib import Path
import sqlite3
from .domain import Asset, FarmError, Plot, integer, text
from .repository import SQLiteRepository
def migration_id(user_id):
return 'legacy-v1:' + hashlib.sha256(user_id.encode()).hexdigest()
def read_user(path, user_id):
path = Path(path).resolve()
if not path.is_file():
return None
with closing(sqlite3.connect(path.as_uri() + '?mode=ro', uri=True)) as db:
db.row_factory = sqlite3.Row
db.execute('BEGIN')
tables = {row[0] for row in db.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)}
required = {'user', 'userSeed', 'userPlant', 'userSoil'}
if not required <= tables:
raise FarmError('legacy_schema_unsupported')
users = db.execute(
'SELECT * FROM user WHERE uid=?', (user_id,),
).fetchall()
if not users:
return None
if len(users) != 1:
raise FarmError('legacy_data_invalid')
archive = {}
for table in sorted(tables):
quoted = '"' + table.replace('"', '""') + '"'
columns = {row[1] for row in db.execute(
f'PRAGMA table_info({quoted})'
)}
if 'uid' in columns:
archive[table] = [dict(row) for row in db.execute(
f'SELECT * FROM {quoted} WHERE uid=?', (user_id,),
)]
return archive
def legacy_plot(row, catalogue):
slot = integer(row['soilIndex'], 1, 10_000)
level = integer(row['soilLevel'], 0, 3)
bonuses = dict(yield_bonus=(10, 10, 20, 28)[level],
experience_bonus=28 if level == 3 else 0)
planted = row['isSoilPlanted']
if planted not in (None, 0, 1):
raise FarmError('legacy_data_invalid')
if not row['plantName']:
if planted == 1:
raise FarmError('legacy_data_invalid')
return Plot(slot, **bonuses)
plant = catalogue.get(row['plantName'])
count = integer(row['harvestCount'])
wilted = integer(row['wiltStatus'], 0, 1)
pests = integer(row['bugStatus'], 0, 1)
mature = integer(row['matureTime'])
return Plot(
slot, plant, mature, max(count, plant.crop) if wilted else count,
**bonuses, pest_count=pests, pest_spawned=pests,
maturity_pests=pests,
)
def import_user(db, identity, archive, catalogue, now):
operation = migration_id(identity.user_id)
if db.execute('SELECT 1 FROM accounts WHERE identity=?',
(identity.key,)).fetchone():
raise FarmError('legacy_account_conflict')
user = archive['user'][0]
text(user['name'], 64)
slots = integer(user['soil'], 1, 10_000)
balances = {
Asset.POINT: integer(user['point']),
Asset.VIP_POINT: integer(user['vipPoint']),
Asset.EXPERIENCE: integer(user['exp']),
}
db.execute('INSERT INTO accounts(identity, name) VALUES (?, ?)',
(identity.key, user['name']))
repo = SQLiteRepository(db, identity.key, operation, 'legacy_import', now)
for asset, balance in balances.items():
repo.asset(asset, balance)
inventory = set()
for table, name, quantity in (
('userSeed', 'seed', 'seeds'), ('userPlant', 'plant', 'crops'),
):
for row in archive[table]:
plant = catalogue.get(row[name])
key = (plant.plant_id, quantity)
if key in inventory:
raise FarmError('legacy_data_invalid')
inventory.add(key)
repo.stock(plant, **{quantity: integer(row['count'])})
if table == 'userPlant':
repo.lock(plant, integer(row['isLock']) != 0)
plots = {}
for row in archive['userSoil']:
plot = legacy_plot(row, catalogue)
if plot.slot in plots or plot.slot > slots:
raise FarmError('legacy_data_invalid')
plots[plot.slot] = plot
db.executemany('INSERT INTO plots(identity, slot) VALUES (?, ?)',
[(identity.key, slot) for slot in range(1, slots + 1)])
for plot in plots.values():
repo.save_plot(plot)
result = {
'migration_version': 1, 'account': asdict(repo.account()),
'legacy_rows': archive,
'unsupported_tables': sorted(set(archive) - {
'user', 'userSeed', 'userPlant', 'userSoil',
}),
}
db.execute(
'INSERT INTO operations VALUES (?, ?, ?, ?, ?, ?)',
(identity.key, operation, 'legacy_import', operation,
json.dumps(result, ensure_ascii=False), now),
)
return True
+42
View File
@@ -252,6 +252,48 @@ class SQLiteStore:
if not self._ready:
raise StorageError("store_not_initialized_or_closed")
async def migrate_legacy(self, source, identity, catalogue, now):
from .legacy import import_user, migration_id, read_user
self._ensure_ready()
def execute():
with self._connection() as connection:
self._validate(connection)
connection.execute('BEGIN IMMEDIATE')
try:
bound = connection.execute(
"SELECT identity FROM operations "
"WHERE operation_id=? AND action='legacy_import'",
(migration_id(identity.user_id),),
).fetchall()
if bound:
if any(row[0] != identity.key for row in bound):
raise FarmError('legacy_identity_bound')
connection.commit()
return False
archive = read_user(source, identity.user_id)
migrated = bool(archive) and import_user(
connection, identity, archive, catalogue, now,
)
connection.commit()
if migrated:
LOGGER.info(
'farm legacy migration committed operation=%s '
'tables=%s', migration_id(identity.user_id),
{name: len(rows) for name, rows in archive.items()},
)
return migrated
except BaseException:
connection.rollback()
LOGGER.exception(
'farm legacy migration rolled back operation=%s',
migration_id(identity.user_id),
)
raise
return await self._offload(execute)
async def transact(
self, identity: Identity, operation_id: str, action: str,
parameters: dict, now: int, operation,
+4 -63
View File
@@ -1,5 +1,4 @@
import importlib.util
import os
from pathlib import Path
import tempfile
import unittest
@@ -20,66 +19,8 @@ class DataDirectoryTests(unittest.TestCase):
self.base = Path(self.temp.name)
def test_default_and_explicit_directory(self):
self.assertEqual(self.module.data_directories(self.base), (
self.base / 'farm_db', self.base / 'farm_preview',
))
custom = self.base / 'custom'
self.assertEqual(self.module.data_directories(self.base, custom), (
custom, None,
))
def test_migrate_preserves_database_log_and_sidecars(self):
old = self.base / 'farm_preview'
old.mkdir()
for name in ('farm-preview.sqlite3', 'farm-preview.sqlite3-wal',
'farm-preview.log'):
(old / name).write_bytes(name.encode())
target = self.base / 'farm_db'
self.assertTrue(self.module.prepare_directory(target, old))
self.assertFalse(old.exists())
for path in target.iterdir():
self.assertEqual(path.read_bytes(), path.name.encode())
self.assertFalse(self.module.prepare_directory(target, old))
def test_conflicting_directory_stops_without_overwrite(self):
old, target = self.base / 'farm_preview', self.base / 'farm_db'
old.mkdir()
target.mkdir()
(old / 'farm-preview.sqlite3').write_bytes(b'old')
(target / 'farm-preview.sqlite3').write_bytes(b'new')
with self.assertRaises(FileExistsError):
self.module.prepare_directory(target, old)
self.assertEqual((old / 'farm-preview.sqlite3').read_bytes(), b'old')
self.assertEqual((target / 'farm-preview.sqlite3').read_bytes(), b'new')
def test_migration_rejects_paths_outside_default_siblings(self):
with self.assertRaises(ValueError):
self.module.prepare_directory(
self.base / 'farm_db', self.base / 'other/farm_preview',
)
@unittest.skipUnless(os.name == 'nt', 'Windows short paths only')
def test_migration_accepts_short_and_long_parent_paths(self):
import ctypes
parent = self.base / 'Farm migration long directory'
parent.mkdir()
buffer = ctypes.create_unicode_buffer(32768)
get_short_path = ctypes.windll.kernel32.GetShortPathNameW
get_short_path.argtypes = (
ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint,
self.assertEqual(
self.module.data_directory(self.base), self.base / 'farm_db',
)
get_short_path.restype = ctypes.c_uint
length = get_short_path(str(parent), buffer, len(buffer))
self.assertTrue(0 < length < len(buffer))
short_parent = Path(buffer.value)
if short_parent == parent.resolve():
self.skipTest('Short path aliases are disabled on this volume')
old = short_parent / 'farm_preview'
old.mkdir()
(old / 'farm-preview.sqlite3').write_bytes(b'preserved')
target = parent.resolve() / 'farm_db'
self.assertTrue(self.module.prepare_directory(target, old))
self.assertEqual((target / 'farm-preview.sqlite3').read_bytes(),
b'preserved')
self.assertFalse(old.exists())
custom = self.base / 'custom'
self.assertEqual(self.module.data_directory(self.base, custom), custom)
+180
View File
@@ -0,0 +1,180 @@
import asyncio
from contextlib import closing
from dataclasses import replace
import json
from pathlib import Path
import sqlite3
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
from farm_core import Catalogue, FarmService, Identity, SQLiteStore
from farm_core.domain import FarmError
def create_legacy(path):
with closing(sqlite3.connect(path)) as db, db:
db.executescript('''
CREATE TABLE user(uid TEXT PRIMARY KEY, name TEXT, exp INTEGER,
point INTEGER, vipPoint INTEGER, soil INTEGER);
CREATE TABLE userSeed(uid TEXT, seed TEXT, count INTEGER);
CREATE TABLE userPlant(uid TEXT, plant TEXT, count INTEGER,
isLock INTEGER);
CREATE TABLE userSoil(uid TEXT, soilIndex INTEGER, plantName TEXT,
matureTime INTEGER, soilLevel INTEGER, wiltStatus INTEGER,
bugStatus INTEGER, harvestCount INTEGER, isSoilPlanted INTEGER);
CREATE TABLE userItem(uid TEXT, item TEXT, count INTEGER);
INSERT INTO user VALUES ('1', '旧农场', 900, 1234, 56, 4);
INSERT INTO userSeed VALUES ('1', '白萝卜', 12);
INSERT INTO userPlant VALUES ('1', '白萝卜', 23, 1);
INSERT INTO userSoil VALUES
('1', 1, '白萝卜', 2000, 2, 0, 1, 0, 1),
('1', 2, '白萝卜', 1000, 3, 1, 0, 0, 1),
('1', 3, '', 0, 0, 0, 0, 0, 0);
INSERT INTO userItem VALUES ('1', '化肥', 7);
''')
class LegacyMigrationTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.source = self.root / 'farm.db'
create_legacy(self.source)
self.catalogue = Catalogue.bundled()
self.store = SQLiteStore(self.root / 'farm.sqlite3')
await self.store.initialize(self.catalogue)
self.who = Identity('zhenxun-preview', 'bot', 'OneBot V11', '1')
async def asyncTearDown(self):
await self.store.close()
self.temp.cleanup()
async def migrate(self, who=None):
return await self.store.migrate_legacy(
self.source, who or self.who, self.catalogue, 1500,
)
async def assert_empty(self):
with closing(sqlite3.connect(self.store.path)) as db, db:
for table in ('accounts', 'inventory', 'plots', 'operations',
'ledger'):
self.assertEqual(db.execute(
f'SELECT COUNT(*) FROM {table}'
).fetchone()[0], 0)
async def test_assets_plots_archive_and_source_preserved(self):
before = self.source.read_bytes()
self.assertTrue(await self.migrate())
snapshot = await self.store.snapshot(self.who)
self.assertEqual((snapshot.account.name, snapshot.account.point,
snapshot.account.vip_point,
snapshot.account.experience),
('旧农场', 1234, 56, 900))
item = snapshot.inventory[0]
self.assertEqual((item.seeds, item.crops, item.locked), (12, 23, True))
self.assertEqual(len(snapshot.plots), 4)
self.assertEqual(snapshot.plots[0].mature_at, 2000)
self.assertEqual(snapshot.plots[0].pest_count, 1)
self.assertEqual(snapshot.plots[0].yield_bonus, 20)
self.assertTrue(snapshot.plots[1].withered)
self.assertEqual(snapshot.plots[1].experience_bonus, 28)
self.assertIsNone(snapshot.plots[3].plant)
with closing(sqlite3.connect(self.store.path)) as db, db:
result = json.loads(db.execute(
"SELECT result FROM operations WHERE action='legacy_import'"
).fetchone()[0])
self.assertEqual(result['legacy_rows']['userItem'][0]['count'], 7)
self.assertEqual(result['unsupported_tables'], ['userItem'])
self.assertEqual(db.execute(
'SELECT SUM(delta) FROM ledger WHERE asset=?', ('point',),
).fetchone()[0], 1234)
self.assertEqual(db.execute('PRAGMA foreign_key_check').fetchall(),
[])
self.assertEqual(self.source.read_bytes(), before)
async def test_repeated_import_does_not_restore_spent_assets(self):
await self.migrate()
service = FarmService(self.store, self.catalogue, clock=lambda: 1500)
await service.buy(self.who, '白萝卜', 1, 'buy')
before = await self.store.snapshot(self.who)
self.assertFalse(await self.migrate())
self.assertEqual(await self.store.snapshot(self.who), before)
await self.store.close()
self.store = SQLiteStore(self.root / 'farm.sqlite3')
await self.store.initialize(self.catalogue)
self.source.rename(self.root / 'farm.db.saved')
self.assertFalse(await self.migrate())
self.assertEqual(await self.store.snapshot(self.who), before)
async def test_concurrent_identities_bind_only_once(self):
other = Identity('zhenxun-preview', 'other', 'QQ', '1')
results = await asyncio.gather(
self.migrate(), self.migrate(other), return_exceptions=True,
)
self.assertEqual(sum(result is True for result in results), 1)
failures = [r for r in results if isinstance(r, FarmError)]
self.assertEqual([r.code for r in failures], ['legacy_identity_bound'])
with closing(sqlite3.connect(self.store.path)) as db, db:
self.assertEqual(db.execute(
'SELECT COUNT(*) FROM accounts'
).fetchone()[0], 1)
async def test_existing_account_is_not_overwritten(self):
service = FarmService(self.store, self.catalogue, clock=lambda: 1500)
await service.register(self.who, '新农场', 'new')
before = await self.store.snapshot(self.who)
with self.assertRaisesRegex(FarmError, 'legacy_account_conflict'):
await self.migrate()
self.assertEqual(await self.store.snapshot(self.who), before)
async def test_unknown_crop_rolls_back_all_tables(self):
with closing(sqlite3.connect(self.source)) as db, db:
db.execute("UPDATE userSeed SET seed='不存在的作物'")
with self.assertRaisesRegex(FarmError, 'unknown_plant'):
await self.migrate()
await self.assert_empty()
async def test_negative_balance_is_rejected(self):
with closing(sqlite3.connect(self.source)) as db, db:
db.execute('UPDATE user SET point=-1')
with self.assertRaisesRegex(FarmError, 'invalid_integer'):
await self.migrate()
await self.assert_empty()
async def test_ambiguous_crop_is_not_guessed(self):
plant = self.catalogue.get('白萝卜')
self.catalogue = Catalogue((plant, replace(plant, plant_id=999999)))
with self.assertRaisesRegex(FarmError, 'ambiguous_plant'):
await self.migrate()
await self.assert_empty()
async def test_duplicate_plot_rolls_back_and_can_retry_after_repair(self):
with closing(sqlite3.connect(self.source)) as db, db:
db.execute('INSERT INTO userSoil SELECT * FROM userSoil '
'WHERE soilIndex=1')
with self.assertRaisesRegex(FarmError, 'legacy_data_invalid'):
await self.migrate()
await self.assert_empty()
with closing(sqlite3.connect(self.source)) as db, db:
db.execute('DELETE FROM userSoil WHERE rowid='
'(SELECT MAX(rowid) FROM userSoil)')
self.assertTrue(await self.migrate())
async def test_missing_source_or_user_does_not_create_account(self):
self.assertFalse(await self.migrate(
Identity('zhenxun-preview', 'bot', 'OneBot V11', 'missing'),
))
self.source.unlink()
self.assertFalse(await self.migrate())
await self.assert_empty()
async def test_missing_schema_is_rejected(self):
with closing(sqlite3.connect(self.source)) as db, db:
db.execute('DROP TABLE userSeed')
with self.assertRaisesRegex(FarmError, 'legacy_schema_unsupported'):
await self.migrate()
await self.assert_empty()
+43 -10
View File
@@ -1,4 +1,5 @@
import asyncio
from contextlib import closing
from dataclasses import replace
import importlib
import importlib.util
@@ -216,29 +217,61 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(before, await self.call("inventory"))
async def test_default_directory_migration_keeps_account_and_inventory(self):
old = self.root / 'migration/farm_preview'
async def test_existing_preview_directory_is_ignored(self):
old = self.root / 'directories/farm_preview'
target = old.with_name('farm_db')
previous = self.module.Runtime(old, choose_font())
await previous.start()
try:
await previous.execute('register', '迁移农场', **self.context)
before = await previous.execute('inventory', '', **self.context)
await previous.execute('register', '旧农场', **self.context)
finally:
await previous.close()
current = self.module.Runtime(
target, choose_font(), legacy_data_dir=old,
)
old_database = old / 'farm.sqlite3'
before = old_database.read_bytes()
target.mkdir()
current = self.module.Runtime(target, choose_font())
await current.start()
try:
self.assertFalse(old.exists())
self.assertTrue((target / 'farm-preview.sqlite3').is_file())
self.assertEqual(old_database.read_bytes(), before)
self.assertTrue((target / 'farm.sqlite3').is_file())
self.assertEqual(await current.execute(
'inventory', '', **self.context,
), before)
), '请先发送:开通农场')
finally:
await current.close()
async def test_first_command_imports_legacy_and_binds_identity(self):
from test_legacy_migration import create_legacy
source = self.root / 'data/farm.db'
create_legacy(source)
before = source.read_bytes()
result = await self.call('inventory')
self.assertIn('旧农场', result)
self.assertIn('1234', result)
self.assertTrue((self.root / 'data/farm.sqlite3').is_file())
self.assertFalse((self.root / 'data/farm-preview.sqlite3').exists())
self.assertEqual(await self.call('inventory'), result)
self.assertIn('已绑定其他机器人或平台', await self.call(
'register', bot_id='other',
))
self.assertEqual(source.read_bytes(), before)
async def test_failed_migration_does_not_register_empty_account(self):
from test_legacy_migration import create_legacy
import sqlite3
source = self.root / 'data/farm.db'
create_legacy(source)
with closing(sqlite3.connect(source)) as db, db:
db.execute("UPDATE userSeed SET seed='不存在的作物'")
self.assertIn('无法迁移', await self.call('register'))
identity = self.module.Identity(
'zhenxun-preview', 'bot', 'OneBot V11', '1',
)
with self.assertRaisesRegex(self.module.FarmError, 'not_registered'):
await self.runtime.store.snapshot(identity)
async def test_mock_host_load_registers_hooks_and_original_matchers(self):
hooks = {"start":[], "close":[], "commands":{}}
driver = SimpleNamespace(