最近在 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 服务 | Express | Spring Boot / Go / NestJS 均可 |
| 数据存储 | 内存对象 | MySQL / PostgreSQL |
| 缓存 | 无 | Redis |
| 视频存储 | 本地 uploads | OSS / 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 LTS)
- npm(随 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 "color=c=blue:s=640x480:d=15:r=30" -pix_fmt yuv420p uploads/v1.mp4 ffmpeg -f lavfi -i "color=c=red:s=640x480:d=15:r=30" -pix_fmt yuv420p uploads/v2.mp4 ffmpeg -f lavfi -i "color=c=green:s=640x480:d=15:r=30" -pix_fmt yuv420p uploads/v3.mp4 ffmpeg -f lavfi -i "color=c=orange:s=640x480:d=15:r=30" -pix_fmt yuv420p uploads/v4.mp4这条命令的作用是让 ffmpeg 生成 15 秒、640x480 分辨率、30 帧每秒的单色视频。参数解释如下:
-f lavfi:使用 ffmpeg 的虚拟输入设备,不依赖真实摄像头或文件。-i "color=c=blue:...":指定视频内容为纯色画面,颜色为蓝色。-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