@@ -0,0 +1,652 @@
using System.Collections.Concurrent ;
using System.Data ;
using System.Data.Common ;
using System.Reflection ;
using System.Text ;
using System.Text.Json ;
using System.Text.RegularExpressions ;
using FreeSql ;
using F9WebAiTest.Application.Common ;
using F9WebAiTest.Application.Common.Exceptions ;
using F9WebAiTest.Application.Dtos.SYS.Reports ;
using F9WebAiTest.Application.Interfaces.Reports ;
using F9WebAiTest.Application.Interfaces.SnowflakeID ;
using F9WebAiTest.Domain.Common ;
using F9WebAiTest.Domain.Entities.SYS.RBAC ;
using F9WebAiTest.Domain.Entities.SYS.Reports ;
using F9WebAiTest.Domain.Enums.SYS.RBAC ;
using MediatR ;
using System.Data.SQLite ;
namespace F9WebAiTest.Application.Services.Reports ;
public class ReportService : IReportService
{
private readonly IRepository < Report > _reportRepo ;
private readonly IRepository < ReportFile > _reportFileRepo ;
private readonly IRepository < Permission > _permissionRepo ;
private readonly IRepository < RolePermission > _rolePermissionRepo ;
private readonly IFreeSql _fsql ;
private readonly ISnowflakeIdGenerator _snowflake ;
private readonly IMediator _mediator ;
private readonly Dictionary < string , IReportDataProvider > _dataProviders ;
public ReportService (
IRepository < Report > reportRepo ,
IRepository < ReportFile > reportFileRepo ,
IRepository < Permission > permissionRepo ,
IRepository < RolePermission > rolePermissionRepo ,
IFreeSql fsql ,
ISnowflakeIdGenerator snowflake ,
IMediator mediator ,
IEnumerable < IReportDataProvider > dataProviders )
{
_reportRepo = reportRepo ;
_reportFileRepo = reportFileRepo ;
_permissionRepo = permissionRepo ;
_rolePermissionRepo = rolePermissionRepo ;
_fsql = fsql ;
_snowflake = snowflake ;
_mediator = mediator ;
_dataProviders = dataProviders . ToDictionary ( p = > p . ReportName , StringComparer . OrdinalIgnoreCase ) ;
}
// ===== 报表管理 =====
public async Task < PageResult < ReportListDto > > GetPagedAsync ( string like , string category , bool? isActive , int pageIndex , int pageSize )
{
var query = _fsql . Select < Report > ( ) . Where ( r = > ! r . IsDeleted ) ;
if ( ! string . IsNullOrWhiteSpace ( like ) )
query = query . Where ( r = > r . Name . Contains ( like ) | | r . Code . Contains ( like ) ) ;
if ( ! string . IsNullOrWhiteSpace ( category ) )
query = query . Where ( r = > r . Category = = category ) ;
if ( isActive . HasValue )
query = query . Where ( r = > r . IsActive = = isActive . Value ) ;
var total = await query . CountAsync ( ) ;
var rows = await query
. OrderBy ( r = > r . SortOrder )
. OrderByDescending ( r = > r . CreatedAt )
. Page ( pageIndex , pageSize )
. ToListAsync ( ) ;
var items = rows . Select ( r = > new ReportListDto (
r . Id , r . Code , r . Name , r . Category , r . Description , r . SortOrder , r . IsActive ,
r . ReportFileId , HasTemplate ( r ) , r . CreatedAt , r . UpdatedAt ) ) . ToList ( ) ;
return new PageResult < ReportListDto > { Items = items , Total = total , PageIndex = pageIndex , PageSize = pageSize } ;
}
public async Task < ReportDetailDto > GetByIdAsync ( long id )
{
var r = await _reportRepo . FirstOrDefaultAsync ( x = > x . Id = = id & & ! x . IsDeleted )
? ? throw new F9NotFoundException ( "报表不存在" ) ;
return ToDetail ( r ) ;
}
public async Task < long > CreateAsync ( ReportCreateDto dto )
{
if ( string . IsNullOrWhiteSpace ( dto . Code ) ) throw new F9OperationException ( "报表编码不能为空" ) ;
if ( string . IsNullOrWhiteSpace ( dto . Name ) ) throw new F9OperationException ( "报表名称不能为空" ) ;
ValidateCode ( dto . Code ) ;
ValidateConfig ( dto . Config ) ;
// 有 DsName 时允许不填 SQL;否则必须为合法 SELECT
if ( GetConfigDsName ( dto . Config ) = = null ) ValidateSql ( dto . DataSql ) ;
if ( await _reportRepo . AnyAsync ( r = > r . Code = = dto . Code & & ! r . IsDeleted ) )
throw new F9OperationException ( $"报表编码已存在:{dto.Code}" ) ;
var entity = new Report ( _snowflake . CreateId ( ) , dto . Code . Trim ( ) , dto . Name . Trim ( ) ,
dto . Category ? ? "通用" , dto . Description ? ? "" , dto . DataSql ? ? "" ,
dto . DataParams ? ? "[]" , dto . SortOrder , dto . Config ? ? "" ) ;
// 模板与配置一起保存(模板实例写入 ReportFile, Report 持有 ReportFileId)
if ( ! string . IsNullOrWhiteSpace ( dto . TemplateJson ) )
await SaveTemplateAsync ( entity , dto . TemplateJson ) ;
await _reportRepo . AddAsync ( entity ) ;
await _reportRepo . SaveChangesAsync ( ) ;
// 同步创建权限单元:report:{code}:visble
var perm = new Permission ( _snowflake . CreateId ( ) , Permission_Type_Enum . Visble ,
Resource_Type_Enum . 报 表 , entity . Code , entity . Id ) ;
perm . SetCode ( $"report:{entity.Code}:visble" ) ;
await _permissionRepo . AddAsync ( perm ) ;
await _permissionRepo . SaveChangesAsync ( ) ;
return entity . Id ;
}
public async Task UpdateAsync ( long id , ReportUpdateDto dto )
{
if ( string . IsNullOrWhiteSpace ( dto . Code ) ) throw new F9OperationException ( "报表编码不能为空" ) ;
if ( string . IsNullOrWhiteSpace ( dto . Name ) ) throw new F9OperationException ( "报表名称不能为空" ) ;
ValidateCode ( dto . Code ) ;
ValidateConfig ( dto . Config ) ;
if ( GetConfigDsName ( dto . Config ) = = null ) ValidateSql ( dto . DataSql ) ;
var entity = await _reportRepo . FirstOrDefaultAsync ( r = > r . Id = = id & & ! r . IsDeleted )
? ? throw new F9NotFoundException ( "报表不存在" ) ;
if ( await _reportRepo . AnyAsync ( r = > r . Code = = dto . Code & & r . Id ! = id & & ! r . IsDeleted ) )
throw new F9OperationException ( $"报表编码已存在:{dto.Code}" ) ;
var oldCode = entity . Code ;
entity . UpdateBasic ( dto . Code . Trim ( ) , dto . Name . Trim ( ) , dto . Category ? ? "通用" ,
dto . Description ? ? "" , dto . DataSql ? ? "" , dto . DataParams ? ? "[]" , dto . SortOrder , dto . Config ? ? "" ) ;
entity . Activate ( ) ;
// 模板与配置一起保存;不传模板时保留原模板
if ( ! string . IsNullOrWhiteSpace ( dto . TemplateJson ) )
await SaveTemplateAsync ( entity , dto . TemplateJson ) ;
await _reportRepo . UpdateAsync ( entity ) ;
await _reportRepo . SaveChangesAsync ( ) ;
// 同步更新权限 Code(编码变更时)
if ( ! string . Equals ( oldCode , entity . Code , StringComparison . Ordinal ) )
{
var perm = await _permissionRepo . FirstOrDefaultAsync ( p = >
p . ResourceType = = Resource_Type_Enum . 报 表 & & p . ResourceId = = entity . Id & & ! p . IsDeleted ) ;
if ( perm ! = null )
{
perm . SetCode ( $"report:{entity.Code}:visble" ) ;
await _permissionRepo . UpdateAsync ( perm ) ;
await _permissionRepo . SaveChangesAsync ( ) ;
}
}
}
public async Task DeleteAsync ( long id )
{
var entity = await _reportRepo . FirstOrDefaultAsync ( r = > r . Id = = id & & ! r . IsDeleted )
? ? throw new F9NotFoundException ( "报表不存在" ) ;
await _reportRepo . DeleteAsync ( entity ) ;
await _reportRepo . SaveChangesAsync ( ) ;
// 级联删除模板文件实例
var files = await _reportFileRepo . FindAsync ( f = > f . ReportId = = id & & ! f . IsDeleted ) ;
if ( files . Count > 0 )
{
await _reportFileRepo . DeleteRangeAsync ( files ) ;
await _reportFileRepo . SaveChangesAsync ( ) ;
}
// 同步删除报表关联的权限单元与角色授权
var perms = await _permissionRepo . FindAsync ( p = > p . ResourceType = = Resource_Type_Enum . 报 表 & & p . ResourceId = = id & & ! p . IsDeleted ) ;
if ( perms . Count > 0 )
{
var permIds = perms . Select ( p = > p . Id ) . ToList ( ) ;
await _rolePermissionRepo . HardDeleteRangeAsync (
( await _rolePermissionRepo . FindAsync ( rp = > permIds . Contains ( rp . PermissionId ) & & ! rp . IsDeleted ) ) ) ;
await _permissionRepo . HardDeleteRangeAsync ( perms ) ;
await _permissionRepo . SaveChangesAsync ( ) ;
}
}
public async Task UploadTemplateAsync ( long id , byte [ ] data , string fileName )
{
if ( data = = null | | data . Length = = 0 ) throw new F9OperationException ( "模板内容为空" ) ;
var entity = await _reportRepo . FirstOrDefaultAsync ( r = > r . Id = = id & & ! r . IsDeleted )
? ? throw new F9NotFoundException ( "报表不存在" ) ;
// .grf 可能是 GR6 时代的 UTF-8 JSON,也可能是 GR7 的 "_WR" 压缩文本,按 BOM 自动识别编码
var text = DecodeTemplateBytes ( data ) ;
await SaveTemplateAsync ( entity , text ) ;
await _reportRepo . UpdateAsync ( entity ) ;
await _reportRepo . SaveChangesAsync ( ) ;
}
/// <summary>
/// 直接保存模板文本(前端「模板源码」在线编辑后保存)。
/// JSON 格式模板做一次语法校验,避免保存后在设计器 / 渲染端打不开;
/// "_WR" 压缩模板无法在服务端校验,原样保存。
/// </summary>
public async Task < int > SaveTemplateContentAsync ( long id , string content )
{
var entity = await _reportRepo . FirstOrDefaultAsync ( r = > r . Id = = id & & ! r . IsDeleted )
? ? throw new F9NotFoundException ( "报表不存在" ) ;
var text = NormalizeTemplateContent ( content ) ;
var head = text . TrimStart ( ) ;
if ( head . StartsWith ( '{' ) | | head . StartsWith ( '[' ) )
{
try { using var _ = JsonDocument . Parse ( text ) ; }
catch ( JsonException ex ) { throw new F9OperationException ( $"模板 JSON 不合法:{ex.Message}" ) ; }
}
await SaveTemplateAsync ( entity , text ) ;
await _reportRepo . UpdateAsync ( entity ) ;
await _reportRepo . SaveChangesAsync ( ) ;
return text . Length ;
}
public async Task < string? > GetTemplateContentAsync ( Report report )
{
var file = await FindTemplateFileAsync ( report ) ;
return file ? . DesignContent ;
}
/// <summary>查找报表的模板文件实例(优先 ReportFileId,兼容按 ReportId 查找)</summary>
private async Task < ReportFile ? > FindTemplateFileAsync ( Report report )
{
if ( report . ReportFileId ! = 0 )
{
var byId = await _reportFileRepo . FirstOrDefaultAsync ( f = > f . Id = = report . ReportFileId & & ! f . IsDeleted ) ;
if ( byId ! = null ) return byId ;
}
return await _reportFileRepo . FirstOrDefaultAsync ( f = > f . ReportId = = report . Id & & ! f . IsDeleted ) ;
}
/// <summary>
/// 保存模板:新建或更新 ReportFile.DesignContent,并把 Report.ReportFileId 绑定到该实例。
/// 模板与报表配置一起保存,前端无需再单独上传 .grf 文件。
/// 注意:只修改内存中的 report,不落库(由调用方统一保存)。
/// </summary>
private async Task SaveTemplateAsync ( Report report , string templateContent )
{
var content = NormalizeTemplateContent ( templateContent ) ;
var file = await FindTemplateFileAsync ( report ) ;
if ( file ! = null )
{
file . UpdateContent ( content ) ;
await _reportFileRepo . UpdateAsync ( file ) ;
await _reportFileRepo . SaveChangesAsync ( ) ;
}
else
{
file = new ReportFile ( _snowflake . CreateId ( ) , report . Id , content ) ;
await _reportFileRepo . AddAsync ( file ) ;
await _reportFileRepo . SaveChangesAsync ( ) ;
}
if ( report . ReportFileId ! = file . Id ) report . BindReportFile ( file . Id ) ;
}
/// <summary>
/// 规范化模板内容:仅去 BOM / 去掉首尾空白,不做格式校验。
/// .grf 有两种格式:GR6 的 UTF-8 JSON 文本,以及 GR7 的 "_WR" 前缀压缩文本;
/// 两者都由 Grid++Report 的 HTML5 渲染库自行识别(内部 decodeWR),服务端原样保存即可。
/// </summary>
private static string NormalizeTemplateContent ( string? content )
{
if ( string . IsNullOrWhiteSpace ( content ) ) throw new F9OperationException ( "报表模板内容为空" ) ;
var text = content . Trim ( ) ;
if ( text . Length > 0 & & text [ 0 ] = = ' \ uFEFF ' ) text = text . Substring ( 1 ) ;
if ( string . IsNullOrWhiteSpace ( text ) ) throw new F9OperationException ( "报表模板内容为空" ) ;
return text ;
}
/// <summary>按 BOM / 内容特征解码 .grf 字节:UTF-8(BOM) / UTF-16LE(BOM) / UTF-16BE(BOM) / 无 BOM 按 UTF-8</summary>
private static string DecodeTemplateBytes ( byte [ ] data )
{
if ( data . Length > = 3 & & data [ 0 ] = = 0xEF & & data [ 1 ] = = 0xBB & & data [ 2 ] = = 0xBF )
return Encoding . UTF8 . GetString ( data , 3 , data . Length - 3 ) ;
if ( data . Length > = 2 & & data [ 0 ] = = 0xFF & & data [ 1 ] = = 0xFE )
return Encoding . Unicode . GetString ( data , 2 , data . Length - 2 ) ;
if ( data . Length > = 2 & & data [ 0 ] = = 0xFE & & data [ 1 ] = = 0xFF )
return Encoding . BigEndianUnicode . GetString ( data , 2 , data . Length - 2 ) ;
// 无 BOM:先按 UTF-8 严格解码,失败则按 UTF-16LE 尝试
try
{
var strict = new UTF8Encoding ( false , true ) ;
return strict . GetString ( data ) ;
}
catch ( DecoderFallbackException )
{
try { return Encoding . Unicode . GetString ( data ) ; }
catch { return Encoding . UTF8 . GetString ( data ) ; }
}
}
// ===== 用户可见报表 =====
public async Task < List < ReportViewDto > > GetVisibleReportsAsync ( long userId , bool isAdmin , IReadOnlyCollection < string > permissions )
{
var all = await _reportRepo . FindAsync ( r = > r . IsActive & & ! r . IsDeleted ) ;
IEnumerable < Report > visible ;
if ( isAdmin )
{
visible = all ;
}
else
{
visible = all . Where ( r = > permissions . Contains ( $"report:{r.Code}:visble" ) ) ;
}
return visible
. OrderBy ( r = > r . SortOrder ) . ThenBy ( r = > r . Name )
. Select ( r = > new ReportViewDto ( r . Id , r . Code , r . Name , r . Category , r . Description , r . SortOrder , HasTemplate ( r ) ) )
. ToList ( ) ;
}
public async Task EnsureReportAccessAsync ( long userId , bool isAdmin , IReadOnlyCollection < string > permissions , long reportId )
{
if ( isAdmin ) return ;
var report = await _reportRepo . FirstOrDefaultAsync ( r = > r . Id = = reportId & & ! r . IsDeleted )
? ? throw new F9NotFoundException ( "报表不存在" , 404 ) ;
if ( ! report . IsActive ) throw new F9NotFoundException ( "报表已停用" , 404 ) ;
if ( ! permissions . Contains ( $"report:{report.Code}:visble" ) )
throw new F9OperationException ( "无权访问该报表" ) ;
}
// ===== 数据查询 =====
public async Task < Dictionary < string , object? > > ExecuteDataAsync ( Report report , IReadOnlyDictionary < string , object? > parameters )
{
// 1. 优先按 Config.DsName 解析到数据提供者(F9 IReportService 模式)
var dsName = GetConfigDsName ( report . Config ) ;
if ( ! string . IsNullOrWhiteSpace ( dsName ) )
{
if ( ! _dataProviders . TryGetValue ( dsName , out var provider ) )
throw new F9OperationException ( $"未找到数据源查询实现:{dsName}" ) ;
var pairs = new Dictionary < string , object > ( StringComparer . Ordinal ) ;
foreach ( var kv in parameters ) pairs [ kv . Key ] = kv . Value ! ;
var dataSource = await provider . GetData ( pairs , _mediator ) ;
return ToGridReportJson ( dataSource ) ;
}
// 2. 回退:DataSql + @参数名(仅绑定 SQL 中实际出现的参数,防注入)
if ( string . IsNullOrWhiteSpace ( report . DataSql ) )
throw new F9OperationException ( "报表未配置数据源(Config.DsName 或 DataSql) " ) ;
try
{
var sqlTokens = Regex . Matches ( report . DataSql , @"@([A-Za-z_][A-Za-z0-9_]*)" )
. Select ( m = > m . Groups [ 1 ] . Value ) . Distinct ( ) . ToHashSet ( StringComparer . Ordinal ) ;
var dbParams = new List < DbParameter > ( ) ;
foreach ( var kv in parameters )
{
if ( kv . Key . Length > 0 & & sqlTokens . Contains ( kv . Key ) & & kv . Value ! = null )
dbParams . Add ( new SQLiteParameter ( "@" + kv . Key , kv . Value ) ) ;
}
var dt = dbParams . Count > 0
? _fsql . Ado . ExecuteDataTable ( report . DataSql , dbParams . ToArray ( ) )
: _fsql . Ado . ExecuteDataTable ( report . DataSql ) ;
var recordset = new List < Dictionary < string , object? > > ( ) ;
foreach ( DataRow row in dt . Rows )
{
var d = new Dictionary < string , object? > ( dt . Columns . Count , StringComparer . Ordinal ) ;
foreach ( DataColumn col in dt . Columns )
{
var v = row [ col ] ;
d [ col . ColumnName ] = v = = DBNull . Value ? null : v ;
}
recordset . Add ( d ) ;
}
return new Dictionary < string , object? > { [ "recordset" ] = recordset } ;
}
catch ( F9OperationException ) { throw ; }
catch ( Exception ex )
{
throw new F9OperationException ( "报表数据查询失败:" + ex . Message ) ;
}
}
/// <summary>
/// 将 F9 的 ReportDto_DataSource 转换为 Grid++Report 约定的 JSON 数据:
/// 单数据集 → { "recordset": [ {...} ] }(兼容无名记录集的模板)
/// 多数据集 → { "main": [ {...} ], "detail": [ {...} ] }(顶层键为数据集名)
/// 报表参数 → 附加 { "parameter": [ { 账期: "...", 车间: "..." } ] }(模板以 Parameter 方式引用)
/// </summary>
private static Dictionary < string , object? > ToGridReportJson ( ReportDto_DataSource dataSource )
{
var result = new Dictionary < string , object? > ( StringComparer . Ordinal ) ;
var datasets = dataSource . Report_Data
. Where ( kv = > ( kv . Value . Field_Data ? . Count ? ? 0 ) > 0 )
. ToList ( ) ;
if ( datasets . Count < = 1 )
{
// 0 行时也必须输出空 recordset:否则渲染库找不到主记录集,
// 报表参数(Parameter)会被错位渲染到明细区(实测 0 行报表参数值错乱)
result [ "recordset" ] = datasets . Count = = 0
? new List < object > ( )
: datasets [ 0 ] . Value . Field_Data . Select ( ToNameValueDict ) . Cast < object > ( ) . ToList ( ) ;
}
else
{
foreach ( var kv in datasets )
result [ kv . Key ] = kv . Value . Field_Data . Select ( ToNameValueDict ) . Cast < object > ( ) . ToList ( ) ;
}
// 报表参数:以 Parameter 数据集输出(键名须与 .grf 模板的 Parameter 节点一致,HTML5 渲染器取数组第一项)
var paramData = dataSource . Report_Data . Values
. FirstOrDefault ( d = > d . Parameter_Data ! = null ) ? . Parameter_Data ;
if ( paramData ! = null )
result [ "Parameter" ] = new List < object > { ToNameValueDict ( paramData ) } ;
return result ;
}
/// <summary>
/// 对象 → 字段名/值的字典。
/// 用字典承载是为了保证属性名原样输出(不受 ASP.NET Core 默认 camelCase 序列化策略影响),
/// 从而与 .grf 模板 DetailGrid.Recordset.Field 定义的字段名严格一致。
/// </summary>
private static readonly ConcurrentDictionary < Type , PropertyInfo [ ] > _rowPropsCache = new ( ) ;
private static Dictionary < string , object? > ToNameValueDict ( object row )
{
if ( row is IDictionary < string , object > exists ) return exists . ToDictionary ( k = > k . Key , v = > ( object? ) v . Value ) ;
var props = _rowPropsCache . GetOrAdd ( row . GetType ( ) , t = > t . GetProperties ( ) ) ;
var dict = new Dictionary < string , object? > ( props . Length , StringComparer . Ordinal ) ;
foreach ( var p in props )
{
if ( ! p . CanRead ) continue ;
dict [ p . Name ] = p . GetValue ( row ) ;
}
return dict ! ;
}
private static string? GetConfigDsName ( string configJson )
{
if ( string . IsNullOrWhiteSpace ( configJson ) ) return null ;
try
{
using var doc = JsonDocument . Parse ( configJson ) ;
if ( doc . RootElement . TryGetProperty ( "DsName" , out var n ) & & n . ValueKind = = JsonValueKind . String )
return n . GetString ( ) ;
}
catch { /* 配置 JSON 非法时忽略 */ }
return null ;
}
public async Task < List < ReportListDto > > GetAllActiveAsync ( )
{
var list = await _reportRepo . FindAsync ( r = > r . IsActive & & ! r . IsDeleted ) ;
return list
. OrderBy ( r = > r . SortOrder ) . ThenBy ( r = > r . Name )
. Select ( r = > new ReportListDto (
r . Id , r . Code , r . Name , r . Category , r . Description , r . SortOrder , r . IsActive ,
r . ReportFileId , HasTemplate ( r ) , r . CreatedAt , r . UpdatedAt ) )
. ToList ( ) ;
}
// ===== 数据源清单 / 结构探测(新增报表时按查询结果自动识别明细列) =====
public Task < List < ReportDataSourceInfoDto > > GetDataSourcesAsync ( )
{
var list = _dataProviders . Values
. OrderBy ( p = > p . ReportName , StringComparer . OrdinalIgnoreCase )
. Select ( p = > new ReportDataSourceInfoDto { DsName = p . ReportName } )
. ToList ( ) ;
return Task . FromResult ( list ) ;
}
public async Task < ReportProbeResultDto > ProbeAsync ( ReportProbeRequestDto request )
{
var dsName = ( request . DsName ? ? "" ) . Trim ( ) ;
var result = new ReportProbeResultDto { DsName = dsName } ;
_dataProviders . TryGetValue ( dsName , out var provider ) ;
// 探测参数只用前端带入的取值(参数本身由用户在报表配置里维护,不在这里生成控件)
var pairs = new Dictionary < string , object > ( StringComparer . Ordinal ) ;
foreach ( var kv in request . Params )
if ( kv . Value ! = null ) pairs [ kv . Key ] = kv . Value ;
try
{
if ( provider ! = null )
await FillFromProviderAsync ( result , provider , pairs ) ;
else if ( ! string . IsNullOrWhiteSpace ( request . DataSql ) )
FillFromSql ( result , request . DataSql ! , pairs ) ;
else if ( ! string . IsNullOrWhiteSpace ( dsName ) )
result . Error = $"未找到数据源实现:{dsName}" ;
else
result . Error = "请先选择数据源(DsName)或填写数据 SQL" ;
}
catch ( F9OperationException ex ) { result . Error = ex . Message ; }
catch ( Exception ex ) { result . Error = "数据源探测失败:" + ex . Message ; }
return result ;
}
/// <summary>通过 IReportDataProvider 执行一次查询,取返回记录集的字段名作为明细列</summary>
private async Task FillFromProviderAsync ( ReportProbeResultDto result , IReportDataProvider provider , Dictionary < string , object > pairs )
{
var dataSource = await provider . GetData ( pairs , _mediator ) ;
// 报表参数:来自 Provider 返回的 Parameter_Data(不是查询条件,也不是硬编码声明)
var paramDatum = dataSource . Report_Data . FirstOrDefault ( kv = > kv . Value ? . Parameter_Data ! = null ) ;
if ( paramDatum . Value ? . Parameter_Data ! = null )
{
result . Parameters = ToNameValueDict ( paramDatum . Value . Parameter_Data )
. Select ( kv = > new ReportParamValueDto
{
Name = kv . Key ,
Value = kv . Value ? . ToString ( ) ? ? "" ,
} )
. ToList ( ) ;
}
var datums = dataSource . Report_Data
. Where ( kv = > kv . Value ? . Field_Data is { Count : > 0 } )
. ToList ( ) ;
result . Total = datums . Sum ( kv = > kv . Value . Field_Data . Count ) ;
if ( datums . Count = = 0 )
{
result . Error = "查询未返回数据,无法识别明细列(可手工添加列)" ;
return ;
}
var rows = datums [ 0 ] . Value . Field_Data . Take ( 3 ) . Select ( ToNameValueDict ) . ToList ( ) ;
result . Rows = rows ;
result . Fields = rows [ 0 ] . Select ( kv = > new ReportFieldDefDto
{
Name = kv . Key ,
Label = kv . Key ,
DataType = InferDataType ( kv . Value ) ,
} ) . ToList ( ) ;
}
/// <summary>直接用 DataSql 执行一次查询(带 @参数),取 DataTable 列作为明细列</summary>
private void FillFromSql ( ReportProbeResultDto result , string sql , Dictionary < string , object > pairs )
{
ValidateSql ( sql ) ;
var sqlTokens = Regex . Matches ( sql , @"@([A-Za-z_][A-Za-z0-9_]*)" )
. Select ( m = > m . Groups [ 1 ] . Value ) . Distinct ( ) . ToHashSet ( StringComparer . Ordinal ) ;
var dbParams = new List < DbParameter > ( ) ;
foreach ( var kv in pairs )
if ( sqlTokens . Contains ( kv . Key ) & & kv . Value ! = null )
dbParams . Add ( new SQLiteParameter ( "@" + kv . Key , kv . Value ) ) ;
var dt = dbParams . Count > 0
? _fsql . Ado . ExecuteDataTable ( sql , dbParams . ToArray ( ) )
: _fsql . Ado . ExecuteDataTable ( sql ) ;
result . Fields = dt . Columns . Cast < DataColumn > ( ) . Select ( c = > new ReportFieldDefDto
{
Name = c . ColumnName ,
Label = c . ColumnName ,
DataType = c . DataType . Name ,
} ) . ToList ( ) ;
result . Total = dt . Rows . Count ;
result . Rows = dt . Rows . Cast < DataRow > ( ) . Take ( 3 ) . Select ( row = >
{
var d = new Dictionary < string , object? > ( dt . Columns . Count , StringComparer . Ordinal ) ;
foreach ( DataColumn col in dt . Columns )
{
var v = row [ col ] ;
d [ col . ColumnName ] = v = = DBNull . Value ? null : v ;
}
return d ;
} ) . ToList ( ) ;
}
private static string InferDataType ( object? value )
{
if ( value = = null ) return "String" ;
if ( value is JsonElement je )
{
return je . ValueKind switch
{
JsonValueKind . Number = > "Number" ,
JsonValueKind . True or JsonValueKind . False = > "Boolean" ,
_ = > "String" ,
} ;
}
return value switch
{
int or long or short or byte or uint or ulong or ushort or sbyte = > "Integer" ,
decimal or double or float = > "Number" ,
DateTime or DateTimeOffset = > "DateTime" ,
bool = > "Boolean" ,
_ = > "String" ,
} ;
}
// ===== 工具 =====
private static bool HasTemplate ( Report r ) = > r . ReportFileId ! = 0 ;
private static ReportDetailDto ToDetail ( Report r ) = > new (
r . Id , r . Code , r . Name , r . Category , r . Description , r . DataSql , r . DataParams , r . Config ,
r . SortOrder , r . IsActive , r . ReportFileId , HasTemplate ( r ) ,
r . CreatedAt , r . UpdatedAt ) ;
private static void ValidateCode ( string code )
{
if ( ! Regex . IsMatch ( code ? ? "" , "^[a-zA-Z0-9_]{2,40}$" ) )
throw new F9OperationException ( "报表编码仅允许字母/数字/下划线,长度 2-40" ) ;
}
private static void ValidateSql ( string sql )
{
if ( string . IsNullOrWhiteSpace ( sql ) ) throw new F9OperationException ( "数据集 SQL 不能为空" ) ;
var trimmed = sql . Trim ( ) ;
if ( ! trimmed . StartsWith ( "select" , StringComparison . OrdinalIgnoreCase ) )
throw new F9OperationException ( "数据集 SQL 必须以 SELECT 开头(仅允许查询语句)" ) ;
// 禁止分号后跟其他语句
if ( trimmed . IndexOf ( ';' ) > = 0 & & trimmed . IndexOf ( ';' ) < trimmed . Length - 1 )
throw new F9OperationException ( "数据集 SQL 不允许包含多条语句" ) ;
}
private static void ValidateConfig ( string? configJson )
{
if ( string . IsNullOrWhiteSpace ( configJson ) ) return ;
try
{
using var doc = JsonDocument . Parse ( configJson ) ;
if ( doc . RootElement . ValueKind ! = JsonValueKind . Object )
throw new F9OperationException ( "查询配置(Config)必须是 JSON 对象" ) ;
}
catch ( JsonException )
{
throw new F9OperationException ( "查询配置(Config)不是合法 JSON" ) ;
}
catch ( F9OperationException ) { throw ; }
}
}