d72b583319
[XP.ReportEngine] 模型与配置扩展:ReportOutputFormat 枚举:新增 Excel=1, Csv=2, Txt=3(显式序号,向后兼容);新增多格式相关配置加载。
310 lines
16 KiB
C#
310 lines
16 KiB
C#
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<T>() 限定的实例)| Logger service (subclass should pass an instance already scoped via ForModule<T>())</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 且路径非空 → "图像引用: <path>"(需求 6.3、6.4)| FilePath non-empty → "Image reference: <path>"
|
||
/// - 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
|
||
}
|
||
}
|
||
}
|
||
}
|