add backend

This commit is contained in:
yoyuzh
2026-03-14 11:03:07 +08:00
parent d993d3f943
commit 8db2fa2aab
130 changed files with 15152 additions and 11861 deletions

71
backend/README.md Normal file
View 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 数据先把前后端链路跑通。

View File

@@ -0,0 +1 @@
^C

1772
backend/dev-backend.log Normal file

File diff suppressed because it is too large Load Diff

108
backend/pom.xml Normal file
View 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>

View 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);

View 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);

View File

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

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

View 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());
}
}

View File

@@ -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, "用户不存在"));
}
}

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

View 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();
}
}

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

View 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()));
}
}

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

View File

@@ -0,0 +1,4 @@
package com.yoyuzh.auth.dto;
public record AuthResponse(String token, UserProfileResponse user) {
}

View File

@@ -0,0 +1,9 @@
package com.yoyuzh.auth.dto;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank String username,
@NotBlank String password
) {
}

View File

@@ -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
) {
}

View File

@@ -0,0 +1,6 @@
package com.yoyuzh.auth.dto;
import java.time.LocalDateTime;
public record UserProfileResponse(Long id, String username, String email, LocalDateTime createdAt) {
}

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

View File

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

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

View File

@@ -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, "服务器内部错误"));
}
}

View File

@@ -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) {
}

View File

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

View File

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

View File

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

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

View 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")));
}
}

View File

@@ -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();
}
}

View 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();
}
}

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

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

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.cqu;
public record CourseResponse(
String courseName,
String teacher,
String classroom,
Integer dayOfWeek,
Integer startTime,
Integer endTime
) {
}

View 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<>() {
});
}
}

View 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());
}
}

View 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());
}
}

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

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

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

View File

@@ -0,0 +1,8 @@
package com.yoyuzh.cqu;
public record GradeResponse(
String courseName,
Double grade,
String semester
) {
}

View 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();
}
}

View File

@@ -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
) {
}

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

View File

@@ -0,0 +1,6 @@
package com.yoyuzh.files;
import jakarta.validation.constraints.NotBlank;
public record MkdirRequest(@NotBlank String path) {
}

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

View File

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

View 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

View 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

View 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>

View 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("用户名或密码错误");
}
}

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

View File

@@ -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");
}
}

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