签入版本
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s

This commit is contained in:
2026-09-16 10:08:07 +08:00
commit fda0fe1a53
820 changed files with 97186 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
# 版本控制与 IDE
.git
.gitignore
.idea
.vscode
# Python 缓存与虚拟环境
__pycache__/
*.py[cod]
*.egg-info/
.venv
venv
env
# 测试与日志
tests
.pytest_cache
*.log
# 运行时数据(不进入镜像,compose 通过 ./uploads 挂载提供)
uploads
# 敏感配置不进入镜像:环境变量由运行时注入(docker compose env_file / docker run --env-file)
# .env 含数据库/Redis/JWT/短信等明文凭据,打进镜像即随分发泄露;仅保留占位模板 .env.example
#.env
.env.*
!.env.example
# 前端 UI 目录(按需排除,如不需要可删)
/ui
/uploads
+153
View File
@@ -0,0 +1,153 @@
# +======================================================================
# | 模块: 环境配置文件模板
# | 说明: 提供完整的 .env 配置示例,包含应用、数据库、缓存、JWT、验证码等
# +======================================================================
# ============================================================
# 基础配置
# ============================================================
# 应用名称
TORNADO_NAME = Tornado+EleVue旗舰版
# 应用版本
TORNADO_VERSION = v3.0.0
# 允许的跨域来源白名单(逗号分隔;留空则允许所有来源且不携带凭据)
TORNADO_CORS_ORIGINS =
# 应用运行地址
TORNADO_HOST = 127.0.0.1
# 应用运行端口
TORNADO_PORT = 8041
# 应用启动文件
TORNADO_APP = app.py
# 应用环境变量
TORNADO_ENV = development
# 是否DEBUG模式:是-True,否-False
TORNADO_DEBUG = True
# 是否DEMO模式:是-True,否-False
TORNADO_DEMO = False
# 应用文件上传路径(留空使用项目内 uploads 目录)
TORNADO_UPLOAD_DIR =
# 应用文件域名
TORNADO_FILE_URL = http://file.tornado.elevue
# ============================================================
# 上传配置
# ============================================================
# 允许上传的文件扩展名 - 逗号分隔,带点号
# 生产环境务必配置,防止上传.py、.exe、.sh等危险文件
UPLOAD_ALLOWED_EXTS = .jpg,.jpeg,.png,.gif,.bmp,.ico,.txt,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.mp3,.mp4,.zip,.rar,.7z
# 上传文件大小上限(MB)- 超过此大小将被拒绝上传
UPLOAD_MAX_SIZE_MB = 10
# ============================================================
# 数据库配置
# ============================================================
# 数据库驱动 - 可选 mysql/postgresql/mssql/sqlite/oracle
# (别名:postgres=postgresql、sqlserver=mssql、sqlite3=sqlite)
# 示例:
# MySQL: DB_DRIVER=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_USERNAME=root DB_PASSWORD=***
# PostgreSQL: DB_DRIVER=postgresql DB_HOST=127.0.0.1 DB_PORT=5432 DB_USERNAME=postgres DB_PASSWORD=***
# SQL Server: DB_DRIVER=mssql DB_HOST=127.0.0.1 DB_PORT=1433 DB_USERNAME=sa DB_PASSWORD=***
# SQLite: DB_DRIVER=sqlite DB_DATABASE=dev.db(此时 DB_DATABASE 即数据库文件完整路径,
# 可填相对/绝对路径如 C:/apps/dev.db,忽略 host/port/user/password)
# Oracle: DB_DRIVER=oracle DB_HOST=127.0.0.1 DB_PORT=1521 DB_USERNAME=system DB_PASSWORD=***
# (DB_DATABASE 填 SERVICE_NAME,如 XEPDB1)
# 数据库驱动
DB_DRIVER = mysql
# 数据库地址 - 数据库服务器IP或域名(sqlite 时忽略)
DB_HOST = 127.0.0.1
# 数据库端口 - MySQL:3306 / PostgreSQL:5432 / SQL Server:1433 / Oracle:1521 / sqlite 时忽略
DB_PORT = 3306
# 数据库名称 - 要连接的数据库名;DB_DRIVER=sqlite 时即数据库文件完整路径;
# DB_DRIVER=oracle 时填 SERVICE_NAME
DB_DATABASE = djangoadmin.tornado.elevue
# 数据库账号 - 连接数据库的用户名(sqlite 时忽略)
DB_USERNAME = root
# 数据库密码 - 连接数据库的密码(sqlite 时无需配置)
DB_PASSWORD = root
# 数据表前缀 - 所有数据表统一前缀,用于多应用或模块隔离
DB_PREFIX = tornado_
# 是否开启调试模式:是-True,否-False
DB_DEBUG = True
# ============================================================
# 缓存配置
# ============================================================
# 缓存服务地址
REDIS_HOST = 127.0.0.1
# 缓存服务端口
REDIS_PORT = 6379
# 缓存服务密码
REDIS_PASSWORD =
# 缓存库索引
REDIS_INDEX = 0
# AUTH 为 True 时需要进行 用户认证
REDIS_AUTH = False
# 是否对查询结果进行编码处理
REDIS_DECODE_RESPONSES = True
# ============================================================
# JWT 配置
# ============================================================
# JWT 密钥 - 用于令牌签名和验证,至少 32 字节,务必使用强随机密钥
# 生成方式:python -c "import secrets; print(secrets.token_urlsafe(48))"
# 切勿使用默认值,否则存在安全风险(启动 fail-close)!
JWT_SECRET = p3rMHeXiB7o0vQQO_K-Z2bJj0IBu6frYSEKcS8aovUxQtHBjD2SC53O7Sv30UVf7
# JWT 过期时间(分钟)
JWT_EXPIRE_MINUTES = 1440
# 登录失败次数上限 - 同一IP+用户名连续失败超过此次数后锁定
LOGIN_FAIL_MAX = 5
# 登录锁定时间(秒)- 锁定期间禁止该IP+用户名登录
LOGIN_LOCK_SECONDS = 300
# ============================================================
# 验证码配置
# ============================================================
# 验证码长度
CAPTCHA_LENGTH = 6
# 验证码有效期(秒)
CAPTCHA_EXPIRE = 300
# 防刷:同一 IP 在窗口期内最多获取验证码次数
CAPTCHA_RATE_LIMIT = 20
# 防刷滑动窗口(秒)
CAPTCHA_RATE_WINDOW = 60
# 字符集(去除易混淆字符,全大写便于大小写不敏感比较)
CAPTCHA_CHARS = 346789ABCDEFGHJKMNPQRTUVWXY
# 验证码图片尺寸与字号
CAPTCHA_IMAGE_WIDTH = 200
CAPTCHA_IMAGE_HEIGHT = 60
CAPTCHA_FONT_SIZE = 30
# 干扰线 / 噪点数量
CAPTCHA_NOISE_LINES = 10
CAPTCHA_NOISE_DOTS = 60
# 万能验证码(仅 TORNADO_DEMO=True 时生效,方便演示环境登录)
CAPTCHA_BYPASS_CODE = 618618
# ============================================================
# 邮件配置
# ============================================================
# 邮寄者
MAIL_MAILER = smtp
# 邮件服务
MAIL_SERVER = smtp.163.com
# 邮件端口
MAIL_PORT = 465
# 邮件SSL证书
MAIL_USE_SSL = True
# 授权邮箱用户名
MAIL_USERNAME =
# 授权邮箱授权码
MAIL_PASSWORD =
# 邮件加密串
MAIL_ENCRYPTION =
# 邮件发件人名称
MAIL_FROM_NAME =
# 邮件发件人地址
MAIL_FROM_ADDRESS =
+151
View File
@@ -0,0 +1,151 @@
# +======================================================================
# | 模块: 环境配置文件模板
# | 说明: 提供完整的 .env 配置示例,包含应用、数据库、缓存、JWT、验证码等
# +======================================================================
# ============================================================
# 基础配置
# ============================================================
# 应用名称
TORNADO_NAME=Tornado+EleVue旗舰版
# 应用版本
TORNADO_VERSION=v3.0.0
# 允许的跨域来源白名单(逗号分隔;留空则允许所有来源且不携带凭据)
TORNADO_CORS_ORIGINS=
# 应用运行地址
TORNADO_HOST=127.0.0.1
# 应用运行端口
TORNADO_PORT=8041
# 应用环境变量
TORNADO_ENV=development
# 是否DEBUG模式:是-True,否-False
TORNADO_DEBUG=True
# 是否DEMO模式:是-True,否-False(为True时所有写操作被拒绝)
TORNADO_DEMO=False
# 应用文件上传路径(留空使用项目内 uploads 目录)
TORNADO_UPLOAD_DIR=
# 应用文件域名(文件回显地址前缀)
TORNADO_FILE_URL=
# ============================================================
# 数据库配置
# ============================================================
# 数据库驱动
DB_DRIVER=mysql
# 数据库地址
DB_HOST=127.0.0.1
# 数据库端口
DB_PORT=3306
# 数据库名称
DB_DATABASE=djangoadmin.tornado.elevue
# 数据库账号
DB_USERNAME=root
# 数据库密码
DB_PASSWORD=
# 数据表前缀
DB_PREFIX=tornado_
# 是否开启SQL调试
DB_DEBUG=True
# 读库地址(读写分离,可选;留空则读写同库)
READ_DATABASE_URL=
# ============================================================
# 缓存配置
# ============================================================
# 缓存服务地址
REDIS_HOST=127.0.0.1
# 缓存服务端口
REDIS_PORT=6379
# 缓存服务密码
REDIS_PASSWORD=
# 缓存库索引
REDIS_INDEX=0
# AUTH 为 True 时需要进行 用户认证
REDIS_AUTH=True
# 是否对查询结果进行编码处理
REDIS_DECODE_RESPONSES=True
# ============================================================
# 邮件配置
# ============================================================
# 邮寄者
MAIL_MAILER=smtp
# 邮件服务
MAIL_SERVER=smtp.163.com
# 邮件端口
MAIL_PORT=465
# 邮件SSL证书
MAIL_USE_SSL=True
# 授权邮箱用户名
MAIL_USERNAME=
# 授权邮箱授权码
MAIL_PASSWORD=
# 邮件加密串
MAIL_ENCRYPTION=
# 邮件发件人名称
MAIL_FROM_NAME=
# 邮件发件人地址
MAIL_FROM_ADDRESS=
# ============================================================
# JWT 配置
# ============================================================
# JWT 密钥 - 用于令牌签名和验证,至少 32 字节,务必使用强随机密钥
# 生成方式:python -c "import secrets; print(secrets.token_urlsafe(48))"
# 切勿使用默认值或留空,否则启动将中止(fail-close)
JWT_SECRET=
# JWT 过期时间(分钟)
JWT_EXPIRE_MINUTES=20
# 登录失败次数上限 - 同一IP+用户名连续失败超过此次数后锁定
LOGIN_FAIL_MAX=5
# 登录锁定时间(秒)- 锁定期间禁止该IP+用户名登录
LOGIN_LOCK_SECONDS=300
# ============================================================
# 验证码配置
# ============================================================
# 验证码长度
CAPTCHA_LENGTH=6
# 验证码有效期(秒)
CAPTCHA_EXPIRE=300
# 防刷:同一 IP 在窗口期内最多获取验证码次数
CAPTCHA_RATE_LIMIT=20
# 防刷滑动窗口(秒)
CAPTCHA_RATE_WINDOW=60
# 字符集(去除易混淆字符,全大写便于大小写不敏感比较)
CAPTCHA_CHARS=346789ABCDEFGHJKMNPQRTUVWXY
# 验证码图片尺寸与字号
CAPTCHA_IMAGE_WIDTH=200
CAPTCHA_IMAGE_HEIGHT=60
CAPTCHA_FONT_SIZE=30
# 干扰线 / 噪点数量
CAPTCHA_NOISE_LINES=10
CAPTCHA_NOISE_DOTS=60
# 万能验证码(仅 TORNADO_DEMO=True 时生效,方便演示环境登录)
CAPTCHA_BYPASS_CODE=618618
# ============================================================
# 上传配置
# ============================================================
# 允许上传的文件扩展名 - 逗号分隔,带点号
# 生产环境务必配置,防止上传.py、.exe、.sh等危险文件
UPLOAD_ALLOWED_EXTS=.jpg,.jpeg,.png,.gif,.bmp,.ico,.txt,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.mp3,.mp4,.zip,.rar,.7z
# 上传文件大小上限(MB)- 超过此大小将被拒绝上传
UPLOAD_MAX_SIZE_MB=10
# ============================================================
# 日志配置
# ============================================================
# 日志级别:DEBUG/INFO/WARNING/ERROR
LOG_LEVEL=INFO
# 日志目录
LOG_DIR=logs
+4
View File
@@ -0,0 +1,4 @@
[flake8]
max-line-length = 120
exclude = .git,__pycache__,build,dist,.venv,alembic/versions
extend-ignore = E203,W503
+28
View File
@@ -0,0 +1,28 @@
---
name: Bug 报告
about: 反馈使用过程中遇到的问题
title: "[BUG] "
labels: bug
---
## 环境信息
- 版本号:
- 操作系统:
- Python 版本:
## 问题描述
<!-- 清晰描述你遇到的问题 -->
## 复现步骤
1.
2.
3.
## 期望行为
<!-- 你期望发生什么 -->
## 实际行为
<!-- 实际发生了什么,附上错误日志 -->
## 截图 / 日志
<!-- 如有请附上 -->
+18
View File
@@ -0,0 +1,18 @@
---
name: 功能建议
about: 提出新的功能需求或改进建议
title: "[FEATURE] "
labels: enhancement
---
## 需求背景
<!-- 为什么需要这个功能 -->
## 功能描述
<!-- 希望实现什么 -->
## 实现思路(可选)
<!-- 你期望的实现方式 -->
## 影响范围
<!-- 涉及哪些模块 / 是否需要同步修改前端 -->
+30
View File
@@ -0,0 +1,30 @@
name: CD
on:
push:
tags:
- "v*"
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ vars.REGISTRY }}/${{ vars.IMAGE_NAME }}:latest
${{ vars.REGISTRY }}/${{ vars.IMAGE_NAME }}:${{ github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max
+61
View File
@@ -0,0 +1,61 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "poetry"
- name: Install dependencies
run: |
pip install poetry
poetry install --with dev
- name: Lint (flake8)
run: poetry run flake8 src tests
- name: Type check (mypy)
run: poetry run mypy src
test:
runs-on: ubuntu-latest
needs: lint
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: djangoadmin_test
ports:
- 3306:3306
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "poetry"
- name: Install dependencies
run: |
pip install poetry
poetry install --with dev
- name: Run tests
env:
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_USERNAME: root
DB_PASSWORD: root
DB_DATABASE: djangoadmin_test
REDIS_HOST: 127.0.0.1
REDIS_PORT: 6379
run: poetry run pytest -v --cov=src --cov-report=term-missing
+29
View File
@@ -0,0 +1,29 @@
name: Security Scan
on:
schedule:
- cron: "0 2 * * 0" # 每周日凌晨 2 点
workflow_dispatch:
jobs:
pip-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install pip-audit
run: pip install pip-audit
- name: Audit dependencies
run: pip-audit -r <(poetry export --without-hashes 2>/dev/null || echo "requirements.txt")
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+31
View File
@@ -0,0 +1,31 @@
.DS_Store
.buildpath
.hgignore.swp
.project
.orig
.swp
.idea/
.settings/
.vscode/
vender/
composer.lock
gitpush.sh
pkg/
bin/
cbuild
*/.DS_Store
.vscode
.svn
evui/
document/*
__pycache__/
*.py[cod]
.pytest_cache/
*.egg-info/
dist/
venv/
.venv/
logs/
uploads/
+16
View File
@@ -0,0 +1,16 @@
masterProfiles: ['','uat','prod']
Profiles: ['','dev']
jobConfigFile: "djangoadminConfig.yml"
npmCompileCMD: |
export PATH=/usr/local/node/bin:$PATH
set -e \
&& yarn config set registry https://registry.npmmirror.com \
&& yarn config get registry \
&& yarn config set strict-ssl false \
&& yarn config set sass_binary_site https://repo.huaweicloud.com/node-sass/node-sass \
&& rm -rf package-lock.json \
&& rm -rf node_modules/.cache 2>/dev/null || true \
&& yarn cache clean --force \
&& yarn global add node-gyp@10.3.1 \
&& yarn install --unsafe-perm --legacy-peer-deps --network-timeout 300000 \
&& yarn run build:${PROFILE}
+141
View File
@@ -0,0 +1,141 @@
# ============================================================
# Dockerfile:项目镜像(多阶段构建)
# 基础镜像:python-base:3.12.8(RockyLinux 8 + Python 3.12.8 + mariadb-connector-c)
#
# 设计要点
# - builder 只编译依赖,装到 /opt/deps,不碰基础镜像的 Python 目录
# - runtime 继承基础镜像的 Python,只把依赖的 site-packages COPY 过来
# - MySQL 运行库(mariadb-connector-c)由基础镜像预置,runtime 无需重复安装
# - 用 COPY --chown 指定属主,避免额外的 chown 层
# - 源码 COPY 放在依赖之后,保护依赖层缓存
#
# 构建参数
# VAR_BASE_IMAGE 基础镜像
# VAR_PYTHON_PREFIX Python 安装前缀(与基础镜像一致)
# VAR_PYTHON_SHORT_VERSION Python 主次版本号,用于 site-packages 路径
# VAR_PYFILE_PATH 项目源码目录
# VAR_PROFILES 应用 profile(dev / prod)
# VAR_PORT 应用监听端口
# VAR_PYFILE 入口文件名(不含 .py)
# ============================================================
# ------------------------------------------------------------
# 全局构建参数
# 在 FROM 之前声明,可供后续各阶段引用
# ------------------------------------------------------------
ARG VAR_BASE_IMAGE
ARG VAR_PYTHON_PREFIX=/usr/local/python3.12.8
ARG VAR_PYTHON_SHORT_VERSION=3.12
# ============================================================
# 阶段一:builder —— 编译并安装项目依赖
# ============================================================
FROM ${VAR_BASE_IMAGE} AS builder
# 阶段内重新声明,否则读不到全局 ARG
# Docker 规则:全局 ARG 只在 FROM 行可见,阶段内使用必须重新声明
ARG VAR_PYTHON_PREFIX
ARG VAR_PYTHON_SHORT_VERSION
ARG VAR_PYFILE_PATH
# 基础镜像末尾是 esxi 用户;dnf/pip 需要写系统目录和 /opt/deps,
# 所以这里仍需 root
USER root
LABEL MAINTAINER="djangoadmin@126.com"
# 编译阶段环境变量
# PYTHONUNBUFFERED=1 日志实时输出
# PYTHONDONTWRITEBYTECODE=1 不生成 .pyc
# PIP_NO_CACHE_DIR=1 不缓存 wheel
# PIP_DISABLE_PIP_VERSION_CHECK=1 关闭 pip 版本检查
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# 安装编译工具链
# gcc / gcc-c++ / make 编译 C 扩展(mysqlclient、uwsgi 等)
# openssl-devel cryptography、_ssl 等
# bzip2-devel / libffi-devel
# mariadb-devel 编译 mysqlclient(提供 .pc 文件)
# pkgconf-pkg-config 提供 pkg-config 命令(mysqlclient 2.2.x 必需)
RUN set -eux; \
# 替换 dnf 源为阿里云镜像
sed -e 's|^mirrorlist=|#mirrorlist=|g' \
-e 's|^#baseurl=http://dl.rockylinux.org/$contentdir|baseurl=https://mirrors.aliyun.com/rockylinux|g' \
-i.bak /etc/yum.repos.d/*.repo; \
dnf install -y --setopt=install_weak_deps=False \
gcc gcc-c++ make \
openssl-devel bzip2-devel libffi-devel \
mariadb-devel pkgconf-pkg-config; \
dnf clean all; rm -rf /var/cache/dnf
# 配置 pip 国内镜像源(阿里云 PyPI)
RUN pip3 config set global.index-url http://mirrors.aliyun.com/pypi/simple && \
pip3 config set install.trusted-host mirrors.aliyun.com
# 只拷 requirements.txt
# 源码变化但依赖未变时,可复用 pip install 层缓存
WORKDIR /opt/apps
COPY ${VAR_PYFILE_PATH}/requirements.txt ./
# 依赖装到 /opt/deps,与基础镜像 Python 目录解耦
# --prefix=/opt/deps 安装到独立目录,runtime 只 COPY 这个目录的产物
# uwsgi 生产环境 WSGI 服务器,单独安装
RUN python3 -m pip install --prefix=/opt/deps -r requirements.txt && \
python3 -m pip install --prefix=/opt/deps uwsgi
# ============================================================
# 阶段二:runtime —— 最终业务镜像
# ============================================================
FROM ${VAR_BASE_IMAGE} AS runtime
# 阶段内重新声明
ARG VAR_PYTHON_PREFIX
ARG VAR_PYTHON_SHORT_VERSION
ARG VAR_PROFILES
ARG VAR_PORT
ARG VAR_PYFILE
ARG VAR_PYFILE_PATH
LABEL MAINTAINER="djangoadmin@126.com"
# 运行期环境变量
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# ARG 转 ENV,运行期可读取
# PORT 供 EXPOSE 与 CMD 使用
# PYFILE 入口文件名
# PROFILES 应用读取的 profile
ENV PORT=${VAR_PORT} \
PYFILE=${VAR_PYFILE} \
PROFILES=${VAR_PROFILES}
# 把 builder 编译好的 site-packages 和可执行文件拷到基础镜像 Python 对应目录
# 目标路径必须与基础镜像 Python 的实际 site-packages 一致
# 用 COPY --chown 指定属主,避免额外的 chown 层
COPY --chown=esxi:esxi --from=builder \
/opt/deps/lib/python${VAR_PYTHON_SHORT_VERSION}/site-packages/ \
${VAR_PYTHON_PREFIX}/lib/python${VAR_PYTHON_SHORT_VERSION}/site-packages/
COPY --chown=esxi:esxi --from=builder /opt/deps/bin/ ${VAR_PYTHON_PREFIX}/bin/
WORKDIR /opt/apps
# 拷项目源码(放在依赖之后,保护 pip 层缓存)
# 建议配合 .dockerignore 排除 .git、__pycache__、本地虚拟环境等
COPY --chown=esxi:esxi ${VAR_PYFILE_PATH}/ ./
USER esxi
EXPOSE ${PORT}
# 启动命令
# sh -c 以 shell 形式执行,可展开 ${PORT} 等环境变量
# ${PYFILE} 入口文件名(不含 .py),由 ENV PYFILE 注入
# ${PORT} 监听端口,由 ENV PORT 注入,运行期可 -e PORT=xxx 覆盖
# poetry run 在 poetry 虚拟环境中运行,保证依赖版本一致
CMD ["sh", "-c", "poetry run python app.py --port=${PORT}"]
+3
View File
@@ -0,0 +1,3 @@
@Library('share-library@master')_
orgName = 'djangoadmin'
pipelinePythonService(orgName)
+3
View File
@@ -0,0 +1,3 @@
@Library('share-library@master')_
orgName = 'djangoadmin'
pipelineNodeService(orgName)
+5
View File
@@ -0,0 +1,5 @@
[mypy]
python_version = 3.10
ignore_missing_imports = True
warn_unused_ignores = False
exclude = alembic/versions
+21
View File
@@ -0,0 +1,21 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-merge-conflict
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.2
hooks:
- id: ruff
args: [--fix]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
additional_dependencies: [pydantic, pydantic-settings]
+25
View File
@@ -0,0 +1,25 @@
# 版本变更日志
本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/) 规范。
## [v3.0.0] - 2026-08-14
### 架构重构
- 采用 `src/` 布局,引入 Poetry 依赖管理与项目打包
- 引入 DDD 分层:`domain/`(实体、值对象、领域事件、仓储接口)
- 配置迁移至 Pydantic Settings(`.env` 驱动)
- 表单校验由 WTForms 迁移至 Pydantic schemas
- 密码加密由 MD5 升级为 bcrypt(兼容旧 MD5 密码自动升级)
- 数据访问引入 Repository 模式与读写分离支持
- API 统一挂载 `/api/v1` 前缀
- 新增核心基础设施:异常体系、事件总线、缓存管理器、结构化日志、统一响应
### 增强
- 新增健康检查端点 `/api/v1/health`
- 新增优雅关闭、链路追踪 ID、操作日志、限流中间件
- 新增 pytest 测试骨架与 CI/CD 流水线
- 新增部署配置(nginx/supervisor/prometheus/grafana/kubernetes)
## [v2.7.0] - 历史版本
- 见 `document/版本更新说明.txt`
+78
View File
@@ -0,0 +1,78 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
DjangoAdmin Tornado+EleVue is a modular enterprise admin framework. This repo is the **backend only** — the Tornado REST API server. The Vue2.x + ElementUI frontend ("EleVue") is a separate project not in this repo (`static/` and `templates/` are mostly empty). Code comments are written in Chinese.
Key stack: Tornado 6.x, SQLAlchemy 2.0, Redis, JWT auth, **Pydantic v2** (settings + validation schemas), **bcrypt** (double-salt password hashing). Python ≥3.10, managed via Poetry. The code lives under `src/` (`src` is added to `sys.path` by the root `app.py` entry and by pytest's `pythonpath`).
**Multi-database support**: default driver is MySQL (`pymysql`, installed by default). PostgreSQL / SQL Server / SQLite / Oracle are supported via optional drivers — `poetry install -E postgresql` (psycopg2-binary) / `-E mssql` (pymssql) / `-E oracle` (oracledb); SQLite needs no extra install. Select via `DB_DRIVER` in `.env` (aliases: `postgres=postgresql`, `sqlserver=mssql`, `sqlite3=sqlite`); see `.env` for per-DB connection examples. When `DB_DRIVER=sqlite`, `DB_DATABASE` is the DB **file path** (host/port/user/password ignored); when `DB_DRIVER=oracle`, `DB_DATABASE` is the **SERVICE_NAME**. Engine/connection-pool options are dialect-aware in `src/core/database.py` (`_connect_args`, SQLite uses `NullPool` + `check_same_thread=False`). `document/djangoadmin.tornado.elevue.sql` is **MySQL-only** — for non-MySQL DBs create the schema via `alembic upgrade head` (`Base.metadata.create_all`) and seed via `scripts/seed.py`. The three join queries that used raw SQL+backticks (permission/menu/user services) were rewritten with SQLAlchemy ORM to stay dialect-agnostic.
> ⚠️ Two files named `app.py` exist intentionally: root `app.py` is the thin **entry script** (runs as `__main__`), `src/app.py` is the **app factory** (`create_app()`). Never `import app` from a context where the project root is `sys.path[0]` (e.g. pytest) — use `from src.app import create_app` there.
## Common Commands
```bash
poetry install # 安装依赖(含 dev)
poetry run python app.py --port=8041 # 启动服务(默认端口 8041)
poetry run pytest # 全部测试(集成测试无服务时自动跳过)
poetry run pytest tests/unit # 仅单元测试
poetry run flake8 src tests # 代码规范检查
poetry run mypy src # 类型检查
poetry run ruff check --fix src tests
poetry run alembic upgrade head # 数据库迁移
python scripts/seed_dict.py # 初始化业务字典(dict/dict_item,幂等)
python scripts/migrate_db.py --help # 跨库数据迁移(MySQL → PG/MSSQL/SQLite/Oracle,参数显式给出,不依赖 .env)
make install / dev / test / lint # Makefile 封装
```
Gotchas:
- Configuration comes from `.env` via Pydantic Settings (`src/config/settings.py`). Keys: `TORNADO_*`, `DB_*`, `REDIS_*`, `MAIL_*`, `JWT_*`, `CAPTCHA_*`, `UPLOAD_*`, `LOGIN_*`, `LOG_*`. `TORNADO_DEMO=True` blocks all write endpoints. **`JWT_SECRET` 必须配置**(≥32 字节强随机,缺失/过弱启动即 fail-close 中止;生成:`python -c "import secrets; print(secrets.token_urlsafe(48))"`)。上传白名单/上限:`UPLOAD_ALLOWED_EXTS`(逗号分隔带点号)、`UPLOAD_MAX_SIZE_MB`。登录防爆破:`LOGIN_FAIL_MAX` / `LOGIN_LOCK_SECONDS`。
- 业务枚举字典(性别/友链/通知来源等)统一由 `scripts/seed_dict.py` 写入 `dict`/`dict_item` 表,业务代码经 `common/utils/dict_util.py` 读取(Redis 缓存,字典维护时自动失效)。`config/constants.py` 不再承载业务枚举,仅保留 `SERIALIZE_EXCLUDE_FIELDS`。
- The database schema is created from `document/djangoadmin.tornado.elevue.sql` (import into MySQL). Alembic is wired up (`alembic/env.py` aggregates `src/modules.*.models`) and can autogenerate.
- `requirements.txt` is a plain UTF-8 fallback; Poetry (`pyproject.toml` + `poetry.lock`) is the source of truth.
## Architecture
Layered design. A request flows: Tornado handler (`src/modules/<name>/handlers.py`) → business logic (`service.py`) → repository (`repository.py`) → SQLAlchemy model (`models.py`). Input is validated with Pydantic schemas (`schemas.py`).
Startup: root `app.py` → `src/bootstrap.py:main()` (parses `--port`, installs SIGINT/SIGTERM graceful shutdown) → `src/app.py:create_app()` builds the `tornado.web.Application` with routes from `src/api/v1/router.py:build_routes()`.
### src/ packages
- **`src/config/`** — `settings.py` (Pydantic Settings, computes `DATABASE_URL`/`UPLOAD_DIR`/`SECRET_KEY`), `constants.py` (仅 `SERIALIZE_EXCLUDE_FIELDS`;业务枚举已迁至 dict 表,经 `common/utils/dict_util.py` 读取), `messages.py` (含 `PAGE_LIMIT`/`MAX_PAGE_LIMIT`), `logging.py` (JSON structured logs).
- **`src/core/`** — infrastructure: `response.py` (R.ok/R.failed/R.response → `{code, data, msg, ok}`; `Codes` class: SUCCESS=0/FAILED=1/UNAUTHORIZED=401/FORBIDDEN=403/…; 写完响应并把 body 存到 `self._api_result` 供日志装饰器解析,函数返回 `None`), `exceptions.py` (BaseAppError hierarchy + `write_error` 记录异常日志), `jwt.py` (JWT 签发/解析/黑名单/刷新), `database.py` (writer + optional reader engine, thread-local `scoped_session` `db`, `Base`), `redis.py`, `logger.py`, `password.py` (double-salt bcrypt), `context.py` (`current_user_id`/`current_username`/`current_realname`/`current_trace_id` contextvars), and base classes: `BaseHandler`(`on_finish` 统一关闭共享 DB 会话), `BaseRepository` (generic CRUD + soft delete + audit; see conventions), `BaseService` (template-method CRUD — see below).
- **`src/modules/`** — the ~20 business modules (auth, user, role, menu, dept, dict, config, level, position, city, notice, link, category, article, param, file_template, job, upload, operation_log, login_log). Each is a six-file package: `models.py` (SQLAlchemy, `core.base_model.BaseModel` gives `id`/`create_user`(String50)/`create_time`/`update_user`/`update_time`/`is_delete` + `to_dict()` which **excludes `password`/`salt`/`is_delete`** and is timezone-defensive), `schemas.py` (Pydantic `XxxSchema`/`XxxStatusSchema`, `extra="ignore"`), `repository.py` (extends `BaseRepository`, sets `model`), `service.py` (a `XxxService(BaseService)` subclass + `xxx_service` instance; only auth/upload keep fully bespoke functions — login, file upload), `handlers.py` (Tornado handlers extending `BaseHandler`, delegating to `xxx_service`), `__init__.py`. `dict` bundles Dict+DictItem, `config` bundles Config+ConfigItem+ConfigWeb, `role` bundles RoleMenu, `user` bundles UserRole and the profile/index handlers. `operation_log` / `login_log` are full audit-log modules: `@operation_log` records write ops (rich fields + param redaction + **IP 归属地**) and `@login_log` records login success/failure/logout; both expose list/detail/delete endpoints under `/api/v1/operationlog` and `/api/v1/loginlog`. Permission nodes (menu rows with `type=1`) are resolved by `src/common/permission.py:get_permissions_list`(Redis 缓存,角色/菜单变更按版本号失效).
- **`src/common/`** — `middleware/` (authentication/cors/operation_log/login_log/trace_id, plus `access_decorators.py` holding `@check_demo` + `@permission_required`), `utils/` (crypto bcrypt, captcha, file [`mkdir` + 魔术字节校验 `verify_file_magic`], rich_text [`save_file`/`get_file_url`/`save_content`,XSS 白名单清洗 + 路径穿越防护,图片/文件保存统一入口], ip2region [IP→省市区,日志归属地], dict_util [字典 Redis 缓存读取], helpers [`parse_id_list`/`detect_os`/`detect_browser`/`detect_source`/`redact_params`], validators), `permission.py` (跨模块权限解析).
- **`src/extensions/`** — re-exports core singletons (`sqlalchemy.py` → `Base`/`db`/`SessionLocal`/`engine`/`reader_engine`/`ReaderSession`; `redis.py` → `redis`) and placeholder `swagger.py`/`sentry.py`.
- **`src/api/v1/`** — one route module per resource (`auth.py`, `users.py`, `roles.py`, …), each exporting `routes`; `router.py:build_routes()` prefixes all with `/api/v1` and adds `/api/v1/health`.
### Request lifecycle / auth
- Every business handler subclasses `src/core/base_handler.py:BaseHandler`. Its `prepare()` is wrapped with `@login_required`, so **every request except `/api/v1/login`, `/api/v1/captcha`, and `OPTIONS` requires a valid JWT** in `Authorization: Bearer <token>`. The authenticated `userId` is stored in `current_user_id` (a contextvar) via `src/common/middleware/authentication.py`.
- Per-handler authorization uses `@permission_required("sys:user:list")` (`src/common/middleware/access_decorators.py`), which checks the user's permission nodes via `common.permission.get_permissions_list`(Redis 缓存,角色/菜单/用户角色变更时按版本号失效). **User id `1` (admin) bypasses all permission checks.**
- Handlers are thin: mutating endpoints are wrapped with `@check_demo` (`src/common/middleware/access_decorators.py`), which blocks writes with a unified message when `TORNADO_DEMO=True`; handlers then delegate to the module's service. Responses go through `R.ok`/`R.failed`.
- 登录/登出安全:登录做防爆破(同一 IP+用户名 连续失败 `LOGIN_FAIL_MAX` 次后锁定 `LOGIN_LOCK_SECONDS` 秒,Redis 计数);登出把当前 token 加入黑名单立即失效;`POST /api/v1/token/refresh` 可轮换签发新 token(旧 token 拉黑)。`/api/v1/dict/data/{code}` 提供字典下拉数据。
### BaseService (template-method CRUD)
Simple modules subclass `src/core/base_service.py:BaseService` and only **declare difference points** to get standard CRUD for free: `model`/`repo_cls`, Pydantic `create_schema`/`update_schema`/`status_schema`, `page_like_fields`/`page_eq_fields`/`page_order_by` (pagination filters & sort), `unique_fields` (`{field: message}` uniqueness check), `serialize_maps` (`{field: dict 字面映射或字典编码字符串}`;str 值优先按 `dict_util` 字典编码解析,回落 `config.constants` 常量名;取值时兼容 int/str key), `serialize_extra`/`detail_serialize` (plain callables `(item) -> dict` for extra fields), `options_filter` (dropdown-list equality filter). Hooks to override for bespoke logic: `_before_add`/`_before_update`/`_before_delete`, `_build_create_fields`/`_build_update_fields` (update excludes `None` values so optional fields aren't NULLed). Standard methods: `get_page`(`_parse_pagination` 限制 page≥1、limit∈[1,`MAX_PAGE_LIMIT`]), `get_detail`, `get_options`, `add`, `update`, `delete`, `update_status` (all take the Tornado handler, return `R` objects). Heavily-custom modules (`user`, `menu`) override `add`/`update`/`get_page`/`_serialize_detail`/`_build_page_query` to handle roles/city/permission-nodes; only auth/upload keep fully bespoke functions.
### Service / repository conventions
- Repositories (`src/core/base_repository.py`): soft-delete convention (`is_delete=0` = active). Convenience methods (`get_by_id` (ID-robust), `get_by_ids`, `get_one`, `all`, `count`, `exists`, `exists_by_field(field, value, exclude_id)`, `filter_by`, `paginate`, `create`, `update`, `soft_delete`, `batch_delete("1,2,3")`) auto-append the soft-delete filter; `filter(*criterion)` is the raw escape hatch (no soft filter) for complex conditions. `create`/`update` auto-fill audit fields (`create_user`/`update_user`) from `current_realname`(操作者姓名).
- `BaseService` standard methods wrap in `try/finally: repo.close()`; bespoke services call `db.close()` in a `finally` block.
- List handlers use `page`/`limit` query args (default `PAGE_LIMIT=10`); deletion endpoints accept comma-separated IDs; all deletes are soft (`is_delete=1`).
- Passwords are **always salted** via the **double-salt bcrypt** scheme in `src/core/password.py` (`generate_salt`/`encrypt_password`/`verify_password`); the salt is stored in the user `salt` column. `src/common/utils/crypto.py` is the facade: `set_password(password) -> (salt, hashed)` and `check_password(password, salt, hashed)` for setting/verifying, plus `verify_user_password`/`is_legacy_hash` for the login path. Legacy **no-salt bcrypt** hashes are accepted on login and **auto-upgraded to the salt scheme** (`modules/auth/service.py:_verify_login_password`). MD5 is intentionally not supported.
- `BaseModel.to_dict()` excludes `password`/`salt`/`is_delete` (`config.constants.SERIALIZE_EXCLUDE_FIELDS`) — never return raw model `to_dict()` output containing secrets to the API. Model instances do **not** expose `save()/delete()` — data access goes through repositories.
### API / URL conventions
- All routes are under `/api/v1`. Handler naming: `XxxListHandler`, `XxxDetailHandler`, `XxxAddHandler`, `XxxUpdateHandler`, `XxxDeleteHandler`, `XxxStatusHandler`, `XxxGetListHandler` (last returns an option list for dropdowns). Route conventions preserved from the original app: `/list?`, `/detail/(\d+)`, `/add`, `/update`, `/delete/(\d+)`, `/status`, `/getXxxList`.
- To add a new feature: copy an existing simple module (e.g. `link`) across the six files, register a route module in `src/api/v1/`, and import it in `router.py`.
## Tests
`tests/unit` covers core (jwt, response, crypto, BaseService, repository, serialization-security) and module schemas. `tests/integration` (health/captcha/DB/Redis) runs only when MySQL and Redis are reachable — verified via a real `SELECT 1`/`ping` in `tests/integration/conftest.py`, otherwise skipped. The root `conftest.py` was removed; pytest resolves `src` via `[tool.pytest.ini_options].pythonpath = ["src"]`, and integration tests import the factory as `from src.app import create_app`.
+38
View File
@@ -0,0 +1,38 @@
# 贡献指南
感谢您对 DjangoAdmin Tornado+EleVue 项目的关注!
## 开发环境
```bash
# 安装依赖
poetry install
# 启动服务(默认端口 8041)
poetry run python app.py
```
## 代码规范
- 运行 `poetry run flake8 src tests` 检查代码规范
- 运行 `poetry run mypy src` 做类型检查
- 运行 `poetry run ruff check --fix src tests` 自动格式化
- 提交前建议安装 pre-commit 钩子:`poetry run pre-commit install`
## 测试
```bash
# 单元测试
poetry run pytest tests/unit
# 全部测试
poetry run pytest
```
集成测试需要本地 MySQL 与 Redis 服务,未配置时自动跳过。
## 提交规范
- 提交信息使用中文,遵循语义化描述(feat/fix/refactor/docs/test/chore)
- 涉及新功能请同时补充测试
- 涉及 API 变更请同步更新 `document/api/swagger.yaml`
+44
View File
@@ -0,0 +1,44 @@
# ======================================================================
# 多阶段构建:Stage 1 构建依赖 -> Stage 2 运行
# ======================================================================
FROM python:3.12-slim AS builder
WORKDIR /build
# 复制依赖清单
COPY pyproject.toml poetry.lock* ./
# 安装 poetry 并导出 requirements
RUN pip install --no-cache-dir poetry \
&& poetry export -f requirements.txt -o requirements.txt --without-hashes
# ======================================================================
FROM python:3.12-slim AS runtime
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
TZ=Asia/Shanghai
WORKDIR /data/apps
# 安装运行依赖
COPY --from=builder /build/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# 复制项目文件
COPY src ./src
COPY app.py ./
COPY alembic ./alembic
COPY alembic.ini ./
COPY .env.example ./.env
COPY scripts ./scripts
COPY templates ./templates
COPY static ./static
# 创建运行时目录
RUN mkdir -p uploads logs
EXPOSE 8041
# 启动应用
CMD ["python", "app.py"]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+54
View File
@@ -0,0 +1,54 @@
.PHONY: install dev test test-unit test-integration lint format migrate seed health build up down
# 安装依赖
install:
poetry install
# 开发运行
dev:
poetry run python app.py
# 运行全部测试
test:
poetry run pytest
# 单元测试
test-unit:
poetry run pytest tests/unit
# 集成测试
test-integration:
poetry run pytest tests/integration
# 代码规范检查
lint:
poetry run flake8 src tests
poetry run mypy src
# 代码自动格式化
format:
poetry run ruff check --fix src tests
# 数据库迁移
migrate:
poetry run alembic upgrade head
# 填充测试数据
seed:
poetry run python scripts/seed.py
# 健康检查
health:
poetry run python scripts/health_check.py
# Docker 构建
build:
docker compose build
# Docker 启动
up:
docker compose up
# Docker 停止
down:
docker compose down
+36
View File
@@ -0,0 +1,36 @@
# DjangoAdmin_Tornado_EleVue
#### Description
一款 Python 语言基于Tornado、Vue2.x、ElementUI、MySQL等框架精心打造的一款模块化、高性能、企业级的敏捷开发框架,本着简化开发、提升开发效率的初衷触发,框架自研了一套个性化的组件,实现了可插拔的组件式开发方式:单图上传、多图上传、下拉选择、开关按钮、单选按钮、多选按钮、图片裁剪等等一系列个性化、轻量级的组件,是一款真正意义上实现组件化开发的敏捷开发框架。
#### Software Architecture
Software architecture description
#### Installation
1. xxxx
2. xxxx
3. xxxx
#### Instructions
1. xxxx
2. xxxx
3. xxxx
#### Contribution
1. Fork the repository
2. Create Feat_xxx branch
3. Commit your code
4. Create Pull Request
#### Gitee Feature
1. You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md
2. Gitee blog [blog.gitee.com](https://blog.gitee.com)
3. Explore open source project [https://gitee.com/explore](https://gitee.com/explore)
4. The most valuable open source project [GVP](https://gitee.com/gvp)
5. The manual of Gitee [https://gitee.com/help](https://gitee.com/help)
6. The most popular members [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)
+125
View File
@@ -0,0 +1,125 @@
## 📚 项目介绍
一款 Python 语言基于Tornado、Vue2.x、ElementUI、MySQL等框架精心打造的一款模块化、高性能、企业级的敏捷开发框架,本着简化开发、提升开发效率的初衷触发,框架自研了一套个性化的组件,实现了可插拔的组件式开发方式:单图上传、多图上传、下拉选择、开关按钮、单选按钮、多选按钮、图片裁剪等等一系列个性化、轻量级的组件,是一款真正意义上实现组件化开发的敏捷开发框架。
## 🍻 项目特点
+ 模块化、松耦合
+ 模块丰富、开箱即用
+ 简洁易用、快速接入
+ 文档详尽、易于维护
+ 自顶向下、体系化设计
+ 统一框架、统一组件、降低选择成本
+ 开发规范、设计模式、代码分层模型
+ 强大便捷的开发工具链
+ 完善的本地中文化支持
+ 设计为团队及企业使用
## 🍪 内置模块
+ 用户管理:用于维护管理系统的用户,常规信息的维护与账号设置。
+ 角色管理:角色菜单管理与权限分配、设置角色所拥有的菜单权限。
+ 菜单管理:配置系统菜单,操作权限,按钮权限标识等。
+ 职级管理:主要管理用户的职级。
+ 岗位管理:主要管理用户担任职务。
+ 部门管理:配置系统组织机构(公司、部门、小组),树结构展现支持数据权限。
+ 字典管理:对系统中常用的较为固定的数据进行统一维护。
+ 配置管理:对系统的常规配置信息进行维护,网站配置管理功能进行统一维护。
+ 通知公告:系统通知公告信息发布维护。
## 👷 软件信息
* 软件名称:DjangoAdmin敏捷开发框架Tornado+EleVue版本
* 软件作者:@小蚂蚁云 团队荣誉出品
* 软件出处:小蚂蚁云团队
* 软件协议:Apache-2.0
* 官网网址:[https://www.djangoadmin.cn](https://www.djangoadmin.cn)
* 文档网址:[http://docs.tornado.elevue.djangoadmin.cn](http://docs.tornado.elevue.djangoadmin.cn)
## 🎨 系统演示
+ 演示地址:http://manage.tornado.elevue.djangoadmin.cn
账号 | 密码| 操作权限
---|---|---
admin | 123456| 演示环境无法进行修改删除操作
## 📌 版本说明
版本名称 | 版本说明 | 版本地址
---|---|---
Django+Layui混编版 | 采用Django、Layui等框架研发 | https://gitee.com/djangoadmin/DjangoAdmin_Django_Layui
Flask+Layui混编版 | 采用Flask、Layui等框架研发 | https://gitee.com/djangoadmin/DjangoAdmin_Flask_Layui
FastAPI+Layui混编版 | 采用FastAPI、Layui等框架研发 | https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_Layui
Tornado+Layui混编版 | 采用Tornado、Layui等框架研发 | https://gitee.com/djangoadmin/DjangoAdmin_Tornado_Layui
Django+EleVue前后端分离版 | 采用Django、Vue2.x、ElementUI等框架研发前后端分离版本 | https://gitee.com/djangoadmin/DjangoAdmin_Django_EleVue
Flask+EleVue前后端分离版 | 采用Flask、Vue2.x、ElementUI等框架研发前后端分离版本 | https://gitee.com/djangoadmin/DjangoAdmin_Flask_EleVue
FastAPI+EleVue前后端分离版 | 采用FastAPI、Vue2.x、ElementUI等框架研发前后端分离版本 | https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_EleVue
Tornado+EleVue前后端分离版 | 采用Tornado、Vue2.x、ElementUI等框架研发前后端分离版本 | https://gitee.com/djangoadmin/DjangoAdmin_Tornado_EleVue
Django+AntdVue前后端分离版 | 采用Django、Vue3.x、AntDesign等框架研发前后端分离版本 | https://gitee.com/djangoadmin/DjangoAdmin_Django_AntdVue
Flask+AntdVue前后端分离版 | 采用Flask、Vue3.x、AntDesign等框架研发前后端分离版本 | https://gitee.com/djangoadmin/DjangoAdmin_Flask_AntdVue
FastAPI+AntdVue前后端分离版 | 采用FastAPI、Vue3.x、AntDesign等框架研发前后端分离版本 | https://gitee.com/djangoadmin/DjangoAdmin_Fastapi_AntdVue
Tornado+AntdVue前后端分离版 | 采用Tornado、Vue、AntDesign等框架研发前后端分离版本 | https://gitee.com/djangoadmin/DjangoAdmin_Tornado_AntdVue
## 🔧 模块展示
![效果图](./uploads/demo/1.png)
![效果图](./uploads/demo/2.png)
![效果图](./uploads/demo/3.png)
![效果图](./uploads/demo/4.png)
![效果图](./uploads/demo/5.png)
![效果图](./uploads/demo/6.png)
![效果图](./uploads/demo/7.png)
![效果图](./uploads/demo/8.png)
![效果图](./uploads/demo/9.png)
![效果图](./uploads/demo/10.png)
![效果图](./uploads/demo/11.png)
![效果图](./uploads/demo/12.png)
![效果图](./uploads/demo/13.png)
![效果图](./uploads/demo/14.png)
![效果图](./uploads/demo/15.png)
![效果图](./uploads/demo/16.png)
![效果图](./uploads/demo/17.png)
![效果图](./uploads/demo/18.png)
![效果图](./uploads/demo/19.png)
![效果图](./uploads/demo/20.png)
![效果图](./uploads/demo/21.png)
![效果图](./uploads/demo/22.png)
## ✨ 特别鸣谢
感谢[Tornado](https://www.tornadoweb.org/en/stable/)、[Vue](https://cn.vuejs.org/)、[ElementUI](https://element.eleme.cn/#/)等优秀开源项目。
## 📚 版权信息
商业版使用需授权,未授权禁止恶意传播和用于商业用途,任何个人、单位、组织授权所获取的框架源码禁止分享、转售、恶意传播、开源(包括框架源码及二次开发后的产品)、禁止简单更改包装声称是自己的产品,否则将追究相关人的法律责任。
本软件框架禁止任何单位和个人用于任何违法、侵害他人合法利益等恶意的行为,禁止用于任何违反我国法律法规的一切平台研发,任何单位和个人使用本软件框架用于产品研发而产生的任何意外、疏忽、合约毁坏、诽谤、版权或知识产权侵犯及其造成的损失 (
包括但不限于直接、间接、附带或衍生的损失等),本团队不承担任何法律责任。本软件框架只能用于公司和个人内部的法律所允许的合法合规的软件产品研发,详细声明内容请阅读《框架免责声明》附件;
本项目包含的第三方源码和二进制文件之版权信息另行标注。
版权所有Copyright © 2021~2026 [djangoadmin.cn](https://www.djangoadmin.cn) All rights reserved。
更多细节参阅 [LICENSE](LICENSE)
+111
View File
@@ -0,0 +1,111 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
;sqlalchemy.url = driver://user:pass@localhost/dbname
; DATABASE_URL is set dynamically in alembic/env.py from config/database.py
;sqlalchemy.url = mysql+pymysql://user:pass@host:port/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+117
View File
@@ -0,0 +1,117 @@
# +======================================================================
# | 模块: Alembic 迁移环境
# | 说明: 配置数据库连接与模型 metadata,支持离线/在线两种迁移模式
# +======================================================================
"""Alembic 迁移环境。
- 数据库 URL 取自 src.config.database.DATABASE_URL
- 自动聚合 src.modules.*.models 中注册的模型 metadata,支持 autogenerate
"""
# ----------------------------------------------------------------------
# 导入依赖
# 涵盖:模块搜索路径、日志配置、路径处理、SQLAlchemy 引擎与连接池、
# Alembic 上下文
# ----------------------------------------------------------------------
import sys
from logging.config import fileConfig
from pathlib import Path
from sqlalchemy import create_engine, pool
from alembic import context
# ============================================================
# 将 src 目录加入 sys.path
# ============================================================
# 将 src 目录加入 sys.path,确保可直接 import 项目代码
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
from config.database import DATABASE_URL
# ============================================================
# 聚合全部模型 metadata
# ============================================================
# 聚合全部模型 metadata(导入各模块 models 即完成注册)
from extensions.sqlalchemy import Base
import modules.auth.models
import modules.user.models
import modules.user_role.models
import modules.role.models
import modules.role_menu.models
import modules.menu.models
import modules.dept.models
import modules.category.models
import modules.article.models
import modules.param.models
import modules.file_template.models
import modules.job.models
import modules.dictionary.dict.models
import modules.dictionary.dict_item.models
import modules.configuration.config.models
import modules.configuration.config_item.models
import modules.level.models
import modules.position.models
import modules.city.models
import modules.notice.models
import modules.link.models
import modules.operation_log.models
import modules.login_log.models
import modules.job_log.models
import modules.example.models
# ============================================================
# 读取 Alembic 配置
# ============================================================
# 读取 alembic.ini 中的配置
config = context.config
# 存在配置文件时加载其日志配置
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# 目标 metadata(autogenerate 依据)
target_metadata = Base.metadata
# 数据库 URL
database_url = DATABASE_URL
# ======================================================================
# 离线模式:仅生成 SQL 脚本
# ======================================================================
def run_migrations_offline() -> None:
"""离线模式:仅生成 SQL 脚本。"""
context.configure(
url=database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
# ======================================================================
# 在线模式:直接执行迁移
# ======================================================================
def run_migrations_online() -> None:
"""在线模式:直接执行迁移。"""
# NullPool:迁移结束后即释放连接,无需连接池
connectable = create_engine(database_url, poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
# ============================================================
# 入口:按模式执行
# ============================================================
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
+635
View File
@@ -0,0 +1,635 @@
"""初始化
Revision ID: d6e1fac26659
Revises:
Create Date: 2026-09-15 17:51:25.310996
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd6e1fac26659'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('tornado_article',
sa.Column('category_id', sa.Integer(), nullable=True, comment='分类ID'),
sa.Column('title', sa.String(length=150), nullable=True, comment='文章标题'),
sa.Column('cover', sa.String(length=255), nullable=True, comment='文章封面'),
sa.Column('images', sa.Text(), nullable=True, comment='文章图集'),
sa.Column('intro', sa.String(length=255), nullable=True, comment='文章导读'),
sa.Column('content', sa.Text(), nullable=True, comment='文章内容'),
sa.Column('author', sa.String(length=100), nullable=True, comment='文章作者'),
sa.Column('status', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='文章状态:0-正常 1-下架'),
sa.Column('click', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='文章点击率'),
sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='文章排序'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='文章表'
)
op.create_index(op.f('ix_tornado_article_category_id'), 'tornado_article', ['category_id'], unique=False)
op.create_index(op.f('ix_tornado_article_is_delete'), 'tornado_article', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_article_status'), 'tornado_article', ['status'], unique=False)
op.create_index(op.f('ix_tornado_article_title'), 'tornado_article', ['title'], unique=False)
op.create_table('tornado_category',
sa.Column('category_name', sa.String(length=100), nullable=False, comment='分类名称'),
sa.Column('category_code', sa.String(length=100), nullable=True, comment='分类编码'),
sa.Column('parent_id', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='上级ID'),
sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='排序'),
sa.Column('note', sa.String(length=100), nullable=True, comment='分类备注'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='文章分类表'
)
op.create_index(op.f('ix_tornado_category_category_name'), 'tornado_category', ['category_name'], unique=False)
op.create_index(op.f('ix_tornado_category_is_delete'), 'tornado_category', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_category_parent_id'), 'tornado_category', ['parent_id'], unique=False)
op.create_table('tornado_city',
sa.Column('city_code', sa.String(length=6), nullable=False, comment='城市区号'),
sa.Column('area_code', sa.String(length=20), nullable=False, comment='行政编码'),
sa.Column('parent_code', sa.String(length=20), nullable=True, comment='上级行政编码'),
sa.Column('zip_code', sa.String(length=6), nullable=False, comment='邮政编码'),
sa.Column('level', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='城市级别:1-省份 2-城市 3-县区 4-街道'),
sa.Column('pid', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='上级城市ID'),
sa.Column('name', sa.String(length=150), nullable=False, comment='城市名称'),
sa.Column('short_name', sa.String(length=150), nullable=False, comment='城市简称'),
sa.Column('full_name', sa.String(length=150), nullable=True, comment='城市全称'),
sa.Column('pinyin', sa.String(length=150), nullable=True, comment='城市拼音'),
sa.Column('lng', sa.String(length=150), nullable=True, comment='城市经度'),
sa.Column('lat', sa.String(length=150), nullable=True, comment='城市纬度'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='城市行政区划表'
)
op.create_index(op.f('ix_tornado_city_area_code'), 'tornado_city', ['area_code'], unique=False)
op.create_index(op.f('ix_tornado_city_city_code'), 'tornado_city', ['city_code'], unique=False)
op.create_index(op.f('ix_tornado_city_is_delete'), 'tornado_city', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_city_level'), 'tornado_city', ['level'], unique=False)
op.create_index(op.f('ix_tornado_city_name'), 'tornado_city', ['name'], unique=False)
op.create_index(op.f('ix_tornado_city_parent_code'), 'tornado_city', ['parent_code'], unique=False)
op.create_index(op.f('ix_tornado_city_pid'), 'tornado_city', ['pid'], unique=False)
op.create_table('tornado_config',
sa.Column('name', sa.String(length=150), nullable=False, comment='配置名称'),
sa.Column('code', sa.String(length=150), nullable=False, comment='配置编码'),
sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='配置排序'),
sa.Column('note', sa.String(length=255), nullable=True, comment='配置备注'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='系统配置表'
)
op.create_index(op.f('ix_tornado_config_code'), 'tornado_config', ['code'], unique=False)
op.create_index(op.f('ix_tornado_config_is_delete'), 'tornado_config', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_config_name'), 'tornado_config', ['name'], unique=False)
op.create_table('tornado_config_item',
sa.Column('name', sa.String(length=100), nullable=False, comment='配置项名称'),
sa.Column('code', sa.String(length=100), nullable=False, comment='配置项编码'),
sa.Column('value', sa.String(length=1000), nullable=False, comment='配置项值'),
sa.Column('options', sa.String(length=255), nullable=False, comment='配置选项'),
sa.Column('config_id', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='配置ID'),
sa.Column('type', sa.String(length=100), nullable=False, comment='配置类型'),
sa.Column('status', sa.Integer(), server_default=sa.text('1'), nullable=False, comment='配置状态:1-正常 2-停用'),
sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='配置项排序'),
sa.Column('note', sa.String(length=255), nullable=True, comment='配置项备注'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='配置项表'
)
op.create_index(op.f('ix_tornado_config_item_config_id'), 'tornado_config_item', ['config_id'], unique=False)
op.create_index(op.f('ix_tornado_config_item_is_delete'), 'tornado_config_item', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_config_item_name'), 'tornado_config_item', ['name'], unique=False)
op.create_index(op.f('ix_tornado_config_item_status'), 'tornado_config_item', ['status'], unique=False)
op.create_table('tornado_dept',
sa.Column('name', sa.String(length=150), nullable=False, comment='部门名称'),
sa.Column('type', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='部门类型:1-公司 2-子公司 3-部门 4-小组'),
sa.Column('parent_id', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='上级部门ID'),
sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='部门排序'),
sa.Column('note', sa.String(length=255), nullable=True, comment='备注'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='部门表'
)
op.create_index(op.f('ix_tornado_dept_is_delete'), 'tornado_dept', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_dept_name'), 'tornado_dept', ['name'], unique=False)
op.create_index(op.f('ix_tornado_dept_parent_id'), 'tornado_dept', ['parent_id'], unique=False)
op.create_table('tornado_dict',
sa.Column('name', sa.String(length=150), nullable=False, comment='字典名称'),
sa.Column('code', sa.String(length=150), nullable=False, comment='字典编码'),
sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='字典排序'),
sa.Column('note', sa.String(length=255), nullable=True, comment='字典备注'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='数据字典表'
)
op.create_index(op.f('ix_tornado_dict_code'), 'tornado_dict', ['code'], unique=False)
op.create_index(op.f('ix_tornado_dict_is_delete'), 'tornado_dict', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_dict_name'), 'tornado_dict', ['name'], unique=False)
op.create_table('tornado_dict_item',
sa.Column('name', sa.String(length=150), nullable=False, comment='字典项名称'),
sa.Column('value', sa.String(length=150), nullable=False, comment='字典项值'),
sa.Column('dict_id', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='字典ID'),
sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='字典项顺序'),
sa.Column('note', sa.String(length=255), nullable=True, comment='字典项备注'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='字典项表'
)
op.create_index(op.f('ix_tornado_dict_item_dict_id'), 'tornado_dict_item', ['dict_id'], unique=False)
op.create_index(op.f('ix_tornado_dict_item_is_delete'), 'tornado_dict_item', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_dict_item_name'), 'tornado_dict_item', ['name'], unique=False)
op.create_table('tornado_file_template',
sa.Column('name', sa.String(length=100), nullable=True, comment='模板名称'),
sa.Column('code', sa.String(length=50), nullable=True, comment='模板编码'),
sa.Column('file_name', sa.String(length=255), nullable=True, comment='文件名称'),
sa.Column('file_path', sa.String(length=255), nullable=True, comment='文件路径'),
sa.Column('note', sa.String(length=255), nullable=True, comment='文件模板备注'),
sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='文件模板顺序'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='文件模板表'
)
op.create_index(op.f('ix_tornado_file_template_code'), 'tornado_file_template', ['code'], unique=False)
op.create_index(op.f('ix_tornado_file_template_is_delete'), 'tornado_file_template', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_file_template_name'), 'tornado_file_template', ['name'], unique=False)
op.create_table('tornado_job',
sa.Column('job_name', sa.String(length=255), nullable=False, comment='任务名称'),
sa.Column('job_alias', sa.String(length=255), nullable=True, comment='任务别名'),
sa.Column('job_group', sa.String(length=255), nullable=True, comment='任务分组'),
sa.Column('job_trigger', sa.String(length=255), nullable=True, comment='任务触发器'),
sa.Column('status', sa.SmallInteger(), server_default=sa.text('0'), nullable=False, comment='任务状态:0-未发布 1-运行中 2-暂停 3-删除'),
sa.Column('cron_expression', sa.String(length=255), nullable=True, comment='Cron表达式'),
sa.Column('execute_policy', sa.SmallInteger(), server_default=sa.text('1'), nullable=False, comment='执行策略:1-立即执行 2-执行一次 3-放弃执行'),
sa.Column('is_sync', sa.SmallInteger(), server_default=sa.text('0'), nullable=False, comment='是否同步任务:0-否 1-是'),
sa.Column('url', sa.String(length=255), nullable=True, comment='任务URL'),
sa.Column('params', sa.Text(), nullable=True, comment='任务参数'),
sa.Column('note', sa.String(length=255), nullable=True, comment='任务备注'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='定时任务表'
)
op.create_index(op.f('ix_tornado_job_is_delete'), 'tornado_job', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_job_job_name'), 'tornado_job', ['job_name'], unique=False)
op.create_index(op.f('ix_tornado_job_status'), 'tornado_job', ['status'], unique=False)
op.create_table('tornado_job_log',
sa.Column('job_id', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='任务ID'),
sa.Column('job_name', sa.String(length=255), nullable=False, comment='任务名称'),
sa.Column('job_group', sa.String(length=255), nullable=True, comment='任务分组'),
sa.Column('job_trigger', sa.String(length=255), nullable=True, comment='任务触发器'),
sa.Column('job_message', sa.String(length=500), nullable=True, comment='任务日志信息'),
sa.Column('cron_expression', sa.String(length=255), nullable=True, comment='Cron表达式'),
sa.Column('status', sa.SmallInteger(), server_default=sa.text('0'), nullable=False, comment='执行状态:0-正常 1-失败'),
sa.Column('start_time', sa.DateTime(), nullable=True, comment='执行开始时间'),
sa.Column('end_time', sa.DateTime(), nullable=True, comment='执行结束时间'),
sa.Column('consume_time', sa.BigInteger(), server_default=sa.text('0'), nullable=False, comment='执行耗时(毫秒)'),
sa.Column('exception_info', sa.Text(), nullable=True, comment='异常信息'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='定时任务日志表'
)
op.create_index(op.f('ix_tornado_job_log_is_delete'), 'tornado_job_log', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_job_log_job_id'), 'tornado_job_log', ['job_id'], unique=False)
op.create_index(op.f('ix_tornado_job_log_status'), 'tornado_job_log', ['status'], unique=False)
op.create_table('tornado_level',
sa.Column('name', sa.String(length=255), nullable=False, comment='职级名称'),
sa.Column('status', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='职级状态:1-在用 2-停用'),
sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='职级排序'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='职级表'
)
op.create_index(op.f('ix_tornado_level_is_delete'), 'tornado_level', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_level_name'), 'tornado_level', ['name'], unique=False)
op.create_index(op.f('ix_tornado_level_status'), 'tornado_level', ['status'], unique=False)
op.create_table('tornado_link',
sa.Column('name', sa.String(length=255), nullable=False, comment='友链名称'),
sa.Column('type', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='友链类型:1-友情链接 2-合作伙伴'),
sa.Column('url', sa.String(length=255), nullable=True, comment='友链URL'),
sa.Column('form', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='友链形式:1-文字链接 2-图片链接'),
sa.Column('image', sa.String(length=255), nullable=True, comment='友链图片'),
sa.Column('status', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='友链状态:1-正常 2-停用'),
sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='友链顺序'),
sa.Column('note', sa.String(length=255), nullable=True, comment='友链备注'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='友链表'
)
op.create_index(op.f('ix_tornado_link_is_delete'), 'tornado_link', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_link_name'), 'tornado_link', ['name'], unique=False)
op.create_index(op.f('ix_tornado_link_status'), 'tornado_link', ['status'], unique=False)
op.create_table('tornado_login_log',
sa.Column('username', sa.String(length=50), nullable=False, comment='登录账号'),
sa.Column('log_type', sa.Integer(), server_default=sa.text('1'), nullable=False, comment='日志类型:1-登录 2-退出'),
sa.Column('source', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='登录来源:0-后台 1-前台'),
sa.Column('method', sa.String(length=10), nullable=False, comment='请求方法'),
sa.Column('request_method', sa.String(length=10), nullable=False, comment='请求方式'),
sa.Column('url', sa.String(length=500), nullable=False, comment='请求URL'),
sa.Column('param', sa.Text(), nullable=True, comment='请求参数'),
sa.Column('result', sa.Text(), nullable=False, comment='返回结果'),
sa.Column('ip', sa.String(length=50), nullable=False, comment='IP地址'),
sa.Column('location', sa.String(length=100), nullable=False, comment='IP归属地'),
sa.Column('os', sa.String(length=100), nullable=False, comment='操作系统'),
sa.Column('browser', sa.String(length=100), nullable=False, comment='浏览器'),
sa.Column('consume_time', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='请求耗时(毫秒)'),
sa.Column('status', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='操作状态:0-正常 1-异常'),
sa.Column('error', sa.Text(), nullable=True, comment='错误信息'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='登录日志表'
)
op.create_index(op.f('ix_tornado_login_log_is_delete'), 'tornado_login_log', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_login_log_log_type'), 'tornado_login_log', ['log_type'], unique=False)
op.create_index(op.f('ix_tornado_login_log_username'), 'tornado_login_log', ['username'], unique=False)
op.create_table('tornado_menu',
sa.Column('name', sa.String(length=150), nullable=False, comment='菜单名称'),
sa.Column('icon', sa.String(length=50), nullable=True, comment='菜单图标'),
sa.Column('icon2', sa.String(length=50), nullable=True, comment='菜单图标'),
sa.Column('parent_id', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='上级ID'),
sa.Column('path', sa.String(length=255), nullable=True, comment='菜单路径'),
sa.Column('component', sa.String(length=255), nullable=True, comment='菜单组件'),
sa.Column('permission', sa.String(length=150), nullable=True, comment='权限节点'),
sa.Column('target', sa.String(length=30), nullable=True, comment='打开方式:1-内部打开 2-外部打开'),
sa.Column('type', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='菜单类型:0-菜单 1-节点'),
sa.Column('status', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='菜单状态:1-正常 2-停用'),
sa.Column('hide', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='是否可见:0-可见 1-不可见'),
sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='排序'),
sa.Column('note', sa.String(length=255), nullable=True, comment='备注'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='菜单表'
)
op.create_index(op.f('ix_tornado_menu_is_delete'), 'tornado_menu', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_menu_name'), 'tornado_menu', ['name'], unique=False)
op.create_index(op.f('ix_tornado_menu_parent_id'), 'tornado_menu', ['parent_id'], unique=False)
op.create_index(op.f('ix_tornado_menu_permission'), 'tornado_menu', ['permission'], unique=False)
op.create_index(op.f('ix_tornado_menu_status'), 'tornado_menu', ['status'], unique=False)
op.create_index(op.f('ix_tornado_menu_type'), 'tornado_menu', ['type'], unique=False)
op.create_table('tornado_notice',
sa.Column('title', sa.String(length=255), nullable=False, comment='通知标题'),
sa.Column('cover', sa.String(length=255), nullable=True, comment='通知封面'),
sa.Column('type', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='通知类型:1-通知 2-公告'),
sa.Column('click_num', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='点击率'),
sa.Column('status', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='通知状态:1-正常 2-停用'),
sa.Column('content', sa.Text(), nullable=True, comment='通知公告内容'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='通知公告表'
)
op.create_index(op.f('ix_tornado_notice_is_delete'), 'tornado_notice', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_notice_status'), 'tornado_notice', ['status'], unique=False)
op.create_index(op.f('ix_tornado_notice_title'), 'tornado_notice', ['title'], unique=False)
op.create_index(op.f('ix_tornado_notice_type'), 'tornado_notice', ['type'], unique=False)
op.create_table('tornado_operation_log',
sa.Column('title', sa.String(length=150), nullable=False, comment='请求标题'),
sa.Column('type', sa.Integer(), server_default=sa.text('1'), nullable=False, comment='请求类型:1-新增 2-修改 3-删除'),
sa.Column('source', sa.SmallInteger(), server_default=sa.text('1'), nullable=False, comment='请求来源:1-PC端 2-移动端'),
sa.Column('method', sa.String(length=100), nullable=False, comment='请求方法(操作)'),
sa.Column('request_method', sa.String(length=100), nullable=False, comment='请求方式(HTTP)'),
sa.Column('url', sa.String(length=1000), nullable=False, comment='请求URL'),
sa.Column('param', sa.Text(), nullable=True, comment='请求参数'),
sa.Column('result', sa.Text(), nullable=True, comment='请求结果'),
sa.Column('ip', sa.String(length=100), nullable=False, comment='请求IP'),
sa.Column('location', sa.String(length=255), nullable=False, comment='请求所在区域'),
sa.Column('os', sa.String(length=100), nullable=False, comment='操作系统'),
sa.Column('browser', sa.String(length=100), nullable=False, comment='浏览器'),
sa.Column('consume_time', sa.BigInteger(), server_default=sa.text('0'), nullable=False, comment='请求耗时(毫秒)'),
sa.Column('status', sa.SmallInteger(), server_default=sa.text('0'), nullable=False, comment='请求状态:0-成功 1-失败'),
sa.Column('error', sa.Text(), nullable=True, comment='错误描述'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='操作日志表'
)
op.create_index(op.f('ix_tornado_operation_log_is_delete'), 'tornado_operation_log', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_operation_log_title'), 'tornado_operation_log', ['title'], unique=False)
op.create_index(op.f('ix_tornado_operation_log_type'), 'tornado_operation_log', ['type'], unique=False)
op.create_table('tornado_param',
sa.Column('name', sa.String(length=100), nullable=False, comment='参数名称'),
sa.Column('code', sa.String(length=100), nullable=False, comment='参数编码'),
sa.Column('value', sa.String(length=255), nullable=False, comment='参数值'),
sa.Column('type', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='参数类型:0-系统 1-业务'),
sa.Column('status', sa.Integer(), server_default=sa.text('1'), nullable=False, comment='参数状态:1-正常 2-禁用'),
sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='参数排序'),
sa.Column('note', sa.String(length=255), nullable=True, comment='参数备注'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='系统参数表'
)
op.create_index(op.f('ix_tornado_param_code'), 'tornado_param', ['code'], unique=False)
op.create_index(op.f('ix_tornado_param_is_delete'), 'tornado_param', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_param_name'), 'tornado_param', ['name'], unique=False)
op.create_index(op.f('ix_tornado_param_status'), 'tornado_param', ['status'], unique=False)
op.create_table('tornado_position',
sa.Column('name', sa.String(length=255), nullable=False, comment='岗位名称'),
sa.Column('status', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='岗位状态:1-在用 2-停用'),
sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='岗位排序'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='岗位表'
)
op.create_index(op.f('ix_tornado_position_is_delete'), 'tornado_position', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_position_name'), 'tornado_position', ['name'], unique=False)
op.create_index(op.f('ix_tornado_position_status'), 'tornado_position', ['status'], unique=False)
op.create_table('tornado_role',
sa.Column('name', sa.String(length=150), nullable=False, comment='角色名称'),
sa.Column('code', sa.String(length=30), nullable=False, comment='角色编码'),
sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='角色排序'),
sa.Column('note', sa.String(length=255), nullable=True, comment='角色备注'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='角色表'
)
op.create_index(op.f('ix_tornado_role_code'), 'tornado_role', ['code'], unique=False)
op.create_index(op.f('ix_tornado_role_is_delete'), 'tornado_role', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_role_name'), 'tornado_role', ['name'], unique=False)
op.create_table('tornado_role_menu',
sa.Column('role_id', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='角色ID'),
sa.Column('menu_id', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='菜单ID'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='角色菜单关联表'
)
op.create_index(op.f('ix_tornado_role_menu_is_delete'), 'tornado_role_menu', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_role_menu_menu_id'), 'tornado_role_menu', ['menu_id'], unique=False)
op.create_index(op.f('ix_tornado_role_menu_role_id'), 'tornado_role_menu', ['role_id'], unique=False)
op.create_table('tornado_user',
sa.Column('code', sa.String(length=100), server_default=sa.text("''"), nullable=False, comment='用户编码'),
sa.Column('realname', sa.String(length=150), nullable=False, comment='用户姓名'),
sa.Column('gender', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='性别:1-男 2-女 3-保密'),
sa.Column('avatar', sa.String(length=255), nullable=False, comment='用户头像'),
sa.Column('mobile', sa.String(length=30), nullable=False, comment='手机号'),
sa.Column('email', sa.String(length=30), nullable=False, comment='邮箱'),
sa.Column('dept_id', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='部门ID'),
sa.Column('level_id', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='职级ID'),
sa.Column('position_id', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='岗位ID'),
sa.Column('province_code', sa.String(length=30), nullable=False, comment='省份编码'),
sa.Column('city_code', sa.String(length=30), nullable=False, comment='城市编码'),
sa.Column('district_code', sa.String(length=30), nullable=False, comment='县区编码'),
sa.Column('street_code', sa.String(length=30), nullable=False, comment='街道编码'),
sa.Column('city_info', sa.String(length=255), nullable=True, comment='省市区信息'),
sa.Column('address', sa.String(length=255), nullable=False, comment='详细地址'),
sa.Column('username', sa.String(length=30), nullable=True, comment='用户名'),
sa.Column('password', sa.String(length=255), nullable=True, comment='密码'),
sa.Column('salt', sa.String(length=30), nullable=True, comment='加密盐'),
sa.Column('intro', sa.String(length=255), nullable=True, comment='个人简介'),
sa.Column('status', sa.Integer(), server_default=sa.text('1'), nullable=True, comment='状态:1-正常 2-禁用'),
sa.Column('sort', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='排序'),
sa.Column('note', sa.String(length=255), nullable=True, comment='个人备注'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='用户表'
)
op.create_index(op.f('ix_tornado_user_code'), 'tornado_user', ['code'], unique=False)
op.create_index(op.f('ix_tornado_user_dept_id'), 'tornado_user', ['dept_id'], unique=False)
op.create_index(op.f('ix_tornado_user_email'), 'tornado_user', ['email'], unique=False)
op.create_index(op.f('ix_tornado_user_is_delete'), 'tornado_user', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_user_mobile'), 'tornado_user', ['mobile'], unique=False)
op.create_index(op.f('ix_tornado_user_realname'), 'tornado_user', ['realname'], unique=False)
op.create_index(op.f('ix_tornado_user_status'), 'tornado_user', ['status'], unique=False)
op.create_index(op.f('ix_tornado_user_username'), 'tornado_user', ['username'], unique=False)
op.create_table('tornado_user_role',
sa.Column('user_id', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='用户ID'),
sa.Column('role_id', sa.Integer(), server_default=sa.text('0'), nullable=False, comment='角色ID'),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'),
sa.Column('create_user', sa.String(length=50), nullable=True, comment='创建人'),
sa.Column('create_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'),
sa.Column('update_user', sa.String(length=50), nullable=True, comment='更新人'),
sa.Column('update_time', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'),
sa.Column('is_delete', sa.Integer(), server_default=sa.text('0'), nullable=True, comment='删除标识:0-正常 1-已删除'),
sa.PrimaryKeyConstraint('id'),
comment='用户角色关联表'
)
op.create_index(op.f('ix_tornado_user_role_is_delete'), 'tornado_user_role', ['is_delete'], unique=False)
op.create_index(op.f('ix_tornado_user_role_role_id'), 'tornado_user_role', ['role_id'], unique=False)
op.create_index(op.f('ix_tornado_user_role_user_id'), 'tornado_user_role', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_tornado_user_role_user_id'), table_name='tornado_user_role')
op.drop_index(op.f('ix_tornado_user_role_role_id'), table_name='tornado_user_role')
op.drop_index(op.f('ix_tornado_user_role_is_delete'), table_name='tornado_user_role')
op.drop_table('tornado_user_role')
op.drop_index(op.f('ix_tornado_user_username'), table_name='tornado_user')
op.drop_index(op.f('ix_tornado_user_status'), table_name='tornado_user')
op.drop_index(op.f('ix_tornado_user_realname'), table_name='tornado_user')
op.drop_index(op.f('ix_tornado_user_mobile'), table_name='tornado_user')
op.drop_index(op.f('ix_tornado_user_is_delete'), table_name='tornado_user')
op.drop_index(op.f('ix_tornado_user_email'), table_name='tornado_user')
op.drop_index(op.f('ix_tornado_user_dept_id'), table_name='tornado_user')
op.drop_index(op.f('ix_tornado_user_code'), table_name='tornado_user')
op.drop_table('tornado_user')
op.drop_index(op.f('ix_tornado_role_menu_role_id'), table_name='tornado_role_menu')
op.drop_index(op.f('ix_tornado_role_menu_menu_id'), table_name='tornado_role_menu')
op.drop_index(op.f('ix_tornado_role_menu_is_delete'), table_name='tornado_role_menu')
op.drop_table('tornado_role_menu')
op.drop_index(op.f('ix_tornado_role_name'), table_name='tornado_role')
op.drop_index(op.f('ix_tornado_role_is_delete'), table_name='tornado_role')
op.drop_index(op.f('ix_tornado_role_code'), table_name='tornado_role')
op.drop_table('tornado_role')
op.drop_index(op.f('ix_tornado_position_status'), table_name='tornado_position')
op.drop_index(op.f('ix_tornado_position_name'), table_name='tornado_position')
op.drop_index(op.f('ix_tornado_position_is_delete'), table_name='tornado_position')
op.drop_table('tornado_position')
op.drop_index(op.f('ix_tornado_param_status'), table_name='tornado_param')
op.drop_index(op.f('ix_tornado_param_name'), table_name='tornado_param')
op.drop_index(op.f('ix_tornado_param_is_delete'), table_name='tornado_param')
op.drop_index(op.f('ix_tornado_param_code'), table_name='tornado_param')
op.drop_table('tornado_param')
op.drop_index(op.f('ix_tornado_operation_log_type'), table_name='tornado_operation_log')
op.drop_index(op.f('ix_tornado_operation_log_title'), table_name='tornado_operation_log')
op.drop_index(op.f('ix_tornado_operation_log_is_delete'), table_name='tornado_operation_log')
op.drop_table('tornado_operation_log')
op.drop_index(op.f('ix_tornado_notice_type'), table_name='tornado_notice')
op.drop_index(op.f('ix_tornado_notice_title'), table_name='tornado_notice')
op.drop_index(op.f('ix_tornado_notice_status'), table_name='tornado_notice')
op.drop_index(op.f('ix_tornado_notice_is_delete'), table_name='tornado_notice')
op.drop_table('tornado_notice')
op.drop_index(op.f('ix_tornado_menu_type'), table_name='tornado_menu')
op.drop_index(op.f('ix_tornado_menu_status'), table_name='tornado_menu')
op.drop_index(op.f('ix_tornado_menu_permission'), table_name='tornado_menu')
op.drop_index(op.f('ix_tornado_menu_parent_id'), table_name='tornado_menu')
op.drop_index(op.f('ix_tornado_menu_name'), table_name='tornado_menu')
op.drop_index(op.f('ix_tornado_menu_is_delete'), table_name='tornado_menu')
op.drop_table('tornado_menu')
op.drop_index(op.f('ix_tornado_login_log_username'), table_name='tornado_login_log')
op.drop_index(op.f('ix_tornado_login_log_log_type'), table_name='tornado_login_log')
op.drop_index(op.f('ix_tornado_login_log_is_delete'), table_name='tornado_login_log')
op.drop_table('tornado_login_log')
op.drop_index(op.f('ix_tornado_link_status'), table_name='tornado_link')
op.drop_index(op.f('ix_tornado_link_name'), table_name='tornado_link')
op.drop_index(op.f('ix_tornado_link_is_delete'), table_name='tornado_link')
op.drop_table('tornado_link')
op.drop_index(op.f('ix_tornado_level_status'), table_name='tornado_level')
op.drop_index(op.f('ix_tornado_level_name'), table_name='tornado_level')
op.drop_index(op.f('ix_tornado_level_is_delete'), table_name='tornado_level')
op.drop_table('tornado_level')
op.drop_index(op.f('ix_tornado_job_log_status'), table_name='tornado_job_log')
op.drop_index(op.f('ix_tornado_job_log_job_id'), table_name='tornado_job_log')
op.drop_index(op.f('ix_tornado_job_log_is_delete'), table_name='tornado_job_log')
op.drop_table('tornado_job_log')
op.drop_index(op.f('ix_tornado_job_status'), table_name='tornado_job')
op.drop_index(op.f('ix_tornado_job_job_name'), table_name='tornado_job')
op.drop_index(op.f('ix_tornado_job_is_delete'), table_name='tornado_job')
op.drop_table('tornado_job')
op.drop_index(op.f('ix_tornado_file_template_name'), table_name='tornado_file_template')
op.drop_index(op.f('ix_tornado_file_template_is_delete'), table_name='tornado_file_template')
op.drop_index(op.f('ix_tornado_file_template_code'), table_name='tornado_file_template')
op.drop_table('tornado_file_template')
op.drop_index(op.f('ix_tornado_dict_item_name'), table_name='tornado_dict_item')
op.drop_index(op.f('ix_tornado_dict_item_is_delete'), table_name='tornado_dict_item')
op.drop_index(op.f('ix_tornado_dict_item_dict_id'), table_name='tornado_dict_item')
op.drop_table('tornado_dict_item')
op.drop_index(op.f('ix_tornado_dict_name'), table_name='tornado_dict')
op.drop_index(op.f('ix_tornado_dict_is_delete'), table_name='tornado_dict')
op.drop_index(op.f('ix_tornado_dict_code'), table_name='tornado_dict')
op.drop_table('tornado_dict')
op.drop_index(op.f('ix_tornado_dept_parent_id'), table_name='tornado_dept')
op.drop_index(op.f('ix_tornado_dept_name'), table_name='tornado_dept')
op.drop_index(op.f('ix_tornado_dept_is_delete'), table_name='tornado_dept')
op.drop_table('tornado_dept')
op.drop_index(op.f('ix_tornado_config_item_status'), table_name='tornado_config_item')
op.drop_index(op.f('ix_tornado_config_item_name'), table_name='tornado_config_item')
op.drop_index(op.f('ix_tornado_config_item_is_delete'), table_name='tornado_config_item')
op.drop_index(op.f('ix_tornado_config_item_config_id'), table_name='tornado_config_item')
op.drop_table('tornado_config_item')
op.drop_index(op.f('ix_tornado_config_name'), table_name='tornado_config')
op.drop_index(op.f('ix_tornado_config_is_delete'), table_name='tornado_config')
op.drop_index(op.f('ix_tornado_config_code'), table_name='tornado_config')
op.drop_table('tornado_config')
op.drop_index(op.f('ix_tornado_city_pid'), table_name='tornado_city')
op.drop_index(op.f('ix_tornado_city_parent_code'), table_name='tornado_city')
op.drop_index(op.f('ix_tornado_city_name'), table_name='tornado_city')
op.drop_index(op.f('ix_tornado_city_level'), table_name='tornado_city')
op.drop_index(op.f('ix_tornado_city_is_delete'), table_name='tornado_city')
op.drop_index(op.f('ix_tornado_city_city_code'), table_name='tornado_city')
op.drop_index(op.f('ix_tornado_city_area_code'), table_name='tornado_city')
op.drop_table('tornado_city')
op.drop_index(op.f('ix_tornado_category_parent_id'), table_name='tornado_category')
op.drop_index(op.f('ix_tornado_category_is_delete'), table_name='tornado_category')
op.drop_index(op.f('ix_tornado_category_category_name'), table_name='tornado_category')
op.drop_table('tornado_category')
op.drop_index(op.f('ix_tornado_article_title'), table_name='tornado_article')
op.drop_index(op.f('ix_tornado_article_status'), table_name='tornado_article')
op.drop_index(op.f('ix_tornado_article_is_delete'), table_name='tornado_article')
op.drop_index(op.f('ix_tornado_article_category_id'), table_name='tornado_article')
op.drop_table('tornado_article')
# ### end Alembic commands ###
+27
View File
@@ -0,0 +1,27 @@
# +======================================================================
# | 模块: 应用入口层
# | 说明: 极简入口,将 src 加入模块搜索路径后交由 bootstrap 启动服务
# +======================================================================
"""应用入口(极简版)。"""
# ----------------------------------------------------------------------
# 导入依赖
# 涵盖:操作系统接口、模块搜索路径管理
# ----------------------------------------------------------------------
import os
import sys
# ============================================================
# 将 src 目录加入 sys.path
# ============================================================
# 将 src 目录加入 sys.path(未安装 poetry 时也可直接运行)
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))
from bootstrap import main
# ============================================================
# 入口
# ============================================================
if __name__ == '__main__':
# 启动应用
main()
+212
View File
@@ -0,0 +1,212 @@
{
"app_name": "example",
"module_comment": "案例演示",
"model_name": "example",
"model_class_name": "Example",
"route_prefix": "example",
"business_description": "标识系统中的案例演示数据",
"business_description_detail": "包含案例名称, 案例图片, 案例类型:1-类型1 2-类型2 3-类型3 4-类型4等属性",
"permission_prefix": "sys:example",
"primary_key": "id",
"display_field": "name",
"display_field_camel": "name",
"table_name": "example",
"table_prefix": [
"tornado_"
],
"remove_prefix": true,
"menu_group": "tool",
"parent_menu_id": 0,
"menu_sort": 5,
"has_sort": true,
"has_status": true,
"has_unique_code": false,
"has_export": false,
"has_image_field": true,
"has_rich_text_field": false,
"fields": [
{
"name": "name",
"camel_name": "name",
"comment": "案例名称",
"py_comment": "案例名称",
"purpose": "存储案例名称信息",
"db_type": "String",
"db_args": "255",
"form_type": "str",
"type_display": "字符串",
"max_length": 255,
"nullable": false,
"required": true,
"is_unique": false,
"db_index": true,
"default": null,
"default_repr": null,
"in_form": true,
"in_list": true,
"filterable": true,
"filter_type": "模糊查询",
"editable": true,
"searchable": true,
"is_image": false,
"is_rich_text": false,
"is_status": false,
"is_sort": false,
"choices": null,
"min_value": null,
"max_value": null
},
{
"name": "avatar",
"camel_name": "avatar",
"comment": "案例图片",
"py_comment": "案例图片",
"purpose": "存储案例图片信息",
"db_type": "String",
"db_args": "255",
"form_type": "str",
"type_display": "字符串",
"max_length": 255,
"nullable": false,
"required": true,
"is_unique": false,
"db_index": false,
"default": null,
"default_repr": null,
"in_form": true,
"in_list": true,
"filterable": false,
"filter_type": "",
"editable": true,
"searchable": false,
"is_image": true,
"is_rich_text": false,
"is_status": false,
"is_sort": false,
"choices": null,
"min_value": null,
"max_value": null
},
{
"name": "type",
"camel_name": "type",
"comment": "案例类型:1-类型1 2-类型2 3-类型3 4-类型4",
"py_comment": "案例类型:1-类型1 2-类型2 3-类型3 4-类型4",
"purpose": "存储案例类型:1-类型1 2-类型2 3-类型3 4-类型4信息",
"db_type": "Integer",
"db_args": null,
"form_type": "int",
"type_display": "整数",
"max_length": null,
"nullable": true,
"required": false,
"is_unique": false,
"db_index": false,
"default": 0,
"default_repr": "0",
"in_form": true,
"in_list": true,
"filterable": true,
"filter_type": "精确匹配",
"editable": true,
"searchable": false,
"is_image": false,
"is_rich_text": false,
"is_status": false,
"is_sort": false,
"choices": [
[
1,
"类型1"
],
[
2,
"类型2"
],
[
3,
"类型3"
],
[
4,
"类型4"
]
],
"min_value": 1,
"max_value": 4,
"dict_code": "example_type"
},
{
"name": "status",
"camel_name": "status",
"comment": "案例状态:1-在用 2-停用",
"py_comment": "案例状态:1-在用 2-停用",
"purpose": "存储案例状态:1-在用 2-停用信息",
"db_type": "Integer",
"db_args": null,
"form_type": "int",
"type_display": "整数",
"max_length": null,
"nullable": true,
"required": false,
"is_unique": false,
"db_index": true,
"default": 0,
"default_repr": "0",
"in_form": true,
"in_list": true,
"filterable": true,
"filter_type": "精确匹配",
"editable": true,
"searchable": false,
"is_image": false,
"is_rich_text": false,
"is_status": true,
"is_sort": false,
"choices": [
[
1,
"在用"
],
[
2,
"停用"
]
],
"min_value": 1,
"max_value": 2,
"dict_code": "example_status"
},
{
"name": "sort",
"camel_name": "sort",
"comment": "案例排序",
"py_comment": "案例排序",
"purpose": "存储案例排序信息",
"db_type": "Integer",
"db_args": null,
"form_type": "int",
"type_display": "整数",
"max_length": null,
"nullable": true,
"required": false,
"is_unique": false,
"db_index": false,
"default": 0,
"default_repr": "0",
"in_form": true,
"in_list": true,
"filterable": false,
"filter_type": "",
"editable": true,
"searchable": false,
"is_image": false,
"is_rich_text": false,
"is_status": false,
"is_sort": true,
"choices": null,
"min_value": null,
"max_value": null
}
]
}
+6
View File
@@ -0,0 +1,6 @@
# Elasticsearch 日志收集配置(占位)
cluster.name: tornado-elevue
node.name: node-1
network.host: 0.0.0.0
http.port: 9200
discovery.type: single-node
+22
View File
@@ -0,0 +1,22 @@
# Logstash 日志管道配置(占位)
input {
file {
path => "/data/apps/logs/app.log"
start_position => "beginning"
sincedb_path => "/dev/null"
codec => json
}
}
filter {
date {
match => ["timestamp", "ISO8601"]
}
}
output {
elasticsearch {
hosts => ["elasticsearch:9200"]
index => "tornado-elevue-%{+YYYY.MM.dd}"
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"title": "Tornado+EleVue 应用监控",
"uid": "tornado-elevue",
"version": 1,
"timezone": "browser",
"refresh": "30s",
"schemaVersion": 39,
"panels": [
{
"id": 1,
"title": "HTTP 请求数",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"targets": [
{
"expr": "sum(rate(http_requests_total{job=\"tornado_elevue\"}[5m]))",
"legendFormat": "请求/秒"
}
]
},
{
"id": 2,
"title": "请求响应时间",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{job=\"tornado_elevue\"}[5m])) by (le))",
"legendFormat": "P95"
}
]
}
]
}
+60
View File
@@ -0,0 +1,60 @@
# Kubernetes 部署(可选)
apiVersion: apps/v1
kind: Deployment
metadata:
name: tornado-elevue
namespace: default
labels:
app: tornado-elevue
spec:
replicas: 2
selector:
matchLabels:
app: tornado-elevue
template:
metadata:
labels:
app: tornado-elevue
spec:
containers:
- name: tornado-elevue
image: registry.example.com/tornado-elevue:latest
ports:
- containerPort: 8041
envFrom:
- configMapRef:
name: tornado-elevue-config
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 500m
memory: 1Gi
livenessProbe:
httpGet:
path: /api/v1/health
port: 8041
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /api/v1/health
port: 8041
initialDelaySeconds: 5
periodSeconds: 10
volumeMounts:
- name: uploads
mountPath: /data/apps/uploads
volumes:
- name: uploads
persistentVolumeClaim:
claimName: tornado-elevue-uploads
---
apiVersion: v1
kind: ConfigMap
metadata:
name: tornado-elevue-config
data:
TORNADO_ENV: production
TORNADO_PORT: "8041"
+18
View File
@@ -0,0 +1,18 @@
# Kubernetes Ingress(可选)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tornado-elevue
namespace: default
spec:
rules:
- host: api.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: tornado-elevue
port:
name: http
+14
View File
@@ -0,0 +1,14 @@
# Kubernetes 服务(可选)
apiVersion: v1
kind: Service
metadata:
name: tornado-elevue
namespace: default
spec:
selector:
app: tornado-elevue
ports:
- name: http
port: 8041
targetPort: 8041
type: ClusterIP
+35
View File
@@ -0,0 +1,35 @@
# DjangoAdmin Tornado+EleVue 反向代理配置
# 前端(Vue)静态资源 + 后端API代理
upstream tornado_backend {
server 127.0.0.1:8041;
}
server {
listen 80;
server_name localhost;
charset utf-8;
client_max_body_size 50m;
# 后端 API(统一 /api 前缀)
location /api/ {
proxy_pass http://tornado_backend;
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;
proxy_read_timeout 300s;
}
# 上传文件访问
location /uploads/ {
alias /data/apps/uploads/;
expires 30d;
}
# 前端静态资源(独立部署时使用)
# location / {
# root /data/frontend/dist;
# try_files $uri $uri/ /index.html;
# }
}
View File
+9
View File
@@ -0,0 +1,9 @@
# Prometheus 监控配置
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: tornado_elevue
static_configs:
- targets: ["127.0.0.1:8041"]
+21
View File
@@ -0,0 +1,21 @@
# Supervisor 进程管理配置
; 安装: apt install supervisor
; 软链: ln -s /data/apps/deploy/supervisor/supervisor.conf /etc/supervisor/conf.d/tornado_elevue.conf
; 重载: supervisorctl update && supervisorctl reload
[program:tornado_elevue]
directory=/data/apps
command=python app.py
user=root
autostart=true
autorestart=true
startsecs=5
startretries=10
stopasgroup=true
killasgroup=true
redirect_stderr=true
stdout_logfile=/data/apps/logs/supervisor_stdout.log
stderr_logfile=/data/apps/logs/supervisor_stderr.log
stdout_logfile_maxbytes=100MB
stdout_logfile_backups=10
environment=TORNADO_ENV="production",PYTHONUNBUFFERED="1"
+45
View File
@@ -0,0 +1,45 @@
# 开发环境覆盖配置(docker compose up 时自动加载)
# 提供本地 MySQL 与 Redis 服务,并将源码热挂载
version: '3.8'
services:
# MySQL 数据库
mysql:
image: mysql:8.0
container_name: tornado_elevue_mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: 123456
MYSQL_DATABASE: djangoadmin.tornado.elevue
TZ: Asia/Shanghai
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./document/sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
# Redis 缓存
redis:
image: redis:7
container_name: tornado_elevue_redis
restart: always
ports:
- "6379:6379"
# 后端服务(开发模式:源码热挂载 + 自动重载)
tornado_elevue:
build:
context: ./
dockerfile: Dockerfile
volumes:
- ./src:/data/apps/src
- ./uploads:/data/apps/uploads
- ./logs:/data/apps/logs
environment:
- TORNADO_DEBUG=True
depends_on:
- mysql
- redis
volumes:
mysql_data:
+28
View File
@@ -0,0 +1,28 @@
version: '3.8'
# 应用服务
services:
# 后端服务
tornado_elevue:
image: tornado_elevue:latest
container_name: tornado_elevue
build:
context: ./
dockerfile: Dockerfile
restart: always
ports:
- "8041:8041"
environment:
- TORNADO_PORT=8041
- TORNADO_ENV=production
env_file:
- .env
volumes:
- ./uploads:/data/apps/uploads
- ./logs:/data/apps/logs
command: python app.py
logging:
driver: "json-file"
options:
max-size: "500m"
max-file: "10"
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+204
View File
@@ -0,0 +1,204 @@
# +======================================================================
# | 模块: 代码生成器命令行工具
# | 说明: 数据库表结构解析与代码生成命令行入口
# +======================================================================
"""
数据库表结构解析与代码生成命令行工具
=============================================================
本项目代码生成器的命令行入口,内部复用 src/modules/generator 下的引擎:
- parser.generate_config_from_table : 从数据库表解析生成配置
- code_generator.CodeGenerator : 根据配置生成后端三层模块代码与前端 Vue 页面
支持 --dry-run / --config / --output 参数:
- 默认从数据库表解析生成配置
- --config 加载已有的配置文件(兼容 config.json 参考配置)
- --dry-run 仅生成配置不写代码,--output 导出配置为 JSON
命令行示例:
# 方式1:解析表结构并生成模块(核心用法)
python generator.py tornado_example
# 方式2:仅生成配置,不执行代码生成(预览模式)
python generator.py tornado_example --dry-run
# 方式2.1:预览并导出配置为 JSON 文件
python generator.py tornado_example --dry-run --output config.json
# 方式3:使用已有配置文件生成(跳过表解析,兼容 config.json 参考配置)
python generator.py --config config.json
# 方式4:生成代码并保存配置(解析 -> 保存 -> 生成)
python generator.py tornado_example --output config.json
"""
import argparse
import json
import os
import sys
# 将项目 src 目录加入 sys.path,保证从任意目录运行都能导入
ROOT = os.path.dirname(os.path.abspath(__file__))
SRC_DIR = os.path.join(ROOT, 'src')
if SRC_DIR not in sys.path:
sys.path.insert(0, SRC_DIR)
# ============================================================
# 辅助函数
# ============================================================
def _init_database():
"""
初始化数据库连接
代码生成引擎通过 SQLAlchemy 的 Session 访问数据库,因此 CLI 需要
先初始化数据库连接。复用 src/core/database.py 的 engine 和 SessionLocal。
"""
from core.database import engine, SessionLocal
return engine, SessionLocal
def _resolve_config(args):
"""
根据命令行参数解析生成配置
Args:
args: argparse 解析结果
Returns:
dict: 生成配置
Raises:
SystemExit: 缺少表名且未指定配置文件时退出
"""
if args.config:
# 方式3:从配置文件加载(兼容 config.json 参考配置)
print(f"从配置文件加载: {args.config}")
with open(args.config, 'r', encoding='utf-8') as f:
config = json.load(f)
print("配置文件加载成功")
return config
if args.table_name:
# 方式1/2/4:从数据库表解析
from modules.generator.parser import generate_config_from_table
print(f"正在解析表: {args.table_name}")
config = generate_config_from_table(args.table_name)
print("表结构解析成功")
return config
# 缺少必要参数:打印帮助并退出
args_parser.print_help()
sys.exit(1)
def _print_config_summary(config):
"""
打印配置摘要,便于人工核对解析结果
Args:
config: 生成配置字典
"""
fields = config.get('fields', [])
print("\n" + "-" * 60)
print("生成配置摘要:")
print("-" * 60)
print(f" 应用名称: {config.get('app_name')}")
print(f" 模块名称: {config.get('module_comment')}")
print(f" 模型类名: {config.get('model_class_name')}")
print(f" 字段数量: {len(fields)}")
print(f" 是否有排序: {config.get('has_sort')}")
print(f" 是否有状态: {config.get('has_status')}")
# 字段列表(仅展示前 5 个,避免刷屏)
for field in fields[:5]:
print(f" - {field['name']}: {field.get('comment', '')} "
f"({field.get('db_type', field.get('type', ''))})")
if field.get('choices'):
print(f" 选项: {field['choices']}")
if len(fields) > 5:
print(f" ... 还有 {len(fields) - 5} 个字段")
def _run_code_generator(config):
"""
执行代码生成(写入模块文件 + 自动注册路由)
Args:
config: 生成配置字典
"""
from modules.generator.code_generator import CodeGenerator
generator = CodeGenerator(config)
generator.generate(auto=True)
return True
# ============================================================
# CLI 主入口
# ============================================================
def main():
"""
CLI 主入口
执行流程:
1. 初始化数据库连接
2. 解析生成配置(表解析 / 配置文件加载)
3. 按需导出配置(--output)
4. 非预览模式下执行代码生成
"""
global args_parser
args_parser = argparse.ArgumentParser(
description='数据库表结构解析和代码生成工具',
)
args_parser.add_argument('table_name', nargs='?', help='数据库表名(如 tornado_example)')
args_parser.add_argument('--config', '-c', help='使用已有的配置文件(JSON格式,兼容 config.json 参考配置)')
args_parser.add_argument('--dry-run', '-d', action='store_true', help='仅生成配置,不执行代码生成')
args_parser.add_argument('--output', '-o', help='输出配置文件路径')
args = args_parser.parse_args()
print("=" * 60)
print("DjangoAdmin Tornado 代码生成工具")
print("=" * 60)
# 初始化数据库连接
_init_database()
try:
# 步骤1:解析生成配置
config = _resolve_config(args)
# 步骤2:导出配置(--output,仅表解析路径生效)
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
print(f"配置已保存到: {args.output}")
# 步骤3:打印配置摘要
_print_config_summary(config)
# 步骤4:执行代码生成(预览模式跳过)
if args.dry_run:
print("\n" + "=" * 60)
print("预览模式 - 不执行代码生成(去掉 --dry-run 参数即可生成)")
print("=" * 60)
else:
print("\n" + "=" * 60)
print("开始执行代码生成")
print("=" * 60)
_run_code_generator(config)
print("代码生成完成!")
print("\n操作完成!")
except Exception as e:
print(f"\n操作失败: {str(e)}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
main()
Generated
+1722
View File
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
[build-system]
requires = ["poetry-core>=2.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry]
name = "djangoadmin-tornado-elevue"
version = "2.8.0"
description = "DjangoAdmin 敏捷开发框架 Tornado + EleVue 版本(模块化、高性能、企业级)"
authors = ["小蚂蚁云团队 <https://www.djangoadmin.cn>"]
license = "Apache-2.0"
readme = "README.md"
homepage = "https://www.djangoadmin.cn"
packages = [{ include = "*", from = "src" }]
[tool.poetry.dependencies]
python = ">=3.10"
tornado = "^6.3.2"
sqlalchemy = "^2.0.18"
pymysql = "^1.1.0"
redis = "^4.6.0"
PyJWT = "^2.7.0"
pydantic = "^2.0.2"
pydantic-settings = "^2.0.0"
bcrypt = "^4.0.0"
python-dotenv = "^1.0.0"
Pillow = "^10.0.0"
openpyxl = "^3.1.2"
APScheduler = "^3.11.2"
# 可选数据库驱动(默认 MySQL 使用 pymysql):
# poetry install -E postgresql / -E mssql / -E oracle
# SQLite 走内置 sqlite3,无需额外安装
bleach = "^6.4.0"
tinycss2 = "^1.5.1"
py-ip2region = "3.0.4"
[tool.poetry.extras]
postgresql = ["psycopg2-binary"]
mssql = ["pymssql"]
oracle = ["oracledb"]
[tool.poetry.group.dev.dependencies]
pytest = "^8.0.0"
pytest-asyncio = ">=0.21,<0.24"
pytest-cov = "^5.0.0"
mypy = "^1.8.0"
flake8 = "^7.0.0"
pre-commit = "^3.6.0"
ruff = "^0.4.0"
types-PyMySQL = "^1.1.0.4"
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
asyncio_mode = "auto"
[tool.ruff]
line-length = 120
target-version = "py310"
[tool.mypy]
python_version = "3.10"
ignore_missing_imports = true
+28
View File
@@ -0,0 +1,28 @@
# ============================================================
# Web 框架
# ============================================================
tornado == 6.5.8
# ============================================================
# 数据库驱动
# ============================================================
psycopg2-binary >= 2.9
pymssql >= 2.3.0
oracledb == 4.0.2
PyMySQL == 1.2.0
# SQLite: 内置支持,无需额外驱动
# ============================================================
# 核心依赖(按字母顺序)
# ============================================================
APScheduler == 3.11.3
bcrypt == 5.0.0
openpyxl == 3.1.5
Pillow == 12.3.0
pydantic == 2.13.4
pydantic-settings == 2.15.0
py-ip2region == 3.0.4
PyJWT == 2.13.0
python-dotenv == 1.2.2
redis == 8.0.1
SQLAlchemy == 2.0.51
+1
View File
@@ -0,0 +1 @@
"""运维脚本集合。"""
+65
View File
@@ -0,0 +1,65 @@
"""部署脚本。
用法:
python scripts/deploy.py --check # 预检(依赖/配置/数据库连通性)
python scripts/deploy.py --health # 服务健康检查
python scripts/deploy.py --docker # docker compose 构建并启动
"""
import argparse
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def pre_check() -> None:
print("== 依赖检查 ==")
try:
import tornado
import sqlalchemy
import pydantic
print(" 核心依赖已安装")
except ImportError as e:
print(f" 缺少依赖: {e}")
sys.exit(1)
print("== 配置文件检查 ==")
if (ROOT / ".env").exists():
print(" .env 存在")
else:
print(" 警告: .env 不存在,将从 .env.example 复制")
(ROOT / ".env.example").read_text()
(ROOT / ".env").write_text((ROOT / ".env.example").read_text())
print(" 已复制 .env.example -> .env")
print("== 预检完成 ==")
def health() -> None:
subprocess.run([sys.executable, str(ROOT / "scripts" / "health_check.py")])
def docker_deploy() -> None:
subprocess.run(["docker", "compose", "up", "-d", "--build"], cwd=str(ROOT), check=True)
def main() -> None:
parser = argparse.ArgumentParser(description="部署脚本")
parser.add_argument("--check", action="store_true", help="部署预检")
parser.add_argument("--health", action="store_true", help="健康检查")
parser.add_argument("--docker", action="store_true", help="Docker 部署")
args = parser.parse_args()
if args.check:
pre_check()
elif args.health:
health()
elif args.docker:
docker_deploy()
else:
parser.print_help()
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
"""健康检查脚本。
用法:python scripts/health_check.py [--host HOST] [--port PORT]
"""
import argparse
import sys
import urllib.request
from pathlib import Path
SRC = Path(__file__).resolve().parent.parent / "src"
sys.path.insert(0, str(SRC))
from config.app import TORNADO_HOST, TORNADO_PORT
def check(host: str, port: int) -> None:
url = f"http://{host}:{port}/api/v1/health"
try:
with urllib.request.urlopen(url, timeout=10) as resp:
body = resp.read().decode("utf-8")
if resp.status == 200:
print(f"[OK] {url} -> {resp.status} {body}")
else:
print(f"[WARN] {url} -> {resp.status} {body}")
sys.exit(1)
except Exception as e:
print(f"[FAIL] {url} 不可达: {e}")
sys.exit(1)
def main() -> None:
parser = argparse.ArgumentParser(description="健康检查")
parser.add_argument("--host", default=TORNADO_HOST)
parser.add_argument("--port", type=int, default=TORNADO_PORT)
args = parser.parse_args()
check(args.host, args.port)
if __name__ == "__main__":
main()
+80
View File
@@ -0,0 +1,80 @@
"""数据库初始化脚本。
用法:
python scripts/init_db.py # 根据模型创建表
python scripts/init_db.py --sql <文件> # 导入 SQL 结构文件
"""
import argparse
import sys
from pathlib import Path
# 将 src 目录加入 sys.path
SRC = Path(__file__).resolve().parent.parent / "src"
sys.path.insert(0, str(SRC))
from config.database import DATABASE_URL
from extensions.sqlalchemy import Base, engine
def import_sql(sql_file: Path) -> None:
"""导入 SQL 文件(按分号+换行拆分逐条执行,避免字符串内分号误切)。"""
from sqlalchemy import text
if not sql_file.exists():
print(f"SQL 文件不存在: {sql_file}")
sys.exit(1)
content = sql_file.read_text(encoding="utf-8", errors="replace")
statements = [s.strip() for s in content.split(";\n") if s.strip()]
with engine.begin() as conn:
for statement in statements:
conn.execute(text(statement))
print(f"SQL 导入完成: {sql_file}(共 {len(statements)} 条语句)")
def create_tables() -> None:
"""根据模型定义创建全部数据表。"""
# 导入各模块 models 触发注册
import modules.auth.models
import modules.user.models
import modules.user_role.models
import modules.role.models
import modules.role_menu.models
import modules.menu.models
import modules.dept.models
import modules.category.models
import modules.article.models
import modules.param.models
import modules.file_template.models
import modules.job.models
import modules.dictionary.dict.models
import modules.dictionary.dict_item.models
import modules.configuration.config.models
import modules.configuration.config_item.models
import modules.level.models
import modules.position.models
import modules.city.models
import modules.notice.models
import modules.link.models
import modules.operation_log.models
import modules.login_log.models
import modules.job_log.models
import modules.example.models
Base.metadata.create_all(bind=engine)
print(f"数据表创建完成,目标数据库: {DATABASE_URL.rsplit('/', 1)[-1]}")
def main() -> None:
parser = argparse.ArgumentParser(description="数据库初始化")
parser.add_argument("--sql", type=Path, default=None, help="SQL 结构文件路径")
args = parser.parse_args()
if args.sql:
import_sql(args.sql)
else:
create_tables()
if __name__ == "__main__":
main()
+32
View File
@@ -0,0 +1,32 @@
"""数据库迁移脚本(Alembic 封装)。
用法:
python scripts/migrate.py # 升级到最新
python scripts/migrate.py --revision abc123 # 升级到指定版本
"""
import argparse
import subprocess
import sys
def run_alembic(args_list: list[str]) -> None:
cmd = [sys.executable, "-m", "alembic", *args_list]
print("执行:", " ".join(cmd))
result = subprocess.run(cmd)
sys.exit(result.returncode)
def main() -> None:
parser = argparse.ArgumentParser(description="数据库迁移")
parser.add_argument("--revision", type=str, default="head", help="迁移目标版本")
parser.add_argument("--downgrade", action="store_true", help="回滚迁移")
args = parser.parse_args()
if args.downgrade:
run_alembic(["downgrade", args.revision])
else:
run_alembic(["upgrade", args.revision])
if __name__ == "__main__":
main()
+615
View File
@@ -0,0 +1,615 @@
"""
跨库数据迁移脚本(MySQL → PostgreSQL / SQL Server / SQLite 等)
=============================================================
把「源库」的数据整体搬到「目标库」:
- 目标库结构用模型 metadata 生成(Base.metadata.create_all,方言可移植,
与 init_db.py / alembic 0001_baseline 同源,模型即 head,含
0002 的 city.parent_code nullable);
- 数据按拓扑序逐表 SELECT ... ORDER BY id,显式带 id 批量插入,保住
无外键裸列引用关系(user.dept_id、role_menu.role_id/menu_id 等);
- 最后按方言重置自增序列(PG setval / MSSQL IDENTITY_INSERT + DBCC
CHECKIDENT / Oracle 为每表重建 sequence + BEFORE INSERT 触发器;
MySQL / SQLite 显式插 id 后自动跟随,无需处理)。
与 init_db.py 的分工:
- init_db.py 单库初始化(建库 + 导 MySQL dump + 补表 + alembic stamp)
- migrate_db.py 双库迁移(源库 → 目标库,CLI 显式参数,不依赖 .env 连接串)
用法:
python scripts/migrate_db.py \
--src-driver=mysql --src-host=127.0.0.1 --src-port=3306 \
--src-db=djangoadmin.fastapi.elevue --src-user=root --src-pass=root \
--dst-driver=postgresql --dst-host=127.0.0.1 --dst-port=5432 \
--dst-db=djangoadmin.fastapi.elevue --dst-user=root --dst-pass=root \
--drop-target-first
先干跑核对计划:加 --dry-run(只连源、逐表数行数、打印计划,不碰目标库)。
"""
import argparse
import os
import sys
import time
from pathlib import Path
from urllib.parse import quote_plus
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
# Windows 控制台默认 GBK(cp936):✓ 等字符无法编码会崩溃、中文按终端编码显示成乱码。
# 统一把 stdout/stderr 切到 UTF-8,Git Bash / 重定向均正常;无 reconfigure 的环境跳过。
if sys.stdout and hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
from sqlalchemy import create_engine, inspect, select, text
from sqlalchemy.pool import NullPool
from config.database import DB_PREFIX
from core.database import Base
def _normalize_driver(driver: str) -> str:
"""方言别名 → 规范名(postgres=postgresql、sqlserver=mssql、sqlite3=sqlite)。"""
aliases = {"postgres": "postgresql", "sqlserver": "mssql", "sqlite3": "sqlite"}
return aliases.get(driver.strip().lower(), driver.strip().lower())
def _sqlite_url(database: str) -> str:
"""SQLite 连接串(database 即数据库文件路径,反斜杠转正斜杠)。"""
if not database:
return "sqlite:///:memory:"
return f"sqlite:///{database.replace(chr(92), '/')}"
def _load_models() -> None:
"""导入各模块 models 填充 Base.metadata(与 init_db.py / alembic env.py 同源,模型即 head)。"""
import modules.auth.models
import modules.user.models
import modules.user_role.models
import modules.role.models
import modules.role_menu.models
import modules.menu.models
import modules.dept.models
import modules.category.models
import modules.article.models
import modules.param.models
import modules.file_template.models
import modules.job.models
import modules.dictionary.dict.models
import modules.dictionary.dict_item.models
import modules.configuration.config.models
import modules.configuration.config_item.models
import modules.level.models
import modules.position.models
import modules.city.models
import modules.notice.models
import modules.link.models
import modules.operation_log.models
import modules.login_log.models
import modules.job_log.models
import modules.example.models
# 各驱动默认端口(sqlite 无端口概念)
_DEFAULT_PORTS = {"mysql": 3306, "postgresql": 5432, "mssql": 1433, "oracle": 1521, "sqlite": None}
# 迁移顺序:基础/被引用表在前,依赖表在后。项目无外键,表间关联是裸 Integer 列
# 由应用层维护,显式带 id 插入即保住引用关系;保序只是锦上添花(父先子后)。
_BASE_TABLES = [
"dept", "menu", "role", "level", "position", "city", "category",
"dict", "config", "file_template", "param", "job", "link",
]
_DEP_TABLES = [
"role_menu", "dict_item", "config_item", "job_log",
"user", "user_role", "article", "notice", "operation_log", "login_log",
]
def build_parser() -> argparse.ArgumentParser:
"""CLI 参数:源/目标两端连接显式给出,不依赖 .env。"""
p = argparse.ArgumentParser(description="跨库数据迁移:源库 → 目标库")
def add_side(side: str, default_user: str):
p.add_argument(
f"--{side}-driver",
choices=("mysql", "postgresql", "mssql", "oracle", "sqlite"),
default="mysql",
help="驱动(oracle 时 --{side}-db 即 service_name;sqlite 时即数据库文件路径)",
)
p.add_argument(f"--{side}-host", default="127.0.0.1")
p.add_argument(f"--{side}-port", type=int, default=None,
help="端口,缺省按驱动默认(mysql 3306 / pg 5432 / mssql 1433 / oracle 1521)")
p.add_argument(f"--{side}-db", required=True, help="数据库名(sqlite 时为文件路径)")
p.add_argument(f"--{side}-user", default=default_user)
p.add_argument(f"--{side}-pass", default="",
help=f"口令,留空回落环境变量 {side.upper()}_DB_PASS(避免进 shell 历史)")
add_side("src", "root")
add_side("dst", "postgres")
p.add_argument("--drop-target-first", action="store_true", help="先删目标库再重建(默认关)")
p.add_argument("--dry-run", action="store_true", help="只连源、逐表数行数、打印计划,不碰目标库")
p.add_argument("--chunk-size", type=int, default=1000, help="每批插入行数(默认 1000)")
p.add_argument("--only-tables", default="", help="逗号分隔的表名子集(全名或去掉前缀的短名),续传用")
p.add_argument("--skip-existing", action="store_true", help="目标已等于源行数的表跳过")
p.add_argument("--skip-verify", action="store_true", help="跳过迁移后的逐表行数校验")
p.add_argument("--single-transaction", action="store_true", help="全部表包进一个大事务(默认每表一个事务)")
return p
# ============================================================
# URL / 引擎
# ============================================================
def make_url(driver: str, host: str, port: int, database: str, username: str, password: str) -> str:
"""带显式参数的连接串构建器,URL 形态与 config.database.DATABASE_URL 的多方言拼接规则一致
(config.database 读 .env 全局,脚本端需要显式参数版)。"""
drv = _normalize_driver(driver)
if drv == 'sqlite':
return _sqlite_url(database) # 此时 database 即文件路径,忽略 host/port/user/pass
base = quote_plus(username) + ':' + quote_plus(password) + '@' + host + ':' + str(port)
if drv == 'oracle':
# Oracle 的"库"即连接的服务名(service_name),schema 是连接用户
return 'oracle+oracledb://' + base + '/?service_name=' + quote_plus(database)
base = base + '/' + database
if drv == 'mysql':
return 'mysql+pymysql://' + base + '?charset=utf8mb4'
if drv == 'postgresql':
return 'postgresql+psycopg://' + base
return 'mssql+pymssql://' + base + '?charset=utf8'
def make_engine(driver: str, host: str, port: int, database: str, username: str, password: str):
"""迁移专用引擎:NullPool,避免连接池把目标/源的会话状态串起来。"""
return create_engine(make_url(driver, host, port, database, username, password), poolclass=NullPool)
def _resolve_port(driver: str, port: int | None) -> int | None:
return port if port is not None else _DEFAULT_PORTS[driver]
def _fmt_duration(sec: float) -> str:
"""耗时格式化:<1s 显示毫秒,<60s 显示秒,否则分+秒。"""
if sec < 1:
return f"{sec * 1000:.0f}ms"
if sec < 60:
return f"{sec:.1f}s"
return f"{int(sec // 60)}m{int(sec % 60)}s"
_BUSY = "[·]" # 阶段进行中
_DONE = "[✓]" # 阶段完成
def _phase_start(n: int, total: int, desc: str, hint: bool = True):
"""阶段开始横幅:`[·] [1/5] 建目标库...(可能耗时较长,请勿中断)`"""
tail = "(可能耗时较长,请勿中断)" if hint else ""
print(f"{_BUSY} [{n}/{total}] {desc}...{tail}")
def _phase_done(n: int, total: int, desc: str, t0: float, extra: str = ""):
"""阶段完成横幅:`[✓] [1/5] 建目标库 完成(耗时 947ms)`"""
print(f"{_DONE} [{n}/{total}] {desc} 完成(耗时 {_fmt_duration(time.perf_counter() - t0)}){extra}")
# ============================================================
# 建目标库(镜像 init_db.py 的分支)
# ============================================================
def create_database_if_missing(driver: str, host: str, port: int, database: str,
username: str, password: str, drop_first: bool = False,
tables: tuple = ()):
"""按方言连维护库建目标库(不存在才建)。sqlite 无建库概念,只确保父目录并可选删旧文件。
- mysql: 连无库连接 CREATE DATABASE ... utf8mb4
- postgresql: 连 postgres 维护库(autocommit),库名含点号必须双引号
- mssql: 连 master 维护库,标识符用方括号
- oracle: 服务由 DBA 管理,schema 即连接用户,无需/无法"建库";仅校验连通性,
drop_first 时尽力 DROP TABLE PURGE 清掉 tables 里已存在的项目表
"""
if driver == 'sqlite':
file = Path(database).expanduser()
print(f" · 检查/准备 sqlite 文件 {file}...")
if file.parent.as_posix() != '.':
file.parent.mkdir(parents=True, exist_ok=True)
if drop_first and file.exists():
print(f" · 删除旧 sqlite 文件 {file}...")
file.unlink()
return
if driver == 'mysql':
import pymysql
print(" · 正在连接 MySQL(无库连接)...")
conn = pymysql.connect(host=host, port=int(port), user=username, password=password, autocommit=True)
cur = conn.cursor()
if drop_first:
print(f" · 正在删除旧目标库 `{database}`...")
cur.execute(f"DROP DATABASE IF EXISTS `{database}`")
print(f" · 正在创建目标库 `{database}`(utf8mb4)...")
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{database}` "
f"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"
)
cur.close()
conn.close()
return
if driver == 'postgresql':
import psycopg
# 连 postgres 维护库创建目标库;DB_DATABASE 含点号,标识符必须双引号
print(" · 正在连接 postgres 维护库...")
conn = psycopg.connect(
host=host, port=int(port), user=username, password=password,
dbname="postgres", autocommit=True,
)
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (database,))
exists = cur.fetchone() is not None
if drop_first and exists:
print(f" · 正在删除旧目标库 \"{database}\"(WITH FORCE)...")
cur.execute(f'DROP DATABASE "{database}" WITH (FORCE)')
exists = False
if not exists:
# 强制 UTF8 编码(TEMPLATE template0 避免继承模板库非 UTF8 编码导致 CJK 乱码)
print(f" · 正在创建目标库 \"{database}\"(UTF8)...")
cur.execute(f'CREATE DATABASE "{database}" ENCODING \'UTF8\' TEMPLATE template0')
conn.close()
return
if driver == 'oracle':
# 服务由 DBA 管理,schema 就是连接用户;连上即视为"目标库就绪"
import oracledb
print(f" · 正在连接 Oracle 服务 {database}(schema={username})...")
conn = oracledb.connect(user=username, password=password, dsn=f"{host}:{port}/{database}")
cur = conn.cursor()
cur.execute("SELECT 1 FROM dual")
cur.fetchone()
if drop_first:
print(f" · drop-first:清理 {len(tables)} 张旧项目表(DROP TABLE ... PURGE)...")
for t in tables:
try:
cur.execute(f"DROP TABLE {t} PURGE")
except Exception:
pass # 表不存在则跳过
conn.close()
return
# mssql:连 master 维护库创建目标库
import pymssql
print(" · 正在连接 SQL Server(master 维护库)...")
conn = pymssql.connect(
server=host, user=username, password=password,
port=int(port), database="master", autocommit=True,
)
cur = conn.cursor()
if drop_first:
print(f" · 正在删除旧目标库 [{database}](SINGLE_USER + DROP)...")
cur.execute(
f"IF DB_ID(N'{database}') IS NOT NULL "
f"ALTER DATABASE [{database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE"
)
cur.execute(f"IF DB_ID(N'{database}') IS NOT NULL DROP DATABASE [{database}]")
print(f" · 正在创建目标库 [{database}]...")
cur.execute(f"IF DB_ID(N'{database}') IS NULL CREATE DATABASE [{database}]")
cur.close()
conn.close()
# ============================================================
# 迁移顺序
# ============================================================
def ordered_tables():
"""按拓扑序返回要迁移的 Table 对象(base 先、dep 后;metadata 中未列出的追加末尾)。"""
out, seen = [], set()
for name in _BASE_TABLES + _DEP_TABLES:
tbl = Base.metadata.tables.get(DB_PREFIX + name)
if tbl is not None:
out.append(tbl)
seen.add(tbl.name)
for tbl in Base.metadata.tables.values():
if tbl.name not in seen:
out.append(tbl)
return out
# ============================================================
# 泵数据
# ============================================================
def sanitize(payload: list[dict], table) -> list[dict]:
"""把驱动读回的零日期/非法日期字符串统一置 None(防御性兜底)。
PyMySQL 默认已把 MySQL 的 '0000-00-00 00:00:00' 转成 None;此处兜底处理
个别驱动返回原始字符串的情况,避免 PostgreSQL/MSSQL 拒绝非法日期。
本项目时间列全部 nullable,置 None 安全。
"""
for row in payload:
for col in table.columns:
v = row.get(col.name)
if isinstance(v, str) and v.startswith('0000-00-00'):
row[col.name] = None
return payload
def pump_table(src, dst, table, chunk_size: int, driver: str) -> int:
"""逐 chunk 批量插入一张表,显式带 id 以保住裸列引用关系。
src/dst 为已就绪的 Connection;dst 处于调用方开启的事务内
(默认每表一个事务;--single-transaction 时全库一个大事务)。
mssql 必须 SET IDENTITY_INSERT ON 才能显式插自增 id。
"""
pk = next(iter(table.primary_key.columns))
sel = select(*table.columns).order_by(pk) # ORDER BY id:自引用父先子后
use_identity = (driver == 'mssql')
total = 0
t0 = time.perf_counter()
if use_identity:
dst.execute(text(f"SET IDENTITY_INSERT [{table.name}] ON"))
try:
result = src.execute(sel)
while True:
rows = result.fetchmany(chunk_size)
if not rows:
break
payload = sanitize([dict(r._mapping) for r in rows], table)
dst.execute(table.insert(), payload)
total += len(payload)
print(f" [{table.name}] +{len(payload)}(累计 {total})")
finally:
if use_identity:
dst.execute(text(f"SET IDENTITY_INSERT [{table.name}] OFF"))
print(f" [✓ {table.name}] 共 {total} 行(耗时 {_fmt_duration(time.perf_counter() - t0)})")
return total
# ============================================================
# 自增序列重置
# ============================================================
def pg_reset_sql(table_name: str) -> str:
"""PostgreSQL:把 SERIAL/IDENTITY 序列推进到 MAX(id),否则显式插 id 后新插入会主键冲突。
- 非空表:setval(seq, MAX(id)),is_called=true → 下一 id = MAX(id)+1
- 空表: setval(seq, 1, false),is_called=false → 下一 id = 1
(不能 setval(seq, 0):PG 序列最小值是 1,会抛 NumericValueOutOfRange)
"""
return (
f"SELECT setval(pg_get_serial_sequence('{table_name}', 'id'), "
f"COALESCE((SELECT MAX(id) FROM \"{table_name}\"), 1), "
f"(SELECT MAX(id) FROM \"{table_name}\") IS NOT NULL)"
)
def mssql_reset_sql(table_name: str, max_id) -> str:
"""SQL Server:RESEED 必须是字面量,先查 MAX(id) 再拼。空表 RESEED 0 → 下一 id=1。"""
return f"DBCC CHECKIDENT ('{table_name}', RESEED, {max_id or 0})"
def oracle_sequence_sql(table_name: str, start_with: int) -> str:
"""Oracle:为无自增机制的 INTEGER 主键建 sequence,START WITH 决定下一 id。"""
return f"CREATE SEQUENCE {table_name}_id_seq START WITH {start_with} INCREMENT BY 1 NOCACHE"
def oracle_trigger_sql(table_name: str) -> str:
"""Oracle:BEFORE INSERT 触发器,id 为 NULL 时从 sequence 取 NEXTVAL。"""
seq = f"{table_name}_id_seq"
trg = f"{table_name}_id_trg"
return (
f"CREATE OR REPLACE TRIGGER {trg}\n"
f"BEFORE INSERT ON {table_name} FOR EACH ROW\n"
f"WHEN (NEW.id IS NULL)\n"
f"BEGIN SELECT {seq}.NEXTVAL INTO :NEW.id FROM dual; END;"
)
def ensure_oracle_autoincrement(conn, table_name: str):
"""Oracle 迁移后补自增机制:重建 sequence(START WITH MAX(id)+1)+ BEFORE INSERT 触发器。
Oracle 的 create_all 对 Integer 自增主键只生成 INTEGER NOT NULL(无 identity/sequence),
不补的话应用 save() 拿不到新 id。空表 MAX(id) 为 NULL → COALESCE 0 → START WITH 1。
Oracle 无 DROP SEQUENCE IF EXISTS,先查 user_sequences 再删。
"""
max_id = conn.execute(text(f"SELECT COALESCE(MAX(id), 0) FROM {table_name}")).scalar()
seq = f"{table_name}_id_seq"
exists = conn.execute(text(
"SELECT COUNT(*) FROM user_sequences WHERE sequence_name = UPPER(:n)"
), {"n": seq}).scalar()
if exists:
conn.execute(text(f"DROP SEQUENCE {seq}"))
conn.execute(text(oracle_sequence_sql(table_name, max_id + 1)))
conn.execute(text(oracle_trigger_sql(table_name)))
def reset_sequence(driver: str, conn, table):
"""迁移后重置自增序列。MySQL / SQLite 显式插 id 后 auto_increment/rowid 自动
跟随 MAX(id),无需处理;PostgreSQL / SQL Server / Oracle 需要。"""
if driver == 'postgresql':
conn.execute(text(pg_reset_sql(table.name)))
elif driver == 'mssql':
max_id = conn.execute(text(f"SELECT MAX(id) FROM [{table.name}]")).scalar()
conn.execute(text(mssql_reset_sql(table.name, max_id)))
elif driver == 'oracle':
ensure_oracle_autoincrement(conn, table.name)
# ============================================================
# 校验
# ============================================================
def verify_counts(src, dst, tables) -> list[tuple[str, int, int]]:
"""逐表 count 对比,返回不一致的表列表((表名, 源行数, 目标行数))。"""
failed = []
for t in tables:
s = src.execute(text(f"SELECT COUNT(*) FROM {t.name}")).scalar()
d = dst.execute(text(f"SELECT COUNT(*) FROM {t.name}")).scalar()
mark = "PASS" if s == d else "FAIL"
print(f" [{t.name}] source={s} target={d} {mark}")
if s != d:
failed.append((t.name, s, d))
return failed
def _existing_equal(src, dst, table) -> bool:
s = src.execute(text(f"SELECT COUNT(*) FROM {table.name}")).scalar()
d = dst.execute(text(f"SELECT COUNT(*) FROM {table.name}")).scalar()
return s == d
# ============================================================
# 主流程
# ============================================================
def _dry_run(src_engine, tables, dst_driver: str, dst_db: str, t_start: float) -> int:
print("=" * 62)
print(f"[DRY-RUN 开始] 计划迁移 {len(tables)} 张表 → {dst_driver}:{dst_db}")
print("=" * 62)
with src_engine.connect() as src:
for t in tables:
n = src.execute(text(f"SELECT COUNT(*) FROM {t.name}")).scalar()
print(f" - {t.name}: {n} 行")
print(f"[DRY-RUN 完成] {len(tables)} 张表核对完毕(耗时 {_fmt_duration(time.perf_counter() - t_start)}),未触碰目标库")
return 0
def run(args) -> int:
"""执行一次迁移。返回进程退出码(0 成功 / 1 校验失败)。"""
src_driver = _normalize_driver(args.src_driver)
dst_driver = _normalize_driver(args.dst_driver)
src_engine = make_engine(
src_driver, args.src_host, _resolve_port(src_driver, args.src_port),
args.src_db, args.src_user, args.src_pass or os.environ.get("SRC_DB_PASS", ""),
)
dst_engine = make_engine(
dst_driver, args.dst_host, _resolve_port(dst_driver, args.dst_port),
args.dst_db, args.dst_user, args.dst_pass or os.environ.get("DST_DB_PASS", ""),
)
# 加载全部模型填充 Base.metadata(与 init_db.py / alembic env.py 同源)
_load_models()
tables = ordered_tables()
if args.only_tables:
wanted = {x.strip() for x in args.only_tables.split(",") if x.strip()}
tables = [t for t in tables if t.name in wanted or t.name[len(DB_PREFIX):] in wanted]
try:
# 源端缺表容错(老库可能缺整表)
src_insp = inspect(src_engine)
present, missing = [], []
for t in tables:
if src_insp.has_table(t.name):
present.append(t)
else:
missing.append(t)
for t in missing:
print(f"[SKIP] 源库不存在表 {t.name},跳过")
t_start = time.perf_counter()
if args.dry_run:
return _dry_run(src_engine, present, dst_driver, args.dst_db, t_start)
print("=" * 62)
print(f"[迁移开始] 源: {src_driver}:{args.src_db} → 目标: {dst_driver}:{args.dst_db}")
note = "" if not missing else f",源端缺失 {len(missing)} 张已跳过"
print(f"[迁移开始] 待迁移 {len(present)} 张表{note}")
print("=" * 62)
# 1) 建目标库
t0 = time.perf_counter()
_phase_start(1, 5, "建目标库")
create_database_if_missing(
dst_driver, args.dst_host, _resolve_port(dst_driver, args.dst_port),
args.dst_db, args.dst_user, args.dst_pass or os.environ.get("DST_DB_PASS", ""),
drop_first=args.drop_target_first,
tables=tuple(t.name for t in present), # Oracle drop_first 时清表用
)
_phase_done(1, 5, "建目标库", t0)
# 2) 建结构(模型即 head,方言可移植)
t0 = time.perf_counter()
_phase_start(2, 5, f"建表结构({len(present)} 张表 create_all)")
Base.metadata.create_all(dst_engine)
_phase_done(2, 5, f"建表结构({len(present)} 张表 create_all)", t0, extra=f",{len(present)} 张表")
# 3) 逐表迁移数据
t0 = time.perf_counter()
_phase_start(3, 5, "逐表迁移数据")
total_rows = 0
n_present = len(present)
if args.single_transaction:
with dst_engine.begin() as dst, src_engine.connect() as src:
for idx, t in enumerate(present, 1):
if args.skip_existing and _existing_equal(src, dst, t):
print(f" · [{idx}/{n_present}] 跳过 {t.name}(源=目标)")
continue
print(f" · [{idx}/{n_present}] 正在迁移 {t.name}...")
total_rows += pump_table(src, dst, t, args.chunk_size, dst_driver)
else:
with src_engine.connect() as src:
for idx, t in enumerate(present, 1):
with dst_engine.begin() as dst:
if args.skip_existing and _existing_equal(src, dst, t):
print(f" · [{idx}/{n_present}] 跳过 {t.name}(源=目标)")
continue
print(f" · [{idx}/{n_present}] 正在迁移 {t.name}...")
total_rows += pump_table(src, dst, t, args.chunk_size, dst_driver)
_phase_done(3, 5, "逐表迁移数据", t0, extra=f",共 {total_rows} 行")
# 4) 重置自增序列(PG/MSSQL/Oracle;MySQL/SQLite 自动跟随)
# 单表失败不中断其余表,避免一张空表序列异常连累后续全部序列
reset_errors = []
t0 = time.perf_counter()
_phase_start(4, 5, "重置自增序列")
if dst_driver in ("postgresql", "mssql", "oracle"):
with dst_engine.begin() as dst:
for t in present:
try:
reset_sequence(dst_driver, dst, t)
except Exception as e:
reset_errors.append((t.name, str(e)))
print(f" · [WARN] 序列重置失败 {t.name}: {e}")
tail = f",{len(reset_errors)} 张失败" if reset_errors else ""
_phase_done(4, 5, "重置自增序列", t0, extra=tail)
else:
print(" · MySQL/SQLite 自动跟随,无需处理")
_phase_done(4, 5, "重置自增序列", t0, extra="(无需处理)")
# 5) 校验
t0 = time.perf_counter()
_phase_start(5, 5, "逐表行数校验")
if not args.skip_verify:
with src_engine.connect() as src, dst_engine.connect() as dst:
failed = verify_counts(src, dst, present)
if failed:
_phase_done(5, 5, "逐表行数校验", t0, extra=f",FAIL {len(failed)} 张")
for name, s, d in failed:
print(f" - {name}: source={s} target={d}")
return 1
_phase_done(5, 5, "逐表行数校验", t0, extra=f",{len(present)} 张表全部 PASS")
else:
print(" · 已跳过(--skip-verify)")
_phase_done(5, 5, "逐表行数校验", t0, extra="(已跳过)")
# 汇总
print("=" * 62)
print(f"[迁移完成] 成功:{total_rows} 行 / {len(present)} 张表 / 总耗时 {_fmt_duration(time.perf_counter() - t_start)}")
print("=" * 62)
return 0
finally:
src_engine.dispose()
dst_engine.dispose()
def main():
args = build_parser().parse_args()
try:
sys.exit(run(args))
except KeyboardInterrupt:
sys.exit(130)
if __name__ == "__main__":
main()
+196
View File
@@ -0,0 +1,196 @@
"""测试数据填充脚本。
用法:python scripts/seed.py
- 创建默认管理员账号(admin / 123456)
- 填充任务调度示例数据:任务菜单/权限节点、示例任务、示例任务日志
"""
import sys
from datetime import datetime, timedelta
from pathlib import Path
from sqlalchemy import inspect
SRC = Path(__file__).resolve().parent.parent / "src"
sys.path.insert(0, str(SRC))
from common.utils.crypto import set_password
from config.app import TORNADO_PORT
from extensions.sqlalchemy import SessionLocal
from modules.user.models import User
def seed_admin() -> None:
"""创建默认管理员账号(幂等)。"""
db = SessionLocal()
try:
exists = db.query(User).filter(User.username == "admin").first()
if exists:
print("管理员账号已存在,跳过。")
return
salt, hashed = set_password("123456")
admin = User(
realname="超级管理员",
nickname="管理员",
username="admin",
password=hashed,
salt=salt,
gender=1,
status=1,
sort=0,
)
db.add(admin)
db.commit()
print("管理员账号创建成功:admin / 123456")
except Exception as e:
db.rollback()
print(f"填充失败: {e}")
sys.exit(1)
finally:
db.close()
def _ensure_table(db, required: tuple) -> bool:
"""校验所需数据表是否存在,不存在则提示并返回 False。"""
existing = set(inspect(db.bind).get_table_names())
missing = [t for t in required if t not in existing]
if missing:
print(f"表不存在,跳过任务数据填充:{', '.join(missing)}(请先执行 alembic upgrade head)")
return False
return True
def seed_job_data() -> None:
"""填充任务调度示例数据(幂等):菜单/权限节点 + 示例任务 + 示例任务日志。"""
db = SessionLocal()
try:
from modules.job.models import Job
from modules.job_log.models import JobLog
from modules.menu.models import Menu
if not _ensure_table(db, ("tornado_menu", "tornado_job", "tornado_job_log")):
return
# ---------- 1. 菜单与权限节点(挂到"系统管理"下) ----------
system = db.query(Menu).filter(Menu.title == "系统管理", Menu.parent_id == 0).first()
parent_id = system.id if system else 0
# 任务调度菜单
job_menu = db.query(Menu).filter(Menu.permission == "sys:job:view").first()
if not job_menu:
job_menu = Menu(title="任务调度", icon="el-icon-alarm-clock", parent_id=parent_id,
path="/system/job", component="/system/job", target="_self",
permission="sys:job:view", type=0, status=1, hide=0, sort=40)
db.add(job_menu)
db.flush()
print("创建菜单:任务调度")
else:
print("菜单已存在:任务调度")
# 任务日志菜单
joblog_menu = db.query(Menu).filter(Menu.permission == "sys:joblog:view").first()
if not joblog_menu:
joblog_menu = Menu(title="任务日志", icon="el-icon-document", parent_id=parent_id,
path="/system/joblog", component="/system/joblog", target="_self",
permission="sys:joblog:view", type=0, status=1, hide=0, sort=45)
db.add(joblog_menu)
db.flush()
print("创建菜单:任务日志")
else:
print("菜单已存在:任务日志")
# 任务调度权限节点(type=1,按钮级)
job_nodes = [
("查询任务", "sys:job:list", "/job/list", 1),
("任务详情", "sys:job:detail", "/job/detail", 5),
("添加任务", "sys:job:add", "/job/add", 10),
("修改任务", "sys:job:update", "/job/update", 15),
("删除任务", "sys:job:delete", "/job/delete", 20),
("设置状态", "sys:job:status", "/job/status", 25),
("立即执行", "sys:job:run", "/job/run", 30),
]
for title, perm, path, sort in job_nodes:
if not db.query(Menu).filter(Menu.permission == perm).first():
db.add(Menu(title=title, icon="", parent_id=job_menu.id, path=path,
component="", target="_self", permission=perm, type=1,
status=1, hide=0, sort=sort))
print(f"创建权限节点:{title}({perm})")
# 任务日志权限节点
joblog_nodes = [
("查询日志", "sys:joblog:list", "/joblog/list", 1),
("日志详情", "sys:joblog:detail", "/joblog/detail", 5),
("删除日志", "sys:joblog:delete", "/joblog/delete", 10),
]
for title, perm, path, sort in joblog_nodes:
if not db.query(Menu).filter(Menu.permission == perm).first():
db.add(Menu(title=title, icon="", parent_id=joblog_menu.id, path=path,
component="", target="_self", permission=perm, type=1,
status=1, hide=0, sort=sort))
print(f"创建权限节点:{title}({perm})")
# ---------- 2. 示例任务(回调指向本服务健康检查接口,可真实触发并产生日志) ----------
base_url = f"http://127.0.0.1:{TORNADO_PORT}/api/v1/health"
sample_jobs = [
dict(job_name="应用健康检查", job_alias="health", job_group="系统任务",
job_trigger="cron", status=1, cron_expression="*/5 * * * *",
execute_policy=1, is_sync=0, url=base_url,
note="每5分钟检测应用健康状态(示例任务)"),
dict(job_name="夜间数据备份", job_alias="backup", job_group="系统任务",
job_trigger="cron", status=2, cron_expression="0 0 2 * * ?",
execute_policy=1, is_sync=0, url=base_url,
note="每日凌晨2点执行数据备份(示例任务,已暂停)"),
dict(job_name="订单数据同步", job_alias="sync", job_group="业务任务",
job_trigger="cron", status=0, cron_expression="0 */30 * * * *",
execute_policy=2, is_sync=0, url=base_url,
note="每30分钟同步订单数据(示例任务,未发布)"),
]
for item in sample_jobs:
if db.query(Job).filter(Job.job_name == item["job_name"]).first():
print(f"任务已存在:{item['job_name']}")
continue
db.add(Job(**item))
print(f"创建示例任务:{item['job_name']}")
# flush 让新增任务可见,供下方示例日志回填真实 job_id
db.flush()
# ---------- 3. 示例任务日志(仅表为空时填充,按任务名回查真实 job_id) ----------
def _job_id(name: str):
job = db.query(Job).filter(Job.job_name == name).first()
return job.id if job else None
if db.query(JobLog).count() == 0:
now = datetime.now()
refs = [
("应用健康检查", 0, 156, None),
("应用健康检查", 0, 203, None),
("应用健康检查", 1, 10001, "urlopen error <urlopen error timed out>"),
]
sample_logs = []
for idx, (name, status, consume, exc) in enumerate(refs):
jid = _job_id(name)
if jid is None:
continue
start = now - timedelta(minutes=(idx + 1) * 5)
sample_logs.append(JobLog(
job_id=jid, job_name=name, job_group="系统任务", job_trigger="cron",
job_message="任务触发成功" if status == 0 else "任务触发失败",
cron_expression="*/5 * * * *", status=status,
start_time=start, end_time=start + timedelta(milliseconds=consume),
consume_time=consume, exception_info=exc,
))
db.add_all(sample_logs)
print(f"创建示例任务日志 {len(sample_logs)} 条")
db.commit()
print("任务调度示例数据填充完成。")
except Exception as e:
db.rollback()
print(f"任务数据填充失败: {e}")
sys.exit(1)
finally:
db.close()
if __name__ == "__main__":
seed_admin()
seed_job_data()
+282
View File
@@ -0,0 +1,282 @@
"""
业务字典数据初始化脚本
=============================================================
幂等写入业务字典(dict + dict_item):按字典编码(code)判重,
已存在则跳过、不覆盖现有数据;适合全新安装或现有库增量补齐。
字典格式(code/name/note/items),字典排序与字典项排序由列表顺序自动生成:
DICT_SEED = [
{
"code": "gender", "name": "性别", "note": "用户性别",
"items": [
{"value": "1", "name": "男"},
{"value": "2", "name": "女"},
],
},
]
注意:code 必须与业务代码中 get_dict_map / get_dict_label / serialize_maps
引用的编码一致(见 src/common/utils/dict_util.py),改 code 需同步改业务引用。
用法:
python scripts/seed_dict.py # 使用 .env 中的数据库配置
python scripts/seed_dict.py --dry-run # 只打印将写入的字典,不落库
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
# Windows 控制台默认 GBK(cp936):把 stdout/stderr 切到 UTF-8,避免中文打印乱码/崩溃
if sys.stdout and hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# 业务字典种子数据
DICT_SEED = [
{
"code": "gender",
"name": "性别",
"note": "用户性别",
"items": [
{"value": "1", "name": "男"},
{"value": "2", "name": "女"},
{"value": "3", "name": "保密"},
],
},
{
"code": "dept_type",
"name": "部门类型",
"note": "部门层级类型",
"items": [
{"value": "1", "name": "公司"},
{"value": "2", "name": "子公司"},
{"value": "3", "name": "部门"},
{"value": "4", "name": "小组"},
],
},
{
"code": "article_status",
"name": "文章状态",
"note": "文章上下架状态",
"items": [
{"value": "0", "name": "正常"},
{"value": "1", "name": "下架"},
],
},
{
"code": "notice_source",
"name": "通知来源",
"note": "通知公告来源",
"items": [
{"value": "1", "name": "官方平台"},
{"value": "2", "name": "开源中国"},
{"value": "3", "name": "CSDN官方"},
{"value": "4", "name": "新浪微博"},
],
},
{
"code": "log_type",
"name": "日志类型",
"note": "登录日志类型",
"items": [
{"value": "1", "name": "登录"},
{"value": "2", "name": "退出"},
],
},
{
"code": "login_source",
"name": "登录来源",
"note": "登录入口来源",
"items": [
{"value": "0", "name": "后台"},
{"value": "1", "name": "前台"},
],
},
{
"code": "login_status",
"name": "登录状态",
"note": "登录日志状态",
"items": [
{"value": "0", "name": "正常"},
{"value": "1", "name": "异常"},
],
},
{
"code": "operation_source",
"name": "操作日志来源",
"note": "操作日志来源",
"items": [
{"value": "0", "name": "后台"},
{"value": "1", "name": "前台"},
],
},
{
"code": "operation_status",
"name": "操作日志状态",
"note": "操作日志状态",
"items": [
{"value": "0", "name": "正常"},
{"value": "1", "name": "异常"},
],
},
{
"code": "param_type",
"name": "参数类型",
"note": "参数分类",
"items": [
{"value": "0", "name": "系统"},
{"value": "1", "name": "业务"},
],
},
{
"code": "param_status",
"name": "参数状态",
"note": "参数启用状态",
"items": [
{"value": "1", "name": "正常"},
{"value": "2", "name": "禁用"},
],
},
{
"code": "job_status",
"name": "任务状态",
"note": "定时任务状态",
"items": [
{"value": "0", "name": "未发布"},
{"value": "1", "name": "运行中"},
{"value": "2", "name": "暂停"},
{"value": "3", "name": "删除"},
],
},
{
"code": "job_execute_policy",
"name": "执行策略",
"note": "任务失败执行策略",
"items": [
{"value": "1", "name": "立即执行"},
{"value": "2", "name": "执行一次"},
{"value": "3", "name": "放弃执行"},
],
},
{
"code": "job_log_status",
"name": "任务日志状态",
"note": "任务执行结果状态",
"items": [
{"value": "0", "name": "正常"},
{"value": "1", "name": "失败"},
],
},
{
"code": "link_type",
"name": "友链类型",
"note": "友链分类",
"items": [
{"value": "1", "name": "友情链接"},
{"value": "2", "name": "合作伙伴"},
],
},
{
"code": "link_platform",
"name": "友链投放平台",
"note": "友链投放平台",
"items": [
{"value": "1", "name": "PC站"},
{"value": "2", "name": "WAP站"},
{"value": "3", "name": "微信小程序"},
{"value": "4", "name": "APP应用"},
],
},
{
"code": "link_form",
"name": "友链形式",
"note": "友链展示形式",
"items": [
{"value": "1", "name": "文字链接"},
{"value": "2", "name": "图片链接"},
],
},
{
"code": "city_level",
"name": "城市级别",
"note": "行政区划级别",
"items": [
{"value": "1", "name": "省份"},
{"value": "2", "name": "城市"},
{"value": "3", "name": "县区"},
{"value": "4", "name": "街道"},
],
},
{
"code": "config_item_type",
"name": "配置项类型",
"note": "配置项输入控件类型",
"items": [
{"value": "text", "name": "单行文本"},
{"value": "textarea", "name": "多行文本"},
{"value": "ueditor", "name": "富文本编辑器"},
{"value": "date", "name": "日期"},
{"value": "datetime", "name": "时间"},
{"value": "number", "name": "数字"},
{"value": "select", "name": "下拉框"},
{"value": "radio", "name": "单选框"},
{"value": "checkbox", "name": "复选框"},
{"value": "image", "name": "单张图片"},
{"value": "images", "name": "多张图片"},
{"value": "password", "name": "密码"},
{"value": "icon", "name": "字体图标"},
{"value": "file", "name": "单个文件"},
{"value": "files", "name": "多个文件"},
{"value": "hidden", "name": "隐藏"},
{"value": "readonly", "name": "只读文本"},
],
},
]
def main():
parser = argparse.ArgumentParser(description="初始化业务字典数据")
parser.add_argument("--dry-run", action="store_true", help="仅打印计划,不写库")
args = parser.parse_args()
from core.database import SessionLocal
from modules.dictionary.dict.models import Dict
from modules.dictionary.dict_item.models import DictItem
db = SessionLocal()
added = skipped = 0
try:
for idx, d in enumerate(DICT_SEED):
code = d["code"]
exists = db.query(Dict).filter(Dict.code == code, Dict.is_delete == 0).first()
if exists:
skipped += 1
continue
if args.dry_run:
print(f"[DRY-RUN] 将新增字典: {code}({d['name']},{len(d['items'])} 项)")
continue
dict_obj = Dict(name=d["name"], code=code, sort=(idx + 1) * 5, note=d.get("note", ""))
db.add(dict_obj)
db.flush() # 获取自增 id 供字典项外键引用
for i, item in enumerate(d["items"]):
db.add(DictItem(
name=item["name"], value=item["value"],
dict_id=dict_obj.id, sort=(i + 1) * 5, note='',
))
added += 1
db.commit()
if not args.dry_run:
print(f"done: added={added} dicts, skipped={skipped}(已存在)")
except Exception:
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
main()
+13
View File
@@ -0,0 +1,13 @@
# +======================================================================
# | 模块: 项目源码根包
# | 说明: 声明 DjangoAdmin 后端源码包,并概述各顶层目录的职责
# +======================================================================
"""DjangoAdmin Tornado+EleVue 后端源码包。
- config: 配置层(Pydantic Settings / 常量 / 消息 / 日志)
- core: 核心基础设施层
- modules: 业务模块
- common: 公共模块(中间件 / 工具 / 装饰器)
- extensions: 扩展
- api: API 版本管理
"""
+5
View File
@@ -0,0 +1,5 @@
# +======================================================================
# | 模块: API 版本管理包
# | 说明: 声明 api 为包,用于组织各版本的 API 路由模块
# +======================================================================
"""API 版本管理。"""
+9
View File
@@ -0,0 +1,9 @@
# +======================================================================
# | 模块: API v1 版本路由包
# | 说明: 声明 api.v1 为包,并说明各资源模块的路由导出约定
# +======================================================================
"""API v1 版本路由模块。
每个资源模块导出 `routes`((正则, Handler) 列表),
由 router.build_routes() 统一添加 /api/v1 前缀。
"""
+33
View File
@@ -0,0 +1,33 @@
# +======================================================================
# | 模块: 文章路由层
# | 说明: 提供文章信息的增删改查、批量删除等 API 接口
# +======================================================================
"""文章相关路由。"""
# ----------------------------------------------------------------------
# 导入文章模块的各类处理器(Handler)
# 涵盖:分页、详情、新增、更新、删除、批量删除
# ----------------------------------------------------------------------
from modules.article.handlers import (ArticleAddHandler, ArticleDeleteHandler, ArticleDetailHandler,
ArticlePageHandler, ArticleStatusHandler, ArticleUpdateHandler,
ArticleBatchDeleteHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 文章分页查询
(r"/article/page", ArticlePageHandler),
# 查询文章详情,路径参数为文章 ID
(r"/article/detail/(\d+)", ArticleDetailHandler),
# 新增文章
(r"/article/add", ArticleAddHandler),
# 更新文章信息
(r"/article/update", ArticleUpdateHandler),
# 删除单篇文章,路径参数为文章 ID
(r"/article/delete/(\d+)", ArticleDeleteHandler),
# 批量删除文章
(r"/article/batchDelete", ArticleBatchDeleteHandler),
# 修改文章状态(如上架 / 下架)
(r"/article/status", ArticleStatusHandler),
]
+30
View File
@@ -0,0 +1,30 @@
# +======================================================================
# | 模块: 认证路由层
# | 说明: 提供登录、验证码获取、Token 刷新等认证相关 API 接口
# +======================================================================
"""认证相关路由。"""
# ----------------------------------------------------------------------
# 导入认证模块的各类处理器(Handler)
# 涵盖:登录、验证码、Token 刷新
# ----------------------------------------------------------------------
from modules.auth.handlers import (
CaptchaHandler,
LoginHandler,
LogoutHandler,
TokenRefreshHandler,
)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 用户登录
(r"/login", LoginHandler),
# 退出登录
(r"/logout", LogoutHandler),
# 获取图形验证码
(r"/captcha", CaptchaHandler),
# 刷新 Token
(r"/token/refresh", TokenRefreshHandler),
]
+33
View File
@@ -0,0 +1,33 @@
# +======================================================================
# | 模块: 文章分类路由层
# | 说明: 提供文章分类的增删改查、列表获取等 API 接口
# +======================================================================
"""文章分类相关路由。"""
# ----------------------------------------------------------------------
# 导入文章分类模块的各类处理器(Handler)
# 涵盖:列表、详情、新增、更新、删除、列表获取
# ----------------------------------------------------------------------
from modules.category.handlers import (CategoryAddHandler, CategoryDeleteHandler, CategoryDetailHandler,
CategoryGetDataHandler, CategoryGetListHandler, CategoryListHandler,
CategoryUpdateHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 文章分类列表(分页 / 条件查询)
(r"/category/list", CategoryListHandler),
# 查询文章分类详情,路径参数为分类 ID
(r"/category/detail/(\d+)", CategoryDetailHandler),
# 新增文章分类
(r"/category/add", CategoryAddHandler),
# 更新文章分类信息
(r"/category/update", CategoryUpdateHandler),
# 删除单个文章分类,路径参数为分类 ID
(r"/category/delete/(\d+)", CategoryDeleteHandler),
# 获取文章分类列表(常用于下拉选择等场景)
(r"/category/getCategoryList", CategoryGetListHandler),
# 获取文章分类数据(树形结构)
(r"/category/getCategoryData", CategoryGetDataHandler),
]
+33
View File
@@ -0,0 +1,33 @@
# +======================================================================
# | 模块: 城市路由层
# | 说明: 提供城市信息的增删改查、批量删除等 API 接口
# +======================================================================
"""城市相关路由。"""
# ----------------------------------------------------------------------
# 导入城市模块的各类处理器(Handler)
# 涵盖:分页、详情、新增、更新、删除、批量删除
# ----------------------------------------------------------------------
from modules.city.handlers import (CityAddHandler, CityBatchDeleteHandler, CityDeleteHandler,
CityDetailHandler, CityGetListHandler, CityPageHandler,
CityUpdateHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 城市分页查询
(r"/city/page", CityPageHandler),
# 查询城市详情,路径参数为城市 ID
(r"/city/detail/(\d+)", CityDetailHandler),
# 新增城市
(r"/city/add", CityAddHandler),
# 更新城市信息
(r"/city/update", CityUpdateHandler),
# 删除单个城市,路径参数为城市 ID
(r"/city/delete/(\d+)", CityDeleteHandler),
# 批量删除城市
(r"/city/batchDelete", CityBatchDeleteHandler),
# 根据城市编码查询子级城市
(r"/city/getCityList/(\w+)", CityGetListHandler),
]
+36
View File
@@ -0,0 +1,36 @@
# +======================================================================
# | 模块: 配置路由层
# | 说明: 提供系统配置的增删改查、批量删除、网站配置读取与保存等 API 接口
# +======================================================================
"""配置相关路由。"""
# ----------------------------------------------------------------------
# 导入配置模块的各类处理器(Handler)
# 涵盖:分页、详情、新增、更新、删除、批量删除、网站配置读取与保存
# ----------------------------------------------------------------------
from modules.configuration.config.handlers import (ConfigAddHandler, ConfigDeleteHandler,
ConfigDetailHandler, ConfigPageHandler,
ConfigUpdateHandler, ConfigBatchDeleteHandler,
ConfigWebIndexHandler, ConfigWebSaveHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 配置分页查询
(r"/config/page", ConfigPageHandler),
# 查询配置详情,路径参数为配置 ID
(r"/config/detail/(\d+)", ConfigDetailHandler),
# 新增配置
(r"/config/add", ConfigAddHandler),
# 更新配置信息
(r"/config/update", ConfigUpdateHandler),
# 删除单个配置,路径参数为配置 ID
(r"/config/delete/(\d+)", ConfigDeleteHandler),
# 批量删除配置
(r"/config/batchDelete", ConfigBatchDeleteHandler),
# 读取网站配置(首页展示)
(r"/config/web/index", ConfigWebIndexHandler),
# 保存网站配置
(r"/config/web/save", ConfigWebSaveHandler),
]
+34
View File
@@ -0,0 +1,34 @@
# +======================================================================
# | 模块: 配置项路由层
# | 说明: 提供配置项的增删改查、批量删除、状态管理等 API 接口
# +======================================================================
"""配置项相关路由。"""
# ----------------------------------------------------------------------
# 导入配置项模块的各类处理器(Handler)
# 涵盖:分页、详情、新增、更新、删除、批量删除、状态变更
# ----------------------------------------------------------------------
from modules.configuration.config_item.handlers import (ConfigItemAddHandler, ConfigItemDeleteHandler,
ConfigItemDetailHandler, ConfigItemPageHandler,
ConfigItemStatusHandler, ConfigItemUpdateHandler,
ConfigItemBatchDeleteHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 配置项分页查询
(r"/config/item/page", ConfigItemPageHandler),
# 查询配置项详情,路径参数为配置项 ID
(r"/config/item/detail/(\d+)", ConfigItemDetailHandler),
# 新增配置项
(r"/config/item/add", ConfigItemAddHandler),
# 更新配置项信息
(r"/config/item/update", ConfigItemUpdateHandler),
# 删除单个配置项,路径参数为配置项 ID
(r"/config/item/delete/(\d+)", ConfigItemDeleteHandler),
# 批量删除配置项
(r"/config/item/batchDelete", ConfigItemBatchDeleteHandler),
# 修改配置项状态(如启用 / 禁用)
(r"/config/item/status", ConfigItemStatusHandler),
]
+30
View File
@@ -0,0 +1,30 @@
# +======================================================================
# | 模块: 部门路由层
# | 说明: 提供部门信息的增删改查、列表获取等 API 接口
# +======================================================================
"""部门相关路由。"""
# ----------------------------------------------------------------------
# 导入部门模块的各类处理器(Handler)
# 涵盖:列表、详情、新增、更新、删除、列表获取
# ----------------------------------------------------------------------
from modules.dept.handlers import (DeptAddHandler, DeptDeleteHandler, DeptDetailHandler,
DeptGetListHandler, DeptListHandler, DeptUpdateHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 部门列表(分页 / 条件查询)
(r"/dept/list", DeptListHandler),
# 查询部门详情,路径参数为部门 ID
(r"/dept/detail/(\d+)", DeptDetailHandler),
# 新增部门
(r"/dept/add", DeptAddHandler),
# 更新部门信息
(r"/dept/update", DeptUpdateHandler),
# 删除单个部门,路径参数为部门 ID
(r"/dept/delete/(\d+)", DeptDeleteHandler),
# 获取部门列表(常用于下拉选择等场景)
(r"/dept/getDeptList", DeptGetListHandler),
]
+33
View File
@@ -0,0 +1,33 @@
# +======================================================================
# | 模块: 字典路由层
# | 说明: 提供数据字典的增删改查、批量删除、字典数据获取等 API 接口
# +======================================================================
"""字典相关路由。"""
# ----------------------------------------------------------------------
# 导入字典模块的各类处理器(Handler)
# 涵盖:分页、详情、新增、更新、删除、批量删除、字典数据获取
# ----------------------------------------------------------------------
from modules.dictionary.dict.handlers import (DictAddHandler, DictDataHandler, DictDeleteHandler,
DictDetailHandler, DictPageHandler, DictUpdateHandler,
DictBatchDeleteHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 字典分页查询
(r"/dict/page", DictPageHandler),
# 查询字典详情,路径参数为字典 ID
(r"/dict/detail/(\d+)", DictDetailHandler),
# 新增字典
(r"/dict/add", DictAddHandler),
# 更新字典信息
(r"/dict/update", DictUpdateHandler),
# 删除单个字典,路径参数为字典 ID
(r"/dict/delete/(\d+)", DictDeleteHandler),
# 批量删除字典
(r"/dict/batchDelete", DictBatchDeleteHandler),
# 获取字典数据,路径参数为字典类型编码(如性别、状态等)
(r"/dict/data/(\w+)", DictDataHandler),
]
+31
View File
@@ -0,0 +1,31 @@
# +======================================================================
# | 模块: 字典项路由层
# | 说明: 提供字典项的增删改查、批量删除等 API 接口
# +======================================================================
"""字典项相关路由。"""
# ----------------------------------------------------------------------
# 导入字典项模块的各类处理器(Handler)
# 涵盖:分页、详情、新增、更新、删除、批量删除
# ----------------------------------------------------------------------
from modules.dictionary.dict_item.handlers import (DictItemAddHandler, DictItemDeleteHandler,
DictItemDetailHandler, DictItemPageHandler,
DictItemUpdateHandler, DictItemBatchDeleteHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 字典项分页查询
(r"/dict/item/page", DictItemPageHandler),
# 查询字典项详情,路径参数为字典项 ID
(r"/dict/item/detail/(\d+)", DictItemDetailHandler),
# 新增字典项
(r"/dict/item/add", DictItemAddHandler),
# 更新字典项信息
(r"/dict/item/update", DictItemUpdateHandler),
# 删除单个字典项,路径参数为字典项 ID
(r"/dict/item/delete/(\d+)", DictItemDeleteHandler),
# 批量删除字典项
(r"/dict/item/batchDelete", DictItemBatchDeleteHandler),
]
+33
View File
@@ -0,0 +1,33 @@
# +======================================================================
# | 模块: 案例演示路由层
# | 说明: 提供案例演示的增删改查、批量删除、状态管理等 API 接口
# +======================================================================
"""案例演示相关路由。"""
# ----------------------------------------------------------------------
# 导入案例演示模块的各类处理器(Handler)
# 涵盖:分页、详情、新增、更新、删除、批量删除、状态变更# ----------------------------------------------------------------------
from modules.example.handlers import (ExampleAddHandler, ExampleBatchDeleteHandler,
ExampleDeleteHandler, ExampleDetailHandler,
ExamplePageHandler, ExampleUpdateHandler,
ExampleStatusHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 案例演示分页查询
(r"/example/page", ExamplePageHandler),
# 查询案例演示详情,路径参数为 ID
(r"/example/detail/(\d+)", ExampleDetailHandler),
# 新增案例演示
(r"/example/add", ExampleAddHandler),
# 更新案例演示信息
(r"/example/update", ExampleUpdateHandler),
# 删除单个案例演示,路径参数为 ID
(r"/example/delete/(\d+)", ExampleDeleteHandler),
# 批量删除案例演示
(r"/example/batchDelete", ExampleBatchDeleteHandler),
# 修改案例演示状态(如启用 / 禁用)
(r"/example/status", ExampleStatusHandler),
]
+34
View File
@@ -0,0 +1,34 @@
# +======================================================================
# | 模块: 文件模板路由层
# | 说明: 提供文件模板的增删改查、批量删除等 API 接口
# +======================================================================
"""文件模板相关路由。"""
# ----------------------------------------------------------------------
# 导入文件模板模块的各类处理器(Handler)
# 涵盖:分页、详情、新增、更新、删除、批量删除
# ----------------------------------------------------------------------
from modules.file_template.handlers import (FileTemplateAddHandler, FileTemplateBatchDeleteHandler,
FileTemplateDeleteHandler, FileTemplateDetailHandler,
FileTemplateGetByCodeHandler, FileTemplatePageHandler,
FileTemplateUpdateHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 文件模板分页查询
(r"/file/template/page", FileTemplatePageHandler),
# 查询文件模板详情,路径参数为文件模板 ID
(r"/file/template/detail/(\d+)", FileTemplateDetailHandler),
# 新增文件模板
(r"/file/template/add", FileTemplateAddHandler),
# 更新文件模板信息
(r"/file/template/update", FileTemplateUpdateHandler),
# 删除单个文件模板,路径参数为文件模板 ID
(r"/file/template/delete/(\d+)", FileTemplateDeleteHandler),
# 批量删除文件模板
(r"/file/template/batchDelete", FileTemplateBatchDeleteHandler),
# 根据编码获取文件模板
(r"/file/template/getTemplateByCode/(\w+)", FileTemplateGetByCodeHandler),
]
+29
View File
@@ -0,0 +1,29 @@
# +======================================================================
# | 模块: 代码生成器路由层
# | 说明: 提供数据库表查询、代码生成等 API 接口
# +======================================================================
"""代码生成器相关路由。"""
# ----------------------------------------------------------------------
# 导入代码生成器模块的各类处理器(Handler)
# 涵盖:表分页查询、表详情、字段查询、代码生成、批量生成
# ----------------------------------------------------------------------
from modules.generator.handlers import (GeneratorBatchGenerateHandler, GeneratorColumnsHandler,
GeneratorDetailHandler, GeneratorGenerateHandler,
GeneratorPageHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 查询数据库表分页数据
(r"/generator/page", GeneratorPageHandler),
# 查询数据库表详情,路径参数为表名
(r"/generator/detail/(\w+)", GeneratorDetailHandler),
# 查询数据库表字段信息,路径参数为表名
(r"/generator/columns/(\w+)", GeneratorColumnsHandler),
# 代码生成
(r"/generator/generate", GeneratorGenerateHandler),
# 批量代码生成
(r"/generator/batchGenerate", GeneratorBatchGenerateHandler),
]
+26
View File
@@ -0,0 +1,26 @@
# +======================================================================
# | 模块: 个人中心路由层
# | 说明: 提供主页菜单、个人信息查询与修改、密码修改等 API 接口
# +======================================================================
"""个人中心/主页路由。"""
# ----------------------------------------------------------------------
# 导入用户模块的各类处理器(Handler)
# 涵盖:菜单获取、密码修改、个人信息查询与修改
# ----------------------------------------------------------------------
from modules.user.handlers import (IndexMenuHandler, PasswordUpdateHandler,
ProfileHandler, ProfileUpdateHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 获取主页菜单
(r"/index/getMenus", IndexMenuHandler),
# 获取当前登录用户信息
(r"/index/getUser", ProfileHandler),
# 更新个人信息
(r"/index/updateProfile", ProfileUpdateHandler),
# 修改登录密码
(r"/index/updatePassword", PasswordUpdateHandler),
]
+42
View File
@@ -0,0 +1,42 @@
# +======================================================================
# | 模块: 任务调度路由层
# | 说明: 提供定时任务的增删改查、批量删除、状态管理、立即执行等 API 接口
# +======================================================================
"""任务调度相关路由。"""
# ----------------------------------------------------------------------
# 导入任务调度模块的各类处理器(Handler)
# 涵盖:分页、详情、新增、更新、删除、批量删除、状态变更、立即执行
# ----------------------------------------------------------------------
from modules.job.handlers import (JobAddHandler, JobBatchDeleteHandler, JobDeleteHandler,
JobDetailHandler, JobPageHandler, JobPauseHandler,
JobReloadHandler, JobResumeHandler, JobRunOnceHandler,
JobStatusHandler, JobUpdateHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 任务分页查询
(r"/job/page", JobPageHandler),
# 查询任务详情,路径参数为任务 ID
(r"/job/detail/(\d+)", JobDetailHandler),
# 新增任务
(r"/job/add", JobAddHandler),
# 更新任务信息
(r"/job/update", JobUpdateHandler),
# 删除单个任务,路径参数为任务 ID
(r"/job/delete/(\d+)", JobDeleteHandler),
# 批量删除任务
(r"/job/batchDelete", JobBatchDeleteHandler),
# 修改任务状态(如启用 / 暂停)
(r"/job/status", JobStatusHandler),
# 立即执行任务,路径参数为任务 ID
(r"/job/runOnce/(\d+)", JobRunOnceHandler),
# 暂停任务,路径参数为任务 ID
(r"/job/pause/(\d+)", JobPauseHandler),
# 恢复任务,路径参数为任务 ID
(r"/job/resume/(\d+)", JobResumeHandler),
# 重新加载所有任务
(r"/job/reload", JobReloadHandler),
]
+26
View File
@@ -0,0 +1,26 @@
# +======================================================================
# | 模块: 任务日志路由层
# | 说明: 提供任务执行日志的查询、详情、删除、批量删除等 API 接口
# +======================================================================
"""任务日志相关路由。"""
# ----------------------------------------------------------------------
# 导入任务日志模块的各类处理器(Handler)
# 涵盖:分页、详情、删除、批量删除
# ----------------------------------------------------------------------
from modules.job_log.handlers import (JobLogDeleteHandler, JobLogDetailHandler,
JobLogPageHandler, JobLogBatchDeleteHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 任务日志分页查询
(r"/job/log/page", JobLogPageHandler),
# 查询任务日志详情,路径参数为日志 ID
(r"/job/log/detail/(\d+)", JobLogDetailHandler),
# 删除单条任务日志,路径参数为日志 ID
(r"/job/log/delete/(\d+)", JobLogDeleteHandler),
# 批量删除任务日志
(r"/job/log/batchDelete", JobLogBatchDeleteHandler),
]
+39
View File
@@ -0,0 +1,39 @@
# +======================================================================
# | 模块: 职级路由层
# | 说明: 提供职级信息的增删改查、批量删除、状态管理、导入导出、列表获取等 API 接口
# +======================================================================
"""职级相关路由。"""
# ----------------------------------------------------------------------
# 导入职级模块的各类处理器(Handler)
# 涵盖:分页、详情、新增、更新、删除、批量删除、状态变更、导入、导出、列表获取
# ----------------------------------------------------------------------
from modules.level.handlers import (LevelAddHandler, LevelDeleteHandler, LevelDetailHandler,
LevelExportHandler, LevelGetListHandler, LevelImportHandler,
LevelPageHandler, LevelStatusHandler, LevelUpdateHandler, LevelBatchDeleteHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 职级分页查询
(r"/level/page", LevelPageHandler),
# 查询职级详情,路径参数为职级 ID
(r"/level/detail/(\d+)", LevelDetailHandler),
# 新增职级
(r"/level/add", LevelAddHandler),
# 更新职级信息
(r"/level/update", LevelUpdateHandler),
# 删除单个职级,路径参数为职级 ID
(r"/level/delete/(\d+)", LevelDeleteHandler),
# 批量删除职级
(r"/level/batchDelete", LevelBatchDeleteHandler),
# 修改职级状态(如启用 / 禁用)
(r"/level/status", LevelStatusHandler),
# 导入职级数据
(r"/level/import", LevelImportHandler),
# 导出职级数据
(r"/level/export", LevelExportHandler),
# 获取职级列表(常用于下拉选择等场景)
(r"/level/list", LevelGetListHandler),
]
+32
View File
@@ -0,0 +1,32 @@
# +======================================================================
# | 模块: 友链路由层
# | 说明: 提供友情链接的增删改查、批量删除、状态管理等 API 接口
# +======================================================================
"""友链相关路由。"""
# ----------------------------------------------------------------------
# 导入友链模块的各类处理器(Handler)
# 涵盖:分页、详情、新增、更新、删除、批量删除、状态变更
# ----------------------------------------------------------------------
from modules.link.handlers import (LinkAddHandler, LinkDeleteHandler, LinkDetailHandler, LinkPageHandler,
LinkStatusHandler, LinkUpdateHandler, LinkBatchDeleteHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 友链分页查询
(r"/link/page", LinkPageHandler),
# 查询友链详情,路径参数为友链 ID
(r"/link/detail/(\d+)", LinkDetailHandler),
# 新增友链
(r"/link/add", LinkAddHandler),
# 更新友链信息
(r"/link/update", LinkUpdateHandler),
# 删除单个友链,路径参数为友链 ID
(r"/link/delete/(\d+)", LinkDeleteHandler),
# 批量删除友链
(r"/link/batchDelete", LinkBatchDeleteHandler),
# 修改友链状态(如显示 / 隐藏)
(r"/link/status", LinkStatusHandler),
]
+26
View File
@@ -0,0 +1,26 @@
# +======================================================================
# | 模块: 登录日志路由层
# | 说明: 提供登录日志的查询、详情、删除、批量删除等 API 接口
# +======================================================================
"""登录日志相关路由。"""
# ----------------------------------------------------------------------
# 导入登录日志模块的各类处理器(Handler)
# 涵盖:分页、详情、删除、批量删除
# ----------------------------------------------------------------------
from modules.login_log.handlers import (LoginLogDeleteHandler, LoginLogDetailHandler,
LoginLogPageHandler, LoginLogBatchDeleteHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 登录日志分页查询
(r"/login/log/page", LoginLogPageHandler),
# 查询登录日志详情,路径参数为日志 ID
(r"/login/log/detail/(\d+)", LoginLogDetailHandler),
# 删除单条登录日志,路径参数为日志 ID
(r"/login/log/delete/(\d+)", LoginLogDeleteHandler),
# 批量删除登录日志
(r"/login/log/batchDelete", LoginLogBatchDeleteHandler),
]
+28
View File
@@ -0,0 +1,28 @@
# +======================================================================
# | 模块: 菜单路由层
# | 说明: 提供菜单信息的增删改查等 API 接口
# +======================================================================
"""菜单相关路由。"""
# ----------------------------------------------------------------------
# 导入菜单模块的各类处理器(Handler)
# 涵盖:列表、详情、新增、更新、删除
# ----------------------------------------------------------------------
from modules.menu.handlers import (MenuAddHandler, MenuDeleteHandler, MenuDetailHandler,
MenuListHandler, MenuUpdateHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 菜单列表(通常返回树形结构)
(r"/menu/list", MenuListHandler),
# 查询菜单详情,路径参数为菜单 ID
(r"/menu/detail/(\d+)", MenuDetailHandler),
# 新增菜单
(r"/menu/add", MenuAddHandler),
# 更新菜单信息
(r"/menu/update", MenuUpdateHandler),
# 删除单个菜单,路径参数为菜单 ID
(r"/menu/delete/(\d+)", MenuDeleteHandler),
]
+30
View File
@@ -0,0 +1,30 @@
# +======================================================================
# | 模块: 通知公告路由层
# | 说明: 提供通知公告的增删改查、批量删除等 API 接口
# +======================================================================
"""通知公告相关路由。"""
# ----------------------------------------------------------------------
# 导入通知公告模块的各类处理器(Handler)
# 涵盖:分页、详情、新增、更新、删除、批量删除
# ----------------------------------------------------------------------
from modules.notice.handlers import (NoticeAddHandler, NoticeDeleteHandler, NoticeDetailHandler,
NoticePageHandler, NoticeUpdateHandler, NoticeBatchDeleteHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 通知公告分页查询
(r"/notice/page", NoticePageHandler),
# 查询通知公告详情,路径参数为公告 ID
(r"/notice/detail/(\d+)", NoticeDetailHandler),
# 新增通知公告
(r"/notice/add", NoticeAddHandler),
# 更新通知公告信息
(r"/notice/update", NoticeUpdateHandler),
# 删除单条通知公告,路径参数为公告 ID
(r"/notice/delete/(\d+)", NoticeDeleteHandler),
# 批量删除通知公告
(r"/notice/batchDelete", NoticeBatchDeleteHandler),
]
+26
View File
@@ -0,0 +1,26 @@
# +======================================================================
# | 模块: 操作日志路由层
# | 说明: 提供操作日志的查询、详情、删除、批量删除等 API 接口
# +======================================================================
"""操作日志相关路由。"""
# ----------------------------------------------------------------------
# 导入操作日志模块的各类处理器(Handler)
# 涵盖:分页、详情、删除、批量删除
# ----------------------------------------------------------------------
from modules.operation_log.handlers import (OperationLogDeleteHandler, OperationLogDetailHandler,
OperationLogPageHandler, OperationLogBatchDeleteHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 操作日志分页查询
(r"/oper/log/page", OperationLogPageHandler),
# 查询操作日志详情,路径参数为日志 ID
(r"/oper/log/detail/(\d+)", OperationLogDetailHandler),
# 删除单条操作日志,路径参数为日志 ID
(r"/oper/log/delete/(\d+)", OperationLogDeleteHandler),
# 批量删除操作日志
(r"/oper/log/batchDelete", OperationLogBatchDeleteHandler),
]
+32
View File
@@ -0,0 +1,32 @@
# +======================================================================
# | 模块: 参数路由层
# | 说明: 提供系统参数的增删改查、批量删除、状态管理等 API 接口
# +======================================================================
"""参数相关路由。"""
# ----------------------------------------------------------------------
# 导入参数模块的各类处理器(Handler)
# 涵盖:分页、详情、新增、更新、删除、批量删除、状态变更
# ----------------------------------------------------------------------
from modules.param.handlers import (ParamAddHandler, ParamDeleteHandler, ParamDetailHandler,
ParamPageHandler, ParamStatusHandler, ParamUpdateHandler, ParamBatchDeleteHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 参数分页查询
(r"/param/page", ParamPageHandler),
# 查询参数详情,路径参数为参数 ID
(r"/param/detail/(\d+)", ParamDetailHandler),
# 新增参数
(r"/param/add", ParamAddHandler),
# 更新参数信息
(r"/param/update", ParamUpdateHandler),
# 删除单个参数,路径参数为参数 ID
(r"/param/delete/(\d+)", ParamDeleteHandler),
# 批量删除参数
(r"/param/batchDelete", ParamBatchDeleteHandler),
# 修改参数状态(如启用 / 禁用)
(r"/param/status", ParamStatusHandler),
]
+35
View File
@@ -0,0 +1,35 @@
# +======================================================================
# | 模块: 岗位路由层
# | 说明: 提供岗位信息的增删改查、状态管理、列表获取等 API 接口
# +======================================================================
"""岗位相关路由。"""
# ----------------------------------------------------------------------
# 导入岗位模块的各类处理器(Handler)
# 涵盖:分页、详情、新增、更新、删除、批量删除、状态变更、列表获取
# ----------------------------------------------------------------------
from modules.position.handlers import (PositionAddHandler, PositionDeleteHandler, PositionDetailHandler,
PositionGetListHandler, PositionPageHandler, PositionStatusHandler,
PositionUpdateHandler, PositionBatchDeleteHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 岗位分页查询
(r"/position/page", PositionPageHandler),
# 查询岗位详情,路径参数为岗位 ID
(r"/position/detail/(\d+)", PositionDetailHandler),
# 新增岗位
(r"/position/add", PositionAddHandler),
# 更新岗位信息
(r"/position/update", PositionUpdateHandler),
# 删除单个岗位,路径参数为岗位 ID
(r"/position/delete/(\d+)", PositionDeleteHandler),
# 批量删除岗位
(r"/position/batchDelete", PositionBatchDeleteHandler),
# 修改岗位状态(如启用 / 禁用)
(r"/position/status", PositionStatusHandler),
# 获取岗位列表(常用于下拉选择等场景)
(r"/position/list", PositionGetListHandler),
]
+33
View File
@@ -0,0 +1,33 @@
# +======================================================================
# | 模块: 角色路由层
# | 说明: 提供角色信息的增删改查、批量删除、列表获取等 API 接口
# +======================================================================
"""角色相关路由。"""
# ----------------------------------------------------------------------
# 导入角色模块的各类处理器(Handler)
# 涵盖:分页、详情、新增、更新、删除、批量删除、列表获取
# ----------------------------------------------------------------------
from modules.role.handlers import (RoleAddHandler, RoleBatchDeleteHandler, RoleDeleteHandler,
RoleDetailHandler, RoleGetListHandler, RolePageHandler,
RoleUpdateHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 角色分页查询
(r"/role/page", RolePageHandler),
# 查询角色详情,路径参数为角色 ID
(r"/role/detail/(\d+)", RoleDetailHandler),
# 新增角色
(r"/role/add", RoleAddHandler),
# 更新角色信息
(r"/role/update", RoleUpdateHandler),
# 删除单个角色,路径参数为角色 ID
(r"/role/delete/(\d+)", RoleDeleteHandler),
# 批量删除角色
(r"/role/batchDelete", RoleBatchDeleteHandler),
# 获取角色列表(常用于下拉选择等场景)
(r"/role/list", RoleGetListHandler),
]
+21
View File
@@ -0,0 +1,21 @@
# +======================================================================
# | 模块: 角色菜单路由层
# | 说明: 提供角色菜单分配的查询与保存等 API 接口
# +======================================================================
"""角色菜单相关路由。"""
# ----------------------------------------------------------------------
# 导入角色菜单模块的各类处理器(Handler)
# 涵盖:查询角色菜单、保存角色菜单分配
# ----------------------------------------------------------------------
from modules.role_menu.handlers import (RoleMenuListHandler, RoleMenuSaveHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 查询角色已分配的菜单,路径参数为角色 ID
(r"/role/menu/list/(\d+)", RoleMenuListHandler),
# 保存角色菜单分配
(r"/role/menu/save", RoleMenuSaveHandler),
]
+57
View File
@@ -0,0 +1,57 @@
# +======================================================================
# | 模块: API v1 路由聚合层
# | 说明: 汇总各资源路由,统一添加 /api/v1 前缀,并注册健康检查端点
# +======================================================================
"""API v1 路由聚合。
汇总各资源路由,统一添加 /api/v1 前缀,并注册健康检查端点。
"""
# ----------------------------------------------------------------------
# 导入各资源的路由模块(每个模块内含 routes 列表)
# ----------------------------------------------------------------------
from api.v1 import (article, auth, category, city,
config, config_item, dept, dict,
dict_item, file_template, generator, index,
job, job_log, level, link,
login_log, menu, notice, operation_log,
param, position, role, role_menu,
upload, user, user_role, example)
# 导入基础处理器中的健康检查处理器
from core.base_handler import HealthHandler
# ----------------------------------------------------------------------
# API v1 统一前缀
# ----------------------------------------------------------------------
V1_PREFIX = "/api/v1"
# ----------------------------------------------------------------------
# 参与路由聚合的资源模块列表
# 新增资源模块时,只需在此追加对应模块即可自动注册其路由
# ----------------------------------------------------------------------
_RESOURCE_MODULES = [
auth, index, upload, user,
user_role, role, role_menu, menu,
dept, dict, dict_item, config,
config_item, level, position, city,
notice, link, category, article,
param, file_template, job, generator,
operation_log, login_log, job_log, example,
]
def build_routes() -> list:
"""构建全部路由(含 /api/v1 前缀与健康检查)。
遍历 _RESOURCE_MODULES,为每个资源模块的每条路由拼接统一前缀,
最后追加健康检查端点。
:return: 完整的路由列表,元素为 (URL 路径, 处理器类)
"""
routes = []
# 遍历所有资源模块,为其路由统一添加 /api/v1 前缀
for module in _RESOURCE_MODULES:
routes.extend([(V1_PREFIX + path, handler) for path, handler in module.routes])
# 追加健康检查端点
routes.append((V1_PREFIX + "/health", HealthHandler))
return routes
+18
View File
@@ -0,0 +1,18 @@
# +======================================================================
# | 模块: 上传路由层
# | 说明: 提供文件上传 API 接口(统一入口,支持图片和各类文件)
# +======================================================================
"""上传相关路由。"""
# ----------------------------------------------------------------------
# 导入上传模块的处理器
# ----------------------------------------------------------------------
from modules.upload.handlers import UploadFileHandler
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 上传文件(统一入口,支持图片和各类文件)
(r"/upload/uploadFile", UploadFileHandler),
]
+40
View File
@@ -0,0 +1,40 @@
# +======================================================================
# | 模块: 用户路由层
# | 说明: 提供用户信息的增删改查、批量删除、状态管理、密码重置、导入导出等 API 接口
# +======================================================================
"""用户相关路由。"""
# ----------------------------------------------------------------------
# 导入用户模块的各类处理器(Handler)
# 涵盖:分页、详情、新增、更新、删除、批量删除、状态变更、密码重置、导入导出
# ----------------------------------------------------------------------
from modules.user.handlers import (UserAddHandler, UserBatchDeleteHandler, UserDeleteHandler,
UserDetailHandler, UserExportHandler, UserImportHandler,
UserPageHandler, UserResetPwdHandler,
UserStatusHandler, UserUpdateHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 用户分页查询
(r"/user/page", UserPageHandler),
# 查询用户详情,路径参数为用户 ID
(r"/user/detail/(\d+)", UserDetailHandler),
# 新增用户
(r"/user/add", UserAddHandler),
# 更新用户信息
(r"/user/update", UserUpdateHandler),
# 删除单个用户,路径参数为用户 ID
(r"/user/delete/(\d+)", UserDeleteHandler),
# 批量删除用户
(r"/user/batchDelete", UserBatchDeleteHandler),
# 修改用户状态(如启用 / 禁用)
(r"/user/status", UserStatusHandler),
# 重置用户密码
(r"/user/resetPwd", UserResetPwdHandler),
# 导入 Excel
(r"/user/import", UserImportHandler),
# 导出 Excel
(r"/user/export", UserExportHandler),
]
+26
View File
@@ -0,0 +1,26 @@
# +======================================================================
# | 模块: 用户角色路由层
# | 说明: 提供用户角色关联关系的查询、设置、删除等 API 接口
# +======================================================================
"""用户角色相关路由。"""
# ----------------------------------------------------------------------
# 导入用户角色模块的各类处理器(Handler)
# 涵盖:查询用户角色、设置用户角色、删除用户角色、清空用户角色
# ----------------------------------------------------------------------
from modules.user_role.handlers import (UserRoleClearHandler, UserRoleDeleteHandler,
UserRoleListHandler, UserRoleSaveHandler)
# ----------------------------------------------------------------------
# 路由表:每一项为 (URL 正则, 处理器类)
# ----------------------------------------------------------------------
routes = [
# 查询用户角色列表,路径参数为用户 ID
(r"/user/role/list/(\d+)", UserRoleListHandler),
# 设置用户角色(全量替换)
(r"/user/role/save", UserRoleSaveHandler),
# 删除用户角色关联,路径参数为用户 ID 和角色 ID
(r"/user/role/delete/(\d+)/(\d+)", UserRoleDeleteHandler),
# 清空用户所有角色,路径参数为用户 ID
(r"/user/role/clear/(\d+)", UserRoleClearHandler),
]
+53
View File
@@ -0,0 +1,53 @@
# +======================================================================
# | 模块: 应用工厂层
# | 说明: 创建并配置 Tornado 应用实例(路由、模板/静态目录、调试与安全选项),
# | 并完成扩展初始化
# +======================================================================
"""应用工厂。"""
# ----------------------------------------------------------------------
# 导入依赖
# 涵盖:应用配置(根路径/调试开关)、认证配置(JWT 秘钥)、
# 扩展注册入口、路由聚合、Tornado 框架
# ----------------------------------------------------------------------
from config.app import ROOT_PATH, TORNADO_DEBUG
from config.auth import JWT_SECRET
from extensions import register_extensions
from api.v1.router import build_routes
import tornado.web
# ======================================================================
# 创建应用实例
# ======================================================================
def create_app():
"""创建 Tornado 应用实例。
:return: 配置完成的 Tornado Application 实例
"""
app = tornado.web.Application(
# 初始化路由
build_routes(),
# 模板文件目录
template_path=str(ROOT_PATH / "templates"),
# 静态文件路径
static_path=str(ROOT_PATH / "static"),
# 静态文件路由前缀
static_url_prefix="/static/",
# 调试模式
debug=TORNADO_DEBUG,
# 自动重载
autoreload=False,
# 支持 gzip 压缩
gzip=True,
# 禁用自动转义
autoescape=None,
# 防跨站请求攻击
xsrf_cookies=None,
# COOKIE 秘钥
cookie_secret=JWT_SECRET,
)
# 初始化扩展
register_extensions(app)
return app
+117
View File
@@ -0,0 +1,117 @@
# +======================================================================
# | 模块: 启动引导层
# | 说明: 解析命令行参数、创建应用并启动 HTTP 服务,
# | 注册信号处理实现优雅关闭
# +======================================================================
"""启动引导程序。
- 解析命令行参数(--port)
- 创建应用并启动 HTTP 服务
- 注册信号处理实现优雅关闭
"""
# ----------------------------------------------------------------------
# 导入依赖
# 涵盖:信号处理、Tornado HTTP 服务与事件循环、命令行参数工具、
# 应用/数据库/邮件/Redis 配置、日志器
# ----------------------------------------------------------------------
import signal
import tornado.httpserver
import tornado.ioloop
from tornado.options import define, options, parse_command_line
from config.app import TORNADO_ENV, TORNADO_HOST, TORNADO_NAME, TORNADO_PORT
from config.database import DB_PASSWORD
from config.email import MAIL_PASSWORD
from config.redis import REDIS_PASSWORD
from core.logger import logger
# ============================================================
# 命令行参数定义
# ============================================================
define('port', default=TORNADO_PORT, help='run on the given port', type=int)
# ======================================================================
# 启动 Banner
# ======================================================================
_BANNER = r"""
____ _ _
| _ \ _ _ __ _ _ __ ___ (_) _ __ __ _ _ __ __| |
| | | || | | | / _` || '__| / __|| || '_ \ / _` || '__| / _` |
| |_| || |_| || (_| || | | (__ | || |_) || (_| || | | (_| |
|____/ \__,_| \__,_||_| \___||_|| .__/ \__,_||_| \__,_|
|_|
Tornado + EleVue 企业级管理框架
"""
# ======================================================================
# 启动服务
# ======================================================================
def main():
"""启动服务。"""
# 延迟导入应用工厂,避免与根目录入口模块同名冲突
from app import create_app
# 打印启动 Banner
print(_BANNER)
parse_command_line()
app = create_app()
# 预热 ip2region 离线库,避免首个日志落库时在事件循环上加载阻塞
try:
from common.utils.ip2region import preload
preload()
except Exception:
logger.warning("ip2region 预热失败(将延迟到首次查询时加载)")
# 生产环境安全提醒(签名密钥已在 config.auth 启动校验中 fail-close;此处对其它
# 敏感凭据做软告警,提示确认来源,不阻断启动)
if TORNADO_ENV.strip().lower() == "production":
for name, value in (("DB_PASSWORD", DB_PASSWORD),
("REDIS_PASSWORD", REDIS_PASSWORD),
("MAIL_PASSWORD", MAIL_PASSWORD)):
if not value:
logger.warning("生产环境检测到 %s 为空,请确认凭据来源(环境变量 / 密钥管理服务)", name)
http_server = tornado.httpserver.HTTPServer(app)
http_server.listen(options.port)
# 启动任务调度器并加载运行中任务
from modules.job.scheduler import init_scheduler
init_scheduler()
logger.info("服务启动成功 → http://%s:%s", TORNADO_HOST, options.port)
ioloop = tornado.ioloop.IOLoop.current()
# ============================================================
# 优雅关闭回调
# ============================================================
def _shutdown():
"""收到退出信号时关闭调度器并停止事件循环。"""
logger.info("收到退出信号,正在优雅关闭...")
from modules.job.scheduler import shutdown_scheduler
shutdown_scheduler()
ioloop.stop()
# ============================================================
# 注册信号处理(优雅关闭)
# ============================================================
for sig in (signal.SIGINT, signal.SIGTERM):
try:
signal.signal(sig, lambda s, f: ioloop.add_callback_from_signal(_shutdown))
except (ValueError, AttributeError, OSError):
pass
ioloop.start()
# ======================================================================
# 入口
# ======================================================================
if __name__ == '__main__':
main()
+8
View File
@@ -0,0 +1,8 @@
# +======================================================================
# | 模块: 公共模块包
# | 说明: 声明 common 为包,用于组织中间件、工具与装饰器等公共代码
# +======================================================================
"""公共模块。
提示:开发者可根据项目需要,自行在此包内添加所需内容。
"""
+6
View File
@@ -0,0 +1,6 @@
# +======================================================================
# | 模块: 中间件包
# | 说明: 声明 middleware 为包,用于组织认证、跨域、日志、链路追踪等
# | 中间件(装饰器实现)
# +======================================================================
"""中间件(装饰器实现)。"""
@@ -0,0 +1,98 @@
# +======================================================================
# | 模块: 访问控制装饰器层
# | 说明: 提供演示模式拦截(check_demo)与权限节点校验(permission_required)
# | 两个装饰器
# +======================================================================
"""访问控制装饰器:演示模式拦截 + 权限校验。
- check_demo: 演示模式(TORNADO_DEMO=True)下拦截写操作,放在 @permission_required 之后、路由函数之前
- permission_required: 权限节点校验(用户 ID 为 1 的超级管理员放行全部权限)
"""
# ----------------------------------------------------------------------
# 导入依赖
# 涵盖:异步判断、装饰器工具、演示模式配置、演示提示文案、
# 统一响应对象、当前用户上下文、日志器
# ----------------------------------------------------------------------
import asyncio
from functools import wraps
from config.app import TORNADO_DEMO
from constants import MESSAGE_DEMO
from core import response as R
from core.context import current_user_id
from core.logger import logger
# ======================================================================
# 演示模式拦截装饰器
# ======================================================================
def check_demo(func):
"""演示模式拦截装饰器:放在 @permission_required 之后、路由函数之前。
:param func: 被装饰的同步 / 异步处理函数
:return: 包装后的函数
"""
# 异步函数分支
if asyncio.iscoroutinefunction(func):
@wraps(func)
async def async_wrapper(self, *args, **kwargs):
"""异步包装:演示模式下直接返回失败响应。"""
if TORNADO_DEMO:
return R.failed(self, MESSAGE_DEMO)
return await func(self, *args, **kwargs)
return async_wrapper
# 同步函数分支
@wraps(func)
def sync_wrapper(self, *args, **kwargs):
"""同步包装:演示模式下直接返回失败响应。"""
if TORNADO_DEMO:
return R.failed(self, MESSAGE_DEMO)
return func(self, *args, **kwargs)
return sync_wrapper
# ======================================================================
# 权限节点鉴权装饰器
# ======================================================================
def permission_required(permission: str, log: bool = False):
"""权限节点鉴权装饰器。
:param permission: 权限节点标识,如 "sys:user:list"
:param log: 是否记录无权限访问日志
"""
def decorator(func):
"""接收被装饰函数,返回带鉴权的包装函数。"""
@wraps(func)
async def wrapper(self, *args, **kwargs):
"""鉴权包装:校验当前用户是否具备指定权限。"""
user_id = current_user_id.get()
# 超级管理员直接放行
if user_id != 1:
# 延迟导入避免循环依赖
from common.permission import get_permissions_list
permission_list = get_permissions_list(user_id)
if permission not in permission_list:
# 可选记录无权限访问日志
if log:
logger.warning("无权限访问 path=%s permission=%s user=%s",
self.request.path, permission, user_id)
# 无权限:HTTP 状态码与业务码统一为 403
self.set_status(403)
return R.failed(self, "权限不足", code=R.Codes.FORBIDDEN)
# 兼容同步 / 异步 handler(同步 handler 的返回不再被 await)
result = func(self, *args, **kwargs)
if asyncio.iscoroutine(result):
return await result
return result
return wrapper
return decorator
+60
View File
@@ -0,0 +1,60 @@
# +======================================================================
# | 模块: 认证中间件层
# | 说明: 提供登录检测装饰器,校验 JWT(含黑名单)并将 userId 写入请求上下文
# +======================================================================
"""认证中间件(装饰器)。
所有请求(除 /api/v1/login、/api/v1/captcha 与 OPTIONS 预检)需携带
有效 JWT(Authorization: Bearer <token>)。认证通过后将 userId 写入
current_user_id 上下文,供仓储/服务层审计使用。
"""
# ----------------------------------------------------------------------
# 导入依赖
# 涵盖:统一响应对象、当前用户上下文、JWT 工具(解析令牌/黑名单/取令牌)
# ----------------------------------------------------------------------
from core import response as R
from core.context import current_realname, current_user_id, current_username
from core.jwt import get_access_token, is_token_blacklisted, parse_payload
# ============================================================
# 无需认证的 URL
# ============================================================
IGNORE_URL = ["/api/v1/login", "/api/v1/captcha"]
# ======================================================================
# 登录检测装饰器
# ======================================================================
def login_required(func):
"""登录检测装饰器。
:param func: 被装饰的处理函数
:return: 包装后的处理函数
"""
def wrapper(self, *args, **kwargs):
"""鉴权包装:非白名单请求需携带有效 JWT。"""
request_url = self.request.path
# 白名单与 OPTIONS 预检请求直接放行
if request_url not in IGNORE_URL and self.request.method != "OPTIONS":
# 获取请求头中的 token(统一走 get_access_token,大小写不敏感解析 Bearer)
success, access_token, _ = get_access_token(self.request)
if not success or not access_token:
return R.failed(self, code=401, msg="登录过期")
# JWT 解密
result = parse_payload(access_token)
if result["code"] != 0:
return R.failed(self, code=401, msg="登录过期")
# 黑名单校验:已注销(登出)的令牌拒绝访问(Redis 不可用时 fail-open 放行)
if is_token_blacklisted(access_token):
return R.failed(self, code=401, msg="登录过期")
# 写入当前用户上下文
data = result["data"]
current_user_id.set(int(data["userId"]))
current_username.set(data.get("username", ""))
current_realname.set(data.get("realname", ""))
# 放行
return func(self, *args, **kwargs)
return wrapper
+53
View File
@@ -0,0 +1,53 @@
# +======================================================================
# | 模块: 跨域请求中间件层
# | 说明: 依据配置的来源白名单为响应设置 CORS 与安全相关响应头
# +======================================================================
"""跨域请求中间件。"""
# ----------------------------------------------------------------------
# 导入依赖
# 涵盖:跨域来源白名单配置
# ----------------------------------------------------------------------
from config.app import TORNADO_CORS_ORIGINS
# ============================================================
# 允许来源白名单
# ============================================================
# 配置的允许来源白名单(TORNADO_CORS_ORIGINS 逗号分隔);为空表示允许所有来源(但不携带凭据)
_ALLOWED_ORIGINS = {o.strip() for o in TORNADO_CORS_ORIGINS.split(",") if o.strip()}
# ======================================================================
# 设置跨域响应头
# ======================================================================
def cros_required(self):
"""为响应设置跨域相关头。
未配置白名单时允许所有来源(Access-Control-Allow-Origin: *),但按浏览器规范
不能同时携带凭据;配置白名单后仅对匹配来源反射该来源并允许携带凭据,其余来源
不返回 CORS 头(浏览器将阻止跨域读取)。
:param self: Tornado 请求处理器
"""
origin = self.request.headers.get("Origin", "")
# 配置了白名单:仅对匹配来源反射来源并允许携带凭据
if _ALLOWED_ORIGINS:
if origin in _ALLOWED_ORIGINS:
self.set_header("Access-Control-Allow-Origin", origin)
self.set_header("Access-Control-Allow-Credentials", "true")
self.set_header("Vary", "Origin")
# 不在白名单:不设置 Access-Control-Allow-Origin,浏览器拦截跨域
else:
# 未配置白名单:允许所有来源(不携带凭据)
self.set_header("Access-Control-Allow-Origin", "*")
# 安全相关响应头
self.set_header("X-XSS-Protection", "1; mode=block")
self.set_header("Content-Security-Policy", "default-src 'self'")
# 允许的响应头(* 表示允许所有响应头)
self.set_header("Access-Control-Allow-Headers", "*")
self.set_header("Access-Control-Expose-Headers", "*")
# 允许的请求方法
self.set_header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
self.set_header("Content-Type", "application/json; charset=UTF-8")
# 预检请求最大缓存时间
self.set_header("Access-Control-Max-Age", "3600")
+155
View File
@@ -0,0 +1,155 @@
# +======================================================================
# | 模块: 登录日志中间件层
# | 说明: 提供 @login_log 装饰器,记录登录(成功/失败)与退出操作日志,
# | 并异步落库、脱敏敏感参数
# +======================================================================
"""登录日志中间件:记录登录(成功/失败)与退出操作。
用法:
@login_log(LOGIN)
async def post(self): ...
说明:
- log_type:1-登录(从请求体取账号),2-退出(从当前用户查询账号)。
- 依赖 core.response 的 R.ok/R.failed 返回结果 dict,解析业务状态(code/msg)。
- 登录参数中的密码等敏感字段会脱敏。
- 落库通过 IOLoop.spawn_callback 延迟到响应写出后异步执行,不阻塞主流程。
"""
# ----------------------------------------------------------------------
# 导入依赖
# 涵盖:JSON 解析、计时、装饰器工具、浏览器/系统识别与参数脱敏工具、
# IP 归属地查询、当前用户上下文、日志器
# ----------------------------------------------------------------------
import json
import time
from functools import wraps
from common.utils.helpers import detect_browser, detect_os, redact_params
from common.utils.ip2region import get_ip_location
from core.context import current_username
from core.logger import logger
# ============================================================
# 常量定义
# ============================================================
# 日志类型:1-登录 2-退出
LOGIN = 1
LOGOUT = 2
# 字段长度上限
_PARAM_MAX_LEN = 2000
_RESULT_MAX_LEN = 2000
_ERROR_MAX_LEN = 1000
# ======================================================================
# 提取登录账号
# ======================================================================
def _extract_username(self, log_type: int) -> str:
"""提取登录账号:登录从请求体取;退出从上下文读取。
:param self: Tornado 请求处理器
:param log_type: 日志类型(LOGIN / LOGOUT)
:return: 登录账号,取不到返回空串
"""
# 登录:从请求体 JSON 中取 username
if log_type == LOGIN:
try:
data = json.loads(self.request.body) if self.request.body else {}
return str(data.get("username", ""))
except Exception:
return ""
# 退出:从上下文获取(用户已认证)
return current_username.get()
# ======================================================================
# 记录登录日志(异步落库)
# ======================================================================
def _record(self, username: str, log_type: int, status: int,
error: str, result: str, consume_time: int) -> None:
"""记录登录日志:延迟到响应写出后异步落库。
:param self: Tornado 请求处理器
:param username: 登录账号
:param log_type: 日志类型(LOGIN / LOGOUT)
:param status: 操作状态(0-正常 1-异常)
:param error: 错误信息
:param result: 返回结果(JSON 字符串)
:param consume_time: 请求耗时(毫秒)
"""
try:
import tornado.ioloop
from modules.login_log.service import record_login_log
ua = self.request.headers.get("User-Agent", "")
body = self.request.body or b""
# 延迟到响应写出后异步落库,不阻塞主流程
tornado.ioloop.IOLoop.current().spawn_callback(
record_login_log,
username=username,
log_type=log_type,
source=0, # 后台登录
method=self.request.path,
request_method=self.request.method,
url=self.request.uri,
param=redact_params(body.decode("utf-8", errors="replace"), _PARAM_MAX_LEN),
result=result[:_RESULT_MAX_LEN],
ip=self.request.remote_ip,
location=get_ip_location(self.request.remote_ip),
os=detect_os(ua),
browser=detect_browser(ua),
consume_time=consume_time,
status=status,
error=error,
)
except Exception:
# 日志记录异常不影响主流程
logger.exception("登录日志记录异常")
# ======================================================================
# 登录/退出日志装饰器
# ======================================================================
def login_log(log_type: int):
"""登录/退出日志装饰器:@login_log(LOGIN) / @login_log(LOGOUT)。
:param log_type: 日志类型(LOGIN / LOGOUT)
"""
def decorator(func):
"""接收被装饰函数,返回带日志记录的包装函数。"""
@wraps(func)
async def wrapper(self, *args, **kwargs):
"""日志包装:调用原函数并根据返回结果记录登录日志。"""
start = time.time()
status = 0
error_msg = ""
result_msg = ""
# 预先提取账号(登录取请求体,退出取当前用户)
username = _extract_username(self, log_type)
try:
await func(self, *args, **kwargs)
# 解析业务状态:R.ok/R.failed 已将结果 dict 写入 self._api_result
result = getattr(self, "_api_result", None)
if isinstance(result, dict):
if result.get("code", 0) != 0:
status = 1
error_msg = result.get("msg", "") or ""
result_msg = json.dumps(result, ensure_ascii=False)[:_RESULT_MAX_LEN]
# 返回 None:避免 Tornado 对 handler 返回值执行 await 触发 TypeError
return None
except Exception as e:
status = 1
error_msg = str(e)
raise
finally:
# 无论成功失败均记录日志
consume_time = int((time.time() - start) * 1000)
_record(self, username, log_type, status,
error_msg[:_ERROR_MAX_LEN], result_msg, consume_time)
return wrapper
return decorator
+14
View File
@@ -0,0 +1,14 @@
# +======================================================================
# | 模块: 性能监控中间件层
# | 说明: 预留请求计数与耗时统计能力(占位),供后续接入 core.metrics
# +======================================================================
"""性能监控中间件(占位)。
TODO: 接入 core.metrics,统计请求计数与耗时。
预留扩展:
- 请求计数:按路径 / 方法 / 状态码维度累计 QPS
- 耗时直方图:记录请求处理耗时分布
- 慢请求标记:超过阈值的请求单独计数或告警
- 指标导出:通过 /api/v1/metrics 端点暴露给 Prometheus 抓取
"""
+171
View File
@@ -0,0 +1,171 @@
# +======================================================================
# | 模块: 操作日志中间件层
# | 说明: 提供 @operation_log 装饰器,拦截写请求并记录操作日志
# | (适配 Tornado 的装饰器形式),异步落库、敏感参数脱敏
# +======================================================================
"""操作日志中间件:拦截写请求并记录操作日志(适配 Tornado 的装饰器形式)。
用法:
@operation_log("职级管理", "详情")
@permission_required("sys:level:list")
async def get(self):
...
说明:
- 仅记录写方法(POST/PUT/DELETE),并按路径前缀排除登录/验证码/上传/主页等接口。
- 依赖 core.response 的 R.ok/R.failed 返回结果 dict,解析业务状态(code/msg)。
- 捕获请求参数(敏感字段脱敏)、URL、IP、UA(操作系统/浏览器/来源)。
- 落库通过 IOLoop.spawn_callback 延迟到响应写出后异步执行,不阻塞主流程。
"""
# ----------------------------------------------------------------------
# 导入依赖
# 涵盖:JSON 解析、计时、装饰器工具、类型标注、
# 浏览器/系统/来源识别与参数脱敏工具、IP 归属地查询、
# 当前用户上下文、日志器
# ----------------------------------------------------------------------
import json
import time
from functools import wraps
from typing import Any
from common.utils.helpers import detect_browser, detect_os, detect_source, redact_params
from common.utils.ip2region import get_ip_location
from core.context import current_realname
from core.logger import logger
# ============================================================
# 常量定义
# ============================================================
# 不记录操作日志的路径前缀(个人中心改资料/改密码不属后台管理操作,避免日志噪音)
_EXCLUDE_PATHS = (
"/api/v1/login",
"/api/v1/captcha",
"/api/v1/upload",
"/api/v1/index/menu",
"/api/v1/index/user",
"/api/v1/index/userInfo",
"/api/v1/index/updatePwd",
"/api/v1/index/logout",
"/api/v1/operationlog",
)
# 写方法才记录
_MUTATE_METHODS = ("POST", "PUT", "DELETE")
# 请求类型:HTTP 方法 -> 业务类型
_REQUEST_TYPE = {"POST": 1, "PUT": 2, "DELETE": 3}
# 字段长度上限
_PARAM_MAX_LEN = 10000
_RESULT_MAX_LEN = 10000
_ERROR_MAX_LEN = 1000
# ======================================================================
# 判断是否需要记录
# ======================================================================
def _should_log(self) -> bool:
"""判断当前请求是否需要记录操作日志。
:param self: Tornado 请求处理器
:return: 需要记录返回 True,否则 False
"""
if self.request.method not in _MUTATE_METHODS:
return False
path = self.request.path
# 按路径段边界匹配排除前缀(path == prefix 或 prefix + '/'),避免子串误伤
return not any(path == prefix or path.startswith(prefix + "/") for prefix in _EXCLUDE_PATHS)
# ======================================================================
# 记录操作日志(异步落库)
# ======================================================================
def _record(self: Any, module: str, action: str, status: int,
error: str, result: str, consume_time: int) -> None:
"""记录操作日志:延迟到响应写出后异步落库。
:param self: Tornado 请求处理器
:param module: 业务模块名(如"职级管理")
:param action: 操作动作(如"添加")
:param status: 操作状态(0-成功 1-失败)
:param error: 错误信息
:param result: 返回结果(JSON 字符串)
:param consume_time: 请求耗时(毫秒)
"""
try:
import tornado.ioloop
from modules.operation_log.service import record_operation_log
ua = self.request.headers.get("User-Agent", "")
body = self.request.body or b""
# 延迟到响应写出后异步落库,不阻塞主流程
tornado.ioloop.IOLoop.current().spawn_callback(
record_operation_log,
realname=current_realname.get(),
title=f"{module}-{action}",
type=_REQUEST_TYPE.get(self.request.method, 1),
source=detect_source(ua),
method=self.request.path,
request_method=self.request.method,
url=self.request.uri,
param=redact_params(body.decode("utf-8", errors="replace"), _PARAM_MAX_LEN),
result=result[:_RESULT_MAX_LEN],
ip=self.request.remote_ip,
location=get_ip_location(self.request.remote_ip),
os=detect_os(ua),
browser=detect_browser(ua),
consume_time=consume_time,
status=status,
error=error,
)
except Exception:
# 日志记录异常不影响主流程
logger.exception("操作日志记录异常")
# ======================================================================
# 操作日志装饰器
# ======================================================================
def operation_log(module: str = "", action: str = ""):
"""操作日志装饰器:放在 @permission_required / @check_demo 之后、路由函数之前。
:param module: 业务模块名(如"职级管理")
:param action: 操作动作(如"添加")
"""
def decorator(func):
"""接收被装饰函数,返回带日志记录的包装函数。"""
@wraps(func)
async def wrapper(self, *args, **kwargs):
"""日志包装:按需调用原函数并记录操作日志。"""
# 非写请求或排除路径:直接放行,不记录
if not _should_log(self):
return await func(self, *args, **kwargs)
start = time.time()
status = 0
error_msg = ""
result_msg = ""
try:
await func(self, *args, **kwargs)
# 解析业务状态:R.ok/R.failed 已将结果 dict 写入 self._api_result
result = getattr(self, "_api_result", None)
if isinstance(result, dict):
if result.get("code", 0) != 0:
status = 1
error_msg = result.get("msg", "") or ""
result_msg = json.dumps(result, ensure_ascii=False)[:_RESULT_MAX_LEN]
# 返回 None:避免 Tornado 对 handler 返回值执行 await 触发 TypeError
return None
except Exception as e:
status = 1
error_msg = str(e)
raise
finally:
# 无论成功失败均记录日志
consume_time = int((time.time() - start) * 1000)
_record(self, module, action, status,
error_msg[:_ERROR_MAX_LEN], result_msg, consume_time)
return wrapper
return decorator
+39
View File
@@ -0,0 +1,39 @@
# +======================================================================
# | 模块: 链路追踪 ID 中间件层
# | 说明: 透传或生成请求 trace_id,写入响应头与请求上下文
# +======================================================================
"""链路追踪 ID 中间件。
透传请求头 X-Trace-Id;无则生成新 ID,写入响应头与请求上下文。
"""
# ----------------------------------------------------------------------
# 导入依赖
# 涵盖:UUID 生成、当前请求 trace_id 上下文
# ----------------------------------------------------------------------
import uuid
from core.context import current_trace_id
# ============================================================
# 常量定义
# ============================================================
# 链路追踪 ID 请求头名称
TRACE_ID_HEADER = "X-Trace-Id"
# ======================================================================
# 生成 / 透传 trace_id
# ======================================================================
def apply_trace_id(self):
"""为当前请求生成/透传 trace_id。
:param self: Tornado 请求处理器
:return: 本次请求的 trace_id
"""
# 优先透传请求头,缺省时生成新的 UUID
trace_id = self.request.headers.get(TRACE_ID_HEADER) or uuid.uuid4().hex
# 写入响应头与请求上下文
self.set_header(TRACE_ID_HEADER, trace_id)
current_trace_id.set(trace_id)
return trace_id
+125
View File
@@ -0,0 +1,125 @@
# +======================================================================
# | 模块: 权限节点解析层
# | 说明: 跨模块共享的权限解析,供访问控制中间件与个人中心使用,
# | 带 Redis 缓存与版本号即时失效
# +======================================================================
"""权限节点解析(跨模块共享,供访问控制中间件与个人中心使用)。
权限节点存储于 menu 表(type=1),非独立业务模块:
- get_permissions_list:按用户解析权限节点列表(带 Redis 缓存)
- invalidate_permission_cache:角色/菜单/用户角色变更时使权限缓存失效
"""
# ----------------------------------------------------------------------
# 导入依赖
# 涵盖:JSON 序列化、SQLAlchemy 条件表达式、数据库会话、日志器、
# Redis 客户端、菜单/角色/用户模型
# ----------------------------------------------------------------------
import json
from sqlalchemy import and_, or_
from core.database import db
from core.logger import logger
from core.redis import redis
from modules.menu.models import Menu
from modules.role.models import Role
from modules.role_menu.models import RoleMenu
from modules.user.models import User
from modules.user_role.models import UserRole
# ============================================================
# 缓存配置
# ============================================================
# 权限缓存 TTL(秒):角色/菜单变更走版本号即时失效,TTL 仅兜底
_PERM_CACHE_TTL = 300
# 权限缓存版本号 key:自增即表示"所有用户的权限缓存失效"
_PERM_VERSION_KEY = "perm:version"
# ======================================================================
# 读取权限缓存版本号
# ======================================================================
def _get_perm_version() -> int:
"""读取权限缓存版本号(Redis 异常时回退 1,仅按 TTL 失效)。
:return: 当前权限缓存版本号
"""
try:
version = redis.get(_PERM_VERSION_KEY)
return int(version) if version else 1
except Exception:
return 1
# ======================================================================
# 失效权限缓存
# ======================================================================
def invalidate_permission_cache() -> None:
"""角色/菜单/用户角色变更后调用:自增版本号使所有权限缓存立即失效。"""
try:
redis.incr(_PERM_VERSION_KEY)
except Exception:
logger.warning("权限缓存失效失败(将按 TTL 自动过期)")
# ======================================================================
# 获取用户权限节点列表
# ======================================================================
def get_permissions_list(user_id) -> list:
"""获取用户权限节点列表(带 Redis 缓存)。超级管理员(id=1)拥有全部节点。
:param user_id: 用户 ID
:return: 权限节点标识列表
"""
user_id = int(user_id)
version = _get_perm_version()
cache_key = f"perm:list:{version}:{user_id}"
# 读缓存
try:
cached = redis.get(cache_key)
if cached is not None:
return json.loads(cached)
except Exception:
pass # 缓存读取异常降级为实时查询
# 实时查询
try:
if user_id == 1:
# 超级管理员:全部权限节点
nodes = db.query(Menu).filter(and_(Menu.is_delete == 0, Menu.type == 1)).all()
else:
# 其他用户:通过角色关联查询权限节点(过滤软删的用户/角色/菜单关联,
# 且仅取状态正常的用户,停用的用户不再授权)
nodes = (
db.query(Menu)
.join(RoleMenu, RoleMenu.menu_id == Menu.id)
.join(UserRole, UserRole.role_id == RoleMenu.role_id)
.join(Role, Role.id == UserRole.role_id)
.join(User, User.id == UserRole.user_id)
.filter(
UserRole.user_id == user_id,
UserRole.is_delete == 0,
RoleMenu.is_delete == 0,
Role.is_delete == 0,
User.is_delete == 0,
User.status == 1,
or_(Menu.type == 1, and_(Menu.type == 0, Menu.permission != "")),
Menu.status == 0,
Menu.is_delete == 0,
)
.distinct()
.all()
)
# 提取权限标识,过滤空值
result = [item.permission for item in nodes if item.permission]
finally:
db.close()
# 写缓存(Redis 异常时静默,仅损失缓存收益)
try:
redis.set(cache_key, json.dumps(result), ex=_PERM_CACHE_TTL)
except Exception:
pass
return result

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