更新.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配置 # ========== AI服务提供商配置 ==========
MINIO_ENDPOINT=10.100.31.21:9000 # 选择使用的AI服务提供商
MINIO_ACCESS_KEY=minio_PC8dcY # 可选值: 'huawei' 或 'siliconflow'
MINIO_SECRET_KEY=minio_7k7RNJ # 默认值: 'siliconflow'
MINIO_BUCKET=finyx
MINIO_SECURE=false # 重要新服务器使用HTTP必须是false
# 其他配置
AI_PROVIDER=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_HOST=152.136.177.240
DB_NAME=finyx
DB_PASSWORD=6QsGK6MpePZDE57Z
DB_PORT=5012 DB_PORT=5012
DB_USER=finyx DB_USER=finyx
DEBUG=False DB_PASSWORD=6QsGK6MpePZDE57Z
HUAWEI_API_ENDPOINT=http://10.100.31.26:3001/v1/chat/completions DB_NAME=finyx
HUAWEI_API_KEY=sk-PoeiV3qwyTIRqcVc84E8E24cD2904872859a87922e0d9186
HUAWEI_API_MAX_TOKENS=12000 # ========== MinIO配置可选文档生成功能需要 ==========
HUAWEI_API_TIMEOUT=180 MINIO_ENDPOINT=minio.datacubeworld.com:9000
HUAWEI_MODEL=DeepSeek-R1-Distill-Llama-70B MINIO_ACCESS_KEY=JOLXFXny3avFSzB0uRA5
MINIO_SECRET_KEY=G1BR8jStNfovkfH5ou39EmPl34E4l7dGrnd3Cz0I
MINIO_BUCKET=finyx
MINIO_SECURE=true
# ========== 服务配置 ==========
# 服务端口
PORT=7500 PORT=7500
SILICONFLOW_API_KEY=sk-pgujibohpenkomkwlufexmqzyckglgogdiubfplgqxkfqgfu
SILICONFLOW_API_MAX_TOKENS=2000 # 调试模式true/false
SILICONFLOW_API_TIMEOUT=120 DEBUG=False
SILICONFLOW_MODEL=Qwen/Qwen2.5-72B-Instruct
SILICONFLOW_URL=https://api.siliconflow.cn/v1/chat/completions

644
app.py
View File

@ -6,8 +6,12 @@ from flask_cors import CORS
from flasgger import Swagger from flasgger import Swagger
import os import os
import pymysql import pymysql
import json
import tempfile
import zipfile
from datetime import datetime from datetime import datetime
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import send_file
from services.ai_service import AIService from services.ai_service import AIService
from services.field_service import FieldService from services.field_service import FieldService
@ -839,54 +843,104 @@ def template_field_manager():
return send_from_directory('static', 'template_field_manager.html') return send_from_directory('static', 'template_field_manager.html')
@app.route('/api/template-field-relations', methods=['GET']) def generate_id():
def get_template_field_relations(): """生成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: try:
conn = document_service.get_connection() conn = document_service.get_connection()
cursor = conn.cursor(pymysql.cursors.DictCursor) cursor = conn.cursor(pymysql.cursors.DictCursor)
try: 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(""" cursor.execute("""
SELECT id, name, template_code SELECT id, name, template_code
FROM f_polic_file_config FROM f_polic_file_config
WHERE state = 1 WHERE tenant_id = %s AND state = 1
ORDER BY name ORDER BY name
""") """, (tenant_id,))
templates = cursor.fetchall() templates = cursor.fetchall()
# 获取所有启用的输入字段 # 获取指定 tenant_id 下所有启用的输入字段
cursor.execute(""" cursor.execute("""
SELECT id, name, filed_code, field_type SELECT id, name, filed_code, field_type
FROM f_polic_field 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 ORDER BY name
""") """, (tenant_id,))
input_fields = cursor.fetchall() input_fields = cursor.fetchall()
# 获取所有启用的输出字段 # 获取指定 tenant_id 下所有启用的输出字段
cursor.execute(""" cursor.execute("""
SELECT id, name, filed_code, field_type SELECT id, name, filed_code, field_type
FROM f_polic_field 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 ORDER BY name
""") """, (tenant_id,))
output_fields = cursor.fetchall() output_fields = cursor.fetchall()
# 获取现有的关联关系 # 获取指定 tenant_id 下现有的关联关系
cursor.execute(""" cursor.execute("""
SELECT file_id, filed_id SELECT fff.file_id, fff.filed_id
FROM f_polic_file_field FROM f_polic_file_field fff
WHERE state = 1 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() relations = cursor.fetchall()
# 构建关联关系映射 (file_id -> list of filed_id) # 构建关联关系映射 (file_id -> list of filed_id)
# 注意JSON不支持set所以转换为list
relation_map = {} relation_map = {}
for rel in relations: for rel in relations:
file_id = rel['file_id'] file_id = rel['file_id']
@ -896,6 +950,7 @@ def get_template_field_relations():
relation_map[file_id].append(filed_id) relation_map[file_id].append(filed_id)
return success_response({ return success_response({
'tenant_id': tenant_id,
'templates': templates, 'templates': templates,
'input_fields': input_fields, 'input_fields': input_fields,
'output_fields': output_fields, 'output_fields': output_fields,
@ -915,6 +970,7 @@ def save_template_field_relations():
""" """
保存模板和字段的关联关系 保存模板和字段的关联关系
请求体格式: { 请求体格式: {
"tenant_id": 123,
"template_id": 123, "template_id": 123,
"input_field_ids": [1, 2, 3], "input_field_ids": [1, 2, 3],
"output_field_ids": [4, 5, 6] "output_field_ids": [4, 5, 6]
@ -926,56 +982,59 @@ def save_template_field_relations():
if not data: if not data:
return error_response(400, "请求参数不能为空") return error_response(400, "请求参数不能为空")
tenant_id = data.get('tenant_id')
template_id = data.get('template_id') template_id = data.get('template_id')
input_field_ids = data.get('input_field_ids', []) input_field_ids = data.get('input_field_ids', [])
output_field_ids = data.get('output_field_ids', []) output_field_ids = data.get('output_field_ids', [])
if not tenant_id:
return error_response(400, "tenant_id参数不能为空")
if not template_id: if not template_id:
return error_response(400, "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() conn = document_service.get_connection()
cursor = conn.cursor() cursor = conn.cursor()
try: try:
# 验证模板是否存在 # 验证模板是否存在且属于该 tenant_id
cursor.execute(""" cursor.execute("""
SELECT id FROM f_polic_file_config SELECT id FROM f_polic_file_config
WHERE id = %s AND state = 1 WHERE id = %s AND tenant_id = %s AND state = 1
""", (template_id,)) """, (template_id, tenant_id))
if not cursor.fetchone(): if not cursor.fetchone():
return error_response(400, f"模板ID {template_id} 不存在或未启用") return error_response(400, f"模板ID {template_id} 不存在或不属于该租户")
# 合并所有字段ID # 合并所有字段ID
all_field_ids = set(input_field_ids + output_field_ids) all_field_ids = set(input_field_ids + output_field_ids)
# 验证字段是否存在 # 验证字段是否存在且属于该 tenant_id
if all_field_ids: if all_field_ids:
placeholders = ','.join(['%s'] * len(all_field_ids)) placeholders = ','.join(['%s'] * len(all_field_ids))
cursor.execute(f""" cursor.execute(f"""
SELECT id FROM f_polic_field SELECT id FROM f_polic_field
WHERE id IN ({placeholders}) AND state = 1 WHERE id IN ({placeholders}) AND tenant_id = %s AND state = 1
""", list(all_field_ids)) """, list(all_field_ids) + [tenant_id])
existing_field_ids = {row[0] for row in cursor.fetchall()} existing_field_ids = {row[0] for row in cursor.fetchall()}
invalid_field_ids = all_field_ids - existing_field_ids invalid_field_ids = all_field_ids - existing_field_ids
if invalid_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(""" cursor.execute("""
DELETE FROM f_polic_file_field DELETE FROM f_polic_file_field
WHERE file_id = %s WHERE file_id = %s AND tenant_id = %s
""", (template_id,)) """, (template_id, tenant_id))
# 插入新的关联关系 # 插入新的关联关系
current_time = datetime.now() current_time = datetime.now()
created_by = 655162080928945152 # 默认创建者ID created_by = 655162080928945152 # 默认创建者ID
# 从环境变量读取tenant_id如果数据库表需要如果不需要可以设置为NULL
tenant_id = os.getenv('TENANT_ID')
if all_field_ids: if all_field_ids:
# 如果tenant_id是必填字段从环境变量读取如果可以为NULL则使用NULL
if tenant_id:
insert_sql = """ insert_sql = """
INSERT INTO f_polic_file_field INSERT INTO f_polic_file_field
(tenant_id, file_id, filed_id, created_time, created_by, updated_time, updated_by, state) (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, current_time,
created_by 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() conn.commit()
@ -1028,6 +1071,505 @@ def save_template_field_relations():
return error_response(500, f"保存关联关系失败: {str(e)}") 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__': if __name__ == '__main__':
# 确保static目录存在 # 确保static目录存在
os.makedirs('static', exist_ok=True) os.makedirs('static', exist_ok=True)

View File

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

File diff suppressed because it is too large Load Diff