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