using System.Security.Claims; using F9WebAiTest.Application.Common; using F9WebAiTest.Application.Common.Exceptions; using F9WebAiTest.Application.Dtos.SYS.Reports; using F9WebAiTest.Application.Services.Auth; using F9WebAiTest.Application.Services.Reports; using F9WebAiTest.Domain.Entities.SYS.RBAC; using F9WebAiTest.Domain.Enums.SYS.RBAC; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace F9WebAiTest.Api.Controllers; /// 报表中心:报表定义维护 + 模板下载 + 数据查询(Grid++Report 7 Web 嵌入) [ApiController] [Route("api/sys/report")] [Authorize] public class SysReportController : ControllerBase { private readonly IReportService _reportService; private readonly IFreeSql _fsql; private readonly IJwtService _jwt; private readonly IConfiguration _config; public SysReportController(IReportService reportService, IFreeSql fsql, IJwtService jwt, IConfiguration config) { _reportService = reportService; _fsql = fsql; _jwt = jwt; _config = config; } // ==================== 报表管理 ==================== [HttpGet] public async Task GetPaged( [FromQuery] string like = "", [FromQuery] string category = "", [FromQuery] bool? isActive = null, [FromQuery] int pageIndex = 1, [FromQuery] int pageSize = 50) { var data = await _reportService.GetPagedAsync(like ?? "", category ?? "", isActive, pageIndex, pageSize); return Ok(ApiResult.Ok(data)); } [HttpGet("all")] public async Task GetAllActive() => Ok(ApiResult.Ok(await _reportService.GetAllActiveAsync())); [HttpGet("{id}")] public async Task GetById(long id) { try { return Ok(ApiResult.Ok(await _reportService.GetByIdAsync(id))); } catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } } [HttpPost] public async Task Create([FromBody] ReportCreateDto dto) { try { return Ok(ApiResult.Ok(new { id = await _reportService.CreateAsync(dto) }, "新增成功")); } catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } } [HttpPut("{id}")] public async Task Update(long id, [FromBody] ReportUpdateDto dto) { try { await _reportService.UpdateAsync(id, dto); return Ok(ApiResult.Ok(new { id }, "修改成功")); } catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } } [HttpDelete("{id}")] public async Task Delete(long id) { try { await _reportService.DeleteAsync(id); return Ok(ApiResult.Ok(new { id }, "删除成功")); } catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } } /// 上传 .grf 模板文件。 /// 常规编辑流程已改为「模板随报表一起保存」(POST/PUT 携带 templateJson), /// 本接口保留给 f9designer:// 本机设计器协议回传使用。 [HttpPost("{id}/template")] [RequestSizeLimit(50 * 1024 * 1024)] public async Task UploadTemplate(long id, IFormFile file) { if (file == null || file.Length == 0) return BadRequest(ApiResult.Fail("请选择要上传的 .grf 模板文件", 400)); if (!file.FileName.EndsWith(".grf", StringComparison.OrdinalIgnoreCase)) return BadRequest(ApiResult.Fail("仅支持上传 .grf 模板文件", 400)); try { using var ms = new MemoryStream(); await file.CopyToAsync(ms); await _reportService.UploadTemplateAsync(id, ms.ToArray(), file.FileName); return Ok(ApiResult.Ok(new { size = file.Length }, "上传成功")); } catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } } /// /// 直接保存模板文本(前端在线编辑「模板源码」后保存,免下载 / 上传 .grf)。 /// 与上传接口的区别:这里提交的是文本(JSON 或 "_WR" 压缩文本),不是文件流。 /// [HttpPut("{id}/template/content")] [RequestSizeLimit(50 * 1024 * 1024)] public async Task SaveTemplateContent(long id, [FromBody] ReportTemplateContentDto dto) { try { var size = await _reportService.SaveTemplateContentAsync(id, dto?.Content ?? ""); return Ok(ApiResult.Ok(new { id, size, hasTemplate = true }, "模板已保存")); } catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } } /// 下载 .grf 模板文件。保留给 f9designer:// 本机设计器协议(下载 → 编辑 → 自动回传)使用。 [HttpGet("{id}/template/download")] public async Task DownloadTemplate(long id) { var r = await _fsql.Select().Where(x => x.Id == id && !x.IsDeleted).FirstAsync(); var content = r == null ? null : await _reportService.GetTemplateContentAsync(r); if (string.IsNullOrEmpty(content)) return NotFound(ApiResult.Fail("模板尚未上传", 404)); return File(ToGrfBytes(content), "application/octet-stream", $"{r!.Code}.grf"); } // ==================== 新增报表辅助:数据源 + 结构探测 ==================== /// 数据源清单(IReportDataProvider 注册表 + 各数据源声明的查询参数) [HttpGet("datasources")] public async Task GetDataSources() => Ok(ApiResult.Ok(await _reportService.GetDataSourcesAsync())); /// /// 探测数据源结构:按 DsName(或 DataSql)执行一次查询, /// 返回可自动配置的查询参数 + 明细字段(列)+ 少量样本行。 /// [HttpPost("scaffold")] public async Task Scaffold([FromBody] ReportProbeRequestDto dto) { var result = await _reportService.ProbeAsync(dto ?? new ReportProbeRequestDto()); return Ok(ApiResult.Ok(result)); } // ==================== 模板与数据公开端点(供 HTML5 渲染 / 客户端调用) ==================== // grhtml5.insertReportViewer 的模板参数是普通 GET,浏览器无法附加 Authorization header, // 故模板/数据接口接受 ?access_token=xxx 方式鉴权(仅内部网络使用,与 /hubs 相同的处理思路)。 /// 下载报表 .grf 模板(带权限校验,支持 query token 鉴权)。 /// 额外暴露 template.json / template.grf 别名:Grid++ 的 JS 库与客户端对 URL 中不含 /// ".grf/.txt/.xml/.json" 的请求会用 POST(取模板会误入上传接口)。 [HttpGet("{id}/template")] [HttpGet("{id}/template.json")] [HttpGet("{id}/template.grf")] [AllowAnonymous] public async Task GetTemplate(long id, [FromQuery(Name = "access_token")] string? accessToken = null) { var (userId, isAdmin) = await ResolveCallerAsync(accessToken); if (userId <= 0) return Unauthorized(new { code = 401, message = "未授权" }); var r = await _fsql.Select().Where(x => x.Id == id && !x.IsDeleted).FirstAsync(); var content = r == null ? null : await _reportService.GetTemplateContentAsync(r); if (string.IsNullOrEmpty(content)) return NotFound(new { code = 404, message = "模板尚未上传" }); if (!await CanAccessReportAsync(userId, isAdmin, r!)) return ForbidReport(); // 不输出 BOM:HTML5 渲染库按 { 或 _WR 前缀判断模板格式,BOM 会破坏该判断 return File(System.Text.Encoding.UTF8.GetBytes(content!), "application/json; charset=utf-8", $"{r!.Code}.grf"); } /// 执行报表数据查询(GET/POST 均可)。data.json 为 GET 别名(客户端 URL 方法判定约定) [HttpGet("{id}/data")] [HttpGet("{id}/data.json")] [AllowAnonymous] public async Task GetData(long id, [FromQuery] string? access_token = null, [FromQuery] string? params_ = null) => await RunDataAsync(id, access_token, params_); [HttpPost("{id}/data")] [AllowAnonymous] public async Task PostData(long id, [FromQuery(Name = "access_token")] string? accessToken = null) { string? paramsJson = null; try { Request.EnableBuffering(); Request.Body.Position = 0; using var reader = new StreamReader(Request.Body, leaveOpen: true); var body = await reader.ReadToEndAsync(); if (!string.IsNullOrEmpty(body)) paramsJson = body; } catch { /* 忽略读取错误 */ } return await RunDataAsync(id, accessToken, paramsJson); } private async Task RunDataAsync(long id, string? accessToken, string? paramsJson) { var (userId, isAdmin) = await ResolveCallerAsync(accessToken); if (userId <= 0) return Unauthorized(new { code = 401, message = "未授权" }); var r = await _fsql.Select().Where(x => x.Id == id && !x.IsDeleted).FirstAsync(); if (r == null) return NotFound(new { code = 404, message = "报表不存在" }); if (!await CanAccessReportAsync(userId, isAdmin, r)) return ForbidReport(); var p = ParseParams(paramsJson); try { var data = await _reportService.ExecuteDataAsync(r, p); return Ok(data); } catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } } private static System.Collections.Generic.Dictionary ParseParams(string? json) { var result = new System.Collections.Generic.Dictionary(StringComparer.Ordinal); if (string.IsNullOrWhiteSpace(json)) return result; try { using var doc = System.Text.Json.JsonDocument.Parse(json); if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object) { foreach (var prop in doc.RootElement.EnumerateObject()) result[prop.Name] = System.Text.Json.JsonSerializer.Deserialize(prop.Value.GetRawText()); } else if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array) { // 支持 [{name,value}] 形式(与 Grid++ WEB 客户端参数一致) foreach (var el in doc.RootElement.EnumerateArray()) { if (el.TryGetProperty("name", out var n) && el.TryGetProperty("value", out var v)) result[n.GetString() ?? ""] = System.Text.Json.JsonSerializer.Deserialize(v.GetRawText()); } } } catch { /* 忽略解析错误,返回空 */ } return result; } // ==================== 报表中心(用户视图) ==================== /// 当前用户可见的报表(按权限过滤) [HttpGet("visible")] public async Task GetVisible() { var userId = GetUserId(); var isAdmin = await IsAdminAsync(userId); var perms = await GetPermissionsAsync(userId, isAdmin); return Ok(ApiResult.Ok(await _reportService.GetVisibleReportsAsync(userId, isAdmin, perms))); } /// 当前用户预览某报表(带数据) [HttpGet("{id}/preview")] public async Task Preview(long id, [FromQuery] string? params_ = null) { var userId = GetUserId(); var isAdmin = await IsAdminAsync(userId); var perms = await GetPermissionsAsync(userId, isAdmin); try { await _reportService.EnsureReportAccessAsync(userId, isAdmin, perms, id); var r = await _fsql.Select().Where(x => x.Id == id && !x.IsDeleted).FirstAsync(); if (r == null) return NotFound(ApiResult.Fail("报表不存在", 404)); if (r.ReportFileId == 0) return BadRequest(ApiResult.Fail("该报表尚未配置模板", 400)); var p = ParseParams(params_); var data = await _reportService.ExecuteDataAsync(r, p); return Ok(ApiResult.Ok(new { report = new { r.Id, r.Code, r.Name, r.Category, r.Description, config = r.Config, hasTemplate = true }, templateUrl = $"/api/sys/report/{r.Id}/template.json", data })); } catch (F9NotFoundException ex) { return NotFound(ApiResult.Fail(ex.Message, 404)); } catch (F9OperationException ex) { return BadRequest(ApiResult.Fail(ex.Message, 400)); } } // ==================== 内部辅助 ==================== private long GetUserId() => long.Parse(User.FindFirst("id")!.Value); private async Task IsAdminAsync(long userId) { var roleIds = await _fsql.Select().Where(ur => ur.UserId == userId).ToListAsync(x => x.RoleId); if (roleIds.Count == 0) return false; return await _fsql.Select().Where(r => roleIds.Contains(r.Id) && r.Code == "admin").AnyAsync(); } private async Task> GetPermissionsAsync(long userId, bool isAdmin) { if (isAdmin) return new System.Collections.Generic.HashSet( await _fsql.Select().Where(p => !p.IsDeleted && !string.IsNullOrEmpty(p.Code)).ToListAsync(p => p.Code)); var roleIds = await _fsql.Select().Where(ur => ur.UserId == userId).ToListAsync(x => x.RoleId); if (roleIds.Count == 0) return new(); var permIds = await _fsql.Select().Where(rp => roleIds.Contains(rp.RoleId) && !rp.IsDeleted).ToListAsync(x => x.PermissionId); if (permIds.Count == 0) return new(); var codes = await _fsql.Select().Where(p => permIds.Contains(p.Id) && !p.IsDeleted && !string.IsNullOrEmpty(p.Code)).ToListAsync(p => p.Code); return new System.Collections.Generic.HashSet(codes); } /// 用 query 中的 access_token 解析用户;优先尝试从 Authorization 头解析;解析失败返回 0。 private async Task<(long UserId, bool IsAdmin)> ResolveCallerAsync(string? accessToken) { // 优先用当前 HTTP 请求上下文(JWT 中间件已解析) if (User?.Identity?.IsAuthenticated == true) return (GetUserId(), await IsAdminAsync(GetUserId())); if (string.IsNullOrEmpty(accessToken)) return (0, false); try { var (uid, _) = _jwt.ValidateAccessToken(accessToken); return (uid, await IsAdminAsync(uid)); } catch { return (0, false); } } private async Task CanAccessReportAsync(long userId, bool isAdmin, Domain.Entities.SYS.Reports.Report r) { if (isAdmin) return true; if (!r.IsActive) return false; var perms = await GetPermissionsAsync(userId, isAdmin); return perms.Contains($"report:{r.Code}:visble"); } private IActionResult ForbidReport() => StatusCode(403, new { code = 403, message = "无权访问该报表" }); /// 模板文本 → .grf 字节(带 UTF-8 BOM,与锐浪设计器保存格式一致) private static byte[] ToGrfBytes(string content) { var text = content.Length > 0 && content[0] == '\uFEFF' ? content.Substring(1) : content; var enc = new System.Text.UTF8Encoding(true); var pre = enc.GetPreamble(); var body = enc.GetBytes(text); var data = new byte[pre.Length + body.Length]; Buffer.BlockCopy(pre, 0, data, 0, pre.Length); Buffer.BlockCopy(body, 0, data, pre.Length, body.Length); return data; } }