513 lines
22 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package edu.whut.smilepicturebackend.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import edu.whut.smilepicturebackend.exception.BusinessException;
import edu.whut.smilepicturebackend.exception.ErrorCode;
import edu.whut.smilepicturebackend.exception.ThrowUtils;
import edu.whut.smilepicturebackend.manager.CosManager;
import edu.whut.smilepicturebackend.manager.FileManager;
import edu.whut.smilepicturebackend.manager.cache.MyCacheManager;
import edu.whut.smilepicturebackend.manager.upload.FilePictureUpload;
import edu.whut.smilepicturebackend.manager.upload.PictureUploadTemplate;
import edu.whut.smilepicturebackend.manager.upload.UrlPictureUpload;
import edu.whut.smilepicturebackend.mapper.PictureMapper;
import edu.whut.smilepicturebackend.model.dto.picture.*;
import edu.whut.smilepicturebackend.model.entity.Picture;
import edu.whut.smilepicturebackend.model.entity.Space;
import edu.whut.smilepicturebackend.model.entity.User;
import edu.whut.smilepicturebackend.model.enums.PictureReviewStatusEnum;
import edu.whut.smilepicturebackend.model.file.UploadPictureResult;
import edu.whut.smilepicturebackend.model.vo.PictureVO;
import edu.whut.smilepicturebackend.model.vo.UserVO;
import edu.whut.smilepicturebackend.service.PictureService;
import edu.whut.smilepicturebackend.service.SpaceService;
import edu.whut.smilepicturebackend.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.springframework.beans.BeanUtils;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* @author 张三
* @description 针对表【picture(图片)】的数据库操作Service实现
* @createDate 2025-06-11 11:23:11
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PictureServiceImpl extends ServiceImpl<PictureMapper, Picture>
implements PictureService {
private final FileManager fileManager;
private final UserService userService;
private final FilePictureUpload filePictureUpload;
private final UrlPictureUpload urlPictureUpload;
private final MyCacheManager cacheManager;
private final CosManager cosManager;
private final SpaceService spaceService;
@Override
public void validPicture(Picture picture) {
ThrowUtils.throwIf(picture == null, ErrorCode.PARAMS_ERROR);
// 从对象中取值
Long id = picture.getId();
String url = picture.getUrl();
String introduction = picture.getIntroduction();
// 修改数据时id 不能为空,有参数则校验
ThrowUtils.throwIf(ObjUtil.isNull(id), ErrorCode.PARAMS_ERROR, "id 不能为空");
// 如果传递了 url才校验
if (StrUtil.isNotBlank(url)) {
ThrowUtils.throwIf(url.length() > 1024, ErrorCode.PARAMS_ERROR, "url 过长");
}
if (StrUtil.isNotBlank(introduction)) {
ThrowUtils.throwIf(introduction.length() > 800, ErrorCode.PARAMS_ERROR, "简介过长");
}
}
@Override
public PictureVO uploadPicture(Object inputSource, PictureUploadRequest pictureUploadRequest, User loginUser) {
// 校验参数
ThrowUtils.throwIf(loginUser == null, ErrorCode.NO_AUTH_ERROR);
// 校验空间是否存在
Long spaceId = pictureUploadRequest.getSpaceId();
if (spaceId != null) {
Space space = spaceService.getById(spaceId);
ThrowUtils.throwIf(space == null, ErrorCode.NOT_FOUND_ERROR, "空间不存在");
// 改为使用统一的权限校验
// 校验是否有空间的权限,仅空间管理员才能上传
if (!loginUser.getId().equals(space.getUserId())) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "没有空间权限");
}
}
// 判断是创建还是替换
Long pictureId = pictureUploadRequest == null ? null : pictureUploadRequest.getId();
Picture oldPicture = null;
// 如果是更新,判断图片是否存在
if (pictureId != null) {
oldPicture = this.getById(pictureId);
ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR, "图片不存在");
// 仅本人或管理员可编辑图片
if (!oldPicture.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
// 校验空间是否一致
// 没传 spaceId则复用原有图片的 spaceId这样也兼容了公共图库
if (spaceId == null) {
if (oldPicture.getSpaceId() != null) {
spaceId = oldPicture.getSpaceId();
}
} else {
// 传了 spaceId必须和原图片的空间 id 一致
if (ObjUtil.notEqual(spaceId, oldPicture.getSpaceId())) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "空间 id 不一致");
}
}
}
// 上传图片,得到图片信息
// 按照用户 id 划分目录 => 按照空间划分目录
String uploadPathPrefix;
if (spaceId == null) {
// 公共图库+用户id
uploadPathPrefix = String.format("public/%s", loginUser.getId());
} else {
// 私人空间+空间id
uploadPathPrefix = String.format("space/%s", spaceId);
}
// 根据 inputSource 的类型区分上传方式!!
PictureUploadTemplate pictureUploadTemplate = filePictureUpload;
if (inputSource instanceof String) {
pictureUploadTemplate = urlPictureUpload;
}
//上传到腾讯云COS上
UploadPictureResult uploadPictureResult = pictureUploadTemplate.uploadPicture(inputSource, uploadPathPrefix);
// 构造要入库的图片信息,将图片信息存入数据库中。
Picture picture = new Picture();
// 复制同名属性url、name、picSize、picWidth、picHeight、picScale、picFormat
BeanUtils.copyProperties(uploadPictureResult, picture);
// 支持外层pictureUploadRequest传递图片名称
picture.setName(
StrUtil.blankToDefault(
pictureUploadRequest == null ? null : pictureUploadRequest.getPicName(),
uploadPictureResult.getName()
)
);
picture.setUserId(loginUser.getId());
picture.setSpaceId(spaceId);
// 补充审核参数
this.fillReviewParams(picture, loginUser);
// 操作数据库
// 如果 pictureId 不为空,表示更新,否则是新增
if (pictureId != null) {
// 如果是更新,需要补充 id 和编辑时间
picture.setId(pictureId);
picture.setEditTime(new Date());
}
boolean result = this.saveOrUpdate(picture);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "图片上传失败,数据库操作失败");
//如果是更新,清理旧的图片
if (oldPicture != null) {
this.clearPictureFile(oldPicture);
}
return PictureVO.objToVo(picture);
}
@Override
public LambdaQueryWrapper<Picture> getQueryWrapper(PictureQueryRequest req) {
LambdaQueryWrapper<Picture> qw = Wrappers.lambdaQuery(Picture.class);
if (req == null) {
return qw;
}
// 精简版条件构造
qw.eq(ObjUtil.isNotEmpty(req.getId()), Picture::getId, req.getId())
.eq(ObjUtil.isNotEmpty(req.getUserId()), Picture::getUserId, req.getUserId())
.like(StrUtil.isNotBlank(req.getName()), Picture::getName, req.getName())
.like(StrUtil.isNotBlank(req.getIntroduction()), Picture::getIntroduction, req.getIntroduction())
.like(StrUtil.isNotBlank(req.getPicFormat()), Picture::getPicFormat, req.getPicFormat())
.eq(ObjUtil.isNotEmpty(req.getReviewMessage()),Picture::getReviewMessage,req.getReviewMessage())
.eq(StrUtil.isNotBlank(req.getCategory()), Picture::getCategory, req.getCategory())
.eq(ObjUtil.isNotEmpty(req.getPicWidth()), Picture::getPicWidth, req.getPicWidth())
.eq(ObjUtil.isNotEmpty(req.getPicHeight()), Picture::getPicHeight, req.getPicHeight())
.eq(ObjUtil.isNotEmpty(req.getPicSize()), Picture::getPicSize, req.getPicSize())
.eq(ObjUtil.isNotEmpty(req.getPicScale()), Picture::getPicScale, req.getPicScale())
.eq(ObjUtil.isNotEmpty(req.getReviewStatus()),Picture::getReviewStatus,req.getReviewStatus())
.eq(ObjUtil.isNotEmpty(req.getReviewerId()),Picture::getReviewerId,req.getReviewerId())
.ge(ObjUtil.isNotEmpty(req.getStartEditTime()), Picture::getEditTime, req.getStartEditTime())
.lt(ObjUtil.isNotEmpty(req.getEndEditTime()), Picture::getEditTime, req.getEndEditTime());
// 全字段模糊搜索
if (StrUtil.isNotBlank(req.getSearchText())) {
qw.and(w -> w
.like(Picture::getName, req.getSearchText())
.or()
.like(Picture::getIntroduction, req.getSearchText())
);
}
// JSON 数组 tags 查询
if (CollUtil.isNotEmpty(req.getTags())) {
req.getTags().forEach(tag ->
qw.like(Picture::getTags, "\"" + tag + "\"")
);
}
// 动态排序:转下划线字段名并手工拼接
if (StrUtil.isNotBlank(req.getSortField())) {
String column = StrUtil.toUnderlineCase(req.getSortField());
String direction = "ascend".equalsIgnoreCase(req.getSortOrder()) ? "ASC" : "DESC";
qw.last("ORDER BY " + column + " " + direction);
}
return qw;
}
@Override
public void deletePicture(long pictureId, User loginUser) {
Picture oldPicture = this.getById(pictureId);
ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR);
// 仅本人或者管理员可删除
if (!oldPicture.getUserId().equals(loginUser.getId())
&& !userService.isAdmin(loginUser)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
// 校验权限
checkPictureAuth(loginUser, oldPicture);
// 操作数据库
boolean result = this.removeById(pictureId);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
//清理图片资源
this.clearPictureFile(oldPicture);
}
@Override
public void editPicture(PictureEditRequest pictureEditRequest, User loginUser) {
// 在此处将实体类和 DTO 进行转换
Picture picture = new Picture();
BeanUtils.copyProperties(pictureEditRequest, picture);
// 注意将 list 转为 string
picture.setTags(JSONUtil.toJsonStr(pictureEditRequest.getTags()));
// 设置编辑时间
picture.setEditTime(new Date());
// 数据校验
this.validPicture(picture);
// 判断是否存在
long id = pictureEditRequest.getId();
Picture oldPicture = this.getById(id);
ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR);
// 校验权限
checkPictureAuth(loginUser, oldPicture);
// 补充审核参数,每次编辑图片都要重新过审
this.fillReviewParams(picture, loginUser);
// 操作数据库
boolean result = this.updateById(picture);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
}
@Override
public PictureVO getPictureVO(Picture picture, HttpServletRequest request) {
// 对象转封装类
PictureVO pictureVO = PictureVO.objToVo(picture);
// 关联查询用户信息
Long userId = picture.getUserId();
if (userId != null && userId > 0) {
User user = userService.getById(userId);
UserVO userVO = userService.getUserVO(user);
pictureVO.setUser(userVO);
}
return pictureVO;
}
/**
* 分页获取图片封装
*/
@Override
public Page<PictureVO> getPictureVOPage(Page<Picture> picturePage, HttpServletRequest request) {
//从 Page<Picture> 中拿到原始记录和分页元数据
List<Picture> pictureList = picturePage.getRecords();
Page<PictureVO> pictureVOPage = new Page<>(picturePage.getCurrent(), picturePage.getSize(), picturePage.getTotal());
if (CollUtil.isEmpty(pictureList)) {
return pictureVOPage;
}
// 实体到 VO 的基本映射
List<PictureVO> pictureVOList = pictureList.stream()
.map(PictureVO::objToVo)
.collect(Collectors.toList());
//批量拉取关联的用户信息
//先从所有 Picture 里摘出不重复的 userId。
//调 userService.listByIds(...) 一次性把这些用户都查出来,避免 N+1 查询。
//再把结果按 userId 分组,方便快速根据 ID 拿到对应的 User 对象。
Set<Long> userIdSet = pictureList.stream().map(Picture::getUserId).collect(Collectors.toSet());
// 1 => user1, 2 => user2
Map<Long, List<User>> userIdUserListMap = userService.listByIds(userIdSet).stream()
.collect(Collectors.groupingBy(User::getId));
// 把用户信息填充到 VO
pictureVOList.forEach(pictureVO -> {
Long userId = pictureVO.getUserId();
User user = null;
if (userIdUserListMap.containsKey(userId)) {
user = userIdUserListMap.get(userId).get(0);
}
pictureVO.setUser(userService.getUserVO(user));
});
pictureVOPage.setRecords(pictureVOList);
return pictureVOPage;
}
@Override
public void doPictureReview(PictureReviewRequest pictureReviewRequest, User loginUser) {
// 1. 校验参数
ThrowUtils.throwIf(pictureReviewRequest == null, ErrorCode.PARAMS_ERROR);
Long id = pictureReviewRequest.getId();
Integer reviewStatus = pictureReviewRequest.getReviewStatus();
PictureReviewStatusEnum reviewStatusEnum = PictureReviewStatusEnum.getEnumByValue(reviewStatus);
String reviewMessage = pictureReviewRequest.getReviewMessage();
if (id == null || reviewStatusEnum == null || PictureReviewStatusEnum.REVIEWING.equals(reviewStatusEnum)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
// 2. 判断图片是否存在
Picture oldPicture = this.getById(id);
ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR);
// 3. 校验审核状态是否重复,已是改状态
if (oldPicture.getReviewStatus().equals(reviewStatus)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "请勿重复审核");
}
// 4. 数据库操作
Picture updatePicture = new Picture();
BeanUtil.copyProperties(pictureReviewRequest, updatePicture);
updatePicture.setReviewerId(loginUser.getId()); //审核人
updatePicture.setReviewTime(new Date());
boolean result = this.updateById(updatePicture);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
}
/**
* 填充审核参数
*
* @param picture
* @param loginUser
*/
@Override
public void fillReviewParams(Picture picture, User loginUser) {
if (userService.isAdmin(loginUser)) {
// 管理员自动过审
picture.setReviewStatus(PictureReviewStatusEnum.PASS.getValue());
picture.setReviewerId(loginUser.getId());
picture.setReviewMessage("管理员自动过审");
picture.setReviewTime(new Date());
} else {
// 非管理员,无论是编辑还是创建默认都是待审核
picture.setReviewStatus(PictureReviewStatusEnum.REVIEWING.getValue());
}
}
//爬取网落图片可以用ai分析标签
@Override
public Integer uploadPictureByBatch(PictureUploadByBatchRequest pictureUploadByBatchRequest, User loginUser) {
// 校验参数
String searchText = pictureUploadByBatchRequest.getSearchText();
Integer count = pictureUploadByBatchRequest.getCount();
ThrowUtils.throwIf(count > 30, ErrorCode.PARAMS_ERROR, "最多 30 条");
// 名称前缀默认等于搜索关键词
String namePrefix = pictureUploadByBatchRequest.getNamePrefix();
if (StrUtil.isBlank(namePrefix)) {
namePrefix = searchText;
}
// 抓取内容
String fetchUrl = String.format("https://cn.bing.com/images/async?q=%s&mmasync=1", searchText);
//最全的html文档
Document document;
try {
document = Jsoup.connect(fetchUrl).get();
} catch (IOException e) {
log.error("获取页面失败", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "获取页面失败");
}
// 解析内容
Element div = document.getElementsByClass("dgControl").first();
if (ObjUtil.isEmpty(div)) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "获取元素失败");
}
Elements imgElementList = div.select("img.mimg");
// 遍历元素,依次处理上传图片
int uploadCount = 0;
for (Element imgElement : imgElementList) {
// 找到最近的 <a class="iusc">,它的 m 属性里有完整的 JSON
Element a = imgElement.closest("a.iusc");
if (a == null || a.attr("m").isEmpty()) {
log.info("没找到 m 属性,跳过该图片");
continue;
}
// 用 Hutool 解析 JSON取出 murl
JSONObject mObj = JSONUtil.parseObj(a.attr("m"));
String fileUrl = mObj.getStr("murl"); // murl是带 .jpg/.png 的原图 , src是缩略图
// 可选:去掉 URL 后面的 ? 及参数
int qm = fileUrl.indexOf("?");
if (qm > 0) {
fileUrl = fileUrl.substring(0, qm);
}
// 上传图片
PictureUploadRequest pictureUploadRequest = new PictureUploadRequest();
pictureUploadRequest.setFileUrl(fileUrl);
pictureUploadRequest.setPicName(namePrefix + (uploadCount + 1));
try {
log.info("爬取图片url"+fileUrl);
PictureVO pictureVO = this.uploadPicture(fileUrl, pictureUploadRequest, loginUser);
log.info("图片上传成功id = {}", pictureVO.getId());
uploadCount++;
} catch (Exception e) {
log.error("图片上传失败", e);
continue;
}
if (uploadCount >= count) {
break;
}
}
return uploadCount;
}
/**
* 查询图片带缓存
* @param queryRequest
* @param httpRequest
* @return
*/
@Override
public Page<PictureVO> listPictureVOByPageWithCache(PictureQueryRequest queryRequest, HttpServletRequest httpRequest) {
long current = queryRequest.getCurrent();
long size = queryRequest.getPageSize();
// 参数校验
ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR);
queryRequest.setReviewStatus(PictureReviewStatusEnum.PASS.getValue());
// 构造 cacheKey
String condJson = JSONUtil.toJsonStr(queryRequest);
String hash = DigestUtils.md5DigestAsHex(condJson.getBytes());
String cacheKey = "smilepicture:listPictureVOByPage:" + hash;
// 随机过期300600s
int expire = 300 + RandomUtil.randomInt(0, 300);
// 调用通用缓存方法
return cacheManager.getFromCacheOrDatabase(
cacheKey,
Page.class,
() -> {
Page<Picture> picturePage = this.page(new Page<>(current, size),
this.getQueryWrapper(queryRequest));
return this.getPictureVOPage(picturePage, httpRequest);
},
expire
);
}
@Async //异步执行
@Override
public void clearPictureFile(Picture oldPicture) {
// 判断该图片是否被多条记录使用(图片秒传的情况可能一个cos地址对应多个数据库url)
String pictureUrl = oldPicture.getUrl();
long count = this.lambdaQuery()
.eq(Picture::getUrl, pictureUrl)
.count();
// 有不止一条记录用到了该图片,不清理
if (count > 1) {
return;
}
// 删除图片
cosManager.deleteIfNotBlank(pictureUrl);
//删除原图
String originalUrl=oldPicture.getOriginalUrl();
cosManager.deleteIfNotBlank(originalUrl);
// 删除缩略图
String thumbnailUrl = oldPicture.getThumbnailUrl();
cosManager.deleteIfNotBlank(thumbnailUrl);
}
@Override
public void checkPictureAuth(User loginUser, Picture picture) {
Long spaceId = picture.getSpaceId();
Long loginUserId = loginUser.getId();
if (spaceId == null) {
// 公共图库,仅本人或管理员可操作
if (!picture.getUserId().equals(loginUserId) && !userService.isAdmin(loginUser)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
} else {
// 私有空间,仅空间管理员可操作
if (!picture.getUserId().equals(loginUserId)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
}
}
}