diff --git a/config/.env.example b/config/.env.example index 28c7309..08142de 100644 --- a/config/.env.example +++ b/config/.env.example @@ -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)—— diff --git a/modules/backup/db_backup.py b/modules/backup/db_backup.py index f15373a..406b99f 100644 --- a/modules/backup/db_backup.py +++ b/modules/backup/db_backup.py @@ -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")) diff --git a/modules/backup/routes.py b/modules/backup/routes.py index 6204d09..5a8de61 100644 --- a/modules/backup/routes.py +++ b/modules/backup/routes.py @@ -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 diff --git a/modules/core/db_conn.py b/modules/core/db_conn.py index cccf41c..fc28f9b 100644 --- a/modules/core/db_conn.py +++ b/modules/core/db_conn.py @@ -3,97 +3,41 @@ # 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。 # 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md -"""数据库连接:开发默认 SQLite,生产推荐 PostgreSQL(DATABASE_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 + return sql 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 diff --git a/modules/market/kline_store.py b/modules/market/kline_store.py index abf239b..bceab7b 100644 --- a/modules/market/kline_store.py +++ b/modules/market/kline_store.py @@ -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 diff --git a/modules/web/templates/settings.html b/modules/web/templates/settings.html index 7f59005..6d7c674 100644 --- a/modules/web/templates/settings.html +++ b/modules/web/templates/settings.html @@ -575,18 +575,17 @@ {{ restore_status.message }} {% endif %} -

备份含 futures.db / postgres_dump.sqluploads/.env。网页恢复目标:{{ backup_restore_dir }}

+

备份含 futures.dbuploads/.env。网页恢复目标:{{ backup_restore_dir }}

{% if backup_items %} - + {% for item in backup_items %} - @@ -606,8 +605,7 @@ 备份恢复说明
  1. 可在上方上传 .tar.gz,或从列表下载备份到本机。
  2. -
  3. 点击「恢复此备份」会停止服务、还原数据库 / uploads/ / .env,然后自动重启。
  4. -
  5. 恢复前请确认备份类型与当前服务一致(SQLite / PostgreSQL)。
  6. +
  7. 点击「恢复此备份」会停止服务、还原 futures.dbuploads/.env,然后自动重启。
  8. 也可在服务器手工执行包内 restore.sh(见 RESTORE_DIR)。
文件名类型.env大小时间操作
文件名.env大小时间操作
{{ item.name }}{{ item.backend_label or '—' }} {% if item.includes_env %}有{% else %}—{% endif %} {{ item.size_mb }} MB {{ (item.created_at or item.mtime).replace('T', ' ')[:16] }}