添加账号修改,后台管理

This commit is contained in:
yoyuzh
2026-03-19 17:52:58 +08:00
parent c39fde6b19
commit ff8d47f44f
60 changed files with 4264 additions and 58 deletions

View File

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

View File

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

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

View File

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

View File

@@ -0,0 +1,4 @@
package com.yoyuzh.admin;
public record AdminPasswordResetResponse(String temporaryPassword) {
}

View File

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

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

View File

@@ -0,0 +1,8 @@
package com.yoyuzh.admin;
public record AdminSummaryResponse(
long totalUsers,
long totalFiles,
long usersWithSchoolCache
) {
}

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package com.yoyuzh.admin;
import com.yoyuzh.auth.UserRole;
import jakarta.validation.constraints.NotNull;
public record AdminUserRoleUpdateRequest(@NotNull UserRole role) {
}

View File

@@ -0,0 +1,6 @@
package com.yoyuzh.admin;
import jakarta.validation.constraints.NotNull;
public record AdminUserStatusUpdateRequest(@NotNull Boolean banned) {
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package com.yoyuzh.auth;
public enum UserRole {
USER,
MODERATOR,
ADMIN
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,5 +15,7 @@ spring:
app:
jwt:
secret: ${APP_JWT_SECRET:}
admin:
usernames: ${APP_ADMIN_USERNAMES:}
cqu:
mock-enabled: true

View File

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