add backend
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user