邮件延迟发送功能优化
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -1,241 +1,114 @@
|
|||||||
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 org.redisson.api.RBucket;
|
||||||
import jakarta.annotation.PostConstruct;
|
import org.redisson.api.RedissonClient;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
import org.redisson.Redisson;
|
import org.sqids.Sqids;
|
||||||
import org.redisson.api.RBucket;
|
|
||||||
import org.redisson.api.RedissonClient;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.redisson.client.codec.StringCodec;
|
import me.qwq.doghouse.entity.config.SiteConfig;
|
||||||
import org.redisson.client.protocol.RedisCommands;
|
import me.qwq.doghouse.util.Cryptos;
|
||||||
import org.redisson.command.CommandAsyncExecutor;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
/**
|
||||||
import org.springframework.stereotype.Component;
|
* Redis key tool.
|
||||||
import org.sqids.Sqids;
|
* @author Doghole
|
||||||
|
*/
|
||||||
import me.qwq.doghouse.entity.Comment;
|
@Component
|
||||||
import me.qwq.doghouse.entity.config.SiteConfig;
|
@Slf4j
|
||||||
import me.qwq.doghouse.util.Cryptos;
|
public class Rk {
|
||||||
/**
|
|
||||||
* Redis Key Tool
|
static SiteConfig siteConfig;
|
||||||
* @author Doghole
|
|
||||||
*
|
static RedissonClient redisson;
|
||||||
*/
|
|
||||||
@Component
|
public static String SYSTEM_PREFIX;
|
||||||
@Slf4j
|
|
||||||
public class Rk {
|
@Autowired
|
||||||
|
public void setService(SiteConfig siteConfig, RedissonClient redisson) {
|
||||||
static SiteConfig siteConfig;
|
Rk.siteConfig = siteConfig;
|
||||||
|
Rk.redisson = redisson;
|
||||||
static RedissonClient redisson;
|
SYSTEM_PREFIX = "doghouse:" + siteConfig.getAddress().hashCode() + ":";
|
||||||
|
}
|
||||||
|
|
||||||
public static String SYSTEM_PREFIX;
|
public static class Gravatar {
|
||||||
|
|
||||||
static final String NOTIFY_KEYSPACE_EVENTS = "notify-keyspace-events",
|
public static final String ID = "gravatar:";
|
||||||
x = "x", E = "E";
|
|
||||||
|
public static final String GRAVATAR_PREFIX = "image:";
|
||||||
|
|
||||||
@Autowired
|
public static final String EMAIL_HASH_PREFIX = "email-hash:";
|
||||||
public void setService(SiteConfig siteConfig, RedissonClient redisson) {
|
|
||||||
Rk.siteConfig = siteConfig;
|
public static final String PRIVATE_HASH_PREFIX = "private-hash:";
|
||||||
Rk.redisson = redisson;
|
|
||||||
}
|
public static final String SALT = "~D09H0U5E-9R4V474R^_^";
|
||||||
|
|
||||||
@PostConstruct
|
private static String getGravatarKey(String subfix) {
|
||||||
private void init() {
|
return SYSTEM_PREFIX + ID + subfix;
|
||||||
|
}
|
||||||
SYSTEM_PREFIX = "doghouse:" + siteConfig.getAddress().hashCode() + ":";
|
|
||||||
|
public static RBucket<Object> getGravatarBucket(String dogHash, int size) {
|
||||||
// 使用 Redisson 设置 redis 服务端的 x(expired) 和 E(evicted) 事件
|
return redisson.getBucket(getGravatarKey(GRAVATAR_PREFIX + dogHash + ":" + size));
|
||||||
Redisson redissonImpl = (Redisson) redisson;
|
}
|
||||||
CommandAsyncExecutor executor = redissonImpl.getCommandExecutor();
|
|
||||||
|
public static void clearGravatarCache() {
|
||||||
@SuppressWarnings("unchecked")
|
for (String name : redisson.getKeys().getKeysByPattern(getGravatarKey(GRAVATAR_PREFIX + "*"))) {
|
||||||
Map<String, String> config = (Map<String, String>) executor
|
redisson.getBucket(name).deleteAsync();
|
||||||
.readAsync("default", StringCodec.INSTANCE, RedisCommands.CONFIG_GET_MAP, NOTIFY_KEYSPACE_EVENTS)
|
}
|
||||||
.toCompletableFuture().join();
|
}
|
||||||
|
|
||||||
String keyEvents = config.getOrDefault(NOTIFY_KEYSPACE_EVENTS, "");
|
public static String prepareDogHash(String email) {
|
||||||
boolean changed = false;
|
if (email == null) {
|
||||||
if (!keyEvents.contains(x)) {
|
email = "";
|
||||||
keyEvents += x;
|
}
|
||||||
changed = true;
|
email = email.toLowerCase(Locale.ENGLISH);
|
||||||
}
|
|
||||||
if (!keyEvents.contains(E)) {
|
RBucket<String> gravatarHashBucket = redisson.getBucket(getGravatarKey(EMAIL_HASH_PREFIX + email));
|
||||||
keyEvents += E;
|
if (gravatarHashBucket.isExists()) {
|
||||||
changed = true;
|
return gravatarHashBucket.get();
|
||||||
}
|
}
|
||||||
if (changed) {
|
|
||||||
log.debug("Redis setConfig {} value {}", NOTIFY_KEYSPACE_EVENTS, keyEvents);
|
byte[] hash = Cryptos.sha3((email + SALT).getBytes(), 256);
|
||||||
executor
|
|
||||||
.writeAsync("default", StringCodec.INSTANCE, RedisCommands.CONFIG_SET, NOTIFY_KEYSPACE_EVENTS, keyEvents)
|
List<Long> list = new ArrayList<>();
|
||||||
.toCompletableFuture()
|
|
||||||
.join();
|
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);
|
||||||
public static class Mail {
|
}
|
||||||
|
i++;
|
||||||
public static final String ID = "mail:";
|
for (int j = 0; j < 8; j++) {
|
||||||
|
x ^= (((long) hash[i * 8 + j]) & 0xFFL) << ((7 - j) * 8);
|
||||||
private static String getMailKey(String subfix) {
|
}
|
||||||
return SYSTEM_PREFIX + ID + subfix;
|
if (x < 0) {
|
||||||
}
|
list.add(0L);
|
||||||
|
list.add(Math.abs(x));
|
||||||
/**
|
}
|
||||||
* 获取所有过期事件的 key
|
else {
|
||||||
* @return
|
list.add(x);
|
||||||
*/
|
}
|
||||||
public static Iterable<String> getAllExpirationTaskKeys() {
|
}
|
||||||
return redisson.getKeys()
|
|
||||||
.getKeysByPattern(getMailKey("task-"));
|
String dogHash = Sqids.builder().build().encode(list);
|
||||||
}
|
gravatarHashBucket.set(dogHash);
|
||||||
|
String realHash = Cryptos.sha256(email);
|
||||||
/**
|
|
||||||
* 获取邮件推送任务的 Bucket(不包括管理员邮件)
|
redisson.getBucket(getGravatarKey(PRIVATE_HASH_PREFIX + dogHash))
|
||||||
* @param commentId
|
.set(realHash);
|
||||||
* @return
|
|
||||||
*/
|
log.info("Email: {}, real hash: {}, dog hash: {}", email, realHash, dogHash);
|
||||||
public static RBucket<Object> getTaskBucket(Long commentId) {
|
|
||||||
return redisson.getBucket(getMailKey("task-" + commentId));
|
return dogHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public static String getRealHash(String dogHash) {
|
||||||
* 获取邮件推送任务的 Comment 缓存 Bucket(不包括管理员邮件)
|
RBucket<String> email = redisson.getBucket(getGravatarKey(PRIVATE_HASH_PREFIX + dogHash));
|
||||||
* @param commentId
|
return email.get();
|
||||||
* @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 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<Object> getGravatarBucket(String dogHash, int size) {
|
|
||||||
return redisson.getBucket(getGravatarKey(GRAVATAR_PREFIX + dogHash + ":" + size));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清除 gravatar 图片缓存
|
|
||||||
*/
|
|
||||||
public static void clearGravatarCache() {
|
|
||||||
for (String name : redisson.getKeys().getKeysByPattern(getGravatarKey(GRAVATAR_PREFIX + "*"))) {
|
|
||||||
redisson.getBucket(name).deleteAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取对应 email 的 hash,并且提前在 Redis 中存储对应关系
|
|
||||||
* @param email
|
|
||||||
* @return
|
|
||||||
*/
|
|
||||||
public static String prepareDogHash(String email) {
|
|
||||||
if (email == null) email = "";
|
|
||||||
email = email.toLowerCase(Locale.ENGLISH);
|
|
||||||
|
|
||||||
RBucket<String> gravatarHashBucket = redisson.getBucket(getGravatarKey(EMAIL_HASH_PREFIX + email));
|
|
||||||
if (gravatarHashBucket.isExists()) {
|
|
||||||
return gravatarHashBucket.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] hash = Cryptos.sha3((email + SALT).getBytes(), 256);
|
|
||||||
|
|
||||||
List<Long> 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<String> email = redisson.getBucket(getGravatarKey(PRIVATE_HASH_PREFIX + dogHash));
|
|
||||||
return email.get();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
|||||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
import com.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,174 +46,136 @@ import java.util.concurrent.TimeUnit;
|
|||||||
|
|
||||||
import javax.imageio.ImageIO;
|
import javax.imageio.ImageIO;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 资产控制器
|
* Asset controller.
|
||||||
* @author Doghole
|
* @author Doghole
|
||||||
*/
|
*/
|
||||||
@Controller
|
@Controller
|
||||||
@RequestMapping("/admin/v2/asset")
|
@RequestMapping("/admin/v2/asset")
|
||||||
@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;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
AssetService assetService;
|
AssetService assetService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
SiteConfig siteConfig;
|
SiteConfig siteConfig;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主页
|
* Main page.
|
||||||
* @return
|
* @return
|
||||||
*/
|
*/
|
||||||
@GetMapping({"", "/", "/index"})
|
@GetMapping({"", "/", "/index"})
|
||||||
String index() {
|
String index() {
|
||||||
return "admin/asset/index";
|
return "admin/asset/index";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
@GetMapping("/list")
|
@GetMapping("/list")
|
||||||
public LayPageResp<?> getAssetList(LayPageReq<Asset> pageReq) {
|
public LayPageResp<?> getAssetList(LayPageReq<Asset> pageReq) {
|
||||||
|
|
||||||
MPJLambdaWrapper<Asset> ew = new MPJLambdaWrapper<Asset>()
|
MPJLambdaWrapper<Asset> ew = new MPJLambdaWrapper<Asset>()
|
||||||
.selectAll(Asset.class)
|
.selectAll(Asset.class)
|
||||||
.leftJoin(Post.class, Post::getPostId, Asset::getFromPostId)
|
.leftJoin(Post.class, Post::getPostId, Asset::getFromPostId)
|
||||||
.orderByDesc(Asset::getCreateTime);
|
.orderByDesc(Asset::getCreateTime);
|
||||||
|
|
||||||
IPage<Asset> page = assetService.page(pageReq, ew);
|
IPage<Asset> page = assetService.page(pageReq, ew);
|
||||||
|
|
||||||
LayPageResp<Asset> result = new LayPageResp<>(page);
|
LayPageResp<Asset> result = new LayPageResp<>(page);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传文件
|
* Upload file.
|
||||||
*/
|
*/
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
@PostMapping("/uploadFile")
|
@PostMapping("/uploadFile")
|
||||||
R<?> uploadFile(
|
R<?> uploadFile(
|
||||||
@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) {
|
|
||||||
return R.badRequest(msg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String suffix = getSuffix(file);
|
||||||
|
if (StringUtils.isBlank(suffix)) {
|
||||||
|
return R.badRequest("无法识别文件扩展名");
|
||||||
|
}
|
||||||
|
if (FileTypeEnum.isUploadDenied(suffix)) {
|
||||||
|
return R.badRequest("不允许上传该类型文件");
|
||||||
|
}
|
||||||
|
|
||||||
String prefix = generateFilePrefix();
|
String 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();
|
||||||
File dir = filePlacedDirPath.toFile();
|
Path storedPath = null;
|
||||||
File destFile = filePlacedDirPath.resolve(filename).toFile();
|
Path destPath = filePlacedDirPath.resolve(filename).normalize();
|
||||||
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
|
|
||||||
.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);
|
|
||||||
|
|
||||||
File finalFile = null;
|
Asset asset = new Asset()
|
||||||
String finalSuffix = null;
|
.setFromPostId(postId)
|
||||||
|
.setTitle(originalFilename)
|
||||||
if (webpFile != null && avifFile != null) {
|
.setCreateTime(now)
|
||||||
if (webpFile.length() >= avifFile.length()) {
|
.setFileType(fileType)
|
||||||
finalFile = webpFile;
|
.setFileSize(Files.size(storedPath))
|
||||||
finalSuffix = ".webp";
|
.setUpdateTime(now)
|
||||||
avifFile.delete();
|
.setPath(fileUrl);
|
||||||
}
|
|
||||||
else {
|
if (fileType == FileTypeEnum.IMAGE) {
|
||||||
finalFile = avifFile;
|
storedPath = prepareImageAsset(asset, suffix, storedPath, prefix, originalPrefix, yearAndMonth);
|
||||||
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(
|
||||||
@@ -277,112 +247,190 @@ public class AssetController extends BaseController {
|
|||||||
.eq(Asset::getAssetId, asset.getAssetId())
|
.eq(Asset::getAssetId, asset.getAssetId())
|
||||||
.set(Asset::getFromPostId, null)), "解绑失败");
|
.set(Asset::getFromPostId, null)), "解绑失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取文件后缀,不包含".", 返回小写
|
* 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() {
|
||||||
String dateStr = DateUtils.format(LocalDateTime.now(), "yyyyMMdd_HHmmssSSS_");
|
String dateStr = DateUtils.format(LocalDateTime.now(), "yyyyMMdd_HHmmssSSS_");
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append(dateStr);
|
sb.append(dateStr);
|
||||||
|
|
||||||
List<Long> list = new ArrayList<>();
|
List<Long> list = new ArrayList<>();
|
||||||
list.add(Math.abs(SECURE_RANDOM.nextLong()));
|
list.add(Math.abs(SECURE_RANDOM.nextLong()));
|
||||||
|
|
||||||
String id = SQIDS.encode(list);
|
String id = SQIDS.encode(list);
|
||||||
sb.append(id);
|
sb.append(id);
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
String avifFilename = finalPrefix + ".avif";
|
String avifFilename = finalPrefix + ".avif";
|
||||||
String avifPath = filePlacedDirPath.resolve(avifFilename).toString();
|
String avifPath = filePlacedDirPath.resolve(avifFilename).toString();
|
||||||
List<String> avifCommand = List.of(
|
List<String> avifCommand = List.of(
|
||||||
"avifenc",
|
"avifenc",
|
||||||
"-q", "60",
|
"-q", "60",
|
||||||
"--qalpha", "80",
|
"--qalpha", "80",
|
||||||
"-s", "6",
|
"-s", "6",
|
||||||
"-j", "all",
|
"-j", "all",
|
||||||
originalFilePath,
|
originalFilePath,
|
||||||
avifPath);
|
avifPath);
|
||||||
|
|
||||||
return executeFile(avifCommand, avifPath);
|
return executeFile(avifCommand, avifPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static File getWebp(String suffix,
|
private static File getWebp(String suffix, String originalFilePath, String finalPrefix, Path filePlacedDirPath) {
|
||||||
String originalFilePath, String finalPrefix, Path filePlacedDirPath) {
|
suffix = FileTypeEnum.normalizeExtension(suffix);
|
||||||
|
if (!FileTypeEnum.isImageCompressible(suffix)) {
|
||||||
|
|
||||||
if (!"jpg".equals(suffix) && !"jpeg".equals(suffix) && !"png".equals(suffix) && !"gif".equals(suffix)) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String webpFilename = finalPrefix + ".webp";
|
String webpFilename = finalPrefix + ".webp";
|
||||||
String webpPath = filePlacedDirPath.resolve(webpFilename).toString();
|
String webpPath = filePlacedDirPath.resolve(webpFilename).toString();
|
||||||
List<String> webpCommand =
|
List<String> webpCommand =
|
||||||
!"gif".equals(suffix) ?
|
!"gif".equals(suffix) ?
|
||||||
List.of("cwebp",
|
List.of("cwebp",
|
||||||
"-z", "9",
|
"-z", "9",
|
||||||
"-q", "100",
|
"-q", "100",
|
||||||
"-near_lossless", "60",
|
"-near_lossless", "60",
|
||||||
"-m", "6",
|
"-m", "6",
|
||||||
"-mt", originalFilePath,
|
"-mt", originalFilePath,
|
||||||
"-o", webpPath):
|
"-o", webpPath):
|
||||||
List.of("gif2webp",
|
List.of("gif2webp",
|
||||||
"-q", "90",
|
"-q", "90",
|
||||||
"-lossy", "-min_size",
|
"-lossy", "-min_size",
|
||||||
"-m", "0",
|
"-m", "0",
|
||||||
"-mt", originalFilePath,
|
"-mt", originalFilePath,
|
||||||
"-o", webpPath);
|
"-o", webpPath);
|
||||||
return executeFile(webpCommand, webpPath);
|
return executeFile(webpCommand, webpPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
||||||
|
}
|
||||||
@@ -168,6 +168,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;
|
||||||
@@ -38,124 +62,56 @@ public enum FileTypeEnum implements IQueryableEnum {
|
|||||||
public int getStatus() {
|
public int getStatus() {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static FileTypeEnum getFileType(Asset asset) {
|
public static FileTypeEnum getFileType(Asset asset) {
|
||||||
if (Objects.isNull(asset)) {
|
if (Objects.isNull(asset)) {
|
||||||
return OTHER;
|
return OTHER;
|
||||||
}
|
}
|
||||||
return getFileType(asset.getExt());
|
return getFileType(asset.getExt());
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +64,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();
|
||||||
@@ -329,6 +337,59 @@ public class CommentService extends BaseServiceImpl<CommentMapper, Comment> {
|
|||||||
distinctEmails.forEach(email -> Rk.Gravatar.prepareDogHash(email));
|
distinctEmails.forEach(email -> Rk.Gravatar.prepareDogHash(email));
|
||||||
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,438 +1,430 @@
|
|||||||
package me.qwq.doghouse.service;
|
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.Locale;
|
||||||
import java.util.ArrayList;
|
import java.util.Properties;
|
||||||
import java.util.List;
|
import java.util.Set;
|
||||||
import java.util.Locale;
|
import java.util.stream.Collectors;
|
||||||
import java.util.Properties;
|
|
||||||
import java.util.Set;
|
import javax.mail.Authenticator;
|
||||||
import java.util.UUID;
|
import javax.mail.Message.RecipientType;
|
||||||
import java.util.concurrent.TimeUnit;
|
import javax.mail.MessagingException;
|
||||||
import java.util.stream.Collectors;
|
import javax.mail.PasswordAuthentication;
|
||||||
|
import javax.mail.Session;
|
||||||
import javax.mail.Authenticator;
|
import javax.mail.Transport;
|
||||||
import javax.mail.PasswordAuthentication;
|
import javax.mail.internet.InternetAddress;
|
||||||
import javax.mail.Session;
|
import javax.mail.internet.MimeMessage;
|
||||||
import javax.mail.Transport;
|
|
||||||
import javax.mail.Message.RecipientType;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import javax.mail.MessagingException;
|
import org.apache.commons.text.StringEscapeUtils;
|
||||||
import javax.mail.internet.InternetAddress;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import javax.mail.internet.MimeMessage;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.thymeleaf.TemplateEngine;
|
||||||
import org.redisson.api.ExpiredObjectListener;
|
import org.thymeleaf.context.Context;
|
||||||
import org.redisson.api.RBucket;
|
import org.thymeleaf.templatemode.TemplateMode;
|
||||||
import org.redisson.api.RedissonClient;
|
import org.thymeleaf.templateresolver.StringTemplateResolver;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import me.qwq.doghouse.entity.Comment;
|
||||||
import org.sqids.Sqids;
|
import me.qwq.doghouse.entity.MailTask;
|
||||||
|
import me.qwq.doghouse.entity.Post;
|
||||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
import me.qwq.doghouse.entity.config.MailConfig;
|
||||||
|
import me.qwq.doghouse.entity.config.NetworkConfig;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import me.qwq.doghouse.entity.config.SiteConfig;
|
||||||
import me.qwq.doghouse.component.Rk;
|
import me.qwq.doghouse.enums.CommentStatusEnum;
|
||||||
import me.qwq.doghouse.entity.Comment;
|
import me.qwq.doghouse.enums.MailTaskTypeEnum;
|
||||||
import me.qwq.doghouse.entity.config.MailConfig;
|
import me.qwq.doghouse.service.post.PostService;
|
||||||
import me.qwq.doghouse.entity.config.NetworkConfig;
|
import me.qwq.doghouse.util.MarkDownUtils;
|
||||||
import me.qwq.doghouse.entity.config.SiteConfig;
|
|
||||||
import me.qwq.doghouse.entity.Post;
|
@Service
|
||||||
import me.qwq.doghouse.enums.CommentStatusEnum;
|
@Slf4j
|
||||||
import me.qwq.doghouse.service.post.PostService;
|
public class MailService {
|
||||||
|
|
||||||
/**
|
static final String CONTENT_FORMAT = "text/html;charset=UTF-8";
|
||||||
* 邮件服务
|
static final String REPLY_SUBSCRIPTION_TEMPLATE = "system/mail/reply-subscription.html";
|
||||||
* @author Doghole
|
static final TemplateEngine MAIL_TEMPLATE_ENGINE = createMailTemplateEngine();
|
||||||
*
|
|
||||||
*/
|
public enum MailTaskResult {
|
||||||
@Service
|
SENT,
|
||||||
@Slf4j
|
CANCELLED
|
||||||
public class MailService {
|
}
|
||||||
|
|
||||||
static final String CONTENT_FORMAT = "text/html;charset=UTF-8";
|
@Autowired
|
||||||
static final String REPLY_SUBSCRIPTION_TEMPLATE = "system/mail/reply-subscription.html";
|
MailConfig mailConfig;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
MailConfig mailConfig;
|
NetworkConfig networkConfig;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
NetworkConfig networkConfig;
|
SiteConfig siteConfig;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
SiteConfig siteConfig;
|
CommentService commentService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
CommentService commentService;
|
PostService postService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
PostService postService;
|
MailTaskService mailTaskService;
|
||||||
|
|
||||||
@Autowired
|
/**
|
||||||
RedissonClient redis;
|
* 创建用于渲染邮件正文的 Thymeleaf 字符串模板引擎。
|
||||||
|
* @return 邮件模板引擎
|
||||||
public Session getMailSession(boolean debug) {
|
*/
|
||||||
|
private static TemplateEngine createMailTemplateEngine() {
|
||||||
String username = mailConfig.getUsername();
|
StringTemplateResolver templateResolver = new StringTemplateResolver();
|
||||||
String password = mailConfig.getPassword();
|
templateResolver.setTemplateMode(TemplateMode.HTML);
|
||||||
|
templateResolver.setCacheable(false);
|
||||||
Properties props = new Properties();
|
|
||||||
|
TemplateEngine templateEngine = new TemplateEngine();
|
||||||
props.put("mail.smtp.starttls.enable", mailConfig.isTlsEnable());
|
templateEngine.setTemplateResolver(templateResolver);
|
||||||
props.put("mail.smtp.host", mailConfig.getSmtpHost());
|
return templateEngine;
|
||||||
props.put("mail.smtp.port", mailConfig.getSmtpPort());
|
}
|
||||||
props.put("mail.smtp.auth", true);
|
|
||||||
|
/**
|
||||||
if (networkConfig.getProxyType() == Proxy.Type.SOCKS) {
|
* @param debug 是否开启调试, 开启后会在输出打印邮件状态
|
||||||
props.put("mail.smtp.socks.host", networkConfig.getProxyHost());
|
* @return 邮件会话
|
||||||
props.put("mail.smtp.socks.port", networkConfig.getProxyPort());
|
*/
|
||||||
}
|
public Session getMailSession(boolean debug) {
|
||||||
else if (networkConfig.getProxyType() == Proxy.Type.HTTP) {
|
|
||||||
props.put("mail.smtp.proxy.host", networkConfig.getProxyHost());
|
String username = mailConfig.getUsername();
|
||||||
props.put("mail.smtp.proxy.port", networkConfig.getProxyPort());
|
String password = mailConfig.getPassword();
|
||||||
}
|
|
||||||
|
Properties props = new Properties();
|
||||||
Session session = Session.getInstance(props, new Authenticator() {
|
|
||||||
@Override
|
props.put("mail.smtp.starttls.enable", mailConfig.isTlsEnable());
|
||||||
protected PasswordAuthentication getPasswordAuthentication() {
|
props.put("mail.smtp.host", mailConfig.getSmtpHost());
|
||||||
return new PasswordAuthentication(username, password);
|
props.put("mail.smtp.port", mailConfig.getSmtpPort());
|
||||||
}
|
props.put("mail.smtp.auth", true);
|
||||||
});
|
|
||||||
|
if (networkConfig.getProxyType() == Proxy.Type.SOCKS) {
|
||||||
session.setDebug(debug);
|
props.put("mail.smtp.socks.host", networkConfig.getProxyHost());
|
||||||
return session;
|
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());
|
||||||
*/
|
}
|
||||||
public void resetTaskDelay() {
|
|
||||||
|
Session session = Session.getInstance(props, new Authenticator() {
|
||||||
for (String key : Rk.Mail.getAllExpirationTaskKeys()) {
|
@Override
|
||||||
if (!key.endsWith("-comment")) {
|
protected PasswordAuthentication getPasswordAuthentication() {
|
||||||
RBucket<Object> task = redis.getBucket(key);
|
return new PasswordAuthentication(username, password);
|
||||||
if (mailConfig.getSendingDelay() == 0L) {
|
}
|
||||||
task.set(null, Duration.ofSeconds(1L));
|
});
|
||||||
}
|
|
||||||
else {
|
session.setDebug(debug);
|
||||||
task.set(null, Duration.ofMinutes(mailConfig.getSendingDelay()));
|
return session;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
/**
|
||||||
}
|
* 在邮件间隔设置变更后,重设邮件任务延时
|
||||||
|
*/
|
||||||
/**
|
public void resetTaskDelay() {
|
||||||
* 清除评论对应的延迟
|
mailTaskService.resetReplyTaskDelay(mailConfig.getMailDuration());
|
||||||
* @param comment
|
}
|
||||||
* @return
|
|
||||||
*/
|
/**
|
||||||
public boolean clearTaskDelay(Comment comment) {
|
* 取消可编辑评论的待处理邮件任务
|
||||||
RBucket<Object> taskBucket = Rk.Mail.getTaskBucket(comment.getId());
|
* @param comment
|
||||||
return taskBucket.clearExpire() || taskBucket.isExists();
|
* @return 是否存在待处理任务并已将其取消
|
||||||
}
|
*/
|
||||||
|
public boolean clearTaskDelay(Comment comment) {
|
||||||
/**
|
if (comment == null || comment.getId() == null) {
|
||||||
* 添加或更新发送队列
|
return false;
|
||||||
* @param comment
|
}
|
||||||
*/
|
return mailTaskService.cancelOutstandingTasks(comment.getId(), MailTaskTypeEnum.REPLY_SUBSCRIPTION);
|
||||||
public void addOrUpdateSendQueue(Comment comment) {
|
}
|
||||||
|
|
||||||
if (comment.isMailSent()) {
|
/**
|
||||||
log.info("评论 ID 为 {} 的通知已发送,不会再次发送", comment.getId());
|
* 添加或更新邮件推送任务
|
||||||
return;
|
* @param comment 需要处理的评论
|
||||||
}
|
*/
|
||||||
|
public void addOrUpdateSendQueue(Comment comment) {
|
||||||
RBucket<Object> taskBucket = Rk.Mail.getTaskBucket(comment.getId());
|
if (comment == null || comment.getId() == null) {
|
||||||
RBucket<Comment> commentCache = Rk.Mail.getTaskCommentBucket(comment.getId());
|
return;
|
||||||
|
}
|
||||||
if (comment.getIsDeleted()) {
|
|
||||||
// 已删除,需要删除其任务
|
if (comment.isMailSent()) {
|
||||||
log.info("评论 ID 为 {} 的评论已删除,清除可能存在的通知任务", comment.getId());
|
log.info("ID 为 {} 的评论通知已发送,不会再次发送", comment.getId());
|
||||||
taskBucket.deleteAsync();
|
return;
|
||||||
commentCache.deleteAsync();
|
}
|
||||||
return;
|
|
||||||
}
|
if (comment.getIsDeleted() || comment.getCommentStatus() != CommentStatusEnum.RELEASED) {
|
||||||
|
log.info("ID 为 {} 的评论已删除或未发布,取消其回复订阅通知任务", comment.getId());
|
||||||
if (taskBucket.isExists()
|
mailTaskService.cancelOutstandingTasks(comment.getId(), MailTaskTypeEnum.REPLY_SUBSCRIPTION);
|
||||||
&& comment.getCommentStatus() != CommentStatusEnum.RELEASED) {
|
return;
|
||||||
// 已存在,但变更了评论状态,不为发布状态,需要删除其任务
|
}
|
||||||
log.info("评论 ID 为 {} 的评论状态修改为 {},清除其通知任务",
|
|
||||||
comment.getId(), comment.getCommentStatusNote());
|
Set<String> recipients = getReplySubscriptionRecipients(comment);
|
||||||
taskBucket.deleteAsync();
|
if (recipients.isEmpty()) {
|
||||||
commentCache.deleteAsync();
|
mailTaskService.cancelOutstandingTasks(comment.getId(), MailTaskTypeEnum.REPLY_SUBSCRIPTION);
|
||||||
return;
|
log.debug("ID 为 {} 的评论未找到回复订阅通知收件人", comment.getId());
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
if (taskBucket.isExists()) {
|
|
||||||
// 已存在,只更新 commentCache 和时间
|
LocalDateTime sendAfter = getSendAfter(comment);
|
||||||
|
mailTaskService.replaceReplyTasks(comment.getId(), recipients, sendAfter);
|
||||||
if (taskBucket.clearExpire()) {
|
log.info("ID 为 {} 的评论回复订阅通知任务已提交,收件人数 {}", comment.getId(), recipients.size());
|
||||||
// 有 TTL,重设时间
|
}
|
||||||
log.info("评论 ID 为 {} 的评论内容已变更,重设推送时间",
|
|
||||||
comment.getId());
|
/**
|
||||||
commentCache.set(comment);
|
* 为新评论发送管理员通知
|
||||||
taskBucket.expire(mailConfig.getMailDuration());
|
* @param comment
|
||||||
return;
|
*/
|
||||||
}
|
public void sendAdminNotification(Comment comment) {
|
||||||
else {
|
if (comment == null || comment.getId() == null) {
|
||||||
// 没 TTL, 继续时间
|
return;
|
||||||
LocalDateTime now = LocalDateTime.now();
|
}
|
||||||
LocalDateTime commentDate = comment.getCreateTime();
|
if (!mailConfig.isMailOn()) {
|
||||||
|
log.debug("邮件功能未开启,评论 ID 为 {} 的管理员邮件不会入队", comment.getId());
|
||||||
Duration delay = Duration.ofMinutes(mailConfig.getSendingDelay());
|
return;
|
||||||
Temporal sendTime = delay.addTo(commentDate);
|
}
|
||||||
|
mailTaskService.enqueueAdminTask(comment.getId(), mailConfig.getAdminAddress());
|
||||||
Duration newDelay = Duration.between(now, sendTime);
|
log.debug("ID 为 {} 的评论的管理员邮件任务已提交", comment.getId());
|
||||||
if (newDelay.isNegative() || newDelay.isZero()) {
|
}
|
||||||
newDelay = Duration.ofSeconds(1L);
|
|
||||||
}
|
/**
|
||||||
commentCache.set(comment);
|
* 执行指定邮件任务,并根据任务类型分派到对应发送逻辑。
|
||||||
taskBucket.expire(newDelay);
|
* @param task 待执行的邮件任务
|
||||||
return;
|
* @return 邮件任务执行结果
|
||||||
}
|
* @throws MessagingException 邮件构造或发送失败时抛出
|
||||||
|
*/
|
||||||
}
|
public MailTaskResult executeMailTask(MailTask task) throws MessagingException {
|
||||||
|
if (task == null || task.getTaskType() == null) {
|
||||||
commentCache.set(comment);
|
return MailTaskResult.CANCELLED;
|
||||||
|
}
|
||||||
|
if (!mailConfig.isMailOn()) {
|
||||||
taskBucket.addListener(new ExpiredObjectListener() {
|
log.debug("邮件功能未开启,邮件任务 {} 取消", task.getId());
|
||||||
@Override
|
return MailTaskResult.CANCELLED;
|
||||||
public void onExpired(String name) {
|
}
|
||||||
// 寻找本级至顶级的所有回复
|
|
||||||
Comment comment = commentCache.getAndDelete();
|
if (task.getTaskType() == MailTaskTypeEnum.ADMIN_NOTIFICATION) {
|
||||||
|
return sendAdminNotificationTask(task);
|
||||||
if (comment == null) {
|
}
|
||||||
log.info("评论 ID 为 {} 的评论无法获取推送缓存,可能状态已变更,推送不会进行");
|
if (task.getTaskType() == MailTaskTypeEnum.REPLY_SUBSCRIPTION) {
|
||||||
return;
|
return sendReplySubscriptionTask(task);
|
||||||
}
|
}
|
||||||
if (!mailConfig.isMailOn()) {
|
return MailTaskResult.CANCELLED;
|
||||||
log.debug("邮件功能未开启,评论 ID 为 {} 的推送任务将不会进行被发送", comment.getId());
|
}
|
||||||
return;
|
|
||||||
}
|
/**
|
||||||
|
* 发送评论回复订阅通知邮件。
|
||||||
log.info("触发评论 ID 为 {} 的推送任务...", comment.getId());
|
* @param task 回复订阅邮件任务
|
||||||
Post post = postService.getById(comment.getPostId());
|
* @return 邮件任务执行结果
|
||||||
if (post == null) {
|
* @throws MessagingException 邮件构造或发送失败时抛出
|
||||||
log.info("评论 ID 为 {} 的评论所属 Post Id = {} 不存在,可能状态已变更,推送不会进行");
|
*/
|
||||||
return;
|
private MailTaskResult sendReplySubscriptionTask(MailTask task) throws MessagingException {
|
||||||
}
|
Comment comment = commentService.getById(task.getCommentId());
|
||||||
List<Comment> parentComments = commentService.listFromChildToParent(comment.getId(), false);
|
if (comment == null || comment.getIsDeleted() || comment.getCommentStatus() != CommentStatusEnum.RELEASED) {
|
||||||
Set<String> mailSet = parentComments.stream()
|
return MailTaskResult.CANCELLED;
|
||||||
.filter(c -> c.isSubscribeReply() && !c.getEmail().equalsIgnoreCase(comment.getEmail()))
|
}
|
||||||
.map(c -> c.getEmail().toLowerCase(Locale.ENGLISH))
|
if (comment.isMailSent()) {
|
||||||
.collect(Collectors.toSet());
|
return MailTaskResult.CANCELLED;
|
||||||
if (!mailSet.isEmpty()) {
|
}
|
||||||
MimeMessage message = new MimeMessage(getMailSession(false));
|
|
||||||
try {
|
String recipient = StringUtils.lowerCase(task.getRecipient(), Locale.ENGLISH);
|
||||||
message.setSubject(
|
if (!isReplySubscriptionRecipientStillValid(comment, recipient)) {
|
||||||
String.format("您在 %s 的 %s 文章评论有新回复",
|
log.info("评论 ID 为 {} 的收件人 {} 已不再订阅该回复,任务取消", comment.getId(), recipient);
|
||||||
siteConfig.getName(),
|
return MailTaskResult.CANCELLED;
|
||||||
post.getTitle()));
|
}
|
||||||
|
|
||||||
if (StringUtils.isEmpty(mailConfig.getFromUser())) {
|
Post post = postService.getByIdOrName(comment.getPostId(), comment.getPostPageName(), false);
|
||||||
message.setFrom(mailConfig.getFromAddress());
|
if (post == null) {
|
||||||
}
|
return MailTaskResult.CANCELLED;
|
||||||
else {
|
}
|
||||||
message.setFrom(
|
|
||||||
String.format("%s<%s>",
|
Comment subscriptionComment = getReplySubscriptionComment(comment, recipient);
|
||||||
mailConfig.getFromUser(),
|
if (subscriptionComment == null) {
|
||||||
mailConfig.getFromAddress()));
|
return MailTaskResult.CANCELLED;
|
||||||
}
|
}
|
||||||
|
|
||||||
String contentTemplate = getResourceText(REPLY_SUBSCRIPTION_TEMPLATE);
|
String contentTemplate = getResourceText(REPLY_SUBSCRIPTION_TEMPLATE);
|
||||||
if (contentTemplate == null) {
|
if (contentTemplate == null) {
|
||||||
log.warn("回复订阅通知模板文件路径 {} 无法找到, 通知将不会被发送",
|
throw new MessagingException("回复订阅通知模板无法读取: " + REPLY_SUBSCRIPTION_TEMPLATE);
|
||||||
REPLY_SUBSCRIPTION_TEMPLATE);
|
}
|
||||||
return;
|
|
||||||
}
|
String token = commentService.refreshUnsubscribeToken(subscriptionComment.getId());
|
||||||
|
if (StringUtils.isBlank(token)) {
|
||||||
mailSet.forEach(mail -> {
|
throw new MessagingException("无法生成邮件退订令牌");
|
||||||
try {
|
}
|
||||||
log.info("评论 ID 为 {} 的通知欲发送至 {}",
|
|
||||||
comment.getId(), mail);
|
MimeMessage message = new MimeMessage(getMailSession(false));
|
||||||
RBucket<String> mgrBucket = Rk.Mail.getMgrBucket(mail);
|
message.setSubject(String.format("您在 %s 的 %s 文章评论有新回复",
|
||||||
String uuid = null;
|
siteConfig.getName(), post.getTitle()));
|
||||||
if (mgrBucket.isExists()) {
|
setMessageFrom(message);
|
||||||
uuid = mgrBucket.get();
|
message.setRecipient(RecipientType.TO, new InternetAddress(recipient));
|
||||||
log.debug("邮箱 {} 存在管理 UUID {}", mail, uuid);
|
message.setContent(renderReplySubscriptionContent(
|
||||||
}
|
contentTemplate, siteConfig, post, comment, token), CONTENT_FORMAT);
|
||||||
else {
|
|
||||||
uuid = UUID.randomUUID().toString();
|
Transport.send(message);
|
||||||
log.debug("邮箱 {} 不存在管理 UUID,生成 {}", mail, uuid);
|
|
||||||
}
|
log.info("评论 ID 为 {} 的回复订阅通知已发送至 {}", comment.getId(), recipient);
|
||||||
|
return MailTaskResult.SENT;
|
||||||
String content = renderReplySubscriptionContent(
|
}
|
||||||
contentTemplate, siteConfig, post, comment, uuid);
|
|
||||||
message.setContent(content, CONTENT_FORMAT);
|
/**
|
||||||
|
* 发送新评论管理员通知邮件。
|
||||||
message.setRecipient(RecipientType.TO,
|
* @param task 管理员通知邮件任务
|
||||||
new InternetAddress(mail));
|
* @return 邮件任务执行结果
|
||||||
Transport.send(message);
|
* @throws MessagingException 邮件构造或发送失败时抛出
|
||||||
|
*/
|
||||||
// 发送成功了再存 :P
|
private MailTaskResult sendAdminNotificationTask(MailTask task) throws MessagingException {
|
||||||
RBucket<String> uuidBucket = Rk.Mail.getMgrUuidBucket(uuid);
|
Comment comment = commentService.getById(task.getCommentId());
|
||||||
uuidBucket.setAsync(mail, 86400, TimeUnit.SECONDS);
|
if (comment == null || comment.getIsDeleted()) {
|
||||||
mgrBucket.setAsync(uuid, 86400, TimeUnit.SECONDS);
|
return MailTaskResult.CANCELLED;
|
||||||
|
}
|
||||||
log.debug("已为邮箱 {} 设置 UUID {}", mail, uuid);
|
|
||||||
|
Post post = postService.getById(comment.getPostId());
|
||||||
log.info("评论 ID 为 {} 的通知已发送至 {}", comment.getId(), mail);
|
if (post == null) {
|
||||||
}
|
return MailTaskResult.CANCELLED;
|
||||||
catch (MessagingException e) {
|
}
|
||||||
log.error("发送评论 ID 为 " + comment.getId() + " 的通知至 " + mail + " 时发生错误", e);
|
|
||||||
}
|
MimeMessage message = new MimeMessage(getMailSession(false));
|
||||||
});
|
message.setSubject(String.format("您在 %s 的 %s 文章有新回复",
|
||||||
// 更新已发送状态
|
siteConfig.getName(), post.getTitle()));
|
||||||
commentService.update(new LambdaUpdateWrapper<Comment>()
|
setMessageFrom(message);
|
||||||
.eq(Comment::getId, comment.getId())
|
message.setRecipient(RecipientType.TO, new InternetAddress(task.getRecipient()));
|
||||||
.set(Comment::isMailSent, true));
|
message.setContent(String.format(
|
||||||
}
|
"<p><b>%s</b>:%s<br>"
|
||||||
catch (MessagingException e) {
|
+ "<b>评论状态</b>:%s<br>"
|
||||||
log.error("初始化评论 ID 为 {} 的通知失败", comment.getId());
|
+ "<a href=\"%s%s\" rel=\"noopener noreferrer\" target=\"_blank\">查看详情</a></p>"
|
||||||
}
|
+ "<p><i style=\"color: red\">该邮件由系统自动发出,请勿直接回复该邮件</i></p>",
|
||||||
}
|
comment.getAuthor(),
|
||||||
else {
|
comment.getContent(),
|
||||||
// 本级到顶级没有找到能接收通知的邮箱,或找到的邮箱是回复者本人的邮箱
|
comment.getCommentStatusNote(),
|
||||||
log.debug("评论 ID 为 {} 的任务未找到可执行通知的邮箱,"
|
siteConfig.getAddress(),
|
||||||
+ "或找到的邮箱为回复者本人邮箱,邮件不会发送。"
|
comment.getCommentStatus() == CommentStatusEnum.RELEASED ?
|
||||||
+ "若非您预期的结果,请检查:被回复的评论及上级"
|
commentService.getJumpToUrl(comment.getId()) :
|
||||||
+ "评论是否开启了邮件订阅?",
|
"/admin/v2"), CONTENT_FORMAT);
|
||||||
comment.getId());
|
|
||||||
}
|
Transport.send(message);
|
||||||
}
|
log.info("评论 ID 为 {} 的管理员邮件已发送至 {}", comment.getId(), task.getRecipient());
|
||||||
});
|
return MailTaskResult.SENT;
|
||||||
Long sendingDelay = mailConfig.getSendingDelay();
|
}
|
||||||
if (sendingDelay == 0L) {
|
|
||||||
taskBucket.set(0, Duration.ofSeconds(1L));
|
/**
|
||||||
log.debug("评论 ID 为 {} 的任务将在 1 秒后立即执行", comment.getId());
|
* 从指定评论中获取开启了订阅的收件人
|
||||||
}
|
* @param comment 关联评论
|
||||||
else {
|
* @return 收件人邮件地址 Set
|
||||||
taskBucket.set(0, Duration.ofMinutes(sendingDelay));
|
*/
|
||||||
log.debug("评论 ID 为 {} 的任务将在 {} 分钟后执行", comment.getId(), sendingDelay);
|
private Set<String> getReplySubscriptionRecipients(Comment comment) {
|
||||||
}
|
return commentService.listFromChildToParent(comment.getId(), false)
|
||||||
log.info("评论 ID 为 {} 的通知任务已提交,等待执行", comment.getId());
|
.stream()
|
||||||
}
|
.filter(c -> c.isSubscribeReply())
|
||||||
|
.map(Comment::getEmail)
|
||||||
/**
|
.filter(StringUtils::isNotBlank)
|
||||||
* 发送新评论通知到管理员邮箱
|
.map(email -> email.toLowerCase(Locale.ENGLISH).trim())
|
||||||
* @param comment
|
.filter(email -> !email.equalsIgnoreCase(comment.getEmail()))
|
||||||
*/
|
.collect(Collectors.toSet());
|
||||||
public void sendAdminNotification(Comment comment) {
|
}
|
||||||
|
|
||||||
if (!mailConfig.isMailOn()) {
|
/**
|
||||||
log.debug("邮件功能未开启,评论 ID 为 {} 的管理邮件将不会被发送", comment.getId());
|
* @param comment 指定评论
|
||||||
return;
|
* @param recipient 指定收件人
|
||||||
}
|
* @return 指定收件人是否存在于某评论的订阅中
|
||||||
|
*/
|
||||||
Post post = postService.getByIdOrName(comment.getPostId(), comment.getPostPageName());
|
private boolean isReplySubscriptionRecipientStillValid(Comment comment, String recipient) {
|
||||||
|
return getReplySubscriptionComment(comment, recipient) != null;
|
||||||
RBucket<Object> expiredKey = Rk.Mail.getRandomExpirationBucket();
|
}
|
||||||
expiredKey.addListener(new ExpiredObjectListener() {
|
|
||||||
@Override
|
private Comment getReplySubscriptionComment(Comment comment, String recipient) {
|
||||||
public void onExpired(String name) {
|
if (StringUtils.isBlank(recipient)) {
|
||||||
log.info("评论 ID 为 {} 的管理邮件任务开始", comment.getId());
|
return null;
|
||||||
try {
|
}
|
||||||
MimeMessage message = new MimeMessage(getMailSession(false));
|
String normalizedRecipient = recipient.toLowerCase(Locale.ENGLISH).trim();
|
||||||
message.setSubject(
|
return commentService.listFromChildToParent(comment.getId(), false)
|
||||||
String.format("您在 %s 的 %s 文章有新回复",
|
.stream()
|
||||||
siteConfig.getName(),
|
.filter(Comment::isSubscribeReply)
|
||||||
post.getTitle()));
|
.filter(c -> StringUtils.isNotBlank(c.getEmail()))
|
||||||
String content = String.format(
|
.filter(c -> normalizedRecipient.equals(c.getEmail().toLowerCase(Locale.ENGLISH).trim()))
|
||||||
"<p><b>%s</b>:%s<br>"
|
.filter(c -> !normalizedRecipient.equalsIgnoreCase(comment.getEmail()))
|
||||||
+ "<b>评论状态</b>:%s<br>"
|
.findFirst()
|
||||||
+ "<a href=\"%s%s\" rel=\"noopener noreferrer\" target=\"_blank\">查看详情</a></p>"
|
.orElse(null);
|
||||||
+ "<p><i style=\"color: red\">该邮件由系统自动发出,请勿直接回复该邮件</i></p>",
|
}
|
||||||
comment.getAuthor(),
|
|
||||||
comment.getContent(),
|
/**
|
||||||
comment.getCommentStatusNote(),
|
* 计算评论回复订阅通知的最早发送时间。
|
||||||
siteConfig.getAddress(),
|
* @param comment 关联评论
|
||||||
comment.getCommentStatus() == CommentStatusEnum.RELEASED ?
|
* @return 最早发送时间
|
||||||
commentService.getJumpToUrl(comment.getId()):
|
*/
|
||||||
"/admin/v2");
|
private LocalDateTime getSendAfter(Comment comment) {
|
||||||
message.setContent(content, CONTENT_FORMAT);
|
LocalDateTime baseTime = comment.getCreateTime() == null ? LocalDateTime.now() : comment.getCreateTime();
|
||||||
|
Duration delay = mailConfig.getMailDuration();
|
||||||
if (StringUtils.isEmpty(mailConfig.getFromUser())) {
|
LocalDateTime sendAfter = baseTime.plus(delay);
|
||||||
message.setFrom(mailConfig.getFromAddress());
|
LocalDateTime minSendAfter = LocalDateTime.now().plusSeconds(1);
|
||||||
}
|
return sendAfter.isBefore(minSendAfter) ? minSendAfter : sendAfter;
|
||||||
else {
|
}
|
||||||
message.setFrom(
|
|
||||||
String.format("%s<%s>",
|
/**
|
||||||
mailConfig.getFromUser(),
|
* 根据邮件配置设置发件人信息。
|
||||||
mailConfig.getFromAddress()));
|
* @param message 待设置的邮件消息
|
||||||
}
|
* @throws MessagingException 发件人设置失败时抛出
|
||||||
message.setRecipient(RecipientType.TO,
|
*/
|
||||||
new InternetAddress(mailConfig.getAdminAddress()));
|
private void setMessageFrom(MimeMessage message) throws MessagingException {
|
||||||
Transport.send(message);
|
if (StringUtils.isEmpty(mailConfig.getFromUser())) {
|
||||||
log.info("评论 ID 为 {} 的管理邮件发送成功", comment.getId());
|
message.setFrom(mailConfig.getFromAddress());
|
||||||
}
|
}
|
||||||
catch (Exception e) {
|
else {
|
||||||
log.error("评论 ID 为 " + comment.getId() + " 的管理邮件发送失败", e);
|
message.setFrom(String.format("%s<%s>",
|
||||||
}
|
mailConfig.getFromUser(),
|
||||||
}
|
mailConfig.getFromAddress()));
|
||||||
});
|
}
|
||||||
expiredKey.set(1, Duration.ofSeconds(1L));
|
}
|
||||||
log.debug("评论 ID 为 {} 的管理邮件任务将在 1 秒后立即执行", comment.getId());
|
|
||||||
}
|
/**
|
||||||
|
* 渲染回复订阅内容
|
||||||
String renderReplySubscriptionContent(
|
* @param template 模板源文本
|
||||||
String template, SiteConfig siteConfig, Post post, Comment comment, String uuid) {
|
* @param siteConfig 站点配置, 用以获取博客名、博客地址
|
||||||
String blogName = siteConfig.getName();
|
* @param post 对应文章, 用以获取文章标题
|
||||||
String postName = post.getPostTitle();
|
* @param comment 订阅者发布的本条评论, 用以获取订阅者称呼、当前评论内容和评论 id, 其中评论 id 用作链接跳转
|
||||||
String author = comment.getAuthor();
|
* @param token 对应评论发布者邮箱退订用令牌,用以鉴别发布者身份
|
||||||
String content = comment.getContent();
|
* @return 渲染完毕后的邮件内容(html)
|
||||||
String detailUrl = siteConfig.getAddress() + "/comments/go/" + comment.getId();
|
*/
|
||||||
String unsubscribeUrl = siteConfig.getAddress() + "/system/unsubscribe-mail?token=" + uuid;
|
private String renderReplySubscriptionContent(
|
||||||
|
String template, SiteConfig siteConfig, Post post, Comment comment, String token) {
|
||||||
List<Long> list = new ArrayList<>();
|
Context context = new Context(Locale.getDefault());
|
||||||
SecureRandom random = new SecureRandom();
|
context.setVariable("blogName", siteConfig.getName());
|
||||||
list.add(Math.abs(random.nextLong()));
|
context.setVariable("postName", post.getPostTitle());
|
||||||
list.add(Math.abs(random.nextLong()));
|
context.setVariable("author", comment.getAuthor());
|
||||||
String mess = Sqids.builder().build().encode(list);
|
context.setVariable("contentHtml", renderSafeCommentContent(comment));
|
||||||
|
context.setVariable("detailUrl", siteConfig.getAddress() + "/comments/go/" + comment.getId());
|
||||||
String blogNameReplacement = "{{blogName" + mess + "}}";
|
context.setVariable("unsubscribeUrl", siteConfig.getAddress() + "/system/unsubscribe-mail?token=" + token);
|
||||||
String postNameReplacement = "{{postName" + mess + "}}";
|
|
||||||
String authorReplacement = "{{author" + mess + "}}";
|
return MAIL_TEMPLATE_ENGINE.process(template, context);
|
||||||
String contentReplacement = "{{content" + mess + "}}";
|
}
|
||||||
String detailUrlReplacement = "{{detailUrl" + mess + "}}";
|
|
||||||
String unsubscribeUrlReplacement = "{{unsubscribeUrl" + mess + "}}";
|
/**
|
||||||
|
* 将评论内容转换为可安全放入邮件正文的 HTML。
|
||||||
|
* @param comment 关联评论
|
||||||
return template
|
* @return 可直接作为 HTML 输出的评论内容
|
||||||
.replace("{{blogName}}", blogNameReplacement)
|
*/
|
||||||
.replace("{{postName}}", postNameReplacement)
|
private String renderSafeCommentContent(Comment comment) {
|
||||||
.replace("{{author}}", authorReplacement)
|
if (comment.isMarkdown()) {
|
||||||
.replace("{{content}}", contentReplacement)
|
return MarkDownUtils.commentToHtml(comment.getContent());
|
||||||
.replace("{{detailUrl}}", detailUrlReplacement)
|
}
|
||||||
.replace("{{unsubscribeUrl}}", unsubscribeUrlReplacement)
|
return StringEscapeUtils.escapeHtml4(comment.getContent());
|
||||||
.replace(blogNameReplacement, blogName)
|
}
|
||||||
.replace(postNameReplacement, postName)
|
|
||||||
.replace(authorReplacement, author)
|
/**
|
||||||
.replace(contentReplacement, content)
|
* 读取 classpath 下的文本资源内容。
|
||||||
.replace(detailUrlReplacement, detailUrl)
|
* @param fileName 资源文件路径
|
||||||
.replace(unsubscribeUrlReplacement, unsubscribeUrl);
|
* @return 资源文本内容,读取失败时返回 null
|
||||||
}
|
*/
|
||||||
|
String getResourceText(String fileName) {
|
||||||
/**
|
ClassPathResource resource = new ClassPathResource(fileName);
|
||||||
* 读取资源文件
|
try (InputStream inputStream = resource.getInputStream()) {
|
||||||
* @param fileName
|
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
* @return
|
}
|
||||||
*/
|
catch (IOException e) {
|
||||||
String getResourceText(String fileName) {
|
log.error("读取 resource 文件 " + fileName + " 时发生错误", e);
|
||||||
ClassPathResource resource = new ClassPathResource(fileName);
|
return null;
|
||||||
String content = null;
|
}
|
||||||
try (InputStream inputStream = resource.getInputStream()) {
|
}
|
||||||
content = new String(inputStream.readAllBytes());
|
}
|
||||||
}
|
|
||||||
catch (IOException e) {
|
|
||||||
log.error("读取 resource 文件 " + fileName + " 时发生错误", e);
|
|
||||||
}
|
|
||||||
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,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>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user