Remove PostgreSQL support and standardize on SQLite only.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-5
@@ -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
|
|
||||||
|
|||||||
+29
-138
@@ -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 backend == "sqlite":
|
|
||||||
if not _member_exists(tar, root, "futures.db"):
|
if not _member_exists(tar, root, "futures.db"):
|
||||||
raise ValueError("SQLite 备份缺少 futures.db")
|
raise ValueError("备份缺少 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,31 +384,13 @@ 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:
|
|
||||||
work = Path(tmp)
|
|
||||||
with tarfile.open(archive_path, "r:gz") as tar:
|
with tarfile.open(archive_path, "r:gz") as tar:
|
||||||
manifest = _read_manifest_from_tar(tar)
|
manifest = _read_manifest_from_tar(tar)
|
||||||
backend = _validate_manifest(manifest, current_backend=db_backend())
|
_validate_manifest(manifest)
|
||||||
root = _manifest_root_prefix(tar)
|
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_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()
|
||||||
@@ -445,22 +399,14 @@ def _perform_restore(archive_path: Path, restore_dir: Path) -> dict:
|
|||||||
_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)
|
||||||
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)
|
_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,9 +581,6 @@ 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_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():
|
if include_uploads and upload_src.is_dir():
|
||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+24
-142
@@ -3,97 +3,41 @@
|
|||||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||||
|
|
||||||
"""数据库连接:开发默认 SQLite,生产推荐 PostgreSQL(DATABASE_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 适配为当前后端。"""
|
|
||||||
if not is_postgres():
|
|
||||||
return sql
|
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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user