- 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 流程)
179 lines
6.8 KiB
Python
179 lines
6.8 KiB
Python
import logging
|
||
import os
|
||
import secrets
|
||
from datetime import timedelta
|
||
|
||
|
||
def _env_bool(key: str, default: bool = False) -> bool:
|
||
"""读取布尔环境变量,兼容 true/1/yes/on(大小写不敏感)。"""
|
||
val = os.environ.get(key)
|
||
if val is None:
|
||
return default
|
||
return val.strip().lower() in {"1", "true", "yes", "on"}
|
||
|
||
|
||
def _env_str(key: str, default: str = "") -> str:
|
||
val = os.environ.get(key)
|
||
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:
|
||
"""通用配置:所有环境共有的项。子类按需覆盖。"""
|
||
|
||
# ---- 应用基础 ----
|
||
SUPERADMIN = 'admin'
|
||
SYSTEM_NAME = 'Pear Admin'
|
||
|
||
SYSTEM_PANEL_LINKS = [
|
||
{"icon": "layui-icon layui-icon-auz", "title": "官方网站", "href": "http://www.pearadmin.com"},
|
||
{"icon": "layui-icon layui-icon-auz", "title": "开发文档", "href": "http://www.pearadmin.com"},
|
||
{"icon": "layui-icon layui-icon-auz", "title": "开源地址", "href": "https://gitee.com/Jmysy/Pear-Admin-Layui"},
|
||
]
|
||
|
||
# ---- 上传 ----
|
||
UPLOADED_PHOTOS_DEST = 'static/upload'
|
||
UPLOADED_FILES_ALLOW = ['gif', 'jpg', 'jpeg', 'png', 'webp']
|
||
UPLOADS_AUTOSERVE = True
|
||
|
||
# ---- JSON ----
|
||
JSON_AS_ASCII = False
|
||
|
||
# ---- 数据库 ----
|
||
# 默认走 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
|
||
|
||
# ---- 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
|
||
|
||
# ---- 安全 / 密钥 ----
|
||
# SECRET_KEY 的优先级:
|
||
# 1. 环境变量 SECRET_KEY
|
||
# 2. .env 中的 SECRET_KEY
|
||
# 3. 自动生成一个临时随机值(仅用于开发启动;生产模式强制要求外部传入)
|
||
SECRET_KEY = os.environ.get("SECRET_KEY") or "pear-system-flask"
|
||
|
||
# ---- Mail(默认占位;环境变量可覆盖)----
|
||
MAIL_SERVER = _env_str("MAIL_SERVER", "smtp.qq.com")
|
||
MAIL_USE_TLS = _env_bool("MAIL_USE_TLS", False)
|
||
MAIL_USE_SSL = _env_bool("MAIL_USE_SSL", True)
|
||
MAIL_PORT = int(_env_str("MAIL_PORT", "465"))
|
||
MAIL_USERNAME = _env_str("MAIL_USERNAME", "123@qq.com")
|
||
MAIL_PASSWORD = _env_str("MAIL_PASSWORD", "XXXXX") # QQ 邮箱授权码
|
||
MAIL_DEFAULT_SENDER = _env_str("MAIL_DEFAULT_SENDER", MAIL_USERNAME)
|
||
|
||
# ---- 插件 ----
|
||
# 站点业务模块(导航 / 友链 / 关于 / 访问统计)均通过 plugins 方式开发,启用即可挂载路由
|
||
PLUGIN_ENABLE_FOLDERS = ["navManager", "friendManager", "aboutManager", "siteStats"]
|
||
|
||
|
||
class DevConfig(BaseConfig):
|
||
"""开发环境配置:DEBUG=True,详细日志。"""
|
||
|
||
DEBUG = True
|
||
TESTING = False
|
||
LOG_LEVEL = logging.INFO
|
||
# 开发环境下 SECRET_KEY 自动生成(每次启动都不同,仅用于本地调试)。
|
||
SECRET_KEY = os.environ.get("SECRET_KEY") or secrets.token_urlsafe(48)
|
||
|
||
|
||
class ProConfig(BaseConfig):
|
||
"""生产环境配置:DEBUG=False,强制要求外部传入 SECRET_KEY。"""
|
||
|
||
DEBUG = False
|
||
TESTING = False
|
||
LOG_LEVEL = logging.WARN
|
||
|
||
# 生产推荐使用 MySQL,例如:
|
||
# SQLALCHEMY_DATABASE_URI=mysql+pymysql://user:pass@host:3306/pear?charset=utf8mb4
|
||
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||
"SQLALCHEMY_DATABASE_URI",
|
||
BaseConfig.SQLALCHEMY_DATABASE_URI,
|
||
)
|
||
|
||
@classmethod
|
||
def init_app(cls, app):
|
||
"""Flask 在 app.config.from_object 之后调用此钩子。
|
||
|
||
生产模式要求:必须从环境变量传入强随机 SECRET_KEY,
|
||
否则启动失败 —— 防止线上仍用默认密钥。
|
||
"""
|
||
secret = os.environ.get("SECRET_KEY")
|
||
if not secret or secret == "pear-system-flask":
|
||
raise RuntimeError(
|
||
"[ProConfig] 生产环境必须通过环境变量 SECRET_KEY 传入强随机密钥,"
|
||
"禁止使用默认值。可用以下命令生成:\n"
|
||
" python -c \"import secrets; print(secrets.token_urlsafe(48))\""
|
||
)
|
||
app.config["SECRET_KEY"] = secret
|
||
|
||
|
||
# 暴露给外部按名字取
|
||
config_map = {
|
||
"dev": DevConfig,
|
||
"development": DevConfig,
|
||
"pro": ProConfig,
|
||
"production": ProConfig,
|
||
}
|
||
|
||
|
||
def get_config_by_name(name: str):
|
||
"""根据名字取配置类,未知名字退回 DevConfig。"""
|
||
if not name:
|
||
return DevConfig
|
||
return config_map.get(name.strip().lower(), DevConfig)
|