添加账号修改,后台管理
This commit is contained in:
@@ -4,6 +4,7 @@ import com.yoyuzh.config.CquApiProperties;
|
||||
import com.yoyuzh.config.CorsProperties;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import com.yoyuzh.config.JwtProperties;
|
||||
import com.yoyuzh.config.AdminProperties;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
@@ -13,7 +14,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
JwtProperties.class,
|
||||
FileStorageProperties.class,
|
||||
CquApiProperties.class,
|
||||
CorsProperties.class
|
||||
CorsProperties.class,
|
||||
AdminProperties.class
|
||||
})
|
||||
public class PortalBackendApplication {
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import com.yoyuzh.config.AdminProperties;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class AdminAccessEvaluator {
|
||||
|
||||
private final Set<String> adminUsernames;
|
||||
|
||||
public AdminAccessEvaluator(AdminProperties adminProperties) {
|
||||
this.adminUsernames = adminProperties.getUsernames().stream()
|
||||
.map(username -> username == null ? "" : username.trim())
|
||||
.filter(username -> !username.isEmpty())
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
public boolean isAdmin(Authentication authentication) {
|
||||
return authentication != null
|
||||
&& authentication.isAuthenticated()
|
||||
&& adminUsernames.contains(authentication.getName());
|
||||
}
|
||||
}
|
||||
82
backend/src/main/java/com/yoyuzh/admin/AdminController.java
Normal file
82
backend/src/main/java/com/yoyuzh/admin/AdminController.java
Normal file
@@ -0,0 +1,82 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import com.yoyuzh.common.ApiResponse;
|
||||
import com.yoyuzh.common.PageResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("@adminAccessEvaluator.isAdmin(authentication)")
|
||||
public class AdminController {
|
||||
|
||||
private final AdminService adminService;
|
||||
|
||||
@GetMapping("/summary")
|
||||
public ApiResponse<AdminSummaryResponse> summary() {
|
||||
return ApiResponse.success(adminService.getSummary());
|
||||
}
|
||||
|
||||
@GetMapping("/users")
|
||||
public ApiResponse<PageResponse<AdminUserResponse>> users(@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(defaultValue = "") String query) {
|
||||
return ApiResponse.success(adminService.listUsers(page, size, query));
|
||||
}
|
||||
|
||||
@GetMapping("/files")
|
||||
public ApiResponse<PageResponse<AdminFileResponse>> files(@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(defaultValue = "") String ownerQuery) {
|
||||
return ApiResponse.success(adminService.listFiles(page, size, query, ownerQuery));
|
||||
}
|
||||
|
||||
@DeleteMapping("/files/{fileId}")
|
||||
public ApiResponse<Void> deleteFile(@PathVariable Long fileId) {
|
||||
adminService.deleteFile(fileId);
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
@GetMapping("/school-snapshots")
|
||||
public ApiResponse<PageResponse<AdminSchoolSnapshotResponse>> schoolSnapshots(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
return ApiResponse.success(adminService.listSchoolSnapshots(page, size));
|
||||
}
|
||||
|
||||
@PatchMapping("/users/{userId}/role")
|
||||
public ApiResponse<AdminUserResponse> updateUserRole(@PathVariable Long userId,
|
||||
@Valid @RequestBody AdminUserRoleUpdateRequest request) {
|
||||
return ApiResponse.success(adminService.updateUserRole(userId, request.role()));
|
||||
}
|
||||
|
||||
@PatchMapping("/users/{userId}/status")
|
||||
public ApiResponse<AdminUserResponse> updateUserStatus(@PathVariable Long userId,
|
||||
@Valid @RequestBody AdminUserStatusUpdateRequest request) {
|
||||
return ApiResponse.success(adminService.updateUserBanned(userId, request.banned()));
|
||||
}
|
||||
|
||||
@PutMapping("/users/{userId}/password")
|
||||
public ApiResponse<AdminUserResponse> updateUserPassword(@PathVariable Long userId,
|
||||
@Valid @RequestBody AdminUserPasswordUpdateRequest request) {
|
||||
return ApiResponse.success(adminService.updateUserPassword(userId, request.newPassword()));
|
||||
}
|
||||
|
||||
@PostMapping("/users/{userId}/password/reset")
|
||||
public ApiResponse<AdminPasswordResetResponse> resetUserPassword(@PathVariable Long userId) {
|
||||
return ApiResponse.success(adminService.resetUserPassword(userId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record AdminFileResponse(
|
||||
Long id,
|
||||
String filename,
|
||||
String path,
|
||||
long size,
|
||||
String contentType,
|
||||
boolean directory,
|
||||
LocalDateTime createdAt,
|
||||
Long ownerId,
|
||||
String ownerUsername,
|
||||
String ownerEmail
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
public record AdminPasswordResetResponse(String temporaryPassword) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
public record AdminSchoolSnapshotResponse(
|
||||
Long id,
|
||||
Long userId,
|
||||
String username,
|
||||
String email,
|
||||
String studentId,
|
||||
String semester,
|
||||
long scheduleCount,
|
||||
long gradeCount
|
||||
) {
|
||||
}
|
||||
207
backend/src/main/java/com/yoyuzh/admin/AdminService.java
Normal file
207
backend/src/main/java/com/yoyuzh/admin/AdminService.java
Normal file
@@ -0,0 +1,207 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import com.yoyuzh.auth.PasswordPolicy;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.auth.UserRole;
|
||||
import com.yoyuzh.auth.UserRepository;
|
||||
import com.yoyuzh.auth.RefreshTokenService;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import com.yoyuzh.common.PageResponse;
|
||||
import com.yoyuzh.cqu.CourseRepository;
|
||||
import com.yoyuzh.cqu.GradeRepository;
|
||||
import com.yoyuzh.files.FileService;
|
||||
import com.yoyuzh.files.StoredFile;
|
||||
import com.yoyuzh.files.StoredFileRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final FileService fileService;
|
||||
private final CourseRepository courseRepository;
|
||||
private final GradeRepository gradeRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public AdminSummaryResponse getSummary() {
|
||||
return new AdminSummaryResponse(
|
||||
userRepository.count(),
|
||||
storedFileRepository.count(),
|
||||
userRepository.countByLastSchoolStudentIdIsNotNull()
|
||||
);
|
||||
}
|
||||
|
||||
public PageResponse<AdminUserResponse> listUsers(int page, int size, String query) {
|
||||
Page<User> result = userRepository.searchByUsernameOrEmail(
|
||||
normalizeQuery(query),
|
||||
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
|
||||
);
|
||||
List<AdminUserResponse> items = result.getContent().stream()
|
||||
.map(this::toUserResponse)
|
||||
.toList();
|
||||
return new PageResponse<>(items, result.getTotalElements(), page, size);
|
||||
}
|
||||
|
||||
public PageResponse<AdminFileResponse> listFiles(int page, int size, String query, String ownerQuery) {
|
||||
Page<StoredFile> result = storedFileRepository.searchAdminFiles(
|
||||
normalizeQuery(query),
|
||||
normalizeQuery(ownerQuery),
|
||||
PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "user.username")
|
||||
.and(Sort.by(Sort.Direction.DESC, "createdAt")))
|
||||
);
|
||||
List<AdminFileResponse> items = result.getContent().stream()
|
||||
.map(this::toFileResponse)
|
||||
.toList();
|
||||
return new PageResponse<>(items, result.getTotalElements(), page, size);
|
||||
}
|
||||
|
||||
public PageResponse<AdminSchoolSnapshotResponse> listSchoolSnapshots(int page, int size) {
|
||||
Page<User> result = userRepository.findByLastSchoolStudentIdIsNotNull(
|
||||
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
|
||||
);
|
||||
List<AdminSchoolSnapshotResponse> items = result.getContent().stream()
|
||||
.map(this::toSchoolSnapshotResponse)
|
||||
.toList();
|
||||
return new PageResponse<>(items, result.getTotalElements(), page, size);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteFile(Long fileId) {
|
||||
StoredFile storedFile = storedFileRepository.findById(fileId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"));
|
||||
fileService.delete(storedFile.getUser(), fileId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminUserResponse updateUserRole(Long userId, UserRole role) {
|
||||
User user = getRequiredUser(userId);
|
||||
user.setRole(role);
|
||||
return toUserResponse(userRepository.save(user));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminUserResponse updateUserBanned(Long userId, boolean banned) {
|
||||
User user = getRequiredUser(userId);
|
||||
user.setBanned(banned);
|
||||
refreshTokenService.revokeAllForUser(user.getId());
|
||||
return toUserResponse(userRepository.save(user));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminUserResponse updateUserPassword(Long userId, String newPassword) {
|
||||
if (!PasswordPolicy.isStrong(newPassword)) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符");
|
||||
}
|
||||
User user = getRequiredUser(userId);
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
refreshTokenService.revokeAllForUser(user.getId());
|
||||
return toUserResponse(userRepository.save(user));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdminPasswordResetResponse resetUserPassword(Long userId) {
|
||||
String temporaryPassword = generateTemporaryPassword();
|
||||
updateUserPassword(userId, temporaryPassword);
|
||||
return new AdminPasswordResetResponse(temporaryPassword);
|
||||
}
|
||||
|
||||
private AdminUserResponse toUserResponse(User user) {
|
||||
return new AdminUserResponse(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getEmail(),
|
||||
user.getCreatedAt(),
|
||||
user.getLastSchoolStudentId(),
|
||||
user.getLastSchoolSemester(),
|
||||
user.getRole(),
|
||||
user.isBanned()
|
||||
);
|
||||
}
|
||||
|
||||
private AdminFileResponse toFileResponse(StoredFile storedFile) {
|
||||
User owner = storedFile.getUser();
|
||||
return new AdminFileResponse(
|
||||
storedFile.getId(),
|
||||
storedFile.getFilename(),
|
||||
storedFile.getPath(),
|
||||
storedFile.getSize(),
|
||||
storedFile.getContentType(),
|
||||
storedFile.isDirectory(),
|
||||
storedFile.getCreatedAt(),
|
||||
owner.getId(),
|
||||
owner.getUsername(),
|
||||
owner.getEmail()
|
||||
);
|
||||
}
|
||||
|
||||
private AdminSchoolSnapshotResponse toSchoolSnapshotResponse(User user) {
|
||||
String studentId = user.getLastSchoolStudentId();
|
||||
String semester = user.getLastSchoolSemester();
|
||||
long scheduleCount = studentId == null || semester == null
|
||||
? 0
|
||||
: courseRepository.countByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester);
|
||||
long gradeCount = studentId == null || semester == null
|
||||
? 0
|
||||
: gradeRepository.countByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester);
|
||||
|
||||
return new AdminSchoolSnapshotResponse(
|
||||
user.getId(),
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getEmail(),
|
||||
studentId,
|
||||
semester,
|
||||
scheduleCount,
|
||||
gradeCount
|
||||
);
|
||||
}
|
||||
|
||||
private User getRequiredUser(Long userId) {
|
||||
return userRepository.findById(userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.UNKNOWN, "用户不存在"));
|
||||
}
|
||||
|
||||
private String normalizeQuery(String query) {
|
||||
if (query == null) {
|
||||
return "";
|
||||
}
|
||||
return query.trim();
|
||||
}
|
||||
|
||||
private String generateTemporaryPassword() {
|
||||
String lowers = "abcdefghjkmnpqrstuvwxyz";
|
||||
String uppers = "ABCDEFGHJKMNPQRSTUVWXYZ";
|
||||
String digits = "23456789";
|
||||
String specials = "!@#$%^&*";
|
||||
String all = lowers + uppers + digits + specials;
|
||||
char[] password = new char[12];
|
||||
password[0] = lowers.charAt(secureRandom.nextInt(lowers.length()));
|
||||
password[1] = uppers.charAt(secureRandom.nextInt(uppers.length()));
|
||||
password[2] = digits.charAt(secureRandom.nextInt(digits.length()));
|
||||
password[3] = specials.charAt(secureRandom.nextInt(specials.length()));
|
||||
for (int i = 4; i < password.length; i += 1) {
|
||||
password[i] = all.charAt(secureRandom.nextInt(all.length()));
|
||||
}
|
||||
for (int i = password.length - 1; i > 0; i -= 1) {
|
||||
int j = secureRandom.nextInt(i + 1);
|
||||
char tmp = password[i];
|
||||
password[i] = password[j];
|
||||
password[j] = tmp;
|
||||
}
|
||||
return new String(password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
public record AdminSummaryResponse(
|
||||
long totalUsers,
|
||||
long totalFiles,
|
||||
long usersWithSchoolCache
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import com.yoyuzh.auth.PasswordPolicy;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record AdminUserPasswordUpdateRequest(
|
||||
@NotBlank
|
||||
@Size(min = 10, max = 64, message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符")
|
||||
String newPassword
|
||||
) {
|
||||
|
||||
@AssertTrue(message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符")
|
||||
public boolean isPasswordStrong() {
|
||||
return PasswordPolicy.isStrong(newPassword);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import com.yoyuzh.auth.UserRole;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record AdminUserResponse(
|
||||
Long id,
|
||||
String username,
|
||||
String email,
|
||||
LocalDateTime createdAt,
|
||||
String lastSchoolStudentId,
|
||||
String lastSchoolSemester,
|
||||
UserRole role,
|
||||
boolean banned
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import com.yoyuzh.auth.UserRole;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record AdminUserRoleUpdateRequest(@NotNull UserRole role) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record AdminUserStatusUpdateRequest(@NotNull Boolean banned) {
|
||||
}
|
||||
@@ -3,21 +3,41 @@ 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.UpdateUserAvatarRequest;
|
||||
import com.yoyuzh.auth.dto.UpdateUserPasswordRequest;
|
||||
import com.yoyuzh.auth.dto.UpdateUserProfileRequest;
|
||||
import com.yoyuzh.auth.dto.UserProfileResponse;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import com.yoyuzh.files.FileService;
|
||||
import com.yoyuzh.files.InitiateUploadResponse;
|
||||
import com.yoyuzh.files.storage.FileContentStorage;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.DisabledException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
private static final String AVATAR_PATH = "/.avatar";
|
||||
private static final long MAX_AVATAR_SIZE = 5L * 1024 * 1024L;
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
@@ -25,6 +45,7 @@ public class AuthService {
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final FileService fileService;
|
||||
private final FileContentStorage fileContentStorage;
|
||||
|
||||
@Transactional
|
||||
public AuthResponse register(RegisterRequest request) {
|
||||
@@ -37,8 +58,11 @@ public class AuthService {
|
||||
|
||||
User user = new User();
|
||||
user.setUsername(request.username());
|
||||
user.setDisplayName(request.username());
|
||||
user.setEmail(request.email());
|
||||
user.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
user.setRole(UserRole.USER);
|
||||
user.setPreferredLanguage("zh-CN");
|
||||
User saved = userRepository.save(user);
|
||||
fileService.ensureDefaultDirectories(saved);
|
||||
return issueTokens(saved);
|
||||
@@ -48,6 +72,8 @@ public class AuthService {
|
||||
try {
|
||||
authenticationManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(request.username(), request.password()));
|
||||
} catch (DisabledException ex) {
|
||||
throw new BusinessException(ErrorCode.PERMISSION_DENIED, "账号已被封禁");
|
||||
} catch (BadCredentialsException ex) {
|
||||
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户名或密码错误");
|
||||
}
|
||||
@@ -69,8 +95,11 @@ public class AuthService {
|
||||
User user = userRepository.findByUsername(finalCandidate).orElseGet(() -> {
|
||||
User created = new User();
|
||||
created.setUsername(finalCandidate);
|
||||
created.setDisplayName(finalCandidate);
|
||||
created.setEmail(finalCandidate + "@dev.local");
|
||||
created.setPasswordHash(passwordEncoder.encode("1"));
|
||||
created.setRole(UserRole.USER);
|
||||
created.setPreferredLanguage("zh-CN");
|
||||
return userRepository.save(created);
|
||||
});
|
||||
fileService.ensureDefaultDirectories(user);
|
||||
@@ -89,8 +118,139 @@ public class AuthService {
|
||||
return toProfile(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserProfileResponse updateProfile(String username, UpdateUserProfileRequest request) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||
|
||||
String nextEmail = request.email().trim();
|
||||
if (!user.getEmail().equalsIgnoreCase(nextEmail) && userRepository.existsByEmail(nextEmail)) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "邮箱已存在");
|
||||
}
|
||||
|
||||
user.setDisplayName(request.displayName().trim());
|
||||
user.setEmail(nextEmail);
|
||||
user.setBio(normalizeOptionalText(request.bio()));
|
||||
user.setPreferredLanguage(normalizePreferredLanguage(request.preferredLanguage()));
|
||||
return toProfile(userRepository.save(user));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AuthResponse changePassword(String username, UpdateUserPasswordRequest request) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||
|
||||
if (!passwordEncoder.matches(request.currentPassword(), user.getPasswordHash())) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "当前密码错误");
|
||||
}
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(request.newPassword()));
|
||||
userRepository.save(user);
|
||||
refreshTokenService.revokeAllForUser(user.getId());
|
||||
return issueTokens(user);
|
||||
}
|
||||
|
||||
public InitiateUploadResponse initiateAvatarUpload(String username, UpdateUserAvatarRequest request) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||
|
||||
validateAvatarUpload(request.filename(), request.contentType(), request.size());
|
||||
String storageName = normalizeAvatarStorageName(request.storageName(), request.filename(), request.contentType());
|
||||
|
||||
var preparedUpload = fileContentStorage.prepareUpload(
|
||||
user.getId(),
|
||||
AVATAR_PATH,
|
||||
storageName,
|
||||
request.contentType(),
|
||||
request.size()
|
||||
);
|
||||
|
||||
String uploadUrl = preparedUpload.direct()
|
||||
? preparedUpload.uploadUrl()
|
||||
: "/api/user/avatar/upload?storageName=" + URLEncoder.encode(storageName, StandardCharsets.UTF_8);
|
||||
|
||||
return new InitiateUploadResponse(
|
||||
preparedUpload.direct(),
|
||||
uploadUrl,
|
||||
preparedUpload.direct() ? preparedUpload.method() : "POST",
|
||||
preparedUpload.direct() ? preparedUpload.headers() : java.util.Map.of(),
|
||||
storageName
|
||||
);
|
||||
}
|
||||
|
||||
public void uploadAvatar(String username, String storageName, MultipartFile file) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||
|
||||
String normalizedStorageName = normalizeAvatarStorageName(storageName, file.getOriginalFilename(), file.getContentType());
|
||||
validateAvatarUpload(file.getOriginalFilename(), file.getContentType(), file.getSize());
|
||||
fileContentStorage.upload(user.getId(), AVATAR_PATH, normalizedStorageName, file);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserProfileResponse completeAvatarUpload(String username, UpdateUserAvatarRequest request) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||
|
||||
validateAvatarUpload(request.filename(), request.contentType(), request.size());
|
||||
String storageName = normalizeAvatarStorageName(request.storageName(), request.filename(), request.contentType());
|
||||
|
||||
fileContentStorage.completeUpload(user.getId(), AVATAR_PATH, storageName, request.contentType(), request.size());
|
||||
|
||||
String previousStorageName = user.getAvatarStorageName();
|
||||
if (StringUtils.hasText(previousStorageName) && !previousStorageName.equals(storageName)) {
|
||||
fileContentStorage.deleteFile(user.getId(), AVATAR_PATH, previousStorageName);
|
||||
}
|
||||
|
||||
user.setAvatarStorageName(storageName);
|
||||
user.setAvatarContentType(request.contentType());
|
||||
user.setAvatarUpdatedAt(LocalDateTime.now());
|
||||
return toProfile(userRepository.save(user));
|
||||
}
|
||||
|
||||
public ResponseEntity<?> getAvatarContent(String username) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||
|
||||
if (!StringUtils.hasText(user.getAvatarStorageName())) {
|
||||
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "头像不存在");
|
||||
}
|
||||
|
||||
String downloadName = buildAvatarDownloadName(user.getAvatarStorageName(), user.getAvatarContentType());
|
||||
if (fileContentStorage.supportsDirectDownload()) {
|
||||
return ResponseEntity.status(302)
|
||||
.location(URI.create(fileContentStorage.createDownloadUrl(
|
||||
user.getId(),
|
||||
AVATAR_PATH,
|
||||
user.getAvatarStorageName(),
|
||||
downloadName
|
||||
)))
|
||||
.build();
|
||||
}
|
||||
|
||||
byte[] content = fileContentStorage.readFile(user.getId(), AVATAR_PATH, user.getAvatarStorageName());
|
||||
String contentType = StringUtils.hasText(user.getAvatarContentType())
|
||||
? user.getAvatarContentType()
|
||||
: MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"inline; filename*=UTF-8''" + URLEncoder.encode(downloadName, StandardCharsets.UTF_8))
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.body(content);
|
||||
}
|
||||
|
||||
private UserProfileResponse toProfile(User user) {
|
||||
return new UserProfileResponse(user.getId(), user.getUsername(), user.getEmail(), user.getCreatedAt());
|
||||
return new UserProfileResponse(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getDisplayName(),
|
||||
user.getEmail(),
|
||||
user.getBio(),
|
||||
user.getPreferredLanguage(),
|
||||
buildAvatarUrl(user),
|
||||
user.getRole(),
|
||||
user.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
private AuthResponse issueTokens(User user) {
|
||||
@@ -101,4 +261,89 @@ public class AuthService {
|
||||
String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getUsername());
|
||||
return AuthResponse.issued(accessToken, refreshToken, toProfile(user));
|
||||
}
|
||||
|
||||
private String normalizeOptionalText(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private String normalizePreferredLanguage(String preferredLanguage) {
|
||||
if (preferredLanguage == null || preferredLanguage.trim().isEmpty()) {
|
||||
return "zh-CN";
|
||||
}
|
||||
return preferredLanguage.trim();
|
||||
}
|
||||
|
||||
private void validateAvatarUpload(String filename, String contentType, long size) {
|
||||
if (!StringUtils.hasText(filename)) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "头像文件名不能为空");
|
||||
}
|
||||
if (!StringUtils.hasText(contentType) || !contentType.toLowerCase(Locale.ROOT).startsWith("image/")) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "头像仅支持图片文件");
|
||||
}
|
||||
if (size <= 0 || size > MAX_AVATAR_SIZE) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "头像大小不能超过 5MB");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeAvatarStorageName(String requestedStorageName, String filename, String contentType) {
|
||||
String candidate = StringUtils.hasText(requestedStorageName)
|
||||
? requestedStorageName.trim()
|
||||
: "avatar-" + UUID.randomUUID() + resolveAvatarExtension(filename, contentType);
|
||||
candidate = candidate.replace("\\", "/");
|
||||
if (candidate.contains("/")) {
|
||||
candidate = candidate.substring(candidate.lastIndexOf('/') + 1);
|
||||
}
|
||||
if (!StringUtils.hasText(candidate)) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "头像文件名不合法");
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private String resolveAvatarExtension(String filename, String contentType) {
|
||||
if (StringUtils.hasText(filename)) {
|
||||
int dot = filename.lastIndexOf('.');
|
||||
if (dot >= 0 && dot < filename.length() - 1) {
|
||||
String extension = filename.substring(dot).toLowerCase(Locale.ROOT);
|
||||
if (extension.matches("\\.[a-z0-9]{1,8}")) {
|
||||
return extension;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return switch (contentType.toLowerCase(Locale.ROOT)) {
|
||||
case "image/jpeg" -> ".jpg";
|
||||
case "image/webp" -> ".webp";
|
||||
case "image/gif" -> ".gif";
|
||||
default -> ".png";
|
||||
};
|
||||
}
|
||||
|
||||
private String buildAvatarUrl(User user) {
|
||||
if (!StringUtils.hasText(user.getAvatarStorageName())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fileContentStorage.supportsDirectDownload()) {
|
||||
return fileContentStorage.createDownloadUrl(
|
||||
user.getId(),
|
||||
AVATAR_PATH,
|
||||
user.getAvatarStorageName(),
|
||||
buildAvatarDownloadName(user.getAvatarStorageName(), user.getAvatarContentType())
|
||||
);
|
||||
}
|
||||
|
||||
long version = user.getAvatarUpdatedAt() == null ? 0L : user.getAvatarUpdatedAt().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli();
|
||||
return "/user/avatar/content?v=" + version;
|
||||
}
|
||||
|
||||
private String buildAvatarDownloadName(String storageName, String contentType) {
|
||||
if (StringUtils.hasText(storageName) && storageName.contains(".")) {
|
||||
return storageName;
|
||||
}
|
||||
return "avatar" + resolveAvatarExtension(storageName, contentType == null ? "image/png" : contentType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ public class CustomUserDetailsService implements UserDetailsService {
|
||||
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
|
||||
return org.springframework.security.core.userdetails.User.withUsername(user.getUsername())
|
||||
.password(user.getPasswordHash())
|
||||
.authorities("ROLE_USER")
|
||||
.authorities("ROLE_" + user.getRole().name())
|
||||
.disabled(user.isBanned())
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
33
backend/src/main/java/com/yoyuzh/auth/PasswordPolicy.java
Normal file
33
backend/src/main/java/com/yoyuzh/auth/PasswordPolicy.java
Normal file
@@ -0,0 +1,33 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
public final class PasswordPolicy {
|
||||
|
||||
private PasswordPolicy() {
|
||||
}
|
||||
|
||||
public static boolean isStrong(String password) {
|
||||
if (password == null || password.length() < 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean hasLower = false;
|
||||
boolean hasUpper = false;
|
||||
boolean hasDigit = false;
|
||||
boolean hasSpecial = false;
|
||||
|
||||
for (int i = 0; i < password.length(); i += 1) {
|
||||
char c = password.charAt(i);
|
||||
if (Character.isLowerCase(c)) {
|
||||
hasLower = true;
|
||||
} else if (Character.isUpperCase(c)) {
|
||||
hasUpper = true;
|
||||
} else if (Character.isDigit(c)) {
|
||||
hasDigit = true;
|
||||
} else {
|
||||
hasSpecial = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasLower && hasUpper && hasDigit && hasSpecial;
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@ package com.yoyuzh.auth;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
|
||||
@@ -12,4 +15,12 @@ public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select token from RefreshToken token join fetch token.user where token.tokenHash = :tokenHash")
|
||||
Optional<RefreshToken> findForUpdateByTokenHash(String tokenHash);
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
update RefreshToken token
|
||||
set token.revoked = true, token.revokedAt = :revokedAt
|
||||
where token.user.id = :userId and token.revoked = false
|
||||
""")
|
||||
int revokeAllActiveByUserId(@Param("userId") Long userId, @Param("revokedAt") LocalDateTime revokedAt);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,11 @@ public class RefreshTokenService {
|
||||
return new RotatedRefreshToken(user, nextRefreshToken);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void revokeAllForUser(Long userId) {
|
||||
refreshTokenRepository.revokeAllActiveByUserId(userId, LocalDateTime.now());
|
||||
}
|
||||
|
||||
private String generateRawToken() {
|
||||
byte[] bytes = new byte[REFRESH_TOKEN_BYTES];
|
||||
secureRandom.nextBytes(bytes);
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.yoyuzh.auth;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
@@ -41,11 +43,45 @@ public class User {
|
||||
@Column(name = "last_school_semester", length = 64)
|
||||
private String lastSchoolSemester;
|
||||
|
||||
@Column(name = "display_name", nullable = false, length = 64)
|
||||
private String displayName;
|
||||
|
||||
@Column(length = 280)
|
||||
private String bio;
|
||||
|
||||
@Column(name = "preferred_language", nullable = false, length = 16)
|
||||
private String preferredLanguage;
|
||||
|
||||
@Column(name = "avatar_storage_name", length = 255)
|
||||
private String avatarStorageName;
|
||||
|
||||
@Column(name = "avatar_content_type", length = 128)
|
||||
private String avatarContentType;
|
||||
|
||||
@Column(name = "avatar_updated_at")
|
||||
private LocalDateTime avatarUpdatedAt;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private UserRole role;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean banned;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
if (role == null) {
|
||||
role = UserRole.USER;
|
||||
}
|
||||
if (displayName == null || displayName.isBlank()) {
|
||||
displayName = username;
|
||||
}
|
||||
if (preferredLanguage == null || preferredLanguage.isBlank()) {
|
||||
preferredLanguage = "zh-CN";
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
@@ -103,4 +139,68 @@ public class User {
|
||||
public void setLastSchoolSemester(String lastSchoolSemester) {
|
||||
this.lastSchoolSemester = lastSchoolSemester;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getBio() {
|
||||
return bio;
|
||||
}
|
||||
|
||||
public void setBio(String bio) {
|
||||
this.bio = bio;
|
||||
}
|
||||
|
||||
public String getPreferredLanguage() {
|
||||
return preferredLanguage;
|
||||
}
|
||||
|
||||
public void setPreferredLanguage(String preferredLanguage) {
|
||||
this.preferredLanguage = preferredLanguage;
|
||||
}
|
||||
|
||||
public String getAvatarStorageName() {
|
||||
return avatarStorageName;
|
||||
}
|
||||
|
||||
public void setAvatarStorageName(String avatarStorageName) {
|
||||
this.avatarStorageName = avatarStorageName;
|
||||
}
|
||||
|
||||
public String getAvatarContentType() {
|
||||
return avatarContentType;
|
||||
}
|
||||
|
||||
public void setAvatarContentType(String avatarContentType) {
|
||||
this.avatarContentType = avatarContentType;
|
||||
}
|
||||
|
||||
public LocalDateTime getAvatarUpdatedAt() {
|
||||
return avatarUpdatedAt;
|
||||
}
|
||||
|
||||
public void setAvatarUpdatedAt(LocalDateTime avatarUpdatedAt) {
|
||||
this.avatarUpdatedAt = avatarUpdatedAt;
|
||||
}
|
||||
|
||||
public UserRole getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public void setRole(UserRole role) {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public boolean isBanned() {
|
||||
return banned;
|
||||
}
|
||||
|
||||
public void setBanned(boolean banned) {
|
||||
this.banned = banned;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,23 @@ package com.yoyuzh.auth;
|
||||
|
||||
import com.yoyuzh.common.ApiResponse;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import com.yoyuzh.auth.dto.UpdateUserAvatarRequest;
|
||||
import com.yoyuzh.auth.dto.UpdateUserPasswordRequest;
|
||||
import com.yoyuzh.auth.dto.UpdateUserProfileRequest;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/user")
|
||||
@@ -21,4 +32,47 @@ public class UserController {
|
||||
public ApiResponse<?> profile(@AuthenticationPrincipal UserDetails userDetails) {
|
||||
return ApiResponse.success(authService.getProfile(userDetails.getUsername()));
|
||||
}
|
||||
|
||||
@Operation(summary = "更新用户资料")
|
||||
@PutMapping("/profile")
|
||||
public ApiResponse<?> updateProfile(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@Valid @RequestBody UpdateUserProfileRequest request) {
|
||||
return ApiResponse.success(authService.updateProfile(userDetails.getUsername(), request));
|
||||
}
|
||||
|
||||
@Operation(summary = "修改当前用户密码")
|
||||
@PostMapping("/password")
|
||||
public ApiResponse<?> changePassword(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@Valid @RequestBody UpdateUserPasswordRequest request) {
|
||||
return ApiResponse.success(authService.changePassword(userDetails.getUsername(), request));
|
||||
}
|
||||
|
||||
@Operation(summary = "初始化头像上传")
|
||||
@PostMapping("/avatar/upload/initiate")
|
||||
public ApiResponse<?> initiateAvatarUpload(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@Valid @RequestBody UpdateUserAvatarRequest request) {
|
||||
return ApiResponse.success(authService.initiateAvatarUpload(userDetails.getUsername(), request));
|
||||
}
|
||||
|
||||
@Operation(summary = "代理上传头像")
|
||||
@PostMapping("/avatar/upload")
|
||||
public ApiResponse<?> uploadAvatar(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@RequestParam String storageName,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
authService.uploadAvatar(userDetails.getUsername(), storageName, file);
|
||||
return ApiResponse.success();
|
||||
}
|
||||
|
||||
@Operation(summary = "完成头像上传")
|
||||
@PostMapping("/avatar/upload/complete")
|
||||
public ApiResponse<?> completeAvatarUpload(@AuthenticationPrincipal UserDetails userDetails,
|
||||
@Valid @RequestBody UpdateUserAvatarRequest request) {
|
||||
return ApiResponse.success(authService.completeAvatarUpload(userDetails.getUsername(), request));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取当前用户头像")
|
||||
@GetMapping("/avatar/content")
|
||||
public ResponseEntity<?> avatarContent(@AuthenticationPrincipal UserDetails userDetails) {
|
||||
return authService.getAvatarContent(userDetails.getUsername());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
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.Optional;
|
||||
|
||||
@@ -10,4 +14,16 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
boolean existsByEmail(String email);
|
||||
|
||||
Optional<User> findByUsername(String username);
|
||||
|
||||
long countByLastSchoolStudentIdIsNotNull();
|
||||
|
||||
Page<User> findByLastSchoolStudentIdIsNotNull(Pageable pageable);
|
||||
|
||||
@Query("""
|
||||
select u from User u
|
||||
where (:query is null or :query = ''
|
||||
or lower(u.username) like lower(concat('%', :query, '%'))
|
||||
or lower(u.email) like lower(concat('%', :query, '%')))
|
||||
""")
|
||||
Page<User> searchByUsernameOrEmail(@Param("query") String query, Pageable pageable);
|
||||
}
|
||||
|
||||
7
backend/src/main/java/com/yoyuzh/auth/UserRole.java
Normal file
7
backend/src/main/java/com/yoyuzh/auth/UserRole.java
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
public enum UserRole {
|
||||
USER,
|
||||
MODERATOR,
|
||||
ADMIN
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.yoyuzh.auth.dto;
|
||||
|
||||
import com.yoyuzh.auth.PasswordPolicy;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
@@ -13,28 +14,6 @@ public record RegisterRequest(
|
||||
|
||||
@AssertTrue(message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符")
|
||||
public boolean isPasswordStrong() {
|
||||
if (password == null || password.length() < 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean hasLower = false;
|
||||
boolean hasUpper = false;
|
||||
boolean hasDigit = false;
|
||||
boolean hasSpecial = false;
|
||||
|
||||
for (int i = 0; i < password.length(); i += 1) {
|
||||
char c = password.charAt(i);
|
||||
if (Character.isLowerCase(c)) {
|
||||
hasLower = true;
|
||||
} else if (Character.isUpperCase(c)) {
|
||||
hasUpper = true;
|
||||
} else if (Character.isDigit(c)) {
|
||||
hasDigit = true;
|
||||
} else {
|
||||
hasSpecial = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasLower && hasUpper && hasDigit && hasSpecial;
|
||||
return PasswordPolicy.isStrong(password);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.yoyuzh.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record UpdateUserAvatarRequest(
|
||||
@NotBlank @Size(max = 255) String filename,
|
||||
@NotBlank @Size(max = 128) String contentType,
|
||||
@Positive long size,
|
||||
@Size(max = 255) String storageName
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.yoyuzh.auth.dto;
|
||||
|
||||
import com.yoyuzh.auth.PasswordPolicy;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record UpdateUserPasswordRequest(
|
||||
@NotBlank String currentPassword,
|
||||
@NotBlank
|
||||
@Size(min = 10, max = 64, message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符")
|
||||
String newPassword
|
||||
) {
|
||||
|
||||
@AssertTrue(message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符")
|
||||
public boolean isPasswordStrong() {
|
||||
return PasswordPolicy.isStrong(newPassword);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.yoyuzh.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record UpdateUserProfileRequest(
|
||||
@NotBlank @Size(min = 2, max = 64) String displayName,
|
||||
@NotBlank @Email @Size(max = 128) String email,
|
||||
@Size(max = 280) String bio,
|
||||
@Size(min = 2, max = 16) String preferredLanguage
|
||||
) {
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
package com.yoyuzh.auth.dto;
|
||||
|
||||
import com.yoyuzh.auth.UserRole;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record UserProfileResponse(Long id, String username, String email, LocalDateTime createdAt) {
|
||||
public record UserProfileResponse(
|
||||
Long id,
|
||||
String username,
|
||||
String displayName,
|
||||
String email,
|
||||
String bio,
|
||||
String preferredLanguage,
|
||||
String avatarUrl,
|
||||
UserRole role,
|
||||
LocalDateTime createdAt
|
||||
) {
|
||||
public UserProfileResponse(Long id, String username, String email, LocalDateTime createdAt) {
|
||||
this(id, username, username, email, null, "zh-CN", null, UserRole.USER, createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
20
backend/src/main/java/com/yoyuzh/config/AdminProperties.java
Normal file
20
backend/src/main/java/com/yoyuzh/config/AdminProperties.java
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.yoyuzh.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@ConfigurationProperties(prefix = "app.admin")
|
||||
public class AdminProperties {
|
||||
|
||||
private List<String> usernames = new ArrayList<>();
|
||||
|
||||
public List<String> getUsernames() {
|
||||
return usernames;
|
||||
}
|
||||
|
||||
public void setUsernames(List<String> usernames) {
|
||||
this.usernames = usernames;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,10 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
&& SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
String username = jwtTokenProvider.getUsername(token);
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
if (!userDetails.isEnabled()) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
|
||||
userDetails, null, userDetails.getAuthorities());
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
|
||||
@@ -47,6 +47,8 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
|
||||
.permitAll()
|
||||
.requestMatchers("/api/admin/**")
|
||||
.authenticated()
|
||||
.requestMatchers("/api/files/**", "/api/user/**", "/api/cqu/**")
|
||||
.authenticated()
|
||||
.anyRequest()
|
||||
|
||||
@@ -11,4 +11,6 @@ public interface CourseRepository extends JpaRepository<Course, Long> {
|
||||
Optional<Course> findTopByUserIdOrderByCreatedAtDesc(Long userId);
|
||||
|
||||
void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
||||
|
||||
long countByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
||||
}
|
||||
|
||||
@@ -13,4 +13,6 @@ public interface GradeRepository extends JpaRepository<Grade, Long> {
|
||||
Optional<Grade> findTopByUserIdOrderByCreatedAtDesc(Long userId);
|
||||
|
||||
void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
||||
|
||||
long countByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.yoyuzh.files;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.EntityGraph;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -10,6 +11,24 @@ import java.util.List;
|
||||
|
||||
public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
||||
|
||||
@EntityGraph(attributePaths = "user")
|
||||
Page<StoredFile> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||
|
||||
@EntityGraph(attributePaths = "user")
|
||||
@Query("""
|
||||
select f from StoredFile f
|
||||
join f.user u
|
||||
where (:query is null or :query = ''
|
||||
or lower(f.filename) like lower(concat('%', :query, '%'))
|
||||
or lower(f.path) like lower(concat('%', :query, '%')))
|
||||
and (:ownerQuery is null or :ownerQuery = ''
|
||||
or lower(u.username) like lower(concat('%', :ownerQuery, '%'))
|
||||
or lower(u.email) like lower(concat('%', :ownerQuery, '%')))
|
||||
""")
|
||||
Page<StoredFile> searchAdminFiles(@Param("query") String query,
|
||||
@Param("ownerQuery") String ownerQuery,
|
||||
Pageable pageable);
|
||||
|
||||
@Query("""
|
||||
select case when count(f) > 0 then true else false end
|
||||
from StoredFile f
|
||||
|
||||
@@ -15,5 +15,7 @@ spring:
|
||||
app:
|
||||
jwt:
|
||||
secret: ${APP_JWT_SECRET:}
|
||||
admin:
|
||||
usernames: ${APP_ADMIN_USERNAMES:}
|
||||
cqu:
|
||||
mock-enabled: true
|
||||
|
||||
@@ -26,6 +26,8 @@ app:
|
||||
secret: ${APP_JWT_SECRET:}
|
||||
access-expiration-seconds: 900
|
||||
refresh-expiration-seconds: 1209600
|
||||
admin:
|
||||
usernames: ${APP_ADMIN_USERNAMES:}
|
||||
storage:
|
||||
root-dir: ./storage
|
||||
max-file-size: 524288000
|
||||
|
||||
Reference in New Issue
Block a user