在内容型网站中,视频收藏是提升用户留存的基础交互。用PHP实现这一功能,重点不在于页面按钮,而在于后端如何设计数据关系、处理并发写入以及高效返回列表。下面从表结构开始,逐步拆解实现逻辑。
一、数据表结构设计
收藏本质是多对多关系:一个用户可收藏多个视频,一个视频也可被多个用户收藏。因此必须单独建一张关联表,而不能在用户表或视频表里加数组字段。关联表只需三个核心字段,外加时间戳用于排序。
建表时务必给 user_id 与 video_id 建立联合唯一索引。这样即使PHP层判断失效,数据库也会拒绝重复插入,从底层杜绝脏数据。下面是MySQL表结构示例:
CREATE TABLE `user_video_favorite` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `user_id` int(11) unsigned NOT NULL COMMENT '用户ID', `video_id` int(11) unsigned NOT NULL COMMENT '视频ID', `created_at` int(11) unsigned NOT NULL COMMENT '收藏时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_video` (`user_id`,`video_id`), KEY `idx_video_id` (`video_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
视频主表可保持简单,至少包含 id、title、cover_url 等字段。统计某视频总收藏数时,不需要在视频表冗余字段,可用 count 查询或定时任务同步,避免每次收藏都更新主表造成锁竞争。
如果业务需要区分收藏夹分类,可再加一个 favorite_group_id 字段,但初期版本建议先不做,降低复杂度。联合唯一索引要调整为 (user_id, video_id, favorite_group_id)。
二、收藏与取消收藏接口
后端接收前端 POST 请求,参数通常包括 user_id 和 video_id。在写入前,应先检查视频是否存在,防止收藏了脏ID。随后尝试插入,并利用数据库唯一索引捕获重复异常,而不是先 select 再 insert,减少一次查询且避免并发竞态。
取消收藏直接用 delete 按 user_id 和 video_id 删除即可。如果前端频繁切换收藏状态,建议提供单个 toggle 接口,根据受影响行数判断当前操作是收藏还是取消,减少接口数量。
<?php
function toggleFavorite($pdo, $userId, $videoId) {
// 先确认视频存在
$stmt = $pdo->prepare("SELECT id FROM video WHERE id = ?");
$stmt->execute([$videoId]);
if (!$stmt->fetch()) {
return ['code' => 404, 'msg' => '视频不存在'];
}
// 尝试插入,依赖唯一索引防重复
$stmt = $pdo->prepare(
"INSERT IGNORE INTO user_video_favorite (user_id, video_id, created_at) VALUES (?, ?, ?)"
);
$stmt->execute([$userId, $videoId, time()]);
if ($stmt->rowCount() > 0) {
return ['code' => 0, 'msg' => '已收藏', 'status' => 1];
}
// 插入失败说明已收藏,执行取消
$stmt = $pdo->prepare(
"DELETE FROM user_video_favorite WHERE user_id = ? AND video_id = ?"
);
$stmt->execute([$userId, $videoId]);
return ['code' => 0, 'msg' => '已取消', 'status' => 0];
}
?>
使用 INSERT IGNORE 是此处的技巧:当违反唯一索引时,MySQL 不报错只返回0行影响。相比捕获异常,代码更简洁。若用普通 INSERT 再捕重复错误码 1062,也可行但需处理异常分支。
在高并发场景下,可在 Redis 用 set 缓存用户收藏的 video_id,减轻数据库压力。但缓存与数据库一致性要用延时双删或消息队列处理,新手建议先跑通数据库逻辑再优化。
三、获取用户收藏列表
用户中心展示收藏视频时,要关联视频表取出标题与封面,并按收藏时间倒序。直接用 JOIN 即可,避免先查收藏表再循环查视频表引发 N+1 查询。
列表接口应支持分页,用 LIMIT 和偏移量控制。同时可一并查出每个视频的总收藏数,方便前端展示热度,但总数字段建议用子查询,不要影响主分页性能。
<?php
function getFavoriteList($pdo, $userId, $page = 1, $size = 10) {
$offset = ($page - 1) * $size;
$sql = "SELECT v.id, v.title, v.cover_url, f.created_at,
(SELECT COUNT(*) FROM user_video_favorite WHERE video_id = v.id) AS total_fav
FROM user_video_favorite f
INNER JOIN video v ON v.id = f.video_id
WHERE f.user_id = ?
ORDER BY f.created_at DESC
LIMIT ? OFFSET ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$userId, $size, $offset]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
?>
上面代码中,total_fav 用相关子查询获取,数据量不大时可接受。若视频收藏量到百万级,应改为独立计数表或 Redis 计数器,在收藏取消时同步增减。
前端拿到列表后,可根据 created_at 做相对时间显示,比如“3天前收藏”。后端只返回时间戳,格式化交给客户端更灵活。
四、防刷与权限控制
收藏接口若不加限制,恶意用户可用脚本狂刷,撑爆关联表。基础防护是在网关层对单用户做每秒请求数限流,PHP内部也可记录用户最后操作时间,间隔小于200毫秒直接拒绝。
另外,user_id 不能信任前端传参,必须从登录态(如 JWT 或 session)解析。否则用户A能收藏到用户B名下,引发越权漏洞。下面示例展示从 session 取用户ID:
<?php
session_start();
if (empty($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['code' => 401, 'msg' => '未登录']);
exit;
}
$userId = (int)$_SESSION['user_id'];
?>
权限校验应放在所有收藏相关接口的最前面,形成统一中间件。这样新增接口时不会漏掉,也方便后续接入 OAuth 第三方登录。
最后,数据库层面的联合唯一索引不仅是防重复,也是防刷的最后防线。即使限流被绕过,脏数据也不会进表,只是接口返回已收藏,对系统影响可控。