diff --git a/CloudBuilder.Topshelf/Lib/CloudBuilder.Core.dll b/CloudBuilder.Topshelf/Lib/CloudBuilder.Core.dll
index 3c674f2..fe79891 100644
Binary files a/CloudBuilder.Topshelf/Lib/CloudBuilder.Core.dll and b/CloudBuilder.Topshelf/Lib/CloudBuilder.Core.dll differ
diff --git a/CloudBuilder/CloudBuilder.Core/DatabaseAccessor/UnitOfWorkAttribute.cs b/CloudBuilder/CloudBuilder.Core/DatabaseAccessor/UnitOfWorkAttribute.cs
index dde1348..fd35910 100644
--- a/CloudBuilder/CloudBuilder.Core/DatabaseAccessor/UnitOfWorkAttribute.cs
+++ b/CloudBuilder/CloudBuilder.Core/DatabaseAccessor/UnitOfWorkAttribute.cs
@@ -24,41 +24,57 @@ namespace CloudBuilder.Core.DatabaseAccessor
[检查异常]
//Controller对象不抛出异常,返回的是异常对象ValidatedException、ServiceErrorException(方便前端信息统一处理)
[提交或回滚事务]
+
+
+ 注意(并发问题修复):
+ 1) ActionFilter 的实例是「每个 Action 一份、被所有并发请求共用」的,不是每请求一个。
+ 原来把 TransactionScope 存在实例字段 _transactionScope 上,两个请求重叠时:
+ A 请求刚写下自己的 scope,B 请求立刻把它覆盖成自己的 scope(在另一个线程上创建),
+ A 请求结束时 Dispose 的其实是 B 的 scope,于是抛
+ "A TransactionScope must be disposed on the same thread that it was created."
+ 而且还会把别人的事务提前提交/回滚。现在改为按请求存放在 HttpContext.Items 里。
+ 2) 创建时必须带 TransactionScopeAsyncFlowOption.Enabled,否则事务与创建线程强绑定,
+ Action 里 await 之后续体落到别的线程池线程,Dispose 同样会抛上面那条异常。
+ 提交/回滚的判断逻辑(ValidatedException / ServiceErrorException → 回滚)保持原样未变。
*/
[AttributeUsage(AttributeTargets.Method)]
public class UnitOfWorkAttribute : ActionFilterAttribute
{
- private TransactionScope _transactionScope;
+ ///
+ /// 事务对象在 HttpContext.Items 里的键。
+ /// 用 HttpContext.Items 而不是实例字段:过滤器实例被所有并发请求共用,
+ /// 实例字段会让并发请求互相覆盖(详见类注释)。
+ ///
+ private const string ScopeKey = "__UnitOfWork_TransactionScope__";
public override void OnActionExecuting(ActionExecutingContext context)
{
- _transactionScope = new TransactionScope(TransactionScopeOption.RequiresNew,
+ // 同一请求内重复进入(嵌套)时复用外层事务,不重复开启
+ if (context.HttpContext.Items.ContainsKey(ScopeKey))
+ {
+ base.OnActionExecuting(context);
+ return;
+ }
+
+ // TransactionScopeAsyncFlowOption.Enabled:允许事务随 async/线程流转,
+ // 否则 Dispose 可能发生在另一个线程上而抛异常
+ var scope = new TransactionScope(TransactionScopeOption.RequiresNew,
new TransactionOptions
{
IsolationLevel = IsolationLevel.ReadCommitted,
Timeout = TransactionManager.MaximumTimeout
- });
+ },
+ TransactionScopeAsyncFlowOption.Enabled);
+
+ context.HttpContext.Items[ScopeKey] = scope;
base.OnActionExecuting(context);
}
- //public override void OnActionExecuting(ActionExecutingContext context)
- //{
- // // 正确:单机数据库事务(不是分布式)
- // _transactionScope = new TransactionScope(
- // TransactionScopeOption.Required,
- // new TransactionOptions
- // {
- // IsolationLevel = IsolationLevel.ReadCommitted
- // },
- // TransactionScopeAsyncFlowOption.Enabled); // 必须加这个
-
- // base.OnActionExecuting(context);
- //}
-
public override void OnActionExecuted(ActionExecutedContext context)
{
- if (_transactionScope == null)
+ // 取不到说明本请求没有开启(或已处理),直接放行
+ if (context.HttpContext.Items[ScopeKey] is not TransactionScope transactionScope)
{
base.OnActionExecuted(context);
return;
@@ -89,13 +105,14 @@ namespace CloudBuilder.Core.DatabaseAccessor
if (!needRollback)
{
//必须要有 SaveChanges() 调用,否则数据只会停留在内存中,不会真正保存到数据库。
- _transactionScope.Complete();
+ transactionScope.Complete();
}
}
finally
{
// 不 Complete() 就是自动回滚
- _transactionScope.Dispose();
+ transactionScope.Dispose();
+ context.HttpContext.Items.Remove(ScopeKey);
}
base.OnActionExecuted(context);
diff --git a/CodeGenerator/Lib/CloudBuilder.Core.dll b/CodeGenerator/Lib/CloudBuilder.Core.dll
index 6212e7a..fe79891 100644
Binary files a/CodeGenerator/Lib/CloudBuilder.Core.dll and b/CodeGenerator/Lib/CloudBuilder.Core.dll differ
diff --git a/WebVue/src/components/basic/FileUpload.vue b/WebVue/src/components/basic/FileUpload.vue
index 7859fdf..4d20bad 100644
--- a/WebVue/src/components/basic/FileUpload.vue
+++ b/WebVue/src/components/basic/FileUpload.vue
@@ -71,7 +71,10 @@ const props = withDefaults(
showButton?: boolean;
/** 默认上传按钮文案 */
buttonText?: string;
- /** 分片大小(字节) */
+ /**
+ * 分片大小(字节)。**不传时按网络自动**:
+ * 内网(localhost/IP,走并发)3MB、公网(域名,走串行)1MB。
+ */
chunkSize?: number;
/** 请求超时时间(毫秒) */
timeout?: number;
@@ -79,6 +82,13 @@ const props = withDefaults(
retryCount?: number;
/** 重试的初始等待毫秒数,之后按 1x、2x、4x 递增 */
retryDelay?: number;
+ /**
+ * 是否并发上传分片(最后一片仍然串行,见下方说明)。
+ * 不传时自动判断:访问地址是 localhost / IP(内网)就并发,域名(公网)就串行。
+ */
+ parallel?: boolean;
+ /** 并发上传时的并行数,默认 4 */
+ concurrency?: number;
}>(),
{
action: "",
@@ -89,10 +99,13 @@ const props = withDefaults(
disabled: false,
showButton: false,
buttonText: "上传文件",
- chunkSize: 1024 * 1024,
+ // 不传时由 resolveChunkSize() 按内网/公网自动决定(内网 3MB / 公网 1MB)
+ chunkSize: undefined,
timeout: 60000,
retryCount: 3,
retryDelay: 1000,
+ parallel: undefined,
+ concurrency: 4,
}
);
@@ -134,7 +147,7 @@ const resolvePath = async (): Promise => {
/** 文件分片 */
const splitFile = (file: File): Blob[] => {
const chunks: Blob[] = [];
- const size = props.chunkSize > 0 ? props.chunkSize : 1024 * 1024;
+ const size = resolveChunkSize();
let start = 0;
while (start < file.size) {
const end = Math.min(start + size, file.size);
@@ -157,7 +170,7 @@ const buildChunkName = (
return `${uid}-${index}-${total}${fileExtension ? "." + fileExtension : ""}`;
};
-/** 分片上传(顺序上传) */
+/** 分片上传 */
/** 计算进度需要的上下文:多文件时要能算到「整体」而不是单个文件内 */
interface ChunkProgress {
/** 当前第几个文件,从 1 开始 */
@@ -170,8 +183,8 @@ interface ChunkProgress {
totalBytes: number;
/** 当前文件的大小 */
fileSize: number;
- /** 每成功一片回调,用来记续传位置 */
- onChunkDone?: (index: number) => void;
+ /** 已完成的分片序号(从 1 开始)。并发上传时顺序是乱的,用它记账、也用它续传 */
+ done: Set;
}
/** 等待指定毫秒 */
@@ -229,7 +242,33 @@ const requestChunk = async (
throw lastError;
};
-const uploadChunk = async (
+/** 按已完成分片数估算整体百分比(按字节算,多文件时不会每个文件退回 0) */
+const calcPercent = (progress: ChunkProgress, total: number) => {
+ const chunkSize = resolveChunkSize();
+ if (progress.totalBytes > 0) {
+ const sent =
+ progress.baseBytes + Math.min(progress.done.size * chunkSize, progress.fileSize);
+ return Math.min(100, Math.round((sent / progress.totalBytes) * 100));
+ }
+ return total > 0 ? Math.round((progress.done.size / total) * 100) : 0;
+};
+
+/** 上报进度:单文件、多文件统一用百分比 */
+const emitProgress = (progress: ChunkProgress, total: number) => {
+ const percent = calcPercent(progress, total);
+ emit("progress", {
+ percent,
+ current: progress.done.size,
+ total,
+ text:
+ progress.fileCount > 1
+ ? `上传中(第 ${progress.fileNo}/${progress.fileCount} 个) ${percent}%`
+ : `上传中 ${percent}%`,
+ });
+};
+
+/** 传一片并记账 */
+const sendChunk = async (
chunks: Blob[],
fileName: string,
uid: string,
@@ -237,49 +276,103 @@ const uploadChunk = async (
path: string,
progress: ChunkProgress
): Promise => {
- const chunkSize = props.chunkSize > 0 ? props.chunkSize : 1024 * 1024;
+ const response = await requestChunk(
+ chunks,
+ fileName,
+ uid,
+ index,
+ path,
+ calcPercent(progress, chunks.length)
+ );
+ progress.done.add(index);
+ emitProgress(progress, chunks.length);
+ return response;
+};
- // 这片还没传时的进度:重试期间保持不动,不让进度条倒退
- const sentBefore = progress.baseBytes + Math.min((index - 1) * chunkSize, progress.fileSize);
- const percentBefore =
- progress.totalBytes > 0
- ? Math.min(100, Math.round((sentBefore / progress.totalBytes) * 100))
- : Math.round(((index - 1) / chunks.length) * 100);
+/**
+ * 内网判定:localhost / 回环地址 / 纯 IP(IPv4、IPv6)。
+ * 内网带宽足、延迟低,分片可以并发传;公网仍保持串行,免得占满上行带宽、也给服务器留点余量。
+ */
+const isIntranetHost = (): boolean => {
+ const host = window.location.hostname;
+ if (!host) return false;
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
+ // IPv4
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
+ // IPv6(形如 [::1] 或 ::1,必须带冒号,避免把普通域名当成 IP)
+ if (host.includes(":") && /^[0-9a-f:.]+$/i.test(host.replace(/[[\]]/g, ""))) return true;
+ return false;
+};
- const response = await requestChunk(chunks, fileName, uid, index, path, percentBefore);
+/** 是否并发上传:显式传了 parallel 就用它,否则按访问地址自动判断 */
+const isParallelUpload = () => props.parallel ?? isIntranetHost();
- // 整体进度按字节算:多文件时进度条一路向前,不会每个文件都退回 0
- const sentBytes = progress.baseBytes + Math.min(index * chunkSize, progress.fileSize);
- const percent =
- progress.totalBytes > 0
- ? Math.min(100, Math.round((sentBytes / progress.totalBytes) * 100))
- : Math.round((index / chunks.length) * 100);
+/**
+ * 实际使用的分片大小:调用方显式传了 chunkSize 就用它的,否则按网络自动选。
+ * 内网走并发:3MB,请求数降到 1/3,省掉每请求的固定开销(MVC 管道 + 事务 + 写盘);
+ * 公网走串行:1MB,重试与续传的粒度小,网络抖动时代价低。
+ */
+const resolveChunkSize = () => {
+ const size = props.chunkSize;
+ if (typeof size === "number" && size > 0) return size;
+ return isParallelUpload() ? 3 * 1024 * 1024 : 1024 * 1024;
+};
- // 这一片已经落到服务器上了,记下位置,中断后从这里之后接着传
- progress.onChunkDone?.(index);
+/**
+ * 上传一个文件的全部分片。
+ *
+ * 关键点:**最后一片必须最后传**——服务端是收到最后一片才合并分片、建文件记录的,
+ * 提前发过去会拿到不完整的文件。所以无论并发还是串行,前面的分片传完才发最后一片。
+ * 续传时按 done 里已完成的序号跳过,乱序完成也能对得上。
+ */
+const uploadChunks = async (
+ chunks: Blob[],
+ fileName: string,
+ uid: string,
+ path: string,
+ progress: ChunkProgress
+): Promise => {
+ const total = chunks.length;
+ if (total === 0) return null;
- emit("progress", {
- percent,
- current: index,
- total: chunks.length,
- // 单文件、多文件都统一用百分比(按字节算的整体进度),不再显示「第几片/共几片」
- text:
- progress.fileCount > 1
- ? `上传中(第 ${progress.fileNo}/${progress.fileCount} 个) ${percent}%`
- : `上传中 ${percent}%`,
- });
-
- if (index < chunks.length) {
- return await uploadChunk(chunks, fileName, uid, index + 1, path, progress);
+ const pending: number[] = [];
+ for (let i = 1; i <= total - 1; i++) {
+ if (!progress.done.has(i)) pending.push(i);
}
- return response.data;
+
+ let lastResponse: any = null;
+
+ if (isParallelUpload()) {
+ // 并发:固定并发数,避免一次把上百个请求全打出去
+ let cursor = 0;
+ const worker = async () => {
+ while (cursor < pending.length) {
+ const index = pending[cursor++];
+ await sendChunk(chunks, fileName, uid, index, path, progress);
+ }
+ };
+ const workers = Math.min(Math.max(1, props.concurrency), pending.length);
+ await Promise.all(Array.from({ length: workers }, () => worker()));
+ } else {
+ // 串行:上一片成功后才发下一片
+ for (const index of pending) {
+ await sendChunk(chunks, fileName, uid, index, path, progress);
+ }
+ }
+
+ // 最后一片:等前面全部完成后再发
+ if (!progress.done.has(total)) {
+ lastResponse = await sendChunk(chunks, fileName, uid, total, path, progress);
+ }
+
+ return lastResponse?.data;
};
/**
* 一次上传的断点状态。
*
* 只要不刷新页面,文件、切好的分片和 uid 都还在内存里,
- * 中断时记住「下一个要传第几片」,续传就能接着发,不必从头再来。
+ * 中断时记住「哪些分片已经传完」,续传只补没传完的,不必从头再来。
* 刷新页面就丢了——这是浏览器的限制,文件对象无法持久化。
*/
interface UploadSession {
@@ -294,8 +387,8 @@ interface UploadSession {
chunks?: Blob[];
/** 当前文件的 uid,续传时必须沿用,否则对不上服务器上的分片 */
uid?: string;
- /** 当前文件下一个要传的分片序号 */
- nextChunk: number;
+ /** 当前文件已完成的分片序号(从 1 开始),并发时顺序是乱的,只能按集合记 */
+ done: Set;
}
/** 未完成的上传会话,null 表示没有待续传的任务 */
@@ -319,21 +412,19 @@ const runSession = async (s: UploadSession): Promise => {
if (chunks.length === 0) throw new Error("无法读取文件内容");
s.chunks = chunks;
s.uid = uuidv4();
- s.nextChunk = 1;
+ s.done = new Set();
}
const chunks = s.chunks;
const uid = s.uid;
- const response = await uploadChunk(chunks, file.name, uid, s.nextChunk, s.path, {
+ const response = await uploadChunks(chunks, file.name, uid, s.path, {
fileNo: s.fileIndex + 1,
fileCount: s.files.length,
baseBytes: s.doneBytes,
totalBytes: s.totalBytes,
fileSize: file.size || 0,
- onChunkDone: (index) => {
- s.nextChunk = index + 1;
- },
+ done: s.done,
});
// 返回分割后的最后一个(序号最大的)分片文件名
@@ -345,7 +436,7 @@ const runSession = async (s: UploadSession): Promise => {
s.fileIndex += 1;
s.chunks = undefined;
s.uid = undefined;
- s.nextChunk = 1;
+ s.done = new Set();
}
session.value = null;
@@ -355,7 +446,7 @@ const runSession = async (s: UploadSession): Promise => {
const file = s.files[s.fileIndex];
emit("interrupted", {
fileName: file?.name || "",
- uploaded: Math.max(0, s.nextChunk - 1),
+ uploaded: s.done?.size ?? 0,
total: s.chunks?.length ?? 0,
});
emit("error", error);
@@ -366,8 +457,8 @@ const runSession = async (s: UploadSession): Promise => {
};
/**
- * 上传一批文件:按顺序一个一个传,不做并发,
- * 免得后端同时收到多个分片流。多选时所有文件进同一个目录。
+ * 上传一批文件:多选时逐个文件上传(每个文件内部再按并发/串行切分片),
+ * 所有文件进同一个目录。
*/
const uploadFiles = async (files: File[]): Promise => {
const list = (files || []).filter((f) => f && typeof f.size === "number");
@@ -382,7 +473,7 @@ const uploadFiles = async (files: File[]): Promise => {
totalBytes: list.reduce((sum, f) => sum + (f.size || 0), 0),
doneBytes: 0,
fileIndex: 0,
- nextChunk: 1,
+ done: new Set(),
});
} catch (error) {
emit("error", error);
diff --git a/WebVue/src/md/fileUpload.md b/WebVue/src/md/fileUpload.md
index 0cf7d13..1617bc1 100644
--- a/WebVue/src/md/fileUpload.md
+++ b/WebVue/src/md/fileUpload.md
@@ -1,10 +1,10 @@
# FileUpload 文件上传组件使用说明
-通用文件上传组件,封装了「分片 + 顺序上传 + 请求头拼装 + 进度回调」的完整逻辑,默认对接 DMS 的 `DmsFile/UploadFile` 接口。
+通用文件上传组件,封装了「分片 + 并发/串行上传 + 请求头拼装 + 进度回调」的完整逻辑,默认对接 DMS 的 `DmsFile/UploadFile` 接口。
- 组件位置:`src/components/basic/FileUpload.vue`
- 适用场景:DMS 文件上传、头像上传、任意需要分片上传的场景
-- 上传方式:基于 `axios`,把文件按 `chunkSize` 切成 `Blob` 分片后**顺序**(串行)上传
+- 上传方式:基于 `axios`,把文件按 `chunkSize` 切成 `Blob` 分片后上传——**内网并发、公网串行,最后一片始终最后传**(见「六、内部上传细节」第 4 条)
- 容错能力:单片失败自动重试(1x → 2x → 4x 退避);重试耗尽保留断点,可由外部决定**续传**或放弃
---
@@ -30,10 +30,12 @@ import FileUpload from "@/components/basic/FileUpload.vue";
| `disabled` | `boolean` | `false` | 是否禁用。为 `true` 时 `trigger()` 不会打开选择框 |
| `showButton` | `boolean` | `false` | 是否渲染内置的 `a-button` 作为触发元素 |
| `buttonText` | `string` | `"上传文件"` | 内置按钮的文案 |
-| `chunkSize` | `number` | `1024 * 1024`(1MB) | 分片大小(字节)。小于等于 0 时按 1MB 处理 |
+| `chunkSize` | `number` | 自动 | 分片大小(字节)。**不传时按网络自动**:内网(`localhost`/纯 IP,走并发)**3MB**、公网(域名,走串行)**1MB**;显式传值(含 `0` 或负数)则一律以传入值为准(≤0 视为自动) |
| `timeout` | `number` | `60000` | 单个分片请求的超时时间(毫秒) |
| `retryCount` | `number` | `3` | 单片请求失败后的自动重试次数,`0` 表示不重试 |
| `retryDelay` | `number` | `1000` | 重试的初始等待毫秒数,之后按 1x → 2x → 4x 递增 |
+| `parallel` | `boolean` | 自动 | 是否并上传分片(**最后一片始终串行**)。不传时自动判断:访问地址是 `localhost` / 纯 IP(内网)→ 并发,域名(公网)→ 串行 |
+| `concurrency` | `number` | `4` | 并发上传时的并行数,避免一次把上百个请求全打出去 |
---
@@ -86,7 +88,7 @@ fileUploadRef.value?.discard();
| --- | --- |
| `trigger()` | 触发原生文件选择。`disabled === true` 或正在上传时会被忽略 |
| `upload(file: File)` | 直接对指定 `File` 执行完整上传流程(不校验 `disabled`) |
-| `resume()` | 从断点继续上传(沿用原来的分片与 `uid`,保证和服务器上的分片对得上) |
+| `resume()` | 从断点继续上传:沿用原来的分片与 `uid`,只补**没传完的那些分片**(并发上传时完成顺序是乱的,所以按已完成序号集合记账) |
| `discard()` | 放弃本次上传,清空断点状态 |
| `hasPendingUpload` | `computed`,是否存在待续传的任务 |
@@ -123,11 +125,18 @@ fileUploadRef.value?.discard();
}
```
3. **分片命名**:`${uuid}-${index}-${total}${".扩展名"}`,例如 `a1b2c3-1-3.mp4`(`index` 从 1 开始)。
-4. **上传顺序**:`index` 从 1 递归到 `total`,**上一片成功后才发下一片**。
-5. **并发控制**:组件内部有 `uploading` 标记,同一时间只允许一个上传流程;上传中再次选择文件会被忽略。
+4. **上传顺序**:**最后一片永远最后传**——服务端是收到最后一片才合并分片、建文件记录的。
+ - 公网(域名):`index` 从 1 到 `total` 顺序发,上一片成功后才发下一片;
+ - 内网(`localhost` / 纯 IP):分片大小自动取 **3MB**,前 `total-1` 片**并发**上传(并发数 `concurrency`,默认 4),全部完成后再发最后一片;可用 `:parallel="false"` 强制串行(此时分片也回到 1MB)。
+5. **并发控制**:组件内部有 `uploading` 标记,同一时间只允许一个上传流程(多选文件之间也是逐个传);上传中再次选择文件会被忽略。
6. **文件列表**:使用 `v-model:file-list` 内部维护,不展示列表(`show-upload-list=false`),每次上传结束后自动清空。
7. **进度口径**:`percent` 按**字节**算的整体进度:`(已完成文件累计字节 + 当前文件已传字节) / 本次所有文件总字节`。多文件时进度一路向前,不会每个文件退回 0;比按分片数算更准(最后一片通常不足 1MB)。`text` 统一输出百分比,单文件 `上传中 66%`、多文件 `上传中(第 2/3 个) 66%`。
8. **失败重试**:单片失败自动重试 `retryCount` 次,等待 `retryDelay × 1/2/4`;重试期间进度保持在上一次的值,不回退。全部重试都失败才 `interrupted`。
+9. **并发与分片大小的取舍**:提速靠「减少请求数」而不是「加大分片」。每个分片请求都要走一遍服务端流程(MVC 管道 + `[UnitOfWork]` 事务 + 写盘),所以分片从 1MB 调到 3MB 能把请求数降到 1/3,内网收益明显;但
+ - 瓶颈若是带宽(千兆内网)则几乎无差别,加大分片只增加重试/续传的粒度成本;
+ - 分片大于文件大小时,该文件只有 1 片(唯一一片就是最后一片),无法并发;
+ - 组件已按此策略**自动设置**:内网 **3MB + 并发 4**、公网 **1MB + 串行**(`chunkSize` / `parallel` / `concurrency` 任一显式传入都以传入值为准);
+ - 进度粒度也会变粗(100MB / 3MB ≈ 3% 一跳)。
---
@@ -339,3 +348,4 @@ await fileUploadRef.value?.upload(file);
7. **视频上传会卡在 99%/100%**:这是正常的——服务端在最后一个分片里同步执行 ffmpeg 生成封面(上限 25 秒)才返回。此时应显示"视频转码中 x%"之类的提示,不要让用户以为卡死(见示例 1)。
8. **视频封面可能晚于列表出现**:ffmpeg 是异步落盘的,首刷时 jpg 可能还没生成,`
` 会 404 且不会自动重试。处理办法:① 上传后补刷列表(每次查询服务端会重新拼 `?v=时间戳`,`src` 变了才会重新请求);② 列表缩略图挂 `@error="retryImage($event)"`(`src/utils/imageRetry.ts`,按去掉查询参数的地址累计,**最多重试 3 次**,用完就隐藏图片露出占位图标,避免反复闪)。
9. **超时时间要留够**:视频场景最后一个分片请求里包含 ffmpeg 执行时间,`timeout` 默认 60s 够用;如果调小,会出现"分片超时 → 触发中断弹窗",但服务端其实已经处理完了。
+10. **加大 `chunkSize` 前先确认代理限制**:后端本身没有限制(`Program.cs` 里 Kestrel/IIS 的 `MaxRequestBodySize = null`、`MultipartBodyLengthLimit = int.MaxValue`),但如果站点前面挂了 Nginx,要确认 `client_max_body_size`(默认 `1m`),否则分片稍大就会被 413 拦掉。