Remove PostgreSQL support and standardize on SQLite only.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+46
-155
@@ -3,7 +3,7 @@
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""数据库备份:SQLite futures.db 或 PostgreSQL pg_dump,含 uploads 与一键恢复脚本。"""
|
||||
"""数据库备份:SQLite futures.db,含 uploads 与一键恢复脚本。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -23,7 +23,7 @@ from pathlib import Path
|
||||
from typing import Any, Callable, IO, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from modules.core.db_conn import DB_PATH, db_backend
|
||||
from modules.core.db_conn import DB_PATH
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,11 +48,10 @@ RESTORE_MD = """# qihuo 备份恢复说明
|
||||
|
||||
| 文件/目录 | 说明 |
|
||||
|-----------|------|
|
||||
| `futures.db` | SQLite 主库(仅 SQLite 模式备份) |
|
||||
| `postgres_dump.sql` | PostgreSQL 逻辑备份(仅 PostgreSQL 模式) |
|
||||
| `futures.db` | SQLite 主库 |
|
||||
| `uploads/` | 复盘截图与 K 线图(若备份时存在) |
|
||||
| `.env` | 环境配置(`config/.env` 或根目录 `.env`) |
|
||||
| `manifest.json` | 备份元数据(含 `backend` 字段) |
|
||||
| `manifest.json` | 备份元数据 |
|
||||
| `restore.sh` | 一键恢复脚本 |
|
||||
|
||||
## 快速恢复(推荐)
|
||||
@@ -68,33 +67,17 @@ chmod +x restore.sh
|
||||
./restore.sh
|
||||
```
|
||||
|
||||
默认恢复到 **`/root/qihuo`**(SQLite)或导入到 `.env` 中的 PostgreSQL(见 manifest)。
|
||||
|
||||
指定应用目录:
|
||||
默认恢复到 **`/root/qihuo`**。指定应用目录:
|
||||
|
||||
```bash
|
||||
RESTORE_DIR=/opt/qihuo ./restore.sh
|
||||
```
|
||||
|
||||
3. 在新服务器部署 qihuo 代码与 Python 环境(见 `docs/POSTGRES.md` / `docs/DEPLOY.md`)
|
||||
3. 在新服务器部署 qihuo 代码与 Python 环境(见 `docs/DEPLOY.md`)
|
||||
4. 若包内含 `.env`,`restore.sh` 会自动恢复到应用目录(无需手工复制)
|
||||
5. 重启服务:`pm2 restart qihuo`
|
||||
|
||||
## PostgreSQL 恢复
|
||||
|
||||
若 `manifest.json` 中 `"backend": "postgres"`:
|
||||
|
||||
1. 确保目标机已安装 PostgreSQL,且 `.env` 中 `DATABASE_URL` 指向空库或待覆盖库
|
||||
2. 执行 `./restore.sh`(会调用 `psql` 导入 `postgres_dump.sql`)
|
||||
|
||||
手工导入:
|
||||
|
||||
```bash
|
||||
export DATABASE_URL=postgresql://qihuo:密码@127.0.0.1:5432/qihuo
|
||||
psql "$DATABASE_URL" -f postgres_dump.sql
|
||||
```
|
||||
|
||||
## SQLite 手工恢复
|
||||
## 手工恢复
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/qihuo/uploads
|
||||
@@ -106,7 +89,7 @@ cp -a uploads/. /opt/qihuo/uploads/
|
||||
|
||||
- 恢复前请停止 qihuo 进程
|
||||
- `.env` 含敏感信息,请妥善保管备份包
|
||||
- 详见 `docs/POSTGRES.md` 与 `docs/BACKUP.md`
|
||||
- 详见 `docs/BACKUP.md`
|
||||
"""
|
||||
|
||||
|
||||
@@ -216,17 +199,14 @@ def _read_manifest_from_tar(tar: tarfile.TarFile) -> dict:
|
||||
return data
|
||||
|
||||
|
||||
def _validate_manifest(manifest: dict, *, current_backend: str | None = None) -> str:
|
||||
def _validate_manifest(manifest: dict) -> None:
|
||||
if manifest.get("app") != "qihuo":
|
||||
raise ValueError("不是有效的 qihuo 备份包")
|
||||
backend = (manifest.get("backend") or "").strip()
|
||||
if backend not in ("sqlite", "postgres"):
|
||||
backend = (manifest.get("backend") or "sqlite").strip()
|
||||
if backend == "postgres":
|
||||
raise ValueError("不再支持 PostgreSQL 备份,请使用 SQLite 备份包")
|
||||
if backend != "sqlite":
|
||||
raise ValueError("manifest 缺少或无效的数据库类型")
|
||||
if current_backend and backend != current_backend:
|
||||
label = "PostgreSQL" if backend == "postgres" else "SQLite"
|
||||
cur = "PostgreSQL" if current_backend == "postgres" else "SQLite"
|
||||
raise ValueError(f"备份为 {label},当前服务为 {cur},无法恢复")
|
||||
return backend
|
||||
|
||||
|
||||
def _member_exists(tar: tarfile.TarFile, root: str, name: str) -> bool:
|
||||
@@ -244,23 +224,16 @@ def _tar_has_member(tar: tarfile.TarFile, path: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_archive_contents(tar: tarfile.TarFile, manifest: dict, root: str) -> None:
|
||||
backend = manifest["backend"]
|
||||
if backend == "sqlite":
|
||||
if not _member_exists(tar, root, "futures.db"):
|
||||
raise ValueError("SQLite 备份缺少 futures.db")
|
||||
elif not _member_exists(tar, root, "postgres_dump.sql"):
|
||||
raise ValueError("PostgreSQL 备份缺少 postgres_dump.sql")
|
||||
def _validate_archive_contents(tar: tarfile.TarFile, root: str) -> None:
|
||||
if not _member_exists(tar, root, "futures.db"):
|
||||
raise ValueError("备份缺少 futures.db")
|
||||
|
||||
|
||||
def _manifest_preview(manifest: dict, path: Path) -> dict:
|
||||
backend = manifest.get("backend", "")
|
||||
stat = path.stat()
|
||||
created_at = (manifest.get("created_at") or "").strip()
|
||||
return {
|
||||
"name": path.name,
|
||||
"backend": backend,
|
||||
"backend_label": "PostgreSQL" if backend == "postgres" else "SQLite",
|
||||
"created_at": created_at,
|
||||
"includes_uploads": bool(manifest.get("includes_uploads")),
|
||||
"includes_env": bool(manifest.get("includes_env")),
|
||||
@@ -276,13 +249,12 @@ def peek_manifest(path: Path) -> dict:
|
||||
return _read_manifest_from_tar(tar)
|
||||
|
||||
|
||||
def inspect_backup_archive(path: Path, *, check_backend: bool = True) -> dict:
|
||||
def inspect_backup_archive(path: Path) -> dict:
|
||||
with tarfile.open(path, "r:gz") as tar:
|
||||
manifest = _read_manifest_from_tar(tar)
|
||||
current = db_backend() if check_backend else None
|
||||
_validate_manifest(manifest, current_backend=current)
|
||||
_validate_manifest(manifest)
|
||||
root = _manifest_root_prefix(tar)
|
||||
_validate_archive_contents(tar, manifest, root)
|
||||
_validate_archive_contents(tar, root)
|
||||
return _manifest_preview(manifest, path)
|
||||
|
||||
|
||||
@@ -313,7 +285,7 @@ def save_uploaded_backup(stream: IO[bytes], original_filename: str = "") -> tupl
|
||||
shutil.copyfileobj(stream, tmp)
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
info = inspect_backup_archive(tmp_path, check_backend=True)
|
||||
info = inspect_backup_archive(tmp_path)
|
||||
manifest = peek_manifest(tmp_path)
|
||||
filename = _allocate_backup_filename(manifest, original_filename)
|
||||
dest = backup_dir() / filename
|
||||
@@ -412,55 +384,29 @@ def _reload_env_file(env_path: Path) -> None:
|
||||
logger.warning("reload .env failed: %s", exc)
|
||||
|
||||
|
||||
def _restore_postgres_dump(dump_path: Path) -> None:
|
||||
url = (os.getenv("DATABASE_URL") or "").strip()
|
||||
if not url:
|
||||
raise RuntimeError("PostgreSQL 恢复需要 DATABASE_URL(请先恢复 .env 或检查环境变量)")
|
||||
if not shutil.which("psql"):
|
||||
raise RuntimeError("未找到 psql,请先安装 PostgreSQL 客户端")
|
||||
proc = subprocess.run(
|
||||
["psql", url, "-f", str(dump_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "psql 导入失败")
|
||||
|
||||
|
||||
def _perform_restore(archive_path: Path, restore_dir: Path) -> dict:
|
||||
restore_dir.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="qihuo_restore_") as tmp:
|
||||
work = Path(tmp)
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
manifest = _read_manifest_from_tar(tar)
|
||||
backend = _validate_manifest(manifest, current_backend=db_backend())
|
||||
root = _manifest_root_prefix(tar)
|
||||
_validate_archive_contents(tar, manifest, root)
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
manifest = _read_manifest_from_tar(tar)
|
||||
_validate_manifest(manifest)
|
||||
root = _manifest_root_prefix(tar)
|
||||
_validate_archive_contents(tar, root)
|
||||
|
||||
env_member = f"{root}/.env" if root else ".env"
|
||||
env_restore_path = (manifest.get("env_restore_path") or "config/.env").strip()
|
||||
if manifest.get("includes_env") and _tar_has_member(tar, env_member):
|
||||
env_dest = restore_dir / env_restore_path
|
||||
_extract_member_to_path(tar, env_member, env_dest)
|
||||
_reload_env_file(env_dest)
|
||||
env_member = f"{root}/.env" if root else ".env"
|
||||
env_restore_path = (manifest.get("env_restore_path") or "config/.env").strip()
|
||||
if manifest.get("includes_env") and _tar_has_member(tar, env_member):
|
||||
env_dest = restore_dir / env_restore_path
|
||||
_extract_member_to_path(tar, env_member, env_dest)
|
||||
_reload_env_file(env_dest)
|
||||
|
||||
if backend == "sqlite":
|
||||
db_member = f"{root}/futures.db" if root else "futures.db"
|
||||
db_dest = Path(DB_PATH)
|
||||
if not db_dest.is_absolute():
|
||||
db_dest = restore_dir / db_dest.name
|
||||
_extract_member_to_path(tar, db_member, db_dest)
|
||||
else:
|
||||
dump_member = f"{root}/postgres_dump.sql" if root else "postgres_dump.sql"
|
||||
dump_path = work / "postgres_dump.sql"
|
||||
_extract_member_to_path(tar, dump_member, dump_path)
|
||||
_restore_postgres_dump(dump_path)
|
||||
|
||||
_restore_uploads_dir(tar, root, restore_dir)
|
||||
db_member = f"{root}/futures.db" if root else "futures.db"
|
||||
db_dest = Path(DB_PATH)
|
||||
if not db_dest.is_absolute():
|
||||
db_dest = restore_dir / db_dest.name
|
||||
_extract_member_to_path(tar, db_member, db_dest)
|
||||
_restore_uploads_dir(tar, root, restore_dir)
|
||||
|
||||
return {
|
||||
"backend": backend,
|
||||
"restore_dir": str(restore_dir),
|
||||
"includes_env": bool(manifest.get("includes_env")),
|
||||
"includes_uploads": bool(manifest.get("includes_uploads")),
|
||||
@@ -526,7 +472,7 @@ def schedule_restore(filename: str) -> tuple[bool, str]:
|
||||
return False, "恢复进行中,请稍后再试"
|
||||
try:
|
||||
path = resolve_backup_file(filename)
|
||||
inspect_backup_archive(path, check_backend=True)
|
||||
inspect_backup_archive(path)
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
return False, str(exc)
|
||||
|
||||
@@ -568,22 +514,6 @@ def _backup_sqlite(src_path: str, dst_path: str) -> None:
|
||||
src.close()
|
||||
|
||||
|
||||
def _backup_postgres(dst_path: str) -> None:
|
||||
url = (os.getenv("DATABASE_URL") or "").strip()
|
||||
if not url:
|
||||
raise RuntimeError("PostgreSQL 备份需要 DATABASE_URL")
|
||||
env = os.environ.copy()
|
||||
proc = subprocess.run(
|
||||
["pg_dump", "--no-owner", "--no-acl", "-f", dst_path, url],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"pg_dump 失败: {proc.stderr.strip() or proc.stdout.strip()}")
|
||||
|
||||
|
||||
def _env_backup_info() -> tuple[Optional[Path], str]:
|
||||
"""返回 (源 .env 路径, 恢复到应用目录的相对路径)。"""
|
||||
from modules.core.paths import CONFIG_DIR, LEGACY_ENV_FILE, ROOT, resolve_env_file
|
||||
@@ -602,32 +532,7 @@ def _env_backup_info() -> tuple[Optional[Path], str]:
|
||||
return src, "config/.env"
|
||||
|
||||
|
||||
def _write_restore_script(dest: Path, *, backend: str, env_restore_path: str = "") -> None:
|
||||
pg_block = ""
|
||||
if backend == "postgres":
|
||||
pg_block = """
|
||||
if [ -f "$SCRIPT_DIR/postgres_dump.sql" ]; then
|
||||
if [ -z "${DATABASE_URL:-}" ]; then
|
||||
if [ -f "$RESTORE_DIR/.env" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$RESTORE_DIR/.env"
|
||||
set +a
|
||||
fi
|
||||
fi
|
||||
if [ -z "${DATABASE_URL:-}" ]; then
|
||||
echo "错误: PostgreSQL 恢复需要 DATABASE_URL(环境变量或 $RESTORE_DIR/.env)"
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v psql >/dev/null; then
|
||||
echo "错误: 未找到 psql,请先安装 PostgreSQL 客户端"
|
||||
exit 1
|
||||
fi
|
||||
echo "导入 PostgreSQL: postgres_dump.sql"
|
||||
psql "$DATABASE_URL" -f "$SCRIPT_DIR/postgres_dump.sql"
|
||||
echo "PostgreSQL 导入完成"
|
||||
fi
|
||||
"""
|
||||
def _write_restore_script(dest: Path, *, env_restore_path: str = "") -> None:
|
||||
env_block = ""
|
||||
if env_restore_path:
|
||||
env_block = f"""
|
||||
@@ -643,7 +548,7 @@ set -euo pipefail
|
||||
RESTORE_DIR="${{RESTORE_DIR:-{default_restore_dir()}}}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
mkdir -p "$RESTORE_DIR/uploads"
|
||||
{pg_block}{env_block}
|
||||
{env_block}
|
||||
if [ -f "$SCRIPT_DIR/futures.db" ]; then
|
||||
cp -f "$SCRIPT_DIR/futures.db" "$RESTORE_DIR/futures.db"
|
||||
echo "已复制 futures.db -> $RESTORE_DIR/futures.db"
|
||||
@@ -655,18 +560,15 @@ fi
|
||||
echo ""
|
||||
echo "恢复完成。目标目录: $RESTORE_DIR"
|
||||
echo "下一步: pm2 restart qihuo"
|
||||
echo "详见 RESTORE.md 与 docs/POSTGRES.md"
|
||||
echo "详见 RESTORE.md 与 docs/BACKUP.md"
|
||||
"""
|
||||
dest.write_text(script, encoding="utf-8")
|
||||
|
||||
|
||||
def create_backup(*, include_uploads: bool = True) -> tuple[str, str]:
|
||||
"""创建 tar.gz 备份,返回 (文件名, 说明)。"""
|
||||
backend = db_backend()
|
||||
if backend == "sqlite" and not os.path.isfile(DB_PATH):
|
||||
if not os.path.isfile(DB_PATH):
|
||||
raise FileNotFoundError(f"数据库不存在: {DB_PATH}")
|
||||
if backend == "postgres" and not (os.getenv("DATABASE_URL") or "").strip():
|
||||
raise RuntimeError("PostgreSQL 模式需要 DATABASE_URL")
|
||||
|
||||
with _backup_lock:
|
||||
stamp = datetime.now(TZ).strftime("%Y%m%d_%H%M%S")
|
||||
@@ -679,10 +581,7 @@ def create_backup(*, include_uploads: bool = True) -> tuple[str, str]:
|
||||
with tempfile.TemporaryDirectory(prefix="qihuo_bak_") as tmp:
|
||||
work = Path(tmp) / folder_name
|
||||
work.mkdir()
|
||||
if backend == "postgres":
|
||||
_backup_postgres(str(work / "postgres_dump.sql"))
|
||||
else:
|
||||
_backup_sqlite(DB_PATH, str(work / "futures.db"))
|
||||
_backup_sqlite(DB_PATH, str(work / "futures.db"))
|
||||
|
||||
if include_uploads and upload_src.is_dir():
|
||||
shutil.copytree(upload_src, work / "uploads", dirs_exist_ok=True)
|
||||
@@ -695,9 +594,9 @@ def create_backup(*, include_uploads: bool = True) -> tuple[str, str]:
|
||||
|
||||
manifest = {
|
||||
"app": "qihuo",
|
||||
"backend": backend,
|
||||
"backend": "sqlite",
|
||||
"created_at": datetime.now(TZ).isoformat(timespec="seconds"),
|
||||
"db_path": DB_PATH if backend == "sqlite" else (os.getenv("DATABASE_URL") or ""),
|
||||
"db_path": DB_PATH,
|
||||
"includes_uploads": include_uploads and upload_src.is_dir(),
|
||||
"includes_env": includes_env,
|
||||
"env_restore_path": env_restore_path if includes_env else "",
|
||||
@@ -711,7 +610,6 @@ def create_backup(*, include_uploads: bool = True) -> tuple[str, str]:
|
||||
(work / "RESTORE.md").write_text(RESTORE_MD, encoding="utf-8")
|
||||
_write_restore_script(
|
||||
work / "restore.sh",
|
||||
backend=backend,
|
||||
env_restore_path=env_restore_path if includes_env else "",
|
||||
)
|
||||
|
||||
@@ -719,8 +617,7 @@ def create_backup(*, include_uploads: bool = True) -> tuple[str, str]:
|
||||
tar.add(work, arcname=folder_name)
|
||||
|
||||
size_mb = out_path.stat().st_size / (1024 * 1024)
|
||||
label = "PostgreSQL" if backend == "postgres" else "SQLite"
|
||||
return filename, f"备份已生成 {filename}({label},{size_mb:.2f} MB)"
|
||||
return filename, f"备份已生成 {filename}({size_mb:.2f} MB)"
|
||||
|
||||
|
||||
def list_backups(*, with_manifest: bool = True) -> list[dict]:
|
||||
@@ -734,8 +631,6 @@ def list_backups(*, with_manifest: bool = True) -> list[dict]:
|
||||
"size": stat.st_size,
|
||||
"size_mb": round(stat.st_size / (1024 * 1024), 2),
|
||||
"mtime": datetime.fromtimestamp(stat.st_mtime, TZ).isoformat(timespec="seconds"),
|
||||
"backend": "",
|
||||
"backend_label": "",
|
||||
"created_at": "",
|
||||
"includes_env": False,
|
||||
"includes_uploads": False,
|
||||
@@ -743,10 +638,6 @@ def list_backups(*, with_manifest: bool = True) -> list[dict]:
|
||||
if with_manifest:
|
||||
try:
|
||||
manifest = peek_manifest(path)
|
||||
item["backend"] = manifest.get("backend", "")
|
||||
item["backend_label"] = (
|
||||
"PostgreSQL" if manifest.get("backend") == "postgres" else "SQLite"
|
||||
)
|
||||
item["created_at"] = (manifest.get("created_at") or "").strip()
|
||||
item["includes_env"] = bool(manifest.get("includes_env"))
|
||||
item["includes_uploads"] = bool(manifest.get("includes_uploads"))
|
||||
|
||||
Reference in New Issue
Block a user