From 944ab6dbf8a74c215d572ca6e56bb18e24aff46d Mon Sep 17 00:00:00 2001 From: yoyuzh Date: Fri, 20 Mar 2026 10:35:04 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=9D=A2=E6=9D=BF=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=AE=8C=E5=96=84=EF=BC=8C=E6=B3=A8=E5=86=8C=E9=9C=80?= =?UTF-8?q?=E8=A6=81=E6=89=8B=E6=9C=BA=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 17 ++++++ .../java/com/yoyuzh/admin/AdminService.java | 1 + .../com/yoyuzh/admin/AdminUserResponse.java | 1 + .../java/com/yoyuzh/auth/AuthService.java | 10 ++++ .../src/main/java/com/yoyuzh/auth/User.java | 11 ++++ .../java/com/yoyuzh/auth/UserRepository.java | 5 +- .../com/yoyuzh/auth/dto/RegisterRequest.java | 4 ++ .../auth/dto/UpdateUserProfileRequest.java | 4 ++ .../yoyuzh/auth/dto/UserProfileResponse.java | 3 +- backend/src/main/resources/application.yml | 1 + .../admin/AdminControllerIntegrationTest.java | 9 +++ .../auth/AuthControllerValidationTest.java | 18 ++++++ .../java/com/yoyuzh/auth/AuthServiceTest.java | 22 ++++++- .../auth/RegisterRequestValidationTest.java | 15 ++++- front/src/admin/users-list.tsx | 3 +- front/src/components/layout/Layout.tsx | 22 ++++++- .../components/layout/account-utils.test.ts | 1 + front/src/components/layout/account-utils.ts | 2 + front/src/lib/types.ts | 2 + front/src/pages/Login.tsx | 18 +++++- front/src/pages/Overview.tsx | 59 +++++++++++++++++-- 21 files changed, 213 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index becbbac..e569f2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,23 @@ Important: 6. Use `reviewer` before final delivery, especially for cross-layer changes or auth/files/storage flows. 7. Use `deployer` only after code is committed or otherwise ready to ship. +## Project-level hard rules + +### First-principles thinking + +- Start from the original requirement and problem, not from assumptions about the user's preferred implementation path. +- Do not assume the user already knows exactly what they want or how to get there. +- Stay cautious about motive, goal, and scope. If the underlying objective or business target is materially unclear, pause and discuss it with the user before implementation. + + +### Solution and refactor rule + +- Do not propose compatibility-style or patch-style solutions. +- Do not over-design. Use the shortest correct implementation path. +- Do not add fallback, downgrade, or extra solution branches that the user did not ask for. +- Do not propose any solution beyond the user's stated requirement if it could shift business logic. +- Every proposed modification or refactor plan must be logically correct and validated across the full request path before it is presented. + ## Repo-specific guardrails - Do not run `npm` commands at the repository root. This repo has a root `package-lock.json` but no root `package.json`. diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminService.java b/backend/src/main/java/com/yoyuzh/admin/AdminService.java index 725d90e..98a04c9 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminService.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminService.java @@ -124,6 +124,7 @@ public class AdminService { user.getId(), user.getUsername(), user.getEmail(), + user.getPhoneNumber(), user.getCreatedAt(), user.getLastSchoolStudentId(), user.getLastSchoolSemester(), diff --git a/backend/src/main/java/com/yoyuzh/admin/AdminUserResponse.java b/backend/src/main/java/com/yoyuzh/admin/AdminUserResponse.java index be78718..4cdfd7c 100644 --- a/backend/src/main/java/com/yoyuzh/admin/AdminUserResponse.java +++ b/backend/src/main/java/com/yoyuzh/admin/AdminUserResponse.java @@ -8,6 +8,7 @@ public record AdminUserResponse( Long id, String username, String email, + String phoneNumber, LocalDateTime createdAt, String lastSchoolStudentId, String lastSchoolSemester, diff --git a/backend/src/main/java/com/yoyuzh/auth/AuthService.java b/backend/src/main/java/com/yoyuzh/auth/AuthService.java index 61ca277..42aab4f 100644 --- a/backend/src/main/java/com/yoyuzh/auth/AuthService.java +++ b/backend/src/main/java/com/yoyuzh/auth/AuthService.java @@ -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), diff --git a/backend/src/main/java/com/yoyuzh/auth/User.java b/backend/src/main/java/com/yoyuzh/auth/User.java index c7e91e4..6dcdf3d 100644 --- a/backend/src/main/java/com/yoyuzh/auth/User.java +++ b/backend/src/main/java/com/yoyuzh/auth/User.java @@ -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; } diff --git a/backend/src/main/java/com/yoyuzh/auth/UserRepository.java b/backend/src/main/java/com/yoyuzh/auth/UserRepository.java index 82445e8..b01908b 100644 --- a/backend/src/main/java/com/yoyuzh/auth/UserRepository.java +++ b/backend/src/main/java/com/yoyuzh/auth/UserRepository.java @@ -13,6 +13,8 @@ public interface UserRepository extends JpaRepository { boolean existsByEmail(String email); + boolean existsByPhoneNumber(String phoneNumber); + Optional findByUsername(String username); long countByLastSchoolStudentIdIsNotNull(); @@ -23,7 +25,8 @@ public interface UserRepository extends JpaRepository { 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 searchByUsernameOrEmail(@Param("query") String query, Pageable pageable); } diff --git a/backend/src/main/java/com/yoyuzh/auth/dto/RegisterRequest.java b/backend/src/main/java/com/yoyuzh/auth/dto/RegisterRequest.java index d319562..1f6c9b0 100644 --- a/backend/src/main/java/com/yoyuzh/auth/dto/RegisterRequest.java +++ b/backend/src/main/java/com/yoyuzh/auth/dto/RegisterRequest.java @@ -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 ) { diff --git a/backend/src/main/java/com/yoyuzh/auth/dto/UpdateUserProfileRequest.java b/backend/src/main/java/com/yoyuzh/auth/dto/UpdateUserProfileRequest.java index 120a6c1..d735bad 100644 --- a/backend/src/main/java/com/yoyuzh/auth/dto/UpdateUserProfileRequest.java +++ b/backend/src/main/java/com/yoyuzh/auth/dto/UpdateUserProfileRequest.java @@ -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 ) { diff --git a/backend/src/main/java/com/yoyuzh/auth/dto/UserProfileResponse.java b/backend/src/main/java/com/yoyuzh/auth/dto/UserProfileResponse.java index ff3279a..d946e61 100644 --- a/backend/src/main/java/com/yoyuzh/auth/dto/UserProfileResponse.java +++ b/backend/src/main/java/com/yoyuzh/auth/dto/UserProfileResponse.java @@ -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); } } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 18b334b..63b62e5 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,4 +1,5 @@ server: + address: 127.0.0.1 port: 8080 spring: diff --git a/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java b/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java index 0f19e01..7804a3e 100644 --- a/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java +++ b/backend/src/test/java/com/yoyuzh/admin/AdminControllerIntegrationTest.java @@ -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(""" diff --git a/backend/src/test/java/com/yoyuzh/auth/AuthControllerValidationTest.java b/backend/src/test/java/com/yoyuzh/auth/AuthControllerValidationTest.java index cb1ca7b..361d5a2 100644 --- a/backend/src/test/java/com/yoyuzh/auth/AuthControllerValidationTest.java +++ b/backend/src/test/java/com/yoyuzh/auth/AuthControllerValidationTest.java @@ -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( diff --git a/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java b/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java index 1317315..8103584 100644 --- a/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java @@ -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"); } diff --git a/backend/src/test/java/com/yoyuzh/auth/RegisterRequestValidationTest.java b/backend/src/test/java/com/yoyuzh/auth/RegisterRequestValidationTest.java index db922d5..3632223 100644 --- a/backend/src/test/java/com/yoyuzh/auth/RegisterRequestValidationTest.java +++ b/backend/src/test/java/com/yoyuzh/auth/RegisterRequestValidationTest.java @@ -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位手机号"); + } } diff --git a/front/src/admin/users-list.tsx b/front/src/admin/users-list.tsx index 6b158eb..7afbd6c 100644 --- a/front/src/admin/users-list.tsx +++ b/front/src/admin/users-list.tsx @@ -151,7 +151,7 @@ export function PortalAdminUsersList() { return ( } - filters={[]} + filters={[]} perPage={25} resource="users" title="用户管理" @@ -161,6 +161,7 @@ export function PortalAdminUsersList() { + label="角色" render={(record) => } diff --git a/front/src/components/layout/Layout.tsx b/front/src/components/layout/Layout.tsx index 5dedfeb..9708a6f 100644 --- a/front/src/components/layout/Layout.tsx +++ b/front/src/components/layout/Layout.tsx @@ -138,6 +138,7 @@ export function Layout() { }, [user]); const email = user?.email || '暂无邮箱'; + const phoneNumber = user?.phoneNumber || '未设置手机号'; const roleLabel = getRoleLabel(user?.role); const avatarFallback = (displayName || 'Y').charAt(0).toUpperCase(); const displayedAvatarUrl = avatarPreviewUrl || avatarSourceUrl; @@ -256,6 +257,7 @@ export function Layout() { body: { displayName: profileDraft.displayName.trim(), email: profileDraft.email.trim(), + phoneNumber: profileDraft.phoneNumber.trim(), bio: profileDraft.bio, preferredLanguage: profileDraft.preferredLanguage, }, @@ -497,11 +499,15 @@ export function Layout() {

手机绑定

-

当前项目暂未实现短信绑定流程

+

当前手机号:{phoneNumber}

- @@ -586,6 +592,16 @@ export function Layout() { /> +
+ + handleProfileDraftChange('phoneNumber', event.target.value)} + className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]" + /> +
+