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 拦掉。