Remove PostgreSQL support and standardize on SQLite only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-02 16:21:27 +08:00
parent 9379bc4f4f
commit 8ebe1a3c77
6 changed files with 77 additions and 310 deletions
+1 -5
View File
@@ -54,8 +54,4 @@ RISK_DAILY_POSITION_LIMIT=5
RISK_DAILY_TRADING_RISK_PCT=2 RISK_DAILY_TRADING_RISK_PCT=2
TRADING_DAY_RESET_HOUR=8 TRADING_DAY_RESET_HOUR=8
# —— 数据库(生产推荐 PostgreSQL,见 docs/POSTGRES.md)—— # —— 数据库(SQLite futures.db,路径见 modules/core/paths.py)——
# 未配置 DATABASE_URL 时使用本地 SQLite futures.db
# DATABASE_URL=postgresql://qihuo:your_password@127.0.0.1:5432/qihuo
# PG_POOL_MIN=2
# PG_POOL_MAX=20
+46 -155
View File
@@ -3,7 +3,7 @@
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 # 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md # 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""数据库备份:SQLite futures.db 或 PostgreSQL pg_dump,含 uploads 与一键恢复脚本。""" """数据库备份:SQLite futures.db,含 uploads 与一键恢复脚本。"""
from __future__ import annotations from __future__ import annotations
import json import json
@@ -23,7 +23,7 @@ from pathlib import Path
from typing import Any, Callable, IO, Optional from typing import Any, Callable, IO, Optional
from zoneinfo import ZoneInfo 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__) logger = logging.getLogger(__name__)
@@ -48,11 +48,10 @@ RESTORE_MD = """# qihuo 备份恢复说明
| 文件/目录 | 说明 | | 文件/目录 | 说明 |
|-----------|------| |-----------|------|
| `futures.db` | SQLite 主库(仅 SQLite 模式备份) | | `futures.db` | SQLite 主库 |
| `postgres_dump.sql` | PostgreSQL 逻辑备份(仅 PostgreSQL 模式) |
| `uploads/` | 复盘截图与 K 线图(若备份时存在) | | `uploads/` | 复盘截图与 K 线图(若备份时存在) |
| `.env` | 环境配置(`config/.env` 或根目录 `.env` | | `.env` | 环境配置(`config/.env` 或根目录 `.env` |
| `manifest.json` | 备份元数据(含 `backend` 字段) | | `manifest.json` | 备份元数据 |
| `restore.sh` | 一键恢复脚本 | | `restore.sh` | 一键恢复脚本 |
## 快速恢复(推荐) ## 快速恢复(推荐)
@@ -68,33 +67,17 @@ chmod +x restore.sh
./restore.sh ./restore.sh
``` ```
默认恢复到 **`/root/qihuo`**SQLite)或导入到 `.env` 中的 PostgreSQL(见 manifest)。 默认恢复到 **`/root/qihuo`**。指定应用目录:
指定应用目录:
```bash ```bash
RESTORE_DIR=/opt/qihuo ./restore.sh 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` 会自动恢复到应用目录(无需手工复制) 4. 若包内含 `.env``restore.sh` 会自动恢复到应用目录(无需手工复制)
5. 重启服务:`pm2 restart qihuo` 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 ```bash
mkdir -p /opt/qihuo/uploads mkdir -p /opt/qihuo/uploads
@@ -106,7 +89,7 @@ cp -a uploads/. /opt/qihuo/uploads/
- 恢复前请停止 qihuo 进程 - 恢复前请停止 qihuo 进程
- `.env` 含敏感信息,请妥善保管备份包 - `.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 return data
def _validate_manifest(manifest: dict, *, current_backend: str | None = None) -> str: def _validate_manifest(manifest: dict) -> None:
if manifest.get("app") != "qihuo": if manifest.get("app") != "qihuo":
raise ValueError("不是有效的 qihuo 备份包") raise ValueError("不是有效的 qihuo 备份包")
backend = (manifest.get("backend") or "").strip() backend = (manifest.get("backend") or "sqlite").strip()
if backend not in ("sqlite", "postgres"): if backend == "postgres":
raise ValueError("不再支持 PostgreSQL 备份,请使用 SQLite 备份包")
if backend != "sqlite":
raise ValueError("manifest 缺少或无效的数据库类型") 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: 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 return False
def _validate_archive_contents(tar: tarfile.TarFile, manifest: dict, root: str) -> None: def _validate_archive_contents(tar: tarfile.TarFile, root: str) -> None:
backend = manifest["backend"] if not _member_exists(tar, root, "futures.db"):
if backend == "sqlite": raise ValueError("备份缺少 futures.db")
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 _manifest_preview(manifest: dict, path: Path) -> dict: def _manifest_preview(manifest: dict, path: Path) -> dict:
backend = manifest.get("backend", "")
stat = path.stat() stat = path.stat()
created_at = (manifest.get("created_at") or "").strip() created_at = (manifest.get("created_at") or "").strip()
return { return {
"name": path.name, "name": path.name,
"backend": backend,
"backend_label": "PostgreSQL" if backend == "postgres" else "SQLite",
"created_at": created_at, "created_at": created_at,
"includes_uploads": bool(manifest.get("includes_uploads")), "includes_uploads": bool(manifest.get("includes_uploads")),
"includes_env": bool(manifest.get("includes_env")), "includes_env": bool(manifest.get("includes_env")),
@@ -276,13 +249,12 @@ def peek_manifest(path: Path) -> dict:
return _read_manifest_from_tar(tar) 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: with tarfile.open(path, "r:gz") as tar:
manifest = _read_manifest_from_tar(tar) manifest = _read_manifest_from_tar(tar)
current = db_backend() if check_backend else None _validate_manifest(manifest)
_validate_manifest(manifest, current_backend=current)
root = _manifest_root_prefix(tar) root = _manifest_root_prefix(tar)
_validate_archive_contents(tar, manifest, root) _validate_archive_contents(tar, root)
return _manifest_preview(manifest, path) return _manifest_preview(manifest, path)
@@ -313,7 +285,7 @@ def save_uploaded_backup(stream: IO[bytes], original_filename: str = "") -> tupl
shutil.copyfileobj(stream, tmp) shutil.copyfileobj(stream, tmp)
tmp_path = Path(tmp.name) tmp_path = Path(tmp.name)
try: try:
info = inspect_backup_archive(tmp_path, check_backend=True) info = inspect_backup_archive(tmp_path)
manifest = peek_manifest(tmp_path) manifest = peek_manifest(tmp_path)
filename = _allocate_backup_filename(manifest, original_filename) filename = _allocate_backup_filename(manifest, original_filename)
dest = backup_dir() / filename dest = backup_dir() / filename
@@ -412,55 +384,29 @@ def _reload_env_file(env_path: Path) -> None:
logger.warning("reload .env failed: %s", exc) 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: def _perform_restore(archive_path: Path, restore_dir: Path) -> dict:
restore_dir.mkdir(parents=True, exist_ok=True) restore_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="qihuo_restore_") as tmp: with tarfile.open(archive_path, "r:gz") as tar:
work = Path(tmp) manifest = _read_manifest_from_tar(tar)
with tarfile.open(archive_path, "r:gz") as tar: _validate_manifest(manifest)
manifest = _read_manifest_from_tar(tar) root = _manifest_root_prefix(tar)
backend = _validate_manifest(manifest, current_backend=db_backend()) _validate_archive_contents(tar, root)
root = _manifest_root_prefix(tar)
_validate_archive_contents(tar, manifest, root)
env_member = f"{root}/.env" if root else ".env" env_member = f"{root}/.env" if root else ".env"
env_restore_path = (manifest.get("env_restore_path") or "config/.env").strip() env_restore_path = (manifest.get("env_restore_path") or "config/.env").strip()
if manifest.get("includes_env") and _tar_has_member(tar, env_member): if manifest.get("includes_env") and _tar_has_member(tar, env_member):
env_dest = restore_dir / env_restore_path env_dest = restore_dir / env_restore_path
_extract_member_to_path(tar, env_member, env_dest) _extract_member_to_path(tar, env_member, env_dest)
_reload_env_file(env_dest) _reload_env_file(env_dest)
if backend == "sqlite": db_member = f"{root}/futures.db" if root else "futures.db"
db_member = f"{root}/futures.db" if root else "futures.db" db_dest = Path(DB_PATH)
db_dest = Path(DB_PATH) if not db_dest.is_absolute():
if not db_dest.is_absolute(): db_dest = restore_dir / db_dest.name
db_dest = restore_dir / db_dest.name _extract_member_to_path(tar, db_member, db_dest)
_extract_member_to_path(tar, db_member, db_dest) _restore_uploads_dir(tar, root, restore_dir)
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)
return { return {
"backend": backend,
"restore_dir": str(restore_dir), "restore_dir": str(restore_dir),
"includes_env": bool(manifest.get("includes_env")), "includes_env": bool(manifest.get("includes_env")),
"includes_uploads": bool(manifest.get("includes_uploads")), "includes_uploads": bool(manifest.get("includes_uploads")),
@@ -526,7 +472,7 @@ def schedule_restore(filename: str) -> tuple[bool, str]:
return False, "恢复进行中,请稍后再试" return False, "恢复进行中,请稍后再试"
try: try:
path = resolve_backup_file(filename) path = resolve_backup_file(filename)
inspect_backup_archive(path, check_backend=True) inspect_backup_archive(path)
except (ValueError, FileNotFoundError) as exc: except (ValueError, FileNotFoundError) as exc:
return False, str(exc) return False, str(exc)
@@ -568,22 +514,6 @@ def _backup_sqlite(src_path: str, dst_path: str) -> None:
src.close() 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]: def _env_backup_info() -> tuple[Optional[Path], str]:
"""返回 (源 .env 路径, 恢复到应用目录的相对路径)。""" """返回 (源 .env 路径, 恢复到应用目录的相对路径)。"""
from modules.core.paths import CONFIG_DIR, LEGACY_ENV_FILE, ROOT, resolve_env_file 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" return src, "config/.env"
def _write_restore_script(dest: Path, *, backend: str, env_restore_path: str = "") -> None: def _write_restore_script(dest: Path, *, 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
"""
env_block = "" env_block = ""
if env_restore_path: if env_restore_path:
env_block = f""" env_block = f"""
@@ -643,7 +548,7 @@ set -euo pipefail
RESTORE_DIR="${{RESTORE_DIR:-{default_restore_dir()}}}" RESTORE_DIR="${{RESTORE_DIR:-{default_restore_dir()}}}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "$RESTORE_DIR/uploads" mkdir -p "$RESTORE_DIR/uploads"
{pg_block}{env_block} {env_block}
if [ -f "$SCRIPT_DIR/futures.db" ]; then if [ -f "$SCRIPT_DIR/futures.db" ]; then
cp -f "$SCRIPT_DIR/futures.db" "$RESTORE_DIR/futures.db" cp -f "$SCRIPT_DIR/futures.db" "$RESTORE_DIR/futures.db"
echo "已复制 futures.db -> $RESTORE_DIR/futures.db" echo "已复制 futures.db -> $RESTORE_DIR/futures.db"
@@ -655,18 +560,15 @@ fi
echo "" echo ""
echo "恢复完成。目标目录: $RESTORE_DIR" echo "恢复完成。目标目录: $RESTORE_DIR"
echo "下一步: pm2 restart qihuo" echo "下一步: pm2 restart qihuo"
echo "详见 RESTORE.md 与 docs/POSTGRES.md" echo "详见 RESTORE.md 与 docs/BACKUP.md"
""" """
dest.write_text(script, encoding="utf-8") dest.write_text(script, encoding="utf-8")
def create_backup(*, include_uploads: bool = True) -> tuple[str, str]: def create_backup(*, include_uploads: bool = True) -> tuple[str, str]:
"""创建 tar.gz 备份,返回 (文件名, 说明)。""" """创建 tar.gz 备份,返回 (文件名, 说明)。"""
backend = db_backend() if not os.path.isfile(DB_PATH):
if backend == "sqlite" and not os.path.isfile(DB_PATH):
raise FileNotFoundError(f"数据库不存在: {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: with _backup_lock:
stamp = datetime.now(TZ).strftime("%Y%m%d_%H%M%S") 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: with tempfile.TemporaryDirectory(prefix="qihuo_bak_") as tmp:
work = Path(tmp) / folder_name work = Path(tmp) / folder_name
work.mkdir() work.mkdir()
if backend == "postgres": _backup_sqlite(DB_PATH, str(work / "futures.db"))
_backup_postgres(str(work / "postgres_dump.sql"))
else:
_backup_sqlite(DB_PATH, str(work / "futures.db"))
if include_uploads and upload_src.is_dir(): if include_uploads and upload_src.is_dir():
shutil.copytree(upload_src, work / "uploads", dirs_exist_ok=True) 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 = { manifest = {
"app": "qihuo", "app": "qihuo",
"backend": backend, "backend": "sqlite",
"created_at": datetime.now(TZ).isoformat(timespec="seconds"), "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_uploads": include_uploads and upload_src.is_dir(),
"includes_env": includes_env, "includes_env": includes_env,
"env_restore_path": env_restore_path if includes_env else "", "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") (work / "RESTORE.md").write_text(RESTORE_MD, encoding="utf-8")
_write_restore_script( _write_restore_script(
work / "restore.sh", work / "restore.sh",
backend=backend,
env_restore_path=env_restore_path if includes_env else "", 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) tar.add(work, arcname=folder_name)
size_mb = out_path.stat().st_size / (1024 * 1024) size_mb = out_path.stat().st_size / (1024 * 1024)
label = "PostgreSQL" if backend == "postgres" else "SQLite" return filename, f"备份已生成 {filename}{size_mb:.2f} MB"
return filename, f"备份已生成 {filename}{label}{size_mb:.2f} MB"
def list_backups(*, with_manifest: bool = True) -> list[dict]: 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": stat.st_size,
"size_mb": round(stat.st_size / (1024 * 1024), 2), "size_mb": round(stat.st_size / (1024 * 1024), 2),
"mtime": datetime.fromtimestamp(stat.st_mtime, TZ).isoformat(timespec="seconds"), "mtime": datetime.fromtimestamp(stat.st_mtime, TZ).isoformat(timespec="seconds"),
"backend": "",
"backend_label": "",
"created_at": "", "created_at": "",
"includes_env": False, "includes_env": False,
"includes_uploads": False, "includes_uploads": False,
@@ -743,10 +638,6 @@ def list_backups(*, with_manifest: bool = True) -> list[dict]:
if with_manifest: if with_manifest:
try: try:
manifest = peek_manifest(path) 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["created_at"] = (manifest.get("created_at") or "").strip()
item["includes_env"] = bool(manifest.get("includes_env")) item["includes_env"] = bool(manifest.get("includes_env"))
item["includes_uploads"] = bool(manifest.get("includes_uploads")) item["includes_uploads"] = bool(manifest.get("includes_uploads"))
+1 -1
View File
@@ -77,7 +77,7 @@ def register(deps) -> None:
def api_backup_info(filename): def api_backup_info(filename):
try: try:
path = resolve_backup_file(filename) path = resolve_backup_file(filename)
return jsonify(inspect_backup_archive(path, check_backend=True)) return jsonify(inspect_backup_archive(path))
except (ValueError, FileNotFoundError) as exc: except (ValueError, FileNotFoundError) as exc:
return jsonify({"error": str(exc)}), 404 return jsonify({"error": str(exc)}), 404
+25 -143
View File
@@ -3,97 +3,41 @@
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 # 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md # 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""数据库连接:开发默认 SQLite,生产推荐 PostgreSQLDATABASE_URL""" """数据库连接:SQLite futures.db"""
from __future__ import annotations from __future__ import annotations
import os
import re
import sqlite3 import sqlite3
import threading
import time import time
from typing import Any, Iterable, Optional, Sequence from typing import Any, Sequence
from modules.core.paths import DB_PATH as _ROOT_DB_PATH, KLINE_DB_PATH as _KLINE_DB_PATH from modules.core.paths import DB_PATH as _ROOT_DB_PATH, KLINE_DB_PATH as _KLINE_DB_PATH
DB_PATH = _ROOT_DB_PATH DB_PATH = _ROOT_DB_PATH
KLINE_DB_PATH = _KLINE_DB_PATH KLINE_DB_PATH = _KLINE_DB_PATH
_backend_lock = threading.Lock()
_backend: Optional[str] = None
try:
import psycopg
from psycopg import OperationalError as PgOperationalError
from psycopg import IntegrityError as PgIntegrityError
from psycopg.rows import dict_row
_PSYCOPG_OK = True
except ImportError:
psycopg = None # type: ignore[assignment]
PgOperationalError = Exception # type: ignore[misc,assignment]
PgIntegrityError = Exception # type: ignore[misc,assignment]
dict_row = None # type: ignore[assignment]
_PSYCOPG_OK = False
OperationalError = sqlite3.OperationalError OperationalError = sqlite3.OperationalError
IntegrityError = sqlite3.IntegrityError IntegrityError = sqlite3.IntegrityError
def db_backend() -> str: def db_backend() -> str:
"""``sqlite`` 或 ``postgres``。""" return "sqlite"
global _backend
if _backend is not None:
return _backend
with _backend_lock:
if _backend is not None:
return _backend
url = (os.getenv("DATABASE_URL") or "").strip()
if url.startswith(("postgresql://", "postgres://")):
if not _PSYCOPG_OK:
raise RuntimeError(
"已配置 DATABASE_URL 但未安装 psycopg,请执行: pip install 'psycopg[binary]'"
)
_backend = "postgres"
else:
_backend = "sqlite"
return _backend
def is_postgres() -> bool: def is_postgres() -> bool:
return db_backend() == "postgres" return False
def database_label() -> str: def database_label() -> str:
if is_postgres():
url = (os.getenv("DATABASE_URL") or "").strip()
host = url.split("@")[-1].split("/")[0] if "@" in url else "postgresql"
return f"PostgreSQL ({host})"
return f"SQLite ({DB_PATH})" return f"SQLite ({DB_PATH})"
def adapt_sql(sql: str) -> str: def adapt_sql(sql: str) -> str:
"""将 SQLite 风格 SQL 适配为当前后端。""" return sql
if not is_postgres():
return sql
out = sql
out = re.sub(
r"\bINTEGER PRIMARY KEY AUTOINCREMENT\b",
"SERIAL PRIMARY KEY",
out,
flags=re.IGNORECASE,
)
out = re.sub(r"\bAUTOINCREMENT\b", "", out, flags=re.IGNORECASE)
out = re.sub(r'DEFAULT\s+"([^"]*)"', r"DEFAULT '\1'", out, flags=re.IGNORECASE)
if "?" in out:
out = out.replace("?", "%s")
return out
def is_benign_migration_error(exc: BaseException) -> bool: def is_benign_migration_error(exc: BaseException) -> bool:
"""ALTER TABLE 重复列等初始化迁移可忽略的错误。""" """ALTER TABLE 重复列等初始化迁移可忽略的错误。"""
if is_schema_migration_error(exc): return is_schema_migration_error(exc)
return True
return False
def is_schema_migration_error(exc: BaseException) -> bool: def is_schema_migration_error(exc: BaseException) -> bool:
@@ -107,8 +51,6 @@ def is_schema_migration_error(exc: BaseException) -> bool:
"duplicate key", "duplicate key",
"no such table", "no such table",
"does not exist", "does not exist",
"undefined table",
"undefined column",
) )
): ):
return True return True
@@ -116,10 +58,6 @@ def is_schema_migration_error(exc: BaseException) -> bool:
"duplicate column" in msg or "no such table" in msg "duplicate column" in msg or "no such table" in msg
): ):
return True return True
if _PSYCOPG_OK and isinstance(exc, PgOperationalError):
code = getattr(exc, "sqlstate", "") or ""
if code in ("42701", "42P07", "42P01", "42703"):
return True
return False return False
@@ -127,51 +65,29 @@ def is_missing_relation_error(exc: BaseException) -> bool:
"""表/视图不存在。""" """表/视图不存在。"""
if is_schema_migration_error(exc): if is_schema_migration_error(exc):
msg = str(exc).lower() msg = str(exc).lower()
return any(x in msg for x in ("no such table", "does not exist", "undefined table")) return any(x in msg for x in ("no such table", "does not exist"))
return False return False
def rollback_if_postgres(conn: "DbConnection") -> None: def rollback_if_postgres(conn: "DbConnection") -> None:
if is_postgres(): """兼容旧调用;当前仅使用 SQLite。"""
try: return
conn.rollback()
except Exception:
pass
class DbCursor: class DbCursor:
"""统一 cursor:兼容 sqlite3 的 execute / fetchone / lastrowid。""" """统一 cursor:兼容 sqlite3 的 execute / fetchone / lastrowid。"""
def __init__(self, backend: str, raw_cursor: Any, raw_conn: Any) -> None: def __init__(self, raw_cursor: Any, raw_conn: Any) -> None:
self._backend = backend
self._cur = raw_cursor self._cur = raw_cursor
self._conn = raw_conn self._conn = raw_conn
self.lastrowid: Optional[int] = None self.lastrowid: int | None = None
self.rowcount: int = 0 self.rowcount: int = 0
def execute(self, sql: str, params: Sequence[Any] | None = None) -> "DbCursor": def execute(self, sql: str, params: Sequence[Any] | None = None) -> "DbCursor":
sql = adapt_sql(sql)
params = params or () params = params or ()
self._cur.execute(sql, params) self._cur.execute(sql, params)
self.rowcount = int(getattr(self._cur, "rowcount", 0) or 0) self.rowcount = int(getattr(self._cur, "rowcount", 0) or 0)
self.lastrowid = getattr(self._cur, "lastrowid", None) self.lastrowid = getattr(self._cur, "lastrowid", None)
if self.lastrowid is None and is_postgres():
if re.match(r"^\s*INSERT\b", sql, re.IGNORECASE):
try:
row = self._cur.fetchone()
if row is not None:
if isinstance(row, dict):
self.lastrowid = int(row.get("id") or row.get("Id") or 0) or None
else:
self.lastrowid = int(row[0])
except Exception:
try:
self._cur.execute("SELECT lastval()")
lv = self._cur.fetchone()
if lv:
self.lastrowid = int(lv[0] if not isinstance(lv, dict) else lv["lastval"])
except Exception:
pass
return self return self
def fetchone(self) -> Any: def fetchone(self) -> Any:
@@ -190,16 +106,8 @@ class DbCursor:
class DbConnection: class DbConnection:
"""统一连接:execute / commit / close,接口对齐 sqlite3.Connection。""" """统一连接:execute / commit / close,接口对齐 sqlite3.Connection。"""
def __init__( def __init__(self, raw_conn: Any) -> None:
self,
backend: str,
raw_conn: Any,
*,
from_pool: bool = False,
) -> None:
self._backend = backend
self._conn = raw_conn self._conn = raw_conn
self._from_pool = from_pool
self.row_factory = None self.row_factory = None
def execute(self, sql: str, params: Sequence[Any] | None = None) -> DbCursor: def execute(self, sql: str, params: Sequence[Any] | None = None) -> DbCursor:
@@ -207,14 +115,10 @@ class DbConnection:
try: try:
return cur.execute(sql, params) return cur.execute(sql, params)
except Exception: except Exception:
rollback_if_postgres(self)
raise raise
def cursor(self) -> DbCursor: def cursor(self) -> DbCursor:
if self._backend == "sqlite": return DbCursor(self._conn.cursor(), self._conn)
return DbCursor(self._backend, self._conn.cursor(), self._conn)
raw = self._conn.cursor(row_factory=dict_row)
return DbCursor(self._backend, raw, self._conn)
def commit(self) -> None: def commit(self) -> None:
self._conn.commit() self._conn.commit()
@@ -246,18 +150,7 @@ class DbConnection:
def connect_db(path: str | None = None) -> DbConnection: def connect_db(path: str | None = None) -> DbConnection:
"""获取数据库连接。PostgreSQL / SQLite 均为每次新建连接(用毕 close)。""" """获取 SQLite 数据库连接(用毕 close)。"""
if is_postgres():
url = (os.getenv("DATABASE_URL") or "").strip()
raw = psycopg.connect(url, row_factory=dict_row)
try:
with raw.cursor() as cur:
cur.execute("SET TIME ZONE 'Asia/Shanghai'")
raw.commit()
except Exception:
pass
return DbConnection("postgres", raw, from_pool=False)
db_path = path or DB_PATH db_path = path or DB_PATH
raw = sqlite3.connect(db_path, timeout=30, check_same_thread=False) raw = sqlite3.connect(db_path, timeout=30, check_same_thread=False)
raw.row_factory = sqlite3.Row raw.row_factory = sqlite3.Row
@@ -266,11 +159,11 @@ def connect_db(path: str | None = None) -> DbConnection:
raw.execute("PRAGMA journal_mode=WAL") raw.execute("PRAGMA journal_mode=WAL")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
return DbConnection("sqlite", raw) return DbConnection(raw)
def close_pg_pool() -> None: def close_pg_pool() -> None:
"""兼容旧调用;当前 PostgreSQL 使用直连,无全局连接池""" """兼容旧调用。"""
return return
@@ -282,15 +175,14 @@ def execute_retry(
retries: int = 6, retries: int = 6,
base_delay: float = 0.05, base_delay: float = 0.05,
) -> DbCursor: ) -> DbCursor:
"""遇锁冲突时短暂退避重试(SQLite locked / PG serialization)。""" """遇锁冲突时短暂退避重试(SQLite locked)。"""
last_exc: Exception | None = None last_exc: Exception | None = None
for attempt in range(retries): for attempt in range(retries):
try: try:
return conn.execute(sql, params) return conn.execute(sql, params)
except (OperationalError, PgOperationalError) as exc: except OperationalError as exc:
msg = str(exc).lower() msg = str(exc).lower()
retryable = "locked" in msg or "serialize" in msg or "deadlock" in msg if "locked" not in msg:
if not retryable:
raise raise
last_exc = exc last_exc = exc
if attempt < retries - 1: if attempt < retries - 1:
@@ -312,10 +204,9 @@ def commit_retry(
try: try:
conn.commit() conn.commit()
return return
except (OperationalError, PgOperationalError) as exc: except OperationalError as exc:
msg = str(exc).lower() msg = str(exc).lower()
retryable = "locked" in msg or "serialize" in msg or "deadlock" in msg if "locked" not in msg:
if not retryable:
raise raise
last_exc = exc last_exc = exc
if attempt < retries - 1: if attempt < retries - 1:
@@ -326,21 +217,12 @@ def commit_retry(
def is_db_contention_error(exc: BaseException) -> bool: def is_db_contention_error(exc: BaseException) -> bool:
"""SQLite locked / PostgreSQL serialization / deadlock""" """SQLite locked。"""
msg = str(exc).lower()
if isinstance(exc, sqlite3.OperationalError): if isinstance(exc, sqlite3.OperationalError):
return "locked" in msg return "locked" in str(exc).lower()
if _PSYCOPG_OK and isinstance(exc, PgOperationalError):
code = getattr(exc, "sqlstate", "") or ""
if code in ("40001", "40P01", "55P03"):
return True
return any(x in msg for x in ("deadlock", "serialize", "lock"))
return False return False
def reset_backend_for_tests(backend: str | None = None) -> None: def reset_backend_for_tests(backend: str | None = None) -> None:
"""测试用:重置后端检测""" """兼容旧测试调用"""
global _backend return
close_pg_pool()
with _backend_lock:
_backend = backend
+1 -1
View File
@@ -29,7 +29,7 @@ REFRESH_SECONDS = {
def connect_kline_db(path: Optional[str] = None) -> sqlite3.Connection: def connect_kline_db(path: Optional[str] = None) -> sqlite3.Connection:
"""K 线专用 SQLite生产环境业务库可为 PostgreSQL,K 线仍走本地文件)。""" """K 线专用 SQLite与业务库 futures.db 分离)。"""
from modules.core.paths import KLINE_DB_PATH from modules.core.paths import KLINE_DB_PATH
db_path = path or KLINE_DB_PATH db_path = path or KLINE_DB_PATH
+3 -5
View File
@@ -575,18 +575,17 @@
{{ restore_status.message }} {{ restore_status.message }}
{% endif %} {% endif %}
</div> </div>
<p class="hint" style="margin:.5rem 0 0">备份含 <code>futures.db</code> / <code>postgres_dump.sql</code><code>uploads/</code><code>.env</code>。网页恢复目标:<code>{{ backup_restore_dir }}</code></p> <p class="hint" style="margin:.5rem 0 0">备份含 <code>futures.db</code><code>uploads/</code><code>.env</code>。网页恢复目标:<code>{{ backup_restore_dir }}</code></p>
{% if backup_items %} {% if backup_items %}
<table class="settings-backup-table" id="backup-items-table"> <table class="settings-backup-table" id="backup-items-table">
<thead> <thead>
<tr><th>文件名</th><th>类型</th><th>.env</th><th>大小</th><th>时间</th><th>操作</th></tr> <tr><th>文件名</th><th>.env</th><th>大小</th><th>时间</th><th>操作</th></tr>
</thead> </thead>
<tbody> <tbody>
{% for item in backup_items %} {% for item in backup_items %}
<tr data-backup-name="{{ item.name }}"> <tr data-backup-name="{{ item.name }}">
<td><code>{{ item.name }}</code></td> <td><code>{{ item.name }}</code></td>
<td>{{ item.backend_label or '—' }}</td>
<td>{% if item.includes_env %}有{% else %}—{% endif %}</td> <td>{% if item.includes_env %}有{% else %}—{% endif %}</td>
<td>{{ item.size_mb }} MB</td> <td>{{ item.size_mb }} MB</td>
<td>{{ (item.created_at or item.mtime).replace('T', ' ')[:16] }}</td> <td>{{ (item.created_at or item.mtime).replace('T', ' ')[:16] }}</td>
@@ -606,8 +605,7 @@
<summary>备份恢复说明</summary> <summary>备份恢复说明</summary>
<ol style="margin:.5rem 0 0 1rem;padding:0"> <ol style="margin:.5rem 0 0 1rem;padding:0">
<li>可在上方上传 <code>.tar.gz</code>,或从列表下载备份到本机。</li> <li>可在上方上传 <code>.tar.gz</code>,或从列表下载备份到本机。</li>
<li>点击「恢复此备份」会停止服务、还原数据库 / <code>uploads/</code> / <code>.env</code>,然后自动重启。</li> <li>点击「恢复此备份」会停止服务、还原 <code>futures.db</code><code>uploads/</code> <code>.env</code>,然后自动重启。</li>
<li>恢复前请确认备份类型与当前服务一致(SQLite / PostgreSQL)。</li>
<li>也可在服务器手工执行包内 <code>restore.sh</code>(见 <code>RESTORE_DIR</code>)。</li> <li>也可在服务器手工执行包内 <code>restore.sh</code>(见 <code>RESTORE_DIR</code>)。</li>
</ol> </ol>
</details> </details>