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
+6 -1
View File
@@ -124,7 +124,7 @@ HTTP/后台任务 -> 业务服务 -> Repository/DataGateway/LLMGateway -> 基础
| 11 | 问天观势、观气、观心 | 公式、安全门、动画和历史验收 | 已完成 |
| 12 | 我的复盘、提醒和复盘助手 | 私有数据、汇总和弹窗验收 | 已完成 |
| 13 | 全站移动端重组和无障碍 | 320/390/430/768/横屏验收 | 已完成 |
| 14 | 数据迁移、Docker、备份恢复和性能安全 | 旧库副本迁移、回滚演练 | 进行中 |
| 14 | 数据迁移、Docker、备份恢复和性能安全 | 旧库副本迁移、回滚演练 | 已完成 |
| 15 | 全量验收、减法审计和切换准备 | 规格覆盖矩阵、最终报告 | 未开始 |
阶段编号不会因为上下文压缩重新规划。只有发现产品规格自身矛盾时,才记录决策并调整阶段内容;不得通过新增阶段掩盖未完成工作。
@@ -255,4 +255,9 @@ HTTP/后台任务 -> 业务服务 -> Repository/DataGateway/LLMGateway -> 基础
- 可访问性补齐跳到主内容、排序`aria-sort`、弹窗焦点回收、44px触控目标和有效的1ms减少动态效果令牌。移动规则集中在537行的唯一`mobile.css`,未复制页面、API或业务状态。
- 阶段13视觉与验收证据位于`next/docs/evidence/stage-13/`
- 新系统进入阶段14:交付可重复的旧库迁移、Docker运行、备份恢复、升级回退及性能安全检查,所有演练仅作用于副本和新系统容器资产。
- 阶段14已完成:一次性迁移工具以只读 URI 打开旧库,真实副本迁移保留3个账号、会员与模型配置、全部已归属私有记录、196次历史选股、16条跟踪及受控展示归档;完整性检查通过且外键违规为0。
- 备份使用SQLite在线备份API并将数据库、环境密钥材料和私有Skill归入同一带SHA-256清单的归档;恢复默认拒绝覆盖并在落盘前校验安全路径、文件摘要和数据库完整性。真实恢复演练通过。
- 生产前端改为FastAPI同端口提供,SPA深链接与API 404边界通过测试;新容器定义采用多阶段构建、非root、只读根文件系统、持久卷和健康检查。开发机没有Docker,实际镜像构建和容器重启恢复列为NAS切换前阻断验收项。
- 阶段14门禁:Ruff、96项pytest、Vue类型检查、7项Vitest、Vite生产构建及17项Playwright全部通过;真实迁移库副本行情摘要读取P95为23.55ms。证据位于`next/docs/evidence/stage-14/`
- 新系统进入阶段15:执行规格覆盖矩阵、减法审计、性能安全总验收、人工维护演练和最终切换准备;阶段15不自动切换NAS生产容器。
- 生产切换明确保留为最终人工确认项。
+11
View File
@@ -0,0 +1,11 @@
.git
.venv
.pytest_cache
.ruff_cache
data
docs/evidence
frontend/node_modules
frontend/dist
tests
*.log
.env
+3
View File
@@ -0,0 +1,3 @@
APP_ENCRYPTION_KEY=replace-with-a-persistent-fernet-key
APP_LOG_LEVEL=INFO
APP_TIMEZONE=Asia/Shanghai
+29
View File
@@ -0,0 +1,29 @@
FROM node:24-alpine AS frontend-build
WORKDIR /build/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM python:3.14-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
APP_ENV=production \
APP_HOST=0.0.0.0 \
APP_PORT=8765 \
APP_DATA_DIR=/app/data \
APP_FRONTEND_DIST=/app/frontend/dist
WORKDIR /app
RUN addgroup --system xiaobai && adduser --system --ingroup xiaobai xiaobai
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ ./backend/
COPY config/ ./config/
COPY tools/ ./tools/
COPY --from=frontend-build /build/frontend/dist ./frontend/dist
RUN mkdir -p /app/data && chown -R xiaobai:xiaobai /app
USER xiaobai
EXPOSE 8765
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/api/health', timeout=3)"]
CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8765", "--proxy-headers", "--no-server-header"]
+14 -1
View File
@@ -46,11 +46,24 @@ npm.cmd run dev
回退只允许显式执行;代码回退不会自动回退数据库。
旧库迁移、生产部署与恢复不进入正常运行时代码。迁移只读打开旧库并写入独立目标:
```powershell
$env:APP_ENCRYPTION_KEY='<现有固定密钥>'
.\.venv\Scripts\python.exe -m tools.legacy_migration `
--source ..\data\review.db `
--target data\xiaobai.db `
--report data\migration-report.json
```
一致性备份、校验恢复和 Docker 部署见 [docs/deployment.md](docs/deployment.md)。正式 NAS
容器不会被脚本自动替换,切换必须在最终验收后单独人工确认。
## 最小质量门禁
```powershell
cd next
.\.venv\Scripts\python.exe -m ruff check backend tests
.\.venv\Scripts\python.exe -m ruff check backend tools tests
.\.venv\Scripts\python.exe -m pytest
cd frontend
npm.cmd run check
+14 -1
View File
@@ -2,7 +2,8 @@ import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from backend.bootstrap.container import build_container
from backend.bootstrap.logging import configure_logging
@@ -59,4 +60,16 @@ def create_application(settings: Settings | None = None) -> FastAPI:
install_request_context(application)
install_error_handlers(application)
application.include_router(api_router, prefix="/api")
if runtime.frontend_dist_directory.joinpath("index.html").is_file():
frontend_root = runtime.frontend_dist_directory.resolve()
@application.get("/{requested_path:path}", include_in_schema=False)
def frontend(requested_path: str) -> FileResponse:
if requested_path == "api" or requested_path.startswith("api/"):
raise HTTPException(status_code=404)
candidate = frontend_root.joinpath(requested_path).resolve()
if candidate.is_relative_to(frontend_root) and candidate.is_file():
return FileResponse(candidate)
return FileResponse(frontend_root / "index.html")
return application
+6
View File
@@ -41,6 +41,7 @@ class Settings:
host: str
port: int
encryption_key: str | None
frontend_dist_directory: Path = PROJECT_ROOT / "frontend" / "dist"
timezone: str = "Asia/Shanghai"
@classmethod
@@ -63,6 +64,9 @@ class Settings:
os.getenv("APP_PRIVATE_MENTOR_SKILLS_DIR", ""),
data_directory / "private-mentor-skills",
)
frontend_dist_directory = _resolve_path(
os.getenv("APP_FRONTEND_DIST", ""), PROJECT_ROOT / "frontend" / "dist"
)
log_file = _resolve_path(
os.getenv("APP_LOG_FILE", ""),
data_directory / "logs" / "application.log",
@@ -80,6 +84,7 @@ class Settings:
database_path=database_path,
mentor_skills_directory=mentor_skills_directory,
private_mentor_skills_directory=private_mentor_skills_directory,
frontend_dist_directory=frontend_dist_directory,
log_file=log_file,
log_level=log_level,
host=os.getenv("APP_HOST", "127.0.0.1").strip() or "127.0.0.1",
@@ -98,6 +103,7 @@ class Settings:
database_path=root / "xiaobai-test.db",
mentor_skills_directory=PROJECT_ROOT / "config" / "mentor-skills",
private_mentor_skills_directory=root / "private-mentor-skills",
frontend_dist_directory=root / "frontend-dist",
log_file=None,
log_level="CRITICAL",
host="127.0.0.1",
+26
View File
@@ -0,0 +1,26 @@
services:
web:
build:
context: .
image: xiaobai-review-next:local
restart: unless-stopped
ports:
- "8765:8765"
env_file:
- .env
environment:
APP_ENV: production
APP_HOST: 0.0.0.0
APP_PORT: 8765
APP_DATA_DIR: /app/data
APP_DATABASE_PATH: /app/data/xiaobai.db
APP_PRIVATE_MENTOR_SKILLS_DIR: /app/data/private-mentor-skills
volumes:
- ./data:/app/data
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
+65
View File
@@ -0,0 +1,65 @@
# 新系统部署与回退
本说明只适用于 `next/` 重建版。旧容器在最终人工确认前继续运行,不得原地覆盖。
## 首次准备
1.`.env.example` 复制为 `.env`,写入固定的 Fernet `APP_ENCRYPTION_KEY`。该密钥必须与迁移旧数据时使用的密钥一致。
2. 创建 `data/private-mentor-skills/`,把不公开的 Skill 放在该持久化目录;镜像内只含公开 Skill。
3. 先在独立目录运行迁移,并查看报告中的迁移数、主动跳过项、完整性和校验和。
4. 使用尚未占用的端口启动新容器并完成验收。不要停止旧容器。
```powershell
$env:APP_ENCRYPTION_KEY='<现有固定密钥>'
python -m tools.legacy_migration `
--source ..\data\review.db `
--target .\data\xiaobai.db `
--report .\data\migration-report.json
docker compose config
docker compose build
docker compose up -d
```
`legacy_migration` 以只读 URI 打开旧库,可重复执行;会保留用户编号和密码散列,不迁移会话。旧的每用户 LLM 配置和可由正式数据源重建的原始因子表会明确记录为主动跳过,不会进入新运行时。
## 备份
SQLite 必须通过在线备份 API 取得一致快照,不能在运行中直接复制数据库文件。生产备份应把数据库、环境/密钥文件与私有 Skill 放入同一归档,并把归档存放在容器数据卷之外。
```powershell
python -m tools.backup `
--database .\data\xiaobai.db `
--output D:\backups\xiaobai-2026-07-30.zip `
--private-skills .\data\private-mentor-skills `
--environment-file .\.env `
--apply-retention
```
保留策略至少为最近 7 份日备份与 4 个不同周的周备份。密钥随归档备份意味着归档自身必须保存到受控、加密的 NAS 位置。
## 恢复演练
恢复默认拒绝覆盖,且在写入前校验清单、逐文件 SHA-256、路径安全、SQLite 完整性。
```powershell
python -m tools.restore `
--archive D:\backups\xiaobai-2026-07-30.zip `
--database .\data\restore-drill.db `
--private-skills .\data\restore-private-skills `
--environment-file .\data\restore.env `
--confirm-restore
```
恢复后必须抽验:原账号登录、共享行情快照、自选与复盘、会员状态、模型配置、问天历史、选股历史和跟踪记录。
## 升级与回退
1. 升级前生成一致备份,记录当前镜像标签与数据库 migration 版本。
2. 用新镜像启动独立验收容器;健康检查通过后再切换反向代理或宿主端口。
3. 代码回退只切换至旧镜像,不自动降级数据库。
4. 若新 migration 与旧镜像不兼容,使用升级前的整套备份恢复到独立目录,再启动旧镜像。
5. NAS 正式切换属于单独人工确认项,本仓库脚本不会自动执行切换。
## 本机验证边界
开发机未安装 Docker 时仍可执行全部 Python、前端构建、迁移和备份恢复测试,但不能声称镜像已经实际构建或容器健康检查已经运行。最终切换前应在有 Docker 的 NAS 或预发布机补做 `docker compose build`、只读文件系统、数据卷写入和重启恢复验证。
+51
View File
@@ -0,0 +1,51 @@
# 阶段 14 验收
## 交付边界
- 旧系统只作为只读来源;真实演练源为 `data/review.db`,目标为新建的 `next/data/stage14-migrated.db`
- 迁移逻辑仅存在于一次性工具 `tools/legacy_migration.py`,正常应用不导入旧系统代码或旧数据库。
- 新 Docker、备份和恢复资产仅位于 `next/`,没有停止、修改或替换 NAS 正式容器。
## 真实迁移结果
- 源库 SHA-256`b7c444359c3b9b201a47ce6b23e55ad7ca4eba3d9dd67dfc8544b575d43f4924`
- SQLite `integrity_check=ok`,外键违规 0。
- 3 个账号与会员状态、1 份出生资料、2 项系统数据凭据、3 个模型配置均已转换。
- 6 条自选、3 条复盘、2 条提醒、28 条问师消息、45 条问师偏好、31 条问天历史均已迁移并保留账号归属。
- 196 次历史选股与 16 条手动策略跟踪已迁移;每个历史交易日使用明确标注的归档因子快照,不伪造旧因子覆盖率。
- 5,534 个股票目录、261 个交易日、24 份市场摘要、5,567 份最近 90 根日 K 展示归档和 24 份兼容市场洞察已迁移。
- 真实账号密码验证、管理员与永久会员状态、模型选择、问天历史和选股历史抽验通过。
- 逐项机器报告见 `real-migration-report.json`,报告不含明文令牌或模型密钥。
## 主动删除或重建
- 会话不迁移,切换后所有账号重新登录。
- 已取消的用户自主 LLM 配置不迁移。
- 旧原始因子表不复制成兼容表;由新系统受治理的数据同步和因子任务重建。
- 旧指数行仅有收盘价,缺少新图表契约要求的 OHLC,未伪造成指数 K 线。
- 日 K 展示归档每个标的保留最近 90 根;旧全量数据库和一致性备份继续作为审计资产保留。
## 备份恢复演练
- 使用 SQLite 在线备份 API 创建一致快照,归档同时包含环境密钥材料与 9 个私有 Skill 文件,共 11 个文件。
- 恢复前验证安全路径、逐文件 SHA-256 和 SQLite 完整性;默认拒绝覆盖已有数据库、环境、密钥和私有 Skill 目录。
- 恢复副本 `integrity_check=ok`,账号数 3、历史选股数 196,与迁移目标一致。
- 单元测试确认篡改数据库内容会因校验和不一致被拒绝。
## 生产与安全抽验
- Vite 生产资源由 FastAPI 同端口提供;SPA 深链接可返回入口文件,未知 `/api/*` 保持统一 JSON 404。
- 生产登录 Cookie 含 `HttpOnly``Secure``SameSite=Lax`;生产环境缺少加密密钥会拒绝启动。
- 真实迁移库副本的行情摘要读取 20 次:中位数 20.90msP95 23.55ms;日 K 返回 90 个点。
- 容器使用多阶段构建、非 root 用户、只读根文件系统、持久化数据卷、健康检查、移除 Linux capabilities 和 `no-new-privileges`
- 本机未安装 Docker,因此未声称镜像已实构建;NAS 切换前必须在有 Docker 的预发布环境补做构建、健康检查、只读根文件系统和重启恢复验证。
## 质量门
- Ruff:通过。
- pytest96 项通过。
- Vue 类型检查:通过。
- Vitest:3 个文件、7 项通过。
- Vite 生产构建:通过,CSS 89.25KBJS 279.23KB,均为构建前原始体积。
- Playwright17 项通过,单 worker,耗时 53.6 秒。
- `git diff --check` 与已知令牌/密码扫描:通过。
@@ -0,0 +1,51 @@
{
"source": "C:\\Users\\MoBai\\Documents\\gupiaofupan\\webapp\\data\\review.db",
"target": "C:\\Users\\MoBai\\Documents\\gupiaofupan\\webapp\\next\\data\\stage14-migrated.db",
"source_sha256": "b7c444359c3b9b201a47ce6b23e55ad7ca4eba3d9dd67dfc8544b575d43f4924",
"target_sha256": "9b0ca7faed7a15b4099bd09ae7a0c94b52a0c8a7dbfedb9d2b26bdf47e34d5d2",
"integrity": "ok",
"foreign_key_violations": 0,
"migrated": {
"alerts": 2,
"birth_profiles": 1,
"chart_series": 5567,
"heaven_readings": 31,
"llm_models": 3,
"llm_usage_daily": 9,
"market_entities": 5534,
"market_insight_snapshots": 24,
"market_summaries": 24,
"memberships": 3,
"mentor_messages": 28,
"mentor_preferences": 45,
"review_notes": 3,
"screener_runs": 196,
"strategy_tracks": 16,
"system_credentials": 2,
"trading_days": 261,
"users": 3,
"watchlist_entries": 6
},
"target_counts": {
"users": 3,
"memberships": 3,
"market_entities": 5537,
"market_summaries": 24,
"chart_series": 5567,
"watchlist_entries": 6,
"review_notes": 3,
"trade_entries": 0,
"alerts": 2,
"mentor_messages": 28,
"heaven_readings": 31,
"screener_runs": 196,
"custom_screener_strategies": 0,
"strategy_tracks": 16
},
"intentionally_skipped": {
"sessions": "sessions are intentionally invalidated during cutover",
"user_credentials": "per-user LLM configuration was removed from the product",
"raw_factor_tables": "reproducible provider inputs are rebuilt by governed sync jobs",
"benchmark_bars": "legacy rows lack OHLC values required by the chart contract"
}
}
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import sqlite3
import zipfile
import pytest
from tools.backup import create_backup
from tools.restore import restore_backup
def test_backup_restore_round_trip_and_rejects_tampering(tmp_path) -> None:
database = tmp_path / "source.db"
with sqlite3.connect(database) as connection:
connection.execute("CREATE TABLE sample (value TEXT)")
connection.execute("INSERT INTO sample VALUES ('preserved')")
skills = tmp_path / "skills"
skills.mkdir()
skills.joinpath("private.md").write_text("private", encoding="utf-8")
environment = tmp_path / "source.env"
environment.write_text("APP_ENCRYPTION_KEY=hidden\n", encoding="utf-8")
archive = tmp_path / "xiaobai-2026-07-30.zip"
create_backup(database, archive, private_skills=skills, environment_file=environment)
restored = tmp_path / "restored.db"
restored_skills = tmp_path / "restored-skills"
restore_backup(archive, restored, private_skills=restored_skills)
with sqlite3.connect(restored) as connection:
assert connection.execute("SELECT value FROM sample").fetchone()[0] == "preserved"
assert restored_skills.joinpath("private.md").read_text(encoding="utf-8") == "private"
tampered = tmp_path / "tampered.zip"
with zipfile.ZipFile(archive) as source, zipfile.ZipFile(tampered, "w") as destination:
for item in source.infolist():
content = source.read(item.filename)
replacement = b"broken" if item.filename == "database.sqlite3" else content
destination.writestr(item, replacement)
with pytest.raises(ValueError, match="checksum mismatch"):
restore_backup(tampered, tmp_path / "rejected.db")
def test_restore_refuses_implicit_overwrite(tmp_path) -> None:
database = tmp_path / "source.db"
with sqlite3.connect(database) as connection:
connection.execute("CREATE TABLE sample (value TEXT)")
archive = tmp_path / "backup.zip"
create_backup(database, archive)
destination = tmp_path / "existing.db"
destination.write_bytes(b"keep")
with pytest.raises(FileExistsError):
restore_backup(archive, destination)
assert destination.read_bytes() == b"keep"
+18
View File
@@ -73,3 +73,21 @@ def test_validation_error_does_not_expose_framework_details(tmp_path) -> None:
assert response.status_code == 422
assert response.json()["error"]["code"] == "invalid_request"
assert "integer" not in response.text.lower()
def test_production_frontend_serves_spa_without_capturing_api_404(tmp_path) -> None:
settings = Settings.for_test(tmp_path)
settings.frontend_dist_directory.mkdir(parents=True)
settings.frontend_dist_directory.joinpath("index.html").write_text(
"<main>application</main>", encoding="utf-8"
)
settings.frontend_dist_directory.joinpath("asset.js").write_text(
"window.ready=true", encoding="utf-8"
)
application = create_application(settings)
assert request(application, "/review/history").text == "<main>application</main>"
assert request(application, "/asset.js").text == "window.ready=true"
api_response = request(application, "/api/unknown")
assert api_response.status_code == 404
assert api_response.json()["error"]["code"] == "not_found"
+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
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import argparse
import hashlib
import json
import sqlite3
import tempfile
import zipfile
from collections.abc import Sequence
from contextlib import closing
from datetime import datetime
from pathlib import Path
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def snapshot_database(source: Path, destination: Path) -> None:
source_uri = f"file:{source.resolve().as_posix()}?mode=ro"
with closing(sqlite3.connect(source_uri, uri=True)) as origin:
with closing(sqlite3.connect(destination)) as snapshot:
origin.backup(snapshot)
if snapshot.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
raise RuntimeError("database snapshot failed integrity check")
def create_backup(
database: Path,
output: Path,
*,
private_skills: Path | None = None,
environment_file: Path | None = None,
encryption_key_file: Path | None = None,
) -> dict:
database = database.resolve()
if not database.is_file():
raise FileNotFoundError(database)
output = output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="xiaobai-backup-") as raw:
staging = Path(raw)
database_copy = staging / "database.sqlite3"
snapshot_database(database, database_copy)
files: list[tuple[Path, str]] = [(database_copy, "database.sqlite3")]
for source, archive_name in (
(environment_file, "secrets/environment"),
(encryption_key_file, "secrets/app-encryption.key"),
):
if source:
resolved = source.resolve()
if not resolved.is_file():
raise FileNotFoundError(resolved)
files.append((resolved, archive_name))
if private_skills and private_skills.exists():
files.extend(
(item, f"private-mentor-skills/{item.relative_to(private_skills).as_posix()}")
for item in private_skills.rglob("*")
if item.is_file()
)
manifest = {
"format": 1,
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"files": {
name: {"sha256": sha256(path), "size": path.stat().st_size} for path, name in files
},
}
manifest_path = staging / "manifest.json"
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
temporary = output.with_suffix(output.suffix + ".tmp")
with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.write(manifest_path, "manifest.json")
for path, name in files:
archive.write(path, name)
temporary.replace(output)
return manifest
def apply_retention(directory: Path, *, keep_daily: int = 7, keep_weekly: int = 4) -> list[Path]:
backups = sorted(
directory.glob("xiaobai-*.zip"), key=lambda item: item.stat().st_mtime, reverse=True
)
keep = set(backups[:keep_daily])
weeks: set[str] = set()
for backup in backups:
week = datetime.fromtimestamp(backup.stat().st_mtime).strftime("%G-%V")
if week not in weeks and len(weeks) < keep_weekly:
weeks.add(week)
keep.add(backup)
removed = []
for backup in backups:
if backup not in keep:
backup.unlink()
removed.append(backup)
return removed
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Create a consistent Xiaobai backup archive")
parser.add_argument("--database", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--private-skills", type=Path)
parser.add_argument("--environment-file", type=Path)
parser.add_argument("--encryption-key-file", type=Path)
parser.add_argument("--apply-retention", action="store_true")
return parser
def main(arguments: Sequence[str] | None = None) -> int:
parsed = build_parser().parse_args(arguments)
manifest = create_backup(
parsed.database,
parsed.output,
private_skills=parsed.private_skills,
environment_file=parsed.environment_file,
encryption_key_file=parsed.encryption_key_file,
)
removed = apply_retention(parsed.output.parent) if parsed.apply_retention else []
print(
json.dumps(
{
"backup": str(parsed.output.resolve()),
"files": len(manifest["files"]),
"removed_by_retention": len(removed),
},
ensure_ascii=False,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+629
View File
@@ -0,0 +1,629 @@
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sqlite3
from collections import defaultdict
from collections.abc import Sequence
from datetime import datetime
from pathlib import Path
from typing import Any
from cryptography.fernet import Fernet
from backend.database import MIGRATIONS, Database, MigrationRunner
ARCHIVE_VERSION = "legacy-archive-v1"
def _iso_date(value: Any) -> str:
text = str(value or "").strip()
compact = text[:10].replace("-", "")
return f"{compact[:4]}-{compact[4:6]}-{compact[6:8]}" if len(compact) >= 8 else ""
def _json(value: Any, fallback: Any) -> Any:
if isinstance(value, (dict, list)):
return value
try:
return json.loads(str(value))
except (TypeError, ValueError, json.JSONDecodeError):
return fallback
def _dump(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def _hash_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def _table_exists(connection: sqlite3.Connection, table: str) -> bool:
return (
connection.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
).fetchone()
is not None
)
class LegacyMigrator:
def __init__(self, source: Path, target: Path, encryption_key: str | None) -> None:
self.source_path = source.resolve()
self.target_path = target.resolve()
self.key = encryption_key
self.counts: dict[str, int] = defaultdict(int)
self.skipped: dict[str, str] = {
"sessions": "sessions are intentionally invalidated during cutover",
"user_credentials": "per-user LLM configuration was removed from the product",
"raw_factor_tables": "reproducible provider inputs are rebuilt by governed sync jobs",
"benchmark_bars": "legacy rows lack OHLC values required by the chart contract",
}
self.user_ids: set[int] = set()
self.run_ids: set[int] = set()
def run(self) -> dict[str, Any]:
if self.source_path == self.target_path:
raise ValueError("source and target database paths must differ")
if not self.source_path.is_file():
raise FileNotFoundError(self.source_path)
database = Database(self.target_path)
MigrationRunner(database).upgrade(MIGRATIONS)
source = sqlite3.connect(f"file:{self.source_path.as_posix()}?mode=ro", uri=True)
source.row_factory = sqlite3.Row
try:
with database.transaction() as target:
self._accounts(source, target)
self._system_settings(source, target)
self._market(source, target)
self._private_data(source, target)
self._screener(source, target)
self._insights(source, target)
with database.read() as target:
integrity = str(target.execute("PRAGMA integrity_check").fetchone()[0])
foreign_keys = list(target.execute("PRAGMA foreign_key_check"))
target_counts = {
table: int(target.execute(f"SELECT count(*) FROM {table}").fetchone()[0])
for table in (
"users", "memberships", "market_entities", "market_summaries",
"chart_series", "watchlist_entries", "review_notes", "trade_entries",
"alerts", "mentor_messages", "heaven_readings", "screener_runs",
"custom_screener_strategies", "strategy_tracks",
)
}
finally:
source.close()
if integrity != "ok" or foreign_keys:
raise RuntimeError("migrated database failed integrity validation")
return {
"source": str(self.source_path),
"target": str(self.target_path),
"source_sha256": _hash_file(self.source_path),
"target_sha256": _hash_file(self.target_path),
"integrity": integrity,
"foreign_key_violations": 0,
"migrated": dict(sorted(self.counts.items())),
"target_counts": target_counts,
"intentionally_skipped": self.skipped,
}
def _accounts(self, source: sqlite3.Connection, target: sqlite3.Connection) -> None:
users = source.execute("SELECT * FROM users ORDER BY id").fetchall()
for row in users:
user_id = int(row["id"])
self.user_ids.add(user_id)
password = f"scrypt$16384$8$1${row['password_salt']}${row['password_hash']}"
target.execute(
"""INSERT INTO users
(id,username,username_key,password_hash,is_admin,status,created_at,updated_at)
VALUES (?,?,?,?,?,'active',?,?) ON CONFLICT(id) DO UPDATE SET
username=excluded.username, username_key=excluded.username_key,
password_hash=excluded.password_hash, is_admin=excluded.is_admin,
status=excluded.status, updated_at=excluded.updated_at""",
(
user_id,
row["username"],
str(row["username"]).casefold(),
password,
int(str(row["role"]) == "admin"),
row["created_at"],
row["updated_at"],
),
)
permanent = str(row["membership_plan"]) == "永久"
state = "active" if row["membership_status"] == "active" else "inactive"
target.execute(
"""INSERT INTO memberships
(user_id,state,expires_at,is_permanent,daily_llm_limit,updated_at,updated_by)
VALUES (?,?,?,?,50,?,NULL) ON CONFLICT(user_id) DO UPDATE SET
state=excluded.state,expires_at=excluded.expires_at,
is_permanent=excluded.is_permanent,updated_at=excluded.updated_at""",
(user_id, state, row["membership_expires_at"], int(permanent), row["updated_at"]),
)
self.counts["users"] = len(users)
self.counts["memberships"] = len(users)
if _table_exists(source, "user_birth_profiles"):
for row in source.execute("SELECT * FROM user_birth_profiles"):
target.execute(
"""INSERT INTO birth_profiles
(user_id,encrypted_payload,created_at,updated_at) VALUES (?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET
encrypted_payload=excluded.encrypted_payload,updated_at=excluded.updated_at""",
(
row["user_id"],
row["encrypted_payload"],
row["updated_at"],
row["updated_at"],
),
)
self.counts["birth_profiles"] += 1
if _table_exists(source, "llm_usage"):
for row in source.execute(
"""SELECT user_id,substr(created_at,1,10) usage_date,count(*) calls,
max(created_at) updated_at FROM llm_usage WHERE status='success'
GROUP BY user_id,substr(created_at,1,10)"""
):
target.execute(
"""INSERT INTO llm_usage_daily VALUES (?,?,?,?)
ON CONFLICT(user_id,usage_date) DO UPDATE SET
successful_calls=excluded.successful_calls,updated_at=excluded.updated_at""",
(row["user_id"], row["usage_date"], row["calls"], row["updated_at"]),
)
self.counts["llm_usage_daily"] += 1
def _system_settings(self, source: sqlite3.Connection, target: sqlite3.Connection) -> None:
row = source.execute(
"""SELECT encrypted_payload,updated_at FROM system_settings
WHERE setting_key='credentials'"""
).fetchone()
if row is None:
return
if not self.key:
raise ValueError("APP_ENCRYPTION_KEY is required to migrate encrypted settings")
fernet = Fernet(self.key.encode("ascii"))
credentials = json.loads(fernet.decrypt(str(row["encrypted_payload"]).encode("ascii")))
admin_id = min(self.user_ids)
limit = max(1, min(int(credentials.get("member_daily_limit") or 50), 1000))
target.execute("UPDATE memberships SET daily_llm_limit=?", (limit,))
for name in ("tushare_token", "ifind_refresh_token", "ifind_access_token"):
value = str(credentials.get(name) or "").strip()
if value:
target.execute(
"""INSERT INTO system_credentials VALUES (?,?,?,?)
ON CONFLICT(name) DO UPDATE SET encrypted_value=excluded.encrypted_value,
updated_at=excluded.updated_at,updated_by=excluded.updated_by""",
(name, fernet.encrypt(value.encode()).decode(), row["updated_at"], admin_id),
)
self.counts["system_credentials"] += 1
model_map: dict[str, int] = {}
for model in credentials.get("llm_models") or []:
key = str(model.get("id") or model.get("name") or "")
display = str(model.get("name") or model.get("model") or "model").strip()
api_key = str(model.get("api_key") or "")
target.execute(
"""INSERT INTO llm_models
(display_name,display_name_key,base_url,model_identifier,encrypted_api_key,
created_at,updated_at,updated_by) VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT(display_name_key) DO UPDATE SET base_url=excluded.base_url,
model_identifier=excluded.model_identifier,encrypted_api_key=excluded.encrypted_api_key,
updated_at=excluded.updated_at,updated_by=excluded.updated_by""",
(
display,
display.casefold(),
str(model.get("base_url") or ""),
str(model.get("model") or ""),
fernet.encrypt(api_key.encode()).decode(),
row["updated_at"],
row["updated_at"],
admin_id,
),
)
model_id = int(
target.execute(
"SELECT id FROM llm_models WHERE display_name_key=?", (display.casefold(),)
).fetchone()[0]
)
model_map[key] = model_id
self.counts["llm_models"] += 1
primary = model_map.get(str(credentials.get("primary_model_id") or ""))
fallback = model_map.get(str(credentials.get("fallback_model_id") or ""))
if fallback == primary:
fallback = None
target.execute(
"""UPDATE llm_configuration SET primary_model_id=?,fallback_model_id=?,
updated_at=?,updated_by=? WHERE id=1""",
(primary, fallback, row["updated_at"], admin_id),
)
def _market(self, source: sqlite3.Connection, target: sqlite3.Connection) -> None:
observed = datetime.now().astimezone().isoformat(timespec="seconds")
stocks = source.execute("SELECT * FROM stock_master ORDER BY ts_code").fetchall()
for row in stocks:
target.execute(
"""INSERT INTO market_entities VALUES ('stock',?,?,?,?,?,1,'legacy',?)
ON CONFLICT(entity_type,identifier) DO UPDATE SET code=excluded.code,
name=excluded.name,search_key=excluded.search_key,sector=excluded.sector,
active=1,source='legacy',observed_at=excluded.observed_at""",
(
row["ts_code"],
row["code"],
row["name"],
f"{row['code']} {row['ts_code']} {row['name']} {row['industry']}".casefold(),
row["industry"] or None,
row["updated_at"] or observed,
),
)
self.counts["market_entities"] = len(stocks)
dates = [
row[0]
for row in source.execute(
"SELECT DISTINCT trade_date FROM daily_bars ORDER BY trade_date"
)
]
previous = None
for raw_date in dates:
trade_date = _iso_date(raw_date)
target.execute(
"""INSERT INTO trading_days VALUES (?,1,?,'legacy',?)
ON CONFLICT(trade_date) DO UPDATE SET is_open=1,
previous_open_date=excluded.previous_open_date""",
(trade_date, previous, observed),
)
previous = trade_date
self.counts["trading_days"] = len(dates)
for row in source.execute("SELECT * FROM dashboard_snapshots"):
target.execute(
"""INSERT INTO market_summaries VALUES (?,?,'archive','legacy',1,?,?)
ON CONFLICT(trade_date) DO UPDATE SET observed_at=excluded.observed_at,
state='archive',source='legacy',coverage=1,payload_json=excluded.payload_json,
created_at=excluded.created_at""",
(
_iso_date(row["trade_date"]),
row["updated_at"],
row["payload"],
row["updated_at"],
),
)
self.counts["market_summaries"] += 1
query = """WITH ranked AS (
SELECT *,row_number() OVER (PARTITION BY ts_code ORDER BY trade_date DESC) rank
FROM daily_bars) SELECT * FROM ranked WHERE rank<=90 ORDER BY ts_code,trade_date"""
current = ""
points: list[dict[str, Any]] = []
for row in source.execute(query):
code = str(row["ts_code"])
if current and code != current:
self._save_chart(target, current, points, observed)
points = []
current = code
points.append(
{
"time": _iso_date(row["trade_date"]),
"open": row["open"],
"high": row["high"],
"low": row["low"],
"close": row["close"],
"volume": float(row["vol"] or 0) * 100,
"amount": float(row["amount"] or 0) * 1000,
"average": None,
}
)
if current:
self._save_chart(target, current, points, observed)
def _save_chart(
self,
target: sqlite3.Connection,
identifier: str,
points: list[dict[str, Any]],
observed: str,
) -> None:
previous = points[-2]["close"] if len(points) > 1 else None
target.execute(
"""INSERT INTO chart_series VALUES ('stock',?,'day',?,?,'tushare','display',
'none',1,?,?) ON CONFLICT(entity_type,identifier,interval,trade_date)
DO UPDATE SET payload_json=excluded.payload_json,observed_at=excluded.observed_at""",
(
identifier,
points[-1]["time"],
observed,
_dump({"previous_close": previous, "points": points}),
observed,
),
)
self.counts["chart_series"] += 1
def _private_data(self, source: sqlite3.Connection, target: sqlite3.Connection) -> None:
stock_ids = {
row["code"]: row["ts_code"]
for row in source.execute("SELECT code,ts_code FROM stock_master")
}
for row in source.execute("SELECT * FROM watchlist"):
if int(row["user_id"]) not in self.user_ids:
continue
target.execute(
"""INSERT INTO watchlist_entries VALUES (?,?,?,?,?,?)
ON CONFLICT(user_id,identifier) DO UPDATE SET name=excluded.name,
sector=excluded.sector,remark=excluded.remark""",
(
row["user_id"],
stock_ids.get(row["code"], row["code"]),
row["name"],
row["sector"] or None,
row["created_at"],
row["remark"],
),
)
self.counts["watchlist_entries"] += 1
self._copy_review_rows(source, target)
for row in source.execute("SELECT * FROM mentor_preferences"):
target.execute(
"INSERT OR REPLACE INTO mentor_preferences VALUES (?,?,?,?,?)",
(
row["user_id"],
row["mentor_id"],
row["pinned"],
row["sort_order"],
row["updated_at"],
),
)
self.counts["mentor_preferences"] += 1
for row in source.execute("SELECT * FROM mentor_messages"):
target.execute(
"""INSERT OR REPLACE INTO mentor_messages
(id,user_id,mentor_id,trade_date,role,content,request_id,status,created_at)
VALUES (?,?,?,?,?,?,NULL,'complete',?)""",
(
row["id"],
row["user_id"],
row["mentor_id"],
_iso_date(row["trade_date"]),
row["role"],
row["content"],
row["created_at"],
),
)
self.counts["mentor_messages"] += 1
for row in source.execute("SELECT * FROM heaven_readings"):
snapshot = _json(row["context_snapshot"], {})
if row["subject_detail"]:
snapshot.setdefault("legacy_subject_detail", row["subject_detail"])
target.execute(
"""INSERT OR REPLACE INTO heaven_readings
(id,user_id,mode,reading_date,subject_key,result_json,interpretation,
interpretation_status,request_id,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,'complete',NULL,?,?)""",
(
row["id"],
row["user_id"],
row["mode"],
_iso_date(row["context_date"]),
row["subject"],
_dump(snapshot),
row["answer"],
row["created_at"],
row["created_at"],
),
)
self.counts["heaven_readings"] += 1
def _copy_review_rows(self, source: sqlite3.Connection, target: sqlite3.Connection) -> None:
for row in source.execute("SELECT * FROM review_notes WHERE user_id IS NOT NULL"):
target.execute(
"""INSERT OR REPLACE INTO review_notes VALUES (?,?,?,?,?,?,?,?,?,?)""",
(
row["id"],
row["user_id"],
row["code"],
row["stock_name"],
_iso_date(row["trade_date"]),
row["summary"],
row["content"],
row["plan"],
row["created_at"],
row["updated_at"],
),
)
self.counts["review_notes"] += 1
actions = {"buy", "sell", "add", "trim", "watch"}
emotions = {"calm", "confident", "hesitant", "anxious", "impulsive"}
for row in source.execute("SELECT * FROM trade_entries"):
target.execute(
"""INSERT OR REPLACE INTO trade_entries
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
row["id"],
row["user_id"],
_iso_date(row["trade_date"]),
row["code"],
row["name"],
row["action"] if row["action"] in actions else "watch",
row["price"],
row["quantity"],
row["position_pct"],
row["pnl_amount"],
row["pnl_pct"],
row["emotion"] if row["emotion"] in emotions else "calm",
row["tags"],
row["thesis"],
row["execution"],
row["created_at"],
row["updated_at"],
),
)
self.counts["trade_entries"] += 1
for row in source.execute("SELECT * FROM alerts"):
kind = (
row["kind"] if row["kind"] in {"manual", "strategy_t1", "strategy_t5"} else "manual"
)
target.execute(
"INSERT OR REPLACE INTO alerts VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(
row["id"],
row["user_id"],
kind,
row["title"],
row["content"],
_iso_date(row["available_date"]),
row["code"],
row["dedupe_key"],
row["is_read"],
row["read_at"],
row["created_at"],
row["updated_at"],
),
)
self.counts["alerts"] += 1
for row in source.execute("SELECT * FROM assistant_messages"):
target.execute(
"""INSERT OR REPLACE INTO review_assistant_messages
VALUES (?,?,?,?,?,NULL,'complete',?)""",
(
row["id"],
row["user_id"],
row["role"],
row["content"],
_iso_date(row["context_date"]),
row["created_at"],
),
)
self.counts["review_assistant_messages"] += 1
def _screener(self, source: sqlite3.Connection, target: sqlite3.Connection) -> None:
snapshots: dict[str, int] = {}
for row in source.execute("SELECT * FROM screener_runs ORDER BY id"):
trade_date = _iso_date(row["trade_date"])
if trade_date not in snapshots:
target.execute(
"""INSERT OR IGNORE INTO screener_factor_snapshots
(trade_date,version,observed_at,state,source_set_json,coverage_json,created_at)
VALUES (?,? ,?,'archive','[\"legacy\"]','{}',?)""",
(trade_date, ARCHIVE_VERSION, row["created_at"], row["created_at"]),
)
snapshots[trade_date] = int(
target.execute(
"SELECT id FROM screener_factor_snapshots WHERE trade_date=? AND version=?",
(trade_date, ARCHIVE_VERSION),
).fetchone()[0]
)
result = _json(row["result"], {})
candidates = result.get("candidates") if isinstance(result, dict) else []
candidates = candidates if isinstance(candidates, list) else []
for candidate in candidates:
if isinstance(candidate, dict) and not candidate.get("identifier"):
candidate["identifier"] = (
candidate.get("ts_code") or candidate.get("code") or ""
)
mode = {"smart": "stage", "curated": "curated", "quant": "custom"}.get(
str(row["mode"]), "custom"
)
formula = _json(row["formula"], {})
strategy_id = str(formula.get("id") or f"legacy-{row['id']}")
target.execute(
"""INSERT OR REPLACE INTO screener_runs
(id,owner_user_id,mode,strategy_id,strategy_name,strategy_version,
selection_date,factor_snapshot_id,status,started_at,completed_at,coverage,
missing_fields_json,result_json,error_message)
VALUES (?,?,?,?,?,1,?,?,?, ?,?,0,'[]',?,'')""",
(
row["id"],
row["user_id"],
mode,
strategy_id,
row["strategy_name"],
trade_date,
snapshots[trade_date],
"completed" if candidates else "no_signal",
row["created_at"],
row["created_at"],
_dump(candidates),
),
)
self.run_ids.add(int(row["id"]))
self.counts["screener_runs"] += 1
for row in source.execute("SELECT * FROM screener_strategies WHERE user_id IS NOT NULL"):
target.execute(
"""INSERT OR REPLACE INTO custom_screener_strategies
(id,user_id,name,version,formula_json,created_at,updated_at)
VALUES (?,?,?,1,?,?,?)""",
(
row["id"],
row["user_id"],
row["name"],
row["formula"],
row["created_at"],
row["updated_at"],
),
)
self.counts["custom_screener_strategies"] += 1
for row in source.execute("SELECT * FROM strategy_tracks"):
if int(row["run_id"]) not in self.run_ids:
continue
target.execute(
"""INSERT OR REPLACE INTO strategy_tracks
(id,user_id,run_id,identifier,code,name,sector,selection_date,
strategy_name,entry_price,added_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(
row["id"],
row["user_id"],
row["run_id"],
row["ts_code"],
row["code"],
row["name"],
row["sector"],
_iso_date(row["selection_date"]),
row["strategy_name"],
row["entry_price"],
row["created_at"],
),
)
self.counts["strategy_tracks"] += 1
def _insights(self, source: sqlite3.Connection, target: sqlite3.Connection) -> None:
mappings = {
"auction_center_v6": "auction",
"theme_library_v1": "themes",
"popularity_v1": "popularity",
"dragon_tiger": "dragon-list",
}
for old_kind, new_kind in mappings.items():
for row in source.execute("SELECT * FROM data_snapshots WHERE kind=?", (old_kind,)):
trade_date = _iso_date(str(row["cache_key"]).split(":", 1)[0])
if not trade_date:
continue
target.execute(
"""INSERT OR REPLACE INTO market_insight_snapshots
VALUES (?,?,'',?,'archive','legacy',1,?)""",
(new_kind, trade_date, row["updated_at"], row["payload"]),
)
self.counts["market_insight_snapshots"] += 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Migrate a read-only legacy database copy")
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--target", type=Path, required=True)
parser.add_argument("--report", type=Path)
return parser
def main(arguments: Sequence[str] | None = None) -> int:
parsed = build_parser().parse_args(arguments)
report = LegacyMigrator(parsed.source, parsed.target, os.getenv("APP_ENCRYPTION_KEY")).run()
rendered = json.dumps(report, ensure_ascii=False, indent=2)
if parsed.report:
parsed.report.parent.mkdir(parents=True, exist_ok=True)
parsed.report.write_text(rendered + "\n", encoding="utf-8")
print(rendered)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import argparse
import json
import shutil
import sqlite3
import tempfile
import zipfile
from collections.abc import Sequence
from contextlib import closing
from pathlib import Path
from tools.backup import sha256
def _safe_members(archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]:
members = archive.infolist()
for member in members:
path = Path(member.filename)
if path.is_absolute() or ".." in path.parts:
raise ValueError("backup contains an unsafe path")
return members
def restore_backup(
archive_path: Path,
database: Path,
*,
private_skills: Path | None = None,
environment_file: Path | None = None,
encryption_key_file: Path | None = None,
overwrite: bool = False,
) -> dict:
archive_path = archive_path.resolve()
database = database.resolve()
destinations = [database]
destinations.extend(path.resolve() for path in (environment_file, encryption_key_file) if path)
if private_skills:
destinations.append(private_skills.resolve())
if not overwrite and any(path.exists() for path in destinations):
raise FileExistsError(
"restore destination exists; explicit overwrite confirmation is required"
)
with tempfile.TemporaryDirectory(prefix="xiaobai-restore-") as raw:
staging = Path(raw)
with zipfile.ZipFile(archive_path) as archive:
_safe_members(archive)
archive.extractall(staging)
manifest = json.loads((staging / "manifest.json").read_text(encoding="utf-8"))
if manifest.get("format") != 1 or not isinstance(manifest.get("files"), dict):
raise ValueError("backup manifest is invalid")
for name, metadata in manifest["files"].items():
path = staging / name
if not path.is_file() or sha256(path) != metadata.get("sha256"):
raise ValueError(f"backup checksum mismatch: {name}")
restored_db = staging / "database.sqlite3"
with closing(
sqlite3.connect(f"file:{restored_db.as_posix()}?mode=ro", uri=True)
) as connection:
if connection.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
raise ValueError("restored database failed integrity check")
database.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(restored_db, database)
for destination, name in (
(environment_file, "secrets/environment"),
(encryption_key_file, "secrets/app-encryption.key"),
):
source = staging / name
if destination and source.is_file():
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
skills_source = staging / "private-mentor-skills"
if private_skills and skills_source.is_dir():
private_skills.mkdir(parents=True, exist_ok=True)
shutil.copytree(skills_source, private_skills, dirs_exist_ok=True)
return manifest
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Validate and restore a Xiaobai backup")
parser.add_argument("--archive", type=Path, required=True)
parser.add_argument("--database", type=Path, required=True)
parser.add_argument("--private-skills", type=Path)
parser.add_argument("--environment-file", type=Path)
parser.add_argument("--encryption-key-file", type=Path)
parser.add_argument("--confirm-restore", action="store_true")
parser.add_argument("--confirm-overwrite", action="store_true")
return parser
def main(arguments: Sequence[str] | None = None) -> int:
parsed = build_parser().parse_args(arguments)
if not parsed.confirm_restore:
raise SystemExit("restore requires --confirm-restore")
manifest = restore_backup(
parsed.archive,
parsed.database,
private_skills=parsed.private_skills,
environment_file=parsed.environment_file,
encryption_key_file=parsed.encryption_key_file,
overwrite=parsed.confirm_overwrite,
)
print(
json.dumps(
{"restored": str(parsed.database.resolve()), "files": len(manifest["files"])},
ensure_ascii=False,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())