13 Commits

Author SHA1 Message Date
Chuyaoyuan cda8a3e0ee Update UI 2026-05-13 15:22:39 +08:00
Chuyaoyuan 28eb17f60b front ui fix 2026-05-13 14:39:57 +08:00
Chuyaoyuan 9e42918d5e Inbox Message & bugs 2026-05-13 13:58:08 +08:00
Chuyaoyuan 4dfd27c785 update theme and user top 2026-05-12 18:49:34 +08:00
Chuyaoyuan ba8c1e3be3 update fix 2026-05-09 11:35:30 +08:00
Chuyaoyuan 1828c37fca Update admin 2026-05-08 19:20:49 +08:00
Chuyaoyuan e8f36222e7 update admin 2026-05-06 19:10:27 +08:00
Chuyaoyuan e24b41f53c update springboot
Co-authored-by: Copilot <copilot@github.com>
2026-05-06 18:18:28 +08:00
Chuyaoyuan 388a34837f update 2026-04-30 18:53:06 +08:00
Chuyaoyuan 2adefb5207 update 2026-04-30 16:27:58 +08:00
Chuyaoyuan ac6442a452 update 2026-04-29 19:04:48 +08:00
Chuyaoyuan 543c4d9096 2 2026-04-29 14:34:14 +08:00
Chuyaoyuan 07e34a1fe8 2 2026-04-29 14:33:43 +08:00
122 changed files with 4758 additions and 1446 deletions
View File
+44
View File
@@ -0,0 +1,44 @@
# Copilot Instructions for Jiscuss
## Build, test, and run commands
This repository is a Maven Spring Boot project (Java 17+).
- Full compile: `mvn -DskipTests compile`
- Full test suite: `mvn test`
- Run one test class: `mvn -Dtest=ClassName test`
- Run one test method: `mvn -Dtest=ClassName#methodName test`
- Run app (default H2 profile): `mvn spring-boot:run`
- Run app (MySQL profile): `mvn spring-boot:run -Dspring-boot.run.profiles=mysql`
- Run app on a non-80 port: `mvn spring-boot:run -Dspring-boot.run.arguments="--server.port=8080"`
There is currently no dedicated lint plugin configured in `pom.xml` (no Checkstyle/SpotBugs/PMD task).
## High-level architecture
Jiscuss is a Spring Boot monolith with a server-rendered web UI plus REST APIs:
- **Web MVC + templates**: `controller/` classes return FreeMarker views from `src/main/resources/templates/`, with frontend assets under `src/main/resources/static/` (Semantic UI + custom JS).
- **REST APIs**: `controller/api/` exposes JSON endpoints (`/user_api/**`, `/other_api/**`) and is documented by SpringDoc OpenAPI (`/swagger-ui.html`, `/v3/api-docs`).
- **Domain and persistence**: `entity/` models map to relational tables; `repository/` uses Spring Data JPA (plus a few native queries); `service/impl/` contains business logic and transaction boundaries.
- **Security and RBAC**: `WebSecurityConfig` wires Spring Security 6; auth uses `UserDetailServiceImpl`; path checks delegate to `RbacPermission`; roles come from `rbac_role` / `rbac_user_role` (Flyway migration `V4__rbac_admin_console.sql`).
- **Admin console and auditing**: `/admin/**` endpoints in `AdminSystemController` manage users/roles/content and write operation history via `AuditLogService`.
- **Database lifecycle**: Flyway owns schema/data evolution (`src/main/resources/db/migration/*`), with profile-specific datasource config in `application-h2.yml` and `application-mysql.yml`.
## Key repository conventions
- **Flyway is the source of truth for DDL**: keep schema changes in new migration files; do not reintroduce `schema.sql`/`data.sql` initialization paths.
- **Boot 3 / Jakarta imports only**: use `jakarta.*` APIs (not legacy `javax.*` servlet/validation/persistence types).
- **Password handling is BCrypt-only**: DB passwords are expected to be BCrypt hashes (`UserDetailServiceImpl`, migration notes in `V2__security_updates.sql`); do not add plaintext comparisons in SQL/repositories.
- **Role naming convention**: security authorities are `ROLE_*`; database role codes are plain (`ADMIN`, `USER`) and are prefixed in the auth layer.
- **Controller split matters**:
- `controller/` for page flows and model population
- `controller/api/` for REST endpoints
- `AdminSystemController` for admin workflows under `/admin/**`
- **Current user lookup pattern**: controllers extending `BaseController` use `getUserInfo(HttpServletRequest)` (reads `SPRING_SECURITY_CONTEXT` from session).
- **Response wrappers are mixed**: newer global exception flow returns `ApiResponse`; legacy endpoints still return entities or `ResponseResult`. Keep changes consistent with the style already used in the touched controller package.
- **Caching is selective**: user read paths in `UsersServiceImpl` use cache names (`user`, `userList`) backed by Ehcache JCache (`ehcache3.xml`).
- **Profile behavior**:
- default/dev flow is H2 profile
- MySQL requires explicit profile activation and datasource credentials
- admin-only tooling endpoints include `/admin/**`, `/actuator/**`, `/druid/**` (and H2 console when enabled)
+172
View File
@@ -57,6 +57,178 @@
http://localhost
```
> 说明:以上为原始快速启动说明。当前升级后的 v2.0 分支已迁移到 Spring Boot 3,推荐使用 JDK 17 运行;旧版本中的 JDK 1.8 说明仅适用于早期分支。
## 当前升级版本说明
当前代码已完成 Spring Boot 3 兼容升级,主要技术栈如下:
- Java 17
- Spring Boot 3.3.5
- Spring Security 6
- Spring Data JPA / Hibernate 6
- FreeMarker
- H2 Database(开发环境默认)
- MySQL 8(生产/外部数据库环境)
- Flyway(数据库版本迁移)
- SpringDoc OpenAPI(替代 Springfox Swagger
- Ehcache 3 + JCache
- Druid Spring Boot 3 Starter
- MapStruct
## 环境要求
- JDK17+
- Maven3.8+
- MySQL8.x(仅 mysql profile 需要)
检查本地环境:
```
java -version
javac -version
mvn -version
```
## 构建与启动
### 使用默认 H2 开发环境
项目默认激活 `h2` profile
```
mvn spring-boot:run
```
访问:
```
http://localhost
```
如果本机 Linux 普通用户无法绑定 80 端口,可以临时指定其他端口:
```
mvn spring-boot:run -Dspring-boot.run.arguments="--server.port=8080"
```
然后访问:
```
http://localhost:8080
```
### 使用 MySQL 环境
1. 创建数据库:
```
CREATE DATABASE jiscuss DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
2. 修改配置文件:
```
src/main/resources/application-mysql.yml
```
根据实际环境修改:
- `spring.datasource.url`
- `spring.datasource.username`
- `spring.datasource.password`
3. 启动 mysql profile
```
mvn spring-boot:run -Dspring-boot.run.profiles=mysql
```
## 数据库迁移说明
项目已使用 Flyway 接管数据库初始化和后续变更,迁移脚本位于:
```
src/main/resources/db/migration/
```
当前迁移文件:
- `V1__init_schema.sql`:初始化表结构和基础数据
- `V2__security_updates.sql`:安全相关字段/数据升级
注意:旧的 `schema.sql``data.sql` 已不再使用,避免与 Flyway 重复初始化。
## 安全与管理端点
- 登录认证已迁移到 Spring Security 6 配置方式。
- `/admin/**``/actuator/**``/druid/**` 需要管理员角色访问。
- H2 Console 默认关闭;如需开发调试,可显式开启:
```
mvn spring-boot:run -Dspring-boot.run.arguments="--spring.h2.console.enabled=true"
```
- OpenAPI 文档地址:
```
http://localhost/swagger-ui.html
http://localhost/v3/api-docs
```
## 网络不佳时的构建建议
如果依赖已经下载到本地 Maven 仓库,可以使用离线模式:
```
mvn -o -DskipTests compile
```
如果首次构建尚未下载 Spring Boot 3.3.5 等依赖,离线模式会失败,需要在网络可用时先执行一次:
```
mvn -DskipTests compile
```
国内网络环境可配置 Maven 镜像源后再构建。依赖下载完成后,后续即可使用 `mvn -o` 离线构建。
## 常用检查命令
编译检查:
```
mvn -DskipTests compile
```
打包:
```
mvn -DskipTests package
```
运行 jar
```
java -jar target/jiscuss-0.0.1-SNAPSHOT.jar
```
## 升级注意事项
- Spring Boot 3 使用 `jakarta.*` 命名空间,旧的 `javax.servlet``javax.persistence``javax.validation` 不再使用。
- Hibernate 6 可自动识别数据库方言,配置中无需手动指定 `database-platform`
- MySQL 连接已移除不推荐的 `autoReconnect=true`
- JSON 响应优先使用 Spring MVC + Jackson 自动序列化,不再依赖 `org.json`
- WebSocket starter 已移除;如后续确实需要实时通信,再按功能重新引入。
## [四]、开源协议
+100 -49
View File
@@ -3,23 +3,25 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.8.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.13</version>
<relativePath/>
</parent>
<groupId>com.yaoyuan</groupId>
<artifactId>jiscuss</artifactId>
<!-- <packaging>war</packaging> -->
<version>0.0.1-SNAPSHOT</version>
<name>jiccuss</name>
<description>jiccuss project for Spring Boot</description>
<description>Jiscuss forum project for Spring Boot 3</description>
<properties>
<java.version>1.8</java.version>
<!-- Java 17 matches the installed JDK (openjdk-17). Upgrade to 21 when JDK 21 is installed. -->
<java.version>17</java.version>
<mapstruct.version>1.6.3</mapstruct.version>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
@@ -28,7 +30,7 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<!-- spring security -->
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
@@ -37,77 +39,126 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Bean Validation (input validation) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- h2 -->
<!-- Actuator for health checks and metrics -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Cache -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- H2 (dev/test only) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- mysql -->
<!-- MySQL 8 connector (updated groupId from mysql:mysql-connector-java) -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.30</version>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- json -->
<!-- SpringDoc OpenAPI 2 — replaces discontinued springfox-swagger2, compatible with Spring Boot 3 -->
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20180813</version>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.9</version>
</dependency>
<!-- swagger2-->
<!-- Lombok -->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<!-- MapStruct for type-safe DTO mapping -->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.9.2</version>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<!-- lombok -->
<!-- Druid Spring Boot 3 starter (Spring Boot 3 compatible version) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>compile</scope>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-3-starter</artifactId>
<version>1.2.23</version>
</dependency>
<!-- 阿里系的Druid依赖包 -->
<!-- Ehcache 3 with Jakarta namespace (for Spring Boot 3 / JCache / JSR-107) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.18</version>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>3.10.8</version>
<classifier>jakarta</classifier>
</dependency>
<!-- JCache API (JSR-107) required for Ehcache 3 Spring integration -->
<dependency>
<groupId>javax.cache</groupId>
<artifactId>cache-api</artifactId>
<version>1.1.1</version>
</dependency>
<!--使用ehcache -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>net.sf.ehcache</groupId>
<artifactId>ehcache</artifactId>
</dependency>
<!-- Flyway for database schema version management -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<!-- Flyway MySQL dialect support (required for MySQL 8+) -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<!-- Ensure Lombok and MapStruct annotation processors cooperate -->
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -3,9 +3,11 @@ package com.yaoyuan.jiscuss;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableCaching
@EnableAsync
public class JiccussApplication {
public static void main(String[] args) {
@@ -2,13 +2,15 @@ package com.yaoyuan.jiscuss.common;
import com.yaoyuan.jiscuss.entity.custom.PostCustom;
import lombok.Data;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* @author yaoyuan2.chu
* @author Chuyaoyuan
* @Title:
* @Package com.yaoyuan.jiscuss.common
* @Description:
@@ -17,6 +19,8 @@ import java.util.List;
@Data
public class Node {
private static final Logger logger = LoggerFactory.getLogger(Node.class);
/**
* Node
* 空方法
@@ -46,6 +50,14 @@ public class Node {
private String ipAddress;
private String ipRegion;
private String browser;
private Integer likeCount;
private Integer dislikeCount;
private String copyright;
private Integer isApproved;
@@ -85,7 +97,7 @@ public class Node {
for (Node node1 : list) { //循环添加
if (node1.getId().equals(node.getParentId())) { //判断留言的上一段是都是这条留言
node1.getNextNodes().add(node); //是,添加,返回true;
System.out.println("添加了一个");
logger.debug("添加了一个");
return true;
} else { //否则递归继续判断
if (node1.getNextNodes().size() != 0) {
@@ -127,7 +139,7 @@ public class Node {
**/
public static void show(List<Node> list) {
for (Node node : list) {
System.out.println(node.getUserId() + " 用户回复了你:" + node.getContent());
logger.debug("{} 用户回复了你:{}", node.getUserId(), node.getContent());
if (node.getNextNodes().size() != 0) {
Node.show(node.getNextNodes());
}
@@ -13,7 +13,7 @@ import java.util.List;
import java.util.Map;
/**
* @author yaoyuan2.chu
* @author Chuyaoyuan
* @Title: 工具类
* @Package com.yaoyuan.jiscuss.common
* @Description: 通用工具类
@@ -52,6 +52,33 @@ public class PostCommonUtil {
postCustom.setAvatar(avatar);
postCustom.setUsername(mapObj.get("create_username") != null ? String.valueOf(mapObj.get("create_username")) : null);
postCustom.setRealname(mapObj.get("create_realname") != null ? String.valueOf(mapObj.get("create_realname")) : null);
// floor number
if (mapObj.get("number") != null) {
postCustom.setNumber(Integer.parseInt(String.valueOf(mapObj.get("number"))));
}
// create user id
if (mapObj.get("create_id") != null) {
postCustom.setCreateId(Integer.parseInt(String.valueOf(mapObj.get("create_id"))));
}
// ip address
if (mapObj.get("ip_address") != null) {
postCustom.setIpAddress(String.valueOf(mapObj.get("ip_address")));
}
// ip region (may be null if column not yet populated)
if (mapObj.get("ip_region") != null) {
postCustom.setIpRegion(String.valueOf(mapObj.get("ip_region")));
}
// browser info (user_agent stored but only display-parsed browser is used)
if (mapObj.get("browser") != null) {
postCustom.setBrowser(String.valueOf(mapObj.get("browser")));
}
// vote counters
if (mapObj.get("like_count") != null) {
postCustom.setLikeCount(Integer.parseInt(String.valueOf(mapObj.get("like_count"))));
}
if (mapObj.get("dislike_count") != null) {
postCustom.setDislikeCount(Integer.parseInt(String.valueOf(mapObj.get("dislike_count"))));
}
String avatarReply = "";
if (mapObj.get("user_avatar") != null) {
if (mapObj.get("user_avatar") instanceof Clob) {
@@ -7,19 +7,11 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class MyMvcConfig implements WebMvcConfigurer {
/*
* addResourceHandlers
* void
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("swagger-ui.html")
.addResourceLocations("classpath:/META-INF/resources/");
registry.addResourceHandler("/webjars/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/");
// SpringDoc OpenAPI serves its own UI resources — no manual mapping needed.
// Static application assets:
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/");
}
}
@@ -1,31 +1,29 @@
package com.yaoyuan.jiscuss.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
/**
* SpringDoc OpenAPI 2.x configuration.
*
* <p>Replaces discontinued springfox-swagger2 (incompatible with Spring Boot 3).
* UI is available at /swagger-ui/index.html
*/
@Configuration
@EnableSwagger2
public class SwaggerConfiguration {
@Bean
public Docket createRestApi() {
return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo()).select()
.apis(RequestHandlerSelectors.basePackage("com.yaoyuan.jiscuss.controller")).paths(PathSelectors.any())
.build();
public OpenAPI jiscussOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Jiscuss REST APIs")
.description("Jiscuss forum system API documentation")
.version("1.0")
.contact(new Contact()
.name("Jiscuss Team")
.email("developer@mail.com")));
}
@SuppressWarnings("deprecation")
private ApiInfo apiInfo() {
return new ApiInfoBuilder().title("swagger-ui RESTful APIs").description("Jiscuss")
.termsOfServiceUrl("http://localhost/").contact("developer@mail.com").version("1.0").build();
}
}
}
@@ -1,104 +1,147 @@
package com.yaoyuan.jiscuss.config;
import com.yaoyuan.jiscuss.handler.CustomAccessDeniedHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Configurable;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.expression.DefaultHttpSecurityExpressionHandler;
import org.springframework.security.web.access.expression.WebExpressionAuthorizationManager;
/**
* prePostEnabled :决定Spring Security的前注解是否可用 [@PreAuthorize,@PostAuthorize,..]
* secureEnabled : 决定是否Spring Security的保障注解 [@Secured] 是否可用
* jsr250Enabled :决定 JSR-250 annotations 注解[@RolesAllowed..] 是否可用.
* 开启 Spring Security 方法级安全注解 @EnableGlobalMethodSecurity
* Spring Security 6 configuration (Spring Boot 3 compatible).
*
* <p>Breaking changes from Spring Boot 2.x:
* <ul>
* <li>{@code WebSecurityConfigurerAdapter} removed → use {@code SecurityFilterChain} bean</li>
* <li>{@code antMatchers} → {@code requestMatchers}</li>
* <li>{@code @EnableGlobalMethodSecurity} → {@code @EnableMethodSecurity}</li>
* <li>{@code @Configurable} (AspectJ) corrected to {@code @Configuration}</li>
* </ul>
*/
@Configurable
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class WebSecurityConfig {
@Autowired
private CustomAccessDeniedHandler customAccessDeniedHandler;
@Qualifier("userDetailServiceImpl")
@Autowired
private UserDetailsService userDetailsService;
private final CustomAccessDeniedHandler customAccessDeniedHandler;
private final UserDetailsService userDetailsService;
private final ApplicationContext applicationContext;
/** Controlled by spring.h2.console.enabled — only true in dev (h2) profile. */
@Value("${spring.h2.console.enabled:false}")
private boolean h2ConsoleEnabled;
public WebSecurityConfig(
CustomAccessDeniedHandler customAccessDeniedHandler,
@Qualifier("userDetailServiceImpl") UserDetailsService userDetailsService,
ApplicationContext applicationContext) {
this.customAccessDeniedHandler = customAccessDeniedHandler;
this.userDetailsService = userDetailsService;
this.applicationContext = applicationContext;
}
/**
* 静态资源设置
* Main security filter chain.
*
* <p>Security decisions:
* <ul>
* <li>H2 console: only accessible when enabled (dev profile) and requires ROLE_ADMIN</li>
* <li>Druid monitoring: requires ROLE_ADMIN through Spring Security</li>
* <li>Admin/Actuator paths: requires ROLE_ADMIN</li>
* <li>SpringDoc UI paths: public (API docs are for developers)</li>
* <li>All other authenticated requests: evaluated by {@code RbacPermission.hasPermission}</li>
* <li>X-Frame-Options: SAMEORIGIN (prevents clickjacking while allowing H2 console iframe)</li>
* </ul>
*/
@Override
public void configure(WebSecurity webSecurity) {
//不拦截静态资源,所有用户均可访问的资源
webSecurity.ignoring().antMatchers(
"/",
"/css/**",
"/js/**",
"/images/**",
"/static/**",
"/h2-console/**",
"/test/**",
"/druid/**"
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// Fix: SAMEORIGIN instead of disable() — prevents clickjacking, allows H2 iframe in same origin
http.headers(headers -> headers
.frameOptions(frame -> frame.sameOrigin())
);
// CSRF: disable for H2 console path only (H2 web console does not send CSRF tokens)
if (h2ConsoleEnabled) {
http.csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**"));
}
// Build the RBAC expression manager with ApplicationContext so @bean references work
DefaultHttpSecurityExpressionHandler expressionHandler = new DefaultHttpSecurityExpressionHandler();
expressionHandler.setApplicationContext(applicationContext);
WebExpressionAuthorizationManager rbacManager = new WebExpressionAuthorizationManager(
"@rbacPermission.hasPermission(request, authentication)");
rbacManager.setExpressionHandler(expressionHandler);
http.authorizeHttpRequests(auth -> {
// Publicly accessible
auth.requestMatchers(
"/css/**", "/js/**", "/images/**", "/static/**", "/favicon.ico",
"/login/**", "/register", "/registerDo", "/initUserData",
"/", "/main", "/index", "/getdiscussionsbyid",
// SpringDoc OpenAPI UI and spec endpoint (developer tools, no sensitive data)
"/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**"
).permitAll();
// H2 console: admin-only, dev profile only
if (h2ConsoleEnabled) {
auth.requestMatchers("/h2-console/**").hasRole("ADMIN");
}
// Druid monitoring, Actuator, Admin UI: restricted to ROLE_ADMIN
auth.requestMatchers("/druid/**").hasRole("ADMIN");
auth.requestMatchers("/actuator/**").hasRole("ADMIN");
auth.requestMatchers("/admin/**").hasRole("ADMIN");
// All remaining requests evaluated by RBAC permission bean
auth.anyRequest().access(rbacManager);
});
http.formLogin(form -> form
.loginPage("/login")
.loginProcessingUrl("/login")
.usernameParameter("username")
.passwordParameter("password")
.defaultSuccessUrl("/index")
);
http.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
);
http.exceptionHandling(ex -> ex
.accessDeniedHandler(customAccessDeniedHandler)
);
return http.build();
}
/**
* http请求设置
* Authentication manager wired with BCrypt — replaces the old
* {@code configure(AuthenticationManagerBuilder)} override.
*/
@Override
public void configure(HttpSecurity http) throws Exception {
//http.csrf().disable(); //注释就是使用 csrf 功能
http.headers().frameOptions().disable();//解决 in a frame because it set 'X-Frame-Options' to 'DENY' 问题
//http.anonymous().disable();
http.authorizeRequests()
.antMatchers("/login/**", "/initUserData")//不拦截登录相关方法
.permitAll()
//.antMatchers("/user").hasRole("ADMIN") // user接口只有ADMIN角色的可以访问
//.anyRequest()
//.authenticated()// 任何尚未匹配的URL只需要验证用户即可访问
.anyRequest()
.access("@rbacPermission.hasPermission(request, authentication)")//根据账号权限访问
.and()
.formLogin()
//.loginPage("/")
.loginPage("/login") //登录请求页
.loginProcessingUrl("/login") //登录POST请求路径
.usernameParameter("username") //登录用户名参数
.passwordParameter("password") //登录密码参数
.defaultSuccessUrl("/index") //默认登录成功页面
.and()
.exceptionHandling()
.accessDeniedHandler(customAccessDeniedHandler) //无权限处理器
.and()
.logout()
.logoutSuccessUrl("/login?logout"); //退出登录成功URL
@Bean
public AuthenticationManager authenticationManager(BCryptPasswordEncoder passwordEncoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(provider);
}
/**
* 自定义获取用户信息接口
*/
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
/**
* 密码加密算法
* @return
*/
/** BCrypt password encoder bean. */
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@@ -1,42 +1,365 @@
package com.yaoyuan.jiscuss.controller;
import com.yaoyuan.jiscuss.entity.Discussion;
import com.yaoyuan.jiscuss.entity.Post;
import com.yaoyuan.jiscuss.entity.Role;
import com.yaoyuan.jiscuss.entity.UpgradeLog;
import com.yaoyuan.jiscuss.entity.User;
import com.yaoyuan.jiscuss.entity.UserInfo;
import com.yaoyuan.jiscuss.entity.UserRole;
import com.yaoyuan.jiscuss.entity.AuditLog;
import com.yaoyuan.jiscuss.repository.DiscussionsRepository;
import com.yaoyuan.jiscuss.repository.DiscussionsTagsRepository;
import com.yaoyuan.jiscuss.repository.PostsRepository;
import com.yaoyuan.jiscuss.repository.RoleRepository;
import com.yaoyuan.jiscuss.repository.UpgradeLogRepository;
import com.yaoyuan.jiscuss.repository.UserRoleRepository;
import com.yaoyuan.jiscuss.repository.UsersRepository;
import com.yaoyuan.jiscuss.repository.AuditLogRepository;
import com.yaoyuan.jiscuss.service.AuditLogService;
import org.springframework.boot.SpringBootVersion;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.RequestMapping;
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.RequestParam;
import javax.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequest;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* @author yaoyuan2.chu
* @author Chuyaoyuan
* 后台系统控制器
*/
@Controller
public class AdminSystemController {
public class AdminSystemController extends BaseController {
/**
* 后台登录
*/
private final UsersRepository usersRepository;
private final DiscussionsRepository discussionsRepository;
private final PostsRepository postsRepository;
private final DiscussionsTagsRepository discussionsTagsRepository;
private final RoleRepository roleRepository;
private final UserRoleRepository userRoleRepository;
private final UpgradeLogRepository upgradeLogRepository;
private final AuditLogRepository auditLogRepository;
private final AuditLogService auditLogService;
/**
* 后台退出
*/
@RequestMapping("/admin/logout")
public String logout(@RequestParam(defaultValue = "discussion") String type, HttpServletRequest request, ModelMap map) {
return "admin/logout";
public AdminSystemController(
UsersRepository usersRepository,
DiscussionsRepository discussionsRepository,
PostsRepository postsRepository,
DiscussionsTagsRepository discussionsTagsRepository,
RoleRepository roleRepository,
UserRoleRepository userRoleRepository,
UpgradeLogRepository upgradeLogRepository,
AuditLogRepository auditLogRepository,
AuditLogService auditLogService) {
this.usersRepository = usersRepository;
this.discussionsRepository = discussionsRepository;
this.postsRepository = postsRepository;
this.discussionsTagsRepository = discussionsTagsRepository;
this.roleRepository = roleRepository;
this.userRoleRepository = userRoleRepository;
this.upgradeLogRepository = upgradeLogRepository;
this.auditLogRepository = auditLogRepository;
this.auditLogService = auditLogService;
}
/**
* 后台设置管理
*/
@GetMapping("/admin/home")
public String home(HttpServletRequest request, ModelMap map) {
fillCommon(request, map, "home");
map.put("userCount", usersRepository.count());
map.put("discussionCount", discussionsRepository.count());
map.put("postCount", postsRepository.count());
map.put("roleCount", roleRepository.count());
map.put("sys", buildSystemMetrics());
map.put("deps", buildDependencyVersions());
/**
* 后台页面
* @return
*/
@RequestMapping("/admin/home")
public String home(@RequestParam(defaultValue = "discussion") String type, HttpServletRequest request, ModelMap map) {
return "admin/home";
}
@GetMapping("/admin/users")
public String users(HttpServletRequest request, ModelMap map) {
fillCommon(request, map, "users");
List<User> users = usersRepository.findAll(Sort.by(Sort.Direction.ASC, "id"));
List<Role> roles = roleRepository.findAllByOrderByIdAsc();
Map<String, Set<Integer>> userRoleIds = new HashMap<>();
for (UserRole ur : userRoleRepository.findAll()) {
userRoleIds.computeIfAbsent(String.valueOf(ur.getUserId()), key -> new HashSet<>()).add(ur.getRoleId());
}
map.put("users", users);
map.put("roles", roles);
map.put("userRoleIds", userRoleIds);
return "admin/users";
}
@PostMapping("/admin/users/{id}/roles")
@Transactional
public String updateUserRoles(HttpServletRequest request,
@PathVariable("id") Integer id,
@RequestParam(value = "roleIds", required = false) List<Integer> roleIds) {
UserInfo currentAdmin = getUserInfo(request);
userRoleRepository.deleteByUserId(id);
if (roleIds != null) {
Date now = new Date();
Set<Integer> uniqueRoleIds = new HashSet<>(roleIds);
Set<Integer> validEnabledRoleIds = roleRepository.findAllById(uniqueRoleIds).stream()
.filter(role -> role.getEnabled() != null && role.getEnabled() == 1)
.map(Role::getId)
.collect(Collectors.toSet());
for (Integer roleId : validEnabledRoleIds) {
UserRole ur = new UserRole();
ur.setUserId(id);
ur.setRoleId(roleId);
ur.setCreateTime(now);
userRoleRepository.save(ur);
}
}
auditLogService.log(currentAdmin, "update_user_roles", "user", id,
"Updated user role assignments. Role count: " + (roleIds == null ? 0 : roleIds.size()));
return "redirect:/admin/users";
}
@PostMapping("/admin/roles")
public String createRole(HttpServletRequest request,
@RequestParam("code") String code,
@RequestParam("name") String name,
@RequestParam(value = "description", required = false) String description) {
UserInfo currentAdmin = getUserInfo(request);
String normalizedCode = code == null ? "" : code.trim().toUpperCase();
if (normalizedCode.isEmpty()) {
return "redirect:/admin/users";
}
Role exists = roleRepository.findByCodeIgnoreCase(normalizedCode);
if (exists == null) {
Date now = new Date();
Role role = new Role();
role.setCode(normalizedCode);
role.setName(name == null ? "" : name.trim());
role.setDescription(description);
role.setEnabled(1);
role.setCreateTime(now);
role.setUpdateTime(now);
roleRepository.save(role);
auditLogService.log(currentAdmin, "create_role", "role", role.getId(),
"Created role: " + normalizedCode);
} else {
auditLogService.log(currentAdmin, "create_role_duplicate", "role", exists.getId(),
"Attempted to create duplicate role: " + normalizedCode, "ignored");
}
return "redirect:/admin/users";
}
@PostMapping("/admin/roles/{id}/toggle")
public String toggleRole(HttpServletRequest request,
@PathVariable("id") Integer id) {
UserInfo currentAdmin = getUserInfo(request);
Role role = roleRepository.findById(id).orElse(null);
if (role != null) {
int nextStatus = role.getEnabled() != null && role.getEnabled() == 1 ? 0 : 1;
// Keep built-in ADMIN role enabled to avoid locking out management access.
if ("ADMIN".equalsIgnoreCase(role.getCode()) && nextStatus == 0) {
auditLogService.log(currentAdmin, "toggle_role_blocked", "role", id,
"Attempted to disable built-in ADMIN role", "blocked");
return "redirect:/admin/users";
}
role.setEnabled(nextStatus);
role.setUpdateTime(new Date());
roleRepository.save(role);
auditLogService.log(currentAdmin, "toggle_role", "role", id,
"Toggled role status to " + (nextStatus == 1 ? "enabled" : "disabled"));
}
return "redirect:/admin/users";
}
@GetMapping("/admin/discussions")
public String discussions(HttpServletRequest request, ModelMap map) {
fillCommon(request, map, "discussions");
List<Discussion> discussions = discussionsRepository.findAll(Sort.by(Sort.Direction.DESC, "createTime"));
map.put("discussions", discussions);
return "admin/discussions";
}
@PostMapping("/admin/discussions/{id}/delete")
@Transactional
public String deleteDiscussion(HttpServletRequest request,
@PathVariable("id") Integer id) {
UserInfo currentAdmin = getUserInfo(request);
Discussion discussion = discussionsRepository.findById(id).orElse(null);
String title = discussion == null ? "unknown" : discussion.getTitle();
discussionsTagsRepository.deleteByDiscussionId(id);
postsRepository.deleteByDiscussionId(id);
discussionsRepository.deleteById(id);
auditLogService.log(currentAdmin, "delete_discussion", "discussion", id,
"Deleted discussion: " + title);
return "redirect:/admin/discussions";
}
@GetMapping("/admin/posts")
public String posts(HttpServletRequest request, ModelMap map) {
fillCommon(request, map, "posts");
List<Post> posts = postsRepository.findAll(Sort.by(Sort.Direction.DESC, "createTime"));
map.put("posts", posts);
Set<Integer> userIds = posts.stream().map(Post::getCreateId).filter(v -> v != null).collect(Collectors.toSet());
Set<Integer> discussionIds = posts.stream().map(Post::getDiscussionId).filter(v -> v != null).collect(Collectors.toSet());
Map<String, String> userNames = usersRepository.findAllById(userIds).stream()
.collect(Collectors.toMap(user -> String.valueOf(user.getId()), User::getUsername));
Map<String, String> discussionNames = discussionsRepository.findAllById(discussionIds).stream()
.collect(Collectors.toMap(discussion -> String.valueOf(discussion.getId()), Discussion::getTitle));
map.put("userNames", userNames);
map.put("discussionNames", discussionNames);
return "admin/posts";
}
@PostMapping("/admin/posts/{id}/delete")
public String deletePost(HttpServletRequest request,
@PathVariable("id") Integer id) {
UserInfo currentAdmin = getUserInfo(request);
Post post = postsRepository.findById(id).orElse(null);
String content = post == null ? "unknown" : (post.getContent() == null ? "" : post.getContent());
if (content.length() > 50) {
content = content.substring(0, 50) + "...";
}
postsRepository.deleteById(id);
auditLogService.log(currentAdmin, "delete_post", "post", id,
"Deleted post: " + content);
return "redirect:/admin/posts";
}
@GetMapping("/admin/upgrade-logs")
public String upgradeLogs(HttpServletRequest request, ModelMap map) {
fillCommon(request, map, "upgradeLogs");
map.put("logs", upgradeLogRepository.findAllByOrderByCreateTimeDesc());
return "admin/upgrade-logs";
}
@PostMapping("/admin/upgrade-logs")
public String createUpgradeLog(HttpServletRequest request,
@RequestParam("version") String version,
@RequestParam("title") String title,
@RequestParam(value = "type", defaultValue = "feature") String type,
@RequestParam("content") String content) {
UserInfo user = getUserInfo(request);
UpgradeLog log = new UpgradeLog();
log.setVersion(version);
log.setTitle(title);
log.setType(type);
log.setContent(content);
log.setCreatedBy(user == null ? null : user.getId());
log.setCreateTime(new Date());
upgradeLogRepository.save(log);
auditLogService.log(user, "create_upgrade_log", "upgrade_log", log.getId(),
"Created upgrade log: " + version + " - " + title);
return "redirect:/admin/upgrade-logs";
}
@GetMapping("/admin/plugins")
public String plugins(HttpServletRequest request, ModelMap map) {
fillCommon(request, map, "plugins");
return "admin/plugins";
}
@GetMapping("/admin/audit-logs")
public String auditLogs(HttpServletRequest request, ModelMap map) {
fillCommon(request, map, "auditLogs");
List<AuditLog> logs = auditLogRepository.findAllByOrderByCreateTimeDesc();
map.put("logs", logs);
return "admin/audit-logs";
}
@GetMapping("/admin/themes")
public String themes(HttpServletRequest request, ModelMap map) {
fillCommon(request, map, "themes");
return "admin/themes";
}
private void fillCommon(HttpServletRequest request, ModelMap map, String active) {
UserInfo user = getUserInfo(request);
map.put("adminName", user == null ? "admin" : user.getUsername());
map.put("active", active);
}
private Map<String, Object> buildSystemMetrics() {
Map<String, Object> result = new HashMap<>();
Runtime rt = Runtime.getRuntime();
MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
long totalMb = bytesToMb(rt.totalMemory());
long freeMb = bytesToMb(rt.freeMemory());
long usedMb = totalMb - freeMb;
long maxMb = bytesToMb(rt.maxMemory());
long heapUsedMb = bytesToMb(memoryMXBean.getHeapMemoryUsage().getUsed());
result.put("totalMb", totalMb);
result.put("freeMb", freeMb);
result.put("usedMb", usedMb);
result.put("maxMb", maxMb);
result.put("heapUsedMb", heapUsedMb);
result.put("uptimeMs", ManagementFactory.getRuntimeMXBean().getUptime());
result.put("cpuLoad", readCpuLoadPercent());
return result;
}
private Map<String, String> buildDependencyVersions() {
Map<String, String> result = new HashMap<>();
result.put("springBoot", SpringBootVersion.getVersion());
result.put("java", System.getProperty("java.version"));
result.put("spring", implVersion("org.springframework.core.SpringVersion"));
result.put("hibernate", implVersion("org.hibernate.Version"));
result.put("flyway", implVersion("org.flywaydb.core.Flyway"));
result.put("druid", implVersion("com.alibaba.druid.pool.DruidDataSource"));
result.put("ehcache", implVersion("org.ehcache.CacheManager"));
result.put("springdoc", implVersion("org.springdoc.core.configuration.SpringDocConfiguration"));
return result;
}
private String implVersion(String className) {
try {
Class<?> clazz = Class.forName(className);
Package pkg = clazz.getPackage();
String version = pkg == null ? null : pkg.getImplementationVersion();
return version == null ? "unknown" : version;
} catch (ClassNotFoundException e) {
return "unknown";
}
}
private String readCpuLoadPercent() {
try {
java.lang.management.OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();
if (osBean instanceof com.sun.management.OperatingSystemMXBean sunOsBean) {
double value = sunOsBean.getCpuLoad();
if (value >= 0) {
return String.format("%.2f%%", value * 100);
}
}
} catch (Exception ignored) {
}
return "N/A";
}
private long bytesToMb(long bytes) {
return bytes / 1024 / 1024;
}
}
@@ -1,48 +1,34 @@
package com.yaoyuan.jiscuss.controller;
import com.yaoyuan.jiscuss.entity.UserInfo;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.Enumeration;
/**
* @author yaoyuan2.chu
* @Title: BaseController
* @Package com.yaoyuan.jiscuss.controller
* @Description: BaseController
* @date 2020/7/16 14:36
* Base controller exposing helpers shared by web controllers.
*
* @author Chuyaoyuan
*/
public class BaseController {
/**
* 获取当前用户
*
* @return
* Returns the current authenticated user, or {@code null} when not logged in.
*/
protected UserInfo getUserInfo(HttpServletRequest request) {
UserInfo user = null;
//获得session对象
HttpSession session = request.getSession();
//取出session域中所有属性名
Enumeration attributeNames = session.getAttributeNames();
while (attributeNames.hasMoreElements()) {
System.out.println(attributeNames.nextElement());
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
//SPRING_SECURITY_CONTEXT
Object spring_security_context = session.getAttribute("SPRING_SECURITY_CONTEXT");
System.out.println(spring_security_context);
SecurityContext securityContext = (SecurityContext) spring_security_context;
if (securityContext != null) {
//获得认证信息
Authentication authentication = securityContext.getAuthentication();
//获得用户详情
Object principal = authentication.getPrincipal();
user = (UserInfo) principal;
Object ctx = session.getAttribute("SPRING_SECURITY_CONTEXT");
if (!(ctx instanceof SecurityContext securityContext)) {
return null;
}
return user;
Authentication authentication = securityContext.getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof UserInfo userInfo)) {
return null;
}
return userInfo;
}
}
@@ -1,15 +1,115 @@
package com.yaoyuan.jiscuss.controller;
import com.yaoyuan.jiscuss.dto.InboxItem;
import com.yaoyuan.jiscuss.entity.Message;
import com.yaoyuan.jiscuss.entity.Notification;
import com.yaoyuan.jiscuss.entity.User;
import com.yaoyuan.jiscuss.entity.UserInfo;
import com.yaoyuan.jiscuss.service.IMessageService;
import com.yaoyuan.jiscuss.service.INotificationService;
import com.yaoyuan.jiscuss.service.IUsersService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.ArrayList;
import java.util.List;
/**
* @author yaoyuan2.chu
* 用户消息控制器
* @author Chuyaoyuan
* 站内私信与通知控制器
*/
@Controller
public class UserMsgController extends BaseController {
/**
* 首页最新消息
*/
@Autowired
private IMessageService messageService;
@Autowired
private INotificationService notificationService;
@Autowired
private IUsersService usersService;
/** 收件箱:对话列表 */
@GetMapping("/messages")
public String inbox(HttpServletRequest request, ModelMap map) {
UserInfo user = getUserInfo(request);
if (user == null) return "redirect:/login";
List<Message> inboxList = messageService.getInbox(user.getId());
List<InboxItem> items = new ArrayList<>();
for (Message msg : inboxList) {
Integer otherId = msg.getSenderId().equals(user.getId()) ? msg.getReceiverId() : msg.getSenderId();
User other = usersService.findOne(otherId);
String rawName = (other != null) ? other.getUsername() : null;
String otherName = (rawName != null && !rawName.isBlank()) ? rawName : "用户" + otherId;
boolean hasUnread = !Boolean.TRUE.equals(msg.getIsRead()) && msg.getReceiverId().equals(user.getId());
items.add(new InboxItem(otherId, otherName, msg.getContent(), msg.getCreateTime(), hasUnread));
}
map.put("username", user.getUsername());
map.put("currentUserId", user.getId());
map.put("inboxItems", items);
map.put("unreadMsgCount", messageService.countUnread(user.getId()));
map.put("unreadNotifCount", notificationService.countUnread(user.getId()));
return "messages";
}
/** 对话详情页 */
@GetMapping("/messages/{otherId}")
public String conversation(@PathVariable Integer otherId, HttpServletRequest request, ModelMap map) {
UserInfo user = getUserInfo(request);
if (user == null) return "redirect:/login";
messageService.markConversationRead(user.getId(), otherId);
List<Message> messages = messageService.getConversation(user.getId(), otherId);
User other = usersService.findOne(otherId);
map.put("username", user.getUsername());
map.put("currentUserId", user.getId());
map.put("otherId", otherId);
map.put("otherUsername", other != null ? other.getUsername() : "用户" + otherId);
map.put("messages", messages);
map.put("unreadMsgCount", messageService.countUnread(user.getId()));
map.put("unreadNotifCount", notificationService.countUnread(user.getId()));
return "conversation";
}
/** 通知列表页 */
@GetMapping("/notifications")
public String notifications(@RequestParam(defaultValue = "0") int page,
HttpServletRequest request, ModelMap map) {
UserInfo user = getUserInfo(request);
if (user == null) return "redirect:/login";
notificationService.markAllRead(user.getId());
Page<Notification> notifs = notificationService.listByUser(user.getId(), page, 20);
List<String> fromUsernames = new ArrayList<>();
for (Notification n : notifs.getContent()) {
if (n.getFromUserId() != null) {
User from = usersService.findOne(n.getFromUserId());
fromUsernames.add(from != null ? from.getUsername() : "用户" + n.getFromUserId());
} else {
fromUsernames.add("系统");
}
}
map.put("username", user.getUsername());
map.put("notifications", notifs.getContent());
map.put("fromUsernames", fromUsernames);
map.put("totalPages", notifs.getTotalPages());
map.put("currentPage", page);
map.put("unreadMsgCount", messageService.countUnread(user.getId()));
map.put("unreadNotifCount", 0L);
return "notifications";
}
}
@@ -3,7 +3,7 @@ package com.yaoyuan.jiscuss.controller;
import org.springframework.stereotype.Controller;
/**
* @author yaoyuan2.chu
* @author Chuyaoyuan
* 其他控制器——积分/权限等
*/
@Controller
@@ -1,68 +1,105 @@
package com.yaoyuan.jiscuss.controller;
import com.yaoyuan.jiscuss.entity.Discussion;
import com.yaoyuan.jiscuss.entity.Post;
import com.yaoyuan.jiscuss.entity.User;
import com.yaoyuan.jiscuss.entity.UserInfo;
import com.yaoyuan.jiscuss.service.IDiscussionsService;
import com.yaoyuan.jiscuss.service.ITagsService;
import com.yaoyuan.jiscuss.repository.DiscussionsRepository;
import com.yaoyuan.jiscuss.repository.LikeCollectRepository;
import com.yaoyuan.jiscuss.repository.PostsRepository;
import com.yaoyuan.jiscuss.service.IMessageService;
import com.yaoyuan.jiscuss.service.INotificationService;
import com.yaoyuan.jiscuss.service.IUsersService;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.List;
/**
* @author yaoyuan2.chu
* @Title:
* @Package com.yaoyuan.jiscuss.controller
* @Description:
* @date
* @author Chuyaoyuan
*/
@Controller
public class UserPageController extends BaseController {
private static Logger logger = LoggerFactory.getLogger(UserPageController.class);
private static final Logger logger = LoggerFactory.getLogger(UserPageController.class);
@Autowired
private IUsersService usersService;
@Autowired
private IDiscussionsService discussionsService;
private DiscussionsRepository discussionsRepository;
@Autowired
private ITagsService tagsService;
private PostsRepository postsRepository;
@Autowired
private LikeCollectRepository likeCollectRepository;
@Autowired
private IMessageService messageService;
@Autowired
private INotificationService notificationService;
/**
* 用户页
*
* @return
* 用户个人主
*/
@RequestMapping("/user")
public String user(@RequestParam(defaultValue = "discussion") String type, HttpServletRequest request, ModelMap map) {
public String user(@RequestParam(defaultValue = "discussion") String type,
HttpServletRequest request, ModelMap map) {
// type 判断
if (type.equals("discussion")) {
map.put("discussion", "active");
UserInfo currentUser = getUserInfo(request);
if (currentUser == null) return "redirect:/login";
User userEntity = usersService.findOne(currentUser.getId());
long liveDiscCount = discussionsRepository.countByStartUserId(currentUser.getId());
long liveReplyCount = postsRepository.countByCreateId(currentUser.getId());
map.put("username", currentUser.getUsername());
map.put("userEntity", userEntity);
map.put("liveDiscCount", liveDiscCount);
map.put("liveReplyCount", liveReplyCount);
map.put("type", type);
map.put("unreadMsgCount", messageService.countUnread(currentUser.getId()));
map.put("unreadNotifCount", notificationService.countUnread(currentUser.getId()));
if ("discussion".equals(type)) {
Page<Discussion> discussions = discussionsRepository.findByStartUserIdOrderByStartTimeDesc(
currentUser.getId(), PageRequest.of(0, 20));
map.put("discussions", discussions.getContent());
} else if ("reply".equals(type)) {
Page<Post> posts = postsRepository.findByCreateIdOrderByCreateTimeDesc(
currentUser.getId(), PageRequest.of(0, 20));
map.put("posts", posts.getContent());
// Attach discussion titles
List<String> discTitles = new ArrayList<>();
for (Post p : posts.getContent()) {
if (p.getDiscussionId() != null) {
Discussion d = discussionsRepository.findById(p.getDiscussionId()).orElse(null);
discTitles.add(d != null ? d.getTitle() : "");
} else {
discTitles.add("");
}
}
map.put("discTitles", discTitles);
} else if ("like".equals(type)) {
List<Integer> likedIds = likeCollectRepository.findLikedDiscussionIdsByUser(currentUser.getId());
List<Discussion> liked = new ArrayList<>();
for (Integer id : likedIds) {
Discussion d = discussionsRepository.findById(id).orElse(null);
if (d != null) liked.add(d);
}
map.put("likedDiscussions", liked);
}
if (type.equals("change")) {
map.put("change", "active");
}
if (type.equals("like")) {
map.put("like", "active");
}
UserInfo user = getUserInfo(request);
if (user != null) {
map.put("username", user.getUsername());
map.put("data", "Jiscuss 用户:" + user.getUsername());
}
return "user";
}
}
@@ -12,7 +12,9 @@ import com.yaoyuan.jiscuss.service.IDiscussionsTagsService;
import com.yaoyuan.jiscuss.service.IPostsService;
import com.yaoyuan.jiscuss.service.ITagsService;
import com.yaoyuan.jiscuss.service.IUsersService;
import org.json.JSONObject;
import com.yaoyuan.jiscuss.service.IpRegionService;
import com.yaoyuan.jiscuss.util.IpUtils;
import com.yaoyuan.jiscuss.util.UserAgentUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
@@ -27,12 +29,14 @@ import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @author yaoyuan2.chu
* @author Chuyaoyuan
* 主题帖子评论控制器
*/
@Controller
@@ -55,6 +59,12 @@ public class UserPostController extends BaseController {
@Autowired
private IUsersService usersService;
@Autowired
private IpRegionService ipRegionService;
@Autowired
private com.yaoyuan.jiscuss.service.INotificationService notificationService;
/**
* 首页查看主题列表(各种条件筛选,最热最新标签等)
@@ -69,8 +79,13 @@ public class UserPostController extends BaseController {
* @return
*/
@RequestMapping("/getdiscussionsbyid")
public String getDiscussionsById(HttpServletRequest request, ModelMap map, @RequestParam("id") Integer id) {
logger.info(">>> getDiscussionsById{}", id);
public String getDiscussionsById(HttpServletRequest request, ModelMap map,
@RequestParam("id") Integer id,
@RequestParam(defaultValue = "newest") String sort) {
logger.info(">>> getDiscussionsById{} sort={}", id, sort);
// Atomically increment view count before loading the discussion
discussionsService.incrementViewCount(id);
Discussion discussion = discussionsService.findOne(id);
// 获取此主题下的评论
@@ -92,10 +107,11 @@ public class UserPostController extends BaseController {
}
List<Tag> tags = tagsService.findByDId(id);
List postsObj = postsService.findPostCustomById(id);
List postsObj = postsService.findPostCustomById(id, sort);
map.put("tags", tags);
map.put("discussions", newdd);
map.put("posts", postsObj);
map.put("sort", sort);
UserInfo user = getUserInfo(request);
if (user != null) {
map.put("username", user.getUsername());
@@ -108,9 +124,9 @@ public class UserPostController extends BaseController {
* @param discussion
* @return
*/
@PostMapping(value = "/newDiscussions")
@PostMapping(value = {"/newDiscussions", "/newdiscussions"})
@ResponseBody
public String newDiscussions(@RequestBody DiscussionCustom discussion) {
public Map<String, Object> newDiscussions(@RequestBody DiscussionCustom discussion) {
logger.info(">>> newPost" + discussion);
ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
@@ -141,13 +157,11 @@ public class UserPostController extends BaseController {
}
}
JSONObject resultobj = new JSONObject();
Map<String, Object> resultobj = new HashMap<>();
logger.info(">>>{}", saveDiscussion);
resultobj.put("msg", "添加主题成功");
resultobj.put("flag", true);
return resultobj.toString(); //
return resultobj;
}
@@ -170,7 +184,7 @@ public class UserPostController extends BaseController {
*/
@PostMapping(value = "/newPost")
@ResponseBody
public String newPosts(@RequestBody Post post) {
public Map<String, Object> newPosts(@RequestBody Post post) {
logger.info(">>> newpost" + post);
ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
@@ -181,18 +195,32 @@ public class UserPostController extends BaseController {
post.setCreateId(user.getId());
}
post.setCreateTime(new Date());
// Capture client IP, region, and browser
String clientIp = IpUtils.getClientIp(request);
post.setIpAddress(clientIp);
post.setIpRegion(ipRegionService.getRegion(clientIp));
post.setBrowser(UserAgentUtils.parseBrowser(request.getHeader("User-Agent")));
if (null != post.getParentId()) {
Post temp = postsService.findOneByid(post.getParentId());
post.setUserId(temp.getCreateId());
}
Post savePost = postsService.insert(post);
JSONObject resultobj = new JSONObject();
// Notify the discussion starter about a reply (async, ignores self-reply)
if (post.getDiscussionId() != null && user != null) {
Discussion disc = discussionsService.findOne(post.getDiscussionId());
if (disc != null && disc.getStartUserId() != null) {
notificationService.notifyReply(disc.getStartUserId(), user.getId(),
disc.getId(), disc.getTitle());
}
}
Map<String, Object> resultobj = new HashMap<>();
logger.info(">>>{}", savePost);
resultobj.put("msg", "添加评论成功");
resultobj.put("flag", true);
return resultobj.toString(); //
return resultobj;
}
/**
@@ -208,7 +236,7 @@ public class UserPostController extends BaseController {
*/
@PostMapping(value = "/newtags")
@ResponseBody
public String newTags(@RequestBody Tag tag) {
public Map<String, Object> newTags(@RequestBody Tag tag) {
logger.info(">>> newTags" + tag);
ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = servletRequestAttributes.getRequest();
@@ -220,12 +248,11 @@ public class UserPostController extends BaseController {
tag.setCreateTime(new Date());
Tag saveTag = tagsService.insert(tag);
JSONObject resultobj = new JSONObject();
Map<String, Object> resultobj = new HashMap<>();
logger.info(">>>{}", saveTag);
resultobj.put("msg", "添加标签成功");
resultobj.put("flag", true);
return resultobj.toString();
return resultobj;
}
/**
@@ -15,22 +15,27 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
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.servlet.view.RedirectView;
import javax.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* @author yaoyuan2.chu
* @author Chuyaoyuan
* 首页页面系统控制器
*/
@Controller
@@ -46,6 +51,9 @@ public class UserSystemController extends BaseController {
@Autowired
private ITagsService tagsService;
@Autowired
private BCryptPasswordEncoder passwordEncoder;
/**
* 首页index
* @param tag
@@ -108,6 +116,8 @@ public class UserSystemController extends BaseController {
map.put("pageTotal", total);
map.put("pageNum", pageNum);
map.put("pageTotalPages", allDiscussionsPage.getTotalPages());
map.put("pageNumAll", allDiscussionsPage.getTotalPages());
map.put("topUsers", usersService.getTopActiveUsers(10));
UserInfo user = getUserInfo(request);
if (user != null) {
map.put("username", user.getUsername());
@@ -142,6 +152,19 @@ public class UserSystemController extends BaseController {
tagMap.put(tc.getDiscussionId(), tagCustomListTemp);
}
}
Set<Integer> userIds = new HashSet<>();
for (Discussion dd : allDiscussions) {
if (dd.getStartUserId() != null) {
userIds.add(dd.getStartUserId());
}
if (dd.getLastUserId() != null) {
userIds.add(dd.getLastUserId());
}
}
Map<Integer, User> userMap = usersService.findAllById(userIds).stream()
.collect(Collectors.toMap(User::getId, u -> u));
for (Discussion dd : allDiscussions) {
DiscussionCustom newdd = new DiscussionCustom();
BeanUtils.copyProperties(dd, newdd);
@@ -154,13 +177,13 @@ public class UserSystemController extends BaseController {
}
newdd.setContent(newCon);
if (null != newdd.getStartUserId()) {
User startUser = usersService.findOne(newdd.getStartUserId());
User startUser = userMap.get(newdd.getStartUserId());
newdd.setAvatar(startUser != null && startUser.getAvatar() != null ? startUser.getAvatar() : "");
newdd.setRealname(startUser != null && startUser.getRealname() != null ? startUser.getRealname() : "");
newdd.setUsername(startUser != null && startUser.getUsername() != null ? startUser.getUsername() : "");
}
if (null != newdd.getLastUserId()) {
User lastUser = usersService.findOne(newdd.getLastUserId());
User lastUser = userMap.get(newdd.getLastUserId());
newdd.setAvatarLast(lastUser != null && lastUser.getAvatar() != null ? lastUser.getAvatar() : "");
newdd.setRealnameLast(lastUser != null && lastUser.getRealname() != null ? lastUser.getRealname() : "");
newdd.setUsernameLast(lastUser != null && lastUser.getUsername() != null ? lastUser.getUsername() : "");
@@ -209,6 +232,11 @@ public class UserSystemController extends BaseController {
return "login";
}
@GetMapping("/favicon.ico")
public RedirectView favicon() {
return new RedirectView("/static/assets/images/logo.png");
}
/**
* 注册提交
@@ -228,7 +256,7 @@ public class UserSystemController extends BaseController {
} else {
User user = new User();
user.setEmail(email);
user.setPassword(password);
user.setPassword(passwordEncoder.encode(password));
user.setUsername(username);
user.setRealname(username);
user.setJoinTime(new Date());
@@ -0,0 +1,111 @@
package com.yaoyuan.jiscuss.controller.api;
import com.yaoyuan.jiscuss.controller.BaseController;
import com.yaoyuan.jiscuss.entity.Message;
import com.yaoyuan.jiscuss.entity.Notification;
import com.yaoyuan.jiscuss.entity.User;
import com.yaoyuan.jiscuss.entity.UserInfo;
import com.yaoyuan.jiscuss.response.ApiResponse;
import com.yaoyuan.jiscuss.service.IMessageService;
import com.yaoyuan.jiscuss.service.INotificationService;
import com.yaoyuan.jiscuss.service.IUsersService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* REST API for private messages and notifications.
*/
@Tag(name = "Message API", description = "Private messages and notifications")
@RestController
@RequestMapping("/msg_api")
public class RestMsgController extends BaseController {
@Autowired
private IMessageService messageService;
@Autowired
private INotificationService notificationService;
@Autowired
private IUsersService usersService;
// ─── User card (public — accessible to any authenticated user) ────────
/** Returns basic user info for the hover card (discussionsCount, commentsCount). */
@Operation(summary = "Get user card info")
@GetMapping("/user-card/{id}")
public ApiResponse<Map<String, Object>> getUserCard(@PathVariable Integer id, HttpServletRequest request) {
UserInfo currentUser = getUserInfo(request);
if (currentUser == null) return ApiResponse.fail(401, "请先登录");
User user = usersService.findOne(id);
if (user == null) return ApiResponse.fail(404, "用户不存在");
return ApiResponse.ok(Map.of(
"id", user.getId(),
"username", user.getUsername() != null ? user.getUsername() : "",
"discussionsCount", user.getDiscussionsCount() != null ? user.getDiscussionsCount() : 0,
"commentsCount", user.getCommentsCount() != null ? user.getCommentsCount() : 0
));
}
// ─── Notifications ────────────────────────────────────────────────────
@Operation(summary = "Get unread notification + message counts")
@GetMapping("/unread-counts")
public ApiResponse<Map<String, Long>> unreadCounts(HttpServletRequest request) {
UserInfo user = getUserInfo(request);
if (user == null) return ApiResponse.ok(Map.of("notifications", 0L, "messages", 0L));
return ApiResponse.ok(Map.of(
"notifications", notificationService.countUnread(user.getId()),
"messages", messageService.countUnread(user.getId())
));
}
@Operation(summary = "Mark a notification as read")
@PostMapping("/notifications/{id}/read")
public ApiResponse<Void> markNotifRead(@PathVariable Integer id, HttpServletRequest request) {
UserInfo user = getUserInfo(request);
if (user == null) return ApiResponse.fail(401, "请先登录");
notificationService.markOneRead(id, user.getId());
return ApiResponse.ok(null);
}
@Operation(summary = "Mark all notifications as read")
@PostMapping("/notifications/read-all")
public ApiResponse<Void> markAllNotifRead(HttpServletRequest request) {
UserInfo user = getUserInfo(request);
if (user == null) return ApiResponse.fail(401, "请先登录");
notificationService.markAllRead(user.getId());
return ApiResponse.ok(null);
}
// ─── Messages ─────────────────────────────────────────────────────────
@Operation(summary = "Send a private message")
@PostMapping("/messages/send")
public ApiResponse<Message> sendMessage(
@RequestParam Integer receiverId,
@RequestParam String content,
HttpServletRequest request) {
UserInfo user = getUserInfo(request);
if (user == null) return ApiResponse.fail(401, "请先登录");
if (user.getId().equals(receiverId)) return ApiResponse.fail(400, "不能给自己发消息");
if (content == null || content.isBlank()) return ApiResponse.fail(400, "消息内容不能为空");
Message msg = messageService.send(user.getId(), receiverId, content.trim());
return ApiResponse.ok(msg);
}
@Operation(summary = "Get conversation with another user")
@GetMapping("/messages/conversation/{otherId}")
public ApiResponse<List<Message>> getConversation(@PathVariable Integer otherId, HttpServletRequest request) {
UserInfo user = getUserInfo(request);
if (user == null) return ApiResponse.fail(401, "请先登录");
messageService.markConversationRead(user.getId(), otherId);
return ApiResponse.ok(messageService.getConversation(user.getId(), otherId));
}
}
@@ -4,8 +4,8 @@ import com.yaoyuan.jiscuss.entity.Discussion;
import com.yaoyuan.jiscuss.exception.BaseException;
import com.yaoyuan.jiscuss.response.ResponseCode;
import com.yaoyuan.jiscuss.service.IDiscussionsService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -20,15 +20,16 @@ import java.util.List;
@RestController
@RequestMapping(path = "/other_api", produces = "application/json;charset=utf-8")
@Api(value = "主题帖子(其他)接口管理", tags = {"主题帖子(其他)接口管理"})
@Tag(name = "主题帖子(其他)接口管理", description = "主题相关 REST API")
public class RestPostController {
private static Logger logger = LoggerFactory.getLogger(RestPostController.class);
private static final Logger logger = LoggerFactory.getLogger(RestPostController.class);
@Autowired
private IDiscussionsService discussionsService;
@PostMapping("/discussion")
@ApiOperation("新增主题")
@Operation(summary = "新增主题")
public Discussion save(@RequestBody Discussion discussion) {
Discussion saveDiscussion = discussionsService.insert(discussion);
if (saveDiscussion != null) {
@@ -39,18 +40,16 @@ public class RestPostController {
}
@GetMapping("/discussion/{id}")
@ApiOperation("获取主题")
@Operation(summary = "获取主题")
public Discussion getDiscussions(@PathVariable Integer id) {
Discussion discussion = discussionsService.findOne(id);
return discussion;
return discussionsService.findOne(id);
}
@GetMapping("/discussions")
@ApiOperation("获取全部主题")
@Operation(summary = "获取全部主题")
public List<Discussion> getAllDiscussions() {
List<Discussion> allDiscussions = discussionsService.getAllList();
logger.info("全部主题==>{}", allDiscussions);
return allDiscussions;
}
}
@@ -1,12 +1,16 @@
package com.yaoyuan.jiscuss.controller.api;
import com.yaoyuan.jiscuss.dto.UserCreateRequest;
import com.yaoyuan.jiscuss.dto.UserMapper;
import com.yaoyuan.jiscuss.dto.UserResponse;
import com.yaoyuan.jiscuss.entity.User;
import com.yaoyuan.jiscuss.exception.BaseException;
import com.yaoyuan.jiscuss.response.ApiResponse;
import com.yaoyuan.jiscuss.response.ResponseCode;
import com.yaoyuan.jiscuss.service.IUsersService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.json.JSONObject;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -23,63 +27,59 @@ import java.util.List;
@RestController
@RequestMapping("/user_api")
@Api(value = "用户接口管理", tags = {"用户接口管理"})
@Tag(name = "用户接口管理", description = "用户 CRUD API")
public class RestUserController {
private static Logger logger = LoggerFactory.getLogger(RestUserController.class);
private static final Logger logger = LoggerFactory.getLogger(RestUserController.class);
@Autowired
private IUsersService usersService;
@PostMapping("/user")
@ApiOperation("新增用户")
public User save(@RequestBody User user) {
User saveUser = usersService.insert(user);
if (saveUser != null) {
return saveUser;
} else {
throw new BaseException(ResponseCode.RESOURCES_NOT_EXIST);
}
@Autowired
private UserMapper userMapper;
@PostMapping("/user")
@Operation(summary = "新增用户")
public ApiResponse<UserResponse> save(@Valid @RequestBody UserCreateRequest request) {
User user = userMapper.fromCreateRequest(request);
User saved = usersService.insert(user);
if (saved != null) {
return ApiResponse.ok(userMapper.toResponse(saved));
}
throw new BaseException(ResponseCode.RESOURCES_NOT_EXIST);
}
@DeleteMapping("/user/{id}")
@ApiOperation("删除用户")
public Boolean delete(@PathVariable Integer id) {
Boolean delete = true;
@Operation(summary = "删除用户")
public ApiResponse<Boolean> delete(@PathVariable Integer id) {
usersService.remove(id);
if (delete) {
return delete;
} else {
throw new BaseException(ResponseCode.RESOURCES_NOT_EXIST);
}
return ApiResponse.ok(true);
}
@PutMapping("/user/{id}")
@ApiOperation("修改用户")
public User update(@RequestBody User user, @PathVariable Integer id) {
User updateuser = usersService.update(user, id);
if (updateuser != null) {
return updateuser;
} else {
throw new BaseException(ResponseCode.RESOURCES_NOT_EXIST);
@Operation(summary = "修改用户")
public ApiResponse<UserResponse> update(@RequestBody User user, @PathVariable Integer id) {
User updated = usersService.update(user, id);
if (updated != null) {
return ApiResponse.ok(userMapper.toResponse(updated));
}
throw new BaseException(ResponseCode.RESOURCES_NOT_EXIST);
}
@GetMapping("/user/{id}")
@ApiOperation("获取用户")
public User getUser(@PathVariable Integer id) {
@Operation(summary = "获取用户")
public ApiResponse<UserResponse> getUser(@PathVariable Integer id) {
User user = usersService.findOne(id);
logger.info("获取用户==>{}", JSONObject.valueToString(user));
return user;
logger.info("获取用户==>{}", user);
return ApiResponse.ok(userMapper.toResponse(user));
}
@GetMapping("/user")
@ApiOperation("获取全部用户")
public List<User> getUser(User user) {
List<User> userall = usersService.getAllList();
logger.info("全部用户==>{}", JSONObject.valueToString(userall));
return userall;
@Operation(summary = "获取全部用户")
public ApiResponse<List<UserResponse>> getAllUsers() {
List<UserResponse> list = usersService.getAllList().stream()
.map(userMapper::toResponse)
.toList();
return ApiResponse.ok(list);
}
}
@@ -0,0 +1,63 @@
package com.yaoyuan.jiscuss.controller.api;
import com.yaoyuan.jiscuss.controller.BaseController;
import com.yaoyuan.jiscuss.entity.UserInfo;
import com.yaoyuan.jiscuss.response.ApiResponse;
import com.yaoyuan.jiscuss.service.ILikeCollectService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PathVariable;
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.RestController;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Map;
/**
* REST API for voting (like / dislike) on discussions and posts.
*/
@Tag(name = "Vote API", description = "Like and dislike discussions and posts")
@RestController
@RequestMapping("/api")
public class VoteController extends BaseController {
@Autowired
private ILikeCollectService likeCollectService;
@Operation(summary = "Vote on a discussion")
@PostMapping("/discussions/{id}/vote")
public ApiResponse<Map<String, String>> voteDiscussion(
@PathVariable Integer id,
@RequestParam String action,
HttpServletRequest request) {
UserInfo user = getUserInfo(request);
if (user == null) {
return ApiResponse.fail(401, "请先登录");
}
if (!"like".equals(action) && !"dislike".equals(action)) {
return ApiResponse.fail(400, "action 参数必须是 like 或 dislike");
}
String result = likeCollectService.voteDiscussion(user.getId(), id, action);
return ApiResponse.ok(Map.of("action", result));
}
@Operation(summary = "Vote on a post (reply)")
@PostMapping("/posts/{id}/vote")
public ApiResponse<Map<String, String>> votePost(
@PathVariable Integer id,
@RequestParam String action,
HttpServletRequest request) {
UserInfo user = getUserInfo(request);
if (user == null) {
return ApiResponse.fail(401, "请先登录");
}
if (!"like".equals(action) && !"dislike".equals(action)) {
return ApiResponse.fail(400, "action 参数必须是 like 或 dislike");
}
String result = likeCollectService.votePost(user.getId(), id, action);
return ApiResponse.ok(Map.of("action", result));
}
}
@@ -0,0 +1,23 @@
package com.yaoyuan.jiscuss.dto;
import lombok.Data;
import java.util.Date;
/** View model for one inbox entry (latest message in a conversation). */
@Data
public class InboxItem {
private final Integer otherId;
private final String otherUsername;
private final String lastContent;
private final Date lastTime;
private final boolean hasUnread;
public InboxItem(Integer otherId, String otherUsername, String lastContent, Date lastTime, boolean hasUnread) {
this.otherId = otherId;
this.otherUsername = otherUsername;
this.lastContent = lastContent;
this.lastTime = lastTime;
this.hasUnread = hasUnread;
}
}
@@ -0,0 +1,31 @@
package com.yaoyuan.jiscuss.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Request payload for creating a new user account.
* Validated at the controller boundary before reaching the service layer.
*/
public record UserCreateRequest(
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 50, message = "用户名长度必须在 3-50 个字符之间")
String username,
/**
* Raw password — will be BCrypt-hashed before persistence.
* Never log or return this field.
*/
@NotBlank(message = "密码不能为空")
@Size(min = 8, max = 100, message = "密码长度至少 8 个字符")
String password,
@Email(message = "邮箱格式不正确")
@Size(max = 100)
String email,
@Size(max = 50)
String realname
) {}
@@ -0,0 +1,54 @@
package com.yaoyuan.jiscuss.dto;
import com.yaoyuan.jiscuss.entity.User;
import org.mapstruct.Mapper;
import org.mapstruct.MappingConstants;
import org.mapstruct.ReportingPolicy;
/**
* MapStruct mapper between {@link User} entity and its DTOs.
*
* <p>The {@code password} field is intentionally never copied into a response and
* is left {@code null} when constructing a {@link User} from a create request —
* the caller (service layer) is responsible for BCrypt-hashing and setting it.
*/
@Mapper(
componentModel = MappingConstants.ComponentModel.SPRING,
unmappedTargetPolicy = ReportingPolicy.IGNORE
)
public interface UserMapper {
/** Map a {@link User} entity to a safe {@link UserResponse} (no password). */
default UserResponse toResponse(User user) {
if (user == null) {
return null;
}
return new UserResponse(
user.getId(),
user.getUsername(),
user.getRealname(),
user.getEmail(),
user.getAvatar(),
user.getGender(),
user.getPhone(),
user.getAge(),
user.getDiscussionsCount(),
user.getCommentsCount(),
user.getJoinTime(),
user.getLastSeenTime(),
user.getLevel()
);
}
/** Map a create request to a new {@link User} entity. Password must be set by the caller. */
default User fromCreateRequest(UserCreateRequest request) {
if (request == null) {
return null;
}
User user = new User();
user.setUsername(request.username());
user.setEmail(request.email());
user.setRealname(request.realname());
return user;
}
}
@@ -0,0 +1,23 @@
package com.yaoyuan.jiscuss.dto;
import java.util.Date;
/**
* Read-only user projection exposed to the frontend / API consumers.
* Never exposes the {@code password} field.
*/
public record UserResponse(
Integer id,
String username,
String realname,
String email,
String avatar,
String gender,
String phone,
Integer age,
Integer discussionsCount,
Integer commentsCount,
Date joinTime,
Date lastSeenTime,
Integer level
) {}
@@ -0,0 +1,47 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@Entity
@Table(name = "audit_log")
public class AuditLog implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "admin_id")
private Integer adminId;
@Column(name = "admin_name")
private String adminName;
@Column(name = "action_type")
private String actionType;
@Column(name = "target_type")
private String targetType;
@Column(name = "target_id")
private Integer targetId;
@Column(name = "description")
private String description;
@Column(name = "status")
private String status;
@Column(name = "create_time")
private Date createTime;
}
@@ -1,13 +1,15 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
import java.util.Date;
@@ -22,6 +24,8 @@ public class Discussion implements Serializable {
@Column(name = "id", unique = true)
private Integer id;
@NotBlank
@Size(max = 200)
@Column(name = "title")
private String title;
@@ -64,7 +68,10 @@ public class Discussion implements Serializable {
@Column(name = "like_count")
private Integer likeCount;
@Column(name = "dislike_count")
private Integer dislikeCount;
/** IP is resolved server-side via IpUtils.getClientIp() — never trusted from X-Forwarded-For alone. */
@Column(name = "ip_address")
private String ipAddress;
@@ -74,4 +81,7 @@ public class Discussion implements Serializable {
@Column(name = "create_time")
private Date createTime;
@Column(name = "view_count")
private Integer viewCount;
}
@@ -1,13 +1,13 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
@Data
@@ -1,22 +1,18 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
import java.util.Date;
/**
* @author yaoyuan2.chu
* @Title:
* @Package com.yaoyuan.jiscuss.entity
* @Description:
* @date 2020/10/21 12:00
* @author Chuyaoyuan
*/
@Data
@Entity
@@ -52,6 +48,10 @@ public class LikeCollect implements Serializable {
@Column(name = "type")
private String type;
/** Vote action: 'like' or 'dislike'. */
@Column(name = "action")
private String action;
@Column(name = "like_type")
private String likeType;
@@ -0,0 +1,40 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@Entity
@Table(name = "message")
public class Message implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "sender_id", nullable = false)
private Integer senderId;
@Column(name = "receiver_id", nullable = false)
private Integer receiverId;
@Column(name = "content", columnDefinition = "TEXT", nullable = false)
private String content;
@Column(name = "is_read")
private Boolean isRead = false;
@Column(name = "create_time")
private Date createTime;
/**
* Conversation identifier = "min(a,b)_max(a,b)", groups all messages
* between two users into a single conversation thread.
*/
@Column(name = "conversation_id", nullable = false)
private String conversationId;
}
@@ -0,0 +1,50 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@Entity
@Table(name = "notification")
public class Notification implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
/** Receiver user ID. */
@Column(name = "user_id", nullable = false)
private Integer userId;
/** Sender user ID (null for system notifications). */
@Column(name = "from_user_id")
private Integer fromUserId;
/** Notification type: reply / like / system. */
@Column(name = "type", nullable = false)
private String type;
@Column(name = "title")
private String title;
@Column(name = "content", columnDefinition = "TEXT")
private String content;
/** Related entity ID (e.g., discussion or post id). */
@Column(name = "related_id")
private Integer relatedId;
/** Type of related entity: discussion / post. */
@Column(name = "related_type")
private String relatedType;
@Column(name = "is_read")
private Boolean isRead = false;
@Column(name = "create_time")
private Date createTime;
}
@@ -1,13 +1,14 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
import java.util.Date;
@@ -36,6 +37,7 @@ public class Post implements Serializable {
@Column(name = "type")
private String type;
@NotBlank
@Column(name = "content")
private String content;
@@ -48,15 +50,30 @@ public class Post implements Serializable {
@Column(name = "edit_user_id")
private Integer editUserId;
/** IP resolved server-side via IpUtils.getClientIp(). */
@Column(name = "ip_address")
private String ipAddress;
/** Province/region derived from IP (e.g. "北京", "广东"). Stored on post creation. */
@Column(name = "ip_region")
private String ipRegion;
/** Parsed browser name+version (e.g. "Chrome 120"). Derived from User-Agent on post creation. */
@Column(name = "browser")
private String browser;
@Column(name = "copyright")
private String copyright;
@Column(name = "is_approved")
private Integer isApproved;
@Column(name = "like_count")
private Integer likeCount;
@Column(name = "dislike_count")
private Integer dislikeCount;
@Column(name = "create_id")
private Integer createId;
@@ -0,0 +1,41 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@Entity
@Table(name = "rbac_role")
public class Role implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "code")
private String code;
@Column(name = "name")
private String name;
@Column(name = "description")
private String description;
@Column(name = "enabled")
private Integer enabled;
@Column(name = "create_time")
private Date createTime;
@Column(name = "update_time")
private Date updateTime;
}
@@ -1,13 +1,13 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
@Data
@@ -1,13 +1,13 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
import java.util.Date;
@@ -0,0 +1,41 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@Entity
@Table(name = "upgrade_log")
public class UpgradeLog implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "version")
private String version;
@Column(name = "title")
private String title;
@Column(name = "content")
private String content;
@Column(name = "type")
private String type;
@Column(name = "created_by")
private Integer createdBy;
@Column(name = "create_time")
private Date createTime;
}
@@ -1,14 +1,18 @@
package com.yaoyuan.jiscuss.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.io.Serializable;
import java.util.Date;
@@ -22,15 +26,22 @@ public class User implements Serializable {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@NotBlank
@Size(min = 3, max = 50)
@Column(name = "username")
private String username;
@Size(max = 50)
@Column(name = "realname")
private String realname;
@Email
@Size(max = 100)
@Column(name = "email")
private String email;
/** BCrypt-hashed password. Column length must be >= 68 (see V2 migration). Never serialized to JSON. */
@JsonIgnore
@Column(name = "password")
private String password;
@@ -40,12 +51,14 @@ public class User implements Serializable {
@Column(name = "age")
private Integer age;
@Pattern(regexp = "^(男|女|其他)?$")
@Column(name = "gender")
private String gender;
@Column(name = "avatar")
private String avatar;
@Size(max = 20)
@Column(name = "phone")
private String phone;
@@ -63,5 +76,4 @@ public class User implements Serializable {
@Column(name = "level")
private Integer level;
}
}
@@ -7,7 +7,7 @@ import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
/**
* @author yaoyuan2.chu
* @author Chuyaoyuan
* @Title:
* @Package com.yaoyuan.jiscuss.entity
* @Description:
@@ -77,10 +77,9 @@ public class UserInfo implements UserDetails {
@Override
public String toString() {
return "UserInfo{" +
"authorities=" + authorities +
", password='" + password + '\'' +
", username='" + username + '\'' +
"username='" + username + '\'' +
", id='" + id + '\'' +
", authorities=" + authorities +
'}';
}
}
@@ -0,0 +1,32 @@
package com.yaoyuan.jiscuss.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@Entity
@Table(name = "rbac_user_role")
public class UserRole implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "user_id")
private Integer userId;
@Column(name = "role_id")
private Integer roleId;
@Column(name = "create_time")
private Date createTime;
}
@@ -8,7 +8,7 @@ import lombok.Setter;
import java.util.List;
/**
* @author yaoyuan2.chu
* @author Chuyaoyuan
* @Title:
* @Package com.yaoyuan.jiscuss.entity.custom
* @Description:
@@ -8,7 +8,7 @@ import lombok.Setter;
import java.util.List;
/**
* @author yaoyuan2.chu
* @author Chuyaoyuan
* @Title:
* @Package com.yaoyuan.jiscuss.entity.custom
* @Description:
@@ -4,10 +4,10 @@ import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import jakarta.persistence.Column;
/**
* @author yaoyuan2.chu
* @author Chuyaoyuan
* @Title:
* @Package com.yaoyuan.jiscuss.entity.custom
* @Description:
@@ -0,0 +1,91 @@
package com.yaoyuan.jiscuss.exception;
import com.yaoyuan.jiscuss.response.ApiResponse;
import com.yaoyuan.jiscuss.response.ResponseCode;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.NoHandlerFoundException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.stream.Collectors;
/**
* Global exception handler.
*
* <p>Catches {@link BaseException}, validation errors, and unexpected exceptions so they
* are returned as structured JSON instead of Spring's default error page.
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/** Business logic exceptions (already typed with a ResponseCode). */
@ExceptionHandler(BaseException.class)
public ResponseEntity<ApiResponse<Void>> handleBaseException(BaseException ex) {
log.warn("Business exception: code={}, msg={}", ex.getCode().getCode(), ex.getCode().getMsg());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.fail(ex.getCode()));
}
/** @Valid / @Validated failures on @RequestBody. */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.collect(Collectors.joining("; "));
log.warn("Validation failed: {}", message);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(message));
}
/** @Validated failures on @RequestParam / @PathVariable. */
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleConstraintViolation(ConstraintViolationException ex) {
String message = ex.getConstraintViolations().stream()
.map(cv -> cv.getPropertyPath() + ": " + cv.getMessage())
.collect(Collectors.joining("; "));
log.warn("Constraint violation: {}", message);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(message));
}
/**
* Spring Security access-denied exceptions re-thrown by method security.
* Note: exceptions raised before method invocation are handled by {@code CustomAccessDeniedHandler}.
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException ex) {
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("没有权限访问该资源"));
}
/** 404 mappings/resources should not be logged as system errors. */
@ExceptionHandler({NoHandlerFoundException.class, NoResourceFoundException.class})
public ResponseEntity<ApiResponse<Void>> handleNotFound(Exception ex) {
log.warn("Not found: {}", ex.getMessage());
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("资源不存在"));
}
/** Catch-all for any unhandled exception — never expose stack traces to clients. */
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGeneral(Exception ex) {
log.error("Unexpected error", ex);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.fail(ResponseCode.SERVICE_ERROR));
}
}
@@ -1,63 +1,62 @@
package com.yaoyuan.jiscuss.handler;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.WebAttributes;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
/**
* 处理无权请求
* Handles 403 access-denied responses.
*
* @author charlie
* <p>Behavior:
* <ul>
* <li>AJAX requests → JSON body with {@code code/msg}</li>
* <li>Regular page requests → simple HTML 403 response (no forward to /403,
* which previously caused a 404)</li>
* </ul>
*/
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private Logger log = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
private static final Logger log = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
boolean isAjax = ControllerTools.isAjaxRequest(request);
System.out.println("CustomAccessDeniedHandler handle");
if (!response.isCommitted()) {
if (isAjax) {
String msg = accessDeniedException.getMessage();
log.info("accessDeniedException.message:" + msg);
String accessDenyMsg = "{\"code\":\"403\",\"msg\":\"没有权限\"}";
ControllerTools.print(response, accessDenyMsg);
} else {
request.setAttribute(WebAttributes.ACCESS_DENIED_403, accessDeniedException);
response.setStatus(HttpStatus.FORBIDDEN.value());
RequestDispatcher dispatcher = request.getRequestDispatcher("/403");
dispatcher.forward(request, response);
if (response.isCommitted()) {
return;
}
log.warn("Access denied for {} → {}", request.getRequestURI(), accessDeniedException.getMessage());
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setCharacterEncoding("UTF-8");
if (isAjaxRequest(request)) {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
try (PrintWriter writer = response.getWriter()) {
writer.write("{\"code\":403,\"msg\":\"没有权限\"}");
}
} else {
response.setContentType(MediaType.TEXT_HTML_VALUE);
try (PrintWriter writer = response.getWriter()) {
writer.write("<!DOCTYPE html><html><head><meta charset=\"UTF-8\"><title>403 Forbidden</title></head>"
+ "<body><h1>403 Forbidden</h1><p>您没有权限访问该资源。</p></body></html>");
}
}
}
public static class ControllerTools {
public static boolean isAjaxRequest(HttpServletRequest request) {
return "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));
}
public static void print(HttpServletResponse response, String msg) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write(msg);
writer.flush();
writer.close();
}
private static boolean isAjaxRequest(HttpServletRequest request) {
return "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));
}
}
@@ -1,37 +1,70 @@
package com.yaoyuan.jiscuss.handler;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import javax.servlet.http.HttpServletRequest;
import java.util.Collection;
/**
* RBAC数据模型控制权限
* RBAC (Role-Based Access Control) permission evaluator.
*
* @author charlie
* <p>Rules:
* <ul>
* <li>Anonymous / unauthenticated: denied</li>
* <li>ROLE_ADMIN: full access (all paths)</li>
* <li>ROLE_USER: access to all non-admin paths</li>
* <li>Admin-only paths ({@code /admin/**}, {@code /druid/**}, etc.) require ROLE_ADMIN</li>
* </ul>
*
* <p>Previously this method unconditionally returned {@code true}, giving every user
* unrestricted access regardless of role — a critical security vulnerability now fixed.
*/
@Component("rbacPermission")
public class RbacPermission {
private AntPathMatcher antPathMatcher = new AntPathMatcher();
private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
/** URL patterns that require ROLE_ADMIN. */
private static final String[] ADMIN_ONLY_PATTERNS = {
"/admin/**",
"/druid/**",
"/actuator/**",
"/h2-console/**",
"/user_api/**",
"/other_api/**"
};
/**
* Evaluates whether the authenticated principal is permitted to access the requested URL.
*
* @param request the current HTTP request
* @param authentication the current authentication token
* @return {@code true} when access is granted
*/
public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
Object principal = authentication.getPrincipal();
boolean hasPermission = false;
if (authentication == null
|| !authentication.isAuthenticated()
|| "anonymousUser".equals(authentication.getPrincipal())) {
return false;
}
hasPermission = true;
// if (principal instanceof UserEntity) {
// // 读取用户所拥有的权限菜单
// List<Menu> menus = ((UserEntity) principal).getRoleMenus();
// System.out.println(menus.size());
// for (Menu menu : menus) {
// if (antPathMatcher.match(menu.getMenuUrl(), request.getRequestURI())) {
// hasPermission = true;
// break;
// }
// }
// }
return hasPermission;
String uri = request.getRequestURI();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
boolean isAdmin = authorities.stream()
.anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()));
// Admin-only paths: only ROLE_ADMIN may proceed
for (String pattern : ADMIN_ONLY_PATTERNS) {
if (PATH_MATCHER.match(pattern, uri)) {
return isAdmin;
}
}
// All other paths are accessible by any authenticated user
return true;
}
}
@@ -0,0 +1,20 @@
package com.yaoyuan.jiscuss.repository;
import com.yaoyuan.jiscuss.entity.AuditLog;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface AuditLogRepository extends JpaRepository<AuditLog, Integer> {
List<AuditLog> findAllByOrderByCreateTimeDesc();
Page<AuditLog> findAllByOrderByCreateTimeDesc(Pageable pageable);
List<AuditLog> findByTargetTypeAndTargetIdOrderByCreateTimeDesc(String targetType, Integer targetId);
List<AuditLog> findByAdminIdOrderByCreateTimeDesc(Integer adminId);
}
@@ -4,8 +4,12 @@ import com.yaoyuan.jiscuss.entity.Discussion;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
@Repository
public interface DiscussionsRepository extends JpaRepository<Discussion, Integer> {
@@ -14,5 +18,35 @@ public interface DiscussionsRepository extends JpaRepository<Discussion, Integer
"SELECT discussion_id from discussiontag where tag_id = ?1 ) ", nativeQuery = true)
Page<Discussion> findByQuery(String tagId, Pageable pageable);
@Modifying
@Transactional
@Query("UPDATE Discussion d SET d.viewCount = d.viewCount + 1 WHERE d.id = :id")
void incrementViewCount(@org.springframework.data.repository.query.Param("id") Integer id);
@Modifying
@Transactional
@Query("UPDATE Discussion d SET d.commentsCount = COALESCE(d.commentsCount, 0) + 1, d.lastTime = :lastTime, d.lastUserId = :userId WHERE d.id = :id")
void incrementCommentCount(
@org.springframework.data.repository.query.Param("id") Integer id,
@org.springframework.data.repository.query.Param("userId") Integer userId,
@org.springframework.data.repository.query.Param("lastTime") Date lastTime);
@Modifying
@Transactional
@Query("UPDATE Discussion d SET d.likeCount = COALESCE(d.likeCount, 0) + :delta WHERE d.id = :id")
void adjustLikeCount(@org.springframework.data.repository.query.Param("id") Integer id,
@org.springframework.data.repository.query.Param("delta") int delta);
@Modifying
@Transactional
@Query("UPDATE Discussion d SET d.dislikeCount = COALESCE(d.dislikeCount, 0) + :delta WHERE d.id = :id")
void adjustDislikeCount(@org.springframework.data.repository.query.Param("id") Integer id,
@org.springframework.data.repository.query.Param("delta") int delta);
/** Discussions started by a given user, most recent first. */
@Query("SELECT d FROM Discussion d WHERE d.startUserId = :userId ORDER BY d.startTime DESC")
org.springframework.data.domain.Page<Discussion> findByStartUserIdOrderByStartTimeDesc(
@org.springframework.data.repository.query.Param("userId") Integer userId,
org.springframework.data.domain.Pageable pageable);
long countByStartUserId(Integer startUserId);
}
@@ -7,4 +7,5 @@ import org.springframework.stereotype.Repository;
@Repository
public interface DiscussionsTagsRepository extends JpaRepository<DiscussionTag, Integer> {
void deleteByDiscussionId(Integer discussionId);
}
@@ -0,0 +1,25 @@
package com.yaoyuan.jiscuss.repository;
import com.yaoyuan.jiscuss.entity.LikeCollect;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface LikeCollectRepository extends JpaRepository<LikeCollect, Integer> {
/** Find an existing vote record for a user on a discussion. */
@Query("FROM LikeCollect lc WHERE lc.userId = :userId AND lc.discussionId = :discussionId AND lc.type = 'discussion'")
Optional<LikeCollect> findDiscussionVote(@Param("userId") Integer userId, @Param("discussionId") Integer discussionId);
/** Find an existing vote record for a user on a post. */
@Query("FROM LikeCollect lc WHERE lc.userId = :userId AND lc.postId = :postId AND lc.type = 'post'")
Optional<LikeCollect> findPostVote(@Param("userId") Integer userId, @Param("postId") Integer postId);
/** Discussions liked by a user (action = 'like'). */
@Query("SELECT lc.discussionId FROM LikeCollect lc WHERE lc.userId = :userId AND lc.type = 'discussion' AND lc.action = 'like'")
java.util.List<Integer> findLikedDiscussionIdsByUser(@Param("userId") Integer userId);
}
@@ -0,0 +1,31 @@
package com.yaoyuan.jiscuss.repository;
import com.yaoyuan.jiscuss.entity.Message;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Repository
public interface MessageRepository extends JpaRepository<Message, Integer> {
/** Latest messages in a conversation, most recent first. */
List<Message> findByConversationIdOrderByCreateTimeDesc(String conversationId);
/** All messages sent by user. */
List<Message> findBySenderIdOrderByCreateTimeDesc(Integer senderId);
/** All messages received by user. */
List<Message> findByReceiverIdOrderByCreateTimeDesc(Integer receiverId);
long countByReceiverIdAndIsReadFalse(Integer receiverId);
@Modifying
@Transactional
@Query("UPDATE Message m SET m.isRead = true WHERE m.conversationId = :convId AND m.receiverId = :userId")
void markConversationReadForUser(@Param("convId") String convId, @Param("userId") Integer userId);
}
@@ -0,0 +1,29 @@
package com.yaoyuan.jiscuss.repository;
import com.yaoyuan.jiscuss.entity.Notification;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
@Repository
public interface NotificationRepository extends JpaRepository<Notification, Integer> {
Page<Notification> findByUserIdOrderByCreateTimeDesc(Integer userId, Pageable pageable);
long countByUserIdAndIsReadFalse(Integer userId);
@Modifying
@Transactional
@Query("UPDATE Notification n SET n.isRead = true WHERE n.userId = :userId")
void markAllReadByUserId(@Param("userId") Integer userId);
@Modifying
@Transactional
@Query("UPDATE Notification n SET n.isRead = true WHERE n.id = :id AND n.userId = :userId")
void markReadById(@Param("id") Integer id, @Param("userId") Integer userId);
}
@@ -2,9 +2,11 @@ package com.yaoyuan.jiscuss.repository;
import com.yaoyuan.jiscuss.entity.Post;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
@@ -12,6 +14,8 @@ import java.util.Map;
@Repository
public interface PostsRepository extends JpaRepository<Post, Integer> {
void deleteByDiscussionId(Integer discussionId);
/**
* @param dId
* @return
@@ -19,6 +23,20 @@ public interface PostsRepository extends JpaRepository<Post, Integer> {
@Query("from Post where discussionId = :dId")
List<Post> findOneBy(@Param("dId") Integer dId);
/** Returns the current max floor number for a discussion (0 if no posts yet). */
@Query("SELECT COALESCE(MAX(p.number), 0) FROM Post p WHERE p.discussionId = :dId")
int findMaxNumberByDiscussionId(@Param("dId") Integer dId);
@Modifying
@Transactional
@Query("UPDATE Post p SET p.likeCount = COALESCE(p.likeCount, 0) + :delta WHERE p.id = :id")
void adjustLikeCount(@Param("id") Integer id, @Param("delta") int delta);
@Modifying
@Transactional
@Query("UPDATE Post p SET p.dislikeCount = COALESCE(p.dislikeCount, 0) + :delta WHERE p.id = :id")
void adjustDislikeCount(@Param("id") Integer id, @Param("delta") int delta);
@Query(value = "select p.*,u.avatar as user_avatar ,u.username as user_username ,u.realname as user_realname ," +
"u2.avatar as create_avatar ,u2.username as create_username ,u2.realname as create_realname \n" +
"from post p \n" +
@@ -29,6 +47,15 @@ public interface PostsRepository extends JpaRepository<Post, Integer> {
List<Map<String, Object>> findPostCustomById(@Param("dId") Integer dId);
@Query(value = "select p.*,u.avatar as user_avatar ,u.username as user_username ,u.realname as user_realname ," +
"u2.avatar as create_avatar ,u2.username as create_username ,u2.realname as create_realname \n" +
"from post p \n" +
"left join user u on p.user_id = u.id \n" +
"left join user u2 on p.create_id = u2.id \n" +
"where p.discussion_id = :dId and p.parent_id is null order by p.create_time asc"
, nativeQuery = true)
List<Map<String, Object>> findAllByDIdAndparentIdNullOldest(@Param("dId") Integer dId);
@Query(value = "select p.*,u.avatar as user_avatar ,u.username as user_username ,u.realname as user_realname ," +
"u2.avatar as create_avatar ,u2.username as create_username ,u2.realname as create_realname \n" +
"from post p \n" +
@@ -36,7 +63,16 @@ public interface PostsRepository extends JpaRepository<Post, Integer> {
"left join user u2 on p.create_id = u2.id \n" +
"where p.discussion_id = :dId and p.parent_id is null order by p.create_time desc"
, nativeQuery = true)
List<Map<String, Object>> findAllByDIdAndparentIdNull(@Param("dId") Integer dId);
List<Map<String, Object>> findAllByDIdAndparentIdNullNewest(@Param("dId") Integer dId);
@Query(value = "select p.*,u.avatar as user_avatar ,u.username as user_username ,u.realname as user_realname ," +
"u2.avatar as create_avatar ,u2.username as create_username ,u2.realname as create_realname \n" +
"from post p \n" +
"left join user u on p.user_id = u.id \n" +
"left join user u2 on p.create_id = u2.id \n" +
"where p.discussion_id = :dId and p.parent_id is null order by p.like_count desc, p.create_time desc"
, nativeQuery = true)
List<Map<String, Object>> findAllByDIdAndparentIdNullHot(@Param("dId") Integer dId);
@Query(value = "select p.*,u.avatar as user_avatar ,u.username as user_username ,u.realname as user_realname ," +
"u2.avatar as create_avatar ,u2.username as create_username ,u2.realname as create_realname \n" +
@@ -53,4 +89,11 @@ public interface PostsRepository extends JpaRepository<Post, Integer> {
//
// @Query("from Post where parentId is not null and discussionId = :dId")
// List<Post> findAllByDIdAndparentIdNotNull(@Param("dId")Integer dId);
/** Posts (replies) created by a given user, most recent first. */
@Query("SELECT p FROM Post p WHERE p.createId = :userId ORDER BY p.createTime DESC")
org.springframework.data.domain.Page<Post> findByCreateIdOrderByCreateTimeDesc(
@Param("userId") Integer userId, org.springframework.data.domain.Pageable pageable);
long countByCreateId(Integer createId);
}
@@ -0,0 +1,16 @@
package com.yaoyuan.jiscuss.repository;
import com.yaoyuan.jiscuss.entity.Role;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface RoleRepository extends JpaRepository<Role, Integer> {
Role findByCode(String code);
Role findByCodeIgnoreCase(String code);
List<Role> findAllByOrderByIdAsc();
}
@@ -0,0 +1,12 @@
package com.yaoyuan.jiscuss.repository;
import com.yaoyuan.jiscuss.entity.UpgradeLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface UpgradeLogRepository extends JpaRepository<UpgradeLog, Integer> {
List<UpgradeLog> findAllByOrderByCreateTimeDesc();
}
@@ -0,0 +1,20 @@
package com.yaoyuan.jiscuss.repository;
import com.yaoyuan.jiscuss.entity.UserRole;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface UserRoleRepository extends JpaRepository<UserRole, Integer> {
List<UserRole> findByUserId(Integer userId);
void deleteByUserId(Integer userId);
@Query(value = "select r.code from rbac_user_role ur join rbac_role r on ur.role_id = r.id where ur.user_id = :userId and r.enabled = 1",
nativeQuery = true)
List<String> findRoleCodesByUserId(@Param("userId") Integer userId);
}
@@ -2,9 +2,11 @@ package com.yaoyuan.jiscuss.repository;
import com.yaoyuan.jiscuss.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@@ -30,11 +32,20 @@ public interface UsersRepository extends JpaRepository<User, Integer> {
@Query("from User where username = :username")
User getByUsername(String username);
/**
* @param username
* @param password
* @return
*/
@Query("from User where username = :username and password = :password")
User checkByUsernameAndPassword(String username, String password);
// REMOVED: checkByUsernameAndPassword — never compare passwords at the SQL layer.
// Password validation must go through BCryptPasswordEncoder.matches() in the service/security layer.
@Modifying
@Transactional
@Query("UPDATE User u SET u.commentsCount = COALESCE(u.commentsCount, 0) + 1 WHERE u.id = :id")
void incrementCommentCount(@Param("id") Integer id);
@Modifying
@Transactional
@Query("UPDATE User u SET u.discussionsCount = COALESCE(u.discussionsCount, 0) + 1 WHERE u.id = :id")
void incrementDiscussionCount(@Param("id") Integer id);
/** Top active users by comment count (for the ranking sidebar). Shows all users if counts are 0. */
@Query("FROM User u ORDER BY COALESCE(u.commentsCount, 0) DESC")
List<User> findTop10ActiveUsers(org.springframework.data.domain.Pageable pageable);
}
@@ -0,0 +1,25 @@
package com.yaoyuan.jiscuss.response;
/**
* Unified API response wrapper.
*
* @param <T> payload type
*/
public record ApiResponse<T>(int code, String msg, T data) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(ResponseCode.SUCCESS.getCode(), ResponseCode.SUCCESS.getMsg(), data);
}
public static <T> ApiResponse<T> fail(ResponseCode responseCode) {
return new ApiResponse<>(responseCode.getCode(), responseCode.getMsg(), null);
}
public static <T> ApiResponse<T> fail(int code, String msg) {
return new ApiResponse<>(code, msg, null);
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(ResponseCode.SERVICE_ERROR.getCode(), message, null);
}
}
@@ -0,0 +1,59 @@
package com.yaoyuan.jiscuss.service;
import com.yaoyuan.jiscuss.entity.AuditLog;
import com.yaoyuan.jiscuss.entity.UserInfo;
import com.yaoyuan.jiscuss.repository.AuditLogRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
/**
* Service for recording admin operations in audit log.
*/
@Service
public class AuditLogService {
private final AuditLogRepository auditLogRepository;
public AuditLogService(AuditLogRepository auditLogRepository) {
this.auditLogRepository = auditLogRepository;
}
@Transactional
public void log(Integer adminId, String adminName, String actionType, String targetType,
Integer targetId, String description, String status) {
AuditLog log = new AuditLog();
log.setAdminId(adminId);
log.setAdminName(adminName);
log.setActionType(actionType);
log.setTargetType(targetType);
log.setTargetId(targetId);
log.setDescription(description);
log.setStatus(status == null ? "success" : status);
log.setCreateTime(new Date());
auditLogRepository.save(log);
}
@Transactional
public void log(Integer adminId, String adminName, String actionType, String targetType,
Integer targetId, String description) {
log(adminId, adminName, actionType, targetType, targetId, description, "success");
}
@Transactional
public void log(UserInfo user, String actionType, String targetType,
Integer targetId, String description) {
Integer userId = user == null ? null : user.getId();
String userName = user == null ? "unknown" : user.getUsername();
log(userId, userName, actionType, targetType, targetId, description, "success");
}
@Transactional
public void log(UserInfo user, String actionType, String targetType,
Integer targetId, String description, String status) {
Integer userId = user == null ? null : user.getId();
String userName = user == null ? "unknown" : user.getUsername();
log(userId, userName, actionType, targetType, targetId, description, status);
}
}
@@ -14,4 +14,6 @@ public interface IDiscussionsService {
Discussion findOne(Integer id);
Page<Discussion> queryAllDiscussionsList(Discussion discussion, int pageNumNew, int pageSiz, String tag, String type);
void incrementViewCount(Integer id);
}
@@ -0,0 +1,22 @@
package com.yaoyuan.jiscuss.service;
/**
* Vote (like / dislike) service for discussions and posts.
*
* <p>Toggle semantics:
* <ul>
* <li>If the user has not voted → create a new vote with the requested action.</li>
* <li>If the user voted with the same action → cancel the vote (toggle off).</li>
* <li>If the user voted with a different action → switch the vote.</li>
* </ul>
*
* @return the user's current action after the operation: "like", "dislike", or "none" (cancelled)
*/
public interface ILikeCollectService {
/** Vote on a discussion. {@code action} must be "like" or "dislike". */
String voteDiscussion(Integer userId, Integer discussionId, String action);
/** Vote on a post (reply). {@code action} must be "like" or "dislike". */
String votePost(Integer userId, Integer postId, String action);
}
@@ -0,0 +1,23 @@
package com.yaoyuan.jiscuss.service;
import com.yaoyuan.jiscuss.entity.Message;
import java.util.List;
public interface IMessageService {
/** Send a message from sender to receiver. */
Message send(Integer senderId, Integer receiverId, String content);
/** All messages in a conversation (ordered oldest→newest). */
List<Message> getConversation(Integer userId, Integer otherId);
/** Inbox: latest message per conversation, sorted by latest activity (max 50). */
List<Message> getInbox(Integer userId);
/** Unread message count for a user. */
long countUnread(Integer userId);
/** Mark an entire conversation as read for the current user. */
void markConversationRead(Integer userId, Integer otherId);
}
@@ -0,0 +1,27 @@
package com.yaoyuan.jiscuss.service;
import com.yaoyuan.jiscuss.entity.Notification;
import org.springframework.data.domain.Page;
public interface INotificationService {
/** Create a notification for a user. */
Notification create(Integer toUserId, Integer fromUserId, String type, String title, String content,
Integer relatedId, String relatedType);
/** Convenience: create a "reply" notification. */
void notifyReply(Integer toUserId, Integer fromUserId, Integer discussionId, String discussionTitle);
/** Convenience: create a "like" notification for a post. */
void notifyLike(Integer toUserId, Integer fromUserId, Integer postId, Integer discussionId);
/** Unread notification count for a user. */
long countUnread(Integer userId);
/** Paged notification list for a user. */
Page<Notification> listByUser(Integer userId, int page, int size);
void markAllRead(Integer userId);
void markOneRead(Integer notificationId, Integer userId);
}
@@ -12,7 +12,7 @@ public interface IPostsService {
List<Post> findOneBy(Integer id);
List<PostCustom> findPostCustomById(Integer id);
List findPostCustomById(Integer id, String sort);
Post findOneByid(Integer parentId);
}
@@ -24,7 +24,9 @@ public interface IUsersService {
User getByUsername(String username);
User checkByUsernameAndPassword(String username, String password);
List<User> findAllById(Iterable<Integer> ids);
User update(User user, Integer id);
List<User> getTopActiveUsers(int limit);
}
@@ -0,0 +1,71 @@
package com.yaoyuan.jiscuss.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.Set;
/**
* Lightweight IP region service.
*
* <p>Current implementation distinguishes local/private addresses from external ones.
* To display accurate province-level location (e.g. "北京", "广东"), replace the
* {@code lookupExternal} method with an ip2region XDB lookup or similar offline database.
*
* <p>Upgrade path:
* <ol>
* <li>Add {@code org.lionsoul:ip2region:2.7.0} to pom.xml</li>
* <li>Place {@code ip2region.xdb} (from the ip2region GitHub release) under
* {@code src/main/resources/}</li>
* <li>Replace {@code lookupExternal()} with:
* {@code Searcher.newWithFileOnly(xdbPath).searchByStr(ip)}</li>
* </ol>
*/
@Service
public class IpRegionService {
private static final Logger log = LoggerFactory.getLogger(IpRegionService.class);
private static final Set<String> LOCAL_PREFIXES = Set.of(
"127.", "0.", "::1", "0:0:0:0:0:0:0:1"
);
private static final Set<String> PRIVATE_PREFIXES = Set.of(
"10.", "192.168.", "172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.",
"172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31."
);
/**
* Returns a display-friendly region label for the given IP address.
*
* @param ip client IP string (IPv4 or IPv6)
* @return region label, e.g. "本地", "局域网", or "未知地区"
*/
public String getRegion(String ip) {
if (ip == null || ip.isBlank()) {
return "";
}
for (String prefix : LOCAL_PREFIXES) {
if (ip.startsWith(prefix)) {
return "本地";
}
}
for (String prefix : PRIVATE_PREFIXES) {
if (ip.startsWith(prefix)) {
return "局域网";
}
}
return lookupExternal(ip);
}
/**
* Override this method to integrate an offline IP database (e.g. ip2region).
* Default implementation returns "未知地区" as a placeholder.
*/
protected String lookupExternal(String ip) {
// TODO: integrate ip2region XDB for accurate province-level lookup
return "未知地区";
}
}
@@ -2,6 +2,7 @@ package com.yaoyuan.jiscuss.service.impl;
import com.yaoyuan.jiscuss.entity.Discussion;
import com.yaoyuan.jiscuss.repository.DiscussionsRepository;
import com.yaoyuan.jiscuss.repository.UsersRepository;
import com.yaoyuan.jiscuss.service.IDiscussionsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Example;
@@ -10,54 +11,62 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.transaction.Transactional;
import java.util.List;
@Service
@Transactional
public class DiscussionsServiceImpl implements IDiscussionsService {
@Autowired
private DiscussionsRepository discussionsRepository;
@Autowired
private UsersRepository usersRepository;
@Transactional(readOnly = true)
@Override
public List<Discussion> getAllList() {
return discussionsRepository.findAll();
}
@Transactional
@Override
public Discussion insert(Discussion discussion) {
return discussionsRepository.save(discussion);
Discussion saved = discussionsRepository.save(discussion);
// Maintain user discussions count
if (discussion.getCreateId() != null) {
usersRepository.incrementDiscussionCount(discussion.getCreateId());
}
return saved;
}
@Transactional(readOnly = true)
@Override
public Discussion findOne(Integer id) {
Discussion discussion = new Discussion();
discussion.setId(id);
return discussionsRepository.getOne(id);
return discussionsRepository.findById(id).orElse(null);
}
@Transactional(readOnly = true)
@Override
public Page<Discussion> queryAllDiscussionsList(Discussion discussion, int pageNum, int pageSize, String tag, String type) {
Sort sort = new Sort(Sort.Direction.DESC, "id");
if (type.equals("hot")) {
sort = new Sort(Sort.Direction.DESC, "likeCount");
} else if (type.equals("new")) {
sort = new Sort(Sort.Direction.DESC, "startTime");
}
@SuppressWarnings("deprecation")
Pageable pageable = new PageRequest(pageNum, pageSize, sort);
//将匹配对象封装成Example对象
Sort sort = switch (type) {
case "hot" -> Sort.by(Sort.Direction.DESC, "likeCount");
case "new" -> Sort.by(Sort.Direction.DESC, "startTime");
default -> Sort.by(Sort.Direction.DESC, "id");
};
Pageable pageable = PageRequest.of(pageNum, pageSize, sort);
Example<Discussion> example = Example.of(discussion);
if (null != tag && !"all".equals(tag)) {
Page<Discussion> pageList = discussionsRepository.findByQuery(tag, pageable);
return pageList;
if (tag != null && !"all".equals(tag)) {
return discussionsRepository.findByQuery(tag, pageable);
} else {
Page<Discussion> pageList = discussionsRepository.findAll(example, pageable);
return pageList;
return discussionsRepository.findAll(example, pageable);
}
}
@Transactional
@Override
public void incrementViewCount(Integer id) {
discussionsRepository.incrementViewCount(id);
}
}
@@ -6,21 +6,22 @@ import com.yaoyuan.jiscuss.service.IDiscussionsTagsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
public class DiscussionsTagsServiceImpl implements IDiscussionsTagsService {
@Autowired
private DiscussionsTagsRepository discussionsTagsRepository;
@Transactional(readOnly = true)
@Override
public List<DiscussionTag> getAllList() {
return discussionsTagsRepository.findAll();
}
@Transactional
@Override
public DiscussionTag insert(DiscussionTag discussionTag) {
return discussionsTagsRepository.save(discussionTag);
@@ -0,0 +1,119 @@
package com.yaoyuan.jiscuss.service.impl;
import com.yaoyuan.jiscuss.entity.LikeCollect;
import com.yaoyuan.jiscuss.entity.Post;
import com.yaoyuan.jiscuss.repository.DiscussionsRepository;
import com.yaoyuan.jiscuss.repository.LikeCollectRepository;
import com.yaoyuan.jiscuss.repository.PostsRepository;
import com.yaoyuan.jiscuss.service.ILikeCollectService;
import com.yaoyuan.jiscuss.service.INotificationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
import java.util.Optional;
@Service
public class LikeCollectServiceImpl implements ILikeCollectService {
@Autowired
private LikeCollectRepository likeCollectRepository;
@Autowired
private DiscussionsRepository discussionsRepository;
@Autowired
private PostsRepository postsRepository;
@Autowired
private INotificationService notificationService;
@Transactional
@Override
public String voteDiscussion(Integer userId, Integer discussionId, String action) {
Optional<LikeCollect> existing = likeCollectRepository.findDiscussionVote(userId, discussionId);
if (existing.isPresent()) {
LikeCollect vote = existing.get();
if (vote.getAction().equals(action)) {
// Toggle off: cancel the same action
likeCollectRepository.delete(vote);
adjustDiscussion(discussionId, action, -1);
return "none";
} else {
// Switch action: undo old, apply new
adjustDiscussion(discussionId, vote.getAction(), -1);
vote.setAction(action);
likeCollectRepository.save(vote);
adjustDiscussion(discussionId, action, +1);
return action;
}
} else {
// New vote
LikeCollect vote = new LikeCollect();
vote.setUserId(userId);
vote.setDiscussionId(discussionId);
vote.setType("discussion");
vote.setAction(action);
vote.setCreateId(userId);
vote.setCreateTime(new Date());
likeCollectRepository.save(vote);
adjustDiscussion(discussionId, action, +1);
return action;
}
}
@Transactional
@Override
public String votePost(Integer userId, Integer postId, String action) {
Optional<LikeCollect> existing = likeCollectRepository.findPostVote(userId, postId);
if (existing.isPresent()) {
LikeCollect vote = existing.get();
if (vote.getAction().equals(action)) {
likeCollectRepository.delete(vote);
adjustPost(postId, action, -1);
return "none";
} else {
adjustPost(postId, vote.getAction(), -1);
vote.setAction(action);
likeCollectRepository.save(vote);
adjustPost(postId, action, +1);
return action;
}
} else {
LikeCollect vote = new LikeCollect();
vote.setUserId(userId);
vote.setPostId(postId);
vote.setType("post");
vote.setAction(action);
vote.setCreateId(userId);
vote.setCreateTime(new Date());
likeCollectRepository.save(vote);
adjustPost(postId, action, +1);
// Notify post author when liked (not disliked)
if ("like".equals(action)) {
Post post = postsRepository.findById(postId).orElse(null);
if (post != null && post.getCreateId() != null) {
notificationService.notifyLike(post.getCreateId(), userId, postId, post.getDiscussionId());
}
}
return action;
}
}
private void adjustDiscussion(Integer id, String action, int delta) {
if ("like".equals(action)) {
discussionsRepository.adjustLikeCount(id, delta);
} else {
discussionsRepository.adjustDislikeCount(id, delta);
}
}
private void adjustPost(Integer id, String action, int delta) {
if ("like".equals(action)) {
postsRepository.adjustLikeCount(id, delta);
} else {
postsRepository.adjustDislikeCount(id, delta);
}
}
}
@@ -0,0 +1,85 @@
package com.yaoyuan.jiscuss.service.impl;
import com.yaoyuan.jiscuss.entity.Message;
import com.yaoyuan.jiscuss.repository.MessageRepository;
import com.yaoyuan.jiscuss.service.IMessageService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
import java.util.List;
@Service
public class MessageServiceImpl implements IMessageService {
@Autowired
private MessageRepository messageRepository;
private String buildConversationId(Integer a, Integer b) {
return Math.min(a, b) + "_" + Math.max(a, b);
}
@Transactional
@Override
public Message send(Integer senderId, Integer receiverId, String content) {
Message msg = new Message();
msg.setSenderId(senderId);
msg.setReceiverId(receiverId);
msg.setContent(content);
msg.setIsRead(false);
msg.setCreateTime(new Date());
msg.setConversationId(buildConversationId(senderId, receiverId));
return messageRepository.save(msg);
}
@Transactional(readOnly = true)
@Override
public List<Message> getConversation(Integer userId, Integer otherId) {
String convId = buildConversationId(userId, otherId);
List<Message> msgs = messageRepository.findByConversationIdOrderByCreateTimeDesc(convId);
// reverse to get oldest-first order
java.util.Collections.reverse(msgs);
return msgs;
}
@Transactional(readOnly = true)
@Override
public List<Message> getInbox(Integer userId) {
// Fetch sent and received separately, merge, then deduplicate by conversationId
List<Message> sent = messageRepository.findBySenderIdOrderByCreateTimeDesc(userId);
List<Message> received = messageRepository.findByReceiverIdOrderByCreateTimeDesc(userId);
// Merge both lists, sort by createTime descending
java.util.List<Message> all = new java.util.ArrayList<>();
all.addAll(sent);
all.addAll(received);
all.sort((a, b) -> {
if (a.getCreateTime() == null) return 1;
if (b.getCreateTime() == null) return -1;
return b.getCreateTime().compareTo(a.getCreateTime());
});
// Keep only the latest message per conversation
java.util.LinkedHashMap<String, Message> seen = new java.util.LinkedHashMap<>();
for (Message m : all) {
String cid = (m.getConversationId() != null && !m.getConversationId().isEmpty())
? m.getConversationId()
: Math.min(m.getSenderId(), m.getReceiverId()) + "_" + Math.max(m.getSenderId(), m.getReceiverId());
seen.putIfAbsent(cid, m);
}
return new java.util.ArrayList<>(seen.values());
}
@Transactional(readOnly = true)
@Override
public long countUnread(Integer userId) {
return messageRepository.countByReceiverIdAndIsReadFalse(userId);
}
@Transactional
@Override
public void markConversationRead(Integer userId, Integer otherId) {
messageRepository.markConversationReadForUser(buildConversationId(userId, otherId), userId);
}
}
@@ -0,0 +1,83 @@
package com.yaoyuan.jiscuss.service.impl;
import com.yaoyuan.jiscuss.entity.Notification;
import com.yaoyuan.jiscuss.repository.NotificationRepository;
import com.yaoyuan.jiscuss.service.INotificationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
@Service
public class NotificationServiceImpl implements INotificationService {
@Autowired
private NotificationRepository notificationRepository;
@Transactional
@Override
public Notification create(Integer toUserId, Integer fromUserId, String type, String title,
String content, Integer relatedId, String relatedType) {
// Do not notify yourself
if (toUserId != null && toUserId.equals(fromUserId)) {
return null;
}
Notification n = new Notification();
n.setUserId(toUserId);
n.setFromUserId(fromUserId);
n.setType(type);
n.setTitle(title);
n.setContent(content);
n.setRelatedId(relatedId);
n.setRelatedType(relatedType);
n.setIsRead(false);
n.setCreateTime(new Date());
return notificationRepository.save(n);
}
@Async
@Override
public void notifyReply(Integer toUserId, Integer fromUserId, Integer discussionId, String discussionTitle) {
create(toUserId, fromUserId, "reply",
"有人回复了你的帖子",
"在话题《" + discussionTitle + "》中回复了你",
discussionId, "discussion");
}
@Async
@Override
public void notifyLike(Integer toUserId, Integer fromUserId, Integer postId, Integer discussionId) {
create(toUserId, fromUserId, "like",
"有人赞了你的回复",
null, postId, "post");
}
@Transactional(readOnly = true)
@Override
public long countUnread(Integer userId) {
return notificationRepository.countByUserIdAndIsReadFalse(userId);
}
@Transactional(readOnly = true)
@Override
public Page<Notification> listByUser(Integer userId, int page, int size) {
return notificationRepository.findByUserIdOrderByCreateTimeDesc(
userId, PageRequest.of(page, size));
}
@Transactional
@Override
public void markAllRead(Integer userId) {
notificationRepository.markAllReadByUserId(userId);
}
@Transactional
@Override
public void markOneRead(Integer notificationId, Integer userId) {
notificationRepository.markReadById(notificationId, userId);
}
}
@@ -4,37 +4,48 @@ import com.yaoyuan.jiscuss.common.Node;
import com.yaoyuan.jiscuss.common.PostCommonUtil;
import com.yaoyuan.jiscuss.entity.Post;
import com.yaoyuan.jiscuss.entity.custom.PostCustom;
import com.yaoyuan.jiscuss.repository.DiscussionsRepository;
import com.yaoyuan.jiscuss.repository.PostsRepository;
import com.yaoyuan.jiscuss.repository.UsersRepository;
import com.yaoyuan.jiscuss.service.IPostsService;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Example;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Service
@Transactional
public class PostsServiceImpl implements IPostsService {
@Autowired
private PostsRepository postsRepository;
@Autowired
private DiscussionsRepository discussionsRepository;
@Autowired
private UsersRepository usersRepository;
@Transactional(readOnly = true)
@Override
public List<Post> getAllList() {
return postsRepository.findAll();
}
@Transactional(readOnly = true)
@Override
public List<Post> findOneBy(Integer id) {
List<Post> posts = postsRepository.findOneBy(id);
return posts;
}
@Transactional(readOnly = true)
@Override
public Post findOneByid(Integer id) {
Post post = new Post();
@@ -44,18 +55,21 @@ public class PostsServiceImpl implements IPostsService {
return postRes.get();
}
@Transactional(readOnly = true)
@Override
public List findPostCustomById(Integer id) {
//查询id为1且parentId为null的评论
List<Map<String, Object>> firstposts = postsRepository.findAllByDIdAndparentIdNull(id);
public List findPostCustomById(Integer id, String sort) {
// Choose sort order for top-level posts
List<Map<String, Object>> firstposts = switch (sort == null ? "newest" : sort) {
case "oldest" -> postsRepository.findAllByDIdAndparentIdNullOldest(id);
case "hot" -> postsRepository.findAllByDIdAndparentIdNullHot(id);
default -> postsRepository.findAllByDIdAndparentIdNullNewest(id); // newest (root only, create_time desc)
};
List<PostCustom> firstpostCustomList = PostCommonUtil.getNewPostsObjMap(firstposts);
// List<PostCustom> firstpostCustomListNew = PostCommonUtil.getNewPostsObjCustom(firstpostCustomList);
//查询id为1且parentId不为null的评论
List<Map<String, Object>> thenposts = postsRepository.findAllByDIdAndparentIdNotNull(id);
List<PostCustom> thenpostCustomList = PostCommonUtil.getNewPostsObjMap(thenposts);
// List<PostCustom> thenpostCustomListNew = PostCommonUtil.getNewPostsObjCustom(thenpostCustomList);
//新建一个Node集合。
ArrayList<Node> nodes = new ArrayList<>();
//将第一层评论都添加都Node集合中
@@ -66,18 +80,30 @@ public class PostsServiceImpl implements IPostsService {
}
//将回复添加到对应的位置
List list = Node.addAllNode(nodes, thenpostCustomList);
System.out.println();
//打印回复链表
Node.show(list);
// List<Map<String, Object>> posts = postsRepository.findPostCustomById(id);
// List<PostCustom> postCustomList = PostCommonUtil.getNewPostsObjMap(posts);
// List<PostCustom> postCustomListNew = PostCommonUtil.getNewPostsObjCustom(postCustomList);
return list;
}
@Transactional
@Override
public Post insert(Post post) {
return postsRepository.save(post);
// Auto-assign floor number within the same transaction to ensure consistency
if (post.getDiscussionId() != null) {
int nextNumber = postsRepository.findMaxNumberByDiscussionId(post.getDiscussionId()) + 1;
post.setNumber(nextNumber);
}
Post saved = postsRepository.save(post);
// Maintain discussion comment count and last activity
if (post.getDiscussionId() != null) {
discussionsRepository.incrementCommentCount(post.getDiscussionId(), post.getCreateId(), new Date());
}
// Maintain user comment count
if (post.getCreateId() != null) {
usersRepository.incrementCommentCount(post.getCreateId());
}
return saved;
}
}
@@ -6,20 +6,21 @@ import com.yaoyuan.jiscuss.service.ISettingsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
public class SettingsServiceImpl implements ISettingsService {
@Autowired
private SettingsRepository settingsRepository;
@Transactional(readOnly = true)
@Override
public List<Setting> getAllList() {
return settingsRepository.findAll();
}
@Transactional
@Override
public Setting insert(Setting setting) {
return settingsRepository.save(setting);
@@ -7,40 +7,45 @@ import com.yaoyuan.jiscuss.service.ITagsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
public class TagsServiceImpl implements ITagsService {
@Autowired
private TagsRepository tagsRepository;
@Transactional(readOnly = true)
@Override
public List<Tag> getAllList() {
return tagsRepository.findAllIsNull();
}
@Transactional(readOnly = true)
@Override
public List<Tag> getAllListDiscussions() {
return tagsRepository.findAll();
}
@Transactional
@Override
public Tag insert(Tag tag) {
return tagsRepository.save(tag);
}
@Transactional(readOnly = true)
@Override
public List<Tag> findByDId(Integer id) {
return tagsRepository.findByDId(id);
}
@Transactional(readOnly = true)
@Override
public List<TagCustom> findByDiscussionIdlistId(List<Integer> discussionIdLsit) {
return tagsRepository.findByDiscussionIdlistId(discussionIdLsit);
}
@Transactional(readOnly = true)
@Override
public List<Tag> findByParentId(String tag) {
int tagId = Integer.parseInt(tag);
@@ -2,59 +2,61 @@ package com.yaoyuan.jiscuss.service.impl;
import com.yaoyuan.jiscuss.entity.User;
import com.yaoyuan.jiscuss.entity.UserInfo;
import com.yaoyuan.jiscuss.repository.UserRoleRepository;
import com.yaoyuan.jiscuss.service.IUsersService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* @author yaoyuan2.chu
* @Title:
* @Package com.yaoyuan.jiscuss.service.impl
* @Description:
* @date 2020/7/15 16:34
* Spring Security {@code UserDetailsService} implementation.
*
* <p>Role assignment: users with {@code level >= 1} receive {@code ROLE_ADMIN};
* all others receive {@code ROLE_USER}.
*
* <p>Password note: passwords stored in the database MUST be BCrypt-hashed.
* See Flyway migration {@code V2__security_updates.sql} for the one-time migration
* that converts legacy plaintext passwords to BCrypt hashes and expands the column.
*/
@Component
public class UserDetailServiceImpl implements UserDetailsService {
@Autowired
private IUsersService userInfoService;
/**
* 需新建配置类注册一个指定的加密方式Bean,或在下一步Security配置类中注册指定
*/
@Autowired
private PasswordEncoder passwordEncoder;
private UserRoleRepository userRoleRepository;
@Override
public UserInfo loadUserByUsername(String username) throws UsernameNotFoundException {
// 通过用户名从数据库获取用户信息
User userInfo = userInfoService.getByUsername(username);
if (userInfo == null) {
throw new UsernameNotFoundException("用户不存在");
throw new UsernameNotFoundException("用户不存在: " + username);
}
// 得到用户角色
String role = "admin";
// 角色集合
List<GrantedAuthority> authorities = new ArrayList<>();
// 角色必须以`ROLE_`开头,数据库中没有,则在这里加
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
List<String> roleCodes = userRoleRepository.findRoleCodesByUserId(userInfo.getId());
if (roleCodes == null || roleCodes.isEmpty()) {
// Compatibility fallback for legacy data before RBAC migration.
String role = (userInfo.getLevel() != null && userInfo.getLevel() >= 1) ? "ADMIN" : "USER";
roleCodes = List.of(role);
}
for (String roleCode : roleCodes) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + roleCode));
}
return new UserInfo(
authorities,
userInfo.getId(),
// 因为数据库是明文,所以这里需加密密码
passwordEncoder.encode(userInfo.getPassword()),
userInfo.getPassword(), // Must be a BCrypt hash (see V2 migration)
userInfo.getUsername(),
userInfo.getPhone()
);
}
}
}
@@ -7,17 +7,17 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
public class UsersServiceImpl implements IUsersService {
@Autowired
@@ -28,7 +28,8 @@ public class UsersServiceImpl implements IUsersService {
*
* @return
*/
@Cacheable(value = "user")
@Transactional(readOnly = true)
@Cacheable(value = "userList")
@Override
public List<User> getAllList() {
return usersRepository.findAll();
@@ -41,11 +42,10 @@ public class UsersServiceImpl implements IUsersService {
* @param pageSize
* @return
*/
@Transactional(readOnly = true)
@Override
public Page<User> queryAllUsersList(int pageNum, int pageSize) {
Sort sort = new Sort(Sort.Direction.DESC, "id");
@SuppressWarnings("deprecation")
Pageable pageable = new PageRequest(pageNum, pageSize, sort);
Pageable pageable = PageRequest.of(pageNum, pageSize, Sort.by(Sort.Direction.DESC, "id"));
return usersRepository.findAll(pageable);
}
@@ -55,6 +55,7 @@ public class UsersServiceImpl implements IUsersService {
* @param name
* @return
*/
@Transactional(readOnly = true)
@Override
public List<User> getByUsernameIsLike(String name) {
return usersRepository.getByUsernameIsLike(name);
@@ -66,10 +67,11 @@ public class UsersServiceImpl implements IUsersService {
* @param id
* @return
*/
@Transactional(readOnly = true)
@Cacheable(value = "user", key = "#id")
@Override
public User findOne(Integer id) {
return usersRepository.getById(id);
return usersRepository.findById(id).orElse(null);
}
/**
@@ -78,7 +80,9 @@ public class UsersServiceImpl implements IUsersService {
* @param user
* @return
*/
@Transactional
@CachePut(value = "user", key = "#user.id")
@CacheEvict(value = "userList", allEntries = true)
@Override
public User insert(User user) {
return usersRepository.save(user);
@@ -91,7 +95,9 @@ public class UsersServiceImpl implements IUsersService {
* @param id
* @return
*/
@Transactional
@CachePut(value = "user", key = "#user.id")
@CacheEvict(value = "userList", allEntries = true)
@Override
public User update(User user, Integer id) {
return usersRepository.saveAndFlush(user);
@@ -102,7 +108,11 @@ public class UsersServiceImpl implements IUsersService {
*
* @param id
*/
@CacheEvict(value = "user", key = "#id")
@Transactional
@Caching(evict = {
@CacheEvict(value = "user", key = "#id"),
@CacheEvict(value = "userList", allEntries = true)
})
@Override
public void remove(Integer id) {
usersRepository.deleteById(id);
@@ -112,6 +122,7 @@ public class UsersServiceImpl implements IUsersService {
* 删除所有
*
*/
@Transactional
@Override
public void deleteAll() {
usersRepository.deleteAll();
@@ -123,23 +134,25 @@ public class UsersServiceImpl implements IUsersService {
* @param username
* @return
*/
@Transactional(readOnly = true)
@Override
public User getByUsername(String username) {
return usersRepository.getByUsername(username);
}
/**
* 验证用户名密码
*
* @param username
* @param password
* @return
*/
@Transactional(readOnly = true)
@Override
public User checkByUsernameAndPassword(String username, String password) {
return usersRepository.checkByUsernameAndPassword(username, password);
public List<User> findAllById(Iterable<Integer> ids) {
return usersRepository.findAllById(ids);
}
@Transactional(readOnly = true)
@Override
public List<User> getTopActiveUsers(int limit) {
return usersRepository.findTop10ActiveUsers(
org.springframework.data.domain.PageRequest.of(0, limit));
}
}
@@ -1,7 +1,7 @@
package com.yaoyuan.jiscuss.util;
/**
* @author yaoyuan2.chu
* @author Chuyaoyuan
* @Title: 去除内容页代码里的HTML标签
* @Package com.yaoyuan.jiscuss.util
* @Description:
@@ -49,9 +49,4 @@ public class DelTagsUtil {
return htmlStr;
}
public static void main(String[] args) {
String htmlStr = "test";
System.out.println(getTextFromHtml(htmlStr));
}
}
@@ -0,0 +1,81 @@
package com.yaoyuan.jiscuss.util;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Secure IP address extraction utility.
*
* <p>Defends against IP spoofing via forged {@code X-Forwarded-For} headers by:
* <ul>
* <li>Treating the first non-internal IP in the proxy chain as the client IP</li>
* <li>Falling back to {@code HttpServletRequest.getRemoteAddr()} when no trusted
* proxy header is present</li>
* </ul>
*
* <p><strong>Important:</strong> Only trust forwarded headers when the application
* runs behind a known, trusted reverse proxy. Consider configuring
* {@code server.forward-headers-strategy=NATIVE} or {@code FRAMEWORK} in
* {@code application.yml} to let Spring handle this automatically.
*/
public final class IpUtils {
private static final Logger log = LoggerFactory.getLogger(IpUtils.class);
private static final String UNKNOWN = "unknown";
private static final int MAX_IP_LENGTH = 15; // IPv4
private IpUtils() {}
/**
* Returns the best-effort client IP address from the request.
*
* @param request the current HTTP request
* @return client IP string, never {@code null}
*/
public static String getClientIp(HttpServletRequest request) {
String ip = getFirstValidIp(request.getHeader("X-Forwarded-For"));
if (isValid(ip)) return sanitize(ip);
ip = request.getHeader("X-Real-IP");
if (isValid(ip)) return sanitize(ip);
ip = request.getHeader("Proxy-Client-IP");
if (isValid(ip)) return sanitize(ip);
ip = request.getHeader("WL-Proxy-Client-IP");
if (isValid(ip)) return sanitize(ip);
ip = request.getRemoteAddr();
return ip != null ? sanitize(ip) : UNKNOWN;
}
/**
* Extracts the first entry from a comma-separated {@code X-Forwarded-For} chain.
* Returns {@code null} if the header is absent/empty/unknown.
*/
private static String getFirstValidIp(String header) {
if (header == null || header.isBlank() || UNKNOWN.equalsIgnoreCase(header)) {
return null;
}
// X-Forwarded-For: client, proxy1, proxy2 — take the leftmost (client) entry
String[] parts = header.split(",");
return parts[0].trim();
}
private static boolean isValid(String ip) {
return ip != null && !ip.isBlank() && !UNKNOWN.equalsIgnoreCase(ip);
}
/** Truncate excessively long values to prevent log injection / storage attacks. */
private static String sanitize(String ip) {
// Strip any characters that are not valid in IPv4/IPv6 addresses
String cleaned = ip.replaceAll("[^0-9a-fA-F:.\\[\\]]", "");
if (cleaned.length() > 45) { // max IPv6 length
log.warn("Suspiciously long IP value truncated: {}", ip);
return cleaned.substring(0, 45);
}
return cleaned;
}
}
@@ -0,0 +1,58 @@
package com.yaoyuan.jiscuss.util;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Lightweight User-Agent parser.
* Extracts a human-readable browser name and major version without
* importing a heavy third-party library.
*
* <p>Detection order matters: Edge must be checked before Chrome, and
* Chrome before Safari, because UA strings commonly contain multiple tokens.
*/
public final class UserAgentUtils {
private UserAgentUtils() {}
private static final Pattern EDGE = Pattern.compile("Edg(?:e|A|iOS)?/([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern FIREFOX = Pattern.compile("Firefox/([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern CHROME = Pattern.compile("Chrome/([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern SAFARI = Pattern.compile("Version/([\\d.]+).*Safari", Pattern.CASE_INSENSITIVE);
private static final Pattern OPERA = Pattern.compile("OPR/([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern IE = Pattern.compile("(?:MSIE |Trident/.*rv:)([\\d.]+)", Pattern.CASE_INSENSITIVE);
/**
* Returns a short browser description, e.g. "Chrome 120", "Firefox 121", "Safari 17".
* Returns "未知浏览器" when detection fails.
*
* @param userAgent the raw User-Agent header value
* @return display string, never null
*/
public static String parseBrowser(String userAgent) {
if (userAgent == null || userAgent.isBlank()) {
return "未知浏览器";
}
// Order matters: Edge contains 'Chrome', Chrome contains 'Safari'
String r;
if ((r = match(EDGE, userAgent, "Edge")) != null) return r;
if ((r = match(OPERA, userAgent, "Opera")) != null) return r;
if ((r = match(FIREFOX, userAgent, "Firefox")) != null) return r;
if ((r = match(CHROME, userAgent, "Chrome")) != null) return r;
if ((r = match(SAFARI, userAgent, "Safari")) != null) return r;
if ((r = match(IE, userAgent, "IE")) != null) return r;
return "未知浏览器";
}
private static String match(Pattern pattern, String ua, String name) {
Matcher m = pattern.matcher(ua);
if (m.find()) {
String version = m.group(1);
// Only keep major version number
int dotIdx = version.indexOf('.');
String major = dotIdx > 0 ? version.substring(0, dotIdx) : version;
return name + " " + major;
}
return null;
}
}
+40 -53
View File
@@ -1,76 +1,63 @@
#h2 配置
# ─────────────────────────────────────────────────────────────────────────────
# H2 / development profile.
# Activate with: --spring.profiles.active=h2
# ─────────────────────────────────────────────────────────────────────────────
spring:
cache:
type: ehcache
ehcache:
config: classpath:ehcache.xml
jpa:
generate-ddl: false
show-sql: true
# Flyway owns DDL. Disable Hibernate validation in H2 because H2 reports
# some MySQL-compatible legacy column types differently from MySQL/Hibernate.
hibernate:
ddl-auto: none
# H2 uses the same SQL dialect as MySQL in MySQL compatibility mode
# H2 console — enabled in dev ONLY.
# Access restricted to ROLE_ADMIN via Spring Security (see WebSecurityConfig).
# web-allow-others=false prevents remote access; rely on security for protection-in-depth.
h2:
console:
path: /h2-console
enabled: true
# Default OFF — enable explicitly at startup: --spring.h2.console.enabled=true
enabled: false
settings:
web-allow-others: true
web-allow-others: false
datasource:
platform: h2
url: jdbc:h2:~/testjiscuss
# H2 2.x reserves USER/VALUE; keep them usable for the legacy schema names.
url: jdbc:h2:~/testjiscuss;MODE=MySQL;NON_KEYWORDS=VALUE,USER
username: sa
password:
schema: classpath:schema.sql
data: classpath:data.sql
driver-class-name: org.h2.Driver
type: com.alibaba.druid.pool.DruidDataSource
druid:
min-idle: 2
initial-size: 5
max-active: 10
max-wait: 5000
validation-query: select 1SS
# 状态监控
min-idle: 1
initial-size: 2
max-active: 5
max-wait: 3000
validation-query: SELECT 1
filter:
stat:
enabled: true
db-type: h2
log-slow-sql: true
slow-sql-millis: 2000
# 监控过滤器
enabled: true
db-type: h2
log-slow-sql: true
slow-sql-millis: 1000
web-stat-filter:
enabled: true
exclusions:
- "*.js"
- "*.gif"
- "*.jpg"
- "*.png"
- "*.css"
- "*.ico"
- "/druid/*"
# druid 监控页面
exclusions: "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*"
stat-view-servlet:
enabled: true
url-pattern: /druid/*
# reset-enable: false
# login-username: root
# login-password: root
freemarker:
# 设置模板后缀名
suffix: .ftl
# 设置文档类型
content-type: text/html
# 设置页面编码格式
charset: UTF-8
# 设置页面缓存
cache: false
# 设置ftl文件路径
template-loader-path:
- classpath:/templates
settings:
classic_compatible: true
# 设置静态文件路径,js,css等
mvc:
static-path-pattern: /static/**
# Druid UI gated by Spring Security ROLE_ADMIN; built-in basic auth disabled
reset-enable: false
allow: 127.0.0.1
# Flyway: run V1 + V2 migrations on H2 in-memory DB on startup
flyway:
locations: classpath:db/migration
url: ${spring.datasource.url}
user: ${spring.datasource.username}
password: ${spring.datasource.password}
# Override default port for dev profile
server:
port: 80
port: 80
+40 -52
View File
@@ -1,69 +1,57 @@
#mysql 配置
# ─────────────────────────────────────────────────────────────────────────────
# MySQL / production profile.
# Activate with: --spring.profiles.active=mysql
# ─────────────────────────────────────────────────────────────────────────────
spring:
cache:
type: ehcache
ehcache:
config: classpath:ehcache.xml
jpa:
database: MYSQL
show-sql: true
show-sql: false
hibernate:
ddl-auto: update
# Flyway owns all DDL; Hibernate only validates
ddl-auto: validate
# H2 console is OFF in production
h2:
console:
enabled: false
datasource:
url: jdbc:mysql://127.0.0.1:3306/jiscuss?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&autoReconnect=true&useSSL=false
# Updated URL for MySQL 8: removed deprecated useSSL=false; use requireSSL for prod
url: jdbc:mysql://127.0.0.1:3306/jiscuss?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.jdbc.Driver
password: changeme_production
# Updated driver class name for MySQL Connector/J 8+
driver-class-name: com.mysql.cj.jdbc.Driver
type: com.alibaba.druid.pool.DruidDataSource
tomcat:
init-s-q-l: SET NAMES utf8mb4
druid:
min-idle: 2
initial-size: 5
max-active: 10
min-idle: 5
initial-size: 10
max-active: 50
max-wait: 5000
validation-query: select 1SS
# 状态监控
validation-query: SELECT 1
filter:
stat:
enabled: true
db-type: mysql
log-slow-sql: true
slow-sql-millis: 2000
# 监控过滤器
enabled: true
db-type: mysql
log-slow-sql: true
slow-sql-millis: 2000
web-stat-filter:
enabled: true
exclusions:
- "*.js"
- "*.gif"
- "*.jpg"
- "*.png"
- "*.css"
- "*.ico"
- "/druid/*"
# druid 监控页面
exclusions: "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*"
stat-view-servlet:
enabled: true
url-pattern: /druid/*
# reset-enable: false
# login-username: root
# login-password: root
freemarker:
# 设置模板后缀名
suffix: .ftl
# 设置文档类型
content-type: text/html
# 设置页面编码格式
charset: UTF-8
# 设置页面缓存
cache: false
# 设置ftl文件路径
template-loader-path:
- classpath:/templates
settings:
classic_compatible: true
# 设置静态文件路径,js,css等
mvc:
static-path-pattern: /static/**
reset-enable: false
# Druid UI is gated by Spring Security ROLE_ADMIN; built-in basic auth disabled
allow: 127.0.0.1
# Flyway for MySQL
flyway:
locations: classpath:db/migration
url: ${spring.datasource.url}
user: ${spring.datasource.username}
password: ${spring.datasource.password}
server:
port: 80
port: 80
forward-headers-strategy: FRAMEWORK
@@ -0,0 +1,3 @@
# Activate h2 (dev) profile by default.
# Override via --spring.profiles.active=mysql for production.
spring.profiles.active=h2
+48 -59
View File
@@ -1,76 +1,65 @@
#h2 配置
# ─────────────────────────────────────────────────────────────────────────────
# Default configuration — shared across all profiles.
# Profile-specific overrides live in application-h2.yml and application-mysql.yml.
# ─────────────────────────────────────────────────────────────────────────────
spring:
# Ehcache 3 via JCache (JSR-107) — replaces Ehcache 2 (net.sf.ehcache)
cache:
type: ehcache
ehcache:
config: classpath:ehcache.xml
type: jcache
jcache:
config: classpath:ehcache3.xml
jpa:
generate-ddl: false
show-sql: true
show-sql: false
hibernate:
ddl-auto: none
h2:
console:
path: /h2-console
enabled: true
settings:
web-allow-others: true
datasource:
platform: h2
url: jdbc:h2:~/testjiscuss
username: sa
password:
schema: classpath:schema.sql
data: classpath:data.sql
driver-class-name: org.h2.Driver
type: com.alibaba.druid.pool.DruidDataSource
druid:
min-idle: 2
initial-size: 5
max-active: 10
max-wait: 5000
validation-query: select 1SS
# 状态监控
filter:
stat:
enabled: true
db-type: h2
log-slow-sql: true
slow-sql-millis: 2000
# 监控过滤器
web-stat-filter:
enabled: true
exclusions:
- "*.js"
- "*.gif"
- "*.jpg"
- "*.png"
- "*.css"
- "*.ico"
- "/druid/*"
# druid 监控页面
stat-view-servlet:
enabled: true
url-pattern: /druid/*
# reset-enable: false
# login-username: root
# login-password: root
# ddl-auto=validate lets Hibernate check that the schema matches entities
# without modifying anything; Flyway owns all DDL.
ddl-auto: validate
freemarker:
# 设置模板后缀名
suffix: .ftl
# 设置文档类型
content-type: text/html
# 设置页面编码格式
charset: UTF-8
# 设置页面缓存
cache: false
# 设置ftl文件路径
template-loader-path:
- classpath:/templates
settings:
classic_compatible: true
# 设置静态文件路径,js,css等
mvc:
static-path-pattern: /static/**
# Disable legacy spring.datasource.schema / spring.datasource.data initialisation;
# Flyway handles all schema setup in db/migration/.
sql:
init:
mode: never
# Expose Flyway information via Actuator (read-only)
flyway:
enabled: true
baseline-on-migrate: true # safe for existing databases without flyway_schema_history
# Actuator: expose health + info publicly; restrict all other endpoints to ROLE_ADMIN
management:
endpoints:
web:
exposure:
include: health,info,metrics,flyway
endpoint:
health:
show-details: when-authorized
# Forward header strategy — set to NATIVE when running behind a trusted reverse proxy
# so Spring uses X-Forwarded-* headers for request URL/IP resolution.
server:
port: 80
port: 80
forward-headers-strategy: NONE # change to NATIVE behind a trusted proxy
# SpringDoc OpenAPI — UI at /swagger-ui/index.html
springdoc:
swagger-ui:
path: /swagger-ui.html
api-docs:
path: /v3/api-docs
-77
View File
@@ -1,77 +0,0 @@
insert into user
values (1, 'admin', '管理员', 'as_2583698@sina.com', '123', '2019-09-09 00:00:00', 31, '', '', '13804250293', 0, 0,
'2019-09-09 00:00:00', 1, 1);
insert into user
values (2, 'test', '测试用户1', 'as_2583698@sina.com', '123', '2019-09-09 00:00:00', 31, '', '', '13804250293', 0, 0,
'2019-09-09 00:00:00', 1, 0);
insert into discussion
values (1, '测试主题1', '测试内容1', null, null, null, '2020-09-19 00:00:00', 1, null, '2020-09-29 00:00:00', 2, null, null,
null, null, null, 1, '2020-09-09 00:00:00');
insert into discussion
values (2, '测试主题2', '测试内容2', null, null, null, null, null, null, null, 1, null, null, null, null, null, null, null);
insert into discussion
values (3, '测试主题3', '测试内容3', null, null, null, null, null, null, null, 1, null, null, null, null, null, null, null);
insert into discussion
values (4, '测试主题4', '测试内容4', null, null, null, null, null, null, null, 1, null, null, null, null, null, null, null);
insert into discussion
values (5, '测试主题5', '测试内容2测试内容2测试内容2测试内容2测试内容2测试内容2', null, null, null, null, null, null, null, 1, null, null, null,
null, null, null, null);
insert into discussion
values (6, '测试主题6', '测试内容3测试测试测试测试测试测试测试测试测试测试测试测试测试', null, null, null, null, null, null, null, 1, null, null, null,
null, null, null, null);
insert into discussion
values (7, '测试主题7', '测试内容7', null, null, null, null, null, null, null, 1, null, null, null, null, null, null, null);
insert into discussion
values (8, '测试主题8', '测试内容8', null, null, null, null, null, null, null, 1, null, null, null, null, null, null, null);
insert into discussion
values (9, '测试主题9', '测试内容9', null, null, null, null, null, null, null, 1, null, null, null, null, null, null, null);
insert into discussion
values (10, '测试主题10', '测试内容113', null, null, null, null, null, null, null, 1, null, null, null, null, null, null, null);
insert into discussion
values (11, '测试主题11', '测试内容3测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', null, null, null, null, null, null, null, 1,
null, null, null, null, null, null, null);
insert into tag
values (1, '测试标签1', null, null, 'edit', null, null, null, null, null, null, null);
insert into post
values (1, 1, 1, '2020-02-09 00:00:00', 1, null, '评论内容222', null, null, null, null, null, null, 1,
'2020-08-09 00:00:00');
insert into post
values (2, 1, 2, '2020-01-09 00:00:00', 2, null, '评论内容333', null, null, 1, null, null, null, 2, '2020-07-09 00:00:00');
insert into post
values (7, 1, 7, '2020-01-09 00:00:00', 1, null, '评论内容3331111', null, null, 1, null, null, null, 1,
'2020-03-09 00:00:00');
insert into post
values (3, 1, 3, '2020-01-09 00:00:00', 1, 1, '评论内容444', null, null, 1, null, null, null, 1, '2020-02-09 00:00:00');
insert into post
values (4, 1, 4, '2020-01-09 00:00:00', 2, 1, '评论内容555', null, null, null, null, null, null, 2, '2020-03-09 00:00:00');
insert into post
values (5, 1, 5, '2020-01-09 00:00:00', 2, null, '评论内容666', null, null, 1, null, null, null, 2, '2020-09-09 00:00:00');
insert into post
values (6, 1, 6, '2020-01-09 00:00:00', 1, null, '评论内容777', null, null, 5, null, null, null, 1, '2020-09-09 00:00:00');
@@ -0,0 +1,144 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- V1: Initial schema — mirrors the existing schema.sql / data.sql.
-- Managed by Flyway; do NOT modify after first deployment.
-- Subsequent schema changes belong in V2, V3, ...
-- ─────────────────────────────────────────────────────────────────────────────
-- User table
CREATE TABLE IF NOT EXISTS user
(
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50),
realname VARCHAR(50),
email VARCHAR(100),
password VARCHAR(255), -- must hold BCrypt hashes (>=68 chars); V2 migrates existing data
join_time DATETIME,
age INTEGER,
avatar TEXT,
gender CHAR(10),
phone VARCHAR(20),
discussions_count INTEGER,
comments_count INTEGER,
last_seen_time DATETIME,
flag INTEGER,
level INTEGER
);
-- Discussion (thread) table
CREATE TABLE IF NOT EXISTS discussion
(
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(200),
content TEXT,
comments_count INTEGER,
participants_count INTEGER,
number_index INTEGER,
start_time DATETIME,
start_user_id INTEGER,
start_post_id INTEGER,
last_time DATETIME,
last_user_id INTEGER,
last_post_id INTEGER,
last_post_number INTEGER,
is_approved INTEGER,
like_count INTEGER,
ip_address VARCHAR(200),
create_id INTEGER,
create_time DATETIME
);
-- Discussion ↔ Tag association table
CREATE TABLE IF NOT EXISTS discussiontag
(
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
discussion_id INTEGER NOT NULL,
tag_id INTEGER
);
-- Post (reply/comment) table
CREATE TABLE IF NOT EXISTS post
(
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
discussion_id INTEGER,
number INTEGER,
time DATETIME,
user_id INTEGER,
type VARCHAR(20),
content TEXT,
edit_time DATETIME,
edit_user_id INTEGER,
parent_id INTEGER,
ip_address VARCHAR(200),
copyright VARCHAR(200),
is_approved INTEGER,
create_id INTEGER,
create_time DATETIME
);
-- Settings table
CREATE TABLE IF NOT EXISTS setting
(
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
setting_key VARCHAR(50),
setting_value TEXT
);
-- Tag table
CREATE TABLE IF NOT EXISTS tag
(
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(200),
description VARCHAR(200),
color VARCHAR(200),
icon VARCHAR(200),
position INTEGER,
parent_id INTEGER,
discussions_count TEXT,
last_time DATETIME,
last_discussion_id INTEGER,
create_id INTEGER,
create_time DATETIME
);
-- Like / Collect table
CREATE TABLE IF NOT EXISTS likecollect
(
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
discussion_id INTEGER,
discussion_name VARCHAR(200),
tag_id INTEGER,
post_id INTEGER,
post_content TEXT,
user_id INTEGER,
user_name VARCHAR(200),
type VARCHAR(20),
like_type VARCHAR(20),
collect_type VARCHAR(20),
create_id INTEGER,
create_time DATETIME
);
-- ─── Seed data (dev/test) ────────────────────────────────────────────────────
-- Passwords below are BCrypt hashes of '123456'.
-- Generated with: BCrypt.encode("123456")
-- DO NOT use these credentials in production.
INSERT INTO user (id, username, realname, email, password, join_time, age, avatar, gender, phone,
discussions_count, comments_count, last_seen_time, flag, level)
VALUES (1, 'admin', '管理员', 'admin@jiscuss.local',
'$2a$10$7EqJtq98hPqEX7fNZaFWoOziYXMEIUBm..wTkPE3bsUJn4Lm7oHVC',
'2019-09-09 00:00:00', 31, '', '', '13800000001', 0, 0, '2019-09-09 00:00:00', 1, 1);
INSERT INTO user (id, username, realname, email, password, join_time, age, avatar, gender, phone,
discussions_count, comments_count, last_seen_time, flag, level)
VALUES (2, 'test', '测试用户', 'test@jiscuss.local',
'$2a$10$7EqJtq98hPqEX7fNZaFWoOziYXMEIUBm..wTkPE3bsUJn4Lm7oHVC',
'2019-09-09 00:00:00', 31, '', '', '13800000002', 0, 0, '2019-09-09 00:00:00', 1, 0);
INSERT INTO discussion (id, title, content, start_time, start_user_id, last_time, last_user_id, create_id, create_time)
VALUES (1, '测试主题1', '测试内容1', '2020-09-19 00:00:00', 1, '2020-09-29 00:00:00', 2, 1, '2020-09-09 00:00:00');
INSERT INTO tag (id, name, icon, position)
VALUES (1, '测试标签1', 'edit', 1);
INSERT INTO post (id, discussion_id, number, time, user_id, content, create_id, create_time)
VALUES (1, 1, 1, '2020-02-09 00:00:00', 1, '评论内容222', 1, '2020-08-09 00:00:00');
@@ -0,0 +1,33 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- V2: Security hardening — password column expansion + BCrypt migration.
--
-- BACKGROUND:
-- The original schema used VARCHAR(20) for the password column, which is too
-- short to store BCrypt hashes (6068 characters). This migration:
-- 1. Expands the column to VARCHAR(255).
-- 2. Provides UPDATE statements to replace any remaining plaintext passwords
-- with BCrypt hashes (useful when applying this migration to an existing
-- database that has not yet been migrated).
--
-- NOTE: The example BCrypt hash below encodes the string '123456'.
-- For a real production migration, generate individual hashes per user
-- using a one-off script and run this migration during a maintenance window.
-- ─────────────────────────────────────────────────────────────────────────────
-- Step 1: Expand the password column so it can hold BCrypt hashes
ALTER TABLE user
MODIFY COLUMN password VARCHAR(255);
-- Step 2: Identify and log any users whose passwords look like plaintext
-- (BCrypt hashes always start with '$2a$' or '$2b$')
-- Run this SELECT manually before Step 3 to audit affected rows:
--
-- SELECT id, username FROM user WHERE password NOT LIKE '$2%';
-- Step 3: Replace all non-BCrypt passwords with the hash of a known reset value
-- ('changeme123' hashed below) and force a password reset via application logic.
-- Replace the hash value with one generated by your BCryptPasswordEncoder.
--
-- UPDATE user
-- SET password = '$2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
-- WHERE password NOT LIKE '$2%';
@@ -0,0 +1,7 @@
-- Reset development seed account passwords to a known BCrypt value.
-- Plaintext password for both seeded accounts: 123456
-- Do not use these credentials in production.
UPDATE user
SET password = '$2a$10$R4prDK/CfI0Hjrcz5dS8cOHClOIHIHlk8SoGmVY1iGWYSPxBGo7nm'
WHERE username IN ('admin', 'test');
@@ -0,0 +1,67 @@
-- V4: RBAC foundation + admin upgrade logs (Spring Boot 3 optimization)
CREATE TABLE IF NOT EXISTS rbac_role
(
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
description VARCHAR(255),
enabled INTEGER NOT NULL DEFAULT 1,
create_time DATETIME,
update_time DATETIME
);
CREATE TABLE IF NOT EXISTS rbac_user_role
(
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
user_id INTEGER NOT NULL,
role_id INTEGER NOT NULL,
create_time DATETIME
);
CREATE TABLE IF NOT EXISTS upgrade_log
(
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
version VARCHAR(50) NOT NULL,
title VARCHAR(200) NOT NULL,
content TEXT,
type VARCHAR(50),
created_by INTEGER,
create_time DATETIME
);
INSERT INTO rbac_role (code, name, description, enabled, create_time, update_time)
SELECT 'ADMIN', '管理员', '后台管理与运维权限', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM rbac_role WHERE code = 'ADMIN');
INSERT INTO rbac_role (code, name, description, enabled, create_time, update_time)
SELECT 'USER', '普通用户', '论坛基础访问权限', 1, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM rbac_role WHERE code = 'USER');
INSERT INTO rbac_user_role (user_id, role_id, create_time)
SELECT u.id, r.id, NOW()
FROM user u
JOIN rbac_role r ON r.code = 'USER'
WHERE NOT EXISTS (
SELECT 1 FROM rbac_user_role ur WHERE ur.user_id = u.id AND ur.role_id = r.id
);
INSERT INTO rbac_user_role (user_id, role_id, create_time)
SELECT u.id, r.id, NOW()
FROM user u
JOIN rbac_role r ON r.code = 'ADMIN'
WHERE u.level >= 1
AND NOT EXISTS (
SELECT 1 FROM rbac_user_role ur WHERE ur.user_id = u.id AND ur.role_id = r.id
);
INSERT INTO upgrade_log (version, title, content, type, created_by, create_time)
SELECT 'v3.5.13',
'后台管理与RBAC架构初始化',
'新增角色与用户角色映射;新增后台管理与运维面板;新增升级日志管理入口。',
'feature',
1,
NOW()
WHERE NOT EXISTS (
SELECT 1 FROM upgrade_log WHERE version = 'v3.5.13' AND title = '后台管理与RBAC架构初始化'
);
@@ -0,0 +1,17 @@
-- V5: Audit log for admin operations (Spring Boot 3 optimization)
CREATE TABLE IF NOT EXISTS audit_log
(
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
admin_id INTEGER,
admin_name VARCHAR(100),
action_type VARCHAR(50) NOT NULL,
target_type VARCHAR(50),
target_id INTEGER,
description VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'success',
create_time DATETIME
);
CREATE INDEX idx_audit_admin_time ON audit_log(admin_id, create_time DESC);
CREATE INDEX idx_audit_target ON audit_log(target_type, target_id);
@@ -0,0 +1,26 @@
-- V6: Add indexes for high-frequency query columns.
-- All indexes use IF NOT EXISTS for idempotent migrations.
-- post table: most reads filter/sort by discussion_id, parent_id, create_time
CREATE INDEX IF NOT EXISTS idx_post_discussion_id ON post(discussion_id);
CREATE INDEX IF NOT EXISTS idx_post_parent_id ON post(parent_id);
CREATE INDEX IF NOT EXISTS idx_post_create_time ON post(create_time);
CREATE INDEX IF NOT EXISTS idx_post_create_id ON post(create_id);
-- discussion table: sorted by id/create_time/start_time/like_count; joined on start_user_id/last_user_id
CREATE INDEX IF NOT EXISTS idx_discussion_create_time ON discussion(create_time);
CREATE INDEX IF NOT EXISTS idx_discussion_start_time ON discussion(start_time);
CREATE INDEX IF NOT EXISTS idx_discussion_like_count ON discussion(like_count);
CREATE INDEX IF NOT EXISTS idx_discussion_start_user_id ON discussion(start_user_id);
CREATE INDEX IF NOT EXISTS idx_discussion_last_user_id ON discussion(last_user_id);
-- discussiontag table: used in tag-filter queries and cascade deletes
CREATE INDEX IF NOT EXISTS idx_discussiontag_discussion_id ON discussiontag(discussion_id);
CREATE INDEX IF NOT EXISTS idx_discussiontag_tag_id ON discussiontag(tag_id);
-- user table: login and username-lookup path
CREATE INDEX IF NOT EXISTS idx_user_username ON user(username);
-- likecollect table: user activity queries
CREATE INDEX IF NOT EXISTS idx_likecollect_user_id ON likecollect(user_id);
CREATE INDEX IF NOT EXISTS idx_likecollect_discussion_id ON likecollect(discussion_id);
@@ -0,0 +1,5 @@
-- V7: Add view_count to discussion table.
-- Tracks how many times a discussion has been viewed.
ALTER TABLE discussion ADD COLUMN IF NOT EXISTS view_count INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_discussion_view_count ON discussion(view_count);
@@ -0,0 +1,19 @@
-- V8: Add vote (like/dislike) support and IP/browser meta to posts
-- likecollect: add explicit action column ('like' or 'dislike')
ALTER TABLE likecollect ADD COLUMN action VARCHAR(20);
-- discussion: add dislike counter
ALTER TABLE discussion ADD COLUMN dislike_count INTEGER DEFAULT 0;
-- post: add per-post vote counters
ALTER TABLE post ADD COLUMN like_count INTEGER DEFAULT 0;
ALTER TABLE post ADD COLUMN dislike_count INTEGER DEFAULT 0;
-- indexes for vote lookup
CREATE INDEX IF NOT EXISTS idx_likecollect_user_discussion ON likecollect(user_id, discussion_id);
CREATE INDEX IF NOT EXISTS idx_likecollect_user_post ON likecollect(user_id, post_id);
-- post: add IP region and parsed browser name
ALTER TABLE post ADD COLUMN ip_region VARCHAR(100);
ALTER TABLE post ADD COLUMN browser VARCHAR(200);
@@ -0,0 +1,33 @@
-- V9: Add notification and private message support
-- Notification table: system/reply/like alerts to a user
CREATE TABLE IF NOT EXISTS notification (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL, -- receiver
from_user_id INT, -- sender (NULL for system notifications)
type VARCHAR(50) NOT NULL, -- 'reply', 'like', 'system'
title VARCHAR(200),
content TEXT,
related_id INT, -- e.g. discussion_id or post_id
related_type VARCHAR(50), -- 'discussion', 'post'
is_read BOOLEAN DEFAULT FALSE,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_notification_user ON notification(user_id);
CREATE INDEX IF NOT EXISTS idx_notification_user_unread ON notification(user_id, is_read);
-- Message table: private messages between users
CREATE TABLE IF NOT EXISTS message (
id INT PRIMARY KEY AUTO_INCREMENT,
sender_id INT NOT NULL,
receiver_id INT NOT NULL,
content TEXT NOT NULL,
is_read BOOLEAN DEFAULT FALSE,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
conversation_id VARCHAR(50) NOT NULL -- format: min_max of user IDs
);
CREATE INDEX IF NOT EXISTS idx_message_receiver ON message(receiver_id);
CREATE INDEX IF NOT EXISTS idx_message_conversation ON message(conversation_id, create_time);
CREATE INDEX IF NOT EXISTS idx_message_receiver_unread ON message(receiver_id, is_read);
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Ehcache 3 configuration (JCache / JSR-107 format).
Referenced by: spring.cache.jcache.config=classpath:ehcache3.xml
Two caches:
"user" — single User entities keyed by ID (e.g. @Cacheable(value="user", key="#id"))
"userList" — full user list (e.g. @Cacheable(value="userList"))
TTI (time-to-idle) evicts entries not accessed within the specified window,
preventing stale data accumulation and cache stampede from eternal entries.
-->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.ehcache.org/v3"
xsi:schemaLocation="http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core-3.0.xsd">
<!-- Single user entity cache: up to 5 000 entries, evict if idle for 5 minutes -->
<cache alias="user">
<expiry>
<tti unit="minutes">5</tti>
</expiry>
<heap unit="entries">5000</heap>
</cache>
<!-- Full user-list cache: small number of keys, evict after 2 minutes (reduces stampede risk) -->
<cache alias="userList">
<expiry>
<ttl unit="minutes">2</ttl>
</expiry>
<heap unit="entries">10</heap>
</cache>
</config>
-115
View File
@@ -1,115 +0,0 @@
-- 用户表1
drop table if exists user;
CREATE TABLE user
(
id INTEGER not null primary key auto_increment,
username varchar(20),
realname varchar(20),
email varchar(20),
password varchar(20),
join_time datetime,
age INTEGER,
avatar text,
gender char(20),
phone char(20),
discussions_count INTEGER,
comments_count INTEGER,
last_seen_time datetime,
flag INTEGER,
level INTEGER
);
-- 主题表2
drop table if exists discussion;
CREATE TABLE discussion
(
id INTEGER not null primary key auto_increment,
title varchar(200),
content text,
comments_count INTEGER,
participants_count INTEGER,
number_index INTEGER,
start_time datetime,
start_user_id INTEGER,
start_post_id INTEGER,
last_time datetime,
last_user_id INTEGER,
last_post_id INTEGER,
last_post_number INTEGER,
is_approved INTEGER,
like_count INTEGER,
ip_address varchar(200),
create_id INTEGER,
create_time datetime
);
-- 主题标签关联表3
drop table if exists discussiontag;
CREATE TABLE discussiontag
(
id INTEGER not null primary key auto_increment,
discussion_id INTEGER not null,
tag_id INTEGER
);
-- 评论表4
drop table if exists post;
CREATE TABLE post
(
id INTEGER not null primary key auto_increment,
discussion_id INTEGER,
number INTEGER,
time datetime,
user_id INTEGER,
type varchar(20),
content text,
edit_time datetime,
edit_user_id INTEGER,
parent_id INTEGER,
ip_address varchar(200),
copyright varchar(200),
is_approved INTEGER,
create_id INTEGER,
create_time datetime
);
-- 设置表5
drop table if exists setting;
CREATE TABLE setting
(
id INTEGER not null primary key auto_increment,
setting_key varchar(20),
setting_value text
);
-- 标签表6
drop table if exists tag;
CREATE TABLE tag
(
id INTEGER not null primary key auto_increment,
name varchar(200),
description varchar(200),
color varchar(200),
icon varchar(200),
position INTEGER,
parent_id INTEGER,
discussions_count text,
last_time datetime,
last_discussion_id INTEGER,
create_id INTEGER,
create_time datetime
);
-- 喜欢收藏表7
drop table if exists likecollect;
CREATE TABLE likecollect
(
id INTEGER not null primary key auto_increment,
discussion_id INTEGER,
discussion_name varchar(200),
tag_id INTEGER,
post_id INTEGER,
post_content text,
user_id INTEGER,
user_name varchar(200),
type varchar(20),
like_type varchar(20),
collect_type varchar(20),
create_id INTEGER,
create_time datetime
);
@@ -21,7 +21,8 @@ $("#newdiscussions").click(function () {
url: "/newdiscussions",
data: JSON.stringify({
title: title,
content: content
content: content,
tag: Array.isArray(tag) ? tag.join(",") : tag
}),
contentType: 'application/json',
beforeSend: function (request) {
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
#nprogress{pointer-events:none}#nprogress .bar{background:#29d;position:fixed;z-index:1031;top:0;left:0;width:100%;height:2px}#nprogress .peg{display:block;position:absolute;right:0;width:100px;height:100%;box-shadow:0 0 10px #29d,0 0 5px #29d;opacity:1;-webkit-transform:rotate(3deg) translate(0,-4px);-ms-transform:rotate(3deg) translate(0,-4px);transform:rotate(3deg) translate(0,-4px)}#nprogress .spinner{display:block;position:fixed;z-index:1031;top:15px;right:15px}#nprogress .spinner-icon{width:18px;height:18px;box-sizing:border-box;border:2px solid transparent;border-top-color:#29d;border-left-color:#29d;border-radius:50%;-webkit-animation:nprogress-spinner 400ms linear infinite;animation:nprogress-spinner 400ms linear infinite}.nprogress-custom-parent{overflow:hidden;position:relative}.nprogress-custom-parent #nprogress .bar,.nprogress-custom-parent #nprogress .spinner{position:absolute}@-webkit-keyframes nprogress-spinner{0%{-webkit-transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg)}}@keyframes nprogress-spinner{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}
+1
View File
@@ -0,0 +1 @@
!function(n,e){"function"==typeof define&&define.amd?define(e):"object"==typeof exports?module.exports=e():n.NProgress=e()}(this,function(){function n(n,e,t){return e>n?e:n>t?t:n}function e(n){return 100*(-1+n)}function t(n,t,r){var i;return i="translate3d"===c.positionUsing?{transform:"translate3d("+e(n)+"%,0,0)"}:"translate"===c.positionUsing?{transform:"translate("+e(n)+"%,0)"}:{"margin-left":e(n)+"%"},i.transition="all "+t+"ms "+r,i}function r(n,e){var t="string"==typeof n?n:o(n);return t.indexOf(" "+e+" ")>=0}function i(n,e){var t=o(n),i=t+e;r(t,e)||(n.className=i.substring(1))}function s(n,e){var t,i=o(n);r(n,e)&&(t=i.replace(" "+e+" "," "),n.className=t.substring(1,t.length-1))}function o(n){return(" "+(n.className||"")+" ").replace(/\s+/gi," ")}function a(n){n&&n.parentNode&&n.parentNode.removeChild(n)}var u={};u.version="0.2.0";var c=u.settings={minimum:.08,easing:"ease",positionUsing:"",speed:200,trickle:!0,trickleRate:.02,trickleSpeed:800,showSpinner:!0,barSelector:'[role="bar"]',spinnerSelector:'[role="spinner"]',parent:"body",template:'<div class="bar" role="bar"><div class="peg"></div></div><div class="spinner" role="spinner"><div class="spinner-icon"></div></div>'};u.configure=function(n){var e,t;for(e in n)t=n[e],void 0!==t&&n.hasOwnProperty(e)&&(c[e]=t);return this},u.status=null,u.set=function(e){var r=u.isStarted();e=n(e,c.minimum,1),u.status=1===e?null:e;var i=u.render(!r),s=i.querySelector(c.barSelector),o=c.speed,a=c.easing;return i.offsetWidth,l(function(n){""===c.positionUsing&&(c.positionUsing=u.getPositioningCSS()),f(s,t(e,o,a)),1===e?(f(i,{transition:"none",opacity:1}),i.offsetWidth,setTimeout(function(){f(i,{transition:"all "+o+"ms linear",opacity:0}),setTimeout(function(){u.remove(),n()},o)},o)):setTimeout(n,o)}),this},u.isStarted=function(){return"number"==typeof u.status},u.start=function(){u.status||u.set(0);var n=function(){setTimeout(function(){u.status&&(u.trickle(),n())},c.trickleSpeed)};return c.trickle&&n(),this},u.done=function(n){return n||u.status?u.inc(.3+.5*Math.random()).set(1):this},u.inc=function(e){var t=u.status;return t?("number"!=typeof e&&(e=(1-t)*n(Math.random()*t,.1,.95)),t=n(t+e,0,.994),u.set(t)):u.start()},u.trickle=function(){return u.inc(Math.random()*c.trickleRate)},function(){var n=0,e=0;u.promise=function(t){return t&&"resolved"!==t.state()?(0===e&&u.start(),n++,e++,t.always(function(){e--,0===e?(n=0,u.done()):u.set((n-e)/n)}),this):this}}(),u.render=function(n){if(u.isRendered())return document.getElementById("nprogress");i(document.documentElement,"nprogress-busy");var t=document.createElement("div");t.id="nprogress",t.innerHTML=c.template;var r,s=t.querySelector(c.barSelector),o=n?"-100":e(u.status||0),l=document.querySelector(c.parent);return f(s,{transition:"all 0 linear",transform:"translate3d("+o+"%,0,0)"}),c.showSpinner||(r=t.querySelector(c.spinnerSelector),r&&a(r)),l!=document.body&&i(l,"nprogress-custom-parent"),l.appendChild(t),t},u.remove=function(){s(document.documentElement,"nprogress-busy"),s(document.querySelector(c.parent),"nprogress-custom-parent");var n=document.getElementById("nprogress");n&&a(n)},u.isRendered=function(){return!!document.getElementById("nprogress")},u.getPositioningCSS=function(){var n=document.body.style,e="WebkitTransform"in n?"Webkit":"MozTransform"in n?"Moz":"msTransform"in n?"ms":"OTransform"in n?"O":"";return e+"Perspective"in n?"translate3d":e+"Transform"in n?"translate":"margin"};var l=function(){function n(){var t=e.shift();t&&t(n)}var e=[];return function(t){e.push(t),1==e.length&&n()}}(),f=function(){function n(n){return n.replace(/^-ms-/,"ms-").replace(/-([\da-z])/gi,function(n,e){return e.toUpperCase()})}function e(n){var e=document.body.style;if(n in e)return n;for(var t,r=i.length,s=n.charAt(0).toUpperCase()+n.slice(1);r--;)if(t=i[r]+s,t in e)return t;return n}function t(t){return t=n(t),s[t]||(s[t]=e(t))}function r(n,e,r){e=t(e),n.style[e]=r}var i=["Webkit","O","Moz","ms"],s={};return function(n,e){var t,i,s=arguments;if(2==s.length)for(t in e)i=e[t],void 0!==i&&e.hasOwnProperty(t)&&r(n,t,i);else r(n,s[1],s[2])}}();return u});
@@ -1,3 +1,73 @@
/* ── CSS custom properties for theme switching ────────────────────────────── */
:root {
--bg-page: #f7f8fa;
--bg-card: #ffffff;
--text-primary: #333333;
--text-secondary: #888888;
--accent: #2185d0;
--border: rgba(34, 36, 38, 0.15);
}
[data-theme="dark"] {
--bg-page: #1b1c1d;
--bg-card: #2d2d2d;
--text-primary: #e0e0e0;
--text-secondary: #999999;
--accent: #6435c9;
--border: rgba(255, 255, 255, 0.1);
}
/* ── Global reset ─────────────────────────────────────────────────────────── */
html, body { margin: 0; padding: 0; }
body { background-color: var(--bg-page) !important; color: var(--text-primary); }
/* ── Sticky header (CSS sticky — works on all pages) ─────────────────────── */
#menu.ui.menu {
position: sticky;
top: 0;
z-index: 999;
margin-top: 0 !important;
margin-bottom: 0 !important;
box-shadow: 0 2px 6px rgba(0,0,0,.08);
}
/* ── Page content spacing ─────────────────────────────────────────────────── */
#container { margin-top: 1.5rem !important; }
/* ── Back-to-top button ───────────────────────────────────────────────────── */
#backToTop {
position: fixed;
bottom: 32px;
right: 32px;
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
font-size: 1.2em;
box-shadow: 0 2px 8px rgba(0,0,0,.2);
z-index: 1000;
transition: opacity .2s;
}
#backToTop:hover { opacity: .85; }
/* Apply card background where Semantic UI uses white */
[data-theme="dark"] .ui.card,
[data-theme="dark"] .ui.segment,
[data-theme="dark"] #context2,
[data-theme="dark"] .ui.menu { background-color: var(--bg-card) !important; color: var(--text-primary) !important; }
[data-theme="dark"] .ui.header,
[data-theme="dark"] .ui.dividing.header { color: var(--text-primary) !important; border-bottom-color: var(--border) !important; }
[data-theme="dark"] a { color: var(--accent) !important; }
/* ── Layout helpers ───────────────────────────────────────────────────────── */
.nullright {
float: right;
}

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