Java 点赞功能设计与实现完整指南

本文详细介绍如何从零设计和实现一个完整的点赞/取消点赞功能,包含数据库设计、API 开发、防刷限制、性能优化等完整实战。


一、概述

1.1 功能需求

  • 用户点赞:登录用户对内容(视频/文章)进行点赞
  • 取消点赞:再次点击取消点赞(toggle 模式)
  • 点赞计数:实时更新内容的点赞总数
  • 点赞状态:查询当前用户是否已点赞
  • 频率限制:每个用户每小时最多点赞 30 次
  • 游客点赞:未登录用户也可点赞(仅增加计数)

1.2 技术栈

层级 技术 说明
框架 Spring Boot + MyBatis Plus 开发框架
数据库 MySQL 关系型数据库
Mapper MyBatis XML + MyBatis Plus BaseMapper 数据访问
限流 自定义注解 @Limit 防刷控制
验证 @Validated 参数校验
接口文档 Swagger / Knife4j (@Api) API 文档

1.3 核心流程

用户点击点赞按钮
    ↓
请求到达 Controller
    ↓
@Limit 限流检查
    ↓
解析 Token 获取用户身份
    ↓
┌─── 有 Token(登录用户) ───┐    ┌─── 无 Token(游客) ───┐
│ Service.niceDetail()       │    │ 视频点赞数 +1          │
│ 查询 t_nice_detail         │    └─────────────────────────┘
└───────────┬───────────────┘
            ↓
    是否有该点赞记录?
    ├── 是 → 删除记录 → 视频点赞数 -1
    └── 否 → 新增记录 → 视频点赞数 +1

二、数据库设计

2.1 点赞记录表

-- 点赞明细表:记录每个用户对每个内容的点赞关系
CREATE TABLE `t_nice_detail` (
    `id`          INT         NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    `consumer_id` BIGINT      DEFAULT NULL COMMENT '用户ID(点赞者)',
    `video_id`    INT         DEFAULT NULL COMMENT '内容ID(被点赞的视频/文章)',
    `create_time` DATETIME    DEFAULT CURRENT_TIMESTAMP COMMENT '点赞时间',
    `update_time` DATETIME    DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_consumer_video` (`consumer_id`, `video_id`),
    KEY `idx_consumer_id` (`consumer_id`),
    KEY `idx_video_id` (`video_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点赞明细表';

2.2 内容点赞数表

-- 视频/内容表:包含点赞数字段(在原表追加)
-- 假设已有 t_video 表,追加 give_up 字段
ALTER TABLE `t_video`
    ADD COLUMN `give_up` INT DEFAULT 0 COMMENT '点赞数';

设计原则:点赞数预存在内容表中以避免每次 COUNT 查询。通过 t_nice_detail 表记录明细,通过 t_video.give_up 字段缓存总数。两者通过事务保持一致。

2.3 关键索引说明

索引 作用
uk_consumer_video 防止重复点赞(唯一约束)
idx_consumer_id 查询用户的所有点赞记录
idx_video_id 查询某内容的所有点赞用户

三、实体与 Mapper 层

3.1 点赞记录实体

import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

/**
 * 点赞明细实体
 */
@Data
@TableName("t_nice_detail")
public class NiceDetail {

    @TableId(value = "id", type = IdType.AUTO)
    @JsonIgnore
    @ApiModelProperty(hidden = true)
    private Integer id;

    /** 用户ID */
    private Long consumerId;

    /** 内容ID(视频/文章) */
    private Integer videoId;
}

3.2 视频实体(含点赞数)

import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;

@Data
@TableName("t_video")
public class Video {

    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /** 其他视频字段... */

    /** 点赞数 */
    private Long giveUp;
}

3.3 DAO 接口

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;

/**
 * 点赞明细 DAO
 */
@Repository
public interface NiceDetailDao extends BaseMapper<NiceDetail> {

    /**
     * 根据主键删除
     */
    int deleteByPrimaryKey(Integer id);

    /**
     * 查询单条点赞记录
     */
    NiceDetail findNiceDetail(NiceDetail niceDetail);

    /**
     * 选择性插入(仅插入非空字段)
     */
    int insertSelective(@Param("record") NiceDetail record);

    /**
     * 增加点赞数(用于游客点赞)
     */
    Integer insertGiveUp(Integer GiveUp);
}

3.4 MyBatis XML Mapper

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.wwtx.chinesemedicine.service.dao.NiceDetailDao">

    <resultMap id="BaseResultMap" type="com.wwtx.chinesemedicine.service.models.domain.NiceDetail">
        <id column="id" property="id" jdbcType="INTEGER"/>
        <result property="consumerId" column="consumer_id"/>
        <result property="videoId" column="video_id" jdbcType="INTEGER"/>
    </resultMap>

    <sql id="Base_Column_List">
        id, consumer_id, video_id
    </sql>

    <!-- 根据主键删除 -->
    <delete id="deleteByPrimaryKey" parameterType="java.lang.Integer">
        DELETE FROM t_nice_detail
        WHERE id = #{id,jdbcType=INTEGER}
    </delete>

    <!-- 查询单条点赞记录 -->
    <select id="findNiceDetail"
            resultMap="BaseResultMap"
            parameterType="com.wwtx.chinesemedicine.service.models.domain.NiceDetail">
        SELECT <include refid="Base_Column_List"/>
        FROM t_nice_detail
        WHERE consumer_id = #{consumerId,jdbcType=VARCHAR}
          AND video_id = #{videoId,jdbcType=INTEGER}
    </select>

    <!-- 选择性插入 -->
    <insert id="insertSelective"
            parameterType="com.wwtx.chinesemedicine.service.models.domain.NiceDetail">
        INSERT INTO t_nice_detail
        <trim prefix="(" suffix=")" suffixOverrides=",">
            <if test="record != null">
                consumer_id,
                video_id,
            </if>
        </trim>
        <trim prefix="values (" suffix=")" suffixOverrides=",">
            <if test="record != null">
                #{record.consumerId,jdbcType=VARCHAR},
                #{record.videoId,jdbcType=INTEGER},
            </if>
        </trim>
    </insert>

    <!-- 增加点赞数(游客) -->
    <update id="insertGiveUp"
            parameterType="com.wwtx.chinesemedicine.service.models.domain.Video">
        UPDATE t_video
        SET give_up = give_up + 1
        WHERE id = #{param2}
    </update>

</mapper>

3.5 MyBatis Plus 视频 Mapper

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;

@Repository
public interface VideoDao extends BaseMapper<Video> {

    /**
     * 批量查询点赞数
     */
    List<Video> selectByIds(@Param("giveUp") List<String> giveUp);
}

四、Service 层

4.1 接口定义

public interface NiceDetailService {

    /**
     * 插入点赞记录
     */
    Integer insertNiceDetail(NiceDetail niceDetail);

    /**
     * 删除点赞记录
     */
    Integer deleteNiceDetail(Integer id);

    /**
     * 查询点赞记录
     */
    NiceDetail findNiceDetail(NiceDetail niceDetail);

    /**
     * 点赞/取消点赞(核心业务)
     */
    JsonResult niceDetail(NiceDetail niceDetail);

    /**
     * 游客点赞数加一
     */
    Integer addGiveUp(Integer GiveUp);
}

4.2 核心实现(Toggle 模式)

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service("nicedetailService")
public class NiceDetailServiceImpl implements NiceDetailService {

    @Autowired
    private NiceDetailDao nicedetailDao;

    @Autowired
    private VideoDao videoDao;

    @Override
    public Integer insertNiceDetail(NiceDetail niceDetail) {
        return nicedetailDao.insertSelective(niceDetail);
    }

    @Override
    public Integer deleteNiceDetail(Integer id) {
        return nicedetailDao.deleteByPrimaryKey(id);
    }

    @Override
    public NiceDetail findNiceDetail(NiceDetail niceDetail) {
        return nicedetailDao.findNiceDetail(niceDetail);
    }

    /**
     * 点赞核心逻辑(toggle)
     *
     * 1. 查询是否有该用户的点赞记录
     * 2. 有记录 → 删除记录 → 点赞数 -1
     * 3. 无记录 → 新增记录 → 点赞数 +1
     */
    @Override
    public JsonResult niceDetail(NiceDetail niceDetail) {
        System.out.println("点赞模块:" + niceDetail.toString());

        // 1. 查询是否有该用户的点赞记录
        NiceDetail existingRecord = nicedetailDao.findNiceDetail(niceDetail);

        if (existingRecord != null) {
            // 已点赞 → 取消点赞
            System.out.println("有该记录,执行取消点赞");

            // 删除点赞记录
            nicedetailDao.deleteByPrimaryKey(existingRecord.getId());

            // 根据点赞记录找到视频
            Video video = videoDao.selectById(existingRecord.getVideoId());
            // 视频点赞数减一
            video.setGiveUp(video.getGiveUp() - 1);
            // 更新视频
            videoDao.updateById(video);

            Long result = video.getGiveUp();
            System.out.println("取消点赞,当前点赞数:" + result);

        } else {
            // 未点赞 → 添加点赞
            System.out.println("没有记录,执行添加点赞");

            // 添加点赞记录
            nicedetailDao.insertSelective(niceDetail);

            // 重新查询(获取自增ID)
            NiceDetail newRecord = nicedetailDao.findNiceDetail(niceDetail);

            // 根据点赞记录找到视频
            Video video = videoDao.selectById(newRecord.getVideoId());
            // 视频点赞数加一
            video.setGiveUp(video.getGiveUp() + 1);
            // 更新视频
            videoDao.updateById(video);

            Long result = video.getGiveUp();
            System.out.println("添加点赞,当前点赞数:" + result);
        }

        return JsonResult.success();
    }

    @Override
    public Integer addGiveUp(Integer videoId) {
        return nicedetailDao.insertGiveUp(videoId);
    }
}

4.3 查询用户点赞状态

/**
 * 批量查询用户对多个内容的点赞状态
 */
public Map<Integer, Boolean> checkLikeStatus(Long userId, List<Integer> videoIds) {
    if (userId == null || videoIds == null || videoIds.isEmpty()) {
        return Collections.emptyMap();
    }

    // 查询用户点赞过的视频ID
    LambdaQueryWrapper<NiceDetail> wrapper = Wrappers.lambdaQuery();
    wrapper.eq(NiceDetail::getConsumerId, userId);
    wrapper.in(NiceDetail::getVideoId, videoIds);

    List<NiceDetail> likedList = nicedetailDao.selectList(wrapper);

    // 转为 Map<videoId, true>
    Set<Integer> likedSet = likedList.stream()
            .map(NiceDetail::getVideoId)
            .collect(Collectors.toSet());

    // 返回所有视频的点赞状态
    return videoIds.stream()
            .collect(Collectors.toMap(
                id -> id,
                likedSet::contains
            ));
}

五、Controller 层

5.1 点赞 / 取消点赞接口

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import java.io.Serializable;

/**
 * 点赞控制器
 */
@RestController
@RequestMapping("api/nicedetail")
@Api(tags = "点赞")
@Validated
@CrossOrigin
public class NiceDetailController {

    @Autowired
    private NiceDetailService niceDetailService;

    @Autowired
    private HttpServletRequest httprequest;

    @Autowired
    private VideoService videoService;

    /**
     * 处理用户点赞行为
     *
     * 点赞做了限制,一个用户每小时只能点 30 次
     */
    @PostMapping("/niceDetail{userid,consumerId}")
    @ResponseBody
    @ApiOperation("点赞")
    @Limit(
        limitType = LimitType.SECONDS,
        period = 3600,
        limitParamTypes = {LimitParamType.PARAM, LimitParamType.IP},
        count = 30,
        params = "niceDetail.videoId"
    )
    public Serializable niceDetail(NiceDetail niceDetail) {

        // 获取请求头中的 Authorization 令牌
        String authorization = httprequest.getHeader("Authorization");

        Long uid = null;

        // 如果有 Token,解析用户 ID
        if (authorization != null) {
            uid = JWTUtil.getAppUID(authorization);
        }

        // 登录用户:执行点赞业务
        if (uid != null) {
            niceDetail.setConsumerId(uid);
            return niceDetailService.niceDetail(niceDetail);
        }
        // 游客:仅增加点赞计数
        else {
            // 游客点赞数加一
            Integer videoId = niceDetail.getVideoId();
            return niceDetailService.addGiveUp(videoId);
        }
    }
}

5.2 其他点赞相关接口

/**
 * 取消点赞(游客取消)
 */
@Limit(
    limitType = LimitType.SECONDS,
    period = 3600,
    limitParamTypes = {LimitParamType.PARAM, LimitParamType.IP},
    count = 30,
    params = "id"
)
@ApiOperation("游客取消点赞")
@DeleteMapping("unlikeVideoGiveUp/{id}")
public JsonResult unlikeVideo(Long id) {
    videoService.guest(id);
    return JsonResult.success();
}

/**
 * 查询用户点赞状态
 */
@GetMapping("/likeStatus")
@ApiOperation("查询点赞状态")
public Map<String, Boolean> getLikeStatus(@RequestParam Long userId,
                                           @RequestParam List<Integer> videoIds) {
    Map<Integer, Boolean> statusMap =
            niceDetailService.checkLikeStatus(userId, videoIds);
    // 转换为 String key 的 Map
    return statusMap.entrySet().stream()
            .collect(Collectors.toMap(
                e -> String.valueOf(e.getKey()),
                Map.Entry::getValue
            ));
}

/**
 * 查询视频的点赞总数
 */
@GetMapping("/count/{videoId}")
@ApiOperation("查询点赞总数")
public JsonResult getLikeCount(@PathVariable Integer videoId) {
    Video video = videoService.getById(videoId);
    if (video == null) {
        return JsonResult.error("视频不存在");
    }
    return JsonResult.success(video.getGiveUp());
}

5.3 使用 MyBatis Plus 简化 Mapper

// 使用 MyBatis Plus 的 LambdaQueryWrapper
// 可以完全替代 XML 中的 findNiceDetail 方法
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;

public NiceDetail findNiceDetailByPlus(NiceDetail niceDetail) {
    LambdaQueryWrapper<NiceDetail> wrapper = Wrappers.lambdaQuery();
    wrapper.eq(NiceDetail::getConsumerId, niceDetail.getConsumerId());
    wrapper.eq(NiceDetail::getVideoId, niceDetail.getVideoId());
    return nicedetailDao.selectOne(wrapper);
}

六、频率限制(@Limit 注解)

6.1 自定义注解

import java.lang.annotation.*;

/**
 * 限流注解
 * 用于控制接口调用频率,防止刷点赞
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Limit {

    /** 限流类型(秒/分钟/小时) */
    LimitType limitType() default LimitType.SECONDS;

    /** 时间周期(单位与 limitType 对应) */
    int period() default 3600;

    /** 限制参数类型 */
    LimitParamType[] limitParamTypes() default {};

    /** 周期内允许的最大次数 */
    int count() default 30;

    /** 参数名称(用于标识不同的限流维度) */
    String[] params() default {};
}

enum LimitType {
    SECONDS, MINUTES, HOURS
}

enum LimitParamType {
    IP,        // 按 IP 限流
    USER_ID,   // 按用户 ID 限流
    PARAM      // 按参数值限流
}

6.2 限流实现(AOP)

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;

@Aspect
@Component
public class LimitAspect {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Around("@annotation(limit)")
    public Object around(ProceedingJoinPoint pjp, Limit limit) throws Throwable {
        // 构建限流 key
        String key = buildLimitKey(limit);

        // 获取当前计数
        String countStr = redisTemplate.opsForValue().get(key);
        int count = countStr == null ? 0 : Integer.parseInt(countStr);

        // 判断是否超过限制
        if (count >= limit.count()) {
            throw new RuntimeException("操作太频繁,请稍后再试");
        }

        // 计数 +1
        long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
        if (ttl < 0) {
            // 首次计数,设置过期时间
            redisTemplate.opsForValue().set(key, "1",
                    limit.period(), TimeUnit.SECONDS);
        } else {
            redisTemplate.opsForValue().increment(key);
        }

        return pjp.proceed();
    }

    private String buildLimitKey(Limit limit) {
        // 根据 IP + 参数构建唯一 key
        String ip = HttpContextUtil.getIpAddress();
        return "limit:" + ip + ":" + limit.params()[0];
    }
}

七、事务控制

7.1 添加事务保证数据一致性

import org.springframework.transaction.annotation.Transactional;

/**
 * 带事务的 toggle 实现
 */
@Transactional(rollbackFor = Exception.class)
@Override
public JsonResult niceDetailWithTx(NiceDetail niceDetail) {

    NiceDetail existingRecord = nicedetailDao.findNiceDetail(niceDetail);

    if (existingRecord != null) {
        // 取消点赞
        int deleted = nicedetailDao.deleteByPrimaryKey(existingRecord.getId());
        if (deleted == 0) {
            throw new RuntimeException("取消点赞失败");
        }

        Video video = videoDao.selectById(existingRecord.getVideoId());
        if (video == null) {
            throw new RuntimeException("视频不存在");
        }

        long newCount = video.getGiveUp() - 1;
        if (newCount < 0) newCount = 0;  // 防止负数

        video.setGiveUp(newCount);
        int updated = videoDao.updateById(video);
        if (updated == 0) {
            throw new RuntimeException("更新点赞数失败");
        }

    } else {
        // 添加点赞
        int inserted = nicedetailDao.insertSelective(niceDetail);
        if (inserted == 0) {
            throw new RuntimeException("添加点赞记录失败");
        }

        NiceDetail newRecord = nicedetailDao.findNiceDetail(niceDetail);
        Video video = videoDao.selectById(newRecord.getVideoId());

        video.setGiveUp(video.getGiveUp() + 1);
        videoDao.updateById(video);
    }

    return JsonResult.success();
}

7.2 使用 CAS 乐观锁防止并发问题

-- SQL 中使用 CAS 方式更新点赞数
UPDATE t_video
SET give_up = give_up + 1
WHERE id = #{videoId}
  AND give_up >= 0;

-- 或者使用版本号乐观锁
ALTER TABLE t_video ADD COLUMN version INT DEFAULT 0;

UPDATE t_video
SET give_up = give_up + 1, version = version + 1
WHERE id = #{videoId} AND version = #{oldVersion};

八、性能优化

8.1 使用 Redis 替代数据库的点赞明细

/**
 * Redis 版本的点赞服务
 * 点赞明细存在 Redis Set 中,点赞数存在 Redis String 中
 * 定时同步到数据库
 */
@Service
public class RedisNiceDetailService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    private static final String LIKE_SET_KEY = "like:set:%s";      // like:set:{videoId}
    private static final String LIKE_COUNT_KEY = "like:count:%s"; // like:count:{videoId}

    /**
     * 点赞/取消点赞(Redis 操作)
     */
    public boolean toggleLike(Long videoId, Long userId) {
        String setKey = String.format(LIKE_SET_KEY, videoId);
        String countKey = String.format(LIKE_COUNT_KEY, videoId);

        Boolean isLiked = redisTemplate.opsForSet().isMember(setKey, userId.toString());

        if (Boolean.TRUE.equals(isLiked)) {
            // 取消点赞
            redisTemplate.opsForSet().remove(setKey, userId.toString());
            redisTemplate.opsForValue().decrement(countKey);
            return false;  // 当前未点赞
        } else {
            // 点赞
            redisTemplate.opsForSet().add(setKey, userId.toString());
            redisTemplate.opsForValue().increment(countKey);
            return true;   // 当前已点赞
        }
    }

    /**
     * 获取点赞数
     */
    public Long getLikeCount(Long videoId) {
        String countKey = String.format(LIKE_COUNT_KEY, videoId);
        String count = redisTemplate.opsForValue().get(countKey);
        return count == null ? 0L : Long.parseLong(count);
    }

    /**
     * 是否已点赞
     */
    public boolean isLiked(Long videoId, Long userId) {
        if (userId == null) return false;
        String setKey = String.format(LIKE_SET_KEY, videoId);
        return Boolean.TRUE.equals(
                redisTemplate.opsForSet().isMember(setKey, userId.toString()));
    }

    /**
     * 批量查询点赞状态
     */
    public Map<Long, Boolean> batchCheckLiked(Long videoId, List<Long> userIds) {
        String setKey = String.format(LIKE_SET_KEY, videoId);
        List<Object> members = redisTemplate.opsForSet().pop(setKey, 0);
        // 使用 pipeline 批量查询
        List<Boolean> results = redisTemplate.executePipelined(
            (RedisCallback<Object>) connection -> {
                for (Long userId : userIds) {
                    connection.sIsMember(
                            setKey.getBytes(),
                            userId.toString().getBytes());
                }
                return null;
            });

        Map<Long, Boolean> map = new HashMap<>();
        for (int i = 0; i < userIds.size(); i++) {
            map.put(userIds.get(i),
                    Boolean.TRUE.equals(results.get(i)));
        }
        return map;
    }
}

8.2 定时同步 Redis 到数据库

import org.springframework.scheduling.annotation.Scheduled;

/**
 * 定时同步 Redis 点赞数据到 MySQL
 */
@Component
public class LikeSyncTask {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private VideoDao videoDao;

    /**
     * 每 5 分钟同步一次点赞数到数据库
     */
    @Scheduled(fixedRate = 300000)
    public void syncLikeCountToDB() {
        // 扫描所有 like:count:* 的 key
        Set<String> keys = redisTemplate.keys("like:count:*");
        if (keys == null || keys.isEmpty()) return;

        for (String key : keys) {
            String videoIdStr = key.replace("like:count:", "");
            String countStr = redisTemplate.opsForValue().get(key);

            if (countStr != null && videoIdStr != null) {
                Long videoId = Long.parseLong(videoIdStr);
                Long count = Long.parseLong(countStr);

                // 更新数据库
                videoDao.updateGiveUpCount(videoId, count);
            }
        }
    }
}

8.3 使用布隆过滤器防止缓存穿透

/**
 * 初始化布隆过滤器(防止恶意请求不存在的视频ID)
 */
@PostConstruct
public void initBloomFilter() {
    // 假设有 100 万个视频 ID
    int expectedInsertions = 1_000_000;
    double fpp = 0.01;  // 误判率 1%

    BloomFilter<Long> bloomFilter = BloomFilter.create(
            Funnels.longFunnel(),
            expectedInsertions,
            fpp);

    // 从数据库加载所有视频 ID
    List<Video> allVideos = videoDao.selectList(null);
    for (Video video : allVideos) {
        bloomFilter.put(video.getId());
    }
}

九、完整 API 文档

9.1 接口汇总

方法 路径 说明 登录
POST /api/nicedetail/niceDetail{userid,consumerId} 点赞/取消点赞 可选
DELETE /api/nicedetail/unlikeVideoGiveUp/{id} 游客取消点赞
GET /api/nicedetail/likeStatus 查询点赞状态
GET /api/nicedetail/count/{videoId} 查询点赞总数

9.2 请求示例

# 点赞/取消点赞(登录)
POST /api/nicedetail/niceDetail{userid,consumerId}
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json

{
    "videoId": 123
}

# 查询点赞总数
GET /api/nicedetail/count/123

# 查询点赞状态
GET /api/nicedetail/likeStatus?userId=456&videoIds=123,124,125

9.3 响应格式

// 点赞成功
{
    "code": 200,
    "message": "success",
    "data": null
}

// 查询点赞总数
{
    "code": 200,
    "message": "success",
    "data": {
        "totalLikes": 128
    }
}

// 查询点赞状态
{
    "123": true,
    "124": false,
    "125": true
}

// 超过频率限制
{
    "code": 429,
    "message": "操作太频繁,请稍后再试",
    "data": null
}

十、常见问题排查

10.1 高并发问题

问题 原因 解决方案
点赞数不准确 并发更新导致丢失 使用 Redis + 定时同步
重复点赞记录 并发插入 使用唯一索引 + try-catch
点赞数负数 并发扣减 SQL 加 AND give_up > 0
超时 大量 COUNT 查询 点赞数缓存到内容表

10.2 数据一致性

/**
 * 最终一致性方案
 */
// 1. Redis 实时处理(毫秒级)
// 2. 定时同步到 MySQL(分钟级)
// 3. 兜底:页面展示 Redis 数据,后端用 MySQL 数据做统计

// 如果 Redis 宕机,降级方案:
public JsonResult niceDetailFallback(NiceDetail niceDetail) {
    try {
        return niceDetailService.niceDetailWithTx(niceDetail);
    } catch (Exception e) {
        log.error("点赞失败,降级处理");
        return JsonResult.error("点赞服务暂时不可用");
    }
}

10.3 Token 解析

// JWT Token 解析工具类
public class JWTUtil {

    public static Long getAppUID(String authorization) {
        if (authorization == null || authorization.isEmpty()) {
            return null;
        }
        try {
            // 去掉 "Bearer " 前缀
            String token = authorization.startsWith("Bearer ")
                    ? authorization.substring(7)
                    : authorization;

            // 解析 Token
            DecodedJWT claims = JWT.require(Algorithm.HMAC256("secret"))
                    .build()
                    .verify(token);

            return claims.getClaim("userId").asLong();
        } catch (Exception e) {
            // Token 无效或过期
            return null;
        }
    }
}

10.4 快速排查清单

  • 数据库 t_nice_detail 是否有 uk_consumer_video 唯一索引
  • 点赞数 give_up 字段是否有默认值 0
  • 高并发场景是否使用 Redis
  • 是否加事务保证明细表和计数一致性
  • 游客点赞是否做了限流
  • Token 过期后是否优雅降级到游客模式
  • 限流注解的 periodcount 是否符合业务需求
  • 接口是否有防重复提交处理(前端按钮置灰 + 后端幂等)

十一、总结

功能 关键代码 说明
点赞记录 t_nice_detail 记录用户-内容关系
点赞计数 t_video.give_up 字段 反范式缓存总数
切换点赞 toggle: 有就删 / 无就增 避免引入状态字段
限流 @Limit(count=30, period=3600) 每小时 30 次
身份识别 JWTUtil.getAppUID(token) 解析用户 ID
游客处理 addGiveUp(videoId) 仅更新计数
Redis 加速 Set 存明细,String 存计数 毫秒级响应
数据同步 @Scheduled 定时任务 持久化到 MySQL

Redis vs MySQL 方案对比

对比项 MySQL 直接操作 Redis + 定时同步
响应速度 慢(磁盘IO) 快(内存操作)
点赞状态查询 COUNT 查询 SISMEMBER O(1)
数据持久性 实时持久化 异步同步(最终一致)
并发能力 中等(2000 QPS) 高(10万+ QPS)
实现复杂度
适用场景 小规模(日活 < 1万) 大规模(日活 > 10万)

推荐策略: 小型系统直接用 MySQL + 事务;大型系统用 Redis 处理实时操作 + 定时任务同步到 MySQL 做持久化。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐