【061】U9系统报表输出
侧边栏壁纸
  • 累计撰写 70 篇文章
  • 累计收到 2 条评论

【061】U9系统报表输出

竹秋廿九
2025-08-20 / 1 评论 / 52 阅读 / 正在检测是否收录...
本文以总账 - 明细账为例,其余系统报表大同小异
原理就是系统报表导出的URL其实在加载报表之后会在js里面,名为ExportUrlBase的属性
可以自己新窗口打开明细账报表加载数据之后,右键查看源代码,搜索ExportUrlBase,看后面的那一串URL值
对比一下自己点击导出->PDF或者Excel,会发现弹出来的URL地址就是ExportUrlBase属性的值拼接上了PDF或者EXCELOPENXML
因为我的程序只是需要这两种格式,所以也只是适配了这两种,看懂流程跟程序之后可以自行增加

UI插件改变查询方案条件

新增查询方案

先给需要UI插件修改的条件给一个默认值(更方便UI插件直接修改)
比如示例:给账簿、会计期间设置默认条件(因为获取报表输出主要还是按组织+会计月)

获取报表URL

明细账报表界面新窗口打开可以拿到一串URL
http://localhost/U9/erp/display.aspx?lnk=FI.GL.Process.Rpt.DetailsRpt&sId=3002nid&mId=1001001289685483&__curOId=1001008170100181&newopen=true&__dbg=true
去掉我们不需要的,留下 http://localhost/U9/erp/display.aspx?lnk=FI.GL.Process.Rpt.DetailsRpt&sId=3002&newopen=true (可以另起一个标签页验证能不能打开)

image.png

右键查看页面源代码(附)

附:查看到的ExportUrlBase,就是导出的URL

6f4637b412e0cf9c56c27416e1c74242.png

73b241757801bc8637effb15df5cf7df.png

增加自定义条件

通过UI插件改变上面默认查询方案的三个条件

UI插件配置示例

WebPartExtend_ReportFilter.config
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
 <configSections>
  <section name="WebPartExtend" type="UFSoft.UBF.UI.Custom.ExtendedPartSection, UFSoft.UBF.UI.FormProcess" />
 </configSections>

 <WebPartExtend>
    
    <!-- 总账-明细账 -->
    <ExtendedPart parentPartFullName="UFIDA.U9.GL.DetailsRptUI.DetailsRptUIFormWebPart"
      extendedPartFullName="UFIDA.U9.Cust.XXXX.UIPlugIn.DetailsRptUIPlugIn"
      extendedPartAssemblyName="UFIDA.U9.Cust.XXXX.UIPlugIn.dll">
    </ExtendedPart>

  </WebPartExtend>
</configuration>

UI插件代码

DetailsRptUIPlugIn.cs
ReportID自己去UBF查看,或者调试一下代码,监听变量Part.CurrentState["ReportID"]的值
using System;
using System.Collections.Generic;
using UFIDA.U9.GL.DetailsRptUI;
using UFIDA.UBF.Query.CaseModel;
using UFIDA.UBF.Report.App.UI;
using UFSoft.UBF.Report.Filter.FilterModel;
using UFSoft.UBF.UI.IView;
using UFSoft.UBF.Util.Context;

namespace UFIDA.U9.Cust.XXXX.UIPlugIn
{
    /// <summary>
    /// 明细账
    /// </summary>
    public class DetailsRptUIPlugIn : UFSoft.UBF.UI.Custom.ExtendedPartBase
    {

        private DetailsRptUIFormWebPart _part;
        // Part.CurrentState["ReportID"]
        private readonly string ReportID = "45b4acd1-f251-4140-b030-9d506d6da095";
        private Case myCase = null;

        public override void AfterInit(IPart Part, EventArgs args)
        {
            base.AfterInit(Part, args);
            _part = Part as DetailsRptUIFormWebPart;
            if (_part != null)
            {
                SetMyCase();
            }
        }

        public override void AfterDataLoad(IPart Part)
        {
            base.AfterDataLoad(Part);
            _part = Part as DetailsRptUIFormWebPart;
            if (_part != null)
            {
                ChangeUserCaseFilter();
            }
        }

        private void SetMyCase()
        {
            string caseName = _part.NameValues["CaseName"]?.ToString();
            if (string.IsNullOrEmpty(caseName)) return;
            myCase = Common.Utils.LoadAndEnsureDefaultCase(ReportID, caseName);
        }

        private void ChangeUserCaseFilter()
        {
            if (myCase != null)
            {
                Common.Utils.UpdateUserCaseDisplayNameByCurrentLanguage(_part.Action.CurrentState[ReportCommonAction.UserCaseDefineStateName] as CaseDefine, myCase);

                // 账簿(必须条件,不可能为空)
                FilterValue fvSOB = myCase.FilterValues.GetObjectByName("SOB_Code");
                string vSOB = Common.Utils.GetMainSOBID(ReportAppService.GetLoginOrgID());
                fvSOB.SetValue(0, vSOB);
                // 期间范围(必须条件,不可能为空) 3指定期间
                FilterValue fvPeriodRange = myCase.FilterValues.GetObjectByName("PeriodRange");
                if (fvPeriodRange.GetValue() != "3")
                {
                    fvPeriodRange.SetValue(0, "3");
                }
                // 记账期间
                FilterValue fvAccountingPeriod = myCase.FilterValues.GetObjectByName("AccountingPeriod");
                string v1 = _part.NameValues["AccountingPeriodStart"]?.ToString();
                string v2 = _part.NameValues["AccountingPeriodEnd"]?.ToString();
                if (fvAccountingPeriod == null)
                {
                    fvAccountingPeriod = Common.Utils.CreatePeriod("AccountingPeriod", myCase.FilterValues.Count + 1, UFSoft.UBF.Report.Filter.enuOperatorListType.Between, new List<string>() { v1, v2 });
                    myCase.FilterValues.Add(fvAccountingPeriod);
                }
                else if (fvAccountingPeriod.RelationOperator == UFSoft.UBF.Report.Filter.enuOperatorListType.Between)
                {
                    fvAccountingPeriod.SetValue(0, v1);
                    fvAccountingPeriod.SetValue(1, v2);
                }
                else if (fvAccountingPeriod.RelationOperator == UFSoft.UBF.Report.Filter.enuOperatorListType.Equal)
                {
                    if (v1 == v2) fvAccountingPeriod.SetValue(0, v1);
                }

                Common.Utils.SetCaseModelSession(this._part.Action, ReportID);
                _part.Action.CurrentState["IsCaseChange"] = true;
                _part.Action.CurrentState[ReportCommonAction.UserCaseStateName] = myCase;
            }
        }

    }
}

image.png

UI插件Common.Utils

using System;
using System.Collections.Generic;
using System.Reflection;
using UFIDA.U9.UI.PDHelper;
using UFIDA.UBF.Query.CaseModel;
using UFIDA.UBF.Report.App.UI;
using UFIDA.UBF.Report.App.UI.CaseManager;
using UFIDA.UBF.Report.App.UI.Interface;
using UFIDA.UBF.Report.App.UI.ProcessStrategy;
using UFSoft.UBF.Report.Filter;
using UFSoft.UBF.Report.Filter.FilterModel;
using UFSoft.UBF.UI;
using UFSoft.UBF.UI.ActionProcess;
using UFSoft.UBF.UI.FormProcess;
using UFSoft.UBF.UI.IView;
using UFSoft.UBF.Util.Context;
using UFSoft.UBF.Util.DataAccess;

namespace UFIDA.U9.Cust.XXXX.UIPlugIn.Common
{
    public class Utils
    {
        /// <summary>
        /// 获取组织的主账簿ID
        /// </summary>
        /// <param name="orgID">组织ID</param>
        /// <returns></returns>
        public static string GetMainSOBID(string orgID)
        {
            string sql = "select ID from Base_SetofBooks where SOBType=0 and Org=" + orgID;
            DataAccessor.RunSQL(DataAccessor.GetConn(), sql, null, out object sobobj);
            return sobobj?.ToString();
        }

        /// <summary>
        /// ReportCommonAction.UpdateUserCaseDisplayNameByCurrentLanguage方法调用
        /// </summary>
        /// <param name="caseDefine"></param>
        /// <param name="userCase"></param>
        public static void UpdateUserCaseDisplayNameByCurrentLanguage(CaseDefine caseDefine, Case userCase)
        {
            // 获取类型
            Type type = typeof(ReportCommonAction);
            // 定义参数类型数组
            Type[] parameterTypes = new Type[] { typeof(CaseDefine), typeof(Case) };
            // 获取方法信息
            MethodInfo methodInfo = type.GetMethod("UpdateUserCaseDisplayNameByCurrentLanguage", BindingFlags.NonPublic | BindingFlags.Static, null, parameterTypes, null);
            if (methodInfo == null) throw new Exception("ReportCommonAction静态方法 UpdateUserCaseDisplayNameByCurrentLanguage 未找到");
            // 调用方法,传递参数
            methodInfo.Invoke(null, new object[] { caseDefine, userCase });
        }

        /// <summary>
        /// ReportCommonAction.SetCaseModelSession方法调用
        /// </summary>
        /// <param name="action"></param>
        /// <param name="reportID">原方法里面这个值是从args获取的,UI插件没有,直接指定</param>
        public static void SetCaseModelSession(BaseAction action, string reportID)
        {
            CaseModel caseModel = new CaseModel();
            caseModel.Case = (action.CurrentState[ReportCommonAction.UserCaseStateName] as Case);
            ReportProcessStrategy reportProcessStrategy = action.CurrentState[ReportAppService.GetReportProcessStrategySessionName()] as ReportProcessStrategy;
            if (reportProcessStrategy != null)
            {
                try
                {
                    caseModel.CaseDefine = reportProcessStrategy.ProcessCaseDefine(caseModel.Case, action.CurrentState[ReportCommonAction.UserCaseDefineStateName] as CaseDefine);
                    ReportDrillHelper.AddExportExcelControlParameter(caseModel.CaseDefine, false);
                    string text = reportProcessStrategy.VerifyUserCaseByCaseDefine(caseModel.CaseDefine, caseModel.Case);
                    if (text.Length > 0)
                    {
                        action.CurrentState["AdjustUserCaseDisplayInfo"] = text;
                    }
                }
                catch (Exception ex)
                {
                    caseModel.CaseDefine = (action.CurrentState[ReportCommonAction.UserCaseDefineStateName] as CaseDefine);
                }
            }
            else
            {
                caseModel.CaseDefine = (action.CurrentState[ReportCommonAction.UserCaseDefineStateName] as CaseDefine);
            }
            caseModel.QryModelID = new Guid(reportID);
            action.CurrentState[ReportAppService.GetInputCaseModelSessionName()] = caseModel;
        }

        /// <summary>
        /// 创建会计期间条件项
        /// </summary>
        /// <param name="name">条件名</param>
        /// <param name="itemID">条件项ID</param>
        /// <param name="relationOperator">关系操作符</param>
        /// <returns></returns>
        public static FilterValue CreatePeriod(string name, int itemID, enuOperatorListType relationOperator, List<string> values)
        {
            FilterValue filterValue = new FilterValue();
            filterValue.Name = name;
            filterValue.FilterItemID = itemID;
            filterValue.RelationOperator = relationOperator;
            filterValue.PageType = enuPageOfInputFilterValueType.basicPage;
            filterValue.ReferenceType = enuReferenceType.reference;
            filterValue.LogicOperator = enuOperatorListType.And;
            filterValue.ValueType = enumFilterValueType.InputValue;
            filterValue.Values = new ValueContext();
            filterValue.Values.Labels.Add(name);
            filterValue.Values.Values = values;
            return filterValue;
        }

        /// <summary>
        /// 创建会计年度条件项,等于
        /// </summary>
        /// <param name="name">条件名</param>
        /// <param name="itemID">条件项ID</param>
        /// <param name="v1"></param>
        /// <returns></returns>
        public static FilterValue CreateAccountingYearEqual(string name, int itemID, string v1)
        {
            FilterValue filterValue = new FilterValue();
            filterValue.Name = name;
            filterValue.FilterItemID = itemID;
            filterValue.RelationOperator = enuOperatorListType.Equal;
            filterValue.PageType = enuPageOfInputFilterValueType.basicPage;
            filterValue.ReferenceType = enuReferenceType.reference;
            filterValue.LogicOperator = enuOperatorListType.And;
            filterValue.ValueType = enumFilterValueType.InputValue;
            filterValue.Values = new ValueContext();
            filterValue.Values.Labels.Add(name);
            filterValue.Values.Values = new List<string>() { v1 };
            return filterValue;
        }

        /// <summary>
        /// 获取当前用户报表的指定查询方案,并确保有一个默认的查询方案
        /// 如果当前用户报表没有默认查询方案,则设置指定查询方案为默认查询方案
        /// </summary>
        /// <param name="reportID">报表ID</param>
        /// <param name="caseName">查询方案名</param>
        /// <returns></returns>
        public static Case LoadAndEnsureDefaultCase(string reportID, string caseName)
        {
            Case myCase = null;
            string userid = PlatformContext.Current.UserID + "#" + ReportAppService.GetLoginOrgID();
            Case defCase = ReportAppService.LoadDefalutCase(reportID, userid);
            // 默认的方案就是要的查询方案
            if (defCase != null && defCase.BasicInfo.Title == caseName)
            {
                myCase = defCase;
            }
            else
            {
                myCase = ReportAppService.LoadCase(reportID, userid, caseName);
                // 这个查询方案不是当前用户创建的(别的用户共享的查询方案)
                // 那就只能是从报表所有的查询方案中匹配
                if (myCase == null)
                {
                    ReportCaseManager caseManager = new ReportCaseManager();
                    var cases = caseManager.LoadCases(reportID); // 报表所有查询方案
                    foreach (Case item in cases)
                    {
                        if (item.BasicInfo.Title == caseName)
                        {
                            myCase = item;
                            break;
                        }
                    }
                }
                if (myCase == null) throw new Exception($"未找到名称【{caseName}】的查询方案!");
                if (defCase == null)
                {
                    // 没有默认查询方案,把当前找到的查询方案设置成默认查询方案
                    SetMyDefaultCase(myCase);
                }
            }
            return myCase;
        }

        /// <summary>
        /// 设置为默认查询方案
        /// </summary>
        /// <param name="case"></param>
        public static void SetMyDefaultCase(Case @case)
        {
            UserCaseInfo userCaseInfo = new UserCaseInfo();
            userCaseInfo.CaseID = @case.BasicInfo.ReportCaseID;
            userCaseInfo.OrgID = Convert.ToInt64(ReportAppService.GetLoginOrgID());
            userCaseInfo.UserID = Convert.ToInt64(PlatformContext.Current.UserID);
            userCaseInfo.UserCaseType = UserCaseType.Default;
            ICaseManager caseManager = new ReportCaseManager();
            caseManager.SetMyDefaultUserCaseInfo(userCaseInfo);
        }
    }
}

报表SSO登录

U9系统自带的auotlogin.aspx拼接的return_url会是以菜单的形式打开,获取iframe难度大
不如仿照SSO登录,直接写一个ReportLogin.aspx,登录后Response.Redirect()
以下为示例的ReportLogin.aspx,可以根据情况个性化

ReportLogin.aspx

ReportLogin.aspx放到C:\yonyou\U9V60\Portal\api\v1目录下
_internalSecureToken是内网请求,所以直接定义了一个固定的值
如果是外网可访问,建议还是生成式token更好
<%@ Page Language="C#" %>
<%@ Import Namespace="UFSoft.UBF.UI.Portal.Components" %>
<%@ Import Namespace="UFSoft.UBF.UI.WebControlAdapter" %>
<%@ Import Namespace="UFSoft.UBF.UI.Portal" %>
<%@ Import Namespace="System.Web.UI" %>
<%@ Import Namespace="System.Web.Configuration" %>
<%@ Import Namespace="System.Text.RegularExpressions" %>
<%@ Import Namespace="System.Web.Security" %>
<%@ Import Namespace="System.Text" %>
<%@ Import Namespace="UFSoft.UBF.Util.Context" %>
<%@ Import Namespace="UFSoft.UBF.UI" %>
<%@ Import Namespace="UFSoft.UBF.UI.IProvider" %>
<%@ Import Namespace="UFSoft.UBF.MVC" %>
<%@ Import Namespace="System.Net" %>
<%@ Import Namespace="System.IO" %>
<%@ Import Namespace="System.Collections.Specialized" %>
<%@ Import Namespace="System.Web" %>


<script runat="server">
    private const string _internalSecureToken = "2FFEAB291D1E7EAF76E94EBE93249C9D";
    
    private string _username;
    private string _enterpriseId;
    private string _orgID;
    private string _enterpriseName = string.Empty;
    
    protected void Page_Load(object sender,EventArgs e)
    {
        Login();
    }

    private void Login()
    {
        string receivedToken = Request.QueryString["securetoken"];
        if (string.IsNullOrEmpty(receivedToken) || receivedToken != _internalSecureToken)
        {
            Response.StatusCode = 403; // Forbidden
            Response.Write("错误: 禁止访问。");
            Response.End();
            return;
        }

        _username = Request.QueryString["usercode"];
        _enterpriseId = Request.QueryString["entcode"];
        _orgID = Request.QueryString["org"];

        if (string.IsNullOrEmpty(_username) || string.IsNullOrEmpty(_enterpriseId) || string.IsNullOrEmpty(_orgID))
        {
            Response.StatusCode = 400; // Bad Request
            Response.Write("错误: 缺少 username, enterpriseId, 或 orgID 参数。");
            Response.End();
            return;
        }

        string reportUrl = Request.QueryString["reporttype"];
        if (string.IsNullOrEmpty(reportUrl))
        {
            Response.StatusCode = 400;
            Response.Write("错误: 缺少必需的 reporttype 参数。");
            Response.End();
            return;
        }
            
        CSUser user = new CSUser();
        user.EnterpriseID = _enterpriseId;
        user.OrgId = _orgID;
        user.Username = _username;     
        user.LoginDateTime = DateTime.Now;
        user.IP = this.Page.Request.UserHostAddress;
        user.EnterpriseName = _enterpriseName;
        user.UICulture = "zh-CN";
        bool isToken = true;
        UFSoft.UBF.MVC.Helper.UserAuthHelper.TransferUser(user, "", "", true, isToken);

        try
        {
            UserCredential credential = UFSoft.UBF.UI.Portal.UserManagement.ValidUser(user,isToken);
            if (credential.IsAuthenticated == LoginUserStatus.Success)
            {
                CSContext.Current["AppSettings"] = WebConfigurationManager.AppSettings;
                CSContext.Current["ShowControlsTooltip"] = WebConfigurationManager.AppSettings["ShowControlsTooltip"];
                CSContext.Current["ReferenceDisableClientCache"] = WebConfigurationManager.AppSettings["ReferenceDisableClientCache"];
                CSContext.Current["quickmenusCache"] = null;
                CSContext.Current["_PassWordStrategy"] = credential.PassWordStrategy;
                CSContext.Current["_SearchSessionID"] = Guid.NewGuid().ToString();
                CSContext.Current.OperationDate =  DateTime.Now.Date;
                CSContext.Current.UiDefaultCulture = UFSoft.UBF.UI.Portal.UserManagement.Provider.getDefaultLanByOrg(long.Parse(_orgID));
                
                if (string.IsNullOrEmpty(CSContext.Current.User.OrgName))
                {
                    CSOrganization csOrg = UFSoft.UBF.UI.Portal.UserManagement.getOrgByID(CSContext.Current.User.OrgId.ToString());
                    CSContext.Current.OrgCode = csOrg.Code;
                    CSContext.Current.User.OrgName = csOrg.Name;
                }
                else
                {
                    CSContext.Current.OrgCode = UFSoft.UBF.UI.Portal.UserManagement.getOrgCode(user.OrgId.ToString());
                }
                
                SetDefaultTheme(user.OrgId.ToString());
                SaveCookies(user);
                WriteCurrentContext();
                UFSoft.UBF.UI.Portal.UserManagement.UserLogoned(user.Username, CSContext.Current.UserName);
                
                NameValueCollection incomingParams = new NameValueCollection(Request.QueryString);
                incomingParams.Remove("usercode");
                incomingParams.Remove("username");
                incomingParams.Remove("enterpriseId");
                incomingParams.Remove("entcode");
                incomingParams.Remove("orgID");
                incomingParams.Remove("org");
                incomingParams.Remove("securetoken");
                incomingParams.Remove("reporttype");

                UriBuilder uriBuilder = new UriBuilder(reportUrl);
                NameValueCollection baseParams = HttpUtility.ParseQueryString(uriBuilder.Query);

                baseParams.Add(incomingParams);
                uriBuilder.Query = baseParams.ToString();
                string finalTargetUrl = uriBuilder.ToString();
    Response.Redirect(finalTargetUrl);
            }
        }
        catch (Exception)
        {
            throw;
        }
    }

    private void WriteCurrentContext()
    {
        using (new SystemWritablePolicy())
        {
            PlatformContext.Current.OrgID = CSContext.Current.User.OrgId.ToString();
            PlatformContext.Current.UserID = CSContext.Current.User.UserId.ToString();
            PlatformContext.Current.UserCode = CSContext.Current.User.UserCode as string;
            PlatformContext.Current.UserName = CSContext.Current.User.Username;
            PlatformContext.Current.UserClientIP = CSContext.Current.User.IP;
        }
    }
    
    private int CookiePeriodPolicy = 7; 
    
    private void SaveCookie(string key, string value)
    {
        this.Page.Response.Cookies[key].Value = Convert.ToBase64String(Encoding.Unicode.GetBytes(value));
        this.Page.Response.Cookies[key].Expires = DateTime.Now.AddDays((double)this.CookiePeriodPolicy);
    }
    
    private void SaveCookies(CSUser csUser)
    {
        SaveCookie("EnterpriseId", _enterpriseId);
        SaveCookie("SelectedOrg", csUser.OrgId as string);
        SaveCookie("SelectedLan", csUser.UICulture);
        SaveCookie("userName", csUser.Username);
    }

    private string SetDefaultTheme(string orgId)
    {
        string orgDefaultThemeByOrgID = UFSoft.UBF.UI.Portal.UserManagement.GetOrgDefaultThemeByOrgID(Convert.ToInt64(orgId));
        
        if (((CSContext.Current != null) && (CSContext.Current.User != null)) && (CSContext.Current.User.Profile != null))
        {
            CSContext.Current.User.Profile.Theme = orgDefaultThemeByOrgID;
        }
        return orgDefaultThemeByOrgID;
    }
</script>

<html xmlns="http://www.w3.org/1999/xhtml">
<head id="Head1" runat="server">
</head>
</html>

PuppeteerSharp实现读取html源代码并跳转下载

示例新增了一个ashx一般处理程序来使用PuppeteerSharp
思路有了,可以根据自己的习惯用API或者别的方式,总之这里提供出去的URL才是请求报表输出的入口
PuppeteerSharp程序可以使用Nuget安装最新稳定版2.0
直接获取U9报表文件流有些报表可能会很大导致等待时间长,优化一下路径,分为三个ashx文件
DownloadReport.ashx主要进行SSO登录并解析ExportUrlBase后下载到临时目录
DownloadReportFile.ashx负责访问临时目录的文件,提供下载文件服务
DeleteReportFile.ashx负责删除临时目录的文件,提供删除文件服务

PuppeteerSharp

image.png

文件下载到临时目录DownloadReport.ashx

ExecutablePath = @"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
这是edge浏览器,如果服务器没有,那就换成别的
p参数里面包含了timespan,避免URL被获取之后重复拿来使用,失效时间就是web.config配置的ExpirationTimeSeconds,以秒为单位
web.config里面PuppeteerTimeoutMinutes是访问报表URL等待的超时时间,因为报表首次访问会有冷启动,加上系统报表查询时间,最好是设置长一点,这里默认30分钟
web.config配置在下面
using Newtonsoft.Json;
using PuppeteerSharp;
using System;
using System.Collections.Specialized;
using System.Configuration;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Security;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;

namespace SHAPIPost
{
    public class DownloadHandler : HttpTaskAsyncHandler
    {
        private static readonly string encryptionKey = ConfigurationManager.AppSettings["EncryptionKey"];
        private static readonly string encryptionIV = ConfigurationManager.AppSettings["EncryptionIV"];
        private static readonly string reportBaseUrl = ConfigurationManager.AppSettings["ReportBaseUrl"];
        private static readonly string loginPageBaseUrl = ConfigurationManager.AppSettings["U9LoginBaseUrl"];
        private static readonly string timeoutSetting = ConfigurationManager.AppSettings["PuppeteerTimeoutMinutes"];
        private static readonly string expirationTimeSeconds = ConfigurationManager.AppSettings["ExpirationTimeSeconds"];

        static DownloadHandler()
        {
            ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls;
        }

        // IHttpAsyncHandler 的 IsReusable 属性,我们用 override 实现
        public override bool IsReusable { get { return false; } }

        // 【核心下载逻辑 - 全新改造版】
        public override async Task ProcessRequestAsync(HttpContext context)
        {
            var stopwatch = Stopwatch.StartNew();
            string requestId = context.Request.QueryString["taskID"];
            if (string.IsNullOrEmpty(requestId)) requestId = Guid.NewGuid().ToString("N").Substring(0, 8);
            ReportLogger.Log(requestId, $"请求开始: {context.Request.Url}");

            var Response = context.Response;
            Response.ContentType = "application/json"; // 响应类型改为JSON

            try
            {
                // --- 1. 认证和参数准备 ---
                var authResult = AuthenticateAndPrepare(context);
                string exportType = authResult.ExportType;

                string fileExtension = ".dat";
                if (exportType.Equals("PDF", StringComparison.OrdinalIgnoreCase)) { fileExtension = ".pdf"; }
                else if (exportType.Equals("EXCELOPENXML", StringComparison.OrdinalIgnoreCase)) { fileExtension = ".xlsx"; }
                else if (exportType.Equals("CSV", StringComparison.OrdinalIgnoreCase)) { fileExtension = ".csv"; }

                // 使用GUID确保文件名唯一,避免冲突
                string fileName = $"{authResult.Filename}{fileExtension}";
                string uniqueFileName = $"{authResult.Filename}_{requestId}{fileExtension}";

                // --- 2. 等待报表在服务器端完全生成 ---
                ReportLogger.Log(requestId, $"开始生成报表: {authResult.BrowserLoginUrl}");
                byte[] fileBytes = await GenerateReportBytesAsync(authResult.BrowserLoginUrl, exportType, requestId, context.Server);
                ReportLogger.Log(requestId, $"报表生成完毕,大小: {fileBytes.Length} 字节。");

                // --- 3. 将文件保存到服务器本地目录 ---
                string directoryPath = context.Server.MapPath("~/ReportFiles");
                Directory.CreateDirectory(directoryPath); // 确保目录存在
                string savePath = Path.Combine(directoryPath, uniqueFileName);
                if (File.Exists(savePath)) File.Delete(savePath);

                File.WriteAllBytes(savePath, fileBytes);
                ReportLogger.Log(requestId, $"文件已成功保存至磁盘: {savePath}");

                // --- 4. 构建可访问的URL和返回结果 ---
                var appUrl = HttpContext.Current.Request.Url;
                string baseUrl = $"{appUrl.Scheme}://{appUrl.Authority}{HttpContext.Current.Request.ApplicationPath.TrimEnd('/')}";
                string downloadUrl = $"{baseUrl}/DownloadReportFile.ashx?fileName={HttpUtility.UrlEncode(fileName)}&downName={HttpUtility.UrlEncode(uniqueFileName)}";
                string delUrl = $"{baseUrl}/DeleteReportFile.ashx?delName={HttpUtility.UrlEncode(uniqueFileName)}";

                var result = new
                {
                    IsSuccess = true,
                    Message = "文件生成成功。",
                    Url = downloadUrl,
                    DelUrl = delUrl
                };

                ReportLogger.Log(requestId, "准备向客户端发送JSON响应。");
                Response.Write(JsonConvert.SerializeObject(result));
                ReportLogger.Log(requestId, "已向客户端发送JSON响应。");
            }
            catch (Exception ex)
            {
                ReportLogger.Log(requestId, $"处理失败: {ex.Message} \n {ex.StackTrace}");
                Response.StatusCode = 500;
                var result = new
                {
                    IsSuccess = false,
                    Message = "处理请求时发生错误: " + ex.Message
                };
                Response.Write(JsonConvert.SerializeObject(result));
            }
            finally
            {
                stopwatch.Stop();
                ReportLogger.Log(requestId, $"请求结束,总耗时: {stopwatch.ElapsedMilliseconds} ms");
            }
        }

        private async Task<byte[]> GenerateReportBytesAsync(string browserLoginUrl, string exportType, string requestId, HttpServerUtility Server)
        {
            IBrowser browser = null;
            try
            {
                ReportLogger.Log(requestId, "后台任务开始:启动Puppeteer...");
                int timeoutMinutes = 30;
                if (!string.IsNullOrEmpty(timeoutSetting) && int.TryParse(timeoutSetting, out int parsedTimeout))
                {
                    timeoutMinutes = parsedTimeout;
                }

                browser = await Puppeteer.LaunchAsync(new LaunchOptions
                {
                    Headless = true,
                    IgnoreHTTPSErrors = true,
                    ExecutablePath = @"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
                });

                IBrowserContext incognitoContext = null;
                try
                {
                    // 1. 创建一个独立的、一次性的隐身浏览器上下文
                    incognitoContext = await browser.CreateIncognitoBrowserContextAsync();
                    ReportLogger.Log(requestId, "后台任务:已创建独立的Incognito上下文。");

                    // 2. 在这个纯净的上下文中创建一个新页面
                    var page = await incognitoContext.NewPageAsync();

                    // 后续所有操作都在这个隔离的页面中进行,不受缓存和Cookie影响
                    ReportLogger.Log(requestId, $"后台任务:Puppeteer访问SSO登录链接: {browserLoginUrl}");
                    page.DefaultNavigationTimeout = timeoutMinutes * 60 * 1000;
                    await page.GoToAsync(browserLoginUrl, new NavigationOptions { WaitUntil = new[] { WaitUntilNavigation.Networkidle0 } });

                    string reportHtml = await page.GetContentAsync();
                    var regex = new Regex("\"ExportUrlBase\":\"([^\"]*)\"");
                    Match match = regex.Match(reportHtml);

                    if (!match.Success)
                    {
                        string debugDir = Server.MapPath("~/ReportDebug");
                        Directory.CreateDirectory(debugDir);
                        string timestamp = DateTime.Now.ToString("yyyyMMddHHmmss");
                        string debugHtmlPath = Path.Combine(debugDir, $"debug_page_{timestamp}.html");
                        File.WriteAllText(debugHtmlPath, reportHtml);
                        throw new Exception($"无法在目标报表页面中解析到导出链接(ExportUrlBase)。已将调试HTML内容保存至 {Path.GetFileName(debugHtmlPath)}。");
                    }

                    string exportUrlBase = Regex.Unescape(match.Groups[1].Value);
                    var reportUri = new Uri(page.Url);
                    string finalDownloadUrl = $"{reportUri.Scheme}://{reportUri.Authority}{exportUrlBase}{exportType}";
                    ReportLogger.Log(requestId, $"后台任务:获取到下载链接,准备下载: {finalDownloadUrl}");

                    var cookies = await page.GetCookiesAsync();
                    var cookieContainer = new CookieContainer();
                    foreach (var cookie in cookies)
                    {
                        cookieContainer.Add(reportUri, new Cookie(cookie.Name, cookie.Value, cookie.Path, cookie.Domain));
                    }

                    using (var handler = new HttpClientHandler { CookieContainer = cookieContainer })
                    using (var client = new HttpClient(handler))
                    {
                        client.Timeout = TimeSpan.FromMinutes(timeoutMinutes);
                        client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36");

                        byte[] fileBytes;
                        // 使用 GetAsync 而不是 GetByteArrayAsync,以便先检查响应头
                        using (var response = await client.GetAsync(finalDownloadUrl, HttpCompletionOption.ResponseHeadersRead))
                        {
                            // 检查 Content-Type
                            var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
                            if (contentType.Equals("text/html", StringComparison.OrdinalIgnoreCase))
                            {
                                // 如果是HTML,尝试读取内容用于记录日志,然后抛出异常
                                string errorHtml = await response.Content.ReadAsStringAsync();
                                ReportLogger.Log(requestId, $"错误:服务器返回了HTML页面,可能因为会话过期。页面内容预览: {errorHtml.Substring(0, Math.Min(500, errorHtml.Length))}");
                                throw new InvalidDataException("下载失败:服务器返回了HTML错误页面,报表会话可能已过期或服务已重启。");
                            }

                            response.EnsureSuccessStatusCode(); // 检查是否有其他HTTP错误

                            // Content-Type看起来正常,现在下载完整的文件内容
                            fileBytes = await response.Content.ReadAsByteArrayAsync();
                        }

                        if (fileBytes == null || fileBytes.Length == 0)
                        {
                            throw new Exception("文件下载失败,获取到的文件流为空。");
                        }

                        ReportLogger.Log(requestId, $"后台任务:文件下载成功,大小: {fileBytes.Length} 字节。");

                        // 可选,修复文件头
                        if (exportType.Equals("EXCELOPENXML", StringComparison.OrdinalIgnoreCase))
                        {
                            ReportLogger.Log(requestId, $"文件原始大小: {fileBytes.Length} 字节。准备进行修复...");
                            // 调用修复器,获取干净的文件字节流
                            byte[] repairedFileBytes = ExcelRepairer.RepairExcelFile(fileBytes);

                            ReportLogger.Log(requestId, $"文件修复后大小: {repairedFileBytes.Length} 字节。");
                            return repairedFileBytes;
                        }

                        return fileBytes;
                    }

                }
                finally
                {
                    // 3. 确保任务结束后,无论成功或失败,都关闭并销毁这个上下文及其所有数据(缓存、Cookie等)
                    if (incognitoContext != null)
                    {
                        await incognitoContext.CloseAsync();
                        ReportLogger.Log(requestId, "后台任务:Incognito上下文已关闭并清理。");
                    }
                }
            }
            finally
            {
                if (browser != null)
                {
                    // 使用带超时的关闭方式,防止主浏览器进程卡死
                    ReportLogger.Log(requestId, "后台任务:准备关闭主Puppeteer浏览器(带10秒超时)...");
                    var closeTask = browser.CloseAsync();
                    var timeoutTask = Task.Delay(TimeSpan.FromSeconds(10));
                    var completedTask = await Task.WhenAny(closeTask, timeoutTask);
                    if (completedTask == closeTask)
                    {
                        ReportLogger.Log(requestId, "后台任务:主Puppeteer浏览器在10秒内成功关闭。");
                    }
                    else
                    {
                        ReportLogger.Log(requestId, "警告:关闭主Puppeteer浏览器超时(超过10秒),已放弃等待。");
                    }
                }
            }
        }

        private AuthResult AuthenticateAndPrepare(HttpContext context)
        {
            var Request = context.Request;
            var routeData = context.Request.RequestContext.RouteData.Values;
            if (string.IsNullOrEmpty(encryptionKey) || string.IsNullOrEmpty(encryptionIV))
                throw new ConfigurationErrorsException("未在配置中找到 EncryptionKey 或 EncryptionIV 设置。");

            if (string.IsNullOrEmpty(loginPageBaseUrl))
                throw new ConfigurationErrorsException("未在配置中找到 U9LoginBaseUrl 设置。");

            string reportTypeKey = routeData["reporttype"] as string;
            string encryptedPayload = Request.QueryString["p"];
            string filename = Request.QueryString["filename"];
            string exportType = Request.QueryString["exporttype"] ?? "PDF";
            if (string.IsNullOrEmpty(encryptedPayload) || string.IsNullOrEmpty(reportTypeKey))
                throw new ArgumentException("缺少必需的 p 或 reporttype 参数。");
            if (string.IsNullOrEmpty(filename)) filename = reportTypeKey + "_" + DateTime.Now.ToString("yyyyMMddHHmmss");

            string reportTypeLink = ConfigurationManager.AppSettings[reportTypeKey];
            if (string.IsNullOrEmpty(reportTypeLink))
                throw new ConfigurationErrorsException("未在配置中为 reporttype 键 '" + reportTypeKey + "' 找到对应的link配置。");

            string reportUrl = $"{reportBaseUrl}?{reportTypeLink}";
            string decryptedPayload = Utils.DecryptString(encryptedPayload, encryptionKey, encryptionIV);
            if (string.IsNullOrEmpty(decryptedPayload))
                throw new SecurityException("凭证解密失败。");

            NameValueCollection authParams = HttpUtility.ParseQueryString(decryptedPayload);
            string userCode = authParams["usercode"],
                enterpriseId = authParams["entcode"],
                organizationId = authParams["org"],
                timestampStr = authParams["timestamp"];
            if (string.IsNullOrEmpty(userCode) || string.IsNullOrEmpty(enterpriseId) || string.IsNullOrEmpty(organizationId) || string.IsNullOrEmpty(timestampStr))
            {
                throw new ArgumentException("解密后的凭证信息不完整(缺少用户、企业、组织或时间戳)。");
            }

            if (!Utils.IsUnixTimestamp(timestampStr))
            {
                throw new SecurityException("无效的时间戳格式,请使用Unix时间戳。");
            }
            if (!long.TryParse(timestampStr, out long unixTimestamp))
            {
                throw new SecurityException("无效的时间戳格式。");
            }
            int timeoutSeconds = 60;
            if (!string.IsNullOrEmpty(timeoutSetting))
            {
                int.TryParse(expirationTimeSeconds, out timeoutSeconds);
            }
            DateTime requestTime = Utils.UnixTimestampToDateTime(unixTimestamp);
            bool isExpired = Utils.IsTimeExpired(requestTime, timeoutSeconds);
            if (isExpired)
            {
                throw new SecurityException("链接已过期。");
            }

            var uriBuilder = new UriBuilder(loginPageBaseUrl);
            var query = HttpUtility.ParseQueryString(uriBuilder.Query);
            query["usercode"] = userCode;
            query["entcode"] = enterpriseId;
            query["org"] = organizationId;
            query["reporttype"] = reportUrl;
            var originalParams = new NameValueCollection(Request.QueryString);
            originalParams.Remove("p");
            originalParams.Remove("reporttype");
            originalParams.Remove("filename");
            query.Add(originalParams);
            uriBuilder.Query = query.ToString();

            return new AuthResult { BrowserLoginUrl = uriBuilder.ToString(), Filename = filename, ExportType = exportType };
        }
    }

    internal class AuthResult
    {
        public string BrowserLoginUrl { get; set; }
        public string Filename { get; set; }
        public string ExportType { get; set; }
    }

}

文件下载服务DownloadReportFile.ashx

通过中转服务提供对临时目录的文件下载,比直接提供流更稳定
而且有下载的文件存档,能够更方便调试是哪一步出现错误
using System;
using System.IO;
using System.Web;

namespace SHAPIPost
{
    public class DownloadReportFileHandler : IHttpHandler
    {
        public void ProcessRequest(HttpContext context)
        {
            // --- 1. 根据新的URL结构解析参数 ---
            // 用户下载时看到的友好文件名,来自 'fileName' 参数
            string friendlyFileName = context.Request.QueryString["fileName"];
            // 服务器上存储的唯一文件名,来自 'downName' 参数
            string serverFileName = context.Request.QueryString["downName"];

            // 提供一个回退机制,如果友好的fileName参数缺失,则使用服务器文件名
            if (string.IsNullOrWhiteSpace(friendlyFileName))
            {
                friendlyFileName = serverFileName;
            }

            // --- 2. 安全性校验 (使用 serverFileName) ---
            if (string.IsNullOrWhiteSpace(serverFileName))
            {
                context.Response.StatusCode = 400; // Bad Request
                // 注意:根据新的URL结构,现在应该检查 'downName'
                context.Response.StatusDescription = "Missing required 'downName' parameter.";
                context.Response.End();
                return;
            }

            try
            {
                string reportFilesDir = context.Server.MapPath("~/ReportFiles");
                // 使用 serverFileName 在磁盘上查找文件
                string physicalFilePath = Path.Combine(reportFilesDir, serverFileName);

                physicalFilePath = Path.GetFullPath(physicalFilePath);
                if (!physicalFilePath.StartsWith(reportFilesDir, StringComparison.OrdinalIgnoreCase))
                {
                    context.Response.StatusCode = 403; // Forbidden
                    context.Response.StatusDescription = "Access to the requested file is forbidden.";
                    context.Response.End();
                    return;
                }

                if (!File.Exists(physicalFilePath))
                {
                    context.Response.StatusCode = 404; // Not Found
                    context.Response.StatusDescription = "The requested file was not found.";
                    context.Response.End();
                    return;
                }

                // --- 3. 文件流式传输 (使用 friendlyFileName) ---
                context.Response.Clear();
                context.Response.ContentType = GetMimeType(Path.GetExtension(serverFileName));
                // 在 Content-Disposition 中使用 friendlyFileName
                context.Response.AddHeader("Content-Disposition", "attachment; filename=" + HttpUtility.UrlEncode(friendlyFileName, System.Text.Encoding.UTF8));

                context.Response.TransmitFile(physicalFilePath);
                context.Response.Flush();
            }
            catch (Exception)
            {
                context.Response.StatusCode = 500; // Internal Server Error
                context.Response.StatusDescription = "An error occurred while processing your request.";
            }
            finally
            {
                context.Response.End();
            }
        }

        public bool IsReusable => false;

        private string GetMimeType(string extension)
        {
            switch (extension.ToLower())
            {
                case ".pdf": return "application/pdf";
                case ".xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
                case ".xls": return "application/vnd.ms-excel";
                case ".csv": return "text/csv";
                default: return "application/octet-stream";
            }
        }
    }
}

文件删除服务DeleteReportFile.ashx

按需调用文件删除服务,比如文件上传到Minio之后的第三方OSS,可以删除本地的留档
也可以在处理文件下载服务之后,配置一个变量控制是否下载后删除,平常都是开启下载后删除,线上有问题的时候,更改配置为关闭下载后删除,方便查看临时目录的原文件是否有问题
using Newtonsoft.Json;
using System;
using System.IO;
using System.Web;

namespace SHAPIPost
{
    public class DeleteReportFileHandler : IHttpHandler
    {
        public void ProcessRequest(HttpContext context)
        {
            context.Response.ContentType = "application/json";

            try
            {
                // 从请求中获取要删除的文件名,推荐使用POST请求体传递
                string fileNameToDelete = context.Request.Form["delName"];
                if (string.IsNullOrEmpty(fileNameToDelete))
                {
                    fileNameToDelete = context.Request.QueryString["delName"];
                }

                if (string.IsNullOrWhiteSpace(fileNameToDelete))
                {
                    throw new ArgumentException("缺少 delName 参数。");
                }

                // --- 安全性校验 ---
                // 1. 获取基础存储目录的物理路径
                string reportFilesDir = context.Server.MapPath("~/ReportFiles");

                // 2. 组合出要删除文件的完整物理路径
                string physicalFileToDelete = Path.Combine(reportFilesDir, fileNameToDelete);

                // 3. 规范化路径以防止路径遍历攻击 (e.g., "..\web.config")
                physicalFileToDelete = Path.GetFullPath(physicalFileToDelete);

                // 4. 【关键】确保要删除的文件确实在我们指定的目录内
                if (!physicalFileToDelete.StartsWith(reportFilesDir, StringComparison.OrdinalIgnoreCase))
                {
                    throw new System.Security.SecurityException("禁止访问指定目录之外的文件。");
                }

                if (File.Exists(physicalFileToDelete))
                {
                    File.Delete(physicalFileToDelete);
                    var successResult = new { IsSuccess = true, Message = $"文件 '{fileNameToDelete}' 已成功删除。" };
                    context.Response.Write(JsonConvert.SerializeObject(successResult));
                }
                else
                {
                    throw new FileNotFoundException($"文件 '{fileNameToDelete}' 不存在。");
                }
            }
            catch (Exception ex)
            {
                var errorResult = new { IsSuccess = false, Message = ex.Message };
                context.Response.Write(JsonConvert.SerializeObject(errorResult));
            }
        }

        public bool IsReusable
        {
            get { return false; }
        }
    }
}

Utils.cs

using System;
using System.Data;
using System.Data.SqlClient;
using System.IO;
using System.Net;
using System.Security.Cryptography;
using System.Text;

namespace SHAPIPost
{
    public static class Utils
    {
        /// <summary>
        /// AES解密
        /// </summary>
        /// <param name="cipherText">密文</param>
        /// <param name="encryptionKey">密钥</param>
        /// <param name="encryptionIV">iv向量</param>
        public static string DecryptString(string cipherText, string encryptionKey, string encryptionIV)
        {
            try
            {
                string base64 = cipherText.Replace('-', '+').Replace('_', '/');
                int padding = base64.Length % 4;
                if (padding != 0)
                {
                    base64 += new string('=', 4 - padding);
                }
                byte[] cipherTextBytes = Convert.FromBase64String(base64);
    
                using (Aes aesAlg = Aes.Create())
                {
                    aesAlg.Key = Encoding.UTF8.GetBytes(encryptionKey);
                    aesAlg.IV = Encoding.UTF8.GetBytes(encryptionIV);
    
                    ICryptoTransform decryptor = aesAlg.CreateDecryptor(aesAlg.Key, aesAlg.IV);
    
                    using (MemoryStream msDecrypt = new MemoryStream(cipherTextBytes))
                    {
                        using (CryptoStream csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read))
                        {
                            using (StreamReader srDecrypt = new StreamReader(csDecrypt))
                            {
                                return srDecrypt.ReadToEnd();
                            }
                        }
                    }
                }
            }
            catch (Exception)
            {
                return null;
            }
        }
        
        /// <summary>
        /// AES加密
        /// </summary>
        /// <param name="plainText">明文文本</param>
        /// <param name="encryptionKey">密钥</param>
        /// <param name="encryptionIV">iv向量</param>
        public static string EncryptString(string plainText, string encryptionKey, string encryptionIV)
        {
            using (Aes aesAlg = Aes.Create())
            {
                aesAlg.Key = Encoding.UTF8.GetBytes(encryptionKey);
                aesAlg.IV = Encoding.UTF8.GetBytes(encryptionIV);

                ICryptoTransform encryptor = aesAlg.CreateEncryptor(aesAlg.Key, aesAlg.IV);

                using (MemoryStream msEncrypt = new MemoryStream())
                {
                    using (CryptoStream csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write))
                    {
                        using (StreamWriter swEncrypt = new StreamWriter(csEncrypt))
                        {
                            swEncrypt.Write(plainText);
                        }
                        byte[] encrypted = msEncrypt.ToArray();
                        return Convert.ToBase64String(encrypted)
                            .Replace('+', '-')
                            .Replace('/', '_')
                            .TrimEnd('=');
                    }
                }
            }
        }

        /// <summary>
        /// 判断是否为Unix时间戳格式
        /// </summary>
        /// <param name="timestamp">时间戳字符串</param>
        /// <returns>是否为Unix时间戳</returns>
        public static bool IsUnixTimestamp(string timestamp)
        {
            if (long.TryParse(timestamp, out long value))
            {
                // Unix时间戳通常在这个范围内(1970年到2038年左右)
                return value >= 0 && value <= 2147483647;
            }
            return false;
        }
    
        /// <summary>
        /// 将Unix时间戳转换为DateTime
        /// </summary>
        /// <param name="timestamp">Unix时间戳</param>
        /// <returns>DateTime对象</returns>
        public static DateTime UnixTimestampToDateTime(long timestamp)
        {
            DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
            return epoch.AddSeconds(timestamp);
        }
    
        /// <summary>
        /// 检查请求时间是否已过期
        /// </summary>
        /// <param name="requestTime">请求时间</param>
        /// <param name="timeoutSeconds">超时秒数</param>
        /// <returns>是否已过期</returns>
        public static bool IsTimeExpired(DateTime requestTime, int timeoutSeconds)
        {
            DateTime currentTime;
            if (requestTime.Kind == DateTimeKind.Utc)
            {
                currentTime = DateTime.UtcNow;
            }
            else
            {
                currentTime = DateTime.Now;
            }
    
            TimeSpan timeDiff = currentTime - requestTime;
            return Math.Abs(timeDiff.TotalSeconds) > timeoutSeconds;
        }
    }   
}

ExcelRepairer.cs

ExcelRepairer处理我们获取报表过程中写入了一些不干净的字节流到文件头,导致excel异常打不开。
此方法依赖于.NET的 ZipArchive 类能够容忍您文件中的损坏程度,并成功枚举出其中的所有条目(entries)。
如果文件的中心目录损坏到连条目列表都无法读取,那么此方法也将失败。
namespace SHAPIPost
{
    using System.IO;
    using System.IO.Compression;

    public static class ExcelRepairer
    {
        /// <summary>
        /// 通过重建ZIP归档的方式,尝试修复一个.xlsx文件。
        /// 此方法可以修复因中心目录与本地文件头元数据不一致(如Length不对)导致的问题。
        /// </summary>
        /// <param name="corruptedFileBytes">原始的、损坏的.xlsx文件字节数组。</param>
        /// <returns>一个新的、结构正确的.xlsx文件字节数组。</returns>
        public static byte[] RepairExcelFile(byte[] corruptedFileBytes)
        {
            // 创建一个内存流用于输出新的、修复后的文件
            using (var outputStream = new MemoryStream())
            {
                // 在输出流上创建一个新的、空的ZIP归档
                using (var destinationArchive = new ZipArchive(outputStream, ZipArchiveMode.Create, true))
                {
                    // 使用输入字节创建一个内存流来读取损坏的文件
                    using (var inputStream = new MemoryStream(corruptedFileBytes))
                    // 以“读取”模式打开损坏的归档,即使它有错误,也希望能读出条目
                    using (var sourceArchive = new ZipArchive(inputStream, ZipArchiveMode.Read))
                    {
                        // 遍历损坏归档中的每一个条目(内部文件)
                        foreach (var sourceEntry in sourceArchive.Entries)
                        {
                            // 在新的归档中,为当前条目创建一个同名的新条目
                            // 我们让.NET自己决定压缩级别等参数
                            var destinationEntry = destinationArchive.CreateEntry(sourceEntry.FullName);

                            // 打开源条目的数据流进行读取
                            using (var sourceStream = sourceEntry.Open())
                            // 打开新条目的数据流进行写入
                            using (var destinationStream = destinationEntry.Open())
                            {
                                // 将原始数据从损坏的归档中完整地复制到新的归档中
                                sourceStream.CopyTo(destinationStream);
                            }
                        }
                    }
                }
                // 当 using (destinationArchive...) 代码块结束时,
                // .NET会自动根据我们实际写入的数据,在文件末尾生成一个全新的、100%正确的中心目录。
                // 这就完成了修复。

                return outputStream.ToArray();
            }
        }
    }
}

ReportLogger.cs

using System;
using System.IO;
using System.Threading.Tasks;
using System.Web;

namespace SHAPIPost
{
    /// <summary>
    /// 轻量级的异步、线程安全日志记录器。
    /// </summary>
    public static class ReportLogger
    {
        private static readonly string LogDirectory = HttpContext.Current.Server.MapPath("~/ReportLogs");
        private static readonly object _lock = new object();

        static ReportLogger()
        {
            try
            {
                Directory.CreateDirectory(LogDirectory);
            }
            catch
            {
                // 忽略在创建目录时发生的错误
            }
        }

        public static void Log(string requestId, string message)
        {
            // 使用 Task.Run 将文件写入操作放入后台线程,避免阻塞主流程
            Task.Run(() =>
            {
                try
                {
                    string logFilePath = Path.Combine(LogDirectory, $"log_{DateTime.Now:yyyy-MM-dd}.txt");
                    string logMessage = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] [RequestID: {requestId}] {message}{Environment.NewLine}";

                    // 使用锁确保多线程写入安全
                    lock (_lock)
                    {
                        File.AppendAllText(logFilePath, logMessage);
                    }
                }
                catch
                {
                    // 忽略日志写入失败
                }
            });
        }
    }
}

web.config

securetoken就是ReportLogin.aspx验证的_internalSecureToken,保持一致即可
<?xml version="1.0" encoding="utf-8"?>
<!--
  有关如何配置 ASP.NET 应用程序的详细信息,请访问
  https://go.microsoft.com/fwlink/?LinkId=169433
-->
<configuration>
  <!--
    有关 web.config 更改的说明,请参见 http://go.microsoft.com/fwlink/?LinkId=235367。

    可在 <httpRuntime> 标记上设置以下特性。
      <system.Web>
        <httpRuntime targetFramework="4.8" />
      </system.Web>
  -->
  <system.web>
    <compilation debug="true" targetFramework="4.8" />
    <!-- 接口超时时间,1800秒=60*30(30分钟,配合报表下载),需要同步修改IIS的超时时间设置 -->
    <httpRuntime targetFramework="4.6.1" executionTimeout="1800"/>
  </system.web>
  <appSettings>

    <!-- U9报表SSO登录地址 -->
    <add key="U9LoginBaseUrl" value="http://localhost/U9/api/v1/ReportLogin.aspx?securetoken=2FFEAB291D1E7EAF76E94EBE93249C9D" />
    <!-- U9报表前缀地址 -->
    <add key="ReportBaseUrl" value="http://localhost/U9/erp/display.aspx" />
    <!-- p参数AES解密的密钥跟向量 -->
    <add key="EncryptionKey" value="106FE9250C1F5FC4A5BAB670EB663AF0" />
    <add key="EncryptionIV" value="0C1F5FC4A5BAB670" />
    <!-- Puppeteer程序下载报表超时时间,默认30分钟(预防首次报表访问冷启动等待时间长) -->
    <add key="PuppeteerTimeoutMinutes" value="30" />
    <!-- DownloadReport接口超时时间,60秒 -->
    <add key="ExpirationTimeSeconds" value="60"/>
    
    <!-- U9报表类型对应的link地址 -->
    <!-- 总账-总账 -->
    <add key="FI_GL_Process_Rpt_GeneralLedgerRpt" value="lnk=FI.GL.Process.Rpt.GeneralLedgerRpt&amp;sId=3002&amp;newopen=true" />
    <!-- 总账-明细账 -->
    <add key="FI_GL_Process_Rpt_DetailsRpt" value="lnk=FI.GL.Process.Rpt.DetailsRpt&amp;sId=3002&amp;newopen=true" />
    <!-- 总账-序时账 -->
    <add key="FI_GL_Process_Rpt_SequenceBookRpt" value="lnk=FI.GL.Process.Rpt.SequenceBookRpt&amp;sId=3002&amp;newopen=true" />
    <!-- 总账-发生额及余额表 -->
    <add key="FI_GL_Process_Rpt_TotalBalanceRpt" value="lnk=FI.GL.Process.Rpt.TotalBalanceRpt&amp;sId=3002&amp;newopen=true" />
    <!-- 总账-资产明细表 -->
    <add key="FA_SubsidiaryLedgerRptMainUIForm" value="lnk=EAM.FA.Report.FA_SubsidiaryLedgerRptMainUIForm&amp;sId=3009&amp;newopen=true" />

  </appSettings>
  <system.codedom>
    <compilers>
      <compiler language="c#;cs;csharp" extension=".cs" type="Microsoft.CodeDom.Providers.DotNetCompilerPlatform.CSharpCodeProvider, Microsoft.CodeDom.Providers.DotNetCompilerPlatform, Version=2.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" warningLevel="4" compilerOptions="/langversion:default /nowarn:1659;1699;1701" />
      <compiler language="vb;vbs;visualbasic;vbscript" extension=".vb" type="Microsoft.CodeDom.Providers.DotNetCompilerPlatform.VBCodeProvider, Microsoft.CodeDom.Providers.DotNetCompilerPlatform, Version=2.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" warningLevel="4" compilerOptions="/langversion:default /nowarn:41008 /define:_MYTYPE=\&quot;Web\&quot; /optionInfer+" />
    </compilers>
  </system.codedom>
  <runtime>
    <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
      <dependentAssembly>
        <assemblyIdentity name="System.Web.Helpers" publicKeyToken="31bf3856ad364e35" />
        <bindingRedirect oldVersion="1.0.0.0-3.0.0.0" newVersion="3.0.0.0" />
      </dependentAssembly>
      <dependentAssembly>
        <assemblyIdentity name="System.Web.WebPages" publicKeyToken="31bf3856ad364e35" />
        <bindingRedirect oldVersion="1.0.0.0-3.0.0.0" newVersion="3.0.0.0" />
      </dependentAssembly>
      <dependentAssembly>
        <assemblyIdentity name="System.Web.Mvc" publicKeyToken="31bf3856ad364e35" />
        <bindingRedirect oldVersion="1.0.0.0-5.3.0.0" newVersion="5.3.0.0" />
      </dependentAssembly>
      <dependentAssembly>
        <assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
        <bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0" />
      </dependentAssembly>
      <dependentAssembly>
        <assemblyIdentity name="System.Memory" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
        <bindingRedirect oldVersion="0.0.0.0-4.0.1.1" newVersion="4.0.1.1" />
      </dependentAssembly>
      <dependentAssembly>
        <assemblyIdentity name="Microsoft.Extensions.Primitives" publicKeyToken="adb9793829ddae60" culture="neutral" />
        <bindingRedirect oldVersion="0.0.0.0-2.2.0.0" newVersion="2.2.0.0" />
      </dependentAssembly>
      <dependentAssembly>
        <assemblyIdentity name="System.Buffers" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
        <bindingRedirect oldVersion="0.0.0.0-4.0.3.0" newVersion="4.0.3.0" />
      </dependentAssembly>
      <dependentAssembly>
        <assemblyIdentity name="System.Text.Encodings.Web" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
        <bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0" />
      </dependentAssembly>
      <dependentAssembly>
        <assemblyIdentity name="Microsoft.Bcl.AsyncInterfaces" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
        <bindingRedirect oldVersion="0.0.0.0-5.0.0.0" newVersion="5.0.0.0" />
      </dependentAssembly>
      <dependentAssembly>
        <assemblyIdentity name="System.Threading.Tasks.Extensions" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
        <bindingRedirect oldVersion="0.0.0.0-4.2.0.1" newVersion="4.2.0.1" />
      </dependentAssembly>
      <dependentAssembly>
        <assemblyIdentity name="System.Text.Json" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
        <bindingRedirect oldVersion="0.0.0.0-5.0.0.1" newVersion="5.0.0.1" />
      </dependentAssembly>
    </assemblyBinding>
  </runtime>
</configuration>

优化访问路由(附加)

通过增加Global.asax文件,自定义路由
从/DownloadReport.ashx?reporttype=FI_GL_Process_Rpt_DetailsRpt&......的形式改为/DownloadReport/FI_GL_Process_Rpt_DetailsRpt?......的形式
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Security;
using System.Web.SessionState;
using System.Web.Routing;      // 核心路由命名空间
using System.Web.Compilation; // 用于 BuildManager
using System.Web.UI;          // 用于 IRouteHandler
using System.Web.Mvc;

namespace SHAPIPost
{
    // 为 .ashx 文件创建一个自定义的路由处理器
    public class AshxRouteHandler : IRouteHandler
    {
        private readonly string _virtualPath;

        public AshxRouteHandler(string virtualPath)
        {
            _virtualPath = virtualPath;
        }

        public IHttpHandler GetHttpHandler(RequestContext requestContext)
        {
            // 使用 BuildManager 从虚拟路径动态创建处理程序实例
            return BuildManager.CreateInstanceFromVirtualPath(
                _virtualPath, typeof(IHttpHandler)) as IHttpHandler;
        }
    }

    public class Global : System.Web.HttpApplication
    {

        protected void Application_Start(object sender, EventArgs e)
        {
            // 在应用程序启动时运行的代码
            RegisterRoutes(RouteTable.Routes);
        }

        void RegisterRoutes(RouteCollection routes)
        {
            // 注册报表下载处理器路由
            routes.Add("DownloadReportHandlerRoute", new Route(
                "DownloadReport/{reporttype}", // 路由模板
                new AshxRouteHandler("~/DownloadReport.ashx") // 目标处理程序
            ));
        }

        protected void Session_Start(object sender, EventArgs e)
        {

        }

        protected void Application_BeginRequest(object sender, EventArgs e)
        {

        }

        protected void Application_AuthenticateRequest(object sender, EventArgs e)
        {

        }

        protected void Application_Error(object sender, EventArgs e)
        {

        }

        protected void Session_End(object sender, EventArgs e)
        {

        }

        protected void Application_End(object sender, EventArgs e)
        {

        }
    }
}

测试程序

使用控制台应用程序测试
我的一般处理程序是发布在8001端口的
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace 测试用例
{
    /// <summary>
    /// 带有超时设置的WebClient
    /// </summary>
    public class WebClientWithTimeout : WebClient
    {
        public int Timeout { get; set; }

        protected override WebRequest GetWebRequest(Uri address)
        {
            WebRequest wr = base.GetWebRequest(address);
            if (wr != null)
            {
                wr.Timeout = this.Timeout;
            }
            return wr;
        }
    }
    
    class Program
    {
        // 密钥和IV必须与 DownloadReport.ashx 中的完全相同
        private const string _encryptionKey = "106FE9250C1F5FC4A5BAB670EB663AF0";
        private const string _encryptionIV = "0C1F5FC4A5BAB670";
        private static readonly SemaphoreSlim _downloadLimiter = new SemaphoreSlim(50);
        private static readonly string BASE_URL = "http://localhost:8001";
        

        static void Main(string[] args)
        {
            string username = "demo";
            string enterpriseId = "666";
            string orgID = "1001008170100181"; // 101组织的ID
            string orgName = "XXXX公司";
            
            // 明细账
            string accPeriodStart = "2025-01";
            string accPeriodEnd = "2025-03";
            string filename = GetFileName(accPeriodStart, accPeriodEnd, orgName, "明细账");
            DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
            long timestamp = (long)(DateTime.UtcNow - epoch).TotalSeconds;

            string payload = string.Format("usercode={0}&entcode={1}&org={2}&timestamp={3}", username, enterpriseId, orgID, timestamp);
            string encryptedPayload = EncryptString(payload);

            string directDownloadUrl = $"{BASE_URL}/DownloadReport/FI_GL_Process_Rpt_DetailsRpt?p={encryptedPayload}&exporttype={exportType}&filename={filename}&taskID={taskID}&CaseName={caseName}&AccountingPeriodStart={accPeriodStart}&AccountingPeriodEnd={accPeriodEnd}";

            DownloadFile(exportType, filename, directDownloadUrl);
            
            // 总账、序时账...其他报表的测试用例
        
            Console.WriteLine("\n按任意键退出...");
            Console.ReadKey();
        }

        /// <summary>
        /// 同步下载文件的完整流程
        /// </summary>
        private static void DownloadFile(string exportType, string filename, string requestUrl)
        {
            _downloadLimiter.Wait(); // 等待一个空位
            try
            {
                Console.WriteLine($"\n[步骤 1] 请求生成报表: {filename}");
                string fileUrl = null;
                string filePathToDelete = null;

                using (var webClient = new WebClientWithTimeout())
                {
                    webClient.Timeout = 1800000; // 设置30分钟超时
                    webClient.Encoding = Encoding.UTF8;

                    // 1. 调用 DownloadReport.ashx 获取下载地址
                    try
                    {
                        string jsonResponse = webClient.DownloadString(requestUrl);
                        Console.WriteLine($"  - 服务器响应: {jsonResponse}");
                        var result = JsonConvert.DeserializeObject<JObject>(jsonResponse);
                        if (result.Value<bool>("IsSuccess"))
                        {
                            fileUrl = result.Value<string>("Url");
                            filePathToDelete = result.Value<string>("DelUrl");
                            Console.WriteLine($"  - 成功获取下载地址: {fileUrl}");
                        }
                        else
                        {
                            Console.WriteLine($"  - 错误: {result["Message"]}");
                            return;
                        }
                    }
                    catch (Exception ex)
                    {
                        Console.WriteLine($"  - [步骤 1] 失败: {ex.Message}");
                        return;
                    }

                    // 2. 从获取到的URL下载文件
                    if (!string.IsNullOrEmpty(fileUrl))
                    {
                        Console.WriteLine($"\n[步骤 2] 开始从 {fileUrl} 下载文件...");
                        try
                        {
                            string fileExtension = exportType.Equals("PDF", StringComparison.OrdinalIgnoreCase) ? ".pdf" : (exportType.Equals("CSV", StringComparison.OrdinalIgnoreCase) ? ".csv" : ".xlsx");
                            string fileNameWithExt = $"{filename}{fileExtension}";
                            string savePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, fileNameWithExt);

                            if (File.Exists(savePath)) File.Delete(savePath);

                            byte[] fileBytes = webClient.DownloadData(fileUrl);
                            File.WriteAllBytes(savePath, fileBytes);
                            Console.WriteLine($"  - 下载成功! 文件已保存至: {savePath} (大小: {fileBytes.Length} 字节)");
                        }
                        catch (Exception ex)
                        {
                            Console.WriteLine($"  - [步骤 2] 失败: {ex.Message}");
                        }
                    }

                    // 3. (可选) 删除服务器上的文件
                    //if (!string.IsNullOrEmpty(filePathToDelete))
                    //{
                    //    Console.WriteLine($"\n[步骤 3] 请求删除服务器文件: {filePathToDelete}");
                    //    try
                    //    {
                    //        using (var webClient = new System.Net.WebClient())
                    //        {
                    //            webClient.Encoding = System.Text.Encoding.UTF8;
                    //            webClient.DownloadString(filePathToDelete);
                    //        }
                    //    }
                    //    catch (Exception ex)
                    //    {
                    //        Console.WriteLine($"  - [步骤 3] 失败: {ex.Message}");
                    //    }
                    //}
                }
            }
            finally
            {
                _downloadLimiter.Release(); // 释放一个空位,让其他等待的请求可以进入
            }
        }

        private static string GetFileName(string accPeriodStart, string accPeriodEnd, string orgName, string v)
        {
            if (accPeriodStart == accPeriodEnd)
            {
                return $"{orgName} {accPeriodStart}{v}";
            }
            else
            {
                return $"{orgName} {accPeriodStart}至{accPeriodEnd}{v}";
            }
        }
        
        public static string EncryptString(string plainText)
        {
            using (Aes aesAlg = Aes.Create())
            {
                aesAlg.Key = Encoding.UTF8.GetBytes(_encryptionKey);
                aesAlg.IV = Encoding.UTF8.GetBytes(_encryptionIV);

                ICryptoTransform encryptor = aesAlg.CreateEncryptor(aesAlg.Key, aesAlg.IV);

                using (MemoryStream msEncrypt = new MemoryStream())
                {
                    using (CryptoStream csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write))
                    {
                        using (StreamWriter swEncrypt = new StreamWriter(csEncrypt))
                        {
                            swEncrypt.Write(plainText);
                        }
                        byte[] encrypted = msEncrypt.ToArray();
                        // 使用不依赖 System.Web 的方式生成URL安全的Base64字符串
                        // 这段代码的功能与 HttpServerUtility.UrlTokenEncode 相同
                        return Convert.ToBase64String(encrypted)
                            .Replace('+', '-')
                            .Replace('/', '_')
                            .TrimEnd('=');
                    }
                }
            }
        }
    }
}

测试结果

输出PDF把exportType=“PDF”即可;
可以看到上面我设置的默认方案是2025-06至2025-08的
输出的excel表已经是按照条件的2025-01至2025-03的

image.png

image.png

0

评论

博主关闭了当前页面的评论