122 lines
4.1 KiB
Markdown
122 lines
4.1 KiB
Markdown
# 【Demo】OSS 直传(客户端签名 + PUT 上传)
|
||
|
||
> 源文件:`lib/pages/demo/oss_upload_demo.dart` | 分类:网络
|
||
> 一句话:选图 → 后端分配路径 → 拿 STS → V4 签名 → PUT 直传 OSS,全程带进度。
|
||
|
||
---
|
||
|
||
## 一、这个 Demo 解决什么问题
|
||
|
||
图片上传不走"客户端 → 业务服务器 → OSS"的转发链路(浪费带宽、服务器压力大),
|
||
而是 **STS 临时凭证 + 客户端直传 OSS**:后端只负责分配路径和签发凭证,图片字节直接传阿里云。
|
||
|
||
### 完整链路(与 UniApp ossUpload.js 对照)
|
||
```
|
||
选图 → 后端分配路径(GetXxxOSSUploadUrl)
|
||
→ 拿 STS 凭证(GetAliOSSSetting)
|
||
→ V4 签名
|
||
→ PUT 直传 OSS(带进度)
|
||
→ 返回 CDN URL
|
||
```
|
||
|
||
---
|
||
|
||
## 二、怎么运行 / 演示内容
|
||
|
||
```dart
|
||
runApp(const MaterialApp(home: OssUploadDemoPage()));
|
||
```
|
||
|
||
1. 选择上传场景:头像 / 笔记 / AI判题(`UploadScene` 枚举,对应后端 mode);
|
||
2. "选择图片"(走 PickerService,见 [note_picker_demo.md](note_picker_demo.md));
|
||
3. "上传到 OSS":读字节 → 4 步链路 → 显示 CDN URL + 进度。
|
||
|
||
> ⚠ 需要后端 OSS 接口可访问(本地/测试环境)。
|
||
|
||
---
|
||
|
||
## 三、核心知识点(新手向)
|
||
|
||
### 1. 双端读取图片字节(不用 dart:io!)
|
||
```dart
|
||
// web :image_picker 已读好字节
|
||
// 原生 :photo_manager 的 AssetEntity.originBytes(纯 Dart API)
|
||
final Uint8List bytes = image.bytes ??
|
||
await image.entity?.originBytes ??
|
||
Uint8List(0);
|
||
if (bytes.isEmpty) { /* 提示重新选择 */ }
|
||
```
|
||
**关键:页面不 import `dart:io`,Web 才能编译**(dart:io 在 Web 是禁用的)。
|
||
双端都能拿字节 → 上传逻辑统一。
|
||
|
||
### 2. 上传入口(OssUploader 封装)
|
||
```dart
|
||
final url = await OssUploader.uploadImage(
|
||
bytes: bytes,
|
||
fileName: fileName,
|
||
scene: _scene, // UploadScene 枚举:头像/笔记/AI判题
|
||
onSendProgress: (sent, total) {
|
||
if (total > 0) {
|
||
setState(() => _progress = sent / total); // total<=0 兜底
|
||
}
|
||
},
|
||
);
|
||
```
|
||
|
||
### 3. 403 的含义
|
||
**403 = 签名或 STS 有问题** —— 看控制台 LogInterceptor 打印的响应体定位:
|
||
- AccessDenied:签名计算不一致(method/path/时间戳对不上);
|
||
- InvalidAccessKeyId / ExpiredToken:STS 凭证失效;
|
||
- SignatureDoesNotMatch:V4 签名算法细节错。
|
||
|
||
---
|
||
|
||
## 四、关键代码剖析
|
||
|
||
```dart
|
||
// 场景名映射(switch 表达式,Dart 3)
|
||
static String _sceneName(UploadScene s) => switch (s) {
|
||
UploadScene.avatar => '头像',
|
||
UploadScene.note => '笔记',
|
||
UploadScene.aiGrading => 'AI判题',
|
||
};
|
||
|
||
// 场景选择(上传中禁用切换)
|
||
Wrap(
|
||
spacing: 10.w,
|
||
children: UploadScene.values
|
||
.map((s) => ChoiceChip(
|
||
label: Text(_sceneName(s)),
|
||
selected: _scene == s,
|
||
onSelected: _uploading ? null : (_) => setState(() => _scene = s),
|
||
))
|
||
.toList(),
|
||
)
|
||
|
||
// 上传按钮(没选图或上传中 → 禁用)
|
||
FilledButton.icon(
|
||
onPressed: (_image == null || _uploading) ? null : _upload,
|
||
icon: const Icon(Icons.cloud_upload_outlined),
|
||
label: Text(_uploading ? '上传中...' : '上传到 OSS'),
|
||
)
|
||
```
|
||
|
||
---
|
||
|
||
## 五、踩坑记录
|
||
|
||
1. **别用 `Image.file` 双端判断** —— dart:io 在 Web 编译不过,统一走 bytes;
|
||
2. **403 优先查签名**:method/URL 编码/时间格式与后端约定不一致是头号原因;
|
||
3. `total <= 0` 时进度要兜底(和 dio_pro_demo 同一个坑);
|
||
4. 上传中要禁用场景切换和重复点击(`_uploading` 统一控制);
|
||
5. 文件名生成:`image.name ?? 'image_时间戳.jpg'`,避免空名。
|
||
|
||
---
|
||
|
||
## 六、扩展思考
|
||
|
||
- **防盗链/私有读**:OSS 私有 Bucket 的 URL 需要签名,展示时再向后端换"临时公开 URL";
|
||
- 断点续传/分片:大文件(视频)用 OSS 分片上传 API,`uploadFile` 封装在 OssUploader 里扩展;
|
||
- 与 [dio_pro_demo.md](dio_pro_demo.md) 对照:PUT 直传的进度就是 `onSendProgress` 的应用;
|
||
- 笔记图片多图上传:先全部直传拿 URL,再一次性提交给"创建笔记"接口(见 note_picker_demo 的 TODO 注释)。
|