更新.env文件以反映新的MinIO和AI服务配置,同时删除无用的文档和模板文件,增强了系统的灵活性和可维护性。添加了数据库备份和恢复功能,支持通过API管理租户ID和字段关联,优化了前端模板字段管理界面。

This commit is contained in:
python 2025-12-14 16:56:26 +08:00
parent 1d0f7a5bfe
commit 665612d2bf
9 changed files with 1750 additions and 246 deletions

324
.cursorrules Normal file
View File

@ -0,0 +1,324 @@
# 智慧监督AI文书写作服务 - AI开发手册
## 项目背景
本项目是一个基于大模型的智能文书生成服务,主要功能包括:
- 从非结构化文本中提取结构化字段数据使用AI大模型
- 根据字段数据填充Word模板生成正式文书
- 支持多种业务类型的文书模板管理
- 文档存储和下载管理MinIO对象存储
核心业务流程:
1. 接收输入的非结构化文本数据
2. 使用AI大模型提取结构化字段
3. 根据字段数据填充Word模板
4. 生成文档并上传到MinIO
5. 返回文档下载链接
## 技术栈与编码标准
### 核心技术栈
- **Python版本**: Python 3.8+
- **Web框架**: Flask 3.0.0
- **数据库**: MySQL (使用PyMySQL 1.1.2)
- **文档处理**: python-docx 1.1.0
- **对象存储**: MinIO 7.2.3
- **AI服务**:
- 华为大模型 (DeepSeek-R1-Distill-Llama-70B)
- 硅基流动 (DeepSeek-V3.2-Exp)
- **其他依赖**: flask-cors, flasgger, python-dotenv, requests, openpyxl, json-repair
### 编码规范
- **代码风格**: 遵循PEP 8规范
- **命名规范**:
- 类名使用大驼峰命名PascalCase`AIService`, `DocumentService`
- 函数和变量使用小写下划线命名snake_case`get_connection`, `field_data`
- 常量使用全大写下划线命名:`AI_PROVIDER`, `DB_HOST`
- **注释要求**:
- 所有类和方法必须有docstring使用三引号
- 复杂逻辑必须添加行内注释
- 使用中文注释(项目统一使用中文)
- **类型提示**: 建议使用类型提示typing模块提高代码可读性
- **异常处理**: 必须使用try-except捕获异常并提供有意义的错误信息
### 文件编码
- 所有Python文件使用UTF-8编码
- 文件开头不需要BOM标记
## 项目文件结构
```
.
├── app.py # Flask主应用定义所有API路由
├── requirements.txt # Python依赖列表
├── .env.example # 环境变量配置示例
├── .cursorrules # AI开发手册本文件
├── services/ # 服务层(业务逻辑)
│ ├── __init__.py
│ ├── ai_service.py # AI服务封装大模型调用逻辑
│ ├── document_service.py # 文档服务Word模板填充、MinIO上传
│ ├── field_service.py # 字段服务:数据库字段配置查询
│ └── ai_logger.py # AI日志记录器
├── utils/ # 工具类
│ ├── __init__.py
│ └── response.py # 统一API响应格式工具
├── config/ # 配置文件
│ ├── prompt_config.json # AI提示词配置
│ └── field_defaults.json # 字段默认值配置
├── static/ # 静态文件
│ ├── index.html # 测试页面
│ └── template_field_manager.html # 模板字段管理页面
├── template/ # Word模板文件目录
├── template_finish/ # 已完成的模板文件
├── test_scripts/ # 测试脚本
└── 技术文档/ # 技术文档目录
```
## 架构约束与最佳实践
### 1. 分层架构
项目采用分层架构,严格遵循以下层次:
- **路由层** (`app.py`): 只负责接收HTTP请求、参数验证、调用服务层、返回响应
- **服务层** (`services/`): 包含所有业务逻辑,服务类之间可以相互调用
- **工具层** (`utils/`): 提供通用工具函数,不包含业务逻辑
- **数据层**: 数据库操作封装在服务层中,不单独抽象
**重要原则**:
- 路由层不包含业务逻辑,只做参数验证和响应格式化
- 服务层方法应该是可测试的不依赖Flask的request对象
- 数据库连接在使用后必须关闭使用try-finally确保
### 2. 服务层设计规范
#### AI服务 (`services/ai_service.py`)
- 负责所有AI大模型调用
- 支持多种AI服务提供商华为、硅基流动通过环境变量切换
- 必须处理JSON解析失败的情况提供多种修复机制
- 字段名规范化将AI返回的各种字段名格式映射到正确的字段编码
- 日期格式规范化统一转换为中文格式YYYY年MM月 或 YYYY年MM月DD日
- 后处理:从已有信息推断缺失字段(如从出生年月计算年龄)
#### 文档服务 (`services/document_service.py`)
- 负责Word模板下载、填充、上传
- 占位符格式:`{{field_code}}`(双大括号)
- 必须处理表格中的占位符使用索引访问避免迭代器bug
- 提供XML备用方案处理特殊表格结构
- 文档名称生成:从原始文件名提取基础名称,添加被核查人姓名后缀
- MinIO路径格式`/{tenant_id}/{timestamp}/{file_name}`
#### 字段服务 (`services/field_service.py`)
- 负责从数据库查询字段配置
- 构建AI提示词根据输入数据和输出字段配置生成提示词
- 支持从配置文件加载提示词模板和字段默认值
### 3. 数据库设计规范
#### 主要数据表
- `f_polic_field`: 字段配置表
- `id`: 主键
- `name`: 字段名称
- `filed_code`: 字段编码注意数据库字段名是filed_code不是field_code
- `field_type`: 字段类型1=输入字段2=输出字段)
- `state`: 状态1=启用0=禁用)
- `f_polic_file_config`: 文件配置表(模板配置)
- `id`: 主键作为fileId使用
- `name`: 文件名称
- `file_path`: MinIO中的文件路径
- `input_data`: JSON格式的输入数据配置
- `state`: 状态1=启用0=禁用)
- `f_polic_file_field`: 文件字段关联表
- `file_id`: 文件配置ID
- `filed_id`: 字段ID
- `state`: 状态1=启用0=禁用)
#### 数据库操作规范
- 所有数据库操作必须使用参数化查询防止SQL注入
- 使用`pymysql.cursors.DictCursor`获取字典格式结果
- 数据库连接必须在使用后关闭try-finally模式
- 事务操作必须正确处理回滚
### 4. API设计规范
#### 统一响应格式
所有API必须使用`utils/response.py`中的工具函数:
**成功响应**:
```python
return success_response(data={'key': 'value'}, msg="ok")
```
**错误响应**:
```python
return error_response(code=400, error_msg="错误信息")
```
**响应结构**:
```json
{
"code": 0, // 0表示成功其他值表示错误码
"data": {}, // 响应数据
"msg": "ok", // 响应消息
"timestamp": "1234567890", // 时间戳(毫秒)
"errorMsg": "", // 错误信息(成功时为空)
"isSuccess": true // 是否成功
}
```
#### API路由规范
- 使用`@app.route`装饰器定义路由
- 支持Swagger文档使用flasgger
- 路由路径使用小写字母和连字符:`/api/ai/extract`
- 保留旧路径以兼容:`/ai/extract` 和 `/api/ai/extract` 同时支持
#### 错误码规范
- `0`: 成功
- `400`: 请求参数错误
- `500`: 服务器内部错误
- `1001`: 模板不存在
- `2001`: AI解析超时
- `2002`: AI解析失败
- `3001`: 文件生成失败
- `3002`: 文件保存失败
### 5. 环境变量配置
所有配置通过环境变量管理,使用`.env`文件(不要提交到版本控制):
**必需配置**:
- `DB_HOST`: 数据库主机
- `DB_PORT`: 数据库端口
- `DB_USER`: 数据库用户名
- `DB_PASSWORD`: 数据库密码
- `DB_NAME`: 数据库名称
- `MINIO_ENDPOINT`: MinIO服务地址
- `MINIO_ACCESS_KEY`: MinIO访问密钥
- `MINIO_SECRET_KEY`: MinIO密钥
- `MINIO_BUCKET`: MinIO存储桶名称
- `MINIO_SECURE`: 是否使用HTTPStrue/false
**AI服务配置**:
- `AI_PROVIDER`: AI服务提供商'huawei' 或 'siliconflow'
- `HUAWEI_API_ENDPOINT`: 华为API地址
- `HUAWEI_API_KEY`: 华为API密钥
- `HUAWEI_MODEL`: 华为模型名称
- `HUAWEI_API_TIMEOUT`: 超时时间(秒)
- `HUAWEI_API_MAX_TOKENS`: 最大token数
- `SILICONFLOW_URL`: 硅基流动API地址
- `SILICONFLOW_API_KEY`: 硅基流动API密钥
- `SILICONFLOW_MODEL`: 硅基流动模型名称
- `SILICONFLOW_API_TIMEOUT`: 超时时间(秒)
- `SILICONFLOW_API_MAX_TOKENS`: 最大token数
**可选配置**:
- `PORT`: 服务端口默认7500
- `DEBUG`: 调试模式true/false默认false
- `TENANT_ID`: 租户ID用于MinIO路径
### 6. 错误处理规范
- 所有可能失败的操作必须使用try-except捕获异常
- 异常信息要详细,包含上下文信息
- 数据库操作异常必须回滚事务
- 文件操作异常必须清理临时文件
- 对外暴露的错误信息要友好,不泄露内部实现细节
**示例**:
```python
try:
# 业务逻辑
result = some_operation()
return success_response(data=result)
except ValueError as e:
return error_response(400, f"参数错误: {str(e)}")
except Exception as e:
# 记录详细错误日志
print(f"[ERROR] 操作失败: {str(e)}")
import traceback
print(traceback.format_exc())
return error_response(500, "服务器内部错误")
```
### 7. 日志规范
- 使用`print`输出日志项目当前使用print不是logging模块
- 日志格式:`[级别] 消息内容`
- 日志级别:
- `[DEBUG]`: 调试信息(详细的执行过程)
- `[INFO]`: 一般信息(正常流程)
- `[WARN]`: 警告信息(不影响功能但需要注意)
- `[ERROR]`: 错误信息(功能失败)
- 关键操作必须记录日志AI调用、文件生成、数据库操作
### 8. 代码质量要求
- **可读性**: 代码要清晰易懂,变量名要有意义
- **可维护性**: 避免重复代码,提取公共方法
- **可测试性**: 服务层方法应该是纯函数,便于单元测试
- **健壮性**: 处理边界情况,避免崩溃
- **性能**: 数据库查询要优化避免N+1查询
### 9. 特殊注意事项
#### Word模板处理
- 使用`python-docx`库处理Word文档
- 占位符格式:`{{field_code}}`(双大括号)
- 表格访问必须使用索引方式避免迭代器导致的IndexError
- 处理跨run的占位符时需要合并runs并保持格式
- 某些复杂表格结构可能导致访问失败提供XML备用方案
#### AI字段提取
- AI返回的JSON可能格式不正确需要多种修复机制
- 字段名可能不规范(如`_source`、`target_organisation`),需要规范化映射
- 日期格式需要统一转换为中文格式
- 缺失字段需要从已有信息推断(如从出生年月计算年龄)
#### MinIO文件管理
- 文件路径使用相对路径(以`/`开头)
- 上传文件时自动生成时间戳路径
- 支持生成预签名下载URL7天有效期
- 临时文件使用后必须清理
## 开发工作流
1. **添加新功能**:
- 在服务层添加业务逻辑方法
- 在路由层添加API端点
- 更新Swagger文档注释
- 测试功能是否正常
2. **修改现有功能**:
- 理解现有代码逻辑
- 保持API接口兼容性如需要修改考虑版本控制
- 更新相关文档
3. **调试问题**:
- 查看日志输出(使用[DEBUG]级别)
- 检查数据库数据是否正确
- 验证环境变量配置
- 测试AI服务是否可用
## 常见问题处理
1. **AI解析失败**: 检查输入文本质量查看AI日志尝试修复JSON格式
2. **模板填充失败**: 检查占位符格式是否正确,查看表格结构是否异常
3. **MinIO上传失败**: 检查网络连接验证MinIO配置和权限
4. **数据库连接失败**: 检查数据库配置和网络连接
## 代码生成指导
当AI需要生成代码时请遵循以下原则
1. **保持一致性**: 遵循项目现有的代码风格和架构模式
2. **错误处理**: 所有可能失败的操作都要有异常处理
3. **日志记录**: 关键操作要记录日志
4. **参数验证**: API接口要验证输入参数
5. **资源清理**: 文件、数据库连接等资源要正确清理
6. **中文注释**: 使用中文编写注释和文档字符串
7. **类型提示**: 建议使用类型提示提高代码可读性
## 更新日志
- 2025-12-13: 创建初始版本

84
.env
View File

@ -1,26 +1,68 @@
# MinIO配置
MINIO_ENDPOINT=10.100.31.21:9000
MINIO_ACCESS_KEY=minio_PC8dcY
MINIO_SECRET_KEY=minio_7k7RNJ
MINIO_BUCKET=finyx
MINIO_SECURE=false # 重要新服务器使用HTTP必须是false
# 其他配置
# ========== AI服务提供商配置 ==========
# 选择使用的AI服务提供商
# 可选值: 'huawei' 或 'siliconflow'
# 默认值: 'siliconflow'
AI_PROVIDER=siliconflow
# ========== 华为大模型API配置 ==========
# 当 AI_PROVIDER=huawei 时使用以下配置
# API端点地址
HUAWEI_API_ENDPOINT=http://10.100.31.26:3001/v1/chat/completions
# API密钥
HUAWEI_API_KEY=sk-PoeiV3qwyTIRqcVc84E8E24cD2904872859a87922e0d9186
# 模型名称
HUAWEI_MODEL=DeepSeek-R1-Distill-Llama-70B
# API超时配置
# 开启思考模式时,响应时间会显著增加,需要更长的超时时间
# 默认180秒3分钟
HUAWEI_API_TIMEOUT=180
# API最大token数配置
# 开启思考模式时模型可能生成更长的响应需要更多的token
# 默认12000
HUAWEI_API_MAX_TOKENS=12000
# ========== 硅基流动API配置 ==========
# 当 AI_PROVIDER=siliconflow 时使用以下配置
# API端点地址默认值通常不需要修改
SILICONFLOW_URL=https://api.siliconflow.cn/v1/chat/completions
# API密钥必需
SILICONFLOW_API_KEY=sk-pgujibohpenkomkwlufexmqzyckglgogdiubfplgqxkfqgfu
# 模型名称(默认值,通常不需要修改)
SILICONFLOW_MODEL=Qwen/Qwen2.5-72B-Instruct
# API超时配置
# 默认120秒
SILICONFLOW_API_TIMEOUT=120
# API最大token数配置
# 默认2000
SILICONFLOW_API_MAX_TOKENS=2000
# ========== 数据库配置 ==========
DB_HOST=152.136.177.240
DB_NAME=finyx
DB_PASSWORD=6QsGK6MpePZDE57Z
DB_PORT=5012
DB_USER=finyx
DEBUG=False
HUAWEI_API_ENDPOINT=http://10.100.31.26:3001/v1/chat/completions
HUAWEI_API_KEY=sk-PoeiV3qwyTIRqcVc84E8E24cD2904872859a87922e0d9186
HUAWEI_API_MAX_TOKENS=12000
HUAWEI_API_TIMEOUT=180
HUAWEI_MODEL=DeepSeek-R1-Distill-Llama-70B
DB_PASSWORD=6QsGK6MpePZDE57Z
DB_NAME=finyx
# ========== MinIO配置可选文档生成功能需要 ==========
MINIO_ENDPOINT=minio.datacubeworld.com:9000
MINIO_ACCESS_KEY=JOLXFXny3avFSzB0uRA5
MINIO_SECRET_KEY=G1BR8jStNfovkfH5ou39EmPl34E4l7dGrnd3Cz0I
MINIO_BUCKET=finyx
MINIO_SECURE=true
# ========== 服务配置 ==========
# 服务端口
PORT=7500
SILICONFLOW_API_KEY=sk-pgujibohpenkomkwlufexmqzyckglgogdiubfplgqxkfqgfu
SILICONFLOW_API_MAX_TOKENS=2000
SILICONFLOW_API_TIMEOUT=120
SILICONFLOW_MODEL=Qwen/Qwen2.5-72B-Instruct
SILICONFLOW_URL=https://api.siliconflow.cn/v1/chat/completions
# 调试模式true/false
DEBUG=False

644
app.py
View File

@ -6,8 +6,12 @@ from flask_cors import CORS
from flasgger import Swagger
import os
import pymysql
import json
import tempfile
import zipfile
from datetime import datetime
from dotenv import load_dotenv
from flask import send_file
from services.ai_service import AIService
from services.field_service import FieldService
@ -839,54 +843,104 @@ def template_field_manager():
return send_from_directory('static', 'template_field_manager.html')
@app.route('/api/template-field-relations', methods=['GET'])
def get_template_field_relations():
def generate_id():
"""生成ID使用时间戳+随机数的方式,模拟雪花算法)"""
import time
import random
timestamp = int(time.time() * 1000)
random_part = random.randint(100000, 999999)
return timestamp * 1000 + random_part
@app.route('/api/tenant-ids', methods=['GET'])
def get_tenant_ids():
"""
获取所有模板和字段的关联关系
用于模板字段关联管理页面
获取数据库中所有已存在的 tenant_id
用于模板字段关联管理页面选择租户
"""
try:
conn = document_service.get_connection()
cursor = conn.cursor(pymysql.cursors.DictCursor)
try:
# 获取所有启用的模板
# 从 f_polic_file_config 表中获取所有不同的 tenant_id
cursor.execute("""
SELECT DISTINCT tenant_id
FROM f_polic_file_config
WHERE tenant_id IS NOT NULL
ORDER BY tenant_id
""")
tenant_ids = [row['tenant_id'] for row in cursor.fetchall()]
return success_response({'tenant_ids': tenant_ids})
finally:
cursor.close()
conn.close()
except Exception as e:
return error_response(500, f"获取租户ID列表失败: {str(e)}")
@app.route('/api/template-field-relations', methods=['GET'])
def get_template_field_relations():
"""
获取指定 tenant_id 下的所有模板和字段的关联关系
用于模板字段关联管理页面
查询参数: tenant_id (必填)
"""
try:
tenant_id = request.args.get('tenant_id')
if not tenant_id:
return error_response(400, "tenant_id参数不能为空")
try:
tenant_id = int(tenant_id)
except ValueError:
return error_response(400, "tenant_id必须是数字")
conn = document_service.get_connection()
cursor = conn.cursor(pymysql.cursors.DictCursor)
try:
# 获取指定 tenant_id 下所有启用的模板
cursor.execute("""
SELECT id, name, template_code
FROM f_polic_file_config
WHERE state = 1
WHERE tenant_id = %s AND state = 1
ORDER BY name
""")
""", (tenant_id,))
templates = cursor.fetchall()
# 获取所有启用的输入字段
# 获取指定 tenant_id 下所有启用的输入字段
cursor.execute("""
SELECT id, name, filed_code, field_type
FROM f_polic_field
WHERE field_type = 1 AND state = 1
WHERE tenant_id = %s AND field_type = 1 AND state = 1
ORDER BY name
""")
""", (tenant_id,))
input_fields = cursor.fetchall()
# 获取所有启用的输出字段
# 获取指定 tenant_id 下所有启用的输出字段
cursor.execute("""
SELECT id, name, filed_code, field_type
FROM f_polic_field
WHERE field_type = 2 AND state = 1
WHERE tenant_id = %s AND field_type = 2 AND state = 1
ORDER BY name
""")
""", (tenant_id,))
output_fields = cursor.fetchall()
# 获取现有的关联关系
# 获取指定 tenant_id 下现有的关联关系
cursor.execute("""
SELECT file_id, filed_id
FROM f_polic_file_field
WHERE state = 1
""")
SELECT fff.file_id, fff.filed_id
FROM f_polic_file_field fff
INNER JOIN f_polic_file_config fc ON fff.file_id = fc.id
WHERE fff.tenant_id = %s AND fff.state = 1
""", (tenant_id,))
relations = cursor.fetchall()
# 构建关联关系映射 (file_id -> list of filed_id)
# 注意JSON不支持set所以转换为list
relation_map = {}
for rel in relations:
file_id = rel['file_id']
@ -896,6 +950,7 @@ def get_template_field_relations():
relation_map[file_id].append(filed_id)
return success_response({
'tenant_id': tenant_id,
'templates': templates,
'input_fields': input_fields,
'output_fields': output_fields,
@ -915,6 +970,7 @@ def save_template_field_relations():
"""
保存模板和字段的关联关系
请求体格式: {
"tenant_id": 123,
"template_id": 123,
"input_field_ids": [1, 2, 3],
"output_field_ids": [4, 5, 6]
@ -926,56 +982,59 @@ def save_template_field_relations():
if not data:
return error_response(400, "请求参数不能为空")
tenant_id = data.get('tenant_id')
template_id = data.get('template_id')
input_field_ids = data.get('input_field_ids', [])
output_field_ids = data.get('output_field_ids', [])
if not tenant_id:
return error_response(400, "tenant_id参数不能为空")
if not template_id:
return error_response(400, "template_id参数不能为空")
try:
tenant_id = int(tenant_id)
except ValueError:
return error_response(400, "tenant_id必须是数字")
conn = document_service.get_connection()
cursor = conn.cursor()
try:
# 验证模板是否存在
# 验证模板是否存在且属于该 tenant_id
cursor.execute("""
SELECT id FROM f_polic_file_config
WHERE id = %s AND state = 1
""", (template_id,))
WHERE id = %s AND tenant_id = %s AND state = 1
""", (template_id, tenant_id))
if not cursor.fetchone():
return error_response(400, f"模板ID {template_id} 不存在或未启用")
return error_response(400, f"模板ID {template_id} 不存在或不属于该租户")
# 合并所有字段ID
all_field_ids = set(input_field_ids + output_field_ids)
# 验证字段是否存在
# 验证字段是否存在且属于该 tenant_id
if all_field_ids:
placeholders = ','.join(['%s'] * len(all_field_ids))
cursor.execute(f"""
SELECT id FROM f_polic_field
WHERE id IN ({placeholders}) AND state = 1
""", list(all_field_ids))
WHERE id IN ({placeholders}) AND tenant_id = %s AND state = 1
""", list(all_field_ids) + [tenant_id])
existing_field_ids = {row[0] for row in cursor.fetchall()}
invalid_field_ids = all_field_ids - existing_field_ids
if invalid_field_ids:
return error_response(400, f"字段ID {list(invalid_field_ids)} 不存在或未启用")
return error_response(400, f"字段ID {list(invalid_field_ids)} 不存在或不属于该租户")
# 删除该模板的所有现有关联关系
# 删除该模板的所有现有关联关系(仅限该 tenant_id
cursor.execute("""
DELETE FROM f_polic_file_field
WHERE file_id = %s
""", (template_id,))
WHERE file_id = %s AND tenant_id = %s
""", (template_id, tenant_id))
# 插入新的关联关系
current_time = datetime.now()
created_by = 655162080928945152 # 默认创建者ID
# 从环境变量读取tenant_id如果数据库表需要如果不需要可以设置为NULL
tenant_id = os.getenv('TENANT_ID')
if all_field_ids:
# 如果tenant_id是必填字段从环境变量读取如果可以为NULL则使用NULL
if tenant_id:
insert_sql = """
INSERT INTO f_polic_file_field
(tenant_id, file_id, filed_id, created_time, created_by, updated_time, updated_by, state)
@ -991,22 +1050,6 @@ def save_template_field_relations():
current_time,
created_by
))
else:
# 如果tenant_id可以为NULL使用NULL
insert_sql = """
INSERT INTO f_polic_file_field
(file_id, filed_id, created_time, created_by, updated_time, updated_by, state)
VALUES (%s, %s, %s, %s, %s, %s, 1)
"""
for field_id in all_field_ids:
cursor.execute(insert_sql, (
template_id,
field_id,
current_time,
created_by,
current_time,
created_by
))
conn.commit()
@ -1028,6 +1071,505 @@ def save_template_field_relations():
return error_response(500, f"保存关联关系失败: {str(e)}")
# 字段管理 API
@app.route('/api/fields', methods=['GET'])
def get_fields():
"""
获取字段列表
查询参数: tenant_id (必填), field_type (可选: 1=输入字段, 2=输出字段)
"""
try:
tenant_id = request.args.get('tenant_id')
field_type = request.args.get('field_type')
if not tenant_id:
return error_response(400, "tenant_id参数不能为空")
try:
tenant_id = int(tenant_id)
except ValueError:
return error_response(400, "tenant_id必须是数字")
conn = document_service.get_connection()
cursor = conn.cursor(pymysql.cursors.DictCursor)
try:
if field_type:
try:
field_type = int(field_type)
except ValueError:
return error_response(400, "field_type必须是数字")
cursor.execute("""
SELECT id, name, filed_code, field_type, state
FROM f_polic_field
WHERE tenant_id = %s AND field_type = %s
ORDER BY field_type, name
""", (tenant_id, field_type))
else:
cursor.execute("""
SELECT id, name, filed_code, field_type, state
FROM f_polic_field
WHERE tenant_id = %s
ORDER BY field_type, name
""", (tenant_id,))
fields = cursor.fetchall()
return success_response({'fields': fields})
finally:
cursor.close()
conn.close()
except Exception as e:
return error_response(500, f"获取字段列表失败: {str(e)}")
@app.route('/api/fields', methods=['POST'])
def create_field():
"""
创建新字段
请求体格式: {
"tenant_id": 123,
"name": "字段名称",
"filed_code": "field_code",
"field_type": 1 // 1=输入字段, 2=输出字段
}
"""
try:
data = request.get_json()
if not data:
return error_response(400, "请求参数不能为空")
tenant_id = data.get('tenant_id')
name = data.get('name')
filed_code = data.get('filed_code')
field_type = data.get('field_type')
if not all([tenant_id, name, filed_code, field_type]):
return error_response(400, "tenant_id, name, filed_code, field_type 参数不能为空")
try:
tenant_id = int(tenant_id)
field_type = int(field_type)
except ValueError:
return error_response(400, "tenant_id 和 field_type 必须是数字")
if field_type not in [1, 2]:
return error_response(400, "field_type 必须是 1输入字段或 2输出字段")
conn = document_service.get_connection()
cursor = conn.cursor()
try:
# 检查字段编码是否已存在(同一 tenant_id 下)
cursor.execute("""
SELECT id FROM f_polic_field
WHERE tenant_id = %s AND filed_code = %s
""", (tenant_id, filed_code))
if cursor.fetchone():
return error_response(400, f"字段编码 {filed_code} 已存在")
# 创建新字段
field_id = generate_id()
current_time = datetime.now()
created_by = 655162080928945152
cursor.execute("""
INSERT INTO f_polic_field
(id, tenant_id, name, filed_code, field_type, created_time, created_by, updated_time, updated_by, state)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 1)
""", (field_id, tenant_id, name, filed_code, field_type, current_time, created_by, current_time, created_by))
conn.commit()
return success_response({'field_id': field_id}, "字段创建成功")
except Exception as e:
conn.rollback()
raise e
finally:
cursor.close()
conn.close()
except Exception as e:
return error_response(500, f"创建字段失败: {str(e)}")
@app.route('/api/fields/<int:field_id>', methods=['PUT'])
def update_field(field_id):
"""
更新字段
请求体格式: {
"tenant_id": 123,
"name": "字段名称",
"filed_code": "field_code",
"field_type": 1,
"state": 1 // 0=未启用, 1=启用
}
"""
try:
data = request.get_json()
if not data:
return error_response(400, "请求参数不能为空")
tenant_id = data.get('tenant_id')
name = data.get('name')
filed_code = data.get('filed_code')
field_type = data.get('field_type')
state = data.get('state')
if not tenant_id:
return error_response(400, "tenant_id参数不能为空")
try:
tenant_id = int(tenant_id)
except ValueError:
return error_response(400, "tenant_id必须是数字")
conn = document_service.get_connection()
cursor = conn.cursor()
try:
# 验证字段是否存在且属于该 tenant_id
cursor.execute("""
SELECT id FROM f_polic_field
WHERE id = %s AND tenant_id = %s
""", (field_id, tenant_id))
if not cursor.fetchone():
return error_response(404, "字段不存在或不属于该租户")
# 如果更新 filed_code检查是否与其他字段冲突
if filed_code:
cursor.execute("""
SELECT id FROM f_polic_field
WHERE tenant_id = %s AND filed_code = %s AND id != %s
""", (tenant_id, filed_code, field_id))
if cursor.fetchone():
return error_response(400, f"字段编码 {filed_code} 已被其他字段使用")
# 构建更新语句
update_fields = []
update_values = []
if name is not None:
update_fields.append("name = %s")
update_values.append(name)
if filed_code is not None:
update_fields.append("filed_code = %s")
update_values.append(filed_code)
if field_type is not None:
if field_type not in [1, 2]:
return error_response(400, "field_type 必须是 1输入字段或 2输出字段")
update_fields.append("field_type = %s")
update_values.append(field_type)
if state is not None:
update_fields.append("state = %s")
update_values.append(state)
if not update_fields:
return error_response(400, "没有需要更新的字段")
update_fields.append("updated_time = %s")
update_values.append(datetime.now())
update_fields.append("updated_by = %s")
update_values.append(655162080928945152)
update_values.append(field_id)
update_values.append(tenant_id)
update_sql = f"""
UPDATE f_polic_field
SET {', '.join(update_fields)}
WHERE id = %s AND tenant_id = %s
"""
cursor.execute(update_sql, update_values)
conn.commit()
return success_response({'field_id': field_id}, "字段更新成功")
except Exception as e:
conn.rollback()
raise e
finally:
cursor.close()
conn.close()
except Exception as e:
return error_response(500, f"更新字段失败: {str(e)}")
@app.route('/api/fields/<int:field_id>', methods=['DELETE'])
def delete_field(field_id):
"""
删除字段软删除 state 设置为 0
查询参数: tenant_id (必填)
"""
try:
tenant_id = request.args.get('tenant_id')
if not tenant_id:
return error_response(400, "tenant_id参数不能为空")
try:
tenant_id = int(tenant_id)
except ValueError:
return error_response(400, "tenant_id必须是数字")
conn = document_service.get_connection()
cursor = conn.cursor()
try:
# 验证字段是否存在且属于该 tenant_id
cursor.execute("""
SELECT id FROM f_polic_field
WHERE id = %s AND tenant_id = %s
""", (field_id, tenant_id))
if not cursor.fetchone():
return error_response(404, "字段不存在或不属于该租户")
# 检查字段是否被模板关联
cursor.execute("""
SELECT COUNT(*) as count
FROM f_polic_file_field
WHERE filed_id = %s AND tenant_id = %s AND state = 1
""", (field_id, tenant_id))
result = cursor.fetchone()
if result and result[0] > 0:
return error_response(400, f"字段正在被 {result[0]} 个模板使用,无法删除")
# 软删除字段
cursor.execute("""
UPDATE f_polic_field
SET state = 0, updated_time = %s, updated_by = %s
WHERE id = %s AND tenant_id = %s
""", (datetime.now(), 655162080928945152, field_id, tenant_id))
conn.commit()
return success_response({'field_id': field_id}, "字段删除成功")
except Exception as e:
conn.rollback()
raise e
finally:
cursor.close()
conn.close()
except Exception as e:
return error_response(500, f"删除字段失败: {str(e)}")
# 数据库备份和恢复 API
@app.route('/api/database/backup', methods=['POST'])
def backup_database():
"""
备份数据库相关表格
请求体格式: {
"tenant_id": 123 // 可选如果提供则只备份该 tenant_id 的数据
}
"""
try:
data = request.get_json() or {}
tenant_id = data.get('tenant_id')
conn = document_service.get_connection()
cursor = conn.cursor(pymysql.cursors.DictCursor)
try:
# 要备份的表
tables = ['f_polic_file_config', 'f_polic_field', 'f_polic_file_field']
backup_data = {}
for table in tables:
if tenant_id:
# 根据表结构决定如何过滤 tenant_id
if table == 'f_polic_file_config':
cursor.execute(f"SELECT * FROM {table} WHERE tenant_id = %s", (tenant_id,))
elif table == 'f_polic_field':
cursor.execute(f"SELECT * FROM {table} WHERE tenant_id = %s", (tenant_id,))
elif table == 'f_polic_file_field':
cursor.execute(f"SELECT * FROM {table} WHERE tenant_id = %s", (tenant_id,))
else:
cursor.execute(f"SELECT * FROM {table}")
backup_data[table] = cursor.fetchall()
# 创建临时文件保存备份数据
temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8')
json.dump({
'backup_time': datetime.now().isoformat(),
'tenant_id': tenant_id,
'tables': backup_data
}, temp_file, ensure_ascii=False, indent=2, default=str)
temp_file.close()
return send_file(
temp_file.name,
as_attachment=True,
download_name=f'db_backup_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json',
mimetype='application/json'
)
finally:
cursor.close()
conn.close()
except Exception as e:
return error_response(500, f"备份数据库失败: {str(e)}")
@app.route('/api/database/restore', methods=['POST'])
def restore_database():
"""
恢复数据库相关表格
通过文件上传: file (multipart/form-data)
查询参数: tenant_id (必填恢复数据到该 tenant_id)
"""
try:
# 从查询参数获取 tenant_id
tenant_id = request.args.get('tenant_id')
if not tenant_id:
# 尝试从表单数据获取
tenant_id = request.form.get('tenant_id')
if not tenant_id:
return error_response(400, "tenant_id参数不能为空可通过查询参数或表单数据提供")
try:
tenant_id = int(tenant_id)
except (ValueError, TypeError):
return error_response(400, "tenant_id必须是数字")
backup_data = None
# 检查是否有文件上传
if 'file' in request.files:
file = request.files['file']
if file.filename:
try:
backup_data = json.load(file)
except json.JSONDecodeError as e:
return error_response(400, f"备份文件格式错误: {str(e)}")
else:
return error_response(400, "请上传备份文件")
if not backup_data:
return error_response(400, "备份数据不能为空")
conn = document_service.get_connection()
cursor = conn.cursor()
try:
# 验证备份数据格式
if 'tables' not in backup_data:
return error_response(400, "备份数据格式错误:缺少 tables 字段")
tables_data = backup_data['tables']
required_tables = ['f_polic_file_config', 'f_polic_field', 'f_polic_file_field']
for table in required_tables:
if table not in tables_data:
return error_response(400, f"备份数据格式错误:缺少表 {table} 的数据")
# 开始恢复(注意:这里只恢复指定 tenant_id 的数据)
# 先删除该 tenant_id 的现有数据
cursor.execute("DELETE FROM f_polic_file_field WHERE tenant_id = %s", (tenant_id,))
cursor.execute("DELETE FROM f_polic_field WHERE tenant_id = %s", (tenant_id,))
cursor.execute("DELETE FROM f_polic_file_config WHERE tenant_id = %s", (tenant_id,))
# 恢复 f_polic_file_config
if tables_data['f_polic_file_config']:
insert_sql = """
INSERT INTO f_polic_file_config
(id, tenant_id, parent_id, name, template_code, input_data, file_path,
created_time, created_by, updated_time, updated_by, state)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
for row in tables_data['f_polic_file_config']:
# 确保 tenant_id 正确
row['tenant_id'] = tenant_id
cursor.execute(insert_sql, (
row.get('id'),
row.get('tenant_id'),
row.get('parent_id'),
row.get('name'),
row.get('template_code'),
row.get('input_data'),
row.get('file_path'),
row.get('created_time'),
row.get('created_by'),
row.get('updated_time'),
row.get('updated_by'),
row.get('state', 1)
))
# 恢复 f_polic_field
if tables_data['f_polic_field']:
insert_sql = """
INSERT INTO f_polic_field
(id, tenant_id, name, filed_code, field_type,
created_time, created_by, updated_time, updated_by, state)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
for row in tables_data['f_polic_field']:
# 确保 tenant_id 正确
row['tenant_id'] = tenant_id
cursor.execute(insert_sql, (
row.get('id'),
row.get('tenant_id'),
row.get('name'),
row.get('filed_code'),
row.get('field_type'),
row.get('created_time'),
row.get('created_by'),
row.get('updated_time'),
row.get('updated_by'),
row.get('state', 1)
))
# 恢复 f_polic_file_field
if tables_data['f_polic_file_field']:
insert_sql = """
INSERT INTO f_polic_file_field
(tenant_id, file_id, filed_id,
created_time, created_by, updated_time, updated_by, state)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
"""
for row in tables_data['f_polic_file_field']:
# 确保 tenant_id 正确
row['tenant_id'] = tenant_id
cursor.execute(insert_sql, (
row.get('tenant_id'),
row.get('file_id'),
row.get('filed_id'),
row.get('created_time'),
row.get('created_by'),
row.get('updated_time'),
row.get('updated_by'),
row.get('state', 1)
))
conn.commit()
return success_response({
'tenant_id': tenant_id,
'restored_tables': required_tables
}, "数据库恢复成功")
except Exception as e:
conn.rollback()
raise e
finally:
cursor.close()
conn.close()
except Exception as e:
return error_response(500, f"恢复数据库失败: {str(e)}")
if __name__ == '__main__':
# 确保static目录存在
os.makedirs('static', exist_ok=True)

View File

@ -1,6 +1,7 @@
flask==3.0.0
flask-cors==4.0.0
pymysql==1.1.2
cryptography>=41.0.0
python-dotenv==1.0.0
requests==2.31.0
flasgger==0.9.7.1

File diff suppressed because it is too large Load Diff