管理面板功能完善,注册需要手机号

This commit is contained in:
yoyuzh
2026-03-20 10:35:04 +08:00
parent ff8d47f44f
commit 944ab6dbf8
21 changed files with 213 additions and 15 deletions

View File

@@ -124,6 +124,7 @@ public class AdminService {
user.getId(),
user.getUsername(),
user.getEmail(),
user.getPhoneNumber(),
user.getCreatedAt(),
user.getLastSchoolStudentId(),
user.getLastSchoolSemester(),

View File

@@ -8,6 +8,7 @@ public record AdminUserResponse(
Long id,
String username,
String email,
String phoneNumber,
LocalDateTime createdAt,
String lastSchoolStudentId,
String lastSchoolSemester,

View File

@@ -55,11 +55,15 @@ public class AuthService {
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");
@@ -127,9 +131,14 @@ public class AuthService {
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));
@@ -245,6 +254,7 @@ public class AuthService {
user.getUsername(),
user.getDisplayName(),
user.getEmail(),
user.getPhoneNumber(),
user.getBio(),
user.getPreferredLanguage(),
buildAvatarUrl(user),

View File

@@ -31,6 +31,9 @@ public class User {
@Column(nullable = false, length = 128, unique = true)
private String email;
@Column(name = "phone_number", length = 32, unique = true)
private String phoneNumber;
@Column(name = "password_hash", nullable = false, length = 255)
private String passwordHash;
@@ -108,6 +111,14 @@ public class User {
this.email = email;
}
public String getPhoneNumber() {
return phoneNumber;
}
public void setPhoneNumber(String phoneNumber) {
this.phoneNumber = phoneNumber;
}
public String getPasswordHash() {
return passwordHash;
}

View File

@@ -13,6 +13,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByEmail(String email);
boolean existsByPhoneNumber(String phoneNumber);
Optional<User> findByUsername(String username);
long countByLastSchoolStudentIdIsNotNull();
@@ -23,7 +25,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
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, '%')))
or lower(u.email) like lower(concat('%', :query, '%'))
or u.phoneNumber like concat('%', :query, '%'))
""")
Page<User> searchByUsernameOrEmail(@Param("query") String query, Pageable pageable);
}

View File

@@ -4,11 +4,15 @@ import com.yoyuzh.auth.PasswordPolicy;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record RegisterRequest(
@NotBlank @Size(min = 3, max = 64) String username,
@NotBlank @Email @Size(max = 128) String email,
@NotBlank
@Pattern(regexp = "^1\\d{10}$", message = "请输入有效的11位手机号")
String phoneNumber,
@NotBlank @Size(min = 10, max = 64, message = "密码至少10位且必须包含大写字母、小写字母、数字和特殊字符") String password
) {

View File

@@ -2,11 +2,15 @@ package com.yoyuzh.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record UpdateUserProfileRequest(
@NotBlank @Size(min = 2, max = 64) String displayName,
@NotBlank @Email @Size(max = 128) String email,
@NotBlank
@Pattern(regexp = "^1\\d{10}$", message = "请输入有效的11位手机号")
String phoneNumber,
@Size(max = 280) String bio,
@Size(min = 2, max = 16) String preferredLanguage
) {

View File

@@ -9,6 +9,7 @@ public record UserProfileResponse(
String username,
String displayName,
String email,
String phoneNumber,
String bio,
String preferredLanguage,
String avatarUrl,
@@ -16,6 +17,6 @@ public record UserProfileResponse(
LocalDateTime createdAt
) {
public UserProfileResponse(Long id, String username, String email, LocalDateTime createdAt) {
this(id, username, username, email, null, "zh-CN", null, UserRole.USER, createdAt);
this(id, username, username, email, null, null, "zh-CN", null, UserRole.USER, createdAt);
}
}

View File

@@ -1,4 +1,5 @@
server:
address: 127.0.0.1
port: 8080
spring:

View File

@@ -63,6 +63,7 @@ class AdminControllerIntegrationTest {
portalUser = new User();
portalUser.setUsername("alice");
portalUser.setEmail("alice@example.com");
portalUser.setPhoneNumber("13800138000");
portalUser.setPasswordHash("encoded-password");
portalUser.setCreatedAt(LocalDateTime.now());
portalUser.setLastSchoolStudentId("20230001");
@@ -72,6 +73,7 @@ class AdminControllerIntegrationTest {
secondaryUser = new User();
secondaryUser.setUsername("bob");
secondaryUser.setEmail("bob@example.com");
secondaryUser.setPhoneNumber("13900139000");
secondaryUser.setPasswordHash("encoded-password");
secondaryUser.setCreatedAt(LocalDateTime.now().minusDays(1));
secondaryUser = userRepository.save(secondaryUser);
@@ -106,6 +108,7 @@ class AdminControllerIntegrationTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.items[0].username").value("alice"))
.andExpect(jsonPath("$.data.items[0].phoneNumber").value("13800138000"))
.andExpect(jsonPath("$.data.items[0].lastSchoolStudentId").value("20230001"))
.andExpect(jsonPath("$.data.items[0].role").value("USER"))
.andExpect(jsonPath("$.data.items[0].banned").value(false));
@@ -125,6 +128,12 @@ class AdminControllerIntegrationTest {
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].username").value("alice"));
mockMvc.perform(get("/api/admin/users?page=0&size=10&query=13900139000"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].username").value("bob"))
.andExpect(jsonPath("$.data.items[0].phoneNumber").value("13900139000"));
mockMvc.perform(patch("/api/admin/users/{userId}/role", portalUser.getId())
.contentType("application/json")
.content("""

View File

@@ -45,6 +45,7 @@ class AuthControllerValidationTest {
{
"username": "alice",
"email": "alice@example.com",
"phoneNumber": "13800138000",
"password": "weakpass"
}
"""))
@@ -53,6 +54,23 @@ class AuthControllerValidationTest {
.andExpect(jsonPath("$.msg").value("密码至少10位且必须包含大写字母、小写字母、数字和特殊字符"));
}
@Test
void shouldReturnReadablePhoneValidationMessage() throws Exception {
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"username": "alice",
"email": "alice@example.com",
"phoneNumber": "12345",
"password": "StrongPass1!"
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(1000))
.andExpect(jsonPath("$.msg").value("请输入有效的11位手机号"));
}
@Test
void shouldExposeRefreshEndpointContract() throws Exception {
AuthResponse response = AuthResponse.issued(

View File

@@ -62,9 +62,10 @@ class AuthServiceTest {
@Test
void shouldRegisterUserWithEncryptedPassword() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "StrongPass1!");
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "StrongPass1!");
when(userRepository.existsByUsername("alice")).thenReturn(false);
when(userRepository.existsByEmail("alice@example.com")).thenReturn(false);
when(userRepository.existsByPhoneNumber("13800138000")).thenReturn(false);
when(passwordEncoder.encode("StrongPass1!")).thenReturn("encoded-password");
when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
User user = invocation.getArgument(0);
@@ -81,13 +82,14 @@ class AuthServiceTest {
assertThat(response.accessToken()).isEqualTo("access-token");
assertThat(response.refreshToken()).isEqualTo("refresh-token");
assertThat(response.user().username()).isEqualTo("alice");
assertThat(response.user().phoneNumber()).isEqualTo("13800138000");
verify(passwordEncoder).encode("StrongPass1!");
verify(fileService).ensureDefaultDirectories(any(User.class));
}
@Test
void shouldRejectDuplicateUsernameOnRegister() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "StrongPass1!");
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "StrongPass1!");
when(userRepository.existsByUsername("alice")).thenReturn(true);
assertThatThrownBy(() -> authService.register(request))
@@ -95,6 +97,18 @@ class AuthServiceTest {
.hasMessageContaining("用户名已存在");
}
@Test
void shouldRejectDuplicatePhoneNumberOnRegister() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "StrongPass1!");
when(userRepository.existsByUsername("alice")).thenReturn(false);
when(userRepository.existsByEmail("alice@example.com")).thenReturn(false);
when(userRepository.existsByPhoneNumber("13800138000")).thenReturn(true);
assertThatThrownBy(() -> authService.register(request))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("手机号已存在");
}
@Test
void shouldLoginAndReturnToken() {
LoginRequest request = new LoginRequest("alice", "plain-password");
@@ -188,6 +202,7 @@ class AuthServiceTest {
user.setUsername("alice");
user.setDisplayName("Alice");
user.setEmail("alice@example.com");
user.setPhoneNumber("13800138000");
user.setBio("old bio");
user.setPreferredLanguage("zh-CN");
user.setRole(UserRole.USER);
@@ -196,18 +211,21 @@ class AuthServiceTest {
UpdateUserProfileRequest request = new UpdateUserProfileRequest(
"Alicia",
"newalice@example.com",
"13900139000",
"new bio",
"en-US"
);
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
when(userRepository.existsByEmail("newalice@example.com")).thenReturn(false);
when(userRepository.existsByPhoneNumber("13900139000")).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.phoneNumber()).isEqualTo("13900139000");
assertThat(response.bio()).isEqualTo("new bio");
assertThat(response.preferredLanguage()).isEqualTo("en-US");
}

View File

@@ -13,7 +13,7 @@ class RegisterRequestValidationTest {
@Test
void shouldRejectWeakPassword() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "weakpass");
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "weakpass");
var violations = validator.validate(request);
@@ -24,10 +24,21 @@ class RegisterRequestValidationTest {
@Test
void shouldAcceptStrongPassword() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "StrongPass1!");
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "StrongPass1!");
var violations = validator.validate(request);
assertThat(violations).isEmpty();
}
@Test
void shouldRejectInvalidPhoneNumber() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "12345", "StrongPass1!");
var violations = validator.validate(request);
assertThat(violations)
.extracting(violation -> violation.getMessage())
.contains("请输入有效的11位手机号");
}
}