3.14 图片查询二级缓存实现 图片优化、压缩格式、缩略图
This commit is contained in:
parent
144a55c1cf
commit
6fcfd80e51
@ -57,3 +57,8 @@ ALTER TABLE picture
|
|||||||
|
|
||||||
-- 创建基于 reviewStatus 列的索引
|
-- 创建基于 reviewStatus 列的索引
|
||||||
CREATE INDEX idx_reviewStatus ON picture (review_status);
|
CREATE INDEX idx_reviewStatus ON picture (review_status);
|
||||||
|
|
||||||
|
ALTER TABLE picture
|
||||||
|
-- 添加新列
|
||||||
|
ADD COLUMN original_url varchar(512) NULL COMMENT '原图 url',
|
||||||
|
ADD COLUMN thumbnail_url varchar(512) NULL COMMENT '缩略图 url';
|
@ -14,7 +14,7 @@ public class ResultUtils {
|
|||||||
* @return 响应
|
* @return 响应
|
||||||
*/
|
*/
|
||||||
public static <T> BaseResponse<T> success(T data) {
|
public static <T> BaseResponse<T> success(T data) {
|
||||||
return new BaseResponse<>(200, data, "success");
|
return new BaseResponse<>(0, data, "success");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
package edu.whut.smilepicturebackend.controller;
|
package edu.whut.smilepicturebackend.controller;
|
||||||
|
|
||||||
import cn.hutool.core.util.RandomUtil;
|
|
||||||
import cn.hutool.json.JSONUtil;
|
import cn.hutool.json.JSONUtil;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.github.benmanes.caffeine.cache.Cache;
|
|
||||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
|
||||||
import edu.whut.smilepicturebackend.annotation.AuthCheck;
|
import edu.whut.smilepicturebackend.annotation.AuthCheck;
|
||||||
import edu.whut.smilepicturebackend.common.BaseResponse;
|
import edu.whut.smilepicturebackend.common.BaseResponse;
|
||||||
import edu.whut.smilepicturebackend.common.DeleteRequest;
|
import edu.whut.smilepicturebackend.common.DeleteRequest;
|
||||||
@ -24,17 +20,14 @@ import edu.whut.smilepicturebackend.service.UserService;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
|
||||||
import org.springframework.data.redis.core.ValueOperations;
|
|
||||||
import org.springframework.util.DigestUtils;
|
import org.springframework.util.DigestUtils;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@ -43,16 +36,8 @@ import java.util.concurrent.TimeUnit;
|
|||||||
public class PictureController {
|
public class PictureController {
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final PictureService pictureService;
|
private final PictureService pictureService;
|
||||||
private final StringRedisTemplate stringRedisTemplate;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 本地缓存
|
|
||||||
*/
|
|
||||||
private final Cache<String, String> LOCAL_CACHE = Caffeine.newBuilder()
|
|
||||||
.initialCapacity(1024)
|
|
||||||
.maximumSize(10_000L) // 最大 10000 条
|
|
||||||
.expireAfterWrite(Duration.ofMinutes(5)) // 缓存 5 分钟后移除
|
|
||||||
.build();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传图片(可重新上传)
|
* 上传图片(可重新上传)
|
||||||
@ -211,45 +196,14 @@ public class PictureController {
|
|||||||
*/
|
*/
|
||||||
@Deprecated
|
@Deprecated
|
||||||
@PostMapping("/list/page/vo/cache")
|
@PostMapping("/list/page/vo/cache")
|
||||||
public BaseResponse<Page<PictureVO>> listPictureVOByPageWithCache(@RequestBody PictureQueryRequest pictureQueryRequest,
|
public BaseResponse<Page<PictureVO>> listPictureVOByPageWithCache(
|
||||||
HttpServletRequest request) {
|
@RequestBody PictureQueryRequest pictureQueryRequest,
|
||||||
long current = pictureQueryRequest.getCurrent();
|
HttpServletRequest request) {
|
||||||
long size = pictureQueryRequest.getPageSize();
|
|
||||||
// 限制爬虫
|
Page<PictureVO> page = pictureService
|
||||||
ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR);
|
.listPictureVOByPageWithCache(pictureQueryRequest, request);
|
||||||
// 普通用户默认只能看到审核通过的数据
|
|
||||||
pictureQueryRequest.setReviewStatus(PictureReviewStatusEnum.PASS.getValue());
|
return ResultUtils.success(page);
|
||||||
// 查询缓存,缓存中没有,再查询数据库
|
|
||||||
// 构建缓存的 key,根据查询条件来构建->json
|
|
||||||
String queryCondition = JSONUtil.toJsonStr(pictureQueryRequest); //json->md5,更轻量化,去除括号,双引号
|
|
||||||
String hashKey = DigestUtils.md5DigestAsHex(queryCondition.getBytes());
|
|
||||||
String cacheKey = String.format("smilepicture:listPictureVOByPage:%s", hashKey);
|
|
||||||
// // 1. 先从本地缓存中查询
|
|
||||||
// String cachedValue = LOCAL_CACHE.getIfPresent(cacheKey);
|
|
||||||
// if (cachedValue != null) {
|
|
||||||
// // 如果缓存命中,返回结果
|
|
||||||
// Page<PictureVO> cachedPage = JSONUtil.toBean(cachedValue, Page.class);
|
|
||||||
// return ResultUtils.success(cachedPage);
|
|
||||||
// }
|
|
||||||
// 2. 本地缓存未命中,查询 Redis 分布式缓存
|
|
||||||
ValueOperations<String, String> opsForValue = stringRedisTemplate.opsForValue();
|
|
||||||
String cachedValue = opsForValue.get(cacheKey);
|
|
||||||
if (cachedValue != null) {
|
|
||||||
Page<PictureVO> cachedPage = JSONUtil.toBean(cachedValue, Page.class);
|
|
||||||
return ResultUtils.success(cachedPage);
|
|
||||||
}
|
|
||||||
// 3. 查询数据库
|
|
||||||
Page<Picture> picturePage = pictureService.page(new Page<>(current, size),
|
|
||||||
pictureService.getQueryWrapper(pictureQueryRequest));
|
|
||||||
Page<PictureVO> pictureVOPage = pictureService.getPictureVOPage(picturePage, request);
|
|
||||||
// 4. 更新缓存
|
|
||||||
// 更新 Redis 缓存
|
|
||||||
String cacheValue = JSONUtil.toJsonStr(pictureVOPage);
|
|
||||||
// 设置缓存的过期时间,5 - 10 分钟过期,防止缓存雪崩!
|
|
||||||
int cacheExpireTime = 300 + RandomUtil.randomInt(0, 300);
|
|
||||||
opsForValue.set(cacheKey, cacheValue, cacheExpireTime, TimeUnit.SECONDS);
|
|
||||||
// 获取封装类
|
|
||||||
return ResultUtils.success(pictureVOPage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,26 +1,31 @@
|
|||||||
package edu.whut.smilepicturebackend.manager;
|
package edu.whut.smilepicturebackend.manager;
|
||||||
|
import cn.hutool.core.io.FileUtil;
|
||||||
import com.qcloud.cos.COSClient;
|
import com.qcloud.cos.COSClient;
|
||||||
import com.qcloud.cos.model.*;
|
import com.qcloud.cos.model.*;
|
||||||
import com.qcloud.cos.model.ciModel.persistence.PicOperations;
|
import com.qcloud.cos.model.ciModel.persistence.PicOperations;
|
||||||
import edu.whut.smilepicturebackend.config.CosClientConfig;
|
import edu.whut.smilepicturebackend.config.CosClientConfig;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 和业务没有关系,通用的文件上传下载
|
* 和业务没有关系,通用的文件上传下载
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class CosManager {
|
public class CosManager {
|
||||||
|
|
||||||
@Resource
|
private final CosClientConfig cosClientConfig;
|
||||||
private CosClientConfig cosClientConfig;
|
|
||||||
|
|
||||||
@Resource
|
private final COSClient cosClient;
|
||||||
private COSClient cosClient;
|
|
||||||
|
final long THUMBNAIL_THRESHOLD = 20 * 1024; // 20 KB
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传对象
|
* 上传对象
|
||||||
@ -47,7 +52,28 @@ public class CosManager {
|
|||||||
PicOperations picOperations = new PicOperations();
|
PicOperations picOperations = new PicOperations();
|
||||||
// 1 表示返回原图信息
|
// 1 表示返回原图信息
|
||||||
picOperations.setIsPicInfo(1);
|
picOperations.setIsPicInfo(1);
|
||||||
|
// 图片处理规则列表
|
||||||
|
List<PicOperations.Rule> rules = new ArrayList<>();
|
||||||
|
// 1. 图片压缩(转成 webp 格式) https://cloud.tencent.com/document/product/436/42215
|
||||||
|
String webpKey = FileUtil.mainName(key) + ".webp";
|
||||||
|
PicOperations.Rule compressRule = new PicOperations.Rule();
|
||||||
|
compressRule.setFileId(webpKey);
|
||||||
|
compressRule.setBucket(cosClientConfig.getBucket());
|
||||||
|
compressRule.setRule("imageMogr2/format/webp");
|
||||||
|
rules.add(compressRule);
|
||||||
|
// 2. 缩略图处理,仅对 > 20 KB 的图片生成缩略图 https://cloud.tencent.com/document/product/436/113295
|
||||||
|
if (file.length() > THUMBNAIL_THRESHOLD) {
|
||||||
|
PicOperations.Rule thumbnailRule = new PicOperations.Rule();
|
||||||
|
// 拼接缩略图的路径
|
||||||
|
String thumbnailKey = FileUtil.mainName(key) + "_thumbnail." + FileUtil.getSuffix(key);
|
||||||
|
thumbnailRule.setFileId(thumbnailKey);
|
||||||
|
thumbnailRule.setBucket(cosClientConfig.getBucket());
|
||||||
|
// 缩放规则 /thumbnail/<Width>x<Height>>(如果大于原图宽高,则不处理)
|
||||||
|
thumbnailRule.setRule(String.format("imageMogr2/thumbnail/%sx%s>", 400, 400));
|
||||||
|
rules.add(thumbnailRule);
|
||||||
|
}
|
||||||
// 构造处理参数
|
// 构造处理参数
|
||||||
|
picOperations.setRules(rules);
|
||||||
putObjectRequest.setPicOperations(picOperations);
|
putObjectRequest.setPicOperations(picOperations);
|
||||||
return cosClient.putObject(putObjectRequest);
|
return cosClient.putObject(putObjectRequest);
|
||||||
}
|
}
|
||||||
|
63
src/main/java/edu/whut/smilepicturebackend/manager/cache/MyCacheManager.java
vendored
Normal file
63
src/main/java/edu/whut/smilepicturebackend/manager/cache/MyCacheManager.java
vendored
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package edu.whut.smilepicturebackend.manager.cache;
|
||||||
|
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import com.github.benmanes.caffeine.cache.Cache;
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.ValueOperations;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MyCacheManager {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地缓存
|
||||||
|
*/
|
||||||
|
private final Cache<String, String> localCache = Caffeine.newBuilder()
|
||||||
|
.initialCapacity(1024)
|
||||||
|
.maximumSize(10_000L) // 最大 10000 条
|
||||||
|
.expireAfterWrite(Duration.ofMinutes(5)) // 缓存 5 分钟后移除
|
||||||
|
.build();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分布式缓存
|
||||||
|
*/
|
||||||
|
private final StringRedisTemplate stringRedisTemplate;
|
||||||
|
|
||||||
|
public <T> T getFromCacheOrDatabase(
|
||||||
|
String cacheKey,
|
||||||
|
Class<T> clazz,
|
||||||
|
Supplier<T> dbSupplier,
|
||||||
|
int redisExpireSeconds) {
|
||||||
|
|
||||||
|
// 查询本地缓存
|
||||||
|
String cachedValue = localCache.getIfPresent(cacheKey);
|
||||||
|
if (cachedValue != null) {
|
||||||
|
return JSONUtil.toBean(cachedValue, clazz);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询 Redis 缓存
|
||||||
|
ValueOperations<String, String> opsForValue = stringRedisTemplate.opsForValue();
|
||||||
|
cachedValue = opsForValue.get(cacheKey);
|
||||||
|
if (cachedValue != null) {
|
||||||
|
localCache.put(cacheKey, cachedValue);
|
||||||
|
return JSONUtil.toBean(cachedValue, clazz);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行数据库查询
|
||||||
|
T dbValue = dbSupplier.get();
|
||||||
|
|
||||||
|
// 更新 Redis 缓存
|
||||||
|
cachedValue = JSONUtil.toJsonStr(dbValue);
|
||||||
|
opsForValue.set(cacheKey, cachedValue, redisExpireSeconds, TimeUnit.SECONDS);
|
||||||
|
// 写入本地缓存
|
||||||
|
localCache.put(cacheKey, cachedValue);
|
||||||
|
return dbValue;
|
||||||
|
}
|
||||||
|
}
|
@ -26,9 +26,13 @@ public class FilePictureUpload extends PictureUploadTemplate {
|
|||||||
ThrowUtils.throwIf(fileSize > 2 * ONE_M, ErrorCode.PARAMS_ERROR, "文件大小不能超过 2MB");
|
ThrowUtils.throwIf(fileSize > 2 * ONE_M, ErrorCode.PARAMS_ERROR, "文件大小不能超过 2MB");
|
||||||
// 2. 校验文件后缀
|
// 2. 校验文件后缀
|
||||||
String fileSuffix = FileUtil.getSuffix(multipartFile.getOriginalFilename());
|
String fileSuffix = FileUtil.getSuffix(multipartFile.getOriginalFilename());
|
||||||
|
ThrowUtils.throwIf(fileSuffix == null, ErrorCode.PARAMS_ERROR, "文件后缀缺失");
|
||||||
// 允许上传的文件后缀列表(或者集合)
|
// 允许上传的文件后缀列表(或者集合)
|
||||||
final List<String> ALLOW_FORMAT_LIST = Arrays.asList("jpeg", "png", "jpg", "webp");
|
final List<String> ALLOW_FORMAT_LIST = Arrays.asList("jpeg", "png", "jpg", "webp");
|
||||||
ThrowUtils.throwIf(!ALLOW_FORMAT_LIST.contains(fileSuffix), ErrorCode.PARAMS_ERROR, "文件类型错误");
|
// 统一转成小写再比较
|
||||||
|
ThrowUtils.throwIf(
|
||||||
|
!ALLOW_FORMAT_LIST.contains(fileSuffix.toLowerCase()),
|
||||||
|
ErrorCode.PARAMS_ERROR, "文件类型错误");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
package edu.whut.smilepicturebackend.manager.upload;
|
package edu.whut.smilepicturebackend.manager.upload;
|
||||||
|
import cn.hutool.core.collection.CollUtil;
|
||||||
import cn.hutool.core.date.DateUtil;
|
import cn.hutool.core.date.DateUtil;
|
||||||
import cn.hutool.core.io.FileUtil;
|
import cn.hutool.core.io.FileUtil;
|
||||||
import cn.hutool.core.util.NumberUtil;
|
import cn.hutool.core.util.NumberUtil;
|
||||||
@ -6,6 +7,7 @@ import cn.hutool.core.util.RandomUtil;
|
|||||||
import com.qcloud.cos.model.PutObjectResult;
|
import com.qcloud.cos.model.PutObjectResult;
|
||||||
import com.qcloud.cos.model.ciModel.persistence.CIObject;
|
import com.qcloud.cos.model.ciModel.persistence.CIObject;
|
||||||
import com.qcloud.cos.model.ciModel.persistence.ImageInfo;
|
import com.qcloud.cos.model.ciModel.persistence.ImageInfo;
|
||||||
|
import com.qcloud.cos.model.ciModel.persistence.ProcessResults;
|
||||||
import edu.whut.smilepicturebackend.config.CosClientConfig;
|
import edu.whut.smilepicturebackend.config.CosClientConfig;
|
||||||
import edu.whut.smilepicturebackend.exception.BusinessException;
|
import edu.whut.smilepicturebackend.exception.BusinessException;
|
||||||
import edu.whut.smilepicturebackend.exception.ErrorCode;
|
import edu.whut.smilepicturebackend.exception.ErrorCode;
|
||||||
@ -16,6 +18,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 图片上传模板
|
* 图片上传模板
|
||||||
@ -42,9 +45,13 @@ public abstract class PictureUploadTemplate {
|
|||||||
// 2. 图片上传地址
|
// 2. 图片上传地址
|
||||||
String uuid = RandomUtil.randomString(16);
|
String uuid = RandomUtil.randomString(16);
|
||||||
String originalFilename = getOriginFilename(inputSource);
|
String originalFilename = getOriginFilename(inputSource);
|
||||||
|
// extName 直接取扩展名,不含点
|
||||||
|
String extension = FileUtil.extName(originalFilename); // "png" 或 "jpg"
|
||||||
// 自己拼接文件上传路径,而不是使用原始文件名称,可以增强安全性
|
// 自己拼接文件上传路径,而不是使用原始文件名称,可以增强安全性
|
||||||
String uploadFilename = String.format("%s_%s.%s", DateUtil.formatDate(new Date()), uuid,
|
String uploadFilename = String.format("%s_%s.%s",
|
||||||
FileUtil.getSuffix(originalFilename));
|
DateUtil.formatDate(new Date()),
|
||||||
|
uuid,
|
||||||
|
extension);
|
||||||
//如果多个项目共享存储桶,请在桶的根目录下以各项目名作为目录。
|
//如果多个项目共享存储桶,请在桶的根目录下以各项目名作为目录。
|
||||||
String projectName="smile-picture";
|
String projectName="smile-picture";
|
||||||
String uploadPath = String.format("/%s/%s/%s",projectName, uploadPathPrefix, uploadFilename);
|
String uploadPath = String.format("/%s/%s/%s",projectName, uploadPathPrefix, uploadFilename);
|
||||||
@ -58,6 +65,21 @@ public abstract class PictureUploadTemplate {
|
|||||||
PutObjectResult putObjectResult = cosManager.putPictureObject(uploadPath, file);
|
PutObjectResult putObjectResult = cosManager.putPictureObject(uploadPath, file);
|
||||||
// 5. 获取图片信息对象,封装返回结果
|
// 5. 获取图片信息对象,封装返回结果
|
||||||
ImageInfo imageInfo = putObjectResult.getCiUploadResult().getOriginalInfo().getImageInfo();
|
ImageInfo imageInfo = putObjectResult.getCiUploadResult().getOriginalInfo().getImageInfo();
|
||||||
|
// 获取到图片处理结果
|
||||||
|
ProcessResults processResults = putObjectResult.getCiUploadResult().getProcessResults();
|
||||||
|
List<CIObject> objectList = processResults.getObjectList();
|
||||||
|
if (CollUtil.isNotEmpty(objectList)) {
|
||||||
|
// 获取压缩之后得到的文件信息
|
||||||
|
CIObject compressedCiObject = objectList.get(0); //第一个是压缩后的
|
||||||
|
// 缩略图默认等于压缩图,压缩图是必有的
|
||||||
|
CIObject thumbnailCiObject = compressedCiObject;
|
||||||
|
// 有生成缩略图,才获取缩略图
|
||||||
|
if (objectList.size() > 1) { //第二个是缩略图
|
||||||
|
thumbnailCiObject = objectList.get(1);
|
||||||
|
}
|
||||||
|
// 封装压缩图的返回结果
|
||||||
|
return buildResult(originalFilename, compressedCiObject, thumbnailCiObject, imageInfo,uploadPath);
|
||||||
|
}
|
||||||
return buildResult(originalFilename, file, uploadPath, imageInfo);
|
return buildResult(originalFilename, file, uploadPath, imageInfo);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("图片上传到对象存储失败", e);
|
log.error("图片上传到对象存储失败", e);
|
||||||
@ -66,7 +88,6 @@ public abstract class PictureUploadTemplate {
|
|||||||
// 6. 临时文件清理
|
// 6. 临时文件清理
|
||||||
this.deleteTempFile(file);
|
this.deleteTempFile(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -85,6 +106,38 @@ public abstract class PictureUploadTemplate {
|
|||||||
protected abstract void processFile(Object inputSource, File file) throws Exception;
|
protected abstract void processFile(Object inputSource, File file) throws Exception;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 封装返回结果
|
||||||
|
*
|
||||||
|
* @param originalFilename 原始文件名
|
||||||
|
* @param compressedCiObject 压缩后的对象
|
||||||
|
* @param thumbnailCiObject 缩略图对象
|
||||||
|
* @param imageInfo 图片信息
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
|
private UploadPictureResult buildResult(String originalFilename, CIObject compressedCiObject, CIObject thumbnailCiObject,
|
||||||
|
ImageInfo imageInfo,String uploadPath) {
|
||||||
|
// 计算宽高
|
||||||
|
int picWidth = compressedCiObject.getWidth();
|
||||||
|
int picHeight = compressedCiObject.getHeight();
|
||||||
|
double picScale = NumberUtil.round(picWidth * 1.0 / picHeight, 2).doubleValue();
|
||||||
|
// 封装返回结果
|
||||||
|
UploadPictureResult uploadPictureResult = new UploadPictureResult();
|
||||||
|
// 设置压缩后的原图地址
|
||||||
|
uploadPictureResult.setUrl(cosClientConfig.getHost() + "/" + compressedCiObject.getKey());
|
||||||
|
uploadPictureResult.setName(FileUtil.mainName(originalFilename));
|
||||||
|
uploadPictureResult.setPicSize(compressedCiObject.getSize().longValue());
|
||||||
|
uploadPictureResult.setPicWidth(picWidth);
|
||||||
|
uploadPictureResult.setPicHeight(picHeight);
|
||||||
|
uploadPictureResult.setPicScale(picScale);
|
||||||
|
uploadPictureResult.setPicFormat(compressedCiObject.getFormat());
|
||||||
|
uploadPictureResult.setPicColor(imageInfo.getAve());
|
||||||
|
// 设置缩略图地址
|
||||||
|
uploadPictureResult.setThumbnailUrl(cosClientConfig.getHost() + "/" + thumbnailCiObject.getKey());
|
||||||
|
uploadPictureResult.setOriginalUrl(cosClientConfig.getHost() + "/" + uploadPath);
|
||||||
|
// 返回可访问的地址
|
||||||
|
return uploadPictureResult;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* 封装返回结果
|
* 封装返回结果
|
||||||
*
|
*
|
||||||
@ -94,6 +147,7 @@ public abstract class PictureUploadTemplate {
|
|||||||
* @param imageInfo 对象存储返回的图片信息
|
* @param imageInfo 对象存储返回的图片信息
|
||||||
* @return
|
* @return
|
||||||
*/
|
*/
|
||||||
|
|
||||||
private UploadPictureResult buildResult(String originalFilename, File file, String uploadPath, ImageInfo imageInfo) {
|
private UploadPictureResult buildResult(String originalFilename, File file, String uploadPath, ImageInfo imageInfo) {
|
||||||
// 计算宽高
|
// 计算宽高
|
||||||
int picWidth = imageInfo.getWidth();
|
int picWidth = imageInfo.getWidth();
|
||||||
|
@ -39,47 +39,72 @@ public class UrlPictureUpload extends PictureUploadTemplate {
|
|||||||
ThrowUtils.throwIf(!fileUrl.startsWith("http://") && !fileUrl.startsWith("https://"),
|
ThrowUtils.throwIf(!fileUrl.startsWith("http://") && !fileUrl.startsWith("https://"),
|
||||||
ErrorCode.PARAMS_ERROR, "仅支持 HTTP 或 HTTPS 协议的文件地址"
|
ErrorCode.PARAMS_ERROR, "仅支持 HTTP 或 HTTPS 协议的文件地址"
|
||||||
);
|
);
|
||||||
// 4. 发送 HEAD 请求验证文件是否存在
|
}
|
||||||
HttpResponse httpResponse = null;
|
|
||||||
try {
|
/**
|
||||||
httpResponse = HttpUtil.createRequest(Method.HEAD, fileUrl)
|
* 只发一次 HEAD,就做远程校验 + 提取扩展名
|
||||||
.execute();
|
* @return 不含点的扩展名,比如 "jpg","png",取不出时返回空串
|
||||||
// 未正常返回,无需执行其他判断
|
*/
|
||||||
if (httpResponse.getStatus() != HttpStatus.HTTP_OK) {
|
protected String fetchAndValidateExtension(String fileUrl) {
|
||||||
return;
|
try (HttpResponse resp = HttpUtil
|
||||||
|
.createRequest(Method.HEAD, fileUrl)
|
||||||
|
.execute()) {
|
||||||
|
if (resp.getStatus() != HttpStatus.HTTP_OK) {
|
||||||
|
throw new BusinessException(
|
||||||
|
ErrorCode.OPERATION_ERROR, "文件不存在或不可访问");
|
||||||
}
|
}
|
||||||
// 5. 文件存在,文件类型校验
|
|
||||||
String contentType = httpResponse.header("Content-Type");
|
// 1) Content-Type 验证 & 提取扩展名
|
||||||
// 不为空,才校验是否合法,这样校验规则相对宽松
|
String ct = resp.header("Content-Type");
|
||||||
if (StrUtil.isNotBlank(contentType)) {
|
String ext = "";
|
||||||
// 允许的图片类型
|
if (StrUtil.isNotBlank(ct) && ct.contains("/")) {
|
||||||
final List<String> ALLOW_CONTENT_TYPES = Arrays.asList("image/jpeg", "image/jpg", "image/png", "image/webp");
|
// e.g. "image/jpeg"
|
||||||
ThrowUtils.throwIf(!ALLOW_CONTENT_TYPES.contains(contentType.toLowerCase()),
|
String raw = StrUtil.subAfter(ct, "/", true).toLowerCase();
|
||||||
ErrorCode.PARAMS_ERROR, "文件类型错误");
|
ext = raw.equals("jpeg") ? "jpg" : raw;
|
||||||
|
// 验证白名单
|
||||||
|
List<String> allow = Arrays.asList(
|
||||||
|
"image/jpeg", "image/jpg", "image/png", "image/webp"
|
||||||
|
);
|
||||||
|
ThrowUtils.throwIf(
|
||||||
|
!allow.contains(ct.toLowerCase()),
|
||||||
|
ErrorCode.PARAMS_ERROR,
|
||||||
|
"不支持的图片类型:" + ct
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// 6. 文件存在,文件大小校验
|
|
||||||
String contentLengthStr = httpResponse.header("Content-Length");
|
// 2) Content-Length 验证
|
||||||
if (StrUtil.isNotBlank(contentLengthStr)) {
|
String len = resp.header("Content-Length");
|
||||||
try {
|
if (StrUtil.isNotBlank(len)) {
|
||||||
long contentLength = Long.parseLong(contentLengthStr);
|
long size = Long.parseLong(len);
|
||||||
final long ONE_M = 1024 * 1024;
|
long max = 2L * 1024 * 1024;
|
||||||
ThrowUtils.throwIf(contentLength > 2 * ONE_M, ErrorCode.PARAMS_ERROR, "文件大小不能超过 2MB");
|
ThrowUtils.throwIf(
|
||||||
} catch (NumberFormatException e) {
|
size > max,
|
||||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件大小格式异常");
|
ErrorCode.PARAMS_ERROR,
|
||||||
}
|
"文件大小不能超过 2MB"
|
||||||
}
|
);
|
||||||
} finally {
|
|
||||||
// 记得释放资源
|
|
||||||
if (httpResponse != null) {
|
|
||||||
httpResponse.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return ext;
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
throw new BusinessException(
|
||||||
|
ErrorCode.PARAMS_ERROR, "文件大小格式异常");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected String getOriginFilename(Object inputSource) {
|
protected String getOriginFilename(Object inputSource) {
|
||||||
String fileUrl = (String) inputSource;
|
String fileUrl = (String) inputSource;
|
||||||
return FileUtil.mainName(fileUrl);
|
// 1) HEAD 验证并拿扩展名(只这一处会发 HEAD)
|
||||||
|
String ext = fetchAndValidateExtension(fileUrl);
|
||||||
|
// 2) fallback:若服务器没返回类型,再从 URL 中简单截取
|
||||||
|
if (StrUtil.isBlank(ext)) {
|
||||||
|
ext = FileUtil.extName(fileUrl);
|
||||||
|
}
|
||||||
|
ThrowUtils.throwIf(ext==null,ErrorCode.PARAMS_ERROR,"不正确的图片格式");
|
||||||
|
// 3) 拿到 baseName,然后拼回去
|
||||||
|
String base = FileUtil.mainName(FileUtil.getName(fileUrl));
|
||||||
|
return StrUtil.isNotBlank(ext) ? base + "." + ext : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -19,11 +19,21 @@ public class Picture implements Serializable {
|
|||||||
@TableId(type = IdType.ASSIGN_ID)
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原图
|
||||||
|
*/
|
||||||
|
private String originalUrl;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 图片 url
|
* 图片 url
|
||||||
*/
|
*/
|
||||||
private String url;
|
private String url;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缩略图 url
|
||||||
|
*/
|
||||||
|
private String thumbnailUrl;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 图片名称
|
* 图片名称
|
||||||
*/
|
*/
|
||||||
|
@ -7,6 +7,10 @@ import lombok.Data;
|
|||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class UploadPictureResult {
|
public class UploadPictureResult {
|
||||||
|
/**
|
||||||
|
* 原图
|
||||||
|
*/
|
||||||
|
private String originalUrl;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 图片地址
|
* 图片地址
|
||||||
|
@ -18,11 +18,20 @@ public class PictureVO implements Serializable {
|
|||||||
*/
|
*/
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原图
|
||||||
|
*/
|
||||||
|
private String originalUrl;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 图片 url
|
* 图片 url
|
||||||
*/
|
*/
|
||||||
private String url;
|
private String url;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缩略图 url
|
||||||
|
*/
|
||||||
|
private String thumbnailUrl;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 图片名称
|
* 图片名称
|
||||||
|
@ -94,4 +94,12 @@ public interface PictureService extends IService<Picture> {
|
|||||||
*/
|
*/
|
||||||
Integer uploadPictureByBatch(PictureUploadByBatchRequest pictureUploadByBatchRequest,
|
Integer uploadPictureByBatch(PictureUploadByBatchRequest pictureUploadByBatchRequest,
|
||||||
User loginUser);
|
User loginUser);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存图片
|
||||||
|
* @param queryRequest
|
||||||
|
* @param httpRequest
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
|
Page<PictureVO> listPictureVOByPageWithCache(PictureQueryRequest queryRequest, HttpServletRequest httpRequest);
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,10 @@ package edu.whut.smilepicturebackend.service.impl;
|
|||||||
import cn.hutool.core.bean.BeanUtil;
|
import cn.hutool.core.bean.BeanUtil;
|
||||||
import cn.hutool.core.collection.CollUtil;
|
import cn.hutool.core.collection.CollUtil;
|
||||||
import cn.hutool.core.util.ObjUtil;
|
import cn.hutool.core.util.ObjUtil;
|
||||||
|
import cn.hutool.core.util.RandomUtil;
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import cn.hutool.json.JSON;
|
||||||
|
import cn.hutool.json.JSONObject;
|
||||||
import cn.hutool.json.JSONUtil;
|
import cn.hutool.json.JSONUtil;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
@ -13,6 +16,7 @@ import edu.whut.smilepicturebackend.exception.BusinessException;
|
|||||||
import edu.whut.smilepicturebackend.exception.ErrorCode;
|
import edu.whut.smilepicturebackend.exception.ErrorCode;
|
||||||
import edu.whut.smilepicturebackend.exception.ThrowUtils;
|
import edu.whut.smilepicturebackend.exception.ThrowUtils;
|
||||||
import edu.whut.smilepicturebackend.manager.FileManager;
|
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.FilePictureUpload;
|
||||||
import edu.whut.smilepicturebackend.manager.upload.PictureUploadTemplate;
|
import edu.whut.smilepicturebackend.manager.upload.PictureUploadTemplate;
|
||||||
import edu.whut.smilepicturebackend.manager.upload.UrlPictureUpload;
|
import edu.whut.smilepicturebackend.manager.upload.UrlPictureUpload;
|
||||||
@ -34,6 +38,7 @@ import org.jsoup.nodes.Element;
|
|||||||
import org.jsoup.select.Elements;
|
import org.jsoup.select.Elements;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.DigestUtils;
|
||||||
|
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
@ -58,7 +63,7 @@ public class PictureServiceImpl extends ServiceImpl<PictureMapper, Picture>
|
|||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final FilePictureUpload filePictureUpload;
|
private final FilePictureUpload filePictureUpload;
|
||||||
private final UrlPictureUpload urlPictureUpload;
|
private final UrlPictureUpload urlPictureUpload;
|
||||||
|
private final MyCacheManager cacheManager;
|
||||||
@Override
|
@Override
|
||||||
public void validPicture(Picture picture) {
|
public void validPicture(Picture picture) {
|
||||||
ThrowUtils.throwIf(picture == null, ErrorCode.PARAMS_ERROR);
|
ThrowUtils.throwIf(picture == null, ErrorCode.PARAMS_ERROR);
|
||||||
@ -99,7 +104,7 @@ public class PictureServiceImpl extends ServiceImpl<PictureMapper, Picture>
|
|||||||
String uploadPathPrefix;
|
String uploadPathPrefix;
|
||||||
//公共图库下,每个用户有自己的userid管理的文件夹。
|
//公共图库下,每个用户有自己的userid管理的文件夹。
|
||||||
uploadPathPrefix = String.format("public/%s", loginUser.getId());
|
uploadPathPrefix = String.format("public/%s", loginUser.getId());
|
||||||
// 根据 inputSource 的类型区分上传方式
|
// 根据 inputSource 的类型区分上传方式!!
|
||||||
PictureUploadTemplate pictureUploadTemplate = filePictureUpload;
|
PictureUploadTemplate pictureUploadTemplate = filePictureUpload;
|
||||||
if (inputSource instanceof String) {
|
if (inputSource instanceof String) {
|
||||||
pictureUploadTemplate = urlPictureUpload;
|
pictureUploadTemplate = urlPictureUpload;
|
||||||
@ -335,23 +340,27 @@ public class PictureServiceImpl extends ServiceImpl<PictureMapper, Picture>
|
|||||||
// 遍历元素,依次处理上传图片
|
// 遍历元素,依次处理上传图片
|
||||||
int uploadCount = 0;
|
int uploadCount = 0;
|
||||||
for (Element imgElement : imgElementList) {
|
for (Element imgElement : imgElementList) {
|
||||||
String fileUrl = imgElement.attr("src");
|
// 找到最近的 <a class="iusc">,它的 m 属性里有完整的 JSON
|
||||||
if (StrUtil.isBlank(fileUrl)) {
|
Element a = imgElement.closest("a.iusc");
|
||||||
//并不是所有图片链接都是正确的
|
if (a == null || a.attr("m").isEmpty()) {
|
||||||
log.info("当前链接为空,已跳过:{}", fileUrl);
|
log.info("没找到 m 属性,跳过该图片");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 处理图片的地址,防止转义或者和对象存储冲突的问题
|
// 用 Hutool 解析 JSON,取出 murl
|
||||||
// codefather.cn?yupi=dog,应该只保留 codefather.cn
|
JSONObject mObj = JSONUtil.parseObj(a.attr("m"));
|
||||||
int questionMarkIndex = fileUrl.indexOf("?");
|
String fileUrl = mObj.getStr("murl"); // murl是带 .jpg/.png 的原图 , src是缩略图,
|
||||||
if (questionMarkIndex > -1) {
|
|
||||||
fileUrl = fileUrl.substring(0, questionMarkIndex);
|
// 可选:去掉 URL 后面的 ? 及参数
|
||||||
|
int qm = fileUrl.indexOf("?");
|
||||||
|
if (qm > 0) {
|
||||||
|
fileUrl = fileUrl.substring(0, qm);
|
||||||
}
|
}
|
||||||
// 上传图片
|
// 上传图片
|
||||||
PictureUploadRequest pictureUploadRequest = new PictureUploadRequest();
|
PictureUploadRequest pictureUploadRequest = new PictureUploadRequest();
|
||||||
pictureUploadRequest.setFileUrl(fileUrl);
|
pictureUploadRequest.setFileUrl(fileUrl);
|
||||||
pictureUploadRequest.setPicName(namePrefix + (uploadCount + 1));
|
pictureUploadRequest.setPicName(namePrefix + (uploadCount + 1));
|
||||||
try {
|
try {
|
||||||
|
log.info("爬取图片url:"+fileUrl);
|
||||||
PictureVO pictureVO = this.uploadPicture(fileUrl, pictureUploadRequest, loginUser);
|
PictureVO pictureVO = this.uploadPicture(fileUrl, pictureUploadRequest, loginUser);
|
||||||
log.info("图片上传成功,id = {}", pictureVO.getId());
|
log.info("图片上传成功,id = {}", pictureVO.getId());
|
||||||
uploadCount++;
|
uploadCount++;
|
||||||
@ -365,6 +374,42 @@ public class PictureServiceImpl extends ServiceImpl<PictureMapper, Picture>
|
|||||||
}
|
}
|
||||||
return uploadCount;
|
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;
|
||||||
|
|
||||||
|
// 随机过期:300–600s
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
server:
|
server:
|
||||||
port: 8100
|
port: 8123
|
||||||
servlet:
|
servlet:
|
||||||
context-path: /api
|
context-path: /api
|
||||||
|
|
||||||
|
@ -6,7 +6,9 @@
|
|||||||
|
|
||||||
<resultMap id="BaseResultMap" type="edu.whut.smilepicturebackend.model.entity.Picture">
|
<resultMap id="BaseResultMap" type="edu.whut.smilepicturebackend.model.entity.Picture">
|
||||||
<id property="id" column="id" jdbcType="BIGINT"/>
|
<id property="id" column="id" jdbcType="BIGINT"/>
|
||||||
|
<result property="originalUrl" column="original_url" jdbcType="VARCHAR"/>
|
||||||
<result property="url" column="url" jdbcType="VARCHAR"/>
|
<result property="url" column="url" jdbcType="VARCHAR"/>
|
||||||
|
<result property="thumbnailUrl" column="thumbnail_url" jdbcType="VARCHAR"/>
|
||||||
<result property="name" column="name" jdbcType="VARCHAR"/>
|
<result property="name" column="name" jdbcType="VARCHAR"/>
|
||||||
<result property="introduction" column="introduction" jdbcType="VARCHAR"/>
|
<result property="introduction" column="introduction" jdbcType="VARCHAR"/>
|
||||||
<result property="category" column="category" jdbcType="VARCHAR"/>
|
<result property="category" column="category" jdbcType="VARCHAR"/>
|
||||||
@ -24,7 +26,7 @@
|
|||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<sql id="Base_Column_List">
|
<sql id="Base_Column_List">
|
||||||
id,url,name,
|
id,url,thumbnail_url,name,
|
||||||
introduction,category,tags,
|
introduction,category,tags,
|
||||||
pic_size,pic_width,pic_height,
|
pic_size,pic_width,pic_height,
|
||||||
pic_scale,pic_format,user_id,
|
pic_scale,pic_format,user_id,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user