添加账号修改,后台管理
This commit is contained in:
@@ -3,21 +3,41 @@ package com.yoyuzh.auth;
|
||||
import com.yoyuzh.auth.dto.AuthResponse;
|
||||
import com.yoyuzh.auth.dto.LoginRequest;
|
||||
import com.yoyuzh.auth.dto.RegisterRequest;
|
||||
import com.yoyuzh.auth.dto.UpdateUserAvatarRequest;
|
||||
import com.yoyuzh.auth.dto.UpdateUserPasswordRequest;
|
||||
import com.yoyuzh.auth.dto.UpdateUserProfileRequest;
|
||||
import com.yoyuzh.auth.dto.UserProfileResponse;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import com.yoyuzh.files.FileService;
|
||||
import com.yoyuzh.files.InitiateUploadResponse;
|
||||
import com.yoyuzh.files.storage.FileContentStorage;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.DisabledException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
private static final String AVATAR_PATH = "/.avatar";
|
||||
private static final long MAX_AVATAR_SIZE = 5L * 1024 * 1024L;
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
@@ -25,6 +45,7 @@ public class AuthService {
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final FileService fileService;
|
||||
private final FileContentStorage fileContentStorage;
|
||||
|
||||
@Transactional
|
||||
public AuthResponse register(RegisterRequest request) {
|
||||
@@ -37,8 +58,11 @@ public class AuthService {
|
||||
|
||||
User user = new User();
|
||||
user.setUsername(request.username());
|
||||
user.setDisplayName(request.username());
|
||||
user.setEmail(request.email());
|
||||
user.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
user.setRole(UserRole.USER);
|
||||
user.setPreferredLanguage("zh-CN");
|
||||
User saved = userRepository.save(user);
|
||||
fileService.ensureDefaultDirectories(saved);
|
||||
return issueTokens(saved);
|
||||
@@ -48,6 +72,8 @@ public class AuthService {
|
||||
try {
|
||||
authenticationManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(request.username(), request.password()));
|
||||
} catch (DisabledException ex) {
|
||||
throw new BusinessException(ErrorCode.PERMISSION_DENIED, "账号已被封禁");
|
||||
} catch (BadCredentialsException ex) {
|
||||
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户名或密码错误");
|
||||
}
|
||||
@@ -69,8 +95,11 @@ public class AuthService {
|
||||
User user = userRepository.findByUsername(finalCandidate).orElseGet(() -> {
|
||||
User created = new User();
|
||||
created.setUsername(finalCandidate);
|
||||
created.setDisplayName(finalCandidate);
|
||||
created.setEmail(finalCandidate + "@dev.local");
|
||||
created.setPasswordHash(passwordEncoder.encode("1"));
|
||||
created.setRole(UserRole.USER);
|
||||
created.setPreferredLanguage("zh-CN");
|
||||
return userRepository.save(created);
|
||||
});
|
||||
fileService.ensureDefaultDirectories(user);
|
||||
@@ -89,8 +118,139 @@ public class AuthService {
|
||||
return toProfile(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserProfileResponse updateProfile(String username, UpdateUserProfileRequest request) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||
|
||||
String nextEmail = request.email().trim();
|
||||
if (!user.getEmail().equalsIgnoreCase(nextEmail) && userRepository.existsByEmail(nextEmail)) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "邮箱已存在");
|
||||
}
|
||||
|
||||
user.setDisplayName(request.displayName().trim());
|
||||
user.setEmail(nextEmail);
|
||||
user.setBio(normalizeOptionalText(request.bio()));
|
||||
user.setPreferredLanguage(normalizePreferredLanguage(request.preferredLanguage()));
|
||||
return toProfile(userRepository.save(user));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AuthResponse changePassword(String username, UpdateUserPasswordRequest request) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||
|
||||
if (!passwordEncoder.matches(request.currentPassword(), user.getPasswordHash())) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "当前密码错误");
|
||||
}
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(request.newPassword()));
|
||||
userRepository.save(user);
|
||||
refreshTokenService.revokeAllForUser(user.getId());
|
||||
return issueTokens(user);
|
||||
}
|
||||
|
||||
public InitiateUploadResponse initiateAvatarUpload(String username, UpdateUserAvatarRequest request) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||
|
||||
validateAvatarUpload(request.filename(), request.contentType(), request.size());
|
||||
String storageName = normalizeAvatarStorageName(request.storageName(), request.filename(), request.contentType());
|
||||
|
||||
var preparedUpload = fileContentStorage.prepareUpload(
|
||||
user.getId(),
|
||||
AVATAR_PATH,
|
||||
storageName,
|
||||
request.contentType(),
|
||||
request.size()
|
||||
);
|
||||
|
||||
String uploadUrl = preparedUpload.direct()
|
||||
? preparedUpload.uploadUrl()
|
||||
: "/api/user/avatar/upload?storageName=" + URLEncoder.encode(storageName, StandardCharsets.UTF_8);
|
||||
|
||||
return new InitiateUploadResponse(
|
||||
preparedUpload.direct(),
|
||||
uploadUrl,
|
||||
preparedUpload.direct() ? preparedUpload.method() : "POST",
|
||||
preparedUpload.direct() ? preparedUpload.headers() : java.util.Map.of(),
|
||||
storageName
|
||||
);
|
||||
}
|
||||
|
||||
public void uploadAvatar(String username, String storageName, MultipartFile file) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||
|
||||
String normalizedStorageName = normalizeAvatarStorageName(storageName, file.getOriginalFilename(), file.getContentType());
|
||||
validateAvatarUpload(file.getOriginalFilename(), file.getContentType(), file.getSize());
|
||||
fileContentStorage.upload(user.getId(), AVATAR_PATH, normalizedStorageName, file);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public UserProfileResponse completeAvatarUpload(String username, UpdateUserAvatarRequest request) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||
|
||||
validateAvatarUpload(request.filename(), request.contentType(), request.size());
|
||||
String storageName = normalizeAvatarStorageName(request.storageName(), request.filename(), request.contentType());
|
||||
|
||||
fileContentStorage.completeUpload(user.getId(), AVATAR_PATH, storageName, request.contentType(), request.size());
|
||||
|
||||
String previousStorageName = user.getAvatarStorageName();
|
||||
if (StringUtils.hasText(previousStorageName) && !previousStorageName.equals(storageName)) {
|
||||
fileContentStorage.deleteFile(user.getId(), AVATAR_PATH, previousStorageName);
|
||||
}
|
||||
|
||||
user.setAvatarStorageName(storageName);
|
||||
user.setAvatarContentType(request.contentType());
|
||||
user.setAvatarUpdatedAt(LocalDateTime.now());
|
||||
return toProfile(userRepository.save(user));
|
||||
}
|
||||
|
||||
public ResponseEntity<?> getAvatarContent(String username) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||
|
||||
if (!StringUtils.hasText(user.getAvatarStorageName())) {
|
||||
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "头像不存在");
|
||||
}
|
||||
|
||||
String downloadName = buildAvatarDownloadName(user.getAvatarStorageName(), user.getAvatarContentType());
|
||||
if (fileContentStorage.supportsDirectDownload()) {
|
||||
return ResponseEntity.status(302)
|
||||
.location(URI.create(fileContentStorage.createDownloadUrl(
|
||||
user.getId(),
|
||||
AVATAR_PATH,
|
||||
user.getAvatarStorageName(),
|
||||
downloadName
|
||||
)))
|
||||
.build();
|
||||
}
|
||||
|
||||
byte[] content = fileContentStorage.readFile(user.getId(), AVATAR_PATH, user.getAvatarStorageName());
|
||||
String contentType = StringUtils.hasText(user.getAvatarContentType())
|
||||
? user.getAvatarContentType()
|
||||
: MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"inline; filename*=UTF-8''" + URLEncoder.encode(downloadName, StandardCharsets.UTF_8))
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.body(content);
|
||||
}
|
||||
|
||||
private UserProfileResponse toProfile(User user) {
|
||||
return new UserProfileResponse(user.getId(), user.getUsername(), user.getEmail(), user.getCreatedAt());
|
||||
return new UserProfileResponse(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getDisplayName(),
|
||||
user.getEmail(),
|
||||
user.getBio(),
|
||||
user.getPreferredLanguage(),
|
||||
buildAvatarUrl(user),
|
||||
user.getRole(),
|
||||
user.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
private AuthResponse issueTokens(User user) {
|
||||
@@ -101,4 +261,89 @@ public class AuthService {
|
||||
String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getUsername());
|
||||
return AuthResponse.issued(accessToken, refreshToken, toProfile(user));
|
||||
}
|
||||
|
||||
private String normalizeOptionalText(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private String normalizePreferredLanguage(String preferredLanguage) {
|
||||
if (preferredLanguage == null || preferredLanguage.trim().isEmpty()) {
|
||||
return "zh-CN";
|
||||
}
|
||||
return preferredLanguage.trim();
|
||||
}
|
||||
|
||||
private void validateAvatarUpload(String filename, String contentType, long size) {
|
||||
if (!StringUtils.hasText(filename)) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "头像文件名不能为空");
|
||||
}
|
||||
if (!StringUtils.hasText(contentType) || !contentType.toLowerCase(Locale.ROOT).startsWith("image/")) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "头像仅支持图片文件");
|
||||
}
|
||||
if (size <= 0 || size > MAX_AVATAR_SIZE) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "头像大小不能超过 5MB");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeAvatarStorageName(String requestedStorageName, String filename, String contentType) {
|
||||
String candidate = StringUtils.hasText(requestedStorageName)
|
||||
? requestedStorageName.trim()
|
||||
: "avatar-" + UUID.randomUUID() + resolveAvatarExtension(filename, contentType);
|
||||
candidate = candidate.replace("\\", "/");
|
||||
if (candidate.contains("/")) {
|
||||
candidate = candidate.substring(candidate.lastIndexOf('/') + 1);
|
||||
}
|
||||
if (!StringUtils.hasText(candidate)) {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "头像文件名不合法");
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private String resolveAvatarExtension(String filename, String contentType) {
|
||||
if (StringUtils.hasText(filename)) {
|
||||
int dot = filename.lastIndexOf('.');
|
||||
if (dot >= 0 && dot < filename.length() - 1) {
|
||||
String extension = filename.substring(dot).toLowerCase(Locale.ROOT);
|
||||
if (extension.matches("\\.[a-z0-9]{1,8}")) {
|
||||
return extension;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return switch (contentType.toLowerCase(Locale.ROOT)) {
|
||||
case "image/jpeg" -> ".jpg";
|
||||
case "image/webp" -> ".webp";
|
||||
case "image/gif" -> ".gif";
|
||||
default -> ".png";
|
||||
};
|
||||
}
|
||||
|
||||
private String buildAvatarUrl(User user) {
|
||||
if (!StringUtils.hasText(user.getAvatarStorageName())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fileContentStorage.supportsDirectDownload()) {
|
||||
return fileContentStorage.createDownloadUrl(
|
||||
user.getId(),
|
||||
AVATAR_PATH,
|
||||
user.getAvatarStorageName(),
|
||||
buildAvatarDownloadName(user.getAvatarStorageName(), user.getAvatarContentType())
|
||||
);
|
||||
}
|
||||
|
||||
long version = user.getAvatarUpdatedAt() == null ? 0L : user.getAvatarUpdatedAt().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli();
|
||||
return "/user/avatar/content?v=" + version;
|
||||
}
|
||||
|
||||
private String buildAvatarDownloadName(String storageName, String contentType) {
|
||||
if (StringUtils.hasText(storageName) && storageName.contains(".")) {
|
||||
return storageName;
|
||||
}
|
||||
return "avatar" + resolveAvatarExtension(storageName, contentType == null ? "image/png" : contentType);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user