做站群/多站点CMS项目时,图片上传这块是最容易翻车的地方。尤其编辑端用的还是CKEDITOR,内容里一贴就是七八张截图,或者从文件夹里拉一二十张商品图,请求像连环炮一样打过来,接口超时、图片丢失、甚至A站点显示了B站点的图——这些坑我几乎每次接手类似项目都要踩一遍。
本文就把我在这类“站群系统 + CKEDITOR + C#.NET”组合下处理多图片并发上传的完整方案写出来:从前端编辑器怎么配置,到后端控制器怎么接收,再到并发写入、目录设计、数据库落表、线上故障排查,全链路拆开讲清楚。适合正在做站群CMS二次开发、或想把CKEDITOR上传改造得更稳的.NET开发者参考。
1. 先讲清楚:站群环境下的CKEDITOR图片上传为什么会“压垮”单机后端
很多人觉得图片上传就是“选个文件,服务端WriteAsync到磁盘,返回URL”,单站CMS确实这样做能跑。但一旦放到站群里,问题会成倍放大,因为你不光要处理“并发”,还要处理“归属”。
1.1 多站点共用一套程序:图片必须有明确的“户口”
站群系统的本质是多套子站点共用同一套程序、同一个数据库或同一批服务器资源。图片上传后必须立刻回答三个问题:这是哪个站点的图?要存到哪个目录?将来用哪个域名来访问?
如果只把图片存到一个共享文件夹,返回一个相对URL,立刻就会出现串站问题——A站点的编辑看到B站点的图片路径,轻则显示错乱,重则权限失控。所以上传入口必须接收站点标识(比如SiteId),并且把它写进存储路径的第一层目录:/uploads/site7/202503/xxx.jpg。
这里有个很关键的点:数据库里存的应当是“相对路径”,而不是完整的https://img.xxx.com/uploads/...。因为站群站点会有独立域名、甚至独立CDN,如果存死绝对地址,将来站点换域名或上CDN,所有历史图片路径都得批量回改数据库,非常痛苦。相对路径加上一个“前缀拼接层”就灵活得多,后面接入对象存储也能平滑过渡。
1.2 编辑器的上传机制与浏览器并发限制
CKEDITOR 4的上传本质上就是向filebrowserImageUploadUrl发送XHR请求,服务端返回JSON,编辑器拿到url字段回显到<img>标签。听起来不复杂,但多图场景有两个天然瓶颈:
第一,浏览器同域名并发连接数有限制。HTTP/1.1下Chrome对同一域名最多建立6个并发连接,其余请求排队。你一次粘贴10张图,CKEDITOR会发起10个上传请求,超过6个的部分全部排队;只要前几个请求稍微慢一点,后面的就可能被浏览器判定超时直接abort,表现就是“明明选了8张图,结果只有3张传上去了”。
第二,单请求体积限制。IIS和ASP.NET Core都有自己的请求体大小上限。几张几MB的手机照片如果挤在同一个请求里,极容易撞上maxAllowedContentLength限制,返回403/413。所以多图并发上传的正确粒度,是“一张图一个请求”,而不是把所有图打包发送,这一点在第3章展开说。
1.3 C#.NET环境本身的“隐藏水位”
C#.NET后端在并发压力下也有一些默认水位,不处理就等于埋雷。
传统.NET Framework部署在IIS上时,一个同步的File.WriteAllBytes()会占住一个线程池线程,线程从请求进来一直绑到磁盘IO结束。几十个上传请求一进来,线程池线程直接被打穿,其它正常业务请求也开始排队。
即使到了ASP.NET Core,Kestrel虽然从设计上规避了“一线程一请求”的模型,但IIS托管模式、以及你代码里是否使用async/await,依然决定了并发上限。我见过不少把IFormFile.CopyToAsync当成“加个async就算异步”的写法,其实原理不对,还是同步阻塞在真IO上。
下面是几个最常见的限制点,建议上线前就确认清楚:
| 瓶颈点 | 影响 | 常用调整方向 |
|---|---|---|
| 浏览器同域连接数 | 多图并发时排队/超时 | 前端并发池控制在4-6,或切换到HTTP/2 |
IISmaxAllowedContentLength | 请求体超过约28.6MB直接413 | 按实际图片大小设置,不能盲目调大 |
appConcurrentRequestLimit | IIS并发请求队列挤压 | 配合异步IO和客户端限流 |
| 线程池被同步IO占满 | 服务端整体响应变慢 | 写文件务必用异步IO,不要WriteAllBytes |
| 站点域名跨域 | XHR被CORS拦截 | 上传API域名统一开放CORS白名单 |
一句话总结:站群里的CKEDITOR多图上传,本质是“多站点归属 + 浏览器并发限制 + 后端IO水位”三重问题的叠加。只解决其中一个,另外两个早晚会爆。
2. 上传链路设计:CKEDITOR前端配置与C#.NET接收端的一次握手
既然知道问题在哪,就可以从链路两端开始设计。前端管好“并发和展示”,后端管好“归属和落盘”,中间用一份双方都能识别的JSON协议衔接。
2.1 前端:给CKEDITOR配一条“站群感知”上传通道
CKEDITOR 4的标准做法是用filebrowserImageUploadUrl指向上传接口。站群场景里,必须在URL上带上当前站点标识。我习惯用Query参数传递siteId,因为CKEDITOR自身插件不会帮你塞Header,而Query参数对浏览器、代理、日志都最友好。
// 页面初始化时 const siteId = document.getElementById('siteId')?.value || 0; CKEDITOR.replace('editorContent', { filebrowserUploadUrl: '/api/upload/file', filebrowserImageUploadUrl: '/api/upload/image?siteId=' + siteId, filebrowserFlashUploadUrl: '/api/upload/file', height: 500, allowedContent: true });这里的filebrowserImageUploadUrl只负责“图片”走图片专用接口,其它附件走filebrowserUploadUrl。接口返回的数据必须是CKEDITOR认识的JSON结构,后面2.2节给出具体格式。
如果你用的是CKEDITOR 5,机制完全不同,不再是配置一个URL,而是实现一个CustomUploadAdapter,通过Promise把文件POST到远端。虽然写起来稍复杂,但好处是每个上传请求的headers、回调、失败处理都在你的掌控范围内。下面给一个简单的Adapter骨架:
class UploadAdapter { constructor(loader, siteId) { this.loader = loader; this.siteId = siteId; } upload() { return this.loader.file.then(file => new Promise((resolve, reject) => { const fd = new FormData(); fd.append('file', file); fetch('/api/upload/image?siteId=' + this.siteId, { method: 'POST', body: fd }) .then(res => res.json()) .then(json => { if (json.uploaded === 1) { resolve({ default: json.url }); } else { reject(json.error?.message || 'upload failed'); } }) .catch(reject); })); } abort() { // 真实项目里取消上传请求用AbortController } }2.2 后端:一个Controller同时处理多站点上传
后端我用ASP.NET Core写了一个统一的上传控制器,所有站点的图片都走这一个入口,通过siteId区分归属。核心逻辑包括:站点合法性校验、扩展名白名单、目录生成、文件写入、返回编辑器所需JSON。
[ApiController] [Route("api/upload")] public class UploadController : ControllerBase { private static readonly HashSet<string> AllowedExts = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".gif", ".webp" }; private readonly IStorageProvider _storage; private readonly ISiteValidator _siteValidator; public UploadController(IStorageProvider storage, ISiteValidator siteValidator) { _storage = storage; _siteValidator = siteValidator; } [HttpPost("image")] [RequestSizeLimit(10 * 1024 * 1024)] // 单图最大10M,按业务可调 public async Task<IActionResult> UploadImage( [FromForm] IFormFile file, [FromQuery] int siteId) { // 1. 站点校验:防止无效站点或盗链调用 if (!await _siteValidator.IsAvailableAsync(siteId)) return Unauthorized(new { uploaded = 0, error = new { message = "站点不可用" } }); // 2. 基础校验 if (file == null || file.Length == 0) return BadRequest(new { uploaded = 0, error = new { message = "未选择图片" } }); var ext = Path.GetExtension(file.FileName); if (!AllowedExts.Contains(ext)) return BadRequest(new { uploaded = 0, error = new { message = "不支持的图片格式" } }); // 3. 保存并返回相对路径 var relativePath = await _storage.SaveImageAsync(file, siteId, ext); // 4. 拼接当前站点的图片访问域名 // relativePath 示例: /uploads/site7/202503/a1b2c3d4.jpg var url = _siteValidator.GetImageBaseUrl(siteId) + relativePath; return Ok(new { uploaded = 1, fileName = file.FileName, url }); } }注意第4步:我用GetImageBaseUrl(siteId)动态拼接图片域名,而不是直接写死https://img.example.com。因为站群里站点A可能绑定了img.a.com,站点B绑定img.b.com,只有按站点解析前缀,才能彻底避免串站。
2.3 响应的JSON格式与跨域约定
CKEDITOR 4对返回JSON的字段有严格要求,字段名叫错了它就不认。正确的成功结构是:
{ "uploaded": 1, "fileName": "product-01.jpg", "url": "/uploads/site7/202503/a1b2c3d4.jpg" }失败时必须是:
{ "uploaded": 0, "error": { "message": "图片大小不能超过10MB" } }如果你在项目里看到“图片传上去了但编辑器不显示、还报JSON错误”,十有八九是这两个字段名的问题——有人返回的是"success": true或"urls": [],CKEDITOR根本解析不了。
跨域部分,如果上传接口域名和站点页面域名不一致(比如页面在www.a.com,上传走api.example.com),必须提前开CORS白名单。否则浏览器Network里能看到请求已经发出、状态也是200,但当前域名拿不到响应,CKEDITOR回调直接进error分支,表现同样像“上传失败”。
builder.Services.AddCors(options => { options.AddPolicy("SiteUpload", policy => { policy.WithOrigins( "https://www.a.com", "https://www.b.com", "https://www.c.com") .AllowAnyHeader() .AllowAnyMethod(); }); });提示:如果你用IIS反向代理或网关统一转发,还需要确保
/api/upload/image这条路径不被网关层的登录态拦截器误伤。CKEDITOR上传请求有时不带浏览器的Cookie/Token,最容易在这里被拦成401/403,前端却报“上传失败”,排查半天才发现根本没有到C#控制器。
3. 并发写入怎么扛:异步IO、目录策略与数据库落地的关键取舍
链路打通之后,真正承压的是后端写入逻辑。多图上传时会有多个请求同时到达,谁先写、写哪儿、怎么写,都需要提前定好规则。
3.1 一次请求一张图,别玩“大包裹”打包上传
我见过有人为了“减少请求数量”,把前端选中的N张图合并成一个multipart/form-data请求,后端一次性读取所有文件循环保存。这样做的坏处非常明显:
- 失败粒度太粗:10张图里第7张写坏了,整个请求返回失败,前端只能重新上传全部10张。
- 体积失控:10张每张3MB就是30MB,轻松突破IIS默认限制。
- 无法展示进度:编辑器只能看到一个转圈,用户不知道到底传了几张。
- 内存占用高:如果后端用
byte[]接收再循环写盘,服务器一次性多占几十MB内存。
正确做法是:一张图一个请求,N张图就发N个请求,由前端控制并发数。这样图片成功一个回显一个,失败一个重试一个,服务端每次只处理一个文件,IO和内存压力都摊匀了。
3.2 目录结构与文件命名:别等出事了再改
目录结构我强烈建议按“站点ID + 月份”做双层分桶:
/uploads/ site7/ 202503/ a1b2c3d4e5f6.jpg 202504/ 9f8e7d6c5b4a.png site9/ 202503/ ...理由很实在:文件不放在根目录,避免单目录条目过多导致文件系统检索变慢;按月分目录后,后续做归档、清理一个月前的临时图都非常方便;同时每个站点物理隔开,权限控制和备份也更清晰。
文件命名坚决不用原始文件名,统一用Guid.NewGuid():N去掉横线,保留原扩展名。原因有两个:原始文件名可能包含中文、空格、特殊字符,容易触发编码问题;也可能两个站点上传了同名文件,如果直接覆盖,会酿成事故。GUID命名的代价是多一两次文件系统查询,但换来的是全局唯一,站群里最怕的就是命名冲突。
写入时用异步流拷贝,不要先读进内存:
public async Task<string> SaveImageAsync(IFormFile file, int siteId, string ext) { var month = DateTime.Now.ToString("yyyyMM"); var fileName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}"; var relativePath = $"/uploads/site{siteId}/{month}/{fileName}"; var physicalPath = Path.Combine(_rootPath, "uploads", $"site{siteId}", month, fileName); Directory.CreateDirectory(Path.GetDirectoryName(physicalPath)!); await using (var stream = File.Create(physicalPath)) { await file.CopyToAsync(stream); // 流式拷贝,不加载整文件到内存 } return relativePath; }这里再补一句:小文件(几十KB的截图)用CopyToAsync和同步File.WriteAllBytes的性能差距几乎可以忽略,但大图或高并发场景下,异步IO的意义在于“不占用线程池线程等待磁盘”,控制器可以继续处理其它请求。所以统一用异步是正确习惯。
3.3 线程池的坑与async/await的正确姿势
ASP.NET Core在Kestrel下本来就是异步IO模型,但你要是写了file.OpenReadStream().CopyTo(targetStream)这种同步方法,那IO期间线程池线程照样被占住。多图并发时,线程饥饿的症状是:其它接口的响应时间突然拉长、CPU占用不高但请求都卡着、IIS性能监视器里看到大量线程等待。
我维护的一个项目曾经就是这种症状,排查到一个上传方法里所有人图省事用了SaveAs()(旧版MVC遗留),改成CopyToAsync后,同一台服务器从扛20个并发直接提升到扛100个并发。
另外,RequestSizeLimit要根据业务设置,不是越大越好。建议单图限制5-10MB,前端再配合canvas压缩,绝大多数运营编辑场景足够使用。你要是把限制开成500MB,恶意上传就能把磁盘写爆。
3.4 数据库落库:存相对路径,不存绝对URL
图片元数据建议落在独立表里,字段设计参考下面这种:
| 字段 | 作用 |
|---|---|
| Id | 主键 |
| SiteId | 所属站点,与存储路径里的site{id}保持一致 |
| RelativePath | /uploads/site7/202503/a1b2c3d4.jpg |
| FileName | 原始文件名,仅用于后台展示 |
| FileSize | 字节数,用于统计和限制 |
| MimeType | 实际检测到的类型 |
| UploaderId | 上传人ID |
| UploadTime | 上传时间 |
| Status | 0=临时,1=已关联内容,2=已清理 |
关键就一句话:永远只存相对路径,不存绝对URL。访问时由站点配置表里的图片域名前缀拼接。这样换域名、换CDN、迁移对象存储,只改配置不改数据库。
var url = _siteValidator.GetImageBaseUrl(siteId) + row.RelativePath;4. 前端并发调度实战:粘贴截图、批量选图与浏览器并发限制的博弈
后端能扛并发是一回事,前端也得把请求调度好,否则浏览器病灶还是在。
4.1 先处理CKEDITOR的“粘贴图片”默认行为
运营编辑最常用的操作是Ctrl+V粘贴截图。CKEDITOR 4默认会把粘贴的图片转成base64直接塞进HTML。这样做有三个恶果:编辑器里的HTML字符串瞬间膨胀几倍、保存到数据库时浪费存储空间、发布页面时图片加载极慢。
所以第一步是监听paste事件,把图片类文件拦截下来,转成独立上传请求:
editor.on('paste', (e) => { const dt = e.data && e.data.dataTransfer; if (!dt || !dt.items || !dt.items.length) return; const imageItems = []; for (const item of dt.items) { if (item.kind === 'file' && item.type && item.type.startsWith('image/')) { imageItems.push(item.getAsFile()); } } if (!imageItems.length) return; // 取消默认base64插入 e.cancel(); // 对拿到的File列表做并发上传 uploadFileListWithConcurrency(imageItems, 4, { onSuccess: (url) => { const img = editor.document.createElement('img'); img.setAttribute('src', url); editor.insertElement(img); } }); });4.2 批量选图时的并发池控制
无论粘贴还是input选择多图,最后拿到的都是一组File对象。我写了一个简单的并发池,限制同时最多4个请求,每完成一个立刻补位,直到全部完成。
function uploadFileListWithConcurrency(files, limit, handlers) { let index = 0; let active = 0; let successCount = 0; let failedList = []; return new Promise((resolve) => { const next = () => { if (index >= files.length && active === 0) { handlers.onDone?.(successCount, failedList); resolve({ successCount, failedList }); return; } while (active < limit && index < files.length) { const file = files[index++]; active++; uploadOneImage(file) .then((url) => { successCount++; handlers.onSuccess?.(url, successCount, files.length); }) .catch((err) => { failedList.push({ file, err }); handlers.onError?.(err, file); }) .finally(() => { active--; next(); }); } }; next(); }); }limit为什么用4而不是6?因为浏览器6个连接里还要留几个给页面本身的静态资源请求,全占满的话,CSS、JS加载都会排队,页面会感觉卡顿。4算是安全和速度的平衡点。
4.3 进度反馈与失败重试
并发上传最怕用户以为没点着,连点好几次,结果重复上传了一堆垃圾图。所以界面上一定要有三个东西:总量提示“正在上传 3/8”,单张失败提示“第5张上传失败,是否重试”,上传期间锁住编辑器的提交按钮。
重试逻辑要注意:4xx类错误(如文件格式不允许)重试一万遍也没用,直接提示用户;5xx和网络中断才值得重试,比如失败后等待2秒重试一次,再失败就上报。
function uploadOneImage(file, retries = 1) { return uploadRequest(file) .catch((err) => { if (retries > 0 && !isClientError(err)) { return new Promise((resolve) => setTimeout(resolve, 2000)) .then(() => uploadOneImage(file, retries - 1)); } throw err; }); }注意:如果上传接口返回的JSON是CKEDITOR的
{ uploaded: 0 },它属于业务失败,不算网络错误,要做好区分,别在fetch的catch里笼统混在一起。
5. 线上踩坑复盘:丢失、超时、串站三类故障的完整排查链路
方案设计再完美,上线后还是会有意外。这一章把我在类似项目里真实遇到的三类故障还原一遍,重点是排查思路,不是单纯给答案。
5.1 图片“神秘消失”:从浏览器Network开始逐步定位
现象:编辑在CKEDITOR里一次粘贴8张截图,只成功3张,其余5张无影无踪,服务端日志里也搜不到对应请求。
排查链路:
第一步,让编辑打开F12的Network面板复现,看到底是哪些请求失败。结果发现失败的请求状态是(canceled),根本没有到达服务端,这就说明问题出在浏览器这侧,而不是后端。
第二步,看是什么触发了取消。经过比对,发现8个请求发出瞬间挤在同一个域名下,前6个进入上传状态,后2个排队;前排请求因为单图5MB较大,处理时间超过了浏览器队列能容忍的时间,后面的请求被abort。
第三步,再叠加一个小细节:编辑器把失败请求视为“从未发送”,所以不会触发CKEDITOR的错误回调,界面上一句话都不提示,体验就是“图片神秘消失”。
解决:前端把所有上传改成并发池(limit=4),排队时间大幅缩短;单张失败后自动重试一次;并且把uploaded: 0的业务错误也统一弹到UI,不让失败悄无声息。
5.2 “接口超时”其实是请求体被截断
现象:某站点编辑上传一张15MB的相机原图,接口几秒后返回403.14或者413 Request Entity Too Large,浏览器提示“上传失败”,日志里连C#控制器都没进来。
排查链路:
先看IIS详细错误码。若是413.1,说明是请求体超过maxAllowedContentLength;若是404.13,说明静态文件处理被送到别处;若是403.14,则是目录列表被禁止——不同状态码指向完全不同的原因。
这个案例最终定位到IIS默认的maxAllowedContentLength(约28.6MB)对单个请求的限制,再加上前端当时把多图合并成一个大请求,两张15MB图加起来就超了。
解决:前端单图单请求;后端把RequestSizeLimit(10 * 1024 * 1024)设为10MB并在返回JSON里提示“单张图片不能超过10MB”;IIS侧maxAllowedContentLength放在100MB作为整体兜底,但前面已经不会有30MB的请求了。
<system.webServer> <security> <requestFiltering> <requestLimits maxAllowedContentLength="104857600" /> </requestFiltering> </security> </system.webServer>这里特别想强调:不要因为出现413就把限制开成无限大。限制开越大,恶意上传风险越高,正确的做法是“让正常请求远小于限制”,而不是“让限制迁就异常大图”。
5.3 串站事故:A站点显示B站点的图片
现象:站点A编辑发布文章后,页面里某张图片的URL带着site7路径,一看就是B站点的图片目录,引用关系彻底乱了。
排查链路:
先查数据库里这张图片记录的SiteId字段,发现写表时用的是Session["SiteId"],而站群系统的同一套代码在每个站点下都有独立Session,但API请求经常不走同样Session——拿到的是空值,兜底逻辑写了个siteId = 0,结果全部图片都落到site0共享目录里。
再查读取URL的前缀拼接,因为存的是相对路径,站点A的页面读数据时用的是“当前站点前缀”,竟然把site0目录的图拼到了A站点的图片域名下,于是出现了A站点页面加载B站点目录文件的诡异现象。
解决:上传接口的siteId一律从请求参数显式传递,禁止从Session隐式获取;对siteId <= 0的请求直接拒绝;数据库里已有脏数据通过脚本按“目录前缀 + 上传人站点编号”修复迁移。
同时补了一个安全校验:图片访问不走静态文件直出,而是通过一个图片代理控制器校验站点归属后再输出文件流。这样即使恶意构造/uploads/site7/...的URL,只要没有对应站点权限,也读不到文件内容。
6. 上线前必做的加固:文件校验、路径安全与未来上云的扩展点
最后想说的这部分,属于“平时不见效、出事能救命”的自我保护。尤其是站群系统面向公网运营后,上传接口就是最容易被拿来做恶意文件投递的入口。
6.1 文件类型不能被Content-Type骗了
很多人的文件校验只看了file.ContentType或扩展名,这是远远不够的。恶意者完全可以上传一个扩展名为.jpg的脚本文件,Content-Type也伪装成image/jpeg。如果上传目录被当作可执行目录解析,脚本就能在服务器上跑起来。
稳妥做法是校验文件头的“魔数”,也就是二进制文件开头的固定字节:
private static readonly Dictionary<string, byte[]> MagicSignatures = new() { { ".jpg", new byte[] { 0xFF, 0xD8, 0xFF } }, { ".png", new byte[] { 0x89, 0x50, 0x4E, 0x47 } }, { ".gif", new byte[] { 0x47, 0x49, 0x46, 0x38 } } }; private bool ValidateImageHeader(Stream stream, string ext) { if (!MagicSignatures.TryGetValue(ext.ToLowerInvariant(), out var signature)) return false; stream.Position = 0; Span<byte> header = stackalloc byte[4]; var read = stream.Read(header); return read >= signature.Length && header[..signature.Length].SequenceEqual(signature); }更保险的做法是:无论原图是什么,都用图像处理库重新解码、重新编码后保存新文件。比如用ImageSharp读取并重新保存,等于把非图片内容全部清洗掉,还能顺带压缩体积,一举两得。
6.2 路径穿越与目录权限
站群里文件命名虽然用了GUID,但你永远不能假设“所有调用方都是好人”。如果哪天有人把fileName改成../../../web.config,而代码里幸运用了Path.Combine,就可能被利用。
所以我坚持每一处文件保存都必须做一次根目录校验:
var rootFull = Path.GetFullPath(_rootPath); var destFull = Path.GetFullPath(physicalPath); if (!destFull.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase)) throw new SecurityException("非法的文件路径");同时,上传目录在IIS上必须禁止脚本执行。web.config里显式放一个清空处理器的配置,或者把Uploads目录单独设成不执行任何脚本。
<location path="uploads"> <system.webServer> <handlers> <clear /> </handlers> </system.webServer> </location>6.3 从本地盘到对象存储的平滑过渡
站群图片量涨上去之后,本地磁盘总会碰到瓶颈。大部分站群项目最终都会走对象存储、CDN分发这条路。我在设计上传逻辑时做了个抽象接口IStorageProvider,本地实现和云存储实现随时可以切换:
| 场景 | 本地磁盘 | 对象存储 + CDN |
|---|---|---|
| 单机部署 | 简单直接,无额外成本 | 需要配置Bucket权限和CDN域名 |
| 多机/负载均衡 | 需要共享存储,否则图片分布在不同机器 | 天然统一 |
| 大流量图片访问 | 占用Web服务器带宽和磁盘IO | CDN边缘节点扛流量 |
| 容量扩展 | 加硬盘,有上限 | 按量付费,基本无上限 |
即便现在还在用本地盘,数据库落RelativePath、前端拼GetImageBaseUrl这套设计也保留了平滑迁移的可能性。到时候写一个S3兼容的IStorageProvider实现,改一行注册代码就能切换,历史数据不需要迁移。
我最后在项目里固定下来的做法,其实是三个“永远”:数据层永远存相对路径、上传入口永远显式带SiteId、前端并发永远控制在4。只要守住这三点,CKEDITOR多图并发上传在C#.NET站群里就能稳定跑。你也一定记得在控制器的catch里返回CKEDITOR能识别的{ uploaded: 0, error: { message: "... " } },否则前端看到的就不是友好提示,而是白屏和“网络错误”了。