邮件延迟发送功能优化

This commit is contained in:
2026-06-29 17:15:38 +08:00
parent 32304cdca0
commit f14a9f70a8
21 changed files with 1457 additions and 1026 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
<groupId>me.qwq</groupId> <groupId>me.qwq</groupId>
<artifactId>doghouse</artifactId> <artifactId>doghouse</artifactId>
<version>1.3.0</version> <version>1.3.1</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>Doghouse</name> <name>Doghouse</name>
@@ -8,6 +8,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.EnableAspectJAutoProxy; import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -15,6 +16,7 @@ import lombok.extern.slf4j.Slf4j;
@MapperScan("me.qwq.doghouse.dao") @MapperScan("me.qwq.doghouse.dao")
@SpringBootApplication @SpringBootApplication
@EnableAsync(proxyTargetClass=true) @EnableAsync(proxyTargetClass=true)
@EnableScheduling
@EnableCaching(proxyTargetClass=true) @EnableCaching(proxyTargetClass=true)
@EnableAspectJAutoProxy(exposeProxy = true) @EnableAspectJAutoProxy(exposeProxy = true)
@Slf4j @Slf4j
@@ -36,7 +36,6 @@ public class DataSourcePostProcessor implements BeanPostProcessor {
* <p> * <p>
* application 指定的 jdbc url 位置存在则使用对应位置,且后续一直以该路径为准(包括调试时修改的数据)。 * application 指定的 jdbc url 位置存在则使用对应位置,且后续一直以该路径为准(包括调试时修改的数据)。
* 若不存在则从 classpath 中取初始数据库复制到 jdbc url 指定的位置 * 若不存在则从 classpath 中取初始数据库复制到 jdbc url 指定的位置
* @param dataSource
*/ */
@Override @Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
+6 -133
View File
@@ -1,30 +1,22 @@
package me.qwq.doghouse.component; package me.qwq.doghouse.component;
import java.security.SecureRandom;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; 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.RBucket;
import org.redisson.api.RedissonClient; 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.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.sqids.Sqids; 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.entity.config.SiteConfig;
import me.qwq.doghouse.util.Cryptos; import me.qwq.doghouse.util.Cryptos;
/** /**
* Redis Key Tool * Redis key tool.
* @author Doghole * @author Doghole
*
*/ */
@Component @Component
@Slf4j @Slf4j
@@ -34,115 +26,13 @@ public class Rk {
static RedissonClient redisson; static RedissonClient redisson;
public static String SYSTEM_PREFIX; public static String SYSTEM_PREFIX;
static final String NOTIFY_KEYSPACE_EVENTS = "notify-keyspace-events",
x = "x", E = "E";
@Autowired @Autowired
public void setService(SiteConfig siteConfig, RedissonClient redisson) { public void setService(SiteConfig siteConfig, RedissonClient redisson) {
Rk.siteConfig = siteConfig; Rk.siteConfig = siteConfig;
Rk.redisson = redisson; Rk.redisson = redisson;
}
@PostConstruct
private void init() {
SYSTEM_PREFIX = "doghouse:" + siteConfig.getAddress().hashCode() + ":"; 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 { public static class Gravatar {
@@ -161,32 +51,20 @@ public class Rk {
return SYSTEM_PREFIX + ID + subfix; return SYSTEM_PREFIX + ID + subfix;
} }
/**
* 获取对应 hash 和尺寸 size 的头像缓存 Bucket
* @param email
* @param size
* @return
*/
public static RBucket<Object> getGravatarBucket(String dogHash, int size) { public static RBucket<Object> getGravatarBucket(String dogHash, int size) {
return redisson.getBucket(getGravatarKey(GRAVATAR_PREFIX + dogHash + ":" + size)); return redisson.getBucket(getGravatarKey(GRAVATAR_PREFIX + dogHash + ":" + size));
} }
/**
* 清除 gravatar 图片缓存
*/
public static void clearGravatarCache() { public static void clearGravatarCache() {
for (String name : redisson.getKeys().getKeysByPattern(getGravatarKey(GRAVATAR_PREFIX + "*"))) { for (String name : redisson.getKeys().getKeysByPattern(getGravatarKey(GRAVATAR_PREFIX + "*"))) {
redisson.getBucket(name).deleteAsync(); redisson.getBucket(name).deleteAsync();
} }
} }
/**
* 获取对应 email 的 hash,并且提前在 Redis 中存储对应关系
* @param email
* @return
*/
public static String prepareDogHash(String email) { public static String prepareDogHash(String email) {
if (email == null) email = ""; if (email == null) {
email = "";
}
email = email.toLowerCase(Locale.ENGLISH); email = email.toLowerCase(Locale.ENGLISH);
RBucket<String> gravatarHashBucket = redisson.getBucket(getGravatarKey(EMAIL_HASH_PREFIX + email)); RBucket<String> gravatarHashBucket = redisson.getBucket(getGravatarKey(EMAIL_HASH_PREFIX + email));
@@ -228,11 +106,6 @@ public class Rk {
return dogHash; return dogHash;
} }
/**
* 根据 hash 获取 gravatar md5
* @param dogHash
* @return
*/
public static String getRealHash(String dogHash) { public static String getRealHash(String dogHash) {
RBucket<String> email = redisson.getBucket(getGravatarKey(PRIVATE_HASH_PREFIX + dogHash)); RBucket<String> email = redisson.getBucket(getGravatarKey(PRIVATE_HASH_PREFIX + dogHash));
return email.get(); return email.get();
@@ -4,13 +4,12 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.yulichang.wrapper.MPJLambdaWrapper; import com.github.yulichang.wrapper.MPJLambdaWrapper;
import jodd.io.FileUtil;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.config.DoghouseProperties; import me.qwq.doghouse.config.DoghouseProperties;
import me.qwq.doghouse.controller.common.BaseController; import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.entity.Asset; import me.qwq.doghouse.entity.Asset;
import me.qwq.doghouse.entity.config.SiteConfig;
import me.qwq.doghouse.entity.Post; import me.qwq.doghouse.entity.Post;
import me.qwq.doghouse.entity.config.SiteConfig;
import me.qwq.doghouse.enums.FileTypeEnum; import me.qwq.doghouse.enums.FileTypeEnum;
import me.qwq.doghouse.exception.RException; import me.qwq.doghouse.exception.RException;
import me.qwq.doghouse.pojo.dto.LayPageReq; 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.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller; 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.springframework.web.multipart.MultipartFile;
import org.sqids.Sqids; import org.sqids.Sqids;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.BufferedReader;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
@@ -43,9 +46,8 @@ import java.util.concurrent.TimeUnit;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
/** /**
* 资产控制器 * Asset controller.
* @author Doghole * @author Doghole
*/ */
@Controller @Controller
@@ -53,9 +55,11 @@ import javax.imageio.ImageIO;
@Slf4j @Slf4j
public class AssetController extends BaseController { 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 @Autowired
DoghouseProperties doghouseProperties; DoghouseProperties doghouseProperties;
@@ -67,7 +71,7 @@ public class AssetController extends BaseController {
SiteConfig siteConfig; SiteConfig siteConfig;
/** /**
* 主页 * Main page.
* @return * @return
*/ */
@GetMapping({"", "/", "/index"}) @GetMapping({"", "/", "/index"})
@@ -90,9 +94,8 @@ public class AssetController extends BaseController {
return result; return result;
} }
/** /**
* 上传文件 * Upload file.
*/ */
@ResponseBody @ResponseBody
@PostMapping("/uploadFile") @PostMapping("/uploadFile")
@@ -100,117 +103,79 @@ public class AssetController extends BaseController {
@RequestParam(name = "file") MultipartFile file, @RequestParam(name = "file") MultipartFile file,
@RequestParam(name = "postId", required = false) Long postId) { @RequestParam(name = "postId", required = false) Long postId) {
String msg = "文件上传失败"; if (file == null || file.isEmpty()) {
String suffix = getSuffix(file); return R.badRequest("上传文件不能为空");
}
if (suffix == null) { String suffix = getSuffix(file);
return R.badRequest(msg); if (StringUtils.isBlank(suffix)) {
return R.badRequest("无法识别文件扩展名");
}
if (FileTypeEnum.isUploadDenied(suffix)) {
return R.badRequest("不允许上传该类型文件");
} }
String prefix = generateFilePrefix(); String prefix = generateFilePrefix();
String filename = prefix + '.' + suffix; String filename = prefix + '.' + suffix;
String originalFilename = file.getOriginalFilename(); String originalFilename = StringUtils.defaultIfBlank(file.getOriginalFilename(), filename);
String originalPrefix = originalFilename.substring(0, originalFilename.length() - String originalPrefix = getOriginalPrefix(originalFilename, suffix);
suffix.length() - 1);
Calendar calendar = Calendar.getInstance(); Calendar calendar = Calendar.getInstance();
String yearAndMonth = String.format("%d/%02d", calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH) + 1); 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 { try {
if (!dir.exists() && !dir.mkdirs()) { if (!filePlacedDirPath.startsWith(uploadRoot) || !destPath.startsWith(filePlacedDirPath)) {
msg = "创建文件夹 " + dir + " 失败"; throw RException.badRequest("非法上传路径");
log.error(msg);
return R.internalServerError(msg);
} }
// file.transferTo(destFile); // 解决 jetty 上传文件问题 Files.createDirectories(filePlacedDirPath);
FileUtil.writeBytes(destFile, file.getBytes()); try (var input = file.getInputStream()) {
String fileUrl = Files.copy(input, destPath, StandardCopyOption.REPLACE_EXISTING);
Paths.get(doghouseProperties.getFileSqlPath(), yearAndMonth, filename) }
.toString().replace('\\', '/'); storedPath = destPath;
String fileUrl = getSqlPath(yearAndMonth, filename);
FileTypeEnum fileType = FileTypeEnum.getFileType(suffix); FileTypeEnum fileType = FileTypeEnum.getFileType(suffix);
LocalDateTime now = LocalDateTime.now();
asset Asset asset = new Asset()
.setFromPostId(postId) .setFromPostId(postId)
.setTitle(file.getOriginalFilename()) .setTitle(originalFilename)
.setCreateTime(LocalDateTime.now()) .setCreateTime(now)
.setFileType(fileType) .setFileType(fileType)
.setFileSize(destFile.length()) .setFileSize(Files.size(storedPath))
.setUpdateTime(LocalDateTime.now()) .setUpdateTime(now)
.setPath(fileUrl); .setPath(fileUrl);
if (fileType == FileTypeEnum.IMAGE) { if (fileType == FileTypeEnum.IMAGE) {
BufferedImage image = ImageIO.read(destFile); storedPath = prepareImageAsset(asset, suffix, storedPath, prefix, originalPrefix, yearAndMonth);
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);
}
} }
if (assetService.save(asset)) { if (assetService.save(asset)) {
return R.ok(asset); return R.ok(asset);
} }
deleteQuietly(storedPath);
throw RException.badRequest("文件上传成功但保存失败"); 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 * @param asset
* @return * @return
*/ */
@@ -218,17 +183,17 @@ public class AssetController extends BaseController {
@PostMapping("/update") @PostMapping("/update")
R<?> update(Asset asset) { R<?> update(Asset asset) {
if (Objects.isNull(asset) || Objects.isNull(asset.getAssetId())) { if (Objects.isNull(asset) || Objects.isNull(asset.getAssetId())) {
throw RException.badRequest("提供的资不能为空!"); throw RException.badRequest("提供的资不能为空!");
} }
LambdaUpdateWrapper<Asset> uw = new LambdaUpdateWrapper<>(); LambdaUpdateWrapper<Asset> uw = new LambdaUpdateWrapper<>();
uw.eq(Asset::getAssetId, asset.getAssetId()) uw.eq(Asset::getAssetId, asset.getAssetId())
.set(Asset::getTitle, asset.getTitle()) .set(Asset::getTitle, asset.getTitle())
.set(Asset::getDescription, asset.getDescription()); .set(Asset::getDescription, asset.getDescription());
return R.judge(assetService.update(uw), "更新资信息失败!"); return R.judge(assetService.update(uw), "更新资信息失败!");
} }
/** /**
* 删除资产 * Delete asset.
* @param assetId * @param assetId
* @return * @return
*/ */
@@ -236,24 +201,29 @@ public class AssetController extends BaseController {
@PostMapping("/delete") @PostMapping("/delete")
R<?> delete(Long assetId) { R<?> delete(Long assetId) {
if (Objects.isNull(assetId)) { if (Objects.isNull(assetId)) {
throw RException.badRequest("提供的资不能为空!"); throw RException.badRequest("提供的资不能为空!");
} }
Asset asset = assetService.getById(assetId); Asset asset = assetService.getById(assetId);
if (Objects.isNull(asset)) { if (Objects.isNull(asset)) {
log.info("试图删除已不存在于数据库内的资"); log.info("试图删除已不存在于数据库内的资");
return R.ok(); return R.ok();
} }
String uploadPath = doghouseProperties.getFileUploadPath(asset);
File file = new File(uploadPath); Path file = resolveAssetUploadPath(asset);
if (file.exists()) { if (Files.exists(file)) {
return R.judge(file.delete() && assetService.removeById(assetId), "删除文件失败"); try {
Files.delete(file);
}
catch (IOException e) {
log.warn("删除文件失败 {}", file, e);
throw RException.badRequest("删除文件失败");
}
} }
return R.judge(assetService.removeById(assetId), "删除文件失败"); return R.judge(assetService.removeById(assetId), "删除文件失败");
} }
/** /**
* 解绑资产 * Unlink asset.
* @param assetId * @param assetId
* @return * @return
*/ */
@@ -261,15 +231,15 @@ public class AssetController extends BaseController {
@PostMapping("/unlink") @PostMapping("/unlink")
R<?> unlink(Long assetId) { R<?> unlink(Long assetId) {
if (Objects.isNull(assetId)) { if (Objects.isNull(assetId)) {
throw RException.badRequest("提供的资不能为空!"); throw RException.badRequest("提供的资不能为空!");
} }
Asset asset = assetService.getById(assetId); Asset asset = assetService.getById(assetId);
if (Objects.isNull(asset)) { if (Objects.isNull(asset)) {
log.info("试图操作已不存在于数据库内的资"); log.info("试图操作已不存在于数据库内的资");
return R.ok(); return R.ok();
} }
if (Objects.isNull(asset.getFromPostId())) { if (Objects.isNull(asset.getFromPostId())) {
return R.ok("该资并未绑定 Post"); return R.ok("该资并未绑定 Post");
} }
return R.judge( 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) { if (file == null) {
return null; return null;
} }
String fileName = file.getOriginalFilename(); String fileName = file.getOriginalFilename();
if (StringUtils.isEmpty(fileName)) { if (StringUtils.isBlank(fileName)) {
return null; return null;
} }
int lastIndexOfDot = fileName.lastIndexOf('.'); int lastIndexOfDot = fileName.lastIndexOf('.');
if (lastIndexOfDot == -1 || lastIndexOfDot == fileName.length() - 1) { if (lastIndexOfDot == -1 || lastIndexOfDot == fileName.length() - 1) {
return ""; return "";
} }
return fileName.substring(lastIndexOfDot + 1).toLowerCase(); return FileTypeEnum.normalizeExtension(fileName.substring(lastIndexOfDot + 1));
} }
/** /**
* 使用随机 Long + Sqids 作为文件名 * Use timestamp + random Sqids as filename prefix.
* @param file
* @return * @return
*/ */
public static String generateFilePrefix() { public static String generateFilePrefix() {
@@ -315,9 +284,93 @@ public class AssetController extends BaseController {
return sb.toString(); return sb.toString();
} }
private static File getAvif(String suffix, private Path prepareImageAsset(
String originalFilePath, String finalPrefix, Path filePlacedDirPath) { 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)) { if (!"jpg".equals(suffix) && !"jpeg".equals(suffix) && !"png".equals(suffix)) {
return null; return null;
} }
@@ -336,12 +389,9 @@ public class AssetController extends BaseController {
return executeFile(avifCommand, avifPath); return executeFile(avifCommand, avifPath);
} }
private static File getWebp(String suffix, String originalFilePath, String finalPrefix, Path filePlacedDirPath) {
private static File getWebp(String suffix, suffix = FileTypeEnum.normalizeExtension(suffix);
String originalFilePath, String finalPrefix, Path filePlacedDirPath) { if (!FileTypeEnum.isImageCompressible(suffix)) {
if (!"jpg".equals(suffix) && !"jpeg".equals(suffix) && !"png".equals(suffix) && !"gif".equals(suffix)) {
return null; return null;
} }
@@ -367,22 +417,20 @@ public class AssetController extends BaseController {
private static File executeFile(List<String> command, String filePath) { private static File executeFile(List<String> command, String filePath) {
ProcessBuilder pb = new ProcessBuilder(command); ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(true); pb.redirectOutput(ProcessBuilder.Redirect.DISCARD);
pb.redirectError(ProcessBuilder.Redirect.DISCARD);
try { try {
Process process = pb.start(); Process process = pb.start();
boolean finished = process.waitFor(IMAGE_ENCODE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
try (var reader = new BufferedReader( if (!finished) {
new InputStreamReader(process.getInputStream()))) { process.destroyForcibly();
String line; log.warn("Execution timed out, command {}", command);
while ((line = reader.readLine()) != null) { return null;
log.debug("[img-encode] {}", line);
}
} }
int exitCode = process.waitFor(15, TimeUnit.SECONDS) ? process.exitValue() : -1; int exitCode = process.exitValue();
if (exitCode == 0) { if (exitCode == 0) {
File out = FileUtil.file(filePath); File out = new File(filePath);
if (!out.exists()) { if (!out.exists()) {
log.warn("Command finished with code 0, but output file not found: {}", filePath); log.warn("Command finished with code 0, but output file not found: {}", filePath);
return null; return null;
@@ -392,9 +440,64 @@ public class AssetController extends BaseController {
log.warn("Execution failed with exit code: {}, command {}", exitCode, command); log.warn("Execution failed with exit code: {}, command {}", exitCode, command);
return null; return null;
} }
catch (IOException | InterruptedException e) { catch (IOException e) {
log.warn("Cannot execute command {}, {}", command, 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; 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; 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.controller.common.BaseController;
import me.qwq.doghouse.entity.*;
import me.qwq.doghouse.entity.config.MailConfig; import me.qwq.doghouse.entity.config.MailConfig;
import me.qwq.doghouse.service.*; import me.qwq.doghouse.service.*;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.redisson.api.RBucket;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Locale;
/** /**
@@ -25,7 +18,7 @@ import java.util.Locale;
@RequestMapping("/system") @RequestMapping("/system")
public class VisitorController extends BaseController { public class VisitorController extends BaseController {
static final String TOKEN_ERROR = "token 不存在或已过期"; static final String TOKEN_ERROR = "token 不存在或已被使用";
static final String MAIL_DISABLED = "站点未开启邮件功能"; static final String MAIL_DISABLED = "站点未开启邮件功能";
@@ -53,8 +46,7 @@ public class VisitorController extends BaseController {
return render(TOKEN_ERROR, null); return render(TOKEN_ERROR, null);
} }
RBucket<String> uuidBucket = Rk.Mail.getMgrUuidBucket(token); String mail = commentService.getUnsubscribeEmail(token);
String mail = uuidBucket.get();
if (StringUtils.isEmpty(mail)) { if (StringUtils.isEmpty(mail)) {
return render(TOKEN_ERROR, null); return render(TOKEN_ERROR, null);
} }
@@ -64,11 +56,9 @@ public class VisitorController extends BaseController {
} }
if ("POST".equals(method)) { if ("POST".equals(method)) {
uuidBucket.delete(); if (commentService.unsubscribeByToken(token) == null) {
commentService.update(new LambdaUpdateWrapper<Comment>() return render(TOKEN_ERROR, null);
.eq(Comment::getEmail, mail.toLowerCase(Locale.ROOT)) }
.set(Comment::isSubscribeReply, false));
return render("操作成功", 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") @TableField("subscribe_reply")
private boolean subscribeReply = false; private boolean subscribeReply = false;
/**
* 邮件退订令牌
*/
@TableField("unsubscribe_token")
private String unsubscribeToken;
@TableField("mail_sent") @TableField("mail_sent")
private boolean mailSent = false; 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; package me.qwq.doghouse.enums;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
@@ -10,7 +11,7 @@ import me.qwq.doghouse.annotation.StaticAttribute;
import me.qwq.doghouse.entity.Asset; import me.qwq.doghouse.entity.Asset;
/** /**
* 文件类型枚举 * File category enum.
**/ **/
@StaticAttribute @StaticAttribute
public enum FileTypeEnum implements IQueryableEnum { public enum FileTypeEnum implements IQueryableEnum {
@@ -26,6 +27,29 @@ public enum FileTypeEnum implements IQueryableEnum {
private final int status; private final int status;
private final String note; 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) { FileTypeEnum(int status, String note) {
this.status = status; this.status = status;
this.note = note; this.note = note;
@@ -47,115 +71,47 @@ public enum FileTypeEnum implements IQueryableEnum {
} }
public static FileTypeEnum getFileType(String ext) { public static FileTypeEnum getFileType(String ext) {
ext = normalizeExtension(ext);
if (StringUtils.isEmpty(ext)) { if (StringUtils.isEmpty(ext)) {
return OTHER; return OTHER;
} }
ext = ext.toLowerCase(); if (IMAGE_EXTENSIONS.contains(ext)) {
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":
return IMAGE; 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.Wrapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; 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.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.github.yulichang.toolkit.LambdaUtils; import com.github.yulichang.toolkit.LambdaUtils;
@@ -32,14 +33,18 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.aop.framework.AopContext; import org.springframework.aop.framework.AopContext;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager; import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Cacheable;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
/** /**
@@ -60,6 +65,9 @@ public class CommentService extends BaseServiceImpl<CommentMapper, Comment> {
@Autowired @Autowired
CacheManager cacheManager; CacheManager cacheManager;
@Autowired
JdbcTemplate jdbcTemplate;
/** /**
* 重写以启用缓存,类内调用需要 proxy() * 重写以启用缓存,类内调用需要 proxy()
*/ */
@@ -237,11 +245,11 @@ public class CommentService extends BaseServiceImpl<CommentMapper, Comment> {
* 按照从子到父的顺序,递归获取从指定 commentId 到父级的所有 Comment * 按照从子到父的顺序,递归获取从指定 commentId 到父级的所有 Comment
* <p>类内调用需要 proxy()</p> * <p>类内调用需要 proxy()</p>
* @param commentId 指定的 commentId * @param commentId 指定的 commentId
* @param containsSelf 返回列表是否包含本级 * @param includeSelf 返回列表是否包含本级
* @return * @return
*/ */
@Cacheable(value=CacheConstants.Comments.LIST_FROM_CHILD_TO_PARENT) @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<>(); List<Comment> list = new ArrayList<>();
Comment post = getOne( Comment post = getOne(
new LambdaQueryWrapper<Comment>() new LambdaQueryWrapper<Comment>()
@@ -251,7 +259,7 @@ public class CommentService extends BaseServiceImpl<CommentMapper, Comment> {
if (post == null) { if (post == null) {
return list; return list;
} }
if (containsSelf) { if (includeSelf) {
list.add(post); list.add(post);
} }
Long parentId = post.getReplyTo(); Long parentId = post.getReplyTo();
@@ -330,6 +338,59 @@ public class CommentService extends BaseServiceImpl<CommentMapper, Comment> {
return (long) distinctEmails.size(); 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 Tag.Comments
}) })
public boolean save(Comment entity) { public boolean save(Comment entity) {
prepareUnsubscribeToken(entity);
Long replyTo = entity.getReplyTo(); Long replyTo = entity.getReplyTo();
if (replyTo != -1L) { if (replyTo != -1L) {
Comment replyToComment = proxy().getById(replyTo); Comment replyToComment = proxy().getById(replyTo);
@@ -356,6 +418,7 @@ public class CommentService extends BaseServiceImpl<CommentMapper, Comment> {
Tag.Comments Tag.Comments
}) })
public boolean saveOrUpdate(Comment entity) { public boolean saveOrUpdate(Comment entity) {
prepareUnsubscribeToken(entity);
Long replyTo = entity.getReplyTo(); Long replyTo = entity.getReplyTo();
if (replyTo == null) entity.setReplyTo(-1L); if (replyTo == null) entity.setReplyTo(-1L);
else if (replyTo != -1L) { else if (replyTo != -1L) {
@@ -380,4 +443,19 @@ public class CommentService extends BaseServiceImpl<CommentMapper, Comment> {
private CommentService proxy() { private CommentService proxy() {
return (CommentService) AopContext.currentProxy(); 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.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.Proxy; import java.net.Proxy;
import java.security.SecureRandom; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.temporal.Temporal;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Properties; import java.util.Properties;
import java.util.Set; import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.mail.Authenticator; import javax.mail.Authenticator;
import javax.mail.Message.RecipientType;
import javax.mail.MessagingException;
import javax.mail.PasswordAuthentication; import javax.mail.PasswordAuthentication;
import javax.mail.Session; import javax.mail.Session;
import javax.mail.Transport; import javax.mail.Transport;
import javax.mail.Message.RecipientType;
import javax.mail.MessagingException;
import javax.mail.internet.InternetAddress; import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage; import javax.mail.internet.MimeMessage;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.redisson.api.ExpiredObjectListener; import org.apache.commons.text.StringEscapeUtils;
import org.redisson.api.RBucket;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.sqids.Sqids; import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.StringTemplateResolver;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.component.Rk;
import me.qwq.doghouse.entity.Comment; 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.MailConfig;
import me.qwq.doghouse.entity.config.NetworkConfig; import me.qwq.doghouse.entity.config.NetworkConfig;
import me.qwq.doghouse.entity.config.SiteConfig; import me.qwq.doghouse.entity.config.SiteConfig;
import me.qwq.doghouse.entity.Post;
import me.qwq.doghouse.enums.CommentStatusEnum; import me.qwq.doghouse.enums.CommentStatusEnum;
import me.qwq.doghouse.enums.MailTaskTypeEnum;
import me.qwq.doghouse.service.post.PostService; import me.qwq.doghouse.service.post.PostService;
import me.qwq.doghouse.util.MarkDownUtils;
/**
* 邮件服务
* @author Doghole
*
*/
@Service @Service
@Slf4j @Slf4j
public class MailService { public class MailService {
static final String CONTENT_FORMAT = "text/html;charset=UTF-8"; static final String CONTENT_FORMAT = "text/html;charset=UTF-8";
static final String REPLY_SUBSCRIPTION_TEMPLATE = "system/mail/reply-subscription.html"; static final String REPLY_SUBSCRIPTION_TEMPLATE = "system/mail/reply-subscription.html";
static final TemplateEngine MAIL_TEMPLATE_ENGINE = createMailTemplateEngine();
public enum MailTaskResult {
SENT,
CANCELLED
}
@Autowired @Autowired
MailConfig mailConfig; MailConfig mailConfig;
@@ -74,8 +71,26 @@ public class MailService {
PostService postService; PostService postService;
@Autowired @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) { public Session getMailSession(boolean debug) {
String username = mailConfig.getUsername(); String username = mailConfig.getUsername();
@@ -109,330 +124,307 @@ public class MailService {
} }
/** /**
* 重设邮件任务延迟,一般在修改邮件设置后触发 * 在邮件间隔设置变更后,重设邮件任务延时
*/ */
public void resetTaskDelay() { public void resetTaskDelay() {
mailTaskService.resetReplyTaskDelay(mailConfig.getMailDuration());
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()));
}
}
}
} }
/** /**
* 清除评论对应的延迟 * 取消可编辑评论的待处理邮件任务
* @param comment * @param comment
* @return * @return 是否存在待处理任务并已将其取消
*/ */
public boolean clearTaskDelay(Comment comment) { public boolean clearTaskDelay(Comment comment) {
RBucket<Object> taskBucket = Rk.Mail.getTaskBucket(comment.getId()); if (comment == null || comment.getId() == null) {
return taskBucket.clearExpire() || taskBucket.isExists(); return false;
}
return mailTaskService.cancelOutstandingTasks(comment.getId(), MailTaskTypeEnum.REPLY_SUBSCRIPTION);
} }
/** /**
* 添加或更新发送队列 * 添加或更新邮件推送任务
* @param comment * @param comment 需要处理的评论
*/ */
public void addOrUpdateSendQueue(Comment comment) { public void addOrUpdateSendQueue(Comment comment) {
if (comment == null || comment.getId() == null) {
return;
}
if (comment.isMailSent()) { if (comment.isMailSent()) {
log.info("评论 ID 为 {} 的通知已发送,不会再次发送", comment.getId()); log.info("ID 为 {} 的评论通知已发送,不会再次发送", comment.getId());
return; return;
} }
RBucket<Object> taskBucket = Rk.Mail.getTaskBucket(comment.getId()); if (comment.getIsDeleted() || comment.getCommentStatus() != CommentStatusEnum.RELEASED) {
RBucket<Comment> commentCache = Rk.Mail.getTaskCommentBucket(comment.getId()); log.info("ID 为 {} 的评论已删除或未发布,取消其回复订阅通知任务", comment.getId());
mailTaskService.cancelOutstandingTasks(comment.getId(), MailTaskTypeEnum.REPLY_SUBSCRIPTION);
if (comment.getIsDeleted()) {
// 已删除,需要删除其任务
log.info("评论 ID 为 {} 的评论已删除,清除可能存在的通知任务", comment.getId());
taskBucket.deleteAsync();
commentCache.deleteAsync();
return; return;
} }
if (taskBucket.isExists() Set<String> recipients = getReplySubscriptionRecipients(comment);
&& comment.getCommentStatus() != CommentStatusEnum.RELEASED) { if (recipients.isEmpty()) {
// 已存在,但变更了评论状态,不为发布状态,需要删除其任务 mailTaskService.cancelOutstandingTasks(comment.getId(), MailTaskTypeEnum.REPLY_SUBSCRIPTION);
log.info("评论 ID 为 {} 的评论状态修改为 {},清除其通知任务", log.debug("ID 为 {} 的评论未找到回复订阅通知收件人", comment.getId());
comment.getId(), comment.getCommentStatusNote());
taskBucket.deleteAsync();
commentCache.deleteAsync();
return; return;
} }
if (taskBucket.isExists()) { LocalDateTime sendAfter = getSendAfter(comment);
// 已存在,只更新 commentCache 和时间 mailTaskService.replaceReplyTasks(comment.getId(), recipients, sendAfter);
log.info("ID 为 {} 的评论回复订阅通知任务已提交,收件人数 {}", comment.getId(), recipients.size());
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());
} }
/** /**
* 发送新评论通知到管理员邮箱 * 新评论发送管理员通知
* @param comment * @param comment
*/ */
public void sendAdminNotification(Comment comment) { public void sendAdminNotification(Comment comment) {
if (comment == null || comment.getId() == null) {
if (!mailConfig.isMailOn()) {
log.debug("邮件功能未开启,评论 ID 为 {} 的管理邮件将不会被发送", comment.getId());
return; return;
} }
if (!mailConfig.isMailOn()) {
Post post = postService.getByIdOrName(comment.getPostId(), comment.getPostPageName()); log.debug("邮件功能未开启,评论 ID 为 {} 的管理员邮件不会入队", comment.getId());
return;
RBucket<Object> expiredKey = Rk.Mail.getRandomExpirationBucket(); }
expiredKey.addListener(new ExpiredObjectListener() { mailTaskService.enqueueAdminTask(comment.getId(), mailConfig.getAdminAddress());
@Override log.debug("ID 为 {} 的评论的管理员邮件任务已提交", comment.getId());
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);
} }
/** /**
* 读取资源文件 * 执行指定邮件任务,并根据任务类型分派到对应发送逻辑。
* @param fileName * @param task 待执行的邮件任务
* @return * @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) { String getResourceText(String fileName) {
ClassPathResource resource = new ClassPathResource(fileName); ClassPathResource resource = new ClassPathResource(fileName);
String content = null;
try (InputStream inputStream = resource.getInputStream()) { try (InputStream inputStream = resource.getInputStream()) {
content = new String(inputStream.readAllBytes()); return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
} }
catch (IOException e) { catch (IOException e) {
log.error("读取 resource 文件 " + fileName + " 时发生错误", 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 * @param id
*/ */
public static void addPostId(HttpServletRequest request, Long id) { public static void addPostId(HttpServletRequest request, Long id) {
Set<Long> postIdSet = getPostIdSet(request); addPostId(request.getSession(), id);
postIdSet.add(id);
request.getSession().setAttribute(POST_ID_SET, postIdSet);
} }
/** /**
@@ -145,6 +143,7 @@ public class SessionUtils {
* @param id * @param id
*/ */
public static void addPostId(HttpSession session, Long id) { public static void addPostId(HttpSession session, Long id) {
if (session == null) return;
Set<Long> postIdSet = getPostIdSet(session); Set<Long> postIdSet = getPostIdSet(session);
postIdSet.add(id); postIdSet.add(id);
session.setAttribute(POST_ID_SET, postIdSet); session.setAttribute(POST_ID_SET, postIdSet);
@@ -197,22 +196,21 @@ public class SessionUtils {
} }
/** /**
* 指定 Session 中添加指定评论 ID * 指定 Session 中添加指定评论 ID, 使得同 session 用户可以管理自己的评论
* @param request * @param request 相关请求, 从中提取 session
* @param id * @param id
*/ */
public static void addCommentId(HttpServletRequest request, Long id) { public static void addCommentId(HttpServletRequest request, Long id) {
Set<Long> commentIdList = getCommentIdSet(request); addCommentId(request.getSession(), id);
commentIdList.add(id);
request.getSession().setAttribute(COMMENT_ID_LIST, commentIdList);
} }
/** /**
* 指定 Session 中添加指定评论 ID * 指定 Session 中添加指定评论 ID, 使得同 session 用户可以管理自己的评论
* @param session * @param session 相关 session
* @param id * @param id
*/ */
public static void addCommentId(HttpSession session, Long id) { public static void addCommentId(HttpSession session, Long id) {
if (session == null) return;
Set<Long> commentIdList = getCommentIdSet(session); Set<Long> commentIdList = getCommentIdSet(session);
commentIdList.add(id); commentIdList.add(id);
session.setAttribute(COMMENT_ID_LIST, commentIdList); session.setAttribute(COMMENT_ID_LIST, commentIdList);
+1
View File
@@ -1,4 +1,5 @@
server: server:
address: 127.0.0.1 # 此处是不开 0.0.0.0, 避免内网访问
port: 7778 port: 7778
compression: compression:
enabled: true 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"> <div style="max-width: 800px; text-align: justify">
<p> <p>
您在 {{blogName}} 中文章 {{postName}} 下的评论有新回复。 您在 <span th:text="${blogName}">博客名</span> 中文章 <span th:text="${postName}">文章标题</span> 下的评论有新回复。
<br> <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> <br>
<blockquote> <blockquote>
<b>{{author}}</b>: {{content}}<br> <b th:text="${author}">评论者</b>: <span th:utext="${contentHtml}">评论内容</span><br>
<a href="{{detailUrl}}" rel="noopener noreferrer" target="_blank">查看详情</a> <a th:href="${detailUrl}" rel="noopener noreferrer" target="_blank">查看详情</a>
</blockquote> </blockquote>
</p> </p>
<p> <p>
<i style="color: gray; font-size: small;"> <i style="color: gray; font-size: small;">
本邮件为系统自动发出,请勿回复该邮件。 本邮件为系统自动发出,请勿回复该邮件。
邮件内容仅为转发评论者内容,不代表本博客站长立场。 邮件内容仅为转发评论者内容,不代表本博客站长立场。
若要退订,请在收到邮件的 24 小时内点击该<a 若要退订,请点击该<a
href="{{unsubscribeUrl}}" rel="noopener noreferrer" target="_blank">链接</a>退订。 th:href="${unsubscribeUrl}" rel="noopener noreferrer" target="_blank">链接</a>退订。
This email is automatically sent by the system. This email is automatically sent by the system.
Please do not reply to this email. Please do not reply to this email.
The content of the email is only for forwarding the The content of the email is only for forwarding the
commenter's content and does not represent commenter's content and does not represent
the position of the webmaster of this blog. the position of the webmaster of this blog.
To unsubscribe, please click this To unsubscribe, please click this
<a href="{{unsubscribeUrl}}" rel="noopener noreferrer" target="_blank">link</a> <a th:href="${unsubscribeUrl}" rel="noopener noreferrer" target="_blank">link</a>.
within 24 hours of receiving the email.
</i> </i>
</p> </p>
</div> </div>