add backend
This commit is contained in:
71
backend/README.md
Normal file
71
backend/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# yoyuzh-portal-backend
|
||||
|
||||
`yoyuzh.xyz` 的 Spring Boot 3.x 后端,提供:
|
||||
|
||||
- 用户注册、登录、JWT 鉴权、用户信息接口
|
||||
- 个人网盘上传、下载、删除、目录管理、分页列表
|
||||
- CQU 课表与成绩聚合接口
|
||||
- Swagger 文档、统一异常、日志输出
|
||||
|
||||
## 环境要求
|
||||
|
||||
- JDK 17+
|
||||
- Maven 3.9+
|
||||
- 生产环境使用 MySQL 8.x 或 openGauss
|
||||
|
||||
## 启动
|
||||
|
||||
默认配置:
|
||||
|
||||
```bash
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
本地联调建议使用 `dev` 环境:
|
||||
|
||||
```bash
|
||||
mvn spring-boot:run -Dspring-boot.run.profiles=dev
|
||||
```
|
||||
|
||||
`dev` 环境特点:
|
||||
|
||||
- 数据库使用 H2 文件库
|
||||
- CQU 接口返回 mock 数据
|
||||
- 方便和 `vue/` 前端直接联调
|
||||
|
||||
## 访问地址
|
||||
|
||||
- Swagger: `http://localhost:8080/swagger-ui.html`
|
||||
- H2 Console: `http://localhost:8080/h2-console`(仅 `dev` 环境)
|
||||
|
||||
## 数据库脚本
|
||||
|
||||
- MySQL: `sql/mysql-init.sql`
|
||||
- openGauss: `sql/opengauss-init.sql`
|
||||
|
||||
## 主要接口
|
||||
|
||||
- `POST /api/auth/register`
|
||||
- `POST /api/auth/login`
|
||||
- `GET /api/user/profile`
|
||||
- `POST /api/files/upload`
|
||||
- `POST /api/files/mkdir`
|
||||
- `GET /api/files/list`
|
||||
- `GET /api/files/download/{fileId}`
|
||||
- `DELETE /api/files/{fileId}`
|
||||
- `GET /api/cqu/schedule`
|
||||
- `GET /api/cqu/grades`
|
||||
|
||||
## CQU 配置
|
||||
|
||||
部署到真实环境时修改:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
cqu:
|
||||
base-url: https://your-cqu-api
|
||||
require-login: false
|
||||
mock-enabled: false
|
||||
```
|
||||
|
||||
当前 Java 后端保留了 HTTP 适配点;本地 `dev` 环境使用 mock 数据先把前后端链路跑通。
|
||||
1
backend/dev-backend.err.log
Normal file
1
backend/dev-backend.err.log
Normal file
@@ -0,0 +1 @@
|
||||
^C
|
||||
1772
backend/dev-backend.log
Normal file
1772
backend/dev-backend.log
Normal file
File diff suppressed because it is too large
Load Diff
108
backend/pom.xml
Normal file
108
backend/pom.xml
Normal file
@@ -0,0 +1,108 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 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>3.3.8</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.yoyuzh</groupId>
|
||||
<artifactId>yoyuzh-portal-backend</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>yoyuzh-portal-backend</name>
|
||||
<description>Spring Boot backend for yoyuzh.xyz</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<jjwt.version>0.12.6</jjwt.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>2.6.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
54
backend/sql/mysql-init.sql
Normal file
54
backend/sql/mysql-init.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
CREATE DATABASE IF NOT EXISTS yoyuzh_portal DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE yoyuzh_portal;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_user (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
email VARCHAR(128) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uk_portal_user_username UNIQUE (username),
|
||||
CONSTRAINT uk_portal_user_email UNIQUE (email)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_file (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
path VARCHAR(512) NOT NULL,
|
||||
storage_name VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(255),
|
||||
size BIGINT NOT NULL,
|
||||
is_directory BIT NOT NULL DEFAULT b'0',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_portal_file_user FOREIGN KEY (user_id) REFERENCES portal_user (id),
|
||||
CONSTRAINT uk_portal_file_user_path_name UNIQUE (user_id, path, filename)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_course (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
course_name VARCHAR(255) NOT NULL,
|
||||
teacher VARCHAR(255),
|
||||
classroom VARCHAR(255),
|
||||
day_of_week INT,
|
||||
start_time INT,
|
||||
end_time INT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_portal_course_user FOREIGN KEY (user_id) REFERENCES portal_user (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_grade (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
course_name VARCHAR(255) NOT NULL,
|
||||
grade DOUBLE NOT NULL,
|
||||
semester VARCHAR(64) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_portal_grade_user FOREIGN KEY (user_id) REFERENCES portal_user (id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_created_at ON portal_user (created_at);
|
||||
CREATE INDEX idx_file_created_at ON portal_file (created_at);
|
||||
CREATE INDEX idx_course_user_created ON portal_course (user_id, created_at);
|
||||
CREATE INDEX idx_grade_user_created ON portal_grade (user_id, created_at);
|
||||
46
backend/sql/opengauss-init.sql
Normal file
46
backend/sql/opengauss-init.sql
Normal file
@@ -0,0 +1,46 @@
|
||||
CREATE TABLE IF NOT EXISTS portal_user (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
email VARCHAR(128) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_file (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES portal_user (id),
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
path VARCHAR(512) NOT NULL,
|
||||
storage_name VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(255),
|
||||
size BIGINT NOT NULL,
|
||||
is_directory BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uk_portal_file_user_path_name UNIQUE (user_id, path, filename)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_course (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES portal_user (id),
|
||||
course_name VARCHAR(255) NOT NULL,
|
||||
teacher VARCHAR(255),
|
||||
classroom VARCHAR(255),
|
||||
day_of_week INTEGER,
|
||||
start_time INTEGER,
|
||||
end_time INTEGER,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_grade (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES portal_user (id),
|
||||
course_name VARCHAR(255) NOT NULL,
|
||||
grade DOUBLE PRECISION NOT NULL,
|
||||
semester VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_created_at ON portal_user (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_file_created_at ON portal_file (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_course_user_created ON portal_course (user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_grade_user_created ON portal_grade (user_id, created_at);
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.yoyuzh;
|
||||
|
||||
import com.yoyuzh.config.CquApiProperties;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import com.yoyuzh.config.JwtProperties;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties({
|
||||
JwtProperties.class,
|
||||
FileStorageProperties.class,
|
||||
CquApiProperties.class
|
||||
})
|
||||
public class PortalBackendApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(PortalBackendApplication.class, args);
|
||||
}
|
||||
}
|
||||
33
backend/src/main/java/com/yoyuzh/auth/AuthController.java
Normal file
33
backend/src/main/java/com/yoyuzh/auth/AuthController.java
Normal file
@@ -0,0 +1,33 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import com.yoyuzh.auth.dto.AuthResponse;
|
||||
import com.yoyuzh.auth.dto.LoginRequest;
|
||||
import com.yoyuzh.auth.dto.RegisterRequest;
|
||||
import com.yoyuzh.common.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
@Operation(summary = "用户注册")
|
||||
@PostMapping("/register")
|
||||
public ApiResponse<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
return ApiResponse.success(authService.register(request));
|
||||
}
|
||||
|
||||
@Operation(summary = "用户登录")
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
return ApiResponse.success(authService.login(request));
|
||||
}
|
||||
}
|
||||
83
backend/src/main/java/com/yoyuzh/auth/AuthService.java
Normal file
83
backend/src/main/java/com/yoyuzh/auth/AuthService.java
Normal file
@@ -0,0 +1,83 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import com.yoyuzh.auth.dto.AuthResponse;
|
||||
import com.yoyuzh.auth.dto.LoginRequest;
|
||||
import com.yoyuzh.auth.dto.RegisterRequest;
|
||||
import com.yoyuzh.auth.dto.UserProfileResponse;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AuthenticationManager authenticationManager;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Transactional
|
||||
public AuthResponse register(RegisterRequest request) {
|
||||
if (userRepository.existsByUsername(request.username())) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "用户名已存在");
|
||||
}
|
||||
if (userRepository.existsByEmail(request.email())) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "邮箱已存在");
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
user.setUsername(request.username());
|
||||
user.setEmail(request.email());
|
||||
user.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
User saved = userRepository.save(user);
|
||||
return new AuthResponse(jwtTokenProvider.generateToken(saved.getId(), saved.getUsername()), toProfile(saved));
|
||||
}
|
||||
|
||||
public AuthResponse login(LoginRequest request) {
|
||||
try {
|
||||
authenticationManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(request.username(), request.password()));
|
||||
} catch (BadCredentialsException ex) {
|
||||
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户名或密码错误");
|
||||
}
|
||||
|
||||
User user = userRepository.findByUsername(request.username())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||
return new AuthResponse(jwtTokenProvider.generateToken(user.getId(), user.getUsername()), toProfile(user));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AuthResponse devLogin(String username) {
|
||||
String candidate = username == null ? "" : username.trim();
|
||||
if (candidate.isEmpty()) {
|
||||
candidate = "1";
|
||||
}
|
||||
|
||||
final String finalCandidate = candidate;
|
||||
User user = userRepository.findByUsername(finalCandidate).orElseGet(() -> {
|
||||
User created = new User();
|
||||
created.setUsername(finalCandidate);
|
||||
created.setEmail(finalCandidate + "@dev.local");
|
||||
created.setPasswordHash(passwordEncoder.encode("1"));
|
||||
return userRepository.save(created);
|
||||
});
|
||||
return new AuthResponse(jwtTokenProvider.generateToken(user.getId(), user.getUsername()), toProfile(user));
|
||||
}
|
||||
|
||||
public UserProfileResponse getProfile(String username) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||
return toProfile(user);
|
||||
}
|
||||
|
||||
private UserProfileResponse toProfile(User user) {
|
||||
return new UserProfileResponse(user.getId(), user.getUsername(), user.getEmail(), user.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CustomUserDetailsService implements UserDetailsService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
|
||||
return org.springframework.security.core.userdetails.User.withUsername(user.getUsername())
|
||||
.password(user.getPasswordHash())
|
||||
.authorities("ROLE_USER")
|
||||
.build();
|
||||
}
|
||||
|
||||
public User loadDomainUser(String username) {
|
||||
return userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||
}
|
||||
}
|
||||
26
backend/src/main/java/com/yoyuzh/auth/DevAuthController.java
Normal file
26
backend/src/main/java/com/yoyuzh/auth/DevAuthController.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import com.yoyuzh.auth.dto.AuthResponse;
|
||||
import com.yoyuzh.common.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
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;
|
||||
|
||||
@Profile("dev")
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class DevAuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
@Operation(summary = "开发环境免密登录")
|
||||
@PostMapping("/dev-login")
|
||||
public ApiResponse<AuthResponse> devLogin(@RequestParam(required = false) String username) {
|
||||
return ApiResponse.success(authService.devLogin(username));
|
||||
}
|
||||
}
|
||||
62
backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java
Normal file
62
backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java
Normal file
@@ -0,0 +1,62 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import com.yoyuzh.config.JwtProperties;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
|
||||
private final JwtProperties jwtProperties;
|
||||
private SecretKey secretKey;
|
||||
|
||||
public JwtTokenProvider(JwtProperties jwtProperties) {
|
||||
this.jwtProperties = jwtProperties;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
secretKey = Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public String generateToken(Long userId, String username) {
|
||||
Instant now = Instant.now();
|
||||
return Jwts.builder()
|
||||
.subject(username)
|
||||
.claim("uid", userId)
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(now.plusSeconds(jwtProperties.getExpirationSeconds())))
|
||||
.signWith(secretKey)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public String getUsername(String token) {
|
||||
return parseClaims(token).getSubject();
|
||||
}
|
||||
|
||||
public Long getUserId(String token) {
|
||||
Object uid = parseClaims(token).get("uid");
|
||||
return uid == null ? null : Long.parseLong(uid.toString());
|
||||
}
|
||||
|
||||
private Claims parseClaims(String token) {
|
||||
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload();
|
||||
}
|
||||
}
|
||||
84
backend/src/main/java/com/yoyuzh/auth/User.java
Normal file
84
backend/src/main/java/com/yoyuzh/auth/User.java
Normal file
@@ -0,0 +1,84 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "portal_user", indexes = {
|
||||
@Index(name = "uk_user_username", columnList = "username", unique = true),
|
||||
@Index(name = "uk_user_email", columnList = "email", unique = true),
|
||||
@Index(name = "idx_user_created_at", columnList = "created_at")
|
||||
})
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 64, unique = true)
|
||||
private String username;
|
||||
|
||||
@Column(nullable = false, length = 128, unique = true)
|
||||
private String email;
|
||||
|
||||
@Column(name = "password_hash", nullable = false, length = 255)
|
||||
private String passwordHash;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getPasswordHash() {
|
||||
return passwordHash;
|
||||
}
|
||||
|
||||
public void setPasswordHash(String passwordHash) {
|
||||
this.passwordHash = passwordHash;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
24
backend/src/main/java/com/yoyuzh/auth/UserController.java
Normal file
24
backend/src/main/java/com/yoyuzh/auth/UserController.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import com.yoyuzh.common.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/user")
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
@Operation(summary = "获取用户信息")
|
||||
@GetMapping("/profile")
|
||||
public ApiResponse<?> profile(@AuthenticationPrincipal UserDetails userDetails) {
|
||||
return ApiResponse.success(authService.getProfile(userDetails.getUsername()));
|
||||
}
|
||||
}
|
||||
13
backend/src/main/java/com/yoyuzh/auth/UserRepository.java
Normal file
13
backend/src/main/java/com/yoyuzh/auth/UserRepository.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
boolean existsByUsername(String username);
|
||||
|
||||
boolean existsByEmail(String email);
|
||||
|
||||
Optional<User> findByUsername(String username);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.yoyuzh.auth.dto;
|
||||
|
||||
public record AuthResponse(String token, UserProfileResponse user) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record LoginRequest(
|
||||
@NotBlank String username,
|
||||
@NotBlank String password
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yoyuzh.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record RegisterRequest(
|
||||
@NotBlank @Size(min = 3, max = 64) String username,
|
||||
@NotBlank @Email @Size(max = 128) String email,
|
||||
@NotBlank @Size(min = 6, max = 64) String password
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.yoyuzh.auth.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record UserProfileResponse(Long id, String username, String email, LocalDateTime createdAt) {
|
||||
}
|
||||
16
backend/src/main/java/com/yoyuzh/common/ApiResponse.java
Normal file
16
backend/src/main/java/com/yoyuzh/common/ApiResponse.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.yoyuzh.common;
|
||||
|
||||
public record ApiResponse<T>(int code, String msg, T data) {
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return new ApiResponse<>(0, "success", data);
|
||||
}
|
||||
|
||||
public static ApiResponse<Void> success() {
|
||||
return new ApiResponse<>(0, "success", null);
|
||||
}
|
||||
|
||||
public static ApiResponse<Void> error(ErrorCode errorCode, String msg) {
|
||||
return new ApiResponse<>(errorCode.getCode(), msg, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.yoyuzh.common;
|
||||
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
private final ErrorCode errorCode;
|
||||
|
||||
public BusinessException(ErrorCode errorCode, String message) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public ErrorCode getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
18
backend/src/main/java/com/yoyuzh/common/ErrorCode.java
Normal file
18
backend/src/main/java/com/yoyuzh/common/ErrorCode.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.yoyuzh.common;
|
||||
|
||||
public enum ErrorCode {
|
||||
UNKNOWN(1000),
|
||||
NOT_LOGGED_IN(1001),
|
||||
PERMISSION_DENIED(1002),
|
||||
FILE_NOT_FOUND(1003);
|
||||
|
||||
private final int code;
|
||||
|
||||
ErrorCode(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.yoyuzh.common;
|
||||
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException ex) {
|
||||
HttpStatus status = switch (ex.getErrorCode()) {
|
||||
case NOT_LOGGED_IN -> HttpStatus.UNAUTHORIZED;
|
||||
case PERMISSION_DENIED -> HttpStatus.FORBIDDEN;
|
||||
case FILE_NOT_FOUND -> HttpStatus.NOT_FOUND;
|
||||
default -> HttpStatus.BAD_REQUEST;
|
||||
};
|
||||
return ResponseEntity.status(status).body(ApiResponse.error(ex.getErrorCode(), ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler({MethodArgumentNotValidException.class, ConstraintViolationException.class})
|
||||
public ResponseEntity<ApiResponse<Void>> handleValidationException(Exception ex) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(ErrorCode.UNKNOWN, ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException ex) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(ErrorCode.PERMISSION_DENIED, "没有权限访问该资源"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(BadCredentialsException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBadCredentials(BadCredentialsException ex) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(ApiResponse.error(ErrorCode.NOT_LOGGED_IN, "用户名或密码错误"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleUnknown(Exception ex) {
|
||||
log.error("Unhandled exception", ex);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error(ErrorCode.UNKNOWN, "服务器内部错误"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.yoyuzh.common;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record PageResponse<T>(List<T> items, long total, int page, int size) {
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yoyuzh.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "app.cqu")
|
||||
public class CquApiProperties {
|
||||
|
||||
private String baseUrl = "https://example-cqu-api.local";
|
||||
private boolean requireLogin = false;
|
||||
private boolean mockEnabled = false;
|
||||
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
public void setBaseUrl(String baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
public boolean isRequireLogin() {
|
||||
return requireLogin;
|
||||
}
|
||||
|
||||
public void setRequireLogin(boolean requireLogin) {
|
||||
this.requireLogin = requireLogin;
|
||||
}
|
||||
|
||||
public boolean isMockEnabled() {
|
||||
return mockEnabled;
|
||||
}
|
||||
|
||||
public void setMockEnabled(boolean mockEnabled) {
|
||||
this.mockEnabled = mockEnabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.yoyuzh.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "app.storage")
|
||||
public class FileStorageProperties {
|
||||
|
||||
private String rootDir = "./storage";
|
||||
private long maxFileSize = 50 * 1024 * 1024L;
|
||||
|
||||
public String getRootDir() {
|
||||
return rootDir;
|
||||
}
|
||||
|
||||
public void setRootDir(String rootDir) {
|
||||
this.rootDir = rootDir;
|
||||
}
|
||||
|
||||
public long getMaxFileSize() {
|
||||
return maxFileSize;
|
||||
}
|
||||
|
||||
public void setMaxFileSize(long maxFileSize) {
|
||||
this.maxFileSize = maxFileSize;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.yoyuzh.config;
|
||||
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.auth.JwtTokenProvider;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
String token = header.substring(7);
|
||||
if (jwtTokenProvider.validateToken(token)
|
||||
&& SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
String username = jwtTokenProvider.getUsername(token);
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
|
||||
userDetails, null, userDetails.getAuthorities());
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
26
backend/src/main/java/com/yoyuzh/config/JwtProperties.java
Normal file
26
backend/src/main/java/com/yoyuzh/config/JwtProperties.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.yoyuzh.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "app.jwt")
|
||||
public class JwtProperties {
|
||||
|
||||
private String secret = "change-me-change-me-change-me-change-me";
|
||||
private long expirationSeconds = 86400;
|
||||
|
||||
public String getSecret() {
|
||||
return secret;
|
||||
}
|
||||
|
||||
public void setSecret(String secret) {
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
public long getExpirationSeconds() {
|
||||
return expirationSeconds;
|
||||
}
|
||||
|
||||
public void setExpirationSeconds(long expirationSeconds) {
|
||||
this.expirationSeconds = expirationSeconds;
|
||||
}
|
||||
}
|
||||
26
backend/src/main/java/com/yoyuzh/config/OpenApiConfig.java
Normal file
26
backend/src/main/java/com/yoyuzh/config/OpenApiConfig.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.yoyuzh.config;
|
||||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class OpenApiConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
.info(new Info().title("yoyuzh.xyz Backend API").version("1.0.0").description("Personal portal backend"))
|
||||
.addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
|
||||
.components(new Components().addSecuritySchemes("bearerAuth",
|
||||
new SecurityScheme()
|
||||
.name("Authorization")
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer")
|
||||
.bearerFormat("JWT")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.yoyuzh.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
@Configuration
|
||||
public class RestClientConfig {
|
||||
|
||||
@Bean
|
||||
public RestClient restClient(RestClient.Builder builder) {
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
83
backend/src/main/java/com/yoyuzh/config/SecurityConfig.java
Normal file
83
backend/src/main/java/com/yoyuzh/config/SecurityConfig.java
Normal file
@@ -0,0 +1,83 @@
|
||||
package com.yoyuzh.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.common.ApiResponse;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
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.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.cors(Customizer.withDefaults())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
|
||||
.permitAll()
|
||||
.requestMatchers("/api/files/**", "/api/user/**")
|
||||
.authenticated()
|
||||
.anyRequest()
|
||||
.permitAll())
|
||||
.authenticationProvider(authenticationProvider())
|
||||
.exceptionHandling(ex -> ex
|
||||
.authenticationEntryPoint((request, response, e) -> {
|
||||
response.setStatus(401);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
objectMapper.writeValue(response.getWriter(),
|
||||
ApiResponse.error(ErrorCode.NOT_LOGGED_IN, "用户未登录"));
|
||||
})
|
||||
.accessDeniedHandler((request, response, e) -> {
|
||||
response.setStatus(403);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
objectMapper.writeValue(response.getWriter(),
|
||||
ApiResponse.error(ErrorCode.PERMISSION_DENIED, "权限不足"));
|
||||
}))
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationProvider authenticationProvider() {
|
||||
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
|
||||
provider.setUserDetailsService(userDetailsService);
|
||||
provider.setPasswordEncoder(passwordEncoder());
|
||||
return provider;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
|
||||
return configuration.getAuthenticationManager();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
154
backend/src/main/java/com/yoyuzh/cqu/Course.java
Normal file
154
backend/src/main/java/com/yoyuzh/cqu/Course.java
Normal file
@@ -0,0 +1,154 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.auth.User;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "portal_course", indexes = {
|
||||
@Index(name = "idx_course_user_semester", columnList = "user_id,semester,student_id"),
|
||||
@Index(name = "idx_course_user_created", columnList = "user_id,created_at")
|
||||
})
|
||||
public class Course {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(name = "course_name", nullable = false, length = 255)
|
||||
private String courseName;
|
||||
|
||||
@Column(length = 64)
|
||||
private String semester;
|
||||
|
||||
@Column(name = "student_id", length = 64)
|
||||
private String studentId;
|
||||
|
||||
@Column(length = 255)
|
||||
private String teacher;
|
||||
|
||||
@Column(length = 255)
|
||||
private String classroom;
|
||||
|
||||
@Column(name = "day_of_week")
|
||||
private Integer dayOfWeek;
|
||||
|
||||
@Column(name = "start_time")
|
||||
private Integer startTime;
|
||||
|
||||
@Column(name = "end_time")
|
||||
private Integer endTime;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public String getCourseName() {
|
||||
return courseName;
|
||||
}
|
||||
|
||||
public void setCourseName(String courseName) {
|
||||
this.courseName = courseName;
|
||||
}
|
||||
|
||||
public String getSemester() {
|
||||
return semester;
|
||||
}
|
||||
|
||||
public void setSemester(String semester) {
|
||||
this.semester = semester;
|
||||
}
|
||||
|
||||
public String getStudentId() {
|
||||
return studentId;
|
||||
}
|
||||
|
||||
public void setStudentId(String studentId) {
|
||||
this.studentId = studentId;
|
||||
}
|
||||
|
||||
public String getTeacher() {
|
||||
return teacher;
|
||||
}
|
||||
|
||||
public void setTeacher(String teacher) {
|
||||
this.teacher = teacher;
|
||||
}
|
||||
|
||||
public String getClassroom() {
|
||||
return classroom;
|
||||
}
|
||||
|
||||
public void setClassroom(String classroom) {
|
||||
this.classroom = classroom;
|
||||
}
|
||||
|
||||
public Integer getDayOfWeek() {
|
||||
return dayOfWeek;
|
||||
}
|
||||
|
||||
public void setDayOfWeek(Integer dayOfWeek) {
|
||||
this.dayOfWeek = dayOfWeek;
|
||||
}
|
||||
|
||||
public Integer getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
public void setStartTime(Integer startTime) {
|
||||
this.startTime = startTime;
|
||||
}
|
||||
|
||||
public Integer getEndTime() {
|
||||
return endTime;
|
||||
}
|
||||
|
||||
public void setEndTime(Integer endTime) {
|
||||
this.endTime = endTime;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
11
backend/src/main/java/com/yoyuzh/cqu/CourseRepository.java
Normal file
11
backend/src/main/java/com/yoyuzh/cqu/CourseRepository.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface CourseRepository extends JpaRepository<Course, Long> {
|
||||
List<Course> findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(Long userId, String studentId, String semester);
|
||||
|
||||
void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
||||
}
|
||||
11
backend/src/main/java/com/yoyuzh/cqu/CourseResponse.java
Normal file
11
backend/src/main/java/com/yoyuzh/cqu/CourseResponse.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
public record CourseResponse(
|
||||
String courseName,
|
||||
String teacher,
|
||||
String classroom,
|
||||
Integer dayOfWeek,
|
||||
Integer startTime,
|
||||
Integer endTime
|
||||
) {
|
||||
}
|
||||
40
backend/src/main/java/com/yoyuzh/cqu/CquApiClient.java
Normal file
40
backend/src/main/java/com/yoyuzh/cqu/CquApiClient.java
Normal file
@@ -0,0 +1,40 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.config.CquApiProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CquApiClient {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final CquApiProperties properties;
|
||||
|
||||
public List<Map<String, Object>> fetchSchedule(String semester, String studentId) {
|
||||
if (properties.isMockEnabled()) {
|
||||
return CquMockDataFactory.createSchedule(semester, studentId);
|
||||
}
|
||||
return restClient.get()
|
||||
.uri(properties.getBaseUrl() + "/schedule?semester={semester}&studentId={studentId}", semester, studentId)
|
||||
.retrieve()
|
||||
.body(new ParameterizedTypeReference<>() {
|
||||
});
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> fetchGrades(String semester, String studentId) {
|
||||
if (properties.isMockEnabled()) {
|
||||
return CquMockDataFactory.createGrades(semester, studentId);
|
||||
}
|
||||
return restClient.get()
|
||||
.uri(properties.getBaseUrl() + "/grades?semester={semester}&studentId={studentId}", semester, studentId)
|
||||
.retrieve()
|
||||
.body(new ParameterizedTypeReference<>() {
|
||||
});
|
||||
}
|
||||
}
|
||||
44
backend/src/main/java/com/yoyuzh/cqu/CquController.java
Normal file
44
backend/src/main/java/com/yoyuzh/cqu/CquController.java
Normal file
@@ -0,0 +1,44 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/cqu")
|
||||
@RequiredArgsConstructor
|
||||
public class CquController {
|
||||
|
||||
private final CquDataService cquDataService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
@Operation(summary = "获取课表")
|
||||
@GetMapping("/schedule")
|
||||
public ApiResponse<List<CourseResponse>> schedule(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam String semester,
|
||||
@RequestParam String studentId) {
|
||||
return ApiResponse.success(cquDataService.getSchedule(resolveUser(userDetails), semester, studentId));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取成绩")
|
||||
@GetMapping("/grades")
|
||||
public ApiResponse<List<GradeResponse>> grades(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam String semester,
|
||||
@RequestParam String studentId) {
|
||||
return ApiResponse.success(cquDataService.getGrades(resolveUser(userDetails), semester, studentId));
|
||||
}
|
||||
|
||||
private User resolveUser(UserDetails userDetails) {
|
||||
return userDetails == null ? null : userDetailsService.loadDomainUser(userDetails.getUsername());
|
||||
}
|
||||
}
|
||||
129
backend/src/main/java/com/yoyuzh/cqu/CquDataService.java
Normal file
129
backend/src/main/java/com/yoyuzh/cqu/CquDataService.java
Normal file
@@ -0,0 +1,129 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import com.yoyuzh.config.CquApiProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CquDataService {
|
||||
|
||||
private final CquApiClient cquApiClient;
|
||||
private final CourseRepository courseRepository;
|
||||
private final GradeRepository gradeRepository;
|
||||
private final CquApiProperties cquApiProperties;
|
||||
|
||||
public List<CourseResponse> getSchedule(User user, String semester, String studentId) {
|
||||
requireLoginIfNecessary(user);
|
||||
List<CourseResponse> responses = cquApiClient.fetchSchedule(semester, studentId).stream()
|
||||
.map(this::toCourseResponse)
|
||||
.toList();
|
||||
if (user != null) {
|
||||
saveCourses(user, semester, studentId, responses);
|
||||
return courseRepository.findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(
|
||||
user.getId(), studentId, semester)
|
||||
.stream()
|
||||
.map(item -> new CourseResponse(
|
||||
item.getCourseName(),
|
||||
item.getTeacher(),
|
||||
item.getClassroom(),
|
||||
item.getDayOfWeek(),
|
||||
item.getStartTime(),
|
||||
item.getEndTime()))
|
||||
.toList();
|
||||
}
|
||||
return responses;
|
||||
}
|
||||
|
||||
public List<GradeResponse> getGrades(User user, String semester, String studentId) {
|
||||
requireLoginIfNecessary(user);
|
||||
List<GradeResponse> responses = cquApiClient.fetchGrades(semester, studentId).stream()
|
||||
.map(this::toGradeResponse)
|
||||
.toList();
|
||||
if (user != null) {
|
||||
saveGrades(user, semester, studentId, responses);
|
||||
return gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(user.getId(), studentId)
|
||||
.stream()
|
||||
.map(item -> new GradeResponse(item.getCourseName(), item.getGrade(), item.getSemester()))
|
||||
.toList();
|
||||
}
|
||||
return responses;
|
||||
}
|
||||
|
||||
private void requireLoginIfNecessary(User user) {
|
||||
if (cquApiProperties.isRequireLogin() && user == null) {
|
||||
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "该接口需要登录后访问");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
protected void saveCourses(User user, String semester, String studentId, List<CourseResponse> responses) {
|
||||
courseRepository.deleteByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester);
|
||||
courseRepository.saveAll(responses.stream().map(item -> {
|
||||
Course course = new Course();
|
||||
course.setUser(user);
|
||||
course.setCourseName(item.courseName());
|
||||
course.setSemester(semester);
|
||||
course.setStudentId(studentId);
|
||||
course.setTeacher(item.teacher());
|
||||
course.setClassroom(item.classroom());
|
||||
course.setDayOfWeek(item.dayOfWeek());
|
||||
course.setStartTime(item.startTime());
|
||||
course.setEndTime(item.endTime());
|
||||
return course;
|
||||
}).toList());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
protected void saveGrades(User user, String semester, String studentId, List<GradeResponse> responses) {
|
||||
gradeRepository.deleteByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester);
|
||||
gradeRepository.saveAll(responses.stream().map(item -> {
|
||||
Grade grade = new Grade();
|
||||
grade.setUser(user);
|
||||
grade.setCourseName(item.courseName());
|
||||
grade.setGrade(item.grade());
|
||||
grade.setSemester(item.semester() == null ? semester : item.semester());
|
||||
grade.setStudentId(studentId);
|
||||
return grade;
|
||||
}).toList());
|
||||
}
|
||||
|
||||
private CourseResponse toCourseResponse(Map<String, Object> source) {
|
||||
return new CourseResponse(
|
||||
stringValue(source, "courseName"),
|
||||
stringValue(source, "teacher"),
|
||||
stringValue(source, "classroom"),
|
||||
intValue(source, "dayOfWeek"),
|
||||
intValue(source, "startTime"),
|
||||
intValue(source, "endTime"));
|
||||
}
|
||||
|
||||
private GradeResponse toGradeResponse(Map<String, Object> source) {
|
||||
return new GradeResponse(
|
||||
stringValue(source, "courseName"),
|
||||
doubleValue(source, "grade"),
|
||||
stringValue(source, "semester"));
|
||||
}
|
||||
|
||||
private String stringValue(Map<String, Object> source, String key) {
|
||||
Object value = source.get(key);
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
|
||||
private Integer intValue(Map<String, Object> source, String key) {
|
||||
Object value = source.get(key);
|
||||
return value == null ? null : Integer.parseInt(value.toString());
|
||||
}
|
||||
|
||||
private Double doubleValue(Map<String, Object> source, String key) {
|
||||
Object value = source.get(key);
|
||||
return value == null ? null : Double.parseDouble(value.toString());
|
||||
}
|
||||
}
|
||||
68
backend/src/main/java/com/yoyuzh/cqu/CquMockDataFactory.java
Normal file
68
backend/src/main/java/com/yoyuzh/cqu/CquMockDataFactory.java
Normal file
@@ -0,0 +1,68 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class CquMockDataFactory {
|
||||
|
||||
private CquMockDataFactory() {
|
||||
}
|
||||
|
||||
public static List<Map<String, Object>> createSchedule(String semester, String studentId) {
|
||||
return List.of(
|
||||
Map.of(
|
||||
"studentId", studentId,
|
||||
"semester", semester,
|
||||
"courseName", "高级 Java 程序设计",
|
||||
"teacher", "李老师",
|
||||
"classroom", "D1131",
|
||||
"dayOfWeek", 1,
|
||||
"startTime", 1,
|
||||
"endTime", 2
|
||||
),
|
||||
Map.of(
|
||||
"studentId", studentId,
|
||||
"semester", semester,
|
||||
"courseName", "计算机网络",
|
||||
"teacher", "王老师",
|
||||
"classroom", "A2204",
|
||||
"dayOfWeek", 3,
|
||||
"startTime", 3,
|
||||
"endTime", 4
|
||||
),
|
||||
Map.of(
|
||||
"studentId", studentId,
|
||||
"semester", semester,
|
||||
"courseName", "软件工程",
|
||||
"teacher", "周老师",
|
||||
"classroom", "B3102",
|
||||
"dayOfWeek", 5,
|
||||
"startTime", 5,
|
||||
"endTime", 6
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public static List<Map<String, Object>> createGrades(String semester, String studentId) {
|
||||
return List.of(
|
||||
Map.of(
|
||||
"studentId", studentId,
|
||||
"semester", semester,
|
||||
"courseName", "高级 Java 程序设计",
|
||||
"grade", 92.0
|
||||
),
|
||||
Map.of(
|
||||
"studentId", studentId,
|
||||
"semester", semester,
|
||||
"courseName", "计算机网络",
|
||||
"grade", 88.5
|
||||
),
|
||||
Map.of(
|
||||
"studentId", studentId,
|
||||
"semester", semester,
|
||||
"courseName", "软件工程",
|
||||
"grade", 90.0
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
110
backend/src/main/java/com/yoyuzh/cqu/Grade.java
Normal file
110
backend/src/main/java/com/yoyuzh/cqu/Grade.java
Normal file
@@ -0,0 +1,110 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.auth.User;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "portal_grade", indexes = {
|
||||
@Index(name = "idx_grade_user_semester", columnList = "user_id,semester,student_id"),
|
||||
@Index(name = "idx_grade_user_created", columnList = "user_id,created_at")
|
||||
})
|
||||
public class Grade {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(name = "course_name", nullable = false, length = 255)
|
||||
private String courseName;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Double grade;
|
||||
|
||||
@Column(nullable = false, length = 64)
|
||||
private String semester;
|
||||
|
||||
@Column(name = "student_id", length = 64)
|
||||
private String studentId;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public String getCourseName() {
|
||||
return courseName;
|
||||
}
|
||||
|
||||
public void setCourseName(String courseName) {
|
||||
this.courseName = courseName;
|
||||
}
|
||||
|
||||
public Double getGrade() {
|
||||
return grade;
|
||||
}
|
||||
|
||||
public void setGrade(Double grade) {
|
||||
this.grade = grade;
|
||||
}
|
||||
|
||||
public String getSemester() {
|
||||
return semester;
|
||||
}
|
||||
|
||||
public void setSemester(String semester) {
|
||||
this.semester = semester;
|
||||
}
|
||||
|
||||
public String getStudentId() {
|
||||
return studentId;
|
||||
}
|
||||
|
||||
public void setStudentId(String studentId) {
|
||||
this.studentId = studentId;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
11
backend/src/main/java/com/yoyuzh/cqu/GradeRepository.java
Normal file
11
backend/src/main/java/com/yoyuzh/cqu/GradeRepository.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface GradeRepository extends JpaRepository<Grade, Long> {
|
||||
List<Grade> findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(Long userId, String studentId);
|
||||
|
||||
void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
||||
}
|
||||
8
backend/src/main/java/com/yoyuzh/cqu/GradeResponse.java
Normal file
8
backend/src/main/java/com/yoyuzh/cqu/GradeResponse.java
Normal file
@@ -0,0 +1,8 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
public record GradeResponse(
|
||||
String courseName,
|
||||
Double grade,
|
||||
String semester
|
||||
) {
|
||||
}
|
||||
77
backend/src/main/java/com/yoyuzh/files/FileController.java
Normal file
77
backend/src/main/java/com/yoyuzh/files/FileController.java
Normal file
@@ -0,0 +1,77 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.common.ApiResponse;
|
||||
import com.yoyuzh.common.PageResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
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.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/files")
|
||||
@RequiredArgsConstructor
|
||||
public class FileController {
|
||||
|
||||
private final FileService fileService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
@Operation(summary = "上传文件")
|
||||
@PostMapping("/upload")
|
||||
public ApiResponse<FileMetadataResponse> upload(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam(defaultValue = "/") String path,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
return ApiResponse.success(fileService.upload(userDetailsService.loadDomainUser(userDetails.getUsername()), path, file));
|
||||
}
|
||||
|
||||
@Operation(summary = "创建目录")
|
||||
@PostMapping("/mkdir")
|
||||
public ApiResponse<FileMetadataResponse> mkdir(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@Valid @ModelAttribute MkdirRequest request) {
|
||||
return ApiResponse.success(fileService.mkdir(userDetailsService.loadDomainUser(userDetails.getUsername()), request.path()));
|
||||
}
|
||||
|
||||
@Operation(summary = "分页列出文件")
|
||||
@GetMapping("/list")
|
||||
public ApiResponse<PageResponse<FileMetadataResponse>> list(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam(defaultValue = "/") String path,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
return ApiResponse.success(fileService.list(userDetailsService.loadDomainUser(userDetails.getUsername()), path, page, size));
|
||||
}
|
||||
|
||||
@Operation(summary = "最近文件")
|
||||
@GetMapping("/recent")
|
||||
public ApiResponse<List<FileMetadataResponse>> recent(@AuthenticationPrincipal UserDetails userDetails) {
|
||||
return ApiResponse.success(fileService.recent(userDetailsService.loadDomainUser(userDetails.getUsername())));
|
||||
}
|
||||
|
||||
@Operation(summary = "下载文件")
|
||||
@GetMapping("/download/{fileId}")
|
||||
public ResponseEntity<byte[]> download(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable Long fileId) {
|
||||
return fileService.download(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId);
|
||||
}
|
||||
|
||||
@Operation(summary = "删除文件")
|
||||
@DeleteMapping("/{fileId}")
|
||||
public ApiResponse<Void> delete(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@PathVariable Long fileId) {
|
||||
fileService.delete(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId);
|
||||
return ApiResponse.success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record FileMetadataResponse(
|
||||
Long id,
|
||||
String filename,
|
||||
String path,
|
||||
long size,
|
||||
String contentType,
|
||||
boolean directory,
|
||||
LocalDateTime createdAt
|
||||
) {
|
||||
}
|
||||
216
backend/src/main/java/com/yoyuzh/files/FileService.java
Normal file
216
backend/src/main/java/com/yoyuzh/files/FileService.java
Normal file
@@ -0,0 +1,216 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import com.yoyuzh.common.PageResponse;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class FileService {
|
||||
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final Path rootPath;
|
||||
private final long maxFileSize;
|
||||
|
||||
public FileService(StoredFileRepository storedFileRepository, FileStorageProperties properties) {
|
||||
this.storedFileRepository = storedFileRepository;
|
||||
this.rootPath = Path.of(properties.getRootDir()).toAbsolutePath().normalize();
|
||||
this.maxFileSize = properties.getMaxFileSize();
|
||||
try {
|
||||
Files.createDirectories(rootPath);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("无法初始化存储目录", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FileMetadataResponse upload(User user, String path, MultipartFile multipartFile) {
|
||||
String normalizedPath = normalizeDirectoryPath(path);
|
||||
String filename = StringUtils.cleanPath(multipartFile.getOriginalFilename());
|
||||
if (!StringUtils.hasText(filename)) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空");
|
||||
}
|
||||
if (multipartFile.getSize() > maxFileSize) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制");
|
||||
}
|
||||
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedPath, filename)) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在");
|
||||
}
|
||||
|
||||
Path targetDir = resolveUserPath(user.getId(), normalizedPath);
|
||||
Path targetFile = targetDir.resolve(filename).normalize();
|
||||
try {
|
||||
Files.createDirectories(targetDir);
|
||||
Files.copy(multipartFile.getInputStream(), targetFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException ex) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "文件上传失败");
|
||||
}
|
||||
|
||||
StoredFile storedFile = new StoredFile();
|
||||
storedFile.setUser(user);
|
||||
storedFile.setFilename(filename);
|
||||
storedFile.setPath(normalizedPath);
|
||||
storedFile.setStorageName(filename);
|
||||
storedFile.setContentType(multipartFile.getContentType());
|
||||
storedFile.setSize(multipartFile.getSize());
|
||||
storedFile.setDirectory(false);
|
||||
return toResponse(storedFileRepository.save(storedFile));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FileMetadataResponse mkdir(User user, String path) {
|
||||
String normalizedPath = normalizeDirectoryPath(path);
|
||||
if ("/".equals(normalizedPath)) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "根目录无需创建");
|
||||
}
|
||||
String parentPath = extractParentPath(normalizedPath);
|
||||
String directoryName = extractLeafName(normalizedPath);
|
||||
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), parentPath, directoryName)) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目录已存在");
|
||||
}
|
||||
try {
|
||||
Files.createDirectories(resolveUserPath(user.getId(), normalizedPath));
|
||||
} catch (IOException ex) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目录创建失败");
|
||||
}
|
||||
|
||||
StoredFile storedFile = new StoredFile();
|
||||
storedFile.setUser(user);
|
||||
storedFile.setFilename(directoryName);
|
||||
storedFile.setPath(parentPath);
|
||||
storedFile.setStorageName(directoryName);
|
||||
storedFile.setContentType("directory");
|
||||
storedFile.setSize(0L);
|
||||
storedFile.setDirectory(true);
|
||||
return toResponse(storedFileRepository.save(storedFile));
|
||||
}
|
||||
|
||||
public PageResponse<FileMetadataResponse> list(User user, String path, int page, int size) {
|
||||
String normalizedPath = normalizeDirectoryPath(path);
|
||||
Page<StoredFile> result = storedFileRepository.findByUserIdAndPathOrderByDirectoryDescCreatedAtDesc(
|
||||
user.getId(), normalizedPath, PageRequest.of(page, size));
|
||||
List<FileMetadataResponse> items = result.getContent().stream().map(this::toResponse).toList();
|
||||
return new PageResponse<>(items, result.getTotalElements(), page, size);
|
||||
}
|
||||
|
||||
public List<FileMetadataResponse> recent(User user) {
|
||||
return storedFileRepository.findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(user.getId())
|
||||
.stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(User user, Long fileId) {
|
||||
StoredFile storedFile = storedFileRepository.findById(fileId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"));
|
||||
if (!storedFile.getUser().getId().equals(user.getId())) {
|
||||
throw new BusinessException(ErrorCode.PERMISSION_DENIED, "没有权限删除该文件");
|
||||
}
|
||||
try {
|
||||
Path basePath = resolveUserPath(user.getId(), storedFile.getPath());
|
||||
Path target = storedFile.isDirectory()
|
||||
? basePath.resolve(storedFile.getFilename()).normalize()
|
||||
: basePath.resolve(storedFile.getStorageName()).normalize();
|
||||
Files.deleteIfExists(target);
|
||||
} catch (IOException ex) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "删除文件失败");
|
||||
}
|
||||
storedFileRepository.delete(storedFile);
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> download(User user, Long fileId) {
|
||||
StoredFile storedFile = storedFileRepository.findById(fileId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"));
|
||||
if (!storedFile.getUser().getId().equals(user.getId())) {
|
||||
throw new BusinessException(ErrorCode.PERMISSION_DENIED, "没有权限下载该文件");
|
||||
}
|
||||
if (storedFile.isDirectory()) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "目录不支持下载");
|
||||
}
|
||||
try {
|
||||
Path filePath = resolveUserPath(user.getId(), storedFile.getPath()).resolve(storedFile.getStorageName()).normalize();
|
||||
byte[] body = Files.readAllBytes(filePath);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename*=UTF-8''" + URLEncoder.encode(storedFile.getFilename(), StandardCharsets.UTF_8))
|
||||
.contentType(MediaType.parseMediaType(
|
||||
storedFile.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : storedFile.getContentType()))
|
||||
.body(body);
|
||||
} catch (IOException ex) {
|
||||
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在");
|
||||
}
|
||||
}
|
||||
|
||||
private FileMetadataResponse toResponse(StoredFile storedFile) {
|
||||
String logicalPath = storedFile.getPath();
|
||||
if (storedFile.isDirectory()) {
|
||||
logicalPath = "/".equals(storedFile.getPath())
|
||||
? "/" + storedFile.getFilename()
|
||||
: storedFile.getPath() + "/" + storedFile.getFilename();
|
||||
}
|
||||
return new FileMetadataResponse(
|
||||
storedFile.getId(),
|
||||
storedFile.getFilename(),
|
||||
logicalPath,
|
||||
storedFile.getSize(),
|
||||
storedFile.getContentType(),
|
||||
storedFile.isDirectory(),
|
||||
storedFile.getCreatedAt());
|
||||
}
|
||||
|
||||
private String normalizeDirectoryPath(String path) {
|
||||
if (!StringUtils.hasText(path) || "/".equals(path.trim())) {
|
||||
return "/";
|
||||
}
|
||||
String normalized = path.replace("\\", "/").trim();
|
||||
if (!normalized.startsWith("/")) {
|
||||
normalized = "/" + normalized;
|
||||
}
|
||||
normalized = normalized.replaceAll("/{2,}", "/");
|
||||
if (normalized.contains("..")) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "路径不合法");
|
||||
}
|
||||
if (normalized.endsWith("/") && normalized.length() > 1) {
|
||||
normalized = normalized.substring(0, normalized.length() - 1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private Path resolveUserPath(Long userId, String normalizedPath) {
|
||||
Path userRoot = rootPath.resolve(userId.toString()).normalize();
|
||||
Path relative = "/".equals(normalizedPath) ? Path.of("") : Path.of(normalizedPath.substring(1));
|
||||
Path resolved = userRoot.resolve(relative).normalize();
|
||||
if (!resolved.startsWith(userRoot)) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "路径不合法");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private String extractParentPath(String normalizedPath) {
|
||||
int lastSlash = normalizedPath.lastIndexOf('/');
|
||||
return lastSlash <= 0 ? "/" : normalizedPath.substring(0, lastSlash);
|
||||
}
|
||||
|
||||
private String extractLeafName(String normalizedPath) {
|
||||
return normalizedPath.substring(normalizedPath.lastIndexOf('/') + 1);
|
||||
}
|
||||
}
|
||||
6
backend/src/main/java/com/yoyuzh/files/MkdirRequest.java
Normal file
6
backend/src/main/java/com/yoyuzh/files/MkdirRequest.java
Normal file
@@ -0,0 +1,6 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record MkdirRequest(@NotBlank String path) {
|
||||
}
|
||||
132
backend/src/main/java/com/yoyuzh/files/StoredFile.java
Normal file
132
backend/src/main/java/com/yoyuzh/files/StoredFile.java
Normal file
@@ -0,0 +1,132 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.yoyuzh.auth.User;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "portal_file", indexes = {
|
||||
@Index(name = "uk_file_user_path_name", columnList = "user_id,path,filename", unique = true),
|
||||
@Index(name = "idx_file_created_at", columnList = "created_at")
|
||||
})
|
||||
public class StoredFile {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(nullable = false, length = 255)
|
||||
private String filename;
|
||||
|
||||
@Column(nullable = false, length = 512)
|
||||
private String path;
|
||||
|
||||
@Column(name = "storage_name", nullable = false, length = 255)
|
||||
private String storageName;
|
||||
|
||||
@Column(name = "content_type", length = 255)
|
||||
private String contentType;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long size;
|
||||
|
||||
@Column(name = "is_directory", nullable = false)
|
||||
private boolean directory;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
|
||||
public void setFilename(String filename) {
|
||||
this.filename = filename;
|
||||
}
|
||||
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public void setPath(String path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
public String getStorageName() {
|
||||
return storageName;
|
||||
}
|
||||
|
||||
public void setStorageName(String storageName) {
|
||||
this.storageName = storageName;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
public void setContentType(String contentType) {
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
public Long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public void setSize(Long size) {
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
public boolean isDirectory() {
|
||||
return directory;
|
||||
}
|
||||
|
||||
public void setDirectory(boolean directory) {
|
||||
this.directory = directory;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
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.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
||||
|
||||
@Query("""
|
||||
select case when count(f) > 0 then true else false end
|
||||
from StoredFile f
|
||||
where f.user.id = :userId and f.path = :path and f.filename = :filename
|
||||
""")
|
||||
boolean existsByUserIdAndPathAndFilename(@Param("userId") Long userId,
|
||||
@Param("path") String path,
|
||||
@Param("filename") String filename);
|
||||
|
||||
@Query("""
|
||||
select f from StoredFile f
|
||||
where f.user.id = :userId and f.path = :path
|
||||
order by f.directory desc, f.createdAt desc
|
||||
""")
|
||||
Page<StoredFile> findByUserIdAndPathOrderByDirectoryDescCreatedAtDesc(@Param("userId") Long userId,
|
||||
@Param("path") String path,
|
||||
Pageable pageable);
|
||||
|
||||
List<StoredFile> findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(Long userId);
|
||||
}
|
||||
17
backend/src/main/resources/application-dev.yml
Normal file
17
backend/src/main/resources/application-dev.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:file:./data/yoyuzh_portal_dev;MODE=MySQL;AUTO_SERVER=TRUE;DB_CLOSE_DELAY=-1
|
||||
username: sa
|
||||
password:
|
||||
driver-class-name: org.h2.Driver
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
h2:
|
||||
console:
|
||||
enabled: true
|
||||
path: /h2-console
|
||||
|
||||
app:
|
||||
cqu:
|
||||
mock-enabled: true
|
||||
41
backend/src/main/resources/application.yml
Normal file
41
backend/src/main/resources/application.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: yoyuzh-portal-backend
|
||||
datasource:
|
||||
url: jdbc:mysql://localhost:3306/yoyuzh_portal?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8
|
||||
username: root
|
||||
password: root
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
open-in-view: false
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 50MB
|
||||
max-request-size: 50MB
|
||||
|
||||
app:
|
||||
jwt:
|
||||
secret: change-me-change-me-change-me-change-me
|
||||
expiration-seconds: 86400
|
||||
storage:
|
||||
root-dir: ./storage
|
||||
max-file-size: 52428800
|
||||
cqu:
|
||||
base-url: https://example-cqu-api.local
|
||||
require-login: false
|
||||
mock-enabled: false
|
||||
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
|
||||
logging:
|
||||
config: classpath:logback.xml
|
||||
14
backend/src/main/resources/logback.xml
Normal file
14
backend/src/main/resources/logback.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>${CONSOLE_PATTERN}</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
</configuration>
|
||||
105
backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java
Normal file
105
backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java
Normal file
@@ -0,0 +1,105 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import com.yoyuzh.auth.dto.AuthResponse;
|
||||
import com.yoyuzh.auth.dto.LoginRequest;
|
||||
import com.yoyuzh.auth.dto.RegisterRequest;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuthServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Mock
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Mock
|
||||
private AuthenticationManager authenticationManager;
|
||||
|
||||
@Mock
|
||||
private JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@InjectMocks
|
||||
private AuthService authService;
|
||||
|
||||
@Test
|
||||
void shouldRegisterUserWithEncryptedPassword() {
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "plain-password");
|
||||
when(userRepository.existsByUsername("alice")).thenReturn(false);
|
||||
when(userRepository.existsByEmail("alice@example.com")).thenReturn(false);
|
||||
when(passwordEncoder.encode("plain-password")).thenReturn("encoded-password");
|
||||
when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
|
||||
User user = invocation.getArgument(0);
|
||||
user.setId(1L);
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
return user;
|
||||
});
|
||||
when(jwtTokenProvider.generateToken(1L, "alice")).thenReturn("jwt-token");
|
||||
|
||||
AuthResponse response = authService.register(request);
|
||||
|
||||
assertThat(response.token()).isEqualTo("jwt-token");
|
||||
assertThat(response.user().username()).isEqualTo("alice");
|
||||
verify(passwordEncoder).encode("plain-password");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectDuplicateUsernameOnRegister() {
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "plain-password");
|
||||
when(userRepository.existsByUsername("alice")).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> authService.register(request))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("用户名已存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldLoginAndReturnToken() {
|
||||
LoginRequest request = new LoginRequest("alice", "plain-password");
|
||||
User user = new User();
|
||||
user.setId(1L);
|
||||
user.setUsername("alice");
|
||||
user.setEmail("alice@example.com");
|
||||
user.setPasswordHash("encoded-password");
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
|
||||
when(jwtTokenProvider.generateToken(1L, "alice")).thenReturn("jwt-token");
|
||||
|
||||
AuthResponse response = authService.login(request);
|
||||
|
||||
verify(authenticationManager).authenticate(
|
||||
new UsernamePasswordAuthenticationToken("alice", "plain-password"));
|
||||
assertThat(response.token()).isEqualTo("jwt-token");
|
||||
assertThat(response.user().email()).isEqualTo("alice@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowBusinessExceptionWhenAuthenticationFails() {
|
||||
LoginRequest request = new LoginRequest("alice", "wrong-password");
|
||||
when(authenticationManager.authenticate(any()))
|
||||
.thenThrow(new BadCredentialsException("bad credentials"));
|
||||
|
||||
assertThatThrownBy(() -> authService.login(request))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("用户名或密码错误");
|
||||
}
|
||||
}
|
||||
86
backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTest.java
Normal file
86
backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTest.java
Normal file
@@ -0,0 +1,86 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.config.CquApiProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CquDataServiceTest {
|
||||
|
||||
@Mock
|
||||
private CquApiClient cquApiClient;
|
||||
|
||||
@Mock
|
||||
private CourseRepository courseRepository;
|
||||
|
||||
@Mock
|
||||
private GradeRepository gradeRepository;
|
||||
|
||||
@InjectMocks
|
||||
private CquDataService cquDataService;
|
||||
|
||||
@Test
|
||||
void shouldNormalizeScheduleFromRemoteApi() {
|
||||
CquApiProperties properties = new CquApiProperties();
|
||||
properties.setRequireLogin(false);
|
||||
cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, properties);
|
||||
when(cquApiClient.fetchSchedule("2025-2026-1", "20230001")).thenReturn(List.of(Map.of(
|
||||
"courseName", "Java",
|
||||
"teacher", "Zhang",
|
||||
"classroom", "A101",
|
||||
"dayOfWeek", 1,
|
||||
"startTime", 1,
|
||||
"endTime", 2
|
||||
)));
|
||||
|
||||
List<CourseResponse> response = cquDataService.getSchedule(null, "2025-2026-1", "20230001");
|
||||
|
||||
assertThat(response).hasSize(1);
|
||||
assertThat(response.get(0).courseName()).isEqualTo("Java");
|
||||
assertThat(response.get(0).teacher()).isEqualTo("Zhang");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPersistGradesForLoggedInUserWhenAvailable() {
|
||||
CquApiProperties properties = new CquApiProperties();
|
||||
properties.setRequireLogin(true);
|
||||
cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, properties);
|
||||
User user = new User();
|
||||
user.setId(1L);
|
||||
user.setUsername("alice");
|
||||
user.setEmail("alice@example.com");
|
||||
user.setPasswordHash("encoded");
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
when(cquApiClient.fetchGrades("2025-2026-1", "20230001")).thenReturn(List.of(Map.of(
|
||||
"courseName", "Java",
|
||||
"grade", 95,
|
||||
"semester", "2025-2026-1"
|
||||
)));
|
||||
Grade persisted = new Grade();
|
||||
persisted.setUser(user);
|
||||
persisted.setCourseName("Java");
|
||||
persisted.setGrade(95D);
|
||||
persisted.setSemester("2025-2026-1");
|
||||
persisted.setStudentId("20230001");
|
||||
when(gradeRepository.saveAll(anyList())).thenReturn(List.of(persisted));
|
||||
when(gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(1L, "20230001"))
|
||||
.thenReturn(List.of(persisted));
|
||||
|
||||
List<GradeResponse> response = cquDataService.getGrades(user, "2025-2026-1", "20230001");
|
||||
|
||||
assertThat(response).hasSize(1);
|
||||
assertThat(response.get(0).grade()).isEqualTo(95D);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CquMockDataFactoryTest {
|
||||
|
||||
@Test
|
||||
void shouldCreateMockScheduleForStudentAndSemester() {
|
||||
List<Map<String, Object>> result = CquMockDataFactory.createSchedule("2025-2026-1", "20230001");
|
||||
|
||||
assertThat(result).isNotEmpty();
|
||||
assertThat(result.get(0)).containsEntry("courseName", "高级 Java 程序设计");
|
||||
assertThat(result.get(0)).containsEntry("semester", "2025-2026-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateMockGradesForStudentAndSemester() {
|
||||
List<Map<String, Object>> result = CquMockDataFactory.createGrades("2025-2026-1", "20230001");
|
||||
|
||||
assertThat(result).isNotEmpty();
|
||||
assertThat(result.get(0)).containsEntry("studentId", "20230001");
|
||||
assertThat(result.get(0)).containsKey("grade");
|
||||
}
|
||||
}
|
||||
114
backend/src/test/java/com/yoyuzh/files/FileServiceTest.java
Normal file
114
backend/src/test/java/com/yoyuzh/files/FileServiceTest.java
Normal file
@@ -0,0 +1,114 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FileServiceTest {
|
||||
|
||||
@Mock
|
||||
private StoredFileRepository storedFileRepository;
|
||||
|
||||
private FileService fileService;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
FileStorageProperties properties = new FileStorageProperties();
|
||||
properties.setRootDir(tempDir.toString());
|
||||
properties.setMaxFileSize(50 * 1024 * 1024);
|
||||
fileService = new FileService(storedFileRepository, properties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldStoreUploadedFileUnderUserDirectory() {
|
||||
User user = createUser(7L);
|
||||
MockMultipartFile multipartFile = new MockMultipartFile(
|
||||
"file", "notes.txt", "text/plain", "hello".getBytes());
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false);
|
||||
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> {
|
||||
StoredFile file = invocation.getArgument(0);
|
||||
file.setId(10L);
|
||||
return file;
|
||||
});
|
||||
|
||||
FileMetadataResponse response = fileService.upload(user, "/docs", multipartFile);
|
||||
|
||||
assertThat(response.id()).isEqualTo(10L);
|
||||
assertThat(response.path()).isEqualTo("/docs");
|
||||
assertThat(response.directory()).isFalse();
|
||||
assertThat(tempDir.resolve("7/docs/notes.txt")).exists();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectDeletingOtherUsersFile() {
|
||||
User owner = createUser(1L);
|
||||
User requester = createUser(2L);
|
||||
StoredFile storedFile = createFile(100L, owner, "/docs", "notes.txt");
|
||||
when(storedFileRepository.findById(100L)).thenReturn(Optional.of(storedFile));
|
||||
|
||||
assertThatThrownBy(() -> fileService.delete(requester, 100L))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("没有权限");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldListFilesByPathWithPagination() {
|
||||
User user = createUser(7L);
|
||||
StoredFile file = createFile(100L, user, "/docs", "notes.txt");
|
||||
when(storedFileRepository.findByUserIdAndPathOrderByDirectoryDescCreatedAtDesc(
|
||||
7L, "/docs", PageRequest.of(0, 10)))
|
||||
.thenReturn(new PageImpl<>(List.of(file)));
|
||||
|
||||
var result = fileService.list(user, "/docs", 0, 10);
|
||||
|
||||
assertThat(result.items()).hasSize(1);
|
||||
assertThat(result.items().get(0).filename()).isEqualTo("notes.txt");
|
||||
}
|
||||
|
||||
private User createUser(Long id) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
user.setUsername("user-" + id);
|
||||
user.setEmail("user-" + id + "@example.com");
|
||||
user.setPasswordHash("encoded");
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
return user;
|
||||
}
|
||||
|
||||
private StoredFile createFile(Long id, User user, String path, String filename) {
|
||||
StoredFile file = new StoredFile();
|
||||
file.setId(id);
|
||||
file.setUser(user);
|
||||
file.setFilename(filename);
|
||||
file.setPath(path);
|
||||
file.setSize(5L);
|
||||
file.setDirectory(false);
|
||||
file.setStorageName(filename);
|
||||
file.setCreatedAt(LocalDateTime.now());
|
||||
return file;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user