You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Elixir生成PDF后云端上传失败及Plug.Conn.NotSentError问题排查

问题描述

本地已成功生成PDF文件,但调用store函数上传时触发「Error uploading file」错误,同时出现以下报错:

[error] GenServer #PID<0.143080.0> terminating ** (Plug.Conn.NotSentError) a response was neither set nor sent from the connection

项目中XLSX文件的上传及下载提示功能运行正常,仅PDF模块存在该问题。


相关代码实现

PDF处理函数

defp handle_export_pdf(template_name, report_name, data) do
    filename = "#{report_name}.pdf"

    tmp_dir = "#{System.tmp_dir()}"
    dir_path = "#{tmp_dir}/#{filename}"

    with {:create_dir, :ok} <- {:create_dir, File.mkdir_p!(tmp_dir)},
         {:write_file, :ok} <-
           {:write_file, generate_pdf(data, report_name, dir_path)},
         {:store_on_cloud, {:ok, file}} <-
           {:store_on_cloud, HandOffFileUploader.store({dir_path, filename})},
         {:delete_file, :ok} <- {:delete_file, File.rm(dir_path)} do
      {:ok, file}
    else
      {:create_dir, _error} ->
        {:error, "Error creating directory"}

      {:write_file, _error} ->
        {:error, "Error writing data to file"}

      {:store_on_cloud, _error} ->
        {:error, "Error uploading file"}

      {:delete_file, _error} ->
        {:error, "Error deleting file"}
    end
  end

PDF生成函数

def generate_pdf(data, title, path) do
    html =
      Sneeze.render([
        :html,
        [
          :body,
          %{
            style:
              style(%{
                "font-family" => "Helvetica",
                "font-size" => "20pt"
              })
          },
          render_header(title),
          render_table(data)
        ]
      ])

    {:ok, filename} = PdfGenerator.generate(html, page_size: "A3", shell_params: ["--dpi", "300"])

    File.rename(filename, path)

    :ok
  end

完整控制器代码

defmodule EpmsWeb.ReportExportController do
  use EpmsWeb, :controller

  alias Epms.Delivery
  alias Epms.Assets
  alias Epms.Repo

  alias Epms.HandOffFileUploader
  alias Elixlsx.Workbook
  alias Elixlsx.Sheet

  @report_names %{
    "a" => "Title A",
    "b" => "Title B",
    "c" => "Title C",
  }

  def export_report(conn, %{
        "file_type" => file_type,
        "frequency_type" => frequency_type,
        "from_date" => from_date,
        "to_date" => to_date,
        "template_name" => template_name
      }) do
    report_name = @report_names[template_name]

    assigns = fetch_report_data(conn, template_name, file_type)

    filename_result =
      case file_type do
        "xlsx" ->
          handle_export_xlsx(template_name, report_name, assigns)

        "pdf" ->
          handle_export_pdf(template_name, report_name, assigns)

        _ ->
          {:error, :unsupported_file_type}
      end

    case filename_result do
      {:ok, filename} ->
        conn
        |> redirect(external: HandOffFileUploader.url(filename, signed: true))
        |> halt()

      _ ->
        conn
        |> put_flash(:error, "Unsupported file type or error in generating report.")
        |> halt()
    end
  end

  defp fetch_report_data(conn, template_name, file_type) do
    case {template_name, file_type} do

      {"a", "xlsx"} ->
        Epms.ExportsHelper.a.get_rows(conn)

      {"b", "xlsx"} ->
        Epms.ExportsHelper.b.get_rows(conn)

      {"c", "xlsx"} ->
        Epms.ExportsHelper.c.get_rows(conn)
      {"a", "pdf"} ->
        Epms.ExportsHelper.a.get_rows(conn)

      {"b", "pdf"} ->
        Epms.ExportsHelper.b.get_rows(conn)

      {"c", "pdf"} ->
        Epms.ExportsHelper.c.get_rows(conn)

      # Add more cases for different file types as needed
      _ ->
        %{}
    end
  end

  defp handle_export_xlsx(template_name, report_name, data) do
    # Use the filename_for function to generate a filename
    filename = "#{report_name}.xlsx"

    tmp_dir = "#{System.tmp_dir()}"
    dir_path = "#{tmp_dir}/#{filename}"

    with {:create_dir, :ok} <- {:create_dir, File.mkdir_p!(tmp_dir)},
         {:write_file, :ok} <-
           {:write_file, write_data_to_file(template_name, dir_path, data)},
         {:store_on_cloud, {:ok, file}} <-
           {:store_on_cloud, HandOffFileUploader.store({dir_path, filename})},
         {:delete_file, :ok} <- {:delete_file, File.rm(dir_path)} do
      {:ok, file}
    else
      {:create_dir, _error} ->
        {:error, "Error creating directory"}

      {:write_file, _error} ->
        {:error, "Error writing data to file"}

      {:store_on_cloud, _error} ->
        {:error, "Error uploading file"}

      {:delete_file, _error} ->
        {:error, "Error deleting file"}
    end
  end

  defp write_data_to_file(report_template, path, data) do
    report_codes = Enum.map(@report_names, fn {code, _name} -> code end)

    if report_template in report_codes do
      write_xlsx(path, data)
    end
  end

  defp handle_export_pdf(template_name, report_name, data) do
    filename = "#{report_name}.pdf"

    tmp_dir = "#{System.tmp_dir()}"
    dir_path = "#{tmp_dir}/#{filename}"

    with {:create_dir, :ok} <- {:create_dir, File.mkdir_p!(tmp_dir)},
         {:write_file, :ok} <-
           {:write_file, generate_pdf(data, report_name, dir_path)},
         {:store_on_cloud, {:ok, file}} <-
           {:store_on_cloud, HandOffFileUploader.store({dir_path, filename})},
         {:delete_file, :ok} <- {:delete_file, File.rm(dir_path)} do
      {:ok, file}
    else
      {:create_dir, _error} ->
        {:error, "Error creating directory"}

      {:write_file, _error} ->
        {:error, "Error writing data to file"}

      {:store_on_cloud, _error} ->
        {:error, "Error uploading file"}

      {:delete_file, _error} ->
        {:error, "Error deleting file"}
    end
  end

  defp write_xlsx(path, rows) do
    # Assuming `rows` is a list of lists, where each inner list represents a row in the sheet
    max_columns = Enum.max_by(rows, &length/1) |> length
    # Generate a map with the same width for all columns
    uniform_col_widths = 1..max_columns |> Enum.map(&{&1, 20}) |> Enum.into(%{})

    # Write the xlsx file
    sheet1 = %Sheet{name: "Export", rows: rows, col_widths: uniform_col_widths}
    workbook = %Workbook{sheets: [sheet1]}

    workbook
    |> Elixlsx.write_to(path)

    :ok
  end

  defp format_date_to_string(date) when is_nil(date), do: ""
  defp format_date_to_string(%Date{} = date), do: Date.to_string(date)




  # PDF GENERATION

  def generate_pdf(data, title, path) do
    html =
      Sneeze.render([
        :html,
        [
          :body,
          %{
            style:
              style(%{
                "font-family" => "Helvetica",
                "font-size" => "20pt"
              })
          },
          render_header(title),
          render_table(data)
        ]
      ])

    {:ok, filename} = PdfGenerator.generate(html, page_size: "A3", shell_params: ["--dpi", "300"])

    File.rename(filename, path)

    :ok
  end

  defp style(style_map) do
    style_map
    |> Enum.map(fn {key, value} ->
      "#{key}: #{value}"
    end)
    |> Enum.join(";")
  end

  defp render_header(title) do

    date = DateTime.utc_now()
    date_string = "#{date.year}/#{date.month}/#{date.day}"

    [
      :div,
      %{
        style:
          style(%{
            "display" => "flex",
            "flex-direction" => "column",
            "align-items" => "flex-start",
          })
      },
      [
        :div,
        %{
          style:
            style(%{
              "display" => "inline-block",
              "margin-top" => "10pt"
            })
        },
        [
          :h1,
          %{
            style:
              style(%{
                "font-size" => "16pt",
                "margin-top" => "0pt",
                "padding-top" => "0pt"
              })
          },
          title
        ],
        [
          :h3,
          %{
            style:
              style(%{
                "font-size" => "14pt",
              })
          },
          date_string
        ]
      ]
    ]
  end

  defp render_table(data) do
    table = [
      :table,
      %{
        style:
          style(%{
            "border" => "1px solid black",
            "border-collapse" => "collapse",
            "width" => "100%"
          })
      },
  ]
    rows = Enum.map(data, &render_row/1)
    table ++ rows
  end

  defp render_row(data) do
    row = [
      :tr,
      %{
        style:
          style(%{
            "border" => "1px solid black",
            "border-collapse" => "collapse"
          })
      },
    ]

    items = Enum.map(data, &render_items/1)
    row ++ items
  end

  defp render_items(item) do
    [
      :td,
      %{
        style:
          style(%{
            "border" => "1px solid black",
            "border-collapse" => "collapse",
            "padding" => "5pt"
          })
      },
      [
        :span,
        %{
          style:
            style(%{
              "font-size" => "12pt",
              "margin-top" => "0pt",
              "padding-top" => "0pt"
            })
        },
        item
      ],
    ]
  end
end

问题排查与解决

1. 核心问题定位

Plug.Conn.NotSentError说明控制器未正确返回响应,结合PDF上传失败的情况,主要原因有两个:

  • PDF生成/上传流程中抛出未捕获的异常,导致流程中断,无法走到响应返回逻辑
  • generate_pdf函数存在隐性错误,虽然本地文件看似生成成功,但实际有异常被忽略

2. 具体修复步骤

步骤1:修复generate_pdf的错误处理

当前generate_pdf中File.rename可能失败,但直接返回:ok,会导致with语句误以为文件写入成功,后续上传时文件可能不存在或损坏。修改为:

def generate_pdf(data, title, path) do
    html =
      Sneeze.render([
        :html,
        [
          :body,
          %{
            style:
              style(%{
                "font-family" => "Helvetica",
                "font-size" => "20pt"
              })
          },
          render_header(title),
          render_table(data)
        ]
      ])

    with {:ok, filename} <- PdfGenerator.generate(html, page_size: "A3", shell_params: ["--dpi", "300"]),
         :ok <- File.rename(filename, path) do
      :ok
    else
      error -> error
    end
  end

这样重命名失败时,错误会传递到handle_export_pdf的write_file阶段,返回明确的错误信息。

步骤2:添加文件存在性检查与错误详情

在handle_export_pdf中增加文件存在校验,并打印错误详情,明确问题环节:

defp handle_export_pdf(template_name, report_name, data) do
    filename = "#{report_name}.pdf"

    tmp_dir = "#{System.tmp_dir()}"
    dir_path = "#{tmp_dir}/#{filename}"

    with {:create_dir, :ok} <- {:create_dir, File.mkdir_p!(tmp_dir)},
         {:write_file, :ok} <- {:write_file, generate_pdf(data, report_name, dir_path)},
         {:file_exists, true} <- {:file_exists, File.exists?(dir_path)},
         {:store_on_cloud, {:ok, file}} <- {:store_on_cloud, HandOffFileUploader.store({dir_path, filename})},
         {:delete_file, :ok} <- {:delete_file, File.rm(dir_path)} do
      {:ok, file}
    else
      {:create_dir, _error} -> {:error, "Error creating directory"}
      {:write_file, error} -> {:error, "Error writing data to file: #{inspect(error)}"}
      {:file_exists, false} -> {:error, "Generated PDF file not found at #{dir_path}"}
      {:store_on_cloud, error} -> {:error, "Error uploading file: #{inspect(error)}"}
      {:delete_file, _error} -> {:error, "Error deleting file"}
    end
  end

步骤3:捕获控制器中的异常

在export_report中添加全局异常捕获,避免因未处理的异常导致NotSentError:

def export_report(conn, params) do
    try do
      %{
        "file_type" => file_type,
        "frequency_type" => frequency_type,
        "from_date" => from_date,
        "to_date" => to_date,
        "template_name" => template_name
      } = params

      report_name = @report_names[template_name]
      assigns = fetch_report_data(conn, template_name, file_type)

      filename_result =
        case file_type do
          "xlsx" -> handle_export_xlsx(template_name, report_name, assigns)
          "pdf" -> handle_export_pdf(template_name, report_name, assigns)
          _ -> {:error, :unsupported_file_type}
        end

      case filename_result do
        {:ok, filename} ->
          conn
          |> redirect(external: HandOffFileUploader.url(filename, signed: true))
          |> halt()

        _ ->
          conn
          |> put_flash(:error, "Unsupported file type or error in generating report.")
          |> halt()
      end
    rescue
      e ->
        conn
        |> put_flash(:error, "Report generation failed: #{inspect(e)}")
        |> halt()
    end
  end

步骤4:检查上传器的MIME类型支持

确认HandOffFileUploader是否配置了PDF的MIME类型(application/pdf),XLSX能正常上传说明上传器已支持对应类型,需确保PDF类型未被拦截。

3. 验证方法

修改代码后重新测试:

  • 如果返回「Generated PDF file not found」,检查临时目录权限或路径是否正确
  • 如果返回上传错误详情,根据信息调整上传器配置
  • 如果不再出现NotSentError,说明异常已被正确捕获处理

内容的提问来源于stack exchange,提问作者Jonathan Talbot

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.21 15:57:02