feat(backend): Improve authorization governance and video interactions

This commit is contained in:
2026-04-24 17:34:09 +08:00
parent 6c954542ac
commit 600ff66bd3
42 changed files with 1239 additions and 74 deletions
@@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.nuaa.video.common.model.vo.Result;
import com.nuaa.video.content.dto.*;
import com.nuaa.video.content.entity.ContentVideo;
import com.nuaa.video.content.entity.ContentVideoPlayRecord;
import com.nuaa.video.content.mapper.ContentVideoMapper;
import com.nuaa.video.content.service.VideoService;
import com.nuaa.video.content.vo.PlayHistoryVO;
import com.nuaa.video.content.vo.UploadResultVO;
import com.nuaa.video.content.vo.VideoInfoVO;
import com.nuaa.video.content.vo.VideoListVO;
@@ -62,14 +64,7 @@ public class VideoController {
@GetMapping("/public/{id}")
public Result<VideoInfoVO> getVideoInfoPublic(@PathVariable Long id, HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
Result<VideoInfoVO> result = videoService.getVideoInfo(id, userId);
if (result.getData() == null) {
return result;
}
if (result.getData().getPublishTime() == null) {
return Result.error("视频暂未公开");
}
return result;
return videoService.getVideoInfo(id, userId);
}
/**
@@ -223,6 +218,34 @@ public class VideoController {
return videoService.playVideo(recordDTO, userId);
}
/**
* 播放历史
*/
@GetMapping("/history")
public Result<Page<PlayHistoryVO>> getPlayHistory(
@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
@RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize,
HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
if (userId == null) {
return Result.error("未获取到用户ID");
}
Page<ContentVideoPlayRecord> page = new Page<>(pageNum, pageSize);
return videoService.getPlayHistory(page, userId);
}
/**
* 清空播放历史
*/
@DeleteMapping("/history")
public Result<Void> clearPlayHistory(HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
if (userId == null) {
return Result.error("未获取到用户ID");
}
return videoService.clearPlayHistory(userId);
}
/**
* 获取用户视频列表
*/
@@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
/**
* 用户服务Feign客户端
@@ -57,4 +58,30 @@ public interface UserFeignClient {
Result<Void> increaseFavoriteCount(@PathVariable("userId") Long userId,
@PathVariable("count") Integer count,
@RequestHeader("X-Internal-Token") String internalToken);
/**
* 增加用户硬币余额
*/
@PostMapping("/user/profile/count/coin/increase/{userId}/{count}")
Result<Void> increaseCoinCount(@PathVariable("userId") Long userId,
@PathVariable("count") Integer count,
@RequestHeader("X-Internal-Token") String internalToken);
/**
* 扣减用户硬币余额
*/
@PostMapping("/user/profile/count/coin/decrease/{userId}/{count}")
Result<Void> decreaseCoinCount(@PathVariable("userId") Long userId,
@PathVariable("count") Integer count,
@RequestHeader("X-Internal-Token") String internalToken);
/**
* 发送用户通知
*/
@PostMapping("/user/notification/send/{userId}/{type}")
Result<Void> sendNotification(@PathVariable("userId") Long userId,
@PathVariable("type") Integer type,
@RequestParam(value = "content", required = false) String content,
@RequestParam(value = "relatedId", required = false) Long relatedId,
@RequestHeader("X-Internal-Token") String internalToken);
}
@@ -39,6 +39,24 @@ public class UserFeignClientFallbackFactory implements FallbackFactory<UserFeign
log.error("增加用户收藏数量失败,用户ID:{}, 数量:{}", userId, count);
return Result.error("更新用户收藏数量失败");
}
@Override
public Result<Void> increaseCoinCount(Long userId, Integer count, String internalToken) {
log.error("增加用户硬币余额失败,用户ID:{}, 数量:{}", userId, count);
return Result.error("更新用户硬币余额失败");
}
@Override
public Result<Void> decreaseCoinCount(Long userId, Integer count, String internalToken) {
log.error("扣减用户硬币余额失败,用户ID:{}, 数量:{}", userId, count);
return Result.error("扣减用户硬币余额失败");
}
@Override
public Result<Void> sendNotification(Long userId, Integer type, String content, Long relatedId, String internalToken) {
log.error("发送用户通知失败,用户ID:{}, 类型:{}, relatedId:{}", userId, type, relatedId);
return Result.error("发送用户通知失败");
}
};
}
}
@@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.extension.service.IService;
import com.nuaa.video.common.model.vo.Result;
import com.nuaa.video.content.dto.*;
import com.nuaa.video.content.entity.ContentVideo;
import com.nuaa.video.content.entity.ContentVideoPlayRecord;
import com.nuaa.video.content.vo.PlayHistoryVO;
import com.nuaa.video.content.vo.UploadResultVO;
import com.nuaa.video.content.vo.VideoInfoVO;
import com.nuaa.video.content.vo.VideoListVO;
@@ -135,6 +137,23 @@ public interface VideoService extends IService<ContentVideo> {
*/
Result<Void> playVideo(PlayRecordDTO recordDTO, Long userId);
/**
* 获取播放历史
*
* @param page 分页参数
* @param userId 用户ID
* @return 播放历史
*/
Result<Page<PlayHistoryVO>> getPlayHistory(Page<ContentVideoPlayRecord> page, Long userId);
/**
* 清空播放历史
*
* @param userId 用户ID
* @return 结果
*/
Result<Void> clearPlayHistory(Long userId);
/**
* 获取用户视频列表
*
@@ -23,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
/**
@@ -52,6 +53,8 @@ public class FavoriteServiceImpl extends ServiceImpl<ContentUserFavoriteMapper,
.map(favorite -> {
FavoriteVO vo = new FavoriteVO();
BeanUtils.copyProperties(favorite, vo);
int visibleVideoCount = countVisibleFavoriteVideos(favorite.getId());
vo.setVideoCount(visibleVideoCount);
return vo;
})
.collect(Collectors.toList());
@@ -192,4 +195,23 @@ public class FavoriteServiceImpl extends ServiceImpl<ContentUserFavoriteMapper,
return Result.success(resultPage);
}
}
private int countVisibleFavoriteVideos(Long favoriteId) {
List<Long> videoIds = videoFavoriteMapper.selectList(
new LambdaQueryWrapper<ContentVideoFavorite>()
.eq(ContentVideoFavorite::getFavoriteId, favoriteId)
).stream().map(ContentVideoFavorite::getVideoId).collect(Collectors.toList());
if (videoIds.isEmpty()) {
return 0;
}
Long count = videoMapper.selectCount(
new LambdaQueryWrapper<ContentVideo>()
.in(ContentVideo::getId, videoIds)
.eq(ContentVideo::getStatus, 1)
);
return Math.toIntExact(Objects.requireNonNullElse(count, 0L));
}
}
@@ -17,6 +17,7 @@ import com.nuaa.video.content.service.TagService;
import com.nuaa.video.content.service.VideoService;
import com.nuaa.video.content.utils.VideoProcessor;
import com.nuaa.video.content.vo.TagVO;
import com.nuaa.video.content.vo.PlayHistoryVO;
import com.nuaa.video.content.vo.UploadResultVO;
import com.nuaa.video.content.vo.VideoInfoVO;
import com.nuaa.video.content.vo.VideoListVO;
@@ -32,6 +33,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -48,6 +50,8 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVideo> implements VideoService {
private static final ZoneId APP_ZONE_ID = ZoneId.of("Asia/Shanghai");
@Value("${service.auth.token}")
private String internalServiceToken;
@@ -164,6 +168,12 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
return Result.error(ResultCode.NOT_FOUND.getCode(), "视频不存在");
}
boolean isOwner = userId != null && userId.equals(video.getUserId());
boolean isPublished = Objects.equals(video.getStatus(), VideoStatusEnum.PUBLISHED.getCode());
if (!isPublished && !isOwner) {
return Result.error(ResultCode.FORBIDDEN.getCode(), "视频暂未公开");
}
// 转换为VO
VideoInfoVO videoInfoVO = new VideoInfoVO();
BeanUtils.copyProperties(video, videoInfoVO);
@@ -205,6 +215,15 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
int coinCount = videoCoinMapper.getUserCoinCount(userId, id);
videoInfoVO.setIsCoined(coinCount > 0);
ContentVideoPlayRecord playRecord = getUserPlayRecord(userId, id);
if (playRecord != null) {
videoInfoVO.setPlayProgress(playRecord.getPlayTime());
videoInfoVO.setIsPlayFinished(Objects.equals(playRecord.getIsFinished(), 1));
} else {
videoInfoVO.setPlayProgress(0);
videoInfoVO.setIsPlayFinished(false);
}
}
return Result.success(videoInfoVO);
@@ -409,6 +428,7 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
}}
);
sendVideoLikeNotificationAsync(video, userId);
updateUserLikeCountAsync(video.getUserId(), 1);
return Result.success();
@@ -473,25 +493,40 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
return Result.error("投币总数不能超过2个");
}
// 添加投币记录
ContentVideoCoin coin = new ContentVideoCoin();
coin.setVideoId(videoId);
coin.setUserId(userId);
coin.setAmount(amount);
coin.setCreateTime(LocalDateTime.now());
videoCoinMapper.insert(coin);
Result<Void> coinBalanceResult = userFeignClient.decreaseCoinCount(userId, amount, internalServiceToken);
if (coinBalanceResult == null || coinBalanceResult.getCode() != 200) {
String message = coinBalanceResult != null ? coinBalanceResult.getMessage() : "扣减硬币失败";
return Result.error(message);
}
// 发送统计消息,增加投币数
videoMessageProducer.sendVideoStatsMessage(
new VideoMessageProducer.VideoStatsMessage() {{
setVideoId(videoId);
setUserId(userId);
setAction("coin");
setCount(amount);
}}
);
try {
// 添加投币记录
ContentVideoCoin coin = new ContentVideoCoin();
coin.setVideoId(videoId);
coin.setUserId(userId);
coin.setAmount(amount);
coin.setCreateTime(LocalDateTime.now());
videoCoinMapper.insert(coin);
return Result.success();
// 发送统计消息,增加投币数
videoMessageProducer.sendVideoStatsMessage(
new VideoMessageProducer.VideoStatsMessage() {{
setVideoId(videoId);
setUserId(userId);
setAction("coin");
setCount(amount);
}}
);
return Result.success();
} catch (Exception e) {
Result<Void> rollbackResult = userFeignClient.increaseCoinCount(userId, amount, internalServiceToken);
if (rollbackResult == null || rollbackResult.getCode() != 200) {
log.error("投币失败且硬币余额补偿失败,videoId={}, userId={}, amount={}, rollbackResult={}",
videoId, userId, amount, rollbackResult);
}
throw e;
}
}
@Override
@@ -673,6 +708,54 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
return Result.success();
}
@Override
public Result<Page<PlayHistoryVO>> getPlayHistory(Page<ContentVideoPlayRecord> page, Long userId) {
Page<ContentVideoPlayRecord> recordPage = playRecordMapper.selectPage(
page,
new LambdaQueryWrapper<ContentVideoPlayRecord>()
.eq(ContentVideoPlayRecord::getUserId, userId)
.orderByDesc(ContentVideoPlayRecord::getUpdateTime)
);
List<PlayHistoryVO> historyRecords = recordPage.getRecords().stream()
.map(record -> {
ContentVideo video = getById(record.getVideoId());
if (video == null) {
return null;
}
PlayHistoryVO vo = new PlayHistoryVO();
vo.setVideoId(record.getVideoId());
vo.setTitle(video.getTitle());
vo.setCoverUrl(video.getCoverUrl());
vo.setDuration(video.getDuration());
vo.setPlayTime(record.getPlayTime());
vo.setIsFinished(record.getIsFinished());
vo.setUpdateTime(record.getUpdateTime());
return vo;
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
Page<PlayHistoryVO> resultPage = new Page<>();
resultPage.setRecords(historyRecords);
resultPage.setTotal(recordPage.getTotal());
resultPage.setSize(recordPage.getSize());
resultPage.setCurrent(recordPage.getCurrent());
resultPage.setPages(recordPage.getPages());
return Result.success(resultPage);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> clearPlayHistory(Long userId) {
playRecordMapper.delete(
new LambdaQueryWrapper<ContentVideoPlayRecord>()
.eq(ContentVideoPlayRecord::getUserId, userId)
);
return Result.success();
}
@Override
public Result<Page<VideoListVO>> getUserVideos(Long userId, Page<ContentVideo> page, Long currentUserId) {
VideoQueryDTO queryDTO = new VideoQueryDTO();
@@ -885,7 +968,7 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
stats.put("totalVideos", totalVideos);
// 今日新增视频
LocalDate today = LocalDate.now();
LocalDate today = LocalDate.now(APP_ZONE_ID);
long todayNewVideos = count(new LambdaQueryWrapper<ContentVideo>()
.ge(ContentVideo::getCreateTime, today.atStartOfDay()));
stats.put("todayNewVideos", todayNewVideos);
@@ -950,6 +1033,26 @@ public class VideoServiceImpl extends ServiceImpl<ContentVideoMapper, ContentVid
});
}
private ContentVideoPlayRecord getUserPlayRecord(Long userId, Long videoId) {
return playRecordMapper.selectOne(
new LambdaQueryWrapper<ContentVideoPlayRecord>()
.eq(ContentVideoPlayRecord::getUserId, userId)
.eq(ContentVideoPlayRecord::getVideoId, videoId)
);
}
private void sendVideoLikeNotificationAsync(ContentVideo video, Long operatorUserId) {
runAfterCommitAsync("发送视频点赞通知", () -> {
if (!operatorUserId.equals(video.getUserId())) {
Result<Void> notifyResult = userFeignClient.sendNotification(video.getUserId(), 1, null, video.getId(), internalServiceToken);
if (notifyResult == null || notifyResult.getCode() != 200) {
log.warn("发送视频点赞通知失败,videoId={}, ownerUserId={}, operatorUserId={}, result={}",
video.getId(), video.getUserId(), operatorUserId, notifyResult);
}
}
});
}
private void runAfterCommitAsync(String action, Runnable task) {
Runnable wrappedTask = () -> CompletableFuture.runAsync(() -> {
try {
@@ -0,0 +1,49 @@
package com.nuaa.video.content.vo;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 播放历史VO
*/
@Data
public class PlayHistoryVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 视频ID
*/
private Long videoId;
/**
* 视频标题
*/
private String title;
/**
* 视频封面
*/
private String coverUrl;
/**
* 视频时长
*/
private Integer duration;
/**
* 播放进度
*/
private Integer playTime;
/**
* 是否已看完
*/
private Integer isFinished;
/**
* 最后播放时间
*/
private LocalDateTime updateTime;
}
@@ -68,6 +68,16 @@ public class VideoInfoVO implements Serializable {
*/
private Integer height;
/**
* 视频格式
*/
private String format;
/**
* 文件大小
*/
private Long size;
/**
* 分类ID
*/
@@ -123,6 +133,11 @@ public class VideoInfoVO implements Serializable {
*/
private Integer danmakuCount;
/**
* 视频状态
*/
private Integer status;
/**
* 是否已点赞
*/
@@ -138,11 +153,31 @@ public class VideoInfoVO implements Serializable {
*/
private Boolean isCoined;
/**
* 播放进度(秒)
*/
private Integer playProgress;
/**
* 是否已看完
*/
private Boolean isPlayFinished;
/**
* 发布时间
*/
private LocalDateTime publishTime;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
/**
* 审核员ID
*/
@@ -162,4 +197,4 @@ public class VideoInfoVO implements Serializable {
* 创建时间
*/
private LocalDateTime reviewTime;
}
}
@@ -0,0 +1,40 @@
package com.nuaa.video.content.service;
import com.nuaa.video.content.entity.ContentVideo;
import com.nuaa.video.content.feign.UserFeignClient;
import com.nuaa.video.content.service.impl.VideoServiceImpl;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
@DisplayName("视频通知发送测试")
class NotificationDispatchTest {
@Mock
private UserFeignClient userFeignClient;
@InjectMocks
private VideoServiceImpl videoService;
@Test
@DisplayName("视频点赞通知应携带视频ID作为 relatedId")
void shouldSendVideoLikeNotificationWithVideoId() {
ReflectionTestUtils.setField(videoService, "internalServiceToken", "internal-token");
ContentVideo video = new ContentVideo();
video.setId(300L);
video.setUserId(200L);
ReflectionTestUtils.invokeMethod(videoService, "sendVideoLikeNotificationAsync", video, 100L);
verify(userFeignClient, timeout(1000)).sendNotification(200L, 1, null, 300L, "internal-token");
}
}