文件上传与对象存储
文件上传与对象存储
系统内置统一上传能力,支持本地存储、阿里云 OSS、S3 兼容对象存储(RustFS / MinIO / Ceph RGW / SeaweedFS / Cloudflare R2 / Backblaze B2 / AWS S3 等)。上传时按存储类型路由,文件记录统一落 SysFile,老文件留在本地、新文件进对象存储,两者并行互不影响。
一、存储类型(StoreType)
定义在 Infrastructure/Enums/StoreType.cs:
| 值 | 枚举 | 前端展示 | 路由到的实现 |
|---|---|---|---|
| 1 | LOCAL | 本地存储 | SysFileService.SaveFileToLocal(写 wwwroot,AccessUrl 由 Upload.uploadUrl 拼出) |
| 2 | ALIYUN | 阿里云存储 | AliyunOssProvider(上传未实现,仅占位) |
| 3 | TENCENT | — | 预留,未实现 |
| 4 | QINIU | — | 预留,未实现 |
| 5 | REMOTE | — | 预留,未实现 |
| 6 | S3 | S3 兼容存储 | S3CompatibleOssProvider(RustFS / MinIO / Ceph RGW / R2 等) |
SysFile.StoreType 是 int,新增取值无数据库结构变更,历史数据无需迁移。
二、上传入口与路由
上传接口 POST /common/uploadFile,表单参数 storeType 可显式指定目标存储:
public async Task<IActionResult> UploadFile([FromForm] UploadDto uploadDto, IFormFile file, StoreType? storeType = null)
{
// 未显式指定时取配置默认:appsettings 的 Upload.defaultStoreType
var targetStoreType = storeType ?? OptionsSetting.Upload.DefaultStoreType;
// LOCAL → SaveFileToLocal;ALIYUN / S3 → SaveFileToOss(按类型取对应 Provider)
}
路由规则:
- 显式传参优先:
storeType传了什么就用什么实现,不受全局配置影响; - 未传参用默认:取
Upload.defaultStoreType(Local/ALIYUN/S3); - 实现由存储类型决定,不由全局配置决定:选 6 一定是
S3CompatibleOssProvider,选 2 一定是AliyunOssProvider。这是有意为之——早期两者脱节,出现过"前端选了 S3、实际却拿阿里云客户端去传"的静默错配; - 工厂
OssProviderFactory用Lazy<T>缓存客户端(高并发不重复建连),未启用的类型不会构造客户端。
响应体:
{ "url": "文件的 AccessUrl", "fileName": "文件名", "fileId": "雪花ID" }
OssProvider 与 defaultStoreType 的区别
Upload.defaultStoreType:决定上传接口的默认分支(未传storeType时用哪个存储);OssProvider:只决定IOssProvider的兜底默认实现(兼容历史调用方),取值s3/aliyun;写错会静默回退阿里云(拼错不报错,注意排查)。- 二者通常保持一致即可。
统一切换业务上传到对象存储
后台的 ImageUpload / FileUpload / VideoUpload 组件都不传 storeType,所以只改一处配置即可让商品图、富文本图等全部进对象存储,无需逐个改前端:
"Upload": { "defaultStoreType": "S3" }
大小限制有三层
| 配置 | 作用 |
|---|---|
Upload.requestLimitSize(50M) | HTTP 请求体上限(中间件层),超过它请求根本进不来,nginx 也要同步 |
S3.maxSize / ALIYUN_OSS.maxSize(100M) | 该存储分支的单文件上限,超限返回「上传文件过大」 |
Upload.maxSize(15M) | 历史配置项,当前上传入口未使用 |
三、配置说明(appsettings.json)
3.1 需要配两个宿主
IOssProviderFactory 与各 IOssProvider 在两个宿主各自注册(均已在 Program.cs 完成),配置节也要保持两边一致:
ZR.Admin.WebApi/appsettings.json(内网后台,实际上传入口在这里);ZR.Front.WebApi/appsettings.json(公网 C 端,当前不暴露上传接口,为将来预留)。
3.2 Upload 节
"Upload": {
"uploadUrl": "https://www.example.com/prod-api/", // 本地存储访问前缀,必须以 / 结尾,见第六节
"localSavePath": "", // 本地存储目录,空 = wwwroot
"maxSize": 15, // 历史项,当前未使用
"requestLimitSize": 50, // 请求体上限(MB)
"notAllowedExt": [".bat", ".exe", ".jar", ".js"],
"defaultStoreType": "Local" // Local / ALIYUN / S3
}
3.3 ALIYUN_OSS 节
"ALIYUN_OSS": {
"REGIONID": "cn-hangzhou",
"KEY": "XX",
"SECRET": "XX",
"bucketName": "bucketName",
"domainUrl": "https://xxx.example.com",
"maxSize": 100
}
阿里云实现现状
AliyunOssProvider 的 UploadAsync 当前未启用(恒返回失败),生产环境请勿把 OssProvider 配成 aliyun,也不要让业务走 storeType=2。需要阿里云上传时请等待该实现补齐,或按第八节自行实现 IOssProvider。
3.4 S3 节
"OssProvider": "s3", // 兜底默认实现(模板已设为 s3;缺省值仍是 aliyun)
"S3": {
"displayName": "RustFS", // 展示/日志用名称,也是接口返回的 ProviderName
"endpoint": "http://192.168.1.10:8000", // S3 API 地址,不要带尾斜杠
"accessKey": "你的 AccessKey",
"secretKey": "你的 SecretKey",
"bucketName": "zradmin", // 单桶 + 对象键前缀区分租户,不要按租户分桶
"domainUrl": "http://192.168.1.10:8000/zradmin", // 可公开读取的访问域名,自建直连必须带桶名
"forcePathStyle": true, // 自建 S3 必须 true
"region": "us-east-1", // SDK 签名必填,自建服务不校验
"maxSize": 100, // 单文件上限(MB)
"cacheControl": "public, max-age=2592000" // 对象缓存头,留空则不发送
}
| 配置项 | 必填 | 说明 |
|---|---|---|
displayName | 展示与日志用名称(如 RustFS),同时作为接口返回的 ProviderName | |
endpoint | ✅ | 服务地址,不要带尾斜杠;自建服务形如 http://ip:port |
accessKey / secretKey | ✅ | 访问密钥(日志中不会输出) |
bucketName | ✅ | 存储桶;对象键已带 tenant/{租户id}/yyyy/MMdd/... 前缀,建议单桶 + 前缀区分 |
domainUrl | ⚠️ | 可公开读取的访问域名,AccessUrl 由它 + 对象键拼出;留空则降级为相对路径(见 4.4) |
forcePathStyle | ✅ | 自建 S3 必须 true,见 4.3 |
region | SDK 签名必填项,自建服务不校验,填 us-east-1 即可 | |
maxSize | 单文件上限(MB),默认 100 | |
cacheControl | 对象缓存头,默认 30 天;留空表示不缓存(见 5.4) |
四、S3 兼容存储接入(以 RustFS 为例)
4.1 为什么只有一个通用 Provider
S3 兼容生态共用同一套客户端,按厂商各建一个类只是重复代码,因此统一为 S3CompatibleOssProvider,端点 / 寻址风格 / 公开域名差异全部由 S3 配置节消化:
| 服务 | 能否共用同一实现 | 说明 |
|---|---|---|
| RustFS / MinIO / Ceph RGW / SeaweedFS / Cloudflare R2 / Backblaze B2 / AWS S3 | ✅ 只需改配置 | 都讲 S3 协议,差异由 S3 配置节吃掉 |
| 阿里云 OSS | ❌ | 无 S3 端点,需阿里云官方 SDK(当前未实现) |
| 腾讯云 COS / 七牛 Kodo | ⚠️ | XML API 形似 S3,签名与行为有差异,建议各家 SDK |
调用链:
Common/UploadFile(storeType = 1 本地 / 2 阿里云 / 6 S3 兼容)
│
├── 1 本地 ─────→ SysFileService.SaveFileToLocal(写 wwwroot)
├── 2 阿里云 ───→ IOssProviderFactory.Get(ALIYUN) → AliyunOssProvider
└── 6 S3 兼容 ──→ IOssProviderFactory.Get(S3) → S3CompatibleOssProvider
├─ UploadAsync → 上传对象
└─ GetAccessUrl → 写入 SysFile.AccessUrl
| 组件 | 文件 | 职责 |
|---|---|---|
| 契约 | Infrastructure/Oss/IOssProvider.cs | UploadAsync / DeleteAsync / GetAccessUrl + MaxSize / IsConfigured |
| 工厂契约 | Infrastructure/Oss/IOssProviderFactory.cs | Get(StoreType) / GetDefault() |
| 工厂实现 | ZR.Oss/OssProviderFactory.cs | 按 StoreType 返回实现(6→S3 / 2→阿里云),Lazy 缓存实例;全局配置仅作兜底 |
| S3 实现 | ZR.Oss/Providers/S3CompatibleOssProvider.cs | SDK 客户端构建(含兼容开关)、上传、删除、公开 URL 拼接、MIME 推断、异常日志 |
| 阿里云实现 | ZR.Oss/Providers/AliyunOssProvider.cs | 占位实现,上传/删除未启用 |
| 存储类型 | Infrastructure/Enums/StoreType.cs | LOCAL=1 / ALIYUN=2 / TENCENT=3 / QINIU=4 / REMOTE=5 / S3=6 |
| 配置模型 | Infrastructure/Model/OptionsSetting.cs | S3Compatible:端点、凭据、桶名、公开域名、寻址风格、地域、上限 |
| 上传入口 | ZR.Admin.WebApi/Controllers/CommonController.cs | 阿里云与 S3 各自独立分支,分别按自己的实现/配置校验并落库 |
接口留在
Infrastructure、实现搬到ZR.Oss:这样AWSSDK.S3只在需要对象存储的宿主里引用,不会顺着ZR.ServiceCore传导到ZR.Tasks、ZR.Workflow等工程。宿主在Program.cs里注册:AddSingleton<IOssProviderFactory, OssProviderFactory>()+AddSingleton<IOssProvider>(sp => factory.GetDefault())。
4.2 服务端准备(三步)
- 建一个桶,名字与
bucketName一致(示例zradmin); - 把该桶的访问策略设为「公有」(RustFS 控制台 → 存储桶设置 → 访问与共享 → 访问策略)——否则前端打开图片会
AccessDenied; - 记下访问密钥(AccessKey / SecretKey)与 S3 API 端口(与"控制台端口"是两个,别填错)。
4.3 三个必须保留的 SDK 开关
AWSSDK.S3 对第三方兼容服务只能算部分兼容:不兼容的是 SDK 的现代化默认行为,核心操作(PutObject / DeleteObject / URL 拼接)各家都支持。实现中显式关掉了三类默认行为,请勿"顺手清理":
| # | 开关 | 关掉什么 | 不关的后果 |
|---|---|---|---|
| 1 | ForcePathStyle = true | Virtual-host 寻址(bucket.host/key) | 自建服务拼成 bucket.ip:port,域名无法解析,直接失败 |
| 2 | UseChunkEncoding = false | aws-chunked 分块签名 | 上传失败,或 HTTP 200 但对象内容被包成 chunked 而损坏(最隐蔽) |
| 3 | RequestChecksumCalculation = WHEN_REQUIRED | SDK 默认附加的 CRC32 校验头 | 多数兼容服务不认该校验头,报错或行为异常 |
不要再加 DisablePayloadSigning = true
它在 AWSSDK v3 时代常与 UseChunkEncoding = false 一起出现,但 v4 规定该选项必须走 HTTPS。自建存储多用明文 http://,客户端会直接拒绝发送:
AmazonClientException: When DisablePayloadSigning is true, the request must be sent over HTTPS.
关掉分块签名靠 UseChunkEncoding = false 就够了——它改用单块签名 + 预计算 SHA256,不要求 HTTPS。
正因为第 2 条的失败形态是"假成功",自测必须把对象下载回来比对字节,不能只看上传返回 200。
SDK 版本固定为
AWSSDK.S3 4.0.103.4(写死在ZR.Oss.csproj便于复现),升级时按第九节回归验证。
4.4 domainUrl:最容易被忽略的一环
AccessUrl = domainUrl + 对象键,代码不会自动补桶名:
| 场景 | domainUrl 填什么 | 拼出的 AccessUrl |
|---|---|---|
| RustFS / MinIO 直连(最常见) | http://ip:8000/zradmin(带桶名) | http://ip:8000/zradmin/tenant/0/2026/0929/xxx.jpg ✅ |
| ❌ 只填端点根地址 | http://ip:8000 | http://ip:8000/tenant/0/... → 对象键被当成根路径,AccessDenied |
| CDN / 反向代理已隐去桶名 | 去掉桶名的地址,如 https://fs.example.com | https://fs.example.com/tenant/0/... ✅ |
AccessUrl 会持久化存进 SysFile,前端 <image src> 直接用,所以必须长期可访问:
- 不要用预签名 URL:有有效期,落库后会集体失效(本项目未采用该方案);
- bucket 要么设为公开读,要么在前面加反向代理 + HTTPS 域名(公开读只解决"匿名可读",解决不了路径写错);
- 小程序端额外要求:HTTPS + 域名已备案 + 在微信后台配置 downloadFile 合法域名;H5 端还需在对象存储侧配置 CORS。
4.5 实测踩过的两个坑
| 坑 | 现象 | 解法 |
|---|---|---|
配了 DisablePayloadSigning=true | 上传失败:When DisablePayloadSigning is true, the request must be sent over HTTPS. | 删掉该选项,只用 UseChunkEncoding=false 关闭分块签名(见 4.3) |
domainUrl 没带桶名 | 上传成功,但打开图片 AccessDenied | 改成 http://ip:端口/桶名(见 4.4) |
桶策略「公有」只解决匿名可读,解决不了路径缺桶名——两件事都正确才不会 403。
五、生产部署
5.1 上传与访问分离(推荐)
endpoint(上传)与 domainUrl(访问)是两件事,可以指向不同地址:
上传地址 endpoint | 访问地址 domainUrl | |
|---|---|---|
| 谁在用 | 后端服务器(PutObject / DeleteObject) | 浏览器 / 小程序(<image src>) |
| 需要能力 | 完整 S3 API + 签名 | 只需匿名 GET 到对象 |
| 暴露范围 | 内网直连,不必对公网开放 | 公网 HTTPS + 已备案域名 |
server {
listen 443 ssl;
server_name fs.example.com; # 需备案(小程序硬要求)
location / {
proxy_pass http://122.0.0.20:8000; # 不带尾斜杠 → 路径原样透传,与上传时的对象键一致
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Connection "";
# 若还要启用 Nginx 缓存(见 5.3),不要加 proxy_buffering off —— 会导致缓存全部 MISS
}
}
"endpoint": "http://122.0.0.20:8000", // 上传:内网直连,不经 Nginx
"domainUrl": "https://fs.example.com/zradmin" // 访问:公网 HTTPS + 桶名
5.2 同域名同时承载上传与访问
必须补齐以下 4 点,否则必然报错:
| # | 必须项 | 缺失后果 |
|---|---|---|
| 1 | proxy_set_header Host $host; | S3 v4 签名含 host 头 → SignatureDoesNotMatch |
| 2 | client_max_body_size ≥ 上传上限 | 默认 1MB,稍大文件直接 413 |
| 3 | endpoint 写域名(不能写 IP),且后端能解析到该 Nginx | 签名与连接目标不一致,上传失败 |
| 4 | forcePathStyle: true(已具备) | 按 Virtual-host 寻址 → 域名解析失败 |
5.3 Nginx 缓存(可选,省回源流量)
先分清省的是哪一段:
| 链路 | 是否消耗公网流量 | proxy_cache 能否省 |
|---|---|---|
| 用户 → Nginx | ✅ 消耗(Nginx 出网) | ❌ 这是"最后一公里" |
| Nginx → RustFS(回源) | 取决于拓扑:两台机器时走公网,消耗 RustFS 出网 | ✅ 能 |
所以 Nginx 与 RustFS 不同机时收益最大;同机部署收益有限,还多占磁盘,可不加。
# http 块
proxy_cache_path /data/nginx-cache/rustfs levels=1:2 keys_zone=rustfs_cache:100m
inactive=30d max_size=20g use_temp_path=off;
# server 块
location / {
proxy_pass http://122.0.0.20:8000;
proxy_cache rustfs_cache;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 206 30d;
proxy_cache_valid 404 1m; # 负缓存,避免反复回源找不存在的对象
proxy_cache_lock on; # 同一对象并发回源只放行一个
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on; # 过期先返回旧内容、后台更新
proxy_cache_revalidate on;
proxy_ignore_headers Set-Cookie; # 上游带 Set-Cookie 时默认不缓存
add_header X-Cache-Status $upstream_cache_status; # 调试:HIT / MISS / EXPIRED
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
三个坑:
| # | 坑 | 说明 |
|---|---|---|
| 1 | proxy_buffering off 与缓存冲突 | 关闭缓冲后响应不落盘,缓存永远 MISS,启用缓存时必须去掉 |
| 2 | 上游响应带 Set-Cookie 时默认不缓存 | 需要 proxy_ignore_headers Set-Cookie; |
| 3 | 上传请求不会被缓存 | Nginx 只缓存 GET/HEAD,PUT/DELETE 天然不缓存 |
验证:连续请求两次同一张图片,看 X-Cache-Status,第一次 MISS、第二次 HIT 即生效。
5.4 对象缓存头(省用户端流量)
Nginx 缓存只保护回源,用户端仍会每次来问 Nginx。因此上传时会写入对象缓存头,让浏览器直接缓存:
"S3": { "cacheControl": "public, max-age=2592000" } // 默认 30 天;留空则不缓存
实现细节:AWSSDK v4 起
CacheControl这类标头不再直接挂在PutObjectRequest上,而是收进Headers集合(request.Headers.CacheControl = …),照 v3 的写法会报CS1061。
5.5 上传下载安全
文件接口是外部可直接访问的入口,框架做了三类处理(Infrastructure/Helper/FileUtil.cs):
| 处理 | 方法 | 行为 |
|---|---|---|
| 目录规整 | SanitizeRelativePath | \ 统一为 /、按段切开逐段清洗,丢弃 .、.. 与非法段,只保留相对路径 |
| 文件名清洗 | SanitizeFileName | 取最后一段(/、\ 都当分隔符)、剔除控制字符与系统非法字符、去首尾点与空格、截断 100 字符 |
| 越界判断 | IsPathUnder | 解析成绝对路径后比对是否仍在根目录下,.. 越界、解析异常一律判 false |
- 上传:
SaveFileToLocal先规整FileDir、再校验扩展名、再清洗文件名,最后用IsPathUnder兜底,越界直接抛「文件存储路径不合法」; - 下载(
/common/download):解析出的绝对路径必须同时满足「在wwwroot下」与「文件存在」,否则统一返回「文件不存在或路径不合法」——越界与不存在共用同一文案,避免把服务器目录结构回显给攻击者,同时记一条 Warn 日志(含 path、user、解析结果); - 扩展名黑名单大小写:
NotAllowedExt的比较改为OrdinalIgnoreCase,.PHP、.Js这类大小写变体不再绕过(控制器与服务层各校验一次)。
黑名单不等于安全
NotAllowedExt 默认只有 .bat/.exe/.jar/.js,.phtml、.htaccess、.svg(含脚本)等仍可上传。上传目录不要给执行权限,nginx 侧至少要这样兜住:
location ^~ /uploads/ {
alias /data/zradmin/uploads/;
types { } # 清空 MIME 表
default_type application/octet-stream;
add_header X-Content-Type-Options nosniff always;
add_header Content-Disposition "attachment" always;
try_files $uri =404;
}
^~ 防止路径被其它正则 location 截获,types { } + nosniff 让上传上来的 .html / .svg 无法被浏览器当页面执行。完整说明与「图片需在线预览」的取舍见前端部署。
另外,/common/uploadFile 在 iprate.json 的端点白名单里(避免大文件上传被通用限流打断),所以限流挡不住上传滥用,真正的护栏是鉴权、NotAllowedExt 与各存储的 MaxSize。
图片不再压缩
上传链路已移除图片压缩(SkiaSharp 的原生依赖让发布包过大):UploadDto.Quality 参数还在,但服务端不再转码,CreateUploadStreamAsync 直接返回原始流。前端若要控制体积,需在上传前自行压缩;AI 视觉请求同样直传原图。
六、本地存储的访问路径
本地存储的物理路径与 URL 拼接规则固定,配置 nginx 时按这个对照:
物理路径: {WebRootPath}/[fileDir/]yyyy/MMdd/文件名
localSavePath 为空 → wwwroot 根;Docker 容器内默认 uploads
开启租户时多一层 tenant/{租户id}/
AccessUrl: new Uri(Upload.uploadUrl, 相对路径)
uploadUrl 必须以 / 结尾
new Uri(baseUri, relative) 会把 base 的最后一段当成"文件名"替换掉:
uploadUrl = https://www.example.com/prod-api/→…/prod-api/2026/0925/a.jpg✅uploadUrl = https://www.example.com/prod-api(无尾斜杠)→…/2026/0925/a.jpg,前缀被吃掉 ❌
开发环境默认 http://localhost:8888(根路径、无需斜杠也正确);生产请填带访问前缀且以 / 结尾的地址。
两种常见落地方式:
| 方式 | 配置 | 访问链路 |
|---|---|---|
| A. 走后端静态文件(默认) | localSavePath: "",uploadUrl: https://域名/prod-api/ | 浏览器 → nginx /prod-api/ → 后端 UseStaticFiles |
| B. nginx 直接托管(推荐) | localSavePath: "/data/zradmin/uploads"(或 "uploads"),uploadUrl: https://域名/uploads/ | 浏览器 → nginx location /uploads/,不经过后端 |
localSavePath 填绝对路径时会直接覆盖 wwwroot 前缀,文件落在指定目录;填相对名则落在 wwwroot/ 下。方式 B 的 nginx 片段(路径按实际部署调整):
location /uploads/ {
alias /data/zradmin/uploads/;
expires 30d;
autoindex off;
access_log off;
}
七、配置完整性校验
每个 Provider 都提供 IsConfigured(端点 / 密钥 / 桶名是否齐全):
- 配置不齐全时构造函数不抛异常(避免"环境没配好导致宿主起不来");
- 上传入口据此直接给出可读错误,如
RustFS 对象存储配置不完整,请检查 appsettings.json 的 S3 配置节,而不是让底层 SDK 抛异常; - 单文件大小上限由各厂商自己的
MaxSize决定,上传入口统一校验。
八、扩展一个新的存储厂商
实现 IOssProvider 并在工厂登记即可,上传入口无需改动。
第一步:实现接口(ZR.Oss/Providers/XxxOssProvider.cs)
public interface IOssProvider
{
string ProviderName { get; } // 提供程序名称(Aliyun / Tencent / Qiniu / S3 ...)
int MaxSize { get; } // 单文件大小上限(MB),供上传入口统一校验
bool IsConfigured { get; } // 关键配置是否齐全(约定:不齐全时构造不得抛异常)
Task<HttpStatusCode> UploadAsync(Stream stream, string objectKey, string bucketName = "");
Task<HttpStatusCode> DeleteAsync(string objectKey, string bucketName = "");
string GetAccessUrl(string objectKey); // 拼接可访问的完整 URL
}
第二步:在工厂加分支(ZR.Oss/OssProviderFactory.cs)
public IOssProvider Get(StoreType storeType) => storeType switch
{
StoreType.S3 => s3Provider.Value,
StoreType.ALIYUN => aliyunProvider.Value,
StoreType.TENCENT => tencentProvider.Value, // 新增
_ => GetDefault()
};
第三步:配置与入口
- 在
OptionsSetting.cs增加对应配置节; StoreType枚举加取值(int存库,新增取值无需改表结构);- 控制器
CommonController.UploadFile的switch (targetStoreType)增加分支(校验IsConfigured+MaxSize后调SaveFileToOss); - 前端文件管理的存储类型下拉同步加一行(否则用户选不到)。
九、自测清单
按顺序执行,第 3 步不能省:
- 填好
S3节 → 重启宿主 → 后台上传图片,存储类型选「S3 兼容存储」; - 返回的
url在浏览器能预览(不是触发下载 → 说明ContentType正确); - 把对象下载回来比对字节/文件大小(证明未启用分块签名、内容未损坏);
- 对象键含租户前缀:
tenant/{租户id}/yyyy/MMdd/xxx.png; - 文件列表能看到该记录(
storeType=6),并按「S3 兼容存储」筛选得出; - 上传超限文件被拒绝(走
S3.maxSize校验); - 把
S3的endpoint等留空 → 返回「…对象存储配置不完整,请检查 appsettings.json 的 S3 配置节」(可读错误,而非底层异常); - 切回「本地存储」上传 → 仍写
wwwroot且能访问(确认没影响既有链路)。
部署检查:bucket 是否已创建、凭据是否有该桶读写权限、
domainUrl是否真的能匿名打开一个已知对象。
十、常见问题排查
| 现象 | 可能原因 | 处理 |
|---|---|---|
域名无法解析(形如 bucket.192.168.x.x) | forcePathStyle 被改成 false | 改回 true |
| 上传成功但文件打不开 / 内容异常 | 分块签名未关闭,内容被包成 chunked | 确认 UseChunkEncoding=false;不要加 DisablePayloadSigning |
| 报 checksum / CRC32 相关错误 | SDK 默认校验头未关闭 | 确认 RequestChecksumCalculation = WHEN_REQUIRED |
报 When DisablePayloadSigning is true, the request must be sent over HTTPS | 误加了 DisablePayloadSigning = true 而端点是明文 http | 删掉该选项,只保留 UseChunkEncoding = false |
SignatureDoesNotMatch | 密钥错误、region 与签名不符、服务器时间偏差过大 | 核对密钥;自建服务填任意 region;校准时间 |
AccessDenied / 403(直接打开图片时) | ① domainUrl 少了桶名 ② bucket 未开放匿名读 ③ 凭据无桶权限 | 先手动拼带桶名的 URL 试:能出图就是 ①,否则查桶策略与凭据 |
| 图片点开变成下载 | 对象未设置 ContentType(或扩展名不在 MIME 表内) | 已按扩展名推断(约 30 种),新类型可补 ContentTypes 表 |
| 提示「对象存储配置不完整」 | S3 节必填项(endpoint / accessKey / secretKey / bucketName)缺失 | 补全并重启宿主 |
| 选 6 却提示「Aliyun 上传失败 / oss连接失败」 | 宿主仍在跑旧版本(改造前 storeType 不参与实现选择) | 重启宿主,确认 OssProviderFactory 已生效 |
本地存储的图片 URL 少了前缀(如丢了 /prod-api) | Upload.uploadUrl 没有以 / 结尾 | 按第六节改成 https://域名/prod-api/ |
| 小程序端图片不显示,浏览器正常 | 域名未加入微信 downloadFile 合法域名 / 未备案 / 非 HTTPS | 微信后台补白名单;先用 H5 验证链路 |
| 上传大文件报请求体过大 | 超过 Upload.requestLimitSize 或 nginx client_max_body_size | 两处一起调大(注意 Kestrel 层限制) |
日志读法:失败时看日志「
S3 上传失败(…)」括号里的内容即可判断方向——
服务端拒绝(4xx/5xx)→ 对象存储返回了错误,看同行errorCode(如NoSuchBucket、SignatureDoesNotMatch、AccessDenied);客户端异常(无法连接/超时/证书)→ 请求没到服务端,同行会给出异常链(如HttpRequestException: Connection refused);未预期异常→ 需看堆栈。三种情况都会在同一行带上
endpoint / bucket / key与异常链。
