diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..65c79f0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,98 @@ +# ============================================================ +# .dockerignore —— 排除不需要进入构建上下文的文件 +# 位置:项目根目录,与 manage.py 同级 +# ============================================================ + +# ------------------------------------------------------------ +# 版本控制(避免 .git 进镜像,省体积、防泄露) +# ------------------------------------------------------------ +.git +.gitignore +.gitattributes +.gitmodules + +# ------------------------------------------------------------ +# Python 缓存与编译产物 +# ------------------------------------------------------------ +__pycache__ +*.pyc +*.pyo +*.pyd +*.py[cod] +*$py.class +.pytest_cache +.mypy_cache +.ruff_cache +.coverage +.coverage.* +htmlcov +.tox +.nox + +# ------------------------------------------------------------ +# 虚拟环境 +# ------------------------------------------------------------ +venv +.venv +env +ENV +virtualenv + +# ------------------------------------------------------------ +# Python 打包产物 +# ------------------------------------------------------------ +build +dist +*.egg-info +*.egg +.eggs +pip-wheel-metadata + +# ------------------------------------------------------------ +# 前端依赖与构建产物 +# ------------------------------------------------------------ +node_modules +npm-debug.log +yarn-error.log +pnpm-debug.log +.ui-build + +# ------------------------------------------------------------ +# 日志与本地数据库 +# ------------------------------------------------------------ +*.log +logs +*.sqlite3 +*.db +*.sql.gz + +# ------------------------------------------------------------ +# 编辑器与 IDE +# ------------------------------------------------------------ +.DS_Store +Thumbs.db +.idea +.vscode +*.swp +*.swo +*~ + +# ------------------------------------------------------------ +# CI / 部署配置(保留 .jenkins/Dockerfile) +# ------------------------------------------------------------ +.jenkins/* +!.jenkins/Dockerfile +jenkins +*.jenkins + +# ------------------------------------------------------------ +# 项目自定义排除 +# ------------------------------------------------------------ +# 前端 UI 目录(按需排除,如不需要可删) +/ui +/public + +# 文档与说明(按需保留) +# README.md +# docs +# *.md \ No newline at end of file diff --git a/.env.example b/.env.example index aaca7ee..a017927 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # ============================================================ -# DjangoAdmin Flask+EleVue 环境变量模板 +# DjangoAdmin Flask 环境变量模板 # ============================================================ # 使用方法: # 1. 复制为 .env:cp .env.example .env @@ -11,7 +11,7 @@ # 应用基础配置(config/app.py) # ============================================================ FLASK_NAME=Flask+EleVue旗舰版 -FLASK_VERSION=v2.8.0 +FLASK_VERSION=v3.0.0 FLASK_APP=app.py FLASK_ENV=development # development / production FLASK_DEBUG=True # 生产环境必须为 False @@ -28,7 +28,7 @@ CORS_ALLOWED_ORIGINS=http://localhost:8001 # 上传配置 FLASK_UPLOAD_DIR=./uploads -FLASK_FILE_URL=http://images.flask.elevue +FLASK_FILE_URL=http://file.flask.elevue UPLOAD_ALLOWED_EXTS=.jpg,.jpeg,.png,.gif,.bmp,.webp,.svg,.ico,.txt,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.mp3,.mp4,.zip,.rar,.7z UPLOAD_MAX_SIZE_MB=10 @@ -38,6 +38,16 @@ PROXY_NUM=0 # ============================================================ # 数据库配置(config/database.py) # ============================================================ +# 数据库驱动 - 可选 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 DB_HOST=127.0.0.1 DB_PORT=3306 @@ -66,7 +76,8 @@ REDIS_DECODE_RESPONSES=True # JWT / 权限 / 白名单(config/auth.py) # ============================================================ JWT_SALT=<必填,JWT 签名密钥,建议随机长串> -JWT_EXPIRE_MINUTES=20 +JWT_EXPIRE_MINUTES=20 # 滑动会话空闲超时(分钟),无请求则过期,活跃自动续期 +JWT_ABSOLUTE_TIMEOUT_MINUTES=1440 # 令牌绝对有效期(分钟),防止无限续期,须≥JWT_EXPIRE_MINUTES LOGIN_FAIL_MAX=5 LOGIN_LOCK_SECONDS=300 PERMISSION_ENABLED=True # 权限校验总开关(开发可关) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index f2fdca3..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,74 +0,0 @@ -# ============================================================ -# CI/CD 工作流 -# ============================================================ -# 触发:push / PR -# 任务:flake8 → type-check → pytest(含 MySQL/Redis 服务) -# ============================================================ - -name: CI - -on: - push: - branches: [master, main] - pull_request: - -jobs: - lint-and-test: - runs-on: ubuntu-latest - - # 依赖服务(供集成测试使用) - services: - mysql: - image: mysql:8.0 - env: - MYSQL_ROOT_PASSWORD: ci_password - MYSQL_DATABASE: djangoadmin_flask_elevue_ci - ports: - - 3306:3306 - options: >- - --health-cmd="mysqladmin ping -h localhost" - --health-interval=10s - --health-timeout=5s - --health-retries=5 - redis: - image: redis:7 - ports: - - 6379:6379 - options: >- - --health-cmd="redis-cli ping" - --health-interval=10s - --health-timeout=5s - --health-retries=5 - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - cache: pip - - - name: 安装依赖 - run: pip install -r requirements-dev.txt - - - name: 静态检查(flake8) - run: flake8 src config tests --max-complexity=30 - - - name: 类型检查(mypy) - run: mypy src || true - - - name: 单元测试(pytest) - env: - DB_HOST: 127.0.0.1 - DB_PORT: 3306 - DB_DATABASE: djangoadmin_flask_elevue_ci - DB_USERNAME: root - DB_PASSWORD: ci_password - DB_SSL: 'False' - REDIS_HOST: 127.0.0.1 - REDIS_PORT: 6379 - REDIS_AUTH: 'False' - FLASK_DEBUG: 'False' - FLASK_SECRET_KEY: ci-secret-key-0123456789abcdef - JWT_SALT: ci-jwt-salt-0123456789abcdef - run: pytest -v diff --git a/.gitignore b/.gitignore index ee79ecd..c8bf095 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,6 @@ __pycache__/ *$py.class *.egg-info/ .eggs/ -build/ dist/ .venv/ venv/ @@ -75,4 +74,4 @@ coverage.xml # ============================================================ *.pid *.sqlite3 -captcha.png \ No newline at end of file +captcha.png diff --git a/.jenkins/.env.yml b/.jenkins/.env.yml new file mode 100644 index 0000000..a17137b --- /dev/null +++ b/.jenkins/.env.yml @@ -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} \ No newline at end of file diff --git a/.jenkins/Dockerfile b/.jenkins/Dockerfile new file mode 100644 index 0000000..d2c5b5e --- /dev/null +++ b/.jenkins/Dockerfile @@ -0,0 +1,142 @@ +# ============================================================ +# 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} + +# 启动命令 +# ${PYFILE} 为入口文件名(不含 .py),如 manage +# runserver 仅适合开发/调试;生产环境建议改用 uwsgi / gunicorn,例如: +# CMD ["sh","-c","uwsgi --http 0.0.0.0:${PORT} \ +# --module 你的项目名.wsgi:application \ +# --processes 4 --threads 2"] +CMD ["sh", "-c", "python3 app.py runserver 0.0.0.0:${PORT} --noreload"] diff --git a/.jenkins/Jenkinsfile b/.jenkins/Jenkinsfile new file mode 100644 index 0000000..8129435 --- /dev/null +++ b/.jenkins/Jenkinsfile @@ -0,0 +1,3 @@ +@Library('share-library@master')_ +orgName = 'djangoadmin' +pipelinePythonService(orgName) \ No newline at end of file diff --git a/.jenkins/Jenkinsfile2 b/.jenkins/Jenkinsfile2 new file mode 100644 index 0000000..2cf3edd --- /dev/null +++ b/.jenkins/Jenkinsfile2 @@ -0,0 +1,3 @@ +@Library('share-library@master')_ +orgName = 'djangoadmin' +pipelineNodeService(orgName) \ No newline at end of file diff --git a/11.txt b/11.txt deleted file mode 100644 index e4083db..0000000 --- a/11.txt +++ /dev/null @@ -1,177 +0,0 @@ -现在我对项目有了全面的了解。以下是分析结果: - - --- - 后端功能完整性分析 - 已实现的模块(共 20 个业务模块) - - ┌──────────┬───────────────┬─────────────────────────┬───────────────────────────────────────────┐ - │ 分类 │ 模块 │ CRUD │ 特殊功能 │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ 系统核心 │ login │ - │ 登录、验证码、退出、Token黑名单 │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ │ index │ - │ 菜单获取、用户信息、个人中心、改密 │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ 系统管理 │ user │ 完整CRUD+批量删除 │ 状态切换、密码重置 │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ │ role │ 完整CRUD+批量删除 │ 角色列表(下拉) │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ │ role_menu │ - │ 角色菜单权限查询/保存 │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ │ menu │ 完整CRUD │ 树形列表 │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ │ dept │ 完整CRUD │ 树形列表、部门数据下拉 │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ │ position │ 完整CRUD+批量删除 │ 状态切换、岗位列表(下拉) │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ │ level │ 完整CRUD+批量删除 │ 状态切换、Excel导入/导出、职级列表(下拉) │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ 系统配置 │ param │ 完整CRUD+批量删除 │ 状态切换 │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ │ dict │ 完整CRUD+批量删除 │ - │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ │ dict_item │ 完整CRUD+批量删除 │ - │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ │ config │ 完整CRUD+批量删除 │ 网站配置查询/保存 │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ │ config_item │ 完整CRUD+批量删除 │ 状态切换 │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ 内容管理 │ category │ 完整CRUD │ 分类数据下拉 │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ │ article │ 完整CRUD+批量删除 │ 状态切换、点击量 │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ │ notice │ 完整CRUD+批量删除 │ - │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ │ link │ 完整CRUD+批量删除 │ 状态切换 │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ 辅助功能 │ upload │ - │ 文件上传 │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ │ file_template │ 完整CRUD+批量删除 │ - │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ 监控日志 │ login_log │ 分页+详情+删除+批量删除 │ - │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ │ operation_log │ 分页+详情+删除+批量删除 │ - │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ 定时任务 │ job │ 完整CRUD+批量删除 │ 状态切换、暂停/恢复、立即执行、重载调度器 │ - ├──────────┼───────────────┼─────────────────────────┼───────────────────────────────────────────┤ - │ 其他业务 │ city │ 完整CRUD │ 省市联动查询 │ - └──────────┴───────────────┴─────────────────────────┴───────────────────────────────────────────┘ - - 基础设施(完整度较高) - - ┌──────────────┬──────┬────────────────────────────────────────────────────────────────┐ - │ 组件 │ 状态 │ 说明 │ - ├──────────────┼──────┼────────────────────────────────────────────────────────────────┤ - │ AOP事务管理 │ 完整 │ before/after/teardown自动提交/回滚 │ - ├──────────────┼──────┼────────────────────────────────────────────────────────────────┤ - │ JWT认证 │ 完整 │ 登录验证、Token黑名单、白名单(精确/前缀/通配符) │ - ├──────────────┼──────┼────────────────────────────────────────────────────────────────┤ - │ RBAC权限 │ 完整 │ 装饰器权限校验、超管放行、内部调用放行 │ - ├──────────────┼──────┼────────────────────────────────────────────────────────────────┤ - │ 操作日志 │ 完整 │ after_request自动记录写操作 │ - ├──────────────┼──────┼────────────────────────────────────────────────────────────────┤ - │ 登录日志 │ 完整 │ 登录/退出自动记录 │ - ├──────────────┼──────┼────────────────────────────────────────────────────────────────┤ - │ 全局异常处理 │ 完整 │ 400/401/403/404/405/500/Exception统一JSON响应 │ - ├──────────────┼──────┼────────────────────────────────────────────────────────────────┤ - │ Demo模式 │ 完整 │ 写操作拦截 │ - ├──────────────┼──────┼────────────────────────────────────────────────────────────────┤ - │ CORS │ 完整 │ 支持指定源+凭证或通配符 │ - ├──────────────┼──────┼────────────────────────────────────────────────────────────────┤ - │ ProxyFix │ 完整 │ 可信代理配置 │ - ├──────────────┼──────┼────────────────────────────────────────────────────────────────┤ - │ 数据库迁移 │ 完整 │ Flask-Migrate/Alembic,3个版本文件 │ - ├──────────────┼──────┼────────────────────────────────────────────────────────────────┤ - │ APScheduler │ 完整 │ 调度器初始化/重载/暂停/恢复/立即执行 │ - ├──────────────┼──────┼────────────────────────────────────────────────────────────────┤ - │ Flask-Mail │ 完整 │ 扩展初始化,配置齐全 │ - ├──────────────┼──────┼────────────────────────────────────────────────────────────────┤ - │ Redis │ 完整 │ 缓存+会话 │ - ├──────────────┼──────┼────────────────────────────────────────────────────────────────┤ - │ Docker │ 完整 │ Dockerfile + docker-compose.yml │ - ├──────────────┼──────┼────────────────────────────────────────────────────────────────┤ - │ 工具库 │ 完整 │ 17个工具模块(R/JWT/密码/验证码/文件/IP/UA/格式化/大小写转换等) │ - ├──────────────┼──────┼────────────────────────────────────────────────────────────────┤ - │ 表单验证 │ 完整 │ 22个WTForms表单+BaseForm+ArrayField │ - └──────────────┴──────┴────────────────────────────────────────────────────────────────┘ - - 存在的问题和缺失 - - 1. user_role 模块缺失视图层 - - models/user_role.py 和 services/user_role.py 存在,但 views/ 中没有 user_role.py - - _blueprint_modules 列表中也没有 user_role - - 用户-角色关联管理没有独立API(可能内嵌在user模块中,但未确认) - - 2. 部分模块缺少常见功能 - - ┌─────────────────────────┬─────────────────────────────────────────────────────────────┐ - │ 模块 │ 缺失功能 │ - ├─────────────────────────┼─────────────────────────────────────────────────────────────┤ - │ user │ 缺少导入/导出(level有,user没有) │ - ├─────────────────────────┼─────────────────────────────────────────────────────────────┤ - │ role │ 缺少状态切换 │ - ├─────────────────────────┼─────────────────────────────────────────────────────────────┤ - │ menu │ 缺少批量删除、排序接口 │ - ├─────────────────────────┼─────────────────────────────────────────────────────────────┤ - │ dept │ 缺少批量删除 │ - ├─────────────────────────┼─────────────────────────────────────────────────────────────┤ - │ dict/dict_item │ 缺少状态切换、根据字典编码获取字典项的接口 │ - ├─────────────────────────┼─────────────────────────────────────────────────────────────┤ - │ notice │ 缺少状态切换、通知推送/已读标记 │ - ├─────────────────────────┼─────────────────────────────────────────────────────────────┤ - │ article │ 缺少导入/导出 │ - ├─────────────────────────┼─────────────────────────────────────────────────────────────┤ - │ category │ 缺少批量删除、排序接口 │ - ├─────────────────────────┼─────────────────────────────────────────────────────────────┤ - │ config │ 缺少状态切换 │ - ├─────────────────────────┼─────────────────────────────────────────────────────────────┤ - │ file_template │ 缺少状态切换、模板文件上传/下载接口(注释提到但未实现路由) │ - ├─────────────────────────┼─────────────────────────────────────────────────────────────┤ - │ login_log/operation_log │ 缺少清空日志、统计/趋势分析接口(注释提到但未实现路由) │ - └─────────────────────────┴─────────────────────────────────────────────────────────────┘ - - 3. 系统级缺失功能 - - ┌──────────────┬─────────────────────────────────────────────┐ - │ 功能 │ 说明 │ - ├──────────────┼─────────────────────────────────────────────┤ - │ 数据权限 │ 无行级数据权限控制(如只能看本部门数据) │ - ├──────────────┼─────────────────────────────────────────────┤ - │ 缓存管理 │ 无Redis缓存查看/清理接口 │ - ├──────────────┼─────────────────────────────────────────────┤ - │ 在线用户 │ 无在线用户查看/强退接口 │ - ├──────────────┼─────────────────────────────────────────────┤ - │ 系统监控 │ 无服务器状态监控(CPU/内存/磁盘等) │ - ├──────────────┼─────────────────────────────────────────────┤ - │ API限流 │ 无请求频率限制 │ - ├──────────────┼─────────────────────────────────────────────┤ - │ 数据备份 │ 无数据库备份/恢复接口 │ - ├──────────────┼─────────────────────────────────────────────┤ - │ WebSocket │ 无实时推送能力 │ - ├──────────────┼─────────────────────────────────────────────┤ - │ 国际化(i18n) │ 无多语言支持 │ - ├──────────────┼─────────────────────────────────────────────┤ - │ 审计日志 │ 操作日志有,但无数据变更前后对比记录 │ - ├──────────────┼─────────────────────────────────────────────┤ - │ 文件管理 │ 只有上传,无文件列表/删除/预览管理 │ - ├──────────────┼─────────────────────────────────────────────┤ - │ 邮件发送 │ Flask-Mail已初始化,但无发送邮件的API接口 │ - ├──────────────┼─────────────────────────────────────────────┤ - │ 短信发送 │ job中有sendSms路由但只是空壳(return R.ok()) │ - └──────────────┴─────────────────────────────────────────────┘ - - 4. 代码层面问题 - - - batchDelete 逻辑在每个模块重复实现,未抽取公共方法 - - user_role 有 model 和 service 但无 view,属于半成品 - - job/sendSms 是空实现 - - file_template 注释提到上传/下载但未实现路由 - - login_log/operation_log 注释提到清空/统计但未实现路由 - - 总结 - - 后端功能完整度约 75-80%。核心的RBAC权限体系、用户/角色/菜单/部门管理、AOP事务、日志记录等基础设施非常完整。主要短板在于: - - 1. 已有模块的功能不够均匀 — 部分模块缺少状态切换、批量删除、导入导出等常见操作 - 2. 运维监控类功能缺失 — 在线用户、缓存管理、服务器监控、数据备份等 - 3. 部分半成品代码 — user_role视图缺失、sendSms空实现、文件模板上传下载未实现 - 4. 代码复用不足 — batchDelete等重复代码应抽取公共方法 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 423faad..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,37 +0,0 @@ -# 更新日志 - -本项目的所有重要变更都会记录在此文件中。 -格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。 - -## [v3.0.0] - 2026-08-07 - -### 🏗️ 企业级重构(P1-P9 全部完成) - -基于《重构方案-v2.md》,后端完成整体重构: - -- **目录重构**:源码迁入 `src/`(core/common/modules/routes),删除 `application/`、`extensions/`、`utils/` -- **服务层范式**:21 个服务模块改为「入参数据/出参数据 + `raise BizError`」,新增 repository 层 -- **依赖注入**:权限校验与操作日志通过 `current_app.extensions` 注入(解决 core→modules 依赖倒置) -- **API 版本化**:全部 148 条路由挂载 `/api/v1` 前缀,登录白名单同步更新 -- **工程化**:pyproject.toml / flake8 / black / pre-commit / Makefile / pytest / CI-CD -- **运维**:`/health` 健康端点、Docker 加固、运维脚本(scripts/) - -**质量指标**:全项目 flake8 从 154 条基线降至 0;pytest 12 项通过。 - -### 修复的问题 - -- 合并模块重名函数互相覆盖(get_info/_apply_filters/_build_page_data) -- `log/` gitignore 规则误忽略 `src/modules/log/`(模块未入库) -- `DictItemItem` typo、`utils.load2dict` 不存在等潜在 bug -- 应用工厂循环导入(factory 独立为模块) - -### 破坏性变更 - -- **API 前缀**:所有接口由 `/xxx` 变更为 `/api/v1/xxx`,前端需同步(见 USAGE.md) -- **服务层签名**:服务函数不再直接读取 `request`,由 router 层取参包 R - ---- - -## [v2.8.0] - 2026-07 - -原版本(重构前)。见 `document/版本更新说明.txt`。 diff --git a/CLAUDE.md b/CLAUDE.md index cf9d26f..8bd9d81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,13 +65,13 @@ docker-compose up -d Each business module follows a strict **View -> Service -> Model** separation: - **`src/modules///router.py`** — Route controllers (Flask Blueprint). Registered under `/api/v1` prefix (see `src/routes/v1.py`). Each file defines routes decorated with `@check_permission("sys:module:action")`. Routers parse request params, delegate to service functions, and wrap with `R.ok()`/`R.failed()`. Write operations check `FLASK_DEMO` first (via `@check_demo`). -- **`src/modules///service.py`** — Business logic layer. **服务函数「入参数据/出参数据」**(不直接读 `request`,不返回 R);业务失败 `raise BizError`;私有辅助函数以 `_` 开头。数据访问委托 repository。 +- **`src/modules///service.py`** — Business logic layer. **服务函数「入参数据/出参数据」**(不直接读 `request`,不返回 R);业务失败 `raise BizError`;私有辅助函数以 `_` 开头。数据访问委托 repository。标准 CRUD 模块继承 `BaseService`(`src/common/base_service.py`,含 page/detail/add/update/remove/update_status/list 模板方法 + before_*/after_*/_detail_dict/_enrich_page_records 覆写钩子),模块级代理函数暴露给 router 调用(router 零改动)。 - **`src/modules///models.py`** — SQLAlchemy models. All models inherit from `BaseModel` + `BaseDB` (in `src/common/`). Table names use `DB_PREFIX` (default `flask_`) + snake_case class name. - **`src/modules///repository.py`** — 数据访问层,继承 `BaseRepository`(`src/common/base_repository.py`)。 ### AOP Transaction Management -The framework uses an AOP-style transaction interceptor (`src/core/middleware.py`) registered via `register_transaction(app)`. **Business code never calls commit/rollback directly.** +The framework uses an AOP-style transaction interceptor (`src/middleware/transaction.py`) registered via `register_transaction(app)`. **Business code never calls commit/rollback directly.** - `before_request`: marks transaction active - `after_request`: auto-commits on POST/PUT/DELETE/PATCH only if HTTP 2xx AND `code==0` in response JSON; auto-rollbacks on business failures (`R.failed` returns HTTP 200 but `code=1`) @@ -83,7 +83,7 @@ The framework uses an AOP-style transaction interceptor (`src/core/middleware.py - **`src/app.py`** — App factory (`create_app()`). Initialization order: Flask core -> Redis config -> DB config -> JSON encoder -> CORS -> ProxyFix -> op_log/transaction -> register `/api/v1` routes -> init extensions -> middleware -> error handlers -> DI injection. - **`src/routes/v1.py`** — API v1 路由注册中心。所有业务蓝图挂载到 `/api/v1` 前缀。 -- **`src/core/middleware.py`** — `check_login` (before_request hook, JWT validation with token blacklist, whitelist matching), `check_demo`, `register_transaction` (AOP 事务), `register_operation_log` (操作日志,写入器 DI 注入), `register_error_handlers`. +- **`src/middleware/`** — 中间件包(按职责拆分:`auth.py` 登录校验 + 白名单、`demo.py` 演示模式、`transaction.py` AOP 事务、`operation_log.py` 操作日志 + `@operation_log` 标题装饰器、`errors.py` 异常处理)。对外统一 `from src.middleware import ...`。 - **`src/core/permission.py`** — `check_permission` (RBAC decorator; superadmin user_id=1 bypasses; 权限数据通过 `current_app.extensions['permission_provider']` 注入,见 `src/app.py` `_inject_dependencies`). - **`src/core/exceptions.py`** — `BizError` 业务异常(业务层 `raise BizError` 统一转 `R.failed`)。 - **`src/common/forms/`** — WTForms 验证表单。继承 `BaseForm`(含 `ArrayField` JSON 序列化)。 @@ -109,6 +109,7 @@ The framework uses an AOP-style transaction interceptor (`src/core/middleware.py - Backend routes follow RESTful patterns: `GET /{module}/page`, `GET /{module}/detail/`, `POST /{module}/add`, `PUT /{module}/update`, `DELETE /{module}/delete/`, `PUT /{module}/status`, `GET /{module}/list` - All responses use `R.ok(data=...)` / `R.failed(msg=...)` format with `{code, data, msg, ok}` structure - Frontend response handler checks `code === 0` for success; `code=401` triggers re-login +- **统一约定:所有响应(含认证/权限失败 401/403)返回 HTTP 200 + 业务 code**,不返回对应 HTTP 状态码——前端按 `code` 判断(401 重登、403 无权限)。若需按 HTTP 状态码防护(WAF/代理),应解析业务 code 而非 HTTP status - Permission strings follow pattern: `sys:module:action` (e.g., `sys:user:page`, `sys:role:add`) - Frontend proxies `/api` prefix to backend via Vite dev server proxy (configured in `ui/.env.development` as `VITE_PROXY=[["/api","http://127.0.0.1:8021/"]]`) @@ -124,10 +125,12 @@ The framework uses an AOP-style transaction interceptor (`src/core/middleware.py ### Adding a New Module 1. Create model in `src/modules///models.py` inheriting `BaseModel` + `BaseDB` -2. Create service in `src/modules///service.py`(入参数据/出参数据 + raise BizError) -3. Create repository in `src/modules///repository.py` inheriting `BaseRepository` +2. Create service in `src/modules///service.py`(入参数据/出参数据 + raise BizError): + - 标准 CRUD 模块:`class XxxService(BaseService)` 指定 `repository/form/status_form/model_name`,差异逻辑覆写钩子,末尾暴露模块级代理函数(如 `get_xxx_page = XxxService.get_page`)供 router 调用 + - 深度定制模块(如 user/auth):保持模块级函数 + raise BizError +3. Create repository in `src/modules///repository.py` inheriting `BaseRepository`(标准模块需实现 `page(params)` 与 `list_enabled()`) 4. Create router in `src/modules///router.py` with routes and permission decorators -5. Create schema in `src/modules///schema.py` inheriting `BaseForm` if input validation needed +5. Create schema in `src/modules///schema.py` inheriting `BaseForm` if input validation needed(唯一性校验用 `XxxRepository.exists_by(filters, exclude_id)`,禁止直接 `Model.query`——查询只允许在 Repository 层) 6. Add the blueprint to `BLUEPRINTS` list in `src/routes/v1.py` 7. Add frontend API in `ui/src/api/{module}/`, views in `ui/src/views/{module}/`(API 前缀 /api/v1) @@ -136,7 +139,8 @@ The framework uses an AOP-style transaction interceptor (`src/core/middleware.py All config is in `.env` at project root. Key settings: - `FLASK_HOST` / `FLASK_PORT` (default 127.0.0.1:8021) - `FLASK_SECRET_KEY` — must be >= 16 chars or app refuses to start -- `DB_*` — MySQL connection (database: `djangoadmin.flask.elevue`, prefix: `flask_`) +- `DB_*` — 数据库连接,**多库支持**(`config/database.py` 的 `_build_database_url()` 按 `DB_DRIVER` 构建 URL):`mysql`(默认)/`postgresql`/`mssql`/`sqlite`/`oracle`(oracledb 驱动,thin 模式无需 Oracle Client;`DB_DATABASE` 填 SERVICE_NAME)。切换只需改 `DB_DRIVER` + 账号,然后运行 `python scripts/setup_db.py`(`create_all` 建表 + 种子数据,幂等)。注意:`flask db migrate/upgrade`(方案 B)仍针对 MySQL,新建其它库请用 `scripts/setup_db.py` +- **跨库数据迁移**:`python scripts/migrate_db.py --src-driver=mysql --src-host=127.0.0.1 --src-port=3306 --src-db=<源库名> --src-user=root --src-pass=<密码> --dst-driver=postgresql --dst-host=127.0.0.1 --dst-port=5432 --dst-db=<目标库名> --dst-user=postgres --dst-pass=<密码> --drop-target-first`。CLI 显式参数、不依赖 `.env`;源/目标均支持 `mysql/postgresql/mssql/sqlite/oracle`(别名 `postgres=postgresql`、`sqlserver=mssql`、`sqlite3=sqlite`),自动建目标库 + `create_all` 建表 + 逐表泵数据 + 按方言重置自增(PG setval / MSSQL CHECKIDENT / Oracle 补 sequence+触发器)+ 逐表行数校验;先加 `--dry-run` 干跑核对计划 - `REDIS_*` — Redis connection - `JWT_SALT` — JWT signing key; `JWT_EXPIRE_MINUTES` (default 20) - `FLASK_DEMO` — Demo mode restricts write operations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 57cd61e..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,60 +0,0 @@ -# 贡献指南 - -欢迎参与 DjangoAdmin Flask+EleVue 的开发。请遵循以下规范。 - -## 开发环境 - -```bash -# 1. 安装开发依赖(含生产依赖) -python -m venv .venv -.venv/Scripts/activate # Windows -# source .venv/bin/activate # Linux/macOS -pip install -r requirements-dev.txt - -# 2. 复制并配置环境变量 -cp .env.example .env - -# 3. 安装 pre-commit 钩子 -pre-commit install -``` - -## 代码规范 - -项目强制 `flake8` 零告警(与 black/isort 配置协同)。 - -```bash -make lint # flake8 + black --check + isort --check -make format # black + isort 自动格式化 -make type-check # mypy -``` - -## 新增业务模块 - -遵循 **View(router) → Service → Repository → Model** 四层: - -1. `src/modules///models.py` — 继承 `BaseModel` + `BaseDB` -2. `src/modules///repository.py` — 继承 `BaseRepository` -3. `src/modules///service.py` — 业务逻辑(**入参数据/出参数据**,失败 `raise BizError`,不读 `request`) -4. `src/modules///schema.py` — WTForms 验证表单(继承 `BaseForm`) -5. `src/modules///router.py` — 路由(取参 + 包 R + `@check_permission`) -6. `src/routes/v1.py` — 将蓝图加入 `BLUEPRINTS` 列表(挂载 `/api/v1`) - -## 测试 - -```bash -make test # pytest -make test-cov # 覆盖率 -``` - -单元测试放置于 `tests/unit/`,集成测试放置于 `tests/integration/`。 - -## 提交规范 - -- 提交信息使用 `type: 描述` 前缀(feat/fix/refactor/chore/docs) -- 每阶段独立提交,便于回滚 -- pre-commit 钩子会自动执行格式检查 - -## 文档 - -- 架构与规范见 `CLAUDE.md` 与 `重构方案-v2.md` -- API 变更需同步 `USAGE.md` 与 `CHANGELOG.md` diff --git a/Makefile b/Makefile index 7e501a8..07d5474 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # ============================================================ -# DjangoAdmin Flask+EleVue 常用命令集 +# DjangoAdmin Flask 常用命令集 # 用法:make help / make lint / make test / ... # ============================================================ @@ -7,7 +7,7 @@ PYTHON ?= python PIP ?= $(PYTHON) -m pip FLASK ?= $(PYTHON) -m flask -# 说明:.env 中 FLASK_VERSION=v2.8.0 会被 Flask CLI 误解析为 --version 布尔标志, +# 说明:.env 中 FLASK_VERSION=v3.0.0 会被 Flask CLI 误解析为 --version 布尔标志, # 导致 flask db 系列命令报错。故 db-* 目标显式跳过 CLI 自动加载 .env # (config/__init__.py 会自行加载 .env,应用配置不受影响)。 FLASK_ENV_PREFIX := FLASK_SKIP_DOTENV=1 FLASK_APP=app.py diff --git a/README.en.md b/README.en.md index 21a3fd0..e69de29 100644 --- a/README.en.md +++ b/README.en.md @@ -1,36 +0,0 @@ -# DjangoAdmin_Flask_EleVue - -#### Description -一款 Python 语言基于Flask、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/) diff --git a/README.md b/README.md index ac56458..91681d7 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,219 @@ +

DjangoAdmin 数据中台解决方案(Flask + ElementPlus 版本)

+ +

DjangoAdmin Logo

+ ## 📚 项目介绍 -一款 Python 语言基于Flask、Vue2.x、ElementUI、MySQL等框架精心打造的一款模块化、高性能、企业级的敏捷开发框架,本着简化开发、提升开发效率的初衷触发,框架自研了一套个性化的组件,实现了可插拔的组件式开发方式:单图上传、多图上传、下拉选择、开关按钮、单选按钮、多选按钮、图片裁剪等等一系列个性化、轻量级的组件,是一款真正意义上实现组件化开发的敏捷开发框架。 +DjangoAdmin 数据中台解决方案是一套面向企业级应用场景的一站式敏捷开发平台。后端以 Python + Flask 为核心,前端采用 Vue3 + TypeScript + ElementPlus + Vite 构建,并通过 Vue Router 与 Pinia 实现路由和状态管理。全栈技术体系成熟稳定,可快速支撑中后台管理系统、数据中台、业务运营平台等多类项目落地。 + +框架以"简化开发、提升效率"为设计初衷,自研了一套个性化、可插拔的组件式开发体系,涵盖单图上传、多图上传、下拉选择、开关按钮、单选按钮、多选按钮等常用交互组件。开发者只需按需组合,即可完成业务页面搭建,真正实现低代码开发。 + +在工程化层面,框架内置代码生成器,可根据数据库表结构自动生成完整的 CRUD 模块,包括后端代码、前端页面以及菜单权限记录,将重复性的增删改查工作一键自动化,让开发者能够将精力聚焦于核心业务逻辑与差异化功能。 + +在架构层面,框架采用前后端分离设计:后端提供标准 API,前端独立部署并支持多端适配。权限体系基于 RBAC 模型构建,覆盖菜单权限、按钮权限、数据权限三级控制;认证采用 JWT 无状态方案,使前后端协作更加高效顺畅。后端采用 View → Service → Model 三层架构,配合 AOP 事务管理、统一响应规范与完善的开发工具链,为团队协作与长期维护提供坚实保障。 ## 🍻 项目特点 -+ 模块化、松耦合 -+ 模块丰富、开箱即用 -+ 简洁易用、快速接入 -+ 文档详尽、易于维护 -+ 自顶向下、体系化设计 -+ 统一框架、统一组件、降低选择成本 -+ 开发规范、设计模式、代码分层模型 -+ 强大便捷的开发工具链 -+ 完善的本地中文化支持 -+ 设计为团队及企业使用 +- **低代码生成** — 通过代码生成器一键生成完整模块,从数据库表定义到前后端代码、菜单权限全自动完成 +- **模块化、松耦合** — 每个业务模块独立封装,互不干扰,支持按需加载 +- **模块丰富、开箱即用** — 内置用户、角色、菜单、部门、字典、日志等十余个基础模块 +- **前后端分离** — 前后端独立开发与部署,接口规范统一,支持多端适配 +- **权限体系完善** — 基于 RBAC 模型,支持菜单权限、按钮权限、数据权限三级控制 +- **认证安全可靠** — 采用无状态认证方案,易于扩展,适配多端与分布式场景 +- **AOP 事务管理** — 业务代码无需手动提交或回滚,中间件自动根据响应状态处理事务 +- **统一响应规范** — 接口返回格式统一,前后端协作更高效 +- **开发规范、设计模式** — 代码分层清晰,职责明确,静态检查零告警,便于扩展与维护 +- **完善的本地中文化支持** — 错误提示、表单验证、操作日志全面中文化 +- **强大便捷的开发工具链** — 内置代码检查与格式化工具,代码质量有保障 +- **设计为团队及企业使用** — 规范统一,便于多人协作与长期维护 ## 🍪 内置模块 -+ 用户管理:用于维护管理系统的用户,常规信息的维护与账号设置。 -+ 角色管理:角色菜单管理与权限分配、设置角色所拥有的菜单权限。 -+ 菜单管理:配置系统菜单,操作权限,按钮权限标识等。 -+ 职级管理:主要管理用户的职级。 -+ 岗位管理:主要管理用户担任职务。 -+ 部门管理:配置系统组织机构,树结构展现支持数据权限。 -+ 字典管理:对系统中常用的较为固定的数据进行统一维护。 -+ 配置管理:对系统的常规配置信息进行维护,网站配置管理功能进行统一维护。 -+ 通知公告:系统通知公告信息发布维护。 +- 用户管理:维护管理系统的用户,常规信息的维护与账号设置,支持头像上传裁剪 +- 角色管理:角色菜单管理与权限分配,设置角色所拥有的菜单权限 +- 菜单管理:配置系统菜单、操作权限、按钮权限标识,支持动态路由生成 +- 职级管理:主要管理用户的职级信息 +- 岗位管理:主要管理用户担任的职务 +- 部门管理:配置系统组织机构,树结构展现支持数据权限 +- 字典管理:对系统中常用的较为固定的数据进行统一维护 +- 字典项管理:字典数据的明细项管理 +- 配置管理:对系统的常规配置信息进行维护,网站配置管理功能统一维护 +- 通知公告:系统通知公告信息发布维护 +- 操作日志:记录用户关键操作,支持日志查询与审计 +- 登录日志:记录用户登录信息,支持 IP 归属地解析 +- 定时任务:基于 APScheduler 的定时任务管理,支持任务调度与执行日志 +- 文件模板:文件模板管理与导出 +- 代码生成器:根据数据库表结构自动生成前后端模块代码 + +## 🚀 快速开始 + +### 环境要求 + +- **Python** >= 3.12 +- **Node.js** >= 22 +- **pnpm** >= 7 +- **MySQL** >= 8.0 +- **Redis** >= 6.0 + +## 🗄️ 数据库支持 + +框架默认支持 **MySQL** 数据库,同时也支持多种数据库,可根据实际业务场景灵活切换。 + +| 数据库 | 驱动 | 支持情况 | +| --- | --- | --- | +| MySQL | PyMySQL 1.1.2 | ✅ 默认支持 | +| PostgreSQL | psycopg2-binary 2.9.10 | ✅ 支持 | +| SQL Server | pymssql 2.3.3 | ✅ 支持 | +| Oracle | oracledb 2.5.1(thin 模式,无需 Oracle Client) | ✅ 支持 | +| SQLite | Python 内置 | ✅ 支持 | + +## 📁 项目结构 + +``` +├── src/ # Flask 后端源码目录 +│ ├── app.py # 应用工厂(create_app()) +│ ├── extensions.py # Flask 扩展注册 +│ ├── models_registry.py # 模型注册中心 +│ ├── routes/ # 路由注册 +│ │ └── v1.py # API v1 路由注册中心(/api/v1 前缀) +│ ├── middleware/ # 中间件 +│ │ ├── auth.py # JWT 登录校验 + 白名单 +│ │ ├── demo.py # 演示模式限制 +│ │ ├── transaction.py # AOP 事务管理 +│ │ ├── operation_log.py # 操作日志记录 +│ │ └── errors.py # 全局异常处理 +│ ├── core/ # 核心组件 +│ │ ├── database.py # SQLAlchemy 数据库初始化 +│ │ ├── redis_client.py # Redis 客户端 +│ │ ├── jwt.py # JWT 工具 +│ │ ├── password.py # bcrypt 密码工具 +│ │ ├── permission.py # RBAC 权限校验装饰器 +│ │ ├── response.py # R 统一响应封装 +│ │ ├── exceptions.py # BizError 业务异常 +│ │ ├── security.py # 安全工具(get_user_id 等) +│ │ ├── scheduler.py # APScheduler 定时任务 +│ │ └── mail.py # 邮件发送 +│ ├── common/ # 公共基础层 +│ │ ├── base_model.py # BaseModel 基类 +│ │ ├── base_db.py # BaseDB 数据库操作基类 +│ │ ├── base_service.py # BaseService 服务基类(CRUD 模板方法) +│ │ ├── base_repository.py # BaseRepository 数据访问基类 +│ │ ├── forms/ # WTForms 表单验证 +│ │ ├── constants/ # 公共常量 +│ │ └── utils/ # 工具函数(JWT、响应、文件、IP 等) +│ └── modules/ # 业务模块(每个模块含 router/service/model/repository) +│ ├── system/ # 系统管理 +│ │ ├── user/ # 用户管理 +│ │ ├── role/ # 角色管理 +│ │ ├── menu/ # 菜单管理 +│ │ ├── dept/ # 部门管理 +│ │ ├── level/ # 职级管理 +│ │ └── position/ # 岗位管理 +│ ├── association/ # 关联关系 +│ │ ├── user_role/ # 用户-角色关联 +│ │ └── role_menu/ # 角色-菜单关联 +│ ├── auth/ # 登录认证 +│ ├── dictionary/ # 字典管理 +│ ├── configuration/ # 配置管理 +│ ├── notice/ # 通知公告 +│ ├── job/ # 定时任务 +│ ├── job_log/ # 任务执行日志 +│ ├── operation_log/ # 操作日志 +│ ├── login_log/ # 登录日志 +│ ├── upload/ # 文件上传 +│ ├── file_template/ # 文件模板 +│ ├── generator/ # 代码生成器 Web 端 +│ ├── city/ # 城市数据 +│ ├── cms/ # 内容管理(文章等) +│ └── example/ # 示例模块(代码生成器产出的参考) +├── config/ # 配置文件目录 +│ ├── __init__.py # 配置入口(加载 .env + 导出所有配置项) +│ ├── app.py # 应用基础配置 +│ ├── database.py # 数据库配置(多数据库支持) +│ ├── redis.py # Redis 配置 +│ ├── auth.py # JWT / 权限 / 白名单配置 +│ ├── mail.py # 邮件配置 +│ ├── sms.py # 短信配置 +│ └── captcha.py # 验证码配置 +├── migrations/ # Flask-Migrate / Alembic 迁移文件 +├── scripts/ # 运维脚本 +│ ├── setup_db.py # 数据库初始化(建表 + 种子数据) +│ └── migrate_db.py # 跨库数据迁移工具 +├── tests/ # 单元测试 +├── docker/ # Docker 部署配置 +├── ui/ # Vue3 前端项目 +│ ├── src/ +│ │ ├── api/ # API 接口模块 +│ │ ├── views/ # 页面视图 +│ │ ├── components/ # 公共组件(BasicTable / BasicForm 等) +│ │ ├── router/ # 路由配置(动态路由生成) +│ │ ├── store/ # Pinia 状态管理 +│ │ └── utils/ # 工具函数(Axios 封装等) +│ ├── .env.development # 开发环境配置 +│ ├── .env.production # 生产环境配置 +│ └── package.json # 前端依赖配置 +├── generator.py # 代码生成器 CLI 入口 +├── config.json # 代码生成器配置示例 +├── gunicorn.conf.py # Gunicorn 生产部署配置 +├── app.py # WSGI 入口(开发启动) +├── pyproject.toml # Python 项目配置(black / isort / flake8) +├── requirements.txt # Python 依赖清单 +├── Makefile # 开发命令快捷入口 +├── CLAUDE.md # Claude Code 开发指南 +└── .env # 环境变量配置 +``` ## 👷 软件信息 -* 软件名称:DjangoAdmin敏捷开发框架Flask+EleVue版本 -* 软件作者:@一米阳光 团队荣誉出品 -* 软件出处:一米阳光团队 -* 软件协议:Apache-2.0 -* 官网网址:[https://www.djangoadmin.cn](https://www.djangoadmin.cn) -* 文档网址:[http://docs.flask.elevue.djangoadmin.cn](http://docs.flask.elevue.djangoadmin.cn) - -## 🎨 系统演示 - -+ 演示地址:http://manage.flask.elevue.djangoadmin.cn - -账号 | 密码| 操作权限 ----|---|--- -admin | 123456| 演示环境无法进行修改删除操作 +- 软件名称:DjangoAdmin 敏捷开发框架 Flask + ElementPlus 版本 +- 软件作者:@小蚂蚁云 团队荣誉出品 +- 软件出处:小蚂蚁云团队 +- 软件协议:Apache-2.0 +- 官网网址: +- 文档网址: +- 演示地址: ## 📌 版本说明 -版本名称 | 版本说明 | 版本地址 ----|---|--- -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 +| 版本名称 | 版本说明 | 版本地址 | +| --- | --- | --- | +| Django + Layui 混编版 | 采用 Django、Layui 等框架研发 | | +| Flask + Layui 混编版 | 采用 Flask、Layui 等框架研发 | | +| FastAPI + Layui 混编版 | 采用 FastAPI、Layui 等框架研发 | | +| Tornado + Layui 混编版 | 采用 Tornado、Layui 等框架研发 | | +| Django + EleVue 前后端分离版 | 采用 Django、Vue3、TypeScript、ElementPlus 等框架研发前后端分离版本 | | +| Flask + EleVue 前后端分离版 | 采用 Flask、Vue3、TypeScript、ElementPlus 等框架研发前后端分离版本 | | +| FastAPI + EleVue 前后端分离版 | 采用 FastAPI、Vue3、TypeScript、ElementPlus 等框架研发前后端分离版本 | | +| Tornado + EleVue 前后端分离版 | 采用 Tornado、Vue3、TypeScript、ElementPlus 等框架研发前后端分离版本 | | +| Django + AntdVue 前后端分离版 | 采用 Django、Vue3、AntDesign 等框架研发前后端分离版本 | | +| Flask + AntdVue 前后端分离版 | 采用 Flask、Vue3、AntDesign 等框架研发前后端分离版本 | | +| FastAPI + AntdVue 前后端分离版 | 采用 FastAPI、Vue3、AntDesign 等框架研发前后端分离版本 | | +| Tornado + AntdVue 前后端分离版 | 采用 Tornado、Vue3、AntDesign 等框架研发前后端分离版本 | | +| Django + NaiveUI 前后端分离版 | 采用 Django、Vue3、TypeScript、NaiveUI 等框架研发前后端分离版本 | | +| Flask + NaiveUI 前后端分离版 | 采用 Flask、Vue3、TypeScript、NaiveUI 等框架研发前后端分离版本 | | +| FastAPI + NaiveUI 前后端分离版 | 采用 FastAPI、Vue3、TypeScript、NaiveUI 等框架研发前后端分离版本 | | +| Tornado + NaiveUI 前后端分离版 | 采用 Tornado、Vue3、TypeScript、NaiveUI 等框架研发前后端分离版本 | | + ## 🔧 模块展示 -![效果图](./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) +| | | +| --- | --- | +| ![模块展示1](./uploads/demo/1.png) | ![模块展示2](./uploads/demo/2.png) | +| ![模块展示3](./uploads/demo/3.png) | ![模块展示4](./uploads/demo/4.png) | +| ![模块展示5](./uploads/demo/5.png) | ![模块展示6](./uploads/demo/6.png) | +| ![模块展示7](./uploads/demo/7.png) | ![模块展示8](./uploads/demo/8.png) | +| ![模块展示9](./uploads/demo/9.png) | ![模块展示10](./uploads/demo/10.png) | +| ![模块展示11](./uploads/demo/11.png) | ![模块展示12](./uploads/demo/12.png) | +| ![模块展示13](./uploads/demo/13.png) | ![模块展示14](./uploads/demo/14.png) | +| ![模块展示15](./uploads/demo/15.png) | ![模块展示16](./uploads/demo/16.png) | +| ![模块展示17](./uploads/demo/17.png) | ![模块展示18](./uploads/demo/18.png) | +| ![模块展示19](./uploads/demo/19.png) | ![模块展示20](./uploads/demo/20.png) | +| ![模块展示21](./uploads/demo/21.png) | ![模块展示22](./uploads/demo/22.png) | +| ![模块展示23](./uploads/demo/23.png) | ![模块展示24](./uploads/demo/24.png) | +| ![模块展示25](./uploads/demo/25.png) | ![模块展示26](./uploads/demo/26.png) | +| ![模块展示27](./uploads/demo/27.png) | ![模块展示28](./uploads/demo/28.png) | ## ✨ 特别鸣谢 -感谢[Flask](https://flask.palletsprojects.com/en/2.2.x/)、[Vue](https://cn.vuejs.org/)、[ElementUI](https://element.eleme.cn/#/)等优秀开源项目。 - -## 📚 版权信息 - -商业版使用需授权,未授权禁止恶意传播和用于商业用途,任何个人、单位、组织授权所获取的框架源码禁止分享、转售、恶意传播、开源(包括框架源码及二次开发后的产品)、禁止简单更改包装声称是自己的产品,否则将追究相关人的法律责任。 - -本软件框架禁止任何单位和个人用于任何违法、侵害他人合法利益等恶意的行为,禁止用于任何违反我国法律法规的一切平台研发,任何单位和个人使用本软件框架用于产品研发而产生的任何意外、疏忽、合约毁坏、诽谤、版权或知识产权侵犯及其造成的损失 ( -包括但不限于直接、间接、附带或衍生的损失等),本团队不承担任何法律责任。本软件框架只能用于公司和个人内部的法律所允许的合法合规的软件产品研发,详细声明内容请阅读《框架免责声明》附件; - -本项目包含的第三方源码和二进制文件之版权信息另行标注。 - -版权所有Copyright © 2021~2026 [djangoadmin.cn](https://www.djangoadmin.cn) All rights reserved。 - -更多细节参阅 [LICENSE](LICENSE) \ No newline at end of file +感谢 [Flask](https://flask.palletsprojects.com/)、[Vue](https://cn.vuejs.org/)、[TypeScript](https://www.typescriptlang.org/)、[ElementPlus](https://element-plus.org/)、[Vite](https://cn.vitejs.dev/)、[Pinia](https://pinia.vuejs.org/)、[TailwindCSS](https://tailwindcss.com/)、[SQLAlchemy](https://www.sqlalchemy.org/)、[APScheduler](https://apscheduler.readthedocs.io/) 等优秀开源项目。 diff --git a/USAGE.md b/USAGE.md deleted file mode 100644 index f116386..0000000 --- a/USAGE.md +++ /dev/null @@ -1,383 +0,0 @@ -# Flask+EleVue 使用文档 - -## 目录 - -- [环境配置](#环境配置) - - [基础配置](#基础配置) - - [数据库配置](#数据库配置) - - [Redis缓存配置](#redis缓存配置) - - [邮件配置](#邮件配置) - - [短信配置(阿里云)](#短信配置阿里云) - - [JWT令牌配置](#jwt令牌配置) - - [登录白名单配置](#登录白名单配置) - - [权限验证配置](#权限验证配置) -- [启动方式](#启动方式) - - [开发环境](#开发环境) - - [生产环境](#生产环境) - - [Docker部署](#docker部署) - - [Gunicorn参数调优](#gunicorn参数调优) -- [应用流程](#应用流程) - - [启动流程](#启动流程) - - [请求处理流程](#请求处理流程) - - [AOP事务管理](#aop事务管理) -- [邮件服务](#邮件服务) -- [短信服务](#短信服务) - ---- - -## 环境配置 - -所有配置项在项目根目录 `.env` 文件中管理,应用启动时由 `python-dotenv` 自动加载。 - -### 基础配置 - -| 配置项 | 默认值 | 说明 | -|--------|--------|------| -| `FLASK_NAME` | Flask+EleVue旗舰版 | 应用名称 | -| `FLASK_VERSION` | v2.8.0 | 应用版本 | -| `FLASK_SECRET_KEY` | - | 应用秘钥,用于Session加密和CSRF保护,必须≥16字符 | -| `FLASK_HOST` | 127.0.0.1 | 运行地址。`0.0.0.0` 允许外部访问 | -| `FLASK_PORT` | 8021 | 运行端口 | -| `FLASK_ENV` | development | 环境标识:`development` / `production` | -| `FLASK_DEBUG` | True | 调试模式。**生产环境必须设为 False** | -| `FLASK_USE_RELOADER` | False | 自动重启(仅 DEBUG=True 时生效) | -| `PROXY_NUM` | 0 | 可信代理层数。Nginx后设为1 | -| `CORS_ALLOWED_ORIGINS` | - | CORS允许的源,逗号分隔。配置后支持携带凭证 | -| `FLASK_DEMO` | False | 演示模式,True时限制写操作 | -| `CAPTCHA_BYPASS_CODE` | 618618 | 万能验证码(仅DEMO模式生效,生产环境必须为空) | -| `FLASK_UPLOAD_DIR` | - | 文件上传存储目录 | -| `FLASK_FILE_URL` | - | 图片域名,用于生成完整URL | -| `UPLOAD_ALLOWED_EXTS` | .jpg,.jpeg,.png,... | 允许上传的文件扩展名 | -| `UPLOAD_MAX_SIZE_MB` | 10 | 上传文件大小上限(MB) | - -### 数据库配置 - -| 配置项 | 默认值 | 说明 | -|--------|--------|------| -| `DB_DRIVER` | mysql | 数据库驱动:`mysql` / `pgsql` / `sqlite` | -| `DB_HOST` | 127.0.0.1 | 数据库地址 | -| `DB_PORT` | 3306 | 数据库端口 | -| `DB_DATABASE` | - | 数据库名称 | -| `DB_USERNAME` | root | 数据库账号 | -| `DB_PASSWORD` | - | 数据库密码 | -| `DB_PREFIX` | flask_ | 表前缀 | -| `DB_DEBUG` | True | 是否打印SQL日志 | -| `DB_SSL` | False | 是否启用SSL加密连接 | - -连接池高级参数(取消注释即可生效): - -| 配置项 | 默认值 | 说明 | -|--------|--------|------| -| `DB_POOL_SIZE` | 10 | 常驻连接数 | -| `DB_MAX_OVERFLOW` | 10 | 最大额外连接数 | -| `DB_POOL_RECYCLE` | 1800 | 连接回收时间(秒),应小于MySQL的wait_timeout | -| `DB_POOL_TIMEOUT` | 30 | 获取连接超时(秒) | -| `DB_POOL_PRE_PING` | True | 使用前检测连接存活 | - -### Redis缓存配置 - -| 配置项 | 默认值 | 说明 | -|--------|--------|------| -| `REDIS_HOST` | 127.0.0.1 | Redis地址 | -| `REDIS_PORT` | 6379 | Redis端口 | -| `REDIS_PASSWORD` | - | Redis密码 | -| `REDIS_INDEX` | 0 | 库索引(0-15) | -| `REDIS_AUTH` | True | 是否需要认证 | -| `REDIS_DECODE_RESPONSES` | True | 是否自动解码 | - -### 邮件配置 - -| 配置项 | 默认值 | 说明 | -|--------|--------|------| -| `MAIL_MAILER` | smtp | 邮寄方式:`smtp` / `sendmail` | -| `MAIL_SERVER` | smtp.163.com | SMTP服务器地址 | -| `MAIL_PORT` | 465 | SMTP端口(465=SSL,587=TLS) | -| `MAIL_USE_SSL` | True | 是否启用SSL | -| `MAIL_USERNAME` | - | 授权邮箱用户名 | -| `MAIL_PASSWORD` | - | 授权码(非登录密码) | -| `MAIL_ENCRYPTION` | - | 加密方式:`ssl` / `tls` | -| `MAIL_FROM_NAME` | - | 发件人名称 | -| `MAIL_FROM_ADDRESS` | - | 发件人地址 | - -### 短信配置(阿里云) - -| 配置项 | 默认值 | 说明 | -|--------|--------|------| -| `SMS_PROVIDER` | aliyun | 短信服务商(当前仅支持aliyun) | -| `SMS_ALIYUN_ACCESS_KEY_ID` | - | 阿里云 AccessKey ID | -| `SMS_ALIYUN_ACCESS_KEY_SECRET` | - | 阿里云 AccessKey Secret | -| `SMS_ALIYUN_SIGN_NAME` | - | 短信签名(需在阿里云审核通过) | -| `SMS_ALIYUN_ENDPOINT` | dysmsapi.aliyuncs.com | 接入点 | -| `SMS_ALIYUN_CONNECT_TIMEOUT` | 10000 | 连接超时(毫秒) | -| `SMS_ALIYUN_READ_TIMEOUT` | 10000 | 读取超时(毫秒) | - -### JWT令牌配置 - -| 配置项 | 默认值 | 说明 | -|--------|--------|------| -| `JWT_SALT` | - | JWT签名密钥,至少32字节,务必使用强随机密钥 | -| `JWT_EXPIRE_MINUTES` | 20 | 令牌过期时间(分钟) | - -生成密钥: - -```bash -python -c "import secrets; print(secrets.token_urlsafe(48))" -``` - -### 登录白名单配置 - -白名单路径免登录验证,支持三种匹配模式: - -| 配置项 | 说明 | -|--------|------| -| `LOGIN_WHITELIST_EXACT` | 精确匹配,逗号分隔。如 `/login,/captcha` | -| `LOGIN_WHITELIST_PREFIX` | 前缀匹配,逗号分隔。如 `/public,/static` | -| `LOGIN_WHITELIST_WILDCARD` | 通配符匹配,`*` 匹配任意字符,`?` 匹配单字符 | -| `LOGIN_WHITELIST_DEBUG` | 是否打印匹配调试日志 | -| `LOGIN_WHITELIST_LOG_ACCESS` | 白名单请求是否记录访问日志 | - -### 权限验证配置 - -| 配置项 | 默认值 | 说明 | -|--------|--------|------| -| `PERMISSION_ENABLED` | True | 是否启用权限验证。False时所有请求放行 | -| `INTERNAL_API_KEY` | - | 内部调用API密钥,请求头携带 `X-Internal-API-Key` | -| `INTERNAL_ALLOWED_IPS` | 127.0.0.1 | 内部调用白名单IP,逗号分隔 | - ---- - -## 启动方式 - -### 开发环境 - -```bash -# 安装依赖 -pip install -r requirements.txt - -# 启动后端(默认 127.0.0.1:8021) -python app.py -``` - -`python app.py` 执行 `__main__` 块,使用 `app.run()` 启动 Werkzeug 开发服务器。 - -开发环境支持 debug 模式和自动重载,由 `.env` 中 `FLASK_DEBUG` 和 `FLASK_USE_RELOADER` 控制。 - -前端开发服务器: - -```bash -cd ui -pnpm install -pnpm run dev # 默认 localhost:8001,自动代理 /api 到后端 :8021 -``` - -### 生产环境 - -```bash -gunicorn -c gunicorn.conf.py app:app -``` - -Gunicorn 通过 `app:app` 导入 Flask 应用对象,**不执行 `__main__` 块**。`gunicorn.conf.py` 中配置了 gevent worker,支持高并发。 - -关键区别: - -| | 开发环境 | 生产环境 | -|---|---------|---------| -| 启动命令 | `python app.py` | `gunicorn -c gunicorn.conf.py app:app` | -| WSGI服务器 | Werkzeug | Gunicorn + gevent | -| Debug | 可开启 | 必须关闭 | -| 自动重载 | 可开启 | 不支持 | -| 并发模型 | 单进程 | 多worker + 协程 | - -### Docker部署 - -```bash -cd docker - -# 构建并启动 -docker-compose up -d --build - -# 查看日志 -docker-compose logs -f flask_elevue - -# 重新构建(代码更新后) -docker-compose up -d --build - -# 停止 -docker-compose down -``` - -Docker 容器内使用 Gunicorn 启动,Dockerfile 和 docker-compose.yml 已配置好 gevent worker。 - -### Gunicorn参数调优 - -所有参数通过环境变量覆盖,无需修改配置文件: - -| 环境变量 | 默认值 | 说明 | -|----------|--------|------| -| `GUNICORN_BIND` | 0.0.0.0:8021 | 监听地址 | -| `GUNICORN_WORKERS` | 2*CPU+1 | worker数量 | -| `GUNICORN_WORKER_CONNECTIONS` | 1000 | 每worker协程数 | -| `GUNICORN_MAX_REQUESTS` | 1000 | 处理请求数后重启(防内存泄漏) | -| `GUNICORN_MAX_REQUESTS_JITTER` | 50 | 重启抖动,避免同时重启 | -| `GUNICORN_TIMEOUT` | 120 | worker超时(秒) | -| `GUNICORN_GRACEFUL_TIMEOUT` | 30 | 优雅关闭超时(秒) | -| `GUNICORN_LOG_LEVEL` | info | 日志级别 | -| `GUNICORN_ACCESS_LOG` | - | 访问日志路径(- 为stdout) | -| `GUNICORN_ERROR_LOG` | - | 错误日志路径(- 为stderr) | - -Docker 中通过 docker-compose.yml 的 `environment` 注入: - -```yaml -environment: - GUNICORN_WORKERS: 4 - GUNICORN_WORKER_CONNECTIONS: 2000 -``` - ---- - -## 应用流程 - -### 启动流程 - -``` -app.py - └─ create_app() # 应用工厂 - ├─ Flask 核心对象创建 - ├─ Redis 配置 - ├─ 数据库配置加载 - ├─ JSON 序列化器设置 - ├─ CORS 跨域配置 - ├─ ProxyFix 可信代理配置 - ├─ register_operation_log() # 操作日志中间件(先于事务注册) - ├─ register_transaction() # AOP 事务拦截器 - ├─ register_blueprint() # 蓝图自动发现与注册 - ├─ register_extends() # 扩展初始化(DB/Redis/Mail/...) - ├─ register_middleware() # 登录验证中间件 - └─ register_error_handlers() # 全局异常处理 -``` - -### 请求处理流程 - -``` -HTTP 请求 - └─ before_request - ├─ 操作日志记录(记录请求信息) - ├─ AOP事务标记(标记事务开始) - ├─ 登录验证(check_login,白名单路径跳过) - └─ 权限验证(check_permission 装饰器,超管/内部调用跳过) - └─ View 路由处理 - └─ Service 业务逻辑 - └─ Model 数据操作(flush,不commit) - └─ after_request - ├─ AOP事务提交/回滚(POST/PUT/DELETE/PATCH,2xx且code=0时提交) - └─ 操作日志写入 - └─ 响应返回 -``` - -### AOP事务管理 - -框架通过 `application/middleware/transaction.py` 实现声明式事务管理,**业务代码无需手动 commit/rollback**。 - -- `before_request`:标记事务活跃 -- `after_request`: - - 写操作(POST/PUT/DELETE/PATCH)且 HTTP 2xx 且响应 `code==0` → 自动提交 - - 业务失败(`R.failed` 返回 HTTP 200 但 `code=1`)→ 自动回滚 -- `teardown_request`:未处理异常时回滚 - -`BaseDB.save()` 和 `BaseDB.save_all()` 仅调用 `db.session.flush()`,由 AOP 拦截器统一提交。 - -特殊场景可使用: -- `@transactional` 装饰器 -- `@transactional_response` 装饰器 -- `TransactionContext` 上下文管理器 - ---- - -## 邮件服务 - -邮件服务封装在 `application/services/mail_svc.py`,基于 Flask-Mail,供业务模块和定时任务直接调用。 - -**调用方式:** - -```python -from application.services import mail_svc - -# 简单文本邮件 -mail_svc.send_mail(to='user@example.com', subject='标题', body='内容') - -# HTML 邮件 -mail_svc.send_mail(to='user@example.com', subject='标题', html='

Hello

') - -# 多收件人 + 抄送 -mail_svc.send_mail( - to=['a@example.com', 'b@example.com'], - subject='标题', - body='内容', - cc=['cc@example.com'], -) -``` - -**参数说明:** - -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `to` | str / List[str] | 是 | 收件人 | -| `subject` | str | 是 | 邮件主题 | -| `body` | str | 否* | 纯文本正文 | -| `html` | str | 否* | HTML正文(与body至少提供一个) | -| `cc` | str / List[str] | 否 | 抄送 | -| `bcc` | str / List[str] | 否 | 密送 | -| `reply_to` | str | 否 | 回复地址 | - -返回 `True` 表示发送成功,`False` 表示失败(异常已捕获并记录日志)。 - -**前置条件:** `.env` 中配置 `MAIL_SERVER`、`MAIL_USERNAME`、`MAIL_PASSWORD`。 - ---- - -## 短信服务 - -短信服务封装在 `application/services/sms_svc.py`,基于阿里云短信 SDK,供业务模块和定时任务直接调用。 - -**调用方式:** - -```python -from application.services import sms_svc - -# 模板短信 -sms_svc.send_sms( - phone='13800138000', - template_code='SMS_123456', - template_params={'code': '123456'} -) - -# 指定签名(覆盖默认签名) -sms_svc.send_sms( - phone='13800138000', - template_code='SMS_123456', - template_params={'code': '123456'}, - sign_name='我的应用' -) - -# 群发(多号码) -sms_svc.send_sms( - phone=['13800138000', '13900139000'], - template_code='SMS_123456', - template_params={'name': '张三'} -) -``` - -**参数说明:** - -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `phone` | str / List[str] | 是 | 手机号,支持单个或多个 | -| `template_code` | str | 是 | 短信模板CODE(阿里云控制台获取) | -| `template_params` | dict | 否 | 模板变量,如 `{'code': '123456'}` 对应 `${code}` | -| `sign_name` | str | 否 | 短信签名,默认使用配置中的 `SMS_ALIYUN_SIGN_NAME` | - -返回 `True` 表示全部发送成功,`False` 表示至少一条失败。 - -**前置条件:** -1. `.env` 中配置 `SMS_ALIYUN_ACCESS_KEY_ID`、`SMS_ALIYUN_ACCESS_KEY_SECRET`、`SMS_ALIYUN_SIGN_NAME` -2. 安装依赖:`pip install alibabacloud-dysmsapi20170525 alibabacloud-tea-openapi`(已包含在 requirements.txt) diff --git a/config.json b/config.json new file mode 100644 index 0000000..9098c26 --- /dev/null +++ b/config.json @@ -0,0 +1,166 @@ +{ + "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": [ + "django_" + ], + "remove_prefix": true, + "menu_group": "tool", + "parent_menu_id": 322, + "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", + "comment": "案例名称", + "purpose": "存储案例名称信息", + "type": "CharField", + "type_display": "字符串", + "max_length": 100, + "nullable": false, + "required": true, + "is_unique": false, + "db_index": true, + "default": null, + "in_form": true, + "in_list": true, + "filterable": true, + "filter_type": "模糊查询", + "editable": true, + "searchable": true, + "is_image": false, + "is_rich_text": false + }, + { + "name": "avatar", + "comment": "案例图片", + "purpose": "存储案例图片信息", + "type": "CharField", + "type_display": "字符串", + "max_length": 255, + "nullable": true, + "required": false, + "is_unique": false, + "db_index": false, + "default": null, + "in_form": true, + "in_list": true, + "filterable": false, + "filter_type": "", + "editable": true, + "searchable": false, + "is_image": true, + "is_rich_text": false + }, + { + "name": "type", + "comment": "案例类型:1-类型1 2-类型2 3-类型3 4-类型4", + "purpose": "存储案例类型:1-类型1 2-类型2 3-类型3 4-类型4信息", + "type": "IntegerField", + "type_display": "整数", + "max_length": null, + "nullable": false, + "required": true, + "is_unique": false, + "db_index": false, + "default": "1", + "in_form": true, + "in_list": true, + "filterable": true, + "filter_type": "精确匹配", + "editable": true, + "searchable": false, + "is_image": false, + "is_rich_text": false, + "choices": [ + [ + 1, + "类型1" + ], + [ + 2, + "类型2" + ], + [ + 3, + "类型3" + ], + [ + 4, + "类型4" + ] + ], + "min_value": 1, + "max_value": 4 + }, + { + "name": "status", + "comment": "案例状态:1-正常 2-禁用", + "purpose": "存储案例状态:1-正常 2-禁用信息", + "type": "IntegerField", + "type_display": "整数", + "max_length": null, + "nullable": false, + "required": true, + "is_unique": false, + "db_index": false, + "default": "1", + "in_form": true, + "in_list": true, + "filterable": true, + "filter_type": "精确匹配", + "editable": true, + "searchable": false, + "is_image": false, + "is_rich_text": false, + "choices": [ + [ + 1, + "正常" + ], + [ + 2, + "禁用" + ] + ], + "min_value": 1, + "max_value": 2 + }, + { + "name": "sort", + "comment": "排序", + "purpose": "存储排序信息", + "type": "IntegerField", + "type_display": "整数", + "max_length": null, + "nullable": false, + "required": true, + "is_unique": false, + "db_index": false, + "default": "0", + "in_form": true, + "in_list": true, + "filterable": false, + "filter_type": "", + "editable": true, + "searchable": false, + "is_image": false, + "is_rich_text": false + } + ] +} \ No newline at end of file diff --git a/config/__init__.py b/config/__init__.py index 03b88c5..4f679a4 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -46,7 +46,7 @@ from config.app import ( # noqa: E402 from config.database import ( # noqa: E402 DatabaseConfig, DB_PREFIX, DB_DRIVER, DB_HOST, DB_PORT, DB_DATABASE, - DB_USERNAME, DB_PASSWORD, DB_DEBUG, DB_SSL, DATABASE_URL, + DB_USERNAME, DB_PASSWORD, DB_DEBUG, DB_SSL, DATABASE_URL, friendly_db_error, ) # Redis 缓存配置 @@ -59,8 +59,8 @@ from config.redis import ( # noqa: E402 # JWT + 权限 + 白名单配置 from config.auth import ( # noqa: E402 - JWT_SALT, JWT_EXPIRE_MINUTES, LOGIN_FAIL_MAX, LOGIN_LOCK_SECONDS, - PERMISSION_ENABLED, INTERNAL_API_KEY, + JWT_SALT, JWT_EXPIRE_MINUTES, JWT_ABSOLUTE_TIMEOUT_MINUTES, + LOGIN_FAIL_MAX, LOGIN_LOCK_SECONDS, PERMISSION_ENABLED, INTERNAL_API_KEY, INTERNAL_ALLOWED_IPS, LOGIN_WHITELIST_EXACT, LOGIN_WHITELIST_PREFIX, LOGIN_WHITELIST_WILDCARD, LOGIN_WHITELIST_DEBUG, LOGIN_WHITELIST_LOG_ACCESS, CAPTCHA_BYPASS_CODE, @@ -108,15 +108,15 @@ __all__ = [ # 数据库配置 'DatabaseConfig', 'DB_PREFIX', 'DB_DRIVER', 'DB_HOST', 'DB_PORT', 'DB_DATABASE', - 'DB_USERNAME', 'DB_PASSWORD', 'DB_DEBUG', 'DB_SSL', 'DATABASE_URL', + 'DB_USERNAME', 'DB_PASSWORD', 'DB_DEBUG', 'DB_SSL', 'DATABASE_URL', 'friendly_db_error', # Redis 配置 'REDIS_HOST', 'REDIS_PORT', 'REDIS_PASSWORD', 'REDIS_INDEX', 'REDIS_AUTH', 'REDIS_DECODE_RESPONSES', 'REDIS_URL', # JWT + 权限 + 白名单配置 - 'JWT_SALT', 'JWT_EXPIRE_MINUTES', 'LOGIN_FAIL_MAX', 'LOGIN_LOCK_SECONDS', - 'PERMISSION_ENABLED', 'INTERNAL_API_KEY', + 'JWT_SALT', 'JWT_EXPIRE_MINUTES', 'JWT_ABSOLUTE_TIMEOUT_MINUTES', + 'LOGIN_FAIL_MAX', 'LOGIN_LOCK_SECONDS', 'PERMISSION_ENABLED', 'INTERNAL_API_KEY', 'INTERNAL_ALLOWED_IPS', 'LOGIN_WHITELIST_EXACT', 'LOGIN_WHITELIST_PREFIX', 'LOGIN_WHITELIST_WILDCARD', 'LOGIN_WHITELIST_DEBUG', 'LOGIN_WHITELIST_LOG_ACCESS', 'CAPTCHA_BYPASS_CODE', diff --git a/config/app.py b/config/app.py index 6031cee..a96cd2f 100644 --- a/config/app.py +++ b/config/app.py @@ -21,7 +21,7 @@ FLASK_NAME = os.getenv('FLASK_NAME', 'DEMO') # 应用版本号 - 用于版本追踪、API版本控制和更新检测 # 格式遵循语义化版本规范 v主版本.次版本.补丁版本 -FLASK_VERSION = os.getenv('FLASK_VERSION', 'v2.8.0') +FLASK_VERSION = os.getenv('FLASK_VERSION', 'v3.0.0') # 应用秘钥 - 用于Session加密、CSRF保护、签名生成 # 生产环境务必使用强随机字符串,长度至少32字符 diff --git a/config/auth.py b/config/auth.py index c15d8c7..d404e23 100644 --- a/config/auth.py +++ b/config/auth.py @@ -20,9 +20,22 @@ logger = logging.getLogger(__name__) # 从环境变量读取,未配置则拒绝启动,防止使用已知弱密钥 JWT_SALT = os.getenv('JWT_SALT', '') -# JWT令牌过期时间(分钟) +# 滑动会话空闲超时(分钟):用户在此时间内无任何请求,会话过期需重新登录。 +# 每收到一次有效请求,Redis 会话 TTL 被重置为该值(sliding session)。 JWT_EXPIRE_MINUTES = int(os.getenv('JWT_EXPIRE_MINUTES', 20)) +# JWT 令牌绝对有效期(分钟):即使一直活跃,令牌最长有效时间,到期强制重新登录。 +# 防止会话无限续期,需大于 JWT_EXPIRE_MINUTES(默认 1440 = 24小时)。 +JWT_ABSOLUTE_TIMEOUT_MINUTES = int(os.getenv('JWT_ABSOLUTE_TIMEOUT_MINUTES', 1440)) + +# 启动校验:绝对有效期须 ≥ 空闲超时,否则滑动会话模型失效(一直活跃也会被绝对期限打断) +if JWT_ABSOLUTE_TIMEOUT_MINUTES < JWT_EXPIRE_MINUTES: + raise RuntimeError( + f"JWT_ABSOLUTE_TIMEOUT_MINUTES({JWT_ABSOLUTE_TIMEOUT_MINUTES}) " + f"不能小于 JWT_EXPIRE_MINUTES({JWT_EXPIRE_MINUTES})," + "绝对有效期须大于等于滑动会话空闲超时" + ) + # 登录失败次数上限(同一IP+用户名连续失败超过此次数后锁定) LOGIN_FAIL_MAX = int(os.getenv('LOGIN_FAIL_MAX', 5)) @@ -40,12 +53,19 @@ PERMISSION_ENABLED = os.getenv('PERMISSION_ENABLED', 'True') == 'True' # 调用方需在请求头中携带 X-Internal-API-Key 或 X-Scheduler-Key INTERNAL_API_KEY = os.getenv('INTERNAL_API_KEY', '') -# 启动时校验:未配置则拒绝启动,防止定时任务权限验证失效 +# 启动时校验:该密钥是权限全绕过的凭据,必须为强随机值,不符合则 fail-closed 拒绝启动。 +# 规则化校验(长度 ≥32)而非黑名单枚举,任何弱值/新弱值都会被拦截。 if not INTERNAL_API_KEY: raise RuntimeError( "INTERNAL_API_KEY 未配置!请在 .env 中设置内部调用密钥。\n" "生成方式:python -c \"import secrets; print(secrets.token_urlsafe(32))\"" ) +if len(INTERNAL_API_KEY) < 32: + raise RuntimeError( + f"INTERNAL_API_KEY 长度不足 32 位(当前 {len(INTERNAL_API_KEY)} 位)," + "拒绝启动(fail-closed)。请更换为强随机密钥:\n" + "python -c \"import secrets; print(secrets.token_urlsafe(32))\"" + ) # 内部调用白名单IP(逗号分隔,支持通配符如 127.0.0.*) # 在此列表中的IP发起的请求将跳过权限验证 diff --git a/config/database.py b/config/database.py index 816819c..c4c3339 100644 --- a/config/database.py +++ b/config/database.py @@ -1,21 +1,22 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== """ 数据库配置模块 ============================================================= -负责配置 MySQL 数据库连接及相关参数,包含连接池、SSL 加密等。 +负责配置数据库连接及相关参数,包含连接池、SSL 加密等,支持多数据库驱动: +mysql(默认)/ postgresql(pgsql)/ mssql / sqlite / oracle。 依赖:config/__init__.py 中的 _load_env_file() 已提前加载 .env """ @@ -27,12 +28,46 @@ from config.app import FLASK_SECRET_KEY, FLASK_UPLOAD_DIR # 数据库环境变量 # ============================================================ -# 数据库驱动(mysql / pgsql / sqlite) -DB_DRIVER = os.getenv('DB_DRIVER', 'mysql') + +def _normalize_driver(driver: str) -> str: + """驱动别名归一化:别名 → 规范名;已是规范名的驱动原样返回。 + + - 规范名:mysql / postgresql / mssql / sqlite / oracle + - 别名:postgres、pgsql → postgresql;sqlserver → mssql;sqlite3 → sqlite + - oracle 本身即规范名,无需映射(经 fallback 直接透传),表中显式列出便于自文档化 + 与 scripts/migrate_db.py 共用(make_url 也调用),保证 .env / CLI 两端别名一致。 + """ + return { + 'postgres': 'postgresql', + 'pgsql': 'postgresql', + 'sqlserver': 'mssql', + 'sqlite3': 'sqlite', + 'oracle': 'oracle', # 规范名之一,显式列出便于自文档化 + }.get(str(driver).lower(), str(driver).lower()) + + +def _sqlite_url(database: str) -> str: + """SQLite 连接 URL:DB_DATABASE 即文件路径(内存库用 ':memory:',Windows 反斜杠转正斜杠)。""" + if database == ':memory:': + return 'sqlite://' + return f"sqlite:///{database.replace(chr(92), '/')}" + + +# 数据库驱动(mysql / postgresql / mssql / sqlite / oracle,支持别名见 _normalize_driver) +DB_DRIVER = _normalize_driver(os.getenv('DB_DRIVER', 'mysql')) # 数据库地址 DB_HOST = os.getenv('DB_HOST', '127.0.0.1') -# 数据库端口(MySQL默认3306,PostgreSQL默认5432) -DB_PORT = int(os.getenv('DB_PORT', 3306)) +# 各驱动默认端口(未显式配置 DB_PORT 时按驱动回退) +_DEFAULT_PORT = { + 'mysql': 3306, + 'pgsql': 5432, + 'postgresql': 5432, + 'mssql': 1433, + 'oracle': 1521, + 'sqlite': 0, # sqlite 无端口 +} +# 数据库端口(MySQL默认3306,PostgreSQL默认5432,MSSQL默认1433,Oracle默认1521) +DB_PORT = int(os.getenv('DB_PORT', _DEFAULT_PORT.get(DB_DRIVER, 3306))) # 数据库名称 DB_DATABASE = os.getenv('DB_DATABASE', 'djangoadmin.flask.elevue') # 数据库账号 @@ -67,8 +102,87 @@ DB_POOL_TIMEOUT = int(os.getenv('DB_POOL_TIMEOUT', 30)) # 使用前是否检测连接存活(默认True,防止使用已被MySQL关闭的连接) DB_POOL_PRE_PING = os.getenv('DB_POOL_PRE_PING', 'True') == 'True' +# 方言 → SQLAlchemy URL 前缀映射(多库支持:只改 DB_DRIVER + 账号即可切换) +_DB_URI_PREFIX = { + 'mysql': 'mysql+pymysql', + 'pgsql': 'postgresql+psycopg2', + 'postgresql': 'postgresql+psycopg2', + 'mssql': 'mssql+pymssql', + 'sqlite': 'sqlite', + 'oracle': 'oracle+oracledb', +} + + +def _build_database_url() -> str: + """ + 按 DB_DRIVER 构建数据库连接 URL。 + + 支持:mysql(默认)/ postgresql / mssql / sqlite / oracle。 + 各库差异(端口、账号、ssl、charset、sqlite 无 host、oracle 用 SERVICE_NAME)在此统一封装。 + """ + prefix = _DB_URI_PREFIX.get(DB_DRIVER, 'mysql+pymysql') + + # SQLite:无 host/port/账号,DB_DATABASE 即文件路径(内存库用 ':memory:') + if DB_DRIVER == 'sqlite': + return _sqlite_url(DB_DATABASE) + + cred = f"{quote_plus(DB_USERNAME)}:{quote_plus(DB_PASSWORD)}@{DB_HOST}:{DB_PORT}" + + # Oracle:无"数据库名",DB_DATABASE 即 SERVICE_NAME(如 XEPDB1),走 URL 查询参数 + if DB_DRIVER == 'oracle': + return f"{prefix}://{cred}/?service_name={quote_plus(DB_DATABASE)}" + + url = f"{prefix}://{cred}/{DB_DATABASE}" + + # MySQL(含未知驱动回退到 mysql):显式 utf8mb4 保证中文正确 + if prefix == 'mysql+pymysql': + url += "?charset=utf8mb4" + # PostgreSQL:SSL 走 URL 参数(MySQL/MSSQL 的 SSL 在 _build_engine_options / pymssql 内配置) + elif DB_DRIVER in ('pgsql', 'postgresql') and DB_SSL: + url += "?sslmode=require" + + return url + + # 数据库连接URL(方便SQLAlchemy直接使用) -DATABASE_URL = f'mysql+pymysql://{quote_plus(DB_USERNAME)}:{quote_plus(DB_PASSWORD)}@{DB_HOST}:{DB_PORT}/{DB_DATABASE}' +DATABASE_URL = _build_database_url() + + +def friendly_db_error(e: Exception) -> str: + """ + 把数据库连接异常翻译成可读的中文提示。 + + 处理 psycopg2 在中文 Windows 下返回 GBK 错误消息、被按 UTF-8 解码抛出 + UnicodeDecodeError 的怪癖:先还原字节并按 GBK 解码,再匹配常见错误。 + """ + raw = str(e) + if isinstance(e, UnicodeDecodeError): + # UnicodeDecodeError.args = (encoding, bytes, start, end, reason);args[1] 是原始字节 + try: + raw = e.args[1].decode('gbk', errors='ignore') + except Exception: + raw = str(e) + low = raw.lower() + if any(k in low for k in ('password authentication failed', '28p01', '认证失败', '密码错误', 'ora-01017')): + return '数据库认证失败:用户名或密码错误,请检查 .env 的 DB_USERNAME / DB_PASSWORD' + # 数据库不存在(PG SQLSTATE 3d000 / "database ... does not exist" / "数据库...不存在") + if '3d000' in low or ('database' in low and 'does not exist' in low) or ('数据库' in raw and '不存在' in raw): + return '数据库不存在:请先在目标库创建对应数据库(如 CREATE DATABASE <库名>)' + # 列/表类型不兼容(PG "type ... does not exist" / "类型...不存在"),区别于"数据库不存在" + if ('type' in low and 'does not exist' in low) or ('类型' in raw and '不存在' in raw): + return '目标表结构或列类型与数据库不兼容:请检查模型列类型(如 DATETIME 需用通用 DateTime)' + # Oracle 服务名/监听解析(ORA-12514 未知服务、ORA-12505 未知 SID、ORA-12154 无法解析连接标识符) + if any(k in low for k in ('ora-12514', 'ora-12505', 'ora-12154', 'could not resolve the connect identifier')): + return 'Oracle 服务名/监听解析失败:请检查 .env 的 DB_DATABASE(Oracle 填 SERVICE_NAME,如 XEPDB1)与监听配置' + if any(k in low for k in ('connection refused', '拒绝连接', 'cannot connect', 'target actively refused', + 'ora-12541', 'ora-12560', 'ora-12518', 'ora-12170', 'ora-01034', + 'oracle not available', 'no listener')): + return '数据库连接被拒绝:请检查 DB_HOST / DB_PORT 与数据库服务是否启动' + if any(k in low for k in ('timeout', '超时', 'timed out')): + return '数据库连接超时:请检查网络与 DB_HOST / DB_PORT' + if any(k in low for k in ('no module named', 'is not installed', '驱动未安装')): + return '数据库驱动未安装:请执行 pip install -r requirements.txt 安装对应驱动' + return f'数据库连接失败:{raw}' def _build_engine_options(): @@ -86,7 +200,9 @@ def _build_engine_options(): 'pool_timeout': DB_POOL_TIMEOUT, 'pool_pre_ping': DB_POOL_PRE_PING, } - if DB_SSL: + # SSL:MySQL 走 connect_args;PG 走 URL sslmode(_build_database_url);MSSQL 由 pymssql 处理; + # Oracle:oracledb thin 模式默认走系统 CA,TLS 用 URL tcp+tls 或在 connect_args 传 ssl_context,本处不做额外处理 + if DB_SSL and DB_DRIVER == 'mysql': ssl_config = {} if DB_SSL_CA: ssl_config['ca'] = DB_SSL_CA @@ -134,14 +250,10 @@ class DatabaseConfig: WTF_CSRF_ENABLED = False # ============================================================ - # MySQL 数据库配置 + # 数据库连接配置(按 DB_DRIVER 驱动切换,见 _build_database_url) # ============================================================ - # 构建 MySQL 连接 URI(使用 PyMySQL 驱动) - SQLALCHEMY_DATABASE_URI = ( - f'mysql+pymysql://{quote_plus(DB_USERNAME)}:{quote_plus(DB_PASSWORD)}@' - f'{DB_HOST}:{DB_PORT}/{DB_DATABASE}' - ) + SQLALCHEMY_DATABASE_URI = DATABASE_URL # 不追踪对象修改(提升性能) SQLALCHEMY_TRACK_MODIFICATIONS = False # 是否打印 SQL 语句(调试模式下开启) diff --git a/generator.py b/generator.py new file mode 100644 index 0000000..daabd1c --- /dev/null +++ b/generator.py @@ -0,0 +1,209 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 小蚂蚁云团队 荣誉出品 +# +====================================================================== + +""" +数据库表结构解析与代码生成命令行工具 +============================================================= + +本项目代码生成器的命令行入口,内部复用 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 flask_example + + # 方式2:仅生成配置,不执行代码生成(预览模式) + python generator.py flask_example --dry-run + + # 方式2.1:预览并导出配置为 JSON 文件 + python generator.py flask_example --dry-run --output config.json + + # 方式3:使用已有配置文件生成(跳过表解析,兼容 config.json 参考配置) + python generator.py --config config.json + + # 方式4:生成代码并保存配置(解析 -> 保存 -> 生成) + python generator.py flask_example --output config.json +""" + +import argparse +import json +import os +import sys + +# 将项目根目录加入 sys.path,保证从任意目录运行都能导入 src +ROOT = os.path.dirname(os.path.abspath(__file__)) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + + +def _get_flask_app(): + """ + 初始化 Flask 应用实例 + + 代码生成引擎通过 Flask-SQLAlchemy 的 db 访问数据库,因此 CLI 需要 + 先创建应用并进入应用上下文。复用 src/app.py 的 create_app(), + 保证与 Web 服务使用同一套配置(.env、Redis、MySQL)。 + + Returns: + Flask: 应用实例 + """ + from src.app import create_app + return create_app() + + +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 src.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 src.modules.generator.code_generator import CodeGenerator + + generator = CodeGenerator(config) + generator.generate(auto=True) + return True + + +def main(): + """ + CLI 主入口 + + 执行流程: + 1. 初始化 Flask 应用(提供数据库连接) + 2. 解析生成配置(表解析 / 配置文件加载) + 3. 按需导出配置(--output) + 4. 非预览模式下执行代码生成 + """ + global args_parser + args_parser = argparse.ArgumentParser( + description='数据库表结构解析和代码生成工具', + ) + args_parser.add_argument('table_name', nargs='?', help='数据库表名(如 flask_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("数据库表结构解析和代码生成工具") + print("=" * 60) + + # 初始化 Flask 应用:提供数据库连接与应用上下文 + app = _get_flask_app() + with app.app_context(): + 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: # noqa: BLE001 - CLI 顶层兜底,输出可读错误 + print(f"\n操作失败: {str(e)}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/migrations/versions/72d1886c9903_.py b/migrations/versions/72d1886c9903_.py deleted file mode 100644 index fcd33f9..0000000 --- a/migrations/versions/72d1886c9903_.py +++ /dev/null @@ -1,400 +0,0 @@ -"""empty message - -Revision ID: 72d1886c9903 -Revises: -Create Date: 2023-04-12 13:45:01.665598 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '72d1886c9903' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('flask_ad', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('title', sa.String(length=255), nullable=False, comment='广告标题'), - sa.Column('sort_id', sa.Integer(), nullable=True, comment='广告ID'), - sa.Column('type', sa.Integer(), nullable=True, comment='广告类型:1-图片 2-文字 3-视频 4-推荐'), - sa.Column('cover', sa.String(length=255), nullable=False, comment='广告封面'), - sa.Column('url', sa.String(length=255), nullable=False, comment='广告地址'), - sa.Column('width', sa.Integer(), nullable=True, comment='广告宽度'), - sa.Column('height', sa.Integer(), nullable=True, comment='广告高度'), - sa.Column('start_time', sa.DateTime(), nullable=True, comment='开始时间'), - sa.Column('end_time', sa.DateTime(), nullable=True, comment='结束时间'), - sa.Column('click', sa.Integer(), nullable=True, comment='点击率'), - sa.Column('status', sa.Integer(), nullable=True, comment='广告状态:1-正常 2-停用'), - sa.Column('note', sa.String(length=255), nullable=True, comment='广告备注'), - sa.Column('sort', sa.Integer(), nullable=True, comment='广告排序'), - sa.Column('content', sa.Text(), nullable=True, comment='广告内容'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_ad_sort', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('name', sa.String(length=255), nullable=False, comment='广告位名称'), - sa.Column('item_id', sa.Integer(), nullable=True, comment='站点ID'), - sa.Column('cate_id', sa.Integer(), nullable=True, comment='栏目ID'), - sa.Column('loc_id', sa.Integer(), nullable=True, comment='广告位位置'), - sa.Column('platform', sa.Integer(), nullable=True, comment='投放平台:1-PC站 2-WAP站 3-微信小程序 4-APP应用'), - sa.Column('description', sa.String(length=255), nullable=True, comment='广告位描述'), - sa.Column('sort', sa.Integer(), nullable=True, comment='广告位排序'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_city', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - 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=False, comment='上级行政编码'), - sa.Column('zip_code', sa.String(length=6), nullable=False, comment='邮政编码'), - sa.Column('level', sa.Integer(), nullable=True, comment='城市级别:1-省份 2-城市 3-县区 4-街道'), - sa.Column('pid', sa.Integer(), 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.PrimaryKeyConstraint('id') - ) - op.create_table('flask_config', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('name', sa.String(length=150), nullable=False, comment='配置名称'), - sa.Column('sort', sa.Integer(), nullable=True, comment='配置排序'), - sa.Column('note', sa.String(length=255), nullable=True, comment='配置备注'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_config_data', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('title', sa.String(length=150), nullable=False, comment='配置项标题'), - sa.Column('code', sa.String(length=150), nullable=False, comment='配置项编码'), - sa.Column('value', sa.String(length=1000), nullable=True, comment='配置项值'), - sa.Column('options', sa.Text(), nullable=True, comment='配置选项'), - sa.Column('config_id', sa.Integer(), nullable=True, comment='配置ID'), - sa.Column('type', sa.String(length=150), nullable=False, comment='配置类型'), - sa.Column('status', sa.Integer(), nullable=True, comment='配置状态:1-正常 2-停用'), - sa.Column('sort', sa.Integer(), nullable=True, comment='配置项顺序'), - sa.Column('note', sa.String(length=255), nullable=True, comment='配置项备注'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_dept', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('name', sa.String(length=150), nullable=False, comment='部门名称'), - sa.Column('code', sa.String(length=150), nullable=False, comment='部门编码'), - sa.Column('type', sa.Integer(), nullable=True, comment='部门类型:1-公司 2-子公司 3-部门 4-小组'), - sa.Column('pid', sa.Integer(), nullable=True, comment='上级部门ID'), - sa.Column('sort', sa.Integer(), nullable=True, comment='部门排序'), - sa.Column('note', sa.String(length=255), nullable=True, comment='备注'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_dict', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - 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(), nullable=True, comment='字典排序'), - sa.Column('note', sa.String(length=255), nullable=True, comment='字典备注'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_dict_data', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - 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(), nullable=True, comment='字典ID'), - sa.Column('sort', sa.Integer(), nullable=True, comment='字典项顺序'), - sa.Column('note', sa.String(length=255), nullable=True, comment='字典项备注'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_item', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('name', sa.String(length=150), nullable=False, comment='站点名称'), - sa.Column('type', sa.Integer(), nullable=True, comment='站点类型:1-普通站点 2-其他站点'), - sa.Column('url', sa.String(length=255), nullable=True, comment='站点地址'), - sa.Column('image', sa.String(length=255), nullable=True, comment='站点图片'), - sa.Column('status', sa.Integer(), nullable=True, comment='站点状态:1-正常 2-停用'), - sa.Column('sort', sa.Integer(), nullable=True, comment='站点顺序'), - sa.Column('note', sa.String(length=255), nullable=True, comment='站点备注'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_item_cate', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('name', sa.String(length=150), nullable=False, comment='栏目名称'), - sa.Column('pid', sa.Integer(), nullable=True, comment='上级ID'), - sa.Column('item_id', sa.Integer(), nullable=True, comment='站点ID'), - sa.Column('pinyin', sa.String(length=150), nullable=False, comment='拼音(全拼)'), - sa.Column('code', sa.String(length=150), nullable=False, comment='拼音(简拼)'), - sa.Column('is_cover', sa.Integer(), nullable=True, comment='是否有封面:1-是 2-否'), - sa.Column('cover', sa.String(length=255), nullable=False, comment='封面地址'), - sa.Column('status', sa.Integer(), nullable=True, comment='栏目状态:1-正常 2-停用'), - sa.Column('sort', sa.Integer(), nullable=True, comment='栏目排序'), - sa.Column('note', sa.String(length=255), nullable=True, comment='栏目备注'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_level', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('name', sa.String(length=255), nullable=False, comment='职级名称'), - sa.Column('status', sa.Integer(), nullable=True, comment='职级状态:1-在用 2-停用'), - sa.Column('sort', sa.Integer(), nullable=True, comment='职级排序'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_link', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('name', sa.String(length=255), nullable=False, comment='友链名称'), - sa.Column('type', sa.Integer(), nullable=True, comment='友链类型:1-友情链接 2-合作伙伴'), - sa.Column('url', sa.String(length=255), nullable=True, comment='友链URL'), - sa.Column('item_id', sa.Integer(), nullable=True, comment='站点ID'), - sa.Column('cate_id', sa.Integer(), nullable=True, comment='栏目ID'), - sa.Column('platform', sa.Integer(), nullable=True, comment='投放平台:1-PC站 2-WAP站 3-微信小程序 4-APP应用'), - sa.Column('form', sa.Integer(), nullable=True, comment='友链形式:1-文字链接 2-图片链接'), - sa.Column('image', sa.String(length=255), nullable=True, comment='友链图片'), - sa.Column('status', sa.Integer(), nullable=True, comment='友链状态:1-正常 2-停用'), - sa.Column('sort', sa.Integer(), nullable=True, comment='友链顺序'), - sa.Column('note', sa.String(length=255), nullable=True, comment='友链备注'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_member', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('realname', sa.String(length=150), nullable=False, comment='用户姓名'), - sa.Column('nickname', sa.String(length=150), nullable=False, comment='用户昵称'), - sa.Column('gender', sa.Integer(), nullable=True, comment='性别:1-男 2-女 3-保密'), - sa.Column('avatar', sa.String(length=255), nullable=False, comment='用户头像'), - sa.Column('birthday', sa.DateTime(), nullable=False, comment='出生日期'), - sa.Column('email', sa.String(length=50), nullable=False, comment='邮箱'), - 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('address_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('member_level', sa.Integer(), nullable=True, comment='会员等级'), - sa.Column('source', sa.Integer(), nullable=True, comment='注册来源:1-网站注册 2-客户端注册 3-小程序注册 4-手机站注册 5-后台添加'), - sa.Column('status', sa.Integer(), nullable=True, comment='状态:1-正常 2-禁用'), - sa.Column('intro', sa.String(length=255), nullable=True, comment='个人简介'), - sa.Column('signature', sa.String(length=255), nullable=True, comment='个人签名'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_member_level', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('name', sa.String(length=150), nullable=False, comment='等级名称'), - sa.Column('sort', sa.Integer(), nullable=True, comment='排序'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_menu', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('title', sa.String(length=150), nullable=False, comment='菜单标题'), - sa.Column('icon', sa.String(length=50), nullable=True, comment='菜单图标'), - sa.Column('parent_id', sa.Integer(), 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('target', sa.String(length=30), nullable=True, comment='打开方式:0-内部打开 1-外部打开'), - sa.Column('permission', sa.String(length=150), nullable=True, comment='权限节点'), - sa.Column('type', sa.Integer(), nullable=True, comment='菜单类型:0-菜单 1-节点'), - sa.Column('status', sa.Integer(), nullable=True, comment='菜单状态:0-正常 1-禁用'), - sa.Column('hide', sa.Integer(), nullable=True, comment='是否可见:0-可见 1-不可见'), - sa.Column('sort', sa.Integer(), nullable=True, comment='排序'), - sa.Column('note', sa.String(length=255), nullable=True, comment='备注'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_notice', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('title', sa.String(length=255), nullable=False, comment='通知标题'), - sa.Column('source', sa.Integer(), nullable=True, comment='通知来源:1-官方平台 2-开源中国 3-CSDN官方 4-新浪微博'), - sa.Column('url', sa.String(length=255), nullable=True, comment='外部地址'), - sa.Column('click', sa.Integer(), nullable=True, comment='点击率'), - sa.Column('status', sa.Integer(), nullable=True, comment='通知状态:1-正常 2-停用'), - sa.Column('is_top', sa.Integer(), nullable=True, comment='是否置顶:1-是 2-否'), - sa.Column('content', sa.Text(), nullable=True, comment='通知公告内容'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_position', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('name', sa.String(length=255), nullable=False, comment='岗位名称'), - sa.Column('status', sa.Integer(), nullable=True, comment='岗位状态:1-在用 2-停用'), - sa.Column('sort', sa.Integer(), nullable=True, comment='岗位排序'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_role', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('name', sa.String(length=150), nullable=False, comment='角色名称'), - sa.Column('code', sa.String(length=30), nullable=False, comment='角色编码'), - sa.Column('status', sa.Integer(), nullable=True, comment='角色状态:1-正常 2-停用'), - sa.Column('sort', sa.Integer(), nullable=True, comment='角色排序'), - sa.Column('note', sa.String(length=255), nullable=True, comment='角色备注'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_role_menu', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('role_id', sa.Integer(), nullable=True, comment='角色ID'), - sa.Column('menu_id', sa.Integer(), nullable=True, comment='菜单ID'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_user', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('realname', sa.String(length=150), nullable=False, comment='用户姓名'), - sa.Column('nickname', sa.String(length=150), nullable=False, comment='用户昵称'), - sa.Column('gender', sa.Integer(), 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('birthday', sa.Date(), nullable=True, comment='出生日期'), - sa.Column('dept_id', sa.Integer(), nullable=True, comment='部门ID'), - sa.Column('level_id', sa.Integer(), nullable=True, comment='职级ID'), - sa.Column('position_id', sa.Integer(), 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('address_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(), nullable=True, comment='状态:1-正常 2-禁用'), - sa.Column('sort', sa.Integer(), nullable=True, comment='排序'), - sa.Column('note', sa.String(length=255), nullable=True, comment='个人备注'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('flask_user_role', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('user_id', sa.Integer(), nullable=True, comment='用户ID'), - sa.Column('role_id', sa.Integer(), nullable=True, comment='角色ID'), - sa.PrimaryKeyConstraint('id') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('flask_user_role') - op.drop_table('flask_user') - op.drop_table('flask_role_menu') - op.drop_table('flask_role') - op.drop_table('flask_position') - op.drop_table('flask_notice') - op.drop_table('flask_menu') - op.drop_table('flask_member_level') - op.drop_table('flask_member') - op.drop_table('flask_link') - op.drop_table('flask_level') - op.drop_table('flask_item_cate') - op.drop_table('flask_item') - op.drop_table('flask_dict_data') - op.drop_table('flask_dict') - op.drop_table('flask_dept') - op.drop_table('flask_config_data') - op.drop_table('flask_config') - op.drop_table('flask_city') - op.drop_table('flask_ad_sort') - op.drop_table('flask_ad') - # ### end Alembic commands ### diff --git a/migrations/versions/8291c8683c19_.py b/migrations/versions/8291c8683c19_.py deleted file mode 100644 index 150faef..0000000 --- a/migrations/versions/8291c8683c19_.py +++ /dev/null @@ -1,74 +0,0 @@ -"""empty message - -Revision ID: 8291c8683c19 -Revises: 9d75730d2484 -Create Date: 2023-04-12 13:50:44.865440 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = '8291c8683c19' -down_revision = '9d75730d2484' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('flask_menu', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', sa.Integer(), nullable=True, comment='创建人'), - sa.Column('create_time', sa.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', sa.Integer(), nullable=True, comment='更新人'), - sa.Column('update_time', sa.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', sa.Integer(), nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('title', sa.String(length=150), nullable=False, comment='菜单标题'), - sa.Column('icon', sa.String(length=50), nullable=True, comment='菜单图标'), - sa.Column('parent_id', sa.Integer(), 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('target', sa.String(length=30), nullable=True, comment='打开方式:0-内部打开 1-外部打开'), - sa.Column('permission', sa.String(length=150), nullable=True, comment='权限节点'), - sa.Column('type', sa.Integer(), nullable=True, comment='菜单类型:0-菜单 1-节点'), - sa.Column('status', sa.Integer(), nullable=True, comment='菜单状态:0-正常 1-禁用'), - sa.Column('hide', sa.Integer(), nullable=True, comment='是否可见:0-可见 1-不可见'), - sa.Column('sort', sa.Integer(), nullable=True, comment='排序'), - sa.Column('note', sa.String(length=255), nullable=True, comment='备注'), - sa.PrimaryKeyConstraint('id') - ) - op.drop_table('flask_menu2') - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('flask_menu2', - sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False, comment='主键ID'), - sa.Column('create_user', mysql.INTEGER(), autoincrement=False, nullable=True, comment='创建人'), - sa.Column('create_time', mysql.DATETIME(), nullable=True, comment='创建时间'), - sa.Column('update_user', mysql.INTEGER(), autoincrement=False, nullable=True, comment='更新人'), - sa.Column('update_time', mysql.DATETIME(), nullable=True, comment='更新时间'), - sa.Column('is_delete', mysql.INTEGER(), autoincrement=False, nullable=True, comment='删除标识:0-正常 1-已删除'), - sa.Column('title', mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_general_ci', length=150), nullable=False, comment='菜单标题'), - sa.Column('icon', mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_general_ci', length=50), nullable=True, comment='菜单图标'), - sa.Column('parent_id', mysql.INTEGER(), autoincrement=False, nullable=True, comment='上级ID'), - sa.Column('path', mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_general_ci', length=255), nullable=True, comment='菜单路径'), - sa.Column('component', mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_general_ci', length=255), nullable=True, comment='菜单组件'), - sa.Column('target', mysql.INTEGER(), autoincrement=False, nullable=True, comment='打开方式:0-内部打开 1-外部打开'), - sa.Column('permission', mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_general_ci', length=150), nullable=True, comment='权限节点'), - sa.Column('type', mysql.INTEGER(), autoincrement=False, nullable=True, comment='菜单类型:0-菜单 1-节点'), - sa.Column('status', mysql.INTEGER(), autoincrement=False, nullable=True, comment='菜单状态:0-正常 1-禁用'), - sa.Column('hide', mysql.INTEGER(), autoincrement=False, nullable=True, comment='是否可见:0-可见 1-不可见'), - sa.Column('sort', mysql.INTEGER(), autoincrement=False, nullable=True, comment='排序'), - sa.Column('note', mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_general_ci', length=255), nullable=True, comment='备注'), - sa.PrimaryKeyConstraint('id'), - mysql_collate='utf8mb4_general_ci', - mysql_default_charset='utf8mb4', - mysql_engine='InnoDB', - mysql_row_format='DYNAMIC' - ) - op.drop_table('flask_menu') - # ### end Alembic commands ### diff --git a/migrations/versions/9d75730d2484_.py b/migrations/versions/9d75730d2484_.py deleted file mode 100644 index 236145a..0000000 --- a/migrations/versions/9d75730d2484_.py +++ /dev/null @@ -1,42 +0,0 @@ -"""empty message - -Revision ID: 9d75730d2484 -Revises: 72d1886c9903 -Create Date: 2023-04-12 13:46:19.874903 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = '9d75730d2484' -down_revision = '72d1886c9903' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('flask_menu', sa.Column('title', sa.String(length=150), nullable=False, comment='菜单标题')) - op.add_column('flask_menu', sa.Column('parent_id', sa.Integer(), nullable=True, comment='上级ID')) - op.add_column('flask_menu', sa.Column('path', sa.String(length=255), nullable=True, comment='菜单路径')) - op.add_column('flask_menu', sa.Column('component', sa.String(length=255), nullable=True, comment='菜单组件')) - op.add_column('flask_menu', sa.Column('hide', sa.Integer(), nullable=True, comment='是否可见:0-可见 1-不可见')) - op.drop_column('flask_menu', 'name') - op.drop_column('flask_menu', 'pid') - op.drop_column('flask_menu', 'url') - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.add_column('flask_menu', sa.Column('url', mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_general_ci', length=255), nullable=True, comment='菜单URL')) - op.add_column('flask_menu', sa.Column('pid', mysql.INTEGER(), autoincrement=False, nullable=True, comment='上级ID')) - op.add_column('flask_menu', sa.Column('name', mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_general_ci', length=150), nullable=False, comment='菜单名称')) - op.drop_column('flask_menu', 'hide') - op.drop_column('flask_menu', 'component') - op.drop_column('flask_menu', 'path') - op.drop_column('flask_menu', 'parent_id') - op.drop_column('flask_menu', 'title') - # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index 7877192..b454264 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,22 +1,31 @@ # ============================================================ -# DjangoAdmin Flask+EleVue 项目管理配置 +# DjangoAdmin Flask 项目管理配置 # ============================================================ # 说明: -# 1. 本项目为应用工程(非库),运行依赖由 requirements.txt 管理, -# 此处不重复声明(与既有部署约定保持一致)。 -# 2. 本文件承载各开发工具(pytest/black/isort/mypy)统一配置。 +# 1. 运行依赖以 requirements.txt 为唯一事实来源(应用工程部署约定)。 +# [project].dependencies 通过 [tool.setuptools.dynamic] 直接读取 +# requirements.txt,二者自动保持同步、无需手工镜像。 +# 2. 本文件承载各开发工具(pytest/black/isort/mypy/coverage)统一配置。 + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" [project] -name = "djangoadmin-flask-elevue" +name = "djangoadmin-flask" version = "3.0.0" -description = "DjangoAdmin Flask+EleVue 企业数字化开发平台(后端)" +description = "DjangoAdmin Flask 企业数字化开发平台(后端)" readme = "README.md" requires-python = ">=3.9" license = {text = "Apache-2.0"} authors = [ - {name = "一米阳光团队", email = "djangoadmin@126.com"}, + {name = "小蚂蚁云团队", email = "djangoadmin@126.com"}, ] -dependencies = [] +dynamic = ["dependencies"] + +# dependencies 动态读取 requirements.txt(以它为准,改动 requirements.txt 即同步) +[tool.setuptools.dynamic] +dependencies = {file = ["requirements.txt"]} # ============================================================ # pytest diff --git a/requirements.txt b/requirements.txt index bf463e7..752d04a 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/scripts/migrate_db.py b/scripts/migrate_db.py new file mode 100644 index 0000000..f428e8d --- /dev/null +++ b/scripts/migrate_db.py @@ -0,0 +1,573 @@ +""" +跨库数据迁移脚本(MySQL → PostgreSQL / SQL Server / SQLite / Oracle 等) +============================================================= + +把「源库」的数据整体搬到「目标库」: +- 目标库结构用模型 metadata 生成(db.metadata.create_all,方言可移植, + 模型即 head,与 src/models_registry.py / setup_db.py 同源); +- 数据按拓扑序逐表 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 / setup_db.py 的分工: +- init_db.py / setup_db.py 单库初始化(当前 .env 目标库 create_all + 种子数据) +- 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.flask.elevue --src-user=root --src-pass=root \ + --dst-driver=postgresql --dst-host=127.0.0.1 --dst-port=5432 \ + --dst-db=djangoadmin.flask.elevue --dst-user=postgres --dst-pass=123456 \ + --drop-target-first + +先干跑核对计划:加 --dry-run(只连源、逐表数行数、打印计划,不碰目标库)。 +""" + +import argparse +import os +import sys +import time +from contextlib import contextmanager +from pathlib import Path +from urllib.parse import quote_plus + +# 将项目根加入 sys.path(脚本以 `python scripts/xxx.py` 运行时 cwd 是 scripts/) +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +# 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 # noqa: E402 +from sqlalchemy.pool import NullPool # noqa: E402 + +from config import DB_PREFIX # noqa: E402 +from config.database import _normalize_driver, _sqlite_url # noqa: E402 +import src.core.database as _dbm # noqa: E402 +import src.models_registry as _models_registry # noqa: E402,F401 # 模型注册中心:填充 db.metadata +db = _dbm.db + +# 各驱动默认端口(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._build_database_url 逐字等价 + (后者读 .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+psycopg2://' + 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" + + +def _busy(msg: str) -> None: + """缓冲提示:耗时操作开始前打印一行,让用户感知进程仍在工作(而非卡死)。""" + print(f" · {msg}...", flush=True) + + +@contextmanager +def step(title: str): + """耗时步骤缓冲包装:进入打印"正在...(请勿中断)",退出打印"✓ 完成(耗时)"。 + + 用于删库/建库/建表/大表迁移等可能耗时较长的操作,避免用户误以为进程卡死。 + 通过 `with step(...) as h: h['detail'] = ...` 可给完成行追加明细(如行数/失败数)。 + """ + t0 = time.perf_counter() + print(f"[·] {title}...(可能耗时较长,请勿中断)", flush=True) + holder: dict = {} + try: + yield holder + finally: + detail = f",{holder['detail']}" if holder.get('detail') else "" + print(f"[✓] {title} 完成(耗时 {_fmt_duration(time.perf_counter() - t0)}){detail}", flush=True) + + +# ============================================================ +# 建目标库(镜像 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() + if file.parent.as_posix() != '.': + file.parent.mkdir(parents=True, exist_ok=True) + if drop_first and file.exists(): + _busy(f"正在删除旧目标文件 {file}") + file.unlink() + print(f"removed existing sqlite file {file}") + return + + if driver == 'mysql': + import pymysql + _busy("正在连接 MySQL(维护实例)") + conn = pymysql.connect(host=host, port=int(port), user=username, password=password, autocommit=True) + cur = conn.cursor() + if drop_first: + _busy(f"正在删除旧目标库 `{database}`") + cur.execute(f"DROP DATABASE IF EXISTS `{database}`") + _busy(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 psycopg2 + _busy("正在连接 PostgreSQL(postgres 维护库)") + # 连 postgres 维护库创建目标库;DB_DATABASE 含点号,标识符必须双引号 + conn = psycopg2.connect( + host=host, port=int(port), user=username, password=password, dbname="postgres", + ) + conn.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: + _busy(f'正在删除旧目标库 "{database}"(DROP ... WITH FORCE)') + cur.execute(f'DROP DATABASE "{database}" WITH (FORCE)') + exists = False + if not exists: + _busy(f'正在创建目标库 "{database}"(UTF8)') + # 强制 UTF8 编码(TEMPLATE template0 避免继承模板库非 UTF8 编码导致 CJK 乱码) + cur.execute(f'CREATE DATABASE "{database}" ENCODING \'UTF8\' TEMPLATE template0') + conn.close() + return + + if driver == 'oracle': + # 服务由 DBA 管理,schema 就是连接用户;连上即视为"目标库就绪" + import oracledb + _busy(f"正在连接 Oracle 服务 {database}") + 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: + _busy(f"正在清理 {len(tables)} 张旧项目表") + for t in tables: + try: + cur.execute(f"DROP TABLE {t} PURGE") + except Exception: + pass # 表不存在则跳过 + conn.close() + print(f"[OK] Oracle 服务 {database} 连接成功(schema={username})") + if drop_first: + print(f" drop-first:已尽力清理 {len(tables)} 张项目表") + return + + # mssql:连 master 维护库创建目标库 + import pymssql + _busy("正在连接 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: + _busy(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}]") + _busy(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 = db.metadata.tables.get(DB_PREFIX + name) + if tbl is not None: + out.append(tbl) + seen.add(tbl.name) + for tbl in db.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", ""), + ) + + 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) 建目标库(删库/建库可能耗时较长,step() 会给出缓冲提示) + with step("[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 时清表用 + ) + + # 2) 建结构(模型即 head,方言可移植) + with step(f"[2/5] 建表结构({len(present)} 张表 create_all)") as h: + db.metadata.create_all(dst_engine) + h['detail'] = f"{len(present)} 张表" + + # 3) 泵数据 + total_rows = 0 + with step("[3/5] 逐表迁移数据") as h: + 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" [skip] {t.name}(源=目标)") + continue + _busy(f"[{idx}/{len(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" [skip] {t.name}(源=目标)") + continue + _busy(f"[{idx}/{len(present)}] 正在迁移 {t.name}") + total_rows += pump_table(src, dst, t, args.chunk_size, dst_driver) + h['detail'] = f"共 {total_rows} 行" + + # 4) 重置自增序列(PG/MSSQL/Oracle;MySQL/SQLite 自动跟随) + # 单表失败不中断其余表,避免一张空表序列异常连累后续全部序列 + reset_errors = [] + if dst_driver in ("postgresql", "mssql", "oracle"): + with step("[4/5] 重置自增序列") as h: + for t in present: + try: + # 每表独立事务:Oracle 的 CREATE/DROP SEQUENCE / TRIGGER 是 DDL 会隐式提交, + # 不能与其它表共享一个大事务 + with dst_engine.begin() as dst: + reset_sequence(dst_driver, dst, t) + except Exception as e: + reset_errors.append((t.name, str(e))) + print(f" [WARN] 序列重置失败 {t.name}: {e}") + if reset_errors: + h['detail'] = f"{len(reset_errors)} 张失败" + else: + print("[4/5 重置序列] MySQL/SQLite 自动跟随,无需处理") + + # 5) 校验 + if not args.skip_verify: + with step("[5/5] 逐表校验行数") as h: + with src_engine.connect() as src, dst_engine.connect() as dst: + failed = verify_counts(src, dst, present) + h['detail'] = f"{len(failed)} 张表不一致" if failed else f"{len(present)} 张表全部 PASS" + if failed: + for name, s, d in failed: + print(f" - {name}: source={s} target={d}") + return 1 + else: + print("[5/5 校验] 已跳过(--skip-verify)") + + # 汇总 + 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() diff --git a/scripts/seed_data.py b/scripts/seed_data.py new file mode 100644 index 0000000..19782f2 --- /dev/null +++ b/scripts/seed_data.py @@ -0,0 +1,221 @@ +""" +种子数据脚本(幂等,可重复执行) +============================================================= + +在空库上初始化系统必需数据,供多数据库(mysql/pgsql/mssql/sqlite/oracle)就绪: + - admin 超管用户(id=1,密码见 DEFAULT_PASSWORD,默认 123456) + - 超级管理员角色 + 关联 + - 系统管理目录 + 常用管理菜单 + 查询权限节点 + - 基础字典(性别)、站点配置、系统参数 + +用法:由 scripts/setup_db.py 在 app 上下文内调用 seed_all()。 +""" + +import logging +import os +import sys + +# 将项目根加入 sys.path(脚本以 `python scripts/xxx.py` 运行时 cwd 是 scripts/) +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from src.core.database import db # noqa: E402 +from src.core.password import encrypt_password, generate_salt # noqa: E402 +from src.common.constants.common import DEFAULT_PASSWORD # noqa: E402 +from src.modules.system.user.repository import UserRepository # noqa: E402 +from src.modules.system.role.repository import RoleRepository # noqa: E402 +from src.modules.association.user_role.repository import UserRoleRepository # noqa: E402 +from src.modules.system.menu.repository import MenuRepository # noqa: E402 +from src.modules.dictionary.dict.repository import DictRepository # noqa: E402 +from src.modules.dictionary.dict_item.repository import DictItemRepository # noqa: E402 +from src.modules.configuration.config.repository import ConfigRepository # noqa: E402 +from src.modules.configuration.config_item.repository import ConfigItemRepository # noqa: E402 +from src.modules.param.repository import ParamRepository # noqa: E402 + +logger = logging.getLogger(__name__) + + +def _get_or_create(repo, filters, defaults): + """幂等:按等值条件查,不存在则创建""" + obj = repo.first_by(filters) + if obj is None: + obj = repo.create(**defaults) + db.session.commit() + logger.info(f"已创建 {repo.__name__}: {filters}") + return obj + + +def seed_admin_user(): + """创建 admin 超管用户(id=1,超管绕过 user_id==1)""" + if UserRepository.first_by({"username": "admin"}): + logger.info("admin 用户已存在,跳过") + return + salt = generate_salt() + admin = UserRepository.create( + username="admin", + realname="超级管理员", + gender=1, + avatar="", + mobile="", + email="", + dept_id=0, + level_id=0, + position_id=0, + province_code="", + city_code="", + district_code="", + street_code="", + address="", + status=1, + sort=1, + note="系统内置超管,请登录后修改密码", + password=encrypt_password(DEFAULT_PASSWORD, salt), + salt=salt, + create_user="system", + ) + db.session.commit() + logger.info(f"已创建 admin 用户(id={admin.id},初始密码 {DEFAULT_PASSWORD})") + + +def seed_superadmin_role(): + """创建超级管理员角色并关联 admin 用户""" + role = _get_or_create( + RoleRepository, + {"code": "admin"}, + {"name": "超级管理员", "code": "admin", "status": 1, "sort": 1, "create_user": "system"}, + ) + admin = UserRepository.first_by({"username": "admin"}) + if admin and not UserRoleRepository.first_by({"user_id": admin.id, "role_id": role.id}): + UserRoleRepository.create(user_id=admin.id, role_id=role.id, create_user="system") + db.session.commit() + logger.info("已关联 admin → 超级管理员") + + +def seed_basic_menus(): + """创建系统管理目录 + 常用管理菜单 + 查询权限节点""" + if MenuRepository.first_by({"name": "系统管理", "parent_id": 0}): + logger.info("系统管理菜单已存在,跳过") + return + + sys_dir = MenuRepository.create( + name="系统管理", parent_id=0, path="/system", component="", + permission="", type=0, status=1, target=0, sort=1, icon="setting", + hide=0, create_user="system", + ) + db.session.commit() + + child_menus = [ + ("用户管理", "/user", "system/user/index", "sys:user:page", 1), + ("角色管理", "/role", "system/role/index", "sys:role:page", 2), + ("菜单管理", "/menu", "system/menu/index", "sys:menu:page", 3), + ("部门管理", "/dept", "system/dept/index", "sys:dept:page", 4), + ("岗位管理", "/position", "system/position/index", "sys:position:page", 5), + ("职级管理", "/level", "system/level/index", "sys:level:page", 6), + ] + for name, path, component, permission, sort in child_menus: + menu = MenuRepository.create( + name=name, parent_id=sys_dir.id, path=path, component=component, + permission=permission, type=0, status=1, target=0, sort=sort, + hide=0, create_user="system", + ) + db.session.commit() + # 查询权限节点(type=1,前端按钮/接口鉴权用) + MenuRepository.create( + name=f"查询{name}", parent_id=menu.id, path=f"{path}/page", component="", + permission=permission, type=1, status=1, target=0, sort=1, + create_user="system", + ) + db.session.commit() + logger.info("已创建系统管理目录与常用菜单") + + +def seed_import_export_permissions(): + """为存量菜单幂等补齐「导入/导出」按钮权限节点 + + 背景说明: + 导入/导出 按钮在前端通过 v-perm 指令按权限码鉴权(如 sys:user:import), + 权限码必须存在于菜单表中(type=1 按钮节点),否则即使后端接口已放行、 + 后端装饰器也已校验,前端按钮仍不会渲染。本函数为老库中已存在的菜单 + 补建对应按钮节点,与新建库流程(seed_basic_menus)保持一致。 + + 设计要点: + 1. 幂等:以「父菜单名 + type=0」定位父菜单,以「按钮名 + parent_id」判重, + 已存在的节点直接跳过,脚本可安全重复执行。 + 2. 容错:父菜单缺失(如老库中未创建「职级管理」)时仅记录日志并跳过, + 不中断其余按钮节点创建。 + 3. 按钮路径约定为 <父菜单路径>/import、<父菜单路径>/export, + 与后端 router 的路由保持一致,便于前端跳转与鉴权对齐。 + """ + # 待补建的按钮节点配置:父菜单名 / 按钮名 / 路径 / 权限码 / 排序 + btn_specs = [ + ("用户管理", "导入用户", "/user/import", "sys:user:import", 2), + ("用户管理", "导出用户", "/user/export", "sys:user:export", 3), + ("职级管理", "导入职级", "/level/import", "sys:level:import", 2), + ("职级管理", "导出职级", "/level/export", "sys:level:export", 3), + ] + for menu_name, btn_name, btn_path, permission, sort in btn_specs: + parent = MenuRepository.first_by({"name": menu_name, "type": 0}) + if not parent: + logger.warning(f"菜单「{menu_name}」不存在,跳过权限节点 {permission}") + continue + if MenuRepository.first_by({"name": btn_name, "parent_id": parent.id}): + logger.info(f"权限节点 {permission} 已存在,跳过") + continue + MenuRepository.create( + name=btn_name, parent_id=parent.id, path=btn_path, component="", + permission=permission, type=1, status=1, target=0, sort=sort, + create_user="system", + ) + db.session.commit() + logger.info(f"已创建权限节点 {permission}({btn_name})") + + +def seed_basic_dict(): + """创建性别字典""" + if DictRepository.first_by({"code": "gender"}): + logger.info("性别字典已存在,跳过") + return + dict_obj = DictRepository.create(name="性别", code="gender", sort=1, create_user="system") + db.session.commit() + for name, value, sort in [("男", "1", 1), ("女", "2", 2), ("保密", "3", 3)]: + DictItemRepository.create( + dict_id=dict_obj.id, name=name, value=value, sort=sort, create_user="system" + ) + db.session.commit() + + +def seed_basic_config(): + """创建站点配置""" + if ConfigRepository.first_by({"code": "site_config"}): + logger.info("站点配置已存在,跳过") + return + config = ConfigRepository.create(name="站点配置", code="site_config", sort=1, create_user="system") + db.session.commit() + ConfigItemRepository.create( + config_id=config.id, name="网站名称", + code="site_name", value="DjangoAdmin", type="text", sort=1, create_user="system", + ) + db.session.commit() + + +def seed_basic_param(): + """创建系统参数""" + _get_or_create( + ParamRepository, + {"code": "system_param"}, + {"name": "系统参数", "code": "system_param", "value": "", "type": 0, "status": 1, "sort": 1, "create_user": "system"}, + ) + + +def seed_all(): + """执行全部种子(幂等)""" + seed_admin_user() + seed_superadmin_role() + seed_basic_menus() + # 为存量库补建导入/导出按钮权限节点,必须在菜单创建之后执行 + seed_import_export_permissions() + seed_basic_dict() + seed_basic_config() + seed_basic_param() + logger.info("种子数据初始化完成") diff --git a/scripts/seed_dict.py b/scripts/seed_dict.py new file mode 100644 index 0000000..6448a59 --- /dev/null +++ b/scripts/seed_dict.py @@ -0,0 +1,222 @@ +""" +字典数据一键初始化脚本 +============================================================= + +以本文件内置的 DICT_SEED 为数据源,把所有字典(dict + dict_item)幂等写入数据库 +(已存在的字典按 code 跳过)。 + +用法: + python scripts/seed_dict.py + +说明: + - 一次性初始化工具,数据入库后可删除本文件 + - DICT_SEED 是本项目全部数据字典的唯一种子数据源(运行时展示一律走 dict_utils + 从字典表读取,勿在业务代码里再硬编码枚举) + - 之后请在后台"字典管理"维护字典项(写入会触发 dict_utils 缓存失效,前端即时生效) +""" + +import os +import sys + +# 将项目根加入 sys.path(脚本以 `python scripts/xxx.py` 运行时 cwd 是 scripts/) +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +# ============================================================ +# 系统全部数据字典种子定义(幂等初始化:已存在的 code 跳过) +# code:字典编码(稳定业务键,如 gender、status、operation_type) +# name:字典中文名;note:字典备注(可选) +# items:字典项,value 为存储值(写入时统一转为字符串,dict_item.value 是 String 列), +# name 为显示文本,顺序即 sort 排序 +# ============================================================ + +DICT_SEED = [ + { + "code": "gender", "name": "性别", "note": "用户性别", + "items": [ + {"value": "1", "name": "男"}, + {"value": "2", "name": "女"}, + {"value": "3", "name": "保密"}, + ], + }, + { + "code": "status", "name": "状态", "note": "通用启用状态", + "items": [ + {"value": "1", "name": "正常"}, + {"value": "2", "name": "禁用"}, + ], + }, + { + "code": "param_type", "name": "参数类型", + "items": [ + {"value": "0", "name": "系统参数"}, + {"value": "1", "name": "业务参数"}, + ], + }, + { + "code": "link_type", "name": "友链类型", + "items": [ + {"value": "1", "name": "友情链接"}, + {"value": "2", "name": "合作伙伴"}, + ], + }, + { + "code": "link_form", "name": "友链形式", + "items": [ + {"value": "1", "name": "文字链接"}, + {"value": "2", "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": "只读文本"}, + ], + }, + { + "code": "city_level", "name": "城市级别", "note": "行政区划级别", + "items": [ + {"value": "1", "name": "省份"}, + {"value": "2", "name": "城市"}, + {"value": "3", "name": "县区"}, + {"value": "4", "name": "街道"}, + ], + }, + { + "code": "notice_type", "name": "通知类型", + "items": [ + {"value": "1", "name": "通知"}, + {"value": "2", "name": "公告"}, + ], + }, + { + "code": "operation_source", "name": "操作日志来源", + "items": [ + {"value": "0", "name": "后台系统"}, + {"value": "1", "name": "手机站"}, + {"value": "2", "name": "客户端"}, + {"value": "3", "name": "其他"}, + ], + }, + { + "code": "operation_type", "name": "操作日志类型", + "items": [ + {"value": "1", "name": "登录"}, + {"value": "2", "name": "退出"}, + {"value": "3", "name": "新增"}, + {"value": "4", "name": "修改"}, + {"value": "5", "name": "删除"}, + {"value": "6", "name": "查询"}, + {"value": "7", "name": "导出"}, + {"value": "8", "name": "导入"}, + {"value": "9", "name": "提交"}, + {"value": "10", "name": "撤销"}, + {"value": "11", "name": "审核"}, + {"value": "12", "name": "强退"}, + {"value": "13", "name": "开账号"}, + {"value": "14", "name": "清空"}, + {"value": "15", "name": "授权"}, + {"value": "16", "name": "缓存"}, + {"value": "17", "name": "生成代码"}, + {"value": "18", "name": "其他"}, + ], + }, + { + "code": "login_type", "name": "登录日志类型", + "items": [ + {"value": "1", "name": "登录"}, + {"value": "2", "name": "退出"}, + ], + }, + { + "code": "article_status", "name": "文章状态", + "items": [ + {"value": "0", "name": "正常"}, + {"value": "1", "name": "下架"}, + ], + }, + { + "code": "job_log_status", "name": "任务日志状态", "note": "定时任务执行状态", + "items": [ + {"value": "0", "name": "正常"}, + {"value": "1", "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": "放弃执行"}, + ], + }, +] + + +def seed_dict(app) -> int: + """将 DICT_SEED 幂等写入 app 绑定的数据库,返回新增字典数(可测试)。""" + from src.core.database import db + from src.modules.dictionary import dict_utils + from src.modules.dictionary.dict.repository import DictRepository + from src.modules.dictionary.dict_item.repository import DictItemRepository + + created = 0 + with app.app_context(): + for sort, spec in enumerate(DICT_SEED, 1): + if DictRepository.first_by({"code": spec["code"]}): + print(f" [skip] {spec['code']}(已存在)") + continue + dict_obj = DictRepository.create( + name=spec["name"], code=spec["code"], + note=spec.get("note", ""), sort=sort, create_user="system", + ) + for idx, item in enumerate(spec["items"], 1): + DictItemRepository.create( + dict_id=dict_obj.id, + name=item["name"], value=str(item["value"]), + sort=item.get("sort", idx), create_user="system", + ) + db.session.commit() + created += 1 + print(f" [ok] {spec['code']}({len(spec['items'])} 项)") + # 失效取值缓存,保证后续读取走新数据 + dict_utils.clear_cache() + return created + + +def main(): + from src.app import create_app + + print(f"开始初始化字典数据(共 {len(DICT_SEED)} 个字典)...") + created = seed_dict(create_app()) + print(f"完成:新增 {created} 个字典,跳过 {len(DICT_SEED) - created} 个已存在") + + +if __name__ == "__main__": + main() diff --git a/scripts/setup_db.py b/scripts/setup_db.py new file mode 100644 index 0000000..344fe17 --- /dev/null +++ b/scripts/setup_db.py @@ -0,0 +1,57 @@ +""" +多数据库建表 + 种子数据脚本(方案 A) +============================================================= + +在任何受支持的数据库(mysql/pgsql/mssql/sqlite/oracle)上初始化系统: + 1. 按 .env 的 DB_DRIVER 连接目标库 + 2. db.create_all() 建全部表(方言中立,各库通用) + 3. 写入种子数据(admin 超管、角色、基础菜单/字典/配置) + +用法: + # 切换数据库只需改 .env(DB_DRIVER + 账号),然后执行: + python scripts/setup_db.py + + # 仅补种子(表已存在) + python -c "from scripts.setup_db import seed_only; seed_only()" +""" + +import logging +import os +import sys + +# 将项目根加入 sys.path(脚本以 `python scripts/xxx.py` 运行时 cwd 是 scripts/) +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from src.app import create_app # noqa: E402 +from src.core.database import db # noqa: E402 +from config import DatabaseConfig, friendly_db_error # noqa: E402 + +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") + + +def setup_database(): + """建表 + 种子(连接失败时给出友好中文提示)""" + app = create_app() + print(f"目标数据库: {DatabaseConfig.SQLALCHEMY_DATABASE_URI}") + try: + with app.app_context(): + db.create_all() + print("已建表(create_all)") + from scripts.seed_data import seed_all + seed_all() + except Exception as e: + raise SystemExit(f"建表/初始化失败:{friendly_db_error(e)}") + + +def seed_only(): + """仅补种子数据(表已存在时)""" + app = create_app() + with app.app_context(): + from scripts.seed_data import seed_all + seed_all() + + +if __name__ == "__main__": + setup_database() diff --git a/src/app.py b/src/app.py index 29aa11e..580e8f9 100644 --- a/src/app.py +++ b/src/app.py @@ -15,7 +15,7 @@ from flask import Flask from flask_cors import CORS from werkzeug.middleware.proxy_fix import ProxyFix -from src.core.middleware import register_middleware, register_transaction, register_error_handlers, register_operation_log +from src.middleware import register_middleware, register_transaction, register_error_handlers, register_operation_log from src.routes.v1 import register_v1 from src.extensions import register_extends from config import DatabaseConfig, REDIS_URL @@ -148,7 +148,7 @@ def _inject_dependencies(app): 2. operation_log_writer:操作日志写入器(log 服务) """ from src.modules.system.menu.service import get_user_permissions - from src.modules.log.service import record_operation_log + from src.modules.operation_log.service import record_operation_log # ---- 权限提供方 ---- class MenuPermissionProvider: diff --git a/src/common/base_db.py b/src/common/base_db.py index 9edbc85..f367374 100644 --- a/src/common/base_db.py +++ b/src/common/base_db.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -186,7 +186,7 @@ class BaseDB: 注意: 此方法执行物理删除,数据不可恢复。 - 如需软删除,请使用 BaseModel.delete_by_id()。 + 如需软删除,请使用对应 Repository 的 delete_by_id()。 Returns: bool: 删除成功返回 True diff --git a/src/common/base_model.py b/src/common/base_model.py index 5fd596a..dec6002 100644 --- a/src/common/base_model.py +++ b/src/common/base_model.py @@ -2,7 +2,7 @@ 基类模型模块 ============================================================= -提供所有数据库模型的基类,包含通用字段和公共方法。 +提供所有数据库模型的基类,包含通用字段与实体行为。 所有业务模型应继承此类,避免重复定义通用字段。 通用字段包括: @@ -13,26 +13,23 @@ - update_time: 更新时间 - is_delete: 软删除标识 -企业级特性: - 1. 参数校验(id 有效性检查) - 2. 完整的异常处理和日志记录 - 3. 增强版分页查询(支持等值、模糊、范围查询) - 4. 事务管理由调用方控制(配合装饰器使用) +职责边界(单一职责): + - 实体行为:字段定义、__init__(驼峰转下划线 + 列过滤)、to_dict 序列化 + - 数据访问:查询/删除统一走 BaseRepository(src/common/base_repository.py), + 本类不再承载查询方法 + - 持久化原语:save / save_all / delete 由 BaseDB mixin 提供 """ -import logging -from datetime import datetime, timezone -from typing import Optional, List, Dict, Any, Tuple, Union +from datetime import datetime +from typing import Dict, Any + +from sqlalchemy import text, func from src.core.database import db from src.common.utils.case_converter import convert_camel_to_snake, convert_camel_case -from src.common.utils.model_utils import models_to_list -from src.common.utils.request import like_contains, parse_id_list +from src.common.utils.time_utils import now from src.common.constants.common import SERIALIZE_EXCLUDE_FIELDS -# 获取模块级日志记录器 -logger = logging.getLogger(__name__) - # ============================================================ # 基类模型 @@ -43,15 +40,14 @@ class BaseModel(db.Model): """ 数据库模型基类 - 所有业务模型应继承此类,自动获得通用字段和公共方法。 + 所有业务模型应继承此类,自动获得通用字段和实体序列化能力。 提供的功能: 1. 通用字段:id、创建人、创建时间、更新人、更新时间、软删除标识 - 2. 序列化方法:to_dict()、to_json() - 3. 查询方法:get_by_id()、get_by_ids()、get_all_active() - 4. 分页查询:paginate() 支持多种筛选条件 - 5. 删除方法:delete_by_id()、batch_delete()、batch_delete_r()、batch_restore() - 6. 辅助方法:exists()、get_delete_status_text() + 2. 初始化:__init__ 自动驼峰转下划线、过滤无效字段 + 3. 序列化:to_dict() 输出驼峰命名字典(排除敏感字段) + + 数据访问(查询/新增/更新/删除)请使用对应 Repository(继承 BaseRepository)。 """ # ============================================================ @@ -61,6 +57,43 @@ class BaseModel(db.Model): # 定义为抽象类,不会在数据库中创建独立表 __abstract__ = True + # ============================================================ + # 表注释支持 + # ============================================================ + + def __init_subclass__(cls, **kwargs): + """ + 子类初始化钩子:自动将 __table_comment__ 转为 __table_args__ 的 comment 参数 + + 使用方式:在模型类中定义 __table_comment__ = 'xxx' 即可, + 无需手动拼装 __table_args__。 + + 支持与已有的 __table_args__(如索引定义)共存: + - 仅 __table_comment__:自动生成 __table_args__ = {'comment': 'xxx'} + - __table_args__ 为元组(含索引):追加 {'comment': 'xxx'} 到元组末尾 + - __table_args__ 为字典:直接合并 comment 键 + - __table_args__ 已含 comment:不覆盖 + """ + super().__init_subclass__(**kwargs) + comment = getattr(cls, '__table_comment__', None) + if not comment: + return + existing = getattr(cls, '__table_args__', None) + if existing is None: + cls.__table_args__ = {'comment': comment} + elif isinstance(existing, dict): + if 'comment' not in existing: + existing['comment'] = comment + elif isinstance(existing, tuple): + # 元组形式:检查末尾是否已有 kwargs 字典 + if existing and isinstance(existing[-1], dict): + last = dict(existing[-1]) + if 'comment' not in last: + last['comment'] = comment + cls.__table_args__ = existing[:-1] + (last,) + else: + cls.__table_args__ = existing + ({'comment': comment},) + # ============================================================ # 通用字段定义 # ============================================================ @@ -73,33 +106,38 @@ class BaseModel(db.Model): comment="主键ID" ) - # 创建人:记录创建者,由业务层通过 get_username() 设置 + # 创建人:记录创建者,由业务层通过 get_realname() 设置 create_user = db.Column( db.String(64), default='', + server_default=text("''"), comment="创建人" ) - # 创建时间:记录创建时间,创建后不再变化(UTC时区) + # 创建时间:记录创建时间,创建后不再变化(北京时间,秒精度) + # 注:用通用 db.DateTime(PG→TIMESTAMP、MySQL→DATETIME),db.DATETIME 在 PG 上不合法 create_time = db.Column( - db.DATETIME, - default=lambda: datetime.now(tz=timezone.utc), + db.DateTime, + default=now, + server_default=func.now(), comment="创建时间" ) - # 更新人:记录最后修改者,由业务层通过 get_username() 设置 + # 更新人:记录最后修改者,由业务层通过 get_realname() 设置 update_user = db.Column( db.String(64), default='', + server_default=text("''"), comment="更新人" ) - # 更新时间:记录最后修改时间,更新时自动刷新(UTC时区) + # 更新时间:记录最后修改时间,更新时自动刷新(北京时间,秒精度) # 注意:onupdate 仅在 ORM 实例脏检测刷新时生效,Query.update() 不会触发 update_time = db.Column( - db.DATETIME, - default=lambda: datetime.now(tz=timezone.utc), - onupdate=lambda: datetime.now(tz=timezone.utc), + db.DateTime, + default=now, + onupdate=now, + server_default=func.now(), comment="更新时间" ) @@ -108,6 +146,7 @@ class BaseModel(db.Model): is_delete = db.Column( db.Integer, default=0, + server_default=text('0'), comment="删除标识:0-正常 1-已删除" ) @@ -160,7 +199,7 @@ class BaseModel(db.Model): 特别处理 datetime 类型,格式化为字符串便于 JSON 序列化。 Args: - convert_case: 是否将字段名转换为驼峰命名,默认False(保持下划线) + convert_case: 是否将字段名转换为驼峰命名,默认True Returns: dict: 包含所有字段的字典(不含敏感字段) @@ -184,488 +223,3 @@ class BaseModel(db.Model): field_name = convert_camel_case(field_name) result[field_name] = value return result - - # ============================================================ - # 通用查询方法(带参数校验和异常处理) - # ============================================================ - - @classmethod - def get_by_id(cls, id: Optional[int]) -> Optional['BaseModel']: - """ - 根据ID获取未删除的记录 - - 企业级实现: - 1. 参数校验:检查 id 是否有效 - 2. 异常处理:捕获数据库异常并记录日志 - - Args: - id: 主键ID - - Returns: - Optional[BaseModel]: 记录对象,不存在时返回 None - - Example: - position = Position.get_by_id(1) - if position: - print(position.name) - """ - # 参数有效性校验 - if id is None or id <= 0: - logger.warning(f"get_by_id 收到无效ID: {id}") - return None - - try: - return cls.query.filter(cls.id == id, cls.is_delete == 0).first() - except Exception as e: - logger.error( - f"get_by_id 查询异常, 模型: {cls.__name__}, ID: {id}, 错误: {str(e)}", - exc_info=True - ) - return None - - @classmethod - def get_by_ids(cls, ids: Optional[List[int]]) -> List['BaseModel']: - """ - 根据ID列表批量获取未删除的记录 - - 适用于批量查询场景,如根据ID列表批量查询用户信息。 - 自动过滤无效ID和已删除记录。 - - Args: - ids: 主键ID列表 - - Returns: - List[BaseModel]: 记录对象列表,无效参数返回空列表 - - Example: - positions = Position.get_by_ids([1, 2, 3]) - for pos in positions: - print(pos.name) - """ - # 空列表直接返回 - if not ids: - logger.debug(f"get_by_ids 收到空ID列表, 模型: {cls.__name__}") - return [] - - # 过滤无效ID - valid_ids = [id for id in ids if id and id > 0] - if not valid_ids: - logger.warning(f"get_by_ids 所有ID均无效: {ids}") - return [] - - try: - return cls.query.filter(cls.id.in_(valid_ids), cls.is_delete == 0).all() - except Exception as e: - logger.error( - f"get_by_ids 查询异常, 模型: {cls.__name__}, 错误: {str(e)}", - exc_info=True - ) - return [] - - @classmethod - def get_all_active(cls) -> List['BaseModel']: - """ - 获取所有未删除的记录 - - 适用于需要获取全部有效数据的场景。 - 数据量大时建议使用分页查询,避免内存溢出。 - - Returns: - List[BaseModel]: 所有未删除的记录列表 - """ - try: - return cls.query.filter(cls.is_delete == 0).all() - except Exception as e: - logger.error( - f"get_all_active 查询异常, 模型: {cls.__name__}, 错误: {str(e)}", - exc_info=True - ) - return [] - - # ============================================================ - # 增强版分页查询方法 - # ============================================================ - - @classmethod - def paginate( # noqa: C901 (兼容性增强分页查询,复杂度较高属存量设计) - cls, - page: int = 1, - per_page: int = 15, - filters: Optional[Dict[str, Any]] = None, - like_filters: Optional[Dict[str, str]] = None, - range_filters: Optional[Dict[str, Tuple[Any, Any]]] = None, - order_by: Optional[Union[str, List[str]]] = None, - order_desc: Optional[Union[str, List[str]]] = None, - with_deleted: bool = False, - as_dict: bool = True - ) -> Tuple[List[Union[Dict, Any]], int, int]: - """ - 通用分页查询方法(增强版) - - 企业级实现: - 1. 参数修正:page 和 per_page 自动修正为有效值 - 2. 多种筛选:支持等值、模糊、范围三种筛选方式 - 3. 灵活排序:支持单字段和多字段排序 - 4. 自动转换:可选的字典转换 - - 支持的筛选类型: - 1. 等值筛选(filters):精确匹配,如 status=1 - 2. 模糊筛选(like_filters):LIKE 查询,如 name LIKE '%经理%' - 3. 范围筛选(range_filters):区间查询,如 create_time BETWEEN - - Args: - page: 当前页码,默认1(自动修正为 >= 1) - per_page: 每页数量,默认15(自动修正为 1-100) - filters: 等值筛选,如 {'status': 1} - like_filters: 模糊筛选,如 {'name': '经理'} - range_filters: 范围筛选,如 {'create_time': ('2024-01-01', '2024-12-31')} - order_by: 升序排序字段,如 'sort' 或 ['sort', 'id'] - order_desc: 降序排序字段,如 'create_time' - with_deleted: 是否包含已删除记录,默认 False - as_dict: 是否转换为字典,默认 True - - Returns: - tuple: (items, total, pages) - - items: 当前页数据列表 - - total: 总记录数 - - pages: 总页数 - - Example: - # 基础分页 - items, total, pages = Position.paginate( - page=1, per_page=10, - filters={'status': 1}, - order_by='sort' - ) - - # 组合查询 - items, total, pages = Position.paginate( - page=1, per_page=10, - filters={'status': 1}, - like_filters={'name': '经理'}, - order_by='sort', - order_desc='create_time' - ) - """ - # ============================================================ - # 步骤1:参数修正 - # ============================================================ - if page < 1: - page = 1 - if per_page < 1: - per_page = 15 - if per_page > 100: - per_page = 100 - - try: - # ============================================================ - # 步骤2:构建基础查询 - # ============================================================ - query = cls.query - if not with_deleted: - query = query.filter(cls.is_delete == 0) - - # ============================================================ - # 步骤3:应用等值筛选 - # ============================================================ - if filters: - for key, value in filters.items(): - if value is not None and hasattr(cls, key): - query = query.filter(getattr(cls, key) == value) - - # ============================================================ - # 步骤4:应用模糊筛选 - # ============================================================ - if like_filters: - for key, value in like_filters.items(): - if value is not None and hasattr(cls, key): - query = query.filter(getattr(cls, key).like(like_contains(value), escape='\\')) - - # ============================================================ - # 步骤5:应用范围筛选 - # ============================================================ - if range_filters: - for key, (start, end) in range_filters.items(): - if hasattr(cls, key): - if start is not None: - query = query.filter(getattr(cls, key) >= start) - if end is not None: - query = query.filter(getattr(cls, key) <= end) - - # ============================================================ - # 步骤6:应用排序 - # ============================================================ - if order_by: - if isinstance(order_by, str): - query = query.order_by(getattr(cls, order_by).asc()) - elif isinstance(order_by, list): - for field in order_by: - query = query.order_by(getattr(cls, field).asc()) - - if order_desc: - if isinstance(order_desc, str): - query = query.order_by(getattr(cls, order_desc).desc()) - elif isinstance(order_desc, list): - for field in order_desc: - query = query.order_by(getattr(cls, field).desc()) - - # ============================================================ - # 步骤7:计算总数和页数 - # ============================================================ - total = query.count() - pages = (total + per_page - 1) // per_page if total > 0 else 0 - - # ============================================================ - # 步骤8:执行分页查询 - # ============================================================ - items = query.offset((page - 1) * per_page).limit(per_page).all() - - # ============================================================ - # 步骤9:转换为字典(可选) - # ============================================================ - if as_dict: - items = models_to_list(items) - - return items, total, pages - - except Exception as e: - logger.error( - f"paginate 查询异常, 模型: {cls.__name__}, 错误: {str(e)}", - exc_info=True - ) - return [], 0, 0 - - # ============================================================ - # 删除方法(事务由调用方控制) - # ============================================================ - - @classmethod - def delete_by_id(cls, id: Optional[int]) -> bool: - """ - 删除单条记录(软删除) - - 将指定ID记录的 is_delete 标识设置为 1。 - 事务由上层装饰器统一管理。 - - Args: - id: 主键ID - - Returns: - bool: 删除成功返回 True,失败返回 False - - Example: - @transactional_response - def delete_position(): - Position.delete_by_id(1) - return R.ok(msg="删除成功") - """ - # 参数校验 - if id is None or id <= 0: - logger.warning(f"delete_by_id 收到无效ID: {id}") - return False - - try: - record = cls.get_by_id(id) - if not record: - logger.debug(f"delete_by_id 记录不存在, 模型: {cls.__name__}, ID: {id}") - return False - - # 标记为已删除 - record.is_delete = 1 - db.session.add(record) - - logger.info(f"delete_by_id 成功, 模型: {cls.__name__}, ID: {id}") - return True - - except Exception as e: - logger.error( - f"delete_by_id 异常, 模型: {cls.__name__}, ID: {id}, 错误: {str(e)}", - exc_info=True - ) - raise - - @classmethod - def batch_delete(cls, ids: Optional[List[int]]) -> int: - """ - 批量删除(软删除) - - 一次性删除多条记录,使用 IN 查询提高效率。 - 只删除未删除的记录。 - - Args: - ids: 主键ID列表 - - Returns: - int: 实际删除的记录数 - - Example: - @transactional_response - def batch_delete(): - count = Position.batch_delete([1, 2, 3]) - return R.ok(msg=f"删除 {count} 条记录") - """ - # 空列表直接返回 - if not ids: - return 0 - - # 过滤无效ID - valid_ids = [id for id in ids if id and id > 0] - if not valid_ids: - logger.warning(f"batch_delete 所有ID均无效: {ids}") - return 0 - - try: - # 批量更新,synchronize_session=False 提高性能 - count = cls.query.filter( - cls.id.in_(valid_ids), - cls.is_delete == 0 - ).update( - {cls.is_delete: 1, cls.update_time: datetime.now(tz=timezone.utc)}, - synchronize_session=False - ) - - logger.info( - f"batch_delete 成功, 模型: {cls.__name__}, 删除数量: {count}" - ) - return count - - except Exception as e: - logger.error( - f"batch_delete 异常, 模型: {cls.__name__}, 错误: {str(e)}", - exc_info=True - ) - raise - - @classmethod - def batch_delete_r(cls, ids_str: Optional[str]): - """ - 批量删除并返回R对象(供service层直接使用) - - 封装了ID校验、解析、批量删除、返回R响应的完整流程, - 消除各service中重复的删除逻辑。 - - Args: - ids_str: 逗号分隔的ID字符串,如 "1,2,3" - - Returns: - R: 操作结果 - - Example: - def delete_positions(position_ids): - return Position.batch_delete_r(position_ids) - """ - if not ids_str: - from utils import R - return R.failed("记录ID不存在") - - id_list = parse_id_list(ids_str) - if not id_list: - from utils import R - return R.failed("无效的记录ID") - - count = cls.batch_delete(id_list) - from utils import R - return R.ok(msg=f"本次共删除{count}条数据") - - @classmethod - def batch_restore(cls, ids: Optional[List[int]]) -> int: - """ - 批量恢复已删除的记录 - - 将已删除的记录恢复为正常状态(is_delete = 0)。 - 只恢复已删除的记录。 - - Args: - ids: 主键ID列表 - - Returns: - int: 实际恢复的记录数 - - Example: - @transactional_response - def batch_restore(): - count = Position.batch_restore([1, 2, 3]) - return R.ok(msg=f"恢复 {count} 条记录") - """ - # 空列表直接返回 - if not ids: - return 0 - - # 过滤无效ID - valid_ids = [id for id in ids if id and id > 0] - if not valid_ids: - logger.warning(f"batch_restore 所有ID均无效: {ids}") - return 0 - - try: - # 批量恢复,只恢复已删除的记录 - count = cls.query.filter( - cls.id.in_(valid_ids), - cls.is_delete == 1 - ).update( - {cls.is_delete: 0}, - synchronize_session=False - ) - - logger.info( - f"batch_restore 成功, 模型: {cls.__name__}, 恢复数量: {count}" - ) - return count - - except Exception as e: - logger.error( - f"batch_restore 异常, 模型: {cls.__name__}, 错误: {str(e)}", - exc_info=True - ) - raise - - # ============================================================ - # 辅助方法 - # ============================================================ - - @classmethod - def get_delete_status_text(cls, is_delete: int) -> str: - """ - 获取删除状态文本 - - 将数字状态的 is_delete 转换为可读的文本。 - 用于前端展示或日志记录。 - - Args: - is_delete: 删除标识值(0或1) - - Returns: - str: 状态文本 - """ - return {0: '正常', 1: '已删除'}.get(is_delete, '未知') - - @classmethod - def exists(cls, id: Optional[int]) -> bool: - """ - 检查记录是否存在(未删除) - - 快速检查记录是否存在,返回布尔值。 - 适用于记录存在性验证场景。 - - Args: - id: 主键ID - - Returns: - bool: 存在返回 True,否则返回 False - - Example: - if Position.exists(1): - print("记录存在") - """ - # 参数校验 - if id is None or id <= 0: - return False - - try: - return cls.query.filter(cls.id == id, cls.is_delete == 0).count() > 0 - except Exception as e: - logger.error( - f"exists 查询异常, 模型: {cls.__name__}, ID: {id}, 错误: {str(e)}", - exc_info=True - ) - return False diff --git a/src/common/base_repository.py b/src/common/base_repository.py index 7858fa5..77ed780 100644 --- a/src/common/base_repository.py +++ b/src/common/base_repository.py @@ -8,58 +8,582 @@ 设计要点: - 查询默认过滤已删除记录(is_delete == 0) - 不持有事务:保存沿用 BaseDB.save(),由 AOP 事务拦截器统一提交 + - 分页筛选(等值/模糊/范围/排序)统一入口 find_page / find_list + - 软删除原语(delete_by_id/batch_delete/batch_restore)内联在本层实现, + 数据访问不依赖 BaseModel 上的查询方法 """ -from typing import Any, List, Optional +import logging +from datetime import datetime +from typing import Any, Dict, List, Optional -from sqlalchemy import and_ +from src.common.constants.common import PAGE_SIZE +from src.common.utils.case_converter import convert_camel_to_snake +from src.common.utils.model_utils import calc_pages +from src.common.utils.request import like_contains +from src.common.utils.time_utils import now +from src.core.database import db + +logger = logging.getLogger(__name__) + + +def _as_list(value) -> List[str]: + """将字符串或列表统一转为列表""" + if value is None: + return [] + if isinstance(value, (list, tuple)): + return list(value) + return [value] class BaseRepository: - """泛型仓储基类""" + """泛型仓储基类,提供通用的数据库CRUD操作""" - #: 子类指定模型类 + #: 子类需指定模型类 model = None + # ============================================================ + # 基础查询方法 + # ============================================================ + + @classmethod + def _base_query(cls, with_deleted: bool = False): + """ + 获取基础查询对象 + + 默认过滤已删除记录(is_delete == 0),确保查询结果均为有效数据。 + 当 with_deleted 为 True 时,不过滤软删除记录,用于恢复操作场景。 + + Args: + with_deleted: 是否包含已删除记录,默认 False + + Returns: + SQLAlchemy Query 对象 + """ + query = cls.model.query + if not with_deleted: + query = query.filter(cls.model.is_delete == 0) + return query + @classmethod def get_by_id(cls, obj_id) -> Optional[Any]: - """按主键查询未删除记录;ID 无效时返回 None""" + """ + 按主键查询单条未删除记录 + + Args: + obj_id: 记录主键ID + + Returns: + 模型实例或 None(ID无效或记录已删除) + """ if not obj_id or int(obj_id) <= 0: return None return cls.model.query.filter( - and_(cls.model.id == int(obj_id), cls.model.is_delete == 0) + cls.model.id == int(obj_id), + cls.model.is_delete == 0, ).first() @classmethod - def get_all_active(cls, order_by=None) -> List[Any]: - """查询全部未删除记录""" - query = cls.model.query.filter(cls.model.is_delete == 0) - if order_by is not None: - query = query.order_by(order_by) - return query.all() + def get_by_ids(cls, ids) -> List[Any]: + """ + 按主键列表批量查询未删除记录 + + Args: + ids: 主键ID列表 + + Returns: + 模型实例列表,空列表表示无有效ID或无匹配记录 + """ + valid_ids = [int(i) for i in (ids or []) if i and int(i) > 0] + if not valid_ids: + return [] + return cls.model.query.filter( + cls.model.id.in_(valid_ids), + cls.model.is_delete == 0, + ).all() @classmethod - def paginate(cls, query, page: int, page_size: int): - """分页查询:返回 (items, total, pages)""" + def get_all(cls, order_by=None, status=None) -> List[Any]: + """ + 查询全部未删除记录 + + Args: + order_by: 排序字段,支持字符串或列表 + status: 状态过滤条件,可选 + + Returns: + 模型实例列表 + """ + filters = {"status": status} if status is not None else None + return cls.find_list(filters=filters, order_by=order_by) + + @classmethod + def get_all_active(cls, order_by=None) -> List[Any]: + """ + 兼容旧接口:查询全部未删除记录 + + Args: + order_by: 排序字段,支持字符串或列表 + + Returns: + 模型实例列表 + """ + return cls.find_list(order_by=order_by) + + @classmethod + def first_by(cls, filters: Dict[str, Any], with_deleted: bool = False) -> Optional[Any]: + """ + 按等值条件查询单条记录 + + 典型使用场景:登录时按用户名查询用户信息。 + + Args: + filters: 等值条件字典,如 {"username": "admin"} + with_deleted: 是否包含已删除记录,默认 False + + Returns: + 模型实例或 None + """ + return cls._build_query(filters=filters, with_deleted=with_deleted).first() + + @classmethod + def exists_by(cls, filters: Dict[str, Any], exclude_id=None) -> bool: + """ + 按等值条件检查是否存在未删除记录 + + 用于唯一性校验场景,支持排除自身ID(更新时校验)。 + + Args: + filters: 等值条件字典,如 {"category_name": "技术"} + exclude_id: 排除记录ID,用于更新时排除自身 + + Returns: + bool: True 表示存在重复记录 + + Example: + CategoryRepository.exists_by({'category_name': '技术'}) + CategoryRepository.exists_by({'category_name': '技术'}, exclude_id=5) + """ + if not filters: + return False + conditions = [ + getattr(cls.model, key) == value + for key, value in filters.items() + if value is not None and hasattr(cls.model, key) + ] + if not conditions: + return False + query = cls.model.query.filter(cls.model.is_delete == 0, *conditions) + if exclude_id: + query = query.filter(cls.model.id != int(exclude_id)) + return query.count() > 0 + + @classmethod + def count_by(cls, filters: Dict[str, Any]) -> int: + """ + 按等值条件统计未删除记录数 + + 用于删除前引用校验,确认数据是否被其他表引用。 + + Args: + filters: 等值条件字典 + + Returns: + int: 符合条件的记录数 + """ + if not filters: + return 0 + conditions = [ + getattr(cls.model, key) == value + for key, value in filters.items() + if value is not None and hasattr(cls.model, key) + ] + if not conditions: + return 0 + return cls.model.query.filter(cls.model.is_delete == 0, *conditions).count() + + @classmethod + def exists(cls, obj_id) -> bool: + """ + 检查未删除记录是否存在 + + Args: + obj_id: 记录主键ID + + Returns: + bool: True 表示记录存在且未删除 + """ + if not obj_id or int(obj_id) <= 0: + return False + return cls.model.query.filter( + cls.model.id == int(obj_id), + cls.model.is_delete == 0, + ).count() > 0 + + @classmethod + def count_children(cls, parent_id, parent_field: str = "parent_id") -> int: + """ + 统计指定父级下的有效子记录数 + + 用于部门/城市/分类等层级结构的删除前校验。 + + Args: + parent_id: 父级ID + parent_field: 父级关联字段名,默认 parent_id + + Returns: + int: 子记录数量 + """ + if not parent_id or int(parent_id) <= 0: + return 0 + parent_id = int(parent_id) + if not hasattr(cls.model, parent_field): + return 0 + return cls.model.query.filter( + getattr(cls.model, parent_field) == parent_id, + cls.model.is_delete == 0, + ).count() + + # ============================================================ + # 筛选与分页查询方法 + # ============================================================ + + @classmethod + def _parse_datetime(cls, value) -> Optional[datetime]: + """ + 解析时间范围入参 + + 支持多种日期时间格式,解析失败时静默忽略并记录警告日志。 + + Args: + value: 日期时间字符串或 datetime 对象 + + Returns: + datetime 对象或 None(解析失败时) + """ + if value is None: + return None + if isinstance(value, datetime): + return value + text = str(value).strip() + if not text: + return None + for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d', '%Y/%m/%d %H:%M:%S', '%Y/%m/%d'): + try: + return datetime.strptime(text, fmt) + except ValueError: + continue + logger.warning(f"parse_datetime 无法解析的日期: {text}") + return None + + @classmethod + def _build_order_columns(cls, order_by=None, order_desc=None) -> List[Any]: + """ + 构建排序列表达式 + + 支持多字段排序,通过单次 order_by(*cols) 避免排序覆盖问题。 + + Args: + order_by: 升序字段列表 + order_desc: 降序字段列表 + + Returns: + SQLAlchemy 排序列表达式列表 + """ + columns = [] + for field in _as_list(order_by): + if hasattr(cls.model, field): + columns.append(getattr(cls.model, field).asc()) + for field in _as_list(order_desc): + if hasattr(cls.model, field): + columns.append(getattr(cls.model, field).desc()) + return columns + + @classmethod + def _build_query( + cls, + filters: Optional[Dict[str, Any]] = None, + like_filters: Optional[Dict[str, str]] = None, + range_filters: Optional[Dict[str, tuple]] = None, + order_by=None, + order_desc=None, + with_deleted: bool = False, + ): + """ + 统一构建查询对象 + + 支持三种过滤条件类型: + 1. filters: 等值精确匹配 + 2. like_filters: 模糊匹配(自动转义通配符) + 3. range_filters: 范围匹配(日期自动解析) + + Args: + filters: 等值条件字典,如 {"status": 1} + like_filters: 模糊条件字典,如 {"name": "经理"}(自动添加百分号通配符) + range_filters: 范围条件字典,如 {"create_time": (start, end)} + order_by: 升序排序字段 + order_desc: 降序排序字段 + with_deleted: 是否包含已删除记录 + + Returns: + SQLAlchemy Query 对象 + """ + query = cls._base_query(with_deleted) + + # 应用等值过滤条件(None 或空字符串不参与筛选,前端常传 '') + for key, value in (filters or {}).items(): + if value is not None and value != '' and hasattr(cls.model, key): + query = query.filter(getattr(cls.model, key) == value) + + # 应用模糊过滤条件(自动转义 LIKE 通配符;None 或空字符串不参与筛选) + for key, value in (like_filters or {}).items(): + if value is not None and value != '' and hasattr(cls.model, key): + query = query.filter( + getattr(cls.model, key).like(like_contains(value), escape='\\') + ) + + # 应用范围过滤条件(日期字段自动解析) + for key, (start, end) in (range_filters or {}).items(): + if hasattr(cls.model, key): + if start is not None: + start_dt = cls._parse_datetime(start) + if start_dt is not None: + query = query.filter(getattr(cls.model, key) >= start_dt) + if end is not None: + end_dt = cls._parse_datetime(end) + if end_dt is not None: + query = query.filter(getattr(cls.model, key) <= end_dt) + + # 应用排序条件 + order_columns = cls._build_order_columns(order_by, order_desc) + if order_columns: + query = query.order_by(*order_columns) + + return query + + @classmethod + def find_page( + cls, + page: int = 1, + page_size: int = PAGE_SIZE, + filters: Optional[Dict[str, Any]] = None, + like_filters: Optional[Dict[str, str]] = None, + range_filters: Optional[Dict[str, tuple]] = None, + order_by=None, + order_desc=None, + with_deleted: bool = False, + ): + """ + 分页查询 + + 统一的分页查询入口,支持等值、模糊、范围三种过滤方式。 + + Args: + page: 当前页码,从1开始 + page_size: 每页记录数 + filters: 等值条件字典 + like_filters: 模糊条件字典 + range_filters: 范围条件字典 + order_by: 升序排序字段 + order_desc: 降序排序字段 + with_deleted: 是否包含已删除记录 + + Returns: + tuple: (items, total, pages) + items: 当前页数据列表 + total: 总记录数 + pages: 总页数 + """ + if page < 1: + page = 1 + if page_size < 1: + page_size = PAGE_SIZE + + query = cls._build_query( + filters, like_filters, range_filters, order_by, order_desc, with_deleted + ) total = query.count() - pages = (total + page_size - 1) // page_size if total > 0 else 0 - items = query.limit(page_size).offset((page - 1) * page_size).all() + pages = calc_pages(total, page_size) + items = query.offset((page - 1) * page_size).limit(page_size).all() return items, total, pages @classmethod - def update_by_id(cls, obj_id, data: dict) -> int: - """按主键更新字段,返回影响行数""" - return cls.model.query.filter_by(id=obj_id).update(data) + def find_list( + cls, + filters: Optional[Dict[str, Any]] = None, + like_filters: Optional[Dict[str, str]] = None, + range_filters: Optional[Dict[str, tuple]] = None, + order_by=None, + order_desc=None, + with_deleted: bool = False, + ) -> List[Any]: + """ + 不分页筛选查询 + + 用于不需要分页的场景,如查询菜单权限节点列表、下拉选项等。 + + Args: + filters: 等值条件字典 + like_filters: 模糊条件字典 + range_filters: 范围条件字典 + order_by: 升序排序字段 + order_desc: 降序排序字段 + with_deleted: 是否包含已删除记录 + + Returns: + List: 模型实例列表 + """ + return cls._build_query( + filters, like_filters, range_filters, order_by, order_desc, with_deleted + ).all() + + # ============================================================ + # 写入方法(flush 不提交,事务由 AOP 拦截器统一管理) + # ============================================================ @classmethod def create(cls, **kwargs) -> Any: - """创建并保存实例,返回实例对象""" + """ + 创建并保存新实例 + + 事务由 AOP 拦截器统一管理,调用方无需手动提交。 + + Args: + **kwargs: 模型字段键值对 + + Returns: + Model: 保存后的模型实例 + """ obj = cls.model(**kwargs) obj.save() return obj @classmethod def save(cls, obj) -> Any: - """保存实例,返回实例对象""" + """ + 保存已有实例 + + 事务由 AOP 拦截器统一管理,调用方无需手动提交。 + + Args: + obj: 模型实例 + + Returns: + Model: 保存后的模型实例 + """ obj.save() return obj + + @classmethod + def update_by_id(cls, obj_id, data: dict, stamp: bool = True) -> int: + """ + 按主键更新字段 + + 支持自动补齐更新时间与更新人信息。 + + Args: + obj_id: 记录主键ID + data: 更新字段字典(支持驼峰命名,自动转为下划线) + stamp: 是否自动补齐 update_time/update_user,默认 True + + Returns: + int: 受影响行数 + """ + if not obj_id or int(obj_id) <= 0: + return 0 + obj_id = int(obj_id) + + # 将驼峰命名字段转为下划线命名字段 + update_data = convert_camel_to_snake(dict(data or {})) + update_data.pop('id', None) + + if stamp: + from src.core.security import get_realname + update_data.setdefault('update_time', now()) + update_data.setdefault('update_user', get_realname()) + + return cls.model.query.filter_by(id=obj_id).update(update_data) + + @classmethod + def update_status(cls, obj_id, status) -> int: + """ + 单独更新状态字段 + + Args: + obj_id: 记录主键ID + status: 新状态值 + + Returns: + int: 受影响行数 + """ + return cls.update_by_id(obj_id, {"status": status}) + + @classmethod + def delete_by_id(cls, obj_id) -> bool: + """ + 软删除单条记录 + + 将记录的 is_delete 标记设为 1,而非物理删除。 + + Args: + obj_id: 记录主键ID + + Returns: + bool: True-删除成功,False-记录不存在或已被删除 + """ + if not obj_id or int(obj_id) <= 0: + return False + record = cls.get_by_id(int(obj_id)) + if not record: + return False + record.is_delete = 1 + db.session.add(record) + return True + + @classmethod + def batch_delete(cls, ids) -> int: + """ + 批量软删除未删除记录 + + 使用 bulk update 提高批量操作性能。 + + Args: + ids: 主键ID列表 + + Returns: + int: 实际删除的记录数 + """ + valid_ids = [int(i) for i in (ids or []) if i and int(i) > 0] + if not valid_ids: + return 0 + return cls.model.query.filter( + cls.model.id.in_(valid_ids), + cls.model.is_delete == 0, + ).update( + {cls.model.is_delete: 1, cls.model.update_time: now()}, + synchronize_session=False, + ) + + @classmethod + def batch_restore(cls, ids) -> int: + """ + 批量恢复已删除记录 + + 将 is_delete 标记从 1 改回 0。 + + Args: + ids: 主键ID列表 + + Returns: + int: 实际恢复的记录数 + """ + valid_ids = [int(i) for i in (ids or []) if i and int(i) > 0] + if not valid_ids: + return 0 + return cls.model.query.filter( + cls.model.id.in_(valid_ids), + cls.model.is_delete == 1, + ).update( + {cls.model.is_delete: 0}, + synchronize_session=False, + ) diff --git a/src/common/base_service.py b/src/common/base_service.py index 2067a59..6680790 100644 --- a/src/common/base_service.py +++ b/src/common/base_service.py @@ -2,32 +2,424 @@ 服务基类 ============================================================= -提供服务层通用骨架。服务层编码规范: +提供标准 CRUD 服务骨架,消除各业务模块 service 中的重复代码。 +与 base_repository.py(数据访问)职责互补:BaseService 编排业务 +(表单校验、存在性校验、前置/后置钩子、分页/字典序列化), +数据读写一律委托子类指定的 repository。 + +约定(沿用现有服务层规范,见 CLAUDE.md): - 函数入参为数据(dict / 标量),出参为纯数据 - 业务失败通过 `raise BizError` 表达(见 src/core/exceptions.py) - 不直接读取 request、不返回 R 对象(request 解析与 R 包装由 router 层负责) - - 数据访问走 repository(见 src/common/base_repository.py) + - 数据访问统一走 repository(见 src/common/base_repository.py) -使用示例: +子类用法: class PositionService(BaseService): repository = PositionRepository + form = PositionForm + status_form = PositionStatusForm + model_name = "岗位" - def add_position(self, data): - ... + 差异化业务通过覆写钩子方法实现(before_add / before_delete / _detail_dict 等), + 或在子类中整体覆写某个通用方法。 + + 为保持 router 层调用兼容,模块级暴露代理函数: + get_position_page = PositionService.get_page + add_position = PositionService.add """ -from src.common.base_repository import BaseRepository +import logging +from typing import Dict + +from src.common.constants.common import PAGE_SIZE +from src.common.utils import regular +from src.common.utils.case_converter import convert_snake_case +from src.common.utils.model_utils import build_page_data, models_to_list, model_to_dict +from src.common.utils.request import parse_id_list +from src.core.exceptions import BizError +from src.core.security import get_realname + +logger = logging.getLogger(__name__) class BaseService: - """服务基类""" + """标准 CRUD 服务基类,提供通用的业务编排能力""" - #: 子类指定仓储类 - repository: BaseRepository = None + #: 子类需指定仓储类(必需) + repository = None + #: 子类指定新增/更新表单(缺省则跳过表单校验,仅适用于无校验模块) + form = None + #: 子类指定状态表单(可选) + status_form = None + #: 业务对象名称,用于错误提示,如 "岗位" + model_name = "记录" + + #: 枚举显示名映射:{'字段名': '字典编码'} → 序列化时自动补 data['{字段名}Text'] + #: 示例:{'type': 'operation_type', 'source': 'operation_source'} + serialize_maps: Dict[str, str] = {} + + # ============================================================ + # 通用查询方法 + # ============================================================ @classmethod def get_info(cls, obj_id): - """通用:按 ID 查询模型对象""" - if cls.repository is None: - return None + """ + 按 ID 查询模型对象 + + 内部复用入口,供子类覆写扩展查询逻辑。 + + Args: + obj_id: 记录主键ID + + Returns: + 模型实例或 None + """ return cls.repository.get_by_id(obj_id) + + @classmethod + def get_detail(cls, obj_id): + """ + 按 ID 查询详情 + + 返回字典格式数据,不存在时返回 None。 + + Args: + obj_id: 记录主键ID + + Returns: + dict: 详情数据字典,不存在时返回 None + """ + obj = cls.get_info(obj_id) + return cls._detail_dict(obj) if obj else None + + @classmethod + def get_page(cls, params: dict) -> dict: + """ + 分页查询 + + 查询构建委托 repository.page,统一使用 build_page_data 构建返回结构。 + + Args: + params: 查询参数字典,包含 pageNo、pageSize 及筛选条件 + + Returns: + dict: 分页数据,包含 items、total、page、pageSize、pages 等字段 + """ + page = int(params.get("pageNo", 1)) + page_size = int(params.get("pageSize", PAGE_SIZE)) + items, total, pages = cls.repository.page(params) + return build_page_data( + items, total, page, page_size, pages, + enrich=cls._enrich_page_records, + ) + + @classmethod + def get_list(cls, params=None): + """ + 无分页列表查询 + + 用于下拉选择等场景,委托 repository.list_enabled 获取启用状态的记录。 + 支持与分页一致的条件筛选参数(各模块在 list_enabled 中定义)。 + + Args: + params: 查询参数字典(可选),如 {"name": "经理"} + + Returns: + list: 模型实例列表的字典形式 + """ + return models_to_list(cls.repository.list_enabled(params)) + + # ============================================================ + # 通用写入方法(模板方法 + 覆写钩子) + # ============================================================ + + @classmethod + def add(cls, data: dict) -> None: + """ + 新增记录 + + 执行流程: + 1. 表单校验(_validate_form) + 2. 新增前钩子(before_add),可返回额外写入字段 + 3. 创建记录(repository.create) + 4. 新增后钩子(after_add) + + Args: + data: 新增数据字典 + + Raises: + BizError: 表单校验失败或业务规则不满足时抛出 + """ + form = cls._validate_form(data) + extra = cls.before_add(form) or {} + create_data = cls._clean_form_data(form) + create_data.update(extra) + create_data["create_user"] = get_realname() + create_data["update_user"] = get_realname() + obj = cls.repository.create(**create_data) + cls.after_add(obj) + + @classmethod + def update(cls, data: dict) -> None: + """ + 更新记录 + + 执行流程: + 1. 表单校验(_validate_form) + 2. 存在性校验(检查记录是否存在) + 3. 更新前钩子(before_update) + 4. 更新记录(repository.update_by_id) + 5. 更新后钩子(after_update) + + Args: + data: 更新数据字典,必须包含 id 字段 + + Raises: + BizError: 表单校验失败、记录不存在或更新失败时抛出 + """ + form = cls._validate_form(data) + obj_id = form.data.get("id") + if not obj_id or int(obj_id) <= 0: + raise BizError(f"{cls.model_name}ID无效") + obj_id = int(obj_id) + obj = cls.get_info(obj_id) + if not obj: + raise BizError(f"{cls.model_name}不存在") + extra = cls.before_update(form, obj) or {} + update_data = cls._clean_form_data(form) + update_data.update(extra) + result = cls.repository.update_by_id(obj_id, update_data) + if not result: + raise BizError("更新失败") + cls.after_update(obj) + + @classmethod + def remove(cls, ids: str) -> int: + """ + 批量软删除 + + 执行流程: + 1. 参数校验(ID 是否存在且有效) + 2. 删除前钩子(before_delete) + 3. 批量软删除(repository.batch_delete) + 4. 删除后钩子(after_delete) + + Args: + ids: 逗号分隔的 ID 字符串 + + Returns: + int: 实际删除的记录数 + + Raises: + BizError: ID 为空或无效时抛出 + """ + if not ids: + raise BizError("记录ID不存在") + id_list = parse_id_list(ids) + if not id_list: + raise BizError("无效的记录ID") + cls.before_delete(id_list) + count = cls.repository.batch_delete(id_list) + cls.after_delete(count) + return count + + @classmethod + def update_status(cls, data: dict) -> None: + """ + 更新记录状态 + + 执行流程: + 1. 状态表单校验(_validate_form) + 2. 存在性校验(检查记录是否存在) + 3. 更新状态(repository.update_status) + + Args: + data: 状态数据字典,必须包含 id 和 status 字段 + + Raises: + BizError: 表单校验失败、记录不存在或更新失败时抛出 + """ + form = cls._validate_form(data, use_status=True) + obj_id = int(form.data["id"]) + status = int(form.data["status"]) + if not cls.get_info(obj_id): + raise BizError(f"{cls.model_name}不存在") + if not cls.repository.update_status(obj_id, status): + raise BizError("设置失败") + + # ============================================================ + # 可覆写钩子方法(子类按需定制,默认空实现/透传) + # ============================================================ + + @classmethod + def _validate_form(cls, data, use_status=False): + """ + 表单校验 + + 根据 use_status 参数选择普通表单或状态表单进行校验。 + + Args: + data: 表单数据字典 + use_status: 是否使用状态表单,默认 False + + Returns: + form: 校验通过的表单对象,form 缺省时返回 None(跳过校验) + + Raises: + BizError: 表单校验失败时抛出 + """ + form_cls = cls.status_form if use_status else cls.form + if form_cls is None: + return None + form = form_cls(data=data) + if not form.validate(): + raise BizError(regular.get_err(form)) + return form + + @classmethod + def _clean_form_data(cls, form): + """ + 过滤表单数据 + + 仅保留模型真实列字段,防止伪字段(pseudo-field)进入 update_by_id 触发 SQL 错误。 + camelCase 字段名自动转为 snake_case 后与模型列集合比对。 + + Args: + form: 表单对象 + + Returns: + dict: 过滤后的数据字典,无表单时返回空字典 + """ + if form is None: + return {} + columns = {c.name for c in cls.repository.model.__table__.columns} + return {k: v for k, v in form.data.items() if convert_snake_case(k) in columns} + + @classmethod + def before_add(cls, form): + """ + 新增前钩子 + + form 已通过校验,可修改 form.data 后写入(如富文本预处理、字段默认值注入等)。 + + Args: + form: 已校验的表单对象 + + Returns: + dict: 可选,返回额外写入字段字典,与表单数据合并 + """ + + @classmethod + def after_add(cls, obj): + """ + 新增后钩子 + + 用于新增后的业务处理,如权限缓存失效、消息通知等。 + + Args: + obj: 已创建的模型实例 + """ + + @classmethod + def before_update(cls, form, obj): + """ + 更新前钩子 + + form 已通过校验,obj 为已存在记录,可基于 obj 做前置校验。 + + Args: + form: 已校验的表单对象 + obj: 已存在的模型实例 + + Returns: + dict: 可选,返回额外更新字段字典,与表单数据合并(与 before_add 对称) + """ + + @classmethod + def after_update(cls, obj): + """ + 更新后钩子 + + 用于更新后的业务处理,如权限缓存失效、审计日志记录等。 + + Args: + obj: 更新后的模型实例 + """ + + @classmethod + def before_delete(cls, ids): + """ + 删除前钩子 + + 用于删除前的业务处理,如删除关联数据、校验引用依赖、失效缓存等。 + + Args: + ids: 待删除的记录ID列表 + """ + + @classmethod + def after_delete(cls, count): + """ + 删除后钩子 + + 用于删除后的业务处理,如记录审计日志、统计更新等。 + + Args: + count: 实际删除的记录数 + """ + + @classmethod + def _enrich_page_records(cls, records): + """ + 分页富化钩子 + + 对分页查询结果进行额外字段追加或格式化处理。 + 基类自动应用 serialize_maps 字典翻译,子类覆写时应调用 super() 保留此能力。 + + Args: + records: 记录字典列表 + + Returns: + list: 富化后的记录字典列表,默认原样返回 + """ + cls._apply_serialize_maps(records) + return records + + @classmethod + def _detail_dict(cls, obj): + """ + 详情序列化钩子 + + 控制模型实例转换为字典时的字段输出,子类可覆写追加字段或格式化处理。 + 基类自动应用 serialize_maps 字典翻译,子类覆写时应调用 super() 保留此能力。 + + Args: + obj: 模型实例 + + Returns: + dict: 序列化后的字典 + """ + data = model_to_dict(obj) + cls._apply_serialize_maps([data]) + return data + + @classmethod + def _apply_serialize_maps(cls, records): + """ + 应用枚举显示名映射 + + 遍历 serialize_maps,从字典表查询 value→name 映射, + 为每条记录补充 {field_name}Text 字段。 + + Args: + records: 记录字典列表(原地修改) + """ + if not cls.serialize_maps: + return + from src.modules.dictionary import dict_utils + for field_name, dict_code in cls.serialize_maps.items(): + value_map = dict_utils.get_value_map(dict_code) + text_key = f"{field_name}Text" + for record in records: + record[text_key] = value_map.get(str(record.get(field_name)), "") diff --git a/src/common/constants/__init__.py b/src/common/constants/__init__.py index 15df3b4..99ce8ee 100644 --- a/src/common/constants/__init__.py +++ b/src/common/constants/__init__.py @@ -2,7 +2,9 @@ constants 常量包 ============================================================= - - common.py 通用常量(分页、默认密码、消息文本、序列化排除字段) - - dictionary_constants.py 字典相关常量(原 application/constants/dict.py) - - error_code.py 错误码定义(后续阶段建设) + - common.py 通用常量(分页、默认密码、消息文本、序列化排除字段) + - error_code.py 错误码定义(后续阶段建设) + +说明:数据字典的种子数据已迁至 scripts/seed_dict.py 的 DICT_SEED(字典数据入库后, +运行时展示一律通过 src/modules/dictionary/dict_utils 从字典表读取)。 """ diff --git a/src/common/constants/common.py b/src/common/constants/common.py index df85b4f..3461630 100644 --- a/src/common/constants/common.py +++ b/src/common/constants/common.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 珊瑚云团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 珊瑚云团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 diff --git a/src/common/constants/dictionary_constants.py b/src/common/constants/dictionary_constants.py deleted file mode 100644 index 6e23df8..0000000 --- a/src/common/constants/dictionary_constants.py +++ /dev/null @@ -1,195 +0,0 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 珊瑚云团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 珊瑚云团队 荣誉出品 -# +====================================================================== -# | 版权和免责声明: -# | 一、知识产权声明 -# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 -# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, -# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 -# | 二、使用限制与禁止条款 -# | 您理解并同意,禁止将本产品用于以下任何行为: -# | 1. 任何违反中华人民共和国法律法规的用途; -# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; -# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 -# | 三、责任豁免 -# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 -# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 -# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 -# | 四、违约与法律责任 -# | 如发现任何违反本声明的行为,本团队有权: -# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; -# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; -# | 3. 保留进一步采取刑事控告等法律措施的权利。 -# | 五、许可范围说明 -# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 -# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 -# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 -# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 -# | 成的一切经济损失及商誉损害。 -# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 -# | 许可协议》附件为准。 -# +====================================================================== - -""" -系统常量定义模块 -============================================================= - -该模块定义了系统中使用的各种常量字典,主要用于: -1. 将数据库中的数字/代码值转换为可读的文本标签 -2. 为前端下拉框、单选框、复选框等提供选项数据 -3. 统一管理系统中所有枚举值的映射关系 - -所有常量均为字典类型,键为存储值,值为显示文本。 -""" - -# ==================== 基础信息常量 ==================== - -# 性别映射字典 -# 用于用户信息中的性别字段显示 -GENDER_LIST = { - 1: "男", # 男性 - 2: "女", # 女性 - 3: "保密", # 保密/未公开 -} - -# ==================== 通用状态常量 ==================== - -# 状态映射字典 -# 用于系统中各类数据的状态字段显示 -# 1-正常(启用、有效、激活等状态) -# 2-禁用(停用、无效、冻结等状态) -STATUS_LIST = { - 1: "正常", # 正常状态 - 2: "禁用", # 禁用状态 -} - -# ==================== 参数相关常量 ==================== - -# 参数类型映射字典 -# 用于系统参数管理中的参数类型字段显示 -# 0-系统参数(系统级配置,影响系统运行) -# 1-业务参数(业务级配置,影响业务逻辑) -PARAM_TYPE_LIST = { - 0: "系统参数", # 系统参数 - 1: "业务参数", # 业务参数 -} - -# ==================== 友情链接常量 ==================== - -# 友链类型映射字典 -# 用于区分友情链接的不同合作性质 -LINK_TYPE_LIST = { - 1: "友情链接", # 普通友情链接 - 2: "合作伙伴" # 合作伙伴链接(关系更紧密) -} - -# 友链形式映射字典 -# 用于指定友情链接的展示形式 -LINK_FORM_LIST = { - 1: "文字链接", # 纯文本形式的链接 - 2: "图片链接", # 带有Logo/图片的链接 -} - -# ==================== 配置相关常量 ==================== - -# 配置项类型映射字典 -# 用于定义系统配置项的数据类型和对应的输入控件 -CONFIG_ITEM_TYPE_LIST = { - "text": "单行文本", # 普通单行文本输入框 - "textarea": "多行文本", # 多行文本域 - "ueditor": "富文本编辑器", # 富文本编辑器(支持图文排版) - "date": "日期", # 日期选择器 - "datetime": "时间", # 日期时间选择器 - "number": "数字", # 数字输入框 - "select": "下拉框", # 下拉选择框 - "radio": "单选框", # 单选按钮组 - "checkbox": "复选框", # 复选框组(多选) - "image": "单张图片", # 单张图片上传 - "images": "多张图片", # 多张图片上传 - "password": "密码", # 密码输入框 - "icon": "字体图标", # 字体图标选择器 - "file": "单个文件", # 单个文件上传 - "files": "多个文件", # 多个文件上传 - "hidden": "隐藏", # 隐藏字段 - "readonly": "只读文本", # 只读文本显示 -} - -# ==================== 地区相关常量 ==================== - -# 城市级别映射字典 -# 用于标识行政区划的级别 -CITY_LEVEL_LIST = { - 1: "省份", # 省级行政区(省、自治区、直辖市) - 2: "城市", # 地级行政区(市、自治州) - 3: "县区", # 县级行政区(区、县、县级市) - 4: "街道" # 乡级行政区(街道、镇、乡) -} - -# 通知类型:1-通知 2-公告 -NOTICE_TYPE_LIST = { - 1: "通知", - 2: "公告", -} - -# ==================== 操作日志相关常量 ==================== - -# 操作日志来源映射字典 -# 用于标识操作日志的请求来源 -OPERATION_LOG_SOURCE_LIST = { - 0: "后台系统", # 后台管理系统操作 - 1: "手机站", # 手机端/移动端操作 - 2: "客户端", # PC客户端/应用程序操作 - 3: "其他", # 其他来源 -} - -# 操作日志类型映射字典 -# 用于标识操作日志的具体操作类型 -OPERATION_LOG_TYPE_LIST = { - 1: "登录", - 2: "退出", - 3: "新增", - 4: "修改", - 5: "删除", - 6: "查询", - 7: "导出", - 8: "导入", - 9: "提交", - 10: "撤销", - 11: "审核", - 12: "强退", - 13: "开账号", - 14: "清空", - 15: "授权", - 16: "缓存", - 17: "生成代码", - 18: "其他", -} - -# ==================== 登录日志相关常量 ==================== - -# 登录日志类型映射字典 -# 用于标识登录日志的操作类型 -LOGIN_LOG_TYPE_LIST = { - 1: "登录", - 2: "退出", -} - -# ==================== 文章相关常量 ==================== - -# 文章状态映射字典 -# 用于文章管理中的状态字段显示 -# 0-正常(已发布、上架状态) -# 1-下架(已下架、草稿状态) -ARTICLE_STATUS_LIST = { - 0: "正常", # 正常/已发布 - 1: "下架", # 下架/草稿 -} diff --git a/src/common/forms/base_form.py b/src/common/forms/base_form.py index aa4741e..4ebb1db 100644 --- a/src/common/forms/base_form.py +++ b/src/common/forms/base_form.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 diff --git a/src/common/forms/fields.py b/src/common/forms/fields.py index 7f9c4ff..2901461 100644 --- a/src/common/forms/fields.py +++ b/src/common/forms/fields.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 diff --git a/src/common/utils/captcha.py b/src/common/utils/captcha.py index 4db50d6..15da725 100644 --- a/src/common/utils/captcha.py +++ b/src/common/utils/captcha.py @@ -1,16 +1,3 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 -# +====================================================================== - """ 验证码生成与验证模块 ============================================================= @@ -74,7 +61,12 @@ from config import ( # ============================================================ def get_random_color(): - """获取随机颜色RGB(使用secrets更安全)""" + """ + 获取随机颜色RGB值 + + Returns: + tuple: (R, G, B) 三个分量,取值范围均为 0-255 + """ return ( secrets.randbelow(256), secrets.randbelow(256), @@ -83,7 +75,15 @@ def get_random_color(): def generate_image(length): - """生成验证码图片 + """ + 生成验证码图片 + + 流程说明: + 1. 从配置的字符集中随机抽取指定长度的字符 + 2. 创建浅灰色背景画布 + 3. 逐个绘制字符,每个字符位置带有随机偏移 + 4. 绘制随机干扰线和噪点 + 5. 应用边缘增强滤镜 Args: length: 验证码长度 @@ -91,13 +91,13 @@ def generate_image(length): Returns: tuple: (PIL.Image对象, 验证码字符串) """ - # 从字符集随机抽取 + # 从字符集随机抽取指定长度的验证码 code = ''.join(secrets.choice(CAPTCHA_CHARS) for _ in range(length)) - # 创建画布(浅灰色背景,减少对比度干扰) + # 创建浅灰色背景画布,减少对比度干扰 image = Image.new('RGB', (CAPTCHA_IMAGE_WIDTH, CAPTCHA_IMAGE_HEIGHT), color=(240, 240, 240)) - # 加载字体 + # 加载字体文件,加载失败时使用默认字体 try: font = ImageFont.truetype(CAPTCHA_FONT_PATH, size=CAPTCHA_FONT_SIZE) except Exception: @@ -105,7 +105,7 @@ def generate_image(length): draw = ImageDraw.Draw(image) - # 绘制验证码字符(随机偏移,防OCR识别) + # 逐个绘制验证码字符,每个字符位置随机偏移以增加识别难度 char_width = (CAPTCHA_IMAGE_WIDTH - 20) // length for i, c in enumerate(code): x = 10 + i * char_width + secrets.randbelow(8) @@ -117,7 +117,7 @@ def generate_image(length): font=font ) - # 绘制干扰线 + # 绘制随机干扰线,防止OCR识别 for _ in range(CAPTCHA_NOISE_LINES): x1 = secrets.randbelow(CAPTCHA_IMAGE_WIDTH) y1 = secrets.randbelow(CAPTCHA_IMAGE_HEIGHT // 2) @@ -125,20 +125,28 @@ def generate_image(length): y2 = secrets.randbelow(CAPTCHA_IMAGE_HEIGHT // 2) + CAPTCHA_IMAGE_HEIGHT // 2 draw.line(((x1, y1), (x2, y2)), fill=get_random_color(), width=1) - # 添加噪点 + # 添加随机噪点,进一步干扰识别 for _ in range(CAPTCHA_NOISE_DOTS): x = secrets.randbelow(CAPTCHA_IMAGE_WIDTH) y = secrets.randbelow(CAPTCHA_IMAGE_HEIGHT) draw.point((x, y), fill=get_random_color()) - # 滤镜增强边缘 + # 应用边缘增强滤镜,使字符更清晰 image = image.filter(ImageFilter.EDGE_ENHANCE) return image, code def get_client_ip(): - """获取客户端真实IP(支持代理)""" + """ + 获取客户端真实IP地址 + + 优先从 X-Forwarded-For 请求头获取真实IP(支持反向代理场景) + 若无代理头,则从 request.remote_addr 获取 + + Returns: + str: 客户端IP地址 + """ from flask import request if request.headers.get('X-Forwarded-For'): return request.headers.get('X-Forwarded-For').split(',')[0].strip() @@ -150,13 +158,25 @@ def get_client_ip(): # ============================================================ def generate_captcha(): - """生成验证码实例 + """ + 生成验证码实例 + + 执行流程: + 1. 获取客户端IP,检查防刷计数 + 2. 超过限制次数则抛出异常 + 3. 生成验证码图片和随机字符串 + 4. 将图片转为 Base64 格式 + 5. 生成唯一ID作为Redis键,存储验证码 + 6. 更新IP访问计数器 Returns: dict: { - "idKey": str, # 验证码唯一标识 - "data": str # base64图片数据 + "idKey": str, # 验证码唯一标识,用于后续验证 + "data": str # base64编码的图片数据 } + + Raises: + Exception: 请求过于频繁时抛出 """ # ============================================================ # 防刷:滑动窗口计数,限制同一IP在窗口期内的请求次数 @@ -165,6 +185,7 @@ def generate_captcha(): rate_key = f"captcha:rate:{client_ip}" count = redis.get(rate_key) + # 检查是否超过频率限制 if count and int(count) >= CAPTCHA_RATE_LIMIT: ttl = redis.ttl(rate_key) remaining = ttl if ttl > 0 else CAPTCHA_RATE_WINDOW @@ -175,19 +196,19 @@ def generate_captcha(): # ============================================================ image, code = generate_image(CAPTCHA_LENGTH) - # 转换为base64 + # 将PIL图片转换为PNG格式的Base64字符串 buffer = BytesIO() image.save(buffer, format='PNG') buf_bytes = buffer.getvalue() data = base64.b64encode(buf_bytes).decode('ascii') - # 生成唯一ID(使用secrets更安全) + # 生成唯一ID作为验证码标识 id_key = secrets.token_urlsafe(24) - # 存储验证码到Redis + # 将验证码存储到Redis,设置过期时间 redis.set(id_key, code, expire=CAPTCHA_EXPIRE) - # 递增计数器,首次时设置过期时间(滑动窗口) + # 更新IP访问计数器,首次访问时设置过期时间实现滑动窗口 if count: redis.incr(rate_key) else: @@ -200,29 +221,37 @@ def generate_captcha(): def verify_captcha(id_key, user_input): - """验证验证码(验证成功后自动删除,一次性使用) + """ + 验证用户输入的验证码 + + 验证逻辑: + 1. 参数为空直接返回False + 2. 从Redis获取存储的验证码 + 3. 不存在则返回False + 4. 转为大写后比较(大小写不敏感) + 5. 验证通过后立即删除,确保一次性使用 Args: - id_key: 验证码ID - user_input: 用户输入的验证码 + id_key: 验证码唯一标识(由 generate_captcha 返回) + user_input: 用户输入的验证码字符串 Returns: - bool: 验证是否通过 + bool: True-验证通过,False-验证失败 """ if not id_key or not user_input: return False - # 从Redis获取验证码 + # 从Redis获取存储的验证码 stored_code = redis.get(id_key) if not stored_code: return False - # 转为大写比较(大小写不敏感) + # 转为大写后比较,实现大小写不敏感 stored_code = str(stored_code).upper().strip() user_input = str(user_input).upper().strip() - # 验证通过后删除(一次性使用,防止重放攻击) + # 验证通过后删除验证码,确保一次性使用防止重放攻击 if stored_code == user_input: redis.delete(id_key) return True diff --git a/src/common/utils/case_converter.py b/src/common/utils/case_converter.py index cc286a3..7033ceb 100644 --- a/src/common/utils/case_converter.py +++ b/src/common/utils/case_converter.py @@ -36,6 +36,7 @@ def convert_camel_case(snake_str): if not snake_str: return '' + # 按下划线分割后,首段保持不变,后续各段首字母大写并拼接 components = snake_str.split('_') return components[0] + ''.join(_capitalize_first_letter(x) for x in components[1:]) @@ -65,6 +66,7 @@ def convert_snake_case(camel_str): result = [] for i, char in enumerate(camel_str): + # 遇到大写字母且不是首字符时,添加下划线前缀并转为小写 if char.isupper() and i > 0: result.append('_') result.append(char.lower()) @@ -95,6 +97,7 @@ def convert_pascal_case(snake_str): if not snake_str: return '' + # 按下划线分割后,每一段首字母大写并拼接 components = snake_str.split('_') return ''.join(_capitalize_first_letter(x) for x in components) @@ -119,9 +122,11 @@ def convert_dict_keys(data, converter=convert_camel_case): >>> convert_dict_keys(data) [{'userName': '张三'}, {'userName': '李四'}] """ + # 处理列表类型:递归处理每个元素 if isinstance(data, list): return [convert_dict_keys(item, converter) for item in data] + # 处理字典类型:递归转换所有键名和值 if isinstance(data, dict): result = {} for key, value in data.items(): @@ -129,6 +134,7 @@ def convert_dict_keys(data, converter=convert_camel_case): result[new_key] = convert_dict_keys(value, converter) return result + # 基础类型直接返回 return data @@ -214,7 +220,7 @@ def _is_camel_case(s): 驼峰命名特征: 1. 首字母小写 2. 不包含下划线 - 3. 包含大写字母(至少一个大写字母) + 3. 包含大写字母(至少有一个大写字母) Args: s: 输入字符串 diff --git a/src/common/utils/excel_utils.py b/src/common/utils/excel_utils.py new file mode 100644 index 0000000..5d598b8 --- /dev/null +++ b/src/common/utils/excel_utils.py @@ -0,0 +1,310 @@ +""" +通用 Excel 导入导出工具模块 +============================================================= + +基于 openpyxl 封装 Excel 读写、临时文件生成与清理等通用机制, +供各业务模块(职级、用户等)复用导入/导出能力。 + +设计要点(职责边界): + - 只处理 Excel 机制:读取单元格、写出工作簿、临时文件生命周期 + - 不做业务映射:列定义(表头/列号/必填/解析/格式化)由业务模块声明 + - 导入逐行收集错误并跳过错误行,业务层决定是否整体失败 + - 导出统一写入临时目录并返回可访问 URL,由调用方包 R.ok + +列定义结构(dict): + title: 表头文字(导出表头、导入错误提示) + index: Excel 列号(1 起),导入必填 + key: 数据字典键名(导入结果 / 导出取值),必填 + required: 导入是否必填(默认 False) + parser: 导入解析函数 cell -> value,解析失败抛 ValueError + formatter: 导出格式化函数 value -> 展示值(如 status 1 -> "正常") +""" + +import logging +import os +import random +import time +from typing import Any, Dict, List, Optional, Tuple + +from openpyxl.reader.excel import load_workbook +from openpyxl.workbook import Workbook + +from config import FLASK_TEMP_PATH, FLASK_UPLOAD_DIR +from src.common.utils.file import mkdir +from src.common.utils.rich_text import get_file_url +from src.core.exceptions import BizError +from src.modules.upload import service as upload + +logger = logging.getLogger(__name__) + + +# ============================================================ +# 上传文件解析 +# ============================================================ + +def resolve_uploaded_excel(allowed_exts=".xls|.xlsx") -> str: + """ + 读取当前请求上传的 Excel 文件,返回临时文件绝对路径 + + 复用 upload 服务完成文件保存与格式校验(失败时抛 BizError), + 将站点相对路径转换为文件系统绝对路径供 openpyxl 读取。 + + Args: + allowed_exts: 允许的文件扩展名,默认 .xls|.xlsx + + Returns: + str: 临时文件绝对路径 + + Raises: + BizError: 未上传文件、格式不支持或文件不存在时抛出 + """ + upload_result = upload.upload_file(allowed_exts) + rel_path = upload_result["filePath"] + abs_path = os.path.join(FLASK_UPLOAD_DIR, rel_path.lstrip("/")) + if not os.path.isfile(abs_path): + raise BizError("导入文件不存在") + return abs_path + + +def remove_file(file_path: Optional[str]) -> None: + """ + 安全删除临时文件(导入结束后清理,失败静默忽略) + + Args: + file_path: 文件绝对路径 + """ + if file_path and os.path.isfile(file_path): + try: + os.remove(file_path) + except OSError: + pass + + +# ============================================================ +# 导入:按列定义读取并解析 +# ============================================================ + +def parse_excel_rows( + file_path: str, + columns: List[Dict[str, Any]], + start_row: int = 2, +) -> Tuple[List[Dict[str, Any]], List[str]]: + """ + 读取 Excel 并按列定义解析数据 + + 从 start_row(默认第 2 行,跳过表头)逐行读取,对每行: + 1. 跳过整行为空的记录 + 2. 单元格为空且 required 则记行级错误 + 3. 单元格有值则调用 parser 解析,抛 ValueError 记行级错误 + 含错误的行整行丢弃,不进入返回结果。 + + Args: + file_path: Excel 文件绝对路径 + columns: 列定义列表(title/index/key/required/parser) + start_row: 数据起始行(1 起),默认 2 跳过表头 + + Returns: + tuple: (rows, errors) + rows: 解析成功的字典列表(key 为列 key) + errors: 行级错误信息列表,格式 "第N行 列标题:原因" + """ + wb = None + try: + wb = load_workbook(file_path) + ws = wb.worksheets[0] + + rows, errors = [], [] + for row_no in range(start_row, ws.max_row + 1): + row_data, row_errors = {}, [] + for col in columns: + key = col["key"] + raw = ws.cell(row_no, col["index"]).value + # 空单元格(None 或纯空白)处理 + if raw is None or (isinstance(raw, str) and not raw.strip()): + if col.get("required"): + row_errors.append(f"{col['title']}不能为空") + row_data[key] = None + continue + # 有值则按 parser 解析 + try: + parser = col.get("parser") + row_data[key] = parser(raw) if parser else raw + except ValueError as e: + row_errors.append(f"{col['title']}:{e}") + row_data[key] = None + + # 整行为空 → 跳过 + if all(v is None for v in row_data.values()): + continue + # 行内有错误 → 整行丢弃并收集错误 + if row_errors: + errors.extend(f"第{row_no}行 {msg}" for msg in row_errors) + else: + rows.append(row_data) + return rows, errors + finally: + if wb is not None: + wb.close() + + +# ============================================================ +# 导出:按列定义生成行数据并写文件 +# ============================================================ + +def build_export_rows( + records: List[Dict[str, Any]], + columns: List[Dict[str, Any]], +) -> List[List[Any]]: + """ + 按列定义将记录字典列表转换为 Excel 行数据 + + Args: + records: 数据字典列表(通常为模型 to_dict 后的驼峰键数据) + columns: 列定义列表(title/key/formatter),index 忽略 + + Returns: + list: 行数据列表,每行为按列顺序排列的单元格值 + """ + rows = [] + for data in records: + row = [] + for col in columns: + value = data.get(col["key"]) + formatter = col.get("formatter") + row.append(formatter(value) if formatter else value) + rows.append(row) + return rows + + +def save_export_file( + headers: List[str], + rows: List[List[Any]], + filename_prefix: str = "export", +) -> str: + """ + 将表头与行数据写入 Excel 并保存到临时目录 + + 自动生成唯一文件名(前缀 + 时间戳 + 随机数),保存到 + FLASK_TEMP_PATH 下按日期分目录,并顺带清理过期临时文件。 + + Args: + headers: 表头文字列表 + rows: 行数据列表(与 headers 列数一致) + filename_prefix: 文件名前缀(建议英文,避免 URL 中文编码问题) + + Returns: + str: 生成文件的访问 URL + + Raises: + BizError: 文件写入失败时抛出 + """ + try: + wb = Workbook() + ws = wb.worksheets[0] + ws.append(headers) + for row in rows: + ws.append(row) + + save_path = FLASK_TEMP_PATH + "/" + time.strftime("%Y%m%d") + mkdir(save_path) + file_name = "{}_{}{}.xlsx".format( + filename_prefix, + time.strftime("%Y%m%d%H%M%S"), + "%05d" % random.randint(0, 100), + ) + file_path = save_path + "/" + file_name + wb.save(file_path) + + cleanup_old_temp_files() + return get_file_url(file_path.replace(FLASK_UPLOAD_DIR, "")) + except Exception as e: + logger.error(f"导出 Excel 异常: {str(e)}") + raise BizError("导出失败,请稍后重试") + + +# ============================================================ +# 通用单元格解析 / 格式化 +# ============================================================ + +def parse_int(value) -> int: + """ + 解析整数单元格 + + Args: + value: 单元格原始值 + + Returns: + int: 解析后的整数 + + Raises: + ValueError: 值不是整数时抛出 + """ + return int(value) + + +def parse_status(value) -> int: + """ + 解析状态单元格:'正常'/'禁用' 或 '1'/'2' -> 1/2 + + Args: + value: 单元格原始值 + + Returns: + int: 1(正常)或 2(禁用) + + Raises: + ValueError: 无法识别的状态值时抛出 + """ + text = str(value).strip() + if text == "正常" or text == "1": + return 1 + if text == "禁用" or text == "2": + return 2 + raise ValueError("状态必须为'正常'或'禁用'") + + +def format_status(value) -> str: + """ + 格式化状态值:1/2 -> '正常'/'禁用' + + Args: + value: 状态值(整数或可比较字符串) + + Returns: + str: 中文状态描述,未知值原样返回 + """ + if value == 1: + return "正常" + if value == 2: + return "禁用" + return value + + +# ============================================================ +# 临时文件清理 +# ============================================================ + +def cleanup_old_temp_files(keep_days: int = 7) -> None: + """ + 清理临时目录中的过期文件 + + 删除临时目录(FLASK_TEMP_PATH)下超过指定天数的旧文件, + 防止导出产物与废弃上传文件长期累积占用磁盘。 + + Args: + keep_days: 保留天数,默认 7 天 + """ + try: + cutoff = time.time() - keep_days * 86400 + if not os.path.isdir(FLASK_TEMP_PATH): + return + for root, _, files in os.walk(FLASK_TEMP_PATH): + for name in files: + fp = os.path.join(root, name) + try: + if os.path.getmtime(fp) < cutoff: + os.remove(fp) + except OSError: + pass + except Exception as e: + logger.warning(f"清理过期临时文件异常: {e}") diff --git a/src/common/utils/file.py b/src/common/utils/file.py index 956565c..eb6d9b3 100644 --- a/src/common/utils/file.py +++ b/src/common/utils/file.py @@ -1,44 +1,3 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 珊瑚云团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 珊瑚云团队 荣誉出品 -# +====================================================================== -# | 版权和免责声明: -# | 一、知识产权声明 -# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 -# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, -# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 -# | 二、使用限制与禁止条款 -# | 您理解并同意,禁止将本产品用于以下任何行为: -# | 1. 任何违反中华人民共和国法律法规的用途; -# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; -# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 -# | 三、责任豁免 -# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 -# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 -# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 -# | 四、违约与法律责任 -# | 如发现任何违反本声明的行为,本团队有权: -# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; -# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; -# | 3. 保留进一步采取刑事控告等法律措施的权利。 -# | 五、许可范围说明 -# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 -# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 -# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 -# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 -# | 成的一切经济损失及商誉损害。 -# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 -# | 许可协议》附件为准。 -# +====================================================================== - """ 文件目录操作工具模块 ============================================================= @@ -62,7 +21,6 @@ import os logger = logging.getLogger(__name__) - # 常见文件类型的魔术字节签名 # 格式:扩展名 -> [签名列表],签名为 (offset, bytes) 元组 MAGIC_SIGNATURES = { @@ -130,7 +88,6 @@ def verify_file_magic(file_stream, ext_with_dot): def mkdir(path): """ 递归创建目录 - ============================================================= 检查指定路径的目录是否存在,如果不存在则递归创建所有必需的目录。 用于确保文件上传等操作前目标目录已存在。 @@ -154,25 +111,17 @@ def mkdir(path): 2. 如果目录已存在,不会重复创建 3. 需要有相应的文件系统权限 """ - - # 去除首位空格 - # 说明:消除用户输入中可能包含的前后空格,避免路径错误 + # 去除首位空格,消除用户输入中可能包含的前后空格,避免路径错误 path = path.strip() - # 去除尾部 \ 符号 - # 说明:Windows系统路径可能以\结尾,去除后保持路径格式统一 + # 去除尾部反斜杠,保持路径格式统一 path = path.rstrip("\\") - # 判断路径是否存在 - # 函数:os.path.exists(path) - # 说明:检查指定路径的文件或目录是否存在 + # 检查路径是否存在 isExists = os.path.exists(path) if not isExists: - # 如果不存在则创建目录 - # 函数:os.makedirs(path) - # 说明:递归创建目录,如果中间目录不存在也会一并创建 - # 例如:创建 /a/b/c 时,如果 /a 和 /a/b 都不存在,会一并创建 + # 递归创建目录,中间目录不存在时一并创建 os.makedirs(path) logger.debug(f'{path} 创建成功') return True @@ -186,7 +135,6 @@ def mkdir(path): class FileUtils: """ 文件操作工具类 - ============================================================= 提供更多常用的文件和目录操作功能,封装为类方法便于调用。 包含文件读写、路径处理、文件信息获取等实用功能。 @@ -205,7 +153,6 @@ class FileUtils: def get_file_size(file_path): """ 获取文件大小 - ============================================================= 参数: file_path (str): 文件路径 @@ -221,7 +168,6 @@ class FileUtils: def get_file_extension(filename): """ 获取文件扩展名 - ============================================================= 参数: filename (str): 文件名或文件路径 @@ -236,7 +182,6 @@ class FileUtils: def get_file_name_without_ext(filename): """ 获取不带扩展名的文件名 - ============================================================= 参数: filename (str): 文件名或文件路径 @@ -251,7 +196,6 @@ class FileUtils: def safe_delete(file_path): """ 安全删除文件 - ============================================================= 参数: file_path (str): 文件路径 @@ -272,7 +216,6 @@ class FileUtils: def read_file(file_path, encoding='utf-8'): """ 读取文件内容 - ============================================================= 参数: file_path (str): 文件路径 @@ -292,7 +235,6 @@ class FileUtils: def write_file(file_path, content, encoding='utf-8', mode='w'): """ 写入文件内容 - ============================================================= 参数: file_path (str): 文件路径 @@ -320,7 +262,6 @@ class FileUtils: def list_files(directory, pattern=None): """ 列出目录下的所有文件 - ============================================================= 参数: directory (str): 目录路径 @@ -345,7 +286,6 @@ class FileUtils: def get_file_info(file_path): """ 获取文件详细信息 - ============================================================= 参数: file_path (str): 文件路径 @@ -372,7 +312,6 @@ class FileUtils: def _format_size(size_bytes): """ 格式化文件大小显示 - ============================================================= 参数: size_bytes (int): 文件大小(字节) diff --git a/src/common/utils/json.py b/src/common/utils/json.py index 4a542dd..f59cbef 100644 --- a/src/common/utils/json.py +++ b/src/common/utils/json.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 diff --git a/src/common/utils/model_utils.py b/src/common/utils/model_utils.py index 53bd2ef..fe969fb 100644 --- a/src/common/utils/model_utils.py +++ b/src/common/utils/model_utils.py @@ -88,3 +88,56 @@ def models_to_list(models, exclude=None, convert_case=True): model_to_dict(model, exclude, convert_case) for model in models ] + + +def calc_pages(total, page_size) -> int: + """ + 计算总页数 + + Args: + total: 总记录数 + page_size: 每页大小 + + Returns: + int: 总页数,无数据时返回 0 + """ + if not total or total <= 0: + return 0 + if not page_size or page_size < 1: + return 0 + return (total + page_size - 1) // page_size + + +def build_page_data(items, total, page, page_size, pages=None, enrich=None) -> dict: + """ + 构建统一分页返回数据 + + 将 ORM 对象列表序列化为字典列表,并组装成分页结构: + {"records", "total", "size", "current", "pages"}。 + + Args: + items: ORM 对象列表,或已序列化/富化后的字典列表 + total: 总记录数 + page: 当前页码 + page_size: 每页大小 + pages: 总页数(可选,缺省时由 calc_pages 计算) + enrich: 可选字段富化钩子,接收字典列表并返回处理后的列表 + + Returns: + dict: 分页数据 + """ + if not isinstance(pages, int): + pages = calc_pages(total, page_size) + + records = models_to_list(items) if items and not isinstance(items[0], dict) else list(items or []) + + if enrich: + records = enrich(records) or records + + return { + 'records': records, + 'total': total, + 'size': page_size, + 'current': page, + 'pages': pages, + } diff --git a/src/common/utils/regular.py b/src/common/utils/regular.py index 24fb8d8..ec5cd6c 100644 --- a/src/common/utils/regular.py +++ b/src/common/utils/regular.py @@ -1,44 +1,3 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 珊瑚云团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 珊瑚云团队 荣誉出品 -# +====================================================================== -# | 版权和免责声明: -# | 一、知识产权声明 -# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 -# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, -# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 -# | 二、使用限制与禁止条款 -# | 您理解并同意,禁止将本产品用于以下任何行为: -# | 1. 任何违反中华人民共和国法律法规的用途; -# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; -# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 -# | 三、责任豁免 -# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 -# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 -# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 -# | 四、违约与法律责任 -# | 如发现任何违反本声明的行为,本团队有权: -# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; -# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; -# | 3. 保留进一步采取刑事控告等法律措施的权利。 -# | 五、许可范围说明 -# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 -# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 -# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 -# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 -# | 成的一切经济损失及商誉损害。 -# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 -# | 许可协议》附件为准。 -# +====================================================================== - """ 表单错误处理模块 ============================================================= diff --git a/src/common/utils/rich_text.py b/src/common/utils/rich_text.py index f1a37c6..6e5eb94 100644 --- a/src/common/utils/rich_text.py +++ b/src/common/utils/rich_text.py @@ -8,11 +8,12 @@ - save_content: 处理富文本HTML内容,提取并保存其中的图片文件 """ +import html import logging import os import re import time -import html +from urllib.parse import urlparse from config import FLASK_FILE_URL, FLASK_UPLOAD_DIR, FLASK_TEMP_PATH from src.common.utils.file import mkdir @@ -35,38 +36,77 @@ def get_file_url(path): return FLASK_FILE_URL + path +def _has_scheme(url): + """ + 判断 URL 是否带协议头(http/https/ftp/data 等) + + 用于区分「外链绝对地址」与「本站相对路径」:相对路径(如 /images/xxx)无协议头, + 编辑回传时原样保留;带协议头的绝对地址视为外链,拒绝保存。 + + Args: + url: URL 字符串 + + Returns: + bool: True 表示带协议头 + """ + return bool(urlparse(url).scheme) + + def save_file(url, directory): """ 保存文件到指定目录 处理本站临时文件迁移:将临时目录中的文件移动到正式存储目录。 + 对已是正式存储路径的文件(不含 /temp)不做迁移,去掉本站文件前缀后原样保留, + 避免编辑回传已保存路径时被误清空。 + + 安全约束: + - 仅对临时文件(含 /temp)做迁移;迁移前校验源文件必须位于 + FLASK_TEMP_PATH 内(realpath 规范化后校验,防路径穿越) + - 带协议头的绝对地址视为外链,一律拒绝;相对路径视为本站路径,原样保留 Args: url: 文件的完整URL或相对路径 directory: 目标子目录名称,用于分类存储 Returns: - 文件的相对路径,失败时返回空字符串 + 文件的相对路径;地址为空时返回提示文案,外链/无法识别的临时文件返回空字符串 """ if not url: return "文件地址不能为空" - if url.find(FLASK_FILE_URL) != -1: - if url.find("/temp") != -1: - path = FLASK_UPLOAD_DIR + "/" + directory + "/" + time.strftime('%Y%m%d') - mkdir(path) - - old_path = url.replace(FLASK_FILE_URL, FLASK_UPLOAD_DIR) - new_path = FLASK_UPLOAD_DIR + "/" + directory + old_path.replace(FLASK_TEMP_PATH, "") - - os.rename(old_path, new_path) - return new_path.replace(FLASK_UPLOAD_DIR, "") - else: - return url.replace(FLASK_FILE_URL, "") - else: + # 不含临时目录标记 → 已是正式路径,无需迁移 + if "/temp" not in url: + # 本站文件前缀 → 去掉前缀返回规范化路径 + if FLASK_FILE_URL and url.startswith(FLASK_FILE_URL): + return url[len(FLASK_FILE_URL):] + # 相对路径(无协议头)→ 原样保留,避免编辑回传时清空已保存的值 + if not _has_scheme(url): + return url + # 绝对外链 → 拒绝 logger.warning(f"远程文件暂不支持保存: {url}") + return "" - return "" + # 含 /temp:新上传的临时文件,需迁移到正式目录 + if not FLASK_FILE_URL or not url.startswith(FLASK_FILE_URL): + logger.warning(f"远程文件暂不支持保存: {url}") + return "" + + # 防路径穿越:源文件真实路径必须位于临时目录内(realpath 解析 .. 后比较) + rel = url[len(FLASK_FILE_URL):] + old_path = FLASK_UPLOAD_DIR + rel + temp_dir = os.path.realpath(FLASK_TEMP_PATH) + old_real = os.path.realpath(old_path) + if not (old_real.startswith(temp_dir + os.sep) or old_real == temp_dir): + logger.warning(f"非本站临时文件或疑似路径穿越,拒绝保存: {url}") + return "" + + path = FLASK_UPLOAD_DIR + "/" + directory + "/" + time.strftime('%Y%m%d') + mkdir(path) + + new_path = FLASK_UPLOAD_DIR + "/" + directory + old_path.replace(FLASK_TEMP_PATH, "") + os.rename(old_path, new_path) + return new_path.replace(FLASK_UPLOAD_DIR, "") def save_content(content, title, directory): diff --git a/src/common/utils/time_utils.py b/src/common/utils/time_utils.py new file mode 100644 index 0000000..0531f54 --- /dev/null +++ b/src/common/utils/time_utils.py @@ -0,0 +1,22 @@ +""" +时间工具模块 +============================================================= + +统一时间生成:北京时间(UTC+8)、naive、秒精度。 + +背景: + create_time/update_time 之前用 datetime.now(tz=timezone.utc) 生成, + 存入 MySQL DATETIME(无时区信息)的是 UTC 墙钟,与北京(UTC+8)相差 8 小时。 + 现统一存"北京时间墙钟",避免入库与展示差 8 小时;同时去掉微秒, + 入库更干净(2026-08-17 09:48:35 而非 2026-08-17 09:48:35.635732)。 +""" + +from datetime import datetime, timezone, timedelta + +# 北京时间时区(UTC+8)。中国无夏令时,固定偏移即可 +BEIJING_TZ = timezone(timedelta(hours=8)) + + +def now() -> datetime: + """返回当前北京时间(naive,秒精度),用于 create_time/update_time 等数据库字段。""" + return datetime.now(tz=BEIJING_TZ).replace(tzinfo=None, microsecond=0) diff --git a/src/core/database.py b/src/core/database.py index ab058dc..83a8277 100644 --- a/src/core/database.py +++ b/src/core/database.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -56,30 +56,33 @@ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() -def commit_independently(obj): +def commit_independently(model_class, data: dict): """ - 使用独立session提交对象,不受请求事务影响 + 使用独立session写入记录,不受请求事务影响 日志等审计数据无论业务成功或失败都应持久化, 因此需要绕过 AOP 事务拦截器的 commit/rollback 机制。 - 实现方式:基于现有引擎创建独立 session,将对象添加并提交, - 完成后关闭 session,不影响请求主 session。 + 在独立 session 内部创建模型实例并提交,与请求主 session 完全隔离, + 避免 scoped session identity map 冲突。 Args: - obj: SQLAlchemy 模型实例(未添加到任何 session) + model_class: SQLAlchemy 模型类(如 LoginLog) + data: 传给模型构造函数的字段字典 - Raises: - Exception: 数据库操作异常 + Returns: + model_class: 创建并提交后的模型实例(已 detach),失败返回 None """ from sqlalchemy.orm import sessionmaker # 复用 Flask-SQLAlchemy 已创建的引擎,避免额外连接池 - independent_session = sessionmaker(bind=db.engine)() + independent_session = sessionmaker(bind=db.engine, expire_on_commit=False)() try: + obj = model_class(**data) independent_session.add(obj) independent_session.commit() + return obj except Exception: independent_session.rollback() raise diff --git a/src/core/exceptions.py b/src/core/exceptions.py index 4a56125..3b48a33 100644 --- a/src/core/exceptions.py +++ b/src/core/exceptions.py @@ -20,7 +20,22 @@ from src.common.constants.common import MESSAGE_FAIL class BizError(Exception): - """业务异常:统一转为 R.failed 响应""" + """ + 业务异常基类 + + 用于表达业务层面的失败场景,由全局异常处理器统一捕获并转换为 R.failed 响应。 + 业务层通过抛出此异常替代返回 R.failed,保持服务函数的纯函数特性。 + + Attributes: + msg: 错误提示信息 + code: 业务错误码,默认 1 + data: 附加数据,可选 + kwargs: 额外参数,用于扩展 + + Example: + raise BizError("用户不存在") + raise BizError("参数校验失败", code=10001, data={"field": "username"}) + """ def __init__(self, msg: str = MESSAGE_FAIL, code: int = 1, data=None, **kwargs): self.msg = msg @@ -31,14 +46,35 @@ class BizError(Exception): class NotFoundError(BizError): - """资源不存在(默认提示)""" + """ + 资源不存在异常 + + 用于表示请求的资源未找到的场景,继承自 BizError。 + 默认错误信息为"资源不存在"。 + + Example: + raise NotFoundError() + raise NotFoundError("岗位不存在") + """ def __init__(self, msg: str = "资源不存在", **kwargs): super().__init__(msg=msg, **kwargs) class PermissionDeniedError(BizError): - """权限不足""" + """ + 权限不足异常 + + 用于表示当前用户无权执行操作或访问资源的场景。 + 默认错误码为 403,与 HTTP 状态码对应。 + + Attributes: + code: 错误码,固定为 403 + + Example: + raise PermissionDeniedError() + raise PermissionDeniedError("需要管理员权限") + """ def __init__(self, msg: str = "权限不足", code: int = 403, **kwargs): super().__init__(msg=msg, code=code, **kwargs) diff --git a/src/core/jwt.py b/src/core/jwt.py index f58f064..0047295 100644 --- a/src/core/jwt.py +++ b/src/core/jwt.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 珊瑚云团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 珊瑚云团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -69,7 +69,7 @@ import jwt from flask import request from jwt import exceptions -from config import JWT_SALT, JWT_EXPIRE_MINUTES +from config import JWT_SALT, JWT_EXPIRE_MINUTES, JWT_ABSOLUTE_TIMEOUT_MINUTES # 配置日志记录器 logger = logging.getLogger(__name__) @@ -112,8 +112,11 @@ _validate_key_strength(JWT_SALT) # JWT算法配置 JWT_ALGORITHM = 'HS256' -# 默认令牌过期时间(分钟),从配置读取 -DEFAULT_TIMEOUT_MINUTES = JWT_EXPIRE_MINUTES +# 默认令牌过期时间(分钟),取"绝对有效期":即使一直活跃,令牌最长有效时间 +DEFAULT_TIMEOUT_MINUTES = JWT_ABSOLUTE_TIMEOUT_MINUTES + +# 滑动会话空闲超时(分钟):用户此时间内无请求则会话过期;每请求重置 Redis TTL 续期 +SESSION_IDLE_MINUTES = JWT_EXPIRE_MINUTES # ============================================================= @@ -437,9 +440,12 @@ def get_access_token(): if not auth_header: return False, None, '缺少 Authorization 头' - # 处理 Bearer 前缀 - # 说明:移除 "Bearer " 前缀并去除首尾空格,获取纯净 token - access_token = auth_header.replace('Bearer ', "").strip() + # 处理 Bearer 前缀(RFC 6750:scheme 大小写不敏感) + # 说明:匹配 "Bearer " 前缀(忽略大小写)并去除首尾空格,获取纯净 token + if auth_header.lower().startswith("bearer "): + access_token = auth_header[7:].strip() + else: + access_token = auth_header.strip() if not access_token: return False, None, 'token 为空' @@ -629,6 +635,73 @@ def is_token_blacklisted(token: str) -> bool: return redis.exists(key) > 0 except Exception as e: - logger.error(f"Token黑名单检查异常: {str(e)}") - # Redis不可用时放行,避免阻断所有请求 + logger.error(f"Token黑名单检查异常,按已注销处理(fail-closed): {str(e)}") + # Redis 不可用时 fail-closed:拒绝放行,防止已注销 token 复活 + # 代价:Redis 故障期间所有 token 暂时不可用(受 JWT_EXPIRE_MINUTES 约束) + return True + + +# ============================================================= +# 滑动会话(Sliding Session) +# ============================================================= +# 模型:JWT exp 为"绝对有效期"上限(DEFAULT_TIMEOUT_MINUTES); +# Redis 会话键 TTL 为"空闲超时"(SESSION_IDLE_MINUTES),每请求重置续期。 +# 效果:持续使用永不过期;空闲超过空闲超时需重新登录。 + +# 会话Key前缀,用于Redis中存储登录态 +_SESSION_PREFIX = 'jwt:session:' + + +def _session_key(token: str) -> str: + """生成会话Key(复用Token指纹,避免存储完整Token)""" + return f'{_SESSION_PREFIX}{_token_fingerprint(token)}' + + +def create_session(token: str, idle_minutes: int = None) -> bool: + """ + 创建会话(登录成功时调用) + + 以空闲超时作为会话键TTL,之后每请求通过 touch_session 续期。 + + Args: + token: JWT令牌字符串 + idle_minutes: 空闲超时(分钟),默认 SESSION_IDLE_MINUTES + + Returns: + bool: 是否创建成功 + """ + if idle_minutes is None: + idle_minutes = SESSION_IDLE_MINUTES + try: + from src.core.redis_client import redis + + key = _session_key(token) + return bool(redis.set(key, '1', expire=idle_minutes * 60)) + except Exception as e: + logger.error(f"创建会话失败: {str(e)}") + return False + + +def touch_session(token: str, idle_minutes: int = None) -> bool: + """ + 续期会话(每次有效请求调用) + + 重置会话键TTL为完整空闲超时,实现"活跃不过期"。 + + Args: + token: JWT令牌字符串 + idle_minutes: 空闲超时(分钟),默认 SESSION_IDLE_MINUTES + + Returns: + bool: True-续期成功;False-会话不存在(空闲超时过期)或Redis异常(fail-closed) + """ + if idle_minutes is None: + idle_minutes = SESSION_IDLE_MINUTES + try: + from src.core.redis_client import redis + + key = _session_key(token) + return bool(redis.expire(key, idle_minutes * 60)) + except Exception as e: + logger.error(f"续期会话失败: {str(e)}") return False diff --git a/src/core/mail.py b/src/core/mail.py index f164589..469621d 100644 --- a/src/core/mail.py +++ b/src/core/mail.py @@ -18,6 +18,9 @@ def init_mail(app: Flask): """ 初始化 Flask-Mail 扩展,将 Mail 实例绑定到应用。 + 从 config 模块读取邮件配置参数,设置到 Flask 应用配置中, + 然后通过 mail.init_app(app) 完成扩展初始化。 + Args: app: Flask 应用实例 diff --git a/src/core/middleware.py b/src/core/middleware.py deleted file mode 100644 index f319c90..0000000 --- a/src/core/middleware.py +++ /dev/null @@ -1,813 +0,0 @@ -""" -中间件模块 -============================================================= - -整合自 application/middleware/*.py: - - check_login: 登录校验(JWT + 白名单) - - check_demo: 演示模式拦截 - - transaction: AOP 事务拦截器 - - op_log: 操作日志自动记录(写入器由应用工厂注入) - - error_handler: 全局异常处理 - -依赖约束:本模块仅依赖 core/common,不依赖 modules 层。 -""" - -import logging -import fnmatch -from flask import g, request, current_app -from config import LOGIN_WHITELIST_EXACT, LOGIN_WHITELIST_DEBUG, LOGIN_WHITELIST_PREFIX, LOGIN_WHITELIST_WILDCARD -from src.core import response as R -from src.core.jwt import parse_payload, is_token_blacklisted, get_access_token -from functools import wraps -from config import FLASK_DEMO -from src.core.database import db -import time -import traceback -from werkzeug.exceptions import HTTPException -from src.core.exceptions import BizError - -logger = logging.getLogger(__name__) - - -# ============================================================ -# 登录校验中间件 -# ============================================================ - - -# 获取日志记录器 - - -def is_whitelisted(path: str) -> bool: - """ - 检查请求路径是否在白名单中 - - 支持三种匹配模式: - 1. 精确匹配:路径完全相等 - 2. 前缀匹配:路径以指定前缀开头 - 3. 通配符匹配:支持 *(匹配任意字符)和 ?(匹配单个字符) - - Args: - path: 请求路径(如:/job/sendSms) - - Returns: - bool: True-在白名单中,False-不在白名单中 - """ - # ============================================================ - # 1. 精确匹配检查 - # ============================================================ - # 示例:LOGIN_WHITELIST_EXACT=/login,/captcha - # 当 path 为 /login 或 /captcha 时返回 True - exact_urls = LOGIN_WHITELIST_EXACT - if exact_urls: - for url in exact_urls.split(","): - if url.strip() and url.strip() == path: - if LOGIN_WHITELIST_DEBUG: - logger.debug(f"精确匹配白名单: {path}") - return True - - # ============================================================ - # 2. 前缀匹配检查 - # ============================================================ - # 示例:LOGIN_WHITELIST_PREFIX=/job/sendSms,/public - # 当 path 以 /job/sendSms 或 /public 开头时返回 True - prefix_urls = LOGIN_WHITELIST_PREFIX - if prefix_urls: - for prefix in prefix_urls.split(","): - prefix = prefix.strip() - if prefix and path.startswith(prefix): - if LOGIN_WHITELIST_DEBUG: - logger.debug(f"前缀匹配白名单: {path} 匹配 {prefix}") - return True - - # ============================================================ - # 3. 通配符匹配检查 - # ============================================================ - # 示例:LOGIN_WHITELIST_WILDCARD=/job/*/send*,/api/v1/*/public* - # 当 path 匹配通配符模式时返回 True - # - * 匹配任意多个字符 - # - ? 匹配单个字符 - wildcard_patterns = LOGIN_WHITELIST_WILDCARD - if wildcard_patterns: - for pattern in wildcard_patterns.split(","): - pattern = pattern.strip() - if pattern and fnmatch.fnmatch(path, pattern): - if LOGIN_WHITELIST_DEBUG: - logger.debug(f"通配符匹配白名单: {path} 匹配 {pattern}") - return True - - return False - - -def check_login(): - """ - 用户登录验证中间件 - - 功能说明: - 1. 检查请求路径是否在白名单中,白名单路径直接放行 - 2. OPTIONS请求(CORS预检)直接放行 - 3. 验证JWT Token的有效性 - 4. Token验证通过后将用户信息存储到g对象中 - - 白名单配置(在.env文件中): - - LOGIN_WHITELIST_EXACT: 精确匹配白名单 - - LOGIN_WHITELIST_PREFIX: 前缀匹配白名单 - - LOGIN_WHITELIST_WILDCARD: 通配符匹配白名单 - - Returns: - None: 验证通过,继续处理请求 - R.failed: 验证失败,返回错误响应(401未登录) - - 使用示例: - # 在app.py中注册中间件 - @app.before_request - def before_request(): - result = check_login() - if result: - return result - """ - current_path = request.path - - # ============================================================ - # 步骤1:检查白名单 - # ============================================================ - # 白名单中的路径不需要验证Token(如:登录页、验证码、短信发送接口等) - if is_whitelisted(current_path): - if LOGIN_WHITELIST_DEBUG: - logger.debug(f"白名单路径,跳过登录验证: {current_path}") - return None - - # ============================================================ - # 步骤2:OPTIONS请求放行 - # ============================================================ - # OPTIONS请求是CORS预检请求,不需要验证Token - if request.method == "OPTIONS": - if LOGIN_WHITELIST_DEBUG: - logger.debug(f"OPTIONS请求,跳过登录验证: {current_path}") - return None - - # ============================================================ - # 步骤3:获取并验证Token - # ============================================================ - success, access_token, msg = get_access_token() - - # 如果没有Token - if not success: - logger.warning(f"未登录访问: {current_path}, IP: {request.remote_addr}") - return R.failed(code=401, msg="请先登录") - - # JWT解密验证 - result = parse_payload(access_token) - - # 验证失败处理 - if result.get("code") != 0: - logger.warning(f"Token验证失败: {result.get('msg')}, 路径: {current_path}") - return R.failed(code=401, msg=result.get("msg", "Token无效,请重新登录")) - - # ============================================================ - # 步骤3.5:检查Token黑名单 - # ============================================================ - # 退出登录时Token会被加入黑名单,黑名单中的Token即使未过期也应拒绝 - if is_token_blacklisted(access_token): - logger.warning(f"Token已被注销(黑名单): user_id={result.get('data', {}).get('userId')}, 路径: {current_path}") - return R.failed(code=401, msg="Token已失效,请重新登录") - - # ============================================================ - # 步骤4:存储用户信息 - # ============================================================ - # 将用户信息存储到g对象中,供后续的视图函数和权限验证使用 - if result.get("data"): - g.user_id = result["data"].get("userId") - g.user_info = result["data"] - if LOGIN_WHITELIST_DEBUG: - logger.debug(f"用户登录验证通过: user_id={g.user_id}, 路径={current_path}") - - # 验证通过,返回None继续处理请求 - return None - - -# ============================================================ -# 演示模式拦截 -# ============================================================ - -""" -演示模式拦截装饰器 -============================================================= - -在演示环境(FLASK_DEMO=True)下拦截所有写操作, -返回"演示环境,暂无操作权限"提示。 - -使用方式: - 在 View 路由函数上添加 @check_demo 装饰器, - 替代原有的 if FLASK_DEMO: return R.failed(...) 判断。 -""" - - -def check_demo(func): - """ - 演示模式拦截装饰器 - - 当 FLASK_DEMO=True 时,拦截写操作并返回提示。 - 应放在 @check_permission 之后、路由函数之前。 - - Example: - @position_blue.route('/add', methods=["POST"]) - @check_permission("sys:position:add") - @check_demo - def add(): - result = position.add_position() - return result - """ - - @wraps(func) - def wrapper(*args, **kwargs): - if FLASK_DEMO: - return R.failed("演示环境,暂无操作权限") - return func(*args, **kwargs) - - return wrapper - - -# ============================================================ -# AOP 事务拦截器 -# ============================================================ - -""" -AOP 事务拦截器(企业级实现) - -设计理念: - 1. 零代码侵入:业务代码无需任何事务管理代码 - 2. 自动读写分离:GET 不提交,POST/PUT/DELETE 自动提交 - 3. 统一异常处理:所有异常自动回滚 - 4. 性能监控:记录事务执行时间 - -使用方式: - 在 Flask 应用初始化时调用 register_transaction(app) 即可。 - 业务代码无需任何改动。 - -Example: - # app/__init__.py - from src.core.middleware import register_transaction - - def create_app(): - app = Flask(__name__) - register_transaction(app) # 一行代码搞定所有事务 - return app -""" - - -# ============================================================ -# 核心:AOP 事务拦截器 -# ============================================================ - - -def register_transaction(app): - """ - 注册 AOP 事务拦截器到 Flask 应用 - - 在请求生命周期中自动管理事务: - - before_request: 开启事务标记 - - after_request: 自动提交(POST/PUT/DELETE) - - teardown_request: 异常回滚 + 资源清理 - - Args: - app: Flask 应用实例 - - Example: - register_transaction(app) - """ - - @app.before_request - def begin_transaction(): - """请求开始:记录开始时间,开启事务标记""" - g.request_start_time = time.time() - g.transaction_active = True - - logger.debug(f"请求开始, method: {request.method}, path: {request.path}, " f"ip: {request.remote_addr}") - - @app.after_request - def commit_transaction(response): - """ - 请求结束:自动提交事务 - - 提交规则: - 1. 只对写操作(POST/PUT/DELETE/PATCH)提交 - 2. 只在响应成功(2xx)且业务成功(code=0)时提交 - 3. GET/OPTIONS/HEAD 不提交(只读) - 4. 业务失败(R.failed)不提交,自动回滚 - """ - # 只对写操作处理事务 - if request.method in ("POST", "PUT", "DELETE", "PATCH"): - should_commit = 200 <= response.status_code < 300 - - # 检查业务状态码:R.failed() 返回 HTTP 200 但 code=1/ok=False - if should_commit: - try: - data = response.get_json(silent=True) - if data and data.get("code", 0) != 0: - should_commit = False - except Exception: - pass - - if should_commit: - try: - if db.session.is_active: - db.session.commit() - logger.debug( - f"事务提交成功, method: {request.method}, " - f"path: {request.path}, status: {response.status_code}" - ) - except Exception as e: - db.session.rollback() - logger.error( - f"事务提交失败, method: {request.method}, " f"path: {request.path}, error: {str(e)}", - exc_info=True, - ) - from src.core import response as R - - return R.failed(msg="数据处理失败"), 500 - else: - # 业务失败或非2xx,回滚未提交的变更 - try: - if db.session.is_active: - db.session.rollback() - except Exception: - pass - - # 记录响应日志 - elapsed = time.time() - g.get("request_start_time", time.time()) - logger.debug( - f"请求结束, method: {request.method}, path: {request.path}, " - f"status: {response.status_code}, elapsed: {elapsed:.3f}s" - ) - - return response - - @app.teardown_request - def cleanup_transaction(exception=None): - """ - 请求结束:清理资源 - - 发生异常时自动回滚事务,确保数据一致性。 - """ - if exception is not None: - try: - db.session.rollback() - logger.error( - f"请求异常,事务已回滚, method: {request.method}, " - f"path: {request.path}, error: {str(exception)}", - exc_info=True, - ) - except Exception as e: - logger.error(f"事务回滚失败: {str(e)}", exc_info=True) - - # 清理请求标记 - g.pop("transaction_active", None) - g.pop("request_start_time", None) - - logger.info("AOP 事务拦截器注册成功") - - -# ============================================================ -# 备用:精细控制装饰器(特殊场景使用) -# ============================================================ - - -def transactional(func): - """ - 事务装饰器(备用) - - 适用于需要精细控制事务边界的特殊场景。 - 大多数情况下,使用 AOP 拦截器即可。 - - Example: - @transactional - def complex_business(): - # 精细控制的事务逻辑 - pass - """ - - @wraps(func) - def wrapper(*args, **kwargs): - if g.get("disable_auto_commit", False): - return func(*args, **kwargs) - - try: - result = func(*args, **kwargs) - if not g.get("transaction_active", False): - db.session.commit() - return result - except Exception as e: - db.session.rollback() - logger.error(f"装饰器事务执行失败: {str(e)}", exc_info=True) - raise - - return wrapper - - -def transactional_response(func): - """ - 事务装饰器(带返回 R 对象) - - 用于需要直接返回 R 对象的接口。 - - Example: - @transactional_response - def add_position(): - position.save() - return R.ok(msg="添加成功") - """ - - @wraps(func) - def wrapper(*args, **kwargs): - from src.core import response as R - - try: - result = func(*args, **kwargs) - if not g.get("transaction_active", False): - db.session.commit() - return result - except Exception as e: - db.session.rollback() - logger.error(f"装饰器事务执行失败: {str(e)}", exc_info=True) - return R.failed(msg=f"操作失败: {str(e)}") - - return wrapper - - -# ============================================================ -# 备用:事务上下文管理器(特殊场景使用) -# ============================================================ - - -class TransactionContext: - """ - 事务上下文管理器 - - 用于需要精细控制事务的特殊场景。 - - Example: - with TransactionContext(): - position.save() - user.save() - # 自动提交或回滚 - """ - - def __enter__(self): - g.disable_auto_commit = True - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - g.disable_auto_commit = False - if exc_type is None: - if not g.get("transaction_active", False): - db.session.commit() - else: - db.session.rollback() - logger.error(f"事务上下文回滚: {str(exc_val)}", exc_info=True) - return False - - -# ============================================================ -# 操作日志自动记录(DI:写入器由应用工厂注入) -# ============================================================ - -""" -操作日志自动记录中间件 -============================================================= - -通过 after_request 钩子自动记录写操作(POST/PUT/DELETE/PATCH)日志。 -无需在每个视图函数中手动调用 record_operation_log。 - -记录规则: - - 只记录写操作(POST/PUT/DELETE/PATCH),GET/OPTIONS/HEAD 不记录 - - 只记录成功操作(HTTP 2xx 且业务 code=0) - - 内部调用(定时任务等)不记录 - - 登录/退出/验证码等路径不记录(由 login_log 单独记录) - - 日志记录失败不影响主流程 -""" - - -# 不记录操作日志的路径前缀(登录/退出/验证码等由 login_log 单独记录) -_EXCLUDED_PREFIXES = ("/login", "/captcha", "/logout") - -# HTTP 方法 → 操作日志类型 映射 -# 类型值参考 application/constants/dict.py 的 OPERATION_LOG_TYPE_LIST -_METHOD_LOG_TYPE = { - "POST": 3, # 新增 - "PUT": 4, # 修改 - "DELETE": 5, # 删除 - "PATCH": 4, # 修改 -} - -# 路径后缀 → 操作日志类型 覆盖映射(更精确) -_PATH_ACTION_TYPE = { - "/add": 3, # 新增 - "/update": 4, # 修改 - "/delete": 5, # 删除 - "/status": 4, # 状态变更→修改 - "/batchdelete": 5, # 批量删除 - "/reset": 4, # 重置→修改 - "/save": 4, # 保存→修改 - "/sort": 4, # 排序→修改 -} - - -def register_operation_log(app): - """ - 注册操作日志自动记录中间件 - - 在 after_request 中自动记录写操作日志。 - - Args: - app: Flask 应用实例 - """ - - @app.after_request - def record_op_log(response): - # 只记录写操作 - if request.method not in ("POST", "PUT", "DELETE", "PATCH"): - return response - - # 排除登录/退出/验证码等路径 - path = request.path - if any(path.startswith(prefix) for prefix in _EXCLUDED_PREFIXES): - return response - - # 只记录成功操作(HTTP 2xx 且业务 code=0) - if not (200 <= response.status_code < 300): - return response - - try: - data = response.get_json(silent=True) - if data and data.get("code", 0) != 0: - return response - except Exception: - pass - - # 内部调用不记录 - if getattr(g, "_internal_call", False): - return response - - # 异步记录,不阻塞响应 - try: - _write_log(response) - except Exception as e: - logger.warning(f"记录操作日志失败: {str(e)}") - - return response - - logger.info("操作日志中间件注册成功") - - -def _write_log(response): - """写入操作日志""" - from src.core.security import get_username - from src.common.utils.ip import get_client_ip - from src.common.utils.ip2region import get_ip_location - from src.common.utils.user_agent import parse_user_agent - - writer = current_app.extensions.get("operation_log_writer") - if writer is None: - logger.warning("operation_log_writer 未注入,跳过操作日志") - return - username = get_username() or "system" - client_ip = get_client_ip() - ua_info = parse_user_agent() - - # 确定操作类型 - log_type = _infer_log_type(request.method, request.path) - - # 构建标题:从路径提取模块名,如 /user/add → "用户管理-新增" - title = _build_title(request.path) - - # 请求参数(限制长度,避免存入过大的数据) - param = _get_request_param() - - # 返回结果(限制长度) - result = _get_response_result(response) - - # 请求耗时 - start_time = getattr(g, "request_start_time", None) - consume_time = int((time.time() - start_time) * 1000) if start_time else 0 - - writer( - title=title, - log_type=log_type, - source=0, # OperationLog.Source.ADMIN - status=0, # OperationLog.Status.NORMAL - method=request.method, - request_method=request.endpoint or "", - url=request.path, - param=param, - result=result, - ip=client_ip, - location=get_ip_location(client_ip), - os=ua_info.get("os", "未知"), - browser=ua_info.get("browser", "未知"), - consume_time=consume_time, - error="", - username=username, - ) - - -def _infer_log_type(method, path): - """根据请求方法和路径推断操作日志类型""" - # 优先根据路径后缀匹配 - path_lower = path.lower() - for suffix, log_type in _PATH_ACTION_TYPE.items(): - if path_lower.endswith(suffix): - return log_type - - # 回退到 HTTP 方法映射 - return _METHOD_LOG_TYPE.get(method, 18) # 18=其他 - - -def _get_request_param(): - """获取请求参数""" - try: - if request.method in ("POST", "PUT", "PATCH", "DELETE"): - # 优先解析 JSON body(batchDelete 等 DELETE 请求也带 body) - data = request.get_json(silent=True) - if data: - import json - - return json.dumps(data, ensure_ascii=False) - # 回退到 URL 查询参数 - if request.args: - import json - from collections import OrderedDict - - return json.dumps(OrderedDict(request.args), ensure_ascii=False) - except Exception: - pass - return "" - - -def _get_response_result(response): - """获取响应结果中的data字段""" - try: - data = response.get_json(silent=True) - if data and isinstance(data, dict): - result_data = data.get("data") - if result_data is not None: - import json - - return json.dumps(result_data, ensure_ascii=False) - # data字段为空时取msg - return data.get("msg", "") - except Exception: - pass - return "" - - -def _build_title(path): - """从请求路径构建操作标题""" - # 路径格式: /user/add, /role/update, /menu/delete 等 - parts = path.strip("/").split("/") - if len(parts) >= 2: - module = parts[0] - # 模块名中文化映射 - module_names = { - "user": "用户管理", - "role": "角色管理", - "menu": "菜单管理", - "dept": "部门管理", - "position": "岗位管理", - "level": "职级管理", - "param": "参数配置", - "dict": "字典管理", - "dictitem": "字典项管理", - "config": "系统配置", - "configitem": "配置项管理", - "category": "分类管理", - "article": "文章管理", - "notice": "通知管理", - "link": "友情链接", - "job": "定时任务", - "login": "登录管理", - "loginlog": "登录日志", - "operationlog": "操作日志", - "upload": "文件上传", - } - module_name = module_names.get(module.lower(), module) - action = parts[-1] if len(parts) > 1 else "" - action_names = { - "add": "新增", - "update": "修改", - "delete": "删除", - "status": "状态变更", - "batchdelete": "批量删除", - "reset": "重置", - "save": "保存", - "sort": "排序", - "page": "查询", - "detail": "查询", - "list": "查询", - } - action_name = action_names.get(action.lower(), action) - return f"{module_name}-{action_name}" if action_name else module_name - return path - - -# ============================================================ -# 全局异常处理 -# ============================================================ - -""" -全局异常处理模块 -============================================================= - -注册 Flask errorhandler,将所有 HTTP 错误和未捕获异常 -统一转为 R.failed() JSON 响应,避免前端收到 HTML 错误页导致解析崩溃。 - -处理范围: - - 400:请求参数错误 - - 401:未授权 - - 403:权限不足 - - 404:资源不存在 - - 405:请求方法不允许 - - 500:服务器内部错误 - - Exception:兜底,捕获所有未预期异常 -""" - - -def register_error_handlers(app): - """ - 注册全局异常处理器 - - 确保所有 HTTP 错误和未捕获异常都返回标准 JSON 响应。 - - Args: - app: Flask 应用实例 - """ - - @app.errorhandler(400) - def bad_request(e): - return R.failed(msg="请求参数错误", code=400), 400 - - @app.errorhandler(401) - def unauthorized(e): - return R.failed(msg="未授权,请重新登录", code=401), 401 - - @app.errorhandler(403) - def forbidden(e): - return R.failed(msg="权限不足", code=403), 403 - - @app.errorhandler(404) - def not_found(e): - return R.failed(msg="请求的资源不存在", code=404), 404 - - @app.errorhandler(405) - def method_not_allowed(e): - return R.failed(msg="请求方法不允许", code=405), 405 - - @app.errorhandler(500) - def internal_error(e): - logger.error( - f"服务器内部错误, method: {request.method}, path: {request.path}, " f"error: {str(e)}", exc_info=True - ) - msg = f"服务器内部错误: {str(e)}" if app.debug else "服务器内部错误" - return R.failed(msg=msg, code=500), 500 - - @app.errorhandler(BizError) - def handle_biz_error(e): - """ - 业务异常处理器 - - 业务代码 raise BizError(...) 时统一转换为 R.failed 响应, - 并回滚当前事务(teardown_request 处理)。 - """ - logger.info(f"业务异常: {e.msg} (code={e.code})") - return R.failed(msg=e.msg, code=e.code, data=e.data, **e.kwargs) - - @app.errorhandler(Exception) - def handle_exception(e): - # HTTPException 子类(如 abort(403))已有专用 handler,不重复处理 - if isinstance(e, HTTPException): - return e.get_response() - - # 非HTTP异常:未知运行时异常 - logger.error(f"未捕获异常, method: {request.method}, path: {request.path}, " f"error: {traceback.format_exc()}") - msg = f"服务器内部错误: {str(e)}" if app.debug else "服务器内部错误" - return R.failed(msg=msg, code=500), 500 - - logger.info("全局异常处理器注册成功") - - -# ============================================================ -# 中间件注册 -# ============================================================ - - -def register_middleware(app): - """ - 注册应用中间件(登录校验 before_request)。 - - Args: - app: Flask 应用实例 - """ - - @app.before_request - def checklogin(): - """登录验证中间件""" - return check_login() diff --git a/src/core/password.py b/src/core/password.py index b3fad4c..612f63e 100644 --- a/src/core/password.py +++ b/src/core/password.py @@ -29,11 +29,19 @@ def generate_salt(length: int = 10) -> str: """ 生成随机盐值 + 使用 secrets.token_urlsafe 生成 URL 安全的随机字符串, + 确保盐值不可预测,抵抗暴力破解。 + Args: length: 盐值长度,默认10位 Returns: str: 随机盐值字符串 + + Example: + >>> salt = generate_salt() + >>> len(salt) + 10 """ return secrets.token_urlsafe(length)[:length] @@ -42,14 +50,24 @@ def encrypt_password(password: str, salt: str) -> str: """ 使用 bcrypt 加密密码 - 将密码与外部 salt 拼接后进行 bcrypt 哈希,实现双重加盐。 + 将密码与外部 salt 拼接后进行 bcrypt 哈希,实现双重加盐策略: + 1. 外部 salt:由 generate_salt 生成,存储在数据库中 + 2. bcrypt 内置盐:bcrypt 自动生成的随机盐,嵌入在哈希结果中 + + 双重加盐确保即使两个用户使用相同密码,最终哈希值也不同。 Args: password: 明文密码 salt: 外部盐值 Returns: - str: bcrypt 哈希字符串(60字符) + str: bcrypt 哈希字符串(固定60字符) + + Example: + >>> salt = generate_salt() + >>> hashed = encrypt_password("my_password", salt) + >>> len(hashed) + 60 """ combined = (password + salt).encode('utf-8') hashed = bcrypt.hashpw(combined, bcrypt.gensalt(rounds=BCRYPT_ROUNDS)) @@ -60,13 +78,23 @@ def verify_password(password: str, salt: str, hashed: str) -> bool: """ 验证密码是否正确 + 使用与加密相同的策略(password + salt)进行比对验证。 + Args: password: 待验证的明文密码 salt: 外部盐值 hashed: 存储的 bcrypt 哈希值 Returns: - bool: 密码是否匹配 + bool: True-密码匹配,False-密码不匹配 + + Example: + >>> salt = generate_salt() + >>> hashed = encrypt_password("my_password", salt) + >>> verify_password("my_password", salt, hashed) + True + >>> verify_password("wrong_password", salt, hashed) + False """ combined = (password + salt).encode('utf-8') return bcrypt.checkpw(combined, hashed.encode('utf-8')) diff --git a/src/core/permission.py b/src/core/permission.py index 79cbeeb..ff2cbcd 100644 --- a/src/core/permission.py +++ b/src/core/permission.py @@ -69,21 +69,17 @@ def is_internal_call() -> bool: req_key = request.headers.get("X-Internal-API-Key", "") if hmac.compare_digest(req_key, INTERNAL_API_KEY): - logger.debug(f"内部API密钥验证通过: IP={request.remote_addr}") + # IP 白名单是"限制"而非"授权":配置了则必须命中。 + # 防止 X-Forwarded-For 伪造来源 / 任意来源携带密钥绕过 RBAC。 + if INTERNAL_ALLOWED_IPS: + client_ip = request.remote_addr + allowed_ips = [ip.strip() for ip in INTERNAL_ALLOWED_IPS.split(",") if ip.strip()] + if any(fnmatch.fnmatch(client_ip, ip) for ip in allowed_ips): + logger.debug(f"内部API密钥+IP白名单验证通过: IP={client_ip}") + return True + return False return True - # ============================================================ - # 方式3:IP白名单验证(可选) - # ============================================================ - if INTERNAL_ALLOWED_IPS: - client_ip = request.remote_addr - allowed_ips = [ip.strip() for ip in INTERNAL_ALLOWED_IPS.split(",") if ip.strip()] - - for allowed_ip in allowed_ips: - if fnmatch.fnmatch(client_ip, allowed_ip): - logger.debug(f"内部IP白名单验证通过: IP={client_ip}") - return True - return False @@ -127,7 +123,7 @@ def check_permission(permission: str, log: bool = False): logger.error(f"获取用户ID失败: {str(e)}") user_id = None - # 超级管理员放行 + # 超级管理员放行(user_id=1 为系统内置管理员) if user_id == 1: logger.debug(f"超级管理员权限验证通过: user_id={user_id}") return func(*args, **kwargs) @@ -140,8 +136,10 @@ def check_permission(permission: str, log: bool = False): return R.failed(msg="权限验证失败", code=500) try: + # 获取用户拥有的权限列表 permission_list = provider.get_user_permissions(user_id) + # 检查当前操作所需权限是否在用户权限列表中 if permission not in permission_list: if log: logger.warning( diff --git a/src/core/redis_client.py b/src/core/redis_client.py index 06622a2..9465212 100644 --- a/src/core/redis_client.py +++ b/src/core/redis_client.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 diff --git a/src/core/response.py b/src/core/response.py index adfd60b..3d2c35e 100644 --- a/src/core/response.py +++ b/src/core/response.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 珊瑚云团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 珊瑚云团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 diff --git a/src/core/scheduler.py b/src/core/scheduler.py index 5937487..2b50dbc 100644 --- a/src/core/scheduler.py +++ b/src/core/scheduler.py @@ -1,44 +1,3 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 -# +====================================================================== -# | 版权和免责声明: -# | 一、知识产权声明 -# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 -# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, -# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 -# | 二、使用限制与禁止条款 -# | 您理解并同意,禁止将本产品用于以下任何行为: -# | 1. 任何违反中华人民共和国法律法规的用途; -# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; -# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 -# | 三、责任豁免 -# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 -# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 -# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 -# | 四、违约与法律责任 -# | 如发现任何违反本声明的行为,本团队有权: -# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; -# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; -# | 3. 保留进一步采取刑事控告等法律措施的权利。 -# | 五、许可范围说明 -# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 -# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 -# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 -# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 -# | 成的一切经济损失及商誉损害。 -# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 -# | 许可协议》附件为准。 -# +====================================================================== - """ 定时任务调度器扩展模块 负责初始化和管理 APScheduler 定时任务 @@ -54,28 +13,36 @@ class Scheduler: """ 定时任务调度器管理类 - 负责调度器的初始化和生命周期管理 + 负责调度器的初始化和生命周期管理,包括: + - 调度器初始化(init_app) + - 任务重新加载(reload) + - 调度器状态查询(get_status) + - 调度器关闭(shutdown) + + 使用示例: + from src.core.scheduler import scheduler + + def create_app(): + app = Flask(__name__) + scheduler.init_app(app) + return app """ def __init__(self): - """初始化调度器实例""" + """初始化调度器实例,默认未初始化""" self._initialized = False def init_app(self, app: Flask): """ 初始化定时任务调度器 + 在应用上下文中执行,加载数据库中所有已启用的定时任务。 + Args: app: Flask 应用实例 - 使用示例: - from extensions.scheduler import scheduler - - def create_app(): - app = Flask(__name__) - # ... 其他配置 - scheduler.init_app(app) - return app + Raises: + Exception: 调度器初始化失败时抛出 """ # 避免重复初始化 if self._initialized: @@ -107,7 +74,11 @@ class Scheduler: raise def _log_scheduler_status(self): - """打印调度器状态(用于调试)""" + """ + 打印调度器状态(用于调试) + + 在初始化完成后输出当前调度器中的任务列表和运行状态。 + """ try: from src.modules.job.scheduler_service import scheduler @@ -128,7 +99,7 @@ class Scheduler: """ 关闭定时任务调度器 - 在应用退出时调用 + 在应用退出时调用,释放调度器资源。 """ try: from src.modules.job.scheduler_service import scheduler @@ -143,8 +114,10 @@ class Scheduler: """ 重新加载所有定时任务 + 从数据库读取最新的任务配置,更新调度器中的任务。 + Returns: - int: 成功加载的任务数量 + int: 成功加载的任务数量,失败时返回 0 """ try: from src.modules.job.scheduler_service import reload_scheduler @@ -161,6 +134,11 @@ class Scheduler: Returns: dict: 包含调度器运行状态和任务列表 + - initialized (bool): 是否已初始化 + - running (bool): 是否正在运行 + - job_count (int): 任务数量 + - jobs (list): 任务详情列表 + - error (str): 错误信息(仅异常时存在) """ try: from src.modules.job.scheduler_service import scheduler diff --git a/src/core/security.py b/src/core/security.py index fa4289f..41f3e41 100644 --- a/src/core/security.py +++ b/src/core/security.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 珊瑚云团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 珊瑚云团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -119,3 +119,23 @@ def get_username(): except Exception as e: logging.error(f"获取用户名失败: {str(e)}") return '' + + +def get_realname(): + """ + 获取当前登录用户姓名 + + Returns: + str: 当前登录用户的真实姓名(realname),若token无效或用户不存在则返回空字符串 + """ + try: + result = parse_token(request) + if result and isinstance(result, dict): + data = result.get('data', {}) + if isinstance(data, dict): + realname = data.get('realname') + return realname if realname else '' + return '' + except Exception as e: + logging.error(f"获取用户姓名失败: {str(e)}") + return '' diff --git a/src/middleware/__init__.py b/src/middleware/__init__.py new file mode 100644 index 0000000..b0457a0 --- /dev/null +++ b/src/middleware/__init__.py @@ -0,0 +1,44 @@ +""" +中间件包 +============================================================= + +按职责拆分: + - auth: 登录校验(JWT + 白名单)+ register_middleware + - demo: 演示模式拦截(check_demo) + - transaction: AOP 事务拦截器 + 备用事务工具 + - operation_log:操作日志自动记录 + operation_log 标题装饰器 + - errors: 全局异常处理 + +依赖约束:各子模块仅依赖 core/common,不依赖 modules 层。 +对外统一从本包导入,既有 `from src.middleware import ...` 调用零改动。 +""" + +from src.middleware.auth import check_login, is_whitelisted, register_middleware +from src.middleware.demo import check_demo +from src.middleware.transaction import ( + register_transaction, + transactional, + transactional_response, + TransactionContext, +) +from src.middleware.operation_log import register_operation_log, operation_log +from src.middleware.errors import register_error_handlers +# 测试兼容(tests/unit/test_core/test_middleware.py 引用) +from src.middleware.operation_log import _is_excluded_path, _redact_sensitive, _build_title + +__all__ = [ + "check_login", + "is_whitelisted", + "register_middleware", + "check_demo", + "register_transaction", + "transactional", + "transactional_response", + "TransactionContext", + "register_operation_log", + "operation_log", + "register_error_handlers", + "_is_excluded_path", + "_redact_sensitive", + "_build_title", +] diff --git a/src/middleware/auth.py b/src/middleware/auth.py new file mode 100644 index 0000000..f6d8d88 --- /dev/null +++ b/src/middleware/auth.py @@ -0,0 +1,181 @@ +""" +登录校验中间件 +============================================================= + +提供登录白名单匹配与 JWT 登录校验(check_login), +并注册为 before_request 钩子(register_middleware)。 +""" + +import fnmatch +import logging + +from flask import g, request + +from config import ( + LOGIN_WHITELIST_EXACT, + LOGIN_WHITELIST_DEBUG, + LOGIN_WHITELIST_PREFIX, + LOGIN_WHITELIST_WILDCARD, +) +from src.core import response as R +from src.core.jwt import get_access_token, is_token_blacklisted, parse_payload, touch_session + +logger = logging.getLogger(__name__) + + +def is_whitelisted(path: str) -> bool: + """ + 检查请求路径是否在白名单中 + + 支持三种匹配模式: + 1. 精确匹配:路径完全相等 + 2. 前缀匹配:路径以指定前缀开头 + 3. 通配符匹配:支持 *(匹配任意字符)和 ?(匹配单个字符) + + Args: + path: 请求路径(如:/job/sendSms) + + Returns: + bool: True-在白名单中,False-不在白名单中 + """ + # ============================================================ + # 1. 精确匹配检查 + # ============================================================ + # 示例:LOGIN_WHITELIST_EXACT=/login,/captcha + # 当 path 为 /login 或 /captcha 时返回 True + exact_urls = LOGIN_WHITELIST_EXACT + if exact_urls: + for url in exact_urls.split(","): + if url.strip() and url.strip() == path: + if LOGIN_WHITELIST_DEBUG: + logger.debug(f"精确匹配白名单: {path}") + return True + + # ============================================================ + # 2. 前缀匹配检查 + # ============================================================ + # 示例:LOGIN_WHITELIST_PREFIX=/job/sendSms,/public + # 当 path 以 /job/sendSms 或 /public 开头时返回 True + prefix_urls = LOGIN_WHITELIST_PREFIX + if prefix_urls: + for prefix in prefix_urls.split(","): + prefix = prefix.strip() + if prefix and path.startswith(prefix): + if LOGIN_WHITELIST_DEBUG: + logger.debug(f"前缀匹配白名单: {path} 匹配 {prefix}") + return True + + # ============================================================ + # 3. 通配符匹配检查 + # ============================================================ + # 示例:LOGIN_WHITELIST_WILDCARD=/job/*/send*,/api/v1/*/public* + # 当 path 匹配通配符模式时返回 True + # - * 匹配任意多个字符 + # - ? 匹配单个字符 + wildcard_patterns = LOGIN_WHITELIST_WILDCARD + if wildcard_patterns: + for pattern in wildcard_patterns.split(","): + pattern = pattern.strip() + if pattern and fnmatch.fnmatch(path, pattern): + if LOGIN_WHITELIST_DEBUG: + logger.debug(f"通配符匹配白名单: {path} 匹配 {pattern}") + return True + + return False + + +def check_login(): + """ + 用户登录验证中间件 + + 功能说明: + 1. 检查请求路径是否在白名单中,白名单路径直接放行 + 2. OPTIONS请求(CORS预检)直接放行 + 3. 验证JWT Token的有效性 + 4. Token验证通过后将用户信息存储到g对象中 + + Returns: + None: 验证通过,继续处理请求 + R.failed: 验证失败,返回错误响应(401未登录) + """ + current_path = request.path + + # ============================================================ + # 步骤1:检查白名单 + # ============================================================ + # 白名单中的路径不需要验证Token(如:登录页、验证码、短信发送接口等) + if is_whitelisted(current_path): + if LOGIN_WHITELIST_DEBUG: + logger.debug(f"白名单路径,跳过登录验证: {current_path}") + return None + + # ============================================================ + # 步骤2:OPTIONS请求放行 + # ============================================================ + # OPTIONS请求是CORS预检请求,不需要验证Token + if request.method == "OPTIONS": + if LOGIN_WHITELIST_DEBUG: + logger.debug(f"OPTIONS请求,跳过登录验证: {current_path}") + return None + + # ============================================================ + # 步骤3:获取并验证Token + # ============================================================ + success, access_token, msg = get_access_token() + + # 如果没有Token + if not success: + logger.warning(f"未登录访问: {current_path}, IP: {request.remote_addr}") + return R.failed(code=401, msg="请先登录") + + # JWT解密验证 + result = parse_payload(access_token) + + # 验证失败处理 + if result.get("code") != 0: + logger.warning(f"Token验证失败: {result.get('msg')}, 路径: {current_path}") + return R.failed(code=401, msg=result.get("msg", "Token无效,请重新登录")) + + # ============================================================ + # 步骤3.5:检查Token黑名单 + # ============================================================ + # 退出登录时Token会被加入黑名单,黑名单中的Token即使未过期也应拒绝 + if is_token_blacklisted(access_token): + logger.warning(f"Token已被注销(黑名单): user_id={result.get('data', {}).get('userId')}, 路径: {current_path}") + return R.failed(code=401, msg="Token已失效,请重新登录") + + # ============================================================ + # 步骤3.6:续期滑动会话 + # ============================================================ + # 空闲超时(默认20分钟)内无请求,Redis 会话键过期 → touch_session 返回 False → 拒绝。 + # 有请求则重置会话 TTL,实现"活跃使用永不过期"。失败(会话缺失/Redis异常)fail-closed。 + if not touch_session(access_token): + logger.warning(f"会话已过期: user_id={result.get('data', {}).get('userId')}, 路径: {current_path}") + return R.failed(code=401, msg="登录已过期,请重新登录") + + # ============================================================ + # 步骤4:存储用户信息 + # ============================================================ + # 将用户信息存储到g对象中,供后续的视图函数和权限验证使用 + if result.get("data"): + g.user_id = result["data"].get("userId") + g.user_info = result["data"] + if LOGIN_WHITELIST_DEBUG: + logger.debug(f"用户登录验证通过: user_id={g.user_id}, 路径={current_path}") + + # 验证通过,返回None继续处理请求 + return None + + +def register_middleware(app): + """ + 注册应用中间件(登录校验 before_request)。 + + Args: + app: Flask 应用实例 + """ + + @app.before_request + def checklogin(): + """登录验证中间件""" + return check_login() diff --git a/src/middleware/demo.py b/src/middleware/demo.py new file mode 100644 index 0000000..d89a410 --- /dev/null +++ b/src/middleware/demo.py @@ -0,0 +1,40 @@ +""" +演示模式拦截中间件 +============================================================= + +在演示环境(FLASK_DEMO=True)下拦截所有写操作, +返回"演示环境,暂无操作权限"提示。 +""" + +import logging +from functools import wraps + +from config import FLASK_DEMO +from src.core import response as R + +logger = logging.getLogger(__name__) + + +def check_demo(func): + """ + 演示模式拦截装饰器 + + 当 FLASK_DEMO=True 时,拦截写操作并返回提示。 + 应放在 @check_permission 之后、路由函数之前。 + + Example: + @position_blue.route('/add', methods=["POST"]) + @check_permission("sys:position:add") + @check_demo + def add(): + result = position.add_position() + return result + """ + + @wraps(func) + def wrapper(*args, **kwargs): + if FLASK_DEMO: + return R.failed("演示环境,暂无操作权限") + return func(*args, **kwargs) + + return wrapper diff --git a/src/middleware/errors.py b/src/middleware/errors.py new file mode 100644 index 0000000..13e5209 --- /dev/null +++ b/src/middleware/errors.py @@ -0,0 +1,112 @@ +""" +全局异常处理中间件 +============================================================= + +注册 Flask errorhandler,将所有 HTTP 错误和未捕获异常 +统一转为 R.failed() JSON 响应,避免前端收到 HTML 错误页导致解析崩溃。 + +处理范围: + - 400:请求参数错误 + - 401:未授权 + - 403:权限不足 + - 404:资源不存在 + - 405:请求方法不允许 + - 500:服务器内部错误 + - Exception:兜底,捕获所有未预期异常 +""" + +import logging +import traceback + +from flask import request +from werkzeug.exceptions import HTTPException + +from src.core import response as R +from src.core.exceptions import BizError + +logger = logging.getLogger(__name__) + + +def register_error_handlers(app): + """ + 注册全局异常处理器 + + 确保所有 HTTP 错误和未捕获异常都返回标准 JSON 响应。 + + Args: + app: Flask 应用实例 + """ + + @app.errorhandler(400) + def bad_request(e): + """处理请求参数错误""" + return R.failed(msg="请求参数错误", code=400), 400 + + @app.errorhandler(401) + def unauthorized(e): + """ + 处理未授权访问 + + 统一约定:HTTP 200 + 业务 code=401(与 check_login 一致)。 + 前端按响应体 code 触发重新登录,而非按 HTTP 状态码;改 HTTP 401 会让前端 + 走 error 分支只提示"无权限"而不跳转登录页。 + """ + return R.failed(msg="未授权,请重新登录", code=401), 200 + + @app.errorhandler(403) + def forbidden(e): + """处理权限不足""" + return R.failed(msg="权限不足", code=403), 403 + + @app.errorhandler(404) + def not_found(e): + """处理资源不存在""" + return R.failed(msg="请求的资源不存在", code=404), 404 + + @app.errorhandler(405) + def method_not_allowed(e): + """处理请求方法不允许""" + return R.failed(msg="请求方法不允许", code=405), 405 + + @app.errorhandler(500) + def internal_error(e): + """ + 处理服务器内部错误 + + 开发环境返回详细错误信息,生产环境返回通用提示。 + """ + logger.error( + f"服务器内部错误, method: {request.method}, path: {request.path}, " f"error: {str(e)}", exc_info=True + ) + msg = f"服务器内部错误: {str(e)}" if app.debug else "服务器内部错误" + return R.failed(msg=msg, code=500), 500 + + @app.errorhandler(BizError) + def handle_biz_error(e): + """ + 业务异常处理器 + + 业务代码 raise BizError(...) 时统一转换为 R.failed 响应, + 并回滚当前事务(teardown_request 处理)。 + """ + logger.info(f"业务异常: {e.msg} (code={e.code})") + return R.failed(msg=e.msg, code=e.code, data=e.data, **e.kwargs) + + @app.errorhandler(Exception) + def handle_exception(e): + """ + 兜底异常处理器 + + 捕获所有未在以上 handler 中处理的异常。 + HTTPException 子类(如 abort(403))已有专用 handler,不重复处理。 + """ + # HTTPException 子类(如 abort(403))已有专用 handler,不重复处理 + if isinstance(e, HTTPException): + return e.get_response() + + # 非HTTP异常:未知运行时异常 + logger.error(f"未捕获异常, method: {request.method}, path: {request.path}, " f"error: {traceback.format_exc()}") + msg = f"服务器内部错误: {str(e)}" if app.debug else "服务器内部错误" + return R.failed(msg=msg, code=500), 500 + + logger.info("全局异常处理器注册成功") diff --git a/src/middleware/operation_log.py b/src/middleware/operation_log.py new file mode 100644 index 0000000..b9cf76d --- /dev/null +++ b/src/middleware/operation_log.py @@ -0,0 +1,337 @@ +""" +操作日志自动记录中间件 +============================================================= + +通过 after_request 钩子自动记录写操作(POST/PUT/DELETE/PATCH)日志。 +无需在每个视图函数中手动调用 record_operation_log。 + +记录规则: + - 只记录写操作(POST/PUT/DELETE/PATCH),GET/OPTIONS/HEAD 不记录 + - 只记录成功操作(HTTP 2xx 且业务 code=0) + - 内部调用(定时任务等)不记录 + - 登录/退出/验证码等路径不记录(由 login_log 单独记录) + - 日志记录失败不影响主流程 + +标题声明: + 在路由视图上使用 `@operation_log("岗位管理", "新增")` 声明操作日志标题, + 中间件从已注册视图函数读取 `__op_log_title__`,无需集中式模块映射表。 +""" + +import logging +import time + +from flask import current_app, g, request + +logger = logging.getLogger(__name__) + + +def operation_log(module_name: str, action_name: str = ""): + """ + 在路由视图上声明操作日志标题(模块名-动作名)。 + + 挂载到视图函数上,中间件从 `current_app.view_functions[endpoint].__op_log_title__` 读取。 + + Args: + module_name: 模块名称,如 "岗位管理" + action_name: 操作名称,如 "新增",为空时仅使用模块名 + + Returns: + decorator: 装饰器函数 + + Example: + @position_blue.route("/add", methods=["POST"]) + @operation_log("岗位管理", "新增") + @check_permission("sys:position:add") + def add(): ... + """ + + def decorator(func): + func.__op_log_title__ = f"{module_name}-{action_name}" if action_name else module_name + return func + + return decorator + + +# 不记录操作日志的敏感路径尾段(登录/验证码/退出/SMS 验证码)。 +# 按路径尾段 endswith 匹配,天然兼容 /api/v1、/api/v2 等任意版本前缀, +# 无需随版本升级逐个追加前缀。 +_EXCLUDED_PATH_SEGMENTS = ("/login", "/captcha", "/logout", "/sendSms") + + +def _is_excluded_path(path) -> bool: + """判断路径是否属于敏感端点(尾段匹配,版本无关)""" + return any(path.endswith(seg) for seg in _EXCLUDED_PATH_SEGMENTS) + + +# 需脱敏的敏感字段(真正的凭据),防止写入操作日志/审计。 +# 注意:不包含 code/key——业务数据(role.code/dict.code 等)常含同名键,会被误打码; +# 验证码/短信端点已按路径排除(_is_excluded_path),无需在此兜底。 +_SENSITIVE_KEYS = frozenset({ + "password", "salt", "oldPassword", "newPassword", "confirmPassword", + "accessToken", "token", +}) + + +def _redact_sensitive(data): + """ + 递归脱敏敏感字段,用于操作日志请求/响应记录 + + 将密码、令牌等敏感字段的值替换为 "******"。 + + Args: + data: 待处理的数据(dict/list/基础类型) + + Returns: + 脱敏后的数据 + """ + if isinstance(data, dict): + return { + k: ("******" if k in _SENSITIVE_KEYS else _redact_sensitive(v)) + for k, v in data.items() + } + if isinstance(data, list): + return [_redact_sensitive(item) for item in data] + return data + + +# HTTP 方法 → 操作日志类型 映射 +# 类型值见 scripts/seed_dict.py 的 DICT_SEED operation_type(显示文本由 dict_utils 从字典表翻译) +_METHOD_LOG_TYPE = { + "POST": 3, # 新增 + "PUT": 4, # 修改 + "DELETE": 5, # 删除 + "PATCH": 4, # 修改 +} + +# 路径后缀 → 操作日志类型 覆盖映射(更精确) +_PATH_ACTION_TYPE = { + "/add": 3, # 新增 + "/update": 4, # 修改 + "/delete": 5, # 删除 + "/status": 4, # 状态变更→修改 + "/batchdelete": 5, # 批量删除 + "/reset": 4, # 重置→修改 + "/save": 4, # 保存→修改 + "/sort": 4, # 排序→修改 +} + + +def register_operation_log(app): + """ + 注册操作日志自动记录中间件 + + 在 after_request 中自动记录写操作日志。 + + Args: + app: Flask 应用实例 + """ + + @app.after_request + def record_op_log(response): + """ + after_request 钩子:自动记录写操作日志 + + 记录条件: + 1. 请求方法为 POST/PUT/DELETE/PATCH + 2. 路径不在排除列表中 + 3. HTTP 状态码为 2xx + 4. 业务 code 为 0(成功) + 5. 非内部调用 + """ + # 只记录写操作 + if request.method not in ("POST", "PUT", "DELETE", "PATCH"): + return response + + # 排除登录/退出/验证码/SMS 等敏感路径(尾段匹配,兼容任意版本前缀) + path = request.path + if _is_excluded_path(path): + return response + + # 只记录成功操作(HTTP 2xx 且业务 code=0) + if not (200 <= response.status_code < 300): + return response + + try: + data = response.get_json(silent=True) + if data and data.get("code", 0) != 0: + return response + except Exception: + pass + + # 内部调用不记录 + if getattr(g, "_internal_call", False): + return response + + # 异步记录,不阻塞响应 + try: + _write_log(response) + except Exception as e: + logger.warning(f"记录操作日志失败: {str(e)}") + + return response + + logger.info("操作日志中间件注册成功") + + +def _write_log(response): + """ + 写入操作日志 + + 从请求和响应中提取日志所需信息,调用注入的 writer 函数写入。 + + Args: + response: Flask 响应对象 + """ + from src.core.security import get_username + from src.common.utils.ip import get_client_ip + from src.common.utils.ip2region import get_ip_location + from src.common.utils.user_agent import parse_user_agent + + # 获取注入的日志写入器 + writer = current_app.extensions.get("operation_log_writer") + if writer is None: + logger.warning("operation_log_writer 未注入,跳过操作日志") + return + + username = get_username() or "system" + client_ip = get_client_ip() + ua_info = parse_user_agent() + + # 确定操作类型 + log_type = _infer_log_type(request.method, request.path) + + # 构建标题:优先取路由 @operation_log 声明的元数据 + title = _build_title(request.path, request.endpoint) + + # 请求参数(限制长度,避免存入过大的数据) + param = _get_request_param() + + # 返回结果(限制长度) + result = _get_response_result(response) + + # 请求耗时 + start_time = getattr(g, "request_start_time", None) + consume_time = int((time.time() - start_time) * 1000) if start_time else 0 + + writer( + title=title, + log_type=log_type, + source=0, # OperationLog.Source.ADMIN + status=0, # OperationLog.Status.NORMAL + method=request.endpoint or "", + request_method=request.method, + url=request.path, + param=param, + result=result, + ip=client_ip, + location=get_ip_location(client_ip), + os=ua_info.get("os", "未知"), + browser=ua_info.get("browser", "未知"), + consume_time=consume_time, + error="", + username=username, + ) + + +def _infer_log_type(method, path): + """ + 根据请求方法和路径推断操作日志类型 + + 优先根据路径后缀匹配,回退到 HTTP 方法映射。 + + Args: + method: HTTP 请求方法 + path: 请求路径 + + Returns: + int: 操作日志类型编码 + """ + # 优先根据路径后缀匹配 + path_lower = path.lower() + for suffix, log_type in _PATH_ACTION_TYPE.items(): + if path_lower.endswith(suffix): + return log_type + + # 回退到 HTTP 方法映射 + return _METHOD_LOG_TYPE.get(method, 18) # 18=其他 + + +def _get_request_param(): + """ + 获取请求参数 + + 优先从 JSON body 获取,回退到 URL 查询参数。 + 自动对敏感字段进行脱敏处理。 + + Returns: + str: JSON 格式的参数字符串,无参数时返回空字符串 + """ + try: + if request.method in ("POST", "PUT", "PATCH", "DELETE"): + # 优先解析 JSON body(batchDelete 等 DELETE 请求也带 body) + data = request.get_json(silent=True) + if data: + import json + + return json.dumps(_redact_sensitive(data), ensure_ascii=False) + # 回退到 URL 查询参数 + if request.args: + import json + from collections import OrderedDict + + return json.dumps(_redact_sensitive(OrderedDict(request.args)), ensure_ascii=False) + except Exception: + pass + return "" + + +def _get_response_result(response): + """ + 获取响应结果中的 data 字段 + + Args: + response: Flask 响应对象 + + Returns: + str: JSON 格式的响应数据字符串,无数据时返回空字符串 + """ + try: + data = response.get_json(silent=True) + if data and isinstance(data, dict): + result_data = data.get("data") + if result_data is not None: + import json + + return json.dumps(_redact_sensitive(result_data), ensure_ascii=False) + # data字段为空时取msg + return data.get("msg", "") + except Exception: + pass + return "" + + +def _build_title(path, endpoint): + """ + 构建操作日志标题 + + 优先读取路由视图函数 `@operation_log` 声明的元数据; + 未标注的路由走路径兜底(模块段-动作段,不做中文映射)。 + + Args: + path: 请求路径 + endpoint: Flask 端点名称 + + Returns: + str: 操作日志标题 + """ + view_func = current_app.view_functions.get(endpoint) + if view_func: + title = getattr(view_func, "__op_log_title__", None) + if title: + return title + + # 兜底:未标注路由,用路径原样(剥离 /api/v1 版本前缀,取前两段) + parts = [p for p in path.strip("/").split("/") if p] + if parts and parts[0] == "api": + parts = parts[2:] if len(parts) > 2 else [] + return "-".join(parts[:2]) if parts else path diff --git a/src/middleware/transaction.py b/src/middleware/transaction.py new file mode 100644 index 0000000..8c02136 --- /dev/null +++ b/src/middleware/transaction.py @@ -0,0 +1,221 @@ +""" +AOP 事务拦截器 +============================================================= + +设计理念: + 1. 零代码侵入:业务代码无需任何事务管理代码 + 2. 自动读写分离:GET 不提交,POST/PUT/DELETE 自动提交 + 3. 统一异常处理:所有异常自动回滚 + 4. 性能监控:记录事务执行时间 + +使用方式: + 在 Flask 应用初始化时调用 register_transaction(app) 即可。 + 业务代码无需任何改动。 +""" + +import logging +import time +from functools import wraps + +from flask import g, request + +from src.core.database import db + +logger = logging.getLogger(__name__) + + +def register_transaction(app): + """ + 注册 AOP 事务拦截器到 Flask 应用 + + 在请求生命周期中自动管理事务: + - before_request: 开启事务标记 + - after_request: 自动提交(POST/PUT/DELETE) + - teardown_request: 异常回滚 + 资源清理 + + Args: + app: Flask 应用实例 + """ + + @app.before_request + def begin_transaction(): + """请求开始:记录开始时间,开启事务标记""" + g.request_start_time = time.time() + g.transaction_active = True + + logger.debug(f"请求开始, method: {request.method}, path: {request.path}, " f"ip: {request.remote_addr}") + + @app.after_request + def commit_transaction(response): + """ + 请求结束:自动提交事务 + + 提交规则: + 1. 只对写操作(POST/PUT/DELETE/PATCH)提交 + 2. 只在响应成功(2xx)且业务成功(code=0)时提交 + 3. GET/OPTIONS/HEAD 不提交(只读) + 4. 业务失败(R.failed)不提交,自动回滚 + """ + # 只对写操作处理事务 + if request.method in ("POST", "PUT", "DELETE", "PATCH"): + should_commit = 200 <= response.status_code < 300 + + # 检查业务状态码:R.failed() 返回 HTTP 200 但 code=1/ok=False + if should_commit: + try: + data = response.get_json(silent=True) + if data and data.get("code", 0) != 0: + should_commit = False + except Exception: + pass + + if should_commit: + try: + if db.session.is_active: + db.session.commit() + logger.debug( + f"事务提交成功, method: {request.method}, " + f"path: {request.path}, status: {response.status_code}" + ) + except Exception as e: + db.session.rollback() + logger.error( + f"事务提交失败, method: {request.method}, " f"path: {request.path}, error: {str(e)}", + exc_info=True, + ) + from src.core import response as R + + # after_request 必须返回 Response 对象(返回元组会破坏响应链) + resp = R.failed(msg="数据处理失败", code=500) + resp.status_code = 500 + return resp + else: + # 业务失败或非2xx,回滚未提交的变更 + try: + if db.session.is_active: + db.session.rollback() + except Exception: + pass + + # 记录响应日志 + elapsed = time.time() - g.get("request_start_time", time.time()) + logger.debug( + f"请求结束, method: {request.method}, path: {request.path}, " + f"status: {response.status_code}, elapsed: {elapsed:.3f}s" + ) + + return response + + @app.teardown_request + def cleanup_transaction(exception=None): + """ + 请求结束:清理资源 + + 发生异常时自动回滚事务,确保数据一致性。 + """ + if exception is not None: + try: + db.session.rollback() + logger.error( + f"请求异常,事务已回滚, method: {request.method}, " + f"path: {request.path}, error: {str(exception)}", + exc_info=True, + ) + except Exception as e: + logger.error(f"事务回滚失败: {str(e)}", exc_info=True) + + # 清理请求标记 + g.pop("transaction_active", None) + g.pop("request_start_time", None) + + logger.info("AOP 事务拦截器注册成功") + + +def transactional(func): + """ + 事务装饰器(备用) + + 适用于需要精细控制事务边界的特殊场景。 + 大多数情况下,使用 AOP 拦截器即可。 + + Example: + @transactional + def complex_business(): + # 精细控制的事务逻辑 + pass + """ + + @wraps(func) + def wrapper(*args, **kwargs): + if g.get("disable_auto_commit", False): + return func(*args, **kwargs) + + try: + result = func(*args, **kwargs) + if not g.get("transaction_active", False): + db.session.commit() + return result + except Exception as e: + db.session.rollback() + logger.error(f"装饰器事务执行失败: {str(e)}", exc_info=True) + raise + + return wrapper + + +def transactional_response(func): + """ + 事务装饰器(带返回 R 对象) + + 用于需要直接返回 R 对象的接口。 + + Example: + @transactional_response + def add_position(): + position.save() + return R.ok(msg="添加成功") + """ + + @wraps(func) + def wrapper(*args, **kwargs): + from src.core import response as R + + try: + result = func(*args, **kwargs) + if not g.get("transaction_active", False): + db.session.commit() + return result + except Exception as e: + db.session.rollback() + logger.error(f"装饰器事务执行失败: {str(e)}", exc_info=True) + return R.failed(msg=f"操作失败: {str(e)}") + + return wrapper + + +class TransactionContext: + """ + 事务上下文管理器 + + 用于需要精细控制事务的特殊场景。 + + Example: + with TransactionContext(): + position.save() + user.save() + # 自动提交或回滚 + """ + + def __enter__(self): + g.disable_auto_commit = True + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + g.disable_auto_commit = False + if exc_type is None: + if not g.get("transaction_active", False): + db.session.commit() + else: + db.session.rollback() + logger.error(f"事务上下文回滚: {str(exc_val)}", exc_info=True) + return False diff --git a/src/models_registry.py b/src/models_registry.py index 4faa7df..21ab3e1 100644 --- a/src/models_registry.py +++ b/src/models_registry.py @@ -14,6 +14,7 @@ from src.modules.cms.category.models import * # noqa: F401,F403 (category) from src.modules.cms.link.models import * # noqa: F401,F403 (link) from src.modules.file_template.models import * # noqa: F401,F403 (file_template) from src.modules.job.models import * # noqa: F401,F403 (job) +from src.modules.job_log.models import * # noqa: F401,F403 (job_log) from src.modules.notice.models import * # noqa: F401,F403 (notice) from src.modules.param.models import * # noqa: F401,F403 (param) from src.modules.system.dept.models import * # noqa: F401,F403 (dept) @@ -22,6 +23,10 @@ from src.modules.system.menu.models import * # noqa: F401,F403 (menu) from src.modules.system.position.models import * # noqa: F401,F403 (position) from src.modules.system.role.models import * # noqa: F401,F403 (role) from src.modules.system.user.models import * # noqa: F401,F403 (user) -from src.modules.dictionary.models import * # noqa: F401,F403 (dict+dict_item) -from src.modules.configuration.models import * # noqa: F401,F403 (config+config_item) -from src.modules.log.models import * # noqa: F401,F403 (login_log+operation_log) +from src.modules.dictionary.dict.models import * # noqa: F401,F403 (dict) +from src.modules.dictionary.dict_item.models import * # noqa: F401,F403 (dict_item) +from src.modules.configuration.config.models import * # noqa: F401,F403 (config) +from src.modules.configuration.config_item.models import * # noqa: F401,F403 (config_item) +from src.modules.login_log.models import * # noqa: F401,F403 (login_log) +from src.modules.operation_log.models import * # noqa: F401,F403 (operation_log) +from src.modules.example.models import * # noqa: F401,F403 (example) diff --git a/src/modules/association/role_menu/models.py b/src/modules/association/role_menu/models.py index b198733..5b1a786 100644 --- a/src/modules/association/role_menu/models.py +++ b/src/modules/association/role_menu/models.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -49,6 +49,7 @@ from src.common.base_db import BaseDB from src.common.base_model import BaseModel from config import DB_PREFIX +from sqlalchemy import text from src.core.database import db @@ -76,6 +77,9 @@ class RoleMenu(BaseModel, BaseDB): # 设置表名 __tablename__ = DB_PREFIX + "role_menu" + # 表注释 + __table_comment__ = "角色菜单关联表" + # ============================================================ # 字段定义 # ============================================================ @@ -84,6 +88,8 @@ class RoleMenu(BaseModel, BaseDB): role_id = db.Column( db.Integer, default=0, + server_default=text('0'), + index=True, comment="角色ID" ) @@ -91,6 +97,8 @@ class RoleMenu(BaseModel, BaseDB): menu_id = db.Column( db.Integer, default=0, + server_default=text('0'), + index=True, comment="菜单ID" ) diff --git a/src/modules/association/role_menu/repository.py b/src/modules/association/role_menu/repository.py new file mode 100644 index 0000000..8071fde --- /dev/null +++ b/src/modules/association/role_menu/repository.py @@ -0,0 +1,60 @@ +""" +RoleMenu 仓储模块 +============================================================= + +数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 +""" + +from src.common.base_repository import BaseRepository +from src.core.database import db +from src.modules.association.role_menu.models import RoleMenu + + +class RoleMenuRepository(BaseRepository): + """角色菜单关联数据仓储,提供角色与菜单关联关系的数据库访问操作""" + + model = RoleMenu + + @classmethod + def get_menu_ids_by_role(cls, role_id): + """ + 按角色查询关联的菜单ID列表 + + Args: + role_id: 角色ID + + Returns: + list: 菜单ID列表 + """ + if not role_id or int(role_id) <= 0: + return [] + rows = cls.model.query.filter(cls.model.role_id == int(role_id)).all() + return [row.menu_id for row in rows] + + @classmethod + def replace_role_menus(cls, role_id, menu_ids): + """ + 替换角色菜单关联 + + 采用先删除后新增的策略,保持角色菜单关联的准确性。 + + Args: + role_id: 角色ID + menu_ids: 菜单ID列表 + + Returns: + int: 新增的关联记录数 + """ + if not role_id or int(role_id) <= 0: + return 0 + role_id = int(role_id) + + # 删除原有角色菜单关联 + cls.model.query.filter(cls.model.role_id == role_id).delete(synchronize_session=False) + + # 批量新增新的角色菜单关联 + valid_ids = [int(m) for m in (menu_ids or []) if m and int(m) > 0] + if valid_ids: + db.session.bulk_insert_mappings(cls.model, [{'role_id': role_id, 'menu_id': mid} for mid in valid_ids]) + db.session.flush() + return len(valid_ids) diff --git a/src/modules/association/role_menu/router.py b/src/modules/association/role_menu/router.py index 6388ccc..e46ad78 100644 --- a/src/modules/association/role_menu/router.py +++ b/src/modules/association/role_menu/router.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -55,8 +55,8 @@ from flask import Blueprint from src.core.permission import check_permission -from src.core.middleware import check_demo -from src.modules.system.role import service as role_menu +from src.middleware import check_demo, operation_log +from src.modules.association.role_menu import service as role_menu from src.common.utils.request import parse_request_body @@ -71,7 +71,8 @@ role_menu_blue = Blueprint("role_menu", __name__, url_prefix="/role/menu") @role_menu_blue.route("/list/", methods=["GET"]) -@check_permission("sys:role:list") +@check_permission("sys:role:page") +@operation_log("角色权限", "查询") def list(role_id): """ 查询角色的菜单权限列表 @@ -97,6 +98,7 @@ def list(role_id): @role_menu_blue.route("/save", methods=["POST"]) @check_permission("sys:role:save") @check_demo +@operation_log("角色权限", "保存") def save(): """ 保存角色菜单权限 diff --git a/src/modules/association/role_menu/schema.py b/src/modules/association/role_menu/schema.py index 7c962c5..58e878d 100644 --- a/src/modules/association/role_menu/schema.py +++ b/src/modules/association/role_menu/schema.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 diff --git a/src/modules/association/role_menu/service.py b/src/modules/association/role_menu/service.py index 627aa28..75aca58 100644 --- a/src/modules/association/role_menu/service.py +++ b/src/modules/association/role_menu/service.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -58,11 +58,9 @@ import logging -from sqlalchemy import and_ - from src.modules.association.role_menu.schema import RoleMenuForm -from src.modules.system.menu.models import Menu -from src.modules.association.role_menu.models import RoleMenu +from src.modules.association.role_menu.repository import RoleMenuRepository +from src.modules.system.menu.repository import MenuRepository from src.modules.system.menu.service import invalidate_role_users_cache from src.common.utils import regular from src.core.exceptions import BizError @@ -105,14 +103,13 @@ def get_role_menus(role_id): try: # 获取全部启用的菜单列表(按排序升序) - menu_list = Menu.query.filter(and_(Menu.is_delete == 0, Menu.status == 0)).order_by(Menu.sort.asc()).all() + menu_list = MenuRepository.find_list(filters={'status': 0}, order_by='sort') if not menu_list: return [] # 根据角色ID查询已分配的角色菜单关系数据 - role_menus = RoleMenu.query.filter(RoleMenu.role_id == role_id).all() - assigned_menu_ids = {rm.menu_id for rm in role_menus} if role_menus else set() + assigned_menu_ids = set(RoleMenuRepository.get_menu_ids_by_role(role_id)) # 构建菜单树数据 result = [] @@ -160,17 +157,8 @@ def save_role_menu(data: dict) -> None: role_id = form.roleId.data menu_ids = form.menuIds.data - # 删除当前角色ID相关的所有菜单权限 - RoleMenu.query.filter(RoleMenu.role_id == role_id).delete() - - # 处理菜单数据 - if menu_ids and menu_ids != "": - for menu_id in menu_ids: - if not menu_id or menu_id <= 0: - continue - # 创建角色菜单关联数据 - role_menu = RoleMenu(role_id=role_id, menu_id=menu_id) - role_menu.save() + # 先删后增角色菜单关联 + RoleMenuRepository.replace_role_menus(role_id, menu_ids) # 清除该角色下所有用户的权限缓存 invalidate_role_users_cache(role_id) diff --git a/src/modules/association/user_role/models.py b/src/modules/association/user_role/models.py index ce0ad3f..cc04b00 100644 --- a/src/modules/association/user_role/models.py +++ b/src/modules/association/user_role/models.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -49,6 +49,7 @@ from src.common.base_db import BaseDB from src.common.base_model import BaseModel from config import DB_PREFIX +from sqlalchemy import text from src.core.database import db @@ -76,6 +77,9 @@ class UserRole(BaseModel, BaseDB): # 设置表名 __tablename__ = DB_PREFIX + "user_role" + # 表注释 + __table_comment__ = "用户角色关联表" + # ============================================================ # 字段定义 # ============================================================ @@ -84,6 +88,8 @@ class UserRole(BaseModel, BaseDB): user_id = db.Column( db.Integer, default=0, + server_default=text('0'), + index=True, comment="用户ID" ) @@ -91,6 +97,8 @@ class UserRole(BaseModel, BaseDB): role_id = db.Column( db.Integer, default=0, + server_default=text('0'), + index=True, comment="角色ID" ) diff --git a/src/modules/association/user_role/repository.py b/src/modules/association/user_role/repository.py new file mode 100644 index 0000000..45a5455 --- /dev/null +++ b/src/modules/association/user_role/repository.py @@ -0,0 +1,144 @@ +""" +UserRole 仓储模块 +============================================================= + +数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 +""" + +from src.common.base_repository import BaseRepository +from src.core.database import db +from src.modules.association.user_role.models import UserRole +from src.modules.system.role.models import Role +from src.modules.system.user.models import User + + +class UserRoleRepository(BaseRepository): + """用户角色关联数据仓储,提供用户与角色关联关系的数据库访问操作""" + + model = UserRole + + @classmethod + def get_users_by_role(cls, role_id): + """ + 按角色查询关联的用户ID列表 + + 用于角色缓存失效场景。 + + Args: + role_id: 角色ID + + Returns: + list: 用户ID列表 + """ + if not role_id or int(role_id) <= 0: + return [] + rows = cls.model.query.filter(cls.model.role_id == int(role_id)).all() + return [row.user_id for row in rows] + + @classmethod + def get_roles_by_user(cls, user_id): + """ + 按用户查询关联的角色对象列表 + + Args: + user_id: 用户ID + + Returns: + list: 角色实例列表(已删除角色自动排除) + """ + if not user_id or int(user_id) <= 0: + return [] + return ( + Role.query.join(cls.model, Role.id == cls.model.role_id) + .filter(cls.model.user_id == int(user_id), Role.is_delete == 0) + .all() + ) + + @classmethod + def get_roles_batch(cls, user_ids): + """ + 批量查询多个用户的角色 + + 用于用户列表富化场景,减少数据库查询次数。 + + Args: + user_ids: 用户ID列表 + + Returns: + list: (user_id, Role) 元组列表 + """ + if not user_ids: + return [] + rows = ( + db.session.query(cls.model.user_id, Role) + .join(Role, Role.id == cls.model.role_id) + .filter(cls.model.user_id.in_(user_ids), Role.is_delete == 0) + .all() + ) + return [(row[0], row[1]) for row in rows] + + @classmethod + def get_user_objects_by_role(cls, role_id): + """ + 按角色查询关联的用户对象列表 + + 用于角色用户列表展示场景。 + + Args: + role_id: 角色ID + + Returns: + list: 用户实例列表 + """ + if not role_id or int(role_id) <= 0: + return [] + return ( + User.query.join(cls.model, User.id == cls.model.user_id) + .filter(cls.model.role_id == int(role_id), User.is_delete == 0) + .all() + ) + + @classmethod + def replace_roles(cls, user_id, role_ids): + """ + 替换用户角色关联 + + 采用先删除后新增的策略,保持用户角色关联的准确性。 + + Args: + user_id: 用户ID + role_ids: 角色ID列表 + + Returns: + int: 新增的关联记录数 + """ + if not user_id or int(user_id) <= 0: + return 0 + user_id = int(user_id) + + # 删除原有用户角色关联 + cls.model.query.filter(cls.model.user_id == user_id).delete(synchronize_session=False) + + # 批量新增新的用户角色关联 + valid_ids = [int(r) for r in (role_ids or []) if r and int(r) > 0] + if valid_ids: + db.session.bulk_insert_mappings(cls.model, [{'user_id': user_id, 'role_id': rid} for rid in valid_ids]) + db.session.flush() + return len(valid_ids) + + @classmethod + def delete_by_user_id(cls, user_id): + """ + 删除指定用户的全部角色关联 + + Args: + user_id: 用户ID + + Returns: + int: 删除的关联记录数 + """ + if not user_id or int(user_id) <= 0: + return 0 + count = cls.model.query.filter(cls.model.user_id == int(user_id)).delete(synchronize_session=False) + db.session.flush() + return count diff --git a/src/modules/association/user_role/router.py b/src/modules/association/user_role/router.py index 5ffb579..5e437b5 100644 --- a/src/modules/association/user_role/router.py +++ b/src/modules/association/user_role/router.py @@ -13,9 +13,9 @@ from flask import Blueprint -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.core.permission import check_permission -from src.modules.system.user import service as user_role +from src.modules.association.user_role import service as user_role from src.core import response as R user_role_blue = Blueprint('user_role', __name__, url_prefix='/user/role') @@ -25,7 +25,8 @@ user_role_blue = Blueprint('user_role', __name__, url_prefix='/user/role') # ============================================================ @user_role_blue.route('/list/', methods=["GET"]) -@check_permission("sys:user:list") +@check_permission("sys:user:page") +@operation_log("用户角色", "查询") def list(user_id): """ 查询用户的角色列表 @@ -50,6 +51,7 @@ def list(user_id): @user_role_blue.route('/save', methods=["POST"]) @check_permission("sys:user:save") @check_demo +@operation_log("用户角色", "保存") def save(): """ 保存用户角色权限 @@ -75,8 +77,8 @@ def save(): form = UserRoleForm(data=data) if not form.validate(): - from src.common.utils.regular import get_error - return R.failed(get_error(form)) + from src.common.utils.regular import get_err + return R.failed(get_err(form)) user_id = form.userId.data role_ids = form.roleIds.data or [] diff --git a/src/modules/association/user_role/service.py b/src/modules/association/user_role/service.py index 19781b1..5fa2f90 100644 --- a/src/modules/association/user_role/service.py +++ b/src/modules/association/user_role/service.py @@ -15,10 +15,7 @@ import logging -from src.modules.system.role.models import Role -from src.modules.system.user.models import User -from src.modules.association.user_role.models import UserRole -from src.core.database import db +from src.modules.association.user_role.repository import UserRoleRepository from src.common.utils.request import parse_id_list # 获取日志记录器 @@ -53,18 +50,11 @@ def get_user_role_list(user_id): return [] try: - # ORM JOIN查询,表名自动从Model获取 - result = ( - db.session.query(Role.id, Role.name) - .join(UserRole, Role.id == UserRole.role_id) - .filter(UserRole.user_id == user_id, Role.is_delete == 0) - .all() - ) + # 按用户查询关联角色对象 + roles = UserRoleRepository.get_roles_by_user(user_id) # 构建角色列表 - role_list = [{'id': row.id, 'name': row.name} for row in result] - - return role_list + return [{'id': role.id, 'name': role.name} for role in roles] except Exception as e: logger.error(f"查询用户角色列表异常,用户ID: {user_id}, 错误: {str(e)}") @@ -111,20 +101,8 @@ def save_user_roles(user_id, role_ids): else: role_id_list = [] - # 删除用户原有角色关系 - UserRole.query.filter(UserRole.user_id == user_id).delete() - - # 创建新的用户角色关系 - for role_id in role_id_list: - if not role_id: - continue - user_role = UserRole( - user_id=user_id, - role_id=role_id - ) - db.session.add(user_role) - - db.session.flush() + # 先删后增用户角色关联 + UserRoleRepository.replace_roles(user_id, role_id_list) return True except Exception as e: @@ -151,8 +129,7 @@ def delete_user_roles_by_user_id(user_id): return False try: - count = UserRole.query.filter(UserRole.user_id == user_id).delete() - db.session.flush() + count = UserRoleRepository.delete_by_user_id(user_id) logger.info(f"删除用户角色关联成功,用户ID: {user_id}, 删除数量: {count}") return True @@ -185,17 +162,12 @@ def get_user_roles_batch(user_ids): return {} try: - result = ( - db.session.query(UserRole.user_id, Role.id, Role.name) - .join(Role, Role.id == UserRole.role_id) - .filter(UserRole.user_id.in_(user_ids), Role.is_delete == 0) - .all() - ) + rows = UserRoleRepository.get_roles_batch(user_ids) # 按 user_id 分组 role_map = {} - for row in result: - role_map.setdefault(row.user_id, []).append({'id': row.id, 'name': row.name}) + for uid, role in rows: + role_map.setdefault(uid, []).append({'id': role.id, 'name': role.name}) return role_map except Exception as e: @@ -221,17 +193,9 @@ def get_role_user_list(role_id): return [] try: - # ORM JOIN查询,表名自动从Model获取 - result = ( - db.session.query(User.id, User.realname, User.username) - .join(UserRole, User.id == UserRole.user_id) - .filter(UserRole.role_id == role_id, User.is_delete == 0) - .all() - ) + users = UserRoleRepository.get_user_objects_by_role(role_id) - user_list = [{'id': row.id, 'realname': row.realname, 'username': row.username} for row in result] - - return user_list + return [{'id': user.id, 'realname': user.realname, 'username': user.username} for user in users] except Exception as e: logger.error(f"查询角色用户列表异常,角色ID: {role_id}, 错误: {str(e)}") diff --git a/src/modules/auth/router.py b/src/modules/auth/router.py index e6eb7c1..6be5284 100644 --- a/src/modules/auth/router.py +++ b/src/modules/auth/router.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -47,9 +47,9 @@ """ from flask import Blueprint from sqlalchemy import and_ -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.modules.auth import service as login -from src.modules.log import service as login_log +from src.modules.login_log import service as login_log from src.modules.system.user.models import User from src.modules.system.menu import service as menu from src.modules.system.user import service as user @@ -60,7 +60,6 @@ from src.core.jwt import add_token_to_blacklist, get_access_token from src.common.utils.rich_text import get_file_url from src.core.security import get_user_id - login_blue = Blueprint("login", __name__, url_prefix="/") @@ -70,6 +69,7 @@ login_blue = Blueprint("login", __name__, url_prefix="/") @login_blue.route("/login", methods=["POST"]) +@operation_log("登录管理", "登录") def user_login(): """ 用户登录认证 @@ -92,6 +92,7 @@ def user_login(): @login_blue.route("/captcha", methods=["GET"]) +@operation_log("登录管理", "验证码") def captcha(): """ 获取图形验证码 @@ -121,6 +122,7 @@ def captcha(): @login_blue.route("/logout", methods=["GET"]) +@operation_log("登录管理", "退出") def logout(): """ 用户退出登录 @@ -140,7 +142,7 @@ def logout(): try: import time from flask import g, request - from src.modules.log.models import LoginLog + from src.modules.login_log.models import LoginLog from src.core.security import get_username from src.common.utils.ip import get_client_ip from src.common.utils.ip2region import get_ip_location @@ -159,8 +161,8 @@ def logout(): source=LoginLog.Source.ADMIN, status=LoginLog.Status.NORMAL, log_type=LoginLog.LogType.LOGOUT, - method=request.method, - request_method=request.endpoint or "", + method=request.endpoint or "", + request_method=request.method, url=request.path, param="", result="退出成功", @@ -196,7 +198,6 @@ def logout(): application.models.user - 用户数据模型 """ - index_blue = Blueprint("index", __name__, url_prefix="/index") @@ -205,7 +206,8 @@ index_blue = Blueprint("index", __name__, url_prefix="/index") # ============================================================ -@index_blue.route("/getMenu", methods=["GET"]) +@index_blue.route("/getMenus", methods=["GET"]) +@operation_log("个人中心", "查询菜单") def get_menus(): """ 获取系统菜单列表 @@ -226,6 +228,7 @@ def get_menus(): @index_blue.route("/getUser", methods=["GET"]) +@operation_log("个人中心", "查询用户") def get_user(): """ 获取当前用户信息 @@ -256,6 +259,13 @@ def get_user(): "email": current_user.email, "intro": current_user.intro, "address": current_user.address, + "note": current_user.note, + "city": [ + current_user.province_code, + current_user.city_code, + current_user.district_code, + current_user.street_code, + ], "roles": [], "permissions": permissions, } @@ -268,9 +278,10 @@ def get_user(): # ============================================================ -@index_blue.route("/userInfo", methods=["PUT"]) +@index_blue.route("/updateProfile", methods=["PUT"]) @check_demo -def update_user_info(): +@operation_log("个人中心", "更新资料") +def update_profile(): """ 更新个人中心信息 @@ -280,8 +291,11 @@ def update_user_info(): Returns: JSON: 操作结果 """ - result = user.update_user_info() - return result + json_data, err = parse_request_body() + if err: + return R.failed(msg=err) + user.update_profile(json_data) + return R.ok(msg="更新成功") # ============================================================ @@ -289,9 +303,10 @@ def update_user_info(): # ============================================================ -@index_blue.route("/updatePwd", methods=["PUT"]) +@index_blue.route("/updatePassword", methods=["PUT"]) @check_demo -def update_pwd(): +@operation_log("个人中心", "修改密码") +def update_password(): """ 更新用户密码 @@ -301,5 +316,8 @@ def update_pwd(): Returns: JSON: 操作结果 """ - result = user.update_password() - return result + json_data, err = parse_request_body() + if err: + return R.failed(msg=err) + user.update_password(json_data) + return R.ok(msg="修改成功") diff --git a/src/modules/auth/schema.py b/src/modules/auth/schema.py index ad131b0..cabfb2d 100644 --- a/src/modules/auth/schema.py +++ b/src/modules/auth/schema.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 diff --git a/src/modules/auth/service.py b/src/modules/auth/service.py index 0563e4a..a07d4cd 100644 --- a/src/modules/auth/service.py +++ b/src/modules/auth/service.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -40,17 +40,16 @@ # +====================================================================== from flask import request, g -from sqlalchemy import and_ from src.modules.auth.schema import LoginForm -from src.modules.log.models import LoginLog -from src.modules.system.user.models import User -from src.modules.log import service as login_log -from config import CAPTCHA_BYPASS_CODE, FLASK_DEMO, LOGIN_FAIL_MAX, LOGIN_LOCK_SECONDS +from src.modules.login_log.models import LoginLog +from src.modules.system.user.repository import UserRepository +from src.modules.login_log import service as login_log +from config import CAPTCHA_BYPASS_CODE, LOGIN_FAIL_MAX, LOGIN_LOCK_SECONDS, FLASK_DEBUG from src.common.utils import regular from src.core.exceptions import BizError from src.core.password import verify_password -from src.core.jwt import create_token +from src.core.jwt import create_session, create_token from src.core.redis_client import redis from src.common.utils.ip import get_client_ip from src.common.utils.ip2region import get_ip_location @@ -83,8 +82,8 @@ def login(data: dict) -> dict: # 验证码校验 image_code = redis.get(key) - if FLASK_DEMO and CAPTCHA_BYPASS_CODE and code == CAPTCHA_BYPASS_CODE: - pass # 演示模式万能验证码,仅FLASK_DEMO=True时生效 + if FLASK_DEBUG and CAPTCHA_BYPASS_CODE and code == CAPTCHA_BYPASS_CODE: + pass # 演示模式万能验证码,仅FLASK_DEBUG=True时生效 else: # 验证码不区分大小写比对 if not image_code or image_code.lower() != code.lower(): @@ -92,8 +91,8 @@ def login(data: dict) -> dict: # 验证码使用后立即删除,防止重复使用 redis.delete(key) - # 查询用户信息(只查询未删除的用户) - user = User.query.filter(and_(User.username == username, User.is_delete == 0)).first() + # 查询用户信息(first_by 默认过滤已删除记录) + user = UserRepository.first_by({"username": username}) # 校验用户是否存在 if not user: @@ -116,11 +115,11 @@ def login(data: dict) -> dict: # 密码验证 if not verify_password(password, user.salt, user.password): - # 递增失败计数,首次失败时原子设置初始值和过期时间 - if redis.ttl(login_attempts_key) == -2: - redis.client.setex(login_attempts_key, LOGIN_LOCK_SECONDS, 1) - else: - redis.incr(login_attempts_key) + # 原子递增失败计数:INCR + EXPIRE NX(首次失败自动设 TTL,并发不丢计数) + pipe = redis.client.pipeline() + pipe.incr(login_attempts_key) + pipe.expire(login_attempts_key, LOGIN_LOCK_SECONDS, nx=True) + pipe.execute() _record_login(username, LoginLog.Status.ERROR, error="密码不正确") raise BizError("密码不正确") @@ -130,6 +129,10 @@ def login(data: dict) -> dict: # 生成访问令牌 access_token = create_token({"userId": user.id, "username": user.username, "realname": user.realname}) + # 创建滑动会话(Redis),无会话的令牌在首请求即失效 + if not create_session(access_token): + raise BizError("创建登录会话失败,请重试") + # 登录成功,返回令牌(纯数据) result = {"code": 0, "data": {"access_token": access_token}, "msg": "登录成功", "ok": True} @@ -180,7 +183,11 @@ def _record_login(username, status, error="", result_data=None): if data and isinstance(data, dict): result_data_value = data.get("data") if result_data_value is not None: - result = json.dumps(result_data_value, ensure_ascii=False) + # 登录成功返回的 access_token 属活跃凭据,不得写入审计日志 + if isinstance(result_data_value, dict) and "access_token" in result_data_value: + result = json.dumps({"access_token": "******"}, ensure_ascii=False) + else: + result = json.dumps(result_data_value, ensure_ascii=False) else: result = data.get("msg", "") except Exception: @@ -197,8 +204,8 @@ def _record_login(username, status, error="", result_data=None): source=LoginLog.Source.ADMIN, status=status, log_type=LoginLog.LogType.LOGIN, - method=request.method, - request_method=request.endpoint or "", + method=request.endpoint or "", + request_method=request.method, url=request.path, param=param, result=result, diff --git a/src/modules/city/models.py b/src/modules/city/models.py index bbcaa5d..5693cc8 100644 --- a/src/modules/city/models.py +++ b/src/modules/city/models.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -49,6 +49,7 @@ from src.common.base_db import BaseDB from src.common.base_model import BaseModel from config import DB_PREFIX +from sqlalchemy import text from src.core.database import db @@ -69,7 +70,7 @@ class City(BaseModel, BaseDB): area_code: 行政编码(String(20),必填) parent_code: 上级行政编码(String(20),必填) zip_code: 邮政编码(String(6),必填) - level: 城市级别(Integer,1-省份,2-城市,3-县区,4-街道) + level: 城市级别(Integer,0-省份 1-城市 2-县区 3-街道 4-社区) pid: 上级城市ID(Integer,默认0) name: 城市名称(String(150),必填) short_name: 城市简称(String(150),必填) @@ -86,6 +87,9 @@ class City(BaseModel, BaseDB): # 设置表名 __tablename__ = DB_PREFIX + "city" + # 表注释 + __table_comment__ = "城市表" + # ============================================================ # 字段定义 # ============================================================ @@ -94,6 +98,7 @@ class City(BaseModel, BaseDB): city_code = db.Column( db.String(6), nullable=False, + index=True, comment="城市区号" ) @@ -101,6 +106,7 @@ class City(BaseModel, BaseDB): area_code = db.Column( db.String(20), nullable=False, + index=True, comment="行政编码" ) @@ -108,6 +114,7 @@ class City(BaseModel, BaseDB): parent_code = db.Column( db.String(20), nullable=False, + index=True, comment="上级行政编码" ) @@ -118,11 +125,22 @@ class City(BaseModel, BaseDB): comment="邮政编码" ) - # 城市级别:1-省份 2-城市 3-县区 4-街道 + # 城市级别:0-省份 1-城市 2-县区 3-街道 4-社区 level = db.Column( db.Integer, default=0, - comment="城市级别:1-省份 2-城市 3-县区 4-街道" + server_default=text('0'), + index=True, + comment="城市级别:0-省份 1-城市 2-县区 3-街道 4-社区" + ) + + # 上级城市ID + pid = db.Column( + db.Integer, + default=0, + server_default=text('0'), + index=True, + comment="上级城市ID" ) # 城市名称 @@ -136,16 +154,10 @@ class City(BaseModel, BaseDB): short_name = db.Column( db.String(150), nullable=False, + index=True, comment="城市简称" ) - # 上级城市ID - pid = db.Column( - db.Integer, - default=0, - comment="上级城市ID" - ) - # 城市全称 full_name = db.Column( db.String(150), diff --git a/src/modules/city/repository.py b/src/modules/city/repository.py index aa0e122..cb69e8b 100644 --- a/src/modules/city/repository.py +++ b/src/modules/city/repository.py @@ -10,6 +10,61 @@ from src.modules.city.models import City class CityRepository(BaseRepository): - """City 仓储""" + """城市数据仓储,提供行政区划相关的数据库访问操作""" model = City + + @classmethod + def find_list_params(cls, params: dict): + """ + 按查询参数获取城市列表 + + 支持按父级ID精确过滤、按名称模糊搜索,按 id 升序排列。 + + Args: + params: 查询参数字典 + - pid: 父级ID(精确匹配) + - name: 城市名称(模糊匹配) + + Returns: + list: 城市实例列表 + """ + # 空串/缺失/非法 pid 统一回落到首层 pid=0(树形懒加载首层), + # 原实现 int('') 失败落到 None,被 _build_query 跳过过滤导致全表查询 + pid = params.get('pid') or 0 + try: + pid = int(pid) + except (TypeError, ValueError): + pid = 0 + return cls.find_list( + filters={'pid': pid}, + like_filters={'name': params.get('name')}, + order_by='id', + ) + + @classmethod + def list_by_pid(cls, parent_code): + """ + 按父级编码查询子级城市 + + 用于级联选择器场景。 + + Args: + parent_code: 父级行政区划编码 + + Returns: + list: 子级城市实例列表,按 id 升序排列 + """ + return cls.find_list(filters={'parent_code': parent_code}, order_by='id') + + @classmethod + def list_all(cls): + """ + 查询全部未删除城市 + + 用于树形展示场景。 + + Returns: + list: 城市实例列表,按 id 升序排列 + """ + return cls.find_list(order_by='id') diff --git a/src/modules/city/router.py b/src/modules/city/router.py index 70f418c..e7d744f 100644 --- a/src/modules/city/router.py +++ b/src/modules/city/router.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -57,7 +57,7 @@ from flask import Blueprint, request -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.core.permission import check_permission from src.modules.city import service as city from src.common.utils.request import parse_request_body @@ -72,9 +72,10 @@ city_blue = Blueprint("city", __name__, url_prefix="/city") # ============================================================ -@city_blue.route("/page", methods=["GET"]) -@check_permission("sys:city:page") -def page(): +@city_blue.route("/list", methods=["GET"]) +@check_permission("sys:city:list") +@operation_log("城市管理", "查询") +def list(): """ 查询城市分页数据 @@ -94,6 +95,7 @@ def page(): @city_blue.route("/detail/", methods=["GET"]) @check_permission("sys:city:detail") +@operation_log("城市管理", "详情") def detail(id): """ 查询城市详情 @@ -116,6 +118,7 @@ def detail(id): @city_blue.route("/add", methods=["POST"]) @check_permission("sys:city:add") @check_demo +@operation_log("城市管理", "新增") def add(): """ 添加城市 @@ -141,6 +144,7 @@ def add(): @city_blue.route("/update", methods=["PUT"]) @check_permission("sys:city:update") @check_demo +@operation_log("城市管理", "修改") def update(): """ 更新城市 @@ -166,6 +170,7 @@ def update(): @city_blue.route("/delete/", methods=["DELETE"]) @check_permission("sys:city:delete") @check_demo +@operation_log("城市管理", "删除") def delete(id): """ 删除城市 @@ -186,6 +191,7 @@ def delete(id): @city_blue.route("/getCityList/", methods=["GET"]) +@operation_log("城市管理", "查询子级") def get_child_list(city_code): """ 根据城市编码查询子级城市 diff --git a/src/modules/city/schema.py b/src/modules/city/schema.py index c745b71..ea776c5 100644 --- a/src/modules/city/schema.py +++ b/src/modules/city/schema.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -72,7 +72,7 @@ class CityForm(BaseForm): cityCode: 城市区号(必填,最长6字符) areaCode: 行政编码(必填,最长20字符) zipCode: 城市邮编(必填,最长6字符) - level: 城市级别(必填,1-省份,2-城市,3-县区,4-街道) + level: 城市级别(必填,0-省份 1-城市 2-县区 3-街道 4-社区) lng: 城市经度(必填,最长150字符) lat: 城市纬度(必填,最长150字符) pid: 上级ID(可选) @@ -148,7 +148,7 @@ class CityForm(BaseForm): DataRequired(message='城市级别不能为空'), NumberRange(min=1, max=4, message='城市级别值在1~4之间') ], - description='城市级别:1-省份,2-城市,3-县区,4-街道' + description='城市级别:0-省份 1-城市 2-县区 3-街道 4-社区' ) # ============================================================ @@ -181,7 +181,7 @@ class CityForm(BaseForm): pid = IntegerField( label='上级ID', validators=[ - # 可选字段,用于树形结构 + Optional(), # 可选字段,用于树形结构(顶级 pid=0/None) ], description='上级城市ID,用于构建城市层级关系' ) diff --git a/src/modules/city/service.py b/src/modules/city/service.py index 28997c0..fa60e66 100644 --- a/src/modules/city/service.py +++ b/src/modules/city/service.py @@ -1,91 +1,174 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 -# +====================================================================== -# | 版权和免责声明: -# | 一、知识产权声明 -# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 -# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, -# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 -# | 二、使用限制与禁止条款 -# | 您理解并同意,禁止将本产品用于以下任何行为: -# | 1. 任何违反中华人民共和国法律法规的用途; -# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; -# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 -# | 三、责任豁免 -# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 -# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 -# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 -# | 四、违约与法律责任 -# | 如发现任何违反本声明的行为,本团队有权: -# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; -# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; -# | 3. 保留进一步采取刑事控告等法律措施的权利。 -# | 五、许可范围说明 -# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 -# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 -# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 -# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 -# | 成的一切经济损失及商誉损害。 -# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 -# | 许可协议》附件为准。 -# +====================================================================== - """ 城市管理服务模块 ============================================================= 该模块实现了城市/行政区划管理相关的所有业务逻辑。 -包括城市的分页查询、详情查询、添加、更新、删除、子级城市查询等功能。 城市数据采用树形结构,通过 pid/parent_code 字段建立层级关系, 支持省、市、区三级行政区划管理。 - -命名规范: -- 函数参数使用驼峰命名(如 cityId, cityCode) -- 表单字段使用下划线命名(如 name, code, level)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters, _build_tree) - -功能模块: -1. 列表查询:支持按父级ID、名称模糊搜索 -2. 详情查询:根据ID获取单个城市的详细信息 -3. 数据维护:城市的添加、更新、删除(支持批量) -4. 子级查询:根据城市编码获取子级城市列表 -5. 城市树形数据:获取完整的城市树形结构 """ import logging -from datetime import datetime, timezone -from sqlalchemy import and_ +from pypinyin import Style, lazy_pinyin -from src.common.constants.dictionary_constants import CITY_LEVEL_LIST -from src.modules.city.schema import CityForm -from src.modules.city.models import City -from src.modules.city.repository import CityRepository -from src.core.exceptions import BizError -from src.common.utils import regular -from src.common.utils.case_converter import convert_camel_to_snake -from src.common.utils.request import parse_id_list, like_contains +from src.common.base_service import BaseService from src.common.utils.model_utils import models_to_list, model_to_dict -from src.core.security import get_username +from src.core.exceptions import BizError +from src.modules.city.repository import CityRepository +from src.modules.city.schema import CityForm +from src.modules.dictionary import dict_utils # 获取日志记录器 logger = logging.getLogger(__name__) +class CityService(BaseService): + """ + 城市服务类 + + 继承 BaseService 获得标准 CRUD 能力,并扩展删除前子级城市校验。 + """ + + repository = CityRepository + form = CityForm + model_name = "城市" + + @classmethod + def before_add(cls, form): + """ + 新增前钩子:自动补全 full_name / pinyin 派生字段 + + 全称沿 pid 链从首层城市拼接(如 台湾省,云林,元长,华鹿), + 拼音由简称生成(无音调,如 台湾 -> taiwan)。 + + Args: + form: 已校验的城市表单对象 + + Returns: + dict: full_name / pinyin 派生字段 + """ + data = form.data + return { + "full_name": cls._build_full_name(data.get("pid") or 0, data.get("name")), + "pinyin": cls._build_pinyin(data.get("shortName") or data.get("name")), + } + + @classmethod + def before_update(cls, form, obj): + """ + 更新前钩子:重算 full_name / pinyin(支持改名与变更父级) + + pid 缺省时沿用原记录,保证改名前缀不丢。 + + Args: + form: 已校验的城市表单对象 + obj: 已存在的城市记录 + + Returns: + dict: full_name / pinyin 派生字段 + """ + data = form.data + pid = data.get("pid") or obj.pid or 0 + return { + "full_name": cls._build_full_name(pid, data.get("name") or obj.name), + "pinyin": cls._build_pinyin( + data.get("shortName") or data.get("name") or obj.short_name + ), + } + + @classmethod + def _build_full_name(cls, pid, name): + """ + 沿 pid 链向上拼接城市全称,如 台湾省,云林,元长,华鹿 + + Args: + pid: 当前城市的父级ID(0 表示顶级) + name: 当前城市名称 + + Returns: + str: 逗号分隔的完整层级名称 + """ + names = [name or ""] + current = pid + while current: + parent = cls.repository.get_by_id(current) + if not parent: + break + names.append(parent.name) + current = parent.pid or 0 + return ",".join(reversed(names)) + + @staticmethod + def _build_pinyin(text): + """ + 由城市简称生成无音调拼音,音节首字母大写 + + 如 华鹿 -> HuaLu、台湾 -> TaiWan。 + + Args: + text: 城市名称或简称 + + Returns: + str: 无音调拼音串(每音节首字母大写) + """ + if not text: + return "" + return "".join(s.capitalize() for s in lazy_pinyin(text, style=Style.NORMAL)) + + @classmethod + def before_delete(cls, ids): + """ + 删除前校验钩子 + + 检查城市是否存在子级城市,若存在则拒绝删除。 + + Args: + ids: 待删除的城市ID列表 + + Raises: + BizError: 存在子级城市时抛出 + """ + for cid in ids: + if CityRepository.count_children(cid, parent_field='pid') > 0: + raise BizError(f"城市ID {cid} 存在子级城市,请先删除子级城市") + + @classmethod + def _detail_dict(cls, obj): + """ + 详情序列化钩子 + + 在标准字段基础上追加 parentName(父级城市名称)。 + + Args: + obj: 城市模型实例 + + Returns: + dict: 详情字典,包含 parentName 字段 + """ + data = model_to_dict(obj) + parent_id = getattr(obj, 'pid', None) + if parent_id: + parent = CityRepository.get_by_id(parent_id) + data['parentName'] = parent.name if parent else None + else: + data['parentName'] = None + return data + + # ============================================================ -# 城市列表查询 +# 模块级代理函数:保持 router 调用兼容 # ============================================================ +get_city_detail = CityService.get_detail +get_info = CityService.get_info +add_city = CityService.add +update_city = CityService.update +delete_cities = CityService.remove + + +# ============================================================ +# 城市列表查询(无分页) +# ============================================================ def get_city_list(params: dict) -> list: """ @@ -99,283 +182,59 @@ def get_city_list(params: dict) -> list: - name: 城市名称(可选,模糊查询) Returns: - list: 城市字典列表 + list: 城市字典列表,包含级别名称和是否有子级标识 """ - # 应用筛选条件 - query = _apply_filters(params) - - # 查询数据 - city_list = query.all() - - # 转换为字典列表并补充关联数据 - return _enrich_city_records(city_list) + city_list = CityRepository.find_list_params(params) + records = models_to_list(city_list) + return _enrich_city_records(records) -def _apply_filters(params: dict): +def _enrich_city_records(records): """ - 应用查询筛选条件 - - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 支持父级ID精确查询 - - 支持城市名称模糊查询 - - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = City.query.filter(City.is_delete == 0) - - # 父级ID精确筛选(默认为0,查询顶级城市) - pid = params.get("pid", 0) - if pid is not None: - try: - query = query.filter(City.pid == int(pid)) - except ValueError: - pass - - # 城市名称模糊筛选 - name = params.get("name") - if name: - query = query.filter(City.name.like(like_contains(name), escape="\\")) - - # 按ID升序排列 - return query.order_by(City.id.asc()) - - -def _enrich_city_records(city_list): - """ - 为城市记录补充关联数据 + 为城市记录补充关联数据(级别名称、是否有子级) Args: - city_list: 城市模型对象列表 + records: 城市字典列表 - Returns: - list: 补充关联数据后的城市字典列表 + Returns: + list: 补充关联数据后的城市字典列表 """ - if not city_list: + if not records: return [] - records = models_to_list(city_list) - + # 级别名称走字典表(dict_utils 带缓存,后台改字典即改显示文本) + level_map = dict_utils.get_value_map("city_level") for data in records: - # 城市级别名称 level = data.get("level") - data["level_name"] = CITY_LEVEL_LIST.get(level) if level else None - # 是否有子级(level < 3 表示还有下级) + data["level_name"] = level_map.get(str(level)) if level else None + # 级别小于3表示有下级(省/市/区三级) data["have_child"] = data.get("level", 4) < 3 if data.get("level") else False return records -# ============================================================ -# 城市详情查询 -# ============================================================ - - -def get_city_detail(city_id): - """ - 根据ID查询城市详情(返回字典格式) - - 此方法调用 get_info 获取模型对象,然后转换为字典格式返回。 - 适用于前端接口调用。 - - Args: - city_id: 城市ID - - Returns: - dict/None: 城市详情字典,不存在时返回 None - """ - try: - # 复用 get_info 方法查询城市 - city = get_info(city_id) - if not city: - return None - # 转换为字典格式 - return model_to_dict(city) - - except Exception as e: - logger.error(f"查询城市详情异常,ID: {city_id}, 错误: {str(e)}") - return None - - -# ============================================================ -# 城市信息查询(根据ID)- 核心查询方法 -# ============================================================ - - -def get_info(city_id: int): - """ - 根据ID查询城市信息(返回模型对象) - - 这是一个通用的城市查询方法,返回城市模型对象而非字典。 - 与 get_city_detail 的区别: - - get_city_detail: 返回字典格式,用于前端接口 - - get_info: 返回模型对象,用于内部服务调用 - - 此方法被以下方法复用: - - get_city_detail: 获取详情字典 - - update_city: 验证城市是否存在 - - delete_cities: 验证城市是否存在 - - Args: - city_id: 城市ID - - Returns: - City/None: 城市模型对象,不存在或已删除时返回 None - - Example: - city = get_info(city_id) - if city: - print(city.name) - """ - return CityRepository.get_by_id(city_id) - - -def add_city(data: dict) -> None: - """ - 添加城市 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证(编码唯一性、字段格式等) - 3. 创建城市对象并保存到数据库 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = CityForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 创建城市 - city = City(**form.data) - city.create_user = get_username() - city.save() - - -# ============================================================ -# 城市更新 -# ============================================================ - - -def update_city(data: dict) -> None: - """ - 更新城市 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 验证城市是否存在(复用 get_info) - 4. 更新城市信息 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = CityForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 获取城市ID - city_id = form.data.get("id") - if not city_id or int(city_id) <= 0: - raise BizError("城市ID无效") - city_id = int(city_id) - - # 复用 get_info 方法验证城市是否存在 - city = get_info(city_id) - if not city: - raise BizError("城市不存在") - - # 更新城市信息(移除ID,避免更新主键) - form_data = convert_camel_to_snake(form.data.copy()) - form_data.pop("id", None) - - form_data["update_time"] = datetime.now(tz=timezone.utc) - form_data["update_user"] = get_username() - - result = City.query.filter_by(id=city_id).update(form_data) - - if not result: - raise BizError("更新失败") - - -# ============================================================ -# 城市删除 -# ============================================================ - - -def delete_cities(city_ids: str) -> int: - """ - 删除城市(批量删除) - - 删除 - 支持逗号分隔的多个ID批量删除。 - - Args: - city_ids: 城市ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - if not city_ids: - raise BizError("记录ID不存在") - - # 解析ID列表 - id_list = parse_id_list(city_ids) - if not id_list: - raise BizError("无效的记录ID") - - # 检查是否有子级城市 - for city_id in id_list: - child_count = City.query.filter(and_(City.pid == city_id, City.is_delete == 0)).count() - if child_count > 0: - raise BizError(f"城市ID {city_id} 存在子级城市,请先删除子级城市") - - city_ids_list = parse_id_list(city_ids) - if not city_ids_list: - raise BizError("无效的记录ID") - return City.batch_delete(city_ids_list) - - # ============================================================ # 子级城市查询 # ============================================================ - def get_child_cities(city_code): """ 根据城市编码获取子级城市列表 - 用于级联选择器,根据父级编码查询下级城市。 + 用于级联选择器场景。 Args: city_code: 父级城市编码 Returns: list: 子级城市列表,每个元素包含 area_code 和 name - - Example: - child_list = get_child_cities("440000") - # 返回:[{"area_code": "440100", "name": "广州市"}, ...] """ if not city_code: return [] try: - # 查询子级城市 - child_list = ( - City.query.filter(and_(City.parent_code == city_code, City.is_delete == 0)).order_by(City.id.asc()).all() - ) - - # 构建返回数据 - result = models_to_list(child_list) - return result - + child_list = CityRepository.list_by_pid(city_code) + return models_to_list(child_list) except Exception as e: logger.error(f"查询子级城市异常,城市编码: {city_code}, 错误: {str(e)}") return [] @@ -385,32 +244,21 @@ def get_child_cities(city_code): # 城市树形数据获取 # ============================================================ - def get_city_tree(): """ 获取城市树形数据 - 用于级联选择器、行政区划选择等场景。 - 返回完整的省-市-区三级树形结构。 + 构建省-市-区三级树形结构,用于级联选择器。 Returns: list: 城市树形数据列表 """ try: - # 查询所有未删除的城市 - all_cities = City.query.filter(City.is_delete == 0).order_by(City.id.asc()).all() - + all_cities = CityRepository.list_all() if not all_cities: return [] - - # 转换为字典列表 city_dicts = models_to_list(all_cities) - - # 构建树形结构 - tree = _build_city_tree(city_dicts) - - return tree - + return _build_city_tree(city_dicts) except Exception as e: logger.error(f"获取城市树形数据异常: {str(e)}") return [] @@ -418,14 +266,14 @@ def get_city_tree(): def _build_city_tree(city_list, parent_id=0): """ - 构建城市树形结构 + 递归构建城市树形结构 Args: city_list: 城市字典列表 parent_id: 父级ID Returns: - list: 树形结构数据 + list: 树形结构数据,包含 id、name、code、level 和 children """ tree = [] for city in city_list: diff --git a/src/modules/cms/article/models.py b/src/modules/cms/article/models.py index ec6b652..c78fdce 100644 --- a/src/modules/cms/article/models.py +++ b/src/modules/cms/article/models.py @@ -8,6 +8,7 @@ from src.common.base_db import BaseDB from src.common.base_model import BaseModel from config import DB_PREFIX +from sqlalchemy import text from src.core.database import db @@ -30,7 +31,7 @@ class Article(BaseModel, BaseDB): intro: 文章导读(String(255),可选) content: 文章内容(Text,可选,富文本内容) author: 文章作者(String(100),可选) - status: 文章状态(Integer,0-正常,1-下架,默认0) + status: 文章状态(Integer,1-正常,2-下架,默认1) click: 文章点击率(Integer,默认0) sort: 文章排序(Integer,默认0) """ @@ -42,6 +43,9 @@ class Article(BaseModel, BaseDB): # 设置表名 __tablename__ = DB_PREFIX + "article" + # 表注释 + __table_comment__ = "文章表" + # ============================================================ # 字段定义 # ============================================================ @@ -50,6 +54,7 @@ class Article(BaseModel, BaseDB): category_id = db.Column( db.Integer, nullable=True, + index=True, comment="分类ID" ) @@ -57,6 +62,7 @@ class Article(BaseModel, BaseDB): title = db.Column( db.String(150), nullable=True, + index=True, comment="文章标题" ) @@ -99,7 +105,9 @@ class Article(BaseModel, BaseDB): status = db.Column( db.Integer, nullable=False, - default=0, + default=1, + server_default=text('1'), + index=True, comment="文章状态:0-正常 1-下架" ) @@ -108,6 +116,7 @@ class Article(BaseModel, BaseDB): db.Integer, nullable=False, default=0, + server_default=text('0'), comment="文章点击率" ) @@ -116,6 +125,7 @@ class Article(BaseModel, BaseDB): db.Integer, nullable=False, default=0, + server_default=text('0'), comment="文章排序" ) diff --git a/src/modules/cms/article/repository.py b/src/modules/cms/article/repository.py index 1df273f..f360dda 100644 --- a/src/modules/cms/article/repository.py +++ b/src/modules/cms/article/repository.py @@ -5,11 +5,46 @@ Article 仓储模块 数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 """ +from src.common.constants.common import PAGE_SIZE from src.common.base_repository import BaseRepository from src.modules.cms.article.models import Article class ArticleRepository(BaseRepository): - """Article 仓储""" + """文章数据仓储,提供文章相关的数据库访问操作""" model = Article + + @classmethod + def page(cls, params: dict): + """ + 文章分页查询 + + 支持按标题模糊搜索,按分类ID和状态精确过滤, + 按创建时间范围筛选,按 sort 升序、create_time 倒序排列。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + - title: 文章标题(模糊匹配) + - categoryId: 分类ID(精确匹配) + - status: 状态(精确匹配) + - startTime: 创建时间起始 + - endTime: 创建时间结束 + + Returns: + tuple: (items, total, pages) + - items: 当前页数据列表 + - total: 总记录数 + - pages: 总页数 + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + like_filters={'title': params.get('title')}, + filters={'category_id': params.get('categoryId'), 'status': params.get('status')}, + range_filters={'create_time': (params.get('startTime'), params.get('endTime'))}, + order_by='sort', + order_desc='create_time', + ) diff --git a/src/modules/cms/article/router.py b/src/modules/cms/article/router.py index a58d969..b1c83b1 100644 --- a/src/modules/cms/article/router.py +++ b/src/modules/cms/article/router.py @@ -18,7 +18,7 @@ from flask import Blueprint, request -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.core.permission import check_permission from src.modules.cms.article import service as article from src.common.utils.request import parse_batch_ids, parse_request_body @@ -35,6 +35,7 @@ article_blue = Blueprint("article", __name__, url_prefix="/article") @article_blue.route("/page", methods=["GET"]) @check_permission("sys:article:page") +@operation_log("文章管理", "查询") def page(): """ 查询文章分页数据 @@ -63,6 +64,7 @@ def page(): @article_blue.route("/detail/", methods=["GET"]) +@operation_log("文章管理", "详情") def detail(id): """ 查询文章详情 @@ -90,6 +92,7 @@ def detail(id): @article_blue.route("/add", methods=["POST"]) @check_permission("sys:article:add") @check_demo +@operation_log("文章管理", "新增") def add(): """ 添加文章 @@ -128,6 +131,7 @@ def add(): @article_blue.route("/update", methods=["PUT"]) @check_permission("sys:article:update") @check_demo +@operation_log("文章管理", "修改") def update(): """ 更新文章 @@ -153,6 +157,7 @@ def update(): @article_blue.route("/delete/", methods=["DELETE"]) @check_permission("sys:article:delete") @check_demo +@operation_log("文章管理", "删除") def delete(id): """ 删除文章 @@ -173,8 +178,9 @@ def delete(id): @article_blue.route("/batchDelete", methods=["DELETE"]) -@check_permission("sys:article:batch_delete") +@check_permission("sys:article:batchDelete") @check_demo +@operation_log("文章管理", "批量删除") def batch_delete(): """ 批量删除文章 @@ -191,7 +197,8 @@ def batch_delete(): id_str, err = parse_batch_ids() if err: return err - return article.delete_articles(id_str) + count = article.delete_articles(id_str) + return R.ok(msg=f"本次共删除{count}条数据") # ============================================================ @@ -202,6 +209,7 @@ def batch_delete(): @article_blue.route("/status", methods=["PUT"]) @check_permission("sys:article:status") @check_demo +@operation_log("文章管理", "状态变更") def status(): """ 设置文章状态 diff --git a/src/modules/cms/article/schema.py b/src/modules/cms/article/schema.py index 91594c4..103ac46 100644 --- a/src/modules/cms/article/schema.py +++ b/src/modules/cms/article/schema.py @@ -211,7 +211,7 @@ class ArticleStatusForm(BaseForm): status = IntegerField( label='文章状态', validators=[ - DataRequired(message='文章状态不能为空'), + Optional(), # 允许 0(正常) NumberRange(min=0, max=1, message='文章状态值在0~1之间') ], description='目标状态:0-正常,1-下架' diff --git a/src/modules/cms/article/service.py b/src/modules/cms/article/service.py index f71e9d0..0d15ec7 100644 --- a/src/modules/cms/article/service.py +++ b/src/modules/cms/article/service.py @@ -4,440 +4,145 @@ 该模块实现了文章管理相关的所有业务逻辑。 包括文章的分页查询、详情查询、添加、更新、删除、状态设置、点击率统计等功能。 - -命名规范: -- 函数参数使用驼峰命名(如 articleId, pageNo) -- 表单字段使用下划线命名(如 title, category_id)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters, _build_page_data) - -功能模块: -1. 分页查询:支持按标题模糊搜索、分类筛选、状态筛选,按sort字段排序 -2. 详情查询:根据ID获取单个文章的详细信息 -3. 数据维护:文章的添加、更新、删除(支持批量) -4. 状态管理:单独更新文章的启用/下架状态 -5. 点击统计:增加文章点击量 -6. 文章列表:获取所有文章列表(用于下拉选择等场景) """ import logging -from datetime import datetime, timezone - -from src.common.constants.common import PAGE_SIZE -from src.common.constants.dictionary_constants import ARTICLE_STATUS_LIST -from src.modules.cms.article.schema import ArticleForm, ArticleStatusForm -from src.modules.cms.article.models import Article -from src.modules.cms.article.repository import ArticleRepository -from src.core.exceptions import BizError -from config import FLASK_FILE_URL -from src.common.utils import regular -from src.common.utils.case_converter import convert_camel_to_snake -from src.common.utils.model_utils import models_to_list, model_to_dict -from src.common.utils.request import parse_id_list, like_contains +from src.common.base_service import BaseService +from src.common.utils.model_utils import model_to_dict from src.common.utils.rich_text import get_file_url, save_file, save_content -from src.core.security import get_username +from src.modules.cms.article.schema import ArticleForm, ArticleStatusForm +from src.modules.cms.article.repository import ArticleRepository +from src.modules.dictionary import dict_utils +from config import FLASK_FILE_URL # 获取日志记录器 logger = logging.getLogger(__name__) -# ============================================================ -# 文章分页查询 -# ============================================================ - - -def get_article_page(params: dict) -> dict: +class ArticleService(BaseService): """ - 查询文章分页数据 + 文章服务类 - Args: - request: Flask request对象,包含查询参数 - - pageNo: 当前页码(可选,默认为1) - - pageSize: 每页记录数(可选,默认为PAGE_SIZE) - - title: 文章标题(可选,模糊查询) - - category_id: 分类ID(可选,精确查询) - - status: 文章状态(可选,精确查询) - - startTime: 开始时间(可选) - - endTime: 结束时间(可选) - - Returns: - R: 包含分页数据的JSON响应,格式为: - { - "records": 文章列表, - "total": 总记录数, - "size": 每页大小, - "current": 当前页码, - "pages": 总页数 - } + 继承 BaseService 获得标准 CRUD 能力,并扩展封面、图集、富文本处理。 """ - # 获取分页参数 - page_no = int(params.get("pageNo", 1)) - page_size = int(params.get("pageSize", PAGE_SIZE)) - # 应用筛选条件 - query = _apply_filters(params) + repository = ArticleRepository + form = ArticleForm + status_form = ArticleStatusForm + model_name = "文章" - # 记录总数 - total = query.count() - # 计算总页数 - pages = (total + page_size - 1) // page_size if total > 0 else 0 + @classmethod + def before_add(cls, form): + """ + 新增前钩子 - # 分页查询 - article_list = ( - query.order_by(Article.sort.asc(), Article.create_time.desc()) - .limit(page_size) - .offset((page_no - 1) * page_size) - .all() - ) + 处理文章封面、图集和富文本内容。 - # 构建返回数据 - page_data = _build_page_data(article_list, total, page_size, page_no, pages) - return page_data + Args: + form: 已校验的表单对象 + """ + _handle_article_media(form) + @classmethod + def before_update(cls, form, obj): + """ + 更新前钩子 -def _apply_filters(params: dict): - """ - 应用查询筛选条件 + 处理文章封面、图集和富文本内容。 - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 支持文章标题模糊查询 - - 支持分类ID精确查询 - - 支持文章状态精确查询 - - 支持时间范围查询 + Args: + form: 已校验的表单对象 + obj: 已存在的模型实例 + """ + _handle_article_media(form) - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = Article.query.filter(Article.is_delete == 0) + @classmethod + def _enrich_page_records(cls, records): + """ + 分页富化钩子 - # 文章标题模糊筛选 - title = params.get("title") - if title: - query = query.filter(Article.title.like(like_contains(title), escape="\\")) + 处理封面 URL,补充状态显示文本(走字典表翻译)。 - # 分类ID精确筛选 - category_id = params.get("categoryId") - if category_id: - query = query.filter(Article.category_id == category_id) + Args: + records: 文章字典列表 - # 文章状态精确筛选 - status = params.get("status") - if status: - query = query.filter(Article.status == status) + Returns: + list: 富化后的文章字典列表 + """ + status_map = dict_utils.get_value_map("article_status") + for record in records: + record["cover"] = get_file_url(record.get("cover")) + record["statusText"] = status_map.get(str(record.get("status"))) + return records - # 时间范围筛选 - start_time = params.get("startTime") - end_time = params.get("endTime") + @classmethod + def _detail_dict(cls, obj): + """ + 详情序列化钩子 - if start_time: - try: - start_date = datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S") - query = query.filter(Article.create_time >= start_date) - except ValueError: - pass + 处理封面 URL、图集列表、富文本图片替换,补充状态显示文本。 - if end_time: - try: - end_date = datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S") - query = query.filter(Article.create_time <= end_date) - except ValueError: - pass + Args: + obj: 文章模型实例 - return query - - -def _build_page_data(article_list, total, page_size, page_no, pages): - """ - 构建分页返回数据 - - Args: - article_list: 文章对象列表 - total: 总记录数 - page_size: 每页大小 - page_no: 当前页码 - pages: 总页数 - - Returns: - dict: 分页数据,包含 records、total、size、current、pages - """ - # 将模型对象列表转换为字典列表 - records = models_to_list(article_list) - - # 为每条记录添加状态文本映射 - for record in records: - # 文章封面 - record["cover"] = get_file_url(record.get("cover")) - # 文章状态 - record["statusText"] = ARTICLE_STATUS_LIST.get(record.get("status")) - - return {"records": records, "total": total, "size": page_size, "current": page_no, "pages": pages} - - -# ============================================================ -# 文章详情查询 -# ============================================================ - - -def get_article_detail(article_id): - """ - 根据ID查询文章详情(返回字典格式) - - 此方法调用 get_info 获取模型对象,然后转换为字典格式返回。 - 适用于前端接口调用。 - - Args: - article_id: 文章ID - - Returns: - dict/None: 文章详情字典,不存在时返回 None - """ - try: - # 复用 get_info 方法查询文章 - article = get_info(article_id) - if not article: - return None - # 转换为字典格式 - record = model_to_dict(article) - - # 图片封面 + Returns: + dict: 序列化后的字典 + """ + record = model_to_dict(obj) record["cover"] = get_file_url(record["cover"]) - # 处理图集(逗号分隔转URL列表) + # 处理图集列表 images = record.get("images") if images: - # 将逗号分隔的路径转换为URL列表 image_paths = [path.strip() for path in images.split(",") if path.strip()] record["imagesList"] = [get_file_url(path) for path in image_paths] else: record["imagesList"] = [] - # 富文本处理 + # 富文本图片URL替换 content = record.get("content") if content: record["content"] = content.replace("[IMG_URL]", FLASK_FILE_URL) - # 文章状态 - record["statusText"] = ARTICLE_STATUS_LIST.get(record.get("status")) - + record["statusText"] = dict_utils.get_name("article_status", record.get("status")) return record - except Exception as e: - logger.error(f"查询文章详情异常,ID: {article_id}, 错误: {str(e)}") - return None - -# ============================================================ -# 文章信息查询(根据ID)- 核心查询方法 -# ============================================================ - - -def get_info(article_id: int): +def _handle_article_media(form): """ - 根据ID查询文章信息(返回模型对象) - - 这是一个通用的文章查询方法,返回文章模型对象而非字典。 - 与 get_article_detail 的区别: - - get_article_detail: 返回字典格式,用于前端接口 - - get_info: 返回模型对象,用于内部服务调用 - - 此方法被以下方法复用: - - get_article_detail: 获取详情字典 - - update_article: 验证文章是否存在 - - update_article_status: 验证文章是否存在 - - increment_click: 增加点击量 + 封面/图集/富文本处理并回写表单数据 Args: - article_id: 文章ID - - Returns: - Article/None: 文章模型对象,不存在或已删除时返回 None - - Example: - article = get_info(article_id) - if article: - print(article.title) + form: 表单对象 """ - return ArticleRepository.get_by_id(article_id) - - -def add_article(data: dict) -> None: - """ - 添加文章 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 处理images字段(JSON序列化) - 4. 创建文章对象并保存到数据库 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = ArticleForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 文章封面 + # 处理文章封面 cover = form.data.get("cover") if cover: - cover = save_file(cover, "article") - form.cover.data = cover + form.cover.data = save_file(cover, "article") - # 文章图集(逗号分隔的图片路径) + # 处理文章图集(逗号分隔的图片路径) images = form.data.get("images") if images: image_paths = [img.strip() for img in images.split(",") if img.strip()] - processed_paths = [] - for img in image_paths: - image = save_file(img, "article") - processed_paths.append(image) - # 处理后的地址逗号分隔 + processed_paths = [save_file(img, "article") for img in image_paths] form.images.data = ",".join(processed_paths) - # 文章内容 + # 处理富文本内容中的图片 title = form.data.get("title", "") content = form.data.get("content", "") if content: form.content.data = save_content(content, title, "article") - # 创建文章 - article = Article(**form.data) - article.create_user = get_username() - article.save() - # ============================================================ -# 文章更新 +# 模块级代理函数:保持 router 调用兼容 # ============================================================ - -def update_article(data: dict) -> None: - """ - 更新文章 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 验证文章是否存在(复用 get_info) - 4. 处理images字段 - 5. 更新文章信息 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = ArticleForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 获取文章ID - article_id = form.data.get("id") - if not article_id or int(article_id) <= 0: - raise BizError("文章ID无效") - article_id = int(article_id) - - # 复用 get_info 方法验证文章是否存在 - article = get_info(article_id) - if not article: - raise BizError("文章不存在") - - # 文章封面 - cover = form.data.get("cover") - if cover: - cover = save_file(cover, "article") - form.cover.data = cover - - # 文章图集(逗号分隔的图片路径) - images = form.data.get("images") - if images: - image_paths = [img.strip() for img in images.split(",") if img.strip()] - processed_paths = [] - for img in image_paths: - image = save_file(img, "article") - processed_paths.append(image) - # 处理后的地址逗号分隔 - form.images.data = ",".join(processed_paths) - - # 文章内容 - title = form.data.get("title", "") - content = form.data.get("content", "") - if content: - form.content.data = save_content(content, title, "article") - - # 更新文章信息(移除ID,避免更新主键) - form_data = convert_camel_to_snake(form.data.copy()) - form_data.pop("id", None) - - form_data["update_time"] = datetime.now(tz=timezone.utc) - form_data["update_user"] = get_username() - - result = Article.query.filter_by(id=article_id).update(form_data) - - if not result: - raise BizError("更新失败") - - -# ============================================================ -# 文章删除 -# ============================================================ - - -def delete_articles(article_ids: str) -> int: - """ - 删除文章(批量删除) - - 删除 - 支持逗号分隔的多个ID批量删除。 - - Args: - article_ids: 文章ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - article_ids_list = parse_id_list(article_ids) - if not article_ids_list: - raise BizError("无效的记录ID") - return Article.batch_delete(article_ids_list) - - -# ============================================================ -# 文章状态设置 -# ============================================================ - - -def update_article_status(data: dict) -> None: - """ - 设置文章状态 - - 单独更新文章的启用/下架状态,不涉及其他字段。 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = ArticleStatusForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - article_id = int(form.data["id"]) - status = int(form.data["status"]) - - # 复用 get_info 方法验证文章是否存在 - article = get_info(article_id) - if not article: - raise BizError("文章不存在") - - # 更新状态 - update_data = {"status": status, "update_time": datetime.now(tz=timezone.utc), "update_user": get_username()} - result = Article.query.filter_by(id=article_id).update(update_data) - - if not result: - raise BizError("设置失败") +get_article_page = ArticleService.get_page +get_article_detail = ArticleService.get_detail +get_info = ArticleService.get_info +add_article = ArticleService.add +update_article = ArticleService.update +delete_articles = ArticleService.remove +update_article_status = ArticleService.update_status diff --git a/src/modules/cms/category/models.py b/src/modules/cms/category/models.py index 82ab008..8acba74 100644 --- a/src/modules/cms/category/models.py +++ b/src/modules/cms/category/models.py @@ -8,6 +8,7 @@ from src.common.base_db import BaseDB from src.common.base_model import BaseModel from config import DB_PREFIX +from sqlalchemy import text from src.core.database import db @@ -29,7 +30,6 @@ class Category(BaseModel, BaseDB): parent_id: 上级分类ID(Integer,默认0,0表示顶级分类) sort: 分类排序(Integer,默认0,数值越小越靠前) note: 分类备注(String(100),可选) - type: 分类类型(Integer,必填,1-公司,2-子公司,3-部门,4-小组) """ # ============================================================ @@ -39,6 +39,9 @@ class Category(BaseModel, BaseDB): # 设置表名 __tablename__ = DB_PREFIX + "category" + # 表注释 + __table_comment__ = "分类表" + # ============================================================ # 字段定义 # ============================================================ @@ -47,13 +50,14 @@ class Category(BaseModel, BaseDB): category_name = db.Column( db.String(100), nullable=False, + index=True, comment="分类名称" ) # 分类编码 category_code = db.Column( db.String(100), - nullable=True, + nullable=False, comment="分类编码" ) @@ -61,7 +65,9 @@ class Category(BaseModel, BaseDB): parent_id = db.Column( db.Integer, nullable=False, + index=True, default=0, + server_default=text('0'), comment="上级ID" ) @@ -70,6 +76,7 @@ class Category(BaseModel, BaseDB): db.Integer, nullable=False, default=0, + server_default=text('0'), comment="排序" ) @@ -80,24 +87,6 @@ class Category(BaseModel, BaseDB): comment="分类备注" ) - # 分类类型 - type = db.Column( - db.Integer, - nullable=False, - comment="分类类型:1-公司 2-子公司 3-部门 4-小组" - ) - - # ============================================================ - # 常量定义 - # ============================================================ - - class Type: - """分类类型常量""" - COMPANY = 1 # 公司 - SUBSIDIARY = 2 # 子公司 - DEPARTMENT = 3 # 部门 - GROUP = 4 # 小组 - # ============================================================ # 魔法方法 # ============================================================ diff --git a/src/modules/cms/category/repository.py b/src/modules/cms/category/repository.py index 7c4d386..6905828 100644 --- a/src/modules/cms/category/repository.py +++ b/src/modules/cms/category/repository.py @@ -10,6 +10,37 @@ from src.modules.cms.category.models import Category class CategoryRepository(BaseRepository): - """Category 仓储""" + """分类数据仓储,提供分类相关的数据库访问操作""" model = Category + + @classmethod + def find_list_params(cls, params: dict): + """ + 按查询参数获取分类列表 + + 支持按分类名称模糊搜索、按类型精确过滤,按 sort 字段升序排列。 + + Args: + params: 查询参数字典 + - category_name: 分类名称(模糊匹配) + + Returns: + list: 分类实例列表 + """ + return cls.find_list( + like_filters={'category_name': params.get('category_name')}, + order_by='sort', + ) + + @classmethod + def list_all(cls): + """ + 查询全部未删除分类 + + 用于下拉选择或树形展示场景。 + + Returns: + list: 分类实例列表,按 sort 升序排列 + """ + return cls.find_list(order_by='sort') diff --git a/src/modules/cms/category/router.py b/src/modules/cms/category/router.py index afadb3b..54204a1 100644 --- a/src/modules/cms/category/router.py +++ b/src/modules/cms/category/router.py @@ -17,7 +17,7 @@ from flask import Blueprint, request -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.core.permission import check_permission from src.modules.cms.category import service as category from src.common.utils.request import parse_request_body @@ -33,7 +33,8 @@ category_blue = Blueprint("category", __name__, url_prefix="/category") @category_blue.route("/list", methods=["GET"]) -@check_permission("sys:category:list") +@check_permission("sys:category:page") +@operation_log("分类管理", "查询") def list(): """ 查询分类列表 @@ -58,6 +59,7 @@ def list(): @category_blue.route("/detail/", methods=["GET"]) @check_permission("sys:category:detail") +@operation_log("分类管理", "详情") def detail(id): """ 查询分类详情 @@ -82,6 +84,7 @@ def detail(id): @category_blue.route("/add", methods=["POST"]) @check_permission("sys:category:add") @check_demo +@operation_log("分类管理", "新增") def add(): """ 添加分类 @@ -117,6 +120,7 @@ def add(): @category_blue.route("/update", methods=["PUT"]) @check_permission("sys:category:update") @check_demo +@operation_log("分类管理", "修改") def update(): """ 更新分类 @@ -153,6 +157,7 @@ def update(): @category_blue.route("/delete/", methods=["DELETE"]) @check_permission("sys:category:delete") @check_demo +@operation_log("分类管理", "删除") def delete(id): """ 删除分类 @@ -173,6 +178,7 @@ def delete(id): @category_blue.route("/getCategoryList", methods=["GET"]) +@operation_log("分类管理", "查询") def get_category_list(): """ 获取分类数据列表 diff --git a/src/modules/cms/category/schema.py b/src/modules/cms/category/schema.py index f849a7c..b3dc8fe 100644 --- a/src/modules/cms/category/schema.py +++ b/src/modules/cms/category/schema.py @@ -10,7 +10,7 @@ from wtforms import ValidationError, IntegerField, StringField from wtforms.validators import NumberRange, DataRequired, Length, Optional from src.common.forms.base_form import BaseForm -from src.modules.cms.category.models import Category +from src.modules.cms.category.repository import CategoryRepository # ============================================================ @@ -31,7 +31,6 @@ class CategoryForm(BaseForm): parentId: 上级ID(可选,不得小于0) sort: 分类排序(可选,0-99999之间的整数) note: 分类备注(可选,最长100字符) - type: 分类类型(必填,1-公司,2-子公司,3-部门,4-小组) """ # ============================================================ @@ -64,7 +63,7 @@ class CategoryForm(BaseForm): categoryCode = StringField( label='分类编码', validators=[ - Optional(), + DataRequired(message='分类编码不能为空'), Length(max=100, message='分类编码长度不得超过100个字符') ], description='分类唯一标识编码,用于程序调用' @@ -76,6 +75,7 @@ class CategoryForm(BaseForm): parentId = IntegerField( label='上级ID', validators=[ + Optional(), # 允许 0(顶级分类) NumberRange(min=0, message='上级ID不得小于0') ], description='上级分类ID,0表示顶级分类' @@ -129,18 +129,8 @@ class CategoryForm(BaseForm): # 获取当前记录的ID(更新时存在,添加时为None) current_id = self.id.data if self.id.data else None - # 构建查询:查找同名记录 - query = Category.query.filter( - Category.category_name == field.data, - Category.is_delete == 0 - ) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(Category.id != current_id) - - # 如果存在同名记录,抛出验证错误 - if query.first(): + # 唯一性校验(软删过滤 + 更新时排除自身) + if CategoryRepository.exists_by({'category_name': field.data}, exclude_id=current_id): raise ValidationError("分类名称已存在") def validate_categoryCode(self, field): @@ -163,16 +153,6 @@ class CategoryForm(BaseForm): # 获取当前记录的ID(更新时存在,添加时为None) current_id = self.id.data if self.id.data else None - # 构建查询:查找相同编码的记录 - query = Category.query.filter( - Category.category_code == field.data, - Category.is_delete == 0 - ) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(Category.id != current_id) - - # 如果存在相同编码的记录,抛出验证错误 - if query.first(): + # 唯一性校验(软删过滤 + 更新时排除自身) + if CategoryRepository.exists_by({'category_code': field.data}, exclude_id=current_id): raise ValidationError("分类编码已存在") diff --git a/src/modules/cms/category/service.py b/src/modules/cms/category/service.py index 448a414..17f403d 100644 --- a/src/modules/cms/category/service.py +++ b/src/modules/cms/category/service.py @@ -4,45 +4,63 @@ 该模块实现了分类管理相关的所有业务逻辑。 包括分类的列表查询、详情查询、添加、更新、删除、树形数据获取等功能。 - -命名规范: -- 函数参数使用驼峰命名(如 categoryId, pageNo) -- 表单字段使用下划线命名(如 category_name, parent_id)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters) - -功能模块: -1. 列表查询:支持按名称模糊搜索,按sort字段排序 -2. 详情查询:根据ID获取单个分类的详细信息 -3. 数据维护:分类的添加、更新、删除(支持批量) -4. 树形数据:获取所有分类数据(用于树形展示、下拉选择等场景) -5. 获取子分类:根据父级ID获取子分类列表 -6. 获取分类树:构建完整的分类树结构 """ import logging -from datetime import datetime, timezone -from sqlalchemy import and_ - -from src.modules.cms.category.schema import CategoryForm -from src.modules.cms.category.models import Category -from src.modules.cms.category.repository import CategoryRepository +from src.common.base_service import BaseService from src.core.exceptions import BizError -from src.common.utils import regular -from src.common.utils.case_converter import convert_camel_to_snake -from src.common.utils.request import parse_id_list, like_contains -from src.common.utils.model_utils import models_to_list, model_to_dict -from src.core.security import get_username +from src.modules.cms.category.repository import CategoryRepository +from src.modules.cms.category.schema import CategoryForm +from src.common.utils.model_utils import models_to_list # 获取日志记录器 logger = logging.getLogger(__name__) +class CategoryService(BaseService): + """ + 分类服务类 + + 继承 BaseService 获得标准 CRUD 能力,并扩展删除前子分类校验。 + """ + + repository = CategoryRepository + form = CategoryForm + model_name = "分类" + + @classmethod + def before_delete(cls, ids): + """ + 删除前校验钩子 + + 检查分类是否存在子分类,若存在则拒绝删除。 + + Args: + ids: 待删除的分类ID列表 + + Raises: + BizError: 存在子分类时抛出 + """ + for cid in ids: + if CategoryRepository.count_children(cid) > 0: + raise BizError("存在子分类,请先删除子分类") + + # ============================================================ -# 分类列表查询 +# 模块级代理函数:保持 router 调用兼容 # ============================================================ +get_category_detail = CategoryService.get_detail +get_info = CategoryService.get_info +add_category = CategoryService.add +update_category = CategoryService.update +delete_categories = CategoryService.remove + + +# ============================================================ +# 分类列表查询(无分页) +# ============================================================ def get_category_list(params: dict) -> list: """ @@ -58,224 +76,14 @@ def get_category_list(params: dict) -> list: Returns: list: 分类字典列表 """ - # 应用筛选条件 - query = _apply_filters(params) - - # 查询数据 - category_list = query.all() - - # 转换为字典列表 + category_list = CategoryRepository.find_list_params(params) return models_to_list(category_list) -def _apply_filters(params: dict): - """ - 应用查询筛选条件 - - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 支持分类名称模糊查询 - - 支持分类类型精确查询 - - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = Category.query.filter(Category.is_delete == 0) - - # 分类名称模糊筛选 - category_name = params.get("category_name") - if category_name: - query = query.filter(Category.category_name.like(like_contains(category_name), escape="\\")) - - # 分类类型精确筛选 - type = params.get("type") - if type: - query = query.filter(Category.type == type) - - # 按排序值升序排列 - return query.order_by(Category.sort.asc()) - - -# ============================================================ -# 分类详情查询 -# ============================================================ - - -def get_category_detail(category_id): - """ - 根据ID查询分类详情(返回字典格式) - - 此方法调用 get_info 获取模型对象,然后转换为字典格式返回。 - 适用于前端接口调用。 - - Args: - category_id: 分类ID - - Returns: - dict/None: 分类详情字典,不存在时返回 None - """ - try: - # 复用 get_info 方法查询分类 - category = get_info(category_id) - if not category: - return None - # 转换为字典格式 - return model_to_dict(category) - - except Exception as e: - logger.error(f"查询分类详情异常,ID: {category_id}, 错误: {str(e)}") - return None - - -# ============================================================ -# 分类信息查询(根据ID)- 核心查询方法 -# ============================================================ - - -def get_info(category_id: int): - """ - 根据ID查询分类信息(返回模型对象) - - 这是一个通用的分类查询方法,返回分类模型对象而非字典。 - 与 get_category_detail 的区别: - - get_category_detail: 返回字典格式,用于前端接口 - - get_info: 返回模型对象,用于内部服务调用 - - 此方法被以下方法复用: - - get_category_detail: 获取详情字典 - - update_category: 验证分类是否存在 - - delete_categories: 验证分类是否存在 - - Args: - category_id: 分类ID - - Returns: - Category/None: 分类模型对象,不存在或已删除时返回 None - - Example: - category = get_info(category_id) - if category: - print(category.category_name) - """ - return CategoryRepository.get_by_id(category_id) - - -def add_category(data: dict) -> None: - """ - 添加分类 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证(名称唯一性、字段格式等) - 3. 创建分类对象并保存到数据库 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = CategoryForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 创建分类 - category = Category(**form.data) - category.create_user = get_username() - category.save() - - -# ============================================================ -# 分类更新 -# ============================================================ - - -def update_category(data: dict) -> None: - """ - 更新分类 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 验证分类是否存在(复用 get_info) - 4. 更新分类信息 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = CategoryForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 获取分类ID - category_id = form.data.get("id") - if not category_id or int(category_id) <= 0: - raise BizError("分类ID无效") - category_id = int(category_id) - - # 复用 get_info 方法验证分类是否存在 - category = get_info(category_id) - if not category: - raise BizError("分类不存在") - - # 更新分类信息(移除ID,避免更新主键) - form_data = convert_camel_to_snake(form.data.copy()) - form_data.pop("id", None) - - form_data["update_time"] = datetime.now(tz=timezone.utc) - form_data["update_user"] = get_username() - - result = Category.query.filter_by(id=category_id).update(form_data) - - if not result: - raise BizError("更新失败") - - -# ============================================================ -# 分类删除 -# ============================================================ - - -def delete_categories(category_ids: str) -> int: - """ - 删除分类(批量删除) - - 删除 - 支持逗号分隔的多个ID批量删除。 - - Args: - category_ids: 分类ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - if not category_ids: - raise BizError("记录ID不存在") - - # 解析ID列表 - id_list = parse_id_list(category_ids) - if not id_list: - raise BizError("无效的记录ID") - - # 检查是否有子分类 - for category_id in id_list: - child_count = Category.query.filter(and_(Category.parent_id == category_id, Category.is_delete == 0)).count() - if child_count > 0: - raise BizError("存在子分类,请先删除子分类") - - category_ids_list = parse_id_list(category_ids) - if not category_ids_list: - raise BizError("无效的记录ID") - return Category.batch_delete(category_ids_list) - - # ============================================================ # 分类数据获取(用于下拉选择、树形展示) # ============================================================ - def get_category_data(): """ 获取分类数据列表(无分页) @@ -287,14 +95,8 @@ def get_category_data(): list: 分类列表,每个元素为分类模型字典,无数据时返回空列表 """ try: - # 查询所有未删除的分类 - category_list = Category.query.filter(Category.is_delete == 0).order_by(Category.sort.asc()).all() - - # 转换为字典列表 - result = models_to_list(category_list) - - return result - + category_list = CategoryRepository.list_all() + return models_to_list(category_list) except Exception as e: logger.error(f"获取分类数据异常: {str(e)}") return [] diff --git a/src/modules/cms/link/models.py b/src/modules/cms/link/models.py index 4f5e301..0c4b181 100644 --- a/src/modules/cms/link/models.py +++ b/src/modules/cms/link/models.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -49,6 +49,7 @@ from src.common.base_db import BaseDB from src.common.base_model import BaseModel from config import DB_PREFIX +from sqlalchemy import text from src.core.database import db @@ -82,6 +83,9 @@ class Link(BaseModel, BaseDB): # 设置表名 __tablename__ = DB_PREFIX + "link" + # 表注释 + __table_comment__ = "友情链接表" + # ============================================================ # 字段定义 # ============================================================ @@ -90,6 +94,7 @@ class Link(BaseModel, BaseDB): name = db.Column( db.String(255), nullable=False, + index=True, comment="友链名称" ) @@ -97,6 +102,7 @@ class Link(BaseModel, BaseDB): type = db.Column( db.Integer, default=1, + server_default=text('1'), comment="友链类型:1-友情链接 2-合作伙伴" ) @@ -111,6 +117,7 @@ class Link(BaseModel, BaseDB): form = db.Column( db.Integer, default=1, + server_default=text('1'), comment="友链形式:1-文字链接 2-图片链接" ) @@ -124,7 +131,9 @@ class Link(BaseModel, BaseDB): # 友链状态:1-正常 2-停用 status = db.Column( db.Integer, + index=True, default=1, + server_default=text('1'), comment="友链状态:1-正常 2-停用" ) @@ -132,6 +141,7 @@ class Link(BaseModel, BaseDB): sort = db.Column( db.Integer, default=0, + server_default=text('0'), comment="友链顺序" ) diff --git a/src/modules/cms/link/repository.py b/src/modules/cms/link/repository.py index 86b602f..9858305 100644 --- a/src/modules/cms/link/repository.py +++ b/src/modules/cms/link/repository.py @@ -6,10 +6,45 @@ Link 仓储模块 """ from src.common.base_repository import BaseRepository +from src.common.constants.common import PAGE_SIZE from src.modules.cms.link.models import Link class LinkRepository(BaseRepository): - """Link 仓储""" + """友情链接数据仓储,提供友链相关的数据库访问操作""" model = Link + + @classmethod + def page(cls, params: dict): + """ + 友情链接分页查询 + + 支持按名称模糊搜索,按类型、形式、状态精确过滤,按 sort 降序排列。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + - name: 链接名称(模糊匹配) + - type: 链接类型(精确匹配) + - form: 链接形式(精确匹配) + - status: 状态(精确匹配) + + Returns: + tuple: (items, total, pages) + - items: 当前页数据列表 + - total: 总记录数 + - pages: 总页数 + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + filters={ + 'type': params.get('type'), + 'form': params.get('form'), + 'status': params.get('status'), + }, + like_filters={'name': params.get('name')}, + order_desc='sort', + ) diff --git a/src/modules/cms/link/router.py b/src/modules/cms/link/router.py index 8e8f338..796acb8 100644 --- a/src/modules/cms/link/router.py +++ b/src/modules/cms/link/router.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -57,7 +57,7 @@ from flask import Blueprint, request -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.core.permission import check_permission from src.modules.cms.link import service as link from src.common.utils.request import parse_batch_ids, parse_request_body @@ -74,6 +74,7 @@ link_blue = Blueprint("link", __name__, url_prefix="/link") @link_blue.route("/page", methods=["GET"]) @check_permission("sys:link:page") +@operation_log("友情链接", "查询") def page(): """ 查询友链分页数据 @@ -94,6 +95,7 @@ def page(): @link_blue.route("/detail/", methods=["GET"]) @check_permission("sys:link:detail") +@operation_log("友情链接", "详情") def detail(id): """ 查询友链详情 @@ -116,6 +118,7 @@ def detail(id): @link_blue.route("/add", methods=["POST"]) @check_permission("sys:link:add") @check_demo +@operation_log("友情链接", "新增") def add(): """ 添加友链 @@ -141,6 +144,7 @@ def add(): @link_blue.route("/update", methods=["PUT"]) @check_permission("sys:link:update") @check_demo +@operation_log("友情链接", "修改") def update(): """ 更新友链 @@ -166,6 +170,7 @@ def update(): @link_blue.route("/delete/", methods=["DELETE"]) @check_permission("sys:link:delete") @check_demo +@operation_log("友情链接", "删除") def delete(id): """ 删除友链 @@ -186,8 +191,9 @@ def delete(id): @link_blue.route("/batchDelete", methods=["DELETE"]) -@check_permission("sys:link:batch_delete") +@check_permission("sys:link:batchDelete") @check_demo +@operation_log("友情链接", "批量删除") def batch_delete(): """ 批量删除友链 @@ -204,7 +210,8 @@ def batch_delete(): id_str, err = parse_batch_ids() if err: return err - return link.delete_links(id_str) + count = link.delete_links(id_str) + return R.ok(msg=f"本次共删除{count}条数据") # ============================================================ @@ -215,6 +222,7 @@ def batch_delete(): @link_blue.route("/status", methods=["PUT"]) @check_permission("sys:link:status") @check_demo +@operation_log("友情链接", "状态变更") def status(): """ 设置友链状态 diff --git a/src/modules/cms/link/schema.py b/src/modules/cms/link/schema.py index fc01a94..2551d0a 100644 --- a/src/modules/cms/link/schema.py +++ b/src/modules/cms/link/schema.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -52,7 +52,7 @@ from wtforms import ValidationError, IntegerField, StringField from wtforms.validators import NumberRange, DataRequired, Length, Optional from src.common.forms.base_form import BaseForm -from src.modules.cms.link.models import Link +from src.modules.cms.link.repository import LinkRepository # ============================================================ @@ -209,15 +209,8 @@ class LinkForm(BaseForm): # 获取当前记录的ID(更新时存在,添加时为None) current_id = self.id.data if self.id.data else None - # 构建查询:查找同名记录 - query = Link.query.filter(Link.name == field.data, Link.is_delete == 0) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(Link.id != current_id) - - # 如果存在同名记录,抛出验证错误 - if query.first(): + # 唯一性校验(软删过滤 + 更新时排除自身) + if LinkRepository.exists_by({'name': field.data}, exclude_id=current_id): raise ValidationError("友链名称已存在") diff --git a/src/modules/cms/link/service.py b/src/modules/cms/link/service.py index f9b46c4..9879b2e 100644 --- a/src/modules/cms/link/service.py +++ b/src/modules/cms/link/service.py @@ -1,415 +1,118 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 -# +====================================================================== -# | 版权和免责声明: -# | 一、知识产权声明 -# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 -# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, -# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 -# | 二、使用限制与禁止条款 -# | 您理解并同意,禁止将本产品用于以下任何行为: -# | 1. 任何违反中华人民共和国法律法规的用途; -# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; -# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 -# | 三、责任豁免 -# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 -# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 -# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 -# | 四、违约与法律责任 -# | 如发现任何违反本声明的行为,本团队有权: -# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; -# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; -# | 3. 保留进一步采取刑事控告等法律措施的权利。 -# | 五、许可范围说明 -# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 -# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 -# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 -# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 -# | 成的一切经济损失及商誉损害。 -# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 -# | 许可协议》附件为准。 -# +====================================================================== - - """ 友链管理服务模块 ============================================================= 该模块实现了友链管理相关的所有业务逻辑。 -包括友链的分页查询、详情查询、添加、更新、删除、状态设置等功能。 友链用于管理友情链接和合作伙伴,支持文字链接和图片链接两种形式, 每个友链包含名称、链接、类型、平台、形式、排序、状态等属性。 - -命名规范: -- 函数参数使用驼峰命名(如 linkId, pageNo) -- 表单字段使用下划线命名(如 name, url, sort)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters, _build_page_data) - -功能模块: -1. 分页查询:支持按名称模糊搜索、类型/平台/形式/状态筛选,按sort字段倒序排列 -2. 详情查询:根据ID获取单个友链的详细信息 -3. 数据维护:友链的添加、更新、删除(支持批量) -4. 状态管理:单独更新友链的启用/禁用状态 """ -import logging - -from datetime import datetime, timezone - -from src.common.constants.common import PAGE_SIZE -from src.common.constants.dictionary_constants import LINK_TYPE_LIST, LINK_FORM_LIST -from src.modules.cms.link.schema import LinkForm, LinkStatusForm -from src.modules.cms.link.models import Link -from src.modules.cms.link.repository import LinkRepository -from src.core.exceptions import BizError -from src.common.utils import regular -from src.common.utils.case_converter import convert_camel_to_snake +from src.common.base_service import BaseService from src.common.utils.model_utils import model_to_dict -from src.common.utils.request import parse_id_list, like_contains from src.common.utils.rich_text import get_file_url, save_file -from src.core.security import get_username - -# 获取日志记录器 -logger = logging.getLogger(__name__) +from src.modules.cms.link.schema import LinkForm, LinkStatusForm +from src.modules.cms.link.repository import LinkRepository +from src.modules.dictionary import dict_utils -# ============================================================ -# 友链分页查询 -# ============================================================ - - -def get_link_page(params: dict) -> dict: +class LinkService(BaseService): """ - 查询友链分页数据 + 友情链接服务类 - Args: - request: Flask request对象,包含查询参数 - - pageNo: 当前页码(可选,默认为1) - - pageSize: 每页记录数(可选,默认为PAGE_SIZE) - - name: 友链名称(可选,模糊查询) - - type: 友链类型(可选,精确匹配) - - form: 友链形式(可选,精确匹配) - - status: 友链状态(可选,精确匹配) - - Returns: - R: 包含分页数据的JSON响应,格式为: - { - "records": 友链列表, - "total": 总记录数, - "size": 每页大小, - "current": 当前页码, - "pages": 总页数 - } + 继承 BaseService 获得标准 CRUD 能力,并扩展图片处理。 """ - # 获取分页参数 - page_no = int(params.get("pageNo", 1)) - page_size = int(params.get("pageSize", PAGE_SIZE)) - # 应用筛选条件 - query = _apply_filters(params) + repository = LinkRepository + form = LinkForm + status_form = LinkStatusForm + model_name = "友链" - # 记录总数 - total = query.count() - # 计算总页数 - pages = (total + page_size - 1) // page_size if total > 0 else 0 + @classmethod + def before_add(cls, form): + """ + 新增前钩子 - # 分页查询 - link_list = query.limit(page_size).offset((page_no - 1) * page_size).all() - # 构建返回数据 - page_data = _build_page_data(link_list, total, page_size, page_no, pages) - return page_data + 保存友链图片并回写表单数据。 + Args: + form: 已校验的表单对象 + """ + _handle_link_image(form) -def _apply_filters(params: dict): - """ - 应用查询筛选条件 + @classmethod + def before_update(cls, form, obj): + """ + 更新前钩子 - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 支持友链名称模糊查询 - - 支持友链类型精确查询 - - 支持投放平台精确查询 - - 支持友链形式精确查询 - - 支持友链状态精确查询 - - 默认按排序值倒序排列 + 保存友链图片并回写表单数据。 - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = Link.query.filter(Link.is_delete == 0) + Args: + form: 已校验的表单对象 + obj: 已存在的模型实例 + """ + _handle_link_image(form) - # 友链名称模糊筛选 - name = params.get("name") - if name: - query = query.filter(Link.name.like(like_contains(name), escape="\\")) + @classmethod + def _enrich_page_records(cls, records): + """ + 分页富化钩子 - # 友链类型精确筛选 - link_type = params.get("type") - if link_type: - query = query.filter(Link.type == link_type) + 补充类型描述、形式描述,处理图片 URL(类型/形式走字典表翻译)。 - # 友链形式精确筛选 - form = params.get("form") - if form: - query = query.filter(Link.form == form) + Args: + records: 友链字典列表 - # 友链状态精确筛选 - status = params.get("status") - if status: - query = query.filter(Link.status == status) + Returns: + list: 富化后的友链字典列表 + """ + type_map = dict_utils.get_value_map("link_type") + form_map = dict_utils.get_value_map("link_form") + for data in records: + data["typeText"] = type_map.get(str(data.get("type"))) + data["formText"] = form_map.get(str(data.get("form"))) + if data.get("image"): + data["image"] = get_file_url(data["image"]) + return records - # 按排序值倒序排列 - return query.order_by(Link.sort.desc()) + @classmethod + def _detail_dict(cls, obj): + """ + 详情序列化钩子 + 将图片路径转换为可访问的 URL。 -def _build_page_data(link_list, total, page_size, page_no, pages): - """ - 构建分页返回数据 + Args: + obj: 友链模型实例 - Args: - link_list: 友链对象列表 - total: 总记录数 - page_size: 每页大小 - page_no: 当前页码 - pages: 总页数 - - Returns: - dict: 分页数据,包含 records、total、size、current、pages - """ - # 将模型对象列表转换为字典列表,并添加额外字段 - records = [] - for item in link_list: - data = model_to_dict(item) - # 添加友链类型描述 - data["typeName"] = LINK_TYPE_LIST.get(item.type) - # 添加友链形式描述 - data["formName"] = LINK_FORM_LIST.get(item.form) - # 友链图片URL - if item.image: - data["image"] = get_file_url(item.image) - records.append(data) - - return {"records": records, "total": total, "size": page_size, "current": page_no, "pages": pages} - - -# ============================================================ -# 友链详情查询 -# ============================================================ - - -def get_link_detail(link_id): - """ - 根据ID查询友链详情(返回字典格式) - - 此方法调用 get_info 获取模型对象,然后转换为字典格式返回。 - 适用于前端接口调用。 - - Args: - link_id: 友链ID - - Returns: - dict/None: 友链详情字典,不存在时返回 None - """ - try: - # 复用 get_info 方法查询友链 - link = get_info(link_id) - if not link: - return None - # 转换为字典格式 - data = model_to_dict(link) - # 友链图片URL - if link.image: - data["image"] = get_file_url(link.image) + Returns: + dict: 序列化后的字典 + """ + data = model_to_dict(obj) + if obj.image: + data["image"] = get_file_url(obj.image) return data - except Exception as e: - logger.error(f"查询友链详情异常,ID: {link_id}, 错误: {str(e)}") - return None - -# ============================================================ -# 友链信息查询(根据ID)- 核心查询方法 -# ============================================================ - - -def get_info(link_id: int): +def _handle_link_image(form): """ - 根据ID查询友链信息(返回模型对象) - - 这是一个通用的友链查询方法,返回友链模型对象而非字典。 - 与 get_link_detail 的区别: - - get_link_detail: 返回字典格式,用于前端接口 - - get_info: 返回模型对象,用于内部服务调用 - - 此方法被以下方法复用: - - get_link_detail: 获取详情字典 - - update_link: 验证友链是否存在 - - update_link_status: 验证友链是否存在 + 友链图片保存并回写表单数据 Args: - link_id: 友链ID - - Returns: - Link/None: 友链模型对象,不存在或已删除时返回 None - - Example: - link = get_info(link_id) - if link: - print(link.name) + form: 表单对象 """ - return LinkRepository.get_by_id(link_id) - - -def add_link(data: dict) -> None: - """ - 添加友链 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证(字段格式等) - 3. 处理友链图片上传 - 4. 创建友链对象并保存到数据库 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = LinkForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 处理友链图片 image = form.data.get("image") if image: form.image.data = save_file(image, "link") - # 创建友链 - link = Link(**form.data) - link.create_user = get_username() - link.save() - # ============================================================ -# 友链更新 +# 模块级代理函数:保持 router 调用兼容 # ============================================================ - -def update_link(data: dict) -> None: - """ - 更新友链 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 验证友链是否存在(复用 get_info) - 4. 处理友链图片上传 - 5. 更新友链信息 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = LinkForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 获取友链ID - link_id = form.data.get("id") - if not link_id or int(link_id) <= 0: - raise BizError("友链ID无效") - link_id = int(link_id) - - # 复用 get_info 方法验证友链是否存在 - link = get_info(link_id) - if not link: - raise BizError("友链不存在") - - # 处理友链图片 - image = form.data.get("image") - if image: - form.image.data = save_file(image, "link") - - # 更新友链信息(移除ID,避免更新主键) - form_data = convert_camel_to_snake(form.data.copy()) - form_data.pop("id", None) - - form_data["update_time"] = datetime.now(tz=timezone.utc) - form_data["update_user"] = get_username() - - result = Link.query.filter_by(id=link_id).update(form_data) - - if not result: - raise BizError("更新失败") - - -# ============================================================ -# 友链删除 -# ============================================================ - - -def delete_links(link_ids: str) -> int: - """ - 删除友链(批量删除) - - 删除 - 支持逗号分隔的多个ID批量删除。 - - Args: - link_ids: 友链ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - link_ids_list = parse_id_list(link_ids) - if not link_ids_list: - raise BizError("无效的记录ID") - return Link.batch_delete(link_ids_list) - - -# ============================================================ -# 友链状态设置 -# ============================================================ - - -def update_link_status(data: dict) -> None: - """ - 设置友链状态 - - 单独更新友链的启用/禁用状态,不涉及其他字段。 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = LinkStatusForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - link_id = int(form.data["id"]) - status = int(form.data["status"]) - - # 复用 get_info 方法验证友链是否存在 - link = get_info(link_id) - if not link: - raise BizError("友链不存在") - - # 更新状态 - update_data = {"status": status, "update_time": datetime.now(tz=timezone.utc), "update_user": get_username()} - result = Link.query.filter_by(id=link_id).update(update_data) - - if not result: - raise BizError("设置失败") +get_link_page = LinkService.get_page +get_link_detail = LinkService.get_detail +get_info = LinkService.get_info +add_link = LinkService.add +update_link = LinkService.update +delete_links = LinkService.remove +update_link_status = LinkService.update_status diff --git a/src/modules/log/__init__.py b/src/modules/configuration/config/__init__.py similarity index 100% rename from src/modules/log/__init__.py rename to src/modules/configuration/config/__init__.py diff --git a/src/modules/configuration/config/models.py b/src/modules/configuration/config/models.py new file mode 100644 index 0000000..baa80a1 --- /dev/null +++ b/src/modules/configuration/config/models.py @@ -0,0 +1,109 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 小蚂蚁云团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +""" +配置模型模块 +============================================================= + +提供系统配置分组的数据模型定义,用于管理的分类。 +""" + +from src.common.base_db import BaseDB +from src.common.base_model import BaseModel +from config import DB_PREFIX +from sqlalchemy import text +from src.core.database import db + + +class Config(BaseModel, BaseDB): + """ + 配置分组模型 + + 用于存储系统配置分组信息,如:基础配置、邮件配置等。 + 继承自 BaseModel(通用字段)和 BaseDB(数据库操作方法)。 + + Attributes: + id: 主键ID(继承自 BaseModel) + name: 配置名称(String(100),必填) + code: 配置编码(String(100),必填) + sort: 配置排序(Integer,默认0,数值越小越靠前) + note: 配置备注(String(255),可选) + """ + + # ============================================================ + # 表配置 + # ============================================================ + + # 设置表名 + __tablename__ = DB_PREFIX + "config" + + # 表注释 + __table_comment__ = "配置表" + + # ============================================================ + # 字段定义 + # ============================================================ + + # 配置名称 + name = db.Column(db.String(100), nullable=False, index=True, comment="配置名称") + + # 配置编码 + code = db.Column(db.String(100), nullable=False, index=True, comment="配置编码") + + # 配置排序 + sort = db.Column(db.Integer, default=0, + server_default=text('0'), comment="配置排序") + + # 配置备注 + note = db.Column(db.String(255), nullable=True, comment="配置备注") + + # ============================================================ + # 魔法方法 + # ============================================================ + + def __str__(self): + """ + 返回模型的字符串表示 + + Returns: + str: 格式为 "配置{id}" 的字符串 + """ + return "配置{}".format(self.id) diff --git a/src/modules/configuration/config/repository.py b/src/modules/configuration/config/repository.py new file mode 100644 index 0000000..b8d599c --- /dev/null +++ b/src/modules/configuration/config/repository.py @@ -0,0 +1,56 @@ +""" +Config 仓储模块 +============================================================= + +数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 +""" + +from src.common.constants.common import PAGE_SIZE +from src.common.base_repository import BaseRepository +from src.modules.configuration.config.models import Config + + +class ConfigRepository(BaseRepository): + """配置数据仓储,提供配置相关的数据库访问操作""" + + model = Config + + @classmethod + def page(cls, params: dict): + """ + 配置分页查询 + + 支持按配置名称模糊搜索,按状态精确过滤,按 sort 字段升序排列。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + - name: 配置名称(模糊匹配) + - status: 状态(精确匹配) + + Returns: + tuple: (items, total, pages) + - items: 当前页数据列表 + - total: 总记录数 + - pages: 总页数 + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + like_filters={'name': params.get('name')}, + filters={'status': params.get('status')}, + order_by='sort', + ) + + @classmethod + def list_enabled(cls): + """ + 查询全部未删除配置 + + 用于网站配置等场景。 + + Returns: + list: 配置实例列表,按 sort 升序排列 + """ + return cls.find_list(order_by='sort') diff --git a/src/modules/configuration/router.py b/src/modules/configuration/config/router.py similarity index 59% rename from src/modules/configuration/router.py rename to src/modules/configuration/config/router.py index c5d5610..7847e97 100644 --- a/src/modules/configuration/router.py +++ b/src/modules/configuration/config/router.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -39,7 +39,6 @@ # | 许可协议》附件为准。 # +====================================================================== - """ 参数配置控制器模块 ============================================================= @@ -48,6 +47,7 @@ - 参数配置分页查询 - 参数配置详情查询 - 参数配置添加、更新、删除 +- 网站配置读取与保存 所属包: application.views 依赖模块: application.services.config - 参数配置业务逻辑层 @@ -55,10 +55,9 @@ """ from flask import Blueprint, request -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.core.permission import check_permission -from src.modules.configuration import service as config -from src.modules.configuration import service as config_item +from src.modules.configuration.config import service as config_service from src.common.utils.request import parse_batch_ids, parse_request_body from src.core import response as R @@ -73,6 +72,7 @@ config_blue = Blueprint("config", __name__, url_prefix="/config") @config_blue.route("/page", methods=["GET"]) @check_permission("sys:config:page") +@operation_log("系统配置", "查询") def page(): """ 查询参数配置分页数据 @@ -83,7 +83,7 @@ def page(): Returns: JSON: 参数配置分页数据,包含 records(数据列表)、total(总记录数)等 """ - return R.ok(data=config.get_config_page(request.args)) + return R.ok(data=config_service.get_config_page(request.args)) # ============================================================ @@ -93,6 +93,7 @@ def page(): @config_blue.route("/detail/", methods=["GET"]) @check_permission("sys:config:detail") +@operation_log("系统配置", "详情") def detail(id): """ 查询参数配置详情 @@ -103,7 +104,7 @@ def detail(id): Returns: JSON: 参数配置详细信息 """ - data = config.get_config_detail(id) + data = config_service.get_config_detail(id) return R.ok(data=data) @@ -115,6 +116,7 @@ def detail(id): @config_blue.route("/add", methods=["POST"]) @check_permission("sys:config:add") @check_demo +@operation_log("系统配置", "新增") def add(): """ 添加参数配置 @@ -128,7 +130,7 @@ def add(): json_data, err = parse_request_body() if err: return R.failed(msg=err) - config.add_config(json_data) + config_service.add_config(json_data) return R.ok(msg="添加成功") @@ -140,6 +142,7 @@ def add(): @config_blue.route("/update", methods=["PUT"]) @check_permission("sys:config:update") @check_demo +@operation_log("系统配置", "修改") def update(): """ 更新参数配置 @@ -153,7 +156,7 @@ def update(): json_data, err = parse_request_body() if err: return R.failed(msg=err) - config.update_config(json_data) + config_service.update_config(json_data) return R.ok(msg="更新成功") @@ -165,6 +168,7 @@ def update(): @config_blue.route("/delete/", methods=["DELETE"]) @check_permission("sys:config:delete") @check_demo +@operation_log("系统配置", "删除") def delete(id): """ 删除参数配置 @@ -175,7 +179,7 @@ def delete(id): Returns: JSON: 操作结果 """ - count = config.delete_configs(id) + count = config_service.delete_configs(id) return R.ok(msg=f"本次共删除{count}条数据") @@ -185,8 +189,9 @@ def delete(id): @config_blue.route("/batchDelete", methods=["DELETE"]) -@check_permission("sys:config:batch_delete") +@check_permission("sys:config:batchDelete") @check_demo +@operation_log("系统配置", "批量删除") def batch_delete(): """ 批量删除配置 @@ -203,7 +208,7 @@ def batch_delete(): id_str, err = parse_batch_ids() if err: return err - count = config.delete_configs(id_str) + count = config_service.delete_configs(id_str) return R.ok(msg=f"本次共删除{count}条数据") @@ -213,7 +218,8 @@ def batch_delete(): @config_blue.route("/web/index", methods=["GET"]) -@check_permission("sys:configweb:index") +@check_permission("sys:config:page") +@operation_log("系统配置", "查询") def web_index(): """ 获取网站配置信息 @@ -221,7 +227,7 @@ def web_index(): Returns: JSON: 网站配置信息 """ - result = config.get_config_data() + result = config_service.get_config_data() return R.ok(result) @@ -233,6 +239,7 @@ def web_index(): @config_blue.route("/web/save", methods=["POST"]) @check_permission("sys:configweb:save") @check_demo +@operation_log("系统配置", "保存") def web_save(): """ 保存网站配置信息 @@ -243,190 +250,5 @@ def web_save(): json_data, err = parse_request_body() if err: return R.failed(msg=err) - config.save_config_data(json_data) + config_service.save_config_data(json_data) return R.ok(msg="保存成功") - - -""" -配置项数据控制器模块 -============================================================= - -提供配置项数据(ConfigItem)相关的API接口,包括: -- 配置项分页查询 -- 配置项详情查询 -- 配置项添加、更新、删除 -- 配置项状态设置 - -所属包: application.views -依赖模块: application.services.config_item - 配置项业务逻辑层 - application.middleware.check_permission - 权限校验中间件 -""" - - -config_item_blue = Blueprint("config_item", __name__, url_prefix="/config/item") - - -# ============================================================ -# 配置项分页查询 -# ============================================================ - - -@config_item_blue.route("/page", methods=["GET"]) -@check_permission("sys:configitem:page") -def config_item_page(): - """ - 查询配置项分页数据 - - 功能说明: - 获取配置项列表的分页数据,支持关键词搜索和状态筛选。 - - Returns: - JSON: 配置项分页数据,包含 records(数据列表)、total(总记录数)等 - """ - return R.ok(data=config_item.get_config_item_page(request.args)) - - -# ============================================================ -# 配置项详情查询 -# ============================================================ - - -@config_item_blue.route("/detail/", methods=["GET"]) -@check_permission("sys:configitem:detail") -def config_item_detail(id): - """ - 查询配置项详情 - - Args: - id: 配置项ID - - Returns: - JSON: 配置项详细信息 - """ - data = config_item.get_config_item_detail(id) - return R.ok(data=data) - - -# ============================================================ -# 配置项添加 -# ============================================================ - - -@config_item_blue.route("/add", methods=["POST"]) -@check_permission("sys:configitem:add") -@check_demo -def config_item_add(): - """ - 添加配置项 - - 功能说明: - 创建新的配置项记录。 - - Returns: - JSON: 操作结果 - """ - json_data, err = parse_request_body() - if err: - return R.failed(msg=err) - config_item.add_config_item(json_data) - return R.ok(msg="添加成功") - - -# ============================================================ -# 配置项更新 -# ============================================================ - - -@config_item_blue.route("/update", methods=["PUT"]) -@check_permission("sys:configitem:update") -@check_demo -def config_item_update(): - """ - 更新配置项 - - 功能说明: - 修改已有的配置项信息。 - - Returns: - JSON: 操作结果 - """ - json_data, err = parse_request_body() - if err: - return R.failed(msg=err) - config_item.update_config_item(json_data) - return R.ok(msg="更新成功") - - -# ============================================================ -# 配置项删除 -# ============================================================ - - -@config_item_blue.route("/delete/", methods=["DELETE"]) -@check_permission("sys:configitem:delete") -@check_demo -def config_item_delete(id): - """ - 删除配置项 - - Args: - id: 配置项ID(支持逗号分隔的多个ID) - - Returns: - JSON: 操作结果 - """ - count = config_item.delete_config_items(id) - return R.ok(msg=f"本次共删除{count}条数据") - - -# ============================================================ -# 配置项批量删除 -# ============================================================ - - -@config_item_blue.route("/batchDelete", methods=["DELETE"]) -@check_permission("sys:configitem:batch_delete") -@check_demo -def config_item_batch_delete(): - """ - 批量删除配置项 - - 功能说明: - 批量删除配置项记录,支持传入ID数组。 - - Request Body: - [1, 2, 3] # 配置项ID数组 - - Returns: - JSON: 操作结果 - """ - id_str, err = parse_batch_ids() - if err: - return err - count = config_item.delete_config_items(id_str) - return R.ok(msg=f"本次共删除{count}条数据") - - -# ============================================================ -# 配置项状态设置 -# ============================================================ - - -@config_item_blue.route("/status", methods=["PUT"]) -@check_permission("sys:configitem:status") -@check_demo -def config_item_status(): - """ - 设置配置项状态 - - 功能说明: - 启用或禁用配置项。 - - Returns: - JSON: 操作结果 - """ - json_data, err = parse_request_body() - if err: - return R.failed(msg=err) - config_item.update_config_item(json_data) - return R.ok(msg="更新成功") diff --git a/src/modules/configuration/config/schema.py b/src/modules/configuration/config/schema.py new file mode 100644 index 0000000..fc995a2 --- /dev/null +++ b/src/modules/configuration/config/schema.py @@ -0,0 +1,179 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 小蚂蚁云团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +""" +配置表单验证模块 +============================================================= + +提供配置相关的表单验证功能,包括: +- 配置添加/更新表单验证 +- 配置名称唯一性验证 +- 配置编码唯一性验证 + +命名规范: +- 类名使用驼峰命名(如 ConfigForm) +- 字段名使用下划线命名(如 name, code, sort) +- 验证方法使用 validate_字段名 格式 +""" + +from wtforms import IntegerField, StringField +from wtforms.validators import NumberRange, DataRequired, Length, ValidationError, Optional + +from src.common.forms.base_form import BaseForm +from src.modules.configuration.config.repository import ConfigRepository + + +# ============================================================ +# 配置表单验证(添加和更新共用) +# ============================================================ + + +class ConfigForm(BaseForm): + """ + 配置表单验证类 + + 用于添加和更新配置时的数据验证。 + 添加时 id 为 None,更新时 id 必须有值。 + + Attributes: + id: 配置ID(更新时必填,添加时可选) + name: 配置名称(必填,最长100字符,不可重复) + code: 配置编码(必填,最长100字符,不可重复) + sort: 配置排序(必填,0-99999之间的整数) + note: 配置备注(可选,最长255字符) + """ + + # ============================================================ + # 配置ID + # ============================================================ + id = IntegerField( + label="配置ID", + validators=[Optional(), NumberRange(min=1, message="配置ID必须大于0")], # 允许为空(添加时) + description="配置唯一标识,更新时必填,添加时自动生成", + ) + + # ============================================================ + # 配置名称 + # ============================================================ + name = StringField( + label="配置名称", + validators=[DataRequired(message="配置名称不能为空"), Length(max=100, message="配置名称长度不得超过100个字符")], + description="配置显示名称", + ) + + # ============================================================ + # 配置编码 + # ============================================================ + code = StringField( + label="配置编码", + validators=[DataRequired(message="配置编码不能为空"), Length(max=100, message="配置编码长度不得超过100个字符")], + description="配置唯一标识编码,用于程序调用", + ) + + # ============================================================ + # 配置排序 + # ============================================================ + sort = IntegerField( + label="配置排序", + validators=[Optional(), NumberRange(min=0, max=99999, message="配置排序值在0~99999之间")], + description="排序值,数值越小越靠前", + ) + + # ============================================================ + # 配置备注 + # ============================================================ + note = StringField( + label="配置备注", + validators=[Length(max=255, message="配置备注长度不得超过255个字符")], + description="配置补充说明", + ) + + # ============================================================ + # 自定义验证方法 + # ============================================================ + + def validate_name(self, field): + """ + 自定义验证:配置名称不能重复 + + 验证逻辑: + 1. 添加时:检查数据库中是否存在同名记录 + 2. 更新时:排除当前记录自身,检查其他记录是否同名 + + Args: + field: name 字段对象 + + Raises: + ValidationError: 配置名称已存在时抛出验证错误 + """ + if not field.data: + return + + # 获取当前记录的ID(更新时存在,添加时为None) + current_id = self.id.data if self.id.data else None + + # 唯一性校验(软删过滤 + 更新时排除自身) + if ConfigRepository.exists_by({'name': field.data}, exclude_id=current_id): + raise ValidationError("配置名称已存在") + + def validate_code(self, field): + """ + 自定义验证:配置编码不能重复 + + 验证逻辑: + 1. 添加时:检查数据库中是否存在相同编码的记录 + 2. 更新时:排除当前记录自身,检查其他记录是否编码相同 + + Args: + field: code 字段对象 + + Raises: + ValidationError: 配置编码已存在时抛出验证错误 + """ + if not field.data: + return + + # 获取当前记录的ID(更新时存在,添加时为None) + current_id = self.id.data if self.id.data else None + + # 唯一性校验(软删过滤 + 更新时排除自身) + if ConfigRepository.exists_by({'code': field.data}, exclude_id=current_id): + raise ValidationError("配置编码已存在") diff --git a/src/modules/configuration/config/service.py b/src/modules/configuration/config/service.py new file mode 100644 index 0000000..e4d79fb --- /dev/null +++ b/src/modules/configuration/config/service.py @@ -0,0 +1,246 @@ +""" +参数配置管理服务模块 +============================================================= + +该模块实现了参数配置(Config)管理相关的所有业务逻辑,含网站配置的 +读取与保存(跨 ConfigItem 数据)。参数配置用于管理系统中的配置项分组。 +""" + +from collections import defaultdict +from typing import List, Dict, Any + +from src.common.base_service import BaseService +from src.modules.configuration.config.schema import ConfigForm +from src.modules.configuration.config.repository import ConfigRepository +from src.modules.configuration.config_item.repository import ConfigItemRepository +from src.common.utils.model_utils import model_to_dict +from src.common.utils.rich_text import get_file_url, save_file + + +class ConfigService(BaseService): + """ + 参数配置服务类 + + 继承 BaseService 获得标准 CRUD 能力。 + """ + + repository = ConfigRepository + form = ConfigForm + model_name = "参数配置" + + +# ============================================================ +# 模块级代理函数:保持 router 调用兼容 +# ============================================================ + +get_config_page = ConfigService.get_page +get_config_detail = ConfigService.get_detail +get_info = ConfigService.get_info +add_config = ConfigService.add +update_config = ConfigService.update +delete_configs = ConfigService.remove + + +# ============================================================ +# 网站配置管理(特有逻辑) +# ============================================================ + +def get_config_data() -> List[Dict[str, Any]]: + """ + 获取系统配置(优化版,使用一次查询获取所有数据) + + 按配置分组返回所有配置项,对图片、文件、多选等类型进行特殊处理。 + + Returns: + List[Dict]: 格式化的配置数据列表 + - configId: 配置ID + - configName: 配置名称 + - dataList: 配置项列表 + """ + # 查询所有配置 + configs = ConfigRepository.list_enabled() + + if not configs: + return [] + + # 获取所有配置ID + config_ids = [config.id for config in configs] + + # 一次性查询所有配置项 + all_items = ConfigItemRepository.list_by_config_ids(config_ids) + + # 按配置ID分组 + items_by_config = defaultdict(list) + for item in all_items: + items_by_config[item.config_id].append(item) + + # 构建返回数据 + config_list = [] + for config in configs: + config_data = {"configId": config.id, "configName": config.name, "dataList": []} + + for item in items_by_config.get(config.id, []): + item_data = _process_config_item(item) + if item_data: + config_data["dataList"].append(item_data) + + config_list.append(config_data) + + return config_list + + +def _process_config_item(item) -> Dict[str, Any]: + """ + 处理单个配置项 + + 根据配置项类型对值进行特殊处理: + - image/images: 转换为可访问的图片URL + - file/files: 处理文件路径 + - checkbox/selects: 转换为值列表 + - array/radio/select: 解析选项 + + Args: + item: 配置项模型对象 + + Returns: + Dict: 处理后的配置项数据 + """ + data = model_to_dict(item) + item_type = data.get("type") + item_value = data.get("value") + item_options = data.get("options") + + # 处理选项类型(array, radio, checkbox, select, selects) + option_types = ["array", "radio", "checkbox", "select", "selects"] + if item_type in option_types and item_options: + param = parse_options(item_options) + if param: + data["param"] = param + + # 根据不同类型处理value + if item_value: + if item_type in ["checkbox", "selects"]: + data["valueList"] = item_value.split(",") + + elif item_type == "image": + data["value"] = get_file_url(item_value) + + elif item_type == "images": + image_list = item_value.split(",") + data["valueList"] = [get_file_url(img) for img in image_list if img] + data["value"] = data["valueList"][0] if data["valueList"] else "" + + elif item_type in ["file", "files"]: + file_list = item_value.split(",") + processed_list = [] + + for file_item in file_list: + if not file_item: + continue + + parts = file_item.split("|", 1) + if len(parts) == 2: + file_name = parts[0] + file_path = parts[1] + full_url = f"{file_name}|{get_file_url(file_path)}" + processed_list.append(full_url) + else: + processed_list.append(file_item) + + data["valueList"] = processed_list + data["value"] = processed_list[0] if processed_list else "" + + elif item_type == "ueditor": + data["value"] = get_content_url(item_value) + + return data + + +def parse_options(options_str: str) -> Dict[int, str]: + """ + 解析选项字符串 + + 格式如:'1=男,2=女,3=保密' + 返回字典格式:{1: '男', 2: '女', 3: '保密'} + + Args: + options_str: 选项字符串 + + Returns: + Dict[int, str]: 选项字典 + """ + result = {} + if not options_str: + return result + + options_str = options_str.replace(",", ",") + options_list = options_str.split(",") + + for option in options_list: + if "=" in option: + parts = option.split("=") + if len(parts) == 2: + try: + key = int(parts[0].strip()) + value = parts[1].strip() + result[key] = value + except (ValueError, IndexError): + continue + + return result + + +def get_content_url(content: str) -> str: + """ + 处理富文本内容中的图片URL + + Args: + content: 富文本内容 + + Returns: + str: 处理后的内容 + """ + return content + + +def save_config_data(data: dict) -> None: + """ + 保存网站配置数据 + + 流程: + 1. 解析请求体JSON数据 + 2. 遍历处理表单数据(列表类型、图片URL等) + 3. 根据编码查询配置项并更新值 + + Args: + data: 配置数据字典,key为配置项编码,value为配置值 + """ + if not data: + return + + for key in data: + val = data[key] + + # 处理列表类型(多选、图片列表等) + if isinstance(val, list): + item = [] + for v in val: + if isinstance(v, str) and (v.startswith("http://") or v.startswith("https://")): + url = save_file(v, "config") + item.append(url) + else: + item.append(v) + val = ",".join(item) + else: + # 处理单个图片/文件 + if isinstance(val, str) and (val.startswith("http://") or val.startswith("https://")): + val = save_file(val, "config") + + # 根据编码查询配置项 + config_item = ConfigItemRepository.first_by({'code': key}) + + if not config_item: + continue + + config_item.value = val + ConfigItemRepository.save(config_item) diff --git a/src/modules/configuration/config_item/__init__.py b/src/modules/configuration/config_item/__init__.py new file mode 100644 index 0000000..56ff4f7 --- /dev/null +++ b/src/modules/configuration/config_item/__init__.py @@ -0,0 +1 @@ +"""模块包。""" diff --git a/src/modules/configuration/models.py b/src/modules/configuration/config_item/models.py similarity index 66% rename from src/modules/configuration/models.py rename to src/modules/configuration/config_item/models.py index 57d4c5f..ba9d6bb 100644 --- a/src/modules/configuration/models.py +++ b/src/modules/configuration/config_item/models.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -40,91 +40,22 @@ # +====================================================================== """ -配置模型模块 +配置项模型模块 ============================================================= -提供系统配置分组的数据模型定义,用于管理的分类。 +提供配置项的数据模型定义,用于管理具体配置的键值对。 """ from src.common.base_db import BaseDB from src.common.base_model import BaseModel from config import DB_PREFIX +from sqlalchemy import text from src.core.database import db -# ============================================================ -# 配置模型 -# ============================================================ - - -class Config(BaseModel, BaseDB): - """ - 配置分组模型 - - 用于存储系统配置分组信息,如:基础配置、邮件配置等。 - 继承自 BaseModel(通用字段)和 BaseDB(数据库操作方法)。 - - Attributes: - id: 主键ID(继承自 BaseModel) - name: 配置名称(String(100),必填) - code: 配置编码(String(100),必填) - sort: 配置排序(Integer,默认0,数值越小越靠前) - note: 配置备注(String(255),可选) - """ - - # ============================================================ - # 表配置 - # ============================================================ - - # 设置表名 - __tablename__ = DB_PREFIX + "config" - - # ============================================================ - # 字段定义 - # ============================================================ - - # 配置名称 - name = db.Column(db.String(100), nullable=False, comment="配置名称") - - # 配置编码 - code = db.Column(db.String(100), nullable=False, comment="配置编码") - - # 配置排序 - sort = db.Column(db.Integer, default=0, comment="配置排序") - - # 配置备注 - note = db.Column(db.String(255), nullable=True, comment="配置备注") - - # ============================================================ - # 魔法方法 - # ============================================================ - - def __str__(self): - """ - 返回模型的字符串表示 - - Returns: - str: 格式为 "配置{id}" 的字符串 - """ - return "配置{}".format(self.id) - - -""" -模型模块 -============================================================= - -提供系统的数据模型定义,用于管理具体的键值对。 -""" - - -# ============================================================ -# 模型 -# ============================================================ - - class ConfigItem(BaseModel, BaseDB): """ - 模型 + 配置项模型 用于存储系统配置的具体项,属于某个配置。 继承自 BaseModel(通用字段)和 BaseDB(数据库操作方法)。 @@ -132,7 +63,6 @@ class ConfigItem(BaseModel, BaseDB): Attributes: id: 主键ID(继承自 BaseModel) config_id: 配置ID(Integer,默认0,关联配置ID) - config_code: 配置编码(String(100),默认0,关联配置编码) name: 名称(String(100),必填) code: 编码(String(100),必填,唯一标识) value: 值(String(1000),可选) @@ -150,18 +80,15 @@ class ConfigItem(BaseModel, BaseDB): # 设置表名 __tablename__ = DB_PREFIX + "config_item" + # 表注释 + __table_comment__ = "配置项表" + # ============================================================ # 字段定义 # ============================================================ - # 配置ID - config_id = db.Column(db.Integer, default=0, comment="配置ID") - - # 配置编码 - config_code = db.Column(db.String(100), nullable=False, comment="配置编码") - # 名称 - name = db.Column(db.String(100), nullable=False, comment="名称") + name = db.Column(db.String(100), nullable=False, index=True, comment="名称") # 编码 code = db.Column(db.String(100), nullable=False, comment="编码") @@ -172,14 +99,20 @@ class ConfigItem(BaseModel, BaseDB): # 配置选项 options = db.Column(db.Text, nullable=True, comment="配置选项") + # 配置ID + config_id = db.Column(db.Integer, default=0, + server_default=text('0'), index=True, comment="配置ID") + # 配置类型 type = db.Column(db.String(100), nullable=False, comment="配置类型") # 配置状态:1-正常 2-停用 - status = db.Column(db.Integer, default=1, comment="配置状态:1-正常 2-停用") + status = db.Column(db.Integer, default=1, + server_default=text('1'), index=True, comment="配置状态:1-正常 2-停用") # 排序 - sort = db.Column(db.Integer, default=0, comment="顺序") + sort = db.Column(db.Integer, default=0, + server_default=text('0'), comment="顺序") # 备注 note = db.Column(db.String(255), nullable=True, comment="备注") diff --git a/src/modules/configuration/config_item/repository.py b/src/modules/configuration/config_item/repository.py new file mode 100644 index 0000000..692919d --- /dev/null +++ b/src/modules/configuration/config_item/repository.py @@ -0,0 +1,70 @@ +""" +ConfigItem 仓储模块 +============================================================= + +数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 +""" + +from src.common.constants.common import PAGE_SIZE +from src.common.base_repository import BaseRepository +from src.modules.configuration.config_item.models import ConfigItem + + +class ConfigItemRepository(BaseRepository): + """配置项数据仓储,提供配置项相关的数据库访问操作""" + + model = ConfigItem + + @classmethod + def page(cls, params: dict): + """ + 配置项分页查询 + + 支持按配置ID和状态精确过滤,按名称模糊搜索,按 sort 字段升序排列。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + - configId: 配置ID(精确匹配) + - status: 状态(精确匹配) + - name: 配置项名称(模糊匹配) + + Returns: + tuple: (items, total, pages) + - items: 当前页数据列表 + - total: 总记录数 + - pages: 总页数 + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + filters={'config_id': params.get('configId'), 'status': params.get('status')}, + like_filters={'name': params.get('name')}, + order_by='sort', + ) + + @classmethod + def list_by_config_ids(cls, config_ids, status: int = 1): + """ + 按配置ID集合批量查询配置项 + + 用于网站配置等场景,批量获取多个配置下的配置项。 + + Args: + config_ids: 配置ID列表 + status: 状态过滤,默认 1(启用),None 表示不过滤 + + Returns: + list: 配置项实例列表,按 sort 升序排列 + """ + valid_ids = [int(i) for i in (config_ids or []) if i and int(i) > 0] + if not valid_ids: + return [] + query = cls.model.query.filter( + cls.model.is_delete == 0, + cls.model.config_id.in_(valid_ids), + ) + if status is not None: + query = query.filter(cls.model.status == status) + return query.order_by(cls.model.sort.asc()).all() diff --git a/src/modules/configuration/config_item/router.py b/src/modules/configuration/config_item/router.py new file mode 100644 index 0000000..d57d1a9 --- /dev/null +++ b/src/modules/configuration/config_item/router.py @@ -0,0 +1,238 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 小蚂蚁云团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +""" +配置项数据控制器模块 +============================================================= + +提供配置项数据(ConfigItem)相关的API接口,包括: +- 配置项分页查询 +- 配置项详情查询 +- 配置项添加、更新、删除 +- 配置项状态设置 + +所属包: application.views +依赖模块: application.services.config_item - 配置项业务逻辑层 + application.middleware.check_permission - 权限校验中间件 +""" +from flask import Blueprint, request + +from src.middleware import check_demo, operation_log +from src.core.permission import check_permission +from src.modules.configuration.config_item import service as config_item_service +from src.common.utils.request import parse_batch_ids, parse_request_body + +from src.core import response as R + +config_item_blue = Blueprint("config_item", __name__, url_prefix="/config/item") + + +# ============================================================ +# 配置项分页查询 +# ============================================================ + + +@config_item_blue.route("/page", methods=["GET"]) +@check_permission("sys:configitem:page") +@operation_log("配置项管理", "查询") +def config_item_page(): + """ + 查询配置项分页数据 + + 功能说明: + 获取配置项列表的分页数据,支持关键词搜索和状态筛选。 + + Returns: + JSON: 配置项分页数据,包含 records(数据列表)、total(总记录数)等 + """ + return R.ok(data=config_item_service.get_config_item_page(request.args)) + + +# ============================================================ +# 配置项详情查询 +# ============================================================ + + +@config_item_blue.route("/detail/", methods=["GET"]) +@check_permission("sys:configitem:detail") +@operation_log("配置项管理", "详情") +def config_item_detail(id): + """ + 查询配置项详情 + + Args: + id: 配置项ID + + Returns: + JSON: 配置项详细信息 + """ + data = config_item_service.get_config_item_detail(id) + return R.ok(data=data) + + +# ============================================================ +# 配置项添加 +# ============================================================ + + +@config_item_blue.route("/add", methods=["POST"]) +@check_permission("sys:configitem:add") +@check_demo +@operation_log("配置项管理", "新增") +def config_item_add(): + """ + 添加配置项 + + 功能说明: + 创建新的配置项记录。 + + Returns: + JSON: 操作结果 + """ + json_data, err = parse_request_body() + if err: + return R.failed(msg=err) + config_item_service.add_config_item(json_data) + return R.ok(msg="添加成功") + + +# ============================================================ +# 配置项更新 +# ============================================================ + + +@config_item_blue.route("/update", methods=["PUT"]) +@check_permission("sys:configitem:update") +@check_demo +@operation_log("配置项管理", "修改") +def config_item_update(): + """ + 更新配置项 + + 功能说明: + 修改已有的配置项信息。 + + Returns: + JSON: 操作结果 + """ + json_data, err = parse_request_body() + if err: + return R.failed(msg=err) + config_item_service.update_config_item(json_data) + return R.ok(msg="更新成功") + + +# ============================================================ +# 配置项删除 +# ============================================================ + + +@config_item_blue.route("/delete/", methods=["DELETE"]) +@check_permission("sys:configitem:delete") +@check_demo +@operation_log("配置项管理", "删除") +def config_item_delete(id): + """ + 删除配置项 + + Args: + id: 配置项ID(支持逗号分隔的多个ID) + + Returns: + JSON: 操作结果 + """ + count = config_item_service.delete_config_items(id) + return R.ok(msg=f"本次共删除{count}条数据") + + +# ============================================================ +# 配置项批量删除 +# ============================================================ + + +@config_item_blue.route("/batchDelete", methods=["DELETE"]) +@check_permission("sys:configitem:batchDelete") +@check_demo +@operation_log("配置项管理", "批量删除") +def config_item_batch_delete(): + """ + 批量删除配置项 + + 功能说明: + 批量删除配置项记录,支持传入ID数组。 + + Request Body: + [1, 2, 3] # 配置项ID数组 + + Returns: + JSON: 操作结果 + """ + id_str, err = parse_batch_ids() + if err: + return err + count = config_item_service.delete_config_items(id_str) + return R.ok(msg=f"本次共删除{count}条数据") + + +# ============================================================ +# 配置项状态设置 +# ============================================================ + + +@config_item_blue.route("/status", methods=["PUT"]) +@check_permission("sys:configitem:status") +@check_demo +@operation_log("配置项管理", "状态变更") +def config_item_status(): + """ + 设置配置项状态 + + 功能说明: + 启用或禁用配置项。 + + Returns: + JSON: 操作结果 + """ + json_data, err = parse_request_body() + if err: + return R.failed(msg=err) + config_item_service.update_config_item_status(json_data) + return R.ok(msg="设置成功") diff --git a/src/modules/configuration/schema.py b/src/modules/configuration/config_item/schema.py similarity index 64% rename from src/modules/configuration/schema.py rename to src/modules/configuration/config_item/schema.py index ce13815..5000b9b 100644 --- a/src/modules/configuration/schema.py +++ b/src/modules/configuration/config_item/schema.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -39,160 +39,6 @@ # | 许可协议》附件为准。 # +====================================================================== -""" -配置表单验证模块 -============================================================= - -提供配置相关的表单验证功能,包括: -- 配置添加/更新表单验证 -- 配置名称唯一性验证 -- 配置编码唯一性验证 - -命名规范: -- 类名使用驼峰命名(如 ConfigForm) -- 字段名使用下划线命名(如 name, code, sort) -- 验证方法使用 validate_字段名 格式 -""" - -from wtforms import IntegerField, StringField -from wtforms.validators import NumberRange, DataRequired, Length, ValidationError, Optional - -from src.common.forms.base_form import BaseForm -from src.modules.configuration.models import Config, ConfigItem - - -# ============================================================ -# 配置表单验证(添加和更新共用) -# ============================================================ - - -class ConfigForm(BaseForm): - """ - 配置表单验证类 - - 用于添加和更新配置时的数据验证。 - 添加时 id 为 None,更新时 id 必须有值。 - - Attributes: - id: 配置ID(更新时必填,添加时可选) - name: 配置名称(必填,最长100字符,不可重复) - code: 配置编码(必填,最长100字符,不可重复) - sort: 配置排序(必填,0-99999之间的整数) - note: 配置备注(可选,最长255字符) - """ - - # ============================================================ - # 配置ID - # ============================================================ - id = IntegerField( - label="配置ID", - validators=[Optional(), NumberRange(min=1, message="配置ID必须大于0")], # 允许为空(添加时) - description="配置唯一标识,更新时必填,添加时自动生成", - ) - - # ============================================================ - # 配置名称 - # ============================================================ - name = StringField( - label="配置名称", - validators=[DataRequired(message="配置名称不能为空"), Length(max=100, message="配置名称长度不得超过100个字符")], - description="配置显示名称", - ) - - # ============================================================ - # 配置编码 - # ============================================================ - code = StringField( - label="配置编码", - validators=[DataRequired(message="配置编码不能为空"), Length(max=100, message="配置编码长度不得超过100个字符")], - description="配置唯一标识编码,用于程序调用", - ) - - # ============================================================ - # 配置排序 - # ============================================================ - sort = IntegerField( - label="配置排序", - validators=[Optional(), NumberRange(min=0, max=99999, message="配置排序值在0~99999之间")], - description="排序值,数值越小越靠前", - ) - - # ============================================================ - # 配置备注 - # ============================================================ - note = StringField( - label="配置备注", - validators=[Length(max=255, message="配置备注长度不得超过255个字符")], - description="配置补充说明", - ) - - # ============================================================ - # 自定义验证方法 - # ============================================================ - - def validate_name(self, field): - """ - 自定义验证:配置名称不能重复 - - 验证逻辑: - 1. 添加时:检查数据库中是否存在同名记录 - 2. 更新时:排除当前记录自身,检查其他记录是否同名 - - Args: - field: name 字段对象 - - Raises: - ValidationError: 配置名称已存在时抛出验证错误 - """ - if not field.data: - return - - # 获取当前记录的ID(更新时存在,添加时为None) - current_id = self.id.data if self.id.data else None - - # 构建查询:查找同名记录 - query = Config.query.filter(Config.name == field.data, Config.is_delete == 0) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(Config.id != current_id) - - # 如果存在同名记录,抛出验证错误 - if query.first(): - raise ValidationError("配置名称已存在") - - def validate_code(self, field): - """ - 自定义验证:配置编码不能重复 - - 验证逻辑: - 1. 添加时:检查数据库中是否存在相同编码的记录 - 2. 更新时:排除当前记录自身,检查其他记录是否编码相同 - - Args: - field: code 字段对象 - - Raises: - ValidationError: 配置编码已存在时抛出验证错误 - """ - if not field.data: - return - - # 获取当前记录的ID(更新时存在,添加时为None) - current_id = self.id.data if self.id.data else None - - # 构建查询:查找相同编码的记录 - query = Config.query.filter(Config.code == field.data, Config.is_delete == 0) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(Config.id != current_id) - - # 如果存在相同编码的记录,抛出验证错误 - if query.first(): - raise ValidationError("配置编码已存在") - - """ 配置项表单验证模块 ============================================================= @@ -202,6 +48,12 @@ class ConfigForm(BaseForm): - 配置项状态设置表单验证 """ +from wtforms import IntegerField, StringField +from wtforms.validators import NumberRange, DataRequired, Length, ValidationError, Optional + +from src.common.forms.base_form import BaseForm +from src.modules.configuration.config_item.repository import ConfigItemRepository + # ============================================================ # 配置项表单验证(添加和更新共用) @@ -323,11 +175,12 @@ class ConfigItemForm(BaseForm): def validate_name(self, field): """ - 自定义验证:配置项名称不能重复 + 自定义验证:配置项名称在同一配置下不能重复 验证逻辑: - 1. 添加时:检查数据库中是否存在同名记录 + 1. 添加时:检查同一配置下是否存在同名记录 2. 更新时:排除当前记录自身,检查其他记录是否同名 + 3. 不同配置(config_id)之间允许存在相同名称 Args: field: name 字段对象 @@ -340,12 +193,14 @@ class ConfigItemForm(BaseForm): current_id = self.id.data if self.id.data else None - query = ConfigItem.query.filter(ConfigItem.name == field.data, ConfigItem.is_delete == 0) + # 获取配置ID + config_id = self.configId.data if self.configId.data else None - if current_id: - query = query.filter(ConfigItem.id != current_id) + if config_id is None: + return - if query.first(): + # 唯一性校验(同配置下 + 软删过滤 + 更新时排除自身) + if ConfigItemRepository.exists_by({'config_id': config_id, 'name': field.data}, exclude_id=current_id): raise ValidationError("配置项名称已存在") def validate_code(self, field): @@ -367,12 +222,8 @@ class ConfigItemForm(BaseForm): current_id = self.id.data if self.id.data else None - query = ConfigItem.query.filter(ConfigItem.code == field.data, ConfigItem.is_delete == 0) - - if current_id: - query = query.filter(ConfigItem.id != current_id) - - if query.first(): + # 唯一性校验(软删过滤 + 更新时排除自身) + if ConfigItemRepository.exists_by({'code': field.data}, exclude_id=current_id): raise ValidationError("配置项编码已存在") diff --git a/src/modules/configuration/config_item/service.py b/src/modules/configuration/config_item/service.py new file mode 100644 index 0000000..dfeb179 --- /dev/null +++ b/src/modules/configuration/config_item/service.py @@ -0,0 +1,76 @@ +""" +配置项管理服务模块 +============================================================= + +该模块实现了配置项(ConfigItem)管理相关的所有业务逻辑。 +配置项用于管理系统中的具体配置,如:网站名称、ICP备案号、联系电话等。 +""" + +from src.common.base_service import BaseService +from src.modules.configuration.config.repository import ConfigRepository +from src.modules.configuration.config_item.schema import ConfigItemForm, ConfigItemStatusForm +from src.modules.configuration.config_item.repository import ConfigItemRepository +from src.modules.dictionary import dict_utils +from src.core.exceptions import BizError + + +class ConfigItemService(BaseService): + """ + 配置项服务类 + + 继承 BaseService 获得标准 CRUD 能力。 + 新增时校验父配置存在。 + """ + + repository = ConfigItemRepository + form = ConfigItemForm + status_form = ConfigItemStatusForm + model_name = "配置项" + + @classmethod + def before_add(cls, form): + """ + 新增前钩子 + + 校验父配置是否存在。 + + Args: + form: 已校验的表单对象 + + Raises: + BizError: 父配置不存在时抛出 + """ + config = ConfigRepository.get_by_id(form.configId.data) + if not config: + raise BizError("配置不存在") + + @classmethod + def _enrich_page_records(cls, records): + """ + 分页富化钩子 + + 补充类型显示文本(走字典表翻译)。 + + Args: + records: 配置项字典列表 + + Returns: + list: 富化后的配置项字典列表 + """ + type_map = dict_utils.get_value_map("config_item_type") + for data in records: + data["typeText"] = type_map.get(str(data.get("type"))) + return records + + +# ============================================================ +# 模块级代理函数:保持 router 调用兼容 +# ============================================================ + +get_config_item_page = ConfigItemService.get_page +get_config_item_detail = ConfigItemService.get_detail +get_config_item_info = ConfigItemService.get_info +add_config_item = ConfigItemService.add +update_config_item = ConfigItemService.update +delete_config_items = ConfigItemService.remove +update_config_item_status = ConfigItemService.update_status diff --git a/src/modules/configuration/config_item_repository.py b/src/modules/configuration/config_item_repository.py deleted file mode 100644 index 5481aa7..0000000 --- a/src/modules/configuration/config_item_repository.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -ConfigItem 仓储模块 -============================================================= - -数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 -""" - -from src.common.base_repository import BaseRepository -from src.modules.configuration.models import ConfigItem - - -class ConfigItemRepository(BaseRepository): - """ConfigItem 仓储""" - - model = ConfigItem diff --git a/src/modules/configuration/config_repository.py b/src/modules/configuration/config_repository.py deleted file mode 100644 index 105a92e..0000000 --- a/src/modules/configuration/config_repository.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Config 仓储模块 -============================================================= - -数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 -""" - -from src.common.base_repository import BaseRepository -from src.modules.configuration.models import Config - - -class ConfigRepository(BaseRepository): - """Config 仓储""" - - model = Config diff --git a/src/modules/configuration/service.py b/src/modules/configuration/service.py deleted file mode 100644 index e73458f..0000000 --- a/src/modules/configuration/service.py +++ /dev/null @@ -1,852 +0,0 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 -# +====================================================================== -# | 版权和免责声明: -# | 一、知识产权声明 -# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 -# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, -# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 -# | 二、使用限制与禁止条款 -# | 您理解并同意,禁止将本产品用于以下任何行为: -# | 1. 任何违反中华人民共和国法律法规的用途; -# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; -# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 -# | 三、责任豁免 -# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 -# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 -# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 -# | 四、违约与法律责任 -# | 如发现任何违反本声明的行为,本团队有权: -# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; -# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; -# | 3. 保留进一步采取刑事控告等法律措施的权利。 -# | 五、许可范围说明 -# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 -# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 -# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 -# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 -# | 成的一切经济损失及商誉损害。 -# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 -# | 许可协议》附件为准。 -# +====================================================================== - -""" -参数配置管理服务模块 -============================================================= - -该模块实现了参数配置管理相关的所有业务逻辑。 -包括参数配置的分页查询、详情查询、添加、更新、删除等功能。 -参数配置用于管理系统中的配置项,如:网站名称、ICP备案号、联系电话等, -每个配置包含名称、键名、键值、排序、状态等属性,通过sort字段控制显示顺序。 - -命名规范: -- 函数参数使用驼峰命名(如 configId, pageNo) -- 表单字段使用下划线命名(如 name, key, value)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters, _build_page_data) - -功能模块: -1. 分页查询:支持按名称模糊搜索、状态筛选,按sort字段排序 -2. 详情查询:根据ID获取单个参数配置的详细信息 -3. 数据维护:参数配置的添加、更新、删除(支持批量) -""" - -import logging -from collections import defaultdict -from datetime import datetime, timezone -from typing import List, Dict, Any - - -from src.common.constants.common import PAGE_SIZE -from src.common.constants.dictionary_constants import CONFIG_ITEM_TYPE_LIST -from src.modules.configuration.schema import ConfigForm, ConfigItemForm, ConfigItemStatusForm -from src.modules.configuration.models import Config, ConfigItem -from src.modules.configuration.config_repository import ConfigRepository -from src.modules.configuration.config_item_repository import ConfigItemRepository -from src.core.exceptions import BizError -from src.common.utils import regular -from src.common.utils.case_converter import convert_camel_to_snake -from src.common.utils.model_utils import models_to_list, model_to_dict -from src.common.utils.request import parse_id_list, like_contains -from src.common.utils.rich_text import get_file_url, save_file -from src.core.security import get_username - -# 获取日志记录器 -logger = logging.getLogger(__name__) - - -# ============================================================ -# 参数配置分页查询 -# ============================================================ - - -def get_config_page(params: dict) -> dict: - """ - 查询参数配置分页数据 - - Args: - request: Flask request对象,包含查询参数 - - pageNo: 当前页码(可选,默认为1) - - pageSize: 每页记录数(可选,默认为PAGE_SIZE) - - name: 配置名称(可选,模糊查询) - - status: 配置状态(可选,精确匹配) - - Returns: - R: 包含分页数据的JSON响应,格式为: - { - "records": 参数配置列表, - "total": 总记录数, - "size": 每页大小, - "current": 当前页码, - "pages": 总页数 - } - """ - # 获取分页参数 - page_no = int(params.get("pageNo", 1)) - page_size = int(params.get("pageSize", PAGE_SIZE)) - - # 应用筛选条件 - query = _apply_filters(params) - - # 记录总数 - total = query.count() - # 计算总页数 - pages = (total + page_size - 1) // page_size if total > 0 else 0 - - # 分页查询 - config_list = query.limit(page_size).offset((page_no - 1) * page_size).all() - # 构建返回数据 - pageData = _build_page_data(config_list, total, page_size, page_no, pages) - return pageData - - -def _apply_filters(params: dict): - """ - 应用查询筛选条件 - - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(isDelete == 0) - - 支持配置名称模糊查询 - - 支持配置状态精确查询 - - 默认按排序值升序排列 - - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = Config.query.filter(Config.is_delete == 0) - - # 配置名称模糊筛选 - name = params.get("name") - if name: - query = query.filter(Config.name.like(like_contains(name), escape="\\")) - - # 配置状态精确筛选 - status = params.get("status") - if status: - query = query.filter(Config.status == status) - - # 按排序值升序排列 - return query.order_by(Config.sort.asc()) - - -def _build_page_data(config_list, total, page_size, page_no, pages): - """ - 构建分页返回数据 - - Args: - config_list: 参数配置对象列表 - total: 总记录数 - page_size: 每页大小 - page_no: 当前页码 - pages: 总页数 - - Returns: - dict: 分页数据,包含 records、total、size、current、pages - """ - # 将模型对象列表转换为字典列表 - records = models_to_list(config_list) - - return {"records": records, "total": total, "size": page_size, "current": page_no, "pages": pages} - - -# ============================================================ -# 参数配置详情查询 -# ============================================================ - - -def get_config_detail(config_id): - """ - 根据ID查询参数配置详情(返回字典格式) - - 此方法调用 get_info 获取模型对象,然后转换为字典格式返回。 - 适用于前端接口调用。 - - Args: - config_id: 参数配置ID - - Returns: - dict/None: 参数配置详情字典,不存在时返回 None - """ - try: - # 复用 get_info 方法查询参数配置 - config = get_info(config_id) - if not config: - return None - # 转换为字典格式 - return model_to_dict(config) - - except Exception as e: - logger.error(f"查询参数配置详情异常,ID: {config_id}, 错误: {str(e)}") - return None - - -# ============================================================ -# 参数配置信息查询(根据ID)- 核心查询方法 -# ============================================================ - - -def get_info(config_id: int): - """ - 根据ID查询参数配置信息(返回模型对象) - - 这是一个通用的参数配置查询方法,返回参数配置模型对象而非字典。 - 与 get_config_detail 的区别: - - get_config_detail: 返回字典格式,用于前端接口 - - get_info: 返回模型对象,用于内部服务调用 - - 此方法被以下方法复用: - - get_config_detail: 获取详情字典 - - update_config: 验证参数配置是否存在 - - Args: - config_id: 参数配置ID - - Returns: - Config/None: 参数配置模型对象,不存在或已删除时返回 None - - Example: - config = get_info(config_id) - if config: - print(config.name) - """ - return ConfigRepository.get_by_id(config_id) - - -def add_config(data: dict) -> None: - """ - 添加参数配置 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证(名称唯一性、字段格式等) - 3. 创建参数配置对象并保存到数据库 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = ConfigForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 创建参数配置 - config = Config(**form.data) - config.create_user = get_username() - config.save() - - -# ============================================================ -# 参数配置更新 -# ============================================================ - - -def update_config(data: dict) -> None: - """ - 更新参数配置 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 验证参数配置是否存在(复用 get_info) - 4. 更新参数配置信息 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = ConfigForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 获取参数配置ID - config_id = form.data.get("id") - if not config_id or int(config_id) <= 0: - raise BizError("参数配置ID无效") - config_id = int(config_id) - - # 复用 get_info 方法验证参数配置是否存在 - config = get_info(config_id) - if not config: - raise BizError("参数配置不存在") - - # 更新参数配置信息(移除ID,避免更新主键) - form_data = convert_camel_to_snake(form.data.copy()) - form_data.pop("id", None) - - form_data["update_time"] = datetime.now(tz=timezone.utc) - form_data["update_user"] = get_username() - - result = Config.query.filter_by(id=config_id).update(form_data) - - if not result: - raise BizError("更新失败") - - -# ============================================================ -# 参数配置删除 -# ============================================================ - - -def delete_configs(config_ids: str) -> int: - """ - 删除参数配置(批量删除) - - 删除 - 支持逗号分隔的多个ID批量删除。 - - Args: - config_ids: 参数配置ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - config_ids_list = parse_id_list(config_ids) - if not config_ids_list: - raise BizError("无效的记录ID") - return Config.batch_delete(config_ids_list) - - -# ============================================================ -# 网站配置管理 -# ============================================================ - - -def get_config_data() -> List[Dict[str, Any]]: - """ - 获取系统配置(优化版,使用一次查询获取所有数据) - - Returns: - 返回格式化的配置数据列表 - """ - # 查询所有配置 - configs = Config.query.filter(Config.is_delete == 0).order_by(Config.sort.asc()).all() - - if not configs: - return [] - - # 获取所有配置ID - config_ids = [config.id for config in configs] - - # 一次性查询所有配置项 - all_items = ( - ConfigItem.query.filter(ConfigItem.is_delete == 0, ConfigItem.status == 1, ConfigItem.config_id.in_(config_ids)) - .order_by(ConfigItem.sort.asc()) - .all() - ) - - # 按配置ID分组 - items_by_config = defaultdict(list) - for item in all_items: - items_by_config[item.config_id].append(item) - - # 构建返回数据 - config_list = [] - for config in configs: - config_data = {"configId": config.id, "configName": config.name, "dataList": []} - - for item in items_by_config.get(config.id, []): - item_data = _process_config_item(item) - if item_data: - config_data["dataList"].append(item_data) - - config_list.append(config_data) - - return config_list - - -def _process_config_item(item) -> Dict[str, Any]: - """ - 处理单个配置项 - - Args: - item: 配置项模型对象 - - Returns: - 处理后的配置项数据 - """ - data = model_to_dict(item) - item_type = data.get("type") - item_value = data.get("value") - item_options = data.get("options") - - # 处理选项类型(array, radio, checkbox, select, selects) - option_types = ["array", "radio", "checkbox", "select", "selects"] - if item_type in option_types and item_options: - param = parse_options(item_options) - if param: - data["param"] = param - - # 根据不同类型处理value - if item_value: - if item_type in ["checkbox", "selects"]: - data["valueList"] = item_value.split(",") - - elif item_type == "image": - data["value"] = get_file_url(item_value) - - elif item_type == "images": - image_list = item_value.split(",") - data["valueList"] = [get_file_url(img) for img in image_list if img] - data["value"] = data["valueList"][0] if data["valueList"] else "" - - elif item_type in ["file", "files"]: - file_list = item_value.split(",") - processed_list = [] - - for file_item in file_list: - if not file_item: - continue - - parts = file_item.split("|", 1) - if len(parts) == 2: - file_name = parts[0] - file_path = parts[1] - full_url = f"{file_name}|{get_file_url(file_path)}" - processed_list.append(full_url) - else: - processed_list.append(file_item) - - data["valueList"] = processed_list - data["value"] = processed_list[0] if processed_list else "" - - elif item_type == "ueditor": - data["value"] = get_content_url(item_value) - - return data - - -def parse_options(options_str: str) -> Dict[int, str]: - """ - 解析选项字符串,格式如:'1=男,2=女,3=保密' - 返回字典格式:{1: '男', 2: '女', 3: '保密'} - """ - result = {} - if not options_str: - return result - - options_str = options_str.replace(",", ",") - options_list = options_str.split(",") - - for option in options_list: - if "=" in option: - parts = option.split("=") - if len(parts) == 2: - try: - key = int(parts[0].strip()) - value = parts[1].strip() - result[key] = value - except (ValueError, IndexError): - continue - - return result - - -def get_content_url(content: str) -> str: - """ - 处理富文本内容中的图片URL - """ - return content - - -def save_config_data(data: dict) -> None: - """ - 保存网站配置数据 - - 流程: - 1. 解析请求体JSON数据 - 2. 遍历处理表单数据(列表类型、图片URL等) - 3. 根据编码查询配置项并更新值 - """ - if not data: - return - - if data: - for key in data: - val = data[key] - - if isinstance(val, list): - item = [] - for v in val: - if isinstance(v, str) and (v.startswith("http://") or v.startswith("https://")): - url = save_file(v, "config") - item.append(url) - else: - item.append(v) - val = ",".join(item) - else: - if isinstance(val, str) and (val.startswith("http://") or val.startswith("https://")): - val = save_file(val, "config") - - config_item = ConfigItem.query.filter(ConfigItem.is_delete == 0, ConfigItem.code == key).first() - - if not config_item: - continue - - config_item.value = val - config_item.save() - - -""" -配置项管理服务模块 -============================================================= - -该模块实现了配置项管理相关的所有业务逻辑。 -包括配置项的分页查询、详情查询、添加、更新、删除、状态设置等功能。 -配置项用于管理具体配置的参数项,如:网站名称对应的值、ICP备案号对应的值等, -每个配置项包含标题、键名、键值、类型、排序、状态等属性,通过sort字段控制显示顺序。 - -命名规范: -- 函数参数使用驼峰命名(如 configItemId, pageNo) -- 表单字段使用下划线命名(如 title, key, value)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters, _build_page_data) - -功能模块: -1. 分页查询:支持按标题模糊搜索、配置ID筛选、状态筛选,按sort字段排序 -2. 详情查询:根据ID获取单个配置项的详细信息 -3. 数据维护:配置项的添加、更新、删除(支持批量) -4. 状态管理:单独更新配置项的启用/禁用状态 -""" - - -# 获取日志记录器 -logger = logging.getLogger(__name__) - - -# ============================================================ -# 配置项分页查询 -# ============================================================ - - -def get_config_item_page(params: dict) -> dict: - """ - 查询配置项分页数据 - - Args: - request: Flask request对象,包含查询参数 - - pageNo: 当前页码(可选,默认为1) - - pageSize: 每页记录数(可选,默认为PAGE_SIZE) - - configId: 配置ID(必填,用于筛选属于哪个配置) - - title: 配置项名称(可选,模糊查询) - - status: 配置项状态(可选,精确匹配) - - Returns: - R: 包含分页数据的JSON响应,格式为: - { - "records": 配置项列表, - "total": 总记录数, - "size": 每页大小, - "current": 当前页码, - "pages": 总页数 - } - """ - # 获取分页参数 - page_no = int(params.get("pageNo", 1)) - page_size = int(params.get("pageSize", PAGE_SIZE)) - - # 应用筛选条件 - query = _apply_config_item_filters(params) - - # 记录总数 - total = query.count() - # 计算总页数 - pages = (total + page_size - 1) // page_size if total > 0 else 0 - - # 分页查询 - config_item_list = query.limit(page_size).offset((page_no - 1) * page_size).all() - # 构建返回数据 - page_data = _build_config_item_page_data(config_item_list, total, page_size, page_no, pages) - return page_data - - -def _apply_config_item_filters(params: dict): - """ - 应用查询筛选条件 - - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 必须按配置ID筛选(config_id) - - 支持配置项名称模糊查询 - - 支持配置项状态精确查询 - - 默认按排序值升序排列 - - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = ConfigItem.query.filter(ConfigItem.is_delete == 0) - - # 配置ID筛选(必填) - config_id = params.get("configId", 0) - if config_id: - query = query.filter(ConfigItem.config_id == config_id) - - # 配置项名称模糊筛选 - name = params.get("name") - if name: - query = query.filter(ConfigItem.name.like(like_contains(name), escape="\\")) - - # 配置项状态精确筛选 - status = params.get("status") - if status: - query = query.filter(ConfigItem.status == status) - - # 按排序值升序排列 - return query.order_by(ConfigItem.sort.asc()) - - -def _build_config_item_page_data(config_item_list, total, page_size, page_no, pages): - """ - 构建分页返回数据 - - Args: - config_item_list: 配置项对象列表 - total: 总记录数 - page_size: 每页大小 - page_no: 当前页码 - pages: 总页数 - - Returns: - dict: 分页数据,包含 records、total、size、current、pages - """ - # 将模型对象列表转换为字典列表,并添加额外字段 - records = [] - for item in config_item_list: - data = model_to_dict(item) - # 添加配置项类型描述 - data["typeName"] = CONFIG_ITEM_TYPE_LIST.get(item.type) - records.append(data) - - return {"records": records, "total": total, "size": page_size, "current": page_no, "pages": pages} - - -# ============================================================ -# 配置项详情查询 -# ============================================================ - - -def get_config_item_detail(config_item_id): - """ - 根据ID查询配置项详情(返回字典格式) - - 此方法调用 get_info 获取模型对象,然后转换为字典格式返回。 - 适用于前端接口调用。 - - Args: - config_item_id: 配置项ID - - Returns: - dict/None: 配置项详情字典,不存在时返回 None - """ - try: - # 复用 get_info 方法查询配置项 - config_item = get_config_item_info(config_item_id) - if not config_item: - return None - # 转换为字典格式 - return model_to_dict(config_item) - - except Exception as e: - logger.error(f"查询配置项详情异常,ID: {config_item_id}, 错误: {str(e)}") - return None - - -# ============================================================ -# 配置项信息查询(根据ID)- 核心查询方法 -# ============================================================ - - -def get_config_item_info(config_item_id: int): - """ - 根据ID查询配置项信息(返回模型对象) - - 这是一个通用的配置项查询方法,返回配置项模型对象而非字典。 - 与 get_config_item_detail 的区别: - - get_config_item_detail: 返回字典格式,用于前端接口 - - get_info: 返回模型对象,用于内部服务调用 - - 此方法被以下方法复用: - - get_config_item_detail: 获取详情字典 - - update_config_item: 验证配置项是否存在 - - update_config_item_status: 验证配置项是否存在 - - Args: - config_item_id: 配置项ID - - Returns: - configItem/None: 配置项模型对象,不存在或已删除时返回 None - - Example: - config_item = get_config_item_info(config_item_id) - if config_item: - print(config_item.title) - """ - return ConfigItemRepository.get_by_id(config_item_id) - - -def add_config_item(data: dict) -> None: - """ - 添加配置项 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证(字段格式等) - 3. 创建配置项对象并保存到数据库 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = ConfigItemForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 查询配置(验证配置是否存在) - config_id = form.configId.data - config = Config.query.filter(Config.id == config_id).first() - if not config: - raise BizError("配置不存在") - - # 创建配置项 - config_item = ConfigItem(**form.data) - config_item.config_code = config.code - config_item.create_user = get_username() - config_item.save() - - -# ============================================================ -# 配置项更新 -# ============================================================ - - -def update_config_item(data: dict) -> None: - """ - 更新配置项 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 验证配置项是否存在(复用 get_info) - 4. 更新配置项信息 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = ConfigItemForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 获取配置项ID - config_item_id = form.data.get("id") - if not config_item_id or int(config_item_id) <= 0: - raise BizError("配置项ID无效") - - # 复用 get_info 方法验证配置项是否存在 - config_item = get_config_item_info(config_item_id) - if not config_item: - raise BizError("配置项不存在") - - # 更新配置项信息(移除ID,避免更新主键) - form_data = convert_camel_to_snake(form.data.copy()) - form_data.pop("id", None) - form_data["update_time"] = datetime.now(tz=timezone.utc) - form_data["update_user"] = get_username() - result = ConfigItem.query.filter_by(id=config_item_id).update(form_data) - - if not result: - raise BizError("更新失败") - - -# ============================================================ -# 配置项删除 -# ============================================================ - - -def delete_config_items(config_item_ids: str) -> int: - """ - 删除配置项(批量删除) - - 删除 - 支持逗号分隔的多个ID批量删除。 - - Args: - config_item_ids: 配置项ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - config_item_ids_list = parse_id_list(config_item_ids) - if not config_item_ids_list: - raise BizError("无效的记录ID") - return ConfigItem.batch_delete(config_item_ids_list) - - -# ============================================================ -# 配置项状态设置 -# ============================================================ - - -def update_config_item_status(data: dict) -> None: - """ - 设置配置项状态 - - 单独更新配置项的启用/禁用状态,不涉及其他字段。 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = ConfigItemStatusForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - config_item_id = int(form.data["id"]) - status = int(form.data["status"]) - - # 复用 get_info 方法验证配置项是否存在 - config_item = get_config_item_info(config_item_id) - if not config_item: - raise BizError("配置项不存在") - - # 更新状态 - update_data = {"status": status, "update_time": datetime.now(tz=timezone.utc), "update_user": get_username()} - result = ConfigItem.query.filter_by(id=config_item_id).update(update_data) - - if not result: - raise BizError("设置失败") diff --git a/src/modules/dictionary/dict/__init__.py b/src/modules/dictionary/dict/__init__.py new file mode 100644 index 0000000..56ff4f7 --- /dev/null +++ b/src/modules/dictionary/dict/__init__.py @@ -0,0 +1 @@ +"""模块包。""" diff --git a/src/modules/dictionary/dict/models.py b/src/modules/dictionary/dict/models.py new file mode 100644 index 0000000..5e40e6b --- /dev/null +++ b/src/modules/dictionary/dict/models.py @@ -0,0 +1,109 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 小蚂蚁云团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +""" +字典模型模块 +============================================================= + +提供数据字典分组的数据模型定义,用于管理字典项的分类。 +""" + +from src.common.base_db import BaseDB +from src.common.base_model import BaseModel +from config import DB_PREFIX +from sqlalchemy import text +from src.core.database import db + + +class Dict(BaseModel, BaseDB): + """ + 字典分组模型 + + 用于存储数据字典分组信息,如:性别、状态等。 + 继承自 BaseModel(通用字段)和 BaseDB(数据库操作方法)。 + + Attributes: + id: 主键ID(继承自 BaseModel) + name: 字典名称(String(150),必填) + code: 字典编码(String(150),必填,唯一标识) + sort: 字典排序(Integer,默认0,数值越小越靠前) + note: 字典备注(String(255),可选) + """ + + # ============================================================ + # 表配置 + # ============================================================ + + # 设置表名 + __tablename__ = DB_PREFIX + "dict" + + # 表注释 + __table_comment__ = "字典表" + + # ============================================================ + # 字段定义 + # ============================================================ + + # 字典名称 + name = db.Column(db.String(150), nullable=False, index=True, comment="字典名称") + + # 字典编码 + code = db.Column(db.String(150), nullable=False, index=True, comment="字典编码") + + # 字典排序 + sort = db.Column(db.Integer, default=0, + server_default=text('0'), comment="字典排序") + + # 字典备注 + note = db.Column(db.String(255), nullable=True, comment="字典备注") + + # ============================================================ + # 魔法方法 + # ============================================================ + + def __str__(self): + """ + 返回模型的字符串表示 + + Returns: + str: 格式为 "字典{id}" 的字符串 + """ + return "字典{}".format(self.id) diff --git a/src/modules/dictionary/dict/repository.py b/src/modules/dictionary/dict/repository.py new file mode 100644 index 0000000..6501b5c --- /dev/null +++ b/src/modules/dictionary/dict/repository.py @@ -0,0 +1,44 @@ +""" +Dict 仓储模块 +============================================================= + +数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 +""" + +from src.common.constants.common import PAGE_SIZE +from src.common.base_repository import BaseRepository +from src.modules.dictionary.dict.models import Dict + + +class DictRepository(BaseRepository): + """字典数据仓储,提供字典相关的数据库访问操作""" + + model = Dict + + @classmethod + def page(cls, params: dict): + """ + 字典分页查询 + + 支持按字典名称模糊搜索,按状态精确过滤,按 sort 字段升序排列。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + - name: 字典名称(模糊匹配) + - status: 状态(精确匹配) + + Returns: + tuple: (items, total, pages) + - items: 当前页数据列表 + - total: 总记录数 + - pages: 总页数 + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + like_filters={'name': params.get('name')}, + filters={'status': params.get('status')}, + order_by='sort', + ) diff --git a/src/modules/dictionary/dict/router.py b/src/modules/dictionary/dict/router.py new file mode 100644 index 0000000..54de028 --- /dev/null +++ b/src/modules/dictionary/dict/router.py @@ -0,0 +1,235 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 小蚂蚁云团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +""" +字典管理控制器模块 +============================================================= + +提供字典(Dict)相关的API接口,包括: +- 字典分页查询 +- 字典详情查询 +- 字典添加、更新、删除 + +所属包: application.views +依赖模块: application.services.dicts - 字典业务逻辑层 + application.middleware.check_permission - 权限校验中间件 +""" +from flask import Blueprint, request + +from src.middleware import check_demo, operation_log +from src.core.permission import check_permission +from src.modules.dictionary.dict import service as dict_service +from src.common.utils.request import parse_batch_ids, parse_request_body + +from src.core import response as R + +dict_blue = Blueprint("dict", __name__, url_prefix="/dict") + + +# ============================================================ +# 字典分页查询 +# ============================================================ + + +@dict_blue.route("/page", methods=["GET"]) +@check_permission("sys:dict:page") +@operation_log("字典管理", "查询") +def page(): + """ + 查询字典分页数据 + + 功能说明: + 获取字典列表的分页数据,支持关键词搜索和状态筛选。 + + Returns: + JSON: 字典分页数据,包含 records(数据列表)、total(总记录数)等 + """ + return R.ok(data=dict_service.get_dict_page(request.args)) + + +# ============================================================ +# 按字典编码读取字典项(登录即可,不加 sys:dict 权限) +# ============================================================ + + +@dict_blue.route("/data/", methods=["GET"]) +def dict_data(code): + """ + 按字典编码查询字典项 + + 功能说明: + 业务消费端入口:按 code 取该字典的全部字典项([{value, name, sort}]), + 供下拉框/展示翻译用。仅需登录,不校验 sys:dict 权限;后台改字典项即时生效 + (dict_utils 缓存 + 写操作失效)。 + + Args: + code: 字典编码,如 gender + + Returns: + JSON: 字典项列表 [{value, name, sort}] + """ + return R.ok(data=dict_service.get_dict_data(code)) + + +# ============================================================ +# 字典详情查询 +# ============================================================ + + +@dict_blue.route("/detail/", methods=["GET"]) +@check_permission("sys:dict:detail") +@operation_log("字典管理", "详情") +def detail(id): + """ + 查询字典详情 + + Args: + id: 字典ID + + Returns: + JSON: 字典详细信息 + """ + data = dict_service.get_dict_detail(id) + return R.ok(data=data) + + +# ============================================================ +# 字典添加 +# ============================================================ + + +@dict_blue.route("/add", methods=["POST"]) +@check_permission("sys:dict:add") +@check_demo +@operation_log("字典管理", "新增") +def add(): + """ + 添加字典 + + 功能说明: + 创建新的字典记录。 + + Returns: + JSON: 操作结果 + """ + json_data, err = parse_request_body() + if err: + return R.failed(msg=err) + dict_service.add_dict(json_data) + return R.ok(msg="添加成功") + + +# ============================================================ +# 字典更新 +# ============================================================ + + +@dict_blue.route("/update", methods=["PUT"]) +@check_permission("sys:dict:update") +@check_demo +@operation_log("字典管理", "修改") +def update(): + """ + 更新字典 + + 功能说明: + 修改已有的字典信息。 + + Returns: + JSON: 操作结果 + """ + json_data, err = parse_request_body() + if err: + return R.failed(msg=err) + dict_service.update_dict(json_data) + return R.ok(msg="更新成功") + + +# ============================================================ +# 字典删除 +# ============================================================ + + +@dict_blue.route("/delete/", methods=["DELETE"]) +@check_permission("sys:dict:delete") +@check_demo +@operation_log("字典管理", "删除") +def delete(id): + """ + 删除字典 + + Args: + id: 字典ID(支持逗号分隔的多个ID) + + Returns: + JSON: 操作结果 + """ + count = dict_service.delete_dicts(id) + return R.ok(msg=f"本次共删除{count}条数据") + + +# ============================================================ +# 字典批量删除 +# ============================================================ + + +@dict_blue.route("/batchDelete", methods=["DELETE"]) +@check_permission("sys:dict:batchDelete") +@check_demo +@operation_log("字典管理", "批量删除") +def batch_delete(): + """ + 批量删除字典 + + 功能说明: + 批量删除字典记录,支持传入ID数组。 + + Request Body: + [1, 2, 3] # 字典ID数组 + + Returns: + JSON: 操作结果 + """ + id_str, err = parse_batch_ids() + if err: + return err + count = dict_service.delete_dicts(id_str) + return R.ok(msg=f"本次共删除{count}条数据") diff --git a/src/modules/dictionary/dict/schema.py b/src/modules/dictionary/dict/schema.py new file mode 100644 index 0000000..541f414 --- /dev/null +++ b/src/modules/dictionary/dict/schema.py @@ -0,0 +1,172 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 小蚂蚁云团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + +""" +字典表单验证模块 +============================================================= + +提供字典相关的表单验证功能,包括: +- 字典添加/更新表单验证 +""" + +from wtforms import ValidationError, IntegerField, StringField +from wtforms.validators import NumberRange, DataRequired, Length, Optional + +from src.common.forms.base_form import BaseForm +from src.modules.dictionary.dict.repository import DictRepository + + +# ============================================================ +# 字典表单验证(添加和更新共用) +# ============================================================ + + +class DictForm(BaseForm): + """ + 字典表单验证类 + + 用于添加和更新字典时的数据验证。 + 添加时 id 为 None,更新时 id 必须有值。 + + Attributes: + id: 字典ID(更新时必填,添加时可选) + name: 字典名称(必填,最长150字符,不可重复) + code: 字典编码(必填,最长150字符,不可重复) + sort: 字典排序(必填,0-99999之间的整数) + note: 字典备注(可选,最长255字符) + """ + + # ============================================================ + # 字典ID + # ============================================================ + id = IntegerField( + label="字典ID", + validators=[Optional(), NumberRange(min=1, message="字典ID必须大于0")], # 允许为空(添加时) + description="字典唯一标识,更新时必填,添加时自动生成", + ) + + # ============================================================ + # 字典名称 + # ============================================================ + name = StringField( + label="字典名称", + validators=[DataRequired(message="字典名称不能为空"), Length(max=150, message="字典名称长度不得超过150个字符")], + description="字典显示名称,如:性别、状态", + ) + + # ============================================================ + # 字典编码 + # ============================================================ + code = StringField( + label="字典编码", + validators=[DataRequired(message="字典编码不能为空"), Length(max=150, message="字典编码长度不得超过150个字符")], + description="字典唯一标识编码,如:gender、status", + ) + + # ============================================================ + # 字典排序 + # ============================================================ + sort = IntegerField( + label="字典排序", + validators=[Optional(), NumberRange(min=0, max=99999, message="字典排序值在0~99999之间")], + description="排序值,数值越小越靠前", + ) + + # ============================================================ + # 字典备注 + # ============================================================ + note = StringField( + label="字典备注", + validators=[Length(max=255, message="字典备注长度不得超过255个字符")], + description="字典补充说明", + ) + + # ============================================================ + # 自定义验证方法 + # ============================================================ + + def validate_name(self, field): + """ + 自定义验证:字典名称不能重复 + + 验证逻辑: + 1. 添加时:检查数据库中是否存在同名记录 + 2. 更新时:排除当前记录自身,检查其他记录是否同名 + + Args: + field: name 字段对象 + + Raises: + ValidationError: 字典名称已存在时抛出验证错误 + """ + if not field.data: + return + + # 获取当前记录的ID(更新时存在,添加时为None) + current_id = self.id.data if self.id.data else None + + # 唯一性校验(软删过滤 + 更新时排除自身) + if DictRepository.exists_by({'name': field.data}, exclude_id=current_id): + raise ValidationError("字典名称已存在") + + def validate_code(self, field): + """ + 自定义验证:字典编码不能重复 + + 验证逻辑: + 1. 添加时:检查数据库中是否存在相同编码记录 + 2. 更新时:排除当前记录自身,检查其他记录是否相同编码 + + Args: + field: code 字段对象 + + Raises: + ValidationError: 字典编码已存在时抛出验证错误 + """ + if not field.data: + return + + # 获取当前记录的ID(更新时存在,添加时为None) + current_id = self.id.data if self.id.data else None + + # 唯一性校验(软删过滤 + 更新时排除自身) + if DictRepository.exists_by({'code': field.data}, exclude_id=current_id): + raise ValidationError("字典编码已存在") diff --git a/src/modules/dictionary/dict/service.py b/src/modules/dictionary/dict/service.py new file mode 100644 index 0000000..9579ba8 --- /dev/null +++ b/src/modules/dictionary/dict/service.py @@ -0,0 +1,71 @@ +""" +字典类型管理服务模块 +============================================================= + +该模块实现了字典分组(Dict)管理相关的所有业务逻辑。 +字典类型用于管理系统中的字典分类,如:性别、状态、类型等。 +""" + +from src.common.base_service import BaseService +from src.modules.dictionary import dict_utils +from src.modules.dictionary.dict.schema import DictForm +from src.modules.dictionary.dict.repository import DictRepository + + +class DictService(BaseService): + """ + 字典类型服务类 + + 继承 BaseService 获得标准 CRUD 能力。 + 字典类型变更后自动失效取值缓存,确保前端获取最新数据。 + """ + + repository = DictRepository + form = DictForm + model_name = "字典类型" + + @classmethod + def after_add(cls, obj): + """ + 新增后钩子 + + 失效 dict_utils 缓存,字典类型变更会影响字典项取值。 + """ + dict_utils.clear_cache() + + @classmethod + def after_update(cls, obj): + """更新后钩子:失效缓存""" + dict_utils.clear_cache() + + @classmethod + def after_delete(cls, count): + """删除后钩子:失效缓存""" + dict_utils.clear_cache() + + +# ============================================================ +# 模块级代理函数:保持 router 调用兼容 +# ============================================================ + +get_dict_page = DictService.get_page +get_dict_detail = DictService.get_detail +get_info = DictService.get_info +add_dict = DictService.add +update_dict = DictService.update +delete_dicts = DictService.remove + + +def get_dict_data(code: str) -> list: + """ + 按字典编码读取字典项列表 + + 消费端取值接口,供下拉框和展示使用,走 dict_utils 缓存。 + + Args: + code: 字典编码 + + Returns: + list: 字典项列表 + """ + return dict_utils.get_dict_items(code) diff --git a/src/modules/dictionary/dict_item/__init__.py b/src/modules/dictionary/dict_item/__init__.py new file mode 100644 index 0000000..56ff4f7 --- /dev/null +++ b/src/modules/dictionary/dict_item/__init__.py @@ -0,0 +1 @@ +"""模块包。""" diff --git a/src/modules/dictionary/models.py b/src/modules/dictionary/dict_item/models.py similarity index 66% rename from src/modules/dictionary/models.py rename to src/modules/dictionary/dict_item/models.py index a8f1fa5..2b60025 100644 --- a/src/modules/dictionary/models.py +++ b/src/modules/dictionary/dict_item/models.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -39,76 +39,6 @@ # | 许可协议》附件为准。 # +====================================================================== -""" -字典模型模块 -============================================================= - -提供数据字典分组的数据模型定义,用于管理字典项的分类。 -""" - -from src.common.base_db import BaseDB -from src.common.base_model import BaseModel -from config import DB_PREFIX -from src.core.database import db - - -# ============================================================ -# 字典模型 -# ============================================================ - - -class Dict(BaseModel, BaseDB): - """ - 字典分组模型 - - 用于存储数据字典分组信息,如:性别、状态等。 - 继承自 BaseModel(通用字段)和 BaseDB(数据库操作方法)。 - - Attributes: - id: 主键ID(继承自 BaseModel) - name: 字典名称(String(150),必填) - code: 字典编码(String(150),必填,唯一标识) - sort: 字典排序(Integer,默认0,数值越小越靠前) - note: 字典备注(String(255),可选) - """ - - # ============================================================ - # 表配置 - # ============================================================ - - # 设置表名 - __tablename__ = DB_PREFIX + "dict" - - # ============================================================ - # 字段定义 - # ============================================================ - - # 字典名称 - name = db.Column(db.String(150), nullable=False, comment="字典名称") - - # 字典编码 - code = db.Column(db.String(150), nullable=False, comment="字典编码") - - # 字典排序 - sort = db.Column(db.Integer, default=0, comment="字典排序") - - # 字典备注 - note = db.Column(db.String(255), nullable=True, comment="字典备注") - - # ============================================================ - # 魔法方法 - # ============================================================ - - def __str__(self): - """ - 返回模型的字符串表示 - - Returns: - str: 格式为 "字典{id}" 的字符串 - """ - return "字典{}".format(self.id) - - """ 字典项模型模块 ============================================================= @@ -116,10 +46,11 @@ class Dict(BaseModel, BaseDB): 提供数据字典项的数据模型定义,用于管理字典的具体选项。 """ - -# ============================================================ -# 字典项模型 -# ============================================================ +from src.common.base_db import BaseDB +from src.common.base_model import BaseModel +from config import DB_PREFIX +from sqlalchemy import text +from src.core.database import db class DictItem(BaseModel, BaseDB): @@ -128,6 +59,7 @@ class DictItem(BaseModel, BaseDB): 用于存储数据字典的具体项,属于某个字典分组。 继承自 BaseModel(通用字段)和 BaseDB(数据库操作方法)。 + 唯一性由应用层校验(exists_by 只查未删除记录),软删除后可重建同值字典项。 Attributes: id: 主键ID(继承自 BaseModel) @@ -145,24 +77,26 @@ class DictItem(BaseModel, BaseDB): # 设置表名 __tablename__ = DB_PREFIX + "dict_item" + # 表注释 + __table_comment__ = "字典项表" + # ============================================================ # 字段定义 # ============================================================ - # 字典ID - dict_id = db.Column(db.Integer, default=0, comment="字典ID") - - # 字典编码 - dict_code = db.Column(db.String(100), nullable=False, comment="字典编码") - # 字典项名称 - name = db.Column(db.String(100), nullable=False, comment="字典项名称") + name = db.Column(db.String(100), nullable=False, index=True, comment="字典项名称") # 字典项值 value = db.Column(db.String(100), nullable=False, comment="字典项值") + # 字典ID + dict_id = db.Column(db.Integer, default=0, + server_default=text('0'), index=True, comment="字典ID") + # 字典项排序 - sort = db.Column(db.Integer, default=0, comment="字典项顺序") + sort = db.Column(db.Integer, default=0, + server_default=text('0'), comment="字典项顺序") # 字典项备注 note = db.Column(db.String(255), nullable=True, comment="字典项备注") diff --git a/src/modules/dictionary/dict_item/repository.py b/src/modules/dictionary/dict_item/repository.py new file mode 100644 index 0000000..1e3496d --- /dev/null +++ b/src/modules/dictionary/dict_item/repository.py @@ -0,0 +1,96 @@ +""" +DictItem 仓储模块 +============================================================= + +数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 +""" + +from src.common.constants.common import PAGE_SIZE +from src.common.base_repository import BaseRepository +from src.core.database import db +from src.modules.dictionary.dict.models import Dict +from src.modules.dictionary.dict_item.models import DictItem + + +class DictItemRepository(BaseRepository): + """字典项数据仓储,提供字典项相关的数据库访问操作""" + + model = DictItem + + @classmethod + def page(cls, params: dict): + """ + 字典项分页查询 + + 支持按字典ID和状态精确过滤,按名称模糊搜索,按 sort 字段升序排列。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + - dictId: 字典ID(精确匹配) + - status: 状态(精确匹配) + - name: 字典项名称(模糊匹配) + + Returns: + tuple: (items, total, pages) + - items: 当前页数据列表 + - total: 总记录数 + - pages: 总页数 + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + filters={'dict_id': params.get('dictId'), 'status': params.get('status')}, + like_filters={'name': params.get('name')}, + order_by='sort', + ) + + @classmethod + def list_by_code(cls, code, with_deleted: bool = False): + """ + 按字典编码查询字典项 + + 通过关联字典表按编码过滤,用于消费端获取字典数据,默认仅返回未删除记录。 + + Args: + code: 字典编码 + with_deleted: 是否包含已删除记录,默认 False + + Returns: + list: 字典项实例列表,按 sort 升序、id 升序排列 + """ + query = ( + cls.model.query + .join(Dict, cls.model.dict_id == Dict.id) + .filter(Dict.code == code) + ) + if not with_deleted: + query = query.filter(cls.model.is_delete == 0) + return query.order_by(cls.model.sort.asc(), cls.model.id.asc()).all() + + @classmethod + def list_by_codes(cls, codes, with_deleted: bool = False): + """ + 按多个字典编码批量查询字典项 + + 通过关联字典表按编码过滤,用于批量获取或缓存预热场景。 + + Args: + codes: 字典编码列表 + with_deleted: 是否包含已删除记录,默认 False + + Returns: + list: 字典项实例列表,按 sort 升序、id 升序排列 + """ + valid = [c for c in (codes or []) if c] + if not valid: + return [] + query = ( + cls.model.query + .join(Dict, cls.model.dict_id == Dict.id) + .filter(Dict.code.in_(valid)) + ) + if not with_deleted: + query = query.filter(cls.model.is_delete == 0) + return query.order_by(cls.model.sort.asc(), cls.model.id.asc()).all() diff --git a/src/modules/dictionary/router.py b/src/modules/dictionary/dict_item/router.py similarity index 61% rename from src/modules/dictionary/router.py rename to src/modules/dictionary/dict_item/router.py index bc3c247..954da90 100644 --- a/src/modules/dictionary/router.py +++ b/src/modules/dictionary/dict_item/router.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -39,174 +39,6 @@ # | 许可协议》附件为准。 # +====================================================================== - -""" -字典管理控制器模块 -============================================================= - -提供字典(Dict)相关的API接口,包括: -- 字典分页查询 -- 字典详情查询 -- 字典添加、更新、删除 - -所属包: application.views -依赖模块: application.services.dicts - 字典业务逻辑层 - application.middleware.check_permission - 权限校验中间件 -""" -from flask import Blueprint, request - -from src.core.middleware import check_demo -from src.core.permission import check_permission -from src.modules.dictionary import service as dict -from src.modules.dictionary import service as dict_item -from src.common.utils.request import parse_batch_ids, parse_request_body - -from src.core import response as R - -dict_blue = Blueprint("dict", __name__, url_prefix="/dict") - - -# ============================================================ -# 字典分页查询 -# ============================================================ - - -@dict_blue.route("/page", methods=["GET"]) -@check_permission("sys:dict:page") -def page(): - """ - 查询字典分页数据 - - 功能说明: - 获取字典列表的分页数据,支持关键词搜索和状态筛选。 - - Returns: - JSON: 字典分页数据,包含 records(数据列表)、total(总记录数)等 - """ - return R.ok(data=dict.get_dict_page(request.args)) - - -# ============================================================ -# 字典详情查询 -# ============================================================ - - -@dict_blue.route("/detail/", methods=["GET"]) -@check_permission("sys:dict:detail") -def detail(id): - """ - 查询字典详情 - - Args: - id: 字典ID - - Returns: - JSON: 字典详细信息 - """ - data = dict.get_dict_detail(id) - return R.ok(data=data) - - -# ============================================================ -# 字典添加 -# ============================================================ - - -@dict_blue.route("/add", methods=["POST"]) -@check_permission("sys:dict:add") -@check_demo -def add(): - """ - 添加字典 - - 功能说明: - 创建新的字典记录。 - - Returns: - JSON: 操作结果 - """ - json_data, err = parse_request_body() - if err: - return R.failed(msg=err) - dict.add_dict(json_data) - return R.ok(msg="添加成功") - - -# ============================================================ -# 字典更新 -# ============================================================ - - -@dict_blue.route("/update", methods=["PUT"]) -@check_permission("sys:dict:update") -@check_demo -def update(): - """ - 更新字典 - - 功能说明: - 修改已有的字典信息。 - - Returns: - JSON: 操作结果 - """ - json_data, err = parse_request_body() - if err: - return R.failed(msg=err) - dict.update_dict(json_data) - return R.ok(msg="更新成功") - - -# ============================================================ -# 字典删除 -# ============================================================ - - -@dict_blue.route("/delete/", methods=["DELETE"]) -@check_permission("sys:dict:delete") -@check_demo -def delete(id): - """ - 删除字典 - - Args: - id: 字典ID(支持逗号分隔的多个ID) - - Returns: - JSON: 操作结果 - """ - count = dict.delete_dicts(id) - return R.ok(msg=f"本次共删除{count}条数据") - - -# ============================================================ -# 字典批量删除 -# ============================================================ - - -@dict_blue.route("/batchDelete", methods=["DELETE"]) -@check_permission("sys:dict:batch_delete") -@check_demo -def batch_delete(): - """ - 批量删除字典 - - 功能说明: - 批量删除字典记录,支持传入ID数组。 - - Request Body: - [1, 2, 3] # 字典ID数组 - - Returns: - JSON: 操作结果 - """ - id_str, err = parse_batch_ids() - if err: - return err - count = dict.delete_dicts(id_str) - return R.ok(msg=f"本次共删除{count}条数据") - - """ 字典项数据控制器模块 ============================================================= @@ -220,6 +52,15 @@ def batch_delete(): 依赖模块: application.services.dicts_data - 字典项业务逻辑层 application.middleware.check_permission - 权限校验中间件 """ +from flask import Blueprint, request + +from src.middleware import check_demo, operation_log +from src.core.permission import check_permission +from src.modules.dictionary.dict_item import service as dict_item_service +from src.common.utils.request import parse_batch_ids, parse_request_body + +from src.core import response as R + dict_item_blue = Blueprint("dict_item", __name__, url_prefix="/dict/item") @@ -230,6 +71,7 @@ dict_item_blue = Blueprint("dict_item", __name__, url_prefix="/dict/item") @dict_item_blue.route("/page", methods=["GET"]) @check_permission("sys:dictitem:page") +@operation_log("字典项管理", "查询") def dict_item_page(): """ 查询字典项分页数据 @@ -240,7 +82,7 @@ def dict_item_page(): Returns: JSON: 字典项分页数据,包含 records(数据列表)、total(总记录数)等 """ - return R.ok(data=dict_item.get_dict_item_page(request.args)) + return R.ok(data=dict_item_service.get_dict_item_page(request.args)) # ============================================================ @@ -250,6 +92,7 @@ def dict_item_page(): @dict_item_blue.route("/detail/", methods=["GET"]) @check_permission("sys:dictitem:detail") +@operation_log("字典项管理", "详情") def dict_item_detail(id): """ 查询字典项详情 @@ -260,7 +103,7 @@ def dict_item_detail(id): Returns: JSON: 字典项详细信息 """ - data = dict_item.get_dict_item_detail(id) + data = dict_item_service.get_dict_item_detail(id) return R.ok(data=data) @@ -272,6 +115,7 @@ def dict_item_detail(id): @dict_item_blue.route("/add", methods=["POST"]) @check_permission("sys:dictitem:add") @check_demo +@operation_log("字典项管理", "新增") def dict_item_add(): """ 添加字典项 @@ -285,7 +129,7 @@ def dict_item_add(): json_data, err = parse_request_body() if err: return R.failed(msg=err) - dict_item.add_dict_item(json_data) + dict_item_service.add_dict_item(json_data) return R.ok(msg="添加成功") @@ -297,6 +141,7 @@ def dict_item_add(): @dict_item_blue.route("/update", methods=["PUT"]) @check_permission("sys:dictitem:update") @check_demo +@operation_log("字典项管理", "修改") def dict_item_update(): """ 更新字典项 @@ -310,7 +155,7 @@ def dict_item_update(): json_data, err = parse_request_body() if err: return R.failed(msg=err) - dict_item.update_dict_item(json_data) + dict_item_service.update_dict_item(json_data) return R.ok(msg="更新成功") @@ -322,6 +167,7 @@ def dict_item_update(): @dict_item_blue.route("/delete/", methods=["DELETE"]) @check_permission("sys:dictitem:delete") @check_demo +@operation_log("字典项管理", "删除") def dict_item_delete(id): """ 删除字典项 @@ -332,7 +178,7 @@ def dict_item_delete(id): Returns: JSON: 操作结果 """ - count = dict_item.delete_dict_items(id) + count = dict_item_service.delete_dict_items(id) return R.ok(msg=f"本次共删除{count}条数据") @@ -342,8 +188,9 @@ def dict_item_delete(id): @dict_item_blue.route("/batchDelete", methods=["DELETE"]) -@check_permission("sys:dictitem:batch_delete") +@check_permission("sys:dictitem:batchDelete") @check_demo +@operation_log("字典项管理", "批量删除") def dict_item_batch_delete(): """ 批量删除字典项 @@ -360,5 +207,5 @@ def dict_item_batch_delete(): id_str, err = parse_batch_ids() if err: return err - count = dict_item.delete_dict_items(id_str) + count = dict_item_service.delete_dict_items(id_str) return R.ok(msg=f"本次共删除{count}条数据") diff --git a/src/modules/dictionary/schema.py b/src/modules/dictionary/dict_item/schema.py similarity index 57% rename from src/modules/dictionary/schema.py rename to src/modules/dictionary/dict_item/schema.py index b350de7..cbaa6da 100644 --- a/src/modules/dictionary/schema.py +++ b/src/modules/dictionary/dict_item/schema.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -39,153 +39,6 @@ # | 许可协议》附件为准。 # +====================================================================== -""" -字典表单验证模块 -============================================================= - -提供字典相关的表单验证功能,包括: -- 字典添加/更新表单验证 -""" - -from wtforms import ValidationError, IntegerField, StringField -from wtforms.validators import NumberRange, DataRequired, Length, Optional - -from src.common.forms.base_form import BaseForm -from src.modules.dictionary.models import Dict, DictItem - - -# ============================================================ -# 字典表单验证(添加和更新共用) -# ============================================================ - - -class DictForm(BaseForm): - """ - 字典表单验证类 - - 用于添加和更新字典时的数据验证。 - 添加时 id 为 None,更新时 id 必须有值。 - - Attributes: - id: 字典ID(更新时必填,添加时可选) - name: 字典名称(必填,最长150字符,不可重复) - code: 字典编码(必填,最长150字符,不可重复) - sort: 字典排序(必填,0-99999之间的整数) - note: 字典备注(可选,最长255字符) - """ - - # ============================================================ - # 字典ID - # ============================================================ - id = IntegerField( - label="字典ID", - validators=[Optional(), NumberRange(min=1, message="字典ID必须大于0")], # 允许为空(添加时) - description="字典唯一标识,更新时必填,添加时自动生成", - ) - - # ============================================================ - # 字典名称 - # ============================================================ - name = StringField( - label="字典名称", - validators=[DataRequired(message="字典名称不能为空"), Length(max=150, message="字典名称长度不得超过150个字符")], - description="字典显示名称,如:性别、状态", - ) - - # ============================================================ - # 字典编码 - # ============================================================ - code = StringField( - label="字典编码", - validators=[DataRequired(message="字典编码不能为空"), Length(max=150, message="字典编码长度不得超过150个字符")], - description="字典唯一标识编码,如:gender、status", - ) - - # ============================================================ - # 字典排序 - # ============================================================ - sort = IntegerField( - label="字典排序", - validators=[Optional(), NumberRange(min=0, max=99999, message="字典排序值在0~99999之间")], - description="排序值,数值越小越靠前", - ) - - # ============================================================ - # 字典备注 - # ============================================================ - note = StringField( - label="字典备注", - validators=[Length(max=255, message="字典备注长度不得超过255个字符")], - description="字典补充说明", - ) - - # ============================================================ - # 自定义验证方法 - # ============================================================ - - def validate_name(self, field): - """ - 自定义验证:字典名称不能重复 - - 验证逻辑: - 1. 添加时:检查数据库中是否存在同名记录 - 2. 更新时:排除当前记录自身,检查其他记录是否同名 - - Args: - field: name 字段对象 - - Raises: - ValidationError: 字典名称已存在时抛出验证错误 - """ - if not field.data: - return - - # 获取当前记录的ID(更新时存在,添加时为None) - current_id = self.id.data if self.id.data else None - - # 构建查询:查找同名记录 - query = Dict.query.filter(Dict.name == field.data, Dict.is_delete == 0) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(Dict.id != current_id) - - # 如果存在同名记录,抛出验证错误 - if query.first(): - raise ValidationError("字典名称已存在") - - def validate_code(self, field): - """ - 自定义验证:字典编码不能重复 - - 验证逻辑: - 1. 添加时:检查数据库中是否存在相同编码记录 - 2. 更新时:排除当前记录自身,检查其他记录是否相同编码 - - Args: - field: code 字段对象 - - Raises: - ValidationError: 字典编码已存在时抛出验证错误 - """ - if not field.data: - return - - # 获取当前记录的ID(更新时存在,添加时为None) - current_id = self.id.data if self.id.data else None - - # 构建查询:查找相同编码记录 - query = Dict.query.filter(Dict.code == field.data, Dict.is_delete == 0) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(Dict.id != current_id) - - # 如果存在相同编码记录,抛出验证错误 - if query.first(): - raise ValidationError("字典编码已存在") - - """ 字典项表单验证模块 ============================================================= @@ -194,6 +47,12 @@ class DictForm(BaseForm): - 字典项添加/更新表单验证 """ +from wtforms import ValidationError, IntegerField, StringField +from wtforms.validators import NumberRange, DataRequired, Length, Optional + +from src.common.forms.base_form import BaseForm +from src.modules.dictionary.dict_item.repository import DictItemRepository + # ============================================================ # 字典项表单验证(添加和更新共用) @@ -279,11 +138,12 @@ class DictItemForm(BaseForm): def validate_name(self, field): """ - 自定义验证:字典项名称不能重复 + 自定义验证:字典项名称在同一字典下不能重复 验证逻辑: - 1. 添加时:检查数据库中是否存在同名记录 + 1. 添加时:检查同一字典下是否存在同名记录 2. 更新时:排除当前记录自身,检查其他记录是否同名 + 3. 不同字典(dict_id)之间允许存在相同名称 Args: field: name 字段对象 @@ -297,15 +157,14 @@ class DictItemForm(BaseForm): # 获取当前记录的ID(更新时存在,添加时为None) current_id = self.id.data if self.id.data else None - # 构建查询:查找同名记录 - query = DictItem.query.filter(DictItem.name == field.data, DictItem.is_delete == 0) + # 获取字典ID + dict_id = self.dictId.data if self.dictId.data else None - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(DictItem.id != current_id) + if dict_id is None: + return - # 如果存在同名记录,抛出验证错误 - if query.first(): + # 唯一性校验(同字典下 + 软删过滤 + 更新时排除自身) + if DictItemRepository.exists_by({'dict_id': dict_id, 'name': field.data}, exclude_id=current_id): raise ValidationError("字典项名称已存在") def validate_value(self, field): @@ -334,15 +193,6 @@ class DictItemForm(BaseForm): if dict_id is None: return - # 构建查询:查找同一字典下相同值的记录 - query = DictItem.query.filter( - DictItem.dict_id == dict_id, DictItem.value == field.data, DictItem.is_delete == 0 - ) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(DictItem.id != current_id) - - # 如果存在相同值记录,抛出验证错误 - if query.first(): + # 唯一性校验(同字典下 + 软删过滤 + 更新时排除自身) + if DictItemRepository.exists_by({'dict_id': dict_id, 'value': field.data}, exclude_id=current_id): raise ValidationError("字典项值已存在") diff --git a/src/modules/dictionary/dict_item/service.py b/src/modules/dictionary/dict_item/service.py new file mode 100644 index 0000000..7198e5b --- /dev/null +++ b/src/modules/dictionary/dict_item/service.py @@ -0,0 +1,71 @@ +""" +字典项管理服务模块 +============================================================= + +该模块实现了字典项(DictItem)管理相关的所有业务逻辑。 +字典项管理具体数据项,如性别字典下的男、女等。 +""" + +from src.common.base_service import BaseService +from src.modules.dictionary import dict_utils +from src.modules.dictionary.dict.repository import DictRepository +from src.modules.dictionary.dict_item.schema import DictItemForm +from src.modules.dictionary.dict_item.repository import DictItemRepository +from src.core.exceptions import BizError + + +class DictItemService(BaseService): + """ + 字典项服务类 + + 继承 BaseService 获得标准 CRUD 能力。 + 新增时校验父字典存在,变更后失效取值缓存。 + """ + + repository = DictItemRepository + form = DictItemForm + model_name = "字典项" + + @classmethod + def before_add(cls, form): + """ + 新增前钩子 + + 校验父字典是否存在。 + + Args: + form: 已校验的表单对象 + + Raises: + BizError: 父字典不存在时抛出 + """ + dict_obj = DictRepository.get_by_id(form.dictId.data) + if not dict_obj: + raise BizError("字典不存在") + + @classmethod + def after_add(cls, obj): + """新增后钩子:失效缓存""" + dict_utils.clear_cache() + + @classmethod + def after_update(cls, obj): + """更新后钩子:失效缓存""" + dict_utils.clear_cache() + + @classmethod + def after_delete(cls, count): + """删除后钩子:失效缓存""" + dict_utils.clear_cache() + + +# ============================================================ +# 模块级代理函数:保持 router 调用兼容 +# ============================================================ + +get_dict_item_page = DictItemService.get_page +get_dict_item_detail = DictItemService.get_detail +get_dict_item_info = DictItemService.get_info +add_dict_item = DictItemService.add +update_dict_item = DictItemService.update +delete_dict_items = DictItemService.remove diff --git a/src/modules/dictionary/dict_item_repository.py b/src/modules/dictionary/dict_item_repository.py deleted file mode 100644 index 561c998..0000000 --- a/src/modules/dictionary/dict_item_repository.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -DictItem 仓储模块 -============================================================= - -数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 -""" - -from src.common.base_repository import BaseRepository -from src.modules.dictionary.models import DictItem - - -class DictItemRepository(BaseRepository): - """DictItem 仓储""" - - model = DictItem diff --git a/src/modules/dictionary/dict_repository.py b/src/modules/dictionary/dict_repository.py deleted file mode 100644 index 43fe680..0000000 --- a/src/modules/dictionary/dict_repository.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Dict 仓储模块 -============================================================= - -数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 -""" - -from src.common.base_repository import BaseRepository -from src.modules.dictionary.models import Dict - - -class DictRepository(BaseRepository): - """Dict 仓储""" - - model = Dict diff --git a/src/modules/dictionary/dict_utils.py b/src/modules/dictionary/dict_utils.py new file mode 100644 index 0000000..d37f6df --- /dev/null +++ b/src/modules/dictionary/dict_utils.py @@ -0,0 +1,150 @@ +""" +字典取值工具模块 +============================================================= + +打通 dict / dict_item 表的"消费端": + - 按字典编码(code)读取字典项列表 / value→name 映射 + - 供业务模块做展示翻译(如 user 模块 gender → 男/女/保密), + 替代硬编码枚举(如 GENDER_LIST),后台改字典即改显示文本 + +缓存策略(内存 + Redis 双级,Redis 不可用时自动降级): + - 内存层:code -> (时间戳, 字典项列表),TTL 300s,每次取值的最快路径 + - Redis 层:key = dict:data:{code},跨 worker 共享;后台改字典后 clear_cache 即时失效 + - Redis 读写均为 best-effort(try/except):Redis 挂了静默降级为"读库 + 只走内存", + 保证无 Redis 环境(单测)也能正常运行 + +关键约定:dict_item.value 以字符串存储(如 "1"),而业务字段可能是 Integer(如 user.gender=1)。 + get_value_map 以 str(value) 为键、get_name 统一 str(value) 归一,因此 int / str 都能命中。 +""" + +import time +from typing import Dict, List, Optional + +from src.core.redis_client import redis +from src.modules.dictionary.dict_item.repository import DictItemRepository + +# ============================================================ +# 常量 +# ============================================================ + +# Redis key 前缀 +_REDIS_PREFIX = "dict:data:" +# 内存缓存 TTL(秒) +_MEM_TTL = 300 + +# 内存缓存:code -> (写入时间戳, [{value, name, sort}]) +_mem_cache: Dict[str, tuple] = {} + + +# ============================================================ +# Redis 读写(best-effort,Redis 不可用时静默降级) +# ============================================================ + +def _redis_key(code: str) -> str: + return f"{_REDIS_PREFIX}{code}" + + +def _redis_get(code: str) -> Optional[list]: + """读 Redis 缓存;不可用 / 未命中返回 None""" + try: + return redis.get(_redis_key(code), default=None, deserialize=True) + except Exception: + return None + + +def _redis_set(code: str, items: list) -> None: + """写 Redis 缓存;不可用时静默跳过(不影响主流程)""" + try: + redis.set(_redis_key(code), items, expire=_MEM_TTL) + except Exception: + pass + + +def _redis_del(code: str) -> None: + """删 Redis 缓存;不可用时静默跳过""" + try: + redis.delete(_redis_key(code)) + except Exception: + pass + + +# ============================================================ +# 内存缓存 +# ============================================================ + +def _mem_hit(code: str) -> Optional[list]: + entry = _mem_cache.get(code) + if entry and time.time() - entry[0] < _MEM_TTL: + return entry[1] + return None + + +def _mem_store(code: str, items: list) -> None: + _mem_cache[code] = (time.time(), items) + + +# ============================================================ +# 核心加载 +# ============================================================ + +def _load_items(code: str) -> List[dict]: + """加载字典项 [{value, name, sort}]:内存 → Redis → DB,命中后逐级回填。""" + mem = _mem_hit(code) + if mem is not None: + return mem + + rds = _redis_get(code) + if rds is not None: + _mem_store(code, rds) + return rds + + items = [ + {"value": it.value, "name": it.name, "sort": it.sort} + for it in DictItemRepository.list_by_code(code) + ] + _mem_store(code, items) + _redis_set(code, items) + return items + + +# ============================================================ +# 对外 API +# ============================================================ + +def get_dict_items(code: str) -> List[dict]: + """按字典编码返回字典项列表 [{value, name, sort}](sort 升序),供下拉框/展示用。""" + return _load_items(code) + + +def get_value_map(code: str) -> Dict[str, str]: + """返回 {value: name} 映射(value 统一为 str),如 {'1': '男', '2': '女'}。""" + return {str(item["value"]): item["name"] for item in _load_items(code)} + + +def get_name(code: str, value, default=None): + """ + 按字典编码 + 值取显示名(int / str 均可命中)。 + + Args: + code: 字典编码 + value: 字典项值(int / str 均可命中) + default: 未命中时的默认值,默认 None + + Returns: + str: 显示名,未命中返回 default + """ + return get_value_map(code).get(str(value), default) + + +def clear_cache(code: Optional[str] = None) -> None: + """失效缓存:code=None 清全部,否则清单个(内存 + Redis,均 best-effort)。""" + if code is None: + _mem_cache.clear() + try: + for key in redis.keys(_REDIS_PREFIX + "*"): + redis.delete(key) + except Exception: + pass + return + _mem_cache.pop(code, None) + _redis_del(code) diff --git a/src/modules/dictionary/service.py b/src/modules/dictionary/service.py deleted file mode 100644 index 51c4543..0000000 --- a/src/modules/dictionary/service.py +++ /dev/null @@ -1,628 +0,0 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 -# +====================================================================== -# | 版权和免责声明: -# | 一、知识产权声明 -# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 -# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, -# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 -# | 二、使用限制与禁止条款 -# | 您理解并同意,禁止将本产品用于以下任何行为: -# | 1. 任何违反中华人民共和国法律法规的用途; -# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; -# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 -# | 三、责任豁免 -# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 -# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 -# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 -# | 四、违约与法律责任 -# | 如发现任何违反本声明的行为,本团队有权: -# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; -# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; -# | 3. 保留进一步采取刑事控告等法律措施的权利。 -# | 五、许可范围说明 -# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 -# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 -# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 -# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 -# | 成的一切经济损失及商誉损害。 -# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 -# | 许可协议》附件为准。 -# +====================================================================== - -""" -字典类型管理服务模块 -============================================================= - -该模块实现了字典类型管理相关的所有业务逻辑。 -包括字典类型的分页查询、详情查询、添加、更新、删除等功能。 -字典类型用于管理系统中的字典分类,如:性别、状态、类型等, -每个字典类型包含名称、编码、排序、状态等属性,通过sort字段控制显示顺序。 - -命名规范: -- 函数参数使用驼峰命名(如 dictId, pageNo) -- 表单字段使用下划线命名(如 name, code, sort)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters, _build_page_data) - -功能模块: -1. 分页查询:支持按名称模糊搜索、状态筛选,按sort字段排序 -2. 详情查询:根据ID获取单个字典类型的详细信息 -3. 数据维护:字典类型的添加、更新、删除(支持批量) -""" - -import logging - -from datetime import datetime, timezone - -from src.common.constants.common import PAGE_SIZE -from src.modules.dictionary.schema import DictForm, DictItemForm -from src.modules.dictionary.models import Dict, DictItem -from src.modules.dictionary.dict_repository import DictRepository -from src.modules.dictionary.dict_item_repository import DictItemRepository -from src.core.exceptions import BizError -from src.common.utils import regular -from src.common.utils.case_converter import convert_camel_to_snake -from src.common.utils.model_utils import models_to_list, model_to_dict -from src.common.utils.request import parse_id_list, like_contains -from src.core.security import get_username - -# 获取日志记录器 -logger = logging.getLogger(__name__) - - -# ============================================================ -# 字典类型分页查询 -# ============================================================ - - -def get_dict_page(params: dict) -> dict: - """ - 查询字典类型分页数据 - - Args: - request: Flask request对象,包含查询参数 - - pageNo: 当前页码(可选,默认为1) - - pageSize: 每页记录数(可选,默认为PAGE_SIZE) - - name: 字典名称(可选,模糊查询) - - status: 字典状态(可选,精确匹配) - - Returns: - R: 包含分页数据的JSON响应,格式为: - { - "records": 字典类型列表, - "total": 总记录数, - "size": 每页大小, - "current": 当前页码, - "pages": 总页数 - } - """ - # 获取分页参数 - page_no = int(params.get("pageNo", 1)) - page_size = int(params.get("pageSize", PAGE_SIZE)) - - # 应用筛选条件 - query = _apply_filters(params) - - # 记录总数 - total = query.count() - # 计算总页数 - pages = (total + page_size - 1) // page_size if total > 0 else 0 - - # 分页查询 - dict_list = query.limit(page_size).offset((page_no - 1) * page_size).all() - # 构建返回数据 - page_data = _build_page_data(dict_list, total, page_size, page_no, pages) - return page_data - - -def _apply_filters(params: dict): - """ - 应用查询筛选条件 - - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 支持字典名称模糊查询 - - 支持字典状态精确查询 - - 默认按排序值升序排列 - - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = Dict.query.filter(Dict.is_delete == 0) - - # 字典名称模糊筛选 - name = params.get("name") - if name: - query = query.filter(Dict.name.like(like_contains(name), escape="\\")) - - # 字典状态精确筛选 - status = params.get("status") - if status: - query = query.filter(Dict.status == status) - - # 按排序值升序排列 - return query.order_by(Dict.sort.asc()) - - -def _build_page_data(dict_list, total, page_size, page_no, pages): - """ - 构建分页返回数据 - - Args: - dict_list: 字典类型对象列表 - total: 总记录数 - page_size: 每页大小 - page_no: 当前页码 - pages: 总页数 - - Returns: - dict: 分页数据,包含 records、total、size、current、pages - """ - # 将模型对象列表转换为字典列表 - records = models_to_list(dict_list) - - return {"records": records, "total": total, "size": page_size, "current": page_no, "pages": pages} - - -# ============================================================ -# 字典类型详情查询 -# ============================================================ - - -def get_dict_detail(dict_id): - """ - 根据ID查询字典类型详情(返回字典格式) - - 此方法调用 get_info 获取模型对象,然后转换为字典格式返回。 - 适用于前端接口调用。 - - Args: - dict_id: 字典类型ID - - Returns: - dict/None: 字典类型详情字典,不存在时返回 None - """ - try: - # 复用 get_info 方法查询字典类型 - dict_obj = get_info(dict_id) - if not dict_obj: - return None - # 转换为字典格式 - return model_to_dict(dict_obj) - - except Exception as e: - logger.error(f"查询字典类型详情异常,ID: {dict_id}, 错误: {str(e)}") - return None - - -# ============================================================ -# 字典类型信息查询(根据ID)- 核心查询方法 -# ============================================================ - - -def get_info(dict_id: int): - """ - 根据ID查询字典类型信息(返回模型对象) - - 这是一个通用的字典类型查询方法,返回字典类型模型对象而非字典。 - 与 get_dict_detail 的区别: - - get_dict_detail: 返回字典格式,用于前端接口 - - get_info: 返回模型对象,用于内部服务调用 - - 此方法被以下方法复用: - - get_dict_detail: 获取详情字典 - - update_dict: 验证字典类型是否存在 - - Args: - dict_id: 字典类型ID - - Returns: - Dict/None: 字典类型模型对象,不存在或已删除时返回 None - - Example: - dict_obj = get_info(dict_id) - if dict_obj: - print(dict_obj.name) - """ - return DictRepository.get_by_id(dict_id) - - -def add_dict(data: dict) -> None: - """ - 添加字典类型 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证(名称唯一性、字段格式等) - 3. 创建字典类型对象并保存到数据库 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = DictForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 创建字典类型 - dict_obj = Dict(**form.data) - dict_obj.create_user = get_username() - dict_obj.save() - - -# ============================================================ -# 字典类型更新 -# ============================================================ - - -def update_dict(data: dict) -> None: - """ - 更新字典类型 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 验证字典类型是否存在(复用 get_info) - 4. 更新字典类型信息 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = DictForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 获取字典类型ID - dict_id = form.data.get("id") - if not dict_id or int(dict_id) <= 0: - raise BizError("字典类型ID无效") - dict_id = int(dict_id) - - # 复用 get_info 方法验证字典类型是否存在 - dict_obj = get_info(dict_id) - if not dict_obj: - raise BizError("字典类型不存在") - - # 更新字典类型信息(移除ID,避免更新主键) - form_data = convert_camel_to_snake(form.data.copy()) - form_data.pop("id", None) - - form_data["update_time"] = datetime.now(tz=timezone.utc) - form_data["update_user"] = get_username() - - result = Dict.query.filter_by(id=dict_id).update(form_data) - - if not result: - raise BizError("更新失败") - - -# ============================================================ -# 字典类型删除 -# ============================================================ - - -def delete_dicts(dict_ids: str) -> int: - """ - 删除字典类型(批量删除) - - 删除 - 支持逗号分隔的多个ID批量删除。 - - Args: - dict_ids: 字典类型ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - dict_ids_list = parse_id_list(dict_ids) - if not dict_ids_list: - raise BizError("无效的记录ID") - return Dict.batch_delete(dict_ids_list) - - -""" -字典项管理服务模块 -============================================================= - -该模块实现了字典项管理相关的所有业务逻辑。 -包括字典项的分页查询、详情查询、添加、更新、删除等功能。 -字典项用于管理字典类型下的具体数据项,如:性别字典下的男、女等, -每个字典项包含名称、键值、排序、状态等属性,通过sort字段控制显示顺序。 - -命名规范: -- 函数参数使用驼峰命名(如 dictItemId, pageNo) -- 表单字段使用下划线命名(如 name, value, sort)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters, _build_page_data) - -功能模块: -1. 分页查询:支持按名称模糊搜索、字典ID筛选、状态筛选,按sort字段排序 -2. 详情查询:根据ID获取单个字典项的详细信息 -3. 数据维护:字典项的添加、更新、删除(支持批量) -""" - - -# 获取日志记录器 -logger = logging.getLogger(__name__) - - -# ============================================================ -# 字典项分页查询 -# ============================================================ - - -def get_dict_item_page(params: dict) -> dict: - """ - 查询字典项分页数据 - - Args: - request: Flask request对象,包含查询参数 - - pageNo: 当前页码(可选,默认为1) - - pageSize: 每页记录数(可选,默认为PAGE_SIZE) - - dictId: 字典ID(必填,用于筛选属于哪个字典类型) - - name: 字典项名称(可选,模糊查询) - - status: 字典项状态(可选,精确匹配) - - Returns: - R: 包含分页数据的JSON响应,格式为: - { - "records": 字典项列表, - "total": 总记录数, - "size": 每页大小, - "current": 当前页码, - "pages": 总页数 - } - """ - # 获取分页参数 - page_no = int(params.get("pageNo", 1)) - page_size = int(params.get("pageSize", PAGE_SIZE)) - - # 应用筛选条件 - query = _apply_dict_item_filters(params) - - # 记录总数 - total = query.count() - # 计算总页数 - pages = (total + page_size - 1) // page_size if total > 0 else 0 - - # 分页查询 - dict_item_list = query.limit(page_size).offset((page_no - 1) * page_size).all() - # 构建返回数据 - page_data = _build_dict_item_page_data(dict_item_list, total, page_size, page_no, pages) - return page_data - - -def _apply_dict_item_filters(params: dict): - """ - 应用查询筛选条件 - - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 必须按字典ID筛选(dict_id) - - 支持字典项名称模糊查询 - - 支持字典项状态精确查询 - - 默认按排序值升序排列 - - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = DictItem.query.filter(DictItem.is_delete == 0) - - # 字典ID筛选(必填) - dict_id = params.get("dictId", 0) - if dict_id: - query = query.filter(DictItem.dict_id == dict_id) - - # 字典项名称模糊筛选 - name = params.get("name") - if name: - query = query.filter(DictItem.name.like(like_contains(name), escape="\\")) - - # 字典项状态精确筛选 - status = params.get("status") - if status: - query = query.filter(DictItem.status == status) - - # 按排序值升序排列 - return query.order_by(DictItem.sort.asc()) - - -def _build_dict_item_page_data(dict_item_list, total, page_size, page_no, pages): - """ - 构建分页返回数据 - - Args: - dict_item_list: 字典项对象列表 - total: 总记录数 - page_size: 每页大小 - page_no: 当前页码 - pages: 总页数 - - Returns: - dict: 分页数据,包含 records、total、size、current、pages - """ - # 将模型对象列表转换为字典列表 - records = models_to_list(dict_item_list) - - return {"records": records, "total": total, "size": page_size, "current": page_no, "pages": pages} - - -# ============================================================ -# 字典项详情查询 -# ============================================================ - - -def get_dict_item_detail(dict_item_id): - """ - 根据ID查询字典项详情(返回字典格式) - - 此方法调用 get_info 获取模型对象,然后转换为字典格式返回。 - 适用于前端接口调用。 - - Args: - dict_item_id: 字典项ID - - Returns: - dict/None: 字典项详情字典,不存在时返回 None - """ - try: - # 复用 get_info 方法查询字典项 - dict_item = get_dict_item_info(dict_item_id) - if not dict_item: - return None - # 转换为字典格式 - return model_to_dict(dict_item) - - except Exception as e: - logger.error(f"查询字典项详情异常,ID: {dict_item_id}, 错误: {str(e)}") - return None - - -# ============================================================ -# 字典项信息查询(根据ID)- 核心查询方法 -# ============================================================ - - -def get_dict_item_info(dict_item_id: int): - """ - 根据ID查询字典项信息(返回模型对象) - - 这是一个通用的字典项查询方法,返回字典项模型对象而非字典。 - 与 get_dict_item_detail 的区别: - - get_dict_item_detail: 返回字典格式,用于前端接口 - - get_info: 返回模型对象,用于内部服务调用 - - 此方法被以下方法复用: - - get_dict_item_detail: 获取详情字典 - - update_dict_item: 验证字典项是否存在 - - Args: - dict_item_id: 字典项ID - - Returns: - DictItem/None: 字典项模型对象,不存在或已删除时返回 None - - Example: - dict_item = get_dict_item_info(dict_item_id) - if dict_item: - print(dict_item.name) - """ - return DictItemRepository.get_by_id(dict_item_id) - - -def add_dict_item(data: dict) -> None: - """ - 添加字典项 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证(字段格式等) - 3. 创建字典项对象并保存到数据库 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = DictItemForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 查询配置(验证配置是否存在) - dict_id = form.dictId.data - dict = Dict.query.filter(Dict.id == dict_id).first() - if not dict: - raise BizError("字典不存在") - - # 创建字典项 - dict_item = DictItem(**form.data) - dict_item.dict_code = dict.code - dict_item.create_user = get_username() - dict_item.save() - - -# ============================================================ -# 字典项更新 -# ============================================================ - - -def update_dict_item(data: dict) -> None: - """ - 更新字典项 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 验证字典项是否存在(复用 get_info) - 4. 更新字典项信息 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = DictItemForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 获取字典项ID - dict_item_id = form.data.get("id") - if not dict_item_id or int(dict_item_id) <= 0: - raise BizError("字典项ID无效") - dict_item_id = int(dict_item_id) - - # 复用 get_info 方法验证字典项是否存在 - dict_item = get_dict_item_info(dict_item_id) - if not dict_item: - raise BizError("字典项不存在") - - # 更新字典项信息(移除ID,避免更新主键) - form_data = convert_camel_to_snake(form.data.copy()) - form_data.pop("id", None) - - form_data["update_time"] = datetime.now(tz=timezone.utc) - form_data["update_user"] = get_username() - - result = DictItem.query.filter_by(id=dict_item_id).update(form_data) - - if not result: - raise BizError("更新失败") - - -# ============================================================ -# 字典项删除 -# ============================================================ - - -def delete_dict_items(dict_item_ids: str) -> int: - """ - 删除字典项(批量删除) - - 删除 - 支持逗号分隔的多个ID批量删除。 - - Args: - dict_item_ids: 字典项ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - dict_item_ids_list = parse_id_list(dict_item_ids) - if not dict_item_ids_list: - raise BizError("无效的记录ID") - return DictItem.batch_delete(dict_item_ids_list) diff --git a/src/modules/example/__init__.py b/src/modules/example/__init__.py new file mode 100644 index 0000000..f0669a7 --- /dev/null +++ b/src/modules/example/__init__.py @@ -0,0 +1 @@ +"""案例演示模块。""" diff --git a/src/modules/example/models.py b/src/modules/example/models.py new file mode 100644 index 0000000..761f70c --- /dev/null +++ b/src/modules/example/models.py @@ -0,0 +1,108 @@ +""" +案例演示模型模块 +============================================================= + +提供案例演示的数据模型定义。 +""" + +from sqlalchemy import text + +from src.common.base_db import BaseDB +from src.common.base_model import BaseModel +from config import DB_PREFIX +from src.core.database import db + + +class Example(BaseModel, BaseDB): + """ + 案例演示模型 + + 继承自 BaseModel(通用字段)和 BaseDB(数据库操作方法)。 + + Attributes: + id: 主键ID(继承自 BaseModel) + name: 案例名称(String) + avatar: 案例图片(String) + type: 案例类型:1-类型1 2-类型2 3-类型3 4-类型4(Integer) + status: 案例状态:1-在用 2-停用(Integer) + sort: 案例排序(Integer) + """ + + # ============================================================ + # 表配置 + # ============================================================ + + # 设置表名 + __tablename__ = DB_PREFIX + "example" + + # 表注释(BaseModel.__init_subclass__ 自动转为 __table_args__ 的 comment 参数) + __table_comment__ = "案例演示表" + + # ============================================================ + # 字段定义 + # ============================================================ + + # 案例名称 + name = db.Column( + db.String(255), + nullable=False, + index=True, + comment="案例名称" + ) + + # 案例图片 + avatar = db.Column( + db.String(255), + nullable=False, + comment="案例图片" + ) + + # 案例类型:1-类型1 2-类型2 3-类型3 4-类型4 + type = db.Column( + db.Integer, + nullable=True, + default=0, + server_default=text('0'), + comment="案例类型:1-类型1 2-类型2 3-类型3 4-类型4" + ) + + # 案例状态:1-在用 2-停用 + status = db.Column( + db.Integer, + nullable=True, + default=0, + server_default=text('0'), + index=True, + comment="案例状态:1-在用 2-停用" + ) + + # 案例排序 + sort = db.Column( + db.Integer, + nullable=True, + default=0, + server_default=text('0'), + comment="案例排序" + ) + + # ============================================================ + # 魔法方法 + # ============================================================ + + def __repr__(self): + """ + 返回模型的字符串表示 + + Returns: + str: 格式为 "案例演示:{...}" 的字符串 + """ + return '案例演示:{}'.format(self.name) + + def __str__(self): + """ + 返回模型的字符串表示 + + Returns: + str: 格式为 "案例演示:{...}" 的字符串 + """ + return self.__repr__() diff --git a/src/modules/example/repository.py b/src/modules/example/repository.py new file mode 100644 index 0000000..65d5af3 --- /dev/null +++ b/src/modules/example/repository.py @@ -0,0 +1,58 @@ +""" +案例演示仓储模块 +============================================================= + +数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 +""" + +from src.common.base_repository import BaseRepository +from src.common.constants.common import PAGE_SIZE +from src.modules.example.models import Example + + +class ExampleRepository(BaseRepository): + """ + 案例演示数据仓储,提供案例演示相关的数据库访问操作 + """ + + model = Example + + @classmethod + def page(cls, params: dict): + """ + 案例演示分页查询 + + 支持按筛选字段精确匹配、按模糊字段关键字搜索,按 sort 字段排序。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + Returns: + tuple: (items, total, pages) + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + filters={ + 'type': params.get('type'), + 'status': params.get('status'), + }, + like_filters={ + 'name': params.get('name'), + }, + order_by='sort', + ) + + @classmethod + def list_enabled(cls, params=None): + """ + 案例演示启用列表(下拉选项等场景使用) + + Args: + params: 查询参数字典(可选) + + Returns: + list: 案例演示列表 + """ + return cls.find_list(order_by='sort') diff --git a/src/modules/example/router.py b/src/modules/example/router.py new file mode 100644 index 0000000..8330183 --- /dev/null +++ b/src/modules/example/router.py @@ -0,0 +1,210 @@ +""" +案例演示控制器模块 +============================================================= + +提供案例演示(Example)相关的API接口,包括: +- 案例演示分页查询、全部列表 +- 案例演示详情查询 +- 案例演示添加、更新、删除、批量删除 +- 案例演示状态设置 +所属包: src.modules.example +依赖模块: src.modules.example.service - 案例演示业务逻辑层 +""" + +from flask import Blueprint, request + +from src.middleware import check_demo, operation_log +from src.core.permission import check_permission +from src.modules.example import service as example +from src.common.utils.request import parse_batch_ids, parse_request_body + +from src.core import response as R + +example_blue = Blueprint("example", __name__, url_prefix="/example") + + +# ============================================================ +# 案例演示分页查询 +# ============================================================ + + +@example_blue.route("/page", methods=["GET"]) +@check_permission("sys:example:page") +@operation_log("案例演示", "查询") +def page(): + """ + 查询案例演示分页数据 + + 功能说明: + 获取案例演示列表的分页数据,支持关键字搜索与筛选。 + + Returns: + JSON: 案例演示分页数据,包含 records(数据列表)、total(总记录数)等 + """ + return R.ok(data=example.get_example_page(request.args)) + + +# ============================================================ +# 案例演示全部列表 +# ============================================================ + + +@example_blue.route("/list", methods=["GET"]) +@check_permission("sys:example:list") +@operation_log("案例演示", "列表") +def list(): + """ + 获取案例演示全部列表(下拉选项等场景使用) + + Returns: + JSON: 案例演示列表数据 + """ + return R.ok(data=example.get_example_list(request.args)) + + +# ============================================================ +# 案例演示详情查询 +# ============================================================ + + +@example_blue.route("/detail/", methods=["GET"]) +@check_permission("sys:example:detail") +@operation_log("案例演示", "详情") +def detail(id): + """ + 查询案例演示详情 + + Args: + id: 案例演示ID + + Returns: + JSON: 案例演示详细信息 + """ + data = example.get_example_detail(id) + return R.ok(data=data) + + +# ============================================================ +# 案例演示添加 +# ============================================================ + + +@example_blue.route("/add", methods=["POST"]) +@check_permission("sys:example:add") +@check_demo +@operation_log("案例演示", "新增") +def add(): + """ + 添加案例演示 + + 功能说明: + 创建新的案例演示记录。 + + Returns: + JSON: 操作结果 + """ + json_data, err = parse_request_body() + if err: + return R.failed(msg=err) + example.add_example(json_data) + return R.ok(msg="添加成功") + + +# ============================================================ +# 案例演示更新 +# ============================================================ + + +@example_blue.route("/update", methods=["PUT"]) +@check_permission("sys:example:update") +@check_demo +@operation_log("案例演示", "修改") +def update(): + """ + 更新案例演示 + + 功能说明: + 修改已有案例演示的信息。 + + Returns: + JSON: 操作结果 + """ + json_data, err = parse_request_body() + if err: + return R.failed(msg=err) + example.update_example(json_data) + return R.ok(msg="更新成功") + + +# ============================================================ +# 案例演示删除 +# ============================================================ + + +@example_blue.route("/delete/", methods=["DELETE"]) +@check_permission("sys:example:delete") +@check_demo +@operation_log("案例演示", "删除") +def delete(id): + """ + 删除案例演示 + + Args: + id: 案例演示ID(支持逗号分隔的多个ID) + + Returns: + JSON: 操作结果 + """ + count = example.delete_examples(id) + return R.ok(msg=f"本次共删除{count}条数据") + + +# ============================================================ +# 案例演示批量删除 +# ============================================================ + + +@example_blue.route("/batchDelete", methods=["DELETE"]) +@check_permission("sys:example:batchDelete") +@check_demo +@operation_log("案例演示", "批量删除") +def batch_delete(): + """ + 批量删除案例演示 + + Request Body: + [1, 2, 3] # 案例演示ID数组 + + Returns: + JSON: 操作结果 + """ + id_str, err = parse_batch_ids() + if err: + return err + count = example.delete_examples(id_str) + return R.ok(msg=f"本次共删除{count}条数据") + +# ============================================================ +# 案例演示状态设置 +# ============================================================ + + +@example_blue.route("/status", methods=["PUT"]) +@check_permission("sys:example:status") +@check_demo +@operation_log("案例演示", "状态变更") +def status(): + """ + 设置案例演示状态 + + 功能说明: + 启用或禁用案例演示。 + + Returns: + JSON: 操作结果 + """ + json_data, err = parse_request_body() + if err: + return R.failed(msg=err) + example.update_example_status(json_data) + return R.ok(msg="更新成功") diff --git a/src/modules/example/schema.py b/src/modules/example/schema.py new file mode 100644 index 0000000..1405a45 --- /dev/null +++ b/src/modules/example/schema.py @@ -0,0 +1,143 @@ +""" +案例演示表单验证模块 +============================================================= + +提供案例演示相关的表单验证功能。 +""" + +from wtforms import IntegerField, StringField +from wtforms.validators import DataRequired, Length, NumberRange, Optional + +from src.common.forms.base_form import BaseForm + + +# ============================================================ +# 案例演示表单验证(添加和更新共用) +# ============================================================ + +class ExampleForm(BaseForm): + """ + 案例演示表单验证类 + + 用于添加和更新案例演示时的数据验证。 + 添加时 id 为 None,更新时 id 必须有值。 + + Attributes: + id: 案例演示ID(更新时必填,添加时可选) + name: 案例名称(StringField) + avatar: 案例图片(StringField) + type: 案例类型:1-类型1 2-类型2 3-类型3 4-类型4(IntegerField) + status: 案例状态:1-在用 2-停用(IntegerField) + sort: 案例排序(IntegerField) + """ + + # ============================================================ + # 案例演示ID + # ============================================================ + id = IntegerField( + label='案例演示ID', + validators=[ + Optional(), # 允许为空(添加时) + NumberRange(min=1, message='案例演示ID必须大于0') + ], + description='案例演示唯一标识,更新时必填,添加时自动生成' + ) + + # ============================================================ + # 案例名称 + # ============================================================ + name = StringField( + label='案例名称', + validators=[ + DataRequired(message='案例名称不能为空'), + Length(max=255, message='案例名称长度不得超过255个字符') + ], + description='案例名称' + ) + + # ============================================================ + # 案例图片 + # ============================================================ + avatar = StringField( + label='案例图片', + validators=[ + DataRequired(message='案例图片不能为空'), + Length(max=255, message='案例图片长度不得超过255个字符') + ], + description='案例图片' + ) + + # ============================================================ + # 案例类型:1-类型1 2-类型2 3-类型3 4-类型4 + # ============================================================ + type = IntegerField( + label='案例类型:1-类型1 2-类型2 3-类型3 4-类型4', + validators=[ + Optional(), + NumberRange(min=1, max=4, message='案例类型:1-类型1 2-类型2 3-类型3 4-类型4值在1~4之间') + ], + description='案例类型:1-类型1 2-类型2 3-类型3 4-类型4' + ) + + # ============================================================ + # 案例状态:1-在用 2-停用 + # ============================================================ + status = IntegerField( + label='案例状态:1-在用 2-停用', + validators=[ + Optional(), + NumberRange(min=1, max=2, message='案例状态:1-在用 2-停用值在1~2之间') + ], + description='案例状态:1-在用 2-停用' + ) + + # ============================================================ + # 案例排序 + # ============================================================ + sort = IntegerField( + label='案例排序', + validators=[ + Optional() + ], + description='案例排序' + ) + + +# ============================================================ +# 状态设置表单 +# ============================================================ + +class ExampleStatusForm(BaseForm): + """ + 案例演示状态设置表单验证类 + + 用于单独修改案例演示状态时的数据验证。 + + Attributes: + id: 案例演示ID(必填,必须大于0) + status: 案例演示状态(必填,0-禁用 1-正常) + """ + + # ============================================================ + # 案例演示ID + # ============================================================ + id = IntegerField( + label='案例演示ID', + validators=[ + DataRequired(message='案例演示ID不能为空'), + NumberRange(min=1, message='案例演示ID必须大于0') + ], + description='要修改状态的案例演示ID' + ) + + # ============================================================ + # 案例演示状态 + # ============================================================ + status = IntegerField( + label='案例演示状态', + validators=[ + DataRequired(message='案例演示状态不能为空'), + NumberRange(min=0, max=1, message='案例演示状态值在0~1之间') + ], + description='目标状态:0-禁用,1-正常' + ) diff --git a/src/modules/example/service.py b/src/modules/example/service.py new file mode 100644 index 0000000..3dd4d75 --- /dev/null +++ b/src/modules/example/service.py @@ -0,0 +1,115 @@ +""" +案例演示服务模块 +============================================================= + +业务逻辑层,继承 BaseService 获得标准 CRUD 能力。 +""" + +from src.common.base_service import BaseService +from src.common.utils.rich_text import get_file_url, save_file +from src.common.utils.model_utils import model_to_dict +from src.modules.example.repository import ExampleRepository +from src.modules.example.schema import ExampleForm, ExampleStatusForm + + +class ExampleService(BaseService): + """ + 案例演示服务类 + + 继承 BaseService 获得标准 CRUD 能力。 + """ + + repository = ExampleRepository + form = ExampleForm + status_form = ExampleStatusForm + model_name = "案例演示" + + @classmethod + def before_add(cls, form): + """ + 新增前钩子 + + 处理文件字段:截取域名前缀,迁移临时文件到正式目录。 + + Args: + form: 已校验的表单对象 + """ + _handle_file_fields(form) + + @classmethod + def before_update(cls, form, obj): + """ + 更新前钩子 + + 处理文件字段:截取域名前缀,迁移临时文件到正式目录。 + + Args: + form: 已校验的表单对象 + obj: 已存在的模型实例 + """ + _handle_file_fields(form) + + @classmethod + def _enrich_page_records(cls, records): + """ + 分页富化钩子 + + 为文件字段拼接 URL 前缀。 + + Args: + records: 字典列表 + + Returns: + list: 富化后的字典列表 + """ + for data in records: + data["avatar"] = get_file_url(data.get("avatar")) + return records + + @classmethod + def _detail_dict(cls, obj): + """ + 详情序列化钩子 + + 为文件字段拼接 URL 前缀。 + + Args: + obj: 模型实例 + + Returns: + dict: 序列化后的字典 + """ + data = model_to_dict(obj) + data["avatar"] = get_file_url(data.get("avatar")) + return data + + +def _handle_file_fields(form): + """ + 文件字段处理 + + 截取域名前缀,迁移临时文件到正式目录。 + save_file 处理逻辑: + - 含 /temp 的 URL:迁移临时文件到正式目录,返回相对路径 + - 已是正式路径(含 FLASK_FILE_URL 前缀):去掉前缀返回相对路径 + - 相对路径(无协议头):原样保留 + + Args: + form: 表单对象 + """ + avatar = form.data.get("avatar") + if avatar: + form.avatar.data = save_file(avatar, "example") + +# ============================================================ +# 模块级代理函数:保持 router 调用兼容 +# ============================================================ + +get_example_page = ExampleService.get_page +get_example_list = ExampleService.get_list +get_example_detail = ExampleService.get_detail +get_info = ExampleService.get_info +add_example = ExampleService.add +update_example = ExampleService.update +delete_examples = ExampleService.remove +update_example_status = ExampleService.update_status diff --git a/src/modules/file_template/models.py b/src/modules/file_template/models.py index 3b7339e..5fd9d5c 100644 --- a/src/modules/file_template/models.py +++ b/src/modules/file_template/models.py @@ -8,6 +8,7 @@ from src.common.base_db import BaseDB from src.common.base_model import BaseModel from config import DB_PREFIX +from sqlalchemy import text from src.core.database import db @@ -38,6 +39,9 @@ class FileTemplate(BaseModel, BaseDB): # 设置表名 __tablename__ = DB_PREFIX + "file_template" + # 表注释 + __table_comment__ = "文件模板表" + # ============================================================ # 字段定义 # ============================================================ @@ -46,13 +50,15 @@ class FileTemplate(BaseModel, BaseDB): name = db.Column( db.String(100), nullable=True, + index=True, comment="模板名称" ) # 模板编码 code = db.Column( db.String(50), - nullable=True, + nullable=False, + index=True, comment="模板编码" ) @@ -82,6 +88,7 @@ class FileTemplate(BaseModel, BaseDB): db.Integer, nullable=False, default=0, + server_default=text('0'), comment="文件模板顺序" ) diff --git a/src/modules/file_template/repository.py b/src/modules/file_template/repository.py index 75d9a5c..cd4e9cb 100644 --- a/src/modules/file_template/repository.py +++ b/src/modules/file_template/repository.py @@ -6,10 +6,38 @@ FileTemplate 仓储模块 """ from src.common.base_repository import BaseRepository +from src.common.constants.common import PAGE_SIZE from src.modules.file_template.models import FileTemplate class FileTemplateRepository(BaseRepository): - """FileTemplate 仓储""" + """文件模板数据仓储,提供文件模板相关的数据库访问操作""" model = FileTemplate + + @classmethod + def page(cls, params: dict): + """ + 文件模板分页查询 + + 支持按模板名称和模板编码模糊搜索,按 sort 字段升序排列。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + - name: 模板名称(模糊匹配) + - code: 模板编码(模糊匹配) + + Returns: + tuple: (items, total, pages) + - items: 当前页数据列表 + - total: 总记录数 + - pages: 总页数 + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + like_filters={'name': params.get('name'), 'code': params.get('code')}, + order_by='sort', + ) diff --git a/src/modules/file_template/router.py b/src/modules/file_template/router.py index dfc18a7..7ca0d92 100644 --- a/src/modules/file_template/router.py +++ b/src/modules/file_template/router.py @@ -5,6 +5,7 @@ 提供文件模板(FileTemplate)相关的API接口,包括: - 文件模板分页查询 - 文件模板详情查询 +- 文件模板按编码查询 - 文件模板添加、更新、删除 - 文件模板列表获取 - 模板文件上传 @@ -17,7 +18,7 @@ from flask import Blueprint, request -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.core.permission import check_permission from src.modules.file_template import service as file_template from src.common.utils.request import parse_batch_ids, parse_request_body @@ -34,6 +35,7 @@ file_template_blue = Blueprint("file_template", __name__, url_prefix="/file/temp @file_template_blue.route("/page", methods=["GET"]) @check_permission("sys:filetemplate:page") +@operation_log("文件模板", "查询") def page(): """ 查询文件模板分页数据 @@ -54,6 +56,7 @@ def page(): @file_template_blue.route("/detail/", methods=["GET"]) @check_permission("sys:filetemplate:detail") +@operation_log("文件模板", "详情") def detail(id): """ 查询文件模板详情 @@ -70,6 +73,29 @@ def detail(id): return R.failed("模板不存在") +# ============================================================ +# 文件模板按编码查询 +# ============================================================ + + +@file_template_blue.route("/getTemplateByCode/", methods=["GET"]) +@operation_log("文件模板", "按编码查询") +def get_by_code(code): + """ + 根据模板编码获取文件模板数据 + + Args: + code: 模板编码 + + Returns: + JSON: 文件模板详细信息(filePath 已转 URL) + """ + data = file_template.get_file_template_by_code(code) + if data: + return R.ok(data=data) + return R.failed("模板不存在") + + # ============================================================ # 文件模板添加 # ============================================================ @@ -78,6 +104,7 @@ def detail(id): @file_template_blue.route("/add", methods=["POST"]) @check_permission("sys:filetemplate:add") @check_demo +@operation_log("文件模板", "新增") def add(): """ 添加文件模板 @@ -103,6 +130,7 @@ def add(): @file_template_blue.route("/update", methods=["PUT"]) @check_permission("sys:filetemplate:update") @check_demo +@operation_log("文件模板", "修改") def update(): """ 更新文件模板 @@ -128,6 +156,7 @@ def update(): @file_template_blue.route("/delete/", methods=["DELETE"]) @check_permission("sys:filetemplate:delete") @check_demo +@operation_log("文件模板", "删除") def delete(id): """ 删除文件模板 @@ -148,8 +177,9 @@ def delete(id): @file_template_blue.route("/batchDelete", methods=["DELETE"]) -@check_permission("sys:filetemplate:batch_delete") +@check_permission("sys:filetemplate:batchDelete") @check_demo +@operation_log("文件模板", "批量删除") def batch_delete(): """ 批量删除文件模板 @@ -166,4 +196,5 @@ def batch_delete(): id_str, err = parse_batch_ids() if err: return err - return file_template.delete_file_templates(id_str) + count = file_template.delete_file_templates(id_str) + return R.ok(msg=f"本次共删除{count}条数据") diff --git a/src/modules/file_template/schema.py b/src/modules/file_template/schema.py index 257859b..780dd3a 100644 --- a/src/modules/file_template/schema.py +++ b/src/modules/file_template/schema.py @@ -10,7 +10,7 @@ from wtforms import ValidationError, StringField, IntegerField from wtforms.validators import DataRequired, Length, NumberRange, Optional from src.common.forms.base_form import BaseForm -from src.modules.file_template.models import FileTemplate +from src.modules.file_template.repository import FileTemplateRepository # ============================================================ @@ -63,7 +63,7 @@ class FileTemplateForm(BaseForm): code = StringField( label='模板编码', validators=[ - Optional(), # 允许为空 + DataRequired(message='模板编码不能为空'), Length(max=50, message='模板编码长度不得超过50个字符') ], description='模板唯一标识编码,用于程序调用' @@ -111,7 +111,7 @@ class FileTemplateForm(BaseForm): sort = IntegerField( label='模板排序', validators=[ - DataRequired(message='模板排序不能为空'), + Optional(), # 允许 0 NumberRange(min=0, max=99999, message='模板排序值在0~99999之间') ], description='排序值,数值越小越靠前' @@ -141,16 +141,6 @@ class FileTemplateForm(BaseForm): # 获取当前记录的ID(更新时存在,添加时为None) current_id = self.id.data if self.id.data else None - # 构建查询:查找相同编码的记录 - query = FileTemplate.query.filter( - FileTemplate.code == field.data, - FileTemplate.is_delete == 0 - ) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(FileTemplate.id != current_id) - - # 如果存在相同编码的记录,抛出验证错误 - if query.first(): + # 唯一性校验(软删过滤 + 更新时排除自身) + if FileTemplateRepository.exists_by({'code': field.data}, exclude_id=current_id): raise ValidationError("模板编码已存在") diff --git a/src/modules/file_template/service.py b/src/modules/file_template/service.py index 2d2b2fa..5f60e71 100644 --- a/src/modules/file_template/service.py +++ b/src/modules/file_template/service.py @@ -4,293 +4,127 @@ 该模块实现了文件模板管理相关的所有业务逻辑。 包括文件模板的分页查询、详情查询、添加、更新、删除、文件上传下载等功能。 - -命名规范: -- 函数参数使用驼峰命名(如 templateId, pageNo) -- 表单字段使用下划线命名(如 name, code, sort)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters, _build_page_data) - -功能模块: -1. 分页查询:支持按模板名称模糊搜索、编码模糊搜索,按sort字段排序 -2. 详情查询:根据ID获取单个文件模板的详细信息 -3. 数据维护:文件模板的添加、更新、删除(支持批量) -4. 文件上传:上传模板文件并保存文件信息 -5. 文件下载:下载指定模板的文件 -6. 数据列表:获取所有文件模板列表(用于下拉选择等场景) """ import logging -from datetime import datetime, timezone - -from src.common.constants.common import PAGE_SIZE +from src.common.base_service import BaseService from src.modules.file_template.schema import FileTemplateForm -from src.modules.file_template.models import FileTemplate from src.modules.file_template.repository import FileTemplateRepository -from src.core.exceptions import BizError -from src.common.utils import regular -from src.common.utils.case_converter import convert_camel_to_snake -from src.common.utils.model_utils import models_to_list, model_to_dict -from src.common.utils.request import parse_id_list, like_contains +from src.common.utils.model_utils import model_to_dict from src.common.utils.rich_text import save_file, get_file_url -from src.core.security import get_username # 获取日志记录器 logger = logging.getLogger(__name__) -# ============================================================ -# 文件模板分页查询 -# ============================================================ - - -def get_file_template_page(params: dict) -> dict: +class FileTemplateService(BaseService): """ - 查询文件模板分页数据 + 文件模板服务类 - Args: - request: Flask request对象,包含查询参数 - - pageNo: 当前页码(可选,默认为1) - - pageSize: 每页记录数(可选,默认为PAGE_SIZE) - - name: 模板名称(可选,模糊查询) - - code: 模板编码(可选,模糊查询) - - Returns: - R: 包含分页数据的JSON响应,格式为: - { - "records": 模板列表, - "total": 总记录数, - "size": 每页大小, - "current": 当前页码, - "pages": 总页数 - } + 继承 BaseService 获得标准 CRUD 能力,并扩展模板文件保存和URL转换。 """ - # 获取分页参数 - page_no = int(params.get("pageNo", 1)) - page_size = int(params.get("pageSize", PAGE_SIZE)) - # 应用筛选条件 - query = _apply_filters(params) + repository = FileTemplateRepository + form = FileTemplateForm + model_name = "模板" - # 记录总数 - total = query.count() - # 计算总页数 - pages = (total + page_size - 1) // page_size if total > 0 else 0 + @classmethod + def before_add(cls, form): + """ + 新增前钩子 - # 分页查询 - template_list = query.limit(page_size).offset((page_no - 1) * page_size).all() - # 构建返回数据 - page_data = _build_page_data(template_list, total, page_size, page_no, pages) - return page_data + 保存模板文件并回写表单数据。 + Args: + form: 已校验的表单对象 + """ + _handle_template_file(form) -def _apply_filters(params: dict): - """ - 应用查询筛选条件 + @classmethod + def before_update(cls, form, obj): + """ + 更新前钩子 - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 支持模板名称模糊查询 - - 支持模板编码模糊查询 - - 默认按排序值升序排列 + 保存模板文件并回写表单数据。 - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = FileTemplate.query.filter(FileTemplate.is_delete == 0) + Args: + form: 已校验的表单对象 + obj: 已存在的模型实例 + """ + _handle_template_file(form) - # 模板名称模糊筛选 - name = params.get("name") - if name: - query = query.filter(FileTemplate.name.like(like_contains(name), escape="\\")) + @classmethod + def _detail_dict(cls, obj): + """ + 详情序列化钩子 - # 模板编码模糊筛选 - code = params.get("code") - if code: - query = query.filter(FileTemplate.code.like(like_contains(code), escape="\\")) + 将文件路径转换为可访问的URL。 - # 按排序值升序排列 - return query.order_by(FileTemplate.sort.asc()) + Args: + obj: 文件模板模型实例 - -def _build_page_data(template_list, total, page_size, page_no, pages): - """ - 构建分页返回数据 - - Args: - template_list: 文件模板对象列表 - total: 总记录数 - page_size: 每页大小 - page_no: 当前页码 - pages: 总页数 - - Returns: - dict: 分页数据,包含 records、total、size、current、pages - """ - # 将模型对象列表转换为字典列表 - records = models_to_list(template_list) - - return {"records": records, "total": total, "size": page_size, "current": page_no, "pages": pages} - - -# ============================================================ -# 文件模板详情查询 -# ============================================================ - - -def get_file_template_detail(template_id): - """ - 根据ID查询文件模板详情(返回字典格式) - - 此方法调用 get_info 获取模型对象,然后转换为字典格式返回。 - 适用于前端接口调用。 - - Args: - template_id: 文件模板ID - - Returns: - dict/None: 文件模板详情字典,不存在时返回 None - """ - try: - # 复用 get_info 方法查询模板 - template = get_info(template_id) - if not template: - return None - # 转换为字典格式 - data = model_to_dict(template) - # 头像URL + Returns: + dict: 序列化后的字典 + """ + data = model_to_dict(obj) data["filePath"] = get_file_url(data.get("filePath")) - # 返回结果 return data - except Exception as e: - logger.error(f"查询文件模板详情异常,ID: {template_id}, 错误: {str(e)}") - return None + @classmethod + def _enrich_page_records(cls, records): + """ + 分页富化钩子 + + 将列表记录的 filePath 转换为可访问的URL。 + + Args: + records: 记录字典列表 + + Returns: + list: 富化后的记录字典列表 + """ + for record in records: + record["filePath"] = get_file_url(record.get("filePath")) + return records + + @classmethod + def get_by_code(cls, code): + """ + 按模板编码查询文件模板 + + 返回与详情一致的序列化结构(filePath 已转 URL)。 + + Args: + code: 模板编码 + + Returns: + dict: 文件模板字典,编码不存在时返回 None + """ + obj = cls.repository.first_by({"code": code}) + return cls._detail_dict(obj) if obj else None -# ============================================================ -# 文件模板信息查询(根据ID)- 核心查询方法 -# ============================================================ - - -def get_info(template_id: int): +def _handle_template_file(form): """ - 根据ID查询文件模板信息(返回模型对象) - - 这是一个通用的模板查询方法,返回模板模型对象而非字典。 + 模板文件保存并回写表单数据 Args: - template_id: 文件模板ID - - Returns: - FileTemplate/None: 文件模板模型对象,不存在或已删除时返回 None + form: 表单对象 """ - return FileTemplateRepository.get_by_id(template_id) - - -def add_file_template(data: dict) -> None: - """ - 添加文件模板 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证(编码唯一性、字段格式等) - 3. 创建文件模板对象并保存到数据库 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = FileTemplateForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 文件处理 file_path = form.data.get("filePath") if file_path: form.filePath.data = save_file(file_path, "file_template") - # 创建文件模板 - template = FileTemplate(**form.data) - template.create_user = get_username() - template.save() - # ============================================================ -# 文件模板更新 +# 模块级代理函数:保持 router 调用兼容 # ============================================================ - -def update_file_template(data: dict) -> None: - """ - 更新文件模板 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 验证模板是否存在(复用 get_info) - 4. 更新模板信息 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = FileTemplateForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 获取模板ID - template_id = form.data.get("id") - if not template_id or int(template_id) <= 0: - raise BizError("模板ID无效") - template_id = int(template_id) - - # 复用 get_info 方法验证模板是否存在 - template = get_info(template_id) - if not template: - raise BizError("模板不存在") - - # 文件处理 - file_path = form.data.get("filePath") - if file_path: - form.filePath.data = save_file(file_path, "file_template") - - # 更新模板信息(移除ID,避免更新主键) - form_data = convert_camel_to_snake(form.data.copy()) - form_data.pop("id", None) - - form_data["update_time"] = datetime.now(tz=timezone.utc) - form_data["update_user"] = get_username() - - result = FileTemplate.query.filter_by(id=template_id).update(form_data) - - if not result: - raise BizError("更新失败") - - -# ============================================================ -# 文件模板删除 -# ============================================================ - - -def delete_file_templates(template_ids: str) -> int: - """ - 删除文件模板(批量删除) - - 删除 - 支持逗号分隔的多个ID批量删除。 - - Args: - template_ids: 模板ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - template_ids_list = parse_id_list(template_ids) - if not template_ids_list: - raise BizError("无效的记录ID") - return FileTemplate.batch_delete(template_ids_list) +get_file_template_page = FileTemplateService.get_page +get_file_template_detail = FileTemplateService.get_detail +get_file_template_by_code = FileTemplateService.get_by_code +get_info = FileTemplateService.get_info +add_file_template = FileTemplateService.add +update_file_template = FileTemplateService.update +delete_file_templates = FileTemplateService.remove diff --git a/src/modules/generator/__init__.py b/src/modules/generator/__init__.py new file mode 100644 index 0000000..1aaa2f3 --- /dev/null +++ b/src/modules/generator/__init__.py @@ -0,0 +1 @@ +"""代码生成器模块。""" diff --git a/src/modules/generator/code_generator.py b/src/modules/generator/code_generator.py new file mode 100644 index 0000000..d94894a --- /dev/null +++ b/src/modules/generator/code_generator.py @@ -0,0 +1,974 @@ +""" +代码生成器类 +============================================================= + +根据配置信息生成完整的后端三层模块代码与前端 Vue 页面代码。 + +后端模块(src/modules/{app_name}/): + models.py / schema.py / repository.py / service.py / router.py / __init__.py + +前端页面(ui/src/views/tool/{module_name}/ 与 ui/src/api/tool/): + 普通分页列表(ui 模板)或树状列表(ui2 模板,含 parent_id/pid 字段时自动切换) + +生成完成后自动注册到项目: + - src/routes/v1.py 的 BLUEPRINTS(追加新蓝图) + - src/models_registry.py(追加模型导入) + - flask_menu 表(自动创建模块菜单 + 权限节点) +""" + +import logging +import os +import re + +from jinja2 import Environment, FileSystemLoader + +from src.core.exceptions import BizError + +logger = logging.getLogger(__name__) + +# 代码生成器自动创建的权限节点(与 router.py.tpl 中 @check_permission 保持一致) +# 格式:(权限动作 camelCase, 节点名称后缀, 节点排序) +# 排序从 1 开始,每个节点间隔 5 +_PERMISSION_NODES = [ + ('page', '分页查询', 1), + ('list', '列表查询', 5), + ('detail', '详情查询', 10), + ('add', '新增', 15), + ('update', '编辑', 20), + ('delete', '删除', 25), + ('batchDelete', '批量删除', 30), + ('status', '状态管理', 35), +] + +# 本模块目录:src/modules/generator +GENERATOR_DIR = os.path.dirname(os.path.abspath(__file__)) +# 业务模块目录:src/modules +MODULES_DIR = os.path.dirname(GENERATOR_DIR) +# 源码目录:src +SRC_DIR = os.path.dirname(MODULES_DIR) +# 项目根目录 +PROJECT_ROOT = os.path.dirname(SRC_DIR) + +# 模板目录 +TEMPLATE_DIR = os.path.join(GENERATOR_DIR, 'templates') + +# 后端代码模板清单(生成文件名 = 模板名去掉 .tpl) +BACKEND_TEMPLATES = [ + '__init__.py.tpl', + 'models.py.tpl', + 'schema.py.tpl', + 'repository.py.tpl', + 'service.py.tpl', + 'router.py.tpl', +] + +# 配置字段类型(config.json 参考配置使用的命名)-> SQLAlchemy 字段类型映射 +_CONFIG_TYPE_TO_DB = { + 'CharField': 'String', + 'TextField': 'Text', + 'IntegerField': 'Integer', + 'BigIntegerField': 'BigInteger', + 'DateTimeField': 'DateTime', + 'DateField': 'Date', + 'TimeField': 'Time', + 'FloatField': 'Float', + 'DecimalField': 'Numeric', + 'BooleanField': 'Boolean', + 'JSONField': 'JSON', +} + +# SQLAlchemy 字段类型 -> 配置字段类型名映射(前端模板按该命名判断字段类型) +_DB_TO_CONFIG_TYPE = { + 'String': 'CharField', + 'Text': 'TextField', + 'Integer': 'IntegerField', + 'BigInteger': 'BigIntegerField', + 'DateTime': 'DateTimeField', + 'Date': 'DateField', + 'Time': 'TimeField', + 'Float': 'FloatField', + 'Numeric': 'DecimalField', + 'Boolean': 'BooleanField', + 'JSON': 'JSONField', +} + +# SQLAlchemy 字段类型 -> WTForms 字段类映射(与 parser.py 保持一致) +_DB_TYPE_TO_FORM = { + 'String': 'StringField', + 'Text': 'TextAreaField', + 'Integer': 'IntegerField', + 'BigInteger': 'IntegerField', + 'Float': 'FloatField', + 'Numeric': 'FloatField', + 'Boolean': 'BooleanField', + 'DateTime': 'StringField', + 'Date': 'StringField', + 'Time': 'StringField', + 'JSON': 'StringField', +} + + +def _py_string(value): + """ + 将字符串转义为可安全嵌入 Python 单引号字符串字面量 + + Args: + value: 原始字符串 + + Returns: + str: 转义后的字符串(不含引号) + """ + if not value: + return '' + return str(value).replace('\\', '\\\\').replace("'", "\\'").replace('"', '\\"') + + +def _build_column_block(field): + """ + 构建 models.py 中单个字段的列定义代码块 + + Args: + field: 字段配置字典 + + Returns: + str: 缩进完整的列定义代码块 + """ + db_type_expr = f"db.{field['db_type']}" + if field.get('db_args'): + db_type_expr += f"({field['db_args']})" + + lines = [ + f" # {field['comment']}", + f" {field['name']} = db.Column(", + f" {db_type_expr},", + f" nullable={'True' if field.get('nullable') else 'False'},", + ] + if field.get('default_repr'): + lines.append(f" default={field['default_repr']},") + # 同步生成 server_default(数据库级默认值) + server_default_expr = _build_server_default(field) + if server_default_expr: + lines.append(f" server_default={server_default_expr},") + if field.get('is_unique'): + lines.append(" unique=True,") + if field.get('db_index'): + lines.append(" index=True,") + lines.append(f' comment="{field.get("py_comment", "")}"') + lines.append(" )") + # 首尾各带一个换行:保证块之间、块与前后段落之间留出一个空行 + return "\n" + "\n".join(lines) + "\n" + + +def _build_server_default(field): + """ + 根据字段类型和默认值生成 server_default 表达式 + + Args: + field: 字段配置字典 + + Returns: + str/None: server_default 表达式字符串,无默认值时返回 None + """ + default = field.get('default') + if default is None: + return None + + db_type = field.get('db_type', 'String') + + # Boolean:Python True/False → SQL '1'/'0' + if db_type == 'Boolean': + return "text('1')" if default else "text('0')" + + # DateTime callable(now)→ CURRENT_TIMESTAMP + if db_type == 'DateTime' and callable(default): + return "text('CURRENT_TIMESTAMP')" + + # 整数/浮点:直接转字符串 + if db_type in ('Integer', 'BigInteger', 'Float', 'Numeric'): + return f"text('{default}')" + + # 字符串:空字符串 → '',非空 → '值' + if db_type == 'String': + if default == '': + return "text(\"''\")" + escaped = str(default).replace("'", "\\'") + return f"text(\"'{escaped}'\")" + + return None + + +def _build_form_field_block(field): + """ + 构建 schema.py 中单个字段的 WTForms 字段定义代码块 + + Args: + field: 字段配置字典 + + Returns: + str: 缩进完整的表单字段定义代码块 + """ + comment = field.get('py_comment', '') + + validators = [] + if field.get('required'): + validators.append(f"DataRequired(message='{comment}不能为空')") + else: + validators.append("Optional()") + if field.get('max_length') and field['form_type'] in ('StringField', 'TextAreaField'): + validators.append( + f"Length(max={field['max_length']}, message='{comment}长度不得超过{field['max_length']}个字符')" + ) + if field['form_type'] == 'IntegerField' and field.get('min_value') is not None and field.get('max_value') is not None: + validators.append( + f"NumberRange(min={field['min_value']}, max={field['max_value']}, " + f"message='{comment}值在{field['min_value']}~{field['max_value']}之间')" + ) + + lines = [ + " # ============================================================", + f" # {field['comment']}", + " # ============================================================", + f" {field['camel_name']} = {field['form_type']}(", + f" label='{comment}',", + " validators=[", + ] + for i, validator in enumerate(validators): + suffix = ',' if i < len(validators) - 1 else '' + lines.append(f" {validator}{suffix}") + lines.extend([ + " ],", + f" description='{comment}'", + " )", + ]) + # 首尾各带一个换行:保证字段之间、字段与前后段落之间留出一个空行 + return "\n" + "\n".join(lines) + "\n" + + +def _build_filters_literal(entries): + """ + 构建 repository.py 中 filters/like_filters 的字典字面量 + + Args: + entries: (snake字段名, camelCase参数名) 列表 + + Returns: + str: 字典字面量(空时为 {}) + """ + if not entries: + return '{}' + lines = ['{'] + for name, camel in entries: + lines.append(f" '{name}': params.get('{camel}'),") + lines.append(' }') + return '\n'.join(lines) + + +def normalize_config(config): + """ + 标准化生成配置(兼容 config.json 参考配置) + ============================================================= + + parser.py 产出的字段配置已带 db_type / form_type 等专用键,直接可用。 + 但 config.json 参考配置的字段只带配置字段类型名(CharField / IntegerField ...), + 此处将其统一转换为 SQLAlchemy 类型: + + type(CharField) -> db_type(String) + db_args(长度) + form_type(StringField) + + 幂等:若字段已含 db_type 键,视为已标准化配置,原样返回。 + + Args: + config: 生成配置字典(parser 产出或 config.json 加载) + + Returns: + dict: 标准化后的配置 + """ + fields = config.get('fields', []) + # 无字段或已含 db_type 的字段视为已标准化配置,直接返回(幂等) + if not fields or any('db_type' in f for f in fields): + return config + + normalized = [] + for field in fields: + # 1) 将配置字段类型映射为 SQLAlchemy 字段类型与参数 + config_type = field.get('type', 'CharField') + db_type = _CONFIG_TYPE_TO_DB.get(config_type, 'String') + max_length = field.get('max_length') + db_args = str(max_length) if db_type == 'String' and max_length else None + + # 2) 派生 WTForms 字段类 + form_type = _DB_TYPE_TO_FORM.get(db_type, 'StringField') + + # 3) 数值字段的字符串默认值转为数值类型(MySQL information_schema 返回字符串) + default = field.get('default') + if default is not None and isinstance(default, str) and db_type in ('Integer', 'BigInteger'): + try: + default = int(default) + except ValueError: + pass + + # 4) 派生命名与状态/排序标识 + name = field['name'] + normalized.append({ + **field, + # 覆盖原字符串默认值(已按数值字段转 int/float),与 parser.py 产出保持一致 + 'default': default, + 'camel_name': _to_camel_case(name), + 'py_comment': field.get('comment') or name, + 'db_type': db_type, + 'db_args': db_args, + 'form_type': form_type, + 'default_repr': repr(default) if default is not None else None, + 'is_status': name in ('status', 'state', 'is_active'), + 'is_sort': name in ('sort', 'order', 'seq', 'sequence', 'priority'), + }) + + return {**config, 'fields': normalized} + + +def _to_camel_case(snake_str): + """下划线命名 -> 驼峰命名(模块级辅助,供 normalize_config 使用)""" + if not snake_str: + return snake_str + components = str(snake_str).split('_') + return components[0] + ''.join(x.title() for x in components[1:]) + + +def _choice_tag_type(value): + """ + 根据选项值返回标签颜色类型 + + 前端表格用 ElTag 渲染选项值,值 1 用 success、2 用 danger、3 用 warning,其余用 info。 + + Args: + value: 选项值 + + Returns: + str: Element Plus 标签颜色类型 + """ + return {1: 'success', 2: 'danger', 3: 'warning'}.get(value, 'info') + + +class CodeGenerator: + """ + 代码生成器类 + ============================================================= + + 根据配置信息生成完整的业务模块代码(后端三层 + 前端 Vue 页面)。 + 支持自动注册到路由与模型注册中心。 + + 使用示例: + config = {...} # 由 parser.generate_config_from_table 生成 + generator = CodeGenerator(config) + generator.generate(auto=True) # 生成所有代码并自动注册 + """ + + def __init__(self, config): + """ + 初始化代码生成器 + + Args: + config: 生成配置字典(由 parser.generate_config_from_table 生成,或 config.json 加载) + """ + # 标准化配置:兼容 config.json 参考配置(幂等,parser 产出的配置原样返回) + self.config = normalize_config(config) + # 输出目录:src/modules/{app_name} + self.output_dir = os.path.join(MODULES_DIR, self.config['app_name']) + + # 初始化 Jinja2 模板环境 + self.env = Environment( + loader=FileSystemLoader(TEMPLATE_DIR), + trim_blocks=True, + lstrip_blocks=True, + ) + + def generate(self, auto=False): + """ + 执行代码生成主方法 + ============================================================= + + 生成完整的后端三层模块代码与前端 Vue 页面代码并自动注册。 + + 执行流程: + - 校验输出目录不存在(避免覆盖已有模块) + - 创建输出目录 + - 准备模板上下文数据 + - 生成后端代码文件 + - 生成前端 Vue 页面代码(普通列表 / 树状列表按字段自动切换) + - 自动注册到 routes/v1.py 与 models_registry.py + - 自动创建菜单与权限节点 + + Args: + auto: 是否自动模式(跳过交互确认,当前无交互差异) + """ + app_name = self.config['app_name'] + + if os.path.exists(self.output_dir): + raise BizError(f"模块 {app_name} 已存在,请先删除 {self.output_dir}") + + os.makedirs(self.output_dir, exist_ok=True) + logger.info("创建输出目录: %s", self.output_dir) + + # 准备模板上下文 + context = self._prepare_context() + + # 生成后端代码文件 + for template_name in BACKEND_TEMPLATES: + output_path = os.path.join(self.output_dir, template_name.replace('.tpl', '')) + self._render_template(template_name, output_path, context) + + # 生成前端 Vue 页面代码 + self._generate_vue_files(context) + + # 自动注册到项目 + self._register_module() + + logger.info("代码生成完成!输出目录: %s", self.output_dir) + + # ============================================================ + # 模板上下文 + # ============================================================ + + def _prepare_context(self): + """ + 准备模板上下文数据 + + Returns: + dict: 模板渲染上下文 + """ + config = self.config + fields = config.get('fields', []) + + # 排序字段:存在排序字段则用它默认排序,否则用 id + sort_field = 'id' + for f in fields: + if f.get('is_sort'): + sort_field = f['name'] + break + + # 模型字段:附加预渲染的列定义代码块 + model_fields = [dict(f, column_block=_build_column_block(f)) for f in fields] + + # 表单字段:所有业务字段均展示在表单中,附加预渲染的字段定义代码块 + form_fields = [dict(f, field_block=_build_form_field_block(f)) for f in fields if f.get('in_form', True)] + + # 筛选字段:精确匹配进 filters,模糊匹配进 like_filters + filter_fields = [f for f in fields if f.get('filterable') and f.get('filter_type') == '精确匹配'] + like_fields = [f for f in fields if f.get('filterable') and f.get('filter_type') == '模糊查询'] + + filters_py = _build_filters_literal([(f['name'], f['camel_name']) for f in filter_fields]) + like_filters_py = _build_filters_literal([(f['name'], f['camel_name']) for f in like_fields]) + + # WTForms 字段类导入集合(id 恒用 IntegerField) + form_imports = sorted({f['form_type'] for f in form_fields} | {'IntegerField'}) + + # WTForms 校验器导入集合(id 恒用 Optional + NumberRange) + validator_imports = {'DataRequired', 'Optional', 'NumberRange'} + if any(f.get('max_length') and f['form_type'] in ('StringField', 'TextAreaField') for f in form_fields): + validator_imports.add('Length') + validator_imports = sorted(validator_imports) + + # 状态路由:仅当表存在名为 status 的列时才生成(框架 update_status 按 status 列实现) + has_status_route = any(f['name'] == 'status' for f in fields) + + # 文件字段:需要 get_file_url 前缀处理的字段(图片、附件等) + file_fields = [f['camel_name'] for f in fields if f.get('is_file')] + + # schema 导入字符串(在 Python 侧拼装,避免模板内联条件吃掉后续空行) + model_class_name = config.get('model_class_name', '') + schema_imports_py = f"{model_class_name}Form" + if has_status_route: + schema_imports_py += f", {model_class_name}StatusForm" + + # 模块中文名转义(供生成代码中的字符串字面量使用) + module_comment = config.get('module_comment') or config.get('app_name', '') + module_comment_py = _py_string(module_comment) + + return { + # 基础信息 + 'app_name': config.get('app_name', ''), + 'module_comment': module_comment, + 'module_comment_py': module_comment_py, + 'model_class_name': model_class_name, + 'schema_imports_py': schema_imports_py, + 'permission_prefix': config.get('permission_prefix', ''), + 'display_field': config.get('display_field', 'id'), + + # 功能标识 + 'has_sort': config.get('has_sort', False), + 'has_status': config.get('has_status', False), + 'has_status_route': has_status_route, + + # 文件字段(需要 get_file_url 前缀处理) + 'file_fields': file_fields, + + # 字段列表 + 'sort_field': sort_field, + 'fields': model_fields, + 'form_fields': form_fields, + 'filters_py': filters_py, + 'like_filters_py': like_filters_py, + 'form_imports': form_imports, + 'validator_imports': validator_imports, + } + + # ============================================================ + # 前端模板上下文 + # ============================================================ + + def _prepare_vue_context(self, base_context): + """ + 准备前端 Vue 模板上下文 + + 在后端模板上下文基础上,补充前端模板所需的变量: + - 模块名/模型类名驼峰等命名 + - 字段重塑为模板所需形状(type/label/choices 等) + - 普通列表与树状列表的标识与展示开关 + + Args: + base_context: 后端模板上下文 + + Returns: + dict: 前端模板渲染上下文 + """ + config = self.config + context = dict(base_context) + + # 模块名:去下划线作为前端目录名(order_item -> orderitem) + app_name = config.get('app_name', '') + context['module_name'] = app_name.replace('_', '') + + # 模型类名驼峰(首字母小写):OrderItem -> orderItem + model_class_name = config.get('model_class_name', '') + context['model_class_name_camel'] = ( + model_class_name[0].lower() + model_class_name[1:] if model_class_name else '' + ) + + # 字段重塑为模板所需形状(含 type/label/choices 等前端专用键) + fields = self._build_vue_fields(config.get('fields', [])) + context['fields'] = fields + context['form_fields'] = [f for f in fields if f.get('in_form', True)] + context['list_fields'] = [f for f in fields if f.get('in_list', True)] + context['filter_fields'] = [f for f in fields if f.get('filterable')] + context['editable_fields'] = [f for f in fields if f.get('editable')] + context['searchable_fields'] = [f for f in fields if f.get('searchable')] + + # 功能标识 + context['has_export'] = config.get('has_export', False) + context['has_status'] = config.get('has_status', False) + context['has_unique_code'] = config.get('has_unique_code', False) + context['has_file_field'] = any(f.get('is_file') for f in fields) + context['has_rich_text_field'] = any(f.get('is_rich_text') for f in fields) + + # 路由前缀:后端蓝图为 /{app_name},前端 API 必须与之一致(不做下划线转斜杠) + context['route_prefix'] = app_name + # API 路径(前端 api 文件所在目录,tool 域下) + context['api_path'] = f"tool/{context['module_name']}" + context['permission_prefix'] = config.get('permission_prefix', f'sys:{app_name}') + context['primary_key'] = config.get('primary_key', 'id') + context['display_field_camel'] = ( + config.get('display_field_camel') + or self._to_camel_case(config.get('display_field', 'id')) + ) + + # 树形结构判定:存在 parent_id / pid 字段时使用树状列表模板 + tree_fields = [f for f in config.get('fields', []) if f['name'] in ('parent_id', 'pid')] + is_tree_structure = bool(tree_fields) + context['is_tree_structure'] = is_tree_structure + context['parent_id_field'] = self._to_camel_case(tree_fields[0]['name']) if tree_fields else '' + + # 普通分页列表与树状列表的展示开关 + if is_tree_structure: + context['has_search_form'] = config.get('has_tree_search', False) + context['show_selection'] = config.get('show_selection', False) + context['action_column_width'] = config.get('action_column_width', 200) + else: + context['has_search_form'] = bool(context['filter_fields']) + context['show_selection'] = True + context['action_column_width'] = 150 + + return context + + def _build_vue_fields(self, fields): + """ + 将字段配置重塑为前端模板所需的形状 + + 转换要点: + - type:SQLAlchemy 类型 -> 配置字段类型名(CharField/IntegerField/...),供模板判断 + - label:清洗注释,去掉「1-正常 2-禁用」选项说明 + - choices:元组列表 -> [{value, label, type}] 字典列表(type 为标签颜色) + + Args: + fields: 字段配置列表(parser/normalize 产出) + + Returns: + list: 前端模板字段列表 + """ + vue_fields = [] + for field in fields: + raw_comment = field.get('comment', '') + vue_fields.append({ + 'name': field['name'], + 'camel_name': field.get('camel_name') or self._to_camel_case(field['name']), + 'comment': raw_comment, + 'label': self._clean_field_comment(raw_comment), + 'type': _DB_TO_CONFIG_TYPE.get(field.get('db_type', 'String'), 'CharField'), + 'type_display': field.get('type_display', '字符串'), + 'purpose': field.get('purpose', ''), + 'nullable': field.get('nullable', False), + 'max_length': field.get('max_length'), + 'is_unique': field.get('is_unique', False), + 'db_index': field.get('db_index', False), + 'default': field.get('default'), + 'choices': self._build_choices(field.get('choices')), + 'min_value': field.get('min_value'), + 'max_value': field.get('max_value'), + 'is_file': field.get('is_file', False), + 'is_rich_text': field.get('is_rich_text', False), + 'required': not field.get('nullable', False) and field.get('required', True), + 'in_form': field.get('in_form', True), + 'in_list': field.get('in_list', True), + 'filterable': field.get('filterable', False), + 'filter_type': field.get('filter_type', '模糊查询'), + 'editable': field.get('editable', True), + 'searchable': field.get('searchable', False), + }) + return vue_fields + + def _clean_field_comment(self, comment): + """ + 清洗字段注释,移除选项说明部分 + + 例如: + - "案例类型:1-类型1 2-类型2 3-类型3 4-类型4" -> "案例类型" + - "案例状态:1-正常 2-停用" -> "案例状态" + + Args: + comment: 原始字段注释 + + Returns: + str: 清洗后的注释 + """ + if not comment: + return '' + + if re.search(r'\d+[\-\:]\S+', comment): + if ':' in comment: + return comment.split(':')[0].strip() + if ':' in comment: + return comment.split(':')[0].strip() + + return comment + + def _build_choices(self, choices): + """ + 将选项元组列表转换为前端模板所需字典列表 + + Args: + choices: 选项列表,如 [(1, '正常'), (2, '禁用')] + + Returns: + list/None: [{value, label, type}] 列表;无选项时返回 None + """ + if not choices: + return None + result = [] + for value, label in choices: + result.append({ + 'value': value, + 'label': label, + 'type': _choice_tag_type(value), + }) + return result + + def _generate_vue_files(self, base_context): + """ + 生成前端 Vue 页面代码 + + 普通分页列表(无 parent_id/pid 字段):使用 ui/ 模板,生成 5 个组件文件; + 树状列表(含 parent_id/pid 字段):使用 ui2/ 树形模板,生成 3 个组件文件。 + 两者共用 ui/api.ts.tpl 生成 API 接口文件。 + + 输出目录: + - 组件文件: ui/src/views/tool/{module_name}/ + - API 文件: ui/src/api/tool/{module_name}.ts + + Args: + base_context: 后端模板上下文 + """ + vue_context = self._prepare_vue_context(base_context) + module_name = vue_context['module_name'] + is_tree = vue_context.get('is_tree_structure', False) + + # 组件目录:ui/src/views/tool/{module_name}/ + vue_views_dir = os.path.join(PROJECT_ROOT, 'ui', 'src', 'views', 'tool', module_name) + os.makedirs(vue_views_dir, exist_ok=True) + + # 按列表类型选择模板集 + if is_tree: + # 树状列表:ui2 树形模板(index 内联列 + buildTree,无需 columns/querySchemas) + component_templates = [ + ('ui2/index.vue.tpl', 'index.vue'), + ('ui2/edit.vue.tpl', 'edit.vue'), + ('ui2/detail.vue.tpl', 'detail.vue'), + ] + else: + # 普通分页列表:ui 标准模板 + component_templates = [ + ('ui/index.vue.tpl', 'index.vue'), + ('ui/edit.vue.tpl', 'edit.vue'), + ('ui/detail.vue.tpl', 'detail.vue'), + ('ui/querySchemas.ts.tpl', 'querySchemas.ts'), + ('ui/columns.ts.tpl', 'columns.ts'), + ] + + for template_name, output_name in component_templates: + self._render_template(template_name, os.path.join(vue_views_dir, output_name), vue_context) + + # API 接口文件:ui/src/api/tool/{module_name}.ts(普通/树形共用) + vue_api_dir = os.path.join(PROJECT_ROOT, 'ui', 'src', 'api', 'tool') + os.makedirs(vue_api_dir, exist_ok=True) + self._render_template('ui/api.ts.tpl', os.path.join(vue_api_dir, f'{module_name}.ts'), vue_context) + + # ============================================================ + # 渲染与文件读写 + # ============================================================ + + def _render_template(self, template_name, output_path, context=None): + """ + 渲染模板并写入目标文件 + + Args: + template_name: 模板文件名 + output_path: 输出文件路径 + context: 渲染上下文 + """ + template = self.env.get_template(template_name) + rendered = template.render(context or {}) + self._write_file(output_path, rendered) + logger.info("生成文件: %s", output_path) + + @staticmethod + def _read_file(path): + """读取文本文件""" + with open(path, 'r', encoding='utf-8') as f: + return f.read() + + @staticmethod + def _write_file(path, content): + """写入文本文件(保证文件末尾有换行,避免 W292)""" + if not content.endswith('\n'): + content += '\n' + with open(path, 'w', encoding='utf-8') as f: + f.write(content) + + # ============================================================ + # 自动注册 + # ============================================================ + + def _register_module(self): + """ + 自动注册新模块到项目: + - src/routes/v1.py:导入并追加新蓝图 + - src/models_registry.py:追加模型导入 + - 自动创建菜单与权限节点 + + 均为幂等操作(已存在则跳过)。 + """ + app_name = self.config['app_name'] + blue_name = f"{app_name}_blue" + router_import = f"from src.modules.{app_name}.router import {blue_name}" + model_import = f"from src.modules.{app_name}.models import * # noqa: F401,F403 ({app_name})" + + # 1. routes/v1.py + routes_path = os.path.join(PROJECT_ROOT, 'src', 'routes', 'v1.py') + if os.path.exists(routes_path): + content = self._read_file(routes_path) + updated = self._insert_module_import(content, router_import) + updated = self._insert_blueprint(updated, blue_name) + if updated != content: + self._write_file(routes_path, updated) + logger.info("已更新路由注册: %s", routes_path) + + # 2. models_registry.py + models_path = os.path.join(PROJECT_ROOT, 'src', 'models_registry.py') + if os.path.exists(models_path): + content = self._read_file(models_path) + updated = self._insert_module_import(content, model_import) + if updated != content: + self._write_file(models_path, updated) + logger.info("已更新模型注册: %s", models_path) + + # 3. 自动创建菜单与权限节点 + self._register_menu() + + @staticmethod + def _insert_module_import(content, import_line): + """ + 在最后一个 src.modules 导入之后插入新的导入语句(幂等) + + Args: + content: 原文件内容 + import_line: 要插入的导入语句 + + Returns: + str: 更新后的内容 + """ + if import_line in content: + return content + + lines = content.splitlines(keepends=True) + insert_idx = None + for i, line in enumerate(lines): + if line.startswith('from src.modules.'): + insert_idx = i + + if insert_idx is None: + return content + + lines.insert(insert_idx + 1, import_line + '\n') + return ''.join(lines) + + @staticmethod + def _insert_blueprint(content, blue_name): + """ + 在 BLUEPRINTS 列表末尾追加新蓝图(幂等) + + Args: + content: 原文件内容 + blue_name: 蓝图变量名 + + Returns: + str: 更新后的内容 + """ + marker = 'BLUEPRINTS = [' + idx = content.find(marker) + if idx == -1: + return content + + close_idx = content.find(']', idx) + if close_idx == -1: + return content + + # 仅检查 BLUEPRINTS 块内是否已包含该蓝图 + block = content[idx:close_idx] + if blue_name in block: + return content + + new_line = f' {blue_name},\n' + return content[:close_idx] + new_line + content[close_idx:] + + # ============================================================ + # 菜单自动注册 + # ============================================================ + + def _register_menu(self): + """ + 自动创建菜单与权限节点 + + 为新生成的模块在数据库中创建: + - 模块菜单(type=0),挂在配置指定的 parent_menu_id 下 + - 权限节点(type=1),覆盖 router 中所有 @check_permission 动作 + + 均为幂等操作(按 name + parent_id 判重,已存在则跳过)。 + 创建完成后失效全局权限缓存,使前端立即可见。 + """ + from src.modules.system.menu.repository import MenuRepository + from src.modules.system.menu.service import invalidate_all_cache + from src.core.security import get_realname + + operator = get_realname() or 'system' + config = self.config + app_name = config['app_name'] + module_comment = config.get('module_comment') or app_name + module_name = app_name.replace('_', '') # 前端目录名(与 _prepare_vue_context 一致) + permission_prefix = config.get('permission_prefix', f'sys:{app_name}') + has_status_route = any(f['name'] == 'status' for f in config.get('fields', [])) + parent_menu_id = config.get('parent_menu_id', 189) + menu_sort = config.get('menu_sort', 5) + + # 1. 创建模块菜单(type=0),幂等判重 + existing_menu = MenuRepository.first_by({ + 'name': module_comment, + 'parent_id': parent_menu_id, + }) + if existing_menu: + logger.info("菜单「%s」已存在(id=%s),跳过创建", module_comment, existing_menu.id) + menu_id = existing_menu.id + else: + menu = MenuRepository.create( + name=module_comment, + parent_id=parent_menu_id, + path=f'/tool/{module_name}', + component=f'/tool/{module_name}/index', + permission='', + icon='el-icon-setting', + icon2='GlobalOutlined', + type=0, + status=0, + target=0, + sort=menu_sort, + hide=0, + create_user=operator, + update_user=operator, + ) + menu_id = menu.id + logger.info("已创建菜单「%s」(id=%s)", module_comment, menu_id) + + # 2. 创建权限节点(type=1),幂等判重 + for action, suffix, sort in _PERMISSION_NODES: + # 无 status 路由时跳过 status 权限节点 + if action == 'status' and not has_status_route: + continue + + perm_name = f'{module_comment}-{suffix}' + perm_code = f'{permission_prefix}:{action}' + + existing_perm = MenuRepository.first_by({ + 'name': perm_name, + 'parent_id': menu_id, + }) + if existing_perm: + logger.info("权限节点「%s」已存在,跳过", perm_name) + continue + + MenuRepository.create( + name=perm_name, + parent_id=menu_id, + path=f'/{module_name}/{action}', + component='', + permission=perm_code, + type=1, + status=0, + target=0, + sort=sort, + hide=0, + create_user=operator, + update_user=operator, + ) + logger.info("已创建权限节点「%s」-> %s", perm_name, perm_code) + + # 3. 提交事务(CLI 场景无 AOP 拦截器,需显式提交; + # Web 场景 AOP 拦截器也会在 after_request 中提交,显式提交不影响) + from src.core.database import db + db.session.commit() + + # 4. 失效全局权限缓存 + invalidate_all_cache() + logger.info("菜单注册完成,已刷新权限缓存") + + # ============================================================ + # 命名转换 + # ============================================================ + + @staticmethod + def _to_camel_case(snake_str): + """下划线命名 -> 驼峰命名""" + if not snake_str: + return snake_str + components = str(snake_str).split('_') + return components[0] + ''.join(x.title() for x in components[1:]) + + @staticmethod + def _to_pascal_case(snake_str): + """下划线命名 -> 帕斯卡命名""" + return ''.join(x.title() for x in snake_str.split('_')) diff --git a/src/modules/generator/parser.py b/src/modules/generator/parser.py new file mode 100644 index 0000000..49b077a --- /dev/null +++ b/src/modules/generator/parser.py @@ -0,0 +1,861 @@ +""" +数据库表结构解析模块 +============================================================= + +该模块用于从数据库中读取表结构信息,自动生成代码生成器所需的配置。 +支持解析字段类型、注释、长度、是否为空、默认值等信息,并智能识别字段类型。 + +输出配置: + - db_type: SQLAlchemy 字段类型名(String/Integer/Text/...) + - db_args: SQLAlchemy 字段类型参数(长度/精度),如 "100"、"10, 2" + - form_type: WTForms 字段类(StringField/IntegerField/...) +""" + +import logging +import re + +from sqlalchemy import text + +from config import DB_PREFIX +from src.core.database import db + +logger = logging.getLogger(__name__) + + +class TableSchemaParser: + """ + 数据库表结构解析器 + ============================================================= + + 从数据库中读取表结构,自动生成代码生成器配置。 + + 功能特性: + - 自动识别字段类型(String、Integer、DateTime、Text等) + - 自动识别图片字段(avatar、cover、logo、image等) + - 自动识别富文本字段(Text且注释包含关键词) + - 自动识别状态字段(status、state、is_active等) + - 自动识别排序字段(sort、order、seq等) + - 自动识别唯一编码字段(code、sn、no等) + - 自动为常用字段设置查询条件(name、title、code、mobile、email等) + - 自动为选项字段设置查询条件(type、category、status等) + - 自动识别字段是否有数据库索引,并设置为查询条件 + - 自动生成字段注释和用途说明 + """ + + def __init__(self, table_name): + """ + 初始化表结构解析器 + + Args: + table_name: 数据库表名(可以带前缀或不带前缀) + """ + # 保存原始表名(用于数据库查询,可能带前缀) + self.raw_table_name = table_name + + # 处理后的表名(用于生成代码,不带前缀) + if table_name.startswith(DB_PREFIX): + self.table_name = table_name[len(DB_PREFIX):] + else: + self.table_name = table_name + + # 排除的系统字段(这些字段在代码生成时会被跳过,对应 BaseModel 公共列) + self.exclude_fields = ['id', 'create_user', 'create_time', 'update_user', 'update_time', 'is_delete'] + + # 文件/图片字段关键词(识别后自动做 get_file_url 前缀处理) + self.file_keywords = ['avatar', 'cover', 'logo', 'image', 'photo', 'picture', 'img', 'icon', + 'thumbnail', 'banner', 'file', 'attachment', 'document', 'video', 'audio'] + + # 富文本字段关键词(Text 字段且注释包含这些关键词时识别为富文本) + self.rich_text_keywords = ['内容', '介绍', '说明', '描述', '详情', '正文'] + + # 状态字段关键词 + self.status_keywords = ['status', 'state', 'is_active', 'is_enabled', 'is_deleted'] + + # 排序字段关键词 + self.sort_keywords = ['sort', 'order', 'seq', 'sequence', 'priority'] + + # 唯一编码字段关键词 + self.code_keywords = ['code', 'sn', 'no', 'serial', 'number', 'unique_code'] + + # 需要设置为查询条件的常用字段关键词 + self.filter_keywords = [ + 'name', 'title', 'code', 'sn', 'no', # 名称/编码类 + 'mobile', 'phone', 'tel', 'email', # 联系方式类 + 'username', 'nickname', 'realname', # 用户信息类 + 'type', 'category', 'kind', 'class', # 类型分类类 + 'status', 'state', 'level', 'grade', # 状态等级类 + 'dept_id', 'department_id', # 部门类 + 'role_id', 'position_id', 'level_id', # 关联ID类 + 'province_code', 'city_code', 'district_code', # 地区代码类 + 'gender', 'sex', # 性别类 + ] + + # 数据库类型 -> SQLAlchemy 字段类型映射 + self.db_type_mapping = { + 'varchar': 'String', + 'char': 'String', + 'text': 'Text', + 'longtext': 'Text', + 'mediumtext': 'Text', + 'tinytext': 'Text', + 'int': 'Integer', + 'tinyint': 'Integer', + 'smallint': 'Integer', + 'mediumint': 'Integer', + 'bigint': 'BigInteger', + 'datetime': 'DateTime', + 'timestamp': 'DateTime', + 'date': 'Date', + 'time': 'Time', + 'float': 'Float', + 'double': 'Float', + 'real': 'Float', + 'decimal': 'Numeric', + 'boolean': 'Boolean', + 'bit': 'Boolean', + 'json': 'JSON', + } + + # SQLAlchemy 字段类型 -> WTForms 字段类映射 + self.form_type_mapping = { + 'String': 'StringField', + 'Text': 'TextAreaField', + 'Integer': 'IntegerField', + 'BigInteger': 'IntegerField', + 'Float': 'FloatField', + 'Numeric': 'FloatField', + 'Boolean': 'BooleanField', + 'DateTime': 'StringField', + 'Date': 'StringField', + 'Time': 'StringField', + 'JSON': 'StringField', + } + + # SQLAlchemy 字段类型 -> 中文显示名映射 + self.type_display_mapping = { + 'String': '字符串', + 'Text': '长文本', + 'Integer': '整数', + 'BigInteger': '大整数', + 'DateTime': '日期时间', + 'Date': '日期', + 'Time': '时间', + 'Float': '浮点数', + 'Numeric': '小数', + 'Boolean': '布尔值', + 'JSON': 'JSON', + } + + # ============================================================ + # 主解析方法 + # ============================================================ + + def parse(self): + """ + 解析表结构,生成配置字典 + + Returns: + dict: 代码生成器配置字典 + """ + # 步骤1:获取表信息(注释、引擎等) + table_info = self._get_table_info() + + # 步骤2:获取字段列表(排除系统字段) + columns = self._get_columns() + logger.info(f"解析表 {self.raw_table_name}:获取到 %s 个业务字段", len(columns)) + + # 步骤3:获取字段索引信息 + indexes = self._get_indexes() + + # 步骤4:处理表名,移除前缀(用于生成模块名) + table_name_cleaned = self._clean_table_name() + + # 步骤5:构建应用名称和模块名称 + # ---------------------------------------------------------- + # app_name 模块目录名 / 蓝图名(小写下划线,如 order_item) + # module_comment 模块中文名(优先取表注释,去掉「表」字) + # model_class_name 模型类名(帕斯卡命名,如 OrderItem) + # route_prefix 路由前缀(下划线转斜杠,如 order/item;与 config.json 案例一致,不含前导斜杠) + app_name = table_name_cleaned.lower() + module_comment = self._get_module_comment(table_info) + model_class_name = self._to_pascal_case(table_name_cleaned) + route_prefix = app_name.replace('_', '/') + + # 步骤6:构建字段列表 + fields = self._build_fields(columns, indexes) + if not fields: + logger.warning("表 %s 没有找到任何业务字段", self.raw_table_name) + + # 步骤7:判断功能标识 + has_sort = self._has_field_by_keywords(columns, self.sort_keywords) + has_status = self._has_field_by_keywords(columns, self.status_keywords) + has_unique_code = self._has_field_by_keywords(columns, self.code_keywords) + + has_file_field = any(f.get('is_file', False) for f in fields) + has_rich_text_field = any(f.get('is_rich_text', False) for f in fields) + + # 步骤8:获取显示字段 + display_field = self._get_display_field(columns) + + # 步骤9:构建完整配置 + config = { + # 基础信息 + 'app_name': app_name, + 'module_comment': module_comment, + 'model_name': app_name, + 'model_class_name': model_class_name, + 'route_prefix': route_prefix, + + # 业务描述 + 'business_description': f'标识系统中的{module_comment}数据', + 'business_description_detail': ( + f'包含{", ".join([f["comment"] for f in fields[:3]])}等属性' if fields + else f'包含{module_comment}相关属性' + ), + + # 权限和标识 + 'permission_prefix': f'sys:{app_name}', + 'primary_key': 'id', + 'display_field': display_field, + 'display_field_camel': self._to_camel_case(display_field), + + # 表信息 + 'table_name': self.table_name, + 'table_prefix': self._get_table_prefixes(), + 'remove_prefix': True, # 是否移除表前缀(供前端展示/后续扩展) + + # 菜单配置(CodeGenerator._register_menu 自动创建菜单与权限节点) + 'menu_group': 'tool', + 'parent_menu_id': 189, + 'menu_sort': 5, + + # 功能标识 + 'has_sort': has_sort, + 'has_status': has_status, + 'has_unique_code': has_unique_code, + 'has_export': False, + 'has_file_field': has_file_field, + 'has_rich_text_field': has_rich_text_field, + + # 字段列表 + 'fields': fields, + } + + return config + + # ============================================================ + # 数据库查询 + # ============================================================ + + def _get_table_info(self): + """ + 获取表的元数据信息(注释、引擎、行数等) + + Returns: + dict: 表信息字典,包含 TABLE_NAME, TABLE_COMMENT, ENGINE, TABLE_ROWS, CREATE_TIME, UPDATE_TIME + """ + sql = """ + SELECT + TABLE_NAME, + TABLE_COMMENT, + ENGINE, + TABLE_ROWS, + CREATE_TIME, + UPDATE_TIME + FROM + information_schema.TABLES + WHERE + TABLE_SCHEMA = (SELECT DATABASE()) + AND TABLE_NAME = :table_name + """ + rows = db.session.execute(text(sql), {"table_name": self.raw_table_name}).fetchall() + if rows: + return dict(rows[0]._mapping) + return {} + + def _get_columns(self): + """ + 获取表的字段列表信息 + + Returns: + list: 字段信息列表,每个字段包含 COLUMN_NAME, COLUMN_COMMENT, DATA_TYPE 等 + """ + sql = """ + SELECT + COLUMN_NAME, + COLUMN_COMMENT, + DATA_TYPE, + COLUMN_TYPE, + IS_NULLABLE, + COLUMN_DEFAULT, + EXTRA, + CHARACTER_MAXIMUM_LENGTH, + NUMERIC_PRECISION, + NUMERIC_SCALE + FROM + information_schema.COLUMNS + WHERE + TABLE_SCHEMA = (SELECT DATABASE()) + AND TABLE_NAME = :table_name + ORDER BY + ORDINAL_POSITION + """ + columns = [] + rows = db.session.execute(text(sql), {"table_name": self.raw_table_name}).fetchall() + for row in rows: + column = dict(row._mapping) + # 排除系统字段(id、create_time 等由 BaseModel 提供) + if column['COLUMN_NAME'] not in self.exclude_fields: + columns.append(column) + return columns + + def _get_indexes(self): + """ + 获取表的索引信息 + + Returns: + set: 有索引的字段名集合 + """ + sql = """ + SELECT DISTINCT + COLUMN_NAME + FROM + information_schema.STATISTICS + WHERE + TABLE_SCHEMA = (SELECT DATABASE()) + AND TABLE_NAME = :table_name + """ + rows = db.session.execute(text(sql), {"table_name": self.raw_table_name}).fetchall() + return {row[0] for row in rows} + + # ============================================================ + # 表名处理 + # ============================================================ + + def _clean_table_name(self): + """ + 清理表名,移除常见前缀(用于生成应用名和模型名) + + Returns: + str: 清理后的表名 + """ + table_name = self.table_name + prefixes = [DB_PREFIX, 'sys_', 'tb_', 'django_', 'auth_', 'captcha_'] + + for prefix in prefixes: + if prefix and table_name.startswith(prefix): + table_name = table_name[len(prefix):] + break + + return table_name + + def _get_table_prefixes(self): + """ + 获取可能的数据表前缀列表 + + Returns: + list: 前缀列表 + """ + return [DB_PREFIX] + + def _get_module_comment(self, table_info): + """ + 获取模块中文注释 + + Args: + table_info: 表信息字典 + + Returns: + str: 模块中文注释 + """ + if table_info.get('TABLE_COMMENT'): + comment = table_info['TABLE_COMMENT'] + if comment.endswith('表'): + comment = comment[:-1] + comment = comment.strip() + if comment: + return comment + + # 从表名生成 + table_name_cleaned = self._clean_table_name() + name_parts = table_name_cleaned.split('_') + return ''.join(part.title() for part in name_parts) + + # ============================================================ + # 字段类型映射 + # ============================================================ + + def _get_field_type(self, column): + """ + 根据数据库字段类型映射到 SQLAlchemy 字段类型 + + Args: + column: 字段信息字典 + + Returns: + str: SQLAlchemy 字段类型名 + """ + data_type = column['DATA_TYPE'].lower() + return self.db_type_mapping.get(data_type, 'String') + + def _get_db_args(self, column): + """ + 获取字段类型的构造参数(长度/精度) + + Args: + column: 字段信息字典 + + Returns: + str/None: 预渲染的参数串,如 "100"、"10, 2";无需参数时返回 None + """ + db_type = self._get_field_type(column) + if db_type == 'String': + max_length = column.get('CHARACTER_MAXIMUM_LENGTH') + return str(max_length) if max_length else None + if db_type == 'Numeric': + precision = column.get('NUMERIC_PRECISION') + if precision: + scale = column.get('NUMERIC_SCALE') or 0 + return f"{precision}, {scale}" + return None + return None + + def _get_form_type(self, column): + """ + 获取 WTForms 字段类 + + Args: + column: 字段信息字典 + + Returns: + str: WTForms 字段类名 + """ + db_type = self._get_field_type(column) + return self.form_type_mapping.get(db_type, 'StringField') + + def _get_field_type_display(self, db_type): + """ + 获取字段类型的中文显示名称 + + Args: + db_type: SQLAlchemy 字段类型名 + + Returns: + str: 类型显示名称(中文) + """ + return self.type_display_mapping.get(db_type, '字符串') + + def _get_max_length(self, column): + """ + 获取字段最大长度 + + Args: + column: 字段信息字典 + + Returns: + int/None: 最大长度,字符串类型才支持 + """ + if column['DATA_TYPE'].lower() in ('varchar', 'char'): + return column.get('CHARACTER_MAXIMUM_LENGTH') + return None + + def _is_nullable(self, column): + """ + 判断字段是否可为空 + + Args: + column: 字段信息字典 + + Returns: + bool: 是否可为空(YES 表示可为空) + """ + return column['IS_NULLABLE'] == 'YES' + + def _get_default_value(self, column): + """ + 获取字段默认值 + + Args: + column: 字段信息字典 + + Returns: + any: 默认值,处理掉字符串默认值的引号 + """ + default = column.get('COLUMN_DEFAULT') + if default is None: + return None + + if isinstance(default, str) and default.startswith("'") and default.endswith("'"): + default = default[1:-1] + + return default + + # ============================================================ + # 字段智能识别 + # ============================================================ + + def _is_file_field(self, column_name): + """ + 判断是否为文件/图片字段 + + Args: + column_name: 字段名 + + Returns: + bool: 是否为文件字段 + """ + return any(kw in column_name.lower() for kw in self.file_keywords) + + def _is_status_field(self, column_name): + """ + 判断是否为状态字段 + + Args: + column_name: 字段名 + + Returns: + bool: 是否为状态字段 + """ + return any(keyword in column_name.lower() for keyword in self.status_keywords) + + def _is_sort_field(self, column_name): + """ + 判断是否为排序字段 + + Args: + column_name: 字段名 + + Returns: + bool: 是否为排序字段 + """ + return any(keyword in column_name.lower() for keyword in self.sort_keywords) + + def _get_choices(self, column): + """ + 获取字段的选项列表(从字段注释中解析) + + 示例注释格式:'0-正常 1-下架' 或 '1:正常 2:停用' + + Args: + column: 字段信息字典 + + Returns: + tuple: (choices列表, min_value, max_value) + """ + comment = column.get('COLUMN_COMMENT', '') + pattern = r'(\d+)[:\-](\S+)' + matches = re.findall(pattern, comment) + + if matches: + choices = [] + values = [] + for match in matches: + try: + value = int(match[0]) + except ValueError: + value = match[0] + values.append(value) + choices.append((value, match[1])) + + if values: + return choices, min(values), max(values) + return choices, None, None + + return None, None, None + + def _should_filterable(self, column, indexed_columns): + """ + 判断字段是否应该作为查询条件 + + 规则: + 1. 有数据库索引的字段 + 2. 在 filter_keywords 中的常用字段 + 3. 有选项(choices)的整数字段 + 4. 状态字段 + 5. 编码字段 + + Args: + column: 字段信息字典 + indexed_columns: 有索引的字段名集合 + + Returns: + bool: 是否作为查询条件 + """ + column_name = column['COLUMN_NAME'].lower() + field_type = self._get_field_type(column) + choices, _, _ = self._get_choices(column) + has_choices = choices is not None + + if column_name in indexed_columns: + return True + + for keyword in self.filter_keywords: + if keyword in column_name: + return True + + if field_type == 'Integer' and has_choices: + return True + + for keyword in self.status_keywords: + if keyword in column_name: + return True + + for keyword in self.code_keywords: + if keyword in column_name: + return True + + return False + + def _get_filter_type(self, column): + """ + 获取查询条件的筛选类型 + + Args: + column: 字段信息字典 + + Returns: + str: 筛选类型(模糊查询/精确匹配) + """ + field_type = self._get_field_type(column) + column_name = column['COLUMN_NAME'].lower() + choices, _, _ = self._get_choices(column) + has_choices = choices is not None + + # 字符串类型且不是编码字段,使用模糊查询 + if field_type == 'String': + for keyword in self.code_keywords: + if keyword in column_name: + return '精确匹配' + return '模糊查询' + + # 整数字段,有选项或状态字段,使用精确匹配 + if field_type == 'Integer': + if has_choices: + return '精确匹配' + for keyword in self.status_keywords: + if keyword in column_name: + return '精确匹配' + + return '精确匹配' + + def _has_field_by_keywords(self, columns, keywords): + """ + 判断表中是否存在包含关键词的字段 + + Args: + columns: 字段列表 + keywords: 关键词列表 + + Returns: + bool: 是否存在 + """ + for col in columns: + column_name = col['COLUMN_NAME'].lower() + for keyword in keywords: + if keyword in column_name: + return True + return False + + def _get_display_field(self, columns): + """ + 获取显示字段(优先使用 name,否则使用第一个字符串字段) + + Args: + columns: 字段列表 + + Returns: + str: 显示字段名 + """ + for col in columns: + if col['COLUMN_NAME'].lower() == 'name': + return 'name' + for col in columns: + if col['COLUMN_NAME'].lower() == 'title': + return 'title' + for col in columns: + if self._get_field_type(col) == 'String': + return col['COLUMN_NAME'] + if columns: + return columns[0]['COLUMN_NAME'] + return 'id' + + # ============================================================ + # 构建字段配置 + # ============================================================ + + def _build_fields(self, columns, indexed_columns): + """ + 构建字段列表配置 + + Args: + columns: 字段列表 + indexed_columns: 有索引的字段名集合 + + Returns: + list: 字段配置列表 + """ + fields = [] + + for col in columns: + column_name = col['COLUMN_NAME'] + db_type = self._get_field_type(col) + is_file = self._is_file_field(column_name) + is_status = self._is_status_field(column_name) + is_sort = self._is_sort_field(column_name) + + # 判断是否为富文本字段(Text 且注释包含关键词) + is_rich_text = False + if db_type == 'Text': + comment_text = col.get('COLUMN_COMMENT', '') + for keyword in self.rich_text_keywords: + if keyword in comment_text: + is_rich_text = True + break + + # 判断是否作为查询条件 + is_filterable = self._should_filterable(col, indexed_columns) + + # 获取字段注释 + comment = col.get('COLUMN_COMMENT') or column_name.title() + if is_file and not comment: + comment = f'{column_name}图片' + elif is_status and not comment: + comment = f'{column_name}状态' + elif is_sort and not comment: + comment = f'{column_name}排序' + + # 获取选项和取值范围 + choices, min_value, max_value = self._get_choices(col) + + default = self._get_default_value(col) + # 数值字段的字符串默认值转为数值类型(MySQL information_schema 返回字符串) + if default is not None and isinstance(default, str): + if db_type in ('Integer', 'BigInteger'): + try: + default = int(default) + except ValueError: + pass + elif db_type in ('Float', 'Numeric'): + try: + default = float(default) + except ValueError: + pass + elif db_type == 'Boolean': + default = str(default).lower() in ('1', 'true') + default_repr = repr(default) if default is not None else None + + field_config = { + 'name': column_name, + 'camel_name': self._to_camel_case(column_name), + 'comment': comment, + 'py_comment': self._py_string(comment), + 'purpose': f'存储{comment}信息', + 'db_type': db_type, + 'db_args': self._get_db_args(col), + 'form_type': self._get_form_type(col), + 'type_display': self._get_field_type_display(db_type), + 'max_length': self._get_max_length(col), + 'nullable': self._is_nullable(col), + 'required': not self._is_nullable(col), + 'is_unique': col.get('EXTRA') == 'unique' or False, + 'db_index': column_name in indexed_columns, + 'default': default, + 'default_repr': default_repr, + 'in_form': True, + 'in_list': True, + 'filterable': is_filterable, + 'filter_type': self._get_filter_type(col) if is_filterable else '', + 'editable': True, + 'searchable': db_type == 'String' and not is_file and is_filterable, + 'is_file': is_file, + 'is_rich_text': is_rich_text, + 'is_status': is_status, + 'is_sort': is_sort, + 'choices': choices, + 'min_value': min_value, + 'max_value': max_value, + } + + # 有选项时,筛选类型为精确匹配 + if choices: + field_config['filter_type'] = '精确匹配' + + # 文件字段特殊处理:不支持筛选和搜索 + if is_file: + field_config['filterable'] = False + field_config['searchable'] = False + + # 排序字段特殊处理:不支持筛选和搜索 + if is_sort: + field_config['filterable'] = False + field_config['searchable'] = False + + fields.append(field_config) + + return fields + + # ============================================================ + # 命名转换 + # ============================================================ + + def _py_string(self, value): + """ + 将字符串转义为可安全嵌入 Python 单引号字符串字面量 + + Args: + value: 原始字符串 + + Returns: + str: 转义后的字符串(不含引号) + """ + if not value: + return '' + return str(value).replace('\\', '\\\\').replace("'", "\\'").replace('"', '\\"') + + def _to_camel_case(self, snake_str): + """ + 将下划线命名转换为驼峰命名 + 例如:'user_name' -> 'userName' + + Args: + snake_str: 下划线命名字符串 + + Returns: + str: 驼峰命名字符串 + """ + if not snake_str: + return snake_str + + components = str(snake_str).split('_') + return components[0] + ''.join(x.title() for x in components[1:]) + + def _to_pascal_case(self, snake_str): + """ + 将下划线命名转换为帕斯卡命名(首字母大写) + 例如:user_info -> UserInfo + + Args: + snake_str: 下划线命名字符串 + + Returns: + str: 帕斯卡命名字符串 + """ + return ''.join(x.title() for x in snake_str.split('_')) + + +# ============================================================ +# 代码生成器集成 +# ============================================================ + +def generate_config_from_table(table_name): + """ + 从数据库表生成代码生成器配置 + + Args: + table_name: 数据库表名 + + Returns: + dict: 代码生成器配置 + """ + parser = TableSchemaParser(table_name) + return parser.parse() diff --git a/src/modules/generator/router.py b/src/modules/generator/router.py new file mode 100644 index 0000000..6738fca --- /dev/null +++ b/src/modules/generator/router.py @@ -0,0 +1,189 @@ +# +====================================================================== +# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] +# +---------------------------------------------------------------------- +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 +# +---------------------------------------------------------------------- +# | 开源协议: Apache-2.0 +# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 +# +---------------------------------------------------------------------- +# | 官方网站: https://www.djangoadmin.cn +# +---------------------------------------------------------------------- +# | 研发团队: 小蚂蚁云团队 荣誉出品 +# +====================================================================== +# | 版权和免责声明: +# | 一、知识产权声明 +# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 +# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, +# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 +# | 二、使用限制与禁止条款 +# | 您理解并同意,禁止将本产品用于以下任何行为: +# | 1. 任何违反中华人民共和国法律法规的用途; +# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; +# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 +# | 三、责任豁免 +# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 +# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 +# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 +# | 四、违约与法律责任 +# | 如发现任何违反本声明的行为,本团队有权: +# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; +# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; +# | 3. 保留进一步采取刑事控告等法律措施的权利。 +# | 五、许可范围说明 +# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 +# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 +# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 +# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 +# | 成的一切经济损失及商誉损害。 +# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 +# | 许可协议》附件为准。 +# +====================================================================== + + +""" +代码生成器控制器模块 +============================================================= + +提供代码生成器(Generator)相关的API接口,包括: +- 数据库表分页查询 +- 表详情查询 +- 表字段查询 +- 单表/批量代码生成 + +所属包: src.modules.generator +依赖模块: src.modules.generator.service - 代码生成器业务逻辑层 +""" + +from flask import Blueprint, request + +from src.middleware import check_demo, operation_log +from src.core.permission import check_permission +from src.modules.generator import service as generator +from src.common.utils.request import parse_request_body + +from src.core import response as R + +generator_blue = Blueprint("generator", __name__, url_prefix="/generator") + + +# ============================================================ +# 数据库表分页查询 +# ============================================================ + + +@generator_blue.route("/page", methods=["GET"]) +@check_permission("sys:generator:page") +@operation_log("代码生成器", "查询") +def page(): + """ + 查询数据库表分页数据 + + 功能说明: + 获取数据库表的元数据分页数据,支持按表名、表注释模糊搜索。 + + Returns: + JSON: 分页数据,包含 records(数据列表)、total(总记录数)等 + """ + return R.ok(data=generator.get_table_page(request.args)) + + +# ============================================================ +# 数据库表详情查询 +# ============================================================ + + +@generator_blue.route("/detail/", methods=["GET"]) +@check_permission("sys:generator:detail") +@operation_log("代码生成器", "详情") +def detail(table_name): + """ + 查询数据库表详情 + + Args: + table_name: 表名 + + Returns: + JSON: 表详细信息 + """ + data = generator.get_table_detail(table_name) + return R.ok(data=data) + + +# ============================================================ +# 数据库表字段查询 +# ============================================================ + + +@generator_blue.route("/columns/", methods=["GET"]) +@check_permission("sys:generator:detail") +@operation_log("代码生成器", "字段") +def columns(table_name): + """ + 查询数据库表字段信息 + + Args: + table_name: 表名 + + Returns: + JSON: 字段信息列表 + """ + columns = generator.get_table_columns(table_name) + return R.ok(data=columns) + + +# ============================================================ +# 单表代码生成 +# ============================================================ + + +@generator_blue.route("/generate", methods=["POST"]) +@check_permission("sys:generator:generate") +@check_demo +@operation_log("代码生成器", "一键生成") +def generate(): + """ + 单表代码生成 + + 功能说明: + 根据表名生成对应的 Flask 三层后端模块代码。 + + Request Body: + {"tableNames": ["flask_article|文章管理表"]} + + Returns: + JSON: 操作结果 + """ + json_data, err = parse_request_body() + if err: + return R.failed(msg=err) + count = generator.generate_tables(json_data) + return R.ok(msg=f"本次共计生成{count}个模块") + + +# ============================================================ +# 批量代码生成 +# ============================================================ + + +@generator_blue.route("/batchGenerate", methods=["POST"]) +@check_permission("sys:generator:batchGenerate") +@check_demo +@operation_log("代码生成器", "批量生成") +def batch_generate(): + """ + 批量代码生成 + + 功能说明: + 批量根据多个表名生成对应的后端模块代码。 + + Request Body: + {"tableNames": ["flask_article|文章管理表", "flask_example|案例表"]} + + Returns: + JSON: 操作结果 + """ + json_data, err = parse_request_body() + if err: + return R.failed(msg=err) + count = generator.generate_tables(json_data) + return R.ok(msg=f"本次共计生成{count}个模块") diff --git a/src/modules/generator/service.py b/src/modules/generator/service.py new file mode 100644 index 0000000..82a6694 --- /dev/null +++ b/src/modules/generator/service.py @@ -0,0 +1,402 @@ +""" +代码生成器服务模块 +============================================================= + +该模块实现了数据库表结构查询与代码生成的业务逻辑。 +包括表的分页查询、详情查询、字段查询等功能, +通过查询 information_schema 获取表元数据,并调用代码生成引擎生成 Flask 三层模块代码。 +""" + +import logging + +from sqlalchemy import text + +from src.common.constants.common import PAGE_SIZE +from src.common.utils.model_utils import calc_pages, format_datetime +from src.core.database import db +from src.core.exceptions import BizError + +logger = logging.getLogger(__name__) + +# 批量生成的最大表数量 +MAX_BATCH_SIZE = 50 + +# 需要排除的非业务表前缀 +_EXCLUDE_TABLE_PREFIXES = ("auth_", "captcha_") + +# 需要排除的非业务表后缀模式 +_EXCLUDE_TABLE_PATTERNS = ("%_migrations", "%_session", "%_content_type") + + +def get_table_page(params: dict) -> dict: + """ + 查询数据库表分页数据 + + Args: + params: 查询参数 + - pageNo: 当前页码(可选,默认1) + - pageSize: 每页记录数(可选,默认PAGE_SIZE) + - tableName: 表名(可选,模糊查询) + - tableComment: 表注释(可选,模糊查询) + + Returns: + dict: 分页数据 {"records", "total", "size", "current", "pages"} + """ + page_no = int(params.get("pageNo") or 1) + page_size = int(params.get("pageSize") or PAGE_SIZE) + tables = _query_tables(params.get("tableName") or "", params.get("tableComment") or "") + return _paginate_tables(tables, page_no, page_size) + + +def get_table_detail(table_name: str) -> dict: + """ + 查询数据库表详情 + + Args: + table_name: 表名 + + Returns: + dict: 表详情信息 + + Raises: + BizError: 表名不存在时抛出 + """ + if not table_name: + raise BizError("表名不能为空") + result = _query_table_detail(table_name) + if not result: + raise BizError("表不存在") + return result + + +def get_table_columns(table_name: str) -> list: + """ + 查询数据库表字段信息 + + Args: + table_name: 表名 + + Returns: + list: 字段信息列表 + """ + if not table_name: + raise BizError("表名不能为空") + return _query_table_columns(table_name) + + +def generate_tables(data: dict) -> int: + """ + 统一的代码生成服务方法 + ============================================================= + + 支持单个或多个表的代码生成,自动解析管道分隔格式。 + + Args: + data: 请求数据 + - tableNames: 表名列表,支持三种格式 + 格式1(管道分隔): ["flask_article|文章管理表", "flask_user|用户表"] + 格式2(纯表名): ["flask_article", "flask_user"] + 格式3(混合): ["flask_article|文章管理表", "flask_user"] + + Returns: + int: 成功生成的模块数量(单项失败只记录日志并继续) + """ + table_names = data.get("tableNames", []) if isinstance(data, dict) else [] + if not table_names: + raise BizError("表名不能为空") + if not isinstance(table_names, list): + raise BizError("tableNames 必须是数组") + if len(table_names) > MAX_BATCH_SIZE: + raise BizError(f"批量生成最多支持 {MAX_BATCH_SIZE} 个表") + + # 逐表生成:单表失败只记录原因并继续,保证批量流程不被单表中断 + success_count = 0 + failed_details = [] # 记录失败表名与原因,便于排查与提示 + for item in table_names: + # 解析每个表项(支持 "表名|注释" / 纯表名 / 字典三种格式) + table_name, table_comment = _parse_table_item(item) + try: + _generate_one(table_name, table_comment) + success_count += 1 + except BizError as e: + # 业务失败(如表不存在、模块已存在),记录原因继续 + logger.error(f"生成表 {table_name} 代码失败: {e.msg}") + failed_details.append(f"{table_name}: {e.msg}") + except Exception as e: # noqa: BLE001 - 单项失败不阻断批量流程 + logger.error(f"生成表 {table_name} 代码失败: {str(e)}") + failed_details.append(f"{table_name}: {str(e)}") + + # 全部失败时给出明确错误,避免返回「本次共计生成0个模块」的误导 + if success_count == 0 and failed_details: + raise BizError("所有表生成失败:" + ";".join(failed_details[:3])) + + return success_count + + +# ============================================================ +# 私有辅助函数 +# ============================================================ + +def _execute_rows(sql: str, params: dict): + """ + 执行只读 SQL 并返回全部行 + + Args: + sql: 原生 SQL 语句 + params: 绑定参数字典 + + Returns: + list: SQLAlchemy Row 列表 + """ + result = db.session.execute(text(sql), params) + return result.fetchall() + + +def _query_tables(table_name: str = "", table_comment: str = "") -> list: + """ + 查询数据库表信息 + + Args: + table_name: 表名模糊查询条件 + table_comment: 表注释模糊查询条件 + + Returns: + list: 表信息字典列表 + """ + sql = """ + SELECT + TABLE_NAME, + TABLE_COMMENT, + ENGINE, + TABLE_ROWS, + DATA_LENGTH, + AUTO_INCREMENT, + TABLE_COLLATION, + CREATE_TIME, + UPDATE_TIME + FROM + information_schema.TABLES + WHERE + TABLE_SCHEMA = (SELECT DATABASE()) + """ + + for prefix in _EXCLUDE_TABLE_PREFIXES: + sql += f" AND TABLE_NAME NOT LIKE '{prefix}%'" + for pattern in _EXCLUDE_TABLE_PATTERNS: + sql += f" AND TABLE_NAME NOT LIKE '{pattern}'" + + params = {} + if table_name: + sql += " AND TABLE_NAME LIKE :table_name" + params["table_name"] = f"%{table_name}%" + if table_comment: + sql += " AND TABLE_COMMENT LIKE :table_comment" + params["table_comment"] = f"%{table_comment}%" + + sql += " ORDER BY TABLE_NAME" + + tables = [] + for row in _execute_rows(sql, params): + row_dict = dict(row._mapping) + + # 自增索引值:为空但有行数时补 TABLE_ROWS + 1 + auto_increment = row_dict.get("AUTO_INCREMENT") + if auto_increment is None and row_dict.get("TABLE_ROWS"): + auto_increment = row_dict["TABLE_ROWS"] + 1 + + tables.append({ + "tableName": row_dict.get("TABLE_NAME", ""), + "tableComment": row_dict.get("TABLE_COMMENT", ""), + "engine": row_dict.get("ENGINE", ""), + "tableRows": row_dict.get("TABLE_ROWS", 0), + "dataLength": row_dict.get("DATA_LENGTH", 0), + "autoIncrement": auto_increment, + "tableCollation": row_dict.get("TABLE_COLLATION", ""), + "createTime": format_datetime(row_dict.get("CREATE_TIME")), + "updateTime": format_datetime(row_dict.get("UPDATE_TIME")), + }) + + return tables + + +def _paginate_tables(tables: list, page_no: int, page_size: int) -> dict: + """ + 对表列表进行内存分页 + + Args: + tables: 表信息列表 + page_no: 当前页码 + page_size: 每页大小 + + Returns: + dict: 分页数据 {"records", "total", "size", "current", "pages"} + """ + total = len(tables) + pages = calc_pages(total, page_size) + if page_no < 1: + page_no = 1 + if pages > 0 and page_no > pages: + # 越界时回退第 1 页 + page_no = 1 + + start = (page_no - 1) * page_size + records = tables[start:start + page_size] + + return { + "records": records, + "total": total, + "size": page_size, + "current": page_no, + "pages": pages, + } + + +def _query_table_detail(table_name: str): + """ + 根据表名查询表详情 + + Args: + table_name: 表名 + + Returns: + dict/None: 表详情或 None + """ + sql = """ + SELECT + TABLE_NAME, + TABLE_COMMENT, + ENGINE, + TABLE_ROWS, + DATA_LENGTH, + AUTO_INCREMENT, + TABLE_COLLATION, + CREATE_TIME, + UPDATE_TIME + FROM + information_schema.TABLES + WHERE + TABLE_SCHEMA = (SELECT DATABASE()) + AND TABLE_NAME = :table_name + """ + rows = _execute_rows(sql, {"table_name": table_name}) + if not rows: + return None + + row_dict = dict(rows[0]._mapping) + if row_dict.get("AUTO_INCREMENT") is None and row_dict.get("TABLE_ROWS"): + row_dict["AUTO_INCREMENT"] = row_dict["TABLE_ROWS"] + 1 + + return { + "tableName": row_dict.get("TABLE_NAME", ""), + "tableComment": row_dict.get("TABLE_COMMENT", ""), + "engine": row_dict.get("ENGINE", ""), + "tableRows": row_dict.get("TABLE_ROWS", 0), + "dataLength": row_dict.get("DATA_LENGTH", 0), + "autoIncrement": row_dict.get("AUTO_INCREMENT"), + "tableCollation": row_dict.get("TABLE_COLLATION", ""), + "createTime": format_datetime(row_dict.get("CREATE_TIME")), + "updateTime": format_datetime(row_dict.get("UPDATE_TIME")), + } + + +def _query_table_columns(table_name: str) -> list: + """ + 获取表的字段信息 + + Args: + table_name: 表名 + + Returns: + list: 字段信息列表 + """ + sql = """ + SELECT + COLUMN_NAME, + COLUMN_COMMENT, + DATA_TYPE, + COLUMN_TYPE, + IS_NULLABLE, + COLUMN_DEFAULT, + EXTRA + FROM + information_schema.COLUMNS + WHERE + TABLE_SCHEMA = (SELECT DATABASE()) + AND TABLE_NAME = :table_name + ORDER BY + ORDINAL_POSITION + """ + columns = [] + for row in _execute_rows(sql, {"table_name": table_name}): + row_dict = dict(row._mapping) + columns.append({ + "columnName": row_dict.get("COLUMN_NAME", ""), + "columnComment": row_dict.get("COLUMN_COMMENT", ""), + "dataType": row_dict.get("DATA_TYPE", ""), + "columnType": row_dict.get("COLUMN_TYPE", ""), + "isNullable": row_dict.get("IS_NULLABLE", ""), + "columnDefault": row_dict.get("COLUMN_DEFAULT"), + "extra": row_dict.get("EXTRA", ""), + }) + + return columns + + +def _parse_table_item(item): + """ + 解析表项,支持三种格式: + 格式1(管道分隔): "flask_article|文章管理表" + 格式2(纯表名): "flask_article" + 格式3(字典): {"name": "flask_article", "comment": "文章管理表"} + + Args: + item: 单个表项 + + Returns: + tuple: (table_name, table_comment) + """ + if isinstance(item, dict): + table_name = str(item.get("name", "")).strip() + table_comment = str(item.get("comment", "")).strip() + return table_name, table_comment + + if isinstance(item, str): + item = item.strip() + if "|" in item: + parts = item.split("|", 1) + return parts[0].strip(), parts[1].strip() if len(parts) > 1 else "" + return item, "" + + return str(item or "").strip(), "" + + +def _generate_one(table_name: str, table_comment: str): + """ + 生成单个表的后端模块代码 + + Args: + table_name: 数据库表名 + table_comment: 表注释(可为空) + + Raises: + BizError: 表不存在或无业务字段时抛出 + """ + # 延迟导入:避免模块加载时产生循环依赖 + from src.modules.generator.parser import generate_config_from_table + from src.modules.generator.code_generator import CodeGenerator + + # 步骤1:解析表结构,生成代码生成配置(含字段智能识别) + config = generate_config_from_table(table_name) + if not config or not config.get("fields"): + raise BizError(f"表 {table_name} 不存在或无业务字段") + + # 步骤2:用请求中的表注释覆盖模块中文名与业务描述 + # (例如 "flask_article|文章管理表" 中的「文章管理表」优先于表本身的注释) + if table_comment: + module_comment = table_comment.replace("表", "").strip() + config["module_comment"] = module_comment + config["business_description"] = f"标识系统中的{module_comment}数据" + config["business_description_detail"] = f"每个{module_comment}包含相关属性" + + # 步骤3:调用代码生成器(自动模式:写入三层模块文件 + 自动注册路由与模型) + CodeGenerator(config).generate(auto=True) diff --git a/src/modules/generator/templates/__init__.py.tpl b/src/modules/generator/templates/__init__.py.tpl new file mode 100644 index 0000000..c2c1f67 --- /dev/null +++ b/src/modules/generator/templates/__init__.py.tpl @@ -0,0 +1 @@ +"""{{ module_comment }}模块。""" diff --git a/src/modules/generator/templates/models.py.tpl b/src/modules/generator/templates/models.py.tpl new file mode 100644 index 0000000..969950e --- /dev/null +++ b/src/modules/generator/templates/models.py.tpl @@ -0,0 +1,64 @@ +""" +{{ module_comment }}模型模块 +============================================================= + +提供{{ module_comment }}的数据模型定义。 +""" + +from sqlalchemy import text + +from src.common.base_db import BaseDB +from src.common.base_model import BaseModel +from config import DB_PREFIX +from src.core.database import db + + +class {{ model_class_name }}(BaseModel, BaseDB): + """ + {{ module_comment }}模型 + + 继承自 BaseModel(通用字段)和 BaseDB(数据库操作方法)。 + + Attributes: + id: 主键ID(继承自 BaseModel) +{% for f in fields %} + {{ f.name }}: {{ f.comment }}({{ f.db_type }}) +{% endfor %} + """ + + # ============================================================ + # 表配置 + # ============================================================ + + # 设置表名 + __tablename__ = DB_PREFIX + "{{ app_name }}" + + # 表注释(BaseModel.__init_subclass__ 自动转为 __table_args__ 的 comment 参数) + __table_comment__ = "{{ module_comment_py }}表" + + # ============================================================ + # 字段定义 + # ============================================================ +{% for f in fields %}{{ f.column_block }}{% endfor %} + + # ============================================================ + # 魔法方法 + # ============================================================ + + def __repr__(self): + """ + 返回模型的字符串表示 + + Returns: + str: 格式为 "{{ module_comment }}:{...}" 的字符串 + """ + return '{{ module_comment_py }}:{}'.format(self.{{ display_field }}) + + def __str__(self): + """ + 返回模型的字符串表示 + + Returns: + str: 格式为 "{{ module_comment }}:{...}" 的字符串 + """ + return self.__repr__() diff --git a/src/modules/generator/templates/repository.py.tpl b/src/modules/generator/templates/repository.py.tpl new file mode 100644 index 0000000..defec70 --- /dev/null +++ b/src/modules/generator/templates/repository.py.tpl @@ -0,0 +1,59 @@ +""" +{{ module_comment }}仓储模块 +============================================================= + +数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 +""" + +from src.common.base_repository import BaseRepository +from src.common.constants.common import PAGE_SIZE +from src.modules.{{ app_name }}.models import {{ model_class_name }} + + +class {{ model_class_name }}Repository(BaseRepository): + """ + {{ module_comment }}数据仓储,提供{{ module_comment }}相关的数据库访问操作 + """ + + model = {{ model_class_name }} + + @classmethod + def page(cls, params: dict): + """ + {{ module_comment }}分页查询 + + 支持按筛选字段精确匹配、按模糊字段关键字搜索,按 {{ sort_field }} 字段排序。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 +{% for f in filter_fields %} + - {{ f.camel_name }}: {{ f.comment }}(精确匹配) +{% endfor %} +{% for f in like_fields %} + - {{ f.camel_name }}: {{ f.comment }}(模糊匹配) +{% endfor %} + Returns: + tuple: (items, total, pages) + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + filters={{ filters_py }}, + like_filters={{ like_filters_py }}, + order_by='{{ sort_field }}', + ) + + @classmethod + def list_enabled(cls, params=None): + """ + {{ module_comment }}启用列表(下拉选项等场景使用) + + Args: + params: 查询参数字典(可选) + + Returns: + list: {{ module_comment }}列表 + """ + return cls.find_list(order_by='{{ sort_field }}') diff --git a/src/modules/generator/templates/router.py.tpl b/src/modules/generator/templates/router.py.tpl new file mode 100644 index 0000000..3d8f9d0 --- /dev/null +++ b/src/modules/generator/templates/router.py.tpl @@ -0,0 +1,213 @@ +""" +{{ module_comment }}控制器模块 +============================================================= + +提供{{ module_comment }}({{ model_class_name }})相关的API接口,包括: +- {{ module_comment }}分页查询、全部列表 +- {{ module_comment }}详情查询 +- {{ module_comment }}添加、更新、删除、批量删除 +{% if has_status_route %}- {{ module_comment }}状态设置 +{% endif %} +所属包: src.modules.{{ app_name }} +依赖模块: src.modules.{{ app_name }}.service - {{ module_comment }}业务逻辑层 +""" + +from flask import Blueprint, request + +from src.middleware import check_demo, operation_log +from src.core.permission import check_permission +from src.modules.{{ app_name }} import service as {{ app_name }} +from src.common.utils.request import parse_batch_ids, parse_request_body + +from src.core import response as R + +{{ app_name }}_blue = Blueprint("{{ app_name }}", __name__, url_prefix="/{{ app_name }}") + + +# ============================================================ +# {{ module_comment }}分页查询 +# ============================================================ + + +@{{ app_name }}_blue.route("/page", methods=["GET"]) +@check_permission("{{ permission_prefix }}:page") +@operation_log("{{ module_comment_py }}", "查询") +def page(): + """ + 查询{{ module_comment }}分页数据 + + 功能说明: + 获取{{ module_comment }}列表的分页数据,支持关键字搜索与筛选。 + + Returns: + JSON: {{ module_comment }}分页数据,包含 records(数据列表)、total(总记录数)等 + """ + return R.ok(data={{ app_name }}.get_{{ app_name }}_page(request.args)) + + +# ============================================================ +# {{ module_comment }}全部列表 +# ============================================================ + + +@{{ app_name }}_blue.route("/list", methods=["GET"]) +@check_permission("{{ permission_prefix }}:list") +@operation_log("{{ module_comment_py }}", "列表") +def list(): + """ + 获取{{ module_comment }}全部列表(下拉选项等场景使用) + + Returns: + JSON: {{ module_comment }}列表数据 + """ + return R.ok(data={{ app_name }}.get_{{ app_name }}_list(request.args)) + + +# ============================================================ +# {{ module_comment }}详情查询 +# ============================================================ + + +@{{ app_name }}_blue.route("/detail/", methods=["GET"]) +@check_permission("{{ permission_prefix }}:detail") +@operation_log("{{ module_comment_py }}", "详情") +def detail(id): + """ + 查询{{ module_comment }}详情 + + Args: + id: {{ module_comment }}ID + + Returns: + JSON: {{ module_comment }}详细信息 + """ + data = {{ app_name }}.get_{{ app_name }}_detail(id) + return R.ok(data=data) + + +# ============================================================ +# {{ module_comment }}添加 +# ============================================================ + + +@{{ app_name }}_blue.route("/add", methods=["POST"]) +@check_permission("{{ permission_prefix }}:add") +@check_demo +@operation_log("{{ module_comment_py }}", "新增") +def add(): + """ + 添加{{ module_comment }} + + 功能说明: + 创建新的{{ module_comment }}记录。 + + Returns: + JSON: 操作结果 + """ + json_data, err = parse_request_body() + if err: + return R.failed(msg=err) + {{ app_name }}.add_{{ app_name }}(json_data) + return R.ok(msg="添加成功") + + +# ============================================================ +# {{ module_comment }}更新 +# ============================================================ + + +@{{ app_name }}_blue.route("/update", methods=["PUT"]) +@check_permission("{{ permission_prefix }}:update") +@check_demo +@operation_log("{{ module_comment_py }}", "修改") +def update(): + """ + 更新{{ module_comment }} + + 功能说明: + 修改已有{{ module_comment }}的信息。 + + Returns: + JSON: 操作结果 + """ + json_data, err = parse_request_body() + if err: + return R.failed(msg=err) + {{ app_name }}.update_{{ app_name }}(json_data) + return R.ok(msg="更新成功") + + +# ============================================================ +# {{ module_comment }}删除 +# ============================================================ + + +@{{ app_name }}_blue.route("/delete/", methods=["DELETE"]) +@check_permission("{{ permission_prefix }}:delete") +@check_demo +@operation_log("{{ module_comment_py }}", "删除") +def delete(id): + """ + 删除{{ module_comment }} + + Args: + id: {{ module_comment }}ID(支持逗号分隔的多个ID) + + Returns: + JSON: 操作结果 + """ + count = {{ app_name }}.delete_{{ app_name }}s(id) + return R.ok(msg=f"本次共删除{count}条数据") + + +# ============================================================ +# {{ module_comment }}批量删除 +# ============================================================ + + +@{{ app_name }}_blue.route("/batchDelete", methods=["DELETE"]) +@check_permission("{{ permission_prefix }}:batchDelete") +@check_demo +@operation_log("{{ module_comment_py }}", "批量删除") +def batch_delete(): + """ + 批量删除{{ module_comment }} + + Request Body: + [1, 2, 3] # {{ module_comment }}ID数组 + + Returns: + JSON: 操作结果 + """ + id_str, err = parse_batch_ids() + if err: + return err + count = {{ app_name }}.delete_{{ app_name }}s(id_str) + return R.ok(msg=f"本次共删除{count}条数据") +{% if has_status_route %} + +# ============================================================ +# {{ module_comment }}状态设置 +# ============================================================ + + +@{{ app_name }}_blue.route("/status", methods=["PUT"]) +@check_permission("{{ permission_prefix }}:status") +@check_demo +@operation_log("{{ module_comment_py }}", "状态变更") +def status(): + """ + 设置{{ module_comment }}状态 + + 功能说明: + 启用或禁用{{ module_comment }}。 + + Returns: + JSON: 操作结果 + """ + json_data, err = parse_request_body() + if err: + return R.failed(msg=err) + {{ app_name }}.update_{{ app_name }}_status(json_data) + return R.ok(msg="更新成功") +{% endif %} diff --git a/src/modules/generator/templates/schema.py.tpl b/src/modules/generator/templates/schema.py.tpl new file mode 100644 index 0000000..311e881 --- /dev/null +++ b/src/modules/generator/templates/schema.py.tpl @@ -0,0 +1,85 @@ +""" +{{ module_comment }}表单验证模块 +============================================================= + +提供{{ module_comment }}相关的表单验证功能。 +""" + +from wtforms import {{ form_imports | join(', ') }} +from wtforms.validators import {{ validator_imports | join(', ') }} + +from src.common.forms.base_form import BaseForm + + +# ============================================================ +# {{ module_comment }}表单验证(添加和更新共用) +# ============================================================ + +class {{ model_class_name }}Form(BaseForm): + """ + {{ module_comment }}表单验证类 + + 用于添加和更新{{ module_comment }}时的数据验证。 + 添加时 id 为 None,更新时 id 必须有值。 + + Attributes: + id: {{ module_comment }}ID(更新时必填,添加时可选) +{% for f in form_fields %} + {{ f.camel_name }}: {{ f.comment }}({{ f.form_type }}) +{% endfor %} + """ + + # ============================================================ + # {{ module_comment }}ID + # ============================================================ + id = IntegerField( + label='{{ module_comment_py }}ID', + validators=[ + Optional(), # 允许为空(添加时) + NumberRange(min=1, message='{{ module_comment_py }}ID必须大于0') + ], + description='{{ module_comment_py }}唯一标识,更新时必填,添加时自动生成' + ) +{% for f in form_fields %}{{ f.field_block }}{% endfor %} +{% if has_status_route %} + + +# ============================================================ +# 状态设置表单 +# ============================================================ + +class {{ model_class_name }}StatusForm(BaseForm): + """ + {{ module_comment }}状态设置表单验证类 + + 用于单独修改{{ module_comment }}状态时的数据验证。 + + Attributes: + id: {{ module_comment }}ID(必填,必须大于0) + status: {{ module_comment }}状态(必填,0-禁用 1-正常) + """ + + # ============================================================ + # {{ module_comment }}ID + # ============================================================ + id = IntegerField( + label='{{ module_comment_py }}ID', + validators=[ + DataRequired(message='{{ module_comment_py }}ID不能为空'), + NumberRange(min=1, message='{{ module_comment_py }}ID必须大于0') + ], + description='要修改状态的{{ module_comment_py }}ID' + ) + + # ============================================================ + # {{ module_comment }}状态 + # ============================================================ + status = IntegerField( + label='{{ module_comment_py }}状态', + validators=[ + DataRequired(message='{{ module_comment_py }}状态不能为空'), + NumberRange(min=0, max=1, message='{{ module_comment_py }}状态值在0~1之间') + ], + description='目标状态:0-禁用,1-正常' + ) +{% endif %} diff --git a/src/modules/generator/templates/service.py.tpl b/src/modules/generator/templates/service.py.tpl new file mode 100644 index 0000000..4d82774 --- /dev/null +++ b/src/modules/generator/templates/service.py.tpl @@ -0,0 +1,131 @@ +""" +{{ module_comment }}服务模块 +============================================================= + +业务逻辑层,继承 BaseService 获得标准 CRUD 能力。 +""" + +from src.common.base_service import BaseService +{% if file_fields %} +from src.common.utils.rich_text import get_file_url, save_file +from src.common.utils.model_utils import model_to_dict +{% endif %} +from src.modules.{{ app_name }}.repository import {{ model_class_name }}Repository +from src.modules.{{ app_name }}.schema import {{ schema_imports_py }} + + +class {{ model_class_name }}Service(BaseService): + """ + {{ module_comment }}服务类 + + 继承 BaseService 获得标准 CRUD 能力。 + """ + + repository = {{ model_class_name }}Repository + form = {{ model_class_name }}Form +{% if has_status_route %} + status_form = {{ model_class_name }}StatusForm +{% endif %} + model_name = "{{ module_comment_py }}" +{% if file_fields %} + + @classmethod + def before_add(cls, form): + """ + 新增前钩子 + + 处理文件字段:截取域名前缀,迁移临时文件到正式目录。 + + Args: + form: 已校验的表单对象 + """ + _handle_file_fields(form) + + @classmethod + def before_update(cls, form, obj): + """ + 更新前钩子 + + 处理文件字段:截取域名前缀,迁移临时文件到正式目录。 + + Args: + form: 已校验的表单对象 + obj: 已存在的模型实例 + """ + _handle_file_fields(form) + + @classmethod + def _enrich_page_records(cls, records): + """ + 分页富化钩子 + + 为文件字段拼接 URL 前缀。 + + Args: + records: 字典列表 + + Returns: + list: 富化后的字典列表 + """ + for data in records: +{% for field in file_fields %} + data["{{ field }}"] = get_file_url(data.get("{{ field }}")) +{% endfor %} + return records + + @classmethod + def _detail_dict(cls, obj): + """ + 详情序列化钩子 + + 为文件字段拼接 URL 前缀。 + + Args: + obj: 模型实例 + + Returns: + dict: 序列化后的字典 + """ + data = model_to_dict(obj) +{% for field in file_fields %} + data["{{ field }}"] = get_file_url(data.get("{{ field }}")) +{% endfor %} + return data +{% endif %} + + +{% if file_fields %} +def _handle_file_fields(form): + """ + 文件字段处理 + + 截取域名前缀,迁移临时文件到正式目录。 + save_file 处理逻辑: + - 含 /temp 的 URL:迁移临时文件到正式目录,返回相对路径 + - 已是正式路径(含 FLASK_FILE_URL 前缀):去掉前缀返回相对路径 + - 相对路径(无协议头):原样保留 + + Args: + form: 表单对象 + """ +{% for field in file_fields %} + {{ field }} = form.data.get("{{ field }}") + if {{ field }}: + form.{{ field }}.data = save_file({{ field }}, "{{ app_name }}") +{% endfor %} +{% endif %} + +# ============================================================ +# 模块级代理函数:保持 router 调用兼容 +# ============================================================ + +get_{{ app_name }}_page = {{ model_class_name }}Service.get_page +get_{{ app_name }}_list = {{ model_class_name }}Service.get_list +get_{{ app_name }}_detail = {{ model_class_name }}Service.get_detail +get_info = {{ model_class_name }}Service.get_info +add_{{ app_name }} = {{ model_class_name }}Service.add +update_{{ app_name }} = {{ model_class_name }}Service.update +delete_{{ app_name }}s = {{ model_class_name }}Service.remove +{% if has_status_route %} +update_{{ app_name }}_status = {{ model_class_name }}Service.update_status +{% endif %} diff --git a/src/modules/generator/templates/ui/api.ts.tpl b/src/modules/generator/templates/ui/api.ts.tpl new file mode 100644 index 0000000..08f3b28 --- /dev/null +++ b/src/modules/generator/templates/ui/api.ts.tpl @@ -0,0 +1,83 @@ +import { http } from '@/utils/http/axios'; + +/** + * @description: {{ module_comment }}分页列表 + */ +export function get{{ model_class_name }}Page(params?) { + return http.request({ + url: '/{{ route_prefix }}/page', + method: 'GET', + params, + }); +} + +/** + * 获取全部{{ module_comment }}列表 + * @param params 参数 + * @returns 返回结果 + */ +export function get{{ model_class_name }}List(params?) { + return http.request({ + url: '/{{ route_prefix }}/list', + method: 'GET', + params, + }); +} + +/** + * @description: 根据ID获取{{ module_comment }}详情 + * @param {{ primary_key }} {{ module_comment }}ID + */ +export function get{{ model_class_name }}Detail({{ primary_key }}) { + return http.request({ + url: '/{{ route_prefix }}/detail/' + {{ primary_key }}, + method: 'get', + }); +} + +/** + * @description: 添加{{ module_comment }} + * @param data {{ module_comment }}数据 + */ +export function {{ model_class_name_camel }}Add(data: any) { + return http.request({ + url: '/{{ route_prefix }}/add', + method: 'POST', + data, + }); +} + +/** + * @description: 更新{{ module_comment }} + * @param data {{ module_comment }}数据 + */ +export function {{ model_class_name_camel }}Update(data: any) { + return http.request({ + url: '/{{ route_prefix }}/update', + method: 'PUT', + data, + }); +} + +/** + * @description: 删除{{ module_comment }} + * @param {{ primary_key }} {{ module_comment }}ID + */ +export function {{ model_class_name_camel }}Delete({{ primary_key }}) { + return http.request({ + url: '/{{ route_prefix }}/delete/' + {{ primary_key }}, + method: 'DELETE', + }); +} + +/** + * @description: 批量删除{{ module_comment }} + * @param data {{ module_comment }}ID数组 + */ +export function {{ model_class_name_camel }}BatchDelete(data: any) { + return http.request({ + url: '/{{ route_prefix }}/batchDelete', + method: 'DELETE', + data, + }); +} diff --git a/src/modules/generator/templates/ui/columns.ts.tpl b/src/modules/generator/templates/ui/columns.ts.tpl new file mode 100644 index 0000000..68895dd --- /dev/null +++ b/src/modules/generator/templates/ui/columns.ts.tpl @@ -0,0 +1,92 @@ +import { h } from 'vue'; +import { ElAvatar, ElTag } from 'element-plus'; + +/** + * 表格列配置 + * 定义表格显示的列 + */ +export const columns = [ + { + type: 'selection', // 多选框列 + }, + { + label: 'ID', + prop: 'id', + fixed: 'left', + width: 50, + }, +{% for field in list_fields %} + { + label: '{{ field.label }}', + prop: '{{ field.camel_name }}', + minWidth: {% if field.type == 'CharField' %}120{% elif field.type == 'IntegerField' %}100{% elif field.type == 'DateTimeField' %}180{% else %}120{% endif %}, +{% if field.is_file %} + // 图片渲染 + render(record) { + return h(ElAvatar, { + size: 48, + src: record.row.{{ field.camel_name }}, + shape: 'square', + fit: 'fill', + }); + }, +{% elif field.choices %} + // 状态标签渲染 + render(record) { + const statusMap = { + {% for choice in field.choices %} + {{ choice.value }}: { text: '{{ choice.label }}', type: {% if choice.value == 1 %}'success'{% elif choice.value == 2 %}'danger'{% elif choice.value == 3 %}'warning'{% else %}'info'{% endif %} }, + {% endfor %} + }; + const status = statusMap[record.row.{{ field.camel_name }}]; + return h( + ElTag, + { + type: status?.type || 'info', + }, + { + default: () => status?.text || record.row.{{ field.camel_name }}, + }, + ); + }, +{% elif field.camel_name == 'status' %} + // 状态标签渲染 + render(record) { + return h( + ElTag, + { + type: record.row.status == 1 ? 'success' : 'danger', + }, + { + default: () => (record.row.status == 1 ? '正常' : '停用'), + }, + ); + }, +{% elif field.type == 'CharField' and field.camel_name|lower in ['website', 'site', 'url', 'link'] %} + // 链接渲染 + render(record) { + return h( + 'a', + { + href: record.row.{{ field.camel_name }}, + target: '_blank', + }, + '点击查看', + ); + }, +{% else %} + // 普通文本渲染 +{% endif %} + }, +{% endfor %} + { + label: '创建人', + prop: 'createUser', + minWidth: 100, + }, + { + label: '创建时间', + prop: 'createTime', + width: 180, + }, +]; \ No newline at end of file diff --git a/src/modules/generator/templates/ui/detail.vue.tpl b/src/modules/generator/templates/ui/detail.vue.tpl new file mode 100644 index 0000000..1f5a351 --- /dev/null +++ b/src/modules/generator/templates/ui/detail.vue.tpl @@ -0,0 +1,184 @@ + + + + + \ No newline at end of file diff --git a/src/modules/generator/templates/ui/edit.vue.tpl b/src/modules/generator/templates/ui/edit.vue.tpl new file mode 100644 index 0000000..5e63aa4 --- /dev/null +++ b/src/modules/generator/templates/ui/edit.vue.tpl @@ -0,0 +1,383 @@ + + + \ No newline at end of file diff --git a/src/modules/generator/templates/ui/index.vue.tpl b/src/modules/generator/templates/ui/index.vue.tpl new file mode 100644 index 0000000..e523da5 --- /dev/null +++ b/src/modules/generator/templates/ui/index.vue.tpl @@ -0,0 +1,278 @@ + + + + + \ No newline at end of file diff --git a/src/modules/generator/templates/ui/querySchemas.ts.tpl b/src/modules/generator/templates/ui/querySchemas.ts.tpl new file mode 100644 index 0000000..927ba63 --- /dev/null +++ b/src/modules/generator/templates/ui/querySchemas.ts.tpl @@ -0,0 +1,29 @@ +import { FormSchema } from '@/components/Form/index'; + +/** + * 查询表单配置 + * 定义搜索区域的表单项 + */ +export const schemas: FormSchema[] = [ +{% for field in filter_fields %} + { + field: '{{ field.name }}', + component: '{% if field.type == 'CharField' %}Input{% elif field.choices %}Select{% elif field.type == 'IntegerField' and not field.choices %}InputNumber{% elif field.type == 'DateField' %}DatePicker{% else %}Input{% endif %}', + label: '{{ field.label }}', + componentProps: { + placeholder: '请输入{{ field.label }}', + clearable: true, +{% if field.choices %} + options: [ +{% for choice in field.choices %} + { + label: '{{ choice.label }}', + value: {{ choice.value }}, + }, +{% endfor %} + ], +{% endif %} + }, + }, +{% endfor %} +]; \ No newline at end of file diff --git a/src/modules/generator/templates/ui2/detail.vue.tpl b/src/modules/generator/templates/ui2/detail.vue.tpl new file mode 100644 index 0000000..1f5a351 --- /dev/null +++ b/src/modules/generator/templates/ui2/detail.vue.tpl @@ -0,0 +1,184 @@ + + + + + \ No newline at end of file diff --git a/src/modules/generator/templates/ui2/edit.vue.tpl b/src/modules/generator/templates/ui2/edit.vue.tpl new file mode 100644 index 0000000..49e8958 --- /dev/null +++ b/src/modules/generator/templates/ui2/edit.vue.tpl @@ -0,0 +1,367 @@ + + + + + + \ No newline at end of file diff --git a/src/modules/generator/templates/ui2/index.vue.tpl b/src/modules/generator/templates/ui2/index.vue.tpl new file mode 100644 index 0000000..f34a434 --- /dev/null +++ b/src/modules/generator/templates/ui2/index.vue.tpl @@ -0,0 +1,245 @@ + + + + + \ No newline at end of file diff --git a/src/modules/job/executor.py b/src/modules/job/executor.py index bc9c4af..277e0f6 100644 --- a/src/modules/job/executor.py +++ b/src/modules/job/executor.py @@ -3,6 +3,7 @@ ============================================================= 该模块实现了根据触发器标识执行对应任务的功能。 +任务执行器负责将调度器触发的任务分派到具体的业务处理器执行。 """ import logging @@ -21,20 +22,21 @@ class JobExecutor: 任务执行器 根据触发器标识调用对应的内部方法执行任务。 + 支持动态注册任务处理器,实现任务与执行逻辑的解耦。 """ def __init__(self): - """初始化执行器,注册默认任务""" + """初始化执行器,注册默认任务处理器""" self._handlers = {} self._register_default_handlers() def _register_default_handlers(self): """注册默认的任务处理器""" - # 示例:短信发送任务 + # 短信发送任务 self.register("send_sms", self._send_sms) - # 示例:邮件发送任务 + # 邮件发送任务 self.register("send_email", self._send_email) - # 示例:数据同步任务 + # 数据同步任务 self.register("sync_data", self._sync_data) def register(self, trigger: str, handler): @@ -42,8 +44,8 @@ class JobExecutor: 注册任务处理器 Args: - trigger: 触发器标识 - handler: 处理函数 + trigger: 触发器标识,与任务配置中的触发器字段对应 + handler: 处理函数,接收 **kwargs 参数并返回执行结果 """ self._handlers[trigger] = handler logger.info(f"注册任务处理器: {trigger}") @@ -52,12 +54,17 @@ class JobExecutor: """ 执行任务 + 根据触发器标识查找对应的处理器并执行。 + Args: trigger: 触发器标识 - **kwargs: 执行参数 + **kwargs: 执行参数,由任务配置中的参数决定 Returns: Any: 执行结果 + + Raises: + KeyError: 未找到对应触发器的方法时抛出 """ if trigger not in self._handlers: logger.error(f"未找到触发器对应的方法: {trigger}") @@ -75,11 +82,18 @@ class JobExecutor: """ 发送短信任务 + 调用短信服务发送短信。 + Args: - **kwargs: 包含 phone, template_code, template_params, sign_name 等参数 + **kwargs: 任务参数 + - phone: 接收手机号 + - template_code: 短信模板编码 + - template_params: 模板参数 + - sign_name: 短信签名 + - job_id: 任务ID(由调度器自动传入) Returns: - Dict: 执行结果 + Dict: 执行结果,包含 success、message 等字段 """ from src.modules.job import sms_service @@ -109,11 +123,19 @@ class JobExecutor: """ 发送邮件任务 + 调用邮件服务发送邮件。 + Args: - **kwargs: 包含 to, subject, body, html 等参数 + **kwargs: 任务参数 + - to: 收件人地址 + - subject: 邮件主题 + - body: 邮件正文(纯文本) + - html: 邮件正文(HTML格式) + - cc: 抄送地址 + - job_id: 任务ID(由调度器自动传入) Returns: - Dict: 执行结果 + Dict: 执行结果,包含 success、message 等字段 """ from src.modules.job import mail_service @@ -139,11 +161,17 @@ class JobExecutor: """ 数据同步任务 + 执行数据同步操作。 + Args: - **kwargs: 包含 source, target, sync_type 等参数 + **kwargs: 任务参数 + - source: 数据源标识 + - target: 目标数据源标识 + - sync_type: 同步类型(全量/增量) + - job_id: 任务ID(由调度器自动传入) Returns: - Dict: 执行结果 + Dict: 执行结果,包含 success、message 等字段 """ source = kwargs.get("source") target = kwargs.get("target") @@ -152,10 +180,11 @@ class JobExecutor: logger.info(f"数据同步 [任务ID: {job_id}, 源: {source}, 目标: {target}, 类型: {sync_type}]") - # TODO: 调用实际的数据同步服务 + # 此处调用实际的数据同步服务 + # TODO: 实现具体的数据同步逻辑 return {"success": True, "message": "数据同步成功", "source": source, "target": target} -# 创建全局执行器实例 +# 创建全局执行器单例实例 job_executor = JobExecutor() diff --git a/src/modules/job/mail_service.py b/src/modules/job/mail_service.py index 9ff640a..2e9f8df 100644 --- a/src/modules/job/mail_service.py +++ b/src/modules/job/mail_service.py @@ -43,18 +43,21 @@ def send_mail( """ 发送邮件 + 支持纯文本和HTML两种格式,收件人、抄送、密送均支持多个地址。 + Args: - to: 收件人地址,字符串或列表 + to: 收件人地址,字符串或列表 subject: 邮件主题 - body: 纯文本正文(与 html 二选一,都提供时邮件客户端自行选择显示) - html: HTML 正文 - cc: 抄送地址 - bcc: 密送地址 + body: 纯文本正文(与 html 二选一,都提供时邮件客户端自行选择显示) + html: HTML 正文 + cc: 抄送地址 + bcc: 密送地址 reply_to: 回复地址 Returns: bool: 发送成功返回 True,失败返回 False """ + # 参数校验 if not to: logger.warning("发送邮件失败:收件人不能为空") return False @@ -67,7 +70,7 @@ def send_mail( logger.warning("发送邮件失败:正文不能为空") return False - # 统一转为列表 + # 统一转为列表格式 if isinstance(to, str): to = [to] if cc and isinstance(cc, str): @@ -76,6 +79,7 @@ def send_mail( bcc = [bcc] try: + # 构建邮件消息 msg = Message( subject=subject, recipients=to, diff --git a/src/modules/job/models.py b/src/modules/job/models.py index cfb6279..deacc1c 100644 --- a/src/modules/job/models.py +++ b/src/modules/job/models.py @@ -8,6 +8,7 @@ from src.common.base_db import BaseDB from src.common.base_model import BaseModel from config import DB_PREFIX +from sqlalchemy import text from src.core.database import db @@ -42,12 +43,15 @@ class Job(BaseModel, BaseDB): __tablename__ = DB_PREFIX + "job" + # 表注释 + __table_comment__ = "定时任务表" + # ============================================================ # 字段定义 # ============================================================ # 任务名称 - job_name = db.Column(db.String(255), nullable=False, comment="任务名称") + job_name = db.Column(db.String(255), nullable=False, index=True, comment="任务名称") # 任务别名 job_alias = db.Column(db.String(255), nullable=True, comment="任务别名") @@ -59,22 +63,28 @@ class Job(BaseModel, BaseDB): job_trigger = db.Column(db.String(255), nullable=True, comment="任务触发器") # 任务状态:0-未发布 1-运行中 2-暂停 3-删除 - status = db.Column(db.Integer, nullable=False, default=0, comment="任务状态:0-未发布 1-运行中 2-暂停 3-删除") + status = db.Column(db.Integer, nullable=False, default=0, + server_default=text('0'), index=True, comment="任务状态:0-未发布 1-运行中 2-暂停 3-删除") # Cron表达式 cron_expression = db.Column(db.String(255), nullable=True, comment="Cron表达式") # 执行策略:1-立即执行 2-执行一次 3-放弃执行 execute_policy = db.Column( - db.Integer, nullable=False, default=1, comment="执行策略:1-立即执行 2-执行一次 3-放弃执行" + db.Integer, nullable=False, default=1, + server_default=text('1'), comment="执行策略:1-立即执行 2-执行一次 3-放弃执行" ) # 是否同步任务:0-否 1-是 - is_sync = db.Column(db.Integer, nullable=False, default=0, comment="是否同步任务:0-否 1-是") + is_sync = db.Column(db.Integer, nullable=False, default=0, + server_default=text('0'), comment="是否同步任务:0-否 1-是") # 任务URL url = db.Column(db.String(255), nullable=True, comment="任务URL") + # 任务参数 + params = db.Column(db.Text, nullable=True, comment="任务参数") + # 任务备注 note = db.Column(db.String(255), nullable=True, comment="任务备注") diff --git a/src/modules/job/repository.py b/src/modules/job/repository.py index 21df901..31a2d46 100644 --- a/src/modules/job/repository.py +++ b/src/modules/job/repository.py @@ -6,10 +6,40 @@ Job 仓储模块 """ from src.common.base_repository import BaseRepository +from src.common.constants.common import PAGE_SIZE from src.modules.job.models import Job class JobRepository(BaseRepository): - """Job 仓储""" + """任务数据仓储,提供定时任务相关的数据库访问操作""" model = Job + + @classmethod + def page(cls, params: dict): + """ + 任务分页查询 + + 支持按任务名称模糊搜索,按状态和任务分组精确过滤,按创建时间倒序排列。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + - jobName: 任务名称(模糊匹配) + - status: 任务状态(精确匹配) + - jobGroup: 任务分组(精确匹配) + + Returns: + tuple: (items, total, pages) + - items: 当前页数据列表 + - total: 总记录数 + - pages: 总页数 + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + like_filters={'job_name': params.get('jobName')}, + filters={'status': params.get('status'), 'job_group': params.get('jobGroup')}, + order_desc='create_time', + ) diff --git a/src/modules/job/router.py b/src/modules/job/router.py index 977a542..40c0cf0 100644 --- a/src/modules/job/router.py +++ b/src/modules/job/router.py @@ -7,7 +7,7 @@ from flask import Blueprint, request -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.core.permission import check_permission from src.modules.job import service as job from src.common.utils.request import parse_batch_ids, parse_request_body @@ -24,6 +24,7 @@ job_blue = Blueprint("job", __name__, url_prefix="/job") @job_blue.route("/page", methods=["GET"]) @check_permission("sys:job:page") +@operation_log("定时任务", "查询") def page(): """查询定时任务分页数据""" return R.ok(data=job.get_job_page(request.args)) @@ -36,6 +37,7 @@ def page(): @job_blue.route("/detail/", methods=["GET"]) @check_permission("sys:job:detail") +@operation_log("定时任务", "详情") def detail(id): """查询定时任务详情""" data = job.get_job_detail(id) @@ -52,6 +54,7 @@ def detail(id): @job_blue.route("/add", methods=["POST"]) @check_permission("sys:job:add") @check_demo +@operation_log("定时任务", "新增") def add(): """添加定时任务""" json_data, err = parse_request_body() @@ -69,6 +72,7 @@ def add(): @job_blue.route("/update", methods=["PUT"]) @check_permission("sys:job:update") @check_demo +@operation_log("定时任务", "修改") def update(): """更新定时任务""" json_data, err = parse_request_body() @@ -86,6 +90,7 @@ def update(): @job_blue.route("/delete/", methods=["DELETE"]) @check_permission("sys:job:delete") @check_demo +@operation_log("定时任务", "删除") def delete(id): """删除定时任务""" count = job.delete_jobs(id) @@ -98,8 +103,9 @@ def delete(id): @job_blue.route("/batchDelete", methods=["DELETE"]) -@check_permission("sys:job:batch_delete") +@check_permission("sys:job:batchDelete") @check_demo +@operation_log("定时任务", "批量删除") def batch_delete(): """批量删除定时任务""" id_str, err = parse_batch_ids() @@ -117,6 +123,7 @@ def batch_delete(): @job_blue.route("/status", methods=["POST"]) @check_permission("sys:job:status") @check_demo +@operation_log("定时任务", "状态变更") def status(): """设置任务状态""" json_data, err = parse_request_body() @@ -131,9 +138,10 @@ def status(): # ============================================================ -@job_blue.route("/pause/", methods=["GET"]) +@job_blue.route("/pause/", methods=["PUT"]) @check_permission("sys:job:pause") @check_demo +@operation_log("定时任务", "暂停") def pause(id): """暂停任务""" job.pause_job(id) @@ -145,9 +153,10 @@ def pause(id): # ============================================================ -@job_blue.route("/resume/", methods=["GET"]) +@job_blue.route("/resume/", methods=["PUT"]) @check_permission("sys:job:resume") @check_demo +@operation_log("定时任务", "恢复") def resume(id): """恢复任务""" job.resume_job(id) @@ -159,9 +168,10 @@ def resume(id): # ============================================================ -@job_blue.route("/runOnce/", methods=["GET"]) +@job_blue.route("/runOnce/", methods=["POST"]) @check_permission("sys:job:runOnce") @check_demo +@operation_log("定时任务", "立即执行") def run_once(id): """立即执行一次任务""" return R.ok(data=job.run_job_once(id)) @@ -172,9 +182,10 @@ def run_once(id): # ============================================================ -@job_blue.route("/reload", methods=["GET"]) +@job_blue.route("/reload", methods=["POST"]) @check_permission("sys:job:reload") @check_demo +@operation_log("定时任务", "重新加载") def reload(): """重新加载所有任务到调度器""" return R.ok(data=job.reload_all_jobs()) @@ -187,6 +198,7 @@ def reload(): @job_blue.route("/sendSms", methods=["POST"]) @check_permission("sys:job:sendSms") +@operation_log("定时任务", "发送短信") def sendSms(): """ 发送测试短信 @@ -198,18 +210,25 @@ def sendSms(): "template_params": {"code": "123456"} } """ + from datetime import datetime from src.modules.job import sms_service from src.common.utils.request import get_json_data + print(f"[JobScheduler] sendSms 接口被触发 [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]") + data = get_json_data() if not data: + print("[JobScheduler] sendSms 请求参数为空") return R.failed("参数不能为空") + print(f"[JobScheduler] sendSms 请求参数: {data}") + phone = data.get("phone") template_code = data.get("template_code") template_params = data.get("template_params") if not phone or not template_code: + print(f"[JobScheduler] sendSms 参数缺失 phone={phone}, template_code={template_code}") return R.failed("手机号和模板CODE不能为空") success = sms_service.send_sms( diff --git a/src/modules/job/scheduler_service.py b/src/modules/job/scheduler_service.py index 0100b70..32fb12b 100644 --- a/src/modules/job/scheduler_service.py +++ b/src/modules/job/scheduler_service.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -50,6 +50,7 @@ import logging import sys import threading +import uuid from datetime import datetime from typing import Dict, Any, Optional @@ -60,6 +61,7 @@ from apscheduler.jobstores.base import JobLookupError from config import INTERNAL_API_KEY, FLASK_SITE_DOMAIN from src.common.utils.model_utils import model_to_dict +from src.core.redis_client import redis # ============================================================ # 全局变量和日志配置 @@ -72,6 +74,10 @@ logger = logging.getLogger(__name__) # 调度器实例(延迟初始化) _scheduler = None +# 调度线程内可用的 Flask app(init_scheduler 在 app 上下文内捕获,供写任务日志用)。 +# APScheduler 的 cron 回调运行在后台线程,没有请求上下文,写库前需手动推 app 上下文。 +_SCHEDULER_APP = None + # ============================================================ # 调度器实例获取函数 @@ -98,6 +104,46 @@ def get_scheduler(): # 创建全局调度器实例 scheduler = get_scheduler() +# ============================================================ +# 分布式任务锁(防重复执行) +# ============================================================ + +#: 锁最长持有时间(秒)。作为进程崩溃时的兜底,超时自动释放, +#: 避免长期占用导致任务被永久跳过。 +_JOB_LOCK_TTL_SECONDS = 300 + + +class _JobExecutionLock: + """ + 基于 Redis 的分布式任务锁。 + + 背景:调度器是进程内的 BackgroundScheduler,gunicorn 多 worker / 多实例 + 部署时每个进程都会注册并触发同一 cron 任务。通过 Redis SET NX EX 原子操作 + 保证同一 job_id 在同一触发周期内只有一个实例真正执行。 + + acquire() 失败(Redis 不可用)时降级放行,避免锁服务故障导致任务停摆。 + """ + + def __init__(self, job_id: int): + self.key = f"scheduler:job_lock:{job_id}" + self.token = uuid.uuid4().hex + + def acquire(self) -> bool: + try: + # 原子 SET key token NX EX ttl:并发下仅一个实例能成功 + return bool(redis.client.set(self.key, self.token, nx=True, ex=_JOB_LOCK_TTL_SECONDS)) + except Exception as e: + logger.error(f"获取调度任务锁异常 [ID: {self.key}]: {str(e)}") + return True + + def release(self): + try: + # 仅删除自己持有的锁,避免误删其他实例刚获取的新锁 + if redis.client.get(self.key) == self.token: + redis.client.delete(self.key) + except Exception as e: + logger.error(f"释放调度任务锁异常 [ID: {self.key}]: {str(e)}") + # ============================================================ # URL 构建工具函数 @@ -140,6 +186,68 @@ def build_full_url(url: str) -> str: return url +# ============================================================ +# 任务执行日志(每次实际执行后写入 flask_job_log) +# ============================================================ + + +def _extract_log_fields(result) -> tuple: + """从执行结果提取 (success, message, error) 三元组,兼容 URL/触发器两种返回形态。 + + - URL 任务:result = {"success": bool, "status_code":..., "data":...} + - 触发器任务:result = {"success": True, "data": {"success": bool, "message": ...}} + (真正成败在 data 里,由 executor 返回) + """ + if not isinstance(result, dict): + return True, str(result), None + success = result.get("success") + data = result.get("data") + if isinstance(data, dict) and "success" in data: + success = data.get("success") + message = result.get("message") or (data.get("message") if isinstance(data, dict) else None) or "" + error = result.get("error") + if not message and data is not None: + message = str(data) + return bool(success), str(message or ""), (str(error) if error else None) + + +def _write_job_log(job_id, success, message, error, start, end): + """在 app 上下文内写任务日志(尽力而为,写失败不影响任务执行结果)。""" + if _SCHEDULER_APP is None: + logger.error("调度器 app 未绑定,跳过任务日志写入 [ID: %s]", job_id) + return + try: + with _SCHEDULER_APP.app_context(): + from src.modules.job_log.service import write_job_log + write_job_log( + job_id, success=success, message=message, error=error, + start_time=start, end_time=end, + ) + except Exception as e: + logger.error(f"写入任务日志异常 [ID: {job_id}]: {e}") + + +def _execute_and_log(job_id, func, *args, **kwargs): + """执行任务并自动记录执行日志(开始/结束时间、耗时、结果、异常)。 + + 无论成功失败(含异常)都会写一条 flask_job_log;返回执行结果给调用方。 + """ + start = datetime.now() + try: + result = func(*args, **kwargs) + success, message, error = _extract_log_fields(result) + except Exception as e: + result = {"success": False, "error": str(e)} + success, message, error = False, str(e), str(e) + logger.error(f"执行任务异常 [ID: {job_id}]: {e}") + end = datetime.now() + _write_job_log(job_id, success, message, error, start, end) + elapsed = (end - start).total_seconds() + status = "成功" if success else "失败" + print(f"[JobScheduler] 任务 [ID: {job_id}] 执行{status},耗时 {elapsed:.2f}s,结果: {message or error}") + return result + + # ============================================================ # 任务执行核心函数 # ============================================================ @@ -167,28 +275,45 @@ def execute_job( Returns: Dict: 执行结果,包含 success 和 data/error 字段 """ + logger.debug(f"execute_job id={job_id} url={url} sync={is_sync} trigger={job_trigger} params={params}") + sys.stdout.flush() + + logger.info(f"执行定时任务 [ID: {job_id}, URL: {url}, Trigger: {job_trigger}]") + print(f"[JobScheduler] 开始执行任务 [ID: {job_id}, URL: {url}, Trigger: {job_trigger}, 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]") + + # 根据任务类型分发执行(同步路径持有分布式锁,防多实例重复执行) + if url: + if is_sync == 1: + return _run_with_job_lock(job_id, _execute_by_url, job_id, url, is_sync, params) + # 异步路径:父线程立即返回,锁由子线程获取(见 _send_async_request) + return _execute_by_url(job_id, url, is_sync, params) + + if job_trigger: + return _run_with_job_lock(job_id, _execute_by_trigger, job_trigger, job_id, params) + + # 无效的任务配置 + return {"success": False, "error": "任务既没有URL也没有触发器"} + + +def _run_with_job_lock(job_id: int, func, *args, **kwargs) -> Dict[str, Any]: + """ + 在分布式锁保护下执行任务函数,保证同一任务只被一个调度器实例执行。 + + 调用方负责传入实际执行函数;锁的获取/释放、异常兜底在此统一处理。 + """ + lock = _JobExecutionLock(job_id) + if not lock.acquire(): + logger.info(f"定时任务 [ID: {job_id}] 已被其他调度器实例执行,本次跳过") + print(f"[JobScheduler] 任务 [ID: {job_id}] 已被其他实例占用,本次跳过") + return {"success": False, "error": "任务已被其他调度器实例执行"} + try: - print(f"[scheduler] execute_job id={job_id} url={url} sync={is_sync} trigger={job_trigger} params={params}") - sys.stdout.flush() - - logger.info(f"执行定时任务 [ID: {job_id}, URL: {url}, Trigger: {job_trigger}]") - - # 根据任务类型分发执行 - if url: - # URL方式:发送HTTP请求 - result = _execute_by_url(job_id, url, is_sync, params) - elif job_trigger: - # 触发器方式:调用内部执行器 - result = _execute_by_trigger(job_trigger, job_id, params) - else: - # 无效的任务配置 - result = {"success": False, "error": "任务既没有URL也没有触发器"} - - return result - + return func(*args, **kwargs) except Exception as e: logger.error(f"执行任务异常 [ID: {job_id}]: {str(e)}") return {"success": False, "error": str(e)} + finally: + lock.release() # ============================================================ @@ -238,7 +363,7 @@ def _send_sync_request(url: str, data: Dict, job_id: int) -> Dict: Returns: Dict: 包含响应状态码和数据的执行结果 """ - try: + def _do() -> Dict: logger.info(f"发送同步请求 [ID: {job_id}, URL: {url}]") # 准备请求头 @@ -263,8 +388,8 @@ def _send_sync_request(url: str, data: Dict, job_id: int) -> Dict: return {"success": True, "status_code": response.status_code, "data": result_data} - except requests.exceptions.RequestException as e: - return {"success": False, "error": str(e)} + # 同步执行(真实干活的地方):成功/失败/异常都写任务日志 + return _execute_and_log(job_id, _do) def _send_async_request(url: str, data: Dict, job_id: int) -> Dict: @@ -285,19 +410,35 @@ def _send_async_request(url: str, data: Dict, job_id: int) -> Dict: def async_request(): """异步请求执行函数(在独立线程中运行)""" + # 多实例下每个 worker 都会触发该线程,必须加锁保证只执行一次 + lock = _JobExecutionLock(job_id) + if not lock.acquire(): + logger.info(f"异步任务 [ID: {job_id}] 已被其他调度器实例执行,本次跳过") + print(f"[JobScheduler] 异步任务 [ID: {job_id}] 已被其他实例占用,本次跳过") + return try: - headers = { - "Content-Type": "application/json", - "X-Scheduler-Key": INTERNAL_API_KEY, - } - resp = requests.post(url, json=data, timeout=30, headers=headers) - logger.info(f"异步任务完成 [ID: {job_id}, 状态码: {resp.status_code}]") + def _do() -> Dict: + headers = { + "Content-Type": "application/json", + "X-Scheduler-Key": INTERNAL_API_KEY, + } + resp = requests.post(url, json=data, timeout=30, headers=headers) + logger.info(f"异步任务完成 [ID: {job_id}, 状态码: {resp.status_code}]") + print(f"[JobScheduler] 异步任务完成 [ID: {job_id}, 状态码: {resp.status_code}]") + return {"success": True, "status_code": resp.status_code, + "message": f"异步任务完成,状态码 {resp.status_code}"} + + # 异步真实干活的地方:成功/失败/异常都写任务日志 + _execute_and_log(job_id, _do) except Exception as e: logger.error(f"异步任务异常 [ID: {job_id}]: {str(e)}") + finally: + lock.release() # 创建并启动独立线程执行异步请求 thread = threading.Thread(target=async_request) thread.start() + print(f"[JobScheduler] 异步任务已触发 [ID: {job_id}, URL: {url}]") return {"success": True, "status": "triggered"} @@ -316,13 +457,14 @@ def _execute_by_trigger(trigger: str, job_id: int, params: Optional[Dict] = None Returns: Dict: 执行结果 """ - try: + def _do() -> Dict: from src.modules.job.executor import job_executor result = job_executor.execute(trigger, job_id=job_id, **(params or {})) return {"success": True, "trigger": trigger, "data": result} - except Exception as e: - return {"success": False, "error": str(e)} + + # 触发器任务真实干活的地方:成功/失败/异常都写任务日志 + return _execute_and_log(job_id, _do) # ============================================================ @@ -361,7 +503,7 @@ def add_job_to_scheduler(job_dict: dict) -> bool: job_trigger = job_dict.get("jobTrigger") job_name = job_dict.get("jobName") - print(f"[scheduler] add_job id={job_id} status={job_status} cron='{job_cron}'") + logger.debug(f"add_job id={job_id} status={job_status} cron='{job_cron}'") # ============================================================ # 2. 参数校验 @@ -418,6 +560,7 @@ def add_job_to_scheduler(job_dict: dict) -> bool: ) logger.info(f"任务已添加到调度器 [ID: {job_id}, cron: {job_cron}]") + print(f"[JobScheduler] 任务已添加到调度器 [ID: {job_id}, 名称: {job_name}, cron: {job_cron}, 下次执行: {next_run}]") return True except Exception as e: @@ -528,6 +671,13 @@ def init_scheduler() -> int: Returns: int: 成功加载的任务数量 """ + global _SCHEDULER_APP + # 捕获 Flask app(供调度线程写任务日志时推 app 上下文;Scheduler.init_app 在 app 上下文内调用) + try: + from flask import current_app + _SCHEDULER_APP = current_app._get_current_object() + except Exception: + pass try: from src.modules.job.models import Job @@ -535,6 +685,7 @@ def init_scheduler() -> int: if not scheduler.running: scheduler.start() logger.info("调度器已启动") + print(f"[JobScheduler] 调度器已启动 [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]") # 查询所有启用的任务 jobs = Job.query.filter(Job.status == 1, Job.is_delete == 0).all() @@ -553,6 +704,7 @@ def init_scheduler() -> int: success_count += 1 logger.info(f"调度器初始化完成,加载 {success_count}/{len(job_dicts)} 个任务") + print(f"[JobScheduler] 调度器初始化完成,加载 {success_count}/{len(job_dicts)} 个任务") return success_count except Exception as e: @@ -572,6 +724,7 @@ def reload_scheduler() -> int: try: # 清空所有现有任务 scheduler.remove_all_jobs() + print(f"[JobScheduler] 开始重新加载所有定时任务 [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]") # 重新初始化 return init_scheduler() except Exception as e: diff --git a/src/modules/job/schema.py b/src/modules/job/schema.py index cf89aca..8efdd6f 100644 --- a/src/modules/job/schema.py +++ b/src/modules/job/schema.py @@ -9,7 +9,7 @@ from wtforms import ValidationError, IntegerField, StringField from wtforms.validators import NumberRange, DataRequired, Length, Optional from src.common.forms.base_form import BaseForm -from src.modules.job.models import Job +from src.modules.job.repository import JobRepository # ============================================================ @@ -62,7 +62,7 @@ class JobForm(BaseForm): # 任务状态 status = IntegerField( label="任务状态", - validators=[DataRequired(message="任务状态不能为空"), NumberRange(min=0, max=3, message="任务状态值在0~3之间")], + validators=[Optional(), NumberRange(min=0, max=3, message="任务状态值在0~3之间")], description="任务状态:0-未发布 1-运行中 2-暂停 3-删除", ) @@ -84,7 +84,7 @@ class JobForm(BaseForm): isSync = IntegerField( label="是否同步任务", validators=[ - DataRequired(message="是否同步任务不能为空"), + Optional(), # 允许 0(异步) NumberRange(min=0, max=1, message="是否同步任务值在0~1之间"), ], description="是否同步任务:0-异步 1-同步", @@ -110,12 +110,7 @@ class JobForm(BaseForm): return current_id = self.id.data if self.id.data else None - query = Job.query.filter(Job.job_name == field.data, Job.is_delete == 0) - - if current_id: - query = query.filter(Job.id != current_id) - - if query.first(): + if JobRepository.exists_by({'job_name': field.data}, exclude_id=current_id): raise ValidationError("任务名称已存在") diff --git a/src/modules/job/service.py b/src/modules/job/service.py index 21bf85e..bdd9de2 100644 --- a/src/modules/job/service.py +++ b/src/modules/job/service.py @@ -3,14 +3,16 @@ ============================================================= 该模块实现了定时任务管理相关的所有业务逻辑。 +任务用于周期性地执行系统业务操作,支持Cron表达式配置执行时间, +可通过调度器实现任务的暂停、恢复、立即执行等操作。 """ import logging - -from src.common.constants.common import PAGE_SIZE +from src.common.base_service import BaseService +from src.common.utils.model_utils import model_to_dict from src.modules.job.schema import JobForm, JobStatusForm -from src.modules.job.models import Job +from src.modules.job.repository import JobRepository from src.core.exceptions import BizError from src.modules.job.scheduler_service import ( add_job_to_scheduler, @@ -19,269 +21,261 @@ from src.modules.job.scheduler_service import ( run_job_once as scheduler_run_job_once, reload_scheduler, ) -from src.common.utils import regular -from src.common.utils.model_utils import models_to_list, model_to_dict -from src.common.utils.request import parse_id_list, like_contains -from src.core.security import get_username +from src.core.security import get_realname +from src.modules.dictionary import dict_utils logger = logging.getLogger(__name__) -# ============================================================ -# 任务分页查询 -# ============================================================ +class JobService(BaseService): + """ + 定时任务服务类 + 继承 BaseService 获得标准 CRUD 能力。 + 新增/删除/更新/状态设置均与调度器联动,保持任务配置与调度器状态一致。 + """ -def get_job_page(params: dict) -> dict: - """查询定时任务分页数据""" - page_no = int(params.get("pageNo", 1)) - page_size = int(params.get("pageSize", PAGE_SIZE)) + repository = JobRepository + form = JobForm + status_form = JobStatusForm + model_name = "任务" - query = Job.query.filter(Job.is_delete == 0) + @classmethod + def _enrich_page_records(cls, records): + """ + 分页富化钩子 - # 筛选条件 - job_name = params.get("jobName") - if job_name: - query = query.filter(Job.job_name.like(like_contains(job_name), escape="\\")) + 补充状态显示文本和执行策略显示文本。 - status = params.get("status") - if status: - query = query.filter(Job.status == status) + Args: + records: 任务字典列表 - job_group = params.get("jobGroup") - if job_group: - query = query.filter(Job.job_group == job_group) + Returns: + list: 富化后的任务字典列表 + """ + for record in records: + record["statusText"] = dict_utils.get_name("job_status", record.get("status")) + record["executePolicyText"] = dict_utils.get_name("job_execute_policy", record.get("executePolicy")) + return records - total = query.count() - pages = (total + page_size - 1) // page_size if total > 0 else 0 + @classmethod + def after_add(cls, obj): + """ + 新增后钩子 - job_list = query.order_by(Job.create_time.desc()).limit(page_size).offset((page_no - 1) * page_size).all() + 若任务状态为运行中且配置了Cron表达式,则挂载到调度器。 - records = models_to_list(job_list) + Args: + obj: 已创建的任务模型实例 + """ + if obj.status == 1 and obj.cron_expression: + add_job_to_scheduler(model_to_dict(obj)) - # 添加状态文本 - for record in records: - record["statusText"] = _get_status_text(record.get("status")) - record["executePolicyText"] = _get_execute_policy_text(record.get("execute_policy")) + @classmethod + def before_delete(cls, ids): + """ + 删除前钩子 - page_data = {"records": records, "total": total, "size": page_size, "current": page_no, "pages": pages} - return page_data + 从调度器移除对应任务。 + Args: + ids: 待删除的任务ID列表 + """ + for job_id in ids: + remove_job_from_scheduler(job_id) -def _get_status_text(status): - """获取状态文本""" - status_map = {0: "未发布", 1: "运行中", 2: "暂停", 3: "删除"} - return status_map.get(status, "") + @classmethod + def update(cls, data: dict) -> None: + """ + 更新任务 + 更新任务信息后,根据状态和Cron表达式的变化同步调度器。 -def _get_execute_policy_text(policy): - """获取执行策略文本""" - policy_map = {1: "立即执行", 2: "执行一次", 3: "放弃执行"} - return policy_map.get(policy, "") + Args: + data: 任务数据字典,必须包含 id 字段 + Raises: + BizError: 表单校验失败、记录不存在或更新异常时抛出 + """ + form = cls._validate_form(data) -# ============================================================ -# 任务详情查询 -# ============================================================ + job_id = form.data.get("id") + if not job_id or int(job_id) <= 0: + raise BizError("任务ID无效") + job_id = int(job_id) - -def get_job_detail(job_id): - """查询定时任务详情""" - try: - job = Job.query.filter(Job.id == job_id, Job.is_delete == 0).first() + job = cls.get_info(job_id) if not job: - return None - return model_to_dict(job) - except Exception as e: - logger.error(f"查询任务详情异常: {str(e)}") - return None + raise BizError("任务不存在") + old_status = job.status + old_cron = job.cron_expression -# ============================================================ -# 任务添加 -# ============================================================ - - -def add_job(data: dict) -> None: - """添加定时任务""" - - form = JobForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - job = Job(**form.data) - job.create_user = get_username() - job.save() - - # 添加到调度器 - if job.status == 1 and job.cron_expression: - add_job_to_scheduler(job) - - -# ============================================================ -# 任务更新 -# ============================================================ - - -def update_job(data: dict) -> None: - """更新定时任务""" - - form = JobForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - job_id = form.data.get("id") - if not job_id: - raise BizError("任务ID无效") - - job = Job.query.filter(Job.id == job_id, Job.is_delete == 0).first() - if not job: - raise BizError("任务不存在") - - old_status = job.status - old_cron = job.cron_expression - - # 更新字段 - for key, value in form.data.items(): - if key != "id" and hasattr(job, key): + # 将表单数据逐字段写回 ORM 实例 + update_data = cls._clean_form_data(form) + for key, value in update_data.items(): setattr(job, key, value) - job.update_user = get_username() - job.save() + job.update_user = get_realname() + cls.repository.save(job) - # 处理调度器 - _handle_scheduler_update(job, old_status, old_cron) + _handle_scheduler_update(job, old_status, old_cron) + + @classmethod + def update_status(cls, data: dict) -> None: + """ + 更新任务状态 + + 修改任务状态后,根据目标状态同步调度器操作: + - 运行中:挂载到调度器 + - 暂停:暂停调度器中的任务 + - 其他状态:从调度器移除 + + Args: + data: 状态数据字典,包含 id 和 status 字段 + + Raises: + BizError: 表单校验失败、记录不存在或更新异常时抛出 + """ + form = cls._validate_form(data, use_status=True) + + job_id = int(form.data["id"]) + target_status = int(form.data["status"]) + + job = cls.get_info(job_id) + if not job: + raise BizError("任务不存在") + + old_status = job.status + job.status = target_status + job.update_user = get_realname() + cls.repository.save(job) + + if target_status == 1 and job.cron_expression: + add_job_to_scheduler(model_to_dict(job)) + elif target_status == 2: + pause_job_in_scheduler(job_id) + elif old_status == 1 and target_status != 1: + remove_job_from_scheduler(job_id) def _handle_scheduler_update(job, old_status, old_cron): - """处理调度器更新""" - # 变为运行中 + """ + 处理调度器更新 + + 根据任务状态和Cron表达式的变化,决定挂载、移除或重新加载任务。 + + Args: + job: 已更新字段的ORM实例 + old_status: 更新前的状态值 + old_cron: 更新前的Cron表达式 + """ + # 状态变为运行中且配置了Cron:挂载任务 if old_status != 1 and job.status == 1 and job.cron_expression: - add_job_to_scheduler(job) - # 从运行中变为其他状态 + add_job_to_scheduler(model_to_dict(job)) + # 从运行中变为其他状态:移除任务 elif old_status == 1 and job.status != 1: remove_job_from_scheduler(job.id) - # 运行中但Cron变化 + # 运行中但Cron表达式发生变化:重新挂载 elif job.status == 1 and old_cron != job.cron_expression: if job.cron_expression: - add_job_to_scheduler(job) + add_job_to_scheduler(model_to_dict(job)) else: remove_job_from_scheduler(job.id) # ============================================================ -# 任务删除 +# 模块级代理函数:保持 router 调用兼容 # ============================================================ - -def delete_jobs(job_ids: str) -> int: - """删除定时任务(批量)""" - if not job_ids: - raise BizError("记录ID不存在") - - id_list = parse_id_list(job_ids) - if not id_list: - raise BizError("无效的记录ID") - - for job_id in id_list: - remove_job_from_scheduler(job_id) - - job_ids_list = parse_id_list(job_ids) - if not job_ids_list: - raise BizError("无效的记录ID") - return Job.batch_delete(job_ids_list) +get_job_page = JobService.get_page +get_job_detail = JobService.get_detail +get_info = JobService.get_info +add_job = JobService.add +update_job = JobService.update +delete_jobs = JobService.remove +update_job_status = JobService.update_status # ============================================================ -# 任务状态设置 +# 暂停/恢复/立即执行/重载(任务特有逻辑) # ============================================================ - -def update_job_status(data: dict) -> None: - """设置任务状态""" - - form = JobStatusForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - job_id = int(form.data["id"]) - target_status = int(form.data["status"]) - - job = Job.query.filter(Job.id == job_id, Job.is_delete == 0).first() - if not job: - raise BizError("任务不存在") - - old_status = job.status - job.status = target_status - job.update_user = get_username() - job.save() - - # 处理调度器 - if target_status == 1 and job.cron_expression: - add_job_to_scheduler(job) - elif target_status == 2: - pause_job_in_scheduler(job_id) - elif old_status == 1 and target_status != 1: - remove_job_from_scheduler(job_id) - - -# ============================================================ -# 暂停任务 -# ============================================================ - - def pause_job(job_id): - """暂停任务""" - job = Job.query.filter(Job.id == job_id, Job.is_delete == 0).first() + """ + 暂停任务 + + 将任务状态设为暂停,并在调度器中暂停该任务。 + + Args: + job_id: 任务ID + + Raises: + BizError: 任务不存在时抛出 + """ + job = JobRepository.get_by_id(job_id) if not job: raise BizError("任务不存在") job.status = 2 - job.update_user = get_username() - job.save() + job.update_user = get_realname() + JobRepository.save(job) pause_job_in_scheduler(job_id) -# ============================================================ -# 恢复任务 -# ============================================================ - - def resume_job(job_id): - """恢复任务""" - job = Job.query.filter(Job.id == job_id, Job.is_delete == 0).first() + """ + 恢复任务 + + 将任务状态设为运行中,并在调度器中恢复该任务。 + + Args: + job_id: 任务ID + + Raises: + BizError: 任务不存在或Cron表达式为空时抛出 + """ + job = JobRepository.get_by_id(job_id) if not job: raise BizError("任务不存在") if not job.cron_expression: raise BizError("Cron表达式为空,无法恢复") job.status = 1 - job.update_user = get_username() - job.save() + job.update_user = get_realname() + JobRepository.save(job) - # 直接传入模型对象,与其它方法保持一致 - add_job_to_scheduler(job) - - -# ============================================================ -# 立即执行 -# ============================================================ + add_job_to_scheduler(model_to_dict(job)) def run_job_once(job_id): - """立即执行一次任务""" + """ + 立即执行一次任务 + + 在调度器中触发任务立即执行。 + + Args: + job_id: 任务ID + + Returns: + dict: 执行结果 + + Raises: + BizError: 执行失败时抛出 + """ result = scheduler_run_job_once(job_id) if not result.get("success"): raise BizError(result.get("error", "执行失败")) return result -# ============================================================ -# 重新加载调度器 -# ============================================================ - - def reload_all_jobs(): - """重新加载所有任务""" - count = reload_scheduler() - return count + """ + 重新加载所有任务 + + 从数据库重新读取所有任务并更新调度器。 + + Returns: + int: 成功加载的任务数量 + """ + return reload_scheduler() diff --git a/src/modules/job_log/__init__.py b/src/modules/job_log/__init__.py new file mode 100644 index 0000000..899708e --- /dev/null +++ b/src/modules/job_log/__init__.py @@ -0,0 +1 @@ +"""任务日志模块:独立表 flask_job_log,含模型/仓储/服务/路由。""" diff --git a/src/modules/job_log/models.py b/src/modules/job_log/models.py new file mode 100644 index 0000000..f8ccab3 --- /dev/null +++ b/src/modules/job_log/models.py @@ -0,0 +1,118 @@ +""" +任务日志模型模块 +============================================================= + +提供定时任务执行日志(JobLog)的数据模型定义,用于记录每个任务的每次执行情况 +(开始/结束时间、耗时、结果、异常信息等)。 +""" + +from src.common.base_db import BaseDB +from src.common.base_model import BaseModel +from config import DB_PREFIX +from sqlalchemy import text +from src.core.database import db + + +class JobLog(BaseModel, BaseDB): + """ + 任务日志模型 + + 用于记录定时任务的每次执行明细,包括任务基础信息、执行状态、耗时与异常。 + + Attributes: + id: 主键ID(继承自 BaseModel) + job_id: 任务ID(Integer,必填) + job_name: 任务名称(String(255),必填) + job_group: 任务分组(String(255),可选) + job_trigger: 任务触发器(String(255),可选) + job_message: 任务日志信息(String(500),可选) + cron_expression: Cron表达式(String(255),可选) + status: 执行状态(SmallInteger,0-正常 1-失败,必填) + start_time: 执行开始时间(DateTime,可选) + end_time: 执行结束时间(DateTime,可选) + consume_time: 执行耗时(BigInteger,单位毫秒,必填) + exception_info: 异常信息(Text,可选) + """ + + # ============================================================ + # 表配置 + # ============================================================ + + __tablename__ = DB_PREFIX + "job_log" + + # 表注释 + __table_comment__ = "任务日志表" + + # 高频筛选组合:job_id+status、start_time(执行时间范围)、job_id、status + __table_args__ = ( + db.Index('idx_job_status', 'job_id', 'status'), + db.Index('idx_start_time', 'start_time'), + db.Index('idx_job_id', 'job_id'), + db.Index('idx_status', 'status'), + ) + + # ============================================================ + # 字段定义 + # ============================================================ + + # 任务ID + job_id = db.Column(db.Integer, nullable=False, comment="任务ID") + + # 任务名称 + job_name = db.Column(db.String(255), nullable=False, index=True, comment="任务名称") + + # 任务分组 + job_group = db.Column(db.String(255), nullable=True, comment="任务分组") + + # 任务触发器 + job_trigger = db.Column(db.String(255), nullable=True, comment="任务触发器") + + # 任务日志信息 + job_message = db.Column(db.String(500), nullable=True, comment="任务日志信息") + + # Cron表达式 + cron_expression = db.Column(db.String(255), nullable=True, comment="Cron表达式") + + # 执行状态:0-正常 1-失败 + status = db.Column(db.SmallInteger, nullable=False, default=0, + server_default=text('0'), index=True, comment="执行状态:0-正常 1-失败") + + # 执行开始时间 + start_time = db.Column(db.DateTime, nullable=True, comment="执行开始时间") + + # 执行结束时间 + end_time = db.Column(db.DateTime, nullable=True, comment="执行结束时间") + + # 执行耗时(毫秒) + consume_time = db.Column(db.BigInteger, nullable=False, default=0, + server_default=text('0'), comment="执行耗时(毫秒)") + + # 异常信息 + exception_info = db.Column(db.Text, nullable=True, comment="异常信息") + + # ============================================================ + # 常量定义 + # ============================================================ + + class Status: + """执行状态常量""" + + NORMAL = 0 # 正常 + FAILED = 1 # 失败 + + # ============================================================ + # 魔法方法 + # ============================================================ + + def __repr__(self): + """ + 返回模型的字符串表示 + + Returns: + str: 格式为 "任务日志:{job_name}" 的字符串 + """ + return "任务日志:{}".format(self.job_name) + + def __str__(self): + """返回模型的字符串表示""" + return self.__repr__() diff --git a/src/modules/job_log/repository.py b/src/modules/job_log/repository.py new file mode 100644 index 0000000..d1ef09d --- /dev/null +++ b/src/modules/job_log/repository.py @@ -0,0 +1,52 @@ +""" +JobLog 仓储模块 +============================================================= + +数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 +""" + +from src.common.constants.common import PAGE_SIZE +from src.common.base_repository import BaseRepository +from src.modules.job_log.models import JobLog + + +class JobLogRepository(BaseRepository): + """任务日志数据仓储,提供任务日志相关的数据库访问操作""" + + model = JobLog + + @classmethod + def page(cls, params: dict): + """ + 任务日志分页查询 + + 支持按任务名称模糊搜索,按任务ID和状态精确过滤, + 按执行时间范围筛选,按创建时间倒序排列。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + - jobId: 任务ID(精确匹配) + - jobName: 任务名称(模糊匹配) + - status: 执行状态(精确匹配) + - startTime: 执行开始时间起始 + - endTime: 执行开始时间结束 + + Returns: + tuple: (items, total, pages) + - items: 当前页数据列表 + - total: 总记录数 + - pages: 总页数 + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + filters={ + 'job_id': params.get('jobId'), + 'status': params.get('status'), + }, + like_filters={'job_name': params.get('jobName')}, + range_filters={'start_time': (params.get('startTime'), params.get('endTime'))}, + order_desc='create_time', + ) diff --git a/src/modules/job_log/router.py b/src/modules/job_log/router.py new file mode 100644 index 0000000..4e59012 --- /dev/null +++ b/src/modules/job_log/router.py @@ -0,0 +1,108 @@ +""" +任务日志控制器模块 +============================================================= + +提供任务日志(JobLog)相关的API接口: +- 分页查询、详情查询、删除(支持批量) +""" + +from flask import Blueprint, request + +from src.middleware import check_demo, operation_log +from src.core.permission import check_permission +from src.modules.job_log import service as job_log +from src.common.utils.request import parse_batch_ids + +from src.core import response as R + +job_log_blue = Blueprint("job_log", __name__, url_prefix="/job/log") + + +# ============================================================ +# 任务日志分页查询 +# ============================================================ + + +@job_log_blue.route("/page", methods=["GET"]) +@check_permission("sys:job_log:page") +@operation_log("任务日志", "查询") +def page(): + """ + 查询任务日志分页数据 + + 支持按任务ID、任务名称、执行状态、执行时间范围等条件筛选。 + """ + return R.ok(data=job_log.get_job_log_page(request.args)) + + +# ============================================================ +# 任务日志详情查询 +# ============================================================ + + +@job_log_blue.route("/detail/", methods=["GET"]) +@check_permission("sys:job_log:detail") +@operation_log("任务日志", "详情") +def detail(id): + """ + 查询任务日志详情 + + Args: + id: 任务日志ID + + Returns: + JSON: 任务日志详细信息 + """ + data = job_log.get_job_log_detail(id) + if data: + return R.ok(data=data) + return R.failed("日志不存在") + + +# ============================================================ +# 任务日志删除 +# ============================================================ + + +@job_log_blue.route("/delete/", methods=["DELETE"]) +@check_permission("sys:job_log:delete") +@check_demo +@operation_log("任务日志", "删除") +def delete(id): + """ + 删除任务日志 + + Args: + id: 任务日志ID(支持逗号分隔的多个ID) + + Returns: + JSON: 操作结果 + """ + count = job_log.delete_job_logs(id) + return R.ok(msg=f"本次共删除{count}条数据") + + +# ============================================================ +# 任务日志批量删除 +# ============================================================ + + +@job_log_blue.route("/batchDelete", methods=["DELETE"]) +@check_permission("sys:job_log:batchDelete") +@check_demo +@operation_log("任务日志", "批量删除") +def batch_delete(): + """ + 批量删除任务日志 + + Request Body: + [1, 2, 3] # 任务日志ID数组 + + Returns: + JSON: 操作结果 + """ + id_str, err = parse_batch_ids() + if err: + return err + count = job_log.delete_job_logs(id_str) + return R.ok(msg=f"本次共删除{count}条数据") diff --git a/src/modules/job_log/service.py b/src/modules/job_log/service.py new file mode 100644 index 0000000..1169136 --- /dev/null +++ b/src/modules/job_log/service.py @@ -0,0 +1,107 @@ +""" +任务日志管理服务模块 +============================================================= + +该模块实现了定时任务执行日志(JobLog)的查询与清理业务逻辑: + - 分页/详情/删除由 BaseService 提供(只读 + 软删除,无新增/编辑) + - 分页富化补充执行状态文本(走字典表 job_log_status) + +写入说明:任务日志由定时任务调度器在任务执行时写入(见 job 模块 executor/scheduler), +本模块负责日志的查询展示与清理。 +""" + +from src.common.base_service import BaseService +from src.modules.job_log.repository import JobLogRepository +from src.modules.dictionary import dict_utils + + +class JobLogService(BaseService): + """ + 任务日志服务类 + + 继承 BaseService 获得标准分页/详情/删除能力,无表单校验。 + """ + + repository = JobLogRepository + model_name = "任务日志" + + @classmethod + def _enrich_page_records(cls, records): + """ + 分页富化钩子 + + 补充执行状态显示文本(走字典表 job_log_status 翻译)。 + + Args: + records: 任务日志字典列表 + + Returns: + list: 富化后的任务日志字典列表 + """ + status_map = dict_utils.get_value_map("job_log_status") + for record in records: + record["statusText"] = status_map.get(str(record.get("status")), "") + return records + + +# ============================================================ +# 模块级代理函数:保持 router 调用兼容 +# ============================================================ + +get_job_log_page = JobLogService.get_page +get_job_log_detail = JobLogService.get_detail +get_job_log_info = JobLogService.get_info +delete_job_logs = JobLogService.remove + + +# ============================================================ +# 任务日志写入(由 job 调度器在任务执行完成后调用) +# ============================================================ + +def write_job_log(job_id, success=True, message="", error=None, start_time=None, end_time=None) -> None: + """ + 任务执行完成后写入一条执行日志 + + 由 src/modules/job/scheduler_service.py 在每次任务实际执行后调用: + - 从 Job 表读取任务快照(job_name/job_group/job_trigger/cron_expression) + - 记录执行状态、耗时(毫秒)、日志信息与异常信息 + - 显式 commit(调度线程无 AOP 事务拦截器) + + Args: + job_id: 任务ID + success: 是否成功(True-正常,False-失败) + message: 任务日志信息 + error: 异常信息(失败时写入 exception_info) + start_time: 执行开始时间 + end_time: 执行结束时间(与 start_time 共同计算耗时) + """ + from src.core.database import db + from src.modules.job.models import Job + from src.modules.job_log.models import JobLog + from src.modules.job_log.repository import JobLogRepository + + # 查询任务信息作为日志快照 + job = Job.query.filter(Job.id == job_id, Job.is_delete == 0).first() + if not job: + return + + # 计算执行耗时(毫秒) + consume_time = 0 + if start_time and end_time: + consume_time = max(0, int((end_time - start_time).total_seconds() * 1000)) + + JobLogRepository.create( + job_id=job.id, + job_name=job.job_name or "", + job_group=job.job_group or "", + job_trigger=job.job_trigger or "", + job_message=(message or "")[:500], + cron_expression=job.cron_expression or "", + status=JobLog.Status.NORMAL if success else JobLog.Status.FAILED, + start_time=start_time, + end_time=end_time, + consume_time=consume_time, + exception_info=error, + create_user="system", + ) + db.session.commit() diff --git a/src/modules/log/login_log_repository.py b/src/modules/log/login_log_repository.py deleted file mode 100644 index 9686efc..0000000 --- a/src/modules/log/login_log_repository.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -LoginLog 仓储模块 -============================================================= - -数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 -""" - -from src.common.base_repository import BaseRepository -from src.modules.log.models import LoginLog - - -class LoginLogRepository(BaseRepository): - """LoginLog 仓储""" - - model = LoginLog diff --git a/src/modules/log/models.py b/src/modules/log/models.py deleted file mode 100644 index 42aa499..0000000 --- a/src/modules/log/models.py +++ /dev/null @@ -1,277 +0,0 @@ -""" -登录日志模型模块 -============================================================= - -提供登录日志管理的数据模型定义,用于记录用户登录、退出等操作日志。 -""" - -from src.common.base_db import BaseDB -from src.common.base_model import BaseModel -from config import DB_PREFIX -from src.core.database import db - - -# ============================================================ -# 登录日志模型 -# ============================================================ - - -class LoginLog(BaseModel, BaseDB): - """ - 登录日志模型 - - 用于记录用户登录和退出操作的详细信息,包括:登录账号、日志类型、 - 来源、请求信息、IP地址、归属地、设备信息、请求耗时、操作状态等。 - - Attributes: - id: 主键ID(继承自 BaseModel) - username: 登录账号(String(50),必填) - log_type: 日志类型(Integer,1-登录,2-退出,必填) - source: 登录来源(Integer,0-后台,1-前台,必填) - method: 请求方法(String(10),必填) - request_method: 请求方式(String(255),必填) - url: 请求URL(String(500),必填) - param: 请求参数(Text,可选) - result: 返回结果(Text,必填) - ip: IP地址(String(50),必填) - location: IP归属地(String(100),必填) - os: 操作系统(String(100),必填) - browser: 浏览器(String(100),必填) - consume_time: 请求耗时(Integer,必填) - status: 操作状态(Integer,0-正常,1-异常,必填) - error: 错误信息(Text,可选) - """ - - # ============================================================ - # 表配置 - # ============================================================ - - # 设置表名 - __tablename__ = DB_PREFIX + "login_log" - - # ============================================================ - # 字段定义 - # ============================================================ - - # 登录账号 - username = db.Column(db.String(50), nullable=False, comment="登录账号") - - # 日志类型:1-登录,2-退出 - log_type = db.Column(db.Integer, nullable=False, comment="日志类型:1-登录,2-退出") - - # 登录来源:0-后台,1-前台 - source = db.Column(db.Integer, nullable=False, comment="登录来源:0-后台,1-前台") - - # 请求方法(HTTP方法,如POST/GET) - method = db.Column(db.String(10), nullable=False, comment="请求方法") - - # 请求方式(视图方法路径,如application.views.login.logout) - request_method = db.Column(db.String(255), nullable=False, comment="请求方式") - - # 请求URL - url = db.Column(db.String(500), nullable=False, comment="请求URL") - - # 请求参数 - param = db.Column(db.Text, nullable=True, comment="请求参数") - - # 返回结果 - result = db.Column(db.Text, nullable=False, comment="返回结果") - - # IP地址 - ip = db.Column(db.String(50), nullable=False, comment="IP地址") - - # IP归属地 - location = db.Column(db.String(100), nullable=False, comment="IP归属地") - - # 操作系统 - os = db.Column(db.String(100), nullable=False, comment="操作系统") - - # 浏览器 - browser = db.Column(db.String(100), nullable=False, comment="浏览器") - - # 请求耗时(毫秒) - consume_time = db.Column(db.Integer, nullable=False, comment="请求耗时") - - # 操作状态:0-正常,1-异常 - status = db.Column(db.Integer, nullable=False, comment="操作状态:0-正常,1-异常") - - # 错误信息 - error = db.Column(db.Text, nullable=True, comment="错误信息") - - # ============================================================ - # 常量定义 - # ============================================================ - - class LogType: - """日志类型常量""" - - LOGIN = 1 # 登录 - LOGOUT = 2 # 退出 - - class Source: - """登录来源常量""" - - ADMIN = 0 # 后台 - FRONT = 1 # 前台 - - class Status: - """操作状态常量""" - - NORMAL = 0 # 正常 - ERROR = 1 # 异常 - - # ============================================================ - # 魔法方法 - # ============================================================ - - def __repr__(self): - """ - 返回模型的字符串表示 - - Returns: - str: 格式为 "登录日志:{username} - {log_type}" 的字符串 - """ - log_type_name = "登录" if self.log_type == self.LogType.LOGIN else "退出" - return "登录日志:{} - {}".format(self.username, log_type_name) - - def __str__(self): - """ - 返回模型的字符串表示 - - Returns: - str: 格式为 "登录日志:{username} - {log_type}" 的字符串 - """ - return self.__repr__() - - -""" -操作日志模型模块 - -与 LoginLog 共用顶部导入。 -""" - - -# ============================================================ -# 操作日志模型 -# ============================================================ - - -class OperationLog(BaseModel, BaseDB): - """ - 操作日志模型 - - 用于记录用户操作行为的详细信息,包括:操作标题、操作类型、 - 来源、请求信息、IP地址、归属地、设备信息、请求耗时、操作状态等。 - - Attributes: - id: 主键ID(继承自 BaseModel) - title: 请求标题(String(150),必填) - type: 请求类型(Integer,必填) - source: 请求来源(Integer,必填,0-后台,1-前台) - method: 请求方法(String(100),必填) - request_method: 请求方式(String(255),必填) - url: 请求URL(String(1000),必填) - param: 请求参数(Text,必填) - result: 请求结果(Text,必填) - ip: 请求IP(String(100),必填) - location: 请求所在区域(String(255),必填) - os: 操作系统(String(100),必填) - browser: 浏览器(String(100),必填) - consume_time: 请求耗时(Integer,必填,单位毫秒) - status: 请求状态(Integer,必填,0-正常,1-异常) - error: 错误描述(Text,必填) - """ - - # ============================================================ - # 表配置 - # ============================================================ - - # 设置表名 - __tablename__ = DB_PREFIX + "operation_log" - - # ============================================================ - # 字段定义 - # ============================================================ - - # 请求标题 - title = db.Column(db.String(150), nullable=False, comment="请求标题") - - # 请求类型 - type = db.Column(db.Integer, nullable=False, comment="请求类型") - - # 请求来源:0-后台,1-前台 - source = db.Column(db.Integer, nullable=False, comment="请求来源:0-后台,1-前台") - - # 请求方法(如:UserController.add) - method = db.Column(db.String(100), nullable=False, comment="请求方法") - - # 请求方式(如:GET、POST、PUT、DELETE) - request_method = db.Column(db.String(255), nullable=False, comment="请求方式") - - # 请求URL - url = db.Column(db.String(1000), nullable=False, comment="请求URL") - - # 请求参数 - param = db.Column(db.Text, nullable=False, comment="请求参数") - - # 请求结果 - result = db.Column(db.Text, nullable=False, comment="请求结果") - - # 请求IP - ip = db.Column(db.String(100), nullable=False, comment="请求IP") - - # 请求所在区域 - location = db.Column(db.String(255), nullable=False, comment="请求所在区域") - - # 操作系统 - os = db.Column(db.String(100), nullable=False, comment="操作系统") - - # 浏览器 - browser = db.Column(db.String(100), nullable=False, comment="浏览器") - - # 请求耗时(毫秒) - consume_time = db.Column(db.BigInteger, nullable=False, comment="请求耗时") - - # 请求状态:0-正常,1-异常 - status = db.Column(db.Integer, nullable=False, comment="请求状态:0-正常,1-异常") - - # 错误描述 - error = db.Column(db.Text, nullable=False, comment="错误描述") - - # ============================================================ - # 常量定义 - # ============================================================ - - class Source: - """请求来源常量""" - - ADMIN = 0 # 后台 - FRONT = 1 # 前台 - - class Status: - """请求状态常量""" - - NORMAL = 0 # 正常 - ERROR = 1 # 异常 - - # ============================================================ - # 魔法方法 - # ============================================================ - - def __repr__(self): - """ - 返回模型的字符串表示 - - Returns: - str: 格式为 "操作日志:{title} - {method}" 的字符串 - """ - return "操作日志:{} - {}".format(self.title, self.method) - - def __str__(self): - """ - 返回模型的字符串表示 - - Returns: - str: 格式为 "操作日志:{title} - {method}" 的字符串 - """ - return self.__repr__() diff --git a/src/modules/log/operation_log_repository.py b/src/modules/log/operation_log_repository.py deleted file mode 100644 index 1e50f41..0000000 --- a/src/modules/log/operation_log_repository.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -OperationLog 仓储模块 -============================================================= - -数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 -""" - -from src.common.base_repository import BaseRepository -from src.modules.log.models import OperationLog - - -class OperationLogRepository(BaseRepository): - """OperationLog 仓储""" - - model = OperationLog diff --git a/src/modules/log/router.py b/src/modules/log/router.py deleted file mode 100644 index e234300..0000000 --- a/src/modules/log/router.py +++ /dev/null @@ -1,255 +0,0 @@ -""" -登录日志管理控制器模块 -============================================================= - -提供登录日志(LoginLog)相关的API接口,包括: -- 登录日志分页查询 -- 登录日志详情查询 -- 登录日志删除(支持批量) -- 登录日志清空 -- 登录日志统计 -- 登录趋势分析 - -所属包: application.views -依赖模块: application.services.login_log - 登录日志业务逻辑层 - application.middleware.check_permission - 权限校验中间件 -""" - -from flask import Blueprint, request - -from src.core.middleware import check_demo -from src.core.permission import check_permission -from src.modules.log import service as login_log -from src.modules.log import service as operation_log -from src.common.utils.request import parse_batch_ids - -from src.core import response as R - -login_log_blue = Blueprint("login_log", __name__, url_prefix="/login/log") - - -# ============================================================ -# 登录日志分页查询 -# ============================================================ - - -@login_log_blue.route("/page", methods=["GET"]) -@check_permission("sys:login_log:page") -def page(): - """ - 查询登录日志分页数据 - - 功能说明: - 获取登录日志列表的分页数据,支持按账号、日志类型、来源、 - 状态、时间范围等条件筛选。 - - Query Parameters: - pageNo: 当前页码(可选,默认1) - pageSize: 每页记录数(可选,默认PAGE_SIZE) - username: 登录账号(可选,模糊查询) - logType: 日志类型(可选,1-登录,2-退出) - source: 登录来源(可选,0-后台,1-前台) - status: 操作状态(可选,0-正常,1-异常) - startTime: 开始时间(可选) - endTime: 结束时间(可选) - - Returns: - JSON: 登录日志分页数据,包含 records(数据列表)、total(总记录数)等 - """ - return R.ok(data=login_log.get_login_log_page(request.args)) - - -# ============================================================ -# 登录日志详情查询 -# ============================================================ - - -@login_log_blue.route("/detail/", methods=["GET"]) -@check_permission("sys:login_log:detail") -def detail(id): - """ - 查询登录日志详情 - - Args: - id: 登录日志ID - - Returns: - JSON: 登录日志详细信息 - """ - data = login_log.get_login_log_detail(id) - if data: - return R.ok(data=data) - return R.failed("日志不存在") - - -# ============================================================ -# 登录日志删除 -# ============================================================ - - -@login_log_blue.route("/delete/", methods=["DELETE"]) -@check_permission("sys:login_log:delete") -@check_demo -def delete(id): - """ - 删除登录日志 - - Args: - id: 登录日志ID(支持逗号分隔的多个ID) - - Returns: - JSON: 操作结果 - """ - count = login_log.delete_login_logs(id) - return R.ok(msg=f"本次共删除{count}条数据") - - -# ============================================================ -# 登录日志批量删除 -# ============================================================ - - -@login_log_blue.route("/batchDelete", methods=["DELETE"]) -@check_permission("sys:login_log:batch_delete") -@check_demo -def batch_delete(): - """ - 批量删除登录日志 - - 功能说明: - 批量删除登录日志记录,支持传入ID数组。 - - Request Body: - [1, 2, 3] # 登录日志ID数组 - - Returns: - JSON: 操作结果 - """ - id_str, err = parse_batch_ids() - if err: - return err - count = login_log.delete_login_logs(id_str) - return R.ok(msg=f"本次共删除{count}条数据") - - -""" -操作日志管理控制器模块 -============================================================= - -提供操作日志(OperationLog)相关的API接口,包括: -- 操作日志分页查询 -- 操作日志详情查询 -- 操作日志删除(支持批量) -- 操作日志清空 -- 操作日志统计 -- 操作日志类型列表 - -所属包: application.views -依赖模块: application.services.operation_log - 操作日志业务逻辑层 - application.middleware.check_permission - 权限校验中间件 -""" -operation_log_blue = Blueprint("operation_log", __name__, url_prefix="/oper/log") - - -# ============================================================ -# 操作日志分页查询 -# ============================================================ - - -@operation_log_blue.route("/page", methods=["GET"]) -@check_permission("sys:operation_log:page") -def operation_log_page(): - """ - 查询操作日志分页数据 - - 功能说明: - 获取操作日志列表的分页数据,支持按标题、操作类型、来源、 - 状态、时间范围等条件筛选。 - - Query Parameters: - pageNo: 当前页码(可选,默认1) - pageSize: 每页记录数(可选,默认PAGE_SIZE) - title: 请求标题(可选,模糊查询) - type: 请求类型(可选,精确查询) - source: 请求来源(可选,0-后台,1-前台) - status: 请求状态(可选,0-正常,1-异常) - startTime: 开始时间(可选) - endTime: 结束时间(可选) - - Returns: - JSON: 操作日志分页数据,包含 records(数据列表)、total(总记录数)等 - """ - return R.ok(data=operation_log.get_operation_log_page(request.args)) - - -# ============================================================ -# 操作日志详情查询 -# ============================================================ - - -@operation_log_blue.route("/detail/", methods=["GET"]) -@check_permission("sys:operation_log:detail") -def operation_log_detail(id): - """ - 查询操作日志详情 - - Args: - id: 操作日志ID - - Returns: - JSON: 操作日志详细信息 - """ - data = operation_log.get_operation_log_detail(id) - if data: - return R.ok(data=data) - return R.failed("日志不存在") - - -# ============================================================ -# 操作日志删除 -# ============================================================ - - -@operation_log_blue.route("/delete/", methods=["DELETE"]) -@check_permission("sys:operation_log:delete") -@check_demo -def operation_log_delete(id): - """ - 删除操作日志 - - Args: - id: 操作日志ID(支持逗号分隔的多个ID) - - Returns: - JSON: 操作结果 - """ - count = operation_log.delete_operation_logs(id) - return R.ok(msg=f"本次共删除{count}条数据") - - -# ============================================================ -# 操作日志批量删除 -# ============================================================ - - -@operation_log_blue.route("/batchDelete", methods=["DELETE"]) -@check_permission("sys:operation_log:batch_delete") -@check_demo -def operation_log_batch_delete(): - """ - 批量删除操作日志 - - 功能说明: - 批量删除操作日志记录,支持传入ID数组。 - - Request Body: - [1, 2, 3] # 操作日志ID数组 - - Returns: - JSON: 操作结果 - """ - id_str, err = parse_batch_ids() - if err: - return err - count = operation_log.delete_operation_logs(id_str) - return R.ok(msg=f"本次共删除{count}条数据") diff --git a/src/modules/log/service.py b/src/modules/log/service.py deleted file mode 100644 index ae9764e..0000000 --- a/src/modules/log/service.py +++ /dev/null @@ -1,593 +0,0 @@ -""" -登录日志管理服务模块 -============================================================= - -该模块实现了登录日志管理相关的所有业务逻辑。 -包括登录日志的分页查询、详情查询、添加、删除、清空等功能。 - -命名规范: -- 函数参数使用驼峰命名(如 logId, pageNo) -- 表单字段使用下划线命名(如 username, log_type)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters, _build_page_data) - -功能模块: -1. 分页查询:支持按账号模糊搜索、日志类型筛选、来源筛选、状态筛选、时间范围筛选 -2. 详情查询:根据ID获取单个登录日志的详细信息 -3. 日志添加:记录用户登录/退出操作日志 -4. 日志删除:删除指定日志(支持批量) -5. 日志清空:清空所有登录日志 -6. 统计查询:获取登录日志统计信息 -""" - -import logging -from datetime import datetime - - -from src.common.constants.common import PAGE_SIZE -from src.common.constants.dictionary_constants import ( - OPERATION_LOG_SOURCE_LIST, - OPERATION_LOG_TYPE_LIST, - LOGIN_LOG_TYPE_LIST, -) -from src.modules.log.models import LoginLog, OperationLog -from src.modules.log.login_log_repository import LoginLogRepository -from src.modules.log.operation_log_repository import OperationLogRepository -from src.core.database import commit_independently -from src.core.exceptions import BizError -from src.common.utils.model_utils import models_to_list, model_to_dict -from src.common.utils.request import parse_id_list, like_contains - -# 获取日志记录器 -logger = logging.getLogger(__name__) - - -# ============================================================ -# 登录日志分页查询 -# ============================================================ - - -def get_login_log_page(params: dict) -> dict: - """ - 查询登录日志分页数据 - - Args: - request: Flask request对象,包含查询参数 - - pageNo: 当前页码(可选,默认为1) - - pageSize: 每页记录数(可选,默认为PAGE_SIZE) - - username: 登录账号(可选,模糊查询) - - logType: 日志类型(可选,1-登录,2-退出) - - source: 登录来源(可选,0-后台,1-前台) - - status: 操作状态(可选,0-正常,1-异常) - - startTime: 开始时间(可选) - - endTime: 结束时间(可选) - - Returns: - R: 包含分页数据的JSON响应,格式为: - { - "records": 日志列表, - "total": 总记录数, - "size": 每页大小, - "current": 当前页码, - "pages": 总页数 - } - """ - # 获取分页参数 - page_no = int(params.get("pageNo", 1)) - page_size = int(params.get("pageSize", PAGE_SIZE)) - - # 应用筛选条件 - query = _apply_filters(params) - - # 记录总数 - total = query.count() - # 计算总页数 - pages = (total + page_size - 1) // page_size if total > 0 else 0 - - # 分页查询(按创建时间倒序) - log_list = query.order_by(LoginLog.create_time.desc()).limit(page_size).offset((page_no - 1) * page_size).all() - - # 构建返回数据 - page_data = _build_page_data(log_list, total, page_size, page_no, pages) - return page_data - - -def _apply_filters(params: dict): - """ - 应用查询筛选条件 - - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 支持登录账号模糊查询 - - 支持日志类型精确查询 - - 支持登录来源精确查询 - - 支持操作状态精确查询 - - 支持时间范围查询 - - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = LoginLog.query.filter(LoginLog.is_delete == 0) - - # 登录账号模糊筛选 - username = params.get("username") - if username: - query = query.filter(LoginLog.username.like(like_contains(username), escape="\\")) - - # 日志类型精确筛选 - log_type = params.get("logType") - if log_type: - query = query.filter(LoginLog.log_type == log_type) - - # 登录来源精确筛选 - source = params.get("source") - if source: - query = query.filter(LoginLog.source == source) - - # 操作状态精确筛选 - status = params.get("status") - if status: - query = query.filter(LoginLog.status == status) - - # 时间范围筛选 - start_time = params.get("startTime") - end_time = params.get("endTime") - - if start_time: - try: - start_date = datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S") - query = query.filter(LoginLog.create_time >= start_date) - except ValueError: - pass - - if end_time: - try: - end_date = datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S") - query = query.filter(LoginLog.create_time <= end_date) - except ValueError: - pass - - return query - - -def _build_page_data(log_list, total, page_size, page_no, pages): - """ - 构建分页返回数据 - - Args: - log_list: 登录日志对象列表 - total: 总记录数 - page_size: 每页大小 - page_no: 当前页码 - pages: 总页数 - - Returns: - dict: 分页数据,包含 records、total、size、current、pages - """ - # 将模型对象列表转换为字典列表 - records = models_to_list(log_list) - - # 遍历数据源 - for record in records: - # 日志来源 - record["sourceText"] = OPERATION_LOG_SOURCE_LIST.get(record.get("source"), "") - # 日志类型 - record["logTypeText"] = LOGIN_LOG_TYPE_LIST.get(record.get("logType"), "") - - return {"records": records, "total": total, "size": page_size, "current": page_no, "pages": pages} - - -# ============================================================ -# 登录日志详情查询 -# ============================================================ - - -def get_login_log_detail(log_id): - """ - 根据ID查询登录日志详情(返回字典格式) - - Args: - log_id: 登录日志ID - - Returns: - dict/None: 登录日志详情字典,不存在时返回 None - """ - try: - log = get_info(log_id) - if not log: - return None - return model_to_dict(log) - - except Exception as e: - logger.error(f"查询登录日志详情异常,ID: {log_id}, 错误: {str(e)}") - return None - - -def get_info(log_id: int): - """ - 根据ID查询登录日志信息(返回模型对象) - - Args: - log_id: 登录日志ID - - Returns: - LoginLog/None: 登录日志模型对象,不存在或已删除时返回 None - """ - return LoginLogRepository.get_by_id(log_id) - - -def record_login_log(username, source, status, **kwargs): - """ - 记录登录日志(便捷方法) - - 供登录/退出接口直接调用,无需通过HTTP请求。 - 使用独立session写入,不受请求事务commit/rollback影响。 - - Args: - username: 登录账号 - source: 登录来源(0-后台,1-前台) - status: 操作状态(0-正常,1-异常) - **kwargs: 其他可选参数 - - log_type: 日志类型(默认1-登录) - - method: 请求方法 - - request_method: 请求方式 - - url: 请求URL - - param: 请求参数 - - result: 返回结果 - - ip: IP地址 - - location: IP归属地 - - os: 操作系统 - - browser: 浏览器 - - consume_time: 请求耗时 - - error: 错误信息 - - Returns: - LoginLog/None: 创建的日志对象,失败返回None - """ - try: - log_data = { - "username": username, - "log_type": kwargs.get("log_type", LoginLog.LogType.LOGIN), - "source": source, - "method": kwargs.get("method", ""), - "request_method": kwargs.get("request_method", ""), - "url": kwargs.get("url", ""), - "param": kwargs.get("param", ""), - "result": kwargs.get("result", ""), - "ip": kwargs.get("ip", ""), - "location": kwargs.get("location", ""), - "os": kwargs.get("os", ""), - "browser": kwargs.get("browser", ""), - "consume_time": kwargs.get("consume_time", 0), - "status": status, - "error": kwargs.get("error", ""), - } - - log = LoginLog(**log_data) - log.create_user = username - log.update_user = username - - # 使用独立session写入,不受请求事务影响 - # 日志是审计数据,无论业务成功或失败都应持久化 - from src.core.database import commit_independently - - commit_independently(log) - - return log - - except Exception as e: - logger.error(f"记录登录日志异常: {str(e)}") - return None - - -# ============================================================ -# 登录日志删除 -# ============================================================ - - -def delete_login_logs(log_ids: str) -> int: - """ - 删除登录日志(批量删除) - - 删除 - - Args: - log_ids: 日志ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - log_ids_list = parse_id_list(log_ids) - if not log_ids_list: - raise BizError("无效的记录ID") - return LoginLog.batch_delete(log_ids_list) - - -""" -操作日志管理服务模块 -============================================================= - -该模块实现了操作日志管理相关的所有业务逻辑。 -包括操作日志的分页查询、详情查询、添加、删除、清空、统计等功能。 - -命名规范: -- 函数参数使用驼峰命名(如 logId, pageNo) -- 表单字段使用下划线命名(如 title, log_type)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters, _build_page_data) - -功能模块: -1. 分页查询:支持按标题模糊搜索、操作类型筛选、来源筛选、状态筛选、时间范围筛选 -2. 详情查询:根据ID获取单个操作日志的详细信息 -3. 日志添加:记录用户操作日志 -4. 日志删除:删除指定日志(支持批量) -5. 日志清空:清空所有操作日志 -6. 统计查询:获取操作日志统计信息 -7. 类型查询:获取操作日志类型列表 -""" - - -# ============================================================ -# 操作日志分页查询 -# ============================================================ - - -def get_operation_log_page(params: dict) -> dict: - """ - 查询操作日志分页数据 - - Args: - request: Flask request对象,包含查询参数 - - pageNo: 当前页码(可选,默认为1) - - pageSize: 每页记录数(可选,默认为PAGE_SIZE) - - title: 请求标题(可选,模糊查询) - - type: 请求类型(可选,精确查询) - - source: 请求来源(可选,0-后台,1-前台) - - status: 请求状态(可选,0-正常,1-异常) - - startTime: 开始时间(可选) - - endTime: 结束时间(可选) - - Returns: - R: 包含分页数据的JSON响应,格式为: - { - "records": 日志列表, - "total": 总记录数, - "size": 每页大小, - "current": 当前页码, - "pages": 总页数 - } - """ - # 获取分页参数 - page_no = int(params.get("pageNo", 1)) - page_size = int(params.get("pageSize", PAGE_SIZE)) - - # 应用筛选条件 - query = _apply_operation_log_filters(params) - - # 记录总数 - total = query.count() - # 计算总页数 - pages = (total + page_size - 1) // page_size if total > 0 else 0 - - # 分页查询(按创建时间倒序) - log_list = query.order_by(OperationLog.create_time.desc()).limit(page_size).offset((page_no - 1) * page_size).all() - - # 构建返回数据 - page_data = _build_operation_log_page_data(log_list, total, page_size, page_no, pages) - return page_data - - -def _apply_operation_log_filters(params: dict): - """ - 应用查询筛选条件 - - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 支持请求标题模糊查询 - - 支持操作类型精确查询 - - 支持请求来源精确查询 - - 支持请求状态精确查询 - - 支持时间范围查询 - - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = OperationLog.query.filter(OperationLog.is_delete == 0) - - # 请求标题模糊筛选 - title = params.get("title") - if title: - query = query.filter(OperationLog.title.like(like_contains(title), escape="\\")) - - # 请求类型精确筛选 - log_type = params.get("type") - if log_type: - query = query.filter(OperationLog.type == log_type) - - # 请求来源精确筛选 - source = params.get("source") - if source: - query = query.filter(OperationLog.source == source) - - # 请求状态精确筛选 - status = params.get("status") - if status: - query = query.filter(OperationLog.status == status) - - # 时间范围筛选 - start_time = params.get("startTime") - end_time = params.get("endTime") - - if start_time: - try: - start_date = datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S") - query = query.filter(OperationLog.create_time >= start_date) - except ValueError: - pass - - if end_time: - try: - end_date = datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S") - query = query.filter(OperationLog.create_time <= end_date) - except ValueError: - pass - - return query - - -def _build_operation_log_page_data(log_list, total, page_size, page_no, pages): - """ - 构建分页返回数据 - - Args: - log_list: 操作日志对象列表 - total: 总记录数 - page_size: 每页大小 - page_no: 当前页码 - pages: 总页数 - - Returns: - dict: 分页数据,包含 records、total、size、current、pages - """ - # 将模型对象列表转换为字典列表 - records = models_to_list(log_list) - - # 遍历数据源 - for record in records: - # 日志来源 - record["sourceText"] = OPERATION_LOG_SOURCE_LIST.get(record.get("source"), "") - # 日志类型 - record["typeText"] = OPERATION_LOG_TYPE_LIST.get(record.get("type"), "") - - return {"records": records, "total": total, "size": page_size, "current": page_no, "pages": pages} - - -# ============================================================ -# 操作日志详情查询 -# ============================================================ - - -def get_operation_log_detail(log_id): - """ - 根据ID查询操作日志详情(返回字典格式) - - Args: - log_id: 操作日志ID - - Returns: - dict/None: 操作日志详情字典,不存在时返回 None - """ - try: - log = get_operation_log_info(log_id) - if not log: - return None - data = model_to_dict(log) - # 日志来源 - data["sourceText"] = OPERATION_LOG_SOURCE_LIST.get(data.get("source"), "") - return data - - except Exception as e: - logger.error(f"查询操作日志详情异常,ID: {log_id}, 错误: {str(e)}") - return None - - -def get_operation_log_info(log_id: int): - """ - 根据ID查询操作日志信息(返回模型对象) - - Args: - log_id: 操作日志ID - - Returns: - OperationLog/None: 操作日志模型对象,不存在或已删除时返回 None - """ - return OperationLogRepository.get_by_id(log_id) - - -def record_operation_log(title, log_type, source, status, **kwargs): - """ - 记录操作日志(便捷方法) - - 供业务接口直接调用,无需通过HTTP请求。 - 使用独立session写入,不受请求事务commit/rollback影响。 - - Args: - title: 请求标题 - log_type: 请求类型 - source: 请求来源(0-后台,1-前台) - status: 操作状态(0-正常,1-异常) - **kwargs: 其他可选参数 - - method: 请求方法 - - request_method: 请求方式 - - url: 请求URL - - param: 请求参数 - - result: 返回结果 - - ip: IP地址 - - location: IP归属地 - - os: 操作系统 - - browser: 浏览器 - - consume_time: 请求耗时 - - error: 错误信息 - - username: 操作用户 - - Returns: - OperationLog/None: 创建的日志对象,失败返回None - """ - try: - log_data = { - "title": title, - "type": log_type, - "source": source, - "method": kwargs.get("method", ""), - "request_method": kwargs.get("request_method", ""), - "url": kwargs.get("url", ""), - "param": kwargs.get("param", ""), - "result": kwargs.get("result", ""), - "ip": kwargs.get("ip", ""), - "location": kwargs.get("location", ""), - "os": kwargs.get("os", ""), - "browser": kwargs.get("browser", ""), - "consume_time": kwargs.get("consume_time", 0), - "status": status, - "error": kwargs.get("error", ""), - } - - log = OperationLog(**log_data) - log.create_user = kwargs.get("username", "system") - log.update_user = kwargs.get("username", "system") - - # 使用独立session写入,不受请求事务影响 - # 日志是审计数据,无论业务成功或失败都应持久化 - - commit_independently(log) - - return log - - except Exception as e: - logger.error(f"记录操作日志异常: {str(e)}") - return None - - -# ============================================================ -# 操作日志删除 -# ============================================================ - - -def delete_operation_logs(log_ids: str) -> int: - """ - 删除操作日志(批量删除) - - 删除 - - Args: - log_ids: 日志ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - log_ids_list = parse_id_list(log_ids) - if not log_ids_list: - raise BizError("无效的记录ID") - return OperationLog.batch_delete(log_ids_list) diff --git a/src/modules/login_log/__init__.py b/src/modules/login_log/__init__.py new file mode 100644 index 0000000..3240680 --- /dev/null +++ b/src/modules/login_log/__init__.py @@ -0,0 +1 @@ +"""登录日志模块:独立表 flask_login_log,含模型/仓储/服务/路由。""" diff --git a/src/modules/login_log/models.py b/src/modules/login_log/models.py new file mode 100644 index 0000000..05d9207 --- /dev/null +++ b/src/modules/login_log/models.py @@ -0,0 +1,142 @@ +""" +登录日志模型模块 +============================================================= + +提供登录日志(LoginLog)的数据模型定义,用于记录用户登录、退出等操作日志。 +""" + +from src.common.base_db import BaseDB +from src.common.base_model import BaseModel +from config import DB_PREFIX +from src.core.database import db + + +class LoginLog(BaseModel, BaseDB): + """ + 登录日志模型 + + 用于记录用户登录和退出操作的详细信息,包括:登录账号、日志类型、 + 来源、请求信息、IP地址、归属地、设备信息、请求耗时、操作状态等。 + + Attributes: + id: 主键ID(继承自 BaseModel) + username: 登录账号(String(50),必填) + log_type: 日志类型(Integer,1-登录,2-退出,必填) + source: 登录来源(Integer,0-后台,1-前台,必填) + method: 请求方法(String(10),必填) + request_method: 请求方式(String(255),必填) + url: 请求URL(String(500),必填) + param: 请求参数(Text,可选) + result: 返回结果(Text,必填) + ip: IP地址(String(50),必填) + location: IP归属地(String(100),必填) + os: 操作系统(String(100),必填) + browser: 浏览器(String(100),必填) + consume_time: 请求耗时(Integer,必填) + status: 操作状态(Integer,0-正常,1-异常,必填) + error: 错误信息(Text,可选) + """ + + # ============================================================ + # 表配置 + # ============================================================ + + # 设置表名 + __tablename__ = DB_PREFIX + "login_log" + + # 表注释 + __table_comment__ = "登录日志表" + + # 时间范围筛选高频 → create_time 索引(继承自 BaseModel 的列) + __table_args__ = ( + db.Index('ix_login_log_create_time', 'create_time'), + ) + + # ============================================================ + # 字段定义 + # ============================================================ + + # 登录账号 + username = db.Column(db.String(50), nullable=False, index=True, comment="登录账号") + + # 日志类型:1-登录,2-退出 + log_type = db.Column(db.Integer, nullable=False, index=True, comment="日志类型:1-登录,2-退出") + + # 登录来源:0-后台,1-前台 + source = db.Column(db.Integer, nullable=False, comment="登录来源:0-后台,1-前台") + + # 请求方法(HTTP方法,如POST/GET) + method = db.Column(db.String(100), nullable=False, comment="请求方法") + + # 请求方式(视图方法路径,如application.views.login.logout) + request_method = db.Column(db.String(255), nullable=False, comment="请求方式") + + # 请求URL + url = db.Column(db.String(500), nullable=False, comment="请求URL") + + # 请求参数 + param = db.Column(db.Text, nullable=True, comment="请求参数") + + # 返回结果 + result = db.Column(db.Text, nullable=False, comment="返回结果") + + # IP地址 + ip = db.Column(db.String(50), nullable=False, comment="IP地址") + + # IP归属地 + location = db.Column(db.String(100), nullable=False, comment="IP归属地") + + # 操作系统 + os = db.Column(db.String(100), nullable=False, comment="操作系统") + + # 浏览器 + browser = db.Column(db.String(100), nullable=False, comment="浏览器") + + # 请求耗时(毫秒) + consume_time = db.Column(db.Integer, nullable=False, comment="请求耗时") + + # 操作状态:0-正常,1-异常 + status = db.Column(db.Integer, nullable=False, comment="操作状态:0-正常,1-异常") + + # 错误信息 + error = db.Column(db.Text, nullable=True, comment="错误信息") + + # ============================================================ + # 常量定义 + # ============================================================ + + class LogType: + """日志类型常量""" + + LOGIN = 1 # 登录 + LOGOUT = 2 # 退出 + + class Source: + """登录来源常量""" + + ADMIN = 0 # 后台 + FRONT = 1 # 前台 + + class Status: + """操作状态常量""" + + NORMAL = 0 # 正常 + ERROR = 1 # 异常 + + # ============================================================ + # 魔法方法 + # ============================================================ + + def __repr__(self): + """ + 返回模型的字符串表示 + + Returns: + str: 格式为 "登录日志:{username} - {log_type}" 的字符串 + """ + log_type_name = "登录" if self.log_type == self.LogType.LOGIN else "退出" + return "登录日志:{} - {}".format(self.username, log_type_name) + + def __str__(self): + """返回模型的字符串表示""" + return self.__repr__() diff --git a/src/modules/login_log/repository.py b/src/modules/login_log/repository.py new file mode 100644 index 0000000..77e4a02 --- /dev/null +++ b/src/modules/login_log/repository.py @@ -0,0 +1,54 @@ +""" +LoginLog 仓储模块 +============================================================= + +数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 +""" + +from src.common.constants.common import PAGE_SIZE +from src.common.base_repository import BaseRepository +from src.modules.login_log.models import LoginLog + + +class LoginLogRepository(BaseRepository): + """登录日志数据仓储,提供登录日志相关的数据库访问操作""" + + model = LoginLog + + @classmethod + def page(cls, params: dict): + """ + 登录日志分页查询 + + 支持按用户名模糊搜索,按日志类型、来源、状态精确过滤, + 按创建时间范围筛选,按创建时间倒序排列。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + - username: 用户名(模糊匹配) + - logType: 日志类型(精确匹配) + - source: 日志来源(精确匹配) + - status: 状态(精确匹配) + - startTime: 创建时间起始 + - endTime: 创建时间结束 + + Returns: + tuple: (items, total, pages) + - items: 当前页数据列表 + - total: 总记录数 + - pages: 总页数 + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + like_filters={'username': params.get('username')}, + filters={ + 'log_type': params.get('logType'), + 'source': params.get('source'), + 'status': params.get('status'), + }, + range_filters={'create_time': (params.get('startTime'), params.get('endTime'))}, + order_desc='create_time', + ) diff --git a/src/modules/login_log/router.py b/src/modules/login_log/router.py new file mode 100644 index 0000000..c7e36f3 --- /dev/null +++ b/src/modules/login_log/router.py @@ -0,0 +1,108 @@ +""" +登录日志控制器模块 +============================================================= + +提供登录日志(LoginLog)相关的API接口: +- 分页查询、详情查询、删除(支持批量) +""" + +from flask import Blueprint, request + +from src.middleware import check_demo, operation_log +from src.core.permission import check_permission +from src.modules.login_log import service as login_log +from src.common.utils.request import parse_batch_ids + +from src.core import response as R + +login_log_blue = Blueprint("login_log", __name__, url_prefix="/login/log") + + +# ============================================================ +# 登录日志分页查询 +# ============================================================ + + +@login_log_blue.route("/page", methods=["GET"]) +@check_permission("sys:login_log:page") +@operation_log("登录日志", "查询") +def page(): + """ + 查询登录日志分页数据 + + 支持按账号、日志类型、来源、状态、时间范围等条件筛选。 + """ + return R.ok(data=login_log.get_login_log_page(request.args)) + + +# ============================================================ +# 登录日志详情查询 +# ============================================================ + + +@login_log_blue.route("/detail/", methods=["GET"]) +@check_permission("sys:login_log:detail") +@operation_log("登录日志", "详情") +def detail(id): + """ + 查询登录日志详情 + + Args: + id: 登录日志ID + + Returns: + JSON: 登录日志详细信息 + """ + data = login_log.get_login_log_detail(id) + if data: + return R.ok(data=data) + return R.failed("日志不存在") + + +# ============================================================ +# 登录日志删除 +# ============================================================ + + +@login_log_blue.route("/delete/", methods=["DELETE"]) +@check_permission("sys:login_log:delete") +@check_demo +@operation_log("登录日志", "删除") +def delete(id): + """ + 删除登录日志 + + Args: + id: 登录日志ID(支持逗号分隔的多个ID) + + Returns: + JSON: 操作结果 + """ + count = login_log.delete_login_logs(id) + return R.ok(msg=f"本次共删除{count}条数据") + + +# ============================================================ +# 登录日志批量删除 +# ============================================================ + + +@login_log_blue.route("/batchDelete", methods=["DELETE"]) +@check_permission("sys:login_log:batchDelete") +@check_demo +@operation_log("登录日志", "批量删除") +def batch_delete(): + """ + 批量删除登录日志 + + Request Body: + [1, 2, 3] # 登录日志ID数组 + + Returns: + JSON: 操作结果 + """ + id_str, err = parse_batch_ids() + if err: + return err + count = login_log.delete_login_logs(id_str) + return R.ok(msg=f"本次共删除{count}条数据") diff --git a/src/modules/login_log/service.py b/src/modules/login_log/service.py new file mode 100644 index 0000000..8082d0e --- /dev/null +++ b/src/modules/login_log/service.py @@ -0,0 +1,101 @@ +""" +登录日志管理服务模块 +============================================================= + +提供登录日志(LoginLog)的分页/详情/删除,以及审计写入 record_login_log。 +""" + +import logging + +from src.common.base_service import BaseService +from src.modules.login_log.models import LoginLog +from src.modules.login_log.repository import LoginLogRepository +from src.core.database import commit_independently + +# 获取日志记录器 +logger = logging.getLogger(__name__) + + +class LoginLogService(BaseService): + """ + 登录日志服务类 + + 继承 BaseService 获得标准 CRUD 能力,无表单校验。 + """ + + repository = LoginLogRepository + model_name = "登录日志" + + # 枚举显示名映射(自动翻译为 {field}Text 字段) + serialize_maps = { + 'logType': 'log_type', + 'source': 'operation_source', + } + + +# ============================================================ +# 模块级代理函数:保持 router 调用兼容 +# ============================================================ + +get_login_log_page = LoginLogService.get_page +get_login_log_detail = LoginLogService.get_detail +get_info = LoginLogService.get_info +delete_login_logs = LoginLogService.remove + + +def record_login_log(username, source, status, **kwargs): + """ + 记录登录日志(便捷方法) + + 供登录/退出接口直接调用,无需通过HTTP请求。 + 使用独立session写入,不受请求事务commit/rollback影响。 + + Args: + username: 登录账号 + source: 登录来源(0-后台,1-前台) + status: 操作状态(0-正常,1-异常) + **kwargs: 其他可选参数 + - log_type: 日志类型(登录/退出) + - method: HTTP请求方法 + - request_method: 请求端点 + - url: 请求URL + - param: 请求参数 + - result: 响应结果 + - ip: 客户端IP + - location: IP归属地 + - os: 操作系统 + - browser: 浏览器 + - consume_time: 请求耗时(毫秒) + - error: 错误信息 + + Returns: + LoginLog/None: 创建的日志对象,失败返回None + """ + try: + log_data = { + "username": username, + "log_type": kwargs.get("log_type", LoginLog.LogType.LOGIN), + "source": source, + "method": kwargs.get("method", ""), + "request_method": kwargs.get("request_method", ""), + "url": kwargs.get("url", ""), + "param": kwargs.get("param", ""), + "result": kwargs.get("result", ""), + "ip": kwargs.get("ip", ""), + "location": kwargs.get("location", ""), + "os": kwargs.get("os", ""), + "browser": kwargs.get("browser", ""), + "consume_time": kwargs.get("consume_time", 0), + "status": status, + "error": kwargs.get("error", ""), + "create_user": username, + "update_user": username, + } + + # 使用独立session写入,不受请求事务影响 + # 日志是审计数据,无论业务成功或失败都应持久化 + return commit_independently(LoginLog, log_data) + + except Exception as e: + logger.error(f"记录登录日志异常: {str(e)}") + return None diff --git a/src/modules/notice/models.py b/src/modules/notice/models.py index 473a01b..c6c95f9 100644 --- a/src/modules/notice/models.py +++ b/src/modules/notice/models.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -49,6 +49,7 @@ from src.common.base_db import BaseDB from src.common.base_model import BaseModel from config import DB_PREFIX +from sqlalchemy import text from src.core.database import db @@ -80,6 +81,9 @@ class Notice(BaseModel, BaseDB): # 设置表名 __tablename__ = DB_PREFIX + "notice" + # 表注释 + __table_comment__ = "通知公告表" + # ============================================================ # 字段定义 # ============================================================ @@ -88,6 +92,7 @@ class Notice(BaseModel, BaseDB): title = db.Column( db.String(150), nullable=False, + index=True, comment="通知标题" ) @@ -102,6 +107,8 @@ class Notice(BaseModel, BaseDB): type = db.Column( db.Integer, default=1, + server_default=text('1'), + index=True, comment="通知类型:1-通知 2-公告" ) @@ -109,6 +116,8 @@ class Notice(BaseModel, BaseDB): status = db.Column( db.Integer, default=1, + server_default=text('1'), + index=True, comment="通知状态:1-正常 2-停用" ) @@ -116,6 +125,7 @@ class Notice(BaseModel, BaseDB): click_num = db.Column( db.Integer, default=0, + server_default=text('0'), comment="点击率" ) diff --git a/src/modules/notice/repository.py b/src/modules/notice/repository.py index 24476a8..ed3cfe4 100644 --- a/src/modules/notice/repository.py +++ b/src/modules/notice/repository.py @@ -6,10 +6,40 @@ Notice 仓储模块 """ from src.common.base_repository import BaseRepository +from src.common.constants.common import PAGE_SIZE from src.modules.notice.models import Notice class NoticeRepository(BaseRepository): - """Notice 仓储""" + """通知公告数据仓储,提供通知公告相关的数据库访问操作""" model = Notice + + @classmethod + def page(cls, params: dict): + """ + 通知公告分页查询 + + 支持按标题模糊搜索,按类型和状态精确过滤,按 id 倒序排列。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + - title: 公告标题(模糊匹配) + - type: 公告类型(精确匹配) + - status: 状态(精确匹配) + + Returns: + tuple: (items, total, pages) + - items: 当前页数据列表 + - total: 总记录数 + - pages: 总页数 + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + filters={'type': params.get('type'), 'status': params.get('status')}, + like_filters={'title': params.get('title')}, + order_desc='id', + ) diff --git a/src/modules/notice/router.py b/src/modules/notice/router.py index 8ea7175..74db24a 100644 --- a/src/modules/notice/router.py +++ b/src/modules/notice/router.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -55,7 +55,7 @@ """ from flask import Blueprint, request -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.core.permission import check_permission from src.modules.notice import service as notice from src.common.utils.request import parse_batch_ids, parse_request_body @@ -72,6 +72,7 @@ notice_blue = Blueprint("notice", __name__, url_prefix="/notice") @notice_blue.route("/page", methods=["GET"]) @check_permission("sys:notice:page") +@operation_log("通知管理", "查询") def page(): """ 查询通知分页数据 @@ -92,6 +93,7 @@ def page(): @notice_blue.route("/detail/", methods=["GET"]) @check_permission("sys:notice:detail") +@operation_log("通知管理", "详情") def detail(id): """ 查询通知详情 @@ -114,6 +116,7 @@ def detail(id): @notice_blue.route("/add", methods=["POST"]) @check_permission("sys:notice:add") @check_demo +@operation_log("通知管理", "新增") def add(): """ 添加通知 @@ -139,6 +142,7 @@ def add(): @notice_blue.route("/update", methods=["PUT"]) @check_permission("sys:notice:update") @check_demo +@operation_log("通知管理", "修改") def update(): """ 更新通知 @@ -164,6 +168,7 @@ def update(): @notice_blue.route("/delete/", methods=["DELETE"]) @check_permission("sys:notice:delete") @check_demo +@operation_log("通知管理", "删除") def delete(id): """ 删除通知 @@ -184,8 +189,9 @@ def delete(id): @notice_blue.route("/batchDelete", methods=["DELETE"]) -@check_permission("sys:notice:batch_delete") +@check_permission("sys:notice:batchDelete") @check_demo +@operation_log("通知管理", "批量删除") def batch_delete(): """ 批量删除通知公告 @@ -202,4 +208,5 @@ def batch_delete(): id_str, err = parse_batch_ids() if err: return err - return notice.delete_notices(id_str) + count = notice.delete_notices(id_str) + return R.ok(msg=f"本次共删除{count}条数据") diff --git a/src/modules/notice/schema.py b/src/modules/notice/schema.py index 7ab899b..b116bb3 100644 --- a/src/modules/notice/schema.py +++ b/src/modules/notice/schema.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -51,7 +51,7 @@ from wtforms import ValidationError, IntegerField, StringField from wtforms.validators import NumberRange, DataRequired, Length, Optional from src.common.forms.base_form import BaseForm -from src.modules.notice.models import Notice +from src.modules.notice.repository import NoticeRepository # ============================================================ @@ -169,13 +169,6 @@ class NoticeForm(BaseForm): # 获取当前记录的ID(更新时存在,添加时为None) current_id = self.id.data if self.id.data else None - # 构建查询:查找同名标题记录 - query = Notice.query.filter(Notice.title == field.data, Notice.is_delete == 0) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(Notice.id != current_id) - - # 如果存在同名记录,抛出验证错误 - if query.first(): + # 唯一性校验(软删过滤 + 更新时排除自身) + if NoticeRepository.exists_by({'title': field.data}, exclude_id=current_id): raise ValidationError("通知标题已存在") diff --git a/src/modules/notice/service.py b/src/modules/notice/service.py index e980869..24ba9dd 100644 --- a/src/modules/notice/service.py +++ b/src/modules/notice/service.py @@ -1,378 +1,126 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 -# +====================================================================== -# | 版权和免责声明: -# | 一、知识产权声明 -# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 -# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, -# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 -# | 二、使用限制与禁止条款 -# | 您理解并同意,禁止将本产品用于以下任何行为: -# | 1. 任何违反中华人民共和国法律法规的用途; -# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; -# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 -# | 三、责任豁免 -# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 -# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 -# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 -# | 四、违约与法律责任 -# | 如发现任何违反本声明的行为,本团队有权: -# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; -# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; -# | 3. 保留进一步采取刑事控告等法律措施的权利。 -# | 五、许可范围说明 -# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 -# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 -# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 -# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 -# | 成的一切经济损失及商誉损害。 -# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 -# | 许可协议》附件为准。 -# +====================================================================== - """ 通知公告管理服务模块 ============================================================= 该模块实现了通知公告管理相关的所有业务逻辑。 -包括通知公告的分页查询、详情查询、添加、更新、删除等功能。 -通知公告用于发布系统通知、平台动态等信息, -包含标题、内容、来源、状态等属性。 - -命名规范: -- 函数参数使用驼峰命名(如 noticeId, pageNo) -- 表单字段使用下划线命名(如 title, content)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters, _build_page_data) - -功能模块: -1. 分页查询:支持按标题模糊搜索、来源筛选、状态筛选,按ID倒序排列 -2. 详情查询:根据ID获取单个通知公告的详细信息(包含富文本内容处理) -3. 数据维护:通知公告的添加、更新、删除(支持批量) +通知公告用于发布系统通知、平台动态等信息,包含标题、内容、来源、状态等属性。 """ - -import logging - -from datetime import datetime, timezone - -from src.common.constants.common import PAGE_SIZE -from src.common.constants.dictionary_constants import NOTICE_TYPE_LIST -from src.modules.notice.schema import NoticeForm -from src.modules.notice.models import Notice -from src.modules.notice.repository import NoticeRepository -from src.core.exceptions import BizError -from config import FLASK_FILE_URL -from src.common.utils import regular -from src.common.utils.case_converter import convert_camel_to_snake +from src.common.utils.rich_text import get_file_url +from src.common.base_service import BaseService from src.common.utils.model_utils import model_to_dict -from src.common.utils.request import parse_id_list, like_contains from src.common.utils.rich_text import save_content, save_file -from src.core.security import get_username - -# 获取日志记录器 -logger = logging.getLogger(__name__) +from src.modules.notice.schema import NoticeForm +from src.modules.notice.repository import NoticeRepository +from src.modules.dictionary import dict_utils +from config import FLASK_FILE_URL -# ============================================================ -# 通知公告分页查询 -# ============================================================ - - -def get_notice_page(params: dict) -> dict: +class NoticeService(BaseService): """ - 查询通知公告分页数据 + 通知公告服务类 - Args: - request: Flask request对象,包含查询参数 - - pageNo: 当前页码(可选,默认为1) - - pageSize: 每页记录数(可选,默认为PAGE_SIZE) - - title: 通知公告标题(可选,模糊查询) - - type: 通知类型(可选,精确匹配) - - status: 通知状态(可选,精确匹配) - - Returns: - R: 包含分页数据的JSON响应,格式为: - { - "records": 通知公告列表, - "total": 总记录数, - "size": 每页大小, - "current": 当前页码, - "pages": 总页数 - } + 继承 BaseService 获得标准 CRUD 能力,并扩展富文本内容处理。 """ - # 获取分页参数 - page_no = int(params.get("pageNo", 1)) - page_size = int(params.get("pageSize", PAGE_SIZE)) - # 应用筛选条件 - query = _apply_filters(params) + repository = NoticeRepository + form = NoticeForm + model_name = "通知公告" - # 记录总数 - total = query.count() - # 计算总页数 - pages = (total + page_size - 1) // page_size if total > 0 else 0 + @classmethod + def before_add(cls, form): + """ + 新增前钩子 - # 分页查询 - notice_list = query.limit(page_size).offset((page_no - 1) * page_size).all() - # 构建返回数据 - page_data = _build_page_data(notice_list, total, page_size, page_no, pages) - return page_data + 处理通知封面与富文本内容。 + Args: + form: 已校验的表单对象 + """ + _handle_rich_text(form) -def _apply_filters(params: dict): - """ - 应用查询筛选条件 + @classmethod + def before_update(cls, form, obj): + """ + 更新前钩子 - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 支持通知公告标题模糊查询 - - 支持通知来源精确查询 - - 支持通知状态精确查询 - - 默认按ID倒序排列 + 处理通知封面与富文本内容。 - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = Notice.query.filter(Notice.is_delete == 0) + Args: + form: 已校验的表单对象 + obj: 已存在的模型实例 + """ + _handle_rich_text(form) - # 通知公告标题模糊筛选 - title = params.get("title") - if title: - query = query.filter(Notice.title.like(like_contains(title), escape="\\")) + @classmethod + def _enrich_page_records(cls, records): + """ + 分页富化钩子 - # 通知类型精确筛选 - type = params.get("type") - if type: - query = query.filter(Notice.type == type) + 补充通知来源描述(走字典表翻译)。 - # 通知状态精确筛选 - status = params.get("status") - if status: - query = query.filter(Notice.status == status) + Args: + records: 通知公告字典列表 - # 按ID倒序排列 - return query.order_by(Notice.id.desc()) + Returns: + list: 富化后的通知公告字典列表 + """ + type_map = dict_utils.get_value_map("notice_type") + for data in records: + data["typeText"] = type_map.get(str(data.get("type"))) + data["cover"] = get_file_url(data.get("cover")) + return records + @classmethod + def _detail_dict(cls, obj): + """ + 详情序列化钩子 -def _build_page_data(notice_list, total, page_size, page_no, pages): - """ - 构建分页返回数据 + 富文本图片 URL 替换,补充来源描述。 - Args: - notice_list: 通知公告对象列表 - total: 总记录数 - page_size: 每页大小 - page_no: 当前页码 - pages: 总页数 + Args: + obj: 通知公告模型实例 - Returns: - dict: 分页数据,包含 records、total、size、current、pages - """ - # 将模型对象列表转换为字典列表,并添加额外字段 - records = [] - for notice in notice_list: - data = model_to_dict(notice) - # 添加通知来源描述 - data["typeText"] = NOTICE_TYPE_LIST.get(notice.type) - records.append(data) - - return {"records": records, "total": total, "size": page_size, "current": page_no, "pages": pages} - - -# ============================================================ -# 通知公告详情查询 -# ============================================================ - - -def get_notice_detail(notice_id): - """ - 根据ID查询通知公告详情(返回字典格式) - - 此方法调用 get_info 获取模型对象,然后转换为字典格式返回。 - 适用于前端接口调用。 - - Args: - notice_id: 通知公告ID - - Returns: - dict/None: 通知公告详情字典,不存在时返回 None - """ - try: - # 复用 get_info 方法查询通知公告 - notice = get_info(notice_id) - if not notice: - return None - # 转换为字典格式 - data = model_to_dict(notice) - # 富文本内容处理:替换图片URL占位符 - content = notice.content.replace("[IMG_URL]", FLASK_FILE_URL) if notice.content else "" + Returns: + dict: 序列化后的字典 + """ + data = model_to_dict(obj) + # 封面URL转换 + data["cover"] = get_file_url(data.get("cover")) + # 将富文本中的占位符替换为实际图片域名 + content = obj.content.replace("[IMG_URL]", FLASK_FILE_URL) if obj.content else "" data["content"] = content - # 通知来源描述 - data["typeText"] = NOTICE_TYPE_LIST.get(notice.type) + data["typeText"] = dict_utils.get_name("notice_type", obj.type) return data - except Exception as e: - logger.error(f"查询通知公告详情异常,ID: {notice_id}, 错误: {str(e)}") - return None - -# ============================================================ -# 通知公告信息查询(根据ID)- 核心查询方法 -# ============================================================ - - -def get_info(notice_id: int): +def _handle_rich_text(form): """ - 根据ID查询通知公告信息(返回模型对象) + 封面与富文本内容处理 - 这是一个通用的通知公告查询方法,返回通知公告模型对象而非字典。 - 与 get_notice_detail 的区别: - - get_notice_detail: 返回字典格式,用于前端接口 - - get_info: 返回模型对象,用于内部服务调用 - - 此方法被以下方法复用: - - get_notice_detail: 获取详情字典 - - update_notice: 验证通知公告是否存在 + 保存封面图片和富文本中的内嵌图片,回写表单数据。 Args: - notice_id: 通知公告ID - - Returns: - Notice/None: 通知公告模型对象,不存在或已删除时返回 None - - Example: - notice = get_info(notice_id) - if notice: - print(notice.title) + form: 表单对象 """ - return NoticeRepository.get_by_id(notice_id) - - -def add_notice(data: dict) -> None: - """ - 添加通知公告 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证(字段格式等) - 3. 处理富文本内容(保存图片) - 4. 创建通知公告对象并保存到数据库 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = NoticeForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 通知封面处理 + # 处理封面图片 cover = form.data.get("cover") if cover: - cover = save_file(cover, "notice") - form.cover.data = cover - # 处理富文本内容 + form.cover.data = save_file(cover, "notice") + + # 处理富文本内容中的图片 title = form.data.get("title", "") content = form.data.get("content", "") if content: form.content.data = save_content(content, title, "notice") - # 创建通知公告 - notice = Notice(**form.data) - notice.create_user = get_username() - notice.save() - # ============================================================ -# 通知公告更新 +# 模块级代理函数:保持 router 调用兼容 # ============================================================ - -def update_notice(data: dict) -> None: - """ - 更新通知公告 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 验证通知公告是否存在(复用 get_info) - 4. 处理富文本内容(保存图片) - 5. 更新通知公告信息 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = NoticeForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 获取通知公告ID - notice_id = form.data.get("id") - if not notice_id or int(notice_id) <= 0: - raise BizError("通知公告ID无效") - notice_id = int(notice_id) - - # 复用 get_info 方法验证通知公告是否存在 - notice = get_info(notice_id) - if not notice: - raise BizError("通知公告不存在") - - # 通知封面处理 - cover = form.data.get("cover") - if cover: - cover = save_file(cover, "notice") - form.cover.data = cover - # 处理富文本内容 - title = form.data.get("title", "") - content = form.data.get("content", "") - if content: - form.content.data = save_content(content, title, "notice") - - # 更新通知公告信息(移除ID,避免更新主键) - form_data = convert_camel_to_snake(form.data.copy()) - form_data.pop("id", None) - - form_data["update_time"] = datetime.now(tz=timezone.utc) - form_data["update_user"] = get_username() - - result = Notice.query.filter_by(id=notice_id).update(form_data) - - if not result: - raise BizError("更新失败") - - -# ============================================================ -# 通知公告删除 -# ============================================================ - - -def delete_notices(notice_ids: str) -> int: - """ - 删除通知公告(批量删除) - - 删除 - 支持逗号分隔的多个ID批量删除。 - - Args: - notice_ids: 通知公告ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - notice_ids_list = parse_id_list(notice_ids) - if not notice_ids_list: - raise BizError("无效的记录ID") - return Notice.batch_delete(notice_ids_list) +get_notice_page = NoticeService.get_page +get_notice_detail = NoticeService.get_detail +get_info = NoticeService.get_info +add_notice = NoticeService.add +update_notice = NoticeService.update +delete_notices = NoticeService.remove diff --git a/src/modules/operation_log/__init__.py b/src/modules/operation_log/__init__.py new file mode 100644 index 0000000..ea35d4a --- /dev/null +++ b/src/modules/operation_log/__init__.py @@ -0,0 +1 @@ +"""操作日志模块:独立表 flask_operation_log,含模型/仓储/服务/路由。""" diff --git a/src/modules/operation_log/models.py b/src/modules/operation_log/models.py new file mode 100644 index 0000000..f8ee0a5 --- /dev/null +++ b/src/modules/operation_log/models.py @@ -0,0 +1,135 @@ +""" +操作日志模型模块 +============================================================= + +提供操作日志(OperationLog)的数据模型定义,用于记录用户操作行为。 +""" + +from src.common.base_db import BaseDB +from src.common.base_model import BaseModel +from config import DB_PREFIX +from src.core.database import db + + +class OperationLog(BaseModel, BaseDB): + """ + 操作日志模型 + + 用于记录用户操作行为的详细信息,包括:操作标题、操作类型、 + 来源、请求信息、IP地址、归属地、设备信息、请求耗时、操作状态等。 + + Attributes: + id: 主键ID(继承自 BaseModel) + title: 请求标题(String(150),必填) + type: 请求类型(Integer,必填) + source: 请求来源(Integer,必填,0-后台,1-前台) + method: 请求方法(String(100),必填) + request_method: 请求方式(String(255),必填) + url: 请求URL(String(1000),必填) + param: 请求参数(Text,必填) + result: 请求结果(Text,必填) + ip: 请求IP(String(100),必填) + location: 请求所在区域(String(255),必填) + os: 操作系统(String(100),必填) + browser: 浏览器(String(100),必填) + consume_time: 请求耗时(Integer,必填,单位毫秒) + status: 请求状态(Integer,必填,0-正常,1-异常) + error: 错误描述(Text,必填) + """ + + # ============================================================ + # 表配置 + # ============================================================ + + # 设置表名 + __tablename__ = DB_PREFIX + "operation_log" + + # 表注释 + __table_comment__ = "操作日志表" + + # 时间范围筛选高频 → create_time 索引(继承自 BaseModel 的列) + __table_args__ = ( + db.Index('ix_operation_log_create_time', 'create_time'), + ) + + # ============================================================ + # 字段定义 + # ============================================================ + + # 请求标题 + title = db.Column(db.String(150), nullable=False, index=True, comment="请求标题") + + # 请求类型 + type = db.Column(db.Integer, nullable=False, index=True, comment="请求类型") + + # 请求来源:0-后台,1-前台 + source = db.Column(db.Integer, nullable=False, comment="请求来源:0-后台,1-前台") + + # 请求方法(如:UserController.add) + method = db.Column(db.String(100), nullable=False, comment="请求方法") + + # 请求方式(如:GET、POST、PUT、DELETE) + request_method = db.Column(db.String(255), nullable=False, comment="请求方式") + + # 请求URL + url = db.Column(db.String(1000), nullable=False, comment="请求URL") + + # 请求参数 + param = db.Column(db.Text, nullable=False, comment="请求参数") + + # 请求结果 + result = db.Column(db.Text, nullable=False, comment="请求结果") + + # 请求IP + ip = db.Column(db.String(100), nullable=False, comment="请求IP") + + # 请求所在区域 + location = db.Column(db.String(255), nullable=False, comment="请求所在区域") + + # 操作系统 + os = db.Column(db.String(100), nullable=False, comment="操作系统") + + # 浏览器 + browser = db.Column(db.String(100), nullable=False, comment="浏览器") + + # 请求耗时(毫秒) + consume_time = db.Column(db.BigInteger, nullable=False, comment="请求耗时") + + # 请求状态:0-正常,1-异常 + status = db.Column(db.Integer, nullable=False, comment="请求状态:0-正常,1-异常") + + # 错误描述 + error = db.Column(db.Text, nullable=False, comment="错误描述") + + # ============================================================ + # 常量定义 + # ============================================================ + + class Source: + """请求来源常量""" + + ADMIN = 0 # 后台 + FRONT = 1 # 前台 + + class Status: + """请求状态常量""" + + NORMAL = 0 # 正常 + ERROR = 1 # 异常 + + # ============================================================ + # 魔法方法 + # ============================================================ + + def __repr__(self): + """ + 返回模型的字符串表示 + + Returns: + str: 格式为 "操作日志:{title} - {method}" 的字符串 + """ + return "操作日志:{} - {}".format(self.title, self.method) + + def __str__(self): + """返回模型的字符串表示""" + return self.__repr__() diff --git a/src/modules/operation_log/repository.py b/src/modules/operation_log/repository.py new file mode 100644 index 0000000..e3feb70 --- /dev/null +++ b/src/modules/operation_log/repository.py @@ -0,0 +1,54 @@ +""" +OperationLog 仓储模块 +============================================================= + +数据访问层,继承 BaseRepository 获得通用 CRUD 能力。 +""" + +from src.common.constants.common import PAGE_SIZE +from src.common.base_repository import BaseRepository +from src.modules.operation_log.models import OperationLog + + +class OperationLogRepository(BaseRepository): + """操作日志数据仓储,提供操作日志相关的数据库访问操作""" + + model = OperationLog + + @classmethod + def page(cls, params: dict): + """ + 操作日志分页查询 + + 支持按标题模糊搜索,按类型、来源、状态精确过滤, + 按创建时间范围筛选,按创建时间倒序排列。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + - title: 日志标题(模糊匹配) + - type: 操作类型(精确匹配) + - source: 日志来源(精确匹配) + - status: 状态(精确匹配) + - startTime: 创建时间起始 + - endTime: 创建时间结束 + + Returns: + tuple: (items, total, pages) + - items: 当前页数据列表 + - total: 总记录数 + - pages: 总页数 + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + like_filters={'title': params.get('title')}, + filters={ + 'type': params.get('type'), + 'source': params.get('source'), + 'status': params.get('status'), + }, + range_filters={'create_time': (params.get('startTime'), params.get('endTime'))}, + order_desc='create_time', + ) diff --git a/src/modules/operation_log/router.py b/src/modules/operation_log/router.py new file mode 100644 index 0000000..b1c4bfc --- /dev/null +++ b/src/modules/operation_log/router.py @@ -0,0 +1,108 @@ +""" +操作日志控制器模块 +============================================================= + +提供操作日志(OperationLog)相关的API接口: +- 分页查询、详情查询、删除(支持批量) +""" + +from flask import Blueprint, request + +from src.middleware import check_demo, operation_log +from src.core.permission import check_permission +from src.modules.operation_log import service as operation_log_service +from src.common.utils.request import parse_batch_ids + +from src.core import response as R + +operation_log_blue = Blueprint("operation_log", __name__, url_prefix="/oper/log") + + +# ============================================================ +# 操作日志分页查询 +# ============================================================ + + +@operation_log_blue.route("/page", methods=["GET"]) +@check_permission("sys:operation_log:page") +@operation_log("操作日志", "查询") +def operation_log_page(): + """ + 查询操作日志分页数据 + + 支持按标题、操作类型、来源、状态、时间范围等条件筛选。 + """ + return R.ok(data=operation_log_service.get_operation_log_page(request.args)) + + +# ============================================================ +# 操作日志详情查询 +# ============================================================ + + +@operation_log_blue.route("/detail/", methods=["GET"]) +@check_permission("sys:operation_log:detail") +@operation_log("操作日志", "详情") +def operation_log_detail(id): + """ + 查询操作日志详情 + + Args: + id: 操作日志ID + + Returns: + JSON: 操作日志详细信息 + """ + data = operation_log_service.get_operation_log_detail(id) + if data: + return R.ok(data=data) + return R.failed("日志不存在") + + +# ============================================================ +# 操作日志删除 +# ============================================================ + + +@operation_log_blue.route("/delete/", methods=["DELETE"]) +@check_permission("sys:operation_log:delete") +@check_demo +@operation_log("操作日志", "删除") +def operation_log_delete(id): + """ + 删除操作日志 + + Args: + id: 操作日志ID(支持逗号分隔的多个ID) + + Returns: + JSON: 操作结果 + """ + count = operation_log_service.delete_operation_logs(id) + return R.ok(msg=f"本次共删除{count}条数据") + + +# ============================================================ +# 操作日志批量删除 +# ============================================================ + + +@operation_log_blue.route("/batchDelete", methods=["DELETE"]) +@check_permission("sys:operation_log:batchDelete") +@check_demo +@operation_log("操作日志", "批量删除") +def operation_log_batch_delete(): + """ + 批量删除操作日志 + + Request Body: + [1, 2, 3] # 操作日志ID数组 + + Returns: + JSON: 操作结果 + """ + id_str, err = parse_batch_ids() + if err: + return err + count = operation_log_service.delete_operation_logs(id_str) + return R.ok(msg=f"本次共删除{count}条数据") diff --git a/src/modules/operation_log/service.py b/src/modules/operation_log/service.py new file mode 100644 index 0000000..5e234d1 --- /dev/null +++ b/src/modules/operation_log/service.py @@ -0,0 +1,102 @@ +""" +操作日志管理服务模块 +============================================================= + +提供操作日志(OperationLog)的分页/详情/删除,以及审计写入 record_operation_log。 +""" + +import logging + +from src.common.base_service import BaseService +from src.modules.operation_log.models import OperationLog +from src.modules.operation_log.repository import OperationLogRepository +from src.core.database import commit_independently + +# 获取日志记录器 +logger = logging.getLogger(__name__) + + +class OperationLogService(BaseService): + """ + 操作日志服务类 + + 继承 BaseService 获得标准 CRUD 能力,无表单校验。 + """ + + repository = OperationLogRepository + model_name = "操作日志" + + # 枚举显示名映射(自动翻译为 {field}Text 字段) + serialize_maps = { + 'type': 'operation_type', + 'source': 'operation_source', + } + +# ============================================================ +# 模块级代理函数:保持 router 调用兼容 +# ============================================================ + +get_operation_log_page = OperationLogService.get_page +get_operation_log_detail = OperationLogService.get_detail +get_operation_log_info = OperationLogService.get_info +delete_operation_logs = OperationLogService.remove + + +def record_operation_log(title, log_type, source, status, **kwargs): + """ + 记录操作日志(便捷方法) + + 供业务接口直接调用,无需通过HTTP请求。 + 使用独立session写入,不受请求事务commit/rollback影响。 + + Args: + title: 日志标题 + log_type: 操作类型 + source: 日志来源(0-后台,1-前台) + status: 操作状态(0-正常,1-异常) + **kwargs: 其他可选参数 + - method: HTTP请求方法 + - request_method: 请求端点 + - url: 请求URL + - param: 请求参数 + - result: 响应结果 + - ip: 客户端IP + - location: IP归属地 + - os: 操作系统 + - browser: 浏览器 + - consume_time: 请求耗时(毫秒) + - error: 错误信息 + - username: 操作用户名 + + Returns: + OperationLog/None: 创建的日志对象,失败返回None + """ + try: + username = kwargs.get("username", "system") + log_data = { + "title": title, + "type": log_type, + "source": source, + "method": kwargs.get("method", ""), + "request_method": kwargs.get("request_method", ""), + "url": kwargs.get("url", ""), + "param": kwargs.get("param", ""), + "result": kwargs.get("result", ""), + "ip": kwargs.get("ip", ""), + "location": kwargs.get("location", ""), + "os": kwargs.get("os", ""), + "browser": kwargs.get("browser", ""), + "consume_time": kwargs.get("consume_time", 0), + "status": status, + "error": kwargs.get("error", ""), + "create_user": username, + "update_user": username, + } + + # 使用独立session写入,不受请求事务影响 + # 日志是审计数据,无论业务成功或失败都应持久化 + return commit_independently(OperationLog, log_data) + + except Exception as e: + logger.error(f"记录操作日志异常: {str(e)}") + return None diff --git a/src/modules/param/models.py b/src/modules/param/models.py index ec5beae..152e40b 100644 --- a/src/modules/param/models.py +++ b/src/modules/param/models.py @@ -40,6 +40,9 @@ class Param(BaseModel, BaseDB): # 设置表名 __tablename__ = DB_PREFIX + "param" + # 表注释 + __table_comment__ = "系统参数表" + # ============================================================ # 字段定义 # ============================================================ @@ -48,6 +51,7 @@ class Param(BaseModel, BaseDB): name = db.Column( db.String(100), nullable=False, + index=True, comment="参数名称" ) @@ -55,6 +59,7 @@ class Param(BaseModel, BaseDB): code = db.Column( db.String(100), nullable=False, + index=True, comment="参数编码" ) @@ -76,6 +81,7 @@ class Param(BaseModel, BaseDB): status = db.Column( db.Integer, nullable=False, + index=True, comment="参数状态:1-正常 2-禁用" ) diff --git a/src/modules/param/repository.py b/src/modules/param/repository.py index 065e4ef..72ade87 100644 --- a/src/modules/param/repository.py +++ b/src/modules/param/repository.py @@ -6,10 +6,41 @@ Param 仓储模块 """ from src.common.base_repository import BaseRepository +from src.common.constants.common import PAGE_SIZE from src.modules.param.models import Param class ParamRepository(BaseRepository): - """Param 仓储""" + """参数数据仓储,提供参数相关的数据库访问操作""" model = Param + + @classmethod + def page(cls, params: dict): + """ + 参数分页查询 + + 支持按参数名称和编码模糊搜索,按类型和状态精确过滤,按 sort 字段升序排列。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + - name: 参数名称(模糊匹配) + - code: 参数编码(模糊匹配) + - type: 参数类型(精确匹配) + - status: 状态(精确匹配) + + Returns: + tuple: (items, total, pages) + - items: 当前页数据列表 + - total: 总记录数 + - pages: 总页数 + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + filters={'type': params.get('type'), 'status': params.get('status')}, + like_filters={'name': params.get('name'), 'code': params.get('code')}, + order_by='sort', + ) diff --git a/src/modules/param/router.py b/src/modules/param/router.py index 38ae364..3439720 100644 --- a/src/modules/param/router.py +++ b/src/modules/param/router.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -59,7 +59,7 @@ """ from flask import Blueprint, request -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.core.permission import check_permission from src.modules.param import service as param from src.common.utils.request import parse_batch_ids, parse_request_body @@ -76,6 +76,7 @@ param_blue = Blueprint("param", __name__, url_prefix="/param") @param_blue.route("/page", methods=["GET"]) @check_permission("sys:param:page") +@operation_log("参数配置", "查询") def page(): """ 查询参数分页数据 @@ -96,6 +97,7 @@ def page(): @param_blue.route("/detail/", methods=["GET"]) @check_permission("sys:param:detail") +@operation_log("参数配置", "详情") def detail(id): """ 查询参数详情 @@ -118,6 +120,7 @@ def detail(id): @param_blue.route("/add", methods=["POST"]) @check_permission("sys:param:add") @check_demo +@operation_log("参数配置", "新增") def add(): """ 添加参数 @@ -143,6 +146,7 @@ def add(): @param_blue.route("/update", methods=["PUT"]) @check_permission("sys:param:update") @check_demo +@operation_log("参数配置", "修改") def update(): """ 更新参数 @@ -168,6 +172,7 @@ def update(): @param_blue.route("/delete/", methods=["DELETE"]) @check_permission("sys:param:delete") @check_demo +@operation_log("参数配置", "删除") def delete(id): """ 删除参数 @@ -188,8 +193,9 @@ def delete(id): @param_blue.route("/batchDelete", methods=["DELETE"]) -@check_permission("sys:param:batch_delete") +@check_permission("sys:param:batchDelete") @check_demo +@operation_log("参数配置", "批量删除") def batch_delete(): """ 批量删除参数 @@ -206,7 +212,8 @@ def batch_delete(): id_str, err = parse_batch_ids() if err: return err - return param.delete_params(id_str) + count = param.delete_params(id_str) + return R.ok(msg=f"本次共删除{count}条数据") # ============================================================ @@ -217,6 +224,7 @@ def batch_delete(): @param_blue.route("/status", methods=["PUT"]) @check_permission("sys:param:status") @check_demo +@operation_log("参数配置", "状态变更") def status(): """ 设置参数状态 diff --git a/src/modules/param/schema.py b/src/modules/param/schema.py index ab29996..28a025b 100644 --- a/src/modules/param/schema.py +++ b/src/modules/param/schema.py @@ -11,7 +11,7 @@ from wtforms import ValidationError, StringField, IntegerField from wtforms.validators import DataRequired, Length, NumberRange, Optional from src.common.forms.base_form import BaseForm -from src.modules.param.models import Param +from src.modules.param.repository import ParamRepository # ============================================================ @@ -156,15 +156,8 @@ class ParamForm(BaseForm): # 获取当前记录的ID(更新时存在,添加时为None) current_id = self.id.data if self.id.data else None - # 构建查询:查找同名记录 - query = Param.query.filter(Param.name == field.data, Param.is_delete == 0) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(Param.id != current_id) - - # 如果存在同名记录,抛出验证错误 - if query.first(): + # 唯一性校验(软删过滤 + 更新时排除自身) + if ParamRepository.exists_by({'name': field.data}, exclude_id=current_id): raise ValidationError("参数名称已存在") def validate_code(self, field): @@ -187,15 +180,8 @@ class ParamForm(BaseForm): # 获取当前记录的ID(更新时存在,添加时为None) current_id = self.id.data if self.id.data else None - # 构建查询:查找相同编码的记录 - query = Param.query.filter(Param.code == field.data, Param.is_delete == 0) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(Param.id != current_id) - - # 如果存在相同编码的记录,抛出验证错误 - if query.first(): + # 唯一性校验(软删过滤 + 更新时排除自身) + if ParamRepository.exists_by({'code': field.data}, exclude_id=current_id): raise ValidationError("参数编码已存在") diff --git a/src/modules/param/service.py b/src/modules/param/service.py index 1033740..50ef12d 100644 --- a/src/modules/param/service.py +++ b/src/modules/param/service.py @@ -3,344 +3,35 @@ ============================================================= 该模块实现了参数管理相关的所有业务逻辑。 -包括参数的分页查询、详情查询、添加、更新、删除、状态设置、导入导出等功能。 参数用于管理系统配置和业务配置,每个参数包含名称、编码、值、类型、排序、状态等属性。 - -命名规范: -- 函数参数使用驼峰命名(如 paramId, pageNo) -- 表单字段使用下划线命名(如 name, code, sort)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters, _build_page_data) - -功能模块: -1. 分页查询:支持按名称模糊搜索、编码模糊搜索、类型筛选、状态筛选,按sort字段排序 -2. 详情查询:根据ID获取单个参数的详细信息 -3. 数据维护:参数的添加、更新、删除(支持批量) -4. 状态管理:单独更新参数的启用/禁用状态 -5. Excel导入:批量导入参数数据 -6. Excel导出:导出参数数据到Excel文件 -7. 数据列表:获取所有启用的参数列表(用于下拉选择等场景) -8. 根据编码获取参数值:通过参数编码获取对应的参数值 """ -import logging - -from datetime import datetime, timezone - -from src.common.constants.common import PAGE_SIZE +from src.common.base_service import BaseService from src.modules.param.schema import ParamForm, ParamStatusForm -from src.modules.param.models import Param from src.modules.param.repository import ParamRepository -from src.core.exceptions import BizError -from src.common.utils import regular -from src.common.utils.model_utils import models_to_list, model_to_dict -from src.common.utils.request import parse_id_list, like_contains -from src.core.security import get_username -# 获取日志记录器 -logger = logging.getLogger(__name__) + +class ParamService(BaseService): + """ + 参数服务类 + + 继承 BaseService 获得标准 CRUD 能力。 + """ + + repository = ParamRepository + form = ParamForm + status_form = ParamStatusForm + model_name = "参数" # ============================================================ -# 参数分页查询 +# 模块级代理函数:保持 router 调用兼容 # ============================================================ - -def get_param_page(params: dict) -> dict: - """ - 查询参数分页数据 - - Args: - request: Flask request对象,包含查询参数 - - pageNo: 当前页码(可选,默认为1) - - pageSize: 每页记录数(可选,默认为PAGE_SIZE) - - name: 参数名称(可选,模糊查询) - - code: 参数编码(可选,模糊查询) - - type: 参数类型(可选,精确匹配) - - status: 参数状态(可选,精确匹配) - - Returns: - R: 包含分页数据的JSON响应,格式为: - { - "records": 参数列表, - "total": 总记录数, - "size": 每页大小, - "current": 当前页码, - "pages": 总页数 - } - """ - # 获取分页参数 - page_no = int(params.get("pageNo", 1)) - page_size = int(params.get("pageSize", PAGE_SIZE)) - - # 应用筛选条件 - query = _apply_filters(params) - - # 记录总数 - total = query.count() - # 计算总页数 - pages = (total + page_size - 1) // page_size if total > 0 else 0 - - # 分页查询 - param_list = query.limit(page_size).offset((page_no - 1) * page_size).all() - # 构建返回数据 - page_data = _build_page_data(param_list, total, page_size, page_no, pages) - return page_data - - -def _apply_filters(params: dict): - """ - 应用查询筛选条件 - - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 支持参数名称模糊查询 - - 支持参数编码模糊查询 - - 支持参数类型精确查询 - - 支持参数状态精确查询 - - 默认按排序值升序排列 - - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = Param.query.filter(Param.is_delete == 0) - - # 参数名称模糊筛选 - name = params.get("name") - if name: - query = query.filter(Param.name.like(like_contains(name), escape="\\")) - - # 参数编码模糊筛选 - code = params.get("code") - if code: - query = query.filter(Param.code.like(like_contains(code), escape="\\")) - - # 参数类型精确筛选 - param_type = params.get("type") - if param_type: - query = query.filter(Param.type == param_type) - - # 参数状态精确筛选 - status = params.get("status") - if status: - query = query.filter(Param.status == status) - - # 按排序值升序排列 - return query.order_by(Param.sort.asc()) - - -def _build_page_data(param_list, total, page_size, page_no, pages): - """ - 构建分页返回数据 - - Args: - param_list: 参数对象列表 - total: 总记录数 - page_size: 每页大小 - page_no: 当前页码 - pages: 总页数 - - Returns: - dict: 分页数据,包含 records、total、size、current、pages - """ - # 将模型对象列表转换为字典列表 - records = models_to_list(param_list) - - return {"records": records, "total": total, "size": page_size, "current": page_no, "pages": pages} - - -# ============================================================ -# 参数详情查询 -# ============================================================ - - -def get_param_detail(param_id): - """ - 根据ID查询参数详情(返回字典格式) - - 此方法调用 get_info 获取模型对象,然后转换为字典格式返回。 - 适用于前端接口调用。 - - Args: - param_id: 参数ID - - Returns: - dict/None: 参数详情字典,不存在时返回 None - """ - try: - # 复用 get_info 方法查询参数 - param = get_info(param_id) - if not param: - return None - # 转换为字典格式 - return model_to_dict(param) - - except Exception as e: - logger.error(f"查询参数详情异常,ID: {param_id}, 错误: {str(e)}") - return None - - -# ============================================================ -# 参数信息查询(根据ID)- 核心查询方法 -# ============================================================ - - -def get_info(param_id: int): - """ - 根据ID查询参数信息(返回模型对象) - - 这是一个通用的参数查询方法,返回参数模型对象而非字典。 - 与 get_param_detail 的区别: - - get_param_detail: 返回字典格式,用于前端接口 - - get_info: 返回模型对象,用于内部服务调用 - - 此方法被以下方法复用: - - get_param_detail: 获取详情字典 - - update_param: 验证参数是否存在 - - update_param_status: 验证参数是否存在 - - Args: - param_id: 参数ID - - Returns: - Param/None: 参数模型对象,不存在或已删除时返回 None - - Example: - param = get_info(param_id) - if param: - print(param.name) - """ - return ParamRepository.get_by_id(param_id) - - -def add_param(data: dict) -> None: - """ - 添加参数 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证(名称唯一性、编码唯一性、字段格式等) - 3. 创建参数对象并保存到数据库 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = ParamForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 创建参数 - param = Param(**form.data) - param.create_user = get_username() - param.save() - - -# ============================================================ -# 参数更新 -# ============================================================ - - -def update_param(data: dict) -> None: - """ - 更新参数 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 验证参数是否存在(复用 get_info) - 4. 更新参数信息 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = ParamForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 获取参数ID - param_id = form.data.get("id") - if not param_id or int(param_id) <= 0: - raise BizError("参数ID无效") - param_id = int(param_id) - - # 复用 get_info 方法验证参数是否存在 - param = get_info(param_id) - if not param: - raise BizError("参数不存在") - - # 更新参数信息(移除ID,避免更新主键) - form_data = form.data.copy() - form_data.pop("id", None) - - form_data["update_time"] = datetime.now(tz=timezone.utc) - form_data["update_user"] = get_username() - - result = Param.query.filter_by(id=param_id).update(form_data) - - if not result: - raise BizError("更新失败") - - -# ============================================================ -# 参数删除 -# ============================================================ - - -def delete_params(param_ids: str) -> int: - """ - 删除参数(批量删除) - - 删除 - 支持逗号分隔的多个ID批量删除。 - - Args: - param_ids: 参数ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - param_ids_list = parse_id_list(param_ids) - if not param_ids_list: - raise BizError("无效的记录ID") - return Param.batch_delete(param_ids_list) - - -# ============================================================ -# 参数状态设置 -# ============================================================ - - -def update_param_status(data: dict) -> None: - """ - 设置参数状态 - - 单独更新参数的启用/禁用状态,不涉及其他字段。 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = ParamStatusForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - param_id = int(form.data["id"]) - status = int(form.data["status"]) - - # 复用 get_info 方法验证参数是否存在 - param = get_info(param_id) - if not param: - raise BizError("参数不存在") - - # 更新状态 - update_data = {"status": status, "update_time": datetime.now(tz=timezone.utc), "update_user": get_username()} - result = Param.query.filter_by(id=param_id).update(update_data) - - if not result: - raise BizError("设置失败") +get_param_page = ParamService.get_page +get_param_detail = ParamService.get_detail +get_info = ParamService.get_info +add_param = ParamService.add +update_param = ParamService.update +delete_params = ParamService.remove +update_param_status = ParamService.update_status diff --git a/src/modules/system/dept/models.py b/src/modules/system/dept/models.py index 85c8190..ecbf0a3 100644 --- a/src/modules/system/dept/models.py +++ b/src/modules/system/dept/models.py @@ -1,44 +1,3 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 -# +====================================================================== -# | 版权和免责声明: -# | 一、知识产权声明 -# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 -# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, -# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 -# | 二、使用限制与禁止条款 -# | 您理解并同意,禁止将本产品用于以下任何行为: -# | 1. 任何违反中华人民共和国法律法规的用途; -# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; -# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 -# | 三、责任豁免 -# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 -# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 -# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 -# | 四、违约与法律责任 -# | 如发现任何违反本声明的行为,本团队有权: -# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; -# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; -# | 3. 保留进一步采取刑事控告等法律措施的权利。 -# | 五、许可范围说明 -# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 -# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 -# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 -# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 -# | 成的一切经济损失及商誉损害。 -# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 -# | 许可协议》附件为准。 -# +====================================================================== - """ 部门模型模块 ============================================================= @@ -49,6 +8,7 @@ from src.common.base_db import BaseDB from src.common.base_model import BaseModel from config import DB_PREFIX +from sqlalchemy import text from src.core.database import db @@ -76,9 +36,12 @@ class Dept(BaseModel, BaseDB): # 表配置 # ============================================================ - # 设置表名 + # 设置表名,使用配置中的表前缀 __tablename__ = DB_PREFIX + "dept" + # 表注释 + __table_comment__ = "部门表" + # ============================================================ # 字段定义 # ============================================================ @@ -87,6 +50,7 @@ class Dept(BaseModel, BaseDB): name = db.Column( db.String(100), nullable=False, + index=True, comment="部门名称" ) @@ -94,24 +58,28 @@ class Dept(BaseModel, BaseDB): type = db.Column( db.Integer, default=0, + server_default=text('0'), comment="部门类型:1-公司 2-子公司 3-部门 4-小组" ) - # 上级部门ID + # 上级部门ID,0表示顶级部门 parent_id = db.Column( db.Integer, default=0, + server_default=text('0'), + index=True, comment="上级部门ID" ) - # 部门排序 + # 部门排序,数值越小越靠前 sort = db.Column( db.Integer, default=0, + server_default=text('0'), comment="部门排序" ) - # 备注 + # 备注信息 note = db.Column( db.String(255), nullable=True, diff --git a/src/modules/system/dept/repository.py b/src/modules/system/dept/repository.py index e18fad9..ca6ec97 100644 --- a/src/modules/system/dept/repository.py +++ b/src/modules/system/dept/repository.py @@ -10,6 +10,33 @@ from src.modules.system.dept.models import Dept class DeptRepository(BaseRepository): - """Dept 仓储""" + """部门数据仓储,提供部门相关的数据库访问操作""" model = Dept + + @classmethod + def find_list_params(cls, params: dict): + """ + 按查询参数获取部门列表 + + 支持按部门名称模糊查询,按 sort 字段升序排列。 + + Args: + params: 查询参数字典,可包含 name 字段用于模糊搜索 + + Returns: + list: 部门实例列表 + """ + return cls.find_list(like_filters={'name': params.get('name')}, order_by='sort') + + @classmethod + def list_tree(cls): + """ + 查询启用状态的部门 + + 用于树形结构展示或下拉选择场景,仅返回启用状态的部门。 + + Returns: + list: 启用状态的部门实例列表,按 sort 升序排列 + """ + return cls.find_list(filters={'status': 1}, order_by='sort') diff --git a/src/modules/system/dept/router.py b/src/modules/system/dept/router.py index 63e408b..b3b1f65 100644 --- a/src/modules/system/dept/router.py +++ b/src/modules/system/dept/router.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -57,7 +57,7 @@ from flask import Blueprint, request -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.core.permission import check_permission from src.modules.system.dept import service as dept from src.common.utils.request import parse_request_body @@ -74,6 +74,7 @@ dept_blue = Blueprint("dept", __name__, url_prefix="/dept") @dept_blue.route("/list", methods=["GET"]) @check_permission("sys:dept:list") +@operation_log("部门管理", "查询") def list(): """ 查询部门列表 @@ -94,6 +95,7 @@ def list(): @dept_blue.route("/detail/", methods=["GET"]) @check_permission("sys:dept:detail") +@operation_log("部门管理", "详情") def detail(id): """ 查询部门详情 @@ -116,6 +118,7 @@ def detail(id): @dept_blue.route("/add", methods=["POST"]) @check_permission("sys:dept:add") @check_demo +@operation_log("部门管理", "新增") def add(): """ 添加部门 @@ -141,6 +144,7 @@ def add(): @dept_blue.route("/update", methods=["PUT"]) @check_permission("sys:dept:update") @check_demo +@operation_log("部门管理", "修改") def update(): """ 更新部门 @@ -166,6 +170,7 @@ def update(): @dept_blue.route("/delete/", methods=["DELETE"]) @check_permission("sys:dept:delete") @check_demo +@operation_log("部门管理", "删除") def delete(id): """ 删除部门 @@ -186,6 +191,8 @@ def delete(id): @dept_blue.route("/getDeptList", methods=["GET"]) +@check_permission("sys:dept:list") +@operation_log("部门管理", "查询") def get_dept_list(): """ 获取部门数据列表 diff --git a/src/modules/system/dept/schema.py b/src/modules/system/dept/schema.py index f3ed97b..946b45c 100644 --- a/src/modules/system/dept/schema.py +++ b/src/modules/system/dept/schema.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -51,7 +51,7 @@ from wtforms import ValidationError, IntegerField, StringField from wtforms.validators import NumberRange, DataRequired, Length, Optional from src.common.forms.base_form import BaseForm -from src.modules.system.dept.models import Dept +from src.modules.system.dept.repository import DeptRepository # ============================================================ @@ -117,6 +117,7 @@ class DeptForm(BaseForm): parentId = IntegerField( label='上级ID', validators=[ + Optional(), # 允许 0(顶级部门) NumberRange(min=0, message='上级ID不得小于0') ], description='上级部门ID,0表示顶级部门' @@ -169,13 +170,6 @@ class DeptForm(BaseForm): # 获取当前记录的ID(更新时存在,添加时为None) current_id = self.id.data if self.id.data else None - # 构建查询:查找同名记录 - query = Dept.query.filter(Dept.name == field.data, Dept.is_delete == 0) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(Dept.id != current_id) - - # 如果存在同名记录,抛出验证错误 - if query.first(): + # 唯一性校验(软删过滤 + 更新时排除自身) + if DeptRepository.exists_by({'name': field.data}, exclude_id=current_id): raise ValidationError("部门名称已存在") diff --git a/src/modules/system/dept/service.py b/src/modules/system/dept/service.py index a30c0c1..256b8e4 100644 --- a/src/modules/system/dept/service.py +++ b/src/modules/system/dept/service.py @@ -1,89 +1,62 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 -# +====================================================================== -# | 版权和免责声明: -# | 一、知识产权声明 -# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 -# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, -# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 -# | 二、使用限制与禁止条款 -# | 您理解并同意,禁止将本产品用于以下任何行为: -# | 1. 任何违反中华人民共和国法律法规的用途; -# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; -# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 -# | 三、责任豁免 -# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 -# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 -# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 -# | 四、违约与法律责任 -# | 如发现任何违反本声明的行为,本团队有权: -# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; -# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; -# | 3. 保留进一步采取刑事控告等法律措施的权利。 -# | 五、许可范围说明 -# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 -# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 -# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 -# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 -# | 成的一切经济损失及商誉损害。 -# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 -# | 许可协议》附件为准。 -# +====================================================================== - """ 部门管理服务模块 ============================================================= 该模块实现了部门管理相关的所有业务逻辑。 -包括部门的列表查询、详情查询、添加、更新、删除等功能。 部门用于组织架构管理,如:技术部、市场部、销售部等, -每个部门包含名称、父级ID、排序、状态等字段,通过sort字段控制显示顺序。 +每个部门包含名称、父级ID、排序、状态等字段,通过 sort 字段控制显示顺序。 -命名规范: -- 函数参数使用驼峰命名(如 deptId, pageNo) -- 表单字段使用下划线命名(如 name, sort, status)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters) - -功能模块: -1. 列表查询:支持按名称模糊搜索,按sort字段排序 -2. 详情查询:根据ID获取单个部门的详细信息 -3. 数据维护:部门的添加、更新、删除(支持批量) -4. 部门列表:获取所有部门数据(用于下拉选择、树形展示等场景) +实现说明(P3 服务层重构): + - 标准 CRUD(详情/新增/更新/删除)由 BaseService 提供 + - 删除前需校验无子部门(before_delete 钩子) + - 部门列表/树形数据为特有逻辑(find_list_params / list_tree),保留为模块函数 + - 模块级代理函数保持 router(dept.get_dept_xxx)调用零改动 """ import logging -from datetime import datetime, timezone -from sqlalchemy import and_ - -from src.modules.system.dept.schema import DeptForm -from src.modules.system.dept.models import Dept -from src.modules.system.dept.repository import DeptRepository +from src.common.base_service import BaseService from src.core.exceptions import BizError -from src.common.utils import regular -from src.common.utils.case_converter import convert_camel_to_snake -from src.common.utils.request import parse_id_list, like_contains -from src.common.utils.model_utils import models_to_list, model_to_dict -from src.core.security import get_username +from src.modules.system.dept.repository import DeptRepository +from src.modules.system.dept.schema import DeptForm +from src.modules.system.user.repository import UserRepository +from src.common.utils.model_utils import models_to_list # 获取日志记录器 logger = logging.getLogger(__name__) +class DeptService(BaseService): + """部门服务:标准 CRUD + 删除前子部门校验""" + + repository = DeptRepository + form = DeptForm + model_name = "部门" + + @classmethod + def before_delete(cls, ids): + """删除前校验:存在子部门或仍被用户引用时拒绝删除""" + for dept_id in ids: + if DeptRepository.count_children(dept_id) > 0: + raise BizError("存在子部门,请先删除子部门") + if UserRepository.count_by({'dept_id': dept_id}) > 0: + raise BizError(f"部门ID {dept_id} 已被用户引用,无法删除") + + # ============================================================ -# 部门列表查询 +# 模块级代理函数:保持 router 调用兼容 # ============================================================ +get_dept_detail = DeptService.get_detail +get_info = DeptService.get_info +add_dept = DeptService.add +update_dept = DeptService.update +delete_depts = DeptService.remove + + +# ============================================================ +# 部门列表查询(无分页) +# ============================================================ def get_dept_list(params: dict) -> list: """ @@ -98,218 +71,14 @@ def get_dept_list(params: dict) -> list: Returns: list: 部门字典列表 """ - # 应用筛选条件 - query = _apply_filters(params) - - # 查询数据 - dept_list = query.all() - - # 转换为字典列表 + dept_list = DeptRepository.find_list_params(params) return models_to_list(dept_list) -def _apply_filters(params: dict): - """ - 应用查询筛选条件 - - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 支持部门名称模糊查询 - - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = Dept.query.filter(Dept.is_delete == 0) - - # 部门名称模糊筛选 - name = params.get("name") - if name: - query = query.filter(Dept.name.like(like_contains(name), escape="\\")) - - # 按排序值升序排列 - return query.order_by(Dept.sort.asc()) - - -# ============================================================ -# 部门详情查询 -# ============================================================ - - -def get_dept_detail(dept_id): - """ - 根据ID查询部门详情(返回字典格式) - - 此方法调用 get_info 获取模型对象,然后转换为字典格式返回。 - 适用于前端接口调用。 - - Args: - dept_id: 部门ID - - Returns: - dict/None: 部门详情字典,不存在时返回 None - """ - try: - # 复用 get_info 方法查询部门 - dept = get_info(dept_id) - if not dept: - return None - # 转换为字典格式 - return model_to_dict(dept) - - except Exception as e: - logger.error(f"查询部门详情异常,ID: {dept_id}, 错误: {str(e)}") - return None - - -# ============================================================ -# 部门信息查询(根据ID)- 核心查询方法 -# ============================================================ - - -def get_info(dept_id: int): - """ - 根据ID查询部门信息(返回模型对象) - - 这是一个通用的部门查询方法,返回部门模型对象而非字典。 - 与 get_dept_detail 的区别: - - get_dept_detail: 返回字典格式,用于前端接口 - - get_info: 返回模型对象,用于内部服务调用 - - 此方法被以下方法复用: - - get_dept_detail: 获取详情字典 - - update_dept: 验证部门是否存在 - - delete_depts: 验证部门是否存在 - - Args: - dept_id: 部门ID - - Returns: - Dept/None: 部门模型对象,不存在或已删除时返回 None - - Example: - dept = get_info(dept_id) - if dept: - print(dept.name) - """ - return DeptRepository.get_by_id(dept_id) - - -def add_dept(data: dict) -> None: - """ - 添加部门 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证(名称唯一性、字段格式等) - 3. 创建部门对象并保存到数据库 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = DeptForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 创建部门 - dept = Dept(**form.data) - dept.create_user = get_username() - dept.save() - - -# ============================================================ -# 部门更新 -# ============================================================ - - -def update_dept(data: dict) -> None: - """ - 更新部门 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 验证部门是否存在(复用 get_info) - 4. 更新部门信息 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = DeptForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 获取部门ID - dept_id = form.data.get("id") - if not dept_id or int(dept_id) <= 0: - raise BizError("部门ID无效") - dept_id = int(dept_id) - - # 复用 get_info 方法验证部门是否存在 - dept = get_info(dept_id) - if not dept: - raise BizError("部门不存在") - - # 更新部门信息(移除ID,避免更新主键) - form_data = convert_camel_to_snake(form.data.copy()) - form_data.pop("id", None) - - form_data["update_time"] = datetime.now(tz=timezone.utc) - form_data["update_user"] = get_username() - - result = Dept.query.filter_by(id=dept_id).update(form_data) - - if not result: - raise BizError("更新失败") - - -# ============================================================ -# 部门删除 -# ============================================================ - - -def delete_depts(dept_ids: str) -> int: - """ - 删除部门(批量删除) - - 采用软删除方式,将 is_delete 标识设置为 1。 - 支持逗号分隔的多个ID批量删除。 - - Args: - dept_ids: 部门ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - if not dept_ids: - raise BizError("记录ID不存在") - - # 解析ID列表 - id_list = parse_id_list(dept_ids) - if not id_list: - raise BizError("无效的记录ID") - - # 检查是否有子部门 - for dept_id in id_list: - child_count = Dept.query.filter(and_(Dept.parent_id == dept_id, Dept.is_delete == 0)).count() - if child_count > 0: - raise BizError("存在子部门,请先删除子部门") - - dept_ids_list = parse_id_list(dept_ids) - if not dept_ids_list: - raise BizError("无效的记录ID") - return Dept.batch_delete(dept_ids_list) - - # ============================================================ # 部门树形数据获取 # ============================================================ - def get_dept_data(): """ 获取部门树形数据(无分页) @@ -321,14 +90,8 @@ def get_dept_data(): list: 部门列表,每个元素为部门模型字典,无数据时返回空列表 """ try: - # 查询所有未删除的部门 - dept_list = Dept.query.filter(and_(Dept.is_delete == 0, Dept.status == 1)).order_by(Dept.sort.asc()).all() - - # 转换为字典列表 - result = models_to_list(dept_list) - - return result - + dept_list = DeptRepository.list_tree() + return models_to_list(dept_list) except Exception as e: logger.error(f"获取部门树形数据异常: {str(e)}") return [] diff --git a/src/modules/system/level/models.py b/src/modules/system/level/models.py index 9e5bd65..58267bf 100644 --- a/src/modules/system/level/models.py +++ b/src/modules/system/level/models.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -49,6 +49,7 @@ from src.common.base_db import BaseDB from src.common.base_model import BaseModel from config import DB_PREFIX +from sqlalchemy import text from src.core.database import db @@ -77,6 +78,9 @@ class Level(BaseModel, BaseDB): # 设置表名 __tablename__ = DB_PREFIX + "level" + # 表注释 + __table_comment__ = "职级表" + # ============================================================ # 字段定义 # ============================================================ @@ -85,6 +89,7 @@ class Level(BaseModel, BaseDB): name = db.Column( db.String(255), nullable=False, + index=True, comment="职级名称" ) @@ -92,6 +97,8 @@ class Level(BaseModel, BaseDB): status = db.Column( db.Integer, default=0, + server_default=text('0'), + index=True, comment="职级状态:1-在用 2-停用" ) @@ -99,6 +106,7 @@ class Level(BaseModel, BaseDB): sort = db.Column( db.Integer, default=0, + server_default=text('0'), comment="职级排序" ) diff --git a/src/modules/system/level/repository.py b/src/modules/system/level/repository.py index f7e58e7..24de0b2 100644 --- a/src/modules/system/level/repository.py +++ b/src/modules/system/level/repository.py @@ -6,10 +6,60 @@ Level 仓储模块 """ from src.common.base_repository import BaseRepository +from src.common.constants.common import PAGE_SIZE from src.modules.system.level.models import Level class LevelRepository(BaseRepository): - """Level 仓储""" + """职级数据仓储,提供职级相关的数据库访问操作""" model = Level + + @classmethod + def page(cls, params: dict): + """ + 职级分页查询 + + 支持按名称模糊搜索、按状态精确过滤,按 sort 字段升序排列。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + - name: 职级名称(模糊匹配) + - status: 状态(精确匹配) + + Returns: + tuple: (items, total, pages) + - items: 当前页数据列表 + - total: 总记录数 + - pages: 总页数 + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + filters={'status': params.get('status')}, + like_filters={'name': params.get('name')}, + order_by='sort', + ) + + @classmethod + def list_enabled(cls, params=None): + """ + 查询启用的职级列表 + + 用于下拉选择场景,仅返回启用状态的职级(status=1 为硬性条件,不可传参覆盖)。 + 支持与分页一致的条件筛选(name 模糊匹配)。 + + Args: + params: 查询参数字典,可包含 name 字段用于模糊搜索 + + Returns: + list: 启用状态的职级实例列表,按 sort 升序排列 + """ + params = params or {} + return cls.find_list( + filters={'status': 1}, + like_filters={'name': params.get('name')}, + order_by='sort', + ) diff --git a/src/modules/system/level/router.py b/src/modules/system/level/router.py index ce11a4d..1107080 100644 --- a/src/modules/system/level/router.py +++ b/src/modules/system/level/router.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -58,7 +58,7 @@ """ from flask import Blueprint, request -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.core.permission import check_permission from src.modules.system.level import service as level from src.common.utils.request import parse_batch_ids, parse_request_body @@ -75,6 +75,7 @@ level_blue = Blueprint("level", __name__, url_prefix="/level") @level_blue.route("/page", methods=["GET"]) @check_permission("sys:level:page") +@operation_log("职级管理", "查询") def page(): """ 查询职级分页数据 @@ -95,6 +96,7 @@ def page(): @level_blue.route("/detail/", methods=["GET"]) @check_permission("sys:level:detail") +@operation_log("职级管理", "详情") def detail(id): """ 查询职级详情 @@ -117,6 +119,7 @@ def detail(id): @level_blue.route("/add", methods=["POST"]) @check_permission("sys:level:add") @check_demo +@operation_log("职级管理", "新增") def add(): """ 添加职级 @@ -142,6 +145,7 @@ def add(): @level_blue.route("/update", methods=["PUT"]) @check_permission("sys:level:update") @check_demo +@operation_log("职级管理", "修改") def update(): """ 更新职级 @@ -167,6 +171,7 @@ def update(): @level_blue.route("/delete/", methods=["DELETE"]) @check_permission("sys:level:delete") @check_demo +@operation_log("职级管理", "删除") def delete(id): """ 删除职级 @@ -187,8 +192,9 @@ def delete(id): @level_blue.route("/batchDelete", methods=["DELETE"]) -@check_permission("sys:level:batch_delete") +@check_permission("sys:level:batchDelete") @check_demo +@operation_log("职级管理", "批量删除") def batch_delete(): """ 批量删除职级 @@ -205,7 +211,8 @@ def batch_delete(): id_str, err = parse_batch_ids() if err: return err - return level.delete_levels(id_str) + count = level.delete_levels(id_str) + return R.ok(msg=f"本次共删除{count}条数据") # ============================================================ @@ -216,6 +223,7 @@ def batch_delete(): @level_blue.route("/status", methods=["PUT"]) @check_permission("sys:level:status") @check_demo +@operation_log("职级管理", "状态变更") def status(): """ 设置职级状态 @@ -241,6 +249,7 @@ def status(): @level_blue.route("/import", methods=["POST"]) @check_permission("sys:level:import") @check_demo +@operation_log("职级管理", "导入") def imports(): """ 导入Excel文件 @@ -260,20 +269,25 @@ def imports(): # ============================================================ -@level_blue.route("/export", methods=["POST"]) +@level_blue.route("/export", methods=["GET"]) @check_permission("sys:level:export") -@check_demo +@operation_log("职级管理", "导出") def export(): """ 导出Excel文件 功能说明: - 将职级数据导出为Excel文件。 + 将职级数据导出为Excel文件,支持与分页查询一致的筛选参数 + (name 模糊匹配、status 精确匹配)。 + + Query Params: + name: 职级名称(模糊匹配,可选) + status: 职级状态(精确匹配,可选) Returns: Response: Excel文件下载响应 """ - result = level.export_levels() + result = level.export_levels(request.args) return result @@ -283,15 +297,19 @@ def export(): @level_blue.route("/list", methods=["GET"]) +@operation_log("职级管理", "查询") def get_level_list(): """ 获取职级数据列表(无分页) 功能说明: - 获取所有职级列表,常用于下拉选择框。 + 获取启用状态的职级列表,支持与分页一致的筛选参数(name 模糊匹配)。 + + Query Params: + name: 职级名称(模糊匹配,可选) Returns: JSON: 职级列表数据 """ - result = level.get_level_list() + result = level.get_level_list(request.args) return R.ok(data=result) diff --git a/src/modules/system/level/schema.py b/src/modules/system/level/schema.py index 6351770..a8f6a4c 100644 --- a/src/modules/system/level/schema.py +++ b/src/modules/system/level/schema.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -52,7 +52,7 @@ from wtforms import ValidationError, StringField, IntegerField from wtforms.validators import DataRequired, Length, NumberRange, Optional from src.common.forms.base_form import BaseForm -from src.modules.system.level.models import Level +from src.modules.system.level.repository import LevelRepository # ============================================================ @@ -145,15 +145,8 @@ class LevelForm(BaseForm): # 获取当前记录的ID(更新时存在,添加时为None) current_id = self.id.data if self.id.data else None - # 构建查询:查找同名记录 - query = Level.query.filter(Level.name == field.data, Level.is_delete == 0) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(Level.id != current_id) - - # 如果存在同名记录,抛出验证错误 - if query.first(): + # 唯一性校验(软删过滤 + 更新时排除自身) + if LevelRepository.exists_by({'name': field.data}, exclude_id=current_id): raise ValidationError("职级名称已存在") diff --git a/src/modules/system/level/service.py b/src/modules/system/level/service.py index ee70469..72dd42f 100644 --- a/src/modules/system/level/service.py +++ b/src/modules/system/level/service.py @@ -1,538 +1,173 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 -# +====================================================================== -# | 版权和免责声明: -# | 一、知识产权声明 -# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 -# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, -# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 -# | 二、使用限制与禁止条款 -# | 您理解并同意,禁止将本产品用于以下任何行为: -# | 1. 任何违反中华人民共和国法律法规的用途; -# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; -# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 -# | 三、责任豁免 -# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 -# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 -# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 -# | 四、违约与法律责任 -# | 如发现任何违反本声明的行为,本团队有权: -# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; -# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; -# | 3. 保留进一步采取刑事控告等法律措施的权利。 -# | 五、许可范围说明 -# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 -# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 -# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 -# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 -# | 成的一切经济损失及商誉损害。 -# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 -# | 许可协议》附件为准。 -# +====================================================================== - - """ 职级管理服务模块 ============================================================= 该模块实现了职级管理相关的所有业务逻辑。 -包括职级的分页查询、详情查询、添加、更新、删除、状态设置、导入导出等功能。 职级用于标识用户的职位等级,如:初级、中级、高级等, -每个职级包含名称、排序、状态等属性,通过sort字段控制显示顺序。 +每个职级包含名称、排序、状态等属性,通过 sort 字段控制显示顺序。 -命名规范: -- 函数参数使用驼峰命名(如 levelId, pageNo) -- 表单字段使用下划线命名(如 name, sort, status)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters, _build_page_data) - -功能模块: -1. 分页查询:支持按名称模糊搜索、状态筛选,按sort字段排序 -2. 详情查询:根据ID获取单个职级的详细信息 -3. 数据维护:职级的添加、更新、删除(支持批量) -4. 状态管理:单独更新职级的启用/禁用状态 -5. Excel导入:批量导入职级数据 -6. Excel导出:导出职级数据到Excel文件 -7. 数据列表:获取所有启用的职级列表(用于下拉选择等场景) +实现说明(P2 服务层重构): + - 标准 CRUD(分页/详情/新增/更新/删除/状态/列表)由 BaseService 提供 + - Excel 导入/导出为职级特有逻辑,保留为模块级函数 + - 模块级代理函数保持 router(level.get_level_xxx)调用零改动 """ -import os -import random -import time import logging -from datetime import datetime, timezone -from sqlalchemy import and_ -from openpyxl.reader.excel import load_workbook -from openpyxl.workbook import Workbook - - -from src.common.constants.common import PAGE_SIZE -from src.modules.system.level.schema import LevelForm, LevelStatusForm -from src.modules.system.level.models import Level -from src.modules.system.level.repository import LevelRepository +from src.common.base_service import BaseService +from src.common.utils import excel_utils +from src.common.utils.model_utils import models_to_list from src.core import response as R from src.core.database import db from src.core.exceptions import BizError -from src.modules.upload import service as upload -from config import FLASK_TEMP_PATH, FLASK_UPLOAD_DIR -from src.common.utils import regular -from src.common.utils.case_converter import convert_camel_to_snake -from src.common.utils.model_utils import models_to_list, model_to_dict -from src.common.utils.request import parse_id_list, like_contains -from src.common.utils.rich_text import get_file_url -from src.common.utils.file import mkdir -from src.core.security import get_username +from src.core.security import get_realname +from src.modules.system.level.models import Level +from src.modules.system.level.repository import LevelRepository +from src.modules.system.level.schema import LevelForm, LevelStatusForm +from src.modules.system.user.repository import UserRepository # 获取日志记录器 logger = logging.getLogger(__name__) -# ============================================================ -# 职级分页查询 -# ============================================================ - - -def get_level_page(params: dict) -> dict: +class LevelService(BaseService): """ - 查询职级分页数据 + 职级服务类 - Args: - request: Flask request对象,包含查询参数 - - pageNo: 当前页码(可选,默认为1) - - pageSize: 每页记录数(可选,默认为PAGE_SIZE) - - name: 职级名称(可选,模糊查询) - - status: 职级状态(可选,精确匹配) - - Returns: - R: 包含分页数据的JSON响应,格式为: - { - "records": 职级列表, - "total": 总记录数, - "size": 每页大小, - "current": 当前页码, - "pages": 总页数 - } + 继承 BaseService 获得标准 CRUD 能力,并扩展删除前用户引用校验。 """ - # 获取分页参数 - page_no = int(params.get("pageNo", 1)) - page_size = int(params.get("pageSize", PAGE_SIZE)) - # 应用筛选条件 - query = _apply_filters(params) + repository = LevelRepository + form = LevelForm + status_form = LevelStatusForm + model_name = "职级" - # 记录总数 - total = query.count() - # 计算总页数 - pages = (total + page_size - 1) // page_size if total > 0 else 0 + @classmethod + def before_delete(cls, ids): + """ + 删除前校验钩子 - # 分页查询 - level_list = query.limit(page_size).offset((page_no - 1) * page_size).all() - # 构建返回数据 - page_data = _build_page_data(level_list, total, page_size, page_no, pages) - return page_data + 检查职级是否仍被用户引用,若被引用则拒绝删除,防止孤儿引用。 + Args: + ids: 待删除的职级ID列表 -def _apply_filters(params: dict): - """ - 应用查询筛选条件 - - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 支持职级名称模糊查询 - - 支持职级状态精确查询 - - 默认按排序值升序排列 - - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = Level.query.filter(Level.is_delete == 0) - - # 职级名称模糊筛选 - name = params.get("name") - if name: - query = query.filter(Level.name.like(like_contains(name), escape="\\")) - - # 职级状态精确筛选 - status = params.get("status") - if status: - query = query.filter(Level.status == status) - - # 按排序值升序排列 - return query.order_by(Level.sort.asc()) - - -def _build_page_data(level_list, total, page_size, page_no, pages): - """ - 构建分页返回数据 - - Args: - level_list: 职级对象列表 - total: 总记录数 - page_size: 每页大小 - page_no: 当前页码 - pages: 总页数 - - Returns: - dict: 分页数据,包含 records、total、size、current、pages - """ - # 将模型对象列表转换为字典列表 - records = models_to_list(level_list) - - return {"records": records, "total": total, "size": page_size, "current": page_no, "pages": pages} + Raises: + BizError: 职级被用户引用时抛出 + """ + for lid in ids: + if UserRepository.count_by({'level_id': lid}) > 0: + raise BizError(f"职级ID {lid} 已被用户引用,无法删除") # ============================================================ -# 职级详情查询 +# 模块级代理函数:保持 router 调用兼容 # ============================================================ - -def get_level_detail(level_id): - """ - 根据ID查询职级详情(返回字典格式) - - 此方法调用 get_info 获取模型对象,然后转换为字典格式返回。 - 适用于前端接口调用。 - - Args: - level_id: 职级ID - - Returns: - dict/None: 职级详情字典,不存在时返回 None - """ - try: - # 复用 get_info 方法查询职级 - level = get_info(level_id) - if not level: - return None - # 转换为字典格式 - return model_to_dict(level) - - except Exception as e: - logger.error(f"查询职级详情异常,ID: {level_id}, 错误: {str(e)}") - return None +# 标准 CRUD 代理函数,保持与 router 层的调用兼容性 +get_level_page = LevelService.get_page +get_level_detail = LevelService.get_detail +get_info = LevelService.get_info +add_level = LevelService.add +update_level = LevelService.update +delete_levels = LevelService.remove +update_level_status = LevelService.update_status +get_level_list = LevelService.get_list # ============================================================ -# 职级信息查询(根据ID)- 核心查询方法 +# Excel 导入 / 导出(复用 excel_utils 通用机制) # ============================================================ +# 导入列定义:与导出的第 2/3/4 列对齐(第 1 列为职级ID,跳过) +LEVEL_IMPORT_COLUMNS = [ + {"title": "职级名称", "index": 2, "key": "name", "required": True}, + {"title": "职级排序", "index": 3, "key": "sort", "parser": excel_utils.parse_int}, + {"title": "职级状态", "index": 4, "key": "status", "parser": excel_utils.parse_status}, +] -def get_info(level_id: int): - """ - 根据ID查询职级信息(返回模型对象) - - 这是一个通用的职级查询方法,返回职级模型对象而非字典。 - 与 get_level_detail 的区别: - - get_level_detail: 返回字典格式,用于前端接口 - - get_info: 返回模型对象,用于内部服务调用 - - 此方法被以下方法复用: - - get_level_detail: 获取详情字典 - - update_level: 验证职级是否存在 - - update_level_status: 验证职级是否存在 - - Args: - level_id: 职级ID - - Returns: - Level/None: 职级模型对象,不存在或已删除时返回 None - - Example: - level = get_info(level_id) - if level: - print(level.name) - """ - return LevelRepository.get_by_id(level_id) - - -def add_level(data: dict) -> None: - """ - 添加职级 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证(名称唯一性、字段格式等) - 3. 创建职级对象并保存到数据库 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = LevelForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 创建职级 - level = Level(**form.data) - level.create_user = get_username() - level.save() - - -# ============================================================ -# 职级更新 -# ============================================================ - - -def update_level(data: dict) -> None: - """ - 更新职级 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 验证职级是否存在(复用 get_info) - 4. 更新职级信息 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = LevelForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 获取职级ID - level_id = form.data.get("id") - if not level_id or int(level_id) <= 0: - raise BizError("职级ID无效") - level_id = int(level_id) - - # 复用 get_info 方法验证职级是否存在 - level = get_info(level_id) - if not level: - raise BizError("职级不存在") - - # 更新职级信息(移除ID,避免更新主键) - form_data = convert_camel_to_snake(form.data.copy()) - form_data.pop("id", None) - - form_data["update_time"] = datetime.now(tz=timezone.utc) - form_data["update_user"] = get_username() - - result = Level.query.filter_by(id=level_id).update(form_data) - - if not result: - raise BizError("更新失败") - - -# ============================================================ -# 职级删除 -# ============================================================ - - -def delete_levels(level_ids: str) -> int: - """ - 删除职级(批量删除) - - 删除 - 支持逗号分隔的多个ID批量删除。 - - Args: - level_ids: 职级ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - level_ids_list = parse_id_list(level_ids) - if not level_ids_list: - raise BizError("无效的记录ID") - return Level.batch_delete(level_ids_list) - - -# ============================================================ -# 职级状态设置 -# ============================================================ - - -def update_level_status(data: dict) -> None: - """ - 设置职级状态 - - 单独更新职级的启用/禁用状态,不涉及其他字段。 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = LevelStatusForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - level_id = int(form.data["id"]) - status = int(form.data["status"]) - - # 复用 get_info 方法验证职级是否存在 - level = get_info(level_id) - if not level: - raise BizError("职级不存在") - - # 更新状态 - update_data = {"status": status, "update_time": datetime.now(tz=timezone.utc), "update_user": get_username()} - result = Level.query.filter_by(id=level_id).update(update_data) - - if not result: - raise BizError("设置失败") - - -# ============================================================ -# Excel导入 -# ============================================================ +# 导出列定义:status 统一展示为"正常/禁用",与导入 parser 闭环 +LEVEL_EXPORT_COLUMNS = [ + {"title": "职级ID", "key": "id"}, + {"title": "职级名称", "key": "name"}, + {"title": "职级排序", "key": "sort"}, + {"title": "职级状态", "key": "status", "formatter": excel_utils.format_status}, + {"title": "创建时间", "key": "createTime"}, + {"title": "更新时间", "key": "updateTime"}, +] def import_levels(): """ 导入Excel文件批量导入职级 - 功能说明: - 通过上传Excel文件批量导入职级数据。 - Excel格式要求: - - 第2列:职级名称 - - 第3列:职级排序 - - 第4列:职级状态("正常" 或 "禁用") + 通过上传Excel文件批量导入职级数据,Excel格式要求: + - 第1行:表头(跳过) + - 第2列:职级名称(必填) + - 第3列:职级排序 + - 第4列:职级状态("正常" 或 "禁用",可兼容数字 1/2) Returns: - R: 导入结果,包含成功数量 + R: 导入结果,包含成功导入的数据条数 """ + abs_path = None try: - # 上传并读取Excel文件 - file = upload.upload_file(".xls|.xlsx") - path = file["filePath"] + # 上传并解析 Excel(含行级错误收集,任一错误即整体失败) + abs_path = excel_utils.resolve_uploaded_excel() + rows, errors = excel_utils.parse_excel_rows(abs_path, LEVEL_IMPORT_COLUMNS) + if errors: + raise BizError("导入失败:" + ";".join(errors[:20])) - wb = load_workbook(path) - ws = wb.worksheets[0] - - max_row = ws.max_row - level_list = [] - current_user = get_username() - - # 遍历读取数据(从第2行开始,跳过表头) - for item in range(2, max_row + 1): - name = ws.cell(item, 2).value # 职级名称 - sort = ws.cell(item, 3).value # 职级排序 - status_value = ws.cell(item, 4).value # 职级状态 - - # 跳过空行 - if not name: - continue - - # 创建职级对象 - level = Level( + operator_user = get_realname() + level_list = [ + Level( id=None, - name=name, - status=1 if status_value == "正常" else 2, - sort=sort, + name=row["name"], + status=row.get("status") or 2, + sort=row.get("sort") or 0, + create_user=operator_user, + update_user=operator_user, ) - level.create_user = current_user - level_list.append(level) - - # 批量添加 + for row in rows + ] db.session.add_all(level_list) - return R.ok(msg="本次共导入【{}】条数据".format(len(level_list))) - + except BizError as e: + # 上传失败(格式不支持等)或行级校验失败,透传具体错误 + return R.failed(msg=e.msg) except Exception as e: logger.error(f"导入职级异常: {str(e)}") return R.failed(msg="导入职级失败,请稍后重试") finally: - # 清理导入产生的临时Excel文件 - try: - if "path" in dir() and path: - abs_path = os.path.join(FLASK_UPLOAD_DIR, path.lstrip("/")) - if os.path.isfile(abs_path): - os.remove(abs_path) - except Exception: - pass + excel_utils.remove_file(abs_path) -# ============================================================ -# Excel导出 -# ============================================================ - - -def export_levels(): +def export_levels(params: dict = None): """ 导出职级数据到Excel文件 - 导出当前筛选条件下的所有职级数据,生成Excel文件并返回下载URL。 + 支持与分页查询一致的筛选参数(name 模糊匹配、status 精确匹配), + 生成Excel文件并返回下载URL。 + + Args: + params: 查询参数字典(GET query string),如 {"name": "经理", "status": 1} Returns: R: 导出结果,包含文件访问URL """ try: - # 获取筛选后的数据列表 - query_list = _apply_filters().all() - # 转换为字典列表 - level_list = models_to_list(query_list) - - # 创建Excel工作簿 - wb = Workbook() - ws = wb.worksheets[0] - # 写入表头 - ws.append(["职级ID", "职级名称", "职级排序", "职级状态", "创建时间", "更新时间"]) - - # 写入数据 - for data in level_list: - ws.append(list(data.values())) - - # 保存文件 - save_path = FLASK_TEMP_PATH + "/" + time.strftime("%Y%m%d") - mkdir(save_path) - - file_name = time.strftime("%Y%m%d%H%M%S") + "%05d" % random.randint(0, 100) + ".xlsx" - file_path = save_path + "/" + file_name - - wb.save(file_path) - - # 生成文件访问地址 - file_url = get_file_url(file_path.replace(FLASK_UPLOAD_DIR, "")) - + params = params or {} + # 与分页查询一致的条件筛选 + query_list = LevelRepository.find_list( + filters={'status': params.get('status')}, + like_filters={'name': params.get('name')}, + order_by='sort', + ) + data = models_to_list(query_list) + headers = [col["title"] for col in LEVEL_EXPORT_COLUMNS] + rows = excel_utils.build_export_rows(data, LEVEL_EXPORT_COLUMNS) + file_url = excel_utils.save_export_file(headers, rows, filename_prefix="level") return R.ok(data=file_url) - except Exception as e: logger.error(f"导出职级异常: {str(e)}") return R.failed(msg="导出失败,请稍后重试") - - -# ============================================================ -# 职级列表获取(无分页) -# ============================================================ - - -def get_level_list(): - """ - 获取所有启用的职级列表(无分页) - - 用于下拉选择框等需要全部数据的场景。 - 只返回状态为"正常"的职级,按排序值升序排列。 - - Returns: - list: 职级列表,每个元素为职级模型字典,无数据时返回空列表 - """ - try: - # 查询所有启用的职级 - level_list = Level.query.filter(and_(Level.is_delete == 0, Level.status == 1)).order_by(Level.sort.asc()).all() - - # 转换为字典列表 - result = [] - for level in level_list: - result.append(model_to_dict(level)) - - return result - - except Exception as e: - logger.error(f"获取职级列表异常: {str(e)}") - return [] diff --git a/src/modules/system/menu/models.py b/src/modules/system/menu/models.py index 071f0f7..fe78a31 100644 --- a/src/modules/system/menu/models.py +++ b/src/modules/system/menu/models.py @@ -1,44 +1,3 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 -# +====================================================================== -# | 版权和免责声明: -# | 一、知识产权声明 -# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 -# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, -# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 -# | 二、使用限制与禁止条款 -# | 您理解并同意,禁止将本产品用于以下任何行为: -# | 1. 任何违反中华人民共和国法律法规的用途; -# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; -# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 -# | 三、责任豁免 -# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 -# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 -# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 -# | 四、违约与法律责任 -# | 如发现任何违反本声明的行为,本团队有权: -# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; -# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; -# | 3. 保留进一步采取刑事控告等法律措施的权利。 -# | 五、许可范围说明 -# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 -# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 -# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 -# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 -# | 成的一切经济损失及商誉损害。 -# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 -# | 许可协议》附件为准。 -# +====================================================================== - """ 菜单模型模块 ============================================================= @@ -49,6 +8,7 @@ from src.common.base_db import BaseDB from src.common.base_model import BaseModel from config import DB_PREFIX +from sqlalchemy import text from src.core.database import db @@ -83,9 +43,12 @@ class Menu(BaseModel, BaseDB): # 表配置 # ============================================================ - # 设置表名 + # 设置表名,使用配置中的表前缀 __tablename__ = DB_PREFIX + "menu" + # 表注释 + __table_comment__ = "菜单表" + # ============================================================ # 字段定义 # ============================================================ @@ -94,6 +57,7 @@ class Menu(BaseModel, BaseDB): name = db.Column( db.String(100), nullable=False, + index=True, comment="菜单名称" ) @@ -104,70 +68,84 @@ class Menu(BaseModel, BaseDB): comment="菜单图标" ) - # 上级ID + # 菜单图标 + icon2 = db.Column( + db.String(50), + nullable=True, + comment="菜单图标" + ) + + # 上级ID,0表示顶级菜单 parent_id = db.Column( db.Integer, default=0, + server_default=text('0'), + index=True, comment="上级ID" ) - # 菜单路径 + # 菜单路径,前端路由地址 path = db.Column( db.String(255), nullable=True, comment="菜单路径" ) - # 菜单组件 + # 菜单组件,前端组件路径 component = db.Column( db.String(255), nullable=True, comment="菜单组件" ) - # 权限节点 + # 权限节点标识符 permission = db.Column( db.String(100), nullable=True, comment="权限节点" ) - # 打开方式:0-内部打开 1-外部打开 + # 打开方式:0-内部打开,1-外部打开 target = db.Column( db.Integer, default=0, - comment="打开方式:0-内部打开 1-外部打开" + server_default=text('0'), + comment="打开方式:0-组件 1-内链 2-外链" ) - # 菜单类型:0-菜单 1-节点 + # 菜单类型:0-菜单,1-节点(权限点) type = db.Column( db.Integer, default=0, + server_default=text('0'), comment="菜单类型:0-菜单 1-节点" ) - # 状态选项 + # 状态:0-正常,1-禁用 status = db.Column( db.Integer, default=0, + server_default=text('0'), comment="菜单状态:0-正常 1-禁用" ) - # 是否可见:0-可见 1-不可见 + # 是否可见:0-可见,1-不可见(隐藏菜单) hide = db.Column( db.Integer, default=0, + server_default=text('0'), comment="是否可见:0-可见 1-不可见" ) - # 排序 + # 排序,数值越小越靠前 sort = db.Column( db.Integer, default=0, + server_default=text('0'), comment="排序" ) - # 备注 + # 备注信息 note = db.Column( db.String(255), nullable=True, diff --git a/src/modules/system/menu/repository.py b/src/modules/system/menu/repository.py index 257f256..636d96a 100644 --- a/src/modules/system/menu/repository.py +++ b/src/modules/system/menu/repository.py @@ -6,10 +6,119 @@ Menu 仓储模块 """ from src.common.base_repository import BaseRepository +from src.core.database import db +from src.modules.association.role_menu.models import RoleMenu +from src.modules.association.user_role.models import UserRole from src.modules.system.menu.models import Menu class MenuRepository(BaseRepository): - """Menu 仓储""" + """菜单数据仓储,提供菜单相关的数据库访问操作""" model = Menu + + @classmethod + def find_list_params(cls, params: dict): + """ + 按查询参数获取菜单列表 + + 支持按菜单名称模糊查询,按 sort 字段升序排列。 + + Args: + params: 查询参数字典,可包含 name 字段用于模糊搜索 + + Returns: + list: 菜单实例列表 + """ + return cls.find_list(like_filters={'name': params.get('name')}, order_by='sort') + + @classmethod + def delete_by_filters(cls, filters: dict) -> int: + """ + 按等值条件批量删除未删除记录 + + 用于清理某菜单的权限节点等场景。 + + Args: + filters: 等值条件字典 + + Returns: + int: 删除的记录数 + """ + query = cls._base_query() + for key, value in (filters or {}).items(): + if value is not None and hasattr(cls.model, key): + query = query.filter(getattr(cls.model, key) == value) + return query.delete(synchronize_session=False) + + @classmethod + def get_permissions_by_user(cls, user_id) -> list: + """ + 按用户查询权限标识列表 + + 超级管理员(user_id==1)返回所有启用状态的权限节点; + 普通用户通过角色关联查询其拥有的权限节点。 + + Args: + user_id: 用户ID + + Returns: + list: 权限标识字符串列表 + """ + # 超级管理员放行全部启用节点 + if user_id == 1: + rows = cls.model.query.filter( + cls.model.is_delete == 0, cls.model.type == 1, cls.model.status == 0 + ).all() + return [item.permission for item in rows if item.permission] + + # 普通用户:通过用户角色关联查询权限 + rows = ( + db.session.query(cls.model.permission) + .join(RoleMenu, cls.model.id == RoleMenu.menu_id) + .join(UserRole, UserRole.role_id == RoleMenu.role_id) + .filter( + UserRole.user_id == user_id, + cls.model.type == 1, + cls.model.status == 0, + cls.model.is_delete == 0, + cls.model.permission != "", + ) + .all() + ) + return [row.permission for row in rows if row.permission] + + @classmethod + def get_menus_by_user(cls, user_id) -> list: + """ + 按用户查询菜单对象列表 + + 超级管理员(user_id==1)返回所有启用状态的菜单; + 普通用户通过角色关联查询其拥有的菜单。 + + Args: + user_id: 用户ID + + Returns: + list: 菜单实例列表,按 sort 升序排列 + """ + # 超级管理员放行全部菜单 + if user_id == 1: + return cls.model.query.filter( + cls.model.is_delete == 0, cls.model.status == 0, cls.model.type == 0 + ).order_by(cls.model.sort.asc()).all() + + # 普通用户:通过用户角色关联查询菜单 + return ( + db.session.query(cls.model) + .join(RoleMenu, cls.model.id == RoleMenu.menu_id) + .join(UserRole, UserRole.role_id == RoleMenu.role_id) + .filter( + UserRole.user_id == user_id, + cls.model.type == 0, + cls.model.status == 0, + cls.model.is_delete == 0, + ) + .order_by(cls.model.sort.asc()) + .all() + ) diff --git a/src/modules/system/menu/router.py b/src/modules/system/menu/router.py index 0101f75..c9047fa 100644 --- a/src/modules/system/menu/router.py +++ b/src/modules/system/menu/router.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -55,7 +55,7 @@ from flask import Blueprint, request -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.core.permission import check_permission from src.modules.system.menu import service as menu @@ -73,6 +73,7 @@ menu_blue = Blueprint("menu", __name__, url_prefix="/menu") @menu_blue.route("/list", methods=["GET"]) @check_permission("sys:menu:list") +@operation_log("菜单管理", "查询") def list(): """ 查询菜单列表 @@ -93,6 +94,7 @@ def list(): @menu_blue.route("/detail/", methods=["GET"]) @check_permission("sys:menu:detail") +@operation_log("菜单管理", "详情") def detail(id): """ 查询菜单详情 @@ -115,6 +117,7 @@ def detail(id): @menu_blue.route("/add", methods=["POST"]) @check_permission("sys:menu:add") @check_demo +@operation_log("菜单管理", "新增") def add(): """ 添加菜单 @@ -140,6 +143,7 @@ def add(): @menu_blue.route("/update", methods=["PUT"]) @check_permission("sys:menu:update") @check_demo +@operation_log("菜单管理", "修改") def update(): """ 更新菜单 @@ -165,6 +169,7 @@ def update(): @menu_blue.route("/delete/", methods=["DELETE"]) @check_permission("sys:menu:delete") @check_demo +@operation_log("菜单管理", "删除") def delete(id): """ 删除菜单 diff --git a/src/modules/system/menu/schema.py b/src/modules/system/menu/schema.py index 789d5be..75f108f 100644 --- a/src/modules/system/menu/schema.py +++ b/src/modules/system/menu/schema.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -51,7 +51,7 @@ from wtforms import ValidationError, IntegerField, StringField from wtforms.validators import NumberRange, DataRequired, Length, Optional from src.common.forms.base_form import BaseForm -from src.modules.system.menu.models import Menu +from src.modules.system.menu.repository import MenuRepository # ============================================================ @@ -168,6 +168,7 @@ class MenuForm(BaseForm): target = IntegerField( label='打开方式', validators=[ + Optional(), # 允许 0(内部打开) NumberRange(min=0, max=1, message='打开方式值在0~1之间'), ], description='打开方式:0-内部打开,1-外部打开' @@ -179,6 +180,7 @@ class MenuForm(BaseForm): type = IntegerField( label='菜单类型', validators=[ + Optional(), # 允许 0(菜单) NumberRange(min=0, max=1, message='菜单类型值在0~1之间') ], description='菜单类型:0-菜单,1-节点(权限点)' @@ -190,6 +192,7 @@ class MenuForm(BaseForm): status = IntegerField( label='菜单状态', validators=[ + Optional(), # 允许 0(正常) NumberRange(min=0, max=1, message='菜单状态值在0~1之间') ], description='菜单状态:0-正常,1-禁用' @@ -201,6 +204,7 @@ class MenuForm(BaseForm): hide = IntegerField( label='是否可见', validators=[ + Optional(), # 允许 0(可见) NumberRange(min=0, max=1, message='是否可见值在0~1之间') ], description='是否可见:0-可见,1-不可见(隐藏)' @@ -256,17 +260,6 @@ class MenuForm(BaseForm): # 获取上级ID parent_id = self.parentId.data if self.parentId.data else 0 - # 构建查询:查找同级下同名标题记录 - query = Menu.query.filter( - Menu.name == field.data, - Menu.parent_id == parent_id, - Menu.is_delete == 0 - ) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(Menu.id != current_id) - - # 如果存在同名记录,抛出验证错误 - if query.first(): + # 唯一性校验(同级内 + 软删过滤 + 更新时排除自身) + if MenuRepository.exists_by({'name': field.data, 'parent_id': parent_id}, exclude_id=current_id): raise ValidationError("同级菜单下标题已存在") diff --git a/src/modules/system/menu/service.py b/src/modules/system/menu/service.py index e6bc6d5..9e9ab35 100644 --- a/src/modules/system/menu/service.py +++ b/src/modules/system/menu/service.py @@ -1,44 +1,3 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 -# +====================================================================== -# | 版权和免责声明: -# | 一、知识产权声明 -# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 -# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, -# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 -# | 二、使用限制与禁止条款 -# | 您理解并同意,禁止将本产品用于以下任何行为: -# | 1. 任何违反中华人民共和国法律法规的用途; -# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; -# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 -# | 三、责任豁免 -# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 -# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 -# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 -# | 四、违约与法律责任 -# | 如发现任何违反本声明的行为,本团队有权: -# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; -# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; -# | 3. 保留进一步采取刑事控告等法律措施的权利。 -# | 五、许可范围说明 -# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 -# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 -# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 -# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 -# | 成的一切经济损失及商誉损害。 -# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 -# | 许可协议》附件为准。 -# +====================================================================== - """ 菜单管理服务模块 ============================================================= @@ -46,41 +5,27 @@ 该模块实现了菜单管理相关的所有业务逻辑。 包括菜单的列表查询、详情查询、添加、更新、删除、权限节点管理等功能。 菜单用于构建系统导航栏和权限控制,支持目录、菜单、按钮三种类型, -通过parent_id字段建立层级关系,sort字段控制显示顺序。 +通过 parent_id 字段建立层级关系,sort 字段控制显示顺序。 -命名规范: -- 函数参数使用驼峰命名(如 menuId, userId) -- 表单字段使用下划线命名(如 name, path, permission)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters, _build_tree) - -功能模块: -1. 列表查询:支持按名称模糊搜索,按sort字段排序 -2. 详情查询:根据ID获取单个菜单的详细信息(含权限节点) -3. 数据维护:菜单的添加、更新、删除(支持批量) -4. 权限节点:自动生成菜单对应的权限节点(增删改查等) -5. 用户权限:根据用户ID获取权限节点列表 -6. 菜单树:根据用户ID获取权限菜单树结构 +实现说明(P3 服务层重构): + - 删除(before_delete 子菜单校验 + after_delete 全局缓存失效)、 + 详情(_detail_dict 权限节点收集)复用 BaseService + - 添加/更新因涉及权限节点自动生成 + 全局缓存失效,整体覆写 + - 权限缓存/用户权限查询等非 CRUD 逻辑保留为模块函数 + - 模块级代理函数保持 router(menu.get_menu_xxx)调用零改动 """ import logging -from datetime import datetime, timezone -from sqlalchemy import and_ - +from src.common.base_service import BaseService from src.modules.system.menu.schema import MenuForm -from src.modules.system.menu.models import Menu from src.modules.system.menu.repository import MenuRepository +from src.modules.association.user_role.repository import UserRoleRepository from src.core.exceptions import BizError -from src.modules.association.role_menu.models import RoleMenu -from src.modules.association.user_role.models import UserRole -from src.core.database import db -from src.common.utils import regular -from src.common.utils.case_converter import convert_camel_to_snake from src.core.redis_client import redis -from src.common.utils.request import parse_id_list, like_contains +from src.common.utils.request import parse_id_list from src.common.utils.model_utils import models_to_list, model_to_dict -from src.core.security import get_username +from src.core.security import get_realname # 获取日志记录器 logger = logging.getLogger(__name__) @@ -101,9 +46,8 @@ _CACHE_EXPIRE = 7200 # 权限节点常量映射 # ============================================================ - class PermissionNode: - """权限节点常量定义""" + """权限节点常量定义,用于菜单权限节点的类型标识""" QUERY = 1 # 查询 ADD = 5 # 添加 @@ -119,17 +63,25 @@ class PermissionNode: IMPORT = 55 # 导入 PERMISSION = 60 # 分配权限 RESET_PWD = 65 # 重置密码 + SAVE = 70 # 保存分配(角色-菜单 / 用户-角色) + PAUSE = 75 # 暂停任务 + RESUME = 80 # 恢复任务 + RUN_ONCE = 85 # 立即执行 + RELOAD = 90 # 重新加载调度器 + SEND_SMS = 95 # 发送短信 # 权限节点配置映射 +# 注意:perm_suffix 必须与各模块 router 的 @check_permission("sys:xxx:action") 保持一致, +# 否则菜单生成的权限永远匹配不上路由校验(此前 QUERY 用 list、BATCH_DELETE 用 dall 即为此类)。 PERMISSION_CONFIG = { - PermissionNode.QUERY: {"suffix": "查询", "action": "list", "perm_suffix": "list"}, + PermissionNode.QUERY: {"suffix": "查询", "action": "page", "perm_suffix": "page"}, PermissionNode.ADD: {"suffix": "添加", "action": "add", "perm_suffix": "add"}, PermissionNode.UPDATE: {"suffix": "修改", "action": "update", "perm_suffix": "update"}, PermissionNode.DELETE: {"suffix": "删除", "action": "delete", "perm_suffix": "delete"}, PermissionNode.DETAIL: {"suffix": "详情", "action": "detail", "perm_suffix": "detail"}, PermissionNode.STATUS: {"suffix": "设置状态", "action": "status", "perm_suffix": "status"}, - PermissionNode.BATCH_DELETE: {"suffix": "批量删除", "action": "dall", "perm_suffix": "dall"}, + PermissionNode.BATCH_DELETE: {"suffix": "批量删除", "action": "batch_delete", "perm_suffix": "batch_delete"}, PermissionNode.ADD_CHILD: {"suffix": "添加子级", "action": "addz", "perm_suffix": "addz"}, PermissionNode.EXPAND: {"suffix": "全部展开", "action": "expand", "perm_suffix": "expand"}, PermissionNode.COLLAPSE: {"suffix": "全部折叠", "action": "collapse", "perm_suffix": "collapse"}, @@ -137,17 +89,162 @@ PERMISSION_CONFIG = { PermissionNode.IMPORT: {"suffix": "导入", "action": "import", "perm_suffix": "import"}, PermissionNode.PERMISSION: {"suffix": "分配权限", "action": "permission", "perm_suffix": "permission"}, PermissionNode.RESET_PWD: {"suffix": "重置密码", "action": "resetPwd", "perm_suffix": "resetPwd"}, + PermissionNode.SAVE: {"suffix": "保存分配", "action": "save", "perm_suffix": "save"}, + PermissionNode.PAUSE: {"suffix": "暂停", "action": "pause", "perm_suffix": "pause"}, + PermissionNode.RESUME: {"suffix": "恢复", "action": "resume", "perm_suffix": "resume"}, + PermissionNode.RUN_ONCE: {"suffix": "立即执行", "action": "runOnce", "perm_suffix": "runOnce"}, + PermissionNode.RELOAD: {"suffix": "重新加载", "action": "reload", "perm_suffix": "reload"}, + PermissionNode.SEND_SMS: {"suffix": "发送短信", "action": "sendSms", "perm_suffix": "sendSms"}, } +class MenuService(BaseService): + """ + 菜单服务类 + + 继承 BaseService 获得标准 CRUD 能力。 + 删除/详情复用基类,添加/更新覆写以支持权限节点自动生成和缓存失效。 + """ + + repository = MenuRepository + form = MenuForm + model_name = "菜单" + + # ============================================================ + # 添加/更新覆写(权限节点自动生成 + 全局缓存失效) + # ============================================================ + + @classmethod + def add(cls, data: dict) -> None: + """ + 添加菜单 + + 创建菜单后自动生成对应的权限节点,并失效全局权限缓存。 + + Args: + data: 菜单数据字典 + + Raises: + BizError: 表单校验失败或创建异常时抛出 + """ + form = cls._validate_form(data) + + checked_list = form.data.get("checked_list") + menu_type = form.data.get("type") + title = form.data.get("title") + path = form.data.get("path", "") + + create_data = cls._clean_form_data(form) + create_data['create_user'] = get_realname() + menu = cls.repository.create(**create_data) + + _create_permission_nodes(menu_type, checked_list, title, path, menu.id) + invalidate_all_cache() + + @classmethod + def update(cls, data: dict) -> None: + """ + 更新菜单 + + 更新菜单信息后重建权限节点,并失效全局权限缓存。 + + Args: + data: 菜单数据字典,必须包含 id 字段 + + Raises: + BizError: 表单校验失败、记录不存在或更新异常时抛出 + """ + form = cls._validate_form(data) + + menu_id = form.data.get("id") + if not menu_id or int(menu_id) <= 0: + raise BizError("菜单ID无效") + menu_id = int(menu_id) + if not cls.get_info(menu_id): + raise BizError("菜单不存在") + + checked_list = form.data.get("checked_list") + menu_type = form.data.get("type") + title = form.data.get("title") + path = form.data.get("path", "") + + result = cls.repository.update_by_id(menu_id, cls._clean_form_data(form)) + if not result: + raise BizError("更新失败") + + _create_permission_nodes(menu_type, checked_list, title, path, menu_id) + invalidate_all_cache() + + # ============================================================ + # 删除/详情钩子(复用基类模板方法) + # ============================================================ + + @classmethod + def before_delete(cls, ids): + """ + 删除前校验钩子 + + 检查菜单是否存在子菜单,若存在则拒绝删除。 + + Args: + ids: 待删除的菜单ID列表 + + Raises: + BizError: 存在子菜单时抛出 + """ + for menu_id in ids: + if cls.repository.count_children(menu_id) > 0: + raise BizError(f"菜单ID {menu_id} 存在子菜单,请先删除子菜单") + + @classmethod + def after_delete(cls, count): + """ + 删除后钩子 + + 递增全局缓存版本号,使所有用户缓存失效。 + + Args: + count: 实际删除的记录数 + """ + invalidate_all_cache() + + @classmethod + def _detail_dict(cls, obj): + """ + 详情序列化钩子 + + 在基础序列化基础上,附加该菜单下的权限节点列表。 + + Args: + obj: 菜单模型实例 + + Returns: + dict: 序列化后的字典,包含 checked_list 字段 + """ + data = model_to_dict(obj) + permission_list = cls.repository.find_list(filters={'parent_id': obj.id, 'type': 1}) + data["checked_list"] = [p.sort for p in permission_list] if permission_list else [] + return data + + # ============================================================ -# 菜单列表查询 +# 模块级代理函数:保持 router 调用兼容 # ============================================================ +get_menu_detail = MenuService.get_detail +get_info = MenuService.get_info +add_menu = MenuService.add +update_menu = MenuService.update +delete_menus = MenuService.remove + + +# ============================================================ +# 菜单列表查询(无分页) +# ============================================================ def get_menu_list(params: dict) -> list: """ - 查询菜单列表 + 查询菜单列表(无分页) 用于菜单管理页面的列表展示,支持按名称模糊搜索。 @@ -158,264 +255,14 @@ def get_menu_list(params: dict) -> list: Returns: list: 菜单字典列表 """ - # 应用筛选条件 - query = _apply_filters(params) - - # 查询数据(按排序升序) - menu_list = query.order_by(Menu.sort.asc()).all() - - # 转换为字典列表 + menu_list = MenuRepository.find_list_params(params) return models_to_list(menu_list) -def _apply_filters(params: dict): - """ - 应用查询筛选条件 - - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 支持菜单名称模糊查询 - - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = Menu.query.filter(Menu.is_delete == 0) - - # 菜单名称模糊筛选 - name = params.get("name") - if name: - query = query.filter(Menu.name.like(like_contains(name), escape="\\")) - - return query - - -# ============================================================ -# 菜单详情查询 -# ============================================================ - - -def get_menu_detail(menu_id): - """ - 根据ID查询菜单详情(返回字典格式) - - 此方法调用 get_info 获取模型对象,然后转换为字典格式返回。 - 同时查询该菜单下的权限节点列表。 - - Args: - menu_id: 菜单ID - - Returns: - dict/None: 菜单详情字典,包含 checked_list 权限节点列表,不存在时返回 None - """ - try: - # 复用 get_info 方法查询菜单 - menu = get_info(menu_id) - if not menu: - return None - - # 转换为字典格式 - data = model_to_dict(menu) - - # 查询菜单权限节点(type=1 表示权限节点) - permission_list = Menu.query.filter(and_(Menu.parent_id == menu_id, Menu.type == 1, Menu.is_delete == 0)).all() - - # 选中的权限节点(按sort值收集) - checked_list = [p.sort for p in permission_list] if permission_list else [] - - # 权限节点 - data["checked_list"] = checked_list - - return data - - except Exception as e: - logger.error(f"查询菜单详情异常,ID: {menu_id}, 错误: {str(e)}") - return None - - -# ============================================================ -# 菜单信息查询(根据ID)- 核心查询方法 -# ============================================================ - - -def get_info(menu_id: int): - """ - 根据ID查询菜单信息(返回模型对象) - - 这是一个通用的菜单查询方法,返回菜单模型对象而非字典。 - 与 get_menu_detail 的区别: - - get_menu_detail: 返回字典格式,用于前端接口 - - get_info: 返回模型对象,用于内部服务调用 - - 此方法被以下方法复用: - - get_menu_detail: 获取详情字典 - - update_menu: 验证菜单是否存在 - - delete_menus: 验证菜单是否存在 - - Args: - menu_id: 菜单ID - - Returns: - Menu/None: 菜单模型对象,不存在或已删除时返回 None - - Example: - menu = get_info(menu_id) - if menu: - print(menu.title) - """ - return MenuRepository.get_by_id(menu_id) - - -def add_menu(data: dict) -> None: - """ - 添加菜单 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 创建菜单对象并保存到数据库 - 4. 如果是目录类型,自动生成权限节点 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = MenuForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 权限节点 - checked_list = form.data.get("checked_list") - menu_type = form.data.get("type") - title = form.data.get("title") - path = form.data.get("path", "") - - # 移除不需要直接保存到Menu表的字段 - form_data = form.data.copy() - form_data.pop("checked_list", None) - - # 创建菜单 - menu = Menu(**form_data) - menu.create_user = get_username() - db.session.add(menu) - db.session.flush() - - # 保存权限节点数据 - _create_permission_nodes(menu_type, checked_list, title, path, menu.id) - - # 菜单变更,递增全局版本号使所有用户缓存失效 - invalidate_all_cache() - - -# ============================================================ -# 菜单更新 -# ============================================================ - - -def update_menu(data: dict) -> None: - """ - 更新菜单 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 验证菜单是否存在(复用 get_info) - 4. 更新菜单信息 - 5. 更新权限节点数据 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = MenuForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 获取菜单ID - menu_id = form.data.get("id") - if not menu_id or int(menu_id) <= 0: - raise BizError("菜单ID无效") - menu_id = int(menu_id) - - # 复用 get_info 方法验证菜单是否存在 - menu = get_info(menu_id) - if not menu: - raise BizError("菜单不存在") - - # 权限节点 - checked_list = form.data.get("checked_list") - menu_type = form.data.get("type") - title = form.data.get("title") - path = form.data.get("path", "") - - # 移除不需要直接更新到Menu表的字段 - form_data = convert_camel_to_snake(form.data.copy()) - form_data.pop("id", None) - form_data.pop("checked_list", None) - - form_data["update_time"] = datetime.now(tz=timezone.utc) - form_data["update_user"] = get_username() - - # 更新菜单信息 - result = Menu.query.filter_by(id=menu_id).update(form_data) - - if not result: - raise BizError("更新失败") - - # 保存权限节点数据 - _create_permission_nodes(menu_type, checked_list, title, path, menu_id) - - # 菜单变更,递增全局版本号使所有用户缓存失效 - invalidate_all_cache() - - -# ============================================================ -# 菜单删除 -# ============================================================ - - -def delete_menus(menu_ids: str) -> int: - """ - 删除菜单(批量删除) - - 采用软删除方式,将 is_delete 标识设置为 1。 - 支持逗号分隔的多个ID批量删除。 - - Args: - menu_ids: 菜单ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - if not menu_ids: - raise BizError("记录ID不存在") - - # 解析ID列表 - id_list = parse_id_list(menu_ids) - if not id_list: - raise BizError("无效的记录ID") - - # 检查是否有子菜单 - for menu_id in id_list: - child_count = Menu.query.filter(and_(Menu.parent_id == menu_id, Menu.is_delete == 0)).count() - if child_count > 0: - raise BizError(f"菜单ID {menu_id} 存在子菜单,请先删除子菜单") - - result = Menu.batch_delete_r(menu_ids) - - # 菜单变更,递增全局版本号使所有用户缓存失效 - invalidate_all_cache() - - return result - - # ============================================================ # 权限节点管理 # ============================================================ - def _create_permission_nodes(menu_type, checked_list, title, path, parent_id): """ 创建权限节点 @@ -435,7 +282,7 @@ def _create_permission_nodes(menu_type, checked_list, title, path, parent_id): try: # 删除现有权限节点 - Menu.query.filter(and_(Menu.parent_id == parent_id, Menu.type == 1, Menu.is_delete == 0)).delete() + MenuRepository.delete_by_filters({'parent_id': parent_id, 'type': 1}) # 模块名称(去掉"管理"后缀) module_title = title.replace("管理", "") if title else "" @@ -476,7 +323,7 @@ def _create_permission_nodes(menu_type, checked_list, title, path, parent_id): permission = f"sys:{module_name}:{config['perm_suffix']}" # 创建权限节点 - permission_menu = Menu( + MenuRepository.create( parent_id=parent_id, title=node_title, path=node_path, @@ -485,9 +332,8 @@ def _create_permission_nodes(menu_type, checked_list, title, path, parent_id): status=1, target="_self", sort=value, - create_user=get_username(), + create_user=get_realname(), ) - permission_menu.save() except Exception as e: logger.error(f"创建权限节点异常: {str(e)}") @@ -498,7 +344,6 @@ def _create_permission_nodes(menu_type, checked_list, title, path, parent_id): # 用户权限节点获取 # ============================================================ - def get_user_permissions(user_id): """ 获取用户权限节点列表(带Redis缓存) @@ -527,25 +372,8 @@ def get_user_permissions(user_id): if cached is not None: return cached - # 缓存未命中,查询数据库 - if user_id == 1: - permission_list = Menu.query.filter(and_(Menu.is_delete == 0, Menu.type == 1, Menu.status == 0)).all() - result = [item.permission for item in permission_list if item.permission] - else: - rows = ( - db.session.query(Menu.permission) - .join(RoleMenu, Menu.id == RoleMenu.menu_id) - .join(UserRole, UserRole.role_id == RoleMenu.role_id) - .filter( - UserRole.user_id == user_id, - Menu.type == 1, - Menu.status == 0, - Menu.is_delete == 0, - Menu.permission != "", - ) - .all() - ) - result = [row.permission for row in rows if row.permission] + # 缓存未命中,查询数据库(superadmin 特判在仓储内) + result = MenuRepository.get_permissions_by_user(user_id) # 写入缓存 redis.set(cache_key, result, expire=_CACHE_EXPIRE) @@ -560,7 +388,6 @@ def get_user_permissions(user_id): # 用户菜单树获取 # ============================================================ - def get_user_permission_menus(user_id): """ 根据用户ID获取权限菜单树(带Redis缓存) @@ -589,22 +416,8 @@ def get_user_permission_menus(user_id): if cached is not None: return cached - # 缓存未命中,查询数据库 - if user_id == 1: - menu_list = ( - Menu.query.filter(and_(Menu.is_delete == 0, Menu.status == 0, Menu.type == 0)) - .order_by(Menu.sort.asc()) - .all() - ) - else: - menu_list = ( - db.session.query(Menu) - .join(RoleMenu, Menu.id == RoleMenu.menu_id) - .join(UserRole, UserRole.role_id == RoleMenu.role_id) - .filter(UserRole.user_id == user_id, Menu.type == 0, Menu.status == 0, Menu.is_delete == 0) - .order_by(Menu.sort.asc()) - .all() - ) + # 缓存未命中,查询数据库(superadmin 特判在仓储内) + menu_list = MenuRepository.get_menus_by_user(user_id) menu_data = models_to_list(menu_list) menu_tree = _build_menu_tree(menu_data, 0) @@ -618,7 +431,6 @@ def get_user_permission_menus(user_id): return [] -# 获取树状结构 def _build_menu_tree(menu_data, parent_id): """ 递归构建菜单树形结构 @@ -653,7 +465,6 @@ def _build_menu_tree(menu_data, parent_id): # 权限缓存失效 # ============================================================ - def invalidate_user_cache(user_id): """ 清除指定用户的权限和菜单缓存 @@ -685,7 +496,7 @@ def invalidate_role_users_cache(role_id): role_id: 角色ID """ try: - user_ids = [row.user_id for row in UserRole.query.filter(UserRole.role_id == role_id).all()] + user_ids = UserRoleRepository.get_users_by_role(role_id) for uid in user_ids: invalidate_user_cache(uid) except Exception as e: diff --git a/src/modules/system/position/models.py b/src/modules/system/position/models.py index eb9bfb1..ed4c4a3 100644 --- a/src/modules/system/position/models.py +++ b/src/modules/system/position/models.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -63,6 +63,7 @@ from typing import Optional, List from src.common.base_db import BaseDB from src.common.base_model import BaseModel from config import DB_PREFIX +from sqlalchemy import text from src.core.database import db from src.common.utils.request import like_contains @@ -128,6 +129,9 @@ class Position(BaseModel, BaseDB): # 表名:使用配置前缀 + 模型名 __tablename__ = DB_PREFIX + "position" + # 表注释 + __table_comment__ = "岗位表" + # ============================================================ # 字段定义 # ============================================================ @@ -136,6 +140,7 @@ class Position(BaseModel, BaseDB): name = db.Column( db.String(255), nullable=False, + index=True, comment="岗位名称" ) @@ -143,7 +148,9 @@ class Position(BaseModel, BaseDB): # 默认值为停用,创建后需手动启用 status = db.Column( db.Integer, + index=True, default=PositionStatus.DISABLED, + server_default=text('0'), comment="岗位状态:0-停用 1-启用" ) @@ -152,6 +159,7 @@ class Position(BaseModel, BaseDB): sort = db.Column( db.Integer, default=0, + server_default=text('0'), comment="岗位排序" ) diff --git a/src/modules/system/position/repository.py b/src/modules/system/position/repository.py index 929ce48..22c0273 100644 --- a/src/modules/system/position/repository.py +++ b/src/modules/system/position/repository.py @@ -6,10 +6,60 @@ """ from src.common.base_repository import BaseRepository +from src.common.constants.common import PAGE_SIZE from src.modules.system.position.models import Position class PositionRepository(BaseRepository): - """岗位仓储""" + """岗位数据仓储,提供岗位相关的数据库访问操作""" model = Position + + @classmethod + def page(cls, params: dict): + """ + 岗位分页查询 + + 支持按名称模糊搜索、按状态精确过滤,按 sort 字段升序排列。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + - name: 岗位名称(模糊匹配) + - status: 状态(精确匹配) + + Returns: + tuple: (items, total, pages) + - items: 当前页数据列表 + - total: 总记录数 + - pages: 总页数 + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + filters={'status': params.get('status')}, + like_filters={'name': params.get('name')}, + order_by='sort', + ) + + @classmethod + def list_enabled(cls, params=None): + """ + 查询启用的岗位列表 + + 用于下拉选择场景,仅返回启用状态的岗位(status=1 为硬性条件,不可传参覆盖)。 + 支持与分页一致的条件筛选(name 模糊匹配)。 + + Args: + params: 查询参数字典,可包含 name 字段用于模糊搜索 + + Returns: + list: 启用状态的岗位实例列表,按 sort 升序排列 + """ + params = params or {} + return cls.find_list( + filters={'status': 1}, + like_filters={'name': params.get('name')}, + order_by='sort', + ) diff --git a/src/modules/system/position/router.py b/src/modules/system/position/router.py index 4bf517e..7e9fe0c 100644 --- a/src/modules/system/position/router.py +++ b/src/modules/system/position/router.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -57,7 +57,7 @@ """ from flask import Blueprint, request -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.core.permission import check_permission from src.modules.system.position import service as position from src.common.utils.request import parse_batch_ids, parse_request_body @@ -73,6 +73,7 @@ position_blue = Blueprint('position', __name__, url_prefix='/position') @position_blue.route('/page', methods=["GET"]) @check_permission("sys:position:page") +@operation_log("岗位管理", "查询") def page(): """ 查询岗位分页数据 @@ -92,6 +93,7 @@ def page(): @position_blue.route('/detail/', methods=["GET"]) @check_permission("sys:position:detail") +@operation_log("岗位管理", "详情") def detail(id): """ 查询岗位详情 @@ -113,6 +115,7 @@ def detail(id): @position_blue.route('/add', methods=["POST"]) @check_permission("sys:position:add") @check_demo +@operation_log("岗位管理", "新增") def add(): """ 添加岗位 @@ -137,6 +140,7 @@ def add(): @position_blue.route('/update', methods=["PUT"]) @check_permission("sys:position:update") @check_demo +@operation_log("岗位管理", "修改") def update(): """ 更新岗位 @@ -161,6 +165,7 @@ def update(): @position_blue.route('/delete/', methods=["DELETE"]) @check_permission("sys:position:delete") @check_demo +@operation_log("岗位管理", "删除") def delete(id): """ 删除岗位 @@ -180,8 +185,9 @@ def delete(id): # ============================================================ @position_blue.route('/batchDelete', methods=["DELETE"]) -@check_permission("sys:position:batch_delete") +@check_permission("sys:position:batchDelete") @check_demo +@operation_log("岗位管理", "批量删除") def batch_delete(): """ 批量删除岗位 @@ -209,6 +215,7 @@ def batch_delete(): @position_blue.route('/status', methods=["PUT"]) @check_permission("sys:position:status") @check_demo +@operation_log("岗位管理", "状态变更") def status(): """ 设置岗位状态 @@ -231,15 +238,20 @@ def status(): # ============================================================ @position_blue.route('/list', methods=["GET"]) +@check_permission("sys:position:list") +@operation_log("岗位管理", "查询") def get_position_list(): """ 获取岗位数据列表(无分页) 功能说明: - 获取所有岗位列表,常用于下拉选择框。 + 获取启用状态的岗位列表,支持与分页一致的筛选参数(name 模糊匹配),常用于下拉选择框。 + + Query Params: + name: 岗位名称(模糊匹配,可选) Returns: JSON: 岗位列表数据 """ - result = position.get_position_list() + result = position.get_position_list(request.args) return R.ok(data=result) diff --git a/src/modules/system/position/schema.py b/src/modules/system/position/schema.py index 5c78cf9..44429cd 100644 --- a/src/modules/system/position/schema.py +++ b/src/modules/system/position/schema.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -52,7 +52,7 @@ from wtforms import ValidationError, StringField, IntegerField from wtforms.validators import DataRequired, Length, NumberRange, Optional from src.common.forms.base_form import BaseForm -from src.modules.system.position.models import Position +from src.modules.system.position.repository import PositionRepository # ============================================================ @@ -145,15 +145,8 @@ class PositionForm(BaseForm): # 获取当前记录的ID(更新时存在,添加时为None) current_id = self.id.data if self.id.data else None - # 构建查询:查找同名记录 - query = Position.query.filter(Position.name == field.data, Position.is_delete == 0) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(Position.id != current_id) - - # 如果存在同名记录,抛出验证错误 - if query.first(): + # 唯一性校验(软删过滤 + 更新时排除自身) + if PositionRepository.exists_by({'name': field.data}, exclude_id=current_id): raise ValidationError("岗位名称已存在") diff --git a/src/modules/system/position/service.py b/src/modules/system/position/service.py index 0005e93..29154a6 100644 --- a/src/modules/system/position/service.py +++ b/src/modules/system/position/service.py @@ -1,404 +1,58 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 -# +====================================================================== -# | 版权和免责声明: -# | 一、知识产权声明 -# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 -# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, -# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 -# | 二、使用限制与禁止条款 -# | 您理解并同意,禁止将本产品用于以下任何行为: -# | 1. 任何违反中华人民共和国法律法规的用途; -# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; -# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 -# | 三、责任豁免 -# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 -# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 -# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 -# | 四、违约与法律责任 -# | 如发现任何违反本声明的行为,本团队有权: -# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; -# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; -# | 3. 保留进一步采取刑事控告等法律措施的权利。 -# | 五、许可范围说明 -# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 -# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 -# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 -# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 -# | 成的一切经济损失及商誉损害。 -# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 -# | 许可协议》附件为准。 -# +====================================================================== - """ 岗位管理服务模块 ============================================================= 该模块实现了岗位管理相关的所有业务逻辑。 -包括岗位的分页查询、详情查询、添加、更新、删除、状态设置等功能。 岗位用于标识用户的职位岗位,如:项目经理、开发工程师、测试工程师等, -每个岗位包含名称、排序、状态等属性,通过sort字段控制显示顺序。 - -命名规范: -- 函数参数使用驼峰命名(如 positionId, pageNo) -- 表单字段使用下划线命名(如 name, sort, status)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters, _build_page_data) - -功能模块: -1. 分页查询:支持按名称模糊搜索、状态筛选,按sort字段排序 -2. 详情查询:根据ID获取单个岗位的详细信息 -3. 数据维护:岗位的添加、更新、删除(支持批量) -4. 状态管理:单独更新岗位的启用/禁用状态 -5. 数据列表:获取所有启用的岗位列表(用于下拉选择等场景) +每个岗位包含名称、排序、状态等属性,通过 sort 字段控制显示顺序。 """ -import logging - -from datetime import datetime, timezone -from sqlalchemy import and_ - -from src.common.constants.common import PAGE_SIZE -from src.modules.system.position.schema import PositionForm, PositionStatusForm -from src.modules.system.position.models import Position -from src.modules.system.position.repository import PositionRepository +from src.common.base_service import BaseService from src.core.exceptions import BizError -from src.common.utils import regular -from src.common.utils.case_converter import convert_camel_to_snake -from src.common.utils.model_utils import models_to_list, model_to_dict -from src.common.utils.request import parse_id_list, like_contains -from src.core.security import get_username +from src.modules.system.position.schema import PositionForm, PositionStatusForm +from src.modules.system.position.repository import PositionRepository +from src.modules.system.user.repository import UserRepository -# 获取日志记录器 -logger = logging.getLogger(__name__) + +class PositionService(BaseService): + """ + 岗位服务类 + + 继承 BaseService 获得标准 CRUD 能力,并扩展删除前用户引用校验。 + """ + + repository = PositionRepository + form = PositionForm + status_form = PositionStatusForm + model_name = "岗位" + + @classmethod + def before_delete(cls, ids): + """ + 删除前校验钩子 + + 检查岗位是否仍被用户引用,若被引用则拒绝删除,防止孤儿引用。 + + Args: + ids: 待删除的岗位ID列表 + + Raises: + BizError: 岗位被用户引用时抛出 + """ + for pid in ids: + if UserRepository.count_by({'position_id': pid}) > 0: + raise BizError(f"岗位ID {pid} 已被用户引用,无法删除") # ============================================================ -# 岗位分页查询 +# 模块级代理函数:保持 router 调用兼容 # ============================================================ -def get_position_page(params: dict) -> dict: - """ - 查询岗位分页数据 - - Args: - params: 查询参数 - - pageNo: 当前页码(可选,默认为1) - - pageSize: 每页记录数(可选,默认为PAGE_SIZE) - - name: 岗位名称(可选,模糊查询) - - status: 岗位状态(可选,精确匹配) - - Returns: - dict: 分页数据,格式为: - { - "records": 岗位列表, - "total": 总记录数, - "size": 每页大小, - "current": 当前页码, - "pages": 总页数 - } - """ - page_no = int(params.get('pageNo', 1)) - page_size = int(params.get('pageSize', PAGE_SIZE)) - - # 应用筛选条件 - query = _apply_filters(params) - - # 分页查询(repository 统一封装分页计算) - position_list, total, pages = PositionRepository.paginate(query, page_no, page_size) - return _build_page_data(position_list, total, page_size, page_no, pages) - - -def _apply_filters(params: dict): - """ - 应用查询筛选条件 - - 根据查询参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 支持岗位名称模糊查询 - - 支持岗位状态精确查询 - - 默认按排序值升序排列 - - Args: - params: 查询参数 dict - - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = Position.query.filter(Position.is_delete == 0) - - # 岗位名称模糊筛选 - name = params.get('name') - if name: - query = query.filter(Position.name.like(like_contains(name), escape='\\')) - - # 岗位状态精确筛选 - status = params.get('status') - if status: - query = query.filter(Position.status == status) - - # 按排序值升序排列 - return query.order_by(Position.sort.asc()) - - -def _build_page_data(position_list, total, page_size, page_no, pages): - """ - 构建分页返回数据 - - Args: - position_list: 岗位对象列表 - total: 总记录数 - page_size: 每页大小 - page_no: 当前页码 - pages: 总页数 - - Returns: - dict: 分页数据,包含 records、total、size、current、pages - """ - # 将模型对象列表转换为字典列表 - records = models_to_list(position_list) - - return { - 'records': records, - 'total': total, - 'size': page_size, - 'current': page_no, - 'pages': pages - } - - -# ============================================================ -# 岗位详情查询 -# ============================================================ - -def get_position_detail(position_id): - """ - 根据ID查询岗位详情(返回字典格式) - - 此方法调用 get_info 获取模型对象,然后转换为字典格式返回。 - 适用于前端接口调用。 - - Args: - position_id: 岗位ID - - Returns: - dict/None: 岗位详情字典,不存在时返回 None - """ - try: - # 复用 get_info 方法查询岗位 - position = get_info(position_id) - if not position: - return None - # 转换为字典格式 - return model_to_dict(position) - - except Exception as e: - logger.error(f"查询岗位详情异常,ID: {position_id}, 错误: {str(e)}") - return None - - -# ============================================================ -# 岗位信息查询(根据ID)- 核心查询方法 -# ============================================================ - -def get_info(position_id: int): - """ - 根据ID查询岗位信息(返回模型对象) - - 这是一个通用的岗位查询方法,返回岗位模型对象而非字典。 - 与 get_position_detail 的区别: - - get_position_detail: 返回字典格式,用于前端接口 - - get_info: 返回模型对象,用于内部服务调用 - - 此方法被以下方法复用: - - get_position_detail: 获取详情字典 - - update_position: 验证岗位是否存在 - - update_position_status: 验证岗位是否存在 - - Args: - position_id: 岗位ID - - Returns: - Position/None: 岗位模型对象,不存在或已删除时返回 None - - Example: - position = get_info(position_id) - if position: - print(position.name) - """ - # 参数有效性验证与查询统一由仓储处理 - return PositionRepository.get_by_id(position_id) - - -# ============================================================ -# 岗位添加 -# ============================================================ - -def add_position(data: dict) -> None: - """ - 添加岗位 - - 流程: - 1. 表单验证(名称唯一性、字段格式等) - 2. 创建岗位对象并保存到数据库 - - Args: - data: 请求体数据 dict - - Raises: - BizError: 表单验证失败时 - """ - form = PositionForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 创建岗位 - position = Position(**form.data) - position.create_user = get_username() - position.save() - - -# ============================================================ -# 岗位更新 -# ============================================================ - -def update_position(data: dict) -> None: - """ - 更新岗位 - - 流程: - 1. 表单验证 - 2. 验证岗位是否存在(复用 get_info) - 3. 更新岗位信息 - - Args: - data: 请求体数据 dict - - Raises: - BizError: 表单验证失败 / 岗位不存在 / 更新失败 - """ - form = PositionForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 获取岗位ID - position_id = form.data.get('id') - if not position_id or int(position_id) <= 0: - raise BizError("岗位ID无效") - position_id = int(position_id) - - # 复用 get_info 方法验证岗位是否存在 - if not get_info(position_id): - raise BizError("岗位不存在") - - # 更新岗位信息(移除ID,避免更新主键) - form_data = convert_camel_to_snake(form.data.copy()) - form_data.pop('id', None) - - form_data['update_time'] = datetime.now(tz=timezone.utc) - form_data['update_user'] = get_username() - - result = Position.query.filter_by(id=position_id).update(form_data) - - if not result: - raise BizError("更新失败") - - -# ============================================================ -# 岗位删除 -# ============================================================ - -def delete_positions(position_ids: str) -> int: - """ - 删除岗位(批量删除) - - 支持逗号分隔的多个ID批量删除。 - - Args: - position_ids: 岗位ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - int: 本次删除的记录数 - - Raises: - BizError: ID 无效时 - """ - if not position_ids: - raise BizError("记录ID不存在") - id_list = parse_id_list(position_ids) - if not id_list: - raise BizError("无效的记录ID") - return Position.batch_delete(id_list) - - -# ============================================================ -# 岗位状态设置 -# ============================================================ - -def update_position_status(data: dict) -> None: - """ - 设置岗位状态 - - 单独更新岗位的启用/禁用状态,不涉及其他字段。 - - Args: - data: 请求体数据 dict - - Raises: - BizError: 表单验证失败 / 岗位不存在 / 设置失败 - """ - form = PositionStatusForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - position_id = int(form.data['id']) - status = int(form.data['status']) - - # 复用 get_info 方法验证岗位是否存在 - if not get_info(position_id): - raise BizError("岗位不存在") - - # 更新状态 - update_data = {"status": status, "update_time": datetime.now(tz=timezone.utc), "update_user": get_username()} - result = Position.query.filter_by(id=position_id).update(update_data) - - if not result: - raise BizError("设置失败") - - -# ============================================================ -# 岗位列表获取(无分页) -# ============================================================ - -def get_position_list(): - """ - 获取所有启用的岗位列表(无分页) - - 用于下拉选择框等需要全部数据的场景。 - 只返回状态为"正常"的岗位,按排序值升序排列。 - - Returns: - list: 岗位列表,每个元素为岗位模型字典,无数据时返回空列表 - """ - try: - # 查询所有启用的岗位 - position_list = Position.query.filter( - and_(Position.is_delete == 0, Position.status == 1) - ).order_by(Position.sort.asc()).all() - - # 转换为字典列表 - return models_to_list(position_list) - - except Exception as e: - logger.error(f"获取岗位列表异常: {str(e)}") - return [] +get_position_page = PositionService.get_page +get_position_detail = PositionService.get_detail +get_info = PositionService.get_info +add_position = PositionService.add +update_position = PositionService.update +delete_positions = PositionService.remove +update_position_status = PositionService.update_status +get_position_list = PositionService.get_list diff --git a/src/modules/system/role/models.py b/src/modules/system/role/models.py index a9a2ee6..555c285 100644 --- a/src/modules/system/role/models.py +++ b/src/modules/system/role/models.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -49,6 +49,7 @@ from src.common.base_db import BaseDB from src.common.base_model import BaseModel from config import DB_PREFIX +from sqlalchemy import text from src.core.database import db @@ -78,6 +79,9 @@ class Role(BaseModel, BaseDB): # 设置表名 __tablename__ = DB_PREFIX + "role" + # 表注释 + __table_comment__ = "角色表" + # ============================================================ # 字段定义 # ============================================================ @@ -86,6 +90,7 @@ class Role(BaseModel, BaseDB): name = db.Column( db.String(150), nullable=False, + index=True, comment="角色名称" ) @@ -93,6 +98,7 @@ class Role(BaseModel, BaseDB): code = db.Column( db.String(30), nullable=False, + index=True, comment="角色编码" ) @@ -100,6 +106,7 @@ class Role(BaseModel, BaseDB): sort = db.Column( db.Integer, default=0, + server_default=text('0'), comment="角色排序" ) diff --git a/src/modules/system/role/repository.py b/src/modules/system/role/repository.py index 5d102d3..87155bb 100644 --- a/src/modules/system/role/repository.py +++ b/src/modules/system/role/repository.py @@ -6,10 +6,56 @@ Role 仓储模块 """ from src.common.base_repository import BaseRepository +from src.common.constants.common import PAGE_SIZE from src.modules.system.role.models import Role class RoleRepository(BaseRepository): - """Role 仓储""" + """角色数据仓储,提供角色相关的数据库访问操作""" model = Role + + @classmethod + def page(cls, params: dict): + """ + 角色分页查询 + + 支持按名称模糊搜索,按 sort 字段升序排列。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + - name: 角色名称(模糊匹配) + + Returns: + tuple: (items, total, pages) + - items: 当前页数据列表 + - total: 总记录数 + - pages: 总页数 + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + like_filters={'name': params.get('name')}, + order_by='sort', + ) + + @classmethod + def list_enabled(cls, params=None): + """ + 查询全部未删除角色 + + 用于下拉选择场景,支持与分页一致的条件筛选(name 模糊匹配)。 + + Args: + params: 查询参数字典,可包含 name 字段用于模糊搜索 + + Returns: + list: 角色实例列表,按 sort 升序排列 + """ + params = params or {} + return cls.find_list( + like_filters={'name': params.get('name')}, + order_by='sort', + ) diff --git a/src/modules/system/role/router.py b/src/modules/system/role/router.py index b5b44f7..601eab2 100644 --- a/src/modules/system/role/router.py +++ b/src/modules/system/role/router.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -57,7 +57,7 @@ """ from flask import Blueprint, request -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.core.permission import check_permission from src.modules.system.role import service as role from src.common.utils.request import parse_batch_ids, parse_request_body @@ -74,6 +74,7 @@ role_blue = Blueprint("role", __name__, url_prefix="/role") @role_blue.route("/page", methods=["GET"]) @check_permission("sys:role:page") +@operation_log("角色管理", "查询") def page(): """ 查询角色分页数据 @@ -94,6 +95,7 @@ def page(): @role_blue.route("/detail/", methods=["GET"]) @check_permission("sys:role:detail") +@operation_log("角色管理", "详情") def detail(id): """ 查询角色详情 @@ -116,6 +118,7 @@ def detail(id): @role_blue.route("/add", methods=["POST"]) @check_permission("sys:role:add") @check_demo +@operation_log("角色管理", "新增") def add(): """ 添加角色 @@ -141,6 +144,7 @@ def add(): @role_blue.route("/update", methods=["PUT"]) @check_permission("sys:role:update") @check_demo +@operation_log("角色管理", "修改") def update(): """ 更新角色 @@ -166,6 +170,7 @@ def update(): @role_blue.route("/delete/", methods=["DELETE"]) @check_permission("sys:role:delete") @check_demo +@operation_log("角色管理", "删除") def delete(id): """ 删除角色 @@ -186,8 +191,9 @@ def delete(id): @role_blue.route("/batchDelete", methods=["DELETE"]) -@check_permission("sys:role:batch_delete") +@check_permission("sys:role:batchDelete") @check_demo +@operation_log("角色管理", "批量删除") def batch_delete(): """ 批量删除角色 @@ -204,7 +210,8 @@ def batch_delete(): id_str, err = parse_batch_ids() if err: return err - return role.delete_roles(id_str) + count = role.delete_roles(id_str) + return R.ok(msg=f"本次共删除{count}条数据") # ============================================================ @@ -213,15 +220,20 @@ def batch_delete(): @role_blue.route("/list", methods=["GET"]) +@check_permission("sys:role:list") +@operation_log("角色管理", "查询") def get_role_list(): """ 获取角色数据列表(无分页) 功能说明: - 获取所有角色列表,常用于下拉选择框。 + 获取所有角色列表,支持与分页一致的筛选参数(name 模糊匹配),常用于下拉选择框。 + + Query Params: + name: 角色名称(模糊匹配,可选) Returns: JSON: 角色列表数据 """ - result = role.get_role_list() + result = role.get_role_list(request.args) return R.ok(data=result) diff --git a/src/modules/system/role/schema.py b/src/modules/system/role/schema.py index d2f6e25..5d8148d 100644 --- a/src/modules/system/role/schema.py +++ b/src/modules/system/role/schema.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -52,7 +52,7 @@ from wtforms import ValidationError, IntegerField, StringField from wtforms.validators import NumberRange, DataRequired, Length, Optional from src.common.forms.base_form import BaseForm -from src.modules.system.role.models import Role +from src.modules.system.role.repository import RoleRepository # ============================================================ @@ -158,15 +158,8 @@ class RoleForm(BaseForm): # 获取当前记录的ID(更新时存在,添加时为None) current_id = self.id.data if self.id.data else None - # 构建查询:查找同名记录 - query = Role.query.filter(Role.name == field.data, Role.is_delete == 0) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(Role.id != current_id) - - # 如果存在同名记录,抛出验证错误 - if query.first(): + # 唯一性校验(软删过滤 + 更新时排除自身) + if RoleRepository.exists_by({'name': field.data}, exclude_id=current_id): raise ValidationError("角色名称已存在") def validate_code(self, field): @@ -189,13 +182,6 @@ class RoleForm(BaseForm): # 获取当前记录的ID(更新时存在,添加时为None) current_id = self.id.data if self.id.data else None - # 构建查询:查找相同编码记录 - query = Role.query.filter(Role.code == field.data, Role.is_delete == 0) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(Role.id != current_id) - - # 如果存在相同编码记录,抛出验证错误 - if query.first(): + # 唯一性校验(软删过滤 + 更新时排除自身) + if RoleRepository.exists_by({'code': field.data}, exclude_id=current_id): raise ValidationError("角色编码已存在") diff --git a/src/modules/system/role/service.py b/src/modules/system/role/service.py index c041ac4..fe99357 100644 --- a/src/modules/system/role/service.py +++ b/src/modules/system/role/service.py @@ -1,365 +1,53 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 -# +====================================================================== -# | 版权和免责声明: -# | 一、知识产权声明 -# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 -# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, -# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 -# | 二、使用限制与禁止条款 -# | 您理解并同意,禁止将本产品用于以下任何行为: -# | 1. 任何违反中华人民共和国法律法规的用途; -# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; -# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 -# | 三、责任豁免 -# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 -# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 -# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 -# | 四、违约与法律责任 -# | 如发现任何违反本声明的行为,本团队有权: -# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; -# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; -# | 3. 保留进一步采取刑事控告等法律措施的权利。 -# | 五、许可范围说明 -# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 -# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 -# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 -# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 -# | 成的一切经济损失及商誉损害。 -# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 -# | 许可协议》附件为准。 -# +====================================================================== - """ 角色管理服务模块 ============================================================= 该模块实现了角色管理相关的所有业务逻辑。 -包括角色的分页查询、详情查询、添加、更新、删除、状态设置等功能。 角色用于管理系统中的用户角色,如:超级管理员、普通管理员等, -每个角色包含名称、编码、排序、状态等属性,通过sort字段控制显示顺序。 - -命名规范: -- 函数参数使用驼峰命名(如 roleId, pageNo) -- 表单字段使用下划线命名(如 name, code, sort)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters, _build_page_data) - -功能模块: -1. 分页查询:支持按名称模糊搜索、状态筛选,按sort字段排序 -2. 详情查询:根据ID获取单个角色的详细信息 -3. 数据维护:角色的添加、更新、删除(支持批量) -4. 状态管理:单独更新角色的启用/禁用状态 -5. 数据列表:获取所有启用的角色列表(用于下拉选择等场景) +每个角色包含名称、编码、排序、状态等属性,通过 sort 字段控制显示顺序。 """ -import logging - -from datetime import datetime, timezone - -from src.common.constants.common import PAGE_SIZE +from src.common.base_service import BaseService from src.modules.system.role.schema import RoleForm -from src.modules.system.role.models import Role from src.modules.system.role.repository import RoleRepository -from src.core.exceptions import BizError -from src.modules.association.user_role.models import UserRole +from src.modules.association.user_role.repository import UserRoleRepository from src.modules.system.menu.service import invalidate_user_cache -from src.common.utils import regular -from src.common.utils.case_converter import convert_camel_to_snake -from src.common.utils.model_utils import models_to_list, model_to_dict -from src.common.utils.request import parse_id_list, like_contains -from src.core.security import get_username -# 获取日志记录器 -logger = logging.getLogger(__name__) + +class RoleService(BaseService): + """ + 角色服务类 + + 继承 BaseService 获得标准 CRUD 能力,并扩展删除前清除用户权限缓存。 + """ + + repository = RoleRepository + form = RoleForm + model_name = "角色" + + @classmethod + def before_delete(cls, ids): + """ + 删除前钩子 + + 清除该角色关联用户的权限缓存,确保用户权限在角色删除后及时失效。 + + Args: + ids: 待删除的角色ID列表 + """ + for rid in ids: + for uid in UserRoleRepository.get_users_by_role(rid): + invalidate_user_cache(uid) # ============================================================ -# 角色分页查询 +# 模块级代理函数:保持 router 调用兼容 # ============================================================ - -def get_role_page(params: dict) -> dict: - """ - 查询角色分页数据 - - Args: - request: Flask request对象,包含查询参数 - - pageNo: 当前页码(可选,默认为1) - - pageSize: 每页记录数(可选,默认为PAGE_SIZE) - - name: 角色名称(可选,模糊查询) - - Returns: - R: 包含分页数据的JSON响应,格式为: - { - "records": 角色列表, - "total": 总记录数, - "size": 每页大小, - "current": 当前页码, - "pages": 总页数 - } - """ - # 获取分页参数 - page_no = int(params.get("pageNo", 1)) - page_size = int(params.get("pageSize", PAGE_SIZE)) - - # 应用筛选条件 - query = _apply_filters(params) - - # 记录总数 - total = query.count() - # 计算总页数 - pages = (total + page_size - 1) // page_size if total > 0 else 0 - - # 分页查询 - role_list = query.limit(page_size).offset((page_no - 1) * page_size).all() - # 构建返回数据 - page_data = _build_page_data(role_list, total, page_size, page_no, pages) - return page_data - - -def _apply_filters(params: dict): - """ - 应用查询筛选条件 - - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 支持角色名称模糊查询 - - 支持角色状态精确查询 - - 默认按排序值升序排列 - - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = Role.query.filter(Role.is_delete == 0) - - # 角色名称模糊筛选 - name = params.get("name") - if name: - query = query.filter(Role.name.like(like_contains(name), escape="\\")) - - # 按排序值升序排列 - return query.order_by(Role.sort.asc()) - - -def _build_page_data(role_list, total, page_size, page_no, pages): - """ - 构建分页返回数据 - - Args: - role_list: 角色对象列表 - total: 总记录数 - page_size: 每页大小 - page_no: 当前页码 - pages: 总页数 - - Returns: - dict: 分页数据,包含 records、total、size、current、pages - """ - # 将模型对象列表转换为字典列表 - records = models_to_list(role_list) - - return {"records": records, "total": total, "size": page_size, "current": page_no, "pages": pages} - - -# ============================================================ -# 角色详情查询 -# ============================================================ - - -def get_role_detail(role_id): - """ - 根据ID查询角色详情(返回字典格式) - - 此方法调用 get_info 获取模型对象,然后转换为字典格式返回。 - 适用于前端接口调用。 - - Args: - role_id: 角色ID - - Returns: - dict/None: 角色详情字典,不存在时返回 None - """ - try: - # 复用 get_info 方法查询角色 - role = get_info(role_id) - if not role: - return None - # 转换为字典格式 - return model_to_dict(role) - - except Exception as e: - logger.error(f"查询角色详情异常,ID: {role_id}, 错误: {str(e)}") - return None - - -# ============================================================ -# 角色信息查询(根据ID)- 核心查询方法 -# ============================================================ - - -def get_info(role_id: int): - """ - 根据ID查询角色信息(返回模型对象) - - 这是一个通用的角色查询方法,返回角色模型对象而非字典。 - 与 get_role_detail 的区别: - - get_role_detail: 返回字典格式,用于前端接口 - - get_info: 返回模型对象,用于内部服务调用 - - 此方法被以下方法复用: - - get_role_detail: 获取详情字典 - - update_role: 验证角色是否存在 - - Args: - role_id: 角色ID - - Returns: - Role/None: 角色模型对象,不存在或已删除时返回 None - - Example: - role = get_info(role_id) - if role: - print(role.name) - """ - return RoleRepository.get_by_id(role_id) - - -def add_role(data: dict) -> None: - """ - 添加角色 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证(名称唯一性、字段格式等) - 3. 创建角色对象并保存到数据库 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = RoleForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 创建角色 - role = Role(**form.data) - role.create_user = get_username() - role.save() - - -# ============================================================ -# 角色更新 -# ============================================================ - - -def update_role(data: dict) -> None: - """ - 更新角色 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 验证角色是否存在(复用 get_info) - 4. 更新角色信息 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = RoleForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 获取角色ID - role_id = form.data.get("id") - if not role_id or int(role_id) <= 0: - raise BizError("角色ID无效") - role_id = int(role_id) - - # 复用 get_info 方法验证角色是否存在 - role = get_info(role_id) - if not role: - raise BizError("角色不存在") - - # 更新角色信息(移除ID,避免更新主键) - form_data = convert_camel_to_snake(form.data.copy()) - form_data.pop("id", None) - - form_data["update_time"] = datetime.now(tz=timezone.utc) - form_data["update_user"] = get_username() - - result = Role.query.filter_by(id=role_id).update(form_data) - - if not result: - raise BizError("更新失败") - - -# ============================================================ -# 角色删除 -# ============================================================ - - -def delete_roles(role_ids: str) -> int: - """ - 删除角色(批量删除) - - 支持逗号分隔的多个ID批量删除。 - 删除前清除该角色下所有用户的权限缓存。 - - Args: - role_ids: 角色ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - # 删除前清除该角色关联用户的缓存 - id_list = parse_id_list(role_ids) - for rid in id_list: - user_ids = [row.user_id for row in UserRole.query.filter(UserRole.role_id == rid).all()] - for uid in user_ids: - invalidate_user_cache(uid) - - role_ids_list = parse_id_list(role_ids) - if not role_ids_list: - raise BizError("无效的记录ID") - return Role.batch_delete(role_ids_list) - - -# ============================================================ -# 角色列表获取(无分页) -# ============================================================ - - -def get_role_list(): - """ - 获取所有启用的角色列表(无分页) - - 用于下拉选择框等需要全部数据的场景。 - 只返回状态为"正常"的角色,按排序值升序排列。 - - Returns: - list: 角色列表,每个元素为角色模型字典,无数据时返回空列表 - """ - try: - # 查询所有启用的角色 - role_list = Role.query.filter(Role.is_delete == 0).order_by(Role.sort.asc()).all() - - # 转换为字典列表 - return models_to_list(role_list) - - except Exception as e: - logger.error(f"获取角色列表异常: {str(e)}") - return [] +get_role_page = RoleService.get_page +get_role_detail = RoleService.get_detail +get_info = RoleService.get_info +add_role = RoleService.add +update_role = RoleService.update +delete_roles = RoleService.remove +get_role_list = RoleService.get_list diff --git a/src/modules/system/user/models.py b/src/modules/system/user/models.py index 817df8c..93845a6 100644 --- a/src/modules/system/user/models.py +++ b/src/modules/system/user/models.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -49,6 +49,7 @@ from src.common.base_db import BaseDB from src.common.base_model import BaseModel from config import DB_PREFIX +from sqlalchemy import text from src.core.database import db @@ -65,6 +66,7 @@ class User(BaseModel, BaseDB): Attributes: id: 主键ID(继承自 BaseModel) + code: 用户姓名(String(100)) realname: 用户姓名(String(150),必填) gender: 性别(Integer,1-男,2-女,3-保密,默认1) avatar: 用户头像(String(255),必填) @@ -95,14 +97,26 @@ class User(BaseModel, BaseDB): # 设置表名 __tablename__ = DB_PREFIX + "user" + # 表注释 + __table_comment__ = "用户表" + # ============================================================ # 字段定义 # ============================================================ + # 用户编码 + code = db.Column( + db.String(100), + nullable=True, + index=True, + comment="用户编码" + ) + # 用户姓名 realname = db.Column( db.String(150), nullable=False, + index=True, comment="用户姓名" ) @@ -110,6 +124,7 @@ class User(BaseModel, BaseDB): gender = db.Column( db.Integer, default=1, + server_default=text('1'), comment="性别:1-男 2-女 3-保密" ) @@ -124,6 +139,7 @@ class User(BaseModel, BaseDB): mobile = db.Column( db.String(30), nullable=False, + index=True, comment="手机号" ) @@ -131,6 +147,7 @@ class User(BaseModel, BaseDB): email = db.Column( db.String(30), nullable=False, + index=True, comment="邮箱" ) @@ -138,6 +155,8 @@ class User(BaseModel, BaseDB): dept_id = db.Column( db.Integer, default=0, + server_default=text('0'), + index=True, comment="部门ID" ) @@ -145,6 +164,8 @@ class User(BaseModel, BaseDB): level_id = db.Column( db.Integer, default=0, + server_default=text('0'), + index=True, comment="职级ID" ) @@ -152,6 +173,8 @@ class User(BaseModel, BaseDB): position_id = db.Column( db.Integer, default=0, + server_default=text('0'), + index=True, comment="岗位ID" ) @@ -172,14 +195,14 @@ class User(BaseModel, BaseDB): # 县区编码 district_code = db.Column( db.String(30), - nullable=False, + nullable=True, comment="县区编码" ) # 街道编码 street_code = db.Column( db.String(30), - nullable=False, + nullable=True, comment="街道编码" ) @@ -201,20 +224,21 @@ class User(BaseModel, BaseDB): username = db.Column( db.String(30), nullable=True, + index=True, comment="用户名" ) # 密码 password = db.Column( db.String(255), - nullable=True, + nullable=False, comment="密码" ) # 加密盐 salt = db.Column( db.String(30), - nullable=True, + nullable=False, comment="加密盐" ) @@ -229,6 +253,8 @@ class User(BaseModel, BaseDB): status = db.Column( db.Integer, default=1, + server_default=text('1'), + index=True, comment="状态:1-正常 2-禁用" ) @@ -236,6 +262,7 @@ class User(BaseModel, BaseDB): sort = db.Column( db.Integer, default=0, + server_default=text('0'), comment="排序" ) diff --git a/src/modules/system/user/repository.py b/src/modules/system/user/repository.py index cb38588..e024bfd 100644 --- a/src/modules/system/user/repository.py +++ b/src/modules/system/user/repository.py @@ -6,10 +6,40 @@ User 仓储模块 """ from src.common.base_repository import BaseRepository +from src.common.constants.common import PAGE_SIZE from src.modules.system.user.models import User class UserRepository(BaseRepository): - """User 仓储""" + """用户数据仓储,提供用户相关的数据库访问操作""" model = User + + @classmethod + def page(cls, params: dict): + """ + 用户分页查询 + + 支持按真实姓名模糊搜索、按性别和状态精确过滤,按 id 倒序排列。 + + Args: + params: 查询参数字典 + - pageNo: 当前页码 + - pageSize: 每页记录数 + - realname: 用户真实姓名(模糊匹配) + - gender: 性别(精确匹配) + - status: 状态(精确匹配) + + Returns: + tuple: (items, total, pages) + - items: 当前页数据列表 + - total: 总记录数 + - pages: 总页数 + """ + return cls.find_page( + page=int(params.get('pageNo', 1)), + page_size=int(params.get('pageSize', PAGE_SIZE)), + like_filters={'realname': params.get('realname')}, + filters={'gender': params.get('gender'), 'status': params.get('status')}, + order_desc='id', + ) diff --git a/src/modules/system/user/router.py b/src/modules/system/user/router.py index 5d3e337..3b772f4 100644 --- a/src/modules/system/user/router.py +++ b/src/modules/system/user/router.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -58,7 +58,7 @@ from flask import Blueprint, request -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.core.permission import check_permission from src.modules.system.user import service as user from src.common.utils.request import parse_batch_ids, parse_request_body @@ -75,6 +75,7 @@ user_blue = Blueprint("user", __name__, url_prefix="/user") @user_blue.route("/page", methods=["GET"]) @check_permission("sys:user:page") +@operation_log("用户管理", "查询") def page(): """ 查询用户分页数据 @@ -95,6 +96,7 @@ def page(): @user_blue.route("/detail/", methods=["GET"]) @check_permission("sys:user:detail") +@operation_log("用户管理", "详情") def detail(id): """ 查询用户详情 @@ -117,6 +119,7 @@ def detail(id): @user_blue.route("/add", methods=["POST"]) @check_permission("sys:user:add") @check_demo +@operation_log("用户管理", "新增") def add(): """ 添加用户 @@ -142,6 +145,7 @@ def add(): @user_blue.route("/update", methods=["PUT"]) @check_permission("sys:user:update") @check_demo +@operation_log("用户管理", "修改") def update(): """ 更新用户 @@ -167,6 +171,7 @@ def update(): @user_blue.route("/delete/", methods=["DELETE"]) @check_permission("sys:user:delete") @check_demo +@operation_log("用户管理", "删除") def delete(id): """ 删除用户 @@ -187,8 +192,9 @@ def delete(id): @user_blue.route("/batchDelete", methods=["DELETE"]) -@check_permission("sys:user:batch_delete") +@check_permission("sys:user:batchDelete") @check_demo +@operation_log("用户管理", "批量删除") def batch_delete(): """ 批量删除用户 @@ -217,6 +223,7 @@ def batch_delete(): @user_blue.route("/status", methods=["PUT"]) @check_permission("sys:user:status") @check_demo +@operation_log("用户管理", "状态变更") def status(): """ 设置用户状态 @@ -242,6 +249,7 @@ def status(): @user_blue.route("/resetPwd", methods=["PUT"]) @check_permission("sys:user:resetPwd") @check_demo +@operation_log("用户管理", "重置密码") def reset_pwd(): """ 重置用户密码 @@ -252,5 +260,52 @@ def reset_pwd(): Returns: JSON: 操作结果 """ - result = user.reset_user_password() - return result + json_data, err = parse_request_body() + if err: + return R.failed(msg=err) + user.reset_user_password(json_data) + return R.ok(msg="重置成功") + + +# ============================================================ +# Excel 导入 / 导出 +# ============================================================ + + +@user_blue.route("/import", methods=["POST"]) +@check_permission("sys:user:import") +@check_demo +@operation_log("用户管理", "导入") +def imports(): + """ + 导入Excel文件 + + 功能说明: + 通过上传Excel文件批量导入用户数据,支持部门/职级/岗位按名称匹配、 + 角色按名称匹配,密码缺省使用默认密码。 + + Returns: + JSON: 导入结果,包含成功导入的数据条数 + """ + return user.import_users() + + +@user_blue.route("/export", methods=["GET"]) +@check_permission("sys:user:export") +@operation_log("用户管理", "导出") +def export(): + """ + 导出Excel文件 + + 功能说明: + 将用户数据导出为Excel文件,支持与分页查询一致的筛选参数 + (realname 模糊匹配、status 精确匹配)。 + + Query Params: + realname: 用户姓名(模糊匹配,可选) + status: 用户状态(精确匹配,可选) + + Returns: + JSON: 导出结果,包含文件访问URL + """ + return user.export_users(request.args) diff --git a/src/modules/system/user/schema.py b/src/modules/system/user/schema.py index 4ff2b14..31b05fd 100644 --- a/src/modules/system/user/schema.py +++ b/src/modules/system/user/schema.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -56,7 +56,7 @@ from wtforms.validators import NumberRange, DataRequired, Length, ValidationErro from src.common.forms.fields import ArrayField from src.common.forms.base_form import BaseForm -from src.modules.system.user.models import User +from src.modules.system.user.repository import UserRepository # ============================================================ @@ -173,7 +173,7 @@ class UserForm(BaseForm): deptId = IntegerField( label='部门ID', validators=[ - DataRequired(message='部门ID不能为空'), + Optional(), # 允许 0(无部门) NumberRange(min=0, message='部门ID不得小于0') ], description='所属部门ID,0表示无部门' @@ -185,7 +185,7 @@ class UserForm(BaseForm): levelId = IntegerField( label='职级ID', validators=[ - DataRequired(message='职级ID不能为空'), + Optional(), # 允许 0(无职级) NumberRange(min=0, message='职级ID不得小于0') ], description='所属职级ID,0表示无职级' @@ -197,7 +197,7 @@ class UserForm(BaseForm): positionId = IntegerField( label='岗位ID', validators=[ - DataRequired(message='岗位ID不能为空'), + Optional(), # 允许 0(无岗位) NumberRange(min=0, message='岗位ID不得小于0') ], description='所属岗位ID,0表示无岗位' @@ -214,50 +214,6 @@ class UserForm(BaseForm): description='行政区划选择组件数据' ) - # ============================================================ - # 省份编码 - # ============================================================ - provinceCode = StringField( - label='省份编码', - validators=[ - Optional(), - ], - description='省份行政编码' - ) - - # ============================================================ - # 城市编码 - # ============================================================ - cityCode = StringField( - label='城市编码', - validators=[ - Optional(), - ], - description='城市行政编码' - ) - - # ============================================================ - # 县区编码 - # ============================================================ - districtCode = StringField( - label='县区编码', - validators=[ - Optional(), - ], - description='县区行政编码' - ) - - # ============================================================ - # 街道编码 - # ============================================================ - streetCode = StringField( - label='街道编码', - validators=[ - Optional(), - ], - description='街道行政编码' - ) - # ============================================================ # 详细地址 # ============================================================ @@ -374,15 +330,8 @@ class UserForm(BaseForm): # 获取当前记录的ID(更新时存在,添加时为None) current_id = self.id.data if self.id.data else None - # 构建查询:查找相同账号记录 - query = User.query.filter(User.username == field.data, User.is_delete == 0) - - # 如果是更新操作,排除当前记录自身 - if current_id: - query = query.filter(User.id != current_id) - - # 如果存在相同账号记录,抛出验证错误 - if query.first(): + # 唯一性校验(软删过滤 + 更新时排除自身) + if UserRepository.exists_by({'username': field.data}, exclude_id=current_id): raise ValidationError("登录账号已存在") @@ -439,10 +388,13 @@ class UserInfoForm(BaseForm): Attributes: realname: 用户姓名(必填,最长150字符) gender: 性别(必填,1-男,2-女,3-保密) + avatar: 头像(必填,最长255字符) mobile: 手机号(必填,最长30字符) email: 邮箱(必填,最长30字符) + city: 行政区划(必填,数组长度4) address: 详细地址(必填,最长255字符) intro: 个人简介(可选,最长255字符) + note: 用户备注(可选,最长255字符) """ # ============================================================ @@ -469,6 +421,18 @@ class UserInfoForm(BaseForm): description='性别:1-男,2-女,3-保密' ) + # ============================================================ + # 头像 + # ============================================================ + avatar = StringField( + label='头像', + validators=[ + DataRequired(message='头像不能为空'), + Length(max=255, message='头像长度不得超过255个字符') + ], + description='用户头像图片地址' + ) + # ============================================================ # 手机号 # ============================================================ @@ -493,6 +457,17 @@ class UserInfoForm(BaseForm): description='用户电子邮箱地址' ) + # ============================================================ + # 行政区划 + # ============================================================ + city = ArrayField( + label='行政区划', + validators=[ + DataRequired(message='请选择行政区划'), + ], + description='行政区划选择组件数据' + ) + # ============================================================ # 详细地址 # ============================================================ @@ -516,6 +491,17 @@ class UserInfoForm(BaseForm): description='用户个人简介或签名' ) + # ============================================================ + # 用户备注 + # ============================================================ + note = StringField( + label='用户备注', + validators=[ + Length(max=255, message='用户备注长度不得超过255个字符') + ], + description='用户补充说明' + ) + # ============================================================ # 修改密码表单验证 @@ -560,7 +546,7 @@ class UpdatePwdForm(BaseForm): # ============================================================ # 确认密码 # ============================================================ - rePassword = StringField( + confirmPassword = StringField( label='确认密码', validators=[ DataRequired(message='确认密码不能为空'), diff --git a/src/modules/system/user/service.py b/src/modules/system/user/service.py index ad01347..470eee7 100644 --- a/src/modules/system/user/service.py +++ b/src/modules/system/user/service.py @@ -1,199 +1,274 @@ -# +====================================================================== -# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] -# +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 -# +---------------------------------------------------------------------- -# | 开源协议: Apache-2.0 -# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 -# +---------------------------------------------------------------------- -# | 官方网站: https://www.djangoadmin.cn -# +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 -# +====================================================================== -# | 版权和免责声明: -# | 一、知识产权声明 -# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 -# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, -# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 -# | 二、使用限制与禁止条款 -# | 您理解并同意,禁止将本产品用于以下任何行为: -# | 1. 任何违反中华人民共和国法律法规的用途; -# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; -# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 -# | 三、责任豁免 -# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 -# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 -# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 -# | 四、违约与法律责任 -# | 如发现任何违反本声明的行为,本团队有权: -# | 1. 无条件配合公安机关及其他有权机关的调查取证工作; -# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; -# | 3. 保留进一步采取刑事控告等法律措施的权利。 -# | 五、许可范围说明 -# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 -# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 -# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 -# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 -# | 成的一切经济损失及商誉损害。 -# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 -# | 许可协议》附件为准。 -# +====================================================================== - """ 用户管理服务模块 ============================================================= 该模块实现了用户管理相关的所有业务逻辑。 -包括用户的分页查询、详情查询、添加、更新、删除、状态设置、密码管理等功能。 +包括用户的分页查询、详情查询、添加、更新、删除、状态设置、密码管理、Excel 导入导出等功能。 用户用于标识系统账户,包含用户名、真实姓名、性别、手机号、邮箱、部门、职级、岗位等信息。 - -命名规范: -- 函数参数使用驼峰命名(如 userId, pageNo) -- 表单字段使用下划线命名(如 realname, gender, status)- 与数据库字段保持一致 -- 返回数据使用驼峰命名(如 createUser, updateTime)- 与前端规范保持一致 -- 私有函数以下划线开头(如 _apply_filters, _build_page_data) - -功能模块: -1. 分页查询:支持按真实姓名模糊搜索、性别筛选、状态筛选 -2. 详情查询:根据ID获取单个用户的详细信息(含角色、行政区划等关联数据) -3. 数据维护:用户的添加、更新、删除(支持批量) -4. 状态管理:单独更新用户的启用/禁用状态 -5. 个人信息:更新当前用户的基本信息 -6. 密码管理:修改密码、重置密码 """ + import logging -from datetime import datetime, timezone - -from src.common.constants.dictionary_constants import GENDER_LIST -from src.modules.system.user.schema import UserForm, UserStatusForm, UpdatePwdForm, UserInfoForm, ResetPwdForm -from src.modules.system.dept.models import Dept -from src.modules.system.level.models import Level -from src.modules.system.position.models import Position -from src.modules.system.user.models import User -from src.modules.system.user.repository import UserRepository +from src.common.base_service import BaseService +from src.common.constants.common import DEFAULT_PASSWORD +from src.common.utils import excel_utils +from src.common.utils.model_utils import model_to_dict, models_to_list +from src.core import response as R from src.core.exceptions import BizError -from src.modules.association.user_role.models import UserRole -from src.modules.association.user_role import service as user_role -from src.modules.system.menu.service import invalidate_user_cache -from src.common.constants.common import DEFAULT_PASSWORD, PAGE_SIZE -from src.core.database import db -from src.common.utils import regular -from src.core import password -from src.common.utils.case_converter import convert_camel_to_snake -from src.common.utils.rich_text import get_file_url, save_file -from src.common.utils.model_utils import models_to_list, model_to_dict from src.core.password import verify_password, encrypt_password, generate_salt -from src.common.utils.request import parse_id_list, like_contains -from src.core.security import get_username, get_user_id +from src.core.security import get_username, get_realname, get_user_id +from src.modules.association.user_role import service as user_role +from src.modules.association.user_role.repository import UserRoleRepository +from src.modules.dictionary import dict_utils +from src.modules.system.dept.repository import DeptRepository +from src.modules.system.level.repository import LevelRepository +from src.modules.system.menu.service import invalidate_user_cache +from src.modules.system.position.repository import PositionRepository +from src.modules.system.role.repository import RoleRepository +from src.modules.system.user.repository import UserRepository +from src.modules.system.user.schema import ( + UserForm, UserStatusForm, UpdatePwdForm, UserInfoForm, ResetPwdForm, +) +from src.common.utils.rich_text import get_file_url, save_file # 获取日志记录器 logger = logging.getLogger(__name__) -# ============================================================ -# 用户分页查询 -# ============================================================ - - -def get_user_page(params: dict) -> dict: +class UserService(BaseService): """ - 查询用户分页数据 + 用户服务类 + + 继承 BaseService 获得标准 CRUD 能力。 + 分页/详情/删除/状态复用基类,新增/更新覆写以支持头像保存、密码加密、角色关联等。 + """ + + # 绑定数据仓库类,供基类 CRUD 操作使用 + repository = UserRepository + # 绑定新增/更新的表单校验类 + form = UserForm + # 绑定状态更新的表单校验类 + status_form = UserStatusForm + # 模型中文名称,用于日志和错误提示 + model_name = "用户" + + # ============================================================ + # 查询(分页/详情富化) + # ============================================================ + + @classmethod + def _enrich_page_records(cls, records): + """ + 分页富化钩子 + + 为用户列表补充部门、职级、岗位、角色、行政区划、头像等关联数据。 + + Args: + records: 用户字典列表 + + Returns: + list: 富化后的用户字典列表 + """ + return _enrich_user_records(records) + + @classmethod + def _detail_dict(cls, obj): + """ + 详情序列化钩子 + + 剔除密码字段,补充头像、角色、行政区划等关联数据。 + + Args: + obj: 用户模型实例 + + Returns: + dict: 序列化后的字典 + """ + # 将模型实例转换为字典 + data = model_to_dict(obj) + if data: + # 移除敏感字段 password,防止泄露 + data.pop("password", None) + # 调用外部函数补充关联数据 + return _enrich_user_detail(data) + + # ============================================================ + # 新增/更新覆写(头像/密码/行政区划/角色) + # ============================================================ + + @classmethod + def add(cls, data: dict) -> None: + """ + 新增用户 + + 处理流程: + 1. 表单校验 + 2. 头像保存 + 3. 密码加密 + 4. 行政区划拆分 + 5. 创建用户 + 6. 关联角色 + 7. 清除用户权限缓存 + + Args: + data: 用户数据字典 + + Raises: + BizError: 表单校验失败或业务规则不满足时抛出 + """ + # 1. 使用表单类校验输入数据 + form = cls._validate_form(data) + + # 2. 如果传入了头像,保存头像文件并更新头像字段为文件路径 + avatar = form.data.get("avatar") + if avatar: + form.avatar.data = save_file(avatar, "user") + + # 3. 处理密码:若未传则使用默认密码,并生成加密盐值 + pwd = form.data.get("password") or DEFAULT_PASSWORD + salt = generate_salt() + + # 4. 校验行政区划数组长度必须为4(省/市/区/街道) + city = form.data.get("city", []) + if len(city) != 4: + raise BizError("请选择完整的所在城市(省/市/区/街道)") + # 获取角色ID列表 + roles = form.data.get("roles") + + # 5. 清理表单数据(移除校验辅助字段),准备入库 + create_data = cls._clean_form_data(form) + # 加密密码并存入 + create_data["password"] = encrypt_password(pwd, salt) + create_data["salt"] = salt + # 记录创建人 + create_data["create_user"] = get_realname() + # 将行政区划数组拆分到独立的编码字段 + _apply_city(create_data, city) + + # 6. 创建用户记录 + user = cls.repository.create(**create_data) + # 6.1 生成用户编码:基于ID补零为6位(如 id=10 -> 000010) + user.code = f"{user.id:06d}" + # 7. 关联用户角色关系 + _add_user_role(user.id, roles) + # 8. 清除该用户的权限缓存,确保权限及时生效 + invalidate_user_cache(user.id) + + @classmethod + def update(cls, data: dict) -> None: + """ + 更新用户 + + 处理流程: + 1. 表单校验 + 2. 存在性校验 + 3. 头像保存 + 4. 密码加密(未传新密码时保留原密码) + 5. 行政区划拆分 + 6. 更新用户 + 7. 关联角色 + 8. 清除用户权限缓存 + + Args: + data: 用户数据字典,必须包含 id 字段 + + Raises: + BizError: 表单校验失败、记录不存在或更新异常时抛出 + """ + # 1. 使用表单类校验输入数据 + form = cls._validate_form(data) + + # 2. 校验用户ID是否有效 + user_id = form.data.get("id") + if not user_id or int(user_id) <= 0: + raise BizError("用户ID无效") + user_id = int(user_id) + # 检查用户是否存在 + if not cls.get_info(user_id): + raise BizError("用户不存在") + + # 3. 如果传入了新头像,保存头像文件 + avatar = form.data.get("avatar") + if avatar: + form.avatar.data = save_file(avatar, "user") + + # 4. 处理密码:若传入了新密码则加密,否则保留原密码(从更新数据中移除密码字段) + pwd = form.data.get("password") + city = form.data.get("city", []) + if len(city) != 4: + raise BizError("请选择完整的所在城市(省/市/区/街道)") + roles = form.data.get("roles") + + # 清理表单数据 + update_data = cls._clean_form_data(form) + if pwd: + # 有新密码:生成新盐并加密 + salt = generate_salt() + update_data["password"] = encrypt_password(pwd, salt) + update_data["salt"] = salt + else: + # 未改密:不触碰 password/salt 字段,从更新数据中移除 + update_data.pop("password", None) + update_data.pop("salt", None) + # 拆分行政区划 + _apply_city(update_data, city) + + # 5. 执行更新操作 + result = cls.repository.update_by_id(user_id, update_data) + if not result: + raise BizError("更新失败") + + # 6. 更新用户角色关联 + _add_user_role(user_id, roles) + # 7. 清除权限缓存 + invalidate_user_cache(user_id) + + +def _apply_city(create_data, city): + """ + 将行政区划数组拆分到对应的蛇形命名字段 Args: - request: Flask request对象,包含查询参数 - - page: 当前页码(可选,默认为1) - - limit: 每页记录数(可选,默认为PAGE_SIZE) - - realname: 用户真实姓名(可选,模糊查询) - - gender: 用户性别(可选,精确匹配) - - status: 用户状态(可选,精确匹配) - - Returns: - R: 包含分页数据的JSON响应,格式为: - { - "records": 用户列表, - "total": 总记录数, - "size": 每页大小, - "current": 当前页码, - "pages": 总页数 - } + create_data: 数据字典 + city: 行政区划数组 [省编码, 市编码, 区编码, 街道编码] """ - # 获取分页参数 - page_no = int(params.get("page", 1)) - page_size = int(params.get("limit", PAGE_SIZE)) - - # 应用筛选条件 - query = _apply_filters(params) - - # 记录总数 - total = query.count() - # 计算总页数 - pages = (total + page_size - 1) // page_size if total > 0 else 0 - - # 分页查询 - user_list = query.limit(page_size).offset((page_no - 1) * page_size).all() - # 构建返回数据 - page_data = _build_page_data(user_list, total, page_size, page_no, pages) - return page_data + create_data["province_code"] = city[0] + create_data["city_code"] = city[1] + create_data["district_code"] = city[2] + create_data["street_code"] = city[3] -def _apply_filters(params: dict): +def _add_user_role(user_id, roles): """ - 应用查询筛选条件 + 创建或更新用户角色关系 - 根据请求参数动态构建查询条件: - - 自动过滤已删除记录(is_delete == 0) - - 支持用户真实姓名模糊查询 - - 支持性别精确查询 - - 支持用户状态精确查询 - - 默认按ID降序排列 - - Returns: - Query: 应用筛选后的SQLAlchemy查询对象 - """ - # 基础查询:过滤已删除记录 - query = User.query.filter(User.is_delete == 0) - - # 用户姓名模糊筛选 - realname = params.get("realname") - if realname: - query = query.filter(User.realname.like(like_contains(realname), escape="\\")) - - # 性别精确筛选 - gender = params.get("gender") - if gender: - query = query.filter(User.gender == gender) - - # 用户状态精确筛选 - status = params.get("status") - if status: - query = query.filter(User.status == status) - - # 按ID降序排列 - return query.order_by(User.id.desc()) - - -def _build_page_data(user_list, total, page_size, page_no, pages): - """ - 构建分页返回数据 + 采用先删后增策略,保持角色关联的准确性。 Args: - user_list: 用户对象列表 - total: 总记录数 - page_size: 每页大小 - page_no: 当前页码 - pages: 总页数 - - Returns: - dict: 分页数据,包含 records、total、size、current、pages + user_id: 用户ID + roles: 角色ID列表 """ - # 将模型对象列表转换为字典列表 - records = models_to_list(user_list) - # 补充关联数据 - records = _enrich_user_records(records) + # 调用仓库的替换方法:先删除所有旧关联,再批量新增新关联 + UserRoleRepository.replace_roles(user_id, roles) - return {"records": records, "total": total, "size": page_size, "current": page_no, "pages": pages} +# ============================================================ +# 模块级代理函数:保持 router 调用兼容 +# ============================================================ + +# 将 UserService 的类方法代理为模块级函数,方便路由层直接调用 +get_user_page = UserService.get_page # 分页查询用户 +get_user_detail = UserService.get_detail # 获取用户详情 +get_info = UserService.get_info # 获取用户基本信息 +add_user = UserService.add # 新增用户 +update_user = UserService.update # 更新用户 +delete_users = UserService.remove # 删除用户(支持批量) +update_user_status = UserService.update_status # 更新用户状态(启用/禁用) + + +# ============================================================ +# 分页/详情富化辅助 +# ============================================================ def _enrich_user_records(records): """ @@ -209,21 +284,28 @@ def _enrich_user_records(records): return [] # 缓存关联数据,避免重复查询 - dept_map = _get_dept_map() - level_map = _get_level_map() - position_map = _get_position_map() + dept_map = _get_dept_map() # 部门ID -> 部门名称 + level_map = _get_level_map() # 职级ID -> 职级名称 + position_map = _get_position_map() # 岗位ID -> 岗位名称 + # 性别名称走字典表(dict_utils 带缓存,后台改字典即改显示文本) + gender_map = dict_utils.get_value_map("gender") # 批量查询所有用户的角色关系(1次查询替代N次) user_ids = [data.get("id") for data in records] - role_map = user_role.get_user_roles_batch(user_ids) + role_map = user_role.get_user_roles_batch(user_ids) # user_id -> 角色列表 for data in records: - # 城市编码列表 - city_list = [data.get("provinceCode"), data.get("cityCode"), data.get("districtCode"), data.get("streetCode")] + # 组装行政区划编码数组 + city_list = [ + data.get("provinceCode"), + data.get("cityCode"), + data.get("districtCode"), + data.get("streetCode") + ] - # 性别名称 - data["genderName"] = GENDER_LIST.get(data.get("gender")) - # 头像URL + # 性别名称(value 统一 str 归一,int/str 都能命中) + data["genderText"] = gender_map.get(str(data.get("gender"))) + # 头像URL(将存储路径转换为可访问的URL) data["avatar"] = get_file_url(data.get("avatar")) # 部门名称 data["deptName"] = dept_map.get(data.get("deptId")) @@ -231,9 +313,9 @@ def _enrich_user_records(records): data["levelName"] = level_map.get(data.get("levelId")) # 岗位名称 data["positionName"] = position_map.get(data.get("positionId")) - # 用户角色列表 + # 用户角色列表(角色ID列表) data["roles"] = role_map.get(data.get("id"), []) - # 行政区划 + # 行政区划编码数组 data["city"] = city_list return records @@ -241,61 +323,22 @@ def _enrich_user_records(records): def _get_dept_map(): """获取部门ID到名称的映射字典""" - dept_list = Dept.query.filter(Dept.is_delete == 0).all() + dept_list = DeptRepository.find_list(order_by='sort') return {dept.id: dept.name for dept in dept_list} def _get_level_map(): """获取职级ID到名称的映射字典""" - level_list = Level.query.filter(Level.is_delete == 0).all() + level_list = LevelRepository.find_list(order_by='sort') return {level.id: level.name for level in level_list} def _get_position_map(): """获取岗位ID到名称的映射字典""" - position_list = Position.query.filter(Position.is_delete == 0).all() + position_list = PositionRepository.find_list(order_by='sort') return {position.id: position.name for position in position_list} -# ============================================================ -# 用户详情查询 -# ============================================================ - - -def get_user_detail(user_id): - """ - 根据ID查询用户详情(返回字典格式) - - 此方法调用 get_info 获取模型对象,然后转换为字典格式返回。 - 适用于前端接口调用。 - - Args: - user_id: 用户ID - - Returns: - dict/None: 用户详情字典,不存在时返回 None - """ - try: - # 复用 get_info 方法查询用户 - user = get_info(user_id) - if not user: - return None - # 转换为字典格式 - data = model_to_dict(user) - - # 移除密码字段 - if data: - data.pop("password", None) - - # 补充关联数据 - data = _enrich_user_detail(data) - return data - - except Exception as e: - logger.error(f"查询用户详情异常,ID: {user_id}, 错误: {str(e)}") - return None - - def _enrich_user_detail(data): """ 为用户详情补充关联数据 @@ -309,318 +352,298 @@ def _enrich_user_detail(data): if not data: return data - # 头像URL + # 头像URL转换 data["avatar"] = get_file_url(data.get("avatar")) + # 性别名称(走字典表,与分页一致) + data["genderText"] = dict_utils.get_name("gender", data.get("gender")) # 获取用户角色数据(复用 user_role 服务) role_list = user_role.get_user_role_list(data.get("id")) data["roles"] = [role["id"] for role in role_list] - # 行政区划 - city_list = [data.get("provinceCode"), data.get("cityCode"), data.get("districtCode"), data.get("streetCode")] + # 行政区划编码数组 + city_list = [ + data.get("provinceCode"), + data.get("cityCode"), + data.get("districtCode"), + data.get("streetCode") + ] data["city"] = city_list return data # ============================================================ -# 用户信息查询(根据ID)- 核心查询方法 +# Excel 导入 / 导出(复用 excel_utils 通用机制) # ============================================================ +# 导入列解析辅助:部门/职级/岗位按"名称"匹配为 ID,角色按"名称"(逗号分隔) +# 匹配为 ID 列表,均需在本模块 service 内先定义,供下方列定义引用。 -def get_info(user_id: int): +def _parse_gender(value): + """性别解析:'男'/'女'/'保密' -> 1/2/3(兼容数字)""" + text = str(value).strip() + if text in ("男", "1"): + return 1 + if text in ("女", "2"): + return 2 + if text in ("保密", "3"): + return 3 + raise ValueError("性别必须为'男'/'女'/'保密'") + + +def _parse_dept(value): + """部门名称 -> 部门ID(按名称匹配,不存在则行级报错)""" + text = str(value).strip() + if not text: + return 0 + obj = DeptRepository.first_by({"name": text}) + if not obj: + raise ValueError(f"部门'{text}'不存在") + return obj.id + + +def _parse_level(value): + """职级名称 -> 职级ID(按名称匹配,不存在则行级报错)""" + text = str(value).strip() + if not text: + return 0 + obj = LevelRepository.first_by({"name": text}) + if not obj: + raise ValueError(f"职级'{text}'不存在") + return obj.id + + +def _parse_position(value): + """岗位名称 -> 岗位ID(按名称匹配,不存在则行级报错)""" + text = str(value).strip() + if not text: + return 0 + obj = PositionRepository.first_by({"name": text}) + if not obj: + raise ValueError(f"岗位'{text}'不存在") + return obj.id + + +def _parse_roles(value): + """角色名称(逗号分隔)-> 角色ID列表(按名称匹配,不存在则行级报错)""" + if value is None: + return [] + text = str(value).strip() + if not text: + return [] + names = [n.strip() for n in text.replace(",", ",").split(",") if n.strip()] + role_ids = [] + for name in names: + role = RoleRepository.first_by({"name": name}) + if not role: + raise ValueError(f"角色'{name}'不存在") + role_ids.append(role.id) + return role_ids + + +def _format_roles(value): + """导出角色列表展示:角色字典列表 -> 中文名顿号拼接""" + if not value: + return "" + return "、".join(role["name"] for role in value if role.get("name")) + + +# 导入列定义:与导出的第 2 列起对齐(第 1 列为用户ID,跳过) +USER_IMPORT_COLUMNS = [ + {"title": "登录账号", "index": 2, "key": "username", "required": True}, + {"title": "用户姓名", "index": 3, "key": "realname", "required": True}, + {"title": "性别", "index": 4, "key": "gender", "parser": _parse_gender}, + {"title": "手机号", "index": 5, "key": "mobile", "required": True}, + {"title": "邮箱", "index": 6, "key": "email", "required": True}, + {"title": "部门名称", "index": 7, "key": "dept_id", "parser": _parse_dept}, + {"title": "职级名称", "index": 8, "key": "level_id", "parser": _parse_level}, + {"title": "岗位名称", "index": 9, "key": "position_id", "parser": _parse_position}, + {"title": "用户角色", "index": 10, "key": "roles", "parser": _parse_roles}, + {"title": "状态", "index": 11, "key": "status", "parser": excel_utils.parse_status}, + {"title": "排序", "index": 12, "key": "sort", "parser": excel_utils.parse_int}, + {"title": "详细地址", "index": 13, "key": "address", "required": True}, + {"title": "个人简介", "index": 14, "key": "intro"}, + {"title": "备注", "index": 15, "key": "note"}, + {"title": "初始密码", "index": 16, "key": "password"}, +] + +# 导出列定义:展示名称由 _enrich_user_records 富化,角色/状态做展示格式化 +USER_EXPORT_COLUMNS = [ + {"title": "用户编码", "key": "code"}, + {"title": "登录账号", "key": "username"}, + {"title": "用户姓名", "key": "realname"}, + {"title": "性别", "key": "genderName"}, + {"title": "手机号", "key": "mobile"}, + {"title": "邮箱", "key": "email"}, + {"title": "部门", "key": "deptName"}, + {"title": "职级", "key": "levelName"}, + {"title": "岗位", "key": "positionName"}, + {"title": "用户角色", "key": "roles", "formatter": _format_roles}, + {"title": "状态", "key": "status", "formatter": excel_utils.format_status}, + {"title": "详细地址", "key": "address"}, + {"title": "创建时间", "key": "createTime"}, +] + + +def import_users(): """ - 根据ID查询用户信息(返回模型对象) + 导入Excel文件批量创建用户 - 这是一个通用的用户查询方法,返回用户模型对象而非字典。 - 与 get_user_detail 的区别: - - get_user_detail: 返回字典格式,用于前端接口 - - get_info: 返回模型对象,用于内部服务调用 + 通过上传Excel文件批量导入用户数据,支持部门/职级/岗位按名称匹配、 + 角色按名称(逗号分隔)匹配,密码缺省使用默认密码,编码创建后按ID生成。 - 此方法被以下方法复用: - - get_user_detail: 获取详情字典 - - update_user: 验证用户是否存在 - - update_user_status: 验证用户是否存在 + Returns: + R: 导入结果,包含成功导入的数据条数 + """ + abs_path = None + try: + # 上传并解析 Excel(含行级错误收集,任一错误即整体失败) + abs_path = excel_utils.resolve_uploaded_excel() + rows, errors = excel_utils.parse_excel_rows(abs_path, USER_IMPORT_COLUMNS) + if errors: + raise BizError("导入失败:" + ";".join(errors[:20])) + + operator = get_username() + created = 0 + for row in rows: + username = row["username"] + # 唯一性校验(软删过滤,与表单校验保持一致,也拦截文件内重复账号) + if UserRepository.exists_by({"username": username}): + raise BizError(f"登录账号 {username} 已存在") + + salt = generate_salt() + pwd = row.get("password") or DEFAULT_PASSWORD + user = UserRepository.create( + username=username, + realname=row["realname"], + gender=row.get("gender") or 1, + avatar="", + mobile=row["mobile"], + email=row["email"], + dept_id=row.get("dept_id") or 0, + level_id=row.get("level_id") or 0, + position_id=row.get("position_id") or 0, + province_code="", + city_code="", + district_code="", + street_code="", + city_info=None, + address=row["address"], + password=encrypt_password(pwd, salt), + salt=salt, + intro=row.get("intro"), + status=row.get("status") or 1, + sort=row.get("sort") or 0, + note=row.get("note"), + create_user=operator, + update_user=operator, + ) + # 根据自增 ID 生成 6 位用户编码(与 UserService.add 保持一致) + user.code = f"{user.id:06d}" + # 关联角色(无角色则跳过) + if row.get("roles"): + UserRoleRepository.replace_roles(user.id, row["roles"]) + created += 1 + + return R.ok(msg=f"本次共导入【{created}】条数据") + except BizError as e: + # 上传失败(格式不支持等)或业务校验失败,透传具体错误 + return R.failed(msg=e.msg) + except Exception as e: + logger.error(f"导入用户异常: {str(e)}") + return R.failed(msg="导入用户失败,请稍后重试") + finally: + excel_utils.remove_file(abs_path) + + +def export_users(params: dict = None): + """ + 导出用户数据到Excel文件 + + 支持与分页查询一致的筛选参数(realname 模糊匹配、status 精确匹配), + 复用列表富化逻辑补充部门/职级/岗位/角色展示名称,生成Excel并返回下载URL。 Args: - user_id: 用户ID + params: 查询参数字典(GET query string),如 {"realname": "张", "status": 1} Returns: - User/None: 用户模型对象,不存在或已删除时返回 None - - Example: - user = get_info(user_id) - if user: - print(user.realname) + R: 导出结果,包含文件访问URL """ - return UserRepository.get_by_id(user_id) - - -def add_user(data: dict) -> None: - """ - 添加用户 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证(用户名唯一性、字段格式等) - 3. 处理头像、密码加密、行政区划 - 4. 创建用户对象并保存到数据库 - 5. 保存用户角色关联关系 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = UserForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 图片处理 - avatar = form.data.get("avatar") - if avatar: - form.avatar.data = save_file(avatar, "user") - - # 密码加密(未设置密码时使用默认密码) - pwd = form.data.get("password") or DEFAULT_PASSWORD - salt = password.generate_salt() - form.password.data = encrypt_password(pwd, salt) - form.salt.data = salt - - # 处理行政区划 - city = form.data.get("city", []) - if len(city) != 4: - raise BizError("请选择完整的所在城市(省/市/区/街道)") - form.provinceCode.data = city[0] - form.cityCode.data = city[1] - form.districtCode.data = city[2] - form.streetCode.data = city[3] - - # 用户角色 - roles = form.data.get("roles") - - # 移除不需要直接保存到User表的字段 - form_data = form.data.copy() - form_data.pop("city", None) - form_data.pop("roles", None) - - # 创建用户 - user = User(**form_data) - user.create_user = get_username() - user.save() - - # 创建用户角色数据 - _add_user_role(user.id, roles) - - # 清除该用户权限缓存 - invalidate_user_cache(user.id) + try: + params = params or {} + query_list = UserRepository.find_list( + filters={'status': params.get('status')}, + like_filters={'realname': params.get('realname')}, + order_desc='id', + ) + data = models_to_list(query_list) + _enrich_user_records(data) + headers = [col["title"] for col in USER_EXPORT_COLUMNS] + rows = excel_utils.build_export_rows(data, USER_EXPORT_COLUMNS) + file_url = excel_utils.save_export_file(headers, rows, filename_prefix="user") + return R.ok(data=file_url) + except Exception as e: + logger.error(f"导出用户异常: {str(e)}") + return R.failed(msg="导出失败,请稍后重试") # ============================================================ -# 用户更新 +# 个人信息更新(个人中心) # ============================================================ - -def update_user(data: dict) -> None: - """ - 更新用户 - - 流程: - 1. 解析请求体JSON数据 - 2. 表单验证 - 3. 验证用户是否存在(复用 get_info) - 4. 处理头像、密码加密、行政区划 - 5. 更新用户信息 - 6. 更新用户角色关联关系 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = UserForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - # 获取用户ID - user_id = form.data.get("id") - if not user_id or int(user_id) <= 0: - raise BizError("用户ID无效") - user_id = int(user_id) - - # 复用 get_info 方法验证用户是否存在 - user = get_info(user_id) - if not user: - raise BizError("用户不存在") - - # 图片处理 - avatar = form.data.get("avatar") - if avatar: - form.avatar.data = save_file(avatar, "user") - - # 密码加密(仅当有传入新密码时) - pwd = form.data.get("password") - if pwd: - salt = password.generate_salt() - form.password.data = encrypt_password(pwd, salt) - form.salt.data = salt - else: - # 密码不填时保持原密码值不做更新 - form_data = form.data.copy() - form_data.pop("password", None) - form_data.pop("salt", None) - - # 处理行政区划 - city = form.data.get("city", []) - if len(city) != 4: - raise BizError("请选择完整的所在城市(省/市/区/街道)") - form.provinceCode.data = city[0] - form.cityCode.data = city[1] - form.districtCode.data = city[2] - form.streetCode.data = city[3] - - # 用户角色 - roles = form.data.get("roles") - - # 移除不需要直接更新到User表的字段 - form_data = convert_camel_to_snake(form.data.copy()) - form_data.pop("id", None) - form_data.pop("city", None) - form_data.pop("roles", None) - - # 更新用户信息 - form_data["update_time"] = datetime.now(tz=timezone.utc) - form_data["update_user"] = get_username() - - result = User.query.filter_by(id=user_id).update(form_data) - - if not result: - raise BizError("更新失败") - - # 更新用户角色数据 - _add_user_role(user_id, roles) - - # 清除该用户权限缓存 - invalidate_user_cache(user_id) - - -def _add_user_role(user_id, roles): - """ - 创建/更新用户角色关系 - - Args: - user_id: 用户ID - roles: 角色ID列表,如 [1, 2, 3] - """ - - # 删除旧数据 - UserRole.query.filter(UserRole.user_id == user_id).delete() - - # 批量插入 - if roles: - db.session.bulk_insert_mappings(UserRole, [{"user_id": user_id, "role_id": role_id} for role_id in roles]) - - -# ============================================================ -# 用户删除 -# ============================================================ - - -def delete_users(user_ids: str) -> int: - """ - 删除用户(批量删除) - - 删除 - 支持逗号分隔的多个ID批量删除。 - - Args: - user_ids: 用户ID字符串,多个ID用逗号分隔,如 "1,2,3" - - Returns: - R: 操作结果 - """ - user_ids_list = parse_id_list(user_ids) - if not user_ids_list: - raise BizError("无效的记录ID") - return User.batch_delete(user_ids_list) - - -# ============================================================ -# 用户状态设置 -# ============================================================ - - -def update_user_status(data: dict) -> None: - """ - 设置用户状态 - - 单独更新用户的启用/禁用状态,不涉及其他字段。 - - Returns: - R: 操作结果 - """ - - # 表单验证 - form = UserStatusForm(data=data) - if not form.validate(): - raise BizError(regular.get_err(form)) - - user_id = int(form.data["id"]) - status = int(form.data["status"]) - - # 复用 get_info 方法验证用户是否存在 - user = get_info(user_id) - if not user: - raise BizError("用户不存在") - - # 更新状态 - update_data = {"status": status, "update_time": datetime.now(tz=timezone.utc), "update_user": get_username()} - result = User.query.filter_by(id=user_id).update(update_data) - - if not result: - raise BizError("设置失败") - - -# ============================================================ -# 用户信息更新(个人信息) -# ============================================================ - - -def update_user_info(data: dict) -> None: +def update_profile(data: dict) -> None: """ 更新当前用户信息 用于用户修改自己的个人信息,不涉及密码和角色。 + 支持更新字段:姓名、性别、头像、手机号、邮箱、行政区划、详细地址、个人简介、备注。 - Returns: - R: 操作结果 + Args: + data: 请求体数据字典 + + Raises: + BizError: 表单校验失败或用户不存在时抛出 """ - - # 表单验证 + # 使用 UserInfoForm 校验个人信息更新表单 form = UserInfoForm(data=data) if not form.validate(): + from src.common.utils import regular raise BizError(regular.get_err(form)) - # 用户ID(当前登录用户) + # 用户ID(从当前登录上下文中获取) user_id = get_user_id() # 复用 get_info 方法验证用户是否存在 - user = get_info(user_id) - if not user: + if not get_info(user_id): raise BizError("用户不存在") - # 更新用户信息 + # 处理头像:如果是 Base64/上传数据则保存文件,获取存储路径 + avatar = form.avatar.data + if avatar: + avatar = save_file(avatar, "user") + + # 校验行政区划数组长度必须为4(省/市/区/街道) + city = form.city.data or [] + if len(city) != 4: + raise BizError("请选择完整的所在城市(省/市/区/街道)") + + # 组装需要更新的字段(不包含密码、角色等敏感字段) update_data = { "realname": form.realname.data, "gender": form.gender.data, + "avatar": avatar, "mobile": form.mobile.data, "email": form.email.data, "address": form.address.data, "intro": form.intro.data, - "update_time": datetime.now(tz=timezone.utc), - "update_user": get_username(), + "note": form.note.data, } - result = User.query.filter_by(id=user_id).update(update_data) + # 将行政区划数组拆分到独立的编码字段 + _apply_city(update_data, city) + + # 执行更新(update_by_id 会自动补充 update_time/update_user) + result = UserRepository.update_by_id(user_id, update_data) if not result: raise BizError("更新失败") @@ -630,23 +653,25 @@ def update_user_info(data: dict) -> None: # 密码修改 # ============================================================ - def update_password(data: dict) -> None: """ 修改当前用户密码 需要验证原始密码正确性。 - Returns: - R: 操作结果 - """ + Args: + data: 请求体数据字典 - # 表单验证 + Raises: + BizError: 表单校验失败、用户不存在或原始密码错误时抛出 + """ + # 使用 UpdatePwdForm 校验密码修改表单 form = UpdatePwdForm(data=data) if not form.validate(): + from src.common.utils import regular raise BizError(regular.get_err(form)) - # 用户ID(当前登录用户) + # 用户ID(从当前登录上下文中获取) user_id = get_user_id() # 根据ID查询用户 @@ -654,20 +679,19 @@ def update_password(data: dict) -> None: if not user: raise BizError("用户不存在") - # 验证原始密码 + # 验证原始密码是否正确 if not verify_password(form.oldPassword.data, user.salt, user.password): raise BizError("原始密码不正确") - # 加密新密码 + # 加密新密码并生成新盐 new_salt = generate_salt() password = encrypt_password(form.newPassword.data, new_salt) update_data = { "password": password, "salt": new_salt, - "update_time": datetime.now(tz=timezone.utc), - "update_user": get_username(), } - result = User.query.filter_by(id=user_id).update(update_data) + # 执行更新 + result = UserRepository.update_by_id(user_id, update_data) if not result: raise BizError("更新失败") @@ -677,39 +701,39 @@ def update_password(data: dict) -> None: # 密码重置 # ============================================================ - def reset_user_password(data: dict) -> None: """ 重置用户密码 将指定用户的密码重置为默认密码(123456)。 - Returns: - R: 操作结果 - """ + Args: + data: 请求体数据字典 - # 表单验证 + Raises: + BizError: 表单校验失败或用户不存在时抛出 + """ + # 使用 ResetPwdForm 校验重置密码表单 form = ResetPwdForm(data=data) if not form.validate(): + from src.common.utils import regular raise BizError(regular.get_err(form)) user_id = form.userId.data # 复用 get_info 方法验证用户是否存在 - user = get_info(user_id) - if not user: + if not get_info(user_id): raise BizError("用户不存在") - # 加密新密码(默认密码123456) + # 将密码加密为默认密码 new_salt = generate_salt() password = encrypt_password(DEFAULT_PASSWORD, new_salt) update_data = { "password": password, "salt": new_salt, - "update_time": datetime.now(tz=timezone.utc), - "update_user": get_username(), } - result = User.query.filter_by(id=user_id).update(update_data) + # 执行更新 + result = UserRepository.update_by_id(user_id, update_data) if not result: raise BizError("重置密码失败") diff --git a/src/modules/upload/router.py b/src/modules/upload/router.py index 0ca95d4..775869e 100644 --- a/src/modules/upload/router.py +++ b/src/modules/upload/router.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -52,8 +52,9 @@ from flask import Blueprint -from src.core.middleware import check_demo +from src.middleware import check_demo, operation_log from src.modules.upload import service as upload +from src.core import response as R from config import UPLOAD_ALLOWED_EXTS upload_blue = Blueprint("upload", __name__, url_prefix="/upload") @@ -66,6 +67,7 @@ upload_blue = Blueprint("upload", __name__, url_prefix="/upload") @upload_blue.route("/uploadFile", methods=["POST"]) @check_demo +@operation_log("文件上传", "上传") def upload_file(): """ 上传文件 @@ -78,15 +80,15 @@ def upload_file(): file: 上传的文件(form-data格式,字段名为'file') Returns: - JSON: 上传结果 - - originalName: 原始文件名 - - fileExtension: 文件扩展名 - - fileType: 文件MIME类型 - - fileSize: 文件大小(字节) - - fileName: 生成的文件名 - - filePath: 文件相对路径 - - fileUrl: 文件访问URL + JSON: 上传结果(R 信封,data 为文件信息字典) + - data.originalName: 原始文件名 + - data.fileExtension: 文件扩展名 + - data.fileType: 文件MIME类型 + - data.fileSize: 文件大小(字节) + - data.fileName: 生成的文件名 + - data.filePath: 文件相对路径 + - data.fileUrl: 文件访问URL """ result = upload.upload_file(file_exts=UPLOAD_ALLOWED_EXTS) - return result + return R.ok(data=result) diff --git a/src/modules/upload/service.py b/src/modules/upload/service.py index 55f6cc8..a62441c 100644 --- a/src/modules/upload/service.py +++ b/src/modules/upload/service.py @@ -1,14 +1,14 @@ # +====================================================================== # | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] # +---------------------------------------------------------------------- -# | Copyright (c) 2021-2026 一米阳光团队 版权所有 +# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有 # +---------------------------------------------------------------------- # | 开源协议: Apache-2.0 # | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 # +---------------------------------------------------------------------- # | 官方网站: https://www.djangoadmin.cn # +---------------------------------------------------------------------- -# | 研发团队: 一米阳光团队 荣誉出品 +# | 研发团队: 小蚂蚁云团队 荣誉出品 # +====================================================================== # | 版权和免责声明: # | 一、知识产权声明 @@ -58,7 +58,7 @@ import mimetypes from flask import request from config import FLASK_TEMP_PATH, FLASK_UPLOAD_DIR, UPLOAD_ALLOWED_EXTS, UPLOAD_MAX_SIZE_MB -from src.core import response as R +from src.core.exceptions import BizError from src.common.utils.file import mkdir, verify_file_magic from src.common.utils.rich_text import get_file_url @@ -79,7 +79,7 @@ def upload_file(file_exts=UPLOAD_ALLOWED_EXTS): 默认使用 UPLOAD_ALLOWED_EXTS 全局配置 Returns: - dict: 包含文件信息的字典,格式为: + dict: 文件信息字典,格式为: { 'originalName': 原始文件名, 'fileExtension': 文件扩展名, @@ -89,13 +89,16 @@ def upload_file(file_exts=UPLOAD_ALLOWED_EXTS): 'filePath': 文件相对路径, 'fileUrl': 文件访问URL } + + Raises: + BizError: 上传失败(未选择文件、大小超限、格式不支持、魔术字节校验失败等) """ try: # 步骤1:获取上传的文件对象 file = request.files.get('file') if not file: logger.warning("未选择上传文件") - return R.failed(msg="请选择要上传的文件") + raise BizError("请选择要上传的文件") # 步骤2:获取文件基本信息 original_name = file.filename @@ -105,7 +108,7 @@ def upload_file(file_exts=UPLOAD_ALLOWED_EXTS): max_size_bytes = UPLOAD_MAX_SIZE_MB * 1024 * 1024 if file_size and file_size > max_size_bytes: logger.warning(f"文件大小超限: {file_size} bytes, 上限: {max_size_bytes} bytes") - return R.failed(msg=f"文件大小超过限制,最大允许 {UPLOAD_MAX_SIZE_MB}MB") + raise BizError(f"文件大小超过限制,最大允许 {UPLOAD_MAX_SIZE_MB}MB") # 步骤3:获取并处理文件扩展名 file_ext_with_dot = os.path.splitext(original_name)[1].lower() @@ -116,13 +119,13 @@ def upload_file(file_exts=UPLOAD_ALLOWED_EXTS): allowed_exts = _parse_allowed_extensions(file_exts) if allowed_exts and file_ext_with_dot not in allowed_exts: logger.warning(f"文件格式不支持,允许: {allowed_exts},实际: {file_ext_with_dot}") - return R.failed(msg=f"文件格式不支持,仅支持 {', '.join(allowed_exts)} 格式") + raise BizError(f"文件格式不支持,仅支持 {', '.join(allowed_exts)} 格式") # 步骤4.5:校验文件魔术字节,防止扩展名伪造 is_valid, magic_msg = verify_file_magic(file, file_ext_with_dot) if not is_valid: logger.warning(f"文件魔术字节校验失败: {magic_msg},文件名: {original_name}") - return R.failed(msg=magic_msg) + raise BizError(magic_msg) # 步骤5:获取文件的MIME类型 mime_type = _get_mime_type(original_name, file_ext_with_dot) @@ -164,11 +167,13 @@ def upload_file(file_exts=UPLOAD_ALLOWED_EXTS): 'fileUrl': file_url, } - return R.ok(msg="上传成功", data=result) + return result + except BizError: + raise except Exception as e: logger.error(f"文件上传异常: {str(e)}") - return R.failed(msg=f"上传失败: {str(e)}") + raise BizError(f"上传失败: {str(e)}") # ============================================================ diff --git a/src/routes/v1.py b/src/routes/v1.py index dd8c658..6ebefbc 100644 --- a/src/routes/v1.py +++ b/src/routes/v1.py @@ -20,11 +20,16 @@ from src.modules.city.router import city_blue from src.modules.cms.article.router import article_blue from src.modules.cms.category.router import category_blue from src.modules.cms.link.router import link_blue -from src.modules.configuration.router import config_blue, config_item_blue -from src.modules.dictionary.router import dict_blue, dict_item_blue +from src.modules.configuration.config.router import config_blue +from src.modules.configuration.config_item.router import config_item_blue +from src.modules.dictionary.dict.router import dict_blue +from src.modules.dictionary.dict_item.router import dict_item_blue from src.modules.file_template.router import file_template_blue +from src.modules.generator.router import generator_blue from src.modules.job.router import job_blue -from src.modules.log.router import login_log_blue, operation_log_blue +from src.modules.job_log.router import job_log_blue +from src.modules.login_log.router import login_log_blue +from src.modules.operation_log.router import operation_log_blue from src.modules.notice.router import notice_blue from src.modules.param.router import param_blue from src.modules.system.dept.router import dept_blue @@ -34,6 +39,7 @@ from src.modules.system.position.router import position_blue from src.modules.system.role.router import role_blue from src.modules.system.user.router import user_blue from src.modules.upload.router import upload_blue +from src.modules.example.router import example_blue # 全部业务蓝图(按领域分组排序) BLUEPRINTS = [ @@ -43,7 +49,8 @@ BLUEPRINTS = [ dict_blue, dict_item_blue, config_blue, config_item_blue, param_blue, notice_blue, category_blue, article_blue, link_blue, upload_blue, file_template_blue, login_log_blue, operation_log_blue, - city_blue, job_blue, + city_blue, generator_blue, job_blue, job_log_blue, + example_blue, ] diff --git a/static/favicon.ico b/static/favicon.ico index 797f492..5f4e189 100644 Binary files a/static/favicon.ico and b/static/favicon.ico differ diff --git a/static/image/logo.png b/static/image/logo.png new file mode 100644 index 0000000..951dcce Binary files /dev/null and b/static/image/logo.png differ diff --git a/tests/conftest.py b/tests/conftest.py index 3252b7a..2569de0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,3 +38,35 @@ def flask_app(): app.config['SECRET_KEY'] = 'test-secret-key' app.config['WTF_CSRF_ENABLED'] = False return app + + +@pytest.fixture +def app(): + """ + sqlite 内存库应用实例:供 Repository 单测使用。 + + - StaticPool 保证所有连接共享同一内存库(多线程/多次 init 下数据一致) + - 每测试独立建表/drop(query.update / bulk_insert 与 identity map 冲突, + 不宜采用事务回滚隔离) + - 测试内数据构造需显式 db.session.commit() + """ + from sqlalchemy.pool import StaticPool + + from src.core.database import db + import src.models_registry # noqa: F401 (注册全部表) + + flask_app = Flask(__name__) + flask_app.config.update( + TESTING=True, + SECRET_KEY='test-secret-key', + WTF_CSRF_ENABLED=False, + SQLALCHEMY_DATABASE_URI='sqlite://', + SQLALCHEMY_TRACK_MODIFICATIONS=False, + SQLALCHEMY_ENGINE_OPTIONS={'poolclass': StaticPool}, + ) + db.init_app(flask_app) + with flask_app.app_context(): + db.create_all() + yield flask_app + db.session.remove() + db.drop_all() diff --git a/tests/unit/test_common/test_base_repository.py b/tests/unit/test_common/test_base_repository.py new file mode 100644 index 0000000..277ada2 --- /dev/null +++ b/tests/unit/test_common/test_base_repository.py @@ -0,0 +1,392 @@ +""" +仓储基类单元测试(P0 基建) +============================================================= + +使用 sqlite 内存库(conftest.app 夹具)验证 BaseRepository 通用能力: +- find_page/find_list 的 like 通配符转义 +- 多字段排序(order_by + order_desc 单次合并,修复覆盖 bug) +- 软删除与恢复(batch_delete 后 get_by_id 不可见) +- update_by_id 自动补齐 update_time/update_user(显式值优先) +- first_by 等值单条查询 / exists / create +- calc_pages / build_page_data 分页结构 +""" + +from datetime import datetime + +from src.core.database import db +from src.common.constants.common import DEFAULT_PASSWORD +from src.core.password import encrypt_password, generate_salt +from src.modules.system.position.repository import PositionRepository +from src.modules.system.user.repository import UserRepository +from src.modules.system.role.repository import RoleRepository +from src.modules.dictionary.dict.repository import DictRepository +from src.modules.dictionary.dict_item.repository import DictItemRepository +from src.modules.association.user_role.repository import UserRoleRepository +from src.modules.operation_log.repository import OperationLogRepository +from src.common.utils.model_utils import calc_pages, build_page_data + + +def _make_position(app, name, sort=0, status=1, create_time=None): + """在测试库中构造一条岗位数据并提交""" + data = {'name': name, 'sort': sort, 'status': status} + if create_time is not None: + data['create_time'] = create_time + with app.app_context(): + position = PositionRepository.create(**data) + db.session.commit() + return position.id + + +def _names(app, items): + return [p.name for p in items] + + +class TestFindPageLike: + """like 模糊查询通配符转义""" + + def test_special_chars_escaped(self, app): + _make_position(app, '100%_x') + _make_position(app, '100_x') + _make_position(app, '经理100%') + _make_position(app, '普通') + + with app.app_context(): + items = PositionRepository.find_list(like_filters={'name': '100%_x'}) + + # 通配符被转义,仅匹配字面量 "100%_x" + assert _names(app, items) == ['100%_x'] + + +class TestEmptyFilterValues: + """空筛选值处理:None 或 '' 不参与筛选(修复前端传 '' 导致查不出数据)""" + + def test_empty_string_equality_ignored(self, app): + _make_position(app, '甲') + _make_position(app, '乙') + with app.app_context(): + items = PositionRepository.find_list(filters={'name': ''}) + assert len(items) == 2 + + def test_none_equality_ignored(self, app): + _make_position(app, '甲') + _make_position(app, '乙') + with app.app_context(): + items = PositionRepository.find_list(filters={'name': None}) + assert len(items) == 2 + + def test_zero_equality_still_filters(self, app): + _make_position(app, '启用', status=1) + _make_position(app, '禁用', status=0) + with app.app_context(): + items = PositionRepository.find_list(filters={'status': 0}) + assert len(items) == 1 + + def test_empty_string_like_ignored(self, app): + _make_position(app, '甲') + _make_position(app, '乙') + with app.app_context(): + items = PositionRepository.find_list(like_filters={'name': ''}) + assert len(items) == 2 + + +class TestMultiFieldOrder: + """多字段排序:order_by 与 order_desc 单次合并,不再相互覆盖""" + + def test_sort_then_id_desc(self, app): + _make_position(app, 'A', sort=2) + _make_position(app, 'B', sort=1) + _make_position(app, 'C', sort=1) + _make_position(app, 'D', sort=2) + + with app.app_context(): + items = PositionRepository.find_list(order_by=['sort'], order_desc=['id']) + + # sort 升序(1 组在前),组内 id 降序 + assert _names(app, items) == ['C', 'B', 'D', 'A'] + + +class TestSoftDelete: + """软删除与恢复""" + + def test_batch_delete_hides_records(self, app): + id1 = _make_position(app, '甲') + id2 = _make_position(app, '乙') + id3 = _make_position(app, '丙') + + with app.app_context(): + count = PositionRepository.batch_delete([id1, id2]) + assert count == 2 + assert PositionRepository.get_by_id(id1) is None + assert PositionRepository.get_by_id(id2) is None + assert PositionRepository.get_by_id(id3) is not None + assert len(PositionRepository.get_all()) == 1 + + def test_batch_restore_recovers(self, app): + id1 = _make_position(app, '甲') + id2 = _make_position(app, '乙') + + with app.app_context(): + PositionRepository.batch_delete([id1]) + restored = PositionRepository.batch_restore([id1]) + assert restored == 1 + assert PositionRepository.get_by_id(id1) is not None + assert PositionRepository.get_by_id(id2) is not None + + +class TestUpdate: + """更新能力""" + + def test_update_by_id_stamps_and_keeps_explicit(self, app): + pid = _make_position(app, '原名称') + + with app.app_context(): + affected = PositionRepository.update_by_id(pid, {'name': '新名称'}) + assert affected == 1 + db.session.expire_all() + position = PositionRepository.get_by_id(pid) + assert position.name == '新名称' + assert position.update_time is not None + + def test_update_by_id_explicit_values_preferred(self, app): + pid = _make_position(app, '原名称') + + with app.app_context(): + PositionRepository.update_by_id(pid, {'name': '显式更新', 'update_user': 'admin'}) + db.session.expire_all() + position = PositionRepository.get_by_id(pid) + assert position.name == '显式更新' + assert position.update_user == 'admin' + + def test_update_status(self, app): + pid = _make_position(app, '岗位') + + with app.app_context(): + affected = PositionRepository.update_status(pid, 0) + assert affected == 1 + db.session.expire_all() + assert PositionRepository.get_by_id(pid).status == 0 + + +class TestFirstByAndExists: + """等值单条查询与存在性检查""" + + def test_first_by(self, app): + _make_position(app, '唯一岗位') + with app.app_context(): + found = PositionRepository.first_by({'name': '唯一岗位'}) + assert found is not None + assert PositionRepository.first_by({'name': '不存在'}) is None + + def test_exists(self, app): + pid = _make_position(app, '存在性检查') + with app.app_context(): + assert PositionRepository.exists(pid) is True + assert PositionRepository.exists(99999) is False + + +class TestExistsBy: + """唯一性校验 exists_by(等值条件 + 更新时排除自身)""" + + def test_hit_and_miss(self, app): + _make_position(app, '唯一岗位') + with app.app_context(): + assert PositionRepository.exists_by({'name': '唯一岗位'}) is True + assert PositionRepository.exists_by({'name': '不存在的岗位'}) is False + + def test_exclude_id(self, app): + pid = _make_position(app, '同名岗位') + with app.app_context(): + # 更新场景:排除自身后不应命中 + assert PositionRepository.exists_by({'name': '同名岗位'}, exclude_id=pid) is False + + def test_ignores_soft_deleted(self, app): + pid = _make_position(app, '软删岗位') + with app.app_context(): + PositionRepository.batch_delete([pid]) + assert PositionRepository.exists_by({'name': '软删岗位'}) is False + + def test_compound_filters(self, app): + with app.app_context(): + dict_obj = DictRepository.create(name='性别', code='gender', sort=0) + db.session.commit() + dict_id = dict_obj.id + DictItemRepository.create(dict_id=dict_id, name='男', value='1', sort=1) + db.session.commit() + + assert DictItemRepository.exists_by({'dict_id': dict_id, 'value': '1'}) is True + assert DictItemRepository.exists_by({'dict_id': dict_id, 'value': '2'}) is False + + def test_empty_filters_false(self, app): + with app.app_context(): + assert PositionRepository.exists_by({}) is False + assert PositionRepository.exists_by({'not_a_column': 1}) is False + + +class TestCreate: + """创建能力""" + + def test_create_returns_object(self, app): + with app.app_context(): + position = PositionRepository.create(name='新建岗位', sort=5) + db.session.commit() + assert position.id is not None + assert PositionRepository.get_by_id(position.id).name == '新建岗位' + + def test_get_by_ids(self, app): + id1 = _make_position(app, '一') + id2 = _make_position(app, '二') + with app.app_context(): + records = PositionRepository.get_by_ids([id1, id2, 99999]) + assert len(records) == 2 + + +def _make_operation_log(app, title, create_time): + """在测试库中构造一条操作日志并提交(覆盖 create_time)""" + with app.app_context(): + log = OperationLogRepository.create( + title=title, + type=1, + source=0, + method='test', + request_method='GET', + url='/api/v1/test', + param='', + result='', + ip='127.0.0.1', + location='', + os='', + browser='', + consume_time=0, + status=0, + error='', + create_time=create_time, + ) + db.session.commit() + return log.id + + +class TestLogTimeRange: + """操作日志时间范围筛选(page 参数映射)""" + + def test_page_time_range_filter(self, app): + _make_operation_log(app, '一月日志', datetime(2026, 1, 15, 10, 0, 0)) + _make_operation_log(app, '二月日志', datetime(2026, 2, 15, 10, 0, 0)) + _make_operation_log(app, '三月日志', datetime(2026, 3, 15, 10, 0, 0)) + + with app.app_context(): + items, total, pages = OperationLogRepository.page({ + 'pageNo': 1, + 'pageSize': 10, + 'startTime': '2026-02-01 00:00:00', + 'endTime': '2026-02-28 23:59:59', + }) + + assert total == 1 + assert pages == 1 + assert items[0].title == '二月日志' + + def test_invalid_date_ignored(self, app): + _make_operation_log(app, '日志', datetime(2026, 1, 15, 10, 0, 0)) + + with app.app_context(): + items, total, _ = OperationLogRepository.page({ + 'pageNo': 1, + 'pageSize': 10, + 'startTime': 'not-a-date', + }) + + assert total == 1 + assert items[0].title == '日志' + + +class TestPageDataUtils: + """分页工具函数""" + + def test_calc_pages(self): + assert calc_pages(0, 10) == 0 + assert calc_pages(5, 10) == 1 + assert calc_pages(10, 10) == 1 + assert calc_pages(11, 10) == 2 + + def test_build_page_data(self): + data = build_page_data([], 0, 1, 10) + assert data == {'records': [], 'total': 0, 'size': 10, 'current': 1, 'pages': 0} + + def test_build_page_data_with_enrich(self, app): + _make_position(app, '富化') + with app.app_context(): + items = PositionRepository.find_list() + data = build_page_data( + items, len(items), 1, 10, enrich=lambda records: [ + {**r, 'extra': 'added'} for r in records + ] + ) + assert data['total'] == 1 + assert data['pages'] == 1 + assert data['records'][0]['extra'] == 'added' + + +def _make_user(app, username): + """在测试库中构造一条用户数据并提交(补齐 NOT NULL 字段)""" + salt = generate_salt() + with app.app_context(): + user = UserRepository.create( + username=username, + realname=username, + avatar='', + mobile='13800000000', + email='test@test.com', + province_code='', + city_code='', + district_code='', + street_code='', + address='', + password=encrypt_password(DEFAULT_PASSWORD, salt), + salt=salt, + ) + db.session.commit() + return user.id + + +def _make_role(app, name, code): + """在测试库中构造一条角色数据并提交""" + with app.app_context(): + role = RoleRepository.create(name=name, code=code) + db.session.commit() + return role.id + + +class TestReplaceRoles: + """用户角色关联 replace_roles 先删后增""" + + def test_replace_roles_delete_then_insert(self, app): + user_id = _make_user(app, 'test_user') + role1 = _make_role(app, '管理员', 'admin') + role2 = _make_role(app, '普通用户', 'common') + + with app.app_context(): + inserted = UserRoleRepository.replace_roles(user_id, [role1, role2]) + assert inserted == 2 + db.session.commit() + + # 二次替换:旧关联全部删除,仅保留 role2 + with app.app_context(): + inserted = UserRoleRepository.replace_roles(user_id, [role2]) + assert inserted == 1 + db.session.commit() + + role_ids = sorted(r.id for r in UserRoleRepository.get_roles_by_user(user_id)) + assert role_ids == [role2] + + def test_replace_roles_empty_clears_all(self, app): + user_id = _make_user(app, 'test_user2') + role1 = _make_role(app, '管理员', 'admin') + + with app.app_context(): + UserRoleRepository.replace_roles(user_id, [role1]) + db.session.commit() + assert len(UserRoleRepository.get_roles_by_user(user_id)) == 1 + + UserRoleRepository.replace_roles(user_id, []) + db.session.commit() + assert UserRoleRepository.get_roles_by_user(user_id) == [] diff --git a/tests/unit/test_common/test_base_service.py b/tests/unit/test_common/test_base_service.py new file mode 100644 index 0000000..19bf4a5 --- /dev/null +++ b/tests/unit/test_common/test_base_service.py @@ -0,0 +1,286 @@ +""" +服务基类单元测试(P1 服务层骨架) +============================================================= + +验证 BaseService 通用 CRUD 骨架: + - 模板方法流程:add / update / remove / update_status / get_page / get_detail / get_list + - 表单校验失败 raise BizError + - 存在性校验(update / update_status 对不存在记录 raise BizError) + - 覆写钩子(before_add / after_add / before_delete / after_update)按序触发 + - 模块级代理函数形态(保持 router 零改动) + +DB 相关用例使用 conftest.app(sqlite 内存库), +表单校验用例使用 conftest.flask_app(最小应用上下文)。 +""" + +import pytest + +from src.common.base_service import BaseService +from src.common.utils.model_utils import build_page_data +from src.core.exceptions import BizError +from src.modules.cms.category.repository import CategoryRepository +from src.modules.cms.category.service import CategoryService +from src.modules.dictionary.dict_item.repository import DictItemRepository +from src.modules.dictionary.dict.repository import DictRepository +from src.modules.dictionary.dict_item.service import DictItemService +from src.modules.system.position.repository import PositionRepository +from src.modules.system.position.schema import PositionForm, PositionStatusForm + + +def _make_position(app, name, sort=0, status=1): + """在测试库中构造一条岗位数据并提交""" + from src.core.database import db + with app.app_context(): + pid = PositionRepository.create(name=name, sort=sort, status=status).id + db.session.commit() + return pid + + +class DummyService(BaseService): + """覆写全部钩子用于断言触发顺序的测试子类""" + repository = PositionRepository + form = PositionForm + status_form = PositionStatusForm + model_name = "岗位" + events = [] + + @classmethod + def before_add(cls, form): + cls.events.append("before_add") + + @classmethod + def after_add(cls, obj): + cls.events.append("after_add") + + @classmethod + def before_update(cls, form, obj): + cls.events.append("before_update") + + @classmethod + def after_update(cls, obj): + cls.events.append("after_update") + + @classmethod + def before_delete(cls, ids): + cls.events.append("before_delete") + + @classmethod + def after_delete(cls, count): + cls.events.append("after_delete") + + +@pytest.fixture(autouse=True) +def _reset_events(): + DummyService.events = [] + yield + + +class TestGetPage: + """分页查询""" + + def test_shape_and_records(self, app): + _make_position(app, "岗位甲") + + with app.app_context(): + data = DummyService.get_page({"pageNo": 1, "pageSize": 10, "name": "岗位"}) + + assert data["total"] == 1 + assert data["size"] == 10 + assert data["current"] == 1 + assert data["records"][0]["name"] == "岗位甲" + + def test_empty_page(self, app): + with app.app_context(): + data = DummyService.get_page({"pageNo": 1, "pageSize": 10}) + assert data == {"records": [], "total": 0, "size": 10, "current": 1, "pages": 0} + + +class TestGetDetail: + """详情查询""" + + def test_detail_returns_dict(self, app): + pid = _make_position(app, "详情岗位") + with app.app_context(): + detail = DummyService.get_detail(pid) + assert detail is not None + assert detail["id"] == pid + assert detail["name"] == "详情岗位" + + def test_detail_missing_returns_none(self, app): + with app.app_context(): + assert DummyService.get_detail(99999) is None + + +class TestGetList: + """无分页列表""" + + def test_list_returns_dicts(self, app): + _make_position(app, "列表一") + _make_position(app, "列表二") + with app.app_context(): + items = DummyService.get_list() + assert [it["name"] for it in items] == ["列表一", "列表二"] + + +class TestAdd: + """新增""" + + def test_form_invalid_raises(self, flask_app): + with flask_app.app_context(): + with pytest.raises(BizError, match="岗位名称不能为空"): + DummyService.add({}) + + def test_add_creates_and_hooks_run(self, app): + from src.core.database import db + with app.app_context(): + DummyService.add({"name": "新增岗位", "status": 1, "sort": 3}) + db.session.commit() + + position = PositionRepository.first_by({"name": "新增岗位"}) + assert position is not None + assert position.status == 1 + + assert DummyService.events == ["before_add", "after_add"] + + +class TestUpdate: + """更新""" + + def test_nonexistent_raises(self, app): + # 表单唯一性校验与存在性校验均查库,需 sqlite 上下文 + with app.app_context(): + with pytest.raises(BizError, match="岗位不存在"): + DummyService.update({"id": 99999, "name": "不存在", "status": 1}) + + def test_update_changes_and_hooks_run(self, app): + from src.core.database import db + pid = _make_position(app, "原名称") + + with app.app_context(): + DummyService.update({"id": pid, "name": "新名称", "status": 1, "sort": 1}) + db.session.commit() + db.session.expire_all() + position = PositionRepository.get_by_id(pid) + + assert position.name == "新名称" + assert DummyService.events == ["before_update", "after_update"] + + +class TestRemove: + """批量删除""" + + def test_invalid_ids_raises(self): + with pytest.raises(BizError, match="无效的记录ID"): + DummyService.remove("abc") + + def test_remove_calls_repo_and_hooks(self, app, monkeypatch): + pid = _make_position(app, "待删除") + captured = {} + + def fake_batch_delete(cls, ids): + captured["ids"] = ids + return len(ids) + + monkeypatch.setattr(PositionRepository, "batch_delete", classmethod(fake_batch_delete)) + + with app.app_context(): + count = DummyService.remove(str(pid)) + + assert count == 1 + assert captured["ids"] == [pid] + assert DummyService.events == ["before_delete", "after_delete"] + + +class TestUpdateStatus: + """状态设置""" + + def test_nonexistent_raises(self, app): + # 存在性校验查库,需 sqlite 上下文 + with app.app_context(): + with pytest.raises(BizError, match="岗位不存在"): + DummyService.update_status({"id": 99999, "status": 2}) + + def test_status_updated(self, app): + pid = _make_position(app, "状态岗位", status=1) + from src.core.database import db + with app.app_context(): + DummyService.update_status({"id": pid, "status": 2}) + db.session.commit() + db.session.expire_all() + assert PositionRepository.get_by_id(pid).status == 2 + + +class TestProxyShape: + """模块级代理函数形态:BaseService 方法可直接绑定为模块函数供 router 调用""" + + def test_method_binds_to_module_level(self): + assert callable(DummyService.get_page) + assert callable(DummyService.add) + assert callable(DummyService.remove) + # 绑定后调用签名与现有 service 模块级函数一致 + assert build_page_data([], 0, 1, 10)["pages"] == 0 + + +class TestBeforeAddExtraFields: + """before_add 校验父记录存在性""" + + def test_dict_item_add_validates_dict_exists(self, app): + from src.core.database import db + with app.app_context(): + dict_obj = DictRepository.create(name="性别", code="gender", sort=0) + db.session.commit() + dict_id = dict_obj.id + + with app.app_context(): + DictItemService.add({"dictId": dict_id, "name": "男", "value": "1", "sort": 1}) + db.session.commit() + item = DictItemRepository.first_by({"dict_id": dict_id}) + + assert item is not None + assert item.name == "男" + + def test_dict_item_add_missing_dict_raises(self, app): + with app.app_context(): + with pytest.raises(BizError, match="字典不存在"): + DictItemService.add({"dictId": 99999, "name": "男", "value": "1"}) + + +class TestMigratedModuleHooks: + """真实迁移模块的钩子端到端接线(before_delete 子级校验)""" + + def _make_category(self, app, name, parent_id=0, sort=0, type_=1): + from src.core.database import db + with app.app_context(): + cid = CategoryRepository.create( + category_name=name, category_code=f"code_{name}", + parent_id=parent_id, sort=sort, type=type_, + ).id + db.session.commit() + return cid + + def test_category_delete_with_children_rejected(self, app): + parent_id = self._make_category(app, "总公司") + self._make_category(app, "技术部", parent_id=parent_id) + + with app.app_context(): + with pytest.raises(BizError, match="存在子分类"): + CategoryService.remove(str(parent_id)) + + def test_category_delete_leaf_succeeds(self, app): + parent_id = self._make_category(app, "总公司") + child_id = self._make_category(app, "技术部", parent_id=parent_id) + + with app.app_context(): + count = CategoryService.remove(str(child_id)) + assert count == 1 + + def test_category_add_defaults_type(self, app): + """前端不传 type → 模型 default=1 兜底(原实现 NOT NULL 必崩)""" + from src.core.database import db + with app.app_context(): + CategoryService.add({"categoryName": "默认类型分类", "categoryCode": "default_type"}) + db.session.commit() + cat = CategoryRepository.first_by({"category_name": "默认类型分类"}) + + assert cat is not None + assert cat.type == 1 diff --git a/tests/unit/test_common/test_database.py b/tests/unit/test_common/test_database.py new file mode 100644 index 0000000..edc2e2d --- /dev/null +++ b/tests/unit/test_common/test_database.py @@ -0,0 +1,201 @@ +""" +数据库 URL 构建单元测试(D1 多库支持) +============================================================= + +验证 _build_database_url() 对 mysql / postgresql / mssql / sqlite / oracle +五类驱动的 URL 构建正确,且 sqlite 无 host/port/账号分支。 +""" + +import config.database as db_mod +from src.common.constants.common import DEFAULT_PASSWORD + + +def _url(monkeypatch, driver, **overrides): + defaults = { + 'DB_DRIVER': driver, + 'DB_HOST': '127.0.0.1', + 'DB_PORT': 1, + 'DB_DATABASE': 'appdb', + 'DB_USERNAME': 'user', + 'DB_PASSWORD': 'pass', + 'DB_SSL': False, + } + defaults.update(overrides) + for key, value in defaults.items(): + monkeypatch.setattr(db_mod, key, value) + return db_mod._build_database_url() + + +class TestBuildDatabaseUrl: + def test_mysql_default(self, monkeypatch): + assert _url(monkeypatch, 'mysql') == 'mysql+pymysql://user:pass@127.0.0.1:1/appdb?charset=utf8mb4' + + def test_postgresql(self, monkeypatch): + assert _url(monkeypatch, 'postgresql') == 'postgresql+psycopg2://user:pass@127.0.0.1:1/appdb' + + def test_pgsql_alias(self, monkeypatch): + assert _url(monkeypatch, 'pgsql') == 'postgresql+psycopg2://user:pass@127.0.0.1:1/appdb' + + def test_mssql(self, monkeypatch): + assert _url(monkeypatch, 'mssql') == 'mssql+pymssql://user:pass@127.0.0.1:1/appdb' + + def test_sqlite_file_path(self, monkeypatch): + # Windows 反斜杠路径应规范化为正斜杠,且无 host/账号 + assert _url(monkeypatch, 'sqlite', DB_DATABASE=r'D:\data\app.db') == 'sqlite:///D:/data/app.db' + + def test_sqlite_memory(self, monkeypatch): + assert _url(monkeypatch, 'sqlite', DB_DATABASE=':memory:') == 'sqlite://' + + def test_oracle_service_name(self, monkeypatch): + # Oracle 无"数据库名",DB_DATABASE 即 SERVICE_NAME,走 ?service_name= 查询参数 + assert _url(monkeypatch, 'oracle') == 'oracle+oracledb://user:pass@127.0.0.1:1/?service_name=appdb' + + def test_unknown_driver_falls_back_mysql(self, monkeypatch): + assert _url(monkeypatch, 'db2') == 'mysql+pymysql://user:pass@127.0.0.1:1/appdb?charset=utf8mb4' + + def test_driver_default_ports(self): + # DB_PORT 未显式配置时按驱动回退(_DEFAULT_PORT 表) + assert db_mod._DEFAULT_PORT['oracle'] == 1521 + assert db_mod._DEFAULT_PORT['mysql'] == 3306 + assert db_mod._DEFAULT_PORT['postgresql'] == 5432 + assert db_mod._DEFAULT_PORT['pgsql'] == 5432 + assert db_mod._DEFAULT_PORT['mssql'] == 1433 + assert db_mod._DEFAULT_PORT['sqlite'] == 0 + + +class TestSeedData: + """种子数据(D3):幂等写入 admin/角色/菜单""" + + def test_seed_creates_admin_role_menu(self, app): + from scripts.seed_data import seed_all + from src.modules.system.user.repository import UserRepository + from src.modules.system.role.repository import RoleRepository + from src.modules.system.menu.repository import MenuRepository + + with app.app_context(): + seed_all() + admin = UserRepository.first_by({'username': 'admin'}) + assert admin is not None + assert admin.password != DEFAULT_PASSWORD # 已加密 + assert admin.salt + role = RoleRepository.first_by({'code': 'admin'}) + assert role is not None + assert MenuRepository.first_by({'name': '系统管理', 'parent_id': 0}) is not None + + def test_seed_is_idempotent(self, app): + from scripts.seed_data import seed_all + from src.modules.system.user.repository import UserRepository + + with app.app_context(): + seed_all() + seed_all() + admins = UserRepository.find_list(filters={'username': 'admin'}) + assert len(admins) == 1 + + +class TestNormalizeDriver: + """驱动别名归一化(_normalize_driver,与 migrate_db.py 共用)""" + + def test_canonical(self): + from config.database import _normalize_driver + assert _normalize_driver('mysql') == 'mysql' + assert _normalize_driver('postgresql') == 'postgresql' + assert _normalize_driver('mssql') == 'mssql' + assert _normalize_driver('sqlite') == 'sqlite' + assert _normalize_driver('oracle') == 'oracle' + + def test_aliases(self): + from config.database import _normalize_driver + assert _normalize_driver('postgres') == 'postgresql' + assert _normalize_driver('pgsql') == 'postgresql' + assert _normalize_driver('sqlserver') == 'mssql' + assert _normalize_driver('sqlite3') == 'sqlite' + + def test_upper_case(self): + from config.database import _normalize_driver + assert _normalize_driver('PostgreSQL') == 'postgresql' + + +class TestSqliteUrl: + """SQLite 连接 URL 构建(_sqlite_url,与 migrate_db.py 共用)""" + + def test_memory(self): + from config.database import _sqlite_url + assert _sqlite_url(':memory:') == 'sqlite://' + + def test_windows_path(self): + from config.database import _sqlite_url + assert _sqlite_url(r'D:\data\app.db') == 'sqlite:///D:/data/app.db' + + +class TestMigrateDbUrl: + """跨库迁移脚本 migrate_db.py 的 URL 构建(显式参数版,M1)""" + + def test_make_url_mysql(self): + from scripts.migrate_db import make_url + assert make_url('mysql', '10.0.0.1', 3306, 'src_db', 'root', 'pwd') \ + == 'mysql+pymysql://root:pwd@10.0.0.1:3306/src_db?charset=utf8mb4' + + def test_make_url_postgresql(self): + from scripts.migrate_db import make_url + assert make_url('postgresql', '10.0.0.1', 5432, 'dst_db', 'pg', 'pwd') \ + == 'postgresql+psycopg2://pg:pwd@10.0.0.1:5432/dst_db' + + def test_make_url_oracle_service_name(self): + from scripts.migrate_db import make_url + assert make_url('oracle', '10.0.0.1', 1521, 'XEPDB1', 'system', 'pwd') \ + == 'oracle+oracledb://system:pwd@10.0.0.1:1521/?service_name=XEPDB1' + + def test_make_url_mssql(self): + from scripts.migrate_db import make_url + assert make_url('mssql', '10.0.0.1', 1433, 'dst_db', 'sa', 'pwd') \ + == 'mssql+pymssql://sa:pwd@10.0.0.1:1433/dst_db?charset=utf8' + + def test_make_url_sqlite_path(self): + from scripts.migrate_db import make_url + assert make_url('sqlite', '', None, r'D:\data\app.db', '', '') == 'sqlite:///D:/data/app.db' + + def test_make_url_driver_alias(self): + from scripts.migrate_db import make_url + # 别名:postgres→postgresql、sqlserver→mssql + assert make_url('postgres', '10.0.0.1', 5432, 'dst_db', 'pg', 'pwd') \ + == 'postgresql+psycopg2://pg:pwd@10.0.0.1:5432/dst_db' + assert make_url('sqlserver', '10.0.0.1', 1433, 'dst_db', 'sa', 'pwd') \ + == 'mssql+pymssql://sa:pwd@10.0.0.1:1433/dst_db?charset=utf8' + + +class TestFriendlyDbError: + """数据库连接异常友好提示(翻译认证失败/库不存在/拒连)""" + + def test_auth_failed_gbk(self): + from config import friendly_db_error + gbk = '用户 "postgres" Password 认证失败'.encode('gbk') + e = UnicodeDecodeError('utf-8', gbk, 0, len(gbk), 'invalid continuation byte') + assert '认证失败' in friendly_db_error(e) + assert 'DB_PASSWORD' in friendly_db_error(e) + + def test_db_not_exist(self): + from config import friendly_db_error + assert '数据库不存在' in friendly_db_error(Exception('database "x" does not exist')) + + def test_connection_refused(self): + from config import friendly_db_error + assert '连接被拒绝' in friendly_db_error(Exception('Connection refused')) + + def test_oracle_auth_failed(self): + from config import friendly_db_error + assert '认证失败' in friendly_db_error(Exception('ORA-01017: invalid username/password; logon denied')) + + def test_oracle_service_name_error(self): + from config import friendly_db_error + msg = friendly_db_error(Exception('ORA-12514: TNS:listener does not currently know of service requested')) + assert '服务名' in msg + assert 'SERVICE_NAME' in msg + + def test_oracle_connection_refused(self): + from config import friendly_db_error + assert '连接被拒绝' in friendly_db_error(Exception('ORA-12170: TNS:Connect timeout occurred')) + + def test_unknown_error(self): + from config import friendly_db_error + assert 'boom' in friendly_db_error(Exception('boom')) diff --git a/tests/unit/test_common/test_excel_utils.py b/tests/unit/test_common/test_excel_utils.py new file mode 100644 index 0000000..c167cef --- /dev/null +++ b/tests/unit/test_common/test_excel_utils.py @@ -0,0 +1,145 @@ +""" +共享 Excel 工具单元测试(excel_utils) +============================================================= + +验证通用 Excel 导入/导出机制(供职级/用户等模块复用): + - parse_excel_rows:按列定义读取,必填/解析错误逐行收集,空行跳过 + - parse_int / parse_status / format_status:单元格解析与状态展示格式化 + - build_export_rows:按列 key 取值并应用 formatter + - save_export_file:写工作簿、生成访问 URL(路径通过 monkeypatch 指向临时目录) + +不依赖具体业务模块,列定义在用例内自构造。 +""" + +import pytest +from openpyxl.reader.excel import load_workbook +from openpyxl.workbook import Workbook + +from src.common.utils import excel_utils + + +# 用例自构造的列定义(结构与 level/user 模块一致) +COLUMNS = [ + {"title": "名称", "index": 1, "key": "name", "required": True}, + {"title": "排序", "index": 2, "key": "sort", "parser": excel_utils.parse_int}, + {"title": "状态", "index": 3, "key": "status", "parser": excel_utils.parse_status}, +] + + +class TestParseStatus: + """状态单元格解析:中文 / 数字 -> 1/2,非法值报错""" + + def test_normal_and_disabled(self): + assert excel_utils.parse_status("正常") == 1 + assert excel_utils.parse_status("禁用") == 2 + assert excel_utils.parse_status("1") == 1 + assert excel_utils.parse_status(2) == 2 + + def test_invalid_raises(self): + with pytest.raises(ValueError): + excel_utils.parse_status("未知") + + +class TestParseInt: + """整数单元格解析""" + + def test_valid(self): + assert excel_utils.parse_int("10") == 10 + assert excel_utils.parse_int(3) == 3 + + def test_invalid_raises(self): + with pytest.raises(ValueError): + excel_utils.parse_int("abc") + + +class TestFormatStatus: + """状态展示格式化:1/2 -> 正常/禁用,未知值原样返回""" + + def test_mapping(self): + assert excel_utils.format_status(1) == "正常" + assert excel_utils.format_status(2) == "禁用" + + def test_unknown_passthrough(self): + assert excel_utils.format_status(0) == 0 + + +class TestBuildExportRows: + """导出行构建:按列 key 取值 + formatter 展示格式化""" + + def test_formatter_applied_and_key_lookup(self): + records = [ + {"name": "初级", "status": 1, "sort": 2}, + {"name": "禁用级", "status": 2, "sort": 1}, + ] + columns = [ + {"title": "名称", "key": "name"}, + {"title": "排序", "key": "sort"}, + {"title": "状态", "key": "status", "formatter": excel_utils.format_status}, + ] + assert excel_utils.build_export_rows(records, columns) == [ + ["初级", 2, "正常"], + ["禁用级", 1, "禁用"], + ] + + def test_missing_key_becomes_none(self): + columns = [{"title": "缺失键", "key": "nope"}] + assert excel_utils.build_export_rows([{"name": "x"}], columns) == [[None]] + + +class TestParseExcelRows: + """导入行解析:必填校验 / parser 错误 / 空行跳过 / 可选空单元格""" + + def _make_wb(self, path): + wb = Workbook() + ws = wb.worksheets[0] + ws.append(["名称", "排序", "状态"]) # 表头(start_row=2 跳过) + ws.append(["初级", 1, "正常"]) # 合法 + ws.append(["坏排序", "abc", "正常"]) # 排序解析失败 + ws.append(["缺状态", 2, ""]) # 可选空单元格 -> None + ws.append([None, None, None]) # 整行为空 -> 跳过 + ws.append(["", 3, "禁用"]) # 必填缺失 + wb.save(path) + + def test_rows_and_errors(self, tmp_path): + path = tmp_path / "in.xlsx" + self._make_wb(path) + + rows, errors = excel_utils.parse_excel_rows(str(path), COLUMNS) + + # 合法行 + 可选空单元格行保留;错误行 / 空行被剔除 + assert rows == [ + {"name": "初级", "sort": 1, "status": 1}, + {"name": "缺状态", "sort": 2, "status": None}, + ] + # 行级错误带行号与原因 + assert any("第3行 排序:" in e for e in errors) + assert any("第6行 名称不能为空" in e for e in errors) + + +class TestSaveExportFile: + """导出文件落盘与 URL 生成""" + + def test_writes_file_and_returns_url(self, tmp_path, monkeypatch): + monkeypatch.setattr(excel_utils, "FLASK_TEMP_PATH", str(tmp_path)) + monkeypatch.setattr(excel_utils, "get_file_url", lambda url: "/static" + url) + + headers = ["名称", "状态"] + rows = [["初级", "正常"], ["高级", "禁用"]] + url = excel_utils.save_export_file(headers, rows, filename_prefix="level") + + # URL 由 get_file_url 拼接(返回约定前缀 + 路径) + assert url.startswith("/static") + + # 文件真实落盘到临时目录,表头与数据一致 + import glob + + saved = glob.glob(str(tmp_path) + "/**/*.xlsx", recursive=True) + assert len(saved) == 1 + wb = load_workbook(saved[0]) + ws = wb.worksheets[0] + assert list(ws.iter_rows(values_only=True)) == [ + ("名称", "状态"), + ("初级", "正常"), + ("高级", "禁用"), + ] + wb.close() diff --git a/tests/unit/test_common/test_rich_text.py b/tests/unit/test_common/test_rich_text.py new file mode 100644 index 0000000..4c5d954 --- /dev/null +++ b/tests/unit/test_common/test_rich_text.py @@ -0,0 +1,47 @@ +""" +save_file 安全修复测试 +============================================================= + +验证临时文件迁移的防护与正式路径保留: + - FLASK_FILE_URL 为空时拒绝外链(原 find('')==0 恒真,外链被当本地文件) + - URL 前缀不匹配时拒绝 + - 路径穿越(.. 逃逸临时目录)拒绝 + - 正式路径(不含 /temp)原样保留:相对路径(如 /images/notice/...)编辑回传 + 不被清空,带前缀的绝对地址只去前缀,外链绝对地址仍拒绝 +""" + +from src.common.utils import rich_text + + +def test_external_url_rejected_when_prefix_empty(monkeypatch): + monkeypatch.setattr(rich_text, 'FLASK_FILE_URL', '') + assert rich_text.save_file('http://evil.com/x.jpg', 'user') == "" + + +def test_external_url_nonmatching_prefix_rejected(monkeypatch): + monkeypatch.setattr(rich_text, 'FLASK_FILE_URL', 'http://img.site') + assert rich_text.save_file('http://evil.com/x.jpg', 'user') == "" + + +def test_path_traversal_rejected(monkeypatch): + monkeypatch.setattr(rich_text, 'FLASK_FILE_URL', 'http://img.site') + # realpath 规范化后逃逸临时目录 → 拒绝 + assert rich_text.save_file('http://img.site/temp/../../../../../../etc/passwd', 'user') == "" + + +def test_non_temp_url_returns_stripped_path(monkeypatch): + monkeypatch.setattr(rich_text, 'FLASK_FILE_URL', 'http://img.site') + # 已是正式路径(无 /temp)→ 去掉前缀返回,不迁移 + assert rich_text.save_file('http://img.site/user/avatar.jpg', 'user') == "/user/avatar.jpg" + + +def test_non_temp_relative_path_kept_when_prefix_mismatch(monkeypatch): + monkeypatch.setattr(rich_text, 'FLASK_FILE_URL', 'http://img.site') + # 编辑回传已保存的相对路径(如通知封面),前缀不匹配也应原样保留,不置空 + assert rich_text.save_file('/images/notice/20260314/x.png', 'notice') == "/images/notice/20260314/x.png" + + +def test_non_temp_relative_path_kept_when_prefix_empty(monkeypatch): + monkeypatch.setattr(rich_text, 'FLASK_FILE_URL', '') + # 未配置文件域名时,相对路径同样原样保留 + assert rich_text.save_file('/notice/20260314/x.png', 'notice') == "/notice/20260314/x.png" diff --git a/tests/unit/test_core/test_jwt_sliding_session.py b/tests/unit/test_core/test_jwt_sliding_session.py new file mode 100644 index 0000000..a738949 --- /dev/null +++ b/tests/unit/test_core/test_jwt_sliding_session.py @@ -0,0 +1,106 @@ +""" +JWT 滑动会话单元测试 +============================================================= + +验证: + - create_session:登录后写入 Redis 会话键,TTL = 空闲超时 + - touch_session:活跃请求重置会话 TTL(返回 True) + - touch_session:空闲超时后会话键缺失 → 返回 False(需重新登录) + - touch_session:Redis 异常时 fail-closed(返回 False) + - 会话键基于 Token SHA256 指纹,不落完整 Token + +测试不依赖真实 Redis,通过 monkeypatch 注入内存假 Redis。 +""" + +import pytest + +from src.core import jwt as jwt_module +from src.core import redis_client as redis_client_module + + +class FakeRedis: + """内存版 Redis 桩:仅支持滑动会话用到的 set/expire""" + + def __init__(self): + self.store = {} + self.ttls = {} + + def set(self, key, value, expire=None): + self.store[key] = value + if expire is not None: + self.ttls[key] = expire + return True + + def expire(self, key, seconds): + if key not in self.store: + return False + self.ttls[key] = seconds + return True + + +class FailingRedis: + """模拟 Redis 故障:expire 抛异常""" + + def set(self, key, value, expire=None): + raise ConnectionError("redis down") + + def expire(self, key, seconds): + raise ConnectionError("redis down") + + +@pytest.fixture +def fake_redis(monkeypatch): + fake = FakeRedis() + monkeypatch.setattr(redis_client_module, "redis", fake) + return fake + + +def test_session_key_is_fingerprint_based(): + """会话键基于 Token 指纹,不含完整 Token""" + key = jwt_module._session_key("a" * 200) + assert key.startswith("jwt:session:") + assert len(key) > len("jwt:session:") # 含 SHA256 摘要 + assert "a" * 200 not in key # 未落完整 Token + + +def test_create_session_sets_ttl(fake_redis): + token = "some-jwt-token" + assert jwt_module.create_session(token) is True + key = jwt_module._session_key(token) + assert fake_redis.store[key] == "1" + assert fake_redis.ttls[key] == jwt_module.SESSION_IDLE_MINUTES * 60 + + +def test_create_session_custom_idle(fake_redis): + token = "custom-idle-token" + assert jwt_module.create_session(token, idle_minutes=5) is True + key = jwt_module._session_key(token) + assert fake_redis.ttls[key] == 5 * 60 + + +def test_touch_session_resets_ttl(fake_redis): + token = "touch-token" + assert jwt_module.create_session(token, idle_minutes=5) is True + + # 活跃请求续期:TTL 重置为完整空闲超时 + assert jwt_module.touch_session(token) is True + key = jwt_module._session_key(token) + assert fake_redis.ttls[key] == jwt_module.SESSION_IDLE_MINUTES * 60 + + +def test_touch_session_missing_key_returns_false(fake_redis): + """空闲超时后会话键已消失 → touch_session 返回 False(需重新登录)""" + assert jwt_module.touch_session("never-logged-in-token") is False + assert fake_redis.store == {} + + +def test_touch_session_fail_closed_on_redis_error(monkeypatch): + """Redis 故障时 touch_session 返回 False(fail-closed)""" + monkeypatch.setattr(redis_client_module, "redis", FailingRedis()) + assert jwt_module.touch_session("any-token") is False + + +def test_create_session_fail_closed_on_redis_error(monkeypatch): + """Redis 故障时 create_session 返回 False""" + monkeypatch.setattr(redis_client_module, "redis", FailingRedis()) + assert jwt_module.create_session("any-token") is False diff --git a/tests/unit/test_core/test_middleware.py b/tests/unit/test_core/test_middleware.py new file mode 100644 index 0000000..d868c85 --- /dev/null +++ b/tests/unit/test_core/test_middleware.py @@ -0,0 +1,93 @@ +""" +中间件单元测试 +============================================================= + +验证: + - 敏感字段递归脱敏(密码/salt/令牌等不会写入操作日志) + - 操作日志标题:@operation_log 声明的元数据被 _build_title 读取;未标注走兜底 +""" + +from src.middleware import _is_excluded_path, _redact_sensitive, _build_title, operation_log + + +class TestRedactSensitive: + """递归脱敏""" + + def test_redacts_sensitive_keys(self): + data = { + "username": "admin", + "password": "plaintext123", + "code": "business-code", # 业务字段(role.code 等)不得误打码 + "key": "business-key", + "nested": {"salt": "s1", "ok": 1}, + "list": [{"token": "jwt"}, "plain"], + } + result = _redact_sensitive(data) + + assert result["password"] == "******" + assert result["code"] == "business-code" # 不再误脱敏 + assert result["key"] == "business-key" + assert result["nested"]["salt"] == "******" + assert result["nested"]["ok"] == 1 + assert result["list"][0]["token"] == "******" + assert result["list"][1] == "plain" + assert result["username"] == "admin" + + def test_non_dict_passthrough(self): + assert _redact_sensitive("plain") == "plain" + assert _redact_sensitive(123) == 123 + assert _redact_sensitive(None) is None + + +class TestBuildTitle: + """@operation_log 元数据解析 + 兜底""" + + def test_uses_view_metadata(self, flask_app): + @flask_app.route('/test/add', methods=['POST']) + @operation_log("测试模块", "新增") + def add(): + return 'ok' + + with flask_app.app_context(): + # 裸 Flask app 的 endpoint 即视图函数名 + title = _build_title('/test/add', 'add') + assert title == '测试模块-新增' + + def test_action_only_uses_module(self, flask_app): + @flask_app.route('/test/list', methods=['GET']) + @operation_log("测试模块") + def lst(): + return 'ok' + + with flask_app.app_context(): + title = _build_title('/test/list', 'lst') + assert title == '测试模块' + + def test_unannotated_fallback(self, flask_app): + with flask_app.app_context(): + title = _build_title('/api/v1/user/add', 'unannotated.endpoint') + assert title == 'user-add' + + +class TestExcludedPath: + """敏感端点尾段匹配:兼容任意版本前缀""" + + def test_v1_paths_excluded(self): + assert _is_excluded_path("/api/v1/login") + assert _is_excluded_path("/api/v1/captcha") + assert _is_excluded_path("/api/v1/logout") + assert _is_excluded_path("/api/v1/job/sendSms") + + def test_v2_prefix_also_excluded(self): + # 尾段匹配 → 后续 v2/v3 无需追加前缀配置 + assert _is_excluded_path("/api/v2/login") + assert _is_excluded_path("/api/v2/job/sendSms") + + def test_normal_path_not_excluded(self): + assert not _is_excluded_path("/api/v1/user/add") + assert not _is_excluded_path("/api/v1/job/page") + assert not _is_excluded_path("/api/v1/job/runOnce/1") + + def test_root_mounted_paths_excluded(self): + assert _is_excluded_path("/login") + assert _is_excluded_path("/captcha") diff --git a/tests/unit/test_modules/test_city_fullname.py b/tests/unit/test_modules/test_city_fullname.py new file mode 100644 index 0000000..9a2db44 --- /dev/null +++ b/tests/unit/test_modules/test_city_fullname.py @@ -0,0 +1,118 @@ +""" +城市 full_name/pinyin 自动补全测试 +============================================================= + +验证新增/编辑城市时自动补全派生字段: + - full_name:沿 pid 链从首层城市拼接(如 台湾省,云林,元长,华鹿) + - pinyin:由城市简称生成无音调拼音,音节首字母大写(如 台湾 -> TaiWan、华鹿 -> HuaLu) + +覆盖新增、编辑改名、编辑变更父级三种场景。 +""" + +from src.core.database import db +from src.modules.city.repository import CityRepository +from src.modules.city.service import CityService + + +def _payload(name, short_name, pid, level): + """构造合法的城市表单数据(不含派生字段)""" + return { + "cityCode": "012", + "areaCode": "640300", + "parentCode": "0", + "zipCode": "750000", + "level": level, + "name": name, + "shortName": short_name, + "pid": pid, + "lng": "106.2", + "lat": "23.7", + } + + +def _add_city(app, name, short_name, pid, level): + """经 CityService.add 新增城市并返回其ID""" + with app.app_context(): + CityService.add(_payload(name, short_name, pid, level)) + db.session.commit() + return CityRepository.first_by({"name": name}).id + + +class TestCityFullName: + """full_name 沿 pid 链从首层拼接""" + + def test_full_name_chain(self, app): + province_id = _add_city(app, "台湾省", "台湾", 0, 1) + city_id = _add_city(app, "云林", "云林", province_id, 2) + district_id = _add_city(app, "元长", "元长", city_id, 3) + street_id = _add_city(app, "华鹿", "华鹿", district_id, 4) + + with app.app_context(): + province = CityRepository.get_by_id(province_id) + city = CityRepository.get_by_id(city_id) + district = CityRepository.get_by_id(district_id) + street = CityRepository.get_by_id(street_id) + + assert province.full_name == "台湾省" + assert city.full_name == "台湾省,云林" + assert district.full_name == "台湾省,云林,元长" + assert street.full_name == "台湾省,云林,元长,华鹿" + + def test_update_rename_recomputes(self, app): + province_id = _add_city(app, "广东省", "广东", 0, 1) + city_id = _add_city(app, "广州市", "广州", province_id, 2) + + with app.app_context(): + data = _payload("东莞市", "东莞", province_id, 2) + data["id"] = city_id + CityService.update(data) + db.session.commit() + db.session.expire_all() + city = CityRepository.get_by_id(city_id) + + assert city.name == "东莞市" + assert city.full_name == "广东省,东莞市" + + def test_update_reparent_recomputes(self, app): + prov_a = _add_city(app, "A省", "A省", 0, 1) + prov_b = _add_city(app, "B省", "B省", 0, 1) + city_id = _add_city(app, "旧城", "旧城", prov_a, 2) + + with app.app_context(): + data = _payload("新城", "新城", prov_b, 2) + data["id"] = city_id + CityService.update(data) + db.session.commit() + db.session.expire_all() + city = CityRepository.get_by_id(city_id) + + assert city.full_name == "B省,新城" + + +class TestCityPinyin: + """pinyin 由简称生成(无音调)""" + + def test_pinyin_uses_short_name(self, app): + province_id = _add_city(app, "台湾省", "台湾", 0, 1) + city_id = _add_city(app, "云林", "云林", province_id, 2) + + with app.app_context(): + province = CityRepository.get_by_id(province_id) + city = CityRepository.get_by_id(city_id) + + assert province.pinyin == "TaiWan" + assert city.pinyin == "YunLin" + + def test_update_rename_recomputes_pinyin(self, app): + province_id = _add_city(app, "广东省", "广东", 0, 1) + city_id = _add_city(app, "广州市", "广州", province_id, 2) + + with app.app_context(): + data = _payload("深圳市", "深圳", province_id, 2) + data["id"] = city_id + CityService.update(data) + db.session.commit() + db.session.expire_all() + city = CityRepository.get_by_id(city_id) + + assert city.pinyin == "ShenZhen" diff --git a/tests/unit/test_modules/test_city_list.py b/tests/unit/test_modules/test_city_list.py new file mode 100644 index 0000000..e0d2bae --- /dev/null +++ b/tests/unit/test_modules/test_city_list.py @@ -0,0 +1,83 @@ +""" +城市列表 pid 空值回归测试(空串/缺失 → 首层 pid=0,杜绝全量查询) +============================================================= + +回归依据:find_list_params 原实现 `int('')` 抛 ValueError 后落到 pid=None, +而 BaseRepository._build_query 对 None 过滤值静默跳过 → 空串 pid 触发全表查询。 +城市为树形结构,首层必须 pid=0;修复后空/缺失/非法 pid 统一回落到首层。 +""" + +from src.core.database import db +from src.modules.city import service as city_service +from src.modules.city.repository import CityRepository + + +def _seed_cities(app): + """构造 省(pid=0) × 2 + 市(pid=省id) × 1,返回各城市ID""" + with app.app_context(): + bj = CityRepository.create( + city_code="010", area_code="110000", parent_code="0", zip_code="100000", + level=1, name="北京市", short_name="北京", pid=0, + ) + hb = CityRepository.create( + city_code="027", area_code="420000", parent_code="0", zip_code="430000", + level=1, name="湖北省", short_name="湖北", pid=0, + ) + wh = CityRepository.create( + city_code="027", area_code="420100", parent_code="420000", zip_code="430000", + level=2, name="武汉市", short_name="武汉", pid=hb.id, + ) + db.session.commit() + return {"bj": bj.id, "hb": hb.id, "wh": wh.id} + + +def _names(items): + return sorted(c.name for c in items) + + +class TestCityListPid: + """pid 空串/缺失/非法 → 首层 pid=0;合法 pid → 对应子级""" + + def test_empty_string_returns_first_layer_only(self, app): + _seed_cities(app) + with app.app_context(): + items = CityRepository.find_list_params({"pid": ""}) + # 空串 pid 只返回 pid=0 首层,不再全表查询 + assert _names(items) == ["北京市", "湖北省"] + + def test_zero_missing_empty_equivalent(self, app): + _seed_cities(app) + with app.app_context(): + empty = _names(CityRepository.find_list_params({"pid": ""})) + zero = _names(CityRepository.find_list_params({"pid": 0})) + missing = _names(CityRepository.find_list_params({})) + assert empty == zero == missing == ["北京市", "湖北省"] + + def test_invalid_pid_falls_back_to_first_layer(self, app): + _seed_cities(app) + with app.app_context(): + items = CityRepository.find_list_params({"pid": "abc"}) + assert _names(items) == ["北京市", "湖北省"] + + def test_valid_pid_returns_children(self, app): + ids = _seed_cities(app) + with app.app_context(): + items = CityRepository.find_list_params({"pid": str(ids["hb"])}) + assert _names(items) == ["武汉市"] + + def test_pid_empty_combined_with_name(self, app): + _seed_cities(app) + with app.app_context(): + items = CityRepository.find_list_params({"pid": "", "name": "湖北"}) + assert _names(items) == ["湖北省"] + + +class TestGetCityListService: + """服务层端到端:空串 pid 只返回首层""" + + def test_service_empty_pid_returns_first_layer(self, app): + _seed_cities(app) + with app.app_context(): + data = city_service.get_city_list({"pid": ""}) + assert sorted(d["name"] for d in data) == ["北京市", "湖北省"] + assert all(d["pid"] == 0 for d in data) diff --git a/tests/unit/test_modules/test_dict_config_item_name_uniqueness.py b/tests/unit/test_modules/test_dict_config_item_name_uniqueness.py new file mode 100644 index 0000000..b662d8d --- /dev/null +++ b/tests/unit/test_modules/test_dict_config_item_name_uniqueness.py @@ -0,0 +1,199 @@ +""" +字典项/配置项名称唯一性测试(同分组下唯一,跨分组允许重名) +============================================================= + +验证名称唯一性按父级分组(dict_id / config_id)限定: + - 同一分组下名称不能重复(新增、编辑均校验) + - 不同分组之间允许存在相同名称 + - 更新时保留自身名称不误报(排除自身) + +回归依据:修复前 validate_name 按全局名称去重,导致不同字典/配置下 +的同名子项被误拒;且与 validate_value 的「同分组唯一」语义不一致。 +""" + +import pytest + +from src.core.database import db +from src.core.exceptions import BizError +from src.modules.configuration.config_item.repository import ConfigItemRepository +from src.modules.configuration.config.repository import ConfigRepository +from src.modules.configuration.config_item.service import ConfigItemService +from src.modules.dictionary.dict_item.repository import DictItemRepository +from src.modules.dictionary.dict.repository import DictRepository +from src.modules.dictionary.dict_item.service import DictItemService + + +# ============================================================ +# 字典项:名称同 dict_id 下唯一,跨 dict_id 允许重名 +# ============================================================ + + +def _seed_dict(app, name="性别", code="gender"): + """创建字典类型并返回其ID""" + with app.app_context(): + dict_obj = DictRepository.create(name=name, code=code, sort=1) + db.session.commit() + return dict_obj.id + + +def _add_dict_item(app, dict_id, name, value, sort=1): + """经 DictItemService.add 新增字典项""" + with app.app_context(): + DictItemService.add({"dictId": dict_id, "name": name, "value": value, "sort": sort}) + db.session.commit() + + +class TestDictItemNameUniqueWithinDict: + """字典项名称:同 dict_id 下唯一,不同 dict_id 允许重名""" + + def test_same_name_same_dict_rejected(self, app): + dict_id = _seed_dict(app) + _add_dict_item(app, dict_id, "男", "1") + with app.app_context(): + with pytest.raises(BizError, match="字典项名称已存在"): + DictItemService.add({"dictId": dict_id, "name": "男", "value": "2"}) + + def test_same_name_different_dict_allowed(self, app): + dict_a = _seed_dict(app, name="性别", code="gender") + dict_b = _seed_dict(app, name="用户性别", code="user_gender") + _add_dict_item(app, dict_a, "男", "1") + with app.app_context(): + DictItemService.add({"dictId": dict_b, "name": "男", "value": "1"}) + db.session.commit() + items = DictItemRepository.find_list({"name": "男"}) + ids = sorted(i.dict_id for i in items) + assert ids == [dict_a, dict_b] + + def test_update_keep_own_name_allowed(self, app): + dict_id = _seed_dict(app) + _add_dict_item(app, dict_id, "男", "1") + with app.app_context(): + item = DictItemRepository.first_by({"dict_id": dict_id, "name": "男"}) + # 编辑时保留自身名称 → 排除自身后无重复,校验通过 + DictItemService.update({"id": item.id, "dictId": dict_id, "name": "男", "value": "1"}) + db.session.commit() + + def test_update_to_duplicate_in_same_dict_rejected(self, app): + dict_id = _seed_dict(app) + _add_dict_item(app, dict_id, "男", "1") + _add_dict_item(app, dict_id, "女", "2") + with app.app_context(): + item = DictItemRepository.first_by({"dict_id": dict_id, "name": "女"}) + with pytest.raises(BizError, match="字典项名称已存在"): + DictItemService.update({"id": item.id, "dictId": dict_id, "name": "男", "value": "2"}) + + def test_update_name_duplicate_across_dict_allowed(self, app): + dict_a = _seed_dict(app, name="性别", code="gender") + dict_b = _seed_dict(app, name="用户性别", code="user_gender") + _add_dict_item(app, dict_a, "男", "1") + _add_dict_item(app, dict_b, "女", "2") + with app.app_context(): + item = DictItemRepository.first_by({"dict_id": dict_b, "name": "女"}) + # 改为另一字典已有的名称 → 跨分组允许 + DictItemService.update({"id": item.id, "dictId": dict_b, "name": "男", "value": "2"}) + db.session.commit() + items = DictItemRepository.find_list({"name": "男"}) + ids = sorted(i.dict_id for i in items) + assert ids == [dict_a, dict_b] + + +# ============================================================ +# 配置项:名称同 config_id 下唯一,跨 config_id 允许重名 +# ============================================================ + + +def _seed_config(app, name="网站配置", code="site"): + """创建参数配置并返回其ID""" + with app.app_context(): + config = ConfigRepository.create(name=name, code=code, sort=1) + db.session.commit() + return config.id + + +def _add_config_item(app, config_id, name, code, value="v", type_="text", sort=1): + """经 ConfigItemService.add 新增配置项(code 全局唯一,跨配置需不同编码)""" + with app.app_context(): + ConfigItemService.add({ + "configId": config_id, + "name": name, + "code": code, + "value": value, + "type": type_, + "sort": sort, + }) + db.session.commit() + + +class TestConfigItemNameUniqueWithinConfig: + """配置项名称:同 config_id 下唯一,不同 config_id 允许重名""" + + def test_same_name_same_config_rejected(self, app): + config_id = _seed_config(app) + _add_config_item(app, config_id, "网站名称", "site_name") + with app.app_context(): + with pytest.raises(BizError, match="配置项名称已存在"): + ConfigItemService.add({ + "configId": config_id, + "name": "网站名称", + "code": "site_name_2", + "value": "v2", + "type": "text", + }) + + def test_same_name_different_config_allowed(self, app): + config_a = _seed_config(app, name="网站配置", code="site") + config_b = _seed_config(app, name="应用配置", code="app") + _add_config_item(app, config_a, "网站名称", "site_name") + with app.app_context(): + ConfigItemService.add({ + "configId": config_b, + "name": "网站名称", + "code": "app_name", + "value": "v", + "type": "text", + }) + db.session.commit() + items = ConfigItemRepository.find_list({"name": "网站名称"}) + ids = sorted(i.config_id for i in items) + assert ids == [config_a, config_b] + + def test_update_keep_own_name_allowed(self, app): + config_id = _seed_config(app) + _add_config_item(app, config_id, "网站名称", "site_name") + with app.app_context(): + item = ConfigItemRepository.first_by({"config_id": config_id, "name": "网站名称"}) + # 编辑时保留自身名称 → 校验通过 + ConfigItemService.update({ + "id": item.id, "configId": config_id, + "name": "网站名称", "code": "site_name", "value": "v", "type": "text", + }) + db.session.commit() + + def test_update_to_duplicate_in_same_config_rejected(self, app): + config_id = _seed_config(app) + _add_config_item(app, config_id, "网站名称", "site_name") + _add_config_item(app, config_id, "联系电话", "contact_phone") + with app.app_context(): + item = ConfigItemRepository.first_by({"config_id": config_id, "name": "联系电话"}) + with pytest.raises(BizError, match="配置项名称已存在"): + ConfigItemService.update({ + "id": item.id, "configId": config_id, + "name": "网站名称", "code": "contact_phone", "value": "v", "type": "text", + }) + + def test_update_name_duplicate_across_config_allowed(self, app): + config_a = _seed_config(app, name="网站配置", code="site") + config_b = _seed_config(app, name="应用配置", code="app") + _add_config_item(app, config_a, "网站名称", "site_name") + _add_config_item(app, config_b, "联系电话", "contact_phone") + with app.app_context(): + item = ConfigItemRepository.first_by({"config_id": config_b, "name": "联系电话"}) + # 改为另一配置已有的名称 → 跨分组允许 + ConfigItemService.update({ + "id": item.id, "configId": config_b, + "name": "网站名称", "code": "contact_phone", "value": "v", "type": "text", + }) + db.session.commit() + items = ConfigItemRepository.find_list({"name": "网站名称"}) + ids = sorted(i.config_id for i in items) + assert ids == [config_a, config_b] diff --git a/tests/unit/test_modules/test_dict_utils.py b/tests/unit/test_modules/test_dict_utils.py new file mode 100644 index 0000000..e1902fb --- /dev/null +++ b/tests/unit/test_modules/test_dict_utils.py @@ -0,0 +1,114 @@ +""" +字典取值工具单元测试(dict_utils) +============================================================= + +验证 dict_utils 按 code 取字典项 / value→name 映射(消费端取值): + - int / str 值都能命中(value 统一 str 归一) + - 未知 code / 未知 value → None + - 内存缓存命中(第二次取值不再查库) + - clear_cache 失效后重新加载 +""" + +import pytest + +from src.core.database import db +from src.modules.dictionary import dict_utils +from src.modules.dictionary.dict.repository import DictRepository +from src.modules.dictionary.dict_item.repository import DictItemRepository + + +@pytest.fixture(autouse=True) +def _clear_dict_cache(): + """每用例前后清空 dict_utils 缓存,避免用例间 sqlite 库重建导致的脏缓存""" + dict_utils.clear_cache() + yield + dict_utils.clear_cache() + + +def _seed_gender(): + """构造 gender 字典(男/"1"、女/"2"、保密/"3"),幂等""" + if DictRepository.first_by({"code": "gender"}): + return + dict_obj = DictRepository.create(name="性别", code="gender", sort=1, create_user="system") + for name, value, sort in [("男", "1", 1), ("女", "2", 2), ("保密", "3", 3)]: + DictItemRepository.create( + dict_id=dict_obj.id, name=name, value=value, + sort=sort, create_user="system", + ) + db.session.commit() + + +class TestGetName: + """int / str 值命中与未知值兜底""" + + def test_int_value_hits(self, app): + with app.app_context(): + _seed_gender() + assert dict_utils.get_name("gender", 1) == "男" + assert dict_utils.get_name("gender", 2) == "女" + assert dict_utils.get_name("gender", 3) == "保密" + + def test_str_value_hits(self, app): + with app.app_context(): + _seed_gender() + assert dict_utils.get_name("gender", "1") == "男" + assert dict_utils.get_name("gender", "2") == "女" + + def test_unknown_value_returns_none(self, app): + with app.app_context(): + _seed_gender() + assert dict_utils.get_name("gender", 99) is None + + def test_unknown_code_returns_none(self, app): + with app.app_context(): + assert dict_utils.get_name("not_exist", 1) is None + + +class TestGetValueMap: + """映射键统一为 str""" + + def test_map_keys_are_str(self, app): + with app.app_context(): + _seed_gender() + value_map = dict_utils.get_value_map("gender") + assert value_map == {"1": "男", "2": "女", "3": "保密"} + + +class TestCache: + """内存缓存命中 + clear_cache 失效""" + + def test_cache_hit_queries_db_once(self, app, monkeypatch): + with app.app_context(): + _seed_gender() + original = DictItemRepository.list_by_code + calls = {"n": 0} + + def spy(cls, code, **kwargs): + calls["n"] += 1 + return original(code, **kwargs) + + monkeypatch.setattr(DictItemRepository, "list_by_code", classmethod(spy)) + + assert dict_utils.get_name("gender", 1) == "男" + assert dict_utils.get_name("gender", 2) == "女" + assert dict_utils.get_name("gender", 3) == "保密" + assert calls["n"] == 1 + + def test_clear_cache_reloads(self, app): + with app.app_context(): + _seed_gender() + assert dict_utils.get_name("gender", 1) == "男" + dict_utils.clear_cache("gender") + # 失效后重新读取仍正确 + assert dict_utils.get_name("gender", 2) == "女" + + +class TestGetDictItems: + """字典项列表返回 {value, name, sort}""" + + def test_items_shape_and_order(self, app): + with app.app_context(): + _seed_gender() + items = dict_utils.get_dict_items("gender") + assert [it["value"] for it in items] == ["1", "2", "3"] + assert items[0] == {"value": "1", "name": "男", "sort": 1} diff --git a/tests/unit/test_modules/test_file_template_service.py b/tests/unit/test_modules/test_file_template_service.py new file mode 100644 index 0000000..a187d15 --- /dev/null +++ b/tests/unit/test_modules/test_file_template_service.py @@ -0,0 +1,69 @@ +""" +文件模板服务单元测试(按编码查询 + 分页 filePath URL 拼接) +============================================================= + +验证: + - get_file_template_by_code 按编码返回数据,filePath 经 get_file_url 拼接 + - 编码不存在返回 None + - get_page 分页记录的 filePath 同样做 URL 拼接 + - 编码查询与详情返回结构一致 +""" + +from config import FLASK_FILE_URL +from src.core.database import db +from src.modules.file_template import service as file_template +from src.modules.file_template.repository import FileTemplateRepository + + +def _create_template(app, code="import_user", name="用户导入模板", file_path="/static/temp/imp.xlsx"): + with app.app_context(): + obj = FileTemplateRepository.create( + name=name, code=code, file_name="imp.xlsx", + file_path=file_path, note="备注", sort=1, + ) + db.session.commit() + return obj.id + + +class TestGetByCode: + """按模板编码查询,filePath 转 URL""" + + def test_by_code_returns_url_joined_path(self, app): + tid = _create_template(app) + with app.app_context(): + data = file_template.get_file_template_by_code("import_user") + + assert data is not None + assert data["id"] == tid + assert data["name"] == "用户导入模板" + assert data["filePath"] == FLASK_FILE_URL + "/static/temp/imp.xlsx" + + def test_by_code_missing_returns_none(self, app): + with app.app_context(): + assert file_template.get_file_template_by_code("not_exist") is None + + def test_by_code_matches_detail_shape(self, app): + tid = _create_template(app, code="detail_same") + with app.app_context(): + by_code = file_template.get_file_template_by_code("detail_same") + detail = file_template.get_file_template_detail(tid) + assert by_code == detail + + +class TestPageUrlJoin: + """分页记录 filePath 做 URL 拼接""" + + def test_page_file_path_url_joined(self, app): + _create_template(app) + with app.app_context(): + data = file_template.get_file_template_page({"pageNo": 1, "pageSize": 10}) + + assert data["total"] == 1 + record = data["records"][0] + assert record["filePath"] == FLASK_FILE_URL + "/static/temp/imp.xlsx" + + def test_page_empty_file_path_returns_none(self, app): + _create_template(app, file_path=None) + with app.app_context(): + data = file_template.get_file_template_page({"pageNo": 1, "pageSize": 10}) + assert data["records"][0]["filePath"] is None diff --git a/tests/unit/test_modules/test_generator.py b/tests/unit/test_modules/test_generator.py new file mode 100644 index 0000000..b4fdab9 --- /dev/null +++ b/tests/unit/test_modules/test_generator.py @@ -0,0 +1,457 @@ +""" +代码生成器模块单元测试 +============================================================= + +验证代码生成器 generator 模块的核心纯函数(无需数据库): + - 表分页:_paginate_tables(总数/页数/切片/越界回退) + - 表项解析:_parse_table_item(管道分隔/纯表名/字典三种格式) + - 生成入口校验:generate_tables(空/非数组/超限抛 BizError,单项失败继续) + - 字段类型映射:TableSchemaParser(DB 类型 -> SQLAlchemy 类型、长度、choices) + - 代码块构建:_build_column_block / _build_form_field_block / _build_filters_literal + - 自动注册:_insert_module_import / _insert_blueprint(插入与幂等) +""" + +import pytest + +from src.core.exceptions import BizError +from src.modules.generator import service as gen +from src.modules.generator.code_generator import ( + CodeGenerator, + _build_column_block, + _build_filters_literal, + _build_form_field_block, + _choice_tag_type, + normalize_config, +) +from src.modules.generator.parser import TableSchemaParser + + +# ============================================================ +# 表分页 +# ============================================================ + +class TestPaginateTables: + """内存分页:切片 + 页数计算 + 越界回退""" + + def test_basic(self): + tables = [{'tableName': f't{i}'} for i in range(25)] + result = gen._paginate_tables(tables, 1, 10) + assert result['total'] == 25 + assert result['pages'] == 3 + assert result['size'] == 10 + assert result['current'] == 1 + assert len(result['records']) == 10 + assert result['records'][0]['tableName'] == 't0' + + def test_last_page(self): + tables = [{'tableName': f't{i}'} for i in range(25)] + result = gen._paginate_tables(tables, 3, 10) + assert result['current'] == 3 + assert len(result['records']) == 5 + + def test_page_out_of_range_falls_back_to_first(self): + tables = [{'tableName': 't0'}] * 5 + result = gen._paginate_tables(tables, 99, 10) + assert result['current'] == 1 + assert result['pages'] == 1 + + def test_empty(self): + result = gen._paginate_tables([], 1, 10) + assert result['total'] == 0 + assert result['pages'] == 0 + assert result['records'] == [] + + +# ============================================================ +# 表项解析 +# ============================================================ + +class TestParseTableItem: + """tableNames 项三种格式解析""" + + def test_pipe_format(self): + assert gen._parse_table_item('flask_article|文章管理表') == ('flask_article', '文章管理表') + + def test_plain_name(self): + assert gen._parse_table_item('flask_article') == ('flask_article', '') + + def test_pipe_with_empty_comment(self): + assert gen._parse_table_item('flask_article|') == ('flask_article', '') + + def test_dict_format(self): + assert gen._parse_table_item({'name': 'flask_user', 'comment': '用户表'}) == ('flask_user', '用户表') + + +# ============================================================ +# 生成入口校验 +# ============================================================ + +class TestGenerateTables: + """参数校验与批量容错""" + + def test_missing_key_raises(self): + with pytest.raises(BizError, match='表名不能为空'): + gen.generate_tables({}) + + def test_empty_list_raises(self): + with pytest.raises(BizError, match='表名不能为空'): + gen.generate_tables({'tableNames': []}) + + def test_not_list_raises(self): + with pytest.raises(BizError, match='必须是数组'): + gen.generate_tables({'tableNames': 'flask_article'}) + + def test_too_many_raises(self): + names = [f't{i}' for i in range(51)] + with pytest.raises(BizError, match='最多支持'): + gen.generate_tables({'tableNames': names}) + + def test_success_returns_count(self, monkeypatch): + monkeypatch.setattr(gen, '_generate_one', lambda name, comment: None) + count = gen.generate_tables({'tableNames': ['flask_a|A表', 'flask_b']}) + assert count == 2 + + def test_partial_failure_continues(self, monkeypatch): + def fake_generate(table_name, table_comment): + if table_name == 'bad': + raise RuntimeError('boom') + + monkeypatch.setattr(gen, '_generate_one', fake_generate) + count = gen.generate_tables({'tableNames': ['good|A', 'bad', 'ok']}) + assert count == 2 + + +# ============================================================ +# 字段类型映射(不查库,仅构造列字典) +# ============================================================ + +def _col(**overrides): + """构造 information_schema 列字典""" + base = { + 'DATA_TYPE': 'varchar', + 'COLUMN_NAME': 'title', + 'COLUMN_COMMENT': '标题', + 'IS_NULLABLE': 'NO', + 'CHARACTER_MAXIMUM_LENGTH': 255, + 'NUMERIC_PRECISION': None, + 'NUMERIC_SCALE': None, + 'COLUMN_DEFAULT': None, + 'EXTRA': '', + } + base.update(overrides) + return base + + +class TestFieldTypeMapping: + """DB 数据类型 -> SQLAlchemy 类型 / 构造参数 / choices""" + + def test_varchar_to_string(self): + parser = TableSchemaParser('flask_t') + assert parser._get_field_type(_col(DATA_TYPE='varchar')) == 'String' + + def test_text_to_text(self): + parser = TableSchemaParser('flask_t') + assert parser._get_field_type(_col(DATA_TYPE='longtext')) == 'Text' + + def test_bigint_to_biginteger(self): + parser = TableSchemaParser('flask_t') + assert parser._get_field_type(_col(DATA_TYPE='bigint')) == 'BigInteger' + + def test_decimal_to_numeric(self): + parser = TableSchemaParser('flask_t') + assert parser._get_field_type(_col(DATA_TYPE='decimal')) == 'Numeric' + + def test_db_args_string_length(self): + parser = TableSchemaParser('flask_t') + assert parser._get_db_args(_col(DATA_TYPE='varchar', CHARACTER_MAXIMUM_LENGTH=100)) == '100' + + def test_db_args_numeric_precision(self): + parser = TableSchemaParser('flask_t') + assert parser._get_db_args(_col(DATA_TYPE='decimal', NUMERIC_PRECISION=10, NUMERIC_SCALE=2)) == '10, 2' + + def test_db_args_none_for_text(self): + parser = TableSchemaParser('flask_t') + assert parser._get_db_args(_col(DATA_TYPE='text')) is None + + def test_form_type_integer(self): + parser = TableSchemaParser('flask_t') + assert parser._get_form_type(_col(DATA_TYPE='int')) == 'IntegerField' + + def test_choices_parse(self): + parser = TableSchemaParser('flask_t') + choices, min_value, max_value = parser._get_choices(_col(COLUMN_COMMENT='0-正常 1-下架')) + assert choices == [(0, '正常'), (1, '下架')] + assert (min_value, max_value) == (0, 1) + + def test_choices_none(self): + parser = TableSchemaParser('flask_t') + choices, min_value, max_value = parser._get_choices(_col(COLUMN_COMMENT='标题')) + assert choices is None + assert min_value is None + + def test_filter_type_string_fuzzy(self): + parser = TableSchemaParser('flask_t') + assert parser._get_filter_type(_col(COLUMN_NAME='title', DATA_TYPE='varchar')) == '模糊查询' + + def test_filter_type_code_exact(self): + parser = TableSchemaParser('flask_t') + assert parser._get_filter_type(_col(COLUMN_NAME='code', DATA_TYPE='varchar')) == '精确匹配' + + def test_numeric_default_normalized(self): + parser = TableSchemaParser('flask_t') + col = _col(DATA_TYPE='int', COLUMN_NAME='sort', COLUMN_DEFAULT='0') + fields = parser._build_fields([col], set()) + # MySQL 默认值以字符串返回,整数列应转为 int(生成 default=0 而非 default='0') + assert fields[0]['default_repr'] == '0' + assert isinstance(fields[0]['default'], int) + + def test_string_default_kept_as_string(self): + parser = TableSchemaParser('flask_t') + col = _col(DATA_TYPE='varchar', COLUMN_NAME='name', COLUMN_DEFAULT="'normal'") + fields = parser._build_fields([col], set()) + assert fields[0]['default'] == 'normal' + assert fields[0]['default_repr'] == "'normal'" + + +# ============================================================ +# 代码块构建 +# ============================================================ + +class TestBlockBuilders: + """models/schema/repository 代码块预构建""" + + def test_column_block(self): + field = dict(name='title', comment='标题', py_comment='标题', db_type='String', db_args='200', + nullable=False, default_repr=None, is_unique=False, db_index=False) + block = _build_column_block(field) + assert 'title = db.Column(' in block + assert 'db.String(200)' in block + assert 'nullable=False' in block + assert 'comment="标题"' in block + + def test_column_block_with_default_unique_index(self): + field = dict(name='status', comment='状态', py_comment='状态', db_type='Integer', db_args=None, + nullable=False, default_repr='1', is_unique=True, db_index=True) + block = _build_column_block(field) + assert 'db.Integer' in block + assert 'default=1' in block + assert 'unique=True' in block + assert 'index=True' in block + + def test_form_field_block(self): + field = dict(camel_name='title', form_type='StringField', comment='标题', py_comment='标题', + required=True, max_length=200, min_value=None, max_value=None) + block = _build_form_field_block(field) + assert 'title = StringField(' in block + assert "DataRequired(message='标题不能为空')" in block + assert 'Length(max=200' in block + + def test_filters_literal_empty(self): + assert _build_filters_literal([]) == '{}' + + def test_filters_literal(self): + literal = _build_filters_literal([('status', 'status'), ('name', 'name')]) + assert "'status': params.get('status')," in literal + assert "'name': params.get('name')," in literal + + +# ============================================================ +# 自动注册辅助 +# ============================================================ + +# ============================================================ +# 配置标准化(兼容 config.json 参考配置) +# ============================================================ + +class TestNormalizeConfig: + """config.json 参考字段类型 -> SQLAlchemy 类型转换""" + + def test_django_config_normalized(self): + config = { + 'app_name': 'example', + 'fields': [ + {'name': 'name', 'type': 'CharField', 'max_length': 100, 'comment': '名称', 'default': None}, + {'name': 'status', 'type': 'IntegerField', 'comment': '状态', 'default': '1'}, + {'name': 'sort', 'type': 'IntegerField', 'comment': '排序', 'default': '0'}, + ], + } + out = normalize_config(config) + + name_field = out['fields'][0] + assert name_field['db_type'] == 'String' + assert name_field['db_args'] == '100' + assert name_field['form_type'] == 'StringField' + assert name_field['camel_name'] == 'name' + + status_field = out['fields'][1] + assert status_field['db_type'] == 'Integer' + assert status_field['form_type'] == 'IntegerField' + assert status_field['default'] == 1 # 字符串默认值转 int + assert status_field['default_repr'] == '1' + assert status_field['is_status'] is True + + sort_field = out['fields'][2] + assert sort_field['is_sort'] is True + + def test_flask_config_passthrough(self): + # 已含 db_type 的 Flask 配置原样返回(幂等) + config = {'app_name': 'x', 'fields': [{'name': 'a', 'db_type': 'String'}]} + assert normalize_config(config) is config + + def test_empty_fields_passthrough(self): + config = {'app_name': 'x', 'fields': []} + assert normalize_config(config) is config + + +# ============================================================ +# 前端 Vue 上下文 +# ============================================================ + +def _gen_config(fields, **extra): + """构造最小生成配置""" + base = { + 'app_name': 'example', 'module_comment': '案例', 'model_class_name': 'Example', + 'permission_prefix': 'sys:example', 'route_prefix': 'example', 'primary_key': 'id', + 'display_field': 'name', 'display_field_camel': 'name', + 'has_sort': True, 'has_status': True, 'has_unique_code': False, 'has_export': False, + 'fields': fields, + } + base.update(extra) + return base + + +def _vf(name, db_type='String', comment='字段', choices=None, filterable=False, **kwargs): + """构造最小字段配置""" + field = { + 'name': name, 'camel_name': name, 'comment': comment, 'py_comment': comment, + 'db_type': db_type, 'db_args': '100' if db_type == 'String' else None, + 'form_type': 'StringField', 'type_display': '字符串', + 'max_length': 100 if db_type == 'String' else None, + 'nullable': False, 'required': True, 'default_repr': None, 'is_unique': False, + 'db_index': False, 'default': None, 'filterable': filterable, + 'filter_type': '模糊查询' if filterable else '', + 'in_form': True, 'in_list': True, 'editable': True, 'searchable': filterable, + 'is_image': False, 'is_rich_text': False, 'is_sort': False, 'is_status': False, + 'choices': choices, 'min_value': None, 'max_value': None, + } + field.update(kwargs) + return field + + +class TestCleanFieldComment: + """清洗注释中的选项说明""" + + def test_strip_choices(self): + gen = CodeGenerator(_gen_config([])) + assert gen._clean_field_comment('案例类型:1-类型1 2-类型2') == '案例类型' + assert gen._clean_field_comment('案例状态:1-正常 2-禁用') == '案例状态' + + def test_plain(self): + gen = CodeGenerator(_gen_config([])) + assert gen._clean_field_comment('标题') == '标题' + assert gen._clean_field_comment('') == '' + + +class TestBuildChoices: + """选项元组 -> 前端字典列表""" + + def test_transformed(self): + gen = CodeGenerator(_gen_config([])) + assert gen._build_choices([(1, '正常'), (2, '禁用')]) == [ + {'value': 1, 'label': '正常', 'type': 'success'}, + {'value': 2, 'label': '禁用', 'type': 'danger'}, + ] + + def test_none(self): + gen = CodeGenerator(_gen_config([])) + assert gen._build_choices(None) is None + + +class TestChoiceTagType: + """选项值 -> 标签颜色""" + + def test_values(self): + assert _choice_tag_type(1) == 'success' + assert _choice_tag_type(2) == 'danger' + assert _choice_tag_type(3) == 'warning' + assert _choice_tag_type(9) == 'info' + + +class TestVueContext: + """前端模板上下文:普通列表与树状列表""" + + def test_normal_context(self): + fields = [ + _vf('name', comment='案例名称', filterable=True), + _vf('status', db_type='Integer', comment='案例状态:1-正常 2-禁用', + choices=[(1, '正常'), (2, '禁用')], filterable=True), + ] + gen = CodeGenerator(_gen_config(fields)) + vctx = gen._prepare_vue_context(gen._prepare_context()) + + assert vctx['is_tree_structure'] is False + assert vctx['route_prefix'] == 'example' # 覆盖为 app_name,与后端蓝图一致 + assert vctx['model_class_name_camel'] == 'example' + assert vctx['module_name'] == 'example' + assert vctx['api_path'] == 'tool/example' + + # 字段重塑:type 转配置字段类型名、label 清洗、choices 转字典 + name_field = next(f for f in vctx['fields'] if f['name'] == 'name') + assert name_field['type'] == 'CharField' + assert name_field['label'] == '案例名称' + status_field = next(f for f in vctx['fields'] if f['name'] == 'status') + assert status_field['type'] == 'IntegerField' + assert status_field['label'] == '案例状态' + assert status_field['choices'][0] == {'value': 1, 'label': '正常', 'type': 'success'} + + def test_tree_context(self): + fields = [_vf('name', comment='部门名称'), _vf('parent_id', db_type='Integer', comment='上级部门')] + gen = CodeGenerator(_gen_config(fields)) + vctx = gen._prepare_vue_context(gen._prepare_context()) + + assert vctx['is_tree_structure'] is True + assert vctx['parent_id_field'] == 'parentId' + assert vctx['has_search_form'] is False # 树形默认不显示搜索表单 + + def test_module_name_underscore_stripped(self): + gen = CodeGenerator(_gen_config([], app_name='order_item', model_class_name='OrderItem')) + vctx = gen._prepare_vue_context(gen._prepare_context()) + assert vctx['module_name'] == 'orderitem' + assert vctx['model_class_name_camel'] == 'orderItem' + + def test_filter_fields(self): + fields = [_vf('name', filterable=True), _vf('type', db_type='Integer', filterable=True)] + gen = CodeGenerator(_gen_config(fields)) + vctx = gen._prepare_vue_context(gen._prepare_context()) + assert [f['name'] for f in vctx['filter_fields']] == ['name', 'type'] + assert vctx['has_search_form'] is True + + +class TestRegisterHelpers: + """_insert_module_import / _insert_blueprint 插入与幂等""" + + def test_insert_module_import(self): + content = "from src.modules.auth.router import login_blue\nfrom src.modules.city.router import city_blue\n" + out = CodeGenerator._insert_module_import( + content, 'from src.modules.generator.router import generator_blue' + ) + assert 'from src.modules.generator.router import generator_blue\n' in out + # 插入位置应在最后一个 src.modules 导入之后 + assert out.index('generator') > out.index('city_blue') + + def test_insert_module_import_idempotent(self): + content = "from src.modules.city.router import city_blue\n" + out = CodeGenerator._insert_module_import(content, 'from src.modules.city.router import city_blue') + assert out == content + + def test_insert_blueprint(self): + content = "BLUEPRINTS = [\n login_blue,\n city_blue,\n]\n" + out = CodeGenerator._insert_blueprint(content, 'generator_blue') + assert 'generator_blue' in out + assert out.index('generator_blue') > out.index('city_blue') + assert out.endswith(' generator_blue,\n]\n') + + def test_insert_blueprint_idempotent(self): + content = "BLUEPRINTS = [\n generator_blue,\n]\n" + out = CodeGenerator._insert_blueprint(content, 'generator_blue') + assert out == content diff --git a/tests/unit/test_modules/test_job_log_service.py b/tests/unit/test_modules/test_job_log_service.py new file mode 100644 index 0000000..bff9589 --- /dev/null +++ b/tests/unit/test_modules/test_job_log_service.py @@ -0,0 +1,113 @@ +""" +任务日志服务单元测试(job_log 模块) +============================================================= + +验证 job_log 模块的查询/富化/删除契约: + - 分页返回 statusText(走字典表 job_log_status:0-正常 1-失败) + - 按 jobId / status 筛选 + - 软删除 +""" + +from datetime import datetime + +import pytest + +from src.core.database import db +from src.modules.dictionary import dict_utils +from src.modules.job.repository import JobRepository +from src.modules.job_log.models import JobLog +from src.modules.job_log.repository import JobLogRepository +from src.modules.job_log.service import JobLogService, write_job_log +from scripts.seed_dict import seed_dict + + +@pytest.fixture(autouse=True) +def _clear_dict_cache(): + """每用例前后清空 dict_utils 缓存,避免用例间 sqlite 库重建导致的脏缓存""" + dict_utils.clear_cache() + yield + dict_utils.clear_cache() + + +def _create_job_log(job_id=1, status=0, job_name="测试任务"): + """构造一条任务日志""" + log = JobLogRepository.create( + job_id=job_id, job_name=job_name, job_group="default", + job_trigger="cron", job_message="执行成功", cron_expression="0 0/5 * * * ?", + status=status, + start_time=datetime.now(), end_time=datetime.now(), + consume_time=120, create_user="system", + ) + db.session.commit() + return log.id + + +class TestJobLogPage: + """分页富化(statusText 走字典)与筛选""" + + def test_page_with_status_text(self, app): + with app.app_context(): + seed_dict(app) + _create_job_log(job_id=1, status=0, job_name="任务A") + _create_job_log(job_id=2, status=1, job_name="任务B") + data = JobLogService.get_page({"pageNo": 1, "pageSize": 10}) + by_name = {r["jobName"]: r for r in data["records"]} + assert by_name["任务A"]["statusText"] == "正常" + assert by_name["任务B"]["statusText"] == "失败" + + def test_page_filter_by_job_id_and_status(self, app): + with app.app_context(): + _create_job_log(job_id=1, status=0, job_name="A") + _create_job_log(job_id=1, status=1, job_name="B") + _create_job_log(job_id=2, status=0, job_name="C") + data = JobLogService.get_page({"pageNo": 1, "pageSize": 10, "jobId": "1", "status": "0"}) + assert [r["jobName"] for r in data["records"]] == ["A"] + + +class TestJobLogDelete: + """软删除""" + + def test_delete_soft(self, app): + with app.app_context(): + log_id = _create_job_log() + count = JobLogService.remove(str(log_id)) + assert count == 1 + assert JobLogRepository.get_by_id(log_id) is None # 软删除后默认查询不可见 + + +class TestWriteJobLog: + """任务执行日志写入(job 调度器调用)""" + + def test_write_success(self, app): + with app.app_context(): + job = JobRepository.create( + job_name="写日志任务", job_group="default", job_trigger="cron", + cron_expression="0 0/5 * * * ?", status=1, create_user="system", + ) + db.session.commit() + write_job_log( + job.id, success=True, message="执行成功", + start_time=datetime(2026, 1, 1, 10, 0, 0), end_time=datetime(2026, 1, 1, 10, 0, 2), + ) + log = JobLogRepository.first_by({"job_id": job.id}) + assert log is not None + assert log.status == JobLog.Status.NORMAL + assert log.job_name == "写日志任务" + assert log.job_group == "default" + assert log.cron_expression == "0 0/5 * * * ?" + assert log.consume_time == 2000 # 2 秒 = 2000ms + assert log.job_message == "执行成功" + + def test_write_failed(self, app): + with app.app_context(): + job = JobRepository.create(job_name="失败任务", status=1, create_user="system") + db.session.commit() + write_job_log(job.id, success=False, error="boom") + log = JobLogRepository.first_by({"job_id": job.id}) + assert log.status == JobLog.Status.FAILED + assert log.exception_info == "boom" + + def test_write_skips_when_job_missing(self, app): + with app.app_context(): + write_job_log(99999, success=False, error="no job") # 任务不存在,不写日志 + assert JobLogRepository.find_list() == [] diff --git a/tests/unit/test_modules/test_job_service.py b/tests/unit/test_modules/test_job_service.py new file mode 100644 index 0000000..77ca622 --- /dev/null +++ b/tests/unit/test_modules/test_job_service.py @@ -0,0 +1,49 @@ +""" +定时任务服务测试(F4 调度器入参修复) +============================================================= + +验证 JobService 与调度器的联动契约: + - add_job_to_scheduler 应收到 dict(驼峰键,含 cronExpression/id/status), + 而非 ORM 实例(原实现传 ORM 实例 → AttributeError 被吞 → 任务永不注册) +""" + +from src.core.database import db +from src.modules.job.service import JobService + + +def _job_payload(): + """构造合法任务表单数据(isSync=0 异步:回归验证 DataRequired→Optional 修复)""" + return { + "jobName": "test_job", + "status": 1, + "executePolicy": 1, + "isSync": 0, + "cronExpression": "0 2 * * *", + "url": "http://localhost/api/v1/notify", + "note": "测试", + } + + +class TestJobSchedulerDict: + """调度器入参应为驼峰 dict""" + + def test_add_passes_dict_to_scheduler(self, app, monkeypatch): + captured = {} + + def fake_add(job_dict): + captured["arg"] = job_dict + return True + + monkeypatch.setattr("src.modules.job.service.add_job_to_scheduler", fake_add) + + with app.app_context(): + JobService.add(_job_payload()) + db.session.commit() + + assert "arg" in captured + arg = captured["arg"] + # 必须是 dict 且含调度器所需驼峰键(原 ORM 实例无 .get → AttributeError) + assert isinstance(arg, dict) + assert arg["cronExpression"] == "0 2 * * *" + assert arg["status"] == 1 + assert arg["id"] is not None diff --git a/tests/unit/test_modules/test_level_service.py b/tests/unit/test_modules/test_level_service.py new file mode 100644 index 0000000..3dbff9c --- /dev/null +++ b/tests/unit/test_modules/test_level_service.py @@ -0,0 +1,213 @@ +""" +职级服务单元测试(Excel 导入/导出复用 excel_utils) +============================================================= + +验证: + - export_levels 支持与分页一致的筛选参数(name 模糊、status 精确) + - 无筛选时导出全部记录;表头与数据列对齐 + - import_levels 上传/解析/入库/清理(临时文件生命周期) + - 上传失败与文件缺失时透传错误 + +文件落盘通过 monkeypatch excel_utils.save_export_file 跳过, +仅验证查询、列构建与写盘调用契约。 +""" + +import pytest + +from src.common.utils import excel_utils +from src.core.exceptions import BizError +from src.modules.system.level import service as level_service +from src.modules.system.level.repository import LevelRepository + + +@pytest.fixture +def seed_levels(app): + """插入职级测试数据(sqlite 内存库)""" + from src.core.database import db + + with app.app_context(): + LevelRepository.create(name="初级工程师", status=1, sort=1) + LevelRepository.create(name="中级工程师", status=1, sort=2) + LevelRepository.create(name="高级工程师", status=2, sort=3) + LevelRepository.create(name="产品经理", status=1, sort=4) + db.session.commit() + + +def _run_export(app, monkeypatch, params): + """调用导出并捕获表头/行数据(跳过文件落盘,仅验证查询与列构建)""" + captured = {} + + def fake_save(headers, rows, filename_prefix="export"): + captured["headers"] = headers + captured["rows"] = rows + return "/static/temp/level_xxx.xlsx" + + monkeypatch.setattr(excel_utils, "save_export_file", fake_save) + + with app.app_context(): + result = level_service.export_levels(params) + url = result.get_json()["data"] + + return captured, url + + +class TestExportLevels: + """Excel 导出:筛选参数 + 列对齐""" + + HEADER = ["职级ID", "职级名称", "职级排序", "职级状态", "创建时间", "更新时间"] + + def test_export_no_filter_exports_all(self, app, monkeypatch, seed_levels): + captured, url = _run_export(app, monkeypatch, None) + assert captured["headers"] == self.HEADER + assert [r[1] for r in captured["rows"]] == ["初级工程师", "中级工程师", "高级工程师", "产品经理"] + assert url.startswith("/static") + + def test_export_filters_by_name(self, app, monkeypatch, seed_levels): + captured, _ = _run_export(app, monkeypatch, {"name": "工程师"}) + assert captured["headers"] == self.HEADER + assert [r[1] for r in captured["rows"]] == ["初级工程师", "中级工程师", "高级工程师"] + + def test_export_filters_by_status(self, app, monkeypatch, seed_levels): + captured, _ = _run_export(app, monkeypatch, {"status": 2}) + assert [r[1] for r in captured["rows"]] == ["高级工程师"] + + def test_export_filters_by_name_and_status(self, app, monkeypatch, seed_levels): + captured, _ = _run_export(app, monkeypatch, {"name": "工程师", "status": 1}) + assert [r[1] for r in captured["rows"]] == ["初级工程师", "中级工程师"] + + def test_export_columns_aligned(self, app, monkeypatch, seed_levels): + captured, _ = _run_export(app, monkeypatch, {"name": "产品经理"}) + assert captured["headers"] == self.HEADER + row = captured["rows"][0] + assert row[0] is not None # 职级ID + assert row[1] == "产品经理" # 职级名称 + assert row[2] == 4 # 职级排序 + assert row[3] == "正常" # 职级状态(formatter 展示为中文) + assert row[4] # 创建时间 + assert row[5] # 更新时间 + + def test_export_empty_result_keeps_header(self, app, monkeypatch, seed_levels): + captured, _ = _run_export(app, monkeypatch, {"name": "不存在的职级"}) + assert captured["headers"] == self.HEADER + assert captured["rows"] == [] + + +class TestLevelList: + """职级列表(无分页):参数条件筛选 + status=1 硬性过滤""" + + def test_list_no_filter_returns_enabled_only(self, app, seed_levels): + with app.app_context(): + data = level_service.get_level_list(None) + assert [d["name"] for d in data] == ["初级工程师", "中级工程师", "产品经理"] + + def test_list_filters_by_name(self, app, seed_levels): + with app.app_context(): + data = level_service.get_level_list({"name": "工程师"}) + assert [d["name"] for d in data] == ["初级工程师", "中级工程师"] + + def test_list_status_param_is_ignored(self, app, seed_levels): + # status 为硬性条件,即使传入 status=2 也只返回启用(status=1)记录 + with app.app_context(): + data = level_service.get_level_list({"name": "工程师", "status": 2}) + assert [d["name"] for d in data] == ["初级工程师", "中级工程师"] + + +class TestImportLevels: + """职级Excel导入(复用 excel_utils 上传/解析/清理机制)""" + + def _patch_upload(self, monkeypatch, tmp_path, file_name="levels.xlsx"): + """将上传模拟指向 tmp_path/temp/20260817 目录,返回该文件路径""" + target_dir = tmp_path / "temp" / "20260817" + target_dir.mkdir(parents=True, exist_ok=True) + target = target_dir / file_name + monkeypatch.setattr( + excel_utils.upload, "upload_file", + lambda *a, **k: {"filePath": "/temp/20260817/" + file_name}, + ) + monkeypatch.setattr(excel_utils, "FLASK_UPLOAD_DIR", str(tmp_path)) + return target + + def test_import_success(self, app, monkeypatch, tmp_path): + import shutil + + from openpyxl.workbook import Workbook + + from src.modules.system.level.models import Level + + # 构造真实 xlsx 并放入模拟的上传目录(temp/日期/文件) + src = tmp_path / "levels.xlsx" + wb = Workbook() + ws = wb.worksheets[0] + ws.append(["职级ID", "职级名称", "职级排序", "职级状态"]) + ws.append([None, "初级", 1, "正常"]) + ws.append([None, "停用级", 2, "禁用"]) + ws.append([None, None, None, None]) # 整行为空 → 跳过 + wb.save(src) + + target = self._patch_upload(monkeypatch, tmp_path) + shutil.copy(src, target) + + with app.app_context(): + result = level_service.import_levels() + rows = Level.query.order_by(Level.sort).all() + + assert result.get_json()["code"] == 0 + assert [r.name for r in rows] == ["初级", "停用级"] + assert rows[0].status == 1 # "正常" → 1 + assert rows[1].status == 2 # "禁用" → 2 + assert not target.exists() # 临时导入文件已清理 + + def test_import_required_missing_fails_whole_import(self, app, monkeypatch, tmp_path): + import shutil + + from openpyxl.workbook import Workbook + + from src.modules.system.level.models import Level + + # 名称必填缺失 → 行级错误 → 整体失败,无任何数据入库 + src = tmp_path / "levels.xlsx" + wb = Workbook() + ws = wb.worksheets[0] + ws.append(["职级ID", "职级名称", "职级排序", "职级状态"]) + ws.append([None, "", 1, "正常"]) + wb.save(src) + + target = self._patch_upload(monkeypatch, tmp_path) + shutil.copy(src, target) + + with app.app_context(): + result = level_service.import_levels() + count = Level.query.count() + + body = result.get_json() + assert body["code"] == 1 + assert "职级名称不能为空" in body["msg"] + assert count == 0 + + def test_import_upload_failure_propagates(self, app, monkeypatch): + # 上传失败由 upload_file 抛 BizError,import_levels 捕获后透传具体错误信息 + def _fail_upload(*args, **kwargs): + raise BizError("文件格式不支持,仅支持 .xls, .xlsx 格式") + + monkeypatch.setattr(excel_utils.upload, "upload_file", _fail_upload) + + with app.app_context(): + result = level_service.import_levels() + + body = result.get_json() + assert body["code"] == 1 + assert "文件格式不支持" in body["msg"] + + def test_import_file_missing_returns_failed(self, app, monkeypatch): + monkeypatch.setattr( + excel_utils.upload, "upload_file", + lambda *a, **k: {"filePath": "/temp/20260817/nonexist.xlsx"}, + ) + monkeypatch.setattr(excel_utils, "FLASK_UPLOAD_DIR", "nonexistent_dir") + + with app.app_context(): + result = level_service.import_levels() + + body = result.get_json() + assert body["code"] == 1 + assert "导入文件不存在" in body["msg"] diff --git a/tests/unit/test_modules/test_position_service.py b/tests/unit/test_modules/test_position_service.py index be67e27..814e446 100644 --- a/tests/unit/test_modules/test_position_service.py +++ b/tests/unit/test_modules/test_position_service.py @@ -10,6 +10,7 @@ import pytest +from src.common.utils.model_utils import build_page_data from src.core.exceptions import BizError from src.modules.system.position import service as position_service @@ -18,7 +19,7 @@ class TestBuildPageData: """分页数据构建(纯函数)""" def test_shape(self): - data = position_service._build_page_data([], 0, 10, 1, 0) + data = build_page_data([], 0, 1, 10) assert data == {'records': [], 'total': 0, 'size': 10, 'current': 1, 'pages': 0} @@ -63,9 +64,9 @@ class TestDeletePositions: with pytest.raises(BizError, match="无效的记录ID"): position_service.delete_positions('abc') - def test_parse_and_delete(self, monkeypatch): - """ID 解析后调用模型批量删除,返回删除计数""" - from src.modules.system.position.models import Position + def test_parse_and_delete(self, app, monkeypatch): + """ID 解析后调用仓库批量删除,返回删除计数(before_delete 引用校验需 DB 上下文)""" + from src.modules.system.position.repository import PositionRepository captured = {} @@ -73,9 +74,41 @@ class TestDeletePositions: captured['ids'] = ids return len(ids) - monkeypatch.setattr(Position, 'batch_delete', classmethod(fake_batch_delete)) + monkeypatch.setattr(PositionRepository, 'batch_delete', classmethod(fake_batch_delete)) - count = position_service.delete_positions('1,2,3') + with app.app_context(): + count = position_service.delete_positions('1,2,3') assert count == 3 assert captured['ids'] == [1, 2, 3] + + +class TestPositionList: + """岗位列表(无分页):参数条件筛选 + status=1 硬性过滤""" + + @pytest.fixture + def seed_positions(self, app): + from src.core.database import db + from src.modules.system.position.repository import PositionRepository + + with app.app_context(): + PositionRepository.create(name="产品经理", status=1, sort=1) + PositionRepository.create(name="项目经理", status=1, sort=2) + PositionRepository.create(name="测试主管", status=0, sort=3) + db.session.commit() + + def test_list_no_filter_returns_enabled_only(self, app, seed_positions): + with app.app_context(): + data = position_service.get_position_list(None) + assert [d["name"] for d in data] == ["产品经理", "项目经理"] + + def test_list_filters_by_name(self, app, seed_positions): + with app.app_context(): + data = position_service.get_position_list({"name": "经理"}) + assert [d["name"] for d in data] == ["产品经理", "项目经理"] + + def test_list_status_param_is_ignored(self, app, seed_positions): + # status 为硬性条件,即使传入 status=0 也只返回启用(status=1)记录 + with app.app_context(): + data = position_service.get_position_list({"name": "经理", "status": 0}) + assert [d["name"] for d in data] == ["产品经理", "项目经理"] diff --git a/tests/unit/test_modules/test_role_service.py b/tests/unit/test_modules/test_role_service.py new file mode 100644 index 0000000..bc9f9eb --- /dev/null +++ b/tests/unit/test_modules/test_role_service.py @@ -0,0 +1,39 @@ +""" +角色服务单元测试(列表查询) +============================================================= + +验证: + - get_role_list 支持与分页一致的 name 模糊筛选 + - 无筛选时返回全部角色(角色无 status 字段) +""" + +import pytest + +from src.modules.system.role import service as role_service +from src.modules.system.role.repository import RoleRepository + + +@pytest.fixture +def seed_roles(app): + """插入角色测试数据(sqlite 内存库)""" + from src.core.database import db + + with app.app_context(): + RoleRepository.create(name="超级管理员", code="super", sort=1) + RoleRepository.create(name="系统管理员", code="admin", sort=2) + RoleRepository.create(name="普通用户", code="user", sort=3) + db.session.commit() + + +class TestRoleList: + """角色列表(无分页):参数条件筛选""" + + def test_list_no_filter_returns_all(self, app, seed_roles): + with app.app_context(): + data = role_service.get_role_list(None) + assert [d["name"] for d in data] == ["超级管理员", "系统管理员", "普通用户"] + + def test_list_filters_by_name(self, app, seed_roles): + with app.app_context(): + data = role_service.get_role_list({"name": "管理员"}) + assert [d["name"] for d in data] == ["超级管理员", "系统管理员"] diff --git a/tests/unit/test_modules/test_seed_dict.py b/tests/unit/test_modules/test_seed_dict.py new file mode 100644 index 0000000..c916c97 --- /dev/null +++ b/tests/unit/test_modules/test_seed_dict.py @@ -0,0 +1,55 @@ +""" +字典一键初始化脚本测试(seed_dict) +============================================================= + +验证 scripts/seed_dict.py 的幂等初始化: + - 首次执行创建 DICT_SEED 中全部字典(dict + dict_item) + - 二次执行不重复(按 code 跳过已存在) + - 种入后 dict_utils 可从字典表取值 +""" + +import pytest + +from src.modules.dictionary import dict_utils +from src.modules.dictionary.dict.repository import DictRepository +from src.modules.dictionary.dict_item.repository import DictItemRepository +from scripts.seed_dict import seed_dict + + +@pytest.fixture(autouse=True) +def _clear_dict_cache(): + """每用例前后清空 dict_utils 缓存,避免用例间 sqlite 库重建导致的脏缓存""" + dict_utils.clear_cache() + yield + dict_utils.clear_cache() + + +class TestSeedDict: + def test_seed_creates_all_dicts(self, app): + from scripts.seed_dict import DICT_SEED + + with app.app_context(): + created = seed_dict(app) + assert created == len(DICT_SEED) + for spec in DICT_SEED: + dict_obj = DictRepository.first_by({"code": spec["code"]}) + assert dict_obj is not None, spec["code"] + assert len(DictItemRepository.list_by_code(spec["code"])) == len(spec["items"]) + + def test_seed_idempotent(self, app): + with app.app_context(): + seed_dict(app) + first_items = len(DictItemRepository.list_by_code("gender")) + created = seed_dict(app) + assert created == 0 + # 重跑不重复:item 数不变、dict 仍只有一份 + assert len(DictItemRepository.list_by_code("gender")) == first_items + assert len(DictRepository.find_list(filters={"code": "gender"})) == 1 + + def test_seed_works_with_dict_utils(self, app): + with app.app_context(): + seed_dict(app) + assert dict_utils.get_name("gender", 1) == "男" + assert dict_utils.get_name("operation_type", 3) == "新增" + assert dict_utils.get_name("article_status", 0) == "正常" + assert dict_utils.get_name("operation_source", "0") == "后台系统" diff --git a/tests/unit/test_modules/test_user_service.py b/tests/unit/test_modules/test_user_service.py new file mode 100644 index 0000000..c7fa760 --- /dev/null +++ b/tests/unit/test_modules/test_user_service.py @@ -0,0 +1,384 @@ +""" +用户服务单元测试(U3 用户模块接入 BaseService) +============================================================= + +验证用户模块重构后的关键契约: + - 新增用户不再触发 salt AttributeError(原 form.salt.data 崩溃点) + - 密码加密 + salt 写入、行政区划拆分到蛇形列 + - 更新时不传密码保留原密码;传密码则重新加密 + - 分页参数统一 pageNo/pageSize(对齐 BaseService.get_page) +""" + +import pytest + +from src.common.utils import excel_utils +from src.core.database import db +from src.core.exceptions import BizError +from src.modules.system.user import service as user_service +from src.modules.system.user.repository import UserRepository +from src.modules.system.user.service import UserService + + +def _payload(username="testuser", password="123456"): + """构造一份合法的用户新增表单数据""" + return { + "username": username, + "realname": "测试用户", + "gender": 1, + "avatar": "avatar.png", + "mobile": "13800000000", + "email": "test@test.com", + "deptId": 1, + "levelId": 1, + "positionId": 1, + "city": ["440000", "440100", "440103", "440106"], + "address": "测试路1号", + "status": 1, + "sort": 1, + "password": password, + } + + +def _create_user(app, username="base_user"): + """经 UserService.add 新增一个用户并返回其ID""" + with app.app_context(): + UserService.add(_payload(username=username)) + db.session.commit() + user = UserRepository.first_by({"username": username}) + return user.id + + +class TestUserAdd: + """新增用户(salt 崩溃点回归)""" + + def test_add_creates_with_encrypted_password_and_city(self, app): + with app.app_context(): + UserService.add(_payload()) + db.session.commit() + user = UserRepository.first_by({"username": "testuser"}) + + assert user is not None + # 密码已加密且 salt 已写入(原实现此处 AttributeError) + assert user.password != "123456" + assert user.salt + # 行政区划拆分到蛇形列 + assert user.province_code == "440000" + assert user.city_code == "440100" + assert user.district_code == "440103" + assert user.street_code == "440106" + + def test_add_invalid_city_raises(self, app): + with app.app_context(): + with pytest.raises(BizError, match="请选择完整的所在城市"): + data = _payload() + data["city"] = ["440000"] + UserService.add(data) + + def test_add_generates_six_digit_code_from_id(self, app): + with app.app_context(): + UserService.add(_payload()) + db.session.commit() + user = UserRepository.first_by({"username": "testuser"}) + + # 编码 = ID 补零为 6 位(如 id=10 -> 000010) + assert user.code == f"{user.id:06d}" + assert len(user.code) == 6 + assert user.code.isdigit() + + +class TestUserUpdate: + """更新用户(密码保留/重设)""" + + def test_update_without_password_keeps_original(self, app): + user_id = _create_user(app) + with app.app_context(): + data = _payload(username="base_user") + data["id"] = user_id + data.pop("password") # 不传新密码 + UserService.update(data) + db.session.commit() + db.session.expire_all() + user = UserRepository.get_by_id(user_id) + + assert user.password != "123456" + assert user.realname == "测试用户" + + def test_update_with_new_password_reencrypts(self, app): + user_id = _create_user(app) + with app.app_context(): + data = _payload(username="base_user", password="newpass888") + data["id"] = user_id + UserService.update(data) + db.session.commit() + db.session.expire_all() + user = UserRepository.get_by_id(user_id) + + assert user.password != "123456" + assert user.password != "newpass888" + assert user.salt + + +class TestReferenceCheck: + """删除前用户引用校验(C4 防孤儿引用)""" + + def test_position_delete_blocked_when_referenced(self, app): + from src.modules.system.position.repository import PositionRepository + from src.modules.system.position.service import PositionService + + with app.app_context(): + pos = PositionRepository.create(name='经理岗', status=1, sort=0) + db.session.commit() + pid = pos.id + + with app.app_context(): + data = _payload(username='ref_user') + data['positionId'] = pid + UserService.add(data) + db.session.commit() + + with app.app_context(): + with pytest.raises(BizError, match="已被用户引用"): + PositionService.remove(str(pid)) + + def test_position_delete_unreferenced_succeeds(self, app): + from src.modules.system.position.repository import PositionRepository + from src.modules.system.position.service import PositionService + + with app.app_context(): + pos = PositionRepository.create(name='临时岗', status=1, sort=0) + db.session.commit() + pid = pos.id + + with app.app_context(): + count = PositionService.remove(str(pid)) + assert count == 1 + + +class TestUserZeroIds: + """deptId/levelId/positionId 允许 0(无部门/无职级/无岗位)""" + + def test_add_with_zero_ids(self, app): + with app.app_context(): + data = _payload(username="zero_ids") + data["deptId"] = 0 + data["levelId"] = 0 + data["positionId"] = 0 + UserService.add(data) + db.session.commit() + user = UserRepository.first_by({"username": "zero_ids"}) + + assert user is not None + assert user.dept_id == 0 + assert user.level_id == 0 + assert user.position_id == 0 + + +class TestUserPage: + """分页参数统一 pageNo/pageSize""" + + def test_page_uses_pageNo_pageSize(self, app): + _create_user(app, "user_a") + _create_user(app, "user_b") + _create_user(app, "user_c") + + with app.app_context(): + data = UserService.get_page({"pageNo": 2, "pageSize": 2}) + + # 3 条数据、每页 2 条 → 第 2 页 1 条 + assert data["total"] == 3 + assert data["size"] == 2 + assert data["current"] == 2 + assert data["pages"] == 2 + assert len(data["records"]) == 1 + + +class TestUserImportExport: + """用户Excel导入/导出(复用 excel_utils)""" + + @pytest.fixture(autouse=True) + def _clear_dict_cache(self): + from src.modules.dictionary import dict_utils + + dict_utils.clear_cache() + yield + dict_utils.clear_cache() + + def _seed_refs(self, app): + """构造部门/职级/岗位/角色 + 性别字典,返回各 ID(避免跨上下文传模型实例)""" + from src.modules.dictionary.dict_item.repository import DictItemRepository + from src.modules.dictionary.dict.repository import DictRepository + from src.modules.system.dept.repository import DeptRepository + from src.modules.system.level.repository import LevelRepository + from src.modules.system.position.repository import PositionRepository + from src.modules.system.role.repository import RoleRepository + + with app.app_context(): + dept = DeptRepository.create(name="研发部", status=1, sort=1) + level = LevelRepository.create(name="高级工程师", status=1, sort=1) + pos = PositionRepository.create(name="后端工程师", status=1, sort=1) + role = RoleRepository.create(name="普通用户", code="normal", status=1, sort=1) + dict_obj = DictRepository.create(name="性别", code="gender", sort=1, create_user="system") + for name, value, sort in [("男", "1", 1), ("女", "2", 2), ("保密", "3", 3)]: + DictItemRepository.create( + dict_id=dict_obj.id, name=name, value=value, + sort=sort, create_user="system", + ) + db.session.commit() + return dept.id, level.id, pos.id, role.id + + def _create_user_with_refs(self, app, dept_id, level_id, pos_id, role_id, username="exp_user", realname="导入导出"): + """经 UserService.add 创建含部门/职级/岗位/角色的用户,用于导出测试""" + with app.app_context(): + data = _payload(username=username) + data.update({ + "realname": realname, + "deptId": dept_id, + "levelId": level_id, + "positionId": pos_id, + "roles": [role_id], + }) + UserService.add(data) + db.session.commit() + + def _patch_upload(self, monkeypatch, tmp_path, file_name="users.xlsx"): + """将上传模拟指向 tmp_path/temp/20260817 目录,返回该文件路径""" + target_dir = tmp_path / "temp" / "20260817" + target_dir.mkdir(parents=True, exist_ok=True) + target = target_dir / file_name + monkeypatch.setattr( + excel_utils.upload, "upload_file", + lambda *a, **k: {"filePath": "/temp/20260817/" + file_name}, + ) + monkeypatch.setattr(excel_utils, "FLASK_UPLOAD_DIR", str(tmp_path)) + return target + + def _run_export(self, app, monkeypatch, params): + """调用导出并捕获表头/行数据(跳过文件落盘)""" + captured = {} + + def fake_save(headers, rows, filename_prefix="export"): + captured["headers"] = headers + captured["rows"] = rows + return "/static/temp/user_xxx.xlsx" + + monkeypatch.setattr(excel_utils, "save_export_file", fake_save) + + with app.app_context(): + result = user_service.export_users(params) + url = result.get_json()["data"] + return captured, url + + # ============================================================ + # 导出 + # ============================================================ + + def test_export_includes_enriched_names_and_url(self, app, monkeypatch): + dept_id, level_id, pos_id, role_id = self._seed_refs(app) + self._create_user_with_refs(app, dept_id, level_id, pos_id, role_id, "exp_user") + + captured, url = self._run_export(app, monkeypatch, None) + + assert url.startswith("/static") + # 表头为导出列定义(含富化后的展示列) + assert captured["headers"] == [ + "用户编码", "登录账号", "用户姓名", "性别", "手机号", "邮箱", + "部门", "职级", "岗位", "用户角色", "状态", "详细地址", "创建时间", + ] + row = captured["rows"][0] + assert len(row[0]) == 6 # 用户编码补零 6 位 + assert row[1] == "exp_user" + assert row[2] == "导入导出" + assert row[3] == "男" # 性别走字典 + assert row[6] == "研发部" # 部门名称 + assert row[7] == "高级工程师" # 职级名称 + assert row[8] == "后端工程师" # 岗位名称 + assert row[9] == "普通用户" # 角色中文名 + assert row[10] == "正常" # 状态展示为中文 + + def test_export_filters_by_realname(self, app, monkeypatch): + dept_id, level_id, pos_id, role_id = self._seed_refs(app) + self._create_user_with_refs(app, dept_id, level_id, pos_id, role_id, "exp_user", realname="张三") + self._create_user_with_refs(app, dept_id, level_id, pos_id, role_id, "other_user", realname="李四") + + captured, _ = self._run_export(app, monkeypatch, {"realname": "张"}) + + assert [r[1] for r in captured["rows"]] == ["exp_user"] + + # ============================================================ + # 导入 + # ============================================================ + + def test_import_creates_users_with_resolved_refs(self, app, monkeypatch, tmp_path): + import shutil + + from openpyxl.workbook import Workbook + + from src.modules.association.user_role.repository import UserRoleRepository + + dept_id, level_id, pos_id, role_id = self._seed_refs(app) + + # 构造真实 xlsx:部门/职级/岗位/角色按名称匹配,密码缺省用默认密码 + src = tmp_path / "users.xlsx" + wb = Workbook() + ws = wb.worksheets[0] + ws.append(["用户ID", "登录账号", "用户姓名", "性别", "手机号", "邮箱", + "部门名称", "职级名称", "岗位名称", "用户角色", "状态", "排序", + "详细地址", "个人简介", "备注", "初始密码"]) + ws.append([None, "imp_user", "导入用户", "男", "13900000000", "imp@test.com", + "研发部", "高级工程师", "后端工程师", "普通用户", "正常", 1, + "测试路3号", "简介", "备注", ""]) + wb.save(src) + + target = self._patch_upload(monkeypatch, tmp_path) + shutil.copy(src, target) + + with app.app_context(): + result = user_service.import_users() + db.session.commit() + user = UserRepository.first_by({"username": "imp_user"}) + + assert result.get_json()["code"] == 0 + assert user is not None + # 编码按 ID 补零 6 位(与 UserService.add 一致) + assert user.code == f"{user.id:06d}" + # 部门/职级/岗位按名称解析为 ID + assert user.dept_id == dept_id + assert user.level_id == level_id + assert user.position_id == pos_id + # 角色按名称关联 + rels = UserRoleRepository.find_list({"user_id": user.id}) + assert [r.role_id for r in rels] == [role_id] + # 密码缺省为默认密码并已加密 + assert user.password != "123456" + assert user.salt + assert not target.exists() # 临时导入文件已清理 + + def test_import_duplicate_username_fails(self, app, monkeypatch, tmp_path): + import shutil + + from openpyxl.workbook import Workbook + + with app.app_context(): + UserService.add(_payload(username="dup_user")) + db.session.commit() + + src = tmp_path / "users.xlsx" + wb = Workbook() + ws = wb.worksheets[0] + ws.append(["用户ID", "登录账号", "用户姓名", "性别", "手机号", "邮箱", + "部门名称", "职级名称", "岗位名称", "用户角色", "状态", "排序", + "详细地址", "个人简介", "备注", "初始密码"]) + ws.append([None, "dup_user", "导入用户", "男", "13900000000", "imp@test.com", + "", "", "", "", "正常", 1, "测试路3号", "", "", ""]) + wb.save(src) + + target = self._patch_upload(monkeypatch, tmp_path) + shutil.copy(src, target) + + with app.app_context(): + result = user_service.import_users() + + body = result.get_json() + assert body["code"] == 1 + assert "dup_user" in body["msg"] + assert "已存在" in body["msg"] diff --git a/ui/.env b/ui/.env index f0472b8..aa0f868 100644 --- a/ui/.env +++ b/ui/.env @@ -2,10 +2,10 @@ VITE_PORT = 8001 # spa-title -VITE_GLOB_APP_TITLE = Django+ElementPlus版 +VITE_GLOB_APP_TITLE = Flask+ElementPlus版 # spa shortname -VITE_GLOB_APP_SHORT_NAME = Django+ElementPlus版 +VITE_GLOB_APP_SHORT_NAME = Flask+ElementPlus版 # 生产环境 开启mock VITE_GLOB_PROD_MOCK = false diff --git a/ui/.env.development b/ui/.env.development index 20713be..5012322 100644 --- a/ui/.env.development +++ b/ui/.env.development @@ -11,7 +11,7 @@ VITE_USE_MOCK = false VITE_DROP_CONSOLE = true # 跨域代理,可以配置多个,请注意不要换行 -VITE_PROXY=[["/api","http://127.0.0.1:8021/"]] +VITE_PROXY=[["/api","http://127.0.0.1:8021/api/v1"]] # API 接口地址 VITE_GLOB_API_URL = diff --git a/ui/README.md b/ui/README.md index 97551ce..9f0c77b 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1 +1 @@ -### Django+ElementPlus版 +### Flask+ElementPlus版 diff --git a/ui/build/constant.ts b/ui/build/constant.ts new file mode 100644 index 0000000..cbcc5f3 --- /dev/null +++ b/ui/build/constant.ts @@ -0,0 +1,6 @@ +/** + * The name of the configuration file entered in the production environment + */ +export const GLOB_CONFIG_FILE_NAME = 'app.config.js'; + +export const OUTPUT_DIR = 'dist'; diff --git a/ui/build/getConfigFileName.ts b/ui/build/getConfigFileName.ts new file mode 100644 index 0000000..255e926 --- /dev/null +++ b/ui/build/getConfigFileName.ts @@ -0,0 +1,12 @@ +/** + * Get the configuration file variable name + * @param env + */ +export const getConfigFileName = (env: Record) => { + const safe = (env.VITE_GLOB_APP_SHORT_NAME || '__APP') + .replace(/[^\w]/g, '_') // 非字母数字→_ + .replace(/_{2,}/g, '_') // 连续 _ 变单个 + .replace(/^_|_$/g, '') // 去掉首尾 _ + .toUpperCase(); + return `__PRODUCTION_${safe}__CONF__`; +}; diff --git a/ui/build/script/buildConf.ts b/ui/build/script/buildConf.ts new file mode 100644 index 0000000..a44a1d8 --- /dev/null +++ b/ui/build/script/buildConf.ts @@ -0,0 +1,44 @@ +/** + * Generate additional configuration files when used for packaging. The file can be configured with some global variables, so that it can be changed directly externally without repackaging + */ +import { GLOB_CONFIG_FILE_NAME, OUTPUT_DIR } from '../constant'; +import fs, { writeFileSync } from 'fs-extra'; +import chalk from 'chalk'; + +import { getRootPath, getEnvConfig } from '../utils'; +import { getConfigFileName } from '../getConfigFileName'; + +import pkg from '../../package.json'; + +function createConfig( + { + configName, + config, + configFileName = GLOB_CONFIG_FILE_NAME, + }: { configName: string; config: any; configFileName?: string } = { configName: '', config: {} } +) { + try { + const windowConf = `window.${configName}`; + // Ensure that the variable will not be modified + const configStr = `${windowConf}=${JSON.stringify(config)}; + Object.freeze(${windowConf}); + Object.defineProperty(window, "${configName}", { + configurable: false, + writable: false, + }); + `.replace(/\s/g, ''); + fs.mkdirp(getRootPath(OUTPUT_DIR)); + writeFileSync(getRootPath(`${OUTPUT_DIR}/${configFileName}`), configStr); + + console.log(chalk.cyan(`✨ [${pkg.name}]`) + ` - configuration file is build successfully:`); + console.log(chalk.gray(OUTPUT_DIR + '/' + chalk.green(configFileName)) + '\n'); + } catch (error) { + console.log(chalk.red('configuration file configuration file failed to package:\n' + error)); + } +} + +export function runBuildConfig() { + const config = getEnvConfig(); + const configFileName = getConfigFileName(config); + createConfig({ config, configName: configFileName }); +} diff --git a/ui/build/script/postBuild.ts b/ui/build/script/postBuild.ts new file mode 100644 index 0000000..79c5e8f --- /dev/null +++ b/ui/build/script/postBuild.ts @@ -0,0 +1,23 @@ +// #!/usr/bin/env node + +import { runBuildConfig } from './buildConf'; +import chalk from 'chalk'; + +import pkg from '../../package.json'; + +export const runBuild = async () => { + try { + const argvList = process.argv.splice(2); + + // Generate configuration file + if (!argvList.includes('disabled-config')) { + await runBuildConfig(); + } + + console.log(`✨ ${chalk.cyan(`[${pkg.name}]`)}` + ' - build successfully!'); + } catch (error) { + console.log(chalk.red('vite build error:\n' + error)); + process.exit(1); + } +}; +runBuild(); diff --git a/ui/build/utils.ts b/ui/build/utils.ts new file mode 100644 index 0000000..a3059f6 --- /dev/null +++ b/ui/build/utils.ts @@ -0,0 +1,71 @@ +import fs from 'fs'; +import path from 'path'; +import dotenv from 'dotenv'; + +export function isDevFn(mode: string): boolean { + return mode === 'development'; +} + +export function isProdFn(mode: string): boolean { + return mode === 'production'; +} + +/** + * Whether to generate package preview + */ +export function isReportMode(): boolean { + return process.env.REPORT === 'true'; +} + +// Read all environment variable configuration files to process.env +export function wrapperEnv(envConf: Recordable): ViteEnv { + const ret: any = {}; + + for (const envName of Object.keys(envConf)) { + let realName = envConf[envName].replace(/\\n/g, '\n'); + realName = realName === 'true' ? true : realName === 'false' ? false : realName; + + if (envName === 'VITE_PORT') { + realName = Number(realName); + } + if (envName === 'VITE_PROXY') { + try { + realName = JSON.parse(realName); + } catch (error) {} + } + ret[envName] = realName; + process.env[envName] = realName; + } + return ret; +} + +/** + * Get the environment variables starting with the specified prefix + * @param match prefix + * @param confFiles ext + */ +export function getEnvConfig(match = 'VITE_GLOB_', confFiles = ['.env', '.env.production']) { + let envConfig = {}; + confFiles.forEach((item) => { + try { + const env = dotenv.parse(fs.readFileSync(path.resolve(process.cwd(), item))); + envConfig = { ...envConfig, ...env }; + } catch (error) {} + }); + + Object.keys(envConfig).forEach((key) => { + const reg = new RegExp(`^(${match})`); + if (!reg.test(key)) { + Reflect.deleteProperty(envConfig, key); + } + }); + return envConfig; +} + +/** + * Get user root directory + * @param dir file path + */ +export function getRootPath(...dir: string[]) { + return path.resolve(process.cwd(), ...dir); +} diff --git a/ui/build/vite/plugin/compress.ts b/ui/build/vite/plugin/compress.ts new file mode 100644 index 0000000..9563679 --- /dev/null +++ b/ui/build/vite/plugin/compress.ts @@ -0,0 +1,35 @@ +/** + * Used to package and output gzip. Note that this does not work properly in Vite, the specific reason is still being investigated + * https://github.com/anncwb/vite-plugin-compression + */ +import type { Plugin } from 'vite'; + +import compressPlugin from 'vite-plugin-compression'; + +export function configCompressPlugin( + compress: 'gzip' | 'brotli' | 'none', + deleteOriginFile = false +): Plugin | Plugin[] { + const compressList = compress.split(','); + + const plugins: Plugin[] = []; + + if (compressList.includes('gzip')) { + plugins.push( + compressPlugin({ + ext: '.gz', + deleteOriginFile, + }) + ); + } + if (compressList.includes('brotli')) { + plugins.push( + compressPlugin({ + ext: '.br', + algorithm: 'brotliCompress', + deleteOriginFile, + }) + ); + } + return plugins; +} diff --git a/ui/build/vite/plugin/html.ts b/ui/build/vite/plugin/html.ts new file mode 100644 index 0000000..ee4d2f8 --- /dev/null +++ b/ui/build/vite/plugin/html.ts @@ -0,0 +1,40 @@ +/** + * Plugin to minimize and use ejs template syntax in index.html. + * https://github.com/anncwb/vite-plugin-html + */ + import type { PluginOption } from 'vite'; + import { createHtmlPlugin } from 'vite-plugin-html'; + import pkg from '../../../package.json'; + import { GLOB_CONFIG_FILE_NAME } from '../../constant'; + + export function configHtmlPlugin(env: ViteEnv, isBuild: boolean) { + const { VITE_GLOB_APP_TITLE, VITE_PUBLIC_PATH } = env; + + const path = VITE_PUBLIC_PATH.endsWith('/') ? VITE_PUBLIC_PATH : `${VITE_PUBLIC_PATH}/`; + + const getAppConfigSrc = () => { + return `${path || '/'}${GLOB_CONFIG_FILE_NAME}?v=${pkg.version}-${new Date().getTime()}`; + }; + + const htmlPlugin: PluginOption[] = createHtmlPlugin({ + minify: isBuild, + inject: { + // Inject data into ejs template + data: { + title: VITE_GLOB_APP_TITLE, + }, + // Embed the generated app.config.js file + tags: isBuild + ? [ + { + tag: 'script', + attrs: { + src: getAppConfigSrc(), + }, + }, + ] + : [], + }, + }); + return htmlPlugin; + } \ No newline at end of file diff --git a/ui/build/vite/plugin/index.ts b/ui/build/vite/plugin/index.ts new file mode 100644 index 0000000..0a7c82b --- /dev/null +++ b/ui/build/vite/plugin/index.ts @@ -0,0 +1,59 @@ +import type { PluginOption } from 'vite'; + +import vue from '@vitejs/plugin-vue'; +import vueJsx from '@vitejs/plugin-vue-jsx'; +import VueSetupExtend from 'vite-plugin-vue-setup-extend'; +// 自动按需导入,根据自身情况选择 +import AutoImport from 'unplugin-auto-import/vite'; +// import Components from 'unplugin-vue-components/vite'; +// import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'; +// import Components from 'unplugin-vue-components/vite'; +// import ElementPlus from 'unplugin-element-plus/vite'; + +import { configHtmlPlugin } from './html'; +import { configMockPlugin } from './mock'; +import { configCompressPlugin } from './compress'; +import { configVisualizerPlugin } from './visualizer'; + +export function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean, prodMock) { + const { VITE_USE_MOCK, VITE_BUILD_COMPRESS, VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE } = viteEnv; + + const vitePlugins: (PluginOption | PluginOption[])[] = [ + // have to + vue(), + // have to + vueJsx(), + VueSetupExtend(), + // ElementPlus({ + // // 引入的样式的类型,可以是css、sass、less等, + // importStyle: 'sass', + // useSource: true, + // }), + // 按需引入element且自动创建组件声明 + AutoImport({ + imports: ['vue', 'vue-router', 'pinia'], // 自动导入的依赖库数组 + dts: './auto-imports.d.ts', // 自动导入类型定义文件路径 + }), + // Components({ + // resolvers: [ElementPlusResolver()], + // }), + ]; + + // vite-plugin-html + vitePlugins.push(configHtmlPlugin(viteEnv, isBuild)); + + // vite-plugin-mock + VITE_USE_MOCK && vitePlugins.push(configMockPlugin(isBuild, prodMock)); + + // rollup-plugin-visualizer + vitePlugins.push(configVisualizerPlugin()); + + if (isBuild) { + // rollup-plugin-gzip + vitePlugins.push( + configCompressPlugin(VITE_BUILD_COMPRESS, VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE), + ); + } + + return vitePlugins; +} diff --git a/ui/build/vite/plugin/mock.ts b/ui/build/vite/plugin/mock.ts new file mode 100644 index 0000000..84bf144 --- /dev/null +++ b/ui/build/vite/plugin/mock.ts @@ -0,0 +1,19 @@ +/** + * Mock plugin for development and production. + * https://github.com/anncwb/vite-plugin-mock + */ +import { viteMockServe } from 'vite-plugin-mock'; + +export function configMockPlugin(isBuild: boolean, prodMock: boolean) { + return viteMockServe({ + ignore: /^\_/, + mockPath: 'mock', + localEnabled: !isBuild, + prodEnabled: isBuild && prodMock, + injectCode: ` + import { setupProdMockServer } from '../mock/_createProductionServer'; + + setupProdMockServer(); + `, + }); +} diff --git a/ui/build/vite/plugin/visualizer.ts b/ui/build/vite/plugin/visualizer.ts new file mode 100644 index 0000000..e57e36b --- /dev/null +++ b/ui/build/vite/plugin/visualizer.ts @@ -0,0 +1,15 @@ +import { PluginOption } from 'vite'; +import visualizer from 'rollup-plugin-visualizer'; +import { isReportMode } from '../../utils'; + +export function configVisualizerPlugin() { + if (isReportMode()) { + return visualizer({ + filename: './node_modules/.cache/visualizer/stats.html', + open: true, + gzipSize: true, + brotliSize: true, + }) as PluginOption; + } + return []; +} diff --git a/ui/build/vite/proxy.ts b/ui/build/vite/proxy.ts new file mode 100644 index 0000000..dc23646 --- /dev/null +++ b/ui/build/vite/proxy.ts @@ -0,0 +1,34 @@ +/** + * Used to parse the .env.development proxy configuration + */ +import type { ProxyOptions } from 'vite'; + +type ProxyItem = [string, string]; + +type ProxyList = ProxyItem[]; + +type ProxyTargetList = Record string }>; + +const httpsRE = /^https:\/\//; + +/** + * Generate proxy + * @param list + */ +export function createProxy(list: ProxyList = []) { + const ret: ProxyTargetList = {}; + for (const [prefix, target] of list) { + const isHttps = httpsRE.test(target); + + // https://github.com/http-party/node-http-proxy#options + ret[prefix] = { + target: target, + changeOrigin: true, + ws: true, + rewrite: (path) => path.replace(new RegExp(`^${prefix}`), ''), + // https is require secure=false + ...(isHttps ? { secure: false } : {}), + }; + } + return ret; +} diff --git a/ui/package.json b/ui/package.json index 894e4c6..3152532 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,10 +1,10 @@ { "name": "djangoadmin", "version": "3.0.0", - "description": "Django+ElementPlus版", + "description": "Flask+ElementPlus版", "license": "Apache-2.0", "author": { - "name": "Django+ElementPlus版", + "name": "Flask+ElementPlus版", "email": "djangoadmin@126.com", "url": "https://www.djangoadmin.cn" }, diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico index 1cfbb66..5f4e189 100644 Binary files a/ui/public/favicon.ico and b/ui/public/favicon.ico differ diff --git a/ui/src/api/common/index.ts b/ui/src/api/common/index.ts index b63cba3..6e86f5d 100644 --- a/ui/src/api/common/index.ts +++ b/ui/src/api/common/index.ts @@ -61,3 +61,13 @@ export function getTemplateByCode(code: any) { }); } +/** + * @description: 发送消息 + */ +export function sendMsg(data) { + return http.request({ + url: '/websocket/sendMsg', + method: 'post', + data, + }); +} diff --git a/ui/src/api/monitor/job.ts b/ui/src/api/monitor/job.ts index 6593885..ac1faf3 100644 --- a/ui/src/api/monitor/job.ts +++ b/ui/src/api/monitor/job.ts @@ -83,7 +83,7 @@ export function jobBatchDelete(data: any) { export function getJobRunOnce(id) { return http.request({ url: '/job/runOnce/' + id, - method: 'get', + method: 'post', }); } @@ -104,7 +104,7 @@ export function setJobStatus(data: any) { export function getJobPause(id) { return http.request({ url: '/job/pause/' + id, - method: 'get', + method: 'put', }); } @@ -114,7 +114,7 @@ export function getJobPause(id) { export function getJobResume(id) { return http.request({ url: '/job/resume/' + id, - method: 'get', + method: 'put', }); } diff --git a/ui/src/api/tool/example.ts b/ui/src/api/tool/example.ts index 457644d..dcb403e 100644 --- a/ui/src/api/tool/example.ts +++ b/ui/src/api/tool/example.ts @@ -1,7 +1,7 @@ import { http } from '@/utils/http/axios'; /** - * @description: 案例分页列表 + * @description: 案例演示分页列表 */ export function getExamplePage(params?) { return http.request({ @@ -12,7 +12,7 @@ export function getExamplePage(params?) { } /** - * 获取全部案例列表 + * 获取全部案例演示列表 * @param params 参数 * @returns 返回结果 */ @@ -25,8 +25,8 @@ export function getExampleList(params?) { } /** - * @description: 根据ID获取案例详情 - * @param id 案例ID + * @description: 根据ID获取案例演示详情 + * @param id 案例演示ID */ export function getExampleDetail(id) { return http.request({ @@ -36,8 +36,8 @@ export function getExampleDetail(id) { } /** - * @description: 添加案例 - * @param data 案例数据 + * @description: 添加案例演示 + * @param data 案例演示数据 */ export function exampleAdd(data: any) { return http.request({ @@ -48,8 +48,8 @@ export function exampleAdd(data: any) { } /** - * @description: 更新案例 - * @param data 案例数据 + * @description: 更新案例演示 + * @param data 案例演示数据 */ export function exampleUpdate(data: any) { return http.request({ @@ -60,8 +60,8 @@ export function exampleUpdate(data: any) { } /** - * @description: 删除案例 - * @param id 案例ID + * @description: 删除案例演示 + * @param id 案例演示ID */ export function exampleDelete(id) { return http.request({ @@ -71,8 +71,8 @@ export function exampleDelete(id) { } /** - * @description: 批量删除案例 - * @param data 案例ID数组 + * @description: 批量删除案例演示 + * @param data 案例演示ID数组 */ export function exampleBatchDelete(data: any) { return http.request({ diff --git a/ui/src/assets/images/logo.png b/ui/src/assets/images/logo.png index 5509337..8728739 100644 Binary files a/ui/src/assets/images/logo.png and b/ui/src/assets/images/logo.png differ diff --git a/ui/src/components/importFile/index.vue b/ui/src/components/importFile/index.vue index fcd8391..88bd2fb 100644 --- a/ui/src/components/importFile/index.vue +++ b/ui/src/components/importFile/index.vue @@ -70,9 +70,9 @@ }, }); const upload = ref(); - const uploadHeaders = reactive({ + const uploadHeaders = computed(() => ({ authorization: 'Bearer ' + useUserStore().getToken, - }); + })); const emit = defineEmits(['success', 'update:visible']); const dialogClose = () => { diff --git a/ui/src/layout/components/Footer/index.vue b/ui/src/layout/components/Footer/index.vue index 0c80a31..d90dc4e 100644 --- a/ui/src/layout/components/Footer/index.vue +++ b/ui/src/layout/components/Footer/index.vue @@ -2,10 +2,10 @@ diff --git a/ui/src/layout/components/Header/AmendPwd.vue b/ui/src/layout/components/Header/AmendPwd.vue index 4cfd37a..fe34307 100644 --- a/ui/src/layout/components/Header/AmendPwd.vue +++ b/ui/src/layout/components/Header/AmendPwd.vue @@ -20,7 +20,7 @@ */ const schemas: FormSchema[] = [ { - field: 'password', + field: 'oldPassword', component: 'Input', type: 'password', label: '旧密码', diff --git a/ui/src/layout/components/Header/index.vue b/ui/src/layout/components/Header/index.vue index affb4df..a92717a 100644 --- a/ui/src/layout/components/Header/index.vue +++ b/ui/src/layout/components/Header/index.vue @@ -12,7 +12,7 @@ > diff --git a/ui/src/layout/components/Logo/index.vue b/ui/src/layout/components/Logo/index.vue index 281432c..20cc342 100644 --- a/ui/src/layout/components/Logo/index.vue +++ b/ui/src/layout/components/Logo/index.vue @@ -9,7 +9,7 @@

Django+ElementPlus版

Flask+ElementPlus版 diff --git a/ui/src/layout/index.vue b/ui/src/layout/index.vue index df57a03..6afd9ce 100644 --- a/ui/src/layout/index.vue +++ b/ui/src/layout/index.vue @@ -74,11 +74,14 @@ - + + diff --git a/ui/src/views/tool/example/columns.ts b/ui/src/views/tool/example/columns.ts index 2ebd58f..7b492b4 100644 --- a/ui/src/views/tool/example/columns.ts +++ b/ui/src/views/tool/example/columns.ts @@ -66,8 +66,8 @@ export const columns = [ // 状态标签渲染 render(record) { const statusMap = { - 1: { text: '正常', type: 'success' }, - 2: { text: '禁用', type: 'danger' }, + 1: { text: '在用', type: 'success' }, + 2: { text: '停用', type: 'danger' }, }; const status = statusMap[record.row.status]; return h( @@ -82,7 +82,7 @@ export const columns = [ }, }, { - label: '排序', + label: '案例排序', prop: 'sort', minWidth: 100, // 普通文本渲染 @@ -97,4 +97,4 @@ export const columns = [ prop: 'createTime', width: 180, }, -]; \ No newline at end of file +]; diff --git a/ui/src/views/tool/example/detail.vue b/ui/src/views/tool/example/detail.vue index 4567680..f8112c0 100644 --- a/ui/src/views/tool/example/detail.vue +++ b/ui/src/views/tool/example/detail.vue @@ -1,7 +1,7 @@