feat(report): PDF生成后才标记已完成,后台增加PDF查看下载

- ReportGenerationService: 结论持久化后保持Status=3,不再设置Status=4
- PdfGenerationService: PDF生成+COS上传成功后设置Status=4和CompleteTime
- ReportQueueConsumer: PDF生成失败时更新Status=5(生成失败)
- AssessmentRecordDto: 增加ReportUrl字段
- AssessmentRecordService: 列表查询增加ReportUrl映射
- Admin.Business实体: AssessmentRecord增加ReportUrl属性
- 前端API类型: AssessmentRecordItem增加reportUrl字段
- 后台记录页面: 增加查看PDF和下载PDF按钮
- Core项目: 增加Tencent.QCloud.Cos.Sdk依赖,支持COS上传
This commit is contained in:
zpc 2026-03-17 23:24:42 +08:00
parent 6d81fa45f4
commit 0b70ef0471
10 changed files with 207 additions and 16 deletions

View File

@ -116,6 +116,12 @@ public class AssessmentRecord
/// </summary>
public bool IsDeleted { get; set; }
/// <summary>
/// 报告PDF地址
/// </summary>
[MaxLength(500)]
public string? ReportUrl { get; set; }
/// <summary>
/// 关联的用户
/// </summary>

View File

@ -119,4 +119,9 @@ public class AssessmentRecordDto
/// 创建时间
/// </summary>
public DateTime CreateTime { get; set; }
/// <summary>
/// 报告PDF地址
/// </summary>
public string? ReportUrl { get; set; }
}

View File

@ -117,7 +117,8 @@ public class AssessmentRecordService : IAssessmentRecordService
StartTime = r.StartTime,
SubmitTime = r.SubmitTime,
CompleteTime = r.CompleteTime,
CreateTime = r.CreateTime
CreateTime = r.CreateTime,
ReportUrl = r.ReportUrl
})
.ToListAsync();

View File

@ -33,6 +33,7 @@ export interface AssessmentRecordItem {
submitTime: string | null
completeTime: string | null
createTime: string
reportUrl: string | null
}
/** 答案详情 */

View File

@ -108,7 +108,7 @@
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="170" align="center" />
<el-table-column label="操作" width="280" fixed="right" align="center">
<el-table-column label="操作" width="380" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleViewDetail(row)">
<el-icon><View /></el-icon>
@ -134,6 +134,26 @@
<el-icon><Monitor /></el-icon>
网页报告
</el-button>
<el-button
v-if="row.reportUrl"
type="primary"
link
size="small"
@click="handleViewPdf(row)"
>
<el-icon><Document /></el-icon>
查看PDF
</el-button>
<el-button
v-if="row.reportUrl"
type="success"
link
size="small"
@click="handleDownloadPdf(row)"
>
<el-icon><Download /></el-icon>
下载PDF
</el-button>
<el-button
v-if="row.status === 3 || row.status === 5"
type="warning"
@ -649,6 +669,25 @@ function handleViewWebReport(row: AssessmentRecordItem) {
window.open(url, '_blank')
}
/** 在新标签页查看 PDF 报告 */
function handleViewPdf(row: AssessmentRecordItem) {
if (row.reportUrl) {
window.open(row.reportUrl, '_blank')
}
}
/** 下载 PDF 报告 */
function handleDownloadPdf(row: AssessmentRecordItem) {
if (!row.reportUrl) return
const link = document.createElement('a')
link.href = row.reportUrl
link.download = `测评报告_${row.name}_${row.id}.pdf`
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
/** 表格选择变化 */
function handleSelectionChange(rows: AssessmentRecordItem[]) {
state.selectedRows = rows

View File

@ -160,8 +160,9 @@ public class ReportQueueConsumer : BackgroundService
}
catch (Exception pdfEx)
{
// PDF 生成失败不影响结论数据,仅记录错误日志
// PDF 生成失败,更新状态为生成失败
_logger.LogError(pdfEx, "PDF 报告生成失败RecordId: {RecordId}", message.RecordId);
await UpdateRecordStatusToFailedAsync(message.RecordId);
}
}
catch (Exception ex)

View File

@ -10,6 +10,8 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="PdfSharpCore" Version="1.3.67" />
<!-- Tencent Cloud COS SDK -->
<PackageReference Include="Tencent.QCloud.Cos.Sdk" Version="5.4.44" />
</ItemGroup>
<PropertyGroup>

View File

@ -1,8 +1,13 @@
using System.Text.Json;
using COSXML;
using COSXML.Auth;
using COSXML.Model.Object;
using MiAssessment.Core.Interfaces;
using MiAssessment.Core.Models;
using MiAssessment.Model.Data;
using MiAssessment.Model.Entities;
using MiAssessment.Model.Models.Auth;
using MiAssessment.Model.Models.Config;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PdfSharpCore.Drawing;
@ -26,27 +31,36 @@ public class PdfGenerationService : IPdfGenerationService
private const double PageHeightPt = 694.5;
private readonly MiAssessmentDbContext _dbContext;
private readonly AdminConfigReadDbContext _adminConfigDbContext;
private readonly IScreenshotService _screenshotService;
private readonly ReportSettings _reportSettings;
private readonly AppSettings _appSettings;
private readonly ILogger<PdfGenerationService> _logger;
private const string ReportBasePath = "reports";
private const string UploadConfigKey = "uploads";
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
/// <summary>
/// 初始化 PDF 报告生成服务
/// </summary>
/// <param name="dbContext">数据库上下文</param>
/// <param name="dbContext">业务数据库上下文</param>
/// <param name="adminConfigDbContext">Admin 配置只读上下文</param>
/// <param name="screenshotService">截图服务</param>
/// <param name="reportSettings">报告生成配置</param>
/// <param name="appSettings">应用程序配置</param>
/// <param name="logger">日志记录器</param>
public PdfGenerationService(
MiAssessmentDbContext dbContext,
AdminConfigReadDbContext adminConfigDbContext,
IScreenshotService screenshotService,
ReportSettings reportSettings,
AppSettings appSettings,
ILogger<PdfGenerationService> logger)
{
_dbContext = dbContext;
_adminConfigDbContext = adminConfigDbContext;
_screenshotService = screenshotService;
_reportSettings = reportSettings;
_appSettings = appSettings;
@ -111,21 +125,46 @@ public class PdfGenerationService : IPdfGenerationService
BuildAndSavePdf(imageList, filePath);
// 6. 更新数据库 ReportUrl
var cdnPrefix = _appSettings.CdnPrefix?.TrimEnd('/') ?? string.Empty;
var reportUrl = string.IsNullOrEmpty(cdnPrefix)
? $"/reports/{fileName}"
: $"{cdnPrefix}/reports/{fileName}";
// 6. 尝试上传到 COS
string reportUrl;
var cosUrl = await UploadToCosAsync(filePath, fileName);
if (!string.IsNullOrEmpty(cosUrl))
{
reportUrl = cosUrl;
// COS 上传成功,删除本地文件
try
{
File.Delete(filePath);
_logger.LogInformation("本地 PDF 文件已删除: {FilePath}", filePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "删除本地 PDF 文件失败: {FilePath}", filePath);
}
}
else
{
// COS 上传失败,回退到本地路径
var cdnPrefix = _appSettings.CdnPrefix?.TrimEnd('/') ?? string.Empty;
reportUrl = string.IsNullOrEmpty(cdnPrefix)
? $"/reports/{fileName}"
: $"{cdnPrefix}/reports/{fileName}";
_logger.LogWarning("COS 上传失败,使用本地路径: {ReportUrl}", reportUrl);
}
// 7. 更新数据库 ReportUrl设置状态为已完成
var record = await _dbContext.AssessmentRecords.FindAsync(recordId);
if (record != null)
{
record.ReportUrl = reportUrl;
record.Status = 4;
record.CompleteTime = DateTime.Now;
record.UpdateTime = DateTime.Now;
await _dbContext.SaveChangesAsync();
}
_logger.LogInformation("PDF 报告生成成功RecordId: {RecordId},文件路径: {FilePath}",
recordId, filePath);
_logger.LogInformation("PDF 报告生成成功RecordId: {RecordId}ReportUrl: {ReportUrl}",
recordId, reportUrl);
}
/// <summary>
@ -262,6 +301,104 @@ public class PdfGenerationService : IPdfGenerationService
return protocol + path;
}
/// <summary>
/// 上传 PDF 文件到腾讯云 COS
/// </summary>
/// <param name="filePath">本地文件路径</param>
/// <param name="fileName">文件名</param>
/// <returns>COS 访问 URL失败返回 null</returns>
private async Task<string?> UploadToCosAsync(string filePath, string fileName)
{
try
{
// 从 Admin 库读取上传配置
var configValue = await _adminConfigDbContext.AdminConfigs
.Where(c => c.ConfigKey == UploadConfigKey)
.Select(c => c.ConfigValue)
.FirstOrDefaultAsync();
if (string.IsNullOrEmpty(configValue))
{
_logger.LogWarning("未找到上传配置,跳过 COS 上传");
return null;
}
var setting = JsonSerializer.Deserialize<UploadSetting>(configValue, JsonOptions);
if (setting == null || setting.Type != "3")
{
_logger.LogWarning("上传配置不是腾讯云 COS 类型(当前: {Type}),跳过 COS 上传", setting?.Type);
return null;
}
// 验证必要配置
if (string.IsNullOrWhiteSpace(setting.Bucket) ||
string.IsNullOrWhiteSpace(setting.Region) ||
string.IsNullOrWhiteSpace(setting.AccessKeyId) ||
string.IsNullOrWhiteSpace(setting.AccessKeySecret) ||
string.IsNullOrWhiteSpace(setting.Domain))
{
_logger.LogWarning("COS 配置不完整,跳过上传");
return null;
}
// 构建 COS 对象路径: reports/2026/03/17/report_3_20260317230151.pdf
var now = DateTime.Now;
var datePath = $"{now.Year}/{now.Month:D2}/{now.Day:D2}";
var objectKey = $"{ReportBasePath}/{datePath}/{fileName}";
// 创建 COS 客户端
var cosConfig = new CosXmlConfig.Builder()
.IsHttps(true)
.SetRegion(setting.Region)
.Build();
var credentialProvider = new DefaultQCloudCredentialProvider(
setting.AccessKeyId, setting.AccessKeySecret, 600);
var cosXml = new CosXmlServer(cosConfig, credentialProvider);
// 上传文件
var fileBytes = await File.ReadAllBytesAsync(filePath);
var putRequest = new PutObjectRequest(setting.Bucket, objectKey, fileBytes);
putRequest.SetRequestHeader("Content-Type", "application/pdf");
var result = cosXml.PutObject(putRequest);
if (result.IsSuccessful())
{
// 生成访问 URL
var domain = setting.Domain!.TrimEnd('/');
if (!domain.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!domain.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
domain = $"https://{domain}";
}
var cosUrl = $"{domain}/{objectKey}";
_logger.LogInformation("PDF 上传 COS 成功: {CosUrl}", cosUrl);
return cosUrl;
}
_logger.LogError("PDF 上传 COS 失败: {HttpMessage}", result.httpMessage);
return null;
}
catch (COSXML.CosException.CosClientException ex)
{
_logger.LogError(ex, "COS 客户端错误,上传 PDF 失败");
return null;
}
catch (COSXML.CosException.CosServerException ex)
{
_logger.LogError(ex, "COS 服务端错误,上传 PDF 失败: {Info}", ex.GetInfo());
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "上传 PDF 到 COS 异常");
return null;
}
}
/// <summary>
/// 使用 PdfSharpCore 将图片列表合并为 PDF 文件并保存
/// </summary>

View File

@ -515,7 +515,7 @@ public class ReportGenerationService
/// - 批量写入新的 AssessmentResult 记录
/// - 删除该 RecordId 已有的 AssessmentRecordConclusion 记录(硬删除,支持重新生成)
/// - 从 report_conclusions 模板表复制结论数据到 assessment_record_conclusions
/// - 更新 AssessmentRecord 的 Status 为 4已完成并设置 CompleteTime
/// - 保持 AssessmentRecord 的 Status 为 3生成中等待 PDF 生成后再更新为 4已完成
/// 异常时回滚事务Status 保持为 3生成中
/// </remarks>
/// <param name="recordId">测评记录ID</param>
@ -615,12 +615,10 @@ public class ReportGenerationService
_logger.LogDebug("复制结论数据recordId: {RecordId}, 数量: {Count}", recordId, newConclusions.Count);
}
// 重新加载测评记录(带跟踪),更新状态为4(已完成)
// 重新加载测评记录(带跟踪),保持 Status=3生成中等待 PDF 生成后再更新为4
var record = await _dbContext.AssessmentRecords
.FirstAsync(r => r.Id == recordId);
record.Status = 4;
record.CompleteTime = now;
record.UpdateTime = now;
await _dbContext.SaveChangesAsync();

View File

@ -161,11 +161,12 @@ public class ServiceModule : Module
builder.Register(c =>
{
var dbContext = c.Resolve<MiAssessmentDbContext>();
var adminConfigDbContext = c.Resolve<AdminConfigReadDbContext>();
var screenshotService = c.Resolve<IScreenshotService>();
var reportSettings = c.Resolve<Core.Models.ReportSettings>();
var appSettings = c.Resolve<AppSettings>();
var logger = c.Resolve<ILogger<PdfGenerationService>>();
return new PdfGenerationService(dbContext, screenshotService, reportSettings, appSettings, logger);
return new PdfGenerationService(dbContext, adminConfigDbContext, screenshotService, reportSettings, appSettings, logger);
}).As<IPdfGenerationService>().InstancePerLifetimeScope();
// ========== 小程序测评模块服务注册 ==========