添加账号修改,后台管理
This commit is contained in:
@@ -4,6 +4,7 @@ import com.yoyuzh.config.CquApiProperties;
|
|||||||
import com.yoyuzh.config.CorsProperties;
|
import com.yoyuzh.config.CorsProperties;
|
||||||
import com.yoyuzh.config.FileStorageProperties;
|
import com.yoyuzh.config.FileStorageProperties;
|
||||||
import com.yoyuzh.config.JwtProperties;
|
import com.yoyuzh.config.JwtProperties;
|
||||||
|
import com.yoyuzh.config.AdminProperties;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
@@ -13,7 +14,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
|||||||
JwtProperties.class,
|
JwtProperties.class,
|
||||||
FileStorageProperties.class,
|
FileStorageProperties.class,
|
||||||
CquApiProperties.class,
|
CquApiProperties.class,
|
||||||
CorsProperties.class
|
CorsProperties.class,
|
||||||
|
AdminProperties.class
|
||||||
})
|
})
|
||||||
public class PortalBackendApplication {
|
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.AuthResponse;
|
||||||
import com.yoyuzh.auth.dto.LoginRequest;
|
import com.yoyuzh.auth.dto.LoginRequest;
|
||||||
import com.yoyuzh.auth.dto.RegisterRequest;
|
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.auth.dto.UserProfileResponse;
|
||||||
import com.yoyuzh.common.BusinessException;
|
import com.yoyuzh.common.BusinessException;
|
||||||
import com.yoyuzh.common.ErrorCode;
|
import com.yoyuzh.common.ErrorCode;
|
||||||
import com.yoyuzh.files.FileService;
|
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 lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.security.authentication.AuthenticationManager;
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
import org.springframework.security.authentication.BadCredentialsException;
|
import org.springframework.security.authentication.BadCredentialsException;
|
||||||
|
import org.springframework.security.authentication.DisabledException;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AuthService {
|
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 UserRepository userRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
@@ -25,6 +45,7 @@ public class AuthService {
|
|||||||
private final JwtTokenProvider jwtTokenProvider;
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
private final RefreshTokenService refreshTokenService;
|
private final RefreshTokenService refreshTokenService;
|
||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
|
private final FileContentStorage fileContentStorage;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AuthResponse register(RegisterRequest request) {
|
public AuthResponse register(RegisterRequest request) {
|
||||||
@@ -37,8 +58,11 @@ public class AuthService {
|
|||||||
|
|
||||||
User user = new User();
|
User user = new User();
|
||||||
user.setUsername(request.username());
|
user.setUsername(request.username());
|
||||||
|
user.setDisplayName(request.username());
|
||||||
user.setEmail(request.email());
|
user.setEmail(request.email());
|
||||||
user.setPasswordHash(passwordEncoder.encode(request.password()));
|
user.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||||
|
user.setRole(UserRole.USER);
|
||||||
|
user.setPreferredLanguage("zh-CN");
|
||||||
User saved = userRepository.save(user);
|
User saved = userRepository.save(user);
|
||||||
fileService.ensureDefaultDirectories(saved);
|
fileService.ensureDefaultDirectories(saved);
|
||||||
return issueTokens(saved);
|
return issueTokens(saved);
|
||||||
@@ -48,6 +72,8 @@ public class AuthService {
|
|||||||
try {
|
try {
|
||||||
authenticationManager.authenticate(
|
authenticationManager.authenticate(
|
||||||
new UsernamePasswordAuthenticationToken(request.username(), request.password()));
|
new UsernamePasswordAuthenticationToken(request.username(), request.password()));
|
||||||
|
} catch (DisabledException ex) {
|
||||||
|
throw new BusinessException(ErrorCode.PERMISSION_DENIED, "账号已被封禁");
|
||||||
} catch (BadCredentialsException ex) {
|
} catch (BadCredentialsException ex) {
|
||||||
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户名或密码错误");
|
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户名或密码错误");
|
||||||
}
|
}
|
||||||
@@ -69,8 +95,11 @@ public class AuthService {
|
|||||||
User user = userRepository.findByUsername(finalCandidate).orElseGet(() -> {
|
User user = userRepository.findByUsername(finalCandidate).orElseGet(() -> {
|
||||||
User created = new User();
|
User created = new User();
|
||||||
created.setUsername(finalCandidate);
|
created.setUsername(finalCandidate);
|
||||||
|
created.setDisplayName(finalCandidate);
|
||||||
created.setEmail(finalCandidate + "@dev.local");
|
created.setEmail(finalCandidate + "@dev.local");
|
||||||
created.setPasswordHash(passwordEncoder.encode("1"));
|
created.setPasswordHash(passwordEncoder.encode("1"));
|
||||||
|
created.setRole(UserRole.USER);
|
||||||
|
created.setPreferredLanguage("zh-CN");
|
||||||
return userRepository.save(created);
|
return userRepository.save(created);
|
||||||
});
|
});
|
||||||
fileService.ensureDefaultDirectories(user);
|
fileService.ensureDefaultDirectories(user);
|
||||||
@@ -89,8 +118,139 @@ public class AuthService {
|
|||||||
return toProfile(user);
|
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) {
|
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) {
|
private AuthResponse issueTokens(User user) {
|
||||||
@@ -101,4 +261,89 @@ public class AuthService {
|
|||||||
String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getUsername());
|
String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getUsername());
|
||||||
return AuthResponse.issued(accessToken, refreshToken, toProfile(user));
|
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("用户不存在"));
|
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
|
||||||
return org.springframework.security.core.userdetails.User.withUsername(user.getUsername())
|
return org.springframework.security.core.userdetails.User.withUsername(user.getUsername())
|
||||||
.password(user.getPasswordHash())
|
.password(user.getPasswordHash())
|
||||||
.authorities("ROLE_USER")
|
.authorities("ROLE_" + user.getRole().name())
|
||||||
|
.disabled(user.isBanned())
|
||||||
.build();
|
.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 jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Lock;
|
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.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
|
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
|
||||||
@@ -12,4 +15,12 @@ public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long
|
|||||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
@Query("select token from RefreshToken token join fetch token.user where token.tokenHash = :tokenHash")
|
@Query("select token from RefreshToken token join fetch token.user where token.tokenHash = :tokenHash")
|
||||||
Optional<RefreshToken> findForUpdateByTokenHash(String 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);
|
return new RotatedRefreshToken(user, nextRefreshToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void revokeAllForUser(Long userId) {
|
||||||
|
refreshTokenRepository.revokeAllActiveByUserId(userId, LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
private String generateRawToken() {
|
private String generateRawToken() {
|
||||||
byte[] bytes = new byte[REFRESH_TOKEN_BYTES];
|
byte[] bytes = new byte[REFRESH_TOKEN_BYTES];
|
||||||
secureRandom.nextBytes(bytes);
|
secureRandom.nextBytes(bytes);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.yoyuzh.auth;
|
|||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
@@ -41,11 +43,45 @@ public class User {
|
|||||||
@Column(name = "last_school_semester", length = 64)
|
@Column(name = "last_school_semester", length = 64)
|
||||||
private String lastSchoolSemester;
|
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
|
@PrePersist
|
||||||
public void prePersist() {
|
public void prePersist() {
|
||||||
if (createdAt == null) {
|
if (createdAt == null) {
|
||||||
createdAt = LocalDateTime.now();
|
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() {
|
public Long getId() {
|
||||||
@@ -103,4 +139,68 @@ public class User {
|
|||||||
public void setLastSchoolSemester(String lastSchoolSemester) {
|
public void setLastSchoolSemester(String lastSchoolSemester) {
|
||||||
this.lastSchoolSemester = 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 com.yoyuzh.common.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
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.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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
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
|
@RestController
|
||||||
@RequestMapping("/api/user")
|
@RequestMapping("/api/user")
|
||||||
@@ -21,4 +32,47 @@ public class UserController {
|
|||||||
public ApiResponse<?> profile(@AuthenticationPrincipal UserDetails userDetails) {
|
public ApiResponse<?> profile(@AuthenticationPrincipal UserDetails userDetails) {
|
||||||
return ApiResponse.success(authService.getProfile(userDetails.getUsername()));
|
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;
|
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.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -10,4 +14,16 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
|||||||
boolean existsByEmail(String email);
|
boolean existsByEmail(String email);
|
||||||
|
|
||||||
Optional<User> findByUsername(String username);
|
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;
|
package com.yoyuzh.auth.dto;
|
||||||
|
|
||||||
|
import com.yoyuzh.auth.PasswordPolicy;
|
||||||
import jakarta.validation.constraints.Email;
|
import jakarta.validation.constraints.Email;
|
||||||
import jakarta.validation.constraints.AssertTrue;
|
import jakarta.validation.constraints.AssertTrue;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
@@ -13,28 +14,6 @@ public record RegisterRequest(
|
|||||||
|
|
||||||
@AssertTrue(message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符")
|
@AssertTrue(message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符")
|
||||||
public boolean isPasswordStrong() {
|
public boolean isPasswordStrong() {
|
||||||
if (password == null || password.length() < 10) {
|
return PasswordPolicy.isStrong(password);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
package com.yoyuzh.auth.dto;
|
||||||
|
|
||||||
|
import com.yoyuzh.auth.UserRole;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
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) {
|
&& SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
String username = jwtTokenProvider.getUsername(token);
|
String username = jwtTokenProvider.getUsername(token);
|
||||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||||
|
if (!userDetails.isEnabled()) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
|
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
|
||||||
userDetails, null, userDetails.getAuthorities());
|
userDetails, null, userDetails.getAuthorities());
|
||||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ public class SecurityConfig {
|
|||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
|
.requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
|
||||||
.permitAll()
|
.permitAll()
|
||||||
|
.requestMatchers("/api/admin/**")
|
||||||
|
.authenticated()
|
||||||
.requestMatchers("/api/files/**", "/api/user/**", "/api/cqu/**")
|
.requestMatchers("/api/files/**", "/api/user/**", "/api/cqu/**")
|
||||||
.authenticated()
|
.authenticated()
|
||||||
.anyRequest()
|
.anyRequest()
|
||||||
|
|||||||
@@ -11,4 +11,6 @@ public interface CourseRepository extends JpaRepository<Course, Long> {
|
|||||||
Optional<Course> findTopByUserIdOrderByCreatedAtDesc(Long userId);
|
Optional<Course> findTopByUserIdOrderByCreatedAtDesc(Long userId);
|
||||||
|
|
||||||
void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
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);
|
Optional<Grade> findTopByUserIdOrderByCreatedAtDesc(Long userId);
|
||||||
|
|
||||||
void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
|
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.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
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.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
@@ -10,6 +11,24 @@ import java.util.List;
|
|||||||
|
|
||||||
public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
|
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("""
|
@Query("""
|
||||||
select case when count(f) > 0 then true else false end
|
select case when count(f) > 0 then true else false end
|
||||||
from StoredFile f
|
from StoredFile f
|
||||||
|
|||||||
@@ -15,5 +15,7 @@ spring:
|
|||||||
app:
|
app:
|
||||||
jwt:
|
jwt:
|
||||||
secret: ${APP_JWT_SECRET:}
|
secret: ${APP_JWT_SECRET:}
|
||||||
|
admin:
|
||||||
|
usernames: ${APP_ADMIN_USERNAMES:}
|
||||||
cqu:
|
cqu:
|
||||||
mock-enabled: true
|
mock-enabled: true
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ app:
|
|||||||
secret: ${APP_JWT_SECRET:}
|
secret: ${APP_JWT_SECRET:}
|
||||||
access-expiration-seconds: 900
|
access-expiration-seconds: 900
|
||||||
refresh-expiration-seconds: 1209600
|
refresh-expiration-seconds: 1209600
|
||||||
|
admin:
|
||||||
|
usernames: ${APP_ADMIN_USERNAMES:}
|
||||||
storage:
|
storage:
|
||||||
root-dir: ./storage
|
root-dir: ./storage
|
||||||
max-file-size: 524288000
|
max-file-size: 524288000
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package com.yoyuzh.admin;
|
||||||
|
|
||||||
|
import com.yoyuzh.PortalBackendApplication;
|
||||||
|
import com.yoyuzh.auth.User;
|
||||||
|
import com.yoyuzh.auth.UserRepository;
|
||||||
|
import com.yoyuzh.files.StoredFile;
|
||||||
|
import com.yoyuzh.files.StoredFileRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@SpringBootTest(
|
||||||
|
classes = PortalBackendApplication.class,
|
||||||
|
properties = {
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:admin_api_test;MODE=MySQL;DB_CLOSE_DELAY=-1",
|
||||||
|
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||||
|
"spring.datasource.username=sa",
|
||||||
|
"spring.datasource.password=",
|
||||||
|
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||||
|
"app.jwt.secret=0123456789abcdef0123456789abcdef",
|
||||||
|
"app.admin.usernames=admin",
|
||||||
|
"app.storage.root-dir=./target/test-storage-admin",
|
||||||
|
"app.cqu.require-login=true",
|
||||||
|
"app.cqu.mock-enabled=false"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class AdminControllerIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StoredFileRepository storedFileRepository;
|
||||||
|
|
||||||
|
private User portalUser;
|
||||||
|
private User secondaryUser;
|
||||||
|
private StoredFile storedFile;
|
||||||
|
private StoredFile secondaryFile;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
storedFileRepository.deleteAll();
|
||||||
|
userRepository.deleteAll();
|
||||||
|
|
||||||
|
portalUser = new User();
|
||||||
|
portalUser.setUsername("alice");
|
||||||
|
portalUser.setEmail("alice@example.com");
|
||||||
|
portalUser.setPasswordHash("encoded-password");
|
||||||
|
portalUser.setCreatedAt(LocalDateTime.now());
|
||||||
|
portalUser.setLastSchoolStudentId("20230001");
|
||||||
|
portalUser.setLastSchoolSemester("2025-2026-1");
|
||||||
|
portalUser = userRepository.save(portalUser);
|
||||||
|
|
||||||
|
secondaryUser = new User();
|
||||||
|
secondaryUser.setUsername("bob");
|
||||||
|
secondaryUser.setEmail("bob@example.com");
|
||||||
|
secondaryUser.setPasswordHash("encoded-password");
|
||||||
|
secondaryUser.setCreatedAt(LocalDateTime.now().minusDays(1));
|
||||||
|
secondaryUser = userRepository.save(secondaryUser);
|
||||||
|
|
||||||
|
storedFile = new StoredFile();
|
||||||
|
storedFile.setUser(portalUser);
|
||||||
|
storedFile.setFilename("report.pdf");
|
||||||
|
storedFile.setPath("/");
|
||||||
|
storedFile.setStorageName("report.pdf");
|
||||||
|
storedFile.setContentType("application/pdf");
|
||||||
|
storedFile.setSize(1024L);
|
||||||
|
storedFile.setDirectory(false);
|
||||||
|
storedFile.setCreatedAt(LocalDateTime.now());
|
||||||
|
storedFile = storedFileRepository.save(storedFile);
|
||||||
|
|
||||||
|
secondaryFile = new StoredFile();
|
||||||
|
secondaryFile.setUser(secondaryUser);
|
||||||
|
secondaryFile.setFilename("notes.txt");
|
||||||
|
secondaryFile.setPath("/docs");
|
||||||
|
secondaryFile.setStorageName("notes.txt");
|
||||||
|
secondaryFile.setContentType("text/plain");
|
||||||
|
secondaryFile.setSize(256L);
|
||||||
|
secondaryFile.setDirectory(false);
|
||||||
|
secondaryFile.setCreatedAt(LocalDateTime.now().minusHours(2));
|
||||||
|
secondaryFile = storedFileRepository.save(secondaryFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "admin")
|
||||||
|
void shouldAllowConfiguredAdminToListUsersAndSummary() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/admin/users?page=0&size=10"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].username").value("alice"))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].lastSchoolStudentId").value("20230001"))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].role").value("USER"))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].banned").value(false));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/admin/summary"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.totalUsers").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.totalFiles").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.usersWithSchoolCache").value(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "admin")
|
||||||
|
void shouldSupportUserSearchPasswordAndStatusManagement() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/admin/users?page=0&size=10&query=ali"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.total").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].username").value("alice"));
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/admin/users/{userId}/role", portalUser.getId())
|
||||||
|
.contentType("application/json")
|
||||||
|
.content("""
|
||||||
|
{"role":"ADMIN"}
|
||||||
|
"""))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.role").value("ADMIN"));
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/admin/users/{userId}/status", portalUser.getId())
|
||||||
|
.contentType("application/json")
|
||||||
|
.content("""
|
||||||
|
{"banned":true}
|
||||||
|
"""))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.banned").value(true));
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/admin/users/{userId}/password", portalUser.getId())
|
||||||
|
.contentType("application/json")
|
||||||
|
.content("""
|
||||||
|
{"newPassword":"AdminSetPass1!"}
|
||||||
|
"""))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.id").value(portalUser.getId()));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/admin/users/{userId}/password/reset", secondaryUser.getId()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.temporaryPassword").isNotEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "admin")
|
||||||
|
void shouldAllowConfiguredAdminToListAndDeleteFiles() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/admin/files?page=0&size=10"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.items[0].filename").value("report.pdf"))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].ownerUsername").value("alice"));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/admin/files?page=0&size=10&query=report&ownerQuery=ali"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.total").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].filename").value("report.pdf"));
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/api/admin/files/{fileId}", storedFile.getId()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "portal-user")
|
||||||
|
void shouldRejectNonAdminUser() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/admin/users?page=0&size=10"))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.msg").value("没有权限访问该资源"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,14 @@ package com.yoyuzh.auth;
|
|||||||
import com.yoyuzh.auth.dto.AuthResponse;
|
import com.yoyuzh.auth.dto.AuthResponse;
|
||||||
import com.yoyuzh.auth.dto.LoginRequest;
|
import com.yoyuzh.auth.dto.LoginRequest;
|
||||||
import com.yoyuzh.auth.dto.RegisterRequest;
|
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.common.BusinessException;
|
import com.yoyuzh.common.BusinessException;
|
||||||
import com.yoyuzh.files.FileService;
|
import com.yoyuzh.files.FileService;
|
||||||
|
import com.yoyuzh.files.InitiateUploadResponse;
|
||||||
|
import com.yoyuzh.files.storage.FileContentStorage;
|
||||||
|
import com.yoyuzh.files.storage.PreparedUpload;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
@@ -12,6 +18,7 @@ import org.mockito.Mock;
|
|||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.springframework.security.authentication.AuthenticationManager;
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
import org.springframework.security.authentication.BadCredentialsException;
|
import org.springframework.security.authentication.BadCredentialsException;
|
||||||
|
import org.springframework.security.authentication.DisabledException;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
@@ -21,6 +28,8 @@ import java.util.Optional;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@@ -45,6 +54,9 @@ class AuthServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private FileService fileService;
|
private FileService fileService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FileContentStorage fileContentStorage;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private AuthService authService;
|
private AuthService authService;
|
||||||
|
|
||||||
@@ -137,6 +149,17 @@ class AuthServiceTest {
|
|||||||
.hasMessageContaining("用户名或密码错误");
|
.hasMessageContaining("用户名或密码错误");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectBannedUserLogin() {
|
||||||
|
LoginRequest request = new LoginRequest("alice", "plain-password");
|
||||||
|
when(authenticationManager.authenticate(any()))
|
||||||
|
.thenThrow(new DisabledException("disabled"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> authService.login(request))
|
||||||
|
.isInstanceOf(BusinessException.class)
|
||||||
|
.hasMessageContaining("账号已被封禁");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldCreateDefaultDirectoriesForDevLoginUser() {
|
void shouldCreateDefaultDirectoriesForDevLoginUser() {
|
||||||
when(userRepository.findByUsername("demo")).thenReturn(Optional.empty());
|
when(userRepository.findByUsername("demo")).thenReturn(Optional.empty());
|
||||||
@@ -157,4 +180,128 @@ class AuthServiceTest {
|
|||||||
assertThat(response.refreshToken()).isEqualTo("refresh-token");
|
assertThat(response.refreshToken()).isEqualTo("refresh-token");
|
||||||
verify(fileService).ensureDefaultDirectories(any(User.class));
|
verify(fileService).ensureDefaultDirectories(any(User.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldUpdateCurrentUserProfile() {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(1L);
|
||||||
|
user.setUsername("alice");
|
||||||
|
user.setDisplayName("Alice");
|
||||||
|
user.setEmail("alice@example.com");
|
||||||
|
user.setBio("old bio");
|
||||||
|
user.setPreferredLanguage("zh-CN");
|
||||||
|
user.setRole(UserRole.USER);
|
||||||
|
user.setCreatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
UpdateUserProfileRequest request = new UpdateUserProfileRequest(
|
||||||
|
"Alicia",
|
||||||
|
"newalice@example.com",
|
||||||
|
"new bio",
|
||||||
|
"en-US"
|
||||||
|
);
|
||||||
|
|
||||||
|
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.existsByEmail("newalice@example.com")).thenReturn(false);
|
||||||
|
when(userRepository.save(user)).thenReturn(user);
|
||||||
|
|
||||||
|
var response = authService.updateProfile("alice", request);
|
||||||
|
|
||||||
|
assertThat(response.displayName()).isEqualTo("Alicia");
|
||||||
|
assertThat(response.email()).isEqualTo("newalice@example.com");
|
||||||
|
assertThat(response.bio()).isEqualTo("new bio");
|
||||||
|
assertThat(response.preferredLanguage()).isEqualTo("en-US");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldChangePasswordAndIssueFreshTokens() {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(1L);
|
||||||
|
user.setUsername("alice");
|
||||||
|
user.setDisplayName("Alice");
|
||||||
|
user.setEmail("alice@example.com");
|
||||||
|
user.setPreferredLanguage("zh-CN");
|
||||||
|
user.setRole(UserRole.USER);
|
||||||
|
user.setPasswordHash("encoded-old");
|
||||||
|
user.setCreatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
UpdateUserPasswordRequest request = new UpdateUserPasswordRequest("OldPass1!", "NewPass1!A");
|
||||||
|
|
||||||
|
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
|
||||||
|
when(passwordEncoder.matches("OldPass1!", "encoded-old")).thenReturn(true);
|
||||||
|
when(passwordEncoder.encode("NewPass1!A")).thenReturn("encoded-new");
|
||||||
|
when(userRepository.save(user)).thenReturn(user);
|
||||||
|
when(jwtTokenProvider.generateAccessToken(1L, "alice")).thenReturn("new-access");
|
||||||
|
when(refreshTokenService.issueRefreshToken(user)).thenReturn("new-refresh");
|
||||||
|
|
||||||
|
AuthResponse response = authService.changePassword("alice", request);
|
||||||
|
|
||||||
|
assertThat(response.accessToken()).isEqualTo("new-access");
|
||||||
|
assertThat(response.refreshToken()).isEqualTo("new-refresh");
|
||||||
|
verify(refreshTokenService).revokeAllForUser(1L);
|
||||||
|
verify(passwordEncoder).encode("NewPass1!A");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectPasswordChangeWhenCurrentPasswordIsWrong() {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(1L);
|
||||||
|
user.setUsername("alice");
|
||||||
|
user.setPasswordHash("encoded-old");
|
||||||
|
|
||||||
|
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
|
||||||
|
when(passwordEncoder.matches("WrongPass1!", "encoded-old")).thenReturn(false);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> authService.changePassword("alice", new UpdateUserPasswordRequest("WrongPass1!", "NewPass1!A")))
|
||||||
|
.isInstanceOf(BusinessException.class)
|
||||||
|
.hasMessageContaining("当前密码错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldInitiateAvatarUploadThroughStorage() {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(1L);
|
||||||
|
user.setUsername("alice");
|
||||||
|
|
||||||
|
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
|
||||||
|
when(fileContentStorage.prepareUpload(eq(1L), eq("/.avatar"), any(), eq("image/png"), eq(2048L)))
|
||||||
|
.thenReturn(new PreparedUpload(true, "https://upload.example.com/avatar", "PUT", java.util.Map.of("Content-Type", "image/png"), "avatar-generated.png"));
|
||||||
|
|
||||||
|
InitiateUploadResponse response = authService.initiateAvatarUpload(
|
||||||
|
"alice",
|
||||||
|
new UpdateUserAvatarRequest("face.png", "image/png", 2048L, "avatar-generated.png")
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(response.direct()).isTrue();
|
||||||
|
assertThat(response.uploadUrl()).isEqualTo("https://upload.example.com/avatar");
|
||||||
|
assertThat(response.storageName()).endsWith(".png");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldCompleteAvatarUploadAndReplacePreviousAvatar() {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(1L);
|
||||||
|
user.setUsername("alice");
|
||||||
|
user.setDisplayName("Alice");
|
||||||
|
user.setEmail("alice@example.com");
|
||||||
|
user.setPreferredLanguage("zh-CN");
|
||||||
|
user.setRole(UserRole.USER);
|
||||||
|
user.setAvatarStorageName("old-avatar.png");
|
||||||
|
user.setAvatarContentType("image/png");
|
||||||
|
user.setCreatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
|
||||||
|
when(fileContentStorage.supportsDirectDownload()).thenReturn(true);
|
||||||
|
when(fileContentStorage.createDownloadUrl(anyLong(), eq("/.avatar"), eq("new-avatar.webp"), any()))
|
||||||
|
.thenReturn("https://cdn.example.com/avatar.webp");
|
||||||
|
when(userRepository.save(user)).thenReturn(user);
|
||||||
|
|
||||||
|
var response = authService.completeAvatarUpload(
|
||||||
|
"alice",
|
||||||
|
new UpdateUserAvatarRequest("face.webp", "image/webp", 4096L, "new-avatar.webp")
|
||||||
|
);
|
||||||
|
|
||||||
|
verify(fileContentStorage).completeUpload(1L, "/.avatar", "new-avatar.webp", "image/webp", 4096L);
|
||||||
|
verify(fileContentStorage).deleteFile(1L, "/.avatar", "old-avatar.png");
|
||||||
|
assertThat(response.avatarUrl()).isEqualTo("https://cdn.example.com/avatar.webp");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1227
front/package-lock.json
generated
1227
front/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,11 @@
|
|||||||
"test": "node --import tsx --test src/**/*.test.ts"
|
"test": "node --import tsx --test src/**/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
"@google/genai": "^1.29.0",
|
"@google/genai": "^1.29.0",
|
||||||
|
"@mui/icons-material": "^7.3.9",
|
||||||
|
"@mui/material": "^7.3.9",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
@@ -23,6 +27,7 @@
|
|||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
"react-admin": "^5.14.4",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { Suspense } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { Layout } from './components/layout/Layout';
|
import { Layout } from './components/layout/Layout';
|
||||||
import { useAuth } from './auth/AuthProvider';
|
import { useAuth } from './auth/AuthProvider';
|
||||||
@@ -8,6 +8,8 @@ import Files from './pages/Files';
|
|||||||
import School from './pages/School';
|
import School from './pages/School';
|
||||||
import Games from './pages/Games';
|
import Games from './pages/Games';
|
||||||
|
|
||||||
|
const PortalAdminApp = React.lazy(() => import('./admin/AdminApp'));
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { ready, session } = useAuth();
|
const { ready, session } = useAuth();
|
||||||
|
|
||||||
@@ -37,6 +39,24 @@ function AppRoutes() {
|
|||||||
<Route path="school" element={<School />} />
|
<Route path="school" element={<School />} />
|
||||||
<Route path="games" element={<Games />} />
|
<Route path="games" element={<Games />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route
|
||||||
|
path="/admin/*"
|
||||||
|
element={
|
||||||
|
isAuthenticated ? (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-white text-slate-700">
|
||||||
|
正在加载后台管理台...
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PortalAdminApp />
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<Navigate to="/login" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={<Navigate to={isAuthenticated ? '/overview' : '/login'} replace />}
|
element={<Navigate to={isAuthenticated ? '/overview' : '/login'} replace />}
|
||||||
|
|||||||
47
front/src/admin/AdminApp.tsx
Normal file
47
front/src/admin/AdminApp.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import FolderOutlined from '@mui/icons-material/FolderOutlined';
|
||||||
|
import GroupsOutlined from '@mui/icons-material/GroupsOutlined';
|
||||||
|
import SchoolOutlined from '@mui/icons-material/SchoolOutlined';
|
||||||
|
import { Admin, Resource } from 'react-admin';
|
||||||
|
|
||||||
|
import { portalAdminAuthProvider } from './auth-provider';
|
||||||
|
import { portalAdminDataProvider } from './data-provider';
|
||||||
|
import { PortalAdminDashboard } from './dashboard';
|
||||||
|
import { PortalAdminFilesList } from './files-list';
|
||||||
|
import { PortalAdminUsersList } from './users-list';
|
||||||
|
import { PortalAdminSchoolSnapshotsList } from './school-snapshots-list';
|
||||||
|
|
||||||
|
export default function PortalAdminApp() {
|
||||||
|
return (
|
||||||
|
<Admin
|
||||||
|
authProvider={portalAdminAuthProvider}
|
||||||
|
basename="/admin"
|
||||||
|
dashboard={PortalAdminDashboard}
|
||||||
|
dataProvider={portalAdminDataProvider}
|
||||||
|
disableTelemetry
|
||||||
|
requireAuth
|
||||||
|
title="YOYUZH Admin"
|
||||||
|
>
|
||||||
|
<Resource
|
||||||
|
name="users"
|
||||||
|
icon={GroupsOutlined}
|
||||||
|
list={PortalAdminUsersList}
|
||||||
|
options={{ label: '用户资源' }}
|
||||||
|
recordRepresentation="username"
|
||||||
|
/>
|
||||||
|
<Resource
|
||||||
|
name="files"
|
||||||
|
icon={FolderOutlined}
|
||||||
|
list={PortalAdminFilesList}
|
||||||
|
options={{ label: '文件资源' }}
|
||||||
|
recordRepresentation="filename"
|
||||||
|
/>
|
||||||
|
<Resource
|
||||||
|
name="schoolSnapshots"
|
||||||
|
icon={SchoolOutlined}
|
||||||
|
list={PortalAdminSchoolSnapshotsList}
|
||||||
|
options={{ label: '教务缓存' }}
|
||||||
|
recordRepresentation="username"
|
||||||
|
/>
|
||||||
|
</Admin>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
front/src/admin/auth-provider.test.ts
Normal file
38
front/src/admin/auth-provider.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import type { AuthSession } from '@/src/lib/types';
|
||||||
|
|
||||||
|
import { buildAdminIdentity, hasAdminSession, portalAdminAuthProvider } from './auth-provider';
|
||||||
|
|
||||||
|
const session: AuthSession = {
|
||||||
|
token: 'token-123',
|
||||||
|
refreshToken: 'refresh-123',
|
||||||
|
user: {
|
||||||
|
id: 7,
|
||||||
|
username: 'alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
createdAt: '2026-03-19T15:00:00',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test('hasAdminSession returns true only when a token is present', () => {
|
||||||
|
assert.equal(hasAdminSession(session), true);
|
||||||
|
assert.equal(hasAdminSession({...session, token: ''}), false);
|
||||||
|
assert.equal(hasAdminSession(null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildAdminIdentity maps the portal session user to react-admin identity', () => {
|
||||||
|
assert.deepEqual(buildAdminIdentity(session), {
|
||||||
|
id: '7',
|
||||||
|
fullName: 'alice',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkError keeps the session when admin API returns 403', async () => {
|
||||||
|
await assert.doesNotReject(() => portalAdminAuthProvider.checkError?.({status: 403}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkError rejects when admin API returns 401', async () => {
|
||||||
|
await assert.rejects(() => portalAdminAuthProvider.checkError?.({status: 401}));
|
||||||
|
});
|
||||||
50
front/src/admin/auth-provider.ts
Normal file
50
front/src/admin/auth-provider.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { AuthProvider, UserIdentity } from 'react-admin';
|
||||||
|
|
||||||
|
import { clearStoredSession, readStoredSession } from '@/src/lib/session';
|
||||||
|
import type { AuthSession } from '@/src/lib/types';
|
||||||
|
|
||||||
|
export function hasAdminSession(session: AuthSession | null | undefined) {
|
||||||
|
return Boolean(session?.token?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAdminIdentity(session: AuthSession): UserIdentity {
|
||||||
|
return {
|
||||||
|
id: String(session.user.id),
|
||||||
|
fullName: session.user.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const portalAdminAuthProvider: AuthProvider = {
|
||||||
|
login: async () => {
|
||||||
|
throw new Error('请先使用门户登录页完成登录');
|
||||||
|
},
|
||||||
|
logout: async () => {
|
||||||
|
clearStoredSession();
|
||||||
|
return '/login';
|
||||||
|
},
|
||||||
|
checkAuth: async () => {
|
||||||
|
if (!hasAdminSession(readStoredSession())) {
|
||||||
|
throw new Error('当前没有可用登录状态');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
checkError: async (error) => {
|
||||||
|
const status = error?.status;
|
||||||
|
if (status === 401) {
|
||||||
|
clearStoredSession();
|
||||||
|
throw new Error('登录状态已失效');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 403) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getIdentity: async () => {
|
||||||
|
const session = readStoredSession();
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('当前没有可用登录状态');
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildAdminIdentity(session);
|
||||||
|
},
|
||||||
|
getPermissions: async () => [],
|
||||||
|
};
|
||||||
160
front/src/admin/dashboard.tsx
Normal file
160
front/src/admin/dashboard.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Alert, Card, CardContent, Chip, CircularProgress, Grid, Stack, Typography } from '@mui/material';
|
||||||
|
|
||||||
|
import { apiRequest } from '@/src/lib/api';
|
||||||
|
import { readStoredSession } from '@/src/lib/session';
|
||||||
|
import type { AdminSummary } from '@/src/lib/types';
|
||||||
|
|
||||||
|
interface DashboardState {
|
||||||
|
summary: AdminSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DASHBOARD_ITEMS = [
|
||||||
|
{
|
||||||
|
title: '文件资源',
|
||||||
|
description: '已接入 /api/admin/files 与 /api/admin/files/{id} 删除接口,可查看全站文件元数据。',
|
||||||
|
status: 'connected',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '用户管理',
|
||||||
|
description: '已接入 /api/admin/users,可查看用户、邮箱与最近教务缓存标记。',
|
||||||
|
status: 'connected',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '教务快照',
|
||||||
|
description: '已接入 /api/admin/school-snapshots,可查看最近学号、学期和缓存条数。',
|
||||||
|
status: 'connected',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PortalAdminDashboard() {
|
||||||
|
const [state, setState] = useState<DashboardState>({
|
||||||
|
summary: null,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const session = readStoredSession();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
async function loadDashboardData() {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = await apiRequest<AdminSummary>('/admin/summary');
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({
|
||||||
|
summary,
|
||||||
|
});
|
||||||
|
} catch (requestError) {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(requestError instanceof Error ? requestError.message : '后台首页数据加载失败');
|
||||||
|
} finally {
|
||||||
|
if (active) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDashboardData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={3} sx={{ p: 2 }}>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="h4" fontWeight={700}>
|
||||||
|
YOYUZH Admin
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
这是嵌入现有门户应用的 react-admin 管理入口,当前通过 `/api/admin/**` 提供后台数据。
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
<Typography color="text.secondary">正在加载后台数据...</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <Alert severity="error">{error}</Alert>}
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{DASHBOARD_ITEMS.map((item) => (
|
||||||
|
<Grid key={item.title} size={{ xs: 12, md: 4 }}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Chip label={item.status} size="small" color="primary" sx={{ width: 'fit-content' }} />
|
||||||
|
<Typography variant="h6" fontWeight={600}>
|
||||||
|
{item.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{item.description}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="h6" fontWeight={600}>
|
||||||
|
当前管理员
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
用户名:{session?.user.username ?? '-'}
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
邮箱:{session?.user.email ?? '-'}
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
用户 ID:{session?.user.id ?? '-'}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="h6" fontWeight={600}>
|
||||||
|
后台汇总
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
用户总数:{state.summary?.totalUsers ?? 0}
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
文件总数:{state.summary?.totalFiles ?? 0}
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
有教务缓存的用户:{state.summary?.usersWithSchoolCache ?? 0}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
front/src/admin/data-provider.test.ts
Normal file
105
front/src/admin/data-provider.test.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import type { AdminFile, PageResponse } from '@/src/lib/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildAdminListPath,
|
||||||
|
buildFilesListPath,
|
||||||
|
mapFilesListResponse,
|
||||||
|
} from './data-provider';
|
||||||
|
|
||||||
|
test('buildFilesListPath maps react-admin pagination to the backend files list query', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildFilesListPath({
|
||||||
|
pagination: {
|
||||||
|
page: 3,
|
||||||
|
perPage: 25,
|
||||||
|
},
|
||||||
|
filter: {},
|
||||||
|
}),
|
||||||
|
'/admin/files?page=2&size=25',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildFilesListPath includes file and owner search filters when present', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildFilesListPath({
|
||||||
|
pagination: {
|
||||||
|
page: 1,
|
||||||
|
perPage: 25,
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
query: 'report',
|
||||||
|
ownerQuery: 'alice',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'/admin/files?page=0&size=25&query=report&ownerQuery=alice',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mapFilesListResponse preserves list items and total count', () => {
|
||||||
|
const payload: PageResponse<AdminFile> = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
filename: 'hello.txt',
|
||||||
|
path: '/',
|
||||||
|
size: 12,
|
||||||
|
contentType: 'text/plain',
|
||||||
|
directory: false,
|
||||||
|
createdAt: '2026-03-19T15:00:00',
|
||||||
|
ownerId: 7,
|
||||||
|
ownerUsername: 'alice',
|
||||||
|
ownerEmail: 'alice@example.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 0,
|
||||||
|
size: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(mapFilesListResponse(payload), {
|
||||||
|
data: payload.items,
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildAdminListPath maps generic admin resources to backend paging queries', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildAdminListPath('users', {
|
||||||
|
pagination: {
|
||||||
|
page: 2,
|
||||||
|
perPage: 20,
|
||||||
|
},
|
||||||
|
filter: {},
|
||||||
|
}),
|
||||||
|
'/admin/users?page=1&size=20',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
buildAdminListPath('schoolSnapshots', {
|
||||||
|
pagination: {
|
||||||
|
page: 1,
|
||||||
|
perPage: 50,
|
||||||
|
},
|
||||||
|
filter: {},
|
||||||
|
}),
|
||||||
|
'/admin/school-snapshots?page=0&size=50',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildAdminListPath includes the user search query when present', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildAdminListPath('users', {
|
||||||
|
pagination: {
|
||||||
|
page: 1,
|
||||||
|
perPage: 25,
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
query: 'alice',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'/admin/users?page=0&size=25&query=alice',
|
||||||
|
);
|
||||||
|
});
|
||||||
144
front/src/admin/data-provider.ts
Normal file
144
front/src/admin/data-provider.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import type { DataProvider, GetListParams, GetListResult, Identifier } from 'react-admin';
|
||||||
|
|
||||||
|
import { apiRequest } from '@/src/lib/api';
|
||||||
|
import type {
|
||||||
|
AdminFile,
|
||||||
|
AdminSchoolSnapshot,
|
||||||
|
AdminUser,
|
||||||
|
PageResponse,
|
||||||
|
} from '@/src/lib/types';
|
||||||
|
|
||||||
|
const FILES_RESOURCE = 'files';
|
||||||
|
const USERS_RESOURCE = 'users';
|
||||||
|
const SCHOOL_SNAPSHOTS_RESOURCE = 'schoolSnapshots';
|
||||||
|
|
||||||
|
function createUnsupportedError(resource: string, action: string) {
|
||||||
|
return new Error(`当前管理台暂未为资源 "${resource}" 实现 ${action} 操作`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSupportedResource(resource: string, action: string) {
|
||||||
|
if (![FILES_RESOURCE, USERS_RESOURCE, SCHOOL_SNAPSHOTS_RESOURCE].includes(resource)) {
|
||||||
|
throw createUnsupportedError(resource, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFilterValue(value: unknown) {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAdminListPath(resource: string, params: Pick<GetListParams, 'pagination' | 'filter'>) {
|
||||||
|
const page = Math.max(0, params.pagination.page - 1);
|
||||||
|
const size = Math.max(1, params.pagination.perPage);
|
||||||
|
const query = normalizeFilterValue(params.filter?.query);
|
||||||
|
|
||||||
|
if (resource === USERS_RESOURCE) {
|
||||||
|
return `/admin/users?page=${page}&size=${size}${query ? `&query=${encodeURIComponent(query)}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource === SCHOOL_SNAPSHOTS_RESOURCE) {
|
||||||
|
return `/admin/school-snapshots?page=${page}&size=${size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createUnsupportedError(resource, 'list');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFilesListPath(params: Pick<GetListParams, 'pagination' | 'filter'>) {
|
||||||
|
const page = Math.max(0, params.pagination.page - 1);
|
||||||
|
const size = Math.max(1, params.pagination.perPage);
|
||||||
|
const query = normalizeFilterValue(params.filter?.query);
|
||||||
|
const ownerQuery = normalizeFilterValue(params.filter?.ownerQuery);
|
||||||
|
const search = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
size: String(size),
|
||||||
|
});
|
||||||
|
if (query) {
|
||||||
|
search.set('query', query);
|
||||||
|
}
|
||||||
|
if (ownerQuery) {
|
||||||
|
search.set('ownerQuery', ownerQuery);
|
||||||
|
}
|
||||||
|
return `/admin/files?${search.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapFilesListResponse(
|
||||||
|
payload: PageResponse<AdminFile>,
|
||||||
|
): GetListResult<AdminFile> {
|
||||||
|
return {
|
||||||
|
data: payload.items,
|
||||||
|
total: payload.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile(id: Identifier) {
|
||||||
|
await apiRequest(`/admin/files/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const portalAdminDataProvider: DataProvider = {
|
||||||
|
getList: async (resource, params) => {
|
||||||
|
ensureSupportedResource(resource, 'list');
|
||||||
|
|
||||||
|
if (resource === FILES_RESOURCE) {
|
||||||
|
const payload = await apiRequest<PageResponse<AdminFile>>(buildFilesListPath(params));
|
||||||
|
return mapFilesListResponse(payload) as GetListResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource === USERS_RESOURCE) {
|
||||||
|
const payload = await apiRequest<PageResponse<AdminUser>>(buildAdminListPath(resource, params));
|
||||||
|
return {
|
||||||
|
data: payload.items,
|
||||||
|
total: payload.total,
|
||||||
|
} as GetListResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await apiRequest<PageResponse<AdminSchoolSnapshot>>(buildAdminListPath(resource, params));
|
||||||
|
return {
|
||||||
|
data: payload.items,
|
||||||
|
total: payload.total,
|
||||||
|
} as GetListResult;
|
||||||
|
},
|
||||||
|
getOne: async (resource) => {
|
||||||
|
ensureSupportedResource(resource, 'getOne');
|
||||||
|
throw createUnsupportedError(resource, 'getOne');
|
||||||
|
},
|
||||||
|
getMany: async (resource) => {
|
||||||
|
ensureSupportedResource(resource, 'getMany');
|
||||||
|
throw createUnsupportedError(resource, 'getMany');
|
||||||
|
},
|
||||||
|
getManyReference: async (resource) => {
|
||||||
|
ensureSupportedResource(resource, 'getManyReference');
|
||||||
|
throw createUnsupportedError(resource, 'getManyReference');
|
||||||
|
},
|
||||||
|
update: async (resource) => {
|
||||||
|
ensureSupportedResource(resource, 'update');
|
||||||
|
throw createUnsupportedError(resource, 'update');
|
||||||
|
},
|
||||||
|
updateMany: async (resource) => {
|
||||||
|
ensureSupportedResource(resource, 'updateMany');
|
||||||
|
throw createUnsupportedError(resource, 'updateMany');
|
||||||
|
},
|
||||||
|
create: async (resource) => {
|
||||||
|
ensureSupportedResource(resource, 'create');
|
||||||
|
throw createUnsupportedError(resource, 'create');
|
||||||
|
},
|
||||||
|
delete: async (resource, params) => {
|
||||||
|
if (resource !== FILES_RESOURCE) {
|
||||||
|
throw createUnsupportedError(resource, 'delete');
|
||||||
|
}
|
||||||
|
await deleteFile(params.id);
|
||||||
|
const fallbackRecord = { id: params.id } as typeof params.previousData;
|
||||||
|
return {
|
||||||
|
data: (params.previousData ?? fallbackRecord) as typeof params.previousData,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
deleteMany: async (resource, params) => {
|
||||||
|
if (resource !== FILES_RESOURCE) {
|
||||||
|
throw createUnsupportedError(resource, 'deleteMany');
|
||||||
|
}
|
||||||
|
await Promise.all(params.ids.map((id) => deleteFile(id)));
|
||||||
|
return {
|
||||||
|
data: params.ids,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
69
front/src/admin/files-list.tsx
Normal file
69
front/src/admin/files-list.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Chip } from '@mui/material';
|
||||||
|
import {
|
||||||
|
Datagrid,
|
||||||
|
DateField,
|
||||||
|
DeleteWithConfirmButton,
|
||||||
|
FunctionField,
|
||||||
|
List,
|
||||||
|
RefreshButton,
|
||||||
|
SearchInput,
|
||||||
|
TextField,
|
||||||
|
TopToolbar,
|
||||||
|
} from 'react-admin';
|
||||||
|
|
||||||
|
import type { AdminFile } from '@/src/lib/types';
|
||||||
|
|
||||||
|
function FilesListActions() {
|
||||||
|
return (
|
||||||
|
<TopToolbar>
|
||||||
|
<RefreshButton />
|
||||||
|
</TopToolbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(size: number) {
|
||||||
|
if (size >= 1024 * 1024) {
|
||||||
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
if (size >= 1024) {
|
||||||
|
return `${(size / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
return `${size} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortalAdminFilesList() {
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
actions={<FilesListActions />}
|
||||||
|
filters={[
|
||||||
|
<SearchInput key="query" source="query" alwaysOn placeholder="搜索文件名或路径" />,
|
||||||
|
<SearchInput key="ownerQuery" source="ownerQuery" placeholder="搜索所属用户" />,
|
||||||
|
]}
|
||||||
|
perPage={25}
|
||||||
|
resource="files"
|
||||||
|
title="文件管理"
|
||||||
|
sort={{ field: 'createdAt', order: 'DESC' }}
|
||||||
|
>
|
||||||
|
<Datagrid bulkActionButtons={false} rowClick={false}>
|
||||||
|
<TextField source="id" label="ID" />
|
||||||
|
<TextField source="filename" label="文件名" />
|
||||||
|
<TextField source="path" label="路径" />
|
||||||
|
<TextField source="ownerUsername" label="所属用户" />
|
||||||
|
<TextField source="ownerEmail" label="用户邮箱" />
|
||||||
|
<FunctionField<AdminFile>
|
||||||
|
label="类型"
|
||||||
|
render={(record) =>
|
||||||
|
record.directory ? <Chip label="目录" size="small" /> : <Chip label="文件" size="small" variant="outlined" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FunctionField<AdminFile>
|
||||||
|
label="大小"
|
||||||
|
render={(record) => (record.directory ? '-' : formatFileSize(record.size))}
|
||||||
|
/>
|
||||||
|
<TextField source="contentType" label="Content-Type" emptyText="-" />
|
||||||
|
<DateField source="createdAt" label="创建时间" showTime />
|
||||||
|
<DeleteWithConfirmButton mutationMode="pessimistic" label="删除" confirmTitle="删除文件" confirmContent="确认删除该文件吗?" />
|
||||||
|
</Datagrid>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
front/src/admin/school-snapshots-list.tsx
Normal file
22
front/src/admin/school-snapshots-list.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Datagrid, List, NumberField, TextField } from 'react-admin';
|
||||||
|
|
||||||
|
export function PortalAdminSchoolSnapshotsList() {
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
perPage={25}
|
||||||
|
resource="schoolSnapshots"
|
||||||
|
title="教务缓存"
|
||||||
|
sort={{ field: 'id', order: 'DESC' }}
|
||||||
|
>
|
||||||
|
<Datagrid bulkActionButtons={false} rowClick={false}>
|
||||||
|
<TextField source="userId" label="用户 ID" />
|
||||||
|
<TextField source="username" label="用户名" />
|
||||||
|
<TextField source="email" label="邮箱" />
|
||||||
|
<TextField source="studentId" label="学号" emptyText="-" />
|
||||||
|
<TextField source="semester" label="学期" emptyText="-" />
|
||||||
|
<NumberField source="scheduleCount" label="课表数" />
|
||||||
|
<NumberField source="gradeCount" label="成绩数" />
|
||||||
|
</Datagrid>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
front/src/admin/users-list.tsx
Normal file
186
front/src/admin/users-list.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Chip, Stack } from '@mui/material';
|
||||||
|
import {
|
||||||
|
Datagrid,
|
||||||
|
DateField,
|
||||||
|
FunctionField,
|
||||||
|
List,
|
||||||
|
SearchInput,
|
||||||
|
TextField,
|
||||||
|
TopToolbar,
|
||||||
|
RefreshButton,
|
||||||
|
useNotify,
|
||||||
|
useRefresh,
|
||||||
|
} from 'react-admin';
|
||||||
|
|
||||||
|
import { apiRequest } from '@/src/lib/api';
|
||||||
|
import type { AdminPasswordResetResponse, AdminUser, AdminUserRole } from '@/src/lib/types';
|
||||||
|
|
||||||
|
const USER_ROLE_OPTIONS: AdminUserRole[] = ['USER', 'MODERATOR', 'ADMIN'];
|
||||||
|
|
||||||
|
function UsersListActions() {
|
||||||
|
return (
|
||||||
|
<TopToolbar>
|
||||||
|
<RefreshButton />
|
||||||
|
</TopToolbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminUserActions({ record }: { record: AdminUser }) {
|
||||||
|
const notify = useNotify();
|
||||||
|
const refresh = useRefresh();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function handleRoleAssign() {
|
||||||
|
const input = window.prompt('请输入角色:USER / MODERATOR / ADMIN', record.role);
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const role = input.trim().toUpperCase() as AdminUserRole;
|
||||||
|
if (!USER_ROLE_OPTIONS.includes(role)) {
|
||||||
|
notify('角色必须是 USER、MODERATOR 或 ADMIN', { type: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiRequest(`/admin/users/${record.id}/role`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { role },
|
||||||
|
});
|
||||||
|
notify(`已将 ${record.username} 设为 ${role}`, { type: 'success' });
|
||||||
|
refresh();
|
||||||
|
} catch (error) {
|
||||||
|
notify(error instanceof Error ? error.message : '角色更新失败', { type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleBan() {
|
||||||
|
const nextBanned = !record.banned;
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
nextBanned ? `确认封禁用户 ${record.username} 吗?` : `确认解封用户 ${record.username} 吗?`,
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiRequest(`/admin/users/${record.id}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { banned: nextBanned },
|
||||||
|
});
|
||||||
|
notify(nextBanned ? '用户已封禁' : '用户已解封', { type: 'success' });
|
||||||
|
refresh();
|
||||||
|
} catch (error) {
|
||||||
|
notify(error instanceof Error ? error.message : '状态更新失败', { type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSetPassword() {
|
||||||
|
const newPassword = window.prompt(
|
||||||
|
'请输入新密码。密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符。',
|
||||||
|
);
|
||||||
|
if (!newPassword) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiRequest(`/admin/users/${record.id}/password`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { newPassword },
|
||||||
|
});
|
||||||
|
notify('密码已更新,旧 refresh token 已失效', { type: 'success' });
|
||||||
|
} catch (error) {
|
||||||
|
notify(error instanceof Error ? error.message : '密码更新失败', { type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetPassword() {
|
||||||
|
const confirmed = window.confirm(`确认重置 ${record.username} 的密码吗?`);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await apiRequest<AdminPasswordResetResponse>(`/admin/users/${record.id}/password/reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
notify('已生成临时密码,请立即复制并安全发送给用户', { type: 'success' });
|
||||||
|
window.prompt(`用户 ${record.username} 的临时密码如下,请复制保存`, result.temporaryPassword);
|
||||||
|
} catch (error) {
|
||||||
|
notify(error instanceof Error ? error.message : '密码重置失败', { type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
|
||||||
|
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleRoleAssign()}>
|
||||||
|
角色分配
|
||||||
|
</Button>
|
||||||
|
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleSetPassword()}>
|
||||||
|
修改密码
|
||||||
|
</Button>
|
||||||
|
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleResetPassword()}>
|
||||||
|
重置密码
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={record.banned ? 'contained' : 'outlined'}
|
||||||
|
color={record.banned ? 'success' : 'warning'}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void handleToggleBan()}
|
||||||
|
>
|
||||||
|
{record.banned ? '解封' : '封禁'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortalAdminUsersList() {
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
actions={<UsersListActions />}
|
||||||
|
filters={[<SearchInput key="query" source="query" alwaysOn placeholder="搜索用户名或邮箱" />]}
|
||||||
|
perPage={25}
|
||||||
|
resource="users"
|
||||||
|
title="用户管理"
|
||||||
|
sort={{ field: 'createdAt', order: 'DESC' }}
|
||||||
|
>
|
||||||
|
<Datagrid bulkActionButtons={false} rowClick={false}>
|
||||||
|
<TextField source="id" label="ID" />
|
||||||
|
<TextField source="username" label="用户名" />
|
||||||
|
<TextField source="email" label="邮箱" />
|
||||||
|
<FunctionField<AdminUser>
|
||||||
|
label="角色"
|
||||||
|
render={(record) => <Chip label={record.role} size="small" color={record.role === 'ADMIN' ? 'primary' : 'default'} />}
|
||||||
|
/>
|
||||||
|
<FunctionField<AdminUser>
|
||||||
|
label="状态"
|
||||||
|
render={(record) => (
|
||||||
|
<Chip
|
||||||
|
label={record.banned ? '已封禁' : '正常'}
|
||||||
|
size="small"
|
||||||
|
color={record.banned ? 'warning' : 'success'}
|
||||||
|
variant={record.banned ? 'filled' : 'outlined'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TextField source="lastSchoolStudentId" label="最近学号" emptyText="-" />
|
||||||
|
<TextField source="lastSchoolSemester" label="最近学期" emptyText="-" />
|
||||||
|
<DateField source="createdAt" label="创建时间" showTime />
|
||||||
|
<FunctionField<AdminUser> label="操作" render={(record) => <AdminUserActions record={record} />} />
|
||||||
|
</Datagrid>
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { apiRequest } from '@/src/lib/api';
|
import { apiRequest } from '@/src/lib/api';
|
||||||
|
import { fetchAdminAccessStatus } from './admin-access';
|
||||||
import {
|
import {
|
||||||
clearStoredSession,
|
clearStoredSession,
|
||||||
createSession,
|
createSession,
|
||||||
@@ -19,6 +20,7 @@ interface AuthContextValue {
|
|||||||
ready: boolean;
|
ready: boolean;
|
||||||
session: AuthSession | null;
|
session: AuthSession | null;
|
||||||
user: UserProfile | null;
|
user: UserProfile | null;
|
||||||
|
isAdmin: boolean;
|
||||||
login: (payload: LoginPayload) => Promise<void>;
|
login: (payload: LoginPayload) => Promise<void>;
|
||||||
devLogin: (username?: string) => Promise<void>;
|
devLogin: (username?: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
@@ -34,6 +36,7 @@ function buildSession(auth: AuthResponse): AuthSession {
|
|||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [session, setSession] = useState<AuthSession | null>(() => readStoredSession());
|
const [session, setSession] = useState<AuthSession | null>(() => readStoredSession());
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncSession = () => {
|
const syncSession = () => {
|
||||||
@@ -93,6 +96,36 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
async function syncAdminAccess() {
|
||||||
|
if (!session?.token) {
|
||||||
|
if (active) {
|
||||||
|
setIsAdmin(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allowed = await fetchAdminAccessStatus();
|
||||||
|
if (active) {
|
||||||
|
setIsAdmin(allowed);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (active) {
|
||||||
|
setIsAdmin(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncAdminAccess();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [session?.token]);
|
||||||
|
|
||||||
async function refreshProfile() {
|
async function refreshProfile() {
|
||||||
const currentSession = readStoredSession();
|
const currentSession = readStoredSession();
|
||||||
if (!currentSession) {
|
if (!currentSession) {
|
||||||
@@ -146,6 +179,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
ready,
|
ready,
|
||||||
session,
|
session,
|
||||||
user: session?.user || null,
|
user: session?.user || null,
|
||||||
|
isAdmin,
|
||||||
login,
|
login,
|
||||||
devLogin,
|
devLogin,
|
||||||
logout,
|
logout,
|
||||||
|
|||||||
28
front/src/auth/admin-access.test.ts
Normal file
28
front/src/auth/admin-access.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { ApiError } from '@/src/lib/api';
|
||||||
|
|
||||||
|
import { fetchAdminAccessStatus } from './admin-access';
|
||||||
|
|
||||||
|
test('fetchAdminAccessStatus returns true when the admin summary request succeeds', async () => {
|
||||||
|
const request = async () => ({
|
||||||
|
totalUsers: 1,
|
||||||
|
totalFiles: 2,
|
||||||
|
usersWithSchoolCache: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.doesNotReject(async () => {
|
||||||
|
const allowed = await fetchAdminAccessStatus(request);
|
||||||
|
assert.equal(allowed, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetchAdminAccessStatus returns false when the server rejects the user with 403', async () => {
|
||||||
|
const request = async () => {
|
||||||
|
throw new ApiError('没有后台权限', 403);
|
||||||
|
};
|
||||||
|
|
||||||
|
const allowed = await fetchAdminAccessStatus(request);
|
||||||
|
assert.equal(allowed, false);
|
||||||
|
});
|
||||||
19
front/src/auth/admin-access.ts
Normal file
19
front/src/auth/admin-access.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ApiError, apiRequest } from '@/src/lib/api';
|
||||||
|
import type { AdminSummary } from '@/src/lib/types';
|
||||||
|
|
||||||
|
type AdminSummaryRequest = () => Promise<AdminSummary>;
|
||||||
|
|
||||||
|
export async function fetchAdminAccessStatus(
|
||||||
|
request: AdminSummaryRequest = () => apiRequest<AdminSummary>('/admin/summary'),
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await request();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status === 403) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
front/src/components/layout/Layout.test.ts
Normal file
12
front/src/components/layout/Layout.test.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { getVisibleNavItems } from './Layout';
|
||||||
|
|
||||||
|
test('getVisibleNavItems hides the admin entry for non-admin users', () => {
|
||||||
|
assert.equal(getVisibleNavItems(false).some((item) => item.path === '/admin'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getVisibleNavItems keeps the admin entry for admin users', () => {
|
||||||
|
assert.equal(getVisibleNavItems(true).some((item) => item.path === '/admin'), true);
|
||||||
|
});
|
||||||
@@ -1,38 +1,333 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||||
import { LayoutDashboard, FolderOpen, GraduationCap, Gamepad2, LogOut } from 'lucide-react';
|
import {
|
||||||
|
Gamepad2,
|
||||||
|
FolderOpen,
|
||||||
|
GraduationCap,
|
||||||
|
Key,
|
||||||
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
|
Mail,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
Smartphone,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
|
||||||
import { clearStoredSession } from '@/src/lib/session';
|
import { useAuth } from '@/src/auth/AuthProvider';
|
||||||
|
import { apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api';
|
||||||
|
import { createSession, readStoredSession, saveStoredSession } from '@/src/lib/session';
|
||||||
|
import type { AuthResponse, InitiateUploadResponse, UserProfile } from '@/src/lib/types';
|
||||||
import { cn } from '@/src/lib/utils';
|
import { cn } from '@/src/lib/utils';
|
||||||
|
import { Button } from '@/src/components/ui/button';
|
||||||
|
import { Input } from '@/src/components/ui/input';
|
||||||
|
|
||||||
|
import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from './account-utils';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ name: '总览', path: '/overview', icon: LayoutDashboard },
|
{ name: '总览', path: '/overview', icon: LayoutDashboard },
|
||||||
{ name: '网盘', path: '/files', icon: FolderOpen },
|
{ name: '网盘', path: '/files', icon: FolderOpen },
|
||||||
{ name: '教务', path: '/school', icon: GraduationCap },
|
{ name: '教务', path: '/school', icon: GraduationCap },
|
||||||
{ name: '游戏', path: '/games', icon: Gamepad2 },
|
{ name: '游戏', path: '/games', icon: Gamepad2 },
|
||||||
];
|
{ name: '后台', path: '/admin', icon: Shield },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type ActiveModal = 'security' | 'settings' | null;
|
||||||
|
|
||||||
|
export function getVisibleNavItems(isAdmin: boolean) {
|
||||||
|
return NAV_ITEMS.filter((item) => isAdmin || item.path !== '/admin');
|
||||||
|
}
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isAdmin, logout, refreshProfile, user } = useAuth();
|
||||||
|
const navItems = getVisibleNavItems(isAdmin);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [selectedAvatarFile, setSelectedAvatarFile] = useState<File | null>(null);
|
||||||
|
const [avatarSourceUrl, setAvatarSourceUrl] = useState<string | null>(user?.avatarUrl ?? null);
|
||||||
|
const [profileDraft, setProfileDraft] = useState(() =>
|
||||||
|
buildAccountDraft(
|
||||||
|
user ?? {
|
||||||
|
id: 0,
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
createdAt: '',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [profileMessage, setProfileMessage] = useState('');
|
||||||
|
const [passwordMessage, setPasswordMessage] = useState('');
|
||||||
|
const [profileError, setProfileError] = useState('');
|
||||||
|
const [passwordError, setPasswordError] = useState('');
|
||||||
|
const [profileSubmitting, setProfileSubmitting] = useState(false);
|
||||||
|
const [passwordSubmitting, setPasswordSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProfileDraft(buildAccountDraft(user));
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!avatarPreviewUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
URL.revokeObjectURL(avatarPreviewUrl);
|
||||||
|
};
|
||||||
|
}, [avatarPreviewUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
|
||||||
|
async function syncAvatar() {
|
||||||
|
if (!user?.avatarUrl) {
|
||||||
|
if (active) {
|
||||||
|
setAvatarSourceUrl(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldLoadAvatarWithAuth(user.avatarUrl)) {
|
||||||
|
if (active) {
|
||||||
|
setAvatarSourceUrl(user.avatarUrl);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiDownload(user.avatarUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
if (active) {
|
||||||
|
setAvatarSourceUrl(objectUrl);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (active) {
|
||||||
|
setAvatarSourceUrl(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void syncAvatar();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [user?.avatarUrl]);
|
||||||
|
|
||||||
|
const displayName = useMemo(() => {
|
||||||
|
if (!user) {
|
||||||
|
return '账户';
|
||||||
|
}
|
||||||
|
return user.displayName || user.username;
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const email = user?.email || '暂无邮箱';
|
||||||
|
const roleLabel = getRoleLabel(user?.role);
|
||||||
|
const avatarFallback = (displayName || 'Y').charAt(0).toUpperCase();
|
||||||
|
const displayedAvatarUrl = avatarPreviewUrl || avatarSourceUrl;
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
clearStoredSession();
|
logout();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAvatarClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedAvatarFile(file);
|
||||||
|
setAvatarPreviewUrl((current) => {
|
||||||
|
if (current) {
|
||||||
|
URL.revokeObjectURL(current);
|
||||||
|
}
|
||||||
|
return URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfileDraftChange = (field: keyof typeof profileDraft, value: string) => {
|
||||||
|
setProfileDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
[field]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setActiveModal(null);
|
||||||
|
setProfileMessage('');
|
||||||
|
setProfileError('');
|
||||||
|
setPasswordMessage('');
|
||||||
|
setPasswordError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistSessionUser = (nextProfile: UserProfile) => {
|
||||||
|
const currentSession = readStoredSession();
|
||||||
|
if (!currentSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStoredSession({
|
||||||
|
...currentSession,
|
||||||
|
user: nextProfile,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadAvatar = async (file: File) => {
|
||||||
|
const initiated = await apiRequest<InitiateUploadResponse>('/user/avatar/upload/initiate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
filename: file.name,
|
||||||
|
contentType: file.type || 'image/png',
|
||||||
|
size: file.size,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (initiated.direct) {
|
||||||
|
try {
|
||||||
|
await apiBinaryUploadRequest(initiated.uploadUrl, {
|
||||||
|
method: initiated.method,
|
||||||
|
headers: initiated.headers,
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
await apiUploadRequest<void>(`/user/avatar/upload?storageName=${encodeURIComponent(initiated.storageName)}`, {
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
await apiUploadRequest<void>(initiated.uploadUrl, {
|
||||||
|
body: formData,
|
||||||
|
method: initiated.method === 'PUT' ? 'PUT' : 'POST',
|
||||||
|
headers: initiated.headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextProfile = await apiRequest<UserProfile>('/user/avatar/upload/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
filename: file.name,
|
||||||
|
contentType: file.type || 'image/png',
|
||||||
|
size: file.size,
|
||||||
|
storageName: initiated.storageName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
persistSessionUser(nextProfile);
|
||||||
|
return nextProfile;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveProfile = async () => {
|
||||||
|
setProfileSubmitting(true);
|
||||||
|
setProfileMessage('');
|
||||||
|
setProfileError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selectedAvatarFile) {
|
||||||
|
await uploadAvatar(selectedAvatarFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextProfile = await apiRequest<UserProfile>('/user/profile', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
displayName: profileDraft.displayName.trim(),
|
||||||
|
email: profileDraft.email.trim(),
|
||||||
|
bio: profileDraft.bio,
|
||||||
|
preferredLanguage: profileDraft.preferredLanguage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
persistSessionUser(nextProfile);
|
||||||
|
|
||||||
|
await refreshProfile();
|
||||||
|
setSelectedAvatarFile(null);
|
||||||
|
setAvatarPreviewUrl((current) => {
|
||||||
|
if (current) {
|
||||||
|
URL.revokeObjectURL(current);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
setProfileMessage('账户资料已保存');
|
||||||
|
} catch (error) {
|
||||||
|
setProfileError(error instanceof Error ? error.message : '账户资料保存失败');
|
||||||
|
} finally {
|
||||||
|
setProfileSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
setPasswordMessage('');
|
||||||
|
setPasswordError('');
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setPasswordError('两次输入的新密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPasswordSubmitting(true);
|
||||||
|
try {
|
||||||
|
const auth = await apiRequest<AuthResponse>('/user/password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentSession = readStoredSession();
|
||||||
|
if (currentSession) {
|
||||||
|
saveStoredSession({
|
||||||
|
...currentSession,
|
||||||
|
...createSession(auth),
|
||||||
|
user: auth.user,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setPasswordMessage('密码已更新,当前登录态已同步刷新');
|
||||||
|
} catch (error) {
|
||||||
|
setPasswordError(error instanceof Error ? error.message : '密码修改失败');
|
||||||
|
} finally {
|
||||||
|
setPasswordSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-[#07101D] text-white relative overflow-hidden">
|
<div className="min-h-screen flex flex-col bg-[#07101D] text-white relative overflow-hidden">
|
||||||
{/* Animated Gradient Background */}
|
|
||||||
<div className="fixed inset-0 z-0 pointer-events-none">
|
<div className="fixed inset-0 z-0 pointer-events-none">
|
||||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full bg-[#336EFF] opacity-20 mix-blend-screen filter blur-[120px] animate-blob" />
|
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[120px] animate-blob" />
|
||||||
<div className="absolute top-[20%] right-[-10%] w-[50%] h-[50%] rounded-full bg-purple-600 opacity-20 mix-blend-screen filter blur-[120px] animate-blob animation-delay-2000" />
|
<div className="absolute top-[20%] right-[-10%] w-[50%] h-[50%] rounded-full bg-purple-600 opacity-20 mix-blend-screen blur-[120px] animate-blob animation-delay-2000" />
|
||||||
<div className="absolute bottom-[-20%] left-[20%] w-[60%] h-[60%] rounded-full bg-indigo-600 opacity-20 mix-blend-screen filter blur-[120px] animate-blob animation-delay-4000" />
|
<div className="absolute bottom-[-20%] left-[20%] w-[60%] h-[60%] rounded-full bg-indigo-600 opacity-20 mix-blend-screen blur-[120px] animate-blob animation-delay-4000" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Navigation */}
|
<header className="sticky top-0 z-50 w-full glass-panel border-b border-white/10 bg-[#07101D]/60 backdrop-blur-xl">
|
||||||
<header className="fixed inset-x-0 top-0 z-50 w-full glass-panel border-b border-white/10 bg-[#07101D]/60 backdrop-blur-xl">
|
|
||||||
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
|
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
|
||||||
{/* Brand */}
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center shadow-lg shadow-[#336EFF]/20">
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center shadow-lg shadow-[#336EFF]/20">
|
||||||
<span className="text-white font-bold text-lg leading-none">Y</span>
|
<span className="text-white font-bold text-lg leading-none">Y</span>
|
||||||
@@ -43,26 +338,21 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav Links */}
|
|
||||||
<nav className="hidden md:flex items-center gap-2">
|
<nav className="hidden md:flex items-center gap-2">
|
||||||
{NAV_ITEMS.map((item) => (
|
{navItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 relative overflow-hidden group',
|
'flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 relative overflow-hidden group',
|
||||||
isActive
|
isActive ? 'text-white shadow-md shadow-[#336EFF]/20' : 'text-slate-400 hover:text-white hover:bg-white/5',
|
||||||
? 'text-white shadow-md shadow-[#336EFF]/20'
|
|
||||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<>
|
<>
|
||||||
{isActive && (
|
{isActive && <div className="absolute inset-0 bg-[#336EFF] opacity-100 z-0" />}
|
||||||
<div className="absolute inset-0 bg-[#336EFF] opacity-100 z-0" />
|
|
||||||
)}
|
|
||||||
<item.icon className="w-4 h-4 relative z-10" />
|
<item.icon className="w-4 h-4 relative z-10" />
|
||||||
<span className="relative z-10">{item.name}</span>
|
<span className="relative z-10">{item.name}</span>
|
||||||
</>
|
</>
|
||||||
@@ -71,23 +361,269 @@ export function Layout() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User / Actions */}
|
<div className="flex items-center gap-4 relative">
|
||||||
<div className="flex items-center gap-4">
|
<button
|
||||||
|
onClick={() => setIsDropdownOpen((current) => !current)}
|
||||||
|
className="w-10 h-10 rounded-full bg-slate-800 border border-white/10 flex items-center justify-center text-slate-300 hover:text-white hover:border-white/20 transition-all relative z-10 overflow-hidden"
|
||||||
|
aria-label="Account"
|
||||||
|
>
|
||||||
|
{displayedAvatarUrl ? (
|
||||||
|
<img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-semibold">{avatarFallback}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setIsDropdownOpen(false)} />
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="absolute right-0 top-full mt-2 w-56 bg-[#0f172a] border border-white/10 rounded-xl shadow-2xl z-50 py-2 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="px-4 py-3 border-b border-white/10 mb-2">
|
||||||
|
<p className="text-sm font-medium text-white">{displayName}</p>
|
||||||
|
<p className="text-xs text-slate-400 truncate">{email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveModal('security');
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-slate-300 hover:bg-white/10 hover:text-white flex items-center gap-3 transition-colors"
|
||||||
|
>
|
||||||
|
<Shield className="w-4 h-4" /> 安全中心
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveModal('settings');
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-slate-300 hover:bg-white/10 hover:text-white flex items-center gap-3 transition-colors"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" /> 账户设置
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-px bg-white/10 my-2" />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="text-slate-400 hover:text-white transition-colors p-2 rounded-xl hover:bg-white/5 relative z-10"
|
className="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 flex items-center gap-3 transition-colors"
|
||||||
aria-label="Logout"
|
|
||||||
>
|
>
|
||||||
<LogOut className="w-5 h-5" />
|
<LogOut className="w-4 h-4" /> 退出登录
|
||||||
</button>
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main Content */}
|
<main className="flex-1 container mx-auto px-4 py-8 relative z-10">
|
||||||
<main className="relative z-10 flex-1 container mx-auto px-4 pb-8 pt-24">
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{activeModal === 'security' && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
className="bg-[#0f172a] border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]"
|
||||||
|
>
|
||||||
|
<div className="p-5 border-b border-white/10 flex justify-between items-center bg-white/5">
|
||||||
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<Shield className="w-5 h-5 text-emerald-400" />
|
||||||
|
安全中心
|
||||||
|
</h3>
|
||||||
|
<button onClick={closeModal} className="text-slate-400 hover:text-white transition-colors p-1 rounded-md hover:bg-white/10">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 overflow-y-auto space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
|
||||||
|
<Key className="w-5 h-5 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">登录密码</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">密码修改后会刷新当前登录凭据并使旧 refresh token 失效</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="当前密码"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||||
|
className="bg-black/20 border-white/10"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="新密码"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(event) => setNewPassword(event.target.value)}
|
||||||
|
className="bg-black/20 border-white/10"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="确认新密码"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||||
|
className="bg-black/20 border-white/10"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="outline" disabled={passwordSubmitting} onClick={() => void handleChangePassword()}>
|
||||||
|
{passwordSubmitting ? '保存中...' : '修改'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||||||
|
<Smartphone className="w-5 h-5 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">手机绑定</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">当前项目暂未实现短信绑定流程</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" disabled className="border-white/10 text-slate-500">
|
||||||
|
暂未开放
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center">
|
||||||
|
<Mail className="w-5 h-5 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">邮箱绑定</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">当前邮箱:{email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-white/10 hover:bg-white/10 text-slate-300"
|
||||||
|
onClick={() => setActiveModal('settings')}
|
||||||
|
>
|
||||||
|
更改
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{passwordError && <p className="text-sm text-rose-300">{passwordError}</p>}
|
||||||
|
{passwordMessage && <p className="text-sm text-emerald-300">{passwordMessage}</p>}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal === 'settings' && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
|
className="bg-[#0f172a] border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]"
|
||||||
|
>
|
||||||
|
<div className="p-5 border-b border-white/10 flex justify-between items-center bg-white/5">
|
||||||
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<Settings className="w-5 h-5 text-[#336EFF]" />
|
||||||
|
账户设置
|
||||||
|
</h3>
|
||||||
|
<button onClick={closeModal} className="text-slate-400 hover:text-white transition-colors p-1 rounded-md hover:bg-white/10">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 overflow-y-auto space-y-6">
|
||||||
|
<div className="flex items-center gap-6 pb-6 border-b border-white/10">
|
||||||
|
<div className="relative group cursor-pointer" onClick={handleAvatarClick}>
|
||||||
|
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center text-2xl font-bold text-white shadow-lg overflow-hidden">
|
||||||
|
{displayedAvatarUrl ? <img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" /> : avatarFallback}
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-black/50 rounded-full opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
|
||||||
|
<span className="text-xs text-white">{selectedAvatarFile ? '等待保存' : '更换头像'}</span>
|
||||||
|
</div>
|
||||||
|
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="hidden" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<h4 className="text-lg font-medium text-white">{displayName}</h4>
|
||||||
|
<p className="text-sm text-slate-400">{roleLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-300">昵称</label>
|
||||||
|
<Input
|
||||||
|
value={profileDraft.displayName}
|
||||||
|
onChange={(event) => handleProfileDraftChange('displayName', event.target.value)}
|
||||||
|
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-300">邮箱</label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={profileDraft.email}
|
||||||
|
onChange={(event) => handleProfileDraftChange('email', event.target.value)}
|
||||||
|
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-300">个人简介</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full min-h-[100px] rounded-md bg-black/20 border border-white/10 text-white p-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] resize-none"
|
||||||
|
value={profileDraft.bio}
|
||||||
|
onChange={(event) => handleProfileDraftChange('bio', event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-300">语言偏好</label>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-md bg-black/20 border border-white/10 text-white p-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] appearance-none"
|
||||||
|
value={profileDraft.preferredLanguage}
|
||||||
|
onChange={(event) => handleProfileDraftChange('preferredLanguage', event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="zh-CN">简体中文</option>
|
||||||
|
<option value="en-US">English</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{profileError && <p className="text-sm text-rose-300">{profileError}</p>}
|
||||||
|
{profileMessage && <p className="text-sm text-emerald-300">{profileMessage}</p>}
|
||||||
|
|
||||||
|
<div className="pt-4 flex justify-end gap-3">
|
||||||
|
<Button variant="outline" onClick={closeModal} className="border-white/10 hover:bg-white/10 text-slate-300">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button variant="default" disabled={profileSubmitting} onClick={() => void handleSaveProfile()}>
|
||||||
|
{profileSubmitting ? '保存中...' : '保存更改'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
38
front/src/components/layout/account-utils.test.ts
Normal file
38
front/src/components/layout/account-utils.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import type { UserProfile } from '@/src/lib/types';
|
||||||
|
|
||||||
|
import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from './account-utils';
|
||||||
|
|
||||||
|
test('buildAccountDraft prefers display name and fills fallback values', () => {
|
||||||
|
const profile: UserProfile = {
|
||||||
|
id: 1,
|
||||||
|
username: 'alice',
|
||||||
|
displayName: 'Alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
bio: null,
|
||||||
|
preferredLanguage: null,
|
||||||
|
role: 'USER',
|
||||||
|
createdAt: '2026-03-19T17:00:00',
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(buildAccountDraft(profile), {
|
||||||
|
displayName: 'Alice',
|
||||||
|
email: 'alice@example.com',
|
||||||
|
bio: '',
|
||||||
|
preferredLanguage: 'zh-CN',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getRoleLabel maps backend roles to readable chinese labels', () => {
|
||||||
|
assert.equal(getRoleLabel('ADMIN'), '管理员');
|
||||||
|
assert.equal(getRoleLabel('MODERATOR'), '协管员');
|
||||||
|
assert.equal(getRoleLabel('USER'), '普通用户');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldLoadAvatarWithAuth only treats relative avatar urls as protected resources', () => {
|
||||||
|
assert.equal(shouldLoadAvatarWithAuth('/api/user/avatar/content?v=1'), true);
|
||||||
|
assert.equal(shouldLoadAvatarWithAuth('https://cdn.example.com/avatar.png?sig=1'), false);
|
||||||
|
assert.equal(shouldLoadAvatarWithAuth(null), false);
|
||||||
|
});
|
||||||
32
front/src/components/layout/account-utils.ts
Normal file
32
front/src/components/layout/account-utils.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { AdminUserRole, UserProfile } from '@/src/lib/types';
|
||||||
|
|
||||||
|
export interface AccountDraft {
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
bio: string;
|
||||||
|
preferredLanguage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAccountDraft(profile: UserProfile): AccountDraft {
|
||||||
|
return {
|
||||||
|
displayName: profile.displayName || profile.username,
|
||||||
|
email: profile.email,
|
||||||
|
bio: profile.bio || '',
|
||||||
|
preferredLanguage: profile.preferredLanguage || 'zh-CN',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoleLabel(role: AdminUserRole | undefined) {
|
||||||
|
switch (role) {
|
||||||
|
case 'ADMIN':
|
||||||
|
return '管理员';
|
||||||
|
case 'MODERATOR':
|
||||||
|
return '协管员';
|
||||||
|
default:
|
||||||
|
return '普通用户';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldLoadAvatarWithAuth(avatarUrl: string | null | undefined) {
|
||||||
|
return Boolean(avatarUrl && avatarUrl.startsWith('/'));
|
||||||
|
}
|
||||||
@@ -1,8 +1,60 @@
|
|||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
email: string;
|
||||||
|
bio?: string | null;
|
||||||
|
preferredLanguage?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
role?: AdminUserRole;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminUserRole = 'USER' | 'MODERATOR' | 'ADMIN';
|
||||||
|
|
||||||
|
export interface AdminSummary {
|
||||||
|
totalUsers: number;
|
||||||
|
totalFiles: number;
|
||||||
|
usersWithSchoolCache: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
lastSchoolStudentId: string | null;
|
||||||
|
lastSchoolSemester: string | null;
|
||||||
|
role: AdminUserRole;
|
||||||
|
banned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminFile {
|
||||||
|
id: number;
|
||||||
|
filename: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
contentType: string | null;
|
||||||
|
directory: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
ownerId: number;
|
||||||
|
ownerUsername: string;
|
||||||
|
ownerEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSchoolSnapshot {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
studentId: string | null;
|
||||||
|
semester: string | null;
|
||||||
|
scheduleCount: number;
|
||||||
|
gradeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminPasswordResetResponse {
|
||||||
|
temporaryPassword: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthSession {
|
export interface AuthSession {
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export default function Overview() {
|
|||||||
<Card className="border-amber-400/20 bg-amber-500/10">
|
<Card className="border-amber-400/20 bg-amber-500/10">
|
||||||
<CardContent className="flex flex-col gap-3 p-4 text-sm text-amber-100 md:flex-row md:items-center md:justify-between">
|
<CardContent className="flex flex-col gap-3 p-4 text-sm text-amber-100 md:flex-row md:items-center md:justify-between">
|
||||||
<span>{loadingError}</span>
|
<span>{loadingError}</span>
|
||||||
<Button variant="secondary" size="sm" onClick={() => setRetryToken((value) => value + 1)}>
|
<Button variant="outline" size="sm" onClick={() => setRetryToken((value) => value + 1)}>
|
||||||
重新加载总览
|
重新加载总览
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
buildObjectKey,
|
buildObjectKey,
|
||||||
createAuthorizationHeader,
|
createAuthorizationHeader,
|
||||||
encodeObjectKey,
|
encodeObjectKey,
|
||||||
|
getFrontendSpaAliasContentType,
|
||||||
|
getFrontendSpaAliasKeys,
|
||||||
getCacheControl,
|
getCacheControl,
|
||||||
getContentType,
|
getContentType,
|
||||||
listFiles,
|
listFiles,
|
||||||
@@ -72,11 +74,12 @@ async function uploadFile({
|
|||||||
endpoint,
|
endpoint,
|
||||||
objectKey,
|
objectKey,
|
||||||
filePath,
|
filePath,
|
||||||
|
contentTypeOverride,
|
||||||
accessKeyId,
|
accessKeyId,
|
||||||
accessKeySecret,
|
accessKeySecret,
|
||||||
}) {
|
}) {
|
||||||
const body = await fs.readFile(filePath);
|
const body = await fs.readFile(filePath);
|
||||||
const contentType = getContentType(objectKey);
|
const contentType = contentTypeOverride || getContentType(objectKey);
|
||||||
const date = new Date().toUTCString();
|
const date = new Date().toUTCString();
|
||||||
const url = `https://${bucket}.${normalizeEndpoint(endpoint)}/${encodeObjectKey(objectKey)}`;
|
const url = `https://${bucket}.${normalizeEndpoint(endpoint)}/${encodeObjectKey(objectKey)}`;
|
||||||
const authorization = createAuthorizationHeader({
|
const authorization = createAuthorizationHeader({
|
||||||
@@ -107,6 +110,39 @@ async function uploadFile({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadSpaAliases({
|
||||||
|
bucket,
|
||||||
|
endpoint,
|
||||||
|
distIndexPath,
|
||||||
|
accessKeyId,
|
||||||
|
accessKeySecret,
|
||||||
|
remotePrefix,
|
||||||
|
dryRun,
|
||||||
|
}) {
|
||||||
|
const aliases = getFrontendSpaAliasKeys();
|
||||||
|
const contentType = getFrontendSpaAliasContentType();
|
||||||
|
|
||||||
|
for (const alias of aliases) {
|
||||||
|
const objectKey = buildObjectKey(remotePrefix, alias);
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(`[dry-run] upload alias ${alias} -> ${objectKey}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadFile({
|
||||||
|
bucket,
|
||||||
|
endpoint,
|
||||||
|
objectKey,
|
||||||
|
filePath: distIndexPath,
|
||||||
|
contentTypeOverride: contentType,
|
||||||
|
accessKeyId,
|
||||||
|
accessKeySecret,
|
||||||
|
});
|
||||||
|
console.log(`uploaded alias ${objectKey}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const {dryRun, skipBuild} = parseArgs(process.argv.slice(2));
|
const {dryRun, skipBuild} = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
@@ -146,6 +182,16 @@ async function main() {
|
|||||||
});
|
});
|
||||||
console.log(`uploaded ${objectKey}`);
|
console.log(`uploaded ${objectKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await uploadSpaAliases({
|
||||||
|
bucket,
|
||||||
|
endpoint,
|
||||||
|
distIndexPath: path.join(distDir, 'index.html'),
|
||||||
|
accessKeyId,
|
||||||
|
accessKeySecret,
|
||||||
|
remotePrefix,
|
||||||
|
dryRun,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ const CONTENT_TYPES = new Map([
|
|||||||
['.webmanifest', 'application/manifest+json; charset=utf-8'],
|
['.webmanifest', 'application/manifest+json; charset=utf-8'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const FRONTEND_SPA_ALIASES = [
|
||||||
|
'overview',
|
||||||
|
'files',
|
||||||
|
'school',
|
||||||
|
'games',
|
||||||
|
'login',
|
||||||
|
'admin',
|
||||||
|
'admin/users',
|
||||||
|
'admin/files',
|
||||||
|
'admin/schoolSnapshots',
|
||||||
|
];
|
||||||
|
|
||||||
export function normalizeEndpoint(endpoint) {
|
export function normalizeEndpoint(endpoint) {
|
||||||
return endpoint.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
return endpoint.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
@@ -44,6 +56,18 @@ export function getContentType(relativePath) {
|
|||||||
return CONTENT_TYPES.get(ext) || 'application/octet-stream';
|
return CONTENT_TYPES.get(ext) || 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFrontendSpaAliasKeys() {
|
||||||
|
return FRONTEND_SPA_ALIASES.flatMap((alias) => [
|
||||||
|
alias,
|
||||||
|
`${alias}/`,
|
||||||
|
`${alias}/index.html`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFrontendSpaAliasContentType() {
|
||||||
|
return 'text/html; charset=utf-8';
|
||||||
|
}
|
||||||
|
|
||||||
export function createAuthorizationHeader({
|
export function createAuthorizationHeader({
|
||||||
method,
|
method,
|
||||||
bucket,
|
bucket,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
buildObjectKey,
|
buildObjectKey,
|
||||||
createAuthorizationHeader,
|
createAuthorizationHeader,
|
||||||
|
getFrontendSpaAliasContentType,
|
||||||
|
getFrontendSpaAliasKeys,
|
||||||
getCacheControl,
|
getCacheControl,
|
||||||
getContentType,
|
getContentType,
|
||||||
normalizeEndpoint,
|
normalizeEndpoint,
|
||||||
@@ -31,6 +33,15 @@ test('getContentType resolves common frontend asset types', () => {
|
|||||||
assert.equal(getContentType('favicon.png'), 'image/png');
|
assert.equal(getContentType('favicon.png'), 'image/png');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('frontend spa aliases are uploaded as html entry points', () => {
|
||||||
|
const aliases = getFrontendSpaAliasKeys();
|
||||||
|
|
||||||
|
assert.ok(aliases.includes('overview'));
|
||||||
|
assert.ok(aliases.includes('admin/users'));
|
||||||
|
assert.ok(aliases.includes('admin/schoolSnapshots/index.html'));
|
||||||
|
assert.equal(getFrontendSpaAliasContentType(), 'text/html; charset=utf-8');
|
||||||
|
});
|
||||||
|
|
||||||
test('createAuthorizationHeader is stable for a known request', () => {
|
test('createAuthorizationHeader is stable for a known request', () => {
|
||||||
const header = createAuthorizationHeader({
|
const header = createAuthorizationHeader({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
Reference in New Issue
Block a user