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

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

22
README.md Normal file
View File

@@ -0,0 +1,22 @@
# Doghouse - 基于 Springboot 的个人博客系统
## 本地 avif 和 webp 转换
下载对应二进制文件,并放在系统环境变量下,确保能通过 CLI 访问即可。
## 本地 Mermaid 流程图转换
安装 node而后
```
npm i -g @mermaid-js/mermaid-cli
```
## 疑难解决
如果出现:`url` 未配置之类的,可能是 `devtools` 热重载出问题,需要 maven clean 以后再 maven install
如果出现 cglib 相关的 java.lang.IncompatibleClassChangeError请检查 cglib 支持的最高版本的 asm并手动指定在 dependency 中。有时候 spring-boot 会指定额外的 asm 版本,导致不兼容。
如果出现 InaccessibleObjectException: Unable to make protected final java.lang.Class请添加 --add-opens=java.base/java.lang=ALL-UNNAMED 在运行前。

338
pom.xml Normal file
View File

@@ -0,0 +1,338 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>me.qwq</groupId>
<artifactId>doghouse</artifactId>
<version>1.3.0</version>
<packaging>jar</packaging>
<name>Doghouse</name>
<description>Private Blog System for Doghole</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
<relativePath />
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>22</java.version>
<maven.compiler.source>22</maven.compiler.source>
<maven.compiler.target>22</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>
<!--
https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-cache -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring session redis 依赖 -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.27.1</version>
</dependency>
<!--
https://mvnrepository.com/artifact/org.redisson/redisson-spring-boot-starter -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.26.0</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.5</version>
<scope>compile</scope>
<exclusions>
<!-- 解决 Invalid value type for attribute 'factoryBeanObjectType':
java.lang.String 问题 -->
<exclusion>
<artifactId>mybatis-spring</artifactId>
<groupId>org.mybatis</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>3.0.3</version>
</dependency>
<dependency>
<groupId>com.github.yulichang</groupId>
<artifactId>mybatis-plus-join-boot-starter</artifactId>
<version>1.4.11</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- https://mvnrepository.com/artifact/com.mysql/mysql-connector-j -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/org.xerial/sqlite-jdbc -->
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-text</artifactId>
<version>1.11.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.14.0</version><!--$NO-MVN-MAN-VER$-->
</dependency>
<dependency>
<groupId>com.vladsch.flexmark</groupId>
<artifactId>flexmark-all</artifactId>
<version>0.62.2</version>
<exclusions>
<!-- 移除不需要的组件 -->
<exclusion>
<groupId>com.vladsch.flexmark</groupId>
<artifactId>flexmark-pdf-converter</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- https://mvnrepository.com/artifact/com.alibaba.fastjson2/fastjson2 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.23</version>
</dependency>
<!--
https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-annotations -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.14.1</version><!--$NO-MVN-MAN-VER$-->
</dependency>
<!-- https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk15to18 -->
<!-- sha224 哈希套件 -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15to18</artifactId>
<version>1.77</version>
</dependency>
<!-- 验证码 -->
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
<!--
https://mvnrepository.com/artifact/com.twelvemonkeys.imageio/imageio-webp -->
<!-- WEBP 图像读取支持,用于判断上传文件的长宽 -->
<dependency>
<groupId>com.twelvemonkeys.imageio</groupId>
<artifactId>imageio-webp</artifactId>
<version>3.10.1</version>
<scope>runtime</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.17.0</version><!--$NO-MVN-MAN-VER$-->
</dependency>
<!-- https://mvnrepository.com/artifact/com.rometools/rome -->
<!-- Atom/RSS -->
<dependency>
<groupId>com.rometools</groupId>
<artifactId>rome</artifactId>
<version>1.16.0</version>
</dependency>
<!-- 邮件 -->
<dependency>
<groupId>com.sun.mail</groupId>
<artifactId>javax.mail</artifactId>
<version>1.6.2</version>
</dependency>
<!-- quartz 定时任务 -->
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz</artifactId>
<exclusions>
<exclusion>
<artifactId>slf4j-api</artifactId>
<groupId>org.slf4j</groupId>
</exclusion>
</exclusions>
</dependency>
<!-- https://mvnrepository.com/artifact/org.reflections/reflections -->
<dependency>
<groupId>org.reflections</groupId>
<artifactId>reflections</artifactId>
<version>0.10.2</version>
</dependency>
<!-- 给头像做反代 -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
</dependency>
<!-- 给头像做反代 -->
<dependency>
<groupId>me.qwq</groupId>
<artifactId>dogface</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.4.8-jre</version>
</dependency>
<!-- IP 归属地 -->
<dependency>
<groupId>com.maxmind.geoip2</groupId>
<artifactId>geoip2</artifactId>
<version>4.3.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>build-info</id>
<goals>
<goal>build-info</goal>
</goals>
<configuration>
<additionalProperties>
<java.version>${java.version}</java.version>
<description>${project.description}</description>
</additionalProperties>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>
--add-opens=java.base/java.lang=ALL-UNNAMED
--add-opens=java.base/java.util=ALL-UNNAMED
</argLine>
<skip>true</skip>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
<resources>
<resource>
<directory>src/main/resources</directory>
<excludes>
<exclude>/static/blog/plugins/doghouse/js/scrollLock/demo/**</exclude>
</excludes>
</resource>
</resources>
</build>
</project>

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;
}
}

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