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

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