基于阿里云视频点播与 video.js 构建流媒体处理与播放链路,包含浏览器切片直传与状态轮询、服务端签名与资源防越权核验、多档 HLS 自适应播放与动态换签改写,以及废弃媒资清理与 HLS 标准加密演进路径。
集成分成五层。路由只解析请求并调用服务;服务做门禁、归属核对和清理编排;适配层只访问阿里云 OpenAPI;浏览器负责直传和播放。控制台配置不进代码。
POST 接口,其余方法返回 405。未配置、处理中与不可用通过 HTTP 200 返回,参数与权限错误返回 4xx / 502。aliyun-vod-upload-sdk 直传视频文件;使用 video.js 8 播放自适应 HLS 并维持行为打点。视频记录保存在已有实体的 JSON 列中,不为点播单独建表:
| 字段 | 含义 |
|---|---|
| videoId | 点播视频标识。空记录不写该字段 |
| fileName | 原始文件名,用于展示,最长 255 字符 |
| durationSeconds | 云端就绪后四舍五入得到的视频时长秒数 |
一次完整的上传至播放数据流向:
CreateUploadVideo,并注入资源归属标签 res-${resourceId}。RefreshUploadVideo 刷新。POST /api/aliyun-vod/video-status,服务端转调 GetVideoInfo,直到视频就绪或转码失败(最长轮询 30 分钟)。GetPlayInfo 返回各档 m3u8 地址。播放器动态拼装总列表播放,并在地址过期前静默换签。接口定义:
| 路径 | 作用 |
|---|---|
| POST /api/aliyun-vod/upload-auth | 签发上传凭证 |
| POST /api/aliyun-vod/refresh-upload-auth | 刷新上传凭证 |
| POST /api/aliyun-vod/video-status | 查询并归并转码状态 |
| POST /api/aliyun-vod/play-url | 按记录签发播放地址 |
| POST /api/aliyun-vod/delete-video | 删除尚未入库的视频 |
官方接口说明:CreateUploadVideo、RefreshUploadVideo、GetVideoInfo、GetPlayInfo、DeleteVideo。
区域使用华东 2(上海,cn-shanghai)。点播加速域名与站点主域名保持独立(如 vod.example.com),并配置 HTTPS 证书,防止被浏览器当作混合内容拦截。
URL 鉴权使用方式 A,默认有效时长 10 分钟。GetPlayInfo 不传 AuthTimeout,播放地址有效期遵循控制台配置。方式 A 查询串形如 auth_key=timestamp-rand-uid-md5hash。主密钥仅保存在控制台,业务服务器不存储也不计算该签名。说明见 鉴权方式 A 与 配置 URL 鉴权。
Referer 防盗链使用白名单且不允许空 Referer。白名单匹配子域名且忽略端口(如 example.com 可匹配 foo.example.com 及任意端口)。空 Referer 或名单外请求返回 403,响应头为 X-Tengine-Error: denied by Referer ACL 且不返回跨域头。配置见 Referer 防盗链。
跨域响应头必须配置在加速域名的「缓存配置 → 节点 HTTP 响应头」并开启跨域验证(不能配在回源响应头)。Access-Control-Allow-Origin 精确到协议、主机与端口:
http://localhost:5173,http://localhost:3000,https://example.com,https://www.example.com转码模板组输出三档未加密 HLS:标清 848×478、高清 1280×720、超清 1920×1080。模板组 ID 写入环境变量;留空时请求不传 TemplateGroupId,回落至账号默认组。
RAM 子账号仅授予最小化权限:
{
"Version": "1",
"Statement": [
{
"Effect": "Allow",
"Action": [
"vod:CreateUploadVideo",
"vod:RefreshUploadVideo",
"vod:GetVideoInfo",
"vod:GetPlayInfo",
"vod:DeleteVideo"
],
"Resource": "*"
}
]
}环境变量与依赖:
ALIYUN_VOD_ACCESS_KEY_ID=""
ALIYUN_VOD_ACCESS_KEY_SECRET=""
ALIYUN_VOD_REGION=cn-shanghai
ALIYUN_VOD_TEMPLATE_GROUP_ID=""npm install aliyun-vod-upload-sdk@^2.0.0 video.js@^8.24.1适配层采用 RPC 风格调用 https://vod.{region}.aliyuncs.com。签名算法为 HMAC-SHA1。阿里云遵循 RFC 3986 规范,标准 encodeURIComponent 不转义的字符(!、'、(、)、*)需要二次编码,否则会触发 SignatureDoesNotMatch。
二次转义与 RPC 签名组装:
function percentEncode(str: string): string {
return encodeURIComponent(str)
.replace(/!/g, "%21").replace(/'/g, "%27")
.replace(/\(/g, "%28").replace(/\)/g, "%29").replace(/\*/g, "%2A");
}
function buildSignedUrl(endpoint: string, accessKeyId: string, secret: string, action: string, params: Record<string, string>): string {
const queryParams: Record<string, string> = {
Format: "JSON", Version: "2017-03-21", AccessKeyId: accessKeyId,
SignatureMethod: "HMAC-SHA1", SignatureVersion: "1.0",
Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
SignatureNonce: crypto.randomUUID(), Action: action,
...params,
};
const canonicalQuery = Object.keys(queryParams).sort()
.map((k) => `${percentEncode(k)}=${percentEncode(queryParams[k])}`).join("&");
const stringToSign = `GET&${percentEncode("/")}&${percentEncode(canonicalQuery)}`;
const signature = crypto.createHmac("sha1", `${secret}&`).update(stringToSign).digest("base64");
return `${endpoint}/?${canonicalQuery}&Signature=${percentEncode(signature)}`;
}适配层采用延迟加载,未配置密钥时不阻塞系统启动:
export function isAliyunVodConfigured(): boolean {
return Boolean(process.env.ALIYUN_VOD_ACCESS_KEY_ID?.trim() && process.env.ALIYUN_VOD_ACCESS_KEY_SECRET?.trim());
}
export function getAliyunVodAdapter(): AliyunVodAdapter {
if (!isAliyunVodConfigured()) throw new Error("未配置阿里云视频点播访问密钥");
return new DefaultAliyunVodAdapter({
accessKeyId: process.env.ALIYUN_VOD_ACCESS_KEY_ID!.trim(),
accessKeySecret: process.env.ALIYUN_VOD_ACCESS_KEY_SECRET!.trim(),
regionId: process.env.ALIYUN_VOD_REGION?.trim() || "cn-shanghai",
});
}获取播放信息时,PlayConfig 中固定传递加速域名,仅请求未加密的 m3u8 流,并按分辨率与码率从高到低排序,过滤加密流与非 video 类型流。
签发上传凭证前执行资源鉴权与文件名清洗。文件名剔除路径前缀,扩展名仅允许 mp4、mov、m4v、avi、mkv、flv、webm、wmv,标题截断至 Unicode 128 字符。视频打上归属标签 res-${resourceId}。
export function checkOwnerTag(tags: string | undefined | null, resourceId: number): boolean {
if (!tags) return false;
return tags.split(",").map((t) => t.trim()).includes(`res-${resourceId}`);
}转码状态归并规则:
Uploading、UploadSucc、Transcoding、Checking:归并为处理中(processing)。Normal:归并为就绪(ready),并返回四舍五入后的时长秒数。UploadFail、TranscodeFail、Blocked:归并为失败(failed)。批量保存前执行差异比对与标签核验,避免冗余接口调用并防止越权引用:
export async function verifyVodRecords(resourceId: number, records: VodRecord[], existingRecords: VodRecord[]): Promise<VodRecord[]> {
const existingMap = new Map(existingRecords.map((r) => [r.id, r.config]));
const videoCache = new Map<string, { status: string; duration?: number; tags?: string }>();
const adapter = getAliyunVodAdapter();
const updated: VodRecord[] = [];
for (const record of records) {
const incomingId = record.config?.videoId?.trim();
const existingId = existingMap.get(record.id)?.videoId;
// 空记录保持空配置
if (!incomingId) {
updated.push({ ...record, config: {} });
continue;
}
// 历史视频未变更:不调用外部接口,沿用已有时长与文件名
if (existingId === incomingId) {
updated.push(record);
continue;
}
// 新增或变更视频:检查标签归属与云端状态(同批次缓存)
let info = videoCache.get(incomingId);
if (!info) {
info = await adapter.getVideoInfo({ videoId: incomingId });
videoCache.set(incomingId, info);
}
if (!checkOwnerTag(info.tags, resourceId)) {
throw new Error("视频不属于此资源");
}
const durationSeconds = (info.status === "Normal" && info.duration) ? Math.round(info.duration) : undefined;
updated.push({
...record,
config: { ...record.config, videoId: incomingId, durationSeconds },
});
}
return updated;
}上传 SDK 依赖浏览器环境,通过动态导入引入。直传逻辑包含取消上传时的延迟补删处理:
const { createUploader } = await import("aliyun-vod-upload-sdk");
let isCancelled = false;
let currentUploadingVideoId: string | null = null;
const uploader = createUploader({
getAuth: async (ctx) => {
if (ctx.kind === "create-video") {
const res = await fetch("/api/aliyun-vod/upload-auth", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resourceId, fileName: (ctx.file as File).name }),
});
const data = await res.json();
currentUploadingVideoId = data.videoId;
// 凭证返回前若用户已触发取消,立即补发异步删除请求
if (isCancelled) {
fetch("/api/aliyun-vod/delete-video", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resourceId, videoId: data.videoId }),
});
throw new Error("上传已取消");
}
return { UploadAuth: data.uploadAuth, UploadAddress: data.uploadAddress, VideoId: data.videoId };
}
// 刷新凭证逻辑
const res = await fetch("/api/aliyun-vod/refresh-upload-auth", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resourceId, videoId: ctx.videoId }),
});
const data = await res.json();
return { UploadAuth: data.uploadAuth, UploadAddress: data.uploadAddress, VideoId: data.videoId };
},
});前端限制单个文件不超过 5 GB。取消上传时调用 uploadTask.abort() 并补调删除接口;离开页面通过 beforeunload 拦截。上传完成后开启 30 分钟轮询熔断机制:
function pollVideoStatus(resourceId: number, videoId: string, onReady: (duration?: number) => void) {
const startTime = Date.now();
const timer = setInterval(async () => {
if (Date.now() - startTime > 30 * 60 * 1000) {
clearInterval(timer); // 超时熔断,切换至手动重试
return;
}
const res = await fetch("/api/aliyun-vod/video-status", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resourceId, videoId }),
});
const data = await res.json();
if (data.status === "ready") {
clearInterval(timer);
onReady(data.durationSeconds);
} else if (data.status === "failed") {
clearInterval(timer);
}
}, 5000);
}播放地址接口(POST /api/aliyun-vod/play-url)执行严格分层校验,业务履约与准入鉴权前置于云厂商调用,避免非授权请求消耗配额:
reason 为 not_configured。reason 为 video_missing。GetPlayInfo 获取流列表,若流未就绪再查 GetVideoInfo 区分处理中(processing)与转码失败(not_playable)。各清晰度为独立 m3u8,播放端动态组装 Master Playlist 并生成 Blob URL 交由播放器加载:
function buildMasterPlaylist(renditions: PlayRendition[]): string {
const lines = ["#EXTM3U", "#EXT-X-VERSION:3"];
for (const r of renditions) {
const bandwidth = r.bitrateKbps ? Math.round(r.bitrateKbps * 1000) : 1000000;
const res = (r.width && r.height) ? `,RESOLUTION=${r.width}x${r.height}` : "";
lines.push(`#EXT-X-STREAM-INF:BANDWIDTH=${bandwidth}${res}`);
lines.push(r.playUrl);
}
return lines.join("\n");
}Safari 默认将 HLS 移交系统播放器,无法识别 blob: 总列表。全平台统一采用 video.js 的 VHS 引擎接管,关闭原生音视频轨与文本轨(iPhone 开启 experimentalUseMMS):
const player = videojs(videoElement, {
html5: {
vhs: { overrideNative: true, ...(isIPhone ? { experimentalUseMMS: true } : {}) },
nativeAudioTracks: false, nativeVideoTracks: false, nativeTextTracks: false,
},
});清晰度手选切换时,不更换播放地址或重建播放器,通过 VHS 双层 API 锁定目标档位:
function applyQualityLevel(player: any, renditions: PlayRendition[], targetQuality: string) {
const isAuto = targetQuality === "auto";
const target = renditions.find((r) => r.definition === targetQuality);
// 1. 标准 qualityLevels API
const qualityLevels = player.qualityLevels?.();
if (qualityLevels) {
for (let i = 0; i < qualityLevels.length; i++) {
const ql = qualityLevels[i];
ql.enabled = isAuto || (target && ql.height === target.height);
}
}
// 2. 底层 VHS representations API 兼容
try {
const reps = player.tech({ IWillNotUseThisInPlugins: true })?.vhs?.representations?.();
if (Array.isArray(reps)) {
for (const rep of reps) {
rep.enabled?.(isAuto || (target && rep.height === target.height));
}
}
} catch {}
}针对起播时播放进度被重置为 0 秒的问题,采用多事件核对状态机予以防护:
function setupPlaybackPositionGuard(player: any, startPosition: number) {
let isStable = false;
player.on(["ready", "loadedmetadata", "canplay", "timeupdate"], () => {
if (startPosition <= 0 || isStable) return;
const current = player.currentTime();
if (current < 1 && Math.abs(current - startPosition) > 1) {
player.currentTime(startPosition);
} else if (current >= startPosition - 0.5) {
isStable = true;
}
});
}播放器与业务打点解耦,通过 10 秒心跳上报当前进度;当播放时长达到设定阈值(如 90%)时触发完成回调:
function setupVideoTracking(player: any, resourceId: number, duration: number, onComplete?: () => void) {
let completed = false;
let timer: NodeJS.Timeout | null = null;
const report = (eventType: string) => {
const position = Math.round(player.currentTime());
fetch("/api/video-tracking", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resourceId, eventType, positionSeconds: position }),
}).catch(() => {});
if (duration > 0 && !completed && position / duration >= 0.9) {
completed = true;
onComplete?.();
}
};
player.on("play", () => { timer = setInterval(() => report("heartbeat"), 10000); });
player.on(["pause", "ended"], (e: any) => {
if (timer) clearInterval(timer);
report(e.type);
});
}CDN 改写后的 m3u8 中,每个分片携带独立的 auth_key,无法直接沿用主列表的查询参数。续签必须重新请求 GetPlayInfo 并通过文件名重新映射。
提前 1 分钟计算刷新时延:
function calculateRefreshDelay(urls: string[]): number {
let earliestTs = Infinity;
for (const url of urls) {
const ts = Number(new URL(url).searchParams.get("auth_key")?.split("-")[0]);
if (!Number.isNaN(ts)) earliestTs = Math.min(earliestTs, ts);
}
if (!Number.isFinite(earliestTs)) return 9 * 60 * 1000;
return Math.max((earliestTs + 10 * 60 - 60) * 1000 - Date.now(), 1000);
}获取新地址后,递归解析各档播放列表建立文件名映射,并通过 VHS xhr.onRequest 拦截改写后续请求:
export function rewritePlaybackRequest(input: { reqUri: string; renditions: { playUrl: string }[]; signedByFilename?: Map<string, string> }): string {
try {
const reqUrl = new URL(input.reqUri);
const filename = reqUrl.pathname.split("/").pop() || "";
for (const r of input.renditions) {
if (filename === new URL(r.playUrl).pathname.split("/").pop()) return r.playUrl;
}
return input.signedByFilename?.get(filename) ?? input.reqUri;
} catch {
return input.reqUri;
}
}在 player.src() 调用前完成钩子挂载。若后续仍出现 403 鉴权失败,清空旧映射后重新执行换源。
当前落地方案采用未加密 HLS 搭配 URL 鉴权与 Referer 防盗链。需要明确的是,未加密方案并不能满足对媒体分片防下载、防离线传播的安全要求:URL 鉴权和 Referer 白名单仅限制了播放地址的请求时效与合法来源,一旦授权用户在有效期内提取到 TS 分片,由于分片本身为明文,仍可被离线播放或抓包保存。当前采用未加密方案,是基于系统初期快速跑通直传、转码与播放核心链路的工程取舍。
点播支持的三类内容安全方案对比:
| 方案 | 加密机制 | 播放端要求 | 适用性与成本 |
|---|---|---|---|
| 阿里云私有加密 | 私有算法,一文一密 | 仅支持阿里云播放器 | Web 端无法直接播 URL,费用同通用加密 |
| HLS 标准加密 | AES-128 信封加密 | 支持标准 EXT-X-KEY(含 video.js) |
需自建 KMS 解密服务与令牌服务 |
| 商业 DRM | FairPlay / Widevine | 平台级原生解密环境 | License 按次计费,接入链条长 |
阿里云私有加密采用私有算法,文档明确要求 Web 端播放必须依赖播放凭证与阿里云专有播放器 SDK;商业 DRM(如 FairPlay、Widevine)依赖平台底层硬件解密和 License 授权服务,开发维护成本高昂。而 HLS 标准加密,是后续强防下载诉求时的平滑演进项,也是阿里云 VOD 官网推荐的最佳实践方式。
HLS 标准加密遵循 RFC 8216 规范,使用 AES-128 对各 TS 分片逐个加密。核心流转包含三个环节:
GenerateDataKey 接口生成数据密钥。密文密钥保存在媒资元数据中,转码作业使用明文密钥对视频分片执行 AES-128 加密。EXT-X-KEY:METHOD=AES-128,URI="https://decrypt.example.com/...",指向业务自建的解密密钥分发服务。MtsHlsUriToken)透传至解密 URI。解密服务验证令牌合法性后,调用 KMS 解密接口恢复出 16 字节明文密钥并返回给客户端。video.js 原生支持标准的 AES-128 解密,无需更换播放内核即可完成播放。流程参考 HLS 标准加密。后续引入 HLS 标准加密时,现有架构的改造范围集中在以下五个节点:
EXT-X-KEY 声明的解密 URI。Encrypt === 1 且 EncryptType === "HLSEncryption" 的流,继续过滤私有加密与非 m3u8 流:function isPlayableStream(item: {
PlayURL?: string;
Format?: string;
StreamType?: string;
Encrypt?: number | string;
EncryptType?: string;
}): boolean {
if (!item.PlayURL) return false;
if (item.Format?.toLowerCase() !== "m3u8") return false;
if (item.StreamType?.toLowerCase() !== "video") return false;
const isEncrypted = item.Encrypt !== undefined && item.Encrypt !== null && String(item.Encrypt) !== "0";
if (!isEncrypted) return true;
return item.EncryptType === "HLSEncryption";
}EXT-X-KEY 时会自动发起密钥请求。现有的 Master Playlist 拼装、清晰度锁定和续签改写钩子均可沿用,需注意解密 URI 同样具有独立路径,不能套用 m3u8 的 auth_key 查询串。视频删除调用 DeleteVideo,分批处理(单批上限 20 个),删除失败不阻断业务数据提交流程:
export async function cleanupOrphanVideos(resourceId: number, candidateVideoIds: string[]): Promise<string | undefined> {
const uniqueIds = Array.from(new Set(candidateVideoIds.filter(Boolean)));
if (uniqueIds.length === 0 || !isAliyunVodConfigured()) return;
const adapter = getAliyunVodAdapter();
const toDelete: string[] = [];
let hasWarning = false;
for (const vid of uniqueIds) {
// 1. 全站引用检查:全站已有记录引用的视频保留不删
if (await checkIsVideoReferencedInAnySavedRecord(vid)) continue;
try {
const info = await adapter.getVideoInfo({ videoId: vid });
// 2. 标签校验:非本资源视频不删
if (checkOwnerTag(info?.tags, resourceId)) toDelete.push(vid);
} catch (err: any) {
if (err.code === "NoSuchVideo") continue;
hasWarning = true;
}
}
// 3. 分批调用删除
for (let i = 0; i < toDelete.length; i += 20) {
try {
await adapter.deleteVideos({ videoIds: toDelete.slice(i, i + 20) });
} catch {
hasWarning = true;
}
}
return hasWarning ? "内容已保存,旧视频未能从阿里云删除,将继续占用存储。" : undefined;
}生产注意事项清单:
PlayConfig.PlayDomain 必须与控制台加速域名完全一致。