first commit

This commit is contained in:
2025-12-29 21:29:13 +08:00
commit 73a006943c
1285 changed files with 290920 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
package me.qwq.doghouse;
import java.lang.management.ManagementFactory;
import java.lang.management.RuntimeMXBean;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.scheduling.annotation.EnableAsync;
@MapperScan("me.qwq.doghouse.dao")
@SpringBootApplication
@EnableAsync(proxyTargetClass=true)
@EnableCaching(proxyTargetClass=true)
@EnableAspectJAutoProxy(exposeProxy = true)
public class BlogApplication {
static Boolean isDebug = null;
public static boolean isDebug () {
if (isDebug == null) {
RuntimeMXBean runtimeMXBean = ManagementFactory.getRuntimeMXBean();
isDebug = runtimeMXBean.getInputArguments().toString().contains("-agentlib:jdwp");
}
return isDebug;
}
public static void main(String[] args) throws Exception {
boolean isDebug = isDebug();
// 根据是否调试加载额外的 application-*.yml
SpringApplication app = new SpringApplication(BlogApplication.class);
app.setAdditionalProfiles(isDebug ? "debug" : "prod");
app.run(args);
}
}

View File

@@ -0,0 +1,50 @@
package me.qwq.doghouse.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 清除缓存的 Tags。注解在 String field 上,自动扫描所有以该 key 为注解的 @Cacheable 注解。
* 注解在方法上,更新时自动驱逐。目前支持的有 Post、Comment。
* 该方法并不影响用原生的 @Caching、@CacheEvict 来驱逐缓存
*/
@Target({ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CacheEvictTags {
Tag[] value();
public static enum Tag {
/**
* 更新 Post 时更新(指定 PostId
*/
PostId,
/**
* 更新 Post 时更新(指定 PostPageName
*/
PostName,
Next,
Prev,
/**
* 任意 Post 更新时都更新
*/
Posts,
/**
* 任意 Comment 更新时都更新
*/
Comments,
/**
* 任意 Meta 更新时都更新
*/
Metas,
/**
* 每日更新
*/
Daily;
}
}

View File

@@ -0,0 +1,33 @@
package me.qwq.doghouse.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 将该注解用在配置上,给后台 ConfigController 自动渲染配置页用
* @author Doghole
*
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ConfigInfo {
/**
* @return 存储在数据库中的配置键名
*/
String field();
/**
* @return 名称
*/
String name() default "配置";
/**
* <p>为 true 时,最终会调用其无参构造方法,若需要填充默认值的,请在无参构造器中填充</p>
* @return 是否初始化默认值
*/
boolean initDefault() default false;
/**
* @return 是否提供后台控制
*/
boolean managed() default true;
}

View File

@@ -0,0 +1,15 @@
package me.qwq.doghouse.annotation;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.annotation.ElementType;
/**
* 注解在方法上确保该方法由请求链调用
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireSession {
boolean create() default false;
}

View File

@@ -0,0 +1,53 @@
package me.qwq.doghouse.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 注入前端的注解
* <p>
* <ol>
* <li>
* 注解在类上,以便在前端使用类的静态方法或常量值
* <hr>
* <code>
* {@code @StaticAttribute}<br>
* <b>public class</b> EncryptUtils {<br>
* &nbsp;&nbsp;<b>public static</b> String <i>DEFAULT_ENC</i> = "md5";<br>
* &nbsp;&nbsp;<b>public static</b> String md5(String plain);<br>
* }
* </code>
* <hr>
* 即可在 thymeleaf html 中使用该类的静态方法:
* <br>
* &nbsp;<br>
* <code>
* &lt;input th:value="${EncryptUtils.DEFAULT_ENC + EncryptUtils.md5(password)}"/>
* </code><br>
* &nbsp;<br>
* </li>
* <li>
* 又如:某类内成员字段是不可修改的枚举<p>
* <hr>
* <code>
* {@code @Data}<br>
* {@code @Component}<br>
* <b>public class</b> Network {<br>
* &nbsp;&nbsp;&nbsp;&nbsp;{@code @StaticAttribute("ProxyTypeEnum")}<br>
* &nbsp;&nbsp;&nbsp;&nbsp;<b>private</b> Proxy.Type <i>proxyType</i> = Proxy.Type.<i><b>DIRECT</b></i>;<br>
* }
* </code>
* <hr>
* 即可在 thymeleaf html 中使用该枚举:<br>
* &nbsp;<br>
* <code>
&lt;option value="DIRECT" th:selected="${@network.proxyType == ProxyTypeEnum.DIRECT}">无&lt;/option><br>
</code>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE, ElementType.FIELD})
public @interface StaticAttribute {
String value() default "";
}

View File

@@ -0,0 +1,15 @@
package me.qwq.doghouse.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface UpdatePosts {
boolean postViews() default false;
boolean commentsCount() default false;
}

View File

@@ -0,0 +1,146 @@
package me.qwq.doghouse.cache;
import me.qwq.doghouse.annotation.CacheEvictTags;
import me.qwq.doghouse.annotation.CacheEvictTags.Tag;
public class CacheConstants {
public static class Statistics {
/** 每日更新、更新 Post 和 Comment 时更新 */
@CacheEvictTags({Tag.Posts, Tag.Comments, Tag.Daily})
public static final String DAILY_POST_AND_COMMENT_COUNTS = "StatisticsService::getPostAndCommentCountsWithDate";
/** 每日更新 */
@CacheEvictTags(Tag.Daily)
public static final String DAY_SINCE = "StatisticsService::daySince";
/** 更新 Post 时更新 */
@CacheEvictTags(Tag.Posts)
public static final String TOTAL_RELEASE_POSTS_COUNT = "StatisticsService::totalReleasedPostsCount";
/** 更新 Post 和 Comment 时更新 */
@CacheEvictTags({Tag.Posts, Tag.Comments})
public static final String TOTAL_COMMENTS_COUNT = "StatisticsService::totalCommentsCount";
}
public static class Posts {
static final String ID = "Posts::";
// Markdown
@CacheEvictTags(Tag.PostId)
public static final String MARKDOWN_CONTENT_RENDERING = ID + "Markdown::renderPostContent";
@CacheEvictTags(Tag.PostId)
public static final String MARKDOWN_PREFACE_RENDERING = ID + "Markdown::renderPostPreface";
@CacheEvictTags(Tag.PostId)
public static final String GET_CONTENT_TEXT = ID + "Markdown::getPostContentText";
@CacheEvictTags(Tag.PostId)
public static final String GET_PREFACE_TEXT = ID + "Markdown::getPostPrefaceText";
// Statistic
public static final String POST_STATISTIC = ID + "PostStatistic::getPostStatistic";
// CondictionPage
/** 更新 Post/Meta 时更新 */
@CacheEvictTags(Tag.Posts)
public static final String CONDITION_PAGE = ID + "ConditionPage::conditionPage";
// PostService
/// 与 ID 或 Name 有关(指定)
@CacheEvictTags(Tag.PostId)
public static final String GET_BY_ID = ID + "PostService::getById";
@CacheEvictTags(Tag.PostId)
public static final String GET_BY_ID_LOGINED = ID + "PostService::getByIdLogined";
@CacheEvictTags(Tag.PostId)
public static final String GET_BY_ID_UNLOGINED = ID + "PostService::getByIdUnlogined";
@CacheEvictTags(Tag.PostName)
public static final String GET_BY_NAME_LOGINED = ID + "PostService::getByNameLogined";
@CacheEvictTags(Tag.PostName)
public static final String GET_BY_NAME_UNLOGINED = ID + "PostService::getByNameUnlogined";
@CacheEvictTags(Tag.PostId)
public static final String GET_SEO_DESCRIPTION = ID + "PostService::getSeoDescription";
/** 更新 Post 时更新(指定 PostID 的 Next 的 PostID) */
@CacheEvictTags(Tag.Next)
public static final String GET_PREVIOUS_LOGINED = ID + "PostService::getPreviousLogined";
/** 更新 Post 时更新(指定 PostID 的 Next 的 PostID) */
@CacheEvictTags(Tag.Next)
public static final String GET_PREVIOUS_UNLOGINED = ID + "PostService::getPreviousUnlogined";
/** 更新 Post 时更新(指定 PostID 的 Previous 的 PostID) */
@CacheEvictTags(Tag.Prev)
public static final String GET_NEXT_LOGINED = ID + "PostService::getNextLogined";
/** 更新 Post 时更新(指定 PostID 的 Previous 的 PostID) */
@CacheEvictTags(Tag.Prev)
public static final String GET_NEXT_UNLOGINED = ID + "PostService::getNextUnlogined";
/** 仅在更新 Post 浏览数时更新 **/
@CacheEvictTags(Tag.PostId)
public static final String GET_POST_VIEWS = ID + "PostService::getPostViews";
/// 与 ID 或 Name 无关(批量)
@CacheEvictTags(Tag.Posts)
public static final String GET_LATEST_POSTS_LOGINED = ID + "PostService::getLatestPostsLogined";
@CacheEvictTags(Tag.Posts)
public static final String GET_LATEST_POSTS_UNLOGINED = ID + "PostService::getLatestPostsUnlogined";
@CacheEvictTags({Tag.Posts, Tag.Comments})
public static final String GET_POPULAR_POSTS_LOGINED = ID + "PostService::getPopularPostsLogined";
@CacheEvictTags({Tag.Posts, Tag.Comments})
public static final String GET_POPULAR_POSTS_UNLOGINED = ID + "PostService::getPopularPostsUnlogined";
@CacheEvictTags(Tag.Posts)
public static final String GET_JUMP_TO_PAGES_URL = ID + "PostService::getJumpToPagesUrl";
}
public static class Comments {
static final String ID = "Comments::";
// Markdown
@CacheEvictTags(Tag.Comments)
public static final String MARKDOWN_CONTENT_RENDERING = ID + "Markdown::renderCommentContent";
@CacheEvictTags(Tag.Comments)
public static final String GET_CONTENT_TEXT = ID + "Markdown::getCommentContentText";
// CommentService
@CacheEvictTags({Tag.Posts, Tag.Comments})
public static final String GET_BY_ID = ID + "CommentService::getById";
@CacheEvictTags({Tag.Posts, Tag.Comments})
public static final String GET_LATEST_COMMENTS = ID + "CommentService::getLatestComments";
@CacheEvictTags({Tag.Posts, Tag.Comments})
public static final String GET_LATEST_COMMENTS_WITH_LEVEL = ID + "CommentService::getLatestCommentsWithLevel";
@CacheEvictTags({Tag.Posts, Tag.Comments})
public static final String LIST_FROM_CHILD_TO_PARENT = ID + "CommentService::listFromChildToParent";
@CacheEvictTags({Tag.Posts, Tag.Comments})
public static final String GET_JUMP_TO_URL = ID + "CommentService::getJumpToUrl";
// Other
@CacheEvictTags(Tag.Comments)
public static final String GET_POST_COMMENT_COUNT = ID + "CommentService::getPostCommentCount";
}
public static class Metas {
static final String ID = "Metas::";
// MetaService
/** 更新 Post 和 Meta 时更新(批量) */
@CacheEvictTags({Tag.Posts, Tag.Metas})
public static final String LIST_META_COUNT = ID + "MetaService::listMetaCount";
}
}

View File

@@ -0,0 +1,263 @@
package me.qwq.doghouse.cache;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.reflections.Reflections;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
import org.springframework.core.ResolvableType;
import org.springframework.stereotype.Component;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.annotation.CacheEvictTags;
import me.qwq.doghouse.annotation.CacheEvictTags.Tag;
import me.qwq.doghouse.entity.Post;
import me.qwq.doghouse.service.CommentService;
import me.qwq.doghouse.service.post.PostService;
/**
*
* @author Doghole
*
*/
@Aspect
@Component
@Slf4j
public class CacheEvictTagsAspect {
@Autowired
PostService postService;
@Autowired
CommentService commentService;
@Autowired
Reflections reflections;
@Autowired
CacheManager cacheManager;
List<Field> fields = new ArrayList<>();
Map<Tag, Set<String>> tagKeyCache = new HashMap<>();
Map<CacheEvictTags, Map<Tag, Boolean>> containsTagCache = new HashMap<>();
@PostConstruct
void init() {
// 使用 Reflections 查找所有添加了 CacheEvictTags 的 field
Set<Field> fields = reflections.getFieldsAnnotatedWith(CacheEvictTags.class);
this.fields = fields.stream()
.filter(f -> f.getType().equals(String.class))
.filter(f -> Modifier.isPublic(f.getModifiers()))
.filter(f -> Modifier.isStatic(f.getModifiers())).toList();
}
@SuppressWarnings("unchecked")
@Around("@annotation(annotation)")
public Object around(ProceedingJoinPoint joinPoint, CacheEvictTags annotation) throws Throwable {
Object[] args = joinPoint.getArgs();
if (args.length != 1) {
return joinPoint.proceed();
}
MethodSignature ms = (MethodSignature)joinPoint.getSignature();
Method method = ms.getMethod();
ResolvableType argType = ResolvableType.forMethodParameter(method, 0);
Class<?> rawClass = argType.resolve();
// 缓存 postName (若有)
List<String> postNames = new ArrayList<>();
List<Post> posts = new ArrayList<>();
if (rawClass == Post.class) {
// 单 Post驱逐 PostId, PostName, Prev, Next
postNames.add(((Post)args[0]).getPostPageName());
posts.add((Post)args[0]);
}
else if (Collection.class.isAssignableFrom(rawClass)) {
rawClass = argType.getGeneric(0).resolve();
if (rawClass == Post.class) {
// 批量
posts = new ArrayList<>((Collection<Post>)args[0]);
postNames.addAll(posts.stream().map(p -> p.getPostPageName()).toList());
}
}
else if (Wrapper.class.isAssignableFrom(rawClass)) {
rawClass = argType.getGeneric(0).resolve();
if (rawClass == Post.class) {
// 批量
posts = postService.list((Wrapper<Post>)args[0]);
postNames.addAll(posts.stream().map(p -> p.getPostPageName()).toList());
}
}
Object result = joinPoint.proceed(args);
if ((method.getReturnType() == Boolean.class || method.getReturnType() == boolean.class) && (Boolean)result) {
// 清除批量类缓存
batchEvictIfContainsTag(annotation, Tag.Comments);
batchEvictIfContainsTag(annotation, Tag.Posts);
batchEvictIfContainsTag(annotation, Tag.Metas);
evictPosts(posts, annotation);
evictPostNames(postNames, annotation);
}
return result;
}
/**
* 清除 Post 有关缓存, 包括 ID, Prev 和 Next, 在更新成功后驱逐
* @param posts
*/
void evictPosts(Collection<Post> posts, CacheEvictTags anno) {
if (posts == null || posts.isEmpty()) {
return;
}
boolean containsPostId = containsTag(anno, Tag.PostId);
boolean containsPrev = containsTag(anno, Tag.Prev);
boolean containsNext = containsTag(anno, Tag.Next);
Set<String> postIdKeys = containsPostId ? getKeysWithTag(Tag.PostId) : Set.of();
Set<String> prevKeys = containsPrev ? getKeysWithTag(Tag.Prev) : Set.of();
Set<String> nextKeys = containsNext ? getKeysWithTag(Tag.Next) : Set.of();
for (Post post : posts) {
for (String key : postIdKeys) {
cacheManager.getCache(key).evictIfPresent(post.getPostId());
}
if (containsPrev) {
for (Post prev : Arrays.asList(
postService.cacheableGetPreviousLogined(post.getPostId()),
postService.cacheableGetPreviousUnlogined(post.getPostId()))) {
if (prev != null) {
for (String key : prevKeys) {
cacheManager.getCache(key).evictIfPresent(prev.getPostId());
}
}
}
}
if (containsNext) {
for (Post next : Arrays.asList(
postService.cacheableGetNextLogined(post.getPostId()),
postService.cacheableGetNextUnlogined(post.getPostId()))) {
if (next != null) {
for (String key : nextKeys) {
cacheManager.getCache(key).evictIfPresent(next.getPostId());
}
}
}
}
}
}
/**
* 清除 PostName 有关缓存,在更新成功后驱逐,但其名称需要在更新前存储
* @param postNames
*/
void evictPostNames(Collection<String> postNames, CacheEvictTags anno) {
if (postNames == null || postNames.isEmpty()) {
return;
}
if (!containsTag(anno, Tag.PostName)) {
return;
}
Set<String> postNameKeys = getKeysWithTag(Tag.PostName);
for (String postName : postNames) {
for (String key : postNameKeys) {
cacheManager.getCache(key).evictIfPresent(postName);
}
}
}
/**
* 批量清除指定 tag 的缓存
* @param tag
*/
void batchEvictIfContainsTag(CacheEvictTags anno, Tag tag) {
if (!containsTag(anno, tag)) return;
Set<String> keys = getKeysWithTag(tag);
for (String key : keys) {
cacheManager.getCache(key).clear();
}
}
/**
* 根据指定 tag 获取被其注解的缓存 key
* @param tag
* @return
*/
Set<String> getKeysWithTag(Tag tag) {
if (tagKeyCache.containsKey(tag)) {
return tagKeyCache.get(tag);
}
Set<String> keys = new HashSet<>();
for (Field field : fields) {
CacheEvictTags anno = field.getAnnotation(CacheEvictTags.class);
if (!containsTag(anno, tag)) continue;
try {
String key = (String)field.get(null);
if (key == null) continue;
keys.add(key);
}
catch (IllegalArgumentException | IllegalAccessException e) {
log.warn("无法获取被 Tag: {} 注解的 field: {}", tag, field);
}
}
tagKeyCache.put(tag, keys);
return keys;
}
/**
* 指定 CacheEvictTags 是否包含 Tag
* @param anno
* @param tag
* @return
*/
boolean containsTag(CacheEvictTags anno, Tag tag) {
if (anno == null || tag == null) return false;
return containsTagCache.computeIfAbsent(anno,
m -> new HashMap<Tag, Boolean>()
).computeIfAbsent(tag, b -> {
Set<Tag> setA = EnumSet.copyOf(Arrays.asList(anno.value()));
return !setA.isEmpty() && setA.contains(tag);
});
}
}

View File

@@ -0,0 +1,93 @@
package me.qwq.doghouse.cache;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.Set;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.redisson.api.RedissonClient;
import org.reflections.Reflections;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.redis.cache.CacheKeyPrefix;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.enums.CacheEvictionSpan;
import me.qwq.doghouse.task.TaskConstants;
/**
* 定时清除缓存任务
* @author Doghole
* @since 2024-01-30
*/
@Component
@Slf4j
public class CacheEvictionJob implements Job {
@Autowired
RedissonClient redis;
@Autowired
CacheProperties cacheProperties;
@Autowired
Reflections reflections;
static CacheKeyPrefix prefix;
static final CacheEvictionTask DAILY_ANNO = new CacheEvictionTask() {
@Override
public Class<? extends Annotation> annotationType() {
return CacheEvictionTask.class;
}
@Override
public CacheEvictionSpan value() {
return CacheEvictionSpan.DAILY;
}
};
@PostConstruct
void onPostConstruct() {
prefix = CacheKeyPrefix.prefixed(
cacheProperties.getRedis().getKeyPrefix());
}
public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
String triggerName = jobExecutionContext.getTrigger().getKey().getName();
Set<Method> methods = null;
if (TaskConstants.CACHE_DAILY_EVICTION_TRIGGER.equals(triggerName)) {
methods = reflections.getMethodsAnnotatedWith(DAILY_ANNO);
}
if (methods != null) {
for (Method method : methods) {
Cacheable cacheable = method.getAnnotation(Cacheable.class);
if (cacheable == null) {
continue;
}
String[] values = cacheable.value();
if (values.length == 0) {
values = cacheable.cacheNames();
}
for (String cacheName : values) {
String redisCacheName = prefix.compute(cacheName) + "*";
for (String name : redis.getKeys().getKeysByPattern(redisCacheName)) {
redis.getBucket(name).deleteAsync();
log.debug("Scheduler clear cacheName {}", name);
}
}
}
}
}
}

View File

@@ -0,0 +1,17 @@
package me.qwq.doghouse.cache;
import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import me.qwq.doghouse.enums.CacheEvictionSpan;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(CacheEvictionTasks.class)
public @interface CacheEvictionTask {
CacheEvictionSpan value() default CacheEvictionSpan.DAILY;
}

View File

@@ -0,0 +1,13 @@
package me.qwq.doghouse.cache;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface CacheEvictionTasks {
CacheEvictionTask[] value();
}

View File

@@ -0,0 +1,65 @@
package me.qwq.doghouse.cache;
import java.util.List;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import me.qwq.doghouse.annotation.UpdatePosts;
import me.qwq.doghouse.entity.Post;
import me.qwq.doghouse.pojo.dto.PageQuery;
import me.qwq.doghouse.service.CommentService;
import me.qwq.doghouse.service.post.PostService;
/**
* 对需要更新 PostViews/CommentsCount 的返回值进行修改注入<br>
* 适用于:频繁更新某个特定值(此处为 PostViews 和 CommentsCount后更新 Post
* @author Doghole
*
*/
@Aspect
@Component
public class PostViewsAndCommentsCountAspect {
@Autowired
PostService postService;
@Autowired
CommentService commentService;
@Around("@annotation(annotation)")
public Object around(ProceedingJoinPoint joinPoint, UpdatePosts annotation) throws Throwable {
Object[] args = joinPoint.getArgs();
Object result = joinPoint.proceed(args);
if (result == null) return result;
if (!annotation.commentsCount() && !annotation.postViews()) return result;
if (result.getClass() == PageQuery.class) {
PageQuery<?> pageQuery = (PageQuery<?>)result;
pageQuery.getRecords().forEach(post -> updatePost(post, annotation));
return result;
}
else if (result instanceof List) {
List<?> list = (List<?>)result;
if (list.isEmpty() || list.get(0).getClass() != Post.class) return result;
list.forEach(post -> updatePost(post, annotation));
return result;
}
return result;
}
private void updatePost(Object post, UpdatePosts annotation) {
if (annotation.commentsCount()) {
Long postCommentCount = commentService.getPostCommentCount(((Post)post).getPostId());
((Post)post).setCommentCount(postCommentCount);
}
if (annotation.postViews()) {
Long postViews = postService.getPostViews(
((Post)post).getPostId());
((Post)post).setPostViews(postViews);
}
}
}

View File

@@ -0,0 +1,98 @@
package me.qwq.doghouse.component;
import java.io.File;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.DependsOn;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.config.DoghouseProperties;
/**
* 重写数据库位置
*/
@Slf4j
@Component
@DependsOn("doghouseProperties")
public class DataSourcePostProcessor implements BeanPostProcessor {
private static final String RESOURCE_PATH = "blog.db";
private static final String PREFIX = "jdbc:sqlite:";
@Autowired
DoghouseProperties doghouseProperties;
/**
* 配置数据库连接
* <p>
* application 指定的 jdbc url 位置存在则使用对应位置,且后续一直以该路径为准(包括调试时修改的数据)。
* 若不存在则从 classpath 中取初始数据库复制到 jdbc url 指定的位置
* @param dataSource
*/
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (bean instanceof HikariDataSource hikariDataSource) {
String filePath = hikariDataSource.getJdbcUrl();
if (filePath == null || !filePath.startsWith(PREFIX)) {
log.warn(
"Unable to obtain a valid SQLite JDBC URL from HikariDataSource; "
+ "the data source may fail to load.\r\n"
+ "A valid JDBC URL is typically specified in application.yml "
+ "or application.properties and should begin with \"jdbc:sqlite:\". "
+ "Current url: {}", filePath);
return bean;
}
filePath = filePath.substring(PREFIX.length()).trim();
log.debug("Retrieved HikariDataSource SQLite location: {}", filePath);
if (doghouseProperties.getUseRelativeDbPath()) {
filePath = Paths.get(doghouseProperties.getWorkPath(), filePath).toString();
log.debug("Use relative db path with work path: {}, so the db path will be: {}",
doghouseProperties.getWorkPath(), filePath);
hikariDataSource.setJdbcUrl(PREFIX + filePath);
}
File dest = new File(filePath);
if (dest.exists()) {
log.debug("Retrieved HikariDataSource SQLite file exists, will be used as database.");
return bean;
}
ClassPathResource original = new ClassPathResource(RESOURCE_PATH);
if (!original.exists()) {
log.warn("Cannot find classpath resource: {}", RESOURCE_PATH);
return bean;
}
// 复制到外部 yml 指定路径,已存在则不复制
File parentDir = dest.getParentFile();
String destAbsolutePath = dest.getAbsolutePath();
if (!parentDir.exists() && !parentDir.mkdirs()) {
// 无法创建放置 SQLite 文件的位置
log.warn("Failed to create the directory in which the SQLite file should be placed: {}", parentDir.getAbsolutePath());
return bean;
}
try (InputStream in = getClass().getClassLoader().getResourceAsStream(RESOURCE_PATH)) {
if (in == null) {
log.warn("Cannot read SQLite resource from classpath: {}", RESOURCE_PATH);
return bean;
}
Files.copy(in, dest.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING);
log.info("SQLite database file has been copy to: {}", destAbsolutePath);
} catch (Exception e) {
log.warn("Copt SQLite databse file failed", e);
}
}
return bean;
}
}

View File

@@ -0,0 +1,31 @@
package me.qwq.doghouse.component;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.DependsOn;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.service.UserService;
import me.qwq.doghouse.service.CommentService;
// 每次启动后
@Component
@DependsOn("rk")
@Slf4j
public class DogfacePreparation {
@Autowired
CommentService commentService;
@Autowired
UserService userService;
@PostConstruct
void init() {
Long regeneratedCount = commentService.reprepareDogHash() + userService.reprepareDogHash();
log.info("重新生成 {} 个邮箱的 Doghash", regeneratedCount);
}
}

View File

@@ -0,0 +1,194 @@
package me.qwq.doghouse.component;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.exception.PageNotFoundException;
import me.qwq.doghouse.exception.RException;
import me.qwq.doghouse.pojo.dto.LayPageResp;
import me.qwq.doghouse.pojo.dto.R;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.stream.Collectors;
import javax.security.auth.login.LoginException;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerMapping;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import com.fasterxml.jackson.databind.JsonNode;
/**
* 异常处理
*/
@ControllerAdvice
@Slf4j
public class DoghouseExceptionHandler {
@Autowired
HttpServletResponse response;
@Autowired
HttpServletRequest request;
@ExceptionHandler({
BindException.class,
MethodArgumentNotValidException.class})
@ResponseBody
@ResponseStatus(HttpStatus.BAD_REQUEST)
public <Ex extends BindException> R<?> handleBindException(Ex ex) {
StringBuilder message = new StringBuilder();
ex.getBindingResult().getAllErrors()
.forEach(error -> message.append(error.getDefaultMessage()).append("\n"));
log.warn("Resolved Exception {}", message.substring(0, message.length() - 1));
log.warn(httpServletRequestToString(request));
return bodyOrPage(HttpStatus.BAD_REQUEST, ex);
}
@ExceptionHandler(LoginException.class)
@ResponseBody
@ResponseStatus(HttpStatus.NOT_ACCEPTABLE)
public R<?> handleLoginException(LoginException ex) {
return bodyOrPage(HttpStatus.NOT_ACCEPTABLE, ex);
}
@ExceptionHandler(RException.class)
@ResponseBody
public R<?> handleRException(RException ex) {
response.setStatus(ex.getHttpStatus().value());
if (ex.getLogRequest()) {
log.warn(httpServletRequestToString(request));
}
return bodyOrPage(ex.getHttpStatus(), ex);
}
@ExceptionHandler(ServletException.class)
@ResponseBody
public R<?> handleServletException(ServletException ex) {
HttpStatus httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
if (ex instanceof HttpRequestMethodNotSupportedException)
httpStatus = HttpStatus.METHOD_NOT_ALLOWED;
response.setStatus(httpStatus.value());
return bodyOrPage(httpStatus, ex);
}
@ExceptionHandler(value = Exception.class)
@ResponseBody
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public R<?> handleException(Exception ex) {
if (ex instanceof PageNotFoundException) {
throw (PageNotFoundException)ex;
}
log.warn("Resolved exception {}", ex.getMessage());
log.warn(httpServletRequestToString(request));
return bodyOrPage(HttpStatus.INTERNAL_SERVER_ERROR, ex);
}
private R<?> bodyOrPage(HttpStatus httpStatus, Exception ex) {
boolean isPage = (ex instanceof RException || ex instanceof LoginException) ?
false : isPage();
if (isPage) {
if (ex instanceof NoResourceFoundException nrfe) {
if (StringUtils.isNotEmpty(nrfe.getMessage())
&& nrfe.getMessage().endsWith(" .well-known/appspecific/com.chrome.devtools.json.")) {
// 傻逼 Chrome 开发工具默认调用该地址
return null;
}
}
throw ex == null ?
new RuntimeException("Page exception raised") :
new RuntimeException(ex);
}
ex.printStackTrace();
return ex != null ? R.status(httpStatus, ex.getMessage()) : R.status(httpStatus);
}
/**
* 根据 @Autowired request 等自动判断当前请求是 text/html 还是 application/json
* @return
*/
private boolean isPage() {
boolean isPage = true;
String accept;
// 匹配当前 request 在控制层的方法,根据是否是
// @ResponseBody 注解、@RestController 注解、
// 已知的返回类型来判断返回的是否是页面
Object handler = request.getAttribute(HandlerMapping.BEST_MATCHING_HANDLER_ATTRIBUTE);
if (handler instanceof HandlerMethod handlerMethod) {
Method method = handlerMethod.getMethod();
Class<?> controllerClass = handlerMethod.getBeanType();
boolean hasResponseBody =
method.isAnnotationPresent(ResponseBody.class) ||
controllerClass.isAnnotationPresent(ResponseBody.class) ||
controllerClass.isAnnotationPresent(RestController.class);
Class<?> returnType = method.getReturnType();
boolean isDtoReturnType =
returnType.equals(R.class) ||
returnType.equals(LayPageResp.class) ||
JsonNode.class.isAssignableFrom(returnType);
if (hasResponseBody || isDtoReturnType) {
isPage = false;
}
}
if (isPage && (accept = request.getHeader("Accept")) != null) {
int indexOfHtml = accept.indexOf("text/html"), indexOfJson = accept.indexOf("application/json");
if (indexOfHtml == -1 && indexOfJson != -1) {
isPage = false;
} else if (indexOfHtml != -1 && indexOfJson != -1) {
isPage = indexOfHtml < indexOfJson;
}
}
return isPage;
}
private static String httpServletRequestToString(HttpServletRequest request) {
StringBuilder sb = new StringBuilder();
sb.append("Request Method = \"" + request.getMethod() + "\", ");
sb.append("Request URL Path = \"" + request.getRequestURL() + "\", ");
String headers =
Collections.list(request.getHeaderNames()).stream()
.map(headerName -> headerName + " : " + Collections.list(request.getHeaders(headerName)) )
.collect(Collectors.joining(", "));
if (headers.isEmpty()) {
sb.append("Request headers: NONE,");
} else {
sb.append("Request headers: ["+headers+"],");
}
String parameters =
Collections.list(request.getParameterNames()).stream()
.map(p -> {
String[] values = request.getParameterValues(p);
return p + ": [" + StringUtils.join(values, ", ") + "]";
})
.collect(Collectors.joining(", "));
if (parameters.isEmpty()) {
sb.append("Request parameters: NONE.");
} else {
sb.append("Request parameters: [" + parameters + "].");
}
return sb.toString();
}
}

View File

@@ -0,0 +1,116 @@
package me.qwq.doghouse.component;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import org.reflections.Reflections;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ResolvableType;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import me.qwq.doghouse.enums.PostTypeEnum;
import me.qwq.doghouse.interfaces.IPostTypeConfig;
import me.qwq.doghouse.service.ThemeService;
import me.qwq.doghouse.service.post.AbstractPostService;
import me.qwq.doghouse.util.SpringContextHolder;
/**
* 提供通过 PostType 获取一些配置的能力
*/
@Component
public class PostTypeComponent {
@Autowired
Reflections reflections;
@Autowired
ThemeService themeService;
@SuppressWarnings("rawtypes")
Set<Class<? extends IPostTypeConfig>> postTypeConfigClassCache = new HashSet<>();
@SuppressWarnings("rawtypes")
Set<Class<? extends AbstractPostService>> abstractPostServiceClassCache = new HashSet<>();
Map<PostTypeEnum, IPostTypeConfig<?>> postTypeConfigCache = new HashMap<>();
Map<PostTypeEnum, AbstractPostService<?, ?>> abstractPostServiceCache = new HashMap<>();
@PostConstruct
void init() {
postTypeConfigClassCache =
reflections.getSubTypesOf(IPostTypeConfig.class);
abstractPostServiceClassCache =
reflections.getSubTypesOf(AbstractPostService.class);
}
/**
* 获取指定模式的 Post 配置
* @param <T>
* @param postType
* @return
*/
@SuppressWarnings("unchecked")
public <T extends IPostTypeConfig<T>> T getPostTypeConfig(PostTypeEnum postType) {
return (T) postTypeConfigCache.computeIfAbsent(postType, key -> {
for (@SuppressWarnings("rawtypes") Class<? extends IPostTypeConfig> clazz : postTypeConfigClassCache) {
T instance = (T) SpringContextHolder.getBean(clazz);
if (instance.getPostType() == key) {
return instance;
}
}
return null;
});
}
/**
* 获取指定模式的 PostService.
* <p>注意在和 AbstractPostService 有关泛型的类内调用时不要指定其返回为泛型, 而是使用
AbstractPostService&lt;?, ?> 作为返回类型, 避免强制转换为泛型。除非很确定返回类型
就是类内指定的泛型类型
* @param <C>
* @param <T>
* @param postType
* @return
*/
@SuppressWarnings("unchecked")
public <C extends IPostTypeConfig<C>, T extends AbstractPostService<T, C>> T getPostTypeService(PostTypeEnum postType) {
return (T) abstractPostServiceCache.computeIfAbsent(postType, key -> {
for (@SuppressWarnings("rawtypes") Class<? extends AbstractPostService> clazz : abstractPostServiceClassCache) {
T instance = (T) SpringContextHolder.getBean(clazz);
ResolvableType type = ResolvableType.forClass(clazz).as(AbstractPostService.class);
ResolvableType iPageConfigType = type.getGeneric(1);
Class<C> iPageConfigClass = (Class<C>) iPageConfigType.resolve();
C pageConfig = SpringContextHolder.getBean(iPageConfigClass);
if (pageConfig.getPostType() == key) {
return instance;
}
}
return null;
});
}
/**
* 获取指定模式的模板路径
* @param postType
* @param fileNameSegment
* @return
*/
public String getTemplatePath(PostTypeEnum postType, String fileNameSegment) {
return themeService.getCurrentTheme().getTemplateString(
getPostTypeConfig(postType).getTemplatePrefix(),
fileNameSegment);
}
/**
* 检查指定模式是否打开
* @param postType
* @return
*/
public boolean checkModeEnable(PostTypeEnum postType) {
return getPostTypeConfig(postType).getEnabled();
}
}

View File

@@ -0,0 +1,39 @@
package me.qwq.doghouse.component;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import me.qwq.doghouse.annotation.RequireSession;
@Aspect
@Component
public class RequireSessionAspect {
@Before("@annotation(requireSession)")
public void verifySession(JoinPoint joinPoint, RequireSession requireSession) {
// 检查是否在 DispatcherServlet 控制下
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
if (!(attrs instanceof ServletRequestAttributes servletAttrs)) {
throw new IllegalStateException(
joinPoint.getSignature() +
" 必须在 Web 请求上下文中调用(未检测到 HttpServletRequest");
}
HttpServletRequest request = servletAttrs.getRequest();
HttpSession session = request.getSession(requireSession.create());
if (session == null) {
throw new IllegalStateException(
joinPoint.getSignature() +
" 调用时不允许创建 Session但未检测到有效 Session");
}
}
}

View File

@@ -0,0 +1,241 @@
package me.qwq.doghouse.component;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.redisson.Redisson;
import org.redisson.api.RBucket;
import org.redisson.api.RedissonClient;
import org.redisson.client.codec.StringCodec;
import org.redisson.client.protocol.RedisCommands;
import org.redisson.command.CommandAsyncExecutor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.sqids.Sqids;
import me.qwq.doghouse.entity.Comment;
import me.qwq.doghouse.entity.config.SiteConfig;
import me.qwq.doghouse.util.Cryptos;
/**
* Redis Key Tool
* @author Doghole
*
*/
@Component
@Slf4j
public class Rk {
static SiteConfig siteConfig;
static RedissonClient redisson;
public static String SYSTEM_PREFIX;
static final String NOTIFY_KEYSPACE_EVENTS = "notify-keyspace-events",
x = "x", E = "E";
@Autowired
public void setService(SiteConfig siteConfig, RedissonClient redisson) {
Rk.siteConfig = siteConfig;
Rk.redisson = redisson;
}
@PostConstruct
private void init() {
SYSTEM_PREFIX = "doghouse:" + siteConfig.getAddress().hashCode() + ":";
// 使用 Redisson 设置 redis 服务端的 x(expired) 和 E(evicted) 事件
Redisson redissonImpl = (Redisson) redisson;
CommandAsyncExecutor executor = redissonImpl.getCommandExecutor();
@SuppressWarnings("unchecked")
Map<String, String> config = (Map<String, String>) executor
.readAsync("default", StringCodec.INSTANCE, RedisCommands.CONFIG_GET_MAP, NOTIFY_KEYSPACE_EVENTS)
.toCompletableFuture().join();
String keyEvents = config.getOrDefault(NOTIFY_KEYSPACE_EVENTS, "");
boolean changed = false;
if (!keyEvents.contains(x)) {
keyEvents += x;
changed = true;
}
if (!keyEvents.contains(E)) {
keyEvents += E;
changed = true;
}
if (changed) {
log.debug("Redis setConfig {} value {}", NOTIFY_KEYSPACE_EVENTS, keyEvents);
executor
.writeAsync("default", StringCodec.INSTANCE, RedisCommands.CONFIG_SET, NOTIFY_KEYSPACE_EVENTS, keyEvents)
.toCompletableFuture()
.join();
}
}
public static class Mail {
public static final String ID = "mail:";
private static String getMailKey(String subfix) {
return SYSTEM_PREFIX + ID + subfix;
}
/**
* 获取所有过期事件的 key
* @return
*/
public static Iterable<String> getAllExpirationTaskKeys() {
return redisson.getKeys()
.getKeysByPattern(getMailKey("task-"));
}
/**
* 获取邮件推送任务的 Bucket不包括管理员邮件
* @param commentId
* @return
*/
public static RBucket<Object> getTaskBucket(Long commentId) {
return redisson.getBucket(getMailKey("task-" + commentId));
}
/**
* 获取邮件推送任务的 Comment 缓存 Bucket不包括管理员邮件
* @param commentId
* @return
*/
public static RBucket<Comment> getTaskCommentBucket(Long commentId) {
return redisson.getBucket(getMailKey("task-" + commentId + "-comment"));
}
/**
* 获取用户邮件订阅管理 UUID 缓存 Bucket
* @param mail
* @return 包含 uuid 的 Bucket用以建立 email → uuid 的映射关系
*/
public static RBucket<String> getMgrBucket(String mail) {
return redisson.getBucket(getMailKey("mgr-addr-" + mail));
}
/**
* 获取用户邮件订阅管理邮箱地址缓存 Bucket
* @param uuid
* @return 包含 email 的 Bucket用以建立 uuid → email 的映射关系
*/
public static RBucket<String> getMgrUuidBucket(String uuid) {
return redisson.getBucket(getMailKey("mgr-uuid-" + uuid));
}
/**
* 获取一个随机 Key 的用于过期事件的 Bucket
*/
public static RBucket<Object> getRandomExpirationBucket(){
List<Long> list = new ArrayList<>();
SecureRandom random = new SecureRandom();
list.add(Math.abs(random.nextLong()));
list.add(Math.abs(random.nextLong()));
return redisson.getBucket(getMailKey(Sqids.builder().build().encode(list)));
}
}
public static class Gravatar {
public static 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) {
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();
}
}
}

View File

@@ -0,0 +1,38 @@
package me.qwq.doghouse.config;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.annotation.ConfigInfo;
/**
* 实现自动化注册 Config
*/
@Slf4j
public class ConfigAutoRegistrar implements BeanDefinitionRegistryPostProcessor {
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider(false);
scanner.addIncludeFilter(new AnnotationTypeFilter(ConfigInfo.class));
scanner.findCandidateComponents("me.qwq.doghouse.entity.config").forEach(beanDefinition -> {
String className = beanDefinition.getBeanClassName();
try {
Class<?> clazz = Class.forName(className);
String beanName = clazz.getSimpleName().substring(0, 1).toLowerCase() + clazz.getSimpleName().substring(1);
BeanDefinitionBuilder factoryBean = BeanDefinitionBuilder
.genericBeanDefinition(ConfigServiceFactoryBean.class)
.addConstructorArgValue(clazz);
registry.registerBeanDefinition(beanName, factoryBean.getBeanDefinition());
log.info("Config part {} registered", beanName);
} catch (ClassNotFoundException e) {
throw new RuntimeException("Failed to load class: " + className, e);
}
});
}
}

View File

@@ -0,0 +1,34 @@
package me.qwq.doghouse.config;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.beans.factory.annotation.Autowired;
import me.qwq.doghouse.interfaces.IConfig;
import me.qwq.doghouse.service.ConfigService;
public class ConfigServiceFactoryBean<T extends IConfig<T>> implements FactoryBean<T> {
private final Class<T> targetClass;
@Autowired
private ConfigService configService;
public ConfigServiceFactoryBean(Class<T> targetClass) {
this.targetClass = targetClass;
}
@Override
public T getObject() throws Exception {
return configService.getConfig(targetClass);
}
@Override
public Class<?> getObjectType() {
return targetClass;
}
@Override
public boolean isSingleton() {
return true;
}
}

View File

@@ -0,0 +1,17 @@
package me.qwq.doghouse.config;
import org.springframework.boot.web.server.MimeMappings;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CustomMimeMapping implements WebServerFactoryCustomizer<ConfigurableServletWebServerFactory> {
@Override
public void customize(ConfigurableServletWebServerFactory factory) {
MimeMappings mappings = new MimeMappings(MimeMappings.DEFAULT);
mappings.add("avif", "image/avif");
factory.setMimeMappings(mappings);
}
}

View File

@@ -0,0 +1,23 @@
package me.qwq.doghouse.config;
import java.io.IOException;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import me.qwq.dogface.Dogface;
/**
* Reflections
* @author Doghole
*
*/
@Configuration
public class DogfaceConfig {
@Bean("dogface")
Dogface dogface() throws IOException {
return new Dogface();
}
}

View File

@@ -0,0 +1,93 @@
package me.qwq.doghouse.config;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.Ordered;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.thymeleaf.spring6.templateresolver.SpringResourceTemplateResolver;
import org.thymeleaf.templateresolver.ITemplateResolver;
import me.qwq.doghouse.interceptions.BlogInterceptor;
import me.qwq.doghouse.interceptions.ThemeStaticFilter;
import me.qwq.doghouse.service.ConfigService;
/**
* Doghouse 配置项
* @author Doghole
*
*/
@Configuration
@EnableConfigurationProperties(DoghouseProperties.class)
@Import(ConfigAutoRegistrar.class)
public class DoghouseConfig implements WebMvcConfigurer {
@Autowired
BlogInterceptor blogInterceptor;
@Autowired
DoghouseProperties doghouseProperties;
@Autowired
ConfigService configService;
/**
* 配置项注入拦截器
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(blogInterceptor)
.addPathPatterns("/**");
}
/**
* 上传文件资源映射、外部主题静态资源映射
* @param registry
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry
.addResourceHandler(doghouseProperties.getFileSqlPath() + "**")
.addResourceLocations("file:" + doghouseProperties.getFileUploadPath());
// URL: /themes/bleaching/css/main.css
// FS: workPath/themes/bleaching/static/css/main.css
registry
.addResourceHandler("/themes/**")
.addResourceLocations("file:" + doghouseProperties.getWorkPath() + "/themes/");
registry
.addResourceHandler("/tmp/themes/**")
.addResourceLocations("file:" + doghouseProperties.getWorkPath() + "/tmp/themes/");
}
@Bean
public FilterRegistrationBean<ThemeStaticFilter> themeStaticFilter() {
FilterRegistrationBean<ThemeStaticFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new ThemeStaticFilter());
bean.addUrlPatterns("/themes/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
return bean;
}
@Bean
public ITemplateResolver fileTemplateResolver() {
SpringResourceTemplateResolver resolver = new SpringResourceTemplateResolver();
resolver.setResolvablePatterns(Set.of("file:*"));
resolver.setPrefix(""); // 不要自动加前缀
resolver.setSuffix(".html"); // 自动补 .html
resolver.setTemplateMode("HTML");
resolver.setCharacterEncoding("UTF-8");
resolver.setOrder(1); // 比默认的 templateResolver 优先
resolver.setCheckExistence(true); // 文件不存在就跳过,交给下一个 resolver
return resolver;
}
}

View File

@@ -0,0 +1,84 @@
package me.qwq.doghouse.config;
import java.nio.file.Paths;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.info.BuildProperties;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import lombok.Data;
import lombok.experimental.Accessors;
import me.qwq.doghouse.BlogApplication;
import me.qwq.doghouse.entity.Asset;
@Data
@Accessors(chain = true)
@ConfigurationProperties(prefix = "doghouse")
@Component("doghouseProperties")
public class DoghouseProperties {
@Autowired
BuildProperties buildProperties;
String workPath = "/tmp/doghouse/";
/**
* 文件上传路径。默认是相对于 workPath 的路径。如需绝对路径请在前加 absolute:
*/
String fileUploadPath = "/upload/";
/**
* 文件在 SQL 中存储的路径前缀
* <p>
* 如:文件名为 filename.file, 则设置为 /upload/ 时, 可通过 http(s)://siteAddress/upload/{年份}/{月份}/filename.file 访问到文件
*/
String fileSqlPath = "/upload/";
/**
* 数据库 blog.db 路径是否使用相对路径, 默认是
*/
Boolean useRelativeDbPath = true;
public String getFileUploadPath() {
String finalPath;
if (StringUtils.startsWith(fileUploadPath, "absolute:")) {
finalPath = Paths.get(fileUploadPath.substring("absolute:".length())).toString();
}
else {
finalPath = Paths.get(workPath, fileUploadPath).toString();
}
return finalPath + Paths.get("/").toString();
}
public String getVersion() {
return buildProperties.getVersion();
}
public boolean isDebug() {
return BlogApplication.isDebug();
}
/**
* 将资产的 sql 路径转换为实际路径
* @param asset
* @return 资产在部署机器上的实际路径
*/
public String getFileUploadPath(@NonNull Asset asset) {
String path = asset.getPath();
if (StringUtils.isNoneBlank(path) && path.startsWith(getFileSqlPath())) {
path = path.replaceFirst(getFileSqlPath(), getFileUploadPath());
}
return path;
}
public String getFileSqlPath(@NonNull String path) {
if (StringUtils.isNoneBlank(path) && path.startsWith(getFileUploadPath())) {
path = path.replaceFirst(getFileUploadPath(), getFileSqlPath());
}
return path;
}
}

View File

@@ -0,0 +1,30 @@
package me.qwq.doghouse.config;
import java.util.Properties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
@Configuration
public class KaptchaConfig {
@Bean(name = "kaptchaProperties")
@ConfigurationProperties
Properties getKaptchaProperties() {
return new Properties();
}
@Bean
@DependsOn("kaptchaProperties")
DefaultKaptcha getDefaultKaptcha(@Autowired Properties kaptchaProperties) {
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
Config config = new Config(kaptchaProperties);
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}

View File

@@ -0,0 +1,32 @@
package me.qwq.doghouse.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;
/**
* MybatisPlus 配置
* @author Doghole
*
*/
@EnableTransactionManagement
@Configuration
public class MybatisPlusConfig {
/**
* 分页器插件
* @return
*/
@Bean
MybatisPlusInterceptor mybatisPlusInterceptor(){
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}

View File

@@ -0,0 +1,30 @@
package me.qwq.doghouse.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.quartz.Scheduler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
import me.qwq.doghouse.task.TaskSchedulerFactory;
@Configuration
public class QuartzConfig {
@Autowired
private TaskSchedulerFactory taskSchedulerFactory;
@Bean("schedulerFactoryBean")
SchedulerFactoryBean schedulerFactoryBean() {
SchedulerFactoryBean schedulerFactoryBean = new SchedulerFactoryBean();
schedulerFactoryBean.setJobFactory( taskSchedulerFactory);
return schedulerFactoryBean;
}
@Bean("scheduler")
@DependsOn("schedulerFactoryBean")
Scheduler scheduler(@Autowired SchedulerFactoryBean schedulerFactoryBean) {
return schedulerFactoryBean.getScheduler();
}
}

View File

@@ -0,0 +1,28 @@
package me.qwq.doghouse.config;
import org.reflections.Reflections;
import org.reflections.scanners.Scanners;
import org.reflections.util.ConfigurationBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Reflections
* @author Doghole
*
*/
@Configuration
public class ReflectionsConfig {
@Bean("reflections")
Reflections reflections() {
return new Reflections(new ConfigurationBuilder()
.addScanners(
Scanners.MethodsAnnotated,
Scanners.SubTypes,
Scanners.TypesAnnotated,
Scanners.FieldsAnnotated)
.forPackages("me.qwq.doghouse"));
}
}

View File

@@ -0,0 +1,67 @@
package me.qwq.doghouse.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import lombok.RequiredArgsConstructor;
import me.qwq.doghouse.service.UserService;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserService userService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
.cacheControl(cache -> cache.disable())
.frameOptions(frame -> frame.sameOrigin()))
.csrf(CsrfConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/layuiadmin/**").permitAll()
.requestMatchers("/admin/v2/**").authenticated()
.requestMatchers("/admin/*/login").permitAll()
.requestMatchers("/**").permitAll()
.anyRequest().authenticated()
)
// TODO 移除 formLogin改为 JsonLogin
.formLogin(form -> form // 开启表单登录,并指定登录页
.loginPage("/admin/v2/login") // 指定登录页
.loginProcessingUrl("/admin/v2/doLogin") // 处理登录请求的 URL
.defaultSuccessUrl("/admin/v2/", false) // 登录成功后默认跳转
.permitAll())
.logout(logout -> logout.logoutUrl("/admin/v2/logout").logoutSuccessUrl("/admin/v2/login")
.invalidateHttpSession(true).permitAll());
;
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(@Autowired PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userService);
provider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(provider);
}
@SuppressWarnings("deprecation")
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
}

View File

@@ -0,0 +1,10 @@
package me.qwq.doghouse.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 86400) //session 过期时间 24h
@ConditionalOnProperty(prefix = "doghouse", name = "spring-session-open", havingValue = "true")
public class SpringSessionConfig {
}

View File

@@ -0,0 +1,35 @@
package me.qwq.doghouse.constants;
import java.util.regex.Pattern;
public class UsefulRegex {
public static final String IGNORE_CASE = "(?i)";
public static final String BLANK = "^\\s*$";
public static final String OR = "|";
public static final String DOMAIN = "^(https?://)?([a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,6}$";
public static final String DOMAIN_WITHOUT_SCHEME = "^([a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,6}$";
public static final String URL = "^((https?:)?//)?([a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,6}(/.*?)?$";
public static final String URL_WITH_SCHEME = "^https?://([a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,6}(/.*?)?$";
public static final String URL_ENDS_WITH_SLASH = "^((https?:)?//)?([a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,6}(/.*?)?/$";
public static final String EMAIL = "^\\w+([-+.]\\w+)*@\\w+([-.]\\w+)*\\.\\w+([-.]\\w+)*$";
public static final String IPv4 = "^((?:(?:25[0-5]|2[0-4]\\d|((1\\d{2})|([1-9]?\\d)))\\.){3}(?:25[0-5]|2[0-4]\\d|((1\\d{2})|([1-9]?\\d))))$";
public static final String IPv4_WITH_WILECARD = "^((?:(?:25[0-5]|2[0-4]\\d|((1\\d{2})|([1-9]?\\d)|\\*))\\.){3}(?:25[0-5]|2[0-4]\\d|((1\\d{2})|([1-9]?\\d)|\\*)))$";
public static final String ANY_CHINESE = ".*?[\\u4E00-\\u9FA5]+.*?";
public static final Pattern ANY_CHINESE_PATTERN = Pattern.compile(UsefulRegex.ANY_CHINESE, Pattern.DOTALL);
public static final Pattern ADMIN_PATTERN = Pattern.compile("^[/\\\\]*admin[/\\\\]+(?<version>v\\d+)[/\\\\]*(?<action>.*?)$");
}

View File

@@ -0,0 +1,87 @@
package me.qwq.doghouse.controller.admin;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
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.ResponseBody;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.entity.User;
import me.qwq.doghouse.exception.RException;
import me.qwq.doghouse.pojo.dto.R;
import me.qwq.doghouse.service.UserService;
import me.qwq.doghouse.util.CookieUtils;
import me.qwq.doghouse.util.Cryptos;
import me.qwq.doghouse.util.SessionUtils;
@Controller
@RequestMapping("/admin/v2")
public class AdminController extends BaseController {
@Autowired
UserService adminUserService;
@GetMapping({"", "/", "/index"})
public String index() {
return "admin/index";
}
@GetMapping("/getCurrentUserInfo")
@ResponseBody
public R<?> getCurrentUserInfo() {
User user = SessionUtils.getUser(session);
user.setPassword(null);
return R.ok(user);
}
static final String EMPTY_PASSWORD = Cryptos.sha3("", 224);
static boolean passwordIsNotEmpty(String password) {
return StringUtils.isNotEmpty(password) &&
!password.equalsIgnoreCase(EMPTY_PASSWORD);
}
@PostMapping("/setCurrentUserInfo")
@ResponseBody
public R<?> setCurrentUserInfo(@Validated User changedUser, BindingResult bindingResult, String newPassword) {
if (bindingResult.hasErrors()) {
throw new RException(HttpStatus.BAD_REQUEST, bindingResult.getAllErrors().get(0).getDefaultMessage())
.setLogRequest(true);
}
User user = SessionUtils.getUser(session);
boolean hasNewPass = passwordIsNotEmpty(newPassword);
if (hasNewPass && !user.getPassword().equals(changedUser.getPassword())) {
throw RException.badRequest("旧密码不正确");
}
LambdaUpdateWrapper<User> uw = new LambdaUpdateWrapper<User>();
uw.set(User::getEmail, changedUser.getEmail())
.set(User::getNickname, changedUser.getNickname())
.set(User::getSite, changedUser.getSite());
if (hasNewPass) {
uw.set(User::getPassword, newPassword);
}
if (adminUserService.update(uw)) {
user = adminUserService.getById(user.getUserId());
SessionUtils.setUser(session, user);
CookieUtils.setUserToCookie(response, user);
return R.ok(user.setPassword(null));
}
throw RException.internalServerError();
}
}

View File

@@ -0,0 +1,399 @@
package me.qwq.doghouse.controller.admin;
import com.alibaba.fastjson2.util.DateUtils;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import jodd.io.FileUtil;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.config.DoghouseProperties;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.entity.Asset;
import me.qwq.doghouse.entity.config.SiteConfig;
import me.qwq.doghouse.entity.Post;
import me.qwq.doghouse.enums.FileTypeEnum;
import me.qwq.doghouse.exception.RException;
import me.qwq.doghouse.pojo.dto.LayPageReq;
import me.qwq.doghouse.pojo.dto.LayPageResp;
import me.qwq.doghouse.pojo.dto.R;
import me.qwq.doghouse.service.AssetService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.sqids.Sqids;
import java.awt.image.BufferedImage;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import javax.imageio.ImageIO;
/**
* 资产控制器
* @author Doghole
*/
@Controller
@RequestMapping("/admin/v2/asset")
@Slf4j
public class AssetController extends BaseController {
final private static SecureRandom SECURE_RANDOM = new SecureRandom();
final private static Sqids SQIDS = Sqids.builder().build();
@Autowired
DoghouseProperties doghouseProperties;
@Autowired
AssetService assetService;
@Autowired
SiteConfig siteConfig;
/**
* 主页
* @return
*/
@GetMapping({"", "/", "/index"})
String index() {
return "admin/asset/index";
}
@ResponseBody
@GetMapping("/list")
public LayPageResp<?> getAssetList(LayPageReq<Asset> pageReq) {
MPJLambdaWrapper<Asset> ew = new MPJLambdaWrapper<Asset>()
.selectAll(Asset.class)
.leftJoin(Post.class, Post::getPostId, Asset::getFromPostId)
.orderByDesc(Asset::getCreateTime);
IPage<Asset> page = assetService.page(pageReq, ew);
LayPageResp<Asset> result = new LayPageResp<>(page);
return result;
}
/**
* 上传文件
*/
@ResponseBody
@PostMapping("/uploadFile")
R<?> uploadFile(
@RequestParam(name = "file") MultipartFile file,
@RequestParam(name = "postId", required = false) Long postId) {
String msg = "文件上传失败";
String suffix = getSuffix(file);
if (suffix == null) {
return R.badRequest(msg);
}
String prefix = generateFilePrefix();
String filename = prefix + '.' + suffix;
String originalFilename = file.getOriginalFilename();
String originalPrefix = originalFilename.substring(0, originalFilename.length() -
suffix.length() - 1);
Calendar calendar = Calendar.getInstance();
String yearAndMonth = String.format("%d/%02d", calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH) + 1);
Path filePlacedDirPath = Paths.get(doghouseProperties.getFileUploadPath(), yearAndMonth);
File dir = filePlacedDirPath.toFile();
File destFile = filePlacedDirPath.resolve(filename).toFile();
Asset asset = new Asset();
try {
if (!dir.exists() && !dir.mkdirs()) {
msg = "创建文件夹 " + dir + " 失败";
log.error(msg);
return R.internalServerError(msg);
}
// file.transferTo(destFile); // 解决 jetty 上传文件问题
FileUtil.writeBytes(destFile, file.getBytes());
String fileUrl =
Paths.get(doghouseProperties.getFileSqlPath(), yearAndMonth, filename)
.toString().replace('\\', '/');
FileTypeEnum fileType = FileTypeEnum.getFileType(suffix);
asset
.setFromPostId(postId)
.setTitle(file.getOriginalFilename())
.setCreateTime(LocalDateTime.now())
.setFileType(fileType)
.setFileSize(destFile.length())
.setUpdateTime(LocalDateTime.now())
.setPath(fileUrl);
if (fileType == FileTypeEnum.IMAGE) {
BufferedImage image = ImageIO.read(destFile);
if (image != null) {
asset.setImageHeight(image.getHeight())
.setImageWidth(image.getWidth());
if (siteConfig.getImageCompress()) {
File avifFile = getAvif(suffix, destFile.toString(), prefix, filePlacedDirPath);
File webpFile = getWebp(suffix, destFile.toString(), prefix, filePlacedDirPath);
File finalFile = null;
String finalSuffix = null;
if (webpFile != null && avifFile != null) {
if (webpFile.length() >= avifFile.length()) {
finalFile = webpFile;
finalSuffix = ".webp";
avifFile.delete();
}
else {
finalFile = avifFile;
finalSuffix = ".avif";
webpFile.delete();
}
}
else if (webpFile == null && avifFile != null) {
finalFile = avifFile;
finalSuffix = ".avif";
}
else if (webpFile != null && avifFile == null) {
finalFile = webpFile;
finalSuffix = ".webp";
}
if (finalFile != null) {
fileUrl = Paths.get(doghouseProperties.getFileSqlPath(), yearAndMonth, finalFile.getName())
.toString().replace('\\', '/');
asset
.setPath(fileUrl)
.setFileSize(finalFile.length())
.setTitle(originalPrefix + finalSuffix);
destFile.delete();
}
}
}
else {
log.warn("Unsupported image format {}", suffix);
}
}
if (assetService.save(asset)) {
return R.ok(asset);
}
throw RException.badRequest("文件上传成功但保存失败");
} catch (IOException e) {
log.error(msg, e);
}
return R.internalServerError(msg);
}
/**
* 更新文件基本信息
* @param asset
* @return
*/
@ResponseBody
@PostMapping("/update")
R<?> update(Asset asset) {
if (Objects.isNull(asset) || Objects.isNull(asset.getAssetId())) {
throw RException.badRequest("提供的资产不能为空!");
}
LambdaUpdateWrapper<Asset> uw = new LambdaUpdateWrapper<>();
uw.eq(Asset::getAssetId, asset.getAssetId())
.set(Asset::getTitle, asset.getTitle())
.set(Asset::getDescription, asset.getDescription());
return R.judge(assetService.update(uw), "更新资产信息失败!");
}
/**
* 删除资产
* @param assetId
* @return
*/
@ResponseBody
@PostMapping("/delete")
R<?> delete(Long assetId) {
if (Objects.isNull(assetId)) {
throw RException.badRequest("提供的资产不能为空!");
}
Asset asset = assetService.getById(assetId);
if (Objects.isNull(asset)) {
log.info("试图删除已不存在于数据库内的资产");
return R.ok();
}
String uploadPath = doghouseProperties.getFileUploadPath(asset);
File file = new File(uploadPath);
if (file.exists()) {
return R.judge(file.delete() && assetService.removeById(assetId), "删除文件失败");
}
return R.judge(assetService.removeById(assetId), "删除文件失败");
}
/**
* 解绑资产
* @param assetId
* @return
*/
@ResponseBody
@PostMapping("/unlink")
R<?> unlink(Long assetId) {
if (Objects.isNull(assetId)) {
throw RException.badRequest("提供的资产不能为空!");
}
Asset asset = assetService.getById(assetId);
if (Objects.isNull(asset)) {
log.info("试图操作已不存在于数据库内的资产");
return R.ok();
}
if (Objects.isNull(asset.getFromPostId())) {
return R.ok("该资产并未绑定 Post");
}
return R.judge(
assetService.update(new LambdaUpdateWrapper<Asset>()
.eq(Asset::getAssetId, asset.getAssetId())
.set(Asset::getFromPostId, null)), "解绑失败");
}
/**
* 获取文件后缀,不包含".", 返回小写
*/
public static String getSuffix(MultipartFile file){
if (file == null) {
return null;
}
String fileName = file.getOriginalFilename();
if (StringUtils.isEmpty(fileName)) {
return null;
}
int lastIndexOfDot = fileName.lastIndexOf('.');
if (lastIndexOfDot == -1 || lastIndexOfDot == fileName.length() - 1) {
return "";
}
return fileName.substring(lastIndexOfDot + 1).toLowerCase();
}
/**
* 使用随机 Long + Sqids 作为文件名
* @param file
* @return
*/
public static String generateFilePrefix() {
String dateStr = DateUtils.format(LocalDateTime.now(), "yyyyMMdd_HHmmssSSS_");
StringBuilder sb = new StringBuilder();
sb.append(dateStr);
List<Long> list = new ArrayList<>();
list.add(Math.abs(SECURE_RANDOM.nextLong()));
String id = SQIDS.encode(list);
sb.append(id);
return sb.toString();
}
private static File getAvif(String suffix,
String originalFilePath, String finalPrefix, Path filePlacedDirPath) {
if (!"jpg".equals(suffix) && !"jpeg".equals(suffix) && !"png".equals(suffix)) {
return null;
}
String avifFilename = finalPrefix + ".avif";
String avifPath = filePlacedDirPath.resolve(avifFilename).toString();
List<String> avifCommand = List.of(
"avifenc",
"-q", "60",
"--qalpha", "80",
"-s", "6",
"-j", "all",
originalFilePath,
avifPath);
return executeFile(avifCommand, avifPath);
}
private static File getWebp(String suffix,
String originalFilePath, String finalPrefix, Path filePlacedDirPath) {
if (!"jpg".equals(suffix) && !"jpeg".equals(suffix) && !"png".equals(suffix) && !"gif".equals(suffix)) {
return null;
}
String webpFilename = finalPrefix + ".webp";
String webpPath = filePlacedDirPath.resolve(webpFilename).toString();
List<String> webpCommand =
!"gif".equals(suffix) ?
List.of("cwebp",
"-z", "9",
"-q", "100",
"-near_lossless", "60",
"-m", "6",
"-mt", originalFilePath,
"-o", webpPath):
List.of("gif2webp",
"-q", "90",
"-lossy", "-min_size",
"-m", "0",
"-mt", originalFilePath,
"-o", webpPath);
return executeFile(webpCommand, webpPath);
}
private static File executeFile(List<String> command, String filePath) {
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(true);
try {
Process process = pb.start();
try (var reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
log.debug("[img-encode] {}", line);
}
}
int exitCode = process.waitFor(15, TimeUnit.SECONDS) ? process.exitValue() : -1;
if (exitCode == 0) {
File out = FileUtil.file(filePath);
if (!out.exists()) {
log.warn("Command finished with code 0, but output file not found: {}", filePath);
return null;
}
return out;
}
log.warn("Execution failed with exit code: {}, command {}", exitCode, command);
return null;
}
catch (IOException | InterruptedException e) {
log.warn("Cannot execute command {}, {}", command, e);
return null;
}
}
}

View File

@@ -0,0 +1,169 @@
package me.qwq.doghouse.controller.admin;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
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 com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import jakarta.validation.Valid;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.entity.Cate;
import me.qwq.doghouse.entity.Meta;
import me.qwq.doghouse.enums.MetaTypeEnum;
import me.qwq.doghouse.exception.RException;
import me.qwq.doghouse.pojo.dto.LayPageReq;
import me.qwq.doghouse.pojo.dto.LayPageResp;
import me.qwq.doghouse.pojo.dto.R;
import me.qwq.doghouse.service.MetaService;
@Controller
@RequestMapping("/admin/v2/category")
public class CategoryController extends BaseController {
@Autowired
MetaService metaService;
@GetMapping({"", "/"})
private String index() {
request.setAttribute("title", "分类管理");
return "admin/category/index";
}
@GetMapping("/list")
@ResponseBody
private LayPageResp<Cate> getCategoryList(
LayPageReq<Cate> pageReq,
@RequestParam(required=false) Long parentId,
@RequestParam(name="unfolded[]", required=false) Long[] unfolded){
List<Long> unfoldedId;
if (unfolded != null && unfolded.length > 0) {
unfoldedId = Arrays.asList(unfolded);
}
else {
unfoldedId = new ArrayList<>();
}
if (parentId == null) {
IPage<Cate> page = metaService.pageTreeViewMetaWithChildren(Cate.class, pageReq, unfoldedId);
return new LayPageResp<Cate>(page);
}
else {
List<Cate> listCate = metaService.listTreeViewMetaWithChildren(Cate.class, parentId, unfoldedId);
return new LayPageResp<Cate>().setData(listCate).setCount(listCate.size());
}
}
/**
* 新增或编辑分类
* @param id
* @return
*/
@PostMapping("/update")
@ResponseBody
private R<?> update(@Valid Cate cate, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
throw new RException(HttpStatus.BAD_REQUEST, bindingResult.getAllErrors().get(0).getDefaultMessage());
}
if (cate == null) {
throw new RException(HttpStatus.BAD_REQUEST, "分类不允许为空");
}
if (StringUtils.isEmpty(cate.getFriendlyName())) {
cate.setFriendlyName(cate.getName().toLowerCase(Locale.ENGLISH));
}
// 查询友好名称是否重复
// 新增
long count = metaService.count(new MPJLambdaWrapper<Meta>()
.eq(Meta::getType, MetaTypeEnum.CATEGORY)
.eq(Meta::getFriendlyName, cate.getFriendlyName()));
if (count >= 1) {
if (cate.getMid() == null) {
throw new RException(HttpStatus.BAD_REQUEST, "分类友好名称与其他分类重复");
}
else {
Cate existCate = metaService.getOneJoin(Cate.class,
new MPJLambdaWrapper<Cate>(Cate.class).eq(Cate::getFriendlyName, cate.getFriendlyName()));
if (!existCate.getMid().equals(cate.getMid())) {
throw new RException(HttpStatus.BAD_REQUEST, "分类友好名称与其他分类重复");
}
}
}
if(cate.insertOrUpdate()) {
return R.ok();
}
throw new RException(HttpStatus.BAD_REQUEST);
}
/**
* 批量操作,支持批量删除、批量移动
* @param ids
* @param op 操作
* @param to 移动到
* @return
*/
@PostMapping("/batchOp")
@ResponseBody
private R<?> batchOp(
@RequestParam(value="ids[]", required=true) Long[] ids,
@RequestParam(value="op", required=false) String op,
@RequestParam(value="to", required=false) Long to) {
List<Long> idArray = Arrays.asList(ids);
if (op == null) {
// 批量删除
metaService.removeByIds(idArray);
return R.ok();
}
else if (to == null) {
// 批量移动到顶层
LambdaUpdateWrapper<Meta> uw = new UpdateWrapper<Meta>()
.lambda().in(Meta::getMid, idArray)
.set(Meta::getParentId, 0L);
metaService.update(uw);
return R.ok();
}
else {
// 批量移动到指定分类下
// 检查父子从属关系
List<Meta> childToParent = metaService.listFromChildToParent(to);
if (childToParent.isEmpty()) {
throw RException.badRequest("指定移动至的目标分类不存在");
}
// 如果传过来的 ids 中有 id 处在父子从属列表中
// 则表明是想将父类移动到子类,或者想将自己移动到自己
if (childToParent.stream().mapToLong(Meta::getMid).anyMatch(id -> idArray.contains(id))) {
throw RException.badRequest("不允许将父级或本级移动到子级或本级");
}
LambdaUpdateWrapper<Meta> uw = new UpdateWrapper<Meta>()
.lambda().in(Meta::getMid, idArray)
.set(Meta::getParentId, to);
metaService.update(uw);
return R.ok();
}
}
/**
* 删除指定 id 的分类
* @param id
* @return
*/
@PostMapping("/delete")
@ResponseBody
private R<?> delete(Long id) {
metaService.removeById(id);
return R.ok();
}
}

View File

@@ -0,0 +1,261 @@
package me.qwq.doghouse.controller.admin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.entity.User;
import me.qwq.doghouse.entity.Comment;
import me.qwq.doghouse.entity.config.CommentConfig;
import me.qwq.doghouse.entity.Post;
import me.qwq.doghouse.enums.CommentAction;
import me.qwq.doghouse.enums.CommentStatusEnum;
import me.qwq.doghouse.exception.RException;
import me.qwq.doghouse.pojo.bo.IpInfo;
import me.qwq.doghouse.pojo.dto.LayPageReq;
import me.qwq.doghouse.pojo.dto.LayPageResp;
import me.qwq.doghouse.pojo.dto.R;
import me.qwq.doghouse.service.CommentService;
import me.qwq.doghouse.service.IpService;
import me.qwq.doghouse.service.MailService;
import me.qwq.doghouse.service.post.PostService;
import me.qwq.doghouse.util.GeoIPUtil;
import me.qwq.doghouse.util.SessionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import java.util.Objects;
/**
* 评论管理
*
*/
@Controller
@RequestMapping("/admin/v2/comment")
public class CommentController extends BaseController {
@Autowired
private PostService postService;
@Autowired
private CommentService commentService;
@Autowired
CommentConfig commentConfig;
@Autowired
MailService mailService;
@Autowired
IpService ipService;
@GetMapping({"", "/", "/index"})
public String index(Long postId) {
request.setAttribute("title", "评论管理");
if (postId != null) {
Post post = postService.getById(postId);
if (post != null) {
request.setAttribute("postId", postId);
request.setAttribute("title", post.getPostTitle() + " 的评论管理");
}
}
return "admin/comment/index";
}
/**
* 更新评论
* @param comment
* @param bindingResult
* @return
*/
@ResponseBody
@PostMapping("/update")
public R<String> update(Comment comment, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
throw new RException(HttpStatus.BAD_REQUEST, bindingResult.getAllErrors().get(0).getDefaultMessage());
}
if (Objects.isNull(comment) || Objects.isNull(comment.getId())) {
return R.status(HttpStatus.BAD_REQUEST);
}
LambdaUpdateWrapper<Comment> uw = new LambdaUpdateWrapper<>();
uw.eq(Comment::getId, comment.getId())
.set(Comment::getAuthor, comment.getAuthor())
.set(Comment::getEmail, comment.getEmail())
.set(Comment::getUrl, comment.getUrl())
.set(Comment::getContent, comment.getContent())
.set(Comment::isMarkdown, comment.isMarkdown());
if (commentService.update(uw)) {
comment = commentService.getById(comment.getId());
mailService.addOrUpdateSendQueue(comment);
return R.status(HttpStatus.OK);
}
return R.status(HttpStatus.INTERNAL_SERVER_ERROR);
}
/**
* 管理员回复
* @param id
* @param content
* @param markdown
* @return
*/
@ResponseBody
@PostMapping("/reply")
public R<?> reply(Long id, String content, Boolean markdown) {
if (StringUtils.isBlank(content) || id == null) {
throw RException.badRequest();
}
User user = SessionUtils.getUser(session);
Comment comment = commentService.getById(id);
if (comment == null) {
return R.ok("回复的评论已不存在");
}
if (markdown == null) markdown = false;
if (markdown && !commentConfig.isMarkdownOn()) {
throw RException.badRequest("当前评论设置未开启 Markdown请先开启 Markdown");
}
Comment myComment = new Comment()
.setAgent(request.getHeader("User-Agent"))
.setAuthor(user.getNickname())
.setCommentStatus(CommentStatusEnum.RELEASED)
.setContent(content)//StringEscapeUtils.escapeEcmaScript())
.setEmail(user.getEmail())
.setIp(ipService.getClientIp(request))
.setPostId(comment.getPostId())
.setReplyTo(id)
.setUrl(user.getSite())
.setMarkdown(commentConfig.isMarkdownOn() && markdown);
if (commentService.save(myComment)) {
mailService.addOrUpdateSendQueue(myComment);
return R.ok();
}
throw RException.internalServerError("回复失败");
}
/**
* 转到对应 Post 的评论页
* @param id
* @return
*/
@GetMapping("/jumpTo")
public String jumpToPostPage(Long id) {
return "redirect:" + commentService.getJumpToUrl(id);
}
/**
* 评论分页列表
*
* @param pageReq 分页参数
* @param condition 筛选条件
* @return {@code me.qwq.doghouse.pojo.dto.LayPageResp<?>}
*/
@ResponseBody
@GetMapping("/list")
public LayPageResp<?> getCommentList(
LayPageReq<Comment> pageReq, Comment condition) {
MPJLambdaWrapper<Comment> ew = new MPJLambdaWrapper<Comment>()
.selectAll(Comment.class)
.selectAll(Post.class)
.leftJoin(Post.class, Post::getPostId, Comment::getPostId)
// TODO xxx
//.likeOrEqNotNull(condition)
.eq(Objects.nonNull(condition.getPostId()), Comment::getPostId, condition.getPostId())
.eq(Objects.nonNull(condition.getCommentStatus()), Comment::getCommentStatus, condition.getCommentStatus())
.eq(Comment::getIsDeleted, false)
.orderByDesc(Comment::getCreateTime);
Page<Comment> page = commentService.page(pageReq, ew);
// 查询 IP 归属地
for (Comment comment : page.getRecords()) {
IpInfo ipInfo = new IpInfo().setIp(comment.getIp());
GeoIPUtil.queryIpInfoGeoLite(ipInfo);
comment.setIpInfo(ipInfo);
}
LayPageResp<Comment> result = new LayPageResp<Comment>(page);
return result;
}
/**
* 评论批量操作
*
* @param blogInfo
* @return me.qwq.doghouse.pojo.dto.R
* @date 2019/8/29 12:22
*/
@ResponseBody
@PostMapping("/batchOp")
public R<?> batchOp(
@RequestParam("ids[]")
Long[] ids, CommentStatusEnum op) {
if (Objects.isNull(ids) || ids.length == 0) {
throw RException.badRequest("提供的评论 ID 不能为空");
}
LambdaUpdateWrapper<Comment> uw =
new LambdaUpdateWrapper<Comment>()
.in(Comment::getId, (Object[])ids);
if (Objects.isNull(op)) {
uw.set(Comment::getIsDeleted, true);
}
else {
uw.set(Comment::getCommentStatus, op);
}
if (commentService.update(uw)) {
for (Comment comment : commentService.list(
new LambdaQueryWrapper<Comment>()
.in(Comment::getId, (Object[])ids))) {
mailService.addOrUpdateSendQueue(comment);
}
return R.ok();
}
throw RException.internalServerError("批量操作失败");
}
/**
* 设置封禁 IP 或 emial
* @param ip
* @param email
* @return
*/
@ResponseBody
@PostMapping("/block")
public R<?> block(@RequestParam(required=false) String ip, @RequestParam(required=false) String email) {
if (StringUtils.isAllBlank(ip, email)) {
throw RException.badRequest();
}
commentConfig
.addBlockEmail(email)
.addBlockIp(ip);
if (commentConfig.saveOrUpdate()) {
if (commentConfig.getBlockEmailAction() == CommentAction.NONE || commentConfig.getBlockIpAction() == CommentAction.NONE) {
return R.ok(
"封禁成功,但您未设置触发封禁后的动作<br>" +
"请前往 <b>设置</b> >> <b>评论设置</b> 进行设置");
}
return R.ok();
}
throw RException.internalServerError("设置封禁失败");
}
}

View File

@@ -0,0 +1,76 @@
package me.qwq.doghouse.controller.admin;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.annotation.ConfigInfo;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.exception.PageNotFoundException;
import me.qwq.doghouse.interfaces.IConfig;
import me.qwq.doghouse.pojo.dto.R;
import me.qwq.doghouse.service.ConfigService;
import org.reflections.Reflections;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
/**
* 配置项控制器
* @author Doghole
*/
@Controller
@RequestMapping("/admin/v2/config")
@Slf4j
public class ConfigController extends BaseController {
@Autowired
ConfigService configService;
@Autowired
Reflections reflections;
/**
* 前端设置页面接口,其设置项必须实现 ConfigInterface 接口,且 resources/templates/admin/config 下有对应的 html 模板,例:
* <ol>
* <li>假设 <code>ConfigInfo</code> 注解 field 为 wiki则其 html 模板路径为 resources/templates/admin/config/wiki/index(.html)</li>
* <li>假设 <code>ConfigInfo</code> 注解 field 为 mail则其 html 模板路径为 resources/templates/admin/config/mail/index(.html)</li>
* <li>假设 <code>ConfigInfo</code> 注解 field 为空,配置类名为 SiteConfig则其 html 模板路径为 resources/templates/admin/config/site/index(.html)</li>
* </ol>
* @param configField
* @return
*/
@GetMapping({"/{configField}", "/{configField}/", "/{configField}/index"})
public String configPage(@PathVariable String configField)
{
ConfigInfo info = configService.getConfigInfoByField(configField);
if (info == null || !info.managed()) {
log.info("Try to enter management of {} but ConfigInfo is null or not managed", configField);
throw new PageNotFoundException();
}
request.setAttribute("title", info.name());
return "admin/config/" + configField + "/index";
}
/**
* 保存配置项统一接口
*/
@PostMapping({"/{configField}", "/{configField}/", "/{configField}/index"})
@ResponseBody
public <T extends IConfig<T>> R<?> saveConfig(@PathVariable String configField, @RequestBody JSONObject config) {
Class<T> clazz = configService.getConfigClassByField(configField);
if (clazz == null) {
throw new PageNotFoundException();
}
return R.judge(
configService.saveOrUpdate((T)config.to(clazz)),
"保存失败");
}
}

View File

@@ -0,0 +1,91 @@
package me.qwq.doghouse.controller.admin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import me.qwq.doghouse.config.DoghouseProperties;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.entity.Link;
import me.qwq.doghouse.exception.RException;
import me.qwq.doghouse.pojo.dto.LayPageReq;
import me.qwq.doghouse.pojo.dto.LayPageResp;
import me.qwq.doghouse.pojo.dto.R;
import me.qwq.doghouse.service.LinkService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import java.util.Objects;
/**
* 友链控制器
* @author Doghole
*/
@Controller
@RequestMapping("/admin/v2/link")
public class LinkController extends BaseController {
@Autowired
DoghouseProperties doghouseProperties;
@Autowired
LinkService linkService;
/**
* 主页
* @return
*/
@GetMapping({"", "/", "/index"})
String index() {
return "admin/link/index";
}
@ResponseBody
@GetMapping("/list")
public LayPageResp<?> getLinkList(LayPageReq<Link> pageReq) {
LambdaQueryWrapper<Link> ew = new LambdaQueryWrapper<Link>()
.eq(Link::getIsDeleted, false)
.orderByDesc(Link::getHealth, Link::getCreateTime);
IPage<Link> page = linkService.page(pageReq, ew);
LayPageResp<Link> result = new LayPageResp<>(page);
return result;
}
/**
* 更新文件基本信息
* @param asset
* @return
*/
@ResponseBody
@PostMapping("/update")
R<?> update(Link link) {
if (linkService.saveOrUpdate(link)) {
return R.ok();
}
throw RException.internalServerError("保存失败");
}
/**
* 删除文件
* @param assetId
* @return
*/
@ResponseBody
@PostMapping("/delete")
R<?> delete(Long id) {
if (Objects.isNull(id)) {
throw RException.badRequest("友链 ID 不允许为空!");
}
if (linkService.update(new LambdaUpdateWrapper<Link>()
.eq(Link::getId, id)
.set(Link::getIsDeleted, true))) {
return R.ok();
}
throw RException.internalServerError("删除友链失败");
}
}

View File

@@ -0,0 +1,92 @@
package me.qwq.doghouse.controller.admin;
import java.util.Objects;
import javax.security.auth.login.LoginException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
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.ResponseBody;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import jodd.util.Base64;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.entity.User;
import me.qwq.doghouse.pojo.dto.R;
import me.qwq.doghouse.service.UserService;
import me.qwq.doghouse.util.CookieUtils;
import me.qwq.doghouse.util.Cryptos;
import me.qwq.doghouse.util.SessionUtils;
@Controller
@RequestMapping("/admin/v2")
public class LoginController extends BaseController {
@Autowired
private UserService adminUserService;
@GetMapping("/login")
public String loginPage() {
if (isLogin()) {
return "redirect:/admin/v2/index";
}
return "admin/login";
}
@PostMapping("/login")
@ResponseBody
public R<?> login(String username, String password, String captcha, String redirect) throws LoginException {
if (StringUtils.isBlank(captcha)) {
throw new LoginException("验证码不能为空");
}
Object sessionCaptcha = session.getAttribute(SessionUtils.CAPTCHA);
if (Objects.isNull(sessionCaptcha) ||
!captcha.equalsIgnoreCase(sessionCaptcha.toString())) {
throw new LoginException("验证码错误");
}
if (StringUtils.isBlank(username) || !passwordIsNotEmpty(password)) {
throw new LoginException("用户名和密码不能为空");
}
User adminUser = adminUserService.getOne(
new LambdaQueryWrapper<User>()
.eq(User::getUsername, username)
.eq(User::getPassword, password));
if (adminUser != null) {
SessionUtils.setUser(session, adminUser);
CookieUtils.setUserToCookie(response, adminUser);
String to = "/admin/v2";
if (StringUtils.isNotEmpty(redirect)) {
to = Base64.decodeToString(redirect);
}
session.removeAttribute(SessionUtils.CAPTCHA);
return R.ok(to);
} else {
// 清除 session 中验证码,避免单次输入验证码后的碰撞
// 直到下一次请求 kaptcha 接口再重新设置
session.removeAttribute(SessionUtils.CAPTCHA);
throw new LoginException("用户名和/或密码错误");
}
}
@GetMapping("/logout")
public String logout() {
session.removeAttribute(SessionUtils.LOGIN_USER);
return "redirect:/admin/v2/login";
}
static final String EMPTY_PASSWORD = Cryptos.sha3("", 224);
static boolean passwordIsNotEmpty(String password) {
return StringUtils.isNotEmpty(password) &&
!password.equalsIgnoreCase(EMPTY_PASSWORD);
}
}

View File

@@ -0,0 +1,123 @@
package me.qwq.doghouse.controller.admin;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
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.ResponseBody;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import jakarta.validation.Valid;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.entity.Meta;
import me.qwq.doghouse.entity.Tag;
import me.qwq.doghouse.enums.MetaTypeEnum;
import me.qwq.doghouse.exception.RException;
import me.qwq.doghouse.pojo.dto.R;
import me.qwq.doghouse.service.MetaService;
@Controller
@RequestMapping("/admin/v2/tag")
public class TagController extends BaseController {
@Autowired
MetaService metaService;
@GetMapping({"", "/"})
private String index() {
request.setAttribute("title", "标签管理");
return "admin/tag/index";
}
/**
* 提供建议的 Tag一般用于输入提示
* @param input
* @return
*/
@GetMapping("/suggest")
@ResponseBody
private List<Tag> suggest(String input){
List<Tag> list = new ArrayList<>();
if (StringUtils.isBlank(input)) return list;
list = metaService.listJoin(Tag.class, new MPJLambdaWrapper<Tag>(Tag.class)
.selectAll(Tag.class)
.eq(Tag::getType, MetaTypeEnum.TAG)
.nested(i -> i
.like(Tag::getName, input).or()
.like(Tag::getFriendlyName, input))
.last(" LIMIT 5"));
return list;
}
/**
* 新增或编辑分类
* @param id
* @return
*/
@PostMapping("/update")
@ResponseBody
private R<?> update(@Valid Tag tag, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
throw new RException(HttpStatus.BAD_REQUEST, bindingResult.getAllErrors().get(0).getDefaultMessage());
}
if (tag == null) {
throw new RException(HttpStatus.BAD_REQUEST, "分类不允许为空");
}
if (StringUtils.isEmpty(tag.getFriendlyName())) {
tag.setFriendlyName(tag.getName().toLowerCase(Locale.ENGLISH));
}
// 查询友好名称是否重复
long count = metaService.count(new MPJLambdaWrapper<Meta>()
.eq(Meta::getType, MetaTypeEnum.TAG)
.eq(Meta::getFriendlyName, tag.getFriendlyName()));
if (count >= 1) {
if (tag.getMid() == null) {
throw new RException(HttpStatus.BAD_REQUEST, "友好名称与其他标签重复");
}
else {
Tag existTag = metaService.getOneJoin(Tag.class,
new MPJLambdaWrapper<Tag>(Tag.class).eq(Tag::getFriendlyName, tag.getFriendlyName()));
if (!existTag.getMid().equals(tag.getMid())) {
throw new RException(HttpStatus.BAD_REQUEST, "友好名称与其他标签重复");
}
}
}
if(metaService.saveOrUpdate(tag)) {
return R.ok();
}
throw new RException(HttpStatus.BAD_REQUEST);
}
/**
* 删除指定 id 的分类
* @param id
* @return
*/
@PostMapping("/deleteUnusedTags")
@ResponseBody
private R<?> deleteUnusedTags() {
metaService.removeUnusedTags();
return R.ok();
}
/**
* 删除指定 id 的分类
* @param id
* @return
*/
@PostMapping("/delete")
@ResponseBody
private R<?> delete(Long id) {
metaService.removeById(id);
return R.ok();
}
}

View File

@@ -0,0 +1,44 @@
package me.qwq.doghouse.controller.admin;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
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.ResponseBody;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.entity.config.SiteConfig;
import me.qwq.doghouse.pojo.dto.R;
import me.qwq.doghouse.service.ThemeService;
@Controller
@RequestMapping("/admin/v2/themes")
public class ThemeController extends BaseController {
@Autowired
SiteConfig siteConfig;
@Autowired
ThemeService themeService;
@GetMapping({"", "/", "/index"})
public String index() {
return "admin/themes/index";
}
@ResponseBody
@PostMapping("/setTheme")
public R<?> setTheme(String identity) {
return R.judge(siteConfig.setThemeIdentity(identity).saveOrUpdate());
}
@ResponseBody
@PostMapping("/extractTheme")
public R<?> extractTheme(String identity) {
return R.judge(() -> themeService.copyInternalToExternal(identity));
}
}

View File

@@ -0,0 +1,302 @@
package me.qwq.doghouse.controller.admin.post;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.component.PostTypeComponent;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.entity.Asset;
import me.qwq.doghouse.entity.Cate;
import me.qwq.doghouse.entity.Meta;
import me.qwq.doghouse.entity.Tag;
import me.qwq.doghouse.entity.Post;
import me.qwq.doghouse.enums.PostStatusEnum;
import me.qwq.doghouse.enums.PostTypeEnum;
import me.qwq.doghouse.exception.PageNotFoundException;
import me.qwq.doghouse.exception.RException;
import me.qwq.doghouse.interfaces.IPostTypeConfig;
import me.qwq.doghouse.pojo.dto.LayPageReq;
import me.qwq.doghouse.pojo.dto.LayPageResp;
import me.qwq.doghouse.pojo.dto.R;
import me.qwq.doghouse.service.AssetService;
import me.qwq.doghouse.service.MetaService;
import me.qwq.doghouse.service.RelationshipService;
import me.qwq.doghouse.service.post.AbstractPostService;
import me.qwq.doghouse.util.SpringContextHolder;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.text.StringEscapeUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ResolvableType;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.util.ObjectUtils;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
/**
* Post 后台控制器
* @author Doghole
*/
@Controller
@Slf4j
public abstract class AbstractAdminPostController<C extends IPostTypeConfig<C>, S extends AbstractPostService<S, C>> extends BaseController {
@Autowired
AssetService assetService;
@Autowired
MetaService metaService;
@Autowired
RelationshipService relationshipService;
@Autowired
PostTypeComponent postTypeComponent;
C postTypeConfig;
/**
* 注意本方法不可信,尤其是某些 PostTypeEnum 未重写本抽象类内的方法的时候。能用 postTypeComponent.getPostTypeConfig(postTypeEnum)
* 来获取的时候尽量不用本方法,本方法仅在无法确认 postTypeEnum 的时候,默认其类型是继承了本抽象类的 PostTypeService 对应的类型
* @return
*/
C getPostTypeConfig() {
if (postTypeConfig != null) return postTypeConfig;
ResolvableType type = ResolvableType.forClass(this.getClass()).as(AbstractAdminPostController.class);
ResolvableType iPageConfigType = type.getGeneric(0);
@SuppressWarnings("unchecked")
Class<C> iPageConfigClass = (Class<C>) iPageConfigType.resolve();
postTypeConfig = SpringContextHolder.getBean(iPageConfigClass);
return postTypeConfig;
}
S postService;
/**
* 注意本方法不可信,尤其是某些 PostTypeEnum 未重写本抽象类内的方法的时候。能用 postTypeComponent.getPostTypeService(postTypeEnum)
* 来获取的时候尽量不用本方法,本方法仅在无法确认 postTypeEnum 的时候,默认其类型是继承了本抽象类的 PostTypeService 对应的类型
* @return
*/
S getPostService() {
if (postService != null) return postService;
ResolvableType type = ResolvableType.forClass(this.getClass()).as(AbstractAdminPostController.class);
ResolvableType postServiceType = type.getGeneric(1);
@SuppressWarnings("unchecked")
Class<S> postServiceClass = (Class<S>) postServiceType.resolve();
postService = SpringContextHolder.getBean(postServiceClass);
return postService;
}
@GetMapping({"", "index", "/index"})
public String nonSlashIndex() {
RequestMapping requestMapping = this.getClass().getAnnotation(RequestMapping.class);
if (requestMapping == null || requestMapping.value().length == 0) {
throw new PageNotFoundException();
}
String uri = requestMapping.value()[0];
uri = uri.replaceAll("/+$", "");
return "redirect:" + uri + "/";
}
/**
* 列表界面
*/
@GetMapping("/")
public String index(
@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) Long tagId) {
PostTypeEnum postType = getPostTypeConfig().getPostType();
request.setAttribute("title", String.format("%s管理", postType.getNote()));
request.setAttribute("manageTitle", String.format("%s列表", postType.getNote()));
if (categoryId != null) {
Meta cate = metaService.getOne(new MPJLambdaWrapper<Meta>().eq(Meta::getMid, categoryId));
request.setAttribute("title", String.format("分类 %s 下的%s管理", cate.getName(), postType.getNote()));
request.setAttribute("manageTitle", String.format("分类 %s 下的%s列表", cate.getName(), postType.getNote()));
}
if (tagId != null) {
Meta tag = metaService.getOne(new MPJLambdaWrapper<Meta>().eq(Meta::getMid, tagId));;
request.setAttribute("title", String.format("标签 %s 下的%s管理", tag.getName(), postType.getNote()));
request.setAttribute("manageTitle", String.format("标签 %s 下的%s列表", tag.getName(), postType.getNote()));
}
return "admin/" + postType.toLowerString() + "/index";
}
/**
* 编辑
*/
@GetMapping("/edit")
public String edit(@RequestParam(required = false) Long postId) {
PostTypeEnum postType = getPostTypeConfig().getPostType();
Post post = getPostService().getById(postId);
if (post != null) {
request.setAttribute("post", post);
request.setAttribute("title", "编辑 - " + post.getPostTitle());
List<Asset> assets = assetService.list(
new LambdaQueryWrapper<Asset>().eq(Asset::getFromPostId, postId));
request.setAttribute("assets", StringEscapeUtils.escapeEcmaScript(JSONObject.toJSONString(assets)));
}
else {
request.setAttribute("title", "新增" + postType.getNote());
}
return "admin/" + postType.toLowerString() + "/edit";
}
/**
* 保存
* @param post 文章实体模型
* @param assets 包含的资产
* @param tagsName 标签
* @param categoriesId 分类
* @param postType 文章类型 - 文章|页面|Wiki
* @return
*/
@ResponseBody
@PostMapping("/edit")
public R<?> savePost(@Valid Post post,
BindingResult bindingResult,
@RequestParam(name="assets[]", required=false) Long[] assets,
@RequestParam(name="_tagsName[]", required=false) String[] tagsName,
@RequestParam(name="_categoriesId[]", required=false) Long[] categoriesId
) {
if (bindingResult.hasErrors()) {
throw new RException(HttpStatus.BAD_REQUEST, bindingResult.getAllErrors().get(0).getDefaultMessage());
}
if (ObjectUtils.isEmpty(post)) {
throw RException.badRequest("无法识别的请求");
}
Post existsPost = post.getPostId() != null ? getPostService().getById(post.getPostId()) : null;
Boolean isUpdate = existsPost != null;
getPostService().beforeSaving(post, assets, tagsName, categoriesId, isUpdate);
if (!getPostService().saveOrUpdate(post)) {
throw RException.internalServerError("保存失败");
}
getPostService().afterSaving(post, assets, tagsName, categoriesId, isUpdate);
Long postId = post.getPostId();
post = getPostService().getById(postId);
return R.ok(post);
}
/**
*
* @param pageReq 分页请求模型
* @param condition 查询条件
* @param categoryId 分类 ID可指定分类查询
* @param tagId 标签 ID可指定标签查询
* @return Post 结果列表分页模型
*/
@ResponseBody
@GetMapping("/list")
public LayPageResp<?> getPostList(
LayPageReq<Post> pageReq,
Post condition,
Long categoryId,
Long tagId,
String keywords) {
MPJLambdaWrapper<Post> ew = getPostService().getPostCommonWrapper()
// TODO
//.likeOrEqNotNull(condition) // postType 以当前控制器为准
.eq(Post::getType, getPostTypeConfig().getPostType())
.eq(Post::getIsDeleted, false)
.eq(Objects.nonNull(categoryId), Cate::getMid, categoryId)
.eq(Objects.nonNull(tagId), Tag::getMid, tagId)
.and(StringUtils.isNotBlank(keywords), o -> {
o.like(Post::getPostTitle, keywords)
.or().like(Post::getPostPreface, keywords)
.or().like(Post::getPostContent, keywords)
.or().like(Post::getKeywords, keywords);
})
.orderByDesc(Post::getCreateTime);
IPage<Post> page = getPostService().page(new Page<>(pageReq.getPage(), pageReq.getLimit()), ew);
LayPageResp<Post> result = new LayPageResp<>(page);
return result;
}
/**
* 对 Post 批量操作
* @param ids 批量操作的 Post ID 集合
* @param op 将要转换为的 PostStatusEnum
* @param to 移动至(针对 Wiki
* @return
*/
@ResponseBody
@PostMapping("/batchOp")
public R<?> batchOp(
@RequestParam(value="ids[]", required=true)
Long[] ids, PostStatusEnum postStatus) {
if (Objects.isNull(ids) || ids.length == 0) {
throw RException.badRequest("提供的批量操作 ID 不能为空");
}
List<Long> idArray = Arrays.asList(ids);
LambdaQueryWrapper<Post> qw = new LambdaQueryWrapper<Post>().in(Post::getPostId, idArray);
List<Post> waitForUpdates = postService.list(qw);
// 看下这批的 postType 是否一致, 不一致直接不允许, 简化操作
boolean allEqual = waitForUpdates.stream()
.map(Post::getType)
.distinct()
.count() <= 1;
if (!allEqual) {
throw RException.badRequest("批量操作类型不一致");
}
LambdaUpdateWrapper<Post> uw =
new LambdaUpdateWrapper<Post>()
.in(Post::getPostId, idArray);
boolean delete = Objects.isNull(postStatus);
// 逻辑删除
uw.set(delete, Post::getIsDeleted, true);
// 更改 Post 状态
uw.set(!delete, Post::getPostStatus, postStatus);
uw.set(!delete && postStatus != PostStatusEnum.RELEASED, Post::getSearchable, false);
return R.judge(
getPostService().update(uw),
"批量操作保存失败");
}
/**
* 删除文章
* @param postId
* @return
*/
@ResponseBody
@PostMapping("/delete")
public R<?> delete(@RequestParam Long postId) {
Post post = postService.getById(postId);
if (post == null || post.getIsDeleted()) {
throw RException.badRequest("删除失败, 请检查是否已删除或不存在");
}
AbstractPostService<?, ?> postTypeService = postTypeComponent.getPostTypeService(post.getType());
LambdaUpdateWrapper<Post> uw = new LambdaUpdateWrapper<Post>()
.eq(Post::getPostId, postId)
.set(Post::getIsDeleted, true)
.set(Post::getUpdateTime, LocalDateTime.now());
return R.judge(postTypeService.update(uw));
}
}

View File

@@ -0,0 +1,20 @@
package me.qwq.doghouse.controller.admin.post;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.entity.config.PageConfig;
import me.qwq.doghouse.service.post.PageService;
/**
* Post 后台控制器
* @author Doghole
*/
@Controller
@RequestMapping("/admin/v2/page")
@Slf4j
public class AdminPageController extends AbstractAdminPostController<PageConfig, PageService> {
}

View File

@@ -0,0 +1,19 @@
package me.qwq.doghouse.controller.admin.post;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.entity.config.SiteConfig;
import me.qwq.doghouse.service.post.PostService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
/**
* Post 后台控制器
* @author Doghole
*/
@Controller
@RequestMapping("/admin/v2/post")
@Slf4j
public class AdminPostController extends AbstractAdminPostController<SiteConfig, PostService> {
}

View File

@@ -0,0 +1,20 @@
package me.qwq.doghouse.controller.admin.post;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.entity.config.TweetConfig;
import me.qwq.doghouse.service.post.TweetService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
/**
* Post 后台控制器
* @author Doghole
*/
@Controller
@RequestMapping("/admin/v2/tweet")
@Slf4j
public class AdminTweetController extends AbstractAdminPostController<TweetConfig, TweetService> {
}

View File

@@ -0,0 +1,262 @@
package me.qwq.doghouse.controller.admin.post;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.entity.Cate;
import me.qwq.doghouse.entity.Tag;
import me.qwq.doghouse.entity.config.WikiConfig;
import me.qwq.doghouse.entity.Post;
import me.qwq.doghouse.enums.PostStatusEnum;
import me.qwq.doghouse.exception.RException;
import me.qwq.doghouse.pojo.dto.LayPageReq;
import me.qwq.doghouse.pojo.dto.LayPageResp;
import me.qwq.doghouse.pojo.dto.R;
import me.qwq.doghouse.service.post.WikiService;
import me.qwq.doghouse.util.EscapeUnescapeUtils;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import jakarta.servlet.http.Cookie;
/**
* Post 后台控制器
* @author Doghole
*/
@Controller
@RequestMapping("/admin/v2/wiki")
@Slf4j
public class AdminWikiController extends AbstractAdminPostController<WikiConfig, WikiService> {
@Autowired
WikiService wikiService;
/**
* 重写 Wiki 列表渲染
*/
@Override
public LayPageResp<?> getPostList(
LayPageReq<Post> pageReq,
Post condition,
Long categoryId,
Long tagId,
String keywords) {
if (condition != null) {
List<Long> unfolded = WikiService.getUnfoldedWikis(request);
if (unfolded == null) {
Cookie unfoldedCookie = new Cookie(WikiService.UNFOLDED_WIKIS,
EscapeUnescapeUtils.escape("[]"));
unfoldedCookie.setPath("/");
response.addCookie(unfoldedCookie);
}
MPJLambdaWrapper<Post> ew = wikiService.getWikiDefaultWrapper(true)
// TODO
//.likeOrEqNotNull(condition)
.eq(Objects.nonNull(condition.getParentId()), Post::getParentId, condition.getParentId())
.eq(Objects.nonNull(condition.getType()), Post::getType, condition.getType())
.eq(Post::getIsDeleted, false)
.eq(Objects.nonNull(categoryId), Cate::getMid, categoryId)
.eq(Objects.nonNull(tagId), Tag::getMid, tagId)
.and(StringUtils.isNotBlank(keywords), o -> {
o.like(Post::getPostTitle, keywords)
.or().like(Post::getPostPreface, keywords)
.or().like(Post::getPostContent, keywords)
.or().like(Post::getKeywords, keywords);
});
if (condition.getParentId() == null) {
// 分页模型
IPage<Post> page = wikiService.pageWikiWithChildren(pageReq, ew, unfolded);
LayPageResp<Post> result = new LayPageResp<>(page);
return result;
}
else {
// 单独查询
List<Post> listPost = wikiService.listWikiWithChildren(ew, unfolded);
return new LayPageResp<Post>().setData(listPost).setCount(listPost.size());
}
}
else {
return super.getPostList(pageReq, condition, categoryId, tagId, keywords);
}
}
@Override
public R<?> batchOp(
@RequestParam(value="ids[]", required=true)
Long[] ids, PostStatusEnum postStatus) {
if (Objects.isNull(ids) || ids.length == 0) {
throw RException.badRequest("提供的批量操作 ID 不能为空");
}
// 接口方法未实现 to 参数接收, 这里手动从 request 里面取
Long to = null;
try {
String toString = request.getParameter("to");
if (!StringUtils.isBlank(toString)) {
to = Long.valueOf(toString);
}
}
catch (NumberFormatException e) {
throw RException.badRequest("错误的移动层级");
}
List<Long> idArray = Arrays.asList(ids);
LambdaUpdateWrapper<Post> uw =
new LambdaUpdateWrapper<Post>()
.in(Post::getPostId, idArray);
boolean delete = Objects.isNull(postStatus) && Objects.isNull(to); // 删除
boolean changeStatus = !delete && Objects.nonNull(postStatus); // 更新状态
boolean move = !delete && !changeStatus && to != null; // 移动层级
if (!(delete || changeStatus || move)) {
throw RException.badRequest("非法操作");
}
if (!move) {
uw.set(delete, Post::getIsDeleted, true);
uw.set(changeStatus, Post::getPostStatus, postStatus);
uw.set(changeStatus && postStatus != PostStatusEnum.RELEASED, Post::getSearchable, false);
if (wikiService.update(uw)) {
// 更新可能包含的子级
boolean subOperationFlag = true;
for (Long id : idArray) {
Post post = wikiService.getById(id);
if (delete ?
wikiService.deleteChildren(post) :
wikiService.resetSearchableStatus(post)) {
continue;
}
else {
subOperationFlag = false;
log.warn(delete ?
"删除 postId={}, title={} 子项失败":
"重设 postId={}, title={} Wiki 子项可搜索状态失败",
id, post.getTitle());
}
}
// TODO 这里应该做成事务
return R.judge(subOperationFlag,
delete ?
"删除部分子项失败, 请查看输出日志" :
"重设部分子项可搜索状态失败,请查看输出日志");
}
else {
throw RException.internalServerError("批量操作保存失败");
}
}
// to
if (to < -1L) {
// 移动到顶级
to = -1L;
}
else if (to >= 0) {
// 检查父子从属关系
List<Post> childToParent = wikiService.listFromChildToParent(to, true, true);
if (childToParent.isEmpty()) {
throw RException.badRequest("指定移动至的目标 Wiki 不存在");
}
// 如果传过来的 ids 中有 id 处在父子从属列表中
// 则表明是想将父类移动到子类,或者想将自己移动到自己
if (childToParent.stream().mapToLong(Post::getPostId).anyMatch(id -> idArray.contains(id))) {
throw RException.badRequest("不允许将父级或本级移动到子级或本级");
}
}
// 获取欲移动的 Post 的原 parentId待移动成功后重排这些 parent 下的子级
List<Long> originalParentIds = getPostService().getParentIds(idArray);
try {
if (!getPostService().batchMoveTo(idArray, to)) {
throw RException.badRequest("重设排序失败");
}
for (Long parentId : originalParentIds) {
if (!wikiService.reorderChildren(parentId)) {
log.warn(
"重排原 parentId = {} 下子级失败,但移动将继续", parentId);
}
}
// 移动后重设可搜索状态
if (to != -1L) {
Post toPost = getPostService().getById(to);
if (!wikiService.resetSearchableStatus(toPost)) {
log.warn("重设 postId={}, title={} Wiki 子项可搜索状态失败", to, toPost.getTitle());
throw RException.internalServerError("重设部分子项可搜索状态失败,请查看输出日志");
}
}
else {
// 判断原本是否有密码
uw.set(Post::getSearchable, true)
.eq(Post::getPassword, "");
if (!getPostService().update(uw)) {
throw RException.internalServerError("重设移动到顶级的可搜索状态失败");
}
boolean resetSearchableOk = true;
for (Long id : idArray) {
Post post = getPostService().getById(id);
if (wikiService.resetSearchableStatus(post)) {
continue;
}
else {
resetSearchableOk = false;
log.warn("重设 postId={}, title={} Wiki 子项可搜索状态失败", id, post.getTitle());
}
}
if (resetSearchableOk)
return R.ok();
else {
throw RException.internalServerError("重设部分子项可搜索状态失败,请查看输出日志");
}
}
return R.ok();
}
catch (Exception e) {
if (e instanceof RException) {
throw e;
}
log.error("批量移动层级失败", e);
}
throw RException.internalServerError("批量操作失败");
}
/**
* 重排, 只有 Wiki 有这个操作
* <p>TODO 前端使用拖拽来实现重排
* @param postId
* @param up
* @return
*/
@ResponseBody
@PostMapping("/reorder")
public R<?> reorder(@RequestParam Long postId, boolean up) {
try {
if (!getPostService().reorder(postId, up)) {
throw RException.badRequest("重设排序失败");
}
} catch (RException e) {
throw e;
}
return R.ok(postId);
}
}

View File

@@ -0,0 +1,223 @@
package me.qwq.doghouse.controller.api.v1;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import org.apache.commons.lang3.StringUtils;
import org.jdom2.DocType;
import org.jdom2.Document;
import org.jdom2.Element;
import org.jdom2.Namespace;
import org.jdom2.output.Format;
import org.jdom2.output.XMLOutputter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.alibaba.fastjson2.JSONObject;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import me.qwq.dogface.Dogface;
import me.qwq.doghouse.component.Rk;
import me.qwq.doghouse.exception.PageNotFoundException;
import me.qwq.doghouse.pojo.dto.R;
import me.qwq.doghouse.service.CommentService;
import me.qwq.doghouse.service.StatisticsService;
import me.qwq.doghouse.util.CompressUtils;
import me.qwq.doghouse.util.GravatarUtils;
@RestController
@RequestMapping("/api/v1")
@Slf4j
public class ApiControllerV1 {
private static final Namespace SVG_NAMESPACE = Namespace.getNamespace("http://www.w3.org/2000/svg");
private static final String[] POST_FILLS_VAR = {"var(--post-fill-0)", "var(--post-fill-1)", "var(--post-fill-2)", "var(--post-fill-3)", "var(--post-fill-4)"};
private static final String[] POST_STROKES_VAR = {"var(--post-stroke-0)", "var(--post-stroke-1)", "var(--post-stroke-2)", "var(--post-stroke-3)", "var(--post-stroke-4)"};
private static final String[] COMMENT_FILLS_VAR = {"var(--comment-fill-0)", "var(--comment-fill-1)", "var(--comment-fill-2)", "var(--comment-fill-3)", "var(--comment-fill-4)"};
private static final String[] COMMENT_STROKES_VAR = {"var(--comment-stroke-0)", "var(--comment-stroke-1)", "var(--comment-stroke-2)", "var(--comment-stroke-3)", "var(--comment-stroke-4)"};
@Autowired
StatisticsService statisticsService;
@Autowired
GravatarUtils gravatarUtils;
@Autowired
CommentService commentService;
@Autowired
Dogface dogface;
@Autowired
HttpServletRequest request;
@Autowired
HttpServletResponse response;
@GetMapping(value={
"/gravatar/{hash}",
"/gravatar/{hash}/{size}"}, produces = MediaType.IMAGE_JPEG_VALUE)
public byte[] getGravatar(@PathVariable(value="hash") String hash,
@PathVariable(value="size", required=false) Integer size,
@RequestParam(required=false) String nocache) {
if (size == null || size <= 0) size = 36;
CompletableFuture<byte[]> future =
gravatarUtils.getReversedProxyGravatarAsync(hash, size, StringUtils.isBlank(nocache));
byte[] avatar;
try {
avatar = future.get();
}
catch (InterruptedException | ExecutionException e) {
throw new PageNotFoundException();
}
if (avatar == null) {
throw new PageNotFoundException();
}
try {
response.setHeader("Content-Encoding", "gzip");
return CompressUtils.compressGZ(avatar);
}
catch (IOException e) {
log.error("GZIP 压缩 gravatar 头像错误", e);
throw new PageNotFoundException();
}
}
@GetMapping(value={
"/dogavatar/{hash}",
"/dogavatar/{hash}/{size}"}, produces = MediaType.IMAGE_PNG_VALUE)
public byte[] getDogavatar(@PathVariable(value="hash") String hash,
@PathVariable(value="size", required=false) Integer size) throws IOException {
if (size == null || size <= 0) size = 36;
byte[] avatar = dogface.getDogavatar(hash, size);
if (avatar == null) {
throw new PageNotFoundException();
}
try {
response.setHeader("Content-Encoding", "gzip");
return CompressUtils.compressGZ(avatar);
}
catch (IOException e) {
log.error("GZIP 压缩 gravatar 头像错误", e);
throw new PageNotFoundException();
}
}
@GetMapping(value={
"/gravatars/clearCache"})
public R<?> clearGravatarCache() {
Rk.Gravatar.clearGravatarCache();
return R.ok();
}
private static String colorClassifier(int count, String[] colorSet) {
if (count < 0) count = 0;
if (count < 4) return colorSet[count];
return colorSet[4];
}
@GetMapping(path="/getPassDaysStatistics", produces="application/json")
public JSONObject getPassDaysStatistics(Integer passDays) {
return statisticsService.getPostAndCommentCountsWithDates(passDays, null);
}
public String gitLikeStatisticsSvg(Integer cols, Integer rows) {
if (cols == null || cols < 1) {
cols = 1;
}
if (rows == null || rows < 1) {
rows = 1;
}
int span = 2, size = 8, stroke = 1;
Integer total = cols * rows;
Element svgRoot = new Element("svg");
// 设置命名空间
svgRoot.setNamespace(SVG_NAMESPACE);
int
w = (span + size) * cols - span + stroke * 2,
h = (span + size) * rows - span + stroke * 2;
svgRoot.setAttribute("width", "100%");
svgRoot.setAttribute("height", "100%");
svgRoot.setAttribute("viewBox", String.format("0 0 %d %d", w, h));
// 获取 n 日以来的文章和评论统计
JSONObject jo = statisticsService.getPostAndCommentCountsWithDates(total, null);
List<String> dates = jo.getList("dates", String.class);
List<Integer> postCounts = jo.getList("postCounts", Integer.class);
List<Integer> commentCounts = jo.getList("commentCounts", Integer.class);
for (int col = 0; col < cols; col++) {
for (int row = 0; row < rows; row++) {
int offset = col * rows + row;
String date = dates.get(offset);
Integer posts = postCounts.get(offset);
Integer comments = commentCounts.get(offset);
Boolean hasPost = posts != 0;
Boolean hasComment = comments != 0;
int x = stroke + (span + size) * col;
int y = stroke + (span + size) * row;
Element postRect = new Element("rect");
postRect.setAttribute("x", Integer.toString(x));
postRect.setAttribute("y", Integer.toString(y));
postRect.setAttribute("stroke-width", "1.2");
postRect.setAttribute("stroke",
!hasComment ?
colorClassifier(posts, POST_STROKES_VAR) :
colorClassifier(comments, COMMENT_STROKES_VAR));
postRect.setAttribute("width", Integer.toString(size));
postRect.setAttribute("height", Integer.toString(size));
postRect.setAttribute("rx", Integer.toString(stroke));
postRect.setAttribute("ry", Integer.toString(stroke));
postRect.setAttribute("fill",
(hasComment && !hasPost) ?
colorClassifier(comments, COMMENT_FILLS_VAR):
colorClassifier(posts, POST_FILLS_VAR));
postRect.setAttribute("data-posts", posts.toString());
postRect.setAttribute("data-comments", comments.toString());
postRect.setAttribute("data-date", date);
postRect.setNamespace(SVG_NAMESPACE);
// 将矩形元素添加到 <svg> 元素中
svgRoot.addContent(postRect);
}
}
// 创建文档对象
Document svgDoc = new Document(svgRoot);
DocType svgDocType = new DocType("svg");
svgDoc.setDocType(svgDocType);
// 输出SVG文档到控制台
XMLOutputter xmlOutputter = new XMLOutputter(Format.getPrettyFormat());
return xmlOutputter.outputString(svgDoc);
}
}

View File

@@ -0,0 +1,69 @@
package me.qwq.doghouse.controller.api.v2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.alibaba.fastjson2.JSONObject;
import me.qwq.dogface.Dogface;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.entity.Comment;
import me.qwq.doghouse.exception.RException;
import me.qwq.doghouse.pojo.dto.R;
import me.qwq.doghouse.service.CommentService;
import me.qwq.doghouse.service.StatisticsService;
import me.qwq.doghouse.util.GravatarUtils;
import me.qwq.doghouse.util.MarkDownUtils;
import me.qwq.doghouse.util.SessionUtils;
@RestController
@RequestMapping("/api/v2/comment")
public class CommentApiControllerV2 extends BaseController {
@Autowired
StatisticsService statisticsService;
@Autowired
GravatarUtils gravatarUtils;
@Autowired
CommentService commentService;
@Autowired
Dogface dogface;
@PostMapping("/preview")
public R<?> preview(@RequestBody JSONObject jo) {
String markDown = MarkDownUtils.commentToHtml(jo.getString("content"));
return R.ok(markDown);
}
@GetMapping("/getContentSource/{id}")
public R<?> getContentSource(@PathVariable("id") Long id) {
if (!isLogin() && !SessionUtils.containsCommentId(session, id)) {
throw RException.unauthorized("非法请求");
}
Comment commentForEdit = commentService.getById(id);
if (commentForEdit == null) {
// 这一般不会进来,除非是管理员
throw RException.badRequest("非法请求");
}
JSONObject jo = new JSONObject();
jo.put("author", commentForEdit.getAuthor());
jo.put("isMarkdown", commentForEdit.isMarkdown());
jo.put("email", commentForEdit.getEmail());
jo.put("url", commentForEdit.getUrl());
jo.put("content", commentForEdit.getContent());
jo.put("subscribeReply", commentForEdit.isSubscribeReply());
jo.put("commentStatu", commentForEdit.getCommentStatus());
jo.put("id", commentForEdit.getId());
return R.ok(jo);
}
}

View File

@@ -0,0 +1,104 @@
package me.qwq.doghouse.controller.api.v2;
import java.io.IOException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import lombok.extern.slf4j.Slf4j;
import me.qwq.dogface.Dogface;
import me.qwq.doghouse.component.Rk;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.exception.PageNotFoundException;
import me.qwq.doghouse.pojo.dto.R;
import me.qwq.doghouse.service.CommentService;
import me.qwq.doghouse.service.StatisticsService;
import me.qwq.doghouse.util.CompressUtils;
import me.qwq.doghouse.util.GravatarUtils;
@RestController
@RequestMapping("/api/v2/gravatar")
@Slf4j
public class GravatarApiControllerV2 extends BaseController {
@Autowired
StatisticsService statisticsService;
@Autowired
GravatarUtils gravatarUtils;
@Autowired
CommentService commentService;
@Autowired
Dogface dogface;
@GetMapping(value={
"/get/{hash}",
"/get/{hash}/{size}"}, produces = MediaType.IMAGE_JPEG_VALUE)
public byte[] getGravatar(@PathVariable(value="hash") String hash,
@PathVariable(value="size", required=false) Integer size,
@RequestParam(required=false) String nocache) {
if (size == null || size <= 0) size = 36;
CompletableFuture<byte[]> future =
gravatarUtils.getReversedProxyGravatarAsync(hash, size, StringUtils.isBlank(nocache));
byte[] avatar;
try {
avatar = future.get();
}
catch (InterruptedException | ExecutionException e) {
throw new PageNotFoundException();
}
if (avatar == null) {
throw new PageNotFoundException();
}
try {
response.setHeader("Content-Encoding", "gzip");
return CompressUtils.compressGZ(avatar);
}
catch (IOException e) {
log.error("GZIP 压缩 gravatar 头像错误", e);
throw new PageNotFoundException();
}
}
@GetMapping(value={
"/dog/{seed}",
"/dog/{seed}/{size}"}, produces = MediaType.IMAGE_PNG_VALUE)
public byte[] getDogavatar(@PathVariable(value="seed") String seed,
@PathVariable(value="size", required=false) Integer size) throws IOException {
if (size == null || size <= 0) size = 36;
byte[] avatar = dogface.getDogavatar(seed, size);
if (avatar == null) {
throw new PageNotFoundException();
}
try {
response.setHeader("Content-Encoding", "gzip");
return CompressUtils.compressGZ(avatar);
}
catch (IOException e) {
log.error("GZIP 压缩 gravatar 头像错误", e);
throw new PageNotFoundException();
}
}
@GetMapping(value={
"/clearCache"})
public R<?> clearGravatarCache() {
Rk.Gravatar.clearGravatarCache();
return R.ok();
}
}

View File

@@ -0,0 +1,161 @@
package me.qwq.doghouse.controller.blog;
import com.rometools.rome.feed.synd.*;
import com.rometools.rome.io.FeedException;
import com.rometools.rome.io.SyndFeedOutput;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.entity.config.SiteConfig;
import me.qwq.doghouse.entity.Post;
import me.qwq.doghouse.exception.PageNotFoundException;
import me.qwq.doghouse.service.*;
import me.qwq.doghouse.service.post.PostService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* Feed
*
* @author Doghole
* @date 2021/12/23
*/
@Controller
public class FeedController extends BaseController {
public final static String ATOM_1_0 = "atom_1.0";
public final static String RSS_2_0 = "rss_2.0";
public final static String CATEGORY_PATH = "/category/";
public final static String COMMA = ",";
public final static String FEED_PATH = "/feed.xml";
public final static String TEXT_HTML = "text/html";
public final static String POST_ENCRYPTED = "加密文章 ";
public final static String POST_ENCRYPTED_DESCRIPTION = "该文章已加密,请输入密码查看";
public final static String RSS_LANGUAGE = "zh-cn";
public final static String SELF = "self";
public final static String SLASH = "/";
public final static String UTF8 = "utf-8";
public final static String MORE = "<!--more-->";
@Autowired
PostService postService;
@Autowired
ConfigService configService;
@Autowired
SiteConfig siteConfig;
@Autowired
MarkdownService markdownService;
@SuppressWarnings("serial")
@GetMapping(value=FEED_PATH, produces = "application/xml")
@ResponseBody
public String feed() throws FeedException {
if (!siteConfig.getRssOn()) {
throw new PageNotFoundException();
}
final String siteAddr = !siteConfig.getAddress().endsWith(SLASH) ?
siteConfig.getAddress() : siteConfig.getAddress().substring(0, siteConfig.getAddress().length());
SyndLink rssLink = new SyndLinkImpl() {{
setHref(new StringBuilder(siteAddr).append(FEED_PATH).toString());
setRel(SELF);
}};
SyndLink siteLink = new SyndLinkImpl() {{setHref(siteAddr);}};
List<SyndLink> links = new ArrayList<SyndLink>() {{
add(rssLink);add(siteLink);
}};
SyndFeed feed = new SyndFeedImpl();
feed.setFeedType(ATOM_1_0);
feed.setCopyright(siteConfig.getCopyright());
feed.setDescription(siteConfig.getDescription());
feed.setEncoding(UTF8);
feed.setLinks(links);
feed.setLanguage(RSS_LANGUAGE);
feed.setTitle(siteConfig.getName());
List<SyndEntry> entries = new ArrayList<>();
List<Post> posts = postService.getLatestPosts(siteConfig.getRecentSize());
for (Post post : posts) {
boolean hasPassword = StringUtils.isNotEmpty(post.getPassword());
SyndEntry entry = new SyndEntryImpl();
entry.setTitle(hasPassword?POST_ENCRYPTED + post.getPostId():post.getPostTitle());
// Add content if it's not encrypted
if (!hasPassword) {
SyndContent content = new SyndContentImpl();
content.setType(TEXT_HTML);
content.setValue(markdownService.renderPostContent(post));
entry.setContents(new ArrayList<SyndContent>(){{add(content);}});
}
// Add description if it's present
if (hasPassword) {
SyndContent description = new SyndContentImpl();
description.setType(TEXT_HTML);
description.setValue(POST_ENCRYPTED_DESCRIPTION);
entry.setDescription(description);
}
else {
SyndContent description = new SyndContentImpl();
description.setType(TEXT_HTML);
description.setValue(markdownService.renderPostPreface(post));
entry.setDescription(description);
}
entry.setLink(new StringBuilder(siteAddr).append(post.getLink()).toString());
entry.setPublishedDate(Date.from(post.getCreateTime().atZone(ZoneId.systemDefault()).toInstant()));
entry.setUpdatedDate(Date.from(post.getUpdateTime().atZone(ZoneId.systemDefault()).toInstant()));
if (StringUtils.isBlank(post.getPassword()) && StringUtils.isNotBlank(post.getCategoriesName())) {
List<SyndCategory> categories = new ArrayList<>();
String[] categoriesFriendlyNames = post.getCategoriesFriendlyName().split(COMMA);
String[] categoriesNames = post.getCategoriesName().split(COMMA);
for (int i = 0; i < categoriesFriendlyNames.length; i++) {
String cateName = categoriesNames[i],
cateFriendlyName = categoriesFriendlyNames[i];
try {
categories.add(new SyndCategoryImpl() {{
this.setLabel(cateFriendlyName);
this.setName(cateName);
this.setTaxonomyUri(
new StringBuilder()
.append(siteAddr)
.append(CATEGORY_PATH)
.append(URLEncoder.encode(cateFriendlyName, UTF8)).toString());
}});
} catch (UnsupportedEncodingException e) {
continue;
}
}
if (!categories.isEmpty()) {
entry.setCategories(categories);
}
}
entries.add(entry);
}
feed.setEntries(entries);
SyndFeedOutput output = new SyndFeedOutput();
return output.outputString(feed);
}
}

View File

@@ -0,0 +1,35 @@
package me.qwq.doghouse.controller.blog;
import java.io.ByteArrayOutputStream;
import javax.imageio.ImageIO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.util.SessionUtils;
@RestController
@RequestMapping("/captcha")
public class KaptchaController extends BaseController {
@Autowired
DefaultKaptcha kaptcha;
@GetMapping(value="/get", produces = MediaType.IMAGE_JPEG_VALUE)
public byte[] getCaptcha() throws Exception {
String createText = kaptcha.createText();
ByteArrayOutputStream os = new ByteArrayOutputStream();
session.setAttribute(SessionUtils.CAPTCHA, createText);
ImageIO.write(kaptcha.createImage(createText), "jpg", os);
byte[] result = os.toByteArray();
os.close();
return result;
}
}

View File

@@ -0,0 +1,362 @@
package me.qwq.doghouse.controller.blog;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.component.PostTypeComponent;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.entity.*;
import me.qwq.doghouse.entity.config.CommentConfig;
import me.qwq.doghouse.entity.config.SiteConfig;
import me.qwq.doghouse.entity.Post;
import me.qwq.doghouse.enums.PostStatusEnum;
import me.qwq.doghouse.exception.PageNotFoundException;
import me.qwq.doghouse.exception.RException;
import me.qwq.doghouse.interfaces.IPostTypeConfig;
import me.qwq.doghouse.pojo.bo.PostPassJar;
import me.qwq.doghouse.pojo.dto.R;
import me.qwq.doghouse.service.*;
import me.qwq.doghouse.service.post.AbstractPostService;
import me.qwq.doghouse.service.post.PostService;
import me.qwq.doghouse.util.Cryptos;
import me.qwq.doghouse.util.IPUtils;
import me.qwq.doghouse.util.MarkDownUtils;
import me.qwq.doghouse.util.SessionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.Objects;
import java.util.Optional;
/**
* 前端控制器
* @author Doghole
*/
@Controller
@Slf4j
public class PostFrontendController extends BaseController {
@Autowired
PostTypeComponent postTypeComponent;
@Autowired
PostService postService;
@Autowired
CommentService commentService;
@Autowired
MarkdownService markdownService;
@Autowired
MailService mailService;
@Autowired
SiteConfig siteConfig;
@Autowired
CommentConfig commentConfig;
@Autowired
IpService ipService;
@Autowired
ThemeService themeService;
/**
* 文章详情
*/
@GetMapping({
"/p/{postId}", "/p/{postId}/{page}",
"/n/{postName}", "/n/{postName}/{page}",
"/{postName}.html", "/{postName}.html/{page}"})
public String detail(
@PathVariable(value="postId", required=false) Long postId,
@PathVariable(value="postName", required=false) String postName,
@PathVariable(value="page", required=false) Long commentPage) {
Boolean isLogin = isLogin();
// 获得文章info
Post post = postService.getByIdOrName(postId, postName, isLogin);
if (post == null) {
throw new PageNotFoundException();
}
IPostTypeConfig<?> postTypeConfig = postTypeComponent.getPostTypeConfig(post.getType());
AbstractPostService<?, ?> postTypeService = postTypeComponent.getPostTypeService(post.getType());
if (!postTypeConfig.getEnabled()) {
throw new PageNotFoundException();
}
// <link rel="canonical" href="https://example.com/dresses/green-dresses" />
// 检查规范网页
// TODO 使用 UriComponentsBuilder
String canonical = post.getLink();
String requestUri = request.getRequestURI();
if (!requestUri.startsWith(canonical)) {
String[] split = requestUri.split("/");
// 获取除却 2 级 uri 后的参数并做重定向
requestUri = requestUri.replace(String.format("/%s/%s", split[1], split[2]), canonical);
return "redirect:" + requestUri;
}
request.setAttribute("canonical", siteConfig.getAddress() + canonical);
// 文章校验、清除
Optional<String> detailCheck = postTypeService.detailCheckAndJump(post);
if (detailCheck.isPresent()) {
return detailCheck.get();
}
request.setAttribute("title",
new StringBuilder()
.append(postTypeService.getFrontEndPostTitle(post))
.append(" - ")
.append(postTypeConfig.getName())
.toString());
request.setAttribute("post", post);
// 评论
IPage<Comment> page = commentService.getPostCommentPage(
commentPage, postTypeConfig.getCommentPageSize(),
post.getPostId());
request.setAttribute("commentPaginationPrefix", post.getLink() + "/");
request.setAttribute("commentPage", page);
if (commentPage != null && commentPage != 1L && page.getRecords().size() == 0) {
// 页数不为首页且评论数为 0一般是属于本来有那么多页但是最终删除了
String uri = request.getRequestURI();
return "redirect:" + request.getRequestURI().substring(0, uri.lastIndexOf('/'));
}
String targetTemplet = postTypeComponent.getTemplatePath(post.getType(), "detail");
if (!StringUtils.isEmpty(post.getTemplate())) {
targetTemplet = themeService
.getCurrentTheme()
.getTemplateString("template", post.getTemplate());
}
return targetTemplet;
}
/**
* 校验密码
* @param postPageName
* @param postId
* @param password
* @return
*/
@PostMapping({
"/p/{postId}/password",
"/n/{postPageName}/password"})
@ResponseBody
public R<?> checkPassword(
@PathVariable(value="postPageName", required=false) String postPageName,
@PathVariable(value="postId", required=false) Long postId,
String password) {
boolean postIdGiven = Objects.nonNull(postId);
boolean postPageNameGiven = StringUtils.isNoneBlank(postPageName);
if (!postIdGiven && !postPageNameGiven) {
throw new RException(HttpStatus.BAD_REQUEST, "非法请求");
}
Boolean isLogin = isLogin();
Post post = postService.getOne(new LambdaQueryWrapper<Post>()
.eq(!isLogin, Post::getPostStatus, PostStatusEnum.RELEASED)
.eq(Post::getIsDeleted, false)
.eq(postIdGiven, Post::getPostId, postId)
.eq(postPageNameGiven, Post::getPostPageName, postPageName));
if (post == null) {
throw new RException(HttpStatus.BAD_REQUEST, "非法操作");
}
if (isLogin || StringUtils.isBlank(post.getPassword())) { //
return R.ok();
}
if (!postTypeComponent.checkModeEnable(post.getType())) {
throw new RException(HttpStatus.BAD_REQUEST, "非法操作");
}
if (Cryptos.sha3(post.getPassword(), 224).equals(password)) {
// 有密码
PostPassJar.addToSession(session, post, password);
return R.ok();
}
throw new RException(HttpStatus.FORBIDDEN, "密码输入错误");
}
@GetMapping({
"/p/{postId}/lock",
"/n/{postPageName}/lock"})
public String lock(
@PathVariable(value="postPageName", required=false) String postPageName,
@PathVariable(value="postId", required=false) Long postId,
String password) {
Boolean isLogin = isLogin();
Post post = postService.getByIdOrName(postId, postPageName, isLogin);
if (post == null) {
throw new PageNotFoundException();
}
PostPassJar.removeFromSession(session, post);
return "redirect:" + post.getLink();
}
/**
* 评论页元素
*/
@GetMapping({
"/{postName}.html/comments/{page}",
"/p/{postId}/comments/{page}",
"/n/{postName}/comments/{page}"
})
public String commentPage(
@PathVariable(value="postId", required=false) Long postId,
@PathVariable(value="postName", required=false) String postName,
@PathVariable(value="page") Long commentPage) {
Boolean isLogin = isLogin();
Post post = postService.getOne(
new LambdaQueryWrapper<Post>()
.eq(!isLogin, Post::getPostStatus, PostStatusEnum.RELEASED)
.eq(Post::getIsDeleted, false)
// 是哪种页面,如何定位
.and(!Objects.isNull(postId), i -> i.eq(Post::getPostId, postId))
.and(!StringUtils.isEmpty(postName), i -> i.eq(Post::getPostPageName, postName))
.last("LIMIT 1")
);
if (post == null) {
// 如果 POST 不存在
throw new PageNotFoundException();
}
IPostTypeConfig<?> postTypeConfig = postTypeComponent.getPostTypeConfig(post.getType());
if (!postTypeConfig.getEnabled()) {
throw new PageNotFoundException();
}
String targetTemplet = postTypeComponent.getTemplatePath(post.getType(), "comment-only");
// 文章加密状态校验、清除
if (!PostPassJar.validFromSession(session, post)) {
return targetTemplet;
}
// 评论
IPage<Comment> page = commentService.getPostCommentPage(
commentPage, postTypeConfig.getCommentPageSize(),
post.getPostId());
response.addHeader("Post-Id", post.getPostId().toString());
request.setAttribute("post", post);
request.setAttribute("enableComment", post.getEnableComment());
request.setAttribute("commentPaginationPrefix", post.getLink() + "/");
request.setAttribute("commentPage", page);
if (commentPage != null && commentPage != 1L && page.getRecords().size() == 0) {
// 页数不为首页且评论数为 0一般是属于本来有那么多页但是最终删除了
String uri = request.getRequestURI();
return "redirect:" + request.getRequestURI().substring(0, uri.lastIndexOf('/'));
}
return targetTemplet;
}
/**
* 评论
*/
@PostMapping({
"/{postPageName}.html/reply",
"/p/{postId}/reply",
"/n/{postPageName}/reply"
})
@ResponseBody
public R<?> reply(@Validated Comment comment, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
throw new RException(HttpStatus.BAD_REQUEST, bindingResult.getAllErrors().get(0).getDefaultMessage())
.setLogRequest(true);
}
if (commentService.saveOrUpdate(comment)) {
mailService.sendAdminNotification(comment);
mailService.addOrUpdateSendQueue(comment);
comment = commentService.getById(comment.getId())
.setIsDeleted(null)
.setIp(null)
.setAgent(null)
.setEditable(true);
SessionUtils.addCommentId(request, comment.getId());
if (commentConfig.isMarkdownOn() && comment.isMarkdown()) {
comment.setContent(MarkDownUtils.commentToHtml(comment.getContent()));
}
return R.ok(comment);
}
throw RException.internalServerError();
}
/**
* 假评论
*/
@PostMapping({
"/{postPageName}.html/honeyReply",
"/p/{postId}/honeyReply",
"/n/{postPageName}/honeyReply"
})
@ResponseBody
public R<?> honeyReply(
@Validated Comment comment, BindingResult bindingResult) {
String ip = ipService.getClientIp(request);
Boolean isLocalhost = IPUtils.isLocalhost(ip);
// 添加入 ip 黑名单
if (!isLocalhost) {
commentConfig.addBlockIp(ip).saveOrUpdate();
}
if (bindingResult.hasErrors()) {
//log.info("触发测试评论蜜罐, ip: {}, 但表单验证不通过", IPUtils.getClientIP(request));
throw new RException(HttpStatus.BAD_REQUEST, bindingResult.getAllErrors().get(0).getDefaultMessage())
.setLogRequest(false);
}
log.debug("触发测试评论蜜罐, ip: {}, {}", ip, comment.toString());
return R.ok();
}
@GetMapping("/comments/go/{commentId}")
public String commentsGo(@PathVariable("commentId") Long id) {
return "redirect:" + commentService.getJumpToUrl(id);
}
@GetMapping("/posts/gotoPage/{postId}")
public String postsGo(@PathVariable("postId") Long id) {
return "redirect:" + postService.getJumpToPagesUrl(id);
}
}

View File

@@ -0,0 +1,220 @@
package me.qwq.doghouse.controller.blog;
import com.alibaba.fastjson2.util.DateUtils;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import me.qwq.doghouse.component.PostTypeComponent;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.entity.*;
import me.qwq.doghouse.entity.config.SiteConfig;
import me.qwq.doghouse.entity.Post;
import me.qwq.doghouse.enums.CommentStatusEnum;
import me.qwq.doghouse.enums.MetaTypeEnum;
import me.qwq.doghouse.enums.PostStatusEnum;
import me.qwq.doghouse.enums.PostTypeEnum;
import me.qwq.doghouse.interfaces.IPostTypeConfig;
import me.qwq.doghouse.pojo.dto.PageQuery;
import me.qwq.doghouse.service.*;
import me.qwq.doghouse.service.post.PostService;
import org.apache.commons.lang3.StringUtils;
import org.jdom2.Document;
import org.jdom2.Element;
import org.jdom2.Namespace;
import org.jdom2.Text;
import org.jdom2.output.Format;
import org.jdom2.output.XMLOutputter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.util.UriUtils;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* Sitemap Controller
*
* @author Doghole
*/
@Controller
public class SitemapController extends BaseController {
public final static String CATEGORY_PATH = "/category/";
public final static String TAG_PATH = "/tag/";
public final static String SITEMAP_PATH = "/sitemap.xml";
public final static String URL = "url";
public final static String LOC = "loc";
public final static String LASTMOD = "lastmod";
public final static String UTF8 = "UTF-8";
public final static String DATE_FORMAT = "yyyy-MM-dd";
public final static Format FORMAT = Format.getCompactFormat().setEncoding(UTF8);
public final static Namespace NAMESPACE = Namespace.getNamespace("http://www.sitemaps.org/schemas/sitemap/0.9");
@Autowired
PostService postService;
@Autowired
MetaService metaService;
@Autowired
CommentService commentService;
@Autowired
ConfigService configService;
@Autowired
SiteConfig siteConfig;
@Autowired
PostTypeComponent postTypeComponent;
@GetMapping(value=SITEMAP_PATH, produces = "application/xml")
@ResponseBody
public String feed() {
String siteAddress = siteConfig.getAddress();
Document doc = new Document();
Element urlset = new Element("urlset", NAMESPACE);
LambdaQueryWrapper<Post> ew = new LambdaQueryWrapper<Post>()
.eq(Post::getPostStatus, PostStatusEnum.RELEASED)
.eq(Post::getIsDeleted, false)
.eq(Post::getSearchable, true)
.orderByDesc(Post::getType, Post::getCreateTime);
for (PostTypeEnum e : PostTypeEnum.values()) {
IPostTypeConfig<?> postTypeConfig = postTypeComponent.getPostTypeConfig(e);
if (!postTypeConfig.getEnabled() || postTypeConfig.getAddToSitemap()) {
ew.ne(Post::getType, e);
}
}
List<Post> releasePosts = postService.list(ew);
List<Element> urls = new ArrayList<>();
for (Post post : releasePosts) {
if (post.getType() == PostTypeEnum.WIKI && StringUtils.isEmpty(post.getPostContent())) {
// 空 WIKI 文章不加入 Sitemap
continue;
}
urls.add(getPostUrlElement(post, siteAddress));
}
List<Cate> releaseCates = metaService.listJoin(Cate.class,
metaService.getCountDefaultWrapper(MetaTypeEnum.CATEGORY)
.selectMax(Post::getUpdateTime, Cate::getUpdateTime)
//.selectFuncs(Post::getUpdateTime, Cate::getUpdateTime, FunctionPool.MAX)
);
List<Tag> releaseTags = metaService.listJoin(Tag.class,
metaService.getCountDefaultWrapper(MetaTypeEnum.TAG)
.selectMax(Post::getUpdateTime, Tag::getUpdateTime)
//.selectFuncs(Post::getUpdateTime, Cate::getUpdateTime, FunctionPool.MAX)
);
for (Cate cate : releaseCates) {
if (cate.getPostCount() == 0) continue;
urls.add(getMetaUrlElement(cate, siteAddress));
}
for (Tag tag : releaseTags) {
if (tag.getPostCount() == 0) continue;
urls.add(getMetaUrlElement(tag, siteAddress));
}
// 首页的 lastmod 一般应该由以下几个部分组成:
// 1. 首页文章的 lastmod
// 2. 最新评论的 lastmod
LocalDateTime date = LocalDateTime.of(1991, 8, 6, 0, 0);
PageQuery<?> indexPage = postService.conditionPage(new PageQuery<>(PostTypeEnum.POST));
for (Post post : indexPage.getRecords()) {
if (date.isBefore(post.getUpdateTime())) {
date = post.getUpdateTime();
}
}
List<Comment> latestComments = commentService.getLatestComments(1L);
if (!latestComments.isEmpty()) {
if (date.isBefore(latestComments.get(0).getCreateTime())) {
date = latestComments.get(0).getCreateTime();
}
}
Element url = new Element(URL, NAMESPACE);
Element loc = new Element(LOC, NAMESPACE);
Element lastmod = new Element(LASTMOD, NAMESPACE);
Text locNode = new Text(
String.format("%s", siteAddress));
loc.addContent(locNode);
Text lastmodNode = new Text(
DateUtils.format(date, DATE_FORMAT));
lastmod.addContent(lastmodNode);
url.addContent(loc);
url.addContent(lastmod);
urlset.addContent(url);
urlset.addContent(urls);
doc.addContent(urlset);
XMLOutputter outputter = new XMLOutputter(FORMAT);
return outputter.outputString(doc);
}
private Element getPostUrlElement(Post post, String siteAddress) {
Element url = new Element(URL, NAMESPACE);
Element loc = new Element(LOC, NAMESPACE);
// For test
/*Element name = new Element("name", NAMESPACE);
name.addContent(new Text(post.getPostTitle()));
url.addContent(name);*/
Element lastmod = new Element(LASTMOD, NAMESPACE);
Text locNode = new Text(
String.format("%s%s", siteAddress, post.getLink()));
loc.addContent(locNode);
LocalDateTime updateTime = post.getUpdateTime();
// getLatestComment
Comment comment = commentService.getOne(new QueryWrapper<Comment>()
.lambda()
.eq(Comment::getPostId, post.getPostId())
.eq(Comment::getCommentStatus, CommentStatusEnum.RELEASED)
.eq(Comment::getIsDeleted, false)
.orderByAsc(Comment::getCreateTime).last("LIMIT 1"));
if (comment != null && updateTime.isBefore(comment.getCreateTime())) {
updateTime = comment.getCreateTime();
}
Text lastmodNode = new Text(
DateUtils.format(updateTime, DATE_FORMAT));
lastmod.addContent(lastmodNode);
url.addContent(loc);
url.addContent(lastmod);
return url;
}
private static Element getMetaUrlElement(Meta meta, String siteAddress) {
Element url = new Element(URL, NAMESPACE);
Element loc = new Element(LOC, NAMESPACE);
Element lastmod = new Element(LASTMOD, NAMESPACE);
Text locNode = new Text(
String.format("%s%s%s", siteAddress, meta.getType() == MetaTypeEnum.TAG ? TAG_PATH : CATEGORY_PATH,
UriUtils.encode(meta.getFriendlyName(), StandardCharsets.UTF_8)));
loc.addContent(locNode);
Text lastmodNode = new Text(
DateUtils.format(meta.getUpdateTime(), DATE_FORMAT));
lastmod.addContent(lastmodNode);
url.addContent(loc);
url.addContent(lastmod);
return url;
}
}

View File

@@ -0,0 +1,83 @@
package me.qwq.doghouse.controller.blog;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import me.qwq.doghouse.component.Rk;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.entity.*;
import me.qwq.doghouse.entity.config.MailConfig;
import me.qwq.doghouse.service.*;
import org.apache.commons.lang3.StringUtils;
import org.redisson.api.RBucket;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Locale;
/**
* 用户功能控制器
*
*/
@Controller
@RequestMapping("/system")
public class VisitorController extends BaseController {
static final String TOKEN_ERROR = "token 不存在或已过期";
static final String MAIL_DISABLED = "站点未开启邮件功能";
static final String PATH = "system/unsubscribe-mail";
@Autowired
CommentService commentService;
@Autowired
ConfigService configService;
@Autowired
MailConfig mailConfig;
@RequestMapping("/unsubscribe-mail")
private String unsubscribeMailPost(String token) {
String method = request.getMethod();
if (!mailConfig.isMailOn()) {
return render(MAIL_DISABLED, null);
}
if (StringUtils.isEmpty(token)) {
return render(TOKEN_ERROR, null);
}
RBucket<String> uuidBucket = Rk.Mail.getMgrUuidBucket(token);
String mail = uuidBucket.get();
if (StringUtils.isEmpty(mail)) {
return render(TOKEN_ERROR, null);
}
if ("GET".equals(method)) {
return render(null, mail);
}
if ("POST".equals(method)) {
uuidBucket.delete();
commentService.update(new LambdaUpdateWrapper<Comment>()
.eq(Comment::getEmail, mail.toLowerCase(Locale.ROOT))
.set(Comment::isSubscribeReply, false));
return render("操作成功", null);
}
return render("非法操作请求: " + method, null);
}
String render(String note, String mail) {
request.setAttribute("note", note);
request.setAttribute("mail", mail);
return PATH;
}
}

View File

@@ -0,0 +1,240 @@
package me.qwq.doghouse.controller.blog.post;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.component.PostTypeComponent;
import me.qwq.doghouse.controller.common.BaseController;
import me.qwq.doghouse.entity.*;
import me.qwq.doghouse.entity.config.CommentConfig;
import me.qwq.doghouse.entity.config.SiteConfig;
import me.qwq.doghouse.enums.PostTypeEnum;
import me.qwq.doghouse.exception.PageNotFoundException;
import me.qwq.doghouse.interfaces.IPostTypeConfig;
import me.qwq.doghouse.pojo.dto.PageQuery;
import me.qwq.doghouse.service.*;
import me.qwq.doghouse.service.post.AbstractPostService;
import me.qwq.doghouse.util.SpringContextHolder;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ResolvableType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
/**
* 前端分页控制器, 不同 PostType 如需分页, 可继承该方法
* @author Doghole
*/
@Controller
@Slf4j
public abstract class AbstractPaginationController<T extends IPostTypeConfig<T>, S extends AbstractPostService<S, T>> extends BaseController {
@Autowired
CommentService commentService;
@Autowired
MarkdownService markdownService;
@Autowired
CommentConfig commentConfig;
@Autowired
PostTypeComponent postTypeComponent;
@Autowired
ThemeService themeService;
@Autowired
SiteConfig siteConfig;
T postTypeConfig;
T getPostTypeConfig() {
if (postTypeConfig != null) return postTypeConfig;
ResolvableType type = ResolvableType.forClass(this.getClass()).as(AbstractPaginationController.class);
ResolvableType iPageConfigType = type.getGeneric(0);
@SuppressWarnings("unchecked")
Class<T> iPageConfigClass = (Class<T>) iPageConfigType.resolve();
postTypeConfig = SpringContextHolder.getBean(iPageConfigClass);
return postTypeConfig;
}
PostTypeEnum getPostType() {
return getPostTypeConfig().getPostType();
}
S postTypeService;
S getPostTypeService() {
if (postTypeService != null) return postTypeService;
ResolvableType type = ResolvableType.forClass(this.getClass()).as(AbstractPaginationController.class);
ResolvableType postServiceType = type.getGeneric(1);
@SuppressWarnings("unchecked")
Class<S> postServiceClass = (Class<S>) postServiceType.resolve();
postTypeService = SpringContextHolder.getBean(postServiceClass);
return postTypeService;
}
/**
* 首页
*/
@GetMapping({
"", "/"})
public String index() {
return this.page(
new PageQuery<>(getPostType())
.setTitle(getPostTypeConfig().getIndexTitle()));
}
/**
* 首页无查询条件
* @param pageQuery
* @return
*/
@GetMapping({"/page", "/page/{current}"})
public String indexPage(@PathVariable(name="current", required=false) Long current) {
return page(new PageQuery<>(getPostType()).setCurrent(current));
}
/**
* 分类
*/
@GetMapping({"/category/{friendlyName}", "/category/{friendlyName}/{current}"})
public String category(
@PathVariable("friendlyName") String friendlyName,
@PathVariable(name="current", required=false) Long current) {
Cate cate = new Cate();
cate.setFriendlyName(friendlyName);
return this.page(new PageQuery<Cate>(getPostType())
.setQuery(cate)
.setCurrent(current));
}
/**
* 标签
*/
@GetMapping({"/tag/{friendlyName}", "/tag/{friendlyName}/{current}"})
public String tag(
@PathVariable("friendlyName") String friendlyName,
@PathVariable(name="current", required=false) Long current) {
Tag tag = new Tag();
tag.setFriendlyName(friendlyName);
return this.page(new PageQuery<Tag>(getPostType())
.setQuery(tag)
.setCurrent(current));
}
/**
* 搜索,适用于伪静态的情况
*/
@GetMapping({"/search/{keyword}", "/search/{keyword}/{page}"})
public String search(@PathVariable("keyword") String keyword, @PathVariable(name="page", required=false) Long c) {
String prefix = "redirect:" + getPostIndexPrefix();
if (StringUtils.isEmpty(keyword)) return prefix;
try {
keyword = URLDecoder.decode(keyword,"UTF-8");
} catch (UnsupportedEncodingException e) {
log.warn("搜索 keyword 编码转换失败,将跳转到首页");
return prefix;
}
return this.page(new PageQuery<String>(getPostType())
.setQuery(keyword)
.setCurrent(c)
);
}
/**
* 搜索,适用于 QueryParam 的情况:.../search?keyword=xxx
*/
@GetMapping({"/search"})
public String search(@RequestParam(name="keyword", required=false) String keyword) {
String prefix = "redirect:" + getPostIndexPrefix();
if (StringUtils.isEmpty(keyword)) return prefix;
try {
return prefix + "search/" + URLEncoder.encode(keyword,"UTF-8");
} catch (UnsupportedEncodingException e) {
log.warn("搜索 keyword 编码转换失败,将跳转到首页");
return prefix;
}
}
/**
* 通用分页<p>
* 注意:此处的 PageQuery 需要在调用方法内自行实例化,
* 不能在映射方法接口上自动装填,否则经由 postService
* 的 conditionPage 方法取缓存后,自动装填对象未被更改,
* 会导致返回的 PageQuery 是空结果
*/
protected String page(PageQuery<?> pageQuery) {
pageQuery = getPostTypeService().conditionPage(pageQuery);
pageQuery.checkPassword(session);
request.setAttribute("pageQuery", pageQuery);
setTitle(pageQuery.getTitle());
request.setAttribute("canonical", siteConfig.getAddress() + "/");
String targetTemplet = postTypeComponent
.getTemplatePath(
getPostType(),
"index");
return targetTemplet;
}
/**
* 获取对应的路由前缀,该前缀由继承了本类的子类上的 @RequestMapping
* 注解指定,仅取第一个路径。
* <p>
* <ul>
* <li>@RequestMapping({"/", ""}) 返回 "/"
* <li>@RequestMapping("/wiki") 返回 "/wiki/"
* <li>@RequestMapping("/wiki/") 返回 "/wiki/"
* <li>无注解返回 "/"
*
* @param postType
* @return
*/
private String getPostIndexPrefix() {
RequestMapping requestMapping = null;
try {
requestMapping = this.getClass().getAnnotation(RequestMapping.class);
}
catch (Exception e) {
e.printStackTrace();
throw new PageNotFoundException();
}
if (requestMapping == null ||
requestMapping.value().length == 0) {
return "/";
}
String uri = requestMapping.value()[0];
if (uri == null || uri.isBlank()) return "/";
// 去掉前后斜杠
uri = uri.replaceAll("^/+|/+$", "");
// 去掉中间重复斜杠
uri = uri.replaceAll("/+", "/");
return "/" + uri + "/";
}
private void setTitle(String title) {
request.setAttribute("title", title);
}
}

View File

@@ -0,0 +1,19 @@
package me.qwq.doghouse.controller.blog.post;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.entity.config.SiteConfig;
import me.qwq.doghouse.service.post.PostService;
import org.springframework.stereotype.Controller;
/**
* PostTypeEnum.POST 前端分页控制器
* @author Doghole
*/
@Controller
@Slf4j
public class PostPaginationController extends AbstractPaginationController<SiteConfig, PostService> {
}

View File

@@ -0,0 +1,21 @@
package me.qwq.doghouse.controller.blog.post;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.entity.config.TweetConfig;
import me.qwq.doghouse.service.post.TweetService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
/**
* PostTypeEnum.TWEET 前端分页控制器
* @author Doghole
*/
@Controller
@Slf4j
@RequestMapping("/tweet")
public class TweetPaginationController extends AbstractPaginationController<TweetConfig, TweetService> {
}

View File

@@ -0,0 +1,75 @@
package me.qwq.doghouse.controller.blog.post;
import com.alibaba.fastjson2.JSON;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.entity.config.WikiConfig;
import me.qwq.doghouse.entity.Post;
import me.qwq.doghouse.pojo.bo.PostPassJar;
import me.qwq.doghouse.service.post.WikiService;
import me.qwq.doghouse.util.EscapeUnescapeUtils;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.servlet.http.Cookie;
import java.util.ArrayList;
import java.util.List;
/**
* PostTypeEnum.WIKI 前端分页控制器
* @author Doghole
*/
@Controller
@Slf4j
@RequestMapping("/wiki")
public class WikiPaginationController extends AbstractPaginationController<WikiConfig, WikiService> {
/**
* 首页
*/
public String index() {
// 先找最顶层的一篇文章,如果有,跳转到顶层文章
Post post = getPostTypeService().getOne(getPostTypeService().getWikiDefaultWrapper(isLogin()).last("LIMIT 1"));
if (post != null) {
return "redirect:" + post.getLink();
}
return super.index();
}
/**
* Wiki 树形菜单点击
* @param postId
* @return
*/
@GetMapping({"/list", "/list/", "/list/{postId}"})
public String listFrontWikisOnly(@PathVariable(name="postId", required=false) Long postId) {
request.setAttribute("wikis", listFrontWikis(postId));
return postTypeComponent.getTemplatePath(getPostType(), "side-only");
}
/**
* 该方法不应通过 Web 接口调用,否则会暴露完整的 POST(WIKI),包括密码
* 放在控制器这里只是方便前端模板调用,并且能获得 service 的缓存能力
* @param postId
* @return
*/
public List<Post> listFrontWikis(Long postId) {
List<Long> unfolded = WikiService.getUnfoldedWikis(request);
String unfoldedStr = unfolded == null ? "[]" : JSON.toJSONString(unfolded);
unfoldedStr = EscapeUnescapeUtils.escape(unfoldedStr);
Cookie unfoldedCookie = new Cookie(WikiService.UNFOLDED_WIKIS, unfoldedStr);
unfoldedCookie.setPath("/");
response.addCookie(unfoldedCookie);
// 清洗 Password
Post post = getPostTypeService().getOne(getPostTypeService().getWikiDefaultWrapper(isLogin()).eq(Post::getPostId, postId));
if (post != null && (!PostPassJar.validFromSession(session, post))) {
return new ArrayList<>();
}
return getPostTypeService().listFrontWikis(postId, unfolded);
}
}

View File

@@ -0,0 +1,26 @@
package me.qwq.doghouse.controller.common;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import me.qwq.doghouse.util.SessionUtils;
@Controller
public abstract class BaseController {
@Autowired
protected HttpServletRequest request;
@Autowired
protected HttpServletResponse response;
@Autowired
protected HttpSession session;
protected Boolean isLogin() {
return SessionUtils.isLogin(session);
}
}

View File

@@ -0,0 +1,62 @@
package me.qwq.doghouse.controller.common;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.error.ErrorAttributeOptions;
import org.springframework.boot.web.error.ErrorAttributeOptions.Include;
import org.springframework.boot.web.servlet.error.ErrorAttributes;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.context.request.WebRequest;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import me.qwq.doghouse.service.ThemeService;
import java.util.Map;
@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class ErrorPageController implements ErrorController {
@Autowired
ErrorAttributes errorAttributes;
@Autowired
ThemeService themeService;
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public String errorHtml(HttpServletRequest request) {
HttpStatus status = getStatus(request);
return themeService.getCurrentTheme().getErrorTemplateString(status);
}
@ResponseBody
@RequestMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
boolean include = Boolean.parseBoolean(request.getParameter("trace"));
ErrorAttributeOptions options = ErrorAttributeOptions.defaults();
if (include) {
options = options.including(Include.STACK_TRACE);
}
WebRequest webRequest = new ServletWebRequest(request);
Map<String, Object> body = errorAttributes.getErrorAttributes(webRequest, options);
return ResponseEntity.status(getStatus(request)).body(body);
}
private HttpStatus getStatus(HttpServletRequest request) {
Object value = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
if (value instanceof Integer code) {
HttpStatus status = HttpStatus.resolve(code);
return (status != null ? status : HttpStatus.INTERNAL_SERVER_ERROR);
}
return HttpStatus.INTERNAL_SERVER_ERROR;
}
}

View File

@@ -0,0 +1,12 @@
package me.qwq.doghouse.dao;
import com.github.yulichang.base.MPJBaseMapper;
import me.qwq.doghouse.entity.Asset;
/**
* 资产 Mapper
*/
public interface AssetMapper extends MPJBaseMapper<Asset> {
}

View File

@@ -0,0 +1,12 @@
package me.qwq.doghouse.dao;
import com.github.yulichang.base.MPJBaseMapper;
import me.qwq.doghouse.entity.Comment;
/**
* 评论 Mapper
*/
public interface CommentMapper extends MPJBaseMapper<Comment> {
}

View File

@@ -0,0 +1,12 @@
package me.qwq.doghouse.dao;
import com.github.yulichang.base.MPJBaseMapper;
import me.qwq.doghouse.entity.RawConfig;
/**
* 配置项 Mapper
*/
public interface ConfigMapper extends MPJBaseMapper<RawConfig> {
}

View File

@@ -0,0 +1,13 @@
package me.qwq.doghouse.dao;
import com.github.yulichang.base.MPJBaseMapper;
import me.qwq.doghouse.entity.Link;
/**
* 友情链接 Mapper
*
*/
public interface LinkMapper extends MPJBaseMapper<Link> {
}

View File

@@ -0,0 +1,13 @@
package me.qwq.doghouse.dao;
import com.github.yulichang.base.MPJBaseMapper;
import me.qwq.doghouse.entity.Meta;
/**
* Meta Mapper
*
*/
public interface MetaMapper extends MPJBaseMapper<Meta> {
}

View File

@@ -0,0 +1,16 @@
package me.qwq.doghouse.dao;
import java.util.Collection;
import java.util.List;
import com.github.yulichang.base.MPJBaseMapper;
import me.qwq.doghouse.entity.Post;
/**
* Post Mapper
*
*/
public interface PostMapper extends MPJBaseMapper<Post> {
List<Long> getParentIds(Collection<Long> childrenIds);
}

View File

@@ -0,0 +1,13 @@
package me.qwq.doghouse.dao;
import com.github.yulichang.base.MPJBaseMapper;
import me.qwq.doghouse.entity.Relationship;
/**
* Meta Relationship Mapper
*
*/
public interface RelationshipMapper extends MPJBaseMapper<Relationship> {
}

View File

@@ -0,0 +1,20 @@
package me.qwq.doghouse.dao;
import java.util.Map;
import me.qwq.doghouse.enums.PostTypeEnum;
public interface StatisticsMapper {
String getDatabaseVersion();
/**
* 获取过去 passDay 日的每日文章和评论数统计
* @param passDays
* @param postType
* @return
*/
Map<String, String> getPostAndCommentCountsWithDates(Integer passDays, PostTypeEnum postType);
}

View File

@@ -0,0 +1,13 @@
package me.qwq.doghouse.dao;
import com.github.yulichang.base.MPJBaseMapper;
import me.qwq.doghouse.entity.User;
/**
* 管理员 Mapper
*
*/
public interface UserMapper extends MPJBaseMapper<User> {
}

View File

@@ -0,0 +1,105 @@
package me.qwq.doghouse.entity;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.apache.commons.lang3.StringUtils;
import org.springframework.format.annotation.DateTimeFormat;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import me.qwq.doghouse.enums.FileTypeEnum;
/**
* 资产实体类
* @author Doghole
*
*/
@SuppressWarnings("serial")
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain=true)
public class Asset extends Model<Post> implements Serializable {
/**
* 资产 ID
*/
@TableId(value="asset_id", type=IdType.AUTO)
private Long assetId;
/**
* 资产路径
*/
@TableField("path")
private String path;
/**
* 资产标题(名称)
*/
@TableField("title")
private String title;
/**
* 获取扩展名
* @return
*/
public String getExt() {
if (StringUtils.isEmpty(path)) {
return "";
}
int lastIndexOfDot = path.lastIndexOf('.');
if (lastIndexOfDot == -1 || lastIndexOfDot == path.length() - 1) {
return "";
}
return path.substring(lastIndexOfDot + 1).toLowerCase();
}
@TableField("file_type")
private FileTypeEnum fileType;
@TableField("file_size")
private Long fileSize;
@TableField("image_width")
private Integer imageWidth;
@TableField("image_height")
private Integer imageHeight;
/**
* 资产描述
*/
@TableField("description")
private String description;
/**
* 添加时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@TableField("create_time")
private LocalDateTime createTime;
/**
* 修改时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@TableField("update_time")
private LocalDateTime updateTime;
@TableField("from_post_id")
private Long fromPostId;
@TableField(exist=false)
private String fromPostTitle;
}

View File

@@ -0,0 +1,91 @@
package me.qwq.doghouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import java.util.List;
import jakarta.validation.constraints.NotBlank;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import java.io.Serializable;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import me.qwq.doghouse.enums.MetaTypeEnum;
import me.qwq.doghouse.interfaces.IFriendlyName;
import me.qwq.doghouse.validator.FriendlyNameValid;
/**
* Cate for category
*
* @author Doghole
* @since 2021-12-01
* @see Meta
*/
@SuppressWarnings("serial")
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("meta")
@FriendlyNameValid
public class Cate extends Meta implements Serializable, ITreeView<Cate>, IFriendlyName {
@TableId(value = "mid", type=IdType.AUTO)
private Long mid;
@TableField("name")
@NotBlank(message = "分类名称不允许为空")
private String name;
@TableField("type")
private MetaTypeEnum type = MetaTypeEnum.CATEGORY;
@TableField("parent_id")
private Long parentId;
@TableField("friendly_name")
private String friendlyName;
@TableField("description")
private String description;
/**
* 排序
*/
@TableField("no")
private Integer no;
/*
* FOR TREEVIEW USE.
*/
@TableField(exist=false)
private List<Cate> children;
public String getTitle() {
return getName();
}
public Long getId() {
return getMid();
}
public Boolean getHasChildren() {
return childrenCount > 0;
}
@TableField(exist=false)
private Boolean checked = false;
@TableField(exist=false)
private Boolean spread = false;
@TableField(value = "post_count", exist = false)
private Long postCount = 0L;
@TableField(value = "children_count", exist = false)
private Long childrenCount = 0L;
}

View File

@@ -0,0 +1,201 @@
package me.qwq.doghouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import java.util.List;
import java.util.Locale;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.baomidou.mybatisplus.annotation.TableField;
import java.io.Serializable;
import java.time.LocalDateTime;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import me.qwq.doghouse.constants.UsefulRegex;
import me.qwq.doghouse.enums.CommentStatusEnum;
import me.qwq.doghouse.enums.PostTypeEnum;
import me.qwq.doghouse.interfaces.IAvatarUrl;
import me.qwq.doghouse.interfaces.IPostPassMess;
import me.qwq.doghouse.pojo.bo.IpInfo;
import me.qwq.doghouse.validator.CommentValid;
import me.qwq.doghouse.validator.CommentValidator;
import org.hibernate.validator.constraints.Length;
import jakarta.validation.constraints.*;
import jakarta.validation.constraints.Pattern.Flag;
/**
*
* Comment entity
*
* @author Doghole
* @since 2021-11-19
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@CommentValid
public class Comment extends Model<Comment> implements Serializable, IPostPassMess<Comment>, IAvatarUrl {
private static final long serialVersionUID=1L;
/**
* 主键id
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 关联的blog主键
*/
@TableField("post_id")
private Long postId;
/**
* 关联 Post 标题
*/
@TableField(exist=false)
private String postTitle;
/**
* 关联 Post 类型
*/
@TableField(exist=false)
private PostTypeEnum type;
/**
* 关联 Post 密码
*/
@TableField(exist=false)
private String password;
/**
* 自定义路径
*/
@TableField(exist=false)
private String postPageName;
/**
* 评论者名称
*/
@TableField("author")
@NotBlank(message = "请输入称呼")
@Length(max = 10, message = CommentValidator.AUTHOR_LENGTH_OVERFLOW)
private String author;
/**
* 评论人的邮箱
*/
@TableField("email")
@NotBlank(message = CommentValidator.CONTENT_CANNOT_BE_EMPTY)
@Email(message = CommentValidator.EMAIL_INVALID)
@Size(max = 100, message = CommentValidator.EMAIL_LENGTH_OVERFLOW)
private String email;
public Comment setEmail(String email) {
if (email != null) email = email.toLowerCase(Locale.ENGLISH);
this.email = email;
return this;
}
/**
* 网址
*/
@TableField("url")
@Pattern(regexp = UsefulRegex.BLANK + UsefulRegex.OR + UsefulRegex.DOMAIN,
flags = Flag.CASE_INSENSITIVE,
message = CommentValidator.URL_INVALID)
@Size(max = 50, message = CommentValidator.URL_LENGTH_OVERFLOW)
private String url;
/**
* 评论内容
*/
@TableField("content")
@NotBlank(message = "请输入评论内容")
@Length(min = 1, max = 6000, message = CommentValidator.CONTENT_LENGTH_MISMATCH)
private String content;
/**
* 评论提交时间
*/
@TableField("create_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;
/**
* 评论时的ip地址
*/
@TableField("ip")
private String ip;
@TableField("agent")
private String agent;
/**
* 回复内容
*/
@TableField("reply_to")
private Long replyTo;
/**
* 回复层级
*/
@TableField("level")
private Integer level;
/**
* 是否审核通过 0-未审核 1-审核通过
*/
@TableField("comment_status")
private CommentStatusEnum commentStatus;
public String getCommentStatusNote() {
return commentStatus.getNote();
}
/**
* 是否删除 0-未删除 1-已删除
*/
@TableField("is_deleted")
private Boolean isDeleted = false;
@TableField("is_markdown")
private boolean markdown = false;
@TableField("subscribe_reply")
private boolean subscribeReply = false;
@TableField("mail_sent")
private boolean mailSent = false;
@TableField(exist=false)
private List<Comment> subComments;
@TableField(exist=false)
private IpInfo ipInfo;
/**
* 是否可编辑
*/
@TableField(exist=false)
private boolean editable = false;
@Override
public String toString() {
return new StringBuilder()
.append("Comment[id=").append(id).append(", ")
.append("author=").append(author).append(", ")
.append("email").append(email).append(", ")
.append("url=").append(url).append(", ")
.append("content=").append(content).append(", ")
.append("replyTo=").append(replyTo).append(", ")
.append("isMarkdown=").append(markdown).append(", ")
.append("subscribeReply=").append(subscribeReply).append("]")
.toString();
}
}

View File

@@ -0,0 +1,18 @@
package me.qwq.doghouse.entity;
import java.io.Serializable;
import java.util.List;
public interface ITreeView<T> extends Serializable {
T setChildren(List<T> children);
Long getChildrenCount();
T setChildrenCount(Long childrenCount);
Long getParentId();
String getTitle();
Boolean getHasChildren();
Boolean getChecked();
T setChecked(Boolean checked);
Boolean getSpread();
T setSpread(Boolean spread);
}

View File

@@ -0,0 +1,90 @@
package me.qwq.doghouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.baomidou.mybatisplus.annotation.TableField;
import java.io.Serializable;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import me.qwq.doghouse.enums.LinkTypeEnum;
/**
* 友链
* @author Doghole
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class Link implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 友链表主键id
*/
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
/**
* 友链类别
*/
@TableField
private LinkTypeEnum type;
/**
* 网站名称
*/
@TableField
private String name;
/**
* 网站链接
*/
@TableField
private String url;
/**
* 网站描述
*/
@TableField
private String description;
/**
* 用于列表排序
*/
@TableField
private Integer health;
/**
* 是否删除 0-未删除 1-已删除
*/
@TableField
private Boolean isDeleted;
/**
* 有效性检测
*/
@TableField
private Boolean detect;
/**
* 添加时间
*/
@TableField
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;
/**
* 检测时间
*/
@TableField
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime detectiveTime;
}

View File

@@ -0,0 +1,69 @@
package me.qwq.doghouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import org.springframework.web.util.UriUtils;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.baomidou.mybatisplus.annotation.TableField;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import me.qwq.doghouse.enums.MetaTypeEnum;
/**
* Meta, super class of Cate and Tag
*
* @author Doghole
* @since 2021-12-01
* @see Cate
* @see Tag
*/
@SuppressWarnings("serial")
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class Meta extends Model<Meta> implements Serializable {
/**
* 标签名称
*/
@TableId(value = "mid", type=IdType.AUTO)
private Long mid;
@TableField("name")
private String name;
@TableField("type")
private MetaTypeEnum type;
@TableField("parent_id")
private Long parentId;
@TableField("friendly_name")
private String friendlyName;
public String getUrlEncodedFriendlyName() {
return UriUtils.encode(getFriendlyName(), StandardCharsets.UTF_8);
}
@TableField("description")
private String description;
@TableField(value = "post_count", exist = false)
private Long postCount = 0L;
/**
* 排序
*/
@TableField("no")
private Integer no;
@TableField(exist=false)
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,311 @@
package me.qwq.doghouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.util.CollectionUtils;
import org.springframework.web.util.UriUtils;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.baomidou.mybatisplus.annotation.TableField;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import me.qwq.doghouse.enums.PostStatusEnum;
import me.qwq.doghouse.enums.PostTypeEnum;
import me.qwq.doghouse.interfaces.IAvatarUrl;
import me.qwq.doghouse.interfaces.IFriendlyName;
import me.qwq.doghouse.interfaces.IPostPassMess;
import me.qwq.doghouse.validator.FriendlyNameValid;
/**
* <p>
* 文章/页面/Wiki
* </p>
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@FriendlyNameValid
public class Post extends Model<Post> implements Serializable, IPostPassMess<Post>, ITreeView<Post>, IAvatarUrl, IFriendlyName {
private static final long serialVersionUID = 1L;
public static final String URI_ID_TAG = "p";
public static final String URI_PAGENAME_TAG = "n";
public static final String URL_ID_FORMAT = "/" + URI_ID_TAG + "/%s";
public static final String URL_PAGENAME_FORMAT = "/" + URI_PAGENAME_TAG + "/%s";
public static final String URL_INDIVIDUAL_PAGE_FORMAT = "/%s.html";
/**
*表主键id
*/
@TableId(value = "post_id", type = IdType.AUTO)
private Long postId;
/**
* 类型0 - 文章1 - 独立页面
*/
@TableField("type")
private PostTypeEnum type;
/**
* 模板
*/
@TableField("template")
private String template;
/**
* 标题
*/
@TableField("post_title")
private String postTitle;
/**
* 自定义路径url
*/
@TableField("post_page_name")
private String postPageName;
/**
* 前言
*/
@TableField("post_preface")
private String postPreface;
/**
* 内容
*/
@TableField("post_content")
private String postContent;
/**
* 关键词
*/
@TableField("keywords")
private String keywords;
/**
* 描述
*/
@TableField("description")
private String description;
/**
* 分类id(冗余字段)
*/
@TableField(value = "categories_id", exist = false)
private String categoriesId;
/**
* 分类(冗余字段)
*/
@TableField(value = "categories_name", exist = false)
private String categoriesName;
/**
* 分类友好名称(冗余字段)
*/
@TableField(value = "categories_friendly_name", exist = false)
private String categoriesFriendlyName;
/**
* 前端遍历用
* @return
*/
public List<String> getCategoriesFriendlyNamesEncoded() {
if (categoriesFriendlyName == null) {
return new ArrayList<>();
}
return Arrays.asList(
categoriesFriendlyName.split(","))
.stream().map(i -> UriUtils.encode(i, StandardCharsets.UTF_8))
.collect(Collectors.toList());
}
/**
* 标签id
*/
@TableField(value = "tags_id", exist = false)
private String tagsId;
/**
* 标签名(冗余字段)
*/
@TableField(value = "tags_name", exist = false)
private String tagsName;
/**
* 标签友好名称(冗余字段)
*/
@TableField(value = "tags_friendly_name", exist = false)
private String tagsFriendlyName;
/**
* 前端遍历用
* @return
*/
public List<String> getTagsFriendlyNamesEncoded() {
if (tagsFriendlyName == null) {
return new ArrayList<>();
}
return Arrays.asList(
tagsFriendlyName.split(","))
.stream().map(i -> UriUtils.encode(i, StandardCharsets.UTF_8))
.collect(Collectors.toList());
}
/**
* 创建者的用户 ID
*/
@TableField(value = "created_by")
private Integer createdBy;
/**
* 创建者昵称
*/
@TableField(value = "creator_nickname", exist = false)
private String creatorNickname;
/**
* 创建者邮箱地址
*/
@TableField(value = "email", exist = false)
private String email;
@TableField("password")
private String password;
@TableField("post_status")
private PostStatusEnum postStatus;
/**
* 阅读量
*/
@TableField("post_views")
private Long postViews;
/**
* 评论量
*/
@TableField(value = "comment_count", exist=false)
private Long commentCount = 0L;
/**
* 0-允许评论 1-不允许评论
*/
@TableField("enable_comment")
private Boolean enableComment;
/**
* 是否删除 0=否 1=是
*/
@TableField("is_deleted")
private Boolean isDeleted;
/**
* 添加时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@TableField("create_time")
private LocalDateTime createTime;
/**
* 修改时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@TableField("update_time")
private LocalDateTime updateTime;
/**
* 获取相对链接
*/
public String getLink() {
return getLink(type, postId, postPageName);
}
public static String getLink(PostTypeEnum type, Long postId, String postPageName) {
if (StringUtils.isBlank(postPageName)) {
return String.format(URL_ID_FORMAT, postId);
}
else {
postPageName = UriUtils.encode(postPageName, StandardCharsets.UTF_8);
if (type == PostTypeEnum.PAGE) {
return String.format(URL_INDIVIDUAL_PAGE_FORMAT, postPageName);
}
else {
return String.format(URL_PAGENAME_FORMAT, postPageName);
}
}
}
/**
* FOR WIKI
*/
@TableField(value="parent_id")
private Long parentId = -1L;
@TableField(value="no")
private Integer no;
@TableField(value="level")
private Integer level;
@TableField(value="searchable")
private Boolean searchable;
/*
* FOR TREEVIEW USE.
*/
@TableField(exist=false)
private List<Post> children;
public String getTitle() {
return getPostTitle();
}
public Long getId() {
return getPostId();
}
public Boolean getHasChildren() {
return childrenCount > 0 || !CollectionUtils.isEmpty(children);
}
@TableField(exist=false)
private Boolean checked = false;
@TableField(exist=false)
private Boolean spread = false;
@TableField(value = "post_count", exist = false)
private Long postCount = 0L;
@TableField(value = "children_count", exist = false)
private Long childrenCount = 0L;
public String getFriendlyName() {
return getPostPageName();
}
}

View File

@@ -0,0 +1,54 @@
package me.qwq.doghouse.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableField;
import java.io.Serializable;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/**
* 配置项
* @author Doghole
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class RawConfig implements Serializable {
private static final long serialVersionUID = 2194163620574826637L;
/**
* 字段名
*/
@TableId("config_field")
protected String configField;
/**
* 配置名
*/
@TableField("config_name")
protected String configName;
/**
* 配置项的值
*/
@TableField("config_value")
protected String configValue;
/**
* 创建时间
*/
@TableField("create_time")
protected LocalDateTime createTime;
/**
* 修改时间
*/
@TableField("update_time")
protected LocalDateTime updateTime;
}

View File

@@ -0,0 +1,41 @@
package me.qwq.doghouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.baomidou.mybatisplus.annotation.TableField;
import java.io.Serializable;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/**
* Mapping relationship between Meta and Post
*
* @author Doghole
* @since 2021-12-01
* @see Post
* @see Meta
*/
@SuppressWarnings("serial")
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class Relationship extends Model<Relationship> implements Serializable {
@TableId(value="relationship_id", type=IdType.AUTO)
private Long relationshipId;
/**
* 标签名称
*/
@TableField("mid")
private Long mid;
@TableField("post_id")
private Long postId;
}

View File

@@ -0,0 +1,60 @@
package me.qwq.doghouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import java.io.Serializable;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import me.qwq.doghouse.enums.MetaTypeEnum;
import me.qwq.doghouse.interfaces.IFriendlyName;
import me.qwq.doghouse.validator.FriendlyNameValid;
/**
* Tag
*
* @author Doghole
* @since 2021-12-01
* @see Meta
*/
@SuppressWarnings("serial")
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("meta")
@FriendlyNameValid
public class Tag extends Meta implements Serializable, IFriendlyName {
/**
* 标签名称
*/
@TableId(value = "mid", type=IdType.AUTO)
private Long mid;
@TableField("name")
@NotBlank(message = "标签名称不允许为空")
private String name;
@TableField("type")
private MetaTypeEnum type = MetaTypeEnum.TAG;
@TableField("parent_id")
private Long parentId;
@TableField("friendly_name")
private String friendlyName;
@TableField("description")
private String description;
/**
* 排序
*/
@TableField("no")
private Integer no;
}

View File

@@ -0,0 +1,100 @@
package me.qwq.doghouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.Pattern.Flag;
import com.baomidou.mybatisplus.annotation.TableField;
import java.io.Serializable;
import org.springframework.stereotype.Component;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import me.qwq.doghouse.component.Rk;
import me.qwq.doghouse.util.GravatarUtils;
/**
* 后台管理员信息
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@Component
public class User implements Serializable {
static final long serialVersionUID=1L;
static final String USERNAME_CANNOT_BE_EMPTY = "Username cannot be empty/用户昵称不允许为空";
static final String USERNAME_LENGTH_OVERFLOW = "Username length overflow/用户昵称过长";
static final String EMAIL_CANNOT_BE_EMPTY = "Email cannot be empty/邮箱地址不允许为空";
static final String EMAIL_INVALID = "Email is invaild/邮箱地址非法";
static final String EMAIL_LENGTH_OVERFLOW = "Email length overflow/邮箱地址过长";
static final String INVALID_REQUEST = "Invalid request/非法请求";
static final String INTERNAL_ERROR = "Internal error/内部错误";
static final String URL_INVALID = "Url is invaild/网站地址非法";
static final String URL_LENGTH_OVERFLOW = "Url length overflow/网站地址过长";
/**
* 管理员id
*/
@TableId(value = "user_id", type = IdType.AUTO)
private Integer userId;
/**
* 管理员登陆名称
*/
@TableField("username")
private String username;
/**
* 管理员登陆密码
*/
@TableField("password")
private String password;
/**
* 管理员显示昵称
*/
@TableField("nickname")
@NotBlank(message = USERNAME_CANNOT_BE_EMPTY)
@Size(max = 25, message = USERNAME_LENGTH_OVERFLOW)
private String nickname;
/**
* 管理员显示昵称
*/
@TableField("email")
@NotBlank(message = EMAIL_CANNOT_BE_EMPTY)
@Email(
flags = Flag.CASE_INSENSITIVE,
message = EMAIL_INVALID)
private String email;
@TableField("site")
private String site;
/**
* 头像
* @return
*/
public String getAvatarUrl() {
return GravatarUtils.getProxyAvatarUrl(
Rk.Gravatar.prepareDogHash(getEmail()), 30);
}
/**
* 头像
* @return
*/
public String getAvatarUrl(Integer size) {
return GravatarUtils.getProxyAvatarUrl(
Rk.Gravatar.prepareDogHash(getEmail()), size);
}
}

View File

@@ -0,0 +1,373 @@
package me.qwq.doghouse.entity.config;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Pattern;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.springframework.util.CollectionUtils;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.annotation.ConfigInfo;
import me.qwq.doghouse.constants.UsefulRegex;
import me.qwq.doghouse.entity.Comment;
import me.qwq.doghouse.enums.CommentAction;
import me.qwq.doghouse.enums.CommentActionAfter;
import me.qwq.doghouse.enums.CommentStatusEnum;
import me.qwq.doghouse.interfaces.IConfig;
import me.qwq.doghouse.util.MarkDownUtils;
/**
* 评论设置
* @author Doghole
*
*/
@SuppressWarnings("serial")
@Data
@Accessors(chain=true)
@ConfigInfo(field="comment", name="评论设置", initDefault = true)
@Slf4j
public class CommentConfig implements Serializable, IConfig<CommentConfig> {
/**
* 默认构造器
*/
public CommentConfig() {
this.markdownOn = false;
this.ruleOn = true;
this.maxLevel = 6;
this.blockEmailAction = CommentAction.NONE;
this.blockEmailActionAfter = CommentActionAfter.NONE;
this.blockIpAction = CommentAction.NONE;
this.blockIpActionAfter = CommentActionAfter.NONE;
this.nonChineseAction = CommentAction.NONE;
this.nonChineseActionAfter = CommentActionAfter.NONE;
this.blockWordAction = CommentAction.NONE;
this.blockWordActionAfter = CommentActionAfter.NONE;
this.sensitiveWordAction = CommentAction.NONE;
this.sensitiveWordActionAfter = CommentActionAfter.NONE;
this.htmlWaitingOn = false;
}
/**
* 是否支持 Markdown
*/
private boolean markdownOn;
/**
* 规则是否开启
*/
private Boolean ruleOn;
/**
* 最大回复层级0 则不限层级
*/
private Integer maxLevel;
/**
* 封禁 IP 列表
*/
@JsonIgnore
private List<String> blockIpList;
/**
* 增加 IP 到 Block IP 列表
* @param ip
* @return
*/
public CommentConfig addBlockIp(String ip) {
if (StringUtils.isBlank(ip)) {
return this;
}
if (blockIpList == null) {
blockIpList = new ArrayList<>();
}
ip = ip.trim().toLowerCase();
if (!blockIpList.contains(ip)) {
blockIpList.add(ip);
}
return this;
}
/**
* 从 IP Block 列表移除指定 IP
* @param ip
* @return
*/
public CommentConfig removeBlockIp(String ip) {
if (blockIpList == null || blockIpList.size() == 0 || StringUtils.isBlank(ip)) {
return this;
}
ip = ip.trim().toLowerCase();
blockIpList.remove(ip.trim());
return this;
}
/**
* 封禁 IP 列表转换为换行符分隔的文本,一般用于前台显示,下同
* @return
*/
public String getBlockIpListText() {
if (CollectionUtils.isEmpty(blockIpList)) {
return "";
}
return String.join("\r\n", blockIpList);
}
/**
* 设置封禁 IP 列表转换为换行符分隔的文本,一般用于接收前台数据后封装,下同
* @return
*/
public CommentConfig setBlockIpListText(String text) {
blockIpList = new ArrayList<>(Arrays.asList(text.split("\r\n")));
return this;
}
/**
* 对封禁 IP 的动作
*/
private CommentAction blockIpAction;
/**
* 触发封禁 IP 动作为SPAM/FAIL 后的进一步动作
*/
private CommentActionAfter blockIpActionAfter;
@JsonIgnore
private List<String> blockEmailList;
public String getBlockEmailListText() {
if (CollectionUtils.isEmpty(blockEmailList)) {
return "";
}
return String.join("\r\n", blockEmailList);
}
public CommentConfig setBlockEmailListText(String text) {
blockEmailList = new ArrayList<>(Arrays.asList(text.split("\r\n")));
return this;
}
/**
* 增加 email 到 Block Email 列表
* @param email
* @return
*/
public CommentConfig addBlockEmail(String email) {
if (StringUtils.isBlank(email)) {
return this;
}
if (blockEmailList == null) {
blockEmailList = new ArrayList<>();
}
email = "^" + email.trim().toLowerCase().replace(".", "\\.").replace("-", "\\-") + "$";
if (!blockEmailList.contains(email)) {
blockEmailList.add(email);
}
return this;
}
/**
* 从 IP Block 列表移除指定 IP
* @param ip
* @return
*/
public CommentConfig removeBlockEmail(String email) {
if (blockEmailList == null || blockEmailList.size() == 0 || StringUtils.isBlank(email)) {
return this;
}
email = "^" + email.trim().toLowerCase().replace(".", "\\.").replace("-", "\\-") + "$";
blockEmailList.remove(email);
return this;
}
private CommentAction blockEmailAction;
private CommentActionAfter blockEmailActionAfter;
private CommentAction nonChineseAction;
private CommentActionAfter nonChineseActionAfter;
@JsonIgnore
private List<String> blockWordList;
public String getBlockWordListText() {
if (CollectionUtils.isEmpty(blockWordList)) {
return "";
}
return String.join("\r\n", blockWordList);
}
public CommentConfig setBlockWordListText(String text) {
blockWordList = new ArrayList<>(Arrays.asList(text.split("\r\n")));
return this;
}
private CommentAction blockWordAction;
private CommentActionAfter blockWordActionAfter;
@JsonIgnore
private List<String> sensitiveWordList;
public String getSensitiveWordListText() {
if (CollectionUtils.isEmpty(sensitiveWordList)) {
return "";
}
return String.join("\r\n", sensitiveWordList);
}
public CommentConfig setSensitiveWordListText(String text) {
sensitiveWordList = new ArrayList<>(Arrays.asList(text.split("\r\n")));
return this;
}
private CommentAction sensitiveWordAction;
private CommentActionAfter sensitiveWordActionAfter;
private Boolean htmlWaitingOn;
public CommentAction filter(Comment comment) {
if (!ruleOn) {
return CommentAction.NONE;
}
// IP Filter, support IPv6
if (!CollectionUtils.isEmpty(blockIpList) && StringUtils.isNotBlank(comment.getIp())) {
String ip = comment.getIp().trim().toLowerCase();
for(String ipBlockRule : blockIpList) {
Boolean fullMatched = !ipBlockRule.contains("?") && !ipBlockRule.contains("*");
String regexForIP = ipBlockRule
.replace(".", "\\.");
if (!fullMatched) {
regexForIP = regexForIP.replace("?", ".").replace("*", ".*?");
}
String prompt = fullMatched ? "完全" : "模糊";
if (ip.matches(regexForIP)) {
log.info("评论触发了 IP 地址黑名单过滤({}匹配)", prompt);
if (!processComment(comment, blockIpAction, blockIpActionAfter)) {
log.info("评论触发了 IP 地址黑名单过滤({}匹配)的 {} 操作", prompt, blockIpAction.getNote());
return blockIpAction;
}
break;
}
}
}
// Email Filter
if (!CollectionUtils.isEmpty(blockEmailList)) {
for(String blockEmailRule : blockEmailList) {
if (StringUtils.isBlank(blockEmailRule)) {
continue;
}
Pattern pattern = Pattern.compile(blockEmailRule, Pattern.CASE_INSENSITIVE);
if (pattern.matcher(comment.getEmail()).find()) {
log.info("评论触发了邮箱地址黑名单过滤");
if (!processComment(comment, blockEmailAction, blockEmailActionAfter)) {
log.info("评论触发了邮箱地址黑名单过滤的 {} 操作", blockEmailAction.getNote());
return blockEmailAction;
}
break;
}
}
}
// Non-Chinese filter
if (!UsefulRegex.ANY_CHINESE_PATTERN.matcher(comment.getContent()).find()) {
log.info("评论触发了非中文过滤");
if (!processComment(comment, nonChineseAction, nonChineseActionAfter)) {
log.info("评论触发了非中文过滤的 {} 操作", nonChineseAction.getNote());
return nonChineseAction;
}
}
// Block Word Filter
if (!CollectionUtils.isEmpty(blockWordList)) {
for(String blockWordRule : blockWordList) {
if (StringUtils.isBlank(blockWordRule)) {
continue;
}
Pattern pattern = Pattern.compile(blockWordRule, Pattern.CASE_INSENSITIVE);
if (pattern.matcher(comment.getAuthor()).find() || pattern.matcher(comment.getContent()).find()) {
log.info("评论触发了违禁词过滤");
if (!processComment(comment, blockWordAction, blockWordActionAfter)) {
log.info("评论触发了违禁词过滤的 {} 操作", blockWordAction.getNote());
return blockWordAction;
}
break;
}
}
}
// Sensitive Word Filter
if (!CollectionUtils.isEmpty(sensitiveWordList)) {
for(String sensitiveWordRule : sensitiveWordList) {
if (StringUtils.isBlank(sensitiveWordRule)) {
continue;
}
Pattern pattern = Pattern.compile(sensitiveWordRule, Pattern.CASE_INSENSITIVE);
if (pattern.matcher(comment.getAuthor()).find() || pattern.matcher(comment.getContent()).find()) {
log.info("评论触发了敏感词过滤");
if (!processComment(comment, sensitiveWordAction, sensitiveWordActionAfter)) {
log.info("评论触发了敏感词过滤的 {} 操作", sensitiveWordAction.getNote());
return sensitiveWordAction;
}
break;
}
}
}
// HTML a tag & img tag
if (markdownOn == true && htmlWaitingOn == true) {
String html = MarkDownUtils.commentToHtml(comment.getContent());
Document jsoupDoc = Jsoup.parseBodyFragment(html);
if (jsoupDoc.select("a,img").size() > 0) {
processComment(comment, CommentAction.WAITING, null);
return CommentAction.WAITING;
}
}
return CommentAction.NONE;
}
/**
* 是否进行下一步
* @param comment
* @param action
* @param actionAfter
* @return
*/
private boolean processComment(Comment comment, CommentAction action, CommentActionAfter actionAfter) {
if (action == CommentAction.NONE) {
return true;
}
if (action == CommentAction.WAITING) {
comment.setCommentStatus(CommentStatusEnum.WAITING);
return false;
}
else {
if (actionAfter == CommentActionAfter.BLOCK_EMAIL || actionAfter == CommentActionAfter.BOTH) {
addBlockEmail(comment.getEmail());
}
if (actionAfter == CommentActionAfter.BLOCK_IP || actionAfter == CommentActionAfter.BOTH) {
addBlockIp(comment.getIp());
}
comment.setCommentStatus(CommentStatusEnum.SPAM);
if (action == CommentAction.FAIL) {
comment.setIsDeleted(true);
}
}
return false;
}
}

View File

@@ -0,0 +1,81 @@
package me.qwq.doghouse.entity.config;
import java.io.Serializable;
import java.time.Duration;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.experimental.Accessors;
import me.qwq.doghouse.annotation.ConfigInfo;
import me.qwq.doghouse.constants.UsefulRegex;
import me.qwq.doghouse.interfaces.IConfig;
import me.qwq.doghouse.service.MailService;
import me.qwq.doghouse.util.SpringContextHolder;
@SuppressWarnings("serial")
@Data
@Accessors(chain=true)
@ConfigInfo(field="mail", name="邮件设置", initDefault=true)
public class MailConfig implements Serializable, IConfig<MailConfig> {
private static final Boolean DEFAULT_MAIL_ON = false;
private static final String EMPTY_STRING = "";
private static final String DEFAULT_SUBJECT = "您在 Doghouse 的留言有新回复啦";
@NotBlank
private boolean mailOn = DEFAULT_MAIL_ON;
@NotBlank
private String username = EMPTY_STRING;
private String password = EMPTY_STRING;
@NotBlank
@Pattern(regexp = UsefulRegex.DOMAIN_WITHOUT_SCHEME)
private String smtpHost = EMPTY_STRING;
@NotNull
@Size(min=1, max=65535)
private Integer smtpPort = 25;
@NotBlank
private String subject = DEFAULT_SUBJECT;
@NotBlank
@Pattern(regexp = UsefulRegex.EMAIL)
private String fromAddress = EMPTY_STRING;
private String fromUser = EMPTY_STRING;
@NotBlank
@Pattern(regexp = UsefulRegex.EMAIL)
private String adminAddress = EMPTY_STRING;
private boolean tlsEnable = false;
@NotNull
@Size(min = 0, max = 1440)
private Long sendingDelay = 5L;
/**
* 获取用于 Redisson 过期的 Duration
* @return
*/
public Duration getMailDuration() {
if (sendingDelay == null || sendingDelay <= 0L) {
return Duration.ofSeconds(1L);
}
return Duration.ofMinutes(sendingDelay);
}
@Override
public void afterSaving() {
SpringContextHolder.getBean(MailService.class).resetTaskDelay();
}
}

View File

@@ -0,0 +1,38 @@
package me.qwq.doghouse.entity.config;
import java.io.Serializable;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import jodd.io.FileUtil;
import lombok.Data;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
import me.qwq.doghouse.annotation.ConfigInfo;
import me.qwq.doghouse.interfaces.IConfig;
@SuppressWarnings("serial")
@Data
@Accessors(chain=true)
@ConfigInfo(field="markdown", name="Markdown 设置", initDefault=true)
@Slf4j
public class MarkdownConfig implements Serializable, IConfig<MarkdownConfig> {
private static final String DEFAULT_MERMAID_CONFIG = "";
private String mermaidConfig = DEFAULT_MERMAID_CONFIG;
public void afterSaving() {
if (StringUtils.isNotBlank(mermaidConfig)) {
try {
JSONObject jo = JSONObject.parse(mermaidConfig);
FileUtil.writeString("./conf/mermaid/config.json", jo.toJSONString());
} catch (Exception e) {
log.warn("Save mermaid self-defined css failed", e);
}
}
}
}

View File

@@ -0,0 +1,128 @@
package me.qwq.doghouse.entity.config;
import java.io.Serializable;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.commons.lang3.ObjectUtils;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import lombok.experimental.Accessors;
import me.qwq.doghouse.annotation.ConfigInfo;
import me.qwq.doghouse.annotation.StaticAttribute;
import me.qwq.doghouse.constants.UsefulRegex;
import me.qwq.doghouse.interfaces.IConfig;
import me.qwq.doghouse.pojo.bo.CidrBlock;
import me.qwq.doghouse.util.IPUtils;
@SuppressWarnings("serial")
@Data
@Accessors(chain=true)
@ConfigInfo(field="network", name="网络设置", initDefault=true)
public class NetworkConfig implements Serializable, IConfig<NetworkConfig> {
@StaticAttribute("ProxyTypeEnum")
private Proxy.Type proxyType = Proxy.Type.DIRECT;
@Pattern(regexp = UsefulRegex.BLANK + UsefulRegex.OR + UsefulRegex.IPv4,
message = "非法 Host")
private String proxyHost = "";
@Min(1)
@Max(65535)
private Integer proxyPort = 1;
@NotNull
private Boolean ignoreHttpsVerification = false;
@Pattern(regexp = "^((\\s*|" + UsefulRegex.IPv4 + ")(\\r?\\n?))*$")
private String trustedProxies;
@JsonIgnore
public List<String> getTrustedProxiesList() {
if (StringUtils.isBlank(trustedProxies)) {
return new ArrayList<>();
}
return new ArrayList<>(trustedProxies.lines().toList());
}
/**
* 根据配置获取代理
* @return
*/
@JsonIgnore
public Proxy getProxy () {
if (getProxyType() != null && getProxyType() != Proxy.Type.DIRECT) {
return new Proxy(getProxyType(),
new InetSocketAddress(getProxyHost(), getProxyPort()));
}
return Proxy.NO_PROXY;
}
/**
* 根据配置获取代理 Url
* @return
*/
public String getProxyUrl() {
Proxy.Type type = getProxyType();
String host = getProxyHost();
Integer port = getProxyPort();
if (ObjectUtils.anyNull(type, host, port)) {
return null;
}
if (type == Proxy.Type.DIRECT) return null;
StringBuilder sb = new StringBuilder()
.append(type == Proxy.Type.SOCKS ? "socks5://" : "http://")
.append(host).append(':').append(port);
return sb.toString();
}
@JsonIgnore
private Set<String> trustedIpList;
@JsonIgnore
public Set<String> getTrustedIpList() {
if (trustedIpList == null || trustedIpList.isEmpty()) {
// 从 trustedProxies 中恢复
trustedIpList = new HashSet<>();
for (String potentialIp : getTrustedProxiesList()) {
if (IPUtils.isIP(potentialIp)) {
trustedIpList.add(potentialIp);
}
}
}
return trustedIpList;
}
@JsonIgnore
private Set<CidrBlock> trustedCidrBlockList;
@JsonIgnore
public Set<CidrBlock> getTrustedCidrBlockList() {
if (trustedCidrBlockList == null || trustedCidrBlockList.isEmpty()) {
// 从 trustedProxies 中恢复
trustedCidrBlockList = new HashSet<>();
for (String potentialCidr : getTrustedProxiesList()) {
if (IPUtils.isCIDR(potentialCidr)) {
trustedCidrBlockList.add(new CidrBlock(potentialCidr));
}
}
}
return trustedCidrBlockList;
}
public void afterSaving() {
trustedIpList = null;
trustedCidrBlockList = null;
}
}

View File

@@ -0,0 +1,93 @@
package me.qwq.doghouse.entity.config;
import java.io.Serializable;
import org.springframework.cache.CacheManager;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.experimental.Accessors;
import me.qwq.doghouse.annotation.ConfigInfo;
import me.qwq.doghouse.cache.CacheConstants;
import me.qwq.doghouse.enums.PostTypeEnum;
import me.qwq.doghouse.interfaces.IConfig;
import me.qwq.doghouse.interfaces.IPostTypeConfig;
import me.qwq.doghouse.util.SpringContextHolder;
@SuppressWarnings("serial")
@Data
@Accessors(chain=true)
@ConfigInfo(field="page", name="独立页面设置", initDefault=true, managed=false)
public class PageConfig implements Serializable, IConfig<PageConfig>, IPostTypeConfig<PageConfig> {
private static final String DEFAULT_NAME = "Doghouse";
private static final String DEFAULT_DESCRIPTION = "Doghouse 个人博客系统";
private static final Long DEFAULT_COMMENT_PAGE_SIZE = 5L;
private static final Long DEFAULT_RECENT_SIZE = 6L;
private static final Long DEFAULT_POPULAR_SIZE = 6L;
private static final Long DEFAULT_TAG_SIZE = 8L;
private static final Long DEFAULT_RECENT_COMMENT_SIZE = 6L;
private static final Integer DEFAULT_RECENT_COMMENT_LEVEL = 3;
@NotBlank
private String name = DEFAULT_NAME;
@NotBlank
private String description = DEFAULT_DESCRIPTION;
@NotNull
@Size(min=0)
private Long commentPageSize = DEFAULT_COMMENT_PAGE_SIZE;
@NotNull
@Size(min=0)
private Long recentSize = DEFAULT_RECENT_SIZE;
@NotNull
@Size(min=0)
private Long popularSize = DEFAULT_POPULAR_SIZE;
@NotNull
@Size(min=0)
private Long tagSize = DEFAULT_TAG_SIZE;
@NotNull
@Size(min=0)
private Long recentCommentSize = DEFAULT_RECENT_COMMENT_SIZE;
@NotNull
@Size(min=0)
private Integer recentCommentLevel = DEFAULT_RECENT_COMMENT_LEVEL;
private Boolean addToSitemap = true;
private Boolean addToFeed = false;
private Boolean rssOn = false;
private Boolean imageCompress = false;
@Override
public void afterSaving() {
// 缓存驱逐GET_JUMP_TO_URL
// 因为具体是哪一页是和每页评论数有关的,每页评论数又在 SiteConfig 里定义
// 一旦更改,那么评论对应页的缓存得驱逐
((CacheManager)SpringContextHolder.getBean("cacheManager")).getCache(CacheConstants.Comments.GET_JUMP_TO_URL).clear();
}
public PostTypeEnum getPostType() {
return PostTypeEnum.PAGE;
}
public Boolean getEnabled() {
return true;
}
/**
* 独立页面不需要 PageSize
*/
public Long getPageSize() {
return 0L;
}
public String getMappingPrefix() {
return "";
}
}

View File

@@ -0,0 +1,124 @@
package me.qwq.doghouse.entity.config;
import java.io.Serializable;
import org.springframework.cache.CacheManager;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.Pattern.Flag;
import lombok.Data;
import lombok.experimental.Accessors;
import me.qwq.doghouse.annotation.ConfigInfo;
import me.qwq.doghouse.cache.CacheConstants;
import me.qwq.doghouse.constants.UsefulRegex;
import me.qwq.doghouse.enums.PostTypeEnum;
import me.qwq.doghouse.interfaces.IConfig;
import me.qwq.doghouse.interfaces.IPostTypeConfig;
import me.qwq.doghouse.util.SpringContextHolder;
@SuppressWarnings("serial")
@Data
@Accessors(chain=true)
@ConfigInfo(field="site", name="基本设置", initDefault=true)
public class SiteConfig implements Serializable, IConfig<SiteConfig>, IPostTypeConfig<SiteConfig> {
private static final String GRAVATAR_URL_ADDRESS_INVALID = "Gravatar URL 地址不正确";
private static final String ADDRESS_INVALID = "站点地址不正确";
private static final String DEFAULT_NAME = "Doghouse";
private static final String DEFAULT_DESCRIPTION = "Doghouse 个人博客系统";
private static final String DEFAULT_ADDRESS = "http://localhost";
private static final String DEFAULT_KEYWORDS = "Doghouse,个人博客,博客,狗洞,日记本";
private static final String DEFAULT_COPYRIGHT = "Doghouse";
private static final String DEFAULT_BEIAN_INFO = "";
private static final Long DEFAULT_PAGE_SIZE = 3L;
private static final Long DEFAULT_COMMENT_PAGE_SIZE = 5L;
private static final Long DEFAULT_RECENT_SIZE = 6L;
private static final Long DEFAULT_POPULAR_SIZE = 6L;
private static final Long DEFAULT_TAG_SIZE = 8L;
private static final Long DEFAULT_RECENT_COMMENT_SIZE = 6L;
private static final Integer DEFAULT_RECENT_COMMENT_LEVEL = 3;
@NotBlank
private String name = DEFAULT_NAME;
private String themeIdentity;
@NotBlank
private String description = DEFAULT_DESCRIPTION;
@NotBlank
@Pattern(regexp = UsefulRegex.URL_WITH_SCHEME,
flags = Flag.CASE_INSENSITIVE,
message = ADDRESS_INVALID)
private String address = DEFAULT_ADDRESS;
private String keywords = DEFAULT_KEYWORDS;
private String copyright = DEFAULT_COPYRIGHT;
@NotBlank
@Pattern(regexp = UsefulRegex.URL_ENDS_WITH_SLASH,
flags = Flag.CASE_INSENSITIVE,
message = GRAVATAR_URL_ADDRESS_INVALID)
private String gravatarUrl = "//sdn.geekzu.org/avatar/";
private String beiAnInfo = DEFAULT_BEIAN_INFO;
@NotNull
@Size(min=1)
private Long pageSize = DEFAULT_PAGE_SIZE;
@NotNull
@Size(min=0)
private Long commentPageSize = DEFAULT_COMMENT_PAGE_SIZE;
@NotNull
@Size(min=0)
private Long recentSize = DEFAULT_RECENT_SIZE;
@NotNull
@Size(min=0)
private Long popularSize = DEFAULT_POPULAR_SIZE;
@NotNull
@Size(min=0)
private Long tagSize = DEFAULT_TAG_SIZE;
@NotNull
@Size(min=0)
private Long recentCommentSize = DEFAULT_RECENT_COMMENT_SIZE;
@NotNull
@Size(min=0)
private Integer recentCommentLevel = DEFAULT_RECENT_COMMENT_LEVEL;
private Boolean addToSitemap = true;
private Boolean addToFeed = true;
private Boolean rssOn = false;
/**
* 是否启用图片压缩
*/
private Boolean imageCompress = false;
@Override
public void afterSaving() {
// 缓存驱逐GET_JUMP_TO_URL
// 因为具体是哪一页是和每页评论数有关的,每页评论数又在 SiteConfig 里定义
// 一旦更改,那么评论对应页的缓存得驱逐
((CacheManager)SpringContextHolder.getBean("cacheManager")).getCache(CacheConstants.Comments.GET_JUMP_TO_URL).clear();
}
public PostTypeEnum getPostType() {
return PostTypeEnum.POST;
}
public Boolean getEnabled() {
return true;
}
public String getMappingPrefix() {
return "";
}
}

View File

@@ -0,0 +1,89 @@
package me.qwq.doghouse.entity.config;
import java.io.Serializable;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.experimental.Accessors;
import me.qwq.doghouse.annotation.ConfigInfo;
import me.qwq.doghouse.enums.PostTypeEnum;
import me.qwq.doghouse.interfaces.IConfig;
import me.qwq.doghouse.interfaces.IPostTypeConfig;
@SuppressWarnings("serial")
@Data
@Accessors(chain = true)
@ConfigInfo(field = "tweet", name = "说说设置", initDefault = true)
public class TweetConfig implements Serializable, IConfig<TweetConfig>, IPostTypeConfig<TweetConfig> {
private static final Boolean DEFAULT_TWEET_MODE = false;
private static final String DEFAULT_NAME = "DogTweet";
private static final String DEFAULT_DESCRIPTION = "Doghouse 说说";
private static final String DEFAULT_KEYWORDS = "Doghouse,doghouse tweet,dogutweet,tweet,碎碎念,说说,心情,微博";
private static final String DEFAULT_COPYRIGHT = "Doghouse";
private static final Long DEFAULT_PAGE_SIZE = 3L;
private static final Long DEFAULT_COMMENT_SIZE = 5L;
private static final Long DEFAULT_RECENT_SIZE = 6L;
private static final Long DEFAULT_POPULAR_SIZE = 6L;
private static final Long DEFAULT_TAG_SIZE = 8L;
private static final Long DEFAULT_RECENT_COMMENT_SIZE = 6L;
private static final Integer DEFAULT_RECENT_COMMENT_LEVEL = 3;
@NotBlank
private Boolean tweetMode = DEFAULT_TWEET_MODE;
@NotBlank
private String name = DEFAULT_NAME;
@NotBlank
private String description = DEFAULT_DESCRIPTION;
private String keywords = DEFAULT_KEYWORDS;
private String copyright = DEFAULT_COPYRIGHT;
@NotNull
@Size(min = 0)
private Long pageSize = DEFAULT_PAGE_SIZE;
@NotNull
@Size(min = 0)
private Long commentPageSize = DEFAULT_COMMENT_SIZE;
@NotNull
@Size(min = 0)
private Long recentSize = DEFAULT_RECENT_SIZE;
@NotNull
@Size(min = 0)
private Long popularSize = DEFAULT_POPULAR_SIZE;
@NotNull
@Size(min = 0)
private Long tagSize = DEFAULT_TAG_SIZE;
@NotNull
@Size(min = 0)
private Long recentCommentSize = DEFAULT_RECENT_COMMENT_SIZE;
@NotNull
@Size(min = 0)
private Integer recentCommentLevel = DEFAULT_RECENT_COMMENT_LEVEL;
private Boolean addToSitemap = true;
private Boolean addToFeed = false;
public PostTypeEnum getPostType() {
return PostTypeEnum.TWEET;
}
public Boolean getEnabled() {
return getTweetMode();
}
public String getMappingPrefix() {
return "/tweet";
}
public String getTemplatePrefix() {
return "tweet";
}
}

View File

@@ -0,0 +1,91 @@
package me.qwq.doghouse.entity.config;
import java.io.Serializable;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.experimental.Accessors;
import me.qwq.doghouse.annotation.ConfigInfo;
import me.qwq.doghouse.enums.PostTypeEnum;
import me.qwq.doghouse.interfaces.IConfig;
import me.qwq.doghouse.interfaces.IPostTypeConfig;
@SuppressWarnings("serial")
@Data
@Accessors(chain=true)
@ConfigInfo(field="wiki", name="Wiki 设置", initDefault=true)
public class WikiConfig implements Serializable, IConfig<WikiConfig>, IPostTypeConfig<WikiConfig> {
private static final Boolean DEFAULT_WIKI_MODE = false;
private static final String DEFAULT_NAME = "DoguWiki";
private static final String DEFAULT_DESCRIPTION = "Doghouse 个人 Wiki 系统";
private static final String DEFAULT_KEYWORDS = "Doghouse, doghouse wiki, doguwiki,维基,知识库,狗洞";
private static final String DEFAULT_COPYRIGHT = "Doghouse";
private static final Long DEFAULT_PAGE_SIZE = 3L;
private static final Long DEFAULT_COMMENT_PAGE_SIZE = 5L;
private static final Long DEFAULT_RECENT_SIZE = 6L;
private static final Long DEFAULT_POPULAR_SIZE = 6L;
private static final Long DEFAULT_TAG_SIZE = 8L;
private static final Long DEFAULT_RECENT_COMMENT_SIZE = 6L;
private static final Integer DEFAULT_RECENT_COMMENT_LEVEL = 3;
@NotBlank
private Boolean wikiMode = DEFAULT_WIKI_MODE;
@NotBlank
private String name = DEFAULT_NAME;
@NotBlank
private String description = DEFAULT_DESCRIPTION;
private String keywords = DEFAULT_KEYWORDS;
private String copyright = DEFAULT_COPYRIGHT;
@NotNull
@Size(min=0)
private Long commentPageSize = DEFAULT_COMMENT_PAGE_SIZE;
@NotNull
@Size(min=0)
private Long recentSize = DEFAULT_RECENT_SIZE;
@NotNull
@Size(min=0)
private Long popularSize = DEFAULT_POPULAR_SIZE;
@NotNull
@Size(min=0)
private Long tagSize = DEFAULT_TAG_SIZE;
@NotNull
@Size(min=0)
private Long recentCommentSize = DEFAULT_RECENT_COMMENT_SIZE;
@NotNull
@Size(min=0)
private Integer recentCommentLevel = DEFAULT_RECENT_COMMENT_LEVEL;
private Boolean addToSitemap = true;
private Boolean addToFeed = false;
public PostTypeEnum getPostType() {
return PostTypeEnum.WIKI;
}
public Boolean getEnabled() {
return getWikiMode();
}
public Long getPageSize() {
return DEFAULT_PAGE_SIZE;
}
public String getMappingPrefix() {
return "/wiki";
}
public String getTemplatePrefix() {
return "wiki";
}
}

View File

@@ -0,0 +1,7 @@
package me.qwq.doghouse.enums;
public enum CacheEvictionSpan {
HOURLY, DAILY, WEEKLY, MONTHLY, SEASONLY, YEARLY
}

View File

@@ -0,0 +1,34 @@
package me.qwq.doghouse.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import me.qwq.doghouse.annotation.StaticAttribute;
/**
* 评论触发动作
**/
@StaticAttribute
public enum CommentAction implements IQueryableEnum {
NONE(0,"无动作"),
WAITING(1,"标记为待审"),
SPAM(2,"标记为垃圾"),
FAIL(3,"评论失败");
@EnumValue
private final int status;
private final String note;
CommentAction(int status, String note) {
this.status = status;
this.note = note;
}
public String getNote() {
return note;
}
public int getStatus() {
return status;
}
}

View File

@@ -0,0 +1,34 @@
package me.qwq.doghouse.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import me.qwq.doghouse.annotation.StaticAttribute;
/**
* 标记为垃圾或失败后的动作
**/
@StaticAttribute
public enum CommentActionAfter implements IQueryableEnum {
NONE(0,"无动作"),
BLOCK_IP(1,"添加到屏蔽IP列表"),
BLOCK_EMAIL(2,"添加到屏蔽邮箱列表"),
BOTH(3, "屏蔽IP和邮箱");
@EnumValue
private final int status;
private final String note;
CommentActionAfter(int status, String note) {
this.status = status;
this.note = note;
}
public String getNote() {
return note;
}
public int getStatus() {
return status;
}
}

View File

@@ -0,0 +1,33 @@
package me.qwq.doghouse.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import me.qwq.doghouse.annotation.StaticAttribute;
/**
* 评论状态
**/
@StaticAttribute
public enum CommentStatusEnum implements IQueryableEnum {
RELEASED(0,"审核通过"),
WAITING(1,"审核中"),
SPAM(2,"垃圾");
@EnumValue
private final int status;
private final String note;
CommentStatusEnum(int status, String note) {
this.status = status;
this.note = note;
}
public String getNote() {
return note;
}
public int getStatus() {
return status;
}
}

View File

@@ -0,0 +1,161 @@
package me.qwq.doghouse.enums;
import java.util.Objects;
import org.apache.commons.lang3.StringUtils;
import com.baomidou.mybatisplus.annotation.EnumValue;
import me.qwq.doghouse.annotation.StaticAttribute;
import me.qwq.doghouse.entity.Asset;
/**
* 文件类型枚举
**/
@StaticAttribute
public enum FileTypeEnum implements IQueryableEnum {
OTHER(0, "其他"),
IMAGE(1, "图片"),
ARCHIVE(2, "压缩文件"),
DOCUMENT(3, "文档"),
MEDIA(4, "媒体文件"),
APPLICATION(5, "应用程序");
@EnumValue
private final int status;
private final String note;
FileTypeEnum(int status, String note) {
this.status = status;
this.note = note;
}
public String getNote() {
return note;
}
public int getStatus() {
return status;
}
public static FileTypeEnum getFileType(Asset asset) {
if (Objects.isNull(asset)) {
return OTHER;
}
return getFileType(asset.getExt());
}
public static FileTypeEnum getFileType(String ext) {
if (StringUtils.isEmpty(ext)) {
return OTHER;
}
ext = ext.toLowerCase();
switch (ext) {
case "jpg":
case "jpeg":
case "gif":
case "png":
case "apng":
case "bmp":
case "svg":
case "ico":
case "webp":
case "psd":
case "tif":
case "raw":
case "avif":
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;
}
}
}

View File

@@ -0,0 +1,22 @@
package me.qwq.doghouse.enums;
import com.github.yulichang.wrapper.enums.BaseFuncEnum;
public enum FuncsEnum implements BaseFuncEnum {
COUNT_DISTINCT("COUNT(DISTINCT %s)"),
SELECT_META("GROUP_CONCAT(DISTINCT %s ORDER BY %s DESC, %s ASC)"),
DISTINCT("DISTINCT %s");
private final String sql;
FuncsEnum(String sql) {
this.sql = sql;
}
@Override
public String getSql() {
return this.sql;
}
}

View File

@@ -0,0 +1,12 @@
package me.qwq.doghouse.enums;
public interface IQueryableEnum {
public default String getName() {
return name();
}
String name();
public int getStatus();
public String getNote();
}

View File

@@ -0,0 +1,35 @@
package me.qwq.doghouse.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonValue;
import me.qwq.doghouse.annotation.StaticAttribute;
/**
* 链接类型枚举
**/
@StaticAttribute
public enum LinkTypeEnum implements IQueryableEnum {
FRIEND(0, "朋友"),
RECOMMEND(1, "推荐"),
NAVI(2, "导航");
@EnumValue
private final int status;
@JsonValue
private final String note;
LinkTypeEnum(int status, String note) {
this.status = status;
this.note = note;
}
public String getNote() {
return note;
}
public int getStatus() {
return status;
}
}

View File

@@ -0,0 +1,29 @@
package me.qwq.doghouse.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import me.qwq.doghouse.annotation.StaticAttribute;
@StaticAttribute
public enum MetaTypeEnum implements IQueryableEnum {
CATEGORY(0,"分类"),
TAG(1,"标签");
@EnumValue
private final int status;
private final String note;
MetaTypeEnum(int status, String note) {
this.status = status;
this.note = note;
}
public String getNote() {
return note;
}
public int getStatus() {
return status;
}
}

View File

@@ -0,0 +1,36 @@
package me.qwq.doghouse.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonValue;
import me.qwq.doghouse.annotation.StaticAttribute;
/**
* 文章状态枚举
**/
@StaticAttribute
public enum PostStatusEnum implements IQueryableEnum {
RELEASED(0, "公开"),
DRAFT(1, "草稿"),
HIDDEN(2, "隐藏"),
PRIVATE(3, "私密");
@EnumValue
private final int status;
@JsonValue
private final String note;
PostStatusEnum(int status, String note) {
this.status = status;
this.note = note;
}
public String getNote() {
return note;
}
public int getStatus() {
return status;
}
}

View File

@@ -0,0 +1,50 @@
package me.qwq.doghouse.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import me.qwq.doghouse.annotation.StaticAttribute;
/**
* 文章/页面类型枚举
**/
@StaticAttribute
public enum PostTypeEnum implements IQueryableEnum {
/**
* 说说
*/
TWEET(3, "说说"),
/**
* 独立页面
*/
WIKI(2, "Wiki"),
/**
* 独立页面
*/
PAGE(1, "页面"),
/**
* 文章
*/
POST(0, "文章");
@EnumValue
private final int status;
private final String note;
PostTypeEnum(int status, String note) {
this.status = status;
this.note = note;
}
public String getNote() {
return note;
}
public int getStatus() {
return status;
}
public String toLowerString() {
return toString().toLowerCase();
}
}

View File

@@ -0,0 +1,17 @@
package me.qwq.doghouse.exception;
public class PageException extends RuntimeException {
/**
*
*/
private static final long serialVersionUID = -6969336301374494629L;
public PageException() {
super();
}
public PageException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,19 @@
package me.qwq.doghouse.exception;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.http.HttpStatus;
@ResponseStatus(code = HttpStatus.NOT_FOUND, reason = "Page not found")
public class PageNotFoundException extends PageException {
private static final long serialVersionUID = -4080133008166027751L;
public PageNotFoundException() {
super();
}
public PageNotFoundException(String message) {
super(message);
}
}

Some files were not shown because too many files have changed in this diff Show More