尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

从零实现真人投票短视频对战系统:类Appealr全栈实战

从零实现真人投票短视频对战系统:类Appealr全栈实战 最近在 Show HN 上看到一个很有辨识度的项目Appealr。它的产品描述非常简洁——15 秒短视频实时对战排名由真人投票决定而不是交给 AI 打分。这个方向在短视频社区、UGC 内容平台、直播 PK 等场景里都很有参考价值。本文会从产品逻辑和技术实现两个角度拆解这个项目并带大家从零实现一个简化版的人气对战投票系统包含视频登记、随机配对、投票计分、排行榜、防重复投票等完整链路。如果你是后端开发者、全栈爱好者或者想给社区类产品加一个互动投票玩法这篇文章可以直接当作参考实现来读。代码部分全部基于 Node.js Express 原生前端不依赖重型框架本地就能跑通方便你在此基础上扩展成自己的项目。1. 背景Appealr 是什么为什么“人肉投票”反而有价值1.1 一个 15 秒视频对战平台先解释一下 Show HN。这是 Hacker News 社区里专门给独立开发者展示早期项目的板块很多热门的开源项目和产品原型都在这里首发。Appealr 就是其中一个产品原型它的核心玩法可以拆成三步用户上传一段 15 秒以内的短视频。系统把两个视频随机组成一场“对战”。其他用户观看后投票选出更好的一个票数汇聚成最终排名。整个产品闭环里最关键的差异点是最后那一句“ranked by human votes (not AI)”。也就是说排名不靠算法打分、不靠 AI 审美模型而是靠真实用户的每一次点击投票。这种设计初看有点“返璞归真”细想却非常符合互动类产品的需求。当内容本身的评价标准没有绝对对错时用户投票带来的不只是排名结果还有参与感和讨论度。1.2 为什么强调 Human Votes而不是 AI在技术圈大家习惯性地会认为“AI 评分更高效、更客观”。但在短视频对战这类场景里AI 评分和真人投票解决的其实不是同一个问题。AI 评分适合有明确客观指标的任务比如视频是否清晰、是否有抖动。语音识别转文字是否准确。画面构图是否符合基础审美规则。而真人投票适合主观偏好很强的任务比如哪个视频更有创意。哪个视频更有梗、更让人想分享。哪个视频能让用户产生共鸣。更重要的是真人投票天然带有社交属性。用户投完票之后会想知道自己选的那个视频是不是最终赢家会想把有趣的对战转发给朋友会为了让自己喜欢的作品进排行榜而持续参与。这种循环是纯 AI 打分给不了的。当然这并不意味着 AI 在这个系统里完全无用。AI 完全可以承担辅助工作比如自动检测违禁内容、生成视频封面、过滤垃圾信息、做重复视频去重。Appealr 强调的是“最终排名由人决定”而不是“整个系统拒绝 AI”。这个度很重要我们在工程实现里也要注意。1.3 适用场景是什么这类“真人投票对战”的玩法实际应用场景比想象中广短视频挑战赛用户上传参赛视频通过 1v1 对战逐步淘汰。社区热门排行用投票替代纯播放量排序提升互动率。直播或活动 PK两个主播或两个作品现场对决。品牌营销互动让用户帮助品牌选下一季的产品配色或包装方案。课程作业互评同学们观看作品后匿名投票打分。从技术角度看实现一个最小可运行的投票对战系统能让你一次性接触全栈开发的常用模块文件上传、数据建模、随机配对算法、积分计算、防重复提交、前端交互、排行榜查询以及生产环境必须考虑的安全加固和性能优化。所以不管你是想理解这类产品还是想自己动手做一个下面的实战部分都值得看完。2. 系统设计拆解一个类 Appealr 的人气对战平台2.1 核心功能模块在写代码之前先把整个系统拆成功能模块。Appealr 的最小闭环可以分成以下几个部分视频登记模块用户提交视频标题和播放地址系统生成唯一 ID并初始化初始积分。对战配对模块从全量视频中随机选择两个生成一场“对战”。投票模块用户在对战中二选一系统记录投票结果并防止同一用户重复投票。积分计算模块根据投票结果更新两个视频的积分。排行榜模块按积分从高到低展示所有视频。前端交互模块展示对战双方视频、投票按钮、实时刷新排行榜。生产环境中通常还要加视频转码、内容审核、风控、多语言、用户账号体系等模块。但最小闭环以上面六个为准我们先把骨架跑通。2.2 技术选型思路本文 Demo 采用如下技术栈后端Node.js Express存储内存对象生产环境建议替换为 MySQL / PostgreSQL Redis视频本地静态文件生产环境建议使用对象存储 CDN前端原生 HTML CSS JavaScript选择这套组合的原因很直接它足够简单不需要数据库安装、不需要前端构建工具任何装了 Node.js 的机器都能在几分钟内跑起来。它又能完整覆盖这类系统的核心设计逻辑后续替换成生产级组件时思路是相通的。如果你要给这个项目做生产化改造替换路径大致如下模块Demo 方案生产方案Web 服务ExpressSpring Boot / Go / NestJS 均可数据存储内存对象MySQL / PostgreSQL缓存无Redis视频存储本地 uploadsOSS / S3 CDN实时推送轮询WebSocket / SSE异步任务无消息队列2.3 数据模型设计虽然 Demo 阶段用内存存储但设计时最好按数据库表来考虑这样以后迁移更顺手。下面是一组参考表结构以 MySQL 风格为例PostgreSQL 需要微调类型。-- 视频表 CREATE TABLE videos ( id VARCHAR(64) PRIMARY KEY, title VARCHAR(128) NOT NULL, url VARCHAR(512) NOT NULL, uploader VARCHAR(64), duration_seconds INT, status TINYINT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 对战表 CREATE TABLE matches ( id VARCHAR(64) PRIMARY KEY, left_video_id VARCHAR(64), right_video_id VARCHAR(64), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 投票表match_id user_id 唯一用于防重 CREATE TABLE votes ( id BIGINT AUTO_INCREMENT PRIMARY KEY, match_id VARCHAR(64), user_id VARCHAR(64), winner_video_id VARCHAR(64), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_match_user (match_id, user_id) ); -- 积分表 CREATE TABLE ratings ( video_id VARCHAR(64) PRIMARY KEY, elo_score INT DEFAULT 1500, wins INT DEFAULT 0, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP );这里最需要关注的是votes表里的联合唯一索引uk_match_user。它保证了同一用户对同一场对战只能投一票是防重复投票的核心手段之一。你可能会说“前端禁用按钮不就行了”但我们稍后会看到前端的禁用只是体验优化后端的唯一约束才是真正的安全兜底。3. 环境准备与项目初始化3.1 环境要求本文示例基于以下环境编写具体版本请按你的实际环境调整Node.js 16 或更高版本建议 18 LTSnpm随 Node.js 一起安装现代浏览器Chrome / Edge / Firefox 均可可选ffmpeg用于生成测试视频如果你本机已经装了 Node.js可以通过下面的命令检查版本node -v npm -v只要node -v能正常输出版本号就说明环境没问题。3.2 项目目录结构先规划好目录结构appealr-demo/ ├── package.json ├── server.js ├── uploads/ # 测试视频目录 │ ├── v1.mp4 │ ├── v2.mp4 │ ├── v3.mp4 │ └── v4.mp4 └── public/ ├── index.html └── app.js创建目录并初始化 npm 项目mkdir appealr-demo cd appealr-demo npm init -y npm install express mkdir uploads public安装完成后你会看到项目里多了一个node_modules目录和一个package-lock.json文件。npm 初始化生成的package.json里已经自动写好了项目名称和入口文件我们会在下一步把main指定成server.js。3.3 生成测试视频可选为了让 Demo 启动后立刻能看到效果我们需要准备几段 15 秒以内的短视频。如果你手头有现成的 mp4 素材直接复制到uploads目录并命名成v1.mp4、v2.mp4、v3.mp4、v4.mp4即可。如果没有素材可以用 ffmpeg 生成纯色测试视频命令如下ffmpeg -f lavfi -i colorcblue:s640x480:d15:r30 -pix_fmt yuv420p uploads/v1.mp4 ffmpeg -f lavfi -i colorcred:s640x480:d15:r30 -pix_fmt yuv420p uploads/v2.mp4 ffmpeg -f lavfi -i colorcgreen:s640x480:d15:r30 -pix_fmt yuv420p uploads/v3.mp4 ffmpeg -f lavfi -i colorcorange:s640x480:d15:r30 -pix_fmt yuv420p uploads/v4.mp4这条命令的作用是让 ffmpeg 生成 15 秒、640x480 分辨率、30 帧每秒的单色视频。参数解释如下-f lavfi使用 ffmpeg 的虚拟输入设备不依赖真实摄像头或文件。-i colorcblue:...指定视频内容为纯色画面颜色为蓝色。-pix_fmt yuv420p使用最常见的 YUV 4:2:0 像素格式保证浏览器能正常解码播放。如果你的 ffmpeg 没有正确安装或者你不想额外装工具跳过这一步也没关系。真正的项目里视频素材由用户上传开发阶段我们可以先用任意可访问的 mp4 文件替代。4. 核心实现视频登记、配对、投票与排行榜4.1 后端主文件 server.js完整的server.js如下这是一个可以直接运行的 Express 应用。我们先把它整体贴出来再逐个拆解关键函数。// 文件路径server.js const express require(express); const path require(path); const crypto require(crypto); const app express(); const PORT process.env.PORT || 3000; // 解析 JSON 请求体 app.use(express.json()); // 托管前端静态资源 app.use(express.static(path.join(__dirname, public))); // 托管上传的测试视频 app.use(/uploads, express.static(path.join(__dirname, uploads))); // 内存数据存储Demo 专用生产环境请替换为数据库 const db { videos: [], // 参赛视频列表 matches: [], // 已生成的对战 users: {}, // 用户投票记录 { userId: { matchId: winnerId } } rating: {}, // 视频 Elo 积分 { videoId: score } wins: {} // 视频获胜次数 { videoId: count } }; // Elo 评分参数 const ELO_K 32; const INITIAL_RATING 1500; const MATCH_TTL 60 * 1000; // 对战有效期 60 秒 // 种子数据方便 Demo 启动后立即体验 function seedVideos() { const demoVideos [ { id: v1, title: 蓝色测试视频, url: /uploads/v1.mp4 }, { id: v2, title: 红色测试视频, url: /uploads/v2.mp4 }, { id: v3, title: 绿色测试视频, url: /uploads/v3.mp4 }, { id: v4, title: 橙色测试视频, url: /uploads/v4.mp4 } ]; demoVideos.forEach(v { db.videos.push(v); db.rating[v.id] INITIAL_RATING; db.wins[v.id] 0; }); } seedVideos(); // 清理过期对战避免内存无限增长 function cleanupExpiredMatches() { const now Date.now(); db.matches db.matches.filter(m now - m.createdAt MATCH_TTL); } // 随机选择两个不同视频组成对战 function pickMatch() { if (db.videos.length 2) { return null; } const shuffled [...db.videos].sort(() Math.random() - 0.5); return { matchId: crypto.randomUUID(), left: shuffled[0], right: shuffled[1], createdAt: Date.now() }; } function createMatch() { cleanupExpiredMatches(); const match pickMatch(); if (match) { db.matches.push(match); } return match; } // 计算 Elo 期望胜率 function expectedScore(ra, rb) { return 1 / (1 Math.pow(10, (rb - ra) / 400)); } // 更新对战双方积分 function updateRating(winnerId, loserId) { const ra db.rating[winnerId]; const rb db.rating[loserId]; const ea expectedScore(ra, rb); const eb expectedScore(rb, ra); db.rating[winnerId] Math.round(ra ELO_K * (1 - ea)); db.rating[loserId] Math.round(rb ELO_K * (0 - eb)); db.wins[winnerId] (db.wins[winnerId] || 0) 1; } // 登记一个新视频 app.post(/api/videos, (req, res) { const { title, url, uploader } req.body; if (!title || !url) { return res.status(400).json({ error: title 和 url 不能为空 }); } const video { id: crypto.randomUUID(), title, url, uploader: uploader || anonymous, createdAt: Date.now() }; db.videos.push(video); db.rating[video.id] INITIAL_RATING; db.wins[video.id] 0; res.status(201).json({ video, rating: INITIAL_RATING }); }); // 获取一场对战 app.get(/api/match, (req, res) { const match createMatch(); if (!match) { return res.status(404).json({ error: 参赛视频不足无法配对 }); } res.json(match); }); // 提交投票 app.post(/api/vote, (req, res) { const { matchId, winnerId, userId } req.body; if (!matchId || !winnerId || !userId) { return res.status(400).json({ error: matchId、winnerId、userId 均为必填项 }); } const match db.matches.find(m m.matchId matchId); if (!match) { return res.status(404).json({ error: 对战不存在或已过期 }); } const isValidWinner match.left.id winnerId || match.right.id winnerId; if (!isValidWinner) { return res.status(400).json({ error: winnerId 不属于当前对战 }); } // 幂等控制同一个用户对同一场对战只能投一次 if (!db.users[userId]) { db.users[userId] {}; } if (db.users[userId][matchId]) { return res.status(409).json({ error: 已投过票不能重复投票 }); } db.users[userId][matchId] winnerId; const loser
返回列表