25 changed files with 1514 additions and 8 deletions
@ -0,0 +1,20 @@ |
|||
package com.youlai.boot.mini.model.dto; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.ArraySchema; |
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import jakarta.validation.constraints.NotBlank; |
|||
import jakarta.validation.constraints.NotEmpty; |
|||
import lombok.Data; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Data |
|||
public class DeleteUserPostDTO { |
|||
|
|||
@NotEmpty(message = "uuid不能为空") |
|||
@ArraySchema( |
|||
arraySchema = @Schema(description = "用户作品uuid列表", requiredMode = Schema.RequiredMode.REQUIRED), |
|||
schema = @Schema(description = "用户作品uuid") |
|||
) |
|||
private List<@NotBlank(message = "uuid不能为空") String> postUuidList; |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
package com.youlai.boot.mini.model.dto; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.ArraySchema; |
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import jakarta.validation.constraints.NotBlank; |
|||
import jakarta.validation.constraints.NotEmpty; |
|||
import lombok.Data; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Data |
|||
public class DeleteUserPostMediaDTO { |
|||
|
|||
@NotEmpty(message = "媒体资源uuid不能为空") |
|||
@ArraySchema( |
|||
arraySchema = @Schema(description = "用户作品媒体资源uuid列表", requiredMode = Schema.RequiredMode.REQUIRED), |
|||
schema = @Schema(description = "媒体资源uuid") |
|||
) |
|||
private List<@NotBlank(message = "uuid不能为空") String> mediaUuidList; |
|||
|
|||
@NotBlank(message = "作品uuid不能为空") |
|||
@Schema(description = "用户作品uuid", requiredMode = Schema.RequiredMode.REQUIRED) |
|||
private String postUuid; |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
package com.youlai.boot.mini.model.form; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import jakarta.validation.constraints.NotBlank; |
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
@Schema(description = "用户作品收藏请求参数") |
|||
public class UserPostCollectForm { |
|||
|
|||
@NotBlank(message = "作品UUID不能为空") |
|||
@Schema(description = "作品UUID", requiredMode = Schema.RequiredMode.REQUIRED, example = "a1b2c3d4e5f6g7h8i9j0") |
|||
private String postUuid; |
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
package com.youlai.boot.mini.model.form; |
|||
|
|||
import com.youlai.boot.common.annotation.EnumValid; |
|||
import com.youlai.boot.mini.model.enums.VisibilityEnum; |
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import jakarta.validation.constraints.NotBlank; |
|||
import lombok.Data; |
|||
import org.hibernate.validator.constraints.Length; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Data |
|||
@Schema(description = "用户作品表单对象") |
|||
public class UserPostForm { |
|||
|
|||
@NotBlank(message = "作品标题不能为空") |
|||
@Length(max = 200, message = "标题不能超过200个字符") |
|||
@Schema(description = "作品标题", requiredMode = Schema.RequiredMode.REQUIRED) |
|||
private String title; |
|||
|
|||
@Schema(description = "作品正文内容") |
|||
private String content; |
|||
|
|||
@NotBlank(message = "可见范围不能为空") |
|||
@EnumValid(enumClass = VisibilityEnum.class, message = "可见范围不合法") |
|||
@Schema(description = "可见范围:public-公开,private-仅自己,friends-仅好友") |
|||
private String visibility; |
|||
|
|||
@Schema(description = "上传的媒体资源URL列表") |
|||
private List<String> mediaUrlList; |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
package com.youlai.boot.mini.model.form; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import jakarta.validation.constraints.NotBlank; |
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
@Schema(description = "用户作品点赞请求参数") |
|||
public class UserPostLikeForm { |
|||
|
|||
@NotBlank(message = "作品UUID不能为空") |
|||
@Schema(description = "作品UUID", requiredMode = Schema.RequiredMode.REQUIRED, example = "a1b2c3d4e5f6g7h8i9j0") |
|||
private String postUuid; |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
package com.youlai.boot.mini.model.query; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import lombok.Data; |
|||
import lombok.EqualsAndHashCode; |
|||
|
|||
@EqualsAndHashCode(callSuper = true) |
|||
@Data |
|||
public class OwnUserPostQuery extends UserPostQuery { |
|||
|
|||
@Schema(description = "登录用户ID", example = "1", hidden = true) |
|||
private Long miniUserId; |
|||
|
|||
@Schema(description = "创建作品的用户ID", example = "1", hidden = true) |
|||
private Long creatorId; |
|||
|
|||
@Schema(description = "关键词", example = "标题内容", hidden = true) |
|||
private String keyword; |
|||
|
|||
@Schema(description = "权限", example = "public", hidden = true) |
|||
private String visibility; |
|||
|
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
package com.youlai.boot.mini.model.query; |
|||
|
|||
import com.youlai.boot.common.base.BaseQuery; |
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import lombok.Data; |
|||
import lombok.EqualsAndHashCode; |
|||
|
|||
@EqualsAndHashCode(callSuper = true) |
|||
@Data |
|||
public class UserPostQuery extends BaseQuery { |
|||
|
|||
@Schema(description = "起始登记时间,毫秒级时间戳", example = "1776426078459") |
|||
private Long createStartTimestamp; |
|||
|
|||
@Schema(description = "截止登记时间,毫秒级时间戳", example = "1776426078459") |
|||
private Long createEndTimestamp; |
|||
|
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
package com.youlai.boot.mini.model.query; |
|||
|
|||
import com.youlai.boot.common.base.BaseQuery; |
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
public class UserPostWaterfallQuery extends BaseQuery { |
|||
|
|||
@Schema(description = "游标(上一页最后一条的ID,首次请求不传)", example = "100") |
|||
private Long cursor; |
|||
|
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
package com.youlai.boot.mini.model.vo; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import lombok.Data; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Data |
|||
public class UserPostDetailsVO { |
|||
|
|||
@Schema(description = "图片信息", example = "[]") |
|||
private List<UserPostMediaVO> images; |
|||
|
|||
@Schema(description = "视频信息", example = "[]") |
|||
private List<UserPostMediaVO> videos; |
|||
|
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
package com.youlai.boot.mini.model.vo; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
public class UserPostMediaVO { |
|||
|
|||
@Schema(type = "string", description = "资源ID") |
|||
private String postMediaUuid; |
|||
|
|||
@Schema(type = "string", description = "用户作品UUID") |
|||
private String postUuid; |
|||
|
|||
@Schema(description = "媒体类型,image-图片,video-视频") |
|||
private String mediaType; |
|||
|
|||
@Schema(description = "资源URL") |
|||
private String sourceUrl; |
|||
|
|||
@Schema(description = "对象存储中的key") |
|||
private String storageKey; |
|||
|
|||
@Schema(description = "缩略图URL(视频需要)") |
|||
private String thumbnailUrl; |
|||
|
|||
@Schema(description = "宽度(像素)") |
|||
private Integer width; |
|||
|
|||
@Schema(description = "高度(像素)") |
|||
private Integer height; |
|||
|
|||
@Schema(description = "时长(秒,视频用)") |
|||
private Integer duration; |
|||
} |
|||
@ -0,0 +1,61 @@ |
|||
package com.youlai.boot.mini.model.vo; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonFormat; |
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import lombok.Data; |
|||
|
|||
import java.util.Date; |
|||
|
|||
@Data |
|||
public class UserPostVO { |
|||
|
|||
@Schema(description = "作品ID", hidden = true) |
|||
private Long id; |
|||
|
|||
@Schema(description = "作者uuid") |
|||
private String authorUuid; |
|||
|
|||
@Schema(description = "作者昵称") |
|||
private String authorName; |
|||
|
|||
@Schema(description = "作者头像") |
|||
private String authorAvatar; |
|||
|
|||
@Schema(description = "作品UUID", requiredMode = Schema.RequiredMode.REQUIRED) |
|||
private String postUuid; |
|||
|
|||
@Schema(description = "封面图片url") |
|||
private String firstImageUrl; |
|||
|
|||
@Schema(description = "作品标题", requiredMode = Schema.RequiredMode.REQUIRED) |
|||
private String title; |
|||
|
|||
@Schema(description = "作品内容摘要") |
|||
private String content; |
|||
|
|||
@Schema(description = "可见性范围:public-公开,private-仅自己,friends-仅好友", example = "public") |
|||
private String visibility; |
|||
|
|||
@Schema(description = "浏览数") |
|||
private Integer viewCount; |
|||
|
|||
@Schema(description = "点赞数") |
|||
private Integer likeCount; |
|||
|
|||
@Schema(description = "评论数") |
|||
private Integer commentCount; |
|||
|
|||
@Schema(description = "收藏数") |
|||
private Integer collectCount; |
|||
|
|||
@Schema(description = "当前用户是否已点赞") |
|||
private Boolean isLiked; |
|||
|
|||
@Schema(description = "当前用户是否收藏") |
|||
private Boolean isCollected; |
|||
|
|||
@Schema(description = "创建时间") |
|||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
|||
private Date createTime; |
|||
|
|||
} |
|||
@ -1,7 +1,53 @@ |
|||
package com.youlai.boot.mini.service; |
|||
|
|||
import com.baomidou.mybatisplus.core.metadata.IPage; |
|||
import com.baomidou.mybatisplus.extension.service.IService; |
|||
import com.youlai.boot.mini.model.dto.DeleteUserPostDTO; |
|||
import com.youlai.boot.mini.model.dto.DeleteUserPostMediaDTO; |
|||
import com.youlai.boot.mini.model.dto.EditVisibilityDTO; |
|||
import com.youlai.boot.mini.model.entity.MiniUserPost; |
|||
import com.youlai.boot.mini.model.form.UserPostForm; |
|||
import com.youlai.boot.mini.model.form.UserPostCollectForm; |
|||
import com.youlai.boot.mini.model.form.UserPostLikeForm; |
|||
import com.youlai.boot.mini.model.query.OwnUserPostQuery; |
|||
import com.youlai.boot.mini.model.query.UserPostWaterfallQuery; |
|||
import com.youlai.boot.mini.model.vo.UserPostDetailsVO; |
|||
import com.youlai.boot.mini.model.vo.UserPostVO; |
|||
import com.youlai.boot.mini.model.vo.WaterfallResult; |
|||
import org.springframework.web.multipart.MultipartFile; |
|||
|
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
public interface UserPostService extends IService<MiniUserPost> { |
|||
|
|||
List<String> saveFile(List<MultipartFile> images, List<MultipartFile> videos); |
|||
|
|||
String saveUserPost(UserPostForm formData); |
|||
|
|||
void updateUserPost(String postUuid, UserPostForm formData); |
|||
|
|||
void deleteMediaSource(DeleteUserPostMediaDTO dto); |
|||
|
|||
void saveMediaSource(String postUuid, List<MultipartFile> images, List<MultipartFile> videos); |
|||
|
|||
void updateVisibility(String postUuid, EditVisibilityDTO dto); |
|||
|
|||
void delete(DeleteUserPostDTO dto); |
|||
|
|||
IPage<UserPostVO> getSelfCreatedPage(OwnUserPostQuery queryParams); |
|||
|
|||
UserPostDetailsVO getDetails(String postUuid); |
|||
|
|||
IPage<UserPostVO> getOthersCreatedPage(String authorUuid, OwnUserPostQuery queryParams); |
|||
|
|||
Map<String, Object> toggleUserPostLike(UserPostLikeForm form, Long userId); |
|||
|
|||
Map<String, Object> toggleUserPostCollect(UserPostCollectForm form, Long userId); |
|||
|
|||
WaterfallResult<UserPostVO> getWaterfall(UserPostWaterfallQuery query, Long userId); |
|||
|
|||
void resetCurrentUserBloom(Long userId); |
|||
|
|||
void increaseUserPostViewCount(String postUuid); |
|||
} |
|||
|
|||
@ -1,15 +1,715 @@ |
|||
package com.youlai.boot.mini.service.impl; |
|||
|
|||
import cn.hutool.core.collection.CollUtil; |
|||
import cn.hutool.core.util.StrUtil; |
|||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
|||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; |
|||
import com.baomidou.mybatisplus.core.toolkit.IdWorker; |
|||
import com.baomidou.mybatisplus.core.metadata.IPage; |
|||
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; |
|||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
|||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
|||
import com.youlai.boot.common.exception.MsgException; |
|||
import com.youlai.boot.common.util.FileUtils; |
|||
import com.youlai.boot.common.util.JavaVCUtils; |
|||
import com.youlai.boot.common.util.RandomNumberUtils; |
|||
import com.youlai.boot.file.service.impl.AliyunFileService; |
|||
import com.youlai.boot.framework.security.util.SecurityUtils; |
|||
import com.youlai.boot.mini.mapper.MiniUserPostCollectMapper; |
|||
import com.youlai.boot.mini.mapper.MiniUserPostLikeMapper; |
|||
import com.youlai.boot.mini.mapper.MiniUserPostMapper; |
|||
import com.youlai.boot.mini.mapper.MiniUserPostMediaMapper; |
|||
import com.youlai.boot.mini.mapper.MiniUserPostViewMapper; |
|||
import com.youlai.boot.mini.model.dto.DeleteUserPostDTO; |
|||
import com.youlai.boot.mini.model.dto.DeleteUserPostMediaDTO; |
|||
import com.youlai.boot.mini.model.dto.EditVisibilityDTO; |
|||
import com.youlai.boot.mini.model.entity.MiniUserPost; |
|||
import com.youlai.boot.mini.model.entity.MiniUserPostCollect; |
|||
import com.youlai.boot.mini.model.entity.MiniUserPostLike; |
|||
import com.youlai.boot.mini.model.entity.MiniUserPostMedia; |
|||
import com.youlai.boot.mini.model.enums.AnimalNoteMediaTypeEnum; |
|||
import com.youlai.boot.mini.model.form.UserPostCollectForm; |
|||
import com.youlai.boot.mini.model.form.UserPostForm; |
|||
import com.youlai.boot.mini.model.form.UserPostLikeForm; |
|||
import com.youlai.boot.mini.model.query.OwnUserPostQuery; |
|||
import com.youlai.boot.mini.model.query.UserPostWaterfallQuery; |
|||
import com.youlai.boot.mini.model.vo.UserPostDetailsVO; |
|||
import com.youlai.boot.mini.model.vo.UserPostMediaVO; |
|||
import com.youlai.boot.mini.model.vo.UserPostVO; |
|||
import com.youlai.boot.mini.model.vo.WaterfallResult; |
|||
import com.youlai.boot.mini.service.UserPostService; |
|||
import com.youlai.boot.system.mapper.UserMapper; |
|||
import com.youlai.boot.system.model.entity.SysUser; |
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.apache.commons.io.FilenameUtils; |
|||
import org.redisson.api.RBloomFilter; |
|||
import org.redisson.api.RLock; |
|||
import org.redisson.api.RedissonClient; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
import org.springframework.web.multipart.MultipartFile; |
|||
|
|||
import javax.imageio.ImageIO; |
|||
import java.awt.image.BufferedImage; |
|||
import java.io.File; |
|||
import java.util.*; |
|||
import java.util.concurrent.TimeUnit; |
|||
import java.util.stream.Collectors; |
|||
|
|||
@Slf4j |
|||
@Service |
|||
@RequiredArgsConstructor |
|||
@Slf4j |
|||
public class UserPostServiceImpl extends ServiceImpl<MiniUserPostMapper, MiniUserPost> implements UserPostService { |
|||
|
|||
private final AliyunFileService aliyunFileService; |
|||
private final MiniUserPostMediaMapper miniUserPostMediaMapper; |
|||
private final MiniUserPostLikeMapper miniUserPostLikeMapper; |
|||
private final MiniUserPostCollectMapper miniUserPostCollectMapper; |
|||
private final MiniUserPostViewMapper miniUserPostViewMapper; |
|||
private final UserMapper userMapper; |
|||
private final RedissonClient redissonClient; |
|||
|
|||
private static final String BLOOM_VIEW_KEY_PREFIX = "mini:userPost:view:bloom:"; |
|||
private static final String BLOOM_REBUILD_LOCK_PREFIX = "lock:rebuild:userPost:bloom:"; |
|||
|
|||
private static final String OSS_IMAGE_DIR = "user_post/image/"; |
|||
private static final String OSS_VIDEO_DIR = "user_post/video/"; |
|||
private static final String OSS_THUMBNAIL_DIR = "user_post/thumbnail/"; |
|||
|
|||
@Value("${oss.aliyun.endpoint}") |
|||
private String endpoint; |
|||
|
|||
@Value("${oss.aliyun.bucket-name}") |
|||
private String bucketName; |
|||
|
|||
@Value("${bloom.view.expected-insertions:1000}") |
|||
private Integer bloomExpectedInsertions; |
|||
|
|||
@Value("${bloom.view.fpp:0.001}") |
|||
private Double bloomFpp; |
|||
|
|||
@Value("${bloom.view.expire-days:}") |
|||
private Integer bloomExpireDays; |
|||
|
|||
@Override |
|||
public List<String> saveFile(List<MultipartFile> images, List<MultipartFile> videos) { |
|||
long currentTimestamp = System.currentTimeMillis(); |
|||
List<String> urlList = new ArrayList<>(); |
|||
|
|||
// 处理图片
|
|||
if (CollUtil.isNotEmpty(images)) { |
|||
for (MultipartFile image : images) { |
|||
try { |
|||
String objectName = OSS_IMAGE_DIR |
|||
+ currentTimestamp + RandomNumberUtils.createRandomLowerLetterAndNumber(8) |
|||
+ "." |
|||
+ FilenameUtils.getExtension(image.getOriginalFilename()); |
|||
String url = aliyunFileService.uploadFile(objectName, image.getInputStream()); |
|||
|
|||
MiniUserPostMedia media = new MiniUserPostMedia(); |
|||
media.setUuid(UUID.randomUUID().toString()); |
|||
media.setMediaType(AnimalNoteMediaTypeEnum.IMAGE.name().toLowerCase()); |
|||
media.setSourceUrl(url); |
|||
media.setStorageKey(objectName); |
|||
BufferedImage imageInfo = ImageIO.read(image.getInputStream()); |
|||
media.setWidth(imageInfo.getWidth()); |
|||
media.setHeight(imageInfo.getHeight()); |
|||
media.setCreateTimestamp(currentTimestamp); |
|||
media.setCreateTime(new Date(currentTimestamp)); |
|||
media.setCreateBy(SecurityUtils.getUserId()); |
|||
|
|||
int result = miniUserPostMediaMapper.insert(media); |
|||
if (result > 0) { |
|||
urlList.add(url); |
|||
} |
|||
} catch (Exception e) { |
|||
log.error("用户作品图片上传失败", e); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 处理视频
|
|||
if (CollUtil.isNotEmpty(videos)) { |
|||
String tmpPath = System.getProperty("user.dir") + "/tmp"; |
|||
for (MultipartFile video : videos) { |
|||
try { |
|||
String fileName = currentTimestamp + RandomNumberUtils.createRandomLowerLetterAndNumber(8); |
|||
String objectName = OSS_VIDEO_DIR |
|||
+ fileName |
|||
+ "." |
|||
+ FilenameUtils.getExtension(video.getOriginalFilename()); |
|||
String url = aliyunFileService.uploadFile(objectName, video.getInputStream()); |
|||
|
|||
MiniUserPostMedia media = new MiniUserPostMedia(); |
|||
media.setUuid(UUID.randomUUID().toString()); |
|||
media.setMediaType(AnimalNoteMediaTypeEnum.VIDEO.name().toLowerCase()); |
|||
media.setSourceUrl(url); |
|||
media.setStorageKey(objectName); |
|||
media.setCreateTimestamp(currentTimestamp); |
|||
media.setCreateTime(new Date(currentTimestamp)); |
|||
media.setCreateBy(SecurityUtils.getUserId()); |
|||
|
|||
// 时长
|
|||
FileUtils.saveFile(video, tmpPath, fileName); |
|||
String videoPath = tmpPath + File.separator + fileName; |
|||
double duration = JavaVCUtils.getVideoDuration(videoPath); |
|||
media.setDuration((int) Math.ceil(duration)); |
|||
|
|||
// 缩略图
|
|||
BufferedImage thumbnail = JavaVCUtils.getVideoThumbnail(videoPath, 1); |
|||
String thumbnailFileName = currentTimestamp + RandomNumberUtils.createRandomLowerLetterAndNumber(8); |
|||
String thumbnailObjectName = OSS_THUMBNAIL_DIR + thumbnailFileName + ".png"; |
|||
String thumbnailUrl = aliyunFileService.uploadFile(thumbnailObjectName, |
|||
FileUtils.bufferedImageToInputStream(thumbnail, "png")); |
|||
media.setThumbnailUrl(thumbnailUrl); |
|||
|
|||
int result = miniUserPostMediaMapper.insert(media); |
|||
if (result > 0) { |
|||
urlList.add(url); |
|||
} |
|||
|
|||
FileUtils.delete(videoPath); |
|||
} catch (Exception e) { |
|||
log.error("用户作品视频上传失败", e); |
|||
} |
|||
} |
|||
} |
|||
|
|||
return urlList; |
|||
} |
|||
|
|||
@Override |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public String saveUserPost(UserPostForm formData) { |
|||
long currentTimestamp = System.currentTimeMillis(); |
|||
Long userId = SecurityUtils.getUserId(); |
|||
|
|||
// 1. 保存用户作品基本信息
|
|||
MiniUserPost post = new MiniUserPost(); |
|||
post.setUuid(UUID.randomUUID().toString()); |
|||
post.setMiniUserId(userId); |
|||
post.setTitle(formData.getTitle()); |
|||
post.setContent(formData.getContent()); |
|||
post.setVisibility(formData.getVisibility()); |
|||
post.setViewCount(0); |
|||
post.setLikeCount(0); |
|||
post.setCommentCount(0); |
|||
post.setCollectCount(0); |
|||
post.setCreateBy(userId); |
|||
post.setCreateTime(new Date(currentTimestamp)); |
|||
post.setCreateTimestamp(currentTimestamp); |
|||
post.setDeleted(false); |
|||
save(post); |
|||
|
|||
// 2. 关联之前上传的媒体文件
|
|||
if (CollUtil.isNotEmpty(formData.getMediaUrlList())) { |
|||
LambdaUpdateWrapper<MiniUserPostMedia> wrapper = new LambdaUpdateWrapper<>(); |
|||
wrapper.in(MiniUserPostMedia::getSourceUrl, formData.getMediaUrlList()) |
|||
.isNull(MiniUserPostMedia::getPostId) |
|||
.set(MiniUserPostMedia::getPostId, post.getId()); |
|||
miniUserPostMediaMapper.update(null, wrapper); |
|||
} |
|||
|
|||
return post.getUuid(); |
|||
} |
|||
|
|||
@Override |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public void updateUserPost(String postUuid, UserPostForm formData) { |
|||
MiniUserPost post = lambdaQuery().eq(MiniUserPost::getUuid, postUuid).one(); |
|||
if (post == null) { |
|||
return; |
|||
} |
|||
long currentTimestamp = System.currentTimeMillis(); |
|||
post.setTitle(formData.getTitle()); |
|||
post.setContent(formData.getContent()); |
|||
post.setVisibility(formData.getVisibility()); |
|||
post.setUpdateBy(SecurityUtils.getUserId()); |
|||
post.setUpdateTime(new Date(currentTimestamp)); |
|||
post.setUpdateTimestamp(currentTimestamp); |
|||
updateById(post); |
|||
} |
|||
|
|||
@Override |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public void deleteMediaSource(DeleteUserPostMediaDTO dto) { |
|||
List<String> mediaUuidList = dto.getMediaUuidList(); |
|||
|
|||
// 校验作品是否存在
|
|||
MiniUserPost post = lambdaQuery() |
|||
.eq(MiniUserPost::getUuid, dto.getPostUuid()) |
|||
.eq(MiniUserPost::getDeleted, false) |
|||
.one(); |
|||
if (post == null) { |
|||
throw new MsgException("用户作品不存在"); |
|||
} |
|||
|
|||
// 查询要删除的媒体资源
|
|||
LambdaQueryWrapper<MiniUserPostMedia> queryWrapper = new LambdaQueryWrapper<>(); |
|||
queryWrapper.in(MiniUserPostMedia::getUuid, mediaUuidList) |
|||
.eq(MiniUserPostMedia::getPostId, post.getId()) |
|||
.eq(MiniUserPostMedia::getDeleted, false); |
|||
|
|||
List<MiniUserPostMedia> mediaList = miniUserPostMediaMapper.selectList(queryWrapper); |
|||
|
|||
if (CollectionUtils.isNotEmpty(mediaList)) { |
|||
// 删除阿里云OSS源文件
|
|||
List<String> storageKeyList = mediaList.stream() |
|||
.map(MiniUserPostMedia::getStorageKey) |
|||
.toList(); |
|||
aliyunFileService.deleteMultiFile(storageKeyList); |
|||
|
|||
long currentTimestamp = System.currentTimeMillis(); |
|||
|
|||
// 逻辑删除数据库记录
|
|||
miniUserPostMediaMapper.update( |
|||
null, |
|||
new LambdaUpdateWrapper<MiniUserPostMedia>() |
|||
.in(MiniUserPostMedia::getUuid, mediaUuidList) |
|||
.eq(MiniUserPostMedia::getPostId, post.getId()) |
|||
.set(MiniUserPostMedia::getDeleted, true) |
|||
.set(MiniUserPostMedia::getUpdateTimestamp, currentTimestamp) |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public void saveMediaSource(String postUuid, List<MultipartFile> images, List<MultipartFile> videos) { |
|||
MiniUserPost post = lambdaQuery().eq(MiniUserPost::getUuid, postUuid).one(); |
|||
if (post == null) { |
|||
return; |
|||
} |
|||
long currentTimestamp = System.currentTimeMillis(); |
|||
|
|||
// 处理图片
|
|||
if (CollUtil.isNotEmpty(images)) { |
|||
for (MultipartFile image : images) { |
|||
try { |
|||
String objectName = OSS_IMAGE_DIR |
|||
+ currentTimestamp + RandomNumberUtils.createRandomLowerLetterAndNumber(8) |
|||
+ "." |
|||
+ FilenameUtils.getExtension(image.getOriginalFilename()); |
|||
String url = aliyunFileService.uploadFile(objectName, image.getInputStream()); |
|||
|
|||
MiniUserPostMedia media = new MiniUserPostMedia(); |
|||
media.setUuid(UUID.randomUUID().toString()); |
|||
media.setPostId(post.getId()); |
|||
media.setMediaType(AnimalNoteMediaTypeEnum.IMAGE.name().toLowerCase()); |
|||
media.setSourceUrl(url); |
|||
media.setStorageKey(objectName); |
|||
BufferedImage imageInfo = ImageIO.read(image.getInputStream()); |
|||
media.setWidth(imageInfo.getWidth()); |
|||
media.setHeight(imageInfo.getHeight()); |
|||
media.setCreateTimestamp(currentTimestamp); |
|||
media.setCreateTime(new Date(currentTimestamp)); |
|||
media.setCreateBy(SecurityUtils.getUserId()); |
|||
miniUserPostMediaMapper.insert(media); |
|||
} catch (Exception e) { |
|||
log.error("用户作品补充图片上传失败", e); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 处理视频
|
|||
if (CollUtil.isNotEmpty(videos)) { |
|||
String tmpPath = System.getProperty("user.dir") + "/tmp"; |
|||
for (MultipartFile video : videos) { |
|||
try { |
|||
String fileName = currentTimestamp + RandomNumberUtils.createRandomLowerLetterAndNumber(8); |
|||
String objectName = OSS_VIDEO_DIR |
|||
+ fileName |
|||
+ "." |
|||
+ FilenameUtils.getExtension(video.getOriginalFilename()); |
|||
String url = aliyunFileService.uploadFile(objectName, video.getInputStream()); |
|||
|
|||
MiniUserPostMedia media = new MiniUserPostMedia(); |
|||
media.setUuid(UUID.randomUUID().toString()); |
|||
media.setPostId(post.getId()); |
|||
media.setMediaType(AnimalNoteMediaTypeEnum.VIDEO.name().toLowerCase()); |
|||
media.setSourceUrl(url); |
|||
media.setStorageKey(objectName); |
|||
media.setCreateTimestamp(currentTimestamp); |
|||
media.setCreateTime(new Date(currentTimestamp)); |
|||
media.setCreateBy(SecurityUtils.getUserId()); |
|||
|
|||
// 时长
|
|||
FileUtils.saveFile(video, tmpPath, fileName); |
|||
String videoPath = tmpPath + File.separator + fileName; |
|||
double duration = JavaVCUtils.getVideoDuration(videoPath); |
|||
media.setDuration((int) Math.ceil(duration)); |
|||
|
|||
// 缩略图
|
|||
BufferedImage thumbnail = JavaVCUtils.getVideoThumbnail(videoPath, 1); |
|||
String thumbnailFileName = currentTimestamp + RandomNumberUtils.createRandomLowerLetterAndNumber(8); |
|||
String thumbnailObjectName = OSS_THUMBNAIL_DIR + thumbnailFileName + ".png"; |
|||
String thumbnailUrl = aliyunFileService.uploadFile(thumbnailObjectName, |
|||
FileUtils.bufferedImageToInputStream(thumbnail, "png")); |
|||
media.setThumbnailUrl(thumbnailUrl); |
|||
|
|||
miniUserPostMediaMapper.insert(media); |
|||
FileUtils.delete(videoPath); |
|||
} catch (Exception e) { |
|||
log.error("用户作品补充视频上传失败", e); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public void updateVisibility(String postUuid, EditVisibilityDTO dto) { |
|||
Long userId = SecurityUtils.getUserId(); |
|||
|
|||
MiniUserPost post = lambdaQuery() |
|||
.eq(MiniUserPost::getUuid, postUuid) |
|||
.eq(MiniUserPost::getMiniUserId, userId) |
|||
.eq(MiniUserPost::getDeleted, false) |
|||
.one(); |
|||
|
|||
if (post == null) { |
|||
throw new MsgException("用户作品不存在"); |
|||
} |
|||
|
|||
long currentTimestamp = System.currentTimeMillis(); |
|||
LambdaUpdateWrapper<MiniUserPost> updateWrapper = new LambdaUpdateWrapper<>(); |
|||
updateWrapper.eq(MiniUserPost::getUuid, postUuid) |
|||
.set(MiniUserPost::getVisibility, dto.getVisibility()) |
|||
.set(MiniUserPost::getUpdateTime, new Date(currentTimestamp)) |
|||
.set(MiniUserPost::getUpdateTimestamp, currentTimestamp); |
|||
update(updateWrapper); |
|||
} |
|||
|
|||
@Override |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public void delete(DeleteUserPostDTO dto) { |
|||
Long userId = SecurityUtils.getUserId(); |
|||
|
|||
LambdaUpdateWrapper<MiniUserPost> updateWrapper = new LambdaUpdateWrapper<>(); |
|||
updateWrapper.in(MiniUserPost::getUuid, dto.getPostUuidList()) |
|||
.eq(MiniUserPost::getMiniUserId, userId) |
|||
.eq(MiniUserPost::getDeleted, false) |
|||
.set(MiniUserPost::getDeleted, true) |
|||
.set(MiniUserPost::getUpdateTime, new Date()) |
|||
.set(MiniUserPost::getUpdateTimestamp, System.currentTimeMillis()); |
|||
|
|||
update(null, updateWrapper); |
|||
} |
|||
|
|||
@Override |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public Map<String, Object> toggleUserPostLike(UserPostLikeForm form, Long userId) { |
|||
Long postId = baseMapper.selectIdByUuid(form.getPostUuid()); |
|||
if (postId == null) { |
|||
throw new MsgException("用户作品不存在或已删除"); |
|||
} |
|||
|
|||
long currentTime = System.currentTimeMillis(); |
|||
Boolean currentLiked; |
|||
|
|||
Integer count = miniUserPostLikeMapper.selectUserLikeCount(postId, userId); |
|||
if (count != null && count > 0) { |
|||
currentLiked = true; |
|||
} else { |
|||
currentLiked = false; |
|||
} |
|||
|
|||
boolean targetLike = !currentLiked; |
|||
|
|||
if (targetLike) { |
|||
if (!Boolean.TRUE.equals(currentLiked)) { |
|||
MiniUserPostLike like = new MiniUserPostLike(); |
|||
like.setUuid(IdWorker.get32UUID()); |
|||
like.setPostId(postId); |
|||
like.setMiniUserId(userId); |
|||
like.setCreateBy(userId); |
|||
like.setCreateTimestamp(currentTime); |
|||
like.setCreateTime(new Date(currentTime)); |
|||
like.setUpdateBy(userId); |
|||
like.setUpdateTimestamp(currentTime); |
|||
like.setUpdateTime(new Date(currentTime)); |
|||
like.setDeleted(false); |
|||
miniUserPostLikeMapper.insertOrUpdateLike(like); |
|||
|
|||
baseMapper.incrementLikeCount(postId); |
|||
} |
|||
} else { |
|||
if (Boolean.TRUE.equals(currentLiked)) { |
|||
miniUserPostLikeMapper.deleteLike(postId, userId, currentTime); |
|||
|
|||
baseMapper.decrementLikeCount(postId); |
|||
} |
|||
} |
|||
|
|||
Long likeCount = baseMapper.selectLikeCount(postId); |
|||
Map<String, Object> result = new HashMap<>(); |
|||
result.put("isLiked", targetLike); |
|||
result.put("likeCount", likeCount != null ? likeCount : 0); |
|||
return result; |
|||
} |
|||
|
|||
@Override |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public Map<String, Object> toggleUserPostCollect(UserPostCollectForm form, Long userId) { |
|||
Long postId = baseMapper.selectIdByUuid(form.getPostUuid()); |
|||
if (postId == null) { |
|||
throw new MsgException("用户作品不存在或已删除"); |
|||
} |
|||
|
|||
long currentTime = System.currentTimeMillis(); |
|||
Boolean currentCollected; |
|||
|
|||
Integer count = miniUserPostCollectMapper.selectUserCollectCount(postId, userId); |
|||
if (count != null && count > 0) { |
|||
currentCollected = true; |
|||
} else { |
|||
currentCollected = false; |
|||
} |
|||
|
|||
boolean targetCollect = !currentCollected; |
|||
|
|||
if (targetCollect) { |
|||
if (!Boolean.TRUE.equals(currentCollected)) { |
|||
MiniUserPostCollect collect = new MiniUserPostCollect(); |
|||
collect.setUuid(IdWorker.get32UUID()); |
|||
collect.setPostId(postId); |
|||
collect.setMiniUserId(userId); |
|||
collect.setCreateBy(userId); |
|||
collect.setCreateTimestamp(currentTime); |
|||
collect.setCreateTime(new Date(currentTime)); |
|||
collect.setUpdateBy(userId); |
|||
collect.setUpdateTimestamp(currentTime); |
|||
collect.setUpdateTime(new Date(currentTime)); |
|||
collect.setDeleted(false); |
|||
miniUserPostCollectMapper.insertOrUpdateCollect(collect); |
|||
|
|||
baseMapper.incrementCollectCount(postId); |
|||
} |
|||
} else { |
|||
if (Boolean.TRUE.equals(currentCollected)) { |
|||
miniUserPostCollectMapper.deleteCollect(postId, userId, currentTime); |
|||
|
|||
baseMapper.decrementCollectCount(postId); |
|||
} |
|||
} |
|||
|
|||
Long collectCount = baseMapper.selectCollectCount(postId); |
|||
Map<String, Object> result = new HashMap<>(); |
|||
result.put("isCollected", targetCollect); |
|||
result.put("collectCount", collectCount != null ? collectCount : 0); |
|||
return result; |
|||
} |
|||
|
|||
@Override |
|||
public IPage<UserPostVO> getSelfCreatedPage(OwnUserPostQuery queryParams) { |
|||
Long userId = SecurityUtils.getUserId(); |
|||
queryParams.setMiniUserId(userId); |
|||
queryParams.setCreatorId(userId); |
|||
|
|||
Page<UserPostVO> page = new Page<>(queryParams.getPageNum(), queryParams.getPageSize()); |
|||
return baseMapper.getUserPostPage(page, queryParams); |
|||
} |
|||
|
|||
@Override |
|||
public UserPostDetailsVO getDetails(String postUuid) { |
|||
MiniUserPost post = lambdaQuery() |
|||
.eq(MiniUserPost::getUuid, postUuid) |
|||
.eq(MiniUserPost::getDeleted, false) |
|||
.one(); |
|||
if (post == null) { |
|||
throw new MsgException("用户作品不存在"); |
|||
} |
|||
|
|||
UserPostDetailsVO detailsVO = new UserPostDetailsVO(); |
|||
|
|||
Map<String, Object> param = new HashMap<>(); |
|||
param.put("postId", post.getId()); |
|||
|
|||
param.put("mediaType", AnimalNoteMediaTypeEnum.IMAGE.name().toLowerCase()); |
|||
List<UserPostMediaVO> images = miniUserPostMediaMapper.getMediaByPostIdAndType(param); |
|||
if (CollectionUtils.isNotEmpty(images)) { |
|||
images.forEach(item -> item.setPostUuid(post.getUuid())); |
|||
} |
|||
|
|||
param.put("mediaType", AnimalNoteMediaTypeEnum.VIDEO.name().toLowerCase()); |
|||
List<UserPostMediaVO> videos = miniUserPostMediaMapper.getMediaByPostIdAndType(param); |
|||
if (CollectionUtils.isNotEmpty(videos)) { |
|||
videos.forEach(item -> item.setPostUuid(post.getUuid())); |
|||
} |
|||
|
|||
detailsVO.setImages(images); |
|||
detailsVO.setVideos(videos); |
|||
|
|||
return detailsVO; |
|||
} |
|||
|
|||
@Override |
|||
public IPage<UserPostVO> getOthersCreatedPage(String authorUuid, OwnUserPostQuery queryParams) { |
|||
SysUser sysUser = userMapper.selectOne(new LambdaQueryWrapper<SysUser>() |
|||
.eq(SysUser::getUuid, authorUuid) |
|||
.eq(SysUser::getIsDeleted, 0)); |
|||
if (sysUser == null) { |
|||
throw new MsgException("用户不存在"); |
|||
} |
|||
|
|||
queryParams.setMiniUserId(SecurityUtils.getUserId()); |
|||
queryParams.setCreatorId(sysUser.getId()); |
|||
|
|||
Page<UserPostVO> page = new Page<>(queryParams.getPageNum(), queryParams.getPageSize()); |
|||
return baseMapper.getUserPostPage(page, queryParams); |
|||
} |
|||
|
|||
@Override |
|||
public WaterfallResult<UserPostVO> getWaterfall(UserPostWaterfallQuery query, Long userId) { |
|||
int pageSize = query.getPageSize(); |
|||
int querySize = pageSize + 1; |
|||
|
|||
List<UserPostVO> originalList = baseMapper.getWaterfall( |
|||
query.getCursor(), |
|||
querySize, |
|||
userId |
|||
); |
|||
|
|||
Long nextCursor = null; |
|||
boolean isLastPage = true; |
|||
if (CollUtil.isNotEmpty(originalList)) { |
|||
if (originalList.size() > pageSize) { |
|||
nextCursor = originalList.get(pageSize - 1).getId(); |
|||
isLastPage = false; |
|||
} |
|||
} else { |
|||
return WaterfallResult.of(Collections.emptyList(), null, true); |
|||
} |
|||
|
|||
List<UserPostVO> resultList = originalList.stream().limit(pageSize).collect(Collectors.toList()); |
|||
|
|||
if (userId != null && CollUtil.isNotEmpty(resultList)) { |
|||
try { |
|||
String bloomKey = BLOOM_VIEW_KEY_PREFIX + userId; |
|||
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(bloomKey); |
|||
if (bloomFilter.isExists()) { |
|||
resultList = resultList.stream() |
|||
.filter(item -> !bloomFilter.contains(item.getId())) |
|||
.collect(Collectors.toList()); |
|||
} else { |
|||
log.debug("用户{}布隆不存在,同步重建浏览记录", userId); |
|||
try { |
|||
rebuildUserBloomFromDB(userId, 365); |
|||
RBloomFilter<Long> newBloomFilter = redissonClient.getBloomFilter(bloomKey); |
|||
if (newBloomFilter.isExists()) { |
|||
resultList = resultList.stream() |
|||
.filter(item -> !newBloomFilter.contains(item.getId())) |
|||
.collect(Collectors.toList()); |
|||
} |
|||
log.debug("用户{}布隆同步重建完成,本次已过滤已浏览内容", userId); |
|||
} catch (Exception ex) { |
|||
log.error("用户{}布隆同步重建失败,本次不过滤,下次访问自动重试", userId, ex); |
|||
} |
|||
} |
|||
} catch (Exception e) { |
|||
log.error("布隆操作异常,自动降级不过滤,userId:{}, cursor:{}", userId, query.getCursor(), e); |
|||
} |
|||
} |
|||
|
|||
if (CollUtil.isNotEmpty(resultList)) { |
|||
resultList.forEach(item -> { |
|||
if (StrUtil.isBlank(item.getFirstImageUrl())) { |
|||
item.setFirstImageUrl(getDefaultCoverHost() + "/default_diary.png"); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
return WaterfallResult.of(resultList, nextCursor, isLastPage); |
|||
} |
|||
|
|||
private String getDefaultCoverHost() { |
|||
return "https://" + bucketName + "." + endpoint; |
|||
} |
|||
|
|||
public void rebuildUserBloomFromDB(Long userId) { |
|||
rebuildUserBloomFromDB(userId, 0); |
|||
} |
|||
|
|||
public void rebuildUserBloomFromDB(Long userId, int days) { |
|||
if (userId == null) { |
|||
return; |
|||
} |
|||
Long startTime = null; |
|||
if (days > 0) { |
|||
startTime = System.currentTimeMillis() - (long) days * 24 * 60 * 60 * 1000; |
|||
} |
|||
List<Long> viewedPostIds = miniUserPostViewMapper.selectViewedPostIdsByUserAndTime(userId, startTime); |
|||
|
|||
if (CollUtil.isEmpty(viewedPostIds)) { |
|||
return; |
|||
} |
|||
|
|||
String bloomKey = BLOOM_VIEW_KEY_PREFIX + userId; |
|||
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(bloomKey); |
|||
RLock lock = redissonClient.getLock(BLOOM_REBUILD_LOCK_PREFIX + userId); |
|||
|
|||
try { |
|||
if (lock.tryLock(3, TimeUnit.SECONDS)) { |
|||
try { |
|||
if (bloomFilter.isExists()) { |
|||
bloomFilter.delete(); |
|||
} |
|||
bloomFilter.tryInit(bloomExpectedInsertions, bloomFpp); |
|||
if (bloomExpireDays != null && bloomExpireDays > 0) { |
|||
bloomFilter.expire(bloomExpireDays, TimeUnit.DAYS); |
|||
} |
|||
|
|||
for (Long postId : viewedPostIds) { |
|||
bloomFilter.add(postId); |
|||
} |
|||
|
|||
log.info("用户{}的作品浏览布隆重建完成,共写入{}条记录", userId, viewedPostIds.size()); |
|||
} finally { |
|||
if (lock.isHeldByCurrentThread()) { |
|||
lock.unlock(); |
|||
} |
|||
} |
|||
} |
|||
} catch (InterruptedException e) { |
|||
Thread.currentThread().interrupt(); |
|||
log.error("重建用户{}的作品布隆时获取锁异常", userId, e); |
|||
} catch (Exception e) { |
|||
log.error("重建用户{}的作品布隆失败", userId, e); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public void resetCurrentUserBloom(Long userId) { |
|||
if (userId == null) { |
|||
throw new MsgException("用户未登录"); |
|||
} |
|||
try { |
|||
String bloomKey = BLOOM_VIEW_KEY_PREFIX + userId; |
|||
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(bloomKey); |
|||
if (bloomFilter.isExists()) { |
|||
bloomFilter.delete(); |
|||
} |
|||
bloomFilter.tryInit(bloomExpectedInsertions, bloomFpp); |
|||
if (bloomExpireDays != null && bloomExpireDays > 0) { |
|||
bloomFilter.expire(bloomExpireDays, TimeUnit.DAYS); |
|||
} |
|||
log.info("用户{}的作品布隆过滤器重置成功,已清空历史过滤规则,新浏览内容将继续记录", userId); |
|||
} catch (Exception e) { |
|||
log.error("重置用户{}的作品布隆过滤器失败", userId, e); |
|||
throw new MsgException("重置失败,请稍后重试"); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public void increaseUserPostViewCount(String postUuid) { |
|||
try { |
|||
baseMapper.incrementViewCount(postUuid); |
|||
} catch (Exception e) { |
|||
log.error("增加用户作品{}浏览量失败", postUuid, e); |
|||
} |
|||
} |
|||
|
|||
|
|||
} |
|||
|
|||
Loading…
Reference in new issue