C# WinForm/WPF 上位机 HTTP 文件上传流程
覆盖场景:单文件上传(CSV / 图片 / 压缩包)、多文件批量上传、多文件先压缩 ZIP 再上传、后端接收保存 + 解析文件,分客户端(C# 上位机) 、服务端(ASP.NET Core API) 两大部分,兼容 WinForm/WPF,支持大文件、进度回调、参数附带、异常处理。
一、整体技术选型
- 客户端:
HttpClient(推荐,线程安全,替代 WebClient),System.IO.Compression实现本地多文件打包 ZIP - 服务端:ASP.NET Core WebAPI,接收
IFormFile文件流 - 支持文件类型:
.csv/.jpg/.png/.bmp/.gif/.zip/.rar/.txt/.xlsx等任意二进制文件 - 功能点:
- 单文件直接 HTTP 上传
- 多选文件批量并行上传
- 本地打包多文件为临时 ZIP,仅上传一个压缩包(减少请求次数)
- 上传进度实时回调(UI 进度条)
- 携带自定义业务参数(设备编号、时间戳、工位号)
- 后端文件校验、自动创建目录、重命名防覆盖、解压服务端 ZIP
- 超时、断流、文件不存在、超大文件异常捕获
第一部分:C# 上位机客户端通用工具类(WinForm/WPF 通用)
新建工具类 HttpFileUploadHelper.cs,无需第三方 NuGet,纯.NET 原生。
1. 基础依赖引用
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using System.IO.Compression;
2. 进度回调委托(UI 更新进度条)
/// <summary>上传进度回调:当前字节、总字节</summary>
public delegate void UploadProgressDelegate(long currentBytes, long totalBytes);
public static class HttpFileUploadHelper
{
// 全局HttpClient静态实例,复用连接(禁止每次new HttpClient)
private static readonly HttpClient _httpClient = new HttpClient()
{
Timeout = TimeSpan.FromMinutes(5) // 大文件5分钟超时
};
#region 场景1:单文件直接上传(CSV/图片/压缩包通用)
/// <summary>
/// 单文件HTTP上传
/// </summary>
/// <param name="apiUrl">后端上传接口地址</param>
/// <param name="filePath">本地文件完整路径</param>
/// <param name="businessParams">自定义业务参数 如设备Id、工位</param>
/// <param name="progress">上传进度回调</param>
/// <param name="cancellationToken">取消上传</param>
/// <returns>后端返回的JSON字符串</returns>
public static async Task<string> UploadSingleFileAsync(
string apiUrl,
string filePath,
Dictionary<string, string> businessParams = null,
UploadProgressDelegate progress = null,
CancellationToken cancellationToken = default)
{
if (!File.Exists(filePath))
throw new FileNotFoundException("文件不存在", filePath);
var fileInfo = new FileInfo(filePath);
long totalLength = fileInfo.Length;
// 构造multipart/form-data表单
using var content = new MultipartFormDataContent();
// 1. 添加业务自定义参数
if (businessParams != null)
{
foreach (var kv in businessParams)
{
content.Add(new StringContent(kv.Value), kv.Key);
}
}
// 2. 创建带进度监听的文件流
using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
var progressStream = new ProgressStream(fileStream, progress, totalLength);
var streamContent = new StreamContent(progressStream);
// 设置文件MIME类型
string mimeType = GetMimeType(fileInfo.Extension);
streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse(mimeType);
content.Add(streamContent, "uploadFile", fileInfo.Name);
// 3. 发起POST请求
using var response = await _httpClient.PostAsync(apiUrl, content, cancellationToken);
response.EnsureSuccessStatusCode(); // 非200直接抛异常
return await response.Content.ReadAsStringAsync(cancellationToken);
}
#endregion
#region 场景2:多文件分别批量上传(多次HTTP请求)
/// <summary>批量逐个上传多个独立文件</summary>
public static async Task<List<string>> UploadMultiFilesSeparateAsync(
string apiUrl,
List<string> filePaths,
Dictionary<string, string> businessParams = null,
UploadProgressDelegate singleFileProgress = null,
CancellationToken cancellationToken = default)
{
var resultList = new List<string>();
foreach (var file in filePaths)
{
var res = await UploadSingleFileAsync(apiUrl, file, businessParams, singleFileProgress, cancellationToken);
resultList.Add(res);
}
return resultList;
}
#endregion
#region 场景3:多文件本地打包ZIP后只上传一个压缩包(推荐大量文件场景)
/// <summary>
/// 多个文件先压缩成临时ZIP,再上传压缩包,上传完成自动删除临时zip
/// </summary>
public static async Task<string> UploadFilesAsZipAsync(
string apiUrl,
List<string> sourceFilePaths,
string tempZipSavePath = null,
Dictionary<string, string> businessParams = null,
UploadProgressDelegate progress = null,
CancellationToken cancellationToken = default)
{
// 生成临时压缩包路径(不传则自动放系统临时目录)
if (string.IsNullOrEmpty(tempZipSavePath))
{
tempZipSavePath = Path.Combine(Path.GetTempPath(), $"upload_{Guid.NewGuid():N}.zip");
}
// 本地打包所有文件到zip
CreateZipFromFiles(sourceFilePaths, tempZipSavePath);
try
{
// 上传生成的zip压缩包
var result = await UploadSingleFileAsync(apiUrl, tempZipSavePath, businessParams, progress, cancellationToken);
return result;
}
finally
{
// 上传成功/失败都删除临时压缩文件
if (File.Exists(tempZipSavePath))
File.Delete(tempZipSavePath);
}
}
/// <summary>将多个本地文件打包成ZIP</summary>
private static void CreateZipFromFiles(List<string> fileList, string zipPath)
{
if (File.Exists(zipPath)) File.Delete(zipPath);
using var zipFile = ZipFile.Open(zipPath, ZipArchiveMode.Create);
foreach (var file in fileList)
{
if (!File.Exists(file)) continue;
var fileName = Path.GetFileName(file);
zipFile.CreateEntryFromFile(file, fileName, CompressionLevel.Optimal);
}
}
#endregion
#region 辅助工具:MIME类型映射 + 进度监听流
/// <summary>文件后缀对应MIME类型</summary>
private static string GetMimeType(string ext)
{
return ext.ToLower() switch
{
".csv" => "text/csv",
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".bmp" => "image/bmp",
".gif" => "image/gif",
".zip" => "application/zip",
".rar" => "application/x-rar-compressed",
".txt" => "text/plain",
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
_ => "application/octet-stream" // 未知二进制文件通用
};
}
/// <summary>包装文件流,读取时触发上传进度回调</summary>
private class ProgressStream : Stream
{
private readonly Stream _innerStream;
private readonly UploadProgressDelegate _progressCallback;
private readonly long _totalLength;
private long _readBytes;
public ProgressStream(Stream inner, UploadProgressDelegate callback, long totalLen)
{
_innerStream = inner;
_progressCallback = callback;
_totalLength = totalLen;
_readBytes = 0;
}
public override int Read(byte[] buffer, int offset, int count)
{
var read = _innerStream.Read(buffer, offset, count);
if (read > 0)
{
_readBytes += read;
_progressCallback?.Invoke(_readBytes, _totalLength);
}
return read;
}
// 下面简单转发所有Stream虚方法
public override bool CanRead => _innerStream.CanRead;
public override bool CanSeek => _innerStream.CanSeek;
public override bool CanWrite => _innerStream.CanWrite;
public override long Length => _innerStream.Length;
public override long Position { get; set; }
public override void Flush() => _innerStream.Flush();
public override long Seek(long offset, SeekOrigin origin) => _innerStream.Seek(offset, origin);
public override void SetLength(long value) => _innerStream.SetLength(value);
public override void Write(byte[] buffer, int offset, int count) => _innerStream.Write(buffer, offset, count);
protected override void Dispose(bool disposing) => _innerStream.Dispose();
}
#endregion
}
3. WinForm 上位机界面调用示例(带进度条)
界面控件:
-
textBox_ApiUrl:输入后端接口地址http://localhost:5000/api/FileUpload/Upload -
progressBar1:上传进度条 -
label_ProgressText:显示百分比 -
button_SelectSingle:选择单个文件上传 -
button_SelectMultiZip:多选文件打包 ZIP 上传using System.Windows.Forms;
private async void button_SelectSingle_Click(object sender, EventArgs e)
{
using var dialog = new OpenFileDialog
{
Filter = "所有文件|.|CSV文件|.csv|图片|.jpg;.png;.bmp|压缩包|.zip;.rar",
Multiselect = false
};
if (dialog.ShowDialog() != DialogResult.OK) return;string filePath = dialog.FileName; string api = textBox_ApiUrl.Text.Trim(); // 业务参数:上位机设备信息 var param = new Dictionary<string, string>() { {"DeviceId", "MACHINE_001"}, {"Station", "装配工位3"}, {"UploadTime", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")} }; // 进度回调(UI线程安全,Invoke更新控件) void ProgressUpdate(long curr, long total) { double percent = curr * 100.0 / total; this.Invoke(new Action(() => { progressBar1.Maximum = (int)total / 1024; // KB单位 progressBar1.Value = (int)curr / 1024; label_ProgressText.Text = $"已上传 {curr / 1024}KB / {total / 1024}KB {percent:F1}%"; })); } try { string result = await HttpFileUploadHelper.UploadSingleFileAsync(api, filePath, param, ProgressUpdate); MessageBox.Show($"上传成功!服务端返回:\n{result}"); } catch (Exception ex) { MessageBox.Show($"上传失败:{ex.Message}"); }}
// 多选文件打包ZIP上传
private async void button_SelectMultiZip_Click(object sender, EventArgs e)
{
using var dialog = new OpenFileDialog
{
Multiselect = true,
Filter = "所有文件|."
};
if (dialog.ShowDialog() != DialogResult.OK) return;List<string> files = dialog.FileNames.ToList(); string api = textBox_ApiUrl.Text.Trim(); var param = new Dictionary<string, string>() { {"DeviceId", "MACHINE_001"} }; void ProgressUpdate(long curr, long total) { double percent = curr * 100.0 / total; this.Invoke(new Action(() => { progressBar1.Maximum = (int)total / 1024; progressBar1.Value = (int)curr / 1024; label_ProgressText.Text = $"压缩包上传进度 {percent:F1}%"; })); } try { string res = await HttpFileUploadHelper.UploadFilesAsZipAsync(api, files, null, param, ProgressUpdate); MessageBox.Show($"打包上传完成:{res}"); } catch (Exception ex) { MessageBox.Show($"打包上传异常:{ex.Message}"); }}
4. WPF 适配说明
WPF 无需修改工具类,进度更新把 this.Invoke 替换成 Application.Current.Dispatcher.Invoke 即可。
第二部分:后端 ASP.NET Core API 接收文件处理(配套上位机)
1. 创建 FileUpload 控制器 FileUploadController.cs
支持:接收单文件、接收压缩包并自动解压、读取业务参数、文件存储、返回 JSON 结果
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.IO;
using System.IO.Compression;
namespace UploadServer.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class FileUploadController : ControllerBase
{
// 文件根存储目录
private readonly string _saveRoot = Path.Combine(AppContext.BaseDirectory, "UploadFiles");
public FileUploadController()
{
// 不存在自动创建目录
if (!Directory.Exists(_saveRoot))
Directory.CreateDirectory(_saveRoot);
}
/// <summary>统一上传接口:接收单文件/zip压缩包</summary>
[HttpPost("Upload")]
public IActionResult Upload([FromForm] UploadModel model)
{
// 1. 获取上位机传的业务参数
string deviceId = model.DeviceId ?? "UnknownDevice";
string station = model.Station ?? "Default";
string uploadTime = model.UploadTime ?? DateTime.Now.ToString("yyyyMMddHHmmss");
if (model.uploadFile == null || model.uploadFile.Length == 0)
return BadRequest(new { code = -1, msg = "未检测到上传文件" });
// 按设备+日期分目录存储
string dateDir = DateTime.Now.ToString("yyyy-MM-dd");
string targetDir = Path.Combine(_saveRoot, deviceId, dateDir);
Directory.CreateDirectory(targetDir);
// 原始文件名,重命名防止覆盖
string fileName = Path.GetFileName(model.uploadFile.FileName);
string saveFullPath = Path.Combine(targetDir, $"{uploadTime}_{fileName}");
// 保存文件到磁盘
using var stream = new FileStream(saveFullPath, FileMode.Create);
model.uploadFile.CopyTo(stream);
// 2. 如果是zip压缩包,自动解压到同目录unzip文件夹
if (Path.GetExtension(fileName).ToLower() == ".zip")
{
string unzipDir = Path.Combine(targetDir, $"unzip_{Path.GetFileNameWithoutExtension(fileName)}");
Directory.CreateDirectory(unzipDir);
ZipFile.ExtractToDirectory(saveFullPath, unzipDir, true); // true覆盖重名
}
// 3. 返回成功JSON给上位机
return Ok(new
{
code = 0,
msg = "文件接收处理完成",
data = new
{
DeviceId = deviceId,
SavePath = saveFullPath,
FileSize = model.uploadFile.Length,
IsZip = Path.GetExtension(fileName).ToLower() == ".zip",
UnzipFolder = Path.GetDirectoryName(saveFullPath) + $"/unzip_{Path.GetFileNameWithoutExtension(fileName)}"
}
});
}
}
/// <summary>接收表单参数绑定模型</summary>
public class UploadModel
{
public IFormFile uploadFile { get; set; }
// 上位机自定义业务参数
public string DeviceId { get; set; }
public string Station { get; set; }
public string UploadTime { get; set; }
}
}
2. 后端配置文件大小限制(Program.cs)
默认ASP.NET限制文件 30MB,上位机上传大图片 / 大 CSV / 大压缩包需要放开限制:
var builder = WebApplication.CreateBuilder(args);
// 放开文件上传最大尺寸 100MB
builder.Services.Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 100 * 1024 * 1024;
});
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run("http://localhost:5000");
第三部分:上位机高频业务场景总结与优化方案
场景 1:单 CSV / 单图片实时上传(巡检截图、报表)
直接调用 UploadSingleFileAsync,无需压缩,上传速度最快,适合单次少量文件。
场景 2:单次几十上百张图片 + 报表 CSV 批量上传
优先用本地打包 ZIP 上传 UploadFilesAsZipAsync 优势:
- 只发起 1 次 HTTP 请求,减少网络握手耗时
- 压缩后体积大幅减小(图片无损压缩、文本 CSV 压缩率极高)
- 后端自动解压,一次性拿到全部原始文件
- 自动清理本地临时压缩包,无垃圾文件残留
场景 3:定时循环采集文件,后台静默上传
封装异步后台任务,搭配 CancellationTokenSource 支持停止上传,UI 不卡死。
场景 4:超大文件(>500MB)断点续传扩展
现有代码是完整文件上传,超大文件建议分片上传:
- 上位机分割文件为 10MB 分片,携带分片索引、文件 MD5
- 后端接收分片缓存,全部接收完成合并完整文件
- 工具类可在现有基础上增加分片上传方法
第四部分:常见问题踩坑指南
-
HttpClient 频繁创建导致套接字泄漏 解决方案:工具类静态全局复用 HttpClient,不要按钮点击每次 new。
-
WinForm 跨线程更新进度条报错 必须使用
Invoke/BeginInvoke切 UI 主线程更新控件。 -
后端接收文件为空 IFormFile = null
- 前端表单字段名必须和后端
uploadFile完全一致(区分大小写) - 接口必须标记
[FromForm],不能用 JSON 传文件 - 检查 Content-Type 为
multipart/form-data
- 前端表单字段名必须和后端
-
文件上传超时
- 增大 HttpClient.Timeout
- 后端放开 MultipartBodyLengthLimit 限制
- 大文件优先压缩再上传
-
中文文件名乱码 ASP.NET Core 默认支持,无需额外配置;旧版 IIS 部署需修改 web.config 编码 UTF-8。
-
临时 ZIP 文件残留 工具类 finally 块自动删除,即使上传报错也会清理。
第五部分:扩展功能建议(工业上位机常用)
- 上传前计算文件 MD5,传给后端做文件完整性校验
- 增加上传失败自动重试机制(网络波动)
- 上传队列管理,避免多线程并发上传占用带宽
- 增加日志写入本地:上传成功 / 失败记录文件、时间、设备号
- HTTPS 加密传输(生产环境禁止 HTTP 明文)
- 接口增加 Token 身份校验,防止非法设备上传文件