360 lines
16 KiB
Java
360 lines
16 KiB
Java
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;
|
|
private final AuthenticationManager authenticationManager;
|
|
private final JwtTokenProvider jwtTokenProvider;
|
|
private final RefreshTokenService refreshTokenService;
|
|
private final FileService fileService;
|
|
private final FileContentStorage fileContentStorage;
|
|
|
|
@Transactional
|
|
public AuthResponse register(RegisterRequest request) {
|
|
if (userRepository.existsByUsername(request.username())) {
|
|
throw new BusinessException(ErrorCode.UNKNOWN, "用户名已存在");
|
|
}
|
|
if (userRepository.existsByEmail(request.email())) {
|
|
throw new BusinessException(ErrorCode.UNKNOWN, "邮箱已存在");
|
|
}
|
|
if (userRepository.existsByPhoneNumber(request.phoneNumber())) {
|
|
throw new BusinessException(ErrorCode.UNKNOWN, "手机号已存在");
|
|
}
|
|
|
|
User user = new User();
|
|
user.setUsername(request.username());
|
|
user.setDisplayName(request.username());
|
|
user.setEmail(request.email());
|
|
user.setPhoneNumber(request.phoneNumber());
|
|
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);
|
|
}
|
|
|
|
public AuthResponse login(LoginRequest request) {
|
|
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, "用户名或密码错误");
|
|
}
|
|
|
|
User user = userRepository.findByUsername(request.username())
|
|
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
|
fileService.ensureDefaultDirectories(user);
|
|
return issueTokens(user);
|
|
}
|
|
|
|
@Transactional
|
|
public AuthResponse devLogin(String username) {
|
|
String candidate = username == null ? "" : username.trim();
|
|
if (candidate.isEmpty()) {
|
|
candidate = "1";
|
|
}
|
|
|
|
final String finalCandidate = candidate;
|
|
User user = userRepository.findByUsername(finalCandidate).orElseGet(() -> {
|
|
User created = new User();
|
|
created.setUsername(finalCandidate);
|
|
created.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);
|
|
return issueTokens(user);
|
|
}
|
|
|
|
@Transactional
|
|
public AuthResponse refresh(String refreshToken) {
|
|
RefreshTokenService.RotatedRefreshToken rotated = refreshTokenService.rotateRefreshToken(refreshToken);
|
|
return issueTokens(rotated.user(), rotated.refreshToken());
|
|
}
|
|
|
|
public UserProfileResponse getProfile(String username) {
|
|
User user = userRepository.findByUsername(username)
|
|
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
|
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, "邮箱已存在");
|
|
}
|
|
String nextPhoneNumber = request.phoneNumber().trim();
|
|
if (!nextPhoneNumber.equals(user.getPhoneNumber()) && userRepository.existsByPhoneNumber(nextPhoneNumber)) {
|
|
throw new BusinessException(ErrorCode.UNKNOWN, "手机号已存在");
|
|
}
|
|
|
|
user.setDisplayName(request.displayName().trim());
|
|
user.setEmail(nextEmail);
|
|
user.setPhoneNumber(nextPhoneNumber);
|
|
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.getDisplayName(),
|
|
user.getEmail(),
|
|
user.getPhoneNumber(),
|
|
user.getBio(),
|
|
user.getPreferredLanguage(),
|
|
buildAvatarUrl(user),
|
|
user.getRole(),
|
|
user.getCreatedAt()
|
|
);
|
|
}
|
|
|
|
private AuthResponse issueTokens(User user) {
|
|
return issueTokens(user, refreshTokenService.issueRefreshToken(user));
|
|
}
|
|
|
|
private AuthResponse issueTokens(User user, String refreshToken) {
|
|
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);
|
|
}
|
|
}
|