1121 lines
43 KiB
Python
1121 lines
43 KiB
Python
#!/usr/bin/env python
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
表单数据操作 API(异步版本)
|
|
动态操作表单数据,支持主表和子表的 CRUD
|
|
支持操作权限和数据权限控制
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import tempfile
|
|
import uuid
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File, Form
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from online_dev.form_data_manager.schema import (
|
|
FormDataCreateIn,
|
|
FormDataCursorListOut,
|
|
FormDataListOut,
|
|
FormDataUpdateIn,
|
|
)
|
|
from online_dev.form_data_manager.db_error_handler import format_error_message, handle_db_error
|
|
from online_dev.form_data_manager.service import (
|
|
FormDataService,
|
|
MAX_IMPORT_EXPORT_ROWS, SERVER_MEMORY_GB,
|
|
)
|
|
from online_dev.form_data_manager.dependencies import (
|
|
check_form_permission,
|
|
get_user_form_permissions,
|
|
get_data_scope_filter,
|
|
get_user_info,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/form-data", tags=["表单数据"])
|
|
|
|
|
|
# ============ 权限查询 ============
|
|
|
|
@router.get("/{form_code}/permissions", summary="获取当前用户的表单权限")
|
|
async def get_form_permissions(
|
|
form_code: str,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
获取当前用户对该表单的操作权限
|
|
|
|
返回格式:
|
|
{
|
|
"view": true,
|
|
"add": true,
|
|
"edit": true,
|
|
"delete": false,
|
|
"export": true,
|
|
"import": false
|
|
}
|
|
"""
|
|
return await get_user_form_permissions(form_code, request, db)
|
|
|
|
|
|
@router.get("/{form_code}/field-permissions", summary="获取当前用户的字段权限")
|
|
async def get_field_permissions(
|
|
form_code: str,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
获取当前用户对该表单的字段权限
|
|
|
|
返回格式:
|
|
{
|
|
"field_name": {
|
|
"permission_type": "read" | "write" | "hidden" | "masked",
|
|
"mask_rule": "phone" | "email" | "id_card" | "name" | null
|
|
},
|
|
...
|
|
}
|
|
"""
|
|
from core.resource_scope.field_permission.service import ResourceFieldPermissionService
|
|
|
|
# 获取用户信息
|
|
user_info = await get_user_info(request)
|
|
if not user_info or not user_info.get('role_ids'):
|
|
return {}
|
|
|
|
role_ids = user_info['role_ids']
|
|
resource_type = f"form:{form_code}"
|
|
|
|
# 获取字段权限配置
|
|
configs = await ResourceFieldPermissionService.get_by_roles_and_resource(
|
|
db, role_ids, resource_type
|
|
)
|
|
|
|
if not configs:
|
|
return {}
|
|
|
|
# 合并权限
|
|
merged_perms = await ResourceFieldPermissionService.merge_field_permissions(
|
|
configs, "most_permissive"
|
|
)
|
|
|
|
return merged_perms or {}
|
|
|
|
|
|
# ============ 表单数据 CRUD ============
|
|
|
|
@router.get("/{form_code}/list", response_model=FormDataListOut, summary="查询表单数据列表")
|
|
async def list_form_data(
|
|
request: Request,
|
|
form_code: str,
|
|
page: int = Query(1, description="页码"),
|
|
page_size: int = Query(20, alias="pageSize", description="每页条数"),
|
|
sort_fields: str = Query(None, alias="sortFields", description="排序字段(多个用逗号分隔)"),
|
|
sort_orders: str = Query(None, alias="sortOrders", description="排序方向(多个用逗号分隔)"),
|
|
search: str = Query(None, description="搜索关键词"),
|
|
search_fields: str = Query(None, alias="search_fields", description="搜索字段(多个用逗号分隔)"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""查询表单数据列表(带数据权限过滤)"""
|
|
import time
|
|
_api_t0 = time.perf_counter()
|
|
|
|
# 权限校验:查看权限
|
|
await check_form_permission(form_code, "view", request, db)
|
|
|
|
_api_t1 = time.perf_counter()
|
|
logger.info(f"[API list 耗时] 权限校验: {(_api_t1 - _api_t0) * 1000:.1f}ms")
|
|
|
|
# 从查询参数中提取过滤条件
|
|
filters = {}
|
|
# 临时存储 gte 和 lte 条件,用于后续合并
|
|
range_conditions = {}
|
|
|
|
for key, value in request.query_params.items():
|
|
if key not in ("page", "pageSize", "sortFields", "sortOrders", "search", "search_fields") and value:
|
|
# 支持 filter_field 格式(多选过滤,值用逗号分隔)
|
|
if key.startswith("filter_"):
|
|
field = key[7:] # 去掉 "filter_" 前缀
|
|
filter_values = [v.strip() for v in value.split(',') if v.strip()]
|
|
if filter_values:
|
|
filters[field] = {"type": "in", "value": filter_values}
|
|
# 支持 field__type 格式,如 name__like, name__gte, name__lte
|
|
elif "__" in key:
|
|
field, filter_type = key.rsplit("__", 1)
|
|
# case_sensitive 是字段过滤的修饰符,合并到已有过滤条件中
|
|
if filter_type == "case_sensitive":
|
|
if field in filters and isinstance(filters[field], dict):
|
|
filters[field]["case_sensitive"] = value.lower() in ("true", "1")
|
|
continue
|
|
# 收集 gte 和 lte 条件,稍后合并
|
|
if filter_type in ("gte", "lte"):
|
|
if field not in range_conditions:
|
|
range_conditions[field] = {}
|
|
range_conditions[field][filter_type] = value
|
|
else:
|
|
filters[field] = {"type": filter_type, "value": value}
|
|
else:
|
|
filters[key] = value
|
|
|
|
# 合并 gte 和 lte 条件为 range 查询
|
|
for field, conditions in range_conditions.items():
|
|
if "gte" in conditions and "lte" in conditions:
|
|
# 同时有 gte 和 lte,合并为 range
|
|
filters[field] = {"type": "range", "value": [conditions["gte"], conditions["lte"]]}
|
|
elif "gte" in conditions:
|
|
filters[field] = {"type": "gte", "value": conditions["gte"]}
|
|
elif "lte" in conditions:
|
|
filters[field] = {"type": "lte", "value": conditions["lte"]}
|
|
|
|
# 解析多字段排序
|
|
sort_list = []
|
|
if sort_fields:
|
|
fields = [f.strip() for f in sort_fields.split(',') if f.strip()]
|
|
orders = [o.strip() for o in sort_orders.split(',')] if sort_orders else []
|
|
|
|
for i, field in enumerate(fields):
|
|
order = orders[i] if i < len(orders) else 'desc'
|
|
sort_list.append({'field': field, 'order': order})
|
|
|
|
# 获取数据权限过滤条件
|
|
data_scope = await get_data_scope_filter(form_code, request, db)
|
|
|
|
_api_t2 = time.perf_counter()
|
|
logger.info(f"[API list 耗时] 数据权限配置获取: {(_api_t2 - _api_t1) * 1000:.1f}ms")
|
|
|
|
# 获取用户信息
|
|
user_info = await get_user_info(request)
|
|
|
|
# 解析搜索字段
|
|
search_field_list = None
|
|
if search and search_fields:
|
|
search_field_list = [f.strip() for f in search_fields.split(',') if f.strip()]
|
|
|
|
try:
|
|
service = await FormDataService.create_service(db, form_code)
|
|
|
|
_api_t3 = time.perf_counter()
|
|
logger.info(f"[API list 耗时] create_service: {(_api_t3 - _api_t2) * 1000:.1f}ms")
|
|
|
|
result = await service.list(
|
|
db=db,
|
|
page=page,
|
|
page_size=page_size,
|
|
filters=filters if filters else None,
|
|
sort_list=sort_list if sort_list else None,
|
|
data_scope=data_scope,
|
|
search=search,
|
|
search_fields=search_field_list
|
|
)
|
|
|
|
_api_t4 = time.perf_counter()
|
|
logger.info(f"[API list 耗时] ===== API 总耗时: {(_api_t4 - _api_t0) * 1000:.1f}ms (form={form_code}, page={page}) =====")
|
|
|
|
return result
|
|
except Exception as e:
|
|
raise handle_db_error(e)
|
|
|
|
|
|
@router.get("/{form_code}/list/cursor", response_model=FormDataCursorListOut, summary="游标分页查询表单数据列表")
|
|
async def list_form_data_cursor(
|
|
request: Request,
|
|
form_code: str,
|
|
cursor: str = Query(None, description="游标值"),
|
|
page_size: int = Query(20, alias="pageSize", description="每页条数"),
|
|
cursor_direction: str = Query("next", alias="cursorDirection", description="翻页方向: next/prev"),
|
|
sort_fields: str = Query(None, alias="sortFields", description="排序字段(多个用逗号分隔)"),
|
|
sort_orders: str = Query(None, alias="sortOrders", description="排序方向(多个用逗号分隔)"),
|
|
search: str = Query(None, description="搜索关键词"),
|
|
search_fields: str = Query(None, alias="search_fields", description="搜索字段(多个用逗号分隔)"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""游标分页查询表单数据列表(跳过 COUNT,适用于大数据量场景)"""
|
|
await check_form_permission(form_code, "view", request, db)
|
|
|
|
# 提取过滤条件(复用已有逻辑)
|
|
filters = {}
|
|
range_conditions = {}
|
|
excluded_keys = {"page", "pageSize", "sortFields", "sortOrders", "search",
|
|
"search_fields", "cursor", "cursorDirection"}
|
|
|
|
for key, value in request.query_params.items():
|
|
if key not in excluded_keys and value:
|
|
if key.startswith("filter_"):
|
|
field = key[7:]
|
|
filter_values = [v.strip() for v in value.split(',') if v.strip()]
|
|
if filter_values:
|
|
filters[field] = {"type": "in", "value": filter_values}
|
|
elif "__" in key:
|
|
field, filter_type = key.rsplit("__", 1)
|
|
if filter_type == "case_sensitive":
|
|
if field in filters and isinstance(filters[field], dict):
|
|
filters[field]["case_sensitive"] = value.lower() in ("true", "1")
|
|
continue
|
|
if filter_type in ("gte", "lte"):
|
|
if field not in range_conditions:
|
|
range_conditions[field] = {}
|
|
range_conditions[field][filter_type] = value
|
|
else:
|
|
filters[field] = {"type": filter_type, "value": value}
|
|
else:
|
|
filters[key] = value
|
|
|
|
for field, conditions in range_conditions.items():
|
|
if "gte" in conditions and "lte" in conditions:
|
|
filters[field] = {"type": "range", "value": [conditions["gte"], conditions["lte"]]}
|
|
elif "gte" in conditions:
|
|
filters[field] = {"type": "gte", "value": conditions["gte"]}
|
|
elif "lte" in conditions:
|
|
filters[field] = {"type": "lte", "value": conditions["lte"]}
|
|
|
|
sort_list = []
|
|
if sort_fields:
|
|
fields = [f.strip() for f in sort_fields.split(',') if f.strip()]
|
|
orders = [o.strip() for o in sort_orders.split(',')] if sort_orders else []
|
|
for i, field in enumerate(fields):
|
|
order = orders[i] if i < len(orders) else 'desc'
|
|
sort_list.append({'field': field, 'order': order})
|
|
|
|
data_scope = await get_data_scope_filter(form_code, request, db)
|
|
|
|
search_field_list = None
|
|
if search and search_fields:
|
|
search_field_list = [f.strip() for f in search_fields.split(',') if f.strip()]
|
|
|
|
try:
|
|
service = await FormDataService.create_service(db, form_code)
|
|
result = await service.list_cursor(
|
|
db=db,
|
|
page_size=page_size,
|
|
cursor=cursor,
|
|
cursor_direction=cursor_direction,
|
|
filters=filters if filters else None,
|
|
sort_list=sort_list if sort_list else None,
|
|
data_scope=data_scope,
|
|
search=search,
|
|
search_fields=search_field_list
|
|
)
|
|
return result
|
|
except Exception as e:
|
|
raise handle_db_error(e)
|
|
|
|
|
|
@router.get("/{form_code}/tree/children", summary="获取树形子节点(懒加载)")
|
|
async def get_tree_children(
|
|
form_code: str,
|
|
request: Request,
|
|
parent_id: str = Query(None, alias="parentId", description="父节点ID,为空获取根节点"),
|
|
parent_field: str = Query("parent_id", alias="parentField", description="父节点字段名"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
获取树形数据的子节点(用于懒加载模式)
|
|
- parent_id 为空时获取根节点(parent_field 为空或 NULL 的记录)
|
|
- parent_id 有值时获取指定父节点的子节点
|
|
"""
|
|
# 权限校验:查看权限
|
|
await check_form_permission(form_code, "view", request, db)
|
|
|
|
# 获取数据权限
|
|
data_scope = await get_data_scope_filter(form_code, request, db)
|
|
|
|
try:
|
|
service = await FormDataService.create_service(db, form_code)
|
|
return await service.get_tree_children(
|
|
db=db,
|
|
parent_id=parent_id,
|
|
parent_field=parent_field,
|
|
data_scope=data_scope
|
|
)
|
|
except Exception as e:
|
|
raise handle_db_error(e)
|
|
|
|
|
|
@router.get("/{form_code}/field-values/{field_name}", summary="获取字段唯一值列表")
|
|
async def get_field_values(
|
|
form_code: str,
|
|
field_name: str,
|
|
page: int = Query(1, description="页码"),
|
|
page_size: int = Query(20, alias="pageSize", description="每页条数"),
|
|
search: str = Query(None, description="搜索关键词"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""获取指定字段的唯一值列表(用于过滤选项)"""
|
|
try:
|
|
service = await FormDataService.create_service(db, form_code)
|
|
return await service.get_field_values(
|
|
db=db,
|
|
field_name=field_name,
|
|
page=page,
|
|
page_size=page_size,
|
|
search=search
|
|
)
|
|
except Exception as e:
|
|
raise handle_db_error(e)
|
|
|
|
|
|
@router.get("/{form_code}/check-unique", summary="检查字段值唯一性")
|
|
async def check_unique(
|
|
form_code: str,
|
|
field: str = Query(..., description="字段名"),
|
|
value: str = Query(..., description="字段值"),
|
|
exclude_id: str = Query(None, alias="excludeId", description="排除的记录ID(编辑时排除自身)"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""检查指定字段的值在数据库中是否唯一"""
|
|
try:
|
|
service = await FormDataService.create_service(db, form_code)
|
|
is_unique = await service.check_unique(
|
|
db=db,
|
|
field_name=field,
|
|
value=value,
|
|
exclude_id=exclude_id
|
|
)
|
|
return {"unique": is_unique}
|
|
except Exception as e:
|
|
raise handle_db_error(e)
|
|
|
|
|
|
@router.get("/{form_code}/detail/{pk}", summary="获取表单数据详情")
|
|
async def get_form_data(
|
|
form_code: str,
|
|
pk: str,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""获取单条表单数据(含子表)"""
|
|
# 权限校验:查看权限
|
|
await check_form_permission(form_code, "view", request, db)
|
|
|
|
try:
|
|
service = await FormDataService.create_service(db, form_code)
|
|
return await service.get(db, pk)
|
|
except Exception as e:
|
|
raise handle_db_error(e)
|
|
|
|
|
|
@router.post("/{form_code}", summary="新增表单数据")
|
|
async def create_form_data(
|
|
form_code: str,
|
|
data: FormDataCreateIn,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""新增表单数据(含子表)"""
|
|
# 权限校验:新增权限
|
|
await check_form_permission(form_code, "add", request, db)
|
|
|
|
try:
|
|
service = await FormDataService.create_service(db, form_code)
|
|
return await service.create(db, data.model_dump())
|
|
except Exception as e:
|
|
raise handle_db_error(e)
|
|
|
|
|
|
@router.put("/{form_code}/{pk}", summary="更新表单数据")
|
|
async def update_form_data(
|
|
form_code: str,
|
|
pk: str,
|
|
data: FormDataUpdateIn,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""更新表单数据(含子表)"""
|
|
# 权限校验:编辑权限
|
|
await check_form_permission(form_code, "edit", request, db)
|
|
|
|
try:
|
|
service = await FormDataService.create_service(db, form_code)
|
|
return await service.update(db, pk, data.model_dump())
|
|
except Exception as e:
|
|
raise handle_db_error(e)
|
|
|
|
|
|
@router.delete("/{form_code}/{pk}", summary="删除表单数据")
|
|
async def delete_form_data(
|
|
form_code: str,
|
|
pk: str,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""删除表单数据(含子表)"""
|
|
# 权限校验:删除权限
|
|
await check_form_permission(form_code, "delete", request, db)
|
|
|
|
try:
|
|
service = await FormDataService.create_service(db, form_code)
|
|
await service.delete(db, pk)
|
|
return {"success": True}
|
|
except Exception as e:
|
|
raise handle_db_error(e)
|
|
|
|
|
|
@router.delete("/{form_code}/batch/delete", summary="批量删除表单数据")
|
|
async def batch_delete_form_data(
|
|
form_code: str,
|
|
request: Request,
|
|
ids: List[str] = Query(..., description="ID列表"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""批量删除表单数据"""
|
|
# 权限校验:删除权限
|
|
await check_form_permission(form_code, "delete", request, db)
|
|
|
|
try:
|
|
service = await FormDataService.create_service(db, form_code)
|
|
count = await service.batch_delete(db, ids)
|
|
return {"count": count}
|
|
except Exception as e:
|
|
raise handle_db_error(e)
|
|
|
|
|
|
# ============ 导入导出 ============
|
|
|
|
# 临时导出文件存储(file_id -> 文件路径,仅当前 worker 进程可见)
|
|
_export_temp_files: Dict[str, Path] = {}
|
|
|
|
|
|
def _get_export_temp_dir() -> Path:
|
|
return Path(tempfile.gettempdir()) / "form_export"
|
|
|
|
|
|
def _is_valid_export_file_id(file_id: str) -> bool:
|
|
if not file_id or "/" in file_id or "\\" in file_id or ".." in file_id:
|
|
return False
|
|
try:
|
|
uuid.UUID(file_id)
|
|
except ValueError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _resolve_export_file_path(file_id: str) -> Optional[Path]:
|
|
"""解析导出文件路径。多 worker 时内存索引不共享,需回退到共享临时目录。"""
|
|
if not _is_valid_export_file_id(file_id):
|
|
return None
|
|
file_path = _export_temp_files.get(file_id)
|
|
if file_path and file_path.exists():
|
|
return file_path
|
|
temp_dir = _get_export_temp_dir().resolve()
|
|
disk_path = (temp_dir / f"{file_id}.xlsx").resolve()
|
|
try:
|
|
disk_path.relative_to(temp_dir)
|
|
except ValueError:
|
|
return None
|
|
if disk_path.exists():
|
|
return disk_path
|
|
return None
|
|
|
|
|
|
def _parse_export_query_params(query_params: dict) -> Tuple[
|
|
Dict[str, Any], List[Dict[str, str]], Optional[str], Optional[List[str]]
|
|
]:
|
|
"""从前端传来的 queryParams 解析出 filters, sort_list, search, search_fields"""
|
|
filters: Dict[str, Any] = {}
|
|
range_conditions: Dict[str, dict] = {}
|
|
sort_list: List[Dict[str, str]] = []
|
|
search: Optional[str] = None
|
|
search_fields: Optional[List[str]] = None
|
|
|
|
for key, value in query_params.items():
|
|
if not value and value != 0:
|
|
continue
|
|
|
|
if key == "sortFields":
|
|
sort_fields_str = str(value)
|
|
sort_orders_str = str(query_params.get("sortOrders", ""))
|
|
fields = [f.strip() for f in sort_fields_str.split(',') if f.strip()]
|
|
orders = [o.strip() for o in sort_orders_str.split(',')] if sort_orders_str else []
|
|
for i, field in enumerate(fields):
|
|
order = orders[i] if i < len(orders) else 'desc'
|
|
sort_list.append({'field': field, 'order': order})
|
|
continue
|
|
|
|
if key in ("sortOrders",):
|
|
continue
|
|
|
|
if key == "search":
|
|
search = str(value)
|
|
continue
|
|
|
|
if key == "search_fields":
|
|
search_fields = [f.strip() for f in str(value).split(',') if f.strip()]
|
|
continue
|
|
|
|
if key.startswith("filter_"):
|
|
field = key[7:]
|
|
filter_values = [v.strip() for v in str(value).split(',') if v.strip()]
|
|
if filter_values:
|
|
filters[field] = {"type": "in", "value": filter_values}
|
|
elif "__" in key:
|
|
field, filter_type = key.rsplit("__", 1)
|
|
# case_sensitive 是修饰符,合并到已有过滤条件中
|
|
if filter_type == "case_sensitive":
|
|
if field in filters and isinstance(filters[field], dict):
|
|
filters[field]["case_sensitive"] = str(value).lower() in ("true", "1")
|
|
continue
|
|
if filter_type in ("gte", "lte"):
|
|
if field not in range_conditions:
|
|
range_conditions[field] = {}
|
|
range_conditions[field][filter_type] = value
|
|
else:
|
|
filters[field] = {"type": filter_type, "value": value}
|
|
else:
|
|
filters[key] = value
|
|
|
|
for field, conditions in range_conditions.items():
|
|
if "gte" in conditions and "lte" in conditions:
|
|
filters[field] = {"type": "range", "value": [conditions["gte"], conditions["lte"]]}
|
|
elif "gte" in conditions:
|
|
filters[field] = {"type": "gte", "value": conditions["gte"]}
|
|
elif "lte" in conditions:
|
|
filters[field] = {"type": "lte", "value": conditions["lte"]}
|
|
|
|
return filters, sort_list, search, search_fields
|
|
|
|
|
|
@router.get("/import-export/config", summary="获取导入导出配置")
|
|
async def get_import_export_config():
|
|
"""返回服务器内存决定的导入导出行数上限"""
|
|
return {
|
|
"maxRows": MAX_IMPORT_EXPORT_ROWS,
|
|
"serverMemoryGB": SERVER_MEMORY_GB,
|
|
}
|
|
|
|
|
|
@router.post("/{form_code}/export/task", summary="导出表单数据到 Excel")
|
|
async def export_form_data(
|
|
form_code: str,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
导出表单数据到 Excel(流式导出,支持大数据量)
|
|
|
|
- 使用分批查询避免内存溢出
|
|
- 支持字段选择和子表导出
|
|
- 支持查询条件过滤导出(与列表查询保持一致)
|
|
- 最大导出行数根据服务器内存动态计算
|
|
"""
|
|
# 权限校验:导出权限
|
|
await check_form_permission(form_code, "export", request, db)
|
|
|
|
try:
|
|
body = await request.json()
|
|
include_sub_tables = body.get("includeSubTables", False)
|
|
selected_fields = body.get("selectedFields", [])
|
|
query_params = body.get("queryParams", {})
|
|
|
|
if include_sub_tables and selected_fields and "id" not in selected_fields:
|
|
selected_fields = ["id"] + selected_fields
|
|
|
|
filters, sort_list, search, search_fields = _parse_export_query_params(query_params)
|
|
|
|
service = await FormDataService.create_service(db, form_code)
|
|
|
|
from online_dev.form_data_manager.dependencies import get_data_scope_filter
|
|
data_scope = await get_data_scope_filter(form_code, request, db)
|
|
|
|
excel_buffer = await service.export_to_excel_streaming(
|
|
db=db,
|
|
selected_fields=selected_fields if selected_fields else None,
|
|
include_sub_tables=include_sub_tables,
|
|
batch_size=1000,
|
|
data_scope=data_scope,
|
|
filters=filters if filters else None,
|
|
sort_list=sort_list if sort_list else None,
|
|
search=search,
|
|
search_fields=search_fields
|
|
)
|
|
|
|
return StreamingResponse(
|
|
excel_buffer,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={
|
|
"Content-Disposition": f"attachment; filename={form_code}_export.xlsx"
|
|
}
|
|
)
|
|
except Exception as e:
|
|
raise handle_db_error(e)
|
|
|
|
|
|
@router.post("/{form_code}/export/sse", summary="SSE 导出表单数据(带进度)")
|
|
async def export_form_data_sse(
|
|
form_code: str,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
通过 SSE 导出表单数据,实时推送导出进度。
|
|
|
|
SSE 事件类型:
|
|
- progress: {processed, total, percent} 每批处理后推送
|
|
- completed: {fileId, total} 导出完成,返回临时文件 ID 用于下载
|
|
- error: {message} 导出失败
|
|
"""
|
|
await check_form_permission(form_code, "export", request, db)
|
|
|
|
body = await request.json()
|
|
include_sub_tables = body.get("includeSubTables", False)
|
|
selected_fields = body.get("selectedFields", [])
|
|
query_params = body.get("queryParams", {})
|
|
|
|
if include_sub_tables and selected_fields and "id" not in selected_fields:
|
|
selected_fields = ["id"] + selected_fields
|
|
|
|
filters, sort_list, search, search_fields = _parse_export_query_params(query_params)
|
|
|
|
from online_dev.form_data_manager.dependencies import get_data_scope_filter
|
|
data_scope = await get_data_scope_filter(form_code, request, db)
|
|
|
|
service = await FormDataService.create_service(db, form_code)
|
|
|
|
progress_queue: asyncio.Queue = asyncio.Queue()
|
|
|
|
async def on_progress(processed: int, total: int, stage: str = "querying"):
|
|
if stage == "generating":
|
|
percent = 99.0
|
|
elif total > 0:
|
|
percent = round(processed / total * 90, 1)
|
|
else:
|
|
percent = 0
|
|
await progress_queue.put(
|
|
f"event: progress\ndata: {json.dumps({'processed': processed, 'total': total, 'percent': percent, 'stage': stage}, ensure_ascii=False)}\n\n"
|
|
)
|
|
|
|
async def run_export():
|
|
try:
|
|
logger.debug("SSE run_export 开始执行")
|
|
excel_buffer = await service.export_to_excel_streaming(
|
|
db=db,
|
|
selected_fields=selected_fields if selected_fields else None,
|
|
include_sub_tables=include_sub_tables,
|
|
batch_size=1000,
|
|
data_scope=data_scope,
|
|
filters=filters if filters else None,
|
|
sort_list=sort_list if sort_list else None,
|
|
search=search,
|
|
search_fields=search_fields,
|
|
on_progress=on_progress
|
|
)
|
|
logger.debug("SSE export_to_excel_streaming 完成,开始写文件")
|
|
|
|
file_id = str(uuid.uuid4())
|
|
temp_dir = _get_export_temp_dir()
|
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
file_path = temp_dir / f"{file_id}.xlsx"
|
|
buffer_bytes = excel_buffer.getvalue()
|
|
file_path.write_bytes(buffer_bytes)
|
|
_export_temp_files[file_id] = file_path
|
|
logger.debug(f"SSE 导出文件已保存: {file_path}, 大小: {len(buffer_bytes)} bytes")
|
|
|
|
async def cleanup():
|
|
await asyncio.sleep(300)
|
|
_export_temp_files.pop(file_id, None)
|
|
try:
|
|
file_path.unlink(missing_ok=True)
|
|
except Exception:
|
|
pass
|
|
asyncio.create_task(cleanup())
|
|
|
|
completed_msg = f"event: completed\ndata: {json.dumps({'fileId': file_id, 'total': len(buffer_bytes)}, ensure_ascii=False)}\n\n"
|
|
logger.debug(f"SSE 即将推送 completed 事件: fileId={file_id}")
|
|
await progress_queue.put(completed_msg)
|
|
logger.debug("SSE completed 事件已推送到队列")
|
|
except Exception as e:
|
|
logger.error(f"SSE 导出失败: {e}", exc_info=True)
|
|
try:
|
|
await progress_queue.put(
|
|
f"event: error\ndata: {json.dumps({'message': format_error_message(e, max_length=500)}, ensure_ascii=False)}\n\n"
|
|
)
|
|
except Exception as e2:
|
|
logger.error(f"SSE 推送 error 事件也失败: {e2}", exc_info=True)
|
|
finally:
|
|
logger.debug("SSE run_export finally: 推送 None 结束信号")
|
|
await progress_queue.put(None)
|
|
|
|
async def event_generator():
|
|
export_task = asyncio.ensure_future(run_export())
|
|
try:
|
|
while True:
|
|
msg = await progress_queue.get()
|
|
if msg is None:
|
|
break
|
|
yield msg
|
|
except (asyncio.CancelledError, GeneratorExit):
|
|
export_task.cancel()
|
|
finally:
|
|
if not export_task.done():
|
|
export_task.cancel()
|
|
|
|
return StreamingResponse(
|
|
event_generator(),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
}
|
|
)
|
|
|
|
|
|
@router.get("/{form_code}/export/download/{file_id}", summary="下载导出的临时文件")
|
|
async def download_export_file(
|
|
form_code: str,
|
|
file_id: str,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""下载 SSE 导出生成的临时 Excel 文件"""
|
|
await check_form_permission(form_code, "export", request, db)
|
|
|
|
file_path = _resolve_export_file_path(file_id)
|
|
if not file_path:
|
|
raise HTTPException(status_code=404, detail="导出文件不存在或已过期,请重新导出")
|
|
|
|
def file_stream():
|
|
with open(file_path, "rb") as f:
|
|
while chunk := f.read(8192):
|
|
yield chunk
|
|
# 下载完成后清理
|
|
_export_temp_files.pop(file_id, None)
|
|
try:
|
|
file_path.unlink(missing_ok=True)
|
|
except Exception:
|
|
pass
|
|
|
|
return StreamingResponse(
|
|
file_stream(),
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={
|
|
"Content-Disposition": f"attachment; filename={form_code}_export.xlsx"
|
|
}
|
|
)
|
|
|
|
|
|
@router.get("/{form_code}/import/template", summary="下载导入模板")
|
|
async def download_import_template(
|
|
form_code: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""下载导入模板"""
|
|
try:
|
|
service = await FormDataService.create_service(db, form_code)
|
|
template_buffer = await service.get_import_template()
|
|
|
|
return StreamingResponse(
|
|
template_buffer,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={
|
|
"Content-Disposition": f"attachment; filename={form_code}_template.xlsx"
|
|
}
|
|
)
|
|
except Exception as e:
|
|
raise handle_db_error(e)
|
|
|
|
|
|
@router.post("/{form_code}/import/excel", summary="从 Excel 导入数据")
|
|
async def import_form_data(
|
|
form_code: str,
|
|
request: Request,
|
|
file: UploadFile = File(..., description="Excel 文件 (.xlsx)"),
|
|
mode: str = Form(default="append", description="导入模式:append(追加)或 overwrite(覆盖)"),
|
|
data_handling: str = Form(default="insert_only", description="数据处理方式:insert_only / update_only / upsert"),
|
|
match_field: str = Form(default="", description="更新模式下用于匹配已有数据的字段名"),
|
|
validate_only: bool = Form(default=False, description="是否仅验证数据,不执行实际导入"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
从 Excel 导入数据
|
|
|
|
- mode: 导入模式
|
|
- append: 追加模式(默认),保留现有数据
|
|
- overwrite: 覆盖模式,先清空表再导入
|
|
- data_handling: 数据处理方式(追加模式下有效)
|
|
- insert_only: 仅新增(默认)
|
|
- update_only: 仅更新已有数据,不新增
|
|
- upsert: 更新已有数据,不存在则新增
|
|
- match_field: 更新/upsert 模式下用于匹配已有数据的字段名
|
|
- validate_only: 仅验证模式,只检查数据是否合规,不执行实际导入
|
|
"""
|
|
await check_form_permission(form_code, "import", request, db)
|
|
|
|
if not file.filename.endswith(".xlsx"):
|
|
raise HTTPException(status_code=400, detail="只支持 .xlsx 格式")
|
|
|
|
if mode not in ("append", "overwrite"):
|
|
raise HTTPException(status_code=400, detail="mode 参数只能是 append 或 overwrite")
|
|
|
|
if data_handling not in ("insert_only", "update_only", "upsert"):
|
|
raise HTTPException(status_code=400, detail="data_handling 参数只能是 insert_only、update_only 或 upsert")
|
|
|
|
if mode == "append" and data_handling in ("update_only", "upsert") and not match_field:
|
|
raise HTTPException(status_code=400, detail="更新模式下必须指定 match_field")
|
|
|
|
try:
|
|
service = await FormDataService.create_service(db, form_code)
|
|
content = await file.read()
|
|
success, fail, errors = await service.import_from_excel(
|
|
db, content, mode=mode, validate_only=validate_only,
|
|
data_handling=data_handling, match_field=match_field or None
|
|
)
|
|
|
|
if validate_only:
|
|
meta = {}
|
|
real_errors = errors
|
|
if errors and isinstance(errors[-1], dict) and errors[-1].get("_meta"):
|
|
meta = errors[-1]
|
|
real_errors = errors[:-1]
|
|
return {
|
|
"success": success,
|
|
"fail": fail,
|
|
"message": f"数据验证完成:{success} 条通过,{fail} 条失败",
|
|
"errors": real_errors,
|
|
"validated": True,
|
|
"will_insert": meta.get("will_insert", success),
|
|
"will_update": meta.get("will_update", 0),
|
|
"action": meta.get("action", ""),
|
|
}
|
|
|
|
return {
|
|
"success": success,
|
|
"fail": fail,
|
|
"message": f"成功导入 {success} 条,失败 {fail} 条",
|
|
"errors": errors
|
|
}
|
|
except Exception as e:
|
|
raise handle_db_error(e)
|
|
|
|
|
|
@router.post("/{form_code}/import/validate/sse", summary="SSE 验证导入数据(带进度)")
|
|
async def validate_import_data_sse(
|
|
form_code: str,
|
|
request: Request,
|
|
file: UploadFile = File(..., description="Excel 文件 (.xlsx)"),
|
|
mode: str = Form(default="append", description="导入模式:append(追加)或 overwrite(覆盖)"),
|
|
data_handling: str = Form(default="insert_only", description="数据处理方式:insert_only / update_only / upsert"),
|
|
match_field: str = Form(default="", description="更新模式下用于匹配已有数据的字段名"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
通过 SSE 验证导入数据,实时推送解析进度。
|
|
|
|
SSE 事件类型:
|
|
- progress: {processed, total, percent, stage} 解析进度
|
|
- completed: {success, fail, errors, validated} 验证完成
|
|
- error: {message} 验证失败
|
|
"""
|
|
await check_form_permission(form_code, "import", request, db)
|
|
|
|
if not file.filename or not file.filename.endswith(".xlsx"):
|
|
raise HTTPException(status_code=400, detail="只支持 .xlsx 格式")
|
|
|
|
if mode not in ("append", "overwrite"):
|
|
raise HTTPException(status_code=400, detail="mode 参数只能是 append 或 overwrite")
|
|
|
|
if data_handling not in ("insert_only", "update_only", "upsert"):
|
|
raise HTTPException(status_code=400, detail="data_handling 参数只能是 insert_only、update_only 或 upsert")
|
|
|
|
if mode == "append" and data_handling in ("update_only", "upsert") and not match_field:
|
|
raise HTTPException(status_code=400, detail="更新模式下必须指定 match_field")
|
|
|
|
content = await file.read()
|
|
service = await FormDataService.create_service(db, form_code)
|
|
|
|
progress_queue: asyncio.Queue = asyncio.Queue()
|
|
|
|
async def on_progress(processed: int, total: int, stage: str, success: int, fail: int):
|
|
if total > 0:
|
|
if stage == "parsing":
|
|
percent = round(processed / total * 60, 1)
|
|
elif stage == "validating":
|
|
percent = 60 + round(processed / total * 39, 1)
|
|
else:
|
|
percent = round(processed / total * 99, 1)
|
|
else:
|
|
percent = 0
|
|
await progress_queue.put(
|
|
f"event: progress\ndata: {json.dumps({'processed': processed, 'total': total, 'percent': percent, 'stage': stage}, ensure_ascii=False)}\n\n"
|
|
)
|
|
|
|
async def run_validate():
|
|
try:
|
|
logger.debug(f"SSE validate 开始: form_code={form_code}")
|
|
success, fail, errors = await service.import_from_excel(
|
|
db, content, mode=mode, validate_only=True,
|
|
data_handling=data_handling,
|
|
match_field=match_field or None,
|
|
on_progress=on_progress
|
|
)
|
|
# 提取 _meta 信息(errors 列表末尾可能含 _meta 字典)
|
|
meta = {}
|
|
real_errors = errors
|
|
if errors and isinstance(errors[-1], dict) and errors[-1].get("_meta"):
|
|
meta = errors[-1]
|
|
real_errors = errors[:-1]
|
|
|
|
completed_data = {
|
|
"success": success,
|
|
"fail": fail,
|
|
"errors": real_errors,
|
|
"message": f"数据验证完成:{success} 条通过,{fail} 条失败",
|
|
"validated": True,
|
|
"will_insert": meta.get("will_insert", success),
|
|
"will_update": meta.get("will_update", 0),
|
|
"action": meta.get("action", ""),
|
|
}
|
|
logger.debug(f"SSE validate 完成: success={success}, fail={fail}")
|
|
await progress_queue.put(
|
|
f"event: completed\ndata: {json.dumps(completed_data, ensure_ascii=False)}\n\n"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"SSE 验证失败: {e}", exc_info=True)
|
|
try:
|
|
await progress_queue.put(
|
|
f"event: error\ndata: {json.dumps({'message': format_error_message(e, max_length=500)}, ensure_ascii=False)}\n\n"
|
|
)
|
|
except Exception as e2:
|
|
logger.error(f"SSE 推送 error 事件也失败: {e2}", exc_info=True)
|
|
finally:
|
|
await progress_queue.put(None)
|
|
|
|
async def event_generator():
|
|
validate_task = asyncio.ensure_future(run_validate())
|
|
try:
|
|
while True:
|
|
msg = await progress_queue.get()
|
|
if msg is None:
|
|
break
|
|
yield msg
|
|
except (asyncio.CancelledError, GeneratorExit):
|
|
validate_task.cancel()
|
|
finally:
|
|
if not validate_task.done():
|
|
validate_task.cancel()
|
|
|
|
return StreamingResponse(
|
|
event_generator(),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
}
|
|
)
|
|
|
|
|
|
@router.post("/{form_code}/import/sse", summary="SSE 导入表单数据(带进度)")
|
|
async def import_form_data_sse(
|
|
form_code: str,
|
|
request: Request,
|
|
file: UploadFile = File(..., description="Excel 文件 (.xlsx)"),
|
|
mode: str = Form(default="append", description="导入模式:append(追加)或 overwrite(覆盖)"),
|
|
data_handling: str = Form(default="insert_only", description="数据处理方式:insert_only / update_only / upsert"),
|
|
match_field: str = Form(default="", description="更新模式下用于匹配已有数据的字段名"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
通过 SSE 导入表单数据,实时推送导入进度。
|
|
|
|
SSE 事件类型:
|
|
- progress: {processed, total, percent, stage, success, fail}
|
|
- stage="parsing" 解析 Excel 阶段
|
|
- stage="importing" 写入数据库阶段
|
|
- completed: {success, fail, errors, message} 导入完成
|
|
- error: {message} 导入失败
|
|
"""
|
|
await check_form_permission(form_code, "import", request, db)
|
|
|
|
if not file.filename or not file.filename.endswith(".xlsx"):
|
|
raise HTTPException(status_code=400, detail="只支持 .xlsx 格式")
|
|
|
|
if mode not in ("append", "overwrite"):
|
|
raise HTTPException(status_code=400, detail="mode 参数只能是 append 或 overwrite")
|
|
|
|
if data_handling not in ("insert_only", "update_only", "upsert"):
|
|
raise HTTPException(status_code=400, detail="data_handling 参数只能是 insert_only、update_only 或 upsert")
|
|
|
|
if mode == "append" and data_handling in ("update_only", "upsert") and not match_field:
|
|
raise HTTPException(status_code=400, detail="更新模式下必须指定 match_field")
|
|
|
|
content = await file.read()
|
|
service = await FormDataService.create_service(db, form_code)
|
|
|
|
progress_queue: asyncio.Queue = asyncio.Queue()
|
|
|
|
async def on_progress(processed: int, total: int, stage: str, success: int, fail: int):
|
|
if total > 0:
|
|
if stage == "parsing":
|
|
percent = round(processed / total * 20, 1)
|
|
elif stage == "validating":
|
|
percent = 20 + round(processed / total * 10, 1)
|
|
else:
|
|
percent = 30 + round(processed / total * 69, 1)
|
|
else:
|
|
percent = 0
|
|
await progress_queue.put(
|
|
f"event: progress\ndata: {json.dumps({'processed': processed, 'total': total, 'percent': percent, 'stage': stage, 'success': success, 'fail': fail}, ensure_ascii=False)}\n\n"
|
|
)
|
|
|
|
async def run_import():
|
|
try:
|
|
logger.debug(f"SSE import 开始: form_code={form_code}, mode={mode}, data_handling={data_handling}")
|
|
success, fail, errors = await service.import_from_excel(
|
|
db, content, mode=mode, validate_only=False,
|
|
data_handling=data_handling,
|
|
match_field=match_field or None,
|
|
on_progress=on_progress
|
|
)
|
|
completed_data = {
|
|
"success": success,
|
|
"fail": fail,
|
|
"errors": errors,
|
|
"message": f"成功导入 {success} 条,失败 {fail} 条"
|
|
}
|
|
logger.debug(f"SSE import 完成: success={success}, fail={fail}")
|
|
await progress_queue.put(
|
|
f"event: completed\ndata: {json.dumps(completed_data, ensure_ascii=False)}\n\n"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"SSE 导入失败: {e}", exc_info=True)
|
|
try:
|
|
await progress_queue.put(
|
|
f"event: error\ndata: {json.dumps({'message': format_error_message(e, max_length=500)}, ensure_ascii=False)}\n\n"
|
|
)
|
|
except Exception as e2:
|
|
logger.error(f"SSE 推送 error 事件也失败: {e2}", exc_info=True)
|
|
finally:
|
|
await progress_queue.put(None)
|
|
|
|
async def event_generator():
|
|
import_task = asyncio.ensure_future(run_import())
|
|
try:
|
|
while True:
|
|
msg = await progress_queue.get()
|
|
if msg is None:
|
|
break
|
|
yield msg
|
|
except (asyncio.CancelledError, GeneratorExit):
|
|
import_task.cancel()
|
|
finally:
|
|
if not import_task.done():
|
|
import_task.cancel()
|
|
|
|
return StreamingResponse(
|
|
event_generator(),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
}
|
|
)
|