邮件延迟发送功能优化
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>me.qwq</groupId>
|
||||
<artifactId>doghouse</artifactId>
|
||||
<version>1.3.0</version>
|
||||
<version>1.3.1</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>Doghouse</name>
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.context.annotation.EnableAspectJAutoProxy;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -15,6 +16,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@MapperScan("me.qwq.doghouse.dao")
|
||||
@SpringBootApplication
|
||||
@EnableAsync(proxyTargetClass=true)
|
||||
@EnableScheduling
|
||||
@EnableCaching(proxyTargetClass=true)
|
||||
@EnableAspectJAutoProxy(exposeProxy = true)
|
||||
@Slf4j
|
||||
|
||||
@@ -36,7 +36,6 @@ public class DataSourcePostProcessor implements BeanPostProcessor {
|
||||
* <p>
|
||||
* application 指定的 jdbc url 位置存在则使用对应位置,且后续一直以该路径为准(包括调试时修改的数据)。
|
||||
* 若不存在则从 classpath 中取初始数据库复制到 jdbc url 指定的位置
|
||||
* @param dataSource
|
||||
*/
|
||||
@Override
|
||||
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
package me.qwq.doghouse.component;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.redisson.Redisson;
|
||||
import org.redisson.api.RBucket;
|
||||
import org.redisson.api.RedissonClient;
|
||||
import org.redisson.client.codec.StringCodec;
|
||||
import org.redisson.client.protocol.RedisCommands;
|
||||
import org.redisson.command.CommandAsyncExecutor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.sqids.Sqids;
|
||||
|
||||
import me.qwq.doghouse.entity.Comment;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import me.qwq.doghouse.entity.config.SiteConfig;
|
||||
import me.qwq.doghouse.util.Cryptos;
|
||||
|
||||
/**
|
||||
* Redis Key Tool
|
||||
* Redis key tool.
|
||||
* @author Doghole
|
||||
*
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
@@ -34,115 +26,13 @@ public class Rk {
|
||||
|
||||
static RedissonClient redisson;
|
||||
|
||||
|
||||
public static String SYSTEM_PREFIX;
|
||||
|
||||
static final String NOTIFY_KEYSPACE_EVENTS = "notify-keyspace-events",
|
||||
x = "x", E = "E";
|
||||
|
||||
|
||||
@Autowired
|
||||
public void setService(SiteConfig siteConfig, RedissonClient redisson) {
|
||||
Rk.siteConfig = siteConfig;
|
||||
Rk.redisson = redisson;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
private void init() {
|
||||
|
||||
SYSTEM_PREFIX = "doghouse:" + siteConfig.getAddress().hashCode() + ":";
|
||||
|
||||
// 使用 Redisson 设置 redis 服务端的 x(expired) 和 E(evicted) 事件
|
||||
Redisson redissonImpl = (Redisson) redisson;
|
||||
CommandAsyncExecutor executor = redissonImpl.getCommandExecutor();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, String> config = (Map<String, String>) executor
|
||||
.readAsync("default", StringCodec.INSTANCE, RedisCommands.CONFIG_GET_MAP, NOTIFY_KEYSPACE_EVENTS)
|
||||
.toCompletableFuture().join();
|
||||
|
||||
String keyEvents = config.getOrDefault(NOTIFY_KEYSPACE_EVENTS, "");
|
||||
boolean changed = false;
|
||||
if (!keyEvents.contains(x)) {
|
||||
keyEvents += x;
|
||||
changed = true;
|
||||
}
|
||||
if (!keyEvents.contains(E)) {
|
||||
keyEvents += E;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
log.debug("Redis setConfig {} value {}", NOTIFY_KEYSPACE_EVENTS, keyEvents);
|
||||
executor
|
||||
.writeAsync("default", StringCodec.INSTANCE, RedisCommands.CONFIG_SET, NOTIFY_KEYSPACE_EVENTS, keyEvents)
|
||||
.toCompletableFuture()
|
||||
.join();
|
||||
}
|
||||
}
|
||||
|
||||
public static class Mail {
|
||||
|
||||
public static final String ID = "mail:";
|
||||
|
||||
private static String getMailKey(String subfix) {
|
||||
return SYSTEM_PREFIX + ID + subfix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有过期事件的 key
|
||||
* @return
|
||||
*/
|
||||
public static Iterable<String> getAllExpirationTaskKeys() {
|
||||
return redisson.getKeys()
|
||||
.getKeysByPattern(getMailKey("task-"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取邮件推送任务的 Bucket(不包括管理员邮件)
|
||||
* @param commentId
|
||||
* @return
|
||||
*/
|
||||
public static RBucket<Object> getTaskBucket(Long commentId) {
|
||||
return redisson.getBucket(getMailKey("task-" + commentId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取邮件推送任务的 Comment 缓存 Bucket(不包括管理员邮件)
|
||||
* @param commentId
|
||||
* @return
|
||||
*/
|
||||
public static RBucket<Comment> getTaskCommentBucket(Long commentId) {
|
||||
return redisson.getBucket(getMailKey("task-" + commentId + "-comment"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户邮件订阅管理 UUID 缓存 Bucket
|
||||
* @param mail
|
||||
* @return 包含 uuid 的 Bucket,用以建立 email → uuid 的映射关系
|
||||
*/
|
||||
public static RBucket<String> getMgrBucket(String mail) {
|
||||
return redisson.getBucket(getMailKey("mgr-addr-" + mail));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户邮件订阅管理邮箱地址缓存 Bucket
|
||||
* @param uuid
|
||||
* @return 包含 email 的 Bucket,用以建立 uuid → email 的映射关系
|
||||
*/
|
||||
public static RBucket<String> getMgrUuidBucket(String uuid) {
|
||||
return redisson.getBucket(getMailKey("mgr-uuid-" + uuid));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取一个随机 Key 的用于过期事件的 Bucket
|
||||
*/
|
||||
public static RBucket<Object> getRandomExpirationBucket(){
|
||||
List<Long> list = new ArrayList<>();
|
||||
SecureRandom random = new SecureRandom();
|
||||
list.add(Math.abs(random.nextLong()));
|
||||
list.add(Math.abs(random.nextLong()));
|
||||
return redisson.getBucket(getMailKey(Sqids.builder().build().encode(list)));
|
||||
}
|
||||
}
|
||||
|
||||
public static class Gravatar {
|
||||
@@ -161,32 +51,20 @@ public class Rk {
|
||||
return SYSTEM_PREFIX + ID + subfix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对应 hash 和尺寸 size 的头像缓存 Bucket
|
||||
* @param email
|
||||
* @param size
|
||||
* @return
|
||||
*/
|
||||
public static RBucket<Object> getGravatarBucket(String dogHash, int size) {
|
||||
return redisson.getBucket(getGravatarKey(GRAVATAR_PREFIX + dogHash + ":" + size));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 gravatar 图片缓存
|
||||
*/
|
||||
public static void clearGravatarCache() {
|
||||
for (String name : redisson.getKeys().getKeysByPattern(getGravatarKey(GRAVATAR_PREFIX + "*"))) {
|
||||
redisson.getBucket(name).deleteAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对应 email 的 hash,并且提前在 Redis 中存储对应关系
|
||||
* @param email
|
||||
* @return
|
||||
*/
|
||||
public static String prepareDogHash(String email) {
|
||||
if (email == null) email = "";
|
||||
if (email == null) {
|
||||
email = "";
|
||||
}
|
||||
email = email.toLowerCase(Locale.ENGLISH);
|
||||
|
||||
RBucket<String> gravatarHashBucket = redisson.getBucket(getGravatarKey(EMAIL_HASH_PREFIX + email));
|
||||
@@ -228,11 +106,6 @@ public class Rk {
|
||||
return dogHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 hash 获取 gravatar md5
|
||||
* @param dogHash
|
||||
* @return
|
||||
*/
|
||||
public static String getRealHash(String dogHash) {
|
||||
RBucket<String> email = redisson.getBucket(getGravatarKey(PRIVATE_HASH_PREFIX + dogHash));
|
||||
return email.get();
|
||||
|
||||
@@ -4,13 +4,12 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.github.yulichang.wrapper.MPJLambdaWrapper;
|
||||
|
||||
import jodd.io.FileUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import me.qwq.doghouse.config.DoghouseProperties;
|
||||
import me.qwq.doghouse.controller.common.BaseController;
|
||||
import me.qwq.doghouse.entity.Asset;
|
||||
import me.qwq.doghouse.entity.config.SiteConfig;
|
||||
import me.qwq.doghouse.entity.Post;
|
||||
import me.qwq.doghouse.entity.config.SiteConfig;
|
||||
import me.qwq.doghouse.enums.FileTypeEnum;
|
||||
import me.qwq.doghouse.exception.RException;
|
||||
import me.qwq.doghouse.pojo.dto.LayPageReq;
|
||||
@@ -22,17 +21,21 @@ import me.qwq.doghouse.util.DateUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.sqids.Sqids;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
@@ -43,9 +46,8 @@ import java.util.concurrent.TimeUnit;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
|
||||
/**
|
||||
* 资产控制器
|
||||
* Asset controller.
|
||||
* @author Doghole
|
||||
*/
|
||||
@Controller
|
||||
@@ -53,9 +55,11 @@ import javax.imageio.ImageIO;
|
||||
@Slf4j
|
||||
public class AssetController extends BaseController {
|
||||
|
||||
final private static SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
private static final int IMAGE_ENCODE_TIMEOUT_SECONDS = 15;
|
||||
|
||||
final private static Sqids SQIDS = Sqids.builder().build();
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
private static final Sqids SQIDS = Sqids.builder().build();
|
||||
|
||||
@Autowired
|
||||
DoghouseProperties doghouseProperties;
|
||||
@@ -67,7 +71,7 @@ public class AssetController extends BaseController {
|
||||
SiteConfig siteConfig;
|
||||
|
||||
/**
|
||||
* 主页
|
||||
* Main page.
|
||||
* @return
|
||||
*/
|
||||
@GetMapping({"", "/", "/index"})
|
||||
@@ -90,9 +94,8 @@ public class AssetController extends BaseController {
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* Upload file.
|
||||
*/
|
||||
@ResponseBody
|
||||
@PostMapping("/uploadFile")
|
||||
@@ -100,117 +103,79 @@ public class AssetController extends BaseController {
|
||||
@RequestParam(name = "file") MultipartFile file,
|
||||
@RequestParam(name = "postId", required = false) Long postId) {
|
||||
|
||||
String msg = "文件上传失败";
|
||||
String suffix = getSuffix(file);
|
||||
if (file == null || file.isEmpty()) {
|
||||
return R.badRequest("上传文件不能为空");
|
||||
}
|
||||
|
||||
if (suffix == null) {
|
||||
return R.badRequest(msg);
|
||||
String suffix = getSuffix(file);
|
||||
if (StringUtils.isBlank(suffix)) {
|
||||
return R.badRequest("无法识别文件扩展名");
|
||||
}
|
||||
if (FileTypeEnum.isUploadDenied(suffix)) {
|
||||
return R.badRequest("不允许上传该类型文件");
|
||||
}
|
||||
|
||||
String prefix = generateFilePrefix();
|
||||
String filename = prefix + '.' + suffix;
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String originalPrefix = originalFilename.substring(0, originalFilename.length() -
|
||||
suffix.length() - 1);
|
||||
|
||||
String originalFilename = StringUtils.defaultIfBlank(file.getOriginalFilename(), filename);
|
||||
String originalPrefix = getOriginalPrefix(originalFilename, suffix);
|
||||
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
String yearAndMonth = String.format("%d/%02d", calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH) + 1);
|
||||
Path filePlacedDirPath = Paths.get(doghouseProperties.getFileUploadPath(), yearAndMonth);
|
||||
Path uploadRoot = Paths.get(doghouseProperties.getFileUploadPath()).toAbsolutePath().normalize();
|
||||
Path filePlacedDirPath = uploadRoot.resolve(yearAndMonth).normalize();
|
||||
Path storedPath = null;
|
||||
Path destPath = filePlacedDirPath.resolve(filename).normalize();
|
||||
|
||||
File dir = filePlacedDirPath.toFile();
|
||||
File destFile = filePlacedDirPath.resolve(filename).toFile();
|
||||
Asset asset = new Asset();
|
||||
try {
|
||||
if (!dir.exists() && !dir.mkdirs()) {
|
||||
msg = "创建文件夹 " + dir + " 失败";
|
||||
log.error(msg);
|
||||
return R.internalServerError(msg);
|
||||
if (!filePlacedDirPath.startsWith(uploadRoot) || !destPath.startsWith(filePlacedDirPath)) {
|
||||
throw RException.badRequest("非法上传路径");
|
||||
}
|
||||
|
||||
// file.transferTo(destFile); // 解决 jetty 上传文件问题
|
||||
FileUtil.writeBytes(destFile, file.getBytes());
|
||||
String fileUrl =
|
||||
Paths.get(doghouseProperties.getFileSqlPath(), yearAndMonth, filename)
|
||||
.toString().replace('\\', '/');
|
||||
Files.createDirectories(filePlacedDirPath);
|
||||
try (var input = file.getInputStream()) {
|
||||
Files.copy(input, destPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
storedPath = destPath;
|
||||
|
||||
String fileUrl = getSqlPath(yearAndMonth, filename);
|
||||
FileTypeEnum fileType = FileTypeEnum.getFileType(suffix);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
asset
|
||||
.setFromPostId(postId)
|
||||
.setTitle(file.getOriginalFilename())
|
||||
.setCreateTime(LocalDateTime.now())
|
||||
.setFileType(fileType)
|
||||
.setFileSize(destFile.length())
|
||||
.setUpdateTime(LocalDateTime.now())
|
||||
.setPath(fileUrl);
|
||||
Asset asset = new Asset()
|
||||
.setFromPostId(postId)
|
||||
.setTitle(originalFilename)
|
||||
.setCreateTime(now)
|
||||
.setFileType(fileType)
|
||||
.setFileSize(Files.size(storedPath))
|
||||
.setUpdateTime(now)
|
||||
.setPath(fileUrl);
|
||||
|
||||
if (fileType == FileTypeEnum.IMAGE) {
|
||||
BufferedImage image = ImageIO.read(destFile);
|
||||
if (image != null) {
|
||||
asset.setImageHeight(image.getHeight())
|
||||
.setImageWidth(image.getWidth());
|
||||
|
||||
if (siteConfig.getImageCompress()) {
|
||||
|
||||
File avifFile = getAvif(suffix, destFile.toString(), prefix, filePlacedDirPath);
|
||||
File webpFile = getWebp(suffix, destFile.toString(), prefix, filePlacedDirPath);
|
||||
|
||||
File finalFile = null;
|
||||
String finalSuffix = null;
|
||||
|
||||
if (webpFile != null && avifFile != null) {
|
||||
if (webpFile.length() >= avifFile.length()) {
|
||||
finalFile = webpFile;
|
||||
finalSuffix = ".webp";
|
||||
avifFile.delete();
|
||||
}
|
||||
else {
|
||||
finalFile = avifFile;
|
||||
finalSuffix = ".avif";
|
||||
webpFile.delete();
|
||||
}
|
||||
}
|
||||
else if (webpFile == null && avifFile != null) {
|
||||
finalFile = avifFile;
|
||||
finalSuffix = ".avif";
|
||||
}
|
||||
else if (webpFile != null && avifFile == null) {
|
||||
finalFile = webpFile;
|
||||
finalSuffix = ".webp";
|
||||
}
|
||||
|
||||
if (finalFile != null) {
|
||||
|
||||
fileUrl = Paths.get(doghouseProperties.getFileSqlPath(), yearAndMonth, finalFile.getName())
|
||||
.toString().replace('\\', '/');
|
||||
|
||||
asset
|
||||
.setPath(fileUrl)
|
||||
.setFileSize(finalFile.length())
|
||||
.setTitle(originalPrefix + finalSuffix);
|
||||
|
||||
destFile.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
log.warn("Unsupported image format {}", suffix);
|
||||
}
|
||||
storedPath = prepareImageAsset(asset, suffix, storedPath, prefix, originalPrefix, yearAndMonth);
|
||||
}
|
||||
|
||||
if (assetService.save(asset)) {
|
||||
return R.ok(asset);
|
||||
}
|
||||
deleteQuietly(storedPath);
|
||||
throw RException.badRequest("文件上传成功但保存失败");
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error(msg, e);
|
||||
}
|
||||
return R.internalServerError(msg);
|
||||
catch (IOException e) {
|
||||
deleteQuietly(storedPath);
|
||||
deleteQuietly(destPath);
|
||||
log.error("文件上传失败", e);
|
||||
return R.internalServerError("文件上传失败");
|
||||
}
|
||||
catch (RuntimeException e) {
|
||||
deleteQuietly(storedPath);
|
||||
deleteQuietly(destPath);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件基本信息
|
||||
* Update asset base info.
|
||||
* @param asset
|
||||
* @return
|
||||
*/
|
||||
@@ -218,17 +183,17 @@ public class AssetController extends BaseController {
|
||||
@PostMapping("/update")
|
||||
R<?> update(Asset asset) {
|
||||
if (Objects.isNull(asset) || Objects.isNull(asset.getAssetId())) {
|
||||
throw RException.badRequest("提供的资产不能为空!");
|
||||
throw RException.badRequest("提供的资源不能为空!");
|
||||
}
|
||||
LambdaUpdateWrapper<Asset> uw = new LambdaUpdateWrapper<>();
|
||||
uw.eq(Asset::getAssetId, asset.getAssetId())
|
||||
.set(Asset::getTitle, asset.getTitle())
|
||||
.set(Asset::getDescription, asset.getDescription());
|
||||
return R.judge(assetService.update(uw), "更新资产信息失败!");
|
||||
return R.judge(assetService.update(uw), "更新资源信息失败!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除资产
|
||||
* Delete asset.
|
||||
* @param assetId
|
||||
* @return
|
||||
*/
|
||||
@@ -236,24 +201,29 @@ public class AssetController extends BaseController {
|
||||
@PostMapping("/delete")
|
||||
R<?> delete(Long assetId) {
|
||||
if (Objects.isNull(assetId)) {
|
||||
throw RException.badRequest("提供的资产不能为空!");
|
||||
throw RException.badRequest("提供的资源不能为空!");
|
||||
}
|
||||
Asset asset = assetService.getById(assetId);
|
||||
if (Objects.isNull(asset)) {
|
||||
log.info("试图删除已不存在于数据库内的资产");
|
||||
log.info("试图删除已不存在于数据库内的资源");
|
||||
return R.ok();
|
||||
}
|
||||
String uploadPath = doghouseProperties.getFileUploadPath(asset);
|
||||
File file = new File(uploadPath);
|
||||
if (file.exists()) {
|
||||
return R.judge(file.delete() && assetService.removeById(assetId), "删除文件失败");
|
||||
|
||||
Path file = resolveAssetUploadPath(asset);
|
||||
if (Files.exists(file)) {
|
||||
try {
|
||||
Files.delete(file);
|
||||
}
|
||||
catch (IOException e) {
|
||||
log.warn("删除文件失败 {}", file, e);
|
||||
throw RException.badRequest("删除文件失败");
|
||||
}
|
||||
}
|
||||
return R.judge(assetService.removeById(assetId), "删除文件失败");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 解绑资产
|
||||
* Unlink asset.
|
||||
* @param assetId
|
||||
* @return
|
||||
*/
|
||||
@@ -261,15 +231,15 @@ public class AssetController extends BaseController {
|
||||
@PostMapping("/unlink")
|
||||
R<?> unlink(Long assetId) {
|
||||
if (Objects.isNull(assetId)) {
|
||||
throw RException.badRequest("提供的资产不能为空!");
|
||||
throw RException.badRequest("提供的资源不能为空!");
|
||||
}
|
||||
Asset asset = assetService.getById(assetId);
|
||||
if (Objects.isNull(asset)) {
|
||||
log.info("试图操作已不存在于数据库内的资产");
|
||||
log.info("试图操作已不存在于数据库内的资源");
|
||||
return R.ok();
|
||||
}
|
||||
if (Objects.isNull(asset.getFromPostId())) {
|
||||
return R.ok("该资产并未绑定 Post");
|
||||
return R.ok("该资源并未绑定 Post");
|
||||
}
|
||||
|
||||
return R.judge(
|
||||
@@ -279,26 +249,25 @@ public class AssetController extends BaseController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件后缀,不包含".", 返回小写
|
||||
* Get lowercase filename suffix, without ".".
|
||||
*/
|
||||
public static String getSuffix(MultipartFile file){
|
||||
public static String getSuffix(MultipartFile file) {
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
String fileName = file.getOriginalFilename();
|
||||
if (StringUtils.isEmpty(fileName)) {
|
||||
if (StringUtils.isBlank(fileName)) {
|
||||
return null;
|
||||
}
|
||||
int lastIndexOfDot = fileName.lastIndexOf('.');
|
||||
if (lastIndexOfDot == -1 || lastIndexOfDot == fileName.length() - 1) {
|
||||
return "";
|
||||
}
|
||||
return fileName.substring(lastIndexOfDot + 1).toLowerCase();
|
||||
return FileTypeEnum.normalizeExtension(fileName.substring(lastIndexOfDot + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用随机 Long + Sqids 作为文件名
|
||||
* @param file
|
||||
* Use timestamp + random Sqids as filename prefix.
|
||||
* @return
|
||||
*/
|
||||
public static String generateFilePrefix() {
|
||||
@@ -315,9 +284,93 @@ public class AssetController extends BaseController {
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static File getAvif(String suffix,
|
||||
String originalFilePath, String finalPrefix, Path filePlacedDirPath) {
|
||||
private Path prepareImageAsset(
|
||||
Asset asset,
|
||||
String suffix,
|
||||
Path originalFile,
|
||||
String finalPrefix,
|
||||
String originalPrefix,
|
||||
String yearAndMonth) throws IOException {
|
||||
|
||||
BufferedImage image = readRasterImage(suffix, originalFile);
|
||||
if (image != null) {
|
||||
asset.setImageHeight(image.getHeight())
|
||||
.setImageWidth(image.getWidth());
|
||||
}
|
||||
|
||||
if (!Boolean.TRUE.equals(siteConfig.getImageCompress()) || !FileTypeEnum.isImageCompressible(suffix)) {
|
||||
return originalFile;
|
||||
}
|
||||
|
||||
File avifFile = getAvif(suffix, originalFile.toString(), finalPrefix, originalFile.getParent());
|
||||
File webpFile = getWebp(suffix, originalFile.toString(), finalPrefix, originalFile.getParent());
|
||||
File finalFile = chooseCompressedFile(originalFile.toFile(), avifFile, webpFile);
|
||||
|
||||
if (finalFile == null) {
|
||||
return originalFile;
|
||||
}
|
||||
|
||||
String finalSuffix = getFileSuffix(finalFile.getName());
|
||||
asset
|
||||
.setPath(getSqlPath(yearAndMonth, finalFile.getName()))
|
||||
.setFileSize(finalFile.length())
|
||||
.setTitle(originalPrefix + '.' + finalSuffix);
|
||||
|
||||
deleteQuietly(originalFile);
|
||||
return finalFile.toPath();
|
||||
}
|
||||
|
||||
private static BufferedImage readRasterImage(String suffix, Path file) throws IOException {
|
||||
if (!mustBeReadableRaster(suffix)) {
|
||||
return null;
|
||||
}
|
||||
BufferedImage image = ImageIO.read(file.toFile());
|
||||
if (image == null) {
|
||||
throw RException.badRequest("不支持或损坏的图片文件");
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
private static boolean mustBeReadableRaster(String suffix) {
|
||||
suffix = FileTypeEnum.normalizeExtension(suffix);
|
||||
return "jpg".equals(suffix)
|
||||
|| "jpeg".equals(suffix)
|
||||
|| "png".equals(suffix)
|
||||
|| "gif".equals(suffix)
|
||||
|| "bmp".equals(suffix)
|
||||
|| "webp".equals(suffix);
|
||||
}
|
||||
|
||||
private static File chooseCompressedFile(File originalFile, File avifFile, File webpFile) {
|
||||
File finalFile = null;
|
||||
if (isSmallerFile(avifFile, originalFile)) {
|
||||
finalFile = avifFile;
|
||||
}
|
||||
if (isSmallerFile(webpFile, finalFile == null ? originalFile : finalFile)) {
|
||||
deleteQuietly(finalFile);
|
||||
finalFile = webpFile;
|
||||
}
|
||||
else {
|
||||
deleteQuietly(webpFile);
|
||||
}
|
||||
|
||||
if (finalFile != avifFile) {
|
||||
deleteQuietly(avifFile);
|
||||
}
|
||||
return finalFile;
|
||||
}
|
||||
|
||||
private static boolean isSmallerFile(File candidate, File base) {
|
||||
return candidate != null
|
||||
&& candidate.exists()
|
||||
&& candidate.isFile()
|
||||
&& base != null
|
||||
&& candidate.length() > 0
|
||||
&& candidate.length() < base.length();
|
||||
}
|
||||
|
||||
private static File getAvif(String suffix, String originalFilePath, String finalPrefix, Path filePlacedDirPath) {
|
||||
suffix = FileTypeEnum.normalizeExtension(suffix);
|
||||
if (!"jpg".equals(suffix) && !"jpeg".equals(suffix) && !"png".equals(suffix)) {
|
||||
return null;
|
||||
}
|
||||
@@ -336,12 +389,9 @@ public class AssetController extends BaseController {
|
||||
return executeFile(avifCommand, avifPath);
|
||||
}
|
||||
|
||||
|
||||
private static File getWebp(String suffix,
|
||||
String originalFilePath, String finalPrefix, Path filePlacedDirPath) {
|
||||
|
||||
|
||||
if (!"jpg".equals(suffix) && !"jpeg".equals(suffix) && !"png".equals(suffix) && !"gif".equals(suffix)) {
|
||||
private static File getWebp(String suffix, String originalFilePath, String finalPrefix, Path filePlacedDirPath) {
|
||||
suffix = FileTypeEnum.normalizeExtension(suffix);
|
||||
if (!FileTypeEnum.isImageCompressible(suffix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -367,22 +417,20 @@ public class AssetController extends BaseController {
|
||||
|
||||
private static File executeFile(List<String> command, String filePath) {
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.redirectErrorStream(true);
|
||||
pb.redirectOutput(ProcessBuilder.Redirect.DISCARD);
|
||||
pb.redirectError(ProcessBuilder.Redirect.DISCARD);
|
||||
try {
|
||||
Process process = pb.start();
|
||||
|
||||
try (var reader = new BufferedReader(
|
||||
new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
log.debug("[img-encode] {}", line);
|
||||
}
|
||||
boolean finished = process.waitFor(IMAGE_ENCODE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!finished) {
|
||||
process.destroyForcibly();
|
||||
log.warn("Execution timed out, command {}", command);
|
||||
return null;
|
||||
}
|
||||
|
||||
int exitCode = process.waitFor(15, TimeUnit.SECONDS) ? process.exitValue() : -1;
|
||||
|
||||
int exitCode = process.exitValue();
|
||||
if (exitCode == 0) {
|
||||
File out = FileUtil.file(filePath);
|
||||
File out = new File(filePath);
|
||||
if (!out.exists()) {
|
||||
log.warn("Command finished with code 0, but output file not found: {}", filePath);
|
||||
return null;
|
||||
@@ -392,9 +440,64 @@ public class AssetController extends BaseController {
|
||||
log.warn("Execution failed with exit code: {}, command {}", exitCode, command);
|
||||
return null;
|
||||
}
|
||||
catch (IOException | InterruptedException e) {
|
||||
log.warn("Cannot execute command {}, {}", command, e);
|
||||
catch (IOException e) {
|
||||
log.warn("Cannot execute command {}, {}", command, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.warn("Execution interrupted, command {}", command);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String getSqlPath(String yearAndMonth, String filename) {
|
||||
return Paths.get(doghouseProperties.getFileSqlPath(), yearAndMonth, filename)
|
||||
.toString()
|
||||
.replace('\\', '/');
|
||||
}
|
||||
|
||||
private Path resolveAssetUploadPath(Asset asset) {
|
||||
Path uploadRoot = Paths.get(doghouseProperties.getFileUploadPath()).toAbsolutePath().normalize();
|
||||
Path file = Paths.get(doghouseProperties.getFileUploadPath(asset)).toAbsolutePath().normalize();
|
||||
if (!file.startsWith(uploadRoot)) {
|
||||
throw RException.badRequest("非法资源路径");
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private static String getOriginalPrefix(String originalFilename, String suffix) {
|
||||
int suffixLength = StringUtils.length(suffix);
|
||||
int dotIndex = originalFilename.length() - suffixLength - 1;
|
||||
if (dotIndex <= 0 || dotIndex >= originalFilename.length()) {
|
||||
return originalFilename;
|
||||
}
|
||||
return originalFilename.substring(0, dotIndex);
|
||||
}
|
||||
|
||||
private static String getFileSuffix(String filename) {
|
||||
int lastIndexOfDot = filename.lastIndexOf('.');
|
||||
if (lastIndexOfDot == -1 || lastIndexOfDot == filename.length() - 1) {
|
||||
return "";
|
||||
}
|
||||
return filename.substring(lastIndexOfDot + 1);
|
||||
}
|
||||
|
||||
private static void deleteQuietly(Path file) {
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(file);
|
||||
}
|
||||
catch (IOException e) {
|
||||
log.warn("Cannot delete file {}", file, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteQuietly(File file) {
|
||||
if (file != null) {
|
||||
deleteQuietly(file.toPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
package me.qwq.doghouse.controller.blog;
|
||||
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
|
||||
import me.qwq.doghouse.component.Rk;
|
||||
import me.qwq.doghouse.controller.common.BaseController;
|
||||
import me.qwq.doghouse.entity.*;
|
||||
import me.qwq.doghouse.entity.config.MailConfig;
|
||||
import me.qwq.doghouse.service.*;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.redisson.api.RBucket;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import java.util.Locale;
|
||||
|
||||
|
||||
/**
|
||||
@@ -25,7 +18,7 @@ import java.util.Locale;
|
||||
@RequestMapping("/system")
|
||||
public class VisitorController extends BaseController {
|
||||
|
||||
static final String TOKEN_ERROR = "token 不存在或已过期";
|
||||
static final String TOKEN_ERROR = "token 不存在或已被使用";
|
||||
|
||||
static final String MAIL_DISABLED = "站点未开启邮件功能";
|
||||
|
||||
@@ -53,8 +46,7 @@ public class VisitorController extends BaseController {
|
||||
return render(TOKEN_ERROR, null);
|
||||
}
|
||||
|
||||
RBucket<String> uuidBucket = Rk.Mail.getMgrUuidBucket(token);
|
||||
String mail = uuidBucket.get();
|
||||
String mail = commentService.getUnsubscribeEmail(token);
|
||||
if (StringUtils.isEmpty(mail)) {
|
||||
return render(TOKEN_ERROR, null);
|
||||
}
|
||||
@@ -64,11 +56,9 @@ public class VisitorController extends BaseController {
|
||||
}
|
||||
|
||||
if ("POST".equals(method)) {
|
||||
uuidBucket.delete();
|
||||
commentService.update(new LambdaUpdateWrapper<Comment>()
|
||||
.eq(Comment::getEmail, mail.toLowerCase(Locale.ROOT))
|
||||
.set(Comment::isSubscribeReply, false));
|
||||
|
||||
if (commentService.unsubscribeByToken(token) == null) {
|
||||
return render(TOKEN_ERROR, null);
|
||||
}
|
||||
return render("操作成功", null);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package me.qwq.doghouse.dao;
|
||||
|
||||
import com.github.yulichang.base.MPJBaseMapper;
|
||||
|
||||
import me.qwq.doghouse.entity.MailTask;
|
||||
|
||||
public interface MailTaskMapper extends MPJBaseMapper<MailTask> {
|
||||
}
|
||||
@@ -169,6 +169,12 @@ private static final long serialVersionUID=1L;
|
||||
@TableField("subscribe_reply")
|
||||
private boolean subscribeReply = false;
|
||||
|
||||
/**
|
||||
* 邮件退订令牌
|
||||
*/
|
||||
@TableField("unsubscribe_token")
|
||||
private String unsubscribeToken;
|
||||
|
||||
@TableField("mail_sent")
|
||||
private boolean mailSent = false;
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package me.qwq.doghouse.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.extension.activerecord.Model;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
import me.qwq.doghouse.enums.MailTaskStatusEnum;
|
||||
import me.qwq.doghouse.enums.MailTaskTypeEnum;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@TableName("mail_task")
|
||||
public class MailTask extends Model<MailTask> implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 邮件任务 ID
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 邮件任务类型
|
||||
*/
|
||||
@TableField("task_type")
|
||||
private MailTaskTypeEnum taskType;
|
||||
|
||||
/**
|
||||
* 关联评论 ID
|
||||
*/
|
||||
@TableField("comment_id")
|
||||
private Long commentId;
|
||||
|
||||
/**
|
||||
* 收件人邮箱地址
|
||||
*/
|
||||
@TableField("recipient")
|
||||
private String recipient;
|
||||
|
||||
/**
|
||||
* 邮件任务状态
|
||||
*/
|
||||
@TableField("status")
|
||||
private MailTaskStatusEnum status = MailTaskStatusEnum.PENDING;
|
||||
|
||||
/**
|
||||
* 计划发送时间
|
||||
*/
|
||||
@TableField("send_after")
|
||||
private LocalDateTime sendAfter;
|
||||
|
||||
/**
|
||||
* 已尝试发送次数
|
||||
*/
|
||||
@TableField("attempts")
|
||||
private Integer attempts = 0;
|
||||
|
||||
/**
|
||||
* 最近一次发送失败原因
|
||||
*/
|
||||
@TableField("last_error")
|
||||
private String lastError;
|
||||
|
||||
/**
|
||||
* 任务创建时间
|
||||
*/
|
||||
@TableField("create_time")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 任务更新时间
|
||||
*/
|
||||
@TableField("update_time")
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package me.qwq.doghouse.enums;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
@@ -10,7 +11,7 @@ import me.qwq.doghouse.annotation.StaticAttribute;
|
||||
import me.qwq.doghouse.entity.Asset;
|
||||
|
||||
/**
|
||||
* 文件类型枚举
|
||||
* File category enum.
|
||||
**/
|
||||
@StaticAttribute
|
||||
public enum FileTypeEnum implements IQueryableEnum {
|
||||
@@ -26,6 +27,29 @@ public enum FileTypeEnum implements IQueryableEnum {
|
||||
private final int status;
|
||||
private final String note;
|
||||
|
||||
private static final Set<String> IMAGE_EXTENSIONS = Set.of(
|
||||
"jpg", "jpeg", "gif", "png", "apng", "bmp", "svg", "ico", "webp", "psd", "tif", "tiff", "raw", "avif");
|
||||
|
||||
private static final Set<String> ARCHIVE_EXTENSIONS = Set.of(
|
||||
"zip", "rar", "7z", "001", "iso", "xz", "txz", "lzma", "tar", "cpio", "bz2", "bzip2", "tbz2", "tbz",
|
||||
"gz", "gzip", "tgz", "tpz", "z", "taz", "lzh", "lha", "rpm", "deb", "arj", "vhd", "wim", "swm", "fat",
|
||||
"ntfs", "dmg", "hfs", "xar", "squashfs");
|
||||
|
||||
private static final Set<String> DOCUMENT_EXTENSIONS = Set.of(
|
||||
"doc", "docx", "dot", "dotx", "ppt", "pptx", "xls", "xlsx", "pdf", "txt", "rtf", "md");
|
||||
|
||||
private static final Set<String> MEDIA_EXTENSIONS = Set.of(
|
||||
"mp3", "aac", "ac3", "amr", "ogg", "ogm", "ogv", "dts", "m1a", "m2a", "m4a", "mp2", "mpa", "mpc",
|
||||
"flac", "mid", "wav", "wma", "wmv", "mp4", "webm", "swf", "rm", "rmvb", "mov", "mpeg", "mkv", "m4b",
|
||||
"m4v", "m4p", "f4v", "flv", "3gp");
|
||||
|
||||
private static final Set<String> APPLICATION_EXTENSIONS = Set.of(
|
||||
"exe", "msi", "bat", "cmd", "apk");
|
||||
|
||||
private static final Set<String> UPLOAD_DENY_EXTENSIONS = Set.of(
|
||||
"bat", "cmd", "com", "cpl", "dll", "exe", "htm", "html", "hta", "jar", "js", "jsp", "msi", "php",
|
||||
"ps1", "sh", "svg", "vbs", "war", "xhtml", "xml");
|
||||
|
||||
FileTypeEnum(int status, String note) {
|
||||
this.status = status;
|
||||
this.note = note;
|
||||
@@ -47,115 +71,47 @@ public enum FileTypeEnum implements IQueryableEnum {
|
||||
}
|
||||
|
||||
public static FileTypeEnum getFileType(String ext) {
|
||||
ext = normalizeExtension(ext);
|
||||
if (StringUtils.isEmpty(ext)) {
|
||||
return OTHER;
|
||||
}
|
||||
ext = ext.toLowerCase();
|
||||
switch (ext) {
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "gif":
|
||||
case "png":
|
||||
case "apng":
|
||||
case "bmp":
|
||||
case "svg":
|
||||
case "ico":
|
||||
case "webp":
|
||||
case "psd":
|
||||
case "tif":
|
||||
case "raw":
|
||||
case "avif":
|
||||
if (IMAGE_EXTENSIONS.contains(ext)) {
|
||||
return IMAGE;
|
||||
case "zip":
|
||||
case "rar":
|
||||
case "7z":
|
||||
case "001":
|
||||
case "iso":
|
||||
case "xz":
|
||||
case "txz":
|
||||
case "lzma":
|
||||
case "tar":
|
||||
case "cpio":
|
||||
case "bz2":
|
||||
case "bzip2":
|
||||
case "tbz2":
|
||||
case "tbz":
|
||||
case "gz":
|
||||
case "gzip":
|
||||
case "tgz":
|
||||
case "tpz":
|
||||
case "z":
|
||||
case "taz":
|
||||
case "lzh":
|
||||
case "lha":
|
||||
case "rpm":
|
||||
case "deb":
|
||||
case "arj":
|
||||
case "vhd":
|
||||
case "wim":
|
||||
case "swm":
|
||||
case "fat":
|
||||
case "ntfs":
|
||||
case "dmg":
|
||||
case "hfs":
|
||||
case "xar":
|
||||
case "squashfs":
|
||||
return ARCHIVE;
|
||||
case "doc":
|
||||
case "docx":
|
||||
case "dot":
|
||||
case "dotx":
|
||||
case "ppt":
|
||||
case "pptx":
|
||||
case "xls":
|
||||
case "xlsx":
|
||||
case "pdf":
|
||||
case "txt":
|
||||
case "rtf":
|
||||
case "md":
|
||||
return DOCUMENT;
|
||||
case "mp3":
|
||||
case "aac":
|
||||
case "ac3":
|
||||
case "amr":
|
||||
case "ogg":
|
||||
case "ogm":
|
||||
case "ogv":
|
||||
case "dts":
|
||||
case "m1a":
|
||||
case "m2a":
|
||||
case "m4a":
|
||||
case "mp2":
|
||||
case "mpa":
|
||||
case "mpc":
|
||||
case "flac":
|
||||
case "mid":
|
||||
case "wav":
|
||||
case "wma":
|
||||
case "wmv":
|
||||
case "mp4":
|
||||
case "webm":
|
||||
case "swf":
|
||||
case "rm":
|
||||
case "rmvb":
|
||||
case "mov":
|
||||
case "mpeg":
|
||||
case "mkv":
|
||||
case "m4b":
|
||||
case "m4v":
|
||||
case "m4p":
|
||||
case "f4v":
|
||||
case "flv":
|
||||
case "3gp":
|
||||
return MEDIA;
|
||||
case "exe":
|
||||
case "msi":
|
||||
case "bat":
|
||||
case "cmd":
|
||||
case "apk":
|
||||
return APPLICATION;
|
||||
default:
|
||||
return OTHER;
|
||||
}
|
||||
if (ARCHIVE_EXTENSIONS.contains(ext)) {
|
||||
return ARCHIVE;
|
||||
}
|
||||
if (DOCUMENT_EXTENSIONS.contains(ext)) {
|
||||
return DOCUMENT;
|
||||
}
|
||||
if (MEDIA_EXTENSIONS.contains(ext)) {
|
||||
return MEDIA;
|
||||
}
|
||||
if (APPLICATION_EXTENSIONS.contains(ext)) {
|
||||
return APPLICATION;
|
||||
}
|
||||
return OTHER;
|
||||
}
|
||||
|
||||
public static boolean isUploadDenied(String ext) {
|
||||
ext = normalizeExtension(ext);
|
||||
return StringUtils.isNotEmpty(ext) && UPLOAD_DENY_EXTENSIONS.contains(ext);
|
||||
}
|
||||
|
||||
public static boolean isImage(String ext) {
|
||||
return getFileType(ext) == IMAGE;
|
||||
}
|
||||
|
||||
public static boolean isImageCompressible(String ext) {
|
||||
ext = normalizeExtension(ext);
|
||||
return "jpg".equals(ext) || "jpeg".equals(ext) || "png".equals(ext) || "gif".equals(ext);
|
||||
}
|
||||
|
||||
public static String normalizeExtension(String ext) {
|
||||
if (StringUtils.isBlank(ext)) {
|
||||
return "";
|
||||
}
|
||||
ext = ext.trim().toLowerCase();
|
||||
return ext.startsWith(".") ? ext.substring(1) : ext;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package me.qwq.doghouse.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
|
||||
public enum MailTaskStatusEnum implements IQueryableEnum {
|
||||
|
||||
PENDING(0, "等待发送"),
|
||||
SENDING(1, "发送中"),
|
||||
SENT(2, "已发送"),
|
||||
FAILED(3, "发送失败"),
|
||||
CANCELLED(4, "已取消");
|
||||
|
||||
@EnumValue
|
||||
private final int status;
|
||||
private final String note;
|
||||
|
||||
MailTaskStatusEnum(int status, String note) {
|
||||
this.status = status;
|
||||
this.note = note;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getNote() {
|
||||
return note;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package me.qwq.doghouse.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
|
||||
public enum MailTaskTypeEnum implements IQueryableEnum {
|
||||
|
||||
REPLY_SUBSCRIPTION(0, "回复订阅通知"),
|
||||
ADMIN_NOTIFICATION(1, "管理员通知");
|
||||
|
||||
@EnumValue
|
||||
private final int status;
|
||||
private final String note;
|
||||
|
||||
MailTaskTypeEnum(int status, String note) {
|
||||
this.status = status;
|
||||
this.note = note;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getNote() {
|
||||
return note;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package me.qwq.doghouse.service;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.github.yulichang.toolkit.LambdaUtils;
|
||||
@@ -32,14 +33,18 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.aop.framework.AopContext;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
/**
|
||||
@@ -60,6 +65,9 @@ public class CommentService extends BaseServiceImpl<CommentMapper, Comment> {
|
||||
@Autowired
|
||||
CacheManager cacheManager;
|
||||
|
||||
@Autowired
|
||||
JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* 重写以启用缓存,类内调用需要 proxy()
|
||||
*/
|
||||
@@ -237,11 +245,11 @@ public class CommentService extends BaseServiceImpl<CommentMapper, Comment> {
|
||||
* 按照从子到父的顺序,递归获取从指定 commentId 到父级的所有 Comment
|
||||
* <p>类内调用需要 proxy()</p>
|
||||
* @param commentId 指定的 commentId
|
||||
* @param containsSelf 返回列表是否包含本级
|
||||
* @param includeSelf 返回列表是否包含本级
|
||||
* @return
|
||||
*/
|
||||
@Cacheable(value=CacheConstants.Comments.LIST_FROM_CHILD_TO_PARENT)
|
||||
public List<Comment> listFromChildToParent(Long commentId, Boolean containsSelf) {
|
||||
public List<Comment> listFromChildToParent(Long commentId, Boolean includeSelf) {
|
||||
List<Comment> list = new ArrayList<>();
|
||||
Comment post = getOne(
|
||||
new LambdaQueryWrapper<Comment>()
|
||||
@@ -251,7 +259,7 @@ public class CommentService extends BaseServiceImpl<CommentMapper, Comment> {
|
||||
if (post == null) {
|
||||
return list;
|
||||
}
|
||||
if (containsSelf) {
|
||||
if (includeSelf) {
|
||||
list.add(post);
|
||||
}
|
||||
Long parentId = post.getReplyTo();
|
||||
@@ -330,6 +338,59 @@ public class CommentService extends BaseServiceImpl<CommentMapper, Comment> {
|
||||
return (long) distinctEmails.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定评论生成新的邮件退订令牌。
|
||||
* @param commentId 评论 ID
|
||||
* @return 新生成的退订令牌
|
||||
*/
|
||||
public String refreshUnsubscribeToken(Long commentId) {
|
||||
if (commentId == null) {
|
||||
return null;
|
||||
}
|
||||
String token = UUID.randomUUID().toString();
|
||||
boolean updated = update(new LambdaUpdateWrapper<Comment>()
|
||||
.eq(Comment::getId, commentId)
|
||||
.set(Comment::getUnsubscribeToken, token));
|
||||
return updated ? token : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费指定退订令牌,并关闭该邮箱所有评论的回复订阅。
|
||||
* <p>只删除本次使用的令牌,其他评论上未使用的令牌会保留。</p>
|
||||
* @param token 退订令牌
|
||||
* @return 令牌对应的邮箱,令牌不存在时返回 null
|
||||
*/
|
||||
@Transactional
|
||||
public String unsubscribeByToken(String token) {
|
||||
if (StringUtils.isBlank(token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Comment tokenComment = getUnsubscribeTokenComment(token);
|
||||
if (tokenComment == null || StringUtils.isBlank(tokenComment.getEmail())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String email = tokenComment.getEmail().toLowerCase(Locale.ENGLISH).trim();
|
||||
update(new LambdaUpdateWrapper<Comment>()
|
||||
.eq(Comment::getId, tokenComment.getId())
|
||||
.set(Comment::getUnsubscribeToken, null));
|
||||
update(new LambdaUpdateWrapper<Comment>()
|
||||
.eq(Comment::getEmail, email)
|
||||
.set(Comment::isSubscribeReply, false));
|
||||
return email;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定退订令牌对应的邮箱,不消费令牌。
|
||||
* @param token 退订令牌
|
||||
* @return 令牌对应的邮箱,令牌不存在时返回 null
|
||||
*/
|
||||
public String getUnsubscribeEmail(String token) {
|
||||
Comment tokenComment = getUnsubscribeTokenComment(token);
|
||||
return tokenComment == null ? null : tokenComment.getEmail();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重写以启用缓存驱逐
|
||||
*/
|
||||
@@ -339,6 +400,7 @@ public class CommentService extends BaseServiceImpl<CommentMapper, Comment> {
|
||||
Tag.Comments
|
||||
})
|
||||
public boolean save(Comment entity) {
|
||||
prepareUnsubscribeToken(entity);
|
||||
Long replyTo = entity.getReplyTo();
|
||||
if (replyTo != -1L) {
|
||||
Comment replyToComment = proxy().getById(replyTo);
|
||||
@@ -356,6 +418,7 @@ public class CommentService extends BaseServiceImpl<CommentMapper, Comment> {
|
||||
Tag.Comments
|
||||
})
|
||||
public boolean saveOrUpdate(Comment entity) {
|
||||
prepareUnsubscribeToken(entity);
|
||||
Long replyTo = entity.getReplyTo();
|
||||
if (replyTo == null) entity.setReplyTo(-1L);
|
||||
else if (replyTo != -1L) {
|
||||
@@ -380,4 +443,19 @@ public class CommentService extends BaseServiceImpl<CommentMapper, Comment> {
|
||||
private CommentService proxy() {
|
||||
return (CommentService) AopContext.currentProxy();
|
||||
}
|
||||
|
||||
private void prepareUnsubscribeToken(Comment entity) {
|
||||
if (entity != null && entity.isSubscribeReply() && StringUtils.isBlank(entity.getUnsubscribeToken())) {
|
||||
entity.setUnsubscribeToken(UUID.randomUUID().toString());
|
||||
}
|
||||
}
|
||||
|
||||
private Comment getUnsubscribeTokenComment(String token) {
|
||||
if (StringUtils.isBlank(token)) {
|
||||
return null;
|
||||
}
|
||||
return getOne(new LambdaQueryWrapper<Comment>()
|
||||
.eq(Comment::getUnsubscribeToken, token)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,60 +3,57 @@ package me.qwq.doghouse.service;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.Proxy;
|
||||
import java.security.SecureRandom;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.Temporal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.mail.Authenticator;
|
||||
import javax.mail.Message.RecipientType;
|
||||
import javax.mail.MessagingException;
|
||||
import javax.mail.PasswordAuthentication;
|
||||
import javax.mail.Session;
|
||||
import javax.mail.Transport;
|
||||
import javax.mail.Message.RecipientType;
|
||||
import javax.mail.MessagingException;
|
||||
import javax.mail.internet.InternetAddress;
|
||||
import javax.mail.internet.MimeMessage;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.redisson.api.ExpiredObjectListener;
|
||||
import org.redisson.api.RBucket;
|
||||
import org.redisson.api.RedissonClient;
|
||||
import org.apache.commons.text.StringEscapeUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.sqids.Sqids;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import org.thymeleaf.TemplateEngine;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.thymeleaf.templatemode.TemplateMode;
|
||||
import org.thymeleaf.templateresolver.StringTemplateResolver;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import me.qwq.doghouse.component.Rk;
|
||||
import me.qwq.doghouse.entity.Comment;
|
||||
import me.qwq.doghouse.entity.MailTask;
|
||||
import me.qwq.doghouse.entity.Post;
|
||||
import me.qwq.doghouse.entity.config.MailConfig;
|
||||
import me.qwq.doghouse.entity.config.NetworkConfig;
|
||||
import me.qwq.doghouse.entity.config.SiteConfig;
|
||||
import me.qwq.doghouse.entity.Post;
|
||||
import me.qwq.doghouse.enums.CommentStatusEnum;
|
||||
import me.qwq.doghouse.enums.MailTaskTypeEnum;
|
||||
import me.qwq.doghouse.service.post.PostService;
|
||||
import me.qwq.doghouse.util.MarkDownUtils;
|
||||
|
||||
/**
|
||||
* 邮件服务
|
||||
* @author Doghole
|
||||
*
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class MailService {
|
||||
|
||||
static final String CONTENT_FORMAT = "text/html;charset=UTF-8";
|
||||
static final String REPLY_SUBSCRIPTION_TEMPLATE = "system/mail/reply-subscription.html";
|
||||
static final TemplateEngine MAIL_TEMPLATE_ENGINE = createMailTemplateEngine();
|
||||
|
||||
public enum MailTaskResult {
|
||||
SENT,
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
@Autowired
|
||||
MailConfig mailConfig;
|
||||
@@ -74,8 +71,26 @@ public class MailService {
|
||||
PostService postService;
|
||||
|
||||
@Autowired
|
||||
RedissonClient redis;
|
||||
MailTaskService mailTaskService;
|
||||
|
||||
/**
|
||||
* 创建用于渲染邮件正文的 Thymeleaf 字符串模板引擎。
|
||||
* @return 邮件模板引擎
|
||||
*/
|
||||
private static TemplateEngine createMailTemplateEngine() {
|
||||
StringTemplateResolver templateResolver = new StringTemplateResolver();
|
||||
templateResolver.setTemplateMode(TemplateMode.HTML);
|
||||
templateResolver.setCacheable(false);
|
||||
|
||||
TemplateEngine templateEngine = new TemplateEngine();
|
||||
templateEngine.setTemplateResolver(templateResolver);
|
||||
return templateEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param debug 是否开启调试, 开启后会在输出打印邮件状态
|
||||
* @return 邮件会话
|
||||
*/
|
||||
public Session getMailSession(boolean debug) {
|
||||
|
||||
String username = mailConfig.getUsername();
|
||||
@@ -109,330 +124,307 @@ public class MailService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 重设邮件任务延迟,一般在修改邮件设置后触发
|
||||
* 在邮件间隔设置变更后,重设邮件任务延时
|
||||
*/
|
||||
public void resetTaskDelay() {
|
||||
|
||||
for (String key : Rk.Mail.getAllExpirationTaskKeys()) {
|
||||
if (!key.endsWith("-comment")) {
|
||||
RBucket<Object> task = redis.getBucket(key);
|
||||
if (mailConfig.getSendingDelay() == 0L) {
|
||||
task.set(null, Duration.ofSeconds(1L));
|
||||
}
|
||||
else {
|
||||
task.set(null, Duration.ofMinutes(mailConfig.getSendingDelay()));
|
||||
}
|
||||
}
|
||||
}
|
||||
mailTaskService.resetReplyTaskDelay(mailConfig.getMailDuration());
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除评论对应的延迟
|
||||
* 取消可编辑评论的待处理邮件任务
|
||||
* @param comment
|
||||
* @return
|
||||
* @return 是否存在待处理任务并已将其取消
|
||||
*/
|
||||
public boolean clearTaskDelay(Comment comment) {
|
||||
RBucket<Object> taskBucket = Rk.Mail.getTaskBucket(comment.getId());
|
||||
return taskBucket.clearExpire() || taskBucket.isExists();
|
||||
if (comment == null || comment.getId() == null) {
|
||||
return false;
|
||||
}
|
||||
return mailTaskService.cancelOutstandingTasks(comment.getId(), MailTaskTypeEnum.REPLY_SUBSCRIPTION);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加或更新发送队列
|
||||
* @param comment
|
||||
* 添加或更新邮件推送任务
|
||||
* @param comment 需要处理的评论
|
||||
*/
|
||||
public void addOrUpdateSendQueue(Comment comment) {
|
||||
if (comment == null || comment.getId() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (comment.isMailSent()) {
|
||||
log.info("评论 ID 为 {} 的通知已发送,不会再次发送", comment.getId());
|
||||
log.info("ID 为 {} 的评论通知已发送,不会再次发送", comment.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
RBucket<Object> taskBucket = Rk.Mail.getTaskBucket(comment.getId());
|
||||
RBucket<Comment> commentCache = Rk.Mail.getTaskCommentBucket(comment.getId());
|
||||
|
||||
if (comment.getIsDeleted()) {
|
||||
// 已删除,需要删除其任务
|
||||
log.info("评论 ID 为 {} 的评论已删除,清除可能存在的通知任务", comment.getId());
|
||||
taskBucket.deleteAsync();
|
||||
commentCache.deleteAsync();
|
||||
if (comment.getIsDeleted() || comment.getCommentStatus() != CommentStatusEnum.RELEASED) {
|
||||
log.info("ID 为 {} 的评论已删除或未发布,取消其回复订阅通知任务", comment.getId());
|
||||
mailTaskService.cancelOutstandingTasks(comment.getId(), MailTaskTypeEnum.REPLY_SUBSCRIPTION);
|
||||
return;
|
||||
}
|
||||
|
||||
if (taskBucket.isExists()
|
||||
&& comment.getCommentStatus() != CommentStatusEnum.RELEASED) {
|
||||
// 已存在,但变更了评论状态,不为发布状态,需要删除其任务
|
||||
log.info("评论 ID 为 {} 的评论状态修改为 {},清除其通知任务",
|
||||
comment.getId(), comment.getCommentStatusNote());
|
||||
taskBucket.deleteAsync();
|
||||
commentCache.deleteAsync();
|
||||
Set<String> recipients = getReplySubscriptionRecipients(comment);
|
||||
if (recipients.isEmpty()) {
|
||||
mailTaskService.cancelOutstandingTasks(comment.getId(), MailTaskTypeEnum.REPLY_SUBSCRIPTION);
|
||||
log.debug("ID 为 {} 的评论未找到回复订阅通知收件人", comment.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
if (taskBucket.isExists()) {
|
||||
// 已存在,只更新 commentCache 和时间
|
||||
|
||||
if (taskBucket.clearExpire()) {
|
||||
// 有 TTL,重设时间
|
||||
log.info("评论 ID 为 {} 的评论内容已变更,重设推送时间",
|
||||
comment.getId());
|
||||
commentCache.set(comment);
|
||||
taskBucket.expire(mailConfig.getMailDuration());
|
||||
return;
|
||||
}
|
||||
else {
|
||||
// 没 TTL, 继续时间
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime commentDate = comment.getCreateTime();
|
||||
|
||||
Duration delay = Duration.ofMinutes(mailConfig.getSendingDelay());
|
||||
Temporal sendTime = delay.addTo(commentDate);
|
||||
|
||||
Duration newDelay = Duration.between(now, sendTime);
|
||||
if (newDelay.isNegative() || newDelay.isZero()) {
|
||||
newDelay = Duration.ofSeconds(1L);
|
||||
}
|
||||
commentCache.set(comment);
|
||||
taskBucket.expire(newDelay);
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
commentCache.set(comment);
|
||||
|
||||
|
||||
taskBucket.addListener(new ExpiredObjectListener() {
|
||||
@Override
|
||||
public void onExpired(String name) {
|
||||
// 寻找本级至顶级的所有回复
|
||||
Comment comment = commentCache.getAndDelete();
|
||||
|
||||
if (comment == null) {
|
||||
log.info("评论 ID 为 {} 的评论无法获取推送缓存,可能状态已变更,推送不会进行");
|
||||
return;
|
||||
}
|
||||
if (!mailConfig.isMailOn()) {
|
||||
log.debug("邮件功能未开启,评论 ID 为 {} 的推送任务将不会进行被发送", comment.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("触发评论 ID 为 {} 的推送任务...", comment.getId());
|
||||
Post post = postService.getById(comment.getPostId());
|
||||
if (post == null) {
|
||||
log.info("评论 ID 为 {} 的评论所属 Post Id = {} 不存在,可能状态已变更,推送不会进行");
|
||||
return;
|
||||
}
|
||||
List<Comment> parentComments = commentService.listFromChildToParent(comment.getId(), false);
|
||||
Set<String> mailSet = parentComments.stream()
|
||||
.filter(c -> c.isSubscribeReply() && !c.getEmail().equalsIgnoreCase(comment.getEmail()))
|
||||
.map(c -> c.getEmail().toLowerCase(Locale.ENGLISH))
|
||||
.collect(Collectors.toSet());
|
||||
if (!mailSet.isEmpty()) {
|
||||
MimeMessage message = new MimeMessage(getMailSession(false));
|
||||
try {
|
||||
message.setSubject(
|
||||
String.format("您在 %s 的 %s 文章评论有新回复",
|
||||
siteConfig.getName(),
|
||||
post.getTitle()));
|
||||
|
||||
if (StringUtils.isEmpty(mailConfig.getFromUser())) {
|
||||
message.setFrom(mailConfig.getFromAddress());
|
||||
}
|
||||
else {
|
||||
message.setFrom(
|
||||
String.format("%s<%s>",
|
||||
mailConfig.getFromUser(),
|
||||
mailConfig.getFromAddress()));
|
||||
}
|
||||
|
||||
String contentTemplate = getResourceText(REPLY_SUBSCRIPTION_TEMPLATE);
|
||||
if (contentTemplate == null) {
|
||||
log.warn("回复订阅通知模板文件路径 {} 无法找到, 通知将不会被发送",
|
||||
REPLY_SUBSCRIPTION_TEMPLATE);
|
||||
return;
|
||||
}
|
||||
|
||||
mailSet.forEach(mail -> {
|
||||
try {
|
||||
log.info("评论 ID 为 {} 的通知欲发送至 {}",
|
||||
comment.getId(), mail);
|
||||
RBucket<String> mgrBucket = Rk.Mail.getMgrBucket(mail);
|
||||
String uuid = null;
|
||||
if (mgrBucket.isExists()) {
|
||||
uuid = mgrBucket.get();
|
||||
log.debug("邮箱 {} 存在管理 UUID {}", mail, uuid);
|
||||
}
|
||||
else {
|
||||
uuid = UUID.randomUUID().toString();
|
||||
log.debug("邮箱 {} 不存在管理 UUID,生成 {}", mail, uuid);
|
||||
}
|
||||
|
||||
String content = renderReplySubscriptionContent(
|
||||
contentTemplate, siteConfig, post, comment, uuid);
|
||||
message.setContent(content, CONTENT_FORMAT);
|
||||
|
||||
message.setRecipient(RecipientType.TO,
|
||||
new InternetAddress(mail));
|
||||
Transport.send(message);
|
||||
|
||||
// 发送成功了再存 :P
|
||||
RBucket<String> uuidBucket = Rk.Mail.getMgrUuidBucket(uuid);
|
||||
uuidBucket.setAsync(mail, 86400, TimeUnit.SECONDS);
|
||||
mgrBucket.setAsync(uuid, 86400, TimeUnit.SECONDS);
|
||||
|
||||
log.debug("已为邮箱 {} 设置 UUID {}", mail, uuid);
|
||||
|
||||
log.info("评论 ID 为 {} 的通知已发送至 {}", comment.getId(), mail);
|
||||
}
|
||||
catch (MessagingException e) {
|
||||
log.error("发送评论 ID 为 " + comment.getId() + " 的通知至 " + mail + " 时发生错误", e);
|
||||
}
|
||||
});
|
||||
// 更新已发送状态
|
||||
commentService.update(new LambdaUpdateWrapper<Comment>()
|
||||
.eq(Comment::getId, comment.getId())
|
||||
.set(Comment::isMailSent, true));
|
||||
}
|
||||
catch (MessagingException e) {
|
||||
log.error("初始化评论 ID 为 {} 的通知失败", comment.getId());
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 本级到顶级没有找到能接收通知的邮箱,或找到的邮箱是回复者本人的邮箱
|
||||
log.debug("评论 ID 为 {} 的任务未找到可执行通知的邮箱,"
|
||||
+ "或找到的邮箱为回复者本人邮箱,邮件不会发送。"
|
||||
+ "若非您预期的结果,请检查:被回复的评论及上级"
|
||||
+ "评论是否开启了邮件订阅?",
|
||||
comment.getId());
|
||||
}
|
||||
}
|
||||
});
|
||||
Long sendingDelay = mailConfig.getSendingDelay();
|
||||
if (sendingDelay == 0L) {
|
||||
taskBucket.set(0, Duration.ofSeconds(1L));
|
||||
log.debug("评论 ID 为 {} 的任务将在 1 秒后立即执行", comment.getId());
|
||||
}
|
||||
else {
|
||||
taskBucket.set(0, Duration.ofMinutes(sendingDelay));
|
||||
log.debug("评论 ID 为 {} 的任务将在 {} 分钟后执行", comment.getId(), sendingDelay);
|
||||
}
|
||||
log.info("评论 ID 为 {} 的通知任务已提交,等待执行", comment.getId());
|
||||
LocalDateTime sendAfter = getSendAfter(comment);
|
||||
mailTaskService.replaceReplyTasks(comment.getId(), recipients, sendAfter);
|
||||
log.info("ID 为 {} 的评论回复订阅通知任务已提交,收件人数 {}", comment.getId(), recipients.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送新评论通知到管理员邮箱
|
||||
* 为新评论发送管理员通知
|
||||
* @param comment
|
||||
*/
|
||||
public void sendAdminNotification(Comment comment) {
|
||||
|
||||
if (!mailConfig.isMailOn()) {
|
||||
log.debug("邮件功能未开启,评论 ID 为 {} 的管理邮件将不会被发送", comment.getId());
|
||||
if (comment == null || comment.getId() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Post post = postService.getByIdOrName(comment.getPostId(), comment.getPostPageName());
|
||||
|
||||
RBucket<Object> expiredKey = Rk.Mail.getRandomExpirationBucket();
|
||||
expiredKey.addListener(new ExpiredObjectListener() {
|
||||
@Override
|
||||
public void onExpired(String name) {
|
||||
log.info("评论 ID 为 {} 的管理邮件任务开始", comment.getId());
|
||||
try {
|
||||
MimeMessage message = new MimeMessage(getMailSession(false));
|
||||
message.setSubject(
|
||||
String.format("您在 %s 的 %s 文章有新回复",
|
||||
siteConfig.getName(),
|
||||
post.getTitle()));
|
||||
String content = String.format(
|
||||
"<p><b>%s</b>:%s<br>"
|
||||
+ "<b>评论状态</b>:%s<br>"
|
||||
+ "<a href=\"%s%s\" rel=\"noopener noreferrer\" target=\"_blank\">查看详情</a></p>"
|
||||
+ "<p><i style=\"color: red\">该邮件由系统自动发出,请勿直接回复该邮件</i></p>",
|
||||
comment.getAuthor(),
|
||||
comment.getContent(),
|
||||
comment.getCommentStatusNote(),
|
||||
siteConfig.getAddress(),
|
||||
comment.getCommentStatus() == CommentStatusEnum.RELEASED ?
|
||||
commentService.getJumpToUrl(comment.getId()):
|
||||
"/admin/v2");
|
||||
message.setContent(content, CONTENT_FORMAT);
|
||||
|
||||
if (StringUtils.isEmpty(mailConfig.getFromUser())) {
|
||||
message.setFrom(mailConfig.getFromAddress());
|
||||
}
|
||||
else {
|
||||
message.setFrom(
|
||||
String.format("%s<%s>",
|
||||
mailConfig.getFromUser(),
|
||||
mailConfig.getFromAddress()));
|
||||
}
|
||||
message.setRecipient(RecipientType.TO,
|
||||
new InternetAddress(mailConfig.getAdminAddress()));
|
||||
Transport.send(message);
|
||||
log.info("评论 ID 为 {} 的管理邮件发送成功", comment.getId());
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.error("评论 ID 为 " + comment.getId() + " 的管理邮件发送失败", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
expiredKey.set(1, Duration.ofSeconds(1L));
|
||||
log.debug("评论 ID 为 {} 的管理邮件任务将在 1 秒后立即执行", comment.getId());
|
||||
}
|
||||
|
||||
String renderReplySubscriptionContent(
|
||||
String template, SiteConfig siteConfig, Post post, Comment comment, String uuid) {
|
||||
String blogName = siteConfig.getName();
|
||||
String postName = post.getPostTitle();
|
||||
String author = comment.getAuthor();
|
||||
String content = comment.getContent();
|
||||
String detailUrl = siteConfig.getAddress() + "/comments/go/" + comment.getId();
|
||||
String unsubscribeUrl = siteConfig.getAddress() + "/system/unsubscribe-mail?token=" + uuid;
|
||||
|
||||
List<Long> list = new ArrayList<>();
|
||||
SecureRandom random = new SecureRandom();
|
||||
list.add(Math.abs(random.nextLong()));
|
||||
list.add(Math.abs(random.nextLong()));
|
||||
String mess = Sqids.builder().build().encode(list);
|
||||
|
||||
String blogNameReplacement = "{{blogName" + mess + "}}";
|
||||
String postNameReplacement = "{{postName" + mess + "}}";
|
||||
String authorReplacement = "{{author" + mess + "}}";
|
||||
String contentReplacement = "{{content" + mess + "}}";
|
||||
String detailUrlReplacement = "{{detailUrl" + mess + "}}";
|
||||
String unsubscribeUrlReplacement = "{{unsubscribeUrl" + mess + "}}";
|
||||
|
||||
|
||||
return template
|
||||
.replace("{{blogName}}", blogNameReplacement)
|
||||
.replace("{{postName}}", postNameReplacement)
|
||||
.replace("{{author}}", authorReplacement)
|
||||
.replace("{{content}}", contentReplacement)
|
||||
.replace("{{detailUrl}}", detailUrlReplacement)
|
||||
.replace("{{unsubscribeUrl}}", unsubscribeUrlReplacement)
|
||||
.replace(blogNameReplacement, blogName)
|
||||
.replace(postNameReplacement, postName)
|
||||
.replace(authorReplacement, author)
|
||||
.replace(contentReplacement, content)
|
||||
.replace(detailUrlReplacement, detailUrl)
|
||||
.replace(unsubscribeUrlReplacement, unsubscribeUrl);
|
||||
if (!mailConfig.isMailOn()) {
|
||||
log.debug("邮件功能未开启,评论 ID 为 {} 的管理员邮件不会入队", comment.getId());
|
||||
return;
|
||||
}
|
||||
mailTaskService.enqueueAdminTask(comment.getId(), mailConfig.getAdminAddress());
|
||||
log.debug("ID 为 {} 的评论的管理员邮件任务已提交", comment.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取资源文件
|
||||
* @param fileName
|
||||
* @return
|
||||
* 执行指定邮件任务,并根据任务类型分派到对应发送逻辑。
|
||||
* @param task 待执行的邮件任务
|
||||
* @return 邮件任务执行结果
|
||||
* @throws MessagingException 邮件构造或发送失败时抛出
|
||||
*/
|
||||
public MailTaskResult executeMailTask(MailTask task) throws MessagingException {
|
||||
if (task == null || task.getTaskType() == null) {
|
||||
return MailTaskResult.CANCELLED;
|
||||
}
|
||||
if (!mailConfig.isMailOn()) {
|
||||
log.debug("邮件功能未开启,邮件任务 {} 取消", task.getId());
|
||||
return MailTaskResult.CANCELLED;
|
||||
}
|
||||
|
||||
if (task.getTaskType() == MailTaskTypeEnum.ADMIN_NOTIFICATION) {
|
||||
return sendAdminNotificationTask(task);
|
||||
}
|
||||
if (task.getTaskType() == MailTaskTypeEnum.REPLY_SUBSCRIPTION) {
|
||||
return sendReplySubscriptionTask(task);
|
||||
}
|
||||
return MailTaskResult.CANCELLED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送评论回复订阅通知邮件。
|
||||
* @param task 回复订阅邮件任务
|
||||
* @return 邮件任务执行结果
|
||||
* @throws MessagingException 邮件构造或发送失败时抛出
|
||||
*/
|
||||
private MailTaskResult sendReplySubscriptionTask(MailTask task) throws MessagingException {
|
||||
Comment comment = commentService.getById(task.getCommentId());
|
||||
if (comment == null || comment.getIsDeleted() || comment.getCommentStatus() != CommentStatusEnum.RELEASED) {
|
||||
return MailTaskResult.CANCELLED;
|
||||
}
|
||||
if (comment.isMailSent()) {
|
||||
return MailTaskResult.CANCELLED;
|
||||
}
|
||||
|
||||
String recipient = StringUtils.lowerCase(task.getRecipient(), Locale.ENGLISH);
|
||||
if (!isReplySubscriptionRecipientStillValid(comment, recipient)) {
|
||||
log.info("评论 ID 为 {} 的收件人 {} 已不再订阅该回复,任务取消", comment.getId(), recipient);
|
||||
return MailTaskResult.CANCELLED;
|
||||
}
|
||||
|
||||
Post post = postService.getByIdOrName(comment.getPostId(), comment.getPostPageName(), false);
|
||||
if (post == null) {
|
||||
return MailTaskResult.CANCELLED;
|
||||
}
|
||||
|
||||
Comment subscriptionComment = getReplySubscriptionComment(comment, recipient);
|
||||
if (subscriptionComment == null) {
|
||||
return MailTaskResult.CANCELLED;
|
||||
}
|
||||
|
||||
String contentTemplate = getResourceText(REPLY_SUBSCRIPTION_TEMPLATE);
|
||||
if (contentTemplate == null) {
|
||||
throw new MessagingException("回复订阅通知模板无法读取: " + REPLY_SUBSCRIPTION_TEMPLATE);
|
||||
}
|
||||
|
||||
String token = commentService.refreshUnsubscribeToken(subscriptionComment.getId());
|
||||
if (StringUtils.isBlank(token)) {
|
||||
throw new MessagingException("无法生成邮件退订令牌");
|
||||
}
|
||||
|
||||
MimeMessage message = new MimeMessage(getMailSession(false));
|
||||
message.setSubject(String.format("您在 %s 的 %s 文章评论有新回复",
|
||||
siteConfig.getName(), post.getTitle()));
|
||||
setMessageFrom(message);
|
||||
message.setRecipient(RecipientType.TO, new InternetAddress(recipient));
|
||||
message.setContent(renderReplySubscriptionContent(
|
||||
contentTemplate, siteConfig, post, comment, token), CONTENT_FORMAT);
|
||||
|
||||
Transport.send(message);
|
||||
|
||||
log.info("评论 ID 为 {} 的回复订阅通知已发送至 {}", comment.getId(), recipient);
|
||||
return MailTaskResult.SENT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送新评论管理员通知邮件。
|
||||
* @param task 管理员通知邮件任务
|
||||
* @return 邮件任务执行结果
|
||||
* @throws MessagingException 邮件构造或发送失败时抛出
|
||||
*/
|
||||
private MailTaskResult sendAdminNotificationTask(MailTask task) throws MessagingException {
|
||||
Comment comment = commentService.getById(task.getCommentId());
|
||||
if (comment == null || comment.getIsDeleted()) {
|
||||
return MailTaskResult.CANCELLED;
|
||||
}
|
||||
|
||||
Post post = postService.getById(comment.getPostId());
|
||||
if (post == null) {
|
||||
return MailTaskResult.CANCELLED;
|
||||
}
|
||||
|
||||
MimeMessage message = new MimeMessage(getMailSession(false));
|
||||
message.setSubject(String.format("您在 %s 的 %s 文章有新回复",
|
||||
siteConfig.getName(), post.getTitle()));
|
||||
setMessageFrom(message);
|
||||
message.setRecipient(RecipientType.TO, new InternetAddress(task.getRecipient()));
|
||||
message.setContent(String.format(
|
||||
"<p><b>%s</b>:%s<br>"
|
||||
+ "<b>评论状态</b>:%s<br>"
|
||||
+ "<a href=\"%s%s\" rel=\"noopener noreferrer\" target=\"_blank\">查看详情</a></p>"
|
||||
+ "<p><i style=\"color: red\">该邮件由系统自动发出,请勿直接回复该邮件</i></p>",
|
||||
comment.getAuthor(),
|
||||
comment.getContent(),
|
||||
comment.getCommentStatusNote(),
|
||||
siteConfig.getAddress(),
|
||||
comment.getCommentStatus() == CommentStatusEnum.RELEASED ?
|
||||
commentService.getJumpToUrl(comment.getId()) :
|
||||
"/admin/v2"), CONTENT_FORMAT);
|
||||
|
||||
Transport.send(message);
|
||||
log.info("评论 ID 为 {} 的管理员邮件已发送至 {}", comment.getId(), task.getRecipient());
|
||||
return MailTaskResult.SENT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从指定评论中获取开启了订阅的收件人
|
||||
* @param comment 关联评论
|
||||
* @return 收件人邮件地址 Set
|
||||
*/
|
||||
private Set<String> getReplySubscriptionRecipients(Comment comment) {
|
||||
return commentService.listFromChildToParent(comment.getId(), false)
|
||||
.stream()
|
||||
.filter(c -> c.isSubscribeReply())
|
||||
.map(Comment::getEmail)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.map(email -> email.toLowerCase(Locale.ENGLISH).trim())
|
||||
.filter(email -> !email.equalsIgnoreCase(comment.getEmail()))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param comment 指定评论
|
||||
* @param recipient 指定收件人
|
||||
* @return 指定收件人是否存在于某评论的订阅中
|
||||
*/
|
||||
private boolean isReplySubscriptionRecipientStillValid(Comment comment, String recipient) {
|
||||
return getReplySubscriptionComment(comment, recipient) != null;
|
||||
}
|
||||
|
||||
private Comment getReplySubscriptionComment(Comment comment, String recipient) {
|
||||
if (StringUtils.isBlank(recipient)) {
|
||||
return null;
|
||||
}
|
||||
String normalizedRecipient = recipient.toLowerCase(Locale.ENGLISH).trim();
|
||||
return commentService.listFromChildToParent(comment.getId(), false)
|
||||
.stream()
|
||||
.filter(Comment::isSubscribeReply)
|
||||
.filter(c -> StringUtils.isNotBlank(c.getEmail()))
|
||||
.filter(c -> normalizedRecipient.equals(c.getEmail().toLowerCase(Locale.ENGLISH).trim()))
|
||||
.filter(c -> !normalizedRecipient.equalsIgnoreCase(comment.getEmail()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算评论回复订阅通知的最早发送时间。
|
||||
* @param comment 关联评论
|
||||
* @return 最早发送时间
|
||||
*/
|
||||
private LocalDateTime getSendAfter(Comment comment) {
|
||||
LocalDateTime baseTime = comment.getCreateTime() == null ? LocalDateTime.now() : comment.getCreateTime();
|
||||
Duration delay = mailConfig.getMailDuration();
|
||||
LocalDateTime sendAfter = baseTime.plus(delay);
|
||||
LocalDateTime minSendAfter = LocalDateTime.now().plusSeconds(1);
|
||||
return sendAfter.isBefore(minSendAfter) ? minSendAfter : sendAfter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据邮件配置设置发件人信息。
|
||||
* @param message 待设置的邮件消息
|
||||
* @throws MessagingException 发件人设置失败时抛出
|
||||
*/
|
||||
private void setMessageFrom(MimeMessage message) throws MessagingException {
|
||||
if (StringUtils.isEmpty(mailConfig.getFromUser())) {
|
||||
message.setFrom(mailConfig.getFromAddress());
|
||||
}
|
||||
else {
|
||||
message.setFrom(String.format("%s<%s>",
|
||||
mailConfig.getFromUser(),
|
||||
mailConfig.getFromAddress()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染回复订阅内容
|
||||
* @param template 模板源文本
|
||||
* @param siteConfig 站点配置, 用以获取博客名、博客地址
|
||||
* @param post 对应文章, 用以获取文章标题
|
||||
* @param comment 订阅者发布的本条评论, 用以获取订阅者称呼、当前评论内容和评论 id, 其中评论 id 用作链接跳转
|
||||
* @param token 对应评论发布者邮箱退订用令牌,用以鉴别发布者身份
|
||||
* @return 渲染完毕后的邮件内容(html)
|
||||
*/
|
||||
private String renderReplySubscriptionContent(
|
||||
String template, SiteConfig siteConfig, Post post, Comment comment, String token) {
|
||||
Context context = new Context(Locale.getDefault());
|
||||
context.setVariable("blogName", siteConfig.getName());
|
||||
context.setVariable("postName", post.getPostTitle());
|
||||
context.setVariable("author", comment.getAuthor());
|
||||
context.setVariable("contentHtml", renderSafeCommentContent(comment));
|
||||
context.setVariable("detailUrl", siteConfig.getAddress() + "/comments/go/" + comment.getId());
|
||||
context.setVariable("unsubscribeUrl", siteConfig.getAddress() + "/system/unsubscribe-mail?token=" + token);
|
||||
|
||||
return MAIL_TEMPLATE_ENGINE.process(template, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将评论内容转换为可安全放入邮件正文的 HTML。
|
||||
* @param comment 关联评论
|
||||
* @return 可直接作为 HTML 输出的评论内容
|
||||
*/
|
||||
private String renderSafeCommentContent(Comment comment) {
|
||||
if (comment.isMarkdown()) {
|
||||
return MarkDownUtils.commentToHtml(comment.getContent());
|
||||
}
|
||||
return StringEscapeUtils.escapeHtml4(comment.getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 classpath 下的文本资源内容。
|
||||
* @param fileName 资源文件路径
|
||||
* @return 资源文本内容,读取失败时返回 null
|
||||
*/
|
||||
String getResourceText(String fileName) {
|
||||
ClassPathResource resource = new ClassPathResource(fileName);
|
||||
String content = null;
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
content = new String(inputStream.readAllBytes());
|
||||
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
catch (IOException e) {
|
||||
log.error("读取 resource 文件 " + fileName + " 时发生错误", e);
|
||||
return null;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package me.qwq.doghouse.service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
|
||||
import me.qwq.doghouse.dao.MailTaskMapper;
|
||||
import me.qwq.doghouse.entity.MailTask;
|
||||
import me.qwq.doghouse.enums.MailTaskStatusEnum;
|
||||
import me.qwq.doghouse.enums.MailTaskTypeEnum;
|
||||
|
||||
@Service
|
||||
public class MailTaskService extends BaseServiceImpl<MailTaskMapper, MailTask> {
|
||||
|
||||
public static final int MAX_ATTEMPTS = 5;
|
||||
private static final int DEFAULT_BATCH_SIZE = 10;
|
||||
|
||||
@Transactional
|
||||
public void replaceReplyTasks(Long commentId, Collection<String> recipients, LocalDateTime sendAfter) {
|
||||
cancelOutstandingTasks(commentId, MailTaskTypeEnum.REPLY_SUBSCRIPTION);
|
||||
if (recipients == null || recipients.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<MailTask> tasks = recipients.stream()
|
||||
.map(recipient -> new MailTask()
|
||||
.setTaskType(MailTaskTypeEnum.REPLY_SUBSCRIPTION)
|
||||
.setCommentId(commentId)
|
||||
.setRecipient(recipient)
|
||||
.setStatus(MailTaskStatusEnum.PENDING)
|
||||
.setSendAfter(sendAfter)
|
||||
.setAttempts(0)
|
||||
.setCreateTime(now)
|
||||
.setUpdateTime(now))
|
||||
.toList();
|
||||
saveBatch(tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* 入队新评论的管理员邮件通知
|
||||
* @param commentId 评论 ID
|
||||
* @param recipient 收件人地址
|
||||
*/
|
||||
public void enqueueAdminTask(Long commentId, String recipient) {
|
||||
if (commentId == null || recipient == null || recipient.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
save(new MailTask()
|
||||
.setTaskType(MailTaskTypeEnum.ADMIN_NOTIFICATION)
|
||||
.setCommentId(commentId)
|
||||
.setRecipient(recipient.toLowerCase())
|
||||
.setStatus(MailTaskStatusEnum.PENDING)
|
||||
.setSendAfter(now.plusSeconds(1))
|
||||
.setAttempts(0)
|
||||
.setCreateTime(now)
|
||||
.setUpdateTime(now));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消未完成的评论邮件任务
|
||||
* @param commentId 指定评论 id
|
||||
* @param taskType 邮件任务类型
|
||||
* @return 是否成功取消指定条件的任务
|
||||
*/
|
||||
public boolean cancelOutstandingTasks(Long commentId, MailTaskTypeEnum taskType) {
|
||||
if (commentId == null || taskType == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<MailTask> query = outstandingQuery(commentId, taskType);
|
||||
boolean exists = count(query) > 0;
|
||||
if (!exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return update(new LambdaUpdateWrapper<MailTask>()
|
||||
.eq(MailTask::getCommentId, commentId)
|
||||
.eq(MailTask::getTaskType, taskType)
|
||||
.in(MailTask::getStatus,
|
||||
MailTaskStatusEnum.PENDING,
|
||||
MailTaskStatusEnum.FAILED,
|
||||
MailTaskStatusEnum.SENDING)
|
||||
.set(MailTask::getStatus, MailTaskStatusEnum.CANCELLED)
|
||||
.set(MailTask::getUpdateTime, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重设订阅邮件任务延迟, 会将邮件类型为订阅邮件、邮件状态为 PEDING 和 FAILED 的重置为 PEDING, 并重设延迟
|
||||
* @param delay 延迟
|
||||
*/
|
||||
public void resetReplyTaskDelay(Duration delay) {
|
||||
LocalDateTime sendAfter = LocalDateTime.now().plus(delay);
|
||||
update(new LambdaUpdateWrapper<MailTask>()
|
||||
.eq(MailTask::getTaskType, MailTaskTypeEnum.REPLY_SUBSCRIPTION)
|
||||
.in(MailTask::getStatus, MailTaskStatusEnum.PENDING, MailTaskStatusEnum.FAILED)
|
||||
.set(MailTask::getStatus, MailTaskStatusEnum.PENDING)
|
||||
.set(MailTask::getSendAfter, sendAfter)
|
||||
.set(MailTask::getUpdateTime, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前满足发送条件的邮件任务, 详见查询条件
|
||||
* @return 当前满足发送条件的邮件任务的列表
|
||||
*/
|
||||
public List<MailTask> pollDueTasks() {
|
||||
return list(new LambdaQueryWrapper<MailTask>()
|
||||
.in(MailTask::getStatus, MailTaskStatusEnum.PENDING, MailTaskStatusEnum.FAILED)
|
||||
.le(MailTask::getSendAfter, LocalDateTime.now())
|
||||
.lt(MailTask::getAttempts, MAX_ATTEMPTS)
|
||||
.orderByAsc(MailTask::getSendAfter)
|
||||
.last("LIMIT " + DEFAULT_BATCH_SIZE));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将指定邮件任务标记为发送中
|
||||
* @param task
|
||||
* @return
|
||||
*/
|
||||
public boolean markSending(MailTask task) {
|
||||
if (task == null || task.getId() == null) {
|
||||
return false;
|
||||
}
|
||||
return update(new LambdaUpdateWrapper<MailTask>()
|
||||
.eq(MailTask::getId, task.getId())
|
||||
.in(MailTask::getStatus, MailTaskStatusEnum.PENDING, MailTaskStatusEnum.FAILED)
|
||||
.set(MailTask::getStatus, MailTaskStatusEnum.SENDING)
|
||||
.set(MailTask::getUpdateTime, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将指定邮件任务标记为已发送
|
||||
* @param task
|
||||
*/
|
||||
public void markSent(MailTask task) {
|
||||
update(new LambdaUpdateWrapper<MailTask>()
|
||||
.eq(MailTask::getId, task.getId())
|
||||
.set(MailTask::getStatus, MailTaskStatusEnum.SENT)
|
||||
.set(MailTask::getLastError, null)
|
||||
.set(MailTask::getUpdateTime, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将指定邮件任务标记为取消
|
||||
* @param task
|
||||
* @param reason
|
||||
*/
|
||||
public void markCancelled(MailTask task, String reason) {
|
||||
update(new LambdaUpdateWrapper<MailTask>()
|
||||
.eq(MailTask::getId, task.getId())
|
||||
.set(MailTask::getStatus, MailTaskStatusEnum.CANCELLED)
|
||||
.set(MailTask::getLastError, trimError(reason))
|
||||
.set(MailTask::getUpdateTime, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将指定邮件任务标记为失败
|
||||
* @param task
|
||||
* @param e
|
||||
*/
|
||||
public void markFailed(MailTask task, Exception e) {
|
||||
int attempts = task.getAttempts() == null ? 0 : task.getAttempts();
|
||||
int nextAttempts = attempts + 1;
|
||||
MailTaskStatusEnum status = nextAttempts >= MAX_ATTEMPTS ?
|
||||
MailTaskStatusEnum.FAILED :
|
||||
MailTaskStatusEnum.PENDING;
|
||||
long delayMinutes = Math.min(60L, 1L << Math.min(nextAttempts, 6));
|
||||
|
||||
update(new LambdaUpdateWrapper<MailTask>()
|
||||
.eq(MailTask::getId, task.getId())
|
||||
.set(MailTask::getStatus, status)
|
||||
.set(MailTask::getAttempts, nextAttempts)
|
||||
.set(MailTask::getLastError, trimError(e.getMessage()))
|
||||
.set(MailTask::getSendAfter, LocalDateTime.now().plusMinutes(delayMinutes))
|
||||
.set(MailTask::getUpdateTime, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定评论 id 下是否有待处理邮件任务
|
||||
* @param commentId
|
||||
* @return 是否有待处理邮件任务
|
||||
*/
|
||||
public boolean hasOutstandingReplyTasks(Long commentId) {
|
||||
return count(outstandingQuery(commentId, MailTaskTypeEnum.REPLY_SUBSCRIPTION)) > 0;
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<MailTask> outstandingQuery(Long commentId, MailTaskTypeEnum taskType) {
|
||||
return new LambdaQueryWrapper<MailTask>()
|
||||
.eq(MailTask::getCommentId, commentId)
|
||||
.eq(MailTask::getTaskType, taskType)
|
||||
.in(MailTask::getStatus,
|
||||
MailTaskStatusEnum.PENDING,
|
||||
MailTaskStatusEnum.FAILED,
|
||||
MailTaskStatusEnum.SENDING);
|
||||
}
|
||||
|
||||
private static String trimError(String error) {
|
||||
if (error == null) {
|
||||
return null;
|
||||
}
|
||||
return error.length() > 1000 ? error.substring(0, 1000) : error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package me.qwq.doghouse.task;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import me.qwq.doghouse.entity.Comment;
|
||||
import me.qwq.doghouse.entity.MailTask;
|
||||
import me.qwq.doghouse.enums.MailTaskTypeEnum;
|
||||
import me.qwq.doghouse.service.CommentService;
|
||||
import me.qwq.doghouse.service.MailService;
|
||||
import me.qwq.doghouse.service.MailTaskService;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class MailTaskWorker {
|
||||
|
||||
@Autowired
|
||||
MailTaskService mailTaskService;
|
||||
|
||||
@Autowired
|
||||
MailService mailService;
|
||||
|
||||
@Autowired
|
||||
CommentService commentService;
|
||||
|
||||
@Scheduled(fixedDelay = 5000L)
|
||||
public void run() {
|
||||
for (MailTask task : mailTaskService.pollDueTasks()) {
|
||||
if (!mailTaskService.markSending(task)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
MailService.MailTaskResult result = mailService.executeMailTask(task);
|
||||
if (result == MailService.MailTaskResult.CANCELLED) {
|
||||
mailTaskService.markCancelled(task, "任务已不满足发送条件");
|
||||
}
|
||||
else {
|
||||
mailTaskService.markSent(task);
|
||||
markCommentMailSentIfFinished(task);
|
||||
}
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.error("邮件任务 {} 发送失败", task.getId(), e);
|
||||
mailTaskService.markFailed(task, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void markCommentMailSentIfFinished(MailTask task) {
|
||||
if (task.getTaskType() != MailTaskTypeEnum.REPLY_SUBSCRIPTION) {
|
||||
return;
|
||||
}
|
||||
if (mailTaskService.hasOutstandingReplyTasks(task.getCommentId())) {
|
||||
return;
|
||||
}
|
||||
commentService.update(new LambdaUpdateWrapper<Comment>()
|
||||
.eq(Comment::getId, task.getCommentId())
|
||||
.set(Comment::isMailSent, true));
|
||||
}
|
||||
}
|
||||
@@ -134,9 +134,7 @@ public class SessionUtils {
|
||||
* @param id
|
||||
*/
|
||||
public static void addPostId(HttpServletRequest request, Long id) {
|
||||
Set<Long> postIdSet = getPostIdSet(request);
|
||||
postIdSet.add(id);
|
||||
request.getSession().setAttribute(POST_ID_SET, postIdSet);
|
||||
addPostId(request.getSession(), id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,6 +143,7 @@ public class SessionUtils {
|
||||
* @param id
|
||||
*/
|
||||
public static void addPostId(HttpSession session, Long id) {
|
||||
if (session == null) return;
|
||||
Set<Long> postIdSet = getPostIdSet(session);
|
||||
postIdSet.add(id);
|
||||
session.setAttribute(POST_ID_SET, postIdSet);
|
||||
@@ -197,22 +196,21 @@ public class SessionUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* 对指定 Session 中添加指定评论 ID
|
||||
* @param request
|
||||
* 在指定 Session 中添加指定评论 ID, 使得同 session 用户可以管理自己的评论
|
||||
* @param request 相关请求, 从中提取 session
|
||||
* @param id
|
||||
*/
|
||||
public static void addCommentId(HttpServletRequest request, Long id) {
|
||||
Set<Long> commentIdList = getCommentIdSet(request);
|
||||
commentIdList.add(id);
|
||||
request.getSession().setAttribute(COMMENT_ID_LIST, commentIdList);
|
||||
addCommentId(request.getSession(), id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对指定 Session 中添加指定评论 ID
|
||||
* @param session
|
||||
* 在指定 Session 中添加指定评论 ID, 使得同 session 用户可以管理自己的评论
|
||||
* @param session 相关 session
|
||||
* @param id
|
||||
*/
|
||||
public static void addCommentId(HttpSession session, Long id) {
|
||||
if (session == null) return;
|
||||
Set<Long> commentIdList = getCommentIdSet(session);
|
||||
commentIdList.add(id);
|
||||
session.setAttribute(COMMENT_ID_LIST, commentIdList);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
server:
|
||||
address: 127.0.0.1 # 此处是不开 0.0.0.0, 避免内网访问
|
||||
port: 7778
|
||||
compression:
|
||||
enabled: true
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="me.qwq.doghouse.dao.MailTaskMapper">
|
||||
|
||||
</mapper>
|
||||
@@ -1,28 +1,27 @@
|
||||
<div style="max-width: 800px; text-align: justify">
|
||||
<p>
|
||||
您在 {{blogName}} 中文章 {{postName}} 下的评论有新回复。
|
||||
您在 <span th:text="${blogName}">博客名</span> 中文章 <span th:text="${postName}">文章标题</span> 下的评论有新回复。
|
||||
<br>
|
||||
There is a new reply to your comment under post {{postName}} in {{blogName}}.
|
||||
There is a new reply to your comment under post <span th:text="${postName}">post title</span> in <span th:text="${blogName}">blog name</span>.
|
||||
<br>
|
||||
<blockquote>
|
||||
<b>{{author}}</b>: {{content}}<br>
|
||||
<a href="{{detailUrl}}" rel="noopener noreferrer" target="_blank">查看详情</a>
|
||||
<b th:text="${author}">评论者</b>: <span th:utext="${contentHtml}">评论内容</span><br>
|
||||
<a th:href="${detailUrl}" rel="noopener noreferrer" target="_blank">查看详情</a>
|
||||
</blockquote>
|
||||
</p>
|
||||
<p>
|
||||
<i style="color: gray; font-size: small;">
|
||||
本邮件为系统自动发出,请勿回复该邮件。
|
||||
邮件内容仅为转发评论者内容,不代表本博客站长立场。
|
||||
若要退订,请在收到邮件的 24 小时内点击该<a
|
||||
href="{{unsubscribeUrl}}" rel="noopener noreferrer" target="_blank">链接</a>退订。
|
||||
若要退订,请点击该<a
|
||||
th:href="${unsubscribeUrl}" rel="noopener noreferrer" target="_blank">链接</a>退订。
|
||||
This email is automatically sent by the system.
|
||||
Please do not reply to this email.
|
||||
The content of the email is only for forwarding the
|
||||
commenter's content and does not represent
|
||||
the position of the webmaster of this blog.
|
||||
To unsubscribe, please click this
|
||||
<a href="{{unsubscribeUrl}}" rel="noopener noreferrer" target="_blank">link</a>
|
||||
within 24 hours of receiving the email.
|
||||
<a th:href="${unsubscribeUrl}" rel="noopener noreferrer" target="_blank">link</a>.
|
||||
</i>
|
||||
</p>
|
||||
</div>
|
||||
Reference in New Issue
Block a user