feat(data): 数据库搬到 data/ 目录 + SQLite WAL + 单卷绑定挂载
- config.py 新增 PEAR_DATA_DIR(默认 <项目根>/data),SQLite 库、Flask-Session
统一收进 DATA_DIR;不设时本地开发行为不变
- applications/__init__.py 顶部先 load_dotenv,保证 .env 里的 PEAR_DATA_DIR
在 config 导入时就生效
- extensions/init_sqlalchemy.py 接 SQLite connect 事件:WAL + synchronous=NORMAL
+ busy_timeout=30s(多 worker 写不再撞锁)
- config.py 注入 SQLALCHEMY_ENGINE_OPTIONS(pool_pre_ping + SQLite connect_args
超时;MySQL 路径下自动跳过 connect_args 防止参数错误)
- 新增 alembic 迁移 a47a5d2a3f1b 建 site_nav_click(含 anon_id 字段),
解决之前该表只由 db.create_all 建、不在迁移链里的隐患
- .gitignore / .dockerignore 加 data/ 排除规则(且把 migrations/ 从
ignore 里重新放行 —— 否则新加的迁移进不了库)
- NAS Docker 部署全面重写:
- compose 唯一绑定挂载 ./data:/app/data,宿主 File Station 看得见
- start.sh 自检 data 目录 + 子目录;workers 默认改为 1 threads 8
(SQLite 写串行,单进程最稳)
- 新增 backup.sh:sqlite3 .backup 在线热备(确保 WAL 一致性)+ 压缩
+ 保留 14 天
- Dockerfile 安装 sqlite3 客户端备用,建 /app/data 子目录并 chown
- docs/deployment-nas.md 重写数据持久化章节(路径表 + 热备命令 +
恢复步骤 + 整库迁移 tar/untar 流程)
This commit is contained in:
@@ -3,18 +3,21 @@ import os
|
||||
from dotenv import load_dotenv
|
||||
from flask import Flask
|
||||
|
||||
from applications.common.script import init_script
|
||||
from applications.config import BaseConfig, get_config_by_name
|
||||
from applications.extensions import init_plugs
|
||||
from applications.view import init_bps
|
||||
|
||||
# 项目根目录 = 本文件父目录的父目录
|
||||
BASE_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
# 必须先于 applications.config 加载:config 在模块级就要读 PEAR_DATA_DIR
|
||||
# 等环境变量来拼 data 目录路径,晚一步就会沿用默认值。
|
||||
load_dotenv(os.path.join(BASE_DIR, ".env"), override=False)
|
||||
|
||||
from applications.common.script import init_script
|
||||
from applications.config import BaseConfig, get_config_by_name, ensure_data_dir
|
||||
from applications.extensions import init_plugs
|
||||
from applications.view import init_bps
|
||||
|
||||
|
||||
def create_app():
|
||||
# 1) 先加载 .env(在 BaseConfig 读取 os.environ 之前)
|
||||
# override=False:环境变量优先级高于 .env,便于 CI/CD 在部署时再覆盖
|
||||
# 1) 再加载一次 .env(幂等;override=False 保证环境变量优先级更高)
|
||||
load_dotenv(os.path.join(BASE_DIR, ".env"), override=False)
|
||||
|
||||
app = Flask(BASE_DIR)
|
||||
@@ -32,6 +35,16 @@ def create_app():
|
||||
if hasattr(config_cls, "init_app"):
|
||||
config_cls.init_app(app)
|
||||
|
||||
# 3.6) 数据目录兜底:确保 data/ 存在,并以运行时解析结果为准写回路径
|
||||
# (.env 里配了 PEAR_DATA_DIR 时,这里保证它一定生效)
|
||||
data_dir = ensure_data_dir()
|
||||
app.config["DATA_DIR"] = data_dir
|
||||
if not os.environ.get("SQLALCHEMY_DATABASE_URI"):
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = (
|
||||
"sqlite:///" + os.path.join(data_dir, "pear.db").replace("\\", "/")
|
||||
)
|
||||
app.config["SESSION_FILE_DIR"] = os.path.join(data_dir, "flask_session")
|
||||
|
||||
# 4) 注册 flask 组件 / 蓝图 / 命令
|
||||
init_plugs(app)
|
||||
init_bps(app)
|
||||
|
||||
+50
-3
@@ -17,6 +17,44 @@ def _env_str(key: str, default: str = "") -> str:
|
||||
return val if val is not None and val != "" else default
|
||||
|
||||
|
||||
# ---- 数据目录(持久化根目录)----
|
||||
#
|
||||
# 所有「会变、需要留下来」的东西统一放这里:
|
||||
# data/pear.db SQLite 主库(含 -wal / -shm)
|
||||
# data/backup/ 在线热备产物
|
||||
# data/flask_session/ Flask-Session 文件会话
|
||||
#
|
||||
# 为什么要单独一个目录:
|
||||
# 项目根目录会被 Git / Docker 镜像整包替换,数据库混在源码里既容易误提交,
|
||||
# 也会在容器重建时随镜像层一起丢掉。独立成 data/ 之后:
|
||||
# - 宿主机/NAS 上只要备份或绑定挂载这一个目录,数据就在;
|
||||
# - .gitignore / .dockerignore 各写一条就能彻底隔开。
|
||||
#
|
||||
# 默认值:项目根目录下的 data/(applications/ 的上一级)。
|
||||
# Docker 部署时用环境变量 PEAR_DATA_DIR=/app/data 覆盖,
|
||||
# 再把 NAS 共享文件夹绑到 /app/data 即可。
|
||||
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DATA_DIR = os.path.abspath(os.environ.get("PEAR_DATA_DIR") or os.path.join(_PROJECT_ROOT, "data"))
|
||||
|
||||
|
||||
def _sqlite_uri(filename: str) -> str:
|
||||
"""拼 SQLite URI。统一换成正斜杠,保证 Linux 容器与 Windows 都能解析。"""
|
||||
return "sqlite:///" + os.path.join(DATA_DIR, filename).replace("\\", "/")
|
||||
|
||||
|
||||
def ensure_data_dir() -> str:
|
||||
"""确保数据目录(及子目录)存在,返回根目录绝对路径。
|
||||
|
||||
在 create_app 里调用;这里再兜一层 try,避免只读环境下 import 就崩。
|
||||
"""
|
||||
for sub in ("", "backup", "flask_session"):
|
||||
try:
|
||||
os.makedirs(os.path.join(DATA_DIR, sub), exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return DATA_DIR
|
||||
|
||||
|
||||
class BaseConfig:
|
||||
"""通用配置:所有环境共有的项。子类按需覆盖。"""
|
||||
|
||||
@@ -39,10 +77,18 @@ class BaseConfig:
|
||||
JSON_AS_ASCII = False
|
||||
|
||||
# ---- 数据库 ----
|
||||
# 默认走项目根目录下的 pear.db(与 instance/ 同一级,Flask 2.x 行为)。
|
||||
# 生产环境推荐通过 SQLALCHEMY_DATABASE_URI 环境变量切换到 MySQL。
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///../pear.db'
|
||||
# 默认走 data/pear.db(Docker 下由 PEAR_DATA_DIR 重定向到挂载点)。
|
||||
# 想换成 MySQL 就直接设环境变量 SQLALCHEMY_DATABASE_URI,优先级最高。
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||
"SQLALCHEMY_DATABASE_URI", _sqlite_uri("pear.db")
|
||||
)
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True}
|
||||
if SQLALCHEMY_DATABASE_URI.startswith("sqlite"):
|
||||
# SQLite 写保护:拿不到锁最多等 30s 再放弃,避免 gunicorn 多 worker 下
|
||||
# 偶发 "database is locked"。WAL 模式由 extensions 里的 connect 事件开启。
|
||||
# 注意:这组 connect_args 是 sqlite3 专用的,切 MySQL 时不能带上。
|
||||
SQLALCHEMY_ENGINE_OPTIONS["connect_args"] = {"timeout": 30}
|
||||
|
||||
# ---- 日志 ----
|
||||
LOG_LEVEL = logging.WARN
|
||||
@@ -50,6 +96,7 @@ class BaseConfig:
|
||||
# ---- Session ----
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
|
||||
SESSION_TYPE = "filesystem"
|
||||
SESSION_FILE_DIR = os.path.join(DATA_DIR, "flask_session")
|
||||
SESSION_PERMANENT = False
|
||||
SESSION_USE_SIGNER = True
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import os
|
||||
from flask import Flask, request
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_sqlalchemy.query import Query as BaseQuery
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
from flask_marshmallow import Marshmallow
|
||||
from marshmallow import fields
|
||||
from marshmallow.validate import (
|
||||
@@ -115,6 +117,24 @@ ma = Marshmallow()
|
||||
def init_databases(app: Flask):
|
||||
db.init_app(app)
|
||||
ma.init_app(app)
|
||||
|
||||
# SQLite 才启用 WAL。WAL 把读与写互不阻塞,gunicorn 多 worker 写时基本不再
|
||||
# 撞 "database is locked";同目录下会产生 pear.db-wal / pear.db-shm 两个文件,
|
||||
# 备份必须用 VACUUM INTO 或 sqlite3 .backup,不能直接 cp(见 deploy/nas/backup.sh)。
|
||||
if app.config.get("SQLALCHEMY_DATABASE_URI", "").startswith("sqlite"):
|
||||
@event.listens_for(Engine, "connect")
|
||||
def _sqlite_pragmas(dbapi_connection, connection_record):
|
||||
cur = dbapi_connection.cursor()
|
||||
try:
|
||||
cur.execute("PRAGMA journal_mode=WAL")
|
||||
cur.execute("PRAGMA synchronous=NORMAL")
|
||||
cur.execute("PRAGMA foreign_keys=ON")
|
||||
# busy_timeout 在 config.py 的 connect_args 里已设;这里再兜一次
|
||||
# 兜底,防止用户自定义 connect_args 时漏配
|
||||
cur.execute("PRAGMA busy_timeout=30000")
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
if os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
|
||||
with app.app_context():
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user