32 Commits
Author SHA1 Message Date
zhengxinonly fa69175e65 refactor(backend): 统一返回的数据格式 2025-07-08 22:16:12 +08:00
zhengxinonly 65e41ac9f1 style: 修改 fetch 请求为 ajax,统一请求风格 2024-04-21 13:01:48 +08:00
zhengxinonly b5057af31e fix(templates): 修复新增内容时还存留缓存数据 2024-01-31 23:51:10 +08:00
zhengxinonly e5a60da215 style: 修复错别字 2024-01-31 23:35:18 +08:00
zhengxinonly c6598df116 style: 修改返回信息格式 2024-01-31 22:52:41 +08:00
zhengxinonly 801eed010f refactor(all): 完成项目的前后端逻辑分离,静态页面可以单独部署 2024-01-28 17:43:25 +08:00
zhengxinonly 2b296c1c64 fix(templates): 修复引用样式错误 2023-12-23 00:51:31 +08:00
zhengxinonly b5625ecf1f refactor(templates): 移除 jinja2 includes 使用 2023-12-22 23:01:45 +08:00
zhengxinonly 7203bcfecc fix(passport): 修复初始化数据错误密码也可以登录问题 2023-12-22 22:46:54 +08:00
zhengxinonly 2f4f1faf82 fix: 完善新增权限操作 2023-12-05 16:50:21 +08:00
zhengxinonly 2ffddda02b style: pear admin layui 更新到 4.0 正式版 2023-11-26 00:51:02 +08:00
zhengxinonly 3086c1a1fa feature: 动态菜单生成 2023-11-07 20:53:20 +08:00
zhengxinonly eee1bda795 docs: 添加使用说明 2023-10-31 21:53:30 +08:00
zhengxinonly 7c07b6c94d feature: 用户角色授权 2023-10-31 21:45:10 +08:00
zhengxinonly fcf2ad232e feature: update flask to 3.0.0 2023-10-31 20:52:32 +08:00
zhengxinonly 537e21728b feature: 完成角色授权 2023-10-31 20:23:09 +08:00
zhengxinonly 6a8fb955f5 feature: 错误页面返回 2023-10-29 17:04:05 +08:00
zhengxinonly 261ac32825 feature: 用户管理 2023-10-29 15:19:14 +08:00
zhengxinonly 6daad895b4 feature: 部门增删改查 2023-10-27 20:51:10 +08:00
zhengxinonly 52e0737311 feature: 角色增删改 2023-10-14 20:20:46 +08:00
zhengxinonly 075b624522 feature: 权限列表-3 2023-10-14 19:17:01 +08:00
zhengxinonly 607cbc94ba feature: 权限列表-2 2023-10-10 22:55:15 +08:00
zhengxinonly f95ae54fd5 feature: 权限列表-01 2023-10-03 01:04:15 +08:00
zhengxinonly 20b611ac29 feature: 动态生成菜单栏 2023-10-02 00:57:20 +08:00
zhengxinonly 69a4955ce2 feature: 登录&权限拦截 2023-10-01 23:42:59 +08:00
zhengxinonly 8765b2c344 feature: 初始化 jwt 插件 2023-09-30 16:19:07 +08:00
zhengxinonly bdd766d2b2 feature: 初始化接口 api 2023-09-30 16:12:43 +08:00
zhengxinonly ba5e2cf67b feature: 添加静态文件 2023-09-29 14:57:03 +08:00
zhengxinonly 2c3c79b21f test: 添加测试数据 2023-09-29 01:48:46 +08:00
zhengxinonly 9bf89e5919 feature: 新增数据模型 2023-09-27 17:49:13 +08:00
zhengxinonly 4db050c93d feature: 配置文件&配置插件 2023-09-27 17:10:45 +08:00
zhengxinonly 49822daada feature: 项目初始化 2023-09-26 22:47:21 +08:00
1305 changed files with 156377 additions and 435631 deletions
+69
View File
@@ -0,0 +1,69 @@
[tool.commitizen]
name = "cz_customize"
tag_format = "$version"
version_scheme = "pep440"
version_provider = "poetry"
update_changelog_on_bump = true
major_version_zero = true
[tool.commitizen.customize]
message_template = "{{change_type}}{% if scope %}({{scope}}){% endif %}:{% if subject %} {{subject}}{% endif %} {% if footer %} {{footer}}{% endif %}"
example = "feature: this feature enable customize through config file"
schema = "<type>: <body>"
schema_pattern = "(feature|fix|docs|style|refactor|perf|test|revert|build).*?:(\\s.*)"
bump_pattern = "^(break|new|fix|hotfix)"
bump_map = { "break" = "MAJOR", "new" = "MINOR", "fix" = "PATCH", "hotfix" = "PATCH" }
change_type_order = ["BREAKING CHANGE", "feat", "fix", "refactor", "perf"]
info_path = "cz_customize_info.txt"
info = """
This is customized info
"""
commit_parser = "^(?P<change_type>feature|fix|docs|style|refactor|perf|test|revert|build).*?:\\s(?P<message>.*)?"
changelog_pattern = "^(feature|fix|docs|style|refactor|perf|test|revert|build)?(!)?"
change_type_map = { "feature" = "Feat", "bug fix" = "Fix" }
# 自定义提交指令
[[tool.commitizen.customize.questions]]
type = "list"
name = "change_type"
choices = [
{ value = "feature", name = "feature: 新功能" },
{ value = "fix", name = "fix: 修复 bug" },
{ value = "docs", name = "docs: 文档的修改" },
{ value = "style", name = "style: 格式化变动,不影响代码逻辑" },
{ value = "refactor", name = "refactor: 重构,即不是新增功能,也不是修改 bug 的代码变动" },
{ value = "perf", name = "perf: 性能优化" },
{ value = "test", name = "test: 增加测试" },
{ value = "revert", name = "revert: 回退" },
{ value = "build", name = "build: 打包" },
]
# choices = ["feature", "fix"] # short version
message = "请选择提交类型:"
# 修改内容的范围
[[tool.commitizen.customize.questions]]
type = "input"
name = "scope"
message = "请输入修改内容的范围(可选):"
# 自己的提交内容
[[tool.commitizen.customize.questions]]
type = "input"
name = "subject"
message = "请输入简要描述(必填):"
# 自己的提交内容
[[tool.commitizen.customize.questions]]
type = "input"
name = "body"
message = "请输入详细描述(可选):"
[[tool.commitizen.customize.questions]]
type = "input"
name = "footer"
message = "请输入需要关闭的 issue(可选):"
-67
View File
@@ -1,67 +0,0 @@
# =====================================================
# Pear Admin Flask - Docker 构建期排除
# =====================================================
# 防止 .git / 缓存 / 测试截图 / 一次性脚本进镜像
# 注意:运行时数据(pear.db)通过 docker-compose volume 挂载,
# 这里排除 *.db 只是防止源代码里的 db 文件被误 COPY
# ---- VCS ----
.git/
.gitignore
.github/
# ---- Python 缓存 ----
__pycache__/
*.py[cod]
*$py.class
*.so
# ---- 虚拟环境 ----
venv/
.venv/
env/
# ---- 数据库 / 运行时数据 ----
# pear.db 不在源码里(运行时由容器创建 + 卷挂载),这里排除只是兜底
*.db
*.db.bak
pear.db.before_dedup.*
# 数据目录(含 pear.db / backup / flask_session),整目录不进镜像,由 volume 接管
data/
data/*/
# ---- 日志 ----
*.log
logs/
# ---- Flask ----
instance/
flask_session/
# ---- IDE / 编辑器 ----
.idea/
.vscode/
*.swp
# ---- 构建 / 测试产物 ----
build/
dist/
*.egg-info/
.pytest_cache/
htmlcov/
# ---- 测试截图 / 探针脚本输出 ----
scripts/_verify_*.png
scripts/screenshots/
output/
# ---- 开发用 docker 配置(不进 NAS 镜像)----
deploy/dev/
# ---- 一次性工具 ----
nav_extracted.js
dedup_nav.py
# ---- 环境变量文件(不进镜像)----
.env
.env.*.local
-52
View File
@@ -1,52 +0,0 @@
# =====================================================
# Pear Admin Flask - 生产环境变量模板
# =====================================================
# 使用方式:
# 1. 复制本文件为 .env cp .env.example .env
# 2. 修改 SECRET_KEY 为强随机值(必填)
# 3. 邮件 / 数据库如不需要可保持注释状态
# 4. docker compose 会自动读取同级目录的 .env
# =====================================================
# ---- 运行模式 ----
# 必填:dev / propro 会强制校验 SECRET_KEY
PEAR_ENV=pro
# ---- Flask 应用入口 ----
FLASK_APP=app.py
# ---- 安全密钥(生产必填,强随机)----
# 生成命令:python -c "import secrets; print(secrets.token_urlsafe(48))"
SECRET_KEY=PLEASE_REPLACE_WITH_RANDOM_STRING
# ---- 数据库 ----
# 默认 SQLite;库文件、session、备份都收进 PEAR_DATA_DIR 指向的目录。
# docker-compose.yaml 已经把宿主 ./data 绑到容器 /app/dataPEAR_DATA_DIR 默认
# 就是 /app/data,不需要额外改。这里只是给你本地开发调整用的口子。
# PEAR_DATA_DIR=D:/bwstudio/pear-admin-flask/data
# 默认 SQLiteWAL 模式由 extensions 自动开启(多 worker 写也不容易撞锁)。
# 想换成 MySQL 就解注释下面这行;docker-compose.yaml 里也写好了 db 服务可参考:
# SQLALCHEMY_DATABASE_URI=mysql+pymysql://pear:pear@db:3306/pear?charset=utf8mb4
# SQLALCHEMY_DATABASE_URI=sqlite:///../pear.db
# ---- 时区 ----
TZ=Asia/Shanghai
# ---- 邮件(可选,未启用找回密码等功能可不填)----
# MAIL_SERVER=smtp.qq.com
# MAIL_PORT=465
# MAIL_USE_SSL=true
# MAIL_USERNAME=yourname@qq.com
# MAIL_PASSWORD=authorization_code_here
# MAIL_DEFAULT_SENDER=yourname@qq.com
# ---- 监听端口(容器内部)----
# 仅在 docker-compose.yaml 的 ports 段同步修改时使用;默认 5000
APP_PORT=5000
# ---- Gunicorn worker / thread 数 ----
# SQLite + WAL 推荐 workers=1 threads=8:单进程写最稳,线程吃满并发。
# 想更高吞吐可改 workers=2,但务必确认没有长事务(埋点已经够短)。
GUNICORN_WORKERS=1
GUNICORN_THREADS=8
+4
View File
@@ -0,0 +1,4 @@
FLASK_DEBUG=True # 开启调试模式
FLASK_RUN_PORT=5000 # 设置运行的端口
FLASK_RUN_HOST=0.0.0.0 # 设置监听的 ip
FLASK_APP="pear_admin:create_app()" # 运行程序
+4 -170
View File
@@ -1,171 +1,5 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# idea
.idea/
# 迁移文件 — 必须入库,让别人的环境也能跑 flask db upgrade
!migrations/
# sqlite
*.db
# 数据目录(SQLite 库 + 备份 + session;不入库,部署时靠挂载恢复)
data/
data/*/
# 文件上传
static/upload/
# flask session
flask_session/
# WorkBuddy 运行时目录(不应纳入版本管理)
.workbuddy/
# ---- Pear Admin Flask 额外忽略 ----
# 私有环境变量模板:保留 .env.example 入库;本地 .env 不入
.env
.env.*
!.env.example
# 系统监控日志(如果产生)
*.pid
*.sock
# 运行日志
logs/
*.log
# pycharm / vscode
.vscode/
# 临时下载/构建产物
tmp/
build/
# 一次性数据备份 / 临时调试产物
pear.db.*.bak
nav_extracted.js
dedup_nav.py
# 测试截图与探针截图(不入库,回归测试现场本地保留即可)
scripts/_verify_*.png
scripts/screenshots/
output/
# 前台卡片图标本地缓存(首次拉取后自动生成,无需入库)
data/icon/
.venv/
*.py[cod]
instance/
migrations/
+28
View File
@@ -0,0 +1,28 @@
repos:
- repo: https://github.com/python/black
rev: 23.9.1
hooks:
- id: black
language_version: python3.10
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
args: [ "--profile", "black", "--filter-files" ]
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v3.0.3"
hooks:
- id: prettier
exclude: >
(?x)^(
.*.yaml|
package-lock.json|
yarn.lock|
^.+\.min\.(js|css)$
)$
stages: [ commit ]
- repo: https://github.com/commitizen-tools/commitizen
rev: 3.5.3
hooks:
- id: commitizen
stages: [ commit-msg ]
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2021 mkg
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-69
View File
@@ -1,69 +0,0 @@
# ============================================================
# Pear Admin Flask - Makefile(跨平台,Windows 下需用 Git Bash / WSL
#
# 用法(在项目根目录):
# make help 查看所有命令
# make venv 创建虚拟环境
# make install 安装依赖
# make migrate 执行 db migrate + upgrade
# make seed 导入 admin 基础数据(app_data/init.json
# make init 等价于 migrate + seed
# make run 开发模式启动(PEAR_ENV=dev
# make run-prod 生产模式启动(PEAR_ENV=production,要求 .env SECRET_KEY
# make clean 清理缓存与数据库(危险!请先备份)
# make health curl /healthz
# ============================================================
PY ?= python
VENV ?= venv
PYBIN := $(VENV)/bin/python
PIP := $(PYBIN) -m pip
FLASK := $(PYBIN) -m flask
APP := app.py
.PHONY: help venv install migrate seed init run run-prod clean health
help:
@echo "可用命令:"
@echo " make venv 创建虚拟环境"
@echo " make install 安装依赖"
@echo " make migrate db migrate + upgrade"
@echo " make seed flask admin init"
@echo " make init migrate + seed"
@echo " make run 开发模式启动(默认端口 5000)"
@echo " make run-prod 生产模式启动(强制要求 .env SECRET_KEY"
@echo " make health curl http://127.0.0.1:5000/healthz"
@echo " make clean 清理 pyc / __pycache__ / pear.db / flask_session"
venv:
@test -d "$(VENV)" || $(PY) -m venv $(VENV)
@echo "[venv] 已创建 $(VENV)"
install: venv
$(PIP) install --upgrade pip
$(PIP) install -r requirements.txt
migrate:
$(FLASK) db migrate
$(FLASK) db upgrade
seed:
$(FLASK) admin init
init: migrate seed
run:
PEAR_ENV=dev $(PYBIN) $(APP)
run-prod:
PEAR_ENV=production $(PYBIN) $(APP)
health:
@curl -sS http://127.0.0.1:5000/healthz | python -m json.tool
clean:
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete
rm -rf flask_session
@echo "[clean] 已清理缓存。注意:pear.db 请手动备份后删除。"
+28 -209
View File
@@ -1,236 +1,55 @@
<div align="center">
<br/>
<br/>
<h1 align="center">
Pear Admin Flask
</h1>
<h4 align="center">
开 箱 即 用 的 Flask 快 速 开 发 平 台
</h4>
## 本地启动
[预览](https://pear.lovepikachu.top/) | [官网](http://www.pearadmin.com/) | [群聊](docs/source/_static/qqgroup.jpg) | [文档](https://lab.lovepikachu.top/document/pear-admin-flask)
环境要求:python 3.10 以上,安装了 poetry
<p align="center">
<a href="#">
<img src="https://img.shields.io/badge/pear%20admin%20flask-2.0.0-green" alt="Pear Admin Layui Version">
</a>
<a href="#">
<img src="https://img.shields.io/badge/Python-3.8+-green.svg" alt="Python Version">
</a>
<a href="#">
<img src="https://img.shields.io/badge/Mysql-5.3.2+-green.svg" alt="Mysql Version">
</a>
</p>
</div>
初始化环境
<div align="center">
<img width="92%" style="border-radius:10px;margin-top:20px;margin-bottom:20px;box-shadow: 2px 0 6px gray;" src="docs/source/_static/feature.png" />
</div>
# 项目简介
> **⚠️注意** Pear Admin Flask 已同步主项目 Pear Admin Layui 4.0,部分页面变化较大,如果是从旧的框架迁移,请查阅 [从旧项目迁移](https://lab.lovepikachu.top/document/pear-admin-flask/welcome/migration.html) 章节。另外,可以在此查看 [更新日志](https://lab.lovepikachu.top/document/pear-admin-flask/welcome/update.html#v2-0-0-4-0-5) 。
Pear Admin Flask 基于 Flask 的后台管理系统,拥抱应用广泛的python语言,通过使用本系统,即可快速构建你的功能业务
项目旨在为 python 开发者提供一个后台管理系统的模板,可以快速构建信息管理系统。
项目使用 flask-sqlalchemy + 权限验证 + marshmallow 序列化与数据验证,以此方式集成了若干不同的功能。
# 内置功能
- [x] 用户管理:用户是系统操作者,该功能主要完成系统用户配置。
- [x] 权限管理:配置系统菜单,操作权限,按钮权限标识等。
- [x] 角色管理:角色菜单权限分配。
- [x] 操作日志:系统正常操作日志记录和查询;系统异常信息日志记录和查询。
- [x] 登录日志:系统登录日志记录查询包含登录异常。
- [x] 服务监控:监视当前系统CPU、内存、磁盘、python版本,运行时长等相关信息。
- [x] 文件上传: 图片上传示例
# 项目分支说明
> **⚠️注意** Pear Admin Flask 不仅仅只提供一种对于 Pear Admin 后端的实现方式,所以提供了不同的分支版本,不同分支版本各有其优劣,并且由不同的开发者维护。
| 分支名称 | 特点 |
|------------------------------------------------------------------|------------------------|
| master(您目前浏览的分支版本) | 功能齐全,处于开发阶段,代码量较大。 |
| [main](https://gitee.com/pear-admin/pear-admin-flask/tree/main/) | 功能精简,代码量小,处于开发阶段,易于维护。 |
| [mini](https://gitee.com/pear-admin/pear-admin-flask/tree/mini/) | 不再更新,是最初版本的镜像。 |
> **⚠️注意** 由于 master 分支项目需要,暂时移除了 Flask-APScheduler 定时任务 功能。
# 版本支持情况
经过测试,此项目的(master分支)运行要求是 `>= Python 3.8` ,推荐使用 `Python 3.11`
> **💡提示** 由于 Flask 中使用的 Werkzeug 模块更新,Flask 官方并未进行更新,所以可能会出现 ImportError 。
> 此类情况的出现可以通过正确安装 `requirements.txt` 中的模块(以及其对应版本)解决。
# 项目结构
## 应用结构
```应用结构
Pear Admin Flask (master)
├─applications # 项目核心模块
│ ├─common # 公共模块(初始化数据库、公用函数)
│ ├─extensions # 注册项目插件
│ ├─schemas # 序列化模型
│ ├─models # 数据库模型
│ ├─views # 视图部分
│ ├─config.py # 项目配置
│ └─__init__.py # 项目初始化入口
├─docs # 文档说明
├─static # 静态资源文件
├─templates # 静态模板文件
└─app.py # 程序入口
```shell
poetry install
```
## 资源结构
初始化数据库
```资源结构
Pear Admin Flask
├─static # 项目设定的 Flask 资源文件夹
│ ├─admin # pear admin flask 的后端资源文件(与 pear admin layui 同步)
│ ├─index # pear admin flask 的前端资源文件
│ └─upload # 用户上传保存目录
└─templates # 项目设定的 Flask 模板文件夹
├─admin # pear admin flask 的后端管理页面模板
│ ├─admin_log # 日志页面
│ ├─common # 基本模板页面(头部模板与页脚模板)
│ ├─console # 系统监控页面模板
│ ├─dept # 部门管理页面模板
│ ├─dict # 数据字典页面模板
│ ├─mail # 邮件管理页面模板
│ ├─photo # 图片上传页面模板
│ ├─power # 权限(菜单)管理页面模板
│ ├─role # 角色管理页面模板
│ ├─task # 任务设置页面模板
│ └─user # 用户管理页面模板
├─errors # 错误页面模板
└─index # 主页模板
```shell
flask init
```
# 项目安装
启动
## 从仓库获取
```bash
# 克隆仓库 / 手动下载
git clone https://gitee.com/pear-admin/pear-admin-flask
cd pear-admin-flask # 进入到项目目录
```shell
flask run
```
## 修改配置
## 贡献指南
> **💡提示** 配置文件位于 `applications/config.py` ,打开配置文件看到的是位于 `BaseConfig` 类下的默认配置文件,您可以编写自己的配置类并继承 `BaseConfig` 类
项目启动时,会调用 `applications/__init__.py` ,这个文件中加载了程序的配置,所以在您编写了自己的类后不要忘记在文件 `applications/__init__.py` 中修改使用的配置类。
如果想参与项目的贡献,提交代码之前需要启用 pre-commit、commitizen 对代码进行校验,运行以下指令即可
> **⚠️注意** 配置文件中对于数据库的配置有所更改,请查看代码中的注释修改配置。
初始化 pre-commit
```python
# 部分配置信息如下所示
# 验证密钥(⚠️ 一定要记得修改 ⚠️)
SECRET_KEY = "pear-system-flask"
# 数据库的配置信息
SQLALCHEMY_DATABASE_URI = 'sqlite:///../pear.db'
# 默认日志等级
LOG_LEVEL = logging.WARN
# flask-mail配置
MAIL_SERVER = 'smtp.qq.com'
MAIL_USE_TLS = False
MAIL_USE_SSL = True
MAIL_PORT = 465
MAIL_USERNAME = '123@qq.com'
MAIL_PASSWORD = 'XXXXX' # 生成的授权码
MAIL_DEFAULT_SENDER = MAIL_USERNAME
```shell
pre-commit install
```
检查代码是否符合规范
## 虚拟环境安装项目(推荐)
> **💡提示** 为了保证项目所依赖的库不影响其他部署在同一主机上的项目,我们推荐使用虚拟环境安装。
```bash
python -m venv venv
# 进入虚拟环境下
venv\Scripts\activate.bat # Windows 提示命令符
venv\Scripts\Activate.ps1 # Windows Powershell
source venv/bin/activate # Linux
# 使用 pip 安装
pip install -r requirements.txt
```shell
git add .
```
## 直接安装项目
```bash
# 使用 pip 安装
pip install -r requirements.txt
# 同时你可以选择以模块的方式调用 pip
python -m pip install -r requirements.txt
```shell
pre-commit run --all-files
```
# 运行项目
初始化 commitizen
+ 一般情况运行项目
```bash
# 初始化数据库
flask db init
flask db migrate
flask db upgrade
flask admin init
# 运行项目
flask --app app.py run -h 0.0.0.0 -p 8000 --debug
# 或者直接调用 app.py
python app.py
```shell
pre-commit install -t commit-msg
```
+ 使用 docker-compose 运行项目
使用
```bash
git clone https://gitee.com/pear-admin/pear-admin-flask
# 安装 docker-compose
curl -L https://github.com/docker/compose/releases/download/1.26.2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose
# 运行如下命令,有输出版本,表示 docker-compose 可以用了
docker-compose --version
# 在当前目录执行如下命令即可以运行 app
docker-compose -f dockercompose.yaml up
# 看到如下表示运行成功,由于 pip 下载慢,需要一些时间,请耐心等待;如果安装失败,重新执行上面的命令即可。
# 运行后在浏览器访问 127.0.0.1:5000
#如果要停止容器运行,在当前文件夹执行如下命令:
docker-compose -f dockercompose.yaml down
```shell
cz commit
```
# 预览项目
| | |
| ---------------------- | ---------------------- |
| ![](docs/source/_static/1.jpg) | ![](docs/source/_static/2.jpg) |
| ![](docs/source/_static/3.jpg) | ![](docs/source/_static/6.jpg) |
# 其他说明
## 项目初始用户以及其密码
默认用户为 `admin` ,密码默认为 `123456`
代替 `git commit` 进行提交
-14
View File
@@ -1,14 +0,0 @@
from applications import create_app
app = create_app()
if __name__ == '__main__':
# 绑定 0.0.0.0 后,同局域网手机可通过本机 IP 访问(例如 http://192.168.1.10:5000/site/
# debug 标志交给配置类决定:PEAR_ENV=dev → DEBUG=TruePEAR_ENV=production → DEBUG=False
# 防止重载进程抢占端口;局域网访问保持稳定
app.run(
host='0.0.0.0',
port=5000,
debug=app.config.get('DEBUG', False),
use_reloader=False,
)
-55
View File
@@ -1,55 +0,0 @@
import os
from dotenv import load_dotenv
from flask import Flask
# 项目根目录 = 本文件父目录的父目录
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(幂等;override=False 保证环境变量优先级更高)
load_dotenv(os.path.join(BASE_DIR, ".env"), override=False)
app = Flask(BASE_DIR)
# 2) 根据 PEAR_ENV / FLASK_ENV 选择配置类,默认 dev
env_name = os.environ.get("PEAR_ENV") or os.environ.get("FLASK_ENV") or "dev"
config_cls = get_config_by_name(env_name)
app.config.from_object(config_cls)
# 3) 把 BaseConfig 默认值里没写、但 .env 可能写了的 SQLALCHEMY_DATABASE_URI 等再覆盖一次
if os.environ.get("SQLALCHEMY_DATABASE_URI"):
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ["SQLALCHEMY_DATABASE_URI"]
# 3.5) 让配置类有机会做运行时校验(例如 ProConfig 强制要求 SECRET_KEY
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)
init_script(app)
# 5) 全站访问埋点已迁移到 plugins/siteStats@app.before_request → record_visit
return app
View File
-96
View File
@@ -1,96 +0,0 @@
from io import BytesIO
from flask import make_response
from flask_login import current_user
from applications.common.utils.validate import str_escape
from applications.common.utils.captcha import vieCode
from applications.extensions import db
from applications.models import AdminLog
def get_captcha():
"""
生成验证码图片及其对应的验证码字符串。
:return: 返回验证码图片的响应对象和验证码字符串。
"""
image, code = vieCode().GetCodeImage()
code = ''.join(code).lower()
out = BytesIO()
image.save(out, 'png')
out.seek(0)
resp = make_response(out.read())
resp.content_type = 'image/png'
return resp, code
def normal_log(method, url, ip, user_agent, desc, uid, is_access):
"""
记录通用日志信息到数据库。
:param method: 请求方法(如 GET、POST)。
:param url: 请求的 URL。
:param ip: 客户端的 IP 地址。
:param user_agent: 客户端的 User-Agent 信息。
:param desc: 日志描述信息。
:param uid: 用户 ID。
:param is_access: 是否成功访问(True 或 False)。
:return: 返回日志记录的 ID。
"""
info = {
'method': method,
'url': url,
'ip': ip,
'user_agent': user_agent,
'desc': desc,
'uid': uid,
'success': int(is_access)
}
log = AdminLog(
url=info.get('url'),
ip=info.get('ip'),
user_agent=info.get('user_agent'),
desc=info.get('desc'),
uid=info.get('uid'),
method=info.get('method'),
success=info.get('success')
)
db.session.add(log)
db.session.commit()
return log.id
def login_log(request, uid, is_access):
"""
记录用户登录日志。
:param request: Flask 请求对象。
:param uid: 用户 ID。
:param is_access: 是否成功登录(True 或 False)。
:return: 返回日志记录的 ID。
"""
method = request.method
url = request.path
ip = request.remote_addr
user_agent = str_escape(request.headers.get('User-Agent'))
desc = str_escape(request.form.get('username'))
return normal_log(method, url, ip, user_agent, desc, uid, is_access)
def admin_log(request, is_access, desc=None):
"""
记录管理员操作日志。
:param request: Flask 请求对象。
:param is_access: 是否成功操作(True 或 False)。
:param desc: 日志描述信息(可选)。如果未提供,则从请求数据中提取。
:return: 返回日志记录的 ID。
"""
method = request.method
url = request.path
ip = request.remote_addr
user_agent = str_escape(request.headers.get('User-Agent'))
request_data = request.json if request.headers.get('Content-Type') == 'application/json' else request.values
if desc is None:
desc = str_escape(str(dict(request_data)))
return normal_log(method, url, ip, user_agent, desc, current_user.id, is_access)
-119
View File
@@ -1,119 +0,0 @@
import datetime
from marshmallow import Schema
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema
from applications.extensions import db, ma
class LogicalDeleteMixin(object):
"""
逻辑删除混入类,为模型提供软删除功能。
示例:
class Test(db.Model, LogicalDeleteMixin):
__tablename__ = 'admin_test'
id = db.Column(db.Integer, primary_key=True, comment='角色ID')
# 软删除
Test.query.filter_by(id=1).soft_delete()
# 查询所有未删除的记录
Test.query.logic_all()
"""
create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_at = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
delete_at = db.Column(db.DateTime, comment='删除时间')
def auto_model_jsonify(data, model: db.Model):
"""
自动序列化模型数据为 JSON 格式,无需手动定义 Schema。
示例:
power_data = curd.auto_model_jsonify(model=Dept, data=dept)
:param data: 需要序列化的 SQLAlchemy 查询结果。
:param model: SQLAlchemy 模型类。
:return: 返回序列化后的 JSON 数据。
"""
def get_model():
return model
class AutoSchema(SQLAlchemyAutoSchema):
class Meta(Schema):
model = get_model()
include_fk = True # 包含外键
include_relationships = True # 包含关联关系
load_instance = True # 反序列化时加载为模型实例
common_schema = AutoSchema(many=True) # 支持序列化多个对象
output = common_schema.dump(data)
return output
def model_to_dicts(schema: ma.Schema, data):
"""
使用指定的 Schema 序列化 SQLAlchemy 查询结果。
:param schema: Marshmallow Schema 类。
:param data: SQLAlchemy 查询结果。
:return: 返回序列化后的数据,返回字典。
"""
common_schema = schema(many=True) # 支持序列化多个对象
output = common_schema.dump(data)
return output
def get_one_by_id(model: db.Model, id):
"""
根据 ID 查询单个记录。
:param model: SQLAlchemy 模型类。
:param id: 记录的主键 ID。
:return: 返回查询到的记录,如果未找到则返回 None。
"""
return model.query.filter_by(id=id).first()
def delete_one_by_id(model: db.Model, id):
"""
根据 ID 删除单个记录。
:param model: SQLAlchemy 模型类。
:param id: 记录的主键 ID。
:return: 返回删除操作影响的行数。
"""
r = model.query.filter_by(id=id).delete()
db.session.commit()
return r
def enable_status(model: db.Model, id):
"""
启用指定 ID 的记录。
:param model: SQLAlchemy 模型类。
:param id: 记录的主键 ID。
:return: 如果操作成功返回 True,否则返回 False。
"""
enable = 1
role = model.query.filter_by(id=id).update({"enable": enable})
if role:
db.session.commit()
return True
return False
def disable_status(model: db.Model, id):
"""
停用指定 ID 的记录。
:param model: SQLAlchemy 模型类。
:param id: 记录的主键 ID。
:return: 如果操作成功返回 True,否则返回 False。
"""
enable = 0
role = model.query.filter_by(id=id).update({"enable": enable})
if role:
db.session.commit()
return True
return False
-173
View File
@@ -1,173 +0,0 @@
from sqlalchemy import and_, func
from applications.extensions import db
class ModelFilter:
"""
ORM 多条件查询构造器,支持多种查询条件组合,自动转义特殊字符防止SQL注入。
示例:
mf = ModelFilter()
mf.exact('name', 'John')
mf.vague('email', 'example.com')
query = User.query.filter(mf.get_filter(User))
"""
filter_field = {} # 存储字段过滤条件
filter_list = [] # 存储最终的过滤条件列表
# 查询类型常量
type_exact = "exact" # 精确匹配
type_neq = "neq" # 不等于
type_greater = "greater" # 大于
type_less = "less" # 小于
type_vague = "vague" # 模糊匹配
type_contains = "contains" # 包含
type_between = "between" # 范围查询
def __init__(self):
"""初始化过滤条件存储字典和列表。"""
self.filter_field = {}
self.filter_list = []
@staticmethod
def escape_like(value: str, escape_char: str = '\\') -> str:
"""
转义LIKE查询中的特殊字符(%, _ 和转义字符本身)
:param value: 需要转义的原始字符串
:param escape_char: 转义字符(默认反斜杠)
:return: 转义后的安全字符串
"""
return (
value.replace(escape_char, escape_char * 2)
.replace('%', escape_char + '%')
.replace('_', escape_char + '_')
)
def exact(self, field_name, value):
"""
添加精确匹配条件(自动处理字符串类型参数)
:param field_name: 模型字段名称
:param value: 匹配的值(自动过滤空字符串)
"""
if value is not None and value != '':
# 字符串类型自动调用escape_like(防止特殊字符影响精确匹配)
processed_value = self.escape_like(str(value)) if isinstance(value, str) else value
self.filter_field[field_name] = {"data": processed_value, "type": self.type_exact}
def neq(self, field_name, value):
"""
添加不等于条件(自动处理字符串类型参数)
:param field_name: 模型字段名称
:param value: 不匹配的值(自动过滤空字符串)
"""
if value is not None and value != '':
# 字符串类型自动调用escape_like
processed_value = self.escape_like(str(value)) if isinstance(value, str) else value
self.filter_field[field_name] = {"data": processed_value, "type": self.type_neq}
def greater(self, field_name, value):
"""
添加大于条件(数值/日期比较)
:param field_name: 模型字段名称
:param value: 比较的数值/日期
"""
if value is not None and value != '':
self.filter_field[field_name] = {"data": value, "type": self.type_greater}
def less(self, field_name, value):
"""
添加小于条件(数值/日期比较)
:param field_name: 模型字段名称
:param value: 比较的数值/日期
"""
if value is not None and value != '':
self.filter_field[field_name] = {"data": value, "type": self.type_less}
def vague(self, field_name, value: str):
"""
添加安全模糊匹配(自动转义特殊字符,左右加%
:param field_name: 模型字段名称
:param value: 需要模糊匹配的字符串(自动过滤空值)
"""
if value and value != '':
escaped_value = self.escape_like(value)
self.filter_field[field_name] = {"data": f'%{escaped_value}%', "type": self.type_vague}
def left_vague(self, field_name, value: str):
"""
添加安全左模糊匹配(自动转义特殊字符,左侧加%
:param field_name: 模型字段名称
:param value: 需要左模糊匹配的字符串
"""
if value and value != '':
escaped_value = self.escape_like(value)
self.filter_field[field_name] = {"data": f'%{escaped_value}', "type": self.type_vague}
def right_vague(self, field_name, value: str):
"""
添加安全右模糊匹配(自动转义特殊字符,右侧加%
:param field_name: 模型字段名称
:param value: 需要右模糊匹配的字符串
"""
if value and value != '':
escaped_value = self.escape_like(value)
self.filter_field[field_name] = {"data": f'{escaped_value}%', "type": self.type_vague}
def contains(self, field_name, value: str):
"""
添加安全包含条件(自动转义特殊字符,等效于vague)
:param field_name: 模型字段名称
:param value: 需要包含的字符串
"""
if value and value != '':
escaped_value = self.escape_like(value)
self.filter_field[field_name] = {"data": f'%{escaped_value}%', "type": self.type_contains}
def between(self, field_name, value1, value2):
"""
添加范围查询条件(自动过滤无效值)
:param field_name: 模型字段名称
:param value1: 范围起始值
:param value2: 范围结束值
"""
if all([v is not None and v != '' for v in [value1, value2]]):
self.filter_field[field_name] = {"data": [value1, value2], "type": self.type_between}
def get_filter(self, model: db.Model):
"""
生成安全的SQLAlchemy过滤条件
:param model: SQLAlchemy 模型类
:return: 组合后的过滤条件(使用and_连接)
"""
for k, v in self.filter_field.items():
field = getattr(model, k)
data = v.get("data")
query_type = v.get("type")
if query_type == self.type_vague:
self.filter_list.append(field.like(data, escape='\\'))
elif query_type == self.type_contains:
self.filter_list.append(field.like(data, escape='\\'))
elif query_type == self.type_exact:
self.filter_list.append(field == data)
elif query_type == self.type_neq:
self.filter_list.append(field != data)
elif query_type == self.type_greater:
self.filter_list.append(field > data)
elif query_type == self.type_less:
self.filter_list.append(field < data)
elif query_type == self.type_between:
self.filter_list.append(field.between(data[0], data[1]))
return and_(*self.filter_list)
-11
View File
@@ -1,11 +0,0 @@
from flask import Flask
from .admin import admin_cli
from .check import check_cli
from applications.extensions.init_plugins import broadcast_execute
def init_script(app: Flask):
app.cli.add_command(admin_cli)
app.cli.add_command(check_cli)
broadcast_execute(app, 'event_finish')
-256
View File
@@ -1,256 +0,0 @@
import click
import json
import datetime
import os
from flask.cli import AppGroup
from applications.extensions import db
from applications.models import User, Role, Dept, Power
# 1. 定义命令组
admin_cli = AppGroup("admin", help="系统数据管理:包括初始化、导入和导出")
# 2. 定义数据存储的文件夹名称
DATA_DIR = "app_data"
# --- 辅助工具类 ---
class DateEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, datetime.datetime):
return obj.strftime('%Y-%m-%d %H:%M:%S')
return json.JSONEncoder.default(self, obj)
def parse_time(item):
"""辅助函数:尝试将字典中的时间字符串转换为 datetime 对象"""
for k, v in item.items():
if k in ['create_at', 'create_time', 'update_at'] and isinstance(v, str):
try:
item[k] = datetime.datetime.strptime(v, '%Y-%m-%d %H:%M:%S')
except ValueError:
pass
return item
def model_to_dict(obj):
"""将 SQLAlchemy 模型对象转换为字典"""
data = {}
for c in obj.__table__.columns:
val = getattr(obj, c.name)
data[c.name] = val
return data
def ensure_dir_exists():
"""
确保数据文件夹存在。
修改:路径基于当前脚本文件的位置,而不是执行命令的位置。
"""
# 获取当前脚本(admin_cli.py)所在的目录
current_script_dir = os.path.dirname(os.path.abspath(__file__))
# 拼接得到 app_data 的绝对路径
base_path = os.path.join(current_script_dir, DATA_DIR)
if not os.path.exists(base_path):
os.makedirs(base_path)
return base_path
# --- 核心逻辑:导入数据 ---
def import_logic(file_path, is_force):
"""具体的导入逻辑封装"""
if not os.path.exists(file_path):
click.echo(f"错误: 找不到文件 {file_path}")
return False
click.echo(f"正在读取文件: {file_path}")
with open(file_path, 'r', encoding='utf-8') as f:
raw_data = json.load(f)
if is_force:
click.echo("警告: 正在清空旧数据...")
try:
db.session.query(User).delete()
db.session.query(Role).delete()
db.session.query(Power).delete()
db.session.query(Dept).delete()
# 新增:清空 Nav 表
from applications.models import Nav
db.session.query(Nav).delete()
db.session.commit()
except Exception as e:
db.session.rollback()
click.echo(f"清空数据失败: {str(e)}")
return False
try:
# 1. 导入部门
count_dept = 0
for item in raw_data.get("depts", []):
item = parse_time(item)
if not Dept.query.get(item['id']):
db.session.add(Dept(**item))
count_dept += 1
db.session.commit()
# 2. 导入权限
count_power = 0
for item in raw_data.get("powers", []):
item = parse_time(item)
if not Power.query.get(item['id']):
db.session.add(Power(**item))
count_power += 1
db.session.commit()
# 3. 导入角色
count_role = 0
for item in raw_data.get("roles", []):
item = parse_time(item)
power_ids = item.pop('power_ids', [])
role = Role.query.get(item['id'])
if not role:
role = Role(**item)
db.session.add(role)
count_role += 1
if power_ids:
powers = Power.query.filter(Power.id.in_(power_ids)).all()
role.power = powers
db.session.commit()
# 4. 导入用户
count_user = 0
for item in raw_data.get("users", []):
item = parse_time(item)
role_ids = item.pop('role_ids', [])
user = User.query.get(item['id'])
if not user:
user = User(**item)
db.session.add(user)
count_user += 1
if role_ids:
roles = Role.query.filter(Role.id.in_(role_ids)).all()
user.role = roles
db.session.commit()
# 5. 导入公开导航(Nav 表)—— 幂等:按 id 或 url 去重,避免每次启动重复插入
from applications.models import Nav
count_nav = 0
existing_urls = {row[0] for row in db.session.query(Nav.url).all()}
for item in raw_data.get("navs", []):
item = parse_time(item)
# 已存在相同主键 id 则跳过
if item.get("id") and Nav.query.get(item["id"]):
continue
# 已存在相同 url 则跳过(典型场景:用户真实导航已覆盖种子数据,避免重复)
url = item.get("url")
if url and url in existing_urls:
continue
db.session.add(Nav(**item))
count_nav += 1
if url:
existing_urls.add(url)
db.session.commit()
click.echo(f"导入成功! 新增: 部门{count_dept}, 权限{count_power}, 角色{count_role}, 用户{count_user}, 导航{count_nav}")
return True
except Exception as e:
db.session.rollback()
click.echo(f"导入失败,已回滚。错误信息: {str(e)}")
return False
# --- 命令:init (系统初始化) ---
@admin_cli.command("init", help="系统初始化:读取 app_data/init.json 并导入数据库")
@click.option('--force', is_flag=True, help='是否强制清空旧数据再导入')
def init_db(force):
"""
系统初始化命令。
默认读取当前脚本同级目录下 app_data/init.json 文件。
"""
base_path = ensure_dir_exists()
target_file = os.path.join(base_path, 'init.json')
if not os.path.exists(target_file):
click.echo(f"错误:在 {base_path} 目录下未找到 init.json 文件。")
click.echo(f"请先将基础数据文件放入该位置,或运行 export 生成。")
return
import_logic(target_file, force)
# --- 命令:export (导出) ---
@admin_cli.command("export", help="导出数据:将数据库数据保存为 JSON 文件")
@click.option('--name', default=None, help='指定导出文件名,不带后缀。如果不填则使用时间戳。')
def export_data(name):
"""
导出数据到当前脚本同级目录下的 app_data 文件夹。
"""
base_path = ensure_dir_exists()
if name:
filename = f"{name}.json"
else:
now_str = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"data_{now_str}.json"
file_path = os.path.join(base_path, filename)
click.echo(f"准备导出数据到: {file_path} ...")
data = {
"depts": [],
"powers": [],
"roles": [],
"users": [],
"navs": []
}
# 1. 导出部门
data["depts"] = [model_to_dict(d) for d in Dept.query.order_by(Dept.id).all()]
# 2. 导出权限
data["powers"] = [model_to_dict(p) for p in Power.query.order_by(Power.id).all()]
# 3. 导出角色
roles = Role.query.order_by(Role.id).all()
for r in roles:
r_dict = model_to_dict(r)
r_dict['power_ids'] = [p.id for p in r.power]
data["roles"].append(r_dict)
# 4. 导出用户
users = User.query.order_by(User.id).all()
for u in users:
u_dict = model_to_dict(u)
u_dict['role_ids'] = [r.id for r in u.role]
data["users"].append(u_dict)
# 5. 导出公开导航
from applications.models import Nav
data["navs"] = [model_to_dict(n) for n in Nav.query.order_by(Nav.category, Nav.sort, Nav.id).all()]
try:
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, cls=DateEncoder, ensure_ascii=False, indent=4)
click.echo(f"导出成功!")
except Exception as e:
click.echo(f"导出失败: {str(e)}")
# --- 命令:import (指定文件导入) ---
@admin_cli.command("import", help="导入数据:从指定的 JSON 文件恢复数据")
@click.option('--file', required=True, help='要导入的文件名 (需包含路径或位于 app_data 下)')
@click.option('--force', is_flag=True, help='是否强制清空旧数据')
def import_data(file, force):
"""
从指定文件导入数据。
"""
# 如果用户只提供了文件名,没有路径,则假设在 app_data 目录下
if not os.path.dirname(file):
file_path = os.path.join(ensure_dir_exists(), file)
else:
file_path = file
import_logic(file_path, force)
File diff suppressed because it is too large Load Diff
-95
View File
@@ -1,95 +0,0 @@
import re
import inspect
from flask.cli import AppGroup
from flask import current_app
check_cli = AppGroup('check', help='Commands for checking application aspects.')
@check_cli.command('auth')
def check_auth():
"""检查路由视图函数的 @login_required 和 @authorize(...) 装饰器使用情况。"""
app = current_app
if not app:
print("错误:未在应用上下文中运行。请使用 'flask check auth' 命令。")
return
print("\n" + "="*120)
print("Flask 路由鉴权装饰器检查报告".center(120))
print("="*120)
# 调整列宽以适应更长的权限字符串
header_format = "{:<35} {:<35} {:<30} {:<20} {:<30}"
row_format = "{:<35} {:<35} {:<30} {:<20} {:<30}"
print(header_format.format("Endpoint", "URL Rule", "View Function", "@login_required", "@authorize(...)"))
print("-" * 120)
has_missing_auth = False # 标记是否有缺失鉴权的路由
with app.app_context():
for rule in app.url_map.iter_rules():
if rule.endpoint == 'static':
continue
view_func = app.view_functions.get(rule.endpoint)
if not view_func:
continue
# --- 获取源代码 ---
try:
# 使用 getsourcelines 可能更稳定,获取整个函数定义
source_lines = inspect.getsourcelines(view_func)[0]
source_code = "".join(source_lines)
except (OSError, TypeError):
# 如果无法获取源代码(如 C 扩展),则跳过
source_code = ""
# --- 检查 @login_required 装饰器 ---
# 匹配常见的形式,包括可能的模块前缀
has_login_required = bool(re.search(r"@.*login_required", source_code))
# --- 检查 @authorize 装饰器并提取权限 ---
authorize_permissions_list = []
# 改进的正则表达式:
# @ : 匹配 @ 符号
# (?:.*?\.)? : 非捕获组,匹配可能的模块名和点 (例如 auth.)
# authorize : 匹配 authorize 函数名
# \( : 匹配左圆括号
# ([^)]*) : 捕获组,匹配括号内的所有内容 (非右括号字符)
# \) : 匹配右圆括号
# 这个模式会找到 @authorize(...) 的整个调用
authorize_calls = re.findall(r"@.*?authorize\s*\(([^)]*)\)", source_code, re.DOTALL)
for call_args in authorize_calls:
# 在找到的参数字符串中,再次使用正则提取被引号包围的权限字符串
# 匹配单引号或双引号内的内容
permissions_found = re.findall(r"['\"]([^'\"]+)['\"]", call_args)
authorize_permissions_list.extend(permissions_found)
# 将找到的所有权限字符串用逗号连接
authorize_permissions = ", ".join(authorize_permissions_list) if authorize_permissions_list else "N/A"
# --- 判断是否可能缺失鉴权 ---
# 简单判断:如果既没有 login_required 也没有 authorize 权限,则标记
if not has_login_required and not authorize_permissions_list:
has_missing_auth = True
# --- 格式化输出 ---
login_status = "✅ Yes" if has_login_required else "❌ No"
auth_status = authorize_permissions if authorize_permissions != "N/A" else "❌ N/A"
# 格式化字符串长度
def truncate(s, length):
return s if len(s) <= length else s[:length-2] + ".."
endpoint_str = truncate(rule.endpoint, 34)
rule_str = truncate(str(rule), 34)
func_name_str = truncate(view_func.__name__, 29)
print(row_format.format(endpoint_str, rule_str, func_name_str, login_status, auth_status))
print("-" * 120)
if has_missing_auth:
print("\n⚠️ 注意:以上标记为 '❌ No''❌ N/A' 的路由可能缺少鉴权,请仔细检查!")
else:
print("\n✅ 所有路由似乎都应用了至少一种鉴权机制。")
print("="*120 + "\n")
-55
View File
@@ -1,55 +0,0 @@
import time
cache_dict = {}
def cache_set_internal(key, value, expired=5):
"""
程序内部实现的记录缓存,用于简单、体量不大的缓存记录,在程序结束后销毁。对于高速、体量大的环境请配置 Redis 等服务自行记录。
记录缓存,存储键值对,并记录当前时间作为缓存的时间戳。
:param key: 键
:param value: 值
:param expired: 过期时间(秒),默认5秒
"""
cache_dict[key] = {
'value': value,
'expired_time': time.time() + expired
}
def cache_get_internal(key):
"""
获取缓存,根据键从缓存中获取值,并检查是否过期。
:param key: 键
:return: 如果缓存存在且未过期,返回缓存的值;否则返回 None
"""
if key in cache_dict:
cache_item = cache_dict[key]
if time.time() < cache_item['expired_time']:
return cache_item['value']
else:
# 如果缓存已过期,删除该缓存
del cache_dict[key]
return None
def cache_auto_internal(key, call, expired=5):
"""
如果缓存存在直接返回缓存内容,缓存不存在或者过期执行 call 函数,并取得返回值记录并返回。
:param key: 键
:param call: 获取新值的地方
:param expired: 过期时间(秒),默认5秒
"""
data = cache_get_internal(key)
if data is not None:
return data
data = call()
cache_set_internal(key, data, expired)
return data
-135
View File
@@ -1,135 +0,0 @@
import random, math
from PIL import Image, ImageDraw, ImageFont, ImageFilter
class vieCode:
__fontSize = 20 # 字体大小
__width = 120 # 画布宽度
__heigth = 45 # 画布高度
__length = 4 # 验证码长度
__draw = None # 画布对象
__img = None # 图片对象
__code = None # 验证码字符
__str = None # 自定义验证码字符集
__inCurve = True # 是否绘制干扰曲线
__inNoise = True # 是否绘制干扰点
__type = 2 # 验证码类型:1-纯字母,2-数字字母混合
__fontPatn = 'applications/common/utils/fonts/captcha.ttf' # 字体路径
def GetCodeImage(self, size=80, length=4):
"""
生成验证码图片及其对应的验证码字符。
:param size: 验证码字体大小,默认为 80。
:param length: 验证码字符长度,默认为 4。
:return: 返回验证码图片对象和验证码字符。
"""
# 准备基础数据
self.__length = length
self.__fontSize = size
self.__width = self.__fontSize * self.__length
self.__heigth = int(self.__fontSize * 1.5)
# 生成验证码图片
self.__createCode()
self.__createImage()
self.__createNoise()
self.__printString()
self.__cerateFilter()
return self.__img, self.__code
def __cerateFilter(self):
"""
对验证码图片进行模糊处理,增加识别难度。
"""
self.__img = self.__img.filter(ImageFilter.BLUR)
filter = ImageFilter.ModeFilter(8)
self.__img = self.__img.filter(filter)
def __createCode(self):
"""
生成验证码字符。
"""
# 是否使用自定义字符集
if not self.__str:
# 源文本
number = "3456789"
srcLetter = "qwertyuipasdfghjkzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM"
srcUpper = srcLetter.upper()
if self.__type == 1:
self.__str = number
else:
self.__str = srcLetter + srcUpper + number
# 随机生成验证码字符
self.__code = random.sample(self.__str, self.__length)
def __createImage(self):
"""
创建画布并设置背景颜色。
"""
bgColor = (random.randint(200, 255), random.randint(200, 255), random.randint(200, 255))
self.__img = Image.new('RGB', (self.__width, self.__heigth), bgColor)
self.__draw = ImageDraw.Draw(self.__img)
def __createNoise(self):
"""
在验证码图片上绘制干扰点。
"""
if not self.__inNoise:
return
font = ImageFont.truetype(self.__fontPatn, int(self.__fontSize / 1.5))
for i in range(5):
# 干扰点颜色
noiseColor = (random.randint(150, 200), random.randint(150, 200), random.randint(150, 200))
putStr = random.sample(self.__str, 2)
for j in range(2):
# 绘制干扰点
size = (random.randint(-10, self.__width), random.randint(-10, self.__heigth))
self.__draw.text(size, putStr[j], font=font, fill=noiseColor)
def __createCurve(self):
"""
在验证码图片上绘制干扰曲线。
"""
if not self.__inCurve:
return
x = y = 0
# 计算曲线系数
a = random.uniform(1, self.__heigth / 2)
b = random.uniform(-self.__width / 4, self.__heigth / 4)
f = random.uniform(-self.__heigth / 4, self.__heigth / 4)
t = random.uniform(self.__heigth, self.__width * 2)
xend = random.randint(self.__width / 2, self.__width * 2)
w = (2 * math.pi) / t
# 绘制曲线
color = (random.randint(30, 150), random.randint(30, 150), random.randint(30, 150))
for x in range(xend):
if w != 0:
for k in range(int(self.__heigth / 10)):
y = a * math.sin(w * x + f) + b + self.__heigth / 2
i = int(self.__fontSize / 5)
while i > 0:
px = x + i
py = y + i + k
self.__draw.point((px, py), color)
i -= i
def __printString(self):
"""
在画布上打印验证码字符。
"""
font = ImageFont.truetype(self.__fontPatn, self.__fontSize)
x = 0
# 打印字符到画布
for i in range(self.__length):
# 设置字体随机颜色
color = (random.randint(30, 150), random.randint(30, 150), random.randint(30, 150))
# 计算坐标
x = random.uniform(self.__fontSize * i * 0.95, self.__fontSize * i * 1.1)
y = self.__fontSize * random.uniform(0.3, 0.5)
# 打印字符
self.__draw.text((x, y), self.__code[i], font=font, fill=color)
Binary file not shown.
-41
View File
@@ -1,41 +0,0 @@
from flask import jsonify, request
def success_api(msg: str = "成功"):
"""
返回成功的 API 响应。
:param msg: 成功消息内容,默认为 "成功"
:return: 返回 JSON 格式的响应,包含 `success` 和 `msg` 字段。
"""
return jsonify(success=True, msg=msg)
def fail_api(msg: str = "失败"):
"""
返回失败的 API 响应。
:param msg: 失败消息内容,默认为 "失败"
:return: 返回 JSON 格式的响应,包含 `success` 和 `msg` 字段。
"""
return jsonify(success=False, msg=msg)
def table_api(msg: str = "", count=0, data=None, limit=10):
"""
返回动态表格渲染所需的 API 响应。
:param msg: 响应消息内容,默认为空字符串。
:param count: 数据总数,默认为 0。
:param data: 表格数据,默认为 None。
:param limit: 每页数据条数,默认为 10。
:return: 返回 JSON 格式的响应,包含 `msg`、`code`、`data`、`count` 和 `limit` 字段。
"""
res = {
'msg': msg,
'code': 0,
'data': data,
'count': count,
'limit': min(request.args.get('limit', default=10, type=int), 90)
}
return jsonify(res)
-93
View File
@@ -1,93 +0,0 @@
"""
集成了对 Pear Admin Flask 二次开发的邮件操作模块,并提供了相应的示例。
"""
from flask import current_app
from flask_mail import Message
from applications.common.curd import model_to_dicts
from applications.common.helper import ModelFilter
from applications.extensions import db, flask_mail
from applications.models import Mail
from applications.schemas import MailOutSchema
def get_all(receiver=None, subject=None, content=None):
"""
获取邮件列表,支持根据接收者、主题和内容进行筛选。
返回的列表中的字典结构如下::
{
"content": "", # HTML 内容
"create_at": "2022-12-25T10:51:17", # 创建时间
"id": 17, # 邮件ID
"realname": "超级管理", # 创建者姓名
"receiver": "", # 接收者
"subject": "" # 邮件主题
}
:param receiver: 接收者邮箱地址,支持模糊查询。
:param subject: 邮件主题,支持模糊查询。
:param content: 邮件内容,支持模糊查询。
:return: 返回符合条件的邮件列表。
"""
# 构造查询条件
mf = ModelFilter()
if receiver:
mf.contains(field_name="receiver", value=receiver)
if subject:
mf.contains(field_name="subject", value=subject)
if content:
mf.exact(field_name="content", value=content)
# 查询邮件数据并分页
mail = Mail.query.filter(mf.get_filter(Mail)).layui_paginate()
return model_to_dicts(schema=MailOutSchema, data=mail.items)
def add(receiver, subject, content, user_id):
"""
发送一封邮件,并将发送记录保存到数据库。 **该方法被邮件发送的视图函数调用。**
:param receiver: 接收者邮箱地址,多个邮箱用英文分号隔开。
:param subject: 邮件主题。
:param content: 邮件内容(HTML 格式)。
:param user_id: 发送者用户ID,表示谁发送了这封邮件。
可以使用 `from flask_login import current_user; current_user.id` 获取当前登录用户的ID。
:return: 发送成功返回 True,失败报错。
"""
send_mail(subject=subject, recipients=receiver.split(";"), content=content)
# 保存邮件记录到数据库
mail = Mail(receiver=receiver, subject=subject, content=content, user_id=user_id)
db.session.add(mail)
db.session.commit()
return True
def delete(id):
"""
删除指定的邮件记录。
:param id: 邮件ID。
:return: 删除成功返回 True,失败返回 False。
"""
res = Mail.query.filter_by(id=id).delete()
if not res:
return False
db.session.commit()
return True
def send_mail(subject, recipients, content):
"""
发送邮件(不记录发送日志)。
注意:如果发送失败会抛出异常,请使用 try-except 进行捕获。
:param subject: 邮件主题。
:param recipients: 接收者邮箱地址,多个邮箱用英文分号隔开。
:param content: 邮件内容(HTML 格式)。
"""
message = Message(subject=subject, recipients=recipients, html=content)
flask_mail.send(message)
-44
View File
@@ -1,44 +0,0 @@
from functools import wraps
from flask import abort, request, jsonify, session, current_app
from flask_login import login_required, current_user
from applications.common.admin import admin_log
def authorize(power: str, log: bool = False):
"""
用户权限判断,用于判断目前会话用户是否拥有访问权限。
在模板中有与之对应的全局非修饰函数 authorize ,此函数定义位于 `applications/extensions/init_template_directives.py` 。
:param power: 权限标识
:type power: str
:param log: 是否记录日志, defaults to False
:type log: bool, optional
"""
def decorator(func):
@login_required
@wraps(func)
def wrapper(*args, **kwargs):
# 定义管理员的id为1
if current_user.username == current_app.config.get("SUPERADMIN"):
if log:
admin_log(request=request, is_access=True)
return func(*args, **kwargs)
if not power in session.get('permissions'):
if log:
admin_log(request=request, is_access=False)
if request.method == 'GET':
abort(403)
else:
return jsonify(success=False, msg="权限不足!")
if log:
admin_log(request=request, is_access=True)
return func(*args, **kwargs)
return wrapper
return decorator
-36
View File
@@ -1,36 +0,0 @@
import os
from flask import current_app
from sqlalchemy import desc
from applications.extensions import db
from applications.extensions.init_upload import photos
from applications.models import Photo
from applications.schemas import PhotoOutSchema
from applications.common.curd import model_to_dicts
def get_photo(page, limit):
photo = Photo.query.order_by(desc(Photo.create_time)).paginate(page=page, per_page=limit, error_out=False)
count = Photo.query.count()
data = model_to_dicts(schema=PhotoOutSchema, data=photo.items)
return data, count
def upload_one(photo, mime):
filename = photos.save(photo)
file_url = '/_uploads/photos/' + filename
# file_url = photos.url(filename)
upload_url = current_app.config.get("UPLOADED_PHOTOS_DEST")
size = os.path.getsize(upload_url + '/' + filename)
photo = Photo(name=filename, href=file_url, mime=mime, size=size)
db.session.add(photo)
db.session.commit()
return file_url
def delete_photo_by_id(_id):
photo_name = Photo.query.filter_by(id=_id).first().name
photo = Photo.query.filter_by(id=_id).delete()
db.session.commit()
upload_url = current_app.config.get("UPLOADED_PHOTOS_DEST")
os.remove(upload_url + '/' + photo_name)
return photo
-240
View File
@@ -1,240 +0,0 @@
# XSS 过滤
import validators
from markupsafe import escape
from validators import validator
def str_escape(s):
"""
对字符串进行 XSS 过滤,返回转义后的安全字符串。
:param s: 需要转义的字符串。
:return: 返回转义后的字符串,如果输入为空则返回 None。
"""
if not s:
return None
return str(escape(s))
def between(*args, **kwargs):
"""
验证数字是否介于最小值和最大值之间。
适用于整数、浮点数、小数和日期等类型。
:param value: 需要验证的数字。
:param min: 数字的最小值(可选)。
:param max: 数字的最大值(可选)。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> between(5, min=2)
True
>>> between(13.2, min=13, max=14)
True
>>> between(500, max=400)
ValidationFailure(func=between, args=...)
"""
return validators.between(*args, **kwargs)
def domain(*args, **kwargs):
"""
验证给定值是否为有效的域名。
:param value: 需要验证的域名字符串。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> domain('example.com')
True
>>> domain('example.com/')
ValidationFailure(func=domain, ...)
"""
return validators.domain(*args, **kwargs)
def email(*args, **kwargs):
"""
验证给定值是否为有效的电子邮件地址。
:param value: 需要验证的电子邮件地址。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> email('someone@example.com')
True
>>> email('bogus@@')
ValidationFailure(func=email, ...)
"""
return validators.email(*args, **kwargs)
def iban(*args, **kwargs):
"""
验证给定值是否为有效的 IBAN 代码。
:param value: 需要验证的 IBAN 代码。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> iban('DE29100500001061045672')
True
>>> iban('123456')
ValidationFailure(func=iban, ...)
"""
return validators.iban(*args, **kwargs)
def ipv4(*args, **kwargs):
"""
验证给定值是否为有效的 IPv4 地址。
:param value: 需要验证的 IPv4 地址。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> ipv4('123.0.0.7')
True
>>> ipv4('900.80.70.11')
ValidationFailure(func=ipv4, args={'value': '900.80.70.11'})
"""
return validators.ipv4(*args, **kwargs)
def ipv6(*args, **kwargs):
"""
验证给定值是否为有效的 IPv6 地址。
:param value: 需要验证的 IPv6 地址。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> ipv6('abcd:ef::42:1')
True
>>> ipv6('abc.0.0.1')
ValidationFailure(func=ipv6, args={'value': 'abc.0.0.1'})
"""
return validators.ipv6(*args, **kwargs)
def length(*args, **kwargs):
"""
验证给定字符串的长度是否在指定范围内。
:param value: 需要验证的字符串。
:param min: 字符串的最小长度(可选)。
:param max: 字符串的最大长度(可选)。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> length('something', min=2)
True
>>> length('something', min=9, max=9)
True
>>> length('something', max=5)
ValidationFailure(func=length, ...)
"""
return validators.length(*args, **kwargs)
def mac_address(*args, **kwargs):
"""
验证给定值是否为有效的 MAC 地址。
:param value: 需要验证的 MAC 地址。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> mac_address('01:23:45:67:ab:CD')
True
>>> mac_address('00:00:00:00:00')
ValidationFailure(func=mac_address, args={'value': '00:00:00:00:00'})
"""
return validators.mac_address(*args, **kwargs)
def slug(*args, **kwargs):
"""
验证给定值是否为有效的 Slug 格式。
有效的 Slug 只能包含字母数字字符、连字符和下划线。
:param value: 需要验证的字符串。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> slug('my.slug')
ValidationFailure(func=slug, args={'value': 'my.slug'})
>>> slug('my-slug-2134')
True
"""
return validators.slug(*args, **kwargs)
def url(*args, **kwargs):
"""
验证给定值是否为有效的 URL。
:param value: 需要验证的 URL。
:param public: 是否仅允许公共 URL(可选)。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> url('http://foobar.dk')
True
>>> url('http://10.0.0.1')
True
>>> url('http://foobar.d')
ValidationFailure(func=url, ...)
>>> url('http://10.0.0.1', public=True)
ValidationFailure(func=url, ...)
"""
return validators.url(*args, **kwargs)
def uuid(*args, **kwargs):
"""
验证给定值是否为有效的 UUID。
:param value: 需要验证的 UUID。
:return: 如果验证成功返回 True,否则返回 ValidationFailure。
示例:
>>> uuid('2bc1c94f-0deb-43e9-92a1-4775189ec9f8')
True
>>> uuid('2bc1c94f 0deb-43e9-92a1-4775189ec9f8')
ValidationFailure(func=uuid, ...)
"""
return validators.uuid(*args, **kwargs)
@validator
def even(value):
"""
验证给定值是否为偶数。
:param value: 需要验证的数字。
:return: 如果是偶数返回 True,否则返回 ValidationFailure。
示例:
>>> even(4)
True
>>> even(5)
ValidationFailure(func=even, args={'value': 5})
"""
return not (value % 2)
-178
View File
@@ -1,178 +0,0 @@
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.dbDocker 下由 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)
-34
View File
@@ -1,34 +0,0 @@
from flask import Flask
from .init_sqlalchemy import db, ma, init_databases
from .init_login import init_login_manager
from .init_template_directives import init_template_directives
from .init_error_views import init_error_views
from .init_mail import init_mail, mail as flask_mail
from .init_upload import init_upload
from .init_migrate import init_migrate
from .init_session import init_session
from .init_limit import init_limit
from .init_plugins import register_plugin, broadcast_execute
def init_plugs(app: Flask) -> None:
# 注册插件 (仅 import + 注册动作)
register_plugin(app)
broadcast_execute(app, 'event_begin')
# 注册 Flask 功能
init_login_manager(app)
init_databases(app)
init_mail(app)
init_upload(app)
init_migrate(app)
init_session(app)
init_limit(app)
# 系统蓝图相关(含 @template_global 等 Jinja 注入)
init_template_directives(app)
init_error_views(app)
# 注:event_init / event_finish / event_context 由具体的
# applications.view.init_bps / applications.common.script.init_script
# 触发,避免 init_plugs 内重复调用导致 Blueprint 注册冲突
@@ -1,25 +0,0 @@
from flask import render_template, jsonify
def init_error_views(app):
@app.errorhandler(403)
def page_not_found(e):
return render_template('errors/403.html'), 403
@app.errorhandler(404)
def page_not_found(e):
return render_template('errors/404.html'), 404
@app.errorhandler(405)
def page_not_found(e):
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def internal_server_error(e):
return render_template('errors/500.html'), 500
@app.errorhandler(429)
def ratelimit_exceeded(e):
return jsonify(
success=False, msg="请求频率超限,请稍后再试。"
)
-20
View File
@@ -1,20 +0,0 @@
from flask_limiter import Limiter
from flask_login import current_user
from flask import request, Flask
# 定义 key 函数:优先使用 current_user.id,否则用 IP
def get_user_identifier():
# 注意:current_user 可能是 AnonymousUserMixin(未登录)
if hasattr(current_user, 'id') and current_user.is_authenticated:
return str(current_user.id) # 用户 ID 作为 key
return request.remote_addr # 未登录用户则按 IP 限流
limiter = Limiter(
key_func=get_user_identifier,
# default_limits=["100 per hour"], # 可选全局默认
storage_uri="memory://" # 生产建议: "redis://localhost:6379"
)
def init_limit(app: Flask) -> None:
limiter.init_app(app)
-14
View File
@@ -1,14 +0,0 @@
from flask_login import LoginManager
def init_login_manager(app):
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'system.passport.login'
@login_manager.user_loader
def load_user(user_id):
from applications.models import User
user = User.query.get(int(user_id))
return user
-8
View File
@@ -1,8 +0,0 @@
from flask import Flask
from flask_mail import Mail
mail = Mail()
def init_mail(app: Flask):
mail.init_app(app)
-11
View File
@@ -1,11 +0,0 @@
from flask import Flask
from applications.extensions.init_sqlalchemy import db
from flask_migrate import Migrate
from applications.models import *
migrate = Migrate()
def init_migrate(app: Flask):
migrate.init_app(app, db)
-61
View File
@@ -1,61 +0,0 @@
import os
from flask import Flask
from flask import Blueprint
import json
import importlib
plugin_bp = Blueprint('plugin', __name__, url_prefix='/plugin')
PLUGIN_ENABLE_FOLDERS = []
PLUGIN_IMPORTLIB = []
def register_plugin(app: Flask):
"""
获取所有插件并加载
"""
global PLUGIN_ENABLE_FOLDERS
app.register_blueprint(plugin_bp)
# 载入插件过程
# plugin_folder 配置的是插件的文件夹名
PLUGIN_ENABLE_FOLDERS = app.config['PLUGIN_ENABLE_FOLDERS']
for plugin_folder in PLUGIN_ENABLE_FOLDERS:
plugin_info = {
'plugin_name': plugin_folder
}
try:
if os.path.exists("plugins/" + plugin_folder + "/__init__.json"):
with open("plugins/" + plugin_folder + "/__init__.json", "r", encoding='utf-8') as f:
plugin_info = json.loads(f.read())
# 将插件全部载入
PLUGIN_IMPORTLIB.append(importlib.import_module('plugins.' + plugin_folder))
print(f" * Plugin: Loaded plugin: {plugin_info['plugin_name']} .")
except BaseException as e:
info = f" * Plugin: Crash a error when loading {plugin_info['plugin_name'] if len(plugin_info) != 0 else 'plugin'} :" + "\n"
app.logger.error(info)
app.logger.exception(e)
def broadcast_execute(app: Flask, function_name):
for plugin in PLUGIN_IMPORTLIB:
try:
# 初始化完成事件
try:
getattr(plugin, function_name)(app)
except AttributeError: # 没有插件启用事件就不调用
pass
except BaseException as e:
app.logger.exception(e)
if function_name == 'event_finish':
with app.app_context():
broadcast_execute(app, 'event_context')
-7
View File
@@ -1,7 +0,0 @@
from flask_session import Session
sess = Session()
def init_session(app):
sess.init_app(app)
-143
View File
@@ -1,143 +0,0 @@
import datetime
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 (
URL, Email, Range, Length, Equal, Regexp,
Predicate, NoneOf, OneOf, ContainsOnly
)
URL.default_message = '无效的链接'
Email.default_message = '无效的邮箱地址'
Range.message_min = '不能小于{min}'
Range.message_max = '不能小于{max}'
Range.message_all = '不能超过{min}{max}这个范围'
Length.message_min = '长度不得小于{min}'
Length.message_max = '长度不得大于{max}'
Length.message_all = '长度不能超过{min}{max}这个范围'
Length.message_equal = '长度必须等于{equal}'
Equal.default_message = '必须等于{other}'
Regexp.default_message = '非法输入'
Predicate.default_message = '非法输入'
NoneOf.default_message = '非法输入'
OneOf.default_message = '无效的选择'
ContainsOnly.default_message = '一个或多个无效的选择'
fields.Field.default_error_messages = {
"required": "缺少必要数据",
"null": "数据不能为空",
"validator_failed": "非法数据",
}
fields.Str.default_error_messages = {
'invalid': "不是合法文本"
}
fields.Int.default_error_messages = {
"invalid": "不是合法整数"
}
fields.Number.default_error_messages = {
"invalid": "不是合法数字"
}
fields.Boolean.default_error_messages = {
"invalid": "不是合法布尔值"
}
class Query(BaseQuery):
def soft_delete(self):
return self.update({"delete_at": datetime.datetime.now()})
def logic_all(self):
return self.filter_by(delete_at=None).all()
def all_json(self, schema: Marshmallow().Schema):
return schema(many=True).dump(self.all())
def layui_paginate(self, page=None, limit=None):
if page is None:
page = request.args.get('page', type=int)
if limit is None:
limit = min(request.args.get('limit', default=10, type=int), 90)
return self.paginate(page=page,
per_page=limit,
error_out=False
)
def layui_paginate_json(self, schema, page=None, limit=None):
if page is None:
page = request.args.get('page', 1, type=int) # 添加默认值
if limit is None:
limit = request.args.get('limit', 10, type=int) # 添加默认值
_res = self.paginate(
page=page,
per_page=limit,
error_out=False
)
return schema(many=True).dump(_res.items), _res.total, _res.page, _res.per_page
def layui_paginate_db_json(self, page=None, limit=None):
if page is None:
page = request.args.get('page', 1, type=int) # 添加默认值
if limit is None:
limit = request.args.get('limit', 10, type=int) # 添加默认值
_res = self.paginate(
page=page,
per_page=limit,
error_out=False
)
# 获取查询的列名列表
column_names = [col["name"] for col in self.column_descriptions]
# 将元组转换为字典(支持单列或多列)
data = [
dict(zip(column_names, row))
for row in _res.items
]
return data, _res.total, _res.page, _res.per_page
db = SQLAlchemy(query_class=Query)
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:
db.engine.connect()
except Exception as e:
exit(f"数据库连接失败: {e}")
@@ -1,26 +0,0 @@
from flask import session, current_app
from flask_login import current_user
from flask_wtf.csrf import generate_csrf
def init_template_directives(app):
@app.template_global()
def authorize(power):
if current_user.username != current_app.config.get("SUPERADMIN"):
return bool(power in session.get('permissions'))
else:
return True
@app.template_global()
def csrf_input():
return f'<input type="hidden" name="csrf_token" value="{generate_csrf()}">'
@app.template_global()
def nav_categories():
"""前台左侧侧栏 / 移动端抽屉共用的分类列表(带 anchor 锚点)。
以函数形式提供而非 context_processor,后台模板不调用就不会查库。
"""
from applications.view.public.nav import get_nav_categories
return get_nav_categories()
-9
View File
@@ -1,9 +0,0 @@
from flask import Flask
from flask_uploads import configure_uploads
from flask_uploads import UploadSet, IMAGES
photos = UploadSet('photos', IMAGES)
def init_upload(app: Flask):
configure_uploads(app, photos)
-16
View File
@@ -1,16 +0,0 @@
from .admin_dept import Dept
from .admin_dict import DictType, DictData
from .admin_log import AdminLog
from .admin_photo import Photo
from .admin_power import Power
from .admin_role import Role
from .admin_role_power import role_power
from .admin_user import User
from .admin_user_role import user_role
from .admin_mail import Mail
from .admin_nav import Nav
from .admin_nav_category import NavCategory
from .admin_about import About
from .admin_friend import Friend
from .admin_stat import VisitLog, PageStat
from .admin_nav_click import NavClick
-62
View File
@@ -1,62 +0,0 @@
"""
「关于本站」内容管理。
单条记录表 (id=1)
- 用于站点"关于"页面:自我介绍、备案号、联系方式、捐赠等。
- 不限制多份:单条表单就能维护全部内容,幂等 upsert。
设计原则:
- 内容字段全可空(除 id),发布前可以临时空着。
- Markdown 字段长度无限制,存原始文本,渲染时由前端解析。
"""
import datetime
from applications.extensions import db
class About(db.Model):
__tablename__ = 'site_about'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='主键')
# 站点名称(公开页头部)
site_name = db.Column(db.String(120), default='', comment='站点名')
# 自我介绍 / 关于(Markdown 格式,前台渲染)
content_md = db.Column(db.Text, default='', comment='关于本站正文 (Markdown)')
# 备案信息
icp = db.Column(db.String(120), default='', comment='ICP 备案号')
# 联系信息(任选)
contact_email = db.Column(db.String(120), default='', comment='联系邮箱')
contact_qq = db.Column(db.String(32), default='', comment='联系 QQ')
contact_wechat = db.Column(db.String(120), default='', comment='微信号')
contact_telegram = db.Column(db.String(120), default='', comment='Telegram')
contact_github = db.Column(db.String(255), default='', comment='GitHub 链接')
# 捐赠 / 打赏 URL(一般挂二维码图片)
donate_url = db.Column(db.String(255), default='', comment='捐赠/打赏 URL 或二维码图片地址')
create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_at = db.Column(
db.DateTime,
default=datetime.datetime.now,
onupdate=datetime.datetime.now,
comment='更新时间',
)
def to_dict(self) -> dict:
return {
'id': self.id,
'site_name': self.site_name or '',
'content_md': self.content_md or '',
'icp': self.icp or '',
'contact_email': self.contact_email or '',
'contact_qq': self.contact_qq or '',
'contact_wechat': self.contact_wechat or '',
'contact_telegram': self.contact_telegram or '',
'contact_github': self.contact_github or '',
'donate_url': self.donate_url or '',
'create_at': self.create_at.strftime('%Y-%m-%d %H:%M:%S') if self.create_at else '',
'update_at': self.update_at.strftime('%Y-%m-%d %H:%M:%S') if self.update_at else '',
}
-18
View File
@@ -1,18 +0,0 @@
import datetime
from applications.extensions import db
class Dept(db.Model):
__tablename__ = 'admin_dept'
id = db.Column(db.Integer, primary_key=True, comment="部门ID")
parent_id = db.Column(db.Integer, comment="父级编号")
dept_name = db.Column(db.String(50), comment="部门名称")
sort = db.Column(db.Integer, comment="排序")
leader = db.Column(db.String(50), comment="负责人")
phone = db.Column(db.String(20), comment="联系方式")
email = db.Column(db.String(50), comment="邮箱")
status = db.Column(db.Integer, comment='状态(1开启,0关闭)')
remark = db.Column(db.Text, comment="备注")
address = db.Column(db.String(255), comment="详细地址")
create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_at = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
-26
View File
@@ -1,26 +0,0 @@
import datetime
from applications.extensions import db
class DictType(db.Model):
__tablename__ = 'admin_dict_type'
id = db.Column(db.Integer, primary_key=True)
type_name = db.Column(db.String(255), comment='字典类型名称')
type_code = db.Column(db.String(255), comment='字典类型标识')
description = db.Column(db.String(255), comment='字典类型描述')
enable = db.Column(db.Integer, comment='是否开启')
create_time = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_time = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
class DictData(db.Model):
__tablename__ = 'admin_dict_data'
id = db.Column(db.Integer, primary_key=True)
data_label = db.Column(db.String(255), comment='字典类型名称')
data_value = db.Column(db.String(255), comment='字典类型标识')
type_code = db.Column(db.String(255), comment='字典类型描述')
is_default = db.Column(db.Integer, comment='是否默认')
enable = db.Column(db.Integer, comment='是否开启')
remark = db.Column(db.String(255), comment='备注')
create_time = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_time = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
-56
View File
@@ -1,56 +0,0 @@
"""
「友情链接」管理。
- 后台:列表 + 新增 / 编辑 / 删除 / 启用 / 禁用(与 Nav 一致接口风格)
- 前台:公开只读
字段:
- title : 友链名称
- url : 友链 URL
- logo : 站点头像 / favicon(可空,自动用 url 的 favicon
- description : 简介
- category : 友链分组(默认「推荐友链」)
- sort : 同分类下排序
- enable : 1 启用,0 禁用
- is_external: 1 新窗口打开 / 0 当前窗口
"""
import datetime
from applications.extensions import db
class Friend(db.Model):
__tablename__ = 'site_friend'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='友情链接ID')
title = db.Column(db.String(100), nullable=False, comment='名称')
url = db.Column(db.String(255), nullable=False, comment='链接')
logo = db.Column(db.String(255), default='', comment='Logo / favicon')
description = db.Column(db.String(255), default='', comment='简介')
category = db.Column(db.String(50), default='推荐友链', comment='分组', index=True)
sort = db.Column(db.Integer, default=0, comment='排序')
enable = db.Column(db.Integer, default=1, comment='状态(1启用,0关闭)')
is_external = db.Column(db.Integer, default=1, comment='是否外链')
create_by = db.Column(db.String(50), default='admin', comment='创建者')
create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_at = db.Column(
db.DateTime,
default=datetime.datetime.now,
onupdate=datetime.datetime.now,
comment='更新时间',
)
def to_dict(self):
return {
'id': self.id,
'title': self.title,
'url': self.url,
'logo': self.logo or '',
'description': self.description or '',
'category': self.category or '推荐友链',
'sort': self.sort or 0,
'enable': self.enable,
'is_external': self.is_external,
'create_by': self.create_by,
'create_at': self.create_at.strftime('%Y-%m-%d %H:%M:%S') if self.create_at else '',
'update_at': self.update_at.strftime('%Y-%m-%d %H:%M:%S') if self.update_at else '',
}
-14
View File
@@ -1,14 +0,0 @@
import datetime
from applications.extensions import db
class AdminLog(db.Model):
__tablename__ = 'admin_admin_log'
id = db.Column(db.Integer, primary_key=True)
method = db.Column(db.String(10))
uid = db.Column(db.Integer)
url = db.Column(db.String(255))
desc = db.Column(db.Text)
ip = db.Column(db.String(255))
success = db.Column(db.Integer)
user_agent = db.Column(db.Text)
create_time = db.Column(db.DateTime, default=datetime.datetime.now)
-12
View File
@@ -1,12 +0,0 @@
import datetime
from applications.extensions import db
class Mail(db.Model):
__tablename__ = 'admin_mail'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='邮件编号')
receiver = db.Column(db.String(1024), comment='收件人邮箱')
subject = db.Column(db.String(128), comment='邮件主题')
content = db.Column(db.Text(), comment='邮件正文')
user_id = db.Column(db.Integer, comment='发送人id')
create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
-105
View File
@@ -1,105 +0,0 @@
"""
前台公开导航模型。
Nav 用于「公开访问的导航聚合页」,与 admin_power 表完全解耦:
- admin_power 控制后台管理菜单,需要登录
- public_nav 控制前台公开导航,无需登录
后续若要做前台用户(收藏、提交链接),只需加独立 User 模型,
不需要碰 RBAC。
"""
import datetime
from applications.extensions import db
class Nav(db.Model):
__tablename__ = 'public_nav'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='导航ID')
# 分类名(如"开发工具"/"AI"/"设计资源"),用于首页聚合展示
category = db.Column(db.String(50), nullable=False, comment='分类', index=True)
# 单条导航的标题
title = db.Column(db.String(100), nullable=False, comment='标题')
# 链接 URL(站内或外链)
url = db.Column(db.String(255), nullable=False, comment='链接')
# 简短描述(鼠标悬停时可见)
description = db.Column(db.String(255), default='', comment='简介')
# layui-icon 的 class 名,例如 "layui-icon-website"
icon = db.Column(db.String(100), default='layui-icon-link', comment='图标')
# 同分类下的显示顺序
sort = db.Column(db.Integer, default=0, comment='排序')
# 1=启用,0=禁用(禁用后前台不再展示,后台列表仍可见)
enable = db.Column(db.Integer, default=1, comment='状态(1启用,0关闭)')
# 1=外链(新窗口打开),0=站内(当前窗口)
is_external = db.Column(db.Integer, default=1, comment='是否外链')
# 创建者(后台管理员用户名,便于追溯),非空约束放宽以便匿名维护
create_by = db.Column(db.String(50), default='admin', comment='创建者')
create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_at = db.Column(
db.DateTime,
default=datetime.datetime.now,
onupdate=datetime.datetime.now,
comment='更新时间',
)
def to_dict(self):
"""方便模板直接读取"""
url = self.url or ''
# 提取域名 / 协议,用于加载 favicon 与生成确定性配色
domain = ''
scheme = 'https'
try:
from urllib.parse import urlparse
parsed = urlparse(url if '://' in url else 'http://' + url)
domain = parsed.netloc or parsed.path.split('/')[0]
if parsed.scheme:
scheme = parsed.scheme
except Exception:
domain = ''
# 取标题首个可见字符作为「无 favicon 时的兜底字母头像」
letter = ''
for ch in (self.title or ''):
if ch.strip():
letter = ch.upper()
break
if not letter:
letter = (domain[:1] or '·').upper()
# 按域名哈希得到稳定的渐变配色(同域名永远同色,跨域名尽量区分)
hue = 0
if domain:
s = 0
for ch in domain:
s = (s * 31 + ord(ch)) & 0xffffffff
hue = s % 360
color = 'linear-gradient(135deg, hsl(%d,62%%,56%%) 0%%, hsl(%d,72%%,46%%) 100%%)' % (
hue, (hue + 28) % 360)
return {
'id': self.id,
'category': self.category,
'title': self.title,
'url': self.url,
'description': self.description or '',
'icon': self.icon or 'layui-icon-link',
'letter': letter,
'color': color,
'domain': domain,
'scheme': scheme,
'sort': self.sort or 0,
'enable': self.enable,
'is_external': self.is_external,
'create_by': self.create_by,
'create_at': self.create_at.strftime('%Y-%m-%d %H:%M:%S') if self.create_at else '',
}
-54
View File
@@ -1,54 +0,0 @@
"""
导航分类表
Nav.category 是字符串字段,难以管理顺序/启用/图标。
NavCategory 把"分类"提到一张独立表,允许后台做完整的 CRUD。
数据关系:
- NavCategory 1 — N Nav
- 保留 Nav.category 字符串字段为冗余存储,避免前端热路径上做 LEFT JOIN
"""
import datetime
from applications.extensions import db
class NavCategory(db.Model):
__tablename__ = 'nav_category'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='分类ID')
# 同名分类不能重复,方便前台 URL /site/category/<name> 用作 slug
name = db.Column(db.String(50), nullable=False, unique=True, index=True, comment='分类名(前端分类详情 URL')
# 分类图标(前端首页标题旁小图标)
icon = db.Column(db.String(50), default='layui-icon-list', comment='layui-icon class')
# 分类简介(前端首页卡片标题下方描述)
description = db.Column(db.String(255), default='', comment='分类简介')
# 同级别的展示顺序(前端首页自上而下)
sort = db.Column(db.Integer, default=0, comment='排序')
# 1=启用,0=禁用(禁用后前台不再展示该分类,但 Nav 记录仍保留)
enable = db.Column(db.Integer, default=1, comment='状态(1启用,0关闭)')
create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_at = db.Column(
db.DateTime,
default=datetime.datetime.now,
onupdate=datetime.datetime.now,
comment='更新时间',
)
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'icon': self.icon or 'layui-icon-list',
'description': self.description or '',
'sort': self.sort or 0,
'enable': self.enable,
'create_at': self.create_at.strftime('%Y-%m-%d %H:%M:%S') if self.create_at else '',
'update_at': self.update_at.strftime('%Y-%m-%d %H:%M:%S') if self.update_at else '',
}
-23
View File
@@ -1,23 +0,0 @@
"""
导航卡片点击埋点模型。
每条记录是用户点了一次某张 Nav 卡片跳出去的事件。
- nav_id 关联 public_nav.id
- anon_id 匿名访客 ID(后端静默下发的 cookie,不关联账号)
- day YYYY-MM-DD 日期
- ip 访客 IP(用于去重 / 隐私留存期控制)
- clicked_at 点击时间
"""
import datetime
from applications.extensions import db
class NavClick(db.Model):
__tablename__ = 'site_nav_click'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='记录ID')
nav_id = db.Column(db.Integer, nullable=False, index=True, comment='被点击的导航ID')
anon_id = db.Column(db.String(36), default='', index=True, comment='匿名访客ID')
day = db.Column(db.String(10), default='', index=True, comment='日期 YYYY-MM-DD')
ip = db.Column(db.String(64), default='', comment='访客IP')
clicked_at = db.Column(db.DateTime, default=datetime.datetime.now, index=True, comment='点击时间')
-12
View File
@@ -1,12 +0,0 @@
import datetime
from applications.extensions import db
class Photo(db.Model):
__tablename__ = 'admin_photo'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False)
href = db.Column(db.String(255))
mime = db.Column(db.CHAR(50), nullable=False)
size = db.Column(db.CHAR(30), nullable=False)
create_time = db.Column(db.DateTime, default=datetime.datetime.now)
-18
View File
@@ -1,18 +0,0 @@
import datetime
from applications.extensions import db
class Power(db.Model):
__tablename__ = 'admin_power'
id = db.Column(db.Integer, primary_key=True, comment='权限编号')
name = db.Column(db.String(255), comment='权限名称')
type = db.Column(db.String(1), comment='权限类型')
code = db.Column(db.String(30), comment='权限标识')
url = db.Column(db.String(255), comment='权限路径')
open_type = db.Column(db.String(10), comment='打开方式')
parent_id = db.Column(db.Integer, comment='父类编号')
icon = db.Column(db.String(128), comment='图标')
sort = db.Column(db.Integer, comment='排序')
create_time = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_time = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
enable = db.Column(db.Integer, comment='是否开启')
-16
View File
@@ -1,16 +0,0 @@
import datetime
from applications.extensions import db
class Role(db.Model):
__tablename__ = 'admin_role'
id = db.Column(db.Integer, primary_key=True, comment='角色ID')
name = db.Column(db.String(255), comment='角色名称')
code = db.Column(db.String(255), comment='角色标识')
enable = db.Column(db.Integer, comment='是否启用')
remark = db.Column(db.String(255), comment='备注')
details = db.Column(db.String(255), comment='详情')
sort = db.Column(db.Integer, comment='排序')
create_time = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_time = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='更新时间')
power = db.relationship('Power', secondary="admin_role_power", backref=db.backref('role'))
-9
View File
@@ -1,9 +0,0 @@
from applications.extensions import db
# 创建中间表
role_power = db.Table(
"admin_role_power", # 中间表名称
db.Column("id", db.Integer, primary_key=True, autoincrement=True, comment='标识'), # 主键
db.Column("power_id", db.Integer, db.ForeignKey("admin_power.id"), comment='用户编号'), # 属性 外键
db.Column("role_id", db.Integer, db.ForeignKey("admin_role.id"), comment='角色编号'), # 属性 外键
)
-57
View File
@@ -1,57 +0,0 @@
"""
「访问统计」数据模型。
每条记录是一次访问事件:
- path 访问路径
- referer 来源页(可空)
- ua User-Agent 摘要(可空)
- ip 访客 IP(如果能拿到)
- day YYYY-MM-DD 日期(用于按天聚合)
- visit_at 访问时间
"""
import datetime
from applications.extensions import db
class VisitLog(db.Model):
__tablename__ = 'site_visit_log'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='记录ID')
path = db.Column(db.String(255), default='', index=True, comment='访问路径')
referer = db.Column(db.String(255), default='', comment='来源页')
ua = db.Column(db.String(255), default='', comment='UA')
ip = db.Column(db.String(64), default='', comment='访问IP')
day = db.Column(db.String(10), default='', index=True, comment='日期 YYYY-MM-DD')
visit_at = db.Column(db.DateTime, default=datetime.datetime.now, index=True, comment='访问时间')
class PageStat(db.Model):
"""
路径级累计统计:每次访问做 upsert(按 path+day)。
字段:
- path
- day
- pv 当日 / 累计 Page View
- uv IP 去重计数(用 set 维护在内存里,但用户规模小可以按 (path, day, ip) 简单去重)
- last_hit 最后一次访问时间
"""
__tablename__ = 'site_page_stat'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
path = db.Column(db.String(255), default='', index=True)
day = db.Column(db.String(10), default='', index=True)
pv = db.Column(db.Integer, default=0, comment='Page View')
uv = db.Column(db.Integer, default=0, comment='独立访客数(IP 去重后)')
uv_ip_set = db.Column(db.Text, default='', comment='独立 IP 列表 CSV')
last_hit = db.Column(db.DateTime, default=datetime.datetime.now, comment='最近访问时间')
def to_dict(self):
return {
'id': self.id,
'path': self.path,
'day': self.day,
'pv': self.pv or 0,
'uv': self.uv or 0,
'last_hit': self.last_hit.strftime('%Y-%m-%d %H:%M:%S') if self.last_hit else '',
}
-25
View File
@@ -1,25 +0,0 @@
import datetime
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from applications.extensions import db
class User(db.Model, UserMixin):
__tablename__ = 'admin_user'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='用户ID')
username = db.Column(db.String(20), comment='用户名')
realname = db.Column(db.String(20), comment='真实名字')
avatar = db.Column(db.String(255), comment='头像', default="/static/system/admin/images/avatar.jpg")
remark = db.Column(db.String(255), comment='备注')
password_hash = db.Column(db.String(128), comment='哈希密码')
enable = db.Column(db.Integer, default=0, comment='启用')
dept_id = db.Column(db.Integer, comment='部门id')
create_at = db.Column(db.DateTime, default=datetime.datetime.now, comment='创建时间')
update_at = db.Column(db.DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment='创建时间')
role = db.relationship('Role', secondary="admin_user_role", backref=db.backref('user'), lazy='dynamic')
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def validate_password(self, password):
return check_password_hash(self.password_hash, password)
-9
View File
@@ -1,9 +0,0 @@
from applications.extensions import db
# 创建中间表
user_role = db.Table(
"admin_user_role", # 中间表名称
db.Column("id", db.Integer, primary_key=True, autoincrement=True, comment='标识'), # 主键
db.Column("user_id", db.Integer, db.ForeignKey("admin_user.id"), comment='用户编号'), # 属性 外键
db.Column("role_id", db.Integer, db.ForeignKey("admin_role.id"), comment='角色编号'), # 属性 外键
)
-11
View File
@@ -1,11 +0,0 @@
from .admin_role import RoleOutSchema
from .admin_power import PowerOutSchema, PowerOutSchema2
from .admin_dict import DictDataOutSchema, DictTypeOutSchema
from .admin_dept import DeptSchema
from .admin_log import LogOutSchema
from .admin_photo import PhotoOutSchema
from .admin_mail import MailOutSchema
from .admin_nav import NavOutSchema, NavManageSchema
from .admin_nav_category import NavCategorySchema
from .admin_friend import FriendOutSchema, FriendManageSchema
-12
View File
@@ -1,12 +0,0 @@
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from applications.models import Dept
class DeptSchema(SQLAlchemyAutoSchema):
class Meta:
model = Dept # table = models.Album.__table__
# include_relationships = True # 输出模型对象时同时对外键,是否也一并进行处理
include_fk = True # 序列化阶段是否也一并返回主键
# fields= ["id","name"] # 启动的字段列表
# exclude = ["id","name"] # 排除字段列表
-21
View File
@@ -1,21 +0,0 @@
from applications.extensions import ma
from marshmallow import fields
class DictTypeOutSchema(ma.Schema):
id = fields.Str(attribute="id")
typeName = fields.Str(attribute="type_name")
typeCode = fields.Str(attribute="type_code")
description = fields.Str(attribute="description")
createTime = fields.Str(attribute="create_time")
updateName = fields.Str(attribute="update_time")
remark = fields.Str()
enable = fields.Str()
class DictDataOutSchema(ma.Schema):
dataId = fields.Str(attribute="id")
dataLabel = fields.Str(attribute="data_label")
dataValue = fields.Str(attribute="data_value")
remark = fields.Str()
enable = fields.Str()
-17
View File
@@ -1,17 +0,0 @@
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from applications.models import Friend
class FriendOutSchema(SQLAlchemyAutoSchema):
"""前台展示用:序列化 Friend 对象为 JSON"""
class Meta:
model = Friend
include_fk = True
class FriendManageSchema(SQLAlchemyAutoSchema):
"""后台管理用:序列化为表格数据 / 表单回填"""
class Meta:
model = Friend
include_fk = True
-14
View File
@@ -1,14 +0,0 @@
from applications.extensions import ma
from marshmallow import fields
class LogOutSchema(ma.Schema):
id = fields.Integer()
method = fields.Str()
uid = fields.Str()
url = fields.Str()
desc = fields.Str()
ip = fields.Str()
user_agent = fields.Str()
success = fields.Bool()
create_time = fields.DateTime()
-19
View File
@@ -1,19 +0,0 @@
from applications.extensions import ma
from marshmallow import fields
from applications.models import User
# 用户models的序列化类
class MailOutSchema(ma.Schema):
id = fields.Integer()
receiver = fields.Str()
subject = fields.Str()
content = fields.Str()
realname = fields.Method("get_realname")
create_at = fields.DateTime()
def get_realname(self, obj):
if obj.user_id != None:
return User.query.filter_by(id=obj.user_id).first().realname
else:
return None
-17
View File
@@ -1,17 +0,0 @@
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from applications.models import Nav
class NavOutSchema(SQLAlchemyAutoSchema):
"""前台展示用:序列化 Nav 对象为 JSON"""
class Meta:
model = Nav
include_fk = True
class NavManageSchema(SQLAlchemyAutoSchema):
"""后台管理用:同上,前缀分开便于将来按需差异化"""
class Meta:
model = Nav
include_fk = True
@@ -1,10 +0,0 @@
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from applications.models import NavCategory
class NavCategorySchema(SQLAlchemyAutoSchema):
"""导航分类序列化:用于后台 table_api 的 data 字段"""
class Meta:
model = NavCategory
include_fk = True
-12
View File
@@ -1,12 +0,0 @@
from applications.extensions import ma
from marshmallow import fields
class PhotoOutSchema(ma.Schema):
id = fields.Integer()
name = fields.Str()
href = fields.Str()
mime = fields.Str()
size = fields.Str()
ext = fields.Str()
create_time = fields.DateTime()
-44
View File
@@ -1,44 +0,0 @@
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from applications.extensions import ma
from marshmallow import fields
from applications.models import Power
class PowerSchema(SQLAlchemyAutoSchema):
class Meta:
model = Power # table = models.Album.__table__
# include_relationships = True # 输出模型对象时同时对外键,是否也一并进行处理
include_fk = True # 序列化阶段是否也一并返回主键
# fields= ["id","name"] # 启动的字段列表
# exclude = ["id","name"] # 排除字段列表
# 权限models序列化类
class PowerOutSchema(ma.Schema):
id = fields.Integer()
title = fields.Str(attribute="name")
type = fields.Str()
code = fields.Str()
href = fields.Str(attribute="url")
openType = fields.Str(attribute="open_type")
parent_id = fields.Integer()
icon = fields.Str()
sort = fields.Integer()
create_time = fields.DateTime()
update_time = fields.DateTime()
enable = fields.Integer()
class PowerOutSchema2(ma.Schema): # 序列化类
powerId = fields.Str(attribute="id")
powerName = fields.Str(attribute="name")
powerType = fields.Str(attribute="type")
powerUrl = fields.Str(attribute="url")
openType = fields.Str(attribute="open_type")
parentId = fields.Str(attribute="parent_id")
icon = fields.Str()
sort = fields.Integer()
create_time = fields.DateTime()
update_time = fields.DateTime()
enable = fields.Integer()
-12
View File
@@ -1,12 +0,0 @@
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from applications.models import Role
class RoleOutSchema(SQLAlchemyAutoSchema):
class Meta:
model = Role # table = models.Album.__table__
# include_relationships = True # 输出模型对象时同时对外键,是否也一并进行处理
include_fk = True # 序列化阶段是否也一并返回主键
# fields= ["id","name"] # 启动的字段列表
# exclude = ["id","name"] # 排除字段列表
-19
View File
@@ -1,19 +0,0 @@
from applications.view.system import register_system_bps
from applications.view.public import bp as public_bp, register_public_bp # public 包的入口就是 nav_bp
from applications.view.health import bp as health_bp
from applications.extensions.init_plugins import broadcast_execute
def init_bps(app):
# 健康检查端点(无需登录,应在负载均衡/容器探活里使用)
app.register_blueprint(health_bp)
# 前台公开蓝图(无需登录)
app.register_blueprint(public_bp)
register_public_bp(app)
# 后台系统蓝图(需要登录 + 权限码)
register_system_bps(app)
# 插件初始化函数
broadcast_execute(app, 'event_init')
-71
View File
@@ -1,71 +0,0 @@
"""
健康检查端点(无需登录)
- GET /healthz
用于探活/负载均衡/容器 readinessProbe。
返回:
{
"status": "ok" | "degraded",
"db_ok": true | false,
"env": "dev" | "production",
"system": "Pear Admin",
"timestamp": "2026-09-05T17:00:00+08:00"
}
HTTP 状态码:
200 - 全部正常
503 - DB 不可达(用于触发 k8s readinessProbe 失败)
"""
from datetime import datetime, timedelta, timezone
import os
from flask import Blueprint, current_app, jsonify
from sqlalchemy import text
from applications.extensions import db
bp = Blueprint("health", __name__)
def _beijing_now() -> str:
"""返回 +08:00 时区 ISO8601 时间字符串。"""
tz = timezone(timedelta(hours=8))
return datetime.now(tz).isoformat()
@bp.get("/healthz")
def healthz():
# 优先按 PEAR_ENV/FLASK_ENV 取环境名;兜底按 DEBUG 反推
env = (
os.environ.get("PEAR_ENV")
or os.environ.get("FLASK_ENV")
or current_app.config.get("PEAR_ENV")
or ("dev" if current_app.config.get("DEBUG") else "production")
)
system_name = current_app.config.get("SYSTEM_NAME", "Pear Admin")
# DB 探活:跑一个最轻量的 SELECT 1
db_ok = True
db_error = None
try:
db.session.execute(text("SELECT 1"))
except Exception as e: # noqa: BLE001 - 这里就是要把异常吃掉,转换成 False
db_ok = False
db_error = str(e)
finally:
try:
db.session.rollback()
except Exception:
pass
payload = {
"status": "ok" if db_ok else "degraded",
"db_ok": db_ok,
"env": env,
"system": system_name,
"timestamp": _beijing_now(),
}
if db_error:
payload["db_error"] = db_error
return jsonify(payload), (200 if db_ok else 503)
-22
View File
@@ -1,22 +0,0 @@
"""
前台公开蓝图:导航聚合页 / 分类详情页 / 关于本站
URL 前缀:/site(避免与后台 / 路由冲突)
鉴权要求:无(任何用户都可访问,含未登录匿名用户)
注意:不要给这些视图加 @authorize 装饰器,否则会强制登录_required。
"""
from applications.view.public.nav import bp as nav_bp
from applications.view.public.about import bp as about_bp
from applications.view.public.friend import bp as friend_bp
# 把 nav 蓝图作为 public 包的对外入口
bp = nav_bp
def register_public_bp(app):
"""注册 public 子蓝图"""
app.register_blueprint(about_bp)
app.register_blueprint(friend_bp)
# /healthz 已在 applications/view/health.py 注册(位于 applications/view/__init__.py),不在这里重复
-49
View File
@@ -1,49 +0,0 @@
"""
前台公开"关于本站"页面。
不需要登录,匿名可访问。Markdown 在模板里渲染。
"""
import re
import markdown as _markdown
from flask import Blueprint, render_template
from applications.models import About
bp = Blueprint('public_about', __name__, url_prefix='/site')
def _get_about() -> About:
"""复用 model 的种子逻辑,避免空表时炸"""
a = About.query.get(1)
if a is None:
from applications.extensions import db
a = About(id=1, site_name='旺珂 · 导航')
db.session.add(a)
db.session.commit()
return a
@bp.get('/about')
def about():
a = _get_about()
html = _render_md(a.content_md)
return render_template('public/about.html', about=a, content_html=html)
_URL_RE = re.compile(r'(https?://[^\s)<>"\\]+|[\w.+-]+@[\w-]+\.[\w.-]+)')
def _render_md(text: str) -> str:
"""Markdown → HTML;自动把裸链接/邮箱变可点;同时禁掉 script/iframe。"""
if not text or not text.strip():
return ''
html = _markdown.markdown(
text,
extensions=['fenced_code', 'tables', 'nl2br', 'sane_lists'],
output_format='html5',
)
# 防御:清掉 <script>/<iframe>
html = re.sub(r'<\s*script[^>]*>.*?<\s*/\s*script\s*>', '', html, flags=re.I | re.S)
html = re.sub(r'<\s*iframe[^>]*>.*?<\s*/\s*iframe\s*>', '', html, flags=re.I | re.S)
return html
-41
View File
@@ -1,41 +0,0 @@
"""
前台友情链接页视图(无需登录)。
URL
- GET /site/friend 按分组聚合展示所有启用友情链接
注:不是 /site/friends(避免与系统内部 hyperlink 对齐)
"""
from collections import OrderedDict
from flask import Blueprint, render_template
from applications.models import Friend
bp = Blueprint('public_friend', __name__, url_prefix='/site')
@bp.get('/friend')
def friend_index():
"""
公开友情链接页:按 category 分组聚合展示
"""
items = (
Friend.query
.filter(Friend.enable == 1)
.order_by(Friend.category.asc(), Friend.sort.asc(), Friend.id.asc())
.all()
)
groups = OrderedDict()
for item in items:
if not item.url:
continue
groups.setdefault(item.category or '推荐友链', []).append(item.to_dict())
total = sum(len(g) for g in groups.values())
return render_template(
'public/friend.html',
groups=groups,
total=total,
site_name='旺珂 · 导航',
)
-400
View File
@@ -1,400 +0,0 @@
"""
前台导航页视图(无需登录)。
URL 路径(挂在根路径下,便于匿名访问):
- GET /site 公开导航聚合首页(按分类展示所有启用的导航)
- GET /site/category/<name> 分类详情:展示指定分类下所有启用的导航
- GET /site/api/navs JSON 接口(供前端动态加载)
注意:
- 这些视图**不加** @authorize,避免 login_required 强制登录。
- 使用 url_prefix='/site' 避免与后台 / 路由冲突(后台 / 是登录后的工作台)。
"""
from collections import OrderedDict
import time
import os
import re
import threading
import urllib.request
import urllib.error
from flask import Blueprint, render_template, request, jsonify, current_app, send_file
from applications.models import Nav, NavCategory
from plugins.siteStats.view.stat import get_nav_visit_counts, my_top_navs
# 「我常访问」冷启动阈值:至少点过这么多个不同网址才展示个人区
MINE_MIN_ITEMS = 2
bp = Blueprint('public_nav', __name__, url_prefix='/site')
# 分类列表缓存(左侧侧栏 / 移动端抽屉共用,避免每个请求都查库)
_NAV_CATS_CACHE = {'ts': 0.0, 'data': []}
_NAV_CATS_TTL = 30.0
def get_nav_categories() -> "list[dict]":
"""返回前台导航用的分类列表(按后台 sort 排序,带稳定锚点)。
返回形如::
[{'name': '开发工具', 'icon': 'layui-icon-list',
'description': '', 'anchor': 'cat-1'}, ...]
锚点 ``anchor`` 基于「启用分类」的排序索引生成(从 1 开始),与首页
各 ``<section>`` 的 id 严格一致——即使某个分类下暂时没有导航(空分组
被过滤)也不会错位。
带 30s 进程内缓存:后台新增/调整分类后最多 30s 生效。
"""
now = time.time()
cached = _NAV_CATS_CACHE.get('data') or []
if cached and (now - _NAV_CATS_CACHE.get('ts', 0.0)) < _NAV_CATS_TTL:
return cached
try:
cats = (
NavCategory.query
.filter(NavCategory.enable == 1)
.order_by(NavCategory.sort.desc(), NavCategory.id.asc())
.all()
)
names = [c.name for c in cats]
meta = {c.name: (c.icon, c.description) for c in cats}
# 兜底:只在 Nav 表里出现、未登记到 NavCategory 的历史分类,
# 追加到末尾(与 _grouped_navs 的兜底顺序一致:按分类名升序),
# 保证侧栏锚点与首页 section 一一对应。
used = Nav.query.filter(Nav.enable == 1).with_entities(Nav.category).distinct().all()
names.extend(sorted(n for (n,) in used if n and n not in meta))
data = []
for idx, name in enumerate(names):
icon, desc = meta.get(name, (None, None))
data.append({
'name': name,
'icon': icon or 'layui-icon-list',
'description': desc or '',
'anchor': 'cat-%d' % (idx + 1),
})
except Exception: # 数据库尚未初始化(如迁移前)时不影响页面渲染
data = []
_NAV_CATS_CACHE['ts'] = now
_NAV_CATS_CACHE['data'] = data
return data
def get_cat_anchor_map() -> "dict[str, str]":
"""分类名 -> 锚点 id 的映射,供首页渲染 section id 使用。"""
return {c['name']: c['anchor'] for c in get_nav_categories()}
def clear_nav_categories_cache():
"""后台改动分类后调用:立即失效缓存。"""
_NAV_CATS_CACHE['ts'] = 0.0
_NAV_CATS_CACHE['data'] = []
@bp.get('/')
def index():
"""
公开首页:按 category 分组聚合,渲染模板
"""
return _render_index()
@bp.get('/category/<string:name>')
def category_detail(name: str):
"""
分类详情页:列出指定分类下所有启用的导航
"""
items = (
Nav.query
.filter(Nav.category == name, Nav.enable == 1)
.order_by(Nav.sort.asc(), Nav.id.asc())
.all()
)
counts = get_nav_visit_counts()
dict_items = []
for it in items:
d = it.to_dict()
d['visit_count'] = counts.get(it.id, 0)
dict_items.append(d)
return render_template(
'public/category.html',
category=name,
items=dict_items,
site_name='旺珂 · 导航',
)
@bp.get('/api/navs')
def api_navs():
"""
JSON 接口:返回按分类聚合的导航数据
可用于首页卡片动态加载、第三方嵌入等
"""
return jsonify({
'success': True,
'msg': '请求成功',
'data': _grouped_navs(),
})
def render_public_index():
"""给外部复用:渲染前台公开导航首页(无需登录)。"""
return _render_index()
def get_category_meta() -> "dict[str, dict]":
"""给外部复用:返回启用的分类元信息。"""
return _category_meta()
def _render_index():
groups = _grouped_navs()
total = sum(len(items) for items in groups.values())
mine = my_top_navs(8)
return render_template(
'public/index.html',
groups=groups,
total=total,
site_name='旺珂 · 导航',
cat_meta=_category_meta(),
cat_anchors=get_cat_anchor_map(),
top_navs=_top_navs(12),
my_navs=mine if len(mine) >= MINE_MIN_ITEMS else [],
)
def _top_navs(limit: int = 12) -> "list[dict]":
"""跨分类聚合:返回点击数 TOP N 的有效 nav 列表(按点击数降序)。
- 复用 get_nav_visit_counts() 的 60s 进程内缓存
- 0 点击时返回空列表(调用方负责隐藏整个分类块)
- 同一个 nav 不可能重复出现(按 nav.id 维度)
"""
counts = get_nav_visit_counts()
if not counts:
return []
# 取点击数最高的 N 个 nav_id(点击数相同的按 id asc 稳定排序)
top_ids = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:limit]
nav_ids = [nid for nid, _ in top_ids]
rows = (
Nav.query
.filter(Nav.id.in_(nav_ids), Nav.enable == 1)
.all()
)
by_id = {n.id: n.to_dict() for n in rows}
out = []
for nid in nav_ids:
if nid in by_id:
d = by_id[nid]
d['visit_count'] = counts.get(nid, 0)
out.append(d)
return out
def _grouped_navs() -> "OrderedDict[str, list]":
"""按 category 分组,组内按 sort asc 排序;分组顺序优先按 NavCategory.sort 排列"""
# 先取启用的分类,按 sort 排好序
cats = (
NavCategory.query
.filter(NavCategory.enable == 1)
.order_by(NavCategory.sort.desc(), NavCategory.id.asc())
.all()
)
ordered_names = [c.name for c in cats]
cat_order = {name: idx for idx, name in enumerate(ordered_names)}
items = (
Nav.query
.filter(Nav.enable == 1)
.order_by(Nav.category.asc(), Nav.sort.asc(), Nav.id.asc())
.all()
)
# 一次性取所有 nav 的点击数(带缓存)
counts = get_nav_visit_counts()
groups = OrderedDict()
# 按 cat_order 初始化空分组,保证顺序
for name in ordered_names:
groups[name] = []
for item in items:
d = item.to_dict()
d['visit_count'] = counts.get(item.id, 0)
if item.category in groups:
groups[item.category].append(d)
else:
# 对于未在 NavCategory 登记的历史分类,放在最后
groups.setdefault(item.category, []).append(d)
# 过滤空分组(启用的分类下可能没有导航)
return OrderedDict((k, v) for k, v in groups.items() if v)
def _category_meta() -> "dict[str, dict]":
"""返回分类元信息 {name: {icon, description, ...}},用于抽屉图标等"""
cats = NavCategory.query.filter(NavCategory.enable == 1).all()
return {c.name: {'icon': c.icon or 'layui-icon-list', 'description': c.description or ''} for c in cats}
# ---------------------------------------------------------------------------
# 卡片图标本地缓存
# 首次请求某域名图标时,自动拉取并保存到 data/icon/,以后直接读本地文件,
# 不再重复拉取。拉取失败(含内网/不可达网址)时自动生成「站点名称首字母」SVG 兜底。
# ---------------------------------------------------------------------------
_ICON_DIR = None
_ICON_LOCKS = {}
_ICON_LOCKS_GUARD = threading.Lock()
_ICON_EXT_MIME = {
'.ico': 'image/x-icon',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
}
def _icon_dir():
global _ICON_DIR
if _ICON_DIR is None:
base = current_app.config.get('DATA_DIR')
if not base:
base = os.path.join(os.path.dirname(os.path.dirname(__file__)), '..', 'data')
_ICON_DIR = os.path.join(base, 'icon')
os.makedirs(_ICON_DIR, exist_ok=True)
return _ICON_DIR
def _safe_name(domain: str) -> str:
return re.sub(r'[^A-Za-z0-9.\-_]', '_', domain or 'unknown')
def _lock_for(key: str):
with _ICON_LOCKS_GUARD:
lock = _ICON_LOCKS.get(key)
if lock is None:
lock = threading.Lock()
_ICON_LOCKS[key] = lock
return lock
def _domain_hue(domain: str) -> int:
"""与 models.admin_nav.Nav.to_dict 中用于生成卡片字母头像的配色保持一致"""
s = 0
for ch in (domain or ''):
s = (s * 31 + ord(ch)) & 0xffffffff
return s % 360
def _letter_svg(letter: str, domain: str) -> bytes:
hue = _domain_hue(domain)
c1 = "hsl(%d,62%%,56%%)" % hue
c2 = "hsl(%d,72%%,46%%)" % ((hue + 28) % 360)
ch = (letter or (domain or '·')[:1] or '·').upper()[:1] or '·'
ch = re.sub(r'[<>&"]', '', ch) # 防止 SVG 注入
svg = (
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">'
'<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">'
'<stop offset="0%%" stop-color="%s"/>'
'<stop offset="100%%" stop-color="%s"/>'
'</linearGradient></defs>'
'<rect width="64" height="64" rx="14" fill="url(#g)"/>'
'<text x="32" y="33" text-anchor="middle" dominant-baseline="central" '
'font-family="Arial, \'PingFang SC\', \'Microsoft YaHei\', sans-serif" '
'font-size="34" font-weight="700" fill="#ffffff">%s</text>'
'</svg>'
) % (c1, c2, ch)
return svg.encode('utf-8')
def _fetch_bytes(url: str, timeout: int = 5):
try:
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = resp.read()
ctype = resp.headers.get('Content-Type', '') or ''
return data, ctype
except Exception:
return None
def _ext_from(ctype: str, data: bytes) -> str:
ct = (ctype or '').lower()
if 'png' in ct:
return '.png'
if 'jpeg' in ct or 'jpg' in ct:
return '.jpg'
if 'gif' in ct:
return '.gif'
if 'svg' in ct:
return '.svg'
if 'icon' in ct:
return '.ico'
if data[:4] == b'\x00\x00\x01\x00':
return '.ico'
if data[:8] == b'\x89PNG\r\n\x1a\n':
return '.png'
if data[:3] == b'GIF':
return '.gif'
return '.ico'
def _resolve_icon(domain: str, letter: str):
"""返回 (file_path, mimetype)。优先读本地缓存,否则拉取/生成并落盘。"""
safe = _safe_name(domain)
d = _icon_dir()
# 1) 命中本地缓存(任意已知扩展名)
for ext in ('.ico', '.png', '.jpg', '.jpeg', '.gif', '.svg'):
p = os.path.join(d, safe + ext)
if os.path.isfile(p):
return p, _ICON_EXT_MIME.get(ext, 'application/octet-stream')
# 2) 加锁后再次检查(避免并发重复拉取)
with _lock_for(safe):
for ext in ('.ico', '.png', '.jpg', '.jpeg', '.gif', '.svg'):
p = os.path.join(d, safe + ext)
if os.path.isfile(p):
return p, _ICON_EXT_MIME.get(ext, 'application/octet-stream')
# 3) 循序尝试:站点自身 https → http(含内网)→ DuckDuckGo
candidates = [
'https://%s/favicon.ico' % domain,
'http://%s/favicon.ico' % domain,
'https://icons.duckduckgo.com/ip3/%s.ico' % domain,
]
for url in candidates:
res = _fetch_bytes(url)
if not res:
continue
data, ctype = res
if not data or len(data) < 32: # 过小多半是错误页/空响应
continue
# 必须是真正的图片(按 Content-Type 或二进制魔数判断),
# 否则跳过——避免把 200 的 HTML 错误页当图标缓存下来
ct = (ctype or '').lower()
is_image = (
ct.startswith('image/')
or data[:4] == b'\x00\x00\x01\x00'
or data[:8] == b'\x89PNG\r\n\x1a\n'
or data[:3] == b'GIF'
)
if not is_image:
continue
ext = _ext_from(ctype, data)
p = os.path.join(d, safe + ext)
try:
with open(p, 'wb') as f:
f.write(data)
return p, _ICON_EXT_MIME.get(ext, 'application/octet-stream')
except OSError:
continue
# 4) 全部失败:生成字母头像 SVG 兜底并缓存
p = os.path.join(d, safe + '.svg')
with open(p, 'wb') as f:
f.write(_letter_svg(letter, domain))
return p, 'image/svg+xml'
@bp.get('/icon/<string:domain>')
def icon(domain):
"""卡片图标:本地缓存优先,拉取失败时返回字母头像 SVG。"""
letter = request.args.get('letter', '') or ''
path, mime = _resolve_icon(domain, letter)
return send_file(path, mimetype=mime, max_age=86400)
-35
View File
@@ -1,35 +0,0 @@
from flask import Flask, Blueprint
from applications.view.system.dict import bp as dict_bp
from applications.view.system.file import bp as file_bp
from applications.view.system.index import bp as index_bp
from applications.view.system.log import bp as log_bp
from applications.view.system.mail import bp as mail_bp
from applications.view.system.monitor import bp as monitor_bp
# 4 个业务模块(nav / friend / about / stat)已迁出到 plugins/* ,下文的 register 也不再挂载
from applications.view.system.passport import bp as passport_bp
from applications.view.system.power import bp as power_bp
from applications.view.system.rights import bp as right_bp
from applications.view.system.role import bp as role_bp
from applications.view.system.user import bp as user_bp
from applications.view.system.dept import bp as dept_bp
# 创建sys
system_bp = Blueprint('system', __name__, url_prefix='/system')
def register_system_bps(app: Flask):
# 在admin_bp下注册子蓝图
system_bp.register_blueprint(user_bp)
system_bp.register_blueprint(file_bp)
system_bp.register_blueprint(monitor_bp)
system_bp.register_blueprint(log_bp)
system_bp.register_blueprint(power_bp)
system_bp.register_blueprint(role_bp)
system_bp.register_blueprint(dict_bp)
system_bp.register_blueprint(mail_bp)
system_bp.register_blueprint(passport_bp)
system_bp.register_blueprint(right_bp)
system_bp.register_blueprint(dept_bp)
app.register_blueprint(index_bp)
app.register_blueprint(system_bp)
-181
View File
@@ -1,181 +0,0 @@
from flask import Blueprint, render_template, request, jsonify
from applications.common import curd
from applications.common.utils import validate
from applications.common.utils.http import success_api, fail_api, table_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import str_escape
from applications.extensions import db
from applications.extensions.init_limit import limiter
from applications.models import Dept, User
from applications.schemas import DeptSchema
bp = Blueprint('dept', __name__, url_prefix='/dept')
@bp.get('/')
@authorize("system:dept:main", log=True)
def main():
return render_template('system/dept/main.html')
@bp.post('/data')
@limiter.limit("60 per minute")
@authorize("system:dept:main", log=True)
def data():
dept = Dept.query.order_by(Dept.sort).all()
data = DeptSchema(many=True).dump(dept)
# 创建一个字典,用于存储每个节点的子节点
tree = {}
for item in data:
item["children"] = []
tree[item["id"]] = item
# 构建树形结构
root_nodes = []
for item in data:
parent_id = item["parent_id"] if item["parent_id"] != 0 else None
if parent_id is None:
root_nodes.append(item)
else:
if parent_id in tree:
tree[parent_id]["children"].append(item)
return table_api(msg="请求成功", data=root_nodes)
@bp.get('/add')
@authorize("system:dept:add", log=True)
def add():
return render_template('system/dept/add.html')
@bp.get('/tree')
@authorize("system:dept:main", log=True)
def tree():
dept = Dept.query.order_by(Dept.sort).all()
power_data = curd.model_to_dicts(schema=DeptSchema, data=dept)
res = {
"status": {"code": 200, "message": "默认"},
"data": power_data
}
return jsonify(res)
@bp.post('/save')
@authorize("system:dept:add", log=True)
def save():
req_json = request.get_json(force=True)
dept = Dept(
parent_id=req_json.get('parentId'),
dept_name=str_escape(req_json.get('deptName')),
sort=str_escape(req_json.get('sort')),
leader=str_escape(req_json.get('leader')),
phone=str_escape(req_json.get('phone')),
email=str_escape(req_json.get('email')),
status=str_escape(req_json.get('status')),
address=str_escape(req_json.get('address'))
)
r = db.session.add(dept)
db.session.commit()
return success_api(msg="添加部门成功")
@bp.get('/edit')
@authorize("system:dept:edit", log=True)
def edit():
_id = request.args.get("deptId")
dept = curd.get_one_by_id(model=Dept, id=_id)
return render_template('system/dept/edit.html', dept=dept)
# 启用
@bp.put('/enable')
@authorize("system:dept:edit", log=True)
def enable():
id = request.get_json(force=True).get('deptId')
if id:
enable = 1
d = Dept.query.filter_by(id=id).update({"status": enable})
if d:
db.session.commit()
return success_api(msg="启用部门成功")
return fail_api(msg="出错啦")
return fail_api(msg="数据错误")
# 禁用
@bp.put('/disable')
@authorize("system:dept:edit", log=True)
def dis_enable():
id = request.get_json(force=True).get('deptId')
if id:
enable = 0
d = Dept.query.filter_by(id=id).update({"status": enable})
if d:
db.session.commit()
return success_api(msg="禁用部门成功")
return fail_api(msg="出错啦")
return fail_api(msg="数据错误")
@bp.put('/update')
@authorize("system:dept:edit", log=True)
def update():
json = request.get_json(force=True)
# id = json.get("deptId"),
id = str_escape(json.get("deptId"))
data = {
"dept_name": validate.str_escape(json.get("deptName")),
"sort": validate.str_escape(json.get("sort")),
"leader": validate.str_escape(json.get("leader")),
"phone": validate.str_escape(json.get("phone")),
"email": validate.str_escape(json.get("email")),
"status": validate.str_escape(json.get("status")),
"address": validate.str_escape(json.get("address"))
}
d = Dept.query.filter_by(id=id).update(data)
if not d:
return fail_api(msg="更新部门失败")
db.session.commit()
return success_api(msg="更新部门成功")
@bp.delete('/remove/<int:_id>')
@authorize("system:dept:remove", log=True)
def remove(_id):
d = Dept.query.filter_by(id=_id).delete()
if not d:
return fail_api(msg="删除部门失败")
User.query.filter_by(dept_id=_id).update({"dept_id": None})
db.session.commit()
return success_api(msg="删除部门成功")
# 批量删除
@bp.delete('/batchRemove')
@authorize("system:dept:remove", log=True)
def batch_remove():
ids = request.form.getlist('ids[]')
if not ids:
return fail_api(msg="未提供删除 ID")
for id in ids:
if not id.isdigit():
db.session.rollback()
return fail_api(msg="参数提供错误")
d = Dept.query.filter_by(id=id).delete()
if not d:
return fail_api(msg="删除部门失败")
User.query.filter_by(dept_id=id).update({"dept_id": None})
db.session.commit()
return success_api(msg="删除部门成功")
-299
View File
@@ -1,299 +0,0 @@
from flask import Blueprint, render_template, request, jsonify
from flask_wtf.csrf import validate_csrf
from wtforms.validators import ValidationError
from applications.common import curd
from applications.common.helper import ModelFilter
from applications.common.utils.http import table_api, success_api, fail_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import str_escape
from applications.extensions import db
from applications.extensions.init_limit import limiter
from applications.models import DictType, DictData
from applications.schemas import DictTypeOutSchema, DictDataOutSchema
bp = Blueprint('dict', __name__, url_prefix='/dict')
# 数据字典
@bp.get('/')
@authorize("system:dict:main")
def main():
return render_template('system/dict/main.html')
@bp.get('/dictType/data')
@limiter.limit("60 per minute")
@authorize("system:dict:main")
def dict_type_data():
# 获取请求参数
type_name = str_escape(request.args.get('typeName', type=str))
# 查询参数构造
mf = ModelFilter()
if type_name:
mf.vague(field_name="type_name", value=type_name)
# orm查询
# 使用分页获取data需要.items
dict_all = DictType.query.filter(mf.get_filter(DictType)).layui_paginate()
count = dict_all.total
data = curd.model_to_dicts(schema=DictTypeOutSchema, data=dict_all.items)
return table_api(data=data, count=count)
@bp.get('/dictType/add')
@authorize("system:dict:add", log=True)
def dict_type_add():
return render_template('system/dict/add.html')
@bp.post('/dictType/save')
@authorize("system:dict:add", log=True)
def dict_type_save():
req_json = request.get_json(force=True)
try:
validate_csrf(req_json.get("csrf_token"))
except ValidationError:
return fail_api(msg='非法请求')
data = {
'type_name': str_escape(req_json.get("typeName")),
'type_code': str_escape(req_json.get("typeCode")),
'enable': str_escape(req_json.get("enable")),
'description': str_escape(req_json.get("description"))
}
description = data['description']
del data['description']
if not all(data.values()):
return fail_api(msg="参数提供不足")
if description is not None:
data['description'] = description
d = DictType(**data)
db.session.add(d)
db.session.commit()
if d.id is None:
return fail_api(msg="增加失败")
return success_api(msg="增加成功")
# 编辑字典类型
@bp.get('/dictType/edit')
@authorize("system:dict:edit", log=True)
def dict_type_edit():
_id = request.args.get('dictTypeId', type=int)
dict_type = DictType.query.filter_by(id=_id).first()
return render_template('system/dict/edit.html', dict_type=dict_type)
# 编辑字典类型
@bp.put('/dictType/update')
@authorize("system:dict:edit", log=True)
def dict_type_update():
req_json = request.get_json(force=True)
id = str_escape(req_json.get("id"))
data = {
"description": str_escape(req_json.get("description")),
"enable": str_escape(req_json.get("enable")),
"type_code": str_escape(req_json.get("typeCode")),
"type_name": str_escape(req_json.get("typeName"))
}
if id is None:
return fail_api(msg="字典类型不存在")
description = data['description']
del data['description']
if not all(data.values()):
return fail_api(msg="参数提供不足")
data['description'] = description
DictType.query.filter_by(id=id).update(data)
db.session.commit()
return success_api(msg="更新成功")
# 启用字典
@bp.put('/dictType/enable')
@authorize("system:dict:edit", log=True)
def dict_type_enable():
_id = request.get_json(force=True).get('id')
if _id:
res = curd.enable_status(DictType, _id)
if not res:
return fail_api(msg="出错啦")
return success_api("启用成功")
return fail_api(msg="数据错误")
# 禁用字典
@bp.put('/dictType/disable')
@authorize("system:dict:edit", log=True)
def dict_type_dis_enable():
_id = request.get_json(force=True).get('id')
if _id:
res = curd.disable_status(DictType, _id)
if not res:
return fail_api(msg="出错啦")
return success_api("禁用成功")
return fail_api(msg="数据错误")
# 删除字典类型
@bp.delete('/dictType/remove/<int:_id>')
@authorize("system:dict:remove", log=True)
def dict_type_delete(_id):
DictData.query.filter_by(
type_code=DictType.query.filter_by(id=_id).first().type_code
).all()
res = DictType.query.filter_by(id=_id).delete()
if not res:
return fail_api(msg="删除失败")
db.session.commit()
return success_api(msg="删除成功")
@bp.get('/dictData/data')
@limiter.limit("60 per minute")
@authorize("system:dict:main", log=True)
def dict_code_data():
type_code = str_escape(request.args.get('typeCode', type=str))
dict_data = DictData.query.filter_by(type_code=type_code).layui_paginate()
count = dict_data.total
data = curd.model_to_dicts(schema=DictDataOutSchema, data=dict_data.items)
return table_api(data=data, count=count)
# 增加字典数据
@bp.get('/dictData/add')
@authorize("system:dict:add", log=True)
def dict_data_add():
type_code = request.args.get('typeCode', type=str)
return render_template('system/dict/data/add.html', type_code=type_code)
# 增加字典数据
@bp.post('/dictData/save')
@authorize("system:dict:add", log=True)
def dict_data_save():
req_json = request.get_json(force=True)
data_label = str_escape(req_json.get("dataLabel"))
data_value = str_escape(req_json.get("dataValue"))
enable = str_escape(req_json.get("enable"))
remark = str_escape(req_json.get("remark"))
type_code = str_escape(req_json.get("typeCode"))
d = DictData(data_label=data_label, data_value=data_value, enable=enable, remark=remark, type_code=type_code)
db.session.add(d)
db.session.commit()
if not d.id:
return jsonify(success=False, msg="增加失败")
return jsonify(success=True, msg="增加成功")
# 编辑字典数据
@bp.get('/dictData/edit')
@authorize("system:dict:edit", log=True)
def dict_data_edit():
_id = request.args.get('dataId', type=str)
dict_data = curd.get_one_by_id(DictData, _id)
return render_template('system/dict/data/edit.html', dict_data=dict_data)
# 编辑字典数据
@bp.put('/dictData/update')
@authorize("system:dict:edit", log=True)
def dict_data_update():
req_json = request.get_json(force=True)
id = req_json.get("dataId")
DictData.query.filter_by(id=id).update({
"data_label": str_escape(req_json.get("dataLabel")),
"data_value": str_escape(req_json.get("dataValue")),
"enable": str_escape(req_json.get("enable")),
"remark": str_escape(req_json.get("remark")),
"type_code": str_escape(req_json.get("typeCode"))
})
db.session.commit()
return success_api(msg="更新成功")
# 启用字典数据
@bp.put('/dictData/enable')
@authorize("system:dict:edit", log=True)
def dict_data_enable():
_id = request.get_json(force=True).get('dataId')
if _id:
res = curd.enable_status(model=DictData, id=_id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="启动成功")
return fail_api(msg="数据错误")
# 禁用字典数据
@bp.put('/dictData/disable')
@authorize("system:dict:edit", log=True)
def dict_data_disenable():
_id = request.get_json(force=True).get('dataId')
if _id:
res = curd.disable_status(model=DictData, id=_id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="禁用成功")
return fail_api(msg="数据错误")
# 删除字典数据
@bp.delete('dictData/remove/<int:id>')
@authorize("system:dict:remove", log=True)
def dict_data_delete(id):
res = curd.delete_one_by_id(model=DictData, id=id)
if not res:
return fail_api(msg="删除失败")
return success_api(msg="删除成功")
# 批量删除字典
@bp.delete('dictData/batchRemoveDictType')
@authorize("system:dict:remove", log=True)
def dict_type_batch_remove():
ids = request.form.getlist('ids[]')
for _id in ids:
DictData.query.filter_by(
type_code=DictType.query.filter_by(id=_id).first().type_code
).all()
res = DictType.query.filter_by(id=_id).delete()
if res == 0:
db.session.rollback()
return fail_api(msg="删除失败,请重试")
db.session.commit()
return success_api(msg="删除成功")
# 批量删除字典数据
@bp.delete('dictData/batchRemoveDictData')
@authorize("system:dict:remove", log=True)
def dict_data_batch_remove():
ids = request.form.getlist('ids[]')
for _id in ids:
res = curd.delete_one_by_id(model=DictData, id=_id)
if not res:
db.session.rollback()
return fail_api(msg="删除失败,请重试")
db.session.commit()
return success_api(msg="删除成功")
-85
View File
@@ -1,85 +0,0 @@
import os
from flask import Blueprint, request, render_template, jsonify, current_app
from applications.common.utils.http import fail_api, success_api, table_api
from applications.common.utils.rights import authorize
from applications.extensions import db
from applications.extensions.init_limit import limiter
from applications.models import Photo
from applications.common.utils import upload as upload_curd
bp = Blueprint('adminFile', __name__, url_prefix='/file')
# 图片管理
@bp.get('/')
@authorize("system:file:main")
def index():
return render_template('system/photo/photo.html')
# 图片数据
@bp.get('/table')
@limiter.limit("60 per minute")
@authorize("system:file:main")
def table():
page = request.args.get('page', type=int)
limit = request.args.get('limit', type=int)
data, count = upload_curd.get_photo(page=page, limit=limit)
return table_api(data=data, count=count)
# 上传
@bp.get('/upload')
@authorize("system:file:add", log=True)
def upload():
return render_template('system/photo/photo_add.html')
# 上传接口
@bp.post('/upload')
@authorize("system:file:add", log=True)
def upload_api():
if 'file' in request.files:
photo = request.files['file']
mime = request.files['file'].content_type
file_url = upload_curd.upload_one(photo=photo, mime=mime)
res = {
"msg": "上传成功",
"code": 0,
"success": True,
"data":
{"src": file_url}
}
return jsonify(res)
return fail_api()
# 图片删除
@bp.route('/delete', methods=['GET', 'POST'])
@authorize("system:file:delete", log=True)
def delete():
_id = request.form.get('id')
res = upload_curd.delete_photo_by_id(_id)
if res:
return success_api(msg="删除成功")
else:
return fail_api(msg="删除失败")
# 图片批量删除
@bp.route('/batchRemove', methods=['GET', 'POST'])
@authorize("system:file:delete", log=True)
def batch_remove():
ids = request.form.getlist('ids[]')
photo_name = Photo.query.filter(Photo.id.in_(ids)).all()
upload_url = current_app.config.get("UPLOADED_PHOTOS_DEST")
for p in photo_name:
os.remove(upload_url + '/' + p.name)
photo = Photo.query.filter(Photo.id.in_(ids)).delete(synchronize_session=False)
db.session.commit()
if photo:
return success_api(msg="删除成功")
else:
return fail_api(msg="删除失败")
-18
View File
@@ -1,18 +0,0 @@
from flask import Blueprint, render_template
from flask_login import login_required, current_user, login_fresh
from applications.view.public.nav import render_public_index
bp = Blueprint('index', __name__, url_prefix='/')
# 首页:未登录 → 前台公开导航聚合页;已登录 → 跳到工作台
@bp.get('/')
def index():
# 已登录用户访问根路径时,直接进后台工作台;
# 匿名访问则直接渲染前台导航页(不重定向,地址栏保持 /)
if current_user.is_authenticated:
return render_template('system/index.html', user=current_user)
return render_public_index()
-43
View File
@@ -1,43 +0,0 @@
from flask import Blueprint, request, render_template
from sqlalchemy import desc
from applications.common.utils.http import table_api
from applications.common.utils.rights import authorize
from applications.models import AdminLog
from applications.schemas import LogOutSchema
from applications.common.curd import model_to_dicts
bp = Blueprint('log', __name__, url_prefix='/log')
# 日志管理
@bp.get('/')
@authorize("system:log:main")
def index():
return render_template('system/admin_log/main.html')
# 登录日志
@bp.get('/loginLog')
@authorize("system:log:main")
def login_log():
# orm查询
# 使用分页获取data需要.items
log = AdminLog.query.filter_by(
url='/system/passport/login'
).order_by(desc(AdminLog.create_time)).layui_paginate()
count = log.total
return table_api(data=model_to_dicts(schema=LogOutSchema, data=log.items), count=count)
# 操作日志
@bp.get('/operateLog')
@authorize("system:log:main")
def operate_log():
# orm查询
# 使用分页获取data需要.items
log = AdminLog.query.filter(
AdminLog.url != '/system/passport/login'
).order_by(
desc(AdminLog.create_time)).layui_paginate()
count = log.total
return table_api(data=model_to_dicts(schema=LogOutSchema, data=log.items), count=count)
-98
View File
@@ -1,98 +0,0 @@
from flask import Blueprint, render_template, request, current_app
from flask_login import current_user
from applications.common.curd import model_to_dicts
from applications.common.helper import ModelFilter
from applications.common.utils.http import table_api, fail_api, success_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import str_escape
from applications.extensions import db
from applications.extensions.init_limit import limiter
from applications.models import Mail
from applications.schemas import MailOutSchema
from applications.common.utils import mail
from applications.common.admin import admin_log
bp = Blueprint('adminMail', __name__, url_prefix='/mail')
# 用户管理
@bp.get('/')
@authorize("system:mail:main")
def main():
return render_template('system/mail/main.html')
# 用户分页查询
@bp.get('/data')
@limiter.limit("60 per minute")
@authorize("system:mail:main")
def data():
# 获取请求参数
receiver = str_escape(request.args.get("receiver"))
subject = str_escape(request.args.get('subject'))
content = str_escape(request.args.get('content'))
# 查询参数构造
mf = ModelFilter()
if receiver:
mf.contains(field_name="receiver", value=receiver)
if subject:
mf.contains(field_name="subject", value=subject)
if content:
mf.exact(field_name="content", value=content)
# orm查询
# 使用分页获取data需要.items
mail = Mail.query.filter(mf.get_filter(Mail)).layui_paginate()
count = mail.total
# 返回api
return table_api(data=model_to_dicts(schema=MailOutSchema, data=mail.items), count=count)
# 用户增加
@bp.get('/add')
@authorize("system:mail:add", log=True)
def add():
return render_template('system/mail/add.html')
@bp.post('/save')
@authorize("system:mail:add", log=True)
def save():
req_json = request.get_json(force=True)
receiver = str_escape(req_json.get("receiver"))
subject = str_escape(req_json.get('subject'))
content = str_escape(req_json.get('content'))
user_id = current_user.id
try:
if mail.add(receiver=receiver, subject=subject, content=content, user_id=user_id):
return success_api(msg="增加成功")
except Exception as e:
current_app.log_exception(e)
admin_log(request, False, desc="发送日志失败:" + str(e))
return success_api(msg="发送失败,请检查日志。")
# 删除用户
@bp.delete('/remove/<int:id>')
@authorize("system:mail:remove", log=True)
def delete(id):
res = Mail.query.filter_by(id=id).delete()
if not res:
return fail_api(msg="删除失败")
db.session.commit()
return success_api(msg="删除成功")
# 批量删除
@bp.delete('/batchRemove')
@authorize("system:mail:remove", log=True)
def batch_remove():
ids = request.form.getlist('ids[]')
for id in ids:
res = Mail.query.filter_by(id=id).delete()
if not res:
return fail_api(msg="批量删除失败")
db.session.commit()
return success_api(msg="批量删除成功")
-170
View File
@@ -1,170 +0,0 @@
import os
import re
import sys
import time
import psutil
import platform
from datetime import datetime
from flask import Blueprint, render_template
from applications.common.utils.http import table_api, success_api
from applications.common.utils.rights import authorize
from applications.common.utils.cache import cache_auto_internal
bp = Blueprint('adminMonitor', __name__, url_prefix='/monitor')
def get_disk_partitions_list():
disk_partitions_list = []
# 判断是否在容器中
if not os.path.exists('/.dockerenv'):
disk_partitions = psutil.disk_partitions()
for i in disk_partitions:
try:
a = psutil.disk_usage(i.mountpoint)
except PermissionError:
continue
disk_partitions_dict = {
'device': i.device,
'fstype': i.fstype,
'total': a.total,
'used': a.used,
'free': a.free,
'percent': a.percent
}
disk_partitions_list.append(disk_partitions_dict)
else:
try:
usage = psutil.disk_usage('/')
except PermissionError:
pass
disk_partitions_list.append({
'device': '/', # 设备名称(根文件系统)
'fstype': psutil.disk_partitions()[0].fstype, # 文件系统类型
'total': usage.total, # 总容量(字节)
'used': usage.used, # 已用空间(字节)
'free': usage.free, # 可用空间(字节)
'percent': usage.percent # 使用百分比
})
return disk_partitions_list
def get_basic_info():
# 主机名称
hostname = platform.node()
# 系统版本
system_version = platform.platform()
# Python 版本
python_version = platform.python_version()
# 开机时间
boot_time = datetime.fromtimestamp(psutil.boot_time()).replace(microsecond=0)
up_time = datetime.now().replace(microsecond=0) - boot_time
up_time_list = re.split(r':', str(up_time))
up_time_format = "{} 小时 {} 分钟 {}".format(up_time_list[0], up_time_list[1], up_time_list[2])
up_time_format = up_time_format.replace("days,", "")
return {
'hostname': hostname,
'system_version': system_version,
'python_version': python_version,
'boot_time': boot_time,
'up_time_format': up_time_format
}
# 系统监控
@bp.get('/')
@authorize("system:monitor:main")
def main():
# 当前时间
time_now = time.strftime('%H:%M:%S ', time.localtime(time.time()))
return render_template(
'system/monitor.html',
time_now=time_now,
**get_basic_info()
)
# 图表 api
@bp.get('/polling')
@authorize("system:monitor:main")
def ajax_polling():
# 获取 CPU 核心数
cpu_count = cache_auto_internal('cpu_count', lambda: psutil.cpu_count(), expired=999999999)
# 获取 CPU 使用率
cpus_percent = cache_auto_internal('cpus_percent',
lambda: psutil.cpu_percent(interval=1, percpu=False),
expired=5)
# 每个 CPU 的使用率
cpu_percent_per_core = cache_auto_internal('cpu_percent_per_core',
lambda: list(enumerate(psutil.cpu_percent(interval=1, percpu=True))),
expired=5)
# 获取空闲率、等待率
cpu_times_percent = cache_auto_internal('cpu_times_percent',
lambda: psutil.cpu_times_percent(interval=1, percpu=False),
expired=5)
# 内存信息
memory_information = cache_auto_internal('memory_information',
psutil.virtual_memory,
expired=5)
# 硬盘信息
disk_partitions_list = cache_auto_internal('disk_partitions_list',
get_disk_partitions_list,
expired=5)
# 系统信息
basic_info = cache_auto_internal('basic_info',
get_basic_info,
expired=5)
memory_usage = memory_information.percent
memory_used = memory_information.used
memory_total = memory_information.total
memory_free = memory_information.free
cpu_idle_percent = cpu_times_percent.idle
if hasattr(cpu_times_percent, 'iowait'):
cpu_wait_percent = cpu_times_percent.iowait
else:
cpu_wait_percent = "-"
return table_api(msg="请求成功",
count=0,
data={
'cpu_count': cpu_count,
'cpus_percent': cpus_percent,
'cpu_idle_percent': cpu_idle_percent,
'cpu_wait_percent': cpu_wait_percent,
'cpu_percent_per_core': cpu_percent_per_core,
'memory_used': memory_used,
'memory_total': memory_total,
'memory_free': memory_free,
'memory_usage': memory_usage,
'disk_partitions_list': disk_partitions_list,
'time_now': time.strftime('%H:%M:%S', time.localtime(time.time())),
'basic_info': basic_info
})
# 关闭程序
@bp.get('/kill')
@authorize("system:monitor:main")
def kill():
# 注:若是多 worker 则不生效
return success_api(msg="关闭命令已发送,请修改代码以生效。")
for proc in psutil.process_iter():
if proc.pid == os.getpid():
proc.kill()
sys.exit(1)
-91
View File
@@ -1,91 +0,0 @@
from flask import Blueprint, session, redirect, url_for, render_template, request
from flask_login import current_user, login_user, login_required, logout_user
from applications.common.admin import get_captcha, login_log
from applications.common.utils.http import fail_api, success_api
from applications.models import User
bp = Blueprint('passport', __name__, url_prefix='/passport')
# 获取验证码
@bp.get('/getCaptcha')
def captcha():
resp, code = get_captcha()
session["code"] = code
return resp
# 登录
@bp.get('/login')
def login():
if current_user.is_authenticated:
return redirect(url_for('index.index'))
return render_template('system/login.html')
# 登录
@bp.post('/login')
def login_post():
req = request.form
username = req.get('username')
password = req.get('password')
remember = bool(req.get('remember-me'))
code = req.get('captcha').__str__().lower()
if not username or not password or not code:
return fail_api(msg="用户名或密码没有输入")
s_code = session.get("code", None)
session["code"] = None
if not all([code, s_code]):
return fail_api(msg="参数错误")
if code != s_code:
return fail_api(msg="验证码错误")
user = User.query.filter_by(username=username).first()
if not user:
return fail_api(msg="不存在的用户")
if user.enable == 0:
return fail_api(msg="用户被暂停使用")
if username == user.username and user.validate_password(password):
# 登录
login_user(user, remember=remember)
# 记录登录日志
login_log(request, uid=user.id, is_access=True)
# 授权路由存入session
role = current_user.role
user_power = []
for i in role:
if i.enable == 0:
continue
for p in i.power:
if p.enable == 0:
continue
user_power.append(p.code)
session['permissions'] = user_power
# # 角色存入session
# roles = []
# for role in current_user.role.all():
# roles.append(role.id)
# session['role'] = [roles]
return success_api(msg="登录成功")
login_log(request, uid=user.id, is_access=False)
return fail_api(msg="用户名或密码错误")
# 退出登录
@bp.post('/logout')
@login_required
def logout():
logout_user()
if 'permissions' in session:
session.pop('permissions')
return success_api(msg="注销成功")
-203
View File
@@ -1,203 +0,0 @@
from flask import Blueprint, render_template, request, jsonify
from applications.common import curd
from applications.common.utils.http import success_api, fail_api, table_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import str_escape
from applications.extensions import db
from applications.extensions.init_limit import limiter
from applications.models import Power
from applications.schemas import PowerOutSchema2
from applications.schemas.admin_power import PowerSchema
bp = Blueprint('power', __name__, url_prefix='/power')
@bp.get('/')
@authorize("system:power:main")
def index():
return render_template('system/power/main.html')
@bp.post('/data')
@limiter.limit("60 per minute")
@authorize("system:power:main")
def data():
power = Power.query.all()
data = PowerSchema(many=True).dump(power)
# 创建一个字典,用于存储每个节点的子节点
tree = {}
for item in data:
item["children"] = []
tree[item["id"]] = item
# 构建树形结构
root_nodes = []
for item in data:
parent_id = item["parent_id"] if item["parent_id"] != 0 else None
if parent_id is None:
root_nodes.append(item)
else:
if parent_id in tree:
tree[parent_id]["children"].append(item)
return table_api(msg="请求成功", data=root_nodes)
@bp.get('/add')
@authorize("system:power:add", log=True)
def add():
return render_template('system/power/add.html')
@bp.get('/selectParent')
@authorize("system:power:main", log=True)
def select_parent():
power = Power.query.all()
res = curd.model_to_dicts(schema=PowerOutSchema2, data=power)
res.append({"powerId": 0, "powerName": "顶级权限", "parentId": -1})
res = {
"status": {"code": 200, "message": "默认"},
"data": res
}
return jsonify(res)
# 增加
@bp.post('/save')
@authorize("system:power:add", log=True)
def save():
req = request.get_json(force=True)
data = {
"icon": str_escape(req.get("icon")),
"open_type": str_escape(req.get("openType")),
"parent_id": str_escape(req.get("parentId")),
"code": str_escape(req.get("powerCode")),
"name": str_escape(req.get("powerName")),
"type": str_escape(req.get("powerType")),
"url": str_escape(req.get("powerUrl")),
"sort": str_escape(req.get("sort"))
}
if not data['sort'].isdigit():
return fail_api(msg="参数 sort 需为整数")
power = Power(**data)
db.session.add(power)
db.session.commit()
return success_api(msg="权限添加成功")
# 权限编辑
@bp.get('/edit/<int:_id>')
@authorize("system:power:edit", log=True)
def edit(_id):
power = curd.get_one_by_id(Power, _id)
icon = str(power.icon).split()
if len(icon) == 2:
icon = icon[1]
else:
icon = None
return render_template('system/power/edit.html', power=power, icon=icon)
# 权限更新
@bp.put('/update')
@authorize("system:power:edit", log=True)
def update():
req_json = request.get_json(force=True)
id = request.get_json(force=True).get("powerId")
data = {
"icon": str_escape(req_json.get("icon")),
"open_type": str_escape(req_json.get("openType")),
"parent_id": str_escape(req_json.get("parentId")),
"code": str_escape(req_json.get("powerCode")),
"name": str_escape(req_json.get("powerName")),
"type": str_escape(req_json.get("powerType")),
"url": str_escape(req_json.get("powerUrl")),
"sort": str_escape(req_json.get("sort"))
}
if not data['sort'].isdigit():
return fail_api(msg="参数 sort 需为整数")
res = Power.query.filter_by(id=id).update(data)
db.session.commit()
if not res:
return fail_api(msg="更新权限失败")
return success_api(msg="更新权限成功")
# 启用权限
@bp.put('/enable')
@authorize("system:power:edit", log=True)
def enable():
_id = request.get_json(force=True).get('powerId')
if _id:
res = curd.enable_status(Power, _id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="启用成功")
return fail_api(msg="数据错误")
# 禁用权限
@bp.put('/disable')
@authorize("system:power:edit", log=True)
def dis_enable():
_id = request.get_json(force=True).get('powerId')
if id:
res = curd.disable_status(Power, _id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="禁用成功")
return fail_api(msg="数据错误")
# 权限删除
@bp.delete('/remove/<int:id>')
@authorize("system:power:remove", log=True)
def remove(id):
power = Power.query.filter_by(id=id).first()
if power:
power.role = []
r = Power.query.filter_by(id=id).delete()
db.session.commit()
if r:
return success_api(msg="删除成功")
else:
return fail_api(msg="删除失败")
# 批量删除
@bp.delete('/batchRemove')
@authorize("system:power:remove", log=True)
def batch_remove():
ids = request.form.getlist('ids[]')
if not ids:
return fail_api(msg="未提供删除 ID")
for id in ids:
if not id.isdigit():
db.session.rollback()
return fail_api(msg="参数提供错误")
id = int(id)
power = Power.query.filter_by(id=id).first()
if power:
# 清空关联的角色(如果需要)
power.role = []
db.session.delete(power)
db.session.commit()
return success_api(msg="批量删除成功")
-265
View File
@@ -1,265 +0,0 @@
import copy
from collections import OrderedDict
from flask import jsonify, current_app, Blueprint, render_template
from flask_login import login_required, current_user
from ...common.utils.http import table_api
from ...models import Power
from ...schemas import PowerOutSchema
bp = Blueprint('rights', __name__, url_prefix='/rights')
# 渲染配置
@bp.get('/configs')
@login_required
def configs():
# 网站配置
config = dict(logo={
# 网站名称
"title": current_app.config.get("SYSTEM_NAME"),
# 网站图标
"image": "/static/system/admin/images/logo.png"
# 菜单配置
}, menu={
# 菜单数据来源
"data": "/system/rights/menu",
"collaspe": False,
# 是否同时只打开一个菜单目录
"accordion": True,
"method": "GET",
# 是否开启多系统菜单模式
"control": False,
# 顶部菜单宽度 PX
"controlWidth": 500,
# 默认选中的菜单项
"select": "0",
# 是否开启异步菜单,false 时 data 属性设置为菜单数据,false 时为 json 文件或后端接口
"async": True
}, tab={
# 是否开启多选项卡
"enable": True,
# 切换选项卡时,是否刷新页面状态
"keepState": True,
# 是否开启 Tab 记忆
"session": True,
# 预加载
"preload": False,
# 最大可打开的选项卡数量
"max": 30,
"index": {
# 标识 ID , 建议与菜单项中的 ID 一致
"id": "10",
# 页面地址
"href": "/system/rights/welcome",
# 标题
"title": "首页"
}
}, theme={
# 默认主题色,对应 colors 配置中的 ID 标识
"defaultColor": "2",
# 默认的菜单主题 dark-theme 黑 / light-theme 白
"defaultMenu": "dark-theme",
# 是否允许用户切换主题,false 时关闭自定义主题面板
"allowCustom": True
}, colors=[{
"id": "1",
"color": "#2d8cf0"
},
{
"id": "2",
"color": "#5FB878"
},
{
"id": "3",
"color": "#1E9FFF"
}, {
"id": "4",
"color": "#FFB800"
}, {
"id": "5",
"color": "darkgray"
}
], links=current_app.config.get("SYSTEM_PANEL_LINKS"), other={
# 主页动画时长
"keepLoad": 0,
# 布局顶部主题
"autoHead": False
}, header={
'message': '/system/rights/message'
})
return jsonify(config)
# 消息
@bp.get('/message')
@login_required
def message():
return dict(code=200,
data=[
{
"id": 1,
"title": "通知",
"children": [
{
"id": 11,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
"title": "你收到了 14 份新周报",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
},
{
"id": 12,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png",
"title": "曲妮妮 已通过第三轮面试",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
},
{
"id": 11,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/kISTdvpyTAhtGxpovNWd.png",
"title": "可以区分多种通知类型",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
},
{
"id": 12,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/GvqBnKhFgObvnSGkDsje.png",
"title": "左侧图标用于区分不同的类型",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
},
{
"id": 11,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
"title": "内容不要超过两行字",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
}
]
},
{
"id": 2,
"title": "消息",
"children": [
{
"id": 11,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
"title": "你收到了 14 份新周报",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
},
{
"id": 12,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png",
"title": "曲妮妮 已通过第三轮面试",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
},
{
"id": 11,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/kISTdvpyTAhtGxpovNWd.png",
"title": "可以区分多种通知类型",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
},
{
"id": 12,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/GvqBnKhFgObvnSGkDsje.png",
"title": "左侧图标用于区分不同的类型",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
},
{
"id": 11,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png",
"title": "内容不要超过两行字",
"context": "这是消息内容。",
"form": "就眠仪式",
"time": "刚刚"
}
]
},
{
"id": 3,
"title": "代办",
"children": []
}
])
# 菜单
@bp.get('/menu')
@login_required
def menu():
if current_user.username != current_app.config.get("SUPERADMIN"):
role = current_user.role
powers = []
for i in role:
# 如果角色没有被启用就直接跳过
if i.enable == 0:
continue
# 变量角色用户的权限
for p in i.power:
# 如果权限关闭了就直接跳过
if p.enable == 0:
continue
# 一二级菜单
if int(p.type) in [0, 1] and p not in powers:
powers.append(p)
power_schema = PowerOutSchema(many=True) # 用已继承 ma.ModelSchema 类的自定制类生成序列化类
power_dict = power_schema.dump(powers) # 生成可序列化对象
power_dict.sort(key=lambda x: (x['parent_id'], x['id']), reverse=True)
menu_dict = OrderedDict()
for _dict in power_dict:
if _dict['id'] in menu_dict:
# 当前节点添加子节点
_dict['children'] = copy.deepcopy(menu_dict[_dict['id']])
_dict['children'].sort(key=lambda item: item['sort'])
# 删除子节点
del menu_dict[_dict['id']]
if _dict['parent_id'] not in menu_dict:
menu_dict[_dict['parent_id']] = [_dict]
else:
menu_dict[_dict['parent_id']].append(_dict)
return jsonify(sorted(menu_dict.get(0), key=lambda item: item['sort']))
else:
powers = Power.query.filter(Power.enable == 1).all()
power_schema = PowerOutSchema(many=True) # 用已继承 ma.ModelSchema 类的自定制类生成序列化类
power_dict = power_schema.dump(powers) # 生成可序列化对象
power_dict.sort(key=lambda x: (x['parent_id'], x['id']), reverse=True)
menu_dict = OrderedDict()
for _dict in power_dict:
if _dict['id'] in menu_dict:
# 当前节点添加子节点
_dict['children'] = copy.deepcopy(menu_dict[_dict['id']])
_dict['children'].sort(key=lambda item: item['sort'])
# 删除子节点
del menu_dict[_dict['id']]
if _dict['parent_id'] not in menu_dict:
menu_dict[_dict['parent_id']] = [_dict]
else:
menu_dict[_dict['parent_id']].append(_dict)
return jsonify(sorted(menu_dict.get(0), key=lambda item: item['sort']))
# 控制台页面
@bp.get('/welcome')
@login_required
def welcome():
return render_template('system/analysis/main.html')
-208
View File
@@ -1,208 +0,0 @@
from flask import Blueprint, render_template, request, jsonify
from flask_login import login_required
from applications.common.curd import model_to_dicts, enable_status, disable_status, get_one_by_id
from applications.common.utils.http import table_api, success_api, fail_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import str_escape
from applications.extensions import db
from applications.extensions.init_limit import limiter
from applications.models import Role, Power, User
from applications.schemas import RoleOutSchema, PowerOutSchema2
bp = Blueprint('role', __name__, url_prefix='/role')
# 用户管理
@bp.get('/')
@authorize("system:role:main")
def main():
return render_template('system/role/main.html')
# 表格数据
@bp.get('/data')
@limiter.limit("60 per minute")
@authorize("system:role:main")
def table():
role_name = str_escape(request.args.get('roleName', type=str))
role_code = str_escape(request.args.get('roleCode', type=str))
filters = []
if role_name:
filters.append(Role.name.contains(role_name))
if role_code:
filters.append(Role.code.contains(role_code))
roles = Role.query.filter(*filters).layui_paginate()
return table_api(data=RoleOutSchema(many=True).dump(roles), count=roles.total)
# 角色增加
@bp.get('/add')
@authorize("system:role:add", log=True)
def add():
return render_template('system/role/add.html')
# 角色增加
@bp.post('/save')
@authorize("system:role:add", log=True)
def save():
req = request.get_json(force=True)
data = {
"details": str_escape(req.get("details")),
"enable": str_escape(req.get("enable")),
"code": str_escape(req.get("roleCode")), # 角色标识
"name": str_escape(req.get("roleName")), # 角色名称
"sort": str_escape(req.get("sort"))
}
details = data.get('details')
del data['details']
if not all(data.values()):
return fail_api(msg="参数不足")
data['details'] = details
# 参数效验
if not data['sort'].isdigit():
return fail_api(msg="参数 sort 需为整数")
role = Role(**data)
db.session.add(role)
db.session.commit()
return success_api(msg="添加角色成功")
# 角色授权
@bp.get('/power/<int:_id>')
@authorize("system:role:power", log=True)
def power(_id):
return render_template('system/role/power.html', id=_id)
# 获取角色权限
@bp.get('/getRolePower/<int:id>')
@authorize("system:role:main", log=True)
def get_role_power(id):
role = Role.query.filter_by(id=id).first()
check_powers = role.power
check_powers_list = []
for cp in check_powers:
check_powers_list.append(cp.id)
powers = Power.query.all()
power_schema = PowerOutSchema2(many=True) # 用已继承ma.ModelSchema类的自定制类生成序列化类
output = power_schema.dump(powers) # 生成可序列化对象
for i in output:
if int(i.get("powerId")) in check_powers_list:
i["checkArr"] = "1"
else:
i["checkArr"] = "0"
res = {
"data": output,
"status": {"code": 200, "message": "默认"}
}
return jsonify(res)
# 保存角色权限
@bp.put('/saveRolePower')
@authorize("system:role:edit", log=True)
def save_role_power():
req_form = request.form
power_ids = req_form.get("powerIds")
power_list = power_ids.split(',')
role_id = req_form.get("roleId")
role = Role.query.filter_by(id=role_id).first()
if not role:
return fail_api(msg="角色不存在")
powers = Power.query.filter(Power.id.in_(power_list)).all()
role.power = powers
db.session.commit()
return success_api(msg="授权成功")
# 角色编辑
@bp.get('/edit/<int:id>')
@authorize("system:role:edit", log=True)
def edit(id):
r = get_one_by_id(model=Role, id=id)
return render_template('system/role/edit.html', role=r)
# 更新角色
@bp.put('/update')
@authorize("system:role:edit", log=True)
def update():
req_json = request.get_json(force=True)
id = req_json.get("roleId")
data = {
"code": str_escape(req_json.get("roleCode")),
"name": str_escape(req_json.get("roleName")),
"sort": str_escape(req_json.get("sort")),
"enable": str_escape(req_json.get("enable")),
"details": str_escape(req_json.get("details"))
}
if not data['enable'].isdigit():
return fail_api(msg="参数 enable 需为整数")
if not data['sort'].isdigit():
return fail_api(msg="参数 sort 需为整数")
role = Role.query.filter_by(id=id).update(data)
db.session.commit()
if not role:
return fail_api(msg="更新角色失败")
return success_api(msg="更新角色成功")
# 启用用户
@bp.put('/enable')
@authorize("system:role:edit", log=True)
def enable():
_id = request.get_json(force=True).get('roleId')
if _id:
res = enable_status(Role, _id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="启动成功")
return fail_api(msg="数据错误")
# 禁用用户
@bp.put('/disable')
@authorize("system:role:edit", log=True)
def dis_enable():
_id = request.get_json(force=True).get('roleId')
if _id:
res = disable_status(Role, _id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="禁用成功")
return fail_api(msg="数据错误")
# 角色删除
@bp.delete('/remove/<int:id>')
@authorize("system:role:remove", log=True)
def remove(id):
role = Role.query.filter_by(id=id).first()
if not role:
return fail_api(msg="角色不存在")
# 删除该角色的权限和用户
role.power = []
role.user = []
r = Role.query.filter_by(id=id).delete()
db.session.commit()
if not r:
return fail_api(msg="角色删除失败")
return success_api(msg="角色删除成功")
-236
View File
@@ -1,236 +0,0 @@
from flask import Blueprint, render_template, request
from flask_login import login_required, current_user
from sqlalchemy import desc
from applications.common import curd
from applications.common.curd import enable_status, disable_status
from applications.common.utils.http import table_api, fail_api, success_api
from applications.common.utils.rights import authorize
from applications.common.utils.validate import str_escape
from applications.extensions import db
from applications.extensions.init_limit import limiter
from applications.models import Role, Dept
from applications.models import User, AdminLog
bp = Blueprint('user', __name__, url_prefix='/user')
# 用户管理
@bp.get('/')
@authorize("system:user:main")
def main():
return render_template('system/user/main.html')
# 用户分页查询
@bp.get('/data')
@limiter.limit("60 per minute")
@authorize("system:user:main")
def data():
# 获取请求参数
real_name = str_escape(request.args.get('realname', type=str))
username = str_escape(request.args.get('username', type=str))
dept_id = request.args.get('deptId', type=int)
filters = []
if real_name:
filters.append(User.realname.contains(real_name))
if username:
filters.append(User.username.contains(username))
if dept_id:
filters.append(User.dept_id == dept_id)
# print(*filters)
query = db.session.query(
User,
Dept
).filter(*filters).outerjoin(Dept, User.dept_id == Dept.id).layui_paginate()
return table_api(
data=[{
'id': user.id,
'username': user.username,
'realname': user.realname,
'enable': user.enable,
'create_at': user.create_at,
'update_at': user.update_at,
'dept_name': dept.dept_name if dept else None
} for user, dept in query.items],
count=query.total)
# 用户增加
@bp.get('/add')
@authorize("system:user:add", log=True)
def add():
roles = Role.query.all()
return render_template('system/user/add.html', roles=roles)
@bp.post('/save')
@authorize("system:user:add", log=True)
def save():
req_json = request.get_json(force=True)
a = req_json.get("roleIds")
username = str_escape(req_json.get('username'))
real_name = str_escape(req_json.get('realName'))
password = str_escape(req_json.get('password'))
dept_id = str_escape(req_json.get('deptId'))
role_ids = a.split(',')
if not username or not real_name or not password:
return fail_api(msg="账号姓名密码不得为空")
if bool(User.query.filter_by(username=username).count()):
return fail_api(msg="用户已经存在")
user = User(username=username, realname=real_name, enable=1, dept_id=dept_id)
user.set_password(password)
db.session.add(user)
roles = Role.query.filter(Role.id.in_(role_ids)).all()
for r in roles:
user.role.append(r)
db.session.commit()
return success_api(msg="增加成功")
# 删除用户
@bp.delete('/remove/<int:id>')
@authorize("system:user:remove", log=True)
def delete(id):
user = User.query.filter_by(id=id).first()
user.role = []
res = User.query.filter_by(id=id).delete()
db.session.commit()
if not res:
return fail_api(msg="删除失败")
return success_api(msg="删除成功")
# 编辑用户
@bp.get('/edit/<int:id>')
@authorize("system:user:edit", log=True)
def edit(id):
user = curd.get_one_by_id(User, id)
roles = Role.query.all()
checked_roles = []
for r in user.role:
checked_roles.append(r.id)
return render_template('system/user/edit.html', user=user, roles=roles, checked_roles=checked_roles)
# 编辑用户
@bp.put('/update')
@authorize("system:user:edit", log=True)
def update():
req_json = request.get_json(force=True)
a = str_escape(req_json.get("roleIds"))
id = str_escape(req_json.get("userId"))
username = str_escape(req_json.get('username'))
real_name = str_escape(req_json.get('realName'))
dept_id = str_escape(req_json.get('deptId'))
role_ids = a.split(',')
User.query.filter_by(id=id).update({'username': username, 'realname': real_name, 'dept_id': dept_id})
u = User.query.filter_by(id=id).first()
roles = Role.query.filter(Role.id.in_(role_ids)).all()
u.role = roles
db.session.commit()
return success_api(msg="更新成功")
# 个人中心
@bp.get('/center')
@login_required
def center():
user_info = current_user
user_logs = AdminLog.query.filter_by(url='/passport/login').filter_by(uid=current_user.id).order_by(
desc(AdminLog.create_time)).limit(10)
return render_template('system/user/center.html', user_info=user_info, user_logs=user_logs)
# 修改头像
@bp.get('/profile')
@login_required
def profile():
return render_template('system/user/profile.html')
# 修改头像
@bp.put('/updateAvatar')
@login_required
def update_avatar():
url = request.get_json(force=True).get("avatar").get("src")
r = User.query.filter_by(id=current_user.id).update({"avatar": url})
db.session.commit()
if not r:
return fail_api(msg="出错啦")
return success_api(msg="修改成功")
# 修改当前用户信息
@bp.put('/updateInfo')
@login_required
def update_info():
req_json = request.get_json(force=True)
r = User.query.filter_by(id=current_user.id).update(
{"realname": req_json.get("realName"), "remark": req_json.get("details")})
db.session.commit()
if not r:
return fail_api(msg="出错啦")
return success_api(msg="更新成功")
# 修改当前用户密码
@bp.get('/editPassword')
@login_required
def edit_password():
return render_template('system/user/edit_password.html')
# 修改当前用户密码
@bp.put('/editPassword')
@login_required
def edit_password_put():
res_json = request.get_json(force=True)
if res_json.get("newPassword") == '':
return fail_api("新密码不得为空")
if res_json.get("newPassword") != res_json.get("confirmPassword"):
return fail_api("两次密码不一样")
user = current_user
is_right = user.validate_password(res_json.get("oldPassword"))
if not is_right:
return fail_api("旧密码错误")
user.set_password(res_json.get("newPassword"))
db.session.add(user)
db.session.commit()
return success_api("更改成功")
# 启用用户
@bp.put('/enable')
@authorize("system:user:edit", log=True)
def enable():
_id = request.get_json(force=True).get('userId')
if _id:
res = enable_status(model=User, id=_id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="启动成功")
return fail_api(msg="数据错误")
# 禁用用户
@bp.put('/disable')
@authorize("system:user:edit", log=True)
def dis_enable():
_id = request.get_json(force=True).get('userId')
if _id:
res = disable_status(model=User, id=_id)
if not res:
return fail_api(msg="出错啦")
return success_api(msg="禁用成功")
return fail_api(msg="数据错误")
+36
View File
@@ -0,0 +1,36 @@
import os
from datetime import timedelta
class BaseConfig:
SECRET_KEY = os.getenv("SECRET_KEY", "pear-admin-flask")
SQLALCHEMY_DATABASE_URI = ""
ROOT_PATH = os.path.dirname(os.path.abspath(__file__))
JWT_TOKEN_LOCATION = ["headers"]
JWT_ACCESS_TOKEN_EXPIRES = timedelta(days=7)
class DevelopmentConfig(BaseConfig):
"""开发配置"""
SQLALCHEMY_DATABASE_URI = "sqlite:///pear_admin.db"
SQLALCHEMY_TRACK_MODIFICATIONS = False
class TestingConfig(BaseConfig):
"""测试配置"""
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" # 内存数据库
class ProductionConfig(BaseConfig):
"""生成环境配置"""
SQLALCHEMY_DATABASE_URI = "mysql://root:root@127.0.0.1:3306/pear_admin"
SQLALCHEMY_TRACK_MODIFICATIONS = False
config = {"dev": DevelopmentConfig, "test": TestingConfig, "prod": ProductionConfig}
-25
View File
@@ -1,25 +0,0 @@
FROM python:3.11-bookworm
# 设置时区
ENV TIME_ZONE Asia/Shanghai
RUN echo "${TIME_ZONE}" > /etc/timezone \
&& ln -sf /usr/share/zoneinfo/${TIME_ZONE} /etc/localtime
WORKDIR /app
# 1. [优化] 先只拷贝依赖文件,利用 Docker 缓存
# 只要 requirements.txt 没变,这一步和 pip install 就会直接用缓存,极大加快构建速度
COPY requirements.txt /app/
# 2. 安装依赖 (使用清华源)
RUN python3 -m pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple/ \
&& python3 -m pip install --no-cache-dir gunicorn -i https://pypi.tuna.tsinghua.edu.cn/simple/
# 3. 拷贝其余项目代码
COPY . /app/
# 4. 确保脚本有执行权限 (直接处理源路径,防止移动失败)
RUN chmod +x /app/deploy/dev/start.sh
# 5. 指定启动入口
CMD ["/bin/bash", "/app/deploy/dev/start.sh"]
-29
View File
@@ -1,29 +0,0 @@
version: '3.8'
services:
flask:
build:
context: ../.. # 指向项目根目录 (pear-admin-flask)
dockerfile: deploy/dev/Dockerfile
container_name: flask_dev
restart: always
ports:
- "5000:5000"
# [优化] 挂载卷:将本地代码映射到容器内部
# 效果:你在 PyCharm 修改代码,容器里立马生效,不需要重启容器
volumes:
- ../..:/app
# [优化] 环境变量
environment:
- FLASK_DEBUG=0 # 是否开启 Flask 调试模式
- FLASK_APP=app.py
# 网络设置
networks:
- dev_network
networks:
dev_network:
driver: bridge
-25
View File
@@ -1,25 +0,0 @@
#!/bin/bash
# 确保进入工作目录
cd /app
echo "=== [Step 1] 检查并初始化数据库 ==="
# 使用 || true 确保即使命令报错(例如migrations文件夹已存在)也不会中断脚本
flask db init || true
flask db migrate || true
flask db upgrade || true
# (可选) 每次重启尝试初始化数据,如果已存在则会被代码逻辑跳过
flask admin init || true
echo "=== [Step 2] 启动服务 (开发模式) ==="
# [优化] 增加 --reload 参数
# 配合 docker-compose 的 volumes,当检测到代码变化时,Gunicorn 会自动重启 worker
# access-logfile - 表示将日志输出到控制台
exec gunicorn --bind 0.0.0.0:5000 \
--workers 2 \
--reload \
--access-logfile - \
--error-logfile - \
app:app
-87
View File
@@ -1,87 +0,0 @@
# NAS 一键部署
## 前置条件
1. **本机安装 paramiko**(仅第一次用):
```bash
C:/Users/PF/.workbuddy/binaries/python/envs/default/Scripts/pip.exe install paramiko
```
2. **配置 SSH 密码**(环境变量,避免每次交互输入):
```bash
set SSH_PASS=peng4077
```
或直接交互式输入。
## 快速上手
```bash
# 完整流程:打包 → 上传 → 同步图标 → 构建 → 重启 → 体检
SSH_PASS=peng4077 python deploy/nas/deploy.py
# 只改了模板/静态文件,不需要重装依赖和重新编译镜像
SSH_PASS=peng4077 python deploy/nas/deploy.py --no-build
# 只同步 favicon 种子(NAS 无外网,靠种子才有真实图标)
SSH_PASS=peng4077 python deploy/nas/deploy.py --icons-only
# 只看远端状态,不改任何东西
SSH_PASS=peng4077 python deploy/nas/deploy.py --check
# 本地打包预览(不打包进 .env/data/venv 等禁忌项)
SSH_PASS=peng4077 python deploy/nas/deploy.py --pack-only
```
## 环境变更
可覆盖的默认值:
| 变量 | 默认 | 说明 |
|------|------|------|
| `NAS_HOST` | `192.168.1.102` | NAS IP |
| `NAS_USER` | `bwadmin` | SSH 用户名 |
| `NAS_PORT` | `22` | SSH 端口 |
| `NAS_DIR` | `/home/bwadmin/pear-admin-flask` | NAS 上的项目目录 |
| `SSH_PASS` | 交互式输入 | 密码(所有 sudo 命令共用) |
## 脚本做了什么
1. **打包** — 排除 `.git``venv``data``.env``*.db``__pycache__``flask_session``logs``output` 等一次性/运行时产物;只打进源码 + 配置 + 部署文件。
2. **上传** — 通过 SFTP 把 tar.gz 传到 NAS `/tmp`
3. **解包**`sudo tar xzf` 覆盖项目目录,`chown bwadmin:bwadmin` 改属主。**刻意跳过** `deploy/nas/data`(数据卷属主 uid 1001 = 容器内 appuser,改错了容器会失去写权限)。
4. **同步图标** — 把本地 `data/icon/` 下的真实 favicon 打进 `deploy/nas/data/icon/`(NAS 容器无外网,抓不到 favicon,必须靠种子才有真实图标)。
5. **构建镜像**`docker compose build`**不加 `--pull`**NAS 的 registry 镜像常返回 401,本地 `python:3.11-slim` 缓存已够用)。
6. **启动容器**`docker compose up -d`(容器会被重建,但 `deploy/nas/data` 数据卷内容原样保留)。
7. **健康检查** — 最多等 30 次(间隔 4 秒)直到 `/healthz` 返回 200,超时不阻塞主流程。
8. **验收**`docker ps` + `curl /healthz` + `curl /site/`
## 注意事项
- **密码安全**:脚本用 `-S -p '' bash -lc` 方式给 sudo 喂密码,**密码不会出现在 stdout**。日志中会把密码替换成 `******`
- **临时包位置**:打包产物放在系统临时目录(`%TEMP%\pear-src-*.tar.gz`),项目根不留残留。
- **构建失败处理**:源码已更新到远端,但容器仍在跑旧镜像;下次重新跑一次就行。
- **NAS 防火墙**:确保本机到 NAS 的 22 端口可达(已验证 ping 连通)。
## 故障排查
| 现象 | 可能原因 | 处理 |
|------|----------|------|
| `paramiko 未找到` | 未安装 | 用管理 python 装一次 `pip install paramiko` |
| `docker: command not found` | sudo 没带 sudoers | 确认 NAS 上 `bwadmin``sudo` 权限(`sudo -S -p '' bash -lc whoami` 能输 `root`|
| `ImageBuildError: python:3.11-slim: 401` | `--pull` 强制拉取 | 去掉 `--pull`NAS 已有本地缓存 |
| 构建后模板未生效 | Docker 层缓存命中 | 加 `--no-cache` 强制重建 |
| 图标仍是字母头像 | 未同步种子 | 运行 `--icons-only`,或手动把 `data/icon/*.ico` 拷进 NAS 同目录 |
| 容器启动后 healthz 不 200 | 数据库迁移缺失 | 进容器跑 `flask db upgrade` |
## 依赖此脚本的其他命令
```bash
# 查看 NAS 上当前容器状态(不动任何东西)
SSH_PASS=peng4077 python deploy/nas/deploy.py --check
# 看本地源包里实际有什么
SSH_PASS=peng4077 python deploy/nas/deploy.py --pack-only
```
-61
View File
@@ -1,61 +0,0 @@
# =====================================================
# Pear Admin Flask - 个人 NAS / 单机部署 Dockerfile
# =====================================================
# 适用场景:内网访问 / 少量并发 / 数据量小
# 基础:python:3.11-slim~150 MB),无 MySQL,纯 SQLite
#
# 构建: docker build -f deploy/nas/Dockerfile -t pear-admin-nas .
# 运行: docker compose -f deploy/nas/docker-compose.yaml up -d
# =====================================================
FROM python:3.11-slim
# 容器内时区与 Python 输出
ENV TZ=Asia/Shanghai \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
PEAR_ENV=pro \
PEAR_DATA_DIR=/app/data \
FLASK_APP=app.py
# 安装时区数据 + 健康检查用的 curl
# (curl 在 slim 里已有,但保持显式声明便于以后切到 alpine)
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl tzdata sqlite3 \
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
&& echo $TZ > /etc/timezone \
&& rm -rf /var/lib/apt/lists/*
# 非 root 运行(生产最佳实践)
RUN useradd -m -u 1001 appuser
WORKDIR /app
# 先只 copy 依赖文件 → 利用 Docker 缓存;requirements 不变则跳过 pip install
COPY requirements.txt ./
RUN pip install -r requirements.txt \
&& pip install gunicorn==21.2.0
# 再 copy 全部源码
COPY --chown=appuser:appuser . .
# 数据目录单独建好并 chown 给 appuseruid 1001)。
# /app/data 由卷挂载覆盖,但容器刚启动时如果宿主目录是空的,
# 子目录(backup/ flask_session/ 等)需要 appuser 自己能写。
RUN mkdir -p /app/data/backup /app/data/flask_session /app/data/logs /app/data/upload \
&& chown -R appuser:appuser /app/data
# 启动脚本加执行权限
RUN chmod +x /app/deploy/nas/start.sh /app/deploy/nas/backup.sh
# 切换到非 root
USER appuser
EXPOSE 5000
# 容器健康检查:访问 /healthz 接口(applications/view/health.py 提供,DB 不可达返回 503)
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -fsS http://localhost:5000/healthz || exit 1
CMD ["/app/deploy/nas/start.sh"]
-55
View File
@@ -1,55 +0,0 @@
#!/bin/bash
# Pear Admin Flask - SQLite 在线热备脚本
#
# 用法: docker exec pear-admin-nas /app/deploy/nas/backup.sh
# (也支持本地直接 ./backup.sh
#
# 关键:不能用 cp 复制 pear.db —— WAL 模式下 .db 文件单独 cp 不含未刷盘的页。
# 改用 SQLite 自带的 `sqlite3 pear.db ".backup target"` 或
# `VACUUM INTO 'target'`,两者都在 SQLite 内部完成拷贝,含 WAL 的一致性快照。
#
# 产物:${DATA_DIR}/backup/pear-YYYYMMDD_HHMMSS.db
# 保留:最近 14 份(可通过 BACKUP_KEEP 覆盖)
set -e
DATA_DIR=${PEAR_DATA_DIR:-/app/data}
DB_PATH="${DATA_DIR}/pear.db"
BACKUP_DIR="${DATA_DIR}/backup"
KEEP=${BACKUP_KEEP:-14}
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
TARGET="${BACKUP_DIR}/pear-${TIMESTAMP}.db"
if [[ ! -f "${DB_PATH}" ]]; then
echo "FATAL: 数据库文件不存在: ${DB_PATH}" >&2
exit 1
fi
mkdir -p "${BACKUP_DIR}"
echo "[backup] db: ${DB_PATH}"
echo "[backup] -> : ${TARGET}"
# sqlite3 二进制在 Dockerfile 里 apt install 了;镜像外本机有的话也用得上
sqlite3 "${DB_PATH}" ".backup '${TARGET}'"
# 压缩一份小的;同时保留原文件方便快速恢复
gzip -f "${TARGET}"
# 清理旧备份:保留文件名最近 KEEP 个(含 .gz 后缀)
KEPT=0
for f in $(ls -1t "${BACKUP_DIR}"/pear-*.db.gz 2>/dev/null); do
KEPT=$((KEPT + 1))
if [[ "${KEPT}" -gt "${KEEP}" ]]; then
rm -f "${f}"
echo "[backup] removed old: ${f}"
fi
done
# 健康检查:备份文件必须 > 0 字节且能正常打开
LATEST="${BACKUP_DIR}/pear-${TIMESTAMP}.db.gz"
if [[ ! -s "${LATEST}" ]]; then
echo "FATAL: 备份文件为空: ${LATEST}" >&2
exit 1
fi
echo "[backup] done. latest = ${LATEST}"
echo "[backup] current keep: $(ls -1 "${BACKUP_DIR}"/pear-*.db.gz 2>/dev/null | wc -l) of ${KEEP}"
-422
View File
@@ -1,422 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Pear Admin Flask -> NAS (192.168.1.102) 一键构建部署脚本
做什么:
1. 本地把源码打成 tar.gz(排除 .git / venv / data / .env / *.db 等)
2. SFTP 上传到 NAS
3. 远端 sudo 解包覆盖源码(保留 .env 与 deploy/nas/data 数据卷)
4. 可选:同步本地已抓取的 favicon 种子到 deploy/nas/data/icon
(NAS 容器无外网,抓不到 favicon,必须靠种子才有真实图标)
5. sudo docker compose build + up -d
6. 健康检查 /healthz 与 /site/
前置条件:
- 本机装了 paramiko
C:/Users/PF/.workbuddy/binaries/python/envs/default/Scripts/pip.exe install paramiko
- 能 ping 通 NAS
用法:
# 完整流程(推荐):打包 + 传代码 + 传图标种子 + 构建 + 重启 + 体检
set SSH_PASS=你的密码
python deploy/nas/deploy.py
# 只改了模板/静态文件,不需要重装依赖和编译镜像时,跳过 build 直接重启
python deploy/nas/deploy.py --no-build
# 只同步图标种子,不动代码
python deploy/nas/deploy.py --icons-only
# 只看远端当前状态(容器、镜像、健康),不改任何东西
python deploy/nas/deploy.py --check
# 只打包不上传(看看包里有什么)
python deploy/nas/deploy.py --pack-only
可用环境变量覆盖默认值:
SSH_PASS SSH/sudo 密码(不设则交互输入)
NAS_HOST 默认 192.168.1.102
NAS_USER 默认 bwadmin
NAS_DIR 默认 /home/bwadmin/pear-admin-flask
NAS_PORT 默认 22
注意:
- NAS 上 bwadmin 没有 docker socket 权限,所有 docker 命令都走 sudo
- deploy/nas/data 是数据卷,属主 uid 1001= 容器内 appuser),
脚本会刻意跳过对它的 chown,否则容器将失去写权限
"""
import argparse
import getpass
import io
import os
import stat
import sys
import tarfile
import tempfile
import time
try:
import paramiko
except ImportError:
sys.exit("缺少 paramiko,请先安装:\n"
" C:/Users/PF/.workbuddy/binaries/python/envs/default/Scripts/pip.exe install paramiko")
# ---------------------------------------------------------------- 配置
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
HOST = os.environ.get("NAS_HOST", "192.168.1.102")
USER = os.environ.get("NAS_USER", "bwadmin")
PORT = int(os.environ.get("NAS_PORT", "22"))
REMOTE_DIR = os.environ.get("NAS_DIR", "/home/bwadmin/pear-admin-flask")
REMOTE_TMP = "/tmp"
COMPOSE_FILE = "deploy/nas/docker-compose.yaml"
# 打包时排除的目录(相对项目根)
EXCLUDE_DIRS = {
".git", "venv", ".venv", "env", "__pycache__", ".workbuddy",
"instance", "flask_session", "logs", "output", "node_modules",
".idea", ".vscode", ".pytest_cache", ".mypy_cache", "build", "dist",
"data", # 运行数据:SQLite / 图标缓存 / 上传,部署靠挂载,不打进包
}
# 打包时排除的文件名
EXCLUDE_FILES = {".env", ".env.local", ".DS_Store", "pear.db", "padmin.db"}
# 打包时排除的后缀
EXCLUDE_SUFFIX = (".pyc", ".pyo", ".log", ".db", ".db-wal", ".db-shm",
".bak", ".tar.gz", ".swp", ".pid")
ICON_SRC = os.path.join(PROJECT_ROOT, "data", "icon") # 本地已抓取的 favicon
ICON_DEST = "deploy/nas/data/icon" # 远端数据卷里的图标目录
ICON_OWNER = "1001:1001" # 容器内 appuser 的 uid:gid
# ---------------------------------------------------------------- 工具
def log(msg=""):
print(msg, flush=True)
def step(msg):
log("\n" + "=" * 62)
log(" " + msg)
log("=" * 62)
def should_skip(rel_path, is_dir):
"""判断某个相对路径是否应被打进源码包"""
parts = rel_path.replace("\\", "/").split("/")
for p in parts:
if p in EXCLUDE_DIRS:
return True
if not is_dir:
name = parts[-1]
if name in EXCLUDE_FILES:
return True
if name.endswith(EXCLUDE_SUFFIX):
return True
return False
def pack_source(out_path):
"""把项目源码打成 tar.gz,返回 (文件路径, 文件数, 字节数)"""
n = 0
with tarfile.open(out_path, "w:gz", compresslevel=6) as tar:
for root, dirs, files in os.walk(PROJECT_ROOT):
rel_root = os.path.relpath(root, PROJECT_ROOT)
rel_root = "" if rel_root == "." else rel_root.replace("\\", "/")
# 就地过滤目录,避免递归进 venv/.git 等大目录
dirs[:] = [d for d in dirs
if not should_skip((rel_root + "/" + d) if rel_root else d, True)]
# 打包脚本自身产生的临时包
dirs[:] = [d for d in dirs if d != os.path.basename(out_path)]
for f in files:
rel = (rel_root + "/" + f) if rel_root else f
if should_skip(rel, False):
continue
full = os.path.join(root, f)
if os.path.abspath(full) == os.path.abspath(out_path):
continue
try:
tar.add(full, arcname=rel, recursive=False)
n += 1
except (OSError, PermissionError) as e:
log(" [跳过] %s (%s)" % (rel, e))
return out_path, n, os.path.getsize(out_path)
def pack_icons(out_path):
"""把本地 data/icon/ 下的真实 favicon 打成 tar.gz(扁平结构,不带目录层级)"""
if not os.path.isdir(ICON_SRC):
return None
files = [f for f in os.listdir(ICON_SRC)
if os.path.isfile(os.path.join(ICON_SRC, f))]
# 只带真实图片,字母头像 SVG 让容器自己生成即可
files = [f for f in files if not f.lower().endswith(".svg")]
if not files:
return None
with tarfile.open(out_path, "w:gz") as tar:
for f in files:
tar.add(os.path.join(ICON_SRC, f), arcname=f, recursive=False)
return out_path
def human(n):
for unit in ("B", "KB", "MB"):
if n < 1024 or unit == "MB":
return "%.1f %s" % (n, unit)
n /= 1024.0
# ---------------------------------------------------------------- SSH
class Nas:
def __init__(self, host, user, pwd, port=22):
self.pwd = pwd
self.ssh = paramiko.SSHClient()
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.ssh.connect(host, port=port, username=user, password=pwd,
timeout=20, allow_agent=False, look_for_keys=False)
self.sftp = self.ssh.open_sftp()
def _mask(self, text):
"""万一 sudo 把密码回显出来,别打在日志里"""
return text.replace(self.pwd, "******") if self.pwd else text
def run(self, cmd, timeout=900, show=True, sudo=False):
"""执行远程命令。sudo=True 时通过 -S 从 stdin 喂密码。"""
full = ("sudo -S -p '' bash -lc %s" % _shq(cmd)) if sudo else ("bash -lc " + _shq(cmd))
chan = self.ssh.get_transport().open_session()
chan.get_pty()
chan.settimeout(timeout)
chan.exec_command(full)
if sudo:
chan.sendall(self.pwd + "\n")
buf = []
deadline = time.time() + timeout
while True:
if chan.recv_ready():
data = chan.recv(65536)
if not data:
break
text = data.decode("utf-8", "replace")
buf.append(text)
if show:
sys.stdout.write(self._mask(text))
sys.stdout.flush()
elif chan.recv_stderr_ready():
data = chan.recv_stderr(65536)
if not data:
break
text = data.decode("utf-8", "replace")
buf.append(text)
if show:
sys.stdout.write(self._mask(text))
sys.stdout.flush()
elif chan.exit_status_ready():
# 退出前把残留输出读干净
while chan.recv_ready():
buf.append(chan.recv(65536).decode("utf-8", "replace"))
break
else:
if time.time() > deadline:
chan.close()
raise TimeoutError("远程命令超时(%ds):%s" % (timeout, cmd))
time.sleep(0.4)
code = chan.recv_exit_status()
chan.close()
return code, "".join(buf)
def upload(self, local, remote):
self.sftp.put(local, remote)
return remote
def close(self):
try:
self.sftp.close()
except Exception:
pass
self.ssh.close()
def _shq(s):
"""把命令包成 bash -lc '...' 能安全接受的单一参数"""
return "'" + s.replace("'", "'\"'\"'") + "'"
# ---------------------------------------------------------------- 流程
def do_check(nas):
step("远端状态检查")
nas.run("docker ps --filter name=pear-admin-nas --format "
"'table {{.Names}} {{.Status}} {{.Ports}}'", sudo=True)
nas.run("docker images pear-admin-nas --format 'table {{.Repository}}:{{.Tag}} {{.Size}} {{.CreatedSince}}'",
sudo=True)
code, _ = nas.run("curl -fsS -o /dev/null -w 'healthz=%{http_code}\\n' http://127.0.0.1:5000/healthz; "
"curl -fsS -o /dev/null -w 'site=%{http_code}\\n' http://127.0.0.1:5000/site/",
sudo=False, show=True)
return code
def do_deploy(nas, local_pkg, remote_pkg, icon_pkg, do_build, args):
# 1) 上传
step("上传源码包")
nas.upload(local_pkg, remote_pkg)
log(" -> %s (%s)" % (remote_pkg, human(os.path.getsize(local_pkg))))
# 2) 解包覆盖(保留 .env 与 deploy/nas/data
step("解包覆盖源码(保留 .env 与 deploy/nas/data")
nas.run("cd %s && tar xzf %s" % (REMOTE_DIR, remote_pkg), sudo=True)
# 解压产生的文件属主可能是 root,改回 bwadmin;但必须跳过数据卷,
# 否则容器(uid 1001)会失去对 pear.db 的写权限
nas.run("cd %s && find . -mindepth 1 -path './deploy/nas/data' -prune "
"-o -exec chown %s:%s {} + 2>/dev/null || true" % (REMOTE_DIR, USER, USER), sudo=True)
nas.run("rm -f %s" % remote_pkg, sudo=True)
# 3) 图标种子
if icon_pkg:
step("同步 favicon 种子(容器无外网,靠种子才有真实图标)")
r_icon = REMOTE_TMP + "/" + os.path.basename(icon_pkg)
nas.upload(icon_pkg, r_icon)
nas.run("mkdir -p %s/%s && tar xzf %s -C %s/%s && chown -R %s %s/%s"
% (REMOTE_DIR, ICON_DEST, r_icon, REMOTE_DIR, ICON_DEST,
ICON_OWNER, REMOTE_DIR, ICON_DEST), sudo=True)
cnt, _ = nas.run("ls -1 %s/%s | wc -l" % (REMOTE_DIR, ICON_DEST), sudo=True, show=False)
log(" 图标种子已就位")
nas.run("rm -f %s" % r_icon, sudo=True)
else:
log("\n[跳过] 本地没有图标种子(data/icon 为空),容器内图标将回退为字母头像")
# 4) 构建
if do_build:
step("构建镜像(docker compose build")
# 先记一下旧 ID,便于和构建后的结果对比
old_id, _ = nas.run("docker images -q pear-admin-nas:latest", sudo=True, show=False)
old_id = (old_id or "").strip()
# 不加 --pullNAS 的 registry 镜像常返回 401),本地 python:3.11-slim 缓存已够用
pull = " --pull" if args.pull else ""
pull += " --no-cache" if args.no_cache else ""
# 单独收退出码,不用 pipe 截断输出(管道会把 stdout 读到一半就丢弃)
build_cmd = ("cd %s && docker compose -f %s build%s"
% (REMOTE_DIR, COMPOSE_FILE, pull))
code, _ = nas.run(build_cmd, sudo=True, timeout=1800)
if code != 0:
sys.exit("\n构建失败(退出码 %d),已中止部署(源码已更新到远端,但容器仍在跑旧镜像)" % code)
new_id, _ = nas.run("docker images -q pear-admin-nas:latest", sudo=True, show=False)
new_id = (new_id or "").strip()
if old_id and new_id and old_id == new_id:
log("\n[提示] 镜像 ID 未变化(%s):Docker 判定无改动复用了缓存。" % new_id[:12])
log(" 如果改了模板却没生效,试试 --no-cache 强制重建。")
else:
log("\n镜像已更新:%s -> %s" % ((old_id or "")[:12], (new_id or "")[:12]))
else:
log("\n[跳过] --no-build:不重新构建镜像,直接重启容器")
# 5) 起容器
step("启动容器")
nas.run("cd %s && docker compose -f %s up -d" % (REMOTE_DIR, COMPOSE_FILE), sudo=True)
# 6) 等健康检查
step("等待健康检查")
ok = False
for i in range(30):
time.sleep(4)
code, out = nas.run("curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:5000/healthz || true",
sudo=False, show=False)
status = out.strip().splitlines()[-1] if out.strip() else ""
if status == "200":
log("%d 次探测通过(healthz=200" % (i + 1))
ok = True
break
log("%d 次探测未就绪(%s" % (i + 1, status or "无响应"))
if not ok:
log("\n[警告] 健康检查未通过,打印容器日志:")
nas.run("docker logs --tail 50 pear-admin-nas", sudo=True)
return 1
# 7) 验收
step("验收")
nas.run("docker ps --filter name=pear-admin-nas --format "
"'table {{.Names}} {{.Status}} {{.Ports}}'", sudo=True)
nas.run("curl -fsS -o /dev/null -w 'healthz=%{http_code}\\n' http://127.0.0.1:5000/healthz; "
"curl -fsS -o /dev/null -w 'site=%{http_code}\\n' http://127.0.0.1:5000/site/", sudo=False)
return 0
# ---------------------------------------------------------------- main
def main():
ap = argparse.ArgumentParser(description="构建并部署 pear-admin-flask 到 NAS")
ap.add_argument("--no-build", action="store_true", help="跳过镜像构建,只更新代码并重启")
ap.add_argument("--no-cache", action="store_true", help="构建时不用 Docker 层缓存(改了模板却没生效时用)")
ap.add_argument("--pull", action="store_true",
help="构建时强制拉取基础镜像(默认不拉:NAS 的 registry 常返回 401"
"本地 python:3.11-slim 缓存已够用)")
ap.add_argument("--icons-only", action="store_true", help="只同步 favicon 种子")
ap.add_argument("--no-icons", action="store_true", help="跳过 favicon 种子同步")
ap.add_argument("--check", action="store_true", help="只看远端状态,不做任何修改")
ap.add_argument("--pack-only", action="store_true", help="只打包不上传")
args = ap.parse_args()
os.chdir(PROJECT_ROOT)
stamp = time.strftime("%Y%m%d-%H%M%S")
if args.pack_only:
pkg, n, size = pack_source("pear-src-%s.tar.gz" % stamp)
log("已打包:%s%d 个文件,%s" % (pkg, n, human(size)))
log("解包查看:tar tzf %s | head -50" % pkg)
return 0
pwd = os.environ.get("SSH_PASS") or getpass.getpass("NAS %s@%s 密码: " % (USER, HOST))
if args.check:
nas = Nas(HOST, USER, pwd, PORT)
try:
return do_check(nas)
finally:
nas.close()
# 临时包放系统临时目录:既不污染项目根,也避免在项目目录里做删除动作
tmpdir = tempfile.gettempdir()
pkg_path = None
icon_path = None
try:
if not args.icons_only:
step("本地打包源码")
pkg_path, n, size = pack_source(os.path.join(tmpdir, "pear-src-%s.tar.gz" % stamp))
log(" %d 个文件,%s" % (n, human(size)))
if not args.no_icons:
icon_path = pack_icons(os.path.join(tmpdir, "pear-icons-%s.tar.gz" % stamp))
if icon_path:
log(" 图标种子:%s%s" % (os.path.basename(icon_path),
human(os.path.getsize(icon_path))))
nas = Nas(HOST, USER, pwd, PORT)
try:
if args.icons_only:
if not icon_path:
log("本地 data/icon 下没有可用的 favicon,无需同步")
return 0
r_icon = REMOTE_TMP + "/" + os.path.basename(icon_path)
nas.upload(icon_path, r_icon)
nas.run("mkdir -p %s/%s && tar xzf %s -C %s/%s && chown -R %s %s/%s && rm -f %s"
% (REMOTE_DIR, ICON_DEST, r_icon, REMOTE_DIR, ICON_DEST,
ICON_OWNER, REMOTE_DIR, ICON_DEST, r_icon), sudo=True)
log("\n图标种子已同步")
return 0
return do_deploy(nas, pkg_path, REMOTE_TMP + "/" + os.path.basename(pkg_path),
icon_path, not args.no_build, args)
finally:
nas.close()
finally:
# 清理临时包;失败也不影响部署结果(某些环境会拦截批量删除)
for p in (pkg_path, icon_path):
if p and os.path.exists(p):
try:
os.remove(p)
except BaseException:
log(" [提示] 临时包未能自动删除:%s" % p)
if __name__ == "__main__":
sys.exit(main() or 0)
-77
View File
@@ -1,77 +0,0 @@
# =====================================================
# Pear Admin Flask - 个人 NAS 单机部署
# =====================================================
# 使用:
# 1) cp .env.example .env 并修改 SECRET_KEY
# 2) mkdir -p data && cp ../pear.db data/pear.db # 首次部署把已有库搬过来
# 3) docker compose -f deploy/nas/docker-compose.yaml up -d
# 4) 访问 http://NAS_IP:5000
#
# 数据持久化(一个目录搞定):
# ./data → 容器内 /app/data
# ├─ pear.db pear.db-wal pear.db-shm ← SQLiteWAL 模式)
# ├─ backup/ ← 在线热备(VACUUM INTO 产物)
# ├─ flask_session/ ← Flask-Session 文件
# ├─ logs/ ← gunicorn / Flask 日志
# └─ upload/ ← 上传图片
#
# 重建容器 / 换机器:整个 ./data 目录拷走就行,pear.db、备份、上传全在里面。
# =====================================================
services:
flask:
build:
context: ../.. # 项目根目录(含 requirements.txt
dockerfile: deploy/nas/Dockerfile
image: pear-admin-nas:latest
container_name: pear-admin-nas
restart: unless-stopped
ports:
- "5000:5000" # 容器 5000 → NAS 5000;可改为 127.0.0.1:5000:5000 仅本地访问
# 环境变量注入(生产环境用 .env 自动读取)
env_file:
- ../../.env
environment:
# 把容器内的 /app/data 重定向到宿主 ./data
# config.py 会自动把 SQLite 库、session、logs、backup 都收到这里
PEAR_DATA_DIR: /app/data
# 数据持久化:单一绑定挂载,NAS 共享文件夹里直接看得见
volumes:
- ./data:/app/data
# 资源限制(NAS 推荐;按需调整)
deploy:
resources:
limits:
cpus: '2.0' # 单容器最多用 2 核
memory: 1024M # 1 GB 上限
reservations:
cpus: '0.5'
memory: 256M
# 健康检查:applications/view/health.py 提供 /healthz
# - 200DB OK
# - 503DB 不可达(让容器被标 unhealthy)
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:5000/healthz"]
interval: 30s
timeout: 10s
start_period: 40s
retries: 3
# 网络模式默认 bridge;如 NAS 上启用了 host 网络可获得更好的局域网性能:
# network_mode: host
networks:
- pear_net
# =====================================================
# 单数据卷已够用:
# - 重建容器:只会重建容器层,./data 完全不受影响
# - 备份:NAS 上定时把 ./data 整个 tar 走,或跑 deploy/nas/backup.sh 单独导出 pear.db
# - 迁移:停容器、把 ./data 拷到新机器、起新容器,pear.db 内容原样可用
# =====================================================
networks:
pear_net:
driver: bridge
-58
View File
@@ -1,58 +0,0 @@
#!/bin/bash
# Pear Admin Flask - NAS 生产启动脚本
#
# 流程:
# 1. 数据目录检查:PEAR_DATA_DIR 必须存在且可写;不存在则用默认行为
# 2. alembic 迁移到最新版本(幂等;migrations 文件夹随镜像打包)
# 3. flask admin init 幂等初始化菜单/权限(已有则跳过)
# 4. exec 切换到 gunicornPID 1 由 gunicorn 接管
#
# 注意:必须 exec,否则容器启动后 PID 1 是 shell 而非 gunicorn
# docker stop 发出的 SIGTERM 不会被正确转发,graceful shutdown 失效。
set -e
DATA_DIR=${PEAR_DATA_DIR:-/app/data}
echo "=== [1/4] 数据目录 ==="
echo "PEAR_DATA_DIR=${DATA_DIR}"
mkdir -p "${DATA_DIR}/backup" "${DATA_DIR}/flask_session" "${DATA_DIR}/logs" "${DATA_DIR}/upload"
# 确保子目录可写(WAL 模式下 SQLite 需要 pear.db 同目录的 -wal / -shm 也能创建)
touch "${DATA_DIR}/.write_test" && rm -f "${DATA_DIR}/.write_test" || {
echo "FATAL: data dir not writable: ${DATA_DIR}" >&2
exit 1
}
echo "=== [2/4] 等待数据库就绪 ==="
# 默认 SQLite 不需要等待;这里预留扩展位(如未来切 MySQL,可在此加 wait-for-it
if [[ "${SQLALCHEMY_DATABASE_URI:-}" == mysql* ]]; then
echo "检测到 MySQL,等待 10s 让 db 服务启动..."
sleep 10
fi
echo "=== [3/4] 初始化数据库(幂等) ==="
# flask 命令依赖 FLASK_APP;镜像里已经设过;这里再 fallback 一次
export FLASK_APP=${FLASK_APP:-app.py}
# 数据库迁移:migrate 仅当模型有变才生成新版本;upgrade 永远是幂等的
flask db upgrade || echo "WARN: flask db upgrade failed (首次启动可能正常)"
flask admin init || true
echo "=== [4/4] 启动 Gunicorn ==="
# SQLite + WAL 模式下,多 worker 写文件本身没问题(同时间互不阻塞),
# 但多个事务并发提交仍可能撞锁。这里把 worker 降到 1(线程给够),
# 既能吃满并发量、又把"database is locked"概率压到最低。
WORKERS=${GUNICORN_WORKERS:-1}
THREADS=${GUNICORN_THREADS:-8}
TIMEOUT=60
exec gunicorn \
--bind 0.0.0.0:5000 \
--workers "$WORKERS" \
--threads "$THREADS" \
--timeout "$TIMEOUT" \
--graceful-timeout 30 \
--keep-alive 5 \
--access-logfile "${DATA_DIR}/logs/access.log" \
--error-logfile "${DATA_DIR}/logs/error.log" \
--log-level info \
app:app
-164
View File
@@ -1,164 +0,0 @@
# Pear Admin Flask - 部署指南
本文档面向把本项目部署到生产环境的运维 / 二次开发者。
请结合根目录的 `README.md``docs/SECURITY.md` 一起阅读。
## 0. 环境要求
| 项目 | 版本 |
| --- | --- |
| Python | 3.8(推荐 3.11 |
| SQLite | 3.x(仅开发用) |
| MySQL | 5.7+ / 8.0(生产推荐) |
| 操作系统 | Windows / Linux 均可 |
## 1. 克隆代码
```bash
git clone https://gitea.bwhome.top/bwadmin/pear-admin-flask.git
cd pear-admin-flask
```
> 如仓库使用自签证书导致 `SEC_E_WRONG_PRINCIPAL` 报错,
> 可临时关闭 SSL 校验:`git -c http.sslVerify=false clone <url>`。
> 长期建议把根证书导入到系统信任库。
## 2. 创建虚拟环境 + 安装依赖
```bash
# Linux / macOS
python -m venv venv
source venv/bin/activate
# Windows (cmd)
python -m venv venv
venv\Scripts\activate.bat
pip install -r requirements.txt
```
## 3. 配置 .env
复制模板:
```bash
cp .env.example .env # Linux
copy .env.example .env # Windows
```
按需修改(**生产模式 SECRET_KEY 必填**,参见下文):
```dotenv
PEAR_ENV=production
SECRET_KEY=<用 python -c "import secrets; print(secrets.token_urlsafe(48))" 生成>
# MySQL 示例
SQLALCHEMY_DATABASE_URI=mysql+pymysql://user:pass@127.0.0.1:3306/pear?charset=utf8mb4
# 邮件(可选)
MAIL_SERVER=smtp.qq.com
MAIL_USE_SSL=true
MAIL_PORT=465
MAIL_USERNAME=your_account@qq.com
MAIL_PASSWORD=your_authorization_code_here
```
⚠️ `.env` 文件已在 `.gitignore` 中,**不会**被提交到 git。
## 4. 数据库初始化
```bash
# 1) 生成迁移脚本
flask db migrate
# 2) 升级到当前 head
flask db upgrade
# 3) 导入 admin 基础数据(用户 / 角色 / 菜单)
flask admin init
```
MySQL 用户需提前建库:
```sql
CREATE DATABASE pear DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'pear'@'%' IDENTIFIED BY 'your_strong_password';
GRANT ALL PRIVILEGES ON pear.* TO 'pear'@'%';
FLUSH PRIVILEGES;
```
## 5. 启动
### 5.1 开发
```bash
# Linux
make run
# Windows
start.bat
```
### 5.2 生产
推荐用 `gunicorn``pip install gunicorn`),或反向代理到 `waitress`
```bash
# Linux
PEAR_ENV=production gunicorn -w 4 -b 0.0.0.0:5000 app:app
# Windows
pip install waitress
PEAR_ENV=production waitress-serve --port=5000 app:app
```
### 5.3 健康检查
服务起来后:
```bash
curl http://127.0.0.1:5000/healthz
# 200 OK → {"status":"ok","db_ok":true,...}
# 503 → {"status":"degraded","db_ok":false,...}
```
## 6. 反向代理(Nginx 示例)
```nginx
server {
listen 80;
server_name pear.example.com;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 50m;
}
location = /healthz {
proxy_pass http://127.0.0.1:5000/healthz;
access_log off;
}
}
```
## 7. 升级流程
```bash
git pull
source venv/bin/activate
pip install -r requirements.txt
flask db migrate
flask db upgrade
# 重启进程
systemctl restart pear-admin-flask
```
## 8. 常见问题
| 现象 | 处理 |
| --- | --- |
| `[ProConfig] 生产环境必须通过环境变量 SECRET_KEY 传入强随机密钥` | 在 `.env` 或系统环境变量中设置 `SECRET_KEY`。 |
| `SEC_E_WRONG_PRINCIPAL` 克隆失败 | `git -c http.sslVerify=false clone ...` 或导入根证书。 |
| 启动后 `/` 跳转到登录页 | 正常。后台默认账号 `admin` / 密码 `123456`**请立即修改**。 |
| 数据库表缺失 | `flask db upgrade``flask admin init`。 |
-20
View File
@@ -1,20 +0,0 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
-89
View File
@@ -1,89 +0,0 @@
# Pear Admin Flask - 安全指南
## 1. 密钥(SECRET_KEY
`SECRET_KEY` 用于 Flask `session` 签名、`csrf_token`、Flask-Session
等。**一旦泄露,攻击者可伪造任意管理员会话。**
要求:
- 长度 ≥ 32 字节(推荐 48+);
- 高熵随机(`secrets.token_urlsafe(48)` / `openssl rand -base64 48`);
- **禁止**使用默认值 `pear-system-flask`
- **禁止**把 `.env` / 含密钥的部署脚本提交到 git。
开发模式自动生成的密钥仅供本地调试,**重启即失效**。
## 2. 凭据(Mail / DB
所有凭据通过 `.env` 注入,代码里只放占位符。强烈建议:
- 生产数据库单独建用户,**只授必要的库权限**;
- Mail 使用**授权码**而非登录密码(QQ / 163 / Gmail 均提供);
- 定期轮换凭据,并在变更后重启服务。
## 3. 默认管理员账号
`flask admin init` 会写入默认账号 `admin / 123456`,**首次登录后必须立即修改密码**。
可执行:
```bash
# 1) 登录后台 → 用户管理 → 修改 admin 密码;
# 2) 或直接 SQL
sqlite3 pear.db "UPDATE rbac_user SET password='<pbkdf2:sha256:...>' WHERE username='admin';"
```
## 4. Cookie / Session
`SESSION_COOKIE_HTTPONLY = True`(默认)与 `SESSION_COOKIE_SAMESITE = 'Lax'`
已经在配置中体现。生产部署在 HTTPS 下请额外设置:
```python
SESSION_COOKIE_SECURE = True
```
> 若启用 Flask-Sessionfilesystem / redis),请把 `flask_session/`
> 目录排除在 Web 静态目录之外,并定期清理。
## 5. CSRF
本项目基于 `flask_wtf`,所有写操作均有 CSRF 校验。
前端表单务必带上 `{{ csrf_token() }}` 或请求头 `X-CSRFToken`
## 6. 上传文件
`UPLOADED_PHOTOS_DEST = static/upload`,后缀白名单已限制为图片。
但**用户可上传 SVG / 伪装为图片的 JS** 等——建议:
- 上传目录关闭脚本执行(Nginx: `location ^~ /static/upload/ { ... }`);
- 后端校验真实文件类型(`python-magic`),而非仅看后缀。
## 7. 限流
`Flask-Limiter` 已挂载(`applications/extensions/init_limit.py`)。
生产模式请按业务需求调整默认速率,避免误伤。
## 8. 日志
`LOG_LEVEL` 控制根 logger 输出等级。生产推荐 `WARN` / `ERROR`
**不要把 `SECRET_KEY`、用户密码、token 写入日志。**
## 9. 依赖漏洞
建议在 CI 中加入 `pip-audit`
```bash
pip install pip-audit
pip-audit -r requirements.txt
```
## 10. 上线前 Checklist
- [ ] `PEAR_ENV=production`
- [ ] `SECRET_KEY` 已替换为强随机值
- [ ] 数据库用户仅授必要权限
- [ ] 默认 admin 密码已修改
- [ ] HTTPS 已上线(`SESSION_COOKIE_SECURE` 同步开启)
- [ ] `flask_session/` 目录已加入备份策略
- [ ] `static/upload/` 已禁用脚本执行
- [ ] `pip-audit` 跑过
- [ ] `/healthz` 已接入负载均衡 / 容器探活
-260
View File
@@ -1,260 +0,0 @@
# Pear Admin Flask - 个人 NAS 部署指南
适用:内网 / 家庭 / 小团队(≤ 50 并发用户,无 K8s 需求)。
---
## 1. 前置检查
NAS 上需安装:
| 软件 | 最低版本 | 验证命令 |
|---|---|---|
| Docker Engine | 20.10+ | `docker --version` |
| Docker Compose | v2.x | `docker compose version` |
> 如果 NAS 是群晖 / 威联通,请在套件中心安装 **Container Manager** 或自行 SSH 安装 docker。
---
## 2. 准备 .env
```bash
cd /volume1/docker/pear-admin-flask
cp .env.example .env
# 编辑 .envSECRET_KEY 必须改成强随机字符串
python -c "import secrets; print(secrets.token_urlsafe(48))"
# 把输出贴到 .env 的 SECRET_KEY=
```
`.env` **绝不能提交到 git**(已在 `.gitignore` 中)。
---
## 3. 首次构建 + 启动
```bash
# 进入项目目录
cd /volume1/docker/pear-admin-flask # 改成你自己的路径
# 如果你之前用过 pear-admin-flask,要把现有库搬过来:
mkdir -p data
cp pear.db data/pear.db # 把根目录的库搬到 data/ 下(容器会把整个 ./data 挂进 /app/data
# 构建镜像(首次约 3-5 分钟)
docker compose -f deploy/nas/docker-compose.yaml build
# 后台启动
docker compose -f deploy/nas/docker-compose.yaml up -d
# 查看启动日志(应看到 "Listening at: http://0.0.0.0:5000"
docker compose -f deploy/nas/docker-compose.yaml logs -f
```
启动成功后访问:`http://NAS_IP:5000`
默认账号 `admin` / `123456`(首次登录后**强烈建议改密码**)。
---
## 4. 健康检查
```bash
# 进程是否还活着(DB OK 返回 200;DB 不可达返回 503)
curl -i http://NAS_IP:5000/healthz
# 200 OK: {"status":"ok","db_ok":true,"env":"production","system":"Pear Admin","timestamp":"..."}
# 容器视角的健康状态(Docker 30s 探一次)
docker inspect --format='{{.State.Health.Status}}' pear-admin-nas
# healthy / unhealthy / starting
```
---
## 5. 端口与局域网访问
`docker-compose.yaml` 中默认 `5000:5000`,局域网内任意设备 `http://NAS_IP:5000` 都能访问。
如要改端口(如 NAS 上 5000 被占用):
```yaml
ports:
- "8080:5000" # 宿主 8080 → 容器 5000
```
手机访问:`http://NAS_IP:8080`(确保 NAS 防火墙放行)。
---
## 6. 数据持久化
整个数据库 / 会话 / 备份 / 上传,**统一收进宿主 `./data` 目录**,容器内路径 `/app/data`
```
./data # docker-compose 唯一绑定挂载
├── pear.db # SQLite 主库(WAL 模式)
├── pear.db-wal # WAL 文件(不要删)
├── pear.db-shm # 共享内存文件(不要删)
├── backup/ # 在线热备(自动保留最近 14 天)
│ └── pear-20260906_020000.db.gz
├── flask_session/ # Flask-Session 文件
├── logs/ # gunicorn / Flask 日志
│ ├── access.log
│ └── error.log
└── upload/ # 上传图片
```
在 NAS File Station 里直接看得到、可以直接 tar 走 —— 这是从"看不见的虚拟卷"改成"看得见的文件夹"的核心收益。
### 备份(在线热备)
容器镜像里带了 `sqlite3``deploy/nas/backup.sh``sqlite3 .backup`(含 WAL 一致性,比 `cp` 安全):
```bash
docker exec pear-admin-nas /app/deploy/nas/backup.sh
# 输出:[backup] done. latest = /app/data/backup/pear-20260906_020000.db.gz
```
### 自动备份(推荐,群晖 / 威联通 / Linux 都适用)
群晖 DSM:「控制面板 → 任务计划表 → 新增 → 计划的任务 → 用户定义的脚本」:
```
每天 02:00 跑:
docker exec pear-admin-nas /app/deploy/nas/backup.sh
```
或者宿主 crontab
```bash
0 2 * * * docker exec pear-admin-nas /app/deploy/nas/backup.sh
```
默认保留 14 天(`BACKUP_KEEP=14` 可改)。
### 恢复
```bash
# 停容器
docker compose -f deploy/nas/docker-compose.yaml down
# 把备份解开(注意:必须先停容器才能覆盖 pear.db,否则 SQLite 还在持有文件锁)
gunzip -c data/backup/pear-20260906_020000.db.gz > data/pear.db
# 顺带把 -wal / -shm 也清掉(WAL 会自动重建)
rm -f data/pear.db-wal data/pear.db-shm
# 起回去
docker compose -f deploy/nas/docker-compose.yaml up -d
```
整库迁移到新机器:
```bash
# 旧机器:tar 整个 data 目录(不算大,库 100MB 级别)
cd /volume1/docker/pear-admin-flask
tar czf pear-data-$(date +%F).tgz data/
# 新机器:解开 → 启动即用
tar xzf pear-data-2026-09-06.tgz -C /volume1/docker/pear-admin-flask/
docker compose -f deploy/nas/docker-compose.yaml up -d
```
---
## 7. 升级流程
```bash
# 1. 拉取最新代码
cd /volume1/docker/pear-admin-flask
git pull
# 2. 重新构建镜像
docker compose -f deploy/nas/docker-compose.yaml build
# 3. 重启容器(migrations 自动跑)
docker compose -f deploy/nas/docker-compose.yaml up -d
# 4. 验证
curl -i http://NAS_IP:5000/healthz
```
数据库迁移由 `start.sh` 中的 `flask db upgrade` 自动执行(幂等;含 `site_nav_click` 等插件表的迁移)。
---
## 8. 常见问题
### Q1:容器启动后立刻退出
```bash
docker compose -f deploy/nas/docker-compose.yaml logs --tail=50
```
最常见原因:
- `SECRET_KEY` 还是占位符 `PLEASE_REPLACE_WITH_RANDOM_STRING` → ProConfig 会拒绝启动
- 端口被占用 → 修改 `ports`
- `data/` 目录存在但权限不对(容器内 uid 1001 没写权限)→ `chown -R 1001:1001 data/`
### Q2:访问首页 502 / 拒绝连接
```bash
# 确认容器在跑
docker ps | grep pear-admin-nas
# 看健康状态
docker inspect --format='{{.State.Health.Status}}' pear-admin-nas
```
### Q3:忘记 admin 密码
直接重置(删除整个 `data/pear.db`,再启动让 `flask admin init` 重新生成默认账号):
```bash
docker compose -f deploy/nas/docker-compose.yaml down
rm -f data/pear.db data/pear.db-wal data/pear.db-shm
docker compose -f deploy/nas/docker-compose.yaml up -d
```
⚠️ **此操作会清空所有数据**,务必先 `docker exec … backup.sh` 一份。
### Q4:日志占满磁盘
```bash
# 看 data/logs 实际大小
du -sh data/logs
# 清空(保留文件)
: > data/logs/access.log
: > data/logs/error.log
```
### Q5:想要外网访问
需要做两件事:
1. NAS 上做端口映射(5000 → 公网 IP + DDNS
2. **必须**在前面套一层 Nginx/Caddy 加 HTTPS(避免密码明文)
可参考 `deploy/nas/README.md`(如果后续加入 nginx 反代方案)。
---
## 9. 性能调优
NAS 通常内存有限(4-8 GB)。如遇卡顿:
```yaml
# docker-compose.yaml 中调小资源限制
deploy:
resources:
limits:
memory: 768M # 从 1024M 降到 768M
```
或减少 worker
```env
# .env 里
GUNICORN_WORKERS=1
GUNICORN_THREADS=16 # SQLite + WAL 单进程跑,线程数给够
```
**为什么默认 workers=1 threads=8 而不是 2×4**:SQLite 写串行;多进程提交事务时仍可能撞锁,gunicorn worker 越多事故面越大。单进程写最稳,线程吃满网络并发。你的场景下 8 线程绰绰有余,要更高把线程拉到 16 就行。

Some files were not shown because too many files have changed in this diff Show More