UniApp全端OSS视频上传:STS临时凭证直传与各端兼容实践
1. 项目背景:为什么“全端兼容视频上传”成了绕不过去的坎
先交代一个背景。这两年接手的App项目里,几乎没有一个能绕过视频上传的需求:用户拍一段产品演示、上传一段课程回放、提交一条短视频动态,看起来都是“选个文件、传上去、拿回URL”,但真在UniApp里做一遍,你会发现远没有想象中简单。
UniApp本身的定位是“一套代码,多端发布”,这个特性在做列表页、表单页、扫码页这类业务时优势确实明显,但只要一碰到视频这种“重资源”,问题就全冒出来了。最典型的几个坑:微信小程序里拿不到完整的视频文件路径,只能拿到临时文件;App端用plus.io读取本地文件时路径格式跟H5完全不一样;iOS上从相册选出来的视频,经常带着奇怪的格式或超大的体积,直接甩给服务器会被拒收;更别提上传过程中断网、切后台、进度回调丢失这些体验问题。再加上要把文件稳定地送进阿里云OSS,你还需要处理STS临时凭证、签名、上传策略、回调通知这一整套流程——单端做尚且繁琐,全端兼容的复杂度直接翻倍。
这个方案要解决的,其实就是三件事: 让一套UniApp代码在App(iOS/Android)、H5、微信小程序里都能选视频、传视频、拿回OSS地址;让每个端都走“客户端直传OSS”这条最优路径,避免文件经过业务服务器中转;同时把上传进度、取消、重试、失败补偿这些交互细节补齐,保证真实用户场景下不掉链子。
顺带说一句,不少刚入门的朋友会问:都有uni.uploadFile了,直接传给自己服务器,再由服务器转存OSS不就行了?技术上确实能通,但代价是你得为视频流量额外付一份服务器带宽费,上传速度还受限于单台机器的出口带宽。更合理的做法是客户端拿着OSS临时凭证直传,服务器只负责“发凭证、收回调”,压力小、速度快、成本低,这也是公司里带我的老哥当初反复强调的一点。下面整个方案就围绕这条主线展开。如果你正被“视频上传在某个端上就是传不上去”折磨,或者想把自己的上传模块从功能型做到生产级,这篇文章应该能帮你把思路彻底理清。
2. 整体设计与技术选型:为什么是STS直传,而不是一把梭
开始写代码之前,先把设计层面的几个决策讲清楚。这些决策直接决定了后面你要踩多少坑。
2.1 全端统一用uni.uploadFile,还是分端写原生?
UniApp的一大优势是uni.uploadFile这个API在App、H5、小程序三端都有实现,而且参数大体一致。所以方案的第一步就是确认:非特殊情况,不允许分端写独立上传逻辑。很多人写到一半发现端上行为不一致,就想“要不H5用xhr、小程序用wx.uploadFile、App用plus.uploader各写一套”,这样固然能解决眼前问题,但后续维护成本极高——你等于同时维护了三套代码,改一个公共逻辑要同步三处,上线后出问题还得逐个端排查。从工程角度讲,能用框架统一API解决的问题,就不要为“省事”引入分端实现。
当然,全端统一不代表完全没差异。差异点集中在三块:一是文件路径的获取方式(下面会专门讲),二是请求头的设置方式(小程序里自定义header字段受限),三是上传任务对象的生命周期管理(App端要处理退到后台等场景)。这三块差异我会在第3章和第4章分别说明,它们算是UniApp上传模块“全端兼容”的真正难点。
2.2 为什么客户端直传OSS,而不是服务器中转
视频文件的典型特征是“大”。一段60秒的1080p视频,压缩后普遍在5MB到20MB,不压缩的甚至能到几百MB。如果走服务器中转:
- 业务服务器需要承担上行带宽成本,按阿里云按量计费,每GB流量都是钱;
- 上传速度受限于服务器带宽(通常5Mbps),一个20MB文件可能要等半分钟以上,用户早就烦躁了;
- 服务器在接收文件的过程中,还需要处理并发连接数、磁盘临时文件清理、超时断开后的孤儿文件等问题,纯属给自己找事。
阿里云OSS本身提供了直传方案:客户端直接把文件传到OSS的Bucket,OSS返回URL给客户端,客户端再把URL提交给业务服务器完成“登记”。这个模式下OSS的带宽和吞吐能力被充分利用,速度快且稳定,业务服务器只做两件事:签发上传凭证、接收上传完成后的回调。这种架构也被称为“客户端直传”,是当前视频类App的标准做法。
2.3 为什么不直接拿AccessKey硬编码,要引入STS临时凭证
如果仅仅是想“把文件传到OSS”,理论上可以在客户端代码里写死一组AccessKey Id和AccessKey Secret。但这等于把服务器最高权限的钥匙发给所有人——任何人反编译你的App或抓包你的H5,都能拿到这组密钥,然后想怎么操作你的Bucket就怎么操作,轻则帮你“清空”存储,重则用你的Bucket做私服。这种事故在圈内并不罕见。
所以正经生产项目必须走STS(Security Token Service)。流程是:客户端请求自己的业务服务器,业务服务器使用阿里云RAM用户的AccessKey调用STS接口,换取一个临时凭证(包括临时AccessKeyId、临时AccessKeySecret和SecurityToken),再把凭证返回给客户端。客户端拿着临时凭证去直传OSS。这个临时凭证的特点是: 有有效期(可设最短15分钟,最长几小时)、权限可控(只允许上传到指定Bucket的指定目录)、用完即弃 。即使被泄露,风险窗口很小,而且权限被限定在“上传文件”这个最小范围,不会殃及Bucket里已有文件的管理操作。
我在项目里一般把有效期设置为30分钟——太短用户还没选完视频凭证就过期了,太长又扩大泄露风险。30分钟覆盖从选视频、裁剪、压缩到上传完成的正常操作节奏,比较合适。如果用户操作超时,前端要捕获凭证过期错误,引导用户重新获取凭证再传,这个逻辑后面会在错误处理里具体讲。
2.4 服务端签发的具体内容
业务服务器的STS签发接口,返回给前端的数据结构大概是这样的:
{
"accessKeyId": "STS.xxxxxxxx",
"accessKeySecret": "xxxxxxxxxxxx",
"securityToken": "xxxxxxxxxxxx",
"expiration": "2025-01-01T00:00:00Z",
"bucket": "your-app-video",
"region": "oss-cn-hangzhou",
"objectKeyPrefix": "video/2025/01/01/"
}
其中
accessKeySecret
和
securityToken
是临时凭证的核心;
bucket
和
region
告诉前端往哪个区域的哪个Bucket传;
objectKeyPrefix
是服务端规定的前缀目录,比如
video/2025/01/01/
,前端在生成最终文件名时拼在后面。这个前缀的设计能起到“隔离业务”的作用——不同业务线的视频分目录存放,后续做生命周期管理(比如自动清理30天前的临时视频)会很方便。
提示:千万不要在服务端把临时凭证的权限设成
oss:*,应该精确到oss:PutObject和oss:PostObject,资源范围限定到acs:oss:*:*:bucketName/video/*。这个最小权限原则,既能满足业务需要,又能避免因凭证泄露导致更大的损失。
3. 基础设施搭建:从视频选择到上传凭证的完整链路
这一章我们实际动手。假设你已经有一个UniApp项目,阿里云OSS Bucket也已创建好。这一步咱们把基础设施逐一打通。
3.1 在manifest.json里配置好权限和SDK
首先检查manifest.json的应用权限声明。视频上传相关的核心权限有两个:相册读取权限和相机权限(如果支持拍摄)。
在App端,往
App模块配置
里找到“相机和相册”相关配置;在小程序端,需要在
mp-weixin
节点下配置
permission
字段,声明
scope.album
和
scope.camera
的用途说明。H5端不需要特殊权限,但如果是HTTPS页面,要确保上传目标地址(OSS的Endpoint)支持HTTPS,否则浏览器会拦截混合内容。
一个容易被忽略的点:微信小程序在
manifest.json
里的
mp-weixin
节点下,要显式加上
"requiredPrivateInfos": ["chooseMedia"]
(用旧版
chooseImage
选视频时可能不需要,但新版
chooseMedia
需要)。不加的话,在开发者工具里可能正常,真机上调用会直接报错。
3.2 封装“获取STS临时凭证”的统一函数
在项目的
utils/oss.js
里,写一个获取临时凭证的函数。这个函数会被所有端复用:
// utils/oss.js
const BASE_URL = 'https://api.yourdomain.com'; // 你的业务服务器地址
export function getStsToken() {
return new Promise((resolve, reject) => {
uni.request({
url: BASE_URL + '/api/oss/sts',
method: 'POST',
data: {},
success: (res) => {
if (res.statusCode === 200 && res.data.code === 0) {
resolve(res.data.data);
} else {
reject(new Error('获取STS凭证失败:' + JSON.stringify(res.data)));
}
},
fail: (err) => {
reject(new Error('网络请求失败:' + JSON.stringify(err)));
}
});
});
}
注意这里
res.statusCode
的判断不能省。很多人习惯直接判断
res.data.code
,但在某些端上网络层错误(比如域名没配白名单)会先触发
fail
,在另一些端上会返回非200的
statusCode
。两层判断都写,出问题时信息才够全。
3.3 选择视频并获取本地文件路径
这是全端兼容最容易“翻车”的环节,各端的返回结构差异比较大。
H5端
:通常用
uni.chooseVideo
或
uni.chooseMedia
,拿到的文件对象里有
tempFilePath
(Blob URL)和
size
字段。但H5端有个特殊点:
uni.uploadFile
内部会把Blob URL转换后上传,所以不需要额外处理。
App端
:同样用
uni.chooseVideo
,但返回的
tempFilePath
可能是一个以
file://
开头的本地路径,也可能是一个
_doc/uniapp_temp_xxx/xxx.mp4
这样的相对路径。上传前建议转成绝对路径:
plus.io.convertLocalFileSystemURL
,或者直接用
tempFilePath
试传——大多数情况下
uni.uploadFile
能自己处理。如果遇到路径解析失败,再考虑手动转。
微信小程序端
:新版基础库推荐用
wx.chooseMedia
,在UniApp里通过
uni.chooseMedia
来调用。返回结构是
res.tempFiles
数组,每个元素里有
tempFilePath
、
size
、
duration
等字段。严格说这个
tempFilePath
是个临时文件,只在本次App生命周期内有效,所以必须尽快上传,不能存起来下次用。
我建议封装一个统一的选择函数,抹平端差异:
// utils/videoPicker.js
export function chooseVideo() {
return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
uni.chooseMedia({
count: 1,
mediaType: ['video'],
sourceType: ['album', 'camera'],
maxDuration: 60,
success: (res) => {
const file = res.tempFiles[0];
resolve({
tempFilePath: file.tempFilePath,
duration: file.duration,
size: file.size,
width: file.width,
height: file.height
});
},
fail: reject
});
// #endif
// #ifndef MP-WEIXIN
uni.chooseVideo({
sourceType: ['album', 'camera'],
maxDuration: 60,
success: (res) => {
resolve({
tempFilePath: res.tempFilePath,
duration: res.duration,
size: res.size,
width: res.width,
height: res.height,
thumbTempFilePath: res.thumbTempFilePath
});
},
fail: reject
});
// #endif
});
}
重点解释为什么小程序端用
uni.chooseMedia
而不是
uni.chooseVideo
:微信官方从基础库2.10.0开始主推
chooseMedia
,它支持同时选择图片和视频,返回信息更完整,而且能拿到
duration
字段(视频时长),这在后续做时长校验时非常有用。如果你的项目还在用很老的基础库,才需要退回
chooseVideo
。
3.4 计算文件名和对象Key
文件名最好不要用用户原始文件名直接上传,否则会带来两个问题:一是中文名或特殊字符在URL里要转义,二是不同用户上传同名文件会互相覆盖。推荐做法是服务端生成或前端拼接一个“时间戳+随机串+扩展名”的文件名:
function generateObjectKey(fileName, ext) {
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
const randomStr = Math.random().toString(36).slice(2, 10);
// ext 需要处理:xxx.mp4 -> mp4
const safeExt = (ext || 'mp4').replace('.', '').toLowerCase();
return `${dateStr}/${randomStr}.${safeExt}`;
}
注意:如果服务端返回了
objectKeyPrefix
,最终的对象Key应该是
objectKeyPrefix + generatedKey
。如果你的STS权限只允许上传到
video/*
目录,那对象Key必须以
video/
开头,否则服务端即使签发了凭证,OSS也会拒绝写入。
4. 核心实操:封装一个全端可用的OSS直传上传器
这一章是本方案的核心,也是整个工程里投入时间最多的地方。我会把完整代码拆成几块讲清楚,每块对应一个关键环节。
4.1 构造一个“传OSS专用”的uni.uploadFile请求
先给出一个核心版本的上传函数,它会接收第3章获取到的临时凭证和本地文件路径,然后往OSS发起直传:
// utils/ossUploader.js
import { getStsToken } from './oss.js';
import { chooseVideo } from './videoPicker.js';
function getFileExt(path) {
// 从路径里取扩展名
const match = path.match(/\.(\w+)$/);
return match ? match[1].toLowerCase() : 'mp4';
}
function getOssHost(bucket, region) {
// 例如:https://your-app-video.oss-cn-hangzhou.aliyuncs.com
return `https://${bucket}.${region}.aliyuncs.com`;
}
export function uploadVideoToOss(options) {
const {
filePath, // 本地视频文件路径
sts, // { accessKeyId, accessKeySecret, securityToken, bucket, region, objectKeyPrefix }
objectKey, // 对象Key
onProgress, // 回调:进度
} = options;
const uploadUrl = getOssHost(sts.bucket, sts.region);
return new Promise((resolve, reject) => {
const uploadTask = uni.uploadFile({
url: uploadUrl,
filePath: filePath,
name: 'file',
formData: {
key: objectKey,
policy: sts.policy, // 如果有policy字段就带上,一般由服务端STS返回
OSSAccessKeyId: sts.accessKeyId,
signature: sts.signature, // 如果走签名URL直传(非STS方式),需要这个字段
'x-oss-security-token': sts.securityToken
},
success: (res) => {
if (res.statusCode === 200 || res.statusCode === 204) {
resolve({
url: `https://${sts.bucket}.${sts.region}.aliyuncs.com/${objectKey}`,
objectKey: objectKey
});
} else {
reject(new Error('OSS上传返回错误:' + res.statusCode + ' ' + res.data));
}
},
fail: (err) => {
reject(new Error('OSS上传请求失败:' + JSON.stringify(err)));
}
});
if (uploadTask && typeof uploadTask.onProgressUpdate === 'function') {
uploadTask.onProgressUpdate((res) => {
if (typeof onProgress === 'function') {
onProgress(res.progress, res.totalBytesExpectedToSend);
}
});
}
});
}
这个函数有几个细节要专门说明。
第一,
name: 'file'
是阿里云OSS规定的表单字段名吗?严格说不是。OSS的PostObject表单上传要求文件字段名是
file
。但实测中,把文件字段名设成其他值偶尔也能成功——那只是因为阿里云的兼容处理。为了避免奇奇怪怪的问题,统一用
file
最稳。
第二,
formData
里的字段是OSS PostObject必须的:
key
是最终对象名,
policy
和
signature
是签名信息,
x-oss-security-token
是STS临时凭证的token。如果你走的是“直接传整个临时凭证+签名”的方式,那
policy
和
signature
通常不需要
securityToken
,但既然项目用了更安全的STS,那
x-oss-security-token
这一步不能少。补充一下:
如果你在业务服务器上采用“STS方式下发签名”,服务端返回的通常不是
policy
+
signature
,而是
accessKeyId
/
accessKeySecret
/
securityToken
三元组。此时OSS服务器在收到上传请求时,是依靠文件表单里的
OSSAccessKeyId
和
x-oss-security-token
来识别身份的,而不是
signature
。
这一点初学者特别容易搞混——以为STS凭证也要像长期AccessKey那样做Base64签名,其实不需要。
如果服务端下发的是
policy
和
signature
(PostObject签名直传方式),那么前端逻辑又不一样。在项目实践中,
STS临时凭证直传
是更通用的方式:客户端功能只依赖
accessKeySecret
和
securityToken
,不用管签名算法;服务端签发逻辑也更清晰。所以下面的完整示例统一走STS。
为了兼容两种后端实现,我在示例代码里保留了
policy
和
signature
字段的注释,供需要时可以查漏补缺。
4.2 把“选择+上传”串成一条丝滑的链路
选择视频后,用户可能等很久才点“上传”,所以凭证不应该在选择视频时才去获取,而应该在点击“选择并上传”按钮后立刻获取——这样可以最大程度减少凭证过期的概率。流程是:
- 用户点击“上传视频”按钮;
- 前端获取STS临时凭证(30分钟有效期);
- 调起系统相册/相机,让用户选择视频;
- 校验视频大小、时长(超限则提示);
- 构造对象Key;
-
调用
uni.uploadFile直传OSS,展示进度条; - 上传成功,拿回URL,调业务接口完成“视频与业务数据绑定”;
- 上传失败,根据错误码给用户可执行的提示。
我把这段串联逻辑写成独立函数。注意以下代码中错误提示的设计,每条错误都尽量给出用户能理解的文案,而不是笼统地“上传失败”:
// utils/videoFlow.js
import { chooseVideo } from './videoPicker.js';
import { getStsToken } from './oss.js';
import { uploadVideoToOss } from './ossUploader.js';
function checkVideo({ size, duration }) {
// 常见的限制:最大200MB、最长5分钟
// 这里只是示例,你可以结合实际情况修改
const MAX_SIZE = 200 * 1024 * 1024;
const MAX_DURATION = 300;
if (size > MAX_SIZE) {
return { valid: false, msg: '视频不能超过200MB' };
}
if (duration && duration > MAX_DURATION) {
return { valid: false, msg: '视频时长不能超过5分钟' };
}
return { valid: true };
}
export async function selectAndUploadVideo({ onProgress }) {
try {
// 1. 先获取STS凭证(这个可能耗时几百毫秒,最好配合loading动画)
const sts = await getStsToken();
// 2. 选择视频
const video = await chooseVideo();
const checkResult = checkVideo(video);
if (!checkResult.valid) {
uni.showToast({ title: checkResult.msg, icon: 'none' });
return null;
}
// 3. 生成对象Key
const ext = getFileExt(video.tempFilePath);
const objectKey = ... // 拼接:sts.objectKeyPrefix + generateObjectKey(...)
// 4. 直传OSS
const result = await uploadVideoToOss({
filePath: video.tempFilePath,
sts,
objectKey,
onProgress
});
return result;
} catch (e) {
// 这里要做统一错误处理。如果错误消息里包含“InvalidAccessKeyId”或“SecurityTokenExpired”,
// 大概率是凭证过期,提示用户重试;如果包含“AccessDenied”,可能是权限配置有问题。
throw e;
}
}
在第3步生成
objectKey
时,要留个心眼:服务端可能返回前缀
video/2025/01/01/
,你拿到的扩展名可能是
mov
、
mp4
、
avi
等。对于
mov
这种格式,我建议在上传前提醒用户“当前格式可能兼容性不佳”,但不强制拦截——总比一刀切不让传要好。
4.3 App端上传进度与“退后台”的特殊处理
在App端,
uni.uploadFile
返回的
uploadTask
对象上挂有
onProgressUpdate
,这个在前面代码里已经用到了。如果你做过原生开发,可能会想到“App进后台后,上传会不会被挂起?”——实测下来,在iOS上如果App被用户主动上滑杀掉,网络请求会中断,这是系统级行为,前端代码无法拦截。但在Android上,如果只是“Home键退到后台”,没有杀死进程,很多机型的上传任务会继续跑,只是UI暂停刷新。
所以工程上更稳妥的做法是:把“上传进度”和“上传状态”存入全局状态(比如Vuex或Pinia),而不是组件内的局部变量。这样用户从后台切回来时,组件重新
onShow
,能从全局状态里读取当前进度,恢复进度条显示。
另外要提醒一点:App端
onProgressUpdate
返回的
res.progress
已经是百分比整数,不需要你手动再算。但小程序端
progress
在部分低版本基础库上可能缺失,稳妥的做法是拿
res.totalBytesExpectedToSend
和
res.totalBytesSent
自己算一遍:
if (typeof res.progress === 'number') {
onProgress && onProgress(res.progress);
} else if (res.totalBytesExpectedToSend > 0) {
const percent = Math.floor((res.totalBytesSent / res.totalBytesExpectedToSend) * 100);
onProgress && onProgress(percent);
}
4.4 H5端上传的额外处理:避免“预检失败”和“跨域拦截”
H5端的直传OSS有一个特殊问题:浏览器在跨域POST请求前,会自动发起一个
OPTIONS
预检请求。OSS若没配置好CORS规则,预检失败,
uni.uploadFile
会在
fail
里返回一个看不懂的“Network Error”。这个问题的排查顺序是:
-
登录OSS控制台,检查Bucket的“权限管理 -> 跨域设置”里是否允许来自你前端域名的
OPTIONS请求; -
允许的方法至少包含
POST和PUT;Allowed Headers至少包含*(因为我们会带x-oss-security-token这个自定义头);Expose Headers建议加上ETag; -
如果你是在本地开发(比如
http://localhost:8080),CORS里也要把这个源加进去,否则本地调试永远失败。
还有一个小坑:有些浏览器对上传请求会做“内容类型检测”,如果你在
formData
里传的内容被浏览器识别为“非简单请求”,会要求服务器允许预检。OSS的CORS配置正确就能通过。如果配置正确但仍然报错,打开开发者工具Network面板,看
OPTIONS
请求的响应是否为200;如果响应非200,基本可以断定是CORS配置问题,把响应的错误信息贴给运维或自己进OSS后台检查即可。
5. 各端踩坑实录:从真机到小程序,问题比想象中多
第二章到第四章把主干逻辑基本写完了,但真实项目上线前,“恶魔都在细节里”。这一章把我在开发过程中遇到的典型问题整理成速查表,并给出解决思路。
5.1 问题速查表
| 问题现象 | 涉及端 | 可能原因 | 解决思路 |
|---|---|---|---|
| 上传返回403 AccessDenied | 所有端 | STS权限策略没限定到该Bucket或目录 |
检查服务端STS Policy的资源路径前缀与
objectKey
是否匹配
|
| 请求直接fail,无状态码 | H5 | CORS跨域预检失败 | OSS控制台补跨域规则,允许自定义Header |
| 上传成功但业务服务器收不到回调 | 所有端 | OSS没有配置消息通知,或回调地址错误 | 在OSS控制台配置“上传回调”或业务侧轮询 |
| 视频选完直接崩溃 | App-Android | 极少数机型无法处理超长视频的采样 | 选择前限制时长,或后端做转码兜底 |
| 微信小程序选视频报错:“chooseMedia:fail api scope is not declared” | 微信小程序 |
manifest里未声明
requiredPrivateInfos
|
在
mp-weixin
节点声明
requiredPrivateInfos: ["chooseMedia"]
|
| 上传进度条卡在99% | 所有端 | 所有数据都传完了,但服务端响应还没返回 | 这是正常现象,多数情况下最终会成功;超过阈值可做超时提示 |
| App退后台再回来,进度丢失 | App | 没有把上传状态持久化 |
改用全局状态管理,或在
onShow
时恢复
|
5.2 微信小程序真机测试里的两个隐藏坑
隐藏坑一:临时路径失效
小程序里
uni.chooseMedia
返回的
tempFilePath
只在本次会话内有效。如果用户选完视频切到后台,过了几分钟再回来点上传,这个路径很可能已经变成无效文件。遇到这种情况,重新执行一次选择即可,或者在上传前检查文件是否存在(小程序里没有同步的
fs.access
,只能try-catch一下
FileSystemManager
的API)。
隐藏坑二:视频过大的编译警告
微信开发者工具有时会对超过一定体积的素材文件进行提示,但那是指你
打包进代码包
里的素材,不是用户运行时上传的文件。别因为开发者工具报了一个“包体积超过2MB”的警告,就以为“视频不能传了”——这完全是两码事。真正影响小程序端上传的是“临时文件存储空间限制”,微信会给每个小程序分配一定的本地临时文件空间,如果用户不断地选大视频、传失败、再选,临时文件堆积过多,可能出现空间不足导致无法写入新文件。此时可以调
uni.getFileSystemManager
清理之前上传失败遗留的临时文件。
5.3 App端视频格式兼容:MOV、AVI这种“边录边传”的怪东西
从App相册选出来的视频,常见的格式是
mp4
或
mov
。其中
mov
是iOS原生相机经常输出的格式,它的编码通常是H.264/AAC,容器结构不同于mp4但码流本身是一致的。iOS端上传
mov
到OSS再在网页端用
<video>
播放,有时会播放失败——因为部分浏览器对
mov
容器的支持并不好。
这时两条路:
-
客户端在上传前先做一次转封装(remux),把
mov封装成mp4。UniApp原生层可以借助插件市场里的“视频处理”插件实现,但注意转封装也需要时间,最好有进度提示; -
服务端收到上传回调后,触发一次异步转码,把
mov统一转成HLS或MP4。阿里云自带媒体处理服务,配置一个转码任务即可。视频最终面向的是网页端用户,我推荐方案2,因为它把兼容性处理集中在服务端,客户端代码不会越写越重。
如果你只是做内部工具型App,不要求所有平台都能预览,那上传
mov
也不会有太大问题——关键是提前明确“最终消费视频的场景是哪些”,再决定要不要加转码这层。
5.4 关于
renderjs
或
web-view
里播放OSS视频的提示
标题里提到的热词里有一条“uniapp renderjs 手机录得mp4无法播放”,这说明不少朋友传完视频后,在renderjs或web-view里播放遇到了问题。这往往不是上传模块本身的锅,而是视频的编码格式、分辨率或HTTP头不兼容导致的。我遇到过的情况有:
- 视频编码是H.265/HEVC,而浏览器或renderjs里的播放器不支持;
-
OSS响应头里缺少
Content-Type: video/mp4(上传时如果未显式指定,OSS可能默认application/octet-stream); - 视频的moov元数据在文件尾部,导致在弱网下无法快速起播(需要“边下边播”时,moov应该在文件头部)。
前两点可以在OSS控制台或上传时设置
Content-Type
来修正;第三点可以通过服务端转码或使用
ffmpeg
做
-movflags +faststart
处理。上传只是第一步,播放链路里的这些细节其实更影响最终用户体验。
6. 进阶:取消、重试、秒传与队列上传
前面的内容已经能让一个“单视频上传”模块跑起来了。但如果你的业务需要支持“一次传多个视频”或者“断网后自动重试”,还有几个进阶问题值得展开。
6.1 取消上传
uni.uploadFile
返回的任务对象上有
abort()
方法,可以中断上传。在UI层,给进度条旁边放一个“取消”按钮即可。需要注意的是,中断上传后,OSS端已经接收的部分数据会被丢弃,不会产生完整文件——所以不会有“传了一半的脏文件残留”问题。但有些时候OSS会生成一个0字节的空对象(取决于服务端的PostObject策略)。
如果希望“取消时连空对象也清理掉”,可以在业务服务器上提供一个删除接口,前端在
abort()
后调用一次删除;但一般场景下,0字节文件不占用多少空间,影响不大,不必为了它额外增加接口调用。
6.2 断网重试
说实话,客户端直传OSS的断点续传,UniApp官方API并不直接支持。
uni.uploadFile
本身是一次性的请求,若网络中断,只能捕获失败后整文件重新上传。如果你做的是短视频工具类App、文件经常超过100MB,那强烈建议不要用原生的
uni.uploadFile
去啃大文件,而是换成“分片上传 + 断点续传”的方案:
- 客户端把文件切成固定大小(例如4MB或8MB)的分片;
-
逐个分片通过
uni.request上传到自己的业务服务器,再由服务器子线程转存OSS; - 或者通过OSS的Multipart Upload接口(需自行构造签名),逐个Part直传。
但UniApp官方API不支持直接操作OSS的分片上传接口。所以更现实的路径是:
中小视频走
uni.uploadFile
直传(本项目的默认方案),超大视频走服务端转存或分片上传。
大多数业务场景下,用户上传的视频在30秒到3分钟之间,压缩后普遍不超过50MB,直接用一次性上传已经够用。别为了“追求极限性能”把架构搞复杂,先判断业务是否真需要分片。
6.3 秒传逻辑
“秒传”的本质是文件哈希匹配:需要上传时,先计算文件的MD5(或SHA1),拿哈希去业务服务器查询OSS里是否存在相同文件。如果存在,直接复用已有的URL,不执行真正的上传。
在UniApp的小程序端,计算文件的MD5并不方便,基础库没有直接提供MD5接口,只能通过
FileSystemManager
读取文件内容,再引入一个MD5库来计算。对于几十MB的视频,在真机上计算MD5可能需要数秒到十几秒,所以“秒传”适合做,但不要对计算时长抱太大期望——用户会觉得按钮点了没反应。
折中方案是: 小视频(小于5MB)算MD5走秒传,大视频直接走上传。 这样既能省流量,又不让用户等待太久的计算过程。
7. 安全和性能优化:让方案经得起生产环境考验
最后这章聊的不再是“能不能用”,而是“能不能放心上生产”。以下优化点都是我实际经历过的场景,值得在开发阶段就提前设计进去。
7.1 防盗链与私有读
视频文件如果是公开可读的,任何人拿到URL都能看。很多项目早期为了省事,把Bucket设成“公共读”,结果URL一旦泄露,视频等于裸奔。更安全的做法是“私有读 + 业务侧签名URL”,即OSS文件不允许匿名访问,每次前端要播放视频时,由服务器生成一个带有效期的访问URL(比如10分钟有效),前端拿这个URL去播放。
在UniApp的
<video>
组件里,
src
可以直接使用这个带签名的URL。需要留意的是:播放器可能会因为视频跳转、Range请求等原因为同一个URL发起多次请求,签名URL的有效期一定要覆盖整个播放时长预期。建议有效期设置15分钟到半小时。如果播放中断提示“签名过期”,多半是有效期设短了。
7.2 上传时压缩与转码的取舍
要不要在客户端做压缩?这取决于产品定位。如果你的用户上传的是“用户随手拍的短视频”,原视频可能1080p甚至4K,动不动几百MB,直接传OSS不仅慢,还浪费存储和CDN流量。可以考虑在客户端引入FFmpeg的wasm版或原生插件做压缩,但UniApp生态里压缩插件的稳定性参差不齐。
我的建议: 第一版先不做客户端压缩,靠服务端限制上传体积(比如最大200MB)来兜底,后续根据用户反馈和存储成本再决定是否引入压缩。 这样上线速度快,逻辑简单,不会因为压缩过程引入新的崩溃和卡顿问题。等数据积累到一定程度,再在服务端针对超大视频做转码和压缩处理——毕竟服务端的计算资源更可控、更容易扩展。
7.3 上传接口鉴权防刷
客户端直传OSS虽然叫“直传”,但并不意味着任何人都可以随便往你的Bucket里传文件。你必须在业务服务器的STS签发接口上做好用户鉴权——接口要校验当前用户是否登录、是否有上传权限、是否超出每日配额。否则,别有用心的人可以刷你的接口获取临时凭证,然后往你的Bucket里塞垃圾文件,塞满后产生大量存储费用。
我在生产项目里还会加两个限制:
- 限制上传次数 :比如一个用户一天最多上传50次,防止脚本循环刷;
-
限制文件大小
:服务端签发的POST Policy里带上最小/最大文件大小限制(
content-length-range),这样即使客户端被破解、绕过前端校验,OSS也可能拒绝超大文件。
这两点都加完后,基本可以防住绝大多数恶意刷量场景。
7.4 埋点与监控:别等用户投诉了才知道上传挂了
最后讲一个体验层面常被忽略的事——监控。视频上传涉及链路较长,任何一个环节出问题用户都会觉得“App坏了”。我的习惯是,在以下几个关键节点上报打点:
- 获取STS成功/失败(失败率异常时,优先查服务端接口);
- 用户点了上传但迟迟没选文件(可能卡在相册授权对话框);
-
OSS上传
start、success、fail; - 上传耗时分段(选文件耗时、上传耗时、业务绑定耗时)。
用uni.report或自己接的埋点SDK上报到后台,配合告警,能在用户大面积投诉前提早发现问题。尤其是
success
率,如果某天突然从98%掉到80%,你打开埋点后台一查,往往能直接定位到是某个端、某个OSS区域、某种文件格式出问题,而不是像无头苍蝇一样问用户“你那边是什么手机什么网络”。这一套做下来,才真正算得上“生产级的全端上传方案”。
总的来说,UniApp全端兼容OSS视频上传,技术上并不存在“一种API打天下”的银弹。每个端的差异、每个平台的限制、每种网络环境的异常,都需要在实际开发中不断踩坑、记录、补丁。我这里分享的STS直传、路径获取、错误处理、CORS配置这些内容,既是方案的骨架,也是上线后运维的地基。你按章节一步步落地,大概率能少走很多弯路。
就我个人目前的使用体验来说,这套方案帮我解决了之前那个“每换一个端就要重写一遍上传逻辑”的窘境。最后再留下一个小建议:不管你现在是刚起步还是已经写到一半,都建议把“OSS上传”封装成一个独立于业务页面的模块,参数尽量精简,错误尽量语义化,这样后续任何页面需要传视频时,都只是调用一个函数的事,而不是再把上传代码从老页面里复制粘贴一遍。真正好的基础设施,就是业务开发的人完全感受不到它的存在,而你心里清楚它一直在稳稳地兜底。
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)