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
TRADING_DAY_RESET_HOUR=8
# —— 数据库(生产推荐 PostgreSQL,见 docs/POSTGRES.md)——
# 未配置 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
# —— 数据库(SQLite futures.db,路径见 modules/core/paths.py)——
+29 -138
View File
@@ -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":
def _validate_archive_contents(tar: tarfile.TarFile, root: str) -> None:
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")
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,31 +384,13 @@ 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())
_validate_manifest(manifest)
root = _manifest_root_prefix(tar)
_validate_archive_contents(tar, manifest, root)
_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()
@@ -445,22 +399,14 @@ def _perform_restore(archive_path: Path, restore_dir: Path) -> dict:
_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)
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,9 +581,6 @@ 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"))
if include_uploads and upload_src.is_dir():
@@ -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"))
+1 -1
View File
@@ -77,7 +77,7 @@ def register(deps) -> None:
def api_backup_info(filename):
try:
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:
return jsonify({"error": str(exc)}), 404
+24 -142
View File
@@ -3,97 +3,41 @@
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
"""数据库连接:开发默认 SQLite,生产推荐 PostgreSQLDATABASE_URL"""
"""数据库连接:SQLite futures.db"""
from __future__ import annotations
import os
import re
import sqlite3
import threading
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
DB_PATH = _ROOT_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
IntegrityError = sqlite3.IntegrityError
def db_backend() -> str:
"""``sqlite`` 或 ``postgres``。"""
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
return "sqlite"
def is_postgres() -> bool:
return db_backend() == "postgres"
return False
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})"
def adapt_sql(sql: str) -> str:
"""将 SQLite 风格 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:
"""ALTER TABLE 重复列等初始化迁移可忽略的错误。"""
if is_schema_migration_error(exc):
return True
return False
return is_schema_migration_error(exc)
def is_schema_migration_error(exc: BaseException) -> bool:
@@ -107,8 +51,6 @@ def is_schema_migration_error(exc: BaseException) -> bool:
"duplicate key",
"no such table",
"does not exist",
"undefined table",
"undefined column",
)
):
return True
@@ -116,10 +58,6 @@ def is_schema_migration_error(exc: BaseException) -> bool:
"duplicate column" in msg or "no such table" in msg
):
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
@@ -127,51 +65,29 @@ def is_missing_relation_error(exc: BaseException) -> bool:
"""表/视图不存在。"""
if is_schema_migration_error(exc):
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
def rollback_if_postgres(conn: "DbConnection") -> None:
if is_postgres():
try:
conn.rollback()
except Exception:
pass
"""兼容旧调用;当前仅使用 SQLite。"""
return
class DbCursor:
"""统一 cursor:兼容 sqlite3 的 execute / fetchone / lastrowid。"""
def __init__(self, backend: str, raw_cursor: Any, raw_conn: Any) -> None:
self._backend = backend
def __init__(self, raw_cursor: Any, raw_conn: Any) -> None:
self._cur = raw_cursor
self._conn = raw_conn
self.lastrowid: Optional[int] = None
self.lastrowid: int | None = None
self.rowcount: int = 0
def execute(self, sql: str, params: Sequence[Any] | None = None) -> "DbCursor":
sql = adapt_sql(sql)
params = params or ()
self._cur.execute(sql, params)
self.rowcount = int(getattr(self._cur, "rowcount", 0) or 0)
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
def fetchone(self) -> Any:
@@ -190,16 +106,8 @@ class DbCursor:
class DbConnection:
"""统一连接:execute / commit / close,接口对齐 sqlite3.Connection。"""
def __init__(
self,
backend: str,
raw_conn: Any,
*,
from_pool: bool = False,
) -> None:
self._backend = backend
def __init__(self, raw_conn: Any) -> None:
self._conn = raw_conn
self._from_pool = from_pool
self.row_factory = None
def execute(self, sql: str, params: Sequence[Any] | None = None) -> DbCursor:
@@ -207,14 +115,10 @@ class DbConnection:
try:
return cur.execute(sql, params)
except Exception:
rollback_if_postgres(self)
raise
def cursor(self) -> DbCursor:
if self._backend == "sqlite":
return DbCursor(self._backend, self._conn.cursor(), self._conn)
raw = self._conn.cursor(row_factory=dict_row)
return DbCursor(self._backend, raw, self._conn)
return DbCursor(self._conn.cursor(), self._conn)
def commit(self) -> None:
self._conn.commit()
@@ -246,18 +150,7 @@ class DbConnection:
def connect_db(path: str | None = None) -> DbConnection:
"""获取数据库连接。PostgreSQL / 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)
"""获取 SQLite 数据库连接(用毕 close)。"""
db_path = path or DB_PATH
raw = sqlite3.connect(db_path, timeout=30, check_same_thread=False)
raw.row_factory = sqlite3.Row
@@ -266,11 +159,11 @@ def connect_db(path: str | None = None) -> DbConnection:
raw.execute("PRAGMA journal_mode=WAL")
except sqlite3.OperationalError:
pass
return DbConnection("sqlite", raw)
return DbConnection(raw)
def close_pg_pool() -> None:
"""兼容旧调用;当前 PostgreSQL 使用直连,无全局连接池"""
"""兼容旧调用。"""
return
@@ -282,15 +175,14 @@ def execute_retry(
retries: int = 6,
base_delay: float = 0.05,
) -> DbCursor:
"""遇锁冲突时短暂退避重试(SQLite locked / PG serialization)。"""
"""遇锁冲突时短暂退避重试(SQLite locked)。"""
last_exc: Exception | None = None
for attempt in range(retries):
try:
return conn.execute(sql, params)
except (OperationalError, PgOperationalError) as exc:
except OperationalError as exc:
msg = str(exc).lower()
retryable = "locked" in msg or "serialize" in msg or "deadlock" in msg
if not retryable:
if "locked" not in msg:
raise
last_exc = exc
if attempt < retries - 1:
@@ -312,10 +204,9 @@ def commit_retry(
try:
conn.commit()
return
except (OperationalError, PgOperationalError) as exc:
except OperationalError as exc:
msg = str(exc).lower()
retryable = "locked" in msg or "serialize" in msg or "deadlock" in msg
if not retryable:
if "locked" not in msg:
raise
last_exc = exc
if attempt < retries - 1:
@@ -326,21 +217,12 @@ def commit_retry(
def is_db_contention_error(exc: BaseException) -> bool:
"""SQLite locked / PostgreSQL serialization / deadlock"""
msg = str(exc).lower()
"""SQLite locked。"""
if isinstance(exc, sqlite3.OperationalError):
return "locked" in msg
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 "locked" in str(exc).lower()
return False
def reset_backend_for_tests(backend: str | None = None) -> None:
"""测试用:重置后端检测"""
global _backend
close_pg_pool()
with _backend_lock:
_backend = backend
"""兼容旧测试调用"""
return
+1 -1
View File
@@ -29,7 +29,7 @@ REFRESH_SECONDS = {
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
db_path = path or KLINE_DB_PATH
+3 -5
View File
@@ -575,18 +575,17 @@
{{ restore_status.message }}
{% endif %}
</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 %}
<table class="settings-backup-table" id="backup-items-table">
<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>
<tbody>
{% for item in backup_items %}
<tr data-backup-name="{{ item.name }}">
<td><code>{{ item.name }}</code></td>
<td>{{ item.backend_label or '—' }}</td>
<td>{% if item.includes_env %}有{% else %}—{% endif %}</td>
<td>{{ item.size_mb }} MB</td>
<td>{{ (item.created_at or item.mtime).replace('T', ' ')[:16] }}</td>
@@ -606,8 +605,7 @@
<summary>备份恢复说明</summary>
<ol style="margin:.5rem 0 0 1rem;padding:0">
<li>可在上方上传 <code>.tar.gz</code>,或从列表下载备份到本机。</li>
<li>点击「恢复此备份」会停止服务、还原数据库 / <code>uploads/</code> / <code>.env</code>,然后自动重启。</li>
<li>恢复前请确认备份类型与当前服务一致(SQLite / PostgreSQL)。</li>
<li>点击「恢复此备份」会停止服务、还原 <code>futures.db</code><code>uploads/</code> <code>.env</code>,然后自动重启。</li>
<li>也可在服务器手工执行包内 <code>restore.sh</code>(见 <code>RESTORE_DIR</code>)。</li>
</ol>
</details>