1 前言
1.1 传统的单体架构服务器方案
传统的单体架构在实际生产中的不足:
- 存储的可扩展性受限: 文件直接存储在服务器本地磁盘,受限于固定容量(如 20GB-40GB) 。这种“存算一体”的模式导致存储空间难以弹性扩展,且在高并发访问时,磁盘 I/O 容易成为系统瓶颈 。
- 网络吞吐量与成本矛盾: 观看速度受限于服务器的固定带宽(通常仅为 1M-2M),导致下行速率极低(约 280KB/s),难以支持流畅的高清视频点播 。此外,传统云服务器的下行流量包月或按流量计费模式,在视频业务中成本极高。
- 高昂的运维与管理成本: 项目依赖手动部署,需要自行维护操作系统环境、处理依赖库并编写后台运行脚本 。这种模式缺乏 CI/CD(持续集成与交付)支持,导致版本迭代和系统迁移非常繁琐。
1.2 现代云原生技术
- Vercel/GitHub (CI/CD): 将部署流程自动化。通过 Git 驱动,代码推送即发布,解决了原本需要手动 SSH 登录服务器编译、运行脚本的繁琐流程
- 腾讯云 COS (对象存储): 专门用于存储视频与图片。相比于受限于服务器物理硬盘(20GB-40GB)的方案,COS 提供了几乎无限的扩展空间,且实现了存算分离
- 腾讯云 CDN (分发加速): 通过边缘节点缓存,将原本受限于 1M/2M 固定带宽的单点出口,升级为全球加速网络,大幅降低了下行流量成本并提升了高并发下的播放稳定性 。
- PostgreSQL (Neon): 一种全托管的无服务器关系型数据库,无需手动维护数据库进程或备份,能根据访问量自动伸缩。
1.3 vercel
Vercel 和 Netlify 作为现代 Jamstack平台的代表,其核心逻辑在于将开发者从繁重的底层运维中解放。
如果说传统的云服务器像是租了一间“毛坯房”,你必须亲力亲为地安装操作系统、修补漏洞、配置防火墙,并根据流量波动手动调整 CPU 和内存资源 ,甚至要为没人访问时的机器空转支付高额年费 ;
那么 Vercel 则更像是一家“全托管的高级酒店”,你只需专注于业务代码的编写与入驻,剩下的基础设施建设、环境配置与全球加速全由平台承载。通过 Serverless 函数,系统实现了极具弹性的自动扩缩容与按需计费,虽然“无状态”的设计带来了函数运行完即消失的特性以及初次唤醒时的“冷启动”延迟,但在无人访问时函数自动休眠的极致成本优势,以及 Git 驱动的自动化部署体验,彻底打破了传统服务器在维护成本、扩展门槛与硬件瓶颈上的多重物理桎梏 。
在我的视频点播系统中,网页的诞生并非一次性完成,而是遵循 Jamstack 架构进行了一场精密的接力。首先是 M (Markup) 阶段:每当我将代码推送至 GitHub,Vercel 随即触发构建,生成静态的
index.html并推送到全球 CDN 边缘节点。这意味着用户在打开网页的瞬间,就能在毫秒级内看到极速渲染的导航栏和加载动画。紧接着进入 J (JavaScript) 阶段:浏览器下载并运行 JS 脚本,接管页面逻辑。由于前端不能直接触碰数据库,JS 会自动发起异步请求去唤醒 A (APIs/Serverless) 阶段。此时,部署在云端的 Serverless 函数 被瞬间激活,它充当安全管家,一边握着 Neon 数据库 的连接密钥获取视频元数据,一边执行我针对.mp4文件定制的 CDN 鉴权校验。最终,这些格式化后的数据流回前端,由 JS 驱动 DOM 瞬间填满视频列表。
传统架构是 “收到请求 → 处理逻辑 → 生成页面”(压力山大,用户越多服务器越累)
Jamstack架构 是 “生成页面 → 收到请求 → 异步填充数据”(压力低,大部分压力被 CDN 抵消了)
2. 相关配置介绍
2.1 云原生基础设施配置
本系统采用存算分离与边缘加速的架构方案,通过精细化配置实现了高性能与低成本的平衡。
1. 资源分发与全球加速
- 域名与备案策略:考虑到国内域名备案对服务器的依赖,本项目采用海外 CDN 加速方案,通过 Vercel 与 GitHub 的深度集成,实现了无需传统服务器的快速自动化部署。
- SSL 安全加密:全站部署由腾讯云提供的免费 SSL 证书,确保了数据传输过程中的 HTTPS 加密安全性。
2. 成本优化与性能
在视频点播场景中,直接访问存储桶(COS)不仅在高并发下存在性能瓶颈,且流量成本较高。经过对比分析,我实施了 COS 回源 CDN 的优化策略:
-
成本模型分析:
-
直连 COS 方案:下行流量费用约为 0.5 元/GB。
-
COS + CDN 组合方案:回源流量(仅首次)0.15 元/GB + CDN 下行流量 0.24 元/GB ≈ \approx ≈ 0.39 元/GB。
-
结论:通过 CDN 缓存机制,不仅单价降低了约 22%,更通过边缘节点分发极大缓解了源站存储桶的并发压力。
3. 安全加固与权限管理
- 最小权限原则:COS 存储桶设置为私有读写,拒绝一切非授权的匿名访问。
- 身份认证机制:在腾讯云控制台配置独立的 CAM 子用户,仅授予其 COS 操作权限。程序后端通过子用户的 API 密钥进行资源调度,有效规避了主账号密钥泄露的风险。
4. 数据库与开发工作流
- 无服务器数据库:选用 PostgreSQL (Neon) 作为核心存储,利用其 Serverless 特性实现自动扩缩容,其免费额度完全覆盖当前日均访问需求。
- 环境一致性:利用
vercel dev工具在本地(VS Code)模拟线上运行环境,确保了本地开发逻辑与 Vercel 生产环境的高度统一,消除了“本地能跑,上线报错”的部署痛点。
2.2 项目核心依赖说明
环境初始化与核心依赖安装
- 项目初始化:运行
npm init -y快速生成默认配置,集中管理项目依赖。- 集成存储服务:通过
npm install cos-nodejs-sdk-v5命令安装cos-nodejs-sdk-v5,这是 Node.js 环境下高效调用腾讯云 COS 接口的核心 SDK,负责处理视频文件的上传、鉴权及元数据管理。
"dependencies": {
"@neondatabase/serverless": "^1.0.2",
"cos-nodejs-sdk-v5": "^2.15.4",
"qcloud-cos-sts": "^3.1.3",
"formidable": "^3.5.4"
}
1. 数据驱动层:@neondatabase/serverless
- 核心作用:连接 PostgreSQL (Neon) 的专型无服务器驱动。
- 选型深度:相比传统的
pg包,该版本针对 Vercel Edge Runtime进行了深度优化。有效规避了 Serverless 环境下频繁建立 TCP 连接带来的高延迟与连接数溢出问题,显著提升了数据库查询的响应速度。
2. 存储分发层:腾讯云生态组合
- cos-nodejs-sdk-v5 (2.15.4):腾讯云对象存储(COS)的官方 SDK,负责文件上传、下载、查询及生命周期管理。
- qcloud-cos-sts (3.1.3):用于生成临时密钥(STS)。
- 架构(安全性闭环):通过这两者的组合,实现了一套后端鉴权、前端直传的安全方案。后端 Serverless 函数不再作为文件流的中转站(避开了带宽压力),而是仅负责发放“限时通行证”(临时密钥),让前端直接与 COS 通信,确保了核心资产的访问安全。
3. 业务处理层:formidable
- 核心作用:高性能的表单与文件上传解析工具。
- 技术适配:本项目选用了其最新的 v3 版本,该版本提供了原生 Promise 支持,与 Vercel 的异步编程模型完美契合。这不仅简化了代码逻辑,更确保了在文件流解析过程中,异常捕获更加精准,极大地增强了上传模块的稳定性。
3 页面展示
3.1 访客登录页面 index.html

3.2 视频播放页面 player.html

3.3 管理员页面 management.html

4 数据库表
4.1 videos 视频表
CREATE TABLE IF NOT EXISTS videos (
-- 唯一的、自动增长的主键 ID
id SERIAL PRIMARY KEY,
-- 视频标题 (不能为空)
title VARCHAR(255) NOT NULL,
-- 视频描述 (可以为空)
description TEXT,
-- 封面图片在 COS 中的存储路径 (Key)
cover_key VARCHAR(255),
-- 视频文件在 COS 中的存储路径 (Key), 必须唯一
video_key VARCHAR(255) UNIQUE NOT NULL,
-- 上传时间,默认为当前时间戳
upload_date TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- 访问次数,默认值为 0
views_count INTEGER DEFAULT 0
);
videos视频表
4.2 tags 标签表
CREATE TABLE tags (
tag_id SERIAL PRIMARY KEY,
-- 标签名,例如 "科技", "美食"
-- 设置为 UNIQUE 保证不会创建两个同名的标签
tag_name VARCHAR(100) NOT NULL UNIQUE
);
tags 标签表
4.3 video_tags 视频标签关联表
CREATE TABLE video_tags (
-- 关联到 videos 表的 id
-- ON DELETE CASCADE 意味着如果某个视频被删了,它在这里的所有标签关联记录也会自动被删除
video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
-- 关联到 tags 表的 tag_id
-- ON DELETE CASCADE 意味着如果某个标签被删了(例如管理员后台删除),所有视频与这个标签的关联也会自动被删除
tag_id INTEGER NOT NULL REFERENCES tags(tag_id) ON DELETE CASCADE,
-- 【核心】复合主键
-- 这行代码保证了 (video_id, tag_id) 这个“组合”是唯一的
-- 你无法插入两条 (100, 5) 的记录,数据库会直接报错,从而防止了重复
PRIMARY KEY (video_id, tag_id)
);
video_tags 视频标签关联表
4.4 comments 评论表
CREATE TABLE comments (
comment_id SERIAL PRIMARY KEY,
-- 关联到 videos 表的 id
-- 同样,视频删除时,该视频的所有评论自动删除
video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
-- 评论内容,不允许为空
content TEXT NOT NULL,
-- 评论发表时间,默认为当前时间
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
comments 评论表
5 JS 文件功能解释
5.1 基本功能逻辑
api\generate-url.js----第一个js文件,从cos存储桶获取一个视频,返回对应的预签名URl链接(3600s)
api\time.js-----返回cos存储桶某个视频的上传时间
api\cdn.js-----返回cdn加速后的md5加密视频文件(图片不加密)
api\upload.js---上传到cos存储桶的指定位置
api\delete.js----删除cos存储桶的指定位置
5.1.1 generate-url.js
generate-url.js----第一个js文件,从cos存储桶获取一个视频,返回对应的预签名URl链接(3600s)
// 1. 使用 CommonJS 语法导入腾讯云COS的SDK
const COS = require('cos-nodejs-sdk-v5');
// 2. 使用 CommonJS 语法导出函数
module.exports = function handler(req, res) {
// 1. 初始化COS客户端
//
const cos = new COS({
SecretId: process.env.SecretId,
SecretKey: process.env.SecretKey,
});
// 2. 准备生成URL所需的参数
const params = {
Bucket: 'video-231', // 你的存储桶全称(格式:桶名-APPID)
Region: 'ap-hongkong', // 你的存储桶所在地域
Key: 'lwx.mp4', // 你要访问的视频文件名
Method: 'GET', // 我们要生成一个用于获取(播放)的链接
Expires: 3600, // 链接的有效时间,单位秒。这里是1小时
};
// 3. 调用SDK生成预签名URL
cos.getObjectUrl(params, (err, data) => {
// 4. 处理结果
if (err) {
console.error('生成签名URL失败:', err);
// 如果出错,返回500错误和详细信息
return res.status(500).json({ error: '生成URL失败', details: err });
}
// 如果成功,返回200状态码和一个包含URL的JSON对象
console.log('成功生成URL:', data.Url);
res.status(200).json({ url: data.Url });
});
}
5.1.2 time.js
time.js-----返回cos存储桶某个视频的上传时间
5.1.3 cdn.js
cdn.js-----返回cdn加速后的md5加密视频文件(对封面图片不加密)
5.1.4 upload.js
upload.js—上传到cos存储桶的指定位置
5.1.5 delete.js
delete.js----删除cos存储桶的指定位置
5.2 用户访问页面
api\add_comment.js---添加评论功能
api\connect_PgSql.js--通过连接池连接neon的PgSql,显示指定的数据,节点缓存设置为300s
api\get_video_details.js---在播放页面显示视频的详情,节点缓存设置为300s
api\get_view_counts.js--获取所有视频的播放量,转换为字典格式 { "1": 100, "2": 50 },节点缓存300s
api\update_view.js---更新播放量,当用户点击对应的视频,播放量+1
5.2.1 connect_PgSql.js
connect_PgSql.js–通过连接池连接neon的PgSql,显示指定的数据,节点缓存设置为300s
// 1. 导入 Neon 的 Serverless 客户端
const { neon } = require('@neondatabase/serverless');
const connectionString = process.env.DATABASE_URL;
const sql = neon(connectionString);
/*** 作用:查询视频列表(用于主页和搜索)*/
module.exports = async (req, res) => {
if (!connectionString) {
return res.status(500).json({ error: '数据库连接配置缺失' });
}
if (req.method !== 'GET') {
return res.status(405).json({ error: '只允许 GET 请求' });
}
res.setHeader('Cache-Control', 'max-age=60, s-maxage=300, stale-while-revalidate');
/*
s-maxage=300:告诉 Vercel 的全球 CDN 边缘节点:“这个视频列表可以缓存 300 秒”
stale-while-revalidate:如果缓存过期了,CDN会先给用户看“旧数据”,同时在后台默默地去数据库取“新数据”更新缓存。这确保了页面永远是秒开的。
*/
try {
const searchTerm = req.query.search;
let result;
if (searchTerm) {
// --- 搜索逻辑 ---
// Neon 会自动处理 SQL 注入防御
const searchPattern = `%${searchTerm}%`;
result = await sql`
SELECT
v.id,
v.title,
v.cover_key,
TO_CHAR(v.upload_date AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS upload_date
FROM videos v
WHERE v.title ILIKE ${searchPattern}
UNION
SELECT
v.id,
v.title,
v.cover_key,
TO_CHAR(v.upload_date AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS upload_date
FROM videos v
JOIN video_tags vt ON v.id = vt.video_id
JOIN tags t ON vt.tag_id = t.tag_id
WHERE t.tag_name ILIKE ${searchPattern}
ORDER BY id ASC;
`; //%js% 意味着只要标题里包含这几个字母(不管前面后面有什么),统统算匹配。
} else {
// --- 默认列表逻辑-首次加载 ---
// 使用 sql`...`
result = await sql`
SELECT
id,
title,
cover_key,
TO_CHAR(upload_date AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS upload_date
FROM videos
ORDER BY id ASC;
`;
}
res.status(200).json(result);
} catch (error) {
console.error('数据库查询错误:', error);
res.status(500).json({ error: '数据库查询失败', details: error.message });
}
};
5.2.2 get_video_details.js
get_video_details.js—在播放页面显示单个视频的详情,节点缓存设置为300s
// 1. 导入所需模块
const { neon } = require('@neondatabase/serverless');
const crypto = require('crypto');
const connectionString = process.env.DATABASE_URL;
const sql = neon(connectionString);
/**
* 作用:获取单个视频的详细信息(用于播放页)
*/
module.exports = async (req, res) => {
if (!connectionString) {
return res.status(500).json({ error: '数据库连接配置缺失' });
}
res.setHeader('Cache-Control', 'max-age=60, s-maxage=300, stale-while-revalidate');
try {
const videoId = req.query.id;
if (!videoId) {
return res.status(400).json({ error: '缺少 video id' });
}
// (修复) 使用 sql`...` 并直接嵌入 ${videoId}
const result = await sql`
SELECT
v.id,
v.title,
v.description,
v.video_key,
TO_CHAR(v.upload_date AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD HH24:MI:SS') AS upload_date,
(SELECT json_agg(t.tag_name)
FROM tags t
JOIN video_tags vt ON t.tag_id = vt.tag_id
WHERE vt.video_id = v.id) AS tags,
(SELECT json_agg(
json_build_object(
'content', c.content,
'created_at', TO_CHAR(c.created_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD HH24:MI')
) ORDER BY c.created_at DESC
)
FROM comments c
WHERE c.video_id = v.id) AS comments
FROM videos v
WHERE v.id = ${videoId}
GROUP BY v.id;
`;
if (result.length === 0) {
return res.status(404).json({ error: '未找到视频' });
}
const details = result[0];
// --- CDN 签名逻辑 ---
const { CDN_AUTH_KEY, CDN_DOMAIN } = process.env;
if (!CDN_AUTH_KEY || !CDN_DOMAIN) {
return res.status(500).json({ error: 'CDN 配置缺失' });
}
const fullUriPath = `/${details.video_key}`;
const validSeconds = 3600;
const t = Math.floor(Date.now() / 1000) + validSeconds;
const stringToSign = `${CDN_AUTH_KEY}${fullUriPath}${t}`;
const sign = crypto.createHash('md5').update(stringToSign, 'utf-8').digest('hex');
const finalResponse = {
...details,
signed_play_url: `https://${CDN_DOMAIN}${fullUriPath}?sign=${sign}&t=${t}`
};
res.status(200).json(finalResponse);
} catch (error) {
console.error('获取视频详情失败:', error);
res.status(500).json({ error: '数据库查询失败', details: error.message });
}
};
5.2.3 add_comment.js
add_comment.js—添加评论功能
// 1. 导入 Neon 的 Serverless 客户端
const { neon } = require('@neondatabase/serverless');
const connectionString = process.env.DATABASE_URL;
const sql = neon(connectionString);
/**
* 作用:向指定视频添加一条评论
*/
module.exports = async (req, res) => {
if (!connectionString) {
return res.status(500).json({ error: '数据库连接配置缺失' });
}
if (req.method !== 'POST') {
return res.status(405).json({ error: '只允许 POST 请求' });
}
try {
const { videoId, content } = req.body;
if (!videoId || !content) {
return res.status(400).json({ error: '缺少 videoId 或 content' });
}
if (content.trim().length === 0) {
return res.status(400).json({ error: '评论内容不能为空' });
}
// 使用 sql`...` 并直接嵌入变量
// Neon 会自动处理参数转义,防止注入
const result = await sql`
INSERT INTO comments (video_id, content)
VALUES (${videoId}, ${content})
RETURNING content, TO_CHAR(created_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD HH24:MI') AS created_at;
`;
res.status(201).json(result[0]);
} catch (error) {
console.error('添加评论失败:', error);
res.status(500).json({ error: '数据库插入失败', details: error.message });
}
};
5.2.4 get_view_counts.js
get_view_counts.js–获取所有视频的播放量,转换为字典格式 { “1”: 100, “2”: 50 },节点缓存300s
// 1. 导入 Neon 的 Serverless 客户端
const { neon } = require('@neondatabase/serverless');
const connectionString = process.env.DATABASE_URL;
const sql = neon(connectionString);
/**
* 作用:获取所有视频的播放量
*/
module.exports = async (req, res) => {
if (!connectionString) {
return res.status(500).json({ error: '数据库连接配置缺失' });
}
// 设置 5 分钟缓存
res.setHeader('Cache-Control', 'max-age=60, s-maxage=300, stale-while-revalidate');
try {
// (修复) 使用标签模板写法 sql`...`
const result = await sql`SELECT id, views_count FROM videos;`;
// 转换为字典格式 { "1": 100, "2": 50 }
const countsDictionary = result.reduce((acc, row) => {
acc[row.id] = row.views_count;
return acc;
}, {});
res.status(200).json(countsDictionary);
} catch (error) {
console.error('获取播放量失败:', error);
res.status(500).json({ error: '数据库查询失败', details: error.message });
}
};
5.2.5 update_view.js
update_view.js—更新播放量,当用户点击对应的视频,播放量+1
// 1. 导入 Neon 的 Serverless 客户端
const { neon } = require('@neondatabase/serverless');
const connectionString = process.env.DATABASE_URL;
const sql = neon(connectionString);
/**
* 作用:更新单个视频的播放量 ( +1 )
*/
module.exports = async (req, res) => {
if (!connectionString) {
return res.status(500).json({ error: '数据库连接配置缺失' });
}
if (req.method !== 'POST' && req.method !== 'GET') {
return res.status(405).json({ error: '只允许 POST (或 GET) 请求' });
}
try {
const videoId = req.query.id;
if (!videoId) {
return res.status(400).json({ error: '缺少 video id' });
}
// 使用 sql`...` 并直接嵌入 ${videoId}
await sql`
UPDATE videos
SET views_count = views_count + 1
WHERE id = ${videoId};
`;
res.status(204).end();
} catch (error) {
console.error('更新播放量失败:', error);
res.status(500).json({
error: '数据库更新失败',
details: error.message
});
}
};
5.3 管理员页面
api\get-sts-credentials.js---前端直传文件到cos上,获取临时sts密钥
api\delete-cos-file.js--当封面和视频成功上传到cos,但数据库写入失败,手动回滚删除之前上传的封面和视频
api\manage-videos.js ---上传页面和修改页面的js文件,包含了四个js文件的功能:(1)get-videos.js 获取视频列表 (2)create-video-record.js 上传视频 (3)update-video.js 更新视频内容(4)delete-video.js 删除视频内容
api\manage-comments.js---管理评论,可以按照内容和标题联合搜索评论
api\analytics.js---统计分析,统计播放量前五、标签引用次数前五和标签播放量前五
5.2.1 get-sts-credentials.js
get-sts-credentials.js—前端直传文件到cos上,获取临时sts密钥
1.腾讯云用于前端直传 COS 的临时密钥安全指引
2.qcloud-cos-sts-sdk 代码示例
临时密钥是由 安全凭证服务(Security Token Service,STS) 提供的临时访问凭证
临时密钥需要通过永久密钥才能生成。
后端通过腾讯云STS服务生成临时密钥(包含TmpSecretId、TmpSecretKey、SecurityToken和有效期ExpiredTime),并返回给前端
参考下面的图片,只不过后端发来临时密钥通行证的是qcloud-cos-sts ,并不是用传统的cos-nodejs-sdk-v5
// 1. 导入 STS SDK
const STS = require('qcloud-cos-sts');
// 2. 导出主函数
module.exports = (req, res) => {
// 3. 准备 STS.getCredential 所需的配置
const config = {
secretId: process.env.SecretId,
secretKey: process.env.SecretKey,
durationSeconds: 1800, //要申请的临时密钥最长有效时间,单位秒,默认 1800
// 权限策略
policy: {
version: '2.0',
statement: [
{
action: [
// 补全分块上传所需的所有权限
// 1. 简单上传
'name/cos:PutObject',
'name/cos:PostObject',
// 2. 分块上传
'name/cos:InitiateMultipartUpload',
'name/cos:UploadPart',
'name/cos:CompleteMultipartUpload',
'name/cos:ListParts',
'name/cos:ListMultipartUploads'
],
effect: 'allow',
// (!!! 规范性修复 !!!) 添加 principal
principal: {'qcs': ['*']},
resource: [
// 用这个清晰的版本
'qcs::cos:ap-hongkong:uid/1383328809:video-1383328809/covers/*',
'qcs::cos:ap-hongkong:uid/1383328809:video-1383328809/videos/*',
],
},
],
},
};
// 4. 包装成 Promise
const getCredentialsPromise = new Promise((resolve, reject) => {
STS.getCredential(config, (err, tempKeys) => {
if (err) {
reject(err);
return;
}
resolve(tempKeys);
});
});
// 5. 执行 Promise
getCredentialsPromise
.then((tempKeys) => {
// 6. 成功
const result = {
...tempKeys.credentials,
expiredTime: tempKeys.expiredTime,
startTime: tempKeys.startTime,
};
res.status(200).json(result);
})
.catch((error) => {
// 7. 失败
console.error('获取 STS 密钥失败:', error);
res.status(500).json({ error: '获取临时密钥失败', details: error.message });
});
};
5.2.2 delete-cos-file.js
delete-cos-file.js–当封面和视频成功上传到cos,但数据库写入失败,手动回滚删除之前上传的封面和视频(因为上传创建视频的时候,一个视频对应多个标签,我们需要在标签表插入新的标签,并且在视频标签关联表插入记录,刚开始写的时候好几次错误,导致就是封面和视频成功上传到cos,但是没用对应的数据库记录,这时候需要模拟cos回滚到上传之前的状态)
下面三个图就展现了
const COS = require('cos-nodejs-sdk-v5');
module.exports = async (req, res) => {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method Not Allowed' });
}
const cos = new COS({
SecretId: process.env.SecretId,
SecretKey: process.env.SecretKey,
});
try {
// 1. 从请求体中获取要删除的 keys
const { coverKey, videoKey } = req.body;
if (!coverKey || !videoKey) {
return res.status(400).json({ error: '缺少 coverKey 或 videoKey' });
}
console.log(`[补偿] 收到删除请求: ${coverKey}, ${videoKey}`);
// 2. 准备删除参数
const objects = [
{ Key: coverKey },
{ Key: videoKey },
];
// 3. (可选) 并行删除
// 你也可以用 cos.deleteObject 两次
await cos.deleteMultipleObject({
Bucket: 'video-321',
Region: 'ap-hongkong',
Objects: objects,
});
console.log(`[补偿] 成功删除: ${coverKey}, ${videoKey}`);
res.status(200).json({ message: '孤儿文件清理成功' });
} catch (error) {
console.error('[补偿] 删除 COS 文件失败:', error);
res.status(500).json({ error: '删除 COS 文件失败', details: error.message });
}
};
5.2.3 manage-videos.js
manage-videos.js —上传页面和修改页面的js文件,包含了四个js文件的功能:
(1)get-videos.js 获取视频列表
(2)create-video-record.js 上传视频(仅上传数据库记录,没有上传cos的封面和视频。在management.html配合前端cos上传)
(3)update-video.js 更新视频内容(更新对应视频的数据库信息,从cos删除旧的封面和视频文件。在management.html配合前端cos上传新的封面和视频)
(4)delete-video.js 删除视频内容(删除包含对应数据库信息和cos封面和视频。这个直接在后端服务器执行,不用配合前端直传这些操作。上传和修改都有在浏览器前端上传照片,所以有前端的上传cos代码,删除直接在后端删除,如果放到前端删除就有点啰嗦了)
因为vercel免费额度最多支持api文件夹下有10个js文件,所以将四个js文件合并成一个js文件,通过主分发器来分发请求
// 1. 导入
const { neon } = require('@neondatabase/serverless');
const COS = require('cos-nodejs-sdk-v5');
// --- 处理器 1: GET (获取视频列表) ---
// (此代码来自 get-videos.js)
const handleGet = async (req, res, sql) => {
try {
const page = parseInt(req.query.page || '1', 10);
const limit = parseInt(req.query.limit || '5', 10);
const search = req.query.search || '';
const offset = (page - 1) * limit;
const searchPattern = `%${search}%`;
const videosQuery = sql`
WITH VideoData AS (
SELECT
v.id, v.title, v.description, v.cover_key, v.video_key,
v.views_count,
TO_CHAR(v.upload_date AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD HH24:MI') AS upload_date,
COALESCE(
json_agg(
json_build_object('tag_id', t.tag_id, 'tag_name', t.tag_name)
) FILTER (WHERE t.tag_id IS NOT NULL),
'[]'
) AS tags,
STRING_AGG(t.tag_name, ', ') AS tag_names
FROM videos v
LEFT JOIN video_tags vt ON v.id = vt.video_id
LEFT JOIN tags t ON vt.tag_id = t.tag_id
GROUP BY v.id
),
FilteredData AS (
SELECT *, COUNT(*) OVER() AS total_count
FROM VideoData
WHERE title ILIKE ${searchPattern} OR tag_names ILIKE ${searchPattern}
)
SELECT * FROM FilteredData
ORDER BY id DESC
LIMIT ${limit}
OFFSET ${offset};
`;
const result = await videosQuery;
const totalCount = result.length > 0 ? parseInt(result[0].total_count, 10) : 0;
const totalPages = Math.ceil(totalCount / limit);
result.forEach(r => delete r.total_count);
res.status(200).json({
videos: result,
pagination: {
currentPage: page,
totalPages: totalPages,
totalCount: totalCount,
limit: limit
}
});
} catch (error) {
console.error('获取视频列表失败:', error);
res.status(500).json({ error: '数据库查询失败', details: error.message });
}
};
// --- 处理器 2: POST (创建新视频) ---
// (此代码来自 create-video-record.js, 使用手动事务)
const handleCreate = async (req, res, sql) => {
const { title, description, tags, coverKey, videoKey } = req.body;
try {
if (!title || !coverKey || !videoKey) {
return res.status(400).json({ error: '缺少 title, coverKey 或 videoKey' });
}
console.log(`[Create] 收到数据库写入请求: ${title}`);
await sql`BEGIN`; // 1. 开始事务
console.log(`(事务) [Create] BEGIN`);
const videoResult = await sql`
INSERT INTO videos (title, description, cover_key, video_key)
VALUES (${title}, ${description}, ${coverKey}, ${videoKey})
RETURNING id
`;
const newVideoId = videoResult[0].id;
console.log(`(事务) [Create] 视频记录创建成功, ID: ${newVideoId}`);
let tagIds = [];
if (tags && tags.length > 0) {
await sql`
INSERT INTO tags (tag_name)
SELECT tag_name
FROM unnest(${tags}::text[]) AS t(tag_name)
WHERE NOT EXISTS (
SELECT 1 FROM tags t_exists WHERE t_exists.tag_name = t.tag_name
)
`;
const tagResults = await sql`
SELECT tag_id FROM tags WHERE tag_name = ANY(${tags})
`;
tagIds = tagResults.map(t => t.tag_id);
for (const tagId of tagIds) {
await sql`
INSERT INTO video_tags (video_id, tag_id)
VALUES (${newVideoId}, ${tagId})
ON CONFLICT (video_id, tag_id) DO NOTHING
`;
}
console.log(`(事务) [Create] 标签关联完成`);
}
await sql`COMMIT`; // 2. 提交事务
console.log(`(事务) [Create] COMMIT 成功`);
return res.status(200).json({
message: '数据库记录创建成功',
video: { newVideoId, title, tagIds }
});
} catch (error) {
console.error('数据库事务写入失败 [Create]:', error);
try {
console.log('(事务) [Create] 正在回滚...');
await sql`ROLLBACK`;
console.log('(事务) [Create] 回滚成功');
} catch (rollbackError) {
console.error('!! 事务回滚失败 [Create] !!:', rollbackError);
}
if (error.code === '23505' && error.constraint === 'videos_video_key_key') {
return res.status(409).json({ error: '数据库写入失败:视频文件 (video_key) 已存在。', details: error.message });
}
return res.status(500).json({
error: '数据库事务写入失败,数据已回滚',
details: error.message || error.toString()
});
}
};
// --- 处理器 3: PUT (更新视频) ---
// (此代码来自 update-video.js, 使用手动事务)
const handleUpdate = async (req, res, sql, cos) => {
try {
const {
videoId, title, description, tags,
newCoverKey, newVideoKey,
oldCoverKey, oldVideoKey
} = req.body;
if (!videoId || !title || oldCoverKey === undefined || oldVideoKey === undefined) {
return res.status(400).json({ error: '缺少必要参数 (id, title, oldKeys)' });
}
console.log(`[Update] 收到 ID:${videoId} 的更新请求`);
const finalCoverKey = newCoverKey || oldCoverKey;
const finalVideoKey = newVideoKey || oldVideoKey;
await sql`BEGIN`; // 1. 开始事务
console.log(`(事务) [Update] BEGIN`);
await sql`
UPDATE videos
SET
title = ${title}, description = ${description},
cover_key = ${finalCoverKey}, video_key = ${finalVideoKey},
upload_date = CURRENT_TIMESTAMP
WHERE id = ${videoId}
`;
console.log(`(事务) [Update] ID:${videoId} videos 表更新成功`);
await sql`DELETE FROM video_tags WHERE video_id = ${videoId}`;
if (tags && tags.length > 0) {
await sql`
INSERT INTO tags (tag_name)
SELECT tag_name
FROM unnest(${tags}::text[]) AS t(tag_name)
WHERE NOT EXISTS (
SELECT 1 FROM tags t_exists WHERE t_exists.tag_name = t.tag_name
)
`;
const tagResults = await sql`
SELECT tag_id FROM tags WHERE tag_name = ANY(${tags})
`;
const tagIds = tagResults.map(t => t.tag_id);
for (const tagId of tagIds) {
await sql`
INSERT INTO video_tags (video_id, tag_id)
VALUES (${videoId}, ${tagId})
ON CONFLICT (video_id, tag_id) DO NOTHING
`;
}
}
console.log(`(事务) [Update] ID:${videoId} 标签更新成功`);
await sql`COMMIT`; // 2. 提交事务
console.log(`(事务) [Update] COMMIT 成功`);
// --- 数据库事务结束 ---
// 3. 数据库成功后,清理旧的 COS 文件
let cleanedFiles = [];
if (newCoverKey && newCoverKey !== oldCoverKey) {
console.log(`[Update] 准备删除旧封面: ${oldCoverKey}`);
await cos.deleteObject({ Bucket: 'video-321', Region: 'ap-hongkong', Key: oldCoverKey });
cleanedFiles.push(oldCoverKey);
}
if (newVideoKey && newVideoKey !== oldVideoKey) {
console.log(`[Update] 准备删除旧视频: ${oldVideoKey}`);
await cos.deleteObject({ Bucket: 'video-321', Region: 'ap-hongkong', Key: oldVideoKey });
cleanedFiles.push(oldVideoKey);
}
return res.status(200).json({
message: '更新成功',
videoId: videoId,
cleanedFiles: cleanedFiles
});
} catch (error) {
console.error('更新视频失败 (事务中):', error);
try {
console.log('(事务) [Update] 正在回滚...');
await sql`ROLLBACK`;
console.log('(事务) [Update] 回滚成功');
} catch (rollbackError) {
console.error('!! 事务回滚失败 [Update] !!:', rollbackError);
}
return res.status(500).json({
error: '更新失败,数据库已回滚',
details: error.message || error.toString()
});
}
};
// --- 处理器 4: DELETE (删除视频) ---
// (此代码来自 delete-video.js)
const handleDelete = async (req, res, sql, cos) => {
try {
// (!!! 关键 !!!) DELETE 请求没有 req.body,我们从 query 获取 videoId
// 我们将在 management.html 中修改 fetch 请求
const { videoId } = req.query;
if (!videoId) {
return res.status(400).json({ error: '缺少 videoId' });
}
console.log(`[Delete] 收到 ID:${videoId} 的删除请求`);
// 1. 先从数据库查出 COS Keys
const videoData = await sql`
SELECT cover_key, video_key
FROM videos
WHERE id = ${videoId}
`;
if (videoData.length === 0) {
throw new Error('视频不存在或已被删除');
}
const { cover_key, video_key } = videoData[0];
// 2. 删除数据库记录
// (ON DELETE CASCADE 会自动清理 video_tags 和 comments)
await sql`DELETE FROM videos WHERE id = ${videoId}`;
console.log(`[Delete] ID:${videoId} 数据库记录删除成功`);
// 3. 数据库成功后,删除 COS 文件
await cos.deleteMultipleObject({
Bucket: 'video-321',
Region: 'ap-hongkong',
Objects: [
{ Key: cover_key },
{ Key: video_key },
],
});
console.log(`[Delete] ID:${videoId} COS 文件 (${cover_key}, ${video_key}) 删除成功`);
// 4. 成功响应
res.status(200).json({ message: '删除成功', videoId: videoId });
} catch (error) {
console.error('删除视频失败:', error);
res.status(500).json({ error: '删除失败', details: error.message });
}
};
// --- 主分发器 (Main Dispatcher) ---
// Vercel 会将所有 /api/manage-videos 的请求发送到这里
module.exports = async (req, res) => {
// 1. 初始化数据库
const connectionString = process.env.DATABASE_URL;
const sql = neon(connectionString);
// 2. 根据方法分发
if (req.method === 'GET') {
return await handleGet(req, res, sql);
}
if (req.method === 'POST') {
return await handleCreate(req, res, sql);
}
// PUT 和 DELETE 需要 COS
const cos = new COS({
SecretId: process.env.SecretId,
SecretKey: process.env.SecretKey,
});
if (req.method === 'PUT') {
return await handleUpdate(req, res, sql, cos);
}
if (req.method === 'DELETE') {
return await handleDelete(req, res, sql, cos);
}
// 3. 不支持的方法
return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
};
5.2.4 manage-comments.js
manage-comments.js—管理评论,可以按照内容和标题联合搜索评论或删除评论
// 文件名: api/manage-comments.js
// 职责: 提供评论的查询 (GET) 和删除 (DELETE) API
const { neon } = require('@neondatabase/serverless');
// --- 处理器 1: GET (获取评论列表) ---
const handleGet = async (req, res, sql) => {
try {
const page = parseInt(req.query.page || '1', 10);
const limit = parseInt(req.query.limit || '10', 10);
const offset = (page - 1) * limit;
// 两个独立的搜索参数
const commentSearch = req.query.commentSearch || '';
const videoSearch = req.query.videoSearch || '';
const commentPattern = `%${commentSearch}%`;
const videoPattern = `%${videoSearch}%`;
// (!!! 关键 SQL !!!)
// 我们使用 JOIN 来获取视频标题,并使用两个独立的 ILIKE 来实现双重筛选
const commentsQuery = sql`
WITH CommentData AS (
SELECT
c.comment_id,
c.content,
-- 关联 videos 表获取标题
v.title AS video_title,
-- 格式化时间戳
TO_CHAR(c.created_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD HH24:MI') AS created_at,
-- 使用 COUNT(*) OVER() 来获取筛选后的总行数,用于分页
COUNT(*) OVER() AS total_count
FROM comments c
JOIN videos v ON c.video_id = v.id
WHERE
-- 筛选 1: 评论内容
c.content ILIKE ${commentPattern}
AND
-- 筛选 2: 视频标题
v.title ILIKE ${videoPattern}
),
PagedData AS (
SELECT * FROM CommentData
ORDER BY created_at DESC -- 评论按最新时间排序
LIMIT ${limit}
OFFSET ${offset}
)
SELECT * FROM PagedData;
`;
const result = await commentsQuery;
const totalCount = result.length > 0 ? parseInt(result[0].total_count, 10) : 0;
const totalPages = Math.ceil(totalCount / limit);
// (可选) 清理掉每行都带的 total_count,减少传输体积
result.forEach(r => delete r.total_count);
res.status(200).json({
comments: result,
pagination: {
currentPage: page,
totalPages: totalPages,
totalCount: totalCount,
limit: limit
}
});
} catch (error) {
console.error('获取评论列表失败:', error);
res.status(500).json({ error: '数据库查询失败', details: error.message });
}
};
// --- 处理器 2: DELETE (删除评论) ---
const handleDelete = async (req, res, sql) => {
try {
const { commentId } = req.query;
if (!commentId) {
return res.status(400).json({ error: '缺少 commentId' });
}
console.log(`[Delete-Comment] 收到 ID:${commentId} 的删除请求`);
const result = await sql`
DELETE FROM comments
WHERE comment_id = ${commentId}
`;
if (result.rowCount === 0) {
return res.status(404).json({ error: '评论不存在或已被删除' });
}
console.log(`[Delete-Comment] ID:${commentId} 评论删除成功`);
res.status(200).json({ message: '删除成功', commentId: commentId });
} catch (error) {
console.error('删除评论失败:', error);
res.status(500).json({ error: '删除失败', details: error.message });
}
};
// --- 主分发器 (Main Dispatcher) ---
module.exports = async (req, res) => {
// 1. 初始化数据库
const connectionString = process.env.DATABASE_URL;
const sql = neon(connectionString);
// 2. 根据方法分发
if (req.method === 'GET') {
return await handleGet(req, res, sql);
}
if (req.method === 'DELETE') {
return await handleDelete(req, res, sql);
}
// 3. 不支持的方法
return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
};
5.2.5 analytics.js
analytics.js—统计分析,统计播放量前五、标签引用次数前五和标签播放量前五
// 文件名: api/analytics.js
// 职责: 提供 "统计分析" 仪表盘所需的所有数据
const { neon } = require('@neondatabase/serverless');
module.exports = async (req, res) => {
// 1. 仅支持 GET
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method Not Allowed' });
}
const connectionString = process.env.DATABASE_URL;
const sql = neon(connectionString);
try {
// 2. 并行执行三个 SQL 查询
const [topVideosResult, topTagsByViewsResult, topTagsByCountResult] = await Promise.all([
// 查询 1: 播放量 Top 5 视频
sql`
SELECT id, cover_key, title, views_count
FROM videos
ORDER BY views_count DESC
LIMIT 5
`,
// 查询 2: 价值 Top 5 标签 (按总播放量)
sql`
SELECT
t.tag_name,
SUM(v.views_count) AS total_views
FROM tags t
JOIN video_tags vt ON t.tag_id = vt.tag_id
JOIN videos v ON vt.video_id = v.id
GROUP BY t.tag_id, t.tag_name
ORDER BY total_views DESC
LIMIT 5
`,
// 查询 3: 热门 Top 5 标签 (按视频数量)
sql`
SELECT
t.tag_name,
COUNT(vt.video_id) AS video_count
FROM tags t
JOIN video_tags vt ON t.tag_id = vt.tag_id
GROUP BY t.tag_id, t.tag_name
ORDER BY video_count DESC
LIMIT 5
`
]);
// 3. 成功响应
res.status(200).json({
topVideos: topVideosResult,
topTagsByViews: topTagsByViewsResult,
topTagsByCount: topTagsByCountResult
});
} catch (error) {
console.error('获取统计数据失败:', error);
res.status(500).json({ error: '数据库查询失败', details: error.message });
}
};
6 测试部分
基于 Vercel Serverless + Neon (PostgreSQL) + 腾讯云 COS 架构的全栈视频点播 (VOD) 系统,从零搭建一套工业级、零成本的自动化测试流水线。为了击穿线上 CDN 缓存并解决前端异步渲染带来的超时痛点,自动化测试全面弃用了传统的 Selenium,转而拥抱 Pytest 结合 Playwright 的现代测试生态。本报告深入拆解了测试架构设计的两大核心:一是基于 Requests 实现的底层 API 深度数据断言与状态流转;二是利用 Playwright 强大的网络拦截功能,无缝串联用户的核心观影链路与管理员管理后台“造数据-改数据-清数据”的闭环。
项目测试部分链接🔗
7 遇到的问题
7.1 EO规则引擎的锅
尝试了以下几种做法
(1)我在腾讯云 EdgeOne 重新开启了解析vercel域名;把vercel的cdn和数据缓存都清空了,重新部署也不行;但是输入我的EO加速域名访问过了缓存时间的文件,一直就显示304 Not Modified,也不刷新
(2)本地vercel dev启动的服务没有问题,无论看域名还是js文件第一次都是200 OK,在缓存时间内是304 Not Modified。
(3)在vercel提供的域名也没问题,数据也是新的
(4)EO清除缓存也试了(后面仔细看了官方文档,语法使用不恰当)
原因:EO局部规则引擎 覆盖了 站点全局配置(默认配置了EO局部规则引擎,要点进去看)
破案了,这个局部规则引擎 默认节点缓存30天
访问的文件的时候,EO节点判定还在缓存时间(30天内),所以返回的都是缓存内容,导致几天看到的视频播放量和评论都没变化
7.2 图片渲染bug问题
从具体播放视频页面返回到主页面,偶尔视频封面抽搐
浏览器BFCache恢复时的渲染时序差异,需用CSS background-image替代 < img>标签解决。
7.3 视频上传页面和视频管理解决
前端上传文件与服务器端上传文件有所不同。服务器端直接调用后端SDK包上传即可(需要永久密钥),但是前端需要后端生成的STS临时密钥来上传文件。
刚开始疑惑为什么前端不能看到服务器端的永久密钥,前端代码是暴露的,如果看到了服务器端的永久密钥会造成危害,所以服务器端的环境变量不会提供给前端,前端也不知道。所以需要STS临时密钥这个中间人,限定时间内获得许可进行操作
7.4 页面缓存
有些数据是很少变化的,没必要经常访问数据库,消耗接口次数,可以加上缓存时间,缓存时间内直接从CDN节点返回缓存值
7.5 CDN缓存键规则配置
CDN对COS存储桶的视频进行了MD5加密(怕对MP4文件流量盗刷),形式类似于https://xxx.cn/dajiang.mp4?sign=xxxxxx&t=12345 。
但是默认的cdn缓存键配置忽略参数(为了提高CDN命中率),问题是我的COS存储桶是私有的,如果忽略参数https://xxx.cn/dajiang.mp4?sign=xxxxxx&t=12345就等价于https://xxx.cn/dajiang.mp4,导致访问失败403,所以这些参数不能被忽略
8 展望
8.1 视频转码
视频分辨率和帧率不统一
有的视频1080P都不卡,但是720P卡(30.12hz)
24、25、30、30.12、60hz视频多种格式
包括.H265和.H264格式各不相同(前者浏览器兼容性差点,但是相同画质体积更小)
下载视频文件速度飞快,但是在线观看速度就很慢(如下图,因为观看未转码的文件需要进行很多操作)
解决办法
(1) 买HLS (m3u8)方案,支持自适应码率,但是成本昂贵
(2)把元数据移到文件头,效果: 浏览器不需要下载完整个文件,只要下载前几 KB 就能立刻播放。解决“加载慢”的问题。解决帧率乱: 强制把帧率固定在 30fps,效果:解决 30.12Hz 导致的音画不同步和兼容性问题。确保编码是 H.264(所有浏览器都能播),防止传了 H.265 导致浏览器黑屏。
8.2 用户管理
没有对应的用户注册和管理员
找第三方库或自己实现
Supabase:是一站式 BaaS(后端即服务)平台,内置了完整的用户认证 / 授权系统(Auth),开箱即用,不用自己写登录、注册、密码重置等核心逻辑,还封装了现成的 API/SDK,专门解决用户管理这类通用需求。
8.3 国内CDN加速
域名没有备案,导致CDN加速视频只能加速境外的,COS存储桶在香港,数据库PGSql在新加坡,导致观看视频速度受影响。
对应的境外流量费用比境内流量费用贵不少。中国境内CDN的100GB下行流量(20元)和亚太一区CDN的100GB下行流量(45元),同样是一年,境外 CDN 流量成本比境内贵 125%




















1052

被折叠的 条评论
为什么被折叠?



