feat(video): 添加视频查看权限控制功能
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good

- 新增视频查看权限相关数据结构和接口
- 实现用户视频查看记录的创建和更新逻辑
- 添加视频查看权限的检查和记录功能
-优化分布式环境下的并发控制
This commit is contained in:
2025-09-18 18:42:53 +08:00
parent 7820a282d9
commit 7a35551a7b
5 changed files with 645 additions and 1 deletions

View File

@@ -1,23 +1,93 @@
package com.ycwl.basic.controller.mobile; package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.constant.BaseContextHandler;
import com.ycwl.basic.model.mobile.video.dto.VideoViewPermissionDTO;
import com.ycwl.basic.model.task.req.VideoInfoReq; import com.ycwl.basic.model.task.req.VideoInfoReq;
import com.ycwl.basic.repository.VideoRepository; import com.ycwl.basic.repository.VideoRepository;
import com.ycwl.basic.service.mobile.VideoViewPermissionService;
import com.ycwl.basic.utils.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@Deprecated @Slf4j
@RestController @RestController
@RequestMapping("/api/mobile/video/v1") @RequestMapping("/api/mobile/video/v1")
public class AppVideoController { public class AppVideoController {
@Autowired @Autowired
private VideoRepository videoRepository; private VideoRepository videoRepository;
@Autowired
private VideoViewPermissionService videoViewPermissionService;
@PostMapping("/{videoId}/updateMeta") @PostMapping("/{videoId}/updateMeta")
public void updateMeta(@PathVariable("videoId") Long videoId, @RequestBody VideoInfoReq req) { public void updateMeta(@PathVariable("videoId") Long videoId, @RequestBody VideoInfoReq req) {
videoRepository.updateMeta(videoId, req); videoRepository.updateMeta(videoId, req);
} }
/**
* 记录用户查看视频并返回权限信息
*
* @param videoId 视频ID
* @return 查看权限信息
*/
@PostMapping("/{videoId}/recordView")
public ApiResponse<VideoViewPermissionDTO> recordView(@PathVariable("videoId") Long videoId) {
try {
String userIdStr = BaseContextHandler.getUserId();
if (userIdStr == null || userIdStr.isEmpty()) {
log.warn("用户未登录,无法记录查看: videoId={}", videoId);
return ApiResponse.fail("用户未登录");
}
Long userId = Long.valueOf(userIdStr);
log.debug("记录用户查看视频: userId={}, videoId={}", userId, videoId);
VideoViewPermissionDTO permission = videoViewPermissionService.checkAndRecordView(userId, videoId);
return ApiResponse.success(permission);
} catch (NumberFormatException e) {
log.error("用户ID格式错误: userId={}, videoId={}", BaseContextHandler.getUserId(), videoId, e);
return ApiResponse.fail("用户信息无效");
} catch (Exception e) {
log.error("记录用户查看视频失败: videoId={}", videoId, e);
return ApiResponse.fail("记录查看失败,请稍后重试");
}
}
/**
* 检查用户查看权限(不记录查看次数)
*
* @param videoId 视频ID
* @return 查看权限信息
*/
@GetMapping("/{videoId}/checkPermission")
public ApiResponse<VideoViewPermissionDTO> checkPermission(@PathVariable("videoId") Long videoId) {
try {
String userIdStr = BaseContextHandler.getUserId();
if (userIdStr == null || userIdStr.isEmpty()) {
log.warn("用户未登录,无法查看权限: videoId={}", videoId);
return ApiResponse.fail("用户未登录");
}
Long userId = Long.valueOf(userIdStr);
log.debug("检查用户查看权限: userId={}, videoId={}", userId, videoId);
VideoViewPermissionDTO permission = videoViewPermissionService.checkViewPermission(userId, videoId);
return ApiResponse.success(permission);
} catch (NumberFormatException e) {
log.error("用户ID格式错误: userId={}, videoId={}", BaseContextHandler.getUserId(), videoId, e);
return ApiResponse.fail("用户信息无效");
} catch (Exception e) {
log.error("检查用户查看权限失败: videoId={}", videoId, e);
return ApiResponse.fail("权限检查失败,请稍后重试");
}
}
} }

View File

@@ -0,0 +1,119 @@
package com.ycwl.basic.model.mobile.video.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* 视频查看权限响应DTO
* 包含用户查看视频的权限信息和限制详情
*/
@Data
public class VideoViewPermissionDTO {
/**
* 是否可以查看
*/
@JsonProperty("canView")
private Boolean canView;
/**
* 是否无限制查看
*/
private Boolean isUnlimited;
/**
* 当前查看次数
*/
private Integer currentViewCount;
/**
* 最大查看次数(0表示无限制)
*/
private Integer maxViewCount;
/**
* 时间限制(秒,0表示无限制)
*/
private Integer timeLimit;
/**
* 剩余完整查看次数
*/
private Integer remainingViews;
/**
* 权限信息描述
*/
private String message;
/**
* 是否为限时查看模式
*/
private Boolean isTimeLimitMode;
public VideoViewPermissionDTO() {
this.canView = true;
this.isUnlimited = false;
this.currentViewCount = 0;
this.maxViewCount = 0;
this.timeLimit = 0;
this.remainingViews = 0;
this.isTimeLimitMode = false;
}
/**
* 创建无限制查看的权限对象
*/
public static VideoViewPermissionDTO createUnlimitedPermission(Integer currentViewCount) {
VideoViewPermissionDTO dto = new VideoViewPermissionDTO();
dto.setCanView(true);
dto.setIsUnlimited(true);
dto.setCurrentViewCount(currentViewCount);
dto.setMessage("无限制查看");
return dto;
}
/**
* 创建完整查看权限对象
*/
public static VideoViewPermissionDTO createFullViewPermission(Integer currentViewCount, Integer maxViewCount) {
VideoViewPermissionDTO dto = new VideoViewPermissionDTO();
dto.setCanView(true);
dto.setIsUnlimited(false);
dto.setCurrentViewCount(currentViewCount);
dto.setMaxViewCount(maxViewCount);
dto.setRemainingViews(Math.max(0, maxViewCount - currentViewCount));
dto.setMessage("完整查看模式,剩余" + dto.getRemainingViews() + "");
return dto;
}
/**
* 创建限时查看权限对象
*/
public static VideoViewPermissionDTO createTimeLimitPermission(Integer currentViewCount, Integer maxViewCount, Integer timeLimit) {
VideoViewPermissionDTO dto = new VideoViewPermissionDTO();
dto.setCanView(true);
dto.setIsUnlimited(false);
dto.setIsTimeLimitMode(true);
dto.setCurrentViewCount(currentViewCount);
dto.setMaxViewCount(maxViewCount);
dto.setTimeLimit(timeLimit);
dto.setRemainingViews(0);
dto.setMessage("限时查看模式,每次可查看" + timeLimit + "");
return dto;
}
/**
* 创建禁止查看权限对象
*/
public static VideoViewPermissionDTO createNoPermission(Integer currentViewCount, Integer maxViewCount) {
VideoViewPermissionDTO dto = new VideoViewPermissionDTO();
dto.setCanView(false);
dto.setIsUnlimited(false);
dto.setCurrentViewCount(currentViewCount);
dto.setMaxViewCount(maxViewCount);
dto.setRemainingViews(0);
dto.setMessage("已达到查看次数限制,无法继续观看");
return dto;
}
}

View File

@@ -0,0 +1,57 @@
package com.ycwl.basic.model.pc.video.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
/**
* 用户视频查看记录实体
* 记录用户查看vlog视频的次数和时间
*/
@Data
@TableName("user_video_view_record")
public class UserVideoViewEntity {
/**
* 主键ID
*/
@TableId
private Long id;
/**
* 用户ID
*/
private Long userId;
/**
* 视频ID
*/
private Long videoId;
/**
* 景区ID
*/
private Long scenicId;
/**
* 完整查看次数
*/
private Integer viewCount;
/**
* 最后查看时间
*/
private Date lastViewTime;
/**
* 创建时间
*/
private Date createTime;
/**
* 更新时间
*/
private Date updateTime;
}

View File

@@ -0,0 +1,168 @@
package com.ycwl.basic.repository;
import com.ycwl.basic.model.pc.video.entity.UserVideoViewEntity;
import com.ycwl.basic.utils.JacksonUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.concurrent.TimeUnit;
/**
* 用户视频查看记录Repository
* 仅使用Redis存储查看记录
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class UserVideoViewRepository {
private final RedisTemplate<String, String> redisTemplate;
private static final String USER_VIDEO_VIEW_CACHE_KEY = "user_video_view:%s:%s";
private static final String VIEW_RECORD_LOCK_KEY = "view_record_lock:%s:%s";
private static final int CACHE_EXPIRE_HOURS = 72; // 3天过期
private static final int LOCK_EXPIRE_SECONDS = 30;
/**
* 获取用户视频查看记录
*
* @param userId 用户ID
* @param videoId 视频ID
* @return 查看记录
*/
public UserVideoViewEntity getViewRecord(Long userId, Long videoId) {
String cacheKey = String.format(USER_VIDEO_VIEW_CACHE_KEY, userId, videoId);
if (redisTemplate.hasKey(cacheKey)) {
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JacksonUtil.parseObject(cached, UserVideoViewEntity.class);
}
}
return null;
}
/**
* 创建查看记录
*
* @param userId 用户ID
* @param videoId 视频ID
* @param scenicId 景区ID
* @return 查看记录
*/
public UserVideoViewEntity createViewRecord(Long userId, Long videoId, Long scenicId) {
UserVideoViewEntity entity = new UserVideoViewEntity();
entity.setUserId(userId);
entity.setVideoId(videoId);
entity.setScenicId(scenicId);
entity.setViewCount(1);
entity.setLastViewTime(new Date());
entity.setCreateTime(new Date());
entity.setUpdateTime(new Date());
String cacheKey = String.format(USER_VIDEO_VIEW_CACHE_KEY, userId, videoId);
redisTemplate.opsForValue().set(cacheKey, JacksonUtil.toJSONString(entity),
CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
log.debug("创建用户视频查看记录到Redis: userId={}, videoId={}, scenicId={}", userId, videoId, scenicId);
return entity;
}
/**
* 更新查看次数
*
* @param userId 用户ID
* @param videoId 视频ID
* @param viewCount 新的查看次数
* @return 是否更新成功
*/
public boolean updateViewCount(Long userId, Long videoId, Integer viewCount) {
UserVideoViewEntity entity = getViewRecord(userId, videoId);
if (entity != null) {
entity.setViewCount(viewCount);
entity.setLastViewTime(new Date());
entity.setUpdateTime(new Date());
String cacheKey = String.format(USER_VIDEO_VIEW_CACHE_KEY, userId, videoId);
redisTemplate.opsForValue().set(cacheKey, JacksonUtil.toJSONString(entity),
CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
log.debug("更新用户视频查看次数到Redis: userId={}, videoId={}, viewCount={}", userId, videoId, viewCount);
return true;
}
return false;
}
/**
* 增加查看次数(原子操作)
*
* @param userId 用户ID
* @param videoId 视频ID
* @return 是否更新成功
*/
public boolean incrementViewCount(Long userId, Long videoId) {
UserVideoViewEntity entity = getViewRecord(userId, videoId);
if (entity != null) {
entity.setViewCount(entity.getViewCount() + 1);
entity.setLastViewTime(new Date());
entity.setUpdateTime(new Date());
String cacheKey = String.format(USER_VIDEO_VIEW_CACHE_KEY, userId, videoId);
redisTemplate.opsForValue().set(cacheKey, JacksonUtil.toJSONString(entity),
CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
log.debug("增加用户视频查看次数到Redis: userId={}, videoId={}, newCount={}",
userId, videoId, entity.getViewCount());
return true;
}
return false;
}
/**
* 使用Redis分布式锁执行查看记录操作
*
* @param userId 用户ID
* @param videoId 视频ID
* @param operation 操作函数
* @return 操作结果
*/
public <T> T executeWithLock(Long userId, Long videoId, java.util.function.Supplier<T> operation) {
String lockKey = String.format(VIEW_RECORD_LOCK_KEY, userId, videoId);
try {
// 尝试获取锁
Boolean lockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked",
LOCK_EXPIRE_SECONDS, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(lockAcquired)) {
try {
return operation.get();
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
} else {
log.warn("获取查看记录锁失败: userId={}, videoId={}", userId, videoId);
return null;
}
} catch (Exception e) {
log.error("执行查看记录操作失败: userId={}, videoId={}", userId, videoId, e);
return null;
}
}
/**
* 清除缓存
*
* @param userId 用户ID
* @param videoId 视频ID
*/
public void clearCache(Long userId, Long videoId) {
String cacheKey = String.format(USER_VIDEO_VIEW_CACHE_KEY, userId, videoId);
redisTemplate.delete(cacheKey);
}
}

View File

@@ -0,0 +1,230 @@
package com.ycwl.basic.service.mobile;
import com.ycwl.basic.biz.OrderBiz;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
import com.ycwl.basic.model.mobile.video.dto.VideoViewPermissionDTO;
import com.ycwl.basic.model.pc.video.entity.UserVideoViewEntity;
import com.ycwl.basic.model.pc.video.entity.VideoEntity;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.repository.UserVideoViewRepository;
import com.ycwl.basic.repository.VideoRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* 视频查看权限服务
* 处理用户查看vlog视频的权限控制和记录
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class VideoViewPermissionService {
private final UserVideoViewRepository userVideoViewRepository;
private final VideoRepository videoRepository;
private final ScenicRepository scenicRepository;
private final OrderBiz orderBiz;
/**
* 检查并记录用户查看视频
*
* @param userId 用户ID
* @param videoId 视频ID
* @return 查看权限信息
*/
public VideoViewPermissionDTO checkAndRecordView(Long userId, Long videoId) {
try {
// 获取视频信息
VideoEntity video = videoRepository.getVideo(videoId);
if (video == null) {
log.warn("视频不存在: videoId={}", videoId);
return createErrorPermission("视频不存在");
}
Long scenicId = video.getScenicId();
if (scenicId == null) {
log.warn("视频缺少景区信息: videoId={}", videoId);
return createErrorPermission("视频信息不完整");
}
// 检查用户是否已购买
IsBuyRespVO buy = orderBiz.isBuy(userId, scenicId, 0, videoId);
if (buy != null && (buy.isBuy() || buy.isFree())) {
// 已购买,不限制查看
log.debug("用户已购买视频,无查看限制: userId={}, videoId={}", userId, videoId);
return VideoViewPermissionDTO.createUnlimitedPermission(0);
}
// 使用分布式锁执行查看记录操作
VideoViewPermissionDTO result = userVideoViewRepository.executeWithLock(userId, videoId, () -> {
try {
// 获取景区配置
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
// 解析配置参数
Integer vlogViewLimit = scenicConfig.getInteger("vlog_view_limit", 0);
Integer vlogTimeLimit = scenicConfig.getInteger("vlog_time_limit", 0);
// 获取用户查看记录
UserVideoViewEntity viewRecord = userVideoViewRepository.getViewRecord(userId, videoId);
int currentViewCount = 0;
if (viewRecord == null) {
// 首次查看,创建记录
viewRecord = userVideoViewRepository.createViewRecord(userId, videoId, scenicId);
currentViewCount = 1;
} else {
// 增加查看次数
currentViewCount = viewRecord.getViewCount() + 1;
userVideoViewRepository.updateViewCount(userId, videoId, currentViewCount);
}
// 根据配置判断权限
return calculatePermission(currentViewCount, vlogViewLimit, vlogTimeLimit);
} catch (Exception e) {
log.error("检查视频查看权限失败: userId={}, videoId={}", userId, videoId, e);
return createErrorPermission("系统异常,请稍后重试");
}
});
return result != null ? result : createErrorPermission("获取权限信息失败");
} catch (Exception e) {
log.error("检查并记录视频查看失败: userId={}, videoId={}", userId, videoId, e);
return createErrorPermission("系统异常,请稍后重试");
}
}
/**
* 仅检查用户查看权限,不记录查看次数
*
* @param userId 用户ID
* @param videoId 视频ID
* @return 查看权限信息
*/
public VideoViewPermissionDTO checkViewPermission(Long userId, Long videoId) {
try {
// 获取视频信息
VideoEntity video = videoRepository.getVideo(videoId);
if (video == null) {
return createErrorPermission("视频不存在");
}
Long scenicId = video.getScenicId();
if (scenicId == null) {
return createErrorPermission("视频信息不完整");
}
// 检查用户是否已购买
IsBuyRespVO buy = orderBiz.isBuy(userId, scenicId, 0, videoId);
if (buy != null && (buy.isBuy() || buy.isFree())) {
// 已购买,不限制查看
log.debug("用户已购买视频,无查看限制: userId={}, videoId={}", userId, videoId);
return VideoViewPermissionDTO.createUnlimitedPermission(0);
}
// 获取景区配置
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
// 解析配置参数
Integer vlogViewLimit = scenicConfig.getInteger("vlog_view_limit", 0);
Integer vlogTimeLimit = scenicConfig.getInteger("vlog_time_limit", 0);
// 获取用户查看记录
UserVideoViewEntity viewRecord = userVideoViewRepository.getViewRecord(userId, videoId);
int currentViewCount = viewRecord != null ? viewRecord.getViewCount() : 0;
// 根据配置判断权限
return calculatePermission(currentViewCount, vlogViewLimit, vlogTimeLimit);
} catch (Exception e) {
log.error("检查视频查看权限失败: userId={}, videoId={}", userId, videoId, e);
return createErrorPermission("系统异常,请稍后重试");
}
}
/**
* 根据配置计算查看权限
*
* @param currentViewCount 当前查看次数
* @param vlogViewLimit 视频查看次数限制
* @param vlogTimeLimit 时间限制(秒)
* @return 查看权限信息
*/
private VideoViewPermissionDTO calculatePermission(int currentViewCount, Integer vlogViewLimit, Integer vlogTimeLimit) {
// 1. 两个值都为0或负数则为不限制
if ((vlogViewLimit == null || vlogViewLimit <= 0) && (vlogTimeLimit == null || vlogTimeLimit <= 0)) {
return VideoViewPermissionDTO.createUnlimitedPermission(currentViewCount);
}
// 2. vlog_view_limit为0则仅通过vlog_time_limit限制
if (vlogViewLimit == null || vlogViewLimit <= 0) {
if (vlogTimeLimit != null && vlogTimeLimit > 0) {
return VideoViewPermissionDTO.createTimeLimitPermission(currentViewCount, 0, vlogTimeLimit);
} else {
return VideoViewPermissionDTO.createUnlimitedPermission(currentViewCount);
}
}
// 3. vlog_view_limit不为0,判断是否超过次数限制
if (currentViewCount <= vlogViewLimit) {
// 未超过次数限制,可完整查看
return VideoViewPermissionDTO.createFullViewPermission(currentViewCount, vlogViewLimit);
} else {
// 已超过次数限制
if (vlogTimeLimit == null || vlogTimeLimit <= 0) {
// 4. vlog_time_limit为0就不允许查看了
return VideoViewPermissionDTO.createNoPermission(currentViewCount, vlogViewLimit);
} else {
// 通过时间限制查看
return VideoViewPermissionDTO.createTimeLimitPermission(currentViewCount, vlogViewLimit, vlogTimeLimit);
}
}
}
/**
* 解析配置值
*
* @param config 配置Map
* @param key 配置键
* @param defaultValue 默认值
* @return 解析后的值
*/
private Integer parseConfigValue(Map<String, Object> config, String key, Integer defaultValue) {
if (config == null || !config.containsKey(key)) {
return defaultValue;
}
Object value = config.get(key);
if (value instanceof Number) {
return ((Number) value).intValue();
} else if (value instanceof String) {
try {
return Integer.valueOf((String) value);
} catch (NumberFormatException e) {
log.warn("配置值解析失败: key={}, value={}, 使用默认值: {}", key, value, defaultValue);
return defaultValue;
}
}
return defaultValue;
}
/**
* 创建错误权限对象
*
* @param message 错误消息
* @return 错误权限对象
*/
private VideoViewPermissionDTO createErrorPermission(String message) {
VideoViewPermissionDTO dto = new VideoViewPermissionDTO();
dto.setCanView(false);
dto.setMessage(message);
return dto;
}
}