PAYATHON 2026

使用 axios 提交退款表单时出现 server error

支付阿杰

明确结论

问题出在前端提交的数据与请求头不匹配:

  1. 图片保存在 refusjonBilde 中,提交时使用的却是不存在的 refusjonData.image。
  2. 使用 FormData 上传文件时,不能将 Content-Type 设置为 application/json。
  3. “Kontonummer” 输入框的 name 被误写成 amount,所以 kontonm 始终为空。
  4. 后端捕获异常后仍返回 HTTP 200,Axios 因此会进入 .then(),前端也就显示了成功提示。
  5. 如果后端的 APIView 和数据模型都叫 Refusjon,那么 Refusjon(image=...) 可能实例化了当前 APIView,而不是数据模型。

Postman 能正常调用,不代表浏览器提交了完全相同的数据。需要对比两边的请求头、上传字段名和图片字段的实际内容。

修正前端图片处理

当前代码:

if ([e.target.name] == "image") {
  setRefusjonBilde({
    image: e.target.files,
  });
}

这段代码保存的是一个包含 FileList 的对象,但提交时使用的是:

formData.append("image", refusjonData.image);

initialFormData 中没有 image,所以这里提交的通常是 undefined,不是用户选择的文件。

可以直接保存第一个文件:

const handleChange = (e) => {
  const { name, value, files } = e.target;

  if (name === "image") {
    setRefusjonBilde(files?.[0] ?? null);
    return;
  }

  updateFormData((current) => ({
    ...current,
    [name]: value,
  }));
};

提交时使用 refusjonBilde:

if (refusjonBilde) {
  formData.append("image", refusjonBilde);
}

e.target.files 是 FileList。上传单个文件时,应使用 e.target.files[0]。

不要为 FormData 设置 application/json

当前配置将请求正文声明为 JSON:

const config = {
  headers: {
    Authorization: `Bearer ${localStorage.getItem("token")}`,
    "Content-Type": "application/json",
  },
};

实际发送的却是 FormData。浏览器需要自动生成类似下面的请求头,并附上必要的 boundary:

Content-Type: multipart/form-data; boundary=...

因此,不要手动设置 Content-Type:

const config = {
  headers: {
    Authorization: `Bearer ${localStorage.getItem("token")}`,
  },
};

Axios 和浏览器会根据 FormData 自动设置正确的 multipart 请求头。

修正 kontonm 字段名称

“Kontonummer” 输入框目前是:

<input
  type="text"
  onChange={handleChange}
  name="amount"
  maxLength="40"
  required
/>

这个输入框会覆盖 amount,不会更新 kontonm。应改为:

<input
  type="text"
  className="form-control col-10 m-2"
  onChange={handleChange}
  name="kontonm"
  maxLength="40"
  required
/>

否则,后端执行下面的代码时,得到的可能是空字符串:

data["kontonm"]

推荐的前端提交代码

提交事件更适合放在 <form> 的 onSubmit 上,而不是按钮的 onClick 上:

<form onSubmit={onSubmit}>
  {/* 表单字段 */}

  <button
    className="btn btn-primary m-2"
    type="submit"
    disabled={loading}
  >
    Send
  </button>
</form>

完整的提交逻辑可以写成:

const onSubmit = async (e) => {
  e.preventDefault();

  if (!refusjonBilde) {
    setAlert("Velg et bilde", "danger");
    return;
  }

  setLoading(true);

  const formData = new FormData();
  formData.append("name", refusjonData.name);
  formData.append("email", refusjonData.email);
  formData.append("address", refusjonData.address);
  formData.append("subject", "Refusjon");
  formData.append("amount", refusjonData.amount);
  formData.append("date", refusjonData.date);
  formData.append("image", refusjonBilde);
  formData.append("message", refusjonData.message);
  formData.append("kontonm", refusjonData.kontonm);

  try {
    const response = await axios.post(
      `${process.env.REACT_APP_API_URL}/api/skjema/`,
      formData,
      {
        headers: {
          Authorization: `Bearer ${localStorage.getItem("token")}`,
        },
      }
    );

    console.log(response.data);
    setAlert("Skjemaet ble sendt", "success");
    window.scrollTo(0, 0);
  } catch (error) {
    console.error(
      "Refund request failed:",
      error.response?.status,
      error.response?.data ?? error.message
    );

    setAlert("Skjema ble ikke sendt! Prøv igjen", "danger");
    window.scrollTo(0, 0);
  } finally {
    setLoading(false);
  }
};

调试时,可以检查实际提交的内容:

for (const [key, value] of formData.entries()) {
  console.log(key, value);
}

此时,image 应显示为 File 对象,而不是 undefined。

为什么后端报错却触发 .then()

后端无论成功还是失败,都使用默认的 HTTP 200:

except:
    return Response({
        "error": "Skjemaet kunne ikke sendes! Prøv igjen."
    })

Axios 根据 HTTP 状态码决定进入 .then() 还是 .catch()。即使响应正文中包含 "error",Axios 也不会自动将其视为失败。

后端应返回正确的错误状态码,例如:

from rest_framework import status
from rest_framework.response import Response

return Response(
    {"error": "Skjemaet kunne ikke sendes! Prøv igjen."},
    status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

如果是输入数据缺失或格式错误,返回 400 Bad Request 更合适。

修正后端文件读取和错误处理

如果当前 APIView 的类名也是 Refusjon,下面的代码有明显风险:

class Refusjon(APIView):
    # ...

    ri = Refusjon(image=data["image"])

这里的 Refusjon 会指向当前 APIView,而不是同名的数据模型。实例化 APIView 时传入 image,很可能直接抛出 TypeError。

这里只需要读取上传的文件,不必创建临时模型对象:

uploaded_image = request.FILES.get("image")

视图可以改成下面的结构:

import logging

from django.core.mail import EmailMessage
from email.mime.image import MIMEImage
from rest_framework import status
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from rest_framework.views import APIView

logger = logging.getLogger(__name__)


class RefusjonView(APIView):
    parser_classes = [MultiPartParser, FormParser]

    def post(self, request, format=None):
        data = request.data
        uploaded_image = request.FILES.get("image")

        required_fields = [
            "name",
            "email",
            "address",
            "amount",
            "date",
            "kontonm",
            "message",
        ]

        missing_fields = [
            field for field in required_fields if not data.get(field)
        ]

        if uploaded_image is None:
            missing_fields.append("image")

        if missing_fields:
            return Response(
                {
                    "error": "Missing required fields",
                    "fields": missing_fields,
                },
                status=status.HTTP_400_BAD_REQUEST,
            )

        try:
            body = "\n".join([
                f"navn: {data['name']}",
                f"epost: {data['email']}",
                f"adresse: {data['address']}",
                f"Sum: {data['amount']}",
                f"dato: {data['date']}",
                f"konto nummer: {data['kontonm']}",
                f"melding: {data['message']}",
            ])

            msg = EmailMessage(
                subject="Refusjon",
                body=body,
                from_email="info@gmail.com",
                to=["admin@gmail.com"],
            )

            image_subtype = uploaded_image.name.rsplit(".", 1)[-1].lower()
            mime_image = MIMEImage(
                uploaded_image.read(),
                _subtype=image_subtype,
            )
            mime_image.add_header("Content-ID", "<image>")
            msg.attach(mime_image)

            sent_count = msg.send(fail_silently=False)

            if sent_count != 1:
                raise RuntimeError("Email backend did not send the message")

            return Response(
                {"success": "Skjemaet ble sendt"},
                status=status.HTTP_200_OK,
            )

        except Exception:
            logger.exception("Failed to send refund email")

            return Response(
                {"error": "Skjemaet kunne ikke sendes! Prøv igjen."},
                status=status.HTTP_500_INTERNAL_SERVER_ERROR,
            )

其他注意事项

不要使用空的 except:。它会隐藏真正的错误,让图片缺失、字段拼写错误和邮件服务器故障看起来毫无区别。至少要记录异常堆栈:

except Exception:
    logger.exception("Failed to send refund email")

另外,代码虽然创建了 serializer,却没有验证或使用它:

serializer = RefusjonSerializer(data=request.data)

如果已经定义了 serializer,应调用:

serializer.is_valid(raise_exception=True)
data = serializer.validated_data

这样一来,缺少 image、kontonm 或其他字段时,Django REST Framework 会直接返回清晰的 400 响应,不必等到构造邮件时才触发异常。

需要优先修复四处问题:上传 refusjonBilde 中的 File,移除错误的 JSON 请求头,将账户输入框改为 name="kontonm",并让后端在发生异常时返回非 2xx 状态码。

备注:内容仅供参考。