feat: restore source parity and harden agent runtime

This commit is contained in:
2026-06-22 11:17:26 +08:00
parent e33f08277b
commit 0793eb82d6
596 changed files with 168879 additions and 290 deletions
@@ -0,0 +1,603 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""报表模板管理服务"""
import logging
from typing import Any, Dict, List, Optional
from sqlalchemy import select, update, delete, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from online_dev.report_manager.model import ReportTemplate, ReportVersion
from online_dev.report_manager.enums import ReportVersionState
from online_dev.report_manager.constants import default_snapshot, default_cells
from online_dev.report_manager.exceptions import ReportServiceException
from app.data_scope_utils import get_data_scope_filter, apply_data_scope_to_conditions
logger = logging.getLogger(__name__)
RESOURCE_TYPE = "report"
RESOURCE_DISPLAY_NAME = "报表管理"
class ReportService:
@staticmethod
def _format_dt(dt) -> str:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else ""
@staticmethod
async def list_with_data_scope(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
application_id: str = None,
name: str = None,
code: str = None,
category: str = None,
status: str = None,
) -> Dict[str, Any]:
conditions = [ReportTemplate.is_deleted == False]
if application_id:
conditions.append(ReportTemplate.application_id == application_id)
else:
conditions.append(ReportTemplate.application_id.is_(None))
if name:
conditions.append(ReportTemplate.name.ilike(f"%{name}%"))
if code:
conditions.append(ReportTemplate.code.ilike(f"%{code}%"))
if category:
conditions.append(ReportTemplate.category == category)
if status:
conditions.append(ReportTemplate.status == status)
data_scope_filter = await get_data_scope_filter(db, RESOURCE_TYPE)
scope_conditions = apply_data_scope_to_conditions(ReportTemplate, data_scope_filter)
conditions.extend(scope_conditions)
count_stmt = select(func.count(ReportTemplate.id)).where(and_(*conditions))
total = (await db.execute(count_stmt)).scalar() or 0
offset = (page - 1) * page_size
stmt = (
select(ReportTemplate)
.where(and_(*conditions))
.order_by(ReportTemplate.sort, ReportTemplate.sys_create_datetime.desc())
.offset(offset)
.limit(page_size)
)
items = list((await db.execute(stmt)).scalars().all())
return {"items": items, "total": total}
@staticmethod
async def get(db: AsyncSession, template_id: str) -> ReportTemplate:
stmt = select(ReportTemplate).where(
ReportTemplate.id == template_id,
ReportTemplate.is_deleted == False,
)
tpl = (await db.execute(stmt)).scalar_one_or_none()
if not tpl:
raise ReportServiceException(f"报表不存在: {template_id}")
return tpl
@staticmethod
async def get_by_code(db: AsyncSession, code: str) -> ReportTemplate:
stmt = select(ReportTemplate).where(
ReportTemplate.code == code,
ReportTemplate.is_deleted == False,
)
tpl = (await db.execute(stmt)).scalar_one_or_none()
if not tpl:
raise ReportServiceException(f"报表不存在: {code}")
return tpl
@staticmethod
async def get_active_version_id(db: AsyncSession, template_id: str) -> Optional[str]:
stmt = select(ReportVersion.id).where(
ReportVersion.template_id == template_id,
ReportVersion.state == ReportVersionState.ACTIVE,
ReportVersion.is_deleted == False,
)
return (await db.execute(stmt)).scalar_one_or_none()
@staticmethod
async def create(db: AsyncSession, data: Dict[str, Any], user_id: str = None) -> ReportTemplate:
code = data.get("code")
stmt = select(ReportTemplate).where(
ReportTemplate.code == code,
ReportTemplate.is_deleted == False,
)
if (await db.execute(stmt)).scalar_one_or_none():
raise ReportServiceException(f"报表编码已存在: {code}")
from utils.context import get_current_user_info_from_context
user_info = get_current_user_info_from_context()
tpl = ReportTemplate(
application_id=data.get("application_id"),
name=data.get("name"),
code=code,
category=data.get("category", ""),
description=data.get("description", ""),
sort=data.get("sort", 0),
sys_creator_id=user_id or (user_info.get("user_id") if user_info else None),
sys_modifier_id=user_id or (user_info.get("user_id") if user_info else None),
)
if user_info and user_info.get("dept_id"):
tpl.sys_dept_id = user_info.get("dept_id")
db.add(tpl)
await db.flush()
version = ReportVersion(
template_id=tpl.id,
version=1,
state=ReportVersionState.DESIGNING,
snapshot=default_snapshot(),
cells=default_cells(),
sys_creator_id=tpl.sys_creator_id,
sys_modifier_id=tpl.sys_modifier_id,
sys_dept_id=tpl.sys_dept_id,
)
db.add(version)
await db.commit()
await db.refresh(tpl)
logger.info("报表创建成功: %s", tpl.code)
return tpl
@staticmethod
async def update(
db: AsyncSession,
template_id: str,
data: Dict[str, Any],
user_id: str = None,
) -> ReportTemplate:
tpl = await ReportService.get(db, template_id)
for key in ("name", "category", "description", "sort", "allow_export", "allow_print",
"allow_watermark", "watermark_config"):
if key in data and data[key] is not None:
setattr(tpl, key, data[key])
tpl.sys_modifier_id = user_id
await db.commit()
await db.refresh(tpl)
return tpl
@staticmethod
async def delete(db: AsyncSession, template_id: str) -> bool:
from core.menu.service import MenuService
tpl = await ReportService.get(db, template_id)
await ReportService._cleanup_report_publish_resources(db, tpl)
tpl.is_deleted = True
tpl.status = "draft"
stmt = update(ReportVersion).where(
ReportVersion.template_id == template_id,
ReportVersion.is_deleted == False,
).values(is_deleted=True)
await db.execute(stmt)
await db.commit()
await MenuService.invalidate_cache()
logger.info("报表删除成功: %s", tpl.code)
return True
@staticmethod
async def batch_delete(db: AsyncSession, template_ids: List[str]) -> int:
count = 0
for tid in template_ids:
if await ReportService.delete(db, tid):
count += 1
return count
@staticmethod
async def get_categories(db: AsyncSession, application_id: str = None) -> List[str]:
conditions = [ReportTemplate.is_deleted == False, ReportTemplate.category != ""]
if application_id:
conditions.append(ReportTemplate.application_id == application_id)
else:
conditions.append(ReportTemplate.application_id.is_(None))
stmt = select(ReportTemplate.category).where(and_(*conditions)).distinct()
rows = (await db.execute(stmt)).scalars().all()
return sorted({r for r in rows if r})
@staticmethod
async def copy(
db: AsyncSession,
template_id: str,
new_code: str,
new_name: str = None,
user_id: str = None,
) -> ReportTemplate:
src = await ReportService.get(db, template_id)
stmt = select(ReportTemplate).where(
ReportTemplate.code == new_code,
ReportTemplate.is_deleted == False,
)
if (await db.execute(stmt)).scalar_one_or_none():
raise ReportServiceException(f"报表编码已存在: {new_code}")
new_tpl = await ReportService.create(
db,
{
"application_id": src.application_id,
"name": new_name or f"{src.name}_copy",
"code": new_code,
"category": src.category,
"description": src.description,
"sort": src.sort,
},
user_id,
)
from online_dev.report_manager.version_service import ReportVersionService
src_version = await ReportVersionService.get_designing_or_latest(db, template_id)
if src_version:
await ReportVersionService.copy_version_content(
db, src_version.id, new_tpl.id, user_id
)
return new_tpl
@staticmethod
async def check_import(db: AsyncSession, code: str) -> Dict[str, bool]:
stmt = select(ReportTemplate).where(
ReportTemplate.code == code,
ReportTemplate.is_deleted == False,
)
exists = (await db.execute(stmt)).scalar_one_or_none() is not None
return {"code_exists": exists, "can_import": not exists}
@staticmethod
async def export_config(db: AsyncSession, template_id: str) -> Dict[str, Any]:
"""导出报表配置包(模板 + 设计中/最新版本 + 数据集)"""
from online_dev.report_manager.version_service import ReportVersionService
from online_dev.report_manager.dataset_bridge import ReportDatasetBridge
tpl = await ReportService.get(db, template_id)
version = await ReportVersionService.get_designing_or_latest(db, template_id)
if not version:
raise ReportServiceException("没有可导出的版本")
datasets_raw = await ReportDatasetBridge.list_by_version(db, version.id)
datasets = [
{
"data_source_id": d.get("data_source_id"),
"data_source_code": d.get("data_source_code"),
"data_source_name": d.get("data_source_name"),
"alias": d.get("alias"),
"field_mapping": d.get("field_mapping") or {},
"convert_config": d.get("convert_config") or {},
"sort": d.get("sort", 0),
}
for d in datasets_raw
]
return {
"schema_version": 1,
"name": tpl.name,
"code": tpl.code,
"category": tpl.category or "",
"description": tpl.description or "",
"sort": tpl.sort or 0,
"allow_export": bool(tpl.allow_export),
"allow_print": bool(tpl.allow_print),
"allow_watermark": bool(tpl.allow_watermark),
"watermark_config": tpl.watermark_config or {},
"version": {
"snapshot": version.snapshot or {},
"cells": version.cells or {},
"query_list": version.query_list or [],
"sort_list": version.sort_list or [],
"column_list": version.column_list or [],
"fence_list": version.fence_list or [],
"convert_config": version.convert_config or {},
},
"datasets": datasets,
}
@staticmethod
async def import_config(
db: AsyncSession,
data: Dict[str, Any],
user_id: str = None,
) -> ReportTemplate:
"""导入报表配置包"""
from core.data_source.service import DataSourceService
from online_dev.report_manager.version_service import ReportVersionService
from online_dev.report_manager.dataset_bridge import ReportDatasetBridge
for field in ("name", "code"):
if not data.get(field):
raise ReportServiceException(f"缺少必要字段: {field}")
code = data["code"]
check = await ReportService.check_import(db, code)
if check["code_exists"]:
raise ReportServiceException(f"报表编码已存在: {code}")
tpl = await ReportService.create(
db,
{
"application_id": data.get("application_id"),
"name": data["name"],
"code": code,
"category": data.get("category", ""),
"description": data.get("description", ""),
"sort": data.get("sort", 0),
},
user_id,
)
for key in ("allow_export", "allow_print", "allow_watermark", "watermark_config"):
if key in data and data[key] is not None:
setattr(tpl, key, data[key])
version = await ReportVersionService.get_designing_or_latest(db, tpl.id)
if not version:
raise ReportServiceException("导入后未找到设计版本")
version_data = data.get("version")
if not version_data and data.get("versions"):
version_data = data["versions"][0] if data["versions"] else None
if version_data:
version.snapshot = version_data.get("snapshot") or version.snapshot
version.cells = version_data.get("cells") or version.cells
version.query_list = version_data.get("query_list") or version.query_list
version.sort_list = version_data.get("sort_list") or version.sort_list
version.column_list = version_data.get("column_list") or version.column_list
version.fence_list = version_data.get("fence_list") or version.fence_list
version.convert_config = version_data.get("convert_config") or version.convert_config
version.sys_modifier_id = user_id
data_set_list = []
skipped_aliases = []
for idx, item in enumerate(data.get("datasets") or []):
ds_id = item.get("data_source_id") or item.get("dataSourceId")
ds_code = item.get("data_source_code") or item.get("dataSourceCode")
if not ds_id and ds_code:
source = await DataSourceService.get_by_code(db, ds_code)
if source:
ds_id = source.id
if not ds_id:
alias = item.get("alias") or f"ds_{idx}"
skipped_aliases.append(alias)
continue
data_set_list.append({
"data_source_id": ds_id,
"alias": item.get("alias") or f"ds_{idx}",
"field_mapping": item.get("field_mapping") or item.get("fieldMapping") or {},
"convert_config": item.get("convert_config") or item.get("convertConfig") or {},
"sort": item.get("sort", idx),
})
await ReportDatasetBridge.sync_datasets(db, version.id, data_set_list)
await db.commit()
await db.refresh(tpl)
if skipped_aliases:
logger.warning(
"导入报表 %s 时跳过未匹配的数据集: %s",
code,
", ".join(skipped_aliases),
)
logger.info("报表配置导入成功: %s", code)
return tpl
# ============ 发布到菜单 ============
@staticmethod
def _report_menu_query(template: ReportTemplate) -> Dict[str, Any]:
return {
"relationId": str(template.id),
"moduleId": str(template.id),
"templateId": str(template.id),
"reportCode": template.code,
}
@staticmethod
async def _create_report_permissions(
db: AsyncSession,
template: ReportTemplate,
menu_id: str,
) -> None:
from core.permission.model import Permission
from online_dev.report_manager.permission_templates import REPORT_ACTIONS
http_method_map = {"GET": 0, "POST": 1, "PUT": 2, "DELETE": 3, "PATCH": 4, "ALL": 5}
for idx, (action, name, enabled, http_method_int, http_method_str, api_path) in enumerate(
REPORT_ACTIONS
):
perm_code = f"report:{template.code}:{action}"
path = api_path.replace("{template_id}", str(template.id))
existing = (
await db.execute(
select(Permission).where(
Permission.menu_id == menu_id,
Permission.code == perm_code,
Permission.is_deleted == False,
)
)
).scalar_one_or_none()
if existing:
existing.is_active = enabled and (
action not in ("export", "export_pdf")
or bool(template.allow_export)
)
existing.name = f"{template.name}-{name}"
existing.api_path = path
existing.http_method = http_method_map.get(http_method_str, http_method_int)
continue
db.add(
Permission(
menu_id=menu_id,
name=f"{template.name}-{name}",
code=perm_code,
permission_type=1,
api_path=path,
http_method=http_method_map.get(http_method_str, http_method_int),
is_active=enabled
and (
action not in ("export", "export_pdf")
or bool(template.allow_export)
),
sort=idx,
)
)
@staticmethod
async def _cleanup_report_publish_resources(db: AsyncSession, template: ReportTemplate) -> None:
"""清理报表发布产生的菜单与 API 权限(取消发布、删除时共用)"""
from core.menu.model import Menu
from core.permission.model import Permission
menu_path = f"/report-render/{template.code}"
delete_menu_stmt = delete(Menu).where(Menu.path == menu_path)
menu_result = await db.execute(delete_menu_stmt)
if menu_result.rowcount > 0:
logger.info(
"物理删除报表菜单: %s, 删除数量: %s",
template.code,
menu_result.rowcount,
)
delete_perm_stmt = delete(Permission).where(
Permission.code.like(f"report:{template.code}:%")
)
perm_result = await db.execute(delete_perm_stmt)
if perm_result.rowcount > 0:
logger.info(
"物理删除报表 API 权限: %s, 删除数量: %s",
template.code,
perm_result.rowcount,
)
@staticmethod
async def publish(
db: AsyncSession,
template_id: str,
publish_config: Dict[str, Any] = None,
) -> ReportTemplate:
"""发布报表并创建 online_report 菜单"""
from core.menu.model import Menu
from core.menu.service import MenuService
from online_dev.report_manager.version_service import ReportVersionService
tpl = await ReportService.get(db, template_id)
version = await ReportVersionService.get_active(db, template_id)
if not version:
version = await ReportVersionService.get_designing_or_latest(db, template_id)
if not version:
raise ReportServiceException("没有可发布的版本,请先在设计器中保存报表")
tpl.status = "published"
if publish_config:
menu_parent_id = publish_config.get("menu_parent_id")
menu_path = f"/report-render/{tpl.code}"
menu_internal_name = f"report_{tpl.code}"
menu_stmt = select(Menu).where(Menu.path == menu_path)
existing_menu = (await db.execute(menu_stmt)).scalar_one_or_none()
title = publish_config.get("menu_name", tpl.name)
icon = publish_config.get("menu_icon", "lucide:file-spreadsheet")
order = publish_config.get("menu_order", 0)
menu_query = ReportService._report_menu_query(tpl)
if existing_menu:
existing_menu.name = menu_internal_name
existing_menu.title = title
existing_menu.parent_id = menu_parent_id
existing_menu.icon = icon
existing_menu.order = order
existing_menu.type = "online_report"
existing_menu.application_id = tpl.application_id
existing_menu.component = "online-dev/report-render/index"
existing_menu.query = menu_query
existing_menu.authCode = f"report:{tpl.code}:preview"
menu_record = existing_menu
logger.info("更新报表菜单: %s", tpl.code)
else:
menu_record = Menu(
application_id=tpl.application_id,
name=menu_internal_name,
title=title,
path=menu_path,
component="online-dev/report-render/index",
type="online_report",
parent_id=menu_parent_id,
icon=icon,
order=order,
query=menu_query,
authCode=f"report:{tpl.code}:preview",
)
db.add(menu_record)
await db.flush()
logger.info("创建报表菜单: %s", tpl.code)
await ReportService._create_report_permissions(db, tpl, menu_record.id)
await db.commit()
await db.refresh(tpl)
await MenuService.invalidate_cache()
logger.info("报表发布成功: %s", tpl.code)
return tpl
@staticmethod
async def unpublish(db: AsyncSession, template_id: str) -> ReportTemplate:
"""取消发布并删除对应菜单"""
from core.menu.service import MenuService
tpl = await ReportService.get(db, template_id)
tpl.status = "draft"
await ReportService._cleanup_report_publish_resources(db, tpl)
await db.commit()
await db.refresh(tpl)
await MenuService.invalidate_cache()
logger.info("报表取消发布: %s", tpl.code)
return tpl
@staticmethod
async def get_release_menu(db: AsyncSession, template_id: str) -> Dict[str, Any]:
from core.menu.model import Menu
tpl = await ReportService.get(db, template_id)
menu_path = f"/report-render/{tpl.code}"
menu = (await db.execute(select(Menu).where(Menu.path == menu_path))).scalar_one_or_none()
if not menu:
return {"published": False, "menu": None}
return {
"published": True,
"menu": {
"id": str(menu.id),
"title": menu.title,
"path": menu.path,
"parent_id": menu.parent_id,
"icon": menu.icon,
"order": menu.order,
},
}
@staticmethod
async def get_selector(
db: AsyncSession,
application_id: str = None,
) -> List[Dict[str, Any]]:
"""分类树形选择器(对标 JNPF GET /Report/Selector"""
result = await ReportService.list_with_data_scope(
db, page=1, page_size=500, application_id=application_id
)
by_cat: Dict[str, List[Dict[str, Any]]] = {}
for tpl in result["items"]:
cat = tpl.category or "未分类"
by_cat.setdefault(cat, []).append(
{
"id": str(tpl.id),
"name": tpl.name,
"code": tpl.code,
"status": tpl.status,
}
)
return [
{"category": cat, "children": items}
for cat, items in sorted(by_cat.items(), key=lambda x: x[0])
]