Files
XplorePlane/XP.ReportEngine/Services/TextReportGeneratorBase.cs
T
QI Mingxuan d72b583319 [XP.ReportEngine] 新增 TXT/CSV/Excel 三种报告输出格式支持;Excel (.xlsx) 报告生成器,基于 ClosedXML,支持结构化生成和模板填充两种模式; 新增 XplorePlane_ReportTemplate.xlsx:Excel 报告模板文件。
[XP.ReportEngine] 模型与配置扩展:ReportOutputFormat 枚举:新增 Excel=1, Csv=2, Txt=3(显式序号,向后兼容);新增多格式相关配置加载。
2026-07-01 11:31:41 +08:00

310 lines
16 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;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using XP.Common.Localization.Interfaces;
using XP.Common.Logging.Interfaces;
using XP.ReportEngine.Configs;
using XP.ReportEngine.Interfaces;
using XP.ReportEngine.Models;
namespace XP.ReportEngine.Services
{
/// <summary>
/// 文本类报告生成器公共基类(TXT / CSV 共享)| Common base class for text-based report generators (shared by TXT / CSV)
/// 以模板方法模式承载 null 上下文校验、开始/结束/失败日志、编码解析、原子文件写入、
/// 图像路径引用提取与内存流返回等公共能力,子类仅需实现内容构建逻辑(BuildContentAsync)。
/// Uses the template method pattern to host common capabilities: null-context validation,
/// start/end/failure logging, encoding resolution, atomic file writing, image reference
/// extraction and memory-stream return. Subclasses only implement content building (BuildContentAsync).
/// </summary>
/// <remarks>
/// 该基类为实现细节层面的复用,不改变 <see cref="IReportGenerator"/> 公开契约(需求 3、4、6、9)。
/// Excel 生成器输出二进制流、逻辑差异较大,不继承此基类。
/// This base is an implementation-level reuse and does not change the public <see cref="IReportGenerator"/>
/// contract (Requirements 3, 4, 6, 9). The Excel generator outputs a binary stream with quite
/// different logic and does not inherit this base.
/// </remarks>
public abstract class TextReportGeneratorBase : IReportGenerator
{
/// <summary>
/// 日志服务(已由子类按其具体类型经 ForModule 限定)| Logger service (scoped by subclass via ForModule with its concrete type)
/// </summary>
protected readonly ILoggerService Logger;
/// <summary>
/// 多语言本地化服务 | Localization service
/// </summary>
protected readonly ILocalizationService Localization;
/// <summary>
/// 报告引擎配置 | Report engine configuration
/// </summary>
protected readonly ReportConfig Config;
/// <summary>
/// 构造函数 | Constructor
/// </summary>
/// <param name="logger">日志服务(子类应传入已经过 ForModule&lt;T&gt;() 限定的实例)| Logger service (subclass should pass an instance already scoped via ForModule&lt;T&gt;())</param>
/// <param name="localization">多语言本地化服务 | Localization service</param>
/// <param name="config">报告引擎配置 | Report engine configuration</param>
protected TextReportGeneratorBase(
ILoggerService logger,
ILocalizationService localization,
ReportConfig config)
{
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
Localization = localization ?? throw new ArgumentNullException(nameof(localization));
Config = config ?? throw new ArgumentNullException(nameof(config));
}
/// <summary>
/// 格式名称(用于日志,如 "TXT" / "CSV"| Format name (for logging, e.g. "TXT" / "CSV")
/// </summary>
protected abstract string FormatName { get; }
/// <summary>
/// 是否要求 <see cref="ReportContext.Metadata"/> 非空 | Whether <see cref="ReportContext.Metadata"/> is required to be non-null
/// TXT 要求 Metadata 非空(需求 3.8);CSV 仅要求 context 非空(需求 4.10),故默认 false。
/// TXT requires non-null Metadata (Requirement 3.8); CSV only requires non-null context
/// (Requirement 4.10), hence the default is false.
/// </summary>
protected virtual bool RequireNonNullMetadata => false;
/// <summary>
/// 由子类实现:根据上下文构建报告文本内容 | Implemented by subclass: build the report text content from the context
/// </summary>
/// <param name="context">报告上下文(已通过 null 校验)| Report context (already passed null validation)</param>
/// <returns>报告文本内容 | Report text content</returns>
protected abstract Task<string> BuildContentAsync(ReportContext context);
/// <summary>
/// 模板方法:异步生成文本类报告 | Template method: generate a text-based report asynchronously
/// 统一流程:null 校验 → 开始日志 → 构建内容 → 编码解析 → 写文件(可选,原子) → 返回内存流 → 结果日志
/// Unified flow: null validation → start log → build content → resolve encoding →
/// write file (optional, atomic) → return memory stream → result log
/// </summary>
/// <param name="context">报告上下文数据 | Report context data</param>
/// <param name="options">生成选项 | Generation options</param>
/// <returns>生成结果 | Generation result</returns>
public async Task<ReportResult> GenerateAsync(ReportContext context, ReportGenerationOptions options)
{
// 统一 null 上下文校验(需求 3.8、4.10、9.3| Unified null-context validation (Requirements 3.8, 4.10, 9.3)
if (context == null)
{
var msg = $"{FormatName} 报告生成失败:ReportContext 为空 | {FormatName} report generation failed: ReportContext is null";
Logger.Error(null, msg);
return ReportResult.Failure(msg);
}
if (RequireNonNullMetadata && context.Metadata == null)
{
var msg = $"{FormatName} 报告生成失败:ReportContext.Metadata 为空 | {FormatName} report generation failed: ReportContext.Metadata is null";
Logger.Error(null, msg);
return ReportResult.Failure(msg);
}
// 开始日志(需求 9.4| Start log (Requirement 9.4)
Logger.Info("{Format} 报告生成开始 | {Format} report generation started", FormatName);
try
{
// 构建文本内容(子类实现)| Build text content (implemented by subclass)
var content = await BuildContentAsync(context).ConfigureAwait(false);
content ??= string.Empty;
// 编码解析(需求 5| Encoding resolution (Requirement 5)
var encoding = ResolveEncoding();
// 一次性编码为字节(含 BOM 前导),保证文件内容与内存流内容完全一致(需求 3.5/3.6、4.7/4.8
// Encode to bytes once (including BOM preamble) so file content and memory-stream content are identical
// (Requirements 3.5/3.6, 4.7/4.8)
var bytes = EncodeContent(content, encoding);
// 写文件(可选,原子写入;失败清理不残留)| Write file (optional, atomic; clean up on failure to avoid residue)
if (!string.IsNullOrEmpty(options?.OutputFilePath))
{
await WriteFileAtomicAsync(bytes, options.OutputFilePath).ConfigureAwait(false);
Logger.Info(
"{Format} 报告已写入文件 {Path} | {Format} report written to file {Path}",
FormatName, options.OutputFilePath);
}
// 始终返回包含内容的内存流(需求 3.6、4.8| Always return a memory stream containing the content (Requirements 3.6, 4.8)
var stream = new MemoryStream();
stream.Write(bytes, 0, bytes.Length);
stream.Position = 0;
// 结果日志(含成功标识,需求 9.5| Result log (with success flag, Requirement 9.5)
Logger.Info("{Format} 报告生成结束,结果: 成功 | {Format} report generation finished, result: Success", FormatName);
return ReportResult.Success(stream);
}
catch (Exception ex)
{
// 失败日志(含异常对象,需求 9.1、9.6| Failure log (with exception object, Requirements 9.1, 9.6)
Logger.Error(ex, "{Format} 报告生成失败 | {Format} report generation failed: {Message}", FormatName, ex.Message);
Logger.Info("{Format} 报告生成结束,结果: 失败 | {Format} report generation finished, result: Failure", FormatName);
return ReportResult.Failure(
$"{FormatName} 报告生成过程中发生错误: {ex.Message} | An error occurred during {FormatName} report generation: {ex.Message}",
ex);
}
}
/// <summary>
/// 解析文本编码 | Resolve text encoding
/// 委托 <see cref="EncodingResolver"/> 处理:默认 UTF-8 with BOM,支持 Utf8/GB2312/GBK
/// 无效配置回退 UTF-8 with BOM 并记录 Warn 日志(需求 5.1~5.4)。
/// Delegates to <see cref="EncodingResolver"/>: default UTF-8 with BOM, supports Utf8/GB2312/GBK,
/// invalid config falls back to UTF-8 with BOM and logs a Warn (Requirements 5.1-5.4).
/// </summary>
/// <returns>解析后的编码实例 | Resolved encoding instance</returns>
protected Encoding ResolveEncoding()
{
return EncodingResolver.Resolve(Config.TextEncoding, Logger);
}
/// <summary>
/// 原子写入文件:先写临时文件,成功后原子移动到目标路径,失败则清理临时文件
/// Atomic file write: write to a temp file first, atomically move to the target path on success,
/// clean up the temp file on failure.
/// 该策略确保磁盘空间不足/无写入权限/目录不存在等 I/O 异常时不残留不完整文件
/// (需求 3.9、4.11、9.2)。
/// This strategy ensures no incomplete file is left behind on I/O failures such as
/// insufficient disk space, no write permission, or a missing directory (Requirements 3.9, 4.11, 9.2).
/// </summary>
/// <param name="bytes">已编码的文件字节内容(含 BOM 前导)| Encoded file byte content (including BOM preamble)</param>
/// <param name="path">目标文件路径 | Target file path</param>
protected async Task WriteFileAtomicAsync(byte[] bytes, string path)
{
// 确保输出目录存在 | Ensure the output directory exists
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
// 临时文件路径(与目标文件同目录,确保 File.Move 为同卷原子操作)
// Temp file path (in the same directory as the target so File.Move is an atomic same-volume operation)
var tempPath = path + ".tmp";
try
{
// 写入临时文件 | Write to temp file
using (var fileStream = new FileStream(
tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
await fileStream.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false);
await fileStream.FlushAsync().ConfigureAwait(false);
}
// 原子移动到目标路径(覆盖已存在文件)| Atomically move to the target path (overwrite if exists)
File.Move(tempPath, path, overwrite: true);
}
catch
{
// 清理不完整的临时文件 | Clean up the incomplete temp file
TryDeleteFile(tempPath);
// 同时清理可能残留的部分目标文件 | Also clean up any partially written target file
TryDeleteFile(path);
throw;
}
}
/// <summary>
/// 提取图像路径引用文本(需求 6| Extract image path reference texts (Requirement 6)
/// TXT/CSV 为纯文本格式,绝不写入图像二进制;按来源类型生成可读文本:
/// TXT/CSV are plain-text formats and never write image binaries; produce readable text by source type:
/// - FilePath 且路径非空 → "图像引用: &lt;path&gt;"(需求 6.3、6.4| FilePath non-empty → "Image reference: &lt;path&gt;"
/// - Bytes / BitmapSource → 本地化占位标识(需求 6.5| Bytes / BitmapSource → localized placeholder (Requirement 6.5)
/// - FilePath 为空/缺失 → 跳过该图像,不中断(需求 6.6| FilePath null/empty → skip without interruption (Requirement 6.6)
/// </summary>
/// <param name="context">报告上下文 | Report context</param>
/// <returns>图像引用文本序列 | Sequence of image reference texts</returns>
protected IEnumerable<string> ExtractImageReferences(ReportContext context)
{
if (context?.Images == null)
{
yield break;
}
// 本地化标签(一次性获取,单次生成语言一致,需求 10.2| Localized labels (obtained once for language consistency, Requirement 10.2)
var imageRefLabel = Localization.GetString("Image_Reference");
var binaryOmitted = Localization.GetString("Image_BinaryOmitted");
foreach (var kvp in context.Images)
{
var image = kvp.Value;
if (image == null)
{
continue;
}
switch (image.SourceType)
{
case ImageSourceType.FilePath:
// 路径非空 → 记录路径引用;为空/缺失 → 跳过(需求 6.3、6.6)
// Non-empty path → record path reference; empty/missing → skip (Requirements 6.3, 6.6)
if (!string.IsNullOrWhiteSpace(image.FilePath))
{
yield return $"{imageRefLabel}: {image.FilePath}";
}
break;
case ImageSourceType.Bytes:
case ImageSourceType.BitmapSource:
// 二进制来源 → 仅输出可读占位标识,绝不写入二进制(需求 6.5)
// Binary source → output only a readable placeholder, never the binary (Requirement 6.5)
yield return binaryOmitted;
break;
}
}
}
/// <summary>
/// 将文本内容按指定编码编码为字节,并在前导处包含编码的 BOM/前导字节
/// Encode text content with the given encoding, including the encoding's BOM/preamble bytes
/// 用于保证文件落盘内容与返回内存流内容完全一致(需求 5.1/5.2 的 BOM 要求)。
/// Ensures the on-disk file content matches the returned memory-stream content exactly
/// (BOM requirement of Requirements 5.1/5.2).
/// </summary>
/// <param name="content">文本内容 | Text content</param>
/// <param name="encoding">目标编码 | Target encoding</param>
/// <returns>含前导字节的编码字节数组 | Encoded byte array including the preamble</returns>
protected static byte[] EncodeContent(string content, Encoding encoding)
{
var preamble = encoding.GetPreamble();
var body = encoding.GetBytes(content ?? string.Empty);
if (preamble == null || preamble.Length == 0)
{
return body;
}
var result = new byte[preamble.Length + body.Length];
Buffer.BlockCopy(preamble, 0, result, 0, preamble.Length);
Buffer.BlockCopy(body, 0, result, preamble.Length, body.Length);
return result;
}
/// <summary>
/// 安全删除文件,忽略删除过程中的任何异常 | Safely delete a file, ignoring any exception during deletion
/// </summary>
/// <param name="path">待删除文件路径 | Path of the file to delete</param>
private static void TryDeleteFile(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// 清理失败不应掩盖原始异常,忽略之 | A failed cleanup must not mask the original exception, so ignore it
}
}
}
}