在线点播系统

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 项目核心依赖说明

环境初始化与核心依赖安装

  1. 项目初始化:运行 npm init -y 快速生成默认配置,集中管理项目依赖。
  2. 集成存储服务:通过 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%

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值