diff --git a/README.en.md b/README.en.md index 68a1fcfa..20e7daa2 100644 --- a/README.en.md +++ b/README.en.md @@ -34,6 +34,15 @@ English | [简体中文](./README.md) > **Design Philosophy**: With modularity and loose coupling at its core, it pursues rich functional modules, simple and easy-to-use interfaces, detailed development documentation, and convenient maintenance methods. By unifying frameworks and components, it reduces the cost of technology selection, follows development specifications and design patterns, builds a powerful code hierarchical model, and comes with comprehensive local language support. It is specifically tailored for team and enterprise development scenarios. +## 📖 Start Here (New Users) + +| I want to… | Go to | +|------------|--------| +| **Run the project locally ASAP** | Section **Quick Start** → **“First-time local setup (in order)”** (env files, migrations, backend + frontend) | +| **See what the project offers** | **Built-in Functional Modules**, **Demo Environment** (credentials) | +| **Extend / plugin development** | **Secondary Development Tutorial**; backend layout and CLI: [**backend/README.md**](backend/README.md) | +| **API docs** | After the backend is up: `http:///docs` (Swagger) or `/redoc` | + ## 🎯 Core Advantages | Advantage | Description | @@ -91,6 +100,10 @@ FastapiAdmin | **Deployment** | Docker / Nginx / Docker Compose | Containerized deployment solution | | **Intelligent Agent Framework** | Langchain / Langgraph | Intelligent agent framework based on Langchain and Langgraph | +## 📐 Backend Conventions (Dates & Serialization) + +With **Pydantic v2** and **PostgreSQL (asyncpg)**, ORM writes expect native Python date/time types; JSON responses need serializable strings. The project uses **`PlainSerializer(..., when_used='json')`** on `DateStr` / `TimeStr` / `DateTimeStr` in `backend/app/core/validator.py`; unified responses use **`jsonable_encoder`** in `backend/app/common/response.py`; when writing to Redis, use **`model_dump(mode='json')`** before `json.dumps`. See [backend/README.md](backend/README.md) for alignment with the root README. + ## 📌 Built-in Functional Modules | Module | Features | Description | @@ -119,16 +132,26 @@ FastapiAdmin ## 🚀 Quick Start +### First-time local setup (in order) + +1. **Install runtimes**: Python ≥ 3.10, Node.js ≥ 20, [pnpm](https://pnpm.io/), local **MySQL or PostgreSQL** (or SQLite if configured in `backend/env/.env.dev`), and **Redis** matching your `.env.dev`. +2. **Clone the repo**: see “Get the Code” below. +3. **Env files**: copy `backend/env/.env.dev.example` → `backend/env/.env.dev`, and `frontend/.env.development.example` → `frontend/.env.development`; fill in **DB, Redis, JWT secret**, etc. Create an empty database first. +4. **Backend deps + migrations**: `cd backend`, run **`uv sync`** (recommended), then **`uv run main.py upgrade --env=dev`** to **apply the schema** (required on first run). +5. **Start backend**: `uv run main.py run --env=dev`. +6. **Frontend**: `cd frontend`, `pnpm install`, `pnpm run dev`. +7. **Browser**: use the URL printed by Vite (port from `VITE_APP_PORT` in `frontend/.env.development`); log in with the admin account (same as [Demo Environment](#-demo-environment) unless you changed seed data). + ### Environment Requirements | Type | Technology Stack | Version | |------|------------------|---------| -| Backend | Python | 3.12 ≥ 3.10 | +| Backend | Python | ≥ 3.10 (3.12 recommended) | | Backend | FastAPI | 0.109+ | | Frontend | Node.js | ≥ 20.0 | | Frontend | Vue3 | 3.3+ | -| Database | MySQL/PostgreSQL | 8.0+/17+ | -| Cache | Redis | 7.0+ | +| Database | MySQL / PostgreSQL / SQLite | As in `backend/env` | +| Cache | Redis | 6.x / 7.x (match `.env`) | ### Get the Code @@ -145,45 +168,47 @@ git clone https://github.com/fastapiadmin/FastapiAdmin.git ### Backend Setup -#### Using uv to manage the project (Recommended) +#### Using uv (recommended, matches `backend/pyproject.toml`) ```bash -# Navigate to the backend directory cd backend -# Install dependencies using uv -uv add -r requirements.txt -# Start the backend service: ensure that MySQL and Redis are running -uv run main.py run -# Or specify environment -uv run main.py run --env=dev or --env=prod +uv sync +uv run main.py upgrade --env=dev +uv run main.py run --env=dev +# uv run main.py run --env=prod ``` -#### Using traditional pip method +> Without `uv`: `pip install -r requirements.txt`, then `python main.py upgrade --env=dev` and `python main.py run --env=dev`. + +#### Using pip / venv ```bash -# Navigate to the backend directory cd backend -# Install dependencies -pip3 install -r requirements.txt -# Start the backend service: ensure that MySQL and Redis are running -python main.py run -# Or specify environment -python main.py run --env=dev or --env=prod +python -m venv .venv +# Windows: .venv\Scripts\activate +# macOS/Linux: source .venv/bin/activate +pip install -r requirements.txt +python main.py upgrade --env=dev +python main.py run --env=dev ``` ### Frontend Setup ```bash -# Navigate to the frontend directory cd frontend -# Install dependencies pnpm install -# Start the development server pnpm run dev -# Build for production pnpm run build ``` +### After startup + +| Service | Notes | +|---------|--------| +| Backend API | Default often `http://127.0.0.1:8000` (check logs and `backend/env/.env.dev`) | +| Swagger | `http:///docs` | +| Web UI | URL printed by Vite (`VITE_APP_PORT` in `frontend/.env.development`) | + ### 🐳 Docker Deployment #### Method 1: Execute script inside project (Recommended) @@ -474,7 +499,7 @@ A: Use the command `python main.py revision --env=dev` to generate migration fil A: Use the command `python main.py upgrade --env=dev` to apply migrations. #### Q: How to start the development server? -A: Use the command `python main.py run --env=dev` to start the development server. +A: From `backend`, run `uv run main.py run --env=dev` (or `python main.py run --env=dev`). On **first run**, install dependencies and run `upgrade` migrations first—see **“First-time local setup”** above. #### Q: How to build the frontend production version? A: Use the command `pnpm run build` to build the frontend production version. diff --git a/README.md b/README.md index 92f932c2..660be730 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,15 @@ > **设计初心**: 以模块化、松耦合为核心,追求丰富的功能模块、简洁易用的接口、详尽的开发文档和便捷的维护方式。通过统一框架和组件,降低技术选型成本,遵循开发规范和设计模式,构建强大的代码分层模型,搭配完善的本地中文化支持,专为团队和企业开发场景量身定制。 +## 📖 新手从这儿开始 + +| 你想…… | 去看 | +|--------|------| +| **最快在本地跑起来** | 下文 **「快速开始」** → **「第一次本地运行(按顺序)」**(含复制环境文件、迁移、启动前后端) | +| **先了解项目能做什么** | **「内置功能模块」**、**「演示环境」**(账号密码) | +| **做二次开发 / 插件** | **「二开教程」**;后端目录与命令见 [**backend/README.md**](backend/README.md) | +| **接口文档** | 后端启动后浏览器打开 `http://<后端地址>/docs`(Swagger)或 `/redoc` | + ## 🎯 核心优势 | 优势 | 描述 | @@ -91,6 +100,10 @@ FastapiAdmin | **部署** | Docker / Nginx / Docker Compose | 容器化部署方案 | | **智能体框架** | Langchain / Langgraph | 基于Langchain和Langgraph的智能体框架 | +## 📐 后端约定(日期与序列化) + +使用 **Pydantic v2** 与 **PostgreSQL(asyncpg)** 时:ORM 写入需要 Python 原生日期时间,JSON 输出需要可序列化字符串。项目通过 `DateStr` / `TimeStr` / `DateTimeStr`(`backend/app/core/validator.py`)的 **`PlainSerializer(..., when_used='json')`** 区分两种场景;统一响应见 `backend/app/common/response.py` 中的 **`jsonable_encoder`**;写入 Redis 时请使用 **`model_dump(mode='json')`** 再序列化。细节见 [backend/README.md](backend/README.md) 中与根文档一致的说明。 + ## 📌 内置功能模块 | 模块 | 功能 | 描述 | @@ -121,16 +134,26 @@ FastapiAdmin ## 🚀 快速开始 +### 第一次本地运行(按顺序) + +1. **安装运行时**:Python ≥ 3.10、Node.js ≥ 20、[pnpm](https://pnpm.io/zh/)(前端包管理)、本机 **MySQL 或 PostgreSQL**(或改用 SQLite 需在 `backend/env/.env.dev` 中配置)、**Redis**(与 `.env.dev` 中一致)。 +2. **获取代码**:见下方「获取代码」。 +3. **配置环境变量**:将 `backend/env/.env.dev.example` 复制为 `backend/env/.env.dev`,将 `frontend/.env.development.example` 复制为 `frontend/.env.development`,按注释填写 **数据库连接、Redis、JWT 密钥** 等(须先在本机创建空数据库)。 +4. **安装后端依赖并迁移**:进入 `backend`,推荐使用 `uv sync`(见下);然后执行 **`uv run main.py upgrade --env=dev`**(或 `python main.py upgrade --env=dev`)**应用数据库表结构**;首次部署不可跳过。 +5. **启动后端**:`uv run main.py run --env=dev`(默认开发环境)。 +6. **安装前端依赖并启动**:进入 `frontend` 执行 `pnpm install` 与 `pnpm run dev`。 +7. **打开浏览器**:前端地址见终端输出(端口由 `frontend/.env.development` 中 `VITE_APP_PORT` 决定);使用管理员账号登录(与 [演示环境](#-演示环境) 一致,若你导入的是初始 SQL 则以后台为准)。 + ### 环境要求 | 类型 | 技术栈 | 版本 | |------|--------|------| -| 后端 | Python | 3.12 ≥ 3.10 | +| 后端 | Python | ≥ 3.10(推荐 3.12) | | 后端 | FastAPI | 0.109+ | | 前端 | Node.js | ≥ 20.0 | | 前端 | Vue3 | 3.3+ | -| 数据库 | MySQL/PostgreSQL | 8.0+/17+ | -| 缓存 | Redis | 7.0+ | +| 数据库 | MySQL / PostgreSQL / SQLite | 见 `backend/env` 配置 | +| 缓存 | Redis | 建议 6.x / 7.x(与 `.env` 一致) | ### 获取代码 @@ -147,45 +170,52 @@ git clone https://github.com/fastapiadmin/FastapiAdmin.git ### 后端启动 -#### 使用 uv 管理项目(推荐) +#### 使用 uv(推荐,与 `backend/pyproject.toml` 一致) ```bash -# 进入后端工程目录 cd backend -# 使用 uv 安装依赖 -uv add -r requirements.txt -# 启动后端服务:启动之前保证mysql中创建好了数据库、redis服务 -uv run main.py run -# 或指定环境 -uv run main.py run --env=dev or --env=prod +# 创建虚拟环境并安装依赖(等价于根据 pyproject 安装) +uv sync +# 首次或模型变更后:应用数据库迁移(不可省略) +uv run main.py upgrade --env=dev +# 启动:请先保证数据库已创建、Redis 已启动且与 .env.dev 一致 +uv run main.py run --env=dev +# 生产环境示例 +# uv run main.py run --env=prod ``` -#### 使用传统 pip 方式 +> 若未使用 `uv`,也可用 `pip install -r requirements.txt` 安装依赖,再用 `python main.py upgrade --env=dev` 与 `python main.py run --env=dev`。 + +#### 使用传统 pip / venv ```bash -# 进入后端工程目录 cd backend -# 安装依赖 -pip3 install -r requirements.txt -# 启动后端服务:启动之前保证mysql中创建好了数据库、redis服务 -python main.py run -# 或指定环境 -python main.py run --env=dev or --env=prod +python -m venv .venv +# Windows: .venv\Scripts\activate +# macOS/Linux: source .venv/bin/activate +pip install -r requirements.txt +python main.py upgrade --env=dev +python main.py run --env=dev ``` ### 前端启动 ```bash -# 进入前端工程目录 cd frontend -# 安装依赖 pnpm install -# 启动开发服务器 pnpm run dev # 构建生产版本 pnpm run build ``` +### 启动后访问 + +| 服务 | 说明 | +|------|------| +| 后端 API | 默认 `http://127.0.0.1:8000`(具体端口以启动日志与 `backend/env/.env.dev` 为准) | +| Swagger | `http://<后端地址>/docs` | +| 前端 Web | 终端中 Vite 输出的本地地址(端口见 `frontend/.env.development` 的 `VITE_APP_PORT`) | + ### 🐳 Docker 部署 #### 方式一:脚本放在项目内执行(推荐) @@ -463,7 +493,7 @@ A:使用 `python main.py revision --env=dev` 命令生成迁移文件。 A:使用 `python main.py upgrade --env=dev` 命令应用迁移。 #### Q:如何启动开发服务器? -A:使用 `python main.py run --env=dev` 命令启动开发服务器。 +A:在 `backend` 目录执行 `uv run main.py run --env=dev`(或 `python main.py run --env=dev`)。**首次**须先完成依赖安装与 `upgrade` 迁移,见上文 **「第一次本地运行」**。 #### Q:如何构建前端生产版本? A:使用 `pnpm run build` 命令构建前端生产版本。 diff --git a/backend/README.md b/backend/README.md index ae218021..90a72016 100644 --- a/backend/README.md +++ b/backend/README.md @@ -2,6 +2,8 @@ 一个基于 FastAPI 框架构建企业级后端架构解决方案,为前端 Vue3 管理系统提供完整的 API 服务支持。 +> **和仓库根目录文档的关系**:**一键前后端启动、演示账号、Docker 部署、新手导航** 请以仓库根目录 [**README.md**](../README.md)(英文 [**README.en.md**](../README.en.md))为准;**本文档**侧重 `backend/` 目录结构、迁移命令与开发约定。 + ## 🚀 项目特性 - **现代技术栈**: FastAPI + SQLAlchemy 2.0 + Pydantic 2.x @@ -87,72 +89,68 @@ module_*/ ### 环境要求 - **Python**: 3.10+ -- **数据库**: MySQL 8.0+ / PostgreSQL 13+ / SQLite 3.x -- **Redis**: 6.0+ (可选) +- **数据库**: MySQL 8.0+ / PostgreSQL 13+ / SQLite 3.x(连接串在 `env/.env.dev`) +- **Redis**: 与 `.env.dev` 中配置一致(多数场景为必需) -#### 1. 数据库初始化 +### 第一次在本机跑起来( checklist ) + +1. 复制 `env/.env.dev.example` → `env/.env.dev`,填写数据库、Redis 等(先在 DB 中建好空库)。 +2. 在 **`backend/` 目录下** 安装依赖:推荐 **`uv sync`**;或 `pip install -r requirements.txt`。 +3. **应用迁移**:`uv run main.py upgrade --env=dev`(或 `python main.py upgrade --env=dev`)。首次运行不可跳过。 +4. **启动**:`uv run main.py run --env=dev`。接口文档:`http://:/docs`。 + +### 数据库迁移命令 ```bash -# 生成迁移文件(仅首次或模型变更时) -python main.py revision --env=dev(不加默认为dev) +# 生成迁移文件(模型变更时) +python main.py revision --env=dev +# 应用迁移(首次启动、拉代码后必做) +python main.py upgrade --env=dev -# 应用数据库迁移 -python main.py upgrade --env=dev(不加默认为dev) - -# 如果是uv管理管理python则是 -uv run main.py revision --env=dev(不加默认为dev) -uv run main.py upgrade --env=dev(不加默认为dev) +# 使用 uv 时 +uv run main.py revision --env=dev +uv run main.py upgrade --env=dev ``` -#### 2. 启动服务 +### 安装依赖与启动服务 ```bash -# 创建虚拟环境 +# 虚拟环境(可选) python -m venv .venv -# 激活虚拟环境 -# Windows -.venv\Scripts\activate -# macOS/Linux -source .venv/bin/activate +# Windows: .venv\Scripts\activate +# macOS/Linux: source .venv/bin/activate -# 如果是uv管理管理python则是 -uv venv (默认创建.venv) - - -# 安装依赖 -pip install -r requirements.txt -# 如果是uv管理管理python则是 -uv add -r requirements.txt -或 +# 依赖:推荐 uv(与 pyproject.toml 一致) uv sync -# 开发环境启动 -python main.py run --env=dev (不加默认为dev) +# 或 pip +# pip install -r requirements.txt -# 生产环境启动 -python main.py run --env=prod (不加默认为dev) +# 开发环境 +uv run main.py run --env=dev +# 或 python main.py run --env=dev -# 如果是uv管理管理python则是 -uv run main.py run --env=dev (不加默认为dev) -uv run main.py run --env=prod (不加默认为dev) +# 生产环境示例 +# uv run main.py run --env=prod ``` -#### 3.代码格式化 +### 代码格式化(ruff) ```bash -# 检查当前目录所有 Python 文件 ruff check -# 检查并自动修复问题 ruff check --fix -# 监听文件变化并重新检查 ruff check --watch -# 如果是uv管理管理python则是 +# 使用 uv 时 uv run ruff check uv run ruff check --fix uv run ruff check --watch ``` +### 日期类型与 PostgreSQL(asyncpg) + +自定义 `DateStr` / `TimeStr` / `DateTimeStr`(`app/core/validator.py`)使用 **`PlainSerializer(..., when_used='json')`**:`model_dump(mode='python')` 供 ORM 使用原生类型;JSON / Redis 使用 `model_dump(mode='json')`。统一 HTTP 响应见 `app/common/response.py`。详见根目录 README。 + ## 📜 相关链接 - **FastAPI 官方文档**: [https://fastapi.tiangolo.com/](https://fastapi.tiangolo.com/) diff --git a/backend/app/api/v1/module_system/dict/service.py b/backend/app/api/v1/module_system/dict/service.py index c24e6566..f62eaca8 100644 --- a/backend/app/api/v1/module_system/dict/service.py +++ b/backend/app/api/v1/module_system/dict/service.py @@ -165,7 +165,7 @@ class DictTypeService: search={"dict_type": data.dict_type} ) dict_data = [ - DictDataOutSchema.model_validate(row).model_dump() for row in dict_data_list if row + DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in dict_data_list if row ] value = json.dumps(dict_data, ensure_ascii=False) @@ -336,7 +336,7 @@ class DictDataService: search={"dict_type": dict_type} ) dict_data = [ - DictDataOutSchema.model_validate(row).model_dump() + DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in dict_data_list if row ] @@ -438,7 +438,7 @@ class DictDataService: search={"dict_type": data.dict_type} ) dict_data = [ - DictDataOutSchema.model_validate(row).model_dump() for row in dict_data_list if row + DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in dict_data_list if row ] value = json.dumps(dict_data, ensure_ascii=False) @@ -507,7 +507,7 @@ class DictDataService: search={"dict_type": dict_type.dict_type} ) dict_data = [ - DictDataOutSchema.model_validate(row).model_dump() + DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in dict_data_list if row ] @@ -527,7 +527,7 @@ class DictDataService: search={"dict_type": data.dict_type} ) dict_data = [ - DictDataOutSchema.model_validate(row).model_dump() for row in dict_data_list if row + DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in dict_data_list if row ] value = json.dumps(dict_data, ensure_ascii=False) diff --git a/backend/app/api/v1/module_system/params/service.py b/backend/app/api/v1/module_system/params/service.py index 6cf9c3a3..00ecccd4 100644 --- a/backend/app/api/v1/module_system/params/service.py +++ b/backend/app/api/v1/module_system/params/service.py @@ -166,12 +166,14 @@ class ParamsService: new_obj = await ParamsCRUD(auth).update_obj_crud(id=id, data=data) if not new_obj: raise CustomException(msg="更新失败,系统配置不存在") - new_obj_dict = ParamsOutSchema.model_validate(new_obj).model_dump() + out = ParamsOutSchema.model_validate(new_obj) + new_obj_dict = out.model_dump() + redis_payload = out.model_dump(mode="json") # 同步redis redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{new_obj.config_key}" try: - value = json.dumps(new_obj_dict, ensure_ascii=False) + value = json.dumps(redis_payload, ensure_ascii=False) result = await RedisCURD(redis).set( key=redis_key, value=value, @@ -306,8 +308,10 @@ class ParamsService: # 保存到Redis并设置过期时间 for config in config_obj: redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{config.config_key}" - config_obj_dict = ParamsOutSchema.model_validate(config).model_dump() - value = json.dumps(config_obj_dict, ensure_ascii=False) + out = ParamsOutSchema.model_validate(config) + config_obj_dict = out.model_dump() + redis_payload = out.model_dump(mode="json") + value = json.dumps(redis_payload, ensure_ascii=False) result = await RedisCURD(redis).set( key=redis_key, value=value, diff --git a/backend/app/api/v1/module_system/tenant/controlller.py b/backend/app/api/v1/module_system/tenant/controlller.py index 345a6ae4..86271244 100644 --- a/backend/app/api/v1/module_system/tenant/controlller.py +++ b/backend/app/api/v1/module_system/tenant/controlller.py @@ -1,4 +1,3 @@ -# # -*- coding: utf-8 -*- # from fastapi import APIRouter, Body, Depends, Path, UploadFile # from fastapi.responses import JSONResponse, StreamingResponse @@ -30,11 +29,11 @@ # ) -> JSONResponse: # """ # 获取租户详情 - + # 参数: # - id (int): 租户ID # - auth (AuthSchema): 认证信息模型 - + # 返回: # - JSONResponse: 包含租户详情的JSON响应 # """ @@ -50,21 +49,21 @@ # ) -> JSONResponse: # """ # 查询租户列表 - + # 参数: # - page (PaginationQueryParam): 分页查询参数 # - search (TenantQueryParam): 查询参数 # - auth (AuthSchema): 认证信息模型 - + # 返回: # - JSONResponse: 包含租户列表分页信息的JSON响应 # """ # # 使用数据库分页而不是应用层分页 # result_dict = await TenantService.page_service( -# auth=auth, -# page_no=page.page_no if page.page_no is not None else 1, -# page_size=page.page_size if page.page_size is not None else 10, -# search=search, +# auth=auth, +# page_no=page.page_no if page.page_no is not None else 1, +# page_size=page.page_size if page.page_size is not None else 10, +# search=search, # order_by=page.order_by # ) # log.info("查询租户列表成功") @@ -77,11 +76,11 @@ # ) -> JSONResponse: # """ # 创建租户 - + # 参数: # - data (TenantCreateSchema): 租户创建模型 # - auth (AuthSchema): 认证信息模型 - + # 返回: # - JSONResponse: 包含创建租户详情的JSON响应 # """ @@ -97,12 +96,12 @@ # ) -> JSONResponse: # """ # 修改租户 - + # 参数: # - data (TenantUpdateSchema): 租户更新模型 # - id (int): 租户ID # - auth (AuthSchema): 认证信息模型 - + # 返回: # - JSONResponse: 包含修改租户详情的JSON响应 # """ @@ -117,11 +116,11 @@ # ) -> JSONResponse: # """ # 删除租户 - + # 参数: # - ids (list[int]): 租户ID列表 # - auth (AuthSchema): 认证信息模型 - + # 返回: # - JSONResponse: 包含删除租户详情的JSON响应 # """ @@ -136,11 +135,11 @@ # ) -> JSONResponse: # """ # 批量修改租户状态 - + # 参数: # - data (BatchSetAvailable): 批量修改租户状态模型 # - auth (AuthSchema): 认证信息模型 - + # 返回: # - JSONResponse: 包含批量修改租户状态详情的JSON响应 # """ @@ -155,11 +154,11 @@ # ) -> StreamingResponse: # """ # 导出租户 - + # 参数: # - search (TenantQueryParam): 查询参数 # - auth (AuthSchema): 认证信息模型 - + # 返回: # - StreamingResponse: 包含租户列表的Excel文件流响应 # """ @@ -182,11 +181,11 @@ # ) -> JSONResponse: # """ # 导入租户 - + # 参数: # - file (UploadFile): 导入的Excel文件 # - auth (AuthSchema): 认证信息模型 - + # 返回: # - JSONResponse: 包含导入租户详情的JSON响应 # """ @@ -198,7 +197,7 @@ # async def export_obj_template_controller() -> StreamingResponse: # """ # 获取租户导入模板 - + # 返回: # - StreamingResponse: 包含租户导入模板的Excel文件流响应 # """ @@ -212,4 +211,4 @@ # 'Content-Disposition': f'attachment; filename={urllib.parse.quote("租户导入模板.xlsx")}', # 'Access-Control-Expose-Headers': 'Content-Disposition' # } -# ) \ No newline at end of file +# ) diff --git a/backend/app/api/v1/module_system/tenant/crud.py b/backend/app/api/v1/module_system/tenant/crud.py index cb40ee24..3e16103a 100644 --- a/backend/app/api/v1/module_system/tenant/crud.py +++ b/backend/app/api/v1/module_system/tenant/crud.py @@ -1,4 +1,3 @@ -# # -*- coding: utf-8 -*- # from typing import Dict, List, Optional, Sequence, Union, Any @@ -15,106 +14,106 @@ # def __init__(self, auth: AuthSchema) -> None: # """ # 初始化CRUD数据层 - + # 参数: # - auth (AuthSchema): 认证信息模型 # """ # super().__init__(model=TenantModel, auth=auth) - + # async def get_by_id_crud(self, id: int, preload: Optional[List[Union[str, Any]]] = None) -> Optional[TenantModel]: # """ # 详情 - + # 参数: # - id (int): 租户ID # - preload (Optional[List[Union[str, Any]]]): 预加载关系,未提供时使用模型默认项 - + # 返回: # - Optional[TenantModel]: 租户模型实例或None # """ # return await self.get(id=id, preload=preload) - + # async def list_crud(self, search: Optional[Dict] = None, order_by: Optional[List[Dict[str, str]]] = None, preload: Optional[List[Union[str, Any]]] = None) -> Sequence[TenantModel]: # """ # 列表查询 - + # 参数: # - search (Optional[Dict]): 查询参数 # - order_by (Optional[List[Dict[str, str]]]): 排序参数 # - preload (Optional[List[Union[str, Any]]]): 预加载关系,未提供时使用模型默认项 - + # 返回: # - Sequence[TenantModel]: 租户模型实例序列 # """ # return await self.list(search=search, order_by=order_by, preload=preload) - + # async def create_crud(self, data: TenantCreateSchema) -> Optional[TenantModel]: # """ # 创建 - + # 参数: # - data (TenantCreateSchema): 租户创建模型 - + # 返回: # - Optional[TenantModel]: 租户模型实例或None # """ # return await self.create(data=data) - + # async def update_crud(self, id: int, data: TenantUpdateSchema) -> Optional[TenantModel]: # """ # 更新 - + # 参数: # - id (int): 租户ID # - data (TenantUpdateSchema): 租户更新模型 - + # 返回: # - Optional[TenantModel]: 租户模型实例或None # """ # return await self.update(id=id, data=data) - + # async def delete_crud(self, ids: List[int]) -> None: # """ # 批量删除 - + # 参数: # - ids (List[int]): 租户ID列表 - + # 返回: # - None # """ # return await self.delete(ids=ids) - + # async def set_available_crud(self, ids: List[int], status: str) -> None: # """ # 批量设置可用状态 - + # 参数: # - ids (List[int]): 租户ID列表 # - status (bool): 可用状态 - + # 返回: # - None # """ # return await self.set(ids=ids, status=status) - + # async def page_crud(self, offset: int, limit: int, order_by: Optional[List[Dict[str, str]]] = None, search: Optional[Dict] = None, preload: Optional[List[Union[str, Any]]] = None) -> Dict: # """ # 分页查询 - + # 参数: # - offset (int): 偏移量 # - limit (int): 每页数量 # - order_by (Optional[List[Dict[str, str]]]): 排序参数 # - search (Optional[Dict]): 查询参数 # - preload (Optional[List[Union[str, Any]]]): 预加载关系,未提供时使用模型默认项 - + # 返回: # - Dict: 分页数据 # """ # order_by_list = order_by or [{'id': 'asc'}] # search_dict = search or {} - + # return await self.page( # offset=offset, # limit=limit, diff --git a/backend/app/api/v1/module_system/tenant/model.py b/backend/app/api/v1/module_system/tenant/model.py index 7c03edeb..db683ffc 100644 --- a/backend/app/api/v1/module_system/tenant/model.py +++ b/backend/app/api/v1/module_system/tenant/model.py @@ -1,4 +1,3 @@ -# # -*- coding: utf-8 -*- # from datetime import datetime # from sqlalchemy import DateTime, String @@ -10,12 +9,12 @@ # class TenantModel(ModelMixin): # """ # 租户模型 - + # 核心数据隔离模型: # - 系统租户(id=1):管理所有租户和系统配置,由平台超管管理 # - 普通租户(id>1):拥有自己的用户、部门、角色、客户等数据,租户间完全隔离 # - 所有业务表通过tenant_id字段关联到租户,实现租户间数据隔离 - + # 注意: # - 租户表本身不需要tenant_id字段(租户不属于租户) # - 租户表不需要customer_id字段(租户不属于客户) @@ -28,14 +27,14 @@ # code: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, comment='租户编码') # start_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None, comment='开始时间') # end_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None, comment='结束时间') - + # @validates('name') # def validate_name(self, key: str, name: str) -> str: # """验证名称不为空""" # if not name or not name.strip(): # raise ValueError('名称不能为空') # return name - + # @validates('code') # def validate_code(self, key: str, code: str) -> str: # """验证编码格式校验""" diff --git a/backend/app/api/v1/module_system/tenant/schema.py b/backend/app/api/v1/module_system/tenant/schema.py index 45213063..70080541 100644 --- a/backend/app/api/v1/module_system/tenant/schema.py +++ b/backend/app/api/v1/module_system/tenant/schema.py @@ -1,4 +1,3 @@ -# # -*- coding: utf-8 -*- # from typing import Optional # from fastapi import Query @@ -17,8 +16,8 @@ # description: Optional[str] = Field(default=None, max_length=255, description="描述") # start_time: Optional[DateTimeStr] = Field(default=None, description="开始时间") # end_time: Optional[DateTimeStr] = Field(default=None, description="结束时间") - -# @field_validator('name') + +# @field_validator('name') # @classmethod # def _validate_name(cls, v: str) -> str: # v = v.strip() @@ -37,7 +36,7 @@ # # 格式校验:名称只能包含字母、数字、下划线和中划线 # if not self.name.isalnum() and not all(c in '-_' for c in self.name): # raise ValueError('名称只能包含字母、数字、下划线和中划线') - + # return self @@ -60,7 +59,7 @@ # status: Optional[str] = Query(None, description="状态用(True:启用 False:禁用)"), # created_time: Optional[list[DateTimeStr]] = Query(None, description="创建时间范围", example=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]), # ) -> None: - + # # 模糊查询字段 # self.name = ("like", name) @@ -70,4 +69,3 @@ # # 时间范围查询 # if created_time and len(created_time) == 2: # self.created_time = ("between", (created_time[0], created_time[1])) - diff --git a/backend/app/api/v1/module_system/tenant/service.py b/backend/app/api/v1/module_system/tenant/service.py index 211976b5..e69f6298 100644 --- a/backend/app/api/v1/module_system/tenant/service.py +++ b/backend/app/api/v1/module_system/tenant/service.py @@ -1,4 +1,3 @@ -# # -*- coding: utf-8 -*- # import io # import random @@ -24,64 +23,64 @@ # """ # 租户管理模块服务层 # """ - + # @classmethod # async def detail_service(cls, auth: AuthSchema, id: int) -> Dict: # """ # 详情 - + # 参数: # - auth (AuthSchema): 认证信息模型 # - id (int): 租户ID - + # 返回: # - Dict: 租户模型实例字典 # """ # obj = await TenantCRUD(auth).get_by_id_crud(id=id) # if not obj: # raise CustomException(msg="该数据不存在") - + # # 获取租户详情基础数据 # result = TenantOutSchema.model_validate(obj).model_dump() - + # return result - + # @classmethod # async def list_service(cls, auth: AuthSchema, search: Optional[TenantQueryParam] = None, order_by: Optional[List[Dict[str, str]]] = None) -> List[Dict]: # """ # 列表查询 - + # 参数: # - auth (AuthSchema): 认证信息模型 # - search (Optional[TenantQueryParam]): 查询参数 # - order_by (Optional[List[Dict[str, str]]]): 排序参数 - + # 返回: # - List[Dict]: 租户模型实例字典列表 # """ # search_dict = search.__dict__ if search else None # obj_list = await TenantCRUD(auth).list_crud(search=search_dict, order_by=order_by) # return [TenantOutSchema.model_validate(obj).model_dump() for obj in obj_list] - + # @classmethod # async def page_service(cls, auth: AuthSchema, page_no: int, page_size: int, search: Optional[TenantQueryParam] = None, order_by: Optional[List[Dict[str, str]]] = None) -> Dict: # """ # 分页查询 - + # 参数: # - auth (AuthSchema): 认证信息模型 # - page_no (int): 页码 # - page_size (int): 每页数量 # - search (Optional[TenantQueryParam]): 查询参数 # - order_by (Optional[List[Dict[str, str]]]): 排序参数 - + # 返回: # - Dict: 分页数据 # """ # search_dict = search.__dict__ if search else {} # order_by_list = order_by or [{'id': 'asc'}] # offset = (page_no - 1) * page_size - + # result = await TenantCRUD(auth).page_crud( # offset=offset, # limit=page_size, @@ -89,16 +88,16 @@ # search=search_dict # ) # return result - + # @classmethod # async def create_service(cls, auth: AuthSchema, data: TenantCreateSchema) -> Dict: # """ # 创建 - + # 参数: # - auth (AuthSchema): 认证信息模型 # - data (TenantCreateSchema): 租户创建模型 - + # 返回: # - Dict: 租户模型实例字典 # """ @@ -111,33 +110,33 @@ # # 创建租户 # tenant_obj = await TenantCRUD(auth).create_crud(data=data) - + # # 自动创建租户初始管理员用户 # await cls._create_tenant_admin_user(auth, tenant_obj) - + # return TenantOutSchema.model_validate(tenant_obj).model_dump() - + # @classmethod # async def _create_tenant_admin_user(cls, auth: AuthSchema, tenant_obj) -> None: # """ # 为新创建的租户自动创建初始管理员用户 - + # 参数: # - auth (AuthSchema): 认证信息模型 # - tenant_obj: 租户对象 - + # 返回: # - None # """ # try: # # 生成初始管理员用户名(使用租户编码) # username = f"{tenant_obj.code}_admin" - + # # 生成随机密码 # password_length = 12 # characters = string.ascii_letters + string.digits + "!@#$%^&*" # password = ''.join(random.choice(characters) for _ in range(password_length)) - + # # 创建管理员用户数据 # admin_user_data = { # "username": username, @@ -148,28 +147,28 @@ # "status": True, # "created_id": auth.user.id if auth.user else None # } - + # # 创建用户 # new_user = await UserCRUD(auth).create(data=admin_user_data) - + # # 记录日志,包含临时密码信息(仅开发环境记录,生产环境应避免) # log.info(f"为租户[{tenant_obj.name}]创建初始管理员用户成功,用户名: {username},临时密码: {password}") - + # except Exception as e: # log.error(f"为租户[{tenant_obj.name}]创建初始管理员用户失败: {str(e)}") # # 不中断租户创建流程,仅记录错误 # pass - + # @classmethod # async def update_service(cls, auth: AuthSchema, id: int, data: TenantUpdateSchema) -> Dict: # """ # 更新 - + # 参数: # - auth (AuthSchema): 认证信息模型 # - id (int): 租户ID # - data (TenantUpdateSchema): 租户更新模型 - + # 返回: # - Dict: 租户模型实例字典 # """ @@ -177,81 +176,81 @@ # if id == 1: # obj = await TenantCRUD(auth).update_crud(id=id, data=data) # log.info(f"系统租户配额设置已更新") - + # return TenantOutSchema.model_validate(obj).model_dump() - + # # 检查数据是否存在 # obj = await TenantCRUD(auth).get_by_id_crud(id=id) # if not obj: # raise CustomException(msg='更新失败,该数据不存在') - + # # 检查名称是否重复 # exist_obj = await TenantCRUD(auth).get(name=data.name) # if exist_obj and exist_obj.id != id: # raise CustomException(msg='更新失败,名称重复') - + # obj = await TenantCRUD(auth).update_crud(id=id, data=data) # return TenantOutSchema.model_validate(obj).model_dump() - + # @classmethod # async def delete_service(cls, auth: AuthSchema, ids: List[int]) -> None: # """ # 删除 - + # 参数: # - auth (AuthSchema): 认证信息模型 # - ids (List[int]): 租户ID列表 - + # 返回: # - None # """ # if len(ids) < 1: # raise CustomException(msg='删除失败,删除对象不能为空') - + # # 系统租户保护:不允许删除系统租户(id=1) # if 1 in ids: # raise CustomException(msg='系统租户不允许删除') - + # # 检查所有要删除的数据是否存在 # for id in ids: # obj = await TenantCRUD(auth).get_by_id_crud(id=id) # if not obj: # raise CustomException(msg=f'删除失败,ID为{id}的数据不存在') - + # await TenantCRUD(auth).delete_crud(ids=ids) - + # @classmethod # async def set_available_service(cls, auth: AuthSchema, data: BatchSetAvailable) -> None: # """ # 批量设置状态 - + # 参数: # - auth (AuthSchema): 认证信息模型 # - data (BatchSetAvailable): 批量设置状态模型 - + # 返回: # - None # """ # # 系统租户保护:不允许禁用系统租户(id=1) # if data.status is False and 1 in data.ids: # raise CustomException(msg='系统租户不允许禁用') - + # await TenantCRUD(auth).set_available_crud(ids=data.ids, status=data.status) - + # @classmethod # async def batch_export_service(cls, obj_list: List[Dict[str, Any]]) -> bytes: # """ # 批量导出 - + # 参数: # - obj_list (List[Dict[str, Any]]): 租户模型实例字典列表 - + # 返回: # - bytes: Excel文件字节流 # """ # mapping_dict = { # 'id': '编号', -# 'name': '名称', +# 'name': '名称', # 'code': '编码', # 'status': '状态', # 'description': '备注', @@ -268,17 +267,17 @@ # # 系统租户特殊标记 # if item.get('id') == 1: # item['name'] = f"{item.get('name')} [系统租户]" - + # # 处理状态 # item['status'] = '正常' if item.get('status') else '停用' - + # # 处理创建者 # creator_info = item.get('created_id') # if isinstance(creator_info, dict): # item['created_id'] = creator_info.get('name', '未知') # else: # item['created_id'] = '未知' - + # # 限制导出数量,防止大数据量导出 # max_export_count = 1000 # if len(data) > max_export_count: @@ -291,16 +290,16 @@ # async def batch_import_service(cls, auth: AuthSchema, file: UploadFile, update_support: bool = False) -> str: # """ # 批量导入 - + # 参数: # - auth (AuthSchema): 认证信息模型 # - file (UploadFile): 上传的Excel文件 # - update_support (bool): 是否支持更新存在数据 - + # 返回: # - str: 导入结果信息 # """ - + # header_dict = { # '名称': 'name', # '编码': 'code', @@ -315,23 +314,23 @@ # contents = await file.read() # df = pd.read_excel(io.BytesIO(contents)) # await file.close() - + # # 验证导入数量限制 # max_import_count = 100 # if len(df) > max_import_count: # raise CustomException(msg=f"单次导入不能超过{max_import_count}条数据") - + # if df.empty: # raise CustomException(msg="导入文件为空") - + # # 检查表头是否完整 # missing_headers = [header for header in header_dict.keys() if header not in df.columns] # if missing_headers: # raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}") - + # # 重命名列名 # df.rename(columns=header_dict, inplace=True) - + # # 验证必填字段 # required_fields = ['name', 'code', 'status'] # for field in required_fields: @@ -340,13 +339,13 @@ # field_name = [k for k,v in header_dict.items() if v == field][0] # error_rows = [i+1 for i in missing_rows] # raise CustomException(msg=f"{field_name}不能为空,第{error_rows}行") - + # error_msgs = [] # success_count = 0 # count = 0 # processed_names = set() # 用于检测重复名称 # processed_codes = set() # 用于检测重复编码 - + # # 处理每一行数据 # for index, row in df.iterrows(): # count += 1 @@ -357,31 +356,31 @@ # except ValueError: # error_msgs.append(f"第{count}行: 状态必须是'正常'或'停用'") # continue - + # # 字段格式验证 # name = str(row['name']).strip() # if len(name) < 2 or len(name) > 64: # error_msgs.append(f"第{count}行: 租户名称长度必须在2-64个字符之间") # continue - + # # 检查名称是否只包含允许的字符 # if not all(c.isalnum() or c in '-_' for c in name.replace(' ', '')): # error_msgs.append(f"第{count}行: 租户名称只能包含字母、数字、下划线、中划线和空格") # continue - + # # 检查导入文件内的重复名称 # if name in processed_names: # error_msgs.append(f"第{count}行: 租户名称 '{name}' 在文件中重复") # continue # processed_names.add(name) - + # # 处理编码 # code = str(row['code']).strip() # if code in processed_codes: # error_msgs.append(f"第{count}行: 租户编码 '{code}' 在文件中重复") # continue # processed_codes.add(code) - + # # 构建租户数据 # data = { # "name": name, @@ -389,13 +388,13 @@ # "status": status, # "description": str(row['description']).strip(), # } - - + + # # 检查时间有效性 # if 'start_time' in data and 'end_time' in data and data['start_time'] > data['end_time']: # error_msgs.append(f"第{count}行: 开始时间不能晚于结束时间") # continue - + # # 处理租户导入 # exists_obj = await TenantCRUD(auth).get(name=data["name"]) # if exists_obj: @@ -403,7 +402,7 @@ # if exists_obj.id == 1: # error_msgs.append(f"第{count}行: 系统租户不允许修改") # continue - + # if update_support: # await TenantCRUD(auth).update(id=exists_obj.id, data=data) # success_count += 1 @@ -415,17 +414,17 @@ # if exists_code: # error_msgs.append(f"第{count}行: 租户编码 '{data['code']}' 已存在") # continue - + # # 创建租户 # new_tenant = await TenantCRUD(auth).create(data=data) # success_count += 1 - + # # 自动创建租户管理员(如果导入数量不是特别大) # if success_count < 10: # 限制自动创建管理员的数量 # await cls._create_tenant_admin_user(auth, new_tenant) # else: # log.info(f"批量导入超过10个租户,跳过自动创建管理员用户") - + # except Exception as e: # error_msgs.append(f"第{count}行: {str(e)}") # continue @@ -436,10 +435,10 @@ # result += "\n错误信息:\n" + "\n".join(error_msgs) # # 记录错误详情到日志 # log.error(f"租户批量导入错误详情: {error_msgs}") - + # log.info(f"租户批量导入完成: 成功{success_count}条, 失败{len(error_msgs)}条") # return result - + # except CustomException: # raise # except Exception as e: @@ -450,20 +449,20 @@ # async def import_template_download_service(cls) -> bytes: # """ # 下载导入模板 - + # 返回: # - bytes: Excel文件字节流 # """ # header_list = ['名称', '编码', '状态', '描述', '开始时间', '结束时间'] -# selector_header_list = ['状态'] +# selector_header_list = ['状态'] # option_list = [{'状态': ['正常', '停用']}] - + # # 添加示例数据和说明 # sample_data = [ # ['测试租户1', 'TEST001', '正常', '这是一个测试租户', '', ''], # ['测试租户2', 'TEST002', '正常', '这是另一个测试租户', '', ''] # ] - + # # 添加说明文本 # description = """导入说明: # 1. 名称和编码为必填项,名称长度2-64个字符 @@ -472,9 +471,9 @@ # 4. 时间格式:YYYY-MM-DD HH:MM:SS或YYYY-MM-DD # 5. 单次导入最多支持100条数据 # """ - + # return ExcelUtil.get_excel_template( # header_list=header_list, # selector_header_list=selector_header_list, # option_list=option_list -# ) \ No newline at end of file +# ) diff --git a/backend/app/common/constant.py b/backend/app/common/constant.py index 95176d25..c23b2a69 100644 --- a/backend/app/common/constant.py +++ b/backend/app/common/constant.py @@ -565,8 +565,8 @@ class GenConstant: } if settings.DATABASE_TYPE == "postgres" else { - # 布尔类型 - "TINYINT": "Boolean", + # 布尔语义仅 tinyint(1),其余 tinyint 在 get_sqlalchemy_type 中映射为 SmallInteger + "TINYINT": "SmallInteger", # 数值类型 "SMALLINT": "SmallInteger", "MEDIUMINT": "Integer", @@ -684,6 +684,7 @@ class GenConstant: # PostgreSQL 字符串类型 "character": "str", "character varying": "str", + "citext": "str", # PostgreSQL 二进制类型 "bytea": "bytes", diff --git a/backend/app/common/response.py b/backend/app/common/response.py index 8c2cd56e..3c88fd93 100644 --- a/backend/app/common/response.py +++ b/backend/app/common/response.py @@ -2,6 +2,7 @@ from collections.abc import Mapping from typing import Any, Generic from fastapi import status +from fastapi.encoders import jsonable_encoder from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from pydantic import BaseModel, Field from pydantic.types import T @@ -51,7 +52,7 @@ class SuccessResponse(JSONResponse): status_code=status_code, success=success, ).model_dump() - super().__init__(content=content, status_code=status_code) + super().__init__(content=jsonable_encoder(content), status_code=status_code) class ErrorResponse(JSONResponse): @@ -85,7 +86,7 @@ class ErrorResponse(JSONResponse): status_code=status_code, success=success, ).model_dump() - super().__init__(content=content, status_code=status_code) + super().__init__(content=jsonable_encoder(content), status_code=status_code) class StreamResponse(StreamingResponse): diff --git a/backend/app/core/ap_scheduler.py b/backend/app/core/ap_scheduler.py index a82a9a43..0837946e 100644 --- a/backend/app/core/ap_scheduler.py +++ b/backend/app/core/ap_scheduler.py @@ -44,7 +44,7 @@ from redis.asyncio import Redis from app.config.setting import settings from app.core.database import engine from app.core.logger import log -from app.plugin.module_task.node.model import NodeModel +from app.plugin.module_task.cronjob.node.model import NodeModel from app.utils.cron_util import CronUtil scheduler = AsyncIOScheduler() @@ -574,7 +574,7 @@ class SchedulerUtil: try: from sqlalchemy.orm import Session - from app.plugin.module_task.job.model import JobModel + from app.plugin.module_task.cronjob.job.model import JobModel with Session(engine) as session: session.query(JobModel).delete() @@ -592,7 +592,7 @@ class SchedulerUtil: try: from sqlalchemy.orm import Session - from app.plugin.module_task.job.model import JobModel + from app.plugin.module_task.cronjob.job.model import JobModel with Session(engine) as session: session.query(JobModel).filter(JobModel.status == "pending").update( @@ -766,7 +766,7 @@ class SchedulerUtil: """ from sqlalchemy.orm import Session - from app.plugin.module_task.job.model import JobModel + from app.plugin.module_task.cronjob.job.model import JobModel try: job = cls.get_job(job_id=job_id) @@ -801,7 +801,7 @@ class SchedulerUtil: """ from sqlalchemy.orm import Session - from app.plugin.module_task.job.model import JobModel + from app.plugin.module_task.cronjob.job.model import JobModel job = cls.get_job(job_id=job_id) next_run_time = str(job.next_run_time) if job and job.next_run_time else None @@ -836,7 +836,7 @@ class SchedulerUtil: """ from sqlalchemy.orm import Session - from app.plugin.module_task.job.model import JobModel + from app.plugin.module_task.cronjob.job.model import JobModel try: job = cls.get_job(job_id=job_id) @@ -934,7 +934,7 @@ class SchedulerUtil: """ from sqlalchemy.orm import Session - from app.plugin.module_task.job.model import JobModel + from app.plugin.module_task.cronjob.job.model import JobModel with Session(engine) as session: job_log = ( @@ -1230,7 +1230,7 @@ class SchedulerUtil: """ from sqlalchemy.orm import Session - from app.plugin.module_task.job.model import JobModel + from app.plugin.module_task.cronjob.job.model import JobModel jobs = cls.get_all_jobs() sync_count = 0 diff --git a/backend/app/core/validator.py b/backend/app/core/validator.py index 86619c70..a2ede7c4 100644 --- a/backend/app/core/validator.py +++ b/backend/app/core/validator.py @@ -14,6 +14,7 @@ DateTimeStr = Annotated[ PlainSerializer( lambda x: x.strftime("%Y-%m-%d %H:%M:%S") if isinstance(x, datetime) else str(x), return_type=str, + when_used="json", ), WithJsonSchema({"type": "string"}, mode="serialization"), ] @@ -25,6 +26,7 @@ DateStr = Annotated[ PlainSerializer( lambda x: x.strftime("%Y-%m-%d") if isinstance(x, date) else str(x), return_type=str, + when_used="json", ), WithJsonSchema({"type": "string"}, mode="serialization"), ] @@ -36,6 +38,7 @@ TimeStr = Annotated[ PlainSerializer( lambda x: x.strftime("%H:%M:%S") if isinstance(x, time) else str(x), return_type=str, + when_used="json", ), WithJsonSchema({"type": "string"}, mode="serialization"), ] diff --git a/backend/app/plugin/module_example/demo/demo01/crud.py b/backend/app/plugin/module_example/demo/demo01/crud.py index 346f5524..a82a9237 100644 --- a/backend/app/plugin/module_example/demo/demo01/crud.py +++ b/backend/app/plugin/module_example/demo/demo01/crud.py @@ -96,7 +96,7 @@ class Demo01CRUD(CRUDBase[Demo01Model, Demo01CreateSchema, Demo01UpdateSchema]): - ids (list[int]): 示例ID列表 - status (str): 可用状态 - 返回: + 返回: - None """ return await self.set(ids=ids, status=status) diff --git a/backend/app/plugin/module_example/demo/demo01/model.py b/backend/app/plugin/module_example/demo/demo01/model.py index ecda722d..4437ed97 100644 --- a/backend/app/plugin/module_example/demo/demo01/model.py +++ b/backend/app/plugin/module_example/demo/demo01/model.py @@ -1,5 +1,3 @@ -import enum -from datetime import date, datetime, time from sqlalchemy import String from sqlalchemy.orm import Mapped, mapped_column @@ -18,4 +16,3 @@ class Demo01Model(ModelMixin, UserMixin): # 字符串类型 name: Mapped[str] = mapped_column(String(64), nullable=False, comment="名称") - diff --git a/backend/app/plugin/module_example/demo/demo01/schema.py b/backend/app/plugin/module_example/demo/demo01/schema.py index 42468279..3fe4f970 100644 --- a/backend/app/plugin/module_example/demo/demo01/schema.py +++ b/backend/app/plugin/module_example/demo/demo01/schema.py @@ -12,7 +12,7 @@ from pydantic import ( from app.common.enums import QueueEnum from app.core.base_schema import BaseSchema, UserBySchema -from app.core.validator import DateStr, DateTimeStr, TimeStr +from app.core.validator import DateTimeStr class Demo01CreateSchema(BaseModel): diff --git a/backend/app/plugin/module_generator/gencode/controller.py b/backend/app/plugin/module_generator/gencode/controller.py index 95b55456..10d35472 100644 --- a/backend/app/plugin/module_generator/gencode/controller.py +++ b/backend/app/plugin/module_generator/gencode/controller.py @@ -12,7 +12,13 @@ from app.core.logger import log from app.core.router_class import OperationLogRoute from app.utils.common_util import bytes2file_response -from .schema import GenDBTableSchema, GenTableOutSchema, GenTableQueryParam, GenTableSchema +from .schema import ( + GenCreateTableSqlBody, + GenDBTableSchema, + GenTableOutSchema, + GenTableQueryParam, + GenTableSchema, +) from .service import GenTableService GenRouter = APIRouter(route_class=OperationLogRoute, prefix="/gencode", tags=["代码生成模块"]) @@ -145,7 +151,7 @@ async def gen_table_detail_controller( response_model=ResponseSchema[bool], ) async def create_table_controller( - sql: Annotated[str, Body(description="SQL语句,用于创建表结构")], + body: GenCreateTableSqlBody, auth: Annotated[ AuthSchema, Depends(AuthPermission(["module_generator:gencode:create"])), @@ -155,13 +161,13 @@ async def create_table_controller( 创建表结构 参数: - - sql (str): SQL语句,用于创建表结构 + - body (GenCreateTableSqlBody): 含 `sql` 字段的请求体(与前端 `data: { sql }` 一致) - auth (AuthSchema): 认证信息模型 返回: - JSONResponse: 包含创建结果的JSON响应 """ - result = await GenTableService.create_table_service(auth, sql) + result = await GenTableService.create_table_service(auth, body.sql) log.info("创建表结构成功") return SuccessResponse(msg="创建表结构成功", data=result) @@ -231,7 +237,7 @@ async def delete_gen_table_controller( ) async def batch_gen_code_controller( table_names: Annotated[list[str], Body(description="表名列表")], - auth: Annotated[AuthSchema, Depends(AuthPermission(["module_generator:gencode:patch"]))], + auth: Annotated[AuthSchema, Depends(AuthPermission(["module_generator:gencode:operate"]))], ) -> StreamResponse: """ 批量生成代码 diff --git a/backend/app/plugin/module_generator/gencode/crud.py b/backend/app/plugin/module_generator/gencode/crud.py index 87d9c4e4..e7672aee 100644 --- a/backend/app/plugin/module_generator/gencode/crud.py +++ b/backend/app/plugin/module_generator/gencode/crud.py @@ -227,6 +227,27 @@ class GenTableCRUD(CRUDBase[GenTableModel, GenTableSchema, GenTableSchema]): inspector: Inspector = inspect(engine) return inspector.has_table(table_name) + async def get_db_table_comment(self, table_name: str) -> str: + """ + 获取数据库中指定表的注释(用于主子表场景下从库中加载子表元信息)。 + """ + from app.core.database import engine + + inspector: Inspector = inspect(engine) + if not inspector.has_table(table_name): + return "" + try: + table_comment = inspector.get_table_comment(table_name) + comment = ( + table_comment.get("text", "") + if isinstance(table_comment, dict) + else (table_comment or "") + ) + return comment or "" + except Exception as e: + log.warning(f"获取表 {table_name} 的注释失败: {e}") + return "" + async def execute_sql(self, sql: str) -> bool: """ 执行SQL语句。 diff --git a/backend/app/plugin/module_generator/gencode/schema.py b/backend/app/plugin/module_generator/gencode/schema.py index d5a58d4e..097860d4 100644 --- a/backend/app/plugin/module_generator/gencode/schema.py +++ b/backend/app/plugin/module_generator/gencode/schema.py @@ -20,6 +20,12 @@ class GenDBTableSchema(BaseModel): table_comment: str | None = Field(default=None, description="表描述") +class GenCreateTableSqlBody(BaseModel): + """从代码生成页提交的建表 SQL(JSON 对象,便于与前端 axios 一致)。""" + + sql: str = Field(..., description="CREATE TABLE 等 DDL,可多条语句") + + class GenTableColumnSchema(BaseModel): """代码生成业务表字段创建模型(原始字段+生成配置)。 - 从根本上解决问题:所有字段都设置了合理的默认值,避免None值问题 @@ -98,6 +104,15 @@ class GenTableSchema(BaseModel): raise ValueError("表名称不能为空") return v + @field_validator("sub_table_name", "sub_table_fk_name", mode="before") + @classmethod + def strip_optional_sub_fields(cls, v: str | None) -> str | None: + """主子表字段去首尾空格,空串视为未填。""" + if v is None: + return None + s = str(v).strip() + return s if s else None + class GenTableOutSchema(GenTableSchema, BaseSchema): """业务表输出模型(面向控制器/前端)。""" @@ -107,6 +122,10 @@ class GenTableOutSchema(GenTableSchema, BaseSchema): pk_column: GenTableColumnOutSchema | None = Field(default=None, description="主键信息") sub_table: GenTableSchema | None = Field(default=None, description="子表信息") sub: bool | None = Field(default=None, description="是否为子表") + master_sub_hint: str | None = Field( + default=None, + description="主子表配置说明或异常提示(仅接口输出,不落库)", + ) @dataclass diff --git a/backend/app/plugin/module_generator/gencode/service.py b/backend/app/plugin/module_generator/gencode/service.py index 7d15a0dd..c34f2241 100644 --- a/backend/app/plugin/module_generator/gencode/service.py +++ b/backend/app/plugin/module_generator/gencode/service.py @@ -1,5 +1,6 @@ import io import os +import re import zipfile from collections.abc import Callable from typing import Any @@ -51,6 +52,17 @@ def handle_service_exception(func: Callable) -> Callable: class GenTableService: """代码生成业务表服务层""" + @classmethod + def normalize_and_validate_master_sub(cls, data: GenTableSchema) -> None: + """主子表业务规则:两字段同填或同空;子表表名不得与主表相同。""" + sn = data.sub_table_name + fk = data.sub_table_fk_name + if bool(sn) ^ bool(fk): + raise CustomException(msg="子表表名与子表外键列须同时填写或同时留空") + tn = (data.table_name or "").strip() + if sn and fk and sn == tn: + raise CustomException(msg="子表表名不能与主表表名相同") + @classmethod @handle_service_exception async def get_gen_table_detail_service(cls, auth: AuthSchema, table_id: int) -> dict: @@ -64,7 +76,7 @@ class GenTableService: - dict: 包含业务表详细信息的字典。 """ gen_table = await cls.get_gen_table_by_id_service(auth, table_id) - return GenTableOutSchema.model_validate(gen_table).model_dump() + return gen_table.model_dump() @classmethod @handle_service_exception @@ -143,6 +155,12 @@ class GenTableService: raise CustomException(msg="导入的表结构不能为空") try: for table in gen_table_list: + _row = { + k: v + for k, v in table.model_dump().items() + if k in GenTableSchema.model_fields + } + cls.normalize_and_validate_master_sub(GenTableSchema.model_validate(_row)) table_name = table.table_name # 检查表是否已存在 existing_table = await GenTableCRUD(auth).get_gen_table_by_name(table_name) @@ -273,6 +291,7 @@ class GenTableService: gen_table_info = await cls.get_gen_table_by_id_service(auth, table_id) if gen_table_info.id: try: + cls.normalize_and_validate_master_sub(data) # 直接调用edit_gen_table方法,它会在内部处理排除嵌套字段的逻辑 result = await GenTableCRUD(auth).edit_gen_table(table_id, data) if not result: @@ -289,7 +308,12 @@ class GenTableService: ) # 重新获取带有预加载关系的对象,避免懒加载导致的MissingGreenlet错误 updated_gen_table = await GenTableCRUD(auth).get_gen_table_by_id(table_id) - return GenTableOutSchema.model_validate(updated_gen_table).model_dump() + out = GenTableOutSchema.model_validate(updated_gen_table) + await cls.set_pk_column(out) + await cls.hydrate_sub_table(auth, out) + return out.model_dump() + except CustomException: + raise except Exception as e: raise CustomException(msg=str(e)) else: @@ -338,6 +362,8 @@ class GenTableService: raise CustomException(msg="业务表不存在") result = GenTableOutSchema.model_validate(gen_table) + await cls.set_pk_column(result) + await cls.hydrate_sub_table(auth, result) return result @classmethod @@ -375,22 +401,38 @@ class GenTableService: 返回: - dict[str, Any]: 文件名到渲染内容的映射。 """ - gen_table = GenTableOutSchema.model_validate( - await GenTableCRUD(auth).get_gen_table_by_id(table_id) - ) + raw = await GenTableCRUD(auth).get_gen_table_by_id(table_id) + if not raw: + raise CustomException(msg="业务表不存在") + gen_table = GenTableOutSchema.model_validate(raw) await cls.set_pk_column(gen_table) + await cls.hydrate_sub_table(auth, gen_table) + cls._assert_master_sub_config_valid(gen_table) env = Jinja2TemplateUtil.get_env() context = Jinja2TemplateUtil.prepare_context(gen_table) template_list = Jinja2TemplateUtil.get_template_list() - preview_code_result = {} + preview_code_result: dict[str, Any] = {} for template in template_list: try: render_content = await env.get_template(template).render_async(**context) - preview_code_result[template] = render_content + out_key = Jinja2TemplateUtil.get_file_name(template, gen_table) + preview_code_result[out_key] = render_content except Exception as e: log.error(f"渲染模板 {template} 时出错: {e!s}") - # 即使某个模板渲染失败,也继续处理其他模板 - preview_code_result[template] = f"渲染错误: {e!s}" + out_key = Jinja2TemplateUtil.get_file_name(template, gen_table) + preview_code_result[out_key] = f"渲染错误: {e!s}" + if gen_table.sub and gen_table.sub_table: + sub_ctx = Jinja2TemplateUtil.prepare_sub_render_context(gen_table, gen_table.sub_table) + sub_table = gen_table.sub_table + for template in template_list: + try: + render_content = await env.get_template(template).render_async(**sub_ctx) + out_key = Jinja2TemplateUtil.get_file_name(template, sub_table) + preview_code_result[out_key] = render_content + except Exception as e: + log.error(f"渲染子表模板 {template} 时出错: {e!s}") + out_key = Jinja2TemplateUtil.get_file_name(template, sub_table) + preview_code_result[out_key] = f"渲染错误: {e!s}" return preview_code_result @classmethod @@ -434,7 +476,8 @@ class GenTableService: dir_menu_id = gen_table_schema.parent_menu_id else: # 如果没传上级菜单ID,则需要创建新的模块目录菜单(类型=1:目录) - existing_dir_menu = await menu_crud.get(name=gen_table_schema.business_name) + # 与下方创建目录菜单时使用的 name(package_name)一致,避免查不到而重复建目录 + existing_dir_menu = await menu_crud.get(name=gen_table_schema.package_name) if existing_dir_menu: dir_menu_id = existing_dir_menu.id else: @@ -566,36 +609,39 @@ class GenTableService: log.info(f"成功创建按钮权限: {button['name']}") log.info(f"成功创建{gen_table_schema.function_name}菜单及按钮权限") - # 2. 菜单创建成功后,再生成页面代码 - for template in render_info[0]: - try: - render_content = await env.get_template(template).render_async(**render_info[2]) - - file_name = Jinja2TemplateUtil.get_file_name(template, gen_table_schema) - full_path = BASE_DIR.parent.joinpath(file_name) - gen_path = str(full_path) - - if not gen_path: - raise CustomException(msg="【代码生成】生成路径为空") - - # 确保目录存在 - os.makedirs(os.path.dirname(gen_path), exist_ok=True) - - await anyio.Path(gen_path).write_text(render_content, encoding="utf-8") - - module_init_path = BASE_DIR.parent.joinpath( - f"backend/app/plugin/{gen_table_schema.module_name}/__init__.py" - ) - if not module_init_path.exists(): - # 创建module_name目录的__init__.py文件 - os.makedirs(os.path.dirname(module_init_path), exist_ok=True) - await anyio.Path(module_init_path).write_text( - "# -*- coding: utf-8 -*-", encoding="utf-8" + # 2. 菜单创建成功后,再生成页面代码(主表 + 可选子表) + async def _write_templates( + templates: list[str], ctx: dict[str, Any], table_schema: GenTableOutSchema + ) -> None: + for template in templates: + try: + render_content = await env.get_template(template).render_async(**ctx) + file_name = Jinja2TemplateUtil.get_file_name(template, table_schema) + full_path = BASE_DIR.parent.joinpath(file_name) + gen_path = str(full_path) + if not gen_path: + raise CustomException(msg="【代码生成】生成路径为空") + os.makedirs(os.path.dirname(gen_path), exist_ok=True) + await anyio.Path(gen_path).write_text(render_content, encoding="utf-8") + module_init_path = BASE_DIR.parent.joinpath( + f"backend/app/plugin/{table_schema.module_name}/__init__.py" ) - except Exception as e: - raise CustomException( - msg=f"渲染模板失败,表名:{gen_table_schema.table_name},详细错误信息:{e!s}" - ) + if not module_init_path.exists(): + os.makedirs(os.path.dirname(module_init_path), exist_ok=True) + await anyio.Path(module_init_path).write_text( + "# -*- coding: utf-8 -*-", encoding="utf-8" + ) + except Exception as e: + raise CustomException( + msg=f"渲染模板失败,表名:{table_schema.table_name},详细错误信息:{e!s}" + ) + + await _write_templates(render_info[0], render_info[2], gen_table_schema) + if gen_table_schema.sub and gen_table_schema.sub_table: + sub_ctx = Jinja2TemplateUtil.prepare_sub_render_context( + gen_table_schema, gen_table_schema.sub_table + ) + await _write_templates(render_info[0], sub_ctx, gen_table_schema.sub_table) return True @@ -613,17 +659,17 @@ class GenTableService: 返回: - bytes: 包含所有生成代码的ZIP文件内容。 """ - # 验证表名列表非空 - if not table_names: + valid_names = [t.strip() for t in table_names if t and str(t).strip()] + if not valid_names: raise CustomException(msg="表名列表不能为空") zip_buffer = io.BytesIO() + file_count = 0 with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: - for table_name in table_names: - if not table_name.strip(): - continue + for table_name in valid_names: try: env = Jinja2TemplateUtil.get_env() render_info = await cls.__get_gen_render_info(auth, table_name) + gen_tbl = render_info[3] for template_file, output_file in zip( render_info[0], render_info[1], strict=False ): @@ -631,12 +677,29 @@ class GenTableService: **render_info[2] ) zip_file.writestr(output_file, render_content) + file_count += 1 + if gen_tbl.sub and gen_tbl.sub_table: + sub_ctx = Jinja2TemplateUtil.prepare_sub_render_context( + gen_tbl, gen_tbl.sub_table + ) + sub_tbl = gen_tbl.sub_table + for template_file in render_info[0]: + render_content = await env.get_template(template_file).render_async( + **sub_ctx + ) + out_path = Jinja2TemplateUtil.get_file_name(template_file, sub_tbl) + zip_file.writestr(out_path, render_content) + file_count += 1 except Exception as e: log.error(f"批量生成代码时处理表 {table_name} 出错: {e!s}") # 继续处理其他表,不中断整个过程 continue zip_data = zip_buffer.getvalue() zip_buffer.close() + if file_count == 0: + raise CustomException( + msg="未能生成任何代码文件:请检查所选表是否存在于代码生成配置中,或主子表、字段配置是否正确" + ) return zip_data @classmethod @@ -717,6 +780,102 @@ class GenTableService: except Exception as e: raise CustomException(msg=f"同步失败: {e!s}") + @classmethod + async def hydrate_sub_table(cls, auth: AuthSchema, gen_table: GenTableOutSchema) -> None: + """从数据库加载子表列结构,填充 ``sub_table`` 并设置 ``sub``。""" + gen_table.master_sub_hint = None + sub_name_raw = (gen_table.sub_table_name or "").strip() + fk_raw = (gen_table.sub_table_fk_name or "").strip() + if not sub_name_raw and not fk_raw: + gen_table.sub = False + gen_table.sub_table = None + return + if sub_name_raw and not fk_raw: + gen_table.sub = False + gen_table.sub_table = None + gen_table.master_sub_hint = "已填写子表表名,请同时填写「子表外键列」后再保存" + return + if fk_raw and not sub_name_raw: + gen_table.sub = False + gen_table.sub_table = None + gen_table.master_sub_hint = "已填写子表外键列,请同时填写「子表表名」后再保存" + return + if sub_name_raw == (gen_table.table_name or "").strip(): + gen_table.sub = False + gen_table.sub_table = None + gen_table.master_sub_hint = "子表表名不能与主表表名相同" + return + try: + gen_table_columns = await GenTableColumnCRUD(auth).get_gen_db_table_columns_by_name( + sub_name_raw + ) + except Exception as e: + log.warning(f"获取子表 {sub_name_raw} 字段失败: {e!s}") + gen_table.sub = False + gen_table.sub_table = None + gen_table.master_sub_hint = f"无法读取子表结构:{e!s}" + return + if not gen_table_columns: + gen_table.sub = False + gen_table.sub_table = None + gen_table.master_sub_hint = ( + f"当前数据库中不存在表「{sub_name_raw}」或该表无列,请先建表再配置主子表" + ) + return + fk_names = {c.column_name for c in gen_table_columns if c.column_name} + if fk_raw not in fk_names: + gen_table.sub = False + gen_table.sub_table = None + gen_table.master_sub_hint = ( + f"子表「{sub_name_raw}」中不存在名为「{fk_raw}」的列,请核对外键列名" + ) + return + table_comment = await GenTableCRUD(auth).get_db_table_comment(sub_name_raw) + sub = GenTableOutSchema( + id=-1, + table_name=sub_name_raw, + table_comment=table_comment or None, + class_name=GenUtils.convert_class_name(sub_name_raw), + package_name=gen_table.package_name, + module_name=gen_table.module_name, + business_name=sub_name_raw, + function_name=re.sub(r"(?:表|测试)", "", table_comment or "") or sub_name_raw, + sub_table_name=None, + sub_table_fk_name=None, + parent_menu_id=gen_table.parent_menu_id, + columns=[], + sub=False, + sub_table=None, + ) + for column in gen_table_columns: + col_dump = column.model_dump() + col_dump["table_id"] = -1 + col_schema = GenTableColumnSchema.model_validate(col_dump) + GenUtils.init_column_field(col_schema, sub) + sub.columns.append(GenTableColumnOutSchema(**col_schema.model_dump())) + await cls.set_pk_column(sub) + gen_table.sub = True + gen_table.sub_table = sub + gen_table.master_sub_hint = None + + @classmethod + def _assert_master_sub_config_valid(cls, gen_table: GenTableOutSchema) -> None: + """预览/生成前校验主子表配置是否可用。""" + sn = (gen_table.sub_table_name or "").strip() + fk = (gen_table.sub_table_fk_name or "").strip() + if not sn and not fk: + return + if not sn or not fk: + raise CustomException( + msg=gen_table.master_sub_hint + or "子表表名与子表外键列须同时填写或同时留空" + ) + if not gen_table.sub_table: + raise CustomException( + msg=gen_table.master_sub_hint + or "无法生成主子表代码:请确认子表已在当前数据库中存在,且外键列名正确" + ) + @classmethod async def set_pk_column(cls, gen_table: GenTableOutSchema) -> None: """设置主键列信息(主表/子表)。 @@ -730,8 +889,8 @@ class GenTableService: """ if gen_table.columns: for column in gen_table.columns: - # 修复:确保正确检查主键标识 - if getattr(column, "pk", False) or getattr(column, "is_pk", "") == "1": + is_pk = getattr(column, "is_pk", False) + if bool(is_pk) if isinstance(is_pk, bool) else str(is_pk) == "1": gen_table.pk_column = column break # 如果没有找到主键列且有列存在,使用第一个列作为主键 @@ -759,6 +918,8 @@ class GenTableService: raise CustomException(msg=f"业务表 {table_name} 不存在") gen_table = GenTableOutSchema.model_validate(gen_table_model) await cls.set_pk_column(gen_table) + await cls.hydrate_sub_table(auth, gen_table) + cls._assert_master_sub_config_valid(gen_table) context = Jinja2TemplateUtil.prepare_context(gen_table) template_list = Jinja2TemplateUtil.get_template_list() output_files = [ diff --git a/backend/app/plugin/module_generator/gencode/templates/python/model.py.j2 b/backend/app/plugin/module_generator/gencode/templates/python/model.py.j2 index dca1dbcc..1bbcf0ad 100644 --- a/backend/app/plugin/module_generator/gencode/templates/python/model.py.j2 +++ b/backend/app/plugin/module_generator/gencode/templates/python/model.py.j2 @@ -3,9 +3,6 @@ {% for model_import in model_import_list %} {{ model_import }} {% endfor %} -{% if table.sub %} -from sqlalchemy.orm import relationship -{% endif %} from sqlalchemy.orm import Mapped, mapped_column from app.core.base_model import ModelMixin, UserMixin @@ -19,13 +16,28 @@ class {{ class_name }}Model(ModelMixin, UserMixin): __table_args__: dict[str, str] = {'comment': '{{ function_name }}'} __loader_options__: list[str] = ["created_by", "updated_by"] +{% if not is_sub_entity %} {% for column in columns %} {% if column.column_name not in ['id', 'uuid', 'status', 'description', 'created_time', 'updated_time', 'created_id', 'updated_id'] %} {% set sqlalchemy_type = column|get_sqlalchemy_type %} - {{ column.column_name }}: Mapped[{{ column.python_type }} | None] = mapped_column({{ sqlalchemy_type }}, {% if column.pk %}primary_key=True, {% endif %}{% if column.increment %}autoincrement=True, {% endif %}{% if column.required or column.pk %}nullable=False{% else %}nullable=True{% endif %}, comment='{{ column.column_comment }}') + {{ column.column_name }}: Mapped[{{ column.python_type }} | None] = mapped_column({{ sqlalchemy_type }}, {% if column.is_pk %}primary_key=True, {% endif %}{% if column.is_increment %}autoincrement=True, {% endif %}{% if (not column.is_nullable) or column.is_pk %}nullable=False{% else %}nullable=True{% endif %}, comment='{{ column.column_comment }}') {% endif %} {% endfor %} {% if table.sub %} - {{ sub_class_name }}_list = relationship('{{ sub_class_name }}', back_populates='{{ business_name }}') + {{ sub_rel_list_name }} = relationship('{{ sub_model_class_name }}', back_populates='{{ parent_rel_name }}') {% endif %} +{% else %} + {% for column in columns %} + {% if column.column_name not in ['id', 'uuid', 'status', 'description', 'created_time', 'updated_time', 'created_id', 'updated_id'] %} + {% set sqlalchemy_type = column|get_sqlalchemy_type %} + {% if column.column_name == sub_table_fk_name %} + {{ column.column_name }}: Mapped[{{ column.python_type }} | None] = mapped_column(ForeignKey('{{ parent_table_name }}.{{ parent_pk_column_name }}', ondelete='CASCADE'), {{ sqlalchemy_type }}, {% if column.is_pk %}primary_key=True, {% endif %}{% if column.is_increment %}autoincrement=True, {% endif %}{% if (not column.is_nullable) or column.is_pk %}nullable=False{% else %}nullable=True{% endif %}, comment='{{ column.column_comment }}') + {% else %} + {{ column.column_name }}: Mapped[{{ column.python_type }} | None] = mapped_column({{ sqlalchemy_type }}, {% if column.is_pk %}primary_key=True, {% endif %}{% if column.is_increment %}autoincrement=True, {% endif %}{% if (not column.is_nullable) or column.is_pk %}nullable=False{% else %}nullable=True{% endif %}, comment='{{ column.column_comment }}') + {% endif %} + {% endif %} + {% endfor %} + + {{ parent_rel_name }} = relationship('{{ parent_model_class_name }}', back_populates='{{ parent_list_rel_name }}') +{% endif %} diff --git a/backend/app/plugin/module_generator/gencode/templates/python/schema.py.j2 b/backend/app/plugin/module_generator/gencode/templates/python/schema.py.j2 index 5d8427f9..6778fbcb 100644 --- a/backend/app/plugin/module_generator/gencode/templates/python/schema.py.j2 +++ b/backend/app/plugin/module_generator/gencode/templates/python/schema.py.j2 @@ -8,9 +8,7 @@ from fastapi import Query {% for import_stmt in schema_import_list %} {{ import_stmt }} {% endfor %} -{% if table.created_time %} -from app.core.validator import DateTimeStr -{% endif %} +{# DateTimeStr 由 schema_import_list 在存在 created_time/updated_time 列时注入 #} from app.common.enums import QueueEnum from app.core.base_schema import BaseSchema, UserBySchema @@ -60,10 +58,10 @@ class {{ class_name }}QueryParam: {{ column.column_name }}: {{ column.python_type }} | None = Query(None, description="{{ column.column_comment }}"), {% endif %} {% endfor %} - {% if table.created_time %} + {% if 'created_time' in table_column_names %} created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]), {% endif %} - {% if table.updated_time %} + {% if 'updated_time' in table_column_names %} updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]), {% endif %} ) -> None: @@ -77,21 +75,13 @@ class {{ class_name }}QueryParam: self.{{ column.column_name }} = (QueueEnum.eq.value, {{ column.column_name }}) {% endif %} {% endfor %} - {% if table.created_time %} + {% if 'created_time' in table_column_names %} # 时间范围查询 if created_time and len(created_time) == 2: self.created_time = (QueueEnum.between.value, (created_time[0], created_time[1])) {% endif %} - {% if table.updated_time %} + {% if 'updated_time' in table_column_names %} if updated_time and len(updated_time) == 2: self.updated_time = (QueueEnum.between.value, (updated_time[0], updated_time[1])) {% endif %} - {% if table.created_id %} - # 关联查询字段 - if created_id: - self.created_id = (QueueEnum.eq.value, created_id) - {% endif %} - {% if table.updated_id %} - if updated_id: - self.updated_id = (QueueEnum.eq.value, updated_id) - {% endif %} + {# created_id / updated_id 若为 EQ 查询列,已在上方 query_type 循环中处理 #} diff --git a/backend/app/plugin/module_generator/gencode/templates/ts/api.ts.j2 b/backend/app/plugin/module_generator/gencode/templates/ts/api.ts.j2 index 0c63c852..ee002f5b 100644 --- a/backend/app/plugin/module_generator/gencode/templates/ts/api.ts.j2 +++ b/backend/app/plugin/module_generator/gencode/templates/ts/api.ts.j2 @@ -1,6 +1,6 @@ import request from "@/utils/request"; -const API_PATH = "/{{ package_name }}/{{ business_name|lower }}"; +const API_PATH = "/{{ api_route_prefix }}/{{ business_name|lower }}"; const {{ class_name }}API = { // 列表查询 @@ -95,12 +95,12 @@ export default {{ class_name }}API; // 列表查询参数 export interface {{ class_name }}PageQuery extends PageQuery { {% for column in columns %} - {% if column.is_query and column.column != "BETWEEN" and column.column_name not in ['created_time', 'updated_time'] %} + {# query_type 为 BETWEEN 的字段由范围查询或单独字段表达,勿生成标量;勿使用不存在的 column.column #} + {% if column.is_query and column.query_type != "BETWEEN" and column.column_name not in ['created_time', 'updated_time'] %} + {# LIKE 走字符串;EQ 等与列 python_type 一致(来自 DB→Python 映射) #} {{ column.column_name }}?: {{ - 'string' if ('status' in (column.python_field|lower)) or (column.html_type == 'radio') - else 'number' if column.is_pk == '1' - else 'number' if column.column_name in ['created_id', 'updated_id'] - else 'string' + 'string' if column.query_type == 'LIKE' + else (column.python_type | python_to_ts_type) }}; {% endif %} {% endfor %} @@ -112,11 +112,7 @@ export interface {{ class_name }}PageQuery extends PageQuery { export interface {{ class_name }}Table extends BaseType { {% for column in columns %} {% if column.column_name not in ['id', 'uuid', 'status', 'description', 'created_time', 'updated_time'] %} - {{ column.column_name }}?: {{ - 'boolean' if ('status' in (column.column_name|lower)) or (column.html_type == 'radio') - else 'number' if column.is_pk == 1 - else 'string' - }}; + {{ column.column_name }}?: {{ column.python_type | python_to_ts_type }}; {% endif %} {% endfor %} created_by?: CommonType; @@ -126,12 +122,8 @@ export interface {{ class_name }}Table extends BaseType { // 新增/修改/详情表单参数 export interface {{ class_name }}Form extends BaseFormType { {% for column in columns %} - {% if (column.is_insert == 1 or column.is_edit == 1) and column.column_name not in ['id', 'uuid', 'status', 'description', 'created_time', 'updated_time', 'created_id', 'updated_id'] %} - {{ column.column_name }}?: {{ - 'boolean' if ('status' in (column.column_name|lower)) or (column.html_type == 'radio') - else 'number' if column.is_pk == 1 - else 'string' - }}; + {% if (column.is_insert or column.is_edit) and column.column_name not in ['id', 'uuid', 'status', 'description', 'created_time', 'updated_time', 'created_id', 'updated_id'] %} + {{ column.column_name }}?: {{ column.python_type | python_to_ts_type }}; {% endif %} {% endfor %} } diff --git a/backend/app/plugin/module_generator/gencode/templates/vue/index.vue.j2 b/backend/app/plugin/module_generator/gencode/templates/vue/index.vue.j2 index 427c4844..09152e89 100644 --- a/backend/app/plugin/module_generator/gencode/templates/vue/index.vue.j2 +++ b/backend/app/plugin/module_generator/gencode/templates/vue/index.vue.j2 @@ -1,375 +1,170 @@ - +