rebuild(stage-14): deliver migration and recovery tooling

This commit is contained in:
leefer
2026-07-30 08:29:37 +08:00
parent 9eb7b548f3
commit fa7a8dde06
17 changed files with 1397 additions and 3 deletions
+170
View File
@@ -0,0 +1,170 @@
# ruff: noqa: E501 - compact SQL fixtures intentionally mirror the legacy schema.
from __future__ import annotations
import base64
import hashlib
import json
import sqlite3
from cryptography.fernet import Fernet
from backend.security.passwords import PasswordHasher
from tools.legacy_migration import LegacyMigrator
def _legacy_database(path, key: str) -> None:
salt = b"0123456789abcdef"
digest = hashlib.scrypt(
b"Password123", salt=salt, n=2**14, r=8, p=1, dklen=32
)
encoded_salt = base64.urlsafe_b64encode(salt).decode()
encoded_hash = base64.urlsafe_b64encode(digest).decode()
fernet = Fernet(key.encode())
credentials = {
"tushare_token": "secret-tushare",
"ifind_refresh_token": "secret-refresh",
"member_daily_limit": 61,
"llm_models": [{
"id": "main", "name": "Primary", "base_url": "https://model.invalid/v1",
"model": "model-a", "api_key": "secret-model",
}],
"primary_model_id": "main",
}
with sqlite3.connect(path) as connection:
connection.executescript(
"""
CREATE TABLE users (id INTEGER PRIMARY KEY,username TEXT,password_salt TEXT,
password_hash TEXT,created_at TEXT,updated_at TEXT,role TEXT,llm_mode TEXT,
membership_status TEXT,membership_plan TEXT,membership_starts_at TEXT,
membership_expires_at TEXT);
CREATE TABLE user_birth_profiles
(user_id INTEGER PRIMARY KEY,encrypted_payload TEXT,updated_at TEXT);
CREATE TABLE llm_usage (id INTEGER PRIMARY KEY,user_id INTEGER,feature TEXT,
source TEXT,model TEXT,status TEXT,latency_ms INTEGER,created_at TEXT,
role TEXT,prompt_version TEXT,error_code TEXT,input_tokens INTEGER,output_tokens INTEGER);
CREATE TABLE system_settings
(setting_key TEXT PRIMARY KEY,encrypted_payload TEXT,updated_at TEXT);
CREATE TABLE stock_master (ts_code TEXT PRIMARY KEY,code TEXT,name TEXT,
industry TEXT,market TEXT,list_date TEXT,updated_at TEXT);
CREATE TABLE daily_bars (trade_date TEXT,ts_code TEXT,open REAL,high REAL,
low REAL,close REAL,pct_chg REAL,vol REAL,amount REAL);
CREATE TABLE dashboard_snapshots
(trade_date TEXT PRIMARY KEY,source TEXT,payload TEXT,record_count INTEGER,updated_at TEXT);
CREATE TABLE watchlist (user_id INTEGER,code TEXT,name TEXT,sector TEXT,color TEXT,
created_at TEXT,updated_at TEXT,remark TEXT);
CREATE TABLE review_notes (id INTEGER PRIMARY KEY,code TEXT,stock_name TEXT,
trade_date TEXT,content TEXT,plan TEXT,created_at TEXT,updated_at TEXT,
user_id INTEGER,summary TEXT);
CREATE TABLE trade_entries (id INTEGER PRIMARY KEY,user_id INTEGER,trade_date TEXT,
code TEXT,name TEXT,action TEXT,price REAL,quantity INTEGER,position_pct REAL,
pnl_amount REAL,pnl_pct REAL,thesis TEXT,execution TEXT,emotion TEXT,tags TEXT,
created_at TEXT,updated_at TEXT);
CREATE TABLE alerts (id INTEGER PRIMARY KEY,user_id INTEGER,kind TEXT,title TEXT,
content TEXT,available_date TEXT,code TEXT,dedupe_key TEXT,is_read INTEGER,
created_at TEXT,updated_at TEXT,read_at TEXT);
CREATE TABLE assistant_messages (id INTEGER PRIMARY KEY,user_id INTEGER,role TEXT,
content TEXT,context_date TEXT,created_at TEXT);
CREATE TABLE mentor_preferences (user_id INTEGER,mentor_id TEXT,pinned INTEGER,
sort_order INTEGER,updated_at TEXT);
CREATE TABLE mentor_messages (id INTEGER PRIMARY KEY,user_id INTEGER,mentor_id TEXT,
trade_date TEXT,role TEXT,content TEXT,meta TEXT,created_at TEXT);
CREATE TABLE heaven_readings (id INTEGER PRIMARY KEY,user_id INTEGER,mode TEXT,
context_date TEXT,subject TEXT,subject_detail TEXT,answer TEXT,
context_snapshot TEXT,dedupe_key TEXT,created_at TEXT);
CREATE TABLE screener_runs (id INTEGER PRIMARY KEY,trade_date TEXT,regime TEXT,
strategy_name TEXT,formula TEXT,result TEXT,created_at TEXT,user_id INTEGER,mode TEXT);
CREATE TABLE screener_strategies (id INTEGER PRIMARY KEY,name TEXT,description TEXT,
regimes TEXT,formula TEXT,builtin INTEGER,created_at TEXT,updated_at TEXT,user_id INTEGER);
CREATE TABLE strategy_tracks (id INTEGER PRIMARY KEY,user_id INTEGER,run_id INTEGER,
selection_date TEXT,strategy_name TEXT,ts_code TEXT,code TEXT,name TEXT,sector TEXT,
entry_price REAL,created_at TEXT,updated_at TEXT);
CREATE TABLE data_snapshots (kind TEXT,cache_key TEXT,source TEXT,payload TEXT,
updated_at TEXT,PRIMARY KEY(kind,cache_key));
"""
)
now = "2026-07-29T16:00:00+08:00"
connection.execute(
"INSERT INTO users VALUES (8,'leefer',?,?,?,?,'admin','auto','active','永久',?,NULL)",
(encoded_salt, encoded_hash, now, now, now),
)
profile = fernet.encrypt(b'{"gender":"male"}').decode()
connection.execute("INSERT INTO user_birth_profiles VALUES (8,?,?)", (profile, now))
connection.execute(
"INSERT INTO llm_usage VALUES (1,8,'mentor','model','','success',3,?,'','','',0,0)",
(now,),
)
encrypted = fernet.encrypt(json.dumps(credentials).encode()).decode()
connection.execute("INSERT INTO system_settings VALUES ('credentials',?,?)", (encrypted, now))
connection.execute(
"INSERT INTO stock_master VALUES ('000001.SZ','000001','平安银行','银行','主板','19910403',?)",
(now,),
)
connection.execute(
"INSERT INTO daily_bars VALUES ('20260729','000001.SZ',10,11,9,10.5,5,100,1000)"
)
connection.execute(
"INSERT INTO dashboard_snapshots VALUES ('20260729','tushare','{}',1,?)", (now,)
)
connection.execute(
"INSERT INTO watchlist VALUES (8,'000001','平安银行','银行','red',?,?, '长期')",
(now, now),
)
connection.execute(
"INSERT INTO review_notes VALUES (1,'000001','平安银行','20260729','复盘','计划',?,?,8,'总结')",
(now, now),
)
connection.execute(
"INSERT INTO trade_entries VALUES (1,8,'20260729','000001','平安银行','buy',10,100,20,NULL,NULL,'逻辑','执行','calm','[]',?,?)",
(now, now),
)
connection.execute(
"INSERT INTO alerts VALUES (1,8,'manual','提醒','','20260730','','a',0,?,?,NULL)",
(now, now),
)
connection.execute("INSERT INTO assistant_messages VALUES (1,8,'user','问题','20260729',?)", (now,))
connection.execute("INSERT INTO mentor_preferences VALUES (8,'mentor',1,1,?)", (now,))
connection.execute(
"INSERT INTO mentor_messages VALUES (1,8,'mentor','20260729','user','问题','',?)", (now,)
)
connection.execute(
"INSERT INTO heaven_readings VALUES (1,8,'fortune','20260729','','','结果','{}','d',?)", (now,)
)
result = json.dumps({"candidates": [{"identifier": "000001.SZ", "close": 10.5}]})
connection.execute(
"INSERT INTO screener_runs VALUES (1,'20260729','','策略','{}',?, ?,8,'curated')",
(result, now),
)
connection.execute(
"INSERT INTO screener_strategies VALUES (1,'自定义','','[]','{}',0,?,?,8)",
(now, now),
)
connection.execute(
"INSERT INTO strategy_tracks VALUES (1,8,1,'20260729','策略','000001.SZ','000001','平安银行','银行',10.5,?,?)",
(now, now),
)
connection.execute(
"INSERT INTO data_snapshots VALUES ('popularity_v1','20260729','legacy','{}',?)", (now,)
)
def test_legacy_migration_is_idempotent_and_preserves_login(tmp_path) -> None:
source = tmp_path / "legacy.db"
target = tmp_path / "next.db"
key = Fernet.generate_key().decode()
_legacy_database(source, key)
first = LegacyMigrator(source, target, key).run()
second = LegacyMigrator(source, target, key).run()
assert first["integrity"] == second["integrity"] == "ok"
rendered = json.dumps(first)
assert "secret-tushare" not in rendered
assert "secret-model" not in rendered
with sqlite3.connect(target) as connection:
password = connection.execute("SELECT password_hash FROM users WHERE id=8").fetchone()[0]
assert PasswordHasher().verify("Password123", password)
assert connection.execute("SELECT count(*) FROM watchlist_entries").fetchone()[0] == 1
assert connection.execute("SELECT count(*) FROM screener_runs").fetchone()[0] == 1
assert connection.execute("SELECT count(*) FROM strategy_tracks").fetchone()[0] == 1
assert connection.execute("SELECT daily_llm_limit FROM memberships").fetchone()[0] == 61