Files
f9-web-ai-test/backend/F9WebAiTest.Application/Services/Reports/ReportService.cs
T
yanpeng200212 a8890c81e9 新增质量模块(工艺执行书 / 样茧试缫)与报表中心
质量模块:
- 11 张工艺执行书表:封面、试产、选剥茧(含混茧明细)、试缫与大样、煮茧(含分车间配温、副煮车间)、自动缫(含大样)、后缫
- 10 张样茧试缫表:原料小样/综合样主表 + 选茧、解舒、茧幅、切剖、清洁洁净、茧丝百回纤度
- 服务与接口、主控指针回写(列表 √ 标记)、封面编号自增、报表数据包
- 前端 8 张报表打印版式、7 类工艺单据与 6 类调查录入弹窗、字段驱动表单/表格组件
- 菜单种子新增「质量」目录,样茧子表增加 YjssType 判别列,避免原料小样与综合样自增 Id 重号串数据

报表中心:
- 报表模板管理、锐浪 grhtml5 加载与预览、全屏设计面板、数据源 Provider(落丝记录、职工花名册)

其他:同步工作进度 1-11 文档、开发数据库快照、.gitignore 忽略运行日志目录
2026-10-22 10:30:37 +08:00

653 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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; }
}
}