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 _reportRepo; private readonly IRepository _reportFileRepo; private readonly IRepository _permissionRepo; private readonly IRepository _rolePermissionRepo; private readonly IFreeSql _fsql; private readonly ISnowflakeIdGenerator _snowflake; private readonly IMediator _mediator; private readonly Dictionary _dataProviders; public ReportService( IRepository reportRepo, IRepository reportFileRepo, IRepository permissionRepo, IRepository rolePermissionRepo, IFreeSql fsql, ISnowflakeIdGenerator snowflake, IMediator mediator, IEnumerable 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> GetPagedAsync(string like, string category, bool? isActive, int pageIndex, int pageSize) { var query = _fsql.Select().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 { Items = items, Total = total, PageIndex = pageIndex, PageSize = pageSize }; } public async Task GetByIdAsync(long id) { var r = await _reportRepo.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted) ?? throw new F9NotFoundException("报表不存在"); return ToDetail(r); } public async Task 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(); } /// /// 直接保存模板文本(前端「模板源码」在线编辑后保存)。 /// JSON 格式模板做一次语法校验,避免保存后在设计器 / 渲染端打不开; /// "_WR" 压缩模板无法在服务端校验,原样保存。 /// public async Task 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 GetTemplateContentAsync(Report report) { var file = await FindTemplateFileAsync(report); return file?.DesignContent; } /// 查找报表的模板文件实例(优先 ReportFileId,兼容按 ReportId 查找) private async Task 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); } /// /// 保存模板:新建或更新 ReportFile.DesignContent,并把 Report.ReportFileId 绑定到该实例。 /// 模板与报表配置一起保存,前端无需再单独上传 .grf 文件。 /// 注意:只修改内存中的 report,不落库(由调用方统一保存)。 /// 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); } /// /// 规范化模板内容:仅去 BOM / 去掉首尾空白,不做格式校验。 /// .grf 有两种格式:GR6 的 UTF-8 JSON 文本,以及 GR7 的 "_WR" 前缀压缩文本; /// 两者都由 Grid++Report 的 HTML5 渲染库自行识别(内部 decodeWR),服务端原样保存即可。 /// 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; } /// 按 BOM / 内容特征解码 .grf 字节:UTF-8(BOM) / UTF-16LE(BOM) / UTF-16BE(BOM) / 无 BOM 按 UTF-8 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> GetVisibleReportsAsync(long userId, bool isAdmin, IReadOnlyCollection permissions) { var all = await _reportRepo.FindAsync(r => r.IsActive && !r.IsDeleted); IEnumerable 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 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> ExecuteDataAsync(Report report, IReadOnlyDictionary 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(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(); 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>(); foreach (DataRow row in dt.Rows) { var d = new Dictionary(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 { ["recordset"] = recordset }; } catch (F9OperationException) { throw; } catch (Exception ex) { throw new F9OperationException("报表数据查询失败:" + ex.Message); } } /// /// 将 F9 的 ReportDto_DataSource 转换为 Grid++Report 约定的 JSON 数据: /// 单数据集 → { "recordset": [ {...} ] }(兼容无名记录集的模板) /// 多数据集 → { "main": [ {...} ], "detail": [ {...} ] }(顶层键为数据集名) /// 报表参数 → 附加 { "parameter": [ { 账期: "...", 车间: "..." } ] }(模板以 Parameter 方式引用) /// private static Dictionary ToGridReportJson(ReportDto_DataSource dataSource) { var result = new Dictionary(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() : datasets[0].Value.Field_Data.Select(ToNameValueDict).Cast().ToList(); } else { foreach (var kv in datasets) result[kv.Key] = kv.Value.Field_Data.Select(ToNameValueDict).Cast().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 { ToNameValueDict(paramData) }; return result; } /// /// 对象 → 字段名/值的字典。 /// 用字典承载是为了保证属性名原样输出(不受 ASP.NET Core 默认 camelCase 序列化策略影响), /// 从而与 .grf 模板 DetailGrid.Recordset.Field 定义的字段名严格一致。 /// private static readonly ConcurrentDictionary _rowPropsCache = new(); private static Dictionary ToNameValueDict(object row) { if (row is IDictionary exists) return exists.ToDictionary(k => k.Key, v => (object?)v.Value); var props = _rowPropsCache.GetOrAdd(row.GetType(), t => t.GetProperties()); var dict = new Dictionary(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> 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> 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 ProbeAsync(ReportProbeRequestDto request) { var dsName = (request.DsName ?? "").Trim(); var result = new ReportProbeResultDto { DsName = dsName }; _dataProviders.TryGetValue(dsName, out var provider); // 探测参数只用前端带入的取值(参数本身由用户在报表配置里维护,不在这里生成控件) var pairs = new Dictionary(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; } /// 通过 IReportDataProvider 执行一次查询,取返回记录集的字段名作为明细列 private async Task FillFromProviderAsync(ReportProbeResultDto result, IReportDataProvider provider, Dictionary 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(); } /// 直接用 DataSql 执行一次查询(带 @参数),取 DataTable 列作为明细列 private void FillFromSql(ReportProbeResultDto result, string sql, Dictionary 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(); 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().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().Take(3).Select(row => { var d = new Dictionary(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; } } }