From f14a9f70a86839d20e13c86099b2829bcb32883f Mon Sep 17 00:00:00 2001 From: Doghole Date: Mon, 29 Jun 2026 17:15:38 +0800 Subject: [PATCH] =?UTF-8?q?=E9=82=AE=E4=BB=B6=E5=BB=B6=E8=BF=9F=E5=8F=91?= =?UTF-8?q?=E9=80=81=E5=8A=9F=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 2 +- .../java/me/qwq/doghouse/BlogApplication.java | 2 + .../component/DataSourcePostProcessor.java | 1 - .../java/me/qwq/doghouse/component/Rk.java | 355 +++---- .../controller/admin/AssetController.java | 469 ++++++---- .../controller/blog/VisitorController.java | 20 +- .../me/qwq/doghouse/dao/MailTaskMapper.java | 8 + .../java/me/qwq/doghouse/entity/Comment.java | 6 + .../java/me/qwq/doghouse/entity/MailTask.java | 85 ++ .../me/qwq/doghouse/enums/FileTypeEnum.java | 172 ++-- .../doghouse/enums/MailTaskStatusEnum.java | 31 + .../qwq/doghouse/enums/MailTaskTypeEnum.java | 28 + .../qwq/doghouse/service/CommentService.java | 84 +- .../me/qwq/doghouse/service/MailService.java | 868 +++++++++--------- .../qwq/doghouse/service/MailTaskService.java | 212 +++++ .../me/qwq/doghouse/task/MailTaskWorker.java | 65 ++ .../me/qwq/doghouse/util/SessionUtils.java | 18 +- src/main/resources/application.yml | 1 + src/main/resources/blog.db | Bin 1003520 -> 1003520 bytes src/main/resources/mapper/MailTaskMapper.xml | 5 + .../system/mail/reply-subscription.html | 51 +- 21 files changed, 1457 insertions(+), 1026 deletions(-) create mode 100644 src/main/java/me/qwq/doghouse/dao/MailTaskMapper.java create mode 100644 src/main/java/me/qwq/doghouse/entity/MailTask.java create mode 100644 src/main/java/me/qwq/doghouse/enums/MailTaskStatusEnum.java create mode 100644 src/main/java/me/qwq/doghouse/enums/MailTaskTypeEnum.java create mode 100644 src/main/java/me/qwq/doghouse/service/MailTaskService.java create mode 100644 src/main/java/me/qwq/doghouse/task/MailTaskWorker.java create mode 100644 src/main/resources/mapper/MailTaskMapper.xml diff --git a/pom.xml b/pom.xml index 00952d0..3a93d51 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ me.qwq doghouse - 1.3.0 + 1.3.1 jar Doghouse diff --git a/src/main/java/me/qwq/doghouse/BlogApplication.java b/src/main/java/me/qwq/doghouse/BlogApplication.java index d95a4a4..aead262 100644 --- a/src/main/java/me/qwq/doghouse/BlogApplication.java +++ b/src/main/java/me/qwq/doghouse/BlogApplication.java @@ -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 diff --git a/src/main/java/me/qwq/doghouse/component/DataSourcePostProcessor.java b/src/main/java/me/qwq/doghouse/component/DataSourcePostProcessor.java index 2c31f17..508402f 100644 --- a/src/main/java/me/qwq/doghouse/component/DataSourcePostProcessor.java +++ b/src/main/java/me/qwq/doghouse/component/DataSourcePostProcessor.java @@ -36,7 +36,6 @@ public class DataSourcePostProcessor implements BeanPostProcessor { *

* application 指定的 jdbc url 位置存在则使用对应位置,且后续一直以该路径为准(包括调试时修改的数据)。 * 若不存在则从 classpath 中取初始数据库复制到 jdbc url 指定的位置 - * @param dataSource */ @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { diff --git a/src/main/java/me/qwq/doghouse/component/Rk.java b/src/main/java/me/qwq/doghouse/component/Rk.java index 379ab88..b023ca1 100644 --- a/src/main/java/me/qwq/doghouse/component/Rk.java +++ b/src/main/java/me/qwq/doghouse/component/Rk.java @@ -1,241 +1,114 @@ -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 me.qwq.doghouse.entity.config.SiteConfig; -import me.qwq.doghouse.util.Cryptos; -/** - * Redis Key Tool - * @author Doghole - * - */ -@Component -@Slf4j -public class Rk { - - static SiteConfig siteConfig; - - 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 config = (Map) 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 getAllExpirationTaskKeys() { - return redisson.getKeys() - .getKeysByPattern(getMailKey("task-")); - } - - /** - * 获取邮件推送任务的 Bucket(不包括管理员邮件) - * @param commentId - * @return - */ - public static RBucket getTaskBucket(Long commentId) { - return redisson.getBucket(getMailKey("task-" + commentId)); - } - - /** - * 获取邮件推送任务的 Comment 缓存 Bucket(不包括管理员邮件) - * @param commentId - * @return - */ - public static RBucket getTaskCommentBucket(Long commentId) { - return redisson.getBucket(getMailKey("task-" + commentId + "-comment")); - } - - /** - * 获取用户邮件订阅管理 UUID 缓存 Bucket - * @param mail - * @return 包含 uuid 的 Bucket,用以建立 email → uuid 的映射关系 - */ - public static RBucket getMgrBucket(String mail) { - return redisson.getBucket(getMailKey("mgr-addr-" + mail)); - } - - /** - * 获取用户邮件订阅管理邮箱地址缓存 Bucket - * @param uuid - * @return 包含 email 的 Bucket,用以建立 uuid → email 的映射关系 - */ - public static RBucket getMgrUuidBucket(String uuid) { - return redisson.getBucket(getMailKey("mgr-uuid-" + uuid)); - } - - /** - * 获取一个随机 Key 的用于过期事件的 Bucket - */ - public static RBucket getRandomExpirationBucket(){ - List 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 final String ID = "gravatar:"; - - public static final String GRAVATAR_PREFIX = "image:"; - - public static final String EMAIL_HASH_PREFIX = "email-hash:"; - - public static final String PRIVATE_HASH_PREFIX = "private-hash:"; - - public static final String SALT = "~D09H0U5E-9R4V474R^_^"; - - private static String getGravatarKey(String subfix) { - return SYSTEM_PREFIX + ID + subfix; - } - - /** - * 获取对应 hash 和尺寸 size 的头像缓存 Bucket - * @param email - * @param size - * @return - */ - public static RBucket 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 = ""; - email = email.toLowerCase(Locale.ENGLISH); - - RBucket gravatarHashBucket = redisson.getBucket(getGravatarKey(EMAIL_HASH_PREFIX + email)); - if (gravatarHashBucket.isExists()) { - return gravatarHashBucket.get(); - } - - byte[] hash = Cryptos.sha3((email + SALT).getBytes(), 256); - - List list = new ArrayList<>(); - - for (int i = 0; i < 4; i++) { - Long x = 0L; - for (int j = 0; j < 8; j++) { - x |= (((long) hash[i * 8 + j]) & 0xFFL) << ((7 - j) * 8); - } - i++; - for (int j = 0; j < 8; j++) { - x ^= (((long) hash[i * 8 + j]) & 0xFFL) << ((7 - j) * 8); - } - if (x < 0) { - list.add(0L); - list.add(Math.abs(x)); - } - else { - list.add(x); - } - } - - String dogHash = Sqids.builder().build().encode(list); - gravatarHashBucket.set(dogHash); - String realHash = Cryptos.sha256(email); - - redisson.getBucket(getGravatarKey(PRIVATE_HASH_PREFIX + dogHash)) - .set(realHash); - - log.info("Email: {}, real hash: {}, dog hash: {}", email, realHash, dogHash); - - return dogHash; - } - - /** - * 根据 hash 获取 gravatar md5 - * @param dogHash - * @return - */ - public static String getRealHash(String dogHash) { - RBucket email = redisson.getBucket(getGravatarKey(PRIVATE_HASH_PREFIX + dogHash)); - return email.get(); - } - } -} +package me.qwq.doghouse.component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.redisson.api.RBucket; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.sqids.Sqids; + +import lombok.extern.slf4j.Slf4j; +import me.qwq.doghouse.entity.config.SiteConfig; +import me.qwq.doghouse.util.Cryptos; + +/** + * Redis key tool. + * @author Doghole + */ +@Component +@Slf4j +public class Rk { + + static SiteConfig siteConfig; + + static RedissonClient redisson; + + public static String SYSTEM_PREFIX; + + @Autowired + public void setService(SiteConfig siteConfig, RedissonClient redisson) { + Rk.siteConfig = siteConfig; + Rk.redisson = redisson; + SYSTEM_PREFIX = "doghouse:" + siteConfig.getAddress().hashCode() + ":"; + } + + public static class Gravatar { + + public static final String ID = "gravatar:"; + + public static final String GRAVATAR_PREFIX = "image:"; + + public static final String EMAIL_HASH_PREFIX = "email-hash:"; + + public static final String PRIVATE_HASH_PREFIX = "private-hash:"; + + public static final String SALT = "~D09H0U5E-9R4V474R^_^"; + + private static String getGravatarKey(String subfix) { + return SYSTEM_PREFIX + ID + subfix; + } + + public static RBucket getGravatarBucket(String dogHash, int size) { + return redisson.getBucket(getGravatarKey(GRAVATAR_PREFIX + dogHash + ":" + size)); + } + + public static void clearGravatarCache() { + for (String name : redisson.getKeys().getKeysByPattern(getGravatarKey(GRAVATAR_PREFIX + "*"))) { + redisson.getBucket(name).deleteAsync(); + } + } + + public static String prepareDogHash(String email) { + if (email == null) { + email = ""; + } + email = email.toLowerCase(Locale.ENGLISH); + + RBucket gravatarHashBucket = redisson.getBucket(getGravatarKey(EMAIL_HASH_PREFIX + email)); + if (gravatarHashBucket.isExists()) { + return gravatarHashBucket.get(); + } + + byte[] hash = Cryptos.sha3((email + SALT).getBytes(), 256); + + List list = new ArrayList<>(); + + for (int i = 0; i < 4; i++) { + Long x = 0L; + for (int j = 0; j < 8; j++) { + x |= (((long) hash[i * 8 + j]) & 0xFFL) << ((7 - j) * 8); + } + i++; + for (int j = 0; j < 8; j++) { + x ^= (((long) hash[i * 8 + j]) & 0xFFL) << ((7 - j) * 8); + } + if (x < 0) { + list.add(0L); + list.add(Math.abs(x)); + } + else { + list.add(x); + } + } + + String dogHash = Sqids.builder().build().encode(list); + gravatarHashBucket.set(dogHash); + String realHash = Cryptos.sha256(email); + + redisson.getBucket(getGravatarKey(PRIVATE_HASH_PREFIX + dogHash)) + .set(realHash); + + log.info("Email: {}, real hash: {}, dog hash: {}", email, realHash, dogHash); + + return dogHash; + } + + public static String getRealHash(String dogHash) { + RBucket email = redisson.getBucket(getGravatarKey(PRIVATE_HASH_PREFIX + dogHash)); + return email.get(); + } + } +} diff --git a/src/main/java/me/qwq/doghouse/controller/admin/AssetController.java b/src/main/java/me/qwq/doghouse/controller/admin/AssetController.java index 60dc615..2fa2bd6 100644 --- a/src/main/java/me/qwq/doghouse/controller/admin/AssetController.java +++ b/src/main/java/me/qwq/doghouse/controller/admin/AssetController.java @@ -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,174 +46,136 @@ import java.util.concurrent.TimeUnit; import javax.imageio.ImageIO; - /** - * 资产控制器 + * Asset controller. * @author Doghole */ @Controller @RequestMapping("/admin/v2/asset") @Slf4j public class AssetController extends BaseController { - - final private static SecureRandom SECURE_RANDOM = new SecureRandom(); - - final private static Sqids SQIDS = Sqids.builder().build(); - + + private static final int IMAGE_ENCODE_TIMEOUT_SECONDS = 15; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private static final Sqids SQIDS = Sqids.builder().build(); + @Autowired DoghouseProperties doghouseProperties; - + @Autowired AssetService assetService; - + @Autowired SiteConfig siteConfig; /** - * 主页 + * Main page. * @return */ @GetMapping({"", "/", "/index"}) String index() { return "admin/asset/index"; } - + @ResponseBody @GetMapping("/list") public LayPageResp getAssetList(LayPageReq pageReq) { - + MPJLambdaWrapper ew = new MPJLambdaWrapper() .selectAll(Asset.class) .leftJoin(Post.class, Post::getPostId, Asset::getFromPostId) .orderByDesc(Asset::getCreateTime); - + IPage page = assetService.page(pageReq, ew); - + LayPageResp result = new LayPageResp<>(page); return result; } - - + /** - * 上传文件 + * Upload file. */ @ResponseBody @PostMapping("/uploadFile") R uploadFile( @RequestParam(name = "file") MultipartFile file, @RequestParam(name = "postId", required = false) Long postId) { - - String msg = "文件上传失败"; - String suffix = getSuffix(file); - - if (suffix == null) { - return R.badRequest(msg); + + if (file == null || file.isEmpty()) { + return R.badRequest("上传文件不能为空"); } - + + 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); - - File dir = filePlacedDirPath.toFile(); - File destFile = filePlacedDirPath.resolve(filename).toFile(); - Asset asset = new Asset(); + Path uploadRoot = Paths.get(doghouseProperties.getFileUploadPath()).toAbsolutePath().normalize(); + Path filePlacedDirPath = uploadRoot.resolve(yearAndMonth).normalize(); + Path storedPath = null; + Path destPath = filePlacedDirPath.resolve(filename).normalize(); + 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); - - asset - .setFromPostId(postId) - .setTitle(file.getOriginalFilename()) - .setCreateTime(LocalDateTime.now()) - .setFileType(fileType) - .setFileSize(destFile.length()) - .setUpdateTime(LocalDateTime.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); + LocalDateTime now = LocalDateTime.now(); - 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); - } + Asset asset = new Asset() + .setFromPostId(postId) + .setTitle(originalFilename) + .setCreateTime(now) + .setFileType(fileType) + .setFileSize(Files.size(storedPath)) + .setUpdateTime(now) + .setPath(fileUrl); + + if (fileType == FileTypeEnum.IMAGE) { + 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 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( @@ -277,112 +247,190 @@ public class AssetController extends BaseController { .eq(Asset::getAssetId, asset.getAssetId()) .set(Asset::getFromPostId, null)), "解绑失败"); } - + /** - * 获取文件后缀,不包含".", 返回小写 + * 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() { String dateStr = DateUtils.format(LocalDateTime.now(), "yyyyMMdd_HHmmssSSS_"); - + StringBuilder sb = new StringBuilder(); sb.append(dateStr); - + List list = new ArrayList<>(); list.add(Math.abs(SECURE_RANDOM.nextLong())); - + String id = SQIDS.encode(list); sb.append(id); 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; } - + String avifFilename = finalPrefix + ".avif"; String avifPath = filePlacedDirPath.resolve(avifFilename).toString(); List avifCommand = List.of( - "avifenc", - "-q", "60", - "--qalpha", "80", + "avifenc", + "-q", "60", + "--qalpha", "80", "-s", "6", - "-j", "all", - originalFilePath, + "-j", "all", + originalFilePath, avifPath); - + 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; } - + String webpFilename = finalPrefix + ".webp"; String webpPath = filePlacedDirPath.resolve(webpFilename).toString(); - List webpCommand = - !"gif".equals(suffix) ? - List.of("cwebp", - "-z", "9", - "-q", "100", - "-near_lossless", "60", - "-m", "6", + List webpCommand = + !"gif".equals(suffix) ? + List.of("cwebp", + "-z", "9", + "-q", "100", + "-near_lossless", "60", + "-m", "6", "-mt", originalFilePath, "-o", webpPath): - List.of("gif2webp", - "-q", "90", - "-lossy", "-min_size", - "-m", "0", + List.of("gif2webp", + "-q", "90", + "-lossy", "-min_size", + "-m", "0", "-mt", originalFilePath, "-o", webpPath); return executeFile(webpCommand, webpPath); } - + private static File executeFile(List 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()); + } + } } diff --git a/src/main/java/me/qwq/doghouse/controller/blog/VisitorController.java b/src/main/java/me/qwq/doghouse/controller/blog/VisitorController.java index 1ba86dd..8057f3b 100644 --- a/src/main/java/me/qwq/doghouse/controller/blog/VisitorController.java +++ b/src/main/java/me/qwq/doghouse/controller/blog/VisitorController.java @@ -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 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() - .eq(Comment::getEmail, mail.toLowerCase(Locale.ROOT)) - .set(Comment::isSubscribeReply, false)); - + if (commentService.unsubscribeByToken(token) == null) { + return render(TOKEN_ERROR, null); + } return render("操作成功", null); } diff --git a/src/main/java/me/qwq/doghouse/dao/MailTaskMapper.java b/src/main/java/me/qwq/doghouse/dao/MailTaskMapper.java new file mode 100644 index 0000000..642b7db --- /dev/null +++ b/src/main/java/me/qwq/doghouse/dao/MailTaskMapper.java @@ -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 { +} diff --git a/src/main/java/me/qwq/doghouse/entity/Comment.java b/src/main/java/me/qwq/doghouse/entity/Comment.java index 5a2beaf..9247f61 100644 --- a/src/main/java/me/qwq/doghouse/entity/Comment.java +++ b/src/main/java/me/qwq/doghouse/entity/Comment.java @@ -168,6 +168,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; diff --git a/src/main/java/me/qwq/doghouse/entity/MailTask.java b/src/main/java/me/qwq/doghouse/entity/MailTask.java new file mode 100644 index 0000000..b4b2543 --- /dev/null +++ b/src/main/java/me/qwq/doghouse/entity/MailTask.java @@ -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 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; +} diff --git a/src/main/java/me/qwq/doghouse/enums/FileTypeEnum.java b/src/main/java/me/qwq/doghouse/enums/FileTypeEnum.java index 59b2b03..df022de 100644 --- a/src/main/java/me/qwq/doghouse/enums/FileTypeEnum.java +++ b/src/main/java/me/qwq/doghouse/enums/FileTypeEnum.java @@ -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 IMAGE_EXTENSIONS = Set.of( + "jpg", "jpeg", "gif", "png", "apng", "bmp", "svg", "ico", "webp", "psd", "tif", "tiff", "raw", "avif"); + + private static final Set 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 DOCUMENT_EXTENSIONS = Set.of( + "doc", "docx", "dot", "dotx", "ppt", "pptx", "xls", "xlsx", "pdf", "txt", "rtf", "md"); + + private static final Set 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 APPLICATION_EXTENSIONS = Set.of( + "exe", "msi", "bat", "cmd", "apk"); + + private static final Set 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; @@ -38,124 +62,56 @@ public enum FileTypeEnum implements IQueryableEnum { public int getStatus() { return status; } - + public static FileTypeEnum getFileType(Asset asset) { if (Objects.isNull(asset)) { return OTHER; } return getFileType(asset.getExt()); } - + 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; } } diff --git a/src/main/java/me/qwq/doghouse/enums/MailTaskStatusEnum.java b/src/main/java/me/qwq/doghouse/enums/MailTaskStatusEnum.java new file mode 100644 index 0000000..c7a8de7 --- /dev/null +++ b/src/main/java/me/qwq/doghouse/enums/MailTaskStatusEnum.java @@ -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; + } +} diff --git a/src/main/java/me/qwq/doghouse/enums/MailTaskTypeEnum.java b/src/main/java/me/qwq/doghouse/enums/MailTaskTypeEnum.java new file mode 100644 index 0000000..5c0a349 --- /dev/null +++ b/src/main/java/me/qwq/doghouse/enums/MailTaskTypeEnum.java @@ -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; + } +} diff --git a/src/main/java/me/qwq/doghouse/service/CommentService.java b/src/main/java/me/qwq/doghouse/service/CommentService.java index 216b4c3..c99d561 100644 --- a/src/main/java/me/qwq/doghouse/service/CommentService.java +++ b/src/main/java/me/qwq/doghouse/service/CommentService.java @@ -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; /** @@ -59,6 +64,9 @@ public class CommentService extends BaseServiceImpl { @Autowired CacheManager cacheManager; + + @Autowired + JdbcTemplate jdbcTemplate; /** * 重写以启用缓存,类内调用需要 proxy() @@ -237,11 +245,11 @@ public class CommentService extends BaseServiceImpl { * 按照从子到父的顺序,递归获取从指定 commentId 到父级的所有 Comment *

类内调用需要 proxy()

* @param commentId 指定的 commentId - * @param containsSelf 返回列表是否包含本级 + * @param includeSelf 返回列表是否包含本级 * @return */ @Cacheable(value=CacheConstants.Comments.LIST_FROM_CHILD_TO_PARENT) - public List listFromChildToParent(Long commentId, Boolean containsSelf) { + public List listFromChildToParent(Long commentId, Boolean includeSelf) { List list = new ArrayList<>(); Comment post = getOne( new LambdaQueryWrapper() @@ -251,7 +259,7 @@ public class CommentService extends BaseServiceImpl { if (post == null) { return list; } - if (containsSelf) { + if (includeSelf) { list.add(post); } Long parentId = post.getReplyTo(); @@ -329,6 +337,59 @@ public class CommentService extends BaseServiceImpl { distinctEmails.forEach(email -> Rk.Gravatar.prepareDogHash(email)); 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() + .eq(Comment::getId, commentId) + .set(Comment::getUnsubscribeToken, token)); + return updated ? token : null; + } + + /** + * 消费指定退订令牌,并关闭该邮箱所有评论的回复订阅。 + *

只删除本次使用的令牌,其他评论上未使用的令牌会保留。

+ * @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() + .eq(Comment::getId, tokenComment.getId()) + .set(Comment::getUnsubscribeToken, null)); + update(new LambdaUpdateWrapper() + .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 { 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 { 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 { 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() + .eq(Comment::getUnsubscribeToken, token) + .last("LIMIT 1")); + } } diff --git a/src/main/java/me/qwq/doghouse/service/MailService.java b/src/main/java/me/qwq/doghouse/service/MailService.java index 3e9cbef..40618de 100644 --- a/src/main/java/me/qwq/doghouse/service/MailService.java +++ b/src/main/java/me/qwq/doghouse/service/MailService.java @@ -1,438 +1,430 @@ -package me.qwq.doghouse.service; - -import java.io.IOException; -import java.io.InputStream; -import java.net.Proxy; -import java.security.SecureRandom; -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.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.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 lombok.extern.slf4j.Slf4j; -import me.qwq.doghouse.component.Rk; -import me.qwq.doghouse.entity.Comment; -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.service.post.PostService; - -/** - * 邮件服务 - * @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"; - - @Autowired - MailConfig mailConfig; - - @Autowired - NetworkConfig networkConfig; - - @Autowired - SiteConfig siteConfig; - - @Autowired - CommentService commentService; - - @Autowired - PostService postService; - - @Autowired - RedissonClient redis; - - public Session getMailSession(boolean debug) { - - String username = mailConfig.getUsername(); - String password = mailConfig.getPassword(); - - Properties props = new Properties(); - - props.put("mail.smtp.starttls.enable", mailConfig.isTlsEnable()); - props.put("mail.smtp.host", mailConfig.getSmtpHost()); - props.put("mail.smtp.port", mailConfig.getSmtpPort()); - props.put("mail.smtp.auth", true); - - if (networkConfig.getProxyType() == Proxy.Type.SOCKS) { - props.put("mail.smtp.socks.host", networkConfig.getProxyHost()); - props.put("mail.smtp.socks.port", networkConfig.getProxyPort()); - } - else if (networkConfig.getProxyType() == Proxy.Type.HTTP) { - props.put("mail.smtp.proxy.host", networkConfig.getProxyHost()); - props.put("mail.smtp.proxy.port", networkConfig.getProxyPort()); - } - - Session session = Session.getInstance(props, new Authenticator() { - @Override - protected PasswordAuthentication getPasswordAuthentication() { - return new PasswordAuthentication(username, password); - } - }); - - session.setDebug(debug); - return session; - } - - /** - * 重设邮件任务延迟,一般在修改邮件设置后触发 - */ - public void resetTaskDelay() { - - for (String key : Rk.Mail.getAllExpirationTaskKeys()) { - if (!key.endsWith("-comment")) { - RBucket task = redis.getBucket(key); - if (mailConfig.getSendingDelay() == 0L) { - task.set(null, Duration.ofSeconds(1L)); - } - else { - task.set(null, Duration.ofMinutes(mailConfig.getSendingDelay())); - } - } - } - } - - /** - * 清除评论对应的延迟 - * @param comment - * @return - */ - public boolean clearTaskDelay(Comment comment) { - RBucket taskBucket = Rk.Mail.getTaskBucket(comment.getId()); - return taskBucket.clearExpire() || taskBucket.isExists(); - } - - /** - * 添加或更新发送队列 - * @param comment - */ - public void addOrUpdateSendQueue(Comment comment) { - - if (comment.isMailSent()) { - log.info("评论 ID 为 {} 的通知已发送,不会再次发送", comment.getId()); - return; - } - - RBucket taskBucket = Rk.Mail.getTaskBucket(comment.getId()); - RBucket commentCache = Rk.Mail.getTaskCommentBucket(comment.getId()); - - if (comment.getIsDeleted()) { - // 已删除,需要删除其任务 - log.info("评论 ID 为 {} 的评论已删除,清除可能存在的通知任务", comment.getId()); - taskBucket.deleteAsync(); - commentCache.deleteAsync(); - return; - } - - if (taskBucket.isExists() - && comment.getCommentStatus() != CommentStatusEnum.RELEASED) { - // 已存在,但变更了评论状态,不为发布状态,需要删除其任务 - log.info("评论 ID 为 {} 的评论状态修改为 {},清除其通知任务", - comment.getId(), comment.getCommentStatusNote()); - taskBucket.deleteAsync(); - commentCache.deleteAsync(); - 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 parentComments = commentService.listFromChildToParent(comment.getId(), false); - Set 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 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 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() - .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 - */ - public void sendAdminNotification(Comment comment) { - - if (!mailConfig.isMailOn()) { - log.debug("邮件功能未开启,评论 ID 为 {} 的管理邮件将不会被发送", comment.getId()); - return; - } - - Post post = postService.getByIdOrName(comment.getPostId(), comment.getPostPageName()); - - RBucket 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( - "

%s:%s
" - + "评论状态:%s
" - + "查看详情

" - + "

该邮件由系统自动发出,请勿直接回复该邮件

", - 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 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 - * @return - */ - String getResourceText(String fileName) { - ClassPathResource resource = new ClassPathResource(fileName); - String content = null; - try (InputStream inputStream = resource.getInputStream()) { - content = new String(inputStream.readAllBytes()); - } - catch (IOException e) { - log.error("读取 resource 文件 " + fileName + " 时发生错误", e); - } - return content; - } - -} +package me.qwq.doghouse.service; + +import java.io.IOException; +import java.io.InputStream; +import java.net.Proxy; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.Properties; +import java.util.Set; +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.internet.InternetAddress; +import javax.mail.internet.MimeMessage; + +import org.apache.commons.lang3.StringUtils; +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.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.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.enums.CommentStatusEnum; +import me.qwq.doghouse.enums.MailTaskTypeEnum; +import me.qwq.doghouse.service.post.PostService; +import me.qwq.doghouse.util.MarkDownUtils; + +@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; + + @Autowired + NetworkConfig networkConfig; + + @Autowired + SiteConfig siteConfig; + + @Autowired + CommentService commentService; + + @Autowired + PostService postService; + + @Autowired + 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(); + String password = mailConfig.getPassword(); + + Properties props = new Properties(); + + props.put("mail.smtp.starttls.enable", mailConfig.isTlsEnable()); + props.put("mail.smtp.host", mailConfig.getSmtpHost()); + props.put("mail.smtp.port", mailConfig.getSmtpPort()); + props.put("mail.smtp.auth", true); + + if (networkConfig.getProxyType() == Proxy.Type.SOCKS) { + props.put("mail.smtp.socks.host", networkConfig.getProxyHost()); + props.put("mail.smtp.socks.port", networkConfig.getProxyPort()); + } + else if (networkConfig.getProxyType() == Proxy.Type.HTTP) { + props.put("mail.smtp.proxy.host", networkConfig.getProxyHost()); + props.put("mail.smtp.proxy.port", networkConfig.getProxyPort()); + } + + Session session = Session.getInstance(props, new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(username, password); + } + }); + + session.setDebug(debug); + return session; + } + + /** + * 在邮件间隔设置变更后,重设邮件任务延时 + */ + public void resetTaskDelay() { + mailTaskService.resetReplyTaskDelay(mailConfig.getMailDuration()); + } + + /** + * 取消可编辑评论的待处理邮件任务 + * @param comment + * @return 是否存在待处理任务并已将其取消 + */ + public boolean clearTaskDelay(Comment comment) { + if (comment == null || comment.getId() == null) { + return false; + } + return mailTaskService.cancelOutstandingTasks(comment.getId(), MailTaskTypeEnum.REPLY_SUBSCRIPTION); + } + + /** + * 添加或更新邮件推送任务 + * @param comment 需要处理的评论 + */ + public void addOrUpdateSendQueue(Comment comment) { + if (comment == null || comment.getId() == null) { + return; + } + + if (comment.isMailSent()) { + log.info("ID 为 {} 的评论通知已发送,不会再次发送", comment.getId()); + return; + } + + if (comment.getIsDeleted() || comment.getCommentStatus() != CommentStatusEnum.RELEASED) { + log.info("ID 为 {} 的评论已删除或未发布,取消其回复订阅通知任务", comment.getId()); + mailTaskService.cancelOutstandingTasks(comment.getId(), MailTaskTypeEnum.REPLY_SUBSCRIPTION); + return; + } + + Set recipients = getReplySubscriptionRecipients(comment); + if (recipients.isEmpty()) { + mailTaskService.cancelOutstandingTasks(comment.getId(), MailTaskTypeEnum.REPLY_SUBSCRIPTION); + log.debug("ID 为 {} 的评论未找到回复订阅通知收件人", comment.getId()); + return; + } + + 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 (comment == null || comment.getId() == null) { + return; + } + if (!mailConfig.isMailOn()) { + log.debug("邮件功能未开启,评论 ID 为 {} 的管理员邮件不会入队", comment.getId()); + return; + } + mailTaskService.enqueueAdminTask(comment.getId(), mailConfig.getAdminAddress()); + log.debug("ID 为 {} 的评论的管理员邮件任务已提交", comment.getId()); + } + + /** + * 执行指定邮件任务,并根据任务类型分派到对应发送逻辑。 + * @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( + "

%s:%s
" + + "评论状态:%s
" + + "查看详情

" + + "

该邮件由系统自动发出,请勿直接回复该邮件

", + 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 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); + try (InputStream inputStream = resource.getInputStream()) { + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + catch (IOException e) { + log.error("读取 resource 文件 " + fileName + " 时发生错误", e); + return null; + } + } +} diff --git a/src/main/java/me/qwq/doghouse/service/MailTaskService.java b/src/main/java/me/qwq/doghouse/service/MailTaskService.java new file mode 100644 index 0000000..cf7c147 --- /dev/null +++ b/src/main/java/me/qwq/doghouse/service/MailTaskService.java @@ -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 { + + public static final int MAX_ATTEMPTS = 5; + private static final int DEFAULT_BATCH_SIZE = 10; + + @Transactional + public void replaceReplyTasks(Long commentId, Collection recipients, LocalDateTime sendAfter) { + cancelOutstandingTasks(commentId, MailTaskTypeEnum.REPLY_SUBSCRIPTION); + if (recipients == null || recipients.isEmpty()) { + return; + } + + LocalDateTime now = LocalDateTime.now(); + List 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 query = outstandingQuery(commentId, taskType); + boolean exists = count(query) > 0; + if (!exists) { + return false; + } + + return update(new LambdaUpdateWrapper() + .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() + .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 pollDueTasks() { + return list(new LambdaQueryWrapper() + .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() + .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() + .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() + .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() + .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 outstandingQuery(Long commentId, MailTaskTypeEnum taskType) { + return new LambdaQueryWrapper() + .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; + } +} diff --git a/src/main/java/me/qwq/doghouse/task/MailTaskWorker.java b/src/main/java/me/qwq/doghouse/task/MailTaskWorker.java new file mode 100644 index 0000000..242b762 --- /dev/null +++ b/src/main/java/me/qwq/doghouse/task/MailTaskWorker.java @@ -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() + .eq(Comment::getId, task.getCommentId()) + .set(Comment::isMailSent, true)); + } +} diff --git a/src/main/java/me/qwq/doghouse/util/SessionUtils.java b/src/main/java/me/qwq/doghouse/util/SessionUtils.java index 90b872d..1da3287 100644 --- a/src/main/java/me/qwq/doghouse/util/SessionUtils.java +++ b/src/main/java/me/qwq/doghouse/util/SessionUtils.java @@ -134,9 +134,7 @@ public class SessionUtils { * @param id */ public static void addPostId(HttpServletRequest request, Long id) { - Set 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 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 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 commentIdList = getCommentIdSet(session); commentIdList.add(id); session.setAttribute(COMMENT_ID_LIST, commentIdList); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 95451d6..d917628 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,5 @@ server: + address: 127.0.0.1 # 此处是不开 0.0.0.0, 避免内网访问 port: 7778 compression: enabled: true diff --git a/src/main/resources/blog.db b/src/main/resources/blog.db index 6cd1cdc627bcc695c8b0eee3fa3584c7f5b2147f..f9cb7038cc82d375f16314e7b3af198d00c17ee9 100644 GIT binary patch delta 4276 zcmeGfZEO=|`0jeWUfbQ?)nJM)rbhs`X?whP?RD27W*w^yG6z{kLSjt0ttZs=i}pGJ z!6Gt^M$JaE@D~k<|M)tRh2J`3;t%zgm}mluCYTt0=^sq+x%S$%qZ>qKB8DaJ^?l#x z>wUhT9Y3?}_?d09-JbmfLHq$rgY}EKpYsQ&_7lQ8y>>W}=r(+bPIQ~?rSI4lZL`#O z6ybi6_{h9A|4tO&aGb~;b9j9|awedw6KO4@rqT&rElxEy?Dnx(bUen7M~6mYeB+w$ zO(iQNwQ)OdVkh*&d5zyOIv$J1#`w`)wt?he`b%QZk6GaY?4<16wSMT6CbD(C6& z^UN`(YQveQjyk;E-Q@e8s{Jg5tshs5?X1hX@ht>&pzNY_-WYi?dn=CV52cDc@zOXjFtaQ8jO zr}B5xs{0$1b(XFETyhi#j>@Z<#L`WNpHCGNNiD7ET2(hdfs2ezpH_z)qv+NR{o}1FL)wdcSI2OsuIrNel<{i_Qy6CQTESmSA zZ_yQW9-T$E(2wXdG;gfuk0HcmZ9cLQw@l^i7M{-7x>@7PEz002p6+6}tps`xJ=Xbg z=QEtaZ8bbyJzRO3aT6>RAmbzvk5iWqlyZ}5>d1l8;K73ec#@K^6b|5UKn#zI(tsQs zz(GNlB9bCW7}L#AaR3l77J@h^DG`7+1FNKBNWgMfR-`b-CA*t&Qh_oVXYVbz##$64 z1D*`1^kYzcD`%TZ1&2lfams=OR6XIz=j0iGTaIinJ+`=YM zSp_JC1vv!4CIKa9kob;ooePqML7NHm16n}yD35lc%@FDyRNicdC@1d`(aa{mVq3Am zI1t%Q;b(7;)EX=F20IyT;#4+MsMWK!_Iz4BL2Wc&I|QLjOb!5#JE60Jse6AGX4SZ#ukNeB@gjQrV<-D3v?}WzK4#iDYT1o4Tz= zuw!&MwwGUq_gYEgca1i;)><)bZh_DNtftrZuy^e8ztXYqzYllRr;SjXcafr4 z6VFLqpqzHLM!=X!&9X4te$hF{UFArZ%HCp+vcUJIqkN?7@b*JaKHDZItr4t|q}(cb zSs&M0Q?oJcHV?OusnyA~59&er!3)9z)q_AoxyI%{KEgjL2(|x}9-hhk{tCh%X?XYD z^v%tBJ{jZ8nv@ kiafb6JjA?Aa%SC#!a8Zz55u$X*4n*j4VC-*(rR0h4W4qna>1}?_aZ2arkds$X9J!d@4 z)5`USCxFYDGiS4)fH&i2uH6rqSX$$Y*(dXGn@tYiqchoho8;sJI|bN_QVVh_<4YzV z*e%1WYpBD^rJ$gclUkOVGx^|dCAP%Ul8pSK$&Gs@Ci8FSU`@?U%*=uDm`jUtCV$v1 zJ~?`)%;d7YR!o@%ATDcSdTL(D + + + + diff --git a/src/main/resources/system/mail/reply-subscription.html b/src/main/resources/system/mail/reply-subscription.html index 704dfcc..1548c0f 100644 --- a/src/main/resources/system/mail/reply-subscription.html +++ b/src/main/resources/system/mail/reply-subscription.html @@ -1,28 +1,27 @@ -
-

-您在 {{blogName}} 中文章 {{postName}} 下的评论有新回复。 -
-There is a new reply to your comment under post {{postName}} in {{blogName}}. -
-

-{{author}}: {{content}}
-查看详情 -
-

-

+

+

+您在 博客名 中文章 文章标题 下的评论有新回复。 +
+There is a new reply to your comment under post post title in blog name. +
+

+评论者: 评论内容
+查看详情 +
+

+

本邮件为系统自动发出,请勿回复该邮件。 - 邮件内容仅为转发评论者内容,不代表本博客站长立场。 - 若要退订,请在收到邮件的 24 小时内点击该链接退订。 - 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 - link - within 24 hours of receiving the email. - -

-
\ No newline at end of file + 邮件内容仅为转发评论者内容,不代表本博客站长立场。 + 若要退订,请点击该链接退订。 + 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 + link. + +

+