重构模板保存和下载逻辑,将文件保存到本地template_finish文件夹,并更新文档服务以从本地读取模板文件。增强了错误处理,确保文件路径的有效性和安全性。

This commit is contained in:
python 2025-12-18 16:45:31 +08:00
parent fb7fb985ad
commit eec66cbe05
94 changed files with 695 additions and 28 deletions

View File

@ -0,0 +1,68 @@
# 模板字段导出说明
## 功能说明
`export_template_fields_to_excel.py` 脚本用于导出所有模板及其关联的输入字段和输出字段到Excel表格方便汇总整理模板和字段关系。
## 使用方法
```bash
python export_template_fields_to_excel.py
```
## 输出文件
脚本会在当前目录生成Excel文件文件名格式`template_fields_export_YYYYMMDD_HHMMSS.xlsx`
## Excel表格结构
生成的Excel表格包含以下列
1. **模板ID** - 模板在数据库中的唯一标识
2. **模板名称** - 模板的中文名称
3. **模板上级** - 模板的分类路径(从文件路径或模板名称推断,可能不完整,需要手动补充)
4. **输入字段** - 该模板关联的输入字段列表,格式:`字段名称(字段编码); 字段名称(字段编码)`
5. **输出字段** - 该模板关联的输出字段列表,格式:`字段名称(字段编码); 字段名称(字段编码)`
6. **输入字段数量** - 输入字段的个数
7. **输出字段数量** - 输出字段的个数
## 注意事项
1. **模板上级字段**脚本会尝试从文件路径或模板名称推断模板的分类但可能不完整或不准确。您可以在Excel中手动补充或修正。
2. **字段格式**:输入字段和输出字段以分号分隔,每个字段的格式为 `字段名称(字段编码)`
3. **数据来源**所有数据来自数据库只导出状态为启用state=1的模板和字段。
4. **后续使用**您可以基于这个Excel表格
- 手动补充或修正模板上级分类
- 新增模板和字段关系
- 创建导入脚本将修改后的数据导入数据库
## 示例数据
```
模板ID: 1765432134276990
模板名称: 1.请示报告卡(初核谈话)
模板上级: 2-初核模版/2.谈话审批
输入字段: 线索信息(clue_info); 被核查人员工作基本情况线索(target_basic_info_clue)
输出字段: 被核查人姓名(target_name); 被核查人员单位及职务(target_organization_and_position); ...
输入字段数量: 2
输出字段数量: 3
```
## 导入脚本开发建议
后续开发导入脚本时,可以参考以下步骤:
1. 读取Excel文件
2. 解析模板名称、模板上级、输入字段、输出字段
3. 根据模板名称查找或创建模板记录
4. 根据字段编码查找字段ID
5. 创建或更新模板和字段的关联关系
## 相关文件
- `export_template_fields_to_excel.py` - 导出脚本
- `template_fields_export_*.xlsx` - 生成的Excel文件

39
app.py
View File

@ -11,6 +11,7 @@ import tempfile
import zipfile
import re
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
from flask import send_file
from docx import Document
@ -1921,26 +1922,30 @@ def save_template():
new_field_map[placeholder] = field_id
# 2. 上传文件到 MinIO
minio_client = get_minio_client()
bucket_name = os.getenv('MINIO_BUCKET', 'finyx')
# 2. 保存文件到本地 template_finish 文件夹
# 获取项目根目录
project_root = Path(__file__).parent
templates_dir = project_root / "template_finish"
# 确保存储桶存在
if not minio_client.bucket_exists(bucket_name):
minio_client.make_bucket(bucket_name)
# 创建 uploaded 子目录用于存放新上传的模板
uploaded_dir = templates_dir / "uploaded"
uploaded_dir.mkdir(parents=True, exist_ok=True)
# 生成 MinIO 路径
# 生成本地文件路径(使用时间戳确保文件名唯一)
now = datetime.now()
object_name = f'{tenant_id}/TEMPLATE/{now.year}/{now.month:02d}/{filename}'
timestamp = now.strftime('%Y%m%d_%H%M%S')
# 处理文件名,确保安全
safe_filename = secure_filename(filename)
# 如果文件名已存在,添加时间戳前缀
local_file_path = uploaded_dir / f"{timestamp}_{safe_filename}"
minio_client.fput_object(
bucket_name,
object_name,
temp_file_path,
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document'
)
# 复制文件到本地
import shutil
shutil.copy2(temp_file_path, local_file_path)
minio_path = f"/{object_name}"
# 生成相对路径(相对于项目根目录,使用正斜杠)
local_path = local_file_path.relative_to(project_root)
local_path_str = str(local_path).replace('\\', '/')
# 3. 保存模板到数据库
template_id = generate_id()
@ -1956,7 +1961,7 @@ def save_template():
None, # parent_id
template_name_final,
json.dumps({'template_name': template_name_final}, ensure_ascii=False),
minio_path,
local_path_str,
created_by,
updated_by
))
@ -2005,7 +2010,7 @@ def save_template():
return success_response({
'template_id': template_id,
'template_name': template_name_final,
'file_path': minio_path,
'file_path': local_path_str,
'field_count': len(all_field_ids)
}, "模板保存成功")

View File

@ -0,0 +1,328 @@
"""
导出模板和字段关系到Excel表格
用于汇总整理模板和字段关系后续可以基于这个Excel表格新增数据并增加导入脚本
"""
import pymysql
import os
from dotenv import load_dotenv
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
from datetime import datetime
import re
# 加载环境变量
load_dotenv()
# 数据库配置
DB_CONFIG = {
'host': os.getenv('DB_HOST'),
'port': int(os.getenv('DB_PORT', 3306)),
'user': os.getenv('DB_USER'),
'password': os.getenv('DB_PASSWORD'),
'database': os.getenv('DB_NAME'),
'charset': 'utf8mb4'
}
TENANT_ID = 615873064429507639
def clean_query_result(data):
"""清理查询结果,将 bytes 类型转换为字符串"""
if isinstance(data, bytes):
if len(data) == 1:
return int.from_bytes(data, byteorder='big')
try:
return data.decode('utf-8')
except UnicodeDecodeError:
return data.decode('utf-8', errors='ignore')
elif isinstance(data, dict):
return {key: clean_query_result(value) for key, value in data.items()}
elif isinstance(data, list):
return [clean_query_result(item) for item in data]
elif isinstance(data, (int, float, str, bool, type(None))):
return data
else:
return str(data)
def extract_template_category(file_path, template_name):
"""
从文件路径或模板名称提取模板的上级分类
例如/615873064429507639/TEMPLATE/2025/12/2-初核模版/2.谈话审批/走读式谈话审批/2谈话审批表.docx
提取为2-初核模版/2.谈话审批/走读式谈话审批
"""
category = ""
# 首先尝试从文件路径提取
if file_path:
# 移除开头的斜杠和租户ID部分
path = file_path.lstrip('/')
# 移除租户ID/TEMPLATE/年份/月份/部分
pattern = r'^\d+/TEMPLATE/\d+/\d+/(.+)'
match = re.match(pattern, path)
if match:
full_path = match.group(1)
# 移除文件名,只保留目录路径
if '/' in full_path:
category = '/'.join(full_path.split('/')[:-1])
# 如果路径格式不匹配,尝试其他方式
if not category and ('template_finish' in path.lower() or '初核' in path or '谈话' in path or '函询' in path):
# 尝试提取目录结构
parts = path.split('/')
result_parts = []
for part in parts:
if any(keyword in part for keyword in ['初核', '谈话', '函询', '模版', '模板']):
result_parts.append(part)
if result_parts:
category = '/'.join(result_parts[:-1]) if len(result_parts) > 1 else result_parts[0]
# 如果从路径无法提取,尝试从模板名称推断
if not category and template_name:
# 根据模板名称中的关键词推断分类
if '初核' in template_name:
if '谈话' in template_name:
category = '2-初核模版/2.谈话审批'
elif '请示' in template_name or '审批' in template_name:
category = '2-初核模版/1.初核请示'
elif '结论' in template_name or '报告' in template_name:
category = '2-初核模版/3.初核结论'
else:
category = '2-初核模版'
elif '谈话' in template_name:
if '函询' in template_name:
category = '1-谈话函询模板/函询模板'
else:
category = '1-谈话函询模板/谈话模版'
elif '函询' in template_name:
category = '1-谈话函询模板/函询模板'
return category
def get_all_templates_with_fields():
"""
获取所有模板及其关联的输入和输出字段
Returns:
list: 模板列表每个模板包含字段信息
"""
conn = pymysql.connect(**DB_CONFIG)
cursor = conn.cursor(pymysql.cursors.DictCursor)
try:
# 查询所有启用的模板
cursor.execute("""
SELECT
fc.id AS template_id,
fc.name AS template_name,
fc.file_path
FROM f_polic_file_config fc
WHERE fc.tenant_id = %s
AND fc.state = 1
ORDER BY fc.name
""", (TENANT_ID,))
templates = cursor.fetchall()
templates = [clean_query_result(t) for t in templates]
result = []
for template in templates:
template_id = template['template_id']
template_name = template['template_name']
file_path = template.get('file_path', '')
# 提取模板上级分类
template_category = extract_template_category(file_path, template_name)
# 查询该模板关联的输入字段
cursor.execute("""
SELECT
f.id AS field_id,
f.name AS field_name,
f.filed_code AS field_code
FROM f_polic_file_field fff
INNER JOIN f_polic_field f ON fff.filed_id = f.id
WHERE fff.file_id = %s
AND fff.tenant_id = %s
AND fff.state = 1
AND f.state = 1
AND f.field_type = 1
ORDER BY f.name
""", (template_id, TENANT_ID))
input_fields = cursor.fetchall()
input_fields = [clean_query_result(f) for f in input_fields]
# 查询该模板关联的输出字段
cursor.execute("""
SELECT
f.id AS field_id,
f.name AS field_name,
f.filed_code AS field_code
FROM f_polic_file_field fff
INNER JOIN f_polic_field f ON fff.filed_id = f.id
WHERE fff.file_id = %s
AND fff.tenant_id = %s
AND fff.state = 1
AND f.state = 1
AND f.field_type = 2
ORDER BY f.name
""", (template_id, TENANT_ID))
output_fields = cursor.fetchall()
output_fields = [clean_query_result(f) for f in output_fields]
# 格式化字段信息
input_fields_str = '; '.join([f"{f['field_name']}({f['field_code']})" for f in input_fields])
output_fields_str = '; '.join([f"{f['field_name']}({f['field_code']})" for f in output_fields])
result.append({
'template_id': template_id,
'template_name': template_name,
'template_category': template_category,
'input_fields': input_fields,
'output_fields': output_fields,
'input_fields_str': input_fields_str,
'output_fields_str': output_fields_str,
'input_field_count': len(input_fields),
'output_field_count': len(output_fields)
})
return result
finally:
cursor.close()
conn.close()
def create_excel_file(templates_data, output_file='template_fields_export.xlsx'):
"""
创建Excel文件
Args:
templates_data: 模板数据列表
output_file: 输出文件名
"""
wb = Workbook()
ws = wb.active
ws.title = "模板字段关系"
# 设置表头
headers = ['模板ID', '模板名称', '模板上级', '输入字段', '输出字段', '输入字段数量', '输出字段数量']
ws.append(headers)
# 设置表头样式
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF", size=11)
header_alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num)
cell.fill = header_fill
cell.font = header_font
cell.alignment = header_alignment
cell.border = border
# 填充数据
data_font = Font(size=10)
data_alignment = Alignment(horizontal="left", vertical="top", wrap_text=True)
for template in templates_data:
row = [
template['template_id'],
template['template_name'],
template['template_category'],
template['input_fields_str'],
template['output_fields_str'],
template['input_field_count'],
template['output_field_count']
]
ws.append(row)
# 设置数据行样式
for col_num in range(1, len(headers) + 1):
cell = ws.cell(row=ws.max_row, column=col_num)
cell.font = data_font
cell.alignment = data_alignment
cell.border = border
# 设置列宽
ws.column_dimensions['A'].width = 18 # 模板ID
ws.column_dimensions['B'].width = 40 # 模板名称
ws.column_dimensions['C'].width = 50 # 模板上级
ws.column_dimensions['D'].width = 60 # 输入字段
ws.column_dimensions['E'].width = 80 # 输出字段
ws.column_dimensions['F'].width = 15 # 输入字段数量
ws.column_dimensions['G'].width = 15 # 输出字段数量
# 设置行高
ws.row_dimensions[1].height = 30 # 表头行高
for row_num in range(2, ws.max_row + 1):
ws.row_dimensions[row_num].height = 60 # 数据行高
# 冻结首行
ws.freeze_panes = 'A2'
# 保存文件
wb.save(output_file)
print(f"Excel文件已生成: {output_file}")
print(f"共导出 {len(templates_data)} 个模板")
def main():
"""主函数"""
print("开始导出模板和字段关系...")
print("=" * 80)
try:
# 获取所有模板及其字段
templates_data = get_all_templates_with_fields()
if not templates_data:
print("未找到任何模板数据")
return
print(f"共找到 {len(templates_data)} 个模板")
# 生成Excel文件
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_file = f"template_fields_export_{timestamp}.xlsx"
create_excel_file(templates_data, output_file)
# 打印统计信息
print("\n统计信息:")
print(f" 模板总数: {len(templates_data)}")
total_input_fields = sum(t['input_field_count'] for t in templates_data)
total_output_fields = sum(t['output_field_count'] for t in templates_data)
print(f" 输入字段总数: {total_input_fields}")
print(f" 输出字段总数: {total_output_fields}")
# 打印前几个模板的信息
print("\n前5个模板预览:")
for i, template in enumerate(templates_data[:5], 1):
print(f"\n{i}. {template['template_name']}")
print(f" 上级: {template['template_category']}")
print(f" 输入字段: {template['input_field_count']}")
print(f" 输出字段: {template['output_field_count']}")
if len(templates_data) > 5:
print(f"\n... 还有 {len(templates_data) - 5} 个模板")
except Exception as e:
print(f"导出失败: {str(e)}")
import traceback
traceback.print_exc()
if __name__ == '__main__':
main()

View File

@ -117,10 +117,10 @@ class DocumentService:
def download_template_from_minio(self, file_path: str) -> str:
"""
MinIO下载模板文件到临时目录
本地template_finish文件夹读取模板文件到临时目录
Args:
file_path: MinIO中的相对路径 '/615873064429507639/TEMPLATE/2024/11/初步核实审批表模板.docx'
file_path: 本地相对路径相对于项目根目录 'template_finish/2-初核模版/1.初核请示/1.请示报告卡XXX.docx'
Returns:
本地临时文件路径
@ -129,23 +129,34 @@ class DocumentService:
if not file_path:
raise Exception("模板文件路径不能为空请检查数据库中模板配置的file_path字段")
client = self.get_minio_client()
# 获取项目根目录document_service.py在services/目录下,需要向上一级)
project_root = Path(__file__).parent.parent
local_template_path = project_root / file_path
# 检查文件是否存在
if not local_template_path.exists():
raise Exception(f"模板文件不存在: {local_template_path}。请检查数据库中的file_path配置是否正确。")
if not local_template_path.is_file():
raise Exception(f"路径不是文件: {local_template_path}")
# 创建临时文件
temp_dir = tempfile.gettempdir()
temp_file = os.path.join(temp_dir, f"template_{datetime.now().strftime('%Y%m%d%H%M%S')}.docx")
# 使用原文件名和扩展名,但添加时间戳确保唯一性
original_name = local_template_path.name
name_without_ext = local_template_path.stem
ext = local_template_path.suffix
temp_file = os.path.join(temp_dir, f"template_{name_without_ext}_{datetime.now().strftime('%Y%m%d%H%M%S')}{ext}")
try:
# 从相对路径中提取对象名称(去掉开头的/
object_name = file_path.lstrip('/')
# 下载文件
client.fget_object(self.bucket_name, object_name, temp_file)
# 复制文件到临时目录
import shutil
shutil.copy2(local_template_path, temp_file)
return temp_file
except S3Error as e:
raise Exception(f"MinIO下载模板文件失败: {str(e)}")
except Exception as e:
raise Exception(f"本地读取模板文件失败: {str(e)}")
def replace_placeholders_via_xml(self, docx_path: str, field_data: Dict[str, str]) -> bool:
"""

Binary file not shown.

View File

@ -0,0 +1,255 @@
"""
更新数据库中的模板路径将MinIO路径改为本地相对路径
"""
import os
import pymysql
from pathlib import Path
from typing import Dict, List, Optional
from dotenv import load_dotenv
import difflib
# 加载环境变量
load_dotenv()
# 数据库配置
DB_CONFIG = {
'host': os.getenv('DB_HOST', '152.136.177.240'),
'port': int(os.getenv('DB_PORT', 5012)),
'user': os.getenv('DB_USER', 'finyx'),
'password': os.getenv('DB_PASSWORD', '6QsGK6MpePZDE57Z'),
'database': os.getenv('DB_NAME', 'finyx'),
'charset': 'utf8mb4'
}
TENANT_ID = 615873064429507639
# 本地模板目录(相对于项目根目录)
PROJECT_ROOT = Path(__file__).parent
TEMPLATES_DIR = PROJECT_ROOT / "template_finish"
def print_section(title):
"""打印章节标题"""
print("\n" + "="*70)
print(f" {title}")
print("="*70)
def print_result(success, message):
"""打印结果"""
status = "[OK]" if success else "[FAIL]"
print(f"{status} {message}")
def scan_local_templates(base_dir: Path) -> Dict[str, Path]:
"""
扫描本地模板文件
Returns:
字典key为文件名不含路径value为相对路径相对于项目根目录
"""
templates = {}
if not base_dir.exists():
print_result(False, f"模板目录不存在: {base_dir}")
return templates
# 遍历所有文件
for file_path in base_dir.rglob('*'):
if file_path.is_file():
# 只处理文档文件
if file_path.suffix.lower() in ['.doc', '.docx', '.wps']:
# 获取相对路径(相对于项目根目录)
relative_path = file_path.relative_to(PROJECT_ROOT)
# 使用正斜杠作为路径分隔符(跨平台兼容)
relative_path_str = str(relative_path).replace('\\', '/')
# 使用文件名作为key不含路径
file_name = file_path.name
templates[file_name] = relative_path_str
return templates
def get_db_templates(conn) -> Dict[str, List[Dict]]:
"""
从数据库获取所有模板配置
Returns:
字典key为文件名从file_path中提取value为模板信息列表可能有多个同名文件
"""
cursor = conn.cursor(pymysql.cursors.DictCursor)
try:
sql = """
SELECT id, name, file_path
FROM f_polic_file_config
WHERE tenant_id = %s
AND state = 1
AND file_path IS NOT NULL
AND file_path != ''
"""
cursor.execute(sql, (TENANT_ID,))
templates = cursor.fetchall()
# 构建字典:文件名 -> 模板信息列表
result = {}
for template in templates:
file_path = template['file_path']
if file_path:
# 从file_path中提取文件名可能是MinIO路径或本地路径
# 处理各种路径格式
file_name = Path(file_path).name
if file_name not in result:
result[file_name] = []
result[file_name].append({
'id': template['id'],
'name': template['name'],
'file_path': file_path
})
return result
finally:
cursor.close()
def find_best_match(local_file_name: str, db_file_names: List[str], threshold: float = 0.8) -> Optional[str]:
"""
使用模糊匹配找到最佳匹配的文件名
Args:
local_file_name: 本地文件名
db_file_names: 数据库中的文件名列表
threshold: 相似度阈值0-1之间
Returns:
最佳匹配的文件名如果没有找到则返回None
"""
if not db_file_names:
return None
# 先尝试精确匹配
if local_file_name in db_file_names:
return local_file_name
# 使用模糊匹配
matches = difflib.get_close_matches(local_file_name, db_file_names, n=1, cutoff=threshold)
if matches:
return matches[0]
return None
def update_template_path(conn, template_id: int, new_path: str, old_path: str):
"""更新数据库中的模板路径"""
cursor = conn.cursor()
try:
sql = """
UPDATE f_polic_file_config
SET file_path = %s
WHERE id = %s
"""
cursor.execute(sql, (new_path, template_id))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
conn.rollback()
raise e
finally:
cursor.close()
def main():
"""主函数"""
print_section("更新模板路径从MinIO路径改为本地相对路径")
# 1. 扫描本地模板文件
print_section("1. 扫描本地模板文件")
local_templates = scan_local_templates(TEMPLATES_DIR)
print_result(True, f"找到 {len(local_templates)} 个本地模板文件")
if not local_templates:
print_result(False, "未找到本地模板文件,请检查 template_finish 目录")
return
# 2. 连接数据库
print_section("2. 连接数据库")
try:
conn = pymysql.connect(**DB_CONFIG)
print_result(True, "数据库连接成功")
except Exception as e:
print_result(False, f"数据库连接失败: {str(e)}")
return
try:
# 3. 获取数据库中的模板
print_section("3. 获取数据库中的模板配置")
db_templates = get_db_templates(conn)
print_result(True, f"找到 {sum(len(v) for v in db_templates.values())} 条数据库模板记录")
# 4. 匹配并更新路径
print_section("4. 匹配并更新路径")
updated_count = 0
skipped_count = 0
not_found_count = 0
# 遍历数据库中的模板
for db_file_name, template_list in db_templates.items():
# 查找本地匹配的文件
local_path = local_templates.get(db_file_name)
if not local_path:
# 尝试模糊匹配
local_file_names = list(local_templates.keys())
matched_name = find_best_match(db_file_name, local_file_names)
if matched_name:
local_path = local_templates[matched_name]
print(f" [模糊匹配] {db_file_name} -> {matched_name}")
if local_path:
# 更新所有匹配的模板记录
for template in template_list:
old_path = template['file_path']
# 检查是否已经是本地路径(避免重复更新)
if old_path.startswith('template_finish/'):
print(f" [跳过] ID={template['id']}, 名称={template['name']}, 已经是本地路径: {old_path}")
skipped_count += 1
continue
# 更新路径
try:
update_template_path(conn, template['id'], local_path, old_path)
print(f" [更新] ID={template['id']}, 名称={template['name']}")
print(f" 旧路径: {old_path}")
print(f" 新路径: {local_path}")
updated_count += 1
except Exception as e:
print(f" [错误] ID={template['id']}, 更新失败: {str(e)}")
else:
# 未找到匹配的本地文件
for template in template_list:
print(f" [未找到] ID={template['id']}, 名称={template['name']}, 文件名={db_file_name}")
not_found_count += 1
# 5. 输出统计信息
print_section("5. 更新结果统计")
print_result(True, f"成功更新: {updated_count} 条记录")
if skipped_count > 0:
print_result(True, f"跳过(已是本地路径): {skipped_count} 条记录")
if not_found_count > 0:
print_result(False, f"未找到匹配文件: {not_found_count} 条记录")
print_section("更新完成")
finally:
conn.close()
print_result(True, "数据库连接已关闭")
if __name__ == "__main__":
main()